From 4e422fc47762777468f878117346c521ce8de43f Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 24 Feb 2026 09:29:44 +0900 Subject: [PATCH 1/7] Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node --- backend-node/src/utils/componentDefaults.ts | 1 + .../01_master-data/bom.md | 2 +- frontend/components/common/ScreenModal.tsx | 1 + frontend/components/screen/EditModal.tsx | 1 + .../screen/InteractiveScreenViewerDynamic.tsx | 102 +++- .../screen/RealtimePreviewDynamic.tsx | 174 +++---- frontend/lib/registry/components/index.ts | 2 + .../split-panel-layout/SplitPanelContext.tsx | 2 + .../v2-bom-tree/BomTreeComponent.tsx | 448 ++++++++++++++++++ .../v2-bom-tree/BomTreeRenderer.tsx | 26 + .../registry/components/v2-bom-tree/index.ts | 27 ++ .../v2-split-line/SplitLineComponent.tsx | 275 +++++++++++ .../v2-split-line/SplitLineConfigPanel.tsx | 78 +++ .../v2-split-line/SplitLineRenderer.tsx | 30 ++ .../v2-split-line/canvasSplitStore.ts | 73 +++ .../components/v2-split-line/index.ts | 41 ++ .../components/v2-split-line/types.ts | 28 ++ .../SplitPanelContext.tsx | 2 + frontend/lib/schemas/componentConfig.ts | 11 + .../lib/utils/getComponentConfigPanel.tsx | 1 + 20 files changed, 1233 insertions(+), 92 deletions(-) create mode 100644 frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx create mode 100644 frontend/lib/registry/components/v2-bom-tree/BomTreeRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-bom-tree/index.ts create mode 100644 frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx create mode 100644 frontend/lib/registry/components/v2-split-line/SplitLineConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-split-line/SplitLineRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts create mode 100644 frontend/lib/registry/components/v2-split-line/index.ts create mode 100644 frontend/lib/registry/components/v2-split-line/types.ts diff --git a/backend-node/src/utils/componentDefaults.ts b/backend-node/src/utils/componentDefaults.ts index 9a3e7d35..51afbeaf 100644 --- a/backend-node/src/utils/componentDefaults.ts +++ b/backend-node/src/utils/componentDefaults.ts @@ -164,6 +164,7 @@ export const componentDefaults: Record = { "v2-date": { type: "v2-date", webType: "date" }, "v2-repeater": { type: "v2-repeater", webType: "custom" }, "v2-repeat-container": { type: "v2-repeat-container", webType: "custom" }, + "v2-split-line": { type: "v2-split-line", webType: "custom", resizable: true, lineWidth: 4 }, }; /** diff --git a/docs/screen-implementation-guide/01_master-data/bom.md b/docs/screen-implementation-guide/01_master-data/bom.md index 6e626289..14b7c9eb 100644 --- a/docs/screen-implementation-guide/01_master-data/bom.md +++ b/docs/screen-implementation-guide/01_master-data/bom.md @@ -225,7 +225,7 @@ childItemsSection: { "config": { "masterPanel": { "title": "BOM 목록", - "entityId": "bom_header", + "entityId": "bom", "columns": [ { "id": "item_code", "label": "품목코드", "width": 100 }, { "id": "item_name", "label": "품목명", "width": 150 }, diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index b6660709..4c4614c9 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1019,6 +1019,7 @@ export const ScreenModal: React.FC = ({ className }) => {
= ({ className }) => {
) : screenData ? (
{ - const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 + const { isPreviewMode } = useScreenPreview(); const { userName: authUserName, user: authUser } = useAuth(); - const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트 + const splitPanelContext = useSplitPanelContext(); + + // 캔버스 분할선 글로벌 스토어 구독 + const canvasSplit = useSyncExternalStore(canvasSplitSubscribe, canvasSplitGetSnapshot, canvasSplitGetServerSnapshot); + const canvasSplitSideRef = React.useRef<"left" | "right" | null>(null); + const myScopeIdRef = React.useRef(null); // 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서) const userName = externalUserName || authUserName; @@ -1079,22 +1089,96 @@ export const InteractiveScreenViewerDynamic: React.FC { + const compType = (component as any).componentType || ""; + const isSplitLine = type === "component" && compType === "v2-split-line"; + const origX = position?.x || 0; + + 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 (!canvasSplit.active || canvasSplit.canvasWidth <= 0 || !canvasSplit.scopeId) { + return origX; + } + + 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; + } + + const { initialDividerX, currentDividerX, canvasWidth } = canvasSplit; + const delta = currentDividerX - initialDividerX; + if (Math.abs(delta) < 1) return origX; + + const origW = size?.width || 200; + if (canvasSplitSideRef.current === null) { + const componentCenterX = origX + (origW / 2); + canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right"; + } + + let newX = origX; + + if (canvasSplitSideRef.current === "left") { + if (initialDividerX > 0) { + newX = origX * (currentDividerX / initialDividerX); + } + } else { + const initialRightWidth = canvasWidth - initialDividerX; + const currentRightWidth = canvasWidth - currentDividerX; + if (initialRightWidth > 0) { + const posRatio = (origX - initialDividerX) / initialRightWidth; + newX = currentDividerX + posRatio * currentRightWidth; + } + } + + // 캔버스 범위 내로 클램핑 + return Math.max(0, Math.min(newX, canvasWidth - 10)); + }; + + const adjustedX = calculateCanvasSplitX(); + const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId; + const componentStyle = { position: "absolute" as const, - left: position?.x || 0, - top: position?.y || 0, // 원래 위치 유지 (음수로 가면 overflow-hidden에 잘림) + left: adjustedX, + top: position?.y || 0, zIndex: position?.z || 1, - ...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용 - width: size?.width || 200, // size의 픽셀 값이 최종 우선순위 + ...styleWithoutSize, + width: size?.width || 200, height: isTableSearchWidget ? "auto" : size?.height || 10, minHeight: isTableSearchWidget ? "48px" : undefined, - // 🆕 라벨이 있으면 overflow visible로 설정하여 라벨이 잘리지 않게 함 overflow: labelOffset > 0 ? "visible" : undefined, + // GPU 가속: 드래그 중 will-change 활성화, 끝나면 해제 + willChange: canvasSplit.isDragging && isSplitActive ? "left" as const : undefined, + transition: isSplitActive + ? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out") + : undefined, }; return ( <> -
+
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */} {renderInteractiveWidget(component)}
diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 45b97b30..b053f25a 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useMemo } from "react"; +import React, { useMemo, useSyncExternalStore } from "react"; import { ComponentData, WebType, WidgetComponent } from "@/types/screen"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { @@ -17,6 +17,11 @@ import { File, } from "lucide-react"; import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; +import { + subscribe as canvasSplitSubscribe, + getSnapshot as canvasSplitGetSnapshot, + getServerSnapshot as canvasSplitGetServerSnapshot, +} from "@/lib/registry/components/v2-split-line/canvasSplitStore"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; // 컴포넌트 렌더러들 자동 등록 @@ -388,10 +393,12 @@ const RealtimePreviewDynamicComponent: React.FC = ({ } : component; - // 🆕 분할 패널 리사이즈 Context + // 기존 분할 패널 리사이즈 Context (레거시 split-panel-layout용) const splitPanelContext = useSplitPanel(); - // 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상) + // 캔버스 분할선 글로벌 스토어 (useSyncExternalStore로 직접 구독) + const canvasSplit = useSyncExternalStore(canvasSplitSubscribe, canvasSplitGetSnapshot, canvasSplitGetServerSnapshot); + const componentType = (component as any).componentType || ""; const componentId = (component as any).componentId || ""; const widgetType = (component as any).widgetType || ""; @@ -402,110 +409,113 @@ const RealtimePreviewDynamicComponent: React.FC = ({ (["button-primary", "button-secondary"].includes(componentType) || ["button-primary", "button-secondary"].includes(componentId))); - // 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점) + // 레거시 분할 패널용 refs const initialPanelRatioRef = React.useRef(null); const initialPanelIdRef = React.useRef(null); - // 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지) const isInLeftPanelRef = React.useRef(null); - // 🆕 분할 패널 위 버튼 위치 자동 조정 - const calculateButtonPosition = () => { - // 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음 + // 캔버스 분할선 좌/우 판정 (한 번만) + const canvasSplitSideRef = React.useRef<"left" | "right" | null>(null); + // 스코프 체크 캐시 (DOM 쿼리 최소화) + const myScopeIdRef = React.useRef(null); + + const calculateSplitAdjustedPosition = () => { + const isSplitLineComponent = + type === "component" && componentType === "v2-split-line"; + + if (isSplitLineComponent) { + return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; + } + + // === 1. 캔버스 분할선 (글로벌 스토어) - 위치만 조정, 너비 미변경 === + if (canvasSplit.active && canvasSplit.canvasWidth > 0 && canvasSplit.scopeId) { + if (myScopeIdRef.current === null) { + const el = document.getElementById(`component-${id}`); + const container = el?.closest("[data-screen-runtime]"); + myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__"; + } + if (myScopeIdRef.current !== canvasSplit.scopeId) { + return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; + } + const { initialDividerX, currentDividerX, canvasWidth, isDragging: splitDragging } = canvasSplit; + const delta = currentDividerX - initialDividerX; + + if (canvasSplitSideRef.current === null) { + const origW = size?.width || 100; + const componentCenterX = position.x + (origW / 2); + canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right"; + } + + if (Math.abs(delta) < 1) { + return { adjustedPositionX: position.x, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging }; + } + + let adjustedX = position.x; + + if (canvasSplitSideRef.current === "left") { + if (initialDividerX > 0) { + adjustedX = position.x * (currentDividerX / initialDividerX); + } + } else { + const initialRightWidth = canvasWidth - initialDividerX; + const currentRightWidth = canvasWidth - currentDividerX; + if (initialRightWidth > 0) { + const posRatio = (position.x - initialDividerX) / initialRightWidth; + adjustedX = currentDividerX + posRatio * currentRightWidth; + } + } + + adjustedX = Math.max(0, Math.min(adjustedX, canvasWidth - 10)); + + return { adjustedPositionX: adjustedX, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging }; + } + + // === 2. 레거시 분할 패널 (Context) - 버튼 전용 === const isSplitPanelComponent = type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType); - if (!isButtonComponent || isSplitPanelComponent) { + if (isSplitPanelComponent) { + return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; + } + + if (!isButtonComponent) { return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; } const componentWidth = size?.width || 100; const componentHeight = size?.height || 40; - // 분할 패널 위에 있는지 확인 (원래 위치 기준) const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight); - // 분할 패널 위에 없으면 기준점 초기화 if (!overlap) { if (initialPanelIdRef.current !== null) { initialPanelRatioRef.current = null; initialPanelIdRef.current = null; isInLeftPanelRef.current = null; } - return { - adjustedPositionX: position.x, - isOnSplitPanel: false, - isDraggingSplitPanel: false, - }; + return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false }; } const { panel } = overlap; - // 🆕 초기 기준 비율 및 좌측 패널 소속 여부 설정 (처음 한 번만) if (initialPanelIdRef.current !== overlap.panelId) { - initialPanelRatioRef.current = panel.leftWidthPercent; + initialPanelRatioRef.current = panel.initialLeftWidthPercent; initialPanelIdRef.current = overlap.panelId; - - // 초기 배치 시 좌측 패널에 있는지 확인 (초기 비율 기준으로 계산) - // 현재 비율이 아닌, 버튼 원래 위치가 초기 좌측 패널 영역 안에 있었는지 판단 - const initialLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100; + const initialDividerX = panel.x + (panel.width * panel.initialLeftWidthPercent) / 100; const componentCenterX = position.x + componentWidth / 2; - const relativeX = componentCenterX - panel.x; - const wasInLeftPanel = relativeX < initialLeftPanelWidth; - - isInLeftPanelRef.current = wasInLeftPanel; - console.log("📌 [버튼 기준점 설정]:", { - componentId: component.id, - panelId: overlap.panelId, - initialRatio: panel.leftWidthPercent, - isInLeftPanel: wasInLeftPanel, - buttonCenterX: componentCenterX, - leftPanelWidth: initialLeftPanelWidth, - }); + isInLeftPanelRef.current = componentCenterX < initialDividerX; } - // 좌측 패널 소속이 아니면 조정하지 않음 (초기 배치 기준) - if (!isInLeftPanelRef.current) { - return { - adjustedPositionX: position.x, - isOnSplitPanel: true, - isDraggingSplitPanel: panel.isDragging, - }; - } + const baseRatio = initialPanelRatioRef.current ?? panel.initialLeftWidthPercent; + const initialDividerX = panel.x + (panel.width * baseRatio) / 100; + const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100; + const dividerDelta = currentDividerX - initialDividerX; - // 초기 기준 비율 (버튼이 처음 배치될 때의 비율) - const baseRatio = initialPanelRatioRef.current ?? panel.leftWidthPercent; - - // 기준 비율 대비 현재 비율로 분할선 위치 계산 - const baseDividerX = panel.x + (panel.width * baseRatio) / 100; // 초기 분할선 위치 - const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100; // 현재 분할선 위치 - - // 분할선 이동량 (px) - const dividerDelta = currentDividerX - baseDividerX; - - // 변화가 없으면 원래 위치 반환 if (Math.abs(dividerDelta) < 1) { - return { - adjustedPositionX: position.x, - isOnSplitPanel: true, - isDraggingSplitPanel: panel.isDragging, - }; + return { adjustedPositionX: position.x, isOnSplitPanel: true, isDraggingSplitPanel: panel.isDragging }; } - // 🆕 버튼도 분할선과 같은 양만큼 이동 - // 분할선이 왼쪽으로 100px 이동하면, 버튼도 왼쪽으로 100px 이동 - const adjustedX = position.x + dividerDelta; - - console.log("📍 [버튼 위치 조정]:", { - componentId: component.id, - originalX: position.x, - adjustedX, - dividerDelta, - baseRatio, - currentRatio: panel.leftWidthPercent, - baseDividerX, - currentDividerX, - isDragging: panel.isDragging, - }); + const adjustedX = isInLeftPanelRef.current ? position.x + dividerDelta : position.x; return { adjustedPositionX: adjustedX, @@ -514,25 +524,25 @@ const RealtimePreviewDynamicComponent: React.FC = ({ }; }; - const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateButtonPosition(); + const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateSplitAdjustedPosition(); - // 🆕 리사이즈 크기가 있으면 우선 사용 - // (size가 업데이트되면 위 useEffect에서 resizeSize를 null로 설정) 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 baseStyle = { - left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용 + left: `${adjustedPositionX}px`, top: `${position.y}px`, - ...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨) - width: displayWidth, // 🆕 리사이즈 중이면 resizeSize 사용 - height: displayHeight, // 🆕 리사이즈 중이면 resizeSize 사용 + ...componentStyle, + width: displayWidth, + height: displayHeight, zIndex: component.type === "layout" ? 1 : position.z || 2, right: undefined, - // 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동, 리사이즈 중에도 트랜지션 없음 + willChange: canvasSplit.isDragging && isSplitActive ? "left" as const : undefined, transition: isResizing ? "none" : - isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined, + isOnSplitPanel ? (isDraggingSplitPanel ? "none" : "left 0.15s ease-out") : undefined, }; // 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제) diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 172f0067..e4d4bb6e 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -112,6 +112,8 @@ import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트 import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트 import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트 +import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선 +import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx index fe3a9327..64fee843 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx @@ -26,6 +26,8 @@ export interface SplitPanelInfo { initialLeftWidthPercent: number; // 드래그 중 여부 isDragging: boolean; + // 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정) + panelType?: "component" | "canvas"; } export interface SplitPanelResizeContextValue { diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx new file mode 100644 index 00000000..536c1ddc --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -0,0 +1,448 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { ChevronRight, ChevronDown, Package, Layers, Box, AlertCircle } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { entityJoinApi } from "@/lib/api/entityJoin"; + +/** + * BOM 트리 노드 데이터 + */ +interface BomTreeNode { + id: string; + bom_id: string; + parent_detail_id: string | null; + seq_no: string; + level: string; + 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: BomTreeNode[]; +} + +/** + * BOM 헤더 정보 + */ +interface BomHeaderInfo { + id: string; + bom_number: string; + item_code: string; + item_name: string; + item_type: string; + base_qty: string; + unit: string; + version: string; + revision: string; + status: string; + effective_date: string; + expired_date: string; + remark: string; +} + +interface BomTreeComponentProps { + component?: any; + formData?: Record; + tableName?: string; + companyCode?: string; + isDesignMode?: boolean; + selectedRowsData?: any[]; + [key: string]: any; +} + +/** + * BOM 트리 컴포넌트 + * 좌측 패널에서 BOM 헤더 선택 시 계층 구조로 BOM 디테일을 표시 + */ +export function BomTreeComponent({ + component, + formData, + companyCode, + isDesignMode = false, + selectedRowsData, + ...props +}: BomTreeComponentProps) { + const [headerInfo, setHeaderInfo] = useState(null); + const [treeData, setTreeData] = useState([]); + const [expandedNodes, setExpandedNodes] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [selectedNodeId, setSelectedNodeId] = useState(null); + + const config = component?.componentConfig || {}; + + // 선택된 BOM 헤더에서 bom_id 추출 + const selectedBomId = useMemo(() => { + // SplitPanel에서 좌측 선택 시 formData나 selectedRowsData로 전달됨 + if (selectedRowsData && selectedRowsData.length > 0) { + return selectedRowsData[0]?.id; + } + if (formData?.id) return formData.id; + return null; + }, [formData, selectedRowsData]); + + // 선택된 BOM 헤더 정보 추출 + const selectedHeaderData = useMemo(() => { + if (selectedRowsData && selectedRowsData.length > 0) { + return selectedRowsData[0] as BomHeaderInfo; + } + if (formData?.id) return formData as unknown as BomHeaderInfo; + return null; + }, [formData, selectedRowsData]); + + // BOM 디테일 데이터 로드 + const loadBomDetails = useCallback(async (bomId: string) => { + if (!bomId) return; + setLoading(true); + try { + const result = await entityJoinApi.getTableDataWithJoins("bom_detail", { + page: 1, + size: 500, + search: { bom_id: bomId }, + sortBy: "seq_no", + sortOrder: "asc", + enableEntityJoin: true, + }); + + const rows = result.data || []; + const tree = buildTree(rows); + setTreeData(tree); + const firstLevelIds = new Set(tree.map((n: BomTreeNode) => n.id)); + setExpandedNodes(firstLevelIds); + } catch (error) { + console.error("[BomTree] 데이터 로드 실패:", error); + } finally { + setLoading(false); + } + }, []); + + // 평면 데이터 -> 트리 구조 변환 + const buildTree = (flatData: any[]): BomTreeNode[] => { + const nodeMap = new Map(); + const roots: BomTreeNode[] = []; + + // 모든 노드를 맵에 등록 + flatData.forEach((item) => { + nodeMap.set(item.id, { ...item, children: [] }); + }); + + // 부모-자식 관계 설정 + flatData.forEach((item) => { + const node = nodeMap.get(item.id)!; + if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) { + nodeMap.get(item.parent_detail_id)!.children.push(node); + } else { + roots.push(node); + } + }); + + return roots; + }; + + // 선택된 BOM 변경 시 데이터 로드 + useEffect(() => { + if (selectedBomId) { + setHeaderInfo(selectedHeaderData); + loadBomDetails(selectedBomId); + } else { + setHeaderInfo(null); + setTreeData([]); + } + }, [selectedBomId, selectedHeaderData, loadBomDetails]); + + // 노드 펼치기/접기 토글 + const toggleNode = useCallback((nodeId: string) => { + setExpandedNodes((prev) => { + const next = new Set(prev); + if (next.has(nodeId)) { + next.delete(nodeId); + } else { + next.add(nodeId); + } + return next; + }); + }, []); + + // 전체 펼치기 + const expandAll = useCallback(() => { + const allIds = new Set(); + const collectIds = (nodes: BomTreeNode[]) => { + nodes.forEach((n) => { + allIds.add(n.id); + if (n.children.length > 0) collectIds(n.children); + }); + }; + collectIds(treeData); + setExpandedNodes(allIds); + }, [treeData]); + + // 전체 접기 + const collapseAll = useCallback(() => { + setExpandedNodes(new Set()); + }, []); + + // 품목 구분 라벨 + const getItemTypeLabel = (type: string) => { + switch (type) { + case "product": return "제품"; + case "semi": return "반제품"; + case "material": return "원자재"; + case "part": return "부품"; + default: return type || "-"; + } + }; + + // 품목 구분 아이콘 & 색상 + const getItemTypeStyle = (type: string) => { + switch (type) { + case "product": + return { icon: Package, color: "text-blue-600", bg: "bg-blue-50" }; + case "semi": + return { icon: Layers, color: "text-amber-600", bg: "bg-amber-50" }; + case "material": + return { icon: Box, color: "text-emerald-600", bg: "bg-emerald-50" }; + default: + return { icon: Box, color: "text-gray-500", bg: "bg-gray-50" }; + } + }; + + // 디자인 모드 미리보기 + if (isDesignMode) { + return ( +
+
+ + BOM 트리 뷰 +
+
+
+ + + 완제품 A (제품) + 수량: 1 +
+
+ + + 반제품 B (반제품) + 수량: 2 +
+
+ + + 원자재 C (원자재) + 수량: 5 +
+
+
+ ); + } + + // 선택 안 된 상태 + if (!selectedBomId) { + return ( +
+
+

