diff --git a/apps/sn/app/(map-layout)/map/components/common/index.ts b/apps/sn/app/(map-layout)/map/components/common/index.ts index a0346280c..377df2344 100644 --- a/apps/sn/app/(map-layout)/map/components/common/index.ts +++ b/apps/sn/app/(map-layout)/map/components/common/index.ts @@ -21,7 +21,7 @@ type LayerComponentProps = { }; type LayerContentMap = { [key: string]: { - component?: React.FC; + component?: React.ComponentType; props?: any; }; }; @@ -46,7 +46,7 @@ export const getLayerContentComponent = (layerNm: string): React.FC null; } - return component.component + return component.component as React.FC }; // 사용자 입력 필드 (보수 및 갱신, 청소 및 준설, 점검 및 진단) export const handleInputChange = (section: string, 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 fabd7c029..6c9b78c23 100644 --- a/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx @@ -1,94 +1,88 @@ "use client"; import type { ChangeEvent } from "react"; -import { useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import UiPageNations from "@common/business_components/ui/table/uiPageNations"; +import { + Button, + Input, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@common/components"; -import { Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; +import { + getBlobDataWithHeaders, + getData, + postMutiPart, +} from "@/components/lib/api"; +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import GisUploadPreviewMap from "./GisUploadPreviewMap"; type UploadFacilityType = { facilityTypeCode: string; facilityTypeName: string; + layerId: string; allowedExtensions: string[]; maxFileSizeMb: number; + requiredDownloadFormat: string; +}; + +type MngInstOption = { + mngInstCd: string; + lwrInstNm: string; }; type UploadHistoryItem = { uploadId: string; - facilityTypeCode: string; - facilityTypeName: string; + downloadId?: string; + mngInstCd?: string | null; + mngInstNm?: string | null; fileName: string; + status?: string; + totalCount?: number | null; + changedCount?: number | null; + invalidCount?: number | null; registeredAt: string; registeredBy: string; + message?: string | null; }; -const UPLOAD_FACILITY_TYPES: UploadFacilityType[] = [ - { - facilityTypeCode: "PPL", - facilityTypeName: "하수관로", - allowedExtensions: [".zip", ".shp", ".geojson", ".json", ".dxf"], - maxFileSizeMb: 200, - }, -]; +type GisUploadResponse = { + uploadId: string; + status: "COMPLETED" | "VALIDATION_FAILED" | string; + mapRefreshRequired?: boolean; + refreshLayerTypeNames?: string[]; + validationErrors?: ValidationErrorItem[]; + message?: string; +}; -const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ - { - uploadId: "GUP-MOCK-20260504-0001", - facilityTypeCode: "PPL", - facilityTypeName: "하수관로", - fileName: "asan_pipe_20260504.zip", - registeredAt: "2026-05-04", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260418-0004", - facilityTypeCode: "PPL", - facilityTypeName: "하수관로", - fileName: "asan_pipe_20260418.zip", - registeredAt: "2026-04-18", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260409-0007", - facilityTypeCode: "PPL", - facilityTypeName: "하수관로", - fileName: "asan_pipe_20260409.zip", - registeredAt: "2026-04-09", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260331-0010", - facilityTypeCode: "PPL", - facilityTypeName: "하수관로", - fileName: "asan_pipe_20260331.zip", - registeredAt: "2026-03-31", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260322-0013", - facilityTypeCode: "PPL", - facilityTypeName: "하수관로", - fileName: "asan_pipe_20260322.zip", - registeredAt: "2026-03-22", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260313-0016", - facilityTypeCode: "PPL", - facilityTypeName: "하수관로", - fileName: "asan_pipe_20260313.zip", - registeredAt: "2026-03-13", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260304-0019", - facilityTypeCode: "PPL", - facilityTypeName: "하수관로", - fileName: "asan_pipe_20260304.zip", - registeredAt: "2026-03-04", - registeredBy: "admin", - }, -]; +type ValidationErrorItem = { + rowNo?: number | null; + fid?: string | null; + errorCode?: string | null; + errorMessage?: string | null; +}; + +type ViewMode = "history" | "validationErrors" | "preview"; + +const SCREEN_ID = "SN_MN010302"; +const FACILITY_TYPE_CODE = "PPL"; +const LAYER_ID = "tb_ms_swage_ppl"; +const GIS_UPLOAD_PAGE_SIZE = 10; +const SELECT_MNG_INST_VALUE = ""; +const DEFAULT_FACILITY: UploadFacilityType = { + facilityTypeCode: FACILITY_TYPE_CODE, + facilityTypeName: "하수관로", + layerId: LAYER_ID, + allowedExtensions: [".gpkg"], + maxFileSizeMb: 2048, + requiredDownloadFormat: "GPKG", +}; const getFileExtension = (fileName: string) => { const lastDotIndex = fileName.lastIndexOf("."); @@ -96,43 +90,87 @@ const getFileExtension = (fileName: string) => { return fileName.slice(lastDotIndex).toLowerCase(); }; -const buildTodayDate = () => new Date().toISOString().slice(0, 10); -const GIS_UPLOAD_PAGE_SIZE = 10; +const formatDateTime = (value?: string | null) => { + if (!value) return "-"; + return value.replace("T", " ").slice(0, 19); +}; + +const toScopeKey = (mngInstCd: string) => mngInstCd || "__UNSELECTED__"; + +const buildManagementLabel = (mngInstCd?: string | null, mngInstNm?: string | null) => { + if (!mngInstCd) return "-"; + return mngInstNm || mngInstCd; +}; + +const saveBlob = (blob: Blob, fileName: string) => { + const url = window.URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.URL.revokeObjectURL(url); +}; + +const getErrorMessage = (error: unknown, fallback: string) => { + const apiError = error as any; + return apiError?.response?.data?.message + ?? apiError?.response?.data?.data?.message + ?? apiError?.body?.message + ?? apiError?.message + ?? fallback; +}; + +const postRefreshMessageToOpener = (response: GisUploadResponse) => { + if (!response.mapRefreshRequired || typeof window === "undefined" || !window.opener) { + return; + } + + window.opener.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.GIS_UPLOAD_COMPLETED, + payload: { + refreshLayerTypeNames: JSON.stringify(response.refreshLayerTypeNames ?? [LAYER_ID]), + }, + }, + window.location.origin, + ); +}; export default function GisManagementPopupPage() { - // 브라우저 파일 picker는 제어형 input보다 ref 제어가 안전하다. + // 파일 input은 브라우저 보안 정책상 value 직접 제어가 제한돼 ref로 선택/초기화한다. const fileInputRef = useRef(null); - const [selectedFacilityTypeCode, setSelectedFacilityTypeCode] = useState(""); + const [facility, setFacility] = useState(DEFAULT_FACILITY); + const [mngInstOptions, setMngInstOptions] = useState([]); + const [selectedMngInstCd, setSelectedMngInstCd] = useState(SELECT_MNG_INST_VALUE); const [selectedFile, setSelectedFile] = useState(null); - const [uploadHistory, setUploadHistory] = useState( - INITIAL_UPLOAD_HISTORY, - ); - const [selectedUploadId, setSelectedUploadId] = useState( - INITIAL_UPLOAD_HISTORY[0]?.uploadId ?? "", - ); + const [downloadIdsByScope, setDownloadIdsByScope] = useState>({}); + const [uploadHistory, setUploadHistory] = useState([]); + const [validationErrors, setValidationErrors] = useState([]); + const [viewMode, setViewMode] = useState("history"); + const [selectedUploadId, setSelectedUploadId] = useState(""); const [currentPage, setCurrentPage] = useState(1); - - const selectedFacility = useMemo( - () => - UPLOAD_FACILITY_TYPES.find( - (facilityType) => - facilityType.facilityTypeCode === selectedFacilityTypeCode, - ) ?? null, - [selectedFacilityTypeCode], - ); + const [total, setTotal] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [notice, setNotice] = useState(""); const selectedHistory = useMemo( - () => - uploadHistory.find((history) => history.uploadId === selectedUploadId) ?? - null, + () => uploadHistory.find((history) => history.uploadId === selectedUploadId) ?? null, [selectedUploadId, uploadHistory], ); - const pagedUploadHistory = useMemo(() => { - const startIndex = (currentPage - 1) * GIS_UPLOAD_PAGE_SIZE; - return uploadHistory.slice(startIndex, startIndex + GIS_UPLOAD_PAGE_SIZE); - }, [currentPage, uploadHistory]); + const selectedScopeKey = toScopeKey(selectedMngInstCd); + const selectedScopeLabel = useMemo(() => { + if (!selectedMngInstCd) return "선택"; + const matched = mngInstOptions.find((option) => option.mngInstCd === selectedMngInstCd); + return matched?.lwrInstNm || selectedMngInstCd; + }, [mngInstOptions, selectedMngInstCd]); + + const allowedExtensionText = facility.allowedExtensions.join(", "); + const latestDownloadId = downloadIdsByScope[selectedScopeKey] ?? ""; + const isManagementSelected = Boolean(selectedMngInstCd); const resetSelectedFile = () => { setSelectedFile(null); @@ -141,64 +179,184 @@ export default function GisManagementPopupPage() { } }; - const handleFacilityTypeChange = ( - event: ChangeEvent, - ) => { - setSelectedFacilityTypeCode(event.target.value); + const fetchHistories = useCallback( + async (page = currentPage, mngInstCd = selectedMngInstCd) => { + if (!mngInstCd) { + setUploadHistory([]); + setSelectedUploadId(""); + setTotal(0); + setCurrentPage(1); + return; + } + + const params = new URLSearchParams({ + screenId: SCREEN_ID, + facilityTypeCode: FACILITY_TYPE_CODE, + page: String(page - 1), + size: String(GIS_UPLOAD_PAGE_SIZE), + }); + params.set("mngInstCd", mngInstCd); + + try { + const response = await getData(`v1/map/gisUploads?${params.toString()}`); + const items = Array.isArray(response?.items) ? response.items : []; + setUploadHistory(items); + setTotal(Number(response?.total ?? 0)); + setCurrentPage(page); + setSelectedUploadId(items[0]?.uploadId ?? ""); + setNotice(""); + } catch (error) { + console.error("GIS 업로드 이력 조회 실패:", error); + setUploadHistory([]); + setSelectedUploadId(""); + setTotal(0); + setCurrentPage(1); + setNotice("GIS 업로드 이력을 불러오지 못했습니다."); + } + }, + [currentPage, selectedMngInstCd], + ); + + useEffect(() => { + let mounted = true; + + const initialize = async () => { + setIsLoading(true); + 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 관리 정보를 불러오지 못했습니다."); + } + } finally { + if (mounted) setIsLoading(false); + } + }; + + void initialize(); + return () => { + mounted = false; + }; + // 최초 로딩은 고정 화면 ID와 전체 관리기관 기준으로 한 번만 수행한다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleMngInstChange = (event: ChangeEvent) => { + if (isLoading) return; + const nextMngInstCd = event.target.value; + setSelectedMngInstCd(nextMngInstCd); + setUploadHistory([]); + setSelectedUploadId(""); + setTotal(0); + setCurrentPage(1); resetSelectedFile(); + setValidationErrors([]); + setViewMode("history"); + setNotice(""); + void fetchHistories(1, nextMngInstCd); }; const handleOpenFilePicker = () => { - if (!selectedFacility) { - window.alert("시설구분을 먼저 선택하세요."); + if (!isManagementSelected) { + window.alert("관리기관을 선택하세요."); return; } - fileInputRef.current?.click(); }; const handleFileSelected = (event: ChangeEvent) => { const nextFile = event.target.files?.[0] ?? null; - - if (!selectedFacility) { - resetSelectedFile(); - window.alert("시설구분을 먼저 선택한 뒤 파일을 선택하세요."); - return; - } - if (!nextFile) { resetSelectedFile(); return; } const fileExtension = getFileExtension(nextFile.name); - const isAllowedExtension = selectedFacility.allowedExtensions.includes( - fileExtension, - ); - - if (!isAllowedExtension) { + if (!facility.allowedExtensions.includes(fileExtension)) { resetSelectedFile(); - window.alert( - `업로드 가능한 GIS 파일 형식이 아닙니다. 허용 형식: ${selectedFacility.allowedExtensions.join(", ")}`, - ); + window.alert(`업로드 가능한 파일 형식이 아닙니다. 허용 형식: ${allowedExtensionText}`); return; } - const maxFileSizeInBytes = selectedFacility.maxFileSizeMb * 1024 * 1024; + const maxFileSizeInBytes = facility.maxFileSizeMb * 1024 * 1024; if (nextFile.size > maxFileSizeInBytes) { resetSelectedFile(); - window.alert( - `파일 크기가 제한을 초과했습니다. 최대 ${selectedFacility.maxFileSizeMb}MB까지 업로드할 수 있습니다.`, - ); + window.alert(`파일 크기가 제한을 초과했습니다. 최대 ${facility.maxFileSizeMb}MB까지 업로드할 수 있습니다.`); return; } setSelectedFile(nextFile); }; - const handleUpload = () => { - if (!selectedFacility) { - window.alert("시설구분을 선택하세요."); + const handleDownloadCurrent = async () => { + if (!isManagementSelected) { + window.alert("관리기관을 선택하세요."); + return; + } + + const params = new URLSearchParams({ + screenId: SCREEN_ID, + facilityTypeCode: FACILITY_TYPE_CODE, + format: facility.requiredDownloadFormat || "GPKG", + }); + params.set("mngInstCd", selectedMngInstCd); + + setIsLoading(true); + setNotice(""); + try { + const response = await getBlobDataWithHeaders( + `v1/map/gisUploads/swagePpl/download?${params.toString()}`, + ); + const downloadId = response.headers.get("X-Gis-Download-Id") ?? ""; + if (!downloadId) { + setNotice("다운로드 기준 정보를 확인하지 못했습니다. 다시 다운로드한 뒤 업로드하세요."); + } else { + setDownloadIdsByScope((current) => ({ ...current, [selectedScopeKey]: downloadId })); + } + saveBlob(response.blob, response.fileName ?? `swage_ppl_${selectedScopeKey}.gpkg`); + if (downloadId) { + setNotice(`${selectedScopeLabel} 기준 GeoPackage를 다운로드했습니다.`); + } + } catch (error) { + console.error("GIS 다운로드 실패:", error); + window.alert("GeoPackage 다운로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleUpload = async () => { + if (!isManagementSelected) { + window.alert("관리기관을 선택하세요."); return; } @@ -207,208 +365,365 @@ export default function GisManagementPopupPage() { return; } - const isDuplicateFile = uploadHistory.some( - (history) => - history.facilityTypeCode === selectedFacility.facilityTypeCode && - history.fileName.toLowerCase() === selectedFile.name.toLowerCase(), - ); - - if (isDuplicateFile) { - window.alert("동일 시설구분에 같은 파일명이 존재합니다."); - return; + const formData = new FormData(); + formData.append("screenId", SCREEN_ID); + formData.append("facilityTypeCode", FACILITY_TYPE_CODE); + formData.append("layerId", facility.layerId || LAYER_ID); + if (latestDownloadId) { + formData.append("downloadId", latestDownloadId); } + formData.append("mngInstCd", selectedMngInstCd); + formData.append("file", selectedFile); - const nextUploadHistory: UploadHistoryItem = { - uploadId: `GUP-MOCK-${Date.now()}`, - facilityTypeCode: selectedFacility.facilityTypeCode, - facilityTypeName: selectedFacility.facilityTypeName, - fileName: selectedFile.name, - registeredAt: buildTodayDate(), - registeredBy: "admin", - }; + setIsLoading(true); + setNotice(""); + try { + const response = (await postMutiPart("v1/map/gisUploads", formData)) as GisUploadResponse; + if (response.status === "VALIDATION_FAILED") { + setValidationErrors(Array.isArray(response.validationErrors) ? response.validationErrors : []); + setViewMode("validationErrors"); + setNotice(response.message ?? "GeoPackage 검증 오류가 있습니다."); + return; + } - setUploadHistory((currentHistory) => [ - nextUploadHistory, - ...currentHistory, - ]); - setCurrentPage(1); - setSelectedUploadId(nextUploadHistory.uploadId); - window.alert("GIS 업로드가 완료되었습니다."); - resetSelectedFile(); + postRefreshMessageToOpener(response); + await fetchHistories(1, selectedMngInstCd); + resetSelectedFile(); + setValidationErrors([]); + setViewMode("history"); + setNotice(response.message ?? "GIS 업로드가 완료되었습니다."); + } catch (error) { + console.error("GIS 업로드 실패:", error); + window.alert(getErrorMessage(error, "GeoPackage 업로드 중 오류가 발생했습니다.")); + } finally { + setIsLoading(false); + } }; - const handleDownload = () => { + const handleDownloadSelectedHistory = async () => { if (!selectedHistory) { - window.alert("다운로드할 파일을 선택하세요."); + window.alert("다운로드할 업로드 현황 행을 선택하세요."); return; } - window.alert(`${selectedHistory.fileName} 다운로드를 요청했습니다.`); + setIsLoading(true); + setNotice(""); + try { + const response = await getBlobDataWithHeaders( + `v1/map/gisUploads/${encodeURIComponent(selectedHistory.uploadId)}/download`, + ); + saveBlob(response.blob, response.fileName ?? `${selectedHistory.uploadId}.gpkg`); + setNotice(`${buildManagementLabel(selectedHistory.mngInstCd, selectedHistory.mngInstNm)} 기준 GeoPackage를 다운로드했습니다.`); + } catch (error) { + console.error("GIS 이력 다운로드 실패:", error); + window.alert("선택한 현황 기준 GeoPackage 다운로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const fetchValidationErrors = async (uploadId = selectedUploadId) => { + if (!uploadId) { + window.alert("오류를 확인할 업로드 현황 행을 선택하세요."); + return; + } + + setIsLoading(true); + setNotice(""); + try { + const response = await getData( + `v1/map/gisUploads/${encodeURIComponent(uploadId)}/validationErrors?page=0&size=500`, + ); + setValidationErrors(Array.isArray(response?.errors) ? response.errors : []); + setViewMode("validationErrors"); + } catch (error) { + console.error("GIS 업로드 오류 조회 실패:", error); + window.alert("업로드 오류 목록을 불러오지 못했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleRefreshHistories = async () => { + setValidationErrors([]); + setViewMode("history"); + await fetchHistories(1, selectedMngInstCd); + }; + + const handleOpenPreview = () => { + if (!selectedHistory) { + window.alert("미리보기할 업로드 현황 행을 선택하세요."); + return; + } + setViewMode("preview"); }; return ( -
-
-
-

