Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node

This commit is contained in:
DDD1542 2026-02-24 09:29:44 +09:00
parent 9614ce3973
commit 4e422fc477
20 changed files with 1233 additions and 92 deletions

View File

@ -164,6 +164,7 @@ export const componentDefaults: Record<string, any> = {
"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 },
};
/**

View File

@ -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 },

View File

@ -1019,6 +1019,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<ActiveTabProvider>
<TableOptionsProvider>
<div
data-screen-runtime="true"
className="relative bg-white"
style={{
width: `${screenDimensions?.width || 800}px`,

View File

@ -1372,6 +1372,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div>
) : screenData ? (
<div
data-screen-runtime="true"
className="relative bg-white"
style={{
width: screenDimensions?.width || 800,

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import React, { useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -20,6 +20,11 @@ import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator";
import {
subscribe as canvasSplitSubscribe,
getSnapshot as canvasSplitGetSnapshot,
getServerSnapshot as canvasSplitGetServerSnapshot,
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer";
@ -82,9 +87,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
parentTabId,
parentTabsComponentId,
}) => {
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<string | null>(null);
// 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서)
const userName = externalUserName || authUserName;
@ -1079,22 +1089,96 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
// 캔버스 분할선에 따른 X 위치 조정 (너비는 변경하지 않음 - 내부 컴포넌트 깨짐 방지)
const calculateCanvasSplitX = (): number => {
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 (
<>
<div className="absolute" style={componentStyle}>
<div id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */}
{renderInteractiveWidget(component)}
</div>

View File

