jskim-node #393
|
|
@ -164,6 +164,7 @@ export const componentDefaults: Record<string, any> = {
|
||||||
"v2-date": { type: "v2-date", webType: "date" },
|
"v2-date": { type: "v2-date", webType: "date" },
|
||||||
"v2-repeater": { type: "v2-repeater", webType: "custom" },
|
"v2-repeater": { type: "v2-repeater", webType: "custom" },
|
||||||
"v2-repeat-container": { type: "v2-repeat-container", webType: "custom" },
|
"v2-repeat-container": { type: "v2-repeat-container", webType: "custom" },
|
||||||
|
"v2-split-line": { type: "v2-split-line", webType: "custom", resizable: true, lineWidth: 4 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,7 @@ childItemsSection: {
|
||||||
"config": {
|
"config": {
|
||||||
"masterPanel": {
|
"masterPanel": {
|
||||||
"title": "BOM 목록",
|
"title": "BOM 목록",
|
||||||
"entityId": "bom_header",
|
"entityId": "bom",
|
||||||
"columns": [
|
"columns": [
|
||||||
{ "id": "item_code", "label": "품목코드", "width": 100 },
|
{ "id": "item_code", "label": "품목코드", "width": 100 },
|
||||||
{ "id": "item_name", "label": "품목명", "width": 150 },
|
{ "id": "item_name", "label": "품목명", "width": 150 },
|
||||||
|
|
|
||||||
|
|
@ -1019,6 +1019,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
<ActiveTabProvider>
|
<ActiveTabProvider>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div
|
<div
|
||||||
|
data-screen-runtime="true"
|
||||||
className="relative bg-white"
|
className="relative bg-white"
|
||||||
style={{
|
style={{
|
||||||
width: `${screenDimensions?.width || 800}px`,
|
width: `${screenDimensions?.width || 800}px`,
|
||||||
|
|
|
||||||
|
|
@ -1372,6 +1372,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
) : screenData ? (
|
) : screenData ? (
|
||||||
<div
|
<div
|
||||||
|
data-screen-runtime="true"
|
||||||
className="relative bg-white"
|
className="relative bg-white"
|
||||||
style={{
|
style={{
|
||||||
width: screenDimensions?.width || 800,
|
width: screenDimensions?.width || 800,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
import React, { useState, useCallback, useEffect, useSyncExternalStore } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
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 { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator";
|
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";
|
import "@/lib/registry/components/ButtonRenderer";
|
||||||
|
|
@ -82,9 +87,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
parentTabId,
|
parentTabId,
|
||||||
parentTabsComponentId,
|
parentTabsComponentId,
|
||||||
}) => {
|
}) => {
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview();
|
||||||
const { userName: authUserName, user: authUser } = useAuth();
|
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 등에서)
|
// 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서)
|
||||||
const userName = externalUserName || authUserName;
|
const userName = externalUserName || authUserName;
|
||||||
|
|
@ -1079,22 +1089,96 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||||
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
|
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 = {
|
const componentStyle = {
|
||||||
position: "absolute" as const,
|
position: "absolute" as const,
|
||||||
left: position?.x || 0,
|
left: adjustedX,
|
||||||
top: position?.y || 0, // 원래 위치 유지 (음수로 가면 overflow-hidden에 잘림)
|
top: position?.y || 0,
|
||||||
zIndex: position?.z || 1,
|
zIndex: position?.z || 1,
|
||||||
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
...styleWithoutSize,
|
||||||
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
width: size?.width || 200,
|
||||||
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
||||||
minHeight: isTableSearchWidget ? "48px" : undefined,
|
minHeight: isTableSearchWidget ? "48px" : undefined,
|
||||||
// 🆕 라벨이 있으면 overflow visible로 설정하여 라벨이 잘리지 않게 함
|
|
||||||
overflow: labelOffset > 0 ? "visible" : undefined,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="absolute" style={componentStyle}>
|
<div id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
|
||||||
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */}
|
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */}
|
||||||
{renderInteractiveWidget(component)}
|
{renderInteractiveWidget(component)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo, useSyncExternalStore } from "react";
|
||||||
import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
|
import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
|
||||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,6 +17,11 @@ import {
|
||||||
File,
|
File,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
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";
|
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||||
|
|
||||||
// 컴포넌트 렌더러들 자동 등록
|
// 컴포넌트 렌더러들 자동 등록
|
||||||
|
|
@ -388,10 +393,12 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
}
|
}
|
||||||
: component;
|
: component;
|
||||||
|
|
||||||
// 🆕 분할 패널 리사이즈 Context
|
// 기존 분할 패널 리사이즈 Context (레거시 split-panel-layout용)
|
||||||
const splitPanelContext = useSplitPanel();
|
const splitPanelContext = useSplitPanel();
|
||||||
|
|
||||||
// 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상)
|
// 캔버스 분할선 글로벌 스토어 (useSyncExternalStore로 직접 구독)
|
||||||
|
const canvasSplit = useSyncExternalStore(canvasSplitSubscribe, canvasSplitGetSnapshot, canvasSplitGetServerSnapshot);
|
||||||
|
|
||||||
const componentType = (component as any).componentType || "";
|
const componentType = (component as any).componentType || "";
|
||||||
const componentId = (component as any).componentId || "";
|
const componentId = (component as any).componentId || "";
|
||||||
const widgetType = (component as any).widgetType || "";
|
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(componentType) ||
|
||||||
["button-primary", "button-secondary"].includes(componentId)));
|
["button-primary", "button-secondary"].includes(componentId)));
|
||||||
|
|
||||||
// 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점)
|
// 레거시 분할 패널용 refs
|
||||||
const initialPanelRatioRef = React.useRef<number | null>(null);
|
const initialPanelRatioRef = React.useRef<number | null>(null);
|
||||||
const initialPanelIdRef = React.useRef<string | null>(null);
|
const initialPanelIdRef = React.useRef<string | null>(null);
|
||||||
// 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지)
|
|
||||||
const isInLeftPanelRef = React.useRef<boolean | 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 =
|
const isSplitPanelComponent =
|
||||||
type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType);
|
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 };
|
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentWidth = size?.width || 100;
|
const componentWidth = size?.width || 100;
|
||||||
const componentHeight = size?.height || 40;
|
const componentHeight = size?.height || 40;
|
||||||
|
|
||||||
// 분할 패널 위에 있는지 확인 (원래 위치 기준)
|
|
||||||
const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight);
|
const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight);
|
||||||
|
|
||||||
// 분할 패널 위에 없으면 기준점 초기화
|
|
||||||
if (!overlap) {
|
if (!overlap) {
|
||||||
if (initialPanelIdRef.current !== null) {
|
if (initialPanelIdRef.current !== null) {
|
||||||
initialPanelRatioRef.current = null;
|
initialPanelRatioRef.current = null;
|
||||||
initialPanelIdRef.current = null;
|
initialPanelIdRef.current = null;
|
||||||
isInLeftPanelRef.current = null;
|
isInLeftPanelRef.current = null;
|
||||||
}
|
}
|
||||||
return {
|
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||||
adjustedPositionX: position.x,
|
|
||||||
isOnSplitPanel: false,
|
|
||||||
isDraggingSplitPanel: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { panel } = overlap;
|
const { panel } = overlap;
|
||||||
|
|
||||||
// 🆕 초기 기준 비율 및 좌측 패널 소속 여부 설정 (처음 한 번만)
|
|
||||||
if (initialPanelIdRef.current !== overlap.panelId) {
|
if (initialPanelIdRef.current !== overlap.panelId) {
|
||||||
initialPanelRatioRef.current = panel.leftWidthPercent;
|
initialPanelRatioRef.current = panel.initialLeftWidthPercent;
|
||||||
initialPanelIdRef.current = overlap.panelId;
|
initialPanelIdRef.current = overlap.panelId;
|
||||||
|
const initialDividerX = panel.x + (panel.width * panel.initialLeftWidthPercent) / 100;
|
||||||
// 초기 배치 시 좌측 패널에 있는지 확인 (초기 비율 기준으로 계산)
|
|
||||||
// 현재 비율이 아닌, 버튼 원래 위치가 초기 좌측 패널 영역 안에 있었는지 판단
|
|
||||||
const initialLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
|
||||||
const componentCenterX = position.x + componentWidth / 2;
|
const componentCenterX = position.x + componentWidth / 2;
|
||||||
const relativeX = componentCenterX - panel.x;
|
isInLeftPanelRef.current = componentCenterX < initialDividerX;
|
||||||
const wasInLeftPanel = relativeX < initialLeftPanelWidth;
|
|
||||||
|
|
||||||
isInLeftPanelRef.current = wasInLeftPanel;
|
|
||||||
console.log("📌 [버튼 기준점 설정]:", {
|
|
||||||
componentId: component.id,
|
|
||||||
panelId: overlap.panelId,
|
|
||||||
initialRatio: panel.leftWidthPercent,
|
|
||||||
isInLeftPanel: wasInLeftPanel,
|
|
||||||
buttonCenterX: componentCenterX,
|
|
||||||
leftPanelWidth: initialLeftPanelWidth,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 좌측 패널 소속이 아니면 조정하지 않음 (초기 배치 기준)
|
const baseRatio = initialPanelRatioRef.current ?? panel.initialLeftWidthPercent;
|
||||||
if (!isInLeftPanelRef.current) {
|
const initialDividerX = panel.x + (panel.width * baseRatio) / 100;
|
||||||
return {
|
const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100;
|
||||||
adjustedPositionX: position.x,
|
const dividerDelta = currentDividerX - initialDividerX;
|
||||||
isOnSplitPanel: true,
|
|
||||||
isDraggingSplitPanel: panel.isDragging,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 초기 기준 비율 (버튼이 처음 배치될 때의 비율)
|
|
||||||
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) {
|
if (Math.abs(dividerDelta) < 1) {
|
||||||
return {
|
return { adjustedPositionX: position.x, isOnSplitPanel: true, isDraggingSplitPanel: panel.isDragging };
|
||||||
adjustedPositionX: position.x,
|
|
||||||
isOnSplitPanel: true,
|
|
||||||
isDraggingSplitPanel: panel.isDragging,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 버튼도 분할선과 같은 양만큼 이동
|
const adjustedX = isInLeftPanelRef.current ? position.x + dividerDelta : position.x;
|
||||||
// 분할선이 왼쪽으로 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
adjustedPositionX: adjustedX,
|
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 displayWidth = resizeSize ? `${resizeSize.width}px` : getWidth();
|
||||||
const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight();
|
const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight();
|
||||||
|
|
||||||
|
const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId;
|
||||||
|
|
||||||
const baseStyle = {
|
const baseStyle = {
|
||||||
left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용
|
left: `${adjustedPositionX}px`,
|
||||||
top: `${position.y}px`,
|
top: `${position.y}px`,
|
||||||
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨)
|
...componentStyle,
|
||||||
width: displayWidth, // 🆕 리사이즈 중이면 resizeSize 사용
|
width: displayWidth,
|
||||||
height: displayHeight, // 🆕 리사이즈 중이면 resizeSize 사용
|
height: displayHeight,
|
||||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||||
right: undefined,
|
right: undefined,
|
||||||
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동, 리사이즈 중에도 트랜지션 없음
|
willChange: canvasSplit.isDragging && isSplitActive ? "left" as const : undefined,
|
||||||
transition:
|
transition:
|
||||||
isResizing ? "none" :
|
isResizing ? "none" :
|
||||||
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
|
isOnSplitPanel ? (isDraggingSplitPanel ? "none" : "left 0.15s ease-out") : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)
|
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,8 @@ import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
|
||||||
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
||||||
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
||||||
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
|
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
|
||||||
|
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
|
||||||
|
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ export interface SplitPanelInfo {
|
||||||
initialLeftWidthPercent: number;
|
initialLeftWidthPercent: number;
|
||||||
// 드래그 중 여부
|
// 드래그 중 여부
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
|
// 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정)
|
||||||
|
panelType?: "component" | "canvas";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SplitPanelResizeContextValue {
|
export interface SplitPanelResizeContextValue {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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} />;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,8 @@ export interface SplitPanelInfo {
|
||||||
initialLeftWidthPercent: number;
|
initialLeftWidthPercent: number;
|
||||||
// 드래그 중 여부
|
// 드래그 중 여부
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
|
// 패널 유형: "component" = 분할 패널 레이아웃 (버튼만 조정), "canvas" = 캔버스 분할선 (모든 컴포넌트 조정)
|
||||||
|
panelType?: "component" | "canvas";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SplitPanelResizeContextValue {
|
export interface SplitPanelResizeContextValue {
|
||||||
|
|
|
||||||
|
|
@ -689,6 +689,12 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
|
||||||
"v2-repeater": v2V2RepeaterOverridesSchema,
|
"v2-repeater": v2V2RepeaterOverridesSchema,
|
||||||
|
|
||||||
// V2 컴포넌트 (9개)
|
// 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-input": v2InputOverridesSchema,
|
||||||
"v2-select": v2SelectOverridesSchema,
|
"v2-select": v2SelectOverridesSchema,
|
||||||
"v2-date": v2DateOverridesSchema,
|
"v2-date": v2DateOverridesSchema,
|
||||||
|
|
@ -738,6 +744,11 @@ const componentDefaultsRegistry: Record<string, Record<string, any>> = {
|
||||||
autoLoad: true,
|
autoLoad: true,
|
||||||
syncSelection: true,
|
syncSelection: true,
|
||||||
},
|
},
|
||||||
|
"v2-split-line": {
|
||||||
|
resizable: true,
|
||||||
|
lineColor: "#e2e8f0",
|
||||||
|
lineWidth: 4,
|
||||||
|
},
|
||||||
"v2-section-card": {
|
"v2-section-card": {
|
||||||
title: "섹션 제목",
|
title: "섹션 제목",
|
||||||
description: "",
|
description: "",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||||
"split-panel-layout2": () => import("@/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel"),
|
"split-panel-layout2": () => import("@/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel"),
|
||||||
"screen-split-panel": () => import("@/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel"),
|
"screen-split-panel": () => import("@/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel"),
|
||||||
"conditional-container": () => import("@/lib/registry/components/conditional-container/ConditionalContainerConfigPanel"),
|
"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"),
|
"table-list": () => import("@/lib/registry/components/table-list/TableListConfigPanel"),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue