diff --git a/frontend/app/(main)/layout.tsx b/frontend/app/(main)/layout.tsx index a0ac6c89..feb83f45 100644 --- a/frontend/app/(main)/layout.tsx +++ b/frontend/app/(main)/layout.tsx @@ -1,12 +1,15 @@ import { AuthProvider } from "@/contexts/AuthContext"; import { MenuProvider } from "@/contexts/MenuContext"; +import { TabProvider } from "@/contexts/TabContext"; import { AppLayout } from "@/components/layout/AppLayout"; export default function MainLayout({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 95305aaf..c0aaa56d 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -29,16 +29,64 @@ import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환 import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/services/ScheduleGeneratorService"; // 스케줄 자동 생성 -function ScreenViewPage() { +// 탭 상태 캐시 (F5 새로고침 시 비활성 탭 데이터 복원용) +interface TabStateCache { + formData: Record; + selectedRowsData: any[]; + tableSortBy?: string; + tableSortOrder: "asc" | "desc"; + tableColumnOrder?: string[]; + tableDisplayData: any[]; + scrollTop?: number; + scrollLeft?: number; + timestamp: number; +} + +const TAB_CACHE_PREFIX = "tab-cache-"; +const TAB_CACHE_MAX_AGE = 30 * 60 * 1000; // 30분 + +function getTabCacheKey(screenId: number, menuObjid?: number): string { + return `${TAB_CACHE_PREFIX}${screenId}-${menuObjid || "default"}`; +} + +function saveTabCache(key: string, data: TabStateCache) { + try { + sessionStorage.setItem(key, JSON.stringify(data)); + } catch { /* 용량 초과 무시 */ } +} + +function loadTabCache(key: string): TabStateCache | null { + try { + const raw = sessionStorage.getItem(key); + if (!raw) return null; + const cache = JSON.parse(raw) as TabStateCache; + if (Date.now() - cache.timestamp > TAB_CACHE_MAX_AGE) { + sessionStorage.removeItem(key); + return null; + } + return cache; + } catch { return null; } +} + +function clearTabCache(key: string) { + sessionStorage.removeItem(key); +} + +interface ScreenViewPageProps { + screenIdProp?: number; + menuObjidProp?: number; +} + +function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) { // 스케줄 자동 생성 서비스 활성화 const { showConfirmDialog, previewResult, handleConfirm, closeDialog, isLoading: scheduleLoading } = useScheduleGenerator(); const params = useParams(); const searchParams = useSearchParams(); const router = useRouter(); - const screenId = parseInt(params.screenId as string); + const screenId = screenIdProp || parseInt(params.screenId as string); - // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) - const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; + // props 우선, 없으면 URL 쿼리에서 menuObjid 가져오기 + const menuObjid = menuObjidProp || (searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined); // URL 쿼리에서 프리뷰용 company_code 가져오기 const previewCompanyCode = searchParams.get("company_code"); @@ -63,16 +111,39 @@ function ScreenViewPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [formData, setFormData] = useState>({}); + // F5 캐시 복원 플래그 (복원된 경우 loadMainTableData/initAutoFill 스킵) + const cacheRestoredRef = React.useRef(false); + const initialCache = React.useMemo(() => { + if (!screenId) return null; + return loadTabCache(getTabCacheKey(screenId, menuObjid)); + }, []); + + const [formData, setFormData] = useState>(() => { + if (initialCache?.formData && Object.keys(initialCache.formData).length > 0) { + cacheRestoredRef.current = true; + return initialCache.formData as Record; + } + return {}; + }); // 테이블에서 선택된 행 데이터 (버튼 액션에 전달) - const [selectedRowsData, setSelectedRowsData] = useState([]); + const [selectedRowsData, setSelectedRowsData] = useState( + () => initialCache?.selectedRowsData ?? [] + ); // 테이블 정렬 정보 (엑셀 다운로드용) - const [tableSortBy, setTableSortBy] = useState(); - const [tableSortOrder, setTableSortOrder] = useState<"asc" | "desc">("asc"); - const [tableColumnOrder, setTableColumnOrder] = useState(); - const [tableDisplayData, setTableDisplayData] = useState([]); // 화면에 표시된 데이터 (컬럼 순서 포함) + const [tableSortBy, setTableSortBy] = useState( + () => initialCache?.tableSortBy + ); + const [tableSortOrder, setTableSortOrder] = useState<"asc" | "desc">( + () => initialCache?.tableSortOrder ?? "asc" + ); + const [tableColumnOrder, setTableColumnOrder] = useState( + () => initialCache?.tableColumnOrder + ); + const [tableDisplayData, setTableDisplayData] = useState( + () => initialCache?.tableDisplayData ?? [] + ); // 플로우에서 선택된 데이터 (버튼 액션에 전달) const [flowSelectedData, setFlowSelectedData] = useState([]); @@ -122,6 +193,139 @@ function ScreenViewPage() { initComponents(); }, []); + // 페이지 스크롤 위치 추적 (비활성 탭 F5 복원용 - 세로+가로) + const pageScrollTopRef = React.useRef(0); + const pageScrollLeftRef = React.useRef(0); + const scrollTargetRef = React.useRef(null); + useEffect(() => { + const el = containerRef.current; + if (!el || !screenId) return; + + const cleanups: (() => void)[] = []; + let saveTimer: ReturnType; + const scrollKey = `page-scroll-${screenId}-${menuObjid || "default"}`; + + const makeHandler = (target: HTMLElement) => () => { + if (target.scrollTop > 0 || target.scrollLeft > 0) { + pageScrollTopRef.current = target.scrollTop; + pageScrollLeftRef.current = target.scrollLeft; + scrollTargetRef.current = target; + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + try { sessionStorage.setItem(scrollKey, JSON.stringify({ top: target.scrollTop, left: target.scrollLeft })); } catch {} + }, 300); + } + }; + + const selfHandler = makeHandler(el); + el.addEventListener("scroll", selfHandler, { passive: true }); + cleanups.push(() => el.removeEventListener("scroll", selfHandler)); + + let parent = el.parentElement; + while (parent) { + const style = getComputedStyle(parent); + const ov = style.overflow + style.overflowX + style.overflowY; + if (ov.includes("auto") || ov.includes("scroll")) { + const h = makeHandler(parent); + parent.addEventListener("scroll", h, { passive: true }); + const pp = parent; + cleanups.push(() => pp.removeEventListener("scroll", h)); + } + parent = parent.parentElement; + } + + return () => { cleanups.forEach(fn => fn()); clearTimeout(saveTimer); }; + }, [screenId, menuObjid, loading]); + + // 탭 상태를 sessionStorage에 debounce 저장 (F5 새로고침 시 복원용) + useEffect(() => { + if (!screenId || loading) return; + const cacheKey = getTabCacheKey(screenId, menuObjid); + const timer = setTimeout(() => { + saveTabCache(cacheKey, { + formData, + selectedRowsData, + tableSortBy, + tableSortOrder, + tableColumnOrder, + tableDisplayData, + scrollTop: pageScrollTopRef.current, + scrollLeft: pageScrollLeftRef.current, + timestamp: Date.now(), + }); + }, 500); + return () => clearTimeout(timer); + }, [formData, selectedRowsData, tableSortBy, tableSortOrder, tableColumnOrder, tableDisplayData, screenId, menuObjid, loading]); + + // 페이지 스크롤 위치 복원 (비활성 탭 F5 복원용 - 세로+가로 재시도) + const scrollRestoredRef = React.useRef(false); + useEffect(() => { + if (scrollRestoredRef.current || loading || !layoutReady || !containerRef.current || !screenId) return; + const scrollKey = `page-scroll-${screenId}-${menuObjid || "default"}`; + const raw = sessionStorage.getItem(scrollKey); + + let scrollTop = 0; + let scrollLeft = 0; + if (raw) { + try { + const parsed = JSON.parse(raw); + scrollTop = parsed.top ?? 0; + scrollLeft = parsed.left ?? 0; + } catch { + scrollTop = parseInt(raw, 10) || 0; + } + } + if (!scrollTop && !scrollLeft) { + scrollTop = initialCache?.scrollTop ?? 0; + } + if (scrollTop <= 0 && scrollLeft <= 0) { scrollRestoredRef.current = true; return; } + + let attempt = 0; + const maxAttempts = 8; + const tryRestore = () => { + attempt++; + if (!containerRef.current) { + if (attempt < maxAttempts) setTimeout(tryRestore, 250 * attempt); + return; + } + + const candidates: HTMLElement[] = []; + if (scrollTargetRef.current) candidates.push(scrollTargetRef.current); + candidates.push(containerRef.current); + let parent = containerRef.current.parentElement; + while (parent && candidates.length < 6) { + const style = getComputedStyle(parent); + const ov = style.overflow + style.overflowX + style.overflowY; + if (ov.includes("auto") || ov.includes("scroll")) { + candidates.push(parent); + } + parent = parent.parentElement; + } + + let restored = false; + for (const el of candidates) { + const canScrollV = el.scrollHeight > el.clientHeight + 1; + const canScrollH = el.scrollWidth > el.clientWidth + 1; + if (canScrollV || canScrollH) { + if (scrollTop > 0 && canScrollV) el.scrollTop = scrollTop; + if (scrollLeft > 0 && canScrollH) el.scrollLeft = scrollLeft; + const topOk = scrollTop <= 0 || Math.abs(el.scrollTop - scrollTop) < 5; + const leftOk = scrollLeft <= 0 || Math.abs(el.scrollLeft - scrollLeft) < 5; + if (topOk && leftOk) { restored = true; break; } + } + } + if (restored) { + scrollRestoredRef.current = true; + } else if (attempt < maxAttempts) { + setTimeout(tryRestore, 250 * attempt); + } else { + scrollRestoredRef.current = true; + } + }; + const timer = setTimeout(tryRestore, 300); + return () => clearTimeout(timer); + }, [loading, layoutReady, screenId, menuObjid, initialCache]); + // 편집 모달 이벤트 리스너 등록 useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { @@ -384,6 +588,8 @@ function ScreenViewPage() { // 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼) // 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움 useEffect(() => { + if (cacheRestoredRef.current) return; + const loadMainTableData = async () => { if (!screen || !layout || !layout.components || !companyCode) { return; @@ -466,6 +672,8 @@ function ScreenViewPage() { // 🆕 개별 autoFill 처리 (메인 테이블과 다른 테이블에서 조회하는 경우) useEffect(() => { + if (cacheRestoredRef.current) return; + const initAutoFill = async () => { if (!layout || !layout.components || !user) { return; @@ -567,13 +775,13 @@ function ScreenViewPage() { } }, [conditionalFieldValues, layout?.components]); - // 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 초기 로딩 시에만 계산 - // 브라우저 배율 조정 시 메뉴와 화면이 함께 축소/확대되도록 resize 이벤트는 감지하지 않음 + // 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) + // display:none 상태(비활성 탭)에서는 offsetWidth가 0이므로 건너뛰고, + // ResizeObserver로 탭이 보이게 될 때 자동 재계산 useEffect(() => { - // 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동) if (isMobile) { setScale(1); - setLayoutReady(true); // 모바일에서도 레이아웃 준비 완료 표시 + setLayoutReady(true); return; } @@ -582,57 +790,52 @@ function ScreenViewPage() { const designWidth = layout?.screenResolution?.width || 1200; const designHeight = layout?.screenResolution?.height || 800; - // 컨테이너의 실제 크기 (프리뷰 모드에서는 window 크기 사용) - let containerWidth: number; - let containerHeight: number; + let cw: number; + let ch: number; if (isPreviewMode) { - // iframe에서는 window 크기를 직접 사용 - containerWidth = window.innerWidth; - containerHeight = window.innerHeight; + cw = window.innerWidth; + ch = window.innerHeight; } else { - containerWidth = containerRef.current.offsetWidth; - containerHeight = containerRef.current.offsetHeight; + cw = containerRef.current.offsetWidth; + ch = containerRef.current.offsetHeight; } + // 비활성 탭(display:none)이면 offsetWidth=0 → 스케일 계산 건너뛰기 + if (cw === 0) return; + let newScale: number; if (isPreviewMode) { - // 프리뷰 모드: 가로/세로 모두 fit하도록 (여백 없이) - const scaleX = containerWidth / designWidth; - const scaleY = containerHeight / designHeight; - newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율 + const scaleX = cw / designWidth; + const scaleY = ch / designHeight; + newScale = Math.min(scaleX, scaleY, 1); } else { - // 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정) const MARGIN_X = 32; - const availableWidth = containerWidth - MARGIN_X; + const availableWidth = cw - MARGIN_X; newScale = availableWidth / designWidth; } - // console.log("📐 스케일 계산:", { - // containerWidth, - // containerHeight, - // designWidth, - // designHeight, - // finalScale: newScale, - // isPreviewMode, - // }); - setScale(newScale); - // 컨테이너 너비 업데이트 - setContainerWidth(containerWidth); - - // 스케일 계산 완료 후 레이아웃 준비 완료 표시 + setContainerWidth(cw); setLayoutReady(true); } }; - // 초기 측정 (한 번만 실행) const timer = setTimeout(updateScale, 100); - // resize 이벤트는 감지하지 않음 - 브라우저 배율 조정 시 메뉴와 화면이 함께 변경되도록 + // ResizeObserver: 탭이 display:none → block으로 전환되면 자동 재계산 + let resizeObserver: ResizeObserver | undefined; + if (containerRef.current) { + resizeObserver = new ResizeObserver(() => { + updateScale(); + }); + resizeObserver.observe(containerRef.current); + } + return () => { clearTimeout(timer); + resizeObserver?.disconnect(); }; }, [layout, isMobile, isPreviewMode]); @@ -1282,7 +1485,20 @@ function ScreenViewPage() { ); } -// 실제 컴포넌트를 Provider로 감싸기 +// 탭 시스템에서 사용할 수 있는 임베드 가능한 컴포넌트 +export function ScreenViewPageEmbeddable({ screenId, menuObjid }: { screenId: number; menuObjid?: number }) { + return ( + + + + + + + + ); +} + +// URL 라우트에서 사용하는 기본 래퍼 function ScreenViewPageWrapper() { return ( diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index de2c5b61..0437807d 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -26,6 +26,9 @@ import { MenuItem } from "@/lib/api/menu"; import { menuScreenApi } from "@/lib/api/screen"; import { apiClient } from "@/lib/api/client"; import { toast } from "sonner"; +import { useTab } from "@/contexts/TabContext"; +import { TabBar } from "./TabBar"; +import { TabContent } from "./TabContent"; import { ProfileModal } from "./ProfileModal"; import { Logo } from "./Logo"; import { SideMenu } from "./SideMenu"; @@ -71,7 +74,7 @@ interface ExtendedUserInfo { } interface AppLayoutProps { - children: React.ReactNode; + children?: React.ReactNode; } // 메뉴 아이콘 매핑 함수 @@ -212,12 +215,27 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten }; }; +// 메뉴 트리에서 특정 메뉴의 부모 이름을 찾는 헬퍼 함수 +function findParentMenuName(uiMenus: any[], targetMenuId: string): string | undefined { + for (const menu of uiMenus) { + if (menu.children) { + const found = menu.children.find((child: any) => String(child.id) === String(targetMenuId)); + if (found) return menu.name; + + const deepResult = findParentMenuName(menu.children, targetMenuId); + if (deepResult) return deepResult; + } + } + return undefined; +} + function AppLayoutInner({ children }: AppLayoutProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const { user, logout, refreshUserData, switchCompany } = useAuth(); const { userMenus, adminMenus, loading, refreshMenus } = useMenu(); + const { openTab, tabs, activeTabId } = useTab(); const [sidebarOpen, setSidebarOpen] = useState(true); const [expandedMenus, setExpandedMenus] = useState>(new Set()); const [isMobile, setIsMobile] = useState(false); @@ -249,6 +267,23 @@ function AppLayoutInner({ children }: AppLayoutProps) { fetchCurrentCompanyName(); }, [(user as ExtendedUserInfo)?.companyCode, (user as ExtendedUserInfo)?.userType]); + // 탭 전환 시 URL 동기화 + useEffect(() => { + if (!activeTabId || tabs.length === 0) return; + const activeTab = tabs.find(t => t.id === activeTabId); + if (!activeTab) return; + + const currentScreenMatch = pathname.match(/^\/screens\/(\d+)/); + const currentScreenId = currentScreenMatch ? parseInt(currentScreenMatch[1]) : null; + + if (currentScreenId !== activeTab.screenId) { + const urlParams = new URLSearchParams(); + if (activeTab.isAdminMode) urlParams.set("mode", "admin"); + if (activeTab.menuObjid) urlParams.set("menuObjid", activeTab.menuObjid.toString()); + router.replace(`/screens/${activeTab.screenId}?${urlParams.toString()}`); + } + }, [activeTabId]); + // 화면 크기 감지 및 사이드바 초기 상태 설정 useEffect(() => { const checkIsMobile = () => { @@ -320,8 +355,9 @@ function AppLayoutInner({ children }: AppLayoutProps) { setExpandedMenus(newExpanded); }; - // 메뉴 클릭 핸들러 - const handleMenuClick = async (menu: any) => { + // 메뉴 클릭 핸들러 (탭 시스템 통합) + // parentMenuName: 사이드바에서 자식 메뉴 클릭 시 부모 이름 직접 전달 + const handleMenuClick = async (menu: any, parentMenuName?: string) => { if (menu.hasChildren) { toggleMenu(menu.id); } else { @@ -337,19 +373,33 @@ function AppLayoutInner({ children }: AppLayoutProps) { const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid); if (assignedScreens.length > 0) { - // 할당된 화면이 있으면 첫 번째 화면으로 이동 const firstScreen = assignedScreens[0]; - // 관리자 모드 상태와 menuObjid를 쿼리 파라미터로 전달 - const params = new URLSearchParams(); - if (isAdminMode) { - params.set("mode", "admin"); - } - params.set("menuObjid", menuObjid.toString()); - - const screenPath = `/screens/${firstScreen.screenId}?${params.toString()}`; + // 상위 카테고리 이름: 직접 전달받은 값 우선, 없으면 트리에서 탐색 + const resolvedParentName = parentMenuName || findParentMenuName( + convertMenuToUI(currentMenus, user as ExtendedUserInfo), + String(menu.id), + ); + + // 탭으로 열기 + openTab({ + screenId: firstScreen.screenId, + menuObjid, + screenName: menuName, + menuName: menuName, + parentMenuName: resolvedParentName, + isAdminMode, + }); + + // URL도 동기화 + const urlParams = new URLSearchParams(); + if (isAdminMode) { + urlParams.set("mode", "admin"); + } + urlParams.set("menuObjid", menuObjid.toString()); + const screenPath = `/screens/${firstScreen.screenId}?${urlParams.toString()}`; + router.replace(screenPath); - router.push(screenPath); if (isMobile) { setSidebarOpen(false); } @@ -366,7 +416,6 @@ function AppLayoutInner({ children }: AppLayoutProps) { setSidebarOpen(false); } } else { - // URL도 없고 할당된 화면도 없으면 경고 메시지 toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다."); } } @@ -428,12 +477,23 @@ function AppLayoutInner({ children }: AppLayoutProps) { {menu.children?.map((child: any) => (
{ + if (child.hasChildren) return; + e.dataTransfer.setData("application/tab-menu", JSON.stringify({ + menuId: child.id, + menuObjid: child.objid || child.id, + menuName: child.label || child.name, + parentMenuName: menu.name, + })); + e.dataTransfer.effectAllowed = "copy"; + }} className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${ pathname === child.url ? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900" : "text-slate-600 hover:bg-slate-50 hover:text-slate-900" }`} - onClick={() => handleMenuClick(child)} + onClick={() => handleMenuClick(child, menu.name)} >
{child.icon} @@ -695,9 +755,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
- {/* 가운데 컨텐츠 영역 - 스크롤 가능 */} -
- {children} + {/* 가운데 컨텐츠 영역 */} +
+ {/* 탭 바 (데스크톱에서만 항상 표시) */} + {!isMobile && } + + {/* 콘텐츠 영역 */} +
+ +
diff --git a/frontend/components/layout/TabBar.tsx b/frontend/components/layout/TabBar.tsx new file mode 100644 index 00000000..61dac241 --- /dev/null +++ b/frontend/components/layout/TabBar.tsx @@ -0,0 +1,586 @@ +"use client"; + +import React, { useRef, useState, useEffect, useCallback } from "react"; +import { flushSync } from "react-dom"; +import { X, ChevronDown, RotateCw } from "lucide-react"; +import { useTab, TabItem } from "@/contexts/TabContext"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { menuScreenApi } from "@/lib/api/screen"; +import { useRouter } from "next/navigation"; + +const TAB_MIN_WIDTH = 190; +const TAB_MAX_WIDTH = 190; +const TAB_GAP = 2; +const OVERFLOW_BUTTON_WIDTH = 48; + +function getTabDisplayName(tab: TabItem): string { + if (tab.parentMenuName && tab.screenName) { + return `${tab.parentMenuName} - ${tab.screenName}`; + } + return tab.screenName || `화면 ${tab.screenId}`; +} + +export function TabBar() { + const { tabs, activeTabId, switchTab, closeTab, closeOtherTabs, closeAllTabs, closeTabsToTheLeft, closeTabsToTheRight, updateTabOrder, openTab, refreshTab } = useTab(); + const router = useRouter(); + const tabsContainerRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + const [visibleCount, setVisibleCount] = useState(tabs.length); + const [tabWidth, setTabWidth] = useState(TAB_MAX_WIDTH); + const [contextMenu, setContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null); + const dragStateRef = useRef<{ + tabId: string; + startX: number; + startIndex: number; + currentIndex: number; + order: string[]; // 드래그 중 논리적 순서 (tabId 배열) + isDragging: boolean; + } | null>(null); + const menuDragInsertRef = useRef(-1); + const pendingDropRef = useRef<{ timerId: ReturnType; finalize: () => void } | null>(null); + const tabsRef = useRef(tabs); + tabsRef.current = tabs; + const newDropTabIdRef = useRef(null); + + const calculateVisibleTabs = useCallback(() => { + const container = tabsContainerRef.current; + if (!container) { + setVisibleCount(tabs.length); + return; + } + + const containerWidth = container.offsetWidth; + const tabSlotWidth = TAB_MIN_WIDTH + TAB_GAP; + + // 오버플로우 없이 전부 들어가는지 확인 + const totalIfAll = tabs.length * tabSlotWidth; + if (totalIfAll <= containerWidth) { + // 전부 표시, 남는 공간을 균등 분배 + setVisibleCount(tabs.length); + const w = Math.floor(containerWidth / tabs.length) - TAB_GAP; + setTabWidth(Math.min(TAB_MAX_WIDTH, Math.max(TAB_MIN_WIDTH, w))); + return; + } + + // 오버플로우 필요: +N 버튼 공간 확보 후 몇 개 들어가는지 계산 + const availableForTabs = containerWidth - OVERFLOW_BUTTON_WIDTH; + const count = Math.max(1, Math.floor(availableForTabs / tabSlotWidth)); + + setVisibleCount(count); + const w = Math.floor(availableForTabs / count) - TAB_GAP; + setTabWidth(Math.min(TAB_MAX_WIDTH, Math.max(TAB_MIN_WIDTH, w))); + }, [tabs.length]); + + useEffect(() => { + calculateVisibleTabs(); + const resizeObserver = new ResizeObserver(calculateVisibleTabs); + if (tabsContainerRef.current) { + resizeObserver.observe(tabsContainerRef.current); + } + return () => resizeObserver.disconnect(); + }, [calculateVisibleTabs]); + + useEffect(() => { + const handleClick = () => setContextMenu(null); + if (contextMenu) { + document.addEventListener("click", handleClick); + return () => document.removeEventListener("click", handleClick); + } + }, [contextMenu]); + + if (tabs.length === 0) { + return ( +
+ ); + } + + const visibleTabs = tabs.slice(0, visibleCount); + const overflowTabs = tabs.slice(visibleCount); + const hasOverflow = overflowTabs.length > 0; + + // 활성 탭이 오버플로우에 있는 경우, 보이는 탭의 마지막을 교체 + const activeInOverflow = overflowTabs.find(t => t.id === activeTabId); + let displayVisibleTabs = [...visibleTabs]; + let displayOverflowTabs = [...overflowTabs]; + + if (activeInOverflow && visibleTabs.length > 0) { + const lastVisible = displayVisibleTabs[displayVisibleTabs.length - 1]; + displayVisibleTabs[displayVisibleTabs.length - 1] = activeInOverflow; + displayOverflowTabs = displayOverflowTabs.filter(t => t.id !== activeInOverflow.id); + displayOverflowTabs.unshift(lastVisible); + } + + const handleContextMenu = (e: React.MouseEvent, tabId: string) => { + e.preventDefault(); + setContextMenu({ tabId, x: e.clientX, y: e.clientY }); + }; + + const applyDragTransforms = (order: string[], dragId: string, dragDx: number) => { + const container = tabsContainerRef.current; + if (!container) return; + const slotWidth = tabWidth + TAB_GAP; + const originalOrder = tabsRef.current.map(t => t.id); + + order.forEach((id, newIdx) => { + const el = container.querySelector(`[data-tab-id="${id}"]`) as HTMLElement | null; + if (!el) return; + const origIdx = originalOrder.indexOf(id); + if (origIdx === -1) return; + + if (id === dragId) { + el.style.transform = `translateX(${dragDx}px)`; + el.style.zIndex = "50"; + el.style.opacity = "0.85"; + } else { + const offset = (newIdx - origIdx) * slotWidth; + el.style.transform = offset !== 0 ? `translateX(${offset}px)` : ""; + el.style.zIndex = ""; + el.style.opacity = ""; + } + el.style.transition = "none"; + }); + }; + + const clearAllTransforms = () => { + const container = tabsContainerRef.current; + if (!container) return; + container.querySelectorAll("[data-tab-id]").forEach((el) => { + const h = el as HTMLElement; + h.style.transform = ""; + h.style.zIndex = ""; + h.style.opacity = ""; + h.style.transition = ""; + }); + }; + + const handleTabMouseDown = (e: React.MouseEvent, tabId: string) => { + if (e.button === 1) { + e.preventDefault(); + closeTab(tabId); + return; + } + if (e.button !== 0) return; + if ((e.target as HTMLElement).closest("button")) return; + + // 이전 드롭 애니메이션이 진행 중이면 즉시 완료 + if (pendingDropRef.current) { + clearTimeout(pendingDropRef.current.timerId); + pendingDropRef.current.finalize(); + pendingDropRef.current = null; + } + + const container = tabsContainerRef.current; + if (!container) return; + + // finalize() 후 flushSync로 상태가 갱신되었으므로 최신 tabs 사용 + const currentTabs = tabsRef.current; + const startIndex = currentTabs.findIndex(t => t.id === tabId); + const startX = e.clientX; + const slotWidth = tabWidth + TAB_GAP; + const order = currentTabs.map(t => t.id); + + dragStateRef.current = { + tabId, + startX, + startIndex, + currentIndex: startIndex, + order, + isDragging: false, + }; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const state = dragStateRef.current; + if (!state) return; + + const dx = moveEvent.clientX - state.startX; + if (!state.isDragging && Math.abs(dx) > 4) { + state.isDragging = true; + } + if (!state.isDragging) return; + + // 마우스 위치로 목표 슬롯 계산 + const containerLeft = container.getBoundingClientRect().left + 4; + const mouseRelX = moveEvent.clientX - containerLeft; + const targetSlot = Math.max(0, Math.min(visibleCount - 1, Math.floor(mouseRelX / slotWidth))); + + // 순서 배열에서 직접 이동 (React 상태 안 건드림) + if (targetSlot !== state.currentIndex) { + const [moved] = state.order.splice(state.currentIndex, 1); + state.order.splice(targetSlot, 0, moved); + state.currentIndex = targetSlot; + } + + // 드래그 탭의 시각적 위치: 원래 슬롯 기준 마우스 이동량 + const visualDx = dx; + applyDragTransforms(state.order, state.tabId, visualDx); + }; + + const handleMouseUp = () => { + const state = dragStateRef.current; + dragStateRef.current = null; + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + + if (state && state.isDragging && state.currentIndex !== state.startIndex) { + const dragEl = container.querySelector(`[data-tab-id="${state.tabId}"]`) as HTMLElement | null; + const targetOffset = (state.currentIndex - state.startIndex) * slotWidth; + const newOrder = [...state.order]; + + if (dragEl) { + dragEl.style.transition = "transform 150ms ease, opacity 150ms ease"; + dragEl.style.transform = `translateX(${targetOffset}px)`; + dragEl.style.opacity = "1"; + dragEl.style.zIndex = "50"; + } + + const finalize = () => { + clearAllTransforms(); + flushSync(() => updateTabOrder(newOrder)); + pendingDropRef.current = null; + }; + const timerId = setTimeout(finalize, 150); + pendingDropRef.current = { timerId, finalize }; + } else if (state && state.isDragging) { + const dragEl = container.querySelector(`[data-tab-id="${state.tabId}"]`) as HTMLElement | null; + if (dragEl) { + dragEl.style.transition = "transform 150ms ease, opacity 150ms ease"; + dragEl.style.transform = "translateX(0)"; + dragEl.style.opacity = "1"; + } + + const finalize = () => { + clearAllTransforms(); + pendingDropRef.current = null; + }; + const timerId = setTimeout(finalize, 150); + pendingDropRef.current = { timerId, finalize }; + } else { + clearAllTransforms(); + if (state) switchTab(tabId); + } + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }; + + const applyMenuDragGap = (insertIdx: number) => { + const container = tabsContainerRef.current; + if (!container) return; + const slotWidth = tabWidth + TAB_GAP; + + displayVisibleTabs.forEach((tab, i) => { + const el = container.querySelector(`[data-tab-id="${tab.id}"]`) as HTMLElement | null; + if (!el) return; + if (i >= insertIdx) { + el.style.transform = `translateX(${slotWidth}px)`; + el.style.transition = "transform 150ms ease"; + } else { + el.style.transform = ""; + el.style.transition = "transform 150ms ease"; + } + }); + }; + + const clearMenuDragGap = () => { + const container = tabsContainerRef.current; + if (!container) return; + container.querySelectorAll("[data-tab-id]").forEach((el) => { + const h = el as HTMLElement; + h.style.transform = ""; + h.style.transition = ""; + }); + menuDragInsertRef.current = -1; + }; + + const handleMenuDragOver = (e: React.DragEvent) => { + if (e.dataTransfer.types.includes("application/tab-menu")) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + if (!dragOver) setDragOver(true); + + const container = tabsContainerRef.current; + if (container) { + const containerLeft = container.getBoundingClientRect().left + 4; + const mouseRelX = e.clientX - containerLeft; + const slotWidth = tabWidth + TAB_GAP; + const insertIdx = Math.max(0, Math.min(displayVisibleTabs.length, Math.round(mouseRelX / slotWidth))); + + if (insertIdx !== menuDragInsertRef.current) { + menuDragInsertRef.current = insertIdx; + applyMenuDragGap(insertIdx); + } + } + } + }; + + const handleMenuDragLeave = (e: React.DragEvent) => { + const container = tabsContainerRef.current; + if (container && !container.contains(e.relatedTarget as Node)) { + setDragOver(false); + clearMenuDragGap(); + } + }; + + const handleMenuDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + + const insertIndex = menuDragInsertRef.current >= 0 + ? menuDragInsertRef.current + : undefined; + + clearMenuDragGap(); + + const raw = e.dataTransfer.getData("application/tab-menu"); + if (!raw) return; + + try { + const data = JSON.parse(raw); + const { menuObjid, menuName, parentMenuName } = data; + + const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid); + if (assignedScreens.length > 0) { + const firstScreen = assignedScreens[0]; + + const expectedTabId = `tab-${firstScreen.screenId}-${menuObjid || "default"}`; + const alreadyOpen = tabs.some(t => t.id === expectedTabId); + + if (!alreadyOpen) { + newDropTabIdRef.current = expectedTabId; + } + + openTab({ + screenId: firstScreen.screenId, + menuObjid, + screenName: menuName, + menuName: menuName, + parentMenuName: parentMenuName, + }, insertIndex); + + const urlParams = new URLSearchParams(); + urlParams.set("menuObjid", menuObjid.toString()); + router.replace(`/screens/${firstScreen.screenId}?${urlParams.toString()}`); + + if (!alreadyOpen) { + requestAnimationFrame(() => { + const container = tabsContainerRef.current; + if (!container) return; + const newEl = container.querySelector(`[data-tab-id="${expectedTabId}"]`) as HTMLElement | null; + if (newEl) { + newEl.style.transition = "none"; + newEl.style.transform = "scale(0.85)"; + newEl.style.opacity = "0"; + requestAnimationFrame(() => { + newEl.style.transition = "transform 150ms ease, opacity 150ms ease"; + newEl.style.transform = "scale(1)"; + newEl.style.opacity = "1"; + setTimeout(() => { + newEl.style.transition = ""; + newEl.style.transform = ""; + newEl.style.opacity = ""; + newDropTabIdRef.current = null; + }, 160); + }); + } + }); + } + } + } catch (error) { + console.warn("드래그 드롭 탭 생성 실패:", error); + } + }; + + return ( +
+ {/* 탭 + 오버플로우 버튼이 나란히 배치 */} +
+ {displayVisibleTabs.map((tab) => { + const isActive = tab.id === activeTabId; + const displayName = getTabDisplayName(tab); + + return ( +
handleTabMouseDown(e, tab.id)} + onDragStart={(e) => e.preventDefault()} + onContextMenu={(e) => handleContextMenu(e, tab.id)} + title={displayName} + > + {displayName} + {isActive && ( + + )} + +
+ ); + })} + + {/* 오버플로우 드롭다운 - 마지막 탭 바로 옆 */} + {hasOverflow && ( + + + + + + {displayOverflowTabs.map((tab) => { + const isActive = tab.id === activeTabId; + + return ( + switchTab(tab.id)} + > +
+ {tab.parentMenuName && ( + {tab.parentMenuName} + )} + {tab.screenName || `화면 ${tab.screenId}`} +
+ +
+ ); + })} + + + 모든 탭 닫기 + +
+
+ )} +
+ + {/* 우클릭 컨텍스트 메뉴 */} + {contextMenu && ( +
+ +
+ + + + +
+ +
+ )} +
+ ); +} diff --git a/frontend/components/layout/TabContent.tsx b/frontend/components/layout/TabContent.tsx new file mode 100644 index 00000000..520dd97b --- /dev/null +++ b/frontend/components/layout/TabContent.tsx @@ -0,0 +1,94 @@ +"use client"; + +import React, { Suspense, useRef } from "react"; +import { Loader2, Inbox } from "lucide-react"; +import { useTab, TabItem } from "@/contexts/TabContext"; +import { ScreenViewPageEmbeddable } from "@/app/(main)/screens/[screenId]/page"; + +function TabLoadingFallback() { + return ( +
+
+ +

화면을 불러오는 중...

+
+
+ ); +} + +function EmptyTabState() { + return ( +
+
+
+ +
+

열린 탭이 없습니다

+

+ 왼쪽 메뉴에서 화면을 선택하면 탭으로 열립니다. +

+
+
+ ); +} + +export function TabContent() { + const { tabs, activeTabId, refreshKeys } = useTab(); + // 탭 순서 변경(드래그 리오더링) 시 DOM 재배치를 방지하기 위해 + // 탭이 추가된 순서(id 기준)로 고정 렌더링 + const stableOrderRef = useRef([]); + + // F5 이후 활성 탭만 마운트하고, 비활성 탭은 클릭 시 마운트 (Lazy Mount) + const mountedTabIdsRef = useRef>( + new Set(activeTabId ? [activeTabId] : []) + ); + + // 활성 탭을 마운트 목록에 추가 + if (activeTabId && !mountedTabIdsRef.current.has(activeTabId)) { + mountedTabIdsRef.current.add(activeTabId); + } + + // 새 탭 추가 시 stableOrder에 append, 닫힌 탭은 제거 + const currentIds = new Set(tabs.map(t => t.id)); + const stableIds = new Set(stableOrderRef.current.map(t => t.id)); + + // 닫힌 탭 제거 + stableOrderRef.current = stableOrderRef.current.filter(t => currentIds.has(t.id)); + // 닫힌 탭을 마운트 목록에서도 제거 + for (const id of mountedTabIdsRef.current) { + if (!currentIds.has(id)) mountedTabIdsRef.current.delete(id); + } + // 새로 추가된 탭 append + for (const tab of tabs) { + if (!stableIds.has(tab.id)) { + stableOrderRef.current.push(tab); + } + } + + const stableTabs = stableOrderRef.current; + + if (stableTabs.length === 0) { + return ; + } + + return ( + <> + {stableTabs.map((tab) => ( +
+ {mountedTabIdsRef.current.has(tab.id) ? ( + }> + + + ) : null} +
+ ))} + + ); +} diff --git a/frontend/components/screen/widgets/CategoryWidget.tsx b/frontend/components/screen/widgets/CategoryWidget.tsx index a4e93256..4744fe4b 100644 --- a/frontend/components/screen/widgets/CategoryWidget.tsx +++ b/frontend/components/screen/widgets/CategoryWidget.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useRef, useCallback } from "react"; +import { usePersistedState } from "@/hooks/usePersistedState"; import { CategoryColumnList } from "@/components/table-category/CategoryColumnList"; import { CategoryValueManager } from "@/components/table-category/CategoryValueManager"; import { GripVertical } from "lucide-react"; @@ -48,12 +49,12 @@ export function CategoryWidget({ widgetId, tableName, menuObjid, component, ...p // menuObjid 우선순위: 직접 전달된 값 > props에서 추출한 값 const effectiveMenuObjid = menuObjid || props.menuObjid; - const [selectedColumn, setSelectedColumn] = useState<{ - uniqueKey: string; // 테이블명.컬럼명 형식 + const [selectedColumn, setSelectedColumn] = usePersistedState<{ + uniqueKey: string; columnName: string; columnLabel: string; tableName: string; - } | null>(null); + } | null>('selectedColumn', null); const [leftWidth, setLeftWidth] = useState(15); // 초기값 15% const containerRef = useRef(null); diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 486e76d9..8abf1cfc 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -64,6 +64,7 @@ export function TabsWidget({ } = component; const storageKey = `tabs-${component.id}-selected`; + const sessionStorageKey = `tabs-session-${menuObjid || "g"}-${component.id}`; // 탭 내부 자체 selectedRowsData 상태 관리 (항상 로컬 상태 사용) // 부모에서 빈 배열 []이 전달되어도 로컬 상태를 우선하여 탭 내부 버튼이 즉시 인식 @@ -89,12 +90,21 @@ export function TabsWidget({ [externalOnSelectedRowsChange], ); - // 초기 선택 탭 결정 + // 초기 선택 탭 결정 (sessionStorage 우선 → localStorage → 기본값) const getInitialTab = () => { - if (persistSelection && typeof window !== "undefined") { - const saved = localStorage.getItem(storageKey); - if (saved && tabs.some((t) => t.id === saved)) { - return saved; + if (typeof window !== "undefined") { + try { + const sessionSaved = sessionStorage.getItem(sessionStorageKey); + if (sessionSaved && tabs.some((t) => t.id === sessionSaved)) { + return sessionSaved; + } + } catch { /* 무시 */ } + + if (persistSelection) { + const saved = localStorage.getItem(storageKey); + if (saved && tabs.some((t) => t.id === saved)) { + return saved; + } } } return defaultTab || tabs[0]?.id || ""; @@ -135,22 +145,32 @@ export function TabsWidget({ const [screenLayouts, setScreenLayouts] = useState>({}); const [screenLoadingStates, setScreenLoadingStates] = useState>({}); const [screenErrors, setScreenErrors] = useState>({}); - // 탭별 화면 정보 (screenId, tableName) - 인라인 컴포넌트의 테이블 설정에서 추출 + // 탭별 화면 정보 (screenId, tableName) - screenId 기반 탭과 인라인 컴포넌트 모두 포함 const screenInfoMap = React.useMemo(() => { const map: Record = {}; for (const tab of tabs as ExtendedTabItem[]) { const inlineComponents = tab.components || []; + + // screenId 기반 탭 (별도 화면 로드 방식) + if (tab.screenId != null && inlineComponents.length === 0) { + map[tab.id] = { id: tab.screenId }; + continue; + } + + // 인라인 컴포넌트 탭 if (inlineComponents.length > 0) { - // 인라인 컴포넌트에서 테이블 컴포넌트의 selectedTable 추출 const tableComp = inlineComponents.find( - (c) => c.componentType === "v2-table-list" || c.componentType === "table-list", + (c) => + c.componentType === "v2-table-list" || + c.componentType === "table-list" || + c.componentType === "v2-split-panel-layout", ); const selectedTable = tableComp?.componentConfig?.selectedTable; - if (selectedTable || tab.screenId) { - map[tab.id] = { - id: tab.screenId, - tableName: selectedTable, - }; + if (selectedTable || tab.screenId != null) { + const entry: { id?: number; tableName?: string } = {}; + if (tab.screenId != null) entry.id = tab.screenId; + if (selectedTable) entry.tableName = selectedTable; + map[tab.id] = entry; } } } @@ -193,10 +213,13 @@ export function TabsWidget({ loadScreenLayouts(); }, [visibleTabs, screenLayouts, screenLoadingStates]); - // 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트 + // 선택된 탭 변경 시 storage에 저장 + ActiveTab Context 업데이트 useEffect(() => { - if (persistSelection && typeof window !== "undefined") { - localStorage.setItem(storageKey, selectedTab); + if (typeof window !== "undefined") { + try { sessionStorage.setItem(sessionStorageKey, selectedTab); } catch { /* 무시 */ } + if (persistSelection) { + localStorage.setItem(storageKey, selectedTab); + } } const currentTabInfo = visibleTabs.find((t) => t.id === selectedTab); @@ -207,7 +230,7 @@ export function TabsWidget({ label: currentTabInfo.label, }); } - }, [selectedTab, persistSelection, storageKey, component.id, visibleTabs, setActiveTab]); + }, [selectedTab, persistSelection, storageKey, sessionStorageKey, component.id, visibleTabs, setActiveTab]); // 컴포넌트 언마운트 시 ActiveTab Context에서 제거 useEffect(() => { @@ -412,11 +435,11 @@ export function TabsWidget({ onSelectedRowsChange={handleSelectedRowsChange} parentTabId={tab.id} parentTabsComponentId={component.id} - // 탭에 screenId가 있으면 해당 화면의 tableName/screenId로 오버라이드 + // 탭에 screenId/tableName이 있으면 오버라이드 (undefined로 부모값을 덮어쓰지 않도록 조건부 적용) {...(screenInfoMap[tab.id] ? { - tableName: screenInfoMap[tab.id].tableName, - screenId: screenInfoMap[tab.id].id, + ...(screenInfoMap[tab.id].tableName != null && { tableName: screenInfoMap[tab.id].tableName }), + ...(screenInfoMap[tab.id].id != null && { screenId: screenInfoMap[tab.id].id }), } : {})} /> diff --git a/frontend/components/table-category/CategoryValueManager.tsx b/frontend/components/table-category/CategoryValueManager.tsx index aba04d6a..a8a99181 100644 --- a/frontend/components/table-category/CategoryValueManager.tsx +++ b/frontend/components/table-category/CategoryValueManager.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useEffect } from "react"; +import { usePersistedState } from "@/hooks/usePersistedState"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; @@ -30,6 +31,7 @@ interface CategoryValueManagerProps { columnLabel: string; onValueCountChange?: (count: number) => void; menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) + screenId?: number | string; // 비활성 탭 F5 복원용 캐시 키 } export const CategoryValueManager: React.FC = ({ @@ -38,20 +40,20 @@ export const CategoryValueManager: React.FC = ({ columnLabel, onValueCountChange, menuObjid, + screenId, }) => { const { toast } = useToast(); + + // TSP: 상태 자동 보존 + const [selectedValueIds, setSelectedValueIds] = usePersistedState(`selectedValueIds-${columnName}`, []); + const [searchQuery, setSearchQuery] = usePersistedState(`searchQuery-${columnName}`, ''); + const [showInactive, setShowInactive] = usePersistedState(`showInactive-${columnName}`, false); + const [values, setValues] = useState([]); - const [filteredValues, setFilteredValues] = useState( - [] - ); - const [selectedValueIds, setSelectedValueIds] = useState([]); - const [searchQuery, setSearchQuery] = useState(""); + const [filteredValues, setFilteredValues] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); - const [editingValue, setEditingValue] = useState( - null - ); - const [showInactive, setShowInactive] = useState(false); // 비활성 항목 표시 옵션 (기본: 숨김) + const [editingValue, setEditingValue] = useState(null); // 카테고리 값 로드 useEffect(() => { diff --git a/frontend/components/table-category/CategoryValueManagerTree.tsx b/frontend/components/table-category/CategoryValueManagerTree.tsx index d4da04fc..2aa2619c 100644 --- a/frontend/components/table-category/CategoryValueManagerTree.tsx +++ b/frontend/components/table-category/CategoryValueManagerTree.tsx @@ -6,7 +6,8 @@ * - 체크박스를 통한 다중 선택 및 일괄 삭제 지원 */ -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { usePersistedState } from "@/hooks/usePersistedState"; import { ChevronRight, ChevronDown, @@ -58,6 +59,7 @@ interface CategoryValueManagerTreeProps { columnName: string; columnLabel: string; onValueCountChange?: (count: number) => void; + screenId?: number | string; // 비활성 탭 F5 복원용 캐시 키 } // 트리 노드 컴포넌트 @@ -271,15 +273,24 @@ export const CategoryValueManagerTree: React.FC = columnName, columnLabel, onValueCountChange, + screenId, }) => { + // TSP: 상태 자동 보존 + const [checkedIds, setCheckedIds] = usePersistedState>(`checkedIds-${columnName}`, new Set()); + const [expandedNodes, setExpandedNodes] = usePersistedState>(`expandedNodes-${columnName}`, new Set()); + const [searchQuery, setSearchQuery] = usePersistedState(`searchQuery-${columnName}`, ''); + const [showInactive, setShowInactive] = usePersistedState(`showInactive-${columnName}`, false); + const [focusedValueId, setFocusedValueId] = usePersistedState(`focusedValueId-${columnName}`, null); + const pendingFocusIdRef = useRef(focusedValueId); + // 상태 const [tree, setTree] = useState([]); const [loading, setLoading] = useState(false); - const [expandedNodes, setExpandedNodes] = useState>(new Set()); - const [selectedValue, setSelectedValue] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [showInactive, setShowInactive] = useState(false); - const [checkedIds, setCheckedIds] = useState>(new Set()); + const [selectedValue, setSelectedValueRaw] = useState(null); + const setSelectedValue = useCallback((val: CategoryValue | null) => { + setSelectedValueRaw(val); + setFocusedValueId(val?.valueId ?? null); + }, [setFocusedValueId]); // 모달 상태 const [isAddModalOpen, setIsAddModalOpen] = useState(false); @@ -386,9 +397,26 @@ export const CategoryValueManagerTree: React.FC = setTree(filteredTree); + // 캐시된 선택 항목 복원 + if (pendingFocusIdRef.current !== null) { + const node = findNodeById(filteredTree, pendingFocusIdRef.current); + if (node) { + setSelectedValue(node); + // 부모 노드들을 펼쳐서 선택 항목이 보이도록 + if (node.path) { + const pathIds = node.path.split("/").filter(Boolean).map(Number); + setExpandedNodes((prev) => new Set([...prev, ...pathIds])); + } + } + pendingFocusIdRef.current = null; + } + // 기존 펼침 상태 유지하지 않으면 모두 접기 (대분류만 표시) if (!keepExpanded) { - setExpandedNodes(new Set()); + setExpandedNodes((prev) => { + // 캐시 복원으로 펼쳐진 노드가 있으면 유지 + return prev.size > 0 ? prev : new Set(); + }); } // 전체 개수 업데이트 @@ -401,7 +429,7 @@ export const CategoryValueManagerTree: React.FC = setLoading(false); } }, - [tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange], + [tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange, findNodeById], ); useEffect(() => { diff --git a/frontend/contexts/TabContext.tsx b/frontend/contexts/TabContext.tsx new file mode 100644 index 00000000..7f17048e --- /dev/null +++ b/frontend/contexts/TabContext.tsx @@ -0,0 +1,289 @@ +"use client"; + +import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react"; + +export interface TabItem { + id: string; + screenId: number; + menuObjid?: number; + screenName: string; + menuName?: string; + parentMenuName?: string; + isAdminMode?: boolean; +} + +interface TabContextType { + tabs: TabItem[]; + activeTabId: string | null; + refreshKeys: Record; + openTab: (tab: Omit, insertIndex?: number) => void; + closeTab: (tabId: string) => void; + closeOtherTabs: (tabId: string) => void; + closeAllTabs: () => void; + closeTabsToTheLeft: (tabId: string) => void; + closeTabsToTheRight: (tabId: string) => void; + switchTab: (tabId: string) => void; + getActiveTab: () => TabItem | undefined; + updateTabOrder: (newOrder: string[]) => void; + refreshTab: (tabId: string) => void; +} + +const TAB_STORAGE_KEY = "erp_open_tabs"; +const ACTIVE_TAB_STORAGE_KEY = "erp_active_tab"; + +const TabContext = createContext(undefined); + +function generateTabId(screenId: number, menuObjid?: number): string { + return `tab-${screenId}-${menuObjid || "default"}`; +} + +function clearTabStateCache(tab: TabItem) { + try { + // 페이지 레벨 캐시 삭제 + sessionStorage.removeItem(`tab-cache-${tab.screenId}-${tab.menuObjid || "default"}`); + + // 페이지 스크롤 위치 캐시 삭제 + sessionStorage.removeItem(`page-scroll-${tab.screenId}-${tab.menuObjid || "default"}`); + + // 하위 컴포넌트 캐시 삭제 (category-mgr, category-widget) + if (tab.menuObjid) { + sessionStorage.removeItem(`category-mgr-${tab.menuObjid}`); + sessionStorage.removeItem(`category-widget-${tab.menuObjid}`); + } + sessionStorage.removeItem(`category-mgr-${tab.screenId}`); + sessionStorage.removeItem(`category-widget-${tab.screenId}`); + + // TSP: 하위 컴포넌트 캐시 일괄 삭제 (레거시 + 새 usePersistedState 훅) + const prefixes = [ + `tsp-${tab.screenId}-`, + `tabs-session-${tab.menuObjid || "g"}-`, + `table-state-${tab.screenId}-`, + `split-sel-${tab.screenId}-`, + `catval-sel-${tab.screenId}-`, + `category-mgr-${tab.menuObjid || tab.screenId}-`, + `bom-tree-${tab.screenId}-`, + ]; + const keysToRemove: string[] = []; + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if (key && prefixes.some(p => key.startsWith(p))) { + keysToRemove.push(key); + } + } + keysToRemove.forEach(k => sessionStorage.removeItem(k)); + } catch { /* 무시 */ } +} + +function loadTabsFromStorage(): { tabs: TabItem[]; activeTabId: string | null } { + if (typeof window === "undefined") return { tabs: [], activeTabId: null }; + + try { + const tabsJson = sessionStorage.getItem(TAB_STORAGE_KEY); + const activeTabId = sessionStorage.getItem(ACTIVE_TAB_STORAGE_KEY); + const tabs = tabsJson ? JSON.parse(tabsJson) : []; + return { tabs, activeTabId }; + } catch { + return { tabs: [], activeTabId: null }; + } +} + +function saveTabsToStorage(tabs: TabItem[], activeTabId: string | null) { + if (typeof window === "undefined") return; + + try { + sessionStorage.setItem(TAB_STORAGE_KEY, JSON.stringify(tabs)); + if (activeTabId) { + sessionStorage.setItem(ACTIVE_TAB_STORAGE_KEY, activeTabId); + } else { + sessionStorage.removeItem(ACTIVE_TAB_STORAGE_KEY); + } + } catch { + // sessionStorage 용량 초과 등 무시 + } +} + +export function TabProvider({ children }: { children: ReactNode }) { + const [tabs, setTabs] = useState([]); + const [activeTabId, setActiveTabId] = useState(null); + const [initialized, setInitialized] = useState(false); + const [refreshKeys, setRefreshKeys] = useState>({}); + + // sessionStorage에서 복원 + useEffect(() => { + const { tabs: savedTabs, activeTabId: savedActiveTabId } = loadTabsFromStorage(); + if (savedTabs.length > 0) { + setTabs(savedTabs); + const activeId = savedActiveTabId && savedTabs.some(t => t.id === savedActiveTabId) ? savedActiveTabId : savedTabs[0].id; + setActiveTabId(activeId); + + // 활성 탭의 상태 캐시 삭제 (F5 시 활성 탭은 정상 새로고침) + const activeTab = savedTabs.find(t => t.id === activeId); + if (activeTab) { + clearTabStateCache(activeTab); + } + } + setInitialized(true); + }, []); + + // 상태 변경 시 sessionStorage에 저장 + useEffect(() => { + if (!initialized) return; + saveTabsToStorage(tabs, activeTabId); + }, [tabs, activeTabId, initialized]); + + const openTab = useCallback((tabData: Omit, insertIndex?: number) => { + const tabId = generateTabId(tabData.screenId, tabData.menuObjid); + + setTabs((prevTabs) => { + const existingTab = prevTabs.find(t => t.screenId === tabData.screenId); + if (existingTab) { + setActiveTabId(existingTab.id); + return prevTabs; + } + + const newTab: TabItem = { ...tabData, id: tabId }; + setActiveTabId(tabId); + + if (insertIndex !== undefined && insertIndex >= 0 && insertIndex <= prevTabs.length) { + const newTabs = [...prevTabs]; + newTabs.splice(insertIndex, 0, newTab); + return newTabs; + } + return [...prevTabs, newTab]; + }); + }, []); + + const closeTab = useCallback((tabId: string) => { + setTabs((prevTabs) => { + const tabIndex = prevTabs.findIndex(t => t.id === tabId); + if (tabIndex === -1) return prevTabs; + + clearTabStateCache(prevTabs[tabIndex]); + + const newTabs = prevTabs.filter(t => t.id !== tabId); + + setActiveTabId((prevActiveId) => { + if (prevActiveId === tabId) { + if (newTabs.length === 0) return null; + const nextIndex = Math.min(tabIndex, newTabs.length - 1); + return newTabs[nextIndex].id; + } + return prevActiveId; + }); + + return newTabs; + }); + }, []); + + const closeOtherTabs = useCallback((tabId: string) => { + setTabs((prevTabs) => { + const keepTab = prevTabs.find(t => t.id === tabId); + if (!keepTab) return prevTabs; + prevTabs.filter(t => t.id !== tabId).forEach(clearTabStateCache); + setActiveTabId(tabId); + return [keepTab]; + }); + }, []); + + const closeAllTabs = useCallback(() => { + setTabs((prevTabs) => { + prevTabs.forEach(clearTabStateCache); + return []; + }); + setActiveTabId(null); + }, []); + + const closeTabsToTheLeft = useCallback((tabId: string) => { + setTabs((prevTabs) => { + const tabIndex = prevTabs.findIndex(t => t.id === tabId); + if (tabIndex <= 0) return prevTabs; + + prevTabs.slice(0, tabIndex).forEach(clearTabStateCache); + const newTabs = prevTabs.slice(tabIndex); + + setActiveTabId((prevActiveId) => { + if (prevActiveId && !newTabs.find(t => t.id === prevActiveId)) { + return tabId; + } + return prevActiveId; + }); + + return newTabs; + }); + }, []); + + const closeTabsToTheRight = useCallback((tabId: string) => { + setTabs((prevTabs) => { + const tabIndex = prevTabs.findIndex(t => t.id === tabId); + if (tabIndex === -1) return prevTabs; + + prevTabs.slice(tabIndex + 1).forEach(clearTabStateCache); + const newTabs = prevTabs.slice(0, tabIndex + 1); + + setActiveTabId((prevActiveId) => { + if (prevActiveId && !newTabs.find(t => t.id === prevActiveId)) { + return tabId; + } + return prevActiveId; + }); + + return newTabs; + }); + }, []); + + const switchTab = useCallback((tabId: string) => { + setActiveTabId(tabId); + }, []); + + const getActiveTab = useCallback(() => { + return tabs.find(t => t.id === activeTabId); + }, [tabs, activeTabId]); + + const refreshTab = useCallback((tabId: string) => { + const tab = tabs.find(t => t.id === tabId); + if (tab) clearTabStateCache(tab); + setRefreshKeys(prev => ({ ...prev, [tabId]: (prev[tabId] || 0) + 1 })); + }, [tabs]); + + const updateTabOrder = useCallback((newOrder: string[]) => { + setTabs((prevTabs) => { + const tabMap = new Map(prevTabs.map(t => [t.id, t])); + const reordered = newOrder + .map(id => tabMap.get(id)) + .filter((t): t is TabItem => t !== undefined); + // 드래그에 포함되지 않은 탭(오버플로우 등)은 뒤에 유지 + const remaining = prevTabs.filter(t => !newOrder.includes(t.id)); + return [...reordered, ...remaining]; + }); + }, []); + + return ( + + {children} + + ); +} + +export function useTab() { + const context = useContext(TabContext); + if (context === undefined) { + throw new Error("useTab must be used within a TabProvider"); + } + return context; +} diff --git a/frontend/hooks/usePersistedState.tsx b/frontend/hooks/usePersistedState.tsx new file mode 100644 index 00000000..be210842 --- /dev/null +++ b/frontend/hooks/usePersistedState.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { + useState, + useEffect, + useRef, + createContext, + useContext, + useMemo, +} from "react"; +import type { Dispatch, SetStateAction, ReactNode } from "react"; + +// ─── TSP (Tab State Persistence) 중앙 관리 훅 ─── +// 모든 컴포넌트의 UI 상태를 sessionStorage에 자동 보존/복원 +// useState 대신 usePersistedState를 사용하면 탭 상태 보존이 자동 적용됨 + +const TSP_PREFIX = "tsp-"; + +// ─── Context ─── + +interface TSPContextValue { + screenId?: number; + componentId?: string; +} + +const TSPContext = createContext({}); + +export function TSPProvider({ + screenId, + componentId, + children, +}: { + screenId?: number; + componentId?: string; + children: ReactNode; +}) { + const value = useMemo( + () => ({ screenId, componentId }), + [screenId, componentId], + ); + return {children}; +} + +// ─── 직렬화 (Set, Map, Date 지원) ─── + +function serialize(value: unknown): string { + return JSON.stringify(value, (_key, val) => { + if (val instanceof Set) return { __tsp: "Set", d: Array.from(val) }; + if (val instanceof Map) return { __tsp: "Map", d: Array.from(val.entries()) }; + if (val instanceof Date) return { __tsp: "Date", d: val.toISOString() }; + return val; + }); +} + +function deserialize(raw: string): unknown { + return JSON.parse(raw, (_key, val) => { + if (val && typeof val === "object" && val.__tsp === "Set") return new Set(val.d); + if (val && typeof val === "object" && val.__tsp === "Map") return new Map(val.d); + if (val && typeof val === "object" && val.__tsp === "Date") return new Date(val.d); + return val; + }); +} + +// ─── 메인 훅 ─── + +interface PersistedStateOptions { + /** sessionStorage 저장 지연 시간 (기본 300ms) */ + debounce?: number; +} + +/** + * useState와 동일한 인터페이스로 탭 상태를 자동 보존하는 훅 + * + * @param key - 상태 식별 키 (같은 컴포넌트 내에서 고유해야 함) + * @param defaultValue - 캐시가 없을 때 사용할 기본값 + * @param options - debounce 등 옵션 + * + * @example + * const [selectedRow, setSelectedRow] = usePersistedState('selectedRow', null); + * const [expanded, setExpanded] = usePersistedState('expanded', new Set()); + * const [scroll, setScroll] = usePersistedState('scrollTop', 0, { debounce: 100 }); + */ +export function usePersistedState( + key: string, + defaultValue: T, + options?: PersistedStateOptions, +): [T, Dispatch>] { + const { screenId, componentId } = useContext(TSPContext); + const debounceMs = options?.debounce ?? 300; + + // tsp-{screenId}-{componentId}-{key} + const cacheKey = + screenId != null && componentId + ? `${TSP_PREFIX}${screenId}-${componentId}-${key}` + : null; + + // 안정적인 참조 (cacheKey가 렌더 중 변하지 않도록) + const cacheKeyRef = useRef(cacheKey); + cacheKeyRef.current = cacheKey; + + const [state, setState] = useState(() => { + if (!cacheKey || typeof window === "undefined") return defaultValue; + try { + const raw = sessionStorage.getItem(cacheKey); + if (raw !== null) return deserialize(raw) as T; + } catch { + /* 파싱 실패 시 기본값 사용 */ + } + return defaultValue; + }); + + // debounce 저장 + const timerRef = useRef | null>(null); + const isFirstRender = useRef(true); + const latestStateRef = useRef(state); + latestStateRef.current = state; + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + + const ck = cacheKeyRef.current; + if (!ck) return; + + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + try { + sessionStorage.setItem(ck, serialize(state)); + } catch { + /* 용량 초과 무시 */ + } + timerRef.current = null; + }, debounceMs); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, [state, debounceMs]); + + // unmount 시 미저장 상태 flush (탭 전환 중 데이터 유실 방지) + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + const ck = cacheKeyRef.current; + if (ck) { + try { sessionStorage.setItem(ck, serialize(latestStateRef.current)); } catch {} + } + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return [state, setState]; +} + +// ─── 캐시 정리 유틸리티 ─── + +/** + * 특정 화면(또는 화면+컴포넌트)의 TSP 캐시를 일괄 삭제 + * + * @param screenId - 화면 ID + * @param componentId - (선택) 특정 컴포넌트만 삭제할 때 + */ +export function clearTSPCache(screenId: number, componentId?: string) { + const prefix = componentId + ? `${TSP_PREFIX}${screenId}-${componentId}-` + : `${TSP_PREFIX}${screenId}-`; + + const toRemove: string[] = []; + for (let i = 0; i < sessionStorage.length; i++) { + const k = sessionStorage.key(i); + if (k?.startsWith(prefix)) toRemove.push(k); + } + toRemove.forEach((k) => sessionStorage.removeItem(k)); +} + +/** + * 모든 TSP 캐시 삭제 (로그아웃 등) + */ +export function clearAllTSPCache() { + const toRemove: string[] = []; + for (let i = 0; i < sessionStorage.length; i++) { + const k = sessionStorage.key(i); + if (k?.startsWith(TSP_PREFIX)) toRemove.push(k); + } + toRemove.forEach((k) => sessionStorage.removeItem(k)); +} diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index ff0285a2..a76798fa 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -8,6 +8,8 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter"; // 통합 폼 시스템 import import { useV2FormOptional } from "@/components/v2/V2FormContext"; +// TSP (Tab State Persistence) - 컴포넌트별 상태 보존 +import { TSPProvider } from "@/hooks/usePersistedState"; // 컴포넌트 렌더러 인터페이스 export interface ComponentRenderer { @@ -669,14 +671,18 @@ export const DynamicComponentRenderer: React.FC = NewComponentRenderer.prototype.render; if (isClass) { - // 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속) const rendererInstance = new NewComponentRenderer(rendererProps); - return rendererInstance.render(); + return ( + + {rendererInstance.render()} + + ); } else { - // 함수형 컴포넌트 - // refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제 - - return ; + return ( + + + + ); } } } catch (error) { @@ -718,22 +724,21 @@ export const DynamicComponentRenderer: React.FC = // 레거시 시스템에서도 DOM 안전한 props만 전달 const safeLegacyProps = filterDOMProps(props); - return renderer({ + const legacyRendered = renderer({ component, isSelected, onClick, onDragStart, onDragEnd, children, - // React 전용 props들은 명시적으로 전달 (레거시 컴포넌트가 필요한 경우) isInteractive: props.isInteractive, formData: props.formData, onFormDataChange: props.onFormDataChange, screenId: props.screenId, tableName: props.tableName, - userId: props.userId, // 🆕 사용자 ID - userName: props.userName, // 🆕 사용자 이름 - companyCode: props.companyCode, // 🆕 회사 코드 + userId: props.userId, + userName: props.userName, + companyCode: props.companyCode, onRefresh: props.onRefresh, onClose: props.onClose, mode: props.mode, @@ -743,18 +748,21 @@ export const DynamicComponentRenderer: React.FC = onZoneClick: props.onZoneClick, onZoneComponentDrop: props.onZoneComponentDrop, allComponents: props.allComponents, - // 테이블 선택된 행 정보 전달 selectedRows: props.selectedRows, selectedRowsData: props.selectedRowsData, onSelectedRowsChange: props.onSelectedRowsChange, - // 플로우 선택된 데이터 정보 전달 flowSelectedData: props.flowSelectedData, flowSelectedStepId: props.flowSelectedStepId, onFlowSelectedDataChange: props.onFlowSelectedDataChange, refreshKey: props.refreshKey, - // DOM 안전한 props들 ...safeLegacyProps, }); + + return ( + + {legacyRendered} + + ); } catch (error) { console.error(`❌ 컴포넌트 렌더링 실패 (${componentType}):`, error); diff --git a/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsComponent.tsx b/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsComponent.tsx index 66c13255..d5cd8e91 100644 --- a/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsComponent.tsx +++ b/frontend/lib/registry/components/related-data-buttons/RelatedDataButtonsComponent.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useEffect, useCallback, useRef } from "react"; +import { usePersistedState } from "@/hooks/usePersistedState"; import { Button } from "@/components/ui/button"; import { Plus, Star, Loader2, ExternalLink } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -38,7 +39,7 @@ export const RelatedDataButtonsComponent: React.FC { const [buttons, setButtons] = useState([]); - const [selectedId, setSelectedId] = useState(null); + const [selectedId, setSelectedId] = usePersistedState('selectedId', null); const [selectedItem, setSelectedItem] = useState(null); const [loading, setLoading] = useState(false); const [masterData, setMasterData] = useState | null>(null); @@ -160,10 +161,11 @@ export const RelatedDataButtonsComponent: React.FC 0) { + const cachedItem = selectedId ? items.find(item => String(item.id) === String(selectedId)) : undefined; const defaultItem = items.find(item => item.isDefault); - const targetItem = defaultItem || items[0]; + const targetItem = cachedItem || defaultItem || items[0]; setSelectedId(targetItem.id); setSelectedItem(targetItem); emitSelection(targetItem); diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index e94b6cce..f3090fc8 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { usePersistedState } from "@/hooks/usePersistedState"; import { ComponentRendererProps } from "../../types"; import { SplitPanelLayoutConfig } from "./types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -166,7 +167,7 @@ export const SplitPanelLayoutComponent: React.FC // 데이터 상태 const [leftData, setLeftData] = useState([]); const [rightData, setRightData] = useState(null); // 조인 모드는 배열, 상세 모드는 객체 - const [selectedLeftItem, setSelectedLeftItem] = useState(null); + const [selectedLeftItem, setSelectedLeftItem] = usePersistedState('selectedLeftItem', null, { debounce: 0 }); const [expandedRightItems, setExpandedRightItems] = useState>(new Set()); // 확장된 우측 아이템 const [leftSearchQuery, setLeftSearchQuery] = useState(""); const [rightSearchQuery, setRightSearchQuery] = useState(""); @@ -2286,6 +2287,19 @@ export const SplitPanelLayoutComponent: React.FC // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDesignMode, componentConfig.autoLoad]); + // TSP: F5 새로고침 후 캐시된 좌측 선택 항목 복원 + const selectionRestoredRef = useRef(false); + useEffect(() => { + if (selectionRestoredRef.current || isDesignMode) return; + if (leftData.length === 0) return; + if (selectedLeftItem) { + selectionRestoredRef.current = true; + loadRightData(selectedLeftItem); + return; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [leftData, isDesignMode]); + // 🔄 필터 변경 시 데이터 다시 로드 useEffect(() => { if (!isDesignMode && componentConfig.autoLoad !== false) { diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index a06c046f..52918ed5 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { ComponentRendererProps } from "@/types/component"; import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, ActionButtonConfig, JoinTableConfig, GroupingConfig, ColumnDisplayConfig, TabConfig } from "./types"; import { Badge } from "@/components/ui/badge"; @@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { apiClient } from "@/lib/api/client"; +import { usePersistedState } from "@/hooks/usePersistedState"; export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps { // 추가 props @@ -66,12 +67,19 @@ export const SplitPanelLayout2Component: React.FC([]); const [rightData, setRightData] = useState([]); - const [selectedLeftItem, setSelectedLeftItem] = useState(null); - const [leftSearchTerm, setLeftSearchTerm] = useState(""); - const [rightSearchTerm, setRightSearchTerm] = useState(""); + + // TSP: 탭 상태 보존 대상 (usePersistedState) + const [selectedLeftItem, setSelectedLeftItem] = usePersistedState('selectedLeftItem', null, { debounce: 0 }); + const initialCachedLeftItemRef = useRef(selectedLeftItem); + const [leftSearchTerm, setLeftSearchTerm] = usePersistedState('leftSearchTerm', ''); + const [rightSearchTerm, setRightSearchTerm] = usePersistedState('rightSearchTerm', ''); + const [expandedItems, setExpandedItems] = usePersistedState>('expandedItems', new Set()); + const [selectedRightItems, setSelectedRightItems] = usePersistedState>('selectedRightItems', new Set()); + const [leftActiveTab, setLeftActiveTab] = usePersistedState('leftActiveTab', null); + const [rightActiveTab, setRightActiveTab] = usePersistedState('rightActiveTab', null); + const [leftLoading, setLeftLoading] = useState(false); const [rightLoading, setRightLoading] = useState(false); - const [expandedItems, setExpandedItems] = useState>(new Set()); const [splitPosition, setSplitPosition] = useState(config.splitRatio || 30); const [isResizing, setIsResizing] = useState(false); @@ -79,19 +87,12 @@ export const SplitPanelLayout2Component: React.FC>({}); const [rightColumnLabels, setRightColumnLabels] = useState>({}); - // 우측 패널 선택 상태 (체크박스용) - const [selectedRightItems, setSelectedRightItems] = useState>(new Set()); - // 삭제 확인 다이얼로그 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); const [isBulkDelete, setIsBulkDelete] = useState(false); const [deleteTargetPanel, setDeleteTargetPanel] = useState<"left" | "right">("right"); - // 탭 상태 (좌측/우측 각각) - const [leftActiveTab, setLeftActiveTab] = useState(null); - const [rightActiveTab, setRightActiveTab] = useState(null); - // 프론트엔드 그룹핑 함수 const groupData = useCallback( (data: Record[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record[] => { @@ -1241,6 +1242,20 @@ export const SplitPanelLayout2Component: React.FC { + if (selectionRestoredRef.current || isDesignMode) return; + if (leftData.length === 0) return; + + if (selectedLeftItem) { + selectionRestoredRef.current = true; + loadRightData(selectedLeftItem); + return; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [leftData, isDesignMode]); + // 컴포넌트 언마운트 시 DataProvider 해제 useEffect(() => { return () => { diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index 536c1ddc..cd4f0f92 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { ChevronRight, ChevronDown, Package, Layers, Box, AlertCircle } from "lucide-react"; import { cn } from "@/lib/utils"; import { entityJoinApi } from "@/lib/api/entityJoin"; +import { usePersistedState } from "@/hooks/usePersistedState"; /** * BOM 트리 노드 데이터 @@ -66,11 +67,13 @@ export function BomTreeComponent({ selectedRowsData, ...props }: BomTreeComponentProps) { + // TSP: usePersistedState 훅으로 자동 보존 + const [expandedNodes, setExpandedNodes] = usePersistedState>("expandedNodes", new Set()); + const [selectedNodeId, setSelectedNodeId] = usePersistedState("selectedNodeId", null); + const [headerInfo, setHeaderInfo] = useState(null); const [treeData, setTreeData] = useState([]); - const [expandedNodes, setExpandedNodes] = useState>(new Set()); const [loading, setLoading] = useState(false); - const [selectedNodeId, setSelectedNodeId] = useState(null); const config = component?.componentConfig || {}; @@ -93,6 +96,14 @@ export function BomTreeComponent({ return null; }, [formData, selectedRowsData]); + // ref로 현재 상태 참조 (useCallback 의존성 순환 방지) + const expandedNodesRef = useRef(expandedNodes); + expandedNodesRef.current = expandedNodes; + const selectedNodeIdRef = useRef(selectedNodeId); + selectedNodeIdRef.current = selectedNodeId; + + const cacheRestoredRef = useRef(false); + // BOM 디테일 데이터 로드 const loadBomDetails = useCallback(async (bomId: string) => { if (!bomId) return; @@ -110,8 +121,30 @@ export function BomTreeComponent({ const rows = result.data || []; const tree = buildTree(rows); setTreeData(tree); - const firstLevelIds = new Set(tree.map((n: BomTreeNode) => n.id)); - setExpandedNodes(firstLevelIds); + + const currentExpanded = expandedNodesRef.current; + const currentSelectedId = selectedNodeIdRef.current; + + if (!cacheRestoredRef.current && currentExpanded.size > 0) { + cacheRestoredRef.current = true; + if (currentSelectedId) { + const parentMap = new Map(); + rows.forEach((r: any) => { + if (r.parent_detail_id) parentMap.set(String(r.id), String(r.parent_detail_id)); + }); + const merged = new Set(currentExpanded); + let cur: string | undefined = currentSelectedId; + while (cur && parentMap.has(cur)) { + cur = parentMap.get(cur); + if (cur) merged.add(cur); + } + setExpandedNodes(merged); + } + } else { + cacheRestoredRef.current = true; + const firstLevelIds = new Set(tree.map((n: BomTreeNode) => n.id)); + setExpandedNodes(firstLevelIds); + } } catch (error) { console.error("[BomTree] 데이터 로드 실패:", error); } finally { diff --git a/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx index 615ea61d..8465a9df 100644 --- a/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx +++ b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx @@ -7,6 +7,7 @@ */ import React, { useState, useRef, useCallback, useEffect } from "react"; +import { usePersistedState } from "@/hooks/usePersistedState"; import { CategoryColumnList } from "@/components/table-category/CategoryColumnList"; import { CategoryValueManager } from "@/components/table-category/CategoryValueManager"; import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree"; @@ -46,6 +47,9 @@ export function V2CategoryManagerComponent({ const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined; const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid; + // screenId (비활성 탭 F5 복원용 캐시 키로 사용) + const effectiveScreenId = typeof props.screenId === "number" || typeof props.screenId === "string" ? props.screenId : undefined; + // 디버그 로그 useEffect(() => { console.log("🔍 V2CategoryManagerComponent props:", { @@ -58,16 +62,16 @@ export function V2CategoryManagerComponent({ }); }, [tableName, menuObjid, selectedScreen, effectiveTableName, effectiveMenuObjid, config]); - // 선택된 컬럼 상태 - const [selectedColumn, setSelectedColumn] = useState<{ + // TSP: 선택된 컬럼 상태 (자동 보존) + const [selectedColumn, setSelectedColumn] = usePersistedState<{ uniqueKey: string; columnName: string; columnLabel: string; tableName: string; - } | null>(null); + } | null>('selectedColumn', null); - // 뷰 모드 상태 - const [viewMode, setViewMode] = useState(config.viewMode); + // TSP: 뷰 모드 상태 (자동 보존) + const [viewMode, setViewMode] = usePersistedState('viewMode', config.viewMode); // 좌측 패널 너비 상태 const [leftWidth, setLeftWidth] = useState(config.leftPanelWidth); @@ -112,7 +116,7 @@ export function V2CategoryManagerComponent({ const handleColumnSelect = useCallback((uniqueKey: string, columnLabel: string, tableName: string) => { const columnName = uniqueKey.split(".")[1]; setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName }); - }, []); + }, [setSelectedColumn]); return (
@@ -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
)} - + {/* 좌측 데이터 목록/테이블/커스텀 */} - {console.log("🔍 [SplitPanel] 왼쪽 패널 displayMode:", componentConfig.leftPanel?.displayMode, "isDesignMode:", isDesignMode)} {componentConfig.leftPanel?.displayMode === "custom" ? ( // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
}} // 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함 onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => { - console.log("🔍 [SplitPanel-Left] onSelectTabComponent 호출:", { tabId, compId, tabComp, parentSplitPanelId: component.id }); // 탭 내 컴포넌트 선택 상태 업데이트 setNestedTabSelectedCompId(compId); // 부모 분할 패널 정보와 함께 전역 이벤트 발생 @@ -3171,9 +3284,14 @@ export const SplitPanelLayoutComponent: React.FC onFormDataChange={(data: any) => { // 커스텀 모드: 좌측 카드/테이블 선택 시 데이터 캡처 if (data?.selectedRowsData && data.selectedRowsData.length > 0) { + userInteractedLeftRef.current = true; setCustomLeftSelectedData(data.selectedRowsData[0]); setSelectedLeftItem(data.selectedRowsData[0]); } else if (data?.selectedRowsData && data.selectedRowsData.length === 0) { + // 사용자가 아직 상호작용하지 않았고 캐시값이 있으면 초기화 시 리셋 방지 + if (!userInteractedLeftRef.current && initialCachedLeftItemRef.current) { + return; + } setCustomLeftSelectedData({}); setSelectedLeftItem(null); } @@ -3743,7 +3861,7 @@ export const SplitPanelLayoutComponent: React.FC
)} - + {/* 추가 탭 컨텐츠 */} {activeTabIndex > 0 ? ( (() => { @@ -4147,7 +4265,6 @@ export const SplitPanelLayoutComponent: React.FC }} // 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함 onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => { - console.log("🔍 [SplitPanel-Right] onSelectTabComponent 호출:", { tabId, compId, tabComp, parentSplitPanelId: component.id }); // 탭 내 컴포넌트 선택 상태 업데이트 setNestedTabSelectedCompId(compId); // 부모 분할 패널 정보와 함께 전역 이벤트 발생 diff --git a/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts b/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts index d9f40aca..279baded 100644 --- a/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts +++ b/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useMemo, useEffect } from "react"; +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { TableGroupedConfig, GroupState, @@ -8,6 +8,7 @@ import { UseGroupedDataResult, } from "../types"; import { apiClient } from "@/lib/api/client"; +import { usePersistedState } from "@/hooks/usePersistedState"; /** * 그룹 요약 데이터 계산 @@ -105,22 +106,17 @@ function formatGroupLabel( export function useGroupedData( config: TableGroupedConfig, externalData?: any[], - searchFilters?: Record + searchFilters?: Record, ): UseGroupedDataResult { // 원본 데이터 const [rawData, setRawData] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - // 그룹 펼침 상태 관리 - const [expandedGroups, setExpandedGroups] = useState>(new Set()); - // 사용자가 수동으로 펼침/접기를 조작했는지 여부 - const [isManuallyControlled, setIsManuallyControlled] = useState(false); - - // 선택 상태 관리 - const [selectedItemIds, setSelectedItemIds] = useState>( - new Set() - ); + // TSP: 그룹 펼침 / 선택 상태 (자동 보존) + const [expandedGroups, setExpandedGroups] = usePersistedState>('expandedGroups', new Set()); + const [isManuallyControlled, setIsManuallyControlled] = usePersistedState('isManuallyControlled', false); + const [selectedItemIds, setSelectedItemIds] = usePersistedState>('selectedItemIds', new Set()); // 테이블명 결정 const tableName = config.useCustomTable diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 59cb47fa..8f393982 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import React, { useState, useEffect, useMemo, useCallback, useRef, useContext } from "react"; +import { usePersistedState } from "@/hooks/usePersistedState"; import { TableListConfig, ColumnConfig } from "./types"; import { WebType } from "@/types/common"; import { tableTypeApi } from "@/lib/api/screen"; @@ -435,7 +436,6 @@ export const TableListComponent: React.FC = ({ const newSearchValues: Record = {}; filters.forEach((filter) => { if (filter.value) { - // operator 정보도 함께 전달 (백엔드에서 equals/contains 구분) newSearchValues[filter.columnName] = { value: filter.value, operator: filter.operator || "contains", @@ -443,10 +443,13 @@ export const TableListComponent: React.FC = ({ } }); - // filters → searchValues 변환 완료 - setSearchValues(newSearchValues); - setCurrentPage(1); // 필터 변경 시 첫 페이지로 + + const filtersKey = JSON.stringify(filters); + if (prevFiltersKeyRef.current !== null && prevFiltersKeyRef.current !== filtersKey) { + setCurrentPage(1); + } + prevFiltersKeyRef.current = filtersKey; }, [filters]); // grouping이 변경되면 groupByColumns 업데이트 @@ -640,13 +643,15 @@ export const TableListComponent: React.FC = ({ return result; }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]); - const [currentPage, setCurrentPage] = useState(1); + // TSP: 테이블 상태 자동 보존 + const [currentPage, setCurrentPage] = usePersistedState('currentPage', 1); + const prevFiltersKeyRef = useRef(null); const [totalPages, setTotalPages] = useState(0); const [totalItems, setTotalItems] = useState(0); - const [searchTerm, setSearchTerm] = useState(""); - const [sortColumn, setSortColumn] = useState(null); - const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); - const hasInitializedSort = useRef(false); + const [searchTerm, setSearchTerm] = usePersistedState('searchTerm', ''); + const [sortColumn, setSortColumn] = usePersistedState('sortColumn', null); + const [sortDirection, setSortDirection] = usePersistedState<"asc" | "desc">('sortDirection', "asc"); + const hasInitializedSort = useRef(sortColumn != null); const [columnLabels, setColumnLabels] = useState>({}); const [tableLabel, setTableLabel] = useState(""); const [localPageSize, setLocalPageSize] = useState(tableConfig.pagination?.pageSize || 20); @@ -662,8 +667,8 @@ export const TableListComponent: React.FC = ({ Record> >({}); const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용 - const [searchValues, setSearchValues] = useState>({}); - const [selectedRows, setSelectedRows] = useState>(new Set()); + const [searchValues, setSearchValues] = usePersistedState>('searchValues', {}); + const [selectedRows, setSelectedRows] = usePersistedState>('selectedRows', new Set()); const [columnWidths, setColumnWidths] = useState>({}); const [refreshTrigger, setRefreshTrigger] = useState(0); // columnOrder는 상단에서 정의됨 (visibleColumns보다 먼저 필요) @@ -676,8 +681,30 @@ export const TableListComponent: React.FC = ({ const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); - // 🆕 키보드 네비게이션 관련 상태 - const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); + // TSP: 스크롤 위치 (자동 보존) + const tableScrollRef = useRef({ top: 0, left: 0 }); + const tableScrollRestoredRef = useRef(false); + const [_scrollPos, _setScrollPos] = usePersistedState<{ top: number; left: number }>('scrollPos', { top: 0, left: 0 }); + const scrollPosRef = useRef(_scrollPos); + + // 스크롤 위치 복원 (데이터 로드 후) + useEffect(() => { + if (tableScrollRestoredRef.current || !scrollContainerRef.current || !data || data.length === 0) return; + const { top, left } = scrollPosRef.current; + if (top <= 0 && left <= 0) { tableScrollRestoredRef.current = true; return; } + + const timer = setTimeout(() => { + const el = scrollContainerRef.current; + if (!el) return; + if (top > 0) el.scrollTop = top; + if (left > 0) el.scrollLeft = left; + tableScrollRestoredRef.current = true; + }, 150); + return () => clearTimeout(timer); + }, [data]); + + // 🆕 키보드 네비게이션 관련 상태 (자동 보존) + const [focusedCell, setFocusedCell] = usePersistedState<{ rowIndex: number; colIndex: number } | null>('focusedCell', null); const tableContainerRef = useRef(null); // 🆕 인라인 셀 편집 관련 상태 @@ -733,13 +760,13 @@ export const TableListComponent: React.FC = ({ // 그룹 설정 관련 상태 const [groupByColumns, setGroupByColumns] = useState([]); - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const [collapsedGroups, setCollapsedGroups] = usePersistedState>('collapsedGroups', new Set()); // 🆕 그룹별 합산 설정 상태 const [groupSumConfig, setGroupSumConfig] = useState(null); // 🆕 Master-Detail 관련 상태 - const [expandedRows, setExpandedRows] = useState>(new Set()); // 확장된 행 키 목록 + const [expandedRows, setExpandedRows] = usePersistedState>('expandedRows', new Set()); const [detailData, setDetailData] = useState>({}); // 상세 데이터 캐시 // 🆕 Drag & Drop 재정렬 관련 상태 @@ -793,7 +820,7 @@ export const TableListComponent: React.FC = ({ const [frozenColumnCount, setFrozenColumnCount] = useState(0); // 🆕 Search Panel (통합 검색) 관련 상태 - const [globalSearchTerm, setGlobalSearchTerm] = useState(""); + const [globalSearchTerm, setGlobalSearchTerm] = usePersistedState('globalSearchTerm', ''); const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false); const [searchHighlights, setSearchHighlights] = useState>(new Set()); // "rowIndex-colIndex" 형식 @@ -801,6 +828,8 @@ export const TableListComponent: React.FC = ({ const [isFilterBuilderOpen, setIsFilterBuilderOpen] = useState(false); const [activeFilterCount, setActiveFilterCount] = useState(0); + // (TSP: 상태 저장은 usePersistedState가 자동 처리) + // 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링) useEffect(() => { const linkedFilters = tableConfig.linkedFilters; @@ -2868,13 +2897,17 @@ export const TableListComponent: React.FC = ({ // 🆕 Virtual Scrolling: virtualScrollInfo는 displayData 정의 이후로 이동됨 (아래 참조) - // 🆕 Virtual Scrolling: 스크롤 핸들러 + // 🆕 Virtual Scrolling: 스크롤 핸들러 + 스크롤 위치 추적 const handleVirtualScroll = useCallback( (e: React.UIEvent) => { - if (!isVirtualScrollEnabled) return; - setScrollTop(e.currentTarget.scrollTop); + const target = e.currentTarget; + if (isVirtualScrollEnabled) { + setScrollTop(target.scrollTop); + } + tableScrollRef.current = { top: target.scrollTop, left: target.scrollLeft }; + _setScrollPos({ top: target.scrollTop, left: target.scrollLeft }); }, - [isVirtualScrollEnabled], + [isVirtualScrollEnabled, _setScrollPos], ); // 🆕 State Persistence: 통합 상태 저장 diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx index f6fbaea2..15578257 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx @@ -72,7 +72,11 @@ export function TimelineSchedulerComponent({ goToNext, goToToday, updateSchedule, - } = useTimelineData(config, externalSchedules, externalResources); + } = useTimelineData( + config, + externalSchedules, + externalResources, + ); const isLoading = externalLoading ?? hookLoading; const error = externalError ?? hookError; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts b/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts index 94c001d4..aef1c2c4 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts @@ -5,6 +5,7 @@ import { apiClient } from "@/lib/api/client"; import { v2EventBus, V2_EVENTS } from "@/lib/v2-core"; import { TimelineSchedulerConfig, ScheduleItem, Resource, ZoomLevel, UseTimelineDataResult } from "../types"; import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config"; +import { usePersistedState } from "@/hooks/usePersistedState"; // schedule_mng 테이블 고정 (공통 스케줄 테이블) const SCHEDULE_TABLE = "schedule_mng"; @@ -38,17 +39,16 @@ export function useTimelineData( const [resources, setResources] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [zoomLevel, setZoomLevel] = useState(config.defaultZoomLevel || "day"); - const [viewStartDate, setViewStartDate] = useState(() => { - if (config.initialDate) { - return new Date(config.initialDate); - } - // 오늘 기준 1주일 전부터 시작 + + // TSP: 줌 레벨 / 시작 날짜 (자동 보존) + const [zoomLevel, setZoomLevel] = usePersistedState('zoomLevel', config.defaultZoomLevel || "day"); + const [viewStartDate, setViewStartDate] = usePersistedState('viewStartDate', (() => { + if (config.initialDate) return new Date(config.initialDate); const today = new Date(); today.setDate(today.getDate() - 7); today.setHours(0, 0, 0, 0); return today; - }); + })()); // 선택된 품목 코드 (좌측 테이블에서 선택된 데이터 기준) const [selectedSourceKeys, setSelectedSourceKeys] = useState([]);