preferred_districts 문서 형태를 백엔드 실제 구조(tier-keyed Dict[str, List[str]])로 수정. onDragLeave가 자식 요소로 커서 이동 시 flicker 발생하던 문제 수정(relatedTarget 체크). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
171 lines
7.0 KiB
JavaScript
171 lines
7.0 KiB
JavaScript
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 = (e) => {
|
||
if (e.currentTarget.contains(e.relatedTarget)) return;
|
||
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>
|
||
);
|
||
}
|