327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
export type CandidateSite = {
|
|
id: string;
|
|
name: string;
|
|
sewageM3d: number; // 하수처리량
|
|
reuseM3d: number; // 재이용량
|
|
// 지도 연동 시 사용(현재는 UI/구조용)
|
|
lat?: number;
|
|
lng?: number;
|
|
};
|
|
|
|
type Props = {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onApply: (selected: CandidateSite[]) => void;
|
|
initialSelected?: CandidateSite[];
|
|
maxSelect?: number; // 기본 3개
|
|
};
|
|
|
|
const MOCK_SITES: CandidateSite[] = [
|
|
{ id: "1", name: "서호생태 수자원 재이용1", sewageM3d: 1300, reuseM3d: 1300, lat: 35.0, lng: 128.0 },
|
|
{ id: "2", name: "서호생태 수자원 재이용2", sewageM3d: 1300, reuseM3d: 1300, lat: 35.01, lng: 128.01 },
|
|
{ id: "3", name: "서호생태 수자원 재이용3", sewageM3d: 1300, reuseM3d: 1300, lat: 35.02, lng: 128.02 },
|
|
];
|
|
|
|
export default function CandidateSiteModal({
|
|
open,
|
|
onClose,
|
|
onApply,
|
|
initialSelected = [],
|
|
maxSelect = 3,
|
|
}: Props) {
|
|
const [q, setQ] = useState("");
|
|
const [selected, setSelected] = useState<CandidateSite[]>(initialSelected);
|
|
|
|
// 지도 마커 컴포넌트
|
|
function MapMarker({ label }: { label?: string }) {
|
|
return (
|
|
<div className="relative -translate-x-1/2 -translate-y-full">
|
|
<svg width="72" height="66" viewBox="0 0 72 92">
|
|
<defs>
|
|
<linearGradient id="pinGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#2EDAA0" />
|
|
<stop offset="100%" stopColor="#02A7D8" />
|
|
</linearGradient>
|
|
|
|
<filter id="pinShadow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feDropShadow dx="0" dy="2" stdDeviation="2" floodColor="#000" floodOpacity="0.18" />
|
|
</filter>
|
|
</defs>
|
|
|
|
{/* pin body */}
|
|
<path
|
|
filter="url(#pinShadow)"
|
|
d="M36 2C21 2 9 14 9 29c0 22 24 50 26.4 52.7a2 2 0 0 0 3.2 0C40.9 79 63 51 63 29 63 14 51 2 36 2z"
|
|
fill="url(#pinGrad)"
|
|
/>
|
|
|
|
{/* 내부 흰 원 */}
|
|
<circle cx="36" cy="29" r="20" fill="#FFFFFF" />
|
|
|
|
{label && (
|
|
<text
|
|
x="36"
|
|
y="36"
|
|
textAnchor="middle"
|
|
fontSize="18"
|
|
fontWeight="700"
|
|
fill="#1F2937"
|
|
>
|
|
{label}
|
|
</text>
|
|
)}
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 모달 열릴 때마다 초기 선택값 동기화
|
|
useEffect(() => {
|
|
if (open) setSelected(initialSelected);
|
|
if (open) setQ("");
|
|
}, [open, initialSelected]);
|
|
|
|
// ESC 닫기
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
}, [open, onClose]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const keyword = q.trim();
|
|
if (!keyword) return MOCK_SITES;
|
|
return MOCK_SITES.filter((s) => s.name.includes(keyword));
|
|
}, [q]);
|
|
|
|
const isSelected = (id: string) => selected.some((s) => s.id === id);
|
|
|
|
const toggleSelect = (item: CandidateSite) => {
|
|
setSelected((prev) => {
|
|
const exists = prev.some((p) => p.id === item.id);
|
|
if (exists) return prev.filter((p) => p.id !== item.id);
|
|
|
|
if (prev.length >= maxSelect) return prev; // 최대 개수 초과 방지
|
|
return [...prev, item];
|
|
});
|
|
};
|
|
|
|
const removeChip = (id: string) => setSelected((prev) => prev.filter((p) => p.id !== id));
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[1000] flex items-center justify-center"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="후보지 조회"
|
|
>
|
|
{/* overlay */}
|
|
<button
|
|
type="button"
|
|
className="absolute inset-0 bg-black/30"
|
|
aria-label="모달 닫기"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* panel */}
|
|
<div className="relative w-[94vw] max-w-[1700px] rounded-xl bg-white shadow-xl">
|
|
{/* header */}
|
|
<div className="flex items-center justify-between px-8 py-6">
|
|
<h2 className="text-2xl font-extrabold text-neutral-900">후보지 조회</h2>
|
|
<button
|
|
type="button"
|
|
aria-label="닫기"
|
|
onClick={onClose}
|
|
className="grid h-10 w-10 place-items-center rounded-sm hover:bg-neutral-100"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div className="h-px w-full bg-neutral-200" />
|
|
|
|
{/* body */}
|
|
<div className="px-8 py-6">
|
|
<div className="grid grid-cols-12 gap-6">
|
|
{/* left: search + table */}
|
|
<div className="col-span-12 lg:col-span-5">
|
|
<div className="mb-3 text-lg font-semibold text-neutral-900">후보지 검색</div>
|
|
|
|
<div className="relative">
|
|
<input
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder="주소검색"
|
|
className="h-12 w-full rounded-sm border border-neutral-300 bg-white px-4 pr-12 text-sm outline-none focus:border-sky-500"
|
|
/>
|
|
<button
|
|
type="button"
|
|
aria-label="검색"
|
|
onClick={() => setQ((v) => v.trim())}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 grid h-9 w-9 place-items-center rounded-full text-[#01ACE5] hover:bg-slate-100"
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
|
<path
|
|
d="M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15Z"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
/>
|
|
<path
|
|
d="M21 21l-4.3-4.3"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* table header */}
|
|
<div className="mt-4 overflow-hidden rounded-sm border border-neutral-200">
|
|
<div className="grid grid-cols-12 bg-emerald-50/70 px-4 py-3 text-sm font-semibold text-neutral-800">
|
|
<div className="col-span-6 flex items-center justify-center">시설명</div>
|
|
<div className="col-span-2 text-center">
|
|
하수처리량<br />(m³/일)
|
|
</div>
|
|
<div className="col-span-2 text-center">
|
|
재이용량<br />(m³/일)
|
|
</div>
|
|
<div className="col-span-2 text-center">선택</div>
|
|
</div>
|
|
|
|
{/* rows */}
|
|
<div className="max-h-[420px] overflow-auto">
|
|
{filtered.length === 0 ? (
|
|
<div className="px-4 py-10 text-center text-sm text-neutral-500">
|
|
검색 결과가 없습니다.
|
|
</div>
|
|
) : (
|
|
filtered.map((s, idx) => {
|
|
const picked = isSelected(s.id);
|
|
const disabled = !picked && selected.length >= maxSelect;
|
|
return (
|
|
<div
|
|
key={s.id}
|
|
className="grid grid-cols-12 items-center gap-2 border-t border-neutral-200 px-4 py-3 text-sm"
|
|
>
|
|
<div className="col-span-6 truncate">
|
|
<span className="mr-2 text-neutral-500">{idx + 1}</span>
|
|
{s.name}
|
|
</div>
|
|
<div className="col-span-2 text-center">{s.sewageM3d.toLocaleString()}</div>
|
|
<div className="col-span-2 text-center">{s.reuseM3d.toLocaleString()}</div>
|
|
<div className="col-span-2 flex justify-center">
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => toggleSelect(s)}
|
|
className={[
|
|
"h-8 rounded-sm px-3 text-xs",
|
|
picked ? "bg-gray-600 text-white" : "bg-sky-500 text-white hover:bg-sky-600",
|
|
disabled ? "cursor-not-allowed opacity-40" : "",
|
|
].join(" ")}
|
|
>
|
|
{picked ? "해제" : "선택"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* <div className="mt-2 text-xs text-neutral-500">
|
|
최대 {maxSelect}개까지 선택 가능합니다.
|
|
</div> */}
|
|
</div>
|
|
|
|
{/* right: map */}
|
|
<div className="col-span-12 lg:col-span-7">
|
|
<div className="h-[520px] w-full overflow-hidden rounded-sm border border-neutral-200 bg-neutral-50 relative">
|
|
{/* TODO: 실제 지도 SDK 연동 시 이 영역 교체 */}
|
|
<div className="absolute inset-0 grid place-items-center text-sm text-neutral-500">
|
|
지도 영역(추후 지도 SDK 연동)
|
|
</div>
|
|
|
|
{/* 선택된 항목의 "가짜 마커" */}
|
|
{selected.map((s, i) => {
|
|
const POS = [
|
|
{ top: "40%", left: "45%" },
|
|
{ top: "25%", left: "75%" },
|
|
{ top: "70%", left: "80%" },
|
|
] as const;
|
|
|
|
const pos = POS[i % POS.length] ?? POS[0];
|
|
|
|
return (
|
|
<div
|
|
key={s.id}
|
|
className="absolute"
|
|
style={{ top: pos.top, left: pos.left }}
|
|
>
|
|
<MapMarker label={String(i + 1)} />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* selected chips */}
|
|
<div className="mt-6">
|
|
<div className="mb-2 text-lg font-semibold text-neutral-900">
|
|
선택된 시설 <span className="text-sm font-normal text-neutral-500">(시설명/하수처리량(m³/일)/재이용량(m³/일))</span>
|
|
</div>
|
|
|
|
<div className="min-h-[52px] rounded-sm border border-neutral-200 bg-white px-4 py-3">
|
|
{selected.length === 0 ? (
|
|
<div className="text-sm text-neutral-500">선택된 시설이 없습니다.</div>
|
|
) : (
|
|
<div className="flex flex-wrap gap-3">
|
|
{selected.map((s) => (
|
|
<div
|
|
key={s.id}
|
|
className="inline-flex items-center gap-3 rounded-sm bg-[#EAF5F5] px-4 py-3 text-sm text-[#01ACE5]"
|
|
>
|
|
<span className="font-medium">
|
|
{s.name} / {s.sewageM3d.toLocaleString()}(m³/일) / {s.reuseM3d.toLocaleString()}(m³/일)
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label="삭제"
|
|
onClick={() => removeChip(s.id)}
|
|
className="grid h-7 w-7 place-items-center rounded-sm hover:bg-sky-100 text-neutral-700 font-semibold"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* footer button */}
|
|
<div className="mt-6 flex justify-center pb-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => onApply(selected)}
|
|
className="h-11 rounded-sm bg-[#0B4EA2] px-10 text-base text-white hover:opacity-90"
|
|
>
|
|
후보지 확인 및 적용
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |