From 3f4e3581dd0e86a0475c0f13cf7ec25a471a049b Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Fri, 7 Aug 2026 15:28:33 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A4=80=EC=84=A4=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SearchDatePicker.tsx | 0 .../_components/DredgingLocationMap.tsx | 314 ------- .../components/DredgingDetailPopupPage.tsx | 722 ++++++++++++++++ .../components/DredgingLocationMap.tsx | 396 +++++++++ .../DredgingRegisterModal.tsx | 791 ++++++++---------- .../DredgingRegisterPopupPage.tsx | 0 .../oper/dredging/{_lib => lib}/client.ts | 277 ++++-- .../oper/dredging/{_lib => lib}/exif.ts | 0 .../oper/dredging/{_lib => lib}/types.ts | 34 +- .../(with-base-layout)/oper/dredging/page.tsx | 403 ++++++--- .../(with-base-layout)/oper/floodPre/page.tsx | 2 +- .../(with-base-layout)/oper/oldPp/page.tsx | 2 +- apps/sn/app/api/gis-upload/route.ts | 14 +- .../001-qa-SFR-010-012-준설관리-검증결과.md | 159 +++- .../002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md | 113 +++ .../001-fe-SFR-010-012-준설관리-작업카드.md | 7 +- .../001-fe-SFR-010-012-준설관리-구현기록.md | 139 ++- apps/sn/src/app/styles/components.css | 28 + .../windowPopup/lib/openWindowPopup.ts | 49 +- .../windowPopup/model/popupDefinitions.ts | 14 + .../src/features/windowPopup/model/types.ts | 13 +- apps/sn/src/features/windowPopup/registry.tsx | 23 +- .../ui/DredgingDetailPopupContent.tsx | 58 ++ .../ui/DredgingEditPopupContent.tsx | 27 + .../ui/DredgingRegisterPopupContent.tsx | 2 +- 25 files changed, 2588 insertions(+), 999 deletions(-) rename apps/sn/app/(with-base-layout)/oper/{_components => components}/SearchDatePicker.tsx (100%) delete 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/DredgingDetailPopupPage.tsx create mode 100644 apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingLocationMap.tsx rename apps/sn/app/(with-base-layout)/oper/dredging/{_components => components}/DredgingRegisterModal.tsx (53%) rename apps/sn/app/(with-base-layout)/oper/dredging/{_components => components}/DredgingRegisterPopupPage.tsx (100%) rename apps/sn/app/(with-base-layout)/oper/dredging/{_lib => lib}/client.ts (52%) rename apps/sn/app/(with-base-layout)/oper/dredging/{_lib => lib}/exif.ts (100%) rename apps/sn/app/(with-base-layout)/oper/dredging/{_lib => lib}/types.ts (74%) create mode 100644 apps/sn/src/features/windowPopup/ui/DredgingDetailPopupContent.tsx create mode 100644 apps/sn/src/features/windowPopup/ui/DredgingEditPopupContent.tsx diff --git a/apps/sn/app/(with-base-layout)/oper/_components/SearchDatePicker.tsx b/apps/sn/app/(with-base-layout)/oper/components/SearchDatePicker.tsx similarity index 100% rename from apps/sn/app/(with-base-layout)/oper/_components/SearchDatePicker.tsx rename to apps/sn/app/(with-base-layout)/oper/components/SearchDatePicker.tsx 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 deleted file mode 100644 index 73ad6e878..000000000 --- a/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingLocationMap.tsx +++ /dev/null @@ -1,314 +0,0 @@ -"use client"; - -import Feature from "ol/Feature.js"; -import type { FeatureLike } from "ol/Feature.js"; -import GeoJSON from "ol/format/GeoJSON.js"; -import Point from "ol/geom/Point.js"; -import type OlMap from "ol/Map.js"; -import { unByKey } from "ol/Observable.js"; -import { bbox as bboxStrategy } from "ol/loadingstrategy.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 Gis, { useGisContext } from "@common/business_components/ui/map/Gis"; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; - -import type { GeoPoint, PipeMatch } from "../_lib/types"; - -type DredgingLocationMapProps = { - value: GeoPoint | null; - onChange?: (point: GeoPoint) => void; - interactive?: boolean; - statusText?: string; - matchedPipe?: PipeMatch | null; -}; - -type GisContextValue = { - gisMap: OlMap | null; -}; - -const confirmedMarkerStyle = new Style({ - image: new CircleStyle({ - radius: 9, - fill: new Fill({ color: "rgba(220, 38, 38, 0.9)" }), - stroke: new Stroke({ color: "#ffffff", width: 3 }), - }), -}); - -const draftMarkerStyle = new Style({ - image: new CircleStyle({ - radius: 9, - fill: new Fill({ color: "rgba(37, 99, 235, 0.9)" }), - stroke: new Stroke({ color: "#ffffff", width: 3 }), - }), -}); - -const nearbyPipeStyle = [ - new Style({ - stroke: new Stroke({ color: "rgba(255, 255, 255, 0.95)", width: 5 }), - zIndex: 10, - }), - new Style({ - stroke: new Stroke({ color: "#0f6cbd", width: 3 }), - zIndex: 11, - }), -]; - -const matchedPipeStyle = [ - new Style({ - stroke: new Stroke({ color: "rgba(255, 255, 255, 0.98)", width: 8 }), - zIndex: 20, - }), - new Style({ - stroke: new Stroke({ color: "#f97316", width: 5 }), - zIndex: 21, - }), -]; - -const PIPE_LAYER_MIN_ZOOM = 13; - -const normalizePipeFid = (fid: unknown) => { - const value = String(fid ?? "").trim(); - const separatorIndex = value.lastIndexOf("."); - return separatorIndex >= 0 ? value.slice(separatorIndex + 1) : value; -}; - -const getPipeFeatureStyle = (matchedPipeFid?: string) => (feature: FeatureLike) => - normalizePipeFid(feature.getId()) === normalizePipeFid(matchedPipeFid) - ? matchedPipeStyle - : nearbyPipeStyle; - -/** - * 공통 Gis 내부 지도 객체에 관로 layer, 클릭 interaction과 위치 marker를 추가한다. - * 공통 지도 코드는 수정하지 않고 이 컴포넌트가 추가한 layer/event만 정리한다. - */ -const LocationInteraction = ({ - value, - onChange, - interactive, - matchedPipe, -}: Required> & - Omit) => { - const { gisMap } = useGisContext() as GisContextValue; - const markerLayerRef = useRef | null>(null); - const pipeLayerRef = useRef | null>(null); - const [pipeLoadState, setPipeLoadState] = useState< - "idle" | "loading" | "loaded" | "error" - >("idle"); - const [visiblePipeCount, setVisiblePipeCount] = useState(0); - - useEffect(() => { - if (!gisMap) return; - - const pipeSource = new VectorSource({ - format: new GeoJSON(), - strategy: bboxStrategy, - url: (extent) => { - const [minX, minY, maxX, maxY] = extent; - const layerModule = GisApp.Module.layer as unknown as { - storageNm?: string; - } | null; - const storageName = String(layerModule?.storageNm ?? "hasudo:"); - const params = new URLSearchParams({ - SERVICE: "WFS", - VERSION: "1.1.0", - REQUEST: "GetFeature", - TYPENAME: `${storageName}tb_ms_swage_ppl`, - SRSNAME: GisApp.TO_PROJ, - OUTPUTFORMAT: "application/json", - MAXFEATURES: "2000", - CQL_FILTER: `BBOX(geom, ${minX}, ${minY}, ${maxX}, ${maxY}, '${GisApp.TO_PROJ}')`, - }); - return `${GisApp.GEOSERVER_URL}?${params.toString()}`; - }, - }); - const pipeLayer = new VectorLayer({ - source: pipeSource, - minZoom: PIPE_LAYER_MIN_ZOOM, - style: getPipeFeatureStyle(matchedPipe?.fid), - updateWhileAnimating: false, - updateWhileInteracting: false, - zIndex: 700, - }); - pipeLayer.set("dredgingPipeLayer", true); - pipeLayer.set("lyrTyNm", "tb_ms_swage_ppl"); - pipeLayer.set("clickPsblYn", "N"); - gisMap.addLayer(pipeLayer); - pipeLayerRef.current = pipeLayer; - - const sourceEventKeys = [ - pipeSource.on("featuresloadstart", () => setPipeLoadState("loading")), - pipeSource.on("featuresloadend", () => { - setVisiblePipeCount(pipeSource.getFeatures().length); - setPipeLoadState("loaded"); - }), - pipeSource.on("featuresloaderror", () => { - setVisiblePipeCount(0); - setPipeLoadState("error"); - }), - ]; - - const markerSource = new VectorSource(); - const markerLayer = new VectorLayer({ - source: markerSource, - style: interactive ? draftMarkerStyle : confirmedMarkerStyle, - zIndex: 999, - }); - markerLayer.set("dredgingLocationLayer", true); - gisMap.addLayer(markerLayer); - markerLayerRef.current = markerLayer; - - 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); - unByKey(sourceEventKeys); - gisMap.removeLayer(markerLayer); - gisMap.removeLayer(pipeLayer); - markerLayerRef.current = null; - pipeLayerRef.current = null; - }; - }, [gisMap, interactive, onChange]); - - useEffect(() => { - if (!gisMap || !markerLayerRef.current) return; - const source = markerLayerRef.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, interactive, value]); - - useEffect(() => { - if (!pipeLayerRef.current) return; - pipeLayerRef.current.setStyle(getPipeFeatureStyle(matchedPipe?.fid)); - pipeLayerRef.current.changed(); - }, [matchedPipe?.fid]); - - const pipeStatusText = (() => { - if (!value) return "위치를 등록하면 주변 관로가 표시됩니다."; - if (pipeLoadState === "loading") return "주변 관로를 불러오는 중입니다."; - if (pipeLoadState === "error") return "주변 관로를 불러오지 못했습니다."; - if (pipeLoadState === "loaded") { - return visiblePipeCount > 0 - ? `주변 관로 ${visiblePipeCount.toLocaleString()}건 표시` - : "현재 지도 범위에 표시할 관로가 없습니다."; - } - return "지도를 확대하면 주변 관로가 표시됩니다."; - })(); - - return ( -
-

- {interactive - ? "지도를 클릭해 준설위치를 선택하세요." - : "등록 위치와 주변 관로를 함께 확인합니다."} -

