300 lines
9.2 KiB
TypeScript
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;
|
|
}
|