import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import type { MenuItem } from "@/entities/menu"; import { MenuAuthorityApi } from "@/entities/menu-authority"; import type { PageMenuAuthority } from "@/entities/menu-authority/model/menu-authority-interface"; import { isBbsNoticeMenu } from "@/shared/lib/bbs-notice-menu"; import { resolveStatisticsTabId } from "@/shared/lib/statistics-menu-url"; export interface Tab { id: string; // 고유 ID (urlPath) label: string; // 탭에 표시될 이름 path: string; // 메뉴 경로 urlPath: string; // url 경로 parentEngNm: string; // 부모 menuEngNm menuItem: MenuItem; // 메뉴 정보 /** undefined: 미조회, null: 조회했으나 없음/실패 */ pageMenuAuthority?: PageMenuAuthority | null; } async function fetchTabPageMenuAuthority( menuCd: number, apply: (authority: PageMenuAuthority | null) => void, ): Promise { try { const response = await MenuAuthorityApi.getPageMenuAuthority(menuCd); apply(response?.[0] ?? null); } catch { apply(null); } } function scheduleTabPageMenuAuthorityFetch( get: () => TabStore, set: ( partial: | Partial | ((state: TabStore) => Partial), ) => void, tabId: string, menuCd: number, ): void { void fetchTabPageMenuAuthority(menuCd, (authority) => { const { tabs } = get(); if (!tabs.some((tab) => tab.id === tabId)) return; set((state) => ({ tabs: state.tabs.map((tab) => tab.id === tabId ? { ...tab, pageMenuAuthority: authority } : tab, ), })); }); } type TabStoreSetState = ( partial: | Partial | ((state: TabStore) => Partial), ) => void; const tabStoreSetState: TabStoreSetState = (partial) => { useTabStoreHook.setState(partial); }; function ensureTabPageMenuAuthority( get: () => TabStore, set: TabStoreSetState, tab: Tab, ): void { if (tab.pageMenuAuthority !== undefined) return; scheduleTabPageMenuAuthorityFetch(get, set, tab.id, tab.menuItem.menuCd); } /** persist 복원·새로고침 후 활성 탭 메뉴 권한이 없으면 조회 */ export function hydrateActiveTabPageMenuAuthority(): void { const { tabs, activeTabId } = getTabStoreState(); if (!activeTabId) return; const tab = tabs.find((t) => t.id === activeTabId); if (!tab) return; ensureTabPageMenuAuthority(getTabStoreState, tabStoreSetState, tab); } interface TabStore { tabs: Tab[]; activeTabId: string | null; menuList: MenuItem[] | null; addTab: (menuItem: MenuItem) => boolean; removeTab: (tabId: string) => void; setActiveTab: (tabId: string) => void; setMenuList: (menuList: MenuItem[] | null) => void; closeAllTabs: () => void; closeOtherTabs: (tabId: string) => void; closeTabsToLeft: (tabId: string) => void; closeTabsToRight: (tabId: string) => void; } export const TAB_STORE_PERSIST_KEY = "st-tab-store"; const TAB_STORE_VERSION = 1; const MAX_TABS = 10; function tabForPersist(tab: Tab): Tab { return { ...tab, menuItem: { ...tab.menuItem, children: null }, pageMenuAuthority: undefined, }; } function normalizePersistedTab(tab: Tab): Tab { const tabId = resolveStatisticsTabId(tab.urlPath || tab.id); return tabId === tab.id ? tab : { ...tab, id: tabId }; } function sanitizePersistedTabsAndActive( tabs: Tab[], activeTabId: string | null, ): { tabs: Tab[]; activeTabId: string | null } { let nextTabs = Array.isArray(tabs) ? tabs.filter((tab) => !isBbsNoticeMenu(tab.menuItem)).map(normalizePersistedTab) : []; if (nextTabs.length > MAX_TABS) nextTabs = nextTabs.slice(0, MAX_TABS); if (nextTabs.length === 0) { return { tabs: [], activeTabId: null }; } let nextActive = activeTabId; if (nextActive && !nextTabs.some((t) => t.id === nextActive)) { nextActive = nextTabs[0]?.id ?? null; } if (nextActive == null && nextTabs.length > 0) { nextActive = nextTabs[0]?.id ?? null; } return { tabs: nextTabs, activeTabId: nextActive }; } const useTabStoreHook = create()( persist( (set, get) => ({ tabs: [], activeTabId: null, menuList: null, addTab: (menuItem: MenuItem) => { if (isBbsNoticeMenu(menuItem)) { return false; } const { tabs, menuList } = get(); const tabId = resolveStatisticsTabId(menuItem.urlPath || ""); const existingTab = tabs.find((tab) => tab.id === tabId); if (existingTab) { set({ activeTabId: tabId }); ensureTabPageMenuAuthority(get, set, existingTab); return true; } if (tabs.length < MAX_TABS) { const newTab: Tab = { id: tabId, label: menuItem.menuKornNm, path: menuItem.menuPath, urlPath: menuItem.urlPath, parentEngNm: menuList?.find( (m) => m.menuCd === menuItem.parentId, )?.menuEngNm || "", menuItem, pageMenuAuthority: undefined, }; set({ tabs: [...tabs, newTab], activeTabId: tabId, }); scheduleTabPageMenuAuthorityFetch( get, set, tabId, menuItem.menuCd, ); return true; } return false; }, removeTab: (tabId: string): void => { const { tabs, activeTabId } = get(); const newTabs = tabs.filter((tab) => tab.id !== tabId); let newActiveTabId = activeTabId; if (activeTabId === tabId) { const closedTabIndex = tabs.findIndex( (tab) => tab.id === tabId, ); if (newTabs.length > 0) { if (closedTabIndex > 0) { const prevTab = tabs[closedTabIndex - 1]; newActiveTabId = prevTab ? prevTab.id : null; } else { const firstTab = newTabs[0]; newActiveTabId = firstTab ? firstTab.id : null; } } else { newActiveTabId = null; } } set({ tabs: newTabs, activeTabId: newActiveTabId, }); if (newActiveTabId) { const nextActive = get().tabs.find((t) => t.id === newActiveTabId); if (nextActive) ensureTabPageMenuAuthority(get, set, nextActive); } }, setActiveTab: (tabId: string) => { set({ activeTabId: tabId }); const tab = get().tabs.find((t) => t.id === tabId); if (tab) ensureTabPageMenuAuthority(get, set, tab); }, setMenuList: (menuList: MenuItem[] | null) => { set({ menuList }); }, closeAllTabs: () => { set({ tabs: [], activeTabId: null, }); }, closeOtherTabs: (tabId: string) => { const { tabs } = get(); const tabToKeep = tabs.find((tab) => tab.id === tabId); if (tabToKeep) { set({ tabs: [tabToKeep], activeTabId: tabId, }); } }, closeTabsToLeft: (tabId: string) => { const { tabs, activeTabId } = get(); const index = tabs.findIndex((tab) => tab.id === tabId); if (index <= 0) return; const newTabs = tabs.slice(index); let newActiveTabId = activeTabId; if (activeTabId && !newTabs.some((tab) => tab.id === activeTabId)) { newActiveTabId = tabId; } set({ tabs: newTabs, activeTabId: newActiveTabId, }); if (newActiveTabId) { const nextActive = get().tabs.find((t) => t.id === newActiveTabId); if (nextActive) ensureTabPageMenuAuthority(get, set, nextActive); } }, closeTabsToRight: (tabId: string) => { const { tabs, activeTabId } = get(); const index = tabs.findIndex((tab) => tab.id === tabId); if (index === -1 || index >= tabs.length - 1) return; const newTabs = tabs.slice(0, index + 1); let newActiveTabId = activeTabId; if (activeTabId && !newTabs.some((tab) => tab.id === activeTabId)) { newActiveTabId = tabId; } set({ tabs: newTabs, activeTabId: newActiveTabId, }); if (newActiveTabId) { const nextActive = get().tabs.find((t) => t.id === newActiveTabId); if (nextActive) ensureTabPageMenuAuthority(get, set, nextActive); } }, }), { name: TAB_STORE_PERSIST_KEY, version: TAB_STORE_VERSION, storage: createJSONStorage(() => localStorage), partialize: (state) => ({ tabs: state.tabs.map(tabForPersist), activeTabId: state.activeTabId, }), merge: (persistedState, currentState) => { const p = persistedState as Partial< Pick > | null; const rawTabs = p?.tabs; const rawActive = p?.activeTabId; const { tabs, activeTabId } = sanitizePersistedTabsAndActive( (rawTabs as Tab[]) ?? [], typeof rawActive === "string" || rawActive === null ? rawActive : null, ); return { ...currentState, tabs, activeTabId, }; }, onRehydrateStorage: () => (state, error) => { if (error || !state) return; queueMicrotask(() => hydrateActiveTabPageMenuAuthority()); }, }, ), ); export const useTabStore = useTabStoreHook; export const getTabStoreState = () => useTabStoreHook.getState(); // 로그아웃 또는 사용자 전환 시 로컬 탭 스냅샷 제거 및 메모리 상태 초기화 export function clearTabStorePersist(): void { useTabStoreHook.getState().closeAllTabs(); useTabStoreHook.persist.clearStorage(); }