From b4033071a17a8a99132944802d6e17bada373efa Mon Sep 17 00:00:00 2001 From: kiminchan Date: Thu, 21 May 2026 13:57:16 +0900 Subject: [PATCH] =?UTF-8?q?=ED=95=98=EC=88=98=EA=B4=80=EB=A1=9C=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=83=81=EC=84=B8=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=B6=94=EA=B0=80,=20=ED=95=98=EC=88=98?= =?UTF-8?q?=EA=B4=80=EB=A7=9D=EB=8F=84=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=8B=9C=EC=84=A4=20=EC=84=A0=ED=83=9D=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gisEdit/GisEditPipeSessionPopup.tsx | 228 +++++++++++++++++ .../gisEdit/GisEditSessionPanel.tsx | 17 +- .../map/components/gisEdit/gisEditTypes.ts | 25 ++ .../map/components/gisEdit/useGisEdit.ts | 112 ++++++++- .../gisManagement/GisManagementPopupPage.tsx | 124 --------- .../components/popupComponent/MsSwagePpl.tsx | 2 +- apps/sn/app/(map-layout)/map/page.tsx | 236 +++++++++++++++++- .../001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md | 5 +- .../001-qa-SFR-012-003-하수관로편집-통합검증계획.md | 14 ++ .../002-qa-SFR-012-003-하수관로편집-통합검증결과.md | 44 ++++ .../001-fe-SFR-012-002-하수관망도 업로드-작업카드.md | 3 +- .../001-fe-SFR-012-003-하수관로편집-작업카드.md | 17 +- .../001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md | 10 +- .../001-fe-SFR-012-003-하수관로편집-통합구현기록.md | 34 +++ .../business_components/src/ui/map/Gis.js | 3 +- .../src/ui/map/GisSelect.js | 12 +- 16 files changed, 728 insertions(+), 158 deletions(-) create mode 100644 apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx new file mode 100644 index 000000000..83bd8564f --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import { Button } from "@common/components/ui"; + +import { getData } from "@/components/lib/api"; +import { VerticalTable } from "@/components/ui/verticalTable"; +import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; + +import type { GisEditChange, GisEditPipeBasicInfo } from "./gisEditTypes"; +import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES, MsSwagePplCol } from "../common/table"; + +type Props = { + change: GisEditChange; + featureData: any; + onClose: () => void; + onSave: (changeId: string, draft: GisEditPipeBasicInfo) => void; +}; + +const PIPE_INPUT_INFO_FIELDS = [ + { id: "instlMbdyCd", title: "설치주체", type: "select", options: ["", "BTL", "재정사업"] }, + { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, + { id: "mltvZoneNm", title: "배수구역", type: "input" }, + { id: "mltvDtdvNm", title: "배수분구", type: "input" }, + { id: "lineCd", title: "지선/간선", type: "select", options: ["", "지선", "간선"] }, + { id: "floCd", title: "흐름방식", type: "select", options: ["", "자연유하", "압송"] }, + { id: "layCd", title: "매설위치", type: "select", options: ["", "도로", "하상"] }, +]; + +const PIPE_BASIC_INFO_FIELDS = MsSwagePplCol.map((column) => { + if (column.id === "usg_cd") { + return { + ...column, + type: "select", + options: ["", ...MS_SWAGE_PPL_USE_CODES.map((item) => item.text)], + }; + } + + if (column.id === "mqlt_cd") { + return { + ...column, + type: "select", + options: ["", ...MS_SWAGE_PPL_CODES.map((item) => item.text)], + }; + } + + return { + ...column, + type: "input", + }; +}); + +const buildBaseFeatureValues = (featureData: any) => { + const resolveUsageText = (value: unknown) => { + const raw = String(value ?? "").trim(); + const matched = MS_SWAGE_PPL_USE_CODES.find((item) => item.val === raw || item.text === raw); + return matched?.text ?? raw; + }; + + const resolveMaterialText = (value: unknown) => { + const raw = String(value ?? "").trim(); + const matched = MS_SWAGE_PPL_CODES.find((item) => item.val === raw || item.text === raw); + return matched?.text ?? raw; + }; + + if (featureData?.values_) { + const formatted = formatDisplayData(featureData.values_); + return { + ...formatted, + usg_cd: resolveUsageText(featureData.values_?.usg_cd ?? formatted.usg_cd), + mqlt_cd: resolveMaterialText(featureData.values_?.mqlt_cd ?? formatted.mqlt_cd), + }; + } + + if (featureData) { + const formatted = formatDisplayData(featureData); + return { + ...formatted, + usg_cd: resolveUsageText(featureData?.usg_cd ?? formatted.usg_cd), + mqlt_cd: resolveMaterialText(featureData?.mqlt_cd ?? formatted.mqlt_cd), + }; + } + + return {}; +}; + +const buildInitialInputDraft = (change: GisEditChange, baseValues: Record) => ({ + fcltMngNo: String(change.basicInfo?.fcltMngNo ?? change.featureRef?.fcltMngNo ?? baseValues.fclt_mng_no ?? baseValues.fcltMngNo ?? ""), + mngInstCd: String(change.basicInfo?.mngInstCd ?? change.featureRef?.mngInstCd ?? baseValues.mng_inst_cd ?? baseValues.mngInstCd ?? ""), + instlMbdyCd: String(change.basicInfo?.instlMbdyCd ?? ""), + swtrmtZoneNm: String(change.basicInfo?.swtrmtZoneNm ?? ""), + mltvZoneNm: String(change.basicInfo?.mltvZoneNm ?? ""), + mltvDtdvNm: String(change.basicInfo?.mltvDtdvNm ?? ""), + lineCd: String(change.basicInfo?.lineCd ?? ""), + floCd: String(change.basicInfo?.floCd ?? ""), + layCd: String(change.basicInfo?.layCd ?? ""), +}); + +export default function GisEditPipeSessionPopup({ + change, + featureData, + onClose, + onSave, +}: Props) { + const isNewPipe = change.editType === "ADD"; + const baseFeatureValues = useMemo(() => buildBaseFeatureValues(featureData), [featureData]); + const [basicInfoTableData, setBasicInfoTableData] = useState>({}); + const [inputInfoTableData, setInputInfoTableData] = useState>( + buildInitialInputDraft(change, baseFeatureValues), + ); + + useEffect(() => { + const initialBasicValues: Record = { + ...baseFeatureValues, + ...(change.basicInfo ?? {}), + }; + + if (isNewPipe) { + delete initialBasicValues.instl_ymd; + delete initialBasicValues.elapsedYears; + } + + setBasicInfoTableData(initialBasicValues); + setInputInfoTableData(buildInitialInputDraft(change, baseFeatureValues)); + }, [baseFeatureValues, change.changeId, isNewPipe]); + + useEffect(() => { + if (isNewPipe) return; + + const fcltMngNo = String(change.featureRef?.fcltMngNo ?? "").trim(); + const mngInstCd = String(change.featureRef?.mngInstCd ?? "").trim(); + if (!fcltMngNo || !mngInstCd) return; + + let isMounted = true; + const fetchPipeInputInfo = async () => { + try { + const result = await getData(`v1/fcltppl/${fcltMngNo}/${mngInstCd}`); + if (!isMounted || !result) return; + + setInputInfoTableData((previous) => ({ + ...previous, + ...result, + ...(change.basicInfo ?? {}), + fcltMngNo, + mngInstCd, + })); + } catch (error) { + console.error("GIS 편집 관로 입력정보 조회 실패:", error); + } + }; + + fetchPipeInputInfo(); + return () => { + isMounted = false; + }; + }, [change.changeId, change.featureRef?.fcltMngNo, change.featureRef?.mngInstCd, isNewPipe]); + + const editableBasicInfoFields = useMemo( + () => PIPE_BASIC_INFO_FIELDS.filter((field) => !(isNewPipe && (field.id === "instl_ymd" || field.id === "elapsedYears"))), + [isNewPipe], + ); + + const buildMergedDraft = ( + nextBasicInfoTableData: Record, + nextInputInfoTableData: Record, + ): GisEditPipeBasicInfo => ({ + ...nextBasicInfoTableData, + ...nextInputInfoTableData, + fcltMngNo: String(nextInputInfoTableData.fcltMngNo ?? change.featureRef?.fcltMngNo ?? change.objectId ?? ""), + mngInstCd: String(nextInputInfoTableData.mngInstCd ?? change.featureRef?.mngInstCd ?? ""), + }); + + const handleBasicInfoChange = (nextFormData: Record) => { + setBasicInfoTableData(nextFormData); + onSave(change.changeId, buildMergedDraft(nextFormData, inputInfoTableData)); + }; + + const handleInputInfoChange = (nextFormData: Record) => { + setInputInfoTableData(nextFormData); + onSave(change.changeId, buildMergedDraft(basicInfoTableData, nextFormData)); + }; + + return ( + + ); +} diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel.tsx b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel.tsx index 0900d875b..0ed92687d 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel.tsx +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel.tsx @@ -42,11 +42,18 @@ const ChangeList = ({ changes, focusedChangeId, onFocusChange }: ChangeListProps
{index + 1}. {TOOL_LABELS[change.editType]} / {change.objectLabel}
- {focusedChangeId === change.changeId ? ( - - 지도 미리보기 선택 - - ) : null} +
+ {change.basicInfoSavedAt ? ( + + 기본정보 세션저장 + + ) : null} + {focusedChangeId === change.changeId ? ( + + 지도 미리보기 선택 + + ) : null} +
{change.summary}
diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts index 34e61e77c..dd2bfd3ed 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/gisEditTypes.ts @@ -8,6 +8,25 @@ export type GisEditPreviewColors = { fillColor: string; }; +export type GisEditFeatureRef = { + fcltMngNo?: string; + mngInstCd?: string; + fid?: string; +}; + +export type GisEditPipeBasicInfo = { + [key: string]: string | undefined; + fcltMngNo?: string; + mngInstCd?: string; + instlMbdyCd?: string; + swtrmtZoneNm?: string; + mltvZoneNm?: string; + mltvDtdvNm?: string; + lineCd?: string; + floCd?: string; + layCd?: string; +}; + export type GisEditLayerInfo = { layerId: string; layerName: string; @@ -24,9 +43,12 @@ export type GisEditChange = { layerId: string; layerName: string; objectId?: string; + featureRef?: GisEditFeatureRef; objectLabel: string; beforeGeometry?: Record | null; afterGeometry?: Record | null; + basicInfo?: GisEditPipeBasicInfo | null; + basicInfoSavedAt?: string | null; summary: string; createdAt: string; source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW"; @@ -42,9 +64,12 @@ export type GisEditSavePayload = { bbox: string | null; }; changes: Array<{ + changeId: string; editType: GisEditTool; objectId?: string; clientTempId?: string; + featureRef?: GisEditFeatureRef; + basicInfo?: GisEditPipeBasicInfo | null; beforeGeometry?: Record | null; afterGeometry?: Record | null; geometry?: Record | null; diff --git a/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts index e99c7c4d9..75de19699 100644 --- a/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts +++ b/apps/sn/app/(map-layout)/map/components/gisEdit/useGisEdit.ts @@ -5,8 +5,10 @@ import GeoJSON from "ol/format/GeoJSON.js"; import type { GisEditChange, + GisEditFeatureRef, GisEditGeometryType, GisEditLayerInfo, + GisEditPipeBasicInfo, GisEditPreviewColors, GisEditSavePayload, GisEditTool, @@ -110,6 +112,19 @@ const EDITABLE_LAYER_RULES: Record< const FLOW_DIRECTION_SRS = "EPSG:5179"; const SCREEN_ID = "SN_MN010301"; +const GIS_EDIT_ALLOWED_LAYER_IDS = new Set(["tb_ms_swage_ppl"]); + +export const EMPTY_GIS_EDIT_PIPE_BASIC_INFO: GisEditPipeBasicInfo = { + fcltMngNo: "", + mngInstCd: "", + instlMbdyCd: "", + swtrmtZoneNm: "", + mltvZoneNm: "", + mltvDtdvNm: "", + lineCd: "", + floCd: "", + layCd: "", +}; const normalizeLayerIdCandidate = (value: unknown) => { const raw = String(value ?? "").trim(); @@ -211,7 +226,7 @@ const resolvePreviewColors = ( }; }; -// FE 1차 구현은 시설 레이어 주요 7종을 기본 편집 후보로 열고, treeData에 없더라도 fallback 후보를 제공한다. +// 현행 운영 기준상 GIS 편집은 하수관로만 다루므로 treeData에서도 해당 레이어만 추린다. const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => { const results = new Map(); @@ -221,7 +236,11 @@ const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => { const layerTypeName = getLayerTypeName(data); const layerRule = EDITABLE_LAYER_RULES[layerTypeName]; - if (layerRule && !results.has(layerTypeName)) { + if ( + layerRule && + GIS_EDIT_ALLOWED_LAYER_IDS.has(layerTypeName) && + !results.has(layerTypeName) + ) { results.set(layerTypeName, { layerId: layerTypeName, layerName: layerRule.layerName, @@ -245,6 +264,7 @@ const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => { if (results.size === 0) { Object.entries(EDITABLE_LAYER_RULES).forEach(([layerId, layerRule]) => { + if (!GIS_EDIT_ALLOWED_LAYER_IDS.has(layerId)) return; results.set(layerId, { layerId, layerName: layerRule.layerName, @@ -311,6 +331,36 @@ const getFeatureObjectId = (featureData: any) => "", ).trim(); +const getFeatureRef = (featureData: any): GisEditFeatureRef | undefined => { + const mapValues = featureData?.values_ ?? {}; + const featureRef: GisEditFeatureRef = { + fcltMngNo: String( + featureData?.get?.("fclt_mng_no") ?? + mapValues?.fclt_mng_no ?? + featureData?.fcltMngNo ?? + "", + ).trim() || undefined, + mngInstCd: String( + featureData?.get?.("mng_inst_cd") ?? + mapValues?.mng_inst_cd ?? + featureData?.mngInstCd ?? + "", + ).trim() || undefined, + fid: String( + featureData?.getId?.() ?? + featureData?.id_ ?? + featureData?.fid ?? + "", + ).trim() || undefined, + }; + + if (!featureRef.fcltMngNo && !featureRef.mngInstCd && !featureRef.fid) { + return undefined; + } + + return featureRef; +}; + const createChangeId = (prefix: string, sequence: number) => { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return `${prefix}-${crypto.randomUUID()}`; @@ -476,9 +526,18 @@ export const useGisEdit = (treeData: any[]) => { layerId: selectedLayer.layerId, layerName: selectedLayer.layerName, objectId: clientTempId, + featureRef: undefined, objectLabel: clientTempId, beforeGeometry: null, afterGeometry: geometry, + basicInfo: + selectedLayer.layerId === "tb_ms_swage_ppl" + ? { + ...EMPTY_GIS_EDIT_PIPE_BASIC_INFO, + fcltMngNo: clientTempId, + } + : null, + basicInfoSavedAt: null, summary: `${selectedLayer.layerName} 신규 객체를 지도에서 직접 입력했습니다.`, createdAt: new Date().toISOString(), source: "MAP_DRAW", @@ -501,17 +560,22 @@ export const useGisEdit = (treeData: any[]) => { focusChange(previewChangeId); return { handled: true, + isPreviewSelection: true, changeId: previewChangeId, editType: previewChange.editType, }; } if (!activeTool || !selectedLayer) return { handled: false }; - if (activeTool === "ADD") return { handled: true }; + if (activeTool === "ADD") return { handled: true, isPreviewSelection: false }; const featureLayerId = getFeatureLayerId(featureData); if (featureLayerId !== selectedLayer.layerId) { - setStatusMessage(`현재 선택 레이어는 ${selectedLayer.layerName}입니다. 같은 레이어의 객체를 선택하세요.`); + setStatusMessage( + selectedLayer.layerId === "tb_ms_swage_ppl" + ? "하수관로를 선택해주세요." + : `현재 선택 레이어는 ${selectedLayer.layerName}입니다. 같은 레이어의 객체를 선택하세요.`, + ); return { handled: true }; } @@ -536,6 +600,7 @@ export const useGisEdit = (treeData: any[]) => { focusChange(existingChange.changeId); return { handled: true, + isPreviewSelection: false, changeId: existingChange.changeId, editType: existingChange.editType, }; @@ -543,6 +608,7 @@ export const useGisEdit = (treeData: any[]) => { const geometryObject = toGeometryObject(featureData); const geometryType = normalizeGeometryType(geometryObject?.type); + const featureRef = getFeatureRef(featureData); const nextGeometry = activeTool === "MOVE" ? geometryObject @@ -566,9 +632,12 @@ export const useGisEdit = (treeData: any[]) => { layerId: selectedLayer.layerId, layerName: selectedLayer.layerName, objectId, + featureRef, objectLabel: objectId, beforeGeometry: geometryObject, afterGeometry: nextGeometry, + basicInfo: null, + basicInfoSavedAt: null, summary: activeTool === "VERTEX_EDIT" && geometryType === "Point" ? `${selectedLayer.layerName} ${objectId}는 점형 객체여서 꼭지점편집 대신 geometry 검토 대상으로 기록했습니다.` @@ -587,7 +656,7 @@ export const useGisEdit = (treeData: any[]) => { } else { setPendingInteraction(null); } - return { handled: true, changeId, editType: activeTool }; + return { handled: true, isPreviewSelection: false, changeId, editType: activeTool }; }, [activeTool, appendChange, findExistingObjectChange, focusChange, isEditMode, selectedLayer]); // 저장 API가 붙기 전까지는 실제 전송 대신 최종 payload를 조립해 검증 패널에 남긴다. @@ -607,9 +676,12 @@ export const useGisEdit = (treeData: any[]) => { bbox: buildBbox(map), }, changes: changes.map((change) => ({ + changeId: change.changeId, editType: change.editType, objectId: change.editType === "ADD" ? undefined : change.objectId, clientTempId: change.editType === "ADD" ? change.objectId : undefined, + featureRef: change.featureRef, + basicInfo: change.basicInfo ?? undefined, beforeGeometry: change.beforeGeometry, afterGeometry: change.editType === "DELETE" ? null : change.afterGeometry, geometry: change.editType === "ADD" ? change.afterGeometry : undefined, @@ -617,7 +689,7 @@ export const useGisEdit = (treeData: any[]) => { }; setLastSavedPayload(payload); - setStatusMessage("백엔드 저장 API가 아직 없어 payload 미리보기만 생성했습니다. 이 구조를 기준으로 BE와 연동하면 됩니다."); + setStatusMessage("관로 기본정보와 geometry를 함께 담은 최종 저장 payload를 생성했습니다. 현재는 FE 세션 검토 단계이며, 실제 DB 반영은 백엔드 저장 API가 필요합니다."); return payload; }, [changes, selectedLayer]); @@ -670,6 +742,31 @@ export const useGisEdit = (treeData: any[]) => { setStatusMessage(summary); }, []); + const updateChangeBasicInfo = useCallback(( + changeId: string, + basicInfo: GisEditPipeBasicInfo, + ) => { + setLastSavedPayload(null); + setChanges((previous) => + previous.map((change) => + change.changeId === changeId + ? { + ...change, + featureRef: { + ...change.featureRef, + fcltMngNo: basicInfo.fcltMngNo || change.featureRef?.fcltMngNo, + mngInstCd: basicInfo.mngInstCd || change.featureRef?.mngInstCd, + }, + basicInfo, + basicInfoSavedAt: new Date().toISOString(), + } + : change, + ), + ); + setFocusedChangeId(changeId); + setStatusMessage("관로 기본정보를 편집 세션에 저장했습니다. 최종 GIS 저장 전까지는 DB에 반영되지 않습니다."); + }, []); + const clearPendingInteraction = useCallback(() => { setPendingInteraction(null); }, []); @@ -692,7 +789,7 @@ export const useGisEdit = (treeData: any[]) => { pendingInteraction, focusedChangeId, pendingApiMessage: - "현재 구현은 FE 로컬 편집 세션과 저장 payload 미리보기까지 포함합니다. BE 저장 API(`/api/v1/map/gisEditSessions/save`) 연결 전까지는 실제 저장 대신 payload를 검토합니다.", + "현재 구현은 FE 로컬 편집 세션, 관로 기본정보 draft, 저장 payload 미리보기까지 포함합니다. BE 저장 API(`/api/v1/map/gisEditSessions/save`) 연결 전까지는 실제 저장 대신 payload를 검토합니다.", canUndo: changes.length > 0, toggleEditMode, selectLayer, @@ -703,6 +800,7 @@ export const useGisEdit = (treeData: any[]) => { removeLastChange, clearChanges, updateChangeGeometry, + updateChangeBasicInfo, clearPendingInteraction, focusChange, announceStatus, diff --git a/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx index 33eae9683..a838764d8 100644 --- a/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx @@ -29,18 +29,6 @@ const UPLOAD_FACILITY_TYPES: UploadFacilityType[] = [ allowedExtensions: [".zip", ".shp", ".geojson", ".json", ".dxf"], maxFileSizeMb: 200, }, - { - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - allowedExtensions: [".zip", ".shp", ".geojson", ".json"], - maxFileSizeMb: 100, - }, - { - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - allowedExtensions: [".zip", ".shp", ".geojson"], - maxFileSizeMb: 150, - }, ]; const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ @@ -52,22 +40,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ registeredAt: "2026-05-04", registeredBy: "admin", }, - { - uploadId: "GUP-MOCK-20260430-0002", - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - fileName: "asan_manhole_20260430.zip", - registeredAt: "2026-04-30", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260421-0003", - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - fileName: "00000", - registeredAt: "", - registeredBy: "", - }, { uploadId: "GUP-MOCK-20260418-0004", facilityTypeCode: "PPL", @@ -76,22 +48,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ registeredAt: "2026-04-18", registeredBy: "admin", }, - { - uploadId: "GUP-MOCK-20260415-0005", - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - fileName: "asan_manhole_20260415.zip", - registeredAt: "2026-04-15", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260412-0006", - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - fileName: "asan_swtrmt_20260412.zip", - registeredAt: "2026-04-12", - registeredBy: "admin", - }, { uploadId: "GUP-MOCK-20260409-0007", facilityTypeCode: "PPL", @@ -100,22 +56,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ registeredAt: "2026-04-09", registeredBy: "admin", }, - { - uploadId: "GUP-MOCK-20260406-0008", - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - fileName: "asan_manhole_20260406.zip", - registeredAt: "2026-04-06", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260403-0009", - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - fileName: "asan_treatment_20260403.zip", - registeredAt: "2026-04-03", - registeredBy: "admin", - }, { uploadId: "GUP-MOCK-20260331-0010", facilityTypeCode: "PPL", @@ -124,22 +64,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ registeredAt: "2026-03-31", registeredBy: "admin", }, - { - uploadId: "GUP-MOCK-20260328-0011", - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - fileName: "asan_manhole_20260328.zip", - registeredAt: "2026-03-28", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260325-0012", - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - fileName: "asan_swtrmt_20260325.zip", - registeredAt: "2026-03-25", - registeredBy: "admin", - }, { uploadId: "GUP-MOCK-20260322-0013", facilityTypeCode: "PPL", @@ -148,22 +72,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ registeredAt: "2026-03-22", registeredBy: "admin", }, - { - uploadId: "GUP-MOCK-20260319-0014", - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - fileName: "asan_manhole_20260319.zip", - registeredAt: "2026-03-19", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260316-0015", - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - fileName: "asan_treatment_20260316.zip", - registeredAt: "2026-03-16", - registeredBy: "admin", - }, { uploadId: "GUP-MOCK-20260313-0016", facilityTypeCode: "PPL", @@ -172,22 +80,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ registeredAt: "2026-03-13", registeredBy: "admin", }, - { - uploadId: "GUP-MOCK-20260310-0017", - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - fileName: "asan_manhole_20260310.zip", - registeredAt: "2026-03-10", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260307-0018", - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - fileName: "asan_swtrmt_20260307.zip", - registeredAt: "2026-03-07", - registeredBy: "admin", - }, { uploadId: "GUP-MOCK-20260304-0019", facilityTypeCode: "PPL", @@ -196,22 +88,6 @@ const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [ registeredAt: "2026-03-04", registeredBy: "admin", }, - { - uploadId: "GUP-MOCK-20260301-0020", - facilityTypeCode: "MHL", - facilityTypeName: "하수맨홀", - fileName: "asan_manhole_20260301.zip", - registeredAt: "2026-03-01", - registeredBy: "admin", - }, - { - uploadId: "GUP-MOCK-20260226-0021", - facilityTypeCode: "SWTRMT_FCLT", - facilityTypeName: "하수처리시설", - fileName: "asan_treatment_20260226.zip", - registeredAt: "2026-02-26", - registeredBy: "admin", - }, ]; const getFileExtension = (fileName: string) => { diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx index 0e2ec3780..2fda0b285 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx @@ -160,4 +160,4 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic ) } -export default MsSwagePpl \ No newline at end of file +export default MsSwagePpl diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 90bfb5cae..73ef17a3c 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -52,8 +52,9 @@ import useStore from "@/store/useStore"; import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel"; import GisEditToolbar from "./components/gisEdit/GisEditToolbar"; +import GisEditPipeSessionPopup from "./components/gisEdit/GisEditPipeSessionPopup"; import { useGisEdit } from "./components/gisEdit/useGisEdit"; -import type { GisEditSessionSnapshot } from "./components/gisEdit/gisEditTypes"; +import type { GisEditPipeBasicInfo, GisEditSessionSnapshot } from "./components/gisEdit/gisEditTypes"; import { PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE, type PipeAnalysisDetailRow, @@ -626,6 +627,19 @@ const getSquaredPixelDistance = ( return (endX - startX) ** 2 + (endY - startY) ** 2; }; +const GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD = 12; + +const getAverageCoordinate = (coordinates: number[][]) => { + if (coordinates.length === 0) return [0, 0]; + + const [sumX, sumY] = coordinates.reduce<[number, number]>( + ([accX, accY], [x = 0, y = 0]) => [accX + Number(x), accY + Number(y)], + [0, 0], + ); + + return [sumX / coordinates.length, sumY / coordinates.length]; +}; + const getPixelDistanceToSegment = ( map: any, coordinate: number[], @@ -752,23 +766,40 @@ const collectVertexHandleCandidates = (geometry: any) => { return []; }; -const buildVertexHandleFeatures = (geometry: any): Feature[] => { - const groupedCandidates = new globalThis.Map(); +const buildVertexHandleFeatures = (geometry: any, map?: any): Feature[] => { + const groupedCandidates: Array<{ + coordinate: number[]; + metaList: VertexHandleMeta[]; + sourceCoordinates: number[][]; + }> = []; collectVertexHandleCandidates(geometry).forEach((candidate: VertexHandleCandidate) => { - const key = buildVertexCoordinateKey(candidate.coordinate); - const existing = groupedCandidates.get(key); + const existing = groupedCandidates.find((group) => { + if (!map) { + return buildVertexCoordinateKey(group.coordinate) === buildVertexCoordinateKey(candidate.coordinate); + } + + return ( + getSquaredPixelDistance(map, group.coordinate, candidate.coordinate) <= + GIS_EDIT_VERTEX_CLUSTER_PIXEL_THRESHOLD ** 2 + ); + }); + if (existing) { existing.metaList.push(candidate.meta); + existing.sourceCoordinates.push(candidate.coordinate); + existing.coordinate = getAverageCoordinate(existing.sourceCoordinates); return; } - groupedCandidates.set(key, { + + groupedCandidates.push({ coordinate: candidate.coordinate, metaList: [candidate.meta], + sourceCoordinates: [candidate.coordinate], }); }); - return Array.from(groupedCandidates.values()).map(({ coordinate, metaList }: { coordinate: number[]; metaList: VertexHandleMeta[] }) => + return groupedCandidates.map(({ coordinate, metaList }) => createVertexHandleFeature(coordinate, metaList[0]!, metaList), ); }; @@ -861,7 +892,7 @@ const getNearestVertexPixelDistance = ( geometry: any, coordinate: number[], ): number => { - return buildVertexHandleFeatures(geometry).reduce((minimumDistance: number, feature: Feature) => { + return buildVertexHandleFeatures(geometry, map).reduce((minimumDistance: number, feature: Feature) => { const vertexCoordinate = (feature.getGeometry?.() as any)?.getCoordinates?.(); if (!Array.isArray(vertexCoordinate)) return minimumDistance; return Math.min( @@ -1400,6 +1431,10 @@ const Map = () => { const agedPipePopupWindowRef = useRef(null); const pipeAnalysisPopupWindowRef = useRef(null); const gisEditSessionPopupWindowRef = useRef(null); + const [gisEditDetailChangeId, setGisEditDetailChangeId] = useState(null); + const gisEditDetailChangeRef = useRef(null); + const gisEditDetailFeatureDataRef = useRef(null); + const gisEditIsEditModeRef = useRef(false); const waterRiskPopupWindowRef = useRef(null); const waterRiskPopupCloseWatcherRef = useRef(null); // 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다. @@ -1466,6 +1501,44 @@ const Map = () => { gisEdit.lastSavedPayload, gisEdit.selectedLayer, ]); + const gisEditDetailChange = useMemo( + () => gisEdit.changes.find((change) => change.changeId === gisEditDetailChangeId) ?? null, + [gisEdit.changes, gisEditDetailChangeId], + ); + const gisEditDetailFeatureData = useMemo(() => { + if (!gisEditDetailChange) return null; + if (featureData) return featureData; + + if (gisEditDetailChange.layerId !== "tb_ms_swage_ppl") return null; + + return { + lyrTypeNm: "tb_ms_swage_ppl", + fcltMngNo: gisEditDetailChange.featureRef?.fcltMngNo ?? gisEditDetailChange.objectId ?? "", + mngInstCd: gisEditDetailChange.featureRef?.mngInstCd ?? "", + fclt_mng_no: gisEditDetailChange.featureRef?.fcltMngNo ?? gisEditDetailChange.objectId ?? "", + mng_inst_cd: gisEditDetailChange.featureRef?.mngInstCd ?? "", + inst_mng_no: "", + instl_ymd: "", + mapsht_no: "", + pdmt_vl: "", + prlg_vl: "", + usg_cd: "", + mqlt_cd: "", + avg_grdn: "", + drtw_mltv_area: "", + excln_mltv_area: "", + tmpt_dpth: "", + tmpt_mhl_mng_no: "", + scend_dpth: "", + scend_mhl_mng_no: "", + trfln_cnt: "", + vrtc_len: "", + wdth_len: "", + }; + }, [featureData, gisEditDetailChange]); + gisEditDetailChangeRef.current = gisEditDetailChange; + gisEditDetailFeatureDataRef.current = gisEditDetailFeatureData; + gisEditIsEditModeRef.current = gisEdit.isEditMode; const waterRiskSessionSnapshot = useMemo( () => waterRiskSession, [waterRiskSession], @@ -1486,6 +1559,14 @@ const Map = () => { ); }, [gisEditSessionSnapshot]); + const openGisEditPipeDetail = useCallback((changeId: string | null) => { + if (!changeId) { + setGisEditDetailChangeId(null); + return; + } + setGisEditDetailChangeId(changeId); + }, []); + const syncWaterRiskPopup = useCallback(() => { const popup = waterRiskPopupWindowRef.current; if (!popup || popup.closed) { @@ -1623,6 +1704,20 @@ const Map = () => { }; }, []); + useEffect(() => { + if (!gisEdit.isEditMode) { + setGisEditDetailChangeId(null); + return; + } + + if (!gisEditDetailChangeId) return; + if (!gisEditDetailChange) return; + + if (gisEditDetailChange.layerId !== "tb_ms_swage_ppl" || gisEditDetailChange.editType === "DELETE") { + setGisEditDetailChangeId(null); + } + }, [gisEdit.isEditMode, gisEditDetailChange, gisEditDetailChangeId]); + useEffect(() => { if (!(gisEdit.isEditMode || gisEdit.changes.length > 0)) return; syncGisEditSessionPopup(); @@ -1642,6 +1737,7 @@ const Map = () => { const changeId = String(data.payload?.changeId ?? "").trim(); if (changeId) { gisEdit.focusChange(changeId); + openGisEditPipeDetail(changeId); } return; } @@ -1660,7 +1756,7 @@ const Map = () => { return () => { window.removeEventListener("message", handleGisEditSessionPopupMessage); }; - }, [gisEdit, syncGisEditSessionPopup]); + }, [gisEdit, openGisEditPipeDetail, syncGisEditSessionPopup]); const fetchData = async () => { try { @@ -2306,9 +2402,44 @@ const Map = () => { // SearchModal, GisContent 등에서 호출 const onFeatureSelect = (data: any) => { + const clickedLayerTypeName = getSelectedFeatureLayerTypeName(data); + if ( + gisEdit.isEditMode && + (gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT") && + gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl" && + clickedLayerTypeName !== "tb_ms_swage_ppl" + ) { + gisEdit.announceStatus("하수관로를 선택해주세요."); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + // 편집 모드 + activeTool 조합일 때는 상세 팝업 대신 편집 세션 누적을 우선한다. const editSelection = gisEdit.registerFeatureSelection(data); if (editSelection.handled) { + const shouldOpenPipeDetail = + clickedLayerTypeName === "tb_ms_swage_ppl" && + typeof editSelection.changeId === "string" && + editSelection.changeId && + editSelection.isPreviewSelection !== true; + + if (typeof editSelection.changeId === "string" && editSelection.changeId) { + openGisEditPipeDetail(editSelection.changeId); + } + if (shouldOpenPipeDetail) { + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + setFeatureData(data); + window.setTimeout(() => { + setGisEditDetailChangeId(String(editSelection.changeId)); + setIsOpen(true); + }, 0); + setIsSearchModalOpen(false); + return; + } + // 편집 대상으로 넘긴 뒤에는 기존 상세 팝업/원본 선택 강조를 정리해 // 실제 편집 대상인 preview feature가 시각적으로 가려지지 않게 한다. const select = GisApp.Module.select as any; @@ -2327,7 +2458,7 @@ const Map = () => { setIsOpen(false); setIsSearchModalOpen(false); return; - } + } const nextPipe = buildWaterRiskPipeFromFeature(data); if (!nextPipe) { @@ -2946,6 +3077,53 @@ const Map = () => { gisEdit.save(gisMaps.current); }, [gisEdit]); + const handleApplyGisEditPipeBasicInfo = useCallback((changeId: string, basicInfo: GisEditPipeBasicInfo) => { + gisEdit.updateChangeBasicInfo(changeId, basicInfo); + }, [gisEdit]); + + const canSelectFeatureDuringGisEdit = useCallback((feature: any) => { + if (!gisEdit.isEditMode) return true; + if (gisEdit.activeTool !== "MOVE" && gisEdit.activeTool !== "VERTEX_EDIT") return true; + if (gisEdit.selectedLayer?.layerId !== "tb_ms_swage_ppl") return true; + + const layerTypeName = getSelectedFeatureLayerTypeName(feature); + if (layerTypeName === "tb_ms_swage_ppl") return true; + + gisEdit.announceStatus("하수관로를 선택해주세요."); + return false; + }, [gisEdit]); + + const GisEditAwareDetailView = useCallback((props: any) => { + const currentChange = gisEditDetailChangeRef.current; + const currentFeatureData = gisEditDetailFeatureDataRef.current; + const isEditMode = gisEditIsEditModeRef.current; + + if (isEditMode && currentChange && currentChange.layerId === "tb_ms_swage_ppl" && currentChange.editType !== "DELETE") { + return ( + { + setGisEditDetailChangeId(null); + props.onClose?.(); + }} + onSave={handleApplyGisEditPipeBasicInfo} + /> + ); + } + + return ( + { + setGisEditDetailChangeId(null); + props.onClose?.(); + }} + /> + ); + }, [handleApplyGisEditPipeBasicInfo]); + useEffect(() => { if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return; @@ -2982,7 +3160,37 @@ const Map = () => { featureProjection: GisApp.TO_PROJ, dataProjection: FLOW_DIRECTION_SRS, }) as Record; - gisEdit.registerAddGeometry(geometryObject); + const changeId = gisEdit.registerAddGeometry(geometryObject); + if (changeId && gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl") { + openGisEditPipeDetail(changeId); + setFeatureData({ + lyrTypeNm: "tb_ms_swage_ppl", + fcltMngNo: "", + mngInstCd: "", + fclt_mng_no: "", + inst_mng_no: "", + instl_ymd: "", + mapsht_no: "", + pdmt_vl: "", + prlg_vl: "", + usg_cd: "", + mqlt_cd: "", + avg_grdn: "", + drtw_mltv_area: "", + excln_mltv_area: "", + tmpt_dpth: "", + tmpt_mhl_mng_no: "", + scend_dpth: "", + scend_mhl_mng_no: "", + trfln_cnt: "", + vrtc_len: "", + wdth_len: "", + }); + window.setTimeout(() => { + setGisEditDetailChangeId(changeId); + setIsOpen(true); + }, 0); + } } catch (error) { console.error("GIS 편집 ADD geometry 변환 실패:", error); gisEdit.announceStatus("신규 geometry를 추가하지 못했습니다. 다시 시도해 주세요."); @@ -3001,6 +3209,7 @@ const Map = () => { }, [ ensureGisEditDrawSource, gisEdit, + openGisEditPipeDetail, mapReady, removeGisEditDrawInteraction, removeGisEditInteractions, @@ -3225,7 +3434,7 @@ const Map = () => { const renderVertexHandles = (hoveredMidpoint: VertexHandleCandidate | null = null) => { vertexSource.clear(); - buildVertexHandleFeatures(targetFeature.getGeometry?.()).forEach((handleFeature: Feature) => { + buildVertexHandleFeatures(targetFeature.getGeometry?.(), map).forEach((handleFeature: Feature) => { vertexSource.addFeature(handleFeature); }); if (hoveredMidpoint) { @@ -3539,7 +3748,8 @@ const Map = () => { gisMaps={gisMaps} isMiniMap={false} searchView={SearchModal} - detailView={FeatureClickEventPopup} + detailView={GisEditAwareDetailView} + canSelectFeature={canSelectFeatureDuringGisEdit} onFeatureSelect={onFeatureSelect} featureData={featureData} setFeatureData={setFeatureData} diff --git a/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md b/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md index 62738f50c..fe8b0e784 100644 --- a/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md +++ b/apps/sn/docs/작업검증/SFR-012-002-하수관망도 업로드/001-qa-SFR-012-002-하수관망도 업로드-통합검증계획.md @@ -8,7 +8,7 @@ ## 2. 검증 기준 요약 - `GIS 관리` popup이 정상적으로 열린다. -- 시설구분 미선택 시 `찾기` 또는 `업로드`가 차단된다. +- 하수관로만 시설구분 대상으로 노출된다. - 파일 선택 시 파일명이 표시된다. - 업로드 성공 시 현황 목록이 즉시 갱신된다. - 다운로드는 선택 행이 있을 때만 가능 상태가 된다. @@ -24,7 +24,8 @@ ### 3.2 업로드 폼 -- 시설구분 미선택 상태에서 `찾기` 클릭 차단 +- 하수관로 단일 시설구분이 기본 표시되는지 확인 +- 하수맨홀, 하수처리시설 등 다른 시설구분이 노출되지 않는지 확인 - 파일 미선택 상태에서 `업로드` 클릭 차단 - 파일 선택 후 파일명 input 반영 diff --git a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md index be0225469..221bf0c55 100644 --- a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md +++ b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/001-qa-SFR-012-003-하수관로편집-통합검증계획.md @@ -79,6 +79,20 @@ - 세션 안내 문구 영역이 제거됐는지 - `추가`, `이동`, `꼭지점편집`, `삭제` 버튼에 `lucide-react` 아이콘이 표시되는지 +### 3.9 2026-05-21 관로 기본정보 세션 편집 검증 + +- 편집 대상 레이어가 `하수관로`일 때만 편집 상세정보창이 열리는지 +- `MOVE`, `VERTEX_EDIT`, `ADD` change 선택 후 상세정보창 상단 `기본정보` 테이블에서 7개 필드가 직접 수정 가능한지 +- 신규 추가일 때 `경과년도`, `설치일자 확인불가` 같은 조회 전용 표식이 노출되지 않는지 +- 상세정보창 저장이 즉시 DB 저장이 아니라 `basicInfo` 세션 저장으로만 처리되는지 +- 입력 중 스크롤이 상단으로 점프하지 않고 현재 편집 위치를 유지하는지 +- `GIS 편집 세션` 목록에 `기본정보 세션저장` 표식이 나타나는지 +- 최종 저장 payload에 `featureRef`, `basicInfo`, `beforeGeometry`, `afterGeometry`가 함께 들어가는지 +- `DELETE` change나 관로 외 레이어에서는 상세정보 세션 편집창이 열리지 않는지 +- 편집 세션 모드에서는 `보수 및 갱신`, `청소 및 준설`, `점검 및 진단` 섹션이 숨겨지는지 +- `MOVE`, `VERTEX_EDIT` 중 비하수관로 클릭 시 선택/줌 이동 없이 차단되고 상태문구만 바뀌는지 +- `MOVE`, `VERTEX_EDIT`에서 하수관로 선택 즉시 편집 상세정보창이 열리는지 + ## 4. 현재 검증 범위 ### 4.1 진행 중 diff --git a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md index 3fd2b6a96..3caacaa1d 100644 --- a/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-012-003-하수관로편집/002-qa-SFR-012-003-하수관로편집-통합검증결과.md @@ -98,6 +98,50 @@ - 세션 팝업과 메인 지도 간 메시지 동기화 - 도구 아이콘 실제 렌더링 +### 2.9 2026-05-21 관로 기본정보 세션 편집 점검 + +- 코드 기준 점검 + - `GisEditChange`에 `featureRef`, `basicInfo`, `basicInfoSavedAt`가 추가돼 geometry change와 관로 기본정보 draft를 함께 담을 수 있게 했다. + - 하수관로 change를 선택하면 편집 전용 상세정보창을 열고, 기존 `GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd}`로 기본정보를 읽어 와 세션 저장할 수 있게 했다. + - 상세정보창 저장은 기존 즉시 `POST /api/v1/fcltppl`이 아니라 `updateChangeBasicInfo`로 change draft만 갱신한다. + - 최종 저장 payload는 `changeId`, `featureRef`, `basicInfo`, `beforeGeometry`, `afterGeometry`를 함께 조립하도록 바뀌었다. +- 정적 검증 + - `node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false` + - 결과: 기존 저장소 오류로 실패 + - 확인된 기존 오류 + - `app/(map-layout)/map/components/common/index.ts` 타입 불일치 + - `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고 + - html 라우트 경로 import 누락 + - 공통 chart 컴포넌트 implicit any + - 이번 변경 파일 관련 신규 오류는 별도 출력되지 않았다. +- 수동 재검증 필요 항목 + - 편집 모드에서 실제 하수관로 선택 시 상세정보창이 예상대로 열리는지 + - 기본정보 저장 후 세션 목록 뱃지가 정상 반영되는지 + - 최종 payload 미리보기에 관로 기본정보와 geometry가 함께 담기는지 + - 백엔드 geometry 저장 API 부재 상태에서 사용자가 최종 저장 한계를 어떻게 인지할지 + +### 2.10 2026-05-21 자동저장 루프/선택 제한 보강 점검 + +- 코드 기준 점검 + - 입력값 변경을 `useEffect` 자동 저장으로 처리하던 구조를 제거하고, 실제 `input/select` 변경 이벤트에서만 세션 draft를 갱신하도록 바꿨다. + - 편집 팝업 wrapper를 안정화해 입력 중 remount로 인한 스크롤 상단 점프 가능성을 줄였다. + - 공통 `GisSelect`에 `canSelectFeature` 가드를 연결해 `MOVE`, `VERTEX_EDIT` + 하수관로 레이어 조합일 때 비하수관로 선택을 선택 단계에서 차단하도록 확장했다. + - 비하수관로 차단 시 지도 줌 이동이나 상세정보 전환 없이 상태문구 `하수관로를 선택해주세요.`만 남기도록 정리했다. + - `MOVE`, `VERTEX_EDIT`의 하수관로 선택 시 상세정보창 오픈은 preview 준비 완료 대기 대신 `setTimeout(0)` 기준 즉시 오픈으로 단순화했다. +- 정적 검증 + - `node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false` + - 결과: 기존 저장소 오류로 실패 + - 확인된 기존 오류 + - `app/(map-layout)/map/components/common/index.ts` 타입 불일치 + - `PipeAnalysisPopupPage.tsx`의 `JSON[]` 캐스팅 경고 + - html 라우트 경로 import 누락 + - 공통 chart 컴포넌트 implicit any +- 수동 재검증 필요 항목 + - 하수관로 편집 팝업의 입력 중 스크롤 위치 유지 + - `MOVE`, `VERTEX_EDIT`에서 하수관로 선택 즉시 팝업 오픈 + - 비하수관로 클릭 시 줌 이동 완전 차단 + - `ADD` 미리보기와 `MOVE`, `VERTEX_EDIT` interaction 회귀 여부 + ## 3. 현재 검증 상태 ### 3.1 통과 diff --git a/apps/sn/docs/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md b/apps/sn/docs/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md index 15cc96f6e..af3d4b3e6 100644 --- a/apps/sn/docs/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md +++ b/apps/sn/docs/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md @@ -9,6 +9,7 @@ - 담당 역할: FE - 관련 역할: DE, BE, QA - 담당 범위: `GIS 관리` popup UI, 시설구분 선택, 파일 찾기, mock 업로드/다운로드 흐름, 업로드 payload 준비 상태까지 +- 운영 제약: `GIS 관리`는 하수관로만 업로드 대상으로 사용하고, 다른 GIS 테이블은 FE 시설구분 목록에서 제외 - 구현 제외: 실제 서버 업로드/다운로드 연결, 백엔드 저장 로직, DB 반영 - 근거 문서 - `COMMON_DOCS_ROOT/작업계획/SFR-012-002-하수관망도 업로드/001-de-SFR-012-002-하수관망도 업로드-설계.md` @@ -46,7 +47,7 @@ ## 2. 작업 목적 - 기존 지도 화면의 `GIS 관리` 기능을 실제 업로드 관리 popup으로 구체화한다. -- 사용자가 시설구분을 선택하고 GIS 파일을 찾은 뒤 업로드 이력과 다운로드 대상을 관리할 수 있는 FE 화면 흐름을 마련한다. +- 사용자가 하수관로 GIS 파일을 찾은 뒤 업로드 이력과 다운로드 대상을 관리할 수 있는 FE 화면 흐름을 마련한다. - 실제 백엔드 연결 전에도 UI, 상태, mock 데이터, 업로드 요청 payload 구조를 검토할 수 있도록 준비한다. ## 3. 예상 수정 대상 파일 diff --git a/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md b/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md index c3aec0802..bef95282e 100644 --- a/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md +++ b/apps/sn/docs/작업계획/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-작업카드.md @@ -7,7 +7,7 @@ - 작성일: 2026-05-14 - 담당 역할: FE - 관련 역할: DE, BE, QA -- 담당 범위: `apps/sn` 지도형 화면에서 GIS편집 진입, 레이어 선택, 추가/이동/꼭지점편집/삭제, 저장 payload 준비까지 구현 +- 담당 범위: `apps/sn` 지도형 화면에서 GIS편집 진입, 레이어 선택, 추가/이동/꼭지점편집/삭제, 관로 기본정보 세션 편집, 저장 payload 준비까지 구현 - 근거 문서: - `COMMON_DOCS_ROOT/작업계획/SFR-012-003-하수관로편집/001-de-SFR-012-003-하수관로편집-설계.md` - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_요구사항명세서_v1_1.docx` @@ -16,6 +16,13 @@ - `COMMON_DOCS_ROOT/작업지시/SFR-012-003-하수관로편집/하수관망도편집_화면설계서_v1_1.png` ## 1-2. 최근 진행 갱신 +- 2026-05-21 + - 실제 구현 코드를 다시 점검한 결과, 현재 GIS편집은 geometry 세션만 관리하고 관로 기본정보 세션은 없는 상태로 정리했다. + - `tb_ms_swage_ppl` geometry와 `tb_sn_swage_ppl` 기본정보 저장 구조가 분리돼 있어, 최종 저장 payload는 `featureRef + basicInfo + geometryPatch` 기준으로 재설계했다. + - 편집 모드에서 하수관로 change를 선택하면 기본정보만 세션 저장할 수 있는 편집 상세정보창을 추가하고, 최종 저장은 geometry와 기본정보를 함께 조립하는 방향으로 구현 중이다. + - 하수관로 편집 팝업의 입력정보 저장버튼은 제거하고, 입력 변경 시 세션 draft가 바로 반영되도록 수정했다. + - 편집 팝업 입력 시 스크롤 상단 점프와 `Maximum update depth exceeded` 에러 원인을 자동저장 effect 루프로 보고 제거했다. + - `MOVE`, `VERTEX_EDIT` 중에는 하수관로만 선택 가능하게 차단하고, 하수관로 선택 즉시 편집 상세정보창이 열리도록 보강했다. - 2026-05-14 - 7개 편집 레이어 확장, 레이어별 허용 도구 규칙, 로컬 세션 undo/reset, `MOVE`/`VERTEX_EDIT`/`DELETE` 임시 변경분 누적까지 반영했다. - 이번 라운드에서 `MOVE`를 preview feature 기반 실제 drag 흐름으로 올리고, 지도 위 preview 강조와 재선택 UX를 보강했다. @@ -42,6 +49,9 @@ - 프론트는 저장 전까지 편집 변경분을 로컬 세션으로 관리하고, 저장 시 BE가 제공할 편집 저장 API에 맞춰 payload를 전달한다. - 백엔드 개발자가 바로 이어받을 수 있도록 필요한 API, 화면 상태, 예외 흐름을 FE 기준으로 구체화한다. - 실제 API 구현은 범위 밖이며, 백엔드 공유용 요청 사항은 `작업내용` 문서로 분리해 관리한다. +- 추가 운영 제약: + - `GIS편집`, `GIS관리`는 하수관로만 대상으로 제한한다. + - 다른 GIS 테이블은 타회사 소유 범위로 보고 FE에서 선택/업로드 대상으로 노출하지 않는다. ## 3. 수정 대상 파일 | 구분 | 파일 경로 | 수정 여부 | 근거 | @@ -83,6 +93,7 @@ - `이동`은 preview drag로 위치 조정 - `꼭지점편집`은 선택 객체의 vertex marker를 노출하고 양 끝점을 포함해 이동 - 변경분이 로컬 편집 세션에 누적 + - 하수관로 편집은 기존 상세정보창 구조를 재사용하고, `입력정보` 저장은 세션 저장으로 처리 - 저장 시 원본과 분리된 편집본 저장 요청 - 빈 결과 흐름: - 변경분이 없으면 저장 비활성 또는 안내 메시지 표시 @@ -98,11 +109,13 @@ | --- | --- | --- | --- | --- | --- | | 기존 레이어 트리 조회 | `GET` | `/api/v1/map/lyrStngs/SN_NETWORK` | 기존 지도 레이어 그룹 | 레이어 트리 데이터 | 기존 사용 중 | | 편집 가능 레이어 메타 조회 | `GET` | `/api/v1/map/gisEditLayers` | `lyrGrp`, `screenId` | 편집 가능 레이어, geometry 타입, 허용 도구 | BE 신규 필요 | +| 관로 기본정보 조회 | `GET` | `/api/v1/fcltppl/{fcltMngNo}/{mngInstCd}` | 관로 식별자 | `PplDto` 기본정보 7개 필드 | 기존 사용 중 | | 편집 저장 | `POST` | `/api/v1/map/gisEditSessions/save` | 레이어 정보, view, `changes[]` | `sessionId`, `editFileId`, `versionNo`, `savedCount` | BE 신규 필요 | - FE 메모: - 저장 전까지 변경분은 FE 로컬 상태로 보관한다. - - 현재 구현은 실제 호출 대신 payload 미리보기만 생성한다. + - 관로 상세정보창의 저장은 즉시 API 호출이 아니라 change별 세션 저장으로 해석한다. + - 현재 구현은 실제 최종 저장 호출 대신 payload 미리보기만 생성한다. - BE 요청 사항은 `FE_APP_DOCS_ROOT/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-프론트공유API요청.md`에 별도 정리한다. ## 6. 영향 범위 diff --git a/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md b/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md index e20f818a9..8f9d81d59 100644 --- a/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-통합구현기록.md @@ -60,7 +60,7 @@ - 구현 완료 범위 - `GIS 관리` popup 진입 - - 시설구분 선택 + - 하수관로 단일 시설구분 표시/사용 - 파일 찾기 - 확장자/크기 1차 검증 - mock 업로드 이력 누적 @@ -74,6 +74,14 @@ - 업로드 성공 후 서버 이력 재조회 - 실제 시설물 레이어 기반 시설구분 동기화 +### 4.5 2026-05-21 업로드 대상 하수관로 단일화 + +- 운영 제약 기준으로 `GIS 관리` 업로드 대상은 하수관로만 사용한다. +- 이에 따라 `UPLOAD_FACILITY_TYPES` mock 상수에서 하수맨홀, 하수처리시설 항목을 제거했다. +- 업로드 현황 mock 목록도 하수관로 이력만 남기고 정리했다. +- FE 문서와 공통 공유 기록에서도 다른 GIS 테이블은 타회사 소유 범위라 업로드 대상에서 제외한다는 근거를 함께 남긴다. +- 현재 구조는 `/map` 진입 시 전체 레이어 트리를 읽더라도 `GIS 관리` popup 소비 단계에서는 하수관로만 노출하는 방식으로 유지한다. + ## 5. BE 공유 초안 ### 5.1 FE가 기대하는 메타 정보 diff --git a/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md b/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md index 2a99ad3aa..7c74014d8 100644 --- a/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-012-003-하수관로편집/001-fe-SFR-012-003-하수관로편집-통합구현기록.md @@ -153,6 +153,40 @@ - 사용자 요청으로 오늘 작업 내역, 설계 변경, 검증 계획, 검증 결과를 묶은 제출용 단일 `md` 파일을 별도 생성한다. - 이는 통합 문서 체계 예외 산출물이며, 기능 운영 문서는 기존 canonical 4문서 체계를 계속 기준으로 유지한다. +### 4.14 2026-05-21 관로 기본정보 세션 편집 결합 + +- 실제 구현 재점검 결과 + - `useGisEdit`는 geometry change 세션만 관리하고 있었다. + - 관로 상세정보 저장은 기존 `FeatureClickEventPopup` 내부 `POST /api/v1/fcltppl` 즉시 저장 구조였다. + - 편집 모드에서는 피처 클릭이 상세 팝업 대신 session change 누적으로 우선 처리돼, 이동/꼭지점편집 중 기존 상세정보를 보며 수정하는 흐름이 없었다. +- 이번 라운드 반영 + - `GisEditChange`에 `featureRef`, `basicInfo`, `basicInfoSavedAt`를 추가했다. + - 기존 상세정보 구현 파일은 직접 수정하지 않는 방향으로 정리하고, GIS 편집 전용 하수관로 팝업을 별도 컴포넌트로 분리했다. + - 신규 추가는 같은 상세정보 형태의 편집 팝업을 빈 값으로 열고, 기존 관로 이동/꼭지점편집은 기존 값이 채워진 상태로 연다. + - 편집 전용 팝업은 `기본정보` editable 테이블과 `입력정보` editable 테이블만 표시하고, `보수 및 갱신`, `청소 및 준설`, `점검 및 진단` 섹션은 렌더링하지 않는다. + - 관로 기본정보는 기존 `GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd}`로 읽어 오되, 저장은 즉시 API 호출이 아니라 change draft 갱신으로만 처리한다. + - 최종 저장 payload는 `featureRef + basicInfo + geometryPatch` 구조로 다시 조립하도록 바꿨다. +- 남긴 판단 + - 현재 백엔드에는 geometry batch save API가 없어 FE는 payload 조립과 저장 지점 정렬까지 수행한다. + - 실제 DB 반영은 `tb_ms_swage_ppl` geometry와 `tb_sn_swage_ppl` 기본정보 저장 구조를 함께 처리하는 백엔드 계약이 추가로 필요하다. + +### 4.15 2026-05-21 편집 팝업 자동저장 루프 및 선택 제한 보강 + +- 사용자 피드백 기준 이슈 + - 편집 팝업 입력 시 스크롤이 상단으로 튀고, 일부 입력에서는 `Maximum update depth exceeded` 런타임 에러가 발생했다. + - `MOVE`, `VERTEX_EDIT` 중 하수관로 외 객체를 클릭하면 지도 기본 선택 흐름이 타면서 줌 이동이 발생했다. + - `MOVE`, `VERTEX_EDIT`에서 하수관로를 선택해도 편집 상세정보창이 항상 즉시 뜨지 않았다. +- 원인 정리 + - 편집 팝업 draft를 `useEffect`에서 자동 저장하도록 구성하면서 `팝업 입력 -> parent change 갱신 -> 팝업 재초기화 -> 재저장` 루프가 생겼다. + - `detailView` wrapper가 부모 렌더마다 재생성되어 스크롤/포커스가 흔들릴 여지가 있었다. + - `MOVE`, `VERTEX_EDIT`의 상세정보창 오픈을 preview 준비 완료 시점에 의존시켜 선택 직후 오픈이 누락되는 경우가 있었다. +- 이번 라운드 반영 + - 자동 세션 저장은 렌더 기반 effect에서 제거하고, 실제 `input/select` 변경 이벤트 시점에만 draft를 세션에 반영하도록 바꿨다. + - `detailView` wrapper를 안정화해 편집 팝업이 입력 중 불필요하게 remount되지 않도록 조정했다. + - `MOVE`, `VERTEX_EDIT`에서 하수관로를 선택하면 `setTimeout(0)` 기준으로 상세정보창을 즉시 열도록 단순화했다. + - 공통 `GisSelect`에 `canSelectFeature` 가드를 연결해, 하수관로 편집 중에는 비하수관로 피처를 아예 선택 단계에서 차단하도록 확장했다. + - 차단된 경우에는 지도 줌 이동이나 상세정보 전환 없이 상태문구 `하수관로를 선택해주세요.`만 남기도록 정리했다. + ## 5. 기능별 현재 상태 ### 5.1 `ADD` diff --git a/packages/common/business_components/src/ui/map/Gis.js b/packages/common/business_components/src/ui/map/Gis.js index 2fae15fc9..b377d6f90 100644 --- a/packages/common/business_components/src/ui/map/Gis.js +++ b/packages/common/business_components/src/ui/map/Gis.js @@ -49,6 +49,7 @@ function Gis({ searchView: SearchView, detailView: DetailView, onFeatureSelect, + canSelectFeature, setIsSearchModalOpen, onCheckedLayersChange, searchBar: SearchBar, @@ -354,7 +355,7 @@ function Gis({ )} - + diff --git a/packages/common/business_components/src/ui/map/GisSelect.js b/packages/common/business_components/src/ui/map/GisSelect.js index c17563724..ac862c2f1 100644 --- a/packages/common/business_components/src/ui/map/GisSelect.js +++ b/packages/common/business_components/src/ui/map/GisSelect.js @@ -3,14 +3,19 @@ import { GisContext } from "./Gis"; import GisApp from "./lib/gis.core"; import GisOlSelect from "./lib/gis.select"; -export default React.memo(function GisSelect({ onFeatureSelect }) { +export default React.memo(function GisSelect({ onFeatureSelect, canSelectFeature }) { const { gisMap } = useContext(GisContext); const onFeatureSelectRef = useRef(onFeatureSelect); + const canSelectFeatureRef = useRef(canSelectFeature); useEffect(() => { onFeatureSelectRef.current = onFeatureSelect; }, [onFeatureSelect]); + useEffect(() => { + canSelectFeatureRef.current = canSelectFeature; + }, [canSelectFeature]); + useEffect(() => { if (!gisMap) return; // 아직 초기화 안 됐으면 아무것도 안 함 @@ -20,6 +25,11 @@ export default React.memo(function GisSelect({ onFeatureSelect }) { // 피처 선택 시 콜백 등록 select.getSelectedInfo(function (features) { if (features?.[0]) { + const isSelectable = canSelectFeatureRef.current?.(features[0]); + if (isSelectable === false) { + select.resetSelectedFeature?.(); + return; + } onFeatureSelectRef.current?.(features[0]); } });