diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx index e5a180fbc..5a8caf2d9 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -20,7 +20,7 @@ import { type AgedPipeFilters, type AgedPipeRow, } from "./agedPipeUtils"; -import { downloadAgedPipeCsv } from "./agedPipeExport"; +import { downloadAgedPipeExcel } from "./agedPipeExport"; import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; @@ -32,16 +32,22 @@ export default function AgedPipePopupPage() { const alert = useAlertStore((state) => state.setMessage); const searchParams = useSearchParams(); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); - const [filters, setFilters] = useState(() => - normalizeAgedPipeFilters({ - elapsedYears: Number( - searchParams.get("elapsedYears") ?? - AGED_PIPE_DEFAULT_FILTERS.elapsedYears, - ), - ctpvCd: searchParams.get("ctpvCd") ?? AGED_PIPE_DEFAULT_FILTERS.ctpvCd, - sggCd: searchParams.get("sggCd") ?? AGED_PIPE_DEFAULT_FILTERS.sggCd, - }), + const initialFilters = useMemo( + () => + normalizeAgedPipeFilters({ + elapsedYears: Number( + searchParams.get("elapsedYears") ?? + AGED_PIPE_DEFAULT_FILTERS.elapsedYears, + ), + ctpvCd: searchParams.get("ctpvCd") ?? AGED_PIPE_DEFAULT_FILTERS.ctpvCd, + sggCd: searchParams.get("sggCd") ?? AGED_PIPE_DEFAULT_FILTERS.sggCd, + }), + [searchParams], ); + const [filters, setFilters] = useState(initialFilters); + // 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다. + const [appliedFilters, setAppliedFilters] = + useState(initialFilters); const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [currentPage, setCurrentPage] = useState(1); @@ -102,6 +108,7 @@ export default function AgedPipePopupPage() { setRows(response.rows); setTotal(response.total); setCurrentPage(page); + setAppliedFilters(normalizeAgedPipeFilters(nextFilters)); } catch (error) { console.error("노후관로 목록 조회 중 오류:", error); setRows([]); @@ -162,22 +169,36 @@ export default function AgedPipePopupPage() { alert("다운로드할 데이터가 없습니다."); return; } + if (!validateElapsedYears(Number(filters.elapsedYears))) { + alert("경과년도는 1년~100년 범위 내에서 입력해주세요."); + return; + } + + const normalizedDraft = normalizeAgedPipeFilters(filters); + if (JSON.stringify(normalizedDraft) !== JSON.stringify(appliedFilters)) { + alert("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요."); + return; + } try { setIsLoading(true); - setDownloadProgressText("CSV export 작업을 준비하고 있습니다."); - await downloadAgedPipeCsv(filters, (job) => { - if (job.status === "QUEUED") { - setDownloadProgressText("CSV export 작업이 대기 중입니다."); - return; - } + setDownloadProgressText("Excel 파일을 준비하고 있습니다."); + await downloadAgedPipeExcel( + appliedFilters, + (job) => { + if (job.status === "QUEUED") { + setDownloadProgressText("Excel 다운로드 작업이 대기 중입니다."); + return; + } - if (job.status === "RUNNING") { - setDownloadProgressText( - `CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`, - ); - } - }); + if (job.status === "RUNNING") { + setDownloadProgressText( + `Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`, + ); + } + }, + total, + ); setDownloadProgressText(""); } catch (error) { console.error("노후관로 목록 다운로드 중 오류:", error); @@ -185,7 +206,7 @@ export default function AgedPipePopupPage() { alert( error instanceof Error ? error.message - : "노후관로 CSV 다운로드 중 오류가 발생했습니다.", + : "노후관로 Excel 다운로드 중 오류가 발생했습니다.", ); } finally { setIsLoading(false); @@ -322,7 +343,7 @@ export default function AgedPipePopupPage() { total={total} page={currentPage} pageSize={AGED_PIPE_PAGE_SIZE} - onPageChange={(page) => void handleSearch(page)} + onPageChange={(page) => void loadRows(page, appliedFilters)} onRowClick={handleRowClick} rowId={(row, index) => String(row.fid ?? row.fcltMngNo ?? index)} actions={ diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts index 7230ccb3f..c915c21ee 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts @@ -14,26 +14,35 @@ type AgedPipeExportProgress = { status: "QUEUED" | "RUNNING" | "COMPLETED"; }; -/** 노후관로 CSV 다운로드를 요청한다. */ -export const downloadAgedPipeCsv = async ( +/** 노후관로 XLSX 다운로드를 요청한다. */ +export const downloadAgedPipeExcel = async ( filters: AgedPipeFilters, onProgress?: (progress: AgedPipeExportProgress) => void, + expectedTotal?: number, ) => { const normalizedFilters = normalizeAgedPipeFilters(filters); onProgress?.({ status: "RUNNING", progressPercent: 0 }); - const { blob, fileName } = await postBlobData("v1/fclts/agedPipes/exportJobs/download", { - elapsedYears: normalizedFilters.elapsedYears, - ctpvCd: normalizedFilters.ctpvCd, - sggCd: normalizedFilters.sggCd, - format: "csv", - columns: [...AGED_PIPE_EXCEL_FIELDS], - }); + const { blob, fileName } = await postBlobData( + "v1/fclts/agedPipes/exportJobs/download", + { + elapsedYears: normalizedFilters.elapsedYears, + ctpvCd: normalizedFilters.ctpvCd, + sggCd: normalizedFilters.sggCd, + format: "xlsx", + columns: [...AGED_PIPE_EXCEL_FIELDS], + expectedTotal, + }, + ); - const resolvedFileName = fileName || "노후관로_현황.csv"; + const resolvedFileName = fileName || "노후관로_현황.xlsx"; fileDownload(blob, resolvedFileName); - onProgress?.({ status: "COMPLETED", fileName: resolvedFileName, progressPercent: 100 }); + onProgress?.({ + status: "COMPLETED", + fileName: resolvedFileName, + progressPercent: 100, + }); return { status: "COMPLETED" as const, diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx index e9ba16d7b..293ae3fe3 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx @@ -11,15 +11,15 @@ import { AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS, buildAgedPipeStatusByCtpvHeadList, buildAgedPipeStatusYearOptions, + getAgedPipeStatusByCtpvValidationMessage, normalizeAgedPipeStatusByCtpvResponse, normalizeAgedPipeStatusByCtpvFilters, toAgedPipeStatusByCtpvRows, - validateAgedPipeStatusByCtpvFilters, type AgedPipeStatusByCtpvFilters, type AgedPipeStatusByCtpvResponse, type AgedPipeStatusByCtpvRow, } from "./agedPipeStatusByCtpvUtils"; -import { downloadAgedPipeStatusByCtpvCsv } from "./agedPipeStatusByCtpvExport"; +import { downloadAgedPipeStatusByCtpvExcel } from "./agedPipeStatusByCtpvExport"; import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; @@ -34,6 +34,13 @@ export default function AgedPipeStatusByCtpvPopupPage() { elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS, }), ); + // 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다. + const [appliedFilters, setAppliedFilters] = + useState(() => + normalizeAgedPipeStatusByCtpvFilters({ + elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS, + }), + ); // 서버 응답을 화면 그리드에 맞게 평탄화한 row 목록이다. const [rows, setRows] = useState([]); // 동적 컬럼 생성을 위한 기준년도 목록이다. @@ -80,6 +87,7 @@ export default function AgedPipeStatusByCtpvPopupPage() { setYears(Array.isArray(response?.years) ? response.years : []); setRows(toAgedPipeStatusByCtpvRows(response)); setCurrentPage(1); + setAppliedFilters(normalized); } catch (error) { console.error("시도별 노후관로현황 조회 중 오류:", error); setYears([]); @@ -92,26 +100,43 @@ export default function AgedPipeStatusByCtpvPopupPage() { ); useEffect(() => { - void loadRows(filters); - }, [filters, loadRows]); + const initialFilters = normalizeAgedPipeStatusByCtpvFilters({ + elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS, + }); + void loadRows(initialFilters); + }, [loadRows]); const handleSearch = async () => { - const normalized = normalizeAgedPipeStatusByCtpvFilters(filters); - if (!validateAgedPipeStatusByCtpvFilters(normalized)) { - alert( - "기준년도는 최대 3년 범위 내에서 선택하고, 경과년도는 1~100 사이로 입력해주세요.", - ); + const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters); + if (validationMessage) { + alert(validationMessage); return; } + + const normalized = normalizeAgedPipeStatusByCtpvFilters(filters); setFilters(normalized); await loadRows(normalized); }; const handleDownload = async () => { + const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters); + if (validationMessage) { + alert(validationMessage); + return; + } + + const normalizedDraft = normalizeAgedPipeStatusByCtpvFilters(filters); + if (JSON.stringify(normalizedDraft) !== JSON.stringify(appliedFilters)) { + alert("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요."); + return; + } + try { setIsLoading(true); - setDownloadProgressText("시도별 노후관로현황 CSV를 준비하고 있습니다."); - await downloadAgedPipeStatusByCtpvCsv(filters); + setDownloadProgressText( + "시도별 노후관로현황 Excel 파일을 준비하고 있습니다.", + ); + await downloadAgedPipeStatusByCtpvExcel(appliedFilters); setDownloadProgressText(""); } catch (error) { console.error("시도별 노후관로현황 다운로드 중 오류:", error); @@ -119,7 +144,7 @@ export default function AgedPipeStatusByCtpvPopupPage() { alert( error instanceof Error ? error.message - : "시도별 노후관로현황 CSV 다운로드 중 오류가 발생했습니다.", + : "시도별 노후관로현황 Excel 다운로드 중 오류가 발생했습니다.", ); } finally { setIsLoading(false); @@ -148,12 +173,10 @@ export default function AgedPipeStatusByCtpvPopupPage() { className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl" value={filters.startYear} onChange={(event) => - setFilters((current) => - normalizeAgedPipeStatusByCtpvFilters({ - ...current, - startYear: Number(event.target.value), - }), - ) + setFilters((current) => ({ + ...current, + startYear: Number(event.target.value), + })) } > {yearOptions.map((year) => ( diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts index df5dbf0d0..888e1db17 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts @@ -3,23 +3,30 @@ import { postBlobData } from "@/components/lib/api"; import { fileDownload } from "@/components/lib/utils/fileDownload"; import { + getAgedPipeStatusByCtpvValidationMessage, normalizeAgedPipeStatusByCtpvFilters, type AgedPipeStatusByCtpvFilters, } from "./agedPipeStatusByCtpvUtils"; -/** 시도별 노후관로현황 CSV 다운로드를 요청한다. */ -export const downloadAgedPipeStatusByCtpvCsv = async ( +/** 시도별 노후관로현황 XLSX 다운로드를 요청한다. */ +export const downloadAgedPipeStatusByCtpvExcel = async ( filters: AgedPipeStatusByCtpvFilters, ) => { - const normalizedFilters = normalizeAgedPipeStatusByCtpvFilters(filters); - const { blob, fileName } = await postBlobData("v1/swagePpl/aged-status-by-ctpv/exportJobs/download", { - startYear: normalizedFilters.startYear, - endYear: normalizedFilters.endYear, - elapsedYears: normalizedFilters.elapsedYears, - format: "csv", - }); + const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters); + if (validationMessage) throw new Error(validationMessage); - const resolvedFileName = fileName || "시도별_노후관로현황.csv"; + const normalizedFilters = normalizeAgedPipeStatusByCtpvFilters(filters); + const { blob, fileName } = await postBlobData( + "v1/swagePpl/aged-status-by-ctpv/exportJobs/download", + { + startYear: normalizedFilters.startYear, + endYear: normalizedFilters.endYear, + elapsedYears: normalizedFilters.elapsedYears, + format: "xlsx", + }, + ); + + const resolvedFileName = fileName || "시도별_노후관로현황.xlsx"; fileDownload(blob, resolvedFileName); return resolvedFileName; }; diff --git a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts index 6a01cc9de..253395321 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts @@ -87,9 +87,6 @@ export const normalizeAgedPipeStatusByCtpvFilters = ( startYear = endYear; endYear = swap; } - if (endYear - startYear + 1 > AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN) { - endYear = startYear + (AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN - 1); - } return { startYear, @@ -98,18 +95,29 @@ export const normalizeAgedPipeStatusByCtpvFilters = ( }; }; -export const validateAgedPipeStatusByCtpvFilters = ( +/** 입력한 기준년도·경과년도가 조회 가능한 범위인지 사용자 안내 문구로 반환한다. */ +export const getAgedPipeStatusByCtpvValidationMessage = ( filters: AgedPipeStatusByCtpvFilters, ) => { - if (filters.startYear > filters.endYear) return false; + if (filters.startYear > filters.endYear) { + return "기준년도 시작은 종료보다 늦을 수 없습니다."; + } if ( filters.endYear - filters.startYear + 1 > AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN - ) - return false; - return filters.elapsedYears >= 1 && filters.elapsedYears <= 100; + ) { + return `기준년도 조회 범위는 최대 ${AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN}년까지 가능합니다.`; + } + if (filters.elapsedYears < 1 || filters.elapsedYears > 100) { + return "노후관로 기준은 1년부터 100년까지 입력할 수 있습니다."; + } + return null; }; +export const validateAgedPipeStatusByCtpvFilters = ( + filters: AgedPipeStatusByCtpvFilters, +) => getAgedPipeStatusByCtpvValidationMessage(filters) === null; + const formatMetric = (value: number) => Number(value ?? 0).toLocaleString("ko-KR", { minimumFractionDigits: 2, 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 b4dccf951..402685f8e 100644 --- a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx @@ -10,6 +10,7 @@ import { getRawJson } from "@/components/lib/api"; import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; 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"; @@ -258,13 +259,15 @@ export default function FldngHstryPopupPage() { setIsLoading(true); try { - const response = await fetchRows( - 1, - appliedFilters, - Math.max(total, FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE), - ); + 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.rows) as unknown as JSON[], + data: buildDownloadRows(response.items) as unknown as JSON[], fileName: `침수발생이력_${new Date().toISOString().slice(0, 10)}.xlsx`, sheetNameArray: ["침수발생이력"], headerRange: "A1:Q1", diff --git a/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts b/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts index fd5a335b1..ab03f132d 100644 --- a/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts +++ b/apps/sn/app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts @@ -5,7 +5,7 @@ import type { UiHeadProps } from "@common/business_components/ui"; export const FLOOD_DNGR_MAP_POPUP_KEY = "floodDngrMapPopup"; export const FLOOD_DNGR_MAP_LAYER_TYPE = "tb_ms_flood_dngr_map"; export const FLOOD_DNGR_MAP_LAYER_NAME = "도시침수지도(정보제공포털)"; -export const FLOOD_DNGR_MAP_PAGE_SIZE = 20; +export const FLOOD_DNGR_MAP_PAGE_SIZE = 10; export type FloodDngrMapFilters = { ctpvCd: string; 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 680c2ba65..f6020437d 100644 --- a/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx @@ -5,8 +5,10 @@ import { DateMonthPicker } from "@common/business_components/ui/date_month_picke import { StandardPopupLayout, type UiHeadProps, + type UiTableSort, } from "@common/business_components"; import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; +import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { useAlertStore } from "@/shared/lib"; import { Button } from "@common/components"; @@ -28,16 +30,21 @@ import { const INFTWT_RESULT_PAGE_SIZE = 10; const INFTWT_RESULT_HEADLIST: UiHeadProps[] = [ - { id: "rowNumber", title: "순번" }, + { + id: "rowNumber", + title: "순번", + sortable: true, + sortKey: "inftwtAnlsSn", + }, { id: "ctpvNm", title: "시도" }, { id: "sggNm", title: "시군구" }, - { id: "swtrmtFcltNm", title: "하수처리시설" }, - { id: "aplcnYm", title: "적용연월" }, - { id: "drtwSwtcRt", title: "오수 전환율" }, - { id: "anlsInfo", title: "침입수 분석정보" }, - { id: "regDt", title: "분석일자" }, + { id: "swtrmtFcltNm", title: "하수처리시설", sortable: true }, + { id: "aplcnYm", title: "적용연월", sortable: true }, + { id: "drtwSwtcRt", title: "오수 전환율", sortable: true }, + { id: "anlsInfo", title: "침입수 분석정보", sortable: true }, + { id: "regDt", title: "분석일자", sortable: true }, { id: "rgtrNm", title: "분석자" }, - { id: "scsYn", title: "분석성공여부" }, + { id: "scsYn", title: "분석성공여부", sortable: true }, ]; type InftwtResultFilters = { @@ -99,6 +106,7 @@ export default function InftwtResultPopupPage() { const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [currentPage, setCurrentPage] = useState(1); + const [sort, setSort] = useState(); const [isLoading, setIsLoading] = useState(false); useEffect(() => { @@ -107,13 +115,22 @@ export default function InftwtResultPopupPage() { }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); const fetchRows = useCallback( - async (page: number, nextFilters: InftwtResultFilters) => { + async ( + page: number, + nextFilters: InftwtResultFilters, + nextSort: UiTableSort | undefined = sort, + ) => { const queryParams = new URLSearchParams({ page: String(page - 1), size: String(INFTWT_RESULT_PAGE_SIZE), scsYn: "Y", }); - queryParams.append("sort", "inftwtAnlsSn,desc"); + queryParams.append( + "sort", + nextSort + ? `${nextSort.key},${nextSort.direction}` + : "inftwtAnlsSn,desc", + ); if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom); @@ -155,14 +172,18 @@ export default function InftwtResultPopupPage() { setTotal(totalElements); setCurrentPage(page); }, - [ctpv, sgg], + [ctpv, sgg, sort], ); const loadRows = useCallback( - async (page = 1, nextFilters = filters) => { + async ( + page = 1, + nextFilters = filters, + nextSort: UiTableSort | undefined = sort, + ) => { setIsLoading(true); try { - await fetchRows(page, nextFilters); + await fetchRows(page, nextFilters, nextSort); } catch (error) { console.error("침입수 분석결과 목록 조회 중 오류:", error); alert("침입수 분석결과 목록을 불러오지 못했습니다."); @@ -172,11 +193,11 @@ export default function InftwtResultPopupPage() { setIsLoading(false); } }, - [alert, fetchRows, filters], + [alert, fetchRows, filters, sort], ); useEffect(() => { - void loadRows(1, DEFAULT_FILTERS); + void loadRows(1, DEFAULT_FILTERS, undefined); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -190,7 +211,15 @@ export default function InftwtResultPopupPage() { setFilters(DEFAULT_FILTERS); setSelectedSwagePrcsFclt("0"); setSelectedRow(null); - await loadRows(1, DEFAULT_FILTERS); + setSort(undefined); + await loadRows(1, DEFAULT_FILTERS, undefined); + }; + + /** 정렬 변경 시 선택 결과를 해제하고 서버 첫 페이지를 조회한다. */ + const handleSortChange = (nextSort?: UiTableSort) => { + setSort(nextSort); + setSelectedRow(null); + void loadRows(1, filters, nextSort); }; const handleRowClick = (index?: number) => { @@ -210,15 +239,19 @@ export default function InftwtResultPopupPage() { try { setIsLoading(true); - const response = await getData( - `v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}`, - ); - const excelRows = Array.isArray(response?.content) - ? response.content - : []; + 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 JSON[], + data: excelRows as unknown as JSON[], fileName: `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`, sheetNameArray: ["침입수 분석결과"], headerRange: "A1:D1", @@ -355,6 +388,8 @@ export default function InftwtResultPopupPage() { rowId={(row) => String(row.inftwtAnlsSn)} scrollMode="horizontal" minTableWidth="110rem" + sort={sort} + onSortChange={handleSortChange} actions={
([]); const [total, setTotal] = useState(0); const [currentPage, setCurrentPage] = useState(1); + const [sort, setSort] = useState(); const [isLoading, setIsLoading] = useState(false); useEffect(() => { @@ -109,13 +117,22 @@ export default function RdiiResultPopupPage() { }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); const fetchRows = useCallback( - async (page: number, nextFilters: RdiiResultFilters) => { + async ( + page: number, + nextFilters: RdiiResultFilters, + nextSort: UiTableSort | undefined = sort, + ) => { const queryParams = new URLSearchParams({ page: String(page - 1), size: String(RDII_RESULT_PAGE_SIZE), scsYn: "Y", }); - queryParams.append("sort", "rainInfwtAnlsSn,desc"); + queryParams.append( + "sort", + nextSort + ? `${nextSort.key},${nextSort.direction}` + : "rainInfwtAnlsSn,desc", + ); if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom); @@ -157,14 +174,18 @@ export default function RdiiResultPopupPage() { setTotal(totalElements); setCurrentPage(page); }, - [ctpv, sgg], + [ctpv, sgg, sort], ); const loadRows = useCallback( - async (page = 1, nextFilters = filters) => { + async ( + page = 1, + nextFilters = filters, + nextSort: UiTableSort | undefined = sort, + ) => { setIsLoading(true); try { - await fetchRows(page, nextFilters); + await fetchRows(page, nextFilters, nextSort); } catch (error) { console.error("RDII 분석결과 목록 조회 중 오류:", error); alert("RDII 분석결과 목록을 불러오지 못했습니다."); @@ -174,11 +195,11 @@ export default function RdiiResultPopupPage() { setIsLoading(false); } }, - [alert, fetchRows, filters], + [alert, fetchRows, filters, sort], ); useEffect(() => { - void loadRows(1, DEFAULT_FILTERS); + void loadRows(1, DEFAULT_FILTERS, undefined); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -192,7 +213,15 @@ export default function RdiiResultPopupPage() { setFilters(DEFAULT_FILTERS); setSelectedSwagePrcsFclt("0"); setSelectedRow(null); - await loadRows(1, DEFAULT_FILTERS); + setSort(undefined); + await loadRows(1, DEFAULT_FILTERS, undefined); + }; + + /** 정렬 변경 시 선택 결과를 해제하고 서버 첫 페이지를 조회한다. */ + const handleSortChange = (nextSort?: UiTableSort) => { + setSort(nextSort); + setSelectedRow(null); + void loadRows(1, filters, nextSort); }; const handleRowClick = (index?: number) => { @@ -212,15 +241,19 @@ export default function RdiiResultPopupPage() { try { setIsLoading(true); - const response = await getData( - `v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}`, - ); - const excelRows = Array.isArray(response?.content) - ? response.content - : []; + 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 JSON[], + data: excelRows as unknown as JSON[], fileName: `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`, sheetNameArray: ["RDII 분석결과"], headerRange: "A1:D1", @@ -357,6 +390,8 @@ export default function RdiiResultPopupPage() { rowId={(row) => String(row.rainInfwtAnlsSn)} scrollMode="horizontal" minTableWidth="125rem" + sort={sort} + onSortChange={handleSortChange} actions={ -
+ void handlePageChange(page)} onRowClick={handleRowClick} rowId={(row, index) => String(row.fid ?? row.acdntNo ?? index)} + renderCell={(row, headId) => + headId === "fullAddress" ? ( + + {toDisplay(row.fullAddress)} + + ) : undefined + } scrollMode="horizontal" minTableWidth="150rem" actions={ diff --git a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx index 362d69f0f..2077532aa 100644 --- a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx @@ -440,6 +440,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { const handleChangeResultMode = (mode: WaterRiskResultMode) => { setResultMode(mode); setResultCurrentPage(1); + setResultSort(undefined); setResultPage(EMPTY_RESULT_PAGE); setSelectedResultKey(""); }; diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 5d4423786..a00d280c4 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -96,7 +96,7 @@ import { toDisplayPipeRow, validateElapsedYears, } from "./components/agedPipe/agedPipeUtils"; -import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport"; +import { downloadAgedPipeExcel } from "./components/agedPipe/agedPipeExport"; import { useInftwtResultMap } from "./components/inftwtResult/useInftwtResultMap"; import { useRdiiResultMap } from "./components/rdiiResult/useRdiiResultMap"; import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel"; @@ -6064,6 +6064,8 @@ const Map = () => { const handlePipeAnalysisRowSelect = useCallback( async (row: Pick) => { + // 관로현황 팝업에서 선택한 경우 메시지를 받은 최상위 지도창을 먼저 앞으로 가져온다. + window.focus(); try { shouldFocusAgedPipeOnNextSelectRef.current = true; const core = GisApp.Module.layer as any; @@ -6076,6 +6078,8 @@ const Map = () => { setFeatureData(row as any); setIsOpen(true); setIsSearchModalOpen(false); + // 지도 이동과 상세 패널 렌더링 이후에도 최상위 창 포커스를 한 번 더 요청한다. + window.focus(); }, [setIsSearchModalOpen], ); @@ -6088,19 +6092,19 @@ const Map = () => { try { setIsAgedPipeListLoading(true); - setAgedPipeDownloadProgressText("CSV export 작업을 준비하고 있습니다."); - await downloadAgedPipeCsv(appliedAgedPipeFilters, (job) => { + setAgedPipeDownloadProgressText("Excel 파일을 준비하고 있습니다."); + await downloadAgedPipeExcel(appliedAgedPipeFilters, (job) => { if (job.status === "QUEUED") { - setAgedPipeDownloadProgressText("CSV export 작업이 대기 중입니다."); + setAgedPipeDownloadProgressText("Excel 다운로드 작업이 대기 중입니다."); return; } if (job.status === "RUNNING") { setAgedPipeDownloadProgressText( - `CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`, + `Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`, ); } - }); + }, agedPipeTotalCount); setAgedPipeDownloadProgressText(""); } catch (error) { console.error("노후관로 목록 다운로드 중 오류:", error); @@ -6108,7 +6112,7 @@ const Map = () => { alert( error instanceof Error ? error.message - : "노후관로 CSV 다운로드 중 오류가 발생했습니다.", + : "노후관로 Excel 다운로드 중 오류가 발생했습니다.", ); } finally { setIsAgedPipeListLoading(false); @@ -8885,13 +8889,12 @@ const Map = () => {
- GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면{" "} + GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면 +
+
위험, 없으면{" "} 정상
-
- 맨홀 침수유량은 지역 위험 판정에서 제외 -
@@ -9061,6 +9064,7 @@ const Map = () => { isFloodDngrMapRootNode(node) || isFldngHstryNode(node) } + layerIconRequiresChecked={false} defaultZoom={7.5} defaultLonLat={[129, 36.3]} viewExtent={SOUTH_KOREA_VIEW_EXTENT} diff --git a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts index 207456e50..368bb0bb3 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts @@ -13,6 +13,7 @@ * - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service) */ import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; +import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, @@ -50,6 +51,9 @@ 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(); @@ -64,6 +68,7 @@ export default function useBasicTab() { setTableData, setTableCount, setIsLoading, + setAppliedBasicQuery, } = useSearchModalStore(); const { ctpv, sgg } = useStore(); @@ -168,6 +173,12 @@ export default function useBasicTab() { setTableData(dataWithRowNumber); setTableCount(totalCount); + queryParams.delete("page"); + queryParams.delete("size"); + setAppliedBasicQuery({ + basicType, + query: queryParams.toString(), + }); }; // 기본정보 테이블 cellClick 핸들러 @@ -220,73 +231,77 @@ export default function useBasicTab() { setIsLoading(true); try { - const queryParams = new URLSearchParams({ - lyrTypeNm: basicType, - size: "10000", - }); - if (basicType === "tb_ms_swage_ppl") - queryParams.set("includeFlowDirectionBasis", "true"); + 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 cqlFilters: string[] = []; let headerMapping: { [key: string]: string } = {}; let headerRange: string = "A1:F1"; - switch (basicType) { + switch (appliedBasicType) { case "tb_ms_swage_ppl": { // 하수관로 - cqlFilters = swagePplCqlFilters(); headerMapping = MsSwagePplExcelCol; headerRange = "A1:Q1"; break; } case "tb_il_swage_prcs_fclt": { // 하수처리시설 - cqlFilters = swagePrcsFcltCqlFilters(); headerMapping = SwtrmtFcltExcelCol; headerRange = "A1:J1"; break; } case "tb_ms_swage_prcs_zone": { // 하수처리구역 - cqlFilters = swagePrcsZoneCqlFilters(); headerMapping = SwtrmtPrcsZoneExcelCol; headerRange = "A1:D1"; break; } case "tb_ms_fldng_wtlv_map": { // 침수흔적도 - cqlFilters = fldngWtlvMapCqlFilters(); headerMapping = FldngWtlvMapExcelCol; headerRange = "A1:O1"; break; } case "tb_ms_swage_mhl": { // 하수맨홀 - cqlFilters = swageMhlCqlFilters(); headerMapping = MsSwageMhlExcelCol; headerRange = "A1:J1"; break; } case "tb_ms_swage_pmstn": { // 하수펌프장 - cqlFilters = swagePmstnCqlFilters(); headerMapping = ExclnSewagePumpExcelCol; headerRange = "A1:J1"; break; } case "tb_ms_stofcbr": { // 우수토실 - cqlFilters = stofcbrCqlFilters(); headerMapping = stofcbrExcelCol; headerRange = "A1:H1"; break; } } - if (cqlFilters.length > 0) - queryParams.append("cqlFiter", cqlFilters.join(" and ")); - - const res = await getData(`v1/fclts?${queryParams.toString()}`); + 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]), @@ -307,7 +322,7 @@ export default function useBasicTab() { sgg.map((code) => [code.val?.trim(), code.text]), ); - let mappedContent = (res.data || []).map((item: any) => { + let mappedContent = res.items.map((item: any) => { const formattedItem = formatDisplayData(item); let ctpvCd = item?.ctpvCd ?? formattedItem?.ctpvCd ?? ""; @@ -329,7 +344,7 @@ export default function useBasicTab() { const trimmedUsgCd = item?.usgCd?.trim(); // const trimmedMqltCd = item?.mqltCd?.trim(); - switch (basicType) { + switch (appliedBasicType) { case "tb_ms_swage_ppl": { // 하수관로 return { @@ -483,11 +498,13 @@ export default function useBasicTab() { const excelData = mappedContent; // 배열로 감싸서 전달 // 파일명 생성 (분석번호 기반) - const label = BASIC_TYPES.find((type) => type.val === basicType)?.text; + const label = BASIC_TYPES.find( + (type) => type.val === appliedBasicType, + )?.text; const fileName = `${label} 조회 결과_${todayYear}${todayMonth}${todayDate}`; await downloadExcelWithKoreanHeaders({ - data: excelData as JSON[], + data: excelData as unknown as JSON[], fileName: fileName, sheetNameArray: [fileName], headerRange: headerRange, diff --git a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts index 43e726cc3..fbe13aa43 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts @@ -15,6 +15,7 @@ * - 유지관리 정보(사업정보) 관련 시설 목록 조회: GET /api/v1/bizInfo/fclts (sewerage-service) */ import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; +import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES, @@ -28,6 +29,7 @@ import useSearchModalStore from "@/store/useSearchModalStore"; import useStore from "@/store/useStore"; import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import { useRef } from "react"; const isPopupWindow = () => typeof window !== "undefined" && @@ -73,6 +75,15 @@ export default function useOperationTab() { { val: "['100']", text: "점검 및 진단" }, { val: "['203']", text: "청소 및 준설" }, ]; + // 화면 사업 목록과 결과 목록을 마지막으로 만든 요청을 보관해 Excel·페이지 이동에 재사용한다. + const appliedOperationQueryRef = useRef<{ + query: string; + workType: string; + } | null>(null); + const appliedOperationDetailQueryRef = useRef<{ + query: string; + workType: string; + } | null>(null); const OPERATION_TABLE_HEADLIST = [ // 유지관리 정보 - 사업 현황 { id: "rowNumber", title: "순번" }, @@ -173,6 +184,14 @@ export default function useOperationTab() { setTableCount(totalElements); setDetailTableData([]); setDetailTableCount(0); + const appliedParams = new URLSearchParams(params); + appliedParams.delete("page"); + appliedParams.delete("size"); + appliedOperationQueryRef.current = { + query: appliedParams.toString(), + workType, + }; + appliedOperationDetailQueryRef.current = null; }; // 유지관리 정보 테이블 cellClick 핸들러 @@ -196,9 +215,9 @@ export default function useOperationTab() { }; RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); - + const requestParams = new URLSearchParams(RequestDTO); const res = await getData( - `v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`, + `v1/bizInfo/fclts?${requestParams.toString()}`, ); const totalDetailElements = Number(res.count ?? 0); @@ -224,6 +243,12 @@ export default function useOperationTab() { setDetailTableData(mappedData); setDetailTableCount(totalDetailElements); + requestParams.delete("page"); + requestParams.delete("size"); + appliedOperationDetailQueryRef.current = { + query: requestParams.toString(), + workType, + }; } catch (error) { console.error("유지관리 정보 cellClick 에러:", error); setDetailTableData([]); @@ -272,13 +297,18 @@ export default function useOperationTab() { if (typeof index === "number") { setIsDetailLoading(true); try { - const RequestDTO: Record = { - page: `${detailCurPage - 1}`, - size: `${OPERATION_TABLE_SIZE}`, - bizId: String(detailBizId), - }; + const appliedDetail = appliedOperationDetailQueryRef.current; + const requestParams = new URLSearchParams( + appliedDetail?.query ?? + new URLSearchParams({ + bizId: String(detailBizId), + workTypes: String(JSON.parse(workType.replace(/'/g, '"'))), + }).toString(), + ); + requestParams.set("page", `${detailCurPage - 1}`); + requestParams.set("size", `${OPERATION_TABLE_SIZE}`); const res = await getData( - `v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`, + `v1/bizInfo/fclts?${requestParams.toString()}`, ); const totalElements = Number(res.count ?? 0); @@ -335,27 +365,24 @@ export default function useOperationTab() { setIsLoading(true); try { - const RequestDTO: Record = { - size: "10000", - }; - if (var1) RequestDTO.bizInfoNm = var1; - if (var2 != "0" && var2) RequestDTO.bizTypeCd = var2; - // 수행기간 범위 - const startDate = getDateString(yearFilter.start, "", "", "19000101"); - const endDate = getDateString( - yearFilter.end, - "", - "", - `${todayYear}${todayMonth}${todayDate}`, - ); - RequestDTO.fromflfmtEndYmd = startDate; - RequestDTO.toflfmtEndYmd = endDate; + const appliedQuery = appliedOperationQueryRef.current; + if (!appliedQuery) { + throw new Error( + "화면 조회조건을 확인할 수 없습니다. 다시 조회해주세요.", + ); + } - // JSON.parse 사용으로 문자열 -> 배열 변환 - RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); - - const params = new URLSearchParams(RequestDTO).toString(); - const res = await getData(`v1/bizInfo?${params}`); + const res = await fetchAllPages({ + fetchPage: (page, size) => { + const params = new URLSearchParams(appliedQuery.query); + params.set("page", String(page)); + params.set("size", String(size)); + return getData(`v1/bizInfo?${params.toString()}`); + }, + getItems: (page) => + Array.isArray(page?.content) ? page.content : [], + getTotal: (page) => Number(page?.totalElements ?? 0), + }); // 헤더 매핑 정의 const headerMapping: { [key: string]: string } = { @@ -371,7 +398,7 @@ export default function useOperationTab() { BIZ_TY_CD.map((code) => [code.lwpoCd, code.lwpoCdNm]), ); - const mappedContent = (res.content || []).map((item: any) => { + const mappedContent = res.items.map((item: any) => { const formattedItem = formatDisplayData(item); return { @@ -394,11 +421,13 @@ export default function useOperationTab() { const excelData = mappedContent; // 배열로 감싸서 전달 // 파일명 생성 (분석번호 기반) - const label = WORK_TYPES.find((type) => type.val === workType)?.text; + const label = WORK_TYPES.find( + (type) => type.val === appliedQuery.workType, + )?.text; const fileName = `${label} 사업 현황_${todayYear}${todayMonth}${todayDate}`; await downloadExcelWithKoreanHeaders({ - data: excelData as JSON[], + data: excelData as unknown as JSON[], fileName: fileName, sheetNameArray: [fileName], headerRange: "A1:F1", @@ -433,14 +462,23 @@ export default function useOperationTab() { setIsLoading(true); try { - const RequestDTO: Record = { - size: "100", - bizId: String(detailBizId), - }; + const appliedQuery = appliedOperationDetailQueryRef.current; + if (!appliedQuery) { + throw new Error( + "화면 결과 조회조건을 확인할 수 없습니다. 다시 조회해주세요.", + ); + } - const res = await getData( - `v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`, - ); + const res = await fetchAllPages({ + fetchPage: (page, size) => { + const params = new URLSearchParams(appliedQuery.query); + params.set("page", String(page)); + params.set("size", String(size)); + return getData(`v1/bizInfo/fclts?${params.toString()}`); + }, + getItems: (page) => (Array.isArray(page?.data) ? page.data : []), + getTotal: (page) => Number(page?.count ?? 0), + }); // 헤더 매핑 정의 const headerMapping: { [key: string]: string } = { @@ -463,7 +501,7 @@ export default function useOperationTab() { ); // 🎯 단일 반복문으로 최적화 - 각 아이템마다 필요한 필드만 직접 매핑 - const mappedData = (res.data || []).map((item: any) => { + const mappedData = res.items.map((item: any) => { const formattedItem = formatDisplayData(item); // trim 한 번만 수행 @@ -507,11 +545,13 @@ export default function useOperationTab() { const excelData = mappedData; // 배열로 감싸서 전달 // 파일명 생성 (분석번호 기반) - const label = WORK_TYPES.find((type) => type.val === workType)?.text; + const label = WORK_TYPES.find( + (type) => type.val === appliedQuery.workType, + )?.text; const fileName = `${label} 결과표_${todayYear}${todayMonth}${todayDate}`; await downloadExcelWithKoreanHeaders({ - data: excelData as JSON[], + data: excelData as unknown as JSON[], fileName: fileName, sheetNameArray: [fileName], headerRange: "A1:I1", diff --git a/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx b/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx index c4528ce94..28d3b3efe 100644 --- a/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx +++ b/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx @@ -319,7 +319,11 @@ const BaseLayout = ({ {titleName?.secondCardTitle}
- {graphBtn && } + {graphBtn && ( + + )} {/* 그래프 있을 시 */} {graphBtn && (
- - {COLUMN_WIDTHS.map((width, index) => ( - - ))} - - - - - - - - - - - - - - - - - - - - - - - - - {rows.length > 0 ? ( - rows.map((row, index) => { - const pipeSpan = pipeSpans.get(row.pipeNo); - const unitKey = `${row.pipeNo}::${row.unitSeq}`; - const unitSpan = unitSpans.get(unitKey); - const showPipeCell = pipeSpan?.firstIndex === index; - const showUnitCells = unitSpan?.firstIndex === index; +
- 관로번호 - - 단위 -
- 구간 -
- 단위 -
- 길이 -
- (m) -
- 결함 -
- 위치 -
- (m) -
- 결함 - - 등급점수(점) - - 조사중단사유 - - 비고 -
- 종류 - - 코드 - - 등급 - - 발생 -
- 위치 -
- 침입수 -
- 가점 -
- 연속 -
- 결함 -
- 구조적 - - 운영적 -
+ + {COLUMN_WIDTHS.map((width, index) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + {rows.length > 0 ? ( + rows.map((row, index) => { + const pipeSpan = pipeSpans.get(row.pipeNo); + const unitKey = `${row.pipeNo}::${row.unitSeq}`; + const unitSpan = unitSpans.get(unitKey); + const showPipeCell = pipeSpan?.firstIndex === index; + const showUnitCells = unitSpan?.firstIndex === index; - return ( - - {showPipeCell && ( - - )} - {showUnitCells && ( - <> - - - - )} - - - - - - - - - - - - - ); - }) - ) : ( - - - - )} - -
+ 관로번호 + + 단위 +
+ 구간 +
+ 단위 +
+ 길이 +
+ (m) +
+ 결함 +
+ 위치 +
+ (m) +
+ 결함 + + 등급점수(점) + + 조사중단사유 + + 비고 +
+ 종류 + + 코드 + + 등급 + + 발생 +
+ 위치 +
+ 침입수 +
+ 가점 +
+ 연속 +
+ 결함 +
+ 구조적 + + 운영적 +
- {row.pipeNo} - - {row.unitSeq} - - {row.unitLengthM} - - {formatOptionalValue(row.defectPositionM)} - - {row.defectName} - - {row.code} - - {row.severity ? SEVERITY_LABELS[row.severity] : "-"} - - {formatOptionalValue(row.occurrencePosition)} - - {formatOptionalValue(row.infiltrationBonus)} - - {row.continuous == null ? "-" : row.continuous ? "Y" : "N"} - - {formatOptionalValue(row.structuralScore)} - - {formatOptionalValue(row.operationalScore)} - - {formatOptionalValue(row.surveyStopReason)} - - {formatOptionalValue(row.remark)} -
- {emptyMessage} -
- + {showPipeCell && ( + + {row.pipeNo} + + )} + {showUnitCells && ( + <> + + {row.unitSeq} + + + {row.unitLengthM} + + + )} + + {formatOptionalValue(row.defectPositionM)} + + + {row.defectName} + + + {row.code} + + + {row.severity ? SEVERITY_LABELS[row.severity] : "-"} + + + {formatOptionalValue(row.occurrencePosition)} + + + {formatOptionalValue(row.infiltrationBonus)} + + + {row.continuous == null ? "-" : row.continuous ? "Y" : "N"} + + + {formatOptionalValue(row.structuralScore)} + + + {formatOptionalValue(row.operationalScore)} + + + {formatOptionalValue(row.surveyStopReason)} + + + {formatOptionalValue(row.remark)} + + + ); + }) + ) : ( + + + {emptyMessage} + + + )} + + ); } diff --git a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx b/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx index 468a0d6f3..35eee27f8 100644 --- a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx @@ -19,11 +19,12 @@ import { import { STANDARD_ROW_NUMBER_HEADER, StandardDataTable, + StandardTableSortHeader, getStandardRowNumber, + type UiTableSort, } from "@common/business_components"; import { getCoreRowModel, - getPaginationRowModel, useReactTable, type ColumnDef, type PaginationState, @@ -40,20 +41,23 @@ import type { import { ExmnStatusDetailPanel } from "./ExmnStatusDetailPanel"; import { EXMN_TABLE_HEADER_CLASS } from "./ExmnStandardDataTable"; -// 서버 호환을 위해 전체 후보를 한 번에 받고 공통 표에서 10건씩 페이지 표시한다. -const BUSINESS_LIST_SCROLL_FETCH_SIZE = 1000; const BUSINESS_LIST_PAGE_SIZE = 10; const BUSINESS_TABLE_HEAD = [ - { id: "rowNumber", title: STANDARD_ROW_NUMBER_HEADER }, - { id: "bizCyclText", title: "사업차수" }, - { id: "bizYr", title: "사업년도" }, - { id: "ctpvNm", title: "시도" }, - { id: "sggNm", title: "시군구" }, - { id: "bizNm", title: "사업명" }, - { id: "trtdstCntText", title: "처리구역·분구(개소)" }, - { id: "prcisExmnPrlgText", title: "정밀조사 연장(km)" }, - { id: "reportYnText", title: "보고서" }, + { id: "rowNumber", title: STANDARD_ROW_NUMBER_HEADER, sortKey: "exmnBizId" }, + { id: "bizCyclText", title: "사업차수", sortKey: "bizCycl" }, + { id: "bizYr", title: "사업년도", sortKey: "bizYr" }, + { id: "ctpvNm", title: "시도", sortKey: "ctpvNm" }, + { id: "sggNm", title: "시군구", sortKey: "sggNm" }, + { id: "bizNm", title: "사업명", sortKey: "bizNm" }, + { id: "trtdstCntText", title: "처리구역·분구(개소)", sortKey: "trtdstCnt" }, + { + id: "prcisExmnPrlgText", + title: "정밀조사 연장(km)", + sortKey: "prcisExmnPrlg", + }, + // 첨부서비스에서 현재 페이지 파일명을 보강하므로 전체 결과 파일명 정렬은 지원하지 않는다. + { id: "reportYnText", title: "보고서", sortKey: null }, ]; interface BusinessFilters { @@ -94,6 +98,8 @@ export function ExmnRegistrationPage() { pageIndex: 0, pageSize: BUSINESS_LIST_PAGE_SIZE, }); + // 표시명 컬럼도 같은 이름 필드를 사용하는 서버 단일 정렬 상태다. + const [sort, setSort] = useState(); // 선택 사업 ID는 서버 재조회 뒤에도 같은 행을 복원할 수 있도록 객체와 별도로 유지한다. const [selectedBusinessId, setSelectedBusinessId] = useState( null, @@ -143,6 +149,8 @@ export function ExmnRegistrationPage() { rowNumber: getStandardRowNumber({ total: totalElements, rowIndex: index, + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, }), bizCyclText: business.bizCycl == null ? "-" : `${business.bizCycl}차`, trtdstCntText: Number(business.trtdstCnt ?? 0).toLocaleString("ko-KR"), @@ -156,35 +164,47 @@ export function ExmnRegistrationPage() { ? "있음" : "없음"), })), - [businesses, totalElements], + [businesses, pagination.pageIndex, pagination.pageSize, totalElements], ); - /** 화면 조건을 행정구역 이름과 목록 내부 스크롤용 단일 서버 Page 계약으로 변환한다. */ + /** 화면 조건과 현재 정렬을 0-base 서버 Page 계약으로 변환한다. */ const buildSearchParams = ( filters: BusinessFilters, + pageIndex: number, + nextSort?: UiTableSort, ): ExmnBusinessSearchParams => ({ bizCycl: filters.bizCycl || undefined, bizYr: filters.bizYr !== "0" ? filters.bizYr : undefined, ctpvNm: filters.ctpvNm || undefined, sggNm: filters.sggNm || undefined, bizNm: filters.bizNm || undefined, - page: 0, - size: BUSINESS_LIST_SCROLL_FETCH_SIZE, + page: pageIndex, + size: BUSINESS_LIST_PAGE_SIZE, + sort: nextSort ? `${nextSort.key},${nextSort.direction}` : undefined, }); - /** 지정 조회조건으로 사업 목록을 읽고 선택 행을 가능한 경우 유지한다. */ - const fetchBusinesses = async (filters: BusinessFilters) => { + /** 지정 조회조건·정렬·페이지로 사업 목록을 읽고 현재 페이지 선택을 복원한다. */ + const fetchBusinesses = async ( + filters: BusinessFilters, + pageIndex = 0, + nextSort: UiTableSort | undefined = sort, + ) => { setIsLoading(true); setNotice(null); try { - const result = await getExmnBusinesses(buildSearchParams(filters)); + const result = await getExmnBusinesses( + buildSearchParams(filters, pageIndex, nextSort), + ); const content = result.content; setBusinesses(content); setTotalElements(result.totalElements); setSelectedBusinessId((current) => content.some((item) => item.exmnBizId === current) ? current : null, ); - setPagination((current) => ({ ...current, pageIndex: 0 })); + setPagination((current) => ({ + ...current, + pageIndex: result.number, + })); return true; } catch (error) { setBusinesses([]); @@ -207,7 +227,7 @@ export function ExmnRegistrationPage() { useEffect(() => { if (initialBusinessesLoadedRef.current) return; initialBusinessesLoadedRef.current = true; - void fetchBusinesses(EMPTY_FILTERS); + void fetchBusinesses(EMPTY_FILTERS, 0, undefined); // 최초 진입 시점의 빈 조회조건으로 한 번만 실행한다. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -215,7 +235,7 @@ export function ExmnRegistrationPage() { /** 입력 중 조건을 확정하고 첫 페이지를 조회한다. */ const handleSearch = async () => { setAppliedFilters(draftFilters); - await fetchBusinesses(draftFilters); + await fetchBusinesses(draftFilters, 0, sort); }; /** CS 초기화 버튼으로 모든 검색조건과 페이지를 최초 조회 기준으로 복원한다. */ @@ -223,7 +243,8 @@ export function ExmnRegistrationPage() { setDraftFilters(EMPTY_FILTERS); setAppliedFilters(EMPTY_FILTERS); setSelectedBusinessId(null); - await fetchBusinesses(EMPTY_FILTERS); + setSort(undefined); + await fetchBusinesses(EMPTY_FILTERS, 0, undefined); }; /** 목록의 보고서 링크에서 첨부서비스 원본 PDF를 내려받는다. */ @@ -332,7 +353,7 @@ export function ExmnRegistrationPage() { event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED ) { setSelectedBusinessId(null); - void fetchBusinesses(appliedFilters).then((refreshed) => { + void fetchBusinesses(appliedFilters, 0, sort).then((refreshed) => { if (refreshed) setNotice({ type: "success", text: "조사사업이 등록되었습니다." }); }); @@ -342,7 +363,11 @@ export function ExmnRegistrationPage() { if ( event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_UPDATED ) { - void fetchBusinesses(appliedFilters).then((refreshed) => { + void fetchBusinesses( + appliedFilters, + pagination.pageIndex, + sort, + ).then((refreshed) => { if (refreshed) setNotice({ type: "success", text: "조사사업이 수정되었습니다." }); }); @@ -352,7 +377,11 @@ export function ExmnRegistrationPage() { if ( event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED ) { - void fetchBusinesses(appliedFilters).then((refreshed) => { + void fetchBusinesses( + appliedFilters, + pagination.pageIndex, + sort, + ).then((refreshed) => { if (refreshed) setNotice({ type: "success", @@ -364,7 +393,14 @@ export function ExmnRegistrationPage() { window.addEventListener("message", handlePopupMessage); return () => window.removeEventListener("message", handlePopupMessage); - }, [appliedFilters]); + }, [appliedFilters, pagination.pageIndex, sort]); + + /** 정렬 변경 시 첫 페이지와 선택 사업을 초기화하고 서버 전체 결과를 다시 조회한다. */ + const handleBusinessSortChange = (nextSort?: UiTableSort) => { + setSort(nextSort); + setSelectedBusinessId(null); + void fetchBusinesses(appliedFilters, 0, nextSort); + }; /** 보고서 다운로드와 행 선택을 유지하며 조사사업 목록을 공통 DataTable 열로 변환한다. */ const columns = useMemo[]>( @@ -372,7 +408,17 @@ export function ExmnRegistrationPage() { BUSINESS_TABLE_HEAD.map((head) => ({ id: head.id, accessorFn: (row) => row[head.id as keyof typeof row], - header: head.title, + header: () => + head.sortKey === null ? ( + head.title + ) : ( + + ), cell: ({ row, getValue }) => { const business = row.original; const isReportCell = head.id === "reportYnText"; @@ -420,7 +466,7 @@ export function ExmnRegistrationPage() { align: "center" as const, }, })), - [], + [appliedFilters, sort], ); const table = useReactTable({ @@ -431,9 +477,16 @@ export function ExmnRegistrationPage() { rowSelection: selectedBusinessId ? { [selectedBusinessId]: true } : {}, }, getRowId: (row) => row.exmnBizId, - onPaginationChange: setPagination, + rowCount: totalElements, + manualPagination: true, + onPaginationChange: (updater) => { + const next = + typeof updater === "function" ? updater(pagination) : updater; + if (next.pageIndex === pagination.pageIndex) return; + setSelectedBusinessId(null); + void fetchBusinesses(appliedFilters, next.pageIndex, sort); + }, getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), }); return ( diff --git a/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx b/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx index 9e15265f7..2276ab421 100644 --- a/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx @@ -2,6 +2,11 @@ import LoadingOverlay from "@/components/ui/LoadingBar"; import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; +import { + StandardTableSortHeader, + sortStandardTableRows, + type UiTableSort, +} from "@common/business_components"; import { Button, Table, @@ -28,23 +33,23 @@ import { EXMN_TABLE_HEADER_HEIGHT_REM } from "./ExmnStandardDataTable"; const SUMMARY_GRID_ROW_HEIGHT_REM = 3.2; const PIPE_SUMMARY_TABLE_HEAD = [ - { id: "rowNumber", title: "순번", widthPercent: 6 }, - { id: "trtdstNm", title: "처리분구", widthPercent: 18 }, - { id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13 }, - { id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13 }, - { id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 15 }, - { id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 14 }, - { id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 14 }, - { id: "etcCountText", title: "기타", widthPercent: 7 }, + { id: "rowNumber", title: "순번", widthPercent: 6, sortKey: "rowNumber" }, + { id: "trtdstNm", title: "처리분구", widthPercent: 18, sortKey: "trtdstNm" }, + { id: "trgtPrlgText", title: "대상연장(m)", widthPercent: 13, sortKey: "trgtPrlgText" }, + { id: "drvngDstncText", title: "주행거리(m)", widthPercent: 13, sortKey: "drvngDstncText" }, + { id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 15, sortKey: "wholeCountText" }, + { id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 14, sortKey: "structuralCountText" }, + { id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 14, sortKey: "operationalCountText" }, + { id: "etcCountText", title: "기타", widthPercent: 7, sortKey: "etcCountText" }, ]; const MANHOLE_SUMMARY_TABLE_HEAD = [ - { id: "rowNumber", title: "순번", widthPercent: 8 }, - { id: "trtdstNm", title: "처리분구", widthPercent: 28 }, - { id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 18 }, - { id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 17 }, - { id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 17 }, - { id: "etcCountText", title: "기타", widthPercent: 12 }, + { id: "rowNumber", title: "순번", widthPercent: 8, sortKey: "rowNumber" }, + { id: "trtdstNm", title: "처리분구", widthPercent: 28, sortKey: "trtdstNm" }, + { id: "wholeCountText", title: "총 이상항목(개소)", widthPercent: 18, sortKey: "wholeCountText" }, + { id: "structuralCountText", title: "구조적결함(개소)", widthPercent: 17, sortKey: "structuralCountText" }, + { id: "operationalCountText", title: "운영적결함(개소)", widthPercent: 17, sortKey: "operationalCountText" }, + { id: "etcCountText", title: "기타", widthPercent: 12, sortKey: "etcCountText" }, ]; type Notice = { type: "info" | "error"; text: string } | null; @@ -88,6 +93,8 @@ export function ExmnStatusDetailPanel({ const [summaryRows, setSummaryRows] = useState([]); /** 상세보기 팝업에 전달할 현재 선택 처리분구다. */ const [selectedTrtdstId, setSelectedTrtdstId] = useState(null); + // 서버가 반환한 전체 처리분구 집계행에 적용하는 단일 로컬 정렬 상태다. + const [sort, setSort] = useState(); const [isLoading, setIsLoading] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const [notice, setNotice] = useState(null); @@ -96,11 +103,13 @@ export function ExmnStatusDetailPanel({ useEffect(() => { setJobType("PPL"); setSelectedTrtdstId(null); + setSort(undefined); setNotice(null); }, [business?.exmnBizId]); useEffect(() => { setSelectedTrtdstId(null); + setSort(undefined); }, [jobType]); useEffect(() => { @@ -176,7 +185,7 @@ export function ExmnStatusDetailPanel({ etc: 0, }, ); - const rows: DisplaySummaryRow[] = summaryRows.map((row, index) => { + const displayRows: DisplaySummaryRow[] = summaryRows.map((row, index) => { const counts = getCounts(row); return { trtdstId: row.trtdstId, @@ -191,7 +200,7 @@ export function ExmnStatusDetailPanel({ }; }); return { - rows, + rows: sortStandardTableRows(displayRows, sort), totals: { trgtPrlgText: formatNumber(totals.trgtPrlg), drvngDstncText: formatNumber(totals.drvngDstnc), @@ -201,7 +210,7 @@ export function ExmnStatusDetailPanel({ etcCountText: formatNumber(totals.etc), } as Record, }; - }, [jobType, summaryRows]); + }, [jobType, sort, summaryRows]); const summaryColumns = jobType === "PPL" ? PIPE_SUMMARY_TABLE_HEAD : MANHOLE_SUMMARY_TABLE_HEAD; @@ -299,6 +308,12 @@ export function ExmnStatusDetailPanel({ setNotice(null); }; + /** 정렬 변경 시 상세 대상 선택을 해제하고 전체 집계행을 다시 배열한다. */ + const handleSummarySortChange = (nextSort?: UiTableSort) => { + setSort(nextSort); + setSelectedTrtdstId(null); + }; + /** 보이는 버튼 전체에서 조사유형을 즉시 바꾸고, 상세 조회는 별도 effect에서 수행한다. */ const handleJobTypeChange = (nextJobType: ExmnJobType) => { if (nextJobType === jobType) return; @@ -417,7 +432,12 @@ export function ExmnStatusDetailPanel({ className="sticky top-0 z-20 !py-[1.2rem] text-center" style={{ height: `${EXMN_TABLE_HEADER_HEIGHT_REM}rem` }} > - {column.title} + ))} diff --git a/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx b/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx index a0851ceb4..a750d4e6e 100644 --- a/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx @@ -3,7 +3,12 @@ import type { UIEvent } from "react"; import { useEffect, useMemo, useState } from "react"; -import { StandardPopupLayout } from "@common/business_components"; +import { + StandardPopupLayout, + StandardTableSortHeader, + sortStandardTableRows, + type UiTableSort, +} from "@common/business_components"; import { Button, Input, @@ -13,9 +18,12 @@ import { SelectItem, SelectTrigger, SelectValue, + Table, } from "@common/components"; import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; -import LoadingOverlay from "@/components/ui/LoadingBar"; +import PopupSearchPanel, { + PopupSearchActions, +} from "@/features/windowPopup/ui/PopupSearchPanel"; import { getExmnFacilityDetails, @@ -40,6 +48,7 @@ import { } from "../model/exmn-detail-table"; import { ExmnPplUnitDetailTable } from "./ExmnPplUnitDetailTable"; +import { ExmnSurveyStandardDataTable } from "./ExmnSurveyStandardDataTable"; const SEVERITY_LABELS: Record = { L: "대", @@ -163,6 +172,8 @@ export function ExmnSurveyDetailPopupPage({ const [searchRequestSeq, setSearchRequestSeq] = useState(0); /** 가로 스크롤 위치를 기준으로 요약 라벨과 고정 열 경계선 위치를 함께 보정한다. */ const [tableScrollLeft, setTableScrollLeft] = useState(0); + // 관로·맨홀 고정 정보 열을 전체 조회 결과에서 정렬하는 상태다. + const [sort, setSort] = useState(); /** 다운로드 미연결 등 현재 팝업 안내 메시지를 표시한다. */ const [notice, setNotice] = useState(null); /** 실제 DB 상세조회 결과와 요청 상태다. */ @@ -245,13 +256,20 @@ export function ExmnSurveyDetailPopupPage({ row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword), ); }, [actualUnitRows, appliedDetailNumberKeyword, isUnitMode]); - const rows = filteredRows; + const rows = useMemo( + () => + sortStandardTableRows(filteredRows, sort, (row, key) => + getFixedCellValue(row, key as ExmnDetailFixedColumnId), + ), + [filteredRows, sort], + ); /** 관로·맨홀 조사유형 또는 결함 범위가 바뀌면 이전 검색 조건과 안내를 초기화한다. */ useEffect(() => { setDetailMode("PIPE"); setDetailNumberKeyword(""); setAppliedDetailNumberKeyword(""); + setSort(undefined); setNotice(null); }, [defectType, jobType]); @@ -705,6 +723,9 @@ export function ExmnSurveyDetailPopupPage({ () => new Set(unitRows.map((row) => `${row.pipeNo}::${row.unitSeq}`)).size, [unitRows], ); + /** 관로 집계와 본단위구간 행을 같은 StandardDataTable 데이터 유무 계약으로 전달한다. */ + const standardTableRows: Array = + isUnitMode ? unitRows : rows; const popupTitle = isUnitMode ? "관로조사 본단위구간 상세현황" : defectType === "ALL" @@ -735,7 +756,6 @@ export function ExmnSurveyDetailPopupPage({ } bodyClassName="overflow-x-hidden" > - {notice && (
{!isDefectTypeView && ( -
+
+ +
)} {loadError && ( @@ -862,9 +882,20 @@ export function ExmnSurveyDetailPopupPage({
)} -
-
-

{tableTitle}

+ {isUnitMode ? ( <> @@ -898,10 +929,9 @@ export function ExmnSurveyDetailPopupPage({ )}
-
- -
- {isUnitMode ? ( + } + renderTable={() => + isUnitMode ? ( ) : ( -
- - - {fixedColumns.map((column) => ( - - ))} - {Array.from({ - length: - (visibleDefectColumns.length + 1) * - EXMN_DETAIL_SEVERITIES.length, - }).map((_, index) => ( - - ))} - - - - {fixedColumns.map((column, columnIndex) => { - const sticky = columnIndex < STICKY_COLUMN_COUNT; - const columnLabel = column.label; - return ( - + {fixedColumns.map((column) => ( + + ))} + {Array.from({ + length: + (visibleDefectColumns.length + 1) * + EXMN_DETAIL_SEVERITIES.length, + }).map((_, index) => ( + + ))} + + + + {fixedColumns.map((column, columnIndex) => { + const sticky = columnIndex < STICKY_COLUMN_COUNT; + const columnLabel = column.label; + return ( + - ); - })} - - - + )} + + ); + })} + + + + + {visibleDefectColumns.map((column) => ( + ))} + + + {EXMN_DETAIL_SEVERITIES.map((severity) => ( + - {visibleDefectColumns.map((column) => ( + ))} + {visibleDefectColumns.flatMap((column) => + EXMN_DETAIL_SEVERITIES.map((severity) => ( - ))} - - - {EXMN_DETAIL_SEVERITIES.map((severity) => ( - - ))} - {visibleDefectColumns.flatMap((column) => - EXMN_DETAIL_SEVERITIES.map((severity) => ( - + + {rows.length > 0 ? ( + <> + {rows.map((row) => { + const visibleAnomalyTotals = getVisibleAnomalyTotals( + row, + visibleDefectColumns, + ); + return ( + - {SEVERITY_LABELS[severity]} - - )), - )} - - - - {rows.length > 0 ? ( - <> - {rows.map((row) => { - const visibleAnomalyTotals = getVisibleAnomalyTotals( - row, - visibleDefectColumns, - ); - return ( - - {fixedColumns.map((column, columnIndex) => { - const sticky = - columnIndex < STICKY_COLUMN_COUNT; - return ( - - ); - })} - {EXMN_DETAIL_SEVERITIES.map((severity) => ( + {fixedColumns.map((column, columnIndex) => { + const sticky = columnIndex < STICKY_COLUMN_COUNT; + return ( - ))} - {visibleDefectColumns.flatMap((column) => - EXMN_DETAIL_SEVERITIES.map((severity) => ( - - )), - )} - - ); - })} - - + ))} + {visibleDefectColumns.flatMap((column) => + EXMN_DETAIL_SEVERITIES.map((severity) => ( + + )), + )} + + ); + })} + + + {summaryLabelColumnCount > STICKY_COLUMN_COUNT && ( + - {summaryLabelColumnCount > STICKY_COLUMN_COUNT && ( + ))} + {EXMN_DETAIL_SEVERITIES.map((severity) => ( + + ))} + {visibleDefectColumns.flatMap((column) => + EXMN_DETAIL_SEVERITIES.map((severity) => ( - ))} - {EXMN_DETAIL_SEVERITIES.map((severity) => ( - - ))} - {visibleDefectColumns.flatMap((column) => - EXMN_DETAIL_SEVERITIES.map((severity) => ( - - )), - )} - - - + - {summaryLabelColumnCount > STICKY_COLUMN_COUNT && ( - - ))} + 합계 + + + {summaryLabelColumnCount > STICKY_COLUMN_COUNT && ( + ))} + + {visibleDefectColumns.map((column) => ( + - {visibleDefectColumns.map((column) => ( - - ))} - - - ) : ( - - + ))} - )} - -
+
+ {columnIndex < 9 ? ( + + {columnLabel} + + } + sortKey={column.id} + sort={sort} + onSortChange={setSort} + /> + ) : ( {columnLabel} - - 이상항목 -
+ 이상항목 +
+ 합계 + + {column.label} ({column.code}) +
- 합계 + {SEVERITY_LABELS[severity]} - {column.label} ({column.code}) -
{SEVERITY_LABELS[severity]} +
- {getFixedCellValue(row, column.id)} - - {formatCount(visibleAnomalyTotals[severity])} + {getFixedCellValue(row, column.id)} - {formatCount( - row.defectCounts[column.code]?.[severity], - )} -
( + + {formatCount(visibleAnomalyTotals[severity])} + + {formatCount( + row.defectCounts[column.code]?.[severity], + )} +
+ - - 소계 - + 소계 + + + {summary.value} + {formatCount( + defectSummary.anomalySubtotals[severity], + )} + - {summary.value} - {formatCount( - defectSummary.anomalySubtotals[severity], + defectSummary.defectSubtotals[column.code]?.[ + severity + ], )} - {formatCount( - defectSummary.defectSubtotals[column.code]?.[ - severity - ], - )} -
+
+ - - 합계 - - - {summary.value} - + {summary.value} + + {formatCount(defectSummary.anomalyTotal)} + - {formatCount(defectSummary.anomalyTotal)} + {formatCount( + defectSummary.defectTotals[column.code], + )} - {formatCount( - defectSummary.defectTotals[column.code], - )} -
- 조회된 상세 결과가 없습니다. -
-
- )} -
-
+ + ) : ( + + + 조회된 상세 결과가 없습니다. + + + )} + + + ) + } + /> ); diff --git a/apps/sn/src/features/exmn/ui/ExmnSurveyStandardDataTable.tsx b/apps/sn/src/features/exmn/ui/ExmnSurveyStandardDataTable.tsx new file mode 100644 index 000000000..fd498371e --- /dev/null +++ b/apps/sn/src/features/exmn/ui/ExmnSurveyStandardDataTable.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useMemo, type ReactNode, type UIEventHandler } from "react"; +import { + getCoreRowModel, + useReactTable, + type ColumnDef, +} from "@tanstack/react-table"; + +import { StandardDataTable } from "@common/business_components"; + +interface ExmnSurveyStandardDataTableProps { + rows: T[]; + title: ReactNode; + actions?: ReactNode; + loading?: boolean; + emptyMessage: string; + minTableWidth?: string; + onScroll?: UIEventHandler; + renderTable: () => ReactNode; +} + +/** + * 조사 복합표의 병합 셀은 업무 화면에서 유지하고 카드·헤더·로딩·빈 상태·스크롤은 + * StandardDataTable이 소유하도록 연결한다. + */ +export function ExmnSurveyStandardDataTable({ + rows, + title, + actions, + loading = false, + emptyMessage, + minTableWidth = "100%", + onScroll, + renderTable, +}: ExmnSurveyStandardDataTableProps) { + /** 공통 표가 데이터 유무와 로딩 상태를 판단할 수 있도록 복합 행을 최소 열로 연결한다. */ + const columns = useMemo[]>( + () => [ + { + id: "complexRow", + accessorFn: (_row, index) => index, + }, + ], + [], + ); + const table = useReactTable({ + data: rows, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + + ); +} + +export type { ExmnSurveyStandardDataTableProps }; diff --git a/apps/sn/src/features/exmn/ui/PplSummaryModal.tsx b/apps/sn/src/features/exmn/ui/PplSummaryModal.tsx index f875a1509..9d7e5580b 100644 --- a/apps/sn/src/features/exmn/ui/PplSummaryModal.tsx +++ b/apps/sn/src/features/exmn/ui/PplSummaryModal.tsx @@ -12,7 +12,6 @@ import { } from "@common/components/ui"; import { useCallback, useEffect, useMemo, useState } from "react"; import { - downloadPplExport, getExmnBusiness, getPplFacilities, getPplSummaries, @@ -25,6 +24,8 @@ import type { } from "../model/exmn-business-types"; import { PplFacilityDetailModal } from "./PplFacilityDetailModal"; import { ExmnStandardDataTable } from "./ExmnStandardDataTable"; +import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; +import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; const SUMMARY_TABLE_HEAD = [ { id: "trtdstNm", title: "처리분구", widthPercent: 20 }, @@ -121,16 +122,6 @@ interface PplSummaryModalProps { const formatNumber = (value: number | null | undefined) => Number(value ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 }); -/** Blob을 PPL 집계 엑셀 다운로드로 전달한다. */ -const saveBlob = (blob: Blob, fileName: string) => { - const objectUrl = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = objectUrl; - anchor.download = fileName; - anchor.click(); - URL.revokeObjectURL(objectUrl); -}; - /** 처리분구별 PPL 집계에서 결함 수를 따라 시설 목록과 단위 상세까지 탐색하는 모달이다. */ export function PplSummaryModal({ open, @@ -352,10 +343,40 @@ export function PplSummaryModal({ setIsLoading(true); setNotice(null); try { - saveBlob( - await downloadPplExport(business.exmnBizId, trtdstId || undefined), - `${business.bizNm}_PPL집계.xlsx`, - ); + const result = await fetchAllPages({ + fetchPage: (page, size) => + getPplSummaries(business.exmnBizId, { + trtdstId: trtdstId || undefined, + page, + size, + }), + getItems: (page) => page.content ?? [], + getTotal: (page) => Number(page.totalElements ?? 0), + }); + const excelRows = result.items.map((row) => ({ + trtdstNm: row.trtdstNm, + trgtPrlg: row.trgtPrlg, + drvngDstnc: row.drvngDstnc, + pplExmnWholAnmlyArtclCnt: row.pplExmnWholAnmlyArtclCnt, + pplExmnStrctDfcCnt: row.pplExmnStrctDfcCnt, + pplExmnOperDfcCnt: row.pplExmnOperDfcCnt, + pplExmnEtcCnt: row.pplExmnEtcCnt, + })); + await downloadExcelWithKoreanHeaders({ + data: excelRows as unknown as JSON[], + fileName: `${business.bizNm}_PPL집계.xlsx`, + sheetNameArray: ["처리분구별 PPL 집계"], + headerRange: "A1:G1", + headerMapping: { + trtdstNm: "처리분구", + trgtPrlg: "대상연장(m)", + drvngDstnc: "주행거리(m)", + pplExmnWholAnmlyArtclCnt: "PPL 전체이상", + pplExmnStrctDfcCnt: "구조결함", + pplExmnOperDfcCnt: "운영결함", + pplExmnEtcCnt: "기타", + }, + }); } catch (error) { setNotice({ type: "error", diff --git a/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx b/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx index 101beaf93..3ade6952d 100644 --- a/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx +++ b/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx @@ -5,7 +5,10 @@ import useStore from "@/store/useStore"; import { STANDARD_ROW_NUMBER_HEADER, StandardDataTable, + StandardTableSortHeader, getStandardRowNumber, + sortStandardTableRows, + type UiTableSort, } from "@common/business_components"; import { Button, @@ -143,6 +146,8 @@ export function TrtdstManagementPage() { pageIndex: 0, pageSize: 10, }); + // 서버가 반환한 전체 활성 처리분구를 페이지 절단 전에 정렬하는 상태다. + const [sort, setSort] = useState(); // DataTable 선택 행은 저장·재조회 중에도 로컬 키 기준으로 명확히 추적한다. const [selectedRowKey, setSelectedRowKey] = useState(null); // 조회·삭제 참조 확인·저장 요청 중 화면의 중복 조작을 막는다. @@ -255,6 +260,7 @@ export function TrtdstManagementPage() { } setCtpvCd("0"); setSggCd("0"); + setSort(undefined); if (await fetchRows(EMPTY_APPLIED_REGION)) { setAppliedRegion(EMPTY_APPLIED_REGION); } @@ -275,6 +281,8 @@ export function TrtdstManagementPage() { return; } setEditingRowKey(selectedRow.localKey); + setSort(undefined); + setPagination((current) => ({ ...current, pageIndex: 0 })); }; /** 선택한 행정구역으로 빈 신규 행을 만들고 처리분구명은 그리드에서 직접 입력받는다. */ @@ -289,6 +297,8 @@ export function TrtdstManagementPage() { } const localKey = createLocalKey(); + setSort(undefined); + setPagination((current) => ({ ...current, pageIndex: 0 })); setRows((current) => withDescendingRowNumbers([ ...current, @@ -464,13 +474,30 @@ export function TrtdstManagementPage() { } }; + const sortedRows = useMemo( + () => sortStandardTableRows(rows, sort), + [rows, sort], + ); + /** 인라인 편집과 변경 상태 표시를 유지한 채 공통 DataTable 열로 변환한다. */ const columns = useMemo[]>( () => TRTDST_TABLE_HEAD.map((head) => ({ id: head.id, accessorKey: head.id, - header: head.title, + header: () => ( + { + setSort(nextSort); + setPagination((current) => ({ ...current, pageIndex: 0 })); + setSelectedRowKey(null); + setEditingRowKey(null); + }} + /> + ), cell: ({ row, getValue }) => { const item = row.original; const isNameEditable = @@ -505,11 +532,11 @@ export function TrtdstManagementPage() { }, meta: { width: head.width, align: "center" as const }, })), - [editingRowKey], + [editingRowKey, sort], ); const table = useReactTable({ - data: rows, + data: sortedRows, columns, state: { pagination, diff --git a/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts b/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts index c073867ae..976665e7f 100644 --- a/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts +++ b/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts @@ -11,6 +11,7 @@ import { postRawJson, putData, } from "@/components/lib/api"; +import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; const API_PATH = "v1/obsltPplBiz"; const EXCEL_DOWNLOAD_PAGE_SIZE = 500; @@ -24,30 +25,35 @@ const unwrapData = (body: unknown): T => { }; /** 목록 검색조건과 페이지 번호로 활성 사업 목록을 조회한다. */ -export const getObsltPplBusinesses = async (params: URLSearchParams): Promise => +export const getObsltPplBusinesses = async ( + params: URLSearchParams, +): Promise => getData(`${API_PATH}?${params.toString()}`) as Promise; /** 적용된 조회조건을 유지하며 모든 페이지를 순회해 Excel 다운로드 대상 전체를 모은다. */ -export const getAllObsltPplBusinesses = async (searchParams: URLSearchParams): Promise => { - const rows: ObsltPplBusiness[] = []; - let page = 0; - let totalPages = 1; - - while (page < totalPages) { - const params = new URLSearchParams(searchParams); - params.set("page", String(page)); - params.set("size", String(EXCEL_DOWNLOAD_PAGE_SIZE)); - params.set("sort", "obsltSwagePplBizSn,asc"); - const result = await getObsltPplBusinesses(params); - rows.push(...result.content); - totalPages = result.totalPages; - page += 1; - } - return rows; +export const getAllObsltPplBusinesses = async ( + searchParams: URLSearchParams, + sort = "obsltSwagePplBizSn,desc", +): Promise => { + const result = await fetchAllPages({ + batchSize: EXCEL_DOWNLOAD_PAGE_SIZE, + fetchPage: (page, size) => { + const params = new URLSearchParams(searchParams); + params.set("page", String(page)); + params.set("size", String(size)); + params.set("sort", sort); + return getObsltPplBusinesses(params); + }, + getItems: (page) => page.content ?? [], + getTotal: (page) => Number(page.totalElements ?? 0), + }); + return result.items; }; /** 수정 화면에서 사업 상세 한 건을 조회한다. */ -export const getObsltPplBusiness = async (id: string): Promise => +export const getObsltPplBusiness = async ( + id: string, +): Promise => getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise; /** 등록 화면의 입력값으로 사업 한 건을 생성한다. */ @@ -61,7 +67,10 @@ export const updateObsltPplBusiness = async ( id: string, request: ObsltPplBusinessSaveRequest, ): Promise => - putData(`${API_PATH}/${encodeURIComponent(id)}`, request) as Promise; + putData( + `${API_PATH}/${encodeURIComponent(id)}`, + request, + ) as Promise; /** 수정 화면에서 선택 사업을 논리삭제한다. */ export const deleteObsltPplBusiness = async (id: string): Promise => { @@ -72,4 +81,6 @@ export const deleteObsltPplBusiness = async (id: string): Promise => { export const uploadObsltPplExcel = async ( rows: ObsltPplBusinessSaveRequest[], ): Promise => - unwrapData(await postRawJson(`${API_PATH}/excel/upload`, rows)); + unwrapData( + await postRawJson(`${API_PATH}/excel/upload`, rows), + ); diff --git a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts index c4e7da426..62ff0630b 100644 --- a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts +++ b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts @@ -1,4 +1,5 @@ import * as XLSX from "xlsx"; +import XlsxPopulate from "xlsx-populate/browser/xlsx-populate"; import { formatDate } from "../../common/util"; import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types"; @@ -35,6 +36,13 @@ const FIRST_DATA_COLUMN_INDEX = 1; const DATA_COLUMN_COUNT = 21; const DOWNLOAD_FILENAME = "노후하수관로 정밀조사 및 정비사업 현황.xlsx"; +/** API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. */ +const excelTextValue = (value: unknown) => + String(value ?? "") + .replace(/_x000d_/gi, "") + .replace(/\r\n?/g, "\n") + .trim(); + /** 줄바꿈과 공백이 포함된 병합 헤더를 비교 가능한 문자열로 정규화한다. */ const normalizeHeader = (value: string) => value.replace(/\s+/g, "").trim(); @@ -179,14 +187,16 @@ export const parseObsltPplExcel = async (file: File): Promise { const rows = Array.from({ length: DATA_START_ROW_INDEX }, () => Array(22).fill("")); const topHeaders = [ - "순번", "정밀조사차수", contract.businessCodeHeader, "시도", "시군", "환경청", "사업명", - "추진현황", "착수년도", "준공(예정)년도", contract.extensionHeader, "", "", "", - contract.progressHeader, "", "사업추진부진사유", "사업담당자(지자체)", "", "비고", "", + "순번", "정밀\n조사\n차수", contract.businessCodeHeader.replace("사업코드", "\n사업코드"), + "시도", "시군", "환경청", "사업명", "추진현황", "착수\n년도", "준공(예정)\n년도", + contract.extensionHeader, "", "", "", contract.progressHeader.replace("추진현황", " 추진현황"), + "", "사업추진 부진 사유", "사업 담당자(지자체)", "", "비고", "", ]; const bottomHeaders = [ "", "", "", "", "", "", "", "", "", "", "계획연장(km)", "상반기(예상)연장(km)", - "잔여연장(km)", "증빙서류제출여부", contract.startDateHeader, "준공(예정)일자(yyyy.mm.dd)", - "", "이름", "연락처", "", "공단의견", + "잔여연장(km)", "증빙서류\n제출여부", contract.startDateHeader.replace("(yyyy.mm.dd)", "\n(yyyy.mm.dd)"), + "준공(예정)일자\n(yyyy.mm.dd)", "", "이름", "연락처", + "(필요시 사업 담당자 추가 작성)", "공단의견", ]; topHeaders.forEach((value, index) => { rows[5]![index + FIRST_DATA_COLUMN_INDEX] = value; }); bottomHeaders.forEach((value, index) => { rows[6]![index + FIRST_DATA_COLUMN_INDEX] = value; }); @@ -197,26 +207,26 @@ const downloadHeaderRows = (contract: SheetContract) => { const downloadDataRow = (row: ObsltPplBusiness, index: number): Array => [ "", index + 1, - row.prcisExmnCycl ?? "", - row.bizCd ?? "", - row.ctpvNm ?? "", - row.sigunNm ?? "", - row.envoNm ?? "", - row.bizNm ?? "", - row.prplPrst ?? "", - row.bgngYr ?? "", - row.cmcnYr ?? "", + excelTextValue(row.prcisExmnCycl), + excelTextValue(row.bizCd), + excelTextValue(row.ctpvNm), + excelTextValue(row.sigunNm), + excelTextValue(row.envoNm), + excelTextValue(row.bizNm), + excelTextValue(row.prplPrst), + excelTextValue(row.bgngYr), + excelTextValue(row.cmcnYr), row.planPrlg ?? "", row.frhfyrExpcPrlg ?? "", row.rmndPrlg ?? "", - row.evdncDcmntSbmsnStts ?? "", + excelTextValue(row.evdncDcmntSbmsnStts), formatDate(row.bgngYmd), formatDate(row.cmcnYmd), - row.bizPrplDlyRsnCn ?? "", - row.bizPicNm ?? "", - row.bizPicCnpl ?? "", - row.rmrkCn ?? "", - row.pbcpOpnnCn ?? "", + excelTextValue(row.bizPrplDlyRsnCn), + excelTextValue(row.bizPicNm), + excelTextValue(row.bizPicCnpl), + excelTextValue(row.rmrkCn), + excelTextValue(row.pbcpOpnnCn), ]; /** 조회 결과를 정밀조사·정비사업 두 시트의 재업로드 가능한 위치 구조로 생성한다. */ @@ -224,17 +234,123 @@ export const buildObsltPplWorkbook = (businesses: ObsltPplBusiness[]): XLSX.Work const workbook = XLSX.utils.book_new(); SHEET_CONTRACTS.forEach((contract) => { const data = businesses.filter((row) => row.bizSe === contract.sheetName); + const headerRows = downloadHeaderRows(contract); + headerRows[1]![2] = + contract.sheetName === "정밀조사" + ? "노후하수관로 정밀조사(2차) 사업추진 현황" + : "노후하수관로 정비사업 사업추진 현황"; + headerRows[4]![1] = "조회 건수"; + headerRows[4]![4] = `${data.length.toLocaleString("ko-KR")}건`; + headerRows[4]![7] = `${contract.extensionHeader} 합계`; + headerRows[4]![11] = + Math.round( + data.reduce((sum, row) => sum + (row.planPrlg ?? 0), 0) * 1000, + ) / 1000; const worksheet = XLSX.utils.aoa_to_sheet([ - ...downloadHeaderRows(contract), + ...headerRows, ...data.map(downloadDataRow), ]); - worksheet["!cols"] = Array.from({ length: 22 }, (_, index) => ({ wch: index === 7 ? 30 : 16 })); + worksheet["!merges"] = [ + { s: { r: 1, c: 2 }, e: { r: 2, c: 10 } }, + { s: { r: 4, c: 1 }, e: { r: 4, c: 3 } }, + { s: { r: 4, c: 4 }, e: { r: 4, c: 6 } }, + { s: { r: 4, c: 7 }, e: { r: 4, c: 10 } }, + { s: { r: 4, c: 11 }, e: { r: 4, c: 13 } }, + ...[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 17].map((column) => ({ + s: { r: 5, c: column }, + e: { r: 6, c: column }, + })), + { s: { r: 5, c: 11 }, e: { r: 5, c: 13 } }, + { s: { r: 5, c: 15 }, e: { r: 5, c: 16 } }, + { s: { r: 5, c: 18 }, e: { r: 5, c: 19 } }, + ]; + const widths = [ + 3, 7, 9, 16, 12, 12, 16, 34, 10, 9, 11, 13, 16, 13, 11, 16, 16, + 34, 11, 16, 34, 24, + ]; + worksheet["!cols"] = widths.map((wch) => ({ wch })); XLSX.utils.book_append_sheet(workbook, worksheet, contract.sheetName); }); return workbook; }; -/** 생성한 두 시트 workbook을 브라우저 다운로드로 저장한다. */ -export const downloadObsltPplExcel = (businesses: ObsltPplBusiness[]) => { - XLSX.writeFileXLSX(buildObsltPplWorkbook(businesses), DOWNLOAD_FILENAME); +/** 확정 원본의 제목·병합 헤더·구분색과 고정 본문 높이를 적용해 브라우저 다운로드를 시작한다. */ +export const downloadObsltPplExcel = async ( + businesses: ObsltPplBusiness[], +) => { + const workbook = buildObsltPplWorkbook(businesses); + const data = XLSX.write(workbook, { type: "array", bookType: "xlsx" }); + const styledWorkbook = await XlsxPopulate.fromDataAsync(data); + + SHEET_CONTRACTS.forEach((contract) => { + const rowCount = businesses.filter( + (business) => business.bizSe === contract.sheetName, + ).length; + const styledSheet = styledWorkbook.sheet(contract.sheetName); + styledSheet.range("C2:K3").style({ + bold: true, + fontFamily: "맑은 고딕", + fontSize: 16, + horizontalAlignment: "center", + verticalAlignment: "center", + border: true, + }); + styledSheet.range("B5:N5").style({ + bold: true, + fontFamily: "맑은 고딕", + fontSize: 10, + horizontalAlignment: "center", + verticalAlignment: "center", + fill: "FFF2CC", + border: true, + }); + styledSheet.range("B6:V7").style({ + bold: true, + fontFamily: "맑은 고딕", + fontSize: 10, + horizontalAlignment: "center", + verticalAlignment: "center", + wrapText: true, + border: true, + }); + styledSheet.range("B6:H7").style("fill", "B4C6E7"); + styledSheet.range("I6:Q7").style("fill", "FFF2CC"); + styledSheet.range("R6:V7").style("fill", "FCE4D6"); + styledSheet.row(2).height(26); + styledSheet.row(3).height(26); + styledSheet.row(5).height(22); + styledSheet.row(6).height(30); + styledSheet.row(7).height(34); + + if (rowCount > 0) { + const dataStartRow = DATA_START_ROW_INDEX + 1; + const dataEndRow = DATA_START_ROW_INDEX + rowCount; + styledSheet.range(`B${dataStartRow}:V${dataEndRow}`).style({ + fontFamily: "맑은 고딕", + fontSize: 10, + horizontalAlignment: "center", + verticalAlignment: "center", + wrapText: false, + border: true, + }); + styledSheet + .range(`L${dataStartRow}:N${dataEndRow}`) + .style("numberFormat", "#,##0.###"); + ["H", "R", "U", "V"].forEach((column) => + styledSheet + .range(`${column}${dataStartRow}:${column}${dataEndRow}`) + .style({ horizontalAlignment: "left", wrapText: true }), + ); + for (let row = dataStartRow; row <= dataEndRow; row += 1) + styledSheet.row(row).height(22); + } + }); + + const blob = await styledWorkbook.outputAsync(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = DOWNLOAD_FILENAME; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); }; diff --git a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx b/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx index 1f13d8c34..b6d7d5f90 100644 --- a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx +++ b/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx @@ -8,7 +8,9 @@ import { useRouter, useSearchParams } from "next/navigation"; import { StandardDataTable, + StandardTableSortHeader, getStandardRowNumber, + type UiTableSort, } from "@common/business_components"; import { Button, @@ -183,12 +185,32 @@ export function ObsltPplListPage() { const [search, setSearch] = useState(initialSearch); const [appliedSearch, setAppliedSearch] = useState(initialSearch); + const [sort, setSort] = useState(); /** 현재 선택한 시도에 속한 시군구만 선택 후보로 제공한다. */ const availableSgg = useMemo(() => { return buildObsltPplRepresentativeSggOptions(sgg, ctpvCd); }, [ctpvCd, sgg]); + /** 정렬 변경 시 동일 검색조건으로 서버 첫 페이지를 다시 조회한다. */ + const handleSortChange = useCallback( + (nextSort?: UiTableSort) => { + setSort(nextSort); + setPage(1); + const queryString = createListQueryString( + 1, + appliedSearch, + appliedCtpvCd, + appliedSggCd, + ); + router.replace( + `/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`, + { scroll: false }, + ); + }, + [appliedCtpvCd, appliedSearch, appliedSggCd, router], + ); + /** 공통 DataTable이 표시할 22개 업무 컬럼과 폭·정렬 규칙을 구성한다. */ const columns = useMemo[]>( () => @@ -196,7 +218,19 @@ export function ObsltPplListPage() { id: column.id, accessorFn: (row) => row[column.id], header: () => ( - {column.title} + {column.title} + } + sortKey={ + column.id === "rowNumber" + ? "obsltSwagePplBizSn" + : String(column.id) + } + sort={sort} + onSortChange={handleSortChange} + /> ), cell: ({ getValue }) => { const displayValue = formatTableValue(getValue()); @@ -214,7 +248,7 @@ export function ObsltPplListPage() { align: column.align ?? "center", }, })), - [], + [handleSortChange, sort], ); const pagination: PaginationState = { pageIndex: page - 1, @@ -258,11 +292,17 @@ export function ObsltPplListPage() { /** 확정 검색조건과 페이지를 서버 요청 파라미터로 변환해 목록을 갱신한다. */ const load = useCallback( - async (targetPage = page, targetSearch = appliedSearch) => { + async ( + targetPage = page, + targetSearch = appliedSearch, + nextSort: UiTableSort | undefined = sort, + ) => { const params = new URLSearchParams({ page: String(Math.max(targetPage - 1, 0)), size: String(DEFAULT_LIST_SIZE), - sort: "obsltSwagePplBizSn,desc", + sort: nextSort + ? `${nextSort.key},${nextSort.direction}` + : "obsltSwagePplBizSn,desc", }); Object.entries(targetSearch).forEach(([key, value]) => { if (value.trim()) params.set(key, value.trim()); @@ -295,7 +335,7 @@ export function ObsltPplListPage() { setLoading(false); } }, - [appliedSearch, page], + [appliedSearch, page, sort], ); useEffect(() => { @@ -368,8 +408,11 @@ export function ObsltPplListPage() { Object.entries(appliedSearch).forEach(([key, value]) => { if (value.trim()) params.set(key, value.trim()); }); - const exportRows = await getAllObsltPplBusinesses(params); - downloadObsltPplExcel(exportRows); + const exportRows = await getAllObsltPplBusinesses( + params, + sort ? `${sort.key},${sort.direction}` : undefined, + ); + await downloadObsltPplExcel(exportRows); } catch (error) { setNotice( error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다.", @@ -407,6 +450,7 @@ export function ObsltPplListPage() { setSggCd("0"); setAppliedCtpvCd("0"); setAppliedSggCd("0"); + setSort(undefined); setNotice(""); setPage(1); router.replace("/oper/obsltPplBiz?bizSe=정밀조사", { scroll: false }); diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index 454b24431..237206abe 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -5,27 +5,27 @@ export const windowPopupDefinitions = { key: "cfmtnBgt", title: "최종확정예산사업", width: 1400, - height: 720, + height: 900, requiredParams: ["bizSe"], }, exmnBusinessRegister: { key: "exmnBusinessRegister", title: "조사사업 등록", width: 1000, - height: 620, + height: 720, }, exmnResultRegister: { key: "exmnResultRegister", title: "조사 결과 업로드", - width: 1000, - height: 650, + width: 1100, + height: 760, requiredParams: ["exmnBizId"], }, exmnSurveyDetail: { key: "exmnSurveyDetail", title: "조사 상세현황", width: 1500, - height: 650, + height: 820, requiredParams: ["exmnBizId", "trtdstId", "trtdstNm", "jobType"], }, dredgingRegister: { @@ -51,8 +51,8 @@ export const windowPopupDefinitions = { oldPpUpload: { key: "oldPpUpload", title: "노후 하수관로 정비사업 등록", - width: 860, - height: 360, + width: 1000, + height: 420, }, oldPpTable: { key: "oldPpTable", @@ -64,8 +64,8 @@ export const windowPopupDefinitions = { floodPreUpload: { key: "floodPreUpload", title: "도시침수예방사업 등록", - width: 860, - height: 360, + width: 1000, + height: 420, }, floodPreTable: { key: "floodPreTable", @@ -138,30 +138,30 @@ export const windowPopupDefinitions = { agedPipePopup: { key: "agedPipePopup", title: "노후 하수관로 목록", - width: 850, - height: 600, + width: 1160, + height: 820, placement: "browser-left", alignTop: true, offsetLeft: 0, - offsetTop: 200, + offsetTop: 70, }, sinkholePopup: { key: "sinkholePopup", title: "지반침하발생현황", width: 1380, - height: 820, + height: 960, }, sbmrgDngrMapPopup: { key: "sbmrgDngrMapPopup", title: "침수위험지도 목록", width: 1160, - height: 720, + height: 820, }, floodDngrMapPopup: { key: "floodDngrMapPopup", title: "도시침수지도 목록", width: 1160, - height: 720, + height: 820, }, pipeAnalysisPopup: { key: "pipeAnalysisPopup", @@ -172,14 +172,14 @@ export const windowPopupDefinitions = { pipeAnalysisDetailPopup: { key: "pipeAnalysisDetailPopup", title: "관로현황", - width: 1180, - height: 820, + width: 1280, + height: 900, }, repairHistoryPopup: { key: "repairHistoryPopup", title: "보수이력", width: 1260, - height: 760, + height: 820, requiredParams: ["zoneId"], }, fldngPredcPrst: { @@ -192,7 +192,7 @@ export const windowPopupDefinitions = { key: "agedPipeStatusByCtpvPopup", title: "시도별 노후관로현황", width: 1160, - height: 790, + height: 820, }, fldngHstryPopup: { key: "fldngHstryPopup", diff --git a/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx b/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx index f5e613879..ace929129 100644 --- a/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx +++ b/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx @@ -13,6 +13,7 @@ import { getStandardRowNumber, type ScrollMode, StandardDataTable, + StandardTableSortHeader, type UiHeadProps, } from "@common/business_components"; import type { UiTableSort } from "@common/business_components/ui/table"; @@ -95,27 +96,13 @@ export default function MapPopupDataTable>({ const title = ROW_NUMBER_IDS.has(head.id) ? "순번" : head.title; if (!head.sortable || !onSortChange) return title; const key = head.sortKey ?? head.id; - const direction = sort?.key === key ? sort.direction : undefined; return ( - + ); }, cell: ({ row, getValue }) => { diff --git a/apps/sn/src/features/windowPopup/ui/PopupSearchPanel.tsx b/apps/sn/src/features/windowPopup/ui/PopupSearchPanel.tsx index e7d4f871a..8ccabac19 100644 --- a/apps/sn/src/features/windowPopup/ui/PopupSearchPanel.tsx +++ b/apps/sn/src/features/windowPopup/ui/PopupSearchPanel.tsx @@ -10,6 +10,18 @@ type PopupSearchPanelProps = { contentClassName?: string; }; +type PopupSearchFieldProps = { + label: ReactNode; + children: ReactNode; + className?: string; + labelClassName?: string; +}; + +type PopupSearchActionsProps = { + children: ReactNode; + className?: string; +}; + /** WindowPopup 조회조건의 외곽 카드, 패딩과 가로 스크롤 규칙을 한 곳에서 관리한다. */ export default function PopupSearchPanel({ children, @@ -24,9 +36,59 @@ export default function PopupSearchPanel({ className, )} > -
{children}
+
+ {children} +
); } -export type { PopupSearchPanelProps }; +/** 조회조건 라벨과 입력 컨트롤을 같은 행에 배치한다. */ +export function PopupSearchField({ + label, + children, + className, + labelClassName, +}: PopupSearchFieldProps) { + return ( + + ); +} + +/** 조회·초기화 버튼을 검색조건의 우측 끝에 같은 간격으로 배치한다. */ +export function PopupSearchActions({ + children, + className, +}: PopupSearchActionsProps) { + return ( +
+ {children} +
+ ); +} + +export type { + PopupSearchActionsProps, + PopupSearchFieldProps, + PopupSearchPanelProps, +}; diff --git a/apps/sn/src/shared/lib/download-excel.ts b/apps/sn/src/shared/lib/download-excel.ts index 659bc06d4..63ebf0a5a 100644 --- a/apps/sn/src/shared/lib/download-excel.ts +++ b/apps/sn/src/shared/lib/download-excel.ts @@ -8,6 +8,7 @@ type ExcelDownloadWithHeaderProps = { }; type ExcelRow = Record; +const EXCEL_DATA_ROWS_PER_SHEET = 500_000; // 엑셀 시트명 제한 문자와 최대 길이를 정리해 다운로드 시 런타임 오류를 방지한다. const normalizeSheetName = (sheetName: string | undefined) => { @@ -15,6 +16,18 @@ const normalizeSheetName = (sheetName: string | undefined) => { return normalized.slice(0, 31) || "Sheet1"; }; +/** 다중 시트일 때 접미사가 잘리지 않도록 시트명 길이를 함께 조정한다. */ +const buildSheetName = ( + baseName: string | undefined, + sheetIndex: number, + sheetCount: number, +) => { + const normalizedBase = normalizeSheetName(baseName); + if (sheetCount === 1) return normalizedBase; + const suffix = `_${sheetIndex + 1}`; + return `${normalizedBase.slice(0, 31 - suffix.length)}${suffix}`; +}; + // 한글을 두 글자 폭으로 계산해 내려받은 파일의 기본 열 너비를 읽기 좋게 맞춘다. const getDisplayWidth = (value: unknown) => Array.from(String(value ?? "")).reduce( @@ -38,7 +51,9 @@ export const downloadExcelWithKoreanHeaders = async ({ throw new Error("엑셀 다운로드는 브라우저에서만 사용할 수 있습니다."); } - const dataRows = (Array.isArray(data) ? data : [data]) as unknown as ExcelRow[]; + const dataRows = (Array.isArray(data) + ? data + : [data]) as unknown as ExcelRow[]; if (dataRows.length === 0) { throw new Error("데이터가 없습니다."); } @@ -49,71 +64,84 @@ export const downloadExcelWithKoreanHeaders = async ({ const koreanHeaders = englishHeaders.map( (header) => headerMapping[header] || header, ); - const worksheetRows = [ - koreanHeaders, - ...dataRows.map((row) => englishHeaders.map((header) => row[header])), - ]; - const worksheet = XLSX.utils.aoa_to_sheet(worksheetRows); - - // SheetJS가 지원하는 범위에서는 기존 공통 다운로드와 유사한 헤더 스타일을 적용한다. const decodedHeaderRange = XLSX.utils.decode_range(headerRange); - for ( - let rowIndex = decodedHeaderRange.s.r; - rowIndex <= decodedHeaderRange.e.r; - rowIndex += 1 - ) { + const workbook = XLSX.utils.book_new(); + const sheetCount = Math.max( + 1, + Math.ceil(dataRows.length / EXCEL_DATA_ROWS_PER_SHEET), + ); + + for (let sheetIndex = 0; sheetIndex < sheetCount; sheetIndex += 1) { + const sheetRows = dataRows.slice( + sheetIndex * EXCEL_DATA_ROWS_PER_SHEET, + (sheetIndex + 1) * EXCEL_DATA_ROWS_PER_SHEET, + ); + const worksheetRows = [ + koreanHeaders, + ...sheetRows.map((row) => englishHeaders.map((header) => row[header])), + ]; + const worksheet = XLSX.utils.aoa_to_sheet(worksheetRows); + + // SheetJS가 지원하는 범위에서는 기존 공통 다운로드와 유사한 헤더 스타일을 적용한다. for ( - let columnIndex = decodedHeaderRange.s.c; - columnIndex <= decodedHeaderRange.e.c; - columnIndex += 1 + let rowIndex = decodedHeaderRange.s.r; + rowIndex <= decodedHeaderRange.e.r; + rowIndex += 1 ) { - const cellAddress = XLSX.utils.encode_cell({ - r: rowIndex, - c: columnIndex, - }); - const headerCell = worksheet[cellAddress] as - | (typeof worksheet[string] & { s?: Record }) - | undefined; - if (headerCell) { - headerCell.s = { - fill: { patternType: "solid", fgColor: { rgb: "9BC1E7" } }, - font: { bold: true, sz: 13 }, - alignment: { - horizontal: "center", - vertical: "center", - wrapText: true, - }, - border: { - top: { style: "thin", color: { rgb: "FFFFFF" } }, - bottom: { style: "thin", color: { rgb: "FFFFFF" } }, - left: { style: "thin", color: { rgb: "FFFFFF" } }, - right: { style: "thin", color: { rgb: "FFFFFF" } }, - }, - }; + for ( + let columnIndex = decodedHeaderRange.s.c; + columnIndex <= decodedHeaderRange.e.c; + columnIndex += 1 + ) { + const cellAddress = XLSX.utils.encode_cell({ + r: rowIndex, + c: columnIndex, + }); + const headerCell = worksheet[cellAddress] as + | ((typeof worksheet)[string] & { s?: Record }) + | undefined; + if (headerCell) { + headerCell.s = { + fill: { patternType: "solid", fgColor: { rgb: "9BC1E7" } }, + font: { bold: true, sz: 13 }, + alignment: { + horizontal: "center", + vertical: "center", + wrapText: true, + }, + border: { + top: { style: "thin", color: { rgb: "FFFFFF" } }, + bottom: { style: "thin", color: { rgb: "FFFFFF" } }, + left: { style: "thin", color: { rgb: "FFFFFF" } }, + right: { style: "thin", color: { rgb: "FFFFFF" } }, + }, + }; + } } } + + worksheet["!autofilter"] = { ref: headerRange }; + if (sheetCount === 1) { + worksheet["!merges"] = mergeRanges.map((range) => + XLSX.utils.decode_range(range), + ); + } + worksheet["!cols"] = englishHeaders.map((header, columnIndex) => { + const sampledValues = sheetRows + .slice(0, 200) + .map((row) => row[header]) + .concat(koreanHeaders[columnIndex]); + const width = Math.max(...sampledValues.map(getDisplayWidth), 10); + return { wch: Math.min(width + 2, 40) }; + }); + + XLSX.utils.book_append_sheet( + workbook, + worksheet, + buildSheetName(sheetNameArray[0], sheetIndex, sheetCount), + ); } - worksheet["!autofilter"] = { ref: headerRange }; - worksheet["!merges"] = mergeRanges.map((range) => - XLSX.utils.decode_range(range), - ); - worksheet["!cols"] = englishHeaders.map((header, columnIndex) => { - const sampledValues = dataRows - .slice(0, 200) - .map((row) => row[header]) - .concat(koreanHeaders[columnIndex]); - const width = Math.max(...sampledValues.map(getDisplayWidth), 10); - return { wch: Math.min(width + 2, 40) }; - }); - - const workbook = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet( - workbook, - worksheet, - normalizeSheetName(sheetNameArray[0]), - ); - const normalizedFileName = fileName.toLowerCase().endsWith(".xlsx") ? fileName : `${fileName}.xlsx`; diff --git a/apps/sn/src/shared/lib/fetch-all-pages.ts b/apps/sn/src/shared/lib/fetch-all-pages.ts new file mode 100644 index 000000000..9cb5fb4e6 --- /dev/null +++ b/apps/sn/src/shared/lib/fetch-all-pages.ts @@ -0,0 +1,48 @@ +type FetchAllPagesOptions = { + fetchPage: (page: number, size: number) => Promise; + getItems: (response: TResponse) => TItem[]; + getTotal: (response: TResponse) => number; + batchSize?: number; +}; + +export type FetchAllPagesResult = { + items: TItem[]; + total: number; +}; + +const DEFAULT_BATCH_SIZE = 2000; +const MAX_BATCH_COUNT = 10000; + +/** + * 서버의 Pageable 최대 크기에 의존하지 않고 모든 페이지를 순회한다. + * 화면 전체건수와 Excel 데이터 행 수가 달라지는 문제를 공통으로 방지한다. + */ +export const fetchAllPages = async ({ + fetchPage, + getItems, + getTotal, + batchSize = DEFAULT_BATCH_SIZE, +}: FetchAllPagesOptions): Promise< + FetchAllPagesResult +> => { + const normalizedBatchSize = Math.max(1, Math.trunc(batchSize)); + const items: TItem[] = []; + let total = 0; + + for (let page = 0; page < MAX_BATCH_COUNT; page += 1) { + const response = await fetchPage(page, normalizedBatchSize); + const pageItems = getItems(response); + if (page === 0) total = Math.max(0, Number(getTotal(response) ?? 0)); + items.push(...pageItems); + + if (pageItems.length === 0 || items.length >= total) break; + } + + if (items.length !== total) { + throw new Error( + `전체 ${total.toLocaleString("ko-KR")}건 중 ${items.length.toLocaleString("ko-KR")}건만 조회되어 Excel 생성을 중단했습니다.`, + ); + } + + return { items, total }; +}; diff --git a/apps/sn/src/shared/ui/business-user-message-bridge.tsx b/apps/sn/src/shared/ui/business-user-message-bridge.tsx new file mode 100644 index 000000000..837170b33 --- /dev/null +++ b/apps/sn/src/shared/ui/business-user-message-bridge.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect } from "react"; + +import { setBusinessUserMessageHandler } from "@common/business_components"; + +import { useAlertStore } from "../lib/use-alert-store"; + +/** 공통 비즈니스 컴포넌트의 사용자 알림을 SN 공통 메시지 다이얼로그로 연결한다. */ +export function BusinessUserMessageBridge() { + const setMessage = useAlertStore((state) => state.setMessage); + + useEffect( + () => setBusinessUserMessageHandler((message) => setMessage(message)), + [setMessage], + ); + + return null; +} diff --git a/packages/common/business_components/src/index.ts b/packages/common/business_components/src/index.ts index 4aedf5936..1dfb4eaf2 100644 --- a/packages/common/business_components/src/index.ts +++ b/packages/common/business_components/src/index.ts @@ -1 +1,2 @@ export * from "./ui"; +export * from "./lib/user-message"; diff --git a/packages/common/business_components/src/lib/user-message.ts b/packages/common/business_components/src/lib/user-message.ts new file mode 100644 index 000000000..52e0ceb95 --- /dev/null +++ b/packages/common/business_components/src/lib/user-message.ts @@ -0,0 +1,27 @@ +export type BusinessUserMessageHandler = (message: string) => void; + +let userMessageHandler: BusinessUserMessageHandler | undefined; + +/** 사용하는 앱이 공통 비즈니스 컴포넌트의 사용자 메시지 표시 방식을 연결한다. */ +export const setBusinessUserMessageHandler = ( + handler: BusinessUserMessageHandler, +) => { + userMessageHandler = handler; + + return () => { + if (userMessageHandler === handler) userMessageHandler = undefined; + }; +}; + +/** 앱 핸들러가 연결되면 공통 UI를 사용하고, 미연결 앱은 기존 브라우저 알림을 유지한다. */ +export const showBusinessUserMessage = (message: unknown) => { + const normalizedMessage = String(message ?? "").trim(); + if (!normalizedMessage) return; + + if (userMessageHandler) { + userMessageHandler(normalizedMessage); + return; + } + + if (typeof window !== "undefined") window.alert(normalizedMessage); +}; diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 1c154da7e..36e5635e4 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -60,6 +60,8 @@ function Gis({ inlineLayerSettingView, treeSectionClassName = "", layerIconVisible, + // 목록·액션 아이콘이 지도 레이어 체크를 선행 조건으로 요구하는지 여부다. + layerIconRequiresChecked = true, isPlaying = false, setIsPlaying = () => { }, targetLayerTypes = [], @@ -314,6 +316,7 @@ function Gis({ }} imageVisible={imgVisble} layerIconVisible={layerIconVisible} + layerIconRequiresChecked={layerIconRequiresChecked} sinkholeLegendIconVisible={sinkholeLegendIconVisible} isPlaying={isPlaying} setIsPlaying={setIsPlaying} diff --git a/packages/common/business_components/src/ui/map/GisEdit.js b/packages/common/business_components/src/ui/map/GisEdit.js index dcf51545e..dab0c6453 100644 --- a/packages/common/business_components/src/ui/map/GisEdit.js +++ b/packages/common/business_components/src/ui/map/GisEdit.js @@ -2,6 +2,7 @@ import GisApp from './lib/gis.core'; import React, { useContext, useEffect, useRef, useState } from 'react'; import { GisContext, setTree } from './Gis'; import { Button } from "@common/components"; +import { showBusinessUserMessage } from '../../lib/user-message'; import { gAlert, gConfirm } from './lib/util'; // import { fclApi } from '../../js/apis/fcl/fclApi'; @@ -39,12 +40,12 @@ export default React.memo(function GisEdit() { let add = !btnEvt.add; if (add) { if (!checkedLayerIds?.current?.includes(selectedLyrId.current)) { - alert('트리에서 레이어 체크 후 추가 가능합니다.'); + showBusinessUserMessage('트리에서 레이어 체크 후 추가 가능합니다.'); return; } const errMsg = editModule.setDraw(selectedLyrId.current); if (errMsg) { - alert(errMsg); + showBusinessUserMessage(errMsg); return; } } else { diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index ba0039ae6..7f7b01ceb 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -7,6 +7,7 @@ import { import { ChevronLeft, ChevronRight } from "lucide-react"; import React, { useEffect, useLayoutEffect, useState, useRef, useCallback } from "react"; +import { showBusinessUserMessage } from "../../lib/user-message"; import { useGisContext } from "./Gis"; import GisApp from "./lib/gis.core"; @@ -29,6 +30,7 @@ import GisApp from "./lib/gis.core"; * - onProgressChange(total, current): 애니메이션 진행상태 콜백 * - layerSettingView: (선택) 좌측 레이어 트리 하단에 표시할 설정 영역 * - layerIconVisible: (선택) 레이어 행 우측 액션 아이콘 노출 여부를 결정하는 함수 + * - layerIconRequiresChecked: (선택) 액션 실행 전에 레이어 체크를 요구할지 여부 * - detachedToggleButton: (선택) 접기/펼치기 버튼을 패널 밖 고정 핸들로 노출할지 여부 */ export default React.memo(function GisTree({ @@ -46,6 +48,7 @@ export default React.memo(function GisTree({ onProgressChange = () => { }, layerSettingView: LayerSettingView = null, layerIconVisible, + layerIconRequiresChecked = true, sinkholeLegendIconVisible = false, inlineLayerSettingView, treeSectionClassName = "", @@ -536,7 +539,7 @@ export default React.memo(function GisTree({ * 각 레이어 행의 아이콘 클릭 시 호출. 선택되지 않은 레이어면 경고, 선택된 경우 콜백 전달. */ const handleLayerImgClick = (nodeNm, nodeId, node) => { - if (!checked.includes(nodeId)) alert("시설을 선택해주세요"); + if (layerIconRequiresChecked && !checked.includes(nodeId)) showBusinessUserMessage("시설을 선택해주세요"); else onLayerIconClick(nodeNm, node); }; diff --git a/packages/common/business_components/src/ui/map/lib/gis.layer.js b/packages/common/business_components/src/ui/map/lib/gis.layer.js index 2745a5ff4..b30f1fec6 100644 --- a/packages/common/business_components/src/ui/map/lib/gis.layer.js +++ b/packages/common/business_components/src/ui/map/lib/gis.layer.js @@ -20,6 +20,7 @@ import GisOlStyle from './gis.style.js'; import Tile from 'ol/layer/Tile.js'; import TileWMS from 'ol/source/TileWMS.js'; import gisCore from './gis.core.js'; +import { showBusinessUserMessage } from '../../../lib/user-message'; import { buildFloodDngrSldBody, FLOOD_DNGR_MAP_LAYER_TYPE, @@ -673,7 +674,7 @@ GisOlLayer.prototype = { // 1) 로드 완료시 extent 맞추기 const onLoadEnd = () => { - if (Object.keys(source.nullGeometryFeatures_).length > 0) alert('해당 시설의 위치 정보가 없습니다.'); + if (Object.keys(source.nullGeometryFeatures_).length > 0) showBusinessUserMessage('해당 시설의 위치 정보가 없습니다.'); const feats = source.getFeatures(); if (feats && feats.length > 0) { diff --git a/packages/common/business_components/src/ui/map/lib/util.js b/packages/common/business_components/src/ui/map/lib/util.js index 7de8c2fc4..7bb76f7c1 100644 --- a/packages/common/business_components/src/ui/map/lib/util.js +++ b/packages/common/business_components/src/ui/map/lib/util.js @@ -1,3 +1,5 @@ +import { showBusinessUserMessage } from '../../../lib/user-message'; + //공통 export const util = { @@ -537,7 +539,7 @@ export const getMessage = (msgGroup, msgKey, innerText, fix) => { // global.js의 message를 읽어오는 alert export const gAlert = (msgGroup, msgKey, innerText, fix) => { - alert(getMessage(msgGroup, msgKey, innerText, fix)); + showBusinessUserMessage(getMessage(msgGroup, msgKey, innerText, fix)); }; // message를 confirm @@ -640,4 +642,4 @@ export const getFeatureLabelCoordinate = (feature) => { const y = coordinate[1]; return `${x.toFixed(p)}_${y.toFixed(p)}`; }; - } \ No newline at end of file + } diff --git a/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx b/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx index 12001a39a..bca6bf2f6 100644 --- a/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx +++ b/packages/common/business_components/src/ui/map/searchBar/procSearchBar.tsx @@ -2,6 +2,7 @@ import { SelectBox, type SelectDataType } from "../../../ui"; import { Button } from "@common/components/ui"; import { useCallback, useEffect, useState } from "react"; +import { showBusinessUserMessage } from "../../../lib/user-message"; import GisApp from "../lib/gis.core"; import { getSigunguCenter, @@ -369,7 +370,7 @@ export const ProcSearchBar = ({ const handleSearch = useCallback(() => { if (sidoValue === "0") { - alert("시도를 선택해 주세요."); + showBusinessUserMessage("시도를 선택해 주세요."); return; } diff --git a/packages/common/business_components/src/ui/map/searchBar/searchBar.tsx b/packages/common/business_components/src/ui/map/searchBar/searchBar.tsx index d4a9a25b6..6ac2c7fcc 100644 --- a/packages/common/business_components/src/ui/map/searchBar/searchBar.tsx +++ b/packages/common/business_components/src/ui/map/searchBar/searchBar.tsx @@ -2,6 +2,7 @@ import { SelectBox, type SelectDataType } from "../../../ui"; import { Button } from "@common/components/ui"; import { useCallback, useEffect, useState } from "react"; +import { showBusinessUserMessage } from "../../../lib/user-message"; import SearchInputText from "./SearchInputText"; import GisApp from "../lib/gis.core"; import { getSigunguCenter, moveMapToCenter, sidoCenter } from "./utils"; @@ -163,7 +164,7 @@ export const SearchBar = ({ const handleSearch = async () => { if (!fcltyNm) { if (sidoValue === "0") { - alert("시도를 선택해 주세요"); + showBusinessUserMessage("시도를 선택해 주세요"); return; } @@ -288,7 +289,7 @@ export const SearchBar = ({ } if (list.length === 0) { - alert("검색 결과가 없습니다."); + showBusinessUserMessage("검색 결과가 없습니다."); setSearchList([]); setFcltyNm(""); return; @@ -352,7 +353,7 @@ export const SearchBar = ({ nearest: true, }); } else { - alert("위치정보를 찾을 수 없습니다."); + showBusinessUserMessage("위치정보를 찾을 수 없습니다."); console.warn("geometry를 찾을 수 없습니다:", data); } } else { diff --git a/packages/common/business_components/src/ui/search_address/zipService.ts b/packages/common/business_components/src/ui/search_address/zipService.ts index 7cdba0992..f0008d300 100644 --- a/packages/common/business_components/src/ui/search_address/zipService.ts +++ b/packages/common/business_components/src/ui/search_address/zipService.ts @@ -1,9 +1,10 @@ +import { showBusinessUserMessage } from "../../lib/user-message"; + interface SortConfig { empty: boolean; sorted: boolean; unsorted: boolean; } - interface PageableConfig { sort: SortConfig; offset: number; @@ -138,7 +139,7 @@ export function searchFilter(search: string): boolean { const expText = /[%=><]/; if (expText.test(search)) { - alert("특수문자를 입력 할수 없습니다."); + showBusinessUserMessage("특수문자를 입력 할수 없습니다."); return false; } @@ -160,7 +161,7 @@ export function searchFilter(search: string): boolean { for (const sqlWord of sqlArray) { const regex = new RegExp(sqlWord, "gi"); if (regex.test(search)) { - alert(`"${sqlWord}"와(과) 같은 특정문자로 검색할 수 없습니다.`); + showBusinessUserMessage(`"${sqlWord}"와(과) 같은 특정문자로 검색할 수 없습니다.`); return false; } } diff --git a/packages/common/business_components/src/ui/search_input_address/findAddress.ts b/packages/common/business_components/src/ui/search_input_address/findAddress.ts index 33cd14d13..ee7e1b3a2 100644 --- a/packages/common/business_components/src/ui/search_input_address/findAddress.ts +++ b/packages/common/business_components/src/ui/search_input_address/findAddress.ts @@ -3,6 +3,7 @@ // const addresssKey = 'devU01TX0FVVEgyMDI1MDgyNTEwMzkwNDExNjEwNTM='; //인증키 만료일은 2025-11-23 import type { Zip, ZipSearchParams } from "./zipService"; +import { showBusinessUserMessage } from "../../lib/user-message"; /** * 주소 API 응답 타입 @@ -80,7 +81,6 @@ export const findAddress = async (searchQuery: ZipSearchParams): Promise => } - export const searchFilter = (search: string) => { @@ -88,7 +88,7 @@ export const searchFilter = (search: string) => { //특수문자 제거 const expText = /[%=><]/; if (expText.test(search) == true) { - alert("특수문자를 입력 할수 없습니다."); + showBusinessUserMessage("특수문자를 입력 할수 없습니다."); search = search.split(expText).join(""); return false; } @@ -105,7 +105,7 @@ export const searchFilter = (search: string) => { regex = new RegExp(sqlArray[i] as string, "gi"); if (regex.test(search)) { - alert("\"" + sqlArray[i] + "\"와(과) 같은 특정문자로 검색할 수 없습니다."); + showBusinessUserMessage("\"" + sqlArray[i] + "\"와(과) 같은 특정문자로 검색할 수 없습니다."); search = search.replace(regex, ""); return false; } diff --git a/packages/common/business_components/src/ui/standard_data_table/index.tsx b/packages/common/business_components/src/ui/standard_data_table/index.tsx index a6c1e0ab2..0753d8f23 100644 --- a/packages/common/business_components/src/ui/standard_data_table/index.tsx +++ b/packages/common/business_components/src/ui/standard_data_table/index.tsx @@ -8,6 +8,7 @@ import { useState, type CSSProperties, type ReactNode, + type UIEventHandler, } from "react"; import type { Table as TableInstance } from "@tanstack/react-table"; import { ChevronLeft, ChevronRight } from "lucide-react"; @@ -21,6 +22,10 @@ import { type ScrollMode = "none" | "horizontal" | "vertical" | "both"; +type StandardDataTableRenderContext = { + table: TableInstance; +}; + type StandardDataTableProps = { table: TableInstance; title?: ReactNode; @@ -38,6 +43,10 @@ type StandardDataTableProps = { flushOuterMargin?: boolean; /** 빈 목록일 때 부모의 남은 높이를 채워 팝업·페이지의 과도한 바깥 여백을 줄인다. */ fillAvailableHeight?: boolean; + /** 병합 셀·고정 열이 필요한 복합표가 공통 카드·로딩·스크롤 안에서 표 본문만 교체한다. */ + renderTable?: (context: StandardDataTableRenderContext) => ReactNode; + /** 고정 열 경계처럼 가로 스크롤 위치가 필요한 소비 화면에 공통 스크롤 이벤트를 전달한다. */ + onScroll?: UIEventHandler; onRowClick?: (row: T) => void; }; @@ -185,6 +194,8 @@ function StandardDataTable({ viewPagination = true, flushOuterMargin = false, fillAvailableHeight = false, + renderTable, + onScroll, onRowClick, }: StandardDataTableProps) { const rowCount = table.getRowModel().rows.length; @@ -272,6 +283,7 @@ function StandardDataTable({ >
({ )} > {loading ? ( - +
+ +
) : ( emptyMessage )}
) : ( - + (renderTable?.({ table }) ?? ( + + )) )} {viewPagination && !loading && rowCount > 0 ? ( @@ -313,4 +333,16 @@ function StandardDataTable({ } export { STANDARD_ROW_NUMBER_HEADER, StandardDataTable, getStandardRowNumber }; -export type { ScrollMode, StandardDataTableProps, StandardRowNumberParams }; +export type { + ScrollMode, + StandardDataTableProps, + StandardDataTableRenderContext, + StandardRowNumberParams, +}; +export { + StandardTableSortHeader, + compareStandardTableValues, + getNextStandardTableSort, + sortStandardTableRows, +} from "./sort"; +export type { StandardTableSortHeaderProps } from "./sort"; diff --git a/packages/common/business_components/src/ui/standard_data_table/sort.tsx b/packages/common/business_components/src/ui/standard_data_table/sort.tsx new file mode 100644 index 000000000..69687b06c --- /dev/null +++ b/packages/common/business_components/src/ui/standard_data_table/sort.tsx @@ -0,0 +1,145 @@ +"use client"; + +import type { ReactNode } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +import type { UiTableSort } from "../table/types"; + +const STANDARD_TABLE_SORT_COLLATOR = new Intl.Collator("ko-KR", { + numeric: true, + sensitivity: "base", +}); + +const isEmptySortValue = (value: unknown) => + value === null || + value === undefined || + String(value).trim() === "" || + String(value).trim() === "-"; + +const toComparableNumber = (value: unknown) => { + const normalized = String(value ?? "") + .trim() + .replace(/,/g, "") + .replace(/%$/, ""); + if (!/^-?\d+(?:\.\d+)?$/.test(normalized)) return undefined; + const number = Number(normalized); + return Number.isFinite(number) ? number : undefined; +}; + +/** 공통 표 헤더의 asc → desc → 기본 순환 상태를 계산한다. */ +export const getNextStandardTableSort = ( + sortKey: string, + currentSort?: UiTableSort, +): UiTableSort | undefined => { + if (currentSort?.key !== sortKey) { + return { key: sortKey, direction: "asc" }; + } + if (currentSort.direction === "asc") { + return { key: sortKey, direction: "desc" }; + } + return undefined; +}; + +/** 숫자·날짜·한글 표시값을 비교하고 빈 값은 방향과 무관하게 마지막에 둔다. */ +export const compareStandardTableValues = ( + leftValue: unknown, + rightValue: unknown, + direction: UiTableSort["direction"], +) => { + const leftEmpty = isEmptySortValue(leftValue); + const rightEmpty = isEmptySortValue(rightValue); + if (leftEmpty || rightEmpty) { + if (leftEmpty && rightEmpty) return 0; + return leftEmpty ? 1 : -1; + } + + const leftNumber = toComparableNumber(leftValue); + const rightNumber = toComparableNumber(rightValue); + const comparison = + leftNumber !== undefined && rightNumber !== undefined + ? leftNumber - rightNumber + : STANDARD_TABLE_SORT_COLLATOR.compare( + String(leftValue), + String(rightValue), + ); + return direction === "asc" ? comparison : -comparison; +}; + +/** 서버 페이징이 아닌 전체 보유 행을 원본 배열 변경 없이 안정 정렬한다. */ +export const sortStandardTableRows = ( + rows: T[], + sort?: UiTableSort, + resolveValue: (row: T, key: string) => unknown = (row, key) => + (row as Record)[key], +) => { + if (!sort) return rows; + return rows + .map((row, originalIndex) => ({ row, originalIndex })) + .sort((left, right) => { + const comparison = compareStandardTableValues( + resolveValue(left.row, sort.key), + resolveValue(right.row, sort.key), + sort.direction, + ); + return comparison || left.originalIndex - right.originalIndex; + }) + .map(({ row }) => row); +}; + +interface StandardTableSortHeaderProps { + label: string; + title?: ReactNode; + sortKey: string; + sort?: UiTableSort; + onSortChange: (sort: UiTableSort | undefined) => void; +} + +/** StandardDataTable 계열 화면이 동일한 아이콘·상태 전이를 사용하는 헤더 버튼이다. */ +export function StandardTableSortHeader({ + label, + title = label, + sortKey, + sort, + onSortChange, +}: StandardTableSortHeaderProps) { + const direction = sort?.key === sortKey ? sort.direction : undefined; + const nextSort = getNextStandardTableSort(sortKey, sort); + const nextAction = + nextSort?.direction === "asc" + ? "오름차순" + : nextSort?.direction === "desc" + ? "내림차순" + : "기본순서"; + + return ( + + ); +} + +export type { StandardTableSortHeaderProps };