From b11658e1d65dae1c090347fabc5851c0770788b3 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Thu, 30 Jul 2026 17:18:59 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A4=80=EC=84=A4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=B4=88=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../map/components/common/index.ts | 4 +- .../gisManagement/GisManagementPopupPage.tsx | 901 ++++++++++++------ .../gisManagement/GisUploadPreviewMap.tsx | 561 +++++++++++ .../pipeAnalysis/PipeAnalysisPopupPage.tsx | 4 +- .../waterRisk/WaterRiskPopupPage.tsx | 646 ++++++++----- .../components/waterRisk/waterRiskTypes.ts | 86 +- apps/sn/app/(map-layout)/map/page.tsx | 28 + .../_components/DredgingLocationMap.tsx | 223 +++++ .../_components/DredgingRegisterModal.tsx | 805 ++++++++++++++++ .../oper/dredging/_lib/client.ts | 202 ++++ .../oper/dredging/_lib/exif.ts | 334 +++++++ .../oper/dredging/_lib/types.ts | 100 ++ .../(with-base-layout)/oper/dredging/page.tsx | 564 +++++++++++ apps/sn/app/components/lib/api/index.ts | 25 + .../map/components/common/index.ts | 6 +- .../sn/app/html/(with-base-layout)/layout.tsx | 2 +- .../001-qa-SFR-010-012-준설관리-검증결과.md | 51 + .../001-fe-SFR-010-012-준설관리-작업카드.md | 49 + .../001-fe-SFR-010-012-준설관리-구현기록.md | 59 ++ .../src/features/windowPopup/model/types.ts | 1 + .../src/ui/chart/LineBarChart.tsx | 6 +- .../src/ui/chart/LineChart.tsx | 4 +- 22 files changed, 4117 insertions(+), 544 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/gisManagement/GisUploadPreviewMap.tsx create mode 100644 apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingLocationMap.tsx create mode 100644 apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterModal.tsx create mode 100644 apps/sn/app/(with-base-layout)/oper/dredging/_lib/client.ts create mode 100644 apps/sn/app/(with-base-layout)/oper/dredging/_lib/exif.ts create mode 100644 apps/sn/app/(with-base-layout)/oper/dredging/_lib/types.ts create mode 100644 apps/sn/app/(with-base-layout)/oper/dredging/page.tsx create mode 100644 apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md create mode 100644 apps/sn/docs/작업계획/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-작업카드.md create mode 100644 apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md 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 dd20952b3..bec20e908 100644 --- a/apps/sn/app/(map-layout)/map/components/common/index.ts +++ b/apps/sn/app/(map-layout)/map/components/common/index.ts @@ -20,7 +20,7 @@ type LayerComponentProps = { }; type LayerContentMap = { [key: string]: { - component?: React.FC; + component?: React.ComponentType; props?: any; }; }; @@ -45,7 +45,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 a838764d8..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 - ? "#eef6ff" - : index % 2 === 0 - ? "#ffffff" - : "#f9fbfd"; - 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 da8248a1d..fbbc560e5 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx @@ -214,9 +214,9 @@ export default function PipeAnalysisPopupPage() { exportRow[head.id] = row[head.id]; }); return exportRow; - }); + }); await downloadExcelWithKoreanHeaders({ - data: exportRows as JSON[], + data: exportRows as unknown as JSON[], fileName: `${SUMMARY_FILE_PREFIX[appliedFilters.analysisType]}_${buildPipeAnalysisFileDate()}`, sheetNameArray: ["관로분석 현황"], headerRange, 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 a8671987a..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,51 +56,26 @@ const DEFAULT_SETTING_FILTERS: SettingFilters = { }; const TOOLBAR_INPUT_CLASS_NAME = - "h-[30px] rounded-none border border-[#8f98a3] bg-white px-2 text-[12px] text-[#1f2937] outline-none"; - -const TOOLBAR_BUTTON_CLASS_NAME = - "h-[30px] min-w-[44px] rounded-none border border-[#7c5a20] bg-[#ef8d2f] px-3 text-[12px] font-semibold text-white hover:bg-[#df7f24]"; - -const SECONDARY_BUTTON_CLASS_NAME = - "h-[28px] rounded-none border border-[#8f98a3] bg-white px-3 text-[12px] text-[#334155] hover:bg-[#f8fafc]"; + "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 = "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 = - "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)]"; + "min-h-0 overflow-hidden border border-[#cbd9e6] bg-white"; const SETTING_TABLE_CONTAINER_CLASS_NAME = "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[], @@ -126,25 +100,25 @@ const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") => 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(""); @@ -153,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; @@ -170,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); }; }, []); @@ -236,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) => ({ @@ -276,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; } @@ -318,6 +296,12 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { setNoticeMessage("통수위험 분석 결과를 갱신했습니다."); }; + const handleChangeResultMode = (mode: WaterRiskResultMode) => { + setResultMode(mode); + setResultCurrentPage(1); + setNoticeMessage(""); + }; + const handleOpenSettingView = () => { setActiveView("setting"); setNoticeMessage(""); @@ -336,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); @@ -354,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 = () => { @@ -378,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) => { @@ -388,60 +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" ? (
-
-
-
- - setDraftResultFilters((current) => ({ - ...current, - analysisDate: event.target.value, - })) - } - aria-label="년월일" - className={`${TOOLBAR_INPUT_CLASS_NAME} w-[120px]`} - /> - - setDraftResultFilters((current) => ({ - ...current, - analysisTime: event.target.value, - })) - } - aria-label="시간" - className={`${TOOLBAR_INPUT_CLASS_NAME} w-[100px]`} - /> +
+ + +
+ +
+
+ + + + + + + + +
-
+ + +
+
+

+ {resultMode === "current" + ? "통수위험 분석 결과" + : "통수위험 과거 이력"}{" "} + + {visibleResultRows.length}건 + +

