관로분석 기능 구현
This commit is contained in:
@@ -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"),
|
||||
);
|
||||
};
|
||||
@@ -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 => (
|
||||
|
||||
Reference in New Issue
Block a user