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

484 lines
15 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 "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { useAlertStore } from "@/shared/lib";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import type { SelectDataType } from "@common/business_components";
import {
buildSinkholeDefaultFilters,
buildSinkholeListQueryString,
formatDateInputToYmd,
formatYmdToDateInput,
normalizeSinkholeFilters,
SINKHOLE_COORD_STATUS_OPTIONS,
SINKHOLE_DOWNLOAD_EXCEL_HEADERS,
SINKHOLE_FILTER_SYNC_MESSAGE,
SINKHOLE_MIN_SEARCH_YMD,
SINKHOLE_RESTORE_STATUS_OPTIONS,
SINKHOLE_ROW_SELECT_MESSAGE,
type SinkholeFilters,
type SinkholeListItem,
} from "./sinkholeTypes";
const PAGE_SIZE = 10;
const DOWNLOAD_BATCH_SIZE = 2000;
type SinkholeTableRow = SinkholeListItem & {
rowNo: number;
sizeWidthDisplay: string;
sizeLengthDisplay: string;
sizeDepthDisplay: string;
detailCauseDisplay: 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: "restoreStatus", 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) => {
const normalized = String(value ?? "")
.trim()
.toUpperCase();
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),
coordStatusDisplay: toCoordStatusDisplay(item.coordStatus),
restoreCompletedYmdDisplay: toDisplay(item.restoreCompletedYmd),
}));
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.restoreStatus || "-",
restoreCompletedYmd: row.restoreCompletedYmdDisplay,
}));
const unwrapPayload = <T,>(response: any): T =>
(response?.data ?? response) as T;
export default function SinkholePopupPage() {
const alert = useAlertStore((state) => state.setMessage);
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 sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
: 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 downloadItems: SinkholeListItem[] = [];
const totalPages = Math.max(1, Math.ceil(total / DOWNLOAD_BATCH_SIZE));
for (let page = 1; page <= totalPages; page += 1) {
const response = await fetchRows(
page,
normalized,
DOWNLOAD_BATCH_SIZE,
{
includeCoordStatus: false,
},
);
downloadItems.push(...response.items);
}
if (downloadItems.length === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
const downloadRows = toTableRows(downloadItems, 0, 1);
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 contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
발생기간(시작)
</div>
<Input
className="h-[3.2rem] !text-[1.3rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.startYmd)}
onChange={(event) =>
setFilters({
...filters,
startYmd: formatDateInputToYmd(event.target.value),
})
}
/>
</label>
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
발생기간(종료)
</div>
<Input
className="h-[3.2rem] !text-[1.3rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.endYmd)}
onChange={(event) =>
setFilters({
...filters,
endYmd: formatDateInputToYmd(event.target.value),
})
}
/>
</label>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">시도</div>
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
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>
</label>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
시군구
</div>
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
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="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
복구상태
</div>
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
value={filters.restoreStatus}
onChange={(event) =>
setFilters({ ...filters, restoreStatus: event.target.value })
}
>
{SINKHOLE_RESTORE_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
좌표보정상태
</div>
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
value={filters.coordStatus}
onChange={(event) =>
setFilters({ ...filters, coordStatus: event.target.value })
}
>
{SINKHOLE_COORD_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="ml-auto flex flex-wrap justify-end gap-2">
<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>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={rows 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)}
scrollMode="horizontal"
minTableWidth="150rem"
actions={
<Button type="button" onClick={handleDownload}>
엑셀다운로드
</Button>
}
/>
</StandardPopupLayout>
);
}