refactor(sn): modularize map and features and standardize file naming
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* 화면명: 침수발생이력 목록
|
||||
* 침수발생이력을 검색하고 엑셀 다운로드 및 지도 위치 확인 기능을 제공한다.
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { StandardPopupLayout } from "@common/business_components";
|
||||
import { getRawJson } from "@/shared/api/sewerage-api";
|
||||
import { requestGridExcel } from "@/shared/api/grid-export";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import useStore from "@/entities/reference-data/model/reference-data-store";
|
||||
import LoadingOverlay from "@/components/ui/loading-bar";
|
||||
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
||||
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
||||
import { FLDNG_HSTRY_LAYER_TYPE, FLDNG_HSTRY_DEFAULT_FILTERS, FLDNG_HSTRY_LIST_HEADLIST, FLDNG_HSTRY_PAGE_SIZE, FLDNG_HSTRY_ROW_SELECT_MESSAGE, type FldngHstryApiItem, type FldngHstryFilters, type FldngHstryTableRow } from "@/entities/flood-history/model/flood-history-types";
|
||||
import { attachFldngHstryGeometry, buildFldngHstryListQueryString, buildFldngHstryYearOptions, normalizeFldngHstryFilters, toFldngHstryTableRows } from "@/entities/flood-history/lib/flood-history-utils";
|
||||
|
||||
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(`${FLDNG_HSTRY_LAYER_TYPE}.`)) return fid;
|
||||
if (fid) return `${FLDNG_HSTRY_LAYER_TYPE}.${fid}`;
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
|
||||
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 ||
|
||||
String((row as any).ctpv_nm ?? row.ctpvCd ?? "").trim(),
|
||||
sggNm:
|
||||
resolveOptionText(row.sggCd ?? "", sggOptions, 5) ||
|
||||
row.sggNm ||
|
||||
String((row as any).sgg_nm ?? row.sggCd ?? "").trim(),
|
||||
});
|
||||
|
||||
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.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) ||
|
||||
String(
|
||||
(hydrated as any).ctpvNm ?? (hydrated as any).ctpv_nm ?? "",
|
||||
).trim() ||
|
||||
String(hydrated.ctpvCd ?? "").trim(),
|
||||
sggNm:
|
||||
resolveOptionText(hydrated.sggCd ?? "", sgg as any, 5) ||
|
||||
String(
|
||||
(hydrated as any).sggNm ?? (hydrated as any).sgg_nm ?? "",
|
||||
).trim() ||
|
||||
String(hydrated.sggCd ?? "").trim(),
|
||||
};
|
||||
});
|
||||
|
||||
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: FLDNG_HSTRY_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 params = new URLSearchParams(buildFldngHstryListQueryString(appliedFilters, 1, FLDNG_HSTRY_PAGE_SIZE));
|
||||
params.delete("page"); params.delete("size");
|
||||
await requestGridExcel("v1/spatialGridExports/floodHistories", {
|
||||
history: Object.fromEntries(params),
|
||||
ctpvNames: Object.fromEntries(ctpv.map((item) => [String(item.val).trim(), item.text])),
|
||||
sggNames: Object.fromEntries(sgg.map((item) => [String(item.val).trim(), item.text])),
|
||||
}, `침수발생이력_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
} 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-[12rem] 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-[8rem] 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-[11rem]"
|
||||
type="date"
|
||||
value={filters.rainStartDate}
|
||||
min={filters.floodYear ? `${filters.floodYear}-01-01` : undefined}
|
||||
max={filters.floodYear ? `${filters.floodYear}-12-31` : undefined}
|
||||
disabled={!filters.floodYear}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
rainStartDate: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
침수지구명
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={filters.districtName}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
districtName: event.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void handleSearch(1);
|
||||
}}
|
||||
/>
|
||||
</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="140rem"
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user