+ + {resultMode === "current" + ? "현재 누적강우량과 향후 6시간 예측강우 기준" + : "강우발생일별 최대강우강도와 최대 침수예상시점 기준"} + +
+
-
+
- - - - - - - - {Array.from({ length: maxPipeCount }).map((_, index) => ( - + + + + + + {resultMode === "current" ? ( + <> + + + + ) : ( + - ))} - - - - {Array.from({ length: maxPipeCount }).map((_, index) => ( - - - - - ))} + )} + + + + {pagedResultRows.map((row, index) => { const isSelected = row.areaId === selectedResultAreaId; + const representativePipe = row.pipeSummaries[0]; const rowNumber = - (resultCurrentPage - 1) * WATER_RISK_PAGE_SIZE + index + 1; + (resultCurrentPage - 1) * WATER_RISK_PAGE_SIZE + + index + + 1; return ( handleSelectResultRow(row)} > - - - - - - {Array.from({ length: maxPipeCount }).map((_, pipeIndex) => { - const pipe = row.pipeSummaries[pipeIndex]; - return ( - - - - - ); - })} - + + + + {resultMode === "current" ? ( + <> + + + + ) : ( + + )} + + + + ); })} + {pagedResultRows.length === 0 ? ( + + + + ) : null}
no시도시군구중점관리지역예측강우량(mm) - 관로 ID {index + 1} +
+ no + + 시도 + + 시군구 + + 중점관리지역 + + 현재누적강우량(mm) + + 예측강우량(mm) + + 최대강우강도(mm/hr) 통수위험
유출유량(㎥/hr)통수량(㎥/hr) + 유출유량(㎥/s) + + 통수량(㎥/s) + + 최대 침수예상시점 + + 통수위험 +
{rowNumber}{row.sidoName}{row.sggName}{row.areaName}{row.forecastRainQty.toFixed(1)} - {pipe ? pipe.runoffFlowQty.toFixed(1) : ""} - - {pipe ? pipe.conveyQty.toFixed(1) : ""} - - + + {rowNumber} + + {row.sidoName} + + {row.sggName} + + {row.areaName} + + {row.currentAccumRainQty.toFixed(1)} + + {row.forecastRainQty.toFixed(1)} + + {row.maxRainIntensity.toFixed(1)} + + {representativePipe?.runoffFlowQty.toFixed(1) ?? + "-"} + + {representativePipe?.conveyQty.toFixed(1) ?? "-"} + + {row.maxFloodingAt} + + {buildWaterRiskStatusLabel(row.riskStatus)}
+ 조회 조건에 해당하는 통수위험 결과가 없습니다. +
@@ -618,6 +733,12 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { />
) : null} +
+ {noticeMessage} +
) : (
@@ -697,25 +818,48 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
- - - - - - - - - + + + + + + + + + {pagedSettingRows.map((row, index) => { const isSelected = - row.areaId === (selectedSettingAreaId || snapshot.selectedSettingAreaId); + row.areaId === + (selectedSettingAreaId || + snapshot.selectedSettingAreaId); return ( handleSelectSettingArea(row.areaId)} > - - - - - - - - + + + + + + + + @@ -795,13 +955,27 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
시도시군구중점관리지역유출관로ID관경연장재질설정순번설정완료여부 + 시도 + + 시군구 + + 중점관리지역 + + 유출관로ID + + 관경 + + 연장 + + 재질 + + 설정순번 + + 설정완료여부 +
{row.sidoName}{row.sggName}{row.areaName}{row.pipeId}{row.pipeDia}{row.pipeLen}{row.pipeMaterial}{row.setSeq} + {row.sidoName} + + {row.sggName} + + {row.areaName} + + {row.pipeId} + + {row.pipeDia} + + {row.pipeLen} + + {row.pipeMaterial} + + {row.setSeq} + {row.setCompleteYn === "Y" ? "완료" : "미완료"}
- - - - - - - + + + + + + + @@ -811,21 +985,31 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { colSpan={7} className="border border-[#dbe4ec] px-3 py-6 text-center text-[#64748b]" > - staged 유출관로가 없습니다. 지도에서 하수관로를 선택하세요. + staged 유출관로가 없습니다. 지도에서 하수관로를 + 선택하세요. ) : ( snapshot.stagedPipes.map((pipe, index) => ( - - - - - - - + + + + + + +
순번유출관로ID관경연장재질선택원천조작 + 순번 + + 유출관로ID + + 관경 + + 연장 + + 재질 + + 선택원천 + + 조작 +
{index + 1}{pipe.pipeId}{pipe.pipeDia}{pipe.pipeLen}{pipe.pipeMaterial}{pipe.source}
+ {index + 1} + + {pipe.pipeId} + + {pipe.pipeDia} + + {pipe.pipeLen} + + {pipe.pipeMaterial} + + {pipe.source} +
@@ -853,7 +1043,9 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) { size="sm" color="white" className="h-[28px] rounded-none border border-[#e7b7b7] bg-white px-3 text-[12px] text-[#c62828] hover:bg-[#fff8f8]" - onClick={() => handleRemoveStagedPipe(pipe.pipeId)} + onClick={() => + handleRemoveStagedPipe(pipe.pipeId) + } > 삭제 diff --git a/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts b/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts index ad94da2e3..adeac297a 100644 --- a/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts +++ b/apps/sn/app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts @@ -15,7 +15,11 @@ export type WaterRiskResultRow = { areaName: string; sidoName: string; sggName: string; + // 화면설계서 v1.12 결과 그리드용 표시값이며 실제 값은 후속 BE API 응답으로 치환한다. + currentAccumRainQty: number; forecastRainQty: number; + maxRainIntensity: number; + maxFloodingAt: string; riskStatus: WaterRiskStatus; calculationMethod: WaterRiskCalculationMethod; pipeSummaries: WaterRiskPipeSummary[]; @@ -88,11 +92,11 @@ const buildRectanglePolygon = ( halfHeight: number, ) => { return [[ - [centerX - halfWidth, centerY - halfHeight], - [centerX + halfWidth, centerY - halfHeight], - [centerX + halfWidth, centerY + halfHeight], - [centerX - halfWidth, centerY + halfHeight], - [centerX - halfWidth, centerY - halfHeight], + [centerX - halfWidth, centerY - halfHeight], + [centerX + halfWidth, centerY - halfHeight], + [centerX + halfWidth, centerY + halfHeight], + [centerX - halfWidth, centerY + halfHeight], + [centerX - halfWidth, centerY - halfHeight], ]]; }; @@ -112,12 +116,12 @@ const buildGridCells = ( return offsets.map(([offsetX, offsetY], index) => { const rainQty = values[index] ?? values[values.length - 1] ?? 0; const colorLevel = rainQty >= 40 - ? "LEVEL_4" - : rainQty >= 30 - ? "LEVEL_3" - : rainQty >= 20 - ? "LEVEL_2" - : "LEVEL_1"; + ? "LEVEL_4" + : rainQty >= 30 + ? "LEVEL_3" + : rainQty >= 20 + ? "LEVEL_2" + : "LEVEL_1"; return { gridId: `GRID-${index + 1}`, @@ -139,7 +143,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "A 중점관리지역", sidoName: "서울특별시", sggName: "강남구", + currentAccumRainQty: 18.6, forecastRainQty: 42.0, + maxRainIntensity: 50.2, + maxFloodingAt: "2026-05-18 14:00", riskStatus: "DANGER", calculationMethod: "METHOD_1", pipeSummaries: [ @@ -152,7 +159,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "B 중점관리지역", sidoName: "서울특별시", sggName: "강남구", + currentAccumRainQty: 12.4, forecastRainQty: 31.4, + maxRainIntensity: 42.1, + maxFloodingAt: "2026-05-18 13:00", riskStatus: "NORMAL", calculationMethod: "METHOD_1", pipeSummaries: [ @@ -164,7 +174,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "C 중점관리지역", sidoName: "부산광역시", sggName: "해운대구", + currentAccumRainQty: 15.2, forecastRainQty: 37.8, + maxRainIntensity: 48.7, + maxFloodingAt: "2026-05-18 14:00", riskStatus: "DANGER", calculationMethod: "METHOD_2", pipeSummaries: [ @@ -178,7 +191,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "D 중점관리지역", sidoName: "부산광역시", sggName: "해운대구", + currentAccumRainQty: 8.9, forecastRainQty: 24.3, + maxRainIntensity: 35.4, + maxFloodingAt: "2026-05-18 12:00", riskStatus: "NORMAL", calculationMethod: "METHOD_2", pipeSummaries: [ @@ -191,7 +207,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "E 중점관리지역", sidoName: "서울특별시", sggName: "강남구", + currentAccumRainQty: 10.7, forecastRainQty: 28.6, + maxRainIntensity: 39.8, + maxFloodingAt: "2026-05-18 13:00", riskStatus: "NORMAL", calculationMethod: "METHOD_1", pipeSummaries: [ @@ -203,7 +222,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "F 중점관리지역", sidoName: "서울특별시", sggName: "강남구", + currentAccumRainQty: 19.8, forecastRainQty: 45.1, + maxRainIntensity: 54.2, + maxFloodingAt: "2026-05-18 15:00", riskStatus: "DANGER", calculationMethod: "METHOD_2", pipeSummaries: [ @@ -216,7 +238,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "G 중점관리지역", sidoName: "부산광역시", sggName: "해운대구", + currentAccumRainQty: 13.5, forecastRainQty: 33.2, + maxRainIntensity: 44.6, + maxFloodingAt: "2026-05-18 14:00", riskStatus: "NORMAL", calculationMethod: "METHOD_1", pipeSummaries: [ @@ -228,7 +253,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "H 중점관리지역", sidoName: "부산광역시", sggName: "해운대구", + currentAccumRainQty: 16.4, forecastRainQty: 39.4, + maxRainIntensity: 51.3, + maxFloodingAt: "2026-05-18 15:00", riskStatus: "DANGER", calculationMethod: "METHOD_2", pipeSummaries: [ @@ -242,7 +270,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "I 중점관리지역", sidoName: "서울특별시", sggName: "강남구", + currentAccumRainQty: 9.6, forecastRainQty: 26.1, + maxRainIntensity: 37.2, + maxFloodingAt: "2026-05-18 12:00", riskStatus: "NORMAL", calculationMethod: "METHOD_1", pipeSummaries: [ @@ -254,7 +285,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "J 중점관리지역", sidoName: "부산광역시", sggName: "해운대구", + currentAccumRainQty: 17.1, forecastRainQty: 41.8, + maxRainIntensity: 52.7, + maxFloodingAt: "2026-05-18 15:00", riskStatus: "DANGER", calculationMethod: "METHOD_2", pipeSummaries: [ @@ -267,7 +301,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "K 중점관리지역", sidoName: "서울특별시", sggName: "강남구", + currentAccumRainQty: 11.2, forecastRainQty: 29.7, + maxRainIntensity: 40.1, + maxFloodingAt: "2026-05-18 13:00", riskStatus: "NORMAL", calculationMethod: "METHOD_1", pipeSummaries: [ @@ -280,7 +317,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "L 중점관리지역", sidoName: "부산광역시", sggName: "해운대구", + currentAccumRainQty: 18.9, forecastRainQty: 43.6, + maxRainIntensity: 53.8, + maxFloodingAt: "2026-05-18 15:00", riskStatus: "DANGER", calculationMethod: "METHOD_2", pipeSummaries: [ @@ -292,7 +332,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "M 중점관리지역", sidoName: "서울특별시", sggName: "강남구", + currentAccumRainQty: 14.3, forecastRainQty: 35.2, + maxRainIntensity: 46.5, + maxFloodingAt: "2026-05-18 14:00", riskStatus: "DANGER", calculationMethod: "METHOD_1", pipeSummaries: [ @@ -306,7 +349,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "N 중점관리지역", sidoName: "부산광역시", sggName: "해운대구", + currentAccumRainQty: 7.6, forecastRainQty: 21.9, + maxRainIntensity: 32.8, + maxFloodingAt: "2026-05-18 11:00", riskStatus: "NORMAL", calculationMethod: "METHOD_2", pipeSummaries: [ @@ -318,7 +364,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "O 중점관리지역", sidoName: "서울특별시", sggName: "강남구", + currentAccumRainQty: 20.4, forecastRainQty: 47.3, + maxRainIntensity: 56.9, + maxFloodingAt: "2026-05-18 16:00", riskStatus: "DANGER", calculationMethod: "METHOD_2", pipeSummaries: [ @@ -331,7 +380,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "P 중점관리지역", sidoName: "부산광역시", sggName: "해운대구", + currentAccumRainQty: 10.1, forecastRainQty: 27.5, + maxRainIntensity: 38.6, + maxFloodingAt: "2026-05-18 13:00", riskStatus: "NORMAL", calculationMethod: "METHOD_1", pipeSummaries: [ @@ -344,7 +396,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "Q 중점관리지역", sidoName: "서울특별시", sggName: "강남구", + currentAccumRainQty: 12.8, forecastRainQty: 32.8, + maxRainIntensity: 43.7, + maxFloodingAt: "2026-05-18 14:00", riskStatus: "NORMAL", calculationMethod: "METHOD_1", pipeSummaries: [ @@ -356,7 +411,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "R 중점관리지역", sidoName: "부산광역시", sggName: "해운대구", + currentAccumRainQty: 18.2, forecastRainQty: 44.9, + maxRainIntensity: 55.1, + maxFloodingAt: "2026-05-18 16:00", riskStatus: "DANGER", calculationMethod: "METHOD_2", pipeSummaries: [ @@ -370,7 +428,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "S 중점관리지역", sidoName: "서울특별시", sggName: "강남구", + currentAccumRainQty: 8.4, forecastRainQty: 23.4, + maxRainIntensity: 34.9, + maxFloodingAt: "2026-05-18 12:00", riskStatus: "NORMAL", calculationMethod: "METHOD_1", pipeSummaries: [ @@ -382,7 +443,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [ areaName: "T 중점관리지역", sidoName: "부산광역시", sggName: "해운대구", + currentAccumRainQty: 16.7, forecastRainQty: 40.7, + maxRainIntensity: 51.9, + maxFloodingAt: "2026-05-18 15:00", riskStatus: "DANGER", calculationMethod: "METHOD_2", pipeSummaries: [ diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 67a3f3497..143de7288 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -1865,6 +1865,34 @@ const Map = () => { } await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids); + return; + } + + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_UPLOAD_COMPLETED) { + const rawLayerTypeNames = data.payload?.refreshLayerTypeNames; + let layerTypeNames = ["tb_ms_swage_ppl"]; + if (rawLayerTypeNames) { + try { + const parsed = JSON.parse(rawLayerTypeNames); + if (Array.isArray(parsed) && parsed.length > 0) { + layerTypeNames = parsed.map((item) => String(item ?? "").trim()).filter(Boolean); + } + } catch (error) { + console.error("GIS 업로드 레이어 refresh 메시지 파싱 실패:", error); + } + } + + const refreshTargetNames = new Set(layerTypeNames); + gisMaps.current?.getAllLayers?.().forEach((layer: any) => { + const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim(); + if (!refreshTargetNames.has(layerTypeName)) return; + + const source = layer.getSource?.(); + source?.updateParams?.({ _t: Date.now() }); + source?.refresh?.(); + source?.changed?.(); + layer.changed?.(); + }); } }; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingLocationMap.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingLocationMap.tsx new file mode 100644 index 000000000..595031f0f --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingLocationMap.tsx @@ -0,0 +1,223 @@ +"use client"; + +import Feature from "ol/Feature.js"; +import Point from "ol/geom/Point.js"; +import type OlMap from "ol/Map.js"; +import { unByKey } from "ol/Observable.js"; +import { transform } from "ol/proj.js"; +import VectorLayer from "ol/layer/Vector.js"; +import VectorSource from "ol/source/Vector.js"; +import CircleStyle from "ol/style/Circle.js"; +import Fill from "ol/style/Fill.js"; +import Stroke from "ol/style/Stroke.js"; +import Style from "ol/style/Style.js"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { Button, Input } from "@common/components/ui"; +import Gis, { useGisContext } from "@common/business_components/ui/map/Gis"; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; + +import type { GeoPoint } from "../_lib/types"; + +type DredgingLocationMapProps = { + value: GeoPoint | null; + onChange?: (point: GeoPoint) => void; + interactive?: boolean; +}; + +type GisContextValue = { + gisMap: OlMap | null; +}; + +const markerStyle = new Style({ + image: new CircleStyle({ + radius: 9, + fill: new Fill({ color: "rgba(220, 38, 38, 0.9)" }), + stroke: new Stroke({ color: "#ffffff", width: 3 }), + }), +}); + +/** + * 공통 Gis 내부 지도 객체에 클릭 interaction과 위치 marker만 추가한다. + * 공통 지도 코드는 수정하지 않고 이 컴포넌트가 추가한 layer/event만 정리한다. + */ +const LocationInteraction = ({ + value, + onChange, + interactive, +}: Required> & + Omit) => { + const { gisMap } = useGisContext() as GisContextValue; + const layerRef = useRef | null>(null); + const [coordinateInput, setCoordinateInput] = useState(""); + + useEffect(() => { + if (!value) return; + setCoordinateInput( + `${value.latitude.toFixed(7)}, ${value.longitude.toFixed(7)}`, + ); + }, [value]); + + useEffect(() => { + if (!gisMap) return; + + const source = new VectorSource(); + const layer = new VectorLayer({ source, style: markerStyle, zIndex: 999 }); + layer.set("dredgingLocationLayer", true); + gisMap.addLayer(layer); + layerRef.current = layer; + + const clickKey = interactive + ? gisMap.on("singleclick", (event) => { + const [longitude, latitude] = transform( + event.coordinate, + GisApp.TO_PROJ, + GisApp.FROM_PROJ, + ); + if ( + longitude === undefined || + latitude === undefined || + !Number.isFinite(longitude) || + !Number.isFinite(latitude) + ) { + return; + } + onChange?.({ longitude, latitude }); + }) + : null; + + return () => { + if (clickKey) unByKey(clickKey); + gisMap.removeLayer(layer); + layerRef.current = null; + }; + }, [gisMap, interactive, onChange]); + + useEffect(() => { + if (!gisMap || !layerRef.current) return; + const source = layerRef.current.getSource(); + source?.clear(); + if (!value) return; + + const coordinate = transform( + [value.longitude, value.latitude], + GisApp.FROM_PROJ, + GisApp.TO_PROJ, + ); + source?.addFeature(new Feature({ geometry: new Point(coordinate) })); + gisMap.getView().animate({ center: coordinate, zoom: 17, duration: 250 }); + }, [gisMap, value]); + + const applyCoordinateInput = () => { + const [latitudeText, longitudeText] = coordinateInput + .split(",") + .map((item) => item.trim()); + const latitude = Number(latitudeText); + const longitude = Number(longitudeText); + if ( + !Number.isFinite(latitude) || + !Number.isFinite(longitude) || + latitude < -90 || + latitude > 90 || + longitude < -180 || + longitude > 180 + ) { + alert("좌표는 '위도, 경도' 형식으로 입력해주세요."); + return; + } + onChange?.({ latitude, longitude }); + }; + + return ( +
+ {interactive ? ( + <> + setCoordinateInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + applyCoordinateInput(); + } + }} + placeholder="위도, 경도 입력 또는 지도 클릭" + className="!text-base" + /> + + + ) : ( + + 등록 위치와 자동 매핑 관로를 확인합니다. + + )} +
+ ); +}; + +const DredgingLocationMap = ({ + value, + onChange, + interactive = true, +}: DredgingLocationMapProps) => { + const gisMaps = useRef(null); + const defaultLonLat = useMemo<[number, number]>( + () => (value ? [value.longitude, value.latitude] : [127.75, 36]), + [value], + ); + + return ( +
+ undefined} + featureData={null} + setFeatureData={() => undefined} + isSearchModalOpen={false} + setIsSearchModalOpen={() => undefined} + searchBar={ + + } + defaultZoom={value ? 17 : 7.5} + defaultLonLat={defaultLonLat} + height="h-[420px]" + showZoomLevel={true} + showFloatingSearchButton={false} + treeTogglePosition="top" + /> +
+ + {interactive + ? "지도 클릭 또는 위도·경도 입력으로 준설위치를 지정하세요." + : "등록된 준설위치"} + + + {value + ? `${value.latitude.toFixed(7)}, ${value.longitude.toFixed(7)}` + : "위치 미선택"} + +
+
+ ); +}; + +export default DredgingLocationMap; 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 new file mode 100644 index 000000000..ec57f9c79 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterModal.tsx @@ -0,0 +1,805 @@ +"use client"; + +import { FileUpload, UiModal } from "@common/business_components/ui"; +import { Button, Input, Label } from "@common/components/ui"; +import { useEffect, useRef, useState } from "react"; + +import { + findNearestPipeMatches, + isDredgingBackendEnabled, + saveDredgingRegistrations, + uploadDredgingPhotos, +} from "../_lib/client"; +import { readPhotoMetadata } from "../_lib/exif"; +import { + EMPTY_PHOTO_METADATA, + type DredgingDraftGroup, + type DredgingPhotoDraft, + type DredgingSaveGroup, + type GeoPoint, +} from "../_lib/types"; +import DredgingLocationMap from "./DredgingLocationMap"; + +type DredgingRegisterModalProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved: (groups: DredgingSaveGroup[]) => void; +}; + +const createGroup = (): DredgingDraftGroup => ({ + id: crypto.randomUUID(), + registrationMode: "PHOTO", + photos: [], + manualLocation: null, + manualDate: "", + matchedPipe: null, + matchError: null, + isMatching: false, + lowConfidenceConfirmed: false, +}); + +const getValidPhotoDates = (group: DredgingDraftGroup) => + Array.from( + new Set( + group.photos + .map((photo) => photo.metadata.dredgingDate) + .filter((date): date is string => Boolean(date)), + ), + ); + +const needsManualDate = (group: DredgingDraftGroup) => + group.registrationMode === "MANUAL" || + group.photos.some((photo) => !photo.metadata.dredgingDate); + +const needsManualLocation = (group: DredgingDraftGroup) => + group.registrationMode === "MANUAL" || + group.photos.some((photo) => !photo.metadata.location); + +const getResolvedDate = (group: DredgingDraftGroup) => { + if (group.registrationMode === "MANUAL") return group.manualDate || null; + + const dates = getValidPhotoDates(group); + if (needsManualDate(group)) { + if (!group.manualDate) return null; + dates.push(group.manualDate); + } + + const uniqueDates = Array.from(new Set(dates)); + return uniqueDates.length === 1 ? (uniqueDates[0] ?? null) : null; +}; + +const getResolvedLocation = (group: DredgingDraftGroup): GeoPoint | null => { + if (group.manualLocation) return group.manualLocation; + return ( + group.photos.find((photo) => photo.metadata.location)?.metadata.location ?? + 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 confidenceLabel = { + HIGH: "높음", + MEDIUM: "보통", + LOW: "낮음", +} as const; + +const formatLocalDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +const DredgingRegisterModal = ({ + open, + onOpenChange, + onSaved, +}: DredgingRegisterModalProps) => { + const [groups, setGroups] = useState([]); + const groupsRef = useRef([]); + const [editingLocationGroupId, setEditingLocationGroupId] = useState< + string | null + >(null); + const [isSaving, setIsSaving] = useState(false); + const [statusMessage, setStatusMessage] = useState(""); + + const commitGroups = ( + updater: (current: DredgingDraftGroup[]) => DredgingDraftGroup[], + ) => { + const next = updater(groupsRef.current); + groupsRef.current = next; + setGroups(next); + }; + + useEffect(() => { + if (!open) return; + const initial = [createGroup()]; + groupsRef.current = initial; + setGroups(initial); + setEditingLocationGroupId(null); + setStatusMessage(""); + }, [open]); + + /** + * 그룹의 모든 사진 위치 또는 수동 좌표를 서버 후보 조회 형식으로 만들고, + * 결과가 하나의 관로로 수렴하는지 확인한다. + */ + const resolvePipeMatch = async (group: DredgingDraftGroup) => { + const locations = + group.manualLocation !== null + ? [ + { + locationKey: `${group.id}-manual`, + ...group.manualLocation, + }, + ] + : group.photos + .filter((photo) => photo.metadata.location) + .map((photo) => ({ + locationKey: photo.key, + ...photo.metadata.location!, + })); + + const expectedLocationCount = + group.manualLocation !== null + ? 1 + : group.registrationMode === "MANUAL" + ? 0 + : group.photos.length; + + if ( + locations.length === 0 || + (group.registrationMode === "PHOTO" && + locations.length !== expectedLocationCount) + ) { + commitGroups((current) => + current.map((item) => + item.id === group.id + ? { + ...item, + matchedPipe: null, + matchError: "지도에서 준설위치를 먼저 등록해주세요.", + isMatching: false, + } + : item, + ), + ); + return; + } + + commitGroups((current) => + current.map((item) => + item.id === group.id + ? { ...item, isMatching: true, matchError: null } + : item, + ), + ); + + try { + const matches = await findNearestPipeMatches(locations); + if (matches.size !== locations.length) { + throw new Error("탐색거리 안에서 관로를 찾지 못한 위치가 있습니다."); + } + + const matchedPipes = Array.from(matches.values()); + const pipeKeys = new Set( + matchedPipes.map( + (pipe) => `${pipe.mngInstCd.trim()}::${pipe.fcltMngNo}`, + ), + ); + if (pipeKeys.size !== 1) { + throw new Error( + "사진 위치가 서로 다른 관로로 매핑됩니다. 관로 그룹을 분리해주세요.", + ); + } + + const matchedPipe = matchedPipes[0] ?? null; + commitGroups((current) => + current.map((item) => + item.id === group.id + ? { + ...item, + matchedPipe, + matchError: matchedPipe + ? null + : "최근접 관로를 찾지 못했습니다.", + isMatching: false, + lowConfidenceConfirmed: false, + } + : item, + ), + ); + } catch (error) { + commitGroups((current) => + current.map((item) => + item.id === group.id + ? { + ...item, + matchedPipe: null, + matchError: + error instanceof Error + ? error.message + : "최근접 관로 조회에 실패했습니다.", + isMatching: false, + } + : item, + ), + ); + } + }; + + const handleFilesChange = async ( + groupId: string, + selectedFiles: File[] | null, + ) => { + const files = selectedFiles ?? []; + if (files.length > 5) { + alert("관로별 사진은 최대 5장까지 선택할 수 있습니다."); + return; + } + + const checkingPhotos: DredgingPhotoDraft[] = files.map((file) => ({ + key: crypto.randomUUID(), + file, + metadata: EMPTY_PHOTO_METADATA, + })); + + commitGroups((current) => + current.map((group) => + group.id === groupId + ? { + ...group, + photos: checkingPhotos, + manualLocation: null, + manualDate: "", + matchedPipe: null, + matchError: null, + isMatching: false, + lowConfidenceConfirmed: false, + } + : group, + ), + ); + + const parsedPhotos = await Promise.all( + checkingPhotos.map(async (photo) => ({ + ...photo, + metadata: await readPhotoMetadata(photo.file), + })), + ); + + let nextGroup: DredgingDraftGroup | null = null; + commitGroups((current) => + current.map((group) => { + if (group.id !== groupId) return group; + nextGroup = { ...group, photos: parsedPhotos }; + return nextGroup; + }), + ); + + if (nextGroup) { + const groupToResolve: DredgingDraftGroup = nextGroup; + if (!needsManualLocation(groupToResolve)) { + await resolvePipeMatch(groupToResolve); + } else { + commitGroups((current) => + current.map((group) => + group.id === groupId + ? { + ...group, + matchError: "GPS가 없는 사진은 지도 위치 등록이 필요합니다.", + } + : group, + ), + ); + } + } + }; + + const changeRegistrationMode = ( + groupId: string, + registrationMode: DredgingDraftGroup["registrationMode"], + ) => { + commitGroups((current) => + current.map((group) => + group.id === groupId + ? { + ...group, + registrationMode, + photos: registrationMode === "MANUAL" ? [] : group.photos, + matchedPipe: null, + matchError: null, + manualLocation: null, + manualDate: "", + lowConfidenceConfirmed: false, + } + : group, + ), + ); + if (registrationMode === "MANUAL") { + setEditingLocationGroupId(groupId); + } + }; + + const updateManualDate = (groupId: string, manualDate: string) => { + commitGroups((current) => + current.map((group) => + group.id === groupId ? { ...group, manualDate } : group, + ), + ); + }; + + const updateManualLocation = async ( + groupId: string, + manualLocation: GeoPoint, + ) => { + const currentGroup = groupsRef.current.find( + (group) => group.id === groupId, + ); + if (!currentGroup) return; + + const nextGroup = { + ...currentGroup, + manualLocation, + matchedPipe: null, + matchError: null, + lowConfidenceConfirmed: false, + }; + commitGroups((current) => + current.map((group) => (group.id === groupId ? nextGroup : group)), + ); + await resolvePipeMatch(nextGroup); + }; + + const validateAndBuildSaveGroups = () => { + const saveGroups: DredgingSaveGroup[] = []; + for (let index = 0; index < groups.length; index += 1) { + const group = groups[index]!; + const label = `관로 ${index + 1}`; + if (group.registrationMode === "PHOTO" && group.photos.length === 0) { + throw new Error(`${label}: 사진을 선택해주세요.`); + } + if (group.photos.length > 5) { + throw new Error(`${label}: 사진은 최대 5장까지 등록할 수 있습니다.`); + } + + const validDates = getValidPhotoDates(group); + if (validDates.length > 1) { + throw new Error( + `${label}: 촬영일자가 서로 다릅니다. 날짜별로 관로 그룹을 분리해주세요.`, + ); + } + + const dredgingDate = getResolvedDate(group); + if (!dredgingDate) { + throw new Error(`${label}: 준설일자를 확인해주세요.`); + } + if (dredgingDate > formatLocalDate(new Date())) { + throw new Error(`${label}: 미래 날짜는 준설일자로 등록할 수 없습니다.`); + } + + const location = getResolvedLocation(group); + if (!location) { + throw new Error(`${label}: 지도에서 준설위치를 등록해주세요.`); + } + if (!group.matchedPipe) { + throw new Error(`${label}: 최근접 관로 자동 매핑을 완료해주세요.`); + } + if ( + group.matchedPipe.confidence === "LOW" && + !group.lowConfidenceConfirmed + ) { + throw new Error(`${label}: 낮은 신뢰도 관로 후보를 확인해주세요.`); + } + + saveGroups.push({ + localId: group.id, + registrationMode: group.registrationMode, + dredgingDate, + location, + locationSource: group.manualLocation ? "MANUAL" : "EXIF", + pipe: group.matchedPipe, + photos: group.photos, + lowConfidenceConfirmed: group.lowConfidenceConfirmed, + }); + } + return saveGroups; + }; + + const handleSave = async () => { + try { + const saveGroups = validateAndBuildSaveGroups(); + setIsSaving(true); + setStatusMessage( + isDredgingBackendEnabled + ? "공통 첨부파일 업로드를 시작합니다." + : "등록 내용을 확인하고 있습니다.", + ); + + const attachments = await uploadDredgingPhotos(saveGroups); + setStatusMessage( + isDredgingBackendEnabled + ? "준설 결과와 관로 매핑을 저장하고 있습니다." + : "준설 결과 등록을 완료했습니다.", + ); + await saveDredgingRegistrations(saveGroups, attachments); + onSaved(saveGroups); + alert("준설 결과를 등록했습니다."); + onOpenChange(false); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "준설 결과 저장에 실패했습니다."; + setStatusMessage(message); + alert(message); + } finally { + setIsSaving(false); + } + }; + + const editingGroup = + groups.find((group) => group.id === editingLocationGroupId) ?? null; + + const modalBody = ( +
+
+ 준설결과 등록 시 주의사항 +
    +
  1. 관로별 사진은 최대 5장까지 등록할 수 있습니다.
  2. +
  3. 사진의 촬영일자가 준설일자가 됩니다.
  4. +
  5. GPS 또는 촬영일자가 없으면 지도 위치와 준설일자를 보완합니다.
  6. +
  7. 한 그룹의 사진은 같은 관로와 같은 준설일자여야 합니다.
  8. +
+
+ +
+ {groups.map((group, groupIndex) => { + const resolvedDate = getResolvedDate(group); + const resolvedLocation = getResolvedLocation(group); + const manualDateRequired = needsManualDate(group); + const manualLocationRequired = needsManualLocation(group); + + return ( +
+
+
+

+ 관로 {groupIndex + 1} +

+

+ 사진 묶음 또는 수동 위치 한 건이 하나의 관로·준설일자로 + 저장됩니다. +

+
+
+ + + {groups.length > 1 ? ( + + ) : null} +
+
+ + {group.registrationMode === "PHOTO" ? ( + <> + photo.file)} + setFiles={(files) => { + void handleFilesChange(group.id, files); + }} + fileTypes=".jpg,.jpeg,.png" + maxFiles={5} + /> + + {group.photos.length > 0 ? ( +
+ + + + + + + + + + + + {group.photos.map((photo) => ( + + + + + + + + ))} + +
사진촬영일시준설일자GPS상태
+ {photo.file.name} + + {photo.metadata.capturedAt ?? "-"} + + {photo.metadata.dredgingDate ?? + "수동 입력 필요"} + + {photo.metadata.location + ? `${photo.metadata.location.latitude.toFixed(6)}, ${photo.metadata.location.longitude.toFixed(6)}` + : "수동 입력 필요"} + + + {statusLabel(photo.metadata.status)} + +
+
+ ) : null} + + ) : ( +
+ +

+ 사진 없이 지도에서 준설위치를 지정합니다. +

+
+ )} + +
+
+
+ + {manualDateRequired ? ( + + updateManualDate(group.id, event.target.value) + } + className="w-[180px]" + /> + ) : ( + {resolvedDate ?? "-"} + )} +
+
+ + + {resolvedLocation + ? `${resolvedLocation.latitude.toFixed(6)}, ${resolvedLocation.longitude.toFixed(6)}` + : "지도 등록 필요"} + +
+ +
+ +
+
+ + {group.isMatching ? ( + 탐색 중... + ) : null} +
+ {group.matchedPipe ? ( +
+
관로번호
+
+ {group.matchedPipe.fcltMngNo} +
+
관리기관
+
{group.matchedPipe.mngInstCd}
+
최근접 거리
+
{group.matchedPipe.distanceMeters.toFixed(2)}m
+
신뢰도
+
{confidenceLabel[group.matchedPipe.confidence]}
+
+ ) : ( +

+ 사진 GPS 또는 수동 위치가 입력되면 최근접 관로를 자동으로 + 찾습니다. +

+ )} + {group.matchError ? ( +

+ {group.matchError} +

+ ) : null} + {group.matchedPipe?.confidence === "LOW" ? ( + + ) : null} +
+
+
+ ); + })} +
+ + + + {editingGroup ? ( +
+
+
+

준설위치 등록

+

+ 지도 클릭 후 최근접 관로가 자동으로 갱신됩니다. +

+
+ +
+ photo.metadata.location) + ?.metadata.location ?? + null + } + onChange={(point) => { + void updateManualLocation(editingGroup.id, point); + }} + /> +
+ ) : null} + +
+ {statusMessage} +
+
+ ); + + return ( + { + if (!isSaving) onOpenChange(nextOpen); + }} + bodyClass="!max-w-[1200px] w-[92vw]" + headerTitle="준설결과 업로드" + headerTitleClass="text-2xl font-semibold text-slate-900" + body={modalBody} + footerContent={ +
+ + +
+ } + /> + ); +}; + +export default DredgingRegisterModal; 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 new file mode 100644 index 000000000..08b91b8f4 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/dredging/_lib/client.ts @@ -0,0 +1,202 @@ +import { postRawJson } from "@/components/lib/api"; +import { handleUpload } from "@/components/lib/utils"; +import type { HttpResponse } from "tus-js-client"; + +import type { + DredgingSaveGroup, + GeoPoint, + MatchConfidence, + PipeMatch, +} from "./types"; + +type LocationRequest = GeoPoint & { + locationKey: string; +}; + +type AttachmentReference = { + atflCd: string; + atflId: string; +}; + +type NearestPipeResponseItem = { + locationKey: string; + selected?: { + fid: string; + mngInstCd: string; + fcltMngNo: string; + pipeDiameterMm?: number; + pipeLengthM?: number; + pipeMaterial?: string; + installationYear?: number | null; + distanceMeters: number; + snappedLongitude: number; + snappedLatitude: number; + confidence: MatchConfidence; + } | null; +}; + +export const isDredgingBackendEnabled = + process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED === "true"; + +const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => { + const first = locations[0]; + const longitude = first?.longitude ?? 127.5; + const latitude = first?.latitude ?? 36.5; + const seed = Math.abs( + Math.round(longitude * 100_000) + Math.round(latitude * 100_000), + ); + const distanceMeters = Number(((seed % 850) / 100 + 1.2).toFixed(2)); + + return { + fid: `MOCK-${String(seed % 1_000_000).padStart(6, "0")}`, + mngInstCd: "4173000", + fcltMngNo: `PPL-${String(seed % 1_000_000).padStart(6, "0")}`, + pipeDiameterMm: [450, 600, 700, 900][seed % 4] ?? 450, + pipeLengthM: Number((((seed % 8_000) + 2_000) / 100).toFixed(2)), + pipeMaterial: ["콘크리트", "PVC", "PE"][seed % 3] ?? "콘크리트", + installationYear: 1995 + (seed % 25), + distanceMeters, + snappedLongitude: longitude + 0.00001, + snappedLatitude: latitude - 0.00001, + confidence: distanceMeters <= 10 ? "HIGH" : "MEDIUM", + isMock: true, + }; +}; + +/** + * 위치 목록을 한 번에 보내고 사진별 최근접 관로를 받는다. + * FE 선행 모드에서는 같은 그룹의 상태 전이를 검증할 수 있는 명시적 모의 관로를 반환한다. + */ +export const findNearestPipeMatches = async ( + locations: LocationRequest[], +): Promise> => { + if (locations.length === 0) return new Map(); + + if (!isDredgingBackendEnabled) { + const mockPipe = mockPipeForLocations(locations); + return new Map( + locations.map((location) => [location.locationKey, mockPipe]), + ); + } + + const body = await postRawJson("v1/dredging/nearestPipes", { locations }); + const items = (body?.data ?? body) as NearestPipeResponseItem[]; + const matches = new Map(); + + for (const item of items ?? []) { + if (!item.selected) continue; + matches.set(item.locationKey, { + fid: item.selected.fid, + mngInstCd: item.selected.mngInstCd, + fcltMngNo: item.selected.fcltMngNo, + pipeDiameterMm: item.selected.pipeDiameterMm ?? 0, + pipeLengthM: item.selected.pipeLengthM ?? 0, + pipeMaterial: item.selected.pipeMaterial ?? "", + installationYear: item.selected.installationYear ?? null, + distanceMeters: item.selected.distanceMeters, + snappedLongitude: item.selected.snappedLongitude, + snappedLatitude: item.selected.snappedLatitude, + confidence: item.selected.confidence, + isMock: false, + }); + } + + return matches; +}; + +const uploadOnePhoto = (file: File): Promise => + new Promise((resolve, reject) => { + const transfer = new DataTransfer(); + transfer.items.add(file); + + const timeoutId = window.setTimeout(() => { + reject(new Error(`${file.name} 첨부 업로드 응답 시간이 초과되었습니다.`)); + }, 90_000); + + const handleResponse = (response: HttpResponse) => { + const fileInfo = response.getHeader("X-File-Info"); + if (!fileInfo) return; + + try { + const parsed = JSON.parse(fileInfo) as Partial; + if (!parsed.atflCd || !parsed.atflId) { + throw new Error("첨부 식별자가 응답에 없습니다."); + } + window.clearTimeout(timeoutId); + resolve({ atflCd: parsed.atflCd, atflId: parsed.atflId }); + } catch (error) { + window.clearTimeout(timeoutId); + reject(error); + } + }; + + try { + handleUpload(transfer.files, { handleResponse }); + } catch (error) { + window.clearTimeout(timeoutId); + reject(error); + } + }); + +/** + * 공통 attach-service TUS 업로드를 순차 실행한다. + * 등록 API가 아직 없는 FE 선행 모드에서는 고아 첨부 생성을 막기 위해 전송하지 않는다. + */ +export const uploadDredgingPhotos = async (groups: DredgingSaveGroup[]) => { + const references = new Map(); + if (!isDredgingBackendEnabled) return references; + + for (const group of groups) { + for (const photo of group.photos) { + references.set(photo.key, await uploadOnePhoto(photo.file)); + } + } + return references; +}; + +export const saveDredgingRegistrations = async ( + groups: DredgingSaveGroup[], + attachments: Map, +) => { + if (!isDredgingBackendEnabled) { + return { + mock: true, + registeredCount: groups.length, + clientRequestId: crypto.randomUUID(), + }; + } + + const clientRequestId = crypto.randomUUID(); + return postRawJson("v1/dredging/registrations", { + clientRequestId, + groups: groups.map((group, groupIndex) => ({ + groupSn: groupIndex + 1, + registrationMode: group.registrationMode, + dredgingDate: group.dredgingDate, + location: { + source: group.locationSource, + longitude: group.location.longitude, + latitude: group.location.latitude, + }, + expectedPipe: { + fid: group.pipe.fid, + mngInstCd: group.pipe.mngInstCd, + fcltMngNo: group.pipe.fcltMngNo, + }, + lowConfidenceConfirmed: group.lowConfidenceConfirmed, + photos: group.photos.map((photo) => { + const attachment = attachments.get(photo.key); + if (!attachment) { + throw new Error(`${photo.file.name}의 첨부 식별자가 없습니다.`); + } + return { + ...attachment, + originalFileName: photo.file.name, + clientCapturedAt: photo.metadata.capturedAt, + clientLongitude: photo.metadata.location?.longitude ?? null, + clientLatitude: photo.metadata.location?.latitude ?? null, + }; + }), + })), + }); +}; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/_lib/exif.ts b/apps/sn/app/(with-base-layout)/oper/dredging/_lib/exif.ts new file mode 100644 index 000000000..1b8a18476 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/dredging/_lib/exif.ts @@ -0,0 +1,334 @@ +import type { ExifStatus, GeoPoint, PhotoMetadata } from "./types"; + +type ByteOrder = "LE" | "BE"; + +type IfdEntry = { + type: number; + count: number; + valueOffset: number; + entryOffset: number; +}; + +const JPEG_START = 0xffd8; +const APP1_MARKER = 0xe1; +const EXIF_IFD_POINTER = 0x8769; +const GPS_IFD_POINTER = 0x8825; +const DATE_TIME = 0x0132; +const DATE_TIME_ORIGINAL = 0x9003; +const DATE_TIME_DIGITIZED = 0x9004; + +const typeByteLength = (type: number) => { + if (type === 1 || type === 2 || type === 7) return 1; + if (type === 3) return 2; + if (type === 4 || type === 9) return 4; + if (type === 5 || type === 10) return 8; + return 0; +}; + +/** + * JPEG EXIF TIFF 블록을 읽는 범위 제한 reader이다. + * 손상된 offset이 파일 범위를 벗어나면 예외를 발생시켜 수동 입력 흐름으로 전환한다. + */ +class TiffReader { + private readonly view: DataView; + private readonly tiffStart: number; + private readonly order: ByteOrder; + + constructor(buffer: ArrayBuffer, tiffStart: number) { + this.view = new DataView(buffer); + this.tiffStart = tiffStart; + this.ensureRange(tiffStart, 8); + + const byteOrder = this.view.getUint16(tiffStart, false); + if (byteOrder === 0x4949) { + this.order = "LE"; + } else if (byteOrder === 0x4d4d) { + this.order = "BE"; + } else { + throw new Error("지원하지 않는 TIFF 바이트 순서입니다."); + } + + if (this.uint16(2) !== 0x002a) { + throw new Error("유효한 TIFF 헤더가 아닙니다."); + } + } + + private get littleEndian() { + return this.order === "LE"; + } + + private absolute(relativeOffset: number) { + return this.tiffStart + relativeOffset; + } + + private ensureRange(absoluteOffset: number, byteLength: number) { + if ( + absoluteOffset < 0 || + byteLength < 0 || + absoluteOffset + byteLength > this.view.byteLength + ) { + throw new Error("EXIF offset이 파일 범위를 벗어났습니다."); + } + } + + uint16(relativeOffset: number) { + const absoluteOffset = this.absolute(relativeOffset); + this.ensureRange(absoluteOffset, 2); + return this.view.getUint16(absoluteOffset, this.littleEndian); + } + + uint32(relativeOffset: number) { + const absoluteOffset = this.absolute(relativeOffset); + this.ensureRange(absoluteOffset, 4); + return this.view.getUint32(absoluteOffset, this.littleEndian); + } + + private readIfd(relativeOffset: number) { + const entryCount = this.uint16(relativeOffset); + const entries = new Map(); + + for (let index = 0; index < entryCount; index += 1) { + const entryOffset = relativeOffset + 2 + index * 12; + const tag = this.uint16(entryOffset); + const type = this.uint16(entryOffset + 2); + const count = this.uint32(entryOffset + 4); + const valueOffset = this.uint32(entryOffset + 8); + entries.set(tag, { type, count, valueOffset, entryOffset }); + } + + return entries; + } + + private dataRelativeOffset(entry: IfdEntry) { + const byteLength = typeByteLength(entry.type) * entry.count; + if (byteLength <= 4) return entry.entryOffset + 8; + return entry.valueOffset; + } + + private ascii(entry: IfdEntry) { + const relativeOffset = this.dataRelativeOffset(entry); + const absoluteOffset = this.absolute(relativeOffset); + const byteLength = typeByteLength(entry.type) * entry.count; + this.ensureRange(absoluteOffset, byteLength); + + const bytes = new Uint8Array(this.view.buffer, absoluteOffset, byteLength); + return new TextDecoder("ascii").decode(bytes).replace(/\0+$/g, "").trim(); + } + + private rational(relativeOffset: number) { + const numerator = this.uint32(relativeOffset); + const denominator = this.uint32(relativeOffset + 4); + if (denominator === 0) return null; + return numerator / denominator; + } + + private rationals(entry: IfdEntry) { + const relativeOffset = this.dataRelativeOffset(entry); + const values: number[] = []; + for (let index = 0; index < entry.count; index += 1) { + const value = this.rational(relativeOffset + index * 8); + if (value === null) return null; + values.push(value); + } + return values; + } + + private pointer(entry: IfdEntry | undefined) { + if (!entry || entry.type !== 4 || entry.count !== 1) return null; + return entry.valueOffset; + } + + private parseDate(value: string | null) { + if (!value) return null; + const match = value.match( + /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/, + ); + if (!match) return null; + + const [, year, month, day, hour, minute, second] = match; + if (!year || !month || !day || !hour || !minute || !second) return null; + + const monthNumber = Number(month); + const dayNumber = Number(day); + const hourNumber = Number(hour); + const minuteNumber = Number(minute); + const secondNumber = Number(second); + if ( + monthNumber < 1 || + monthNumber > 12 || + dayNumber < 1 || + dayNumber > 31 || + hourNumber > 23 || + minuteNumber > 59 || + secondNumber > 59 + ) { + return null; + } + + return { + capturedAt: `${year}-${month}-${day}T${hour}:${minute}:${second}`, + dredgingDate: `${year}-${month}-${day}`, + }; + } + + private parseGps(entries: Map): GeoPoint | null { + const latitudeRefEntry = entries.get(0x0001); + const latitudeEntry = entries.get(0x0002); + const longitudeRefEntry = entries.get(0x0003); + const longitudeEntry = entries.get(0x0004); + if ( + !latitudeRefEntry || + !latitudeEntry || + !longitudeRefEntry || + !longitudeEntry + ) { + return null; + } + + const latitudeRef = this.ascii(latitudeRefEntry).toUpperCase(); + const longitudeRef = this.ascii(longitudeRefEntry).toUpperCase(); + const latitudeDms = this.rationals(latitudeEntry); + const longitudeDms = this.rationals(longitudeEntry); + if ( + !latitudeDms || + latitudeDms.length < 3 || + !longitudeDms || + longitudeDms.length < 3 + ) { + return null; + } + + const toDecimal = (values: number[]) => + (values[0] ?? 0) + (values[1] ?? 0) / 60 + (values[2] ?? 0) / 3600; + + let latitude = toDecimal(latitudeDms); + let longitude = toDecimal(longitudeDms); + if (latitudeRef === "S") latitude *= -1; + if (longitudeRef === "W") longitude *= -1; + + if ( + !Number.isFinite(latitude) || + !Number.isFinite(longitude) || + latitude < -90 || + latitude > 90 || + longitude < -180 || + longitude > 180 + ) { + return null; + } + + return { latitude, longitude }; + } + + parse() { + const ifd0Offset = this.uint32(4); + const ifd0 = this.readIfd(ifd0Offset); + + const exifIfdOffset = this.pointer(ifd0.get(EXIF_IFD_POINTER)); + const exifIfd = exifIfdOffset === null ? null : this.readIfd(exifIfdOffset); + const gpsIfdOffset = this.pointer(ifd0.get(GPS_IFD_POINTER)); + const gpsIfd = gpsIfdOffset === null ? null : this.readIfd(gpsIfdOffset); + + const dateValue = + (exifIfd?.get(DATE_TIME_ORIGINAL) + ? this.ascii(exifIfd.get(DATE_TIME_ORIGINAL)!) + : null) ?? + (exifIfd?.get(DATE_TIME_DIGITIZED) + ? this.ascii(exifIfd.get(DATE_TIME_DIGITIZED)!) + : null) ?? + (ifd0.get(DATE_TIME) ? this.ascii(ifd0.get(DATE_TIME)!) : null); + + return { + date: this.parseDate(dateValue), + location: gpsIfd ? this.parseGps(gpsIfd) : null, + }; + } +} + +const toMetadata = ( + capturedAt: string | null, + dredgingDate: string | null, + location: GeoPoint | null, + unsupported = false, +): PhotoMetadata => { + let status: ExifStatus; + let message: string; + + if (unsupported) { + status = "UNSUPPORTED"; + message = "JPEG EXIF가 없어 준설일자와 위치를 직접 입력해야 합니다."; + } else if (dredgingDate && location) { + status = "COMPLETE"; + message = "촬영일자와 GPS를 확인했습니다."; + } else if (!dredgingDate && !location) { + status = "MISSING_BOTH"; + message = "촬영일자와 GPS가 없어 수동 입력이 필요합니다."; + } else if (!dredgingDate) { + status = "MISSING_DATE"; + message = "촬영일자가 없어 준설일자를 직접 입력해야 합니다."; + } else { + status = "MISSING_GPS"; + message = "GPS가 없어 지도에서 준설위치를 등록해야 합니다."; + } + + return { capturedAt, dredgingDate, location, status, message }; +}; + +/** + * 브라우저에서 즉시 보여 줄 JPEG EXIF 미리보기를 만든다. + * 서버는 저장 시 원본 첨부파일을 기준으로 동일 정보를 다시 검증해야 한다. + */ +export const readPhotoMetadata = async (file: File): Promise => { + if (file.type !== "image/jpeg" && !/\.jpe?g$/i.test(file.name)) { + return toMetadata(null, null, null, true); + } + + try { + const buffer = await file.arrayBuffer(); + const view = new DataView(buffer); + if (view.byteLength < 4 || view.getUint16(0, false) !== JPEG_START) { + return toMetadata(null, null, null, true); + } + + let offset = 2; + while (offset + 4 <= view.byteLength) { + if (view.getUint8(offset) !== 0xff) break; + + const marker = view.getUint8(offset + 1); + if (marker === 0xda || marker === 0xd9) break; + + const segmentLength = view.getUint16(offset + 2, false); + if (segmentLength < 2 || offset + 2 + segmentLength > view.byteLength) { + break; + } + + if (marker === APP1_MARKER) { + const exifStart = offset + 4; + const exifSignature = + exifStart + 6 <= view.byteLength + ? String.fromCharCode( + view.getUint8(exifStart), + view.getUint8(exifStart + 1), + view.getUint8(exifStart + 2), + view.getUint8(exifStart + 3), + ) + : ""; + + if (exifSignature === "Exif") { + const parsed = new TiffReader(buffer, exifStart + 6).parse(); + return toMetadata( + parsed.date?.capturedAt ?? null, + parsed.date?.dredgingDate ?? null, + parsed.location, + ); + } + } + + offset += 2 + segmentLength; + } + } catch (error) { + console.warn("준설 사진 EXIF 분석 실패", error); + } + + return toMetadata(null, null, null, false); +}; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/_lib/types.ts b/apps/sn/app/(with-base-layout)/oper/dredging/_lib/types.ts new file mode 100644 index 000000000..ec9571950 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/dredging/_lib/types.ts @@ -0,0 +1,100 @@ +export type DredgingViewType = "MUNICIPALITY" | "DIAMETER" | "ELAPSED_YEAR"; + +export type DredgingRegistrationMode = "PHOTO" | "MANUAL"; + +export type DredgingLocationSource = "EXIF" | "MANUAL"; + +export type ExifStatus = + | "COMPLETE" + | "MISSING_DATE" + | "MISSING_GPS" + | "MISSING_BOTH" + | "UNSUPPORTED"; + +export type MatchConfidence = "HIGH" | "MEDIUM" | "LOW"; + +export type GeoPoint = { + longitude: number; + latitude: number; +}; + +export type PhotoMetadata = { + capturedAt: string | null; + dredgingDate: string | null; + location: GeoPoint | null; + status: ExifStatus; + message: string; +}; + +export type DredgingPhotoDraft = { + key: string; + file: File; + metadata: PhotoMetadata; +}; + +export type PipeMatch = { + fid: string; + mngInstCd: string; + fcltMngNo: string; + pipeDiameterMm: number; + pipeLengthM: number; + pipeMaterial: string; + installationYear: number | null; + distanceMeters: number; + snappedLongitude: number; + snappedLatitude: number; + confidence: MatchConfidence; + isMock: boolean; +}; + +export type DredgingDraftGroup = { + id: string; + registrationMode: DredgingRegistrationMode; + photos: DredgingPhotoDraft[]; + manualLocation: GeoPoint | null; + manualDate: string; + matchedPipe: PipeMatch | null; + matchError: string | null; + isMatching: boolean; + lowConfidenceConfirmed: boolean; +}; + +export type DredgingRecord = { + id: string; + ctpv: string; + sgg: string; + treatmentZone: string; + fcltMngNo: string; + pipeDiameterMm: number; + pipeLengthM: number; + pipeMaterial: string; + installationYear: number | null; + dredgingDate: string; + photoCount: number; + registeredDate: string; + longitude: number; + latitude: number; + registrationSource: DredgingLocationSource; + matchDistanceMeters: number; + matchConfidence: MatchConfidence; + isMock: boolean; +}; + +export type DredgingSaveGroup = { + localId: string; + registrationMode: DredgingRegistrationMode; + dredgingDate: string; + location: GeoPoint; + locationSource: DredgingLocationSource; + pipe: PipeMatch; + photos: DredgingPhotoDraft[]; + lowConfidenceConfirmed: boolean; +}; + +export const EMPTY_PHOTO_METADATA: PhotoMetadata = { + capturedAt: null, + dredgingDate: null, + location: null, + status: "UNSUPPORTED", + message: "메타정보를 확인하고 있습니다.", +}; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx new file mode 100644 index 000000000..c10539141 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx @@ -0,0 +1,564 @@ +/** + * ➡️ 경로 : 유지관리 정보 - 준설관리 + * + * - SFR-010-012 화면설계서 50~55쪽을 기준으로 한 FE 선행 구현 + * - 사진 EXIF 촬영일/GPS, 지도 수동 위치, 최근접 관로 자동 매핑 흐름 제공 + * - 지자체별/관경별/경과년도별 조회와 CSV 다운로드 제공 + */ +"use client"; + +import { + SearchDiv, + SearchFormLeft, + SearchFormRight, + UiModal, + UiTable, +} from "@common/business_components/ui"; +import ListDiv from "@common/business_components/ui/list_div"; +import { Button, Input, Label } from "@common/components/ui"; +import { useMemo, useState } from "react"; + +import DredgingLocationMap from "./_components/DredgingLocationMap"; +import DredgingRegisterModal from "./_components/DredgingRegisterModal"; +import { isDredgingBackendEnabled } from "./_lib/client"; +import type { + DredgingRecord, + DredgingSaveGroup, + DredgingViewType, +} from "./_lib/types"; + +const PAGE_SIZE = 10; +const currentYear = new Date().getFullYear(); + +const formatLocalDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +const initialRecords: DredgingRecord[] = [ + { + id: "mock-dredging-1", + ctpv: "경기도", + sgg: "양평군", + treatmentZone: "양평처리구역", + fcltMngNo: "PPL-DEMO-001", + pipeDiameterMm: 450, + pipeLengthM: 82.4, + pipeMaterial: "콘크리트", + installationYear: 2004, + dredgingDate: `${currentYear}-05-14`, + photoCount: 3, + registeredDate: `${currentYear}-05-15`, + longitude: 127.4875, + latitude: 37.4916, + registrationSource: "EXIF", + matchDistanceMeters: 4.28, + matchConfidence: "HIGH", + isMock: true, + }, + { + id: "mock-dredging-2", + ctpv: "서울특별시", + sgg: "구로구", + treatmentZone: "서남처리구역", + fcltMngNo: "PPL-DEMO-002", + pipeDiameterMm: 900, + pipeLengthM: 116.7, + pipeMaterial: "PVC", + installationYear: 1992, + dredgingDate: `${currentYear - 1}-11-08`, + photoCount: 0, + registeredDate: `${currentYear - 1}-11-09`, + longitude: 126.8874, + latitude: 37.4954, + registrationSource: "MANUAL", + matchDistanceMeters: 8.15, + matchConfidence: "HIGH", + isMock: true, + }, +]; + +type TableRow = Record & { + recordIds: string[]; +}; + +const municipalityHeadlist = [ + { id: "rowNumber", title: "순번" }, + { id: "ctpv", title: "시도" }, + { id: "sgg", title: "시군구" }, + { id: "treatmentZone", title: "처리구역" }, + { id: "fcltMngNo", title: "관로 번호" }, + { id: "pipeDiameterMm", title: "관경(mm)", align: "right" }, + { id: "pipeLengthM", title: "관로연장(m)", align: "right" }, + { id: "pipeMaterial", title: "관재질" }, + { id: "installationYear", title: "매설년도" }, + { id: "elapsedYears", title: "경과년도" }, + { id: "dredgingDate", title: "준설일자" }, + { id: "photoCount", title: "등록사진건수", align: "right" }, + { id: "registeredDate", title: "등록일자" }, +]; + +const diameterHeadlist = [ + { id: "ctpv", title: "시도" }, + { id: "totalLengthKm", title: "관로연장(km)", align: "right" }, + { id: "diameter450", title: "450 이하", align: "right" }, + { id: "diameter600", title: "450~600", align: "right" }, + { id: "diameter700", title: "700", align: "right" }, + { id: "diameter900", title: "800~900", align: "right" }, + { id: "diameter1000", title: "1000", align: "right" }, + { id: "diameter1200", title: "1200", align: "right" }, + { id: "diameter1500", title: "1500 이상", align: "right" }, +]; + +const elapsedYearHeadlist = [ + { id: "ctpv", title: "시도" }, + { id: "totalLengthKm", title: "관로연장(km)", align: "right" }, + { id: "under10", title: "10년 미만", align: "right" }, + { id: "year10To20", title: "10년~20년", align: "right" }, + { id: "year20To30", title: "20년~30년", align: "right" }, + { id: "over30", title: "30년 이상", align: "right" }, + { id: "unknown", title: "확인불가", align: "right" }, +]; + +const roundKm = (meters: number) => Number((meters / 1000).toFixed(3)); + +const groupByCtpv = (records: DredgingRecord[]) => { + const groups = new Map(); + for (const record of records) { + groups.set(record.ctpv, [...(groups.get(record.ctpv) ?? []), record]); + } + return groups; +}; + +const buildDiameterRows = (records: DredgingRecord[]): TableRow[] => + Array.from(groupByCtpv(records), ([ctpv, items]) => { + const sumLength = (predicate: (record: DredgingRecord) => boolean) => + roundKm( + items + .filter(predicate) + .reduce((sum, record) => sum + record.pipeLengthM, 0), + ); + + return { + ctpv, + totalLengthKm: roundKm( + items.reduce((sum, record) => sum + record.pipeLengthM, 0), + ), + diameter450: sumLength((record) => record.pipeDiameterMm <= 450), + diameter600: sumLength( + (record) => record.pipeDiameterMm > 450 && record.pipeDiameterMm <= 600, + ), + diameter700: sumLength( + (record) => record.pipeDiameterMm > 600 && record.pipeDiameterMm <= 700, + ), + diameter900: sumLength( + (record) => record.pipeDiameterMm > 700 && record.pipeDiameterMm <= 900, + ), + diameter1000: sumLength( + (record) => + record.pipeDiameterMm > 900 && record.pipeDiameterMm <= 1000, + ), + diameter1200: sumLength( + (record) => + record.pipeDiameterMm > 1000 && record.pipeDiameterMm <= 1200, + ), + diameter1500: sumLength((record) => record.pipeDiameterMm >= 1500), + recordIds: items.map((record) => record.id), + }; + }); + +const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] => + Array.from(groupByCtpv(records), ([ctpv, items]) => { + const sumLength = (predicate: (record: DredgingRecord) => boolean) => + roundKm( + items + .filter(predicate) + .reduce((sum, record) => sum + record.pipeLengthM, 0), + ); + const elapsedYears = (record: DredgingRecord) => + record.installationYear === null + ? null + : currentYear - record.installationYear; + + return { + ctpv, + totalLengthKm: roundKm( + items.reduce((sum, record) => sum + record.pipeLengthM, 0), + ), + under10: sumLength((record) => { + const elapsed = elapsedYears(record); + return elapsed !== null && elapsed < 10; + }), + year10To20: sumLength((record) => { + const elapsed = elapsedYears(record); + return elapsed !== null && elapsed >= 10 && elapsed < 20; + }), + year20To30: sumLength((record) => { + const elapsed = elapsedYears(record); + return elapsed !== null && elapsed >= 20 && elapsed < 30; + }), + over30: sumLength((record) => { + const elapsed = elapsedYears(record); + return elapsed !== null && elapsed >= 30; + }), + unknown: sumLength((record) => elapsedYears(record) === null), + recordIds: items.map((record) => record.id), + }; + }); + +const Page = () => { + const today = formatLocalDate(new Date()); + const [fromDate, setFromDate] = useState(`${currentYear}-01-01`); + const [toDate, setToDate] = useState(today); + const [ctpv, setCtpv] = useState("전체"); + const [sgg, setSgg] = useState("전체"); + const [viewType, setViewType] = useState("MUNICIPALITY"); + const [records, setRecords] = useState(initialRecords); + const [isRegisterOpen, setIsRegisterOpen] = useState(false); + const [selectedRecordId, setSelectedRecordId] = useState(null); + const [isMapOpen, setIsMapOpen] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [searchVersion, setSearchVersion] = useState(0); + + const filteredRecords = useMemo( + () => + records.filter( + (record) => + record.dredgingDate >= fromDate && + record.dredgingDate <= toDate && + (ctpv === "전체" || record.ctpv === ctpv) && + (sgg === "전체" || record.sgg === sgg), + ), + [records, fromDate, toDate, ctpv, sgg, searchVersion], + ); + + const tableRows = useMemo(() => { + if (viewType === "DIAMETER") return buildDiameterRows(filteredRecords); + if (viewType === "ELAPSED_YEAR") + return buildElapsedYearRows(filteredRecords); + + return filteredRecords.map((record, index) => ({ + rowNumber: index + 1, + ...record, + pipeLengthM: Number(record.pipeLengthM.toFixed(2)), + installationYear: record.installationYear ?? "확인불가", + elapsedYears: + record.installationYear === null + ? "확인불가" + : currentYear - record.installationYear, + recordIds: [record.id], + })); + }, [filteredRecords, viewType]); + + const pageRows = useMemo( + () => + tableRows.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE), + [tableRows, currentPage], + ); + + const selectedRecord = + records.find((record) => record.id === selectedRecordId) ?? null; + + const ctpvOptions = useMemo( + () => [ + "전체", + ...Array.from(new Set(records.map((record) => record.ctpv))), + ], + [records], + ); + const sggOptions = useMemo( + () => [ + "전체", + ...Array.from( + new Set( + records + .filter((record) => ctpv === "전체" || record.ctpv === ctpv) + .map((record) => record.sgg), + ), + ), + ], + [records, ctpv], + ); + + const headlist = + viewType === "MUNICIPALITY" + ? municipalityHeadlist + : viewType === "DIAMETER" + ? diameterHeadlist + : elapsedYearHeadlist; + + const title = + viewType === "MUNICIPALITY" + ? "지자체별 준설현황" + : viewType === "DIAMETER" + ? "관경별 준설현황" + : "경과년도별 준설현황"; + + const handleSearch = () => { + if (fromDate > toDate) { + alert("조회 시작일은 종료일보다 늦을 수 없습니다."); + return; + } + setCurrentPage(1); + setSelectedRecordId(null); + setSearchVersion((version) => version + 1); + }; + + const handleDownload = async () => { + try { + const visibleHeads = headlist.filter((head) => head.id !== "recordIds"); + const excelRows = [ + visibleHeads.map((head) => head.title), + ...tableRows.map((row) => + visibleHeads.map((head) => row[head.id] ?? ""), + ), + ]; + const { utils, writeFileXLSX } = await import("xlsx"); + const worksheet = utils.aoa_to_sheet(excelRows); + worksheet["!cols"] = visibleHeads.map((head) => ({ + wch: Math.max(head.title.length * 2, 12), + })); + const workbook = utils.book_new(); + utils.book_append_sheet(workbook, worksheet, title.slice(0, 31)); + writeFileXLSX( + workbook, + `준설관리_${viewType}_${fromDate}_${toDate}.xlsx`, + { compression: true }, + ); + } catch (error) { + console.error("준설현황 엑셀 다운로드 실패", error); + alert("엑셀 파일을 생성하지 못했습니다."); + } + }; + + const handleSaved = (savedGroups: DredgingSaveGroup[]) => { + const registeredDate = formatLocalDate(new Date()); + const addedRecords = savedGroups.map((group, index) => ({ + id: `local-${crypto.randomUUID()}`, + ctpv: ctpv === "전체" ? "경기도" : ctpv, + sgg: sgg === "전체" ? "양평군" : sgg, + treatmentZone: "양평처리구역", + fcltMngNo: group.pipe.fcltMngNo, + pipeDiameterMm: group.pipe.pipeDiameterMm, + pipeLengthM: group.pipe.pipeLengthM, + pipeMaterial: group.pipe.pipeMaterial, + installationYear: group.pipe.installationYear, + dredgingDate: group.dredgingDate, + photoCount: group.photos.length, + registeredDate, + longitude: group.location.longitude, + latitude: group.location.latitude, + registrationSource: group.locationSource, + matchDistanceMeters: group.pipe.distanceMeters, + matchConfidence: group.pipe.confidence, + isMock: group.pipe.isMock || !isDredgingBackendEnabled, + })); + setRecords((current) => [...addedRecords, ...current]); + setCurrentPage(1); + }; + + return ( + <> +
+

하수관로 준설

+
+ + + +
+
+ + setFromDate(event.target.value)} + className="w-[155px]" + /> + ~ + setToDate(event.target.value)} + className="w-[155px]" + /> +
+ + + + + + +
+
+ +
+ + + + +
+
+
+ + + { + const recordId = + index === undefined ? undefined : pageRows[index]?.recordIds[0]; + setSelectedRecordId(recordId ?? null); + }} + resetSelection={selectedRecordId === null} + widthFixed={false} + /> + + + + + +
+
+ 관로번호 + {selectedRecord.fcltMngNo} +
+
+ 준설일자 + {selectedRecord.dredgingDate} +
+
+ 위치출처 + {selectedRecord.registrationSource} +
+
+ 최근접 거리 + + {selectedRecord.matchDistanceMeters.toFixed(2)}m ·{" "} + {selectedRecord.matchConfidence} + +
+
+ +
+ ) : null + } + /> + + ); +}; + +export default Page; diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index 038a1b8ef..176d44da0 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -264,6 +264,31 @@ export const getBlobData = async (url: string): Promise => { return res.blob(); }; +export const getBlobDataWithHeaders = async ( + url: string, +): Promise<{ blob: Blob; fileName?: string; headers: Headers }> => { + const res = await fetch(resolveApiUrl(url), { + method: "GET", + headers: withSiteIdHeaders(), + }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`, + ); + } + + return { + blob: await res.blob(), + fileName: parseDownloadFileName(res.headers.get("content-disposition")), + headers: res.headers, + }; +}; + // DC API 호출 export const getDcData = async (url: string, resType: string = "json"): Promise => { const res = await fetch('/dc-service' + url, { diff --git a/apps/sn/app/html/(map-layout)/map/components/common/index.ts b/apps/sn/app/html/(map-layout)/map/components/common/index.ts index d2aed5956..a57bafa3d 100644 --- a/apps/sn/app/html/(map-layout)/map/components/common/index.ts +++ b/apps/sn/app/html/(map-layout)/map/components/common/index.ts @@ -18,7 +18,7 @@ type LayerComponentProps = { }; type LayerContentMap = { [key: string]: { - component?: React.FC; + component?: React.ComponentType; props?: any; }; }; @@ -43,7 +43,7 @@ export const getLayerContentComponent = (layerNm: string): React.FC null; } - return component.component + return component.component as React.FC }; // 사용자 입력 필드 (보수 및 갱신, 청소 및 준설, 점검 및 진단) export const handleInputChange = (section: string, @@ -65,4 +65,4 @@ export const handleInputChange = (section: string, updated[index][field as keyof typeof updated[number]] = value; } target.setData(updated); -}; \ No newline at end of file +}; diff --git a/apps/sn/app/html/(with-base-layout)/layout.tsx b/apps/sn/app/html/(with-base-layout)/layout.tsx index 346521d17..1907bd438 100644 --- a/apps/sn/app/html/(with-base-layout)/layout.tsx +++ b/apps/sn/app/html/(with-base-layout)/layout.tsx @@ -20,7 +20,7 @@ import { BaseLayout } from "@common/components"; import type { MenuItem } from "@/entities/menu"; import { useMenuStore } from "@/shared/lib"; import { PageLayout } from "@/shared/ui/page-layout"; -import useStore from '../store/useStore'; +import useStore from '../../store/useStore'; export default function WithBaseLayout({ children, 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 new file mode 100644 index 000000000..ea552859c --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md @@ -0,0 +1,51 @@ +# SFR-010-012 준설관리 FE 선행 검증결과 + +## 1. 검증 환경 + +- 일자: 2026-07-30 +- URL: `http://localhost:3002/oper/dredging` +- 브라우저: Codex 인앱 브라우저 +- 모드: 로컬 상태 검증, 개발단계 안내 UI 미노출 +- 백엔드·DB 변경: 없음 + +## 2. 결과 + +| 항목 | 결과 | 근거 | +| --- | --- | --- | +| 직접 경로 진입 | 통과 | 로그인 후 `/oper/dredging` 제목과 목록 렌더링 | +| 조회 유형 전환 | 통과 | 지자체별 → 관경별 → 경과년도별 제목·컬럼 전환 | +| 수동 위치 등록 | 통과 | 준설일자와 `37.4916, 127.4875` 입력 | +| 최근접 관로 자동 매핑 | 통과 | 모의 관로 `PPL-497910`, 거리 `3.80m`, 신뢰도 높음 | +| JPEG EXIF 분석 | 통과 | `2026-07-29T10:20:30`, `2026-07-29`, `37.4916, 127.4875` 표시 | +| 사진 기반 모의 저장 | 통과 | 저장 알림 후 모달 닫힘, 목록 총 2건, 사진 1건 반영 | +| 등록 결과 지도보기 | 통과 | 선택 행 지도보기 활성화, 관로·거리 표시 | +| 임시 안내 문구 제거 | 통과 | 목록·등록창에서 선행 구현, 모의 데이터, 모의 매핑, 고아 첨부 안내 미노출 | +| 브라우저 콘솔 | 통과 | 최종 경고·오류 0건 | +| 신규 경로 타입 검사 | 통과 | 전용 임시 tsconfig 기준 `tsc --noEmit`, 종료코드 0 | +| 엑셀 다운로드 | 정적 통과/브라우저 재확인 필요 | 최종 XLSX 생성 코드는 타입 검사 통과. 브라우저 다운로드 이벤트는 최종 변경 전 자동화에서 포착하지 못함 | +| ESLint | 환경 보류 | 설치된 lock/node_modules 불일치로 `eslint-plugin-react-hooks` 해석 실패 | +| 전체 Next build | 미수행 | 실행 중인 `next dev -p 3002` 보호 기준에 따라 생략 | + +## 3. 발견 및 보정 + +- 날짜 입력 직후 좌표를 갱신할 때 그룹 참조가 늦게 갱신될 수 있어 `groupsRef`를 상태 변경 시 즉시 동기화하도록 수정했다. +- 조회 영역이 공통 `SearchFormLeft`의 기본 12열 grid 한 칸에 들어가 세로로 압축되는 문제를 전용 `leftClassName`으로 보정했다. +- 다운로드는 화면설계·상세설계와 맞춰 CSV가 아니라 `.xlsx`로 최종 구현했다. +- 사용자 요청에 따라 개발단계·모의 데이터 안내 배너와 모의 매핑 표식을 제거했다. + +## 4. 증적 + +- `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/001-준설관리-목록화면.png` +- `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/002-수동등록-자동관로매핑.png` +- `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/003-사진EXIF-자동등록.png` +- `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/004-사진등록-목록반영.png` +- `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/준설사진-EXIF-테스트.jpg` + +`003`, `004` 화면은 최초 FE 흐름 검증 당시 증적이며, 이후 개발단계 안내 문구 제거는 브라우저 DOM에서 별도 재검증했다. + +## 5. 후속 검증 + +- 백엔드 구현 후 공통 첨부 TUS 응답과 준설 등록 트랜잭션을 실제로 확인한다. +- 실제 MS 관로 geometry로 거리·스냅 좌표·동률 처리·탐색 반경을 검증한다. +- 서버 원본 EXIF 재분석 결과와 브라우저 분석 결과 불일치 케이스를 검증한다. +- 실제 XLSX 다운로드 파일명, 시트명, 행·열 값을 브라우저에서 다시 확인한다. 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 new file mode 100644 index 000000000..d8a56c0ce --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-작업카드.md @@ -0,0 +1,49 @@ +# SFR-010-012 준설관리 프론트엔드 작업카드 + +## 1. 목적 + +- 백엔드 구현 전에 준설관리 화면과 상태 전이를 FE 선행 구현한다. +- 공통 첨부 업로드, EXIF 분석, 지도 수동 위치, 자동 관로 매핑 payload를 검증 가능하게 만든다. + +## 2. 구현 경로 + +- `apps/sn/app/(with-base-layout)/oper/dredging/**` + +## 3. 수정 금지 + +- 진행 중인 `apps/sn/app/(map-layout)/map/page.tsx` +- 진행 중인 `apps/sn/app/components/lib/api/index.ts` +- `packages/common/components/**` +- 다른 앱 + +## 4. 화면 범위 + +- 지자체별/관경별/경과년도별 조회 전환 +- 조회, 다운로드, 등록, 지도보기 +- 관로 그룹 추가/삭제 +- 공통 `FileUpload` 최대 5장 +- JPEG EXIF 촬영일/GPS 파싱 +- 사진 없는 수동 등록 +- GPS/촬영일 누락 보완 +- 공통 `Gis` 지도 클릭 +- 최근접 관로 자동매핑 상태 +- 실제 연동 전 로컬 데이터 기반 상태 전이 + +## 5. 연동 모드 + +- 기본: FE 선행 모의 모드 + - 첨부파일 전송과 준설 DB 저장을 수행하지 않는다. + - EXIF, 수동 위치, 관로 매핑, payload 조립을 화면에서 확인한다. +- `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED=true` + - 공통 TUS 업로드 수행 + - 최근접 관로/등록 API 호출 + +## 6. 완료 조건 + +- `/oper/dredging` 직접 진입 +- 세 조회 유형의 컬럼과 집계 전환 +- 사진/수동 등록 사용자 흐름 +- EXIF 상태가 사진별로 표시 +- 관로, 거리, 신뢰도 표시 +- 저장 전 필수 검증 +- 브라우저 실제 조작과 콘솔 오류 확인 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 new file mode 100644 index 000000000..76725d818 --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md @@ -0,0 +1,59 @@ +# SFR-010-012 준설관리 프론트엔드 구현기록 + +## 1. 구현 결과 + +- 신규 직접 진입 경로: `/oper/dredging` +- 기본 모드: `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED`가 `true`가 아니면 FE 선행 모의 모드 +- 기존 지도 메인, 공통 API 모듈, 공통 컴포넌트는 수정하지 않았다. + +## 2. 구현 파일 + +| 파일 | 책임 | +| --- | --- | +| `page.tsx` | 지자체별·관경별·경과년도별 조회, 검색, 엑셀 다운로드, 등록/지도보기 진입 | +| `_components/DredgingRegisterModal.tsx` | 관로 그룹, 사진/수동 등록, 검증, 첨부 업로드 및 저장 순서 | +| `_components/DredgingLocationMap.tsx` | 공통 `Gis`에 로컬 위치 마커와 지도 클릭 interaction 연결 | +| `_lib/exif.ts` | JPEG APP1/TIFF EXIF 촬영일·GPS 분석 | +| `_lib/client.ts` | 최근접 관로, 공통 TUS 첨부 업로드, 준설 등록 API 어댑터와 FE 모의 응답 | +| `_lib/types.ts` | 화면 draft, 사진 메타정보, 관로 매핑, 저장 payload 타입 | + +## 3. 주요 처리 + +### 3.1 사진 등록 + +1. 공통 `FileUpload`로 관로 그룹당 최대 5장을 선택한다. +2. JPEG의 `DateTimeOriginal → DateTimeDigitized → DateTime` 순서로 촬영일을 분석한다. +3. 촬영일의 날짜 부분을 준설일자로 사용한다. +4. GPS DMS를 WGS84 경위도로 변환한다. +5. 사진 위치 목록으로 최근접 관로를 조회한다. +6. 한 그룹의 촬영일 또는 관로가 둘 이상이면 저장을 차단한다. +7. 백엔드 연동 모드에서는 공통 TUS 첨부 업로드 완료 후 준설 등록 API를 호출한다. + +### 3.2 수동 등록 + +1. `지도 수동 등록`을 선택한다. +2. 준설일자를 입력한다. +3. 공통 지도 클릭 또는 `위도, 경도` 입력으로 위치를 지정한다. +4. 좌표 변경 즉시 최근접 관로와 거리·신뢰도를 갱신한다. +5. 관로 매핑이 완료된 건만 저장 payload로 만든다. + +### 3.3 FE 선행 모드 + +- 고아 첨부 생성을 막기 위해 실제 파일 전송과 DB 저장을 수행하지 않는다. +- 최근접 관로 응답과 저장 결과만 명시적인 `isMock` 데이터로 생성한다. +- 사용자 화면에는 개발단계, 선행 구현, 모의 매핑 안내 문구를 노출하지 않는다. +- 등록 결과는 현재 탭의 목록 상태에만 반영한다. + +## 4. 연동 전환 + +- 환경변수: `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED=true` +- 예정 API: + - `POST /api/v1/dredging/nearestPipes` + - `POST /api/v1/dredging/registrations` +- 첨부: 기존 공통 `handleUpload`의 TUS 응답 `X-File-Info`에서 `atflCd`, `atflId`를 받는다. +- 서버는 원본 파일 EXIF, 날짜, 좌표, 관로 키를 다시 검증해야 한다. + +## 5. 롤백 + +- `apps/sn/app/(with-base-layout)/oper/dredging/**`를 제거한다. +- 신규 전용 경로이므로 기존 지도·운영정보 화면의 코드 롤백은 필요하지 않다. diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts index 904a9bc2f..3e0cb8a96 100644 --- a/apps/sn/src/features/windowPopup/model/types.ts +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -37,6 +37,7 @@ export const WINDOW_POPUP_MESSAGE_TYPE = { GIS_EDIT_SESSION_FOCUS_CHANGE: "sn:popup:gisEditSessionFocusChange", GIS_EDIT_SESSION_REMOVE_LAST_CHANGE: "sn:popup:gisEditSessionRemoveLastChange", GIS_EDIT_SESSION_CLEAR_CHANGES: "sn:popup:gisEditSessionClearChanges", + GIS_UPLOAD_COMPLETED: "sn:popup:gisUploadCompleted", WATER_RISK_READY: "sn:popup:waterRiskReady", WATER_RISK_SYNC: "sn:popup:waterRiskSync", WATER_RISK_SELECT_RESULT: "sn:popup:waterRiskSelectResult", diff --git a/packages/common/business_components/src/ui/chart/LineBarChart.tsx b/packages/common/business_components/src/ui/chart/LineBarChart.tsx index 271629e6a..7dcb06d34 100644 --- a/packages/common/business_components/src/ui/chart/LineBarChart.tsx +++ b/packages/common/business_components/src/ui/chart/LineBarChart.tsx @@ -223,7 +223,7 @@ export default function LineBarchart({ domain={[0, leftScale.max]} ticks={leftScale.ticks} orientation="left" - label={(props) => } + label={(props: any) => } />} {/* 오른쪽 Y축: barVal */} {yFieldText2 && } + label={(props: any) => } reversed={true} />} } /> @@ -281,4 +281,4 @@ export default function LineBarchart({ ); -} \ No newline at end of file +} diff --git a/packages/common/business_components/src/ui/chart/LineChart.tsx b/packages/common/business_components/src/ui/chart/LineChart.tsx index b9ba58ff6..d7ea71c44 100644 --- a/packages/common/business_components/src/ui/chart/LineChart.tsx +++ b/packages/common/business_components/src/ui/chart/LineChart.tsx @@ -252,7 +252,7 @@ export default function Linechart({ tickFormatter={(value) => typeof value === "number" ? value.toLocaleString() : value } - label={(props) => }/> + label={(props: any) => }/> } /> {showLegend && } {Object.keys(chartData).map((key) => { @@ -277,4 +277,4 @@ export default function Linechart({ ); -} \ No newline at end of file +}