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

337 lines
13 KiB
TypeScript

/**
* 화면명: 지반침하 상세정보
* 선택한 지반침하 정보와 주변 관로 조회 결과를 표시한다.
*/
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@common/components";
import { VerticalTable } from "@/components/ui/vertical-table";
import { getCmsData } from "@/shared/api/external-service-api";
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types";
import { useSinkholeCommonCodes } from "./use-sinkhole-common-codes";
import { downloadSinkholeDetailExcel } from "./sinkhole-detail-excel";
type Props = {
detail: SinkholeDetail;
nearbyPipes: SinkholeNearbyPipe[];
radiusMeters: number;
onClose: () => void;
onSelectPipe: (pipe: SinkholeNearbyPipe) => void;
};
type PipeUseCodeItem = {
lwpoCd: string;
lwpoCdNm: string;
};
let pipeUseCodeCache: PipeUseCodeItem[] | null = null;
let pipeUseCodePromise: Promise<PipeUseCodeItem[]> | null = null;
const display = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized ? normalized : "-";
};
const displayNumber = (value: unknown, suffix = "") => {
if (value === null || value === undefined || value === "") return "-";
const normalized = Number(value);
if (Number.isNaN(normalized)) return display(value);
return `${normalized.toLocaleString()}${suffix}`;
};
const parseElapsedYears = (value: unknown) => {
if (value === null || value === undefined || value === "") return null;
const normalized = Number(value);
return Number.isFinite(normalized) ? normalized : null;
};
const displayElapsedYears = (value: unknown) => displayNumber(value, "년");
const normalizePipeUseCodes = (data: any): PipeUseCodeItem[] => {
if (!Array.isArray(data)) return [];
return data.map((item: any) => ({
lwpoCd: String(item?.lwpoCd ?? "").trim(),
lwpoCdNm: String(item?.lwpoCdNm ?? "").trim(),
}));
};
const loadPipeUseCodes = async () => {
if (pipeUseCodeCache) return pipeUseCodeCache;
if (!pipeUseCodePromise) {
pipeUseCodePromise = getCmsData(`/api/v1/code/cache/OGC011/Y`)
.catch(() => getCmsData(`/api/v1/code/cache/OGC011`))
.then((data) => {
const codes = normalizePipeUseCodes(data);
pipeUseCodeCache = codes;
return codes;
})
.finally(() => {
pipeUseCodePromise = null;
});
}
return pipeUseCodePromise;
};
export default function SinkholeDetailPanel({
detail,
nearbyPipes,
radiusMeters,
onClose,
onSelectPipe,
}: Props) {
// 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다.
const { restoreStatusLabels } = useSinkholeCommonCodes();
// 파일 생성 중 버튼 표시와 같은 이벤트 루프의 중복 클릭을 함께 제어한다.
const [isDownloading, setIsDownloading] = useState(false);
const downloadInProgressRef = useRef(false);
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(
new Map(),
);
const canShowNearby = String(detail.coordStatus ?? "").trim() === "SUCCESS";
useEffect(() => {
let cancelled = false;
loadPipeUseCodes()
.then((codes) => {
if (cancelled) return;
setPipeUseCodeMap(
new Map(codes.map((item) => [item.lwpoCd, item.lwpoCdNm])),
);
})
.catch((error) => {
console.error("Failed to load OGC011 pipe use codes:", error);
});
return () => {
cancelled = true;
};
}, []);
const getPipeUseName = (pipe: SinkholeNearbyPipe) => {
const rawCode = String(pipe.pipeTypeCd ?? pipe.pipeTypeName ?? "").trim();
if (!rawCode) return "-";
return pipeUseCodeMap.get(rawCode) ?? rawCode;
};
const nearbyPipeElapsedSummary = useMemo(() => {
const elapsedYears = nearbyPipes
.map((pipe) => parseElapsedYears(pipe.elapsedYears))
.filter((value): value is number => value !== null);
if (elapsedYears.length === 0) {
return { average: null, oldest: null };
}
const total = elapsedYears.reduce((sum, value) => sum + value, 0);
const average = Math.round((total / elapsedYears.length) * 10) / 10;
const oldest = Math.max(...elapsedYears);
return { average, oldest };
}, [nearbyPipes]);
const summaryTableData = useMemo(() => {
const restoreStatus = String(detail.restoreStatus ?? "").trim();
return [
{
acdntNo: display(detail.acdntNo),
ocrnYmd: display(detail.ocrnYmd),
fullAddress: display(detail.fullAddress || detail.daddr),
sizeWidth: display(detail.sizeWidth),
sizeLength: display(detail.sizeLength),
sizeDepth: display(detail.sizeDepth),
detailCause: display(detail.detailCause),
restoreStatus: display(
restoreStatusLabels[restoreStatus] || restoreStatus,
),
restoreCompletedYmd: display(detail.restoreCompletedYmd),
},
];
}, [detail, restoreStatusLabels]);
const summaryHeadList = [
{ id: "acdntNo", title: "사고번호" },
{ id: "ocrnYmd", title: "발생일자" },
{ id: "fullAddress", title: "주소" },
{ id: "sizeWidth", title: "발생규모폭" },
{ id: "sizeLength", title: "발생규모연장" },
{ id: "sizeDepth", title: "발생규모깊이" },
{ id: "detailCause", title: "상세발생원인" },
{ id: "restoreStatus", title: "복구상태" },
{ id: "restoreCompletedYmd", title: "복구완료일자" },
];
/** 클릭 시의 상세·반경·표시값을 고정해 탭 전환 중에도 한 사고만 내보낸다. */
const handleDownload = async () => {
if (downloadInProgressRef.current) return;
downloadInProgressRef.current = true;
setIsDownloading(true);
const summary = summaryTableData[0] as Record<string, string>;
try {
await downloadSinkholeDetailExcel({
detail: { ...detail },
radiusMeters,
summaryEntries: summaryHeadList.map(({ id, title }) => [
title,
summary[id] ?? "-",
]),
getPipeUseName,
});
} catch (error) {
console.error("지반침하 상세 엑셀 다운로드 실패:", error);
alert(
error instanceof Error
? error.message
: "엑셀 다운로드 중 오류가 발생했습니다.",
);
} finally {
downloadInProgressRef.current = false;
setIsDownloading(false);
}
};
return (
<div
id="popup"
className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-border bg-background transition-all duration-300"
>
<div className="card-header relative flex items-center justify-between bg-primary p-4">
<div className="flex-1">
<h3 className="text-2xl font-medium text-primary-foreground">
지반침하정보
<br />
사고번호 : {display(detail.acdntNo)}
</h3>
</div>
<Button
onClick={onClose}
className="text-primary-foreground/80 flex items-center space-x-4 hover:text-primary-foreground focus:outline-none"
size="xs"
>
✕
</Button>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<VerticalTable
headName="발생정보"
tableData={summaryTableData}
headlist={summaryHeadList}
pageSize={20}
total={0}
cellClick={undefined}
onPageChange={function (): void {
throw new Error("Function not implemented.");
}}
/>
<section className="pb-[1.2rem]">
<div className="card-header !block px-[1.2rem]">
<div className="flex items-center justify-between gap-2">
<h3 className="ml-3 min-w-0 flex-1 text-2xl font-medium text-label">
침하발생 반경내 관로정보
</h3>
<Button
type="button"
className="hover:bg-primary/90 mr-3 shrink-0 bg-primary text-primary-foreground"
onClick={handleDownload}
disabled={isDownloading}
aria-busy={isDownloading}
>
{isDownloading ? "다운로드 중..." : "엑셀다운로드"}
</Button>
</div>
{canShowNearby ? (
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-muted-foreground">
<span>반경 {radiusMeters}m · </span>
<span className="font-bold text-primary">
{nearbyPipes.length.toLocaleString()}건
</span>
<span>, 평균경과 </span>
<span className="font-bold text-primary">
{displayElapsedYears(nearbyPipeElapsedSummary.average)}
</span>
<span>, 최장경과 </span>
<span className="font-bold text-primary">
{displayElapsedYears(nearbyPipeElapsedSummary.oldest)}
</span>
</div>
) : null}
</div>
{!canShowNearby ? (
<div className="card !m-[1.2rem] !mt-0 rounded-sm border border-amber-200 bg-amber-50 px-3 py-3 text-[1.05rem] text-amber-700">
좌표가 없어 반경 내 하수관로를 조회할 수 없습니다.
{detail.geocodeErrorMessage ? (
<div className="mt-2 break-words text-[0.98rem] text-amber-800">
사유: {display(detail.geocodeErrorMessage)}
</div>
) : null}
</div>
) : nearbyPipes.length === 0 ? (
<div className="card !m-[1.2rem] !mt-0 rounded-sm border border-border bg-secondary px-3 py-3 text-[1.05rem] text-label">
반경 내 하수관로 정보가 없습니다.
</div>
) : (
<div className="card !mx-[1.2rem] !mb-[1.2rem] !mt-0 overflow-hidden">
<table className="w-full table-fixed border-collapse">
<colgroup>
<col className="w-[34px]" />
<col className="w-[90px]" />
<col className="w-[60px]" />
<col className="w-[56px]" />
<col className="w-[56px]" />
</colgroup>
<thead>
<tr>
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
NO
</th>
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
관로 ID
</th>
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
용도
</th>
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
경과년도
</th>
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
거리(m)
</th>
</tr>
</thead>
<tbody>
{nearbyPipes.map((pipe, index) => (
<tr
key={`${pipe.fid}-${index}`}
className="cursor-pointer hover:bg-accent"
onClick={() => onSelectPipe(pipe)}
>
<td className="border border-border px-1 py-2 text-center text-[0.95rem] text-foreground">
{index + 1}
</td>
<td className="border border-border px-1 py-2 text-left text-[0.95rem] font-semibold text-primary underline underline-offset-2">
{display(pipe.pipeId)}
</td>
<td className="whitespace-nowrap border border-border px-1 py-2 text-center text-[0.95rem] text-foreground">
{getPipeUseName(pipe)}
</td>
<td className="border border-border px-1 py-2 text-right text-[0.95rem] text-foreground">
{displayElapsedYears(pipe.elapsedYears)}
</td>
<td className="border border-border px-1 py-2 text-right text-[0.95rem] text-foreground">
{displayNumber(pipe.distanceMeters)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</div>
);
}