좌측에서 BOM을 선택하세요

+

선택한 BOM의 구성 정보가 트리로 표시됩니다

+
+
+ ); + } + + return ( +
+ {/* 헤더 정보 */} + {headerInfo && ( +
+
+ +

{headerInfo.item_name || "-"}

+ + {headerInfo.bom_number || "-"} + + + {headerInfo.status === "active" ? "사용" : "미사용"} + +
+
+ 품목코드: {headerInfo.item_code || "-"} + 구분: {getItemTypeLabel(headerInfo.item_type)} + 기준수량: {headerInfo.base_qty || "1"} {headerInfo.unit || ""} + 버전: v{headerInfo.version || "1.0"} (차수 {headerInfo.revision || "1"}) +
+
+ )} + + {/* 트리 툴바 */} +
+ BOM 구성 + + {treeData.length}건 + +
+ + +
+
+ + {/* 트리 컨텐츠 */} +
+ {loading ? ( +
+
로딩 중...
+
+ ) : treeData.length === 0 ? ( +
+ +

등록된 하위 품목이 없습니다

+
+ ) : ( +
+ {treeData.map((node) => ( + + ))} +
+ )} +
+
+ ); +} + +/** + * 트리 노드 행 (재귀 렌더링) + */ +interface TreeNodeRowProps { + node: BomTreeNode; + depth: number; + expandedNodes: Set; + selectedNodeId: string | null; + onToggle: (id: string) => void; + onSelect: (id: string) => void; + getItemTypeLabel: (type: string) => string; + getItemTypeStyle: (type: string) => { icon: any; color: string; bg: string }; +} + +function TreeNodeRow({ + node, + depth, + expandedNodes, + selectedNodeId, + onToggle, + onSelect, + getItemTypeLabel, + getItemTypeStyle, +}: TreeNodeRowProps) { + const isExpanded = expandedNodes.has(node.id); + const hasChildren = node.children.length > 0; + const isSelected = selectedNodeId === node.id; + const style = getItemTypeStyle(node.child_item_type); + const ItemIcon = style.icon; + + return ( + <> +
{ + onSelect(node.id); + if (hasChildren) onToggle(node.id); + }} + > + {/* 펼치기/접기 화살표 */} + + {hasChildren ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )} + + + {/* 품목 타입 아이콘 */} + + + + + {/* 품목 정보 */} +
+ + {node.child_item_name || "-"} + + + {node.child_item_code || ""} + + + {getItemTypeLabel(node.child_item_type)} + +
+ + {/* 수량/단위 */} +
+ + 수량: {node.quantity || "0"} {node.unit || ""} + + {node.loss_rate && node.loss_rate !== "0" && ( + + 로스: {node.loss_rate}% + + )} +
+
+ + {/* 하위 노드 재귀 렌더링 */} + {hasChildren && isExpanded && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} + + ); +} diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeRenderer.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeRenderer.tsx new file mode 100644 index 00000000..a8219810 --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeRenderer.tsx @@ -0,0 +1,26 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2BomTreeDefinition } from "./index"; +import { BomTreeComponent } from "./BomTreeComponent"; + +export class BomTreeRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2BomTreeDefinition; + + render(): React.ReactElement { + return ; + } +} + +BomTreeRenderer.registerSelf(); + +if (typeof window !== "undefined") { + setTimeout(() => { + try { + BomTreeRenderer.registerSelf(); + } catch (error) { + console.error("BomTree 등록 실패:", error); + } + }, 1000); +} diff --git a/frontend/lib/registry/components/v2-bom-tree/index.ts b/frontend/lib/registry/components/v2-bom-tree/index.ts new file mode 100644 index 00000000..b11f9651 --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-tree/index.ts @@ -0,0 +1,27 @@ +"use client"; + +import { ComponentCategory } from "@/types/component"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { BomTreeComponent } from "./BomTreeComponent"; + +export const V2BomTreeDefinition = createComponentDefinition({ + id: "v2-bom-tree", + name: "BOM 트리 뷰", + nameEng: "BOM Tree View", + description: "BOM 구성을 계층 트리 형태로 표시하는 컴포넌트", + category: ComponentCategory.V2, + webType: "text", + component: BomTreeComponent, + defaultConfig: { + detailTable: "bom_detail", + foreignKey: "bom_id", + parentKey: "parent_detail_id", + }, + defaultSize: { width: 900, height: 600 }, + icon: "GitBranch", + tags: ["BOM", "트리", "계층", "제조", "생산"], + version: "1.0.0", + author: "개발팀", +}); + +export default V2BomTreeDefinition; diff --git a/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx b/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx new file mode 100644 index 00000000..f155d772 --- /dev/null +++ b/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx @@ -0,0 +1,275 @@ +"use client"; + +import React, { useState, useCallback, useEffect, useRef } from "react"; +import { ComponentRendererProps } from "@/types/component"; +import { SplitLineConfig } from "./types"; +import { setCanvasSplit, resetCanvasSplit } from "./canvasSplitStore"; + +export interface SplitLineComponentProps extends ComponentRendererProps { + config?: SplitLineConfig; +} + +export const SplitLineComponent: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + onClick, + config, + className, + style, + ...props +}) => { + const componentConfig = { + ...config, + ...component.componentConfig, + } as SplitLineConfig; + + const resizable = componentConfig.resizable ?? true; + const lineColor = componentConfig.lineColor || "#e2e8f0"; + const lineWidth = componentConfig.lineWidth || 4; + + const [dragOffset, setDragOffset] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + + // CSS transform: scale()이 적용된 캔버스에서 정확한 디자인 해상도 + const detectCanvasWidth = useCallback((): number => { + if (containerRef.current) { + const canvas = + containerRef.current.closest("[data-screen-runtime]") || + containerRef.current.closest("[data-screen-canvas]"); + if (canvas) { + const w = parseInt((canvas as HTMLElement).style.width); + if (w > 0) return w; + } + } + const canvas = document.querySelector("[data-screen-runtime]"); + if (canvas) { + const w = parseInt((canvas as HTMLElement).style.width); + if (w > 0) return w; + } + return 1200; + }, []); + + // CSS scale 보정 계수 + const getScaleFactor = useCallback((): number => { + if (containerRef.current) { + const canvas = containerRef.current.closest("[data-screen-runtime]"); + if (canvas) { + const el = canvas as HTMLElement; + const designWidth = parseInt(el.style.width) || 1200; + const renderedWidth = el.getBoundingClientRect().width; + if (renderedWidth > 0 && designWidth > 0) { + return designWidth / renderedWidth; + } + } + } + return 1; + }, []); + + // 스코프 ID (같은 data-screen-runtime 안의 컴포넌트만 영향) + const scopeIdRef = useRef(""); + + // 글로벌 스토어에 등록 (런타임 모드) + useEffect(() => { + if (isDesignMode) return; + + const timer = setTimeout(() => { + const cw = detectCanvasWidth(); + const posX = component.position?.x || 0; + + // 스코프 ID: 가장 가까운 data-screen-runtime 요소에 고유 ID 부여 + let scopeId = ""; + if (containerRef.current) { + const runtimeEl = containerRef.current.closest("[data-screen-runtime]"); + if (runtimeEl) { + scopeId = runtimeEl.getAttribute("data-split-scope") || ""; + if (!scopeId) { + scopeId = `split-scope-${component.id}`; + runtimeEl.setAttribute("data-split-scope", scopeId); + } + } + } + scopeIdRef.current = scopeId; + + console.log("[SplitLine] 등록:", { canvasWidth: cw, positionX: posX, scopeId }); + + setCanvasSplit({ + initialDividerX: posX, + currentDividerX: posX, + canvasWidth: cw, + isDragging: false, + active: true, + scopeId, + }); + }, 100); + + return () => { + clearTimeout(timer); + resetCanvasSplit(); + }; + }, [isDesignMode, component.id, component.position?.x, detectCanvasWidth]); + + // 드래그 핸들러 (requestAnimationFrame으로 스로틀링 → 렉 방지) + const rafIdRef = useRef(0); + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (!resizable || isDesignMode) return; + e.preventDefault(); + e.stopPropagation(); + + const posX = component.position?.x || 0; + const startX = e.clientX; + const startOffset = dragOffset; + const scaleFactor = getScaleFactor(); + const cw = detectCanvasWidth(); + const MIN_POS = 50; + const MAX_POS = cw - 50; + + setIsDragging(true); + setCanvasSplit({ isDragging: true }); + + const handleMouseMove = (moveEvent: MouseEvent) => { + // rAF로 스로틀링: 프레임당 1회만 업데이트 + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = requestAnimationFrame(() => { + const rawDelta = moveEvent.clientX - startX; + const delta = rawDelta * scaleFactor; + let newOffset = startOffset + delta; + + const newDividerX = posX + newOffset; + if (newDividerX < MIN_POS) newOffset = MIN_POS - posX; + if (newDividerX > MAX_POS) newOffset = MAX_POS - posX; + + setDragOffset(newOffset); + setCanvasSplit({ currentDividerX: posX + newOffset }); + }); + }; + + const handleMouseUp = () => { + cancelAnimationFrame(rafIdRef.current); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + + setIsDragging(false); + setCanvasSplit({ isDragging: false }); + }; + + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }, + [resizable, isDesignMode, dragOffset, component.position?.x, getScaleFactor, detectCanvasWidth], + ); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(); + }; + + // props 필터링 + const { + selectedScreen: _1, onZoneComponentDrop: _2, onZoneClick: _3, + componentConfig: _4, component: _5, isSelected: _6, + onClick: _7, onDragStart: _8, onDragEnd: _9, + size: _10, position: _11, style: _12, + screenId: _13, tableName: _14, onRefresh: _15, onClose: _16, + webType: _17, autoGeneration: _18, isInteractive: _19, + formData: _20, onFormDataChange: _21, + menuId: _22, menuObjid: _23, onSave: _24, + userId: _25, userName: _26, companyCode: _27, + isInModal: _28, readonly: _29, originalData: _30, + _originalData: _31, _initialData: _32, _groupedData: _33, + allComponents: _34, onUpdateLayout: _35, + selectedRows: _36, selectedRowsData: _37, onSelectedRowsChange: _38, + sortBy: _39, sortOrder: _40, tableDisplayData: _41, + flowSelectedData: _42, flowSelectedStepId: _43, onFlowSelectedDataChange: _44, + onConfigChange: _45, refreshKey: _46, flowRefreshKey: _47, onFlowRefresh: _48, + isPreview: _49, groupedData: _50, + ...domProps + } = props as any; + + if (isDesignMode) { + return ( +
+
+
+ 스플릿선 +
+
+ ); + } + + return ( +
+
+
+ ); +}; + +export const SplitLineWrapper: React.FC = (props) => { + return ; +}; diff --git a/frontend/lib/registry/components/v2-split-line/SplitLineConfigPanel.tsx b/frontend/lib/registry/components/v2-split-line/SplitLineConfigPanel.tsx new file mode 100644 index 00000000..49f741b8 --- /dev/null +++ b/frontend/lib/registry/components/v2-split-line/SplitLineConfigPanel.tsx @@ -0,0 +1,78 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; + +interface SplitLineConfigPanelProps { + config: any; + onConfigChange: (config: any) => void; +} + +/** + * SplitLine 설정 패널 + */ +export const SplitLineConfigPanel: React.FC = ({ config, onConfigChange }) => { + const currentConfig = config || {}; + + const handleChange = (key: string, value: any) => { + onConfigChange({ + ...currentConfig, + [key]: value, + }); + }; + + return ( +
+

스플릿선 설정

+ + {/* 드래그 리사이즈 허용 */} +
+ + handleChange("resizable", checked)} + /> +
+ + {/* 분할선 스타일 */} +
+ + handleChange("lineWidth", parseInt(e.target.value) || 4)} + className="h-8 text-xs" + min={1} + max={12} + /> +
+ +
+ +
+ handleChange("lineColor", e.target.value)} + className="h-8 w-8 cursor-pointer rounded border" + /> + handleChange("lineColor", e.target.value)} + className="h-8 flex-1 text-xs" + placeholder="#e2e8f0" + /> +
+
+ +

