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

페이지 로딩 불가

+

+ 경로: {url} +

+

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

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

+ 열린 탭이 없습니다 +

+

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

+
+
+
+ ); +} diff --git a/frontend/components/layout/TabBar.tsx b/frontend/components/layout/TabBar.tsx new file mode 100644 index 00000000..e83e9b10 --- /dev/null +++ b/frontend/components/layout/TabBar.tsx @@ -0,0 +1,467 @@ +"use client"; + +import React, { useRef, useState, useEffect, useCallback } from "react"; +import { X, RefreshCw, ChevronDown } from "lucide-react"; +import { useTabStore, selectTabs, selectActiveTabId, Tab } from "@/stores/tabStore"; +import { menuScreenApi } from "@/lib/api/screen"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; + +const TAB_WIDTH = 180; +const TAB_GAP = 2; +const TAB_UNIT = TAB_WIDTH + TAB_GAP; +const OVERFLOW_BTN_WIDTH = 48; +const DRAG_THRESHOLD = 5; +const SETTLE_MS = 200; +const BAR_PAD_X = 8; + +interface DragState { + tabId: string; + pointerId: number; + startX: number; + currentX: number; + tabRect: DOMRect; + fromIndex: number; + targetIndex: number; + activated: boolean; + settling: boolean; +} + +export function TabBar() { + const tabs = useTabStore(selectTabs); + const activeTabId = useTabStore(selectActiveTabId); + const { + switchTab, closeTab, refreshTab, + closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, + updateTabOrder, openTab, + } = useTabStore(); + + const containerRef = useRef(null); + const settleTimer = useRef | null>(null); + const dragActiveRef = useRef(false); + + const [visibleCount, setVisibleCount] = useState(tabs.length); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: string } | null>(null); + const [dragState, setDragState] = useState(null); + const [externalDragIdx, setExternalDragIdx] = useState(null); + + dragActiveRef.current = !!dragState; + + useEffect(() => { + return () => { if (settleTimer.current) clearTimeout(settleTimer.current); }; + }, []); + + // --- 오버플로우 계산 (드래그 중 재계산 방지) --- + const recalcVisible = useCallback(() => { + if (dragActiveRef.current) return; + if (!containerRef.current) return; + const w = containerRef.current.clientWidth; + setVisibleCount(Math.max(1, Math.floor((w - OVERFLOW_BTN_WIDTH) / TAB_UNIT))); + }, []); + + useEffect(() => { + recalcVisible(); + const obs = new ResizeObserver(recalcVisible); + if (containerRef.current) obs.observe(containerRef.current); + return () => obs.disconnect(); + }, [recalcVisible]); + + useEffect(() => { recalcVisible(); }, [tabs.length, recalcVisible]); + + const visibleTabs = tabs.slice(0, visibleCount); + const overflowTabs = tabs.slice(visibleCount); + const hasOverflow = overflowTabs.length > 0; + + const activeInOverflow = activeTabId && overflowTabs.some((t) => t.id === activeTabId); + let displayVisible = visibleTabs; + let displayOverflow = overflowTabs; + + if (activeInOverflow && activeTabId) { + const activeTab = tabs.find((t) => t.id === activeTabId)!; + displayVisible = [...visibleTabs.slice(0, -1), activeTab]; + displayOverflow = overflowTabs.filter((t) => t.id !== activeTabId); + if (visibleTabs.length > 0) { + displayOverflow = [visibleTabs[visibleTabs.length - 1], ...displayOverflow]; + } + } + + // ============================================================ + // 사이드바 -> 탭 바 드롭 (네이티브 DnD + 삽입 위치 애니메이션) + // ============================================================ + + const resolveMenuAndOpenTab = async ( + menuName: string, menuObjid: string | number, url: string, insertIndex?: number, + ) => { + const numericObjid = typeof menuObjid === "string" ? parseInt(menuObjid) : menuObjid; + try { + const screens = await menuScreenApi.getScreensByMenu(numericObjid); + if (screens.length > 0) { + openTab( + { type: "screen", title: menuName, screenId: screens[0].screenId, menuObjid: numericObjid }, + insertIndex, + ); + return; + } + } catch { /* ignore */ } + if (url && url !== "#") { + openTab({ type: "admin", title: menuName, adminUrl: url }, insertIndex); + } + }; + + const handleBarDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + const bar = containerRef.current?.getBoundingClientRect(); + if (bar) { + let idx = Math.round((e.clientX - bar.left - BAR_PAD_X) / TAB_UNIT); + idx = Math.max(0, Math.min(idx, displayVisible.length)); + setExternalDragIdx(idx); + } + }; + + const handleBarDragLeave = (e: React.DragEvent) => { + if (!containerRef.current?.contains(e.relatedTarget as Node)) { + setExternalDragIdx(null); + } + }; + + const handleBarDrop = (e: React.DragEvent) => { + e.preventDefault(); + const insertIdx = externalDragIdx ?? undefined; + setExternalDragIdx(null); + + const pending = e.dataTransfer.getData("application/tab-menu-pending"); + if (pending) { + try { + const { menuName, menuObjid, url } = JSON.parse(pending); + resolveMenuAndOpenTab(menuName, menuObjid, url, insertIdx); + } catch { /* ignore */ } + return; + } + const menuData = e.dataTransfer.getData("application/tab-menu"); + if (menuData && menuData.length > 2) { + try { openTab(JSON.parse(menuData), insertIdx); } catch { /* ignore */ } + } + }; + + // ============================================================ + // 탭 드래그 (Pointer Events) - 임계값 + settling 애니메이션 + // ============================================================ + + const calcTarget = useCallback( + (clientX: number): number => { + const bar = containerRef.current?.getBoundingClientRect(); + if (!bar) return 0; + let idx = Math.round((clientX - bar.left - BAR_PAD_X) / TAB_UNIT); + return Math.max(0, Math.min(idx, displayVisible.length - 1)); + }, + [displayVisible.length], + ); + + const handlePointerDown = (e: React.PointerEvent, tabId: string, idx: number) => { + if ((e.target as HTMLElement).closest("button")) return; + if (dragState?.settling) return; + + e.preventDefault(); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + + setDragState({ + tabId, + pointerId: e.pointerId, + startX: e.clientX, + currentX: e.clientX, + tabRect: (e.currentTarget as HTMLElement).getBoundingClientRect(), + fromIndex: idx, + targetIndex: idx, + activated: false, + settling: false, + }); + }; + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragState || dragState.settling) return; + const bar = containerRef.current?.getBoundingClientRect(); + if (!bar) return; + + const clampedX = Math.max(bar.left, Math.min(e.clientX, bar.right)); + + if (!dragState.activated) { + if (Math.abs(clampedX - dragState.startX) < DRAG_THRESHOLD) return; + setDragState((p) => + p ? { ...p, activated: true, currentX: clampedX, targetIndex: calcTarget(clampedX) } : null, + ); + return; + } + + setDragState((p) => + p ? { ...p, currentX: clampedX, targetIndex: calcTarget(clampedX) } : null, + ); + }, + [dragState, calcTarget], + ); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + if (!dragState || dragState.settling) return; + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + + // 임계값 미달 → 클릭으로 처리 + if (!dragState.activated) { + switchTab(dragState.tabId); + setDragState(null); + return; + } + + const { fromIndex, targetIndex, tabId } = dragState; + + // settling 시작: 고스트가 목표(또는 원래) 슬롯으로 부드럽게 복귀 + setDragState((p) => (p ? { ...p, settling: true } : null)); + + if (targetIndex === fromIndex) { + // 이동 없음: 고스트가 원래 위치로 애니메이션 후 정리 + settleTimer.current = setTimeout(() => setDragState(null), SETTLE_MS + 30); + return; + } + + // 실제 배열 인덱스 계산 (setTimeout 전에 캡처) + const actualFrom = tabs.findIndex((t) => t.id === tabId); + const tgtTab = displayVisible[targetIndex]; + const actualTo = tgtTab ? tabs.findIndex((t) => t.id === tgtTab.id) : actualFrom; + + settleTimer.current = setTimeout(() => { + setDragState(null); + if (actualFrom !== -1 && actualTo !== -1 && actualFrom !== actualTo) { + updateTabOrder(actualFrom, actualTo); + } + }, SETTLE_MS + 30); + }, + [dragState, tabs, displayVisible, switchTab, updateTabOrder], + ); + + // ============================================================ + // 스타일 계산 + // ============================================================ + + const getTabAnimStyle = (tabId: string, index: number): React.CSSProperties => { + // 사이드바 드래그 호버: 삽입 지점 이후 탭이 오른쪽으로 shift + if (externalDragIdx !== null && !dragState) { + return { + transform: index >= externalDragIdx ? `translateX(${TAB_UNIT}px)` : "none", + transition: `transform ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`, + }; + } + + // 탭 드래그 미활성화 → 기본 + if (!dragState || !dragState.activated) return {}; + + const { fromIndex, targetIndex, tabId: draggedId } = dragState; + + if (tabId === draggedId) { + return { opacity: 0, transition: "none" }; + } + + let shift = 0; + if (fromIndex < targetIndex) { + if (index > fromIndex && index <= targetIndex) shift = -TAB_UNIT; + } else if (fromIndex > targetIndex) { + if (index >= targetIndex && index < fromIndex) shift = TAB_UNIT; + } + + return { + transform: shift !== 0 ? `translateX(${shift}px)` : "none", + transition: `transform ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`, + }; + }; + + const getGhostStyle = (): React.CSSProperties | null => { + if (!dragState || !dragState.activated) return null; + const bar = containerRef.current?.getBoundingClientRect(); + if (!bar) return null; + + const base: React.CSSProperties = { + position: "fixed", + top: dragState.tabRect.top, + width: TAB_WIDTH, + height: dragState.tabRect.height, + zIndex: 100, + pointerEvents: "none", + opacity: 0.9, + }; + + if (dragState.settling) { + return { + ...base, + left: bar.left + BAR_PAD_X + dragState.targetIndex * TAB_UNIT, + transition: `left ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`, + }; + } + + const offsetX = dragState.currentX - dragState.startX; + const rawLeft = dragState.tabRect.left + offsetX; + return { + ...base, + left: Math.max(bar.left, Math.min(rawLeft, bar.right - TAB_WIDTH)), + transition: "none", + }; + }; + + const ghostStyle = getGhostStyle(); + const draggedTab = dragState ? tabs.find((t) => t.id === dragState.tabId) : null; + + // ============================================================ + // 우클릭 컨텍스트 메뉴 + // ============================================================ + + const handleContextMenu = (e: React.MouseEvent, tabId: string) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, tabId }); + }; + + useEffect(() => { + if (!contextMenu) return; + const close = () => setContextMenu(null); + window.addEventListener("click", close); + window.addEventListener("scroll", close); + return () => { + window.removeEventListener("click", close); + window.removeEventListener("scroll", close); + }; + }, [contextMenu]); + + // ============================================================ + // 렌더링 + // ============================================================ + + const renderTab = (tab: Tab, displayIndex: number) => { + const isActive = tab.id === activeTabId; + const animStyle = getTabAnimStyle(tab.id, displayIndex); + + return ( +
handlePointerDown(e, tab.id, displayIndex)} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onContextMenu={(e) => handleContextMenu(e, tab.id)} + className={cn( + "group relative flex h-9 shrink-0 cursor-pointer items-center gap-1 rounded-t-lg border border-b-0 px-3 text-sm select-none", + isActive + ? "border-border bg-white text-foreground z-10" + : "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground", + )} + style={{ width: TAB_WIDTH, touchAction: "none", ...animStyle }} + title={tab.title} + > + {tab.title} + +
+ {isActive && ( + + )} + +
+
+ ); + }; + + if (tabs.length === 0) return null; + + return ( + <> +
+ {displayVisible.map((tab, i) => renderTab(tab, i))} + + {hasOverflow && ( + + + + + + {displayOverflow.map((tab) => ( + switchTab(tab.id)} className="flex items-center justify-between gap-2"> + {tab.title} + + + ))} + + + )} +
+ + {/* 드래그 고스트 */} + {ghostStyle && draggedTab && ( +
+
+ {draggedTab.title} +
+
+ )} + + {/* 우클릭 컨텍스트 메뉴 */} + {contextMenu && ( +
+ { refreshTab(contextMenu.tabId); setContextMenu(null); }} /> +
+ { closeTabsToLeft(contextMenu.tabId); setContextMenu(null); }} /> + { closeTabsToRight(contextMenu.tabId); setContextMenu(null); }} /> + { closeOtherTabs(contextMenu.tabId); setContextMenu(null); }} /> +
+ { closeAllTabs(); setContextMenu(null); }} destructive /> +
+ )} + + ); +} + +function ContextMenuItem({ label, onClick, destructive }: { label: string; onClick: () => void; destructive?: boolean }) { + return ( + + ); +} diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 49aed98b..a786079b 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -18,6 +18,8 @@ import { useAuth } from "@/hooks/useAuth"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { ScreenContextProvider } from "@/contexts/ScreenContext"; +import { useTabId } from "@/contexts/TabIdContext"; +import { useTabStore } from "@/stores/tabStore"; interface EditModalState { isOpen: boolean; @@ -82,6 +84,9 @@ const findSaveButtonInComponents = (components: any[]): any | null => { export const EditModal: React.FC = ({ className }) => { const { user } = useAuth(); + const tabId = useTabId(); + const activeTabId = useTabStore((s) => s[s.mode].activeTabId); + const isTabActive = !tabId || tabId === activeTabId; const [modalState, setModalState] = useState({ isOpen: false, screenId: null, @@ -244,9 +249,13 @@ export const EditModal: React.FC = ({ className }) => { } }; - // 전역 모달 이벤트 리스너 + // 전역 모달 이벤트 리스너 (활성 탭에서만 처리) useEffect(() => { const handleOpenEditModal = async (event: CustomEvent) => { + const storeState = useTabStore.getState(); + const currentActiveTabId = storeState[storeState.mode].activeTabId; + if (tabId && tabId !== currentActiveTabId) return; + const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext, menuObjid } = event.detail; // 🆕 모달 내부 저장 버튼의 제어로직 설정 조회 @@ -312,7 +321,7 @@ export const EditModal: React.FC = ({ className }) => { window.removeEventListener("openEditModal", handleOpenEditModal as EventListener); window.removeEventListener("closeEditModal", handleCloseEditModal); }; - }, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조 + }, [tabId, modalState.onSave]); // 화면 데이터 로딩 useEffect(() => { diff --git a/frontend/components/screen/widgets/types/NumberWidget.tsx b/frontend/components/screen/widgets/types/NumberWidget.tsx index 9c28528a..eadc15c4 100644 --- a/frontend/components/screen/widgets/types/NumberWidget.tsx +++ b/frontend/components/screen/widgets/types/NumberWidget.tsx @@ -4,6 +4,7 @@ import React from "react"; import { Input } from "@/components/ui/input"; import { WebTypeComponentProps } from "@/lib/registry/types"; import { WidgetComponent, NumberTypeConfig } from "@/types/screen"; +import { formatNumber as formatNum, formatCurrency } from "@/lib/formatting"; export const NumberWidget: React.FC = ({ component, value, onChange, readonly = false }) => { const widget = component as WidgetComponent; @@ -21,10 +22,7 @@ export const NumberWidget: React.FC = ({ component, value if (isNaN(numValue)) return ""; if (config?.format === "currency") { - return new Intl.NumberFormat("ko-KR", { - style: "currency", - currency: "KRW", - }).format(numValue); + return formatCurrency(numValue); } if (config?.format === "percentage") { @@ -32,7 +30,7 @@ export const NumberWidget: React.FC = ({ component, value } if (config?.thousandSeparator) { - return new Intl.NumberFormat("ko-KR").format(numValue); + return formatNum(numValue); } return numValue.toString(); diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx index 2da0647f..1325f487 100644 --- a/frontend/components/ui/alert-dialog.tsx +++ b/frontend/components/ui/alert-dialog.tsx @@ -5,8 +5,24 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import { cn } from "@/lib/utils"; import { buttonVariants } from "@/components/ui/button"; +import { useModalPortal } from "@/lib/modalPortalRef"; +import { useTabId } from "@/contexts/TabIdContext"; +import { useTabStore } from "@/stores/tabStore"; -const AlertDialog = AlertDialogPrimitive.Root; +// AlertDialog: 비활성 탭이면 자동으로 open={false} 처리 +const AlertDialog: React.FC> = ({ + open, + ...props +}) => { + const tabId = useTabId(); + const activeTabId = useTabStore((s) => s[s.mode].activeTabId); + const isTabActive = !tabId || tabId === activeTabId; + + const effectiveOpen = open != null ? open && isTabActive : undefined; + + return ; +}; +AlertDialog.displayName = "AlertDialog"; const AlertDialogTrigger = AlertDialogPrimitive.Trigger; @@ -18,7 +34,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( { + /** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */ + container?: HTMLElement | null; + /** 탭 비활성 시 포탈 내용 숨김 */ + hidden?: boolean; +} + const AlertDialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - - -)); + ScopedAlertDialogContentProps +>(({ className, container: explicitContainer, hidden: hiddenProp, style, ...props }, ref) => { + const autoContainer = useModalPortal(); + const container = explicitContainer !== undefined ? explicitContainer : autoContainer; + const scoped = !!container; + + const adjustedStyle = scoped && style + ? { ...style, maxHeight: undefined, maxWidth: undefined } + : style; + + return ( + +
+ {scoped ? ( +
+ ) : ( + + )} + +
+ + ); +}); AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx index 4813828e..3e17f5a5 100644 --- a/frontend/components/ui/dialog.tsx +++ b/frontend/components/ui/dialog.tsx @@ -5,8 +5,27 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; +import { useModalPortal } from "@/lib/modalPortalRef"; +import { useTabId } from "@/contexts/TabIdContext"; +import { useTabStore } from "@/stores/tabStore"; -const Dialog = DialogPrimitive.Root; +// Dialog: 탭 시스템 내에서 자동으로 modal={false} + 비활성 탭이면 open={false} 처리 +const Dialog: React.FC> = ({ + modal, + open, + ...props +}) => { + const autoContainer = useModalPortal(); + const tabId = useTabId(); + const activeTabId = useTabStore((s) => s[s.mode].activeTabId); + const isTabActive = !tabId || tabId === activeTabId; + + const effectiveModal = modal !== undefined ? modal : !autoContainer ? undefined : false; + const effectiveOpen = open != null ? open && isTabActive : undefined; + + return ; +}; +Dialog.displayName = "Dialog"; const DialogTrigger = DialogPrimitive.Trigger; @@ -21,7 +40,7 @@ const DialogOverlay = React.forwardRef< { + /** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */ + container?: HTMLElement | null; + /** 탭 비활성 시 포탈 내용 숨김 */ + hidden?: boolean; +} + const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)); + ScopedDialogContentProps +>(({ className, children, container: explicitContainer, hidden: hiddenProp, onInteractOutside, style, ...props }, ref) => { + const autoContainer = useModalPortal(); + const container = explicitContainer !== undefined ? explicitContainer : autoContainer; + const scoped = !!container; + + const handleInteractOutside = React.useCallback( + (e: any) => { + if (scoped && container) { + const target = (e.detail?.originalEvent?.target ?? e.target) as HTMLElement | null; + if (target && !container.contains(target)) { + e.preventDefault(); + return; + } + } + onInteractOutside?.(e); + }, + [scoped, container, onInteractOutside], + ); + + // scoped 모드: 뷰포트 기반 maxHeight/maxWidth 제거 → className의 max-h-full이 컨테이너 기준으로 적용됨 + const adjustedStyle = scoped && style + ? { ...style, maxHeight: undefined, maxWidth: undefined } + : style; + + return ( + +
+ {scoped ? ( +
+ ) : ( + + )} + + {children} + + + Close + + +
+ + ); +}); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( diff --git a/frontend/contexts/TabIdContext.tsx b/frontend/contexts/TabIdContext.tsx new file mode 100644 index 00000000..e22a7b06 --- /dev/null +++ b/frontend/contexts/TabIdContext.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { createContext, useContext } from "react"; + +const TabIdContext = createContext(null); + +export const TabIdProvider = TabIdContext.Provider; + +export function useTabId(): string | null { + return useContext(TabIdContext); +} diff --git a/frontend/lib/formatting/index.ts b/frontend/lib/formatting/index.ts new file mode 100644 index 00000000..53dff08d --- /dev/null +++ b/frontend/lib/formatting/index.ts @@ -0,0 +1,137 @@ +/** + * 중앙 포맷팅 함수. + * 모든 컴포넌트는 날짜/숫자/통화를 표시할 때 이 함수들만 호출한다. + * + * 사용법: + * import { formatDate, formatNumber, formatCurrency } from "@/lib/formatting"; + * formatDate("2025-01-01") // "2025-01-01" + * formatDate("2025-01-01T14:30:00Z", "datetime") // "2025-01-01 14:30:00" + * formatNumber(1234567) // "1,234,567" + * formatCurrency(50000) // "₩50,000" + */ + +export { getFormatRules, setFormatRules, DEFAULT_FORMAT_RULES } from "./rules"; +export type { FormatRules, DateFormatRules, NumberFormatRules, CurrencyFormatRules } from "./rules"; + +import { getFormatRules } from "./rules"; + +// --- 날짜 포맷 --- + +type DateFormatType = "display" | "datetime" | "input" | "time"; + +/** + * 날짜 값을 지정된 형식으로 포맷한다. + * @param value - ISO 문자열, Date, 타임스탬프 + * @param type - "display" | "datetime" | "input" | "time" + * @returns 포맷된 문자열 (파싱 실패 시 원본 반환) + */ +export function formatDate(value: unknown, type: DateFormatType = "display"): string { + if (value == null || value === "") return ""; + + const rules = getFormatRules(); + const format = rules.date[type]; + + try { + const date = value instanceof Date ? value : new Date(String(value)); + if (isNaN(date.getTime())) return String(value); + + return applyDateFormat(date, format); + } catch { + return String(value); + } +} + +/** + * YYYY-MM-DD HH:mm:ss 패턴을 Date 객체에 적용 + */ +function applyDateFormat(date: Date, pattern: string): string { + const y = date.getFullYear(); + const M = date.getMonth() + 1; + const d = date.getDate(); + const H = date.getHours(); + const m = date.getMinutes(); + const s = date.getSeconds(); + + return pattern + .replace("YYYY", String(y)) + .replace("MM", String(M).padStart(2, "0")) + .replace("DD", String(d).padStart(2, "0")) + .replace("HH", String(H).padStart(2, "0")) + .replace("mm", String(m).padStart(2, "0")) + .replace("ss", String(s).padStart(2, "0")); +} + +// --- 숫자 포맷 --- + +/** + * 숫자를 로케일 기반으로 포맷한다 (천단위 구분자 등). + * @param value - 숫자 또는 숫자 문자열 + * @param decimals - 소수점 자릿수 (미지정 시 기본값 사용) + * @returns 포맷된 문자열 + */ +export function formatNumber(value: unknown, decimals?: number): string { + if (value == null || value === "") return ""; + + const rules = getFormatRules(); + const num = typeof value === "number" ? value : parseFloat(String(value)); + if (isNaN(num)) return String(value); + + const dec = decimals ?? rules.number.decimals; + + return new Intl.NumberFormat(rules.number.locale, { + minimumFractionDigits: dec, + maximumFractionDigits: dec, + }).format(num); +} + +// --- 통화 포맷 --- + +/** + * 금액을 통화 형식으로 포맷한다. + * @param value - 숫자 또는 숫자 문자열 + * @param currencyCode - 통화 코드 (미지정 시 기본값 사용) + * @returns 포맷된 문자열 (예: "₩50,000") + */ +export function formatCurrency(value: unknown, currencyCode?: string): string { + if (value == null || value === "") return ""; + + const rules = getFormatRules(); + const num = typeof value === "number" ? value : parseFloat(String(value)); + if (isNaN(num)) return String(value); + + const code = currencyCode ?? rules.currency.code; + + return new Intl.NumberFormat(rules.currency.locale, { + style: "currency", + currency: code, + maximumFractionDigits: code === "KRW" ? 0 : 2, + }).format(num); +} + +// --- 범용 포맷 --- + +/** + * 데이터 타입에 따라 자동으로 적절한 포맷을 적용한다. + * @param value - 포맷할 값 + * @param dataType - "date" | "datetime" | "number" | "currency" | "text" + */ +export function formatValue(value: unknown, dataType: string): string { + switch (dataType) { + case "date": + return formatDate(value, "display"); + case "datetime": + return formatDate(value, "datetime"); + case "time": + return formatDate(value, "time"); + case "number": + case "integer": + case "float": + case "decimal": + return formatNumber(value); + case "currency": + case "money": + return formatCurrency(value); + default: + return value == null ? "" : String(value); + } +} diff --git a/frontend/lib/formatting/rules.ts b/frontend/lib/formatting/rules.ts new file mode 100644 index 00000000..dc91a87d --- /dev/null +++ b/frontend/lib/formatting/rules.ts @@ -0,0 +1,71 @@ +/** + * 중앙 포맷팅 규칙 정의. + * 모든 날짜/숫자/통화 포맷은 이 파일의 규칙을 따른다. + * 변경이 필요하면 이 파일만 수정하면 전체 적용된다. + */ + +export interface DateFormatRules { + /** 날짜만 표시 (예: "2025-01-01") */ + display: string; + /** 날짜+시간 표시 (예: "2025-01-01 14:30:00") */ + datetime: string; + /** 입력 필드용 (예: "YYYY-MM-DD") */ + input: string; + /** 시간만 표시 (예: "14:30") */ + time: string; +} + +export interface NumberFormatRules { + /** 숫자 로케일 (천단위 구분자 등) */ + locale: string; + /** 기본 소수점 자릿수 */ + decimals: number; +} + +export interface CurrencyFormatRules { + /** 통화 코드 (예: "KRW", "USD") */ + code: string; + /** 통화 로케일 */ + locale: string; +} + +export interface FormatRules { + date: DateFormatRules; + number: NumberFormatRules; + currency: CurrencyFormatRules; +} + +/** 기본 포맷 규칙 (한국어 기준) */ +export const DEFAULT_FORMAT_RULES: FormatRules = { + date: { + display: "YYYY-MM-DD", + datetime: "YYYY-MM-DD HH:mm:ss", + input: "YYYY-MM-DD", + time: "HH:mm", + }, + number: { + locale: "ko-KR", + decimals: 0, + }, + currency: { + code: "KRW", + locale: "ko-KR", + }, +}; + +/** 현재 적용 중인 포맷 규칙 (런타임에 변경 가능) */ +let currentRules: FormatRules = { ...DEFAULT_FORMAT_RULES }; + +export function getFormatRules(): FormatRules { + return currentRules; +} + +export function setFormatRules(rules: Partial): void { + currentRules = { + ...currentRules, + ...rules, + date: { ...currentRules.date, ...rules.date }, + number: { ...currentRules.number, ...rules.number }, + currency: { ...currentRules.currency, ...rules.currency }, + }; +} diff --git a/frontend/lib/modalPortalRef.ts b/frontend/lib/modalPortalRef.ts new file mode 100644 index 00000000..85c7d66d --- /dev/null +++ b/frontend/lib/modalPortalRef.ts @@ -0,0 +1,31 @@ +"use client"; + +import { useState, useEffect } from "react"; + +/** + * 모달 포탈 컨테이너 전역 레퍼런스. + * TabContent가 마운트 시 registerModalPortal(el)로 등록하고, + * 모달 컴포넌트들은 useModalPortal()로 컨테이너를 구독합니다. + * React 컴포넌트 트리 위치에 무관하게 동작합니다. + */ +let _container: HTMLElement | null = null; +const _subscribers = new Set<(el: HTMLElement | null) => void>(); + +export function registerModalPortal(el: HTMLElement | null) { + _container = el; + _subscribers.forEach((fn) => fn(el)); +} + +export function useModalPortal(): HTMLElement | null { + const [el, setEl] = useState(_container); + + useEffect(() => { + setEl(_container); + _subscribers.add(setEl); + return () => { + _subscribers.delete(setEl); + }; + }, []); + + return el; +} diff --git a/frontend/lib/registry/components/aggregation-widget/AggregationWidgetComponent.tsx b/frontend/lib/registry/components/aggregation-widget/AggregationWidgetComponent.tsx index ce0b0325..cdbb668a 100644 --- a/frontend/lib/registry/components/aggregation-widget/AggregationWidgetComponent.tsx +++ b/frontend/lib/registry/components/aggregation-widget/AggregationWidgetComponent.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, useMemo } from "react"; import { ComponentRendererProps } from "@/types/component"; import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType } from "./types"; +import { formatNumber } from "@/lib/formatting"; import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; @@ -136,11 +137,11 @@ export function AggregationWidgetComponent({ let formattedValue = value.toFixed(item.decimalPlaces ?? 0); if (item.format === "currency") { - formattedValue = new Intl.NumberFormat("ko-KR").format(value); + formattedValue = formatNumber(value); } else if (item.format === "percent") { formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`; } else if (item.format === "number") { - formattedValue = new Intl.NumberFormat("ko-KR").format(value); + formattedValue = formatNumber(value); } if (item.prefix) { diff --git a/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts b/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts index 39aa1c5f..063efe89 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/aggregation.ts @@ -3,6 +3,8 @@ * 다양한 집계 연산을 수행합니다. */ +import { getFormatRules } from "@/lib/formatting"; + import { AggregationType, PivotFieldFormat } from "../types"; // ==================== 집계 함수 ==================== @@ -102,16 +104,18 @@ export function formatNumber( let formatted: string; + const locale = getFormatRules().number.locale; + switch (type) { case "currency": - formatted = value.toLocaleString("ko-KR", { + formatted = value.toLocaleString(locale, { minimumFractionDigits: precision, maximumFractionDigits: precision, }); break; case "percent": - formatted = (value * 100).toLocaleString("ko-KR", { + formatted = (value * 100).toLocaleString(locale, { minimumFractionDigits: precision, maximumFractionDigits: precision, }); @@ -120,7 +124,7 @@ export function formatNumber( case "number": default: if (thousandSeparator) { - formatted = value.toLocaleString("ko-KR", { + formatted = value.toLocaleString(locale, { minimumFractionDigits: precision, maximumFractionDigits: precision, }); @@ -138,7 +142,7 @@ export function formatNumber( */ export function formatDate( value: Date | string | null | undefined, - format: string = "YYYY-MM-DD" + format: string = getFormatRules().date.display ): string { if (!value) return "-"; diff --git a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts index 35893dea..0358a3cb 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -48,7 +48,7 @@ function getFieldValue( const weekNum = getWeekNumber(date); return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`; case "day": - return formatDate(date, "YYYY-MM-DD"); + return formatDate(date); default: return String(rawValue); } diff --git a/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx b/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx index 0dbc5033..4ea4153f 100644 --- a/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx +++ b/frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx @@ -6,6 +6,7 @@ import { AggregationWidgetConfig, AggregationItem, AggregationResult, Aggregatio import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; +import { formatNumber } from "@/lib/formatting"; import { apiClient } from "@/lib/api/client"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; @@ -566,11 +567,11 @@ export function AggregationWidgetComponent({ let formattedValue = value.toFixed(item.decimalPlaces ?? 0); if (item.format === "currency") { - formattedValue = new Intl.NumberFormat("ko-KR").format(value); + formattedValue = formatNumber(value); } else if (item.format === "percent") { formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`; } else if (item.format === "number") { - formattedValue = new Intl.NumberFormat("ko-KR").format(value); + formattedValue = formatNumber(value); } if (item.prefix) { diff --git a/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx b/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx index 1550bbe3..70c2ed23 100644 --- a/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx +++ b/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx @@ -4,6 +4,7 @@ import React from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { V2DateDefinition } from "./index"; import { V2Date } from "@/components/v2/V2Date"; +import { getFormatRules } from "@/lib/formatting"; /** * V2Date 렌더러 @@ -45,7 +46,7 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer { onChange={handleChange} config={{ dateType: config.dateType || config.webType || "date", - format: config.format || "YYYY-MM-DD", + format: config.format || getFormatRules().date.display, placeholder: config.placeholder || style.placeholder || "날짜 선택", showTime: config.showTime || false, use24Hours: config.use24Hours ?? true, diff --git a/frontend/lib/registry/components/v2-pivot-grid/utils/aggregation.ts b/frontend/lib/registry/components/v2-pivot-grid/utils/aggregation.ts index 39aa1c5f..063efe89 100644 --- a/frontend/lib/registry/components/v2-pivot-grid/utils/aggregation.ts +++ b/frontend/lib/registry/components/v2-pivot-grid/utils/aggregation.ts @@ -3,6 +3,8 @@ * 다양한 집계 연산을 수행합니다. */ +import { getFormatRules } from "@/lib/formatting"; + import { AggregationType, PivotFieldFormat } from "../types"; // ==================== 집계 함수 ==================== @@ -102,16 +104,18 @@ export function formatNumber( let formatted: string; + const locale = getFormatRules().number.locale; + switch (type) { case "currency": - formatted = value.toLocaleString("ko-KR", { + formatted = value.toLocaleString(locale, { minimumFractionDigits: precision, maximumFractionDigits: precision, }); break; case "percent": - formatted = (value * 100).toLocaleString("ko-KR", { + formatted = (value * 100).toLocaleString(locale, { minimumFractionDigits: precision, maximumFractionDigits: precision, }); @@ -120,7 +124,7 @@ export function formatNumber( case "number": default: if (thousandSeparator) { - formatted = value.toLocaleString("ko-KR", { + formatted = value.toLocaleString(locale, { minimumFractionDigits: precision, maximumFractionDigits: precision, }); @@ -138,7 +142,7 @@ export function formatNumber( */ export function formatDate( value: Date | string | null | undefined, - format: string = "YYYY-MM-DD" + format: string = getFormatRules().date.display ): string { if (!value) return "-"; diff --git a/frontend/lib/registry/components/v2-pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/v2-pivot-grid/utils/pivotEngine.ts index 4d3fecfd..5241ae75 100644 --- a/frontend/lib/registry/components/v2-pivot-grid/utils/pivotEngine.ts +++ b/frontend/lib/registry/components/v2-pivot-grid/utils/pivotEngine.ts @@ -47,7 +47,7 @@ function getFieldValue( const weekNum = getWeekNumber(date); return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`; case "day": - return formatDate(date, "YYYY-MM-DD"); + return formatDate(date); default: return String(rawValue); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 01edd32d..a360f556 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -94,7 +94,8 @@ "three": "^0.180.0", "uuid": "^13.0.0", "xlsx": "^0.18.5", - "zod": "^4.1.5" + "zod": "^4.1.5", + "zustand": "^5.0.11" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -15843,9 +15844,9 @@ } }, "node_modules/zustand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", - "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/frontend/package.json b/frontend/package.json index 2de7c057..8a31c255 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -103,7 +103,8 @@ "three": "^0.180.0", "uuid": "^13.0.0", "xlsx": "^0.18.5", - "zod": "^4.1.5" + "zod": "^4.1.5", + "zustand": "^5.0.11" }, "devDependencies": { "@eslint/eslintrc": "^3",