# 탭 시스템 아키텍처 및 구현 계획 ## 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` | 관리자 페이지 레지스트리 |