+ 캔버스에서 스플릿선의 X 위치가 초기 분할 지점이 됩니다. + 런타임에서 드래그하면 좌우 컴포넌트가 함께 이동합니다. +

+
+ ); +}; + +export default SplitLineConfigPanel; diff --git a/frontend/lib/registry/components/v2-split-line/SplitLineRenderer.tsx b/frontend/lib/registry/components/v2-split-line/SplitLineRenderer.tsx new file mode 100644 index 00000000..81888266 --- /dev/null +++ b/frontend/lib/registry/components/v2-split-line/SplitLineRenderer.tsx @@ -0,0 +1,30 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2SplitLineDefinition } from "./index"; +import { SplitLineComponent } from "./SplitLineComponent"; + +/** + * SplitLine 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class SplitLineRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2SplitLineDefinition; + + render(): React.ReactElement { + return ; + } + + protected handleValueChange = (value: any) => { + this.updateComponent({ value }); + }; +} + +// 자동 등록 실행 +SplitLineRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + SplitLineRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts b/frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts new file mode 100644 index 00000000..365d3fc3 --- /dev/null +++ b/frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts @@ -0,0 +1,73 @@ +/** + * 캔버스 분할선 글로벌 스토어 + * + * React Context를 우회하여 useSyncExternalStore로 직접 상태를 공유. + * SplitLineComponent가 드래그 시 이 스토어를 업데이트하고, + * RealtimePreviewDynamic이 구독하여 컴포넌트 위치를 조정. + */ + +export interface CanvasSplitState { + /** 스플릿선의 초기 X 위치 (캔버스 기준 px) */ + initialDividerX: number; + /** 스플릿선의 현재 X 위치 (드래그 중 변경) */ + currentDividerX: number; + /** 캔버스 전체 너비 (px) */ + canvasWidth: number; + /** 드래그 진행 중 여부 */ + isDragging: boolean; + /** 활성 여부 (스플릿선이 등록되었는지) */ + active: boolean; + /** 스코프 ID (같은 data-screen-runtime 컨테이너의 컴포넌트만 영향) */ + scopeId: string; +} + +let state: CanvasSplitState = { + initialDividerX: 0, + currentDividerX: 0, + canvasWidth: 0, + isDragging: false, + active: false, + scopeId: "", +}; + +const listeners = new Set<() => void>(); + +export function setCanvasSplit(updates: Partial): void { + state = { ...state, ...updates }; + listeners.forEach((fn) => fn()); +} + +export function resetCanvasSplit(): void { + state = { + initialDividerX: 0, + currentDividerX: 0, + canvasWidth: 0, + isDragging: false, + active: false, + scopeId: "", + }; + listeners.forEach((fn) => fn()); +} + +export function subscribe(callback: () => void): () => void { + listeners.add(callback); + return () => { + listeners.delete(callback); + }; +} + +export function getSnapshot(): CanvasSplitState { + return state; +} + +// SSR 호환 +export function getServerSnapshot(): CanvasSplitState { + return { + initialDividerX: 0, + currentDividerX: 0, + canvasWidth: 0, + isDragging: false, + active: false, + scopeId: "", + }; +} diff --git a/frontend/lib/registry/components/v2-split-line/index.ts b/frontend/lib/registry/components/v2-split-line/index.ts new file mode 100644 index 00000000..55a3a0d1 --- /dev/null +++ b/frontend/lib/registry/components/v2-split-line/index.ts @@ -0,0 +1,41 @@ +"use client"; + +import React from "react"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { SplitLineWrapper } from "./SplitLineComponent"; +import { SplitLineConfigPanel } from "./SplitLineConfigPanel"; +import { SplitLineConfig } from "./types"; + +/** + * SplitLine 컴포넌트 정의 + * 캔버스를 좌우로 분할하는 드래그 가능한 세로 분할선 + */ +export const V2SplitLineDefinition = createComponentDefinition({ + id: "v2-split-line", + name: "스플릿선", + nameEng: "SplitLine Component", + description: "캔버스를 좌우로 분할하는 드래그 가능한 분할선", + category: ComponentCategory.LAYOUT, + webType: "text", + component: SplitLineWrapper, + defaultConfig: { + resizable: true, + lineColor: "#e2e8f0", + lineWidth: 4, + } as SplitLineConfig, + defaultSize: { width: 8, height: 600 }, + configPanel: SplitLineConfigPanel, + icon: "SeparatorVertical", + tags: ["스플릿", "분할", "분할선", "레이아웃"], + version: "1.0.0", + author: "개발팀", + documentation: "", +}); + +// 타입 내보내기 +export type { SplitLineConfig } from "./types"; + +// 컴포넌트 내보내기 +export { SplitLineComponent } from "./SplitLineComponent"; +export { SplitLineRenderer } from "./SplitLineRenderer"; diff --git a/frontend/lib/registry/components/v2-split-line/types.ts b/frontend/lib/registry/components/v2-split-line/types.ts new file mode 100644 index 00000000..2e3109b3 --- /dev/null +++ b/frontend/lib/registry/components/v2-split-line/types.ts @@ -0,0 +1,28 @@ +"use client"; + +import { ComponentConfig } from "@/types/component"; + +/** + * SplitLine 컴포넌트 설정 타입 + * 캔버스를 좌우로 분할하는 드래그 가능한 분할선 + * + * 초기 분할 지점은 캔버스 위 X 위치로 결정됨 (별도 splitRatio 불필요) + */ +export interface SplitLineConfig extends ComponentConfig { + // 드래그 리사이즈 허용 여부 + resizable?: boolean; + + // 분할선 스타일 + lineColor?: string; + lineWidth?: number; +} + +/** + * SplitLine 컴포넌트 Props 타입 + */ +export interface SplitLineProps { + id?: string; + config?: SplitLineConfig; + className?: string; + style?: React.CSSProperties; +} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelContext.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelContext.tsx index fe3a9327..64fee843 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelContext.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelContext.tsx @@ -26,6 +26,8 @@ export interface SplitPanelInfo { initialLeftWidthPercent: number; // 드래그 중 여부 isDragging: boolean; + // 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정) + panelType?: "component" | "canvas"; } export interface SplitPanelResizeContextValue { diff --git a/frontend/lib/schemas/componentConfig.ts b/frontend/lib/schemas/componentConfig.ts index 5908e5f3..2fd03bd4 100644 --- a/frontend/lib/schemas/componentConfig.ts +++ b/frontend/lib/schemas/componentConfig.ts @@ -689,6 +689,12 @@ const componentOverridesSchemaRegistry: Record> = { autoLoad: true, syncSelection: true, }, + "v2-split-line": { + resizable: true, + lineColor: "#e2e8f0", + lineWidth: 4, + }, "v2-section-card": { title: "섹션 제목", description: "", diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index 6de75bc1..6c2e5ebf 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -57,6 +57,7 @@ const CONFIG_PANEL_MAP: Record Promise> = { "split-panel-layout2": () => import("@/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel"), "screen-split-panel": () => import("@/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel"), "conditional-container": () => import("@/lib/registry/components/conditional-container/ConditionalContainerConfigPanel"), + "v2-split-line": () => import("@/lib/registry/components/v2-split-line/SplitLineConfigPanel"), // ========== 테이블/리스트 ========== "table-list": () => import("@/lib/registry/components/table-list/TableListConfigPanel"), -- 2.43.0 From 27853a9447ee107712810881acfc2cdb387b87b8 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 24 Feb 2026 10:49:23 +0900 Subject: [PATCH 2/7] 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 }); -- 2.43.0 From 5afa373b1f3aab8f9cd8e2ff275da88aeca2cf7c Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 24 Feb 2026 11:02:43 +0900 Subject: [PATCH 3/7] refactor: Update middleware and enhance component interactions - Improved the middleware to handle authentication checks more effectively, ensuring that users are redirected appropriately based on their authentication status. - Updated the InteractiveScreenViewerDynamic and RealtimePreviewDynamic components to utilize a new subscription method for DOM manipulation during drag events, enhancing performance and user experience. - Refactored the SplitLineComponent to optimize drag handling and state management, ensuring smoother interactions during component adjustments. - Integrated API client for menu data loading, streamlining token management and error handling. --- .../screen/InteractiveScreenViewerDynamic.tsx | 65 +++++++- .../screen/RealtimePreviewDynamic.tsx | 60 +++++++- frontend/hooks/useMenu.ts | 89 +++-------- frontend/lib/api/client.ts | 2 + .../BomItemEditorComponent.tsx | 139 ++++++++++++++++-- .../v2-split-line/SplitLineComponent.tsx | 20 ++- .../v2-split-line/canvasSplitStore.ts | 85 +++++++---- frontend/middleware.ts | 33 ++--- 8 files changed, 349 insertions(+), 144 deletions(-) diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 4f76778e..e2143e8e 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -24,6 +24,7 @@ import { subscribe as canvasSplitSubscribe, getSnapshot as canvasSplitGetSnapshot, getServerSnapshot as canvasSplitGetServerSnapshot, + subscribeDom as canvasSplitSubscribeDom, } from "@/lib/registry/components/v2-split-line/canvasSplitStore"; // 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록 @@ -1181,14 +1182,66 @@ export const InteractiveScreenViewerDynamic: React.FC { + if (isSplitActive && adjustedW !== origW) { + return { ...component, size: { ...(component as any).size, width: Math.round(adjustedW) } }; + } + return component; + }, [component, isSplitActive, adjustedW, origW]); + + // 드래그 중 DOM 직접 조작 (React 리렌더 없이 매 프레임 업데이트) + const elRef = React.useRef(null); + React.useEffect(() => { + const compType = (component as any).componentType || ""; + if (type === "component" && compType === "v2-split-line") return; + + const unsubscribe = canvasSplitSubscribeDom((snap) => { + if (!snap.isDragging || !snap.active || !snap.scopeId) return; + if (myScopeIdRef.current !== snap.scopeId) return; + const el = elRef.current; + if (!el) return; + + const origX = position?.x || 0; + const oW = size?.width || 200; + const { initialDividerX, currentDividerX, canvasWidth } = snap; + const delta = currentDividerX - initialDividerX; + if (Math.abs(delta) < 1) return; + + if (canvasSplitSideRef.current === null) { + canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right"; + } + + const GAP = 4; + let nx: number, nw: number; + if (canvasSplitSideRef.current === "left") { + const scale = initialDividerX > 0 ? Math.max(20, currentDividerX - GAP) / initialDividerX : 1; + nx = origX * scale; + nw = oW * scale; + if (nx + nw > currentDividerX - GAP) nw = currentDividerX - GAP - nx; + } else { + const irw = canvasWidth - initialDividerX; + const crw = Math.max(20, canvasWidth - currentDividerX - GAP); + const scale = irw > 0 ? crw / irw : 1; + nx = currentDividerX + GAP + (origX - initialDividerX) * scale; + nw = oW * scale; + if (nx < currentDividerX + GAP) nx = currentDividerX + GAP; + if (nx + nw > canvasWidth) nw = canvasWidth - nx; + } + nx = Math.max(0, nx); + nw = Math.max(20, nw); + + el.style.left = `${nx}px`; + el.style.width = `${Math.round(nw)}px`; + el.style.overflow = nw < oW ? "hidden" : ""; + }); + return unsubscribe; + }, [component.id, position?.x, size?.width, type]); + return ( <> -
- {renderInteractiveWidget( - isSplitActive && adjustedW !== origW - ? { ...component, size: { ...(component as any).size, width: adjustedW } } - : component - )} +
+ {renderInteractiveWidget(splitAdjustedComponent)}
{/* 팝업 화면 렌더링 */} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index d4c91d93..b95506d9 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -21,6 +21,7 @@ import { subscribe as canvasSplitSubscribe, getSnapshot as canvasSplitGetSnapshot, getServerSnapshot as canvasSplitGetServerSnapshot, + subscribeDom as canvasSplitSubscribeDom, } from "@/lib/registry/components/v2-split-line/canvasSplitStore"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; @@ -603,6 +604,60 @@ const RealtimePreviewDynamicComponent: React.FC = ({ onDragEnd?.(); }; + const splitAdjustedComp = React.useMemo(() => { + if (isSplitShrunk && splitAdjustedWidth !== null) { + return { ...enhancedComponent, size: { ...(enhancedComponent as any).size, width: Math.round(splitAdjustedWidth) } }; + } + return enhancedComponent; + }, [enhancedComponent, isSplitShrunk, splitAdjustedWidth]); + + // 드래그 중 DOM 직접 조작 (React 리렌더 없이 매 프레임 업데이트) + React.useEffect(() => { + const isSplitLine = type === "component" && componentType === "v2-split-line"; + if (isSplitLine) return; + + const unsubscribe = canvasSplitSubscribeDom((snap) => { + if (!snap.isDragging || !snap.active || !snap.scopeId) return; + if (myScopeIdRef.current !== snap.scopeId) return; + const el = outerDivRef.current; + if (!el) return; + + const origX = position.x; + const oW = size?.width || 100; + const { initialDividerX, currentDividerX, canvasWidth } = snap; + const delta = currentDividerX - initialDividerX; + if (Math.abs(delta) < 1) return; + + if (canvasSplitSideRef.current === null) { + canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right"; + } + + const GAP = 4; + let nx: number, nw: number; + if (canvasSplitSideRef.current === "left") { + const scale = initialDividerX > 0 ? Math.max(20, currentDividerX - GAP) / initialDividerX : 1; + nx = origX * scale; + nw = oW * scale; + if (nx + nw > currentDividerX - GAP) nw = currentDividerX - GAP - nx; + } else { + const irw = canvasWidth - initialDividerX; + const crw = Math.max(20, canvasWidth - currentDividerX - GAP); + const scale = irw > 0 ? crw / irw : 1; + nx = currentDividerX + GAP + (origX - initialDividerX) * scale; + nw = oW * scale; + if (nx < currentDividerX + GAP) nx = currentDividerX + GAP; + if (nx + nw > canvasWidth) nw = canvasWidth - nx; + } + nx = Math.max(0, nx); + nw = Math.max(20, nw); + + el.style.left = `${nx}px`; + el.style.width = `${Math.round(nw)}px`; + el.style.overflow = nw < oW ? "hidden" : ""; + }); + return unsubscribe; + }, [id, position.x, size?.width, type, componentType]); + return (
= ({ style={{ width: "100%", maxWidth: "100%" }} > { const router = useRouter(); - // 상태 관리 const [menuState, setMenuState] = useState({ menuList: [], expandedMenus: new Set(), @@ -36,103 +37,58 @@ export const useMenu = (user: any, authLoading: boolean) => { * 메뉴 트리 구조 생성 */ const buildMenuTree = useCallback((menuItems: MenuItem[]): MenuItem[] => { - console.log("빌드 메뉴 트리 - 원본 메뉴 아이템들:", menuItems); - const menuMap = new Map(); const rootMenus: MenuItem[] = []; - // 모든 메뉴를 맵에 저장 (ID를 문자열로 변환) menuItems.forEach((menu) => { const objId = String(menu.OBJID); const parentId = String(menu.PARENT_OBJ_ID); - console.log(`메뉴 처리: ${menu.MENU_NAME_KOR}, OBJID: ${objId}, PARENT_OBJ_ID: ${parentId}`); menuMap.set(objId, { ...menu, OBJID: objId, PARENT_OBJ_ID: parentId, children: [] }); }); - console.log("메뉴 맵 생성 완료, 총 메뉴 수:", menuMap.size); - - // 부모-자식 관계 설정 menuItems.forEach((menu) => { const objId = String(menu.OBJID); const parentId = String(menu.PARENT_OBJ_ID); const menuItem = menuMap.get(objId)!; - // PARENT_OBJ_ID가 특정 값이 아닌 경우 (루트가 아닌 경우) if (parentId !== "-395553955") { const parent = menuMap.get(parentId); if (parent) { parent.children = parent.children || []; parent.children.push(menuItem); - console.log(`자식 메뉴 추가: ${menu.MENU_NAME_KOR} -> ${parent.MENU_NAME_KOR}`); - } else { - console.log(`부모 메뉴를 찾을 수 없음: ${menu.MENU_NAME_KOR}, 부모 ID: ${parentId}`); } } else { rootMenus.push(menuItem); - console.log(`루트 메뉴 추가: ${menu.MENU_NAME_KOR}`); } }); - console.log("루트 메뉴 개수:", rootMenus.length); - console.log( - "최종 루트 메뉴들:", - rootMenus.map((m) => m.MENU_NAME_KOR), - ); - return rootMenus.sort((a, b) => (a.SEQ || 0) - (b.SEQ || 0)); }, []); /** * 메뉴 데이터 로드 + * - apiClient 사용으로 토큰/401 자동 처리 + * - 실패 시 빈 메뉴 유지 (로그인 리다이렉트는 client.ts 인터셉터가 담당) */ const loadMenuData = useCallback(async () => { try { - // JWT 토큰 가져오기 - const token = localStorage.getItem("authToken"); + const response = await apiClient.get("/admin/user-menus"); - if (!token) { - console.error("JWT 토큰이 없습니다."); - router.push("/login"); - return; + if (response.data?.success && response.data?.data) { + const convertedMenuData = convertToUpperCaseKeys(response.data.data || []); + setMenuState((prev: MenuState) => ({ + ...prev, + menuList: buildMenuTree(convertedMenuData), + isLoading: false, + })); + } else { + setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); } - - // 메뉴 목록 조회 - const menuResponse = await fetch(`${LAYOUT_CONFIG.API_BASE_URL}${LAYOUT_CONFIG.ENDPOINTS.USER_MENUS}`, { - method: "GET", - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/json", - "Content-Type": "application/json", - }, - }); - - if (menuResponse.ok) { - const menuResult = await menuResponse.json(); - console.log("메뉴 응답 데이터:", menuResult); - - if (menuResult.success && menuResult.data) { - console.log("메뉴 데이터 배열:", menuResult.data); - const convertedMenuData = convertToUpperCaseKeys(menuResult.data || []); - console.log("변환된 메뉴 데이터:", convertedMenuData); - - setMenuState((prev: MenuState) => ({ - ...prev, - menuList: buildMenuTree(convertedMenuData), - isLoading: false, - })); - } - } else if (menuResponse.status === 401) { - // 인증 실패 시 토큰 제거 및 로그인 페이지로 리다이렉트 - localStorage.removeItem("authToken"); - router.push("/login"); - } - } catch (error) { - console.error("메뉴 데이터 로드 실패:", error); - localStorage.removeItem("authToken"); - router.push("/login"); + } catch { + // API 실패 시 빈 메뉴로 유지 (401은 client.ts 인터셉터가 리다이렉트 처리) setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); } - }, [router, convertToUpperCaseKeys, buildMenuTree]); + }, [convertToUpperCaseKeys, buildMenuTree]); /** * 메뉴 토글 @@ -160,13 +116,11 @@ export const useMenu = (user: any, authLoading: boolean) => { if (menu.children && menu.children.length > 0) { toggleMenu(String(menu.OBJID)); } else { - // 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용) const menuName = menu.MENU_NAME_KOR || menu.menuNameKor || menu.TRANSLATED_NAME || "메뉴"; if (typeof window !== "undefined") { localStorage.setItem("currentMenuName", menuName); } - // 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이) try { const menuObjid = menu.OBJID || menu.objid; if (menuObjid) { @@ -174,9 +128,7 @@ export const useMenu = (user: any, authLoading: boolean) => { const assignedScreens = await menuScreenApi.getScreensByMenu(parseInt(menuObjid.toString())); if (assignedScreens.length > 0) { - // 할당된 화면이 있으면 첫 번째 화면으로 이동 const firstScreen = assignedScreens[0]; - // menuObjid를 쿼리 파라미터로 전달 router.push(`/screens/${firstScreen.screenId}?menuObjid=${menuObjid}`); return; } @@ -185,11 +137,9 @@ export const useMenu = (user: any, authLoading: boolean) => { console.warn("할당된 화면 조회 실패:", error); } - // 할당된 화면이 없고 URL이 있으면 기존 URL로 이동 if (menu.MENU_URL) { router.push(menu.MENU_URL); } else { - // URL도 없고 할당된 화면도 없으면 경고 메시지 console.warn("메뉴에 URL이나 할당된 화면이 없습니다:", menu); const { toast } = await import("sonner"); toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다."); @@ -199,7 +149,6 @@ export const useMenu = (user: any, authLoading: boolean) => { [toggleMenu, router], ); - // 사용자 정보가 있고 로딩이 완료되면 메뉴 데이터 로드 useEffect(() => { if (user && !authLoading) { loadMenuData(); @@ -212,6 +161,6 @@ export const useMenu = (user: any, authLoading: boolean) => { isMenuLoading: menuState.isLoading, handleMenuClick, toggleMenu, - refreshMenus: loadMenuData, // 메뉴 새로고침 함수 추가 + refreshMenus: loadMenuData, }; }; diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 01a069ce..7abe856c 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -73,12 +73,14 @@ const TokenManager = { setToken: (token: string): void => { if (typeof window !== "undefined") { localStorage.setItem("authToken", token); + document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`; } }, removeToken: (): void => { if (typeof window !== "undefined") { localStorage.removeItem("authToken"); + document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; } }, diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index 8a34a42e..8191e68b 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -641,18 +641,139 @@ export function BomItemEditorComponent({ }); }; - // ─── 디자인 모드 ─── + // ─── 디자인 모드 미리보기 ─── if (isDesignMode) { + const cfg = component?.componentConfig || {}; + const hasConfig = + cfg.mainTableName || cfg.dataSource?.sourceTable || (cfg.columns && cfg.columns.length > 0); + + const sourceColumns = (cfg.columns || []).filter((c: any) => c.isSourceDisplay); + const inputColumns = (cfg.columns || []).filter((c: any) => !c.isSourceDisplay); + + if (!hasConfig) { + return ( +
+ +

+ BOM 하위 품목 편집기 +

+

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

+
+ ); + } + + const dummyRows = [ + { depth: 0, code: "ASM-001", name: "본체 조립", type: "조립", qty: "1" }, + { depth: 1, code: "PRT-010", name: "프레임", type: "구매", qty: "2" }, + { depth: 1, code: "PRT-011", name: "커버", type: "구매", qty: "1" }, + { depth: 0, code: "ASM-002", name: "전장 조립", type: "조립", qty: "1" }, + { depth: 1, code: "PRT-020", name: "PCB 보드", type: "구매", qty: "3" }, + ]; + return ( -
- -

- BOM 하위 품목 편집기 -

-

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

+
+ {/* 헤더 */} +
+

하위 품목 구성

+ +
+ + {/* 설정 요약 뱃지 */} +
+ {cfg.mainTableName && ( + + 저장: {cfg.mainTableName} + + )} + {cfg.dataSource?.sourceTable && ( + + 소스: {cfg.dataSource.sourceTable} + + )} + {cfg.parentKeyColumn && ( + + 트리: {cfg.parentKeyColumn} + + )} + {inputColumns.length > 0 && ( + + 입력 {inputColumns.length}개 + + )} + {sourceColumns.length > 0 && ( + + 표시 {sourceColumns.length}개 + + )} +
+ + {/* 더미 트리 미리보기 */} +
+ {dummyRows.map((row, i) => ( +
0 && "border-l-2 border-l-primary/20", + i === 0 && "bg-accent/30", + )} + style={{ marginLeft: `${row.depth * 20}px` }} + > + + {row.depth === 0 ? ( + + ) : ( + + )} + + {i + 1} + + + {row.code} + + + {row.name} + + + {/* 소스 표시 컬럼 미리보기 */} + {sourceColumns.slice(0, 2).map((col: any) => ( + + {col.title} + + ))} + + {/* 입력 컬럼 미리보기 */} + {inputColumns.slice(0, 2).map((col: any) => ( +
+ {col.key === "quantity" || col.title === "수량" + ? row.qty + : ""} +
+ ))} + +
+
+ +
+
+ +
+
+
+ ))} +
); } diff --git a/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx b/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx index a0110195..b27f9d9f 100644 --- a/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx +++ b/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx @@ -110,7 +110,10 @@ export const SplitLineComponent: React.FC = ({ }; }, [isDesignMode, component.id, component.position?.x, detectCanvasWidth]); - // 드래그 핸들러 (requestAnimationFrame으로 스로틀링 → 렉 방지) + // 드래그 중 최종 오프셋 (DOM 직접 조작용) + const latestOffsetRef = useRef(dragOffset); + latestOffsetRef.current = dragOffset; + const rafIdRef = useRef(0); const handleMouseDown = useCallback( (e: React.MouseEvent) => { @@ -120,7 +123,7 @@ export const SplitLineComponent: React.FC = ({ const posX = component.position?.x || 0; const startX = e.clientX; - const startOffset = dragOffset; + const startOffset = latestOffsetRef.current; const scaleFactor = getScaleFactor(); const cw = detectCanvasWidth(); const MIN_POS = Math.max(50, cw * 0.15); @@ -130,7 +133,6 @@ export const SplitLineComponent: React.FC = ({ setCanvasSplit({ isDragging: true }); const handleMouseMove = (moveEvent: MouseEvent) => { - // rAF로 스로틀링: 프레임당 1회만 업데이트 cancelAnimationFrame(rafIdRef.current); rafIdRef.current = requestAnimationFrame(() => { const rawDelta = moveEvent.clientX - startX; @@ -141,7 +143,13 @@ export const SplitLineComponent: React.FC = ({ if (newDividerX < MIN_POS) newOffset = MIN_POS - posX; if (newDividerX > MAX_POS) newOffset = MAX_POS - posX; - setDragOffset(newOffset); + latestOffsetRef.current = newOffset; + + // 스플릿선 자체는 DOM 직접 조작 (React 리렌더 없음) + if (containerRef.current) { + containerRef.current.style.transform = `translateX(${newOffset}px)`; + } + // 스토어 업데이트 → DOM 리스너만 호출 (React 리렌더 없음) setCanvasSplit({ currentDividerX: posX + newOffset }); }); }; @@ -153,6 +161,8 @@ export const SplitLineComponent: React.FC = ({ document.body.style.userSelect = ""; document.body.style.cursor = ""; + // 최종 오프셋을 React 상태에 동기화 (1회만 리렌더) + setDragOffset(latestOffsetRef.current); setIsDragging(false); setCanvasSplit({ isDragging: false }); }; @@ -162,7 +172,7 @@ export const SplitLineComponent: React.FC = ({ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [resizable, isDesignMode, dragOffset, component.position?.x, getScaleFactor, detectCanvasWidth], + [resizable, isDesignMode, component.position?.x, getScaleFactor, detectCanvasWidth], ); const handleClick = (e: React.MouseEvent) => { diff --git a/frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts b/frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts index 365d3fc3..85cdbdbd 100644 --- a/frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts +++ b/frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts @@ -1,27 +1,21 @@ /** * 캔버스 분할선 글로벌 스토어 * - * React Context를 우회하여 useSyncExternalStore로 직접 상태를 공유. - * SplitLineComponent가 드래그 시 이 스토어를 업데이트하고, - * RealtimePreviewDynamic이 구독하여 컴포넌트 위치를 조정. + * 성능 최적화: 이중 리스너 구조 + * - React 리스너 (subscribe): 구조적 변경만 알림 (active, isDragging 시작/종료) + * - DOM 리스너 (subscribeDom): 드래그 중 위치 변경 알림 (React 우회, 직접 DOM 조작) */ export interface CanvasSplitState { - /** 스플릿선의 초기 X 위치 (캔버스 기준 px) */ initialDividerX: number; - /** 스플릿선의 현재 X 위치 (드래그 중 변경) */ currentDividerX: number; - /** 캔버스 전체 너비 (px) */ canvasWidth: number; - /** 드래그 진행 중 여부 */ isDragging: boolean; - /** 활성 여부 (스플릿선이 등록되었는지) */ active: boolean; - /** 스코프 ID (같은 data-screen-runtime 컨테이너의 컴포넌트만 영향) */ scopeId: string; } -let state: CanvasSplitState = { +const initialState: CanvasSplitState = { initialDividerX: 0, currentDividerX: 0, canvasWidth: 0, @@ -30,44 +24,71 @@ let state: CanvasSplitState = { scopeId: "", }; -const listeners = new Set<() => void>(); +let state: CanvasSplitState = { ...initialState }; + +// React 리렌더링을 트리거하는 리스너 (구조적 변경 전용) +const reactListeners = new Set<() => void>(); + +// DOM 직접 조작용 리스너 (드래그 중 위치 업데이트, React 우회) +const domListeners = new Set<(state: CanvasSplitState) => void>(); + +// React용 스냅샷 (드래그 중 위치 변경에는 갱신 안 함) +let reactSnapshot: CanvasSplitState = { ...initialState }; export function setCanvasSplit(updates: Partial): void { state = { ...state, ...updates }; - listeners.forEach((fn) => fn()); + + // 드래그 중 위치만 변경 → DOM 리스너만 호출 (React 리렌더 없음) + const isPositionOnlyDuringDrag = + state.isDragging && + Object.keys(updates).length === 1 && + "currentDividerX" in updates; + + if (isPositionOnlyDuringDrag) { + domListeners.forEach((fn) => fn(state)); + return; + } + + // 구조적 변경 → React 리스너 + DOM 리스너 모두 호출 + reactSnapshot = { ...state }; + reactListeners.forEach((fn) => fn()); + domListeners.forEach((fn) => fn(state)); } export function resetCanvasSplit(): void { - state = { - initialDividerX: 0, - currentDividerX: 0, - canvasWidth: 0, - isDragging: false, - active: false, - scopeId: "", - }; - listeners.forEach((fn) => fn()); + state = { ...initialState }; + reactSnapshot = { ...initialState }; + reactListeners.forEach((fn) => fn()); + domListeners.forEach((fn) => fn(state)); } +// React용: useSyncExternalStore에 연결 export function subscribe(callback: () => void): () => void { - listeners.add(callback); + reactListeners.add(callback); return () => { - listeners.delete(callback); + reactListeners.delete(callback); }; } export function getSnapshot(): CanvasSplitState { - return state; + return reactSnapshot; } -// SSR 호환 export function getServerSnapshot(): CanvasSplitState { - return { - initialDividerX: 0, - currentDividerX: 0, - canvasWidth: 0, - isDragging: false, - active: false, - scopeId: "", + return { ...initialState }; +} + +// DOM 직접 조작용: 드래그 중 매 프레임 위치 업데이트 수신 +export function subscribeDom( + callback: (state: CanvasSplitState) => void, +): () => void { + domListeners.add(callback); + return () => { + domListeners.delete(callback); }; } + +// 현재 상태 직접 참조 (DOM 리스너 콜백 외부에서 최신 상태 필요 시) +export function getCurrentState(): CanvasSplitState { + return state; +} diff --git a/frontend/middleware.ts b/frontend/middleware.ts index eb42b4c2..d603adc7 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -4,36 +4,41 @@ import type { NextRequest } from "next/server"; /** * Next.js 미들웨어 * 페이지 렌더링 전에 실행되어 인증 상태를 확인하고 리다이렉트 처리 + * + * 주의: 미들웨어는 쿠키만 접근 가능 (localStorage 접근 불가) + * 따라서 토큰이 localStorage에만 있고 쿠키에 없는 경우는 + * 클라이언트 사이드의 AuthGuard에서 처리 */ export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - // 인증 토큰 확인 - const token = request.cookies.get("authToken")?.value || request.headers.get("authorization")?.replace("Bearer ", ""); + const token = request.cookies.get("authToken")?.value; - // /login 페이지 접근 시 + // /login 페이지 접근 시 - 토큰이 있으면 메인으로 if (pathname === "/login") { - // 토큰이 있으면 메인 페이지로 리다이렉트 if (token) { const url = request.nextUrl.clone(); url.pathname = "/main"; return NextResponse.redirect(url); } - // 토큰이 없으면 로그인 페이지 표시 return NextResponse.next(); } - // 인증이 필요한 페이지들 - const protectedPaths = ["/main", "/admin", "/dashboard", "/settings"]; - const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path)); + // 보호 경로 목록 - 쿠키에 토큰이 없으면 로그인으로 + // 단, 쿠키 없이 localStorage에만 토큰이 있을 수 있으므로 + // 클라이언트에서 한 번 더 확인 후 리다이렉트 (AuthGuard) + const strictProtectedPaths = ["/admin"]; - if (isProtectedPath && !token) { - // 인증되지 않은 사용자는 로그인 페이지로 리다이렉트 + const isStrictProtected = strictProtectedPaths.some((path) => pathname.startsWith(path)); + + if (isStrictProtected && !token) { const url = request.nextUrl.clone(); url.pathname = "/login"; return NextResponse.redirect(url); } + // /main, /screens, /dashboard 등은 쿠키 없어도 통과 허용 + // (localStorage 토큰이 있을 수 있으므로 클라이언트 AuthGuard에 위임) return NextResponse.next(); } @@ -42,14 +47,6 @@ export function middleware(request: NextRequest) { */ export const config = { matcher: [ - /* - * 다음 경로를 제외한 모든 요청에 대해 실행: - * - api (API routes) - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - * - public 폴더의 파일들 - */ "/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$|.*\\.jpg$|.*\\.jpeg$|.*\\.svg$).*)", ], }; -- 2.43.0 From 72068d003ac0773f8a8f8bd9eb8d20fbae6daaf1 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 24 Feb 2026 15:27:18 +0900 Subject: [PATCH 4/7] refactor: Enhance screen layout retrieval logic for multi-tenancy support - Updated the ScreenManagementService to prioritize fetching layouts based on layer_id, ensuring that only the default layer is retrieved for users. - Implemented logic for administrators to re-query layouts based on the screen definition's company_code when no layout is found. - Adjusted the BomItemEditorComponent to dynamically render table cells based on configuration, improving flexibility and usability in the BOM item editor. - Introduced category options loading for dynamic cell rendering, enhancing the user experience in item editing. --- .../src/services/screenManagementService.ts | 35 +- frontend/components/screen/EditModal.tsx | 20 +- .../BomItemEditorComponent.tsx | 466 +++++++++++------- 3 files changed, 331 insertions(+), 190 deletions(-) diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 2c25f7e0..6f412de5 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1721,18 +1721,28 @@ export class ScreenManagementService { throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다."); } - // 🆕 V2 테이블 우선 조회 (회사별 → 공통(*)) + // V2 테이블 우선 조회: 기본 레이어(layer_id=1)만 가져옴 + // layer_id 필터 없이 queryOne 하면 조건부 레이어가 반환될 수 있음 let v2Layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 - WHERE screen_id = $1 AND company_code = $2`, + WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`, [screenId, companyCode], ); - // 회사별 레이아웃 없으면 공통(*) 조회 + // 최고관리자(*): 화면 정의의 company_code로 재조회 + if (!v2Layout && companyCode === "*" && existingScreen.company_code && existingScreen.company_code !== "*") { + v2Layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`, + [screenId, existingScreen.company_code], + ); + } + + // 일반 사용자: 회사별 레이아웃 없으면 공통(*) 조회 if (!v2Layout && companyCode !== "*") { v2Layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 - WHERE screen_id = $1 AND company_code = '*'`, + WHERE screen_id = $1 AND company_code = '*' AND layer_id = 1`, [screenId], ); } @@ -5302,7 +5312,22 @@ export class ScreenManagementService { [screenId, companyCode, layerId], ); - // 회사별 레이어가 없으면 공통(*) 조회 + // 최고관리자(*): 화면 정의의 company_code로 재조회 + if (!layout && companyCode === "*") { + const screenDef = await queryOne<{ company_code: string }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId], + ); + if (screenDef && screenDef.company_code && screenDef.company_code !== "*") { + layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>( + `SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`, + [screenId, screenDef.company_code, layerId], + ); + } + } + + // 일반 사용자: 회사별 레이어가 없으면 공통(*) 조회 if (!layout && companyCode !== "*") { layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>( `SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2 diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 096d50e9..8dad77db 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -376,12 +376,26 @@ export const EditModal: React.FC = ({ className }) => { try { setLoading(true); - // 화면 정보와 레이아웃 데이터 로딩 - const [screenInfo, layoutData] = await Promise.all([ + // 화면 정보와 레이아웃 데이터 로딩 (ScreenModal과 동일하게 V2 API 우선) + const [screenInfo, v2LayoutData] = await Promise.all([ screenApi.getScreen(screenId), - screenApi.getLayout(screenId), + screenApi.getLayoutV2(screenId), ]); + // V2 → Legacy 변환 (ScreenModal과 동일한 패턴) + let layoutData: any = null; + if (v2LayoutData && isValidV2Layout(v2LayoutData)) { + layoutData = convertV2ToLegacy(v2LayoutData); + if (layoutData) { + layoutData.screenResolution = v2LayoutData.screenResolution || layoutData.screenResolution; + } + } + + // V2 없으면 기존 API fallback + if (!layoutData) { + layoutData = await screenApi.getLayout(screenId); + } + if (screenInfo && layoutData) { const components = layoutData.components || []; diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index 8191e68b..16aebd59 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -35,21 +35,23 @@ 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; + data: Record; +} + +interface BomColumnConfig { + key: string; + title: string; + width?: string; + visible?: boolean; + editable?: boolean; + isSourceDisplay?: boolean; + inputType?: string; } interface ItemInfo { @@ -211,13 +213,16 @@ function ItemSearchModal({ ); } -// ─── 트리 노드 행 렌더링 ─── +// ─── 트리 노드 행 렌더링 (config.columns 기반 동적 셀) ─── interface TreeNodeRowProps { node: BomItemNode; depth: number; expanded: boolean; hasChildren: boolean; + columns: BomColumnConfig[]; + categoryOptionsMap: Record; + mainTableName?: string; onToggle: () => void; onFieldChange: (tempId: string, field: string, value: string) => void; onDelete: (tempId: string) => void; @@ -229,12 +234,84 @@ function TreeNodeRow({ depth, expanded, hasChildren, + columns, + categoryOptionsMap, + mainTableName, onToggle, onFieldChange, onDelete, onAddChild, }: TreeNodeRowProps) { const indentPx = depth * 32; + const visibleColumns = columns.filter((c) => c.visible !== false); + + const renderCell = (col: BomColumnConfig) => { + const value = node.data[col.key] ?? ""; + + // 소스 표시 컬럼 (읽기 전용) + if (col.isSourceDisplay) { + return ( + + {value || "-"} + + ); + } + + // 카테고리 타입: API에서 로드한 옵션으로 Select 렌더링 + if (col.inputType === "category") { + const categoryRef = mainTableName ? `${mainTableName}.${col.key}` : ""; + const options = categoryOptionsMap[categoryRef] || []; + return ( + + ); + } + + // 편집 불가능 컬럼 + if (col.editable === false) { + return ( + + {value || "-"} + + ); + } + + // 숫자 입력 + if (col.inputType === "number" || col.inputType === "decimal") { + return ( + onFieldChange(node.tempId, col.key, e.target.value)} + className="h-7 w-full min-w-[50px] text-center text-xs" + placeholder={col.title} + /> + ); + } + + // 기본 텍스트 입력 + return ( + onFieldChange(node.tempId, col.key, e.target.value)} + className="h-7 w-full min-w-[50px] text-xs" + placeholder={col.title} + /> + ); + }; return (
- {/* 드래그 핸들 */} - {/* 펼침/접기 */} - {/* 삭제 버튼 */}
- {/* 더미 트리 미리보기 */} -
- {dummyRows.map((row, i) => ( -
0 && "border-l-2 border-l-primary/20", - i === 0 && "bg-accent/30", - )} - style={{ marginLeft: `${row.depth * 20}px` }} - > - - {row.depth === 0 ? ( - - ) : ( - - )} - - {i + 1} - - - {row.code} - - - {row.name} - - - {/* 소스 표시 컬럼 미리보기 */} - {sourceColumns.slice(0, 2).map((col: any) => ( - - {col.title} - - ))} - - {/* 입력 컬럼 미리보기 */} - {inputColumns.slice(0, 2).map((col: any) => ( -
- {col.key === "quantity" || col.title === "수량" - ? row.qty - : ""} -
- ))} - -
-
- -
-
- -
-
+ {/* 테이블 형태 미리보기 - config.columns 순서 그대로 */} +
+ {visibleColumns.length === 0 ? ( +
+ +

+ 컬럼 탭에서 표시할 컬럼을 선택하세요 +

- ))} + ) : ( + + + + + {visibleColumns.map((col: any) => ( + + ))} + + + + + {DUMMY_DEPTHS.map((depth, rowIdx) => ( + + + + {visibleColumns.map((col: any) => ( + + ))} + + + ))} + +
+ # + {col.title} + 액션
+
+ {depth === 0 ? ( + + ) : ( + + )} +
+
+ {rowIdx + 1} + + {col.isSourceDisplay ? ( + + {getDummyValue(col, rowIdx) || col.title} + + ) : col.editable !== false ? ( +
+ {getDummyValue(col, rowIdx)} +
+ ) : ( + + {getDummyValue(col, rowIdx)} + + )} +
+
+
+ +
+
+ +
+
+
+ )}
); -- 2.43.0 From a6f37fd3dc9026557517e84a85a2ea1773b493b0 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 24 Feb 2026 15:28:21 +0900 Subject: [PATCH 5/7] feat: Enhance SplitPanelLayoutComponent with improved filtering and modal handling - Updated search conditions to use an object structure with an "equals" operator for better filtering logic. - Added validation to ensure an item is selected in the left panel before opening the modal, providing user feedback through a toast notification. - Extracted foreign key data from the selected left item for improved data handling when opening the modal. - Cleaned up the code by removing unnecessary comments and consolidating logic for clarity and maintainability. --- .../src/routes/processWorkStandardRoutes.ts | 3 + frontend/lib/registry/components/index.ts | 1 + .../v2-item-routing/ItemRoutingComponent.tsx | 503 +++++++++++ .../ItemRoutingConfigPanel.tsx | 780 ++++++++++++++++++ .../v2-item-routing/ItemRoutingRenderer.tsx | 32 + .../components/v2-item-routing/config.ts | 38 + .../v2-item-routing/hooks/useItemRouting.ts | 239 ++++++ .../components/v2-item-routing/index.ts | 57 ++ .../components/v2-item-routing/types.ts | 77 ++ .../SplitPanelLayoutComponent.tsx | 53 +- 10 files changed, 1765 insertions(+), 18 deletions(-) create mode 100644 frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx create mode 100644 frontend/lib/registry/components/v2-item-routing/ItemRoutingConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-item-routing/config.ts create mode 100644 frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts create mode 100644 frontend/lib/registry/components/v2-item-routing/index.ts create mode 100644 frontend/lib/registry/components/v2-item-routing/types.ts diff --git a/backend-node/src/routes/processWorkStandardRoutes.ts b/backend-node/src/routes/processWorkStandardRoutes.ts index 087f08c0..0c052007 100644 --- a/backend-node/src/routes/processWorkStandardRoutes.ts +++ b/backend-node/src/routes/processWorkStandardRoutes.ts @@ -3,10 +3,13 @@ */ import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; import * as ctrl from "../controllers/processWorkStandardController"; const router = express.Router(); +router.use(authenticateToken); + // 품목/라우팅/공정 조회 (좌측 트리) router.get("/items", ctrl.getItemsWithRouting); router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses); diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 910f3a0b..bb64b79c 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -113,6 +113,7 @@ import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트 import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트 import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준 +import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx new file mode 100644 index 00000000..492f7255 --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx @@ -0,0 +1,503 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Search, Plus, Trash2, Edit, ListOrdered, Package } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { cn } from "@/lib/utils"; +import { useToast } from "@/hooks/use-toast"; +import { ItemRoutingConfig, ItemRoutingComponentProps } from "./types"; +import { defaultConfig } from "./config"; +import { useItemRouting } from "./hooks/useItemRouting"; + +export function ItemRoutingComponent({ + config: configProp, + isPreview, +}: ItemRoutingComponentProps) { + const { toast } = useToast(); + + const { + config, + items, + versions, + details, + loading, + selectedItemCode, + selectedItemName, + selectedVersionId, + fetchItems, + selectItem, + selectVersion, + refreshVersions, + refreshDetails, + deleteDetail, + deleteVersion, + } = useItemRouting(configProp || {}); + + const [searchText, setSearchText] = useState(""); + const [deleteTarget, setDeleteTarget] = useState<{ + type: "version" | "detail"; + id: string; + name: string; + } | null>(null); + + // 초기 로딩 (마운트 시 1회만) + const mountedRef = React.useRef(false); + useEffect(() => { + if (!mountedRef.current) { + mountedRef.current = true; + fetchItems(); + } + }, [fetchItems]); + + // 모달 저장 성공 감지 -> 데이터 새로고침 + useEffect(() => { + const handleSaveSuccess = () => { + refreshVersions(); + refreshDetails(); + }; + window.addEventListener("saveSuccessInModal", handleSaveSuccess); + return () => { + window.removeEventListener("saveSuccessInModal", handleSaveSuccess); + }; + }, [refreshVersions, refreshDetails]); + + // 품목 검색 + const handleSearch = useCallback(() => { + fetchItems(searchText || undefined); + }, [fetchItems, searchText]); + + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleSearch(); + }, + [handleSearch] + ); + + // 버전 추가 모달 + const handleAddVersion = useCallback(() => { + if (!selectedItemCode) { + toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" }); + return; + } + const screenId = config.modals.versionAddScreenId; + if (!screenId) return; + + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId, + urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable }, + splitPanelParentData: { + [config.dataSource.routingVersionFkColumn]: selectedItemCode, + }, + }, + }) + ); + }, [selectedItemCode, config, toast]); + + // 공정 추가 모달 + const handleAddProcess = useCallback(() => { + if (!selectedVersionId) { + toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" }); + return; + } + const screenId = config.modals.processAddScreenId; + if (!screenId) return; + + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId, + urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable }, + splitPanelParentData: { + [config.dataSource.routingDetailFkColumn]: selectedVersionId, + }, + }, + }) + ); + }, [selectedVersionId, config, toast]); + + // 공정 수정 모달 + const handleEditProcess = useCallback( + (detail: Record) => { + const screenId = config.modals.processEditScreenId; + if (!screenId) return; + + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId, + urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable }, + editData: detail, + }, + }) + ); + }, + [config] + ); + + // 삭제 확인 + const handleConfirmDelete = useCallback(async () => { + if (!deleteTarget) return; + + let success = false; + if (deleteTarget.type === "version") { + success = await deleteVersion(deleteTarget.id); + } else { + success = await deleteDetail(deleteTarget.id); + } + + if (success) { + toast({ title: `${deleteTarget.name} 삭제 완료` }); + } else { + toast({ title: "삭제 실패", variant: "destructive" }); + } + setDeleteTarget(null); + }, [deleteTarget, deleteVersion, deleteDetail, toast]); + + // entity join으로 가져온 공정명 컬럼 이름 추정 + const processNameKey = useMemo(() => { + const ds = config.dataSource; + return `${ds.processTable}_${ds.processNameColumn}`; + }, [config.dataSource]); + + const splitRatio = config.splitRatio || 40; + + if (isPreview) { + return ( +
+
+ +