@ -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<RealtimePreviewProps> = ({
}
: 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<RealtimePreviewProps> = ({
(["button-primary", "button-secondary"].includes(componentType) ||
["button-primary", "button-secondary"].includes(componentId)));
// 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점)
// 레거시 분할 패널용 refs
const initialPanelRatioRef = React.useRef<number | null>(null);
const initialPanelIdRef = React.useRef<string | null>(null);
// 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지)
const isInLeftPanelRef = React.useRef<boolean | null>(null);
// 🆕 분할 패널 위 버튼 위치 자동 조정
const calculateButtonPosition = () => {
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
// 캔버스 분할선 좌/우 판정 (한 번만)
const canvasSplitSideRef = React.useRef<"left" | "right" | null>(null);
// 스코프 체크 캐시 (DOM 쿼리 최소화)
const myScopeIdRef = React.useRef<string | null>(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<RealtimePreviewProps> = ({
};
};
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,
};
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)

View File

@ -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 트리 뷰
/**
*

View File

@ -26,6 +26,8 @@ export interface SplitPanelInfo {
initialLeftWidthPercent: number;
// 드래그 중 여부
isDragging: boolean;
// 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정)
panelType?: "component" | "canvas";
}
export interface SplitPanelResizeContextValue {

View File

@ -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<string, any>;
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<BomHeaderInfo | null>(null);
const [treeData, setTreeData] = useState<BomTreeNode[]>([]);
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(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<string>(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<string, BomTreeNode>();
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<string>();
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 (
<div className="flex h-full flex-col rounded-md border bg-white p-4">
<div className="mb-3 flex items-center gap-2">
<Layers className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">BOM </span>
</div>
<div className="flex-1 space-y-1 rounded border border-dashed border-gray-300 p-3">
<div className="flex items-center gap-2 text-xs text-gray-500">
<ChevronDown className="h-3 w-3" />
<Package className="h-3 w-3 text-blue-500" />
<span> A ()</span>
<span className="ml-auto text-gray-400">수량: 1</span>
</div>
<div className="ml-5 flex items-center gap-2 text-xs text-gray-500">
<ChevronRight className="h-3 w-3" />
<Layers className="h-3 w-3 text-amber-500" />
<span> B ()</span>
<span className="ml-auto text-gray-400">수량: 2</span>
</div>
<div className="ml-5 flex items-center gap-2 text-xs text-gray-500">
<span className="ml-3.5" />
<Box className="h-3 w-3 text-emerald-500" />
<span> C ()</span>
<span className="ml-auto text-gray-400">수량: 5</span>
</div>
</div>
</div>
);
}
// 선택 안 된 상태
if (!selectedBomId) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-center text-sm">
<p className="mb-2"> BOM을 </p>
<p className="text-xs"> BOM의 </p>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* 헤더 정보 */}
{headerInfo && (
<div className="border-b bg-gray-50/80 px-4 py-3">
<div className="mb-2 flex items-center gap-2">
<Package className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{headerInfo.item_name || "-"}</h3>
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{headerInfo.bom_number || "-"}
</span>
<span className={cn(
"ml-1 rounded px-1.5 py-0.5 text-[10px] font-medium",
headerInfo.status === "active" ? "bg-emerald-100 text-emerald-700" : "bg-gray-100 text-gray-500"
)}>
{headerInfo.status === "active" ? "사용" : "미사용"}
</span>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>: <b className="text-foreground">{headerInfo.item_code || "-"}</b></span>
<span>: <b className="text-foreground">{getItemTypeLabel(headerInfo.item_type)}</b></span>
<span>: <b className="text-foreground">{headerInfo.base_qty || "1"} {headerInfo.unit || ""}</b></span>
<span>: <b className="text-foreground">v{headerInfo.version || "1.0"} ( {headerInfo.revision || "1"})</b></span>
</div>
</div>
)}
{/* 트리 툴바 */}
<div className="flex items-center gap-2 border-b px-4 py-2">
<span className="text-xs font-medium text-muted-foreground">BOM </span>
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{treeData.length}
</span>
<div className="ml-auto flex gap-1">
<button
onClick={expandAll}
className="rounded px-2 py-0.5 text-[10px] text-muted-foreground hover:bg-gray-100"
>
</button>
<button
onClick={collapseAll}
className="rounded px-2 py-0.5 text-[10px] text-muted-foreground hover:bg-gray-100"
>
</button>
</div>
</div>
{/* 트리 컨텐츠 */}
<div className="flex-1 overflow-auto px-2 py-2">
{loading ? (
<div className="flex h-32 items-center justify-center">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
) : treeData.length === 0 ? (
<div className="flex h-32 flex-col items-center justify-center gap-2">
<AlertCircle className="h-5 w-5 text-muted-foreground" />
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-0.5">
{treeData.map((node) => (
<TreeNodeRow
key={node.id}
node={node}
depth={0}
expandedNodes={expandedNodes}
selectedNodeId={selectedNodeId}
onToggle={toggleNode}
onSelect={setSelectedNodeId}
getItemTypeLabel={getItemTypeLabel}
getItemTypeStyle={getItemTypeStyle}
/>
))}
</div>
)}
</div>
</div>
);
}
/**
* ( )
*/
interface TreeNodeRowProps {
node: BomTreeNode;
depth: number;
expandedNodes: Set<string>;
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 (
<>
<div
className={cn(
"group flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 transition-colors",
isSelected ? "bg-primary/10" : "hover:bg-gray-50"
)}
style={{ paddingLeft: `${depth * 20 + 8}px` }}
onClick={() => {
onSelect(node.id);
if (hasChildren) onToggle(node.id);
}}
>
{/* 펼치기/접기 화살표 */}
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
{hasChildren ? (
isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 text-gray-400" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-gray-400" />
)
) : (
<span className="h-1 w-1 rounded-full bg-gray-300" />
)}
</span>
{/* 품목 타입 아이콘 */}
<span className={cn("flex h-5 w-5 flex-shrink-0 items-center justify-center rounded", style.bg)}>
<ItemIcon className={cn("h-3 w-3", style.color)} />
</span>
{/* 품목 정보 */}
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">
{node.child_item_name || "-"}
</span>
<span className="flex-shrink-0 text-[10px] text-muted-foreground">
{node.child_item_code || ""}
</span>
<span className={cn(
"flex-shrink-0 rounded px-1 py-0.5 text-[10px]",
style.bg, style.color
)}>
{getItemTypeLabel(node.child_item_type)}
</span>
</div>
{/* 수량/단위 */}
<div className="flex flex-shrink-0 items-center gap-2 text-[11px]">
<span className="text-muted-foreground">
: <b className="text-foreground">{node.quantity || "0"}</b> {node.unit || ""}
</span>
{node.loss_rate && node.loss_rate !== "0" && (
<span className="text-amber-600">
: {node.loss_rate}%
</span>
)}
</div>
</div>
{/* 하위 노드 재귀 렌더링 */}
{hasChildren && isExpanded && (
<div>
{node.children.map((child) => (
<TreeNodeRow
key={child.id}
node={child}
depth={depth + 1}
expandedNodes={expandedNodes}
selectedNodeId={selectedNodeId}
onToggle={onToggle}
onSelect={onSelect}
getItemTypeLabel={getItemTypeLabel}
getItemTypeStyle={getItemTypeStyle}
/>
))}
</div>
)}
</>
);
}

View File

