Files
frontend/apps/sn/app/(map-layout)/map/components/aged-pipe-status-by-ctpv/aged-pipe-status-by-ctpv-popup-page.tsx
T

316 lines
10 KiB
TypeScript

/**
* 화면명: 시도별 노후관로현황
* 기준연도별 시도 단위 노후관로 현황을 조회하고 엑셀로 내려받는다.
*/
"use client";
import { getSnRowNumber } from "@/shared/lib/row-number";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components";
import {
StandardDataTable,
StandardPopupLayout,
} from "@common/business_components";
import {
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
} from "@tanstack/react-table";
import { getRawJson } from "@/shared/api/sewerage-api";
import LoadingOverlay from "@/components/ui/loading-bar";
import { useAlertStore } from "@/shared/lib";
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
import {
AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
buildAgedPipeStatusYearOptions,
getAgedPipeStatusByCtpvValidationMessage,
normalizeAgedPipeStatusByCtpvResponse,
normalizeAgedPipeStatusByCtpvFilters,
toAgedPipeStatusByCtpvRows,
type AgedPipeStatusByCtpvFilters,
type AgedPipeStatusByCtpvResponse,
type AgedPipeStatusByCtpvRow,
} from "./aged-pipe-status-by-ctpv-utils";
import { downloadAgedPipeStatusByCtpvExcel } from "./aged-pipe-status-by-ctpv-export";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
// CS 목록과 동일하게 한 페이지에는 최대 10개 시도만 표시한다.
const AGED_PIPE_STATUS_PAGE_SIZE = 10;
export default function AgedPipeStatusByCtpvPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
// 조회조건 입력 상태다. 기본값 30년, 최근 연도 기준 단년 조회로 시작한다.
const [filters, setFilters] = useState<AgedPipeStatusByCtpvFilters>(() =>
normalizeAgedPipeStatusByCtpvFilters({
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
}),
);
// 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다.
const [appliedFilters, setAppliedFilters] =
useState<AgedPipeStatusByCtpvFilters>(() =>
normalizeAgedPipeStatusByCtpvFilters({
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
}),
);
// 서버 응답을 화면 그리드에 맞게 평탄화한 row 목록이다.
const [rows, setRows] = useState<AgedPipeStatusByCtpvRow[]>([]);
// 동적 컬럼 생성을 위한 기준년도 목록이다.
const [years, setYears] = useState<number[]>([]);
// 조회/다운로드 중 로딩 overlay를 제어한다.
const [isLoading, setIsLoading] = useState(false);
// 사용자에게 현재 다운로드 상태를 보여주는 진행 문구다.
const [downloadProgressText, setDownloadProgressText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const yearOptions = useMemo(() => buildAgedPipeStatusYearOptions(), []);
const columns = useMemo<ColumnDef<AgedPipeStatusByCtpvRow>[]>(
() => [
{
id: "rowNumber",
header: "순번",
cell: ({ row }) =>
getSnRowNumber({
total: rows.length,
rowIndex: row.index,
}),
},
{ accessorKey: "ctpvNm", header: "시도" },
...years.map((year) => ({
id: `year_${year}`,
header: `${year}년`,
columns: [
{
accessorKey: `year${year}TotalLengthKm`,
header: "전체관로연장(km)",
},
{
accessorKey: `year${year}AgedLengthKm`,
header: "노후관로연장(km)",
},
{
accessorKey: `year${year}AgedRate`,
header: "노후화율(%)",
},
],
})),
],
[rows.length, years],
);
const pagination: PaginationState = {
pageIndex: currentPage - 1,
pageSize: AGED_PIPE_STATUS_PAGE_SIZE,
};
const table = useReactTable({
data: rows,
columns,
state: { pagination },
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
setCurrentPage(next.pageIndex + 1);
},
});
const loadRows = useCallback(
async (nextFilters: AgedPipeStatusByCtpvFilters) => {
setIsLoading(true);
try {
const normalized = normalizeAgedPipeStatusByCtpvFilters(nextFilters);
const queryParams = new URLSearchParams({
startYear: String(normalized.startYear),
endYear: String(normalized.endYear),
elapsedYears: String(normalized.elapsedYears),
});
const rawResponse = await getRawJson(
`v1/swagePpl/agedStatusByCtpv?${queryParams.toString()}`,
);
const response = normalizeAgedPipeStatusByCtpvResponse(
rawResponse,
) as AgedPipeStatusByCtpvResponse;
setYears(Array.isArray(response?.years) ? response.years : []);
setRows(toAgedPipeStatusByCtpvRows(response));
setCurrentPage(1);
setAppliedFilters(normalized);
} catch (error) {
console.error("시도별 노후관로현황 조회 중 오류:", error);
setYears([]);
setRows([]);
} finally {
setIsLoading(false);
}
},
[],
);
useEffect(() => {
const initialFilters = normalizeAgedPipeStatusByCtpvFilters({
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
});
void loadRows(initialFilters);
}, [loadRows]);
const handleSearch = async () => {
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
if (validationMessage) {
alert(validationMessage);
return;
}
const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
setFilters(normalized);
await loadRows(normalized);
};
const handleDownload = async () => {
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
if (validationMessage) {
alert(validationMessage);
return;
}
const normalizedDraft = normalizeAgedPipeStatusByCtpvFilters(filters);
if (JSON.stringify(normalizedDraft) !== JSON.stringify(appliedFilters)) {
alert("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요.");
return;
}
try {
setIsLoading(true);
setDownloadProgressText(
"시도별 노후관로현황 Excel 파일을 준비하고 있습니다.",
);
await downloadAgedPipeStatusByCtpvExcel(appliedFilters);
setDownloadProgressText("");
} catch (error) {
console.error("시도별 노후관로현황 다운로드 중 오류:", error);
setDownloadProgressText("");
alert(
error instanceof Error
? error.message
: "시도별 노후관로현황 Excel 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
};
return (
<StandardPopupLayout
title={windowPopupDefinitions.agedPipeStatusByCtpvPopup.title}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
{downloadProgressText ? (
<div className="rounded-sm border border-primary bg-primary-transparent px-4 py-3 text-[1.3rem] text-primary">
{downloadProgressText}
</div>
) : null}
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-xl font-medium text-label">
기준년도 시작
</span>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl"
value={filters.startYear}
onChange={(event) =>
setFilters((current) => ({
...current,
startYear: Number(event.target.value),
}))
}
>
{yearOptions.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-xl font-medium text-label">
기준년도 종료
</span>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl"
value={filters.endYear}
onChange={(event) =>
setFilters((current) => ({
...current,
endYear: Number(event.target.value),
}))
}
>
{yearOptions.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-xl font-medium text-label">
노후관로 기준(년)
</span>
<Input
type="text"
inputMode="numeric"
value={String(filters.elapsedYears)}
onChange={(event) =>
setFilters((current) => ({
...current,
elapsedYears: Number(event.target.value.replace(/[^0-9]/g, "")),
}))
}
className="w-[12rem]"
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch();
}}
/>
</label>
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
<Button onClick={handleSearch} color="primary">
조회
</Button>
<Button
color="white"
onClick={() => {
const resetFilters = normalizeAgedPipeStatusByCtpvFilters({
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
});
setFilters(resetFilters);
void loadRows(resetFilters);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<StandardDataTable
table={table}
className="!w-full !max-w-full"
title="시도별 노후관로 목록"
scrollMode="horizontal"
minTableWidth={years.length > 0 ? "120rem" : "100%"}
actions={
<Button type="button" onClick={handleDownload}>
엑셀다운로드
</Button>
}
flushOuterMargin
/>
</StandardPopupLayout>
);
}