ERP-node/frontend/contexts/TabContext.tsx

300 lines
9.2 KiB
TypeScript

"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]);
// activeTabId 변경 시 글로벌 이벤트 발행 (탭 외부 컴포넌트에서 탭 전환 감지용)
useEffect(() => {
if (activeTabId) {
(window as any).__activeTabId = activeTabId;
window.dispatchEvent(
new CustomEvent("tabSwitch", { detail: { tabId: activeTabId } }),
);
}
}, [activeTabId]);
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;
}