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` 내부 중앙 모달로 열지 확정 필요