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..c363d13ee --- /dev/null +++ b/apps/dm_new/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx @@ -0,0 +1,538 @@ +// apps/dm/app/(landing)/reuse/apply/modal/SimilarCaseModal.tsx (유사사례 모달) +"use client"; + +import { useEffect, useMemo, useState, useId } 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; +}) { + const titleId = useId(); + + // 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-2 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가 4개/더 많아져서 높이를 넘으면 자동 스크롤 생성 +
+ + {/* thead sticky: bg + shadow + z-index로 겹침/투명 방지 */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {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..4a57089fa 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,455 @@ export default function DemandRegisterPage() { const [cand2, setCand2] = useState(""); const [cand3, setCand3] = useState(""); + // 샘플 데이터 + const similarRows: SimilarCaseRow[] = [ + // ---------------- A (2행) ---------------- + { + caseId: "A", + projectName: "OO시 물 재이용(빗물, 중수도) 시설 설치사업", + period: "2020.01.~2025.05.", + supplyFacilityType: "중수도", + supplyName: "OO폐수재이용", + supplyCapacity: 50, + + demandName: "OO시체육센터", + demandAddress: "OO시 수성로 192", + + facilityCapacity: 50, + pipeKm: 3, + purpose: "조경", + supplyAmt: 50, + treatment: "MBLM", + usePurpose: "청소, 화장실", + }, + // A의 공급처 row 추가(=표 1행 추가) → 나머지 컬럼도 같이 채움 + { + caseId: "A", + projectName: "OO시 물 재이용(빗물, 중수도) 시설 설치사업", + period: "2020.01.~2025.05.", + supplyFacilityType: "중수도", + supplyName: "OO스포츠센터", + supplyCapacity: 30, + + demandName: "OO시체육센터", + demandAddress: "OO시 수성로 192", + + facilityCapacity: 50, + pipeKm: 3, + purpose: "조경", + supplyAmt: 50, + treatment: "MBLM", + usePurpose: "청소, 화장실", + }, + + // ---------------- B (2행) ---------------- + { + caseId: "B", + projectName: "OO 염성저수지 하수처리수 재이용사업", + period: "2019.12~2026.1", + supplyFacilityType: "하수", + supplyName: "OO3.4단계 공공하수처리시설", + supplyCapacity: 70000, + + demandName: "OO천", + demandAddress: "-", + + facilityCapacity: 0, + pipeKm: 3, + purpose: "하천 등 유지용수", + supplyAmt: 2300, + treatment: "탈황, 총인, 생물산화, 소독", + usePurpose: "하천 등 유지용수", + }, + // B의 공급처 row 추가(=표 1행 추가) + { + caseId: "B", + projectName: "OO 염성저수지 하수처리수 재이용사업", + period: "2019.12~2026.1", + supplyFacilityType: "하수", + supplyName: "OO3.4단계 공공하수처리시설(2라인)", + supplyCapacity: 70000, + + demandName: "OO저수지", + demandAddress: "-", + + facilityCapacity: 0, + pipeKm: 5, + purpose: "하천 등 유지용수", + supplyAmt: 7700, + treatment: "탈황, 총인, 생물산화, 소독", + usePurpose: "하천 등 유지용수", + }, + + // ---------------- C (1행) ---------------- + { + caseId: "C", + projectName: "OO 산업단지 공업용수 재이용사업", + period: "2021.03~2024.12", + supplyFacilityType: "하수", + supplyName: "OO공공하수처리장", + supplyCapacity: 15000, + + demandName: "OO산업단지", + demandAddress: "OO시 산업로 25", + + facilityCapacity: 5000, + pipeKm: 4, + purpose: "공업용수", + supplyAmt: 5000, + treatment: "UF + RO", + usePurpose: "공업용수", + }, + + // ---------------- D (1행) ---------------- + { + caseId: "D", + projectName: "OO 공원 조경용수 재이용사업", + period: "2018.05~2022.11", + supplyFacilityType: "중수도", + supplyName: "OO정수장", + supplyCapacity: 8000, + + demandName: "OO중앙공원", + demandAddress: "OO시 공원로 88", + + facilityCapacity: 1200, + pipeKm: 2, + purpose: "조경용수", + supplyAmt: 1200, + treatment: "모래여과", + usePurpose: "조경", + }, +]; + + 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, + }, + C: { + 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, + }, + D: { + 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={ -