diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index a4923b8c4..f8b84ffa9 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -134,6 +134,8 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 ## 작업 원칙 +- 일반 목록·모든 팝업은 공통 AGENTS.md의 서버 페이징·지도·Excel 계약을 따른다. 지도 전체 표출에 현재 그리드 페이지 배열을 재사용하지 않는다. Excel은 적용조건 전체를 사용하고 대량 원시 자료는 서버 생성으로 분리한다. 화면 페이징/페이징 없음 예외는 사유와 검증 범위를 메뉴 > 화면명별로 기록한다. + 1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다. 2. `apps/sn`과 `packages/common/business_components` 범위를 넘는 수정은 하지 않는다. 3. 공통 패키지 수정은 `sn` 요구사항을 충족하기 위한 최소 범위로 제한한다. diff --git a/apps/sn/app/(map-layout)/map/components/common/table.ts b/apps/sn/app/(map-layout)/map/components/common/table.ts index 8a13eb830..a12e8c8e1 100644 --- a/apps/sn/app/(map-layout)/map/components/common/table.ts +++ b/apps/sn/app/(map-layout)/map/components/common/table.ts @@ -529,6 +529,15 @@ export const FldngDpthMapCol = [ // { id: 'shape_area', title: '형상면적(㎡)' }, // { id: 'shape_len', title: '형상길이' }, ]; +/** 기본정보 목록 API의 camelCase 속성을 침수흔적심선 그리드에 연결한다. */ +export const FldngDpthMapCol2 = [ + { id: 'rowNumber', title: '순번' }, + { id: 'fldngYr', title: '침수년도' }, + { id: 'ctpvNm', title: '시도명' }, + { id: 'sggNm', title: '시군구명' }, + { id: 'fldngDpth', title: '침수깊이(m)' }, + { id: 'fldngArea', title: '침수면적(㎡)' }, +]; export const FldngWtlvMapExcelCol = { 'fldngYr': '침수년도', 'fldngDstrNm': '침수재해명', diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx index b289831a3..79a1265a6 100644 --- a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx @@ -8,10 +8,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Button, Input } from "@common/components"; import { StandardPopupLayout } from "@common/business_components"; import { getRawJson } from "@/components/lib/api"; -import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; +import { requestGridExcel } from "@/shared/api/grid-export"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; import { useAlertStore } from "@/shared/lib"; -import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import useStore from "@/store/useStore"; import LoadingOverlay from "@/components/ui/LoadingBar"; import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; @@ -19,8 +18,6 @@ import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; import { FLDNG_HSTRY_LAYER_TYPE, FLDNG_HSTRY_DEFAULT_FILTERS, - FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE, - FLDNG_HSTRY_DOWNLOAD_HEADERS, FLDNG_HSTRY_LIST_HEADLIST, FLDNG_HSTRY_PAGE_SIZE, FLDNG_HSTRY_ROW_SELECT_MESSAGE, @@ -58,26 +55,6 @@ const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => { return ""; }; -const buildDownloadRows = (rows: FldngHstryTableRow[]) => - rows.map((row) => ({ - rowNo: row.rowNo, - floodYearDisplay: row.floodYearDisplay, - ctpvNm: row.ctpvNm, - sggNm: row.sggNm, - disasterName: row.disasterName, - rainBgngTmDisplay: row.rainBgngTmDisplay, - rainEndTmDisplay: row.rainEndTmDisplay, - rainTotDisplay: row.rainTotDisplay, - rainStrDisplay: row.rainStrDisplay, - floodHourDisplay: row.floodHourDisplay, - districtName: row.districtName, - causeDetailCode: row.causeDetailCode, - causeDetailName: row.causeDetailName, - avgFloodDepthDisplay: row.avgFloodDepthDisplay, - floodAreaDisplay: row.floodAreaDisplay, - exmnYearDisplay: row.exmnYearDisplay, - damageAmountDisplay: row.damageAmountDisplay, - })); const resolveRegionNames = ( row: FldngHstryTableRow, @@ -265,19 +242,13 @@ export default function FldngHstryPopupPage() { setIsLoading(true); try { - const response = await fetchAllPages({ - batchSize: FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE, - fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size), - getItems: (page) => page.rows, - getTotal: (page) => page.total, - }); - await downloadExcelWithKoreanHeaders({ - data: buildDownloadRows(response.items) as unknown as JSON[], - fileName: `침수발생이력_${new Date().toISOString().slice(0, 10)}.xlsx`, - sheetNameArray: ["침수발생이력"], - headerRange: "A1:Q1", - headerMapping: FLDNG_HSTRY_DOWNLOAD_HEADERS, - }); + const params = new URLSearchParams(buildFldngHstryListQueryString(appliedFilters, 1, FLDNG_HSTRY_PAGE_SIZE)); + params.delete("page"); params.delete("size"); + await requestGridExcel("v1/spatialGridExports/floodHistories", { + history: Object.fromEntries(params), + ctpvNames: Object.fromEntries(ctpv.map((item) => [String(item.val).trim(), item.text])), + sggNames: Object.fromEntries(sgg.map((item) => [String(item.val).trim(), item.text])), + }, `침수발생이력_${new Date().toISOString().slice(0, 10)}.xlsx`); } catch (error) { console.error("침수발생이력 다운로드 중 오류:", error); alert( 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 25c2b5977..32d8d1004 100644 --- a/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx @@ -11,8 +11,7 @@ import { type UiHeadProps, type UiTableSort, } from "@common/business_components"; -import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; -import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; +import { downloadAnalysisExcel } from "@/shared/lib/download-analysis-excel"; import { useAlertStore } from "@/shared/lib"; import { Button } from "@common/components"; @@ -241,29 +240,7 @@ export default function InftwtResultPopupPage() { try { setIsLoading(true); - const response = await fetchAllPages>({ - fetchPage: (page, size) => - getData( - `v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}?page=${page}&size=${size}`, - ), - getItems: (page) => - Array.isArray(page?.content) ? page.content : [], - getTotal: (page) => Number(page?.totalElements ?? 0), - }); - const excelRows = response.items; - - await downloadExcelWithKoreanHeaders({ - data: excelRows as unknown as JSON[], - fileName: `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`, - sheetNameArray: ["침입수 분석결과"], - headerRange: "A1:D1", - headerMapping: { - inftwtAnlsSn: "침입수분석\n분석번호", - pplMngNo: "관로\n관리번호", - anlsTm: "분석시각", - predcOilq: "예측유량\n(m³/h)", - }, - }); + await downloadAnalysisExcel("inftwt", selectedRow.inftwtAnlsSn, `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`); } catch (error) { console.error("침입수 분석결과 다운로드 중 오류:", error); alert("침입수 분석결과 다운로드 중 오류가 발생했습니다."); 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 e04012733..06c82e009 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx @@ -8,20 +8,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Button, Input } from "@common/components"; import { StandardPopupLayout } from "@common/business_components"; -import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; +import { requestGridExcel } from "@/shared/api/grid-export"; 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"; -import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { useUserStore } from "@/shared/lib/use-user-store"; import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table"; import { PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS, - PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS, PIPE_ANALYSIS_DETAIL_HEADLIST, PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE, applyScopeToDetailFilters, @@ -41,19 +39,6 @@ import { type DetailViewRow = ReturnType[number]; const PIPE_ANALYSIS_DETAIL_PAGE_SIZE = 10; const PIPE_ANALYSIS_MAP_WINDOW_NAME = "sn-sewerage-map-root"; -const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [ - "rowNo", - "ctpvNm", - "sggNm", - "fcltMngNo", - "instMngNo", - "swtrmtZoneNm", - "instlYmd", - "useNm", - "pdmtVl", - "prlgVl", - "materialName", -] as const; const PIPE_ANALYSIS_DETAIL_STANDARD_HEADLIST = PIPE_ANALYSIS_DETAIL_HEADLIST; const resolveErrorMessage = (error: unknown, fallbackMessage: string) => @@ -240,33 +225,19 @@ export default function PipeAnalysisDetailPopupPage() { try { setIsLoading(true); - const response = await fetchAllPages({ - fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size), - getItems: (page) => page.rows, - getTotal: (page) => page.total, - }); - const exportRows = response.items.map((row) => ({ - rowNo: row.rowNo, - ctpvNm: row.ctpvNm, - sggNm: row.sggNm, - fcltMngNo: row.fcltMngNo, - instMngNo: row.instMngNo, - swtrmtZoneNm: row.swtrmtZoneNm, - instlYmd: row.instlYmd, - useNm: row.useNm, - pdmtVl: row.pdmtVl, - prlgVl: row.prlgVl, - materialName: - resolveMaterialKoreanName(row.materialCode) || row.materialName, - })); - const detailHeaderRange = `A1:${String.fromCharCode(64 + PIPE_ANALYSIS_DETAIL_EXPORT_KEYS.length)}1`; - await downloadExcelWithKoreanHeaders({ - data: exportRows as unknown as JSON[], - fileName: `관로분석_관로현황_${buildPipeAnalysisFileDate()}`, - sheetNameArray: ["관로현황"], - headerRange: detailHeaderRange, - headerMapping: PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS, + const params = new URLSearchParams({ analysisType: baseAnalysisType }); + appendApiSearchParams(params, { + ctpvCd: appliedFilters.ctpvCd, sggCd: appliedFilters.sggCd, + swtrmtZoneNm: appliedFilters.swtrmtZoneNm, elapsedYearBucket: baseElapsedYearBucket, + materialCode: appliedFilters.materialCode, useCd: appliedFilters.useCd, + diameterFrom: appliedFilters.diameterFrom, diameterTo: appliedFilters.diameterTo, }); + if (scope.regionCode) params.set("scopeRegionCode", scope.regionCode); + await requestGridExcel("v1/spatialGridExports/pipes", { + pipe: Object.fromEntries(params), + ctpvNames: Object.fromEntries(ctpv.map((item) => [String(item.val).trim(), item.text])), + sggNames: Object.fromEntries(sgg.map((item) => [String(item.val).trim(), item.text])), + }, `관로분석_관로현황_${buildPipeAnalysisFileDate()}.xlsx`); } catch (error) { console.error("관로현황 다운로드 중 오류:", error); alert( 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 ad07fc886..fcc656ad7 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx @@ -83,8 +83,12 @@ export default function PipeAnalysisPopupPage() { Array<{ value: string; label: string }> >([]); const [summaryCurrentPage, setSummaryCurrentPage] = useState(1); + // 복합표는 물리 행 수가 아니라 서버가 반환하는 처리구역 그룹 수를 사용한다. + const [summaryTotal, setSummaryTotal] = useState(0); const [isLoading, setIsLoading] = useState(false); const initializedRef = useRef(false); + // 늦게 완료된 이전 페이지 응답이 최신 조회조건의 표를 덮어쓰지 않게 한다. + const summaryRequestId = useRef(0); useEffect(() => { if (ctpv.length === 0) fetchCtpv(); @@ -124,9 +128,13 @@ export default function PipeAnalysisPopupPage() { }, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode, sgg]); const fetchSummary = useCallback( - async (nextFilters: PipeAnalysisSummaryFilters) => { + async (nextFilters: PipeAnalysisSummaryFilters, page = 1) => { + const requestId = ++summaryRequestId.current; + setIsLoading(true); const params = new URLSearchParams({ analysisType: nextFilters.analysisType, + page: String(page - 1), + size: String(PIPE_ANALYSIS_SUMMARY_PAGE_SIZE), }); appendApiSearchParams(params, { @@ -137,22 +145,27 @@ export default function PipeAnalysisPopupPage() { if (nextFilters.scopeRegionCode) params.set("scopeRegionCode", nextFilters.scopeRegionCode); - const response = (await getData( - `v1/fclts/pplAnalysis/summaries?${params.toString()}`, - )) as PipeAnalysisSummaryResponse; - - const nextMaterialColumns = Array.isArray(response?.materialColumns) - ? response.materialColumns - : []; - setMaterialColumns(nextMaterialColumns); - setRows(buildPipeAnalysisSummaryViewRows(response, ctpv, sgg)); + try { + const response = (await getData( + `v1/fclts/pplAnalysis/summaryPage?${params.toString()}`, + )) as PipeAnalysisSummaryResponse & { count: number }; + if (requestId !== summaryRequestId.current) return; + const nextMaterialColumns = Array.isArray(response?.materialColumns) ? response.materialColumns : []; + setMaterialColumns(nextMaterialColumns); + setRows(buildPipeAnalysisSummaryViewRows(response, ctpv, sgg)); + setSummaryTotal(response.count); + setSummaryCurrentPage(page); + } catch (error) { + if (requestId === summaryRequestId.current) throw error; + } finally { + if (requestId === summaryRequestId.current) setIsLoading(false); + } }, [ctpv, sgg], ); const handleSearch = useCallback( async (nextFilters = draftFilters) => { - setIsLoading(true); try { const scopedFilters = applyScopeToSummaryFilters(nextFilters, scope); setDraftFilters(scopedFilters); @@ -168,9 +181,8 @@ export default function PipeAnalysisPopupPage() { ), ); setRows([]); + setSummaryTotal(0); setMaterialColumns([]); - } finally { - setIsLoading(false); } }, [alert, draftFilters, fetchSummary, scope], @@ -191,6 +203,18 @@ export default function PipeAnalysisPopupPage() { } try { + // 화면은 한 페이지뿐이므로 Excel만 별도 전체 집계 계약으로 조회한다. + const params = new URLSearchParams({ analysisType: appliedFilters.analysisType }); + appendApiSearchParams(params, { + ctpvCd: appliedFilters.ctpvCd, sggCd: appliedFilters.sggCd, + swtrmtZoneNm: appliedFilters.swtrmtZoneNm, + }); + if (appliedFilters.scopeRegionCode) params.set("scopeRegionCode", appliedFilters.scopeRegionCode); + const response = await getData(`v1/fclts/pplAnalysis/summaries?${params}`) as PipeAnalysisSummaryResponse; + const materialColumns = response.materialColumns ?? []; + const rows = buildPipeAnalysisSummaryViewRows(response, ctpv, sgg); + const orderedYearMaterialGroups = buildPipeAnalysisYearMaterialGroups(rows, materialColumns) + .map((group, index) => ({ ...group, rowNo: index + 1 })); const exportHeadList = buildPipeAnalysisSummaryHeadList( appliedFilters.analysisType, materialColumns, @@ -334,31 +358,11 @@ export default function PipeAnalysisPopupPage() { () => yearMaterialGroups.map((group, index) => ({ ...group, - rowNo: index + 1, + rowNo: (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE + index + 1, })), - [yearMaterialGroups], + [yearMaterialGroups, summaryCurrentPage], ); - const summaryPageOffset = - (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE; - const pagedRows = useMemo( - () => - appliedFilters.analysisType === "YEAR_MATERIAL" - ? rows - : rows.slice( - summaryPageOffset, - summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE, - ), - [appliedFilters.analysisType, rows, summaryPageOffset], - ); - const visibleYearMaterialGroups = useMemo( - () => - orderedYearMaterialGroups.slice( - summaryPageOffset, - summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE, - ), - [orderedYearMaterialGroups, summaryPageOffset], - ); const sggOptions = useMemo( () => draftFilters.ctpvCd === "0" @@ -498,19 +502,18 @@ export default function PipeAnalysisPopupPage() { { + void fetchSummary(appliedFilters, page) + .catch(() => alert("관로분석 페이지를 불러오지 못했습니다.")); + }} onCellClick={handleCellClick} onGroupedCellClick={handleYearMaterialCellClick} actions={ diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx index a15e37b55..5887d73c5 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx @@ -34,7 +34,7 @@ import { getSwtrmtPrcsZoneId, } from "./swtrmtPrcsZoneAnalysisApi"; import type { - RepairHistoryRow, + RepairHistoryMapFeature, SwtrmtPrcsZoneAnalysisMetric, SwtrmtPrcsZoneAnalysisResponse, } from "./swtrmtPrcsZoneAnalysisApi"; @@ -140,6 +140,8 @@ const SwtrmtPrcsZoneAnalysis = ({ // 보수이력 지도 레이어는 React 렌더링 대상이 아니므로 ref로 보관해 팝업 종료·구역 전환 때 제거한다. const repairLayerRef = useRef | null>(null); const repairPopupRef = useRef(null); + // 목록이 먼저 표시되어도 지도 도형 준비 후 마지막으로 클릭한 관로로 이동한다. + const pendingRepairFidRef = useRef(""); const requestKey = useMemo(() => { const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {}; @@ -173,7 +175,7 @@ const SwtrmtPrcsZoneAnalysis = ({ /** 윈도우 팝업 조회 결과 전체를 청록색 관로 레이어로 교체한다. */ const replaceRepairLayer = useCallback( - (rows: RepairHistoryRow[]) => { + (rows: RepairHistoryMapFeature[]) => { if (!gisMap) return; clearRepairLayer(); @@ -285,16 +287,19 @@ const SwtrmtPrcsZoneAnalysis = ({ if ( data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS ) { - replaceRepairLayer( - Array.isArray(data.payload?.rows) ? data.payload.rows : [], - ); + const features = Array.isArray(data.payload?.rows) ? data.payload.rows : []; + if (features.length === 0) pendingRepairFidRef.current = ""; + replaceRepairLayer(features); + if (pendingRepairFidRef.current) focusRepairFeature(pendingRepairFidRef.current); return; } if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SELECT_PIPE) { - focusRepairFeature(String(data.payload?.fid ?? "")); + pendingRepairFidRef.current = String(data.payload?.fid ?? ""); + focusRepairFeature(pendingRepairFidRef.current); return; } if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_CLEAR) { + pendingRepairFidRef.current = ""; clearRepairLayer(); } }; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts b/apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts index d0c92d678..0221f1918 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts @@ -69,7 +69,8 @@ export type RepairHistoryResponse = { }; const ANALYSIS_ENDPOINT_PREFIX = "v1/swagePrcsZones"; -const REPAIR_HISTORY_PAGE_SIZE = 200; +/** 지도 전체 강조에 필요한 필드만 제공하며 표의 행 수와 독립적이다. */ +export type RepairHistoryMapFeature = Pick; const firstValue = (...values: unknown[]) => { const value = values.find((item) => { @@ -158,41 +159,26 @@ const getRepairHistoryPath = ( return `${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories${suffix}${query ? `?${query}` : ""}`; }; -/** 윈도우 팝업과 부모 지도가 같은 전체 결과를 사용하도록 서버 페이지를 끝까지 합친다. */ -export const fetchAllRepairHistoriesByZoneId = async ( +/** 목록은 현재 페이지 하나만 조회한다. 전체 지도/Excel과 호출 계약을 분리한다. */ +export const fetchRepairHistoryPage = async ( zoneId: string, filters: RepairHistoryFilters, + page: number, + size: number, ): Promise => { if (!zoneId.trim()) throw new Error("처리구역 식별값이 없습니다."); - - const firstParams = new URLSearchParams(); - appendRepairHistoryFilters(firstParams, filters); - firstParams.set("page", "0"); - firstParams.set("size", String(REPAIR_HISTORY_PAGE_SIZE)); - const first = (await getData( - `${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories?${firstParams.toString()}`, - )) as RepairHistoryResponse; - - const pages: RepairHistoryResponse[] = [first]; - for (let page = 1; page < (first.totalPages ?? 0); page += 1) { - const params = new URLSearchParams(firstParams); - params.set("page", String(page)); - pages.push((await getData( - `${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories?${params.toString()}`, - )) as RepairHistoryResponse); - } - - const data = pages.flatMap((page) => page?.data ?? []); - return { - ...first, - totalElements: data.length, - totalPages: 1, - page: 0, - size: data.length, - data, - }; + return getData(getRepairHistoryPath(zoneId, filters, "", { + page: String(page), size: String(size), + })) as Promise; }; +/** 검색조건에 맞는 전체 도형을 한 번 조회하며 목록 페이지 이동 시에는 호출하지 않는다. */ +export const fetchRepairHistoryMapFeatures = ( + zoneId: string, + filters: RepairHistoryFilters, +): Promise => + getData(getRepairHistoryPath(zoneId, filters, "/mapFeatures")); + /** 현재 조회 조건의 화면 목록 항목 파일을 내려받는다. */ export const downloadRepairHistoriesByZoneId = async ( zoneId: string, 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 1b1427d1e..4bb4f1553 100644 --- a/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx @@ -15,8 +15,7 @@ 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 { downloadAnalysisExcel } from "@/shared/lib/download-analysis-excel"; import { useAlertStore } from "@/shared/lib"; import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; @@ -243,29 +242,7 @@ export default function RdiiResultPopupPage() { try { setIsLoading(true); - const response = await fetchAllPages>({ - fetchPage: (page, size) => - getData( - `v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}?page=${page}&size=${size}`, - ), - getItems: (page) => - Array.isArray(page?.content) ? page.content : [], - getTotal: (page) => Number(page?.totalElements ?? 0), - }); - const excelRows = response.items; - - await downloadExcelWithKoreanHeaders({ - data: excelRows as unknown as JSON[], - fileName: `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`, - sheetNameArray: ["RDII 분석결과"], - headerRange: "A1:D1", - headerMapping: { - rainInfwtAnlsSn: "RDII분석\n분석번호", - pplMngNo: "관로\n관리번호", - anlsTm: "분석시각", - predcOilq: "예측유량\n(m³/h)", - }, - }); + await downloadAnalysisExcel("rdii", selectedRow.rainInfwtAnlsSn, `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`); } catch (error) { console.error("RDII 분석결과 다운로드 중 오류:", error); alert("RDII 분석결과 다운로드 중 오류가 발생했습니다."); diff --git a/apps/sn/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx index 228b1c1b8..6406a436b 100644 --- a/apps/sn/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx @@ -4,7 +4,7 @@ */ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button, Input } from "@common/components"; import { StandardPopupLayout, @@ -23,7 +23,8 @@ import { import { MS_SWAGE_PPL_CODES } from "../common/table"; import { downloadRepairHistoriesByZoneId, - fetchAllRepairHistoriesByZoneId, + fetchRepairHistoryPage, + fetchRepairHistoryMapFeatures, type RepairHistoryFilters, type RepairHistoryRow, } from "../popupComponent/swtrmtPrcsZoneAnalysisApi"; @@ -175,17 +176,21 @@ export default function RepairHistoryPopupPage({ useState(buildEmptyFilters); const [appliedFilters, setAppliedFilters] = useState(buildEmptyFilters); - // 전체 조회 결과는 부모 지도 강조와 팝업 내 10건 페이지 표가 함께 사용한다. + // 목록에는 현재 서버 페이지의 행만 저장하고 지도 전체 도형은 별도로 전달한다. const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + // 페이지 요청과 지도 요청을 분리해 페이지 이동이 진행 중인 전체 지도 조회를 취소하지 않는다. + const pageRequestId = useRef(0); + const mapRequestId = useRef(0); // 용도 선택지는 별도 하드코딩하지 않고 관망도 트리 설정 API의 sortOrdr를 따른다. const [pipeUseOptions, setPipeUseOptions] = useState([]); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(""); - /** 조회 결과 전체를 부모 지도 레이어와 팝업 표에 동시에 반영한다. */ + /** 목록은 한 페이지만 조회하고 검색조건 변경 시에만 지도 전체 도형을 갱신한다. */ const searchRepairHistories = useCallback( - async (nextFilters: RepairHistoryFilters) => { + async (nextFilters: RepairHistoryFilters, page = 1, refreshMap = true) => { if (!zoneId) { setErrorMessage("처리구역 식별값이 없습니다."); return; @@ -193,38 +198,45 @@ export default function RepairHistoryPopupPage({ setIsLoading(true); setErrorMessage(""); + const requestId = ++pageRequestId.current; + if (refreshMap) { + const mapId = ++mapRequestId.current; + postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS, { zoneId, rows: [] }); + void fetchRepairHistoryMapFeatures(zoneId, nextFilters).then((features) => { + if (mapId !== mapRequestId.current) return; + postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS, { zoneId, rows: features }); + }).catch(() => { + if (mapId === mapRequestId.current) setErrorMessage("보수이력 지도 도형을 불러오지 못했습니다. 조회를 다시 실행해주세요."); + }); + } try { - const response = await fetchAllRepairHistoriesByZoneId( + const response = await fetchRepairHistoryPage( zoneId, nextFilters, + page - 1, + REPAIR_HISTORY_PAGE_SIZE, ); + if (requestId !== pageRequestId.current) return; const nextRows = response.data ?? []; setRows(nextRows); + setTotal(response.totalElements); setAppliedFilters({ ...nextFilters }); - setCurrentPage(1); - postRepairMessage( - WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS, - { - zoneId, - rows: nextRows, - }, - ); + setCurrentPage(page); } catch (error) { + if (requestId !== pageRequestId.current) return; setRows([]); - postRepairMessage( - WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS, - { - zoneId, - rows: [], - }, - ); + setTotal(0); + if (refreshMap) { + mapRequestId.current += 1; + postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS, { zoneId, rows: [] }); + } setErrorMessage( error instanceof Error ? error.message : "보수이력을 불러오지 못했습니다.", ); } finally { - setIsLoading(false); + if (requestId === pageRequestId.current) setIsLoading(false); } }, [zoneId], @@ -270,6 +282,8 @@ export default function RepairHistoryPopupPage({ }; window.addEventListener("beforeunload", clearParentLayer); return () => { + pageRequestId.current += 1; + mapRequestId.current += 1; window.removeEventListener("beforeunload", clearParentLayer); clearParentLayer(); }; @@ -277,7 +291,7 @@ export default function RepairHistoryPopupPage({ /** 화면에서 적용한 조건과 동일한 9개 목록 항목의 XLSX를 내려받는다. */ const handleDownload = async () => { - if (!zoneId || rows.length === 0) return; + if (!zoneId || total === 0) return; setIsLoading(true); setErrorMessage(""); try { @@ -306,18 +320,10 @@ export default function RepairHistoryPopupPage({ }); }; - const visibleRows = useMemo( - () => - rows.slice( - (currentPage - 1) * REPAIR_HISTORY_PAGE_SIZE, - currentPage * REPAIR_HISTORY_PAGE_SIZE, - ), - [currentPage, rows], - ); // 공통 표에 전달할 표시값은 원본 API 행과 분리해 지도 연계 식별값을 보존한다. const tableRows = useMemo( () => - visibleRows.map((row) => ({ + rows.map((row) => ({ fid: row.fid, fcltMngNo: formatCellValue(row.fcltMngNo), useName: formatCellValue(row.useNm), @@ -329,7 +335,7 @@ export default function RepairHistoryPopupPage({ installationYear: formatYear(row.installationYear), burialDepth: formatCellValue(row.burialDepth), })), - [visibleRows], + [rows], ); return ( @@ -459,12 +465,12 @@ export default function RepairHistoryPopupPage({ } headlist={REPAIR_HISTORY_HEADLIST} title={`${zoneName || "처리구역"} 보수이력 목록`} - total={rows.length} + total={total} page={currentPage} pageSize={REPAIR_HISTORY_PAGE_SIZE} - onPageChange={setCurrentPage} + onPageChange={(page) => void searchRepairHistories(appliedFilters, page, false)} onRowClick={(_, index) => { - const row = visibleRows[index]; + const row = rows[index]; if (row) handleRowClick(row); }} rowId={(row, index) => String(row.fid ?? index)} diff --git a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx index 2ad4328cf..7e063073f 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx @@ -23,6 +23,7 @@ import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefini import { ExclnSewagePumpCol2, FldngWtlvMapCol2, + FldngDpthMapCol2, MsSwageMhlCol2, MsSwagePplCol2, stofcbrCol2, @@ -103,6 +104,8 @@ const SearchModal: React.FC = ({ ? SwtrmtPrcsZoneCol2 : basicType === "tb_ms_fldng_wtlv_map" ? FldngWtlvMapCol2 + : basicType === "tb_ms_fldng_dpth_map" + ? FldngDpthMapCol2 : basicType === "tb_ms_swage_mhl" ? MsSwageMhlCol2 : basicType === "tb_ms_swage_pmstn" diff --git a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts index 6a35bf112..eb7c07465 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts @@ -12,21 +12,13 @@ * 🔗 사용 API * - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service) */ -import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; -import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; +import { requestGridExcel } from "@/shared/api/grid-export"; import { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_SHAPE, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, - FldngWtlvMapExcelCol, - MsSwagePplExcelCol, - SwtrmtFcltExcelCol, - SwtrmtPrcsZoneExcelCol, - MsSwageMhlExcelCol, - ExclnSewagePumpExcelCol, - stofcbrExcelCol, } from "../components/common/table"; import { getData } from "@/components/lib/api"; import { BASIC_TYPES } from "./basicTab"; @@ -51,9 +43,6 @@ const isPopupWindow = () => !!window.opener && window.location.pathname.startsWith("/popup/"); -// Excel 열에 포함되지 않는 화면용 유하방향 보강은 제외하고 서버가 일괄 병합할 수 있는 크기로 순회한다. -const BASIC_EXCEL_BATCH_SIZE = 2_000; - export default function useBasicTab() { const alert = useAlertStore((state) => state.setMessage); const { confirm } = useConfirm(); @@ -232,284 +221,17 @@ export default function useBasicTab() { try { const appliedQuery = useSearchModalStore.getState().appliedBasicQuery; - if (!appliedQuery) { - throw new Error( - "화면 조회조건을 확인할 수 없습니다. 다시 조회해주세요.", - ); - } - const appliedBasicType = appliedQuery.basicType; - const queryParams = new URLSearchParams(appliedQuery.query); - if (appliedBasicType === "tb_ms_swage_ppl") { - queryParams.delete("includeFlowDirectionBasis"); - } - - let headerMapping: { [key: string]: string } = {}; - let headerRange: string = "A1:F1"; - - switch (appliedBasicType) { - case "tb_ms_swage_ppl": { - // 하수관로 - headerMapping = MsSwagePplExcelCol; - headerRange = "A1:Q1"; - break; - } - case "tb_il_swage_prcs_fclt": { - // 하수처리시설 - headerMapping = SwtrmtFcltExcelCol; - headerRange = "A1:J1"; - break; - } - case "tb_ms_swage_prcs_zone": { - // 하수처리구역 - headerMapping = SwtrmtPrcsZoneExcelCol; - headerRange = "A1:D1"; - break; - } - case "tb_ms_fldng_wtlv_map": { - // 침수흔적도 - headerMapping = FldngWtlvMapExcelCol; - headerRange = "A1:O1"; - break; - } - case "tb_ms_swage_mhl": { - // 하수맨홀 - headerMapping = MsSwageMhlExcelCol; - headerRange = "A1:J1"; - break; - } - case "tb_ms_swage_pmstn": { - // 하수펌프장 - headerMapping = ExclnSewagePumpExcelCol; - headerRange = "A1:J1"; - break; - } - case "tb_ms_stofcbr": { - // 우수토실 - headerMapping = stofcbrExcelCol; - headerRange = "A1:H1"; - break; - } - } - - const res = await fetchAllPages({ - batchSize: BASIC_EXCEL_BATCH_SIZE, - fetchPage: (page, size) => { - const pageParams = new URLSearchParams(queryParams); - pageParams.set("page", String(page)); - pageParams.set("size", String(size)); - return getData(`v1/fclts?${pageParams.toString()}`); - }, - getItems: (page) => (Array.isArray(page?.data) ? page.data : []), - getTotal: (page) => Number(page?.count ?? 0), - }); - - const mhlUsgCdMap = new Map( - MS_SWAGE_MHL_USE_CODES.map((code) => [code.val?.trim(), code.text]), - ); - const pplUsgCdMap = new Map( - MS_SWAGE_PPL_USE_CODES.map((code) => [code.val?.trim(), code.text]), - ); - const pplMqltCdMap = new Map( - MS_SWAGE_PPL_CODES.map((code) => [code.val?.trim(), code.text]), - ); - const pplShpCdMap = new Map( - MS_SWAGE_PPL_SHAPE.map((code) => [code.val?.trim(), code.text]), - ); // 관로 시설물형태코드 - const ctpvMap = new Map( - ctpv.map((code) => [code.val?.trim(), code.text]), - ); - const sggMap = new Map( - sgg.map((code) => [code.val?.trim(), code.text]), - ); - - let mappedContent = res.items.map((item: any) => { - const formattedItem = formatDisplayData(item); - - let ctpvCd = item?.ctpvCd ?? formattedItem?.ctpvCd ?? ""; - let sggCd = item?.sggCd ?? formattedItem?.sggCd ?? ""; - - if (!ctpvCd || !sggCd) { - const code = String( - item?.pbadmsEmdCd ?? - formattedItem?.pbadmsEmdCd ?? - item?.stdgCd ?? - formattedItem?.stdgCd ?? - "", - )?.trim(); - if (!ctpvCd && code) ctpvCd = code.slice(0, 2); - if (!sggCd && code) sggCd = code.slice(0, 5); - } - - // trim 한 번만 수행 - const trimmedUsgCd = item?.usgCd?.trim(); - // const trimmedMqltCd = item?.mqltCd?.trim(); - - switch (appliedBasicType) { - case "tb_ms_swage_ppl": { - // 하수관로 - return { - fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-", - instMngNo: formattedItem.instMngNo ?? item.instMngNo ?? "-", - ctpvNm: ctpvMap.get(ctpvCd) ?? item.ctpvNm ?? "-", - sggNm: sggMap.get(sggCd) ?? item.sggNm ?? "-", - swtrmt_zone_nm: - formattedItem.swtrmt_zone_nm ?? item.swtrmt_zone_nm ?? "-", - instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-", - usgCd: - pplUsgCdMap.get(trimmedUsgCd) || - item.usgCd || - formattedItem.usgCd || - "-", - pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? "-", - prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? "-", - tmptDpth: formattedItem.tmptDpth ?? item.tmptDpth ?? "-", - tmptMhlMngNo: - formattedItem.tmptMhlMngNo ?? item.tmptMhlMngNo ?? "-", - scendDpth: formattedItem.scendDpth ?? item.scendDpth ?? "-", - scendMhlMngNo: - formattedItem.scendMhlMngNo ?? item.scendMhlMngNo ?? "-", - vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? "-", - wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? "-", - fcltyShpCd: - pplShpCdMap.get(item?.fcltyShpCd?.trim()) || - item.fcltyShpCd || - formattedItem.fcltyShpCd || - "-", - mqltCd: - pplMqltCdMap.get(item?.mqltCd?.trim()) || - item.mqltCd || - formattedItem.mqltCd || - "-", - }; - } - case "tb_il_swage_prcs_fclt": { - // 하수처리시설 - return { - swtrmtFcltNm: - formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? "-", - fcltCpct: formattedItem.fcltCpct ?? item.fcltCpct ?? "-", - rgnExpln: formattedItem.rgnExpln ?? item.rgnExpln ?? "-", - oprtngStrtYmd: - formattedItem.oprtngStrtYmd ?? item.oprtngStrtYmd ?? "-", - laeNm: formattedItem.laeNm ?? item.laeNm ?? "-", - sareaNm: formattedItem.sareaNm ?? item.sareaNm ?? "-", - rvrNm: formattedItem.rvrNm ?? item.rvrNm ?? "-", - dschgWtstNm: - formattedItem.dschgWtstNm ?? item.dschgWtstNm ?? "-", - swtrmtPpltnCnt: - formattedItem.swtrmtPpltnCnt ?? item.swtrmtPpltnCnt ?? "-", - rprsMsrcCn: formattedItem.rprsMsrcCn ?? item.rprsMsrcCn ?? "-", - }; - } - case "tb_ms_swage_prcs_zone": { - // 하수처리구역 - return { - swtrmtZoneNm: - formattedItem.swtrmtZoneNm ?? item.swtrmtZoneNm ?? "-", - ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-", - sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-", - swtrmtFcltNm: - formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? "-", - }; - } - case "tb_ms_fldng_wtlv_map": { - // 침수흔적도 - return { - fldngYr: formattedItem.fldngYr ?? item.fldngYr ?? "-", - fldngDstrNm: - formattedItem.fldngDstrNm ?? item.fldngDstrNm ?? "-", - ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-", - sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-", - bgngYmd: formattedItem.bgngYmd ?? item.bgngYmd ?? "-", - fldngBgngHm: - formattedItem.fldngBgngHm ?? item.fldngBgngHm ?? "-", - endYmd: formattedItem.endYmd ?? item.endYmd ?? "-", - fldngEndHm: formattedItem.fldngEndHm ?? item.fldngEndHm ?? "-", - fldngHr: formattedItem.fldngHr ?? item.fldngHr ?? "-", - fldngDstrctNm: - formattedItem.fldngDstrctNm ?? item.fldngDstrctNm ?? "-", - fldngCsDtlCd: - formattedItem.fldngCsDtlCd ?? item.fldngCsDtlCd ?? "-", - avgFldngWtlv: - formattedItem.avgFldngWtlv ?? item.avgFldngWtlv ?? "-", - fldngArea: formattedItem.fldngArea ?? item.fldngArea ?? "-", - exmnYr: formattedItem.exmnYr ?? item.exmnYr ?? "-", - damAmt: formattedItem.damAmt ?? item.damAmt ?? "-", - }; - } - case "tb_ms_swage_mhl": { - // 하수맨홀 - return { - fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-", - ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-", - sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-", - instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-", - usgCd: - mhlUsgCdMap.get(trimmedUsgCd) || - item.usgCd || - formattedItem.usgCd || - "-", - swageMhlDiamVl: - formattedItem.swageMhlDiamVl ?? item.swageMhlDiamVl ?? "-", - wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? "-", - vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? "-", - altdVl: formattedItem.altdVl ?? item.altdVl ?? "-", - dpthlVl: formattedItem.dpthlVl ?? item.dpthlVl ?? "-", - }; - } - case "tb_ms_swage_pmstn": { - // 하수펌프장 - return { - fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-", - ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-", - sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-", - swagePmstnNm: - formattedItem.swagePmstnNm ?? item.swagePmstnNm ?? "-", - instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-", - dailyPrcsCpct: - formattedItem.dailyPrcsCpct ?? item.dailyPrcsCpct ?? "-", - maxImpndCpct: - formattedItem.maxImpndCpct ?? item.maxImpndCpct ?? "-", - altdVl: formattedItem.altdVl ?? item.altdVl ?? "-", - wtlvVl: formattedItem.wtlvVl ?? item.wtlvVl ?? "-", - exclnWpmpAbltVl: - formattedItem.exclnWpmpAbltVl ?? item.exclnWpmpAbltVl ?? "-", - }; - } - case "tb_ms_stofcbr": { - // 우수토실 - return { - fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-", - mapshtNo: formattedItem.mapshtNo ?? item.mapshtNo ?? "-", - ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-", - sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-", - instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-", - worflwWallHgt: - formattedItem.worflwWallHgt ?? item.worflwWallHgt ?? "-", - swageMhlMngNo: - formattedItem.swageMhlMngNo ?? item.swageMhlMngNo ?? "-", - drtnAngl: formattedItem.drtnAngl ?? item.drtnAngl ?? "-", - }; - } - } - }); - - // 선택된 행 데이터를 Excel 다운로드 형식으로 변환 - const excelData = mappedContent; // 배열로 감싸서 전달 - - // 파일명 생성 (분석번호 기반) - const label = BASIC_TYPES.find( - (type) => type.val === appliedBasicType, - )?.text; - const fileName = `${label} 조회 결과_${todayYear}${todayMonth}${todayDate}`; - - await downloadExcelWithKoreanHeaders({ - data: excelData as unknown as JSON[], - fileName: fileName, - sheetNameArray: [fileName], - headerRange: headerRange, - headerMapping: headerMapping, - }); + if (!appliedQuery) throw new Error("화면 조회조건을 확인할 수 없습니다. 다시 조회해주세요."); + const params = new URLSearchParams(appliedQuery.query); + // 현재 페이지나 목록 배열을 보내지 않고 전체 조건과 표시명만 서버 작업으로 전달한다. + const label = BASIC_TYPES.find((type) => type.val === appliedQuery.basicType)?.text ?? "시설"; + await requestGridExcel("v1/facilityExports", { + lyrTypeNm: appliedQuery.basicType, + cqlFilter: params.get("cqlFiter") ?? "", + sort: params.get("sort"), + ctpvNames: Object.fromEntries(ctpv.map((item) => [String(item.val).trim(), item.text])), + sggNames: Object.fromEntries(sgg.map((item) => [String(item.val).trim(), item.text])), + }, `${label} 조회 결과_${todayYear}${todayMonth}${todayDate}.xlsx`); setIsLoading(false); } catch (error) { setIsLoading(false); diff --git a/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx b/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx index aefbb5917..c426473b4 100644 --- a/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx @@ -19,8 +19,7 @@ */ "use client"; import { deleteData, getData } from "@/components/lib/api"; -import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; -import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; +import { downloadAnalysisExcel } from "@/shared/lib/download-analysis-excel"; import { zodResolver } from "@hookform/resolvers/zod"; import { useState, useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -240,37 +239,7 @@ const Result = () => { } try { - const data = await fetchAllPages>({ - fetchPage: (page, size) => - getData( - `v1/rdiiAnlsRslt/ppl/${selectedRowData.rainInfwtAnlsSn}?page=${page}&size=${size}`, - ), - getItems: (page) => - Array.isArray(page?.content) ? page.content : [], - getTotal: (page) => Number(page?.totalElements ?? 0), - }); - - // 선택된 분석의 전체 관로 결과를 Excel 다운로드 형식으로 변환한다. - const excelData = data.items; - - // 헤더 매핑 정의 - const headerMapping: { [key: string]: string } = { - rainInfwtAnlsSn: "RDII분석\n분석번호", - pplMngNo: "관로\n관리번호", - anlsTm: "분석시각", - predcOilq: "예측유량\n(m³/h)", - }; - - // 파일명 생성 (분석번호 기반) - const fileName = `RDII분석결과_${selectedRowData.swtrmtFcltNm}_${selectedRowData.aplcnYm}_${selectedRowData.rainInfwtAnlsSn}`; - - await downloadExcelWithKoreanHeaders({ - data: excelData as unknown as JSON[], - fileName: fileName, - sheetNameArray: ["RDII 분석결과"], - headerRange: "A1:D1", - headerMapping: headerMapping, - }); + await downloadAnalysisExcel("rdii", selectedRowData.rainInfwtAnlsSn, `RDII분석결과_${selectedRowData.swtrmtFcltNm}_${selectedRowData.aplcnYm}_${selectedRowData.rainInfwtAnlsSn}`); setIsLoadingResult(false); } catch (error) { setIsLoadingResult(false); diff --git a/apps/sn/app/(with-base-layout)/ii/result/page.tsx b/apps/sn/app/(with-base-layout)/ii/result/page.tsx index 78f151630..084795639 100644 --- a/apps/sn/app/(with-base-layout)/ii/result/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/result/page.tsx @@ -19,8 +19,7 @@ */ "use client"; import { deleteData, getData } from "@/components/lib/api"; -import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; -import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; +import { downloadAnalysisExcel } from "@/shared/lib/download-analysis-excel"; import { zodResolver } from "@hookform/resolvers/zod"; import { useState, useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -240,37 +239,7 @@ const Result = () => { } try { - const data = await fetchAllPages>({ - fetchPage: (page, size) => - getData( - `v1/inftwtAnlsRslt/ppl/${selectedRowData.inftwtAnlsSn}?page=${page}&size=${size}`, - ), - getItems: (page) => - Array.isArray(page?.content) ? page.content : [], - getTotal: (page) => Number(page?.totalElements ?? 0), - }); - - // 선택된 분석의 전체 관로 결과를 Excel 다운로드 형식으로 변환한다. - const excelData = data.items; - - // 헤더 매핑 정의 - const headerMapping: { [key: string]: string } = { - inftwtAnlsSn: "침입수분석\n분석번호", - pplMngNo: "관로\n관리번호", - anlsTm: "분석시각", - predcOilq: "예측유량\n(m³/h)", - }; - - // 파일명 생성 (분석번호 기반) - const fileName = `침입수분석결과_${selectedRowData.swtrmtFcltNm}_${selectedRowData.aplcnYm}_${selectedRowData.inftwtAnlsSn}`; - - await downloadExcelWithKoreanHeaders({ - data: excelData as unknown as JSON[], - fileName: fileName, - sheetNameArray: ["침입수 분석결과"], - headerRange: "A1:D1", - headerMapping: headerMapping, - }); + await downloadAnalysisExcel("inftwt", selectedRowData.inftwtAnlsSn, `침입수분석결과_${selectedRowData.swtrmtFcltNm}_${selectedRowData.aplcnYm}_${selectedRowData.inftwtAnlsSn}`); setIsLoadingResult(false); } catch (error) { setIsLoadingResult(false); diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx index 3565bacbf..d16914bb4 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx @@ -40,7 +40,8 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { deleteDredgingRecord, - fetchAllDredgingRecords, + fetchDredgingGridPage, + downloadDredgingGridExcel, isDredgingBackendEnabled, } from "./lib/client"; import { createDredgingClientUuid } from "./lib/uuid"; @@ -343,6 +344,9 @@ const Page = () => { const [records, setRecords] = useState( isDredgingBackendEnabled ? [] : initialRecords, ); + // 서버 페이지와 전체 건수는 별도 보관해 목록 10건이 전체 결과로 오인되지 않게 한다. + const [serverRows, setServerRows] = useState([]); + const [serverTotal, setServerTotal] = useState(0); const [isLoading, setIsLoading] = useState(isDredgingBackendEnabled); const [loadError, setLoadError] = useState(null); const [selectedRecordId, setSelectedRecordId] = useState(null); @@ -376,16 +380,26 @@ const Page = () => { setIsLoading(true); setLoadError(null); try { - const response = await fetchAllDredgingRecords({ + const response = await fetchDredgingGridPage({ + viewType, fromDate: appliedFromDate, toDate: appliedToDate, ctpv: appliedCtpvName, sgg: appliedSggName, + page: currentPage - 1, + size: PAGE_SIZE, + sort: sort ? `${sort.key},${sort.direction}` : undefined, }); - if (!cancelled) setRecords(response.items ?? []); + if (!cancelled) { + setServerRows(response.content); + setServerTotal(response.totalElements); + setRecords(viewType === "MUNICIPALITY" ? response.content as unknown as DredgingRecord[] : []); + } } catch { if (!cancelled) { setRecords([]); + setServerRows([]); + setServerTotal(0); setLoadError(DREDGING_LOAD_ERROR_MESSAGE); } } finally { @@ -398,7 +412,7 @@ const Page = () => { }; // 조회 버튼과 등록 완료 이벤트에서 searchVersion을 증가시켜 명시적으로 재조회한다. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchVersion]); + }, [searchVersion, currentPage, viewType, sort, appliedFromDate, appliedToDate, appliedCtpvName, appliedSggName]); const filteredRecords = useMemo( () => @@ -420,6 +434,14 @@ const Page = () => { ); const tableRows = useMemo(() => { + if (isDredgingBackendEnabled) { + return serverRows.map((row) => viewType === "MUNICIPALITY" ? { + ...row, + pipeMaterial: resolvePipeMaterialName(String(row.pipeMaterial ?? "")), + installationYear: row.installationYear ?? "확인불가", + elapsedYears: row.elapsedYears ?? "확인불가", + } : row); + } if (viewType === "DIAMETER") return buildDiameterRows(filteredRecords); if (viewType === "ELAPSED_YEAR") return buildElapsedYearRows(filteredRecords); @@ -436,17 +458,17 @@ const Page = () => { : currentYear - record.installationYear, recordIds: [record.id], })); - }, [filteredRecords, viewType]); + }, [filteredRecords, viewType, serverRows]); - // 준설 API의 모든 페이지를 수집한 뒤 표시명·집계값 기준으로 정렬한다. + // 운영 모드는 서버가 정렬·페이징하며 로컬 시연 모드만 메모리 처리를 유지한다. const sortedTableRows = useMemo( - () => sortStandardTableRows(tableRows, sort), + () => isDredgingBackendEnabled ? tableRows : sortStandardTableRows(tableRows, sort), [sort, tableRows], ); const pageRows = useMemo( () => - sortedTableRows.slice( + isDredgingBackendEnabled ? sortedTableRows : sortedTableRows.slice( (currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE, ), @@ -455,11 +477,6 @@ const Page = () => { const selectedRecord = records.find((record) => record.id === selectedRecordId) ?? null; - const selectedJobRecords = selectedRecord - ? records.filter( - (record) => record.dredgingJobSn === selectedRecord.dredgingJobSn, - ) - : []; const canManageSelectedRecord = viewType === "MUNICIPALITY" && selectedRecord !== null && !isMutating; @@ -477,6 +494,7 @@ const Page = () => { headlist.map((head) => ({ id: head.id, accessorFn: (row) => row[head.id], + // 표시명 컬럼까지 서버 전체 정렬을 사용한다. header: () => ( { align: head.align ?? "center", }, })), - [headlist, sort], + [headlist, sort, viewType], ); const pagination: PaginationState = { pageIndex: currentPage - 1, @@ -513,7 +531,7 @@ const Page = () => { pagination, rowSelection: selectedTableRowId ? { [selectedTableRowId]: true } : {}, }, - rowCount: sortedTableRows.length, + rowCount: isDredgingBackendEnabled ? serverTotal : sortedTableRows.length, manualPagination: true, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.recordIds.join("|"), @@ -559,6 +577,18 @@ const Page = () => { const handleDownload = async () => { try { + if (isDredgingBackendEnabled) { + setIsLoading(true); + await downloadDredgingGridExcel({ + viewType, + fromDate: appliedFromDate, + toDate: appliedToDate, + ctpv: appliedCtpvName, + sgg: appliedSggName, + sort: sort ? `${sort.key},${sort.direction}` : undefined, + }); + return; + } const visibleHeads = headlist.filter((head) => head.id !== "recordIds"); const excelRows = [ visibleHeads.map((head) => head.title), @@ -581,6 +611,8 @@ const Page = () => { } catch (error) { console.error("준설현황 엑셀 다운로드 실패", error); alert("엑셀 파일을 생성하지 못했습니다."); + } finally { + setIsLoading(false); } }; @@ -645,7 +677,7 @@ const Page = () => { const handleDeleteRecord = async () => { if (!canManageSelectedRecord || !selectedRecord) return; const confirmed = await confirm( - `선택한 준설작업 전체를 삭제하시겠습니까?\n준설일자: ${selectedRecord.dredgingDate}\n연결 관로: ${selectedJobRecords.length}건 · 등록사진: ${selectedRecord.photoCount}건`, + `선택한 준설작업 전체를 삭제하시겠습니까?\n준설일자: ${selectedRecord.dredgingDate}\n현재 페이지에 보이지 않는 연결 관로와 사진도 모두 삭제됩니다.`, ); if (!confirmed) return; @@ -877,7 +909,7 @@ const Page = () => { }} actions={ <> -