물 재이용 대민서비스 수요신청 수요정보등록 후보지조회 팝업창 신규 추가 및 물재이용소개/물재이용현황 이미지교체
@@ -0,0 +1,327 @@
|
||||
"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-[1400px] 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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useMemo, useState, useRef } from "react";
|
||||
import SubPageHeader from "../../_components/SubPageHeader";
|
||||
import WaterQualityRefModal from "../modal/WaterQualityRefModal";
|
||||
import CandidateSiteModal, { type CandidateSite } from "../modal/CandidateSiteModal";
|
||||
|
||||
type PHRange = { min: string; max: string };
|
||||
|
||||
@@ -18,6 +19,9 @@ export default function DemandRegisterPage() {
|
||||
|
||||
const [openRef, setOpenRef] = useState(false);
|
||||
|
||||
const [openCand, setOpenCand] = useState(false);
|
||||
const [selectedCands, setSelectedCands] = useState<CandidateSite[]>([]);
|
||||
|
||||
// 기본정보
|
||||
const [reqDate, setReqDate] = useState<string>(today);
|
||||
const [reqQty, setReqQty] = useState<string>("");
|
||||
@@ -251,10 +255,7 @@ export default function DemandRegisterPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-700"
|
||||
onClick={() => {
|
||||
// TODO: 후보지 조회 모달/검색 연결
|
||||
console.log("후보지 조회", { cand1, cand2, cand3 });
|
||||
}}
|
||||
onClick={() => setOpenCand(true)}
|
||||
>
|
||||
후보지 조회
|
||||
</button>
|
||||
@@ -290,6 +291,22 @@ export default function DemandRegisterPage() {
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<CandidateSiteModal
|
||||
open={openCand}
|
||||
onClose={() => setOpenCand(false)}
|
||||
initialSelected={selectedCands}
|
||||
onApply={(list) => {
|
||||
setSelectedCands(list);
|
||||
|
||||
// ✅ cand1~3 입력칸에 반영(시설명만 넣는 버전)
|
||||
setCand1(list[0]?.name ?? "");
|
||||
setCand2(list[1]?.name ?? "");
|
||||
setCand3(list[2]?.name ?? "");
|
||||
|
||||
setOpenCand(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 435 KiB After Width: | Height: | Size: 526 KiB |
|
Before Width: | Height: | Size: 355 KiB After Width: | Height: | Size: 385 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 77 KiB |