From 27853a9447ee107712810881acfc2cdb387b87b8 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 24 Feb 2026 10:49:23 +0900 Subject: [PATCH] feat: Add BOM tree view and BOM item editor components - Introduced new components for BOM tree view and BOM item editor, enhancing the data management capabilities within the application. - Updated the ComponentsPanel to include these new components with appropriate descriptions and default sizes. - Integrated the BOM item editor into the V2PropertiesPanel for seamless editing of BOM items. - Adjusted the SplitLineComponent to improve the handling of canvas split positions, ensuring better user experience during component interactions. --- frontend/components/auth/AuthGuard.tsx | 141 +-- .../screen/InteractiveScreenViewerDynamic.tsx | 94 +- .../screen/RealtimePreviewDynamic.tsx | 66 +- .../screen/panels/ComponentsPanel.tsx | 16 + .../screen/panels/V2PropertiesPanel.tsx | 5 + .../V2BomItemEditorConfigPanel.tsx | 1088 +++++++++++++++++ frontend/hooks/useAuth.ts | 273 ++--- frontend/lib/api/client.ts | 331 ++--- frontend/lib/registry/components/index.ts | 1 + .../BomItemEditorComponent.tsx | 709 +++++++++++ .../BomItemEditorRenderer.tsx | 22 + .../components/v2-bom-item-editor/index.ts | 30 + .../v2-split-line/SplitLineComponent.tsx | 4 +- 13 files changed, 2258 insertions(+), 522 deletions(-) create mode 100644 frontend/components/v2/config-panels/V2BomItemEditorConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx create mode 100644 frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-bom-item-editor/index.ts diff --git a/frontend/components/auth/AuthGuard.tsx b/frontend/components/auth/AuthGuard.tsx index a02dce73..efb8cd25 100644 --- a/frontend/components/auth/AuthGuard.tsx +++ b/frontend/components/auth/AuthGuard.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, ReactNode, useState } from "react"; +import { useEffect, ReactNode } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; +import { Loader2 } from "lucide-react"; interface AuthGuardProps { children: ReactNode; @@ -15,6 +16,8 @@ interface AuthGuardProps { /** * 인증 보호 컴포넌트 * 로그인 상태 및 권한에 따라 접근을 제어 + * - 토큰 갱신/401 처리는 client.ts 인터셉터가 담당 + * - 이 컴포넌트는 인증 상태 기반 라우팅 가드 역할만 수행 */ export function AuthGuard({ children, @@ -23,145 +26,67 @@ export function AuthGuard({ redirectTo = "/login", fallback, }: AuthGuardProps) { - const { isLoggedIn, isAdmin, loading, error } = useAuth(); + const { isLoggedIn, isAdmin, loading } = useAuth(); const router = useRouter(); - const [redirectCountdown, setRedirectCountdown] = useState(null); - const [authDebugInfo, setAuthDebugInfo] = useState({}); useEffect(() => { - console.log("=== AuthGuard 디버깅 ==="); - console.log("requireAuth:", requireAuth); - console.log("requireAdmin:", requireAdmin); - console.log("loading:", loading); - console.log("isLoggedIn:", isLoggedIn); - console.log("isAdmin:", isAdmin); - console.log("error:", error); + if (loading) return; - // 토큰 확인을 더 정확하게 - const token = localStorage.getItem("authToken"); - console.log("AuthGuard localStorage 토큰:", token ? "존재" : "없음"); - console.log("현재 경로:", window.location.pathname); - - // 디버깅 정보 수집 - setAuthDebugInfo({ - requireAuth, - requireAdmin, - loading, - isLoggedIn, - isAdmin, - error, - hasToken: !!token, - currentPath: window.location.pathname, - timestamp: new Date().toISOString(), - tokenLength: token ? token.length : 0, - }); - - if (loading) { - console.log("AuthGuard: 로딩 중 - 대기"); - return; + // 토큰이 있는데 아직 인증 확인 중이면 대기 + if (typeof window !== "undefined") { + const token = localStorage.getItem("authToken"); + if (token && !isLoggedIn && !loading) { + return; + } } - // 토큰이 있는데도 인증이 안 된 경우, 잠시 대기 - if (token && !isLoggedIn && !loading) { - console.log("AuthGuard: 토큰은 있지만 인증이 안됨 - 잠시 대기"); - return; - } - - // 인증이 필요한데 로그인되지 않은 경우 if (requireAuth && !isLoggedIn) { - console.log("AuthGuard: 인증 필요하지만 로그인되지 않음 - 5초 후 리다이렉트"); - console.log("리다이렉트 대상:", redirectTo); - - setRedirectCountdown(5); - const countdownInterval = setInterval(() => { - setRedirectCountdown((prev) => { - if (prev === null || prev <= 1) { - clearInterval(countdownInterval); - router.push(redirectTo); - return null; - } - return prev - 1; - }); - }, 1000); - + router.push(redirectTo); return; } - // 관리자 권한이 필요한데 관리자가 아닌 경우 if (requireAdmin && !isAdmin) { - console.log("AuthGuard: 관리자 권한 필요하지만 관리자가 아님 - 5초 후 리다이렉트"); - console.log("리다이렉트 대상:", redirectTo); - - setRedirectCountdown(5); - const countdownInterval = setInterval(() => { - setRedirectCountdown((prev) => { - if (prev === null || prev <= 1) { - clearInterval(countdownInterval); - router.push(redirectTo); - return null; - } - return prev - 1; - }); - }, 1000); - + router.push(redirectTo); return; } + }, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, redirectTo, router]); - console.log("AuthGuard: 모든 인증 조건 통과 - 컴포넌트 렌더링"); - }, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, error, redirectTo, router]); - - // 로딩 중일 때 fallback 또는 기본 로딩 표시 if (loading) { - console.log("AuthGuard: 로딩 중 - fallback 표시"); return ( -
-
-

AuthGuard 로딩 중...

-
{JSON.stringify(authDebugInfo, null, 2)}
+ fallback || ( +
+
+ +

로딩 중...

+
- {fallback ||
로딩 중...
} -
+ ) ); } - // 인증 실패 시 fallback 또는 기본 메시지 표시 if (requireAuth && !isLoggedIn) { - console.log("AuthGuard: 인증 실패 - fallback 표시"); return ( -
-
-

인증 실패

- {redirectCountdown !== null && ( -
- 리다이렉트 카운트다운: {redirectCountdown}초 후 {redirectTo}로 이동 -
- )} -
{JSON.stringify(authDebugInfo, null, 2)}
+ fallback || ( +
+
+ +

인증 확인 중...

+
- {fallback ||
인증이 필요합니다.
} -
+ ) ); } if (requireAdmin && !isAdmin) { - console.log("AuthGuard: 관리자 권한 없음 - fallback 표시"); return ( -
-
-

관리자 권한 없음

- {redirectCountdown !== null && ( -
- 리다이렉트 카운트다운: {redirectCountdown}초 후 {redirectTo}로 이동 -
- )} -
{JSON.stringify(authDebugInfo, null, 2)}
+ fallback || ( +
+

관리자 권한이 필요합니다.

- {fallback ||
관리자 권한이 필요합니다.
} -
+ ) ); } - console.log("AuthGuard: 인증 성공 - 자식 컴포넌트 렌더링"); return <>{children}; } diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 1415bb72..4f76778e 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1089,98 +1089,106 @@ export const InteractiveScreenViewerDynamic: React.FC { + const calculateCanvasSplitX = (): { x: number; w: number } => { const compType = (component as any).componentType || ""; const isSplitLine = type === "component" && compType === "v2-split-line"; const origX = position?.x || 0; + const defaultW = size?.width || 200; - if (isSplitLine) return origX; - - // DEBUG: 스플릿 스토어 상태 확인 (첫 컴포넌트만) - if (canvasSplit.active && origX > 0 && origX < 50) { - console.log("[SplitDebug]", { - compId: component.id, - compType, - type, - active: canvasSplit.active, - scopeId: canvasSplit.scopeId, - myScopeId: myScopeIdRef.current, - canvasWidth: canvasSplit.canvasWidth, - initialX: canvasSplit.initialDividerX, - currentX: canvasSplit.currentDividerX, - origX, - }); - } + if (isSplitLine) return { x: origX, w: defaultW }; if (!canvasSplit.active || canvasSplit.canvasWidth <= 0 || !canvasSplit.scopeId) { - return origX; + return { x: origX, w: defaultW }; } if (myScopeIdRef.current === null) { const el = document.getElementById(`interactive-${component.id}`); const container = el?.closest("[data-screen-runtime]"); myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__"; - console.log("[SplitDebug] scope resolved:", { compId: component.id, elFound: !!el, containerFound: !!container, myScopeId: myScopeIdRef.current, storeScopeId: canvasSplit.scopeId }); } if (myScopeIdRef.current !== canvasSplit.scopeId) { - return origX; + return { x: origX, w: defaultW }; } const { initialDividerX, currentDividerX, canvasWidth } = canvasSplit; const delta = currentDividerX - initialDividerX; - if (Math.abs(delta) < 1) return origX; + if (Math.abs(delta) < 1) return { x: origX, w: defaultW }; - const origW = size?.width || 200; + const origW = defaultW; if (canvasSplitSideRef.current === null) { const componentCenterX = origX + (origW / 2); canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right"; } - let newX = origX; + // 영역별 비례 스케일링: 스플릿선이 벽 역할 → 절대 넘어가지 않음 + let newX: number; + let newW: number; + const GAP = 4; // 스플릿선과의 최소 간격 if (canvasSplitSideRef.current === "left") { - if (initialDividerX > 0) { - newX = origX * (currentDividerX / initialDividerX); + // 왼쪽 영역: [0, currentDividerX - GAP] + const initialZoneWidth = initialDividerX; + const currentZoneWidth = Math.max(20, currentDividerX - GAP); + const scale = initialZoneWidth > 0 ? currentZoneWidth / initialZoneWidth : 1; + newX = origX * scale; + newW = origW * scale; + // 안전 클램핑: 왼쪽 영역을 절대 넘지 않음 + if (newX + newW > currentDividerX - GAP) { + newW = currentDividerX - GAP - newX; } } else { + // 오른쪽 영역: [currentDividerX + GAP, canvasWidth] const initialRightWidth = canvasWidth - initialDividerX; - const currentRightWidth = canvasWidth - currentDividerX; - if (initialRightWidth > 0) { - const posRatio = (origX - initialDividerX) / initialRightWidth; - newX = currentDividerX + posRatio * currentRightWidth; - } + const currentRightWidth = Math.max(20, canvasWidth - currentDividerX - GAP); + const scale = initialRightWidth > 0 ? currentRightWidth / initialRightWidth : 1; + const rightOffset = origX - initialDividerX; + newX = currentDividerX + GAP + rightOffset * scale; + newW = origW * scale; + // 안전 클램핑: 오른쪽 영역을 절대 넘지 않음 + if (newX < currentDividerX + GAP) newX = currentDividerX + GAP; + if (newX + newW > canvasWidth) newW = canvasWidth - newX; } - // 캔버스 범위 내로 클램핑 - return Math.max(0, Math.min(newX, canvasWidth - 10)); + newX = Math.max(0, newX); + newW = Math.max(20, newW); + + return { x: newX, w: newW }; }; - const adjustedX = calculateCanvasSplitX(); + const splitResult = calculateCanvasSplitX(); + const adjustedX = splitResult.x; + const adjustedW = splitResult.w; + const origW = size?.width || 200; const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId; + // styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지) + const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any; + const componentStyle = { position: "absolute" as const, + ...safeStyleWithoutSize, + // left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게) left: adjustedX, top: position?.y || 0, zIndex: position?.z || 1, - ...styleWithoutSize, - width: size?.width || 200, + width: isSplitActive ? adjustedW : (size?.width || 200), height: isTableSearchWidget ? "auto" : size?.height || 10, minHeight: isTableSearchWidget ? "48px" : undefined, - overflow: labelOffset > 0 ? "visible" : undefined, - // GPU 가속: 드래그 중 will-change 활성화, 끝나면 해제 - willChange: canvasSplit.isDragging && isSplitActive ? "left" as const : undefined, + overflow: (isSplitActive && adjustedW < origW) ? "hidden" : (labelOffset > 0 ? "visible" : undefined), + willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined, transition: isSplitActive - ? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out") + ? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out") : undefined, }; return ( <>
- {/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */} - {renderInteractiveWidget(component)} + {renderInteractiveWidget( + isSplitActive && adjustedW !== origW + ? { ...component, size: { ...(component as any).size, width: adjustedW } } + : component + )}
{/* 팝업 화면 렌더링 */} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index b053f25a..d4c91d93 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -424,10 +424,10 @@ const RealtimePreviewDynamicComponent: React.FC = ({ type === "component" && componentType === "v2-split-line"; if (isSplitLineComponent) { - return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; + return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false }; } - // === 1. 캔버스 분할선 (글로벌 스토어) - 위치만 조정, 너비 미변경 === + // === 1. 캔버스 분할선 (글로벌 스토어) === if (canvasSplit.active && canvasSplit.canvasWidth > 0 && canvasSplit.scopeId) { if (myScopeIdRef.current === null) { const el = document.getElementById(`component-${id}`); @@ -435,7 +435,7 @@ const RealtimePreviewDynamicComponent: React.FC = ({ myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__"; } if (myScopeIdRef.current !== canvasSplit.scopeId) { - return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; + return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false }; } const { initialDividerX, currentDividerX, canvasWidth, isDragging: splitDragging } = canvasSplit; const delta = currentDividerX - initialDividerX; @@ -447,27 +447,39 @@ const RealtimePreviewDynamicComponent: React.FC = ({ } if (Math.abs(delta) < 1) { - return { adjustedPositionX: position.x, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging }; + return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging }; } - let adjustedX = position.x; + // 영역별 비례 스케일링: 스플릿선이 벽 역할 → 절대 넘어가지 않음 + const origW = size?.width || 100; + const GAP = 4; + let adjustedX: number; + let adjustedW: number; if (canvasSplitSideRef.current === "left") { - if (initialDividerX > 0) { - adjustedX = position.x * (currentDividerX / initialDividerX); + const initialZoneWidth = initialDividerX; + const currentZoneWidth = Math.max(20, currentDividerX - GAP); + const scale = initialZoneWidth > 0 ? currentZoneWidth / initialZoneWidth : 1; + adjustedX = position.x * scale; + adjustedW = origW * scale; + if (adjustedX + adjustedW > currentDividerX - GAP) { + adjustedW = currentDividerX - GAP - adjustedX; } } else { const initialRightWidth = canvasWidth - initialDividerX; - const currentRightWidth = canvasWidth - currentDividerX; - if (initialRightWidth > 0) { - const posRatio = (position.x - initialDividerX) / initialRightWidth; - adjustedX = currentDividerX + posRatio * currentRightWidth; - } + const currentRightWidth = Math.max(20, canvasWidth - currentDividerX - GAP); + const scale = initialRightWidth > 0 ? currentRightWidth / initialRightWidth : 1; + const rightOffset = position.x - initialDividerX; + adjustedX = currentDividerX + GAP + rightOffset * scale; + adjustedW = origW * scale; + if (adjustedX < currentDividerX + GAP) adjustedX = currentDividerX + GAP; + if (adjustedX + adjustedW > canvasWidth) adjustedW = canvasWidth - adjustedX; } - adjustedX = Math.max(0, Math.min(adjustedX, canvasWidth - 10)); + adjustedX = Math.max(0, adjustedX); + adjustedW = Math.max(20, adjustedW); - return { adjustedPositionX: adjustedX, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging }; + return { adjustedPositionX: adjustedX, adjustedWidth: adjustedW, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging }; } // === 2. 레거시 분할 패널 (Context) - 버튼 전용 === @@ -475,11 +487,11 @@ const RealtimePreviewDynamicComponent: React.FC = ({ type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType); if (isSplitPanelComponent) { - return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; + return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false }; } if (!isButtonComponent) { - return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; + return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false }; } const componentWidth = size?.width || 100; @@ -493,7 +505,7 @@ const RealtimePreviewDynamicComponent: React.FC = ({ initialPanelIdRef.current = null; isInLeftPanelRef.current = null; } - return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; + return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false }; } const { panel } = overlap; @@ -512,37 +524,42 @@ const RealtimePreviewDynamicComponent: React.FC = ({ const dividerDelta = currentDividerX - initialDividerX; if (Math.abs(dividerDelta) < 1) { - return { adjustedPositionX: position.x, isOnSplitPanel: true, isDraggingSplitPanel: panel.isDragging }; + return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: true, isDraggingSplitPanel: panel.isDragging }; } const adjustedX = isInLeftPanelRef.current ? position.x + dividerDelta : position.x; return { adjustedPositionX: adjustedX, + adjustedWidth: null, isOnSplitPanel: true, isDraggingSplitPanel: panel.isDragging, }; }; - const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateSplitAdjustedPosition(); + const { adjustedPositionX, adjustedWidth: splitAdjustedWidth, isOnSplitPanel, isDraggingSplitPanel } = calculateSplitAdjustedPosition(); const displayWidth = resizeSize ? `${resizeSize.width}px` : getWidth(); const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight(); const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId; + const origWidth = size?.width || 100; + const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth; + const baseStyle = { left: `${adjustedPositionX}px`, top: `${position.y}px`, ...componentStyle, - width: displayWidth, + width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth, height: displayHeight, zIndex: component.type === "layout" ? 1 : position.z || 2, right: undefined, - willChange: canvasSplit.isDragging && isSplitActive ? "left" as const : undefined, + overflow: isSplitShrunk ? "hidden" as const : undefined, + willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined, transition: isResizing ? "none" : - isOnSplitPanel ? (isDraggingSplitPanel ? "none" : "left 0.15s ease-out") : undefined, + isOnSplitPanel ? (isDraggingSplitPanel ? "none" : "left 0.15s ease-out, width 0.15s ease-out") : undefined, }; // 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제) @@ -612,7 +629,10 @@ const RealtimePreviewDynamicComponent: React.FC = ({ style={{ width: "100%", maxWidth: "100%" }} > = ({ "v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel, "v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel, "v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel, + "v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel, }; const V2ConfigPanel = v2ConfigPanels[componentId]; @@ -239,6 +240,10 @@ export const V2PropertiesPanel: React.FC = ({ if (componentId === "v2-list") { extraProps.currentTableName = currentTableName; } + if (componentId === "v2-bom-item-editor") { + extraProps.currentTableName = currentTableName; + extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; + } return (
diff --git a/frontend/components/v2/config-panels/V2BomItemEditorConfigPanel.tsx b/frontend/components/v2/config-panels/V2BomItemEditorConfigPanel.tsx new file mode 100644 index 00000000..51c9a4f9 --- /dev/null +++ b/frontend/components/v2/config-panels/V2BomItemEditorConfigPanel.tsx @@ -0,0 +1,1088 @@ +"use client"; + +/** + * BOM 하위품목 편집기 설정 패널 + * + * V2RepeaterConfigPanel 구조를 기반으로 구현: + * - 기본 탭: 저장 테이블 + 엔티티 선택 + 트리 설정 + 기능 옵션 + * - 컬럼 탭: 소스 표시 컬럼 + 저장 입력 컬럼 + 선택된 컬럼 상세 + */ + +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Database, + Link2, + Trash2, + GripVertical, + ArrowRight, + ChevronDown, + ChevronRight, + Eye, + EyeOff, + Check, + ChevronsUpDown, + GitBranch, +} from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { tableTypeApi } from "@/lib/api/screen"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { cn } from "@/lib/utils"; + +// ─── 타입 (리피터와 동일한 패턴) ─── + +interface TableRelation { + tableName: string; + tableLabel: string; + foreignKeyColumn: string; + referenceColumn: string; +} + +interface ColumnOption { + columnName: string; + displayName: string; + inputType?: string; + detailSettings?: { + codeGroup?: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + format?: string; + }; +} + +interface EntityColumnOption { + columnName: string; + displayName: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; +} + +interface BomColumnConfig { + key: string; + title: string; + width?: string; + visible?: boolean; + editable?: boolean; + hidden?: boolean; + inputType?: string; + isSourceDisplay?: boolean; + detailSettings?: { + codeGroup?: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + format?: string; + }; +} + +interface BomItemEditorConfig { + // 저장 테이블 설정 (리피터 패턴) + useCustomTable?: boolean; + mainTableName?: string; + foreignKeyColumn?: string; + foreignKeySourceColumn?: string; + + // 트리 구조 설정 + parentKeyColumn?: string; + + // 엔티티 (품목 참조) 설정 + dataSource?: { + sourceTable?: string; + foreignKey?: string; + referenceKey?: string; + displayColumn?: string; + }; + + // 컬럼 설정 + columns: BomColumnConfig[]; + + // 기능 옵션 + features?: { + showAddButton?: boolean; + showDeleteButton?: boolean; + inlineEdit?: boolean; + showRowNumber?: boolean; + maxDepth?: number; + }; +} + +interface V2BomItemEditorConfigPanelProps { + config: BomItemEditorConfig; + onChange: (config: BomItemEditorConfig) => void; + currentTableName?: string; + screenTableName?: string; +} + +// ─── 메인 패널 ─── + +export function V2BomItemEditorConfigPanel({ + config: propConfig, + onChange, + currentTableName: propCurrentTableName, + screenTableName, +}: V2BomItemEditorConfigPanelProps) { + const currentTableName = screenTableName || propCurrentTableName; + + const config: BomItemEditorConfig = useMemo( + () => ({ + columns: [], + ...propConfig, + dataSource: { ...propConfig?.dataSource }, + features: { + showAddButton: true, + showDeleteButton: true, + inlineEdit: false, + showRowNumber: false, + maxDepth: 3, + ...propConfig?.features, + }, + }), + [propConfig], + ); + + // ─── 상태 ─── + const [currentTableColumns, setCurrentTableColumns] = useState([]); + const [entityColumns, setEntityColumns] = useState([]); + const [sourceTableColumns, setSourceTableColumns] = useState([]); + const [allTables, setAllTables] = useState<{ tableName: string; displayName: string }[]>([]); + const [relatedTables, setRelatedTables] = useState([]); + const [loadingColumns, setLoadingColumns] = useState(false); + const [loadingSourceColumns, setLoadingSourceColumns] = useState(false); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingRelations, setLoadingRelations] = useState(false); + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); + const [expandedColumn, setExpandedColumn] = useState(null); + + // ─── 업데이트 헬퍼 (리피터 패턴) ─── + const updateConfig = useCallback( + (updates: Partial) => { + onChange({ ...config, ...updates }); + }, + [config, onChange], + ); + + const updateFeatures = useCallback( + (field: string, value: any) => { + updateConfig({ features: { ...config.features, [field]: value } }); + }, + [config.features, updateConfig], + ); + + // ─── 전체 테이블 목록 로드 (리피터 동일) ─── + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables( + response.data.map((t: any) => ({ + tableName: t.tableName || t.table_name, + displayName: t.displayName || t.table_label || t.tableName || t.table_name, + })), + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + // ─── 연관 테이블 로드 (리피터 동일) ─── + useEffect(() => { + const loadRelatedTables = async () => { + const baseTable = currentTableName || config.mainTableName; + if (!baseTable) { + setRelatedTables([]); + return; + } + setLoadingRelations(true); + try { + const { apiClient } = await import("@/lib/api/client"); + const allRelations: TableRelation[] = []; + + if (currentTableName) { + const response = await apiClient.get( + `/table-management/columns/${currentTableName}/referenced-by`, + ); + if (response.data.success && response.data.data) { + allRelations.push( + ...response.data.data.map((rel: any) => ({ + tableName: rel.tableName || rel.table_name, + tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name, + foreignKeyColumn: rel.columnName || rel.column_name, + referenceColumn: rel.referenceColumn || rel.reference_column || "id", + })), + ); + } + } + + if (config.mainTableName && config.mainTableName !== currentTableName) { + const response2 = await apiClient.get( + `/table-management/columns/${config.mainTableName}/referenced-by`, + ); + if (response2.data.success && response2.data.data) { + response2.data.data.forEach((rel: any) => { + const tn = rel.tableName || rel.table_name; + if (!allRelations.some((r) => r.tableName === tn)) { + allRelations.push({ + tableName: tn, + tableLabel: rel.tableLabel || rel.table_label || tn, + foreignKeyColumn: rel.columnName || rel.column_name, + referenceColumn: rel.referenceColumn || rel.reference_column || "id", + }); + } + }); + } + } + + setRelatedTables(allRelations); + } catch (error) { + console.error("연관 테이블 로드 실패:", error); + setRelatedTables([]); + } finally { + setLoadingRelations(false); + } + }; + loadRelatedTables(); + }, [currentTableName, config.mainTableName]); + + // ─── 저장 테이블 선택 (리피터 동일) ─── + const handleSaveTableSelect = useCallback( + (tableName: string) => { + if (!tableName || tableName === currentTableName) { + updateConfig({ + useCustomTable: false, + mainTableName: undefined, + foreignKeyColumn: undefined, + foreignKeySourceColumn: undefined, + }); + return; + } + + const relation = relatedTables.find((r) => r.tableName === tableName); + if (relation) { + updateConfig({ + useCustomTable: true, + mainTableName: tableName, + foreignKeyColumn: relation.foreignKeyColumn, + foreignKeySourceColumn: relation.referenceColumn, + }); + } else { + updateConfig({ + useCustomTable: true, + mainTableName: tableName, + foreignKeyColumn: undefined, + foreignKeySourceColumn: "id", + }); + } + }, + [currentTableName, relatedTables, updateConfig], + ); + + // ─── 저장 테이블 컬럼 로드 (리피터 동일) ─── + const targetTableForColumns = + config.useCustomTable && config.mainTableName ? config.mainTableName : currentTableName; + + useEffect(() => { + const loadColumns = async () => { + if (!targetTableForColumns) { + setCurrentTableColumns([]); + setEntityColumns([]); + return; + } + setLoadingColumns(true); + try { + const columnData = await tableTypeApi.getColumns(targetTableForColumns); + const cols: ColumnOption[] = []; + const entityCols: EntityColumnOption[] = []; + + for (const c of columnData) { + let detailSettings: any = null; + if (c.detailSettings) { + try { + detailSettings = + typeof c.detailSettings === "string" ? JSON.parse(c.detailSettings) : c.detailSettings; + } catch { + // ignore + } + } + + const col: ColumnOption = { + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + detailSettings: detailSettings + ? { + codeGroup: detailSettings.codeGroup, + referenceTable: detailSettings.referenceTable, + referenceColumn: detailSettings.referenceColumn, + displayColumn: detailSettings.displayColumn, + format: detailSettings.format, + } + : undefined, + }; + cols.push(col); + + if (col.inputType === "entity") { + const refTable = detailSettings?.referenceTable || c.referenceTable; + if (refTable) { + entityCols.push({ + columnName: col.columnName, + displayName: col.displayName, + referenceTable: refTable, + referenceColumn: detailSettings?.referenceColumn || c.referenceColumn || "id", + displayColumn: detailSettings?.displayColumn || c.displayColumn, + }); + } + } + } + + setCurrentTableColumns(cols); + setEntityColumns(entityCols); + } catch (error) { + console.error("컬럼 로드 실패:", error); + setCurrentTableColumns([]); + setEntityColumns([]); + } finally { + setLoadingColumns(false); + } + }; + loadColumns(); + }, [targetTableForColumns]); + + // ─── 소스(엔티티) 테이블 컬럼 로드 (리피터 동일) ─── + useEffect(() => { + const loadSourceColumns = async () => { + const sourceTable = config.dataSource?.sourceTable; + if (!sourceTable) { + setSourceTableColumns([]); + return; + } + setLoadingSourceColumns(true); + try { + const columnData = await tableTypeApi.getColumns(sourceTable); + setSourceTableColumns( + columnData.map((c: any) => ({ + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + })), + ); + } catch (error) { + console.error("소스 테이블 컬럼 로드 실패:", error); + setSourceTableColumns([]); + } finally { + setLoadingSourceColumns(false); + } + }; + loadSourceColumns(); + }, [config.dataSource?.sourceTable]); + + // ─── 엔티티 컬럼 선택 시 소스 테이블 자동 설정 (리피터 동일) ─── + const handleEntityColumnSelect = (columnName: string) => { + const selectedEntity = entityColumns.find((c) => c.columnName === columnName); + if (selectedEntity) { + updateConfig({ + dataSource: { + ...config.dataSource, + sourceTable: selectedEntity.referenceTable || "", + foreignKey: selectedEntity.columnName, + referenceKey: selectedEntity.referenceColumn || "id", + displayColumn: selectedEntity.displayColumn, + }, + }); + } + }; + + // ─── 컬럼 토글 (리피터 동일) ─── + const toggleInputColumn = (column: ColumnOption) => { + const exists = config.columns.findIndex((c) => c.key === column.columnName && !c.isSourceDisplay); + if (exists >= 0) { + updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName || c.isSourceDisplay) }); + } else { + const newCol: BomColumnConfig = { + key: column.columnName, + title: column.displayName, + width: "auto", + visible: true, + editable: true, + inputType: column.inputType || "text", + detailSettings: column.detailSettings, + }; + updateConfig({ columns: [...config.columns, newCol] }); + } + }; + + const toggleSourceDisplayColumn = (column: ColumnOption) => { + const exists = config.columns.some((c) => c.key === column.columnName && c.isSourceDisplay); + if (exists) { + updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName) }); + } else { + const newCol: BomColumnConfig = { + key: column.columnName, + title: column.displayName, + width: "auto", + visible: true, + editable: false, + isSourceDisplay: true, + }; + updateConfig({ columns: [...config.columns, newCol] }); + } + }; + + const isColumnAdded = (columnName: string) => + config.columns.some((c) => c.key === columnName && !c.isSourceDisplay); + + const isSourceColumnSelected = (columnName: string) => + config.columns.some((c) => c.key === columnName && c.isSourceDisplay); + + const updateColumnProp = (key: string, field: keyof BomColumnConfig, value: any) => { + updateConfig({ + columns: config.columns.map((col) => (col.key === key ? { ...col, [field]: value } : col)), + }); + }; + + // FK 컬럼 제외한 입력 가능 컬럼 + const inputableColumns = useMemo(() => { + const fkColumn = config.dataSource?.foreignKey; + return currentTableColumns.filter( + (col) => col.columnName !== fkColumn && col.inputType !== "entity", + ); + }, [currentTableColumns, config.dataSource?.foreignKey]); + + // ─── 렌더링 ─── + return ( +
+ + + + 기본 + + + 컬럼 + + + + {/* ─── 기본 설정 탭 ─── */} + + {/* 저장 대상 테이블 (리피터 동일) */} +
+ + +
+
+ +
+

+ {config.useCustomTable && config.mainTableName + ? allTables.find((t) => t.tableName === config.mainTableName)?.displayName || + config.mainTableName + : currentTableName || "미설정"} +

+ {config.useCustomTable && config.mainTableName && config.foreignKeyColumn && ( +

+ FK: {config.foreignKeyColumn} → {currentTableName}. + {config.foreignKeySourceColumn || "id"} +

+ )} + {!config.useCustomTable && currentTableName && ( +

화면 메인 테이블

+ )} +
+
+
+ + {/* 테이블 Combobox (리피터 동일) */} + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {currentTableName && ( + + { + handleSaveTableSelect(currentTableName); + setTableComboboxOpen(false); + }} + className="text-xs" + > + + + {currentTableName} + (기본) + + + )} + + {relatedTables.length > 0 && ( + + {relatedTables.map((rel) => ( + { + handleSaveTableSelect(rel.tableName); + setTableComboboxOpen(false); + }} + className="text-xs" + > + + + {rel.tableLabel} + + ({rel.foreignKeyColumn}) + + + ))} + + )} + + + {allTables + .filter( + (t) => + t.tableName !== currentTableName && + !relatedTables.some((r) => r.tableName === t.tableName), + ) + .map((table) => ( + { + handleSaveTableSelect(table.tableName); + setTableComboboxOpen(false); + }} + className="text-xs" + > + + + {table.displayName} + + ))} + + + + + + + {/* FK 직접 입력 (연관 없는 테이블 선택 시) */} + {config.useCustomTable && + config.mainTableName && + currentTableName && + !relatedTables.some((r) => r.tableName === config.mainTableName) && ( +
+

+ 화면 테이블({currentTableName})과의 엔티티 관계가 없습니다. FK 컬럼을 직접 + 입력하세요. +

+
+
+ + updateConfig({ foreignKeyColumn: e.target.value })} + placeholder="예: bom_id" + className="h-7 text-xs" + /> +
+
+ + updateConfig({ foreignKeySourceColumn: e.target.value })} + placeholder="id" + className="h-7 text-xs" + /> +
+
+
+ )} +
+ + + + {/* 트리 구조 설정 (BOM 전용) */} +
+
+ + +
+