-
- - - 주변 관로 - - - - 자동 연결 관로 - - - {pipeStatusText} - -
-
- ); -}; - -const DredgingLocationMap = ({ - value, - onChange, - interactive = true, - statusText, - matchedPipe, -}: 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-[540px]" - showZoomLevel={true} - showFloatingSearchButton={false} - treeTogglePosition="top" - /> -
- {statusText ?? - (interactive - ? "지도에서 준설위치를 선택해주세요." - : value - ? "등록된 준설위치가 표시되었습니다." - : "위치가 등록되지 않았습니다.")} -
-
- ); -}; - -export default DredgingLocationMap; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx new file mode 100644 index 000000000..01246f5b8 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx @@ -0,0 +1,722 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { FileUpload } from "@common/business_components/ui/file-upload"; +import { Button } from "@common/components/ui/button"; +import { Input } from "@common/components/ui/input"; +import { Label } from "@common/components/ui/label"; +import { MS_SWAGE_PPL_CODES } from "@/app/(map-layout)/map/components/common/table"; +import { Fragment, useEffect, useMemo, useRef, useState } from "react"; + +import { + fetchDredgingPhotoBlob, + fetchDredgingPhotos, + findNearestPipeMatches, + updateDredgingRecord, + uploadDredgingPhotoDrafts, + type AttachmentReference, +} from "../lib/client"; +import { readPhotoMetadata } from "../lib/exif"; +import { + EMPTY_PHOTO_METADATA, + type DredgingPhoto, + type DredgingPhotoDraft, + type DredgingRecord, + type GeoPoint, + type PipeMatch, +} from "../lib/types"; + +const DredgingLocationMap = dynamic(() => import("./DredgingLocationMap"), { + ssr: false, +}); + +type Props = { + record: DredgingRecord; + mode?: "detail" | "edit"; + onUpdated?: () => void; +}; + +type DredgingPhotoPreview = DredgingPhoto & { + previewUrl: string | null; + loadError: string | null; +}; + +type NewPhotoPreview = DredgingPhotoDraft & { + previewUrl: string; +}; + +const PRIMARY_BUTTON_CLASS = + "min-w-[6.4rem] border !border-blue-600 !bg-blue-600 px-4 font-medium !text-white hover:!bg-blue-700"; +const SECONDARY_BUTTON_CLASS = + "min-w-[6.4rem] border !border-blue-300 !bg-white px-4 font-medium !text-blue-700 hover:!bg-blue-50"; + +const pipeMaterialNameByCode = new Map( + MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]), +); + +const resolvePipeMaterialName = (value: string) => { + const normalizedValue = value.trim(); + if (!normalizedValue) return "확인불가"; + const normalizedCode = normalizedValue.toUpperCase(); + if (!/^MOP\d+$/.test(normalizedCode)) return normalizedValue; + return pipeMaterialNameByCode.get(normalizedCode) ?? "확인불가"; +}; + +const locationSourceLabel = { + EXIF: "사진 GPS", + MANUAL: "지도 지정", +} as const; + +const formatLocalDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +/** 상세 UI를 공통 기반으로 사용해 조회와 수정 상태의 시각 구조를 동일하게 유지한다. */ +const DredgingDetailPopupPage = ({ + record, + mode = "detail", + onUpdated, +}: Props) => { + const editable = mode === "edit"; + const [photos, setPhotos] = useState([]); + const [newPhotos, setNewPhotos] = useState([]); + const newPhotoUrlsRef = useRef([]); + const [selectedPhotoKey, setSelectedPhotoKey] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [dredgingDate, setDredgingDate] = useState(record.dredgingDate); + const [location, setLocation] = useState({ + longitude: record.longitude, + latitude: record.latitude, + }); + const [locationDraft, setLocationDraft] = useState(null); + const [isEditingLocation, setIsEditingLocation] = useState(false); + const [locationSource, setLocationSource] = useState( + record.registrationSource, + ); + const [matchError, setMatchError] = useState(null); + const [isMatching, setIsMatching] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [statusMessage, setStatusMessage] = useState(""); + const uploadedAttachmentsRef = useRef(new Map()); + + const initialMatchedPipe = useMemo( + () => ({ + fid: record.pipeFid, + mngInstCd: record.mngInstCd, + fcltMngNo: record.fcltMngNo, + pipeDiameterMm: record.pipeDiameterMm, + pipeLengthM: record.pipeLengthM, + pipeMaterial: record.pipeMaterial, + installationYear: record.installationYear, + swtrmtZoneFid: null, + swtrmtZoneNm: record.treatmentZone, + distanceMeters: record.matchDistanceMeters, + snappedLongitude: record.snappedLongitude || record.longitude, + snappedLatitude: record.snappedLatitude || record.latitude, + isMock: record.isMock, + }), + [record], + ); + const [matchedPipe, setMatchedPipe] = useState(initialMatchedPipe); + + useEffect(() => { + let cancelled = false; + const createdUrls: string[] = []; + + const load = async () => { + setIsLoading(true); + setError(null); + setPhotos([]); + setSelectedPhotoKey(null); + + try { + const items = await fetchDredgingPhotos( + record.dredgingJobSn, + record.pipeFid, + ); + const previews = await Promise.all( + items.map(async (photo): Promise => { + try { + const blob = await fetchDredgingPhotoBlob(photo.atflId); + const previewUrl = URL.createObjectURL(blob); + createdUrls.push(previewUrl); + return { ...photo, previewUrl, loadError: null }; + } catch (loadError) { + return { + ...photo, + previewUrl: null, + loadError: + loadError instanceof Error + ? loadError.message + : "사진 원본을 불러오지 못했습니다.", + }; + } + }), + ); + + if (!cancelled) { + setPhotos(previews); + setSelectedPhotoKey( + previews[0] ? `existing-${previews[0].photoSn}` : null, + ); + } + } catch (loadError) { + if (!cancelled) { + setError( + loadError instanceof Error + ? loadError.message + : "등록된 준설사진을 불러오지 못했습니다.", + ); + } + } finally { + if (!cancelled) setIsLoading(false); + } + }; + + void load(); + + return () => { + cancelled = true; + createdUrls.forEach((url) => URL.revokeObjectURL(url)); + }; + }, [record.dredgingJobSn, record.pipeFid]); + + useEffect( + () => () => { + newPhotoUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); + }, + [], + ); + + const selectedExistingPhoto = selectedPhotoKey?.startsWith("existing-") + ? (photos.find( + (photo) => `existing-${photo.photoSn}` === selectedPhotoKey, + ) ?? null) + : null; + const selectedNewPhoto = selectedPhotoKey?.startsWith("new-") + ? (newPhotos.find((photo) => `new-${photo.key}` === selectedPhotoKey) ?? + null) + : null; + + const pipeInfo = [ + ["시도", record.ctpv || "확인불가"], + ["시군구", record.sgg || "확인불가"], + [ + "처리구역", + matchedPipe.swtrmtZoneNm || record.treatmentZone || "확인불가", + ], + ["관경", `${matchedPipe.pipeDiameterMm.toLocaleString()} mm`], + ["관로연장", `${matchedPipe.pipeLengthM.toLocaleString()} m`], + ["관재질", resolvePipeMaterialName(matchedPipe.pipeMaterial)], + [ + "매설년도", + matchedPipe.installationYear + ? `${matchedPipe.installationYear}년` + : "확인불가", + ], + ["위치등록", locationSourceLabel[locationSource]], + ]; + const automaticMappingValue = `${matchedPipe.distanceMeters.toFixed(2)} m`; + + const handleNewFiles = async (files: File[] | null) => { + const selectedFiles = files ?? []; + if (photos.length + selectedFiles.length > 5) { + alert( + "관로별 사진은 기존 사진을 포함해 최대 5장까지 등록할 수 있습니다.", + ); + return; + } + + newPhotoUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); + newPhotoUrlsRef.current = []; + const checking = selectedFiles.map((file) => { + const previewUrl = URL.createObjectURL(file); + newPhotoUrlsRef.current.push(previewUrl); + return { + key: crypto.randomUUID(), + file, + metadata: EMPTY_PHOTO_METADATA, + previewUrl, + }; + }); + setNewPhotos(checking); + if (checking[0]) setSelectedPhotoKey(`new-${checking[0].key}`); + + const parsed = await Promise.all( + checking.map(async (photo) => ({ + ...photo, + metadata: await readPhotoMetadata(photo.file), + })), + ); + setNewPhotos(parsed); + }; + + const removeExistingPhoto = (photoSn: number) => { + setPhotos((current) => + current.filter((photo) => photo.photoSn !== photoSn), + ); + if (selectedPhotoKey === `existing-${photoSn}`) setSelectedPhotoKey(null); + }; + + const removeNewPhoto = (photoKey: string) => { + const removed = newPhotos.find((photo) => photo.key === photoKey); + if (removed) { + URL.revokeObjectURL(removed.previewUrl); + newPhotoUrlsRef.current = newPhotoUrlsRef.current.filter( + (url) => url !== removed.previewUrl, + ); + } + setNewPhotos((current) => + current.filter((photo) => photo.key !== photoKey), + ); + if (selectedPhotoKey === `new-${photoKey}`) setSelectedPhotoKey(null); + }; + + const startLocationEditing = () => { + setLocationDraft(location); + setIsEditingLocation(true); + setMatchError(null); + }; + + const cancelLocationEditing = () => { + setLocationDraft(null); + setIsEditingLocation(false); + setMatchError(null); + }; + + const applyLocation = async () => { + if (!locationDraft) { + alert("지도에서 변경할 준설위치를 선택해주세요."); + return; + } + setIsMatching(true); + setMatchError(null); + try { + const locationKey = `edit-${record.dredgingJobSn}-${record.pipeFid}`; + const matches = await findNearestPipeMatches([ + { locationKey, ...locationDraft }, + ]); + const nextPipe = matches.get(locationKey); + if (!nextPipe) { + throw new Error( + "50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요.", + ); + } + setLocation(locationDraft); + setLocationSource("MANUAL"); + setMatchedPipe(nextPipe); + setLocationDraft(null); + setIsEditingLocation(false); + setStatusMessage("변경한 위치와 최근접 관로를 확인했습니다."); + } catch (matchFailure) { + setMatchError( + matchFailure instanceof Error + ? matchFailure.message + : "최근접 관로를 찾지 못했습니다.", + ); + } finally { + setIsMatching(false); + } + }; + + const handleSave = async () => { + if (!dredgingDate) { + alert("준설일자를 입력해주세요."); + return; + } + if (dredgingDate > formatLocalDate(new Date())) { + alert("미래 날짜는 준설일자로 수정할 수 없습니다."); + return; + } + if (isEditingLocation) { + alert("선택한 지도 위치를 먼저 적용해주세요."); + return; + } + setIsSaving(true); + try { + setStatusMessage( + newPhotos.length > 0 + ? "추가한 사진을 공통 첨부파일로 업로드하고 있습니다." + : "수정 내용을 저장하고 있습니다.", + ); + const attachments = await uploadDredgingPhotoDrafts( + newPhotos, + uploadedAttachmentsRef.current, + ); + uploadedAttachmentsRef.current = attachments; + await updateDredgingRecord( + record.dredgingJobSn, + { + dredgingDate, + originalPipeFid: record.pipeFid, + location, + locationSource, + pipe: matchedPipe, + retainedPhotoSns: photos.map((photo) => photo.photoSn), + newPhotos, + }, + attachments, + ); + uploadedAttachmentsRef.current.clear(); + setStatusMessage("준설정보 수정을 완료했습니다."); + onUpdated?.(); + alert("준설정보를 수정했습니다."); + window.close(); + } catch (saveError) { + const message = + saveError instanceof Error + ? saveError.message + : "준설정보를 수정하지 못했습니다."; + setStatusMessage(message); + alert(message); + } finally { + setIsSaving(false); + } + }; + + const totalPhotoCount = photos.length + newPhotos.length; + + return ( +
+
+
+

+ 준설결과 {editable ? "수정" : "상세"} +

+ + 관로번호 {matchedPipe.fcltMngNo || "확인불가"} + +
+ {editable ? ( +
+ + +
+ ) : null} +
+ +
+ + +
+ +
+
+
+ ); +}; + +export default DredgingDetailPopupPage; 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..f9726eac7 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingLocationMap.tsx @@ -0,0 +1,396 @@ +"use client"; + +import Feature from "ol/Feature.js"; +import type { FeatureLike } from "ol/Feature.js"; +import Point from "ol/geom/Point.js"; +import type OlMap from "ol/Map.js"; +import type { EventsKey } from "ol/events.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 type { StyleFunction, StyleLike } from "ol/style/Style.js"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { getRawJson } from "@/components/lib/api"; +import Gis, { useGisContext } from "@common/business_components/ui/map/Gis"; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; + +import type { GeoPoint, PipeMatch } from "../lib/types"; + +type DredgingLocationMapProps = { + value: GeoPoint | null; + onChange?: (point: GeoPoint) => void; + interactive?: boolean; + statusText?: string; + matchedPipe?: PipeMatch | null; + height?: string; +}; + +type LayerSetting = Record & { + id?: string; + lyrSn?: string; + lyrTyNm?: string; + useYn?: string; + actlLyrYn?: string; + exprsYn?: string; + children?: LayerSetting[]; +}; + +type GisCore = { + addWfsLayer: (setting: LayerSetting) => VectorLayer; +}; + +type GisContextValue = { + gisMap: OlMap | null; + gisCore: GisCore | null; +}; + +const confirmedMarkerStyle = new Style({ + image: new CircleStyle({ + radius: 9, + fill: new Fill({ color: "rgba(220, 38, 38, 0.9)" }), + stroke: new Stroke({ color: "#ffffff", width: 3 }), + }), +}); + +const draftMarkerStyle = new Style({ + image: new CircleStyle({ + radius: 9, + fill: new Fill({ color: "rgba(37, 99, 235, 0.9)" }), + stroke: new Stroke({ color: "#ffffff", width: 3 }), + }), +}); + +const matchedPipeStyle = [ + new Style({ + stroke: new Stroke({ color: "rgba(255, 255, 255, 0.98)", width: 7 }), + zIndex: 20, + }), + new Style({ + stroke: new Stroke({ color: "#f97316", width: 4 }), + zIndex: 21, + }), +]; + +const normalizePipeFid = (fid: unknown) => { + const value = String(fid ?? "").trim(); + const separatorIndex = value.lastIndexOf("."); + return separatorIndex >= 0 ? value.slice(separatorIndex + 1) : value; +}; + +const flattenLayerSettings = (items: LayerSetting[]): LayerSetting[] => + items.flatMap((item) => [ + item, + ...flattenLayerSettings(Array.isArray(item.children) ? item.children : []), + ]); + +const isPipeLayerSetting = (setting: LayerSetting) => + setting.lyrTyNm === "tb_ms_swage_ppl" && + setting.useYn === "Y" && + setting.actlLyrYn === "Y" && + setting.exprsYn !== "N"; + +const resolveStyle = ( + style: StyleLike | null | undefined, + feature: FeatureLike, + resolution: number, +) => (typeof style === "function" ? style(feature, resolution) : style); + +/** + * 공통 관망도와 같은 DB 레이어 설정으로 관로를 그리고, 이 화면이 소유한 마커와 + * 클릭 이벤트만 추가·정리한다. + */ +const LocationInteraction = ({ + value, + onChange, + interactive, + matchedPipe, + pipeLayerSettings, +}: Required> & + Omit & { + pipeLayerSettings: LayerSetting[]; + }) => { + const { gisMap, gisCore } = useGisContext() as GisContextValue; + const markerLayerRef = useRef | null>(null); + const pipeLayersRef = useRef< + Array<{ layer: VectorLayer; baseStyle: StyleLike | null }> + >([]); + const onChangeRef = useRef(onChange); + const previousValueRef = useRef(null); + const [pipeLoadState, setPipeLoadState] = useState< + "idle" | "loading" | "loaded" | "error" + >("idle"); + const [visiblePipeCount, setVisiblePipeCount] = useState(0); + + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + if (!gisMap) return; + const markerSource = new VectorSource(); + const markerLayer = new VectorLayer({ + source: markerSource, + style: interactive ? draftMarkerStyle : confirmedMarkerStyle, + zIndex: 999, + }); + markerLayer.set("dredgingLocationLayer", true); + gisMap.addLayer(markerLayer); + markerLayerRef.current = markerLayer; + + return () => { + gisMap.removeLayer(markerLayer); + markerLayerRef.current = null; + }; + }, [gisMap]); + + useEffect(() => { + markerLayerRef.current?.setStyle( + interactive ? draftMarkerStyle : confirmedMarkerStyle, + ); + }, [interactive]); + + useEffect(() => { + if (!gisMap || !interactive) return; + const clickKey = 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; + } + onChangeRef.current?.({ longitude, latitude }); + }); + return () => unByKey(clickKey); + }, [gisMap, interactive]); + + useEffect(() => { + if (!gisMap || !gisCore || pipeLayerSettings.length === 0) return; + setPipeLoadState("loading"); + let loadErrors = 0; + const sourceEventKeys: EventsKey[] = []; + const layers = pipeLayerSettings.map((setting, index) => { + const layer = gisCore.addWfsLayer({ + ...setting, + id: `dredging-pipe-${setting.lyrSn ?? setting.id ?? index}`, + clickPsblYn: "N", + }); + layer.set("dredgingPipeLayer", true); + const source = layer.getSource(); + const refreshCount = () => { + setVisiblePipeCount( + pipeLayersRef.current.reduce( + (count, item) => + count + (item.layer.getSource()?.getFeatures().length ?? 0), + 0, + ), + ); + setPipeLoadState(loadErrors > 0 ? "error" : "loaded"); + }; + if (source) { + sourceEventKeys.push( + source.on("featuresloadstart", () => + setPipeLoadState("loading"), + ) as EventsKey, + source.on("featuresloadend", refreshCount) as EventsKey, + source.on("featuresloaderror", () => { + loadErrors += 1; + refreshCount(); + }) as EventsKey, + ); + } + return { layer, baseStyle: layer.getStyle() ?? null }; + }); + pipeLayersRef.current = layers; + + return () => { + unByKey(sourceEventKeys); + layers.forEach(({ layer }) => gisMap.removeLayer(layer)); + pipeLayersRef.current = []; + setVisiblePipeCount(0); + setPipeLoadState("idle"); + }; + }, [gisCore, gisMap, pipeLayerSettings]); + + useEffect(() => { + const matchedFid = normalizePipeFid(matchedPipe?.fid); + pipeLayersRef.current.forEach(({ layer, baseStyle }) => { + const styleFunction: StyleFunction = (feature, resolution) => + matchedFid && normalizePipeFid(feature.getId()) === matchedFid + ? matchedPipeStyle + : (resolveStyle(baseStyle, feature, resolution) ?? undefined); + layer.setStyle(styleFunction); + layer.changed(); + }); + }, [matchedPipe?.fid, pipeLayerSettings]); + + useEffect(() => { + if (!gisMap || !markerLayerRef.current) return; + const source = markerLayerRef.current.getSource(); + source?.clear(); + if (!value) { + previousValueRef.current = null; + return; + } + + const coordinate = transform( + [value.longitude, value.latitude], + GisApp.FROM_PROJ, + GisApp.TO_PROJ, + ); + source?.addFeature(new Feature({ geometry: new Point(coordinate) })); + + const previous = previousValueRef.current; + const locationChanged = + !previous || + previous.longitude !== value.longitude || + previous.latitude !== value.latitude; + // 사진 GPS가 처음 들어오거나 등록 건을 바꾼 때만 이동한다. 위치수정 진입처럼 + // interactive 상태만 바뀐 경우에는 사용자가 보고 있던 지도 범위를 유지한다. + if (locationChanged && (!interactive || previous === null)) { + gisMap.getView().animate({ center: coordinate, zoom: 17, duration: 250 }); + } + previousValueRef.current = value; + }, [gisMap, interactive, value?.latitude, value?.longitude]); + + const pipeStatusText = (() => { + if (pipeLayerSettings.length === 0) + return "관망도 레이어 설정을 불러오는 중입니다."; + if (pipeLoadState === "loading") return "관망도 관로를 불러오는 중입니다."; + if (pipeLoadState === "error") + return visiblePipeCount > 0 + ? `일부 관로만 표시 중 · ${visiblePipeCount.toLocaleString()}건` + : "관망도 관로를 불러오지 못했습니다."; + if (pipeLoadState === "loaded") + return visiblePipeCount > 0 + ? `관망도 관로 ${visiblePipeCount.toLocaleString()}건 표시` + : "현재 지도 범위에 표시할 관로가 없습니다."; + return "지도를 확대하면 관망도 관로가 표시됩니다."; + })(); + + return ( +
+

+ {interactive + ? "지도를 클릭해 준설위치를 선택하세요." + : "등록 위치와 관망도 관로를 함께 확인합니다."} +

