물 재이용 대민서비스 수요신청 > 수요정보등록 > 후보지조회 / 유사사례조회 모달팝업 수정 및 신규 추가

This commit is contained in:
LGS
2026-03-05 09:19:52 +09:00
parent 3772cd6edd
commit cb442bfc13
3 changed files with 1100 additions and 272 deletions
@@ -133,7 +133,7 @@ export default function CandidateSiteModal({
/>
{/* panel */}
<div className="relative w-[94vw] max-w-[1400px] rounded-xl bg-white shadow-xl">
<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>
@@ -0,0 +1,606 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
type Criterion = "" | "supplyAmt" | "treatment" | "usePurpose";
export type SimilarCaseRow = {
caseId: string;
projectName: string;
period: string;
supplyFacilityType?: string;
supplyName?: string;
supplyCapacity?: number;
demandName?: string;
demandAddress?: string;
facilityCapacity?: number;
pipeKm?: number;
purpose?: string;
// 유사기준용
supplyAmt?: number; // 공급량(m3/일)
treatment?: string; // 처리공법
usePurpose?: string; // 이용용도
};
export type SimilarCaseDetail = {
// 보증수질
coliform?: string;
residualCl?: string;
turbidity?: string;
bod?: string;
odor?: string;
color?: string;
tn?: string;
tp?: string;
ph?: string;
chloride?: string;
ec?: string;
note?: string;
// 소요사업비(백만원) - 현재 타입에 있는 항목만 매핑
costTotal?: number;
costFacility?: number;
costPipe?: number;
costDesign?: number;
costSupervision?: number;
costOther?: number;
// 재원조달계획(백만원)
fundingTotal?: number;
fundingNational?: number;
fundingLocal?: number;
fundingSelf?: number;
fundingRate?: number; // %
};
function formatNumber(v: any) {
if (v === null || v === undefined || v === "" || Number.isNaN(Number(v)))
return "-";
const n = typeof v === "number" ? v : Number(String(v).replaceAll(",", ""));
if (!Number.isFinite(n)) return "-";
return n.toLocaleString("ko-KR");
}
export default function SimilarCaseModal({
open,
onClose,
rows,
detailsByCaseId,
}: {
open: boolean;
onClose: () => void;
rows: SimilarCaseRow[];
detailsByCaseId: Record<string, SimilarCaseDetail | undefined>;
}) {
// open 타입 방어
if (typeof open !== "boolean") {
console.error("SimilarCaseModal: open prop is not boolean:", open);
return null;
}
const [mounted, setMounted] = useState(false);
const [criterion, setCriterion] = useState<Criterion>("");
const [selectedCaseId, setSelectedCaseId] = useState<string>("");
useEffect(() => setMounted(true), []);
// 모달 열릴 때 초기화
useEffect(() => {
if (!open) return;
setCriterion("");
setSelectedCaseId("");
}, [open]);
// ESC 닫기 + 배경 스크롤 잠금
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose]);
const groupInfo = useMemo(() => {
const counts = new Map<string, number>();
rows.forEach((r) => counts.set(r.caseId, (counts.get(r.caseId) ?? 0) + 1));
const firstIndex = new Map<string, number>();
rows.forEach((r, idx) => {
if (!firstIndex.has(r.caseId)) firstIndex.set(r.caseId, idx);
});
return { counts, firstIndex };
}, [rows]);
const selectedDetail = selectedCaseId ? detailsByCaseId[selectedCaseId] : undefined;
if (!open || !mounted) return null;
const tdBase =
"border border-[#C9C9C9] px-2 py-2 text-sm text-neutral-800 align-middle";
const thBase =
"border border-[#C9C9C9] bg-[#E7F4E2] px-2 py-3 text-sm font-semibold text-neutral-800 text-center align-middle";
const highlightTd = "bg-neutral-200";
const showList = criterion !== "";
const hasRows = rows.length > 0;
const ui = (
<div className="fixed inset-0 z-[1000] flex items-center justify-center">
{/* 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="mb-4 flex items-center justify-between">
<div className="text-lg font-semibold text-neutral-900">
물 재이용 사업 유사 사례
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-neutral-800">
유사기준
</span>
<select
value={criterion}
onChange={(e) => {
const v = e.target.value as Criterion;
setCriterion(v);
setSelectedCaseId("");
}}
className="h-10 w-[180px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none focus:border-neutral-500"
>
<option value="">유사기준선택</option>
<option value="supplyAmt">공급량</option>
<option value="treatment">처리공법</option>
<option value="usePurpose">이용용도</option>
</select>
</div>
</div>
{/* 리스트 영역 */}
<div className="border border-[#C9C9C9] bg-white">
{!showList ? (
<div className="grid min-h-[260px] place-items-center bg-emerald-50/30">
<div className="text-neutral-500">
유사기준을 선택해주세요.
</div>
</div>
) : !hasRows ? (
<div className="grid min-h-[260px] place-items-center bg-emerald-50/30">
<div className="text-neutral-500">조회 결과가 없습니다.</div>
</div>
) : (
<div className="max-h-[300px] overflow-auto">
<table className="w-full border-collapse">
<thead className="sticky top-0 z-10">
<tr>
<th rowSpan={2} className={thBase}>
선택
</th>
<th rowSpan={2} className={thBase}>
사업명
</th>
<th rowSpan={2} className={thBase}>
사업기간
</th>
<th colSpan={3} className={thBase}>
공급처
</th>
<th colSpan={2} className={thBase}>
수요처
</th>
<th colSpan={6} className={thBase}>
재이용시설
</th>
</tr>
<tr>
<th className={thBase}>시설종류</th>
<th className={thBase}>공급처명</th>
<th className={thBase}>
시설용량
<br />
(m³/일)
</th>
<th className={thBase}>수요처명</th>
<th className={thBase}>주소</th>
<th className={thBase}>
시설용량
<br />
(m³/일)
</th>
<th className={thBase}>
관로
<br />
(km)
</th>
<th className={thBase}>용도</th>
<th className={thBase}>
공급량
<br />
(m³/일)
</th>
<th className={thBase}>처리공법</th>
<th className={thBase}>이용용도</th>
</tr>
</thead>
<tbody>
{rows.map((r, idx) => {
const isFirst = groupInfo.firstIndex.get(r.caseId) === idx;
const rowSpan = groupInfo.counts.get(r.caseId) ?? 1;
const selected = selectedCaseId === r.caseId;
const supplyAmtCls = criterion === "supplyAmt" ? highlightTd : "";
const treatmentCls = criterion === "treatment" ? highlightTd : "";
const usePurposeCls = criterion === "usePurpose" ? highlightTd : "";
return (
<tr
key={`${r.caseId}-${idx}`}
className={[
"cursor-pointer hover:bg-neutral-50",
selected ? "bg-sky-50" : "",
].join(" ")}
onClick={() => setSelectedCaseId(r.caseId)}
>
{isFirst ? (
<>
<td className={`${tdBase} text-center`} rowSpan={rowSpan}>
<input
type="checkbox"
checked={selected}
readOnly
className="h-4 w-4"
/>
</td>
<td className={`${tdBase} text-center`} rowSpan={rowSpan}>
{r.projectName ?? "-"}
</td>
<td className={`${tdBase} text-center`} rowSpan={rowSpan}>
{r.period ?? "-"}
</td>
</>
) : null}
<td className={`${tdBase} text-center`}>
{r.supplyFacilityType ?? "-"}
</td>
<td className={`${tdBase} text-center`}>{r.supplyName ?? "-"}</td>
<td className={`${tdBase} text-center`}>
{formatNumber(r.supplyCapacity)}
</td>
<td className={`${tdBase} text-center`}>{r.demandName ?? "-"}</td>
<td className={`${tdBase} text-center`}>{r.demandAddress ?? "-"}</td>
<td className={`${tdBase} text-center`}>
{formatNumber(r.facilityCapacity)}
</td>
<td className={`${tdBase} text-center`}>
{formatNumber(r.pipeKm)}
</td>
<td className={tdBase}>{r.purpose ?? "-"}</td>
<td className={`${tdBase} text-center ${supplyAmtCls}`}>
{formatNumber(r.supplyAmt)}
</td>
<td className={`${tdBase} text-center ${treatmentCls}`}>
{r.treatment ?? "-"}
</td>
<td className={`${tdBase} text-center ${usePurposeCls}`}>
{r.usePurpose ?? "-"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* 상세정보 */}
<div className="mt-6">
<div className="mb-2 text-lg font-semibold text-neutral-900">
상세정보
</div>
{/* 1) 보증수질 */}
<div className="overflow-hidden border border-[#C9C9C9]">
<table className="w-full border-collapse">
<thead>
<tr>
<th colSpan={13} className={thBase}>
보증수질
</th>
</tr>
<tr>
<th rowSpan={2} className={thBase}>
총대장균군수<br />(개/100ml)
</th>
<th rowSpan={2} className={thBase}>
용도별 결합 잔류염소<br />(mg/L)
</th>
<th rowSpan={2} className={thBase}>
탁도<br />(NTU)
</th>
</tr>
<tr>
<th className={thBase}>
생물학적<br />산소요구량(BOD)(mg/L)
</th>
<th className={thBase}>냄새</th>
<th className={thBase}>색도(도)</th>
<th className={thBase}>
총질소<br />(T-N)(mg/L)
</th>
<th className={thBase}>
총인<br />(T-P)(mg/L)
</th>
<th className={thBase}>
수소이온<br />농도(pH)
</th>
<th className={thBase}>
염화물<br />(mgCL/L)
</th>
<th className={thBase}>
전기전도도<br />(μs/cm)
</th>
<th className={thBase}>
비고
</th>
</tr>
</thead>
<tbody>
<tr>
<td className={`${tdBase} text-center`}>
{selectedDetail?.coliform ?? "-"}
</td>
<td className={`${tdBase} text-center`}>
{selectedDetail?.residualCl ?? "-"}
</td>
<td className={`${tdBase} text-center`}>
{selectedDetail?.turbidity ?? "-"}
</td>
<td className={`${tdBase} text-center`}>
{selectedDetail?.bod ?? "-"}
</td>
<td className={`${tdBase} text-center`}>
{selectedDetail?.odor ?? "-"}
</td>
<td className={`${tdBase} text-center`}>
{selectedDetail?.color ?? "-"}
</td>
<td className={`${tdBase} text-center`}>
{selectedDetail?.tn ?? "-"}
</td>
<td className={`${tdBase} text-center`}>
{selectedDetail?.tp ?? "-"}
</td>
<td className={`${tdBase} text-center`}>
{selectedDetail?.ph ?? "-"}
</td>
<td className={`${tdBase} text-center`}>
{selectedDetail?.chloride ?? "-"}
</td>
<td className={`${tdBase} text-center`}>
{selectedDetail?.ec ?? "-"}
</td>
<td className={`${tdBase} text-center`}>
{selectedDetail?.note ?? "-"}
</td>
</tr>
</tbody>
</table>
</div>
{/* 2) 사업비 */}
<div className="mt-4 overflow-hidden border border-[#C9C9C9]">
<table className="w-full border-collapse">
<thead>
<tr>
<th colSpan={14} className={thBase}>
소요사업비(백만원)
</th>
<th colSpan={5} className={thBase}>
재원조달계획(백만원)
</th>
<th colSpan={5} className={thBase}>
사업비 정산(백만원)
</th>
</tr>
<tr>
{/* 소요사업비 */}
<th rowSpan={2} className={thBase}>총</th>
<th colSpan={2} className={thBase}>토목</th>
<th rowSpan={2} className={thBase}>건축</th>
<th rowSpan={2} className={thBase}>기계</th>
<th rowSpan={2} className={thBase}>전기<br />·계측</th>
<th rowSpan={2} className={thBase}>GIS<br />DB</th>
<th rowSpan={2} className={thBase}>CCTV</th>
<th rowSpan={2} className={thBase}>시공 및 준공</th>
<th rowSpan={2} className={thBase}>폐기물</th>
<th rowSpan={2} className={thBase}>설계비</th>
<th rowSpan={2} className={thBase}>감리비</th>
<th rowSpan={2} className={thBase}>시설부대</th>
<th rowSpan={2} className={thBase}>기타</th>
{/* 재원조달 */}
<th rowSpan={2} className={thBase}>총</th>
<th rowSpan={2} className={thBase}>국비</th>
<th rowSpan={2} className={thBase}>지방비</th>
<th rowSpan={2} className={thBase}>원인자부담</th>
<th rowSpan={2} className={thBase}>국고비율</th>
{/* 정산 */}
<th rowSpan={2} className={thBase}>총</th>
<th rowSpan={2} className={thBase}>국비</th>
<th rowSpan={2} className={thBase}>지방비</th>
<th rowSpan={2} className={thBase}>원인자부담</th>
<th rowSpan={2} className={thBase}>국고비율</th>
</tr>
<tr>
<th className={thBase}>재이용<br />시설</th>
<th className={thBase}>관로</th>
</tr>
</thead>
<tbody>
<tr>
{/* 소요사업비 */}
<td className={`${tdBase} text-right`}>
{selectedDetail?.costTotal != null
? formatNumber(selectedDetail.costTotal)
: "-"}
</td>
<td className={`${tdBase} text-right`}>
{selectedDetail?.costFacility != null
? formatNumber(selectedDetail.costFacility)
: "-"}
</td>
<td className={`${tdBase} text-right`}>
{selectedDetail?.costPipe != null
? formatNumber(selectedDetail.costPipe)
: "-"}
</td>
<td className={`${tdBase} text-center`}>-</td>
<td className={`${tdBase} text-center`}>-</td>
<td className={`${tdBase} text-center`}>-</td>
<td className={`${tdBase} text-center`}>-</td>
<td className={`${tdBase} text-center`}>-</td>
<td className={`${tdBase} text-center`}>-</td>
<td className={`${tdBase} text-center`}>-</td>
<td className={`${tdBase} text-right`}>
{selectedDetail?.costDesign != null
? formatNumber(selectedDetail.costDesign)
: "-"}
</td>
<td className={`${tdBase} text-right`}>
{selectedDetail?.costSupervision != null
? formatNumber(selectedDetail.costSupervision)
: "-"}
</td>
<td className={`${tdBase} text-center`}>-</td>
<td className={`${tdBase} text-right`}>
{selectedDetail?.costOther != null
? formatNumber(selectedDetail.costOther)
: "-"}
</td>
{/* 재원조달 */}
<td className={`${tdBase} text-right`}>
{selectedDetail?.fundingTotal != null
? formatNumber(selectedDetail.fundingTotal)
: "-"}
</td>
<td className={`${tdBase} text-right`}>
{selectedDetail?.fundingNational != null
? formatNumber(selectedDetail.fundingNational)
: "-"}
</td>
<td className={`${tdBase} text-right`}>
{selectedDetail?.fundingLocal != null
? formatNumber(selectedDetail.fundingLocal)
: "-"}
</td>
<td className={`${tdBase} text-right`}>
{selectedDetail?.fundingSelf != null
? formatNumber(selectedDetail.fundingSelf)
: "-"}
</td>
<td className={`${tdBase} text-center`}>
{typeof selectedDetail?.fundingRate === "number"
? `${selectedDetail.fundingRate}%`
: "-"}
</td>
{/* 정산(필드 없으면 동일 값 표시) */}
<td className={`${tdBase} text-right`}>
{selectedDetail?.fundingTotal != null
? formatNumber(selectedDetail.fundingTotal)
: "-"}
</td>
<td className={`${tdBase} text-right`}>
{selectedDetail?.fundingNational != null
? formatNumber(selectedDetail.fundingNational)
: "-"}
</td>
<td className={`${tdBase} text-right`}>
{selectedDetail?.fundingLocal != null
? formatNumber(selectedDetail.fundingLocal)
: "-"}
</td>
<td className={`${tdBase} text-right`}>
{selectedDetail?.fundingSelf != null
? formatNumber(selectedDetail.fundingSelf)
: "-"}
</td>
<td className={`${tdBase} text-center`}>
{typeof selectedDetail?.fundingRate === "number"
? `${selectedDetail.fundingRate}%`
: "-"}
</td>
</tr>
</tbody>
</table>
</div>
{!selectedCaseId && showList ? (
<div className="mt-3 text-sm text-neutral-500">
리스트에서 사업을 선택하면 상세정보가 표시됩니다.
</div>
) : null}
</div>
</div>
</div>
</div>
);
return createPortal(ui, document.body);
}
@@ -1,10 +1,11 @@
// apps/dm/app/(landing)/reuse/apply/register/page.tsx
// apps/dm/app/(landing)/reuse/apply/register/page.tsx (수요정보등록 페이지)
"use client";
import { useMemo, useState, useRef } from "react";
import { useMemo, useState, useRef, useEffect } from "react";
import SubPageHeader from "../../_components/SubPageHeader";
import WaterQualityRefModal from "../modal/WaterQualityRefModal";
import CandidateSiteModal, { type CandidateSite } from "../modal/CandidateSiteModal";
import SimilarCaseModal, { type SimilarCaseRow, type SimilarCaseDetail } from "../modal/SimilarCaseModal";
type PHRange = { min: string; max: string };
@@ -22,6 +23,141 @@ export default function DemandRegisterPage() {
const [openCand, setOpenCand] = useState(false);
const [selectedCands, setSelectedCands] = useState<CandidateSite[]>([]);
// 유사사례 모달
const [openSimilar, setOpenSimilar] = useState(false);
// 유사사례 rows (목업) - API 연동 시 여기만 교체
const rows: SimilarCaseRow[] = useMemo(
() => [
{
caseId: "C-001",
projectName: "OO시 물 재이용(빗물, 중수도) 시설 설치사업",
period: "2020.01. ~ 2025.05.",
supplyFacilityType: "중수도",
supplyName: "OO새교복지타운",
supplyCapacity: 0,
demandName: "OO새교복지타운",
demandAddress: "OO시 수청로 192",
facilityCapacity: 50,
pipeKm: 0,
purpose: "청소, 화장실, 조경",
supplyAmt: 50,
treatment: "MBLM",
usePurpose: "청소,화장실",
},
{
caseId: "C-001",
projectName: "OO시 물 재이용(빗물, 중수도) 시설 설치사업",
period: "2020.01. ~ 2025.05.",
supplyFacilityType: "중수도",
supplyName: "OO스포츠센터",
supplyCapacity: 0,
demandName: "OO스포츠센터",
demandAddress: "OO시 경기동로 33",
facilityCapacity: 50,
pipeKm: 0,
purpose: "청소, 화장실, 조경",
supplyAmt: 50,
treatment: "MBLM",
usePurpose: "청소,화장실",
},
{
caseId: "C-002",
projectName: "OO 엄성저수지 하수처리수 재이용사업",
period: "2019.12 ~ 2026.01",
supplyFacilityType: "하수",
supplyName: "OO 3,4단계 공공하수처리시설",
supplyCapacity: 70000,
demandName: "OO천",
demandAddress: "-",
facilityCapacity: 0,
pipeKm: 3,
purpose: "하천 등 유지용수",
supplyAmt: 2300,
treatment: "탈황,총인, 생물산화과,소독",
usePurpose: "하천 등 유지용수",
},
{
caseId: "C-002",
projectName: "OO 엄성저수지 하수처리수 재이용사업",
period: "2019.12 ~ 2026.01",
supplyFacilityType: "하수",
supplyName: "OO 3,4단계 공공하수처리시설",
supplyCapacity: 70000,
demandName: "OO저수지",
demandAddress: "-",
facilityCapacity: 0,
pipeKm: 5,
purpose: "하천 등 유지용수",
supplyAmt: 7700,
treatment: "탈황,총인, 생물산화과,소독",
usePurpose: "하천 등 유지용수",
},
],
[]
);
// 유사사례 상세(목업) - caseId로 매핑
const detailsByCaseId: Record<string, SimilarCaseDetail> = useMemo(
() => ({
"C-001": {
coliform: "불검출",
residualCl: "0.2이상",
turbidity: "1.5이하",
bod: "4.0이하",
odor: "불쾌하지않을것",
color: "10이하",
tn: "-",
tp: "-",
ph: "5.8~8.5",
chloride: "-",
ec: "-",
note: "-",
costTotal: 4305,
costFacility: 3518,
costPipe: undefined,
costDesign: 350,
costSupervision: 417,
costOther: 12,
fundingTotal: 4305,
fundingNational: 3104,
fundingLocal: 1291,
fundingSelf: undefined,
fundingRate: 70.0,
},
"C-002": {
coliform: "불검출",
residualCl: "0.15이상",
turbidity: "2.0이하",
bod: "5.0이하",
odor: "무",
color: "10이하",
tn: "-",
tp: "-",
ph: "6.0~8.5",
chloride: "-",
ec: "-",
note: "-",
costTotal: 9800,
costFacility: 2100,
costPipe: 1800,
costDesign: 300,
costSupervision: 280,
costOther: 1200,
fundingTotal: 9800,
fundingNational: 4900,
fundingLocal: 3000,
fundingSelf: 1900,
fundingRate: 50,
},
}),
[]
);
// 기본정보
const [reqDate, setReqDate] = useState<string>(today);
const [reqQty, setReqQty] = useState<string>("");
@@ -49,264 +185,352 @@ export default function DemandRegisterPage() {
const [cand2, setCand2] = useState("");
const [cand3, setCand3] = useState("");
// 샘플 데이터
const similarRows: SimilarCaseRow[] = [
{
caseId: "A",
projectName: "OO시 물 재이용(빗물, 중수도)\n시설 설치사업",
period: "2020.01.\n~2025.05.",
supplyFacilityType: "중수도",
supplyName: "OO에코복지타운",
supplyCapacity: 50,
demandName: "OO에코복지타운",
demandAddress: "OO시 수성로 192",
facilityCapacity: 50,
pipeKm: 0,
purpose: "청소, 화장실,\n조경",
supplyAmt: 50,
treatment: "MBLM",
usePurpose: "청소,화장실",
},
{
caseId: "A",
projectName: "OO시 물 재이용(빗물, 중수도)\n시설 설치사업",
period: "2020.01.\n~2025.05.",
supplyFacilityType: "중수도",
supplyName: "OO스포츠센터",
supplyCapacity: 50,
demandName: "OO스포츠센터",
demandAddress: "OO시 경기둥로 33",
facilityCapacity: 50,
pipeKm: 0,
purpose: "청소, 화장실,\n조경",
supplyAmt: 50,
treatment: "MBLM",
usePurpose: "청소,화장실",
},
{
caseId: "B",
projectName: "OO 염성저수지 하수처리수\n재이용사업",
period: "2019.12\n~2026.1",
supplyFacilityType: "하수",
supplyName: "OO 3.4단계 공공하수처리시설",
supplyCapacity: 70000,
demandName: "OO천",
demandAddress: "-",
facilityCapacity: 0,
pipeKm: 3,
purpose: "하천 등\n유지용수",
supplyAmt: 2300,
treatment: "탈황,총인,\n생물여과,소독",
usePurpose: "하천 등\n유지용수",
},
{
caseId: "B",
projectName: "OO 염성저수지 하수처리수\n재이용사업",
period: "2019.12\n~2026.1",
supplyFacilityType: "하수",
supplyName: "OO 3.4단계 공공하수처리시설",
supplyCapacity: 70000,
demandName: "OO저수지",
demandAddress: "-",
facilityCapacity: 0,
pipeKm: 5,
purpose: "하천 등\n유지용수",
supplyAmt: 7700,
treatment: "탈황,총인,\n생물여과,소독",
usePurpose: "하천 등\n유지용수",
},
];
const similarDetailsByCaseId: Record<string, SimilarCaseDetail> = {
A: {
coliform: "불검출",
residualCl: "0.2이상",
turbidity: "1.5이하",
bod: "4.0이하",
odor: "불쾌하지않을것",
color: "10이하",
tn: "-",
tp: "-",
ph: "5.8~8.5",
chloride: "-",
ec: "-",
note: "-",
costTotal: 4305,
costFacility: 3518,
costPipe: 0,
costDesign: 350,
costSupervision: 417,
costOther: 12,
fundingTotal: 4305,
fundingNational: 3104,
fundingLocal: 1291,
fundingSelf: 0,
fundingRate: 70.0,
},
B: {
coliform: "-",
residualCl: "-",
turbidity: "-",
bod: "-",
odor: "-",
color: "-",
tn: "-",
tp: "-",
ph: "-",
chloride: "-",
ec: "-",
note: "-",
costTotal: 0,
costFacility: 0,
costPipe: 0,
costDesign: 0,
costSupervision: 0,
costOther: 0,
fundingTotal: 0,
fundingNational: 0,
fundingLocal: 0,
fundingSelf: 0,
fundingRate: 0,
},
};
return (
<>
<main className="bg-white">
{/* 서브 타이틀 + 브레드크럼 */}
<SubPageHeader
title="수요정보등록"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "수요신청", href: "/reuse/apply" },
{ label: "수요정보등록" },
]}
/>
{/* 컨텐츠 영역 */}
<div>
{/* 기본정보 테이블 */}
<div className="border border-neutral-200 bg-white">
<FormRow label="요구시기" required>
<DateInput value={reqDate} onChange={setReqDate} />
</FormRow>
<Divider />
<FormRow label="요구수량(m²/d)" required>
<TextInput value={reqQty} onChange={setReqQty} placeholder="" />
</FormRow>
<Divider />
<FormRow label="사용용도" required>
<TextInput value={usePurpose} onChange={setUsePurpose} />
</FormRow>
<Divider />
<FormRow label="희망단가(원)" required>
<TextInput value={hopePrice} onChange={setHopePrice} />
</FormRow>
</div>
{/* 요구수질 섹션 타이틀 바 */}
<div className="grid grid-cols-12">
<div className="col-span-12 lg:col-span-2 inline-flex justify-center items-center gap-3 px-6 py-6 text-center">
<span className="text-lg font-semibold text-neutral-800">요구수질</span>
<button
type="button"
onClick={() => setOpenRef(true)}
className="rounded border-[2px] border-[#DE3412] px-2 py-3 text-[15px] font-medium text-[#4F4F4F] hover:bg-orange-50"
>
수질기준 참고
</button>
{/* 수질기준참고 모달 */}
<WaterQualityRefModal
open={openRef}
onClose={() => setOpenRef(false)}
imageSrc="/images/landing/water_quality_ref_1.png"
/>
</div>
</div>
{/* 요구수질 테이블 */}
<div className="border border-neutral-200 bg-white">
{/* 1행 */}
<QualityRow
leftLabel="총대장균군수(개/mg)"
leftRequired
leftControl={
<div className="flex items-center gap-4">
<TextInput
value={coliform}
onChange={setColiform}
disabled={noDetect}
className={noDetect ? "bg-neutral-100 text-neutral-400" : ""}
/>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
checked={noDetect}
onChange={(e) => {
setNoDetect(e.target.checked);
if (e.target.checked) setColiform("");
}}
className="h-4 w-4 rounded border-neutral-300"
/>
불검출
</label>
</div>
}
rightLabel="총질소(T-N)(mg/L)"
rightControl={
<InlineSuffixInput value={tn} onChange={setTn} suffix="이하" />
}
/>
<Divider />
{/* 2행 */}
<QualityRow
leftLabel="결합잔류염소(mg/L)"
leftRequired
leftControl={<TextInput value={residualCl} onChange={setResidualCl} />}
rightLabel="총인(T-P)(mg/L)"
rightControl={
<InlineSuffixInput value={tp} onChange={setTp} suffix="이하" />
}
/>
<Divider />
{/* 3행 */}
<QualityRow
leftLabel="탁도(NTU)"
leftRequired
leftControl={
<InlineSuffixInput value={turbidity} onChange={setTurbidity} suffix="이하" />
}
rightLabel="수소이온농도(pH)"
rightControl={
<div className="flex items-center gap-2">
<TextInput
value={ph.min}
onChange={(v) => setPh((p) => ({ ...p, min: v }))}
className="w-32"
/>
<span className="text-neutral-500">~</span>
<TextInput
value={ph.max}
onChange={(v) => setPh((p) => ({ ...p, max: v }))}
className="w-32"
/>
</div>
}
/>
<Divider />
{/* 4행 */}
<QualityRow
leftLabel={
<>
생물화학적산소요구량
<br />
(BOD)(mg/L)
</>
}
leftRequired
leftControl={<InlineSuffixInput value={bod} onChange={setBod} suffix="이하" />}
rightLabel="염화물(mgCL/L)"
rightControl={<InlineSuffixInput value={chloride} onChange={setChloride} suffix="이하" />}
/>
<Divider />
{/* 5행 */}
<QualityRow
leftLabel="냄새"
leftRequired
leftControl={
<select
value={odor}
onChange={(e) => setOdor(e.target.value)}
className="h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none focus:border-neutral-500"
>
<option>불쾌하지 않을 것</option>
<option>약간 있음</option>
<option>있음</option>
</select>
}
rightLabel="전기전도도(µs/cm)"
rightControl={<InlineSuffixInput value={ec} onChange={setEc} suffix="이하" />}
/>
<Divider />
{/* 6행 */}
<QualityRow
leftLabel="색도(도)"
leftRequired
leftControl={<InlineSuffixInput value={color} onChange={setColor} suffix="이하" />}
rightLabel="비고"
rightControl={
<textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
className="h-11 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-neutral-500"
/>
}
/>
<Divider />
{/* 후보지 선택 */}
<div className="grid grid-cols-12">
<LabelCell>
공급처 후보지 선택
<br />
(필수아님)
</LabelCell>
<div className="col-span-12 md:col-span-10 p-6">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:gap-4">
<TextInput value={cand1} onChange={setCand1} className="max-w-[220px]" />
<TextInput value={cand2} onChange={setCand2} className="max-w-[220px]" />
<TextInput value={cand3} onChange={setCand3} className="max-w-[220px]" />
<div className="flex items-center gap-3 xl:ml-2">
<button
type="button"
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-700"
onClick={() => setOpenCand(true)}
>
후보지 조회
</button>
<button
type="button"
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-600"
onClick={() => {
// TODO: 유사사례 연결
console.log("유사사례");
}}
>
유사사례
</button>
</div>
</div>
</div>
</div>
</div>
{/* 하단 버튼 */}
<div className="flex justify-center py-12">
<button
type="button"
className="h-11 rounded-md bg-[#0B4EA2] px-10 text-lg text-white hover:opacity-90"
onClick={() => {
// TODO: 저장 처리
console.log("수요정보 등록");
}}
>
수요정보 등록
</button>
</div>
</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);
}}
<main className="bg-white">
<SubPageHeader
title="수요정보등록"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "수요신청", href: "/reuse/apply" },
{ label: "수요정보등록" },
]}
/>
<div>
<div className="border border-neutral-200 bg-white">
<FormRow label="요구시기" required>
<DateInput value={reqDate} onChange={setReqDate} />
</FormRow>
<Divider />
<FormRow label="요구수량(m²/d)" required>
<TextInput value={reqQty} onChange={setReqQty} />
</FormRow>
<Divider />
<FormRow label="사용용도" required>
<TextInput value={usePurpose} onChange={setUsePurpose} />
</FormRow>
<Divider />
<FormRow label="희망단가(원)" required>
<TextInput value={hopePrice} onChange={setHopePrice} />
</FormRow>
</div>
<div className="grid grid-cols-12">
<div className="col-span-12 lg:col-span-2 inline-flex items-center justify-center gap-3 px-6 py-6 text-center">
<span className="text-lg font-semibold text-neutral-800">요구수질</span>
<button
type="button"
onClick={() => setOpenRef(true)}
className="rounded border-[2px] border-[#DE3412] px-2 py-3 text-[15px] font-medium text-[#4F4F4F] hover:bg-orange-50"
>
수질기준 참고
</button>
<WaterQualityRefModal
open={openRef}
onClose={() => setOpenRef(false)}
imageSrc="/images/landing/water_quality_ref_1.png"
/>
</div>
</div>
<div className="border border-neutral-200 bg-white">
<QualityRow
leftLabel="총대장균군수(개/mg)"
leftRequired
leftControl={
<div className="flex items-center gap-4">
<TextInput
value={coliform}
onChange={setColiform}
disabled={noDetect}
className={noDetect ? "bg-neutral-100 text-neutral-400" : ""}
/>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
checked={noDetect}
onChange={(e) => {
setNoDetect(e.target.checked);
if (e.target.checked) setColiform("");
}}
className="h-4 w-4 rounded border-neutral-300"
/>
불검출
</label>
</div>
}
rightLabel="총질소(T-N)(mg/L)"
rightControl={<InlineSuffixInput value={tn} onChange={setTn} suffix="이하" />}
/>
<Divider />
<QualityRow
leftLabel="결합잔류염소(mg/L)"
leftRequired
leftControl={<TextInput value={residualCl} onChange={setResidualCl} />}
rightLabel="총인(T-P)(mg/L)"
rightControl={<InlineSuffixInput value={tp} onChange={setTp} suffix="이하" />}
/>
<Divider />
<QualityRow
leftLabel="탁도(NTU)"
leftRequired
leftControl={<InlineSuffixInput value={turbidity} onChange={setTurbidity} suffix="이하" />}
rightLabel="수소이온농도(pH)"
rightControl={
<div className="flex items-center gap-2">
<TextInput value={ph.min} onChange={(v) => setPh((p) => ({ ...p, min: v }))} className="w-32" />
<span className="text-neutral-500">~</span>
<TextInput value={ph.max} onChange={(v) => setPh((p) => ({ ...p, max: v }))} className="w-32" />
</div>
}
/>
<Divider />
<QualityRow
leftLabel={
<>
생물화학적산소요구량
<br />
(BOD)(mg/L)
</>
}
leftRequired
leftControl={<InlineSuffixInput value={bod} onChange={setBod} suffix="이하" />}
rightLabel="염화물(mgCL/L)"
rightControl={<InlineSuffixInput value={chloride} onChange={setChloride} suffix="이하" />}
/>
<Divider />
<QualityRow
leftLabel="냄새"
leftRequired
leftControl={
<select
value={odor}
onChange={(e) => setOdor(e.target.value)}
className="h-11 w-full max-w-[200px] rounded-md border border-neutral-300 bg-white px-3 text-sm outline-none focus:border-neutral-500"
>
<option>불쾌하지 않을 것</option>
<option>약간 있음</option>
<option>있음</option>
</select>
}
rightLabel="전기전도도(µs/cm)"
rightControl={<InlineSuffixInput value={ec} onChange={setEc} suffix="이하" />}
/>
<Divider />
<QualityRow
leftLabel="색도(도)"
leftRequired
leftControl={<InlineSuffixInput value={color} onChange={setColor} suffix="이하" />}
rightLabel="비고"
rightControl={
<textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
className="h-11 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm outline-none focus:border-neutral-500"
/>
}
/>
<Divider />
<div className="grid grid-cols-12">
<LabelCell>
공급처 후보지 선택
<br />
(필수아님)
</LabelCell>
<div className="col-span-12 md:col-span-10 p-6">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:gap-4">
<TextInput value={cand1} onChange={setCand1} className="max-w-[220px]" />
<TextInput value={cand2} onChange={setCand2} className="max-w-[220px]" />
<TextInput value={cand3} onChange={setCand3} className="max-w-[220px]" />
<div className="flex items-center gap-3 xl:ml-2">
<button
type="button"
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-700"
onClick={() => setOpenCand(true)}
>
후보지 조회
</button>
<button
type="button"
className="h-11 rounded-md bg-[#888] px-5 text-base text-white hover:bg-neutral-700"
onClick={() => setOpenSimilar(true)}
>
유사사례 조회
</button>
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-center py-12">
<button
type="button"
className="h-11 rounded-md bg-[#0B4EA2] px-10 text-lg text-white hover:opacity-90"
onClick={() => console.log("수요정보 등록")}
>
수요정보 등록
</button>
</div>
</div>
</main>
{/* 후보지 조회 모달 */}
<CandidateSiteModal
open={openCand}
onClose={() => setOpenCand(false)}
initialSelected={selectedCands}
onApply={(list) => {
setSelectedCands(list);
setCand1(list[0]?.name ?? "");
setCand2(list[1]?.name ?? "");
setCand3(list[2]?.name ?? "");
setOpenCand(false);
}}
/>
{/* 유사사례 모달 */}
<SimilarCaseModal
open={openSimilar}
onClose={() => setOpenSimilar(false)}
rows={rows}
detailsByCaseId={detailsByCaseId}
/>
</>
);
}
@@ -325,7 +549,7 @@ function LabelCell({
required?: boolean;
}) {
return (
<div className="col-span-12 md:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<div className="col-span-12 md:col-span-2 flex items-center justify-center bg-emerald-50/70 px-6 py-6 text-center text-lg font-semibold text-neutral-800">
<span className="inline-flex items-center gap-1">
{children}
{required ? <span className="text-red-500">*</span> : null}
@@ -366,23 +590,19 @@ function QualityRow({
}) {
return (
<div className="grid grid-cols-12">
{/* 좌측 라벨 */}
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<div className="col-span-12 lg:col-span-2 flex items-center justify-center bg-emerald-50/70 px-6 py-6 text-center text-lg font-semibold text-neutral-800">
<span className="inline-flex items-center gap-1">
<span className="leading-6">{leftLabel}</span>
{leftRequired ? <span className="text-red-500">*</span> : null}
</span>
</div>
{/* 좌측 입력 */}
<div className="col-span-12 lg:col-span-4 p-6">{leftControl}</div>
{/* 우측 라벨 */}
<div className="col-span-12 lg:col-span-2 flex items-center justify-center text-center bg-emerald-50/70 px-6 py-6 text-lg font-semibold text-neutral-800">
<div className="col-span-12 lg:col-span-2 flex items-center justify-center bg-emerald-50/70 px-6 py-6 text-center text-lg font-semibold text-neutral-800">
<span className="leading-6">{rightLabel}</span>
</div>
{/* 우측 입력 */}
<div className="col-span-12 lg:col-span-4 p-6">{rightControl}</div>
</div>
);
@@ -441,23 +661,21 @@ function DateInput({
value: string;
onChange: (v: string) => void;
}) {
const ref = useRef<HTMLInputElement | null>(null);
const openPicker = () => {
const el = ref.current;
if (!el) return;
// Chrome/Edge 등 지원
if (typeof (el as any).showPicker === "function") {
(el as any).showPicker();
return;
}
// fallback (일부 브라우저)
el.focus();
el.click();
};
return (
<div className="relative w-full max-w-[200px]">
<input
@@ -467,7 +685,6 @@ function DateInput({
onChange={(e) => onChange(e.target.value)}
className={[
"h-11 w-full rounded-md border border-neutral-300 bg-white px-3 pr-10 text-sm outline-none focus:border-neutral-500",
// ✅ 기본 달력 아이콘 숨김 (Tailwind arbitrary selector)
"[&::-webkit-calendar-picker-indicator]:opacity-0",
"[&::-webkit-calendar-picker-indicator]:absolute",
"[&::-webkit-calendar-picker-indicator]:right-0",
@@ -477,7 +694,6 @@ function DateInput({
].join(" ")}
/>
{/* ✅ 클릭 가능한 아이콘 버튼 */}
<button
type="button"
onClick={openPicker}
@@ -485,9 +701,15 @@ function DateInput({
aria-label="달력 열기"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M7.14286 7.99104H16.7812M6.48512 2.5625V4.19126M17.3125 2.5625V4.19106M20.5 7.19106L20.5 18.5625C20.5 20.2194 19.1569 21.5625 17.5 21.5625H6.5C4.84315 21.5625 3.5 20.2194 3.5 18.5625V7.19106C3.5 5.53421 4.84315 4.19106 6.5 4.19106H17.5C19.1569 4.19106 20.5 5.53421 20.5 7.19106Z" stroke="#1A1A1A" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<path
d="M7.14286 7.99104H16.7812M6.48512 2.5625V4.19126M17.3125 2.5625V4.19106M20.5 7.19106L20.5 18.5625C20.5 20.2194 19.1569 21.5625 17.5 21.5625H6.5C4.84315 21.5625 3.5 20.2194 3.5 18.5625V7.19106C3.5 5.53421 4.84315 4.19106 6.5 4.19106H17.5C19.1569 4.19106 20.5 5.53421 20.5 7.19106Z"
stroke="#1A1A1A"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
);
}
}