@ -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 <BomTreeComponent {...this.props} />;
}
}
BomTreeRenderer.registerSelf();
if (typeof window !== "undefined") {
setTimeout(() => {
try {
BomTreeRenderer.registerSelf();
} catch (error) {
console.error("BomTree 등록 실패:", error);
}
}, 1000);
}

View File

@ -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;

View File

@ -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<SplitLineComponentProps> = ({
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<HTMLDivElement>(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 (
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
position: "relative",
...style,
}}
className={className}
onClick={handleClick}
{...domProps}
>
<div
style={{
width: `${lineWidth}px`,
height: "100%",
borderLeft: `${lineWidth}px dashed ${isSelected ? "#3b82f6" : lineColor}`,
}}
/>
<div
style={{
position: "absolute",
top: "4px",
left: "50%",
transform: "translateX(-50%)",
fontSize: "10px",
color: isSelected ? "#3b82f6" : "#9ca3af",
whiteSpace: "nowrap",
backgroundColor: "rgba(255,255,255,0.9)",
padding: "1px 6px",
borderRadius: "4px",
fontWeight: 500,
}}
>
릿
</div>
</div>
);
}
return (
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: resizable ? "col-resize" : "default",
transform: `translateX(${dragOffset}px)`,
transition: isDragging ? "none" : "transform 0.1s ease-out",
zIndex: 50,
...style,
}}
className={className}
onMouseDown={handleMouseDown}
{...domProps}
>
<div
style={{
width: `${lineWidth}px`,
height: "100%",
backgroundColor: isDragging ? "hsl(var(--primary))" : lineColor,
transition: isDragging ? "none" : "background-color 0.15s ease",
}}
className="hover:bg-primary"
/>
</div>
);
};
export const SplitLineWrapper: React.FC<SplitLineComponentProps> = (props) => {
return <SplitLineComponent {...props} />;
};

View File

@ -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<SplitLineConfigPanelProps> = ({ config, onConfigChange }) => {
const currentConfig = config || {};
const handleChange = (key: string, value: any) => {
onConfigChange({
...currentConfig,
[key]: value,
});
};
return (
<div className="space-y-4 p-4">
<h3 className="text-sm font-semibold">릿 </h3>
{/* 드래그 리사이즈 허용 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={currentConfig.resizable ?? true}
onCheckedChange={(checked) => handleChange("resizable", checked)}
/>
</div>
{/* 분할선 스타일 */}
<div className="space-y-2">
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={currentConfig.lineWidth || 4}
onChange={(e) => handleChange("lineWidth", parseInt(e.target.value) || 4)}
className="h-8 text-xs"
min={1}
max={12}
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex items-center gap-2">
<input
type="color"
value={currentConfig.lineColor || "#e2e8f0"}
onChange={(e) => handleChange("lineColor", e.target.value)}
className="h-8 w-8 cursor-pointer rounded border"
/>
<Input
value={currentConfig.lineColor || "#e2e8f0"}
onChange={(e) => handleChange("lineColor", e.target.value)}
className="h-8 flex-1 text-xs"
placeholder="#e2e8f0"
/>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
릿 X .
.
</p>
</div>
);
};
export default SplitLineConfigPanel;

View File

@ -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 <SplitLineComponent {...this.props} renderer={this} />;
}
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
}
// 자동 등록 실행
SplitLineRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
SplitLineRenderer.enableHotReload();
}

View File

@ -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<CanvasSplitState>): 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: "",
};
}

View File

@ -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";

View File

@ -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;
}

View File

@ -26,6 +26,8 @@ export interface SplitPanelInfo {
initialLeftWidthPercent: number;
// 드래그 중 여부
isDragging: boolean;
// 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정)
panelType?: "component" | "canvas";
}
export interface SplitPanelResizeContextValue {

View File

@ -689,6 +689,12 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
"v2-repeater": v2V2RepeaterOverridesSchema,
// V2 컴포넌트 (9개)
"v2-split-line": z.object({
resizable: z.boolean().default(true),
lineColor: z.string().default("#e2e8f0"),
lineWidth: z.number().default(4),
}).passthrough(),
"v2-input": v2InputOverridesSchema,
"v2-select": v2SelectOverridesSchema,
"v2-date": v2DateOverridesSchema,
@ -738,6 +744,11 @@ const componentDefaultsRegistry: Record<string, Record<string, any>> = {
autoLoad: true,
syncSelection: true,
},
"v2-split-line": {
resizable: true,
lineColor: "#e2e8f0",
lineWidth: 4,
},
"v2-section-card": {
title: "섹션 제목",
description: "",

View File

@ -57,6 +57,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"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"),