/** * 화면명: 도시침수지도 목록 * 빈도별 도시침수지도를 검색하고 목록·엑셀·지도 보기 기능을 제공한다. */ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@common/components"; import { StandardPopupLayout } from "@common/business_components"; import { getData } from "@/shared/api/sewerage-api"; import LoadingOverlay from "@/components/ui/loading-bar"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note"; import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; import useStore from "@/entities/reference-data/model/reference-data-store"; 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 "./flood-dngr-map-types"; 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((_, 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( FLOOD_DNGR_MAP_DEFAULT_FILTERS, ); const [appliedFilters, setAppliedFilters] = useState( FLOOD_DNGR_MAP_DEFAULT_FILTERS, ); const [items, setItems] = useState([]); 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.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/floodDngrMap?${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/floodDngrMap/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/floodDngrMap/group/extent?${params.toString()}`, ); const payload = response as FloodDngrMapExtent | null | undefined; // 성공 응답의 null·생략된 data는 위치 부재이다. 좌표 접근과 지도 메시지를 중단한다. if (payload == null) { setErrorMessage("위치 정보가 없습니다"); return; } 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 : "도시침수지도 위치 이동 중 오류가 발생했습니다."; setErrorMessage(message); } finally { setIsLoading(false); } }; return (
{errorMessage ? (
{errorMessage}
) : null} >} 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}`} /> {/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */}
); }