Compare commits
2 Commits
3db8a8a276
...
d04dc4c050
| Author | SHA1 | Date |
|---|---|---|
|
|
d04dc4c050 | |
|
|
7acdd852a5 |
|
|
@ -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<tabId, Map<path, {top, left}>>` - 탭 내 요소별 최신 스크롤 위치 |
|
||||
| `pathCacheRef` | `WeakMap<HTMLElement, string>` - 동일 요소의 경로 재계산 방지용 캐시 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 참고 파일
|
||||
|
||||
| 파일 | 비고 |
|
||||
|---|---|
|
||||
| `frontend/components/layout/AppLayout.tsx` | 사이드바 + 콘텐츠 레이아웃 |
|
||||
| `frontend/app/(main)/screens/[screenId]/page.tsx` | 화면 렌더링 (건드리지 않음) |
|
||||
| `frontend/stores/modalDataStore.ts` | Zustand store 참고 패턴 |
|
||||
| `frontend/lib/adminPageRegistry.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 (
|
||||
<TableSearchWidgetHeightProvider>
|
||||
<ScreenContextProvider>
|
||||
<SplitPanelProvider>
|
||||
<ScreenViewPage />
|
||||
<ScreenViewPage screenIdProp={screenIdProp} menuObjidProp={menuObjidProp} />
|
||||
</SplitPanelProvider>
|
||||
</ScreenContextProvider>
|
||||
</TableSearchWidgetHeightProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScreenViewPageWrapper };
|
||||
export default ScreenViewPageWrapper;
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<QueryProvider>
|
||||
<RegistryProvider>{children}</RegistryProvider>
|
||||
<Toaster position="top-right" />
|
||||
<ScreenModal />
|
||||
</QueryProvider>
|
||||
{/* Portal 컨테이너 */}
|
||||
<div id="portal-root" data-radix-portal="true" />
|
||||
|
|
|
|||
|
|
@ -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<ScreenModalProps> = ({ 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<ScreenModalState>({
|
||||
isOpen: false,
|
||||
|
|
@ -169,6 +174,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ 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<ScreenModalProps> = ({ className }) => {
|
|||
isCreateMode,
|
||||
});
|
||||
|
||||
// 🆕 모달 열린 시간 기록
|
||||
// 모달 열린 시간 기록
|
||||
modalOpenedAtRef.current = Date.now();
|
||||
|
||||
// 폼 변경 추적 초기화
|
||||
|
|
@ -442,7 +452,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
window.removeEventListener("closeSaveModal", handleCloseModal);
|
||||
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||
};
|
||||
}, [continuousMode]); // continuousMode 의존성 추가
|
||||
}, [tabId, continuousMode]);
|
||||
|
||||
// 화면 데이터 로딩
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const LoadingFallback = () => (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리.
|
||||
* 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다.
|
||||
* 매핑되지 않은 URL은 catch-all fallback으로 처리된다.
|
||||
*/
|
||||
const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
// 관리자 메인
|
||||
"/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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-foreground">페이지 로딩 불가</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
경로: {url}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
AdminPageRenderer 레지스트리에 이 URL을 추가해주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <AdminPageFallback url={url} />;
|
||||
}
|
||||
|
||||
return <PageComponent />;
|
||||
}
|
||||
|
|
@ -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<string>("");
|
||||
|
||||
// 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<string | null> => {
|
||||
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 (
|
||||
<div key={menu.id}>
|
||||
<div
|
||||
draggable={isLeaf}
|
||||
onDragStart={(e) => 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) => (
|
||||
<div
|
||||
key={child.id}
|
||||
draggable={!child.hasChildren}
|
||||
onDragStart={(e) => 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) {
|
|||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
|
||||
<main className={`min-w-0 flex-1 overflow-auto bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
|
||||
{children}
|
||||
{/* 가운데 컨텐츠 영역 - 탭 시스템 */}
|
||||
<main className={`flex min-w-0 flex-1 flex-col overflow-hidden bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
|
||||
<TabBar />
|
||||
<TabContent />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
|
||||
export function EmptyDashboard() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-white">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||
<LayoutGrid className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
열린 탭이 없습니다
|
||||
</h2>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
왼쪽 사이드바에서 메뉴를 클릭하거나 드래그하여 탭을 추가하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const settleTimer = useRef<ReturnType<typeof setTimeout> | 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<DragState | null>(null);
|
||||
const [externalDragIdx, setExternalDragIdx] = useState<number | null>(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 (
|
||||
<div
|
||||
key={tab.id}
|
||||
onPointerDown={(e) => 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}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">{tab.title}</span>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{isActive && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); refreshTab(tab.id); }}
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-5 w-5 items-center justify-center rounded-sm transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:bg-destructive/10 hover:text-destructive flex h-5 w-5 items-center justify-center rounded-sm transition-colors",
|
||||
!isActive && "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="border-border bg-muted/30 flex h-[37px] shrink-0 items-end gap-[2px] overflow-hidden border-b px-2 pt-1"
|
||||
onDragOver={handleBarDragOver}
|
||||
onDragLeave={handleBarDragLeave}
|
||||
onDrop={handleBarDrop}
|
||||
>
|
||||
{displayVisible.map((tab, i) => renderTab(tab, i))}
|
||||
|
||||
{hasOverflow && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground flex h-9 shrink-0 items-center gap-1 rounded-t-lg border border-b-0 border-transparent px-3 text-xs font-medium transition-colors">
|
||||
+{displayOverflow.length}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-[300px] overflow-y-auto">
|
||||
{displayOverflow.map((tab) => (
|
||||
<DropdownMenuItem key={tab.id} onClick={() => switchTab(tab.id)} className="flex items-center justify-between gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-xs">{tab.title}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
|
||||
className="hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 shrink-0 items-center justify-center rounded-sm"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 드래그 고스트 */}
|
||||
{ghostStyle && draggedTab && (
|
||||
<div
|
||||
style={ghostStyle}
|
||||
className="border-primary/50 bg-background rounded-t-lg border border-b-0 px-3 shadow-lg"
|
||||
>
|
||||
<div className="flex h-full items-center">
|
||||
<span className="truncate text-xs font-medium">{draggedTab.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우클릭 컨텍스트 메뉴 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="border-border bg-popover fixed z-50 min-w-[180px] rounded-md border p-1 shadow-md"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<ContextMenuItem label="새로고침" onClick={() => { refreshTab(contextMenu.tabId); setContextMenu(null); }} />
|
||||
<div className="bg-border my-1 h-px" />
|
||||
<ContextMenuItem label="왼쪽 탭 닫기" onClick={() => { closeTabsToLeft(contextMenu.tabId); setContextMenu(null); }} />
|
||||
<ContextMenuItem label="오른쪽 탭 닫기" onClick={() => { closeTabsToRight(contextMenu.tabId); setContextMenu(null); }} />
|
||||
<ContextMenuItem label="다른 탭 모두 닫기" onClick={() => { closeOtherTabs(contextMenu.tabId); setContextMenu(null); }} />
|
||||
<div className="bg-border my-1 h-px" />
|
||||
<ContextMenuItem label="모든 탭 닫기" onClick={() => { closeAllTabs(); setContextMenu(null); }} destructive />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({ label, onClick, destructive }: { label: string; onClick: () => void; destructive?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-sm px-2 py-1.5 text-xs transition-colors",
|
||||
destructive ? "text-destructive hover:bg-destructive/10" : "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Set<string>>(new Set());
|
||||
|
||||
// 각 탭의 스크롤 컨테이너 ref
|
||||
const scrollRefsMap = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
|
||||
// 이전 활성 탭 ID 추적
|
||||
const prevActiveTabIdRef = useRef<string | null>(null);
|
||||
|
||||
// 활성 탭의 스크롤 위치를 실시간 추적 (display:none 전에 캡처하기 위함)
|
||||
// Map<tabId, Map<elementPath, {top, left}>> - 탭 내 여러 스크롤 영역을 각각 추적
|
||||
const lastScrollMapRef = useRef<Map<string, Map<string, { top: number; left: number }>>>(new Map());
|
||||
// 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함)
|
||||
const pathCacheRef = useRef<WeakMap<HTMLElement, string | null>>(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 <EmptyDashboard />;
|
||||
}
|
||||
|
||||
const tabLookup = new Map(tabs.map((t) => [t.id, t]));
|
||||
const stableIds = Array.from(mountedTabIdsRef.current);
|
||||
|
||||
return (
|
||||
<div ref={portalRefCallback} className="relative min-h-0 flex-1 overflow-hidden">
|
||||
{stableIds.map((tabId) => {
|
||||
const tab = tabLookup.get(tabId);
|
||||
if (!tab) return null;
|
||||
|
||||
const isActive = tab.id === activeTabId;
|
||||
const refreshKey = refreshKeys[tab.id] || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
ref={(el) => setScrollRef(tab.id, el)}
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ display: isActive ? "block" : "none" }}
|
||||
>
|
||||
<TabIdProvider value={tab.id}>
|
||||
<TabPageRenderer tab={tab} refreshKey={refreshKey} />
|
||||
<ScreenModal key={`modal-${tab.id}-${refreshKey}`} />
|
||||
</TabIdProvider>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ScreenViewPageWrapper
|
||||
key={`${tab.id}-${refreshKey}`}
|
||||
screenIdProp={tab.screenId}
|
||||
menuObjidProp={tab.menuObjid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (tab.type === "admin" && tab.adminUrl) {
|
||||
return (
|
||||
<div key={`${tab.id}-${refreshKey}`} className="h-full">
|
||||
<AdminPageRenderer url={tab.adminUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -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<EditModalProps> = ({ className }) => {
|
||||
const { user } = useAuth();
|
||||
const tabId = useTabId();
|
||||
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
|
||||
const isTabActive = !tabId || tabId === activeTabId;
|
||||
const [modalState, setModalState] = useState<EditModalState>({
|
||||
isOpen: false,
|
||||
screenId: null,
|
||||
|
|
@ -244,9 +249,13 @@ export const EditModal: React.FC<EditModalProps> = ({ 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<EditModalProps> = ({ className }) => {
|
|||
window.removeEventListener("openEditModal", handleOpenEditModal as EventListener);
|
||||
window.removeEventListener("closeEditModal", handleCloseEditModal);
|
||||
};
|
||||
}, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조
|
||||
}, [tabId, modalState.onSave]);
|
||||
|
||||
// 화면 데이터 로딩
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -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<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
|
||||
const widget = component as WidgetComponent;
|
||||
|
|
@ -21,10 +22,7 @@ export const NumberWidget: React.FC<WebTypeComponentProps> = ({ 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<WebTypeComponentProps> = ({ component, value
|
|||
}
|
||||
|
||||
if (config?.thousandSeparator) {
|
||||
return new Intl.NumberFormat("ko-KR").format(numValue);
|
||||
return formatNum(numValue);
|
||||
}
|
||||
|
||||
return numValue.toString();
|
||||
|
|
|
|||
|
|
@ -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<React.ComponentProps<typeof AlertDialogPrimitive.Root>> = ({
|
||||
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 <AlertDialogPrimitive.Root {...props} open={effectiveOpen} />;
|
||||
};
|
||||
AlertDialog.displayName = "AlertDialog";
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
|
|
@ -18,7 +34,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-[1050] bg-black/80",
|
||||
"fixed inset-0 z-1050 bg-black/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -27,22 +43,53 @@ const AlertDialogOverlay = React.forwardRef<
|
|||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface ScopedAlertDialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> {
|
||||
/** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */
|
||||
container?: HTMLElement | null;
|
||||
/** 탭 비활성 시 포탈 내용 숨김 */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background fixed top-[50%] left-[50%] z-[1100] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
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 (
|
||||
<AlertDialogPortal container={container ?? undefined}>
|
||||
<div
|
||||
className={scoped ? "absolute inset-0 z-1050 flex items-center justify-center overflow-hidden p-4" : undefined}
|
||||
style={hiddenProp ? { display: "none" } : undefined}
|
||||
>
|
||||
{scoped ? (
|
||||
<div className="absolute inset-0 bg-black/80" />
|
||||
) : (
|
||||
<AlertDialogPrimitive.Overlay className="fixed inset-0 z-1050 bg-black/80" />
|
||||
)}
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
scoped
|
||||
? "bg-background relative z-1 grid w-full max-w-lg max-h-full gap-4 border p-6 shadow-lg sm:rounded-lg"
|
||||
: "bg-background fixed top-[50%] left-[50%] z-1100 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
scoped && "max-h-full",
|
||||
)}
|
||||
style={adjustedStyle}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
});
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
|
|
|||
|
|
@ -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<React.ComponentProps<typeof DialogPrimitive.Root>> = ({
|
||||
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 <DialogPrimitive.Root {...props} open={effectiveOpen} modal={effectiveModal} />;
|
||||
};
|
||||
Dialog.displayName = "Dialog";
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
|
|
@ -21,7 +40,7 @@ const DialogOverlay = React.forwardRef<
|
|||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-[999] bg-black/60",
|
||||
"fixed inset-0 z-999 bg-black/60",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -29,28 +48,75 @@ const DialogOverlay = React.forwardRef<
|
|||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface ScopedDialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
/** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */
|
||||
container?: HTMLElement | null;
|
||||
/** 탭 비활성 시 포탈 내용 숨김 */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
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 (
|
||||
<DialogPortal container={container ?? undefined}>
|
||||
<div
|
||||
className={scoped ? "absolute inset-0 z-999 flex items-center justify-center overflow-hidden p-4" : undefined}
|
||||
style={hiddenProp ? { display: "none" } : undefined}
|
||||
>
|
||||
{scoped ? (
|
||||
<div className="absolute inset-0 bg-black/60" />
|
||||
) : (
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-999 bg-black/60" />
|
||||
)}
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
onInteractOutside={handleInteractOutside}
|
||||
className={cn(
|
||||
scoped
|
||||
? "bg-background relative z-1 flex w-full max-w-lg max-h-full flex-col gap-4 border p-6 shadow-lg sm:rounded-lg"
|
||||
: "bg-background fixed top-[50%] left-[50%] z-1000 flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
scoped && "max-h-full",
|
||||
)}
|
||||
style={adjustedStyle}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const TabIdContext = createContext<string | null>(null);
|
||||
|
||||
export const TabIdProvider = TabIdContext.Provider;
|
||||
|
||||
export function useTabId(): string | null {
|
||||
return useContext(TabIdContext);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<FormatRules>): void {
|
||||
currentRules = {
|
||||
...currentRules,
|
||||
...rules,
|
||||
date: { ...currentRules.date, ...rules.date },
|
||||
number: { ...currentRules.number, ...rules.number },
|
||||
currency: { ...currentRules.currency, ...rules.currency },
|
||||
};
|
||||
}
|
||||
|
|
@ -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<HTMLElement | null>(_container);
|
||||
|
||||
useEffect(() => {
|
||||
setEl(_container);
|
||||
_subscribers.add(setEl);
|
||||
return () => {
|
||||
_subscribers.delete(setEl);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return el;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 "-";
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 "-";
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Omit<TabCacheData, "cachedAt">>): 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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
||||
"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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
|
||||
): 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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
||||
"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;
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, number>;
|
||||
|
||||
setMode: (mode: AppMode) => void;
|
||||
|
||||
openTab: (tab: Omit<Tab, "id">, 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<Tab, "id">): 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, "id">): 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<TabState>()(
|
||||
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" },
|
||||
),
|
||||
);
|
||||
Loading…
Reference in New Issue