+ 품목별 라우팅 관리 +

+

+ 품목 선택 - 라우팅 버전 - 공정 순서 +

+
+
+ ); + } + + return ( +
+
+ {/* 좌측 패널: 품목 목록 */} +
+
+

+ {config.leftPanelTitle || "품목 목록"} +

+
+ + {/* 검색 */} +
+ setSearchText(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="품목명/품번 검색" + className="h-8 text-xs" + /> + +
+ + {/* 품목 리스트 */} +
+ {items.length === 0 ? ( +
+

+ {loading ? "로딩 중..." : "품목이 없습니다"} +

+
+ ) : ( +
+ {items.map((item) => { + const itemCode = + item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number; + const itemName = + item[config.dataSource.itemNameColumn] || item.item_name; + const isSelected = selectedItemCode === itemCode; + + return ( + + ); + })} +
+ )} +
+
+ + {/* 우측 패널: 버전 + 공정 */} +
+ {selectedItemCode ? ( + <> + {/* 헤더: 선택된 품목 + 버전 추가 */} +
+
+

{selectedItemName}

+

{selectedItemCode}

+
+ {!config.readonly && ( + + )} +
+ + {/* 버전 선택 버튼들 */} + {versions.length > 0 ? ( +
+ 버전: + {versions.map((ver) => { + const isActive = selectedVersionId === ver.id; + return ( +
+ selectVersion(ver.id)} + > + {ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id} + + {!config.readonly && ( + + )} +
+ ); + })} +
+ ) : ( +
+

+ 라우팅 버전이 없습니다. 버전을 추가해주세요. +

+
+ )} + + {/* 공정 테이블 */} + {selectedVersionId ? ( +
+ {/* 공정 테이블 헤더 */} +
+

+ {config.rightPanelTitle || "공정 순서"} ({details.length}건) +

+ {!config.readonly && ( + + )} +
+ + {/* 테이블 */} +
+ {details.length === 0 ? ( +
+

+ {loading ? "로딩 중..." : "등록된 공정이 없습니다"} +

+
+ ) : ( + + + + {config.processColumns.map((col) => ( + + {col.label} + + ))} + {!config.readonly && ( + + 관리 + + )} + + + + {details.map((detail) => ( + + {config.processColumns.map((col) => { + let cellValue = detail[col.name]; + if ( + col.name === "process_code" && + detail[processNameKey] + ) { + cellValue = `${detail[col.name]} (${detail[processNameKey]})`; + } + return ( + + {cellValue ?? "-"} + + ); + })} + {!config.readonly && ( + +
+ + +
+
+ )} +
+ ))} +
+
+ )} +
+
+ ) : ( + versions.length > 0 && ( +
+

+ 라우팅 버전을 선택해주세요 +

+
+ ) + )} + + ) : ( +
+ +

+ 좌측에서 품목을 선택하세요 +

+

+ 품목을 선택하면 라우팅 버전별 공정 순서를 관리할 수 있습니다 +

+
+ )} +
+
+ + {/* 삭제 확인 다이얼로그 */} + setDeleteTarget(null)}> + + + 삭제 확인 + + {deleteTarget?.name}을(를) 삭제하시겠습니까? + {deleteTarget?.type === "version" && ( + <> +
+ 해당 버전에 포함된 모든 공정 정보도 함께 삭제됩니다. + + )} +
+
+ + 취소 + + 삭제 + + +
+
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingConfigPanel.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingConfigPanel.tsx new file mode 100644 index 00000000..f6fefd2e --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingConfigPanel.tsx @@ -0,0 +1,780 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Plus, Trash2, Check, ChevronsUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { ItemRoutingConfig, ProcessColumnDef } from "./types"; +import { defaultConfig } from "./config"; + +interface TableInfo { + tableName: string; + displayName?: string; +} + +interface ColumnInfo { + columnName: string; + displayName?: string; + dataType?: string; +} + +interface ScreenInfo { + screenId: number; + screenName: string; + screenCode: string; +} + +// 테이블 셀렉터 Combobox +function TableSelector({ + value, + onChange, + tables, + loading, +}: { + value: string; + onChange: (v: string) => void; + tables: TableInfo[]; + loading: boolean; +}) { + const [open, setOpen] = useState(false); + const selected = tables.find((t) => t.tableName === value); + + return ( + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((t) => ( + { + onChange(t.tableName); + setOpen(false); + }} + className="text-xs" + > + +
+ + {t.displayName || t.tableName} + + {t.displayName && ( + + {t.tableName} + + )} +
+
+ ))} +
+
+
+
+
+ ); +} + +// 컬럼 셀렉터 Combobox +function ColumnSelector({ + value, + onChange, + tableName, + label, +}: { + value: string; + onChange: (v: string) => void; + tableName: string; + label?: string; +}) { + const [open, setOpen] = useState(false); + const [columns, setColumns] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!tableName) { + setColumns([]); + return; + } + const load = async () => { + setLoading(true); + try { + const { tableManagementApi } = await import( + "@/lib/api/tableManagement" + ); + const res = await tableManagementApi.getColumnList(tableName); + if (res.success && res.data?.columns) { + setColumns(res.data.columns); + } + } catch { + /* ignore */ + } finally { + setLoading(false); + } + }; + load(); + }, [tableName]); + + const selected = columns.find((c) => c.columnName === value); + + return ( + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {columns.map((c) => ( + { + onChange(c.columnName); + setOpen(false); + }} + className="text-xs" + > + +
+ + {c.displayName || c.columnName} + + {c.displayName && ( + + {c.columnName} + + )} +
+
+ ))} +
+
+
+
+
+ ); +} + +// 화면 셀렉터 Combobox +function ScreenSelector({ + value, + onChange, +}: { + value?: number; + onChange: (v?: number) => void; +}) { + const [open, setOpen] = useState(false); + const [screens, setScreens] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const load = async () => { + setLoading(true); + try { + const { screenApi } = await import("@/lib/api/screen"); + const res = await screenApi.getScreens({ page: 1, size: 1000 }); + setScreens( + res.data.map((s: any) => ({ + screenId: s.screenId, + screenName: s.screenName, + screenCode: s.screenCode, + })) + ); + } catch { + /* ignore */ + } finally { + setLoading(false); + } + }; + load(); + }, []); + + const selected = screens.find((s) => s.screenId === value); + + return ( + + + + + + + + + + 화면을 찾을 수 없습니다. + + + {screens.map((s) => ( + { + onChange(s.screenId === value ? undefined : s.screenId); + setOpen(false); + }} + className="text-xs" + > + +
+ {s.screenName} + + {s.screenCode} (ID: {s.screenId}) + +
+
+ ))} +
+
+
+
+
+ ); +} + +// 공정 테이블 컬럼 셀렉터 (routingDetailTable의 컬럼 목록에서 선택) +function ProcessColumnSelector({ + value, + onChange, + tableName, + processTable, +}: { + value: string; + onChange: (v: string) => void; + tableName: string; + processTable: string; +}) { + const [open, setOpen] = useState(false); + const [columns, setColumns] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const loadAll = async () => { + if (!tableName) return; + setLoading(true); + try { + const { tableManagementApi } = await import( + "@/lib/api/tableManagement" + ); + const res = await tableManagementApi.getColumnList(tableName); + const cols: ColumnInfo[] = []; + if (res.success && res.data?.columns) { + cols.push(...res.data.columns); + } + if (processTable && processTable !== tableName) { + const res2 = await tableManagementApi.getColumnList(processTable); + if (res2.success && res2.data?.columns) { + cols.push( + ...res2.data.columns.map((c: any) => ({ + ...c, + columnName: c.columnName, + displayName: `[${processTable}] ${c.displayName || c.columnName}`, + })) + ); + } + } + setColumns(cols); + } catch { + /* ignore */ + } finally { + setLoading(false); + } + }; + loadAll(); + }, [tableName, processTable]); + + const selected = columns.find((c) => c.columnName === value); + + return ( + + + + + + + + + + 없음 + + + {columns.map((c) => ( + { + onChange(c.columnName); + setOpen(false); + }} + className="text-xs" + > + + {c.displayName || c.columnName} + + ))} + + + + + + ); +} + +interface ConfigPanelProps { + config: Partial; + onChange: (config: Partial) => void; +} + +export function ItemRoutingConfigPanel({ + config: configProp, + onChange, +}: ConfigPanelProps) { + const config: ItemRoutingConfig = { + ...defaultConfig, + ...configProp, + dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource }, + modals: { ...defaultConfig.modals, ...configProp?.modals }, + processColumns: configProp?.processColumns?.length + ? configProp.processColumns + : defaultConfig.processColumns, + }; + + const [allTables, setAllTables] = useState([]); + const [tablesLoading, setTablesLoading] = useState(false); + + useEffect(() => { + const load = async () => { + setTablesLoading(true); + try { + const { tableManagementApi } = await import( + "@/lib/api/tableManagement" + ); + const res = await tableManagementApi.getTableList(); + if (res.success && res.data) { + setAllTables(res.data); + } + } catch { + /* ignore */ + } finally { + setTablesLoading(false); + } + }; + load(); + }, []); + + const update = (partial: Partial) => { + onChange({ ...configProp, ...partial }); + }; + + const updateDataSource = (field: string, value: string) => { + update({ dataSource: { ...config.dataSource, [field]: value } }); + }; + + const updateModals = (field: string, value: number | undefined) => { + update({ modals: { ...config.modals, [field]: value } }); + }; + + // 컬럼 관리 + const addColumn = () => { + update({ + processColumns: [ + ...config.processColumns, + { name: "", label: "새 컬럼", width: 100 }, + ], + }); + }; + + const removeColumn = (idx: number) => { + update({ + processColumns: config.processColumns.filter((_, i) => i !== idx), + }); + }; + + const updateColumn = ( + idx: number, + field: keyof ProcessColumnDef, + value: any + ) => { + const next = [...config.processColumns]; + next[idx] = { ...next[idx], [field]: value }; + update({ processColumns: next }); + }; + + return ( +
+

