Compare commits

..

No commits in common. "d04dc4c05030284f0fb8941e91822af91d634f0d" and "3db8a8a27670984a5a0e76fb36fca6c1a1ec0d02" have entirely different histories.

28 changed files with 135 additions and 2336 deletions

View File

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

View File

@ -28,24 +28,17 @@ 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";
export interface ScreenViewPageProps {
screenIdProp?: number;
menuObjidProp?: number;
}
function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
function ScreenViewPage() {
// 스케줄 자동 생성 서비스 활성화
const { showConfirmDialog, previewResult, handleConfirm, closeDialog, isLoading: scheduleLoading } = useScheduleGenerator();
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const screenId = screenIdProp ?? parseInt(params.screenId as string);
const screenId = parseInt(params.screenId as string);
// props 우선, 없으면 URL 쿼리에서 menuObjid 가져오기
const menuObjid = menuObjidProp ?? (searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined);
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
// URL 쿼리에서 프리뷰용 company_code 가져오기
const previewCompanyCode = searchParams.get("company_code");
@ -131,13 +124,10 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
initComponents();
}, []);
// 편집 모달 이벤트 리스너 등록 (활성 탭에서만 처리)
const tabId = useTabId();
// 편집 모달 이벤트 리스너 등록
useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => {
const state = useTabStore.getState();
const currentActiveTabId = state[state.mode].activeTabId;
if (tabId && tabId !== currentActiveTabId) return;
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
setEditModalConfig({
screenId: event.detail.screenId,
@ -157,7 +147,7 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
// @ts-expect-error - CustomEvent type
window.removeEventListener("openEditModal", handleOpenEditModal);
};
}, [tabId]);
}, []);
useEffect(() => {
const loadScreen = async () => {
@ -1335,17 +1325,16 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
}
// 실제 컴포넌트를 Provider로 감싸기
function ScreenViewPageWrapper({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
function ScreenViewPageWrapper() {
return (
<TableSearchWidgetHeightProvider>
<ScreenContextProvider>
<SplitPanelProvider>
<ScreenViewPage screenIdProp={screenIdProp} menuObjidProp={menuObjidProp} />
<ScreenViewPage />
</SplitPanelProvider>
</ScreenContextProvider>
</TableSearchWidgetHeightProvider>
);
}
export { ScreenViewPageWrapper };
export default ScreenViewPageWrapper;

View File

@ -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,6 +45,7 @@ export default function RootLayout({
<QueryProvider>
<RegistryProvider>{children}</RegistryProvider>
<Toaster position="top-right" />
<ScreenModal />
</QueryProvider>
{/* Portal 컨테이너 */}
<div id="portal-root" data-radix-portal="true" />

View File

@ -26,8 +26,6 @@ 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;
@ -44,9 +42,6 @@ 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,
@ -174,11 +169,6 @@ 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,
@ -200,7 +190,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
isCreateMode,
});
// 모달 열린 시간 기록
// 🆕 모달 열린 시간 기록
modalOpenedAtRef.current = Date.now();
// 폼 변경 추적 초기화
@ -452,7 +442,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
window.removeEventListener("closeSaveModal", handleCloseModal);
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
};
}, [tabId, continuousMode]);
}, [continuousMode]); // continuousMode 의존성 추가
// 화면 데이터 로딩
useEffect(() => {

View File

@ -1,109 +0,0 @@
"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 />;
}

View File

@ -29,9 +29,6 @@ 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,
@ -93,8 +90,7 @@ const getMenuIcon = (menuName: string) => {
};
// 메뉴 데이터를 UI용으로 변환하는 함수 (최상위 "사용자", "관리자" 제외)
// parentPath: 탭 제목에 "기준정보 - 회사관리" 형태로 상위 카테고리를 포함하기 위한 경로
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0", parentPath: string = ""): any[] => {
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0"): any[] => {
const filteredMenus = menus
.filter((menu) => (menu.parent_obj_id || menu.PARENT_OBJ_ID) === parentId)
.filter((menu) => (menu.status || menu.STATUS) === "active")
@ -107,34 +103,40 @@ 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, parentPath));
return filteredMenus.map((menu) => convertSingleMenu(menu, menus, userInfo));
};
// 단일 메뉴 변환 함수
const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null, parentPath: string = ""): any => {
const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null): any => {
const menuId = menu.objid || menu.OBJID;
// 사용자 locale 기준으로 번역 처리
const getDisplayText = (m: MenuItem) => {
if (m.translated_name || m.TRANSLATED_NAME) {
return m.translated_name || m.TRANSLATED_NAME;
const getDisplayText = (menu: MenuItem) => {
// 다국어 텍스트가 있으면 사용, 없으면 기본 텍스트 사용
if (menu.translated_name || menu.TRANSLATED_NAME) {
return menu.translated_name || menu.TRANSLATED_NAME;
}
const baseName = m.menu_name_kor || m.MENU_NAME_KOR || "메뉴명 없음";
const baseName = menu.menu_name_kor || menu.MENU_NAME_KOR || "메뉴명 없음";
// 사용자 정보에서 locale 가져오기
const userLocale = userInfo?.locale || "ko";
if (userLocale === "EN") {
// 영어 번역
const translations: { [key: string]: string } = {
: "Administrator",
: "User Management",
@ -154,6 +156,7 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
}
}
} else if (userLocale === "JA") {
// 일본어 번역
const translations: { [key: string]: string } = {
: "管理者",
: "ユーザー管理",
@ -173,6 +176,7 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
}
}
} else if (userLocale === "ZH") {
// 중국어 번역
const translations: { [key: string]: string } = {
: "管理员",
: "用户管理",
@ -196,15 +200,11 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
return baseName;
};
const displayName = getDisplayText(menu);
const tabTitle = parentPath ? `${parentPath} - ${displayName}` : displayName;
const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle);
const children = convertMenuToUI(allMenus, userInfo, menuId);
return {
id: menuId,
name: displayName,
tabTitle,
name: getDisplayText(menu),
icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || ""),
url: menu.menu_url || menu.MENU_URL || "#",
children: children.length > 0 ? children : undefined,
@ -224,28 +224,6 @@ 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 () => {
@ -321,10 +299,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
handleRegisterVehicle,
} = useProfile(user, refreshUserData, refreshMenus);
// 탭 스토어에서 현재 모드 가져오기
const tabMode = useTabStore((s) => s.mode);
const setTabMode = useTabStore((s) => s.setMode);
const isAdminMode = tabMode === "admin";
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용)
const isPreviewMode = searchParams.get("preview") === "true";
@ -344,55 +320,67 @@ function AppLayoutInner({ children }: AppLayoutProps) {
setExpandedMenus(newExpanded);
};
const { openTab } = useTabStore();
// 메뉴 클릭 핸들러 (탭으로 열기)
// 메뉴 클릭 핸들러
const handleMenuClick = async (menu: any) => {
if (menu.hasChildren) {
toggleMenu(menu.id);
} else {
// tabTitle: "기준정보 - 회사관리" 형태의 상위 포함 이름
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
// 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용)
const menuName = 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];
openTab({
type: "screen",
title: menuName,
screenId: firstScreen.screenId,
menuObjid: parseInt(menuObjid),
});
if (isMobile) setSidebarOpen(false);
// 관리자 모드 상태와 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);
}
return;
}
} catch (error) {
console.warn("할당된 화면 조회 실패:", error);
}
// 할당된 화면이 없고 URL이 있으면 기존 URL로 이동
if (menu.url && menu.url !== "#") {
openTab({
type: "admin",
title: menuName,
adminUrl: menu.url,
});
if (isMobile) setSidebarOpen(false);
router.push(menu.url);
if (isMobile) {
setSidebarOpen(false);
}
} else {
// URL도 없고 할당된 화면도 없으면 경고 메시지
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
}
}
};
// 모드 전환 핸들러
// 모드 전환: 탭 스토어의 모드만 변경 (각 모드 탭은 독립 보존)
const handleModeSwitch = () => {
setTabMode(isAdminMode ? "user" : "admin");
if (isAdminMode) {
// 관리자 → 사용자 모드: 선택한 회사 유지
router.push("/main");
} else {
// 사용자 → 관리자 모드: 선택한 회사 유지 (회사 전환 없음)
router.push("/admin");
}
};
// 로그아웃 핸들러
@ -405,57 +393,13 @@ function AppLayoutInner({ children }: AppLayoutProps) {
}
};
// 사이드바 메뉴 -> 탭 바 드래그용 데이터 생성
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);
};
// 메뉴 트리 렌더링 (드래그 가능)
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
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"
@ -484,8 +428,6 @@ 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"
@ -753,10 +695,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</div>
</aside>
{/* 가운데 컨텐츠 영역 - 탭 시스템 */}
<main className={`flex min-w-0 flex-1 flex-col overflow-hidden bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
<TabBar />
<TabContent />
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
<main className={`min-w-0 flex-1 overflow-auto bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
{children}
</main>
</div>

View File

@ -1,23 +0,0 @@
"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>
);
}