+ 계층 구조를 위한 자기 참조 FK 컬럼을 선택하세요 +

+ + {currentTableColumns.length > 0 ? ( + + ) : ( +
+

+ {loadingColumns ? "로딩 중..." : "저장 테이블을 먼저 선택하세요"} +

+
+ )} + + {/* 최대 깊이 */} +
+ + updateFeatures("maxDepth", parseInt(e.target.value) || 3)} + className="h-7 w-20 text-xs" + /> +
+
+ + + + {/* 엔티티 선택 (리피터 모달 모드와 동일) */} +
+ +

+ 품목 검색 시 참조할 엔티티를 선택하세요 (FK만 저장됨) +

+ + {entityColumns.length > 0 ? ( + + ) : ( +
+

+ {loadingColumns + ? "로딩 중..." + : !targetTableForColumns + ? "저장 테이블을 먼저 선택하세요" + : "엔티티 타입 컬럼이 없습니다"} +

+
+ )} + + {config.dataSource?.sourceTable && ( +
+

선택된 엔티티

+
+

검색 테이블: {config.dataSource.sourceTable}

+

저장 컬럼: {config.dataSource.foreignKey} (FK)

+
+
+ )} +
+ + + + {/* 기능 옵션 */} +
+ +
+
+ updateFeatures("showAddButton", !!checked)} + /> + +
+
+ updateFeatures("showDeleteButton", !!checked)} + /> + +
+
+ updateFeatures("inlineEdit", !!checked)} + /> + +
+
+ updateFeatures("showRowNumber", !!checked)} + /> + +
+
+
+ + {/* 메인 화면 테이블 참고 */} + {currentTableName && ( + <> + +
+ +
+

{currentTableName}

+

+ 컬럼 {currentTableColumns.length}개 / 엔티티 {entityColumns.length}개 +

+
+
+ + )} +
+ + {/* ─── 컬럼 설정 탭 (리피터 동일 패턴) ─── */} + +
+ +

