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