품목별 라우팅 설정

+ + {/* 데이터 소스 설정 */} +
+

+ 데이터 소스 +

+ +
+ + updateDataSource("itemTable", v)} + tables={allTables} + loading={tablesLoading} + /> +
+
+
+ + updateDataSource("itemNameColumn", v)} + tableName={config.dataSource.itemTable} + label="품목명" + /> +
+
+ + updateDataSource("itemCodeColumn", v)} + tableName={config.dataSource.itemTable} + label="품목코드" + /> +
+
+ +
+ + updateDataSource("routingVersionTable", v)} + tables={allTables} + loading={tablesLoading} + /> +
+
+
+ + updateDataSource("routingVersionFkColumn", v)} + tableName={config.dataSource.routingVersionTable} + label="FK 컬럼" + /> +
+
+ + + updateDataSource("routingVersionNameColumn", v) + } + tableName={config.dataSource.routingVersionTable} + label="버전명" + /> +
+
+ +
+ + updateDataSource("routingDetailTable", v)} + tables={allTables} + loading={tablesLoading} + /> +
+
+ + updateDataSource("routingDetailFkColumn", v)} + tableName={config.dataSource.routingDetailTable} + label="FK 컬럼" + /> +
+ +
+ + updateDataSource("processTable", v)} + tables={allTables} + loading={tablesLoading} + /> +
+
+
+ + updateDataSource("processNameColumn", v)} + tableName={config.dataSource.processTable} + label="공정명" + /> +
+
+ + updateDataSource("processCodeColumn", v)} + tableName={config.dataSource.processTable} + label="공정코드" + /> +
+
+
+ + {/* 모달 설정 */} +
+

