Files
frontend/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx
T
2026-05-06 14:42:21 +09:00

339 lines
12 KiB
TypeScript

"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>
);
}