399 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|