diff --git a/frontend/components/layout/TabContent.tsx b/frontend/components/layout/TabContent.tsx new file mode 100644 index 00000000..5d0ce2fd --- /dev/null +++ b/frontend/components/layout/TabContent.tsx @@ -0,0 +1,247 @@ +"use client"; + +import React, { useRef, useEffect, useCallback } from "react"; +import { useTabStore, selectTabs, selectActiveTabId } from "@/stores/tabStore"; +import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page"; +import { AdminPageRenderer } from "./AdminPageRenderer"; +import { EmptyDashboard } from "./EmptyDashboard"; +import { TabIdProvider } from "@/contexts/TabIdContext"; +import { registerModalPortal } from "@/lib/modalPortalRef"; +import ScreenModal from "@/components/common/ScreenModal"; +import { + saveTabCacheImmediate, + loadTabCache, + captureAllScrollPositions, + restoreAllScrollPositions, + getElementPath, + captureFormState, + restoreFormState, + clearTabCache, +} from "@/lib/tabStateCache"; + +export function TabContent() { + const tabs = useTabStore(selectTabs); + const activeTabId = useTabStore(selectActiveTabId); + const refreshKeys = useTabStore((s) => s.refreshKeys); + + // 한 번이라도 활성화된 탭만 마운트 (지연 마운트) + const mountedTabIdsRef = useRef>(new Set()); + + // 각 탭의 스크롤 컨테이너 ref + const scrollRefsMap = useRef>(new Map()); + + // 이전 활성 탭 ID 추적 + const prevActiveTabIdRef = useRef(null); + + // 활성 탭의 스크롤 위치를 실시간 추적 (display:none 전에 캡처하기 위함) + // Map> - 탭 내 여러 스크롤 영역을 각각 추적 + const lastScrollMapRef = useRef>>(new Map()); + // 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함) + const pathCacheRef = useRef>(new WeakMap()); + + if (activeTabId) { + mountedTabIdsRef.current.add(activeTabId); + } + + // 활성 탭의 scroll 이벤트를 감지하여 요소별 위치를 실시간 저장 + useEffect(() => { + if (!activeTabId) return; + + const container = scrollRefsMap.current.get(activeTabId); + if (!container) return; + + const handleScroll = (e: Event) => { + const target = e.target as HTMLElement; + + let path = pathCacheRef.current.get(target); + if (path === undefined) { + path = getElementPath(target, container); + pathCacheRef.current.set(target, path); + } + if (path === null) return; + + let tabMap = lastScrollMapRef.current.get(activeTabId); + if (!tabMap) { + tabMap = new Map(); + lastScrollMapRef.current.set(activeTabId, tabMap); + } + + if (target.scrollTop > 0 || target.scrollLeft > 0) { + tabMap.set(path, { top: target.scrollTop, left: target.scrollLeft }); + } else { + tabMap.delete(path); + } + }; + + container.addEventListener("scroll", handleScroll, true); + return () => container.removeEventListener("scroll", handleScroll, true); + }, [activeTabId]); + + // 복원 관련 cleanup ref + const scrollRestoreCleanupRef = useRef<(() => void) | null>(null); + const formRestoreCleanupRef = useRef<(() => void) | null>(null); + + // 탭 전환 시: 이전 탭 상태 캐싱, 새 탭 상태 복원 + useEffect(() => { + // 이전 복원 작업 취소 + if (scrollRestoreCleanupRef.current) { + scrollRestoreCleanupRef.current(); + scrollRestoreCleanupRef.current = null; + } + if (formRestoreCleanupRef.current) { + formRestoreCleanupRef.current(); + formRestoreCleanupRef.current = null; + } + + const prevId = prevActiveTabIdRef.current; + + // 이전 활성 탭의 스크롤 + 폼 상태 저장 + if (prevId && prevId !== activeTabId) { + const tabMap = lastScrollMapRef.current.get(prevId); + const scrollPositions = + tabMap && tabMap.size > 0 + ? Array.from(tabMap.entries()).map(([path, pos]) => ({ path, ...pos })) + : undefined; + const prevEl = scrollRefsMap.current.get(prevId); + const formFields = captureFormState(prevEl ?? null); + saveTabCacheImmediate(prevId, { + ...(scrollPositions && { scrollPositions }), + ...(formFields && { domFormFields: formFields }), + }); + } + + // 새 활성 탭의 스크롤 + 폼 상태 복원 + if (activeTabId) { + const cache = loadTabCache(activeTabId); + if (cache) { + const el = scrollRefsMap.current.get(activeTabId); + if (cache.scrollPositions) { + const cleanup = restoreAllScrollPositions(el ?? null, cache.scrollPositions); + if (cleanup) scrollRestoreCleanupRef.current = cleanup; + } + if (cache.domFormFields) { + const cleanup = restoreFormState(el ?? null, cache.domFormFields ?? null); + if (cleanup) formRestoreCleanupRef.current = cleanup; + } + } + } + + prevActiveTabIdRef.current = activeTabId; + }, [activeTabId]); + + // F5 새로고침 직전에 활성 탭의 스크롤/폼 상태를 저장 + useEffect(() => { + const handleBeforeUnload = () => { + const currentActiveId = prevActiveTabIdRef.current; + if (!currentActiveId) return; + + const el = scrollRefsMap.current.get(currentActiveId); + // 활성 탭은 display:block이므로 DOM에서 직접 캡처 (가장 정확) + const scrollPositions = captureAllScrollPositions(el ?? null); + // DOM 캡처 실패 시 실시간 추적 데이터 fallback + const tabMap = lastScrollMapRef.current.get(currentActiveId); + const trackedPositions = + !scrollPositions && tabMap && tabMap.size > 0 + ? Array.from(tabMap.entries()).map(([path, pos]) => ({ path, ...pos })) + : undefined; + + const finalPositions = scrollPositions || trackedPositions; + const formFields = captureFormState(el ?? null); + saveTabCacheImmediate(currentActiveId, { + ...(finalPositions && { scrollPositions: finalPositions }), + ...(formFields && { domFormFields: formFields }), + }); + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + if (scrollRestoreCleanupRef.current) scrollRestoreCleanupRef.current(); + if (formRestoreCleanupRef.current) formRestoreCleanupRef.current(); + }; + }, []); + + // 탭 닫기 시 캐시 정리 (tabs 배열 변화 감지) + useEffect(() => { + const currentTabIds = new Set(tabs.map((t) => t.id)); + const mountedIds = mountedTabIdsRef.current; + + mountedIds.forEach((id) => { + if (!currentTabIds.has(id)) { + clearTabCache(id); + scrollRefsMap.current.delete(id); + mountedIds.delete(id); + } + }); + }, [tabs]); + + const setScrollRef = useCallback((tabId: string, el: HTMLDivElement | null) => { + scrollRefsMap.current.set(tabId, el); + }, []); + + // 포탈 컨테이너 ref callback: 전역 레퍼런스에 등록 + const portalRefCallback = useCallback((el: HTMLDivElement | null) => { + registerModalPortal(el); + }, []); + + if (tabs.length === 0) { + return ; + } + + const tabLookup = new Map(tabs.map((t) => [t.id, t])); + const stableIds = Array.from(mountedTabIdsRef.current); + + return ( +
+ {stableIds.map((tabId) => { + const tab = tabLookup.get(tabId); + if (!tab) return null; + + const isActive = tab.id === activeTabId; + const refreshKey = refreshKeys[tab.id] || 0; + + return ( +
setScrollRef(tab.id, el)} + className="absolute inset-0 overflow-hidden" + style={{ display: isActive ? "block" : "none" }} + > + + + + +
+ ); + })} +
+ ); +} + +function TabPageRenderer({ + tab, + refreshKey, +}: { + tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string }; + refreshKey: number; +}) { + if (tab.type === "screen" && tab.screenId != null) { + return ( + + ); + } + + if (tab.type === "admin" && tab.adminUrl) { + return ( +
+ +
+ ); + } + + return null; +} diff --git a/frontend/lib/tabStateCache.ts b/frontend/lib/tabStateCache.ts new file mode 100644 index 00000000..bb33de3d --- /dev/null +++ b/frontend/lib/tabStateCache.ts @@ -0,0 +1,428 @@ +/** + * 탭별 상태를 sessionStorage에 캐싱/복원하는 엔진. + * F5 새로고침 시 비활성 탭의 데이터를 보존한다. + * + * 캐싱 키 구조: `tab-cache-{tabId}` + * 값: JSON 직렬화된 TabCacheData + */ + +const CACHE_PREFIX = "tab-cache-"; + +// --- 캐싱할 상태 구조 --- + +export interface FormFieldSnapshot { + idx: number; + tag: string; + type: string; + name: string; + id: string; + value?: string; + checked?: boolean; +} + +/** 개별 스크롤 요소의 위치 스냅샷 (DOM 경로 기반) */ +export interface ScrollSnapshot { + /** 탭 컨테이너 기준 자식 인덱스 경로 (예: "0/2/1/3") */ + path: string; + top: number; + left: number; +} + +export interface TabCacheData { + /** DOM 폼 필드 스냅샷 (F5 복원용) */ + domFormFields?: FormFieldSnapshot[]; + + /** 다중 스크롤 위치 (split panel 등 여러 스크롤 영역 지원) */ + scrollPositions?: ScrollSnapshot[]; + + /** 캐싱 시각 */ + cachedAt: number; +} + +// --- 공개 API --- + +/** + * 탭 상태를 sessionStorage에 즉시 저장 + */ +export function saveTabCacheImmediate(tabId: string, data: Partial>): void { + if (typeof window === "undefined") return; + + try { + const key = CACHE_PREFIX + tabId; + const current = loadTabCache(tabId); + const merged: TabCacheData = { + ...current, + ...data, + cachedAt: Date.now(), + }; + sessionStorage.setItem(key, JSON.stringify(merged)); + } catch (e) { + console.warn("[TabCache] 저장 실패:", tabId, e); + } +} + +/** + * 탭 상태를 sessionStorage에서 로드 + */ +export function loadTabCache(tabId: string): TabCacheData | null { + if (typeof window === "undefined") return null; + + try { + const key = CACHE_PREFIX + tabId; + const raw = sessionStorage.getItem(key); + if (!raw) return null; + return JSON.parse(raw) as TabCacheData; + } catch { + return null; + } +} + +/** + * 특정 탭의 캐시 삭제 + */ +export function clearTabCache(tabId: string): void { + if (typeof window === "undefined") return; + + try { + sessionStorage.removeItem(CACHE_PREFIX + tabId); + } catch { + // ignore + } +} + +/** + * 모든 탭 캐시 삭제 + */ +export function clearAllTabCaches(): void { + if (typeof window === "undefined") return; + + try { + const keysToRemove: string[] = []; + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if (key?.startsWith(CACHE_PREFIX)) { + keysToRemove.push(key); + } + } + keysToRemove.forEach((key) => sessionStorage.removeItem(key)); + } catch { + // ignore + } +} + +// ============================================================ +// DOM 폼 상태 캡처/복원 +// ============================================================ + +/** + * 컨테이너 내의 모든 폼 요소 상태를 스냅샷으로 캡처 + */ +export function captureFormState(container: HTMLElement | null): FormFieldSnapshot[] | null { + if (!container) return null; + + const fields: FormFieldSnapshot[] = []; + const elements = container.querySelectorAll( + "input, textarea, select", + ); + + elements.forEach((el, idx) => { + const field: FormFieldSnapshot = { + idx, + tag: el.tagName.toLowerCase(), + type: (el as HTMLInputElement).type || "", + name: el.name || "", + id: el.id || "", + }; + + if (el instanceof HTMLInputElement) { + if (el.type === "checkbox" || el.type === "radio") { + field.checked = el.checked; + } else if (el.type !== "file" && el.type !== "password") { + field.value = el.value; + } + } else if (el instanceof HTMLTextAreaElement) { + field.value = el.value; + } else if (el instanceof HTMLSelectElement) { + field.value = el.value; + } + + fields.push(field); + }); + + return fields.length > 0 ? fields : null; +} + +/** + * 단일 폼 필드에 값을 복원하고 React onChange를 트리거 + */ +function applyFieldValue(el: Element, field: FormFieldSnapshot): void { + if (el instanceof HTMLInputElement) { + if (field.type === "checkbox" || field.type === "radio") { + if (el.checked !== field.checked) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "checked")?.set; + setter?.call(el, field.checked); + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + } + } else if (field.value !== undefined && el.value !== field.value) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(el, field.value); + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + } + } else if (el instanceof HTMLTextAreaElement) { + if (field.value !== undefined && el.value !== field.value) { + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + setter?.call(el, field.value); + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + } + } else if (el instanceof HTMLSelectElement) { + if (field.value !== undefined && el.value !== field.value) { + el.value = field.value; + el.dispatchEvent(new Event("change", { bubbles: true })); + } + } +} + +/** + * 컨테이너에서 스냅샷 필드에 해당하는 DOM 요소를 찾는다 + */ +function findFieldElement( + container: HTMLElement, + field: FormFieldSnapshot, + allElements: NodeListOf, +): Element | null { + // 1순위: id로 검색 + if (field.id) { + try { + const el = container.querySelector(`#${CSS.escape(field.id)}`); + if (el) return el; + } catch { + /* ignore */ + } + } + // 2순위: name으로 검색 (유일한 경우) + if (field.name) { + try { + const candidates = container.querySelectorAll(`[name="${CSS.escape(field.name)}"]`); + if (candidates.length === 1) return candidates[0]; + } catch { + /* ignore */ + } + } + // 3순위: 인덱스 + tag/type 일치 검증 + if (field.idx < allElements.length) { + const candidate = allElements[field.idx]; + if (candidate.tagName.toLowerCase() === field.tag && ((candidate as HTMLInputElement).type || "") === field.type) { + return candidate; + } + } + return null; +} + +/** + * 캡처한 폼 스냅샷을 DOM에 복원하고 React onChange를 트리거. + * 폼 필드가 아직 DOM에 없으면 폴링으로 대기한다. + * 반환된 cleanup 함수를 호출하면 대기를 취소할 수 있다. + */ +export function restoreFormState( + container: HTMLElement | null, + fields: FormFieldSnapshot[] | null, +): (() => void) | undefined { + if (!container || !fields || fields.length === 0) return undefined; + + let cleaned = false; + + const cleanup = () => { + if (cleaned) return; + cleaned = true; + clearInterval(pollId); + clearTimeout(timeoutId); + }; + + const tryRestore = (): boolean => { + const elements = container.querySelectorAll( + "input, textarea, select", + ); + if (elements.length === 0) return false; + + let restoredCount = 0; + for (const field of fields) { + const el = findFieldElement(container, field, elements); + if (el) { + applyFieldValue(el, field); + restoredCount++; + } + } + return restoredCount > 0; + }; + + // 즉시 시도 + if (tryRestore()) return undefined; + + // 다음 프레임에서 재시도 + requestAnimationFrame(() => { + if (cleaned) return; + if (tryRestore()) { + cleanup(); + return; + } + }); + + // 폼 필드가 DOM에 나타날 때까지 폴링 (API 데이터 로드 대기) + const pollId = setInterval(() => { + if (tryRestore()) cleanup(); + }, 100); + + // 최대 5초 대기 후 포기 + const timeoutId = setTimeout(() => { + tryRestore(); + cleanup(); + }, 5000); + + return cleanup; +} + +// ============================================================ +// DOM 경로 기반 스크롤 위치 캡처/복원 (다중 스크롤 영역 지원) +// ============================================================ + +/** + * 컨테이너 기준 자식 인덱스 경로를 생성한다. + * 예: container > div(2번째) > div(1번째) > div(3번째) → "2/1/3" + */ +export function getElementPath(element: HTMLElement, container: HTMLElement): string | null { + const indices: number[] = []; + let current: HTMLElement | null = element; + + while (current && current !== container) { + const parent: HTMLElement | null = current.parentElement; + if (!parent) return null; + + const children = parent.children; + let idx = -1; + for (let i = 0; i < children.length; i++) { + if (children[i] === current) { + idx = i; + break; + } + } + if (idx === -1) return null; + + indices.unshift(idx); + current = parent; + } + + if (current !== container) return null; + return indices.join("/"); +} + +/** + * 경로 문자열로 컨테이너 내의 요소를 찾는다. + */ +function findElementByPath(container: HTMLElement, path: string): HTMLElement | null { + if (!path) return container; + + const indices = path.split("/").map(Number); + let current: HTMLElement = container; + + for (const idx of indices) { + if (!current.children || idx >= current.children.length) return null; + const child = current.children[idx]; + if (!(child instanceof HTMLElement)) return null; + current = child; + } + + return current; +} + +/** + * 컨테이너 하위의 모든 스크롤된 요소를 찾아 경로와 함께 캡처한다. + * F5 직전 (beforeunload)에 호출 - 활성 탭은 display:block이므로 DOM 값이 정확하다. + */ +export function captureAllScrollPositions(container: HTMLElement | null): ScrollSnapshot[] | undefined { + if (!container) return undefined; + + const snapshots: ScrollSnapshot[] = []; + const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT); + let node: Node | null; + + while ((node = walker.nextNode())) { + const el = node as HTMLElement; + if (el.scrollTop > 0 || el.scrollLeft > 0) { + const path = getElementPath(el, container); + if (path) { + snapshots.push({ path, top: el.scrollTop, left: el.scrollLeft }); + } + } + } + + return snapshots.length > 0 ? snapshots : undefined; +} + +/** + * 다중 스크롤 위치를 DOM 경로 기반으로 복원한다. + * 컨텐츠가 아직 로드되지 않았을 수 있으므로 폴링으로 대기한다. + */ +export function restoreAllScrollPositions( + container: HTMLElement | null, + positions?: ScrollSnapshot[], +): (() => void) | undefined { + if (!container || !positions || positions.length === 0) return undefined; + + let cleaned = false; + + const cleanup = () => { + if (cleaned) return; + cleaned = true; + clearInterval(pollId); + clearTimeout(timeoutId); + }; + + const tryRestore = (): boolean => { + let restoredCount = 0; + + for (const pos of positions) { + const el = findElementByPath(container, pos.path); + if (!el) continue; + + if (el.scrollHeight >= pos.top + el.clientHeight) { + el.scrollTop = pos.top; + el.scrollLeft = pos.left; + restoredCount++; + } + } + + return restoredCount === positions.length; + }; + + if (tryRestore()) return undefined; + + requestAnimationFrame(() => { + if (cleaned) return; + if (tryRestore()) { + cleanup(); + return; + } + }); + + const pollId = setInterval(() => { + if (tryRestore()) cleanup(); + }, 50); + + // 최대 5초 대기 후 강제 복원 + const timeoutId = setTimeout(() => { + for (const pos of positions) { + const el = findElementByPath(container, pos.path); + if (el) { + el.scrollTop = pos.top; + el.scrollLeft = pos.left; + } + } + cleanup(); + }, 5000); + + return cleanup; +} + diff --git a/frontend/stores/tabStore.ts b/frontend/stores/tabStore.ts new file mode 100644 index 00000000..ea0b8c5b --- /dev/null +++ b/frontend/stores/tabStore.ts @@ -0,0 +1,224 @@ +"use client"; + +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; +import { clearTabCache } from "@/lib/tabStateCache"; + +// --- 타입 정의 --- + +export type AppMode = "user" | "admin"; + +export interface Tab { + id: string; + type: "screen" | "admin"; + title: string; + screenId?: number; + menuObjid?: number; + adminUrl?: string; +} + +interface ModeTabData { + tabs: Tab[]; + activeTabId: string | null; +} + +interface TabState { + mode: AppMode; + user: ModeTabData; + admin: ModeTabData; + refreshKeys: Record; + + setMode: (mode: AppMode) => void; + + openTab: (tab: Omit, insertIndex?: number) => void; + closeTab: (tabId: string) => void; + switchTab: (tabId: string) => void; + refreshTab: (tabId: string) => void; + + closeOtherTabs: (tabId: string) => void; + closeTabsToLeft: (tabId: string) => void; + closeTabsToRight: (tabId: string) => void; + closeAllTabs: () => void; + + updateTabOrder: (fromIndex: number, toIndex: number) => void; +} + +// --- 헬퍼 함수 --- + +function generateTabId(tab: Omit): string { + if (tab.type === "screen" && tab.screenId != null) { + return `tab-screen-${tab.screenId}-${tab.menuObjid ?? 0}`; + } + if (tab.type === "admin" && tab.adminUrl) { + return `tab-admin-${tab.adminUrl.replace(/[^a-zA-Z0-9]/g, "-")}`; + } + return `tab-${Date.now()}`; +} + +function findDuplicateTab(tabs: Tab[], newTab: Omit): Tab | undefined { + if (newTab.type === "screen" && newTab.screenId != null) { + return tabs.find( + (t) => t.type === "screen" && t.screenId === newTab.screenId && t.menuObjid === newTab.menuObjid, + ); + } + if (newTab.type === "admin" && newTab.adminUrl) { + return tabs.find((t) => t.type === "admin" && t.adminUrl === newTab.adminUrl); + } + return undefined; +} + +function getNextActiveTabId(tabs: Tab[], closedTabId: string, currentActiveId: string | null): string | null { + if (currentActiveId !== closedTabId) return currentActiveId; + const idx = tabs.findIndex((t) => t.id === closedTabId); + if (idx === -1) return null; + const remaining = tabs.filter((t) => t.id !== closedTabId); + if (remaining.length === 0) return null; + if (idx > 0) return remaining[Math.min(idx - 1, remaining.length - 1)].id; + return remaining[0].id; +} + +// 현재 모드의 데이터 키 반환 +function modeKey(state: TabState): AppMode { + return state.mode; +} + +// --- 셀렉터 (컴포넌트에서 사용) --- + +export function selectTabs(state: TabState): Tab[] { + return state[state.mode].tabs; +} + +export function selectActiveTabId(state: TabState): string | null { + return state[state.mode].activeTabId; +} + +// --- Store --- + +const EMPTY_MODE: ModeTabData = { tabs: [], activeTabId: null }; + +export const useTabStore = create()( + devtools( + persist( + (set, get) => ({ + mode: "user" as AppMode, + user: { ...EMPTY_MODE }, + admin: { ...EMPTY_MODE }, + refreshKeys: {}, + + setMode: (mode) => { + set({ mode }); + }, + + openTab: (tabData, insertIndex) => { + const mk = modeKey(get()); + const modeData = get()[mk]; + const existing = findDuplicateTab(modeData.tabs, tabData); + + if (existing) { + set({ [mk]: { ...modeData, activeTabId: existing.id } }); + return; + } + + const id = generateTabId(tabData); + const newTab: Tab = { ...tabData, id }; + const newTabs = [...modeData.tabs]; + + if (insertIndex != null && insertIndex >= 0 && insertIndex <= newTabs.length) { + newTabs.splice(insertIndex, 0, newTab); + } else { + newTabs.push(newTab); + } + + set({ [mk]: { tabs: newTabs, activeTabId: id } }); + }, + + closeTab: (tabId) => { + clearTabCache(tabId); + const mk = modeKey(get()); + const modeData = get()[mk]; + const nextActive = getNextActiveTabId(modeData.tabs, tabId, modeData.activeTabId); + const newTabs = modeData.tabs.filter((t) => t.id !== tabId); + const { [tabId]: _, ...restKeys } = get().refreshKeys; + set({ [mk]: { tabs: newTabs, activeTabId: nextActive }, refreshKeys: restKeys }); + }, + + switchTab: (tabId) => { + const mk = modeKey(get()); + const modeData = get()[mk]; + set({ [mk]: { ...modeData, activeTabId: tabId } }); + }, + + refreshTab: (tabId) => { + set((state) => ({ + refreshKeys: { ...state.refreshKeys, [tabId]: (state.refreshKeys[tabId] || 0) + 1 }, + })); + }, + + closeOtherTabs: (tabId) => { + const mk = modeKey(get()); + const modeData = get()[mk]; + modeData.tabs.filter((t) => t.id !== tabId).forEach((t) => clearTabCache(t.id)); + set({ [mk]: { tabs: modeData.tabs.filter((t) => t.id === tabId), activeTabId: tabId } }); + }, + + closeTabsToLeft: (tabId) => { + const mk = modeKey(get()); + const modeData = get()[mk]; + const idx = modeData.tabs.findIndex((t) => t.id === tabId); + if (idx === -1) return; + modeData.tabs.slice(0, idx).forEach((t) => clearTabCache(t.id)); + set({ [mk]: { tabs: modeData.tabs.slice(idx), activeTabId: tabId } }); + }, + + closeTabsToRight: (tabId) => { + const mk = modeKey(get()); + const modeData = get()[mk]; + const idx = modeData.tabs.findIndex((t) => t.id === tabId); + if (idx === -1) return; + modeData.tabs.slice(idx + 1).forEach((t) => clearTabCache(t.id)); + set({ [mk]: { tabs: modeData.tabs.slice(0, idx + 1), activeTabId: tabId } }); + }, + + closeAllTabs: () => { + const mk = modeKey(get()); + const modeData = get()[mk]; + modeData.tabs.forEach((t) => clearTabCache(t.id)); + set({ [mk]: { tabs: [], activeTabId: null } }); + }, + + updateTabOrder: (fromIndex, toIndex) => { + const mk = modeKey(get()); + const modeData = get()[mk]; + const newTabs = [...modeData.tabs]; + const [moved] = newTabs.splice(fromIndex, 1); + newTabs.splice(toIndex, 0, moved); + set({ [mk]: { ...modeData, tabs: newTabs } }); + }, + }), + { + name: "erp-tab-store", + storage: { + getItem: (name) => { + if (typeof window === "undefined") return null; + const raw = sessionStorage.getItem(name); + return raw ? JSON.parse(raw) : null; + }, + setItem: (name, value) => { + if (typeof window === "undefined") return; + sessionStorage.setItem(name, JSON.stringify(value)); + }, + removeItem: (name) => { + if (typeof window === "undefined") return; + sessionStorage.removeItem(name); + }, + }, + partialize: (state) => ({ + mode: state.mode, + user: state.user, + admin: state.admin, + }) as unknown as TabState, + }, + ), + { name: "TabStore" }, + ), +);