feat: Implement tab state persistence and enhance layout structure

- Added TabProvider to MainLayout for managing tab states.
- Introduced tab state caching in ScreenViewPage to restore data on refresh.
- Enhanced AppLayout to include TabBar and TabContent for better tab management.
- Updated various components to utilize usePersistedState for maintaining UI states across sessions.
- Improved user experience by ensuring selected tab and scroll positions are preserved during navigation and refresh.
This commit is contained in:
syc0123 2026-02-25 17:26:45 +09:00
parent 6d40c3ea1c
commit 72d9e55159
23 changed files with 1941 additions and 214 deletions

View File

@ -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 (
<AuthProvider>
<MenuProvider>
<AppLayout>{children}</AppLayout>
<TabProvider>
<AppLayout>{children}</AppLayout>
</TabProvider>
</MenuProvider>
</AuthProvider>
);

View File

@ -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<string, unknown>;
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<string | null>(null);
const [formData, setFormData] = useState<Record<string, unknown>>({});
// 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<Record<string, unknown>>(() => {
if (initialCache?.formData && Object.keys(initialCache.formData).length > 0) {
cacheRestoredRef.current = true;
return initialCache.formData as Record<string, unknown>;
}
return {};
});
// 테이블에서 선택된 행 데이터 (버튼 액션에 전달)
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
const [selectedRowsData, setSelectedRowsData] = useState<any[]>(
() => initialCache?.selectedRowsData ?? []
);
// 테이블 정렬 정보 (엑셀 다운로드용)
const [tableSortBy, setTableSortBy] = useState<string | undefined>();
const [tableSortOrder, setTableSortOrder] = useState<"asc" | "desc">("asc");
const [tableColumnOrder, setTableColumnOrder] = useState<string[] | undefined>();
const [tableDisplayData, setTableDisplayData] = useState<any[]>([]); // 화면에 표시된 데이터 (컬럼 순서 포함)
const [tableSortBy, setTableSortBy] = useState<string | undefined>(
() => initialCache?.tableSortBy
);
const [tableSortOrder, setTableSortOrder] = useState<"asc" | "desc">(
() => initialCache?.tableSortOrder ?? "asc"
);
const [tableColumnOrder, setTableColumnOrder] = useState<string[] | undefined>(
() => initialCache?.tableColumnOrder
);
const [tableDisplayData, setTableDisplayData] = useState<any[]>(
() => initialCache?.tableDisplayData ?? []
);
// 플로우에서 선택된 데이터 (버튼 액션에 전달)
const [flowSelectedData, setFlowSelectedData] = useState<any[]>([]);
@ -122,6 +193,139 @@ function ScreenViewPage() {
initComponents();
}, []);
// 페이지 스크롤 위치 추적 (비활성 탭 F5 복원용 - 세로+가로)
const pageScrollTopRef = React.useRef(0);
const pageScrollLeftRef = React.useRef(0);
const scrollTargetRef = React.useRef<HTMLElement | null>(null);
useEffect(() => {
const el = containerRef.current;
if (!el || !screenId) return;
const cleanups: (() => void)[] = [];
let saveTimer: ReturnType<typeof setTimeout>;
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 (
<TableSearchWidgetHeightProvider>
<ScreenContextProvider>
<SplitPanelProvider>
<ScreenViewPage screenIdProp={screenId} menuObjidProp={menuObjid} />
</SplitPanelProvider>
</ScreenContextProvider>
</TableSearchWidgetHeightProvider>
);
}
// URL 라우트에서 사용하는 기본 래퍼
function ScreenViewPageWrapper() {
return (
<TableSearchWidgetHeightProvider>

View File

@ -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<Set<string>>(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) => (
<div
key={child.id}
draggable={!child.hasChildren}
onDragStart={(e) => {
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)}
>
<div className="flex min-w-0 flex-1 items-center">
{child.icon}
@ -695,9 +755,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</div>
</aside>
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
<main className={`min-w-0 flex-1 overflow-auto bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
{children}
{/* 가운데 컨텐츠 영역 */}
<main className={`flex min-w-0 flex-1 flex-col bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
{/* 탭 바 (데스크톱에서만 항상 표시) */}
{!isMobile && <TabBar />}
{/* 콘텐츠 영역 */}
<div className="flex-1 overflow-auto">
<TabContent />
</div>
</main>
</div>

View File

@ -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<HTMLDivElement>(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<number>(-1);
const pendingDropRef = useRef<{ timerId: ReturnType<typeof setTimeout>; finalize: () => void } | null>(null);
const tabsRef = useRef(tabs);
tabsRef.current = tabs;
const newDropTabIdRef = useRef<string | null>(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 (
<div className="relative flex h-9 min-w-0 items-end bg-slate-50 px-1 overflow-hidden shadow-[inset_0_-1px_0_0_rgb(226,232,240)]" />
);
}
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 (
<div
className={cn(
"relative flex h-9 min-w-0 items-end bg-slate-50 px-1 overflow-hidden shadow-[inset_0_-1px_0_0_rgb(226,232,240)]",
dragOver && "shadow-[inset_0_-2px_0_0_rgb(59,130,246)]",
)}
ref={tabsContainerRef}
onDragOver={handleMenuDragOver}
onDragLeave={handleMenuDragLeave}
onDrop={handleMenuDrop}
>
{/* 탭 + 오버플로우 버튼이 나란히 배치 */}
<div className="flex min-w-0 items-end">
{displayVisibleTabs.map((tab) => {
const isActive = tab.id === activeTabId;
const displayName = getTabDisplayName(tab);
return (
<div
key={tab.id}
data-tab-id={tab.id}
style={{ width: `${tabWidth}px` }}
className={cn(
"group relative mr-0.5 flex shrink-0 cursor-pointer items-center gap-1 rounded-t-lg border px-3 text-xs font-medium select-none transition-colors",
isActive
? "z-10 h-[33px] border-slate-200 border-b-0 bg-white text-slate-900"
: "h-8 border-transparent text-slate-500 hover:bg-slate-100 hover:text-slate-700",
)}
onMouseDown={(e) => handleTabMouseDown(e, tab.id)}
onDragStart={(e) => e.preventDefault()}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
title={displayName}
>
<span className="min-w-0 flex-1 truncate">{displayName}</span>
{isActive && (
<button
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-slate-400 transition-colors hover:bg-slate-200 hover:text-slate-700"
onClick={(e) => {
e.stopPropagation();
refreshTab(tab.id);
}}
title="새로고침"
>
<RotateCw className="h-2.5 w-2.5" />
</button>
)}
<button
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded-sm transition-colors",
isActive
? "text-slate-400 hover:bg-slate-200 hover:text-slate-700"
: "text-transparent group-hover:text-slate-400 group-hover:hover:bg-slate-200 group-hover:hover:text-slate-700",
)}
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
{/* 오버플로우 드롭다운 - 마지막 탭 바로 옆 */}
{hasOverflow && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="mb-0.5 flex h-7 shrink-0 items-center justify-center gap-0.5 rounded-md px-2 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-200 hover:text-slate-700"
title={`${displayOverflowTabs.length}개 탭`}
>
<span className="text-[10px]">+{displayOverflowTabs.length}</span>
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-[400px] w-[280px] overflow-y-auto">
{displayOverflowTabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<DropdownMenuItem
key={tab.id}
className={cn(
"flex cursor-pointer items-center justify-between gap-2",
isActive && "bg-slate-100 font-semibold",
)}
onClick={() => switchTab(tab.id)}
>
<div className="flex min-w-0 flex-1 flex-col">
{tab.parentMenuName && (
<span className="text-[10px] text-slate-400">{tab.parentMenuName}</span>
)}
<span className="truncate text-xs">{tab.screenName || `화면 ${tab.screenId}`}</span>
</div>
<button
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-slate-400 hover:bg-slate-200 hover:text-slate-700"
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
>
<X className="h-3 w-3" />
</button>
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-xs text-slate-500"
onClick={closeAllTabs}
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* 우클릭 컨텍스트 메뉴 */}
{contextMenu && (
<div
className="fixed z-50 min-w-[160px] rounded-md border border-slate-200 bg-white py-1 shadow-lg"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
onClick={() => {
refreshTab(contextMenu.tabId);
setContextMenu(null);
}}
>
</button>
<div className="my-1 border-t border-slate-200" />
<button
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
onClick={() => {
closeTab(contextMenu.tabId);
setContextMenu(null);
}}
>
</button>
<button
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
onClick={() => {
closeOtherTabs(contextMenu.tabId);
setContextMenu(null);
}}
>
</button>
<button
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
onClick={() => {
closeTabsToTheLeft(contextMenu.tabId);
setContextMenu(null);
}}
>
</button>
<button
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
onClick={() => {
closeTabsToTheRight(contextMenu.tabId);
setContextMenu(null);
}}
>
</button>
<div className="my-1 border-t border-slate-200" />
<button
className="flex w-full items-center px-3 py-1.5 text-xs text-red-600 hover:bg-red-50"
onClick={() => {
closeAllTabs();
setContextMenu(null);
}}
>
</button>
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="flex h-full min-h-[400px] w-full items-center justify-center">
<div className="rounded-xl border border-slate-200 bg-white p-8 text-center shadow-lg">
<Loader2 className="text-primary mx-auto h-10 w-10 animate-spin" />
<p className="mt-4 font-medium text-slate-900"> ...</p>
</div>
</div>
);
}
function EmptyTabState() {
return (
<div className="flex h-full w-full items-center justify-center bg-white">
<div className="flex flex-col items-center py-12 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-100">
<Inbox className="h-8 w-8 text-slate-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-700"> </h3>
<p className="max-w-sm text-sm text-slate-500">
.
</p>
</div>
</div>
);
}
export function TabContent() {
const { tabs, activeTabId, refreshKeys } = useTab();
// 탭 순서 변경(드래그 리오더링) 시 DOM 재배치를 방지하기 위해
// 탭이 추가된 순서(id 기준)로 고정 렌더링
const stableOrderRef = useRef<TabItem[]>([]);
// F5 이후 활성 탭만 마운트하고, 비활성 탭은 클릭 시 마운트 (Lazy Mount)
const mountedTabIdsRef = useRef<Set<string>>(
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 <EmptyTabState />;
}
return (
<>
{stableTabs.map((tab) => (
<div
key={`${tab.id}-${refreshKeys[tab.id] || 0}`}
className="h-full w-full"
style={{ display: tab.id === activeTabId ? "block" : "none" }}
>
{mountedTabIdsRef.current.has(tab.id) ? (
<Suspense fallback={<TabLoadingFallback />}>
<ScreenViewPageEmbeddable
screenId={tab.screenId}
menuObjid={tab.menuObjid}
/>
</Suspense>
) : null}
</div>
))}
</>
);
}

View File

@ -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<HTMLDivElement>(null);

View File

@ -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<Record<string, ComponentData[]>>({});
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
// 탭별 화면 정보 (screenId, tableName) - 인라인 컴포넌트의 테이블 설정에서 추출
// 탭별 화면 정보 (screenId, tableName) - screenId 기반 탭과 인라인 컴포넌트 모두 포함
const screenInfoMap = React.useMemo(() => {
const map: Record<string, { id?: number; tableName?: string }> = {};
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 }),
}
: {})}
/>

View File

@ -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<CategoryValueManagerProps> = ({
@ -38,20 +40,20 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
columnLabel,
onValueCountChange,
menuObjid,
screenId,
}) => {
const { toast } = useToast();
// TSP: 상태 자동 보존
const [selectedValueIds, setSelectedValueIds] = usePersistedState<number[]>(`selectedValueIds-${columnName}`, []);
const [searchQuery, setSearchQuery] = usePersistedState(`searchQuery-${columnName}`, '');
const [showInactive, setShowInactive] = usePersistedState(`showInactive-${columnName}`, false);
const [values, setValues] = useState<TableCategoryValue[]>([]);
const [filteredValues, setFilteredValues] = useState<TableCategoryValue[]>(
[]
);
const [selectedValueIds, setSelectedValueIds] = useState<number[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [filteredValues, setFilteredValues] = useState<TableCategoryValue[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [editingValue, setEditingValue] = useState<TableCategoryValue | null>(
null
);
const [showInactive, setShowInactive] = useState(false); // 비활성 항목 표시 옵션 (기본: 숨김)
const [editingValue, setEditingValue] = useState<TableCategoryValue | null>(null);
// 카테고리 값 로드
useEffect(() => {

View File

@ -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<CategoryValueManagerTreeProps> =
columnName,
columnLabel,
onValueCountChange,
screenId,
}) => {
// TSP: 상태 자동 보존
const [checkedIds, setCheckedIds] = usePersistedState<Set<number>>(`checkedIds-${columnName}`, new Set());
const [expandedNodes, setExpandedNodes] = usePersistedState<Set<number>>(`expandedNodes-${columnName}`, new Set());
const [searchQuery, setSearchQuery] = usePersistedState(`searchQuery-${columnName}`, '');
const [showInactive, setShowInactive] = usePersistedState(`showInactive-${columnName}`, false);
const [focusedValueId, setFocusedValueId] = usePersistedState<number | null>(`focusedValueId-${columnName}`, null);
const pendingFocusIdRef = useRef<number | null>(focusedValueId);
// 상태
const [tree, setTree] = useState<CategoryValue[]>([]);
const [loading, setLoading] = useState(false);
const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
const [selectedValue, setSelectedValue] = useState<CategoryValue | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [showInactive, setShowInactive] = useState(false);
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
const [selectedValue, setSelectedValueRaw] = useState<CategoryValue | null>(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<CategoryValueManagerTreeProps> =
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<CategoryValueManagerTreeProps> =
setLoading(false);
}
},
[tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange],
[tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange, findNodeById],
);
useEffect(() => {

View File

@ -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<string, number>;
openTab: (tab: Omit<TabItem, "id">, 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<TabContextType | undefined>(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<TabItem[]>([]);
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
const [refreshKeys, setRefreshKeys] = useState<Record<string, number>>({});
// 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<TabItem, "id">, 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 (
<TabContext.Provider
value={{
tabs,
activeTabId,
refreshKeys,
openTab,
closeTab,
closeOtherTabs,
closeAllTabs,
closeTabsToTheLeft,
closeTabsToTheRight,
switchTab,
getActiveTab,
updateTabOrder,
refreshTab,
}}
>
{children}
</TabContext.Provider>
);
}
export function useTab() {
const context = useContext(TabContext);
if (context === undefined) {
throw new Error("useTab must be used within a TabProvider");
}
return context;
}

View File

@ -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<TSPContextValue>({});
export function TSPProvider({
screenId,
componentId,
children,
}: {
screenId?: number;
componentId?: string;
children: ReactNode;
}) {
const value = useMemo(
() => ({ screenId, componentId }),
[screenId, componentId],
);
return <TSPContext.Provider value={value}>{children}</TSPContext.Provider>;
}
// ─── 직렬화 (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<string>());
* const [scroll, setScroll] = usePersistedState('scrollTop', 0, { debounce: 100 });
*/
export function usePersistedState<T>(
key: string,
defaultValue: T,
options?: PersistedStateOptions,
): [T, Dispatch<SetStateAction<T>>] {
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<T>(() => {
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<ReturnType<typeof setTimeout> | null>(null);
const isFirstRender = useRef(true);
const latestStateRef = useRef<T>(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));
}

View File

@ -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<DynamicComponentRendererProps> =
NewComponentRenderer.prototype.render;
if (isClass) {
// 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속)
const rendererInstance = new NewComponentRenderer(rendererProps);
return rendererInstance.render();
return (
<TSPProvider screenId={screenId} componentId={component.id}>
{rendererInstance.render()}
</TSPProvider>
);
} else {
// 함수형 컴포넌트
// refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제
return <NewComponentRenderer key={refreshKey} {...rendererProps} />;
return (
<TSPProvider screenId={screenId} componentId={component.id}>
<NewComponentRenderer key={refreshKey} {...rendererProps} />
</TSPProvider>
);
}
}
} catch (error) {
@ -718,22 +724,21 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 레거시 시스템에서도 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<DynamicComponentRendererProps> =
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 (
<TSPProvider screenId={props.screenId} componentId={component.id}>
{legacyRendered}
</TSPProvider>
);
} catch (error) {
console.error(`❌ 컴포넌트 렌더링 실패 (${componentType}):`, error);

View File

@ -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<RelatedDataButtonsComponentPr
style,
}) => {
const [buttons, setButtons] = useState<ButtonItem[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedId, setSelectedId] = usePersistedState<string | null>('selectedId', null);
const [selectedItem, setSelectedItem] = useState<ButtonItem | null>(null);
const [loading, setLoading] = useState(false);
const [masterData, setMasterData] = useState<Record<string, any> | null>(null);
@ -160,10 +161,11 @@ export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentPr
setButtons(items);
// 자동 선택: 기본 항목 또는 첫 번째 항목
// 자동 선택: 캐시 복원 → 기본 항목 → 첫 번째 항목
if (config.autoSelectFirst && items.length > 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);

View File

@ -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<SplitPanelLayoutComponentProps>
// 데이터 상태
const [leftData, setLeftData] = useState<any[]>([]);
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
const [selectedLeftItem, setSelectedLeftItem] = usePersistedState<any>('selectedLeftItem', null, { debounce: 0 });
const [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템
const [leftSearchQuery, setLeftSearchQuery] = useState("");
const [rightSearchQuery, setRightSearchQuery] = useState("");
@ -2286,6 +2287,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 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) {

View File

@ -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<SplitPanelLayout2ComponentProp
// 상태 관리
const [leftData, setLeftData] = useState<any[]>([]);
const [rightData, setRightData] = useState<any[]>([]);
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
const [leftSearchTerm, setLeftSearchTerm] = useState("");
const [rightSearchTerm, setRightSearchTerm] = useState("");
// TSP: 탭 상태 보존 대상 (usePersistedState)
const [selectedLeftItem, setSelectedLeftItem] = usePersistedState<any>('selectedLeftItem', null, { debounce: 0 });
const initialCachedLeftItemRef = useRef(selectedLeftItem);
const [leftSearchTerm, setLeftSearchTerm] = usePersistedState('leftSearchTerm', '');
const [rightSearchTerm, setRightSearchTerm] = usePersistedState('rightSearchTerm', '');
const [expandedItems, setExpandedItems] = usePersistedState<Set<string>>('expandedItems', new Set());
const [selectedRightItems, setSelectedRightItems] = usePersistedState<Set<string | number>>('selectedRightItems', new Set());
const [leftActiveTab, setLeftActiveTab] = usePersistedState<string | null>('leftActiveTab', null);
const [rightActiveTab, setRightActiveTab] = usePersistedState<string | null>('rightActiveTab', null);
const [leftLoading, setLeftLoading] = useState(false);
const [rightLoading, setRightLoading] = useState(false);
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [splitPosition, setSplitPosition] = useState(config.splitRatio || 30);
const [isResizing, setIsResizing] = useState(false);
@ -79,19 +87,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({});
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({});
// 우측 패널 선택 상태 (체크박스용)
const [selectedRightItems, setSelectedRightItems] = useState<Set<string | number>>(new Set());
// 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<any>(null);
const [isBulkDelete, setIsBulkDelete] = useState(false);
const [deleteTargetPanel, setDeleteTargetPanel] = useState<"left" | "right">("right");
// 탭 상태 (좌측/우측 각각)
const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null);
const [rightActiveTab, setRightActiveTab] = useState<string | null>(null);
// 프론트엔드 그룹핑 함수
const groupData = useCallback(
(data: Record<string, any>[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record<string, any>[] => {
@ -1241,6 +1242,20 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
config.rightPanel?.tableName,
]);
// TSP: 캐시에서 복원된 좌측 선택 항목으로 우측 데이터 로드
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]);
// 컴포넌트 언마운트 시 DataProvider 해제
useEffect(() => {
return () => {

View File

@ -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<Set<string>>("expandedNodes", new Set());
const [selectedNodeId, setSelectedNodeId] = usePersistedState<string | null>("selectedNodeId", null);
const [headerInfo, setHeaderInfo] = useState<BomHeaderInfo | null>(null);
const [treeData, setTreeData] = useState<BomTreeNode[]>([]);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(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<string>(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<string, string>();
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<string>(tree.map((n: BomTreeNode) => n.id));
setExpandedNodes(firstLevelIds);
}
} catch (error) {
console.error("[BomTree] 데이터 로드 실패:", error);
} finally {

View File

@ -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<ViewMode>(config.viewMode);
// TSP: 뷰 모드 상태 (자동 보존)
const [viewMode, setViewMode] = usePersistedState<ViewMode>('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 (
<div ref={containerRef} className="flex h-full min-h-[10px] gap-0 overflow-hidden" style={{ height: config.height }}>
@ -181,6 +185,7 @@ export function V2CategoryManagerComponent({
tableName={selectedColumn.tableName}
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
screenId={effectiveScreenId}
/>
) : (
<CategoryValueManager
@ -189,6 +194,7 @@ export function V2CategoryManagerComponent({
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
menuObjid={effectiveMenuObjid}
screenId={effectiveScreenId}
/>
)
) : (

View File

@ -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<PivotGridProps> = ({
// 🆕 초기 로드 시 자동 확장 (첫 레벨만)
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<HTMLDivElement>(null);
@ -997,7 +998,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
}, [stateStorageKey, initialFields]);
// 필드 숨기기/표시 상태
const [hiddenFields, setHiddenFields] = useState<Set<string>>(new Set());
const [hiddenFields, setHiddenFields] = usePersistedState<Set<string>>('hiddenFields', new Set());
const toggleFieldVisibility = useCallback((fieldName: string) => {
setHiddenFields((prev) => {

View File

@ -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<SplitPanelLayoutComponentProps>
}) => {
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<SplitPanelLayoutComponentProps>
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
// TSP: 분할패널 상태 자동 보존
const [selectedLeftItem, setSelectedLeftItemRaw] = usePersistedState<any>('selectedLeftItem', null, { debounce: 0 });
const initialCachedLeftItemRef = useRef(selectedLeftItem);
const setSelectedLeftItem = useCallback((val: any) => {
setSelectedLeftItemRaw(val);
}, [setSelectedLeftItemRaw]);
const userInteractedLeftRef = useRef(false);
const [expandedRightItems, setExpandedRightItems] = usePersistedState<Set<string | number>>('expandedRightItems', new Set());
// 데이터 상태
const [leftData, setLeftData] = useState<any[]>([]);
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
const [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템
const [rightData, setRightData] = useState<any[] | any>(null);
const [customLeftSelectedData, setCustomLeftSelectedData] = useState<Record<string, any>>({}); // 커스텀 모드: 좌측 선택 데이터
// 커스텀 모드: 탭/버튼 간 공유할 selectedRowsData 자체 관리 (항상 로컬 상태 사용)
const [localSelectedRowsData, setLocalSelectedRowsData] = useState<any[]>([]);
@ -205,15 +200,51 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
},
[(props as any).onSelectedRowsChange],
);
const [leftSearchQuery, setLeftSearchQuery] = useState("");
const [rightSearchQuery, setRightSearchQuery] = useState("");
// (TSP: selectedLeftItem 저장은 usePersistedState가 자동 처리)
// 좌/우측 패널 스크롤 위치 저장/복원
const leftPanelContentRef = useRef<HTMLDivElement>(null);
const rightPanelContentRef = useRef<HTMLDivElement>(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<typeof setTimeout>;
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<any[]>([]); // 우측 테이블 컬럼 정보
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]);
const [expandedItems, setExpandedItems] = usePersistedState<Set<any>>('expandedItems', new Set());
// 추가 탭 관련 상태
const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭, 1+ = 추가 탭
const [activeTabIndex, setActiveTabIndex] = usePersistedState('activeTabIndex', 0);
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터
const [tabsLoading, setTabsLoading] = useState<Record<number, boolean>>({}); // 탭별 로딩 상태
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
@ -225,6 +256,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
Record<string, Record<string, { label: string; color?: string }>>
>({}); // 우측 카테고리 매핑
// (TSP: UI 상태 저장은 usePersistedState가 자동 처리)
// 🆕 커스텀 모드: 드래그/리사이즈 상태
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
@ -1555,6 +1588,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시)
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<SplitPanelLayoutComponentProps>
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<SplitPanelLayoutComponentProps>
setSelectedLeftItem(item);
setCustomLeftSelectedData(item); // 커스텀 모드 우측 폼에 선택된 데이터 전달
// 부모에게 선택 전파 (탭 상태 캐시용)
(props as any).onSelectedRowsChange?.([item], [item]);
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
setTabsData({}); // 모든 탭 데이터 초기화
@ -2809,6 +2849,81 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 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<HTMLDivElement | null>, 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<SplitPanelLayoutComponentProps>
</div>
</div>
)}
<CardContent className="flex-1 overflow-auto p-4">
<CardContent ref={leftPanelContentRef} className="flex-1 overflow-auto p-4">
{/* 좌측 데이터 목록/테이블/커스텀 */}
{console.log("🔍 [SplitPanel] 왼쪽 패널 displayMode:", componentConfig.leftPanel?.displayMode, "isDesignMode:", isDesignMode)}
{componentConfig.leftPanel?.displayMode === "custom" ? (
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
<div
@ -3087,7 +3201,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}}
// 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함
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<SplitPanelLayoutComponentProps>
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<SplitPanelLayoutComponentProps>
</div>
</div>
)}
<CardContent className="flex-1 overflow-hidden p-4">
<CardContent ref={rightPanelContentRef} className="flex-1 overflow-hidden p-4">
{/* 추가 탭 컨텐츠 */}
{activeTabIndex > 0 ? (
(() => {
@ -4147,7 +4265,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}}
// 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함
onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => {
console.log("🔍 [SplitPanel-Right] onSelectTabComponent 호출:", { tabId, compId, tabComp, parentSplitPanelId: component.id });
// 탭 내 컴포넌트 선택 상태 업데이트
setNestedTabSelectedCompId(compId);
// 부모 분할 패널 정보와 함께 전역 이벤트 발생

View File

@ -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<string, any>
searchFilters?: Record<string, any>,
): UseGroupedDataResult {
// 원본 데이터
const [rawData, setRawData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 그룹 펼침 상태 관리
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
// 사용자가 수동으로 펼침/접기를 조작했는지 여부
const [isManuallyControlled, setIsManuallyControlled] = useState(false);
// 선택 상태 관리
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(
new Set()
);
// TSP: 그룹 펼침 / 선택 상태 (자동 보존)
const [expandedGroups, setExpandedGroups] = usePersistedState<Set<string>>('expandedGroups', new Set());
const [isManuallyControlled, setIsManuallyControlled] = usePersistedState('isManuallyControlled', false);
const [selectedItemIds, setSelectedItemIds] = usePersistedState<Set<string>>('selectedItemIds', new Set());
// 테이블명 결정
const tableName = config.useCustomTable

View File

@ -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<TableListComponentProps> = ({
const newSearchValues: Record<string, any> = {};
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<TableListComponentProps> = ({
}
});
// 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<TableListComponentProps> = ({
return result;
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]);
const [currentPage, setCurrentPage] = useState(1);
// TSP: 테이블 상태 자동 보존
const [currentPage, setCurrentPage] = usePersistedState('currentPage', 1);
const prevFiltersKeyRef = useRef<string | null>(null);
const [totalPages, setTotalPages] = useState(0);
const [totalItems, setTotalItems] = useState(0);
const [searchTerm, setSearchTerm] = useState("");
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const hasInitializedSort = useRef(false);
const [searchTerm, setSearchTerm] = usePersistedState('searchTerm', '');
const [sortColumn, setSortColumn] = usePersistedState<string | null>('sortColumn', null);
const [sortDirection, setSortDirection] = usePersistedState<"asc" | "desc">('sortDirection', "asc");
const hasInitializedSort = useRef(sortColumn != null);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [tableLabel, setTableLabel] = useState<string>("");
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
@ -662,8 +667,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
Record<string, Record<string, { label: string; color?: string }>>
>({});
const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [searchValues, setSearchValues] = usePersistedState<Record<string, any>>('searchValues', {});
const [selectedRows, setSelectedRows] = usePersistedState<Set<string>>('selectedRows', new Set());
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
const [refreshTrigger, setRefreshTrigger] = useState(0);
// columnOrder는 상단에서 정의됨 (visibleColumns보다 먼저 필요)
@ -676,8 +681,30 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(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<HTMLDivElement>(null);
// 🆕 인라인 셀 편집 관련 상태
@ -733,13 +760,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 그룹 설정 관련 상태
const [groupByColumns, setGroupByColumns] = useState<string[]>([]);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const [collapsedGroups, setCollapsedGroups] = usePersistedState<Set<string>>('collapsedGroups', new Set());
// 🆕 그룹별 합산 설정 상태
const [groupSumConfig, setGroupSumConfig] = useState<GroupSumConfig | null>(null);
// 🆕 Master-Detail 관련 상태
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set()); // 확장된 행 키 목록
const [expandedRows, setExpandedRows] = usePersistedState<Set<string>>('expandedRows', new Set());
const [detailData, setDetailData] = useState<Record<string, any[]>>({}); // 상세 데이터 캐시
// 🆕 Drag & Drop 재정렬 관련 상태
@ -793,7 +820,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
// 🆕 Search Panel (통합 검색) 관련 상태
const [globalSearchTerm, setGlobalSearchTerm] = useState("");
const [globalSearchTerm, setGlobalSearchTerm] = usePersistedState('globalSearchTerm', '');
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
const [searchHighlights, setSearchHighlights] = useState<Set<string>>(new Set()); // "rowIndex-colIndex" 형식
@ -801,6 +828,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
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<TableListComponentProps> = ({
// 🆕 Virtual Scrolling: virtualScrollInfo는 displayData 정의 이후로 이동됨 (아래 참조)
// 🆕 Virtual Scrolling: 스크롤 핸들러
// 🆕 Virtual Scrolling: 스크롤 핸들러 + 스크롤 위치 추적
const handleVirtualScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
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: 통합 상태 저장

View File

@ -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;

View File

@ -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<Resource[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [zoomLevel, setZoomLevel] = useState<ZoomLevel>(config.defaultZoomLevel || "day");
const [viewStartDate, setViewStartDate] = useState<Date>(() => {
if (config.initialDate) {
return new Date(config.initialDate);
}
// 오늘 기준 1주일 전부터 시작
// TSP: 줌 레벨 / 시작 날짜 (자동 보존)
const [zoomLevel, setZoomLevel] = usePersistedState<ZoomLevel>('zoomLevel', config.defaultZoomLevel || "day");
const [viewStartDate, setViewStartDate] = usePersistedState<Date>('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<string[]>([]);