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"),