diff --git a/apps/sn/.env.local b/apps/sn/.env.local index dce96bf40..3d871b007 100644 --- a/apps/sn/.env.local +++ b/apps/sn/.env.local @@ -12,8 +12,8 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y} SERVER_API_URL = http://dev.onasys.co.kr:8000 -SEWERAGE_API_URL = http://localhost:8883 -# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083 +# SEWERAGE_API_URL = http://localhost:8883 +SEWERAGE_API_URL = http://con.mind-one.co.kr:11083 DC_URL = https://apigateway.apps.rsonesoft.com # NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login diff --git a/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx index f6020437d..a0cf5765e 100644 --- a/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx @@ -13,6 +13,7 @@ import { useAlertStore } from "@/shared/lib"; import { Button } from "@common/components"; import { getData } from "@/components/lib/api"; +import { appendApiSearchParams } from "@/shared/util/util"; import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; import useStore from "@/store/useStore"; @@ -132,16 +133,13 @@ export default function InftwtResultPopupPage() { : "inftwtAnlsSn,desc", ); - if (nextFilters.aplcnYmFrom) - queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom); - if (nextFilters.aplcnYmTo) - queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo); - if (nextFilters.ctpvCd !== "0") - queryParams.set("ctpvCd", nextFilters.ctpvCd); - if (nextFilters.sggCd !== "0") - queryParams.set("sggCd", nextFilters.sggCd); - if (nextFilters.swtrmtFcltNm.trim()) - queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim()); + appendApiSearchParams(queryParams, { + aplcnYmFrom: nextFilters.aplcnYmFrom, + aplcnYmTo: nextFilters.aplcnYmTo, + ctpvCd: nextFilters.ctpvCd, + sggCd: nextFilters.sggCd, + swtrmtFcltNm: nextFilters.swtrmtFcltNm, + }); const response = await getData(`v1/inftwtAnls?${queryParams.toString()}`); const content = Array.isArray(response?.content) ? response.content : []; diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx index 44a32f067..969f472d8 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx @@ -6,6 +6,7 @@ import { Button, Input } from "@common/components"; import { StandardPopupLayout } from "@common/business_components"; import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; import { getData } from "@/components/lib/api"; +import { appendApiSearchParams } from "@/shared/util/util"; import LoadingOverlay from "@/components/ui/LoadingBar"; import useStore from "@/store/useStore"; import { useAlertStore } from "@/shared/lib"; @@ -131,19 +132,16 @@ export default function PipeAnalysisDetailPopupPage() { size: String(size), }); - if (nextFilters.ctpvCd !== "0") params.set("ctpvCd", nextFilters.ctpvCd); - if (nextFilters.sggCd !== "0") params.set("sggCd", nextFilters.sggCd); - if (nextFilters.swtrmtZoneNm !== "0") - params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm); - if (baseElapsedYearBucket) - params.set("elapsedYearBucket", baseElapsedYearBucket); - if (nextFilters.materialCode !== "0") - params.set("materialCode", nextFilters.materialCode); - if (nextFilters.useCd !== "0") params.set("useCd", nextFilters.useCd); - if (nextFilters.diameterFrom) - params.set("diameterFrom", nextFilters.diameterFrom); - if (nextFilters.diameterTo) - params.set("diameterTo", nextFilters.diameterTo); + appendApiSearchParams(params, { + ctpvCd: nextFilters.ctpvCd, + sggCd: nextFilters.sggCd, + swtrmtZoneNm: nextFilters.swtrmtZoneNm, + elapsedYearBucket: baseElapsedYearBucket, + materialCode: nextFilters.materialCode, + useCd: nextFilters.useCd, + diameterFrom: nextFilters.diameterFrom, + diameterTo: nextFilters.diameterTo, + }); if (scope.regionCode) params.set("scopeRegionCode", scope.regionCode); const response = await getData( diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx index 2e0791e92..725921045 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx @@ -14,6 +14,7 @@ import { StandardPopupLayout } from "@common/business_components"; import UiPageNations from "@common/business_components/ui/table/uiPageNations"; import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; import { getData } from "@/components/lib/api"; +import { appendApiSearchParams } from "@/shared/util/util"; import LoadingOverlay from "@/components/ui/LoadingBar"; import useStore from "@/store/useStore"; import { useAlertStore } from "@/shared/lib"; @@ -144,10 +145,11 @@ export default function PipeAnalysisPopupPage() { analysisType: nextFilters.analysisType, }); - if (nextFilters.ctpvCd !== "0") params.set("ctpvCd", nextFilters.ctpvCd); - if (nextFilters.sggCd !== "0") params.set("sggCd", nextFilters.sggCd); - if (nextFilters.swtrmtZoneNm !== "0") - params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm); + appendApiSearchParams(params, { + ctpvCd: nextFilters.ctpvCd, + sggCd: nextFilters.sggCd, + swtrmtZoneNm: nextFilters.swtrmtZoneNm, + }); if (nextFilters.scopeRegionCode) params.set("scopeRegionCode", nextFilters.scopeRegionCode); diff --git a/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx index 8c2521ada..06641fed8 100644 --- a/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx @@ -10,6 +10,7 @@ import { import { Button } from "@common/components"; import { getData } from "@/components/lib/api"; +import { appendApiSearchParams } from "@/shared/util/util"; import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { useAlertStore } from "@/shared/lib"; @@ -134,16 +135,13 @@ export default function RdiiResultPopupPage() { : "rainInfwtAnlsSn,desc", ); - if (nextFilters.aplcnYmFrom) - queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom); - if (nextFilters.aplcnYmTo) - queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo); - if (nextFilters.ctpvCd !== "0") - queryParams.set("ctpvCd", nextFilters.ctpvCd); - if (nextFilters.sggCd !== "0") - queryParams.set("sggCd", nextFilters.sggCd); - if (nextFilters.swtrmtFcltNm.trim()) - queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim()); + appendApiSearchParams(queryParams, { + aplcnYmFrom: nextFilters.aplcnYmFrom, + aplcnYmTo: nextFilters.aplcnYmTo, + ctpvCd: nextFilters.ctpvCd, + sggCd: nextFilters.sggCd, + swtrmtFcltNm: nextFilters.swtrmtFcltNm, + }); const response = await getData(`v1/rdiiAnls?${queryParams.toString()}`); const content = Array.isArray(response?.content) ? response.content : []; diff --git a/apps/sn/docs/작업계획/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측현황-작업계획.md b/apps/sn/docs/작업계획/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측현황-작업계획.md index 17410affd..ebf869639 100644 --- a/apps/sn/docs/작업계획/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측현황-작업계획.md +++ b/apps/sn/docs/작업계획/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측현황-작업계획.md @@ -69,3 +69,11 @@ - 2026-08-18 목록 강수량을 소수점 한 자리로, 헤더를 `초과빈도년도`로 변경하고 값의 `년` 접미사를 제거했다. - 기존 `.next` 산출물을 재생성한 뒤 `pnpm --filter sn run build`를 최종 통과했다. - TypeScript 정적검사는 저장소 기존 `ignoreDeprecations` 설정과 실행 중 산출물 잠금 때문에 완료하지 못했고, Next 빌드는 최적화 단계 장기 대기 후 중단했다. + +## Excel 프론트 생성 전환 결과 (2026-09-03) + +- 현재 적용 검색조건으로 `/api/v1/fldng/predc/prst`를 500건 단위 전체 페이지 조회한다. +- 공통 `fetchAllPages`로 `totalElements`와 실제 수집 건수 일치를 검증한다. +- `fldng-predc-prst-excel.ts`가 화면 열 순서의 단일 시트 `침수예측현황.xlsx`를 브라우저에서 생성한다. +- 서버 `/prst/excel` Blob 호출과 `getBlobData`, `fileDownload` 사용을 제거했다. +- 독립 TypeScript `--noEmit` 검증을 통과했다. diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx index 1068af5a1..12666c3be 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx @@ -26,7 +26,7 @@ import { type PaginationState, } from "@tanstack/react-table"; -import { formatDate } from "../../common/util"; +import { appendApiSearchParams, formatDate } from "@/shared/util/util"; import { getAllCtyFldngPrvtnBusinesses, getCtyFldngPrvtnBusinesses, @@ -454,9 +454,7 @@ export function CtyFldngPrvtnBizListPage() { ? `${nextSort.key},${nextSort.direction}` : "ctyFldngPrvtnBizSn,desc", }); - Object.entries(condition).forEach(([k, v]) => { - if (v.trim()) query.set(k, v.trim()); - }); + appendApiSearchParams(query, condition); setLoading(true); try { const result = await getCtyFldngPrvtnBusinesses(query); @@ -529,9 +527,7 @@ export function CtyFldngPrvtnBizListPage() { setLoading(true); try { const query = new URLSearchParams(); - Object.entries(applied).forEach(([k, v]) => { - if (v.trim()) query.set(k, v.trim()); - }); + appendApiSearchParams(query, applied); await downloadCtyFldngPrvtnExcel( await getAllCtyFldngPrvtnBusinesses( query, diff --git a/apps/sn/src/features/fldngPredcPrst/lib/fldng-predc-prst-excel.ts b/apps/sn/src/features/fldngPredcPrst/lib/fldng-predc-prst-excel.ts new file mode 100644 index 000000000..4408e8e07 --- /dev/null +++ b/apps/sn/src/features/fldngPredcPrst/lib/fldng-predc-prst-excel.ts @@ -0,0 +1,66 @@ +import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; + +export type FloodPredictionExcelRow = { + ctpvNm: string; + sggNm: string; + analysisDateTime: string; + rainBeginningDateTime: string; + observedAccumulatedRainfall: number | null; + predictionHour: number | null; + predictedAccumulatedRainfall: number | null; + exceededFrequencyYear: number | null; + floodRiskGrade: string; +}; + +const HEADERS: Record = { + rowNo: "순번", + ctpvNm: "시도", + sggNm: "시군구", + analysisDateTime: "분석시각", + rainBeginningDateTime: "강우 시작시각", + observedAccumulatedRainfall: "관측 누적강수량(mm)", + predictionHour: "예측시간", + predictedAccumulatedRainfall: "예측 누적강수량(mm)", + exceededFrequencyYear: "초과빈도(년)", + floodRiskGrade: "침수위험도", +}; + +/** yyyyMMddHHmm 형식의 API 시각을 Excel 표시 문자열로 변환한다. */ +const formatDateTime = (value: string) => + /^\d{12}$/.test(value) + ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)} ${value.slice(8, 10)}:${value.slice(10, 12)}` + : value || "-"; + +/** 적용 조회조건의 전체 침수예측현황을 브라우저에서 한 시트 XLSX로 생성한다. */ +export const downloadFloodPredictionStatusExcel = async ( + rows: FloodPredictionExcelRow[], +) => { + const excelRows = rows.map((row, index) => ({ + rowNo: index + 1, + ctpvNm: row.ctpvNm || "-", + sggNm: row.sggNm || "-", + analysisDateTime: formatDateTime(row.analysisDateTime), + rainBeginningDateTime: formatDateTime(row.rainBeginningDateTime), + observedAccumulatedRainfall: + row.observedAccumulatedRainfall == null + ? "-" + : Number(row.observedAccumulatedRainfall.toFixed(1)), + predictionHour: + row.predictionHour == null ? "-" : `${row.predictionHour}시간 후`, + predictedAccumulatedRainfall: + row.predictedAccumulatedRainfall == null + ? "-" + : Number(row.predictedAccumulatedRainfall.toFixed(1)), + exceededFrequencyYear: + row.exceededFrequencyYear == null ? "-" : row.exceededFrequencyYear, + floodRiskGrade: row.floodRiskGrade || "-", + })); + + await downloadExcelWithKoreanHeaders({ + data: excelRows as unknown as JSON[], + fileName: "침수예측현황.xlsx", + sheetNameArray: ["침수예측현황"], + headerRange: "A1:J1", + headerMapping: HEADERS, + }); +}; diff --git a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx index 93c588c75..b16991eb1 100644 --- a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx +++ b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx @@ -8,8 +8,8 @@ import { } from "@common/business_components"; import { Button } from "@common/components"; -import { getBlobData, getData, getRawJson } from "@/components/lib/api"; -import { fileDownload } from "@/components/lib/utils/fileDownload"; +import { getData, getRawJson } from "@/components/lib/api"; +import { appendApiSearchParams } from "@/shared/util/util"; import LoadingOverlay from "@/components/ui/LoadingBar"; import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; @@ -18,6 +18,8 @@ import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; import { useAlertStore } from "@/shared/lib"; import useStore from "@/store/useStore"; import { createPredictionAnalysisTimeRange } from "../model/analysisTimeRange"; +import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; +import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel"; type ApiRow = { sggCd: string; @@ -55,6 +57,7 @@ type Filters = { }; const PAGE_SIZE = 10; +const EXCEL_DOWNLOAD_PAGE_SIZE = 500; const MAP_FREQUENCIES = new Set([30, 50, 80, 100]); const FREQUENCY_OPTIONS = [30, 50, 80, 100] as const; @@ -118,7 +121,7 @@ const buildInitialFilters = (): Filters => { rainBgngTo: "", analysisFrom: toDateTimeLocal(range.analysisFrom), analysisTo: toDateTimeLocal(range.analysisTo), - frequencyYear: "", + frequencyYear: "0", }; }; @@ -141,21 +144,21 @@ const formatNumber = (value: number | null, digits: number) => : Number(value).toFixed(digits); /** 화면 조회조건을 목록·Excel API 쿼리로 변환한다. */ -const buildQuery = (filters: Filters, page?: number) => { +const buildQuery = (filters: Filters, page?: number, size = PAGE_SIZE) => { const params = new URLSearchParams(); if (page !== undefined) { params.set("page", String(page - 1)); - params.set("size", String(PAGE_SIZE)); + params.set("size", String(size)); } - if (filters.ctpvCd !== "0") params.set("ctpvCd", filters.ctpvCd.slice(0, 2)); - if (filters.sggCd !== "0") params.set("sggCd", filters.sggCd.slice(0, 5)); - ( - ["rainBgngFrom", "rainBgngTo", "analysisFrom", "analysisTo"] as const - ).forEach((key) => { - const value = apiDate(filters[key]); - if (value) params.set(key, value); + appendApiSearchParams(params, { + ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2), + sggCd: filters.sggCd === "0" ? "0" : filters.sggCd.slice(0, 5), + rainBgngFrom: apiDate(filters.rainBgngFrom), + rainBgngTo: apiDate(filters.rainBgngTo), + analysisFrom: apiDate(filters.analysisFrom), + analysisTo: apiDate(filters.analysisTo), + frequencyYear: filters.frequencyYear, }); - if (filters.frequencyYear) params.set("frequencyYear", filters.frequencyYear); return params.toString(); }; @@ -176,7 +179,7 @@ const validateFilters = (filters: Filters) => { return "분석시각의 시작은 종료보다 늦을 수 없습니다."; } if ( - filters.frequencyYear && + filters.frequencyYear != "0" && !FREQUENCY_OPTIONS.includes( Number(filters.frequencyYear) as 30 | 50 | 80 | 100, ) @@ -389,10 +392,25 @@ export default function FldngPredcPrstPopupContent() { } setLoading(true); try { - const blob = await getBlobData( - `/sewerage-service/api/v1/fldng/predc/prst/excel?${buildQuery(appliedFilters)}`, - ); - fileDownload(blob, "침수예측현황.xlsx"); + const result = await fetchAllPages< + { content: ApiRow[]; totalElements: number }, + ApiRow + >({ + batchSize: EXCEL_DOWNLOAD_PAGE_SIZE, + fetchPage: async (pageIndex, size) => { + const response = await getRawJson( + `/sewerage-service/api/v1/fldng/predc/prst?${buildQuery(appliedFilters, pageIndex + 1, size)}`, + ); + const payload = response?.data ?? response; + return { + content: Array.isArray(payload?.content) ? payload.content : [], + totalElements: Number(payload?.totalElements ?? 0), + }; + }, + getItems: (response) => response.content, + getTotal: (response) => response.totalElements, + }); + await downloadFloodPredictionStatusExcel(result.items); } catch (error) { console.error("침수예측현황 Excel 다운로드 중 오류:", error); alert("침수예측현황 Excel 다운로드에 실패했습니다."); @@ -519,7 +537,7 @@ export default function FldngPredcPrstPopupContent() { })) } > - + {FREQUENCY_OPTIONS.map((frequency) => (