diff --git a/apps/st/app/(with-base-layout)/layout.tsx b/apps/st/app/(with-base-layout)/layout.tsx
index 98c126bf6..71343c00e 100644
--- a/apps/st/app/(with-base-layout)/layout.tsx
+++ b/apps/st/app/(with-base-layout)/layout.tsx
@@ -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 (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
}
diff --git a/apps/st/src/entities/auth/api/login-service.ts b/apps/st/src/entities/auth/api/login-service.ts
index c1968fe36..7fd87cc27 100644
--- a/apps/st/src/entities/auth/api/login-service.ts
+++ b/apps/st/src/entities/auth/api/login-service.ts
@@ -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(
diff --git a/apps/st/src/entities/auth/lib/token-manager.ts b/apps/st/src/entities/auth/lib/token-manager.ts
index dd9aea352..60cd47de5 100644
--- a/apps/st/src/entities/auth/lib/token-manager.ts
+++ b/apps/st/src/entities/auth/lib/token-manager.ts
@@ -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);
}
diff --git a/apps/st/src/store/tab-store.ts b/apps/st/src/store/tab-store.ts
index ace855808..640cada97 100644
--- a/apps/st/src/store/tab-store.ts
+++ b/apps/st/src/store/tab-store.ts
@@ -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((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()(
+ 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
+ > | 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();
\ No newline at end of file
+export const getTabStoreState = () => useTabStoreHook.getState();
+
+// 로그아웃 또는 사용자 전환 시 로컬 탭 스냅샷 제거 및 메모리 상태 초기화
+export function clearTabStorePersist(): void {
+ useTabStoreHook.getState().closeAllTabs();
+ useTabStoreHook.persist.clearStorage();
+}