@@ -181,6 +185,7 @@ export function V2CategoryManagerComponent({
tableName={selectedColumn.tableName}
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
+ screenId={effectiveScreenId}
/>
) : (
)
) : (
diff --git a/frontend/lib/registry/components/v2-pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/v2-pivot-grid/PivotGridComponent.tsx
index b55907a4..4e31d01c 100644
--- a/frontend/lib/registry/components/v2-pivot-grid/PivotGridComponent.tsx
+++ b/frontend/lib/registry/components/v2-pivot-grid/PivotGridComponent.tsx
@@ -6,6 +6,7 @@
*/
import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
+import { usePersistedState } from "@/hooks/usePersistedState";
import { cn } from "@/lib/utils";
import {
PivotGridProps,
@@ -327,13 +328,13 @@ export const PivotGridComponent: React.FC
= ({
// 🆕 초기 로드 시 자동 확장 (첫 레벨만)
const [isInitialExpanded, setIsInitialExpanded] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
- const [showFieldPanel, setShowFieldPanel] = useState(false); // 기본적으로 접힌 상태
+ const [showFieldPanel, setShowFieldPanel] = usePersistedState('showFieldPanel', false);
const [showFieldChooser, setShowFieldChooser] = useState(false);
const [drillDownData, setDrillDownData] = useState<{
open: boolean;
cellData: PivotCellData | null;
}>({ open: false, cellData: null });
- const [showChart, setShowChart] = useState(chartConfig?.enabled || false);
+ const [showChart, setShowChart] = usePersistedState('showChart', chartConfig?.enabled ?? false);
const [containerHeight, setContainerHeight] = useState(400);
const tableContainerRef = useRef(null);
@@ -997,7 +998,7 @@ export const PivotGridComponent: React.FC = ({
}, [stateStorageKey, initialFields]);
// 필드 숨기기/표시 상태
- const [hiddenFields, setHiddenFields] = useState>(new Set());
+ const [hiddenFields, setHiddenFields] = usePersistedState>('hiddenFields', new Set());
const toggleFieldVisibility = useCallback((fieldName: string) => {
setHiddenFields((prev) => {
diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx
index f56b0fb3..68c38e16 100644
--- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx
+++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx
@@ -1,6 +1,7 @@
"use client";
-import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
+import React, { useState, useCallback, useEffect, useMemo, useRef, useContext } from "react";
+import { usePersistedState } from "@/hooks/usePersistedState";
import { ComponentRendererProps } from "../../types";
import { SplitPanelLayoutConfig } from "./types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -68,19 +69,6 @@ export const SplitPanelLayoutComponent: React.FC
}) => {
const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig;
- // 🐛 디버깅: 로드 시 rightPanel.components 확인
- const rightComps = componentConfig.rightPanel?.components || [];
- const finishedTimeline = rightComps.find((c: any) => c.id === "finished_timeline");
- if (finishedTimeline) {
- const fm = finishedTimeline.componentConfig?.fieldMapping;
- console.log("🔍 [SplitPanelLayout] finished_timeline fieldMapping:", {
- componentId: finishedTimeline.id,
- fieldMapping: fm ? JSON.stringify(fm) : "undefined",
- fieldMappingKeys: fm ? Object.keys(fm) : [],
- fieldMappingId: fm?.id,
- fullComponentConfig: JSON.stringify(finishedTimeline.componentConfig || {}, null, 2),
- });
- }
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 사용 가능)
const companyCode = (props as any).companyCode as string | undefined;
@@ -188,11 +176,18 @@ export const SplitPanelLayoutComponent: React.FC
const [rightGrouping, setRightGrouping] = useState([]);
const [rightColumnVisibility, setRightColumnVisibility] = useState([]);
+ // TSP: 분할패널 상태 자동 보존
+ const [selectedLeftItem, setSelectedLeftItemRaw] = usePersistedState('selectedLeftItem', null, { debounce: 0 });
+ const initialCachedLeftItemRef = useRef(selectedLeftItem);
+ const setSelectedLeftItem = useCallback((val: any) => {
+ setSelectedLeftItemRaw(val);
+ }, [setSelectedLeftItemRaw]);
+ const userInteractedLeftRef = useRef(false);
+ const [expandedRightItems, setExpandedRightItems] = usePersistedState>('expandedRightItems', new Set());
+
// 데이터 상태
const [leftData, setLeftData] = useState([]);
- const [rightData, setRightData] = useState(null); // 조인 모드는 배열, 상세 모드는 객체
- const [selectedLeftItem, setSelectedLeftItem] = useState(null);
- const [expandedRightItems, setExpandedRightItems] = useState>(new Set()); // 확장된 우측 아이템
+ const [rightData, setRightData] = useState(null);
const [customLeftSelectedData, setCustomLeftSelectedData] = useState>({}); // 커스텀 모드: 좌측 선택 데이터
// 커스텀 모드: 탭/버튼 간 공유할 selectedRowsData 자체 관리 (항상 로컬 상태 사용)
const [localSelectedRowsData, setLocalSelectedRowsData] = useState([]);
@@ -205,15 +200,51 @@ export const SplitPanelLayoutComponent: React.FC
},
[(props as any).onSelectedRowsChange],
);
- const [leftSearchQuery, setLeftSearchQuery] = useState("");
- const [rightSearchQuery, setRightSearchQuery] = useState("");
+ // (TSP: selectedLeftItem 저장은 usePersistedState가 자동 처리)
+
+ // 좌/우측 패널 스크롤 위치 저장/복원
+ const leftPanelContentRef = useRef(null);
+ const rightPanelContentRef = useRef(null);
+ const splitScrollBase = (() => {
+ const sid = (props as any).screenId;
+ return sid != null && component.id ? `tsp-${sid}-${component.id}` : null;
+ })();
+ const leftScrollCacheKey = splitScrollBase ? `${splitScrollBase}-lscroll` : null;
+ const rightScrollCacheKey = splitScrollBase ? `${splitScrollBase}-rscroll` : null;
+ const scrollRestoredRef = useRef(false);
+
+ useEffect(() => {
+ const attachScroll = (el: HTMLElement | null, cacheKey: string | null) => {
+ if (!el || !cacheKey) return () => {};
+ let timer: ReturnType;
+ const handler = (e: Event) => {
+ const target = e.target as HTMLElement;
+ const t = target === el ? el : target;
+ if (t.scrollTop > 0 || t.scrollLeft > 0) {
+ clearTimeout(timer);
+ timer = setTimeout(() => {
+ try { sessionStorage.setItem(cacheKey, JSON.stringify({ top: t.scrollTop, left: t.scrollLeft })); } catch {}
+ }, 300);
+ }
+ };
+ el.addEventListener("scroll", handler, true);
+ return () => { el.removeEventListener("scroll", handler, true); clearTimeout(timer); };
+ };
+ const cleanL = attachScroll(leftPanelContentRef.current, leftScrollCacheKey);
+ const cleanR = attachScroll(rightPanelContentRef.current, rightScrollCacheKey);
+ return () => { cleanL(); cleanR(); };
+ }, [leftScrollCacheKey, rightScrollCacheKey]);
+
+ // TSP: UI 상태 자동 보존
+ const [leftSearchQuery, setLeftSearchQuery] = usePersistedState('leftSearchQuery', '');
+ const [rightSearchQuery, setRightSearchQuery] = usePersistedState('rightSearchQuery', '');
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
const [isLoadingRight, setIsLoadingRight] = useState(false);
- const [rightTableColumns, setRightTableColumns] = useState([]); // 우측 테이블 컬럼 정보
- const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들
+ const [rightTableColumns, setRightTableColumns] = useState([]);
+ const [expandedItems, setExpandedItems] = usePersistedState>('expandedItems', new Set());
// 추가 탭 관련 상태
- const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭, 1+ = 추가 탭
+ const [activeTabIndex, setActiveTabIndex] = usePersistedState('activeTabIndex', 0);
const [tabsData, setTabsData] = useState>({}); // 탭별 데이터
const [tabsLoading, setTabsLoading] = useState>({}); // 탭별 로딩 상태
const [leftColumnLabels, setLeftColumnLabels] = useState>({}); // 좌측 컬럼 라벨
@@ -225,6 +256,8 @@ export const SplitPanelLayoutComponent: React.FC
Record>
>({}); // 우측 카테고리 매핑
+ // (TSP: UI 상태 저장은 usePersistedState가 자동 처리)
+
// 🆕 커스텀 모드: 드래그/리사이즈 상태
const [draggingCompId, setDraggingCompId] = useState(null);
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
@@ -1555,6 +1588,7 @@ export const SplitPanelLayoutComponent: React.FC
// 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시)
const handleLeftItemSelect = useCallback(
(item: any) => {
+ userInteractedLeftRef.current = true;
// 동일 항목 클릭 시 선택 해제 (전체 보기로 복귀)
const leftPk = componentConfig.rightPanel?.relation?.leftColumn ||
componentConfig.rightPanel?.relation?.keys?.[0]?.leftColumn;
@@ -1568,6 +1602,9 @@ export const SplitPanelLayoutComponent: React.FC
setExpandedRightItems(new Set());
setTabsData({});
+ // 부모에게 선택 해제 전파 (탭 상태 캐시용)
+ (props as any).onSelectedRowsChange?.([], []);
+
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
if (mainRelationType === "detail") {
// "선택 시 표시" 모드: 선택 해제 시 데이터 비움
@@ -1593,6 +1630,9 @@ export const SplitPanelLayoutComponent: React.FC
setSelectedLeftItem(item);
setCustomLeftSelectedData(item); // 커스텀 모드 우측 폼에 선택된 데이터 전달
+
+ // 부모에게 선택 전파 (탭 상태 캐시용)
+ (props as any).onSelectedRowsChange?.([item], [item]);
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
setTabsData({}); // 모든 탭 데이터 초기화
@@ -2809,6 +2849,81 @@ export const SplitPanelLayoutComponent: React.FC
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDesignMode, componentConfig.autoLoad]);
+ // F5 새로고침 후 캐시된 좌측 선택 항목 복원
+ const selectionRestoredRef = useRef(false);
+ useEffect(() => {
+ if (selectionRestoredRef.current || isDesignMode) return;
+ if (leftData.length === 0) return;
+
+ // 1순위: usePersistedState에서 복원된 selectedLeftItem
+ if (selectedLeftItem) {
+ selectionRestoredRef.current = true;
+ setCustomLeftSelectedData(selectedLeftItem);
+ loadRightData(selectedLeftItem);
+ if (activeTabIndex > 0) {
+ loadTabData(activeTabIndex, selectedLeftItem);
+ }
+ return;
+ }
+
+ // 2순위: page-level selectedRowsData에서 복원
+ const parentSelectedRows = (props as any).selectedRowsData;
+ if (parentSelectedRows && parentSelectedRows.length > 0) {
+ const cachedItem = parentSelectedRows[0];
+ selectionRestoredRef.current = true;
+ setSelectedLeftItem(cachedItem);
+ setCustomLeftSelectedData(cachedItem);
+ loadRightData(cachedItem);
+ if (activeTabIndex > 0) {
+ loadTabData(activeTabIndex, cachedItem);
+ }
+ return;
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [leftData, isDesignMode]);
+
+ // 좌/우 패널 스크롤 위치 복원 (데이터 로드 완료 후)
+ useEffect(() => {
+ if (scrollRestoredRef.current || isDesignMode) return;
+ if (isLoadingLeft || isLoadingRight) return;
+
+ const restoreScroll = (panelRef: React.RefObject, cacheKey: string | null) => {
+ if (!panelRef.current || !cacheKey) return;
+ const saved = sessionStorage.getItem(cacheKey);
+ if (!saved) return;
+
+ let top = 0, left = 0;
+ try {
+ const parsed = JSON.parse(saved);
+ top = parsed.top ?? 0;
+ left = parsed.left ?? 0;
+ } catch {
+ top = parseInt(saved, 10) || 0;
+ }
+ if (top <= 0 && left <= 0) return;
+
+ const apply = (el: HTMLElement) => {
+ if (top > 0) el.scrollTop = top;
+ if (left > 0) el.scrollLeft = left;
+ };
+
+ if (panelRef.current.scrollHeight > panelRef.current.clientHeight ||
+ panelRef.current.scrollWidth > panelRef.current.clientWidth) {
+ apply(panelRef.current);
+ } else {
+ const scrollable = panelRef.current.querySelector("[class*='overflow-auto']") as HTMLElement;
+ if (scrollable) apply(scrollable);
+ }
+ };
+
+ const timer = setTimeout(() => {
+ restoreScroll(leftPanelContentRef, leftScrollCacheKey);
+ restoreScroll(rightPanelContentRef, rightScrollCacheKey);
+ scrollRestoredRef.current = true;
+ }, 150);
+ return () => clearTimeout(timer);
+ }, [leftData, rightData, isLoadingLeft, isLoadingRight, isDesignMode, leftScrollCacheKey, rightScrollCacheKey]);
+
// 🔄 필터 변경 시 데이터 다시 로드
useEffect(() => {
if (!isDesignMode && componentConfig.autoLoad !== false) {
@@ -2948,9 +3063,8 @@ export const SplitPanelLayoutComponent: React.FC
)}
-