Files
frontend/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx
T

683 lines
24 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Button,
Table,
TableBody,
TableCell,
TableHeader,
TableHead,
TableRow,
} from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import { useAlertStore } from "@/shared/lib";
import { useUserStore } from "@/shared/lib/use-user-store";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import {
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
PIPE_ANALYSIS_TYPE_OPTIONS,
applyScopeToSummaryFilters,
buildPipeAnalysisFileDate,
buildPipeAnalysisYearMaterialGroups,
buildPipeAnalysisScope,
buildPipeAnalysisSummaryDownloadHeaders,
buildPipeAnalysisSummaryHeadList,
buildPipeAnalysisSummaryViewRows,
fetchPipeAnalysisZoneOptions,
resolvePipeAnalysisTypeLabel,
type PipeAnalysisMaterialColumn,
type PipeAnalysisSummaryFilters,
type PipeAnalysisSummaryResponse,
type PipeAnalysisSummaryViewRow,
type PipeAnalysisYearMaterialGroup,
} from "./pipeAnalysisUtils";
const SUMMARY_FILE_PREFIX: Record<string, string> = {
YEAR: "관로분석_경과년도별",
MATERIAL: "관로분석_관재질별",
YEAR_MATERIAL: "관로분석_경과년도별_관재질별",
};
const PIPE_ANALYSIS_SUMMARY_PAGE_SIZE = 10;
const toExcelColumnName = (index: number) => {
let current = index;
let result = "";
while (current > 0) {
const remainder = (current - 1) % 26;
result = String.fromCharCode(65 + remainder) + result;
current = Math.floor((current - 1) / 26);
}
return result || "A";
};
const formatGridNumber = (value: unknown) => {
const num = Number(value ?? 0);
if (!Number.isFinite(num)) {
return String(value ?? "");
}
return num.toLocaleString("ko-KR", {
minimumFractionDigits: 0,
maximumFractionDigits: 3,
});
};
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
error instanceof Error && error.message.trim()
? error.message
: fallbackMessage;
export default function PipeAnalysisPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const userInfo = useUserStore((state) => state.userInfo);
const scope = useMemo(() => buildPipeAnalysisScope(userInfo), [userInfo]);
const [draftFilters, setDraftFilters] = useState<PipeAnalysisSummaryFilters>(
() =>
applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope),
);
const [appliedFilters, setAppliedFilters] =
useState<PipeAnalysisSummaryFilters>(() =>
applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope),
);
const [materialColumns, setMaterialColumns] = useState<
PipeAnalysisMaterialColumn[]
>([]);
const [rows, setRows] = useState<PipeAnalysisSummaryViewRow[]>([]);
const [zoneOptions, setZoneOptions] = useState<
Array<{ value: string; label: string }>
>([]);
const [summaryCurrentPage, setSummaryCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const initializedRef = useRef(false);
useEffect(() => {
if (ctpv.length === 0) fetchCtpv();
if (sgg.length === 0) fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
useEffect(() => {
setDraftFilters((current) => applyScopeToSummaryFilters(current, scope));
setAppliedFilters((current) => applyScopeToSummaryFilters(current, scope));
}, [scope]);
useEffect(() => {
let mounted = true;
(async () => {
try {
const zones = await fetchPipeAnalysisZoneOptions({
ctpvCd: draftFilters.ctpvCd,
sggCd: draftFilters.sggCd,
scopeRegionCode: scope.regionCode,
});
if (!mounted) return;
setZoneOptions([{ value: "0", label: "전체" }, ...zones]);
} catch (error) {
console.error("처리구역 목록 조회 중 오류:", error);
if (!mounted) return;
setZoneOptions([{ value: "0", label: "전체" }]);
}
})();
return () => {
mounted = false;
};
}, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode]);
const fetchSummary = useCallback(
async (nextFilters: PipeAnalysisSummaryFilters) => {
const params = new URLSearchParams({
analysisType: nextFilters.analysisType,
});
if (nextFilters.ctpvCd !== "0") params.set("ctpvCd", nextFilters.ctpvCd);
if (nextFilters.sggCd !== "0") params.set("sggCd", nextFilters.sggCd);
if (nextFilters.swtrmtZoneNm !== "0")
params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm);
if (nextFilters.scopeRegionCode)
params.set("scopeRegionCode", nextFilters.scopeRegionCode);
const response = (await getData(
`v1/fclts/ppl-analysis/summaries?${params.toString()}`,
)) as PipeAnalysisSummaryResponse;
const nextMaterialColumns = Array.isArray(response?.materialColumns)
? response.materialColumns
: [];
setMaterialColumns(nextMaterialColumns);
setRows(buildPipeAnalysisSummaryViewRows(response, ctpv, sgg));
},
[ctpv, sgg],
);
const handleSearch = useCallback(
async (nextFilters = draftFilters) => {
setIsLoading(true);
try {
const scopedFilters = applyScopeToSummaryFilters(nextFilters, scope);
setDraftFilters(scopedFilters);
setAppliedFilters(scopedFilters);
setSummaryCurrentPage(1);
await fetchSummary(scopedFilters);
} catch (error) {
console.error("관로분석 집계 조회 중 오류:", error);
alert(
resolveErrorMessage(
error,
"관로분석 집계 조회 중 오류가 발생했습니다.",
),
);
setRows([]);
setMaterialColumns([]);
} finally {
setIsLoading(false);
}
},
[alert, draftFilters, fetchSummary, scope],
);
useEffect(() => {
if (initializedRef.current) return;
if (ctpv.length === 0 || sgg.length === 0) return;
initializedRef.current = true;
void handleSearch(applyScopeToSummaryFilters(draftFilters, scope));
}, [ctpv.length, draftFilters, handleSearch, scope, sgg.length]);
const handleDownload = async () => {
if (rows.length === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
try {
const exportHeadList = buildPipeAnalysisSummaryHeadList(
appliedFilters.analysisType,
materialColumns,
);
const headerRange = `A1:${toExcelColumnName(exportHeadList.length)}1`;
const mergeRanges =
appliedFilters.analysisType === "YEAR_MATERIAL"
? (() => {
const ranges: string[] = [];
let currentRow = 2;
orderedYearMaterialGroups.forEach((group) => {
const rowSpan = group.rows.length;
if (rowSpan > 1) {
["A", "B", "C", "D", "E"].forEach((column) => {
ranges.push(
`${column}${currentRow}:${column}${currentRow + rowSpan - 1}`,
);
});
}
currentRow += rowSpan;
});
return ranges;
})()
: [];
const exportRows =
appliedFilters.analysisType === "YEAR_MATERIAL"
? orderedYearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => {
const exportRow: Record<string, unknown> = {
rowNo: rowIndex === 0 ? group.rowNo : "",
ctpvNm: rowIndex === 0 ? group.ctpvNm : "",
sggNm: rowIndex === 0 ? group.sggNm : "",
swtrmtZoneNm: rowIndex === 0 ? group.swtrmtZoneNm : "",
totalLengthKm: rowIndex === 0 ? group.totalLengthKm : "",
elapsedYearLabel: yearRow.elapsedYearLabel,
};
materialColumns.forEach((column) => {
exportRow[column.key] = yearRow.metrics[column.key] ?? 0;
});
return exportRow;
}),
)
: rows.map((row) => {
const exportRow: Record<string, unknown> = {};
exportHeadList.forEach((head) => {
exportRow[head.id] = row[head.id];
});
return exportRow;
});
await downloadExcelWithKoreanHeaders({
data: exportRows as unknown as JSON[],
fileName: `${SUMMARY_FILE_PREFIX[appliedFilters.analysisType]}_${buildPipeAnalysisFileDate()}`,
sheetNameArray: ["관로분석 현황"],
headerRange,
headerMapping: buildPipeAnalysisSummaryDownloadHeaders(
appliedFilters.analysisType,
materialColumns,
),
mergeRanges,
});
} catch (error) {
console.error("관로분석 집계 다운로드 중 오류:", error);
alert(
resolveErrorMessage(
error,
"관로분석 집계 다운로드 중 오류가 발생했습니다.",
),
);
}
};
const handleCellClick = (
row: PipeAnalysisSummaryViewRow,
columnId: string,
) => {
if (row.__isNoZone || row.__isSummaryTotal) return;
const cellFilter = row.__cellFilters[columnId];
if (!cellFilter) return;
const popup = openWindowPopup("pipeAnalysisDetailPopup", {
analysisType: appliedFilters.analysisType,
ctpvCd: String(row.__base.ctpvCd || appliedFilters.ctpvCd),
sggCd: String(row.__base.sggCd || appliedFilters.sggCd || "0"),
swtrmtZoneNm: String(
row.__base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm,
),
elapsedYearBucket: cellFilter.elapsedYearBucket ?? "",
materialCode: cellFilter.materialCode ?? "",
scopeRegionCode: scope.regionCode,
});
if (!popup) {
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
};
const handleYearMaterialCellClick = (
group: PipeAnalysisYearMaterialGroup,
row: PipeAnalysisYearMaterialGroup["rows"][number],
columnId: string,
) => {
const cellFilter = row.cellFilters[columnId];
if (!cellFilter) return;
const popup = openWindowPopup("pipeAnalysisDetailPopup", {
analysisType: appliedFilters.analysisType,
ctpvCd: String(group.base.ctpvCd || appliedFilters.ctpvCd),
sggCd: String(group.base.sggCd || appliedFilters.sggCd || "0"),
swtrmtZoneNm: String(
group.base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm,
),
elapsedYearBucket: cellFilter.elapsedYearBucket ?? "",
materialCode: cellFilter.materialCode ?? "",
scopeRegionCode: scope.regionCode,
});
if (!popup) {
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
};
const headList = useMemo(
() =>
buildPipeAnalysisSummaryHeadList(
appliedFilters.analysisType,
materialColumns,
),
[appliedFilters.analysisType, materialColumns],
);
const yearMaterialGroups = useMemo(
() =>
appliedFilters.analysisType === "YEAR_MATERIAL"
? buildPipeAnalysisYearMaterialGroups(rows, materialColumns)
: [],
[appliedFilters.analysisType, materialColumns, rows],
);
const orderedYearMaterialGroups = useMemo(
() =>
yearMaterialGroups.map((group, index) => ({
...group,
rowNo: yearMaterialGroups.length - index,
})),
[yearMaterialGroups],
);
const useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR";
const summaryPageOffset =
(summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
const pagedRows = useMemo(
() =>
appliedFilters.analysisType === "YEAR_MATERIAL"
? rows
: rows.slice(
summaryPageOffset,
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
),
[appliedFilters.analysisType, rows, summaryPageOffset],
);
const clickableHeadIds = useMemo(
() =>
headList
.filter((head) =>
rows.some((row) => Boolean(row.__cellFilters[head.id])),
)
.map((head) => head.id),
[headList, rows],
);
const visibleYearMaterialGroups = useMemo(
() =>
orderedYearMaterialGroups.slice(
summaryPageOffset,
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
),
[orderedYearMaterialGroups, summaryPageOffset],
);
const sggOptions = useMemo(
() =>
draftFilters.ctpvCd === "0"
? sgg
: sgg.filter((item) =>
String(item.val).startsWith(draftFilters.ctpvCd.slice(0, 2)),
),
[draftFilters.ctpvCd, sgg],
);
return (
<StandardPopupLayout
title={windowPopupDefinitions.pipeAnalysisPopup.title}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시도
</div>
<select
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
value={draftFilters.ctpvCd}
disabled={scope.ctpvLocked}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "0",
swtrmtZoneNm: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시군구
</div>
<select
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
value={draftFilters.sggCd}
disabled={scope.sggLocked}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
sggCd: event.target.value,
swtrmtZoneNm: "0",
}))
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
처리구역
</div>
<select
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
value={draftFilters.swtrmtZoneNm}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
swtrmtZoneNm: event.target.value,
}))
}
>
{zoneOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
분석구분
</div>
<select
className="sn-popup-input !h-[3.2rem] w-[18rem] !text-xl"
value={draftFilters.analysisType}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
analysisType: event.target
.value as PipeAnalysisSummaryFilters["analysisType"],
}))
}
>
{PIPE_ANALYSIS_TYPE_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
<Button
type="button"
color="primary"
onClick={() => void handleSearch()}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
const resetFilters = applyScopeToSummaryFilters(
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
scope,
);
setDraftFilters(resetFilters);
void handleSearch(resetFilters);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
{appliedFilters.analysisType === "YEAR_MATERIAL" ? (
<div className="card card-border !m-0 min-w-0 overflow-hidden !p-0">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록
</h3>
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
</div>
<div className="card !m-[1.2rem] overflow-x-auto !p-0">
<Table
variant="secondary"
className="w-max min-w-full caption-bottom text-sm"
>
<TableHeader className="font-bold">
<TableRow>
{headList.map((head) => (
<TableHead
key={head.id}
className="border border-border bg-primary px-4 py-[1.2rem] text-center text-[1.3rem] font-medium text-primary-foreground"
>
{head.id === "rowNo" ? "순번" : head.title}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="text-center">
{visibleYearMaterialGroups.length > 0 ? (
visibleYearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => (
<TableRow
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}`}
>
{rowIndex === 0 ? (
<>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.rowNo}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.ctpvNm}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.sggNm}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.swtrmtZoneNm}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-right text-[1.3rem]"
>
{formatGridNumber(group.totalLengthKm)}
</TableCell>
</>
) : null}
<TableCell className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
{yearRow.elapsedYearLabel}
</TableCell>
{materialColumns.map((column) => {
const value = Number(
yearRow.metrics[column.key] ?? 0,
);
const isClickable =
value > 0 &&
Boolean(yearRow.cellFilters[column.key]);
return (
<TableCell
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}-${column.key}`}
className={`border border-border px-2 py-[1.2rem] text-right text-[1.3rem] ${
isClickable
? "cursor-pointer font-semibold text-primary underline underline-offset-2"
: ""
}`}
onClick={() => {
if (isClickable) {
handleYearMaterialCellClick(
group,
yearRow,
column.key,
);
}
}}
>
{formatGridNumber(value)}
</TableCell>
);
})}
</TableRow>
)),
)
) : (
<TableRow>
<TableCell
colSpan={headList.length}
className="h-24 border border-border text-center text-[1.3rem] text-label"
>
조회 결과가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{orderedYearMaterialGroups.length >
PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
<div className="mb-[1.2rem] flex justify-center">
<UiPageNations
total={orderedYearMaterialGroups.length}
cur={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
/>
</div>
) : null}
</div>
) : null}
{appliedFilters.analysisType !== "YEAR_MATERIAL" ? (
<MapPopupDataTable
rows={
pagedRows as Array<
PipeAnalysisSummaryViewRow & Record<string, unknown>
>
}
headlist={headList}
title={`${resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록`}
total={rows.length}
page={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
scrollMode={useHorizontalScrollTable ? "horizontal" : "none"}
minTableWidth={useHorizontalScrollTable ? "160rem" : "100%"}
clickableHeadIds={clickableHeadIds}
onCellClick={(row, _rowIndex, columnId) =>
handleCellClick(row, columnId)
}
rowId={(row, index) =>
`${String(row.__base.ctpvCd)}-${String(row.__base.sggCd)}-${String(row.__base.swtrmtZoneNm)}-${index}`
}
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
) : null}
</StandardPopupLayout>
);
}