diff --git a/apps/sn/src/features/fldng-predc-prst/lib/fldng-predc-prst-excel.ts b/apps/sn/src/features/fldng-predc-prst/lib/fldng-predc-prst-excel.ts index 5535ff05a..751477c3e 100644 --- a/apps/sn/src/features/fldng-predc-prst/lib/fldng-predc-prst-excel.ts +++ b/apps/sn/src/features/fldng-predc-prst/lib/fldng-predc-prst-excel.ts @@ -3,6 +3,10 @@ import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; export type FloodPredictionExcelRow = { ctpvNm: string; sggNm: string; + /** `/prst` 조회 때 시군구-관측소 매핑에서 붙인 관측소명이다. 기존 로직 행이나 매핑에 없는 조합은 null이다. */ + obsrvnBrnchNm: string | null; + /** 시군구 면적 중 관측소 영역의 비율(%)이다. 기존 로직 행이나 매핑에 없는 조합은 null이다. */ + areaRt: number | null; analysisDateTime: string; rainBeginningDateTime: string; observedAccumulatedRainfall: number | null; @@ -12,10 +16,13 @@ export type FloodPredictionExcelRow = { floodRiskGrade: string; }; +/** Excel 행 객체 키를 한글 헤더로 바꾸는 매핑이다. 실제 열 순서는 행 객체의 키 순서를 따르므로 같은 순서로 둔다. */ const HEADERS: Record = { rowNo: "순번", ctpvNm: "시도", sggNm: "시군구", + obsrvnBrnchNm: "관측소", + areaRt: "면적비율(%)", analysisDateTime: "분석시각", rainBeginningDateTime: "강우시작시각", observedAccumulatedRainfall: "관측 누적강수량(mm)", @@ -39,12 +46,20 @@ export const downloadFloodPredictionStatusExcel = async ( rowNo: index + 1, ctpvNm: row.ctpvNm || "-", sggNm: row.sggNm || "-", + // 관측소·면적비율은 시군구 뒤에 두고, 기존 로직 행이나 매핑에 없는 조합은 `-`로 내보낸다. + obsrvnBrnchNm: row.obsrvnBrnchNm || "-", + // 면적비율은 목록과 같이 소수 둘째 자리까지 반올림한 숫자로 내보낸다. + areaRt: + row.areaRt == null || !Number.isFinite(Number(row.areaRt)) + ? "-" + : Number(Number(row.areaRt).toFixed(2)), analysisDateTime: formatDateTime(row.analysisDateTime), rainBeginningDateTime: formatDateTime(row.rainBeginningDateTime), observedAccumulatedRainfall: row.observedAccumulatedRainfall == null ? "-" : Number(row.observedAccumulatedRainfall.toFixed(1)), + // 관측소 기준 행의 예측시간 0(대표값이 분석시각)도 목록과 같이 `0시간 후`로 내보낸다. predictionHour: row.predictionHour == null ? "-" : `${row.predictionHour}시간 후`, predictedAccumulatedRainfall: @@ -60,7 +75,8 @@ export const downloadFloodPredictionStatusExcel = async ( data: excelRows as unknown as JSON[], fileName: "침수예측현황.xlsx", sheetNameArray: ["침수예측현황"], - headerRange: "A1:J1", + // 순번~침수위험도 12개 열(관측소·면적비율 포함)의 헤더 행 범위다. + headerRange: "A1:L1", headerMapping: HEADERS, }); }; diff --git a/apps/sn/src/features/fldng-predc-prst/ui/PrbbtRnflDialog.tsx b/apps/sn/src/features/fldng-predc-prst/ui/PrbbtRnflDialog.tsx index bd9975312..ddf0cbea6 100644 --- a/apps/sn/src/features/fldng-predc-prst/ui/PrbbtRnflDialog.tsx +++ b/apps/sn/src/features/fldng-predc-prst/ui/PrbbtRnflDialog.tsx @@ -6,7 +6,10 @@ import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from "@commo import { downloadExcelWorkbook } from "@/shared/util/download-excel-workbook"; -/** 시군구별 면적가중 확률강우량 한 셀의 API 응답이다. */ +/** + * 시군구별 면적가중 확률강우량 한 셀의 API 응답이다. + * 관측소 기준표 응답에서는 최근접 확률관측소 1곳의 값을 면적가중 없이 그대로 담는다. + */ export type PrbbtRnflCell = { frequencyYear: number; durationHour: number; @@ -20,18 +23,37 @@ export type PrbbtRnflStation = { areaRt: number; }; -/** 확률강우량 표와 그 계산에 사용한 관측소 비율을 함께 받는다. */ +/** + * 확률강우량 표와 그 계산에 사용한 관측소 비율을 함께 받는다. + * 시군구 기준표(`/prst/prbbt-rnfl?sggCd=`)는 `sggCd`를, 관측소 기준표(`/prst/prbbt-rnfl/obsvtr?obsrvnBrnchCd=`)는 + * `sggCd` 대신 `obsrvnBrnchCd`·`obsrvnBrnchNm`을 담으므로 두 응답을 모두 받도록 식별 필드는 선택값으로 둔다. + */ export type PrbbtRnflResponse = { - sggCd: string; + /** 시군구 기준표 응답의 시군구코드다. 관측소 기준표 응답에는 없다. */ + sggCd?: string; + /** 관측소 기준표 응답의 관측지점코드다. 시군구 기준표 응답에는 없다. */ + obsrvnBrnchCd?: string; + /** 관측소 기준표 응답의 관측소명이다. 시군구 기준표 응답에는 없다. */ + obsrvnBrnchNm?: string; rainfall: PrbbtRnflCell[]; + /** 시군구 기준은 면적가중에 쓴 확률관측소 전부, 관측소 기준은 최근접 확률관측소 1곳(비율 100)이다. */ stations: PrbbtRnflStation[]; areaRtTotal: number; }; +/** + * 기준표를 연 목록 행의 분석 기준이다. + * `sgg`는 기존 시군구 면적가중 행, `obsvtr`는 관측지점코드가 있는 관측소 기준(새 로직) 행이다. + */ +export type PrbbtRnflTargetKind = "sgg" | "obsvtr"; + type PrbbtRnflDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; - sggName: string; + /** 상단 제목·표 제목·Excel 파일명에 쓰는 대상 이름이다. 시군구 기준은 시군구명, 관측소 기준은 관측소명이다. */ + targetName: string; + /** 대상 이름이 시군구인지 관측소인지 구분해 아래 표 제목과 안내 문구를 고른다. */ + targetKind: PrbbtRnflTargetKind; selectedDurationHour: number | null; selectedFrequencyYear: number | null; data: PrbbtRnflResponse | null; @@ -45,11 +67,27 @@ const formatRainfall = (value: number) => const formatAreaRatio = (value: number) => Number.isFinite(Number(value)) ? Number(value).toFixed(3) : "-"; +/** + * 아래 확률관측소 표의 제목(Excel 두 번째 시트명 겸용)이다. + * 관측소 기준은 면적가중 없이 최근접 확률관측소 1곳을 쓰므로 `적용 확률관측소`로 보인다. + */ +const STATION_TABLE_TITLE: Record = { + sgg: "확률관측소 면적비율", + obsvtr: "적용 확률관측소", +}; + +/** 기준자료가 없을 때 보이는 안내 문구다. 관측소 기준은 시군구 대신 관측소로 안내한다. */ +const EMPTY_MESSAGE: Record = { + sgg: "해당 시군구의 확률강우량 기준자료가 없습니다.", + obsvtr: "해당 관측소의 확률강우량 기준자료가 없습니다.", +}; + /** 재현기간을 행, 강우지속시간을 열로 구성하고 선택한 두 기준이 교차하는 값만 색으로 강조한다. */ export default function PrbbtRnflDialog({ open, onOpenChange, - sggName, + targetName, + targetKind, selectedDurationHour, selectedFrequencyYear, data, @@ -60,6 +98,8 @@ export default function PrbbtRnflDialog({ const tableScrollRef = useRef(null); /** 목록에서 클릭한 지속시간에 대응하는 표 헤더이다. */ const selectedDurationHeaderRef = useRef(null); + /** 행 종류에 따른 아래 표 제목이다. 시군구 기준 행은 기존 제목을 그대로 쓴다. */ + const stationTableTitle = STATION_TABLE_TITLE[targetKind]; const { frequencies, durations, rainfallByKey } = useMemo(() => { const rainfall = data?.rainfall ?? []; @@ -79,11 +119,14 @@ export default function PrbbtRnflDialog({ }; }, [data]); - /** 화면에 표시 중인 확률강우량 표와 관측소 면적비율 표를 두 시트로 내려받는다. */ + /** + * 화면에 표시 중인 확률강우량 표와 관측소 면적비율 표를 두 시트로 내려받는다. + * 파일명은 대상 이름(시군구명 또는 관측소명)을, 두 번째 시트명은 아래 표 제목을 따른다. + */ const handleExcelDownload = async () => { const stations = data?.stations ?? []; await downloadExcelWorkbook({ - fileName: `${sggName}_확률강우량`, + fileName: `${targetName}_확률강우량`, sheets: [ { name: "확률강우량", @@ -100,7 +143,7 @@ export default function PrbbtRnflDialog({ columnWidths: [14, ...durations.map(() => 12)], }, { - name: "확률관측소 면적비율", + name: stationTableTitle, rows: [ ["확률관측소코드", "관측소명", "면적비율(%)"], ...stations.map((station) => [ @@ -135,12 +178,12 @@ export default function PrbbtRnflDialog({ return ( {/* 우측 상단 닫기(X) 버튼과 겹치지 않도록 pr-14 여백을 둔다. */} - {sggName} 확률강우량 + {targetName} 확률강우량