536 lines
19 KiB
TypeScript
536 lines
19 KiB
TypeScript
/**
|
|
* 화면명: 관로분석
|
|
* 행정구역·처리구역과 분석구분별 관로 집계 결과를 조회한다.
|
|
*/
|
|
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Button } from "@common/components";
|
|
import { StandardPopupLayout } from "@common/business_components";
|
|
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
|
import { getData } from "@/shared/api/sewerage-api";
|
|
import { appendApiSearchParams } from "@/shared/util/util";
|
|
import LoadingOverlay from "@/components/ui/loading-bar";
|
|
import useStore from "@/entities/reference-data/model/reference-data-store";
|
|
import { useAlertStore } from "@/shared/lib";
|
|
import { useUserStore } from "@/shared/lib/use-user-store";
|
|
import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup";
|
|
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
|
|
import PipeAnalysisSummaryTable from "./pipe-analysis-summary-table";
|
|
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
|
|
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
|
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 "./pipe-analysis-utils";
|
|
|
|
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 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 [summaryTotal, setSummaryTotal] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const initializedRef = useRef(false);
|
|
// 늦게 완료된 이전 페이지 응답이 최신 조회조건의 표를 덮어쓰지 않게 한다.
|
|
const summaryRequestId = useRef(0);
|
|
|
|
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,
|
|
sggNm: sgg.find(
|
|
(option) => String(option.val).trim() === draftFilters.sggCd,
|
|
)?.text,
|
|
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, sgg]);
|
|
|
|
const fetchSummary = useCallback(
|
|
async (nextFilters: PipeAnalysisSummaryFilters, page = 1) => {
|
|
const requestId = ++summaryRequestId.current;
|
|
setIsLoading(true);
|
|
const params = new URLSearchParams({
|
|
analysisType: nextFilters.analysisType,
|
|
page: String(page - 1),
|
|
size: String(PIPE_ANALYSIS_SUMMARY_PAGE_SIZE),
|
|
});
|
|
|
|
appendApiSearchParams(params, {
|
|
ctpvCd: nextFilters.ctpvCd,
|
|
sggCd: nextFilters.sggCd,
|
|
swtrmtZoneNm: nextFilters.swtrmtZoneNm,
|
|
});
|
|
if (nextFilters.scopeRegionCode)
|
|
params.set("scopeRegionCode", nextFilters.scopeRegionCode);
|
|
|
|
try {
|
|
const response = (await getData(
|
|
`v1/fclts/pplAnalysis/summaryPage?${params.toString()}`,
|
|
)) as PipeAnalysisSummaryResponse & { count: number };
|
|
if (requestId !== summaryRequestId.current) return;
|
|
const nextMaterialColumns = Array.isArray(response?.materialColumns) ? response.materialColumns : [];
|
|
setMaterialColumns(nextMaterialColumns);
|
|
setRows(buildPipeAnalysisSummaryViewRows(response, ctpv, sgg));
|
|
setSummaryTotal(response.count);
|
|
setSummaryCurrentPage(page);
|
|
} catch (error) {
|
|
if (requestId === summaryRequestId.current) throw error;
|
|
} finally {
|
|
if (requestId === summaryRequestId.current) setIsLoading(false);
|
|
}
|
|
},
|
|
[ctpv, sgg],
|
|
);
|
|
|
|
const handleSearch = useCallback(
|
|
async (nextFilters = draftFilters) => {
|
|
try {
|
|
const scopedFilters = applyScopeToSummaryFilters(nextFilters, scope);
|
|
setDraftFilters(scopedFilters);
|
|
setAppliedFilters(scopedFilters);
|
|
setSummaryCurrentPage(1);
|
|
await fetchSummary(scopedFilters);
|
|
} catch (error) {
|
|
console.error("관로분석 집계 조회 중 오류:", error);
|
|
alert(
|
|
resolveErrorMessage(
|
|
error,
|
|
"관로분석 집계 조회 중 오류가 발생했습니다.",
|
|
),
|
|
);
|
|
setRows([]);
|
|
setSummaryTotal(0);
|
|
setMaterialColumns([]);
|
|
}
|
|
},
|
|
[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 {
|
|
// 화면은 한 페이지뿐이므로 Excel만 별도 전체 집계 계약으로 조회한다.
|
|
const params = new URLSearchParams({ analysisType: appliedFilters.analysisType });
|
|
appendApiSearchParams(params, {
|
|
ctpvCd: appliedFilters.ctpvCd, sggCd: appliedFilters.sggCd,
|
|
swtrmtZoneNm: appliedFilters.swtrmtZoneNm,
|
|
});
|
|
if (appliedFilters.scopeRegionCode) params.set("scopeRegionCode", appliedFilters.scopeRegionCode);
|
|
const response = await getData(`v1/fclts/pplAnalysis/summaries?${params}`) as PipeAnalysisSummaryResponse;
|
|
const materialColumns = response.materialColumns ?? [];
|
|
const rows = buildPipeAnalysisSummaryViewRows(response, ctpv, sgg);
|
|
const orderedYearMaterialGroups = buildPipeAnalysisYearMaterialGroups(rows, materialColumns)
|
|
.map((group, index) => ({ ...group, rowNo: index + 1 }));
|
|
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: (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE + index + 1,
|
|
})),
|
|
[yearMaterialGroups, summaryCurrentPage],
|
|
);
|
|
|
|
const sggOptions = useMemo(
|
|
() =>
|
|
draftFilters.ctpvCd === "0"
|
|
? []
|
|
: 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",
|
|
}))
|
|
}
|
|
>
|
|
{/* 시도는 전체 조회 없이 개별 지역만 선택한다. */}
|
|
{ctpv.filter((item) => String(item.val).trim() !== "0").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>
|
|
|
|
<PipeAnalysisSummaryTable
|
|
rows={rows}
|
|
groups={orderedYearMaterialGroups}
|
|
grouped={appliedFilters.analysisType === "YEAR_MATERIAL"}
|
|
heads={headList}
|
|
title={`${resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록`}
|
|
total={summaryTotal}
|
|
page={summaryCurrentPage}
|
|
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
|
|
onPageChange={(page) => {
|
|
void fetchSummary(appliedFilters, page)
|
|
.catch(() => alert("관로분석 페이지를 불러오지 못했습니다."));
|
|
}}
|
|
onCellClick={handleCellClick}
|
|
onGroupedCellClick={handleYearMaterialCellClick}
|
|
actions={
|
|
<Button type="button" onClick={() => void handleDownload()}>
|
|
엑셀다운로드
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
{/* 인수테스트 요구: 관로분석의 기반 자료(원천)를 목록 아래에 표시한다. */}
|
|
<PopupFooterNote
|
|
items={[
|
|
"국토교통부로부터 제공받은 GIS 관로정보(2025년)를 기반으로 분석하고 있음",
|
|
]}
|
|
/>
|
|
</StandardPopupLayout>
|
|
);
|
|
}
|