528 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|