View File

@ -1,467 +0,0 @@
"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>
);
}

View File

@ -1,247 +0,0 @@
"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;
}

View File

@ -18,8 +18,6 @@ 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;
@ -84,9 +82,6 @@ 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,
@ -249,13 +244,9 @@ 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;
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
@ -321,7 +312,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
window.removeEventListener("openEditModal", handleOpenEditModal as EventListener);
window.removeEventListener("closeEditModal", handleCloseEditModal);
};
}, [tabId, modalState.onSave]);
}, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조
// 화면 데이터 로딩
useEffect(() => {

View File

@ -4,7 +4,6 @@ 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;
@ -22,7 +21,10 @@ export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value
if (isNaN(numValue)) return "";
if (config?.format === "currency") {
return formatCurrency(numValue);
return new Intl.NumberFormat("ko-KR", {
style: "currency",
currency: "KRW",
}).format(numValue);
}
if (config?.format === "percentage") {
@ -30,7 +32,7 @@ export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value
}
if (config?.thousandSeparator) {
return formatNum(numValue);
return new Intl.NumberFormat("ko-KR").format(numValue);
}
return numValue.toString();

View File

@ -5,24 +5,8 @@ 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";
// 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 AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
@ -34,7 +18,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}
@ -43,53 +27,22 @@ 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>,
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>
);
});
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>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (

View File

@ -5,27 +5,8 @@ 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";
// 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 Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
@ -40,7 +21,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}
@ -48,75 +29,28 @@ 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>,
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>
);
});
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>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (

View File

@ -1,11 +0,0 @@
"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);
}

