From 7acdd852a51b4f455e005d69c2c741204ae53d49 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Fri, 27 Feb 2026 14:21:15 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20F5=20=EC=83=88=EB=A1=9C=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20=EC=8B=9C=20=EB=8B=A4=EC=A4=91=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EC=98=81=EC=97=AD=20=EC=9C=84=EC=B9=98=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5/=EB=B3=B5=EC=9B=90=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit split panel 등 여러 스크롤 영역이 있는 화면에서 F5 새로고침 시 우측 패널 스크롤 위치가 복원되지 않던 문제 해결. - DOM 경로 기반 다중 스크롤 위치 캡처/복원 (ScrollSnapshot) - 실시간 스크롤 추적을 요소별 Map으로 전환 - 미사용 레거시 단일 스크롤 함수 제거 (약 130줄 정리) Made-with: Cursor --- frontend/components/layout/TabContent.tsx | 247 +++++++++++++ frontend/lib/tabStateCache.ts | 428 ++++++++++++++++++++++ frontend/stores/tabStore.ts | 224 +++++++++++ 3 files changed, 899 insertions(+) create mode 100644 frontend/components/layout/TabContent.tsx create mode 100644 frontend/lib/tabStateCache.ts create mode 100644 frontend/stores/tabStore.ts 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" }, + ), +); From d04dc4c05030284f0fb8941e91822af91d634f0d Mon Sep 17 00:00:00 2001 From: syc0123 Date: Fri, 27 Feb 2026 14:25:53 +0900 Subject: [PATCH 02/18] feat: Add Zustand for state management and enhance modal handling - Integrated Zustand for improved state management across components. - Updated modal components to handle visibility based on active tabs, ensuring better user experience. - Refactored various components to utilize the new tab store for managing active tab states. - Enhanced number formatting utility to streamline number and currency display across the application. Made-with: Cursor --- docs/ycshin-node/탭_시스템_설계.md | 241 +++++++++ .../app/(main)/screens/[screenId]/page.tsx | 29 +- frontend/app/layout.tsx | 3 +- frontend/components/common/ScreenModal.tsx | 14 +- .../components/layout/AdminPageRenderer.tsx | 109 ++++ frontend/components/layout/AppLayout.tsx | 173 ++++--- frontend/components/layout/EmptyDashboard.tsx | 23 + frontend/components/layout/TabBar.tsx | 467 ++++++++++++++++++ frontend/components/screen/EditModal.tsx | 13 +- .../screen/widgets/types/NumberWidget.tsx | 8 +- frontend/components/ui/alert-dialog.tsx | 79 ++- frontend/components/ui/dialog.tsx | 110 ++++- frontend/contexts/TabIdContext.tsx | 11 + frontend/lib/formatting/index.ts | 137 +++++ frontend/lib/formatting/rules.ts | 71 +++ frontend/lib/modalPortalRef.ts | 31 ++ .../AggregationWidgetComponent.tsx | 5 +- .../pivot-grid/utils/aggregation.ts | 12 +- .../pivot-grid/utils/pivotEngine.ts | 2 +- .../AggregationWidgetComponent.tsx | 5 +- .../components/v2-date/V2DateRenderer.tsx | 3 +- .../v2-pivot-grid/utils/aggregation.ts | 12 +- .../v2-pivot-grid/utils/pivotEngine.ts | 2 +- frontend/package-lock.json | 9 +- frontend/package.json | 3 +- 25 files changed, 1437 insertions(+), 135 deletions(-) create mode 100644 docs/ycshin-node/탭_시스템_설계.md create mode 100644 frontend/components/layout/AdminPageRenderer.tsx create mode 100644 frontend/components/layout/EmptyDashboard.tsx create mode 100644 frontend/components/layout/TabBar.tsx create mode 100644 frontend/contexts/TabIdContext.tsx create mode 100644 frontend/lib/formatting/index.ts create mode 100644 frontend/lib/formatting/rules.ts create mode 100644 frontend/lib/modalPortalRef.ts diff --git a/docs/ycshin-node/탭_시스템_설계.md b/docs/ycshin-node/탭_시스템_설계.md new file mode 100644 index 00000000..50ca2468 --- /dev/null +++ b/docs/ycshin-node/탭_시스템_설계.md @@ -0,0 +1,241 @@ +# 탭 시스템 아키텍처 및 구현 계획 + +## 1. 개요 + +사이드바 메뉴 클릭 시 `router.push()` 페이지 이동 방식에서 **탭 기반 멀티 화면 시스템**으로 전환한다. + +``` + ┌──────────────────────────┐ + │ Tab Data Layer (중앙) │ + API 응답 ────────→│ │ + │ 탭별 상태 저장소 │ + │ ├─ formData │ + │ ├─ selectedRows │ + │ ├─ scrollPosition │ + │ ├─ modalState │ + │ ├─ sortState │ + │ └─ cacheState │ + │ │ + │ 공통 규칙 엔진 │ + │ ├─ 날짜 포맷 규칙 │ + │ ├─ 숫자/통화 포맷 규칙 │ + │ ├─ 로케일 처리 규칙 │ + │ ├─ 유효성 검증 규칙 │ + │ └─ 데이터 타입 변환 규칙 │ + │ │ + │ F5 복원 / 캐시 관리 │ + │ (sessionStorage 중앙관리) │ + └────────────┬─────────────┘ + │ + 가공 완료된 데이터 + │ + ┌────────────────┼────────────────┐ + │ │ │ + 화면 A (경량) 화면 B (경량) 화면 C (경량) + 렌더링만 담당 렌더링만 담당 렌더링만 담당 +``` + +## 2. 레이어 구조 + +| 레이어 | 책임 | +|---|---| +| **Tab Data Layer** | 탭별 상태 보관, 캐시, 복원, 데이터 가공 | +| **공통 규칙 엔진** | 날짜/숫자/로케일 포맷, 유효성 검증 | +| **화면 컴포넌트** | 가공된 데이터를 받아서 렌더링만 담당 | + +## 3. 파일 구성 + +| 파일 | 역할 | +|---|---| +| `stores/tabStore.ts` | Zustand 기반 탭 상태 관리 | +| `components/layout/TabBar.tsx` | 탭 바 UI (드래그, 우클릭, 오버플로우) | +| `components/layout/TabContent.tsx` | 탭별 콘텐츠 렌더링 (컨테이너) | +| `components/layout/EmptyDashboard.tsx` | 탭 없을 때 안내 화면 | +| `components/layout/AppLayout.tsx` | 전체 레이아웃 (사이드바 + 탭 + 콘텐츠) | +| `lib/tabStateCache.ts` | 탭별 상태 캐싱 엔진 | +| `lib/formatting/rules.ts` | 포맷 규칙 정의 | +| `lib/formatting/index.ts` | formatDate, formatNumber, formatCurrency | +| `app/(main)/screens/[screenId]/page.tsx` | 화면별 렌더링 | + +## 4. 기술 스택 + +- Next.js 15, React 19, Zustand +- Tailwind CSS, shadcn/ui + +--- + +## 5. Phase 1: 탭 껍데기 + +### 5-1. Zustand 탭 Store (`stores/tabStore.ts`) +- [ ] zustand 직접 의존성 추가 +- [ ] Tab 인터페이스: id, type, title, screenId, menuObjid, adminUrl +- [ ] 탭 목록, 활성 탭 ID +- [ ] openTab, closeTab, switchTab, refreshTab +- [ ] closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs +- [ ] updateTabOrder (드래그 순서 변경) +- [ ] 중복 방지: 같은 탭이면 해당 탭으로 이동 +- [ ] 닫기 후 왼쪽 탭으로 이동, 왼쪽 없으면 오른쪽 +- [ ] sessionStorage 영속화 (persist middleware) +- [ ] 탭 ID 생성 규칙: V2 화면 `tab-{screenId}-{menuObjid}`, URL 탭 `tab-url-{menuObjid}` + +### 5-2. TabBar 컴포넌트 (`components/layout/TabBar.tsx`) +- [ ] 고정 너비 탭, 화면 너비에 맞게 동적 개수 +- [ ] 활성 탭: 새로고침 버튼 + X 버튼 +- [ ] 비활성 탭: X 버튼만 +- [ ] 오버플로우 시 +N 드롭다운 (ResizeObserver 감시) +- [ ] 드래그 순서 변경 (mousedown/move/up, DOM transform 직접 조작) +- [ ] 사이드바 메뉴 드래그 드롭 수신 (`application/tab-menu` 커스텀 데이터, 마우스 위치에 삽입) +- [ ] 우클릭 컨텍스트 메뉴 (새로고침/왼쪽닫기/오른쪽닫기/다른탭닫기/모든탭닫기) +- [ ] 휠 클릭: 탭 즉시 닫기 + +### 5-3. TabContent 컴포넌트 (`components/layout/TabContent.tsx`) +- [ ] display:none 방식 (비활성 탭 DOM 유지, 상태 보존) +- [ ] 지연 마운트 (한 번 활성화된 탭만 마운트) +- [ ] 안정적 순서 유지 (탭 순서 변경 시 리마운트 방지) +- [ ] 탭별 모달 격리 (DialogPortalContainerContext) +- [ ] tab.type === "screen" -> ScreenViewPageWrapper 임베딩 +- [ ] tab.type === "admin" -> 동적 import로 관리자 페이지 렌더링 + +### 5-4. EmptyDashboard 컴포넌트 (`components/layout/EmptyDashboard.tsx`) +- [ ] 탭이 없을 때 "사이드바에서 메뉴를 선택하여 탭을 추가하세요" 표시 + +### 5-5. AppLayout 수정 (`components/layout/AppLayout.tsx`) +- [ ] handleMenuClick: router.push -> tabStore.openTab 호출 +- [ ] 레이아웃: main 영역을 TabBar + TabContent로 교체 +- [ ] children prop 제거 (탭이 콘텐츠 관리) +- [ ] 사이드바 메뉴 드래그 가능하게 (draggable) + +### 5-6. 라우팅 연동 +- [ ] `app/(main)/layout.tsx` 수정 - children 대신 탭 시스템 +- [ ] URL 직접 접근 시 탭으로 열기 (북마크/공유 링크 대응) + +--- + +## 6. Phase 2: F5 최대 복원 + +### 6-1. 탭 상태 캐싱 엔진 (`lib/tabStateCache.ts`) +- [ ] 탭별 상태 저장/복원 (sessionStorage) +- [ ] 저장 대상: formData, selectedRows, sortState, scrollPosition, modalState, checkboxState +- [ ] debounce 적용 (상태 변경마다 저장하지 않음) + +### 6-2. 복원 로직 +- [ ] 활성 탭: fresh API 호출 (캐시 데이터 무시) +- [ ] 비활성 탭: 캐시에서 복원 +- [ ] 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제 + +### 6-3. 캐시 키 관리 (clearTabStateCache) + +탭 닫기/새로고침 시 관련 sessionStorage 키 일괄 제거: +- `tab-cache-{screenId}-{menuObjid}` +- `page-scroll-{screenId}-{menuObjid}` +- `tsp-{screenId}-*`, `table-state-{screenId}-*` +- `split-sel-{screenId}-*`, `catval-sel-{screenId}-*` +- `bom-tree-{screenId}-*` +- URL 탭: `tsp-{urlHash}-*`, `admin-scroll-{url}` + +--- + +## 7. Phase 3: 포맷팅 중앙화 + +### 7-1. 포맷팅 규칙 엔진 + +```typescript +// lib/formatting/rules.ts + +interface FormatRules { + date: { + display: string; // "YYYY-MM-DD" + datetime: string; // "YYYY-MM-DD HH:mm:ss" + input: string; // "YYYY-MM-DD" + }; + number: { + locale: string; // 사용자 로케일 기반 + decimals: number; // 기본 소수점 자릿수 + }; + currency: { + code: string; // 회사 설정 기반 + locale: string; + }; +} + +export function formatValue(value: any, dataType: string, rules: FormatRules): string; +export function formatDate(value: any, format?: string): string; +export function formatNumber(value: any, locale?: string): string; +export function formatCurrency(value: any, currencyCode?: string): string; +``` + +### 7-2. 하드코딩 교체 대상 +- [ ] V2DateRenderer.tsx +- [ ] EditModal.tsx +- [ ] InteractiveDataTable.tsx +- [ ] FlowWidget.tsx +- [ ] AggregationWidgetComponent.tsx +- [ ] aggregation.ts (피벗) +- [ ] 기타 하드코딩 파일들 + +--- + +## 8. Phase 4: ScreenViewPage 경량화 +- [ ] 탭 데이터 레이어에서 받은 데이터로 렌더링만 담당 +- [ ] API 호출, 캐시, 복원 로직 제거 (탭 레이어가 담당) +- [ ] 관리자 페이지도 동일한 데이터 레이어 패턴 적용 + +--- + +--- + +## 구현 완료: 다중 스크롤 영역 F5 복원 + +### 개요 + +split panel 등 한 탭 안에 **스크롤 영역이 여러 개**인 화면에서, F5 새로고침 후에도 각 영역의 스크롤 위치가 복원된다. + +탭 전환 시에는 `display: none` 방식으로 DOM이 유지되므로 브라우저가 스크롤을 자연 보존한다. 이 기능은 **F5 새로고침** 전용이다. + +### 동작 방식 + +탭 내 모든 스크롤 가능한 요소를 DOM 경로(`"0/1/0/2"` 형태)와 함께 저장한다. + +``` +scrollPositions: [ + { path: "0/1/0/2", top: 150, left: 0 }, // 예: 좌측 패널 + { path: "0/1/1/3/1", top: 420, left: 0 }, // 예: 우측 패널 +] +``` + +- **실시간 추적**: 스크롤 이벤트 발생 시 해당 요소의 경로와 위치를 Map에 기록 +- **저장 시점**: 탭 전환 시 + `beforeunload`(F5/닫기) 시 sessionStorage에 저장 +- **복원 시점**: 탭 활성화 시 경로를 기반으로 각 요소를 찾아 개별 복원 + +### 관련 파일 및 주요 함수 + +| 파일 | 역할 | +|---|---| +| `lib/tabStateCache.ts` | 스크롤 캡처/복원 핵심 로직 | +| `components/layout/TabContent.tsx` | 스크롤 이벤트 감지, 저장/복원 호출 | + +**`tabStateCache.ts` 핵심 함수**: + +| 함수 | 설명 | +|---|---| +| `getElementPath(element, container)` | 요소의 DOM 경로를 자식 인덱스 문자열로 생성 | +| `captureAllScrollPositions(container)` | TreeWalker로 컨테이너 하위 모든 스크롤 요소의 위치를 일괄 캡처 | +| `restoreAllScrollPositions(container, positions)` | 경로 기반으로 각 요소를 찾아 스크롤 위치 복원 (콘텐츠 렌더링 대기 폴링 포함) | + +**`TabContent.tsx` 핵심 Ref**: + +| Ref | 설명 | +|---|---| +| `lastScrollMapRef` | `Map>` - 탭 내 요소별 최신 스크롤 위치 | +| `pathCacheRef` | `WeakMap` - 동일 요소의 경로 재계산 방지용 캐시 | + +--- + +## 9. 참고 파일 + +| 파일 | 비고 | +|---|---| +| `frontend/components/layout/AppLayout.tsx` | 사이드바 + 콘텐츠 레이아웃 | +| `frontend/app/(main)/screens/[screenId]/page.tsx` | 화면 렌더링 (건드리지 않음) | +| `frontend/stores/modalDataStore.ts` | Zustand store 참고 패턴 | +| `frontend/lib/adminPageRegistry.tsx` | 관리자 페이지 레지스트리 | diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 160883ad..693b4be5 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -28,17 +28,24 @@ import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조 import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어 import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환 import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/services/ScheduleGeneratorService"; // 스케줄 자동 생성 +import { useTabId } from "@/contexts/TabIdContext"; +import { useTabStore } from "@/stores/tabStore"; -function ScreenViewPage() { +export interface ScreenViewPageProps { + screenIdProp?: number; + menuObjidProp?: number; +} + +function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) { // 스케줄 자동 생성 서비스 활성화 const { showConfirmDialog, previewResult, handleConfirm, closeDialog, isLoading: scheduleLoading } = useScheduleGenerator(); const params = useParams(); const searchParams = useSearchParams(); const router = useRouter(); - const screenId = parseInt(params.screenId as string); + const screenId = screenIdProp ?? parseInt(params.screenId as string); - // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) - const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; + // props 우선, 없으면 URL 쿼리에서 menuObjid 가져오기 + const menuObjid = menuObjidProp ?? (searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined); // URL 쿼리에서 프리뷰용 company_code 가져오기 const previewCompanyCode = searchParams.get("company_code"); @@ -124,10 +131,13 @@ function ScreenViewPage() { initComponents(); }, []); - // 편집 모달 이벤트 리스너 등록 + // 편집 모달 이벤트 리스너 등록 (활성 탭에서만 처리) + const tabId = useTabId(); useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { - // console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail); + const state = useTabStore.getState(); + const currentActiveTabId = state[state.mode].activeTabId; + if (tabId && tabId !== currentActiveTabId) return; setEditModalConfig({ screenId: event.detail.screenId, @@ -147,7 +157,7 @@ function ScreenViewPage() { // @ts-expect-error - CustomEvent type window.removeEventListener("openEditModal", handleOpenEditModal); }; - }, []); + }, [tabId]); useEffect(() => { const loadScreen = async () => { @@ -1325,16 +1335,17 @@ function ScreenViewPage() { } // 실제 컴포넌트를 Provider로 감싸기 -function ScreenViewPageWrapper() { +function ScreenViewPageWrapper({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) { return ( - + ); } +export { ScreenViewPageWrapper }; export default ScreenViewPageWrapper; diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 0661f649..c473c245 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -4,7 +4,7 @@ import "./globals.css"; import { QueryProvider } from "@/providers/QueryProvider"; import { RegistryProvider } from "./registry-provider"; import { Toaster } from "sonner"; -import ScreenModal from "@/components/common/ScreenModal"; + const inter = Inter({ subsets: ["latin"], @@ -45,7 +45,6 @@ export default function RootLayout({ {children} - {/* Portal 컨테이너 */}
diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 854b1159..0d0f422a 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -26,6 +26,8 @@ import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; import { ScreenContextProvider } from "@/contexts/ScreenContext"; +import { useTabStore } from "@/stores/tabStore"; +import { useTabId } from "@/contexts/TabIdContext"; interface ScreenModalState { isOpen: boolean; @@ -42,6 +44,9 @@ interface ScreenModalProps { export const ScreenModal: React.FC = ({ className }) => { const { userId, userName, user } = useAuth(); const splitPanelContext = useSplitPanelContext(); + const tabId = useTabId(); + const activeTabId = useTabStore((s) => s[s.mode].activeTabId); + const isTabActive = !tabId || tabId === activeTabId; const [modalState, setModalState] = useState({ isOpen: false, @@ -169,6 +174,11 @@ export const ScreenModal: React.FC = ({ className }) => { // 전역 모달 이벤트 리스너 useEffect(() => { const handleOpenModal = (event: CustomEvent) => { + // 활성 탭에서만 이벤트 처리 (다른 탭의 ScreenModal 인스턴스는 무시) + const storeState = useTabStore.getState(); + const currentActiveTabId = storeState[storeState.mode].activeTabId; + if (tabId && tabId !== currentActiveTabId) return; + const { screenId, title, @@ -190,7 +200,7 @@ export const ScreenModal: React.FC = ({ className }) => { isCreateMode, }); - // 🆕 모달 열린 시간 기록 + // 모달 열린 시간 기록 modalOpenedAtRef.current = Date.now(); // 폼 변경 추적 초기화 @@ -442,7 +452,7 @@ export const ScreenModal: React.FC = ({ className }) => { window.removeEventListener("closeSaveModal", handleCloseModal); window.removeEventListener("saveSuccessInModal", handleSaveSuccess); }; - }, [continuousMode]); // continuousMode 의존성 추가 + }, [tabId, continuousMode]); // 화면 데이터 로딩 useEffect(() => { diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx new file mode 100644 index 00000000..20175b5e --- /dev/null +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -0,0 +1,109 @@ +"use client"; + +import React, { useMemo } from "react"; +import dynamic from "next/dynamic"; +import { Loader2 } from "lucide-react"; + +const LoadingFallback = () => ( +
+ +
+); + +/** + * 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리. + * 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다. + * 매핑되지 않은 URL은 catch-all fallback으로 처리된다. + */ +const ADMIN_PAGE_REGISTRY: Record> = { + // 관리자 메인 + "/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }), + + // 메뉴 관리 + "/admin/menu": dynamic(() => import("@/app/(main)/admin/menu/page"), { ssr: false, loading: LoadingFallback }), + + // 사용자 관리 + "/admin/userMng/userMngList": dynamic(() => import("@/app/(main)/admin/userMng/userMngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }), + + // 화면 관리 + "/admin/screenMng/screenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/screenMngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/screenMng/popScreenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/screenMng/dashboardList": dynamic(() => import("@/app/(main)/admin/screenMng/dashboardList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/screenMng/reportList": dynamic(() => import("@/app/(main)/admin/screenMng/reportList/page"), { ssr: false, loading: LoadingFallback }), + + // 시스템 관리 + "/admin/systemMng/commonCodeList": dynamic(() => import("@/app/(main)/admin/systemMng/commonCodeList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }), + + // 자동화 관리 + "/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }), + + // 메일 + "/admin/automaticMng/mail/send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/send/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/receive": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/receive/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/sent": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/sent/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/drafts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/trash": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/trash/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/accounts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }), + + // 배치 관리 + "/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }), + "/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }), + + // 기타 + "/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), + "/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }), + "/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }), + "/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }), + "/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }), + "/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }), + "/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }), + "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }), +}; + +// 매핑되지 않은 URL용 Fallback +function AdminPageFallback({ url }: { url: string }) { + return ( +
+
+

페이지 로딩 불가

+

+ 경로: {url} +

+

+ AdminPageRenderer 레지스트리에 이 URL을 추가해주세요. +

+
+
+ ); +} + +interface AdminPageRendererProps { + url: string; +} + +export function AdminPageRenderer({ url }: AdminPageRendererProps) { + const PageComponent = useMemo(() => { + // URL에서 쿼리스트링/해시 제거 후 매칭 + const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); + return ADMIN_PAGE_REGISTRY[cleanUrl] || null; + }, [url]); + + if (!PageComponent) { + return ; + } + + return ; +} diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index de2c5b61..b0f4cbc9 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -29,6 +29,9 @@ import { toast } from "sonner"; import { ProfileModal } from "./ProfileModal"; import { Logo } from "./Logo"; import { SideMenu } from "./SideMenu"; +import { TabBar } from "./TabBar"; +import { TabContent } from "./TabContent"; +import { useTabStore } from "@/stores/tabStore"; import { DropdownMenu, DropdownMenuContent, @@ -90,7 +93,8 @@ const getMenuIcon = (menuName: string) => { }; // 메뉴 데이터를 UI용으로 변환하는 함수 (최상위 "사용자", "관리자" 제외) -const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0"): any[] => { +// parentPath: 탭 제목에 "기준정보 - 회사관리" 형태로 상위 카테고리를 포함하기 위한 경로 +const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0", parentPath: string = ""): any[] => { const filteredMenus = menus .filter((menu) => (menu.parent_obj_id || menu.PARENT_OBJ_ID) === parentId) .filter((menu) => (menu.status || menu.STATUS) === "active") @@ -103,40 +107,34 @@ const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, p for (const menu of filteredMenus) { const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase(); - // "사용자" 또는 "관리자" 카테고리면 하위 메뉴들을 직접 추가 if (menuName.includes("사용자") || menuName.includes("관리자")) { - const childMenus = convertMenuToUI(menus, userInfo, menu.objid || menu.OBJID); + const childMenus = convertMenuToUI(menus, userInfo, menu.objid || menu.OBJID, ""); allMenus.push(...childMenus); } else { - // 일반 메뉴는 그대로 추가 - allMenus.push(convertSingleMenu(menu, menus, userInfo)); + allMenus.push(convertSingleMenu(menu, menus, userInfo, "")); } } return allMenus; } - return filteredMenus.map((menu) => convertSingleMenu(menu, menus, userInfo)); + return filteredMenus.map((menu) => convertSingleMenu(menu, menus, userInfo, parentPath)); }; // 단일 메뉴 변환 함수 -const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null): any => { +const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null, parentPath: string = ""): any => { const menuId = menu.objid || menu.OBJID; // 사용자 locale 기준으로 번역 처리 - const getDisplayText = (menu: MenuItem) => { - // 다국어 텍스트가 있으면 사용, 없으면 기본 텍스트 사용 - if (menu.translated_name || menu.TRANSLATED_NAME) { - return menu.translated_name || menu.TRANSLATED_NAME; + const getDisplayText = (m: MenuItem) => { + if (m.translated_name || m.TRANSLATED_NAME) { + return m.translated_name || m.TRANSLATED_NAME; } - const baseName = menu.menu_name_kor || menu.MENU_NAME_KOR || "메뉴명 없음"; - - // 사용자 정보에서 locale 가져오기 + const baseName = m.menu_name_kor || m.MENU_NAME_KOR || "메뉴명 없음"; const userLocale = userInfo?.locale || "ko"; if (userLocale === "EN") { - // 영어 번역 const translations: { [key: string]: string } = { 관리자: "Administrator", 사용자: "User Management", @@ -156,7 +154,6 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten } } } else if (userLocale === "JA") { - // 일본어 번역 const translations: { [key: string]: string } = { 관리자: "管理者", 사용자: "ユーザー管理", @@ -176,7 +173,6 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten } } } else if (userLocale === "ZH") { - // 중국어 번역 const translations: { [key: string]: string } = { 관리자: "管理员", 사용자: "用户管理", @@ -200,11 +196,15 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten return baseName; }; - const children = convertMenuToUI(allMenus, userInfo, menuId); + const displayName = getDisplayText(menu); + const tabTitle = parentPath ? `${parentPath} - ${displayName}` : displayName; + + const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle); return { id: menuId, - name: getDisplayText(menu), + name: displayName, + tabTitle, icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || ""), url: menu.menu_url || menu.MENU_URL || "#", children: children.length > 0 ? children : undefined, @@ -224,6 +224,28 @@ function AppLayoutInner({ children }: AppLayoutProps) { const [showCompanySwitcher, setShowCompanySwitcher] = useState(false); const [currentCompanyName, setCurrentCompanyName] = useState(""); + // URL 직접 접근 시 탭 자동 열기 (북마크/공유 링크 대응) + useEffect(() => { + const store = useTabStore.getState(); + const currentModeTabs = store[store.mode].tabs; + if (currentModeTabs.length > 0) return; + + // /screens/[screenId] 패턴 감지 + const screenMatch = pathname.match(/^\/screens\/(\d+)/); + if (screenMatch) { + const screenId = parseInt(screenMatch[1]); + const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; + store.openTab({ type: "screen", title: `화면 ${screenId}`, screenId, menuObjid }); + return; + } + + // /admin/* 패턴 감지 -> admin 모드로 전환 후 탭 열기 + if (pathname.startsWith("/admin") && pathname !== "/admin") { + store.setMode("admin"); + store.openTab({ type: "admin", title: pathname.split("/").pop() || "관리자", adminUrl: pathname }); + } + }, []); // 마운트 시 1회만 실행 + // 현재 회사명 조회 (SUPER_ADMIN 전용) useEffect(() => { const fetchCurrentCompanyName = async () => { @@ -299,8 +321,10 @@ function AppLayoutInner({ children }: AppLayoutProps) { handleRegisterVehicle, } = useProfile(user, refreshUserData, refreshMenus); - // 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려) - const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin"; + // 탭 스토어에서 현재 모드 가져오기 + const tabMode = useTabStore((s) => s.mode); + const setTabMode = useTabStore((s) => s.setMode); + const isAdminMode = tabMode === "admin"; // 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용) const isPreviewMode = searchParams.get("preview") === "true"; @@ -320,67 +344,55 @@ function AppLayoutInner({ children }: AppLayoutProps) { setExpandedMenus(newExpanded); }; - // 메뉴 클릭 핸들러 + const { openTab } = useTabStore(); + + // 메뉴 클릭 핸들러 (탭으로 열기) const handleMenuClick = async (menu: any) => { if (menu.hasChildren) { toggleMenu(menu.id); } else { - // 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용) - const menuName = menu.label || menu.name || "메뉴"; + // tabTitle: "기준정보 - 회사관리" 형태의 상위 포함 이름 + const menuName = menu.tabTitle || menu.label || menu.name || "메뉴"; if (typeof window !== "undefined") { localStorage.setItem("currentMenuName", menuName); } - // 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이) try { const menuObjid = menu.objid || menu.id; const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid); if (assignedScreens.length > 0) { - // 할당된 화면이 있으면 첫 번째 화면으로 이동 const firstScreen = assignedScreens[0]; - - // 관리자 모드 상태와 menuObjid를 쿼리 파라미터로 전달 - const params = new URLSearchParams(); - if (isAdminMode) { - params.set("mode", "admin"); - } - params.set("menuObjid", menuObjid.toString()); - - const screenPath = `/screens/${firstScreen.screenId}?${params.toString()}`; - - router.push(screenPath); - if (isMobile) { - setSidebarOpen(false); - } + openTab({ + type: "screen", + title: menuName, + screenId: firstScreen.screenId, + menuObjid: parseInt(menuObjid), + }); + if (isMobile) setSidebarOpen(false); return; } } catch (error) { console.warn("할당된 화면 조회 실패:", error); } - // 할당된 화면이 없고 URL이 있으면 기존 URL로 이동 if (menu.url && menu.url !== "#") { - router.push(menu.url); - if (isMobile) { - setSidebarOpen(false); - } + openTab({ + type: "admin", + title: menuName, + adminUrl: menu.url, + }); + if (isMobile) setSidebarOpen(false); } else { - // URL도 없고 할당된 화면도 없으면 경고 메시지 toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다."); } } }; // 모드 전환 핸들러 + // 모드 전환: 탭 스토어의 모드만 변경 (각 모드 탭은 독립 보존) const handleModeSwitch = () => { - if (isAdminMode) { - // 관리자 → 사용자 모드: 선택한 회사 유지 - router.push("/main"); - } else { - // 사용자 → 관리자 모드: 선택한 회사 유지 (회사 전환 없음) - router.push("/admin"); - } + setTabMode(isAdminMode ? "user" : "admin"); }; // 로그아웃 핸들러 @@ -393,13 +405,57 @@ function AppLayoutInner({ children }: AppLayoutProps) { } }; - // 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용) + // 사이드바 메뉴 -> 탭 바 드래그용 데이터 생성 + const buildMenuDragData = async (menu: any): Promise => { + const menuName = menu.label || menu.name || "메뉴"; + const menuObjid = menu.objid || menu.id; + + try { + const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid); + if (assignedScreens.length > 0) { + return JSON.stringify({ + type: "screen" as const, + title: menuName, + screenId: assignedScreens[0].screenId, + menuObjid: parseInt(menuObjid), + }); + } + } catch { /* ignore */ } + + if (menu.url && menu.url !== "#") { + return JSON.stringify({ + type: "admin" as const, + title: menuName, + adminUrl: menu.url, + }); + } + + return null; + }; + + const handleMenuDragStart = (e: React.DragEvent, menu: any) => { + if (menu.hasChildren) { + e.preventDefault(); + return; + } + e.dataTransfer.effectAllowed = "copy"; + const menuName = menu.tabTitle || menu.label || menu.name || "메뉴"; + const menuObjid = menu.objid || menu.id; + const dragPayload = JSON.stringify({ menuName, menuObjid, url: menu.url }); + e.dataTransfer.setData("application/tab-menu-pending", dragPayload); + e.dataTransfer.setData("text/plain", menuName); + }; + + // 메뉴 트리 렌더링 (드래그 가능) const renderMenu = (menu: any, level: number = 0) => { const isExpanded = expandedMenus.has(menu.id); + const isLeaf = !menu.hasChildren; return (
handleMenuDragStart(e, menu)} className={`group flex h-10 cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ease-in-out ${ pathname === menu.url ? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900" @@ -428,6 +484,8 @@ function AppLayoutInner({ children }: AppLayoutProps) { {menu.children?.map((child: any) => (
handleMenuDragStart(e, child)} className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${ pathname === child.url ? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900" @@ -695,9 +753,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
- {/* 가운데 컨텐츠 영역 - 스크롤 가능 */} -
- {children} + {/* 가운데 컨텐츠 영역 - 탭 시스템 */} +
+ +
diff --git a/frontend/components/layout/EmptyDashboard.tsx b/frontend/components/layout/EmptyDashboard.tsx new file mode 100644 index 00000000..26d27d4a --- /dev/null +++ b/frontend/components/layout/EmptyDashboard.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { LayoutGrid } from "lucide-react"; + +export function EmptyDashboard() { + return ( +
+
+
+ +
+
+

+ 열린 탭이 없습니다 +

+

+ 왼쪽 사이드바에서 메뉴를 클릭하거나 드래그하여 탭을 추가하세요. +

+
+
+
+ ); +} diff --git a/frontend/components/layout/TabBar.tsx b/frontend/components/layout/TabBar.tsx new file mode 100644 index 00000000..e83e9b10 --- /dev/null +++ b/frontend/components/layout/TabBar.tsx @@ -0,0 +1,467 @@ +"use client"; + +import React, { useRef, useState, useEffect, useCallback } from "react"; +import { X, RefreshCw, ChevronDown } from "lucide-react"; +import { useTabStore, selectTabs, selectActiveTabId, Tab } from "@/stores/tabStore"; +import { menuScreenApi } from "@/lib/api/screen"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; + +const TAB_WIDTH = 180; +const TAB_GAP = 2; +const TAB_UNIT = TAB_WIDTH + TAB_GAP; +const OVERFLOW_BTN_WIDTH = 48; +const DRAG_THRESHOLD = 5; +const SETTLE_MS = 200; +const BAR_PAD_X = 8; + +interface DragState { + tabId: string; + pointerId: number; + startX: number; + currentX: number; + tabRect: DOMRect; + fromIndex: number; + targetIndex: number; + activated: boolean; + settling: boolean; +} + +export function TabBar() { + const tabs = useTabStore(selectTabs); + const activeTabId = useTabStore(selectActiveTabId); + const { + switchTab, closeTab, refreshTab, + closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, + updateTabOrder, openTab, + } = useTabStore(); + + const containerRef = useRef(null); + const settleTimer = useRef | null>(null); + const dragActiveRef = useRef(false); + + const [visibleCount, setVisibleCount] = useState(tabs.length); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: string } | null>(null); + const [dragState, setDragState] = useState(null); + const [externalDragIdx, setExternalDragIdx] = useState(null); + + dragActiveRef.current = !!dragState; + + useEffect(() => { + return () => { if (settleTimer.current) clearTimeout(settleTimer.current); }; + }, []); + + // --- 오버플로우 계산 (드래그 중 재계산 방지) --- + const recalcVisible = useCallback(() => { + if (dragActiveRef.current) return; + if (!containerRef.current) return; + const w = containerRef.current.clientWidth; + setVisibleCount(Math.max(1, Math.floor((w - OVERFLOW_BTN_WIDTH) / TAB_UNIT))); + }, []); + + useEffect(() => { + recalcVisible(); + const obs = new ResizeObserver(recalcVisible); + if (containerRef.current) obs.observe(containerRef.current); + return () => obs.disconnect(); + }, [recalcVisible]); + + useEffect(() => { recalcVisible(); }, [tabs.length, recalcVisible]); + + const visibleTabs = tabs.slice(0, visibleCount); + const overflowTabs = tabs.slice(visibleCount); + const hasOverflow = overflowTabs.length > 0; + + const activeInOverflow = activeTabId && overflowTabs.some((t) => t.id === activeTabId); + let displayVisible = visibleTabs; + let displayOverflow = overflowTabs; + + if (activeInOverflow && activeTabId) { + const activeTab = tabs.find((t) => t.id === activeTabId)!; + displayVisible = [...visibleTabs.slice(0, -1), activeTab]; + displayOverflow = overflowTabs.filter((t) => t.id !== activeTabId); + if (visibleTabs.length > 0) { + displayOverflow = [visibleTabs[visibleTabs.length - 1], ...displayOverflow]; + } + } + + // ============================================================ + // 사이드바 -> 탭 바 드롭 (네이티브 DnD + 삽입 위치 애니메이션) + // ============================================================ + + const resolveMenuAndOpenTab = async ( + menuName: string, menuObjid: string | number, url: string, insertIndex?: number, + ) => { + const numericObjid = typeof menuObjid === "string" ? parseInt(menuObjid) : menuObjid; + try { + const screens = await menuScreenApi.getScreensByMenu(numericObjid); + if (screens.length > 0) { + openTab( + { type: "screen", title: menuName, screenId: screens[0].screenId, menuObjid: numericObjid }, + insertIndex, + ); + return; + } + } catch { /* ignore */ } + if (url && url !== "#") { + openTab({ type: "admin", title: menuName, adminUrl: url }, insertIndex); + } + }; + + const handleBarDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + const bar = containerRef.current?.getBoundingClientRect(); + if (bar) { + let idx = Math.round((e.clientX - bar.left - BAR_PAD_X) / TAB_UNIT); + idx = Math.max(0, Math.min(idx, displayVisible.length)); + setExternalDragIdx(idx); + } + }; + + const handleBarDragLeave = (e: React.DragEvent) => { + if (!containerRef.current?.contains(e.relatedTarget as Node)) { + setExternalDragIdx(null); + } + }; + + const handleBarDrop = (e: React.DragEvent) => { + e.preventDefault(); + const insertIdx = externalDragIdx ?? undefined; + setExternalDragIdx(null); + + const pending = e.dataTransfer.getData("application/tab-menu-pending"); + if (pending) { + try { + const { menuName, menuObjid, url } = JSON.parse(pending); + resolveMenuAndOpenTab(menuName, menuObjid, url, insertIdx); + } catch { /* ignore */ } + return; + } + const menuData = e.dataTransfer.getData("application/tab-menu"); + if (menuData && menuData.length > 2) { + try { openTab(JSON.parse(menuData), insertIdx); } catch { /* ignore */ } + } + }; + + // ============================================================ + // 탭 드래그 (Pointer Events) - 임계값 + settling 애니메이션 + // ============================================================ + + const calcTarget = useCallback( + (clientX: number): number => { + const bar = containerRef.current?.getBoundingClientRect(); + if (!bar) return 0; + let idx = Math.round((clientX - bar.left - BAR_PAD_X) / TAB_UNIT); + return Math.max(0, Math.min(idx, displayVisible.length - 1)); + }, + [displayVisible.length], + ); + + const handlePointerDown = (e: React.PointerEvent, tabId: string, idx: number) => { + if ((e.target as HTMLElement).closest("button")) return; + if (dragState?.settling) return; + + e.preventDefault(); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + + setDragState({ + tabId, + pointerId: e.pointerId, + startX: e.clientX, + currentX: e.clientX, + tabRect: (e.currentTarget as HTMLElement).getBoundingClientRect(), + fromIndex: idx, + targetIndex: idx, + activated: false, + settling: false, + }); + }; + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragState || dragState.settling) return; + const bar = containerRef.current?.getBoundingClientRect(); + if (!bar) return; + + const clampedX = Math.max(bar.left, Math.min(e.clientX, bar.right)); + + if (!dragState.activated) { + if (Math.abs(clampedX - dragState.startX) < DRAG_THRESHOLD) return; + setDragState((p) => + p ? { ...p, activated: true, currentX: clampedX, targetIndex: calcTarget(clampedX) } : null, + ); + return; + } + + setDragState((p) => + p ? { ...p, currentX: clampedX, targetIndex: calcTarget(clampedX) } : null, + ); + }, + [dragState, calcTarget], + ); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + if (!dragState || dragState.settling) return; + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + + // 임계값 미달 → 클릭으로 처리 + if (!dragState.activated) { + switchTab(dragState.tabId); + setDragState(null); + return; + } + + const { fromIndex, targetIndex, tabId } = dragState; + + // settling 시작: 고스트가 목표(또는 원래) 슬롯으로 부드럽게 복귀 + setDragState((p) => (p ? { ...p, settling: true } : null)); + + if (targetIndex === fromIndex) { + // 이동 없음: 고스트가 원래 위치로 애니메이션 후 정리 + settleTimer.current = setTimeout(() => setDragState(null), SETTLE_MS + 30); + return; + } + + // 실제 배열 인덱스 계산 (setTimeout 전에 캡처) + const actualFrom = tabs.findIndex((t) => t.id === tabId); + const tgtTab = displayVisible[targetIndex]; + const actualTo = tgtTab ? tabs.findIndex((t) => t.id === tgtTab.id) : actualFrom; + + settleTimer.current = setTimeout(() => { + setDragState(null); + if (actualFrom !== -1 && actualTo !== -1 && actualFrom !== actualTo) { + updateTabOrder(actualFrom, actualTo); + } + }, SETTLE_MS + 30); + }, + [dragState, tabs, displayVisible, switchTab, updateTabOrder], + ); + + // ============================================================ + // 스타일 계산 + // ============================================================ + + const getTabAnimStyle = (tabId: string, index: number): React.CSSProperties => { + // 사이드바 드래그 호버: 삽입 지점 이후 탭이 오른쪽으로 shift + if (externalDragIdx !== null && !dragState) { + return { + transform: index >= externalDragIdx ? `translateX(${TAB_UNIT}px)` : "none", + transition: `transform ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`, + }; + } + + // 탭 드래그 미활성화 → 기본 + if (!dragState || !dragState.activated) return {}; + + const { fromIndex, targetIndex, tabId: draggedId } = dragState; + + if (tabId === draggedId) { + return { opacity: 0, transition: "none" }; + } + + let shift = 0; + if (fromIndex < targetIndex) { + if (index > fromIndex && index <= targetIndex) shift = -TAB_UNIT; + } else if (fromIndex > targetIndex) { + if (index >= targetIndex && index < fromIndex) shift = TAB_UNIT; + } + + return { + transform: shift !== 0 ? `translateX(${shift}px)` : "none", + transition: `transform ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`, + }; + }; + + const getGhostStyle = (): React.CSSProperties | null => { + if (!dragState || !dragState.activated) return null; + const bar = containerRef.current?.getBoundingClientRect(); + if (!bar) return null; + + const base: React.CSSProperties = { + position: "fixed", + top: dragState.tabRect.top, + width: TAB_WIDTH, + height: dragState.tabRect.height, + zIndex: 100, + pointerEvents: "none", + opacity: 0.9, + }; + + if (dragState.settling) { + return { + ...base, + left: bar.left + BAR_PAD_X + dragState.targetIndex * TAB_UNIT, + transition: `left ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`, + }; + } + + const offsetX = dragState.currentX - dragState.startX; + const rawLeft = dragState.tabRect.left + offsetX; + return { + ...base, + left: Math.max(bar.left, Math.min(rawLeft, bar.right - TAB_WIDTH)), + transition: "none", + }; + }; + + const ghostStyle = getGhostStyle(); + const draggedTab = dragState ? tabs.find((t) => t.id === dragState.tabId) : null; + + // ============================================================ + // 우클릭 컨텍스트 메뉴 + // ============================================================ + + const handleContextMenu = (e: React.MouseEvent, tabId: string) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, tabId }); + }; + + useEffect(() => { + if (!contextMenu) return; + const close = () => setContextMenu(null); + window.addEventListener("click", close); + window.addEventListener("scroll", close); + return () => { + window.removeEventListener("click", close); + window.removeEventListener("scroll", close); + }; + }, [contextMenu]); + + // ============================================================ + // 렌더링 + // ============================================================ + + const renderTab = (tab: Tab, displayIndex: number) => { + const isActive = tab.id === activeTabId; + const animStyle = getTabAnimStyle(tab.id, displayIndex); + + return ( +
handlePointerDown(e, tab.id, displayIndex)} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onContextMenu={(e) => handleContextMenu(e, tab.id)} + className={cn( + "group relative flex h-9 shrink-0 cursor-pointer items-center gap-1 rounded-t-lg border border-b-0 px-3 text-sm select-none", + isActive + ? "border-border bg-white text-foreground z-10" + : "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground", + )} + style={{ width: TAB_WIDTH, touchAction: "none", ...animStyle }} + title={tab.title} + > + {tab.title} + +
+ {isActive && ( + + )} + +
+
+ ); + }; + + if (tabs.length === 0) return null; + + return ( + <> +
+ {displayVisible.map((tab, i) => renderTab(tab, i))} + + {hasOverflow && ( + + + + + + {displayOverflow.map((tab) => ( + switchTab(tab.id)} className="flex items-center justify-between gap-2"> + {tab.title} + + + ))} + + + )} +
+ + {/* 드래그 고스트 */} + {ghostStyle && draggedTab && ( +
+
+ {draggedTab.title} +
+
+ )} + + {/* 우클릭 컨텍스트 메뉴 */} + {contextMenu && ( +
+ { refreshTab(contextMenu.tabId); setContextMenu(null); }} /> +
+ { closeTabsToLeft(contextMenu.tabId); setContextMenu(null); }} /> + { closeTabsToRight(contextMenu.tabId); setContextMenu(null); }} /> + { closeOtherTabs(contextMenu.tabId); setContextMenu(null); }} /> +
+ { closeAllTabs(); setContextMenu(null); }} destructive /> +
+ )} + + ); +} + +function ContextMenuItem({ label, onClick, destructive }: { label: string; onClick: () => void; destructive?: boolean }) { + return ( + + ); +} diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 49aed98b..a786079b 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -18,6 +18,8 @@ import { useAuth } from "@/hooks/useAuth"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { ScreenContextProvider } from "@/contexts/ScreenContext"; +import { useTabId } from "@/contexts/TabIdContext"; +import { useTabStore } from "@/stores/tabStore"; interface EditModalState { isOpen: boolean; @@ -82,6 +84,9 @@ const findSaveButtonInComponents = (components: any[]): any | null => { export const EditModal: React.FC = ({ className }) => { const { user } = useAuth(); + const tabId = useTabId(); + const activeTabId = useTabStore((s) => s[s.mode].activeTabId); + const isTabActive = !tabId || tabId === activeTabId; const [modalState, setModalState] = useState({ isOpen: false, screenId: null, @@ -244,9 +249,13 @@ export const EditModal: React.FC = ({ className }) => { } }; - // 전역 모달 이벤트 리스너 + // 전역 모달 이벤트 리스너 (활성 탭에서만 처리) useEffect(() => { const handleOpenEditModal = async (event: CustomEvent) => { + const storeState = useTabStore.getState(); + const currentActiveTabId = storeState[storeState.mode].activeTabId; + if (tabId && tabId !== currentActiveTabId) return; + const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext, menuObjid } = event.detail; // 🆕 모달 내부 저장 버튼의 제어로직 설정 조회 @@ -312,7 +321,7 @@ export const EditModal: React.FC = ({ className }) => { window.removeEventListener("openEditModal", handleOpenEditModal as EventListener); window.removeEventListener("closeEditModal", handleCloseEditModal); }; - }, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조 + }, [tabId, modalState.onSave]); // 화면 데이터 로딩 useEffect(() => { diff --git a/frontend/components/screen/widgets/types/NumberWidget.tsx b/frontend/components/screen/widgets/types/NumberWidget.tsx index 9c28528a..eadc15c4 100644 --- a/frontend/components/screen/widgets/types/NumberWidget.tsx +++ b/frontend/components/screen/widgets/types/NumberWidget.tsx @@ -4,6 +4,7 @@ import React from "react"; import { Input } from "@/components/ui/input"; import { WebTypeComponentProps } from "@/lib/registry/types"; import { WidgetComponent, NumberTypeConfig } from "@/types/screen"; +import { formatNumber as formatNum, formatCurrency } from "@/lib/formatting"; export const NumberWidget: React.FC = ({ component, value, onChange, readonly = false }) => { const widget = component as WidgetComponent; @@ -21,10 +22,7 @@ export const NumberWidget: React.FC = ({ component, value if (isNaN(numValue)) return ""; if (config?.format === "currency") { - return new Intl.NumberFormat("ko-KR", { - style: "currency", - currency: "KRW", - }).format(numValue); + return formatCurrency(numValue); } if (config?.format === "percentage") { @@ -32,7 +30,7 @@ export const NumberWidget: React.FC = ({ component, value } if (config?.thousandSeparator) { - return new Intl.NumberFormat("ko-KR").format(numValue); + return formatNum(numValue); } return numValue.toString(); diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx index 2da0647f..1325f487 100644 --- a/frontend/components/ui/alert-dialog.tsx +++ b/frontend/components/ui/alert-dialog.tsx @@ -5,8 +5,24 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import { cn } from "@/lib/utils"; import { buttonVariants } from "@/components/ui/button"; +import { useModalPortal } from "@/lib/modalPortalRef"; +import { useTabId } from "@/contexts/TabIdContext"; +import { useTabStore } from "@/stores/tabStore"; -const AlertDialog = AlertDialogPrimitive.Root; +// AlertDialog: 비활성 탭이면 자동으로 open={false} 처리 +const AlertDialog: React.FC> = ({ + open, + ...props +}) => { + const tabId = useTabId(); + const activeTabId = useTabStore((s) => s[s.mode].activeTabId); + const isTabActive = !tabId || tabId === activeTabId; + + const effectiveOpen = open != null ? open && isTabActive : undefined; + + return ; +}; +AlertDialog.displayName = "AlertDialog"; const AlertDialogTrigger = AlertDialogPrimitive.Trigger; @@ -18,7 +34,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( { + /** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */ + container?: HTMLElement | null; + /** 탭 비활성 시 포탈 내용 숨김 */ + hidden?: boolean; +} + const AlertDialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - - -)); + ScopedAlertDialogContentProps +>(({ className, container: explicitContainer, hidden: hiddenProp, style, ...props }, ref) => { + const autoContainer = useModalPortal(); + const container = explicitContainer !== undefined ? explicitContainer : autoContainer; + const scoped = !!container; + + const adjustedStyle = scoped && style + ? { ...style, maxHeight: undefined, maxWidth: undefined } + : style; + + return ( + +
+ {scoped ? ( +
+ ) : ( + + )} + +
+ + ); +}); AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx index 4813828e..3e17f5a5 100644 --- a/frontend/components/ui/dialog.tsx +++ b/frontend/components/ui/dialog.tsx @@ -5,8 +5,27 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useModalPortal } from "@/lib/modalPortalRef"; +import { useTabId } from "@/contexts/TabIdContext"; +import { useTabStore } from "@/stores/tabStore"; -const Dialog = DialogPrimitive.Root; +// Dialog: 탭 시스템 내에서 자동으로 modal={false} + 비활성 탭이면 open={false} 처리 +const Dialog: React.FC> = ({ + modal, + open, + ...props +}) => { + const autoContainer = useModalPortal(); + const tabId = useTabId(); + const activeTabId = useTabStore((s) => s[s.mode].activeTabId); + const isTabActive = !tabId || tabId === activeTabId; + + const effectiveModal = modal !== undefined ? modal : !autoContainer ? undefined : false; + const effectiveOpen = open != null ? open && isTabActive : undefined; + + return ; +}; +Dialog.displayName = "Dialog"; const DialogTrigger = DialogPrimitive.Trigger; @@ -21,7 +40,7 @@ const DialogOverlay = React.forwardRef< { + /** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */ + container?: HTMLElement | null; + /** 탭 비활성 시 포탈 내용 숨김 */ + hidden?: boolean; +} + const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)); + ScopedDialogContentProps +>(({ className, children, container: explicitContainer, hidden: hiddenProp, onInteractOutside, style, ...props }, ref) => { + const autoContainer = useModalPortal(); + const container = explicitContainer !== undefined ? explicitContainer : autoContainer; + const scoped = !!container; + + const handleInteractOutside = React.useCallback( + (e: any) => { + if (scoped && container) { + const target = (e.detail?.originalEvent?.target ?? e.target) as HTMLElement | null; + if (target && !container.contains(target)) { + e.preventDefault(); + return; + } + } + onInteractOutside?.(e); + }, + [scoped, container, onInteractOutside], + ); + + // scoped 모드: 뷰포트 기반 maxHeight/maxWidth 제거 → className의 max-h-full이 컨테이너 기준으로 적용됨 + const adjustedStyle = scoped && style + ? { ...style, maxHeight: undefined, maxWidth: undefined } + : style; + + return ( + +
+ {scoped ? ( +
+ ) : ( + + )} + + {children} + + + Close + + +
+ + ); +}); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( diff --git a/frontend/contexts/TabIdContext.tsx b/frontend/contexts/TabIdContext.tsx new file mode 100644 index 00000000..e22a7b06 --- /dev/null +++ b/frontend/contexts/TabIdContext.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { createContext, useContext } from "react"; + +const TabIdContext = createContext(null); + +export const TabIdProvider = TabIdContext.Provider; + +export function useTabId(): string | null { + return useContext(TabIdContext); +} diff --git a/frontend/lib/formatting/index.ts b/frontend/lib/formatting/index.ts new file mode 100644 index 00000000..53dff08d --- /dev/null +++ b/frontend/lib/formatting/index.ts @@ -0,0 +1,137 @@ +/** + * 중앙 포맷팅 함수. + * 모든 컴포넌트는 날짜/숫자/통화를 표시할 때 이 함수들만 호출한다. + * + * 사용법: + * import { formatDate, formatNumber, formatCurrency } from "@/lib/formatting"; + * formatDate("2025-01-01") // "2025-01-01" + * formatDate("2025-01-01T14:30:00Z", "datetime") // "2025-01-01 14:30:00" + * formatNumber(1234567) // "1,234,567" + * formatCurrency(50000) // "₩50,000" + */ + +export { getFormatRules, setFormatRules, DEFAULT_FORMAT_RULES } from "./rules"; +export type { FormatRules, DateFormatRules, NumberFormatRules, CurrencyFormatRules } from "./rules"; + +import { getFormatRules } from "./rules"; + +// --- 날짜 포맷 --- + +type DateFormatType = "display" | "datetime" | "input" | "time"; + +/** + * 날짜 값을 지정된 형식으로 포맷한다. + * @param value - ISO 문자열, Date, 타임스탬프 + * @param type - "display" | "datetime" | "input" | "time" + * @returns 포맷된 문자열 (파싱 실패 시 원본 반환) + */ +export function formatDate(value: unknown, type: DateFormatType = "display"): string { + if (value == null || value === "") return ""; + + const rules = getFormatRules(); + const format = rules.date[type]; + + try { + const date = value instanceof Date ? value : new Date(String(value)); + if (isNaN(date.getTime())) return String(value); + + return applyDateFormat(date, format); + } catch { + return String(value); + } +} + +/** + * YYYY-MM-DD HH:mm:ss 패턴을 Date 객체에 적용 + */ +function applyDateFormat(date: Date, pattern: string): string { + const y = date.getFullYear(); + const M = date.getMonth() + 1; + const d = date.getDate(); + const H = date.getHours(); + const m = date.getMinutes(); + const s = date.getSeconds(); + + return pattern + .replace("YYYY", String(y)) + .replace("MM", String(M).padStart(2, "0")) + .replace("DD", String(d).padStart(2, "0")) + .replace("HH", String(H).padStart(2, "0")) + .replace("mm", String(m).padStart(2, "0")) + .replace("ss", String(s).padStart(2, "0")); +} + +// --- 숫자 포맷 --- + +/** + * 숫자를 로케일 기반으로 포맷한다 (천단위 구분자 등). + * @param value - 숫자 또는 숫자 문자열 + * @param decimals - 소수점 자릿수 (미지정 시 기본값 사용) + * @returns 포맷된 문자열 + */ +export function formatNumber(value: unknown, decimals?: number): string { + if (value == null || value === "") return ""; + + const rules = getFormatRules(); + const num = typeof value === "number" ? value : parseFloat(String(value)); + if (isNaN(num)) return String(value); + + const dec = decimals ?? rules.number.decimals; + + return new Intl.NumberFormat(rules.number.locale, { + minimumFractionDigits: dec, + maximumFractionDigits: dec, + }).format(num); +} + +// --- 통화 포맷 --- + +/** + * 금액을 통화 형식으로 포맷한다. + * @param value - 숫자 또는 숫자 문자열 + * @param currencyCode - 통화 코드 (미지정 시 기본값 사용) + * @returns 포맷된 문자열 (예: "₩50,000") + */ +export function formatCurrency(value: unknown, currencyCode?: string): string { + if (value == null || value === "") return ""; + + const rules = getFormatRules(); + const num = typeof value === "number" ? value : parseFloat(String(value)); + if (isNaN(num)) return String(value); + + const code = currencyCode ?? rules.currency.code; + + return new Intl.NumberFormat(rules.currency.locale, { + style: "currency", + currency: code, + maximumFractionDigits: code === "KRW" ? 0 : 2, + }).format(num); +} + +// --- 범용 포맷 --- + +/** + * 데이터 타입에 따라 자동으로 적절한 포맷을 적용한다. + * @param value - 포맷할 값 + * @param dataType - "date" | "datetime" | "number" | "currency" | "text" + */ +export function formatValue(value: unknown, dataType: string): string { + switch (dataType) { + case "date": + return formatDate(value, "display"); + case "datetime": + return formatDate(value, "datetime"); + case "time": + return formatDate(value, "time"); + case "number": + case "integer": + case "float": + case "decimal": + return formatNumber(value); + case "currency": + case "money": + return formatCurrency(value); + default: + return value == null ? "" : String(value); + } +} diff --git a/frontend/lib/formatting/rules.ts b/frontend/lib/formatting/rules.ts new file mode 100644 index 00000000..dc91a87d --- /dev/null +++ b/frontend/lib/formatting/rules.ts @@ -0,0 +1,71 @@ +/** + * 중앙 포맷팅 규칙 정의. + * 모든 날짜/숫자/통화 포맷은 이 파일의 규칙을 따른다. + * 변경이 필요하면 이 파일만 수정하면 전체 적용된다. + */ + +export interface DateFormatRules { + /** 날짜만 표시 (예: "2025-01-01") */ + display: string; + /** 날짜+시간 표시 (예: "2025-01-01 14:30:00") */ + datetime: string; + /** 입력 필드용 (예: "YYYY-MM-DD") */ + input: string; + /** 시간만 표시 (예: "14:30") */ + time: string; +} + +export interface NumberFormatRules { + /** 숫자 로케일 (천단위 구분자 등) */ + locale: string; + /** 기본 소수점 자릿수 */ + decimals: number; +} + +export interface CurrencyFormatRules { + /** 통화 코드 (예: "KRW", "USD") */ + code: string; + /** 통화 로케일 */ + locale: string; +} + +export interface FormatRules { + date: DateFormatRules; + number: NumberFormatRules; + currency: CurrencyFormatRules; +} + +/** 기본 포맷 규칙 (한국어 기준) */ +export const DEFAULT_FORMAT_RULES: FormatRules = { + date: { + display: "YYYY-MM-DD", + datetime: "YYYY-MM-DD HH:mm:ss", + input: "YYYY-MM-DD", + time: "HH:mm", + }, + number: { + locale: "ko-KR", + decimals: 0, + }, + currency: { + code: "KRW", + locale: "ko-KR", + }, +}; + +/** 현재 적용 중인 포맷 규칙 (런타임에 변경 가능) */ +let currentRules: FormatRules = { ...DEFAULT_FORMAT_RULES }; + +export function getFormatRules(): FormatRules { + return currentRules; +} + +export function setFormatRules(rules: Partial): void { + currentRules = { + ...currentRules, + ...rules, + date: { ...currentRules.date, ...rules.date }, + number: { ...currentRules.number, ...rules.number }, + currency: { ...currentRules.currency, ...rules.currency }, + }; +} diff --git a/frontend/lib/modalPortalRef.ts b/frontend/lib/modalPortalRef.ts new file mode 100644 index 00000000..85c7d66d --- /dev/null +++ b/frontend/lib/modalPortalRef.ts @@ -0,0 +1,31 @@ +"use client"; + +import { useState, useEffect } from "react"; + +/** + * 모달 포탈 컨테이너 전역 레퍼런스. + * TabContent가 마운트 시 registerModalPortal(el)로 등록하고, + * 모달 컴포넌트들은 useModalPortal()로 컨테이너를 구독합니다. + * React 컴포넌트 트리 위치에 무관하게 동작합니다. + */ +let _container: HTMLElement | null = null; +const _subscribers = new Set<(el: HTMLElement | null) => void>(); + +export function registerModalPortal(el: HTMLElement | null) { + _container = el; + _subscribers.forEach((fn) => fn(el)); +} + +export function useModalPortal(): HTMLElement | null { + const [el, setEl] = useState(_container); + + useEffect(() => { + setEl(_container); + _subscribers.add(setEl); + return () => { + _subscribers.delete(setEl); + }; + }, []); + + return el; +} diff --git a/frontend/lib/registry/components/aggregation-widget/AggregationWidgetComponent.tsx b/frontend/lib/registry/components/aggregation-widget/AggregationWidgetComponent.tsx index ce0b0325..cdbb668a 100644 --- a/frontend/lib/registry/components/aggregation-widget/AggregationWidgetComponent.tsx +++ b/frontend/lib/registry/components/aggregation-widget/AggregationWidgetComponent.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, useMemo } from "react"; import { ComponentRendererProps } from "@/types/component"; import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType } from "./types"; +import { formatNumber } from "@/lib/formatting"; import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; @@ -136,11 +137,11 @@ export function AggregationWidgetComponent({ let formattedValue = value.toFixed(item.decimalPlaces ?? 0); if (item.format === "currency") { - formattedValue = new Intl.NumberFormat("ko-KR").format(value); + formattedValue = formatNumber(value); } else if (item.format === "percent") { formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`; } else if (item.format === "number") { - formattedValue = new Intl.NumberFormat("ko-KR").format(value); + formattedValue = formatNumber(value); } if (item.prefix) { diff --git a/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts b/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts index 39aa1c5f..063efe89 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts @@ -3,6 +3,8 @@ * 다양한 집계 연산을 수행합니다. */ +import { getFormatRules } from "@/lib/formatting"; + import { AggregationType, PivotFieldFormat } from "../types"; // ==================== 집계 함수 ==================== @@ -102,16 +104,18 @@ export function formatNumber( let formatted: string; + const locale = getFormatRules().number.locale; + switch (type) { case "currency": - formatted = value.toLocaleString("ko-KR", { + formatted = value.toLocaleString(locale, { minimumFractionDigits: precision, maximumFractionDigits: precision, }); break; case "percent": - formatted = (value * 100).toLocaleString("ko-KR", { + formatted = (value * 100).toLocaleString(locale, { minimumFractionDigits: precision, maximumFractionDigits: precision, }); @@ -120,7 +124,7 @@ export function formatNumber( case "number": default: if (thousandSeparator) { - formatted = value.toLocaleString("ko-KR", { + formatted = value.toLocaleString(locale, { minimumFractionDigits: precision, maximumFractionDigits: precision, }); @@ -138,7 +142,7 @@ export function formatNumber( */ export function formatDate( value: Date | string | null | undefined, - format: string = "YYYY-MM-DD" + format: string = getFormatRules().date.display ): string { if (!value) return "-"; diff --git a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts index 35893dea..0358a3cb 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -48,7 +48,7 @@ function getFieldValue( const weekNum = getWeekNumber(date); return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`; case "day": - return formatDate(date, "YYYY-MM-DD"); + return formatDate(date); default: return String(rawValue); } diff --git a/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx b/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx index 0dbc5033..4ea4153f 100644 --- a/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx +++ b/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx @@ -6,6 +6,7 @@ import { AggregationWidgetConfig, AggregationItem, AggregationResult, Aggregatio import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; +import { formatNumber } from "@/lib/formatting"; import { apiClient } from "@/lib/api/client"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; @@ -566,11 +567,11 @@ export function AggregationWidgetComponent({ let formattedValue = value.toFixed(item.decimalPlaces ?? 0); if (item.format === "currency") { - formattedValue = new Intl.NumberFormat("ko-KR").format(value); + formattedValue = formatNumber(value); } else if (item.format === "percent") { formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`; } else if (item.format === "number") { - formattedValue = new Intl.NumberFormat("ko-KR").format(value); + formattedValue = formatNumber(value); } if (item.prefix) { diff --git a/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx b/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx index 1550bbe3..70c2ed23 100644 --- a/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx +++ b/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx @@ -4,6 +4,7 @@ import React from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { V2DateDefinition } from "./index"; import { V2Date } from "@/components/v2/V2Date"; +import { getFormatRules } from "@/lib/formatting"; /** * V2Date 렌더러 @@ -45,7 +46,7 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer { onChange={handleChange} config={{ dateType: config.dateType || config.webType || "date", - format: config.format || "YYYY-MM-DD", + format: config.format || getFormatRules().date.display, placeholder: config.placeholder || style.placeholder || "날짜 선택", showTime: config.showTime || false, use24Hours: config.use24Hours ?? true, diff --git a/frontend/lib/registry/components/v2-pivot-grid/utils/aggregation.ts b/frontend/lib/registry/components/v2-pivot-grid/utils/aggregation.ts index 39aa1c5f..063efe89 100644 --- a/frontend/lib/registry/components/v2-pivot-grid/utils/aggregation.ts +++ b/frontend/lib/registry/components/v2-pivot-grid/utils/aggregation.ts @@ -3,6 +3,8 @@ * 다양한 집계 연산을 수행합니다. */ +import { getFormatRules } from "@/lib/formatting"; + import { AggregationType, PivotFieldFormat } from "../types"; // ==================== 집계 함수 ==================== @@ -102,16 +104,18 @@ export function formatNumber( let formatted: string; + const locale = getFormatRules().number.locale; + switch (type) { case "currency": - formatted = value.toLocaleString("ko-KR", { + formatted = value.toLocaleString(locale, { minimumFractionDigits: precision, maximumFractionDigits: precision, }); break; case "percent": - formatted = (value * 100).toLocaleString("ko-KR", { + formatted = (value * 100).toLocaleString(locale, { minimumFractionDigits: precision, maximumFractionDigits: precision, }); @@ -120,7 +124,7 @@ export function formatNumber( case "number": default: if (thousandSeparator) { - formatted = value.toLocaleString("ko-KR", { + formatted = value.toLocaleString(locale, { minimumFractionDigits: precision, maximumFractionDigits: precision, }); @@ -138,7 +142,7 @@ export function formatNumber( */ export function formatDate( value: Date | string | null | undefined, - format: string = "YYYY-MM-DD" + format: string = getFormatRules().date.display ): string { if (!value) return "-"; diff --git a/frontend/lib/registry/components/v2-pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/v2-pivot-grid/utils/pivotEngine.ts index 4d3fecfd..5241ae75 100644 --- a/frontend/lib/registry/components/v2-pivot-grid/utils/pivotEngine.ts +++ b/frontend/lib/registry/components/v2-pivot-grid/utils/pivotEngine.ts @@ -47,7 +47,7 @@ function getFieldValue( const weekNum = getWeekNumber(date); return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`; case "day": - return formatDate(date, "YYYY-MM-DD"); + return formatDate(date); default: return String(rawValue); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 01edd32d..a360f556 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -94,7 +94,8 @@ "three": "^0.180.0", "uuid": "^13.0.0", "xlsx": "^0.18.5", - "zod": "^4.1.5" + "zod": "^4.1.5", + "zustand": "^5.0.11" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -15843,9 +15844,9 @@ } }, "node_modules/zustand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", - "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/frontend/package.json b/frontend/package.json index 2de7c057..8a31c255 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -103,7 +103,8 @@ "three": "^0.180.0", "uuid": "^13.0.0", "xlsx": "^0.18.5", - "zod": "^4.1.5" + "zod": "^4.1.5", + "zustand": "^5.0.11" }, "devDependencies": { "@eslint/eslintrc": "^3", From dc04bd162a61b8a918f956f8ed1dc7f3081319f1 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Fri, 27 Feb 2026 16:01:23 +0900 Subject: [PATCH 03/18] refactor: Enhance modal and tab handling in ScreenModal and TabContent components - Removed unnecessary variable `isTabActive` in ScreenModal for cleaner state management. - Updated `useEffect` dependencies to include `tabId` for accurate modal behavior. - Improved tab content caching logic to ensure scroll positions and form states are correctly saved and restored. - Enhanced dialog handling to prevent unintended closures when tabs are inactive, ensuring a smoother user experience. Made-with: Cursor --- frontend/components/common/ScreenModal.tsx | 4 +- frontend/components/layout/TabContent.tsx | 9 +- frontend/components/ui/alert-dialog.tsx | 138 ++++++++++++++++----- frontend/components/ui/dialog.tsx | 37 +++++- 4 files changed, 147 insertions(+), 41 deletions(-) diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 0d0f422a..47cb7549 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -46,7 +46,6 @@ export const ScreenModal: React.FC = ({ className }) => { const splitPanelContext = useSplitPanelContext(); const tabId = useTabId(); const activeTabId = useTabStore((s) => s[s.mode].activeTabId); - const isTabActive = !tabId || tabId === activeTabId; const [modalState, setModalState] = useState({ isOpen: false, @@ -855,7 +854,7 @@ export const ScreenModal: React.FC = ({ className }) => { } else { handleCloseInternal(); } - }, []); + }, [tabId]); // 확인 후 실제로 모달을 닫는 함수 const handleConfirmClose = useCallback(() => { @@ -993,7 +992,6 @@ export const ScreenModal: React.FC = ({ className }) => { { - // X 버튼 클릭 시에도 확인 다이얼로그 표시 if (!open) { handleCloseAttempt(); } diff --git a/frontend/components/layout/TabContent.tsx b/frontend/components/layout/TabContent.tsx index 5d0ce2fd..0c1fabfb 100644 --- a/frontend/components/layout/TabContent.tsx +++ b/frontend/components/layout/TabContent.tsx @@ -96,6 +96,7 @@ export function TabContent() { const prevId = prevActiveTabIdRef.current; // 이전 활성 탭의 스크롤 + 폼 상태 저장 + // 키를 항상 포함하여 이전 캐시의 오래된 값이 병합으로 살아남지 않도록 함 if (prevId && prevId !== activeTabId) { const tabMap = lastScrollMapRef.current.get(prevId); const scrollPositions = @@ -105,8 +106,8 @@ export function TabContent() { const prevEl = scrollRefsMap.current.get(prevId); const formFields = captureFormState(prevEl ?? null); saveTabCacheImmediate(prevId, { - ...(scrollPositions && { scrollPositions }), - ...(formFields && { domFormFields: formFields }), + scrollPositions, + domFormFields: formFields ?? undefined, }); } @@ -148,8 +149,8 @@ export function TabContent() { const finalPositions = scrollPositions || trackedPositions; const formFields = captureFormState(el ?? null); saveTabCacheImmediate(currentActiveId, { - ...(finalPositions && { scrollPositions: finalPositions }), - ...(formFields && { domFormFields: formFields }), + scrollPositions: finalPositions, + domFormFields: formFields ?? undefined, }); }; diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx index 1325f487..f75c75ee 100644 --- a/frontend/components/ui/alert-dialog.tsx +++ b/frontend/components/ui/alert-dialog.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; import { cn } from "@/lib/utils"; import { buttonVariants } from "@/components/ui/button"; @@ -9,18 +10,54 @@ import { useModalPortal } from "@/lib/modalPortalRef"; import { useTabId } from "@/contexts/TabIdContext"; import { useTabStore } from "@/stores/tabStore"; -// AlertDialog: 비활성 탭이면 자동으로 open={false} 처리 +/** + * 탭 시스템 스코프 여부를 하위 컴포넌트에 전달하는 내부 Context. + * scoped=true 이면 AlertDialogPrimitive 대신 DialogPrimitive 사용. + */ +const ScopedAlertCtx = React.createContext(false); + const AlertDialog: React.FC> = ({ open, + children, + onOpenChange, ...props }) => { + const autoContainer = useModalPortal(); + const scoped = !!autoContainer; const tabId = useTabId(); const activeTabId = useTabStore((s) => s[s.mode].activeTabId); const isTabActive = !tabId || tabId === activeTabId; + const isTabActiveRef = React.useRef(isTabActive); + isTabActiveRef.current = isTabActive; + const effectiveOpen = open != null ? open && isTabActive : undefined; - return ; + const guardedOnOpenChange = React.useCallback( + (newOpen: boolean) => { + if (scoped && !newOpen && !isTabActiveRef.current) return; + onOpenChange?.(newOpen); + }, + [scoped, onOpenChange], + ); + + if (scoped) { + return ( + + + {children} + + + ); + } + + return ( + + + {children} + + + ); }; AlertDialog.displayName = "AlertDialog"; @@ -45,9 +82,7 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; interface ScopedAlertDialogContentProps extends React.ComponentPropsWithoutRef { - /** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */ container?: HTMLElement | null; - /** 탭 비활성 시 포탈 내용 숨김 */ hidden?: boolean; } @@ -57,31 +92,62 @@ const AlertDialogContent = React.forwardRef< >(({ className, container: explicitContainer, hidden: hiddenProp, style, ...props }, ref) => { const autoContainer = useModalPortal(); const container = explicitContainer !== undefined ? explicitContainer : autoContainer; - const scoped = !!container; + const scoped = React.useContext(ScopedAlertCtx); const adjustedStyle = scoped && style ? { ...style, maxHeight: undefined, maxWidth: undefined } : style; + const handleInteractOutside = React.useCallback( + (e: any) => { + if (scoped && container) { + const target = (e.detail?.originalEvent?.target ?? e.target) as HTMLElement | null; + if (target && !container.contains(target)) { + e.preventDefault(); + return; + } + } + e.preventDefault(); + }, + [scoped, container], + ); + + if (scoped) { + return ( + +
+
+ e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + className={cn( + "bg-background relative z-1 grid w-full max-w-lg max-h-full gap-4 border p-6 shadow-lg sm:rounded-lg", + className, + )} + style={adjustedStyle} + {...props} + /> +
+ + ); + } + return (
- {scoped ? ( -
- ) : ( - - )} + , React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); +>(({ className, ...props }, ref) => { + const scoped = React.useContext(ScopedAlertCtx); + const Comp = scoped ? DialogPrimitive.Title : AlertDialogPrimitive.Title; + return ; +}); AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; const AlertDialogDescription = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); +>(({ className, ...props }, ref) => { + const scoped = React.useContext(ScopedAlertCtx); + const Comp = scoped ? DialogPrimitive.Description : AlertDialogPrimitive.Description; + return ; +}); AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; const AlertDialogAction = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); +>(({ className, ...props }, ref) => { + const scoped = React.useContext(ScopedAlertCtx); + const Comp = scoped ? DialogPrimitive.Close : AlertDialogPrimitive.Action; + return ; +}); AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; const AlertDialogCancel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); +>(({ className, ...props }, ref) => { + const scoped = React.useContext(ScopedAlertCtx); + const Comp = scoped ? DialogPrimitive.Close : AlertDialogPrimitive.Cancel; + return ( + + ); +}); AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; export { diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx index 3e17f5a5..83299b92 100644 --- a/frontend/components/ui/dialog.tsx +++ b/frontend/components/ui/dialog.tsx @@ -13,17 +13,35 @@ import { useTabStore } from "@/stores/tabStore"; const Dialog: React.FC> = ({ modal, open, + onOpenChange, ...props }) => { const autoContainer = useModalPortal(); + const scoped = !!autoContainer; const tabId = useTabId(); const activeTabId = useTabStore((s) => s[s.mode].activeTabId); const isTabActive = !tabId || tabId === activeTabId; - const effectiveModal = modal !== undefined ? modal : !autoContainer ? undefined : false; + // ref로 최신 isTabActive를 동기적으로 추적 (useEffect보다 빠르게 업데이트) + const isTabActiveRef = React.useRef(isTabActive); + isTabActiveRef.current = isTabActive; + + const effectiveModal = modal !== undefined ? modal : !scoped ? undefined : false; const effectiveOpen = open != null ? open && isTabActive : undefined; - return ; + // 비활성 탭에서 발생하는 onOpenChange(false) 차단 + // (탭 전환 시 content unmount → focus 이동 → Radix가 onOpenChange(false)를 호출하는 것을 방지) + const guardedOnOpenChange = React.useCallback( + (newOpen: boolean) => { + if (scoped && !newOpen && !isTabActiveRef.current) { + return; + } + onOpenChange?.(newOpen); + }, + [scoped, onOpenChange, tabId], + ); + + return ; }; Dialog.displayName = "Dialog"; @@ -59,7 +77,7 @@ interface ScopedDialogContentProps const DialogContent = React.forwardRef< React.ElementRef, ScopedDialogContentProps ->(({ className, children, container: explicitContainer, hidden: hiddenProp, onInteractOutside, style, ...props }, ref) => { +>(({ className, children, container: explicitContainer, hidden: hiddenProp, onInteractOutside, onFocusOutside, style, ...props }, ref) => { const autoContainer = useModalPortal(); const container = explicitContainer !== undefined ? explicitContainer : autoContainer; const scoped = !!container; @@ -78,6 +96,18 @@ const DialogContent = React.forwardRef< [scoped, container, onInteractOutside], ); + // scoped 모드: content unmount 시 포커스 이동으로 인한 onOpenChange(false) 방지 + const handleFocusOutside = React.useCallback( + (e: any) => { + if (scoped) { + e.preventDefault(); + return; + } + onFocusOutside?.(e); + }, + [scoped, onFocusOutside], + ); + // scoped 모드: 뷰포트 기반 maxHeight/maxWidth 제거 → className의 max-h-full이 컨테이너 기준으로 적용됨 const adjustedStyle = scoped && style ? { ...style, maxHeight: undefined, maxWidth: undefined } @@ -97,6 +127,7 @@ const DialogContent = React.forwardRef< Date: Fri, 27 Feb 2026 18:11:59 +0900 Subject: [PATCH 04/18] feat: Enhance form validation and modal handling in various components - Added `isInModal` prop to `ScreenModal` and `InteractiveScreenViewerDynamic` for improved modal context awareness. - Implemented `isFieldEmpty` and `checkAllRequiredFieldsFilled` utility functions to validate required fields in forms. - Updated `SaveModal` and `ButtonPrimaryComponent` to disable save actions when required fields are missing, enhancing user feedback. - Introduced error messages for required fields in modals to guide users in completing necessary inputs. Made-with: Cursor --- frontend/components/common/ScreenModal.tsx | 2 + .../screen/InteractiveScreenViewerDynamic.tsx | 47 +++++++++++- frontend/components/screen/SaveModal.tsx | 10 ++- .../button-primary/ButtonPrimaryComponent.tsx | 13 +++- .../ButtonPrimaryComponent.tsx | 22 +++--- frontend/lib/utils/formValidation.ts | 73 +++++++++++++++++++ 6 files changed, 151 insertions(+), 16 deletions(-) diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 47cb7549..a57b5ed4 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1218,6 +1218,7 @@ export const ScreenModal: React.FC = ({ className }) => { userId={userId} userName={userName} companyCode={user?.companyCode} + isInModal={true} /> ); }); @@ -1261,6 +1262,7 @@ export const ScreenModal: React.FC = ({ className }) => { userId={userId} userName={userName} companyCode={user?.companyCode} + isInModal={true} /> ); })} diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index a35c5ed2..249f92eb 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -14,6 +14,7 @@ import { DynamicWebTypeRenderer } from "@/lib/registry"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils"; +import { isFieldEmpty } from "@/lib/utils/formValidation"; import { FlowButtonGroup } from "./widgets/FlowButtonGroup"; import { FlowVisibilityConfig } from "@/types/control-management"; import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils"; @@ -447,6 +448,7 @@ export const InteractiveScreenViewerDynamic: React.FC { // buttonActions.ts가 이미 처리함 }} + isInModal={isInModal} // 탭 관련 정보 전달 parentTabId={parentTabId} parentTabsComponentId={parentTabsComponentId} @@ -956,7 +958,7 @@ export const InteractiveScreenViewerDynamic: React.FC { const compType = (component as any).componentType || ""; const isSplitLine = type === "component" && compType === "v2-split-line"; @@ -1204,7 +1242,7 @@ export const InteractiveScreenViewerDynamic: React.FC 0 ? "visible" : undefined), + overflow: (isSplitActive && adjustedW < origW) ? "hidden" : ((labelOffset > 0 || showRequiredError) ? "visible" : undefined), willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined, transition: isSplitActive ? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out") @@ -1317,6 +1355,11 @@ export const InteractiveScreenViewerDynamic: React.FC + 필수 입력 항목입니다 +

+ )}
{/* 팝업 화면 렌더링 */} diff --git a/frontend/components/screen/SaveModal.tsx b/frontend/components/screen/SaveModal.tsx index 1c848d6a..18c9127b 100644 --- a/frontend/components/screen/SaveModal.tsx +++ b/frontend/components/screen/SaveModal.tsx @@ -11,6 +11,7 @@ import { screenApi } from "@/lib/api/screen"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { ComponentData } from "@/lib/types/screen"; import { useAuth } from "@/hooks/useAuth"; +import { checkAllRequiredFieldsFilled } from "@/lib/utils/formValidation"; interface SaveModalProps { isOpen: boolean; @@ -304,6 +305,7 @@ export const SaveModal: React.FC = ({ }; const dynamicSize = calculateDynamicSize(); + const isRequiredFieldsMissing = !checkAllRequiredFieldsFilled(components, formData); return ( !isSaving && !open && onClose()}> @@ -320,7 +322,13 @@ export const SaveModal: React.FC = ({
{initialData ? "데이터 수정" : "데이터 등록"}
- )}
@@ -389,19 +389,20 @@ export function TabBar() { <>
+
{displayVisible.map((tab, i) => renderTab(tab, i))} {hasOverflow && ( - @@ -425,10 +426,10 @@ export function TabBar() { {ghostStyle && draggedTab && (
- {draggedTab.title} + {draggedTab.title}
)} From aa020bfdd867c8a727d68144032bd7602982f1f8 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Tue, 3 Mar 2026 12:07:12 +0900 Subject: [PATCH 06/18] feat: Implement automatic validation for modal forms - Introduced a new hook `useDialogAutoValidation` to handle automatic validation of required fields in modals. - Added visual feedback for empty required fields, including red borders and error messages. - Disabled action buttons when required fields are not filled, enhancing user experience. - Updated `DialogContent` to integrate the new validation logic, ensuring that only user mode modals are validated. Made-with: Cursor --- .../ycshin-node/필수입력항목_자동검증_설계.md | 188 +++++++++++++++++ frontend/components/ui/button.tsx | 9 +- frontend/components/ui/dialog.tsx | 17 +- frontend/lib/hooks/useDialogAutoValidation.ts | 191 ++++++++++++++++++ 4 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 docs/ycshin-node/필수입력항목_자동검증_설계.md create mode 100644 frontend/lib/hooks/useDialogAutoValidation.ts diff --git a/docs/ycshin-node/필수입력항목_자동검증_설계.md b/docs/ycshin-node/필수입력항목_자동검증_설계.md new file mode 100644 index 00000000..3fc764eb --- /dev/null +++ b/docs/ycshin-node/필수입력항목_자동검증_설계.md @@ -0,0 +1,188 @@ +# 모달 자동 검증 설계 + +## 1. 목표 + +모든 모달에서 필수 입력값이 있는 경우: +- 빈 필수 필드 아래에 경고 문구 표시 +- 모든 필수 필드가 입력되기 전까지 저장/등록 버튼 비활성화 + +--- + +## 2. 전체 구조 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ DialogContent (모든 모달의 공통 래퍼) │ +│ │ +│ useDialogAutoValidation(contentRef) │ +│ │ │ +│ ├─ 0단계: 모드 확인 │ +│ │ └─ useTabStore.mode === "user" 일 때만 실행 │ +│ │ (관리자 모드에서는 return → 나중에 필요 시 확장) │ +│ │ │ +│ ├─ 1단계: 필수 필드 탐지 │ +│ │ └─ Label 내부 안에 * 문자 존재 여부 │ +│ │ (라벨 텍스트 직접 매칭 X → span 태그 안의 * 만 감지) │ +│ │ │ +│ ├─ 2단계: 시각적 피드백 │ +│ │ ├─ 빈 필수 필드 → 빨간 테두리 (border-destructive) │ +│ │ └─ 필드 아래 에러 메시지 주입 ("필수 입력 항목입니다") │ +│ │ │ +│ └─ 3단계: 버튼 비활성화 │ +│ │ │ +│ ├─ 대상: data-variant="default" 인 버튼 │ +│ │ (저장, 등록, 수정, 확인 등 — variant 미지정 = default) │ +│ │ │ +│ ├─ 제외: outline, ghost, destructive, secondary │ +│ │ (취소, 닫기, X, 삭제 등) │ +│ │ │ +│ ├─ 빈 필수 필드 있음 → 버튼 반투명 + 클릭 차단 │ +│ └─ 모든 필수 필드 입력됨 → 정상 활성화 │ +│ │ +│ 제외 조건: │ +│ └─ 필수 필드가 0개인 모달 (자동 비활성) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. 필수 필드 감지: span 기반 * 감지 + +### 원리 + +화면 관리에서 필드를 "필수"로 체크하면 `component.required = true`가 저장된다. +V2 컴포넌트가 렌더링할 때 `required = true`이면 Label 안에 `*`을 추가한다. +훅은 이 span 안의 `*`를 감지하여 필수 필드를 식별한다. + +### 오탐 방지 + +관리자가 라벨 텍스트에 직접 `*`를 입력해도 span 안에 들어가지 않으므로 오탐이 발생하지 않는다. + +``` +required = true → + → span 안에 * 있음 → 감지 O + +required = false → + → span 없음 → 감지 X + +라벨에 * 직접 입력 → + → span 없이 텍스트에 * → 감지 X (오탐 방지) +``` + +### 코드 + +```typescript +const hasRequiredMark = Array.from(label.querySelectorAll("span")) + .some(span => span.textContent?.trim() === "*"); +if (!hasRequiredMark) return; +``` + +--- + +## 4. 버튼 식별: data-variant 속성 기반 + +### 원리 + +shadcn Button 컴포넌트의 `variant` 값을 `data-variant` 속성으로 DOM에 노출한다. +텍스트 매칭 없이 버튼의 역할을 식별할 수 있다. + +### 비활성화 대상 + +| data-variant | 용도 | 훅 동작 | +|:---:|------|:---:| +| `default` | 저장, 등록, 수정, 확인 | 비활성화 대상 | + +### 제외 (건드리지 않음) + +| data-variant | 용도 | +|:---:|------| +| `outline` | 취소 | +| `ghost` | 닫기, X 버튼 | +| `destructive` | 삭제 | +| `secondary` | 보조 액션 | + +--- + +## 5. 동작 흐름 + +``` +모달 열림 + │ + ▼ +DialogContent 마운트 + │ + ▼ +useDialogAutoValidation 실행 + │ + ▼ +모드 확인 (useTabStore.mode) + │ + ├─ mode !== "user"? → return (관리자 모드에서는 비활성) + │ + ▼ +필수 필드 탐지 (Label 내 span에서 * 감지) + │ + ├─ 필수 필드 0개? → return (비활성) + │ + ▼ +초기 검증 실행 (50ms 후) + │ + ├─ 빈 필수 필드 발견 + │ ├─ 해당 input에 border-destructive 클래스 추가 + │ ├─ input 아래에 "필수 입력 항목입니다" 에러 메시지 주입 + │ └─ data-variant="default" 버튼 비활성화 + │ + ├─ 모든 필수 필드 입력됨 + │ ├─ 에러 메시지 제거 + │ ├─ border-destructive 제거 + │ └─ 버튼 활성화 + │ + ▼ +이벤트 리스너 등록 + ├─ input 이벤트 → 재검증 + ├─ change 이벤트 → 재검증 + ├─ click 캡처링 → 비활성 버튼 클릭 차단 + └─ MutationObserver → DOM 변경 시 재검증 + +모달 닫힘 + │ + ▼ +클린업 + ├─ 이벤트 리스너 제거 + ├─ MutationObserver 해제 + ├─ 주입된 에러 메시지 제거 + ├─ 버튼 비활성화 상태 복원 + └─ border-destructive 클래스 제거 +``` + +--- + +## 6. 관련 파일 + +| 파일 | 역할 | +|------|------| +| `frontend/lib/hooks/useDialogAutoValidation.ts` | 자동 검증 훅 본체 | +| `frontend/components/ui/button.tsx` | data-variant 속성 노출 | +| `frontend/components/ui/dialog.tsx` | DialogContent에서 훅 호출 | + +--- + +## 7. 적용 범위 + +### 현재 (1단계): 사용자 모드만 + +| 모달 유형 | 동작 여부 | 이유 | +|---------------------------------------|:---:|-------------------------------| +| 사용자 모드 모달 (SaveModal 등) | O | mode === "user" + span * 있음 | +| 관리자 모드 모달 (CodeFormModal 등) | X | mode !== "user" → return | +| 확인/삭제 다이얼로그 (필수 필드 없음) | X | 필수 필드 0개 → 자동 제외 | + +### 나중에 (2단계): 관리자 모드 확장 시 + +```typescript +// 1단계 (현재) +if (mode !== "user") return; + +// 2단계 (확장) +if (!["user", "admin"].includes(mode)) return; +``` diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx index 9a7847f7..42f71f26 100644 --- a/frontend/components/ui/button.tsx +++ b/frontend/components/ui/button.tsx @@ -44,7 +44,14 @@ function Button({ }) { const Comp = asChild ? Slot : "button"; - return ; + return ( + + ); } export { Button, buttonVariants }; diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx index 83299b92..df383f25 100644 --- a/frontend/components/ui/dialog.tsx +++ b/frontend/components/ui/dialog.tsx @@ -8,6 +8,7 @@ import { cn } from "@/lib/utils"; import { useModalPortal } from "@/lib/modalPortalRef"; import { useTabId } from "@/contexts/TabIdContext"; import { useTabStore } from "@/stores/tabStore"; +import { useDialogAutoValidation } from "@/lib/hooks/useDialogAutoValidation"; // Dialog: 탭 시스템 내에서 자동으로 modal={false} + 비활성 탭이면 open={false} 처리 const Dialog: React.FC> = ({ @@ -82,6 +83,18 @@ const DialogContent = React.forwardRef< const container = explicitContainer !== undefined ? explicitContainer : autoContainer; const scoped = !!container; + // 모달 자동 검증용 내부 ref + const internalRef = React.useRef(null); + const mergedRef = React.useCallback( + (node: HTMLDivElement | null) => { + internalRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) (ref as React.MutableRefObject).current = node; + }, + [ref], + ); + useDialogAutoValidation(internalRef); + const handleInteractOutside = React.useCallback( (e: any) => { if (scoped && container) { @@ -125,7 +138,7 @@ const DialogContent = React.forwardRef< )} ) => ( -
+
); DialogFooter.displayName = "DialogFooter"; diff --git a/frontend/lib/hooks/useDialogAutoValidation.ts b/frontend/lib/hooks/useDialogAutoValidation.ts new file mode 100644 index 00000000..50010f4d --- /dev/null +++ b/frontend/lib/hooks/useDialogAutoValidation.ts @@ -0,0 +1,191 @@ +"use client"; + +import { useEffect, useRef, type RefObject } from "react"; +import { useTabStore } from "@/stores/tabStore"; + +const ERROR_ATTR = "data-auto-validation-error"; +const DISABLED_ATTR = "data-validation-disabled"; +const ACTION_BTN_SELECTOR = '[data-variant="default"]'; + +/** + * 모달 자동 폼 검증 훅 + * + * 활성화 조건: + * - useTabStore.mode === "user" (사용자 모드) + * - 필수 필드(label 내 *)가 1개 이상 존재 + * + * 동작: + * - Label 내부 안의 * 문자로 필수 필드 자동 탐지 + * - 빈 필수 필드에 빨간 테두리 + 에러 메시지 주입 + * - data-variant="default" 버튼 비활성화 (저장/등록/수정/확인) + */ +export function useDialogAutoValidation( + contentRef: RefObject, +) { + const mode = useTabStore((s) => s.mode); + const activeRef = useRef(false); + + useEffect(() => { + if (mode !== "user") return; + + const el = contentRef.current; + if (!el) return; + + activeRef.current = true; + const injected = new Set(); + let isValidating = false; + + type InputEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; + + function findRequiredFields(): Map { + const fields = new Map(); + if (!el) return fields; + + el.querySelectorAll("label").forEach((label) => { + const hasRequiredMark = Array.from(label.querySelectorAll("span")).some( + (span) => span.textContent?.trim() === "*", + ); + if (!hasRequiredMark) return; + + const forId = + label.getAttribute("for") || (label as HTMLLabelElement).htmlFor; + let input: Element | null = null; + + if (forId) { + try { + input = el!.querySelector(`#${CSS.escape(forId)}`); + } catch { + /* invalid id */ + } + } + + if (!input) { + const parent = + label.closest('[class*="space-y"]') || label.parentElement; + input = parent?.querySelector("input, textarea, select") || null; + } + + if ( + input instanceof HTMLInputElement || + input instanceof HTMLTextAreaElement || + input instanceof HTMLSelectElement + ) { + const labelText = + label.textContent?.replace(/\*/g, "").trim() || ""; + fields.set(input, labelText); + } + }); + + return fields; + } + + function isEmpty(input: InputEl): boolean { + return input.value.trim() === ""; + } + + function validate() { + if (isValidating) return; + isValidating = true; + + try { + const fields = findRequiredFields(); + if (fields.size === 0) return; + + let hasEmpty = false; + + fields.forEach((_label, input) => { + if (isEmpty(input)) { + hasEmpty = true; + input.classList.add("border-destructive"); + + const parent = input.parentElement; + if (parent && !parent.querySelector(`[${ERROR_ATTR}]`)) { + const p = document.createElement("p"); + p.className = "text-xs text-destructive mt-1"; + p.textContent = "필수 입력 항목입니다"; + p.setAttribute(ERROR_ATTR, "true"); + input.insertAdjacentElement("afterend", p); + injected.add(p); + } + } else { + input.classList.remove("border-destructive"); + + const errorEl = input.parentElement?.querySelector( + `[${ERROR_ATTR}]`, + ); + if (errorEl) { + injected.delete(errorEl as HTMLElement); + errorEl.remove(); + } + } + }); + + updateButtons(hasEmpty); + } finally { + requestAnimationFrame(() => { + isValidating = false; + }); + } + } + + function updateButtons(hasErrors: boolean) { + el!.querySelectorAll(ACTION_BTN_SELECTOR).forEach( + (btn) => { + if (hasErrors) { + btn.setAttribute(DISABLED_ATTR, "true"); + btn.style.opacity = "0.5"; + btn.style.cursor = "not-allowed"; + btn.title = "필수 입력 항목을 모두 채워주세요"; + } else if (btn.hasAttribute(DISABLED_ATTR)) { + btn.removeAttribute(DISABLED_ATTR); + btn.style.opacity = ""; + btn.style.cursor = ""; + btn.title = ""; + } + }, + ); + } + + function blockClick(e: Event) { + const btn = (e.target as HTMLElement).closest(`[${DISABLED_ATTR}]`); + if (btn) { + e.stopPropagation(); + e.preventDefault(); + } + } + + el.addEventListener("input", validate); + el.addEventListener("change", validate); + el.addEventListener("click", blockClick, true); + + const initTimer = setTimeout(validate, 50); + + const observer = new MutationObserver(() => { + if (!isValidating) validate(); + }); + observer.observe(el, { childList: true, subtree: true }); + + return () => { + activeRef.current = false; + el.removeEventListener("input", validate); + el.removeEventListener("change", validate); + el.removeEventListener("click", blockClick, true); + clearTimeout(initTimer); + observer.disconnect(); + + injected.forEach((p) => p.remove()); + injected.clear(); + + el.querySelectorAll(`[${DISABLED_ATTR}]`).forEach((btn) => { + btn.removeAttribute(DISABLED_ATTR); + (btn as HTMLElement).style.opacity = ""; + (btn as HTMLElement).style.cursor = ""; + (btn as HTMLElement).title = ""; + }); + + el.querySelectorAll(".border-destructive").forEach((input) => { + input.classList.remove("border-destructive"); + }); + }; + }, [mode]); +} From eb2bd8f10fff03bb724687b258310eae8ab91d8e Mon Sep 17 00:00:00 2001 From: syc0123 Date: Tue, 3 Mar 2026 14:54:41 +0900 Subject: [PATCH 07/18] feat: Enhance modal button behavior and validation feedback - Updated modal button handling to disable all buttons by default, with exceptions for specific button types (e.g., cancel, close, delete). - Introduced a new validation mechanism that visually indicates empty required fields with red borders and error messages after a delay. - Improved the `useDialogAutoValidation` hook to manage button states based on field validation, ensuring a smoother user experience. - Added CSS animations to prevent flickering during validation state changes. Made-with: Cursor --- .../ycshin-node/필수입력항목_자동검증_설계.md | 70 +++--- frontend/app/globals.css | 39 ++++ .../screen/InteractiveScreenViewerDynamic.tsx | 2 + frontend/components/ui/dialog.tsx | 10 +- frontend/components/v2/V2Select.tsx | 5 +- frontend/lib/hooks/useDialogAutoValidation.ts | 215 ++++++++++++------ .../ButtonPrimaryComponent.tsx | 1 + 7 files changed, 236 insertions(+), 106 deletions(-) diff --git a/docs/ycshin-node/필수입력항목_자동검증_설계.md b/docs/ycshin-node/필수입력항목_자동검증_설계.md index 3fc764eb..6f1fc6da 100644 --- a/docs/ycshin-node/필수입력항목_자동검증_설계.md +++ b/docs/ycshin-node/필수입력항목_자동검증_설계.md @@ -30,14 +30,20 @@ │ │ │ │ └─ 3단계: 버튼 비활성화 │ │ │ │ -│ ├─ 대상: data-variant="default" 인 버튼 │ -│ │ (저장, 등록, 수정, 확인 등 — variant 미지정 = default) │ +│ ├─ 기본: 모달 내 모든 ` (data-variant="default") | shadcn 기본 버튼 | +| ` From 2647031ef75b43532fc0def07970038f71da498f Mon Sep 17 00:00:00 2001 From: syc0123 Date: Tue, 3 Mar 2026 16:43:56 +0900 Subject: [PATCH 08/18] feat: Enhance TabBar component with drag-and-drop functionality and drop ghost animation - Added support for drag-and-drop functionality in the TabBar component, allowing users to reorder tabs seamlessly. - Introduced a drop ghost feature that visually represents the target position of a dragged tab, enhancing user experience during tab reordering. - Updated the timing for settling animations to improve responsiveness and visual feedback. - Refactored state management to accommodate new drag-and-drop logic, ensuring smooth interactions and animations. Made-with: Cursor --- frontend/components/layout/TabBar.tsx | 304 ++++++++++++++++++++++---- 1 file changed, 257 insertions(+), 47 deletions(-) diff --git a/frontend/components/layout/TabBar.tsx b/frontend/components/layout/TabBar.tsx index f2616173..8ef3d0b1 100644 --- a/frontend/components/layout/TabBar.tsx +++ b/frontend/components/layout/TabBar.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useRef, useState, useEffect, useCallback } from "react"; +import React, { useRef, useState, useEffect, useLayoutEffect, useCallback } from "react"; import { X, RotateCw, ChevronDown } from "lucide-react"; import { useTabStore, selectTabs, selectActiveTabId, Tab } from "@/stores/tabStore"; import { menuScreenApi } from "@/lib/api/screen"; @@ -17,7 +17,8 @@ const TAB_GAP = 2; const TAB_UNIT = TAB_WIDTH + TAB_GAP; const OVERFLOW_BTN_WIDTH = 48; const DRAG_THRESHOLD = 5; -const SETTLE_MS = 200; +const SETTLE_MS = 70; +const DROP_SETTLE_MS = 180; const BAR_PAD_X = 8; interface DragState { @@ -32,30 +33,98 @@ interface DragState { settling: boolean; } +interface DropGhost { + title: string; + startX: number; + startY: number; + targetIdx: number; + tabCountAtCreation: number; +} + export function TabBar() { const tabs = useTabStore(selectTabs); const activeTabId = useTabStore(selectActiveTabId); const { - switchTab, closeTab, refreshTab, - closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, - updateTabOrder, openTab, + switchTab, + closeTab, + refreshTab, + closeOtherTabs, + closeTabsToLeft, + closeTabsToRight, + closeAllTabs, + updateTabOrder, + openTab, } = useTabStore(); + // --- Refs --- const containerRef = useRef(null); const settleTimer = useRef | null>(null); const dragActiveRef = useRef(false); + const dragLeaveTimerRef = useRef | null>(null); + const dropGhostRef = useRef(null); + const prevTabCountRef = useRef(tabs.length); + // --- State --- const [visibleCount, setVisibleCount] = useState(tabs.length); - const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: string } | null>(null); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + tabId: string; + } | null>(null); const [dragState, setDragState] = useState(null); const [externalDragIdx, setExternalDragIdx] = useState(null); + const [dropGhost, setDropGhost] = useState(null); dragActiveRef.current = !!dragState; + // --- 타이머 정리 --- useEffect(() => { - return () => { if (settleTimer.current) clearTimeout(settleTimer.current); }; + return () => { + if (settleTimer.current) clearTimeout(settleTimer.current); + if (dragLeaveTimerRef.current) clearTimeout(dragLeaveTimerRef.current); + }; }, []); + // --- 드롭 고스트: Web Animations API로 드롭 위치 → 목표 슬롯 이동 --- + useEffect(() => { + if (!dropGhost) return; + const el = dropGhostRef.current; + const bar = containerRef.current?.getBoundingClientRect(); + if (!el || !bar) return; + + const targetX = bar.left + BAR_PAD_X + dropGhost.targetIdx * TAB_UNIT; + const targetY = bar.bottom - 28; + const dx = dropGhost.startX - targetX; + const dy = dropGhost.startY - targetY; + + const anim = el.animate( + [ + { transform: `translate(${dx}px, ${dy}px)`, opacity: 0.85 }, + { transform: "translate(0, 0)", opacity: 1 }, + ], + { + duration: DROP_SETTLE_MS, + easing: "cubic-bezier(0.25, 1, 0.5, 1)", + fill: "forwards", + }, + ); + + anim.onfinish = () => { + setDropGhost(null); + setExternalDragIdx(null); + }; + + const safety = setTimeout(() => { + setDropGhost(null); + setExternalDragIdx(null); + }, DROP_SETTLE_MS + 500); + + return () => { + anim.cancel(); + clearTimeout(safety); + }; + }, [dropGhost]); + // --- 오버플로우 계산 (드래그 중 재계산 방지) --- const recalcVisible = useCallback(() => { if (dragActiveRef.current) return; @@ -71,7 +140,9 @@ export function TabBar() { return () => obs.disconnect(); }, [recalcVisible]); - useEffect(() => { recalcVisible(); }, [tabs.length, recalcVisible]); + useLayoutEffect(() => { + recalcVisible(); + }, [tabs.length, recalcVisible]); const visibleTabs = tabs.slice(0, visibleCount); const overflowTabs = tabs.slice(visibleCount); @@ -94,8 +165,18 @@ export function TabBar() { // 사이드바 -> 탭 바 드롭 (네이티브 DnD + 삽입 위치 애니메이션) // ============================================================ + useLayoutEffect(() => { + if (tabs.length !== prevTabCountRef.current && externalDragIdx !== null) { + setExternalDragIdx(null); + } + prevTabCountRef.current = tabs.length; + }, [tabs.length, externalDragIdx]); + const resolveMenuAndOpenTab = async ( - menuName: string, menuObjid: string | number, url: string, insertIndex?: number, + menuName: string, + menuObjid: string | number, + url: string, + insertIndex?: number, ) => { const numericObjid = typeof menuObjid === "string" ? parseInt(menuObjid) : menuObjid; try { @@ -107,45 +188,84 @@ export function TabBar() { ); return; } - } catch { /* ignore */ } + } catch { + /* ignore */ + } if (url && url !== "#") { openTab({ type: "admin", title: menuName, adminUrl: url }, insertIndex); + } else { + setExternalDragIdx(null); } }; const handleBarDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; + if (dragLeaveTimerRef.current) { + clearTimeout(dragLeaveTimerRef.current); + dragLeaveTimerRef.current = null; + } const bar = containerRef.current?.getBoundingClientRect(); if (bar) { - let idx = Math.round((e.clientX - bar.left - BAR_PAD_X) / TAB_UNIT); - idx = Math.max(0, Math.min(idx, displayVisible.length)); + const idx = Math.max( + 0, + Math.min(Math.round((e.clientX - bar.left - BAR_PAD_X) / TAB_UNIT), displayVisible.length), + ); setExternalDragIdx(idx); } }; const handleBarDragLeave = (e: React.DragEvent) => { if (!containerRef.current?.contains(e.relatedTarget as Node)) { - setExternalDragIdx(null); + dragLeaveTimerRef.current = setTimeout(() => { + setExternalDragIdx(null); + dragLeaveTimerRef.current = null; + }, 50); } }; + const createDropGhost = (e: React.DragEvent, title: string, targetIdx: number) => { + setDropGhost({ + title, + startX: e.clientX - TAB_WIDTH / 2, + startY: e.clientY - 14, + targetIdx, + tabCountAtCreation: tabs.length, + }); + }; + const handleBarDrop = (e: React.DragEvent) => { e.preventDefault(); + if (dragLeaveTimerRef.current) { + clearTimeout(dragLeaveTimerRef.current); + dragLeaveTimerRef.current = null; + } const insertIdx = externalDragIdx ?? undefined; - setExternalDragIdx(null); + const ghostIdx = insertIdx ?? displayVisible.length; const pending = e.dataTransfer.getData("application/tab-menu-pending"); if (pending) { try { const { menuName, menuObjid, url } = JSON.parse(pending); + createDropGhost(e, menuName, ghostIdx); resolveMenuAndOpenTab(menuName, menuObjid, url, insertIdx); - } catch { /* ignore */ } + } catch { + setExternalDragIdx(null); + } return; } const menuData = e.dataTransfer.getData("application/tab-menu"); if (menuData && menuData.length > 2) { - try { openTab(JSON.parse(menuData), insertIdx); } catch { /* ignore */ } + try { + const parsed = JSON.parse(menuData); + createDropGhost(e, parsed.title || "새 탭", ghostIdx); + setExternalDragIdx(null); + openTab(parsed, insertIdx); + } catch { + setExternalDragIdx(null); + } + } else { + setExternalDragIdx(null); } }; @@ -154,11 +274,9 @@ export function TabBar() { // ============================================================ const calcTarget = useCallback( - (clientX: number): number => { - const bar = containerRef.current?.getBoundingClientRect(); - if (!bar) return 0; - let idx = Math.round((clientX - bar.left - BAR_PAD_X) / TAB_UNIT); - return Math.max(0, Math.min(idx, displayVisible.length - 1)); + (clientX: number, startX: number, fromIndex: number): number => { + const delta = Math.round((clientX - startX) / TAB_UNIT); + return Math.max(0, Math.min(fromIndex + delta, displayVisible.length - 1)); }, [displayVisible.length], ); @@ -194,13 +312,20 @@ export function TabBar() { if (!dragState.activated) { if (Math.abs(clampedX - dragState.startX) < DRAG_THRESHOLD) return; setDragState((p) => - p ? { ...p, activated: true, currentX: clampedX, targetIndex: calcTarget(clampedX) } : null, + p + ? { + ...p, + activated: true, + currentX: clampedX, + targetIndex: calcTarget(clampedX, p.startX, p.fromIndex), + } + : null, ); return; } setDragState((p) => - p ? { ...p, currentX: clampedX, targetIndex: calcTarget(clampedX) } : null, + p ? { ...p, currentX: clampedX, targetIndex: calcTarget(clampedX, p.startX, p.fromIndex) } : null, ); }, [dragState, calcTarget], @@ -211,7 +336,6 @@ export function TabBar() { if (!dragState || dragState.settling) return; (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); - // 임계값 미달 → 클릭으로 처리 if (!dragState.activated) { switchTab(dragState.tabId); setDragState(null); @@ -220,16 +344,13 @@ export function TabBar() { const { fromIndex, targetIndex, tabId } = dragState; - // settling 시작: 고스트가 목표(또는 원래) 슬롯으로 부드럽게 복귀 setDragState((p) => (p ? { ...p, settling: true } : null)); if (targetIndex === fromIndex) { - // 이동 없음: 고스트가 원래 위치로 애니메이션 후 정리 - settleTimer.current = setTimeout(() => setDragState(null), SETTLE_MS + 30); + settleTimer.current = setTimeout(() => setDragState(null), SETTLE_MS + 10); return; } - // 실제 배열 인덱스 계산 (setTimeout 전에 캡처) const actualFrom = tabs.findIndex((t) => t.id === tabId); const tgtTab = displayVisible[targetIndex]; const actualTo = tgtTab ? tabs.findIndex((t) => t.id === tgtTab.id) : actualFrom; @@ -239,7 +360,7 @@ export function TabBar() { if (actualFrom !== -1 && actualTo !== -1 && actualFrom !== actualTo) { updateTabOrder(actualFrom, actualTo); } - }, SETTLE_MS + 30); + }, SETTLE_MS + 10); }, [dragState, tabs, displayVisible, switchTab, updateTabOrder], ); @@ -249,15 +370,13 @@ export function TabBar() { // ============================================================ const getTabAnimStyle = (tabId: string, index: number): React.CSSProperties => { - // 사이드바 드래그 호버: 삽입 지점 이후 탭이 오른쪽으로 shift if (externalDragIdx !== null && !dragState) { return { transform: index >= externalDragIdx ? `translateX(${TAB_UNIT}px)` : "none", - transition: `transform ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`, + transition: `transform ${DROP_SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`, }; } - // 탭 드래그 미활성화 → 기본 if (!dragState || !dragState.activated) return {}; const { fromIndex, targetIndex, tabId: draggedId } = dragState; @@ -298,7 +417,9 @@ export function TabBar() { return { ...base, left: bar.left + BAR_PAD_X + dragState.targetIndex * TAB_UNIT, - transition: `left ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`, + opacity: 1, + boxShadow: "none", + transition: `left ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1), box-shadow 80ms ease-out`, }; } @@ -341,6 +462,8 @@ export function TabBar() { const renderTab = (tab: Tab, displayIndex: number) => { const isActive = tab.id === activeTabId; const animStyle = getTabAnimStyle(tab.id, displayIndex); + const hiddenByGhost = + !!dropGhost && displayIndex === dropGhost.targetIdx && tabs.length > dropGhost.tabCountAtCreation; return (
{tab.title} @@ -363,14 +491,20 @@ export function TabBar() {
{isActive && ( )}
- {/* 드래그 고스트 */} + {/* 탭 드래그 고스트 (내부 재정렬) */} {ghostStyle && draggedTab && (
)} + {/* 사이드바 드롭 고스트 (드롭 지점 → 탭 슬롯 이동) */} + {dropGhost && + (() => { + const bar = containerRef.current?.getBoundingClientRect(); + if (!bar) return null; + + const targetX = bar.left + BAR_PAD_X + dropGhost.targetIdx * TAB_UNIT; + const targetY = bar.bottom - 28; + + return ( +
+
+ {dropGhost.title} +
+
+ ); + })()} + {/* 우클릭 컨텍스트 메뉴 */} {contextMenu && (
- { refreshTab(contextMenu.tabId); setContextMenu(null); }} /> + { + refreshTab(contextMenu.tabId); + setContextMenu(null); + }} + />
- { closeTabsToLeft(contextMenu.tabId); setContextMenu(null); }} /> - { closeTabsToRight(contextMenu.tabId); setContextMenu(null); }} /> - { closeOtherTabs(contextMenu.tabId); setContextMenu(null); }} /> + { + closeTabsToLeft(contextMenu.tabId); + setContextMenu(null); + }} + /> + { + closeTabsToRight(contextMenu.tabId); + setContextMenu(null); + }} + /> + { + closeOtherTabs(contextMenu.tabId); + setContextMenu(null); + }} + />
- { closeAllTabs(); setContextMenu(null); }} destructive /> + { + closeAllTabs(); + setContextMenu(null); + }} + destructive + />
)} ); } -function ContextMenuItem({ label, onClick, destructive }: { label: string; onClick: () => void; destructive?: boolean }) { +function ContextMenuItem({ + label, + onClick, + destructive, +}: { + label: string; + onClick: () => void; + destructive?: boolean; +}) { return (