From f4b8f9d65dc5cf9f6165e495ab5979b04f934a7e Mon Sep 17 00:00:00 2001 From: kiminchan Date: Mon, 29 Jun 2026 13:31:31 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9A=B0=EC=B8=A1=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=ED=83=AD=20=EC=A7=80=EB=8F=84=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(map-layout)/map/page.tsx | 456 +++++++++++++++--- .../002-qa-SFR-010-010-지반침하정보-통합검증결과.md | 64 +++ .../001-fe-SFR-010-010-지반침하정보-통합구현기록.md | 53 ++ 3 files changed, 514 insertions(+), 59 deletions(-) diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 4c4f54056..8776b0992 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -165,6 +165,82 @@ const WATER_RISK_AREA_LAYER_ID = "SN_WATER_RISK_AREA"; const WATER_RISK_GRID_LAYER_ID = "SN_WATER_RISK_GRID"; const WATER_RISK_PIPE_LAYER_ID = "SN_WATER_RISK_PIPE"; const FLOOD_HISTORY_SELECTION_PADDING: [number, number, number, number] = [100, 380, 120, 120]; +const RIGHT_PANEL_TAB_LIMIT = 5; + +type RightPanelTabKind = "feature" | "sinkhole" | "gisEdit"; + +type RightPanelTab = { + id: string; + kind: RightPanelTabKind; + title: string; + subTitle?: string; + featureData: any; + sinkholeDetail?: SinkholeDetail | null; + nearbyPipes?: SinkholeNearbyPipe[]; + gisEditChangeId?: string | null; + lastActiveAt: number; +}; + +type RightPanelBookmarkTabsProps = { + tabs: RightPanelTab[]; + activeTabId: string | null; + onActivate: (tabId: string) => void; +}; + +// 탭 제한을 넘으면 새 탭과 현재 탭을 보존하고 오래된 일반 상세부터 정리한다. +const findRightPanelTabOverflowCandidate = ( + tabs: RightPanelTab[], + nextTabId: string, + activeTabId: string | null, +) => { + const protectedTabIds = new Set([nextTabId, activeTabId].filter(Boolean) as string[]); + const removableTabs = tabs + .filter((tab) => !protectedTabIds.has(tab.id)) + .sort((a, b) => a.lastActiveAt - b.lastActiveAt); + + return ( + removableTabs.find((tab) => tab.kind === "feature") ?? + removableTabs.find((tab) => tab.kind !== "gisEdit") ?? + removableTabs[0] ?? + tabs.filter((tab) => tab.id !== nextTabId).sort((a, b) => a.lastActiveAt - b.lastActiveAt)[0] ?? + null + ); +}; + +// 오른쪽 상세 패널에 상세가 2개 이상 쌓였을 때만 책갈피형 전환 버튼을 보여준다. +const RightPanelBookmarkTabs = ({ tabs, activeTabId, onActivate }: RightPanelBookmarkTabsProps) => { + if (tabs.length < 2) return null; + + return ( +
+ {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + return ( + + ); + })} +
+ ); +}; type FlowDirectionUsageStyle = { color: string; @@ -742,6 +818,65 @@ const getSelectedFeatureLayerTypeName = (data: any) => { return String(data?.lyrTypeNm ?? data?.values_?.lyrTypeNm ?? "").trim(); }; +const getRightPanelFeatureIdentifier = (data: any) => { + const rawId = + data?.get?.("changeId") ?? + data?.get?.("fid") ?? + data?.values_?.fid ?? + data?.fid ?? + data?.getId?.() ?? + data?.id_ ?? + data?.fcltMngNo ?? + data?.values_?.fclt_mng_no ?? + data?.swtrmtFcltCd ?? + data?.values_?.swtrmt_fclt_cd ?? + ""; + + return String(rawId).trim(); +}; + +const getRightPanelFeatureTitle = (data: any) => { + const layerTypeName = getSelectedFeatureLayerTypeName(data); + + switch (layerTypeName) { + case "tb_ms_swage_ppl": + return "하수관로"; + case "tb_ms_swage_mhl": + return "하수맨홀"; + case "tb_ms_swage_pmstn": + return "펌프장"; + case "tb_ms_stofcbr": + return "우수토실"; + case "tb_il_swage_prcs_fclt": + return "처리시설"; + case "tb_ms_swage_prcs_zone": + return "처리구역"; + case "om_dt_untrt_swg_rain": + return "중점우수"; + default: + return String(data?.lyrNm ?? data?.values_?.lyrNm ?? "상세").trim() || "상세"; + } +}; + +const getRightPanelFeatureSubTitle = (data: any) => { + const layerTypeName = getSelectedFeatureLayerTypeName(data); + if (layerTypeName !== "tb_ms_swage_ppl") return ""; + + const pipeManagementNo = + data?.get?.("fclt_mng_no") ?? + data?.get?.("fcltMngNo") ?? + data?.get?.("pipeId") ?? + data?.values_?.fclt_mng_no ?? + data?.values_?.fcltMngNo ?? + data?.values_?.pipeId ?? + data?.fcltMngNo ?? + data?.pipeId ?? + data?.fid ?? + ""; + + return String(pipeManagementNo).trim(); +}; + const createFloodHistoryAreaStyle = (label: string) => new Style({ fill: new Fill({ @@ -2275,6 +2410,9 @@ const Map = () => { const gisMaps = useRef(null); const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터 + // 오른쪽 상세 패널에 열린 상세 컴포넌트를 책갈피 탭 단위로 보관한다. + const [rightPanelTabs, setRightPanelTabs] = useState([]); + const [activeRightPanelTabId, setActiveRightPanelTabId] = useState(null); const [isWaterDepthLayerActive, setIsWaterDepthLayerActive] = useState(false); // 침수심 레이어 활성 여부 const [isPlaying, setIsPlaying] = useState(false); const [mapReady, setMapReady] = useState(false); @@ -3426,6 +3564,8 @@ const Map = () => { // SearchModal, GisContent 등에서 호출 const onFeatureSelect = (data: any) => { const clickedLayerTypeName = getSelectedFeatureLayerTypeName(data); + // 지도에서 새 피처를 직접 선택하면 지반침하 반경 표에서 만든 임시 관로 포커스는 해제한다. + clearSinkholeFocusedPipeLayer(); if ( gisEdit.isEditMode && (gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT") && @@ -3925,6 +4065,44 @@ const Map = () => { }); }, []); + // 우측 상세 탭 전환 시 패널의 활성 대상과 지도 선택/강조 상태를 맞춘다. + const syncRightPanelFeatureSelectionOnMap = useCallback( + (data: any) => { + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + + const layerTypeName = getSelectedFeatureLayerTypeName(data); + if (layerTypeName === SINKHOLE_LAYER_TYPE) { + clearSinkholeFocusedPipeLayer(); + return; + } + + if (!data || typeof data.getGeometry !== "function") { + clearSinkholeFocusedPipeLayer(); + return; + } + + if (layerTypeName === "tb_ms_swage_ppl") { + const source = ensureSinkholeFocusedPipeLayer(); + if (!source) return; + + source.clear(); + const focusedFeature = typeof data.clone === "function" ? data.clone() : data; + if (typeof focusedFeature.setId === "function" && typeof data.getId === "function") { + focusedFeature.setId(data.getId()); + } + focusedFeature.setStyle(createSinkholeFocusedPipeStyle()); + source.addFeature(focusedFeature); + select?.selectFeature?.(focusedFeature, true); + return; + } + + clearSinkholeFocusedPipeLayer(); + select?.selectFeature?.(data, true); + }, + [clearSinkholeFocusedPipeLayer, ensureSinkholeFocusedPipeLayer], + ); + const ensureFloodHistoryAreaSource = useCallback(() => { if (!mapReady || !gisMaps.current) return null; @@ -4304,9 +4482,6 @@ const Map = () => { } try { - const source = ensureSinkholeFocusedPipeLayer(); - if (!source) return; - const layerTypeName = String(pipe?.lyrTypeNm ?? "tb_ms_swage_ppl"); const params = new URLSearchParams({ SERVICE: "WFS", @@ -4324,12 +4499,10 @@ const Map = () => { featureProjection: GisApp.TO_PROJ, }); - source.clear(); - features.forEach((feature: any) => feature.setStyle(createSinkholeFocusedPipeStyle())); - source.addFeatures(features); const firstFeature = features[0]; if (firstFeature) { - restoreSinkholeDetailOnCloseRef.current = true; + syncRightPanelFeatureSelectionOnMap(firstFeature); + restoreSinkholeDetailOnCloseRef.current = false; setFeatureData(firstFeature); setIsOpen(true); setIsSearchModalOpen(false); @@ -4338,7 +4511,161 @@ const Map = () => { console.error("지반침하 반경 내 관로 지도 표시 실패:", error); alert("선택한 관로를 지도에서 표시하지 못했습니다."); } - }, [ensureSinkholeFocusedPipeLayer, setIsSearchModalOpen]); + }, [setIsSearchModalOpen, syncRightPanelFeatureSelectionOnMap]); + + const buildRightPanelTab = useCallback( + (data: any): RightPanelTab | null => { + if (!data) return null; + + const layerTypeName = getSelectedFeatureLayerTypeName(data); + const now = Date.now(); + + if (layerTypeName === SINKHOLE_LAYER_TYPE) { + const detail = selectedSinkholeDetail; + const acdntNo = String(detail?.acdntNo ?? getSinkholeAccidentId(data) ?? "").trim(); + if (!acdntNo) return null; + + return { + id: `sinkhole:${acdntNo}`, + kind: "sinkhole", + title: "지반침하", + subTitle: acdntNo, + featureData: { + lyrTypeNm: SINKHOLE_LAYER_TYPE, + acdntNo, + }, + sinkholeDetail: detail, + nearbyPipes: selectedSinkholeNearbyPipes, + lastActiveAt: now, + }; + } + + if (gisEditDetailChangeId) { + return { + id: `gisEdit:${gisEditDetailChangeId}`, + kind: "gisEdit", + title: "관로편집", + featureData: data, + gisEditChangeId: gisEditDetailChangeId, + lastActiveAt: now, + }; + } + + const featureId = getRightPanelFeatureIdentifier(data) || `${layerTypeName || "feature"}:${now}`; + return { + id: `feature:${layerTypeName || "unknown"}:${featureId}`, + kind: "feature", + title: getRightPanelFeatureTitle(data), + subTitle: getRightPanelFeatureSubTitle(data), + featureData: data, + lastActiveAt: now, + }; + }, + [gisEditDetailChangeId, selectedSinkholeDetail, selectedSinkholeNearbyPipes], + ); + + const upsertRightPanelTab = useCallback((tab: RightPanelTab) => { + setRightPanelTabs((currentTabs) => { + const nextTab = { ...tab, lastActiveAt: Date.now() }; + const existingIndex = currentTabs.findIndex((item) => item.id === tab.id); + const mergedTabs = existingIndex < 0 + ? [...currentTabs, nextTab] + : currentTabs.map((item, index) => (index === existingIndex ? { ...item, ...nextTab } : item)); + + if (mergedTabs.length <= RIGHT_PANEL_TAB_LIMIT) return mergedTabs; + + const overflowCandidate = findRightPanelTabOverflowCandidate( + mergedTabs, + nextTab.id, + activeRightPanelTabId, + ); + if (!overflowCandidate) return mergedTabs.slice(-RIGHT_PANEL_TAB_LIMIT); + + return mergedTabs.filter((item) => item.id !== overflowCandidate.id); + }); + setActiveRightPanelTabId(tab.id); + }, [activeRightPanelTabId]); + + const activateRightPanelTab = useCallback( + (tabId: string) => { + const targetTab = rightPanelTabs.find((tab) => tab.id === tabId); + if (!targetTab) return; + + setRightPanelTabs((currentTabs) => + currentTabs.map((tab) => (tab.id === tabId ? { ...tab, lastActiveAt: Date.now() } : tab)), + ); + setActiveRightPanelTabId(tabId); + + if (targetTab.kind === "sinkhole") { + const detail = targetTab.sinkholeDetail ?? null; + setSelectedSinkholeDetail(detail); + setSelectedSinkholeNearbyPipes(targetTab.nearbyPipes ?? []); + setGisEditDetailChangeId(null); + syncRightPanelFeatureSelectionOnMap(targetTab.featureData); + if (detail) { + renderSelectedSinkhole(detail, sinkholeRadiusMeters); + } + } else if (targetTab.kind === "gisEdit") { + setGisEditDetailChangeId(targetTab.gisEditChangeId ?? null); + } else { + setGisEditDetailChangeId(null); + syncRightPanelFeatureSelectionOnMap(targetTab.featureData); + } + + setFeatureData(targetTab.featureData); + setIsOpen(true); + setIsSearchModalOpen(false); + }, + [renderSelectedSinkhole, rightPanelTabs, setIsSearchModalOpen, sinkholeRadiusMeters, syncRightPanelFeatureSelectionOnMap], + ); + + const closeRightPanelTab = useCallback( + (tabId: string | null) => { + if (!tabId) return; + const closingTab = rightPanelTabs.find((tab) => tab.id === tabId); + const remainingTabs = rightPanelTabs.filter((tab) => tab.id !== tabId); + + if (closingTab?.kind === "sinkhole") { + setSelectedSinkholeDetail(null); + setSelectedSinkholeNearbyPipes([]); + clearSinkholeSelectionLayer(); + } + if (closingTab?.kind === "feature") { + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + clearSinkholeFocusedPipeLayer(); + } + if (closingTab?.kind === "gisEdit") { + setGisEditDetailChangeId(null); + } + + setRightPanelTabs(remainingTabs); + + if (remainingTabs.length === 0) { + setActiveRightPanelTabId(null); + restoreSinkholeDetailOnCloseRef.current = false; + setFeatureData(null); + setIsOpen(false); + return; + } + + const fallbackTab = [...remainingTabs].sort((a, b) => b.lastActiveAt - a.lastActiveAt)[0]; + activateRightPanelTab(fallbackTab.id); + }, + [activateRightPanelTab, clearSinkholeFocusedPipeLayer, clearSinkholeSelectionLayer, rightPanelTabs], + ); + + useEffect(() => { + if (!isOpen || !featureData) return; + const nextTab = buildRightPanelTab(featureData); + if (nextTab) upsertRightPanelTab(nextTab); + }, [buildRightPanelTab, featureData, isOpen, upsertRightPanelTab]); + + useEffect(() => { + if (isOpen) return; + setRightPanelTabs([]); + setActiveRightPanelTabId(null); + }, [isOpen]); const openSinkholePopupWindow = useCallback(() => { const effectiveFilters = normalizeSinkholeFilters(appliedSinkholeFilters, sinkholeScope); @@ -5373,79 +5700,90 @@ const Map = () => { }, [gisEdit]); const GisEditAwareDetailView = useCallback((props: any) => { + const activeRightPanelTab = rightPanelTabs.find((tab) => tab.id === activeRightPanelTabId) ?? null; + const panelFeatureData = activeRightPanelTab?.featureData ?? props.featureData; const currentChange = gisEditDetailChangeRef.current; const currentFeatureData = gisEditDetailFeatureDataRef.current; const isEditMode = gisEditIsEditModeRef.current; - const selectedLayerTypeName = getSelectedFeatureLayerTypeName(props.featureData); + const selectedLayerTypeName = getSelectedFeatureLayerTypeName(panelFeatureData); + const panelSinkholeDetail = activeRightPanelTab?.kind === "sinkhole" + ? activeRightPanelTab.sinkholeDetail ?? selectedSinkholeDetail + : selectedSinkholeDetail; + const panelSinkholeNearbyPipes = activeRightPanelTab?.kind === "sinkhole" + ? activeRightPanelTab.nearbyPipes ?? selectedSinkholeNearbyPipes + : selectedSinkholeNearbyPipes; + const tabsView = ( + + ); + const closeActiveTab = () => { + if (activeRightPanelTabId) { + closeRightPanelTab(activeRightPanelTabId); + return; + } + props.onClose?.(); + }; if (isEditMode && currentChange && currentChange.layerId === "tb_ms_swage_ppl" && currentChange.editType !== "DELETE") { return ( - { - setGisEditDetailChangeId(null); - props.onClose?.(); - }} - onSave={handleApplyGisEditPipeBasicInfo} - /> + <> + {tabsView} + { + setGisEditDetailChangeId(null); + closeActiveTab(); + }} + onSave={handleApplyGisEditPipeBasicInfo} + /> + ); } - if (selectedLayerTypeName === SINKHOLE_LAYER_TYPE && selectedSinkholeDetail) { + if (selectedLayerTypeName === SINKHOLE_LAYER_TYPE && panelSinkholeDetail) { return ( - { - closeSinkholeDetail(); - props.onClose?.(); - }} - /> + <> + {tabsView} + + ); } return ( - { - const select = GisApp.Module.select as any; - setGisEditDetailChangeId(null); - const shouldRestoreSinkhole = - restoreSinkholeDetailOnCloseRef.current && Boolean(selectedSinkholeDetail); - restoreSinkholeDetailOnCloseRef.current = false; - select?.resetSelectedFeature?.(); - clearSinkholeFocusedPipeLayer(); - props.onClose?.(); - if (shouldRestoreSinkhole && selectedSinkholeDetail) { - window.setTimeout(() => { - setFeatureData({ - lyrTypeNm: SINKHOLE_LAYER_TYPE, - acdntNo: selectedSinkholeDetail.acdntNo, - }); - setIsOpen(true); - setIsSearchModalOpen(false); - }, 0); - } - }} - /> + <> + {tabsView} + { + setGisEditDetailChangeId(null); + restoreSinkholeDetailOnCloseRef.current = false; + closeActiveTab(); + }} + /> + ); }, [ - closeSinkholeDetail, - closeFloodHistoryDetail, - clearSinkholeFocusedPipeLayer, + activateRightPanelTab, + activeRightPanelTabId, + closeRightPanelTab, focusSinkholeNearbyPipe, handleApplyGisEditPipeBasicInfo, - selectedFloodHistoryDetail, + rightPanelTabs, selectedSinkholeDetail, selectedSinkholeNearbyPipes, - setIsSearchModalOpen, sinkholeRadiusMeters, ]); - useEffect(() => { if (!mapReady || !gisMaps.current || !gisEdit.isEditMode) return; diff --git a/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md index c99e223ad..41bffccaa 100644 --- a/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-010-지반침하정보/002-qa-SFR-010-010-지반침하정보-통합검증결과.md @@ -151,6 +151,70 @@ - 상세 API 500 상황에서 fallback이 실제 화면에서 패널을 유지하는지, 반경 적용 후 Network 요청이 좌표 기반으로 나가는지는 로그인 세션이 있는 브라우저에서 후속 수동 확인이 필요하다. +### 2.19 2026-06-26 우측 상세 패널 공통 탭 구현 검증 결과 + +- 검증 대상 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- 확인 내용 + - `RightPanelTab`, `RightPanelBookmarkTabs`, `rightPanelTabs`, `activeRightPanelTabId`가 추가되어 오른쪽 상세 패널의 열린 상세를 탭으로 관리하는 코드 경로를 확인했다. + - 탭이 2개 이상일 때만 책갈피 UI가 렌더링되고, 탭 클릭 시 활성 payload를 기준으로 `FeatureClickEventPopup`, `SinkholeDetailPanel`, `GisEditPipeSessionPopup` 중 해당 상세를 렌더링하는 구조를 확인했다. + - 지반침하 반경 내 관로 클릭 경로에서 `restoreSinkholeDetailOnCloseRef` 기반 복귀 대신 탭 상태로 전환할 수 있도록 플래그 의존을 제거했다. + - 변경 파일 `page.tsx`는 로컬 TypeScript `transpileModule` 파서 기준 diagnostics 0건이었다. + - `git -C frontend diff --check -- apps/sn/app/(map-layout)/map/page.tsx`는 공백 오류 없이 통과했다. Git 줄바꿈 정책상 LF→CRLF 경고만 출력됐다. + - 기존 dev 서버 `http://localhost:3002/map` 요청이 200으로 응답했다. +- 미확인 내용 + - 로그인 세션이 있는 브라우저에서 지반침하 상세 → 반경 내 관로 ID 클릭 → 책갈피 탭 2개 표시 → 탭 전환 → 현재 탭 닫기 흐름은 수동 확인이 필요하다. + - `pnpm --filter sn exec tsc --noEmit`는 기존 `tsconfig.json`의 `ignoreDeprecations` 값 오류와 `tsc` 실행 경로 문제로 변경 코드 타입 검증까지 도달하지 못했다. +### 2.20 2026-06-26 우측 상세 패널 탭 식별값 보강 검증 결과 + +- 검증 대상 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- 확인 내용 + - 지반침하 탭은 `SinkholeDetail.acdntNo` 또는 feature 사고번호를 `subTitle`로 저장해 제목 아래에 표시하는 코드 경로를 확인했다. + - 하수관로 탭은 `fclt_mng_no`, `fcltMngNo`, `pipeId`, `fid` 순서로 관리번호 후보를 읽어 제목 아래에 표시하는 코드 경로를 확인했다. + - 보조 식별값이 없으면 제목만 렌더링되어 다른 상세 탭의 표시가 깨지지 않는 구조를 확인했다. + - 탭 폭과 최소 높이를 조정해 숫자 표시 공간을 확보했다. +- 미확인 내용 + - 로그인 세션이 있는 브라우저에서 지반침하/하수관로 탭의 실제 표시 폭과 줄바꿈은 수동 확인이 필요하다. +### 2.21 2026-06-29 우측 상세 패널 탭 최대 개수 제한 검증 결과 + +- 검증 대상 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- 확인 내용 + - `RIGHT_PANEL_TAB_LIMIT=5` 기준으로 우측 상세 패널 탭 상태가 5개를 초과하지 않도록 제한하는 코드 경로를 확인했다. + - 5개가 열린 상태에서 새 상세를 추가하면 새 탭과 현재 활성 탭을 보호하고, 오래 사용하지 않은 일반 상세 탭을 우선 제거하는 구조를 확인했다. + - 지반침하 탭은 기준 상세로 쓰일 가능성이 높아 일반 상세 탭보다 늦게 제거되고, GIS 편집 탭은 자동 제거 우선순위가 낮은 구조를 확인했다. + - 변경 파일 `page.tsx`는 로컬 TypeScript `transpileModule` 파서 기준 diagnostics 0건이었다. + - `git -C frontend diff --check -- apps/sn/app/(map-layout)/map/page.tsx`는 공백 오류 없이 통과했다. Git 줄바꿈 정책상 LF→CRLF 경고만 출력됐다. + - 기존 dev 서버 `http://localhost:3002/map` 요청이 200으로 응답했다. +- 미확인 내용 + - 로그인 세션이 있는 브라우저에서 6번째 상세 클릭 시 실제로 가장 오래된 일반 탭이 교체되는지는 수동 확인이 필요하다. +### 2.22 2026-06-29 우측 상세 패널 탭 전환 지도 선택 동기화 검증 결과 + +- 검증 대상 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- 확인 내용 + - 탭 전환 함수가 활성 탭의 feature payload를 기준으로 지도 선택/강조 동기화 함수를 호출하는 코드 경로를 확인했다. + - 하수관로 탭 전환 시 기존 `SN_SINKHOLE_FOCUSED_PIPE_LAYER`를 비우고 해당 관로 feature를 다시 그린 뒤 선택 상태로 맞추는 구조를 확인했다. + - 지반침하 탭 전환 시 관로 강조 레이어를 지워 이전 관로 선택 표시가 남지 않도록 하는 구조를 확인했다. + - 반경 내 관로 ID 최초 클릭 경로도 같은 동기화 함수를 사용하도록 정리된 것을 확인했다. + - 변경 파일 `page.tsx`는 로컬 TypeScript `transpileModule` 파서 기준 diagnostics 0건이었다. + - `git -C frontend diff --check -- apps/sn/app/(map-layout)/map/page.tsx`는 공백 오류 없이 통과했다. Git 줄바꿈 정책상 LF→CRLF 경고만 출력됐다. + - 기존 dev 서버 `http://localhost:3002/map` 요청이 200으로 응답했다. +- 미확인 내용 + - 로그인 세션이 있는 브라우저에서 탭을 번갈아 클릭할 때 지도 관로 강조선이 실제로 따라 바뀌는지는 수동 확인이 필요하다. +### 2.23 2026-06-29 지도 직접 선택 시 지반침하 관로 포커스 해제 검증 결과 + +- 검증 대상 + - `FE_APP_ROOT/app/(map-layout)/map/page.tsx` +- 확인 내용 + - 일반 지도 피처 선택 함수 진입 시 지반침하 반경 내 관로 ID 클릭으로 만든 `SN_SINKHOLE_FOCUSED_PIPE_LAYER`를 먼저 정리하는 코드 경로를 확인했다. + - 반경 내 관로 ID 클릭과 책갈피 탭 전환은 기존 동기화 함수를 통해 필요한 관로 포커스를 다시 그리므로, 직접 지도 선택과 탭 전환의 역할이 분리되는 구조를 확인했다. + - 변경 파일 `page.tsx`는 로컬 TypeScript `transpileModule` 파서 기준 diagnostics 0건이었다. + - `git -C frontend diff --check -- apps/sn/app/(map-layout)/map/page.tsx`는 공백 오류 없이 통과했다. Git 줄바꿈 정책상 LF→CRLF 경고만 출력됐다. + - 기존 dev 서버 `http://localhost:3002/map` 요청이 200으로 응답했다. +- 미확인 내용 + - 로그인 세션이 있는 브라우저에서 파란 포커스 관로가 표시된 상태로 주변 관로를 직접 클릭했을 때 파란 선이 실제로 사라지는지는 수동 확인이 필요하다. ## 3. 미실행 항목 - FE 브라우저 검증 diff --git a/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md index 0174c1b70..4e9486c84 100644 --- a/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-010-지반침하정보/001-fe-SFR-010-010-지반침하정보-통합구현기록.md @@ -305,6 +305,59 @@ - 노후관로 경과년도 적용 alert는 적용된 경과년 값을 포함해 `노후관로 경과년도 {값}년으로 반영되었습니다.`로 표시한다. - 침수심 범례는 내부 디자인은 유지하고 외곽 세로 여백만 줄여 선택 레이어 설정 영역의 높이를 소폭 낮췄다. +### 4.38 2026-06-26 우측 상세 패널 공통 탭 UX 설계 + +- 사용자 요구사항을 재해석해, 이번 보완은 `SinkholeDetailPanel`에만 탭을 붙이는 작업이 아니라 `/map` 오른쪽 상세 패널 자리에 열리는 모든 상세 컴포넌트를 공통 탭으로 관리하는 UX 개선으로 정리했다. +- 현재 지반침하 상세에서 관로 ID를 클릭하면 `FeatureClickEventPopup`으로 하수관로 상세가 열리고, `restoreSinkholeDetailOnCloseRef`를 통해 닫기 시 지반침하 상세로 복귀한다. 이 숨은 복귀 흐름이 사용자에게 혼란을 줄 수 있으므로, 열린 상세를 책갈피 탭으로 명시하는 방향을 채택한다. +- 1개 상세만 열렸을 때는 기존과 동일하게 탭을 숨기고, 2개 이상일 때만 오른쪽 패널 왼쪽 가장자리에 세로 책갈피 탭을 표시한다. +- 탭 관리 대상은 우선 `FeatureClickEventPopup`과 `SinkholeDetailPanel`로 두고, 편집 세션처럼 저장 전 입력값 손실 위험이 있는 팝업은 구현 단계에서 포함 여부를 별도 판단한다. +- 다음 구현에서는 `page.tsx`에 `rightPanelTabs`, `activeRightPanelTabId` 상태와 `openRightPanelTab`, `closeRightPanelTab`, `activateRightPanelTab` helper를 추가하는 방향을 우선 검토한다. +- 기존 `featureData`/`selectedSinkholeDetail` 단일 패널 흐름은 한 번에 제거하지 않고, 탭 활성 payload 기반 렌더러로 감싸 회귀 위험을 줄인다. +- 같은 상세 대상은 중복 탭을 만들지 않고 기존 탭을 활성화한다. 예: 같은 `tb_ms_swage_ppl.fid` 관로를 다시 클릭하면 기존 하수관로 탭으로 이동한다. +- 이번 설계는 FE 상태관리와 UX 변경이며, BE API나 DB 계약 변경은 필요하지 않다. +### 4.39 2026-06-26 우측 상세 패널 공통 탭 UX 구현 + +- `page.tsx`에 `RightPanelTab`, `RightPanelBookmarkTabs`를 추가해 오른쪽 상세 패널에 열린 상세들을 탭 단위로 관리하도록 구현했다. +- 탭이 1개일 때는 기존 화면처럼 책갈피를 숨기고, 2개 이상일 때만 오른쪽 패널 왼쪽 가장자리에 세로 책갈피 탭을 표시한다. +- `featureData`, `selectedSinkholeDetail`, `gisEditDetailChangeId`를 기준으로 탭 id/title/payload를 만들고, 같은 피처나 같은 지반침하 사고번호를 다시 열면 기존 탭을 활성화한다. +- 기존 `GisEditAwareDetailView`는 `props.featureData`만 보던 구조에서 활성 탭 payload를 우선 사용하는 구조로 변경했다. +- `FeatureClickEventPopup`, `SinkholeDetailPanel`, `GisEditPipeSessionPopup` 내부 레이아웃은 유지하고, `X` 버튼은 현재 활성 탭 닫기로 연결했다. +- 지반침하 반경 내 관로 클릭 경로에서 기존 `restoreSinkholeDetailOnCloseRef` 복귀 플래그 의존을 제거하고, 지반침하/하수관로 탭을 병렬로 유지하도록 전환했다. +- 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 단일 파일이다. +- BE API, DTO, DB 변경은 없다. + +### 4.40 2026-06-26 우측 상세 패널 탭 식별값 보강 + +- 책갈피 탭이 여러 개 표시될 때 사용자가 같은 종류의 상세를 구분하기 쉽도록 탭 내부에 보조 식별값을 추가했다. +- 지반침하 탭은 제목 아래에 `사고번호`를 표시하고, 하수관로 탭은 제목 아래에 `관리번호`를 표시한다. +- 보조 식별값이 없는 상세 탭은 기존처럼 제목만 표시해 불필요한 빈 줄이 생기지 않도록 했다. +- 탭 폭과 최소 높이를 소폭 넓혀 긴 숫자가 줄바꿈되더라도 기존 오른쪽 상세 패널을 가리지 않는 범위에서 읽히도록 조정했다. +- 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 단일 파일이다. +- BE API, DTO, DB 변경은 없다. +### 4.41 2026-06-29 우측 상세 패널 탭 최대 개수 제한 + +- 책갈피 탭이 과도하게 쌓여 오른쪽 상세 패널 주변을 가리는 문제를 줄이기 위해 우측 상세 패널 탭 최대 개수를 5개로 제한했다. +- 5개가 열린 상태에서 새 상세를 추가하면 새 탭은 정상적으로 열고, 현재 활성 탭은 유지하며, 나머지 중 가장 오래 사용하지 않은 일반 상세 탭을 우선 제거한다. +- 지반침하 탭은 관로 탐색의 기준점이 될 수 있으므로 일반 하수관로 탭보다 늦게 제거되도록 했다. +- GIS 편집 상세 탭은 저장 전 입력값 손실 가능성이 있어 자동 제거 후보의 우선순위를 낮췄다. +- 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 단일 파일이다. +- BE API, DTO, DB 변경은 없다. +### 4.42 2026-06-29 우측 상세 패널 탭 전환 지도 선택 동기화 + +- 책갈피 탭을 클릭해 활성 상세를 바꿀 때 오른쪽 패널 내용만 바뀌고 지도상의 관로 강조가 이전 선택에 남아 있던 문제를 보정했다. +- 우측 상세 탭 전환 시 활성 탭의 feature payload를 기준으로 지도 선택 상태와 지반침하 관로 강조 레이어를 함께 갱신하도록 `syncRightPanelFeatureSelectionOnMap` 경로를 추가했다. +- 하수관로 탭으로 이동하면 해당 관로 feature를 `SN_SINKHOLE_FOCUSED_PIPE_LAYER`에 다시 그리고 선택 상태로 맞춘다. +- 지반침하 탭으로 이동하면 관로 강조 레이어를 지우고 지반침하 중심점/반경 표시만 남도록 했다. +- 반경 내 관로 ID 최초 클릭 경로도 같은 지도 동기화 함수를 사용하도록 정리해, 최초 클릭과 탭 전환의 지도 선택 동작이 같은 규칙을 따른다. +- 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 단일 파일이다. +- BE API, DTO, DB 변경은 없다. +### 4.43 2026-06-29 지도 직접 선택 시 지반침하 관로 포커스 해제 + +- 지반침하 상세의 반경 내 관로 ID 클릭으로 표시한 파란 관로 포커스가 남아 있는 상태에서 사용자가 지도상의 다른 관로를 직접 클릭하면, 기존 파란 포커스가 함께 남는 문제가 있었다. +- 일반 지도 피처 선택 진입점에서 `clearSinkholeFocusedPipeLayer()`를 먼저 호출하도록 보정해, 사용자가 직접 선택한 보라색 선택선만 남도록 했다. +- 반경 내 관로 ID 클릭과 책갈피 탭 전환은 기존처럼 `syncRightPanelFeatureSelectionOnMap`을 통해 필요한 관로 포커스를 다시 그린다. +- 변경 파일은 `FE_APP_ROOT/app/(map-layout)/map/page.tsx` 단일 파일이다. +- BE API, DTO, DB 변경은 없다. ## 5. 승인 전 보류 사항 - 목록 팝업을 `window popup`으로 열지 `/map` 내부 중앙 모달로 열지 확정 필요