+ 표시할 소스 컬럼과 입력 컬럼을 선택하세요 +

+ + {/* 소스 테이블 컬럼 (표시용) */} + {config.dataSource?.sourceTable && ( + <> +
+ + 소스 테이블 ({config.dataSource.sourceTable}) - 표시용 +
+ {loadingSourceColumns ? ( +

로딩 중...

+ ) : sourceTableColumns.length === 0 ? ( +

컬럼 정보가 없습니다

+ ) : ( +
+ {sourceTableColumns.map((column) => ( +
toggleSourceDisplayColumn(column)} + > + toggleSourceDisplayColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + + {column.displayName} + 표시 +
+ ))} +
+ )} + + )} + + {/* 저장 테이블 컬럼 (입력용) */} +
+ + 저장 테이블 ({targetTableForColumns || "미선택"}) - 입력용 +
+ {loadingColumns ? ( +

로딩 중...

+ ) : inputableColumns.length === 0 ? ( +

컬럼 정보가 없습니다

+ ) : ( +
+ {inputableColumns.map((column) => ( +
toggleInputColumn(column)} + > + toggleInputColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + + {column.displayName} + {column.inputType} +
+ ))} +
+ )} +
+ + {/* 선택된 컬럼 상세 (리피터 동일 패턴) */} + {config.columns.length > 0 && ( + <> + +
+ +
+ {config.columns.map((col, index) => ( +
+
e.dataTransfer.setData("columnIndex", String(index))} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const fromIndex = parseInt(e.dataTransfer.getData("columnIndex"), 10); + if (fromIndex !== index) { + const newColumns = [...config.columns]; + const [movedCol] = newColumns.splice(fromIndex, 1); + newColumns.splice(index, 0, movedCol); + updateConfig({ columns: newColumns }); + } + }} + > + + + {!col.isSourceDisplay && ( + + )} + + {col.isSourceDisplay ? ( + + ) : ( + + )} + + updateColumnProp(col.key, "title", e.target.value)} + placeholder="제목" + className="h-6 flex-1 text-xs" + /> + + {!col.isSourceDisplay && ( + + )} + + {!col.isSourceDisplay && ( + + updateColumnProp(col.key, "editable", !!checked) + } + title="편집 가능" + /> + )} + + +
+ + {/* 확장 상세 */} + {!col.isSourceDisplay && expandedColumn === col.key && ( +
+
+ + updateColumnProp(col.key, "width", e.target.value)} + placeholder="auto, 100px, 20%" + className="h-6 text-xs" + /> +
+
+ )} +
+ ))} +
+
+ + )} +
+
+
+ ); +} + +V2BomItemEditorConfigPanel.displayName = "V2BomItemEditorConfigPanel"; + +export default V2BomItemEditorConfigPanel; diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts index 29752559..737710d3 100644 --- a/frontend/hooks/useAuth.ts +++ b/frontend/hooks/useAuth.ts @@ -1,8 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; -import { apiCall, API_BASE_URL } from "@/lib/api/client"; +import { apiCall } from "@/lib/api/client"; -// 사용자 정보 타입 정의 interface UserInfo { userId: string; userName: string; @@ -23,11 +22,10 @@ interface UserInfo { isAdmin: boolean; sabun?: string; photo?: string | null; - companyCode?: string; // 백엔드와 일치하도록 수정 - company_code?: string; // 하위 호환성을 위해 유지 + companyCode?: string; + company_code?: string; } -// 인증 상태 타입 정의 interface AuthStatus { isLoggedIn: boolean; isAdmin: boolean; @@ -35,14 +33,12 @@ interface AuthStatus { deptCode?: string; } -// 로그인 결과 타입 정의 interface LoginResult { success: boolean; message: string; errorCode?: string; } -// API 응답 타입 정의 interface ApiResponse { success: boolean; message: string; @@ -50,9 +46,7 @@ interface ApiResponse { errorCode?: string; } -/** - * JWT 토큰 관리 유틸리티 - */ +// JWT 토큰 관리 유틸리티 (client.ts와 동일한 localStorage 키 사용) const TokenManager = { getToken: (): string | null => { if (typeof window !== "undefined") { @@ -63,7 +57,6 @@ const TokenManager = { setToken: (token: string): void => { if (typeof window !== "undefined") { - // localStorage에 저장 localStorage.setItem("authToken", token); // 쿠키에도 저장 (미들웨어에서 사용) document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`; @@ -72,9 +65,7 @@ const TokenManager = { removeToken: (): void => { if (typeof window !== "undefined") { - // localStorage에서 제거 localStorage.removeItem("authToken"); - // 쿠키에서도 제거 document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; } }, @@ -91,12 +82,12 @@ const TokenManager = { /** * 인증 상태 관리 훅 - * 로그인, 로그아웃, 사용자 정보 조회, 권한 확인 등을 담당 + * - 401 처리는 client.ts의 응답 인터셉터에서 통합 관리 + * - 이 훅은 상태 관리와 사용자 정보 조회에만 집중 */ export const useAuth = () => { const router = useRouter(); - // 상태 관리 const [user, setUser] = useState(null); const [authStatus, setAuthStatus] = useState({ isLoggedIn: false, @@ -106,8 +97,6 @@ export const useAuth = () => { const [error, setError] = useState(null); const initializedRef = useRef(false); - // API 기본 URL 설정 (동적으로 결정) - /** * 현재 사용자 정보 조회 */ @@ -116,26 +105,19 @@ export const useAuth = () => { const response = await apiCall("GET", "/auth/me"); if (response.success && response.data) { - // 사용자 로케일 정보도 함께 조회하여 전역 저장 + // 사용자 로케일 정보 조회 try { const localeResponse = await apiCall("GET", "/admin/user-locale"); if (localeResponse.success && localeResponse.data) { const userLocale = localeResponse.data; - - // 전역 상태에 저장 (다른 컴포넌트에서 사용) (window as any).__GLOBAL_USER_LANG = userLocale; (window as any).__GLOBAL_USER_LOCALE_LOADED = true; - - // localStorage에도 저장 (새 창에서 공유) localStorage.setItem("userLocale", userLocale); localStorage.setItem("userLocaleLoaded", "true"); } - } catch (localeError) { - console.warn("⚠️ 사용자 로케일 조회 실패, 기본값 사용:", localeError); + } catch { (window as any).__GLOBAL_USER_LANG = "KR"; (window as any).__GLOBAL_USER_LOCALE_LOADED = true; - - // localStorage에도 저장 localStorage.setItem("userLocale", "KR"); localStorage.setItem("userLocaleLoaded", "true"); } @@ -144,8 +126,7 @@ export const useAuth = () => { } return null; - } catch (error) { - console.error("사용자 정보 조회 실패:", error); + } catch { return null; } }, []); @@ -157,95 +138,89 @@ export const useAuth = () => { try { const response = await apiCall("GET", "/auth/status"); if (response.success && response.data) { - // 백엔드에서 isAuthenticated를 반환하므로 isLoggedIn으로 매핑 - const mappedData = { + return { isLoggedIn: (response.data as any).isAuthenticated || response.data.isLoggedIn || false, isAdmin: response.data.isAdmin || false, }; - return mappedData; } - return { - isLoggedIn: false, - isAdmin: false, - }; - } catch (error) { - console.error("인증 상태 확인 실패:", error); - return { - isLoggedIn: false, - isAdmin: false, - }; + return { isLoggedIn: false, isAdmin: false }; + } catch { + return { isLoggedIn: false, isAdmin: false }; } }, []); /** * 사용자 데이터 새로고침 + * - API 실패 시에도 토큰이 유효하면 토큰 기반으로 임시 인증 유지 + * - 토큰 자체가 없거나 만료된 경우에만 비인증 상태로 전환 */ const refreshUserData = useCallback(async () => { try { setLoading(true); - // JWT 토큰 확인 const token = TokenManager.getToken(); - if (!token) { + if (!token || TokenManager.isTokenExpired(token)) { setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); - setTimeout(() => { - router.push("/login"); - }, 3000); + setLoading(false); return; } - // 토큰이 있으면 임시로 인증된 상태로 설정 + // 토큰이 유효하면 우선 인증된 상태로 설정 setAuthStatus({ isLoggedIn: true, - isAdmin: false, // API 호출 후 업데이트될 예정 + isAdmin: false, }); try { - // 병렬로 사용자 정보와 인증 상태 조회 const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]); - setUser(userInfo); - - // 관리자 권한 확인 로직 개선 - let finalAuthStatus = authStatusData; if (userInfo) { - // 사용자 정보를 기반으로 관리자 권한 추가 확인 + setUser(userInfo); + const isAdminFromUser = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN"; - finalAuthStatus = { + const finalAuthStatus = { isLoggedIn: authStatusData.isLoggedIn, isAdmin: authStatusData.isAdmin || isAdminFromUser, }; - } - setAuthStatus(finalAuthStatus); + setAuthStatus(finalAuthStatus); - // console.log("✅ 최종 사용자 상태:", { - // userId: userInfo?.userId, - // userName: userInfo?.userName, - // companyCode: userInfo?.companyCode || userInfo?.company_code, - // }); - - // 디버깅용 로그 - - // 로그인되지 않은 상태인 경우 토큰 제거 (리다이렉트는 useEffect에서 처리) - if (!finalAuthStatus.isLoggedIn) { - TokenManager.removeToken(); - setUser(null); - setAuthStatus({ isLoggedIn: false, isAdmin: false }); + // API 결과가 비인증이면 상태만 업데이트 (리다이렉트는 client.ts가 처리) + if (!finalAuthStatus.isLoggedIn) { + TokenManager.removeToken(); + setUser(null); + setAuthStatus({ isLoggedIn: false, isAdmin: false }); + } } else { + // userInfo 조회 실패 → 토큰에서 최소 정보 추출하여 유지 + try { + const payload = JSON.parse(atob(token.split(".")[1])); + const tempUser: UserInfo = { + userId: payload.userId || payload.id || "unknown", + userName: payload.userName || payload.name || "사용자", + companyCode: payload.companyCode || payload.company_code || "", + isAdmin: payload.userId === "plm_admin" || payload.userType === "ADMIN", + }; + + setUser(tempUser); + setAuthStatus({ + isLoggedIn: true, + isAdmin: tempUser.isAdmin, + }); + } catch { + // 토큰 파싱도 실패하면 비인증 상태로 전환 + TokenManager.removeToken(); + setUser(null); + setAuthStatus({ isLoggedIn: false, isAdmin: false }); + } } - } catch (apiError) { - console.error("API 호출 실패:", apiError); - - // API 호출 실패 시에도 토큰이 있으면 임시로 인증된 상태로 처리 - - // 토큰에서 사용자 정보 추출 시도 + } catch { + // API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도 try { const payload = JSON.parse(atob(token.split(".")[1])); - - const tempUser = { + const tempUser: UserInfo = { userId: payload.userId || payload.id || "unknown", userName: payload.userName || payload.name || "사용자", companyCode: payload.companyCode || payload.company_code || "", @@ -257,32 +232,20 @@ export const useAuth = () => { isLoggedIn: true, isAdmin: tempUser.isAdmin, }); - } catch (tokenError) { - console.error("토큰 파싱 실패:", tokenError); - // 토큰 파싱도 실패하면 로그인 페이지로 리다이렉트 + } catch { TokenManager.removeToken(); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); - setTimeout(() => { - router.push("/login"); - }, 3000); } } - } catch (error) { - console.error("사용자 데이터 새로고침 실패:", error); + } catch { setError("사용자 정보를 불러오는데 실패했습니다."); - - // 오류 발생 시 토큰 제거 및 로그인 페이지로 리다이렉트 - TokenManager.removeToken(); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); - setTimeout(() => { - router.push("/login"); - }, 3000); } finally { setLoading(false); } - }, [fetchCurrentUser, checkAuthStatus, router]); + }, [fetchCurrentUser, checkAuthStatus]); /** * 로그인 처리 @@ -299,10 +262,7 @@ export const useAuth = () => { }); if (response.success && response.data?.token) { - // JWT 토큰 저장 TokenManager.setToken(response.data.token); - - // 로그인 성공 시 사용자 정보 및 인증 상태 업데이트 await refreshUserData(); return { @@ -328,7 +288,7 @@ export const useAuth = () => { setLoading(false); } }, - [apiCall, refreshUserData], + [refreshUserData], ); /** @@ -337,40 +297,27 @@ export const useAuth = () => { const switchCompany = useCallback( async (companyCode: string): Promise<{ success: boolean; message: string }> => { try { - // console.log("🔵 useAuth.switchCompany 시작:", companyCode); setLoading(true); setError(null); - // console.log("🔵 API 호출: POST /auth/switch-company"); const response = await apiCall("POST", "/auth/switch-company", { companyCode, }); - // console.log("🔵 API 응답:", response); if (response.success && response.data?.token) { - // console.log("🔵 새 토큰 받음:", response.data.token.substring(0, 20) + "..."); - - // 새로운 JWT 토큰 저장 TokenManager.setToken(response.data.token); - // console.log("🔵 토큰 저장 완료"); - - // refreshUserData 호출하지 않고 바로 성공 반환 - // (페이지 새로고침 시 자동으로 갱신됨) - // console.log("🔵 회사 전환 완료 (페이지 새로고침 필요)"); return { success: true, message: response.message || "회사 전환에 성공했습니다.", }; } else { - // console.error("🔵 API 응답 실패:", response); return { success: false, message: response.message || "회사 전환에 실패했습니다.", }; } } catch (error: any) { - // console.error("🔵 switchCompany 에러:", error); const errorMessage = error.message || "회사 전환 중 오류가 발생했습니다."; setError(errorMessage); @@ -380,10 +327,9 @@ export const useAuth = () => { }; } finally { setLoading(false); - // console.log("🔵 switchCompany 완료"); } }, - [apiCall] + [], ); /** @@ -395,51 +341,37 @@ export const useAuth = () => { const response = await apiCall("POST", "/auth/logout"); - // JWT 토큰 제거 TokenManager.removeToken(); - // 로케일 정보도 제거 localStorage.removeItem("userLocale"); localStorage.removeItem("userLocaleLoaded"); (window as any).__GLOBAL_USER_LANG = undefined; (window as any).__GLOBAL_USER_LOCALE_LOADED = undefined; - // 로그아웃 API 호출 성공 여부와 관계없이 클라이언트 상태 초기화 setUser(null); - setAuthStatus({ - isLoggedIn: false, - isAdmin: false, - }); + setAuthStatus({ isLoggedIn: false, isAdmin: false }); setError(null); - // 로그인 페이지로 리다이렉트 router.push("/login"); return response.success; - } catch (error) { - console.error("로그아웃 처리 실패:", error); - - // 오류가 발생해도 JWT 토큰 제거 및 클라이언트 상태 초기화 + } catch { TokenManager.removeToken(); - // 로케일 정보도 제거 localStorage.removeItem("userLocale"); localStorage.removeItem("userLocaleLoaded"); (window as any).__GLOBAL_USER_LANG = undefined; (window as any).__GLOBAL_USER_LOCALE_LOADED = undefined; setUser(null); - setAuthStatus({ - isLoggedIn: false, - isAdmin: false, - }); + setAuthStatus({ isLoggedIn: false, isAdmin: false }); router.push("/login"); return false; } finally { setLoading(false); } - }, [apiCall, router]); + }, [router]); /** * 메뉴 접근 권한 확인 @@ -453,8 +385,7 @@ export const useAuth = () => { } return false; - } catch (error) { - console.error("메뉴 권한 확인 실패:", error); + } catch { return false; } }, []); @@ -463,96 +394,56 @@ export const useAuth = () => { * 초기 인증 상태 확인 */ useEffect(() => { - // 이미 초기화되었으면 실행하지 않음 - if (initializedRef.current) { - return; - } - + if (initializedRef.current) return; initializedRef.current = true; + if (typeof window === "undefined") return; + // 로그인 페이지에서는 인증 상태 확인하지 않음 if (window.location.pathname === "/login") { + setLoading(false); return; } - // 토큰이 있는 경우에만 인증 상태 확인 const token = TokenManager.getToken(); if (token && !TokenManager.isTokenExpired(token)) { - // 토큰이 있으면 임시로 인증된 상태로 설정 (API 호출 전에) + // 유효한 토큰 → 우선 인증 상태로 설정 후 API 확인 setAuthStatus({ isLoggedIn: true, - isAdmin: false, // API 호출 후 업데이트될 예정 - }); - - refreshUserData(); - } else if (!token) { - // 토큰이 없으면 3초 후 로그인 페이지로 리다이렉트 - setTimeout(() => { - router.push("/login"); - }, 3000); - } else { - TokenManager.removeToken(); - setTimeout(() => { - router.push("/login"); - }, 3000); - } - }, []); // 초기 마운트 시에만 실행 - - /** - * 세션 만료 감지 및 처리 - */ - useEffect(() => { - const handleSessionExpiry = () => { - TokenManager.removeToken(); - setUser(null); - setAuthStatus({ - isLoggedIn: false, isAdmin: false, }); - setError("세션이 만료되었습니다. 다시 로그인해주세요."); - router.push("/login"); - }; - - // 전역 에러 핸들러 등록 (401 Unauthorized 응답 처리) - const originalFetch = window.fetch; - window.fetch = async (...args) => { - const response = await originalFetch(...args); - - if (response.status === 401 && window.location.pathname !== "/login") { - handleSessionExpiry(); - } - - return response; - }; - - return () => { - window.fetch = originalFetch; - }; - }, [router]); + refreshUserData(); + } else if (token && TokenManager.isTokenExpired(token)) { + // 만료된 토큰 → 정리 (리다이렉트는 AuthGuard에서 처리) + TokenManager.removeToken(); + setAuthStatus({ isLoggedIn: false, isAdmin: false }); + setLoading(false); + } else { + // 토큰 없음 → 비인증 상태 (리다이렉트는 AuthGuard에서 처리) + setAuthStatus({ isLoggedIn: false, isAdmin: false }); + setLoading(false); + } + }, []); return { - // 상태 user, authStatus, loading, error, - // 계산된 값 isLoggedIn: authStatus.isLoggedIn, isAdmin: authStatus.isAdmin, userId: user?.userId, userName: user?.userName, - companyCode: user?.companyCode || user?.company_code, // 🆕 회사 코드 + companyCode: user?.companyCode || user?.company_code, - // 함수 login, logout, - switchCompany, // 🆕 회사 전환 함수 + switchCompany, checkMenuAuth, refreshUserData, - // 유틸리티 clearError: () => setError(null), }; }; diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 0721fb38..01a069ce 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -1,24 +1,19 @@ -import axios, { AxiosResponse, AxiosError } from "axios"; +import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios"; // API URL 동적 설정 - 환경변수 우선 사용 const getApiBaseUrl = (): string => { - // 1. 환경변수가 있으면 우선 사용 if (process.env.NEXT_PUBLIC_API_URL) { return process.env.NEXT_PUBLIC_API_URL; } - // 2. 클라이언트 사이드에서 동적 설정 if (typeof window !== "undefined") { const currentHost = window.location.hostname; const currentPort = window.location.port; - const protocol = window.location.protocol; - // 프로덕션 환경: v1.vexplor.com → api.vexplor.com if (currentHost === "v1.vexplor.com") { return "https://api.vexplor.com/api"; } - // 로컬 개발환경: localhost:9771 또는 localhost:3000 → localhost:8080 if ( (currentHost === "localhost" || currentHost === "127.0.0.1") && (currentPort === "9771" || currentPort === "3000") @@ -27,57 +22,46 @@ const getApiBaseUrl = (): string => { } } - // 3. 기본값 return "http://localhost:8080/api"; }; export const API_BASE_URL = getApiBaseUrl(); // 이미지 URL을 완전한 URL로 변환하는 함수 -// 주의: 모듈 로드 시점이 아닌 런타임에 hostname을 확인해야 SSR 문제 방지 export const getFullImageUrl = (imagePath: string): string => { - // 빈 값 체크 if (!imagePath) return ""; - // 이미 전체 URL인 경우 그대로 반환 if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { return imagePath; } - // /uploads로 시작하는 상대 경로인 경우 API 서버 주소 추가 if (imagePath.startsWith("/uploads")) { - // 런타임에 현재 hostname 확인 (SSR 시점이 아닌 클라이언트에서 실행될 때) if (typeof window !== "undefined") { const currentHost = window.location.hostname; - // 프로덕션 환경: v1.vexplor.com → api.vexplor.com if (currentHost === "v1.vexplor.com") { return `https://api.vexplor.com${imagePath}`; } - // 로컬 개발환경 if (currentHost === "localhost" || currentHost === "127.0.0.1") { return `http://localhost:8080${imagePath}`; } } - // SSR 또는 알 수 없는 환경에서는 API_BASE_URL 사용 (fallback) - // 주의: 프로덕션 URL이 https://api.vexplor.com/api 이므로 - // 단순 .replace("/api", "")는 호스트명의 /api까지 제거하는 버그 발생 - // 반드시 문자열 끝의 /api만 제거해야 함 const baseUrl = API_BASE_URL.replace(/\/api$/, ""); if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { return `${baseUrl}${imagePath}`; } - // 최종 fallback return imagePath; } return imagePath; }; +// ============================================ // JWT 토큰 관리 유틸리티 +// ============================================ const TokenManager = { getToken: (): string | null => { if (typeof window !== "undefined") { @@ -107,20 +91,19 @@ const TokenManager = { } }, - // 토큰이 곧 만료되는지 확인 (30분 이내) + // 만료 30분 전부터 갱신 대상 isTokenExpiringSoon: (token: string): boolean => { try { const payload = JSON.parse(atob(token.split(".")[1])); const expiryTime = payload.exp * 1000; const currentTime = Date.now(); - const thirtyMinutes = 30 * 60 * 1000; // 30분 + const thirtyMinutes = 30 * 60 * 1000; return expiryTime - currentTime < thirtyMinutes && expiryTime > currentTime; } catch { return false; } }, - // 토큰 만료까지 남은 시간 (밀리초) getTimeUntilExpiry: (token: string): number => { try { const payload = JSON.parse(atob(token.split(".")[1])); @@ -131,77 +114,85 @@ const TokenManager = { }, }; -// 토큰 갱신 중복 방지 플래그 +// ============================================ +// 토큰 갱신 로직 (중복 요청 방지) +// ============================================ let isRefreshing = false; -let refreshPromise: Promise | null = null; +let refreshSubscribers: Array<(token: string) => void> = []; +let failedRefreshSubscribers: Array<(error: Error) => void> = []; -// 토큰 갱신 함수 -const refreshToken = async (): Promise => { - // 이미 갱신 중이면 기존 Promise 반환 - if (isRefreshing && refreshPromise) { - return refreshPromise; - } - - isRefreshing = true; - refreshPromise = (async () => { - try { - const currentToken = TokenManager.getToken(); - if (!currentToken) { - return null; - } - - const response = await axios.post( - `${API_BASE_URL}/auth/refresh`, - {}, - { - headers: { - Authorization: `Bearer ${currentToken}`, - }, - }, - ); - - if (response.data?.success && response.data?.data?.token) { - const newToken = response.data.data.token; - TokenManager.setToken(newToken); - console.log("[TokenManager] 토큰 갱신 성공"); - return newToken; - } - return null; - } catch (error) { - console.error("[TokenManager] 토큰 갱신 실패:", error); - return null; - } finally { - isRefreshing = false; - refreshPromise = null; - } - })(); - - return refreshPromise; +// 갱신 대기 중인 요청들에게 새 토큰 전달 +const onTokenRefreshed = (newToken: string) => { + refreshSubscribers.forEach((callback) => callback(newToken)); + refreshSubscribers = []; + failedRefreshSubscribers = []; }; -// 자동 토큰 갱신 타이머 -let tokenRefreshTimer: NodeJS.Timeout | null = null; +// 갱신 실패 시 대기 중인 요청들에게 에러 전달 +const onRefreshFailed = (error: Error) => { + failedRefreshSubscribers.forEach((callback) => callback(error)); + refreshSubscribers = []; + failedRefreshSubscribers = []; +}; + +// 갱신 완료 대기 Promise 등록 +const waitForTokenRefresh = (): Promise => { + return new Promise((resolve, reject) => { + refreshSubscribers.push(resolve); + failedRefreshSubscribers.push(reject); + }); +}; + +const refreshToken = async (): Promise => { + try { + const currentToken = TokenManager.getToken(); + if (!currentToken) { + return null; + } + + const response = await axios.post( + `${API_BASE_URL}/auth/refresh`, + {}, + { + headers: { + Authorization: `Bearer ${currentToken}`, + }, + }, + ); + + if (response.data?.success && response.data?.data?.token) { + const newToken = response.data.data.token; + TokenManager.setToken(newToken); + return newToken; + } + return null; + } catch { + return null; + } +}; + +// ============================================ +// 자동 토큰 갱신 (백그라운드) +// ============================================ +let tokenRefreshTimer: ReturnType | null = null; -// 자동 토큰 갱신 시작 const startAutoRefresh = (): void => { if (typeof window === "undefined") return; - // 기존 타이머 정리 if (tokenRefreshTimer) { clearInterval(tokenRefreshTimer); } - // 10분마다 토큰 상태 확인 + // 5분마다 토큰 상태 확인 (기존 10분 → 5분으로 단축) tokenRefreshTimer = setInterval( async () => { const token = TokenManager.getToken(); if (token && TokenManager.isTokenExpiringSoon(token)) { - console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작..."); await refreshToken(); } }, - 10 * 60 * 1000, - ); // 10분 + 5 * 60 * 1000, + ); // 페이지 로드 시 즉시 확인 const token = TokenManager.getToken(); @@ -210,29 +201,49 @@ const startAutoRefresh = (): void => { } }; -// 사용자 활동 감지 및 토큰 갱신 +// 페이지 포커스 복귀 시 토큰 갱신 체크 (백그라운드 탭 throttle 대응) +const setupVisibilityRefresh = (): void => { + if (typeof window === "undefined") return; + + document.addEventListener("visibilitychange", () => { + if (!document.hidden) { + const token = TokenManager.getToken(); + if (!token) return; + + if (TokenManager.isTokenExpired(token)) { + // 만료됐으면 갱신 시도 + refreshToken().then((newToken) => { + if (!newToken) { + redirectToLogin(); + } + }); + } else if (TokenManager.isTokenExpiringSoon(token)) { + refreshToken(); + } + } + }); +}; + +// 사용자 활동 감지 기반 갱신 const setupActivityBasedRefresh = (): void => { if (typeof window === "undefined") return; - let lastActivity = Date.now(); + let lastActivityCheck = Date.now(); const activityThreshold = 5 * 60 * 1000; // 5분 const handleActivity = (): void => { const now = Date.now(); - // 마지막 활동으로부터 5분 이상 지났으면 토큰 상태 확인 - if (now - lastActivity > activityThreshold) { + if (now - lastActivityCheck > activityThreshold) { const token = TokenManager.getToken(); if (token && TokenManager.isTokenExpiringSoon(token)) { refreshToken(); } + lastActivityCheck = now; } - lastActivity = now; }; - // 사용자 활동 이벤트 감지 - ["click", "keydown", "scroll", "mousemove"].forEach((event) => { - // 너무 잦은 호출 방지를 위해 throttle 적용 - let throttleTimer: NodeJS.Timeout | null = null; + ["click", "keydown"].forEach((event) => { + let throttleTimer: ReturnType | null = null; window.addEventListener( event, () => { @@ -240,7 +251,7 @@ const setupActivityBasedRefresh = (): void => { throttleTimer = setTimeout(() => { handleActivity(); throttleTimer = null; - }, 1000); // 1초 throttle + }, 2000); } }, { passive: true }, @@ -248,38 +259,56 @@ const setupActivityBasedRefresh = (): void => { }); }; +// 로그인 페이지 리다이렉트 (중복 방지) +let isRedirecting = false; +const redirectToLogin = (): void => { + if (typeof window === "undefined") return; + if (isRedirecting) return; + if (window.location.pathname === "/login") return; + + isRedirecting = true; + TokenManager.removeToken(); + window.location.href = "/login"; +}; + // 클라이언트 사이드에서 자동 갱신 시작 if (typeof window !== "undefined") { startAutoRefresh(); + setupVisibilityRefresh(); setupActivityBasedRefresh(); } +// ============================================ // Axios 인스턴스 생성 +// ============================================ export const apiClient = axios.create({ baseURL: API_BASE_URL, - timeout: 30000, // 30초로 증가 (다중 커넥션 처리 시간 고려) + timeout: 30000, headers: { "Content-Type": "application/json", }, - withCredentials: true, // 쿠키 포함 + withCredentials: true, }); +// ============================================ // 요청 인터셉터 +// ============================================ apiClient.interceptors.request.use( - (config) => { - // JWT 토큰 추가 + async (config: InternalAxiosRequestConfig) => { const token = TokenManager.getToken(); - if (token && !TokenManager.isTokenExpired(token)) { - config.headers.Authorization = `Bearer ${token}`; - } else if (token && TokenManager.isTokenExpired(token)) { - console.warn("❌ 토큰이 만료되었습니다."); - // 토큰 제거 - if (typeof window !== "undefined") { - localStorage.removeItem("authToken"); + if (token) { + if (!TokenManager.isTokenExpired(token)) { + // 유효한 토큰 → 그대로 사용 + config.headers.Authorization = `Bearer ${token}`; + } else { + // 만료된 토큰 → 갱신 시도 후 사용 + const newToken = await refreshToken(); + if (newToken) { + config.headers.Authorization = `Bearer ${newToken}`; + } + // 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리) } - } else { - console.warn("⚠️ 토큰이 없습니다."); } // FormData 요청 시 Content-Type 자동 처리 @@ -287,18 +316,14 @@ apiClient.interceptors.request.use( delete config.headers["Content-Type"]; } - // 언어 정보를 쿼리 파라미터에 추가 (GET 요청 시에만) + // 언어 정보를 쿼리 파라미터에 추가 (GET 요청) if (config.method?.toUpperCase() === "GET") { - // 우선순위: 전역 변수 > localStorage > 기본값 - let currentLang = "KR"; // 기본값 + let currentLang = "KR"; if (typeof window !== "undefined") { - // 1순위: 전역 변수에서 확인 if ((window as unknown as { __GLOBAL_USER_LANG?: string }).__GLOBAL_USER_LANG) { currentLang = (window as unknown as { __GLOBAL_USER_LANG: string }).__GLOBAL_USER_LANG; - } - // 2순위: localStorage에서 확인 (새 창이나 페이지 새로고침 시) - else { + } else { const storedLocale = localStorage.getItem("userLocale"); if (storedLocale) { currentLang = storedLocale; @@ -316,19 +341,19 @@ apiClient.interceptors.request.use( return config; }, (error) => { - console.error("❌ API 요청 오류:", error); return Promise.reject(error); }, ); +// ============================================ // 응답 인터셉터 +// ============================================ apiClient.interceptors.response.use( (response: AxiosResponse) => { // 백엔드에서 보내주는 새로운 토큰 처리 const newToken = response.headers["x-new-token"]; if (newToken) { TokenManager.setToken(newToken); - console.log("[TokenManager] 서버에서 새 토큰 수신, 저장 완료"); } return response; }, @@ -336,79 +361,80 @@ apiClient.interceptors.response.use( const status = error.response?.status; const url = error.config?.url; - // 409 에러 (중복 데이터)는 조용하게 처리 + // 409 에러 (중복 데이터) - 조용하게 처리 if (status === 409) { - // 중복 검사 API와 관계도 저장은 완전히 조용하게 처리 if (url?.includes("/check-duplicate") || url?.includes("/dataflow-diagrams")) { - // 중복 검사와 관계도 중복 이름은 정상적인 비즈니스 로직이므로 콘솔 출력 없음 return Promise.reject(error); } - - // 일반 409 에러는 간단한 로그만 출력 - console.warn("데이터 중복:", { - url: url, - message: (error.response?.data as { message?: string })?.message || "중복된 데이터입니다.", - }); return Promise.reject(error); } - // 채번 규칙 미리보기 API 실패는 조용하게 처리 (화면 로드 시 자주 발생) + // 채번 규칙 미리보기 API 실패는 조용하게 처리 if (url?.includes("/numbering-rules/") && url?.includes("/preview")) { return Promise.reject(error); } - // 다른 에러들은 기존처럼 상세 로그 출력 - console.error("API 응답 오류:", { - status: status, - statusText: error.response?.statusText, - url: url, - data: error.response?.data, - message: error.message, - }); - - // 401 에러 처리 + // 401 에러 처리 (핵심 개선) if (status === 401 && typeof window !== "undefined") { const errorData = error.response?.data as { error?: { code?: string } }; const errorCode = errorData?.error?.code; + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; - console.warn("[Auth] 401 오류 발생:", { - url: url, - errorCode: errorCode, - token: TokenManager.getToken() ? "존재" : "없음", - }); + // 이미 재시도한 요청이면 로그인으로 + if (originalRequest?._retry) { + redirectToLogin(); + return Promise.reject(error); + } - // 토큰 만료 에러인 경우 갱신 시도 - const originalRequest = error.config as typeof error.config & { _retry?: boolean }; - if (errorCode === "TOKEN_EXPIRED" && originalRequest && !originalRequest._retry) { - console.log("[Auth] 토큰 만료, 갱신 시도..."); - originalRequest._retry = true; + // 토큰 만료 에러 → 갱신 후 재시도 + if (errorCode === "TOKEN_EXPIRED" && originalRequest) { + if (!isRefreshing) { + isRefreshing = true; + originalRequest._retry = true; - try { - const newToken = await refreshToken(); - if (newToken && originalRequest) { + try { + const newToken = await refreshToken(); + if (newToken) { + isRefreshing = false; + onTokenRefreshed(newToken); + originalRequest.headers.Authorization = `Bearer ${newToken}`; + return apiClient.request(originalRequest); + } else { + isRefreshing = false; + onRefreshFailed(new Error("토큰 갱신 실패")); + redirectToLogin(); + return Promise.reject(error); + } + } catch (refreshError) { + isRefreshing = false; + onRefreshFailed(refreshError as Error); + redirectToLogin(); + return Promise.reject(error); + } + } else { + // 다른 요청이 이미 갱신 중 → 갱신 완료 대기 후 재시도 + try { + const newToken = await waitForTokenRefresh(); + originalRequest._retry = true; originalRequest.headers.Authorization = `Bearer ${newToken}`; return apiClient.request(originalRequest); + } catch { + return Promise.reject(error); } - } catch (refreshError) { - console.error("[Auth] 토큰 갱신 실패:", refreshError); } } - // 토큰 갱신 실패 또는 다른 401 에러인 경우 로그아웃 - TokenManager.removeToken(); - - // 로그인 페이지가 아닌 경우에만 리다이렉트 - if (window.location.pathname !== "/login") { - console.log("[Auth] 로그인 페이지로 리다이렉트"); - window.location.href = "/login"; - } + // TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로 + redirectToLogin(); } return Promise.reject(error); }, ); -// 공통 응답 타입 +// ============================================ +// 공통 타입 및 헬퍼 +// ============================================ export interface ApiResponse { success: boolean; data?: T; @@ -416,7 +442,6 @@ export interface ApiResponse { errorCode?: string; } -// 사용자 정보 타입 export interface UserInfo { userId: string; userName: string; @@ -430,13 +455,11 @@ export interface UserInfo { isAdmin?: boolean; } -// 현재 사용자 정보 조회 export const getCurrentUser = async (): Promise> => { try { const response = await apiClient.get("/auth/me"); return response.data; } catch (error: any) { - console.error("현재 사용자 정보 조회 실패:", error); return { success: false, message: error.response?.data?.message || error.message || "사용자 정보를 가져올 수 없습니다.", @@ -445,7 +468,6 @@ export const getCurrentUser = async (): Promise> => { } }; -// API 호출 헬퍼 함수 export const apiCall = async ( method: "GET" | "POST" | "PUT" | "DELETE", url: string, @@ -459,7 +481,6 @@ export const apiCall = async ( }); return response.data; } catch (error: unknown) { - console.error("API 호출 실패:", error); const axiosError = error as AxiosError; return { success: false, diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index e4d4bb6e..3cf92664 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -114,6 +114,7 @@ import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트 import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선 import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰 +import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx new file mode 100644 index 00000000..8a34a42e --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -0,0 +1,709 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { + GripVertical, + Plus, + X, + Search, + ChevronRight, + ChevronDown, + Package, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { entityJoinApi } from "@/lib/api/entityJoin"; +import { apiClient } from "@/lib/api/client"; + +// ─── 타입 정의 ─── + +interface BomItemNode { + tempId: string; + id?: string; + bom_id?: string; + parent_detail_id: string | null; + seq_no: number; + level: number; + child_item_id: string; + child_item_code: string; + child_item_name: string; + child_item_type: string; + quantity: string; + unit: string; + loss_rate: string; + remark: string; + children: BomItemNode[]; + _isNew?: boolean; + _isDeleted?: boolean; +} + +interface ItemInfo { + id: string; + item_number: string; + item_name: string; + type: string; + unit: string; + division: string; +} + +interface BomItemEditorProps { + component?: any; + formData?: Record; + companyCode?: string; + isDesignMode?: boolean; + selectedRowsData?: any[]; + onChange?: (flatData: any[]) => void; + bomId?: string; +} + +// 임시 ID 생성 +let tempIdCounter = 0; +const generateTempId = () => `temp_${Date.now()}_${++tempIdCounter}`; + +// ─── 품목 검색 모달 ─── + +interface ItemSearchModalProps { + open: boolean; + onClose: () => void; + onSelect: (item: ItemInfo) => void; + companyCode?: string; +} + +function ItemSearchModal({ + open, + onClose, + onSelect, + companyCode, +}: ItemSearchModalProps) { + const [searchText, setSearchText] = useState(""); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + + const searchItems = useCallback( + async (query: string) => { + setLoading(true); + try { + const result = await entityJoinApi.getTableDataWithJoins("item_info", { + page: 1, + size: 50, + search: query + ? { item_number: query, item_name: query } + : undefined, + enableEntityJoin: true, + companyCodeOverride: companyCode, + }); + setItems(result.data || []); + } catch (error) { + console.error("[BomItemEditor] 품목 검색 실패:", error); + } finally { + setLoading(false); + } + }, + [companyCode], + ); + + useEffect(() => { + if (open) { + setSearchText(""); + searchItems(""); + } + }, [open, searchItems]); + + const handleSearch = () => { + searchItems(searchText); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSearch(); + } + }; + + return ( + + + + 품목 검색 + + 하위 품목으로 추가할 품목을 선택하세요. + + + +
+ setSearchText(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="품목코드 또는 품목명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> + +
+ +
+ {loading ? ( +
+ 검색 중... +
+ ) : items.length === 0 ? ( +
+ + 검색 결과가 없습니다. + +
+ ) : ( + + + + + + + + + + + {items.map((item) => ( + { + onSelect(item); + onClose(); + }} + className="hover:bg-accent cursor-pointer border-t transition-colors" + > + + + + + + ))} + +
품목코드품목명구분단위
+ {item.item_number} + {item.item_name}{item.type}{item.unit}
+ )} +
+
+
+ ); +} + +// ─── 트리 노드 행 렌더링 ─── + +interface TreeNodeRowProps { + node: BomItemNode; + depth: number; + expanded: boolean; + hasChildren: boolean; + onToggle: () => void; + onFieldChange: (tempId: string, field: string, value: string) => void; + onDelete: (tempId: string) => void; + onAddChild: (parentTempId: string) => void; +} + +function TreeNodeRow({ + node, + depth, + expanded, + hasChildren, + onToggle, + onFieldChange, + onDelete, + onAddChild, +}: TreeNodeRowProps) { + const indentPx = depth * 32; + + return ( +
0 && "ml-2 border-l-2 border-l-primary/20", + )} + style={{ marginLeft: `${indentPx}px` }} + > + {/* 드래그 핸들 */} + + + {/* 펼침/접기 */} + + + {/* 순번 */} + + {node.seq_no} + + + {/* 품목코드 */} + + {node.child_item_code || "-"} + + + {/* 품목명 */} + + {node.child_item_name || "-"} + + + {/* 레벨 뱃지 */} + {node.level > 0 && ( + + L{node.level} + + )} + + {/* 수량 */} + + onFieldChange(node.tempId, "quantity", e.target.value) + } + className="h-7 w-16 shrink-0 text-center text-xs" + placeholder="수량" + /> + + {/* 품목구분 셀렉트 */} + + + {/* 하위 추가 버튼 */} + + + {/* 삭제 버튼 */} + +
+ ); +} + +// ─── 메인 컴포넌트 ─── + +export function BomItemEditorComponent({ + component, + formData, + companyCode, + isDesignMode = false, + selectedRowsData, + onChange, + bomId: propBomId, +}: BomItemEditorProps) { + const [treeData, setTreeData] = useState([]); + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [itemSearchOpen, setItemSearchOpen] = useState(false); + const [addTargetParentId, setAddTargetParentId] = useState( + null, + ); + + // BOM ID 결정 + const bomId = useMemo(() => { + if (propBomId) return propBomId; + if (formData?.id) return formData.id as string; + if (selectedRowsData?.[0]?.id) return selectedRowsData[0].id as string; + return null; + }, [propBomId, formData, selectedRowsData]); + + // ─── 데이터 로드 ─── + + const loadBomDetails = useCallback( + async (id: string) => { + if (!id) return; + setLoading(true); + try { + const result = await entityJoinApi.getTableDataWithJoins("bom_detail", { + page: 1, + size: 500, + search: { bom_id: id }, + sortBy: "seq_no", + sortOrder: "asc", + enableEntityJoin: true, + }); + + const rows = result.data || []; + const tree = buildTree(rows); + setTreeData(tree); + + // 1레벨 기본 펼침 + const firstLevelIds = new Set( + tree.map((n) => n.tempId || n.id || ""), + ); + setExpandedNodes(firstLevelIds); + } catch (error) { + console.error("[BomItemEditor] 데이터 로드 실패:", error); + } finally { + setLoading(false); + } + }, + [], + ); + + useEffect(() => { + if (bomId && !isDesignMode) { + loadBomDetails(bomId); + } + }, [bomId, isDesignMode, loadBomDetails]); + + // ─── 트리 빌드 ─── + + const buildTree = (flatData: any[]): BomItemNode[] => { + const nodeMap = new Map(); + const roots: BomItemNode[] = []; + + flatData.forEach((item) => { + const tempId = item.id || generateTempId(); + nodeMap.set(item.id || tempId, { + tempId, + id: item.id, + bom_id: item.bom_id, + parent_detail_id: item.parent_detail_id || null, + seq_no: Number(item.seq_no) || 0, + level: Number(item.level) || 0, + child_item_id: item.child_item_id || "", + child_item_code: item.child_item_code || "", + child_item_name: item.child_item_name || "", + child_item_type: item.child_item_type || "", + quantity: item.quantity || "1", + unit: item.unit || "EA", + loss_rate: item.loss_rate || "0", + remark: item.remark || "", + children: [], + }); + }); + + flatData.forEach((item) => { + const nodeId = item.id || ""; + const node = nodeMap.get(nodeId); + if (!node) return; + + if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) { + nodeMap.get(item.parent_detail_id)!.children.push(node); + } else { + roots.push(node); + } + }); + + // 순번 정렬 + const sortChildren = (nodes: BomItemNode[]) => { + nodes.sort((a, b) => a.seq_no - b.seq_no); + nodes.forEach((n) => sortChildren(n.children)); + }; + sortChildren(roots); + + return roots; + }; + + // ─── 트리 -> 평면 변환 (onChange 콜백용) ─── + + const flattenTree = useCallback((nodes: BomItemNode[]): any[] => { + const result: any[] = []; + const traverse = ( + items: BomItemNode[], + parentId: string | null, + level: number, + ) => { + items.forEach((node, idx) => { + result.push({ + id: node.id, + tempId: node.tempId, + bom_id: node.bom_id, + parent_detail_id: parentId, + seq_no: String(idx + 1), + level: String(level), + child_item_id: node.child_item_id, + child_item_code: node.child_item_code, + child_item_name: node.child_item_name, + child_item_type: node.child_item_type, + quantity: node.quantity, + unit: node.unit, + loss_rate: node.loss_rate, + remark: node.remark, + _isNew: node._isNew, + _targetTable: "bom_detail", + }); + if (node.children.length > 0) { + traverse(node.children, node.id || node.tempId, level + 1); + } + }); + }; + traverse(nodes, null, 0); + return result; + }, []); + + // 트리 변경 시 부모에게 알림 + const notifyChange = useCallback( + (newTree: BomItemNode[]) => { + setTreeData(newTree); + onChange?.(flattenTree(newTree)); + }, + [onChange, flattenTree], + ); + + // ─── 노드 조작 함수들 ─── + + // 트리에서 특정 노드 찾기 (재귀) + const findAndUpdate = ( + nodes: BomItemNode[], + targetTempId: string, + updater: (node: BomItemNode) => BomItemNode | null, + ): BomItemNode[] => { + const result: BomItemNode[] = []; + for (const node of nodes) { + if (node.tempId === targetTempId) { + const updated = updater(node); + if (updated) result.push(updated); + } else { + result.push({ + ...node, + children: findAndUpdate(node.children, targetTempId, updater), + }); + } + } + return result; + }; + + // 필드 변경 + const handleFieldChange = useCallback( + (tempId: string, field: string, value: string) => { + const newTree = findAndUpdate(treeData, tempId, (node) => ({ + ...node, + [field]: value, + })); + notifyChange(newTree); + }, + [treeData, notifyChange], + ); + + // 노드 삭제 + const handleDelete = useCallback( + (tempId: string) => { + const newTree = findAndUpdate(treeData, tempId, () => null); + notifyChange(newTree); + }, + [treeData, notifyChange], + ); + + // 하위 품목 추가 시작 (모달 열기) + const handleAddChild = useCallback((parentTempId: string) => { + setAddTargetParentId(parentTempId); + setItemSearchOpen(true); + }, []); + + // 루트 품목 추가 시작 + const handleAddRoot = useCallback(() => { + setAddTargetParentId(null); + setItemSearchOpen(true); + }, []); + + // 품목 선택 후 추가 + const handleItemSelect = useCallback( + (item: ItemInfo) => { + const newNode: BomItemNode = { + tempId: generateTempId(), + parent_detail_id: null, + seq_no: 0, + level: 0, + child_item_id: item.id, + child_item_code: item.item_number || "", + child_item_name: item.item_name || "", + child_item_type: item.type || "", + quantity: "1", + unit: item.unit || "EA", + loss_rate: "0", + remark: "", + children: [], + _isNew: true, + }; + + let newTree: BomItemNode[]; + + if (addTargetParentId === null) { + // 루트에 추가 + newNode.seq_no = treeData.length + 1; + newNode.level = 0; + newTree = [...treeData, newNode]; + } else { + // 특정 노드 하위에 추가 + newTree = findAndUpdate(treeData, addTargetParentId, (parent) => { + newNode.parent_detail_id = parent.id || parent.tempId; + newNode.seq_no = parent.children.length + 1; + newNode.level = parent.level + 1; + return { + ...parent, + children: [...parent.children, newNode], + }; + }); + // 부모 노드 펼침 + setExpandedNodes((prev) => new Set([...prev, addTargetParentId])); + } + + notifyChange(newTree); + }, + [addTargetParentId, treeData, notifyChange], + ); + + // 펼침/접기 토글 + const toggleExpand = useCallback((tempId: string) => { + setExpandedNodes((prev) => { + const next = new Set(prev); + if (next.has(tempId)) next.delete(tempId); + else next.add(tempId); + return next; + }); + }, []); + + // ─── 재귀 렌더링 ─── + + const renderNodes = (nodes: BomItemNode[], depth: number) => { + return nodes.map((node) => { + const isExpanded = expandedNodes.has(node.tempId); + return ( + + 0} + onToggle={() => toggleExpand(node.tempId)} + onFieldChange={handleFieldChange} + onDelete={handleDelete} + onAddChild={handleAddChild} + /> + {isExpanded && + node.children.length > 0 && + renderNodes(node.children, depth + 1)} + + ); + }); + }; + + // ─── 디자인 모드 ─── + + if (isDesignMode) { + return ( +
+ +

