539 lines
22 KiB
TypeScript
539 lines
22 KiB
TypeScript
// 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<string, SimilarCaseDetail | undefined>;
|
|
}) {
|
|
const titleId = useId();
|
|
|
|
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;
|
|
|
|
// open 타입 방어
|
|
if (typeof open !== "boolean") {
|
|
console.error("SimilarCaseModal: open prop is not boolean:", open);
|
|
return null;
|
|
}
|
|
|
|
|
|
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 = (
|
|
<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
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={titleId}
|
|
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 id={titleId} 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 focus:outline-none focus:ring-2 focus:ring-neutral-300"
|
|
>
|
|
✕
|
|
</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>
|
|
) : (
|
|
// 여기만 스크롤: rows가 4개/더 많아져서 높이를 넘으면 자동 스크롤 생성
|
|
<div className="max-h-[300px] overflow-y-auto overflow-x-auto">
|
|
<table className="w-full border-collapse">
|
|
{/* thead sticky: bg + shadow + z-index로 겹침/투명 방지 */}
|
|
<thead className="sticky top-0 z-20 bg-[#EAF5F5] shadow-sm">
|
|
<tr>
|
|
<th rowSpan={2} className={`${thBase} bg-[#EAF5F5]`}>
|
|
선택
|
|
</th>
|
|
<th rowSpan={2} className={`${thBase} bg-[#EAF5F5]`}>
|
|
사업명
|
|
</th>
|
|
<th rowSpan={2} className={`${thBase} bg-[#EAF5F5]`}>
|
|
사업기간
|
|
</th>
|
|
|
|
<th colSpan={3} className={`${thBase} bg-[#EAF5F5]`}>
|
|
공급처
|
|
</th>
|
|
<th colSpan={2} className={`${thBase} bg-[#EAF5F5]`}>
|
|
수요처
|
|
</th>
|
|
<th colSpan={6} className={`${thBase} bg-[#EAF5F5]`}>
|
|
재이용시설
|
|
</th>
|
|
</tr>
|
|
<tr>
|
|
<th className={`${thBase} bg-[#EAF5F5]`}>시설<br/>종류</th>
|
|
<th className={`${thBase} bg-[#EAF5F5]`}>공급처명</th>
|
|
<th className={`${thBase} bg-[#EAF5F5]`}>
|
|
시설용량
|
|
<br />
|
|
(m³/일)
|
|
</th>
|
|
|
|
<th className={`${thBase} bg-[#EAF5F5]`}>수요처명</th>
|
|
<th className={`${thBase} bg-[#EAF5F5]`}>주소</th>
|
|
|
|
<th className={`${thBase} bg-[#EAF5F5]`}>
|
|
시설용량
|
|
<br />
|
|
(m³/일)
|
|
</th>
|
|
<th className={`${thBase} bg-[#EAF5F5]`}>
|
|
관로
|
|
<br />
|
|
(km)
|
|
</th>
|
|
<th className={`${thBase} bg-[#EAF5F5]`}>용도</th>
|
|
|
|
<th className={`${thBase} bg-[#EAF5F5]`}>
|
|
공급량
|
|
<br />
|
|
(m³/일)
|
|
</th>
|
|
<th className={`${thBase} bg-[#EAF5F5]`}>처리공법</th>
|
|
<th className={`${thBase} bg-[#EAF5F5]`}>이용용도</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>
|
|
|
|
<hr className="my-6 border-y-[1px] border-[#C9C9C9]" />
|
|
|
|
{/* 상세정보 */}
|
|
<div className="mt-0">
|
|
<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);
|
|
} |