Files
frontend/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx
T

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: index + 1,
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>
);
}