328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
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<void> {
|
|
try {
|
|
const response = await MenuAuthorityApi.getPageMenuAuthority(menuCd);
|
|
apply(response?.[0] ?? null);
|
|
} catch {
|
|
apply(null);
|
|
}
|
|
}
|
|
|
|
function scheduleTabPageMenuAuthorityFetch(
|
|
get: () => TabStore,
|
|
set: (
|
|
partial:
|
|
| Partial<TabStore>
|
|
| ((state: TabStore) => Partial<TabStore>),
|
|
) => 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<TabStore>
|
|
| ((state: TabStore) => Partial<TabStore>),
|
|
) => 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<TabStore>()(
|
|
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<TabStore, "tabs" | "activeTabId">
|
|
> | 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();
|
|
}
|