Files
frontend/apps/sn/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx
T

399 lines
13 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import useStore from "@/store/useStore";
import {
FLOOD_DNGR_MAP_DEFAULT_FILTERS,
FLOOD_DNGR_MAP_DEPTH_OPTIONS,
FLOOD_DNGR_MAP_FREQUENCY_OPTIONS,
FLOOD_DNGR_MAP_HEADLIST,
FLOOD_DNGR_MAP_PAGE_SIZE,
buildFloodDngrMapListQueryString,
toFloodDngrMapTableRows,
type FloodDngrMapApiItem,
type FloodDngrMapExtent,
type FloodDngrMapFilters,
type FloodDngrMapTableRow,
} from "./floodDngrMapTypes";
const FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS = 10_000;
const FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS = 12_000;
/** Abort 처리 지연 여부와 무관하게 제한 시간이 지나면 팝업 요청 Promise를 종료한다. */
const getFloodDngrMapJson = async (url: string) => {
let timeoutId: number | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(() => {
reject(
new Error(
"도시침수지도 조회 시간이 초과되었습니다. 검색조건을 좁혀 다시 조회해주세요.",
),
);
}, FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS);
});
try {
return await Promise.race([getData(url), timeoutPromise]);
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
}
};
/** 시설물 레이어의 정보제공포털 도시침수지도 피처를 조건별 목록으로 제공한다. */
export default function FloodDngrMapPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<FloodDngrMapFilters>(
FLOOD_DNGR_MAP_DEFAULT_FILTERS,
);
const [appliedFilters, setAppliedFilters] = useState<FloodDngrMapFilters>(
FLOOD_DNGR_MAP_DEFAULT_FILTERS,
);
const [items, setItems] = useState<FloodDngrMapApiItem[]>([]);
const [administrativeScopeCodes, setAdministrativeScopeCodes] = useState<
string[] | null
>(null);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const availableCtpvCodes = useMemo(
() =>
new Set(
(administrativeScopeCodes ?? [])
.map((code) => code.slice(0, 2))
.filter(Boolean),
),
[administrativeScopeCodes],
);
const availableSggCodes = useMemo(
() =>
new Set(
(administrativeScopeCodes ?? [])
.map((code) => code.slice(0, 5))
.filter(Boolean),
),
[administrativeScopeCodes],
);
const ctpvOptions = useMemo(
() =>
administrativeScopeCodes === null
? ctpv
: ctpv.filter((item) =>
availableCtpvCodes.has(String(item.val).trim().slice(0, 2)),
),
[administrativeScopeCodes, availableCtpvCodes, ctpv],
);
const sggOptions = useMemo(() => {
const scopedOptions =
administrativeScopeCodes === null
? sgg
: sgg.filter((item) =>
availableSggCodes.has(String(item.val).trim().slice(0, 5)),
);
return filters.ctpvCd === "0"
? scopedOptions
: scopedOptions.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
);
}, [administrativeScopeCodes, availableSggCodes, filters.ctpvCd, sgg]);
const rows = useMemo(
() =>
toFloodDngrMapTableRows(
items,
currentPage - 1,
FLOOD_DNGR_MAP_PAGE_SIZE,
ctpv,
sgg,
total,
),
[ctpv, currentPage, items, sgg, total],
);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 요청 Promise가 예상과 다르게 정리되지 않아도 로딩 오버레이를 최대 12초 안에 해제한다. */
useEffect(() => {
if (!isLoading) return;
const watchdogId = window.setTimeout(() => {
setIsLoading(false);
setErrorMessage(
(current) =>
current ||
"도시침수지도 조회가 지연되어 로딩을 종료했습니다. 검색조건을 좁혀 다시 조회해주세요.",
);
}, FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS);
return () => window.clearTimeout(watchdogId);
}, [isLoading]);
/** 마지막 적용 조건으로 서버 페이지를 조회한다. */
const loadRows = useCallback(
async (page: number, nextFilters: FloodDngrMapFilters) => {
setIsLoading(true);
setErrorMessage("");
try {
const queryString = buildFloodDngrMapListQueryString(
nextFilters,
page - 1,
FLOOD_DNGR_MAP_PAGE_SIZE,
);
const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/flood-dngr-map?${queryString}`,
);
const payload = response as {
items?: FloodDngrMapApiItem[];
total?: number;
} | null;
if (!payload || !Array.isArray(payload.items)) {
throw new Error(
"도시침수지도 목록 API 응답 형식이 올바르지 않습니다.",
);
}
setItems(payload.items);
setTotal(Number(payload?.total ?? 0));
setCurrentPage(page);
setAppliedFilters(nextFilters);
} catch (error) {
console.error("도시침수지도 목록 조회 중 오류:", error);
setItems([]);
setTotal(0);
setErrorMessage(
error instanceof Error
? error.message
: "도시침수지도 목록 조회 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
},
[],
);
/** 실제 도시침수지도 데이터가 존재하는 행정구역 코드만 검색 선택지로 제한한다. */
const loadAdministrativeScope = useCallback(async () => {
try {
const response = await getFloodDngrMapJson(
"/sewerage-service/api/v1/fldng/flood-dngr-map/scope",
);
if (!Array.isArray(response)) {
throw new Error(
"도시침수지도 행정구역 API 응답 형식이 올바르지 않습니다.",
);
}
setAdministrativeScopeCodes(response);
} catch (error) {
console.error("도시침수지도 행정구역 범위 조회 중 오류:", error);
setAdministrativeScopeCodes(null);
}
}, []);
useEffect(() => {
void loadRows(1, FLOOD_DNGR_MAP_DEFAULT_FILTERS);
void loadAdministrativeScope();
}, [loadAdministrativeScope, loadRows]);
const handleSearch = () => {
void loadRows(1, filters);
};
/** 시군구·침수빈도 그룹의 범위와 선택 조건을 부모 지도에 전달한다. */
const handleRowClick = async (row: FloodDngrMapTableRow) => {
if (!row?.sggCd || !row?.sbmrgFrqExpln) return;
setIsLoading(true);
setErrorMessage("");
try {
const params = new URLSearchParams({
sggCd: row.sggCd,
sbmrgFrqExpln: row.sbmrgFrqExpln,
});
const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${params.toString()}`,
);
const payload = response as FloodDngrMapExtent;
const extent = [
payload?.minX,
payload?.minY,
payload?.maxX,
payload?.maxY,
].map(Number);
if (
extent.length !== 4 ||
extent.some((value) => !Number.isFinite(value))
) {
throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다.");
}
window.opener?.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP,
payload: {
extent,
sggCd: row.sggCd,
sbmrgFrqExpln: row.sbmrgFrqExpln,
},
},
window.location.origin,
);
} catch (error) {
console.error("도시침수지도 위치 이동 중 오류:", error);
const message =
error instanceof Error && error.message.includes("404")
? "해당 도시침수지도 데이터에 위치 정보가 없어 이동할 수 없습니다."
: error instanceof Error
? error.message
: "도시침수지도 위치 이동 중 오류가 발생했습니다.";
setErrorMessage(message);
} finally {
setIsLoading(false);
}
};
return (
<StandardPopupLayout title="도시침수지도 목록">
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시도
</div>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpvOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시군구
</div>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
value={filters.sggCd}
onChange={(event) =>
setFilters({ ...filters, 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 shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
침수심도
</div>
<select
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
value={filters.sbmrgDpthlCd}
onChange={(event) =>
setFilters({ ...filters, sbmrgDpthlCd: event.target.value })
}
>
<option value="0">전체</option>
{FLOOD_DNGR_MAP_DEPTH_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
침수빈도
</div>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
value={filters.sbmrgFrqExpln}
onChange={(event) =>
setFilters({ ...filters, sbmrgFrqExpln: event.target.value })
}
>
<option value="0">전체</option>
{FLOOD_DNGR_MAP_FREQUENCY_OPTIONS.map((value) => (
<option key={value} value={value}>
{Number(value)}년
</option>
))}
</select>
</label>
<div className="flex min-w-fit flex-1 justify-end gap-2">
<Button
type="button"
size="default"
color="primary"
onClick={handleSearch}
>
조회
</Button>
<Button
type="button"
size="default"
color="white"
onClick={() => {
setFilters(FLOOD_DNGR_MAP_DEFAULT_FILTERS);
void loadRows(1, FLOOD_DNGR_MAP_DEFAULT_FILTERS);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
{errorMessage ? (
<div
role="alert"
className="mt-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-[1rem] text-red-700"
>
{errorMessage}
</div>
) : null}
<MapPopupDataTable
rows={rows as Array<FloodDngrMapTableRow & Record<string, unknown>>}
headlist={FLOOD_DNGR_MAP_HEADLIST}
title="도시침수지도 목록"
total={total}
page={currentPage}
pageSize={FLOOD_DNGR_MAP_PAGE_SIZE}
onPageChange={(page) => void loadRows(page, appliedFilters)}
onRowClick={(row) => void handleRowClick(row)}
rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`}
scrollMode="horizontal"
minTableWidth="120rem"
/>
</StandardPopupLayout>
);
}