View File

@ -1,137 +0,0 @@
/**
* .
* // .
*
* :
* 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);
}
}

View File

@ -1,71 +0,0 @@
/**
* .
* // .
* .
*/
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 },
};
}

View File

@ -1,31 +0,0 @@
"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;
}

View File

@ -3,7 +3,6 @@
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";
@ -137,11 +136,11 @@ export function AggregationWidgetComponent({
let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
if (item.format === "currency") {
formattedValue = formatNumber(value);
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
} else if (item.format === "percent") {
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
} else if (item.format === "number") {
formattedValue = formatNumber(value);
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
}
if (item.prefix) {

View File

@ -3,8 +3,6 @@
* .
*/
import { getFormatRules } from "@/lib/formatting";
import { AggregationType, PivotFieldFormat } from "../types";
// ==================== 집계 함수 ====================
@ -104,18 +102,16 @@ export function formatNumber(
let formatted: string;
const locale = getFormatRules().number.locale;
switch (type) {
case "currency":
formatted = value.toLocaleString(locale, {
formatted = value.toLocaleString("ko-KR", {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
break;
case "percent":
formatted = (value * 100).toLocaleString(locale, {
formatted = (value * 100).toLocaleString("ko-KR", {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
@ -124,7 +120,7 @@ export function formatNumber(
case "number":
default:
if (thousandSeparator) {
formatted = value.toLocaleString(locale, {
formatted = value.toLocaleString("ko-KR", {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
@ -142,7 +138,7 @@ export function formatNumber(
*/
export function formatDate(
value: Date | string | null | undefined,
format: string = getFormatRules().date.display
format: string = "YYYY-MM-DD"
): string {
if (!value) return "-";

View File

@ -48,7 +48,7 @@ function getFieldValue(
const weekNum = getWeekNumber(date);
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
case "day":
return formatDate(date);
return formatDate(date, "YYYY-MM-DD");
default:
return String(rawValue);
}

View File

@ -6,7 +6,6 @@ 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";
@ -567,11 +566,11 @@ export function AggregationWidgetComponent({
let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
if (item.format === "currency") {
formattedValue = formatNumber(value);
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
} else if (item.format === "percent") {
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
} else if (item.format === "number") {
formattedValue = formatNumber(value);
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
}
if (item.prefix) {

View File

@ -4,7 +4,6 @@ import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { V2DateDefinition } from "./index";
import { V2Date } from "@/components/v2/V2Date";
import { getFormatRules } from "@/lib/formatting";
/**
* V2Date
@ -46,7 +45,7 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
onChange={handleChange}
config={{
dateType: config.dateType || config.webType || "date",
format: config.format || getFormatRules().date.display,
format: config.format || "YYYY-MM-DD",
placeholder: config.placeholder || style.placeholder || "날짜 선택",
showTime: config.showTime || false,
use24Hours: config.use24Hours ?? true,

View File

@ -3,8 +3,6 @@
* .
*/
import { getFormatRules } from "@/lib/formatting";
import { AggregationType, PivotFieldFormat } from "../types";
// ==================== 집계 함수 ====================
@ -104,18 +102,16 @@ export function formatNumber(
let formatted: string;
const locale = getFormatRules().number.locale;
switch (type) {
case "currency":
formatted = value.toLocaleString(locale, {
formatted = value.toLocaleString("ko-KR", {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
break;
case "percent":
formatted = (value * 100).toLocaleString(locale, {
formatted = (value * 100).toLocaleString("ko-KR", {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
@ -124,7 +120,7 @@ export function formatNumber(
case "number":
default:
if (thousandSeparator) {
formatted = value.toLocaleString(locale, {
formatted = value.toLocaleString("ko-KR", {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
@ -142,7 +138,7 @@ export function formatNumber(
*/
export function formatDate(
value: Date | string | null | undefined,
format: string = getFormatRules().date.display
format: string = "YYYY-MM-DD"
): string {
if (!value) return "-";

View File

@ -47,7 +47,7 @@ function getFieldValue(
const weekNum = getWeekNumber(date);
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
case "day":
return formatDate(date);
return formatDate(date, "YYYY-MM-DD");
default:
return String(rawValue);
}

View File

@ -1,428 +0,0 @@
/**
* 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;
}

View File

@ -94,8 +94,7 @@
"three": "^0.180.0",
"uuid": "^13.0.0",
"xlsx": "^0.18.5",
"zod": "^4.1.5",
"zustand": "^5.0.11"
"zod": "^4.1.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@ -15844,9 +15843,9 @@
}
},
"node_modules/zustand": {
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"

View File

@ -103,8 +103,7 @@
"three": "^0.180.0",
"uuid": "^13.0.0",
"xlsx": "^0.18.5",
"zod": "^4.1.5",
"zustand": "^5.0.11"
"zod": "^4.1.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

View File

@ -1,224 +0,0 @@
"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" },
),
);