Files
frontend/apps/sn/app/(map-layout)/map/components/rdii-result/rdii-result-popup-page.tsx
T

396 lines
13 KiB
TypeScript

/**
* 화면명: RDII 분석결과 목록
* RDII 분석결과를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useState } from "react";
import { DateMonthPicker } from "@common/business_components/ui/date_month_picker";
import {
StandardPopupLayout,
type UiHeadProps,
type UiTableSort,
} from "@common/business_components";
import { Button } from "@common/components";
import { getData } from "@/shared/api/sewerage-api";
import { appendApiSearchParams } from "@/shared/util/util";
import { downloadAnalysisExcel } from "@/shared/lib/download-analysis-excel";
import { useAlertStore } from "@/shared/lib";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box";
import { formatDisplayData } from "@/components/lib/utils/format-display-value";
import useStore from "@/entities/reference-data/model/reference-data-store";
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import LoadingOverlay from "@/components/ui/loading-bar";
import {
RDII_RESULT_ROW_SELECT_MESSAGE,
RDII_RESULT_ROW_SELECT_STORAGE_KEY,
type RdiiResultRow,
} from "./rdii-result-types";
const RDII_RESULT_PAGE_SIZE = 10;
const RDII_RESULT_HEADLIST: UiHeadProps[] = [
{
id: "rowNumber",
title: "순번",
sortable: true,
sortKey: "rainInfwtAnlsSn",
},
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "swtrmtFcltNm", title: "하수처리시설", sortable: true },
{ id: "aplcnYm", title: "상수사용연월", sortable: true },
{ id: "drtwSwtcRt", title: "오수 전환율", sortable: true },
{ id: "srcNm", title: "RTK 계수 출처", sortable: true },
{ id: "cntbArea", title: "RDII 기여면적", sortable: true },
{ id: "anlsInfo", title: "RDII\n분석정보", sortable: true },
{ id: "regDt", title: "분석일자", sortable: true },
{ id: "rgtrNm", title: "분석자" },
{ id: "scsYn", title: "분석성공여부", sortable: true },
];
type RdiiResultFilters = {
ctpvCd: string;
sggCd: string;
sggName: string;
swtrmtFcltNm: string;
aplcnYmFrom: string;
aplcnYmTo: string;
};
const createDefaultAplcnYm = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
return `${year}${month}`;
};
const DEFAULT_FILTERS: RdiiResultFilters = {
ctpvCd: "0",
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
aplcnYmFrom: `${new Date().getFullYear()}01`,
aplcnYmTo: createDefaultAplcnYm(),
};
const postRowSelectMessage = (row: RdiiResultRow) => {
// 같은 클릭의 message/storage 전달을 식별한다.
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(
RDII_RESULT_ROW_SELECT_STORAGE_KEY,
JSON.stringify({
row,
timestamp: Date.now(),
requestId,
}),
);
} catch (error) {
console.warn("RDII 분석결과 선택 동기화 저장에 실패했습니다.", error);
}
}
if (typeof window === "undefined" || !window.opener) return;
window.opener.postMessage(
{
type: RDII_RESULT_ROW_SELECT_MESSAGE,
row,
requestId,
},
window.location.origin,
);
};
export default function RdiiResultPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<RdiiResultFilters>(DEFAULT_FILTERS);
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0");
const [selectedRow, setSelectedRow] = useState<RdiiResultRow | null>(null);
const [rows, setRows] = useState<RdiiResultRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [sort, setSort] = useState<UiTableSort>();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const fetchRows = useCallback(
async (
page: number,
nextFilters: RdiiResultFilters,
nextSort: UiTableSort | undefined = sort,
) => {
const queryParams = new URLSearchParams({
page: String(page - 1),
size: String(RDII_RESULT_PAGE_SIZE),
scsYn: "Y",
});
queryParams.append(
"sort",
nextSort
? `${nextSort.key},${nextSort.direction}`
: "rainInfwtAnlsSn,desc",
);
appendApiSearchParams(queryParams, {
aplcnYmFrom: nextFilters.aplcnYmFrom,
aplcnYmTo: nextFilters.aplcnYmTo,
ctpvCd: nextFilters.ctpvCd,
sggCd: nextFilters.sggCd,
swtrmtFcltNm: nextFilters.swtrmtFcltNm,
});
const response = await getData(`v1/rdiiAnls?${queryParams.toString()}`);
const content = Array.isArray(response?.content) ? response.content : [];
const totalElements = Number(response?.totalElements ?? content.length);
const nextRows = content
.map((item: Record<string, unknown>, index: number) => {
const formattedItem = formatDisplayData(item);
const ctpvOption = ctpv.find(
(code) => code.val.trim() === String(item?.ctpvCd ?? "").trim(),
);
const sggOption = sgg.find((code) => code.val === item?.sggCd);
return {
...(formattedItem as Record<string, unknown>),
rowNumber: Math.max(
(page - 1) * RDII_RESULT_PAGE_SIZE + index + 1,
0,
),
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
scsYn: String(item?.scsYn ?? "N"),
} as RdiiResultRow;
})
.filter((item: RdiiResultRow) => item.scsYn === "Y");
setRows(nextRows);
setTotal(totalElements);
setCurrentPage(page);
},
[ctpv, sgg, sort],
);
const loadRows = useCallback(
async (
page = 1,
nextFilters = filters,
nextSort: UiTableSort | undefined = sort,
) => {
setIsLoading(true);
try {
await fetchRows(page, nextFilters, nextSort);
} catch (error) {
console.error("RDII 분석결과 목록 조회 중 오류:", error);
alert("RDII 분석결과 목록을 불러오지 못했습니다.");
setRows([]);
setTotal(0);
} finally {
setIsLoading(false);
}
},
[alert, fetchRows, filters, sort],
);
useEffect(() => {
void loadRows(1, DEFAULT_FILTERS, undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSearch = async (page = 1) => {
setSelectedRow(null);
await loadRows(page, filters);
};
/** CS 검색 카드와 동일하게 조건과 선택 결과를 기본값으로 되돌린다. */
const handleReset = async () => {
setFilters(DEFAULT_FILTERS);
setSelectedSwagePrcsFclt("0");
setSelectedRow(null);
setSort(undefined);
await loadRows(1, DEFAULT_FILTERS, undefined);
};
/** 정렬 변경 시 선택 결과를 해제하고 서버 첫 페이지를 조회한다. */
const handleSortChange = (nextSort?: UiTableSort) => {
setSort(nextSort);
setSelectedRow(null);
void loadRows(1, filters, nextSort);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelectedRow(row);
postRowSelectMessage(row);
};
const handleDownload = async () => {
if (!selectedRow?.rainInfwtAnlsSn) {
alert("다운로드할 분석결과를 먼저 선택해주세요.");
return;
}
try {
setIsLoading(true);
await downloadAnalysisExcel("rdii", selectedRow.rainInfwtAnlsSn, `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`);
} catch (error) {
console.error("RDII 분석결과 다운로드 중 오류:", error);
alert("RDII 분석결과 다운로드 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
return (
<StandardPopupLayout
title={windowPopupDefinitions.rdiiResultPopup.title}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] font-medium text-label">
상수사용기간
</span>
<DateMonthPicker
field={{
value: filters.aplcnYmFrom,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmFrom: value,
}));
},
}}
selectClass="w-[145px] !text-[1.3rem]"
startYear={1900}
/>
<span className="text-[1.1rem] font-semibold text-muted-foreground">
~
</span>
<DateMonthPicker
field={{
value: filters.aplcnYmTo,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmTo: value,
}));
},
}}
selectClass="w-[145px] !text-[1.3rem]"
startYear={1900}
/>
</div>
<div className="min-w-0 shrink-0">
<CtpvSggPrcsFcltSelectBox
compact
className="!justify-start"
selectValueClassName="!text-[1.3rem]"
sido={filters.ctpvCd}
sigungu={filters.sggCd}
sggName={filters.sggName}
onSidoChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
ctpvCd: value,
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
}));
}}
onSigunguChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggCd: value,
swtrmtFcltNm: "",
}));
}}
setSggName={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggName: value ?? "",
swtrmtFcltNm: "",
}));
}}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setSwtrmtFcltNm={(value) =>
setFilters((current) => ({
...current,
swtrmtFcltNm: value ?? "",
}))
}
setIsLoading={setIsLoading}
/>
</div>
<div className="ml-auto flex shrink-0 items-center justify-end gap-2">
<Button
type="button"
color="primary"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => void handleReset()}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<RdiiResultRow & Record<string, unknown>>}
headlist={RDII_RESULT_HEADLIST}
title="RDII 분석결과 목록"
total={total}
page={currentPage}
pageSize={RDII_RESULT_PAGE_SIZE}
onPageChange={(page) => void handleSearch(page)}
onRowClick={(_, index) => handleRowClick(index)}
rowId={(row) => String(row.rainInfwtAnlsSn)}
scrollMode="horizontal"
minTableWidth="125rem"
sort={sort}
onSortChange={handleSortChange}
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
{selectedRow ? (
<div className="rounded-sm border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
{"선택 분석: "}
<span className="font-semibold text-foreground">
{selectedRow.anlsInfo}
</span>
</div>
) : null}
</StandardPopupLayout>
);
}