575 lines
22 KiB
TypeScript
575 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import LoadingOverlay from "@/components/ui/LoadingBar";
|
|
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
|
import {
|
|
StandardTableSortHeader,
|
|
sortStandardTableRows,
|
|
type UiTableSort,
|
|
} from "@common/business_components";
|
|
import {
|
|
Button,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@common/components/ui";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
downloadExmnSectionSummary,
|
|
getExmnSectionSummaries,
|
|
} from "../api/exmn-business-api";
|
|
import type {
|
|
ExmnBusiness,
|
|
ExmnJobType,
|
|
ExmnSectionSummaryRow,
|
|
} from "../model/exmn-business-types";
|
|
import type { ExmnDetailDefectType } from "../model/exmn-detail-table";
|
|
import { EXMN_TABLE_HEADER_HEIGHT_REM } from "./ExmnStandardDataTable";
|
|
|
|
// 상세 본문 행 높이는 유지하되 세로 이동은 CS 목록과 같이 브라우저 기본 스크롤에 맡긴다.
|
|
const SUMMARY_GRID_ROW_HEIGHT_REM = 3.2;
|
|
|
|
const PIPE_SUMMARY_TABLE_HEAD = [
|
|
{ id: "rowNumber", title: "순번", widthPercent: 6, sortKey: "rowNumber" },
|
|
{ id: "trtdstNm", title: "처리분구", widthPercent: 18, sortKey: "trtdstNm" },
|
|
{ id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13, sortKey: "trgtPrlgText" },
|
|
{ id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13, sortKey: "drvngDstncText" },
|
|
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 15, sortKey: "wholeCountText" },
|
|
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 14, sortKey: "structuralCountText" },
|
|
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 14, sortKey: "operationalCountText" },
|
|
{ id: "etcCountText", title: "기타", widthPercent: 7, sortKey: "etcCountText" },
|
|
];
|
|
|
|
const MANHOLE_SUMMARY_TABLE_HEAD = [
|
|
{ id: "rowNumber", title: "순번", widthPercent: 8, sortKey: "rowNumber" },
|
|
{ id: "trtdstNm", title: "처리분구", widthPercent: 28, sortKey: "trtdstNm" },
|
|
{ id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 18, sortKey: "wholeCountText" },
|
|
{ id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 17, sortKey: "structuralCountText" },
|
|
{ id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 17, sortKey: "operationalCountText" },
|
|
{ id: "etcCountText", title: "기타", widthPercent: 12, sortKey: "etcCountText" },
|
|
];
|
|
|
|
type Notice = { type: "info" | "error"; text: string } | null;
|
|
|
|
interface ExmnStatusDetailPanelProps {
|
|
business: ExmnBusiness | null;
|
|
}
|
|
|
|
const formatNumber = (value: number | null | undefined) =>
|
|
Number(value ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 });
|
|
|
|
/** 서버가 생성한 다중 시트 XLSX Blob을 브라우저 다운로드로 전달한다. */
|
|
const saveBlob = (blob: Blob, fileName: string) => {
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = objectUrl;
|
|
anchor.download = fileName;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
URL.revokeObjectURL(objectUrl);
|
|
};
|
|
|
|
interface DisplaySummaryRow extends Record<string, string | number> {
|
|
trtdstId: string;
|
|
rowNumber: string | number;
|
|
trtdstNm: string;
|
|
trgtPrlgText: string;
|
|
drvngDstncText: string;
|
|
wholeCountText: string;
|
|
structuralCountText: string;
|
|
operationalCountText: string;
|
|
etcCountText: string;
|
|
}
|
|
|
|
/** 동일 조사현황 화면에서 선택 사업의 관로·맨홀 처리분구 집계를 펼쳐 표시한다. */
|
|
export function ExmnStatusDetailPanel({
|
|
business,
|
|
}: ExmnStatusDetailPanelProps) {
|
|
const [jobType, setJobType] = useState<ExmnJobType>("PPL");
|
|
const [summaryRows, setSummaryRows] = useState<ExmnSectionSummaryRow[]>([]);
|
|
/** 상세보기 팝업에 전달할 현재 선택 처리분구다. */
|
|
const [selectedTrtdstId, setSelectedTrtdstId] = useState<string | null>(null);
|
|
// 서버가 반환한 전체 처리분구 집계행에 적용하는 단일 로컬 정렬 상태다.
|
|
const [sort, setSort] = useState<UiTableSort>();
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isDownloading, setIsDownloading] = useState(false);
|
|
const [notice, setNotice] = useState<Notice>(null);
|
|
const summaryRequestIdRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
setJobType("PPL");
|
|
setSelectedTrtdstId(null);
|
|
setSort(undefined);
|
|
setNotice(null);
|
|
}, [business?.exmnBizId]);
|
|
|
|
useEffect(() => {
|
|
setSelectedTrtdstId(null);
|
|
setSort(undefined);
|
|
}, [jobType]);
|
|
|
|
useEffect(() => {
|
|
if (!business) {
|
|
summaryRequestIdRef.current += 1;
|
|
setSummaryRows([]);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
const requestId = summaryRequestIdRef.current + 1;
|
|
summaryRequestIdRef.current = requestId;
|
|
setIsLoading(true);
|
|
setNotice(null);
|
|
getExmnSectionSummaries(business.exmnBizId, jobType)
|
|
.then((rows) => {
|
|
if (summaryRequestIdRef.current === requestId) setSummaryRows(rows);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (summaryRequestIdRef.current !== requestId) return;
|
|
setSummaryRows([]);
|
|
setNotice({
|
|
type: "error",
|
|
text:
|
|
error instanceof Error
|
|
? error.message
|
|
: "조사 상세현황 조회 중 오류가 발생했습니다.",
|
|
});
|
|
})
|
|
.finally(() => {
|
|
if (summaryRequestIdRef.current === requestId) setIsLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
if (summaryRequestIdRef.current === requestId)
|
|
summaryRequestIdRef.current += 1;
|
|
};
|
|
}, [business, jobType]);
|
|
|
|
const summaryTable = useMemo(() => {
|
|
const getCounts = (row: ExmnSectionSummaryRow) =>
|
|
jobType === "PPL"
|
|
? {
|
|
whole: row.pplExmnWholAnmlyArtclCnt,
|
|
structural: row.pplExmnStrctDfcCnt,
|
|
operational: row.pplExmnOperDfcCnt,
|
|
etc: row.pplExmnEtcCnt,
|
|
}
|
|
: {
|
|
whole: row.mnhlExmnWholAnmlyArtclCnt,
|
|
structural: row.mnhlExmnStrctDfcCnt,
|
|
operational: row.mnhlExmnOperDfcCnt,
|
|
etc: row.mnhlExmnEtcCnt,
|
|
};
|
|
const totals = summaryRows.reduce(
|
|
(sum, row) => {
|
|
const counts = getCounts(row);
|
|
return {
|
|
trgtPrlg: sum.trgtPrlg + row.trgtPrlg,
|
|
drvngDstnc: sum.drvngDstnc + row.drvngDstnc,
|
|
whole: sum.whole + counts.whole,
|
|
structural: sum.structural + counts.structural,
|
|
operational: sum.operational + counts.operational,
|
|
etc: sum.etc + counts.etc,
|
|
};
|
|
},
|
|
{
|
|
trgtPrlg: 0,
|
|
drvngDstnc: 0,
|
|
whole: 0,
|
|
structural: 0,
|
|
operational: 0,
|
|
etc: 0,
|
|
},
|
|
);
|
|
const displayRows: DisplaySummaryRow[] = summaryRows.map((row, index) => {
|
|
const counts = getCounts(row);
|
|
return {
|
|
trtdstId: row.trtdstId,
|
|
rowNumber: summaryRows.length - index,
|
|
trtdstNm: row.trtdstNm,
|
|
trgtPrlgText: formatNumber(row.trgtPrlg),
|
|
drvngDstncText: formatNumber(row.drvngDstnc),
|
|
wholeCountText: formatNumber(counts.whole),
|
|
structuralCountText: formatNumber(counts.structural),
|
|
operationalCountText: formatNumber(counts.operational),
|
|
etcCountText: formatNumber(counts.etc),
|
|
};
|
|
});
|
|
return {
|
|
rows: sortStandardTableRows(displayRows, sort),
|
|
totals: {
|
|
trgtPrlgText: formatNumber(totals.trgtPrlg),
|
|
drvngDstncText: formatNumber(totals.drvngDstnc),
|
|
wholeCountText: formatNumber(totals.whole),
|
|
structuralCountText: formatNumber(totals.structural),
|
|
operationalCountText: formatNumber(totals.operational),
|
|
etcCountText: formatNumber(totals.etc),
|
|
} as Record<string, string>,
|
|
};
|
|
}, [jobType, sort, summaryRows]);
|
|
|
|
const summaryColumns =
|
|
jobType === "PPL" ? PIPE_SUMMARY_TABLE_HEAD : MANHOLE_SUMMARY_TABLE_HEAD;
|
|
const selectedSummaryRow =
|
|
summaryTable.rows.find((row) => row.trtdstId === selectedTrtdstId) ?? null;
|
|
|
|
/** 선택 사업·처리분구와 현재 조사유형을 하수관망도 초기 조회 계약으로 전달한다. */
|
|
const openExmnMap = () => {
|
|
if (!business || !selectedSummaryRow) return;
|
|
const query = new URLSearchParams({
|
|
exmnBizId: business.exmnBizId,
|
|
ctpvNm: business.ctpvNm,
|
|
sggNm: business.sggNm,
|
|
exmnJobType: jobType,
|
|
trtdstId: selectedSummaryRow.trtdstId,
|
|
trtdstNm: selectedSummaryRow.trtdstNm,
|
|
});
|
|
if (business.bizCycl != null)
|
|
query.set("bizCycl", String(business.bizCycl));
|
|
window.location.assign(`/map?${query.toString()}`);
|
|
};
|
|
|
|
/** 현재 조사유형의 종합집계와 처리분구별 전체 상세 시트를 서버 XLSX로 내려받는다. */
|
|
const handleSummaryDownload = async () => {
|
|
if (!business || summaryTable.rows.length === 0 || isDownloading) {
|
|
if (summaryTable.rows.length === 0) {
|
|
setNotice({
|
|
type: "info",
|
|
text: "다운로드할 조사 상세현황 데이터가 없습니다.",
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
setIsDownloading(true);
|
|
setNotice(null);
|
|
try {
|
|
const jobTypeLabel = jobType === "PPL" ? "관로조사" : "맨홀조사";
|
|
const fileName =
|
|
`${business.bizNm}_${jobTypeLabel}_조사상세현황.xlsx`.replace(
|
|
/[\\/:*?"<>|]/g,
|
|
"_",
|
|
);
|
|
const blob = await downloadExmnSectionSummary(
|
|
business.exmnBizId,
|
|
jobType,
|
|
);
|
|
saveBlob(blob, fileName);
|
|
} catch (error) {
|
|
setNotice({
|
|
type: "error",
|
|
text:
|
|
error instanceof Error
|
|
? error.message
|
|
: "조사 상세현황 다운로드 중 오류가 발생했습니다.",
|
|
});
|
|
} finally {
|
|
setIsDownloading(false);
|
|
}
|
|
};
|
|
|
|
/** 선택 사업·처리분구, 조사유형, 결함 범위를 공통 WindowPopup 상세표에 전달한다. */
|
|
const openSurveyDetailPopup = (
|
|
summaryRow: DisplaySummaryRow | null = selectedSummaryRow,
|
|
defectType: ExmnDetailDefectType = "ALL",
|
|
) => {
|
|
if (!business || !summaryRow) return;
|
|
const popup = openWindowPopup("exmnSurveyDetail", {
|
|
exmnBizId: business.exmnBizId,
|
|
trtdstId: summaryRow.trtdstId,
|
|
trtdstNm: summaryRow.trtdstNm,
|
|
jobType,
|
|
defectType,
|
|
bizYr: business.bizYr,
|
|
bizCycl: business.bizCycl,
|
|
bizNm: business.bizNm,
|
|
ctpvNm: business.ctpvNm,
|
|
sggNm: business.sggNm,
|
|
trtdstCnt: business.trtdstCnt,
|
|
prcisExmnPrlg: business.prcisExmnPrlg,
|
|
tpco: business.tpco,
|
|
reportYn: business.reportYn,
|
|
});
|
|
if (!popup) {
|
|
setNotice({
|
|
type: "error",
|
|
text: "상세보기 팝업을 열 수 없습니다. 브라우저의 팝업 허용 상태를 확인해주세요.",
|
|
});
|
|
}
|
|
};
|
|
|
|
/** 사업목록과 동일하게 처리분구 행의 어느 셀을 눌러도 상세 대상 행으로 선택한다. */
|
|
const handleSummaryRowSelect = (row: DisplaySummaryRow) => {
|
|
setSelectedTrtdstId(row.trtdstId);
|
|
setNotice(null);
|
|
};
|
|
|
|
/** 정렬 변경 시 상세 대상 선택을 해제하고 전체 집계행을 다시 배열한다. */
|
|
const handleSummarySortChange = (nextSort?: UiTableSort) => {
|
|
setSort(nextSort);
|
|
setSelectedTrtdstId(null);
|
|
};
|
|
|
|
/** 보이는 버튼 전체에서 조사유형을 즉시 바꾸고, 상세 조회는 별도 effect에서 수행한다. */
|
|
const handleJobTypeChange = (nextJobType: ExmnJobType) => {
|
|
if (nextJobType === jobType) return;
|
|
setJobType(nextJobType);
|
|
};
|
|
|
|
// PPT 3의 최초 상태처럼 사업을 선택하기 전에는 상세현황 영역 자체를 표시하지 않는다.
|
|
if (!business) return null;
|
|
|
|
return (
|
|
<section className="card card-border mt-4 !p-0">
|
|
<LoadingOverlay isLoading={isLoading} />
|
|
<div className="card-header flex min-h-[5.2rem] flex-wrap items-center gap-3">
|
|
<h3 className="mr-3 text-2xl font-medium">조사 상세현황</h3>
|
|
<div
|
|
role="tablist"
|
|
aria-label="조사 유형"
|
|
aria-busy={isLoading}
|
|
className="flex items-center gap-1"
|
|
>
|
|
{(
|
|
[
|
|
{ value: "PPL", label: "관로조사" },
|
|
{ value: "MNHL", label: "맨홀조사" },
|
|
] as const
|
|
).map((tab) => (
|
|
<button
|
|
key={tab.value}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={jobType === tab.value}
|
|
className={`inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-md border text-[1.4rem] font-semibold ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
|
|
jobType === tab.value
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
: "border-border bg-background text-muted-foreground hover:bg-accent"
|
|
}`}
|
|
style={{ width: "8rem", height: "3.2rem" }}
|
|
onClick={() => handleJobTypeChange(tab.value)}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<Button
|
|
color="primary"
|
|
disabled={
|
|
isLoading || isDownloading || summaryTable.rows.length === 0
|
|
}
|
|
onClick={() => void handleSummaryDownload()}
|
|
>
|
|
{isDownloading ? "엑셀다운로드 중..." : "엑셀다운로드"}
|
|
</Button>
|
|
<Button
|
|
color="white"
|
|
disabled={!selectedSummaryRow}
|
|
title={
|
|
selectedSummaryRow
|
|
? `${selectedSummaryRow.trtdstNm} 상세보기`
|
|
: "처리분구 행을 먼저 선택해주세요."
|
|
}
|
|
onClick={() => openSurveyDetailPopup()}
|
|
>
|
|
상세보기
|
|
</Button>
|
|
<Button
|
|
color="white"
|
|
disabled={!selectedSummaryRow}
|
|
title={
|
|
selectedSummaryRow
|
|
? `${selectedSummaryRow.trtdstNm} 지도보기`
|
|
: "처리분구 행을 먼저 선택해주세요."
|
|
}
|
|
onClick={openExmnMap}
|
|
>
|
|
지도보기
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{notice && (
|
|
<div
|
|
role="alert"
|
|
className={`mx-4 mt-3 rounded border px-4 py-3 text-sm ${notice.type === "error" ? "border-red-200 bg-red-50 text-red-700" : "border-primary bg-primary-transparent text-primary"}`}
|
|
>
|
|
{notice.text}
|
|
</div>
|
|
)}
|
|
|
|
<div className="pb-0 pt-3">
|
|
<div className="relative mx-[1.2rem] mb-[1.2rem] min-w-0 overflow-hidden">
|
|
<div className="overflow-x-auto overflow-y-visible">
|
|
<div
|
|
className="card !m-0"
|
|
style={{ paddingTop: 0, paddingBottom: 0 }}
|
|
>
|
|
<Table variant="default" className="table-fixed">
|
|
<colgroup>
|
|
{summaryColumns.map((column) => (
|
|
<col
|
|
key={column.id}
|
|
style={{ width: `${column.widthPercent}%` }}
|
|
/>
|
|
))}
|
|
</colgroup>
|
|
<TableHeader
|
|
className="font-bold"
|
|
style={{ position: "static", zIndex: "auto" }}
|
|
>
|
|
<TableRow
|
|
style={{ height: `${EXMN_TABLE_HEADER_HEIGHT_REM}rem` }}
|
|
>
|
|
{summaryColumns.map((column) => (
|
|
<TableHead
|
|
key={column.id}
|
|
className="sticky top-0 z-20 !py-[1.2rem] text-center"
|
|
style={{ height: `${EXMN_TABLE_HEADER_HEIGHT_REM}rem` }}
|
|
>
|
|
<StandardTableSortHeader
|
|
label={column.title}
|
|
sortKey={column.sortKey}
|
|
sort={sort}
|
|
onSortChange={handleSummarySortChange}
|
|
/>
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody className="text-center">
|
|
{summaryTable.rows.length > 0 ? (
|
|
<>
|
|
<TableRow
|
|
className="text-label"
|
|
style={{
|
|
height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem`,
|
|
backgroundColor: "#d2deef",
|
|
}}
|
|
>
|
|
<TableCell
|
|
colSpan={2}
|
|
className="!py-[0.8rem] text-center !font-semibold"
|
|
style={{ backgroundColor: "#d2deef" }}
|
|
>
|
|
합계
|
|
</TableCell>
|
|
{summaryColumns.slice(2).map((column) => (
|
|
<TableCell
|
|
key={`total-${column.id}`}
|
|
className="!py-[0.8rem] text-center !font-semibold"
|
|
style={{ backgroundColor: "#d2deef" }}
|
|
>
|
|
{summaryTable.totals[column.id] ?? "-"}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
{summaryTable.rows.map((row, rowIndex) => {
|
|
const selected = row.trtdstId === selectedTrtdstId;
|
|
return (
|
|
<TableRow
|
|
key={row.trtdstId}
|
|
data-state={selected ? "selected" : undefined}
|
|
className="cursor-pointer"
|
|
style={{
|
|
height: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem`,
|
|
}}
|
|
onClick={() => handleSummaryRowSelect(row)}
|
|
>
|
|
{summaryColumns.map((column) => {
|
|
const defectType: ExmnDetailDefectType | null =
|
|
column.id === "structuralCountText"
|
|
? "STRUCTURAL"
|
|
: column.id === "operationalCountText"
|
|
? "OPERATIONAL"
|
|
: null;
|
|
const defectCount = defectType
|
|
? Number(
|
|
String(row[column.id] ?? "0").replaceAll(
|
|
",",
|
|
"",
|
|
),
|
|
)
|
|
: 0;
|
|
const opensFilteredDetail =
|
|
defectType !== null && defectCount > 0;
|
|
|
|
return (
|
|
<TableCell
|
|
key={`${row.trtdstId}-${column.id}`}
|
|
className="!p-0 text-center"
|
|
style={
|
|
rowIndex === summaryTable.rows.length - 1
|
|
? { borderBottomWidth: 0 }
|
|
: undefined
|
|
}
|
|
>
|
|
<button
|
|
type="button"
|
|
className={`flex w-full cursor-pointer items-center justify-center px-2 py-[0.8rem] text-[1.3rem] ${
|
|
opensFilteredDetail
|
|
? "hover:text-primary/80 font-semibold text-primary underline underline-offset-4"
|
|
: selected
|
|
? "font-bold"
|
|
: "font-normal"
|
|
}`}
|
|
style={{
|
|
minHeight: `${SUMMARY_GRID_ROW_HEIGHT_REM}rem`,
|
|
}}
|
|
aria-label={
|
|
opensFilteredDetail
|
|
? `${row.trtdstNm} ${
|
|
defectType === "STRUCTURAL"
|
|
? "구조적"
|
|
: "운영적"
|
|
} 결함 상세보기`
|
|
: undefined
|
|
}
|
|
title={
|
|
opensFilteredDetail
|
|
? "해당 결함만 상세보기"
|
|
: undefined
|
|
}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
handleSummaryRowSelect(row);
|
|
if (opensFilteredDetail && defectType) {
|
|
openSurveyDetailPopup(row, defectType);
|
|
}
|
|
}}
|
|
>
|
|
{row[column.id]}
|
|
</button>
|
|
</TableCell>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</>
|
|
) : (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={summaryColumns.length}
|
|
className="h-24 text-center text-muted-foreground"
|
|
style={{ borderBottomWidth: 0 }}
|
|
>
|
|
조회된 결과가 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|