diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index 76b511b8f..ddfeda2ff 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -166,6 +166,7 @@ $commonDocsRoot = Join-Path $beRepoRoot 'docs' ## 작업 원칙 +- `sn` 프론트엔드 엑셀 다운로드 기능을 신규 개발하거나 수정할 때는 `src/shared/util/download-excel-workbook.ts` 재사용을 먼저 검토한다. 세부 기준은 [컴포넌트정책의 공통 유틸 재사용 기준](docs/기준문서/004-컴포넌트정책.md#공통-유틸-재사용-기준)을 따른다. 이 코드 경로는 `FE_APP_ROOT` 기준이다. - 일반 목록·모든 팝업은 공통 AGENTS.md의 서버 페이징·지도·Excel 계약을 따른다. 지도 전체 표출에 현재 그리드 페이지 배열을 재사용하지 않는다. Excel은 적용조건 전체를 사용하고 대량 원시 자료는 서버 생성으로 분리한다. 화면 페이징/페이징 없음 예외는 사유와 검증 범위를 메뉴 > 화면명별로 기록한다. 1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다. diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-detail-excel.ts b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-detail-excel.ts new file mode 100644 index 000000000..44155f5b1 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-detail-excel.ts @@ -0,0 +1,126 @@ +/** 지반침하 상세의 표시값과 다운로드 시점 관로 조회 결과를 두 시트로 저장한다. */ +import { getRawJson } from "@/shared/api/sewerage-api"; +import { + downloadExcelWorkbook, + type ExcelCellValue, + type ExcelSheetSpec, +} from "@/shared/util/download-excel-workbook"; +import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types"; + +type DownloadOptions = { + detail: SinkholeDetail; + radiusMeters: number; + summaryEntries: Array<[string, string]>; + getPipeUseName: (pipe: SinkholeNearbyPipe) => string; +}; + +/** 식별자는 문자열을 유지하고 결측 숫자는 0으로 바꾸지 않는다. */ +const numberCell = (value: unknown): ExcelCellValue => { + if (value == null || String(value).trim() === "") return "-"; + const number = Number(value); + return Number.isFinite(number) ? number : String(value); +}; + +/** 화면과 같은 순서의 발생정보 및 반경 내 관로 목록 시트 명세를 만든다. */ +export const buildSinkholeDetailSheets = ( + options: DownloadOptions, + pipes: SinkholeNearbyPipe[], + noCoordinates: boolean, +): ExcelSheetSpec[] => { + const pipeRows: ExcelCellValue[][] = pipes.map((pipe, index) => [ + index + 1, + String(pipe.pipeId ?? "").trim() || "-", + options.getPipeUseName(pipe), + numberCell(pipe.elapsedYears), + numberCell(pipe.distanceMeters), + ]); + if (pipeRows.length === 0) + pipeRows.push([ + noCoordinates + ? "좌표가 없어 반경 내 하수관로를 조회할 수 없습니다." + : "반경 내 하수관로 정보가 없습니다.", + ]); + return [ + { + name: "지반침하정보", + rows: [["항목", "내용"], ...options.summaryEntries], + columnWidths: [22, 75], + }, + { + name: "하수관로목록", + rows: [ + ["사고번호", String(options.detail.acdntNo)], + ["조회 반경(m)", options.radiusMeters], + ["조회 기준", "기존 반경 조회 기준, 최대 200건 (가까운 거리순)"], + [], + ["NO", "관로 ID", "용도", "경과년도(년)", "거리(m)"], + ...pipeRows, + ], + columnWidths: [12, 36, 24, 20, 18], + autoFilterRange: `A5:E${5 + pipes.length}`, + mergeRanges: ["B3:E3", ...(pipes.length === 0 ? ["A6:E6"] : [])], + }, + ]; +}; + +/** 기존 반경 API를 다시 조회하여 실패를 빈 목록으로 내보내지 않는다. */ +export const downloadSinkholeDetailExcel = async (options: DownloadOptions) => { + // 비동기 재조회 동안 호출자의 상세·반경·표시 행이 변경되어도 클릭 시점을 유지한다. + const snapshot: DownloadOptions = { + ...options, + detail: { ...options.detail }, + summaryEntries: options.summaryEntries.map(([title, value]) => [ + title, + value, + ]), + }; + const { detail, radiusMeters } = snapshot; + const noCoordinates = String(detail.coordStatus ?? "").trim() !== "SUCCESS"; + let pipes: SinkholeNearbyPipe[] = []; + if (!noCoordinates) { + const hasPoint = + detail.x != null && + detail.y != null && + String(detail.x).trim() !== "" && + String(detail.y).trim() !== "" && + Number.isFinite(Number(detail.x)) && + Number.isFinite(Number(detail.y)); + const params = new URLSearchParams({ radiusMeters: String(radiusMeters) }); + if (hasPoint) { + params.set("x", String(detail.x)); + params.set("y", String(detail.y)); + } + const path = hasPoint + ? "nearbyPipesByPoint" + : `${encodeURIComponent(detail.acdntNo)}/nearbyPipes`; + const raw = await getRawJson( + `/sewerage-service/api/v1/sinkholes/${path}?${params}`, + ); + const response = raw?.data ?? raw; + if (response?.coordStatus !== "SUCCESS") + throw new Error( + "관로 조회 좌표를 확인할 수 없습니다. 상세정보를 다시 조회해 주세요.", + ); + if ( + !Array.isArray(response?.items) || + response.items.some( + (item: unknown) => + !item || + typeof item !== "object" || + Array.isArray(item) || + !("pipeId" in item) || + !("fid" in item), + ) + ) + throw new Error( + "관로 조회 응답이 올바르지 않습니다. 다시 시도해 주세요.", + ); + pipes = response.items; + } + const now = new Date(); + const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + await downloadExcelWorkbook({ + fileName: `지반침하정보_${detail.acdntNo}_${date}.xlsx`, + sheets: buildSinkholeDetailSheets(snapshot, pipes, noCoordinates), + }); +}; diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx index 64681befb..816bfa053 100644 --- a/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx @@ -4,12 +4,13 @@ */ "use client"; -import { useEffect, useMemo, useState } from "react"; +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; @@ -81,6 +82,9 @@ export default function SinkholeDetailPanel({ }: Props) { // 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다. const { restoreStatusLabels } = useSinkholeCommonCodes(); + // 파일 생성 중 버튼 표시와 같은 이벤트 루프의 중복 클릭을 함께 제어한다. + const [isDownloading, setIsDownloading] = useState(false); + const downloadInProgressRef = useRef(false); const [pipeUseCodeMap, setPipeUseCodeMap] = useState>( new Map(), ); @@ -127,27 +131,24 @@ export default function SinkholeDetailPanel({ 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 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: "사고번호" }, @@ -161,6 +162,35 @@ export default function SinkholeDetailPanel({ { id: "restoreCompletedYmd", title: "복구완료일자" }, ]; + /** 클릭 시의 상세·반경·표시값을 고정해 탭 전환 중에도 한 사고만 내보낸다. */ + const handleDownload = async () => { + if (downloadInProgressRef.current) return; + downloadInProgressRef.current = true; + setIsDownloading(true); + const summary = summaryTableData[0] as Record; + 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 (