- GIS 관리 -

-
+
+ +
+

GIS 관리

+
-
-
- GIS 업로드 -
+ {notice ? ( +
+ {notice} +
+ ) : null} -
-
- + + {mngInstOptions.map((option) => { + const value = String(option.mngInstCd ?? "").trim(); + if (!value) return null; + return ( + - ))} - + ); + })} + + - + -
- - - -
-
- - - -
-
- GIS 업로드 현황 -
- +
+ +
+
-
- - - - - no - - - 시설구분 - - - 파일명 - - - 등록일자 - - - 등록자 - - - + - - {pagedUploadHistory.map((history, index) => { - const isSelected = history.uploadId === selectedUploadId; - const backgroundColor = isSelected - ? "var(--primary-transparent)" - : index % 2 === 0 - ? "var(--background)" - : "var(--secondary)"; - const rowNumber = - (currentPage - 1) * GIS_UPLOAD_PAGE_SIZE + index + 1; +
+

+ {viewMode === "preview" ? "GIS 업로드 미리보기" : viewMode === "validationErrors" ? "GIS 업로드 오류 확인" : `GIS 업로드 현황 총 ${total.toLocaleString("ko-KR")}건`} +

+
+ + + + +
+
- return ( - setSelectedUploadId(history.uploadId)} - style={{ backgroundColor }} - > - - {rowNumber} - - - {history.facilityTypeName} - - - {history.fileName} - - - {history.registeredAt} - - - {history.registeredBy} + {viewMode === "preview" && selectedHistory ? ( +
+ +
+ ) : viewMode === "validationErrors" ? ( +
+
+ + + {["no", "행번호", "FID", "오류코드", "오류내용"].map((head) => ( + + {head} + + ))} + + + + {validationErrors.length > 0 ? ( + validationErrors.map((error, index) => ( + + + {index + 1} + + + {error.rowNo ?? "-"} + + + {error.fid || "-"} + + + {error.errorCode || "-"} + + + {error.errorMessage || "-"} + + + )) + ) : ( + + + 표시할 오류가 없습니다. - ); - })} - + )} +
- - {uploadHistory.length > GIS_UPLOAD_PAGE_SIZE ? ( -
- -
- ) : null} + ) : ( + <> +
+ + + + {["no", "관리기관", "파일명", "상태", "변경/오류", "등록일자", "등록자"].map((head) => ( + + {head} + + ))} + + + + {uploadHistory.length > 0 ? ( + uploadHistory.map((history, index) => { + const rowNumber = (currentPage - 1) * GIS_UPLOAD_PAGE_SIZE + index + 1; + const isSelected = history.uploadId === selectedUploadId; + return ( + setSelectedUploadId(history.uploadId)} + > + + {rowNumber} + + + {buildManagementLabel(history.mngInstCd, history.mngInstNm)} + + + {history.fileName} + + + {history.status ?? "-"} + + + {(history.changedCount ?? 0).toLocaleString("ko-KR")} / {(history.invalidCount ?? 0).toLocaleString("ko-KR")} + + + {formatDateTime(history.registeredAt)} + + + {history.registeredBy || "-"} + + + ); + }) + ) : ( + + + 조회 결과가 없습니다. + + + )} + +
+ + {total > GIS_UPLOAD_PAGE_SIZE ? ( +
+ void fetchHistories(page, selectedMngInstCd)} + /> +
+ ) : null} + + )}
); diff --git a/apps/sn/app/(map-layout)/map/components/gisManagement/GisUploadPreviewMap.tsx b/apps/sn/app/(map-layout)/map/components/gisManagement/GisUploadPreviewMap.tsx new file mode 100644 index 000000000..f60f1ae6d --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisManagement/GisUploadPreviewMap.tsx @@ -0,0 +1,561 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { FeatureLike } from "ol/Feature"; +import GeoJSON from "ol/format/GeoJSON"; +import Map from "ol/Map"; +import View from "ol/View"; +import { Circle as CircleStyle, Fill, Stroke, Style } from "ol/style"; +import TileLayer from "ol/layer/Tile"; +import VectorLayer from "ol/layer/Vector"; +import VectorSource from "ol/source/Vector"; +import TileWMTS from "ol/source/WMTS"; +import WMTSTileGrid from "ol/tilegrid/WMTS"; +import { getTopLeft } from "ol/extent"; +import { get as getProjection } from "ol/proj"; +import { register } from "ol/proj/proj4"; +import proj4 from "proj4"; + +import { getRawJson } from "@/components/lib/api"; + +type GisUploadPreviewMapProps = { + uploadId: string; + mngInstCd?: string | null; +}; + +type LayerSetting = { + id?: string; + lyrSn?: string; + lyrNm?: string; + lyrTyNm?: string; + lyrTypeNm?: string; + lyr_type_nm?: string; + flterSql?: string | null; + minViewSize?: string | null; + minZoom?: string | null; + lnColor?: string | null; + lineColr?: string | null; + lnWdth?: string | null; + lineWdth?: string | null; + drawOrdr?: string | null; + exprsYn?: string | null; + useYn?: string | null; + actlLyrYn?: string | null; + children?: LayerSetting[]; +}; + +const MAP_SRS = "EPSG:5179"; +const INITIAL_CENTER_5179 = [965000, 1900000]; +const INITIAL_ZOOM = 8; +const FALLBACK_PIPE_MIN_ZOOM = 13; +const FALLBACK_MANHOLE_MIN_ZOOM = 14; +const LAYER_GROUP = "SN_GIS_ULD_PREVIEW"; +const FALLBACK_LAYER_GROUP = "SN_NETWORK"; +const PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; +const MANHOLE_LAYER_TYPE = "tb_ms_swage_mhl"; +const NGII_WMTS_URL = `/ngii-server/openapi/Gettile.do?apikey=${process.env.NEXT_PUBLIC_NGII_PARAM ?? ""}`; +const NGII_RESOLUTIONS = [1044.48, 522.24, 261.12, 130.56, 65.28, 32.64, 16.32, 8.16, 4.08, 2.04, 1.02, 0.51, 0.255]; +const NGII_MATRIX_IDS = ["L06", "L07", "L08", "L09", "L10", "L11", "L12", "L13", "L14", "L15", "L16", "L17", "L18"]; + +let isProjectionRegistered = false; + +const registerMapProjection = () => { + if (isProjectionRegistered) return; + proj4.defs( + MAP_SRS, + "+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs", + ); + register(proj4); + const projection = getProjection(MAP_SRS); + projection?.setExtent([-200000.0, -28024123.62, 31824123.62, 4000000.0]); + isProjectionRegistered = true; +}; + +const createNgiiBaseLayer = () => { + registerMapProjection(); + const projection = getProjection(MAP_SRS); + const tileGrid = new WMTSTileGrid({ + origin: getTopLeft(projection?.getExtent() ?? [-200000.0, -28024123.62, 31824123.62, 4000000.0]), + resolutions: NGII_RESOLUTIONS, + matrixIds: NGII_MATRIX_IDS, + tileSize: [256, 256], + }); + const source = new TileWMTS({ + projection, + tileGrid, + crossOrigin: "anonymous", + requestEncoding: "KVP", + layer: "korean_map", + matrixSet: MAP_SRS, + format: "image/png", + style: "korean", + }); + source.setTileUrlFunction((tileCoord) => { + const [z, x, y] = tileCoord; + return NGII_WMTS_URL + .replace("{z}", NGII_MATRIX_IDS[z] ?? NGII_MATRIX_IDS[0]) + .replace("{x}", String(x)) + .replace("{y}", String(y)); + }); + return new TileLayer({ + source, + zIndex: 0, + }); +}; + +const flattenLayerSettings = (items: LayerSetting[] = []): LayerSetting[] => + items.flatMap((item) => [item, ...flattenLayerSettings(item.children ?? [])]); + +const getLayerTypeName = (layer: LayerSetting) => + String(layer.lyrTyNm ?? layer.lyrTypeNm ?? layer.lyr_type_nm ?? "").trim(); + +const toFiniteNumber = (value: unknown, fallback: number) => { + if (value === null || value === undefined || String(value).trim() === "") return fallback; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const normalizeColor = (value: unknown, fallback: string) => { + const color = String(value ?? "").trim(); + return color || fallback; +}; + +const getLineColor = (layer?: LayerSetting | null) => + normalizeColor(layer?.lnColor ?? layer?.lineColr, "#f97316"); + +const getLayerMinZoom = (layer: LayerSetting | null | undefined, fallback: number) => + toFiniteNumber(layer?.minViewSize ?? layer?.minZoom, fallback); + +const getPipeLineWidth = (layer?: LayerSetting | null) => + Math.max(3, toFiniteNumber(layer?.lnWdth ?? layer?.lineWdth, 1) + 2); + +const pipePreviewStyle = (layerSetting?: LayerSetting | null) => (feature: FeatureLike) => { + const status = feature.get("previewStatus"); + const baseColor = + status === "CREATED" ? "#16a34a" : + status === "UPDATED" ? "#f97316" : + getLineColor(layerSetting); + const width = status === "LATEST" ? getPipeLineWidth(layerSetting) : getPipeLineWidth(layerSetting) + 3; + return [ + new Style({ + stroke: new Stroke({ + color: "#ffffff", + width: width + 3, + }), + }), + new Style({ + stroke: new Stroke({ + color: baseColor, + width, + }), + }), + ]; +}; + +const manholeStyle = new Style({ + image: new CircleStyle({ + radius: 5, + fill: new Fill({ color: "#e11d48" }), + stroke: new Stroke({ color: "#ffffff", width: 2 }), + }), +}); + +const unwrapFeatureCollection = (response: any) => response?.data ?? response; + +const refreshMapSize = (map: Map) => { + window.requestAnimationFrame(() => { + map.updateSize(); + }); +}; + +const getFeatureExtent = (features: ReturnType) => { + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + + features.forEach((feature) => { + const geometry = feature.getGeometry(); + if (!geometry) return; + const extent = geometry.getExtent(); + minX = Math.min(minX, extent[0]); + minY = Math.min(minY, extent[1]); + maxX = Math.max(maxX, extent[2]); + maxY = Math.max(maxY, extent[3]); + }); + + if (![minX, minY, maxX, maxY].every(Number.isFinite)) { + return null; + } + return [minX, minY, maxX, maxY] as [number, number, number, number]; +}; + +const getFeatureExtentCenter = (features: ReturnType) => { + const extent = getFeatureExtent(features); + if (!extent) return null; + const targetCenter = [(extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2]; + return features.reduce((nearestCenter, feature) => { + const featureExtent = feature.getGeometry()?.getExtent(); + if (!featureExtent) return nearestCenter; + const featureCenter = [ + (featureExtent[0] + featureExtent[2]) / 2, + (featureExtent[1] + featureExtent[3]) / 2, + ]; + if (!nearestCenter) return featureCenter; + const nearestDistance = ((nearestCenter[0] - targetCenter[0]) ** 2) + + ((nearestCenter[1] - targetCenter[1]) ** 2); + const featureDistance = ((featureCenter[0] - targetCenter[0]) ** 2) + + ((featureCenter[1] - targetCenter[1]) ** 2); + return featureDistance < nearestDistance ? featureCenter : nearestCenter; + }, null); +}; + +const buildBboxFilter = (extent: number[], layer?: LayerSetting | null) => { + const bboxFilter = `BBOX(geom, ${extent.join(",")}, '${MAP_SRS}')`; + const layerFilter = String(layer?.flterSql ?? "").trim(); + return layerFilter ? `(${layerFilter}) AND ${bboxFilter}` : bboxFilter; +}; + +const withManagementFilter = (filter: string, mngInstCd?: string | null) => { + const normalized = String(mngInstCd ?? "").trim(); + if (!normalized) return filter; + return `(mng_inst_cd='${normalized.replace(/'/g, "''")}') AND (${filter})`; +}; + +export default function GisUploadPreviewMap({ uploadId, mngInstCd }: GisUploadPreviewMapProps) { + const mapElementRef = useRef(null); + const mapRef = useRef(null); + const pipeSourceRef = useRef(new VectorSource()); + const manholeSourceRef = useRef(new VectorSource()); + const pipeLayerRef = useRef | null>(null); + const abortControllerRef = useRef(null); + const loadTimerRef = useRef(null); + const didFitPreviewRef = useRef(false); + + const [layerSettings, setLayerSettings] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [featureCount, setFeatureCount] = useState(0); + const [manholeCount, setManholeCount] = useState(0); + const [deletedCount, setDeletedCount] = useState(0); + const [truncated, setTruncated] = useState(false); + const [message, setMessage] = useState(""); + + const flatLayerSettings = useMemo(() => flattenLayerSettings(layerSettings), [layerSettings]); + const pipeLayerSetting = useMemo( + () => flatLayerSettings.find((layer) => getLayerTypeName(layer) === PIPE_LAYER_TYPE) ?? null, + [flatLayerSettings], + ); + const manholeLayerSetting = useMemo( + () => flatLayerSettings.find((layer) => getLayerTypeName(layer) === MANHOLE_LAYER_TYPE) ?? null, + [flatLayerSettings], + ); + const pipeMinZoom = getLayerMinZoom(pipeLayerSetting, FALLBACK_PIPE_MIN_ZOOM); + const manholeMinZoom = getLayerMinZoom(manholeLayerSetting, FALLBACK_MANHOLE_MIN_ZOOM); + + useEffect(() => { + let mounted = true; + const loadLayerSettings = async () => { + try { + const previewSettings = await getRawJson(`/sewerage-service/api/v1/map/lyrStngs/${LAYER_GROUP}`); + const previewFlat = flattenLayerSettings(Array.isArray(previewSettings) ? previewSettings : []); + if (mounted && previewFlat.some((layer) => [PIPE_LAYER_TYPE, MANHOLE_LAYER_TYPE].includes(getLayerTypeName(layer)))) { + setLayerSettings(previewSettings); + return; + } + + const networkSettings = await getRawJson(`/sewerage-service/api/v1/map/lyrStngs/${FALLBACK_LAYER_GROUP}`); + const filtered = flattenLayerSettings(Array.isArray(networkSettings) ? networkSettings : []) + .filter((layer) => [PIPE_LAYER_TYPE, MANHOLE_LAYER_TYPE].includes(getLayerTypeName(layer))) + .filter((layer) => (layer.useYn ?? "Y") === "Y" && (layer.exprsYn ?? "Y") === "Y" && (layer.actlLyrYn ?? "Y") === "Y"); + if (mounted) { + setLayerSettings(filtered); + } + } catch (error) { + console.error("GIS 업로드 미리보기 레이어 설정 조회 실패:", error); + if (mounted) { + setLayerSettings([]); + } + } + }; + void loadLayerSettings(); + return () => { + mounted = false; + }; + }, []); + + useEffect(() => { + let disposed = false; + let initializeTimer: number | null = null; + + const initializeMap = () => { + const mapElement = mapElementRef.current; + if (disposed || mapRef.current) return; + if (!mapElement || mapElement.clientWidth <= 0 || mapElement.clientHeight <= 0) { + initializeTimer = window.setTimeout(initializeMap, 100); + return; + } + + registerMapProjection(); + const pipeLayer = new VectorLayer({ + source: pipeSourceRef.current, + style: pipePreviewStyle(pipeLayerSetting), + minZoom: pipeMinZoom, + zIndex: toFiniteNumber(pipeLayerSetting?.drawOrdr, 300), + }); + const manholeLayer = new VectorLayer({ + source: manholeSourceRef.current, + style: manholeStyle, + minZoom: manholeMinZoom, + zIndex: toFiniteNumber(manholeLayerSetting?.drawOrdr, 510), + }); + const map = new Map({ + target: mapElement, + layers: [createNgiiBaseLayer(), pipeLayer, manholeLayer], + view: new View({ + center: INITIAL_CENTER_5179, + zoom: INITIAL_ZOOM, + projection: MAP_SRS, + }), + controls: [], + }); + + pipeLayerRef.current = pipeLayer; + mapRef.current = map; + refreshMapSize(map); + }; + + initializeMap(); + return () => { + disposed = true; + if (initializeTimer) { + window.clearTimeout(initializeTimer); + } + abortControllerRef.current?.abort(); + if (loadTimerRef.current) { + window.clearTimeout(loadTimerRef.current); + } + mapRef.current?.setTarget(undefined); + mapRef.current = null; + pipeLayerRef.current = null; + pipeSourceRef.current.clear(); + manholeSourceRef.current.clear(); + }; + }, []); + + useEffect(() => { + pipeLayerRef.current?.setStyle(pipePreviewStyle(pipeLayerSetting)); + pipeLayerRef.current?.setMinZoom(pipeMinZoom); + }, [pipeLayerSetting, pipeMinZoom]); + + useEffect(() => { + const map = mapRef.current; + const mapElement = mapElementRef.current; + if (!map || !mapElement) return; + + const resizeObserver = new ResizeObserver(() => { + refreshMapSize(map); + }); + resizeObserver.observe(mapElement); + + const updateTimer = window.setTimeout(() => { + map.updateSize(); + }, 0); + + return () => { + window.clearTimeout(updateTimer); + resizeObserver.disconnect(); + }; + }, []); + + useEffect(() => { + didFitPreviewRef.current = false; + }, [mngInstCd, uploadId]); + + const loadPreview = useCallback(async function loadPreviewRequest() { + const map = mapRef.current; + if (!map || !uploadId) return; + + map.updateSize(); + let size = map.getSize(); + if (!size) { + map.updateSize(); + size = map.getSize(); + } + if (!size || size[0] <= 0 || size[1] <= 0) { + if (loadTimerRef.current) { + window.clearTimeout(loadTimerRef.current); + } + loadTimerRef.current = window.setTimeout(() => { + void loadPreviewRequest(); + }, 150); + return; + } + + const extent = map.getView().calculateExtent(size); + const previewParams = new URLSearchParams({ + bbox: extent.join(","), + srs: MAP_SRS, + }); + if (mngInstCd) { + previewParams.set("mngInstCd", mngInstCd); + } + + const manholeParams = new URLSearchParams({ + TYPENAME: MANHOLE_LAYER_TYPE, + CQL_FILTER: withManagementFilter(buildBboxFilter(extent, manholeLayerSetting), mngInstCd), + }); + + abortControllerRef.current?.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setIsLoading(true); + setMessage(""); + pipeSourceRef.current.clear(); + manholeSourceRef.current.clear(); + setFeatureCount(0); + setManholeCount(0); + setDeletedCount(0); + setTruncated(false); + try { + const [previewResponse, manholeResponse] = await Promise.all([ + getRawJson( + `v1/map/gisUploads/${encodeURIComponent(uploadId)}/preview?${previewParams.toString()}`, + { signal: abortController.signal }, + ), + getRawJson( + `/sewerage-service/api/v1/map/geometry?${manholeParams.toString()}`, + { signal: abortController.signal }, + ).catch((error) => { + if (error?.name === "AbortError") { + return { type: "FeatureCollection", features: [] }; + } + console.warn("GIS 업로드 미리보기 맨홀 조회 실패:", error); + return { type: "FeatureCollection", features: [] }; + }), + ]); + + const previewCollection = unwrapFeatureCollection(previewResponse); + const pipeFeatures = new GeoJSON({ + dataProjection: MAP_SRS, + featureProjection: MAP_SRS, + }).readFeatures(previewCollection); + const manholeFeatures = new GeoJSON({ + dataProjection: "EPSG:3857", + featureProjection: MAP_SRS, + }).readFeatures(unwrapFeatureCollection(manholeResponse)); + + pipeSourceRef.current.addFeatures(pipeFeatures); + manholeSourceRef.current.addFeatures(manholeFeatures); + + if (!didFitPreviewRef.current && pipeFeatures.length > 0) { + const changedFeatures = pipeFeatures.filter((feature) => feature.get("previewStatus") !== "LATEST"); + const center = getFeatureExtentCenter(changedFeatures.length > 0 ? changedFeatures : pipeFeatures); + if (center) { + map.getView().setCenter(center); + map.getView().setZoom(pipeMinZoom); + didFitPreviewRef.current = true; + } + } + + setFeatureCount(pipeFeatures.length); + setManholeCount(manholeFeatures.length); + setDeletedCount(Number(previewCollection?.meta?.deletedCount ?? 0)); + setTruncated(Boolean(previewCollection?.meta?.truncated)); + } catch (error: any) { + if (error?.name === "AbortError") return; + console.error("GIS 업로드 미리보기 조회 실패:", error); + setMessage("미리보기 데이터를 불러오지 못했습니다."); + } finally { + if (!abortController.signal.aborted) { + setIsLoading(false); + } + } + }, [manholeLayerSetting, manholeMinZoom, mngInstCd, pipeMinZoom, uploadId]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const scheduleLoad = () => { + if (loadTimerRef.current) { + window.clearTimeout(loadTimerRef.current); + } + loadTimerRef.current = window.setTimeout(() => { + void loadPreview(); + }, 250); + }; + + map.on("moveend", scheduleLoad); + scheduleLoad(); + return () => { + map.un("moveend", scheduleLoad); + }; + }, [loadPreview]); + + return ( +
+
+
+

업로드 이력 미리보기

+

+ 변경 관로는 업로드 이력 기준으로, 변경 없는 관로와 맨홀은 최신 기준으로 표시합니다. +

+
+
+ +
+ 신규 / 수정 / 최신본 + 관로 표출 줌 {pipeMinZoom} 이상 + 관로 {featureCount.toLocaleString("ko-KR")}건 + 맨홀 {manholeCount.toLocaleString("ko-KR")}건 + 삭제 미표시 {deletedCount.toLocaleString("ko-KR")}건 + {truncated ? 조회 상한 초과 : null} + {isLoading ? 불러오는 중... : null} +
+ + {message ? ( +
+ {message} +
+ ) : null} + +
+ + +
+ ); +} 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 a518ae6f4..0c3b86188 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx @@ -311,7 +311,7 @@ export default function PipeAnalysisPopupPage() { exportRow[head.id] = row[head.id]; }); return exportRow; - }); + }); await downloadExcelWithKoreanHeaders({ data: exportRows as unknown as JSON[], fileName: `${SUMMARY_FILE_PREFIX[appliedFilters.analysisType]}_${buildPipeAnalysisFileDate()}`, 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 ed21c01d1..fc3f7a585 100644 --- a/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx @@ -1,6 +1,6 @@ "use client"; -import { Fragment, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import UiPageNations from "@common/business_components/ui/table/uiPageNations"; import { Button } from "@common/components"; @@ -10,7 +10,6 @@ import { type WindowPopupContentProps, } from "@/features/windowPopup/model/types"; import { - buildWaterRiskMethodLabel, buildWaterRiskSettingAreaName, buildWaterRiskStatusLabel, EMPTY_WATER_RISK_SESSION_SNAPSHOT, @@ -20,15 +19,15 @@ import { WATER_RISK_SIDO_OPTIONS, type WaterRiskResultRow, type WaterRiskSessionSnapshot, - type WaterRiskSettingRow, type WaterRiskStagedPipe, } from "./waterRiskTypes"; type WaterRiskView = "result" | "setting"; +type WaterRiskResultMode = "current" | "history"; type ResultFilters = { - analysisDate: string; - analysisTime: string; + startDate: string; + endDate: string; sidoName: string; sggName: string; areaId: string; @@ -42,8 +41,8 @@ type SettingFilters = { }; const DEFAULT_RESULT_FILTERS: ResultFilters = { - analysisDate: "2026-05-18", - analysisTime: "09:00", + startDate: "2026-05-18", + endDate: "2026-05-18", sidoName: "전체", sggName: "전체", areaId: "ALL", @@ -57,48 +56,26 @@ const DEFAULT_SETTING_FILTERS: SettingFilters = { }; const TOOLBAR_INPUT_CLASS_NAME = - "sn-popup-input"; - -const SECONDARY_BUTTON_CLASS_NAME = - "sn-popup-secondary-button"; + "h-[32px] rounded-[4px] border border-[#b9c7d6] bg-white px-2 text-[12px] text-[#1f2937] outline-none focus:border-[#00aeb3] focus:ring-1 focus:ring-[#00aeb3]"; const ACTION_BUTTON_CLASS_NAME = - "sn-popup-primary-button"; + "h-[28px] rounded-[6px] border border-[#2e77bc] bg-[#3f8fd7] px-3 text-[12px] font-semibold text-white hover:bg-[#337ec1]"; const WATER_RISK_PAGE_SIZE = 10; const RESULT_TABLE_CONTAINER_CLASS_NAME = - "sn-popup-card min-h-0"; + "min-h-0 overflow-hidden border border-[#cbd9e6] bg-white"; const SETTING_TABLE_CONTAINER_CLASS_NAME = - "sn-popup-card min-h-0"; + "min-h-0 overflow-hidden rounded-[10px] border border-[#d6e1ee] bg-[linear-gradient(180deg,#ffffff_0%,#f9fbfe_100%)] shadow-[0_6px_16px_rgba(31,59,87,0.06)]"; -const postMessageToOpener = (type: string, payload?: Record) => { +const postMessageToOpener = ( + type: string, + payload?: Record, +) => { if (typeof window === "undefined" || !window.opener) return; window.opener.postMessage({ type, payload }, window.location.origin); }; const buildPreviewTimestamp = () => new Date().toISOString(); -const buildResultDownloadPreview = ( - filters: ResultFilters, - rows: WaterRiskResultRow[], -) => ({ - endpoint: "/api/v1/map/wraResults/download", - method: "POST", - generatedAt: buildPreviewTimestamp(), - filters, - resultCount: rows.length, -}); - -const buildSettingDownloadPreview = ( - filters: SettingFilters, - rows: WaterRiskSettingRow[], -) => ({ - endpoint: "/api/v1/map/wraOutletPipeSettings/download", - method: "POST", - generatedAt: buildPreviewTimestamp(), - filters, - resultCount: rows.length, -}); - const buildSavePreview = ( snapshot: WaterRiskSessionSnapshot, stagedPipes: WaterRiskStagedPipe[], @@ -118,30 +95,30 @@ const buildSavePreview = ( const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") => riskStatus === "DANGER" - ? "sn-popup-badge-danger" - : "sn-popup-badge-normal"; + ? "bg-[#ff1c15] text-white" + : "bg-white text-[#111827]"; export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { const [activeView, setActiveView] = useState("result"); + // 최신 화면설계서의 현재 결과/과거 이력 분리 요구를 팝업 내부 탭 상태로 관리한다. + const [resultMode, setResultMode] = useState("current"); const [snapshot, setSnapshot] = useState( EMPTY_WATER_RISK_SESSION_SNAPSHOT, ); const [draftResultFilters, setDraftResultFilters] = useState( DEFAULT_RESULT_FILTERS, ); - const [appliedResultFilters, setAppliedResultFilters] = useState( - DEFAULT_RESULT_FILTERS, - ); - const [draftSettingFilters, setDraftSettingFilters] = useState( - DEFAULT_SETTING_FILTERS, - ); - const [appliedSettingFilters, setAppliedSettingFilters] = useState( - DEFAULT_SETTING_FILTERS, - ); + const [appliedResultFilters, setAppliedResultFilters] = + useState(DEFAULT_RESULT_FILTERS); + const [draftSettingFilters, setDraftSettingFilters] = + useState(DEFAULT_SETTING_FILTERS); + const [appliedSettingFilters, setAppliedSettingFilters] = + useState(DEFAULT_SETTING_FILTERS); const [selectedResultAreaId, setSelectedResultAreaId] = useState( WATER_RISK_RESULT_ROWS[0]?.areaId ?? "", ); - const [selectedSettingAreaId, setSelectedSettingAreaId] = useState(""); + const [selectedSettingAreaId, setSelectedSettingAreaId] = + useState(""); const [resultCurrentPage, setResultCurrentPage] = useState(1); const [settingCurrentPage, setSettingCurrentPage] = useState(1); const [noticeMessage, setNoticeMessage] = useState(""); @@ -150,7 +127,8 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { const handleMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; - if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC) return; + if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC) + return; const nextSnapshot = (event.data.payload ?? EMPTY_WATER_RISK_SESSION_SNAPSHOT) as WaterRiskSessionSnapshot; @@ -167,7 +145,9 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_READY); return () => { - postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT); + postMessageToOpener( + WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_STOP_SETTING_EDIT, + ); window.removeEventListener("message", handleMessage); }; }, []); @@ -233,15 +213,6 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { }); }, [appliedSettingFilters]); - const maxPipeCount = useMemo( - () => - visibleResultRows.reduce( - (maxCount, row) => Math.max(maxCount, row.pipeSummaries.length), - 0, - ), - [visibleResultRows], - ); - const resultAreaOptions = useMemo( () => WATER_RISK_RESULT_ROWS.map((row) => ({ @@ -273,40 +244,50 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { const currentSettingAreaName = snapshot.selectedSettingAreaName || - buildWaterRiskSettingAreaName(selectedSettingAreaId || snapshot.selectedSettingAreaId); + buildWaterRiskSettingAreaName( + selectedSettingAreaId || snapshot.selectedSettingAreaId, + ); const pagedResultRows = useMemo(() => { const startIndex = (resultCurrentPage - 1) * WATER_RISK_PAGE_SIZE; - return visibleResultRows.slice(startIndex, startIndex + WATER_RISK_PAGE_SIZE); + return visibleResultRows.slice( + startIndex, + startIndex + WATER_RISK_PAGE_SIZE, + ); }, [resultCurrentPage, visibleResultRows]); const pagedSettingRows = useMemo(() => { const startIndex = (settingCurrentPage - 1) * WATER_RISK_PAGE_SIZE; - return visibleSettingRows.slice(startIndex, startIndex + WATER_RISK_PAGE_SIZE); + return visibleSettingRows.slice( + startIndex, + startIndex + WATER_RISK_PAGE_SIZE, + ); }, [settingCurrentPage, visibleSettingRows]); - const showSettingEditor = snapshot.isSettingEditMode || snapshot.stagedPipes.length > 0; - const resultPipeGroupCount = Math.max(1, maxPipeCount); - const resultTableMinWidth = 560 + resultPipeGroupCount * 180; + const showSettingEditor = + snapshot.isSettingEditMode || snapshot.stagedPipes.length > 0; + const resultTableMinWidth = resultMode === "current" ? 1140 : 1060; const settingTableMinWidth = 900; useEffect(() => { if (typeof window === "undefined" || !window.resizeTo) return; const popupWidth = - activeView === "result" - ? Math.min(1320, Math.max(860, 620 + resultPipeGroupCount * 180)) - : showSettingEditor - ? 1180 - : 980; - const popupHeight = activeView === "setting" && showSettingEditor ? 900 : 720; + activeView === "result" ? 1180 : showSettingEditor ? 1180 : 980; + const popupHeight = + activeView === "setting" && showSettingEditor ? 900 : 720; window.resizeTo(popupWidth, popupHeight); - }, [activeView, resultPipeGroupCount, showSettingEditor]); + }, [activeView, showSettingEditor]); const handleApplyResultFilters = () => { - if (!draftResultFilters.analysisDate || !draftResultFilters.analysisTime) { - setNoticeMessage("기준 날짜와 시간을 입력해야 조회할 수 있습니다."); + if (!draftResultFilters.startDate || !draftResultFilters.endDate) { + setNoticeMessage("조회 시작일과 종료일을 입력해야 합니다."); + return; + } + + if (draftResultFilters.startDate > draftResultFilters.endDate) { + setNoticeMessage("조회 시작일은 종료일보다 늦을 수 없습니다."); return; } @@ -315,6 +296,12 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { setNoticeMessage("통수위험 분석 결과를 갱신했습니다."); }; + const handleChangeResultMode = (mode: WaterRiskResultMode) => { + setResultMode(mode); + setResultCurrentPage(1); + setNoticeMessage(""); + }; + const handleOpenSettingView = () => { setActiveView("setting"); setNoticeMessage(""); @@ -333,12 +320,6 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { setNoticeMessage(`${row.areaName}을 지도에서 강조했습니다.`); }; - const handlePreviewResultDownload = () => { - const preview = buildResultDownloadPreview(appliedResultFilters, visibleResultRows); - console.info("WaterRisk result download preview", preview); - setNoticeMessage("결과 다운로드 preview를 생성했습니다. 콘솔을 확인하세요."); - }; - const handleApplySettingFilters = () => { setSettingCurrentPage(1); setAppliedSettingFilters(draftSettingFilters); @@ -351,20 +332,28 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { }; const handleRemoveStagedPipe = (pipeId: string) => { - postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REMOVE_STAGED_PIPE, { - pipeId, - }); + postMessageToOpener( + WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REMOVE_STAGED_PIPE, + { + pipeId, + }, + ); }; const handleMoveStagedPipe = (pipeId: string, direction: "UP" | "DOWN") => { - postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REORDER_STAGED_PIPE, { - pipeId, - direction, - }); + postMessageToOpener( + WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_REORDER_STAGED_PIPE, + { + pipeId, + direction, + }, + ); }; const handleClearStagedPipes = () => { - postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLEAR_STAGED_PIPES); + postMessageToOpener( + WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLEAR_STAGED_PIPES, + ); }; const handlePreviewSave = () => { @@ -375,7 +364,9 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { const preview = buildSavePreview(snapshot, snapshot.stagedPipes); console.info("WaterRisk save preview", preview); - setNoticeMessage("설정 저장 payload preview를 생성했습니다. 콘솔을 확인하세요."); + setNoticeMessage( + "설정 저장 payload preview를 생성했습니다. 콘솔을 확인하세요.", + ); }; const handleSelectSettingArea = (areaId: string) => { @@ -385,66 +376,98 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { settingAreaOptions.find((item) => item.areaId === areaId)?.areaName ?? buildWaterRiskSettingAreaName(areaId); - postMessageToOpener(WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_START_SETTING_EDIT, { - areaId, - areaName: targetAreaName, - }); + postMessageToOpener( + WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_START_SETTING_EDIT, + { + areaId, + areaName: targetAreaName, + }, + ); setNoticeMessage(`${targetAreaName} 설정 편집 대상을 선택했습니다.`); }; return ( -
-
+
+
{activeView === "setting" ? ( ) : null} -

- {activeView === "result" ? "통수위험분석" : "통수위험분석 설정"} +

+ {activeView === "result" + ? "통수위험분석 결과조회" + : "통수위험분석 설정"}

-
+
{activeView === "result" ? ( -
-
- - -