diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx index 9a6174db1..9dda96f8f 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { useAlertStore } from "@/shared/lib"; import { validateElapsedYears, type AgedPipeFilters } from "./agedPipeUtils"; +import LayerSettingCard from "../layerSetting/LayerSettingCard"; type MaybePromise = T | Promise; @@ -11,6 +12,9 @@ type Props = { onFiltersChange: (filters: AgedPipeFilters) => void; onApply: (filters?: AgedPipeFilters) => MaybePromise; compact?: boolean; + enabled?: boolean; + onEnabledChange?: (enabled: boolean) => void; + onClose?: () => void; }; export default function AgedPipeLayerSettingPanel({ @@ -18,6 +22,9 @@ export default function AgedPipeLayerSettingPanel({ onFiltersChange, onApply, compact = false, + enabled = true, + onEnabledChange = () => {}, + onClose = () => {}, }: Props) { const alert = useAlertStore((state) => state.setMessage); const [isCollapsed, setIsCollapsed] = useState(false); @@ -47,16 +54,16 @@ export default function AgedPipeLayerSettingPanel({ }; const inputClass = - "h-[22px] border border-input bg-background px-[2px] text-center text-[1rem] leading-none text-foreground"; + "h-[20px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground"; const numberInputClass = inputClass + " w-[40px]"; const applyButtonClass = - "grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-primary text-[0.95rem] font-bold leading-none text-primary-foreground"; + "grid h-[16px] w-[16px] shrink-0 place-items-center rounded-full bg-primary text-[0.85rem] font-bold leading-none text-primary-foreground"; const indicator = ( ); const settingRow = ( -
+
{indicator}
@@ -96,9 +103,14 @@ export default function AgedPipeLayerSettingPanel({ if (compact) { return ( -
+ {settingRow} -
+ ); } 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/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index 582016955..fa6aa96c3 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -12,6 +12,8 @@ import { TriangleAlert, Waves, } from "lucide-react"; +import { getRawJson } from "@/components/lib/api"; +import { createPredictionAnalysisTimeRange } from "@/features/fldngPredcPrst/model/analysisTimeRange"; import { windowPopupDefinitions, type WindowPopupKey, @@ -120,6 +122,32 @@ export default function AnalysisPopupMenu({ const [activeKey, setActiveKey] = useState< WindowPopupKey | "infoSearch" | null >(initialActiveKey); + const [seriousPredictionCount, setSeriousPredictionCount] = useState(0); + + // 침수예측현황 팝업의 기본 분석시각 범위와 동일한 범위로 메뉴 건수를 조회한다. + useEffect(() => { + const range = createPredictionAnalysisTimeRange(); + const params = new URLSearchParams({ + analysisFrom: range.analysisFrom, + analysisTo: range.analysisTo, + }); + let cancelled = false; + void getRawJson( + `/sewerage-service/api/v1/fldng/predc/prst/count?${params.toString()}`, + ) + .then((response) => { + if (cancelled) return; + const payload = response?.data ?? response; + const count = Number(payload); + setSeriousPredictionCount(Number.isFinite(count) ? count : 0); + }) + .catch(() => { + if (!cancelled) setSeriousPredictionCount(0); + }); + return () => { + cancelled = true; + }; + }, []); useEffect(() => { if (!isGisEditActive && activeKey === "gisEditPopup") { @@ -171,6 +199,10 @@ export default function AnalysisPopupMenu({ item.key === "gisEditPopup" ? isGisEditActive : activeKey === item.key; + const label = + item.key === "fldngPredcPrst" + ? `침수예측현황(${seriousPredictionCount}건)` + : item.label; return ( ); })} diff --git a/apps/sn/app/(map-layout)/map/components/common/table.ts b/apps/sn/app/(map-layout)/map/components/common/table.ts index bd3a44dbe..b8ba17b4f 100644 --- a/apps/sn/app/(map-layout)/map/components/common/table.ts +++ b/apps/sn/app/(map-layout)/map/components/common/table.ts @@ -519,6 +519,15 @@ export const FldngWtlvMapCol2 = [ { id: 'rainTot', title: '총강우량(mm)' }, { id: 'rainStr', title: '강우강도(mm/hr)' }, ]; +export const FldngDpthMapCol = [ + { id: 'fldng_yr', title: '침수년도' }, + { id: 'ctpv_nm', title: '시도명' }, + { id: 'sgg_nm', title: '시군구명' }, + { id: 'fldng_dpth', title: '침수깊이(m)' }, + { id: 'fldng_area', title: '침수면적(㎡)' }, + // { id: 'shape_area', title: '형상면적(㎡)' }, + // { id: 'shape_len', title: '형상길이' }, +]; export const FldngWtlvMapExcelCol = { 'fldngYr': '침수년도', 'fldngDstrNm': '침수재해명', diff --git a/apps/sn/app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx index 8d0907f5a..a8e0a020f 100644 --- a/apps/sn/app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx @@ -1,11 +1,15 @@ "use client"; import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes"; +import LayerSettingCard from "../layerSetting/LayerSettingCard"; type Props = { jobType: ExmnMapJobType; filters: ExmnMapFilters; onFiltersChange: (filters: ExmnMapFilters) => void; + enabled?: boolean; + onEnabledChange?: (enabled: boolean) => void; + onClose?: () => void; }; const GRADE_OPTIONS = ["", "1", "2", "3", "4", "5"]; @@ -15,6 +19,9 @@ export default function ExmnLayerSettingPanel({ jobType, filters, onFiltersChange, + enabled = true, + onEnabledChange = () => {}, + onClose = () => {}, }: Props) { const updateFilter = (key: keyof ExmnMapFilters, value: string) => { onFiltersChange({ @@ -23,23 +30,25 @@ export default function ExmnLayerSettingPanel({ }); }; const selectClass = - "h-[24px] w-[54px] rounded-[4px] border border-input bg-background px-2 text-[1rem] text-foreground outline-none focus:border-primary"; - const labelClass = "shrink-0 text-[1.05rem] font-medium text-label"; + "h-[20px] w-[62px] rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground outline-none focus:border-primary"; + const labelClass = "shrink-0 text-[1.15rem] font-medium text-label"; + const title = jobType === "PPL" ? "관로조사" : "맨홀조사"; return ( -
-
- {/* 관로/맨홀 조사유형을 구분하는 지도 범례 의미색이므로 고정색을 유지한다. */} - - - {jobType === "PPL" ? "관로조사" : "맨홀조사"} - -
-
-
+ ); } diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryLayerSettingPanel.tsx b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryLayerSettingPanel.tsx new file mode 100644 index 000000000..0cf554a46 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/fldngHstry/FldngHstryLayerSettingPanel.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { SelectBox } from "@common/business_components/ui"; +import { + FLOOD_HISTORY_MIN_YEAR, + normalizeFloodHistoryYearRange, + type FloodHistoryYearRange, +} from "./fldngHstryTypes"; + +type Props = { + yearRange: FloodHistoryYearRange; + enabled: boolean; + onYearRangeChange: (yearRange: FloodHistoryYearRange) => void; + onEnabledChange: (enabled: boolean) => void; + onClose: () => void; +}; + +/** 침수흔적위선·심선에 공통 적용되는 즉시 반영 침수년도 설정 카드다. */ +export default function FldngHstryLayerSettingPanel({ + yearRange, + enabled, + onYearRangeChange, + onEnabledChange, + onClose, +}: Props) { + const normalizedRange = normalizeFloodHistoryYearRange(yearRange); + const currentYear = new Date().getFullYear(); + const yearOptions = Array.from( + { length: currentYear - FLOOD_HISTORY_MIN_YEAR + 1 }, + (_, index) => String(currentYear - index), + ); + const startYearOptions = yearOptions + .filter((year) => Number(year) <= Number(normalizedRange.endYear)) + .map((year) => ({ val: year, text: year })); + const endYearOptions = yearOptions + .filter((year) => Number(year) >= Number(normalizedRange.startYear)) + .map((year) => ({ val: year, text: year })); + const yearSelectClass = + "h-[20px] w-[62px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground"; + + const changeYear = (key: keyof FloodHistoryYearRange, value: string) => { + onYearRangeChange( + normalizeFloodHistoryYearRange({ + ...normalizedRange, + [key]: value, + }), + ); + }; + + return ( +
+
+ + 침수흔적도 + +
+ + +
+
+
+ + 침수년도 +
+
+ changeYear("startYear", String(value))} + selectContentClass="z-[101]" + labelClass="hidden" + /> +
+ + ~ + +
+ changeYear("endYear", String(value))} + selectContentClass="z-[101]" + labelClass="hidden" + /> +
+
+
+
+ ); +} 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 ee4789351..9191a4be5 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"; @@ -51,8 +52,7 @@ const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => { if (fid.startsWith(`${FLOOD_HISTORY_LAYER_TYPE}.`)) return fid; if (fid) return `${FLOOD_HISTORY_LAYER_TYPE}.${fid}`; - const mapFid = String(row.fldngMapFid ?? "").trim(); - return mapFid || ""; + return ""; }; const buildDownloadRows = (rows: FldngHstryTableRow[]) => @@ -82,8 +82,14 @@ const resolveRegionNames = ( sggOptions: Array<{ text: string; val: string | number }>, ): FldngHstryTableRow => ({ ...row, - ctpvNm: resolveOptionText(row.ctpvCd ?? "", ctpvOptions, 2) || row.ctpvNm, - sggNm: resolveOptionText(row.sggCd ?? "", sggOptions, 5) || row.sggNm, + ctpvNm: + resolveOptionText(row.ctpvCd ?? "", ctpvOptions, 2) || + row.ctpvNm || + String((row as any).ctpv_nm ?? row.ctpvCd ?? "").trim(), + sggNm: + resolveOptionText(row.sggCd ?? "", sggOptions, 5) || + row.sggNm || + String((row as any).sgg_nm ?? row.sggCd ?? "").trim(), }); export default function FldngHstryPopupPage() { @@ -116,6 +122,7 @@ export default function FldngHstryPopupPage() { const fldngYearOptions = useMemo(() => buildFldngHstryYearOptions(), []); + const sggOptions = useMemo( () => filters.ctpvCd === "0" @@ -147,8 +154,14 @@ export default function FldngHstryPopupPage() { const hydrated = attachFldngHstryGeometry(item); return { ...hydrated, - ctpvNm: resolveOptionText(hydrated.ctpvCd ?? "", ctpv as any, 2), - sggNm: resolveOptionText(hydrated.sggCd ?? "", sgg as any, 5), + ctpvNm: + resolveOptionText(hydrated.ctpvCd ?? "", ctpv as any, 2) || + String((hydrated as any).ctpvNm ?? (hydrated as any).ctpv_nm ?? "").trim() || + String(hydrated.ctpvCd ?? "").trim(), + sggNm: + resolveOptionText(hydrated.sggCd ?? "", sgg as any, 5) || + String((hydrated as any).sggNm ?? (hydrated as any).sgg_nm ?? "").trim() || + String(hydrated.sggCd ?? "").trim(), }; }); @@ -246,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", @@ -280,7 +295,7 @@ export default function FldngHstryPopupPage() { 시도 setFilters((current) => ({ @@ -345,6 +360,24 @@ export default function FldngHstryPopupPage() { + + + +
- {TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => { + {TOOL_BUTTONS.map(({ tool, icon: Icon }) => { const isActive = activeTool === tool; const isToolEnabled = canUseTools && allowedTools.includes(tool); + const label = operationLabels[tool]; if (tool === "VERTEX_EDIT") { return (
= { + ADD: "추가", + MOVE: "이동", + VERTEX_EDIT: "꼭지점편집", + DELETE: "삭제", +}; + +let cachedOperationLabels: Record | null = null; +let operationLabelsPromise: Promise> | null = null; + +const isGisEditTool = (value: string): value is GisEditTool => + value === "ADD" || + value === "MOVE" || + value === "VERTEX_EDIT" || + value === "DELETE"; + +/** 같은 브라우저 세션에서는 GIS 편집작업코드를 한 번만 조회해 툴바와 세션이 공유한다. */ +const loadGisEditOperationLabels = async () => { + if (cachedOperationLabels) return cachedOperationLabels; + + if (!operationLabelsPromise) { + operationLabelsPromise = getCommonCodes(GIS_EDIT_OPERATION_UP_CD) + .then((codes) => { + const labels = { ...DEFAULT_GIS_EDIT_OPERATION_LABELS }; + codes.forEach((code) => { + if (isGisEditTool(code.lwpoCd) && code.lwpoCdNm) { + labels[code.lwpoCd] = code.lwpoCdNm; + } + }); + cachedOperationLabels = labels; + return labels; + }) + .finally(() => { + operationLabelsPromise = null; + }); + } + + return operationLabelsPromise; +}; + +/** GIS 편집 도구 코드값은 유지하고 사용자 표시명만 CMS 공통코드로 교체한다. */ +export const useGisEditOperationLabels = () => { + const [operationLabels, setOperationLabels] = useState< + Record + >(cachedOperationLabels ?? DEFAULT_GIS_EDIT_OPERATION_LABELS); + + useEffect(() => { + let cancelled = false; + + loadGisEditOperationLabels() + .then((labels) => { + if (!cancelled) setOperationLabels(labels); + }) + .catch((error) => { + console.error("GIS 편집작업 공통코드 조회에 실패했습니다.", error); + }); + + return () => { + cancelled = true; + }; + }, []); + + return operationLabels; +}; diff --git a/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx index 96c4ff666..a4a224440 100644 --- a/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx @@ -31,6 +31,7 @@ import { useAlertStore } from "@/shared/lib"; import { useUserStore } from "@/shared/lib/use-user-store"; import GisUploadPreviewMap from "./GisUploadPreviewMap"; import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; +import { useGisManagementCommonCodes } from "./useGisManagementCommonCodes"; type UploadFacilityType = { facilityTypeCode: string; @@ -108,6 +109,8 @@ type DownloadJob = { retryAfterMillis?: number | null; }; +type DownloadJobStatusListener = (job: DownloadJob) => void; + const SCREEN_ID = "SN_MN010302"; const FACILITY_TYPE_CODE = "PPL"; const LAYER_ID = "tb_ms_swage_ppl"; @@ -212,12 +215,16 @@ const startDownloadJob = async (url: string): Promise => { return job; }; -const waitForDownloadJob = async (jobId: string): Promise => { +const waitForDownloadJob = async ( + jobId: string, + onStatus?: DownloadJobStatusListener, +): Promise => { const startedAt = Date.now(); while (Date.now() - startedAt < DOWNLOAD_JOB_MAX_WAIT_MS) { const job = (await getData( `v1/map/gisDownloadJobs/${encodeURIComponent(jobId)}`, )) as DownloadJob; + onStatus?.(job); if (job?.status === "READY") return job; if (job?.status === "FAILED") { throw new Error( @@ -283,6 +290,12 @@ const postRefreshMessageToOpener = (response: GisUploadResponse) => { export default function GisManagementPopupPage() { const alert = useAlertStore((state) => state.setMessage); const userInfo = useUserStore((state) => state.userInfo); + // GIS 관리 코드값은 기존 분기에 사용하고 CMS 공통코드명은 화면 안내에만 사용한다. + const { + uploadStatusLabels, + downloadStatusLabels, + facilityTypeLabels, + } = useGisManagementCommonCodes(); // 파일 input은 브라우저 보안 정책상 value 직접 제어가 제한돼 ref로 선택/초기화한다. const fileInputRef = useRef(null); @@ -344,6 +357,8 @@ export default function GisManagementPopupPage() { }, [mngInstOptions, selectedMngInstCd]); const allowedExtensionText = facility.allowedExtensions.join(", "); + const facilityTypeDisplayName = + facilityTypeLabels[FACILITY_TYPE_CODE] || facility.facilityTypeName; const latestDownloadId = downloadIdsByScope[selectedScopeKey] ?? ""; const isManagementSelected = Boolean(selectedMngInstCd); @@ -399,43 +414,67 @@ export default function GisManagementPopupPage() { const initialize = async () => { setIsLoading(true); + // 시설유형과 관리기관은 서로 독립 계약이므로 한쪽 실패가 다른 콤보박스까지 비우지 않게 분리한다. + const loadFacilityType = async () => { + try { + const facilityTypes = await getData( + `v1/map/gisUploadFacilityTypes?screenId=${SCREEN_ID}`, + ); + if (!mounted) return; + + const pplFacility = Array.isArray(facilityTypes) + ? facilityTypes.find( + (item) => item?.facilityTypeCode === FACILITY_TYPE_CODE, + ) + : null; + if (pplFacility) { + setFacility({ + ...DEFAULT_FACILITY, + ...pplFacility, + allowedExtensions: Array.isArray(pplFacility.allowedExtensions) + ? pplFacility.allowedExtensions + : DEFAULT_FACILITY.allowedExtensions, + }); + } + } catch (error) { + console.error("GIS 관리 시설유형 조회 실패:", error); + if (mounted) { + setNotice( + "GIS 시설정보를 불러오지 못했습니다. 기본 하수관로 설정으로 표시합니다.", + ); + } + } + }; + + // 관리기관 목록은 시설유형 조회 결과와 무관하게 즉시 화면 상태에 반영한다. + const loadManagementOptions = async () => { + try { + const managementOptions = await getData("v1/fcltppl/mngInsts"); + if (!mounted) return; + + const sortedOptions = Array.isArray(managementOptions) + ? [...managementOptions].sort((a, b) => + String(a?.lwrInstNm ?? a?.mngInstCd ?? "").localeCompare( + String(b?.lwrInstNm ?? b?.mngInstCd ?? ""), + "ko", + ), + ) + : []; + setMngInstOptions(sortedOptions); + if (sortedOptions.length === 0) { + setNotice("조회 가능한 관리기관이 없습니다."); + } + } catch (error) { + console.error("GIS 관리 관리기관 조회 실패:", error); + if (mounted) { + setMngInstOptions([]); + setNotice("관리기관 목록을 불러오지 못했습니다."); + } + } + }; + try { - const [facilityTypes, managementOptions] = await Promise.all([ - getData(`v1/map/gisUploadFacilityTypes?screenId=${SCREEN_ID}`), - getData("v1/fcltppl/mngInsts"), - ]); - - if (!mounted) return; - - const pplFacility = Array.isArray(facilityTypes) - ? facilityTypes.find( - (item) => item?.facilityTypeCode === FACILITY_TYPE_CODE, - ) - : null; - if (pplFacility) { - setFacility({ - ...DEFAULT_FACILITY, - ...pplFacility, - allowedExtensions: Array.isArray(pplFacility.allowedExtensions) - ? pplFacility.allowedExtensions - : DEFAULT_FACILITY.allowedExtensions, - }); - } - - const sortedOptions = Array.isArray(managementOptions) - ? [...managementOptions].sort((a, b) => - String(a?.lwrInstNm ?? a?.mngInstCd ?? "").localeCompare( - String(b?.lwrInstNm ?? b?.mngInstCd ?? ""), - "ko", - ), - ) - : []; - setMngInstOptions(sortedOptions); - } catch (error) { - console.error("GIS 관리 초기 데이터 조회 실패:", error); - if (mounted) { - setNotice("GIS 관리 정보를 불러오지 못했습니다."); - } + await Promise.all([loadFacilityType(), loadManagementOptions()]); } finally { if (mounted) setIsLoading(false); } @@ -519,8 +558,13 @@ export default function GisManagementPopupPage() { const job = await startDownloadJob( `v1/map/gisUploads/swagePpl/downloadJobs?${params.toString()}`, ); - setNotice(`${selectedScopeLabel} GeoPackage 파일을 생성하고 있습니다.`); - const readyJob = await waitForDownloadJob(job.jobId); + setNotice( + `${selectedScopeLabel} ${downloadStatusLabels[job.status] || "GeoPackage 파일을 생성하고 있습니다."}`, + ); + const readyJob = await waitForDownloadJob(job.jobId, (currentJob) => { + const statusLabel = downloadStatusLabels[currentJob.status]; + if (statusLabel) setNotice(`${selectedScopeLabel} ${statusLabel}`); + }); const response = await downloadGeoPackage( `v1/map/gisDownloadJobs/${encodeURIComponent(job.jobId)}/file`, ); @@ -590,7 +634,11 @@ export default function GisManagementPopupPage() { : [], ); setViewMode("validationErrors"); - setNotice(response.message ?? "GeoPackage 검증 오류가 있습니다."); + setNotice( + response.message ?? + uploadStatusLabels[response.status] ?? + "GeoPackage 검증 오류가 있습니다.", + ); return; } @@ -599,7 +647,11 @@ export default function GisManagementPopupPage() { resetSelectedFile(); setValidationErrors([]); setViewMode("history"); - setNotice(response.message ?? "GIS 업로드가 완료되었습니다."); + setNotice( + response.message ?? + uploadStatusLabels[response.status] ?? + "GIS 업로드가 완료되었습니다.", + ); } catch (error) { console.error("GIS 업로드 실패:", error); alert( @@ -623,8 +675,14 @@ export default function GisManagementPopupPage() { const job = await startDownloadJob( `v1/map/gisUploads/${encodeURIComponent(selectedHistory.uploadId)}/downloadJobs`, ); - setNotice("선택한 현황의 GeoPackage 파일을 생성하고 있습니다."); - const readyJob = await waitForDownloadJob(job.jobId); + setNotice( + downloadStatusLabels[job.status] || + "선택한 현황의 GeoPackage 파일을 생성하고 있습니다.", + ); + const readyJob = await waitForDownloadJob(job.jobId, (currentJob) => { + const statusLabel = downloadStatusLabels[currentJob.status]; + if (statusLabel) setNotice(statusLabel); + }); const response = await downloadGeoPackage( `v1/map/gisDownloadJobs/${encodeURIComponent(job.jobId)}/file`, ); @@ -736,7 +794,7 @@ export default function GisManagementPopupPage() {
+
+ {children ? ( +
{children}
+ ) : null} +
+ ); +} diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx index 761f38ff1..44a32f067 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx @@ -9,9 +9,11 @@ import { getData } from "@/components/lib/api"; import LoadingOverlay from "@/components/ui/LoadingBar"; import useStore from "@/store/useStore"; import { useAlertStore } from "@/shared/lib"; +import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { useUserStore } from "@/shared/lib/use-user-store"; import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; +import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table"; import { PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS, PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS, @@ -33,6 +35,7 @@ import { type DetailViewRow = ReturnType[number]; const PIPE_ANALYSIS_DETAIL_PAGE_SIZE = 10; +const PIPE_ANALYSIS_MAP_WINDOW_NAME = "sn-sewerage-map-root"; const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [ "rowNo", "ctpvNm", @@ -205,9 +208,20 @@ export default function PipeAnalysisDetailPopupPage() { void loadRows(page, appliedFilters); }; + /** 선택 관로를 지도에 표시하고 상세창의 최상위 하수관망도 부모창을 앞으로 가져온다. */ const handleRowClick = (row: DetailViewRow) => { const targetWindow = window.opener?.opener ?? window.opener; - targetWindow?.postMessage( + if (!targetWindow || targetWindow.closed) return; + + // Chrome가 다른 탭/창의 focus()만 무시하는 경우를 대비해 사용자 클릭 안에서 + // 기존 최상위 지도창을 고정 이름으로 다시 선택한 뒤 포커스를 요청한다. + targetWindow.name = targetWindow.name || PIPE_ANALYSIS_MAP_WINDOW_NAME; + const namedTargetWindow = + window.open("", targetWindow.name) ?? targetWindow; + window.opener?.blur(); + window.blur(); + namedTargetWindow.focus(); + targetWindow.postMessage( { type: PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE, row, @@ -224,12 +238,12 @@ export default function PipeAnalysisDetailPopupPage() { try { setIsLoading(true); - const response = await fetchRows( - 1, - appliedFilters, - Math.max(total, 1000), - ); - const exportRows = response.rows.map((row) => ({ + const response = await fetchAllPages({ + fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size), + getItems: (page) => page.rows, + getTotal: (page) => page.total, + }); + const exportRows = response.items.map((row) => ({ rowNo: row.rowNo, ctpvNm: row.ctpvNm, sggNm: row.sggNm, @@ -264,15 +278,9 @@ export default function PipeAnalysisDetailPopupPage() { const materialOptions = useMemo(() => { const options = [ { value: "0", label: "전체" }, - ...Array.from( - new Set( - rows - .map((row) => String(row.materialCode ?? "").trim()) - .filter((value) => value && value !== "0"), - ), - ).map((value) => ({ - value, - label: resolveMaterialName(value), + ...MS_SWAGE_PPL_CODES.map((item) => ({ + value: item.val.trim(), + label: item.text, })), ]; @@ -288,7 +296,7 @@ export default function PipeAnalysisDetailPopupPage() { } return options; - }, [baseMaterialCode, rows]); + }, [baseMaterialCode]); const titleSuffix = useMemo(() => { const locationParts: string[] = []; @@ -345,7 +353,7 @@ export default function PipeAnalysisDetailPopupPage() { 용도
@@ -367,7 +376,7 @@ export default function PipeAnalysisDetailPopupPage() { 관재질 setFilters((current) => ({ @@ -408,7 +417,7 @@ export default function PipeAnalysisDetailPopupPage() { 관경 종료 setFilters((current) => ({ diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx index a0df537f9..2e0791e92 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx @@ -119,6 +119,9 @@ export default function PipeAnalysisPopupPage() { const zones = await fetchPipeAnalysisZoneOptions({ ctpvCd: draftFilters.ctpvCd, sggCd: draftFilters.sggCd, + sggNm: sgg.find( + (option) => String(option.val).trim() === draftFilters.sggCd, + )?.text, scopeRegionCode: scope.regionCode, }); if (!mounted) return; @@ -133,7 +136,7 @@ export default function PipeAnalysisPopupPage() { return () => { mounted = false; }; - }, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode]); + }, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode, sgg]); const fetchSummary = useCallback( async (nextFilters: PipeAnalysisSummaryFilters) => { diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts index 8f292878d..dbce492b3 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts @@ -654,15 +654,23 @@ export const buildPipeAnalysisFileDate = () => export const fetchPipeAnalysisZoneOptions = async ({ ctpvCd, sggCd, + sggNm, scopeRegionCode, }: { ctpvCd: string; sggCd: string; + sggNm?: string; scopeRegionCode?: string; }): Promise => { const filters: string[] = []; + const selectedSggCd = String(sggCd ?? "").trim(); + const selectedSggNm = String(sggNm ?? "").trim(); if (sggCd && sggCd !== "0") { - filters.push(`sgg_cd='${sggCd}'`); + // 처리구역 원천의 시군구코드가 잘못된 경우도 있어 시도 범위로 받은 뒤 + // 기준코드 또는 시군구명 중 하나가 일치하는 처리구역만 남긴다. + const selectedCtpvCd = + ctpvCd && ctpvCd !== "0" ? ctpvCd : selectedSggCd.slice(0, 2); + filters.push(`ctpv_cd='${selectedCtpvCd}'`); } else if (ctpvCd && ctpvCd !== "0") { filters.push(`ctpv_cd='${ctpvCd}'`); } else if (scopeRegionCode) { @@ -679,7 +687,7 @@ export const fetchPipeAnalysisZoneOptions = async ({ request: "GetFeature", typeName: "hasudo:tb_ms_swage_prcs_zone", outputFormat: "application/json", - propertyName: "ctpv_cd,sgg_cd,swtrmt_zone_nm", + propertyName: "ctpv_cd,sgg_cd,sgg_nm,swtrmt_zone_nm", }); if (filters.length > 0) { @@ -698,12 +706,22 @@ export const fetchPipeAnalysisZoneOptions = async ({ features.forEach((feature: any) => { const properties = feature?.properties ?? {}; const zoneName = String(properties.swtrmt_zone_nm ?? "").trim(); + const featureSggCd = String(properties.sgg_cd ?? "").trim(); + const featureSggNm = String(properties.sgg_nm ?? "").trim(); if (!zoneName) return; + if ( + selectedSggCd && + selectedSggCd !== "0" && + featureSggCd !== selectedSggCd && + (!selectedSggNm || featureSggNm !== selectedSggNm) + ) { + return; + } dedupMap.set(zoneName, { value: zoneName, label: zoneName, ctpvCd: String(properties.ctpv_cd ?? "").trim(), - sggCd: String(properties.sgg_cd ?? "").trim(), + sggCd: selectedSggCd !== "0" ? selectedSggCd : featureSggCd, }); }); diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 9ac20b8db..96ff8edb2 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -25,6 +25,7 @@ import { getData, postData } from "@/components/lib/api"; import { VerticalTable } from "@/components/ui/verticalTable"; import { useGisContext } from "@common/business_components/ui/map/Gis"; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; import { Button } from "@common/components/ui"; import { useEffect, useRef, useState, useMemo } from "react"; import Style from "ol/style/Style.js"; @@ -35,6 +36,7 @@ import { getLayerContentComponent } from "../common"; import { FldngWtlvMapCol, FldngWtlvMapCol2, + FldngDpthMapCol, SwtrmtFcltCol, SwtrmtFcltCol2, SwtrmtPrcsZoneCol, @@ -177,7 +179,24 @@ const FeatureClickEventPopup = ({ const [chmbrYdnts, setChmbrYdnts] = useState(""); // 로딩 상태 const linkedFacilityFeatureRef = useRef(null); const linkedFacilityOriginalStyleRef = useRef(null); + const flowDirectionBasisRequestCacheRef = useRef( + new Map>(), + ); const [mngInstOptions, setMngInstOptions] = useState([]); + const [floodDepthRows, setFloodDepthRows] = useState< + Record[] + >([]); + const [floodWtlvRows, setFloodWtlvRows] = useState[]>( + [], + ); + const [selectedFloodDepthFid, setSelectedFloodDepthFid] = useState< + string | null + >(null); + const [selectedFloodWtlvFid, setSelectedFloodWtlvFid] = useState< + string | null + >(null); + const floodDepthYearListRef = useRef(null); + const floodWtlvListRef = useRef(null); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); // 🎯 관로관리번호 추출 @@ -466,7 +485,7 @@ const FeatureClickEventPopup = ({ break; } case "tb_ms_fldng_wtlv_map": { - result = "침수흔적도"; + result = "침수흔적위선"; setHeadlist(FldngWtlvMapCol); setLayerSpecificNm( normalizeFldngWtlvMapDisplayId( @@ -475,6 +494,14 @@ const FeatureClickEventPopup = ({ ); break; } + case "tb_ms_fldng_dpth_map": { + result = "침수흔적심선"; + setHeadlist(FldngDpthMapCol); + setLayerSpecificNm( + String(featureId ?? "").replace(/^tb_ms_fldng_dpth_map\./, ""), + ); + break; + } case "tb_ms_stofcbr": { result = "우수토실"; setHeadlist(stofcbrCol); @@ -539,11 +566,22 @@ const FeatureClickEventPopup = ({ break; } case "tb_ms_fldng_wtlv_map": { - result = "침수흔적도"; + result = "침수흔적위선"; setHeadlist(FldngWtlvMapCol2); setLayerSpecificNm(normalizeFldngWtlvMapDisplayId(featureData.fid)); break; } + case "tb_ms_fldng_dpth_map": { + result = "침수흔적심선"; + setHeadlist(FldngDpthMapCol); + setLayerSpecificNm( + String(featureData.fid ?? featureId ?? "").replace( + /^tb_ms_fldng_dpth_map\./, + "", + ), + ); + break; + } case "tb_ms_stofcbr": { result = "우수토실"; setHeadlist(stofcbrCol2); @@ -596,13 +634,14 @@ const FeatureClickEventPopup = ({ // 🎯 기본 데이터 포맷팅 const normalizedRegionData = - selectedLayerName === "침수흔적도" + selectedLayerName === "침수흔적위선" || + selectedLayerName === "침수흔적심선" ? withResolvedRegionNames(data) : data; const formattedData = withMngInstName( formatDisplayData(normalizedRegionData), ); - if (selectedLayerName === "침수흔적도") { + if (selectedLayerName === "침수흔적위선") { const fldngBgngDtm = formatFldngWtlvDateTime( formattedData.bgngYmd ?? formattedData.bgng_ymd ?? @@ -637,6 +676,7 @@ const FeatureClickEventPopup = ({ formattedData.instlYmd = "확인불가"; } } + let mergedDetailData = formattedData; setFetchData(formattedData); setTableData(formattedData); @@ -684,7 +724,7 @@ const FeatureClickEventPopup = ({ case "하수처리구역": setButtonChk(false); break; - case "침수흔적도": + case "침수흔적위선": setButtonChk(false); break; case "우수토실": @@ -742,11 +782,11 @@ const FeatureClickEventPopup = ({ const result = await getData(apiEndpoint); if (result) { - const mergedData = mergeDetailData(formattedData, result); + mergedDetailData = mergeDetailData(mergedDetailData, result); const processedApiData = withMngInstName( codeMapper({ layerName: selectedLayerName, - data: mergedData, + data: mergedDetailData, }), ); @@ -759,7 +799,67 @@ const FeatureClickEventPopup = ({ } } - if (selectedLayerName === "침수흔적도" && featureId) { + if (selectedLayerName === "하수관로" && canRequestDetail) { + try { + const escapeCqlLiteral = (value: string) => + value.replace(/'/g, "''"); + const requestCacheKey = `${fcltMngNo}::${mngInstCd}`; + const params = new URLSearchParams({ + lyrTypeNm: "tb_ms_swage_ppl", + includeFlowDirectionBasis: "true", + cqlFiter: + `fclt_mng_no='${escapeCqlLiteral(fcltMngNo)}' and ` + + `mng_inst_cd='${escapeCqlLiteral(mngInstCd)}'`, + page: "0", + size: "1", + }); + let flowDirectionRequest = + flowDirectionBasisRequestCacheRef.current.get(requestCacheKey); + if (!flowDirectionRequest) { + flowDirectionRequest = getData(`v1/fclts?${params.toString()}`) + .then((response) => + Array.isArray(response?.data) ? response.data[0] : null, + ) + .catch((error) => { + flowDirectionBasisRequestCacheRef.current.delete( + requestCacheKey, + ); + throw error; + }); + flowDirectionBasisRequestCacheRef.current.set( + requestCacheKey, + flowDirectionRequest, + ); + } + const enrichedPipe = await flowDirectionRequest; + if (enrichedPipe) { + mergedDetailData = mergeDetailData( + mergedDetailData, + enrichedPipe, + ); + const processedPipeData = withMngInstName( + codeMapper({ + layerName: selectedLayerName, + data: mergedDetailData, + }), + ); + setFetchData(processedPipeData); + setTableData(processedPipeData); + } + } catch (flowDirectionError) { + console.warn( + "관로 유하방향 근거 조회 실패, 기존 상세정보를 유지합니다.", + flowDirectionError, + ); + } + } + + if (selectedLayerName === "침수흔적위선" && featureId) { + const depthLookupKey = normalizeFldngWtlvMapApiFid(featureId); + setFloodDepthRows([]); + setFloodWtlvRows([]); + setSelectedFloodDepthFid(null); + setSelectedFloodWtlvFid(null); try { const normalizedFloodMapFid = normalizeFldngWtlvMapApiFid(featureId); @@ -780,6 +880,35 @@ const FeatureClickEventPopup = ({ } catch (rainError) { console.warn("침수강우분석 조회 실패:", rainError); } + try { + if (depthLookupKey) { + const depthRows = await getData( + `v1/fldng/hstry/depth-map/${encodeURIComponent(depthLookupKey)}`, + ); + const depthPayload = depthRows?.data ?? depthRows; + const depthItems = Array.isArray(depthPayload) + ? depthPayload + : depthPayload?.depthMaps; + const waterLevelItems = Array.isArray( + depthPayload?.waterLevelMaps, + ) + ? depthPayload.waterLevelMaps + : []; + const normalizedDepthRows = Array.isArray(depthItems) + ? depthItems.filter((row) => row && typeof row === "object") + : []; + const normalizedWaterLevelRows = waterLevelItems.filter( + (row: unknown) => row && typeof row === "object", + ); + setFloodDepthRows(normalizedDepthRows); + setFloodWtlvRows(normalizedWaterLevelRows); + // 최초에는 연계 항목을 선택하지 않고 버튼 목록만 표시한다. + setSelectedFloodDepthFid(null); + setSelectedFloodWtlvFid(null); + } + } catch (depthError) { + console.warn("침수깊이지도 조회 실패:", depthError); + } } } catch (error) { console.error("데이터 로딩 중 오류:", error); @@ -892,7 +1021,7 @@ const FeatureClickEventPopup = ({ className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-border bg-background transition-all duration-300" > -
+
{surveyMode && (

@@ -912,11 +1041,14 @@ const FeatureClickEventPopup = ({ 관리번호 : {layerSpecificNm}

-
+
{/* 하수관로일 때만 Infil/RDII 버튼 표시 */} {layerNm === "하수관로" && ( <>
0 || floodWtlvRows.length > 0) ? ( +
+ 기본정보 + {floodDepthRows.length > 0 && ( +
+ + 심선 + + +
+ {[...floodDepthRows] + .map((row, index) => ({ + row, + index, + year: String( + row.fldng_yr ?? row.fldngYr ?? row.yr ?? "", + ).trim(), + })) + .filter(({ year }) => Boolean(year)) + .sort((left, right) => { + const yearOrder = left.year.localeCompare( + right.year, + undefined, + { numeric: true }, + ); + return yearOrder !== 0 + ? yearOrder + : left.index - right.index; + }) + .map(({ row, index, year }) => ( + + ))} +
+ +
+ )} + {floodWtlvRows.length > 0 && ( +
+ + 위선 + + +
+ {floodWtlvRows.map((row, index) => { + const waterLevelFid = String(row.fid ?? "").trim(); + const disasterName = String( + row.fldng_dstr_nm ?? row.fldngDstrNm ?? "", + ).trim(); + return ( + + ); + })} +
+ +
+ )} +
+ ) : layerNm === "하수관로" && tableData?.elapsedYears ? ( <> 기본정보{" "} diff --git a/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx index 1e052cfad..8c2521ada 100644 --- a/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx @@ -5,11 +5,13 @@ import { DateMonthPicker } from "@common/business_components/ui/date_month_picke import { StandardPopupLayout, type UiHeadProps, + type UiTableSort, } from "@common/business_components"; import { Button } from "@common/components"; import { getData } from "@/components/lib/api"; import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; +import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { useAlertStore } from "@/shared/lib"; import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; @@ -28,18 +30,23 @@ import { const RDII_RESULT_PAGE_SIZE = 10; const RDII_RESULT_HEADLIST: UiHeadProps[] = [ - { id: "rowNumber", title: "순번" }, + { + id: "rowNumber", + title: "순번", + sortable: true, + sortKey: "rainInfwtAnlsSn", + }, { id: "ctpvNm", title: "시도" }, { id: "sggNm", title: "시군구" }, - { id: "swtrmtFcltNm", title: "하수처리시설" }, - { id: "aplcnYm", title: "상수사용연월" }, - { id: "drtwSwtcRt", title: "오수 전환율" }, - { id: "srcNm", title: "RTK 계수 출처" }, - { id: "cntbArea", title: "RDII 기여면적" }, - { id: "anlsInfo", title: "RDII\n분석정보" }, - { id: "regDt", title: "분석일자" }, + { id: "swtrmtFcltNm", title: "하수처리시설", sortable: true }, + { id: "aplcnYm", title: "상수사용연월", sortable: true }, + { id: "drtwSwtcRt", title: "오수 전환율", sortable: true }, + { id: "srcNm", title: "RTK 계수 출처", sortable: true }, + { id: "cntbArea", title: "RDII 기여면적", sortable: true }, + { id: "anlsInfo", title: "RDII\n분석정보", sortable: true }, + { id: "regDt", title: "분석일자", sortable: true }, { id: "rgtrNm", title: "분석자" }, - { id: "scsYn", title: "분석성공여부" }, + { id: "scsYn", title: "분석성공여부", sortable: true }, ]; type RdiiResultFilters = { @@ -101,6 +108,7 @@ export default function RdiiResultPopupPage() { const [rows, setRows] = useState([]); 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={
-
+
{indicator}
@@ -208,7 +215,7 @@ export default function SinkholeLayerSettingPanel({ title="적용" aria-label="연도범위 적용" > - ✓ + ✓
@@ -217,9 +224,14 @@ export default function SinkholeLayerSettingPanel({ if (compact) { return ( -
+
{settingRows}
-
+ ); } diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx index 3738f1e69..c837cefdf 100644 --- a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx @@ -10,10 +10,14 @@ import { import { getRawJson } from "@/components/lib/api"; import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; 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"; -import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; +import PopupSearchPanel, { + PopupSearchActions, + PopupSearchField, +} from "@/features/windowPopup/ui/PopupSearchPanel"; import type { SelectDataType } from "@common/business_components"; import { buildSinkholeDefaultFilters, @@ -21,15 +25,14 @@ import { formatDateInputToYmd, formatYmdToDateInput, normalizeSinkholeFilters, - SINKHOLE_COORD_STATUS_OPTIONS, SINKHOLE_DOWNLOAD_EXCEL_HEADERS, SINKHOLE_FILTER_SYNC_MESSAGE, SINKHOLE_MIN_SEARCH_YMD, - SINKHOLE_RESTORE_STATUS_OPTIONS, SINKHOLE_ROW_SELECT_MESSAGE, type SinkholeFilters, type SinkholeListItem, } from "./sinkholeTypes"; +import { useSinkholeCommonCodes } from "./useSinkholeCommonCodes"; const PAGE_SIZE = 10; const DOWNLOAD_BATCH_SIZE = 2000; @@ -40,6 +43,7 @@ type SinkholeTableRow = SinkholeListItem & { sizeLengthDisplay: string; sizeDepthDisplay: string; detailCauseDisplay: string; + restoreStatusDisplay: string; restoreCompletedYmdDisplay: string; coordStatusDisplay: string; }; @@ -53,7 +57,7 @@ const HEADLIST: UiHeadProps[] = [ { id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 9 }, { id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 9 }, { id: "detailCauseDisplay", title: "상세발생원인", widthPercent: 11 }, - { id: "restoreStatus", title: "복구상태", widthPercent: 8 }, + { id: "restoreStatusDisplay", title: "복구상태", widthPercent: 8 }, { id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 9 }, { id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 11 }, ]; @@ -63,10 +67,14 @@ const toDisplay = (value: unknown) => { return normalized ? normalized : "-"; }; -const toCoordStatusDisplay = (value: unknown) => { +const toCoordStatusDisplay = ( + value: unknown, + labels: Record = {}, +) => { const normalized = String(value ?? "") .trim() .toUpperCase(); + if (labels[normalized]) return labels[normalized]; if (normalized === "SUCCESS") return "성공"; if (normalized === "NO_ADDRESS") return "주소없음"; if (normalized === "FAILED") return "실패"; @@ -85,10 +93,29 @@ const toTableRows = ( sizeLengthDisplay: toDisplay(item.sizeLength), sizeDepthDisplay: toDisplay(item.sizeDepth), detailCauseDisplay: toDisplay(item.detailCause), + restoreStatusDisplay: toDisplay(item.restoreStatus), coordStatusDisplay: toCoordStatusDisplay(item.coordStatus), restoreCompletedYmdDisplay: toDisplay(item.restoreCompletedYmd), })); +const applyStatusDisplayNames = ( + rows: SinkholeTableRow[], + restoreStatusLabels: Record, + coordStatusLabels: Record, +) => + rows.map((row) => { + const restoreStatus = String(row.restoreStatus ?? "").trim(); + return { + ...row, + restoreStatusDisplay: + restoreStatusLabels[restoreStatus] || restoreStatus || "-", + coordStatusDisplay: toCoordStatusDisplay( + row.coordStatus, + coordStatusLabels, + ), + }; + }); + const buildDownloadRows = (rows: SinkholeTableRow[]) => rows.map((row) => ({ rowNo: row.rowNo, @@ -98,7 +125,7 @@ const buildDownloadRows = (rows: SinkholeTableRow[]) => sizeLength: row.sizeLengthDisplay, sizeDepth: row.sizeDepthDisplay, detailCause: row.detailCauseDisplay, - restoreStatus: row.restoreStatus || "-", + restoreStatus: row.restoreStatusDisplay, restoreCompletedYmd: row.restoreCompletedYmdDisplay, })); @@ -107,6 +134,13 @@ const unwrapPayload = (response: any): T => export default function SinkholePopupPage() { const alert = useAlertStore((state) => state.setMessage); + // 검색 파라미터 계약은 유지하고 선택지·목록 표시명만 CMS 공통코드로 구성한다. + const { + restoreStatusOptions, + coordStatusOptions, + restoreStatusLabels, + coordStatusLabels, + } = useSinkholeCommonCodes(); const searchParams = useSearchParams(); const hasInitialFilterParams = searchParams.has("startYmd") || @@ -156,6 +190,10 @@ export default function SinkholePopupPage() { const [total, setTotal] = useState(0); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); + const displayRows = useMemo( + () => applyStatusDisplayNames(rows, restoreStatusLabels, coordStatusLabels), + [coordStatusLabels, restoreStatusLabels, rows], + ); const sggOptions = useMemo( () => filters.ctpvCd === "0" @@ -274,27 +312,27 @@ export default function SinkholePopupPage() { setIsLoading(true); try { const normalized = normalizeSinkholeFilters(appliedFilters); - const downloadItems: SinkholeListItem[] = []; - const totalPages = Math.max(1, Math.ceil(total / DOWNLOAD_BATCH_SIZE)); - - for (let page = 1; page <= totalPages; page += 1) { - const response = await fetchRows( - page, - normalized, - DOWNLOAD_BATCH_SIZE, - { + const response = await fetchAllPages({ + batchSize: DOWNLOAD_BATCH_SIZE, + fetchPage: (page, size) => + fetchRows(page + 1, normalized, size, { includeCoordStatus: false, - }, - ); - downloadItems.push(...response.items); - } + }), + getItems: (page) => page.items, + getTotal: (page) => page.total, + }); + const downloadItems: SinkholeListItem[] = response.items; if (downloadItems.length === 0) { alert("다운로드할 데이터가 없습니다."); return; } - const downloadRows = toTableRows(downloadItems, 0, 1); + const downloadRows = applyStatusDisplayNames( + toTableRows(downloadItems, 0, 1), + restoreStatusLabels, + coordStatusLabels, + ); await downloadExcelWithKoreanHeaders({ data: buildDownloadRows(downloadRows) as unknown as JSON[], fileName: `지반침하정보_${new Date().toISOString().slice(0, 10)}.xlsx`, @@ -318,13 +356,10 @@ export default function SinkholePopupPage() { - - + - + - + - + - + - + -
+ -
+
>} + rows={displayRows as Array>} headlist={HEADLIST} title="지반침하 목록" total={total} @@ -470,6 +492,16 @@ export default function SinkholePopupPage() { onPageChange={(page) => 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/sinkhole/useSinkholeCommonCodes.ts b/apps/sn/app/(map-layout)/map/components/sinkhole/useSinkholeCommonCodes.ts new file mode 100644 index 000000000..f5de60b24 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/useSinkholeCommonCodes.ts @@ -0,0 +1,138 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import { getCommonCodes } from "@/components/lib/common/apiHelpers"; + +import { + SINKHOLE_COORD_STATUS_OPTIONS, + SINKHOLE_RESTORE_STATUS_OPTIONS, +} from "./sinkholeTypes"; + +type CodeLabelMap = Record; +type StatusOption = { value: string; label: string }; + +const COMMON_CODE_GROUPS = { + restoreStatus: "SNKHLRSTRSTTS", + coordStatus: "SNKHLCRDSTTS", +} as const; + +const cachedCodeLists = new Map< + string, + Array<{ lwpoCd: string; lwpoCdNm: string }> +>(); +const pendingCodeLists = new Map< + string, + Promise> +>(); + +/** 같은 브라우저 세션에서는 지반침하 상태 코드그룹별 CMS 요청을 한 번만 수행한다. */ +const loadCodeList = async (upCd: string) => { + const cached = cachedCodeLists.get(upCd); + if (cached) return cached; + + const pending = pendingCodeLists.get(upCd); + if (pending) return pending; + + const request = getCommonCodes(upCd) + .then((codes) => { + const codeList = codes.map(({ lwpoCd, lwpoCdNm }) => ({ + lwpoCd, + lwpoCdNm, + })); + cachedCodeLists.set(upCd, codeList); + return codeList; + }) + .finally(() => { + pendingCodeLists.delete(upCd); + }); + + pendingCodeLists.set(upCd, request); + return request; +}; + +/** 지반침하 상태의 검색값 계약은 유지하고 선택지와 표시명만 CMS 기준으로 제공한다. */ +export const useSinkholeCommonCodes = () => { + const [restoreCodes, setRestoreCodes] = useState( + cachedCodeLists.get(COMMON_CODE_GROUPS.restoreStatus) ?? [], + ); + const [coordCodes, setCoordCodes] = useState( + cachedCodeLists.get(COMMON_CODE_GROUPS.coordStatus) ?? [], + ); + + useEffect(() => { + let cancelled = false; + + loadCodeList(COMMON_CODE_GROUPS.restoreStatus) + .then((codes) => { + if (!cancelled) setRestoreCodes(codes); + }) + .catch((error) => { + console.error("SNKHLRSTRSTTS 공통코드 조회에 실패했습니다.", error); + }); + loadCodeList(COMMON_CODE_GROUPS.coordStatus) + .then((codes) => { + if (!cancelled) setCoordCodes(codes); + }) + .catch((error) => { + console.error("SNKHLCRDSTTS 공통코드 조회에 실패했습니다.", error); + }); + + return () => { + cancelled = true; + }; + }, []); + + const restoreStatusOptions = useMemo( + () => + restoreCodes.length > 0 + ? [ + { value: "0", label: "전체" }, + ...restoreCodes.map((code) => ({ + // 백엔드는 복구상태 한글 설명값을 검색조건으로 사용하므로 코드명을 전송한다. + value: code.lwpoCdNm, + label: code.lwpoCdNm, + })), + ] + : [...SINKHOLE_RESTORE_STATUS_OPTIONS], + [restoreCodes], + ); + + const coordStatusOptions = useMemo( + () => + coordCodes.length > 0 + ? [ + { value: "0", label: "전체" }, + ...coordCodes.map((code) => ({ + value: code.lwpoCd, + label: code.lwpoCdNm, + })), + ] + : [...SINKHOLE_COORD_STATUS_OPTIONS], + [coordCodes], + ); + + const restoreStatusLabels = useMemo(() => { + const labels: CodeLabelMap = {}; + restoreCodes.forEach((code) => { + labels[code.lwpoCd] = code.lwpoCdNm; + labels[code.lwpoCdNm] = code.lwpoCdNm; + }); + return labels; + }, [restoreCodes]); + + const coordStatusLabels = useMemo(() => { + const labels: CodeLabelMap = {}; + coordCodes.forEach((code) => { + labels[code.lwpoCd] = code.lwpoCdNm; + }); + return labels; + }, [coordCodes]); + + return { + restoreStatusOptions, + coordStatusOptions, + restoreStatusLabels, + coordStatusLabels, + }; +}; 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 fa2e8cf84..2077532aa 100644 --- a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx @@ -174,9 +174,16 @@ const postMessageToOpener = ( const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") => riskStatus === "DANGER" ? "sn-popup-badge-danger" : "sn-popup-badge-normal"; -/** 날짜까지 포함해 과거 이력에서도 결과 행 선택을 유일하게 식별한다. */ +/** 동일 날짜·지역에 여러 강우이벤트가 있어도 실행별 행 키가 겹치지 않게 식별한다. */ const buildResultRowKey = (row: WaterRiskResultRow) => - `${row.analysisDate ?? ""}:${row.ctpvCd ?? ""}:${row.sggCd ?? ""}:${row.areaId}`; + [ + row.analysisDate ?? "", + row.rainStartedAt ?? "", + row.analysisBaseAt ?? "", + row.ctpvCd ?? "", + row.sggCd ?? "", + row.areaId, + ].join(":"); /** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { @@ -433,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 94da6bc7e..ff0c97513 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -33,6 +33,7 @@ import { usePathname, useSearchParams } from "next/navigation"; import Collection from "ol/Collection.js"; import Feature from "ol/Feature.js"; import GeoJSON from "ol/format/GeoJSON.js"; +import Overlay from "ol/Overlay.js"; import CircleGeom from "ol/geom/Circle.js"; import Draw from "ol/interaction/Draw.js"; import Snap from "ol/interaction/Snap.js"; @@ -95,13 +96,15 @@ 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"; import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel"; import FldngHstryDetailPanel from "./components/fldngHstry/FldngHstryDetailPanel"; +import FldngHstryLayerSettingPanel from "./components/fldngHstry/FldngHstryLayerSettingPanel"; import ExmnLayerSettingPanel from "./components/exmn/ExmnLayerSettingPanel"; +import LayerSettingCard from "./components/layerSetting/LayerSettingCard"; import { getExmnMapLayer } from "./components/exmn/exmnMapApi"; import { EMPTY_EXMN_MAP_FILTERS, @@ -142,12 +145,20 @@ import { } from "./components/sinkhole/sinkholeTypes"; import { FLOOD_HISTORY_AREA_LAYER_ID, + FLOOD_HISTORY_DEPTH_LAYER_NAME, + FLOOD_HISTORY_DEPTH_LAYER_TYPE, FLOOD_HISTORY_DEPTH_LAYER_ID, FLOOD_HISTORY_PREVIEW_LAYER_TYPE, FLOOD_HISTORY_LAYER_TYPE, + FLOOD_HISTORY_LAYER_NAME, FLOOD_HISTORY_POPUP_KEY, FLDNG_HSTRY_ROW_SELECT_MESSAGE, - isFldngHstryNode, + buildFloodHistoryDefaultYearRange, + buildFloodHistoryYearCql, + isFloodHistoryDepthNode, + isFloodHistoryWaterLevelNode, + isFldngHstryRootNode, + type FloodHistoryYearRange, type FldngHstryItem, } from "./components/fldngHstry/fldngHstryTypes"; import { @@ -183,6 +194,8 @@ const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT"; // 노후관로 지도 강조선과 시설물 트리의 선형 범례가 동일하게 사용하는 빨간색이다. const AGED_PIPE_HIGHLIGHT_COLOR = "rgba(235, 40, 40, 0.82)"; const FLDNG_HSTRY_WFS_LAYER_ID = "SN_FLDNG_HSTRY_WFS"; +// 심선은 동일 레이어 유형에 6개 깊이 등급 설정이 있어 prefix 뒤에 lyrSn을 붙여 각각 생성한다. +const FLDNG_DEPTH_WFS_LAYER_ID_PREFIX = "SN_FLDNG_DEPTH_WFS"; const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; @@ -203,6 +216,15 @@ const EXMN_GRADE_COLORS: Record = { }; const EXMN_RESULT_MANHOLE_ICON_SRC = "/images/map/layericon/SN_EXMN_MANHOLE_PURPLE.png?v=3"; +const FLOOD_PREDICTION_GRID_LAYER_ID = "SN_FLDNG_PREDICTION_GRID_LABEL"; + +/** 침수예측 오버레이용 12자리 분석시각을 화면 표시 형식으로 변환한다. */ +const formatFloodPredictionDateTime = (value: unknown) => { + const text = String(value ?? "").trim(); + return /^\d{12}$/.test(text) + ? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)} ${text.slice(8, 10)}:${text.slice(10, 12)}` + : text || "-"; +}; const getExmnGradeColor = (feature: any) => { const grade = Number(feature?.get?.("structuralGrade") ?? 0); @@ -228,6 +250,7 @@ const createExmnManholeStyle = () => }), }); +/** 선택한 도시침수지도 중앙에 침수예측 분석값을 표시하는 오버레이 스타일이다. */ // 유하방향은 GeoServer WMS/WFS 레이어가 아니라 BE 보정 GeoJSON API로 그리는 가상 레이어이다. const FLOW_DIRECTION_LAYER_TYPE = "tb_sn_swage_ppl_flow_direction"; const FLOW_DIRECTION_LAYER_NAME = "유하방향"; @@ -243,6 +266,7 @@ const GIS_EDIT_VERTEX_LAYER_ID = "SN_GIS_EDIT_VERTEX_HANDLES"; const WATER_RISK_AREA_LAYER_ID = "SN_WATER_RISK_AREA"; const WATER_RISK_PIPE_LAYER_ID = "SN_WATER_RISK_PIPE"; const WATER_RISK_MANHOLE_LAYER_ID = "SN_WATER_RISK_MANHOLE"; +const SHORT_TERM_FORECAST_GRID_LAYER_NAME = "초단기예보격자망"; const FLOOD_HISTORY_SELECTION_PADDING: [number, number, number, number] = [ 100, 380, 120, 120, ]; @@ -434,6 +458,7 @@ const DEFAULT_FEATURE_FIT_PADDING: [number, number, number, number] = [ 50, 50, 50, 50, ]; const SWAGE_PRCS_FCLT_LAYER_TYPE = "tb_il_swage_prcs_fclt"; +const RAIN_OBSERVATION_LAYER_TYPE = "tb_ms_rain_obsvtr_pstn_info"; type SwagePrcsFcltLayerMode = "all" | "gte500" | "lt500" | "none"; @@ -546,6 +571,21 @@ const normalizeFlowDirectionLayerInfos = (data: any[]): any[] => { }); }; +// 강우관측소는 위치와 라벨만 제공하며, 과거 DB 설정값이 Y여도 일반 속성 패널 선택 대상에서는 제외한다. +const normalizeRainObservationLayerInteraction = (data: any[]): any[] => { + if (!Array.isArray(data)) return data; + + return data.map((item) => { + const children = Array.isArray(item.children) + ? normalizeRainObservationLayerInteraction(item.children) + : item.children; + + return getLayerTypeName(item) === RAIN_OBSERVATION_LAYER_TYPE + ? { ...item, children, clickPsblYn: "N" } + : { ...item, children }; + }); +}; + // 레이어 설정의 최소 표출 줌을 읽되, 값이 없으면 유하방향 기본 줌 기준을 적용한다. const getFiniteNumber = (value: unknown) => { if (value === null || value === undefined || String(value).trim() === "") @@ -1049,7 +1089,9 @@ const getRightPanelFeatureTitle = (data: any) => { case "tb_ms_swage_prcs_zone": return "처리구역"; case "tb_ms_fldng_wtlv_map": - return "침수흔적도"; + return FLOOD_HISTORY_LAYER_NAME; + case "tb_ms_fldng_dpth_map": + return "침수흔적심선"; case "om_dt_untrt_swg_rain": return "중점우수"; default: @@ -2950,6 +2992,11 @@ const Map = () => { const sinkholeScope = useMemo(() => buildSinkholeScope(userInfo), [userInfo]); const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 + // 통수위험 결과 행을 선택할 때 기존 체크를 유지하면서 강우 격자 레이어를 켜기 위한 요청이다. + const [checkedLayerRequest, setCheckedLayerRequest] = useState<{ + requestKey: number; + layerNames: string[]; + } | null>(null); const [layerLoadError, setLayerLoadError] = useState(null); const gisMaps = useRef(null); const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 @@ -2967,7 +3014,48 @@ const Map = () => { const [checkedLayerLegendVersion, setCheckedLayerLegendVersion] = useState(0); const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false); const [isSinkholeChecked, setIsSinkholeChecked] = useState(false); + const [isSinkholeSettingEnabled, setIsSinkholeSettingEnabled] = + useState(true); + const [isSinkholeSettingVisible, setIsSinkholeSettingVisible] = + useState(true); + const [isAgedPipeSettingEnabled, setIsAgedPipeSettingEnabled] = + useState(true); + const [isAgedPipeSettingVisible, setIsAgedPipeSettingVisible] = + useState(true); + const [isExmnPplSettingEnabled, setIsExmnPplSettingEnabled] = useState(true); + const [isExmnPplSettingVisible, setIsExmnPplSettingVisible] = useState(true); + const [isExmnMnhlSettingEnabled, setIsExmnMnhlSettingEnabled] = + useState(true); + const [isExmnMnhlSettingVisible, setIsExmnMnhlSettingVisible] = + useState(true); + const [isWaterDepthLegendEnabled, setIsWaterDepthLegendEnabled] = + useState(true); + const [isWaterDepthLegendVisible, setIsWaterDepthLegendVisible] = + useState(true); const [isFldngHstryChecked, setIsFldngHstryChecked] = useState(false); + // 침수흔적위선과 심선은 하나의 연도 범위를 공유하지만 체크 상태는 독립적으로 관리한다. + const [isFloodWtlvChecked, setIsFloodWtlvChecked] = useState(false); + const [isFloodDepthChecked, setIsFloodDepthChecked] = useState(false); + // 심선은 6개 깊이 등급 중 사용자가 실제 체크한 lyrSn만 필터 레이어로 생성한다. + const [checkedFloodDepthLayerKeys, setCheckedFloodDepthLayerKeys] = useState< + string[] + >([]); + const [floodYearRange, setFloodYearRange] = useState( + () => buildFloodHistoryDefaultYearRange(), + ); + const [isFloodYearFilterEnabled, setIsFloodYearFilterEnabled] = + useState(true); + const [isFloodYearSettingVisible, setIsFloodYearSettingVisible] = + useState(true); + // 닫힌 설정 카드는 레이어가 완전히 해제된 뒤 다시 체크될 때만 자동으로 연다. + const floodLayerCheckedRef = useRef(false); + const sinkholeLayerCheckedRef = useRef(false); + const agedPipeLayerCheckedRef = useRef(false); + const exmnPplLayerCheckedRef = useRef(false); + const exmnMnhlLayerCheckedRef = useRef(false); + const waterDepthLayerCheckedRef = useRef(false); + // 체크 등급 변경과 연도 필터 변경을 구분해 연도 변경 때만 기존 선택 상세를 정리한다. + const floodYearFilterSignatureRef = useRef(""); const [isExmnPplChecked, setIsExmnPplChecked] = useState(false); const [isExmnMnhlChecked, setIsExmnMnhlChecked] = useState(false); const [exmnPplFilters, setExmnPplFilters] = useState(() => ({ @@ -3193,6 +3281,8 @@ const Map = () => { const waterRiskPipeSourceRef = useRef(null); const waterRiskManholeLayerRef = useRef | null>(null); const waterRiskManholeSourceRef = useRef(null); + // 침수예측현황 선택행에 따라 초단기예보 격자 중앙 라벨을 교체하는 전용 overlay를 보관한다. + const floodPredictionGridLayerRef = useRef(null); const gisEditDrawRef = useRef(null); const gisEditDrawSnapRef = useRef(null); const gisEditTranslateRef = useRef(null); @@ -3416,6 +3506,20 @@ const Map = () => { [selectedWaterRiskApiResult], ); + // 지도카드는 실제 GIS geometry까지 결합된 mapView 위험상태를 목록 상태보다 우선한다. + const selectedWaterRiskDisplayStatus = useMemo< + WaterRiskResultRow["riskStatus"] + >( + () => + selectedWaterRiskMapView?.riskStatus ?? + selectedWaterRiskResultRow?.riskStatus ?? + "NORMAL", + [ + selectedWaterRiskMapView?.riskStatus, + selectedWaterRiskResultRow?.riskStatus, + ], + ); + /** 선택 영역 오른쪽에 중점관리지역 강우·침수 요약카드를 배치한다. */ const syncWaterRiskInfoCardPosition = useCallback(() => { const map = gisMaps.current; @@ -3456,6 +3560,109 @@ const Map = () => { } }, []); + /** 기존 침수예측 중앙 오버레이를 제거한다. */ + const removeFloodPredictionGridLayer = useCallback(() => { + const core = GisApp.Module.core as any; + core?.deleteLayer?.(FLOOD_PREDICTION_GRID_LAYER_ID); + const map = gisMaps.current; + if (map && floodPredictionGridLayerRef.current) { + map.removeOverlay(floodPredictionGridLayerRef.current); + } + floodPredictionGridLayerRef.current = null; + }, []); + + /** 선택행의 시군구·분석시각으로 도시침수지도 중앙 오버레이를 표시한다. */ + const loadFloodPredictionGridLayer = useCallback( + async (sggCd: string, analysisDateTime: string, extent: number[]) => { + const map = gisMaps.current; + if (!map || !/^\d{5}$/.test(sggCd) || !/^\d{12}$/.test(analysisDateTime)) + return; + removeFloodPredictionGridLayer(); + try { + const response = await getRawJson( + `/sewerage-service/api/v1/fldng/predc/prst/grid-map?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`, + ); + const payload = response?.data ?? response; + const row = payload && !Array.isArray(payload) ? payload : null; + if ( + !row || + extent.length !== 4 || + extent.some((value) => !Number.isFinite(value)) + ) + return; + const center = [ + (extent[0]! + extent[2]!) / 2, + (extent[1]! + extent[3]!) / 2, + ]; + const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed( + 1, + ); + const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed( + 1, + ); + const container = document.createElement("div"); + container.style.cssText = + "position:relative; background:rgba(255,255,255,.55); color:#111827; border:1px solid rgba(17,24,39,.7); box-shadow:0 2px 8px rgba(0,0,0,.2); padding:8px 10px; font:400 11px/1.35 sans-serif; min-width:230px;"; + const close = document.createElement("button"); + close.type = "button"; + close.textContent = "×"; + close.title = "침수예측 오버레이 닫기"; + close.style.cssText = + "position:absolute; right:3px; top:1px; border:0; background:transparent; color:#111827; font-size:16px; line-height:16px; cursor:pointer;"; + close.addEventListener("click", () => removeFloodPredictionGridLayer()); + const table = document.createElement("table"); + table.style.cssText = "border-collapse:collapse; width:100%;"; + const title = table.createTHead().insertRow(); + const titleCell = title.insertCell(); + titleCell.colSpan = 2; + titleCell.textContent = `${row.ctpvNm ?? row.ctpvCd ?? "-"} ${row.sggNm ?? row.sggCd ?? "-"} 침수예측`; + titleCell.style.cssText = + "font-weight:500; text-align:center; padding:2px 16px 6px 2px; border-bottom:1px solid #9ca3af;"; + const body = table.createTBody(); + [ + [ + "분석시각", + formatFloodPredictionDateTime( + row.analysisDateTime ?? analysisDateTime, + ), + ], + [ + "강우시작시각", + formatFloodPredictionDateTime(row.rainBeginningDateTime), + ], + ["관측 누적강수량(mm)", observed], + ["예측시각", formatFloodPredictionDateTime(row.predictionDateTime)], + ["예측 누적강수량(mm)", predicted], + ["초과 빈도", `${row.exceededFrequencyYear ?? "-"}년`], + ["침수위험도", row.floodRiskGrade ?? "-"], + ].forEach(([label, value]) => { + const tr = body.insertRow(); + const key = tr.insertCell(); + key.textContent = label; + key.style.cssText = + "font-weight:400; padding:2px 8px 2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; + const val = tr.insertCell(); + val.textContent = value; + val.style.cssText = + "font-weight:400; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; + }); + container.append(close, table); + const overlay = new Overlay({ + element: container, + positioning: "center-center", + stopEvent: true, + }); + overlay.set("id", FLOOD_PREDICTION_GRID_LAYER_ID); + overlay.setPosition(center); + map.addOverlay(overlay); + floodPredictionGridLayerRef.current = overlay; + } catch (error) { + console.error("침수예측지도 오버레이 표시 실패:", error); + } + }, + [removeFloodPredictionGridLayer], + ); + useEffect(() => { fetchData(); }, []); @@ -3499,12 +3706,30 @@ const Map = () => { if (isSinkholePopupFilterActive) { return normalizeSinkholeFilters(currentFilters, sinkholeScope); } + if (!isSinkholeSettingEnabled) { + return normalizeSinkholeFilters( + { + ...buildSinkholeFiltersFromYearRange( + sinkholeYearRange, + sinkholeScope, + ), + startYmd: "", + endYmd: "", + }, + sinkholeScope, + ); + } return normalizeSinkholeFilters( buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope), sinkholeScope, ); }); - }, [isSinkholePopupFilterActive, sinkholeScope, sinkholeYearRange]); + }, [ + isSinkholePopupFilterActive, + isSinkholeSettingEnabled, + sinkholeScope, + sinkholeYearRange, + ]); useEffect(() => { const timer = window.setInterval(() => { @@ -3575,6 +3800,9 @@ const Map = () => { : []; const sggCd = String(data.payload?.sggCd ?? "").trim(); const frequency = String(data.payload?.sbmrgFrqExpln ?? "").trim(); + const analysisDateTime = String( + data.payload?.analysisDateTime ?? "", + ).trim(); const map = gisMaps.current; const select = GisApp.Module.select as any; if ( @@ -3590,6 +3818,7 @@ const Map = () => { await select.resetSelectedFeature?.(); const selectedFeatureIds = new Set(); + let floodLayerVisible = false; const selectLoadedGroupFeatures = (source: any) => { const features = source?.getFeatures?.() ?? []; features.forEach((feature: any) => { @@ -3617,6 +3846,7 @@ const Map = () => { FLOOD_DNGR_MAP_LAYER_TYPE ) return; + if (layer.getVisible?.() !== false) floodLayerVisible = true; const source = layer.getSource?.(); if (!source?.getFeatures) return; selectLoadedGroupFeatures(source); @@ -3624,11 +3854,19 @@ const Map = () => { selectLoadedGroupFeatures(source), ); }); + if (!floodLayerVisible) { + (event.source as Window | null)?.postMessage?.( + { type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED }, + event.origin, + ); + return; + } map.getView().fit(extent, { duration: 300, padding: [50, 50, 50, 50], maxZoom: 16, }); + void loadFloodPredictionGridLayer(sggCd, analysisDateTime, extent); return; } @@ -3667,7 +3905,7 @@ const Map = () => { window.clearInterval(timer); window.removeEventListener("message", handlePopupMessage); }; - }, []); + }, [loadFloodPredictionGridLayer]); useEffect(() => { if (shouldShowGisEditSession) return; @@ -3793,7 +4031,11 @@ const Map = () => { appendExmnResultControlNodes( appendAgedPipeControlNode( appendSinkholeControlNode( - normalizeTreeRowSpacing(normalizeFlowDirectionLayerInfos(data)), + normalizeTreeRowSpacing( + normalizeRainObservationLayerInteraction( + normalizeFlowDirectionLayerInfos(data), + ), + ), ), ), exmnMapEntryContext.jobType, @@ -4065,6 +4307,10 @@ const Map = () => { ...current, selectedAreaId: areaId, })); + setCheckedLayerRequest((current) => ({ + requestKey: (current?.requestKey ?? 0) + 1, + layerNames: [SHORT_TERM_FORECAST_GRID_LAYER_NAME], + })); setIsOpen(false); setFeatureData(null); return; @@ -5092,6 +5338,30 @@ const Map = () => { return; } + // 연계 심선/위선 조회는 공용 search-layer를 교체한다. 이때 탭에 보관된 + // 기존 침수흔적 feature는 더 이상 지도 레이어에 속하지 않으므로, 단순히 + // Select 컬렉션에 다시 넣어도 강조가 렌더링되지 않는다. 탭으로 돌아올 + // 때는 FID로 search-layer를 복원한 뒤 로드된 feature를 선택하게 한다. + if ( + layerTypeName === FLOOD_HISTORY_LAYER_TYPE || + layerTypeName === "tb_ms_fldng_dpth_map" + ) { + clearSinkholeFocusedPipeLayer(); + const featureIdentifier = getRightPanelFeatureIdentifier(data).replace( + new RegExp(`^${layerTypeName}\\.`), + "", + ); + if (featureIdentifier) { + const core = GisApp.Module.layer as any; + void core?.showSearchResultsFromGeoJSON?.( + layerTypeName, + `${layerTypeName}.${featureIdentifier}`, + ); + } + + return; + } + if (!data || typeof data.getGeometry !== "function") { clearSinkholeFocusedPipeLayer(); @@ -5134,7 +5404,7 @@ const Map = () => { zIndex: 9993, }); layer.set("id", FLOOD_HISTORY_AREA_LAYER_ID); - layer.set("lyrNm", "침수흔적도"); + layer.set("lyrNm", FLOOD_HISTORY_LAYER_NAME); layer.set("lyrTyNm", FLOOD_HISTORY_PREVIEW_LAYER_TYPE); layer.set("clickPsblYn", "N"); gisMaps.current.addLayer(layer); @@ -5154,7 +5424,7 @@ const Map = () => { zIndex: 9992, }); layer.set("id", FLOOD_HISTORY_DEPTH_LAYER_ID); - layer.set("lyrNm", "침수흔적도"); + layer.set("lyrNm", FLOOD_HISTORY_LAYER_NAME); layer.set("lyrTyNm", FLOOD_HISTORY_PREVIEW_LAYER_TYPE); layer.set("clickPsblYn", "N"); gisMaps.current.addLayer(layer); @@ -5774,6 +6044,22 @@ const Map = () => { if (!tabId) return; const closingTab = rightPanelTabs.find((tab) => tab.id === tabId); const remainingTabs = removeRightPanelTabGroup(rightPanelTabs, tabId); + const closingLayerTypeName = getSelectedFeatureLayerTypeName( + closingTab?.featureData, + ); + + // 침수흔적 연계 조회로 만든 공용 search-layer는 선택 해제만으로 + // 제거되지 않는다. 마지막 탭을 닫는 경우와 다른 일반 feature 탭으로 + // 전환되는 경우 모두 잔상으로 남지 않도록 여기서 명시적으로 제거한다. + if ( + closingLayerTypeName === FLOOD_HISTORY_LAYER_TYPE || + closingLayerTypeName === "tb_ms_fldng_dpth_map" + ) { + const layerModule = GisApp.Module.layer as any; + if (layerModule?.searchLayer) { + layerModule.clearSearchLayer?.(); + } + } if (closingTab?.kind === "sinkhole") { setSelectedSinkholeDetail(null); @@ -5951,7 +6237,9 @@ const Map = () => { ); const handlePipeAnalysisRowSelect = useCallback( - async (row: Pick) => { + async (row: PipeAnalysisDetailRow) => { + // 관로현황 팝업에서 선택한 경우 메시지를 받은 최상위 지도창을 먼저 앞으로 가져온다. + window.focus(); try { shouldFocusAgedPipeOnNextSelectRef.current = true; const core = GisApp.Module.layer as any; @@ -5964,6 +6252,8 @@ const Map = () => { setFeatureData(row as any); setIsOpen(true); setIsSearchModalOpen(false); + // 지도 이동과 상세 패널 렌더링 이후에도 최상위 창 포커스를 한 번 더 요청한다. + window.focus(); }, [setIsSearchModalOpen], ); @@ -5976,19 +6266,25 @@ const Map = () => { try { setIsAgedPipeListLoading(true); - setAgedPipeDownloadProgressText("CSV export 작업을 준비하고 있습니다."); - await downloadAgedPipeCsv(appliedAgedPipeFilters, (job) => { - if (job.status === "QUEUED") { - setAgedPipeDownloadProgressText("CSV export 작업이 대기 중입니다."); - return; - } + setAgedPipeDownloadProgressText("Excel 파일을 준비하고 있습니다."); + await downloadAgedPipeExcel( + appliedAgedPipeFilters, + (job) => { + if (job.status === "QUEUED") { + setAgedPipeDownloadProgressText( + "Excel 다운로드 작업이 대기 중입니다.", + ); + return; + } - if (job.status === "RUNNING") { - setAgedPipeDownloadProgressText( - `CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`, - ); - } - }); + if (job.status === "RUNNING") { + setAgedPipeDownloadProgressText( + `Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`, + ); + } + }, + agedPipeTotalCount, + ); setAgedPipeDownloadProgressText(""); } catch (error) { console.error("노후관로 목록 다운로드 중 오류:", error); @@ -5996,7 +6292,7 @@ const Map = () => { alert( error instanceof Error ? error.message - : "노후관로 CSV 다운로드 중 오류가 발생했습니다.", + : "노후관로 Excel 다운로드 중 오류가 발생했습니다.", ); } finally { setIsAgedPipeListLoading(false); @@ -6172,17 +6468,52 @@ const Map = () => { const waterDepthChecked = checkedNodesData.some((node) => WATER_DEPTH_LAYER_TYPES.includes(String(node?.data?.lyrTyNm ?? "")), ); + if (agedPipeChecked && !agedPipeLayerCheckedRef.current) { + setIsAgedPipeSettingVisible(true); + } + if (waterDepthChecked && !waterDepthLayerCheckedRef.current) { + setIsWaterDepthLegendVisible(true); + } + agedPipeLayerCheckedRef.current = agedPipeChecked; + waterDepthLayerCheckedRef.current = waterDepthChecked; setIsAgedPipeChecked(agedPipeChecked); - const fldngHstryChecked = checkedNodesData.some((node) => - isFldngHstryNode(node), + const floodWtlvChecked = checkedNodesData.some((node) => + isFloodHistoryWaterLevelNode(node), + ); + const checkedFloodDepthNodes = checkedNodesData.filter((node) => + isFloodHistoryDepthNode(node), + ); + const checkedDepthLayerKeys = Array.from( + new Set( + checkedFloodDepthNodes + .map((node) => String(node?.data?.lyrSn ?? node?.id ?? "").trim()) + .filter(Boolean), + ), + ).sort((left, right) => Number(left) - Number(right)); + const floodDepthChecked = checkedDepthLayerKeys.length > 0; + const fldngHstryChecked = floodWtlvChecked || floodDepthChecked; + setIsFloodWtlvChecked(floodWtlvChecked); + setIsFloodDepthChecked(floodDepthChecked); + setCheckedFloodDepthLayerKeys((currentKeys) => + currentKeys.join(",") === checkedDepthLayerKeys.join(",") + ? currentKeys + : checkedDepthLayerKeys, ); setIsFldngHstryChecked(fldngHstryChecked); + if (fldngHstryChecked && !floodLayerCheckedRef.current) { + setIsFloodYearSettingVisible(true); + } + floodLayerCheckedRef.current = fldngHstryChecked; if (!fldngHstryChecked) { clearFldngHstrySelection(); } const sinkholeChecked = checkedNodesData.some((node) => isSinkholeLayerNode(node), ); + if (sinkholeChecked && !sinkholeLayerCheckedRef.current) { + setIsSinkholeSettingVisible(true); + } + sinkholeLayerCheckedRef.current = sinkholeChecked; setIsSinkholeChecked(sinkholeChecked); const exmnPplChecked = checkedNodesData.some((node) => isExmnResultPplNode(node), @@ -6190,11 +6521,20 @@ const Map = () => { const exmnMnhlChecked = checkedNodesData.some((node) => isExmnResultMnhlNode(node), ); + if (exmnPplChecked && !exmnPplLayerCheckedRef.current) { + setIsExmnPplSettingVisible(true); + } + if (exmnMnhlChecked && !exmnMnhlLayerCheckedRef.current) { + setIsExmnMnhlSettingVisible(true); + } + exmnPplLayerCheckedRef.current = exmnPplChecked; + exmnMnhlLayerCheckedRef.current = exmnMnhlChecked; setIsExmnPplChecked(exmnPplChecked); setIsExmnMnhlChecked(exmnMnhlChecked); setActiveLayerSettingOrder((currentOrder) => { const activeKeys = [ waterDepthChecked ? "waterDepthLegend" : null, + fldngHstryChecked ? "fldngHstry" : null, sinkholeChecked ? "sinkhole" : null, agedPipeChecked ? "agedPipe" : null, exmnPplChecked ? "exmnPpl" : null, @@ -6244,15 +6584,9 @@ const Map = () => { ); setSinkholeYearRange(normalizedRange); setIsSinkholePopupFilterActive(false); - setAppliedSinkholeFilters( - normalizeSinkholeFilters( - buildSinkholeFiltersFromYearRange(normalizedRange, sinkholeScope), - sinkholeScope, - ), - ); setSinkholeSelectionVersion((version) => version + 1); }, - [sinkholeScope, sinkholeYearRange], + [sinkholeYearRange], ); /** 침수위험지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */ @@ -6291,7 +6625,7 @@ const Map = () => { openFloodDngrMapPopupWindow(); return false; } - if (isFldngHstryNode(node)) { + if (isFldngHstryRootNode(node)) { openFldngHstryPopupWindow(); return false; } @@ -6322,7 +6656,7 @@ const Map = () => { return index >= 0 ? index : fallback; }; - if (isExmnPplChecked) { + if (isExmnPplChecked && isExmnPplSettingVisible) { nextItems.push({ key: "exmnPpl", label: "관로조사", @@ -6332,12 +6666,15 @@ const Map = () => { jobType="PPL" filters={exmnPplFilters} onFiltersChange={setExmnPplFilters} + enabled={isExmnPplSettingEnabled} + onEnabledChange={setIsExmnPplSettingEnabled} + onClose={() => setIsExmnPplSettingVisible(false)} /> ), }); } - if (isExmnMnhlChecked) { + if (isExmnMnhlChecked && isExmnMnhlSettingVisible) { nextItems.push({ key: "exmnMnhl", label: "맨홀조사", @@ -6347,12 +6684,32 @@ const Map = () => { jobType="MNHL" filters={exmnMnhlFilters} onFiltersChange={setExmnMnhlFilters} + enabled={isExmnMnhlSettingEnabled} + onEnabledChange={setIsExmnMnhlSettingEnabled} + onClose={() => setIsExmnMnhlSettingVisible(false)} /> ), }); } - if (isSinkholeChecked) { + if (isFldngHstryChecked && isFloodYearSettingVisible) { + nextItems.push({ + key: "fldngHstry", + label: "침수흔적도", + order: resolveSettingOrder("fldngHstry", 9997), + content: ( + setIsFloodYearSettingVisible(false)} + /> + ), + }); + } + + if (isSinkholeChecked && isSinkholeSettingVisible) { nextItems.push({ key: "sinkhole", label: "지반침하정보", @@ -6365,13 +6722,16 @@ const Map = () => { onApplyYearRange={applySinkholeYearRange} lastSyncSummary={lastSinkholeSyncSummary} isAdmin={userInfo?.adminRoleYn === "Y"} + enabled={isSinkholeSettingEnabled} + onEnabledChange={setIsSinkholeSettingEnabled} + onClose={() => setIsSinkholeSettingVisible(false)} compact /> ), }); } - if (isAgedPipeChecked) { + if (isAgedPipeChecked && isAgedPipeSettingVisible) { nextItems.push({ key: "agedPipe", label: "노후관로", @@ -6381,43 +6741,47 @@ const Map = () => { filters={draftAgedPipeFilters} onFiltersChange={setDraftAgedPipeFilters} onApply={handleApplyAgedPipeFilters} + enabled={isAgedPipeSettingEnabled} + onEnabledChange={setIsAgedPipeSettingEnabled} + onClose={() => setIsAgedPipeSettingVisible(false)} compact /> ), }); } - if (isWaterDepthLayerActive) { + if (isWaterDepthLayerActive && isWaterDepthLegendVisible) { nextItems.push({ key: "waterDepthLegend", label: "침수심 범례", order: resolveSettingOrder("waterDepthLegend", 9997), content: ( -
-
-
-
- 침수심 범례 -
-
- {waterDepthLegendItems.map((item) => ( -
- - - {item.label} - -
- ))} -
+ setIsWaterDepthLegendVisible(false)} + showToggle={false} + > + {isWaterDepthLegendEnabled ? ( +
+ {waterDepthLegendItems.map((item) => ( +
+ + + {item.label} + +
+ ))}
-
-
+ ) : null} + ), }); } @@ -6433,11 +6797,25 @@ const Map = () => { exmnMnhlFilters, exmnPplFilters, handleApplyAgedPipeFilters, + floodYearRange, isAgedPipeChecked, + isAgedPipeSettingEnabled, + isAgedPipeSettingVisible, isExmnMnhlChecked, + isExmnMnhlSettingEnabled, + isExmnMnhlSettingVisible, isExmnPplChecked, + isExmnPplSettingEnabled, + isExmnPplSettingVisible, + isFldngHstryChecked, + isFloodYearFilterEnabled, + isFloodYearSettingVisible, isSinkholeChecked, + isSinkholeSettingEnabled, + isSinkholeSettingVisible, isWaterDepthLayerActive, + isWaterDepthLegendEnabled, + isWaterDepthLegendVisible, lastSinkholeSyncSummary, sinkholeRadiusMeters, sinkholeYearRange, @@ -6476,24 +6854,9 @@ const Map = () => { return (
-
- - 선택 레이어 설정 - - - {activeLayerSettingItems.length}건 - -
-
- {activeLayerSettingItems.map((item) => ( - - {item.label} - - ))} -
+ + 선택 레이어 설정 +
{ const core = GisApp.Module.core as any; core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID); - if (!isAgedPipeChecked) return; + if (!isAgedPipeChecked || !isAgedPipeSettingEnabled) return; const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find( (item: any) => @@ -6556,45 +6919,184 @@ const Map = () => { return () => { core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID); }; - }, [appliedAgedPipeFilters, isAgedPipeChecked, mapReady]); + }, [ + appliedAgedPipeFilters, + isAgedPipeChecked, + isAgedPipeSettingEnabled, + mapReady, + ]); useEffect(() => { if (!mapReady || !gisMaps.current) return; const core = GisApp.Module.core as any; - core?.deleteLayer?.(FLDNG_HSTRY_WFS_LAYER_ID); + const yearCql = buildFloodHistoryYearCql( + floodYearRange, + isFloodYearFilterEnabled, + ); + const yearFilterSignature = `${floodYearRange.startYear}:${floodYearRange.endYear}:${isFloodYearFilterEnabled}`; + if ( + floodYearFilterSignatureRef.current && + floodYearFilterSignatureRef.current !== yearFilterSignature + ) { + clearFldngHstrySelection(); + } + floodYearFilterSignatureRef.current = yearFilterSignature; - if (!isFldngHstryChecked) return; - - const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find( - (item: any) => - item?.lyrTyNm === FLOOD_HISTORY_LAYER_TYPE && item?.actlLyrYn === "Y", - ) as any; - - if (!baseLayerCode) return; - - const layerCode = { - ...baseLayerCode, - id: FLDNG_HSTRY_WFS_LAYER_ID, - lyrSn: FLDNG_HSTRY_WFS_LAYER_ID, - lyrNm: "침수흔적도", - lyrTyNm: FLOOD_HISTORY_LAYER_TYPE, - actlLyrYn: "Y", - exprsYn: "Y", - clickPsblYn: "Y", - cmncNm: "bbox", - srvcNm: "wfs", + /** 기준 레이어 필터를 보존하면서 선택한 침수년도 조건을 결합한다. */ + const resolveFloodFilter = (baseLayerCode: any) => { + const baseFilter = String(baseLayerCode?.flterSql ?? "").trim(); + return [baseFilter && `(${baseFilter})`, yearCql] + .filter(Boolean) + .join(" AND "); }; - const layer = core?.addWfsLayer?.(layerCode); - layer?.set?.("id", FLDNG_HSTRY_WFS_LAYER_ID); - layer?.set?.("lyrNm", "침수흔적도"); - layer?.set?.("lyrTyNm", FLOOD_HISTORY_LAYER_TYPE); + type FloodLayerSpec = { + id: string; + layerType: string; + layerName: string; + baseLayerCode: any; + filterSql: string; + }; + const desiredLayers: FloodLayerSpec[] = []; + const layerCodes = Object.values(GisApp.layerCode ?? {}) as any[]; + if (isFloodWtlvChecked) { + const baseLayerCode = layerCodes.find( + (item) => + item?.lyrTyNm === FLOOD_HISTORY_LAYER_TYPE && item?.actlLyrYn === "Y", + ); + if (baseLayerCode) { + desiredLayers.push({ + id: FLDNG_HSTRY_WFS_LAYER_ID, + layerType: FLOOD_HISTORY_LAYER_TYPE, + layerName: baseLayerCode?.lyrNm || FLOOD_HISTORY_LAYER_NAME, + baseLayerCode, + filterSql: resolveFloodFilter(baseLayerCode), + }); + } + } + + if (isFloodDepthChecked) { + const selectedDepthLayerKeys = new Set(checkedFloodDepthLayerKeys); + layerCodes + .filter((item) => { + const layerKey = String(item?.lyrSn ?? item?.id ?? "").trim(); + return ( + item?.lyrTyNm === FLOOD_HISTORY_DEPTH_LAYER_TYPE && + item?.actlLyrYn === "Y" && + selectedDepthLayerKeys.has(layerKey) + ); + }) + .forEach((baseLayerCode) => { + const layerKey = String( + baseLayerCode?.lyrSn ?? baseLayerCode?.id, + ).trim(); + desiredLayers.push({ + id: `${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_${layerKey}`, + layerType: FLOOD_HISTORY_DEPTH_LAYER_TYPE, + layerName: baseLayerCode?.lyrNm || FLOOD_HISTORY_DEPTH_LAYER_NAME, + baseLayerCode, + filterSql: resolveFloodFilter(baseLayerCode), + }); + }); + } + + const desiredLayerIds = new Set(desiredLayers.map((item) => item.id)); + const mapLayers = gisMaps.current?.getLayers?.()?.getArray?.() ?? []; + const isManagedFloodLayerId = (id: string) => + id === FLDNG_HSTRY_WFS_LAYER_ID || + id.startsWith(`${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_`); + + // 체크 해제된 등급만 삭제하고, 계속 체크된 등급은 지도에 그대로 유지한다. + mapLayers.forEach((mapLayer: any) => { + const layerId = String(mapLayer?.get?.("id") ?? ""); + if (isManagedFloodLayerId(layerId) && !desiredLayerIds.has(layerId)) { + core?.deleteLayer?.(layerId); + } + }); + + desiredLayers.forEach((spec) => { + const filterSignature = `${spec.layerType}|${spec.filterSql}`; + // 공통 getLayerById는 ID를 Number로 비교하므로 문자열 ID인 필터 레이어를 찾지 못한다. + // 지도 컬렉션에서 문자열 ID를 직접 비교해 기존 레이어와 WFS source를 재사용한다. + const existingLayer = gisMaps.current + ?.getLayers?.() + ?.getArray?.() + ?.find( + (mapLayer: any) => String(mapLayer?.get?.("id") ?? "") === spec.id, + ); + if (existingLayer?.get?.("floodFilterSignature") === filterSignature) { + existingLayer?.setVisible?.(true); + return; + } + + // 연도나 적용 여부가 달라진 레이어만 교체하고, 동일 필터 레이어는 재사용한다. + core?.deleteLayer?.(spec.id); + const layerCode = { + ...spec.baseLayerCode, + id: spec.id, + lyrSn: spec.id, + lyrNm: spec.layerName, + lyrTyNm: spec.layerType, + actlLyrYn: "Y", + exprsYn: "Y", + clickPsblYn: "Y", + cmncNm: "bbox", + srvcNm: "wfs", + flterSql: spec.filterSql, + }; + const layer = core?.addWfsLayer?.(layerCode); + layer?.set?.("id", spec.id); + layer?.set?.("lyrNm", spec.layerName); + layer?.set?.("lyrTyNm", spec.layerType); + layer?.set?.("floodFilterSignature", filterSignature); + }); + + // 전용 필터 레이어가 담당하는 유형의 원본 레이어만 숨겨 중복 표출을 막는다. + const activeFilteredTypes = new Set( + desiredLayers.map((item) => item.layerType), + ); + gisMaps.current + ?.getLayers?.() + ?.getArray?.() + ?.forEach((mapLayer: any) => { + const layerId = String(mapLayer?.get?.("id") ?? ""); + const layerType = String(mapLayer?.get?.("lyrTyNm") ?? ""); + if ( + activeFilteredTypes.has(layerType) && + !isManagedFloodLayerId(layerId) + ) { + mapLayer?.setVisible?.(false); + } + }); + }, [ + checkedFloodDepthLayerKeys, + clearFldngHstrySelection, + floodYearRange, + isFldngHstryChecked, + isFloodDepthChecked, + isFloodWtlvChecked, + isFloodYearFilterEnabled, + mapReady, + ]); + + // 페이지가 닫힐 때만 관리 대상 필터 레이어 전체를 정리한다. + useEffect(() => { return () => { - core?.deleteLayer?.(FLDNG_HSTRY_WFS_LAYER_ID); + const map = gisMaps.current; + const layers = [...(map?.getLayers?.()?.getArray?.() ?? [])]; + layers.forEach((layer: any) => { + const layerId = String(layer?.get?.("id") ?? ""); + if ( + layerId === FLDNG_HSTRY_WFS_LAYER_ID || + layerId.startsWith(`${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_`) + ) { + map?.removeLayer?.(layer); + } + }); }; - }, [isFldngHstryChecked, mapReady]); + }, []); const openAnalysisPopup = useCallback( (popupKey: WindowPopupKey) => { @@ -8605,10 +9107,14 @@ const Map = () => { removeExmnResultLayer("PPL"); return; } - void loadExmnResultLayer("PPL", exmnPplFilters); + void loadExmnResultLayer( + "PPL", + isExmnPplSettingEnabled ? exmnPplFilters : EMPTY_EXMN_MAP_FILTERS, + ); }, [ exmnPplFilters, isExmnPplChecked, + isExmnPplSettingEnabled, loadExmnResultLayer, mapReady, removeExmnResultLayer, @@ -8619,10 +9125,14 @@ const Map = () => { removeExmnResultLayer("MNHL"); return; } - void loadExmnResultLayer("MNHL", exmnMnhlFilters); + void loadExmnResultLayer( + "MNHL", + isExmnMnhlSettingEnabled ? exmnMnhlFilters : EMPTY_EXMN_MAP_FILTERS, + ); }, [ exmnMnhlFilters, isExmnMnhlChecked, + isExmnMnhlSettingEnabled, loadExmnResultLayer, mapReady, removeExmnResultLayer, @@ -8761,20 +9271,20 @@ const Map = () => {
- {buildWaterRiskStatusLabel( - selectedWaterRiskResultRow.riskStatus, - )} + {buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)}
- capacity 0.8 초과 관로 존재 시{" "} - 위험, 없으면{" "} - 정상 +
GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면
+
+ 위험, 없으면{" "} + 정상 +
@@ -8895,6 +9405,7 @@ const Map = () => { /> { isSinkholeRootNode(node) || isSbmrgDngrMapRootNode(node) || isFloodDngrMapRootNode(node) || - isFldngHstryNode(node) + isFldngHstryRootNode(node) } + layerLegendIconVisible={(node: any) => !isFldngHstryRootNode(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/basicTab.tsx b/apps/sn/app/(map-layout)/map/searchModal/basicTab.tsx index 7c21ac071..769c33036 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/basicTab.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/basicTab.tsx @@ -26,7 +26,8 @@ export const BASIC_TYPES = [ { val: "tb_ms_swage_ppl", text: "하수관로" }, { val: "tb_il_swage_prcs_fclt", text: "하수처리시설" }, { val: "tb_ms_swage_prcs_zone", text: "하수처리구역" }, - { val: "tb_ms_fldng_wtlv_map", text: "침수흔적도" }, + { val: "tb_ms_fldng_wtlv_map", text: "침수흔적위선" }, + { val: "tb_ms_fldng_dpth_map", text: "침수흔적심선" }, { val: "tb_ms_swage_mhl", text: "하수맨홀" }, { val: "tb_ms_swage_pmstn", text: "하수펌프장" }, { val: "tb_ms_stofcbr", text: "우수토실" }, 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/(map-layout)/map/waterDepth/waterDepth.tsx b/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx index 3c9cb48f5..266b4b59c 100644 --- a/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx +++ b/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx @@ -89,7 +89,9 @@ const hasLegendStyle = (layerInfo: any) => const normalizeLegendLabel = (label: string) => { const normalized = String(label).trim(); - return normalized.endsWith("미만") ? normalized : `${normalized} 미만`; + return /(?:미만|이상)$/.test(normalized) + ? normalized + : `${normalized} 미만`; }; export const extractWaterDepthLegendItems = ( diff --git a/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx b/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx index e984e6ce4..28d3b3efe 100644 --- a/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx +++ b/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx @@ -109,6 +109,7 @@ const BaseLayout = ({ onDeleteFile, onAnalysis, onResultView, + selectedRowData, isIr = false, isRdii = false, }: BaseLayout) => { @@ -283,11 +284,7 @@ const BaseLayout = ({ )} {resultViewBtn && ( - )} @@ -295,6 +292,7 @@ const BaseLayout = ({ )} {/* 그래프 있을 시 */} {graphBtn && ( - )} diff --git a/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx b/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx index 49d16f3ea..f42f040ee 100644 --- a/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx @@ -20,6 +20,7 @@ "use client"; import { deleteData, getData } from "@/components/lib/api"; import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; +import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { zodResolver } from "@hookform/resolvers/zod"; import { useState, useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -239,12 +240,18 @@ const Result = () => { } try { - const data = await getData( - `v1/rdiiAnlsRslt/ppl/${selectedRowData.rainInfwtAnlsSn}`, - ); + const data = await fetchAllPages>({ + fetchPage: (page, size) => + getData( + `v1/rdiiAnlsRslt/ppl/${selectedRowData.rainInfwtAnlsSn}?page=${page}&size=${size}`, + ), + getItems: (page) => + Array.isArray(page?.content) ? page.content : [], + getTotal: (page) => Number(page?.totalElements ?? 0), + }); - // 선택된 행 데이터를 Excel 다운로드 형식으로 변환 - const excelData = data.content; // 배열로 감싸서 전달 + // 선택된 분석의 전체 관로 결과를 Excel 다운로드 형식으로 변환한다. + const excelData = data.items; // 헤더 매핑 정의 const headerMapping: { [key: string]: string } = { @@ -258,7 +265,7 @@ const Result = () => { const fileName = `RDII분석결과_${selectedRowData.swtrmtFcltNm}_${selectedRowData.aplcnYm}_${selectedRowData.rainInfwtAnlsSn}`; await downloadExcelWithKoreanHeaders({ - data: excelData as JSON[], + data: excelData as unknown as JSON[], fileName: fileName, sheetNameArray: ["RDII 분석결과"], headerRange: "A1:D1", diff --git a/apps/sn/app/(with-base-layout)/ii/result/page.tsx b/apps/sn/app/(with-base-layout)/ii/result/page.tsx index fdabb783d..550bbca25 100644 --- a/apps/sn/app/(with-base-layout)/ii/result/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/result/page.tsx @@ -20,6 +20,7 @@ "use client"; import { deleteData, getData } from "@/components/lib/api"; import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; +import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { zodResolver } from "@hookform/resolvers/zod"; import { useState, useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -239,12 +240,18 @@ const Result = () => { } try { - const data = await getData( - `v1/inftwtAnlsRslt/ppl/${selectedRowData.inftwtAnlsSn}`, - ); + const data = await fetchAllPages>({ + fetchPage: (page, size) => + getData( + `v1/inftwtAnlsRslt/ppl/${selectedRowData.inftwtAnlsSn}?page=${page}&size=${size}`, + ), + getItems: (page) => + Array.isArray(page?.content) ? page.content : [], + getTotal: (page) => Number(page?.totalElements ?? 0), + }); - // 선택된 행 데이터를 Excel 다운로드 형식으로 변환 - const excelData = data.content; // 배열로 감싸서 전달 + // 선택된 분석의 전체 관로 결과를 Excel 다운로드 형식으로 변환한다. + const excelData = data.items; // 헤더 매핑 정의 const headerMapping: { [key: string]: string } = { @@ -258,7 +265,7 @@ const Result = () => { const fileName = `침입수분석결과_${selectedRowData.swtrmtFcltNm}_${selectedRowData.aplcnYm}_${selectedRowData.inftwtAnlsSn}`; await downloadExcelWithKoreanHeaders({ - data: excelData as JSON[], + data: excelData as unknown as JSON[], fileName: fileName, sheetNameArray: ["침입수 분석결과"], headerRange: "A1:D1", diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx index 62e7d8f86..818a26f5a 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx +++ b/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx @@ -25,7 +25,10 @@ import { uploadDredgingPhotoDrafts, type AttachmentReference, } from "../lib/client"; +import { formatDredgingCapturedDate } from "../lib/date"; import { readPhotoMetadata } from "../lib/exif"; +import { useDredgingCommonCodes } from "../lib/useDredgingCommonCodes"; +import { createDredgingClientUuid } from "../lib/uuid"; import { EMPTY_PHOTO_METADATA, type DredgingPhoto, @@ -67,11 +70,6 @@ const resolvePipeMaterialName = (value: string) => { return pipeMaterialNameByCode.get(normalizedCode) ?? "확인불가"; }; -const locationSourceLabel = { - EXIF: "사진 GPS", - MANUAL: "지도 지정", -} as const; - const formatLocalDate = (date: Date) => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); @@ -89,6 +87,9 @@ const DredgingDetailPopupPage = ({ }: Props) => { const alert = useAlertStore((state) => state.setMessage); const { confirm } = useConfirm(); + // 수정 화면도 등록 화면과 같은 CMS 공통코드 표시명을 공유한다. + const { registrationModeLabels, locationSourceLabels } = + useDredgingCommonCodes(); const editable = mode === "edit"; const [photos, setPhotos] = useState([]); const [newPhotos, setNewPhotos] = useState([]); @@ -235,7 +236,7 @@ const DredgingDetailPopupPage = ({ ? `${matchedPipe.installationYear}년` : "확인불가", ], - ["위치등록", locationSourceLabel[locationSource]], + ["위치등록", locationSourceLabels[locationSource] || locationSource], ]; const pipeInfoRows = Array.from( { length: Math.ceil(pipeInfo.length / 2) }, @@ -259,7 +260,7 @@ const DredgingDetailPopupPage = ({ const previewUrl = URL.createObjectURL(file); newPhotoUrlsRef.current.push(previewUrl); return { - key: crypto.randomUUID(), + key: createDredgingClientUuid(), file, metadata: EMPTY_PHOTO_METADATA, previewUrl, @@ -574,7 +575,7 @@ const DredgingDetailPopupPage = ({ onClick={() => void changeRegistrationMode("PHOTO")} data-testid="dredging-edit-photo-mode" > - 사진 기반 등록 + {registrationModeLabels.PHOTO} ) : null} @@ -657,7 +658,8 @@ const DredgingDetailPopupPage = ({ `준설사진 ${index + 1}`} - {photo.capturedAt ?? "촬영일시 없음"} + {formatDredgingCapturedDate(photo.capturedAt) ?? + "촬영일시 없음"} @@ -704,7 +706,9 @@ const DredgingDetailPopupPage = ({ 신규 · {photo.file.name} - {photo.metadata.capturedAt ?? "촬영일시 없음"} + {formatDredgingCapturedDate( + photo.metadata.capturedAt, + ) ?? "촬영일시 없음"} diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx index 6ab628f74..c79652681 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx @@ -29,7 +29,10 @@ import { type AttachmentReference, uploadDredgingPhotos, } from "../lib/client"; +import { formatDredgingCapturedDate } from "../lib/date"; import { readPhotoMetadata } from "../lib/exif"; +import { useDredgingCommonCodes } from "../lib/useDredgingCommonCodes"; +import { createDredgingClientUuid } from "../lib/uuid"; import { EMPTY_PHOTO_METADATA, type DredgingDraftGroup, @@ -51,7 +54,7 @@ type DredgingRegisterModalProps = { }; const createGroup = (): DredgingDraftGroup => ({ - id: crypto.randomUUID(), + id: createDredgingClientUuid(), registrationMode: "PHOTO", photos: [], manualLocation: null, @@ -72,14 +75,6 @@ const getResolvedLocation = (group: DredgingDraftGroup): GeoPoint | null => { ); }; -const statusLabel = (status: DredgingPhotoDraft["metadata"]["status"]) => { - if (status === "COMPLETE") return "정상"; - if (status === "MISSING_DATE") return "촬영일 없음"; - if (status === "MISSING_GPS") return "GPS 없음"; - if (status === "MISSING_BOTH") return "촬영일·GPS 없음"; - return "수동 입력 필요"; -}; - const formatLocalDate = (date: Date) => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); @@ -97,6 +92,12 @@ const DredgingRegisterModal = ({ }: DredgingRegisterModalProps) => { const alert = useAlertStore((state) => state.setMessage); const { confirm } = useConfirm(); + // 등록방식·위치출처·사진상태 코드값은 유지하고 CMS 코드명을 화면에 표시한다. + const { + registrationModeLabels, + locationSourceLabels, + photoStatusLabels, + } = useDredgingCommonCodes(); const [groups, setGroups] = useState([]); const groupsRef = useRef([]); const [activeGroupId, setActiveGroupId] = useState(null); @@ -253,7 +254,7 @@ const DredgingRegisterModal = ({ } const checkingPhotos: DredgingPhotoDraft[] = files.map((file) => ({ - key: crypto.randomUUID(), + key: createDredgingClientUuid(), file, metadata: EMPTY_PHOTO_METADATA, })); @@ -704,7 +705,7 @@ const DredgingRegisterModal = ({ void changeRegistrationMode(activeGroup.id, "PHOTO") } > - 사진 기반 등록 + {registrationModeLabels.PHOTO} @@ -762,9 +763,15 @@ const DredgingRegisterModal = ({ - {photo.metadata.capturedAt ?? "-"} + {formatDredgingCapturedDate( + photo.metadata.capturedAt, + ) ?? "-"} {photo.metadata.location @@ -779,7 +786,9 @@ const DredgingRegisterModal = ({ : "bg-amber-100 text-amber-800" }`} > - {statusLabel(photo.metadata.status)} + {photoStatusLabels[ + photo.metadata.status + ] || "수동 입력 필요"} @@ -882,9 +891,9 @@ const DredgingRegisterModal = ({ {hasUnappliedLocationDraft ? "새 위치 선택됨" : activeGroup.manualLocation - ? "지도 지정 위치" + ? `${locationSourceLabels.MANUAL} 위치` : activeResolvedLocation - ? "사진 GPS 위치" + ? `${locationSourceLabels.EXIF} 위치` : "위치 지정 필요"} diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/client.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/client.ts index f5b5b17e0..4dcb1a1f0 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/lib/client.ts +++ b/apps/sn/app/(with-base-layout)/oper/dredging/lib/client.ts @@ -6,6 +6,7 @@ import { putData, } from "@/components/lib/api"; import { handleUpload } from "@/components/lib/utils"; +import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import type { HttpResponse } from "tus-js-client"; import type { @@ -17,6 +18,7 @@ import type { GeoPoint, PipeMatch, } from "./types"; +import { createDredgingClientUuid } from "./uuid"; type LocationRequest = GeoPoint & { locationKey: string; @@ -121,6 +123,19 @@ export const fetchDredgingRecords = async (params: { } }; +/** 조회조건에 맞는 준설기록 전체 페이지를 순회해 목록·Excel 건수를 일치시킨다. */ +export const fetchAllDredgingRecords = async (params: { + fromDate: string; + toDate: string; + ctpv?: string; + sgg?: string; +}) => + fetchAllPages({ + fetchPage: (page, size) => fetchDredgingRecords({ ...params, page, size }), + getItems: (response) => response.items ?? [], + getTotal: (response) => Number(response.total ?? 0), + }); + /** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */ export const fetchDredgingPhotos = async ( dredgingJobSn: string | number, @@ -388,11 +403,11 @@ export const saveDredgingRegistrations = async ( return { mock: true, registeredCount: 1, - clientRequestId: crypto.randomUUID(), + clientRequestId: createDredgingClientUuid(), }; } - const clientRequestId = crypto.randomUUID(); + const clientRequestId = createDredgingClientUuid(); try { return await postRawJson("v1/dredging/registrations", { clientRequestId, diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/date.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/date.ts new file mode 100644 index 000000000..011944168 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/dredging/lib/date.ts @@ -0,0 +1,21 @@ +/** 촬영일시 원본은 유지하고 등록·수정 화면 표출값만 YYYY-MM-DD로 정규화한다. */ +export const formatDredgingCapturedDate = ( + value: string | null | undefined, +) => { + const normalized = String(value ?? "").trim(); + const matched = normalized.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!matched) return null; + + const year = Number(matched[1]); + const month = Number(matched[2]); + const day = Number(matched[3]); + const date = new Date(year, month - 1, day); + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return null; + } + return matched[0]; +}; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/useDredgingCommonCodes.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/useDredgingCommonCodes.ts new file mode 100644 index 000000000..bf64d139a --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/dredging/lib/useDredgingCommonCodes.ts @@ -0,0 +1,135 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { getCommonCodes } from "@/components/lib/common/apiHelpers"; + +import type { + DredgingLocationSource, + DredgingRegistrationMode, + ExifStatus, +} from "./types"; + +type CodeLabelMap = Record; + +const COMMON_CODE_GROUPS = { + registrationMode: "DRDGNREGMODE", + locationSource: "DRDGNLOCSRC", + photoStatus: "DRDGNPHOTOSTTS", +} as const; + +const DEFAULT_REGISTRATION_MODE_LABELS: Record< + DredgingRegistrationMode, + string +> = { + PHOTO: "사진 기반 등록", + MANUAL: "사진 없이 직접 등록", +}; + +const DEFAULT_LOCATION_SOURCE_LABELS: Record = { + EXIF: "사진 GPS", + MANUAL: "지도 지정", +}; + +const DEFAULT_PHOTO_STATUS_LABELS: Record = { + COMPLETE: "정상", + MISSING_DATE: "촬영일 없음", + MISSING_GPS: "GPS 없음", + MISSING_BOTH: "촬영일·GPS 없음", + UNSUPPORTED: "수동 입력 필요", +}; + +const cachedLabelMaps = new Map(); +const pendingLabelMaps = new Map>(); + +/** 같은 브라우저 세션에서는 준설 코드그룹별 CMS 요청을 한 번만 수행한다. */ +const loadCodeLabelMap = async ( + upCd: string, + fallback: CodeLabelMap, +): Promise => { + const cached = cachedLabelMaps.get(upCd); + if (cached) return cached; + + const pending = pendingLabelMaps.get(upCd); + if (pending) return pending; + + const request = getCommonCodes(upCd) + .then((codes) => { + const labels = { ...fallback }; + codes.forEach((code) => { + if (code.lwpoCd && code.lwpoCdNm) { + labels[code.lwpoCd] = code.lwpoCdNm; + } + }); + cachedLabelMaps.set(upCd, labels); + return labels; + }) + .finally(() => { + pendingLabelMaps.delete(upCd); + }); + + pendingLabelMaps.set(upCd, request); + return request; +}; + +/** 준설 저장 코드값은 유지하고 버튼·상세·사진상태 표시명만 CMS 기준으로 제공한다. */ +export const useDredgingCommonCodes = () => { + const [registrationModeLabels, setRegistrationModeLabels] = + useState( + cachedLabelMaps.get(COMMON_CODE_GROUPS.registrationMode) ?? + DEFAULT_REGISTRATION_MODE_LABELS, + ); + const [locationSourceLabels, setLocationSourceLabels] = + useState( + cachedLabelMaps.get(COMMON_CODE_GROUPS.locationSource) ?? + DEFAULT_LOCATION_SOURCE_LABELS, + ); + const [photoStatusLabels, setPhotoStatusLabels] = useState( + cachedLabelMaps.get(COMMON_CODE_GROUPS.photoStatus) ?? + DEFAULT_PHOTO_STATUS_LABELS, + ); + + useEffect(() => { + let cancelled = false; + + const applyLabels = ( + upCd: string, + fallback: CodeLabelMap, + setter: (labels: CodeLabelMap) => void, + ) => { + loadCodeLabelMap(upCd, fallback) + .then((labels) => { + if (!cancelled) setter(labels); + }) + .catch((error) => { + console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error); + }); + }; + + applyLabels( + COMMON_CODE_GROUPS.registrationMode, + DEFAULT_REGISTRATION_MODE_LABELS, + setRegistrationModeLabels, + ); + applyLabels( + COMMON_CODE_GROUPS.locationSource, + DEFAULT_LOCATION_SOURCE_LABELS, + setLocationSourceLabels, + ); + applyLabels( + COMMON_CODE_GROUPS.photoStatus, + DEFAULT_PHOTO_STATUS_LABELS, + setPhotoStatusLabels, + ); + + return () => { + cancelled = true; + }; + }, []); + + return { + registrationModeLabels, + locationSourceLabels, + photoStatusLabels, + }; +}; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/uuid.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/uuid.ts new file mode 100644 index 000000000..eb47a4613 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/dredging/lib/uuid.ts @@ -0,0 +1,31 @@ +/** + * 일반 HTTP에서는 `crypto.randomUUID`가 제공되지 않을 수 있어 Web Crypto 난수로 UUID v4를 생성한다. + * 백엔드 `clientRequestId`가 UUID 타입이므로 fallback도 같은 36자 형식을 유지한다. + */ +export const createDredgingClientUuid = () => { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { + return crypto.randomUUID(); + } + + const bytes = new Uint8Array(16); + if ( + typeof crypto !== "undefined" && + typeof crypto.getRandomValues === "function" + ) { + crypto.getRandomValues(bytes); + } else { + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256); + } + } + + bytes[6] = (bytes[6]! & 0x0f) | 0x40; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = Array.from(bytes, (value) => + value.toString(16).padStart(2, "0"), + ).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +}; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx index 0572571d6..aa9e18396 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx @@ -3,13 +3,18 @@ * * - SFR-010-012 화면설계서 50~55쪽을 기준으로 한 FE 선행 구현 * - 사진 EXIF 촬영일/GPS, 지도 수동 위치, 최근접 관로 자동 매핑 흐름 제공 - * - 지자체별/관경별/경과년도별 조회와 CSV 다운로드 제공 + * - 지자체별/관경별/경과년도별 조회와 XLSX 다운로드 제공 */ "use client"; import LoadingOverlay from "@/components/ui/LoadingBar"; import { useAlertStore } from "@/shared/lib"; -import { StandardDataTable } from "@common/business_components"; +import { + StandardDataTable, + StandardTableSortHeader, + sortStandardTableRows, + type UiTableSort, +} from "@common/business_components"; import { Button, InputDatePicker, @@ -35,9 +40,10 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { deleteDredgingRecord, - fetchDredgingRecords, + fetchAllDredgingRecords, isDredgingBackendEnabled, } from "./lib/client"; +import { createDredgingClientUuid } from "./lib/uuid"; import type { DredgingRecord, DredgingSaveGroup, @@ -343,6 +349,8 @@ const Page = () => { // 삭제 중 중복 요청과 선택 변경을 막는다. const [isMutating, setIsMutating] = useState(false); const [currentPage, setCurrentPage] = useState(1); + // 조회구분별 전체 결과를 페이지 절단 전에 정렬하는 단일 상태다. + const [sort, setSort] = useState(); const [searchVersion, setSearchVersion] = useState(0); /** 공통 레이아웃에서 아직 지역 기준정보를 받지 못한 경우 조회를 보완한다. */ @@ -368,12 +376,11 @@ const Page = () => { setIsLoading(true); setLoadError(null); try { - const response = await fetchDredgingRecords({ + const response = await fetchAllDredgingRecords({ fromDate: appliedFromDate, toDate: appliedToDate, ctpv: appliedCtpvName, sgg: appliedSggName, - size: 2000, }); if (!cancelled) setRecords(response.items ?? []); } catch { @@ -431,10 +438,19 @@ const Page = () => { })); }, [filteredRecords, viewType]); + // 준설 API의 모든 페이지를 수집한 뒤 표시명·집계값 기준으로 정렬한다. + const sortedTableRows = useMemo( + () => sortStandardTableRows(tableRows, sort), + [sort, tableRows], + ); + const pageRows = useMemo( () => - tableRows.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE), - [tableRows, currentPage], + sortedTableRows.slice( + (currentPage - 1) * PAGE_SIZE, + currentPage * PAGE_SIZE, + ), + [currentPage, sortedTableRows], ); const selectedRecord = @@ -461,13 +477,24 @@ const Page = () => { headlist.map((head) => ({ id: head.id, accessorFn: (row) => row[head.id], - header: head.title, + header: () => ( + { + setSort(nextSort); + setCurrentPage(1); + setSelectedRecordId(null); + }} + /> + ), cell: ({ getValue }) => formatTableValue(getValue()), meta: { align: head.align ?? "center", }, })), - [headlist], + [headlist, sort], ); const pagination: PaginationState = { pageIndex: currentPage - 1, @@ -486,7 +513,7 @@ const Page = () => { pagination, rowSelection: selectedTableRowId ? { [selectedTableRowId]: true } : {}, }, - rowCount: tableRows.length, + rowCount: sortedTableRows.length, manualPagination: true, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.recordIds.join("|"), @@ -524,6 +551,7 @@ const Page = () => { setSggName(""); setAppliedCtpvName(""); setAppliedSggName(""); + setSort(undefined); setCurrentPage(1); setSelectedRecordId(null); setSearchVersion((version) => version + 1); @@ -534,7 +562,7 @@ const Page = () => { const visibleHeads = headlist.filter((head) => head.id !== "recordIds"); const excelRows = [ visibleHeads.map((head) => head.title), - ...tableRows.map((row) => + ...sortedTableRows.map((row) => visibleHeads.map((head) => row[head.id] ?? ""), ), ]; @@ -565,7 +593,7 @@ const Page = () => { return; } const registeredDate = formatLocalDate(new Date()); - const localJobSn = `local-job-${crypto.randomUUID()}`; + const localJobSn = `local-job-${createDredgingClientUuid()}`; const addedRecords = savedGroups.map((group, index) => ({ id: `${localJobSn}-${index + 1}`, dredgingJobSn: localJobSn, @@ -727,6 +755,7 @@ const Page = () => { value={viewType} onValueChange={(value) => { setViewType(value as DredgingViewType); + setSort(undefined); setCurrentPage(1); setSelectedRecordId(null); }} diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx index 87cc92d8b..273453c0a 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx @@ -342,7 +342,12 @@ const Page = () => { - diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx index 40e707bac..8a8e3781f 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx @@ -317,7 +317,12 @@ const Page = () => { - diff --git a/apps/sn/app/components/lib/common/apiHelpers.ts b/apps/sn/app/components/lib/common/apiHelpers.ts index 8f9abbf08..9094b75de 100644 --- a/apps/sn/app/components/lib/common/apiHelpers.ts +++ b/apps/sn/app/components/lib/common/apiHelpers.ts @@ -12,7 +12,47 @@ * 🔗 사용 API * - 하위코드페이지 캐시 목록 조회: GET /api/v1/code/cache/{upCd} (cms-service) */ -import { getData } from "../api"; +import { getCmsData, getData } from "../api"; + +/** CMS 공통코드 캐시 API가 반환하는 하위코드의 공통 계약이다. */ +export type CommonCodeItem = { + lwpoCd: string; + upCd: string; + lwpoCdNm: string; + unit: string; + prntLwpoCd: string | null; +}; + +/** + * 기존 CMS 캐시 조회 URL을 유지하면서 상위코드에 속한 하위코드를 조회한다. + * 화면이 잘못된 응답을 정상 코드 목록으로 사용하지 않도록 배열 계약을 확인한다. + */ +export const getCommonCodes = async ( + upCd: string, +): Promise => { + const normalizedUpCd = upCd.trim(); + if (!normalizedUpCd) return []; + + const data = await getCmsData( + `/api/v1/code/cache/${encodeURIComponent(normalizedUpCd)}`, + ); + if (!Array.isArray(data)) { + throw new Error(`공통코드 응답 형식이 올바르지 않습니다: ${normalizedUpCd}`); + } + + return data + .map((item: any) => ({ + lwpoCd: String(item?.lwpoCd ?? "").trim(), + upCd: String(item?.upCd ?? normalizedUpCd).trim(), + lwpoCdNm: String(item?.lwpoCdNm ?? "").trim(), + unit: String(item?.unit ?? "").trim(), + prntLwpoCd: + item?.prntLwpoCd === null || item?.prntLwpoCd === undefined + ? null + : String(item.prntLwpoCd).trim(), + })) + .filter((item) => item.lwpoCd.length > 0); +}; export const searchData = async>(url: string, paramObj: T) => { const params = new URLSearchParams( @@ -29,10 +69,10 @@ export const searchData = async>(url: string, para // 상위코드로 하위코드 조회 export const searchCmmnCd = async (upCd: string) => { - const data = await getData(`/cms-service/api/v1/code/cache/${upCd}`); + const data = await getCommonCodes(upCd); const filteredData = data.map((item: any) => ({ lwpoCd: item.lwpoCd, lwpoCdNm: item.lwpoCdNm, })); return filteredData; -} \ No newline at end of file +}; diff --git a/apps/sn/app/components/ui/verticalTable.tsx b/apps/sn/app/components/ui/verticalTable.tsx index 3a012de61..f2685d98c 100644 --- a/apps/sn/app/components/ui/verticalTable.tsx +++ b/apps/sn/app/components/ui/verticalTable.tsx @@ -165,7 +165,9 @@ export function VerticalTable({ <> {headName ? (
-

{headName}

+

+ {headName} +

{showSaveButton && onSaveClick && (
` 단위 `rowClick`, 80×32px 실제 button 탭, 상세 샘플 10행 적용 | -| 2026-07-23 | 조사현황 사용자 4차 재확인 | 정밀조사 연장 우측 일부부터 보고서 영역만 선택되고 앞쪽 셀은 클릭되지 않아 공통 `rowClick` 방식 실패 판정 | -| 2026-07-23 | 조사현황 4차 클릭 구조 교체 | 목록을 공통 Table 스타일의 화면 전용 표로 교체하고 모든 데이터 셀을 전체 면적 button으로 구성, 공통 `rowClick` 제거 | -| 2026-07-23 | 조사관리 상세현황 사용자 최종 확인 | 전체 행 선택, 관로·맨홀 탭, 목록·상세 배치와 상세 집계 표시 확인 완료. 조사관리 상세현황 화면 단계 완료 판정 | -| 2026-07-23 | git status·문서 정합성 점검 | 추적 코드 8개·신규 코드 15개와 FE 문서 4개를 대조하고 작업카드·구현·검증 문서의 단계 상태와 실제 파일 목록을 동기화 | -| 2026-07-23 | 사업보고서 다운로드 정적 확인 | 첨부 ID가 있는 보고서 셀의 파란 밑줄 링크, 행 클릭 전파 차단, 기존 첨부서비스 Blob 다운로드, 샘플 미연결 안내 확인 | -| 2026-07-27 | FE-0 신규 행 인라인 입력 정적 확인 | 변경상태 표시 열 제거, 선택 행정구역 신규 행 생성, 처리분구명 셀 입력, 내부 CREATE 상태 유지 확인 | -| 2026-07-27 | FE-0 기존 행 인라인 수정 정적 확인 | 선택 행 수정 버튼, 처리분구명 셀 입력 전환, 원본 대비 `modified/clean` 상태 복원, 수정 모달 제거 확인 | -| 2026-07-27 | FE-0 최초 자동 조회 정적 확인 | 최초 Effect에서 조건 없는 활성 목록 조회, 개발 모드 Effect 재실행 중복 요청 방지 ref 확인 | -| 2026-07-27 | FE-0 저장 후 목록 유지 정적 확인 | 임시 선택값과 적용 조회조건 분리, 성공 즉시 저장 상태 정규화·삭제 행 제거, 적용 조건 서버 재조회 확인 | -| 2026-07-27 | FE-0 실 API CRUD 사용자 확인 | 최초·조건 조회, 그리드 추가·수정, 무참조 삭제와 저장 후 최신 목록 표시까지 정상 동작 확인 | -| 2026-07-27 | FE-0 TypeScript 재실행 | pnpm 비대화형 모듈 정리 중단 및 로컬 compiler 파일 `EPERM`으로 결과 미확보 | +| 날짜 | 검증 | 결과 | +| ---------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-07-22 | `git diff --check` | 통과, 공백 오류 없음 | +| 2026-07-22 | `tsc --noEmit` 기본 실행 | 저장소 기존 `ignoreDeprecations=6.0` 값 오류 확인 | +| 2026-07-22 | `tsc --noEmit --ignoreDeprecations 5.0` | 신규 `trtdst` 파일 오류 0건, 저장소 선행 오류 존재 | +| 2026-07-22 | Next 프로덕션 빌드 | 실행 권한 승인 거절로 미수행 | +| 2026-07-22 | 브라우저/API 통합 | 백엔드·메뉴 원천 미구현으로 미수행 | +| 2026-07-22 | FE-0 import 경계 정적 확인 | 라우트가 client UI 파일을 직접 import하고 대상 파일 존재·`git diff --check` 통과 | +| 2026-07-22 | FE-0 Next 개발 서버 재컴파일 | 실행 권한이 허용되지 않아 미수행, 사용자 재진입 확인 대기 | +| 2026-07-22 | FE-0 메뉴 진입 | 사용자 화면 확인으로 Next Hook 경계 오류 해소와 화면 렌더링 통과 | +| 2026-07-22 | FE-0 UI 마무리 정적 확인 | 공통 정렬 props·정렬 표시 목록 기반 선택·12열 필터 폭·버튼 공통 클래스 확인, UTF-8·`git diff --check` 통과 | +| 2026-07-22 | FE-0 UI 마무리 브라우저 확인 | 3002 개발 서버 미실행으로 사용자 재확인 대기 | +| 2026-07-22 | FE-0 UI 1차 사용자 재확인 | 시도·시군구 간격 미적용, 조회 버튼이 저장 버튼보다 약 `0.6rem` 왼쪽임을 캡처로 확인 | +| 2026-07-22 | FE-0 UI 2차 정적 확인 | `.next` 생성 CSS에 `gap-10`·`col-span-12`가 없음을 확인하고 인라인 `2rem` 간격·고정 폭·조회 버튼 `0.6rem` 이동 적용, UTF-8·공백 검사 통과 | +| 2026-07-22 | FE-0 최종 사용자 확인 | 처리분구관리 화면과 2차 UI 보정이 정상임을 사용자가 확인, 화면 단계 완료 판정 | +| 2026-07-22 | FE-1 import·Hook·API 경로 정적 검사 | 상대 import 누락과 client 경계 누락 없음, 승인된 SFR-010-004 API 경로 확인 | +| 2026-07-22 | FE-1 공백·한글 검사 | 신규/수정 대상 후행 공백 없음, 한글 본문 UTF-8 재조회 정상 | +| 2026-07-22 | FE-1 `git diff --check` | 추적 파일 통과, 신규 파일은 별도 후행 공백 검사 통과 | +| 2026-07-22 | FE-1 TypeScript 실행 | pnpm 비대화형 의존성 정리 중단 및 compiler 파일 샌드박스 `EPERM`으로 결과 미확보 | +| 2026-07-22 | FE-1 Tailwind 경로 확인 | 기존 content에 `src/features`가 없음을 확인하고 `./src/**/*` 추가 | +| 2026-07-22 | FE-1 브라우저·실 API | 3002 서버 미실행·백엔드 미구현으로 사용자 화면 확인 및 통합 단계 대기 | +| 2026-07-22 | FE-1 사용자 1차 화면 확인 | 연도·지역 선택 컴포넌트, 사업명 폭, 상단 영역 분리, 불필요 PPL 버튼, 목록 열 순서 보완 요청 확인 | +| 2026-07-22 | FE-1 사용자 1차 보정 정적 확인 | 기존 연도 옵션·I/I 지역 선택 재사용, 상단 단일 카드, PPL 버튼 제거, 9개 열 순서·단위 반영 | +| 2026-07-22 | FE-1 WindowPopup 전환 정적 확인 | popup key·registry·content·부모 메시지, 처리분구 가나다순 체크 선택, 결과 파일 5종 확인 | +| 2026-07-22 | FE-1 샘플 경로 Next 개발 서버 확인 | `/oper/exmn/register?sample=Y`, 사업등록 popup, 결과등록 popup 모두 HTTP 200 | +| 2026-07-22 | FE-1 사업등록 2차 보정 | popup `920×620`, 5개 입력, 버튼 구성·green 스타일, client mount hydration 방어 정적 확인 | +| 2026-07-22 | FE-1 샘플 전환·라우트 재확인 | 일반 목록, 샘플 목록, 사업등록 샘플 popup, 결과등록 샘플 popup 모두 HTTP 200 | +| 2026-07-22 | FE-1 사업등록 3차 배치 보정 | 정밀조사연장 첫 줄 이동, 처리분구 높이 `20rem` 확장, 샘플 배지 제거 및 popup HTTP 200 확인 | +| 2026-07-22 | FE-1 사업등록 4차 배치 보정 | 정밀조사연장 한 줄·입력 `7rem`, 처리분구 높이 `21rem`, 샘플 21개 및 초과 스크롤 구조 확인 | +| 2026-07-22 | FE-1 팝업 5차 보정 | 연장 스피너 제거·최대 10자, 결과 popup `980×620`, 4개 사업요약·경계선 table, 샘플 배지 제거 및 두 popup HTTP 200 | +| 2026-07-22 | FE-1 결과 popup 6차 보정 | `980×460`, 닫기 제거, 강한 격자 제거, 창 스크롤 차단·본문 조건부 스크롤 및 HTTP 200 확인 | +| 2026-07-22 | FE-1 결과 popup 7차 보정 | html/body 문서 스크롤 잠금·복원, 업로드 행 높이 확대 및 HTTP 200 정적 확인 | +| 2026-07-22 | FE-1 결과 popup 최종 크기 보정 | 두 캡처 외곽 높이 차이를 반영해 definition `980×340`으로 조정 | +| 2026-07-22 | FE-1 결과 popup 최종 UI 보정 | 헤더·행 `gap-3` 통일, 선택 파일명 파란색·굵게 표시 및 HTTP 200 확인 | +| 2026-07-22 | FE-1 결과 사업요약 순서 보정 | 사업차수→사업년도→행정구역→사업명, 비율 열·`gap-8` 적용 | +| 2026-07-22 | FE-1 사업·결과 WindowPopup 사용자 확인 | 사업등록과 조사결과 업로드 최종 UI 완료 판정 | +| 2026-07-23 | 조사현황 목록·상세 스크롤 보정 정적 확인 | 가로 여백 `1.2rem`, 전체 셀 행 선택, 양쪽 내부 스크롤 및 목록 페이지네이션 제거 확인 | +| 2026-07-23 | 변경 파일 공백·UTF-8 검사 | `git diff --check` 통과, 대상 코드·문서 UTF-8 확인 | +| 2026-07-23 | 조사현황 브라우저 재검증 | 3002 개발 서버 미실행으로 미수행 | +| 2026-07-23 | 조사현황 사용자 재확인 | 목록·상세 실제 가로 경계 불일치, 상세 하단 여백, 일부 열만 클릭되는 문제 확인 | +| 2026-07-23 | 조사현황 2차 구조 보정 | 동일 외부 스크롤 래퍼·stable gutter, 상세 남은 높이 사용, 전체 열 기존 `cellClick` 적용 | +| 2026-07-23 | 조사현황 사용자 3차 재확인 | 목록 3행에서도 스크롤·하단선 잘림, 행·탭의 시각 영역과 실제 클릭 영역 불일치 확인 | +| 2026-07-23 | 조사현황 3차 구조 보정 | 목록 `18rem`, 공통 표 `` 단위 `rowClick`, 80×32px 실제 button 탭, 상세 샘플 10행 적용 | +| 2026-07-23 | 조사현황 사용자 4차 재확인 | 정밀조사 연장 우측 일부부터 보고서 영역만 선택되고 앞쪽 셀은 클릭되지 않아 공통 `rowClick` 방식 실패 판정 | +| 2026-07-23 | 조사현황 4차 클릭 구조 교체 | 목록을 공통 Table 스타일의 화면 전용 표로 교체하고 모든 데이터 셀을 전체 면적 button으로 구성, 공통 `rowClick` 제거 | +| 2026-07-23 | 조사관리 상세현황 사용자 최종 확인 | 전체 행 선택, 관로·맨홀 탭, 목록·상세 배치와 상세 집계 표시 확인 완료. 조사관리 상세현황 화면 단계 완료 판정 | +| 2026-07-23 | git status·문서 정합성 점검 | 추적 코드 8개·신규 코드 15개와 FE 문서 4개를 대조하고 작업카드·구현·검증 문서의 단계 상태와 실제 파일 목록을 동기화 | +| 2026-07-23 | 사업보고서 다운로드 정적 확인 | 첨부 ID가 있는 보고서 셀의 파란 밑줄 링크, 행 클릭 전파 차단, 기존 첨부서비스 Blob 다운로드, 샘플 미연결 안내 확인 | +| 2026-07-27 | FE-0 신규 행 인라인 입력 정적 확인 | 변경상태 표시 열 제거, 선택 행정구역 신규 행 생성, 처리분구명 셀 입력, 내부 CREATE 상태 유지 확인 | +| 2026-07-27 | FE-0 기존 행 인라인 수정 정적 확인 | 선택 행 수정 버튼, 처리분구명 셀 입력 전환, 원본 대비 `modified/clean` 상태 복원, 수정 모달 제거 확인 | +| 2026-07-27 | FE-0 최초 자동 조회 정적 확인 | 최초 Effect에서 조건 없는 활성 목록 조회, 개발 모드 Effect 재실행 중복 요청 방지 ref 확인 | +| 2026-07-27 | FE-0 저장 후 목록 유지 정적 확인 | 임시 선택값과 적용 조회조건 분리, 성공 즉시 저장 상태 정규화·삭제 행 제거, 적용 조건 서버 재조회 확인 | +| 2026-07-27 | FE-0 실 API CRUD 사용자 확인 | 최초·조건 조회, 그리드 추가·수정, 무참조 삭제와 저장 후 최신 목록 표시까지 정상 동작 확인 | +| 2026-07-27 | FE-0 TypeScript 재실행 | pnpm 비대화형 모듈 정리 중단 및 로컬 compiler 파일 `EPERM`으로 결과 미확보 | ## 3. FE-0 검증 결과 @@ -246,19 +246,19 @@ ## 17. 2026-07-28 조사사업 실제 API·양식 다운로드 검증 결과 -| 검증 항목 | 결과 | 비고 | -| --- | --- | --- | -| 페이지 최초 조사사업 조회 | 통과 | 사용자 로그인 화면 확인 | -| 조사사업 등록 후 목록 갱신 | 통과 | 저장 결과 즉시 표시 | -| 등록 후 상세현황 자동 열림 방지 | 통과 | 목록만 갱신 | -| 기존 사업 수정 팝업 정보 복원 | 통과 | 기본정보·처리분구 복원 | -| 정밀조사연장 숫자 입력 | 통과 | 입력·저장 확인 | -| 처리분구별 조사 입력양식 다운로드 | 통과 | 다중 시트 생성 확인 | -| 본단위등급표 동적 관로번호 | 통과 | `#REF!` 없이 표시 | -| 첫 처리분구만 예시 데이터 유지 | 통과 | 이후 시트는 빈 입력 양식 | -| 이후 관로조사 글꼴 검정색 | 통과 | 사용자 최종 확인 | -| 결과 미등록 상세현황 | 통과 | 오류 배너 없이 조회 결과 없음 표시 | -| 조사보고서 양식 최종 클릭 | 확인 필요 | API와 프론트 호출 구현 완료 | +| 검증 항목 | 결과 | 비고 | +| --------------------------------- | --------- | ---------------------------------- | +| 페이지 최초 조사사업 조회 | 통과 | 사용자 로그인 화면 확인 | +| 조사사업 등록 후 목록 갱신 | 통과 | 저장 결과 즉시 표시 | +| 등록 후 상세현황 자동 열림 방지 | 통과 | 목록만 갱신 | +| 기존 사업 수정 팝업 정보 복원 | 통과 | 기본정보·처리분구 복원 | +| 정밀조사연장 숫자 입력 | 통과 | 입력·저장 확인 | +| 처리분구별 조사 입력양식 다운로드 | 통과 | 다중 시트 생성 확인 | +| 본단위등급표 동적 관로번호 | 통과 | `#REF!` 없이 표시 | +| 첫 처리분구만 예시 데이터 유지 | 통과 | 이후 시트는 빈 입력 양식 | +| 이후 관로조사 글꼴 검정색 | 통과 | 사용자 최종 확인 | +| 결과 미등록 상세현황 | 통과 | 오류 배너 없이 조회 결과 없음 표시 | +| 조사보고서 양식 최종 클릭 | 확인 필요 | API와 프론트 호출 구현 완료 | ### 남은 검증 @@ -267,13 +267,13 @@ ## 18. 2026-07-29 결과등록 파일별 검토 내역 정적 검증 -| 검증 항목 | 결과 | 비고 | -| --- | --- | --- | -| 파일별 검토 결과 독립 보관 | 통과 | Excel·관로 PDF·맨홀 PDF 상태와 메시지를 파일 구분별로 관리 | -| Excel 재선택 시 의존 검토 초기화 | 통과 | 관로·맨홀 PDF 검토 상태와 내역을 함께 폐기 | -| 검토 항목·건수·실패 사유 표시 | 통과 | 검토한 파일만 하단 내역에 표시 | -| 최종 저장 검토 해시 | 통과 | 조사결과 Excel의 정상 검토 결과를 사용 | -| 프론트 변경 공백·문법 흔적 검사 | 통과 | 대상 파일 `git diff --check` 통과 | +| 검증 항목 | 결과 | 비고 | +| -------------------------------- | ---- | ---------------------------------------------------------- | +| 파일별 검토 결과 독립 보관 | 통과 | Excel·관로 PDF·맨홀 PDF 상태와 메시지를 파일 구분별로 관리 | +| Excel 재선택 시 의존 검토 초기화 | 통과 | 관로·맨홀 PDF 검토 상태와 내역을 함께 폐기 | +| 검토 항목·건수·실패 사유 표시 | 통과 | 검토한 파일만 하단 내역에 표시 | +| 최종 저장 검토 해시 | 통과 | 조사결과 Excel의 정상 검토 결과를 사용 | +| 프론트 변경 공백·문법 흔적 검사 | 통과 | 대상 파일 `git diff --check` 통과 | ### 남은 검증 @@ -282,13 +282,13 @@ ## 19. 2026-07-29 사업보고서 원본 파일명 표시 정적 검증 -| 검증 항목 | 결과 | 비고 | -| --- | --- | --- | +| 검증 항목 | 결과 | 비고 | +| -------------------------------- | ---- | ----------------------------------------------------------------------------------------------- | | 첨부 메타데이터 원본 파일명 조회 | 통과 | 원문 JSON의 직접 배열·`data`·`content` 응답을 정규화한 뒤 첨부 ID가 일치하는 `orgnlFileNm` 선택 | -| 목록 보고서 셀 표시 | 통과 | 원본 파일명 우선, 메타데이터 실패 시 `있음`, 미첨부 시 `없음` | -| 다운로드 파일명 | 통과 | 원본 파일명 우선 사용 | -| 목록 조회 장애 격리 | 통과 | 첨부 메타 조회 실패를 행별 `null`로 처리 | -| 변경 파일 공백 검사 | 통과 | `git diff --check` 통과 | +| 목록 보고서 셀 표시 | 통과 | 원본 파일명 우선, 메타데이터 실패 시 `있음`, 미첨부 시 `없음` | +| 다운로드 파일명 | 통과 | 원본 파일명 우선 사용 | +| 목록 조회 장애 격리 | 통과 | 첨부 메타 조회 실패를 행별 `null`로 처리 | +| 변경 파일 공백 검사 | 통과 | `git diff --check` 통과 | ### 남은 검증 @@ -296,17 +296,17 @@ ## 20. 2026-07-29 결과등록 사용자 통합 검증과 목록 UI 최종 확인 -| 검증 항목 | 결과 | 비고 | -| --- | --- | --- | -| 조사결과 Excel 데이터 검토 | 통과 | 처리분구별 PPL·MNHL 및 본단위구간 검토 완료 | -| 관로 PDF 관로번호 검토 | 통과 | PDF 관로번호가 Excel 관로번호에 포함되는지 확인 | -| 파일별 검토 상태 표시 | 통과 | 성공은 연한 초록색·검토 완료, 실패는 연한 빨간색·검토 실패 | -| 최종 저장 활성 조건 | 통과 | Excel과 관로 PDF 검토가 모두 정상일 때 활성 | -| 조사결과 최종 저장 | 통과 | 사용자 화면에서 PPL/MNHL 저장·집계 완료 메시지 확인 | -| 사업보고서 원본 파일명 표시 | 통과 | `있음` 대신 첨부 원본 파일명 표시 확인 | -| 보고서 열 폭 | 통과 | 보고서 22%, 사업차수 6%, 처리구역·분구/정밀조사 연장 각 8% | -| 좁은 헤더 한 줄 표시 | 통과 | 헤더 줄바꿈 금지와 간격·글자 크기 보정 | -| 프론트 공백 검사 | 통과 | `git diff --check` | +| 검증 항목 | 결과 | 비고 | +| --------------------------- | ---- | ---------------------------------------------------------- | +| 조사결과 Excel 데이터 검토 | 통과 | 처리분구별 PPL·MNHL 및 본단위구간 검토 완료 | +| 관로 PDF 관로번호 검토 | 통과 | PDF 관로번호가 Excel 관로번호에 포함되는지 확인 | +| 파일별 검토 상태 표시 | 통과 | 성공은 연한 초록색·검토 완료, 실패는 연한 빨간색·검토 실패 | +| 최종 저장 활성 조건 | 통과 | Excel과 관로 PDF 검토가 모두 정상일 때 활성 | +| 조사결과 최종 저장 | 통과 | 사용자 화면에서 PPL/MNHL 저장·집계 완료 메시지 확인 | +| 사업보고서 원본 파일명 표시 | 통과 | `있음` 대신 첨부 원본 파일명 표시 확인 | +| 보고서 열 폭 | 통과 | 보고서 22%, 사업차수 6%, 처리구역·분구/정밀조사 연장 각 8% | +| 좁은 헤더 한 줄 표시 | 통과 | 헤더 줄바꿈 금지와 간격·글자 크기 보정 | +| 프론트 공백 검사 | 통과 | `git diff --check` | ### 남은 검증 @@ -316,14 +316,14 @@ ## 21. 2026-07-29 조사 상세현황 실제 집계 조회 -| 검증 항목 | 결과 | 비고 | -| --- | --- | --- | -| 프론트 API URL·파라미터 계약 | 정적 확인 | `/sections?jobType=PPL\|MNHL` | -| 관로 집계 필드 매핑 | 정적 확인 | PPL 전체이상·구조·운영·기타, 연장·주행거리 | -| 맨홀 집계 필드 매핑 | 정적 확인 | MNHL 전체이상·구조·운영·기타 | -| 빈 배열 화면 | 정적 확인 | `조회된 결과가 없습니다.` | -| 빠른 사업·탭 전환 보호 | 유지 | request id 기반 이전 응답 차단 | -| 실제 서버 화면 검증 | 대기 | 백엔드 재기동 후 사용자 확인 필요 | +| 검증 항목 | 결과 | 비고 | +| ---------------------------- | --------- | ------------------------------------------ | +| 프론트 API URL·파라미터 계약 | 정적 확인 | `/sections?jobType=PPL\|MNHL` | +| 관로 집계 필드 매핑 | 정적 확인 | PPL 전체이상·구조·운영·기타, 연장·주행거리 | +| 맨홀 집계 필드 매핑 | 정적 확인 | MNHL 전체이상·구조·운영·기타 | +| 빈 배열 화면 | 정적 확인 | `조회된 결과가 없습니다.` | +| 빠른 사업·탭 전환 보호 | 유지 | request id 기반 이전 응답 차단 | +| 실제 서버 화면 검증 | 대기 | 백엔드 재기동 후 사용자 확인 필요 | ### 남은 검증 @@ -332,14 +332,14 @@ ## 22. 2026-07-29 조사 상세보기 전체 집계 API 정적 검증 -| 검증 항목 | 결과 | 비고 | -| --- | --- | --- | -| 상세 API 요청 | 정적 확인 | 사업·처리분구·PPL/MNHL·관리번호 전달 | -| 실제/샘플 데이터 분리 | 통과 | `sample=Y`는 기존 샘플, 일반 URL은 서버 응답 | -| 기존 표 계약 유지 | 통과 | 고정 열·다단 결함 열·소계·합계·정렬 재사용 | -| 요청 순서 보호 | 통과 | effect cleanup 후 이전 응답 무시 | -| 후속 기능 격리 | 통과 | UNIT·결함별 화면은 아직 실 API 미호출 | -| 변경 공백 검사 | 통과 | `git diff --check` | +| 검증 항목 | 결과 | 비고 | +| --------------------- | --------- | -------------------------------------------- | +| 상세 API 요청 | 정적 확인 | 사업·처리분구·PPL/MNHL·관리번호 전달 | +| 실제/샘플 데이터 분리 | 통과 | `sample=Y`는 기존 샘플, 일반 URL은 서버 응답 | +| 기존 표 계약 유지 | 통과 | 고정 열·다단 결함 열·소계·합계·정렬 재사용 | +| 요청 순서 보호 | 통과 | effect cleanup 후 이전 응답 무시 | +| 후속 기능 격리 | 통과 | UNIT·결함별 화면은 아직 실 API 미호출 | +| 변경 공백 검사 | 통과 | `git diff --check` | ### 잔여 확인 @@ -348,15 +348,15 @@ ## 23. 2026-07-29 맨홀 전체 상세표 고정정보 정적 검증 -| 검증 항목 | 결과 | 비고 | -| --- | --- | --- | -| 맨홀 식별 열 용어 | 통과 | `맨홀번호` 대신 `관로번호` 표시 | -| 관로 고정 열 | 정적 확인 | 보고서번호부터 주행거리까지 표시 | -| 맨홀 구조·운영 등급 | 정적 확인 | 공통 열 뒤에 시설 등급코드 두 열 표시 | -| 소계·합계 | 정적 확인 | 관로는 연장·주행거리, 맨홀은 구조·운영 등급까지 합산 | -| 조사유형별 결함 열 | 정적 확인 | 고정 열 이후 PPL/MNHL 코드 정의 분기 | -| 엑셀 다운로드 열 | 정적 확인 | 관로·맨홀 화면별 고정 열과 결함 집계 생성 | -| 변경 화면 ESLint | 통과 | `ExmnSurveyDetailPopupPage.tsx` 오류·경고 없음 | +| 검증 항목 | 결과 | 비고 | +| ------------------- | --------- | ---------------------------------------------------- | +| 맨홀 식별 열 용어 | 통과 | `맨홀번호` 대신 `관로번호` 표시 | +| 관로 고정 열 | 정적 확인 | 보고서번호부터 주행거리까지 표시 | +| 맨홀 구조·운영 등급 | 정적 확인 | 공통 열 뒤에 시설 등급코드 두 열 표시 | +| 소계·합계 | 정적 확인 | 관로는 연장·주행거리, 맨홀은 구조·운영 등급까지 합산 | +| 조사유형별 결함 열 | 정적 확인 | 고정 열 이후 PPL/MNHL 코드 정의 분기 | +| 엑셀 다운로드 열 | 정적 확인 | 관로·맨홀 화면별 고정 열과 결함 집계 생성 | +| 변경 화면 ESLint | 통과 | `ExmnSurveyDetailPopupPage.tsx` 오류·경고 없음 | ### 남은 검증 @@ -366,15 +366,15 @@ ## 24. 2026-07-29 상세보기 처리분구 선택 정적 검증 -| 검증 항목 | 결과 | 비고 | -| --- | --- | --- | -| 조사유형별 처리분구 목록 | 정적 확인 | 기존 `/sections` API를 PPL·MNHL로 구분 호출 | -| 진입 처리분구 기본값 | 통과 | 조사 상세현황에서 선택한 처리분구 ID 유지 | -| 처리분구 변경 재조회 | 정적 확인 | 선택 ID를 시설 상세 API에 전달 | -| 이전 조회 상태 초기화 | 통과 | 상세 행·관리번호·정렬·안내 초기화 | -| 제목·다운로드 이름 | 통과 | 현재 선택 처리분구명 사용 | -| 대상 화면 ESLint | 통과 | `ExmnSurveyDetailPopupPage.tsx` 오류·경고 없음 | -| 변경 공백 검사 | 통과 | `git diff --check` | +| 검증 항목 | 결과 | 비고 | +| ------------------------ | --------- | ---------------------------------------------- | +| 조사유형별 처리분구 목록 | 정적 확인 | 기존 `/sections` API를 PPL·MNHL로 구분 호출 | +| 진입 처리분구 기본값 | 통과 | 조사 상세현황에서 선택한 처리분구 ID 유지 | +| 처리분구 변경 재조회 | 정적 확인 | 선택 ID를 시설 상세 API에 전달 | +| 이전 조회 상태 초기화 | 통과 | 상세 행·관리번호·정렬·안내 초기화 | +| 제목·다운로드 이름 | 통과 | 현재 선택 처리분구명 사용 | +| 대상 화면 ESLint | 통과 | `ExmnSurveyDetailPopupPage.tsx` 오류·경고 없음 | +| 변경 공백 검사 | 통과 | `git diff --check` | ### 남은 검증 @@ -383,15 +383,15 @@ ## 25. 2026-07-29 관로 본단위구간 실데이터 연결 검증 -| 검증 항목 | 결과 | 비고 | -| --- | --- | --- | -| 실제 API 연결 | 통과 | PPL 단위구간 전용 API 호출 | -| 초기 전체 조회 | 정적 확인 | 관리번호 없이 현재 처리분구 전체 | -| 관리번호 부분 조회 | 정적 확인 | 조회 버튼 확정값 전달 | -| 상태·결함 명칭 | 통과 | 코드 기반 프론트 매핑 | -| 빈 상세 단위구간 | 통과 | 결함 필드 하이픈 표시 | -| 기존 샘플·다운로드 | 유지 | 현재 14열 화면 계약 재사용 | -| 대상 ESLint | 통과 | 관련 4개 파일 오류·경고 없음 | +| 검증 항목 | 결과 | 비고 | +| ------------------ | --------- | -------------------------------- | +| 실제 API 연결 | 통과 | PPL 단위구간 전용 API 호출 | +| 초기 전체 조회 | 정적 확인 | 관리번호 없이 현재 처리분구 전체 | +| 관리번호 부분 조회 | 정적 확인 | 조회 버튼 확정값 전달 | +| 상태·결함 명칭 | 통과 | 코드 기반 프론트 매핑 | +| 빈 상세 단위구간 | 통과 | 결함 필드 하이픈 표시 | +| 기존 샘플·다운로드 | 유지 | 현재 14열 화면 계약 재사용 | +| 대상 ESLint | 통과 | 관련 4개 파일 오류·경고 없음 | ### 남은 검증 @@ -411,14 +411,14 @@ ## 26. 2026-07-29 구조·운영 결함별 실데이터 연결 검증 -| 검증 항목 | 결과 | 비고 | -| --- | --- | --- | +| 검증 항목 | 결과 | 비고 | +| -------------- | ---- | ---------------------------------------- | | 결함 링크 요청 | 통과 | 선택 `defectType`을 시설 상세 API에 전달 | -| 간소화 팝업 | 통과 | 조회조건·모드 없이 결과와 다운로드 표시 | -| 선택 코드 열 | 통과 | 구조 또는 운영 코드 정의만 사용 | -| 선택 시설 행 | 통과 | 선택 분류 결함이 존재하는 행만 표시 | -| 소계·합계 | 통과 | 현재 표시 행·열 기준 계산 | -| 대상 ESLint | 통과 | API·팝업 변경 파일 오류 없음 | +| 간소화 팝업 | 통과 | 조회조건·모드 없이 결과와 다운로드 표시 | +| 선택 코드 열 | 통과 | 구조 또는 운영 코드 정의만 사용 | +| 선택 시설 행 | 통과 | 선택 분류 결함이 존재하는 행만 표시 | +| 소계·합계 | 통과 | 현재 표시 행·열 기준 계산 | +| 대상 ESLint | 통과 | API·팝업 변경 파일 오류 없음 | ### 남은 검증 @@ -427,15 +427,15 @@ ## 27. 2026-07-30 조사 상세현황 다중 시트 다운로드 정적 검증 -| 검증 항목 | 결과 | 비고 | -| --- | --- | --- | -| 프론트 API 연결 | 구현 | `/sections/download?jobType=PPL\|MNHL` | -| 다운로드 파일 | 구현 | 서버 Blob 1개 저장 | -| 시트 계약 | 정적 확인 | 종합집계 1 + 처리분구 상세 N | -| 본단위구간 제외 | 정적 확인 | 서버 생성 계약에서 제외 | -| 빈 결과·진행 상태 | 구현 | 버튼 비활성·안내 표시 | -| 샘플 모드 | 제거 | 실제 API만 사용하도록 단일화 | -| ESLint | 미실행 | pnpm TTY 오류와 로컬 eslint 파일 접근 `EPERM` | +| 검증 항목 | 결과 | 비고 | +| ----------------- | --------- | --------------------------------------------- | +| 프론트 API 연결 | 구현 | `/sections/download?jobType=PPL\|MNHL` | +| 다운로드 파일 | 구현 | 서버 Blob 1개 저장 | +| 시트 계약 | 정적 확인 | 종합집계 1 + 처리분구 상세 N | +| 본단위구간 제외 | 정적 확인 | 서버 생성 계약에서 제외 | +| 빈 결과·진행 상태 | 구현 | 버튼 비활성·안내 표시 | +| 샘플 모드 | 제거 | 실제 API만 사용하도록 단일화 | +| ESLint | 미실행 | pnpm TTY 오류와 로컬 eslint 파일 접근 `EPERM` | ### 남은 검증 @@ -445,13 +445,13 @@ ## 28. 2026-07-30 샘플 데이터 보기 제거 정적 검증 -| 검증 항목 | 결과 | 비고 | -| --- | --- | --- | -| 샘플 데이터 파일 | 제거 | `exmn-sample-data.ts` 삭제 | -| 샘플 URL·상태 분기 | 통과 | 기능 범위에서 `sample=`, `sampleMode` 참조 0건 | -| 목록·팝업 실제 API | 통과 | 사업·처리분구·검토·집계·상세 API 호출 유지 | -| 양식 생성 예시행 정리 | 영향 없음 | 백엔드 실제 양식 생성 로직 유지 | -| 전체 빌드 | 미실행 | 사용자 요청에 따라 정적 확인만 수행 | +| 검증 항목 | 결과 | 비고 | +| --------------------- | --------- | ---------------------------------------------- | +| 샘플 데이터 파일 | 제거 | `exmn-sample-data.ts` 삭제 | +| 샘플 URL·상태 분기 | 통과 | 기능 범위에서 `sample=`, `sampleMode` 참조 0건 | +| 목록·팝업 실제 API | 통과 | 사업·처리분구·검토·집계·상세 API 호출 유지 | +| 양식 생성 예시행 정리 | 영향 없음 | 백엔드 실제 양식 생성 로직 유지 | +| 전체 빌드 | 미실행 | 사용자 요청에 따라 정적 확인만 수행 | ### 남은 검증 @@ -461,17 +461,17 @@ ## 29. 2026-07-30 샘플 제거 변경분 코드 리뷰 결과 -| 검증 항목 | 결과 | 비고 | -| --- | --- | --- | -| 목록 최초 조회 | 통과 | 빈 조회조건으로 실제 목록 API 호출 | -| 등록·수정 후 동기화 | 통과 | popup 메시지 수신 후 현재 조건으로 목록 재조회 | -| 결과등록 | 통과 | 실제 상세조회·검토·첨부 연결·commit 흐름 유지 | -| 종합집계 | 통과 | 선택 사업·조사유형으로 실제 집계 API 호출 | -| 전체·본단위·결함별 상세 | 통과 | 사업·처리분구·유형 파라미터와 실제 API 호출 유지 | -| WindowPopup props | 통과 | 제거된 샘플 props 참조 없음 | -| 런타임 샘플 참조 | 통과 | 기능 범위 검색 결과 0건 | -| diff 형식 | 통과 | 프론트·공통 문서 `git diff --check` 오류 없음 | -| TypeScript/전체 빌드 | 미실행 | 로컬 TypeScript 접근 `EPERM`, 사용자 요청에 따라 장시간 빌드 생략 | +| 검증 항목 | 결과 | 비고 | +| ----------------------- | ------ | ----------------------------------------------------------------- | +| 목록 최초 조회 | 통과 | 빈 조회조건으로 실제 목록 API 호출 | +| 등록·수정 후 동기화 | 통과 | popup 메시지 수신 후 현재 조건으로 목록 재조회 | +| 결과등록 | 통과 | 실제 상세조회·검토·첨부 연결·commit 흐름 유지 | +| 종합집계 | 통과 | 선택 사업·조사유형으로 실제 집계 API 호출 | +| 전체·본단위·결함별 상세 | 통과 | 사업·처리분구·유형 파라미터와 실제 API 호출 유지 | +| WindowPopup props | 통과 | 제거된 샘플 props 참조 없음 | +| 런타임 샘플 참조 | 통과 | 기능 범위 검색 결과 0건 | +| diff 형식 | 통과 | 프론트·공통 문서 `git diff --check` 오류 없음 | +| TypeScript/전체 빌드 | 미실행 | 로컬 TypeScript 접근 `EPERM`, 사용자 요청에 따라 장시간 빌드 생략 | ### 남은 수동 회귀 @@ -481,15 +481,15 @@ ## 30. 2026-07-30 배수방식·관종 명칭 표시 검증 -| 검증 항목 | 결과 | 비고 | -| --- | --- | --- | -| 상세 응답 타입 | 반영 | 코드 2개와 명칭 2개를 함께 유지 | -| 화면 표시 우선순위 | 정적 확인 | 명칭 우선, 없으면 코드 | -| 고정 열 정렬 | 정적 확인 | 화면 표시 명칭 기준 | -| 클라이언트 상세 Excel | 정적 확인 | 배수방식·관종 명칭 기록 | -| 기존 상세표 구조 | 영향 없음 | 고정 열·결함 열·소계·합계 유지 | -| 변경 공백 검사 | 통과 | `git diff --check` | -| 대상 ESLint | 환경 제한 | pnpm 비대화형 설치 중단 및 eslint 모듈 접근 `EPERM` | +| 검증 항목 | 결과 | 비고 | +| --------------------- | --------- | --------------------------------------------------- | +| 상세 응답 타입 | 반영 | 코드 2개와 명칭 2개를 함께 유지 | +| 화면 표시 우선순위 | 정적 확인 | 명칭 우선, 없으면 코드 | +| 고정 열 정렬 | 정적 확인 | 화면 표시 명칭 기준 | +| 클라이언트 상세 Excel | 정적 확인 | 배수방식·관종 명칭 기록 | +| 기존 상세표 구조 | 영향 없음 | 고정 열·결함 열·소계·합계 유지 | +| 변경 공백 검사 | 통과 | `git diff --check` | +| 대상 ESLint | 환경 제한 | pnpm 비대화형 설치 중단 및 eslint 모듈 접근 `EPERM` | ### 남은 수동 확인 @@ -499,18 +499,18 @@ ## 31. 2026-07-31 지도보기 이동·조사결과 레이어 정적 검증 -| 검증 항목 | 결과 | 비고 | -|---|---|---| -| 지도 이동 query | 통과 | 사업·행정구역·조사유형·처리분구 전달 | -| 초기 행정구역 연계 | 통과 | 기존 지도 검색 이벤트 재사용 | -| 조사결과 트리 구성 | 통과 | 루트와 PPL·MNHL 가상 노드 구성 | -| 설정 조건 | 통과 | 사업차수·구조·운영 등급 3개만 제공 | -| 자동 조회 | 통과 | 설정 변경 시 레이어 재조회 | -| 레이어 분리 | 통과 | PPL 선·MNHL 점 소스와 필터 상태 독립 | -| 응답 순서 보호 | 통과 | 조사유형별 요청 순번 적용 | -| 변경 공백 검사 | 통과 | `git diff --check` | -| 대상 ESLint | 환경 제한 | pnpm 비대화형 설치 중단, 직접 eslint 모듈 접근 `EPERM` | -| 브라우저 수동 검증 | 대기 | 서버 재기동 후 실제 지도에서 확인 필요 | +| 검증 항목 | 결과 | 비고 | +| ------------------ | --------- | ------------------------------------------------------ | +| 지도 이동 query | 통과 | 사업·행정구역·조사유형·처리분구 전달 | +| 초기 행정구역 연계 | 통과 | 기존 지도 검색 이벤트 재사용 | +| 조사결과 트리 구성 | 통과 | 루트와 PPL·MNHL 가상 노드 구성 | +| 설정 조건 | 통과 | 사업차수·구조·운영 등급 3개만 제공 | +| 자동 조회 | 통과 | 설정 변경 시 레이어 재조회 | +| 레이어 분리 | 통과 | PPL 선·MNHL 점 소스와 필터 상태 독립 | +| 응답 순서 보호 | 통과 | 조사유형별 요청 순번 적용 | +| 변경 공백 검사 | 통과 | `git diff --check` | +| 대상 ESLint | 환경 제한 | pnpm 비대화형 설치 중단, 직접 eslint 모듈 접근 `EPERM` | +| 브라우저 수동 검증 | 대기 | 서버 재기동 후 실제 지도에서 확인 필요 | ### 남은 수동 확인 @@ -521,43 +521,43 @@ ## 32. 2026-07-31 지도 우측 조사관리 탭 정적 검증 -| 검증 항목 | 결과 | 비고 | -|---|---|---| -| 조사관리 탭 분기 | 통과 | `surveyOverlayType=EXMN_RESULT`를 일반 시설보다 우선 판별 | -| 탭 식별자 | 통과 | 조사시설물 ID와 맨홀 위치로 구성 | -| 조사 결과 주입 | 통과 | 관로·맨홀 입력정보 컴포넌트에 선택적 ReactNode 전달 | -| 관로 부분 PDF 호출 | 통과 | 사업 ID와 관로번호를 URL 인코딩하여 Blob 조회 | -| 맨홀 PDF 보류 | 통과 | 버튼과 별도 보류 안내 모두 미표시 | -| 변경 공백 검사 | 통과 | 프론트·백엔드 `git diff --check` | -| 브라우저 수동 검증 | 대기 | 개발 서버에서 실제 지도 선택과 팝업 허용 상태 확인 필요 | +| 검증 항목 | 결과 | 비고 | +| ------------------ | ---- | --------------------------------------------------------- | +| 조사관리 탭 분기 | 통과 | `surveyOverlayType=EXMN_RESULT`를 일반 시설보다 우선 판별 | +| 탭 식별자 | 통과 | 조사시설물 ID와 맨홀 위치로 구성 | +| 조사 결과 주입 | 통과 | 관로·맨홀 입력정보 컴포넌트에 선택적 ReactNode 전달 | +| 관로 부분 PDF 호출 | 통과 | 사업 ID와 관로번호를 URL 인코딩하여 Blob 조회 | +| 맨홀 PDF 보류 | 통과 | 버튼과 별도 보류 안내 모두 미표시 | +| 변경 공백 검사 | 통과 | 프론트·백엔드 `git diff --check` | +| 브라우저 수동 검증 | 대기 | 개발 서버에서 실제 지도 선택과 팝업 허용 상태 확인 필요 | - 사용자 요청에 따라 전체 프론트 빌드와 장시간 Gradle 작업은 수행하지 않았다. ## 33. 2026-07-31 조사관리 탭 유지관리 영역·부분 PDF 보정 -| 검증 항목 | 결과 | 비고 | -|---|---|---| -| 조사관리 전용 표시 | 정적 통과 | 조사결과 아래 일반 유지관리 3개 영역 숨김 | -| 일반 관로 탭 회귀 | 정적 통과 | 조사관리 탭이 아닐 때 기존 유지관리 영역 유지 | -| 부분 관로 PDF | 정적 통과 | PDF에 포함된 활성 관로 쪽 범위만 저장 | -| PDF의 미등록 관로 | 정적 통과 | 활성 조사시설에 없는 번호가 있으면 저장 거부 | -| 선택 관로 PDF 누락 | 정적 통과 | 선택 관로 보고서 없음 메시지 반환 | -| 브라우저·단위 테스트 | 대기 | 백엔드 재기동 후 실제 첨부 PDF로 확인 필요 | +| 검증 항목 | 결과 | 비고 | +| -------------------- | --------- | --------------------------------------------- | +| 조사관리 전용 표시 | 정적 통과 | 조사결과 아래 일반 유지관리 3개 영역 숨김 | +| 일반 관로 탭 회귀 | 정적 통과 | 조사관리 탭이 아닐 때 기존 유지관리 영역 유지 | +| 부분 관로 PDF | 정적 통과 | PDF에 포함된 활성 관로 쪽 범위만 저장 | +| PDF의 미등록 관로 | 정적 통과 | 활성 조사시설에 없는 번호가 있으면 저장 거부 | +| 선택 관로 PDF 누락 | 정적 통과 | 선택 관로 보고서 없음 메시지 반환 | +| 브라우저·단위 테스트 | 대기 | 백엔드 재기동 후 실제 첨부 PDF로 확인 필요 | ## 34. 2026-07-31 최종 정적 회귀점검 -| 검증 항목 | 결과 | 비고 | -|---|---|---| -| 처리분구·사업·결과등록 | 구현 확인 | 실제 API 호출과 저장 후 재조회 흐름 유지 | +| 검증 항목 | 결과 | 비고 | +| ---------------------------- | --------- | -------------------------------------------------- | +| 처리분구·사업·결과등록 | 구현 확인 | 실제 API 호출과 저장 후 재조회 흐름 유지 | | 종합·전체·본단위·결함별 조회 | 구현 확인 | PPL/MNHL 및 `ALL/STRUCTURAL/OPERATIONAL` 계약 유지 | -| 다중 시트 다운로드 | 구현 확인 | 종합 1 + 처리분구 상세 N, 본단위구간 제외 | -| 지도 이동·행정구역 | 구현 확인 | 시도·시군구 query와 공통 검색 이벤트 연결 | -| 지도 조사 레이어 | 구현 확인 | PPL 선·MNHL 점, 사업차수·구조·운영 등급 필터 | -| 조사유형 시각 구분 | 구현 확인 | 관로 노란색, 맨홀 보라색, 탭 명칭에 조사유형 표시 | -| 관로 부분 PDF | 구현 확인 | `exmnPipeReport` WindowPopup과 Blob URL 정리 | -| 일반 시설 회귀 | 정적 확인 | 조사 탭에서만 유지관리 영역을 숨김 | -| 변경 공백 검사 | 통과 | 프론트·백엔드 `git diff --check` | -| 전체 빌드·대상 테스트 | 미실행 | 장시간 실행 이력과 사용자 검증 방침에 따라 생략 | +| 다중 시트 다운로드 | 구현 확인 | 종합 1 + 처리분구 상세 N, 본단위구간 제외 | +| 지도 이동·행정구역 | 구현 확인 | 시도·시군구 query와 공통 검색 이벤트 연결 | +| 지도 조사 레이어 | 구현 확인 | PPL 선·MNHL 점, 사업차수·구조·운영 등급 필터 | +| 조사유형 시각 구분 | 구현 확인 | 관로 노란색, 맨홀 보라색, 탭 명칭에 조사유형 표시 | +| 관로 부분 PDF | 구현 확인 | `exmnPipeReport` WindowPopup과 Blob URL 정리 | +| 일반 시설 회귀 | 정적 확인 | 조사 탭에서만 유지관리 영역을 숨김 | +| 변경 공백 검사 | 통과 | 프론트·백엔드 `git diff --check` | +| 전체 빌드·대상 테스트 | 미실행 | 장시간 실행 이력과 사용자 검증 방침에 따라 생략 | ### 정적 보완 결과 @@ -573,30 +573,69 @@ ## 35. 2026-07-31 관로 보고서 미등록 선분기 점검 -| 검증 항목 | 결과 | 비고 | -|---|---|---| -| 지도 응답 첨부 여부 | 정적 통과 | `reportAvailable` 속성 전달 | -| 보고서 미등록 | 정적 통과 | WindowPopup 없이 alert 표시 | -| 보고서 등록 | 정적 통과 | 기존 `exmnPipeReport` WindowPopup 호출 | -| 부분 PDF 분석 실패 | 기존 동작 유지 | 팝업 내부 백엔드 오류 표시 | -| 브라우저 수동 검증 | 대기 | 보고서 등록·미등록 사업을 각각 선택해 확인 | +| 검증 항목 | 결과 | 비고 | +| ------------------- | -------------- | ------------------------------------------ | +| 지도 응답 첨부 여부 | 정적 통과 | `reportAvailable` 속성 전달 | +| 보고서 미등록 | 정적 통과 | WindowPopup 없이 alert 표시 | +| 보고서 등록 | 정적 통과 | 기존 `exmnPipeReport` WindowPopup 호출 | +| 부분 PDF 분석 실패 | 기존 동작 유지 | 팝업 내부 백엔드 오류 표시 | +| 브라우저 수동 검증 | 대기 | 보고서 등록·미등록 사업을 각각 선택해 확인 | ## 36. 2026-08-06 지도보기 처리분구 선택 조건 점검 -| 검증 항목 | 결과 | 비고 | -|---|---|---| -| 처리분구 미선택 | 정적 통과 | `지도보기` 비활성화 및 선택 안내 title 표시 | -| 처리분구 행 선택 | 정적 통과 | 선택 행 기준 버튼 활성화 | -| 지도 이동 조건 | 정적 통과 | `trtdstId`, `trtdstNm` 필수 전달 및 함수 내부 방어 조건 적용 | -| 합계 행 | 정적 통과 | 클릭 선택 이벤트가 없어 지도 대상이 되지 않음 | -| 브라우저 수동 검증 | 대기 | 관로·맨홀 탭 각각에서 미선택·행 선택 상태 확인 필요 | +| 검증 항목 | 결과 | 비고 | +| ------------------ | --------- | ------------------------------------------------------------ | +| 처리분구 미선택 | 정적 통과 | `지도보기` 비활성화 및 선택 안내 title 표시 | +| 처리분구 행 선택 | 정적 통과 | 선택 행 기준 버튼 활성화 | +| 지도 이동 조건 | 정적 통과 | `trtdstId`, `trtdstNm` 필수 전달 및 함수 내부 방어 조건 적용 | +| 합계 행 | 정적 통과 | 클릭 선택 이벤트가 없어 지도 대상이 되지 않음 | +| 브라우저 수동 검증 | 대기 | 관로·맨홀 탭 각각에서 미선택·행 선택 상태 확인 필요 | ## 37. 2026-08-06 맨홀조사 선택 심볼 유지 점검 -| 검증 항목 | 결과 | 비고 | -|---|---|---| -| 실제 레이어 스타일 조회 | 정적 통과 | 선택기의 `getLayer(feature)`와 레이어 `getStyleFunction()` 연결 | -| 점 시설 렌더링 순서 | 정적 통과 | 선택 원형 강조 후 원래 아이콘을 위에 렌더링 | -| 선형 시설 회귀 | 정적 통과 | 기존 스타일 뒤에 선형 선택 강조를 추가하는 순서 유지 | -| 변경 공백·구문 검사 | 통과 | `git diff --check`, `node --check` 수행 | -| 브라우저 수동 검증 | 대기 | 맨홀 클릭 후 보라색 맨홀 심볼과 선택 원이 함께 유지되는지 확인 필요 | +| 검증 항목 | 결과 | 비고 | +| ----------------------- | --------- | ------------------------------------------------------------------- | +| 실제 레이어 스타일 조회 | 정적 통과 | 선택기의 `getLayer(feature)`와 레이어 `getStyleFunction()` 연결 | +| 점 시설 렌더링 순서 | 정적 통과 | 선택 원형 강조 후 원래 아이콘을 위에 렌더링 | +| 선형 시설 회귀 | 정적 통과 | 기존 스타일 뒤에 선형 선택 강조를 추가하는 순서 유지 | +| 변경 공백·구문 검사 | 통과 | `git diff --check`, `node --check` 수행 | +| 브라우저 수동 검증 | 대기 | 맨홀 클릭 후 보라색 맨홀 심볼과 선택 원이 함께 유지되는지 확인 필요 | + +## 38. 2026-08-28 조사 입력양식 다운로드 검증 + +| 검증 항목 | 결과 | 근거 | +| -------------------------- | ------ | ------------------------------------------------------- | +| 저장 전 클릭 안내 | 통과 | `사업정보를 먼저 저장해주세요.` 안내 표출 확인 | +| 등록 화면 사업별 양식 버튼 | 통과 | `양식다운로드` 버튼 0개 | +| 등록 화면 공통 보고서 양식 | 통과 | `조사보고서 양식다운로드` 버튼 1개 유지 | +| 수정 화면 사업별 양식 버튼 | 통과 | 저장 사업 `신사업`에서 `양식다운로드` 버튼 1개 | +| 수정 화면 공통 보고서 양식 | 통과 | `조사보고서 양식다운로드` 버튼 1개 유지 | +| 실제 파일 생성 | 통과 | `신사업_조사입력양식 (2).xlsx`, 188,261바이트 생성 | +| 정적 검사 | 통과 | 전체 TypeScript, 대상 Prettier, `git diff --check` 통과 | +| Next build | 미수행 | 사용자 실행 중인 3002 개발서버 보호 기준에 따라 생략 | + +- API·DB 변경 없음. 판정: PASS. + +## 39. 2026-08-28 사업등록·수정 양식 버튼 검증 + +| 검증 항목 | 결과 | 비고 | +| -------------------- | --------- | ---------------------------------------------------------------------- | +| 사업등록 버튼 순서 | 정적 통과 | 양식다운로드 → 조사보고서 양식다운로드 → 저장 → 취소 | +| 저장 전 입력양식 | 정적 통과 | 사업 ID가 없어 비활성 | +| 저장 전 hover 안내 | 정적 통과 | 사업정보와 처리분구 저장 안내 표시 | +| 신규 저장 후 팝업 | 정적 통과 | 부모 목록 갱신 메시지 전송, 팝업 유지·입력 잠금·양식 활성 | +| 수정 버튼 순서 | 정적 통과 | 양식다운로드 → 저장 → 취소, 조사보고서 양식 미표시 | +| 수정 후 양식 | 정적 통과 | 저장된 최신 처리분구 기준 재생성 | +| 변경 파일 ESLint | 통과 | 오류·경고 0건 | +| `sn` TypeScript 검사 | 통과 | `tsc --noEmit`, 기존 설정값은 명령줄 `ignoreDeprecations=5.0`으로 보정 | +| 로그인 브라우저 | 대기 | 사업등록 저장 후 두 다운로드와 수정 팝업 버튼 3개 최종 확인 | + +## 40. 2026-09-01 조사사업 공통코드 프론트 표시 빠른 검증 + +- 실제 CMS 캐시 API 확인 + - 결함등급 5건, 기록유형 2건, 결함분류 3건, 심각도 3건, 상태그룹 4건 + - 관로 조사항목 31건, 맨홀 조사항목 23건 +- 총 71개 코드가 계획한 그룹별 건수로 조회됐다. +- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다. +- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다. +- 조사 종합집계 필터, 조사 상세표·Excel, 본단위등급표와 시설 상세의 코드명 수동 검증이 남아 있다. diff --git a/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md index 5ddd15323..946a954e7 100644 --- a/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md @@ -333,3 +333,13 @@ - TypeScript compiler API의 `transpileModule`로 `page.tsx`를 TSX 파싱한 결과 error diagnostics 0건이었다. - 프론트 변경 파일 대상 `git diff --check`는 통과했다. - 로그인 세션이 필요한 실제 탭 누적·교체·복귀 상호작용은 수동 검증이 남아 있다. + +### 2.31 2026-09-01 지반침하 상태 공통코드 적용 빠른 검증 + +- 실제 CMS 캐시 API 확인 + - `SNKHLRSTRSTTS`: `RESTORED=복구완료`, `RESTORING=복구중`, `NOT_RESTORED=미복구` + - `SNKHLCRDSTTS`: `SUCCESS=보정성공`, `FAILED=보정실패` +- 백엔드와 기존 데이터가 복구상태를 `복구완료/복구중/미복구` 설명값으로 처리하는 계약을 코드·기존 검증 문서에서 확인했다. +- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다. +- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다. +- 실제 지반침하 목록에서 검색조건·목록·Excel·상세 표시와 조회 결과 건수의 수동 검증이 남아 있다. diff --git a/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md b/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md index f632f0049..125937ce7 100644 --- a/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md @@ -338,3 +338,46 @@ Chrome 확장의 파일 URL 접근 권한이 꺼져 있어 이번 회귀에서 E | 공통 선택 스타일 | 통과 | 별도 준설 CSS 없이 기존 `bg-accent !text-primary` 규칙 적용 | | 브라우저 콘솔 | 통과 | 새 검증 탭 경고·오류 0건 | | Next build | 미수행 | 사용자가 실행한 3002 개발 서버 보호 기준에 따라 생략 | + +## 14. 2026-08-28 일반 HTTP 준설 등록 팝업 회귀검증 + +| 검증 항목 | 결과 | 근거 | +| --- | --- | --- | +| 원격 오류 재현 | 통과 | `con.mind-one.co.kr:11302`에서 client-side exception과 `crypto.randomUUID is not a function` 확인 | +| 비보안 컨텍스트 조건 | 통과 | LAN HTTP 개발주소에서 `isSecureContext=false`, `crypto` 미제공 확인 | +| UUID fallback | 통과 | Web Crypto `getRandomValues`만 있는 경우와 `crypto`가 없는 경우 모두 UUID v4 정규식 통과 | +| 로컬 HMR 팝업 | 통과 | 등록 건 1, 사진 업로드, 위치 지정, 지도 영역이 정상 렌더링되고 오류·경고 0건 | +| 준설 직접 호출 제거 | 통과 | 기능 파일의 직접 호출을 제거하고 `lib/uuid.ts` 공통 함수 한 곳으로 집중 | +| TypeScript | 통과 | SN 전체 `tsc --noEmit --ignoreDeprecations 5.0` 종료코드 0 | +| Prettier·공백 검사 | 통과 | 변경 5개 파일 Prettier 및 `git diff --check` 통과 | +| Next build | 미수행 | 사용자 실행 중인 3002 개발서버 보호 기준에 따라 생략 | + +- 수정 전 원격 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260828/01-원격HTTP-randomUUID-오류.png`. +- 수정 후 로컬 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260828/02-로컬-수정후-준설등록-정상.png`. +- 원격 11302 서버는 아직 수정 코드가 배포되지 않았으므로 배포 후 같은 URL의 최종 회귀검증이 필요하다. + +## 15. 2026-08-28 등록·수정 촬영일시 날짜 표출 검증 + +| 검증 항목 | 결과 | 근거 | +| --- | --- | --- | +| ISO 일시 | 통과 | `2026-06-20T15:47:45 → 2026-06-20` | +| 공백 구분 일시 | 통과 | `2026-07-18 16:12:57 → 2026-07-18` | +| 날짜 단독 | 통과 | `2026-02-14 → 2026-02-14` | +| 잘못된 날짜·빈 값 | 통과 | `2026-02-30`, null, 빈 문자열은 fallback 처리 | +| 등록 화면 | 통과 | 사진 표의 촬영일시 cell과 title에 공통 날짜 포맷 함수 적용 | +| 수정 화면 | 통과 | 기존 사진 3건이 `2026-06-20`, `2026-07-18`로 표출되고 시간 미표시 확인 | +| 저장 계약 | 통과 | `clientCapturedAt`에는 기존 원본 일시를 그대로 전달 | +| 정적 검사 | 통과 | 전체 TypeScript, 대상 Prettier, `git diff --check` 통과 | + +- 검증 화면: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260828/03-수정화면-촬영일자-날짜만.png`. +- 사용자 실행 중인 3002 개발서버를 보호하기 위해 Next build는 수행하지 않았다. + +## 16. 2026-09-01 준설 공통코드 적용 빠른 검증 + +- 실제 CMS 캐시 API 확인 + - `DRDGNREGMODE`: `PHOTO=사진등록`, `MANUAL=수기등록` + - `DRDGNLOCSRC`: `EXIF=사진 EXIF`, `MANUAL=수기지정` + - `DRDGNPHOTOSTTS`: `COMPLETE=메타정보정상`, `MISSING_BOTH=촬영일시·GPS누락`, `MISSING_DATE=촬영일시누락`, `MISSING_GPS=GPS누락` +- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다. +- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다. +- 등록·수정 팝업에서 버튼명, 위치출처, 사진상태 표시와 기존 저장 흐름의 수동 검증이 남아 있다. diff --git a/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md index 039853b6d..d70d513a3 100644 --- a/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md @@ -32,3 +32,54 @@ - 입력 중인 필터를 바로 읽던 기존 흐름 대신 `조회` 시점에만 적용 조건을 갱신하므로, 미적용 초안 필터 때문에 상세 팝업 결과 수나 페이지 상태가 흔들리던 회귀를 방지한다. - 상세 팝업은 페이지당 10건과 별도 테이블 스크롤 영역으로 조정되어 현재 팝업 높이에서 페이지네이션이 화면 하단에 노출되는 구조로 바뀌었다. - 브라우저 실동작 최종 확인은 사용자 수동 검증 기준으로 진행한다. + +## 8. 2026-08-26 대구광역시 서구 브라우저 검증 + +- 브라우저에서 `관로분석 > 대구광역시 > 서구 > 조회`를 직접 실행했다. +- 처리구역 선택지에 `달서천처리구역`, `북부처리구역`이 표시됐다. +- 결과표에 전체 656.701km, 달서천처리구역 413.045km, 북부처리구역 243.656km와 경과년도 구간별 값이 표시됐다. +- `재질별`은 처리구역 2행, `경과년도&재질`은 8행이 렌더링됐고 두 화면 모두 브라우저 오류 로그가 없었다. +- 검증 화면: `COMMON_DOCS_ROOT/작업내용/SFR-011-002-관로분석/검증/20260826-대구광역시-서구-조회결과.jpg` +- 변경 파일 TypeScript 구문검사 0건, Prettier와 `git diff --check` 통과. +- SN 전체 타입검사는 동시 작업 파일 `cty-fldng-prvtn-biz-excel.ts`의 선행 타입 오류 3건으로 차단됐으며, 이번 관로분석 변경 파일에서는 오류가 확인되지 않았다. +- 판정: 화면 표출 PASS. + +## 9. 2026-08-26 관로현황 용도·관재질 검색조건 검증 + +- 용도 선택지: 전체, 미분류, 합류관, 차집관, 우수관, 오수관, 기타의 7개를 브라우저에서 확인했다. +- 관재질 선택지: 공통 코드 193개와 전체를 합한 194개를 확인했다. +- 전국 API 실데이터: 미분류 8,577건, 기타 5,921건. +- 대구광역시 서구 브라우저 조회: 미분류 51건 중 10건 표출, 관재질 `MOP012` 127건 중 10건 표출. +- 브라우저 오류 로그 없이 필터가 API 요청과 결과표에 반영됐다. +- 검증 화면: `COMMON_DOCS_ROOT/작업내용/SFR-011-002-관로분석/검증/20260826-관로현황-용도미분류-조회.jpg` +- SN 전체 TypeScript, Prettier, `git diff --check` 통과. +- 판정: PASS. + +## 10. 2026-08-26 관로현황 최상위 지도창 포커스 검증 + +- 실제 Chrome에서 `하수관망도 → 관로분석 → 재질별 결과 → 관로현황`의 3단계 창 계층을 구성했다. +- 관로 `118529`, `118530` 행 클릭 시 최상위 지도창에서 해당 관로 이동·강조와 우측 상세정보 갱신을 확인했다. +- 상세창 직접 `focus()`만 사용하면 Chrome에서 상세창이 활성 상태로 남았고, 상세창 `blur()`만 추가하면 중간 관로분석창이 활성화되는 차이를 확인했다. +- 최종 구현은 상세창·중간창 `blur()`와 이름 기반 지도창 `focus()`, 지도창 메시지 처리 전후 self-focus를 함께 적용했다. +- 자동화 제어는 창 전환 순간 기존 상세창 제어가 종료되어 OS 전경창 최종 상태의 재확인이 제한됐다. 실제 사용자 클릭 기준 수동 포커스 확인은 남은 검증 항목이다. +- SN 전체 TypeScript와 `git diff --check` 통과. `map/page.tsx` 전체 Prettier는 기존 파일의 선행 포맷 차이로 통과하지 않았고 이번 추가 구문은 기존 스타일에 맞췄다. + +## 11. 2026-08-26 관로 상세 유하방향 근거 검증 + +- 수정 전 Chrome 지도 상세에서 관로 `118529`의 유하방향 근거가 `-`로 표출되는 것을 확인했다. +- 같은 관로의 유하방향 포함 API 응답은 `flowDirectionBasis=GIS 작도순서`, `flowDirectionBasisCode=GEOMETRY_FALLBACK`, `flowDirectionRuleLabel=geometry 시점->종점 fallback`을 반환했다. +- 관로현황 행과 보강 API 응답의 병합 결과에 위 세 필드가 유지되는 것을 확인했다. +- `map/page.tsx` 변경 구문 TypeScript 파서와 `git diff --check`는 통과했다. +- SN 전체 TypeScript는 동시 변경된 기존 `event.source.postMessage` 타입 오류 1건으로 차단됐으며 이번 유하방향 보강 코드의 추가 오류는 없었다. +- Chrome 입력 제어가 관로행 클릭 단계에서 시간 초과되어 수정 후 최종 화면 표출은 사용자 수동 확인 항목으로 남긴다. + +## 12. 2026-08-28 하수관로 상세 공통 보강 재검증 + +- 재현 관로: 시설관리번호 `2015303665`, 관리기관코드 `5690000`, FID `tb_ms_swage_ppl.1135978`. +- 실 API 응답: `flowDirectionBasis=시종점 관저고`, `flowDirectionBasisCode=DEPTH`, `flowDirectionRuleLabel=맨홀 표고-관로 깊이 관저고 높은 쪽->낮은 쪽`, 조회건수 1건. +- 로컬 브라우저에서 같은 관로를 지도 상세 흐름으로 열어 우측 기본정보의 `유하방향 근거`가 `시종점 관저고`로 표시되는 것을 확인했다. +- 검증 화면: `COMMON_DOCS_ROOT/작업내용/SFR-012-005-하수관로유하방향표출/검증/20260828-관로-2015303665-유하방향근거.png`. +- 변경 컴포넌트 Prettier와 TypeScript 구문검사, `git diff --check` 통과. +- SN 전체 TypeScript는 기존 `map/page.tsx:3659`의 `postMessage` 타입 오류 1건만 남았고 이번 공통 보강 코드의 추가 오류는 없다. +- ESLint는 설치 상태에서 `eslint-plugin-react-hooks`를 찾지 못해 실행 환경 오류로 완료하지 못했다. +- 판정: PASS. diff --git a/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md index ee5442281..be891d8e0 100644 --- a/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md @@ -145,7 +145,7 @@ - 분석기준시간 라벨·값이 현재 탭 행 오른쪽 끝에 표시되고 검색패널에서 제거된 것을 정적 확인했다. - 행 클릭 성공 안내문 제거와 오류 메시지 조건부 렌더링을 확인했다. - 보령 동대궁촌의 목록 최대 침수유량과 지도카드 값이 모두 `121.48m³/s`로 일치함을 실 API로 확인했다. -- 지도카드 위험/정상 판정 설명과 `capacity > 0.8` 서비스 기준이 일치한다. +- 지도카드 안내는 GIS 매핑 관로의 `Capacity >= 0.8` 지역 위험 기준만 표시한다. - 페이지별 데이터 길이·스크롤바에 따른 전체표 확대축소를 제거하고 `1320px` 고정폭·10개 열 비율·긴 행정명 말줄임 처리를 확인했다. - 팝업 컴포넌트에 `window.resizeTo` 호출이 없고 창 크기는 `popupDefinitions`의 `1380×900`으로만 결정되는 것을 정적 확인했다. 검색·결과영역의 overflow 숨김과 결과영역 좌우 여백 보정은 유지한다. @@ -192,3 +192,23 @@ - 과거 종료결과에서 실제 `2026-07-01 01:00`, `03:00`, `05:00` 등의 분석기준시간이 반환됐다. - 과거 종료결과 전체의 상단 최신 분석기준시간은 `2026-07-06 08:00`으로 반환됐다. - Java Service 테스트와 FE 대상 ESLint가 통과했다. + +## 14. 2026-08-26 과거분석이력 최신순·중복 키 검증 + +- 정렬 파라미터가 없는 기본 요청도 백엔드 `RAIN_BGNG_DT DESC`에 따라 2026-08-26 07:00, 2026-08-25 13:00, 10:00, 06:00 순으로 반환된다. +- 2026-08-25 경기 가평 지역 2건은 기존 키가 1개로 충돌했지만 강우시작시간·분석기준시간을 포함한 신규 키는 2개 모두 고유하다. + +## 15. 2026-08-31 초단기예보격자망 자동 활성화 검증 + +| 검증 항목 | 결과 | 비고 | +|---|---|---| +| 통수위험 선택 메시지 연계 | 정적 통과 | 유효한 결과행·mapView 수신 후 체크 요청 생성 | +| 기존 체크 상태 보존 | 정적 통과 | 요청 레이어를 기존 배열에 추가하는 방식 | +| 동일 기능 재실행 | 정적 통과 | 행 선택마다 `requestKey` 증가 | +| 트리 가시성 | 정적 통과 | 대상 노드와 `강우관련` 조상 그룹 펼침 | +| 실제 레이어 ON | 정적 통과 | 공통 `checkSpecificNode`의 지도 반영 경로 재사용 | +| 공통 지도 패키지 빌드 | 통과 | `pnpm --filter @common/business_components run build` | +| `sn` TypeScript 검사 | 통과 | `tsc --noEmit --incremental false`, `ignoreDeprecations=5.0` 명령줄 보정 | +| 브라우저 수동 검증 | 대기 | 실제 결과 행 클릭 후 격자망 체크·WFS 표출 확인 필요 | + +- Prettier 실행 파일은 현재 workspace에 설치되어 있지 않아 별도 포맷 검사는 수행하지 못했다. diff --git a/apps/sn/docs/작업검증/SFR-011-008-침수예측/001-qa-SFR-011-008-침수예측지도-격자표시-검증계획.md b/apps/sn/docs/작업검증/SFR-011-008-침수예측/001-qa-SFR-011-008-침수예측지도-격자표시-검증계획.md new file mode 100644 index 000000000..6fdc82af7 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-011-008-침수예측/001-qa-SFR-011-008-침수예측지도-격자표시-검증계획.md @@ -0,0 +1,7 @@ +# SFR-011-008 침수예측지도 격자 표시 검증계획 + +- 침수예측현황 행 클릭 시 기존 지도 extent 이동 확인 +- `grid-map` API의 시군구·분석시각 파라미터와 WFS `latc_cd` 필터 확인 +- 각 격자 중앙 `latc_cd`·침수위험도·현재/예측 누적강우 표시 확인 +- 행 재선택 시 기존 overlay 제거 및 신규 격자 갱신 확인 +- BE 단위 테스트·컴파일 및 SN 브라우저 콘솔 오류 확인 diff --git a/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md b/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md index 23406cf07..025cf7b83 100644 --- a/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md @@ -293,3 +293,33 @@ - 10초는 로컬 warm 11.2MB·16,670건 조건의 통과 결과이며 운영 SLA 보증이 아니다. - 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-012-002-하수관망도 업로드/검증/20260807/10s/23-performance-summary.json` - 최종 데이터 대조: 같은 폴더의 `22-final-feature-comparison.json` + +## 9. 2026-08-28 관리기관 콤보박스 목록 검증 + +- 개발서버 API 확인: + - `GET /api/v1/map/gisUploadFacilityTypes?screenId=SN_MN010302`: 하수관로 시설유형 1건, HTTP 200. + - `GET /api/v1/fcltppl/mngInsts`: 관리기관 160건, 빈 관리기관코드 0건, HTTP 200. +- 수정 후 실제 Chrome 개발서버 팝업에서 관리기관 콤보박스 161개 옵션(`선택` 1개 + 관리기관 160개)을 확인했다. +- `가평군(4160000)`을 선택한 뒤 GIS 업로드 이력 목록과 다운로드·조회 버튼 활성화를 확인했다. +- Prettier, SN 전체 TypeScript, `git diff --check`를 통과했다. +- 사용자 소유 개발서버 3002·8883은 종료·재시작하지 않았고 `next build`는 수행하지 않았다. +- 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-012-002-하수관망도 업로드/검증/20260828/01-GIS관리-관리기관-가평군-선택.png`. +- 판정: PASS. + +## 10. 2026-08-28 GIS 관리 목록 버튼 문구 검증 + +- 기본 GIS 업로드 목록 액션에서 `목록` 버튼 표출을 확인했다. +- 오류 확인 화면에서도 같은 버튼이 `목록`으로 표시됐다. +- 오류 화면의 `목록` 클릭 시 `GIS 업로드 목록`으로 정상 복귀했다. +- 전체 TypeScript, 대상 Prettier와 `git diff --check`를 통과했다. +- API·DB 변경 없음. 판정: PASS. + +## 11. 2026-09-01 GIS 관리 공통코드 적용 빠른 검증 + +- 실제 CMS 캐시 API 확인 + - `GISULDSTTS`: `COMPLETED=업로드완료`, `VALIDATION_FAILED=검증실패`, `FAILED=업로드실패` + - `GISDWNLDSTTS`: `QUEUED=작업대기`, `RUNNING=파일생성중`, `READY=다운로드준비완료`, `FAILED=파일생성실패` + - `SNFCLTSE`: `PPL=관로`, `MNHL=맨홀` +- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다. +- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 이번 단계에서 수행하지 않았다. +- 실제 GIS 관리 팝업에서 시설구분명, 다운로드 상태 안내와 업로드 결과 fallback을 확인하는 수동 검증이 남아 있다. diff --git a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md index 93697c3fe..8a31d7391 100644 --- a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md @@ -759,3 +759,18 @@ - 사용자가 실제 지도에서 GIS 편집 두 번째 클릭 시 하단 도구바와 인라인 세션이 함께 사라지는 것을 확인했다. - 다시 GIS 편집 진입 시 기존 변경분과 인라인 세션이 복원되는 것을 확인했다. - 결론: 추가 회귀 보정까지 사용자 수동 검증 완료. + +### 2.32 2026-09-01 GIS 편집작업 공통코드 1차 적용 검증 + +- 실제 CMS 캐시 API 확인 + - `GISEDITOPRT`: `ADD=추가`, `MOVE=이동`, `VERTEX_EDIT=꼭지점편집`, `DELETE=삭제` 4건 확인 + - `GISEDITTYPE`: `C=등록`, `U=수정`, `D=삭제` 3건 확인 +- 전체 TypeScript 검사 + - 명령: `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0` + - 결과: 통과 +- SN 프로덕션 빌드 + - Next.js 최적화 컴파일과 정적 페이지 28개 생성까지 통과했다. + - standalone traced file 복사 단계에서 Windows symlink `EPERM`으로 최종 종료코드 1이 발생했다. + - TypeScript 또는 이번 변경 코드의 컴파일 오류는 아니다. +- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다. +- 실제 지도 화면에서 툴바와 인라인·팝업 세션 라벨이 CMS 응답으로 표시되는지는 로컬 배포 또는 개발 서버 기동 후 수동 확인이 남아 있다. diff --git a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md index 76b4a4478..93e2fe0d1 100644 --- a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md +++ b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md @@ -24,3 +24,12 @@ - 시트명과 B6 `순번`, D6 업무별 사업코드 헤더, B8 데이터 시작 위치를 자동 확인했다. - SN 개발 서버가 3002 포트에서 실행 중이어서 앱 규칙에 따라 `next build`는 실행하지 않았다. - 로그인·실 API 환경에서 조회조건 다운로드와 다운로드→수정→업로드 브라우저 검증은 남은 통합검증이다. + +## 2026-09-01 운영사업 공통코드 화면 적용 빠른 검증 + +- 실제 CMS에서 사업구분 2건, 증빙상태 4건, 사업추진상태 7건을 확인했다. +- 개발 DB에서 사업구분 `정밀조사 130건/정비사업 391건`, 추진현황의 `준공·공사중·설계중·발주중` 등 비표준 기존값, 증빙의 `O/X/공단/요청/준비중` 값을 확인했다. +- 기존값 보존을 위해 추진·증빙 입력은 자유문자 datalist 방식으로 적용했다. +- `git diff --check`는 whitespace 오류 없이 통과했다. +- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다. +- 실제 목록·등록·수정에서 선택 후보와 기존값 저장 회귀 확인이 남아 있다. diff --git a/apps/sn/docs/작업검증/SN-도시침수예방사업/001-qa-SN-도시침수예방사업-FE검증.md b/apps/sn/docs/작업검증/SN-도시침수예방사업/001-qa-SN-도시침수예방사업-FE검증.md index 11f1df2d4..7b6577124 100644 --- a/apps/sn/docs/작업검증/SN-도시침수예방사업/001-qa-SN-도시침수예방사업-FE검증.md +++ b/apps/sn/docs/작업검증/SN-도시침수예방사업/001-qa-SN-도시침수예방사업-FE검증.md @@ -9,3 +9,12 @@ - `/oper/ctyFldngPrvtnBiz/register/1`: HTTP 200 - 0811 원본 구조 확인: `사업 추진 현황` 시트, 상세 시작 `AJ/BK/CS/DX`, 결산 2013~2026년, 데이터 257행을 확인했다. - 실제 API·DB 저장과 브라우저 조작, 원본 파일 업로드 후 DB 대조는 테이블 및 인증 환경에서 추가 확인이 필요하다. + +## 2026-09-01 운영사업 공통코드 화면 적용 빠른 검증 + +- 실제 CMS에서 도시침수 진행단계 4건, 지정유형 2건, 사업추진상태 7건을 확인했다. +- 개발 DB에서 상세단계 `EM/MP/DD/CW` 각 257건, 지정유형 `우려 4건/피해 253건`을 확인했다. +- 추진현황에는 표준 6개 값 외 `완료(연월)` 변형이 다수 있어 자유문자 datalist로 적용했다. +- `git diff --check`는 whitespace 오류 없이 통과했다. +- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다. +- 실제 목록·등록·수정에서 구역 제목, 입력 후보와 기존 변형값 유지 확인이 남아 있다. diff --git a/apps/sn/docs/작업검증/공통-UiTable-컬럼정렬/002-qa-공통-UiTable-컬럼정렬-통합검증결과.md b/apps/sn/docs/작업검증/공통-UiTable-컬럼정렬/002-qa-공통-UiTable-컬럼정렬-통합검증결과.md index 22182ccee..f625a7f5f 100644 --- a/apps/sn/docs/작업검증/공통-UiTable-컬럼정렬/002-qa-공통-UiTable-컬럼정렬-통합검증결과.md +++ b/apps/sn/docs/작업검증/공통-UiTable-컬럼정렬/002-qa-공통-UiTable-컬럼정렬-통합검증결과.md @@ -91,3 +91,41 @@ - 노후관로 요청에는 `includeFlowDirectionBasis`가 없고, 정보검색 기본정보의 하수관로 요청에만 `true`가 전달되는 것을 정적으로 확인했다. - 따라서 노후관로 정렬·페이지 이동마다 유하방향 근거 CTE 배치 조회가 추가 실행되지 않는다. - 실제 응답시간 비교는 변경된 백엔드 재기동 후 사용자 통합 환경에서 확인한다. + +## 11. 2026-08-26 유지관리 대메뉴 검증 + +- `@common/business_components` tsup ESM/DTS 빌드: 성공. +- SN TypeScript `tsc --noEmit`: 성공. +- sewerage-service `compileJava`: 성공. +- 사용자 실행 SN 개발 서버를 보호하기 위해 Next build는 수행하지 않았다. +- 조사 목록의 사업명 서버 정렬은 11건을 10건·1건의 2페이지로 조회했고 asc/desc 변경 시 첫 페이지 복귀를 확인했다. +- 조사 상세현황은 합계 행을 고정한 채 처리분구명 `덕풍↔초이` 순서 전환을 확인했다. +- 관로조사 상세 팝업은 9개 고정 열 정렬 버튼과 보고서번호 asc/desc, 소계·합계 고정을 확인했다. +- 준설 지자체별 13개·관경별 9개 정렬 헤더와 관재질 표시명 asc/desc를 확인했다. +- 처리분구 목록은 4개 정렬 헤더와 처리분구명 asc/desc/default 순환을 확인했다. +- 정렬 아이콘은 미선택 흰색, 선택 노란색이며 한 단계 확대된 상태를 실제 화면에서 확인했다. +- 팝업 콘솔 신규 오류는 0건이다. 메인 화면의 Radix `aria-controls` hydration mismatch는 기존 공통 경고로 분리한다. +- FO·WR 화면은 수정·브라우저 적용하지 않았다. + +## 12. 2026-08-26 운영정보·I/I 분석 검증 + +- SN TypeScript `tsc --noEmit`: 성공. +- 도시침수예방사업 23개 직접 필드 정렬 헤더와 시군구 asc/desc를 확인했다. +- 노후 하수관로 정비사업 22개 정렬 헤더와 시군구 `가평군...` asc, `화순군...` desc를 확인했다. +- 침입수 분석결과는 2025-01~2026-08 범위 48건·5페이지, 7개 헤더와 분석정보 asc/desc를 확인했다. +- RDII 분석결과는 같은 기간 20건·2페이지, 9개 헤더와 기여면적 sort 요청을 확인했다. +- I/I 시도·시군구·분석자는 정렬 버튼이 노출되지 않는 것을 확인했다. +- MapPopup 정렬 아이콘이 유지관리와 같은 미선택 흰색·선택 노란색 스타일을 사용하는 것을 확인했다. +- 운영정보 등록·수정과 I/I 분석 입력 편집표는 정렬 대상에서 제외했다. +- FO·WR는 수정·검증 범위에서 제외했다. + +## 13. 2026-08-26 운영정보 Excel 생성 수정 검증 + +- 사용자 첨부 도시침수·노후관로 생성본과 확정 원본 양식을 값·시트·렌더링 이미지로 비교했다. +- SN TypeScript `tsc --noEmit --ignoreDeprecations 5.0`: 성공. +- 브라우저에서 도시침수 19건과 노후관로 정밀조사 128건을 실제 재다운로드했고 콘솔 오류는 0건이었다. +- 도시침수 첫 데이터 순번이 `1, 2, 3`이고 제목·요약·5단 헤더·본문 테두리·고정 행 높이가 렌더링되는 것을 확인했다. +- 노후관로 두 시트의 제목·요약·다단 병합 헤더·구분색과 22pt 본문 높이를 확인했다. +- 계획연장 합계는 `30,381.602`로 반올림됐고 긴 텍스트의 `_x000d_` 표기는 실제 줄바꿈으로 변환됐다. +- 두 최종 파일의 수식 오류 토큰 검색 결과는 0건이었다. +- 사용자 실행 개발 서버를 보호하기 위해 Next 전체 빌드는 수행하지 않았다. diff --git a/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md b/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md index 39b63f5f3..1e06ae146 100644 --- a/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md +++ b/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md @@ -336,3 +336,32 @@ - 지도 피처 선택 강조는 조사유형 색과 분리하고 기존 공통 지도 선택 효과를 유지한다. - 관로 PDF는 새 브라우저 탭이 아니라 `exmnPipeReport` WindowPopup 안에서 표시한다. - 최종 완료 판정 전에는 지도 관로·맨홀 동시 ON/OFF, 필터 변경, 시설 선택, 관로 PDF 성공·실패 흐름의 로그인 브라우저 회귀가 필요하다. + +## 26. 2026-08-28 사업등록·수정 양식 버튼 계약 + +- 사업등록 진입은 `양식다운로드 → 조사보고서 양식다운로드 → 저장 → 취소` 순서를 사용한다. +- `양식다운로드`는 사업 저장 전 비활성이고, 저장 성공 후 팝업을 유지한 상태에서 처리분구별 입력양식을 생성·다운로드한다. +- 저장 전 비활성 양식다운로드에 마우스를 올리면 `사업정보와 처리분구를 먼저 저장해주세요.` 안내를 표시한다. +- `조사보고서 양식다운로드`는 사업 저장 여부와 무관하게 공통 원본 XLSX를 내려받는다. +- 수정 진입은 `양식다운로드 → 저장 → 취소`만 표시하고 조사보고서 공통 양식 버튼은 숨긴다. +- 수정 화면의 양식다운로드는 저장된 최신 처리분구 기준으로 입력양식을 다시 생성한다. +- 등록된 실제 사업보고서 PDF는 기존 조사목록의 보고서 열에서 다운로드하는 계약을 유지한다. + +## 27. 2026-09-01 지도 조사 설정 카드 UI 통일 계획 + +- `ExmnLayerSettingPanel`의 관로조사·맨홀조사 카드를 침수흔적도 설정과 동일한 header/body 구조로 변경한다. +- 제목 오른쪽의 액션 `div`에는 `적용 여부 토글 → 닫기(×)`를 배치하고 토글 기본값은 ON으로 한다. +- 토글 OFF에서는 해당 조사유형의 사업차수·구조·운영 필터 적용을 해제하되 입력값은 유지하며, 닫기는 카드만 숨기고 레이어 체크·필터 상태를 유지한다. +- 두 조사유형의 적용 여부, 닫기, 입력값은 독립 상태로 관리한다. +- 카드 제목 `1.15rem`, 본문 `1.05rem`, header `32px`, 본문 padding `8px`, 선택 입력 `22px`, font `1rem`, 좌우 padding `4px`를 적용한다. +- 관로 노랑·맨홀 보라 의미색은 indicator로 유지하고 카드 외곽·제목은 중립 스타일로 통일한다. +- 상태등급 select는 침수흔적도 설정의 SelectBox와 같은 font·padding·border/background 스타일을 적용한다. +- 기존 자동반영 동작을 유지하며 원래 없는 `✓` 버튼을 새로 추가하지 않는다. 지반침하·노후관로 등 기존 `✓`가 있는 카드의 버튼은 공통 변경으로 제거하지 않는다. +- `map/page.tsx`의 선택 레이어 설정 header에서는 활성 카드 개수와 카드명 pill/chip 목록을 제거하고 제목만 유지한다. +- 검증은 관로·맨홀 카드 동시 표시, 독립 토글·닫기, 스타일 통일, 자동반영, 상단 개수·chip 미표시와 지도 필터 회귀를 포함한다. +- API·DTO·DB 변경은 없다. + +### 27.1 구현 결과 + +- `ExmnLayerSettingPanel`을 공통 카드 header·토글·닫기 구조로 변경하고 관로·맨홀 상태를 독립 관리한다. +- 사업차수·상태등급의 기존 자동반영은 유지하며 토글 OFF에서는 빈 필터로 다시 조회한다. diff --git a/apps/sn/docs/작업계획/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-작업카드.md b/apps/sn/docs/작업계획/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-작업카드.md index 03072e28a..dc8b2f69e 100644 --- a/apps/sn/docs/작업계획/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-작업카드.md +++ b/apps/sn/docs/작업계획/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-작업카드.md @@ -273,3 +273,24 @@ - 부모가 있는 관로 탭은 활성 여부와 무관하게 `←` 버튼을 항상 표시해 일반 관로와 구분하고 지반침하 상세로 복귀한다. - 공용 우측 탭은 최대 5개 FIFO를 적용하되, 제거 대상이 부모 지반침하이면 그 부모에서 열린 연계 관로도 함께 제거한다. 연계 관로만 제거될 때는 부모 지반침하를 유지한다. - 변경 범위는 `app/(map-layout)/map/page.tsx`이며 BE API·DTO·DB 계약은 변경하지 않는다. + +## 16. 2026-09-01 선택 레이어 설정 지반침하 카드 UI 통일 계획 + +- 작업 모드: `maintenance` +- 이번 단계는 작업계획만 수정하고 코드는 후속 승인 후 변경한다. +- `SinkholeLayerSettingPanel`을 침수흔적도 설정 카드와 동일한 compact 카드 구조로 정리한다. +- 헤더에 `지반침하정보` 제목을 표시하고 우측 액션 `div`에 `적용 여부 토글 → 닫기(×)` 순서로 배치한다. +- 토글 기본값은 ON이며 OFF에서는 연도 조회조건을 제거하되 현재 연도·반경 입력값은 유지한다. +- 닫기는 설정 카드만 숨기고 레이어와 현재 설정값은 유지한다. +- 침수흔적도 설정과 동일하게 카드 header `32px`, 제목 `1.15rem`, 본문 `1.05rem`, 본문 padding `8px`, SelectBox `62×22px`, SelectBox font `1rem`·좌우 padding `4px`를 적용한다. +- 주황 카드·제목 스타일은 제거하고 공통 border/background/label 색상으로 통일한다. +- 반경과 년도설정의 `✓` 적용 버튼은 모두 유지하며 SelectBox 선택만으로 자동 적용하지 않는다. +- `map/page.tsx`의 선택 레이어 설정 공통 header에서는 활성 카드 개수와 카드명 pill/chip 목록을 제거하고 `선택 레이어 설정` 제목만 유지한다. +- 공통 상단 표시 제거는 지반침하·침수흔적도·조사시설·노후관로·침수심 범례 등 모든 설정 카드에 적용한다. +- 검증은 토글/닫기 배치, 카드·SelectBox 스타일 일치, `✓` 적용 유지, 상단 개수·chip 미표시, 다중 카드 순서·스크롤 회귀를 포함한다. +- API·DTO·DB 변경은 없다. + +### 16.1 구현 결과 + +- `SinkholeLayerSettingPanel` compact 카드에 공통 header·토글·닫기와 침수흔적도 기준 입력 스타일을 적용했다. +- 반경·년도 `✓` 적용 버튼은 유지했고 토글·닫기 상태는 `map/page.tsx`에서 관리한다. diff --git a/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md b/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md index 650ca7444..8a605f2a0 100644 --- a/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md +++ b/apps/sn/docs/작업계획/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-작업카드.md @@ -208,3 +208,11 @@ - 요구사항정의서 93행은 합리식 `Q=CIA`와 Manning 공식을 요구하지만, 화면설계서 20쪽은 SWMM5 연계를 제시한다. 실제 산정 기능 설계 전에 적용 기준을 확정해야 한다. - 2026-08-04 실제 API 연동 시 `map/page.tsx`, 세션 타입, popup 메시지 타입에 남아 있던 설정 편집·staged 관로 호환 코드도 제거해 결과 조회와 지도보기 계약만 유지했다. 상세 구현·검증은 `002-fe-SFR-011-007-통수위험분석-실API연동및검증.md`를 따른다. + +## 14. 2026-08-31 행 선택 시 초단기예보격자망 자동 활성화 + +- 원본 추적 근거는 요구사항정의서 `SFR-011-007` 93행과 화면설계서 21쪽 `SN_MN010202`의 결과 행 선택 후 GIS 위치 이동 흐름이다. +- 사용자의 최신 지시에 따라 현재·과거분석이력 결과 행 선택으로 지도 결과를 표시할 때 시설물 레이어의 `강우관련 > 초단기예보격자망`을 자동으로 ON 한다. +- 기존 사용자가 켜둔 다른 시설물 레이어는 유지하고 초단기예보격자망만 추가 체크한다. +- 사용자가 격자망을 다시 끈 뒤 다른 결과 행을 선택해도 새 요청으로 다시 ON 한다. +- 백엔드 API·DB·통수위험 mapView 응답은 변경하지 않는다. diff --git a/apps/sn/docs/작업계획/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측현황-작업계획.md b/apps/sn/docs/작업계획/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측현황-작업계획.md index bb425c07b..17410affd 100644 --- a/apps/sn/docs/작업계획/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측현황-작업계획.md +++ b/apps/sn/docs/작업계획/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측현황-작업계획.md @@ -31,12 +31,15 @@ - `ListDiv` 내부에서 최소 테이블 폭과 가로 스크롤을 제공한다. - Excel은 현재 적용 검색조건을 동일하게 전달한다. - 신규 파일과 함수에 화면/API 역할 한글 주석을 작성한다. +- 결과표는 `시도, 시군구, 분석시각, 강우시작시각, 관측 누적강수량, 예측시간, 예측 누적강수량, 초과 빈도(년), 침수위험도` 순서로 표시한다. +- 예측시간은 `N시간 후`, 관측·예측 누적강수량은 소수점 한 자리로 표시한다. +- 지도화면 분석도구는 공용 분석시각 범위와 `위험` 조건의 count API를 조회하여 `침수예측현황(x건)`으로 표시한다. ## 지도 연계 - 행 선택 시 기존 도시침수지도 group extent API를 호출한다. - 부모 창에 기존 `MAP_SEARCH_SHOW_FLOOD_GROUP` 메시지를 보내 범위 이동·레이어 표시를 요청한다. -- 도시침수지도에 없는 빈도는 자동 변환하지 않고 화면 안내를 표시한다. +- 도시침수지도 빈도는 30년 이하를 `030`, 100년 초과를 `100`으로 보정하고 그 외 미지원 빈도는 안내한다. ## 검증 diff --git a/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측지도-격자표시-작업계획.md b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측지도-격자표시-작업계획.md new file mode 100644 index 000000000..a553df935 --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측지도-격자표시-작업계획.md @@ -0,0 +1,23 @@ +# SFR-011-008 침수예측지도 중앙 오버레이 표시 FE 작업계획 + +## 목적 + +침수예측현황 선택행으로 지도 이동 시 선택한 도시침수지도 중앙에 사각 HTML table 단일 오버레이를 표시한다. 제목은 `[시도] [시군구] 침수예측` 두 열 병합이며, 본문은 분석시각, 강우시작시각, 관측 누적강수량(mm), 예측시각, 예측 누적강수량(mm), 초과 빈도(년), 침수위험도 순서다. 격자별 라벨과 위치 마커는 표시하지 않는다. + +## 구현 방법 + +- 현황 팝업 메시지에 `analysisDateTime`을 추가한다. +- 지도 페이지에서 BE `grid-map` API를 호출한다. +- 기존 도시침수지도 extent 중심 좌표에 위 9개 표시 항목을 table 행으로 렌더링한다. +- 초단기예보격자망 WFS 폴리곤은 기존 레이어 표현을 유지한다. +- 다음 선택 시 전용 중앙 overlay를 삭제하고 새 응답으로 갱신한다. + +현재·예측 누적강우는 FE에서 격자별로 계산하지 않는다. BE가 반환하는 선택 시군구 분석행의 `obsrvn_acml_rn`, `predc_acml_rn`을 사용하며, 선택 도시침수지도 중앙의 단일 table overlay에 표시한다. + +## 검증 + +- 슬라이드 4의 선택 시군구 도시침수지도 중앙 정보 배치 +- 다른 행 선택 시 중앙 overlay 교체 +- 닫기 버튼 클릭 시 overlay 제거 +- 기존 지도 extent 이동·침수위험 레이어 선택 유지 +- API 오류 시 기존 지도 이동 기능 유지 diff --git a/apps/sn/docs/작업계획/SFR-011-009-침수발생이력/002-fe-SFR-011-009-침수흔적도-깊이도연계-작업계획.md b/apps/sn/docs/작업계획/SFR-011-009-침수발생이력/002-fe-SFR-011-009-침수흔적도-깊이도연계-작업계획.md new file mode 100644 index 000000000..b84daf0f0 --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-011-009-침수발생이력/002-fe-SFR-011-009-침수흔적도-깊이도연계-작업계획.md @@ -0,0 +1,28 @@ +# SFR-011-009 FE 침수흔적도-깊이도 연계 작업계획 + +## 1. 상태 + +- 승인 대기 +- 구현 전 계획 문서 +- 범위: `apps/sn` 내부 + +## 2. 화면 계획 + +- 기존 침수흔적도 기본정보 상세 영역 우측에 침수깊이지도 연도 버튼 목록을 추가한다. +- 버튼은 투명 배경, 테두리만 있는 라운드 형태로 기존 상세정보 UI와 조화되게 구현한다. +- 선택 연도의 깊이도 항목은 기본정보 아래 별도 div와 table로 표시한다. +- 상세 div 우측 상단에 `X` 닫기 버튼을 둔다. +- 지도 시설물 클릭과 침수발생이력 선택에서 동일 상태 모델을 사용한다. +- 현재 표시 중인 연도 버튼은 파란색으로 표시한다. +- 연도가 많으면 좌우 화살표로 연도 버튼 목록을 가로 스크롤한다. +- 연도 선택 시 해당 침수흔적도-깊이 feature를 지도에서 선택 상태로 표시한다. + +## 3. 구현 후보 + +- `app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` +- `app/(map-layout)/map/components/fldngHstry/**` +- 관련 침수흔적도 타입/API 호출부 + +## 4. 검증 + +- 연도 중복 제거·정렬, 선택 연도 파란색 상태, 좌우 스크롤, 연도 전환, 닫기, 빈 데이터, 지도 feature 선택, 양쪽 진입 경로 및 기존 상세정보 회귀를 브라우저에서 확인한다. diff --git a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md index 24aa95c8c..b61216ca4 100644 --- a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md @@ -14,20 +14,20 @@ ## 3. 구현 라운드 요약 -| 날짜 | 단계 | 결과 | -| --- | --- | --- | -| 2026-07-22 | FE-0 | 처리분구 조회·추가·명칭수정·삭제대기·일괄저장 UI와 타입/API 계약 구현 | -| 2026-07-22 | FE-0 후속 | 메뉴 진입 시 발생한 Next 서버/클라이언트 import 경계 오류 수정 | -| 2026-07-22 | FE-0 화면 마무리 | 공통 UiTable 정렬과 검색조건·버튼 배치/색상 보완 | -| 2026-07-22 | FE-0 완료 | 2차 UI 보정 후 사용자 화면 검증 통과, FE-1 시작 대기 | -| 2026-07-22 | FE-1 | 사업 목록·등록, 파일 검토/확정, PPL 집계·시설·단위상세 구현 | -| 2026-07-22 | 화면 2단계 완료 | 조사사업 목록과 사업등록·조사결과 업로드 WindowPopup 사용자 확인 완료 | -| 2026-07-23 | FE-1 조사현황 보정 | 목록·상세 가로 기준 통일, 사업 행 전체 선택, 목록·상세 내부 스크롤 통일 | -| 2026-07-23 | 화면 3단계 완료 | 같은 화면의 관로·맨홀 종합집계, 합계, 내부 스크롤, 요청 순서 보호 사용자 확인 완료 | -| 2026-07-23 | 팝업 회귀 복구 | Tailwind `src` 스캔 복구와 핵심 열·폭 인라인 고정 후 완료 디자인 재확인 | -| 2026-07-24 | 상세보기 화면 완료 | 관로·맨홀 WindowPopup 다단표, 조회·정렬, 고정열, 소계·합계 사용자 확인 완료 | -| 2026-07-27~29 | 실 API 통합 | 처리분구·사업·파일검토/저장·집계·전체/본단위/결함별 상세 API 연결 | -| 2026-07-30 | 다운로드·샘플 제거 | 다중 시트 XLSX 다운로드 연결 및 샘플 전환·데이터·분기 제거 | +| 날짜 | 단계 | 결과 | +| ------------- | ------------------ | ---------------------------------------------------------------------------------- | +| 2026-07-22 | FE-0 | 처리분구 조회·추가·명칭수정·삭제대기·일괄저장 UI와 타입/API 계약 구현 | +| 2026-07-22 | FE-0 후속 | 메뉴 진입 시 발생한 Next 서버/클라이언트 import 경계 오류 수정 | +| 2026-07-22 | FE-0 화면 마무리 | 공통 UiTable 정렬과 검색조건·버튼 배치/색상 보완 | +| 2026-07-22 | FE-0 완료 | 2차 UI 보정 후 사용자 화면 검증 통과, FE-1 시작 대기 | +| 2026-07-22 | FE-1 | 사업 목록·등록, 파일 검토/확정, PPL 집계·시설·단위상세 구현 | +| 2026-07-22 | 화면 2단계 완료 | 조사사업 목록과 사업등록·조사결과 업로드 WindowPopup 사용자 확인 완료 | +| 2026-07-23 | FE-1 조사현황 보정 | 목록·상세 가로 기준 통일, 사업 행 전체 선택, 목록·상세 내부 스크롤 통일 | +| 2026-07-23 | 화면 3단계 완료 | 같은 화면의 관로·맨홀 종합집계, 합계, 내부 스크롤, 요청 순서 보호 사용자 확인 완료 | +| 2026-07-23 | 팝업 회귀 복구 | Tailwind `src` 스캔 복구와 핵심 열·폭 인라인 고정 후 완료 디자인 재확인 | +| 2026-07-24 | 상세보기 화면 완료 | 관로·맨홀 WindowPopup 다단표, 조회·정렬, 고정열, 소계·합계 사용자 확인 완료 | +| 2026-07-27~29 | 실 API 통합 | 처리분구·사업·파일검토/저장·집계·전체/본단위/결함별 상세 API 연결 | +| 2026-07-30 | 다운로드·샘플 제거 | 다중 시트 XLSX 다운로드 연결 및 샘플 전환·데이터·분기 제거 | ### 3.1 현재 구현 경계 @@ -706,11 +706,11 @@ ### 화면·API 계약 -| 구분 | 계약 | -|---|---| -| 지도 이동 | `/map?exmnBizId=...&ctpvNm=...&sggNm=...&exmnJobType=PPL|MNHL&bizCycl=...` | -| 레이어 조회 | `/sewerage-service/api/v1/exmnMap/layers` | -| 필터 | `jobType`, `sggCd`(앞 5자리), 행정구역명, 사업차수, 구조적 등급, 운영적 등급 | +| 구분 | 계약 | +| ----------- | ---------------------------------------------------------------------------- | ----------------- | +| 지도 이동 | `/map?exmnBizId=...&ctpvNm=...&sggNm=...&exmnJobType=PPL | MNHL&bizCycl=...` | +| 레이어 조회 | `/sewerage-service/api/v1/exmnMap/layers` | +| 필터 | `jobType`, `sggCd`(앞 5자리), 행정구역명, 사업차수, 구조적 등급, 운영적 등급 | ### 2026-07-31 지도보기 초기 시군구 선택·대표 관로 확대 보완 @@ -718,9 +718,9 @@ - 공통 지도 검색바는 외부 선택 이벤트가 시군구 원천 목록 조회보다 먼저 도착해도 목록 로딩 후 현재 시도에 맞는 시군구 후보를 다시 구성한다. - 최초 조사결과 레이어 조회 성공 시 진입한 조사유형의 첫 시설 한 건을 기준으로 최대 줌 18까지 한 번만 확대한다. - 이후 사용자의 지도 이동이나 필터 재조회에서는 초기 자동 확대를 반복하지 않는다. -| 필터 결합 | 구조·운영 등급 동시 입력 시 `AND` | -| 좌표계 | GeoJSON `EPSG:5179` | -| 레이어 선택 | 기존 지도 선택 모듈에 PPL 선·MNHL 점 레이어 등록 | + | 필터 결합 | 구조·운영 등급 동시 입력 시 `AND` | + | 좌표계 | GeoJSON `EPSG:5179` | + | 레이어 선택 | 기존 지도 선택 모듈에 PPL 선·MNHL 점 레이어 등록 | ### 수정 파일 @@ -934,3 +934,29 @@ - Excel 데이터 내보내기는 `엑셀다운로드`, 입력 템플릿은 `양식다운로드`, 일반 첨부파일은 `다운로드`로 구분했다. - `StandardPopupLayout` 본문을 margin 기반 `space-y` 대신 `flex`와 `gap: 1.2rem`으로 변경했다. 자식 카드의 `!m-0` 때문에 간격이 0으로 덮이던 문제를 공통 레이아웃에서 해결했다. - 브라우저에서 조사사업 등록·수정의 `사업 정보`와 `사업대상 처리분구` 카드 사이 실제 간격이 모두 12px인 것을 확인했다. + +## 45. 2026-08-28 조사 입력양식 다운로드 노출·Blob 처리 보완 + +- 사업별 조사 입력양식은 저장된 `exmnBizId`와 확정 처리분구가 있어야 생성할 수 있으므로 신규 등록 화면에서는 `양식다운로드` 버튼을 숨겼다. +- 수정 화면에는 `양식다운로드`를 유지하고, 저장과 무관한 공통 `조사보고서 양식다운로드`는 등록·수정 모두 유지한다. +- Blob 다운로드 링크를 DOM에 연결한 뒤 클릭하고 1초 후 object URL을 해제하도록 변경해 Chrome가 파일을 수락하기 전에 URL이 사라지는 문제를 방지했다. +- 사업·처리분구 저장, 양식 생성 API와 DB 계약은 변경하지 않았다. + +## 46. 2026-08-28 사업등록·수정 양식 버튼 분리 + +- 신규 사업등록 저장 성공 시 부모 목록에 `EXMN_BUSINESS_CREATED` 메시지를 전달하되 팝업은 닫지 않도록 변경했다. +- 저장 후 기존 `savedBusiness`와 `formLocked`를 사용해 입력은 잠그고 `양식다운로드`를 활성화한다. +- 저장 전 비활성 버튼은 hover 가능한 래퍼에서 `사업정보와 처리분구를 먼저 저장해주세요.` 안내를 제공한다. +- 사업등록 진입에서는 `양식다운로드`, `조사보고서 양식다운로드`, `저장`, `취소` 순서로 표시한다. +- 수정 진입에서는 `조사보고서 양식다운로드`를 숨기고 `양식다운로드`, `저장`, `취소`만 표시한다. +- 처리분구를 수정한 경우 기존 계약대로 먼저 저장한 뒤 최신 처리분구 기준 입력양식을 다시 생성한다. +- 공통 조사보고서 XLSX와 등록된 사업보고서 PDF의 다운로드 경로는 변경하지 않았다. + +## 47. 2026-09-01 조사사업 공통코드 프론트 표시 1차 적용 + +- `EXMNDFCGRD`, `EXMNRECDTYPE`, `EXMNDFCTYPE`, `EXMNDFCSEV`, `EXMNSTTSGRP`, `PPLEXMNITEM`, `MNHLEXMNITEM`을 기존 CMS 캐시 API로 조회하고 브라우저 세션에서 그룹별 응답과 진행 중 요청을 재사용한다. +- PPL 시설 필터의 구조·운영등급 선택지와 결함분류 표시, 조사 상세표·Excel의 결함분류·심각도·관로·맨홀 항목명에 CMS 코드명을 적용했다. +- 본단위등급표와 PPL 시설 상세의 기록유형·결함분류·심각도·상태그룹 표시에도 같은 코드명을 적용했다. +- 조사 코드값, 상세표 열 순서, API 파라미터와 백엔드 Excel 파서·분류 목록은 변경하지 않았다. +- CMS 조회 실패 또는 미등록 코드에는 기존 화면 라벨과 서버 제공값을 fallback으로 유지한다. +- 백엔드 API와 DB는 변경하지 않았다. diff --git a/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md index 2babe2395..adb7fecea 100644 --- a/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md @@ -473,6 +473,7 @@ - `SinkholePopupPage.tsx` 그리드 첫 데이터 열에 `사고번호(acdntNo)`를 배치하고 화면 `NO` 열은 제거했다. 내부 `rowNo`와 다운로드 순번은 유지했다. - 반경 내 관로 GeoServer 조회의 기존 storage nullable 타입 경고는 저장소 이름 확인 후 요청하도록 방어 처리했다. - BE API·DTO·Repository·DB 변경은 없다. + - 브라우저·API·빌드 검증은 사용자 요청에 따라 수행하지 않았으며 사용자 검증 대기 상태다. ### 4.55 2026-07-14 목록 NO 열 제거 후속 반영 @@ -499,3 +500,13 @@ - 부모가 있는 지반침하 연계 관로는 비활성 상태에서도 `←`를 항상 표시해 일반 관로와 구분되도록 후속 보완했다. - 탭 최대 5개 초과 시 가장 먼저 열린 탭을 제거하는 FIFO를 유지하고, 제거 대상이 부모 지반침하이면 그 부모를 참조하는 연계 관로 탭도 함께 제거하도록 공통 그룹 제거 함수를 적용했다. 연계 관로 단독 제거는 부모에 영향을 주지 않는다. - BE API·DTO·Repository·DB 변경은 없다. + +### 4.58 2026-09-01 지반침하 상태 공통코드 적용 + +- `SNKHLRSTRSTTS`, `SNKHLCRDSTTS`를 기존 CMS 캐시 API로 조회하고 브라우저 세션에서 그룹별 응답과 진행 중 요청을 재사용한다. +- 좌표보정상태는 기존 `SUCCESS/FAILED` 검색값을 유지하고 선택지·목록 표시명을 공통코드의 `보정성공/보정실패`로 표시한다. +- 복구상태는 백엔드가 외부 원천의 한글 설명값을 검색·응답하므로 `RESTORED/RESTORING/NOT_RESTORED`를 API에 전송하지 않는다. CMS의 코드명을 기존 한글 검색값으로 사용해 계약을 유지한다. +- 목록은 원본 상태 필드를 유지한 별도 표시 필드를 사용하고, Excel과 상세 패널에도 같은 CMS 코드명을 적용한다. +- CMS에 없는 기존 좌표상태 `NO_ADDRESS`는 `주소없음` fallback을 유지한다. +- CMS 조회 실패 시 기존 선택지와 상태 문구를 유지한다. +- 백엔드 API, 외부 원천 데이터와 DB는 변경하지 않았다. diff --git a/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md b/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md index ce801bfbd..0b6a5370c 100644 --- a/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md @@ -333,3 +333,32 @@ - 수정 화면은 기존과 같이 관로정보·준설일자·사진·위치·자동매핑 정보를 모두 제공하므로 조회에 필요한 정보 확인과 편집 기능은 유지된다. - 수정 화면에서도 사용하는 검색 파라미터 복원 로직은 중립 유틸 파일로 분리했으며 API, 저장 payload, 부모창 갱신 메시지는 변경하지 않았다. - 요구사항정의서 sheet3의 `SFR-010-013`은 준설 등록·수정·삭제·조회를 요구하지만 별도 상세 버튼을 강제하지 않는다. 화면설계서 슬라이드 51의 `SN_MN040201`과 슬라이드 53의 `SN_MN040202`도 등록·조회·다운로드 중심이며, 슬라이드 57의 별도 세부현황은 `화면 삭제`로 표시되어 있다. + +## 20. 2026-08-28 일반 HTTP 준설 팝업 UUID 호환성 보완 + +- 원격 `http://con.mind-one.co.kr:11302/popup/dredgingRegister`에서 `crypto.randomUUID is not a function` 예외로 등록 팝업이 렌더링 전에 중단됐다. +- `localhost`는 브라우저가 보안 컨텍스트 예외로 취급하지만 일반 HTTP 주소에서는 `crypto.randomUUID()` 또는 `crypto` 자체가 제공되지 않을 수 있다. +- `lib/uuid.ts`에 준설 전용 UUID v4 생성 함수를 추가했다. + - `crypto.randomUUID`가 있으면 기존 동작을 유지한다. + - 없으면 `crypto.getRandomValues`를 사용하고 UUID version·variant 비트를 적용한다. + - Web Crypto 자체가 없으면 36자 UUID v4 형식을 유지하는 최종 fallback을 사용한다. +- 등록 건 ID, 신규 사진 key, 수정 사진 key, 백엔드 `clientRequestId`, mock 작업번호의 직접 `randomUUID` 호출을 모두 공통 함수로 교체했다. +- 백엔드 UUID 계약, 등록 payload, DB 구조는 변경하지 않았다. +- 추적 근거: 요구사항정의서 `SFR-010-012` G84, 화면설계서 슬라이드 53~59와 화면 ID `SN_MN040201~040203`. + +## 21. 2026-08-28 촬영일시 날짜 표출 통일 + +- 등록 화면 사진 표와 수정 화면의 기존·신규 사진 목록에서 촬영일시를 `YYYY-MM-DD`까지만 표시한다. +- `lib/date.ts`에 촬영일시 표출 전용 함수를 추가해 ISO `T` 구분, 공백 구분과 날짜 단독 문자열을 같은 형식으로 처리한다. +- 존재하지 않는 날짜나 빈 값은 기존 화면별 fallback(`-`, `촬영일시 없음`)을 유지한다. +- EXIF 원본 `capturedAt`, 첨부정보와 백엔드 저장 payload는 변경하지 않고 화면 렌더링만 변경했다. +- 추적 근거는 요구사항 `SFR-010-012`, 화면설계서 슬라이드 53의 `SN_MN040201`이며 세부 시간 숨김은 사용자 지시에 따른 표출 보완이다. + +## 22. 2026-09-01 준설 공통코드 표시명 적용 + +- `DRDGNREGMODE`, `DRDGNLOCSRC`, `DRDGNPHOTOSTTS`를 기존 CMS 캐시 API로 조회하고 브라우저 세션에서 그룹별 응답과 진행 중 요청을 재사용한다. +- 등록·수정 화면의 `PHOTO/MANUAL` 버튼명, 위치출처 표시와 등록 화면 사진 메타상태명을 CMS의 `lwpoCdNm`으로 표시한다. +- 등록방식, 위치출처, 사진상태 코드값과 저장·검증 분기는 변경하지 않았다. +- CMS에 없는 기존 `UNSUPPORTED` 상태는 `수동 입력 필요` fallback을 유지한다. +- CMS 조회 실패 시 기존 화면 문구를 fallback으로 사용한다. +- 백엔드 API, 준설 저장 payload와 DB 구조는 변경하지 않았다. diff --git a/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md index 67235202d..504b89518 100644 --- a/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md @@ -46,3 +46,42 @@ - 엑셀 다운로드는 화면에 마지막으로 적용된 필터·정렬과 같은 조건으로 전체 상세 데이터를 다시 조회한다. - `관로현황`은 페이지당 10건으로 조정하고, 팝업 본문은 필터 영역과 테이블 영역을 분리해 페이지네이션이 현재 창 높이 안에서 바로 보이도록 정리했다. - 이번 수정으로 `관로현황` 팝업의 기존 페이징 기능을 유지하면서, 사용자가 필터 입력만 바꿔 둔 상태에서 페이지 이동이나 정렬을 눌러도 미적용 초안 값이 섞이지 않고 페이지네이션도 다시 노출되도록 정리했다. + +## 8. 2026-08-26 대구광역시 서구 처리구역 선택 보완 + +- 처리구역 WFS 원천의 시군구코드가 기준코드와 다를 수 있으므로, 시군구 선택 시 시도 범위의 처리구역을 조회한 뒤 `기준 시군구코드 또는 시군구명`이 일치하는 행만 남긴다. +- 선택된 정상 시군구코드를 처리구역 옵션에도 유지해 이후 화면/API 조건이 잘못된 원천코드로 되돌아가지 않게 했다. +- 대구광역시 서구 선택 시 `달서천처리구역`, `북부처리구역` 옵션이 노출되는 것을 확인했다. + +## 9. 2026-08-26 관로현황 용도·관재질 검색조건 전체 코드 보완 + +- 용도 선택지는 화면에 하드코딩된 합류관·차집관·우수관·오수관 4개 대신 공통 `MS_SWAGE_PPL_USE_CODES` 전체를 사용한다. +- 이에 따라 `미분류(SBA000)`와 `기타(SBA999)`가 검색조건에 추가됐다. +- 관재질 선택지는 현재 페이지의 최대 10개 결과에서 추출하던 방식을 제거하고 공통 `MS_SWAGE_PPL_CODES` 전체를 사용한다. +- 상위 집계에서 전달된 관재질 코드가 공통 목록에 없는 경우에는 기존 fallback 옵션 추가를 유지한다. + +## 10. 2026-08-26 관로현황 선택 시 최상위 지도창 포커스 보완 + +- 기존 상세창은 `window.opener?.opener`인 최상위 지도창에 관로 선택 메시지만 전달했다. +- 관로행 클릭 시 최상위 지도창에 고정 window name을 부여하고 같은 이름의 기존 창을 다시 선택해 `focus()`를 요청한다. +- 상세창과 중간 관로분석창을 `blur()`한 뒤 지도창을 활성화해 중간 팝업에 포커스가 머무는 경우를 방지한다. +- 최상위 지도창도 메시지 처리 시작과 지도 이동·상세 패널 렌더링 완료 시점에 각각 `window.focus()`를 요청한다. +- 기존 관로 지도 이동, 강조와 우측 상세정보 표출은 그대로 유지한다. + +## 11. 2026-08-26 관로현황 선택 관로 유하방향 근거 보강 + +- 관로현황 상세 API 행에는 `flowDirectionBasis`가 없어 지도 상세 패널을 행 데이터로 갱신하면 유하방향 근거가 `-`로 표출됐다. +- 관로행 클릭 시 시설관리번호와 관리기관코드로 `v1/fclts` 한 건을 `includeFlowDirectionBasis=true` 조건으로 조회한다. +- 조회 결과를 기존 관로현황 행에 병합한 뒤 지도 상세 패널에 전달한다. +- 보강 API가 실패하면 지도 이동과 기존 행 상세는 유지하고 경고 로그만 남긴다. +- 하수관로 유하방향 레이어 계산과 DB 구조는 변경하지 않는다. + +## 12. 2026-08-28 하수관로 상세 공통 유하방향 근거 보강 + +- 사용자 재확인에서 관로분석 행 선택 외의 지도 피처 클릭 경로는 여전히 `유하방향 근거`가 `-`로 표시되는 것을 확인했다. +- 관로분석 메시지 처리 함수에 두었던 한정 보강을 제거하고 `FeatureClickEventPopup`의 하수관로 상세 공통 로딩 흐름으로 이동했다. +- 지도 피처 클릭, 정보검색/관로현황 연계 등 진입 경로와 관계없이 시설관리번호·관리기관코드가 유효하면 `v1/fclts?includeFlowDirectionBasis=true` 한 건을 조회한다. +- 기존 피처 정보와 자체입력정보를 먼저 병합한 뒤 유하방향 응답을 최종 병합해 기존 상세 필드를 잃지 않도록 했다. +- 동일 관로의 상세 효과가 개발 모드에서 반복 실행돼도 시설관리번호·관리기관코드별 요청 Promise를 재사용해 중복 API 호출을 막는다. 실패한 요청은 캐시에서 제거해 재시도를 허용한다. +- 대상 관로 `2015303665`의 화면 표출값은 `시종점 관저고`이며, 계산코드는 `DEPTH`이다. +- 수정 파일은 `app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx`이며, `map/page.tsx`는 관로 선택 행을 그대로 상세 컴포넌트에 전달한다. diff --git a/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md index e0e0b5521..eb32a40df 100644 --- a/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md @@ -216,7 +216,7 @@ - 분석기준시간은 DB 결과와 독립된 현재 정시이므로 진행중 데이터가 없어도 사라지지 않는다. 입력창으로 오해하지 않도록 테두리·배경 없이 글씨만 표시한다. - `분석기준시간` 라벨과 시각은 현재 탭 행의 오른쪽 끝으로 이동하고 검색패널에는 중점관리지역 선택과 우측 조회 버튼만 유지한다. - 행 선택 성공 후 왼쪽 아래 안내문은 제거하고 오류 메시지만 조건부 표시한다. -- 지도 요약카드에는 capacity 0.8 초과 관로 존재 여부가 위험/정상을 결정한다는 설명을 추가했다. +- 지도 요약카드에는 GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면 위험이라는 기준만 간결하게 표시한다. - 결과표 `No.`를 `No`로 바꾸고 표 전체를 `1320px` 고정폭으로 지정한 뒤 `table-fixed`·`colgroup` 비율을 적용해 1·2페이지의 절대 열 너비를 동일하게 고정했다. - 공통 그리드 교체 후 검색영역과 결과영역의 가로·세로 overflow를 숨기고 결과영역 좌우 여백을 보정해 팝업 내부 스크롤바가 나타나지 않게 했다. @@ -245,3 +245,18 @@ - 중점관리지역 아래 capacity 설명 자체에는 구분선을 두지 않고 문장 안의 `위험`은 빨간색, `정상`은 파란색 텍스트로 강조한다. 설명과 수치표의 경계인 표 상단선은 유지한다. - 기존 위험상태 배지와 카드 `290px` 폭을 유지한다. - 팝업은 `pagehide`·`beforeunload`에서 `WATER_RISK_CLOSED` 메시지를 부모로 전달한다. 부모는 메시지 수신 즉시 중점관리지역·관로·맨홀 레이어, capacity/Flooding 범례, 정보카드와 선택상태를 모두 초기화하며 300ms close watcher는 예비 감시로 유지한다. + +## 17. 2026-08-26 과거분석이력 최신순 기본 정렬·행 키 보완 + +- 팝업 최초 조회와 현재·과거 탭 전환 시 정렬 파라미터를 비워 백엔드 기본 강우시작시간 최신순을 적용한다. +- 사용자가 다른 정렬 열을 선택하면 기존 서버 정렬을 유지하고, 탭을 전환하면 다시 강우시작시간 최신순으로 돌아간다. +- 동일 날짜·지역에 여러 강우이벤트가 존재해도 React 행 키가 겹치지 않도록 분석일·강우시작시간·분석기준시간·행정코드·지역번호를 조합한다. + +## 18. 2026-08-31 통수위험 행 선택과 초단기예보격자망 연계 + +- 부모 지도에서 `WATER_RISK_SELECT_RESULT` 메시지를 정상 검증한 뒤 통수위험 결과·mapView 상태와 함께 초단기예보격자망 체크 요청을 생성한다. +- 공통 `Gis`에 `checkedLayerRequest`를 추가하고 `GisTree`가 레이어명을 기준으로 기존 체크 상태에 대상 노드를 합치는 방식으로 구현했다. +- 대상 노드와 조상인 `강우관련` 그룹을 펼쳐 자동 체크 결과를 사용자가 트리에서 바로 확인할 수 있게 했다. +- 요청마다 `requestKey`를 증가시켜 사용자가 격자망을 수동 해제한 후 다른 통수위험 행을 선택해도 다시 활성화한다. +- 지도 체크박스 DOM을 직접 조작하지 않고 기존 `checkSpecificNode` 경로를 사용해 실제 WFS 레이어와 체크 상태, `onCheckedLayersChange` 콜백을 함께 갱신한다. +- 공통 기능은 요청이 없는 기존 `Gis` 사용 화면에서 동작하지 않으므로 타 화면의 기본 체크 계약은 유지된다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측지도-격자표시-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측지도-격자표시-구현기록.md new file mode 100644 index 000000000..9a3b4f0b1 --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/001-fe-SFR-011-008-침수예측지도-격자표시-구현기록.md @@ -0,0 +1,17 @@ +# SFR-011-008 침수예측지도 격자 표시 FE 구현기록 + +## 구현 내용 + +- 침수예측현황 선택행의 `analysisDateTime`을 지도 이동 메시지에 포함했다. +- BE `grid-map` 응답의 `latcCd`를 GeoServer `tb_ms_shtm_frcst_latc_info` WFS CQL 필터로 사용한다. +- 격자 폴리곤 내부점에 `latc_cd`, 침수위험도, 현재·예측 누적강우 라벨을 표시한다. +- `SN_FLDNG_PREDICTION_GRID_LABEL` 전용 레이어로 재선택 시 오버레이를 교체한다. + +## 설계 변경 대기 + +사용자 확인에 따라 최종 구현 대상은 격자별 라벨이 아니라 선택한 도시침수지도 중앙의 단일 오버레이로 변경되었다. 기존 격자별 구현은 중앙 오버레이 구현으로 교체한다. + +## 기준 + +- `침수예측.pptx` 4페이지 +- 요구사항 `SFR-011-008` diff --git a/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md b/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md index a5918dc76..4eea57a85 100644 --- a/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md @@ -145,3 +145,38 @@ - 두 파일 모두 16,670건, EPSG:5179, SQLite 무결성 `ok` - `fid INTEGER PRIMARY KEY` 단일 컬럼과 공간 인덱스 확인 - 최종 브라우저 콘솔 오류 및 경고 없음 + +## 8. 2026-08-28 GIS 관리 관리기관 목록 초기화 보완 + +### 8.1 추적 근거 + +- 화면설계서 슬라이드 35 `SN_MN010302`, 요구사항 `SFR-012-002`의 GIS 관리 팝업·시설정보 선택·업로드 흐름을 기준으로 확인했다. +- 요구사항정의서 `3. (기능)요구사항정의 및 추적표`의 `SFR-012-002`를 대조했다. + +### 8.2 원인 + +- 팝업 초기화가 시설유형 API와 관리기관 API를 하나의 `Promise.all` 및 단일 `try/catch`로 처리했다. +- 어느 한 API라도 일시 실패하면 관리기관 API가 정상이어도 `setMngInstOptions`가 실행되지 않아 콤보박스에는 `선택`만 남았다. + +### 8.3 변경 내용 + +- 시설유형과 관리기관 조회를 독립 비동기 함수로 분리했다. +- 시설유형 조회 실패 시 기본 하수관로 설정을 유지하고, 관리기관 조회 결과는 별도로 정렬·반영한다. +- 관리기관 조회 실패와 정상 빈 결과를 구분한 사용자 안내를 추가했다. +- BE API·DB·업로드/다운로드 계약은 변경하지 않았다. + +### 8.4 GIS 업로드 목록 이동 버튼 문구 + +- 업로드 이력을 다시 조회하고 목록 화면으로 전환하는 버튼명을 `조회`에서 `목록`으로 변경했다. +- 기본 업로드 이력 화면과 오류·미리보기 화면의 동일 액션 문구를 함께 통일했다. +- 버튼 이벤트와 API 요청은 변경하지 않았다. + +### 8.5 2026-09-01 GIS 관리 공통코드 1차 적용 + +- `GISULDSTTS`, `GISDWNLDSTTS`, `SNFCLTSE`를 기존 CMS 캐시 URL로 기능 진입 시 병렬 조회한다. +- 같은 브라우저 세션에서는 그룹별 조회 결과와 진행 중 요청을 재사용해 팝업 재진입 시 중복 호출을 줄였다. +- 업로드의 `COMPLETED/VALIDATION_FAILED/FAILED`와 다운로드의 `QUEUED/RUNNING/READY/FAILED` 분기는 그대로 유지하고 안내문 fallback에 공통코드명을 사용한다. +- 다운로드 대기 중에는 현재 작업상태를 공통코드명으로 갱신한다. +- 시설구분은 백엔드 지원범위 `PPL` 단일을 유지하면서 화면 표시명만 `SNFCLTSE`의 `PPL=관로`로 표시한다. `MNHL` 기능은 새로 활성화하지 않았다. +- CMS 조회 실패 시 기존 업로드·다운로드·하수관로 문구를 fallback으로 사용한다. +- 백엔드 API, 업로드·다운로드 payload와 DB 계약은 변경하지 않았다. diff --git a/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md b/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md index d764b6ba6..63de2ce70 100644 --- a/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md @@ -488,3 +488,13 @@ - 편집 재진입 시 `gisEditSessionView`를 `inline`으로 복원한다. - 팝업 sync effect도 편집 모드가 꺼져 있으면 실행하지 않는다. - `useGisEdit`의 변경분 배열은 유지하므로 다시 진입하면 기존 변경 카드가 복원된다. + +### 4.32 2026-09-01 GIS 편집작업 공통코드 1차 적용 + +- 기존 CMS 호출 구조인 `getCmsData('/api/v1/code/cache/{upCd}')`를 유지한 `getCommonCodes(upCd)` 타입 안전 조회 함수를 추가했다. +- 기존 `searchCmmnCd()`도 같은 조회 함수를 사용하도록 정리해 CMS의 배열 응답을 `getData()`의 공통 응답 wrapper로 잘못 해제하던 경로를 제거했다. +- `useGisEditOperationLabels()`는 `GISEDITOPRT`를 브라우저 세션당 한 번 조회하고 툴바와 인라인·윈도우 편집 세션이 같은 코드명을 공유한다. +- `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE` 타입과 상태 분기는 유지하고 표시명만 공통코드의 `lwpoCdNm`으로 교체했다. +- CMS 조회 실패 시에는 기존 `추가/이동/꼭지점편집/삭제` 문구를 fallback으로 사용해 편집 기능 자체가 중단되지 않게 했다. +- `GISEDITTYPE(C/U/D)`는 실제 CMS 응답까지 확인했지만 현재 SN GIS 편집 화면에 직접 표시되는 소비처가 없어 불필요한 조회는 추가하지 않았다. +- 백엔드 API, 저장 payload, DB 구조는 변경하지 않았다. diff --git a/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md b/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md index c226d6a37..0bf92de3e 100644 --- a/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md +++ b/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md @@ -139,3 +139,12 @@ - 공통 팝업의 12px 간격과 중복되던 별도 여백을 제거하고, WindowPopup 크기를 `1400×720`으로 조정했다. 빈 목록은 목록 카드가 남은 세로 공간을 채우도록 해 카드 밖에 과도한 공백이 남지 않게 했다. - `StandardDataTable`에 팝업 전용 바깥 margin 제거 옵션을 추가해 이 팝업의 검색 카드와 목록 카드 좌우 기준선을 동일하게 맞췄다. 일반 페이지의 기존 카드 margin은 그대로 보존한다. 빈 목록은 남은 높이를 채우고, 조회 결과가 있으면 표의 자연 높이를 보존해 팝업 본문만 세로 이동하도록 공통 동작을 구분했다. - API, 조회조건, 다중선택, Excel 다운로드, 등록과 부모창 메시지 계약은 변경하지 않았다. + +## 2026-09-01 운영사업 공통코드 화면 적용 + +- `OBSLTBIZSE`, `EVDNCSBMSNSTTS`, `BIZPRGRSSTTS`를 기존 CMS 캐시 API로 조회하고 브라우저 세션에서 재사용한다. +- 사업구분 Select는 CMS 코드명을 기존 한글 전송값으로 사용해 `정밀조사/정비사업` DB·API 계약을 유지한다. +- 추진현황과 증빙서류 상태는 기존 `준공`, `발주중`, `O/X`, `공단` 등의 자유문자를 보존하면서 CMS 표준값을 datalist 선택 후보로 제공한다. +- 목록은 CMS 코드값 또는 코드명과 정확히 일치하는 값만 코드명으로 표시하고 미등록 기존 값은 그대로 표시한다. +- 영문 `lwpoCd` 저장 전환과 기존 데이터 이관은 이번 범위에서 수행하지 않았다. +- 백엔드 API, Excel 입출력과 DB는 변경하지 않았다. diff --git a/apps/sn/docs/작업내용/SN-도시침수예방사업/001-fe-SN-도시침수예방사업-구현내용.md b/apps/sn/docs/작업내용/SN-도시침수예방사업/001-fe-SN-도시침수예방사업-구현내용.md index ce42efdcb..51fd49900 100644 --- a/apps/sn/docs/작업내용/SN-도시침수예방사업/001-fe-SN-도시침수예방사업-구현내용.md +++ b/apps/sn/docs/작업내용/SN-도시침수예방사업/001-fe-SN-도시침수예방사업-구현내용.md @@ -14,3 +14,11 @@ - 신규 FE feature와 route만 추가했다. - 기존 공통 store와 UI 컴포넌트는 소비만 하며 수정하지 않았다. + +## 2026-09-01 운영사업 공통코드 화면 적용 + +- `CTYFLDNGPRGRSSE`, `CTYFLDNGDSGNTYPE`, `BIZPRGRSSTTS`를 기존 CMS 캐시 API로 조회한다. +- `EM/MP/DD/CW` 상세 구역 제목을 CMS 코드명으로 표시하되 상세 코드와 Excel 열 순서는 유지한다. +- 지정유형과 두 추진현황 입력은 자유문자 기존값을 유지하면서 CMS 표준값을 datalist 후보로 제공한다. +- 목록은 CMS와 정확히 일치하는 지정유형·추진현황만 코드명으로 표시하고 `완료(20.06.)` 같은 기존 상세값은 그대로 표시한다. +- 영문 코드 저장과 기존 데이터 이관은 수행하지 않았으며 백엔드 API·Excel·DB는 변경하지 않았다. diff --git a/apps/sn/docs/작업내용/공통-UiTable-컬럼정렬/001-fe-공통-UiTable-컬럼정렬-통합구현기록.md b/apps/sn/docs/작업내용/공통-UiTable-컬럼정렬/001-fe-공통-UiTable-컬럼정렬-통합구현기록.md index ecc9f8b0b..92ecd69d1 100644 --- a/apps/sn/docs/작업내용/공통-UiTable-컬럼정렬/001-fe-공통-UiTable-컬럼정렬-통합구현기록.md +++ b/apps/sn/docs/작업내용/공통-UiTable-컬럼정렬/001-fe-공통-UiTable-컬럼정렬-통합구현기록.md @@ -129,3 +129,33 @@ - 정보검색 기본정보의 하수관로 목록만 `includeFlowDirectionBasis=true`를 전달해 현재 페이지의 유하방향 근거를 배치 보강한다. - 노후관로 목록은 해당 파라미터를 전달하지 않아 페이징·CQL 필터·WFS 서버 정렬과 기존 시설 정보 병합만 수행한다. - 정렬 정확도를 위해 전체 필터 결과의 서버 정렬은 유지하며, 현재 페이지 배열만 정렬하는 임시 우회는 사용하지 않는다. + +## 16. 2026-08-26 유지관리 대메뉴 적용 + +- 사용자 최신 지시에 따라 FO·WR는 적용하지 않고 SN `유지관리` 대메뉴만 구현했다. +- 공통 `StandardTableSortHeader`, `sortStandardTableRows`를 추가해 StandardDataTable 계열이 기존 UiTable과 같은 3상태 정렬을 선택 사용하도록 했다. +- 미선택 아이콘은 흰색, 선택 아이콘은 노란색이며 헤더 높이를 유지한 채 아이콘을 한 단계 확대했다. +- 조사 목록은 `page/size=10` 서버 페이징과 기존 `ExmnBusinessRepositoryImpl` 정렬을 연결했다. +- 조사 상세현황과 관로·맨홀 상세 팝업은 전체 결과 로컬 정렬을 적용하고 합계·소계·가변 결함·병합 본단위표는 제외했다. +- 준설 지자체별·관경별·경과년도별은 전체 페이지 수집 결과를 정렬한 뒤 10건 페이지를 자르고 Excel에도 정렬 순서를 유지한다. +- 처리분구관리는 전체 활성 목록을 정렬한 뒤 페이지를 자르며 추가·수정 시작 시 기본순서로 복귀한다. +- 상세 화면·정렬 컬럼·제외 사유는 `COMMON_DOCS_ROOT/작업내용/공통-UiTable-컬럼정렬/003-ai-공통-UiTable-컬럼정렬-20260826-유지관리대메뉴-구현기록.md`에 통합했다. + +## 17. 2026-08-26 운영정보·I/I 분석 적용 + +- 도시침수예방사업 직접 필드 23개와 노후 하수관로 정비사업 22개를 기존 서버 Pageable 정렬에 연결했다. +- 두 운영정보 Excel 전체 페이지 수집은 현재 화면 sort를 유지한다. +- 결산연도·단계별 자식 상세와 등록·수정 입력표는 제외했다. +- 침입수 결과 7개, RDII 결과 9개 원천 필드를 서버 정렬에 연결했다. +- I/I 코드명·외부 사용자명 컬럼은 잘못된 코드·UUID 정렬 대신 비활성화했다. +- `MapPopupDataTable`의 정렬 헤더도 공통 Standard 스타일로 통일했다. +- 상세 결과는 `COMMON_DOCS_ROOT/작업내용/공통-UiTable-컬럼정렬/004-ai-공통-UiTable-컬럼정렬-20260826-운영정보-I-I분석-구현기록.md`를 따른다. + +## 18. 2026-08-26 운영정보 Excel 생성 보완 + +- 사용자 첨부 생성본 2개를 `COMMON_DOCS_ROOT/작업지시`의 확정 원본 양식 2개와 직접 비교했다. +- 도시침수 Excel은 제목·요약·본문 서식을 복원하고 순번을 출력 순서 `1..N`으로 변경했으며 결산 총계를 연도별 합으로 채운다. +- 노후관로 Excel은 두 시트의 제목·조회 건수·계획연장 합계, B6:V7 병합 헤더·구분색과 고정 본문 높이를 적용했다. +- 두 생성기 모두 API 문자열의 `_x000d_`를 실제 줄바꿈으로 정리한다. +- 노후관로 다운로드는 비동기 스타일 생성 완료를 `await`한 뒤 로딩 상태를 해제한다. +- 시트명·업로드 열 위치, 조회조건·정렬순서, 백엔드·DB 계약은 변경하지 않았다. diff --git a/apps/sn/src/app/providers/app-provider.tsx b/apps/sn/src/app/providers/app-provider.tsx index 2169a8e47..b4750452f 100644 --- a/apps/sn/src/app/providers/app-provider.tsx +++ b/apps/sn/src/app/providers/app-provider.tsx @@ -11,6 +11,7 @@ import { PromptProvider, StoreProvider } from "@/shared/providers"; import { AlertDialog } from "@/shared/ui/alert-dialog"; import { PromptDialog } from "@/shared/ui/prompt-dialog"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; +import { BusinessUserMessageBridge } from "@/shared/ui/business-user-message-bridge"; // 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다. const LazyReactQueryDevtools = dynamic( @@ -26,6 +27,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { return ( + diff --git a/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx b/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx index ef0631b56..4784372bb 100644 --- a/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx +++ b/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx @@ -46,6 +46,7 @@ import type { import LoadingOverlay from "@/components/ui/LoadingBar"; import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; +import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; type SelectOption = { text: string; val: string }; type CfmtnBgtTableRow = CfmtnBgtRow; @@ -159,6 +160,8 @@ export default function CfmtnBgtPopupContent({ }); const [search, setSearch] = useState(initial); + // 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다. + const [appliedSearch, setAppliedSearch] = useState(initial); const [codes, setCodes] = useState([]); const [page, setPage] = useState(0); const [data, setData] = useState(empty); @@ -414,6 +417,7 @@ export default function CfmtnBgtPopupContent({ : empty, ); setPage(targetPage); + setAppliedSearch({ ...targetSearch }); setNotice(""); } catch (error) { console.error( @@ -449,26 +453,21 @@ export default function CfmtnBgtPopupContent({ }; const download = async () => { + if (JSON.stringify(search) !== JSON.stringify(appliedSearch)) { + setNotice("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요."); + return; + } + setLoading(true); try { - let exportPage = 0; - let totalPages = 1; - const exportRows: CfmtnBgtRow[] = []; - while (exportPage < totalPages) { - const result = await getCfmtnBgtPage(params(exportPage, EXPORT_SIZE)); - const safeResult = - result && - typeof result === "object" && - "content" in result && - Array.isArray((result as CfmtnBgtPage).content) && - "totalPages" in result - ? (result as CfmtnBgtPage) - : empty; - exportRows.push(...safeResult.content); - totalPages = safeResult.totalPages; - exportPage += 1; - } - downloadCfmtnBgtExcel(exportRows, search.year); + const result = await fetchAllPages({ + batchSize: EXPORT_SIZE, + fetchPage: (targetPage, size) => + getCfmtnBgtPage(params(targetPage, size, appliedSearch)), + getItems: (page) => (Array.isArray(page?.content) ? page.content : []), + getTotal: (page) => Number(page?.totalElements ?? 0), + }); + downloadCfmtnBgtExcel(result.items, appliedSearch.year); setNotice(""); } catch (error) { console.error( @@ -522,7 +521,7 @@ export default function CfmtnBgtPopupContent({ const next = typeof updater === "function" ? updater(pagination) : updater; if (next.pageIndex === page) return; - void load(next.pageIndex); + void load(next.pageIndex, appliedSearch); }, }); diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts b/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts index c05cca963..bdbd5d405 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-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/ctyFldngPrvtnBiz"; @@ -27,21 +28,21 @@ export const getCtyFldngPrvtnBusinesses = (params: URLSearchParams) => /** 모든 페이지를 순회하여 Excel 대상 전체를 모은다. */ export const getAllCtyFldngPrvtnBusinesses = async ( search: URLSearchParams, + sort = "ctyFldngPrvtnBizSn,desc", ) => { - const rows: CtyFldngPrvtnBiz[] = []; - let page = 0; - let totalPages = 1; - while (page < totalPages) { - const params = new URLSearchParams(search); - params.set("page", String(page)); - params.set("size", "500"); - params.set("sort", "ctyFldngPrvtnBizSn,asc"); - const result = await getCtyFldngPrvtnBusinesses(params); - rows.push(...result.content); - totalPages = result.totalPages; - page += 1; - } - return rows; + const result = await fetchAllPages({ + batchSize: 500, + fetchPage: (page, size) => { + const params = new URLSearchParams(search); + params.set("page", String(page)); + params.set("size", String(size)); + params.set("sort", sort); + return getCtyFldngPrvtnBusinesses(params); + }, + getItems: (page) => page.content ?? [], + getTotal: (page) => Number(page.totalElements ?? 0), + }); + return result.items; }; /** 수정 화면의 사업 한 건을 조회한다. */ diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts b/apps/sn/src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts index 5a0b726af..aabe8100f 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts @@ -41,6 +41,17 @@ const textValue = (value: unknown): string | null => { return !valueText || valueText === "-" ? null : valueText; }; +/** API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. */ +const excelTextValue = (value: unknown): string | number => { + if (value === null || value === undefined) return ""; + if (typeof value === "number") return value; + if (typeof value !== "string") return String(value); + return value + .replace(/_x000d_/gi, "") + .replace(/\r\n?/g, "\n") + .trim(); +}; + /** 콤마와 단위를 제거한 유한 숫자를 반환한다. */ const numberValue = ( value: unknown, @@ -333,7 +344,7 @@ const writeDetail = ( detail.exlnPplImprvmLen, detail.rnwtrPumpstSumCn, ].forEach((v) => { - row[start + o++] = v ?? ""; + row[start + o++] = excelTextValue(v); }); for (let i = 0; i < PUMP_COUNTS[code]; i += 1) row[start + o++] = @@ -346,7 +357,7 @@ const writeDetail = ( row[start + o++] = detail.impdFclt?.find((v) => v.no === i + 1)?.cpct ?? ""; [detail.tnlDmtr, detail.tnlLen, detail.tnlRscpt, detail.tnlExcvDpth].forEach( (v) => { - row[start + o++] = v ?? ""; + row[start + o++] = excelTextValue(v); }, ); if (code !== "EM") { @@ -363,10 +374,10 @@ const writeDetail = ( detail.frstAprvCn, detail.frstChgAprvCn, ].forEach((v) => { - row[start + o++] = v ?? ""; + row[start + o++] = excelTextValue(v); }); - if (code === "MP") row[start + o++] = detail.scndChgAprvCn ?? ""; - if (code === "CW") row[start + o++] = detail.rmrkSrcCn ?? ""; + if (code === "MP") row[start + o++] = excelTextValue(detail.scndChgAprvCn); + if (code === "CW") row[start + o++] = excelTextValue(detail.rmrkSrcCn); } }; @@ -557,6 +568,7 @@ const saveStyledWorkbook = async ( filename: string, starts: Record<(typeof DETAIL_CODES)[number], number>, last: number, + rowCount: number, ) => { const data = XLSX.write(workbook, { type: "array", bookType: "xlsx" }); const styledWorkbook = await XlsxPopulate.fromDataAsync(data); @@ -571,6 +583,23 @@ const saveStyledWorkbook = async ( wrapText: true, border: true, }; + styledSheet.range("B2:Q3").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: "D9EAF7", + border: true, + }); styledSheet.range(`B8:${lastColumn}12`).style(commonHeaderStyle); styledSheet .range(`B8:${XLSX.utils.encode_col(starts.EM - 1)}12`) @@ -591,6 +620,32 @@ const saveStyledWorkbook = async ( [8, 9, 10, 11, 12].forEach((row) => styledSheet.row(row).height(row === 8 ? 30 : 24), ); + styledSheet.row(2).height(26); + styledSheet.row(3).height(26); + styledSheet.row(5).height(22); + if (rowCount > 0) { + const dataStartRow = DATA_START_ROW + 1; + const dataEndRow = DATA_START_ROW + rowCount; + styledSheet.range(`B${dataStartRow}:${lastColumn}${dataEndRow}`).style({ + fontFamily: "맑은 고딕", + fontSize: 10, + horizontalAlignment: "center", + verticalAlignment: "center", + wrapText: false, + border: true, + }); + styledSheet + .range(`R${dataStartRow}:${lastColumn}${dataEndRow}`) + .style("numberFormat", "#,##0.###"); + styledSheet + .range(`M${dataStartRow}:Q${dataEndRow}`) + .style("horizontalAlignment", "left"); + styledSheet + .range(`${lastColumn}${dataStartRow}:${lastColumn}${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"); @@ -625,7 +680,30 @@ export const downloadCtyFldngPrvtnExcel = async ( Array(last + 1).fill(""), ); const merges: XLSX.Range[] = []; - aoa[0]![1] = "하수도정비중점관리지역 도시침수대응사업 현황"; + aoa[1]![1] = "하수도정비중점관리지역 도시침수 대응사업 추진 현황"; + addMerge(merges, 1, 1, 2, 16); + if (templateOnly) { + putHeader(aoa, 4, 1, "업로드 양식 · 13행부터 사업자료를 입력해 주세요."); + addMerge(merges, 4, 1, 4, 13); + } else { + const totalBudget = businesses.reduce( + (sum, business) => sum + (business.tpco ?? 0), + 0, + ); + putHeader(aoa, 4, 1, "조회 건수"); + addMerge(merges, 4, 1, 4, 3); + putHeader(aoa, 4, 4, `${businesses.length.toLocaleString("ko-KR")}건`); + addMerge(merges, 4, 4, 4, 6); + putHeader(aoa, 4, 7, "총사업비 합계(백만원)"); + addMerge(merges, 4, 7, 4, 9); + putHeader( + aoa, + 4, + 10, + totalBudget ? totalBudget.toLocaleString("ko-KR") : "-", + ); + addMerge(merges, 4, 10, 4, 13); + } const verticalBasicHeaders: Array<[number, string]> = [ [1, "순번"], [2, "단계"], @@ -680,7 +758,7 @@ export const downloadCtyFldngPrvtnExcel = async ( businesses.forEach((b, index) => { const row = Array(last + 1).fill(""); [ - b.seqNo, + index + 1, b.stp, b.ctpvNm, b.sggNm, @@ -700,11 +778,22 @@ export const downloadCtyFldngPrvtnExcel = async ( b.gvexp, b.lcexp, ].forEach((v, i) => { - row[i + 1] = v ?? ""; + row[i + 1] = excelTextValue(v); }); - years.forEach((year, i) => { - row[21 + i] = - b.settlements?.find((s) => s.soaYr === year)?.soaAmt ?? ""; + const settlementAmounts = years.map( + (year) => b.settlements?.find((s) => s.soaYr === year)?.soaAmt, + ); + const hasSettlement = settlementAmounts.some( + (amount) => amount !== null && amount !== undefined, + ); + row[20] = hasSettlement + ? settlementAmounts.reduce( + (sum, amount) => sum + (amount ?? 0), + 0, + ) + : ""; + settlementAmounts.forEach((amount, i) => { + row[21 + i] = amount ?? ""; }); DETAIL_CODES.forEach((code) => writeDetail( @@ -714,14 +803,24 @@ export const downloadCtyFldngPrvtnExcel = async ( code, ), ); - row[last - 2] = b.picNm ?? ""; - row[last - 1] = b.picCnpl ?? ""; - row[last] = b.excptnMttrCn ?? ""; + row[last - 2] = excelTextValue(b.picNm); + row[last - 1] = excelTextValue(b.picCnpl); + row[last] = excelTextValue(b.excptnMttrCn); aoa.push(row); }); const sheet = XLSX.utils.aoa_to_sheet(aoa); sheet["!merges"] = merges; - sheet["!cols"] = Array.from({ length: last + 1 }, () => ({ wch: 14 })); + const columnWidths = Array.from({ length: last + 1 }, () => ({ wch: 11 })); + const widthOverrides: Array<[number, number]> = [ + [0, 3], [1, 7], [2, 9], [3, 12], [4, 12], [5, 12], [6, 11], + [7, 12], [8, 10], [9, 9], [10, 13], [11, 13], [12, 32], [13, 30], + [14, 16], [15, 20], [16, 15], [17, 14], [18, 14], [19, 14], + [last - 2, 12], [last - 1, 16], [last, 42], + ]; + widthOverrides.forEach(([column, width]) => { + columnWidths[column] = { wch: width }; + }); + sheet["!cols"] = columnWidths; const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, sheet, SHEET_NAME); await saveStyledWorkbook( @@ -729,5 +828,6 @@ export const downloadCtyFldngPrvtnExcel = async ( templateOnly ? "도시침수예방사업_업로드양식.xlsx" : DOWNLOAD_FILENAME, starts, last, + templateOnly ? 0 : businesses.length, ); }; diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx index b4e4a2c32..b4db9d898 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx @@ -32,6 +32,7 @@ import type { import LoadingOverlay from "@/components/ui/LoadingBar"; import { useAlertStore } from "@/shared/lib"; +import { useOperBusinessCommonCodes } from "@/shared/lib/use-oper-business-common-codes"; import useStore from "@/store/useStore"; const DETAIL_META = [ @@ -281,6 +282,12 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { const searchParams = useSearchParams(); const { confirm } = useConfirm(); const alert = useAlertStore((state) => state.setMessage); + // 자유문자 기존값은 유지하고 지정유형·추진현황 후보와 단계명을 CMS에서 제공한다. + const { + cityDesignationTypeOptions, + businessProgressOptions, + cityProgressStageLabels, + } = useOperBusinessCommonCodes(); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); const [form, setForm] = useState(emptyForm); const [loading, setLoading] = useState(Boolean(id)); @@ -372,6 +379,14 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { type, ]: (typeof BASIC_FIELDS)[number]) => { const fieldId = `cty-fldng-basic-${key}`; + const suggestionOptions = + key === "dsgnType" + ? cityDesignationTypeOptions + : key === "prplPrst" || key === "bizPrplPrst" + ? businessProgressOptions + : []; + const suggestionListId = + suggestionOptions.length > 0 ? `${fieldId}-suggestions` : undefined; const span: 1 | 2 | 3 = key === "excptnMttrCn" ? 3 : key === "bizNm" || key === "pstn" ? 2 : 1; return ( @@ -382,21 +397,33 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { required={key === "bizNm"} span={span} > - - setForm((current) => ({ - ...current, - [key]: - type === "date" - ? formatDateInput(event.target.value) - : inputValue(event.target.value, type), - })) - } - /> + <> + + setForm((current) => ({ + ...current, + [key]: + type === "date" + ? formatDateInput(event.target.value) + : inputValue(event.target.value, type), + })) + } + /> + {suggestionListId ? ( + + {suggestionOptions.map((option) => ( + + ))} + + ) : null} + ); }; @@ -539,7 +566,7 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { return ( void, ): ColumnDef => ({ id, accessorFn: (row) => row[id], - header: () => {title}, + header: () => + sortKey && onSortChange ? ( + {title}} + sortKey={sortKey} + sort={sort} + onSortChange={onSortChange} + /> + ) : ( + {title} + ), cell: ({ getValue }) => { const value = getValue(); const displayValue = @@ -260,6 +277,13 @@ const flattenRow = (row: CtyFldngPrvtnBiz, rowNumber: number): TableRow => { export function CtyFldngPrvtnBizListPage() { const router = useRouter(); const params = useSearchParams(); + // 원본 자유문자 값은 유지하고 지정유형·추진현황·단계 표시명만 CMS 기준으로 변환한다. + const { + businessProgressOptions, + cityProgressStageLabels, + cityDesignationTypeLabels, + businessProgressLabels, + } = useOperBusinessCommonCodes(); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); const fileRef = useRef(null); const defaultDesignationDateRange = useMemo( @@ -286,12 +310,28 @@ export function CtyFldngPrvtnBizListPage() { ); const [data, setData] = useState(EMPTY_PAGE); const [rows, setRows] = useState([]); + const displayRows = useMemo( + () => + rows.map((row) => ({ + ...row, + dsgnType: + cityDesignationTypeLabels[String(row.dsgnType ?? "")] || + row.dsgnType, + prplPrst: + businessProgressLabels[String(row.prplPrst ?? "")] || row.prplPrst, + bizPrplPrst: + businessProgressLabels[String(row.bizPrplPrst ?? "")] || + row.bizPrplPrst, + })), + [businessProgressLabels, cityDesignationTypeLabels, rows], + ); const [loading, setLoading] = useState(false); const [notice, setNotice] = useState(""); const [ctpvCd, setCtpvCd] = useState(params.get("ctpvCd") ?? "0"); const [sggCd, setSggCd] = useState(params.get("sggCd") ?? "0"); const [appliedCtpvCd, setAppliedCtpvCd] = useState(ctpvCd); const [appliedSggCd, setAppliedSggCd] = useState(sggCd); + const [sort, setSort] = useState(); const availableSgg = useMemo( () => sgg.filter( @@ -308,10 +348,37 @@ export function CtyFldngPrvtnBizListPage() { ), [], ); + const detailGroups = useMemo( + () => + DETAIL_GROUPS.map((group) => ({ + ...group, + title: cityProgressStageLabels[group.code] || group.title, + })), + [cityProgressStageLabels], + ); + /** 정렬 변경 시 서버 첫 페이지로 돌아가고 동일 검색조건을 유지한다. */ + const handleSortChange = useCallback( + (nextSort?: UiTableSort) => { + setSort(nextSort); + setPage(1); + const q = listQuery(1, applied, appliedCtpvCd, appliedSggCd); + router.replace(`/oper/ctyFldngPrvtnBiz${q ? `?${q}` : ""}`, { + scroll: false, + }); + }, + [applied, appliedCtpvCd, appliedSggCd, router], + ); const columns = useMemo[]>( () => [ ...BASIC_HEADLIST.map((head) => - createTableColumn(head.id, head.title, head.widthPercent), + createTableColumn( + head.id, + head.title, + head.widthPercent, + head.id === "rowNumber" ? "ctyFldngPrvtnBizSn" : head.id, + sort, + handleSortChange, + ), ), { id: "settlements", @@ -320,7 +387,7 @@ export function CtyFldngPrvtnBizListPage() { createTableColumn(`soa_${year}`, `${year}년`, 7), ), }, - ...DETAIL_GROUPS.map((group) => ({ + ...detailGroups.map((group) => ({ id: `detail_${group.code}`, header: group.title, columns: DETAIL_FIELDS.map((field, index) => @@ -332,17 +399,24 @@ export function CtyFldngPrvtnBizListPage() { ), })), ...TAIL_HEADLIST.map((head) => - createTableColumn(head.id, head.title, head.widthPercent), + createTableColumn( + head.id, + head.title, + head.widthPercent, + head.id, + sort, + handleSortChange, + ), ), ], - [years], + [detailGroups, handleSortChange, sort, years], ); const pagination: PaginationState = { pageIndex: page - 1, pageSize: PAGE_SIZE, }; const table = useReactTable({ - data: rows, + data: displayRows, columns, state: { pagination }, rowCount: data.totalElements, @@ -368,11 +442,17 @@ export function CtyFldngPrvtnBizListPage() { }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); /** 확정된 조건으로 서버 목록을 갱신한다. */ const load = useCallback( - async (target = page, condition = applied) => { + async ( + target = page, + condition = applied, + nextSort: UiTableSort | undefined = sort, + ) => { const query = new URLSearchParams({ page: String(target - 1), size: String(PAGE_SIZE), - sort: "ctyFldngPrvtnBizSn,desc", + sort: nextSort + ? `${nextSort.key},${nextSort.direction}` + : "ctyFldngPrvtnBizSn,desc", }); Object.entries(condition).forEach(([k, v]) => { if (v.trim()) query.set(k, v.trim()); @@ -404,7 +484,7 @@ export function CtyFldngPrvtnBizListPage() { setLoading(false); } }, - [applied, page], + [applied, page, sort], ); useEffect(() => { void load(); @@ -453,7 +533,10 @@ export function CtyFldngPrvtnBizListPage() { if (v.trim()) query.set(k, v.trim()); }); await downloadCtyFldngPrvtnExcel( - await getAllCtyFldngPrvtnBusinesses(query), + await getAllCtyFldngPrvtnBusinesses( + query, + sort ? `${sort.key},${sort.direction}` : undefined, + ), ); } catch (e) { setNotice(e instanceof Error ? e.message : "엑셀을 내려받지 못했습니다."); @@ -478,6 +561,7 @@ export function CtyFldngPrvtnBizListPage() { setSggCd("0"); setAppliedCtpvCd("0"); setAppliedSggCd("0"); + setSort(undefined); setNotice(""); setPage(1); router.replace("/oper/ctyFldngPrvtnBiz", { scroll: false }); @@ -605,6 +689,7 @@ export function CtyFldngPrvtnBizListPage() { setSearch((current) => ({ @@ -613,6 +698,13 @@ export function CtyFldngPrvtnBizListPage() { })) } /> + + {businessProgressOptions.map((option) => ( + + ))} +
diff --git a/apps/sn/src/features/exmn/api/exmn-business-api.ts b/apps/sn/src/features/exmn/api/exmn-business-api.ts index 9fa4deb9f..3331a3b12 100644 --- a/apps/sn/src/features/exmn/api/exmn-business-api.ts +++ b/apps/sn/src/features/exmn/api/exmn-business-api.ts @@ -372,17 +372,19 @@ export const getPplSummaries = async ( exmnBizId: string, params: { trtdstId?: string; page: number; size: number; sort?: string }, ): Promise> => { - const query = new URLSearchParams({ - page: String(params.page), - size: String(params.size), - }); - if (params.trtdstId) query.set("trtdstId", params.trtdstId); - if (params.sort) query.set("sort", params.sort); - return toPage( - await getData( - `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/summaries/ppl?${query}`, - ), - ); + const summaries = await getExmnSectionSummaries(exmnBizId, "PPL"); + const filtered = params.trtdstId + ? summaries.filter((row) => row.trtdstId === params.trtdstId) + : summaries; + const start = Math.max(params.page, 0) * Math.max(params.size, 1); + const size = Math.max(params.size, 1); + return { + content: filtered.slice(start, start + size), + totalElements: filtered.length, + number: Math.max(params.page, 0), + size, + totalPages: Math.ceil(filtered.length / size), + }; }; /** 선택 사업의 PPL 시설을 처리분구·구간·GIS번호·등급으로 조회한다. */ @@ -408,17 +410,3 @@ export const getPplFacilities = async ( if (params.sort) query.set("sort", params.sort); return toPage(await getData(`v1/exmnFacilities?${query}`)); }; - -/** 현재 PPL 조회조건을 서버 엑셀로 내려받는다. */ -export const downloadPplExport = async ( - exmnBizId: string, - trtdstId?: string, -): Promise => { - const query = new URLSearchParams(); - if (trtdstId) query.set("trtdstId", trtdstId); - const queryString = query.toString(); - const suffix = queryString ? `?${queryString}` : ""; - return getBlobData( - `${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/exports/ppl${suffix}`, - ); -}; diff --git a/apps/sn/src/features/exmn/lib/useExmnCommonCodes.ts b/apps/sn/src/features/exmn/lib/useExmnCommonCodes.ts new file mode 100644 index 000000000..93d180776 --- /dev/null +++ b/apps/sn/src/features/exmn/lib/useExmnCommonCodes.ts @@ -0,0 +1,137 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import { + getCommonCodes, + type CommonCodeItem, +} from "@/components/lib/common/apiHelpers"; + +type CodeLabelMap = Record; +type ExmnCodeGroup = + | "EXMNDFCGRD" + | "EXMNRECDTYPE" + | "EXMNDFCTYPE" + | "EXMNDFCSEV" + | "EXMNSTTSGRP" + | "PPLEXMNITEM" + | "MNHLEXMNITEM"; + +const COMMON_CODE_GROUPS: ExmnCodeGroup[] = [ + "EXMNDFCGRD", + "EXMNRECDTYPE", + "EXMNDFCTYPE", + "EXMNDFCSEV", + "EXMNSTTSGRP", + "PPLEXMNITEM", + "MNHLEXMNITEM", +]; + +const cachedCodeGroups = new Map(); +const pendingCodeGroups = new Map< + ExmnCodeGroup, + Promise +>(); + +const loadCodeGroup = async (upCd: ExmnCodeGroup) => { + const cached = cachedCodeGroups.get(upCd); + if (cached) return cached; + + const pending = pendingCodeGroups.get(upCd); + if (pending) return pending; + + const request = getCommonCodes(upCd) + .then((codes) => { + cachedCodeGroups.set(upCd, codes); + return codes; + }) + .finally(() => { + pendingCodeGroups.delete(upCd); + }); + + pendingCodeGroups.set(upCd, request); + return request; +}; + +const toLabelMap = (codes: CommonCodeItem[]): CodeLabelMap => + Object.fromEntries(codes.map((code) => [code.lwpoCd, code.lwpoCdNm])); + +/** 조사 코드값과 표 열 순서는 유지하고 화면에서 사용할 CMS 코드명만 제공한다. */ +export const useExmnCommonCodes = () => { + const [codeGroups, setCodeGroups] = useState< + Partial> + >( + () => + Object.fromEntries(cachedCodeGroups.entries()) as Partial< + Record + >, + ); + + useEffect(() => { + let cancelled = false; + + COMMON_CODE_GROUPS.forEach((upCd) => { + loadCodeGroup(upCd) + .then((codes) => { + if (!cancelled) { + setCodeGroups((current) => ({ ...current, [upCd]: codes })); + } + }) + .catch((error) => { + console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error); + }); + }); + + return () => { + cancelled = true; + }; + }, []); + + const defectGradeOptions = useMemo( + () => + codeGroups.EXMNDFCGRD?.map((code) => ({ + value: code.lwpoCd, + label: code.lwpoCdNm, + })) ?? + [1, 2, 3, 4, 5].map((grade) => ({ + value: String(grade), + label: `${grade}등급`, + })), + [codeGroups.EXMNDFCGRD], + ); + + const recordTypeLabels = useMemo( + () => toLabelMap(codeGroups.EXMNRECDTYPE ?? []), + [codeGroups.EXMNRECDTYPE], + ); + const defectTypeLabels = useMemo( + () => toLabelMap(codeGroups.EXMNDFCTYPE ?? []), + [codeGroups.EXMNDFCTYPE], + ); + const severityLabels = useMemo( + () => toLabelMap(codeGroups.EXMNDFCSEV ?? []), + [codeGroups.EXMNDFCSEV], + ); + const statusGroupLabels = useMemo( + () => toLabelMap(codeGroups.EXMNSTTSGRP ?? []), + [codeGroups.EXMNSTTSGRP], + ); + const pipeItemLabels = useMemo( + () => toLabelMap(codeGroups.PPLEXMNITEM ?? []), + [codeGroups.PPLEXMNITEM], + ); + const manholeItemLabels = useMemo( + () => toLabelMap(codeGroups.MNHLEXMNITEM ?? []), + [codeGroups.MNHLEXMNITEM], + ); + + return { + defectGradeOptions, + recordTypeLabels, + defectTypeLabels, + severityLabels, + statusGroupLabels, + pipeItemLabels, + manholeItemLabels, + }; +}; diff --git a/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx b/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx index 8ebc6c279..2f78ed781 100644 --- a/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx @@ -1,12 +1,10 @@ "use client"; -import LoadingOverlay from "@/components/ui/LoadingBar"; -import { getStartYearArray } from "@/components/ui/YearRangeSelectBox"; -import useStore from "@/store/useStore"; -import { - postPopupMessage, - postPopupMessageAndClose, -} from "@/features/windowPopup/lib/popupMessage"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { useConfirm } from "@frontend-opensource/use-react-hooks"; + +import { postPopupMessage } from "@/features/windowPopup/lib/popupMessage"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; import { StandardFormField, @@ -22,8 +20,7 @@ import { SelectTrigger, SelectValue, } from "@common/components"; -import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { useEffect, useMemo, useRef, useState } from "react"; + import { createExmnBusiness, createExmnTemplates, @@ -40,6 +37,10 @@ import type { } from "../model/exmn-business-types"; import type { TrtdstSettingDto } from "../model/trtdst-types"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { getStartYearArray } from "@/components/ui/YearRangeSelectBox"; +import useStore from "@/store/useStore"; + type Notice = { type: "success" | "error" | "info"; text: string } | null; interface ExmnBusinessRegisterPopupPageProps { @@ -52,14 +53,16 @@ const sortDistricts = (items: TrtdstSettingDto[]) => left.trtdstNm.trim().localeCompare(right.trtdstNm.trim(), "ko"), ); -/** Blob을 브라우저 다운로드로 전달하고 임시 URL을 즉시 정리한다. */ +/** Blob 다운로드 링크를 DOM에 연결하고 브라우저가 수락한 뒤 임시 URL을 정리한다. */ const saveBlob = (blob: Blob, fileName: string) => { const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = objectUrl; anchor.download = fileName; + document.body.appendChild(anchor); anchor.click(); - URL.revokeObjectURL(objectUrl); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1_000); }; /** 공통 WindowPopup 안에서 사업정보·대상 처리분구·조사양식을 등록·수정하는 화면이다. */ @@ -92,6 +95,11 @@ export function ExmnBusinessRegisterPopupPage({ const [isLoading, setIsLoading] = useState(false); const [notice, setNotice] = useState(null); const formLocked = Boolean(savedBusiness) && !isEditMode; + /** 저장 전 비활성 양식버튼에 표시할 업무 안내와 활성 조건이다. */ + const templateDownloadReady = Boolean(savedBusiness?.exmnBizId); + const templateDownloadGuide = templateDownloadReady + ? undefined + : "사업정보와 처리분구를 먼저 저장해주세요."; /** 팝업 레이아웃은 BaseLayout 초기화를 거치지 않으므로 행정구역 공통코드를 직접 준비한다. */ useEffect(() => { @@ -326,10 +334,14 @@ export function ExmnBusinessRegisterPopupPage({ exmnBizId: persisted.exmnBizId, }; if (!isEditMode) { - postPopupMessageAndClose( + postPopupMessage( WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED, popupPayload, ); + setNotice({ + type: "success", + text: "사업정보를 등록했습니다. 조사 입력양식과 조사보고서 양식을 다운로드할 수 있습니다.", + }); return; } @@ -416,29 +428,36 @@ export function ExmnBusinessRegisterPopupPage({ showDefaultCancelAction={false} actions={ <> - - + + + {!isEditMode ? ( + + ) : null}
- - {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.recordType + ? `(${recordTypeLabels[row.recordType] || row.recordType})${pipeItemLabels[row.code] || row.defectName.replace(/^\([^)]*\)/, "")}` + : row.defectName} + + + {row.code} + + + {row.severity ? severityLabels[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 5e30fe15f..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 ( @@ -633,7 +686,7 @@ export function ExmnRegistrationPage() {
+ +
+ + setDetailNumberKeyword(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") handleSearch(); + }} + /> +
+ + {jobType === "PPL" && ( +
+ +
+ {( + Object.keys(PPL_DETAIL_MODE_LABELS) as ExmnPplDetailMode[] + ).map((mode) => ( + + ))} +
+
+ )} + + + + + + )} {loadError && (
{loadError}
)} -
-
-

- {tableTitle} -

-
-
- {isUnitMode ? ( - <> - - 관로번호 - {appliedDetailNumberKeyword || "전체"} - - - 단위구간 - {unitSectionCount.toLocaleString("ko-KR")}개 - - - ) : ( - <> - - - 연장거리 합계 - - {formatNumber(totals.extensionM)}m - - - - 주행거리 합계 - - {formatNumber(totals.drivingDistanceM)}m - - - )} -
-
- - {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 ( - - ); - })} -
- - {columnLabel} - - + {isUnitMode ? ( + <> + + + 관로번호 + + {appliedDetailNumberKeyword || "전체"} + + + + 단위구간 + + {unitSectionCount.toLocaleString("ko-KR")}개 + + + ) : ( + <> + + + 연장거리 합계 + + {formatNumber(totals.extensionM)}m + + + + 주행거리 합계 + + {formatNumber(totals.drivingDistanceM)}m + + + )} + + } + renderTable={() => + isUnitMode ? ( + + ) : ( + + + {fixedColumns.map((column) => ( + + ))} + {Array.from({ + length: (visibleDefectColumns.length + 1) * - EXMN_DETAIL_SEVERITIES.length - } - className="border-b border-r border-border bg-primary px-2 py-2 text-center text-primary-foreground" - > - 이상항목 - - - - - - - - {EXMN_DETAIL_SEVERITIES.map((severity) => ( - + EXMN_DETAIL_SEVERITIES.length, + }).map((_, index) => ( + ))} - {visibleDefectColumns.map((column) => ( - - ))} - - - {visibleDefectColumns.flatMap((column) => - EXMN_DETAIL_SEVERITIES.map((severity) => ( - - )), - )} - - - - {rows.length > 0 ? ( - <> - {rows.map((row) => { - const visibleAnomalyTotals = getVisibleAnomalyTotals( - row, - visibleDefectColumns, - ); + + + + {fixedColumns.map((column, columnIndex) => { + const sticky = columnIndex < STICKY_COLUMN_COUNT; + const columnLabel = column.label; return ( - - {fixedColumns.map((column, columnIndex) => { - const sticky = columnIndex < STICKY_COLUMN_COUNT; - return ( - - ); - })} - {EXMN_DETAIL_SEVERITIES.map((severity) => ( - - ))} - {visibleDefectColumns.flatMap((column) => - EXMN_DETAIL_SEVERITIES.map((severity) => ( - - )), + : undefined + } + > + {columnIndex < 9 ? ( + + {columnLabel} + + } + sortKey={column.id} + sort={sort} + onSortChange={setSort} + /> + ) : ( + + {columnLabel} + )} - + ); })} - - + + + {visibleDefectColumns.map((column) => ( + + + {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 ( + + {fixedColumns.map((column, columnIndex) => { + const sticky = columnIndex < STICKY_COLUMN_COUNT; + return ( + + ); + })} + {EXMN_DETAIL_SEVERITIES.map((severity) => ( + + ))} + {visibleDefectColumns.flatMap((column) => + EXMN_DETAIL_SEVERITIES.map((severity) => ( + + )), + )} + + ); + })} + + - {summaryLabelColumnCount > STICKY_COLUMN_COUNT && ( - - ))} - {EXMN_DETAIL_SEVERITIES.map((severity) => ( - - ))} - {visibleDefectColumns.flatMap((column) => - EXMN_DETAIL_SEVERITIES.map((severity) => ( + {summaryLabelColumnCount > STICKY_COLUMN_COUNT && ( + ))} + {EXMN_DETAIL_SEVERITIES.map((severity) => ( + - )), - )} - - - + )), + )} + + + - {summaryLabelColumnCount > STICKY_COLUMN_COUNT && ( - - ))} - - {visibleDefectColumns.map((column) => ( + {summaryLabelColumnCount > STICKY_COLUMN_COUNT && ( + + ))} - ))} + {visibleDefectColumns.map((column) => ( + + ))} + + + ) : ( + + - - ) : ( - - - - )} - -
- 합계 - - {visibleDefectGroupTitle} -
- {SEVERITY_LABELS[severity]} -
- {column.label} ({column.code}) -
- {SEVERITY_LABELS[severity]} -
- {getFixedCellValue(row, column.id)} - - {formatCount(visibleAnomalyTotals[severity])} - - {formatCount( - row.defectCounts[column.code]?.[severity], - )} -
+ 이상항목 + +
+ 합계 + - + ))} +
+ {severityLabels[severity]} + + {severityLabels[severity]} +
+ {getFixedCellValue(row, column.id)} + + {formatCount(visibleAnomalyTotals[severity])} + + {formatCount( + row.defectCounts[column.code]?.[severity], + )} +
- 소계 - - - {summary.value} + + 소계 + - {formatCount( - defectSummary.anomalySubtotals[severity], - )} - + {summary.value} + {formatCount( - defectSummary.defectSubtotals[column.code]?.[ - severity - ], + defectSummary.anomalySubtotals[severity], )}
- + EXMN_DETAIL_SEVERITIES.map((severity) => ( + + {formatCount( + defectSummary.defectSubtotals[column.code]?.[ + severity + ], + )} +
- 합계 - - - {summary.value} + + 합계 + - {formatCount(defectSummary.anomalyTotal)} - + {summary.value} + - {formatCount(defectSummary.defectTotals[column.code])} + {formatCount(defectSummary.anomalyTotal)} + {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/PplFacilityDetailModal.tsx b/apps/sn/src/features/exmn/ui/PplFacilityDetailModal.tsx index 09811e159..936ae0431 100644 --- a/apps/sn/src/features/exmn/ui/PplFacilityDetailModal.tsx +++ b/apps/sn/src/features/exmn/ui/PplFacilityDetailModal.tsx @@ -9,6 +9,7 @@ import { DialogTitle, } from "@common/components/ui"; import { useEffect, useMemo, useState } from "react"; +import { useExmnCommonCodes } from "../lib/useExmnCommonCodes"; import type { PplFacility } from "../model/exmn-business-types"; import { ExmnStandardDataTable } from "./ExmnStandardDataTable"; @@ -49,9 +50,25 @@ export function PplFacilityDetailModal({ facility, onOpenChange, }: PplFacilityDetailModalProps) { + // 정규화 상세의 코드값은 유지하고 기록유형·분류·심각도·상태그룹 표시명만 변환한다. + const { + defectGradeOptions, + recordTypeLabels, + defectTypeLabels, + severityLabels, + statusGroupLabels, + } = useExmnCommonCodes(); // 선택 단위는 원문 배열 인덱스가 아니라 unitSeq로 보관해 서버 정렬과 무관하게 유지한다. const [selectedUnitSeq, setSelectedUnitSeq] = useState(null); + const defectGradeLabels = useMemo( + () => + Object.fromEntries( + defectGradeOptions.map((option) => [option.value, option.label]), + ), + [defectGradeOptions], + ); + /** 시설이 바뀌면 첫 단위 구간을 기본 선택한다. */ useEffect(() => { if (!open) return; @@ -62,9 +79,15 @@ export function PplFacilityDetailModal({ () => (facility?.unitSections ?? []).map((unit) => ({ ...unit, + structuralGrade: + defectGradeLabels[String(unit.structuralGrade)] || + unit.structuralGrade, + operationalGrade: + defectGradeLabels[String(unit.operationalGrade)] || + unit.operationalGrade, detailCount: unit.details?.length ?? 0, })), - [facility], + [defectGradeLabels, facility], ); const selectedUnit = useMemo( @@ -77,18 +100,32 @@ export function PplFacilityDetailModal({ const detailRows = useMemo( () => - (selectedUnit?.details ?? []).map((detail) => ({ - ...detail, - typeText: detail.type ?? "-", - severityText: detail.severity ?? "-", - occurrencePositionText: detail.occurrencePosition ?? "-", - continuousText: - detail.continuous == null ? "-" : detail.continuous ? "Y" : "N", - structuralScoreText: detail.structuralScore ?? "-", - operationalScoreText: detail.operationalScore ?? "-", - statusGroupText: detail.statusGroup ?? "-", - })), - [selectedUnit], + (selectedUnit?.details ?? []).map((detail) => { + const defectType = detail.type ?? ""; + const severity = detail.severity ?? ""; + const statusGroup = detail.statusGroup ?? ""; + return { + ...detail, + recordType: + recordTypeLabels[detail.recordType] || detail.recordType || "-", + typeText: defectTypeLabels[defectType] || defectType || "-", + severityText: severityLabels[severity] || severity || "-", + occurrencePositionText: detail.occurrencePosition ?? "-", + continuousText: + detail.continuous == null ? "-" : detail.continuous ? "Y" : "N", + structuralScoreText: detail.structuralScore ?? "-", + operationalScoreText: detail.operationalScore ?? "-", + statusGroupText: + statusGroupLabels[statusGroup] || statusGroup || "-", + }; + }), + [ + defectTypeLabels, + recordTypeLabels, + selectedUnit, + severityLabels, + statusGroupLabels, + ], ); return ( diff --git a/apps/sn/src/features/exmn/ui/PplSummaryModal.tsx b/apps/sn/src/features/exmn/ui/PplSummaryModal.tsx index f875a1509..7ab586316 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,9 @@ 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"; +import { useExmnCommonCodes } from "../lib/useExmnCommonCodes"; const SUMMARY_TABLE_HEAD = [ { id: "trtdstNm", title: "처리분구", widthPercent: 20 }, @@ -121,22 +123,14 @@ 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, business, onOpenChange, }: PplSummaryModalProps) { + // 조사시설 검색 코드값은 유지하고 등급·결함분류 표시명만 CMS 기준으로 사용한다. + const { defectGradeOptions, defectTypeLabels } = useExmnCommonCodes(); const [businessDetail, setBusinessDetail] = useState(null); const [summaryRows, setSummaryRows] = useState([]); @@ -352,10 +346,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", @@ -470,9 +494,9 @@ export function PplSummaryModal({ onChange={(event) => setStructuralGrade(event.target.value)} > - {[1, 2, 3, 4, 5].map((grade) => ( - ))} @@ -487,9 +511,9 @@ export function PplSummaryModal({ } > - {[1, 2, 3, 4, 5].map((grade) => ( - ))} @@ -497,7 +521,7 @@ export function PplSummaryModal({
- 결함 분류: {defectType} + 결함 분류: {defectTypeLabels[defectType] || defectType} + ); }, 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/lib/use-oper-business-common-codes.ts b/apps/sn/src/shared/lib/use-oper-business-common-codes.ts new file mode 100644 index 000000000..8959288af --- /dev/null +++ b/apps/sn/src/shared/lib/use-oper-business-common-codes.ts @@ -0,0 +1,155 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import { + getCommonCodes, + type CommonCodeItem, +} from "@/components/lib/common/apiHelpers"; + +type CodeLabelMap = Record; +type OperCodeGroup = + | "OBSLTBIZSE" + | "EVDNCSBMSNSTTS" + | "CTYFLDNGPRGRSSE" + | "CTYFLDNGDSGNTYPE" + | "BIZPRGRSSTTS"; + +const COMMON_CODE_GROUPS: OperCodeGroup[] = [ + "OBSLTBIZSE", + "EVDNCSBMSNSTTS", + "CTYFLDNGPRGRSSE", + "CTYFLDNGDSGNTYPE", + "BIZPRGRSSTTS", +]; + +const cachedCodeGroups = new Map(); +const pendingCodeGroups = new Map< + OperCodeGroup, + Promise +>(); + +const loadCodeGroup = async (upCd: OperCodeGroup) => { + const cached = cachedCodeGroups.get(upCd); + if (cached) return cached; + + const pending = pendingCodeGroups.get(upCd); + if (pending) return pending; + + const request = getCommonCodes(upCd) + .then((codes) => { + cachedCodeGroups.set(upCd, codes); + return codes; + }) + .finally(() => { + pendingCodeGroups.delete(upCd); + }); + + pendingCodeGroups.set(upCd, request); + return request; +}; + +const toLegacyValueOptions = (codes: CommonCodeItem[]) => + codes.map((code) => ({ + // 현행 운영사업 DB는 한글 설명값을 저장하므로 코드명을 기존 전송값으로 유지한다. + value: code.lwpoCdNm, + label: code.lwpoCdNm, + code: code.lwpoCd, + })); + +const toLabelMap = (codes: CommonCodeItem[]): CodeLabelMap => { + const labels: CodeLabelMap = {}; + codes.forEach((code) => { + labels[code.lwpoCd] = code.lwpoCdNm; + labels[code.lwpoCdNm] = code.lwpoCdNm; + }); + return labels; +}; + +/** 운영사업의 기존 한글 저장계약을 유지하면서 CMS 선택지·표시명을 제공한다. */ +export const useOperBusinessCommonCodes = () => { + const [codeGroups, setCodeGroups] = useState< + Partial> + >( + () => + Object.fromEntries(cachedCodeGroups.entries()) as Partial< + Record + >, + ); + + useEffect(() => { + let cancelled = false; + + COMMON_CODE_GROUPS.forEach((upCd) => { + loadCodeGroup(upCd) + .then((codes) => { + if (!cancelled) { + setCodeGroups((current) => ({ ...current, [upCd]: codes })); + } + }) + .catch((error) => { + console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error); + }); + }); + + return () => { + cancelled = true; + }; + }, []); + + const obsltBusinessTypeOptions = useMemo( + () => + codeGroups.OBSLTBIZSE?.length + ? toLegacyValueOptions(codeGroups.OBSLTBIZSE) + : [ + { value: "정밀조사", label: "정밀조사", code: "PRCIS_EXMN" }, + { value: "정비사업", label: "정비사업", code: "MNTN_BIZ" }, + ], + [codeGroups.OBSLTBIZSE], + ); + + const evidenceStatusOptions = useMemo( + () => toLegacyValueOptions(codeGroups.EVDNCSBMSNSTTS ?? []), + [codeGroups.EVDNCSBMSNSTTS], + ); + const cityDesignationTypeOptions = useMemo( + () => toLegacyValueOptions(codeGroups.CTYFLDNGDSGNTYPE ?? []), + [codeGroups.CTYFLDNGDSGNTYPE], + ); + const businessProgressOptions = useMemo( + () => toLegacyValueOptions(codeGroups.BIZPRGRSSTTS ?? []), + [codeGroups.BIZPRGRSSTTS], + ); + const cityProgressStageLabels = useMemo( + () => toLabelMap(codeGroups.CTYFLDNGPRGRSSE ?? []), + [codeGroups.CTYFLDNGPRGRSSE], + ); + const obsltBusinessTypeLabels = useMemo( + () => toLabelMap(codeGroups.OBSLTBIZSE ?? []), + [codeGroups.OBSLTBIZSE], + ); + const evidenceStatusLabels = useMemo( + () => toLabelMap(codeGroups.EVDNCSBMSNSTTS ?? []), + [codeGroups.EVDNCSBMSNSTTS], + ); + const cityDesignationTypeLabels = useMemo( + () => toLabelMap(codeGroups.CTYFLDNGDSGNTYPE ?? []), + [codeGroups.CTYFLDNGDSGNTYPE], + ); + const businessProgressLabels = useMemo( + () => toLabelMap(codeGroups.BIZPRGRSSTTS ?? []), + [codeGroups.BIZPRGRSSTTS], + ); + + return { + obsltBusinessTypeOptions, + evidenceStatusOptions, + cityDesignationTypeOptions, + businessProgressOptions, + cityProgressStageLabels, + obsltBusinessTypeLabels, + evidenceStatusLabels, + cityDesignationTypeLabels, + businessProgressLabels, + }; +}; 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..81d5c4c13 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -54,12 +54,17 @@ function Gis({ sinkholeLegendIconVisible = false, setIsSearchModalOpen, onCheckedLayersChange, + // 외부 업무 흐름에서 특정 시설물 레이어를 기존 체크 상태에 추가하도록 요청한다. + checkedLayerRequest = null, searchBar: SearchBar, waterDepth: WaterDepth = null, layerSettingView: LayerSettingView = null, inlineLayerSettingView, treeSectionClassName = "", layerIconVisible, + layerLegendIconVisible, + // 목록·액션 아이콘이 지도 레이어 체크를 선행 조건으로 요구하는지 여부다. + layerIconRequiresChecked = true, isPlaying = false, setIsPlaying = () => { }, targetLayerTypes = [], @@ -306,6 +311,7 @@ function Gis({ visible={tabVisible} setVisible={setTabVisible} onCheckedChange={handleCheckedLayersChange} + checkedLayerRequest={checkedLayerRequest} onLayerIconClick={(layerNm, node) => { layerNmRef.current = layerNm; setLayerNm(layerNm); @@ -314,6 +320,8 @@ function Gis({ }} imageVisible={imgVisble} layerIconVisible={layerIconVisible} + layerLegendIconVisible={layerLegendIconVisible} + 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..9bfac1400 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"; @@ -18,6 +19,7 @@ import GisApp from "./lib/gis.core"; * * Props 요약 * - checkedNodes, setCheckedNodes: (선택) 외부 관리 체크 상태 (현재 내부 state 사용) + * - checkedLayerRequest: (선택) 기존 체크 상태를 유지하면서 지정 레이어를 추가로 체크하는 요청 * - visible, setVisible: 사이드 패널 가시성 on/off * - onCheckedChange(checkedIds, checkedNodesData): 체크 변경 시 콜백 * - onLayerIconClick(nodeNm): 각 노드의 미리보기/아이콘 클릭 시 콜백 @@ -29,11 +31,14 @@ import GisApp from "./lib/gis.core"; * - onProgressChange(total, current): 애니메이션 진행상태 콜백 * - layerSettingView: (선택) 좌측 레이어 트리 하단에 표시할 설정 영역 * - layerIconVisible: (선택) 레이어 행 우측 액션 아이콘 노출 여부를 결정하는 함수 + * - layerLegendIconVisible: (선택) 레이어 행 범례 아이콘 노출 여부를 결정하는 함수 + * - layerIconRequiresChecked: (선택) 액션 실행 전에 레이어 체크를 요구할지 여부 * - detachedToggleButton: (선택) 접기/펼치기 버튼을 패널 밖 고정 핸들로 노출할지 여부 */ export default React.memo(function GisTree({ checkedNodes = [], setCheckedNodes = () => { }, + checkedLayerRequest = null, visible, setVisible, onCheckedChange, @@ -46,6 +51,8 @@ export default React.memo(function GisTree({ onProgressChange = () => { }, layerSettingView: LayerSettingView = null, layerIconVisible, + layerLegendIconVisible = () => true, + layerIconRequiresChecked = true, sinkholeLegendIconVisible = false, inlineLayerSettingView, treeSectionClassName = "", @@ -66,6 +73,8 @@ export default React.memo(function GisTree({ const targetNodesRef = useRef([]); const isPausedRef = useRef(false); const timeoutIdRef = useRef(null); + // 동일한 외부 체크 요청이 리렌더링마다 반복 적용되지 않도록 마지막 요청 키를 보관한다. + const lastCheckedLayerRequestKeyRef = useRef(null); // 체크 후 부모 설정영역까지 다시 렌더되어도 사용자가 보던 트리 위치를 유지한다. const layerPanelScrollRef = useRef(null); const layerPanelScrollTopRef = useRef(0); @@ -317,6 +326,37 @@ export default React.memo(function GisTree({ [nodes] ); + /** + * 업무 화면이 요청한 레이어를 기존 사용자 체크 상태에 추가하고 조상 그룹을 펼친다. + * 레이어명은 공백 차이를 무시하며, 실제 지도 반영과 onCheckedChange는 수동 체크와 같은 경로를 사용한다. + */ + useEffect(() => { + const requestKey = checkedLayerRequest?.requestKey; + if (requestKey == null || nodes.length === 0) return; + if (lastCheckedLayerRequestKeyRef.current === requestKey) return; + + const normalizeLayerName = (value) => String(value ?? "") + .replace(/\s+/g, "") + .trim(); + const requestedLayerNames = new Set( + (checkedLayerRequest?.layerNames ?? []) + .map(normalizeLayerName) + .filter(Boolean) + ); + const targetNodes = nodes.filter((node) => requestedLayerNames.has( + normalizeLayerName(node?.data?.lyrNm ?? node?.text) + )); + if (targetNodes.length === 0) return; + + setExpanded((current) => Array.from(new Set([ + ...current, + ...targetNodes.map((node) => node.id), + ...targetNodes.flatMap((node) => getAncestors(node.id, nodes)), + ]))); + targetNodes.forEach((node) => checkSpecificNode(node.id, true)); + lastCheckedLayerRequestKeyRef.current = requestKey; + }, [checkedLayerRequest, checkSpecificNode, nodes]); + // ===================== // 자동 애니메이션: 지정된 순서/간격으로 노드 체크 상태를 순차 변경 // ===================== @@ -536,7 +576,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); }; @@ -592,7 +632,8 @@ export default React.memo(function GisTree({ String(node.id) === "SN_SINKHOLE" || nodeLabel === "지반침하정보"; const hideTreeLegendIcon = - (isSinkholeRootTreeNode && !sinkholeLegendIconVisible); + (isSinkholeRootTreeNode && !sinkholeLegendIconVisible) || + !layerLegendIconVisible(node); const showLayerActionButton = layerIconVisible?.(node); const showLayerActionIcon = depth > 0 && imageVisible; 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 };