+
+ 관로 색상·굵기: 관망도 레이어 설정 + + + 자동 연결 관로 + + + {pipeStatusText} + +
+
+ ); +}; + +const DredgingLocationMap = ({ + value, + onChange, + interactive = true, + statusText, + matchedPipe, + height = "h-[540px]", +}: DredgingLocationMapProps) => { + const gisMaps = useRef(null); + const [pipeLayerSettings, setPipeLayerSettings] = useState( + [], + ); + const defaultLonLat = useMemo<[number, number]>( + () => (value ? [value.longitude, value.latitude] : [127.75, 36]), + // 지도 최초 생성 위치만 사용하며 이후 위치 변경은 marker effect가 담당한다. + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const body = await getRawJson( + "/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK", + ); + const rows = (body?.data ?? body ?? []) as LayerSetting[]; + if (!cancelled) { + setPipeLayerSettings( + flattenLayerSettings(rows).filter(isPipeLayerSetting), + ); + } + } catch (error) { + console.error("준설 등록 관망도 레이어 설정 조회 실패", error); + if (!cancelled) setPipeLayerSettings([]); + } + }; + void load(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+ undefined} + featureData={null} + setFeatureData={() => undefined} + isSearchModalOpen={false} + setIsSearchModalOpen={() => undefined} + searchBar={ + + } + defaultZoom={value ? 17 : 7.5} + defaultLonLat={defaultLonLat} + height={height} + showZoomLevel={true} + showFloatingSearchButton={false} + treeTogglePosition="top" + /> +
+ {statusText ?? + (interactive + ? "지도에서 준설위치를 선택해주세요." + : value + ? "등록된 준설위치가 표시되었습니다." + : "위치가 등록되지 않았습니다.")} +
+
+ ); +}; + +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 similarity index 53% rename from apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterModal.tsx rename to apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx index d9e2ad666..684707589 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx @@ -14,15 +14,15 @@ import { saveDredgingRegistrations, type AttachmentReference, uploadDredgingPhotos, -} from "../_lib/client"; -import { readPhotoMetadata } from "../_lib/exif"; +} from "../lib/client"; +import { readPhotoMetadata } from "../lib/exif"; import { EMPTY_PHOTO_METADATA, type DredgingDraftGroup, type DredgingPhotoDraft, type DredgingSaveGroup, type GeoPoint, -} from "../_lib/types"; +} from "../lib/types"; // 무거운 OpenLayers 지도 모듈은 준설 등록 본문과 별도 chunk로 지연 로드한다. const DredgingLocationMap = dynamic(() => import("./DredgingLocationMap"), { @@ -40,51 +40,21 @@ const POPUP_PRIMARY_BUTTON_CLASS = "min-w-[6.4rem] border !border-blue-600 !bg-blue-600 px-4 font-medium !text-white hover:!bg-blue-700"; const POPUP_SECONDARY_BUTTON_CLASS = "min-w-[6.4rem] border !border-blue-300 !bg-white px-4 font-medium !text-blue-700 hover:!bg-blue-50"; -const POPUP_DANGER_BUTTON_CLASS = - "min-w-[6.4rem] border !border-rose-300 !bg-white px-4 font-medium !text-rose-600 hover:!bg-rose-50"; 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 ( @@ -101,12 +71,6 @@ const statusLabel = (status: DredgingPhotoDraft["metadata"]["status"]) => { 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"); @@ -128,10 +92,11 @@ const DredgingRegisterModal = ({ >(null); // 지도 클릭 좌표는 적용 전까지 원본 EXIF 또는 확정 수동 위치를 덮어쓰지 않는 임시 상태다. const [locationDraft, setLocationDraft] = useState(null); + // 모든 등록 그룹은 하나의 준설작업이며 화면 상단의 단일 준설일자를 공유한다. + const [representativeDredgingDate, setRepresentativeDredgingDate] = + useState(""); const [isSaving, setIsSaving] = useState(false); - const uploadedAttachmentsRef = useRef( - new Map(), - ); + const uploadedAttachmentsRef = useRef(new Map()); const [statusMessage, setStatusMessage] = useState(""); const commitGroups = ( @@ -150,6 +115,7 @@ const DredgingRegisterModal = ({ setActiveGroupId(initial[0]?.id ?? null); setEditingLocationGroupId(null); setLocationDraft(null); + setRepresentativeDredgingDate(""); setStatusMessage(""); }, [open]); @@ -237,7 +203,6 @@ const DredgingRegisterModal = ({ ? null : "최근접 관로를 찾지 못했습니다.", isMatching: false, - lowConfidenceConfirmed: false, } : item, ), @@ -287,11 +252,9 @@ const DredgingRegisterModal = ({ ...group, photos: checkingPhotos, manualLocation: null, - manualDate: "", matchedPipe: null, matchError: null, isMatching: false, - lowConfidenceConfirmed: false, } : group, ), @@ -304,6 +267,15 @@ const DredgingRegisterModal = ({ })), ); + // 최초 사진의 촬영일을 공통 준설일자 초깃값으로만 사용한다. + // 이후 다른 사진을 추가하거나 탭을 바꿔도 사용자가 수정한 값은 덮어쓰지 않는다. + const firstPhotoDate = parsedPhotos.find( + (photo) => photo.metadata.dredgingDate, + )?.metadata.dredgingDate; + if (firstPhotoDate) { + setRepresentativeDredgingDate((current) => current || firstPhotoDate); + } + let nextGroup: DredgingDraftGroup | null = null; commitGroups((current) => current.map((group) => { @@ -332,6 +304,21 @@ const DredgingRegisterModal = ({ } }; + /** + * 공통 첨부영역의 중복 파일목록은 숨기고 메타정보 표를 단일 파일목록으로 사용한다. + * 표의 삭제 버튼도 공통 FileUpload와 같은 files 배열 갱신 흐름으로 연결한다. + */ + const removePhoto = (groupId: string, photoKey: string) => { + const group = groupsRef.current.find((item) => item.id === groupId); + if (!group) return; + void handleFilesChange( + groupId, + group.photos + .filter((photo) => photo.key !== photoKey) + .map((photo) => photo.file), + ); + }; + const changeRegistrationMode = ( groupId: string, registrationMode: DredgingDraftGroup["registrationMode"], @@ -343,13 +330,11 @@ const DredgingRegisterModal = ({ return; } const hasEnteredData = - currentGroup.photos.length > 0 || - currentGroup.manualLocation !== null || - currentGroup.manualDate !== ""; + currentGroup.photos.length > 0 || currentGroup.manualLocation !== null; if ( hasEnteredData && !window.confirm( - "등록 방식을 변경하면 현재 사진과 입력한 위치·일자가 초기화됩니다. 변경하시겠습니까?", + "등록 방식을 변경하면 현재 사진과 입력한 위치가 초기화됩니다. 변경하시겠습니까?", ) ) { return; @@ -365,8 +350,6 @@ const DredgingRegisterModal = ({ matchedPipe: null, matchError: null, manualLocation: null, - manualDate: "", - lowConfidenceConfirmed: false, } : group, ), @@ -376,14 +359,6 @@ const DredgingRegisterModal = ({ setEditingLocationGroupId(null); }; - const updateManualDate = (groupId: string, manualDate: string) => { - commitGroups((current) => - current.map((group) => - group.id === groupId ? { ...group, manualDate } : group, - ), - ); - }; - const updateManualLocation = async ( groupId: string, manualLocation: GeoPoint, @@ -398,7 +373,6 @@ const DredgingRegisterModal = ({ manualLocation, matchedPipe: null, matchError: null, - lowConfidenceConfirmed: false, }; commitGroups((current) => current.map((group) => (group.id === groupId ? nextGroup : group)), @@ -438,6 +412,13 @@ const DredgingRegisterModal = ({ }; const validateAndBuildSaveGroups = () => { + if (!representativeDredgingDate) { + throw new Error("준설일자를 입력해주세요."); + } + if (representativeDredgingDate > formatLocalDate(new Date())) { + throw new Error("미래 날짜는 준설일자로 등록할 수 없습니다."); + } + const saveGroups: DredgingSaveGroup[] = []; for (let index = 0; index < groups.length; index += 1) { const group = groups[index]!; @@ -449,21 +430,6 @@ const DredgingRegisterModal = ({ 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}: 지도에서 준설위치를 등록해주세요.`); @@ -473,22 +439,14 @@ const DredgingRegisterModal = ({ `${label}: ${group.matchError ?? "최근접 관로 자동 매핑을 완료해주세요."}`, ); } - if ( - group.matchedPipe.confidence === "LOW" && - !group.lowConfidenceConfirmed - ) { - throw new Error(`${label}: 낮은 신뢰도 관로 후보를 확인해주세요.`); - } - saveGroups.push({ localId: group.id, registrationMode: group.registrationMode, - dredgingDate, + dredgingDate: representativeDredgingDate, location, locationSource: group.manualLocation ? "MANUAL" : "EXIF", pipe: group.matchedPipe, photos: group.photos, - lowConfidenceConfirmed: group.lowConfidenceConfirmed, }); } return saveGroups; @@ -533,23 +491,18 @@ const DredgingRegisterModal = ({ const activeGroup = groups.find((group) => group.id === activeGroupId) ?? groups[0] ?? null; - const activeResolvedDate = activeGroup ? getResolvedDate(activeGroup) : null; const activeResolvedLocation = activeGroup ? getResolvedLocation(activeGroup) : null; - const activeManualDateRequired = activeGroup - ? needsManualDate(activeGroup) - : false; const isEditingActiveLocation = - activeGroup !== null && - editingLocationGroupId === activeGroup.id; + activeGroup !== null && editingLocationGroupId === activeGroup.id; // 지도에서 새로 찍은 좌표가 아직 확정 위치와 다른지 구분해 이전 좌표의 관로 오류를 노출하지 않는다. const hasUnappliedLocationDraft = Boolean( isEditingActiveLocation && - locationDraft && - (!activeResolvedLocation || - locationDraft.longitude !== activeResolvedLocation.longitude || - locationDraft.latitude !== activeResolvedLocation.latitude), + locationDraft && + (!activeResolvedLocation || + locationDraft.longitude !== activeResolvedLocation.longitude || + locationDraft.latitude !== activeResolvedLocation.latitude), ); // 새 관로 등록 건을 만든 뒤 즉시 해당 건을 편집 대상으로 전환한다. @@ -560,14 +513,23 @@ const DredgingRegisterModal = ({ cancelLocationEditing(); }; - // 현재 등록 건을 삭제하고 남은 첫 번째 건으로 안전하게 이동한다. - const removeActiveGroup = () => { - if (!activeGroup || groupsRef.current.length <= 1) return; + // 첫 번째 등록 건은 기준 건으로 유지하고 두 번째 건부터 탭의 X로 삭제한다. + const removeGroup = (groupId: string) => { + const removeIndex = groupsRef.current.findIndex( + (group) => group.id === groupId, + ); + if (removeIndex <= 0) return; const remainingGroups = groupsRef.current.filter( - (group) => group.id !== activeGroup.id, + (group) => group.id !== groupId, ); commitGroups(() => remainingGroups); - setActiveGroupId(remainingGroups[0]?.id ?? null); + if (activeGroupId === groupId) { + setActiveGroupId( + remainingGroups[Math.max(0, removeIndex - 1)]?.id ?? + remainingGroups[0]?.id ?? + null, + ); + } cancelLocationEditing(); }; @@ -581,83 +543,130 @@ const DredgingRegisterModal = ({
-
+
등록 안내 관로별 사진은 최대 5장까지 등록할 수 있습니다. - 촬영일자가 준설일자가 되며, 누락된 정보만 직접 보완합니다. -
- -
-
- {groups.map((group, groupIndex) => ( - - ))} -
- + + 모든 등록 건은 하나의 준설작업이며 동일한 준설일자를 사용합니다. +
{activeGroup ? ( -
-
group.id === activeGroup.id) + 1 - }`} - > -
-
-

- 사진 및 준설정보 -

-

- 사진 메타정보를 먼저 확인하고 누락된 항목만 보완합니다. -

-
+
group.id === activeGroup.id) + 1 + }`} + > +
- {activeGroup.registrationMode === "PHOTO" ? ( - <> - photo.file)} - setFiles={(files) => { - void handleFilesChange(activeGroup.id, files); - }} - fileTypes=".jpg,.jpeg,.png" - maxFiles={5} - /> + {activeGroup.registrationMode === "PHOTO" ? ( + <> +
+ photo.file)} + setFiles={(files) => { + void handleFilesChange(activeGroup.id, files); + }} + fileTypes=".jpg,.jpeg,.png" + maxFiles={5} + /> +
- {activeGroup.photos.length > 0 ? ( -
- - - - - - - - - - - - {activeGroup.photos.map((photo) => ( - - - - - - + {activeGroup.photos.length > 0 ? ( +
+
사진촬영일시준설일자위치정보상태
- {photo.file.name} - - {photo.metadata.capturedAt ?? "-"} - - {photo.metadata.dredgingDate ?? "직접 입력 필요"} - - {photo.metadata.location ? ( - - GPS 확인됨 - - ) : ( - - 지도 지정 필요 - - )} - - - {statusLabel(photo.metadata.status)} - -
+ + + + + + + - ))} - -
사진촬영일시위치상태 + 삭제 +
-
- ) : ( -
- 준설사진을 선택하면 촬영일과 위치정보를 자동으로 확인합니다. -
- )} - - ) : ( -
- -

- 사진 없이 준설일자와 지도 위치를 직접 등록합니다. -

-

- 아래 위치등록 버튼으로 지도 레이어를 열어 위치를 선택해주세요. -

-
- )} - -
-
- - {activeManualDateRequired ? ( - - updateManualDate(activeGroup.id, event.target.value) - } - className="w-full" - /> - ) : ( - - {activeResolvedDate ?? "촬영일자 확인 필요"} - {activeResolvedDate ? ( - - 사진 촬영일 - - ) : null} - - )} -
-
- - - {activeGroup.manualLocation - ? "지도에서 지정한 위치" - : activeResolvedLocation - ? "사진 GPS 위치" - : "지도에서 위치 지정 필요"} - -
-
-
-
-
-

- 위치 및 관로 확인 -

-

- 아래 지도에서 위치를 확인하고, 위치 적용 후 최근접 관로를 - 자동으로 연결합니다. -

+ + + {activeGroup.photos.map((photo) => ( + + + {photo.file.name} + + + {photo.metadata.capturedAt ?? "-"} + + + {photo.metadata.location ? ( + + GPS + + ) : ( + + 지정 필요 + + )} + + + + {statusLabel(photo.metadata.status)} + + + + + + + ))} + + +
+ ) : ( +
+ 준설사진을 선택하면 촬영일과 위치정보를 자동으로 + 확인합니다. +
+ )} + + ) : ( +
+ 사진 없이 아래 지도에서 준설위치를 직접 지정합니다.
-
+ )} +
+ +
+
+

+ 위치 및 관로 +

