Files
frontend/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx
T
2026-08-05 16:05:07 +09:00

420 lines
16 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 } from "@common/business_components/ui/table";
import type { UiTableSort } from "@common/business_components/ui/table";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import { 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,
resolveMaterialKoreanName,
resolveMaterialName,
resolvePipeAnalysisTypeLabel,
resolveRegionName,
toPipeAnalysisDetailViewRows,
type PipeAnalysisDetailFilters,
type PipeAnalysisDetailRow,
} from "./pipeAnalysisUtils";
type DetailViewRow = ReturnType<typeof toPipeAnalysisDetailViewRows>[number];
const PIPE_ANALYSIS_DETAIL_PAGE_SIZE = 10;
const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
"rowNo",
"ctpvNm",
"sggNm",
"fcltMngNo",
"instMngNo",
"swtrmtZoneNm",
"instlYmd",
"useNm",
"pdmtVl",
"prlgVl",
"materialName",
] as const;
// 표시명 열은 원천 코드로, NO 열은 서버의 전체 순번 계약으로 정렬한다.
const PIPE_ANALYSIS_DETAIL_SORT_KEYS: Record<string, string> = {
useNm: "useCd",
materialName: "materialCode",
};
const PIPE_ANALYSIS_DETAIL_SORTABLE_HEADLIST = PIPE_ANALYSIS_DETAIL_HEADLIST.map((head) => ({
...head,
sortable: true,
sortKey: PIPE_ANALYSIS_DETAIL_SORT_KEYS[head.id] ?? head.id,
}));
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
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 [appliedFilters, setAppliedFilters] = 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 [sort, setSort] = useState<UiTableSort>();
const [appliedSort, setAppliedSort] = useState<UiTableSort>();
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));
setAppliedFilters((current) => applyScopeToDetailFilters(current, scope));
}, [scope]);
const fetchRows = useCallback(
async (page: number, nextFilters: PipeAnalysisDetailFilters, size: number, nextSort?: UiTableSort) => {
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 (nextSort) params.set("sort", `${nextSort.key},${nextSort.direction}`);
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, nextSort = sort) => {
setIsLoading(true);
try {
const response = await fetchRows(page, nextFilters, PIPE_ANALYSIS_DETAIL_PAGE_SIZE, nextSort);
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
setAppliedFilters(nextFilters);
setAppliedSort(nextSort);
} catch (error) {
console.error("관로현황 조회 중 오류:", error);
alert(resolveErrorMessage(error, "관로현황 조회 중 오류가 발생했습니다."));
setRows([]);
setTotal(0);
} finally {
setIsLoading(false);
}
},
[fetchRows, filters, sort],
);
useEffect(() => {
if (initializedRef.current) return;
if (ctpv.length === 0 || sgg.length === 0) return;
initializedRef.current = true;
const initialFilters = applyScopeToDetailFilters(filters, scope);
void loadRows(1, initialFilters);
}, [ctpv.length, filters, loadRows, scope, sgg.length]);
const handleSearch = () => {
setSelectedIndex(-1);
const nextFilters = applyScopeToDetailFilters(filters, scope);
void loadRows(1, nextFilters, sort);
};
/** Keeps the last searched filters and sort when the user moves pages. */
const handlePageChange = (page: number) => {
setSelectedIndex(-1);
void loadRows(page, appliedFilters, appliedSort);
};
/** Reloads the first server page with the selected grid sort. */
const handleSortChange = (nextSort?: UiTableSort) => {
setSort(nextSort);
setSelectedIndex(-1);
void loadRows(1, appliedFilters, nextSort);
};
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, appliedFilters, Math.max(total, 1000), appliedSort);
const exportRows = response.rows.map((row) => ({
rowNo: row.rowNo,
ctpvNm: row.ctpvNm,
sggNm: row.sggNm,
fcltMngNo: row.fcltMngNo,
instMngNo: row.instMngNo,
swtrmtZoneNm: row.swtrmtZoneNm,
instlYmd: row.instlYmd,
useNm: row.useNm,
pdmtVl: row.pdmtVl,
prlgVl: row.prlgVl,
materialName: resolveMaterialKoreanName(row.materialCode) || row.materialName,
}));
const detailHeaderRange = `A1:${String.fromCharCode(64 + PIPE_ANALYSIS_DETAIL_EXPORT_KEYS.length)}1`;
await downloadExcelWithKoreanHeaders({
data: exportRows as unknown as JSON[],
fileName: `관로분석_관로현황_${buildPipeAnalysisFileDate()}`,
sheetNameArray: ["관로현황"],
headerRange: detailHeaderRange,
headerMapping: PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS,
});
} catch (error) {
console.error("관로현황 다운로드 중 오류:", error);
alert(resolveErrorMessage(error, "관로현황 다운로드 중 오류가 발생했습니다."));
} 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="sn-popup-page">
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">관로현황{titleSuffix}</h1>
</header>
<section className="sn-popup-section overflow-y-hidden">
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary 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-label">용도</div>
<select
className="sn-popup-input !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-label">관재질</div>
<select
className="sn-popup-input !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-label">관경 시작</div>
<Input
className="sn-popup-input !text-[1.1rem]"
value={filters.diameterFrom}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterFrom: normalizeNumberInput(event.target.value),
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 종료</div>
<Input
className="sn-popup-input !text-[1.1rem]"
value={filters.diameterTo}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterTo: normalizeNumberInput(event.target.value),
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
}}
/>
</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}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
<UiTable<DetailViewRow>
headName={<>관로현황 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={PIPE_ANALYSIS_DETAIL_SORTABLE_HEADLIST}
pageSize={PIPE_ANALYSIS_DETAIL_PAGE_SIZE}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={handlePageChange}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
className="!m-0 overflow-x-hidden"
sort={sort}
onSortChange={handleSortChange}
/>
</div>
</section>
</main>
);
}