+ BOM 하위 품목 편집기 +

+

+ 트리 구조로 하위 품목을 관리합니다 +

+
+ ); + } + + // ─── 메인 렌더링 ─── + + return ( +
+ {/* 헤더 */} +
+

하위 품목 구성

+ +
+ + {/* 트리 목록 */} +
+ {loading ? ( +
+ 로딩 중... +
+ ) : treeData.length === 0 ? ( +
+ +

+ 하위 품목이 없습니다. +

+

+ "품목추가" 버튼을 눌러 추가하세요. +

+
+ ) : ( + renderNodes(treeData, 0) + )} +
+ + {/* 품목 검색 모달 */} + setItemSearchOpen(false)} + onSelect={handleItemSelect} + companyCode={companyCode} + /> +
+ ); +} + +export default BomItemEditorComponent; diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorRenderer.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorRenderer.tsx new file mode 100644 index 00000000..09cf855c --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorRenderer.tsx @@ -0,0 +1,22 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { BomItemEditorComponent } from "./BomItemEditorComponent"; +import { V2BomItemEditorDefinition } from "./index"; + +export class BomItemEditorRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2BomItemEditorDefinition; + + render(): React.ReactElement { + return ; + } +} + +BomItemEditorRenderer.registerSelf(); + +if (typeof window !== "undefined") { + setTimeout(() => { + BomItemEditorRenderer.registerSelf(); + }, 0); +} diff --git a/frontend/lib/registry/components/v2-bom-item-editor/index.ts b/frontend/lib/registry/components/v2-bom-item-editor/index.ts new file mode 100644 index 00000000..c2d45b7d --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-item-editor/index.ts @@ -0,0 +1,30 @@ +import { ComponentCategory } from "@/types/component"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { BomItemEditorComponent } from "./BomItemEditorComponent"; + +export const V2BomItemEditorDefinition = createComponentDefinition({ + id: "v2-bom-item-editor", + name: "BOM 하위품목 편집기", + nameEng: "BOM Item Editor", + description: "BOM 하위 품목을 트리 구조로 추가/편집/삭제하는 컴포넌트", + category: ComponentCategory.V2, + webType: "text", + component: BomItemEditorComponent, + defaultConfig: { + detailTable: "bom_detail", + sourceTable: "item_info", + foreignKey: "bom_id", + parentKey: "parent_detail_id", + itemCodeField: "item_number", + itemNameField: "item_name", + itemTypeField: "type", + itemUnitField: "unit", + }, + defaultSize: { width: 900, height: 400 }, + icon: "ListTree", + tags: ["BOM", "트리", "편집", "하위품목", "제조"], + version: "1.0.0", + author: "개발팀", +}); + +export default V2BomItemEditorDefinition; diff --git a/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx b/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx index f155d772..a0110195 100644 --- a/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx +++ b/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx @@ -123,8 +123,8 @@ export const SplitLineComponent: React.FC = ({ const startOffset = dragOffset; const scaleFactor = getScaleFactor(); const cw = detectCanvasWidth(); - const MIN_POS = 50; - const MAX_POS = cw - 50; + const MIN_POS = Math.max(50, cw * 0.15); + const MAX_POS = cw - Math.max(50, cw * 0.15); setIsDragging(true); setCanvasSplit({ isDragging: true });