Files
frontend/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx
T

415 lines
13 KiB
TypeScript

/**
* 침수발생이력 popup
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { getRawJson } from "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { useAlertStore } from "@/shared/lib";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import {
FLOOD_HISTORY_LAYER_TYPE,
FLDNG_HSTRY_DEFAULT_FILTERS,
FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
FLDNG_HSTRY_DOWNLOAD_HEADERS,
FLDNG_HSTRY_LIST_HEADLIST,
FLDNG_HSTRY_PAGE_SIZE,
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
attachFldngHstryGeometry,
buildFldngHstryListQueryString,
buildFldngHstryYearOptions,
normalizeFldngHstryFilters,
toFldngHstryTableRows,
type FldngHstryApiItem,
type FldngHstryFilters,
type FldngHstryTableRow,
} from "./fldngHstryTypes";
const unwrapPayload = <T,>(response: any): T =>
(response?.data ?? response) as T;
const resolveOptionText = (
code: string,
options: Array<{ text: string; val: string | number }>,
prefixLength?: number,
) => {
const normalized = String(code ?? "").trim();
if (!normalized || normalized === "0") return "";
const lookup = prefixLength ? normalized.slice(0, prefixLength) : normalized;
return options.find((item) => String(item.val).trim() === lookup)?.text ?? "";
};
const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => {
const fid = String(row.fid ?? "").trim();
if (fid.startsWith(`${FLOOD_HISTORY_LAYER_TYPE}.`)) return fid;
if (fid) return `${FLOOD_HISTORY_LAYER_TYPE}.${fid}`;
const mapFid = String(row.fldngMapFid ?? "").trim();
return mapFid || "";
};
const buildDownloadRows = (rows: FldngHstryTableRow[]) =>
rows.map((row) => ({
rowNo: row.rowNo,
floodYearDisplay: row.floodYearDisplay,
ctpvNm: row.ctpvNm,
sggNm: row.sggNm,
disasterName: row.disasterName,
rainBgngTmDisplay: row.rainBgngTmDisplay,
rainEndTmDisplay: row.rainEndTmDisplay,
rainTotDisplay: row.rainTotDisplay,
rainStrDisplay: row.rainStrDisplay,
floodHourDisplay: row.floodHourDisplay,
districtName: row.districtName,
causeDetailCode: row.causeDetailCode,
causeDetailName: row.causeDetailName,
avgFloodDepthDisplay: row.avgFloodDepthDisplay,
floodAreaDisplay: row.floodAreaDisplay,
exmnYearDisplay: row.exmnYearDisplay,
damageAmountDisplay: row.damageAmountDisplay,
}));
const resolveRegionNames = (
row: FldngHstryTableRow,
ctpvOptions: Array<{ text: string; val: string | number }>,
sggOptions: Array<{ text: string; val: string | number }>,
): FldngHstryTableRow => ({
...row,
ctpvNm: resolveOptionText(row.ctpvCd ?? "", ctpvOptions, 2) || row.ctpvNm,
sggNm: resolveOptionText(row.sggCd ?? "", sggOptions, 5) || row.sggNm,
});
export default function FldngHstryPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<FldngHstryFilters>(
FLDNG_HSTRY_DEFAULT_FILTERS,
);
const [appliedFilters, setAppliedFilters] = useState<FldngHstryFilters>(
FLDNG_HSTRY_DEFAULT_FILTERS,
);
const [rows, setRows] = useState<FldngHstryTableRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
useEffect(() => {
if (rows.length === 0) return;
if (ctpv.length === 0 && sgg.length === 0) return;
setRows((current) =>
current.map((row) => resolveRegionNames(row, ctpv as any, sgg as any)),
);
}, [ctpv, rows.length, sgg]);
const fldngYearOptions = useMemo(() => buildFldngHstryYearOptions(), []);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
: sgg.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
),
[filters.ctpvCd, sgg],
);
const fetchRows = useCallback(
async (page: number, nextFilters: FldngHstryFilters, size: number) => {
const normalized = normalizeFldngHstryFilters(nextFilters);
const queryString = buildFldngHstryListQueryString(
normalized,
page,
size,
);
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/hstry?${queryString}`,
);
const payload = unwrapPayload<{
items?: FldngHstryApiItem[];
total?: number;
}>(response);
const items = Array.isArray(payload?.items) ? payload.items : [];
const displayItems = items.map((item) => {
const hydrated = attachFldngHstryGeometry(item);
return {
...hydrated,
ctpvNm: resolveOptionText(hydrated.ctpvCd ?? "", ctpv as any, 2),
sggNm: resolveOptionText(hydrated.sggCd ?? "", sgg as any, 5),
};
});
return {
rows: toFldngHstryTableRows(displayItems, page, size),
total: Number(payload?.total ?? 0),
};
},
[ctpv, sgg],
);
const loadRows = useCallback(
async (page: number, nextFilters: FldngHstryFilters) => {
setIsLoading(true);
try {
const normalized = normalizeFldngHstryFilters(nextFilters);
const response = await fetchRows(
page,
normalized,
FLDNG_HSTRY_PAGE_SIZE,
);
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
setAppliedFilters(normalized);
} catch (error) {
console.error("침수발생이력 목록 조회 중 오류:", error);
setRows([]);
setTotal(0);
alert(
error instanceof Error
? error.message
: "침수발생이력 목록 조회 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
},
[alert, fetchRows],
);
useEffect(() => {
void loadRows(1, FLDNG_HSTRY_DEFAULT_FILTERS);
// 최초 로딩은 한 번만 수행한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSearch = async (page = 1) => {
const normalized = normalizeFldngHstryFilters(filters);
if (normalized.sggCd !== "0" && normalized.ctpvCd === "0") {
alert("시도를 먼저 선택하세요.");
return;
}
setFilters(normalized);
await loadRows(page, normalized);
};
const handlePageChange = async (page: number) => {
await loadRows(page, appliedFilters);
};
const handleRowClick = (row: FldngHstryTableRow) => {
const mapFeatureFid = resolveFldngHstryMapFeatureFid(row);
if (mapFeatureFid) {
window.opener?.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE,
payload: {
layerNm: FLOOD_HISTORY_LAYER_TYPE,
fid: mapFeatureFid,
},
},
window.location.origin,
);
return;
}
window.opener?.postMessage(
{
type: FLDNG_HSTRY_ROW_SELECT_MESSAGE,
row,
openDetail: false,
},
window.location.origin,
);
};
const handleDownload = async () => {
if (total === 0) {
alert("다운로드할 침수발생이력 데이터가 없습니다.");
return;
}
setIsLoading(true);
try {
const response = await fetchRows(
1,
appliedFilters,
Math.max(total, FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE),
);
await downloadExcelWithKoreanHeaders({
data: buildDownloadRows(response.rows) as unknown as JSON[],
fileName: `침수발생이력_${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetNameArray: ["침수발생이력"],
headerRange: "A1:Q1",
headerMapping: FLDNG_HSTRY_DOWNLOAD_HEADERS,
});
} catch (error) {
console.error("침수발생이력 다운로드 중 오류:", error);
alert(
error instanceof Error
? error.message
: "침수발생이력 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
};
return (
<StandardPopupLayout title="침수발생이력 목록">
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시도
</span>
<select
className="h-[3.2rem] w-[16rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시군구
</span>
<select
className="h-[3.2rem] w-[16rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.sggCd}
onChange={(event) =>
setFilters((current) => ({
...current,
sggCd: event.target.value,
}))
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
침수발생년도
</span>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.floodYear}
onChange={(event) =>
setFilters((current) => ({
...current,
floodYear: event.target.value,
}))
}
>
<option value="">전체</option>
{fldngYearOptions.map((year) => (
<option key={year} value={year}>
{year}년
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
침수재해명
</span>
<Input
className="w-[20rem]"
type="text"
autoComplete="off"
value={filters.disasterName}
onChange={(event) =>
setFilters((current) => ({
...current,
disasterName: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
color="primary"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
setFilters(FLDNG_HSTRY_DEFAULT_FILTERS);
void loadRows(1, FLDNG_HSTRY_DEFAULT_FILTERS);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<FldngHstryTableRow & Record<string, unknown>>}
headlist={FLDNG_HSTRY_LIST_HEADLIST}
title="침수발생이력 목록"
total={total}
page={currentPage}
pageSize={FLDNG_HSTRY_PAGE_SIZE}
onPageChange={(page) => void handlePageChange(page)}
onRowClick={handleRowClick}
rowId={(row, index) => String(row.fid ?? index)}
scrollMode="horizontal"
minTableWidth="220rem"
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
<div className="rounded-sm bg-secondary px-4 py-3 text-[1.2rem] text-muted-foreground">
행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.
</div>
</StandardPopupLayout>
);
}