[st] feat: tab store에 persist 적용

This commit is contained in:
2026-03-23 14:36:18 +09:00
parent a46b185c16
commit 2ee400c9bb
4 changed files with 312 additions and 196 deletions
+142 -109
View File
@@ -1,20 +1,24 @@
"use client";
import { useEffect, useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { usePathname } from "next/navigation";
import { useQueryClient, useQuery } from "@tanstack/react-query";
import type { MenuItem } from "@/entities/menu";
import { useMenuStore, useAlertStore } from "@/shared/lib";
import { useTabStore, getTabStoreState } from "@/store/tab-store";
import { useTabStore as gridTabStore } from "@rsone/components";
import { getConfig } from "@common/config";
import { getCookie } from "@/shared/lib";
import { getMenuList } from "../../../../packages/common/components/src/api";
import {
getTabStoreState as getGridTabStoreState,
useTabStore as gridTabStore,
} from "@rsone/components";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { BaseLayout } from "@common/components";
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
import { StatisticsPageLayout } from "@/shared/ui/statistics-page-layout";
import type { MenuItem } from "@/entities/menu";
import { BaseLayout } from "@common/components";
import { getConfig } from "@common/config";
import { getMenuList } from "../../../../packages/common/components/src/api";
import { getTabStoreState, useTabStore } from "@/store/tab-store";
const appConfig = getConfig();
const userId = getCookie(appConfig.AUTH_USER_ID ?? "");
@@ -22,118 +26,147 @@ const appSiteId = getCookie("site-id-key") ?? "";
const isLoggedIn = !!userId;
export default function WithBaseLayout({
children,
children,
}: {
children: React.ReactNode;
children: React.ReactNode;
}) {
const pathname = usePathname();
const queryClient = useQueryClient();
const pathname = usePathname();
const queryClient = useQueryClient();
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
const setCurrentMenuInfo = useMenuStore(
(state) => state.setCurrentMenuInfo,
);
const currentMenuInfo = useMenuStore((state) => state.currentMenuInfo);
const addTab = useTabStore((state) => state.addTab);
const setActiveTab = useTabStore((state) => state.setActiveTab);
const setMenuList = useTabStore((state) => state.setMenuList);
const activeTabId = useTabStore((state) => state.activeTabId);
const menuList = useTabStore((state) => state.menuList);
const tabs = useTabStore((state) => state.tabs);
const addTabForGrid = gridTabStore((state) => state.addTab);
const setActiveTabForGrid = gridTabStore((state) => state.setActiveTab);
const setMenuListForGrid = gridTabStore((state) => state.setMenuList);
const alert = useAlertStore((state) => state.setMessage);
const addTab = useTabStore((state) => state.addTab);
const setActiveTab = useTabStore((state) => state.setActiveTab);
const setMenuList = useTabStore((state) => state.setMenuList);
const activeTabId = useTabStore((state) => state.activeTabId);
const menuList = useTabStore((state) => state.menuList);
const tabs = useTabStore((state) => state.tabs);
const addTabForGrid = gridTabStore((state) => state.addTab);
const setActiveTabForGrid = gridTabStore((state) => state.setActiveTab);
const setMenuListForGrid = gridTabStore((state) => state.setMenuList);
const alert = useAlertStore((state) => state.setMessage);
const { data: menuListData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
queryFn: () => getMenuList(appSiteId),
enabled: isLoggedIn,
});
const { data: menuListData } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
queryFn: () => getMenuList(appSiteId),
enabled: isLoggedIn,
});
useEffect(() => {
if (menuListData) {
setMenuList(menuListData);
setMenuListForGrid(menuListData);
}
}, [menuListData, setMenuList, setMenuListForGrid]);
useEffect(() => {
if (!pathname) return;
// pathname이 변경될 때만 실행되도록 tabs를 직접 가져옴
const currentTabs = getTabStoreState().tabs;
const tab = currentTabs.find((t) => t.path === pathname);
if (tab) {
setActiveTab(tab.id);
setActiveTabForGrid(tab.id);
}
}, [pathname, setActiveTab, setActiveTabForGrid]);
// 활성 탭의 메뉴를 찾아서 urlPath를 가져옴. 탭이 전부 닫히면 ''를 넘겨 LNB에서 메뉴 강조 제거
const currentPath = useMemo(() => {
if (tabs.length === 0) return "";
if (activeTabId && menuList) {
// menuList에서 활성 탭의 메뉴 찾기
const findMenuByEngNm = (items: MenuItem[]): MenuItem | null => {
for (const item of items) {
if (item.urlPath === activeTabId) {
return item;
}
if (item.children) {
const found = findMenuByEngNm(item.children);
if (found) return found;
}
useEffect(() => {
if (menuListData) {
setMenuList(menuListData);
setMenuListForGrid(menuListData);
}
return null;
};
}, [menuListData, setMenuList, setMenuListForGrid]);
const activeMenu = findMenuByEngNm(menuList);
// 새로고침 후 persist로 앱 탭만 복원된 경우 rsone 그리드 스토어에 탭을 맞춤
useEffect(() => {
if (!menuList) return;
const appTabs = getTabStoreState().tabs;
if (appTabs.length === 0) return;
if (getGridTabStoreState().tabs.length > 0) return;
if (activeMenu?.urlPath) {
return activeMenu.urlPath;
}
}
for (const tab of appTabs) {
getGridTabStoreState().addTab(tab.menuItem);
}
// 탭에서 직접 찾기
if (activeTabId) {
const currentTabs = getTabStoreState().tabs;
const activeTab = currentTabs.find((t) => t.id === activeTabId);
if (activeTab?.menuItem?.urlPath) {
return activeTab.menuItem.urlPath;
}
}
const activeId = getTabStoreState().activeTabId;
if (activeId) {
const activeTab = appTabs.find((t) => t.id === activeId);
const gridActiveId = activeTab?.menuItem.menuEngNm;
if (gridActiveId) {
getGridTabStoreState().setActiveTab(gridActiveId);
}
}
}, [menuList, tabs]);
// currentMenuInfo에서 가져오기
if (currentMenuInfo?.urlPath) {
return currentMenuInfo.urlPath;
}
useEffect(() => {
if (!pathname) return;
// 기본값으로 pathname 사용
return pathname ?? "";
}, [tabs.length, activeTabId, menuList, currentMenuInfo, pathname]);
// pathname이 변경될 때만 실행되도록 tabs를 직접 가져옴
const currentTabs = getTabStoreState().tabs;
const tab = currentTabs.find((t) => t.path === pathname);
if (tab) {
setActiveTab(tab.id);
const gridActiveId = tab.menuItem.menuEngNm;
if (gridActiveId) {
setActiveTabForGrid(gridActiveId);
}
}
}, [pathname, setActiveTab, setActiveTabForGrid]);
const handleMenuClick = useCallback((item: MenuItem) => {
setCurrentMenuInfo(item);
const addTabResult: boolean = addTab(item);
// packages/rsone의 스토어에도 동기화
if (addTabResult) {
addTabForGrid(item);
}
// 활성 탭의 메뉴를 찾아서 urlPath를 가져옴. 탭이 전부 닫히면 ''를 넘겨 LNB에서 메뉴 강조 제거
const currentPath = useMemo(() => {
if (tabs.length === 0) return "";
if (!addTabResult) {
alert(`최대 추가 할 수 있는 화면 수를 초과 하였습니다.
if (activeTabId && menuList) {
// menuList에서 활성 탭의 메뉴 찾기
const findMenuByEngNm = (items: MenuItem[]): MenuItem | null => {
for (const item of items) {
if (item.urlPath === activeTabId) {
return item;
}
if (item.children) {
const found = findMenuByEngNm(item.children);
if (found) return found;
}
}
return null;
};
const activeMenu = findMenuByEngNm(menuList);
if (activeMenu?.urlPath) {
return activeMenu.urlPath;
}
}
// 탭에서 직접 찾기
if (activeTabId) {
const currentTabs = getTabStoreState().tabs;
const activeTab = currentTabs.find((t) => t.id === activeTabId);
if (activeTab?.menuItem?.urlPath) {
return activeTab.menuItem.urlPath;
}
}
// currentMenuInfo에서 가져오기
if (currentMenuInfo?.urlPath) {
return currentMenuInfo.urlPath;
}
// 기본값으로 pathname 사용
return pathname ?? "";
}, [tabs.length, activeTabId, menuList, currentMenuInfo, pathname]);
const handleMenuClick = useCallback(
(item: MenuItem) => {
setCurrentMenuInfo(item);
const addTabResult: boolean = addTab(item);
// packages/rsone의 스토어에도 동기화
if (addTabResult) {
addTabForGrid(item);
}
if (!addTabResult) {
alert(`최대 추가 할 수 있는 화면 수를 초과 하였습니다.
기존 화면을 닫은 후 이용해주세요.`);
}
}, [setCurrentMenuInfo, addTab, addTabForGrid, alert]);
}
},
[setCurrentMenuInfo, addTab, addTabForGrid, alert],
);
return (
<BaseLayout
queryClient={queryClient}
currentPath={currentPath}
handleMenuClick={handleMenuClick}
>
<StatisticsPageLayout>{children}</StatisticsPageLayout>
</BaseLayout>
);
return (
<BaseLayout
queryClient={queryClient}
currentPath={currentPath}
handleMenuClick={handleMenuClick}
>
<StatisticsPageLayout>{children}</StatisticsPageLayout>
</BaseLayout>
);
}
+13 -3
View File
@@ -1,13 +1,18 @@
import { AxiosError } from "axios";
import {
ENDPOINT,
JWT_EXPIRATION_TIME,
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
ENDPOINT,
JWT_EXPIRATION_TIME,
} from "@/shared/config";
import type { LoginService, ILogin } from "../model/login-interface";
import { getCookie } from "@/shared/lib";
import { tokenManager } from "../lib/token-manager";
import type { ILogin,LoginService } from "../model/login-interface";
import { clearTabStorePersist } from "@/store/tab-store";
export class LoginApiService implements LoginService {
private static instance: LoginApiService;
@@ -39,6 +44,11 @@ export class LoginApiService implements LoginService {
throw new Error("Missing required authentication tokens");
}
const prevTokenId = getCookie(AUTH_USER_ID);
if (prevTokenId !== undefined && prevTokenId !== tokenId) {
clearTabStorePersist();
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
tokenManager.setupRefreshTimer(
@@ -1,5 +1,7 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config";
import { getCookie, setCookie, removeCookie } from "@/shared/lib";
import { getCookie, removeCookie,setCookie } from "@/shared/lib";
import { clearTabStorePersist } from "@/store/tab-store";
export class TokenManager {
private static instance: TokenManager;
@@ -74,6 +76,7 @@ export class TokenManager {
this.refreshTimer = null;
}
clearTabStorePersist();
this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID);
}
+153 -83
View File
@@ -1,8 +1,10 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { MenuItem } from "@/entities/menu";
export interface Tab {
id: string; // 고유 ID (menuEngNm)
id: string; // 고유 ID (urlPath)
label: string; // 탭에 표시될 이름
path: string; // 메뉴 경로
urlPath: string; // url 경로
@@ -22,96 +24,164 @@ interface TabStore {
closeOtherTabs: (tabId: string) => void;
}
const useTabStoreHook = create<TabStore>((set, get) => ({
tabs: [],
activeTabId: null,
menuList: null,
addTab: (menuItem: MenuItem) => {
const { tabs, menuList } = get();
const tabId = menuItem.urlPath || '';
export const TAB_STORE_PERSIST_KEY = "st-tab-store";
const TAB_STORE_VERSION = 1;
const MAX_TABS = 10;
// 이미 존재하는 탭인지 확인
const existingTab = tabs.find((tab) => tab.id === tabId);
function tabForPersist(tab: Tab): Tab {
return {
...tab,
menuItem: { ...tab.menuItem, children: null },
};
}
if (existingTab) {
// 이미 존재하면 활성화만
set({ activeTabId: tabId });
return true;
}
function sanitizePersistedTabsAndActive(
tabs: Tab[],
activeTabId: string | null,
): { tabs: Tab[]; activeTabId: string | null } {
let nextTabs = Array.isArray(tabs) ? tabs : [];
if (nextTabs.length > MAX_TABS) nextTabs = nextTabs.slice(0, MAX_TABS);
if (tabs.length < 10) {
const newTab: Tab = {
id: tabId,
label: menuItem.menuKornNm,
path: menuItem.menuPath,
urlPath: menuItem.urlPath,
parentEngNm: menuList?.find((m) => m.menuCd === menuItem.parentId)?.menuEngNm || '',
menuItem,
};
set({
tabs: [...tabs, newTab],
activeTabId: tabId,
});
if (nextTabs.length === 0) {
return { tabs: [], activeTabId: null };
}
return true;
} else {
return false;
}
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;
}
// 새 탭 추가
},
removeTab: (tabId: string): void => {
const { tabs, activeTabId } = get();
const newTabs = tabs.filter((tab) => tab.id !== tabId);
return { tabs: nextTabs, activeTabId: nextActive };
}
// 닫는 탭이 현재 활성 탭이면 다른 탭으로 전환
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,
});
},
setActiveTab: (tabId: string) => {
set({ activeTabId: tabId });
},
setMenuList: (menuList: MenuItem[] | null) => {
set({ menuList });
},
closeAllTabs: () => {
set({
const useTabStoreHook = create<TabStore>()(
persist(
(set, get) => ({
tabs: [],
activeTabId: null,
});
},
closeOtherTabs: (tabId: string) => {
const { tabs } = get();
const tabToKeep = tabs.find((tab) => tab.id === tabId);
menuList: null,
addTab: (menuItem: MenuItem) => {
const { tabs, menuList } = get();
const tabId = menuItem.urlPath || "";
if (tabToKeep) {
set({
tabs: [tabToKeep],
activeTabId: tabId,
});
}
},
}));
const existingTab = tabs.find((tab) => tab.id === tabId);
if (existingTab) {
set({ activeTabId: tabId });
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,
};
set({
tabs: [...tabs, newTab],
activeTabId: tabId,
});
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,
});
},
setActiveTab: (tabId: string) => {
set({ activeTabId: tabId });
},
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,
});
}
},
}),
{
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,
};
},
},
),
);
export const useTabStore = useTabStoreHook;
export const getTabStoreState = () => useTabStoreHook.getState();
export const getTabStoreState = () => useTabStoreHook.getState();
// 로그아웃 또는 사용자 전환 시 로컬 탭 스냅샷 제거 및 메모리 상태 초기화
export function clearTabStorePersist(): void {
useTabStoreHook.getState().closeAllTabs();
useTabStoreHook.persist.clearStorage();
}