+
{isEditingActiveLocation ? ( <> ) : ( @@ -878,137 +833,101 @@ const DredgingRegisterModal = ({ className={POPUP_PRIMARY_BUTTON_CLASS} onClick={() => startLocationEditing(activeGroup)} > - {activeResolvedLocation ? "위치 수정" : "위치등록"} + {activeResolvedLocation ? "위치 수정" : "위치 지정"} )}
-
-
-
- +
+ + {hasUnappliedLocationDraft + ? "새 위치 선택됨" + : activeGroup.manualLocation + ? "지도 지정 위치" + : activeResolvedLocation + ? "사진 GPS 위치" + : "위치 지정 필요"} + + +
+
+ + 자동 연결 관로 ·{" "} + {activeGroup.isMatching + ? "탐색 중" + : hasUnappliedLocationDraft + ? "재탐색 필요" + : activeGroup.matchedPipe + ? activeGroup.matchedPipe.fcltMngNo + : "미연결"} + - {activeGroup.manualLocation - ? "수동 지정" - : activeResolvedLocation - ? "사진 GPS" - : "위치 미지정"} + {hasUnappliedLocationDraft + ? "선택 위치를 적용하면 최근접 관로를 다시 찾습니다." + : activeGroup.matchedPipe + ? `${activeGroup.matchedPipe.distanceMeters.toFixed(2)}m · ${activeGroup.matchedPipe.swtrmtZoneNm || "처리구역 확인불가"}` + : activeGroup.matchError || + "위치를 지정하면 50m 이내 최근접 관로를 자동으로 연결합니다."}
-

- {activeResolvedLocation - ? "위치가 등록되었습니다. 아래 지도에서 실제 위치를 확인할 수 있습니다." - : "사진에 GPS가 없거나 직접 등록하는 경우 지도에서 위치를 선택해주세요."} -

-
- -
-
- - {activeGroup.isMatching ? ( - 탐색 중... - ) : null} -
- {hasUnappliedLocationDraft ? ( -

- 새 위치를 선택했습니다. 상단의 ‘이 위치로 관로 다시 찾기’를 - 눌러 새 좌표를 적용해주세요. -

- ) : activeGroup.matchedPipe ? ( -
-
관로번호
-
- {activeGroup.matchedPipe.fcltMngNo} -
-
관리기관
-
{activeGroup.matchedPipe.mngInstCd}
-
최근접 거리
-
- {activeGroup.matchedPipe.distanceMeters.toFixed(2)}m -
-
신뢰도
-
- {confidenceLabel[activeGroup.matchedPipe.confidence]} -
-
- ) : ( -

- 위치를 확정하면 최근접 관로를 자동으로 찾습니다. -

- )} - {!hasUnappliedLocationDraft && activeGroup.matchError ? ( -

- {activeGroup.matchError} -

- ) : null} - {activeGroup.matchedPipe?.confidence === "LOW" ? ( - - ) : null}
+
-
- -
+
+ {statusMessage || + "사진·준설일자·위치와 자동 연결 관로를 확인해주세요."} +
+ + +
+
+
) : null} - -
- {statusMessage || "사진·준설일자·위치와 자동 연결 관로를 확인해주세요."} -
); @@ -1032,7 +951,7 @@ const DredgingRegisterModal = ({ {isSaving ? "저장 중..." : "저장"} -
+
{modalBody}
diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterPopupPage.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterPopupPage.tsx similarity index 100% rename from apps/sn/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterPopupPage.tsx rename to apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterPopupPage.tsx 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 similarity index 52% rename from apps/sn/app/(with-base-layout)/oper/dredging/_lib/client.ts rename to apps/sn/app/(with-base-layout)/oper/dredging/lib/client.ts index 84fd86f61..961f3f73a 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/_lib/client.ts +++ b/apps/sn/app/(with-base-layout)/oper/dredging/lib/client.ts @@ -1,12 +1,20 @@ -import { getRawJson, postRawJson } from "@/components/lib/api"; +import { + deleteData, + getBlobData, + getRawJson, + postRawJson, + putData, +} from "@/components/lib/api"; import { handleUpload } from "@/components/lib/utils"; import type { HttpResponse } from "tus-js-client"; import type { DredgingSaveGroup, + DredgingRecordUpdate, + DredgingPhotoDraft, + DredgingPhoto, DredgingRecord, GeoPoint, - MatchConfidence, PipeMatch, } from "./types"; @@ -21,12 +29,7 @@ export type AttachmentReference = { type NearestPipeResponseItem = { locationKey: string; - failureCode?: - | "PIPE_NOT_FOUND" - | "PIPE_ASSET_NOT_MAPPED" - | "PIPE_ASSET_AMBIGUOUS" - | string - | null; + failureCode?: "PIPE_NOT_FOUND" | string | null; failureMessage?: string | null; selected?: { fid: string; @@ -36,10 +39,11 @@ type NearestPipeResponseItem = { pipeLengthM?: number; pipeMaterial?: string; installationYear?: number | null; + swtrmtZoneFid?: string | null; + swtrmtZoneNm?: string | null; distanceMeters: number; snappedLongitude: number; snappedLatitude: number; - confidence: MatchConfidence; } | null; }; @@ -47,10 +51,6 @@ const nearestPipeFailureMessage = (item: NearestPipeResponseItem) => { switch (item.failureCode) { case "PIPE_NOT_FOUND": return "50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요."; - case "PIPE_ASSET_NOT_MAPPED": - return "가장 가까운 관로는 찾았지만 자산 매핑 정보가 없습니다. 관로 자산 매핑 데이터를 확인해주세요."; - case "PIPE_ASSET_AMBIGUOUS": - return "가장 가까운 관로의 자산 매핑이 둘 이상입니다. 관로 자산 매핑 데이터를 확인해주세요."; default: return ( item.failureMessage?.trim() ?? @@ -62,21 +62,24 @@ const nearestPipeFailureMessage = (item: NearestPipeResponseItem) => { export const isDredgingBackendEnabled = process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED !== "false"; -const toDredgingApiError = (error: unknown, fallbackMessage: string) => { - if (!(error instanceof Error)) return new Error(fallbackMessage); - - // 프록시/서버 오류가 HTML 또는 plain text로 반환되면 공통 JSON 파서의 - // SyntaxError를 그대로 노출하지 않고 사용자가 이해할 수 있는 문구로 바꾼다. - if ( - error instanceof SyntaxError || - /Unexpected token|Internal Server Error|Failed to (fetch|post) data/i.test( - error.message, - ) - ) { - return new Error(fallbackMessage); - } - - return error; +/** + * 백엔드·프록시의 상태코드, HTML 오류 본문과 파싱 문구를 화면에 전달하지 않는다. + * 상세 응답은 브라우저 Network에서 확인하고 화면에는 업무별 재시도 안내만 노출한다. + */ +const toDredgingApiError = ( + error: unknown, + fallbackMessage: string, + operation: string, +) => { + const status = + typeof error === "object" && error !== null && "status" in error + ? (error as { status?: unknown }).status + : undefined; + console.warn(`[준설관리] ${operation} 실패`, { + status: typeof status === "number" ? status : undefined, + errorType: error instanceof Error ? error.name : typeof error, + }); + return new Error(fallbackMessage); }; type DredgingRecordListResponse = { @@ -90,6 +93,8 @@ type DredgingRecordListResponse = { export const fetchDredgingRecords = async (params: { fromDate: string; toDate: string; + ctpv?: string; + sgg?: string; page?: number; size?: number; }): Promise => { @@ -102,8 +107,119 @@ export const fetchDredgingRecords = async (params: { page: String(params.page ?? 0), size: String(params.size ?? 2000), }); - const body = await getRawJson(`v1/dredging/records?${search.toString()}`); - return (body?.data ?? body) as DredgingRecordListResponse; + if (params.ctpv?.trim()) search.set("ctpv", params.ctpv.trim()); + if (params.sgg?.trim()) search.set("sgg", params.sgg.trim()); + try { + const body = await getRawJson(`v1/dredging/records?${search.toString()}`); + return (body?.data ?? body) as DredgingRecordListResponse; + } catch (error) { + throw toDredgingApiError( + error, + "준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.", + "준설현황 조회", + ); + } +}; + +/** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */ +export const fetchDredgingPhotos = async ( + dredgingJobSn: string | number, + pipeFid: string, +): Promise => { + if (!isDredgingBackendEnabled) return []; + try { + const body = await getRawJson( + `v1/dredging/records/${encodeURIComponent(dredgingJobSn)}/photos?fid=${encodeURIComponent(pipeFid)}`, + ); + return (body?.data ?? body ?? []) as DredgingPhoto[]; + } catch (error) { + throw toDredgingApiError( + error, + "등록된 준설사진을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.", + "준설사진 목록 조회", + ); + } +}; + +/** 상세 기반 수정 팝업에서 선택 관로의 일자·위치·사진 구성을 함께 수정한다. */ +export const updateDredgingRecord = async ( + dredgingJobSn: string | number, + update: DredgingRecordUpdate, + attachments: Map, +) => { + if (!isDredgingBackendEnabled) return true; + try { + await putData(`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, { + dredgingDate: update.dredgingDate, + pipeFid: update.originalPipeFid, + location: { + source: update.locationSource, + longitude: update.location.longitude, + latitude: update.location.latitude, + }, + expectedPipe: { + fid: update.pipe.fid, + mngInstCd: update.pipe.mngInstCd, + fcltMngNo: update.pipe.fcltMngNo, + }, + retainedPhotoSns: update.retainedPhotoSns, + newPhotos: update.newPhotos.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, + }; + }), + }); + return true; + } catch (error) { + throw toDredgingApiError( + error, + "준설정보를 수정하지 못했습니다. 잠시 후 다시 시도해주세요.", + "준설작업 수정", + ); + } +}; + +/** 목록에서 선택한 준설작업과 관로·사진 참조를 모두 물리 삭제한다. */ +export const deleteDredgingRecord = async (dredgingJobSn: string | number) => { + if (!isDredgingBackendEnabled) return true; + try { + const response = await deleteData( + `v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, + ); + if (response instanceof Response && !response.ok) { + throw new Error(`준설작업 삭제 응답 오류: ${response.status}`); + } + return true; + } catch (error) { + throw toDredgingApiError( + error, + "준설정보를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.", + "준설작업 삭제", + ); + } +}; + +/** 공통 첨부서비스에서 준설사진 원본을 Blob으로 내려받는다. */ +export const fetchDredgingPhotoBlob = async (atflId: string) => { + try { + return await getBlobData( + `/attach-service/api/v1/download/${encodeURIComponent(atflId)}`, + ); + } catch (error) { + throw toDredgingApiError( + error, + "사진 원본을 불러오지 못했습니다.", + "준설사진 원본 조회", + ); + } }; const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => { @@ -123,10 +239,11 @@ const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => { pipeLengthM: Number((((seed % 8_000) + 2_000) / 100).toFixed(2)), pipeMaterial: ["콘크리트", "PVC", "PE"][seed % 3] ?? "콘크리트", installationYear: 1995 + (seed % 25), + swtrmtZoneFid: "MOCK-ZONE", + swtrmtZoneNm: "모의처리구역", distanceMeters, snappedLongitude: longitude + 0.00001, snappedLatitude: latitude - 0.00001, - confidence: distanceMeters <= 10 ? "HIGH" : "MEDIUM", isMock: true, }; }; @@ -154,6 +271,7 @@ export const findNearestPipeMatches = async ( throw toDredgingApiError( error, "최근접 관로를 조회하지 못했습니다. 잠시 후 다시 시도해주세요.", + "최근접 관로 조회", ); } const items = (body?.data ?? body) as NearestPipeResponseItem[]; @@ -173,10 +291,11 @@ export const findNearestPipeMatches = async ( pipeLengthM: item.selected.pipeLengthM ?? 0, pipeMaterial: item.selected.pipeMaterial ?? "", installationYear: item.selected.installationYear ?? null, + swtrmtZoneFid: item.selected.swtrmtZoneFid ?? null, + swtrmtZoneNm: item.selected.swtrmtZoneNm ?? "", distanceMeters: item.selected.distanceMeters, snappedLongitude: item.selected.snappedLongitude, snappedLatitude: item.selected.snappedLatitude, - confidence: item.selected.confidence, isMock: false, }); } @@ -229,16 +348,33 @@ const uploadOnePhoto = (file: File): Promise => export const uploadDredgingPhotos = async ( groups: DredgingSaveGroup[], existingReferences: Map = new Map(), +) => { + return uploadDredgingPhotoDrafts( + groups.flatMap((group) => group.photos), + existingReferences, + ); +}; + +/** 등록·수정 화면이 공통으로 사용하는 신규 준설사진 첨부 업로드이다. */ +export const uploadDredgingPhotoDrafts = async ( + photos: DredgingPhotoDraft[], + existingReferences: Map = new Map(), ) => { const references = new Map(existingReferences); if (!isDredgingBackendEnabled) return references; - for (const group of groups) { - for (const photo of group.photos) { + try { + for (const photo of photos) { // DB 저장만 실패한 재시도에서는 이미 성공한 공통 첨부 업로드를 재사용한다. if (references.has(photo.key)) continue; references.set(photo.key, await uploadOnePhoto(photo.file)); } + } catch (error) { + throw toDredgingApiError( + error, + "준설사진을 업로드하지 못했습니다. 잠시 후 다시 시도해주세요.", + "준설사진 업로드", + ); } return references; }; @@ -250,42 +386,49 @@ export const saveDredgingRegistrations = async ( if (!isDredgingBackendEnabled) { return { mock: true, - registeredCount: groups.length, + registeredCount: 1, 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, - }; - }), - })), - }); + try { + return await postRawJson("v1/dredging/registrations", { + clientRequestId, + dredgingDate: groups[0]?.dredgingDate, + groups: groups.map((group, groupIndex) => ({ + groupSn: groupIndex + 1, + registrationMode: group.registrationMode, + 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, + }, + 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, + }; + }), + })), + }); + } catch (error) { + throw toDredgingApiError( + error, + "준설결과를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.", + "준설결과 저장", + ); + } }; 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 similarity index 100% rename from apps/sn/app/(with-base-layout)/oper/dredging/_lib/exif.ts rename to apps/sn/app/(with-base-layout)/oper/dredging/lib/exif.ts 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 similarity index 74% rename from apps/sn/app/(with-base-layout)/oper/dredging/_lib/types.ts rename to apps/sn/app/(with-base-layout)/oper/dredging/lib/types.ts index ec9571950..c36d61fb0 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/_lib/types.ts +++ b/apps/sn/app/(with-base-layout)/oper/dredging/lib/types.ts @@ -11,8 +11,6 @@ export type ExifStatus = | "MISSING_BOTH" | "UNSUPPORTED"; -export type MatchConfidence = "HIGH" | "MEDIUM" | "LOW"; - export type GeoPoint = { longitude: number; latitude: number; @@ -40,10 +38,11 @@ export type PipeMatch = { pipeLengthM: number; pipeMaterial: string; installationYear: number | null; + swtrmtZoneFid: string | null; + swtrmtZoneNm: string; distanceMeters: number; snappedLongitude: number; snappedLatitude: number; - confidence: MatchConfidence; isMock: boolean; }; @@ -52,15 +51,16 @@ export type DredgingDraftGroup = { registrationMode: DredgingRegistrationMode; photos: DredgingPhotoDraft[]; manualLocation: GeoPoint | null; - manualDate: string; matchedPipe: PipeMatch | null; matchError: string | null; isMatching: boolean; - lowConfidenceConfirmed: boolean; }; export type DredgingRecord = { id: string; + dredgingJobSn: number | string; + pipeFid: string; + mngInstCd: string; ctpv: string; sgg: string; treatmentZone: string; @@ -74,12 +74,22 @@ export type DredgingRecord = { registeredDate: string; longitude: number; latitude: number; + snappedLongitude: number; + snappedLatitude: number; registrationSource: DredgingLocationSource; matchDistanceMeters: number; - matchConfidence: MatchConfidence; isMock: boolean; }; +export type DredgingPhoto = { + photoSn: number; + pipeFid: string; + atflCd: string; + atflId: string; + originalFileName: string; + capturedAt: string | null; +}; + export type DredgingSaveGroup = { localId: string; registrationMode: DredgingRegistrationMode; @@ -88,7 +98,17 @@ export type DredgingSaveGroup = { locationSource: DredgingLocationSource; pipe: PipeMatch; photos: DredgingPhotoDraft[]; - lowConfidenceConfirmed: boolean; +}; + +/** 상세 기반 수정 팝업에서 선택한 한 관로의 위치·사진을 함께 갱신한다. */ +export type DredgingRecordUpdate = { + dredgingDate: string; + originalPipeFid: string; + location: GeoPoint; + locationSource: DredgingLocationSource; + pipe: PipeMatch; + retainedPhotoSns: number[]; + newPhotos: DredgingPhotoDraft[]; }; export const EMPTY_PHOTO_METADATA: PhotoMetadata = { diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx index aa0a80ae5..015731b21 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx @@ -7,35 +7,47 @@ */ "use client"; -import dynamic from "next/dynamic"; +import { SelectBox } from "@common/business_components/ui"; import { SearchDiv } from "@common/business_components/ui/search_form"; -import { UiModal } from "@common/business_components/ui/dialog_ui"; import { UiTable } from "@common/business_components/ui/table"; import ListDiv from "@common/business_components/ui/list_div"; import { Button } from "@common/components/ui/button"; import { Input } from "@common/components/ui/input"; import { Label } from "@common/components/ui/label"; +import { MS_SWAGE_PPL_CODES } from "@/app/(map-layout)/map/components/common/table"; import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import useStore from "@/store/useStore"; import { useCallback, useEffect, useMemo, useState } from "react"; import { + deleteDredgingRecord, fetchDredgingRecords, isDredgingBackendEnabled, -} from "./_lib/client"; +} from "./lib/client"; import type { DredgingRecord, DredgingSaveGroup, DredgingViewType, -} from "./_lib/types"; - -// OpenLayers 지도는 사용자가 지도 팝업을 열 때만 컴파일해 목록의 최초 로딩을 가볍게 유지한다. -const DredgingLocationMap = dynamic( - () => import("./_components/DredgingLocationMap"), - { ssr: false }, -); +} from "./lib/types"; const PAGE_SIZE = 10; +const ALL_REGION_OPTION = { text: "전체", val: "0" }; +const pipeMaterialNameByCode = new Map( + MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]), +); + +const resolvePipeMaterialName = (value: string) => { + const normalizedValue = value.trim(); + if (!normalizedValue) return "확인불가"; + const normalizedCode = normalizedValue.toUpperCase(); + if (!/^MOP\d+$/.test(normalizedCode)) return normalizedValue; + return pipeMaterialNameByCode.get(normalizedCode) ?? "확인불가"; +}; + +// 백엔드·프록시 상세 오류 대신 사용자가 다음 행동을 알 수 있는 문구만 표시한다. +const DREDGING_LOAD_ERROR_MESSAGE = + "준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."; const currentYear = new Date().getFullYear(); const ACTION_BUTTON_CLASS = "rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white"; @@ -50,6 +62,9 @@ const formatLocalDate = (date: Date) => { const initialRecords: DredgingRecord[] = [ { id: "mock-dredging-1", + dredgingJobSn: "mock-dredging-job-1", + pipeFid: "MOCK-PIPE-1", + mngInstCd: "4173000", ctpv: "경기도", sgg: "양평군", treatmentZone: "양평처리구역", @@ -63,13 +78,17 @@ const initialRecords: DredgingRecord[] = [ registeredDate: `${currentYear}-05-15`, longitude: 127.4875, latitude: 37.4916, + snappedLongitude: 127.48751, + snappedLatitude: 37.49159, registrationSource: "EXIF", matchDistanceMeters: 4.28, - matchConfidence: "HIGH", isMock: true, }, { id: "mock-dredging-2", + dredgingJobSn: "mock-dredging-job-2", + pipeFid: "MOCK-PIPE-2", + mngInstCd: "1153000", ctpv: "서울특별시", sgg: "구로구", treatmentZone: "서남처리구역", @@ -83,13 +102,41 @@ const initialRecords: DredgingRecord[] = [ registeredDate: `${currentYear - 1}-11-09`, longitude: 126.8874, latitude: 37.4954, + snappedLongitude: 126.88741, + snappedLatitude: 37.49539, registrationSource: "MANUAL", matchDistanceMeters: 8.15, - matchConfidence: "HIGH", isMock: true, }, ]; +/** 상세·수정 팝업이 동일한 관로·위치 정보를 받도록 쿼리 파라미터를 한곳에서 구성한다. */ +const buildDredgingPopupParams = (record: DredgingRecord) => ({ + sourcePath: window.location.pathname, + id: record.id, + dredgingJobSn: record.dredgingJobSn, + pipeFid: record.pipeFid, + mngInstCd: record.mngInstCd, + ctpv: record.ctpv, + sgg: record.sgg, + treatmentZone: record.treatmentZone, + fcltMngNo: record.fcltMngNo, + pipeDiameterMm: record.pipeDiameterMm, + pipeLengthM: record.pipeLengthM, + pipeMaterial: record.pipeMaterial, + installationYear: record.installationYear, + dredgingDate: record.dredgingDate, + photoCount: record.photoCount, + registeredDate: record.registeredDate, + longitude: record.longitude, + latitude: record.latitude, + snappedLongitude: record.snappedLongitude, + snappedLatitude: record.snappedLatitude, + registrationSource: record.registrationSource, + matchDistanceMeters: record.matchDistanceMeters, + isMock: record.isMock, +}); + type TableRow = Record & { recordIds: string[]; }; @@ -220,10 +267,21 @@ const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] => const Page = () => { const today = formatLocalDate(new Date()); + const { + ctpv: ctpvRegionOptions, + sgg: sggRegionOptions, + fetchCtpv, + fetchSgg, + } = useStore(); const [fromDate, setFromDate] = useState(`${currentYear}-01-01`); const [toDate, setToDate] = useState(today); - const [ctpv, setCtpv] = useState("전체"); - const [sgg, setSgg] = useState("전체"); + // 선택 UI는 행정구역 코드를, 준설 조회 API는 행정구역 명칭을 사용한다. + const [ctpvCd, setCtpvCd] = useState("0"); + const [sggCd, setSggCd] = useState("0"); + const [ctpvName, setCtpvName] = useState(""); + const [sggName, setSggName] = useState(""); + const [appliedCtpvName, setAppliedCtpvName] = useState(""); + const [appliedSggName, setAppliedSggName] = useState(""); const [viewType, setViewType] = useState("MUNICIPALITY"); const [records, setRecords] = useState( isDredgingBackendEnabled ? [] : initialRecords, @@ -231,10 +289,26 @@ const Page = () => { const [isLoading, setIsLoading] = useState(isDredgingBackendEnabled); const [loadError, setLoadError] = useState(null); const [selectedRecordId, setSelectedRecordId] = useState(null); - const [isMapOpen, setIsMapOpen] = useState(false); + // 삭제 중 중복 요청과 선택 변경을 막는다. + const [isMutating, setIsMutating] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [searchVersion, setSearchVersion] = useState(0); + /** 공통 레이아웃에서 아직 지역 기준정보를 받지 못한 경우 조회를 보완한다. */ + useEffect(() => { + if (ctpvRegionOptions.length === 0) void fetchCtpv(); + if (sggRegionOptions.length === 0) void fetchSgg(); + }, [ctpvRegionOptions.length, fetchCtpv, fetchSgg, sggRegionOptions.length]); + + /** 선택한 시도 코드와 앞 두 자리가 같은 시군구만 후보로 제공한다. */ + const availableSggOptions = useMemo(() => { + if (ctpvCd === "0") return sggRegionOptions; + const ctpvPrefix = ctpvCd.slice(0, 2); + return sggRegionOptions.filter((option) => + String(option.val).startsWith(ctpvPrefix), + ); + }, [ctpvCd, sggRegionOptions]); + // 조회 버튼을 누른 시점의 기간으로 실제 준설관리 API를 호출한다. useEffect(() => { if (!isDredgingBackendEnabled) return; @@ -246,17 +320,15 @@ const Page = () => { const response = await fetchDredgingRecords({ fromDate, toDate, + ctpv: appliedCtpvName, + sgg: appliedSggName, size: 2000, }); if (!cancelled) setRecords(response.items ?? []); - } catch (error) { + } catch { if (!cancelled) { setRecords([]); - setLoadError( - error instanceof Error - ? error.message - : "준설현황을 조회하지 못했습니다.", - ); + setLoadError(DREDGING_LOAD_ERROR_MESSAGE); } } finally { if (!cancelled) setIsLoading(false); @@ -276,10 +348,10 @@ const Page = () => { (record) => record.dredgingDate >= fromDate && record.dredgingDate <= toDate && - (ctpv === "전체" || record.ctpv === ctpv) && - (sgg === "전체" || record.sgg === sgg), + (!appliedCtpvName || record.ctpv === appliedCtpvName) && + (!appliedSggName || record.sgg === appliedSggName), ), - [records, fromDate, toDate, ctpv, sgg, searchVersion], + [records, fromDate, toDate, appliedCtpvName, appliedSggName, searchVersion], ); const tableRows = useMemo(() => { @@ -291,6 +363,7 @@ const Page = () => { rowNumber: index + 1, ...record, pipeLengthM: Number(record.pipeLengthM.toFixed(2)), + pipeMaterial: resolvePipeMaterialName(record.pipeMaterial), installationYear: record.installationYear ?? "확인불가", elapsedYears: record.installationYear === null @@ -308,27 +381,16 @@ const Page = () => { 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 selectedJobRecords = selectedRecord + ? records.filter( + (record) => record.dredgingJobSn === selectedRecord.dredgingJobSn, + ) + : []; + const canManageSelectedRecord = + viewType === "MUNICIPALITY" && selectedRecord !== null && !isMutating; + // 관경별·경과년도별 행은 여러 관로를 합산한 집계 행이므로 단일 관로 상세를 열지 않는다. + const canViewSelectedRecord = + viewType === "MUNICIPALITY" && selectedRecord !== null; const headlist = viewType === "MUNICIPALITY" @@ -351,6 +413,8 @@ const Page = () => { } setCurrentPage(1); setSelectedRecordId(null); + setAppliedCtpvName(ctpvName); + setAppliedSggName(sggName); setSearchVersion((version) => version + 1); }; @@ -390,11 +454,15 @@ const Page = () => { return; } const registeredDate = formatLocalDate(new Date()); - const addedRecords = savedGroups.map((group) => ({ - id: `local-${crypto.randomUUID()}`, - ctpv: ctpv === "전체" ? "경기도" : ctpv, - sgg: sgg === "전체" ? "양평군" : sgg, - treatmentZone: "양평처리구역", + const localJobSn = `local-job-${crypto.randomUUID()}`; + const addedRecords = savedGroups.map((group, index) => ({ + id: `${localJobSn}-${index + 1}`, + dredgingJobSn: localJobSn, + pipeFid: group.pipe.fid, + mngInstCd: group.pipe.mngInstCd, + ctpv: ctpvName || "경기도", + sgg: sggName || "양평군", + treatmentZone: group.pipe.swtrmtZoneNm || "확인불가", fcltMngNo: group.pipe.fcltMngNo, pipeDiameterMm: group.pipe.pipeDiameterMm, pipeLengthM: group.pipe.pipeLengthM, @@ -405,26 +473,73 @@ const Page = () => { registeredDate, longitude: group.location.longitude, latitude: group.location.latitude, + snappedLongitude: group.pipe.snappedLongitude, + snappedLatitude: group.pipe.snappedLatitude, registrationSource: group.locationSource, matchDistanceMeters: group.pipe.distanceMeters, - matchConfidence: group.pipe.confidence, isMock: group.pipe.isMock || !isDredgingBackendEnabled, })); setRecords((current) => [...addedRecords, ...current]); setCurrentPage(1); }, - [ctpv, sgg], + [ctpvName, sggName], ); + /** 상세 화면과 같은 최대화 팝업에서 준설일자·사진·위치를 함께 수정한다. */ + const openEditRecord = () => { + if (!canManageSelectedRecord || !selectedRecord) return; + + const popup = openWindowPopup( + "dredgingEdit", + buildDredgingPopupParams(selectedRecord), + { maximize: true }, + ); + if (!popup) { + alert( + "준설정보 수정 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.", + ); + } + }; + + /** 선택 행이 속한 준설작업과 연결 관로·사진 참조를 물리 삭제하고 목록을 재조회한다. */ + const handleDeleteRecord = async () => { + if (!canManageSelectedRecord || !selectedRecord) return; + const confirmed = window.confirm( + `선택한 준설작업 전체를 삭제하시겠습니까?\n준설일자: ${selectedRecord.dredgingDate}\n연결 관로: ${selectedJobRecords.length}건 · 등록사진: ${selectedRecord.photoCount}건`, + ); + if (!confirmed) return; + + setIsMutating(true); + try { + await deleteDredgingRecord(selectedRecord.dredgingJobSn); + if (isDredgingBackendEnabled) { + setSearchVersion((version) => version + 1); + } else { + setRecords((current) => + current.filter( + (record) => record.dredgingJobSn !== selectedRecord.dredgingJobSn, + ), + ); + } + setSelectedRecordId(null); + alert("준설정보를 삭제했습니다."); + } catch (error) { + alert( + error instanceof Error + ? error.message + : "준설정보를 삭제하지 못했습니다.", + ); + } finally { + setIsMutating(false); + } + }; + /** 공통 WindowPopup 체계로 준설결과 등록 화면을 연다. */ const openDredgingRegister = () => { const popup = openWindowPopup( "dredgingRegister", { sourcePath: window.location.pathname }, - { - width: window.screen.availWidth, - height: window.screen.availHeight, - }, + { maximize: true }, ); if (!popup) { alert( @@ -433,10 +548,33 @@ const Page = () => { } }; - /** 등록 팝업의 저장 완료 메시지를 받아 목록에 신규 준설결과를 반영한다. */ + /** 선택 관로의 정보·사진·위치를 하나의 최대화 WindowPopup에서 확인한다. */ + const openDredgingDetail = () => { + if (!canViewSelectedRecord || !selectedRecord) return; + + const popup = openWindowPopup( + "dredgingDetail", + buildDredgingPopupParams(selectedRecord), + { maximize: true }, + ); + + if (!popup) { + alert( + "준설결과 상세 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.", + ); + } + }; + + /** 등록·수정 팝업의 저장 완료 메시지를 받아 목록을 최신 상태로 갱신한다. */ useEffect(() => { const handlePopupMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; + if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.DREDGING_UPDATED) { + setSelectedRecordId(null); + setCurrentPage(1); + setSearchVersion((version) => version + 1); + return; + } if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.DREDGING_REGISTERED) { return; } @@ -499,38 +637,47 @@ const Page = () => { - + /> +
- +
+ { + const nextSggCd = String(value); + setSggCd(nextSggCd); + setSggName( + availableSggOptions.find( + (option) => String(option.val) === nextSggCd, + )?.text ?? "", + ); + }} + /> +
+ +
@@ -571,9 +739,17 @@ const Page = () => { {loadError ? (
- 준설현황 조회 실패: {loadError} + {loadError} +
) : null} @@ -595,49 +771,6 @@ const Page = () => { widthFixed={false} /> - - -
-
- 관로번호 - {selectedRecord.fcltMngNo} -
-
- 준설일자 - {selectedRecord.dredgingDate} -
-
- 위치출처 - {selectedRecord.registrationSource} -
-
- 최근접 거리 - - {selectedRecord.matchDistanceMeters.toFixed(2)}m ·{" "} - {selectedRecord.matchConfidence} - -
-
- - - ) : null - } - /> ); }; diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx index 36ea376b4..737d52bdf 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx @@ -31,7 +31,7 @@ import { ENDPOINT } from "@common/components/config"; import { getUserInfo } from "@common/components/api"; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; -import { SearchDatePicker } from '../_components/SearchDatePicker'; +import { SearchDatePicker } from '../components/SearchDatePicker'; import type { UiTableSort } from '@common/business_components/ui'; diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx index a1aadf3f1..be3d0753f 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx @@ -31,7 +31,7 @@ import { ENDPOINT } from "@common/components/config"; import { getUserInfo } from "@common/components/api"; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; -import { SearchDatePicker } from '../_components/SearchDatePicker'; +import { SearchDatePicker } from '../components/SearchDatePicker'; import type { UiTableSort } from '@common/business_components/ui'; diff --git a/apps/sn/app/api/gis-upload/route.ts b/apps/sn/app/api/gis-upload/route.ts index beae937a6..d4510c8f7 100644 --- a/apps/sn/app/api/gis-upload/route.ts +++ b/apps/sn/app/api/gis-upload/route.ts @@ -19,7 +19,8 @@ export const dynamic = "force-dynamic"; export const maxDuration = 600; /** - * 대용량 multipart 본문을 middleware rewrite의 복제 단계 없이 백엔드로 그대로 전달한다. + * multipart 본문을 한 번 버퍼링해 백엔드에 정확한 Content-Length로 전달한다. + * 로컬 Node Web Stream의 chunked 재전송 지연을 피하며, 허용 크기는 백엔드의 50MB 제한을 따른다. */ export async function POST(request: NextRequest) { const sewerageApiUrl = process.env.SEWERAGE_API_URL?.trim(); @@ -48,7 +49,7 @@ export async function POST(request: NextRequest) { const requestHeaders = new Headers(request.headers); HOP_BY_HOP_HEADERS.forEach((headerName) => requestHeaders.delete(headerName)); requestHeaders.delete("host"); - // Next가 라우트 본문을 Web Stream으로 다시 노출하므로 원 요청 길이를 전달하면 undici가 불일치로 중단할 수 있다. + // 원 요청 길이는 Next가 다시 구성한 multipart 본문 길이와 다를 수 있어 아래에서 다시 설정한다. requestHeaders.delete("content-length"); // undici fetch는 curl 등이 대용량 요청에 붙이는 Expect: 100-continue 전달을 지원하지 않는다. requestHeaders.delete("expect"); @@ -62,15 +63,16 @@ export async function POST(request: NextRequest) { const targetUrl = `${sewerageApiUrl.replace(/\/$/, "")}${GIS_UPLOAD_PATH}`; try { + const requestBody = await request.arrayBuffer(); + requestHeaders.set("content-length", String(requestBody.byteLength)); + const upstreamResponse = await fetch(targetUrl, { method: "POST", headers: requestHeaders, - body: request.body, + body: requestBody, cache: "no-store", signal: request.signal, - // Node fetch는 ReadableStream 요청 본문을 전송할 때 duplex 지정이 필요하다. - duplex: "half", - } as RequestInit & { duplex: "half" }); + }); const responseHeaders = new Headers(upstreamResponse.headers); HOP_BY_HOP_HEADERS.forEach((headerName) => diff --git a/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md b/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md index 769cfcc42..f632f0049 100644 --- a/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-012-준설관리/001-qa-SFR-010-012-준설관리-검증결과.md @@ -1,5 +1,37 @@ # SFR-010-012 준설관리 FE 누적 검증결과 +## 0.8 2026-08-06 등록 UI·처리구역·사진조회 회귀검증 + +| 항목 | 결과 | 확인 내용 | +| --- | --- | --- | +| 등록 건 삭제 | 통과 | 건 2 추가 시 `등록 건 2 삭제` X 노출, 삭제 후 건 1 유지, 건 1에는 삭제 버튼 없음 | +| 위치 마커 | 통과 | 수동 위치 클릭 즉시 파란 임시 마커 표시, 적용 후 빨간 확정 마커 표시 | +| 위치수정 포커스 | 통과 | 확정 후 지도를 이동하고 `위치 수정` 진입해도 이동한 범위와 마커 화면 위치 유지 | +| 상단 밀도·지도 높이 | 통과 | 상단 영역 축소, 사진목록 최대 112px 내부 스크롤, 지도 최소 300px·잔여 높이 확장 | +| 관망도 관로 계약 | 통과 | DB 레이어 설정 적용 문구와 줌 17에서 관망도 관로 12,663건 표시, 콘솔 error/warn 0건 | +| 처리구역 공간매핑 | 통과 | MS 관로 `4119279` → FID `181`, `전북혁신도시처리구역`; 등록·단건·목록 셀 표출 후 검증 데이터 삭제 | +| 등록사진 조회 | 통과 | 작업 121의 사진 1건 API 조회, `사진보기` 팝업에서 실제 JPG 원본 렌더링 | +| 백엔드 대상 테스트 | 통과 | `DredgingServiceTest`, `DredgingNearestPipeRepositoryTest` 성공 | +| TypeScript | 변경 파일 통과/전체 기존 오류 | `oper/dredging` 신규 오류 0건, 준설 외 기존 오류 존재 | +| Next build | 미수행 | 사용자 소유 3002 개발 서버와 `.next` 보호 기준 적용 | + +## 0.7 2026-08-05 독립 준설 테이블 전환 회귀검증 + +| 항목 | 결과 | 확인 내용 | +| --- | --- | --- | +| API 최근접 관로 | 통과 | `fid=3538280`, 관로 `2400429`, 거리 `0.000m`, HIGH 반환; 자산 매핑 요구 없음 | +| API 등록 | 통과 | 작업 101번과 신규 `tb_sn_drdgn_ppl` 관로 저장 성공 | +| 멱등 등록 | 통과 | 동일 요청 재전송 시 등록 0건·기등록 1건 | +| 목록·상세 | 통과 | 관경 200, 연장 42.80, 재질 `MOP919`, 매설년도 2024 확인 | +| 개발 DB | 통과 | 기존 작업 1건의 관로 무손실 이관, 구 사업/자산 컬럼 제거, 코멘트 깨짐/설명 접미사 없음 | +| 실제 목록 화면 | 통과 | 경상남도/양산시, 관로 `2400429`, 관재질 `MOP919` 1건 표시 | +| 수동 위치 흐름 | 통과 | 날짜 입력, 지도 위치 선택, 관로 위 재선택 후 `2024100064`, 19.58m, 보통 연결 | +| 화면 저장 | API로 보완 | 자동화 브라우저 alert 제어 응답 없음; 실제 API 등록·멱등·목록·상세로 저장 계약 검증 | +| TypeScript | 변경 파일 통과/전체 기존 오류 | 준설 client 신규 오류 없음; 전체 검사는 준설 외 기존 오류로 실패 | +| Next build | 미수행 | 사용자 소유 3002 개발 서버와 `.next` 보호 기준 적용 | + +0.5~0.6의 `PIPE_ASSET_*` 판정은 2026-08-05 사용자 결정으로 폐기된 과거 회귀 이력이다. + ## 0.6 2026-08-05 사진 위치 보정 이전 오류 제거 회귀검증 | 항목 | 결과 | 확인 내용 | @@ -43,7 +75,7 @@ | 관로 WFS 식별자 연계 | 통과 | 같은 FID의 WFS feature ID가 `tb_ms_swage_ppl.3538280`, 시설관리번호가 `2400429`임을 실 GeoServer로 확인하여 자동 연결 관로 강조 조건과 일치 | | 프록시/서버 오류 표시 | 통과 | JSON이 아닌 500 응답의 `Unexpected token` 원문 대신 `최근접 관로를 조회하지 못했습니다...` 사용자 문구로 변환 | | 주변 관로 표시 | 통과 | 수동 위치 선택 후 지도 줌 17, `주변 관로 128건` 및 `154건` 표시를 실제 화면에서 확인 | -| 개발 DB 등록 | 통과 | 수동 등록 1건 생성(jobSn `81`), 상세·목록 조회 후 논리 삭제 및 잔여 0건 확인 | +| 개발 DB 등록 | 통과 | 수동 등록 1건 생성(jobSn `81`), 상세·목록 조회 후 삭제 및 잔여 0건 확인 | | 멱등 등록 | 통과 | 같은 `clientRequestId` 재호출 시 등록 0건·기등록 1건 반환 | | 통계 API 회귀 | 통과 | `MUNICIPALITY`, `DIAMETER`, `ELAPSED_YEAR` 모두 응답 1행 확인 | | 목록 화면 회귀 | 통과 | 지자체별·관경별·경과년도별 제목/컬럼 전환, 최종 목록 0건, 콘솔 error/warn 0건 | @@ -181,3 +213,128 @@ Chrome 확장의 파일 URL 접근 권한이 꺼져 있어 이번 회귀에서 E - 실제 MS 관로 geometry로 거리·스냅 좌표·동률 처리·탐색 반경을 검증한다. - 서버 원본 EXIF 재분석 결과와 브라우저 분석 결과 불일치 케이스를 검증한다. - 실제 XLSX 다운로드 파일명, 시트명, 행·열 값을 브라우저에서 다시 확인한다. + +## 6. 2026-08-06 회귀검증 + +| 검증 항목 | 결과 | 근거 | +| --- | --- | --- | +| 대표 준설일자 단일 입력 | 통과 | 등록창 상단에 날짜 입력 1개만 표시 | +| 등록 탭 간 날짜 유지 | 통과 | `2026-08-04` 입력 후 등록 건 2로 전환해 동일 값 확인 | +| 단일 작업·다중 관로 저장 | 통과 | 개발 API에서 작업 1건, 관로 2건, 고유 목록 행 2건 확인 | +| 서로 다른 사진 촬영일 허용 | 통과 | 촬영일이 다른 사진 2장이 동일 대표 준설일자 작업에 저장됨 | +| 관로별 사진 조회 | 통과 | 같은 작업의 두 관로가 `jobSn + fid` 기준으로 서로 다른 사진 1건씩 반환 | +| 백엔드 전체 테스트 | 통과 | 128건, 실패 0건, `gradlew test` BUILD SUCCESSFUL | +| 프론트 준설 변경 파일 타입 검사 | 통과 | 준설 경로 신규 진단 0건 | +| 프론트 전체 타입 검사 | 기존 오류 | `ignoreDeprecations` 및 준설 외 기존 진단이 남아 전체 종료코드는 0이 아님 | +| Next 빌드 | 미수행 | 사용자 실행 중인 3002 개발 서버 보호 기준에 따라 생략 | + +사진 조회는 관로별 분리 방식으로 보완됐으며, 3002 프록시를 통한 목록과 사진 바이너리 표시를 실제 브라우저에서 확인했다. + +## 7. 2026-08-06 통신 오류 화면 회귀검증 + +| 검증 항목 | 결과 | 근거 | +| --- | --- | --- | +| 실제 백엔드 실패 조건 | 확인 | 사용자 실행 8883 목록 API가 HTTP 500을 반환하는 상태 사용 | +| 사용자 안내 문구 | 통과 | `준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.` 표시 | +| 기술정보 비노출 | 통과 | DOM에 `500`, `Internal Server Error`, `Failed to fetch data`, `doctype/html` 없음 | +| 재시도 흐름 | 통과 | `다시 시도` 클릭 후 재조회되고 동일한 사용자 안내로 복귀 | +| 개발 오류 오버레이 | 통과 | 기술 오류를 화면 오류로 다시 기록하지 않아 Next 오류 배지 없음 | +| 준설 TypeScript 진단 | 통과 | 준설 경로 신규 진단 0건 | +| 코드 형식 | 통과 | Prettier 및 변경 파일 `git diff --check` 통과 | + +백엔드가 정상 응답하도록 재기동된 뒤에는 조회 성공 시 안내 영역이 사라지고 목록 데이터가 표시되는 정상 전환도 재확인한다. + +## 8. 2026-08-06 최대화·좌우 분할 UI 회귀검증 + +| 검증 항목 | 결과 | 근거 | +| --- | --- | --- | +| 최대화 팝업 | 통과 | Chrome 실제 팝업 viewport `1920 × 855` 확인 | +| 좌우 분할 | 통과 | 최대화 화면에서 왼쪽 입력영역 520px, 오른쪽 지도 1380px 확인 | +| 지도 가용 영역 | 통과 | 지도는 오른쪽 열의 남은 폭과 높이를 모두 사용하고 입력영역 스크롤과 분리됨 | +| 부모 화면 달력 스타일 | 통과 | 부모·팝업 모두 높이 32px, 폭 155px, 글자 12.5px, 색상 `rgb(102, 102, 102)` 확인 | +| 등록 건 추가·삭제 | 통과 | 등록 건 2 추가 시 탭과 삭제 버튼 생성, 삭제 후 등록 건 1개 유지 | +| 등록 방식 전환 | 통과 | 사진 기반/사진 없이 직접 등록 전환 후 `aria-pressed` 상태 확인 | +| 위치 지정 흐름 | 통과 | 위치 지정 시 `취소`, `선택 위치 적용` 노출 후 취소 복귀 확인 | +| 파일명 중복 영역 | 통과 | 공통 업로드 목록 `display: none`, 메타정보 목록만 사용 | +| 개발 화면 오류 | 통과 | `Failed to compile`, `Build Error`, `Internal Server Error` 미표출 | +| 변경 파일 공백 검사 | 통과 | `git diff --check` 오류 없음 | +| ESLint | 환경 보류 | 기존 의존성/설정 불일치로 `eslint-plugin-react-hooks` 및 flat config 로딩 실패 | +| 전체 Next build | 미수행 | 사용자가 실행한 3002 개발 서버 보호 기준에 따라 생략 | + +검증 화면은 `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260806-준설등록-최대화-팝업.png`에 저장했다. + +## 9. 2026-08-06 목록 관재질 코드명 회귀검증 + +| 검증 항목 | 결과 | 근거 | +| --- | --- | --- | +| 목록 코드 비노출 | 통과 | 화면 본문에서 `MOP` 코드 패턴 미검출 | +| 관재질 코드명 | 통과 | `MOP012 → 흉관(HP)`, `MOP873 → 파형강관(CSP)`, `MOP005 → 폴리에틸렌관(PE)` 확인 | +| 목록 건수·다른 컬럼 | 통과 | 기존 6개 행과 준설일자·사진건수 등 다른 값 유지 | +| 개발 화면 오류 | 통과 | Build/Runtime 오류 문자열 없음 | + +## 10. 2026-08-06 부모 목록 수정·삭제 회귀검증 + +| 검증 항목 | 결과 | 근거 | +| --- | --- | --- | +| 초기 버튼 상태 | 통과 | 행 선택 전 수정·삭제 비활성 | +| 선택 후 활성화 | 통과 | 지자체별 첫 행 선택 후 수정·삭제 활성 | +| 작업 단위 식별 | 통과 | 같은 작업의 관로 2건을 수정 안내에 함께 표시 | +| 수정 레이어 | 통과 | 기존 준설일자 `2026-06-20`, 최대일 `2026-08-06`, 취소·저장 표시 | +| 수정 취소 | 통과 | 레이어 닫힘 후 선택과 관리 버튼 상태 유지 | +| 삭제 확인 | 통과 | 삭제 버튼에서 JavaScript confirm 진입, 실제 데이터 삭제 없이 취소 | +| 집계행 보호 | 통과 | 관경별 조회에서 행을 눌러도 수정·삭제 비활성 | +| 브라우저 콘솔 | 통과 | 오류 로그 0건 | +| 백엔드 단위 테스트 | 통과 | `DredgingServiceTest` BUILD SUCCESSFUL | +| 백엔드 전체 회귀 | 통과 | 35 suites, 131 tests, 실패·오류 0건 | +| 프론트 전체 타입 검사 | 기존 오류 | 이번 준설 변경 파일 신규 오류는 없고 조사·침수·GIS 등 기존 오류만 남음 | +| ESLint | 환경 보류 | 기존 `eslint.config.mjs` flat config 로딩 오류 | +| 전체 Next build | 미수행 | 사용자가 실행한 3002 개발 서버 보호 기준에 따라 생략 | + +사용자 실행 백엔드 8883은 수정 전 Spring 컨텍스트이므로 브라우저에서 실제 PUT을 보내지 않았다. 대신 변경 코드로 에이전트 전용 18884 서버를 기동해 작업 261번의 기존 준설일자 `2026-06-20`을 같은 값으로 PUT했고 204 응답과 상세 GET의 동일 날짜를 확인했다. 검증 서버는 종료했으며 3002·8883 사용자 프로세스는 유지했다. + +## 11. 2026-08-06 시도·시군구 검색조건 회귀검증 + +| 검증 항목 | 결과 | 근거 | +| --- | --- | --- | +| 시도 기준정보 | 통과 | 실제 브라우저 시도 선택 목록에서 전국 17개 시도와 `전체` 확인 | +| 기존 조회값 독립성 | 통과 | 준설 목록은 2개 지역만 조회된 상태에서도 시도 선택 목록은 전국 기준정보 유지 | +| 시군구 연동 로직 | 정적 통과 | 시도 코드 앞 두 자리 필터, 시도 변경 시 시군구 코드·명칭 초기화 확인 | +| API 값 계약 | 통과 | 선택 코드는 화면 상태에 유지하고 API에는 공식 한글 명칭을 전송하도록 확인 | +| 서버 지역 조회 | 통과 | `ctpv=충청남도&sgg=서산시` 요청이 HTTP 200, 총 1건·서산시 행 반환 | +| 초기 목록 회귀 | 통과 | `/oper/dredging` 기존 2건과 관재질 코드명, 수정·삭제·사진·지도 버튼 정상 렌더링 | +| 브라우저 콘솔 | 통과 | 오류 로그 0건 | +| 프론트 전체 타입 검사 | 기존 오류 | 이번 준설 변경 파일 신규 오류는 없고 조사·침수·GIS·노후관로 엑셀 등 기존 오류만 남음 | +| ESLint | 환경 보류 | 기존 `eslint.config.mjs` flat config 로딩 오류 | +| 전체 Next build | 미수행 | 사용자가 실행한 3002 개발 서버 보호 기준에 따라 생략 | + +브라우저 자동화 도구에서 공통 Radix 선택 항목의 포인터 선택 이벤트가 상태 변경까지 재현되지 않아 시도 선택 후 시군구 후보 축소는 소스와 기준정보 값으로 확인했다. 실제 브라우저에는 선택 목록과 화면 렌더링 오류가 없었고, 동일 명칭을 사용한 백엔드 필터 응답까지 별도로 검증했다. + +## 12. 2026-08-06 통합 상세 WindowPopup 회귀검증 + +| 검증 항목 | 결과 | 근거 | +| --- | --- | --- | +| 부모 버튼 통합 | 통과 | 부모 화면에 사진보기·지도보기 대신 상세보기 한 개만 표시 | +| 선택 상태 연동 | 통과 | 초기 상세보기 비활성, 지자체별 첫 행 선택 후 활성 | +| 상세 좌우 배치 | 통과 | 1280px 검증 화면에서 왼쪽 420px, 오른쪽 지도 845px | +| 관로정보 | 통과 | 관로번호·준설일자·행정구역·관경·연장·관재질·매설년도·매핑 거리 표시 | +| 관재질 코드명 | 통과 | `MOP012 → 흉관(HP)`, `MOP033 → 철근콘크리트박스(RCB)` 표시 | +| 관로별 사진 분리 | 통과 | 작업 301의 FID 2398511과 3749103이 서로 다른 사진 1장씩 표시 | +| 사진 선택 미리보기 | 통과 | 사진목록 버튼 `aria-pressed=true`, 아래 미리보기 이미지 1건 표시 | +| 관망도 레이어 | 통과 | 상세 2건에서 각각 관망도 관로 13,365건·13,488건 표시 | +| 자동 연결 관로 | 통과 | 선택 관로 FID를 지도에 전달하고 관망도 위 강조 스타일 적용 | +| 브라우저 콘솔 | 통과 | 부모·상세 팝업 경고·오류 로그 0건 | +| 준설 TypeScript 진단 | 통과 | 이번 상세 팝업과 준설 경로 신규 진단 0건 | +| 프론트 전체 타입 검사 | 기존 오류 | 조사·침수·GIS·노후관로 엑셀 등 기존 오류로 종료코드 2 | +| 변경 파일 공백 검사 | 통과 | `git diff --check` 오류 없음 | +| Next build | 미수행 | 사용자가 실행한 3002 개발 서버 보호 기준에 따라 생략 | + +## 13. 2026-08-06 준설관리 LNB 선택 표시 회귀검증 + +| 검증 항목 | 결과 | 근거 | +| --- | --- | --- | +| 준설관리 직접 진입 | 통과 | `/oper/dredging`에서 준설관리 `data-matched=true` | +| 잘못된 메뉴 선택 방지 | 통과 | 같은 화면에서 조사관리 `data-matched=false` | +| 조사관리 회귀 | 통과 | `/oper/exmn/register` 이동 후 조사관리만 `true`, 준설관리는 `false` | +| 공통 선택 스타일 | 통과 | 별도 준설 CSS 없이 기존 `bg-accent !text-primary` 규칙 적용 | +| 브라우저 콘솔 | 통과 | 새 검증 탭 경고·오류 0건 | +| Next build | 미수행 | 사용자가 실행한 3002 개발 서버 보호 기준에 따라 생략 | diff --git a/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md b/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md index 5a497aa91..23406cf07 100644 --- a/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/002-qa-SFR-012-002-하수관망도 업로드-통합검증결과.md @@ -180,3 +180,116 @@ - 복원 응답: `28-regression-rollback-completed.json` - 복원 후 현재본·이력: `29-current-after-rollback.gpkg`, `30-history-after-rollback.json` - 복원 데이터 대조: `32-regression-rollback-semantic-verification.txt` + +## 7. 2026-08-07 SPAR 재검증·보완 + +### 7.1 범위와 추적 근거 + +- 요구사항정의서: `3. (기능)요구사항정의 및 추적표`, `SFR-012-002 하수관로자료 업로드/다운로드` +- 화면설계서: 슬라이드 33, 화면 ID `SN_MN010302`; 공유 데이터 회귀는 `SFR-012-003`, 화면 ID `SN_MN010301` +- 관리기관·데이터: 가평군 `4160000`, `tb_ms_swage_ppl`, `tb_ms_edit_swage_ppl`, EPSG:5179 +- 사용자 소유 서버: FE `localhost:3002`, BE `localhost:8883`; 종료·재시작하거나 Next build를 병행하지 않음 + +### 7.2 다운로드와 GeoPackage + +| 항목 | 결과 | +| --- | --- | +| 최신 다운로드 | HTTP 200, 11,169,792 byte, 12.629초 | +| 최신본 구조 | 16,670건, 26컬럼, MultiLineString, EPSG:5179 | +| FID·인덱스 | `fid INTEGER PRIMARY KEY`, `fid_1` 없음, RTree 존재 | +| 무결성 | `PRAGMA integrity_check=ok` | +| 선택 이력 | `GUP-20260729120404-B389A9CD`, HTTP 200, 11,149,312 byte, 16,671건 | + +선택 이력의 16,671건은 과거 변경 행과 현재 미변경 행을 조합하는 현행 계약에서, 삭제 후 다른 FID로 복원된 현재 행이 함께 포함된 결과다. 전체 과거 시점 snapshot을 보장하지 않는 기존 구조적 한계로 분류했으며 이번 범위에서 제품 결함으로 수정하지 않았다. + +### 7.3 업로드·오류·C/U/D + +| 시나리오 | 업로드 ID / 결과 | 브라우저 처리시간 | +| --- | --- | ---: | +| 무변경 | `GUP-20260807104159-E41ED143`, C/U/D/오류 `0/0/0/0` | 19.123초 | +| 관리기관 불일치 | `GIS_UPLOAD_MNG_INST_MISMATCH`, 행 1, FID `1891744`, 이력·최종본 변경 없음 | 16.763초 | +| 1차 생성·수정 | `GUP-20260807104544-502263E3`, C/U/D/오류 `1/1/0/0` | 20.301초 | +| 2차 삭제·복원 | `GUP-20260807104858-88542F52`, C/U/D/오류 `0/1/1/0` | 20.852초 | + +- 수정 대상 FID `1891744`는 `SPAR_QA_UPDATE_20260807`로 변경된 뒤 원래 `4121674063`으로 복원됐다. +- 임시 신규 FID `4132724`와 `SPAR_QA_NEW_20260807`은 2차 업로드에서 삭제됐다. +- 복원 후 최신본은 16,670건, 최대 FID `4132723`이며 기준본과 피처·속성·도형 의미 해시 `1290dc800d4be4166f7669a771fe8901a3314a6ceb5db424fb14e820cfb9f4f4`가 정확히 일치했다. +- 모든 쓰기 요청은 31.2초 회귀 조사 기준과 60초 실패 상한을 만족했다. + +### 7.4 브라우저·자동화·정적 검증 + +- 이력은 최종 총 18건으로 1페이지 10건, 2페이지 8건을 확인했다. +- 생성·수정 이력과 복원 이력의 미리보기, 오류 0건 화면, 선택 이력 다운로드를 확인했다. +- 복원 이력 미리보기는 관로 3,557건, 맨홀 3,219건, 삭제 미표시 1건이었다. +- 조회·미리보기·오류 화면을 반복 전환했으나 기존 OpenLayers 0 크기 경고는 재현되지 않았고 기능 대상 console error·warning은 0건이었다. +- `gradlew.bat compileJava test --tests iipbs.sewerage.service.map.SwagePplEditServiceTest`: `BUILD SUCCESSFUL` +- fixture·검사 Python 스크립트: 문법 검사 통과 +- FE 업로드 route: Prettier 통과. 기존 미수정 팝업·미리보기 2개 파일은 현재 저장소 형식 기준 Prettier 경고가 남아 자동 포맷하지 않았다. +- 실행 중인 사용자 Next 개발 서버 보호를 위해 `next build`는 수행하지 않았다. + +### 7.5 보완 판정·롤백·잔여 위험 + +- 재현 가능한 제품 결함이 없어 FE·BE 제품 코드는 변경하지 않았다. 검증 재현용 SPAR 문서·GPKG 검사/fixture 스크립트와 증빙만 추가했다. +- 테스트 데이터는 최신본 기준으로 완전 복원되어 별도 데이터 롤백이 필요 없다. +- 검증 산출물 롤백은 `specs/active/sfr-012-002-gis-management-validation-hardening/`과 `검증/20260807/` 추가분 및 이 문서의 7절을 제거하면 된다. +- 선택 이력 전체 시점 snapshot 비지원, 미수정 FE 파일의 Prettier 경고, MS·SN 다중 DB 정합성 보상 정책은 기존 후속 위험으로 유지한다. + +### 7.6 증빙 + +- 공통 증빙 폴더: `COMMON_DOCS_ROOT/작업내용/SFR-012-002-하수관망도 업로드/검증/20260807/` +- 기준본·검사: `05-gapyeong-current-baseline.gpkg`, `06-current-gpkg-inspection.json`, `35-final-semantic-comparison.json` +- 오류 차단: `16-invalid-management-error.png`, `18-current-after-invalid.gpkg` +- 생성·수정: `20-current-after-create-update.gpkg`, `21-create-update-gpkg-inspection.json`, `24-create-update-preview.png` +- 복원: `28-current-after-restore.gpkg`, `29-restore-gpkg-inspection.json`, `30-restore-preview-deleted-one.png` +- 복원 이력: `32-restore-history-download.gpkg`, `33-restore-history-inspection.json` +- 최종 이력 페이지: `34-final-history-page2.jpg` + +## 8. 2026-08-07 GIS 업로드 10초 목표 검증·보완 + +### 8.1 판정 기준과 변경 + +- 원본 요구사항 `SFR-012-002`, 화면 `SN_MN010302`에는 처리시간 SLA가 없다. 10초는 이번 사용자 추가 목표다. +- 대상은 가평군 `4160000`, 11,169,792 byte, 16,670건 기준 GPKG다. +- 기존 `/api/gis-upload`는 Next Web Stream을 chunked 방식으로 백엔드에 재전송했다. +- 보완 후에는 multipart body를 한 번 버퍼링하고 실제 byte 수를 `Content-Length`로 설정해 재전송한다. API·DB·응답 계약과 백엔드 비교 로직은 변경하지 않았다. + +### 8.2 성능 결과 + +| 구분 | 결과 | 판정 | +| --- | ---: | --- | +| 변경 전 FE route warm 3회 | 8.449 / 7.778 / 8.781초 | 10초 이내이나 여유 부족 | +| 변경 후 FE route warm 3회 | 4.686 / 5.593 / 4.568초 | 통과 | +| 변경 후 FE route 중앙값·최댓값 | 4.686 / 5.593초 | 통과 | +| 최종 브라우저 3회 | 7.576 / 7.615 / 8.937초 | 전건 통과 | +| 브라우저 중앙값·최댓값 | 7.615 / 8.937초 | 통과 | +| 관리기관 오류 route·브라우저 | 4.532 / 7.992초 | 통과 | +| 생성 1·수정 1 | 4.911초 | 통과 | +| 삭제 1·복원 1 | 6.351초 | 통과 | + +- 에이전트 소유 BE 8894 warm HTTP 3회는 2.540 / 2.565 / 3.109초였다. +- warm 서버 stage 중앙값은 GPKG 읽기·signature 828ms, DB 현재본·signature 1,027ms, 분류 341ms, total 2,370ms였다. +- 변경 전 대비 FE route warm 중앙값은 44.53% 단축됐다. + +### 8.3 기능·데이터 회귀 + +- 무변경은 16,670건, C/U/D/오류 `0/0/0/0`이다. +- 관리기관 불일치는 `GIS_UPLOAD_MNG_INST_MISMATCH`, 행 1, FID `1891744` 한 건으로 차단됐다. +- 생성·수정은 C/U/D `1/1/0`, 삭제·복원은 `0/1/1`로 응답·이력이 일치했다. +- 최종 현재본은 16,670건, FID `1891744`~`4132723`, 전체 26개 컬럼·geometry BLOB 해시가 기준본과 정확히 일치했다. +- 이력 총 44건·7개 페이지 링크, 미리보기 관로 3,557건·맨홀 3,219건, 오류 확인 화면을 실제 브라우저에서 확인했다. + +### 8.4 자동화·정적 검사와 제한 + +- `SwagePplEditServiceTest`: `BUILD SUCCESSFUL` +- 변경 route Prettier: 통과 +- TypeScript: 기존 `tsconfig.json`의 `ignoreDeprecations` 값과 실행 가능한 프로젝트 `tsc` 부재로 시작되지 않음 +- 실행 중인 사용자 Next 개발 서버 보호를 위해 `next build`는 수행하지 않았다. 3002 개발 서버의 실제 route 컴파일과 브라우저 3회 검증으로 보완했다. +- 브라우저 로그의 초기화 500 한 건은 사용자 BE가 일시 응답하지 않던 11:23 기록이다. 복구 후 최종 시나리오에서 새 오류·경고는 발생하지 않았다. + +### 8.5 롤백·잔여 위험·증빙 + +- 롤백은 `app/api/gis-upload/route.ts`에서 `request.arrayBuffer()`와 재설정한 `content-length`를 제거하고 기존 `request.body` + `duplex: "half"` 전달로 되돌린다. +- 버퍼링은 요청당 최대 백엔드 허용치 50MB 수준의 Node 메모리를 사용한다. 운영 동시 업로드 부하와 더 큰 파일은 이번 로컬 10초 판정 범위 밖이다. +- 10초는 로컬 warm 11.2MB·16,670건 조건의 통과 결과이며 운영 SLA 보증이 아니다. +- 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-012-002-하수관망도 업로드/검증/20260807/10s/23-performance-summary.json` +- 최종 데이터 대조: 같은 폴더의 `22-final-feature-comparison.json` diff --git a/apps/sn/docs/작업계획/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-작업카드.md b/apps/sn/docs/작업계획/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-작업카드.md index d8a56c0ce..04e11ed6d 100644 --- a/apps/sn/docs/작업계획/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-작업카드.md +++ b/apps/sn/docs/작업계획/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-작업카드.md @@ -19,7 +19,9 @@ ## 4. 화면 범위 - 지자체별/관경별/경과년도별 조회 전환 -- 조회, 다운로드, 등록, 지도보기 +- 조회, 다운로드, 등록, 수정, 삭제, 상세보기 +- 상세 WindowPopup 좌측 관로정보·관로별 사진목록·선택 사진 미리보기 +- 상세 WindowPopup 우측 관망도·선택 관로 강조 - 관로 그룹 추가/삭제 - 공통 `FileUpload` 최대 5장 - JPEG EXIF 촬영일/GPS 파싱 @@ -44,6 +46,7 @@ - 세 조회 유형의 컬럼과 집계 전환 - 사진/수동 등록 사용자 흐름 - 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 index 0fd0e54a0..e91c3efc1 100644 --- a/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-012-준설관리/001-fe-SFR-010-012-준설관리-구현기록.md @@ -1,5 +1,25 @@ # SFR-010-012 준설관리 프론트엔드 구현기록 +## 0.8 2026-08-06 등록 탭·지도·사진조회·처리구역 보완 + +- 등록 건 탭의 두 번째 건부터 `×` 삭제 버튼을 표시하고, 기준이 되는 첫 번째 등록 건은 삭제할 수 없게 했다. +- 마커 레이어 생성과 위치 편집 이벤트를 분리해 첫 위치가 들어올 때 마커가 누락되지 않게 했다. +- 위치수정 진입은 좌표값을 변경하지 않으므로 사용자가 이동한 현재 지도 범위를 유지하며 사진 GPS로 다시 포커스하지 않는다. +- 상단 안내·사진·일자·위치 카드를 축소하고 상단 상세영역과 사진목록에 내부 스크롤을 적용해 다중 사진에서도 지도 높이를 유지한다. +- 등록 지도 관로는 `/api/v1/map/lyrStngs/SN_NETWORK`의 `tb_ms_swage_ppl` 활성 레이어를 모두 사용한다. 용도별 CQL 필터, 선 색·굵기, 최소 줌은 관망도 DB 설정과 동일하고 자동 연결 관로만 주황색으로 강조한다. +- 최근접 관로 응답의 처리구역명을 등록 화면에 표시하고 저장 후 목록의 처리구역 셀에 반영한다. +- 목록에 `사진보기` 버튼과 원본 미리보기 팝업을 추가해 공통 첨부서비스에서 등록사진을 조회한다. +- 다중 사진은 모든 사진의 GPS가 같은 관로로 수렴해야 하며, 대표 등록 위치는 첫 번째 GPS 사진이다. 지도에서 위치를 보정하면 수동 위치가 그룹 전체의 대표 위치로 우선한다. +- 브라우저에서 등록 건 2 추가·X 삭제·첫 건 보호, 지도 수동 마커, 위치수정 무재포커스, 관망도 관로 12,663건, 저장사진 원본 미리보기, 처리구역 목록 표출을 확인했다. + +## 0.7 2026-08-05 사업·자산 매핑 의존 제거 + +- 백엔드 저장 구조가 `준설작업–준설관로–사진`으로 변경되어 `tb_sn_biz_info`, `tb_sn_cstrn_rslt`, `ast_id`를 더 이상 요구하지 않는다. +- 최근접 관로 성공 조건은 MS 관로의 `fid`, 관리기관코드, 시설관리번호 확인이며 SN 관로자산 매핑은 조회하지 않는다. +- 더 이상 발생하지 않는 `PIPE_ASSET_NOT_MAPPED`, `PIPE_ASSET_AMBIGUOUS` 전용 안내를 프론트 API 계약에서 제거했다. +- 3002 실제 화면에서 목록의 관로 `2400429`, 관재질 `MOP919` 표시와 수동 위치 재선택 후 관로 `2024100064`, 거리 `19.58m` 자동 연결을 확인했다. +- 아래 0.5~0.6의 자산 매핑 관련 내용은 당시 구현 이력이며 현재 계약에는 적용하지 않는다. + ## 0.6 2026-08-05 사진 위치 보정 시 이전 관로 오류 제거 - 사진 EXIF 원위치에서 발생한 `PIPE_NOT_FOUND` 결과가 지도에서 새 위치를 선택한 뒤에도 남아 새 좌표의 조회 결과처럼 보이던 상태 표시 오류를 수정하였다. @@ -78,12 +98,13 @@ | 파일 | 책임 | | --- | --- | | `page.tsx` | 지자체별·관경별·경과년도별 조회, 검색, 엑셀 다운로드, 등록/지도보기 진입 | -| `_components/DredgingRegisterModal.tsx` | 관로 그룹, 사진/수동 등록, 검증, 첨부 업로드 및 저장 순서와 팝업 표시 모드 | -| `_components/DredgingRegisterPopupPage.tsx` | 등록 완료 메시지 전송과 팝업 창 닫기 | -| `_components/DredgingLocationMap.tsx` | 공통 `Gis`에 주변 관로 WFS, 자동 연결 관로 강조, 위치 마커와 지도 클릭 interaction 연결 | -| `_lib/exif.ts` | JPEG APP1/TIFF EXIF 촬영일·GPS 분석 | -| `_lib/client.ts` | 최근접 관로, 공통 TUS 첨부 업로드, 준설 등록 API 어댑터, API 오류 정규화와 FE 모의 응답 | -| `_lib/types.ts` | 화면 draft, 사진 메타정보, 관로 매핑, 저장 payload 타입 | +| `components/DredgingRegisterModal.tsx` | 관로 그룹, 사진/수동 등록, 검증, 첨부 업로드 및 저장 순서와 팝업 표시 모드 | +| `components/DredgingRegisterPopupPage.tsx` | 등록 완료 메시지 전송과 팝업 창 닫기 | +| `components/DredgingLocationMap.tsx` | 공통 `Gis`에 관망도 DB 레이어 설정 관로, 자동 연결 관로 강조, 위치 마커와 지도 클릭 interaction 연결 | +| `components/DredgingPhotoViewerModal.tsx` | 등록사진 목록 조회와 공통 첨부 원본 미리보기 | +| `lib/exif.ts` | JPEG APP1/TIFF EXIF 촬영일·GPS 분석 | +| `lib/client.ts` | 최근접 관로, 공통 TUS 첨부 업로드, 준설 등록 API 어댑터, API 오류 정규화와 FE 모의 응답 | +| `lib/types.ts` | 화면 draft, 사진 메타정보, 관로 매핑, 저장 payload 타입 | | `src/features/windowPopup/**` | `dredgingRegister` 정의·레지스트리·완료 메시지 계약 | ## 3. 주요 처리 @@ -104,7 +125,7 @@ 2. 준설일자를 입력한다. 3. 등록창 하단의 `위치등록`을 눌러 같은 화면의 지도를 편집 상태로 전환한다. 4. 사용자는 좌표값을 직접 입력하지 않고 지도에서 위치를 클릭한다. -5. `선택 위치 적용`을 눌렀을 때만 선택 위치를 확정하고 최근접 관로와 거리·신뢰도를 갱신한다. +5. `선택 위치 적용`을 눌렀을 때만 선택 위치를 확정하고 최근접 관로와 거리를 갱신한다. 6. 편집을 취소하면 적용하지 않은 위치는 폐기하고 기존 위치를 유지한다. 7. 관로 매핑이 완료된 건만 저장 payload로 만든다. @@ -119,7 +140,7 @@ 1. 검색조건을 조사관리와 같은 단일 가로 flex 행으로 배치한다. 2. 라벨은 `text-xl`, 입력 높이는 `3.2rem`, 선택상자는 흰색 `rounded-lg` 규칙을 사용한다. -3. 조회·다운로드·등록·지도보기 버튼을 조사관리와 같은 파란색 액션 버튼으로 통일하고 장식 아이콘을 제거한다. +3. 조회·다운로드·등록·수정·삭제·상세보기 버튼을 조사관리와 같은 파란색 액션 버튼으로 통일하고 장식 아이콘을 제거한다. 4. 등록 버튼은 `/popup/dredgingRegister` 창에 화면 가용 크기를 호출 옵션으로 전달해 풀사이즈에 가깝게 연다. 5. 저장 성공 시 팝업이 같은 출처의 `sn:popup:dredgingRegistered` 메시지를 부모 화면에 전달한다. 6. 부모 화면은 메시지의 등록 그룹을 기존 목록 상태에 반영하고 첫 페이지로 이동한다. @@ -130,7 +151,7 @@ 2. 공통 `Gis` 지도는 위치·관로 요약 바로 아래에 상시 표시한다. 3. 확정 위치가 있으면 `위치 수정`, 위치가 없으면 `위치등록`을 제공한다. 4. 위치 편집 중 지도 클릭 위치는 임시 상태로 유지하고 `선택 위치 적용` 이후에만 등록 데이터와 최근접 관로 조회에 반영한다. -5. 목록 화면의 `지도보기`는 기존 `UiModal` 레이어 팝업 방식을 유지한다. +5. 목록 화면의 사진·지도 조회는 `상세보기` WindowPopup으로 통합해 관로정보, 관로별 사진, 관망도를 한 화면에서 확인한다. ### 3.6 관로 선형 표시 @@ -156,3 +177,103 @@ - `DredgingRegisterModal.tsx`의 `presentation="popup"` 분기와 `DredgingRegisterPopupPage.tsx`를 제거한다. - `src/features/windowPopup`의 `dredgingRegister` 정의·레지스트리·메시지 타입을 제거한다. - 신규 전용 경로이므로 기존 지도·운영정보 화면의 코드 롤백은 필요하지 않다. + +## 6. 2026-08-06 대표 준설일자와 전체 사진보기 + +- 등록 탭별 준설일자 입력을 제거하고 등록창 상단에 `대표 준설일자` 1개를 배치했다. +- 첫 번째로 등록한 사진에 촬영일이 있으면 대표 준설일자를 자동 입력하며, 이후 사용자가 날짜 입력에서 직접 수정할 수 있다. +- 사진 기반·사진 없이 직접 등록 탭을 여러 개 추가해도 모든 저장 그룹은 같은 대표 준설일자를 사용한다. +- 사진 촬영일시는 사진 메타정보 표에 별도로 표시하되 대표 준설일자와 일치하도록 강제하지 않는다. +- 등록 payload는 최상위 `dredgingDate` 한 개와 다중 관로 그룹을 전송한다. +- 같은 준설작업의 사진은 저장 그룹별 관로 FID를 함께 보관하고, 조회 시 `dredgingJobSn + pipeFid`로 선택 관로 사진만 구분한다. +- 상세 팝업은 선택 관로 사진 목록을 제공하고 목록에서 선택한 한 장을 아래 미리보기 영역에 표시한다. +- 로컬 모의 등록도 여러 관로 행이 같은 `dredgingJobSn`을 공유하되 사진수는 관로별로 계산한다. + +브라우저에서 대표 준설일자 `2026-08-04`를 입력하고 두 번째 등록 탭으로 전환해도 날짜가 유지되는 것을 확인했다. + +## 7. 2026-08-06 백엔드 통신 오류 노출 보완 + +- 준설 목록, 최근접 관로, 등록, 사진 업로드, 사진 목록과 사진 원본 조회의 통신 실패를 업무별 사용자 안내 문구로 정규화했다. +- HTTP 상태코드, 영문 fetch 오류, HTML 오류 페이지와 JSON 파싱 문구는 화면 상태·알림·팝업 본문에 전달하지 않는다. +- 목록 조회 실패는 붉은 기술 오류 문자열 대신 노란 안내 영역과 `다시 시도` 버튼으로 표시한다. +- 상세 오류 본문은 화면과 콘솔 메시지에 복사하지 않고 브라우저 Network 응답에서만 확인하도록 했다. +- 추적 근거는 요구사항 `SFR-010-012/013`, 요구사항정의서 84~85행, 화면설계서 51~56번 슬라이드와 화면 ID `SN_MN040201~03`이다. + +## 8. 2026-08-06 등록 팝업 좌우 분할·최대화 보완 + +- 최대화 등록 팝업을 왼쪽 입력·상태 영역과 오른쪽 지도 영역으로 분할했다. +- 왼쪽 영역은 최대 520px로 제한하고 준설일자, 등록 건, 사진, 위치·관로 상태만 콘텐츠 높이로 배치했다. +- 오른쪽 지도는 팝업의 남은 폭과 높이를 모두 사용하며 1920px 화면에서 왼쪽 520px, 지도 1380px로 표시된다. +- 사진이 여러 장이어도 왼쪽 사진 목록에만 스크롤이 생겨 지도 크기가 줄지 않는다. +- 공통 첨부 컴포넌트의 파일명 목록은 숨기고 사진 메타정보 표에서 파일명을 한 번만 표시한다. +- 준설일자 입력은 부모 목록 화면과 동일한 높이 32px, 폭 155px, 글자 12.5px, 색상과 테두리 규격을 적용했다. +- 등록 호출에는 `maximize` 옵션을 사용해 가용 화면 전체 크기로 열고, 브라우저가 이동·크기 변경 API를 제한하면 `window.open`의 최대화 feature 값을 유지한다. +- 추적 근거는 요구사항 `SFR-010-012`, 요구사항정의서 84행, 화면설계서 51~52번 슬라이드와 화면 ID `SN_MN040201`이다. 원본의 업무 흐름을 유지하면서 좌우 배치는 사용성 개선 설계로 적용했다. + +## 9. 2026-08-06 목록 관재질 코드명 표출 + +- 준설 목록의 관재질은 API와 저장 데이터의 `MOP` 코드를 변경하지 않고 화면 행을 만들 때 코드명으로 변환한다. +- 관망도에서 공통으로 사용하는 `MS_SWAGE_PPL_CODES` 코드표를 재사용해 코드표 중복을 만들지 않았다. +- 매핑되지 않은 `MOP` 코드는 코드값을 그대로 노출하지 않고 `확인불가`로 표시하며, 이미 코드명이 전달된 값은 그대로 유지한다. +- 같은 화면 행을 사용하는 엑셀 다운로드에도 관재질 코드명이 적용된다. + +## 10. 2026-08-06 부모 목록 수정·삭제 + +- 지자체별 준설현황에서 행을 선택하면 `수정`, `삭제` 버튼이 활성화된다. +- 관경별·경과년도별 집계 행은 단일 준설작업을 특정할 수 없으므로 두 버튼을 비활성화한다. +- 수정 레이어에서는 자동 매핑 관로와 등록사진은 유지하고, 하나의 준설작업이 공유하는 준설일자만 변경한다. +- 같은 `dredgingJobSn`의 관로가 여러 행이면 수정 안내와 삭제 확인창에 연결 관로 수와 사진 수를 함께 표시한다. +- 삭제는 선택 행 하나가 아니라 해당 `dredgingJobSn`의 사진 참조·연결 관로·작업 마스터를 물리 삭제한 뒤 목록을 재조회한다. +- 통신 실패는 HTTP 상태나 응답 본문을 노출하지 않고 수정·삭제별 재시도 안내로 정규화한다. +- 원본 화면설계서에는 수정·삭제 흐름이 정의되지 않아 이번 기능은 사용자 지시에 따른 `설계 보완`으로 기록한다. 추적 기준은 `SFR-010-012`, 요구사항정의서 84행, 화면설계서 51~52번 슬라이드와 화면 ID `SN_MN040201`이다. + +## 11. 2026-08-06 시도·시군구 검색조건 공통화 + +- 준설 조회 결과에 포함된 지역명으로 선택 후보를 만들던 방식을 제거하고 공통 행정구역 store의 `tb_ms_ctpv`, `tb_ms_sgg` 기준정보를 사용한다. +- 시도·시군구 선택값은 공통 기준정보의 행정구역 코드로 관리하고, 기존 준설 목록 API에는 선택 코드에 해당하는 공식 한글 명칭을 전송한다. +- 시도를 변경하면 기존 시군구 선택을 `전체`로 초기화하고, 시도 코드 앞 두 자리와 일치하는 시군구만 후보로 표시한다. +- 조회 버튼을 누른 시점의 지역명을 확정 조건으로 보관해 입력 중 선택 변경만으로 기존 목록이 먼저 필터링되지 않게 했다. +- `GET /api/v1/dredging/records`의 기존 `ctpv`, `sgg` 선택 파라미터를 사용하므로 백엔드와 DB 구조 변경은 없다. +- 원본 화면설계서에는 지역 선택의 기준정보 출처와 코드·명칭 변환 규칙이 정의되지 않아, 노후 하수관로 정비사업 화면의 기존 공통 패턴을 준설관리에 적용한 `설계 보완`으로 기록한다. 추적 기준은 `SFR-010-012`, 요구사항정의서 84행, 화면설계서 51~52번 슬라이드와 화면 ID `SN_MN040201`이다. + +## 12. 2026-08-06 사진·지도 통합 상세 WindowPopup + +- 부모 목록의 `사진보기`, `지도보기` 버튼을 `상세보기` 한 개로 통합했다. +- 지자체별 조회 행을 선택한 경우만 상세보기가 활성화되며 선택 행의 작업번호, 관로 FID, 관로 속성과 위치를 최대화 WindowPopup에 전달한다. +- 상세 팝업은 왼쪽에 관로정보와 해당 관로의 사진목록·선택 사진 미리보기를, 오른쪽에 등록 화면과 같은 공통 `Gis` 관망도를 배치한다. +- 사진은 `GET /api/v1/dredging/records/{jobSn}/photos?fid={pipeFid}`로 조회해 같은 작업의 다른 관로 사진이 섞이지 않게 한다. +- 사진목록 행을 누르면 선택 상태가 바뀌고 같은 왼쪽 영역 아래에서 공통 첨부 원본을 미리보기한다. +- 지도는 DB 레이어 설정의 `tb_ms_swage_ppl` 관로 색상·굵기를 사용하고 선택 관로 FID를 주황색으로 강조한다. +- 백엔드 API와 DB 구조는 변경하지 않았다. +- 원본 화면설계서 슬라이드 53의 상세정보와 슬라이드 56의 지도보기를 하나의 화면으로 합친 사용자 지시 기반 `설계 보완`이다. 추적 기준은 요구사항 `SFR-010-012`, 요구사항정의서 84행, 화면 ID `SN_MN040202`, `SN_MN040203`이다. + +## 13. 2026-08-06 준설관리 LNB 선택 표시 보완 + +- `/oper/dredging` 진입 시 좌측 `준설관리` 메뉴에 선택 배경과 기본색이 적용되지 않던 문제를 수정했다. +- 메뉴 데이터에서 상위 메뉴와 준설 하위 메뉴가 같은 URL을 공유하며, 공통 LNB가 먼저 발견한 상위 메뉴 코드를 현재 메뉴로 확정한 것이 원인이었다. +- 동일 URL이 여러 계층에 존재하면 실제 클릭 가능한 가장 깊은 하위 메뉴를 우선 선택하도록 공통 LNB의 탐색 순서를 자식 우선으로 변경했다. +- 준설관리 전용 색상이나 경로 예외를 추가하지 않아 기존 조사관리와 동일한 공통 선택 스타일을 사용한다. +- 원본 요구사항의 업무 기능 변경은 없으며 화면 사용성 보완으로 적용했다. 추적 기준은 요구사항 `SFR-010-012`, 화면설계서 51~56번 슬라이드와 화면 ID `SN_MN040201~03`이다. + +## 14. 2026-08-06 상세 화면 기반 수정 팝업 + +- 부모 목록의 `수정` 버튼은 기존 일자 전용 레이어 대신 최대화 `dredgingEdit` WindowPopup을 연다. +- 수정 화면은 상세보기와 같은 좌우 분할 컴포넌트를 공유한다. 왼쪽에는 관로정보, 준설일자, 해당 관로 사진목록과 선택 사진 미리보기, 위치·매핑 상태를 두고 오른쪽에는 DB 레이어 설정을 사용하는 관망도를 배치한다. +- 기존 사진 행의 `×`로 유지 대상에서 제외할 수 있고, 공통 `FileUpload`로 신규 사진을 최대 5장 범위에서 추가한다. 저장할 때만 신규 파일을 공통 첨부서비스에 업로드한다. +- `위치 수정`을 누르면 현재 지도 화면을 유지한 채 위치 선택 모드로 전환한다. 지도에서 위치를 고르고 `선택 위치 적용`을 누르면 최근접 관로를 다시 조회하며 사진 GPS 위치로 화면을 되돌리지 않는다. +- 저장 요청에는 원래 관로 FID, 수정 위치·출처, 재확인 관로, 유지할 사진 일련번호와 신규 사진 메타정보를 전달한다. +- 수정 완료 메시지를 부모 창에 전달해 선택을 해제하고 목록을 다시 조회한다. + +### 검증 결과 + +- 전체 TypeScript 검사에서 이번 준설 수정 파일 오류는 0건이다. 전체 검사는 기존 조사·침수·GIS·노후관로 파일 오류로 종료되며 사용자 실행 중인 Next 서버 보호를 위해 `next build`는 수행하지 않았다. +- 로컬 브라우저에서 목록 행 선택 후 수정 버튼 활성화, 상세와 동일한 좌우 UI, 관로정보·기존 사진 1장·사진 미리보기·관망도 표시를 확인했다. +- 지도 위치 선택 후 최근접 관로 재매핑 상태가 `변경한 위치와 최근접 관로를 확인했습니다.`로 변경되는 것을 확인했다. +- 기존 사진 `×` 클릭 시 0/5장과 빈 목록으로 바뀌고, 저장하지 않고 다시 열면 서버의 사진 1장이 복원되는 것을 확인했다. +- 실제 저장 후 개발 API에서 수정 위치와 최근접 거리 변경, 관로별 사진 1장 유지가 확인됐다. 브라우저 콘솔의 수정 팝업 오류는 0건이다. +- 화면 증빙: `D:/workspace/IIPBS/docs/작업내용/SFR-010-013-준설수정/검증/001-상세기반-수정팝업.png` + +### 추적 근거 + +- 요구사항정의서 `SFR-010-012`, `SFR-010-013`, 화면설계서 51~52번 슬라이드와 화면 ID `SN_MN040201`을 확인했다. +- 상세 UI를 수정 기반으로 재사용하고 사진·위치 편집을 추가한 부분은 사용자 지시에 따른 설계 보완이다. diff --git a/apps/sn/src/app/styles/components.css b/apps/sn/src/app/styles/components.css index 707983373..948bbe8a4 100644 --- a/apps/sn/src/app/styles/components.css +++ b/apps/sn/src/app/styles/components.css @@ -88,6 +88,34 @@ font-size: 1.8rem; } + /* 부모 조회 화면의 날짜 입력 규격과 등록 팝업의 준설일자 입력을 동일하게 유지한다. */ + .sn-dredging-register .sn-dredging-date-input { + color: #666 !important; + font-size: 12.5px !important; + } + + /* 준설사진 파일명은 메타정보 표에서만 표시하고 공통 업로드 목록의 중복 표출을 숨긴다. */ + .sn-dredging-photo-upload [aria-description="content file holder"] { + display: none; + } + + /* 등록 팝업의 첨부영역을 한 줄 높이로 압축해 하단 지도 가용 높이를 확보한다. */ + .sn-dredging-photo-upload .fileup_root { + @apply rounded-none bg-transparent p-0; + } + + .sn-dredging-photo-upload .fileup_panel { + @apply min-h-10 flex-row gap-1 py-1; + } + + .sn-dredging-photo-upload .fileup_hint { + @apply mb-0; + } + + .sn-dredging-photo-upload .fileup_meta { + @apply ml-1; + } + .sn-popup-filter-panel { @apply grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4; } diff --git a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts index 95fafb96f..6b706cd59 100644 --- a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts +++ b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts @@ -29,6 +29,7 @@ const clamp = (value: number, min: number, max: number) => { const buildPopupFeatures = ({ width, height, + maximize = false, placement = "center", alignTop = false, offsetLeft = 0, @@ -36,7 +37,9 @@ const buildPopupFeatures = ({ }: Pick< WindowPopupDefinition, "width" | "height" | "placement" | "alignTop" | "offsetLeft" | "offsetTop" ->) => { +> & { + maximize?: boolean; +}) => { const screenLeft = window.screenX ?? window.screenLeft ?? 0; const screenTop = window.screenY ?? window.screenTop ?? 0; const outerWidth = window.outerWidth || window.screen.width; @@ -49,27 +52,34 @@ const buildPopupFeatures = ({ const availableTop = screen.availTop ?? 0; const availableWidth = window.screen.availWidth || outerWidth; const availableHeight = window.screen.availHeight || outerHeight; - const safeWidth = Math.min(width, Math.max(360, availableWidth - 48)); - const safeHeight = Math.min(height, Math.max(420, availableHeight - 72)); + const safeWidth = maximize + ? availableWidth + : Math.min(width, Math.max(360, availableWidth - 48)); + const safeHeight = maximize + ? availableHeight + : Math.min(height, Math.max(420, availableHeight - 72)); const minLeft = availableLeft; const minTop = availableTop + 24; const maxLeft = availableLeft + availableWidth - safeWidth - 16; const maxTop = availableTop + availableHeight - safeHeight - 24; - const left = - placement === "browser-left" + const left = maximize + ? availableLeft + : placement === "browser-left" ? clamp(screenLeft + offsetLeft, minLeft, maxLeft) : clamp( screenLeft + Math.round((outerWidth - safeWidth) / 2) + offsetLeft, availableLeft + 16, maxLeft, ); - const top = clamp( - alignTop - ? screenTop + 24 + offsetTop - : screenTop + Math.round((outerHeight - safeHeight) / 2) + offsetTop, - minTop, - maxTop, - ); + const top = maximize + ? availableTop + : clamp( + alignTop + ? screenTop + 24 + offsetTop + : screenTop + Math.round((outerHeight - safeHeight) / 2) + offsetTop, + minTop, + maxTop, + ); return [ `width=${safeWidth}`, @@ -99,6 +109,7 @@ export const openWindowPopup = ( ...definition, width: options?.width ?? definition.width, height: options?.height ?? definition.height, + maximize: options?.maximize, }); const popupWindow = window.open( popupUrl, @@ -106,6 +117,20 @@ export const openWindowPopup = ( popupFeatures, ); + if (popupWindow && options?.maximize) { + const screen = window.screen as Screen & { + availLeft?: number; + availTop?: number; + }; + try { + // 브라우저가 window.open의 최대 크기를 제한하더라도 가용 화면 전체로 한 번 더 맞춘다. + popupWindow.moveTo(screen.availLeft ?? 0, screen.availTop ?? 0); + popupWindow.resizeTo(screen.availWidth, screen.availHeight); + } catch { + // 브라우저 정책이 이동·크기 변경을 막으면 window.open feature의 최대 크기를 유지한다. + } + } + popupWindow?.focus(); return popupWindow; diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index 8519f2b95..990573f19 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -27,6 +27,20 @@ export const windowPopupDefinitions = { width: 1280, height: 900, }, + dredgingDetail: { + key: "dredgingDetail", + title: "준설결과 상세", + width: 1280, + height: 900, + requiredParams: ["dredgingJobSn", "pipeFid", "longitude", "latitude"], + }, + dredgingEdit: { + key: "dredgingEdit", + title: "준설결과 수정", + width: 1280, + height: 900, + requiredParams: ["dredgingJobSn", "pipeFid", "longitude", "latitude"], + }, exmnPipeReport: { key: "exmnPipeReport", title: "관로 조사보고서", diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts index d008c4cbf..4b8dfa7fd 100644 --- a/apps/sn/src/features/windowPopup/model/types.ts +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -30,16 +30,19 @@ export type OpenWindowPopupParams = Record< string | number | boolean | null | undefined >; -/** 팝업별 기본 정의를 유지하면서 호출 화면에서 필요한 크기만 재정의한다. */ +/** 팝업별 기본 정의를 유지하면서 호출 화면에서 필요한 크기와 최대화 여부를 재정의한다. */ export type OpenWindowPopupOptions = Partial< Pick ->; +> & { + maximize?: boolean; +}; export const WINDOW_POPUP_MESSAGE_TYPE = { EXMN_BUSINESS_CREATED: "sn:popup:exmnBusinessCreated", EXMN_BUSINESS_UPDATED: "sn:popup:exmnBusinessUpdated", EXMN_RESULT_COMMITTED: "sn:popup:exmnResultCommitted", DREDGING_REGISTERED: "sn:popup:dredgingRegistered", + DREDGING_UPDATED: "sn:popup:dredgingUpdated", OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted", FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted", MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature", @@ -50,8 +53,10 @@ export const WINDOW_POPUP_MESSAGE_TYPE = { GIS_EDIT_SESSION_SYNC: "sn:popup:gisEditSessionSync", GIS_EDIT_SESSION_SHOW_INLINE: "sn:popup:gisEditSessionShowInline", GIS_EDIT_SESSION_FOCUS_CHANGE: "sn:popup:gisEditSessionFocusChange", - GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE: "sn:popup:gisEditSessionRemoveFocusedChange", - GIS_EDIT_SESSION_REMOVE_LAST_CHANGE: "sn:popup:gisEditSessionRemoveLastChange", + GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE: + "sn:popup:gisEditSessionRemoveFocusedChange", + 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", diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx index 6de024d37..b178aa19f 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -1,8 +1,13 @@ -import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefinitions"; +import { + windowPopupDefinitions, + type WindowPopupKey, +} from "./model/popupDefinitions"; import type { WindowPopupRegistryItem } from "./model/types"; import AgedPipePopupContent from "./ui/AgedPipePopupContent"; import AgedPipeStatusByCtpvPopupContent from "./ui/AgedPipeStatusByCtpvPopupContent"; import DredgingRegisterPopupContent from "./ui/DredgingRegisterPopupContent"; +import DredgingDetailPopupContent from "./ui/DredgingDetailPopupContent"; +import DredgingEditPopupContent from "./ui/DredgingEditPopupContent"; import ExmnBusinessRegisterPopupContent from "./ui/ExmnBusinessRegisterPopupContent"; import ExmnPipeReportPopupContent from "./ui/ExmnPipeReportPopupContent"; import ExmnResultRegisterPopupContent from "./ui/ExmnResultRegisterPopupContent"; @@ -31,14 +36,18 @@ import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent"; import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent"; import WaterRiskPopupContent from "./ui/WaterRiskPopupContent"; -const FloodPredictionPlaceholderPopupContent = (_props: WindowPopupContentProps) => ( +const FloodPredictionPlaceholderPopupContent = ( + _props: WindowPopupContentProps, +) => ( ); const GisEditPopupContent = (_props: WindowPopupContentProps) => ( - + ); export const windowPopupRegistry = { @@ -58,6 +67,14 @@ export const windowPopupRegistry = { ...windowPopupDefinitions.dredgingRegister, component: DredgingRegisterPopupContent, }, + dredgingDetail: { + ...windowPopupDefinitions.dredgingDetail, + component: DredgingDetailPopupContent, + }, + dredgingEdit: { + ...windowPopupDefinitions.dredgingEdit, + component: DredgingEditPopupContent, + }, exmnPipeReport: { ...windowPopupDefinitions.exmnPipeReport, component: ExmnPipeReportPopupContent, diff --git a/apps/sn/src/features/windowPopup/ui/DredgingDetailPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/DredgingDetailPopupContent.tsx new file mode 100644 index 000000000..9edbfc824 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/DredgingDetailPopupContent.tsx @@ -0,0 +1,58 @@ +"use client"; + +import DredgingDetailPopupPage from "@/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage"; + +import type { DredgingRecord } from "@/app/(with-base-layout)/oper/dredging/lib/types"; +import type { WindowPopupContentProps } from "../model/types"; + +const getParam = (value: string | string[] | undefined) => + Array.isArray(value) ? (value[0] ?? "") : (value ?? ""); + +const getNumber = (value: string | string[] | undefined) => { + const parsed = Number(getParam(value)); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const getNullableNumber = (value: string | string[] | undefined) => { + const raw = getParam(value); + if (!raw) return null; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; +}; + +/** 목록 행의 직렬화된 값을 준설 상세 화면 모델로 복원한다. */ +export const buildDredgingPopupRecord = ( + searchParams: WindowPopupContentProps["searchParams"], +): DredgingRecord => ({ + id: getParam(searchParams.id), + dredgingJobSn: getParam(searchParams.dredgingJobSn), + pipeFid: getParam(searchParams.pipeFid), + mngInstCd: getParam(searchParams.mngInstCd), + ctpv: getParam(searchParams.ctpv), + sgg: getParam(searchParams.sgg), + treatmentZone: getParam(searchParams.treatmentZone), + fcltMngNo: getParam(searchParams.fcltMngNo), + pipeDiameterMm: getNumber(searchParams.pipeDiameterMm), + pipeLengthM: getNumber(searchParams.pipeLengthM), + pipeMaterial: getParam(searchParams.pipeMaterial), + installationYear: getNullableNumber(searchParams.installationYear), + dredgingDate: getParam(searchParams.dredgingDate), + photoCount: getNumber(searchParams.photoCount), + registeredDate: getParam(searchParams.registeredDate), + longitude: getNumber(searchParams.longitude), + latitude: getNumber(searchParams.latitude), + snappedLongitude: getNumber(searchParams.snappedLongitude), + snappedLatitude: getNumber(searchParams.snappedLatitude), + registrationSource: + getParam(searchParams.registrationSource) === "MANUAL" ? "MANUAL" : "EXIF", + matchDistanceMeters: getNumber(searchParams.matchDistanceMeters), + isMock: getParam(searchParams.isMock) === "true", +}); + +export default function DredgingDetailPopupContent({ + searchParams, +}: WindowPopupContentProps) { + return ( + + ); +} diff --git a/apps/sn/src/features/windowPopup/ui/DredgingEditPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/DredgingEditPopupContent.tsx new file mode 100644 index 000000000..8ab7bf42e --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/DredgingEditPopupContent.tsx @@ -0,0 +1,27 @@ +"use client"; + +import DredgingDetailPopupPage from "@/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage"; + +import { postPopupMessage } from "../lib/popupMessage"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types"; +import type { WindowPopupContentProps } from "../model/types"; +import { buildDredgingPopupRecord } from "./DredgingDetailPopupContent"; + +/** 상세 화면과 같은 구조에서 선택 관로의 사진·위치·준설일자를 수정한다. */ +export default function DredgingEditPopupContent({ + searchParams, +}: WindowPopupContentProps) { + return ( + { + postPopupMessage(WINDOW_POPUP_MESSAGE_TYPE.DREDGING_UPDATED, { + dredgingJobSn: Array.isArray(searchParams.dredgingJobSn) + ? (searchParams.dredgingJobSn[0] ?? "") + : (searchParams.dredgingJobSn ?? ""), + }); + }} + /> + ); +} diff --git a/apps/sn/src/features/windowPopup/ui/DredgingRegisterPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/DredgingRegisterPopupContent.tsx index f75d79b0a..cc05abe62 100644 --- a/apps/sn/src/features/windowPopup/ui/DredgingRegisterPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/DredgingRegisterPopupContent.tsx @@ -1,6 +1,6 @@ "use client"; -import DredgingRegisterPopupPage from "@/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterPopupPage"; +import DredgingRegisterPopupPage from "@/app/(with-base-layout)/oper/dredging/components/DredgingRegisterPopupPage"; import { useEffect, useState } from "react"; /** 준설결과 등록 화면을 공통 WindowPopup 레이아웃에 연결한다. */