관로분석 기능 구현

This commit is contained in:
kiminchan
2026-05-06 14:42:21 +09:00
parent 54bd38ae5f
commit 31ce13397e
15 changed files with 1759 additions and 13 deletions
@@ -203,8 +203,8 @@ export default function AgedPipePopupPage() {
</header>
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[140px_minmax(0,1fr)_140px_minmax(0,1fr)_auto_auto]">
<label className="block">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[160px_320px_320px_auto] md:items-end md:justify-between">
<label className="block md:max-w-[160px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년도</div>
<Input
className="h-10 !text-[1.1rem]"
@@ -226,7 +226,7 @@ export default function AgedPipePopupPage() {
/>
</label>
<label className="block">
<label className="block md:max-w-[320px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
@@ -248,7 +248,7 @@ export default function AgedPipePopupPage() {
</select>
</label>
<label className="block">
<label className="block md:max-w-[320px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
@@ -264,7 +264,7 @@ export default function AgedPipePopupPage() {
</select>
</label>
<div className="flex flex-wrap gap-2 md:col-span-3 md:justify-end">
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
<Button
type="button"
size="sm"
@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
type Props = {
onOpenPopup: (popupKey: WindowPopupKey) => void;
};
const ANALYSIS_POPUP_ITEMS: Array<{ key: WindowPopupKey; label: string }> = [
{ key: "pipeAnalysisPopup", label: "관로분석" },
{ key: "floodPredictionMapPopup", label: "침수예측지도" },
{ key: "floodHistoryPopup", label: "침수발생이력" },
{ key: "gisEditPopup", label: "GIS 편집" },
{ key: "gisManagementPopup", label: "GIS 관리" },
{ key: "flowRiskPopup", label: "통수 위험" },
];
export default function AnalysisPopupMenu({ onOpenPopup }: Props) {
const [isOpen, setIsOpen] = useState(true);
return (
<div className="absolute right-[0.35rem] top-[53%] z-[70]">
<div className="relative">
{isOpen ? (
<div className="absolute right-[21px] top-1/2 flex w-[86px] -translate-y-1/2 flex-col gap-[3px]">
{ANALYSIS_POPUP_ITEMS.map((item) => (
<button
key={item.key}
type="button"
className="min-h-[40px] rounded-md border border-primary bg-primary px-2 py-1 text-center text-[1rem] font-semibold leading-[1.2] text-white shadow transition hover:brightness-95"
onClick={() => onOpenPopup(item.key)}
>
<span className="break-keep whitespace-normal">{item.label}</span>
</button>
))}
</div>
) : null}
<button
type="button"
className="flex h-[36px] w-[18px] items-center justify-center rounded-sm border border-[#2f5c9b] bg-white text-[0.9rem] font-bold text-[#2f5c9b] shadow transition hover:bg-slate-50"
onClick={() => setIsOpen((value) => !value)}
title={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
aria-label={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
>
{isOpen ? ">" : "<"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,338 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import { useUserStore } from "@/shared/lib/use-user-store";
import {
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS,
PIPE_ANALYSIS_DETAIL_HEADLIST,
PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE,
applyScopeToDetailFilters,
buildPipeAnalysisFileDate,
buildPipeAnalysisScope,
normalizeNumberInput,
resolveElapsedYearBucketLabel,
resolveMaterialName,
resolvePipeAnalysisTypeLabel,
resolveRegionName,
toPipeAnalysisDetailViewRows,
type PipeAnalysisDetailFilters,
type PipeAnalysisDetailRow,
} from "./pipeAnalysisUtils";
type DetailViewRow = ReturnType<typeof toPipeAnalysisDetailViewRows>[number];
export default function PipeAnalysisDetailPopupPage() {
const searchParams = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const userInfo = useUserStore((state) => state.userInfo);
const scope = useMemo(() => buildPipeAnalysisScope(userInfo), [userInfo]);
const baseAnalysisType = searchParams.get("analysisType") ?? "YEAR";
const baseElapsedYearBucket = searchParams.get("elapsedYearBucket") ?? "";
const baseMaterialCode = searchParams.get("materialCode") ?? "0";
const [filters, setFilters] = useState<PipeAnalysisDetailFilters>(() =>
applyScopeToDetailFilters(
{
...PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
ctpvCd: searchParams.get("ctpvCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
sggCd: searchParams.get("sggCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
swtrmtZoneNm: searchParams.get("swtrmtZoneNm") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
materialCode: baseMaterialCode,
},
scope,
),
);
const [rows, setRows] = useState<DetailViewRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = 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(() => {
setFilters((current) => applyScopeToDetailFilters(current, scope));
}, [scope]);
const fetchRows = useCallback(
async (page: number, nextFilters: PipeAnalysisDetailFilters, size: number) => {
const params = new URLSearchParams({
analysisType: baseAnalysisType,
page: String(page - 1),
size: String(size),
});
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 (baseElapsedYearBucket) params.set("elapsedYearBucket", baseElapsedYearBucket);
if (nextFilters.materialCode !== "0") params.set("materialCode", nextFilters.materialCode);
if (nextFilters.useCd !== "0") params.set("useCd", nextFilters.useCd);
if (nextFilters.diameterFrom) params.set("diameterFrom", nextFilters.diameterFrom);
if (nextFilters.diameterTo) params.set("diameterTo", nextFilters.diameterTo);
if (scope.regionCode) params.set("scopeRegionCode", scope.regionCode);
const response = await getData(`v1/fclts/ppl-analysis/details?${params.toString()}`);
const responseRows = Array.isArray(response?.data) ? response.data : [];
return {
rows: toPipeAnalysisDetailViewRows(responseRows as PipeAnalysisDetailRow[], ctpv, sgg),
total: Number(response?.count ?? 0),
};
},
[baseAnalysisType, baseElapsedYearBucket, ctpv, scope.regionCode, sgg],
);
const loadRows = useCallback(
async (page = 1, nextFilters = filters) => {
setIsLoading(true);
try {
const response = await fetchRows(page, nextFilters, 20);
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
} catch (error) {
console.error("관로현황 조회 중 오류:", error);
alert("관로현황 조회 중 오류가 발생했습니다.");
setRows([]);
setTotal(0);
} finally {
setIsLoading(false);
}
},
[fetchRows, filters],
);
useEffect(() => {
if (initializedRef.current) return;
if (ctpv.length === 0 || sgg.length === 0) return;
initializedRef.current = true;
void loadRows(1, applyScopeToDetailFilters(filters, scope));
}, [ctpv.length, filters, loadRows, scope, sgg.length]);
const handleSearch = (page = 1) => {
setSelectedIndex(-1);
void loadRows(page, applyScopeToDetailFilters(filters, scope));
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelectedIndex(index);
const targetWindow = window.opener?.opener ?? window.opener;
targetWindow?.postMessage(
{
type: PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE,
row,
},
window.location.origin,
);
};
const handleDownload = async () => {
if (total === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
try {
setIsLoading(true);
const response = await fetchRows(1, filters, Math.max(total, 1000));
await downloadExcelWithKoreanHeaders({
data: response.rows as unknown as JSON[],
fileName: `관로분석_관로현황_${buildPipeAnalysisFileDate()}`,
sheetNameArray: ["관로현황"],
headerRange: "A1:K1",
headerMapping: PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS,
});
} catch (error) {
console.error("관로현황 다운로드 중 오류:", error);
alert("관로현황 다운로드 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const materialOptions = useMemo(() => {
const options = [
{ value: "0", label: "전체" },
...Array.from(
new Set(
rows
.map((row) => String(row.materialCode ?? "").trim())
.filter((value) => value && value !== "0"),
),
).map((value) => ({
value,
label: resolveMaterialName(value),
})),
];
if (
baseMaterialCode &&
baseMaterialCode !== "0" &&
!options.some((item) => item.value === baseMaterialCode)
) {
options.push({
value: baseMaterialCode,
label: resolveMaterialName(baseMaterialCode),
});
}
return options;
}, [baseMaterialCode, rows]);
const titleSuffix = useMemo(() => {
const locationParts: string[] = [];
const resolvedCtpvName = rows[0]?.ctpvNm || resolveRegionName(filters.ctpvCd, ctpv, 2);
const resolvedSggName = rows[0]?.sggNm || resolveRegionName(filters.sggCd, sgg, 5);
const resolvedZoneName =
(filters.swtrmtZoneNm && filters.swtrmtZoneNm !== "0" ? filters.swtrmtZoneNm : rows[0]?.swtrmtZoneNm) || "";
if (resolvedCtpvName && resolvedCtpvName !== "-") locationParts.push(resolvedCtpvName);
if (resolvedSggName && resolvedSggName !== "-") locationParts.push(resolvedSggName);
if (resolvedZoneName && resolvedZoneName !== "-" && resolvedZoneName !== "0") locationParts.push(resolvedZoneName);
const parts = [];
if (locationParts.length > 0) parts.push(locationParts.join(" "));
const analysisTypeLabel = resolvePipeAnalysisTypeLabel(baseAnalysisType);
if (analysisTypeLabel) parts.push(analysisTypeLabel);
if (baseElapsedYearBucket) parts.push(resolveElapsedYearBucketLabel(baseElapsedYearBucket));
if (baseMaterialCode && baseMaterialCode !== "0") parts.push(resolveMaterialName(baseMaterialCode));
return parts.length > 0 ? ` (${parts.join(" / ")})` : "";
}, [baseAnalysisType, baseElapsedYearBucket, baseMaterialCode, ctpv, filters.ctpvCd, filters.sggCd, filters.swtrmtZoneNm, rows, sgg]);
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">관로현황{titleSuffix}</h1>
</header>
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_110px_110px_auto] md:items-end">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">용도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.useCd}
onChange={(event) =>
setFilters((current) => ({
...current,
useCd: event.target.value,
}))
}
>
<option value="0">전체</option>
<option value="SBA001">합류관</option>
<option value="SBA002">차집관</option>
<option value="SBA003">우수관</option>
<option value="SBA004">오수관</option>
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">관재질</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.materialCode}
onChange={(event) =>
setFilters((current) => ({
...current,
materialCode: event.target.value,
}))
}
>
{materialOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">관경 시작</div>
<Input
className="h-10 !text-[1.1rem]"
value={filters.diameterFrom}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterFrom: normalizeNumberInput(event.target.value),
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch(1);
}}
/>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">관경 종료</div>
<Input
className="h-10 !text-[1.1rem]"
value={filters.diameterTo}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterTo: normalizeNumberInput(event.target.value),
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch(1);
}}
/>
</label>
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => handleSearch(1)}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
<UiTable<DetailViewRow>
headName={<>관로현황 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={PIPE_ANALYSIS_DETAIL_HEADLIST}
pageSize={20}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
/>
</section>
</main>
);
}
@@ -0,0 +1,526 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import { useUserStore } from "@/shared/lib/use-user-store";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
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 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,
});
};
export default function PipeAnalysisPopupPage() {
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 [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);
await fetchSummary(scopedFilters);
} catch (error) {
console.error("관로분석 집계 조회 중 오류:", error);
alert("관로분석 집계 조회 중 오류가 발생했습니다.");
setRows([]);
setMaterialColumns([]);
} finally {
setIsLoading(false);
}
},
[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;
yearMaterialGroups.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"
? yearMaterialGroups.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 JSON[],
fileName: `${SUMMARY_FILE_PREFIX[appliedFilters.analysisType]}_${buildPipeAnalysisFileDate()}`,
sheetNameArray: ["관로분석 현황"],
headerRange,
headerMapping: buildPipeAnalysisSummaryDownloadHeaders(appliedFilters.analysisType, materialColumns),
mergeRanges,
});
} catch (error) {
console.error("관로분석 집계 다운로드 중 오류:", error);
alert("관로분석 집계 다운로드 중 오류가 발생했습니다.");
}
};
const handleCellClick = (index?: number, columnId?: string) => {
if (typeof index !== "number" || !columnId) return;
const row = rows[index];
if (!row) return;
if (row.__isNoZone || row.__isSummaryTotal) return;
const cellFilter = row.__cellFilters[columnId];
if (!cellFilter) return;
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,
});
};
const handleYearMaterialCellClick = (
group: PipeAnalysisYearMaterialGroup,
row: PipeAnalysisYearMaterialGroup["rows"][number],
columnId: string,
) => {
const cellFilter = row.cellFilters[columnId];
if (!cellFilter) return;
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,
});
};
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 useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR";
const sggOptions = useMemo(
() =>
draftFilters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(draftFilters.ctpvCd.slice(0, 2))),
[draftFilters.ctpvCd, sgg],
);
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">관로분석</h1>
</header>
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
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="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
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="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">처리구역</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
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="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">분석구분</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
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 flex-wrap justify-end gap-2 md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch()}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
{appliedFilters.analysisType === "YEAR_MATERIAL" ? (
<>
<div className="card-header">
<h3 className="hidden">
寃쎄낵?꾨룄&?ъ쭏 愿€濡쒗쁽?? 珥?{yearMaterialGroups.length.toLocaleString("ko-KR")}嫄?
</h3>
<h3 className="text-2xl font-medium text-label">
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 총 {yearMaterialGroups.length.toLocaleString("ko-KR")}건
</h3>
</div>
<div className="card !p-0r !m-[1.2rem] overflow-x-auto">
<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-[#eaf3fb] px-4 py-[1.2rem] text-[1.3rem] font-medium text-[#647784] ${
head.align === "right" ? "text-right" : "text-center"
}`}
>
{head.title}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="text-center">
{yearMaterialGroups.length > 0 ? (
yearMaterialGroups.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-blue-700 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-gray-500">
議고쉶??寃곌낵媛€ ?놁뒿?덈떎.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</>
) : null}
<UiTable<PipeAnalysisSummaryViewRow>
className={appliedFilters.analysisType === "YEAR_MATERIAL"
? "hidden"
: useHorizontalScrollTable
? "[&>div.relative.w-full]:overflow-x-auto [&>div.relative.w-full]:overflow-y-visible"
: ""}
headName={
appliedFilters.analysisType === "YEAR_MATERIAL"
? "경과년도&재질 관로현황"
: "관로분석현황"
}
tableData={rows}
headlist={headList}
pageSize={Math.max(rows.length, 1)}
total={rows.length}
cellClick={(index, columnId) => handleCellClick(index, columnId)}
onPageChange={() => undefined}
curPage={1}
widthFixed={!useHorizontalScrollTable}
cellClassName={(row, columnId) =>
!row.__isNoZone && row.__cellFilters[columnId]
? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2"
: ""
}
/>
</section>
</main>
);
}
@@ -0,0 +1,628 @@
"use client";
import type { SelectDataType } from "@common/business_components";
import type { IUserInfo } from "@/shared/store";
import {
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_USE_CODES,
} from "../common/table";
export const PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE = "SN_PIPE_ANALYSIS_DETAIL_ROW_SELECT";
export type PipeAnalysisType = "YEAR" | "MATERIAL" | "YEAR_MATERIAL";
export type PipeAnalysisSummaryFilters = {
analysisType: PipeAnalysisType;
ctpvCd: string;
sggCd: string;
swtrmtZoneNm: string;
scopeRegionCode: string;
};
export type PipeAnalysisDetailFilters = {
ctpvCd: string;
sggCd: string;
swtrmtZoneNm: string;
useCd: string;
materialCode: string;
diameterFrom: string;
diameterTo: string;
};
export type PipeAnalysisMaterialColumn = {
key: string;
materialCode: string;
materialName: string;
};
export type PipeAnalysisSummaryRow = {
rowNo: number;
ctpvCd: string;
ctpvNm?: string;
sggCd: string;
sggNm?: string;
swtrmtZoneNm: string;
totalLengthKm: number;
elapsedYearBucket?: string;
elapsedYearLabel?: string;
metrics: Record<string, number>;
};
export type PipeAnalysisSummaryResponse = {
analysisType: PipeAnalysisType;
materialColumns: PipeAnalysisMaterialColumn[];
data: PipeAnalysisSummaryRow[];
};
export type PipeAnalysisDetailRow = {
rowNo: number;
fid: string;
lyrTypeNm: string;
fcltMngNo: string;
instMngNo: string;
mngInstCd: string;
ctpvCd: string;
ctpvNm?: string;
sggCd: string;
sggNm?: string;
swtrmtZoneNm: string;
instlYmd: string;
elapsedYears?: number;
useCd: string;
materialCode: string;
pdmtVl?: number | string;
prlgVl?: number | string;
};
export type PipeAnalysisZoneOption = {
value: string;
label: string;
ctpvCd: string;
sggCd: string;
};
export type PipeAnalysisScope = {
regionCode: string;
ctpvCd: string;
sggCd: string;
ctpvLocked: boolean;
sggLocked: boolean;
};
export type PipeAnalysisCellFilter = {
elapsedYearBucket?: string;
materialCode?: string;
};
export type PipeAnalysisSummaryViewRow = Record<string, unknown> & {
__isNoZone: boolean;
__isSummaryTotal: boolean;
__cellFilters: Record<string, PipeAnalysisCellFilter>;
__base: {
ctpvCd: string;
sggCd: string;
swtrmtZoneNm: string;
};
};
export type PipeAnalysisYearMaterialGroupRow = {
elapsedYearBucket: string;
elapsedYearLabel: string;
metrics: Record<string, number>;
cellFilters: Record<string, PipeAnalysisCellFilter>;
};
export type PipeAnalysisYearMaterialGroup = {
rowNo: number;
ctpvNm: string;
sggNm: string;
swtrmtZoneNm: string;
totalLengthKm: number;
base: {
ctpvCd: string;
sggCd: string;
swtrmtZoneNm: string;
};
rows: PipeAnalysisYearMaterialGroupRow[];
};
const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]));
const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text]));
export const PIPE_ANALYSIS_TYPE_OPTIONS: Array<{ value: PipeAnalysisType; label: string }> = [
{ value: "YEAR", label: "경과년도별" },
{ value: "MATERIAL", label: "재질별" },
{ value: "YEAR_MATERIAL", label: "경과년도&재질" },
];
export const PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS: PipeAnalysisSummaryFilters = {
analysisType: "YEAR",
ctpvCd: "0",
sggCd: "0",
swtrmtZoneNm: "0",
scopeRegionCode: "",
};
export const PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS: PipeAnalysisDetailFilters = {
ctpvCd: "0",
sggCd: "0",
swtrmtZoneNm: "0",
useCd: "0",
materialCode: "0",
diameterFrom: "",
diameterTo: "",
};
export const PIPE_ANALYSIS_FIXED_YEAR_COLUMNS = [
{ key: "LT10", title: "10년 미만(km)" },
{ key: "Y10_20", title: "10년~20년(km)" },
{ key: "Y20_30", title: "20년~30년(km)" },
{ key: "GE30", title: "30년 이상(km)" },
{ key: "UNKNOWN", title: "확인 불가(km)" },
];
const elapsedYearBucketLabelMap: Record<string, string> = {
LT10: "10년 미만",
Y10_20: "10년~20년",
Y20_30: "20년~30년",
GE30: "30년 이상",
UNKNOWN: "확인 불가",
};
const YEAR_MATERIAL_BASE_BUCKETS = ["LT10", "Y10_20", "Y20_30", "GE30"];
export const PIPE_ANALYSIS_DETAIL_HEADLIST = [
{ id: "rowNo", title: "NO", widthPercent: 6 },
{ id: "ctpvNm", title: "시도", widthPercent: 9 },
{ id: "sggNm", title: "시군구", widthPercent: 10 },
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 15 },
{ id: "instMngNo", title: "기관관리번호", widthPercent: 15 },
{ id: "swtrmtZoneNm", title: "처리구역", widthPercent: 12 },
{ id: "instlYmd", title: "설치일자", widthPercent: 10 },
{ id: "useNm", title: "용도", widthPercent: 9 },
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 8, align: "right" },
{ id: "prlgVl", title: "연장(m)", widthPercent: 8, align: "right" },
{ id: "materialName", title: "관재질", widthPercent: 12 },
];
export const buildPipeAnalysisScope = (userInfo: IUserInfo | null): PipeAnalysisScope => {
const admin = userInfo?.adminRoleYn === "Y";
if (admin) {
return {
regionCode: "",
ctpvCd: "",
sggCd: "",
ctpvLocked: false,
sggLocked: false,
};
}
const digits = String(userInfo?.laeSeCd ?? "").replace(/[^0-9]/g, "");
if (digits.length >= 5) {
return {
regionCode: digits.slice(0, 5),
ctpvCd: digits.slice(0, 2),
sggCd: digits.slice(0, 5),
ctpvLocked: true,
sggLocked: true,
};
}
if (digits.length >= 2) {
return {
regionCode: digits.slice(0, 2),
ctpvCd: digits.slice(0, 2),
sggCd: "",
ctpvLocked: true,
sggLocked: false,
};
}
return {
regionCode: "",
ctpvCd: "",
sggCd: "",
ctpvLocked: false,
sggLocked: false,
};
};
export const applyScopeToSummaryFilters = (
filters: PipeAnalysisSummaryFilters,
scope: PipeAnalysisScope,
): PipeAnalysisSummaryFilters => {
const next = { ...filters, scopeRegionCode: scope.regionCode };
if (scope.ctpvCd) {
next.ctpvCd = scope.ctpvCd;
}
if (scope.sggCd) {
next.sggCd = scope.sggCd;
} else if (scope.ctpvCd && !next.sggCd.startsWith(scope.ctpvCd)) {
next.sggCd = "0";
}
return next;
};
export const applyScopeToDetailFilters = (
filters: PipeAnalysisDetailFilters,
scope: PipeAnalysisScope,
): PipeAnalysisDetailFilters => {
const next = { ...filters };
if (scope.ctpvCd) {
next.ctpvCd = scope.ctpvCd;
}
if (scope.sggCd) {
next.sggCd = scope.sggCd;
} else if (scope.ctpvCd && !next.sggCd.startsWith(scope.ctpvCd)) {
next.sggCd = "0";
}
return next;
};
export const buildPipeAnalysisSummaryHeadList = (
analysisType: PipeAnalysisType,
materialColumns: PipeAnalysisMaterialColumn[],
) => {
const common = [
{ id: "rowNo", title: "NO", widthPercent: 6 },
{ id: "ctpvNm", title: "시도", widthPercent: 10 },
{ id: "sggNm", title: "시군구", widthPercent: 10 },
{ id: "swtrmtZoneNm", title: "처리구역", widthPercent: 14 },
{ id: "totalLengthKm", title: "합계(km)", widthPercent: 10, align: "right" },
];
if (analysisType === "YEAR") {
return [
...common,
...PIPE_ANALYSIS_FIXED_YEAR_COLUMNS.map((column) => ({
id: column.key,
title: column.title,
widthPercent: 12,
align: "right" as const,
})),
];
}
const materialHeads = materialColumns.map((column) => ({
id: column.key,
title: resolveMaterialColumnName(column),
widthPercent: 12,
align: "right" as const,
}));
if (analysisType === "YEAR_MATERIAL") {
return [
...common,
{ id: "elapsedYearLabel", title: "경과년도", widthPercent: 12 },
...materialHeads,
];
}
return [...common, ...materialHeads];
};
export const buildPipeAnalysisSummaryViewRows = (
response: PipeAnalysisSummaryResponse,
ctpvOptions: SelectDataType[],
sggOptions: SelectDataType[],
): PipeAnalysisSummaryViewRow[] => {
const materialCodes = response.materialColumns.map((column) => column.key);
return response.data.map((row) => {
const isNoZone = String(row.swtrmtZoneNm ?? "").trim() === "NO_ZONE";
const isSummaryTotal = !String(row.ctpvCd ?? "").trim()
&& String(row.swtrmtZoneNm ?? "").trim() === "-";
const viewRow: PipeAnalysisSummaryViewRow = {
rowNo: row.rowNo,
ctpvNm: isNoZone ? "전체" : row.ctpvNm || resolveRegionName(row.ctpvCd, ctpvOptions, 2),
sggNm: isNoZone ? "-" : row.sggNm || resolveRegionName(row.sggCd, sggOptions, 5),
swtrmtZoneNm: isNoZone ? "-" : row.swtrmtZoneNm || "-",
totalLengthKm: row.totalLengthKm ?? 0,
elapsedYearBucket: row.elapsedYearBucket ?? "",
elapsedYearLabel: row.elapsedYearLabel ?? "",
__isNoZone: isNoZone,
__isSummaryTotal: isSummaryTotal,
__cellFilters: {},
__base: {
ctpvCd: isNoZone ? "" : row.ctpvCd,
sggCd: isNoZone ? "" : row.sggCd,
swtrmtZoneNm: isNoZone ? "NO_ZONE" : row.swtrmtZoneNm,
},
};
if (isSummaryTotal) {
viewRow.ctpvNm = row.ctpvNm || viewRow.ctpvNm;
viewRow.sggNm = row.sggNm || "-";
viewRow.swtrmtZoneNm = "-";
viewRow.__base.ctpvCd = "";
viewRow.__base.sggCd = "";
viewRow.__base.swtrmtZoneNm = "";
}
if (response.analysisType === "YEAR") {
if (isNoZone || isSummaryTotal) {
PIPE_ANALYSIS_FIXED_YEAR_COLUMNS.forEach((column) => {
viewRow[column.key] = Number(row.metrics?.[column.key] ?? 0);
});
return viewRow;
}
PIPE_ANALYSIS_FIXED_YEAR_COLUMNS.forEach((column) => {
const value = Number(row.metrics?.[column.key] ?? 0);
viewRow[column.key] = value;
if (value > 0) {
viewRow.__cellFilters[column.key] = {
elapsedYearBucket: column.key,
};
}
});
return viewRow;
}
const columns = response.analysisType === "YEAR_MATERIAL"
? materialCodes
: materialCodes;
columns.forEach((columnKey) => {
const value = Number(row.metrics?.[columnKey] ?? 0);
viewRow[columnKey] = value;
if (!isNoZone && !isSummaryTotal && value > 0) {
viewRow.__cellFilters[columnKey] = {
elapsedYearBucket: response.analysisType === "YEAR_MATERIAL" ? row.elapsedYearBucket : undefined,
materialCode: columnKey === "OTHER" ? "" : columnKey,
};
}
});
return viewRow;
});
};
export const buildPipeAnalysisYearMaterialGroups = (
rows: PipeAnalysisSummaryViewRow[],
materialColumns: PipeAnalysisMaterialColumn[],
): PipeAnalysisYearMaterialGroup[] => {
const materialKeys = materialColumns.map((column) => column.key);
const groupMap = new Map<string, PipeAnalysisYearMaterialGroup>();
rows.forEach((row) => {
if (row.__isNoZone || row.__isSummaryTotal) {
return;
}
const groupKey = `${row.__base.ctpvCd}|${row.__base.sggCd}|${row.__base.swtrmtZoneNm}`;
const elapsedYearBucket = String(row.elapsedYearBucket ?? "").trim();
let group = groupMap.get(groupKey);
if (!group) {
group = {
rowNo: groupMap.size + 1,
ctpvNm: String(row.ctpvNm ?? ""),
sggNm: String(row.sggNm ?? ""),
swtrmtZoneNm: String(row.swtrmtZoneNm ?? ""),
totalLengthKm: Number(row.totalLengthKm ?? 0),
base: {
ctpvCd: row.__base.ctpvCd,
sggCd: row.__base.sggCd,
swtrmtZoneNm: row.__base.swtrmtZoneNm,
},
rows: [],
};
groupMap.set(groupKey, group);
}
const metrics: Record<string, number> = {};
materialKeys.forEach((materialKey) => {
metrics[materialKey] = Number(row[materialKey] ?? 0);
});
group.rows.push({
elapsedYearBucket,
elapsedYearLabel: String(row.elapsedYearLabel ?? resolveElapsedYearBucketLabel(elapsedYearBucket)),
metrics,
cellFilters: row.__cellFilters,
});
});
return Array.from(groupMap.values()).map((group) => {
const rowByBucket = new Map(group.rows.map((row) => [row.elapsedYearBucket, row]));
const orderedBuckets = [...YEAR_MATERIAL_BASE_BUCKETS];
if (rowByBucket.has("UNKNOWN")) {
orderedBuckets.push("UNKNOWN");
}
const normalizedRows = orderedBuckets.map((bucket) => {
const existing = rowByBucket.get(bucket);
if (existing) {
return existing;
}
const emptyMetrics: Record<string, number> = {};
materialKeys.forEach((materialKey) => {
emptyMetrics[materialKey] = 0;
});
return {
elapsedYearBucket: bucket,
elapsedYearLabel: resolveElapsedYearBucketLabel(bucket),
metrics: emptyMetrics,
cellFilters: {},
};
});
return {
...group,
rows: normalizedRows,
};
});
};
export const toPipeAnalysisDetailViewRows = (
rows: PipeAnalysisDetailRow[],
ctpvOptions: SelectDataType[],
sggOptions: SelectDataType[],
) =>
rows.map((row) => ({
...row,
ctpvNm: row.ctpvNm || resolveRegionName(row.ctpvCd, ctpvOptions, 2),
sggNm: row.sggNm || resolveRegionName(row.sggCd, sggOptions, 5),
useNm: resolveUseName(row.useCd),
materialName: resolveMaterialName(row.materialCode),
}));
export const buildPipeAnalysisSummaryDownloadHeaders = (
analysisType: PipeAnalysisType,
materialColumns: PipeAnalysisMaterialColumn[],
) => {
const headers: Record<string, string> = {
rowNo: "NO",
ctpvNm: "시도",
sggNm: "시군구",
swtrmtZoneNm: "처리구역",
totalLengthKm: "합계(km)",
};
if (analysisType === "YEAR_MATERIAL") {
headers.elapsedYearLabel = "경과년도";
}
if (analysisType === "YEAR") {
PIPE_ANALYSIS_FIXED_YEAR_COLUMNS.forEach((column) => {
headers[column.key] = column.title;
});
return headers;
}
materialColumns.forEach((column) => {
headers[column.key] = resolveMaterialColumnName(column);
});
return headers;
};
export const PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS = {
rowNo: "NO",
ctpvNm: "시도",
sggNm: "시군구",
fcltMngNo: "시설관리번호",
instMngNo: "기관관리번호",
swtrmtZoneNm: "처리구역",
instlYmd: "설치일자",
useNm: "용도",
pdmtVl: "관경(mm)",
prlgVl: "연장(m)",
materialName: "관재질",
};
export const resolveMaterialName = (materialCode?: string) => {
const code = String(materialCode ?? "").trim();
if (!code) return "-";
if (code === "OTHER") return "기타";
return materialMap.get(code) ?? code;
};
export const resolveMaterialColumnName = (column: PipeAnalysisMaterialColumn) => {
const rawName = String(column.materialName ?? "").trim();
const rawCode = String(column.materialCode ?? "").trim();
const rawKey = String(column.key ?? "").trim();
if (!rawName) {
return resolveMaterialName(rawCode || rawKey);
}
if (rawName === rawCode || rawName === rawKey) {
return resolveMaterialName(rawCode || rawKey);
}
return rawName;
};
export const resolveElapsedYearBucketLabel = (bucket?: string) => {
const normalized = String(bucket ?? "").trim();
return elapsedYearBucketLabelMap[normalized] ?? normalized;
};
export const resolvePipeAnalysisTypeLabel = (analysisType?: string) => {
const normalized = String(analysisType ?? "").trim();
return (
PIPE_ANALYSIS_TYPE_OPTIONS.find((option) => option.value === normalized)?.label
?? normalized
);
};
export const resolveUseName = (useCode?: string) => {
const code = String(useCode ?? "").trim();
if (!code) return "-";
return useMap.get(code) ?? code;
};
export const resolveRegionName = (
code: string,
options: SelectDataType[],
prefixLength: number,
) => {
const normalized = String(code ?? "").trim();
if (!normalized) return "-";
const prefix = normalized.slice(0, prefixLength);
return options.find((item) => String(item.val).trim() === prefix)?.text ?? prefix;
};
export const normalizeNumberInput = (value: string) => value.replace(/[^0-9]/g, "");
export const buildPipeAnalysisFileDate = () => new Date().toISOString().slice(0, 10);
export const fetchPipeAnalysisZoneOptions = async ({
ctpvCd,
sggCd,
scopeRegionCode,
}: {
ctpvCd: string;
sggCd: string;
scopeRegionCode?: string;
}): Promise<PipeAnalysisZoneOption[]> => {
const filters: string[] = [];
if (sggCd && sggCd !== "0") {
filters.push(`sgg_cd='${sggCd}'`);
} else if (ctpvCd && ctpvCd !== "0") {
filters.push(`ctpv_cd='${ctpvCd}'`);
} else if (scopeRegionCode) {
if (scopeRegionCode.length >= 5) {
filters.push(`sgg_cd='${scopeRegionCode.slice(0, 5)}'`);
} else if (scopeRegionCode.length >= 2) {
filters.push(`ctpv_cd='${scopeRegionCode.slice(0, 2)}'`);
}
}
const params = new URLSearchParams({
service: "WFS",
version: "1.0.0",
request: "GetFeature",
typeName: "hasudo:tb_ms_swage_prcs_zone",
outputFormat: "application/json",
propertyName: "ctpv_cd,sgg_cd,swtrmt_zone_nm",
});
if (filters.length > 0) {
params.set("CQL_FILTER", filters.join(" and "));
}
const response = await fetch(`/geoserver/hasudo/ows?${params.toString()}`);
if (!response.ok) {
throw new Error("처리구역 목록을 불러오지 못했습니다.");
}
const data = await response.json();
const features = Array.isArray(data?.features) ? data.features : [];
const dedupMap = new Map<string, PipeAnalysisZoneOption>();
features.forEach((feature: any) => {
const properties = feature?.properties ?? {};
const zoneName = String(properties.swtrmt_zone_nm ?? "").trim();
if (!zoneName) return;
dedupMap.set(zoneName, {
value: zoneName,
label: zoneName,
ctpvCd: String(properties.ctpv_cd ?? "").trim(),
sggCd: String(properties.sgg_cd ?? "").trim(),
});
});
return Array.from(dedupMap.values()).sort((left, right) =>
left.label.localeCompare(right.label, "ko"),
);
};
+48 -1
View File
@@ -17,6 +17,7 @@
"use client";
import dynamic from "next/dynamic";
import Gis from "@common/business_components/ui/map/Gis";
import { getData, getRawJson } from "../../components/lib/api";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -30,6 +31,7 @@ import useSearchModalStore from "@/store/useSearchModalStore";
import useStore from "@/store/useStore";
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel";
import type { PipeAnalysisDetailRow } from "./components/pipeAnalysis/pipeAnalysisUtils";
import {
AGED_PIPE_EXCEL_HEADER,
AGED_PIPE_MIN_ZOOM,
@@ -45,9 +47,16 @@ import {
type AgedPipeRow,
AGED_PIPE_DEFAULT_FILTERS,
} from "./components/agedPipe/agedPipeUtils";
import { PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE } from "./components/pipeAnalysis/pipeAnalysisUtils";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
const AnalysisPopupMenu = dynamic(
() => import("./components/analysisMenu/AnalysisPopupMenu"),
{ ssr: false },
);
const AGED_PIPE_CONTROL_NODE_ID = "SN_AGED_PIPE";
const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT";
const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
@@ -125,6 +134,7 @@ const Map = () => {
const { ctpv, sgg } = useStore();
const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
const agedPipePopupWindowRef = useRef<Window | null>(null);
const pipeAnalysisPopupWindowRef = useRef<Window | null>(null);
useEffect(() => {
fetchData();
@@ -385,6 +395,24 @@ const Map = () => {
[setIsSearchModalOpen],
);
const handlePipeAnalysisRowSelect = useCallback(
async (row: Pick<PipeAnalysisDetailRow, "fid">) => {
try {
shouldFocusAgedPipeOnNextSelectRef.current = true;
const core = GisApp.Module.layer as any;
await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid);
} catch (error) {
shouldFocusAgedPipeOnNextSelectRef.current = false;
console.error("관로 위치 이동 중 오류:", error);
}
setFeatureData(row as any);
setIsOpen(true);
setIsSearchModalOpen(false);
},
[setIsSearchModalOpen],
);
const handleAgedPipeDownload = useCallback(async () => {
if (agedPipeTotalCount === 0) {
alert("다운로드할 데이터가 없습니다.");
@@ -428,12 +456,18 @@ const Map = () => {
}
setAgedPipeTotalCount(Number(event.data?.total ?? agedPipeTotalCount));
setIsAgedPipeListMinimized(true);
return;
}
if (event.data?.type === PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE) {
if (!event.data?.row) return;
handlePipeAnalysisRowSelect(event.data.row as PipeAnalysisDetailRow);
}
};
window.addEventListener("message", handleAgedPipePopupMessage);
return () => window.removeEventListener("message", handleAgedPipePopupMessage);
}, [agedPipeTotalCount, handleAgedPipeRowDoubleClick]);
}, [agedPipeTotalCount, handleAgedPipeRowDoubleClick, handlePipeAnalysisRowSelect]);
// WaterDepth 표시 여부를 결정하는 함수
const handleWaterDepthVisibility = useCallback((checkedIds: string[], checkedNodesData: any[]) => {
@@ -521,6 +555,18 @@ const Map = () => {
};
}, [appliedAgedPipeFilters, isAgedPipeChecked, mapReady]);
const openAnalysisPopup = useCallback((popupKey: WindowPopupKey) => {
const popup = openWindowPopup(popupKey);
if (!popup) {
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
return;
}
if (popupKey === "pipeAnalysisPopup") {
pipeAnalysisPopupWindowRef.current = popup;
}
}, []);
return (
<>
@@ -530,6 +576,7 @@ const Map = () => {
레이어 설정 조회 실패: {layerLoadError}
</div>
) : null}
<AnalysisPopupMenu onOpenPopup={openAnalysisPopup} />
<Gis
treeData={treeData}
isSearch={true}
@@ -73,6 +73,48 @@ export const windowPopupDefinitions = {
width: 1180,
height: 760,
},
pipeAnalysisPopup: {
key: "pipeAnalysisPopup",
title: "관로분석",
width: 1180,
height: 820,
},
pipeAnalysisDetailPopup: {
key: "pipeAnalysisDetailPopup",
title: "관로현황",
width: 1180,
height: 760,
},
floodPredictionMapPopup: {
key: "floodPredictionMapPopup",
title: "침수예측지도",
width: 1080,
height: 760,
},
floodHistoryPopup: {
key: "floodHistoryPopup",
title: "침수발생이력",
width: 1080,
height: 760,
},
gisEditPopup: {
key: "gisEditPopup",
title: "GIS 편집",
width: 1080,
height: 760,
},
gisManagementPopup: {
key: "gisManagementPopup",
title: "GIS 관리",
width: 1080,
height: 760,
},
flowRiskPopup: {
key: "flowRiskPopup",
title: "통수 위험",
width: 1080,
height: 760,
},
} as const satisfies Record<string, WindowPopupDefinition>;
export type WindowPopupKey = keyof typeof windowPopupDefinitions;
@@ -11,6 +11,36 @@ import ReportFilesPopupContent from "./ui/ReportFilesPopupContent";
import ReportTabPopupContent from "./ui/ReportTabPopupContent";
import SearchModalPopupContent from "./ui/SearchModalPopupContent";
import SscDetailPopupContent from "./ui/SscDetailPopupContent";
import type { WindowPopupContentProps } from "./model/types";
import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent";
import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent";
import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent";
const FloodPredictionMapPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent
title={windowPopupDefinitions.floodPredictionMapPopup.title}
/>
);
const FloodHistoryPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent
title={windowPopupDefinitions.floodHistoryPopup.title}
/>
);
const GisEditPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent title={windowPopupDefinitions.gisEditPopup.title} />
);
const GisManagementPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent
title={windowPopupDefinitions.gisManagementPopup.title}
/>
);
const FlowRiskPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent title={windowPopupDefinitions.flowRiskPopup.title} />
);
export const windowPopupRegistry = {
oldPpUpload: {
@@ -57,4 +87,32 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.agedPipePopup,
component: AgedPipePopupContent,
},
pipeAnalysisPopup: {
...windowPopupDefinitions.pipeAnalysisPopup,
component: PipeAnalysisPopupContent,
},
pipeAnalysisDetailPopup: {
...windowPopupDefinitions.pipeAnalysisDetailPopup,
component: PipeAnalysisDetailPopupContent,
},
floodPredictionMapPopup: {
...windowPopupDefinitions.floodPredictionMapPopup,
component: FloodPredictionMapPopupContent,
},
floodHistoryPopup: {
...windowPopupDefinitions.floodHistoryPopup,
component: FloodHistoryPopupContent,
},
gisEditPopup: {
...windowPopupDefinitions.gisEditPopup,
component: GisEditPopupContent,
},
gisManagementPopup: {
...windowPopupDefinitions.gisManagementPopup,
component: GisManagementPopupContent,
},
flowRiskPopup: {
...windowPopupDefinitions.flowRiskPopup,
component: FlowRiskPopupContent,
},
} as const satisfies Record<WindowPopupKey, WindowPopupRegistryItem>;
@@ -0,0 +1,25 @@
type Props = {
title: string;
};
export default function FeaturePlaceholderPopupContent({ title }: Props) {
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<header className="flex min-h-[44px] items-center border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">{title}</h1>
</header>
<section className="flex flex-1 items-center justify-center bg-slate-50 p-8">
<div className="w-full max-w-[520px] rounded-md border border-slate-200 bg-white p-8 text-center shadow-sm">
<h2 className="text-[1.35rem] font-semibold text-slate-800">{title}</h2>
<p className="mt-4 text-[1.1rem] leading-7 text-slate-600">
현재는 팝업 창 구조만 우선 연결했습니다.
</p>
<p className="mt-2 text-[1.05rem] leading-7 text-slate-500">
상세 기능은 추후 단계에서 구현될 예정입니다.
</p>
</div>
</section>
</main>
);
}
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import PipeAnalysisDetailPopupPage from "@/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage";
export default function PipeAnalysisDetailPopupContent() {
return (
<Suspense fallback={null}>
<PipeAnalysisDetailPopupPage />
</Suspense>
);
}
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import PipeAnalysisPopupPage from "@/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage";
export default function PipeAnalysisPopupContent() {
return (
<Suspense fallback={null}>
<PipeAnalysisPopupPage />
</Suspense>
);
}
@@ -70,7 +70,8 @@ export const excelStyleWithKoreanHeaders = async (
range: string = "A1:V3",
fileNm: string,
headerMapping: { [key: string]: string },
sampleData: any
sampleData: any,
mergeRanges?: string[]
) => {
const workbook = await XlsxPopulate.Workbook.fromData(data);
const sheet = workbook.activeSheet();
@@ -83,7 +84,7 @@ export const excelStyleWithKoreanHeaders = async (
const englishHeaders = Object.keys(sampleData);
englishHeaders.forEach((englishHeader, index) => {
const koreanHeader = headerMapping[englishHeader] || englishHeader;
const cellAddress = String.fromCharCode(65 + index) + '1'; // A1, B1, C1, ...
const cellAddress = `${getExcelColumnLetter(index + 1)}1`;
sheet.cell(cellAddress).value(koreanHeader);
});
@@ -98,13 +99,20 @@ export const excelStyleWithKoreanHeaders = async (
const numRows = sheet.usedRange()?.endCell()?.rowNumber() || 1;
if (numRows > 1) {
const dataRange = sheet.range(`A1:${String.fromCharCode(65 + numCols - 1)}${numRows}`);
const lastColLetter = getExcelColumnLetter(numCols);
const dataRange = sheet.range(`A1:${lastColLetter}${numRows}`);
dataRange?.style((styleQ) => {
styleQ.verticalAlignment("center");
styleQ.borderEach({ style: "thin", color: { rgb: "CCCCCC" } });
});
}
if (mergeRanges && mergeRanges.length > 0) {
mergeRanges.forEach((mergeRange) => {
sheet.range(mergeRange).merged(true);
});
}
// 4. 파일 다운로드
await makeExcelFile(workbook, fileNm);
};
@@ -407,6 +407,7 @@ type ExcelDownloadWithHeaderProps = {
sheetNameArray: string[];
headerRange?: string;
headerMapping: { [key: string]: string };
mergeRanges?: string[];
}
export const downloadExcelWithKoreanHeaders = async ({
@@ -414,7 +415,8 @@ export const downloadExcelWithKoreanHeaders = async ({
fileName = new Date().getTime().toString() + '.xlsx',
sheetNameArray = ["Sheet1"],
headerRange = 'A1:V3',
headerMapping
headerMapping,
mergeRanges,
}: ExcelDownloadWithHeaderProps) => {
const dataArray = Array.isArray(data) ? data : [data];
@@ -432,5 +434,5 @@ export const downloadExcelWithKoreanHeaders = async ({
const buffer = XLSX.write(workbook, { bookType: "xlsx", type: "buffer" });
// 3. 헤더만 한글로 교체하는 커스텀 스타일 함수 호출
await excelStyleWithKoreanHeaders(buffer, headerRange, fileName, headerMapping, dataArray[0]);
await excelStyleWithKoreanHeaders(buffer, headerRange, fileName, headerMapping, dataArray[0], mergeRanges);
};
@@ -343,7 +343,7 @@ function Gis({
</div>
{isSearch && (
<div className="absolute right-8 top-[305px] z-20">
<div className="absolute right-8 top-[248px] z-20">
<button
onClick={() => setIsSearchModalOpen(true)}
title="정보 검색"
@@ -152,7 +152,7 @@ export function UiTable<T extends object = Record<string, unknown>>({
<></>
)}
<div className={`card !p-0r !m-[1.2rem] ${className}`}>
<Table variant="secondary" className={widthFixed ? "table-fixed" : ""}>
<Table variant="secondary" className={widthFixed ? "table-fixed" : "w-max min-w-max"}>
{widthFixed ?
<colgroup>
{leafCols.map(col => (