feat(subscription): DistrictTierEditor — 자치구 5티어 드래그앤드롭
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
167
src/pages/subscription/components/DistrictTierEditor.jsx
Normal file
167
src/pages/subscription/components/DistrictTierEditor.jsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const SEOUL_DISTRICTS = [
|
||||
"강남구","강동구","강북구","강서구","관악구",
|
||||
"광진구","구로구","금천구","노원구","도봉구",
|
||||
"동대문구","동작구","마포구","서대문구","서초구",
|
||||
"성동구","성북구","송파구","양천구","영등포구",
|
||||
"용산구","은평구","종로구","중구","중랑구",
|
||||
];
|
||||
|
||||
const TIERS = [
|
||||
{ key: "S", label: "S", weight: "100%" },
|
||||
{ key: "A", label: "A", weight: "80%" },
|
||||
{ key: "B", label: "B", weight: "60%" },
|
||||
{ key: "C", label: "C", weight: "40%" },
|
||||
{ key: "D", label: "D", weight: "20%" },
|
||||
];
|
||||
|
||||
const EMPTY_TIERS = { S: [], A: [], B: [], C: [], D: [] };
|
||||
|
||||
function useIsDesktop() {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mq = window.matchMedia("(min-width: 768px)");
|
||||
const handler = (e) => setIsDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
return isDesktop;
|
||||
}
|
||||
|
||||
export default function DistrictTierEditor({ value, onChange }) {
|
||||
const isDesktop = useIsDesktop();
|
||||
const [dragOver, setDragOver] = useState(null); // 현재 hover 중인 zone key
|
||||
|
||||
const current = value && Object.keys(value).length > 0 ? value : EMPTY_TIERS;
|
||||
|
||||
const unassigned = SEOUL_DISTRICTS.filter(
|
||||
d => !TIERS.some(t => (current[t.key] || []).includes(d))
|
||||
);
|
||||
|
||||
const moveDistrict = (district, targetTier /* null = 미할당 */) => {
|
||||
const next = { S: [], A: [], B: [], C: [], D: [] };
|
||||
for (const t of Object.keys(next)) {
|
||||
next[t] = (current[t] || []).filter(d => d !== district);
|
||||
}
|
||||
if (targetTier) {
|
||||
next[targetTier] = [...next[targetTier], district];
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const onDragStart = (e, district) => {
|
||||
e.dataTransfer.setData("text/district", district);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
const onDragOver = (e, key) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (dragOver !== key) setDragOver(key);
|
||||
};
|
||||
const onDragLeave = () => setDragOver(null);
|
||||
const onDrop = (e, targetTier /* null = 미할당 */) => {
|
||||
e.preventDefault();
|
||||
const district = e.dataTransfer.getData("text/district");
|
||||
setDragOver(null);
|
||||
if (district) moveDistrict(district, targetTier);
|
||||
};
|
||||
|
||||
if (!isDesktop) {
|
||||
return (
|
||||
<div className="sub-panel">
|
||||
<div className="sub-panel__head">
|
||||
<p className="sub-panel__eyebrow">자치구 우선순위</p>
|
||||
<h3>지역 5티어</h3>
|
||||
</div>
|
||||
<div className="sub-panel__body" style={{ display: "grid", gap: 10 }}>
|
||||
{TIERS.map(t => (
|
||||
<div key={t.key} className="dte-row dte-row--readonly">
|
||||
<span className={`sub-chip sub-chip--tier sub-chip--tier-${t.key}`}>
|
||||
{t.label} {t.weight}
|
||||
</span>
|
||||
<span className="dte-row__list">
|
||||
{(current[t.key] || []).length === 0
|
||||
? <span className="dte-empty">(없음)</span>
|
||||
: (current[t.key] || []).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="dte-mobile-hint">✏️ 자치구 분류는 PC에서 편집할 수 있어요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sub-panel">
|
||||
<div className="sub-panel__head">
|
||||
<p className="sub-panel__eyebrow">자치구 우선순위</p>
|
||||
<h3>지역 5티어 (드래그해서 분류)</h3>
|
||||
</div>
|
||||
<div className="sub-panel__body" style={{ display: "grid", gap: 12 }}>
|
||||
{/* 미할당 풀 */}
|
||||
<div
|
||||
className={`dte-pool ${dragOver === "_unassigned" ? "dte-pool--over" : ""}`}
|
||||
onDragOver={(e) => onDragOver(e, "_unassigned")}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, null)}
|
||||
>
|
||||
<p className="dte-pool__title">미할당 ({unassigned.length})</p>
|
||||
<div className="dte-chips">
|
||||
{unassigned.map(d => (
|
||||
<span
|
||||
key={d}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, d)}
|
||||
className="sub-chip sub-chip--district dte-chip"
|
||||
>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5티어 그리드 */}
|
||||
<div className="dte-grid">
|
||||
{TIERS.map(t => (
|
||||
<div
|
||||
key={t.key}
|
||||
className={`dte-zone ${dragOver === t.key ? "dte-zone--over" : ""}`}
|
||||
onDragOver={(e) => onDragOver(e, t.key)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, t.key)}
|
||||
>
|
||||
<div className={`dte-zone__head sub-chip--tier-${t.key}`}>
|
||||
{t.label} <span className="dte-zone__weight">{t.weight}</span>
|
||||
</div>
|
||||
<div className="dte-zone__chips">
|
||||
{(current[t.key] || []).map(d => (
|
||||
<span
|
||||
key={d}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, d)}
|
||||
className="sub-chip sub-chip--district dte-chip"
|
||||
>
|
||||
{d}
|
||||
<button
|
||||
type="button"
|
||||
className="dte-chip__remove"
|
||||
onClick={() => moveDistrict(d, null)}
|
||||
aria-label={`${d} 미할당으로`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user