Files
frontend/apps/sn/src/store/tab-store.ts
T

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();
}