모달 연동

+ +
+ + updateModals("versionAddScreenId", v)} + /> +
+
+ + updateModals("processAddScreenId", v)} + /> +
+
+ + updateModals("processEditScreenId", v)} + /> +
+
+ + {/* 공정 테이블 컬럼 설정 */} +
+
+

+ 공정 테이블 컬럼 +

+ +
+ +
+ {config.processColumns.map((col, idx) => ( +
+ updateColumn(idx, "name", v)} + tableName={config.dataSource.routingDetailTable} + processTable={config.dataSource.processTable} + /> + updateColumn(idx, "label", e.target.value)} + className="h-7 flex-1 text-[10px]" + placeholder="표시명" + /> + + updateColumn( + idx, + "width", + e.target.value ? Number(e.target.value) : undefined + ) + } + className="h-7 w-14 text-[10px]" + placeholder="너비" + /> + +
+ ))} +
+
+ + {/* UI 설정 */} +
+

UI 설정

+ +
+ + update({ splitRatio: Number(e.target.value) })} + min={20} + max={60} + className="mt-1 h-8 w-20 text-xs" + /> +
+ +
+ + update({ leftPanelTitle: e.target.value })} + className="mt-1 h-8 text-xs" + /> +
+
+ + update({ rightPanelTitle: e.target.value })} + className="mt-1 h-8 text-xs" + /> +
+ +
+ + update({ versionAddButtonText: e.target.value })} + className="mt-1 h-8 text-xs" + /> +
+
+ + update({ processAddButtonText: e.target.value })} + className="mt-1 h-8 text-xs" + /> +
+ +
+ update({ autoSelectFirstVersion: v })} + /> + +
+ +
+ update({ readonly: v })} + /> + +
+
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx new file mode 100644 index 00000000..7a9fa624 --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx @@ -0,0 +1,32 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2ItemRoutingDefinition } from "./index"; +import { ItemRoutingComponent } from "./ItemRoutingComponent"; + +export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2ItemRoutingDefinition; + + render(): React.ReactElement { + const { formData, isPreview, config, tableName } = this.props as Record< + string, + unknown + >; + + return ( + } + tableName={tableName as string} + isPreview={isPreview as boolean} + /> + ); + } +} + +ItemRoutingRenderer.registerSelf(); + +if (process.env.NODE_ENV === "development") { + ItemRoutingRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/v2-item-routing/config.ts b/frontend/lib/registry/components/v2-item-routing/config.ts new file mode 100644 index 00000000..a84ff23e --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/config.ts @@ -0,0 +1,38 @@ +import { ItemRoutingConfig } from "./types"; + +export const defaultConfig: ItemRoutingConfig = { + dataSource: { + itemTable: "item_info", + itemNameColumn: "item_name", + itemCodeColumn: "item_number", + routingVersionTable: "item_routing_version", + routingVersionFkColumn: "item_code", + routingVersionNameColumn: "version_name", + routingDetailTable: "item_routing_detail", + routingDetailFkColumn: "routing_version_id", + processTable: "process_mng", + processNameColumn: "process_name", + processCodeColumn: "process_code", + }, + modals: { + versionAddScreenId: 1613, + processAddScreenId: 1614, + processEditScreenId: 1615, + }, + processColumns: [ + { name: "seq_no", label: "순서", width: 60, align: "center" }, + { name: "process_code", label: "공정코드", width: 120 }, + { name: "work_type", label: "작업유형", width: 100 }, + { name: "standard_time", label: "표준시간(분)", width: 100, align: "right" }, + { name: "is_required", label: "필수여부", width: 80, align: "center" }, + { name: "is_fixed_order", label: "순서고정", width: 80, align: "center" }, + { name: "outsource_supplier", label: "외주업체", width: 120 }, + ], + splitRatio: 40, + leftPanelTitle: "품목 목록", + rightPanelTitle: "공정 순서", + readonly: false, + autoSelectFirstVersion: true, + versionAddButtonText: "+ 라우팅 버전 추가", + processAddButtonText: "+ 공정 추가", +}; diff --git a/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts b/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts new file mode 100644 index 00000000..c53dd3a9 --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/hooks/useItemRouting.ts @@ -0,0 +1,239 @@ +"use client"; + +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { apiClient } from "@/lib/api/client"; +import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData } from "../types"; +import { defaultConfig } from "../config"; + +const API_BASE = "/process-work-standard"; + +export function useItemRouting(configPartial: Partial) { + const configKey = useMemo( + () => JSON.stringify(configPartial), + [configPartial] + ); + + const config: ItemRoutingConfig = useMemo(() => ({ + ...defaultConfig, + ...configPartial, + dataSource: { ...defaultConfig.dataSource, ...configPartial?.dataSource }, + modals: { ...defaultConfig.modals, ...configPartial?.modals }, + processColumns: configPartial?.processColumns?.length + ? configPartial.processColumns + : defaultConfig.processColumns, + }), [configKey]); + + const configRef = useRef(config); + configRef.current = config; + + const [items, setItems] = useState([]); + const [versions, setVersions] = useState([]); + const [details, setDetails] = useState([]); + const [loading, setLoading] = useState(false); + + // 선택 상태 + const [selectedItemCode, setSelectedItemCode] = useState(null); + const [selectedItemName, setSelectedItemName] = useState(null); + const [selectedVersionId, setSelectedVersionId] = useState(null); + + // 품목 목록 조회 + const fetchItems = useCallback( + async (search?: string) => { + try { + setLoading(true); + const ds = configRef.current.dataSource; + const params = new URLSearchParams({ + tableName: ds.itemTable, + nameColumn: ds.itemNameColumn, + codeColumn: ds.itemCodeColumn, + routingTable: ds.routingVersionTable, + routingFkColumn: ds.routingVersionFkColumn, + ...(search ? { search } : {}), + }); + const res = await apiClient.get(`${API_BASE}/items?${params}`); + if (res.data?.success) { + setItems(res.data.data || []); + } + } catch (err) { + console.error("품목 조회 실패", err); + } finally { + setLoading(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [configKey] + ); + + // 라우팅 버전 목록 조회 + const fetchVersions = useCallback( + async (itemCode: string) => { + try { + const ds = configRef.current.dataSource; + const params = new URLSearchParams({ + routingVersionTable: ds.routingVersionTable, + routingDetailTable: ds.routingDetailTable, + routingFkColumn: ds.routingVersionFkColumn, + processTable: ds.processTable, + processNameColumn: ds.processNameColumn, + processCodeColumn: ds.processCodeColumn, + }); + const res = await apiClient.get( + `${API_BASE}/items/${encodeURIComponent(itemCode)}/routings?${params}` + ); + if (res.data?.success) { + const routingData = res.data.data || []; + setVersions(routingData); + return routingData; + } + } catch (err) { + console.error("라우팅 버전 조회 실패", err); + } + return []; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [configKey] + ); + + // 공정 상세 목록 조회 (특정 버전의 공정들) + const fetchDetails = useCallback( + async (versionId: string) => { + try { + setLoading(true); + const ds = configRef.current.dataSource; + const res = await apiClient.get("/table-data/entity-join-api/data-with-joins", { + params: { + tableName: ds.routingDetailTable, + searchConditions: JSON.stringify({ + [ds.routingDetailFkColumn]: { + value: versionId, + operator: "equals", + }, + }), + sortColumn: "seq_no", + sortDirection: "ASC", + }, + }); + if (res.data?.success) { + setDetails(res.data.data || []); + } + } catch (err) { + console.error("공정 상세 조회 실패", err); + } finally { + setLoading(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [configKey] + ); + + // 품목 선택 + const selectItem = useCallback( + async (itemCode: string, itemName: string) => { + setSelectedItemCode(itemCode); + setSelectedItemName(itemName); + setSelectedVersionId(null); + setDetails([]); + + const versionList = await fetchVersions(itemCode); + + // 첫번째 버전 자동 선택 + if (config.autoSelectFirstVersion && versionList.length > 0) { + const firstVersion = versionList[0]; + setSelectedVersionId(firstVersion.id); + await fetchDetails(firstVersion.id); + } + }, + [fetchVersions, fetchDetails, config.autoSelectFirstVersion] + ); + + // 버전 선택 + const selectVersion = useCallback( + async (versionId: string) => { + setSelectedVersionId(versionId); + await fetchDetails(versionId); + }, + [fetchDetails] + ); + + // 모달에서 데이터 변경 후 새로고침 + const refreshVersions = useCallback(async () => { + if (selectedItemCode) { + const versionList = await fetchVersions(selectedItemCode); + if (selectedVersionId) { + await fetchDetails(selectedVersionId); + } else if (versionList.length > 0) { + const lastVersion = versionList[versionList.length - 1]; + setSelectedVersionId(lastVersion.id); + await fetchDetails(lastVersion.id); + } + } + }, [selectedItemCode, selectedVersionId, fetchVersions, fetchDetails]); + + const refreshDetails = useCallback(async () => { + if (selectedVersionId) { + await fetchDetails(selectedVersionId); + } + }, [selectedVersionId, fetchDetails]); + + // 공정 삭제 + const deleteDetail = useCallback( + async (detailId: string) => { + try { + const ds = configRef.current.dataSource; + const res = await apiClient.delete( + `/table-data/${ds.routingDetailTable}/${detailId}` + ); + if (res.data?.success) { + await refreshDetails(); + return true; + } + } catch (err) { + console.error("공정 삭제 실패", err); + } + return false; + }, + [refreshDetails] + ); + + // 버전 삭제 + const deleteVersion = useCallback( + async (versionId: string) => { + try { + const ds = configRef.current.dataSource; + const res = await apiClient.delete( + `/table-data/${ds.routingVersionTable}/${versionId}` + ); + if (res.data?.success) { + if (selectedVersionId === versionId) { + setSelectedVersionId(null); + setDetails([]); + } + await refreshVersions(); + return true; + } + } catch (err) { + console.error("버전 삭제 실패", err); + } + return false; + }, + [selectedVersionId, refreshVersions] + ); + + return { + config, + items, + versions, + details, + loading, + selectedItemCode, + selectedItemName, + selectedVersionId, + fetchItems, + selectItem, + selectVersion, + refreshVersions, + refreshDetails, + deleteDetail, + deleteVersion, + }; +} diff --git a/frontend/lib/registry/components/v2-item-routing/index.ts b/frontend/lib/registry/components/v2-item-routing/index.ts new file mode 100644 index 00000000..1ccd3c7a --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/index.ts @@ -0,0 +1,57 @@ +"use client"; + +import React from "react"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { ItemRoutingComponent } from "./ItemRoutingComponent"; +import { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel"; +import { defaultConfig } from "./config"; + +export const V2ItemRoutingDefinition = createComponentDefinition({ + id: "v2-item-routing", + name: "품목별 라우팅", + nameEng: "Item Routing", + description: "품목별 라우팅 버전과 공정 순서를 관리하는 3단계 계층 컴포넌트", + category: ComponentCategory.INPUT, + webType: "component", + component: ItemRoutingComponent, + defaultConfig: defaultConfig, + defaultSize: { + width: 1400, + height: 800, + gridColumnSpan: "12", + }, + configPanel: ItemRoutingConfigPanel, + icon: "ListOrdered", + tags: ["라우팅", "공정", "품목", "버전", "제조", "생산"], + version: "1.0.0", + author: "개발팀", + documentation: ` +품목별 라우팅 버전과 공정 순서를 관리하는 전용 컴포넌트입니다. + +## 주요 기능 +- 좌측: 품목 목록 검색 및 선택 +- 우측 상단: 라우팅 버전 선택 (Badge 버튼) 및 추가/삭제 +- 우측 하단: 선택된 버전의 공정 순서 테이블 (추가/수정/삭제) +- 기존 모달 화면 재활용 (1613, 1614, 1615) + +## 커스터마이징 +- 데이터 소스 테이블/컬럼 변경 가능 +- 모달 화면 ID 변경 가능 +- 공정 테이블 컬럼 추가/삭제 가능 +- 좌우 분할 비율, 패널 제목, 버튼 텍스트 변경 가능 +- 읽기 전용 모드 지원 + `, +}); + +export type { + ItemRoutingConfig, + ItemRoutingComponentProps, + ItemRoutingDataSource, + ItemRoutingModals, + ProcessColumnDef, +} from "./types"; + +export { ItemRoutingComponent } from "./ItemRoutingComponent"; +export { ItemRoutingRenderer } from "./ItemRoutingRenderer"; +export { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel"; diff --git a/frontend/lib/registry/components/v2-item-routing/types.ts b/frontend/lib/registry/components/v2-item-routing/types.ts new file mode 100644 index 00000000..e5b1aa38 --- /dev/null +++ b/frontend/lib/registry/components/v2-item-routing/types.ts @@ -0,0 +1,77 @@ +/** + * 품목별 라우팅 관리 컴포넌트 타입 정의 + * + * 3단계 계층: item_info → item_routing_version → item_routing_detail + */ + +// 데이터 소스 설정 +export interface ItemRoutingDataSource { + itemTable: string; + itemNameColumn: string; + itemCodeColumn: string; + routingVersionTable: string; + routingVersionFkColumn: string; // item_routing_version에서 item_code를 가리키는 FK + routingVersionNameColumn: string; + routingDetailTable: string; + routingDetailFkColumn: string; // item_routing_detail에서 routing_version_id를 가리키는 FK + processTable: string; + processNameColumn: string; + processCodeColumn: string; +} + +// 모달 연동 설정 +export interface ItemRoutingModals { + versionAddScreenId?: number; + processAddScreenId?: number; + processEditScreenId?: number; +} + +// 공정 테이블 컬럼 정의 +export interface ProcessColumnDef { + name: string; + label: string; + width?: number; + align?: "left" | "center" | "right"; +} + +// 전체 Config +export interface ItemRoutingConfig { + dataSource: ItemRoutingDataSource; + modals: ItemRoutingModals; + processColumns: ProcessColumnDef[]; + splitRatio?: number; + leftPanelTitle?: string; + rightPanelTitle?: string; + readonly?: boolean; + autoSelectFirstVersion?: boolean; + versionAddButtonText?: string; + processAddButtonText?: string; +} + +// 컴포넌트 Props +export interface ItemRoutingComponentProps { + config: Partial; + formData?: Record; + isPreview?: boolean; + tableName?: string; +} + +// 데이터 모델 +export interface ItemData { + id: string; + [key: string]: any; +} + +export interface RoutingVersionData { + id: string; + version_name: string; + [key: string]: any; +} + +export interface RoutingDetailData { + id: string; + routing_version_id: string; + seq_no: string; + process_code: string; + [key: string]: any; +} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index f56b0fb3..3458c1df 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1241,7 +1241,7 @@ export const SplitPanelLayoutComponent: React.FC const searchConditions: Record = {}; keys?.forEach((key: any) => { if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) { - searchConditions[key.rightColumn] = originalItem[key.leftColumn]; + searchConditions[key.rightColumn] = { value: originalItem[key.leftColumn], operator: "equals" }; } }); @@ -1271,11 +1271,11 @@ export const SplitPanelLayoutComponent: React.FC // 복합키: 여러 조건으로 필터링 const { entityJoinApi } = await import("@/lib/api/entityJoin"); - // 복합키 조건 생성 + // 복합키 조건 생성 (FK 필터링이므로 equals 연산자 사용) const searchConditions: Record = {}; keys.forEach((key) => { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { - searchConditions[key.rightColumn] = leftItem[key.leftColumn]; + searchConditions[key.rightColumn] = { value: leftItem[key.leftColumn], operator: "equals" }; } }); @@ -2035,20 +2035,47 @@ export const SplitPanelLayoutComponent: React.FC : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addButton; if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) { - // 커스텀 모달 화면 열기 + if (!selectedLeftItem) { + toast({ + title: "항목을 선택해주세요", + description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.", + variant: "destructive", + }); + return; + } + const currentTableName = activeTabIndex === 0 ? componentConfig.rightPanel?.tableName || "" : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || ""; - // 좌측 선택 데이터를 modalDataStore에 저장 (추가 화면에서 참조 가능) + // 좌측 선택 데이터를 modalDataStore에 저장 if (selectedLeftItem && componentConfig.leftPanel?.tableName) { import("@/stores/modalDataStore").then(({ useModalDataStore }) => { useModalDataStore.getState().setData(componentConfig.leftPanel!.tableName!, [selectedLeftItem]); }); } - // ScreenModal 열기 이벤트 발생 + // relation.keys에서 FK 데이터 추출 + const parentData: Record = {}; + const relation = activeTabIndex === 0 + ? componentConfig.rightPanel?.relation + : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation; + + if (relation?.keys && Array.isArray(relation.keys)) { + for (const key of relation.keys) { + if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) { + parentData[key.rightColumn] = selectedLeftItem[key.leftColumn]; + } + } + } else if (relation) { + const leftColumn = relation.leftColumn; + const rightColumn = relation.foreignKey || relation.rightColumn; + if (leftColumn && rightColumn && selectedLeftItem[leftColumn] != null) { + parentData[rightColumn] = selectedLeftItem[leftColumn]; + } + } + window.dispatchEvent( new CustomEvent("openScreenModal", { detail: { @@ -2056,19 +2083,8 @@ export const SplitPanelLayoutComponent: React.FC urlParams: { mode: "add", tableName: currentTableName, - // 좌측 선택 항목의 연결 키 값 전달 - ...(selectedLeftItem && (() => { - const relation = activeTabIndex === 0 - ? componentConfig.rightPanel?.relation - : (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation; - const leftColumn = relation?.keys?.[0]?.leftColumn || relation?.leftColumn; - const rightColumn = relation?.keys?.[0]?.rightColumn || relation?.foreignKey; - if (leftColumn && rightColumn && selectedLeftItem[leftColumn] !== undefined) { - return { [rightColumn]: selectedLeftItem[leftColumn] }; - } - return {}; - })()), }, + splitPanelParentData: parentData, }, }), ); @@ -2076,6 +2092,7 @@ export const SplitPanelLayoutComponent: React.FC console.log("✅ [SplitPanel] 추가 모달 화면 열기:", { screenId: addButtonConfig.modalScreenId, tableName: currentTableName, + parentData, }); return; } -- 2.43.0 From 969b53637a4749cc25769d70435eff964f8a6bb7 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 24 Feb 2026 15:28:42 +0900 Subject: [PATCH 6/7] Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node -- 2.43.0 From 9a85343166ecd22baf9162a52149cf2a1225b5bb Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 24 Feb 2026 15:28:59 +0900 Subject: [PATCH 7/7] Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node -- 2.43.0