From cb442bfc136a2811edf44833bba03f9566a75075 Mon Sep 17 00:00:00 2001 From: LGS Date: Thu, 5 Mar 2026 09:19:52 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AC=BC=20=EC=9E=AC=EC=9D=B4=EC=9A=A9=20?= =?UTF-8?q?=EB=8C=80=EB=AF=BC=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=88=98?= =?UTF-8?q?=EC=9A=94=EC=8B=A0=EC=B2=AD=20>=20=EC=88=98=EC=9A=94=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EB=93=B1=EB=A1=9D=20>=20=ED=9B=84=EB=B3=B4=EC=A7=80?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20/=20=EC=9C=A0=EC=82=AC=EC=82=AC=EB=A1=80?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=AA=A8=EB=8B=AC=ED=8C=9D=EC=97=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=8B=A0=EA=B7=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reuse/apply/modal/CandidateSiteModal.tsx | 2 +- .../reuse/apply/modal/SimilarCaseModal.tsx | 606 ++++++++++++++ .../(landing)/reuse/apply/register/page.tsx | 764 +++++++++++------- 3 files changed, 1100 insertions(+), 272 deletions(-) create mode 100644 apps/dm_new/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx diff --git a/apps/dm_new/app/(landing)/reuse/apply/modal/CandidateSiteModal.tsx b/apps/dm_new/app/(landing)/reuse/apply/modal/CandidateSiteModal.tsx index aa5a9c0ee..75253392a 100644 --- a/apps/dm_new/app/(landing)/reuse/apply/modal/CandidateSiteModal.tsx +++ b/apps/dm_new/app/(landing)/reuse/apply/modal/CandidateSiteModal.tsx @@ -133,7 +133,7 @@ export default function CandidateSiteModal({ /> {/* panel */} -
+
{/* header */}

후보지 조회

diff --git a/apps/dm_new/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx b/apps/dm_new/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx new file mode 100644 index 000000000..f226f2ca9 --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx @@ -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; +}) { + // 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(""); + const [selectedCaseId, setSelectedCaseId] = useState(""); + + 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(); + rows.forEach((r) => counts.set(r.caseId, (counts.get(r.caseId) ?? 0) + 1)); + + const firstIndex = new Map(); + 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 = ( +
+ {/* overlay */} + +
+
+ + {/* body */} +
+ {/* 상단 타이틀 + 유사기준 */} +
+
+ 물 재이용 사업 유사 사례 +
+ +
+ + 유사기준 + + +
+
+ + {/* 리스트 영역 */} +
+ {!showList ? ( +
+
+ 유사기준을 선택해주세요. +
+
+ ) : !hasRows ? ( +
+
조회 결과가 없습니다.
+
+ ) : ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {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 ( + setSelectedCaseId(r.caseId)} + > + {isFirst ? ( + <> + + + + + ) : null} + + + + + + + + + + + + + + + + + ); + })} + +
+ 선택 + + 사업명 + + 사업기간 + + 공급처 + + 수요처 + + 재이용시설 +
시설종류공급처명 + 시설용량 +
+ (m³/일) +
수요처명주소 + 시설용량 +
+ (m³/일) +
+ 관로 +
+ (km) +
용도 + 공급량 +
+ (m³/일) +
처리공법이용용도
+ + + {r.projectName ?? "-"} + + {r.period ?? "-"} + + {r.supplyFacilityType ?? "-"} + {r.supplyName ?? "-"} + {formatNumber(r.supplyCapacity)} + {r.demandName ?? "-"}{r.demandAddress ?? "-"} + {formatNumber(r.facilityCapacity)} + + {formatNumber(r.pipeKm)} + {r.purpose ?? "-"} + {formatNumber(r.supplyAmt)} + + {r.treatment ?? "-"} + + {r.usePurpose ?? "-"} +
+
+ )} +
+ + {/* 상세정보 */} +
+
+ 상세정보 +
+ + {/* 1) 보증수질 */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 보증수질 +
+ 총대장균군수
(개/100ml) +
+ 용도별 결합 잔류염소
(mg/L) +
+ 탁도
(NTU) +
+ 생물학적
산소요구량(BOD)(mg/L) +
냄새색도(도) + 총질소
(T-N)(mg/L) +
+ 총인
(T-P)(mg/L) +
+ 수소이온
농도(pH) +
+ 염화물
(mgCL/L) +
+ 전기전도도
(μs/cm) +
+ 비고 +
+ {selectedDetail?.coliform ?? "-"} + + {selectedDetail?.residualCl ?? "-"} + + {selectedDetail?.turbidity ?? "-"} + + {selectedDetail?.bod ?? "-"} + + {selectedDetail?.odor ?? "-"} + + {selectedDetail?.color ?? "-"} + + {selectedDetail?.tn ?? "-"} + + {selectedDetail?.tp ?? "-"} + + {selectedDetail?.ph ?? "-"} + + {selectedDetail?.chloride ?? "-"} + + {selectedDetail?.ec ?? "-"} + + {selectedDetail?.note ?? "-"} +
+
+ + {/* 2) 사업비 */} +
+ + + + + + + + + + {/* 소요사업비 */} + + + + + + + + + + + + + + + {/* 재원조달 */} + + + + + + + {/* 정산 */} + + + + + + + + + + + + + + + + {/* 소요사업비 */} + + + + + + + + + + + + + + + + {/* 재원조달 */} + + + + + + + {/* 정산(필드 없으면 동일 값 표시) */} + + + + + + + +
+ 소요사업비(백만원) + + 재원조달계획(백만원) + + 사업비 정산(백만원) +
총토목건축기계전기
·계측
GIS
DB
CCTV시공 및 준공폐기물설계비감리비시설부대기타총국비지방비원인자부담국고비율총국비지방비원인자부담국고비율
재이용
시설
관로
+ {selectedDetail?.costTotal != null + ? formatNumber(selectedDetail.costTotal) + : "-"} + + {selectedDetail?.costFacility != null + ? formatNumber(selectedDetail.costFacility) + : "-"} + + {selectedDetail?.costPipe != null + ? formatNumber(selectedDetail.costPipe) + : "-"} + ------- + {selectedDetail?.costDesign != null + ? formatNumber(selectedDetail.costDesign) + : "-"} + + {selectedDetail?.costSupervision != null + ? formatNumber(selectedDetail.costSupervision) + : "-"} + - + {selectedDetail?.costOther != null + ? formatNumber(selectedDetail.costOther) + : "-"} + + {selectedDetail?.fundingTotal != null + ? formatNumber(selectedDetail.fundingTotal) + : "-"} + + {selectedDetail?.fundingNational != null + ? formatNumber(selectedDetail.fundingNational) + : "-"} + + {selectedDetail?.fundingLocal != null + ? formatNumber(selectedDetail.fundingLocal) + : "-"} + + {selectedDetail?.fundingSelf != null + ? formatNumber(selectedDetail.fundingSelf) + : "-"} + + {typeof selectedDetail?.fundingRate === "number" + ? `${selectedDetail.fundingRate}%` + : "-"} + + {selectedDetail?.fundingTotal != null + ? formatNumber(selectedDetail.fundingTotal) + : "-"} + + {selectedDetail?.fundingNational != null + ? formatNumber(selectedDetail.fundingNational) + : "-"} + + {selectedDetail?.fundingLocal != null + ? formatNumber(selectedDetail.fundingLocal) + : "-"} + + {selectedDetail?.fundingSelf != null + ? formatNumber(selectedDetail.fundingSelf) + : "-"} + + {typeof selectedDetail?.fundingRate === "number" + ? `${selectedDetail.fundingRate}%` + : "-"} +
+
+ + {!selectedCaseId && showList ? ( +
+ 리스트에서 사업을 선택하면 상세정보가 표시됩니다. +
+ ) : null} +
+
+
+
+ ); + + return createPortal(ui, document.body); +} \ No newline at end of file diff --git a/apps/dm_new/app/(landing)/reuse/apply/register/page.tsx b/apps/dm_new/app/(landing)/reuse/apply/register/page.tsx index a8a7297d3..0a6cac34a 100644 --- a/apps/dm_new/app/(landing)/reuse/apply/register/page.tsx +++ b/apps/dm_new/app/(landing)/reuse/apply/register/page.tsx @@ -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([]); + // 유사사례 모달 + 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 = 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(today); const [reqQty, setReqQty] = useState(""); @@ -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 = { + 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 ( <> - -
- - {/* 서브 타이틀 + 브레드크럼 */} - - - {/* 컨텐츠 영역 */} -
- - {/* 기본정보 테이블 */} -
- - - - - - - - - - - - - - - - - - - - - -
- - {/* 요구수질 섹션 타이틀 바 */} -
-
- 요구수질 - - {/* 수질기준참고 모달 */} - setOpenRef(false)} - imageSrc="/images/landing/water_quality_ref_1.png" - /> -
-
- - {/* 요구수질 테이블 */} -
- {/* 1행 */} - - - -
- } - rightLabel="총질소(T-N)(mg/L)" - rightControl={ - - } - /> - - - {/* 2행 */} - } - rightLabel="총인(T-P)(mg/L)" - rightControl={ - - } - /> - - - {/* 3행 */} - - } - rightLabel="수소이온농도(pH)" - rightControl={ -
- setPh((p) => ({ ...p, min: v }))} - className="w-32" - /> - ~ - setPh((p) => ({ ...p, max: v }))} - className="w-32" - /> -
- } - /> - - - {/* 4행 */} - - 생물화학적산소요구량 -
- (BOD)(mg/L) - - } - leftRequired - leftControl={} - rightLabel="염화물(mgCL/L)" - rightControl={} - /> - - - {/* 5행 */} - 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" - > - - - - - } - rightLabel="전기전도도(µs/cm)" - rightControl={} - /> - - - {/* 6행 */} - } - rightLabel="비고" - rightControl={ -