Files
web-page/src/pages/subscription/components/DistrictTierEditor.jsx
gahusb 1c331f209a fix(subscription): CLAUDE.md districts shape + dragLeave 정확도
preferred_districts 문서 형태를 백엔드 실제 구조(tier-keyed Dict[str, List[str]])로 수정.
onDragLeave가 자식 요소로 커서 이동 시 flicker 발생하던 문제 수정(relatedTarget 체크).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 20:31:24 +09:00

171 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}