Files
frontend/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-popup-page.tsx
T

528 lines
17 KiB
TypeScript

/**
* 화면명: 지반침하 목록
* 지반침하 정보를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import {
StandardPopupLayout,
type UiHeadProps,
} from "@common/business_components";
import { getRawJson } from "@/shared/api/sewerage-api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { useAlertStore } from "@/shared/lib";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
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 PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel, {
PopupSearchActions,
PopupSearchField,
} from "@/features/window-popup/ui/popup-search-panel";
import type { SelectDataType } from "@common/business_components";
import {
buildSinkholeDefaultFilters,
buildSinkholeListQueryString,
formatDateInputToYmd,
formatYmdToDateInput,
normalizeSinkholeFilters,
SINKHOLE_DOWNLOAD_EXCEL_HEADERS,
SINKHOLE_FILTER_SYNC_MESSAGE,
SINKHOLE_MIN_SEARCH_YMD,
SINKHOLE_ROW_SELECT_MESSAGE,
type SinkholeFilters,
type SinkholeListItem,
} from "./sinkhole-types";
import { useSinkholeCommonCodes } from "./use-sinkhole-common-codes";
const PAGE_SIZE = 10;
const DOWNLOAD_BATCH_SIZE = 2000;
type SinkholeTableRow = SinkholeListItem & {
rowNo: number;
sizeWidthDisplay: string;
sizeLengthDisplay: string;
sizeDepthDisplay: string;
detailCauseDisplay: string;
restoreStatusDisplay: string;
restoreCompletedYmdDisplay: string;
coordStatusDisplay: string;
};
const HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번", widthPercent: 6 },
{ id: "acdntNo", title: "사고번호", widthPercent: 15 },
{ id: "ocrnYmd", title: "발생일자", widthPercent: 10 },
{ id: "fullAddress", title: "주소", widthPercent: 20 },
{ id: "sizeWidthDisplay", title: "발생규모폭", widthPercent: 8 },
{ id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 9 },
{ id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 9 },
{ id: "detailCauseDisplay", title: "상세발생원인", widthPercent: 11 },
{ id: "restoreStatusDisplay", title: "복구상태", widthPercent: 8 },
{ id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 9 },
{ id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 11 },
];
const toDisplay = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized ? normalized : "-";
};
const toCoordStatusDisplay = (
value: unknown,
labels: Record<string, string> = {},
) => {
const normalized = String(value ?? "")
.trim()
.toUpperCase();
if (labels[normalized]) return labels[normalized];
if (normalized === "SUCCESS") return "성공";
if (normalized === "NO_ADDRESS") return "주소없음";
if (normalized === "FAILED") return "실패";
return normalized || "-";
};
const toTableRows = (
items: SinkholeListItem[],
page: number,
pageSize = PAGE_SIZE,
) =>
items.map((item, index) => ({
...item,
rowNo: page * pageSize + index + 1,
sizeWidthDisplay: toDisplay(item.sizeWidth),
sizeLengthDisplay: toDisplay(item.sizeLength),
sizeDepthDisplay: toDisplay(item.sizeDepth),
detailCauseDisplay: toDisplay(item.detailCause),
restoreStatusDisplay: toDisplay(item.restoreStatus),
coordStatusDisplay: toCoordStatusDisplay(item.coordStatus),
restoreCompletedYmdDisplay: toDisplay(item.restoreCompletedYmd),
}));
const applyStatusDisplayNames = (
rows: SinkholeTableRow[],
restoreStatusLabels: Record<string, string>,
coordStatusLabels: Record<string, string>,
) =>
rows.map((row) => {
const restoreStatus = String(row.restoreStatus ?? "").trim();
return {
...row,
restoreStatusDisplay:
restoreStatusLabels[restoreStatus] || restoreStatus || "-",
coordStatusDisplay: toCoordStatusDisplay(
row.coordStatus,
coordStatusLabels,
),
};
});
const buildDownloadRows = (rows: SinkholeTableRow[]) =>
rows.map((row) => ({
rowNo: row.rowNo,
ocrnYmd: row.ocrnYmd || "-",
fullAddress: row.fullAddress || "-",
sizeWidth: row.sizeWidthDisplay,
sizeLength: row.sizeLengthDisplay,
sizeDepth: row.sizeDepthDisplay,
detailCause: row.detailCauseDisplay,
restoreStatus: row.restoreStatusDisplay,
restoreCompletedYmd: row.restoreCompletedYmdDisplay,
}));
const unwrapPayload = <T,>(response: any): T =>
(response?.data ?? response) as T;
export default function SinkholePopupPage() {
const alert = useAlertStore((state) => state.setMessage);
// 검색 파라미터 계약은 유지하고 선택지·목록 표시명만 CMS 공통코드로 구성한다.
const {
restoreStatusOptions,
coordStatusOptions,
restoreStatusLabels,
coordStatusLabels,
} = useSinkholeCommonCodes();
const searchParams = useSearchParams();
const hasInitialFilterParams =
searchParams.has("startYmd") ||
searchParams.has("endYmd") ||
searchParams.has("ctpvCd") ||
searchParams.has("sggCd") ||
searchParams.has("restoreStatus") ||
searchParams.has("coordStatus");
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<SinkholeFilters>(() => {
const seeded = normalizeSinkholeFilters(
{
startYmd: searchParams.get("startYmd") ?? "",
endYmd: searchParams.get("endYmd") ?? "",
ctpvCd: searchParams.get("ctpvCd") ?? "0",
sggCd: searchParams.get("sggCd") ?? "0",
restoreStatus: searchParams.get("restoreStatus") ?? "0",
coordStatus: searchParams.get("coordStatus") ?? "0",
},
undefined,
);
if (!hasInitialFilterParams) {
return normalizeSinkholeFilters(buildSinkholeDefaultFilters(), undefined);
}
return seeded;
});
const [appliedFilters, setAppliedFilters] = useState<SinkholeFilters>(() => {
const seeded = normalizeSinkholeFilters(
{
startYmd: searchParams.get("startYmd") ?? "",
endYmd: searchParams.get("endYmd") ?? "",
ctpvCd: searchParams.get("ctpvCd") ?? "0",
sggCd: searchParams.get("sggCd") ?? "0",
restoreStatus: searchParams.get("restoreStatus") ?? "0",
coordStatus: searchParams.get("coordStatus") ?? "0",
},
undefined,
);
if (!hasInitialFilterParams) {
return normalizeSinkholeFilters(buildSinkholeDefaultFilters(), undefined);
}
return seeded;
});
const [rows, setRows] = useState<SinkholeTableRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const displayRows = useMemo(
() => applyStatusDisplayNames(rows, restoreStatusLabels, coordStatusLabels),
[coordStatusLabels, restoreStatusLabels, rows],
);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? []
: sgg.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
),
[filters.ctpvCd, sgg],
);
useEffect(() => {
if (ctpv.length === 0) fetchCtpv();
if (sgg.length === 0) fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const fetchRows = useCallback(
async (
page: number,
nextFilters: SinkholeFilters,
pageSize = PAGE_SIZE,
options?: {
includeCoordStatus?: boolean;
},
) => {
const queryString = buildSinkholeListQueryString(
nextFilters,
ctpv as SelectDataType[],
sgg as SelectDataType[],
page - 1,
pageSize,
options,
);
const response = await getRawJson(
`/sewerage-service/api/v1/sinkholes?${queryString}`,
);
const payload = unwrapPayload<{
items?: SinkholeListItem[];
total?: number;
}>(response);
const items = Array.isArray(payload?.items) ? payload.items : [];
return {
rows: toTableRows(items, page - 1, pageSize),
items,
total: Number(payload?.total ?? 0),
};
},
[ctpv, sgg],
);
const loadRows = useCallback(
async (page: number, nextFilters: SinkholeFilters) => {
setIsLoading(true);
try {
const normalized = normalizeSinkholeFilters(nextFilters);
const response = await fetchRows(page, normalized, PAGE_SIZE);
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
setAppliedFilters(normalized);
window.opener?.postMessage(
{
type: SINKHOLE_FILTER_SYNC_MESSAGE,
filters: normalized,
},
window.location.origin,
);
} catch (error) {
console.error("지반침하 목록 조회 중 오류:", error);
setRows([]);
setTotal(0);
alert(
error instanceof Error
? error.message
: "지반침하 목록 조회 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
},
[alert, fetchRows],
);
useEffect(() => {
void loadRows(1, filters);
// 최초 로딩은 한 번만 수행한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSearch = async (page = 1) => {
const normalized = normalizeSinkholeFilters(filters);
setFilters(normalized);
await loadRows(page, normalized);
};
/** 페이지 이동은 입력 중인 조건이 아니라 마지막 적용 조건을 사용한다. */
const handlePageChange = async (page: number) => {
await loadRows(page, appliedFilters);
};
const handleRowClick = (row: SinkholeTableRow) => {
window.opener?.postMessage(
{
type: SINKHOLE_ROW_SELECT_MESSAGE,
row,
},
window.location.origin,
);
};
const handleDownload = async () => {
if (total === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
setIsLoading(true);
try {
const normalized = normalizeSinkholeFilters(appliedFilters);
const response = await fetchAllPages({
batchSize: DOWNLOAD_BATCH_SIZE,
fetchPage: (page, size) =>
fetchRows(page + 1, normalized, size, {
includeCoordStatus: false,
}),
getItems: (page) => page.items,
getTotal: (page) => page.total,
});
const downloadItems: SinkholeListItem[] = response.items;
if (downloadItems.length === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
const downloadRows = applyStatusDisplayNames(
toTableRows(downloadItems, 0, 1),
restoreStatusLabels,
coordStatusLabels,
);
await downloadExcelWithKoreanHeaders({
data: buildDownloadRows(downloadRows) as unknown as JSON[],
fileName: `지반침하정보_${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetNameArray: ["지반침하정보"],
headerRange: "A1:I1",
headerMapping: SINKHOLE_DOWNLOAD_EXCEL_HEADERS,
});
} catch (error) {
console.error("지반침하 다운로드 중 오류:", error);
alert(
error instanceof Error
? error.message
: "지반침하 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
};
return (
<StandardPopupLayout title="지반침하 목록">
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel>
<PopupSearchField label="발생기간(시작)">
<Input
className="h-[3.2rem] w-[12rem] !text-[1.3rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.startYmd)}
onChange={(event) =>
setFilters({
...filters,
startYmd: formatDateInputToYmd(event.target.value),
})
}
/>
</PopupSearchField>
<PopupSearchField label="발생기간(종료)">
<Input
className="h-[3.2rem] w-[12rem] !text-[1.3rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.endYmd)}
onChange={(event) =>
setFilters({
...filters,
endYmd: formatDateInputToYmd(event.target.value),
})
}
/>
</PopupSearchField>
<PopupSearchField label="시도">
<select
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
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>
</PopupSearchField>
<PopupSearchField label="시군구">
<select
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-[1.3rem] 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>
</PopupSearchField>
<PopupSearchField label="복구상태">
<select
className="h-[3.2rem] w-[10rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.restoreStatus}
onChange={(event) =>
setFilters({ ...filters, restoreStatus: event.target.value })
}
>
{restoreStatusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</PopupSearchField>
<PopupSearchField label="좌표보정상태">
<select
className="h-[3.2rem] w-[10rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.coordStatus}
onChange={(event) =>
setFilters({ ...filters, coordStatus: event.target.value })
}
>
{coordStatusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</PopupSearchField>
<PopupSearchActions>
<Button
type="button"
color="primary"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
const resetFilters = normalizeSinkholeFilters(
buildSinkholeDefaultFilters(),
undefined,
);
setFilters(resetFilters);
void loadRows(1, resetFilters);
}}
>
초기화
</Button>
</PopupSearchActions>
</PopupSearchPanel>
<MapPopupDataTable
rows={displayRows as Array<SinkholeTableRow & Record<string, unknown>>}
headlist={HEADLIST}
title="지반침하 목록"
total={total}
page={currentPage}
pageSize={PAGE_SIZE}
onPageChange={(page) => void handlePageChange(page)}
onRowClick={handleRowClick}
rowId={(row, index) => String(row.fid ?? row.acdntNo ?? index)}
renderCell={(row, headId) =>
headId === "fullAddress" ? (
<span
className="block whitespace-nowrap"
title={toDisplay(row.fullAddress)}
>
{toDisplay(row.fullAddress)}
</span>
) : undefined
}
scrollMode="horizontal"
minTableWidth="150rem"
actions={
<Button type="button" onClick={handleDownload}>
엑셀다운로드
</Button>
}
/>
{/* 인수테스트 요구: 지반침하 정보의 연계 원천과 연계 주기를 목록 아래에 표시한다. */}
<PopupFooterNote
items={[
"출처 : 지하안전정보시스템 지반침하사고 정보 연계 표출(연계 주기 : 매 시간 연계)",
]}
/>
</StandardPopupLayout>
);
}