feat: enhance split panel and tab component interactions

- Improved the drop handling logic for split panels and tabs, prioritizing the innermost container during drag-and-drop operations.
- Added internal state management for selected components within split panels to enhance user experience.
- Updated the rendering logic to ensure proper data attributes are set for design mode, facilitating better component identification.
- Enhanced the dynamic component rendering to support updates and selections for nested components within tabs and split panels.

Made-with: Cursor
This commit is contained in:
kjs 2026-03-12 09:49:17 +09:00
parent b1e50f2e0a
commit 014979bebf
9 changed files with 481 additions and 400 deletions

View File

@ -2861,9 +2861,190 @@ export default function ScreenDesigner({
}
}
// 🎯 탭 컨테이너 내부 드롭 처리 (중첩 구조 지원)
// 🎯 컨테이너 드롭 우선순위: 가장 안쪽(innermost) 컨테이너 우선
// 분할패널과 탭 둘 다 감지될 경우, DOM 트리에서 더 가까운 쪽을 우선 처리
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
if (tabsContainer) {
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
// 분할패널이 탭보다 안쪽에 있으면 분할패널 우선 처리
const splitPanelFirst =
splitPanelContainer &&
(!tabsContainer || tabsContainer.contains(splitPanelContainer));
if (splitPanelFirst && splitPanelContainer) {
const containerId = splitPanelContainer.getAttribute("data-component-id");
const panelSide = splitPanelContainer.getAttribute("data-panel-side");
if (containerId && panelSide) {
// 분할 패널을 최상위 또는 중첩(탭 안)에서 찾기
let targetComponent: any = layout.components.find((c) => c.id === containerId);
let parentTabsId: string | null = null;
let parentTabId: string | null = null;
let parentSplitId: string | null = null;
let parentSplitSide: string | null = null;
if (!targetComponent) {
// 탭 안에 중첩된 분할패널 찾기
// top-level: overrides.type / overrides.tabs
// nested: componentType / componentConfig.tabs
for (const comp of layout.components) {
const compType = (comp as any)?.componentType || (comp as any)?.overrides?.type;
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
if (compType === "tabs-widget" || compType === "v2-tabs-widget") {
const tabs = compConfig.tabs || [];
for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId);
if (found) {
targetComponent = found;
parentTabsId = comp.id;
parentTabId = tab.id;
break;
}
}
if (targetComponent) break;
}
if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") {
for (const side of ["leftPanel", "rightPanel"] as const) {
const panelComps = compConfig[side]?.components || [];
for (const pc of panelComps) {
const pct = pc.componentType || pc.overrides?.type;
if (pct === "tabs-widget" || pct === "v2-tabs-widget") {
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId);
if (found) {
targetComponent = found;
parentSplitId = comp.id;
parentSplitSide = side === "leftPanel" ? "left" : "right";
parentTabsId = pc.id;
parentTabId = tab.id;
break;
}
}
if (targetComponent) break;
}
}
if (targetComponent) break;
}
if (targetComponent) break;
}
}
}
const compType = (targetComponent as any)?.componentType;
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
const currentConfig = (targetComponent as any).componentConfig || {};
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
const panelConfig = currentConfig[panelKey] || {};
const currentComponents = panelConfig.components || [];
const panelRect = splitPanelContainer.getBoundingClientRect();
const cs1 = window.getComputedStyle(splitPanelContainer);
const dropX = (e.clientX - panelRect.left - (parseFloat(cs1.paddingLeft) || 0)) / zoomLevel;
const dropY = (e.clientY - panelRect.top - (parseFloat(cs1.paddingTop) || 0)) / zoomLevel;
const componentType = component.id || component.componentType || "v2-text-display";
const newPanelComponent = {
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
componentType: componentType,
label: component.name || component.label || "새 컴포넌트",
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
size: component.defaultSize || { width: 200, height: 100 },
componentConfig: component.defaultConfig || {},
};
const updatedPanelConfig = {
...panelConfig,
components: [...currentComponents, newPanelComponent],
};
const updatedSplitPanel = {
...targetComponent,
componentConfig: {
...currentConfig,
[panelKey]: updatedPanelConfig,
},
};
let newLayout;
if (parentTabsId && parentTabId) {
// 중첩: (최상위 분할패널 →) 탭 → 분할패널
const updateTabsComponent = (tabsComp: any) => {
const ck = tabsComp.componentConfig ? "componentConfig" : "overrides";
const cfg = tabsComp[ck] || {};
const tabs = cfg.tabs || [];
return {
...tabsComp,
[ck]: {
...cfg,
tabs: tabs.map((tab: any) =>
tab.id === parentTabId
? {
...tab,
components: (tab.components || []).map((c: any) =>
c.id === containerId ? updatedSplitPanel : c,
),
}
: tab,
),
},
};
};
if (parentSplitId && parentSplitSide) {
// 최상위 분할패널 → 탭 → 분할패널
const pKey = parentSplitSide === "left" ? "leftPanel" : "rightPanel";
newLayout = {
...layout,
components: layout.components.map((c) => {
if (c.id === parentSplitId) {
const sc = (c as any).componentConfig || {};
return {
...c,
componentConfig: {
...sc,
[pKey]: {
...sc[pKey],
components: (sc[pKey]?.components || []).map((pc: any) =>
pc.id === parentTabsId ? updateTabsComponent(pc) : pc,
),
},
},
};
}
return c;
}),
};
} else {
// 최상위 탭 → 분할패널
newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === parentTabsId ? updateTabsComponent(c) : c,
),
};
}
} else {
// 최상위 분할패널
newLayout = {
...layout,
components: layout.components.map((c) => (c.id === containerId ? updatedSplitPanel : c)),
};
}
setLayout(newLayout);
saveToHistory(newLayout);
toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
return;
}
}
}
if (tabsContainer && !splitPanelFirst) {
const containerId = tabsContainer.getAttribute("data-component-id");
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
if (containerId && activeTabId) {
@ -3004,69 +3185,6 @@ export default function ScreenDesigner({
}
}
// 🎯 분할 패널 커스텀 모드 컨테이너 내부 드롭 처리
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
if (splitPanelContainer) {
const containerId = splitPanelContainer.getAttribute("data-component-id");
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
if (containerId && panelSide) {
const targetComponent = layout.components.find((c) => c.id === containerId);
const compType = (targetComponent as any)?.componentType;
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
const currentConfig = (targetComponent as any).componentConfig || {};
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
const panelConfig = currentConfig[panelKey] || {};
const currentComponents = panelConfig.components || [];
// 드롭 위치 계산
const panelRect = splitPanelContainer.getBoundingClientRect();
const dropX = (e.clientX - panelRect.left) / zoomLevel;
const dropY = (e.clientY - panelRect.top) / zoomLevel;
// 새 컴포넌트 생성
const componentType = component.id || component.componentType || "v2-text-display";
console.log("🎯 분할 패널에 컴포넌트 드롭:", {
componentId: component.id,
componentType: componentType,
panelSide: panelSide,
dropPosition: { x: dropX, y: dropY },
});
const newPanelComponent = {
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
componentType: componentType,
label: component.name || component.label || "새 컴포넌트",
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
size: component.defaultSize || { width: 200, height: 100 },
componentConfig: component.defaultConfig || {},
};
const updatedPanelConfig = {
...panelConfig,
components: [...currentComponents, newPanelComponent],
};
const updatedComponent = {
...targetComponent,
componentConfig: {
...currentConfig,
[panelKey]: updatedPanelConfig,
},
};
const newLayout = {
...layout,
components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)),
};
setLayout(newLayout);
saveToHistory(newLayout);
toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
return; // 분할 패널 처리 완료
}
}
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
@ -3378,15 +3496,12 @@ export default function ScreenDesigner({
e.preventDefault();
const dragData = e.dataTransfer.getData("application/json");
// console.log("🎯 드롭 이벤트:", { dragData });
if (!dragData) {
// console.log("❌ 드래그 데이터가 없습니다");
return;
}
try {
const parsedData = JSON.parse(dragData);
// console.log("📋 파싱된 데이터:", parsedData);
// 템플릿 드래그인 경우
if (parsedData.type === "template") {
@ -3480,9 +3595,225 @@ export default function ScreenDesigner({
}
}
// 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원)
// 🎯 컨테이너 감지: innermost 우선 (분할패널 > 탭)
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
if (tabsContainer && type === "column" && column) {
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
// 분할패널이 탭 안에 있으면 분할패널이 innermost → 분할패널 우선
const splitPanelFirst =
splitPanelContainer &&
(!tabsContainer || tabsContainer.contains(splitPanelContainer));
// 🎯 분할패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리 (우선 처리)
if (splitPanelFirst && splitPanelContainer && type === "column" && column) {
const containerId = splitPanelContainer.getAttribute("data-component-id");
let panelSide = splitPanelContainer.getAttribute("data-panel-side");
// panelSide가 없으면 드롭 좌표와 splitRatio로 좌/우 판별
if (!panelSide) {
const splitRatio = parseInt(splitPanelContainer.getAttribute("data-split-ratio") || "40", 10);
const containerRect = splitPanelContainer.getBoundingClientRect();
const relativeX = e.clientX - containerRect.left;
const splitPoint = containerRect.width * (splitRatio / 100);
panelSide = relativeX < splitPoint ? "left" : "right";
}
if (containerId && panelSide) {
// 최상위에서 찾기
let targetComponent: any = layout.components.find((c) => c.id === containerId);
let parentTabsId: string | null = null;
let parentTabId: string | null = null;
let parentSplitId: string | null = null;
let parentSplitSide: string | null = null;
if (!targetComponent) {
// 탭 안 중첩 분할패널 찾기
// top-level 컴포넌트: overrides.type / overrides.tabs
// nested 컴포넌트: componentType / componentConfig.tabs
for (const comp of layout.components) {
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
const tabs = compConfig.tabs || [];
for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId);
if (found) {
targetComponent = found;
parentTabsId = comp.id;
parentTabId = tab.id;
break;
}
}
if (targetComponent) break;
}
// 분할패널 → 탭 → 분할패널 중첩
if (ct === "split-panel-layout" || ct === "v2-split-panel-layout") {
for (const side of ["leftPanel", "rightPanel"] as const) {
const panelComps = compConfig[side]?.components || [];
for (const pc of panelComps) {
const pct = pc.componentType || pc.overrides?.type;
if (pct === "tabs-widget" || pct === "v2-tabs-widget") {
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId);
if (found) {
targetComponent = found;
parentSplitId = comp.id;
parentSplitSide = side === "leftPanel" ? "left" : "right";
parentTabsId = pc.id;
parentTabId = tab.id;
break;
}
}
if (targetComponent) break;
}
}
if (targetComponent) break;
}
if (targetComponent) break;
}
}
}
const compType = (targetComponent as any)?.componentType;
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
const currentConfig = (targetComponent as any).componentConfig || {};
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
const panelConfig = currentConfig[panelKey] || {};
const currentComponents = panelConfig.components || [];
const panelRect = splitPanelContainer.getBoundingClientRect();
const computedStyle = window.getComputedStyle(splitPanelContainer);
const padLeft = parseFloat(computedStyle.paddingLeft) || 0;
const padTop = parseFloat(computedStyle.paddingTop) || 0;
const dropX = (e.clientX - panelRect.left - padLeft) / zoomLevel;
const dropY = (e.clientY - panelRect.top - padTop) / zoomLevel;
const v2Mapping = createV2ConfigFromColumn({
widgetType: column.widgetType,
columnName: column.columnName,
columnLabel: column.columnLabel,
codeCategory: column.codeCategory,
inputType: column.inputType,
required: column.required,
detailSettings: column.detailSettings,
referenceTable: column.referenceTable,
referenceColumn: column.referenceColumn,
displayColumn: column.displayColumn,
});
const newPanelComponent = {
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
componentType: v2Mapping.componentType,
label: column.columnLabel || column.columnName,
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
size: { width: 200, height: 36 },
inputType: column.inputType || column.widgetType,
widgetType: column.widgetType,
componentConfig: {
...v2Mapping.componentConfig,
columnName: column.columnName,
tableName: column.tableName,
inputType: column.inputType || column.widgetType,
},
};
const updatedSplitPanel = {
...targetComponent,
componentConfig: {
...currentConfig,
[panelKey]: {
...panelConfig,
displayMode: "custom",
components: [...currentComponents, newPanelComponent],
},
},
};
let newLayout;
if (parentSplitId && parentSplitSide && parentTabsId && parentTabId) {
// 분할패널 → 탭 → 분할패널 3중 중첩
newLayout = {
...layout,
components: layout.components.map((c) => {
if (c.id !== parentSplitId) return c;
const sc = (c as any).componentConfig || {};
const pk = parentSplitSide === "left" ? "leftPanel" : "rightPanel";
return {
...c,
componentConfig: {
...sc,
[pk]: {
...sc[pk],
components: (sc[pk]?.components || []).map((pc: any) => {
if (pc.id !== parentTabsId) return pc;
return {
...pc,
componentConfig: {
...pc.componentConfig,
tabs: (pc.componentConfig?.tabs || []).map((tab: any) => {
if (tab.id !== parentTabId) return tab;
return {
...tab,
components: (tab.components || []).map((tc: any) =>
tc.id === containerId ? updatedSplitPanel : tc,
),
};
}),
},
};
}),
},
},
};
}),
};
} else if (parentTabsId && parentTabId) {
// 탭 → 분할패널 2중 중첩
newLayout = {
...layout,
components: layout.components.map((c) => {
if (c.id !== parentTabsId) return c;
// top-level은 overrides, nested는 componentConfig
const configKey = (c as any).componentConfig ? "componentConfig" : "overrides";
const tabsConfig = (c as any)[configKey] || {};
return {
...c,
[configKey]: {
...tabsConfig,
tabs: (tabsConfig.tabs || []).map((tab: any) => {
if (tab.id !== parentTabId) return tab;
return {
...tab,
components: (tab.components || []).map((tc: any) =>
tc.id === containerId ? updatedSplitPanel : tc,
),
};
}),
},
};
}),
};
} else {
// 최상위 분할패널
newLayout = {
...layout,
components: layout.components.map((c) => (c.id === containerId ? updatedSplitPanel : c)),
};
}
toast.success("컬럼이 분할패널에 추가되었습니다");
setLayout(newLayout);
saveToHistory(newLayout);
return;
}
}
}
// 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원)
if (tabsContainer && !splitPanelFirst && type === "column" && column) {
const containerId = tabsContainer.getAttribute("data-component-id");
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
if (containerId && activeTabId) {
@ -3648,9 +3979,8 @@ export default function ScreenDesigner({
}
}
// 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
if (splitPanelContainer && type === "column" && column) {
// 🎯 분할 패널 커스텀 모드 (탭 밖 최상위) 컬럼 드롭 처리
if (splitPanelContainer && !splitPanelFirst && type === "column" && column) {
const containerId = splitPanelContainer.getAttribute("data-component-id");
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
if (containerId && panelSide) {
@ -3662,12 +3992,11 @@ export default function ScreenDesigner({
const panelConfig = currentConfig[panelKey] || {};
const currentComponents = panelConfig.components || [];
// 드롭 위치 계산
const panelRect = splitPanelContainer.getBoundingClientRect();
const dropX = (e.clientX - panelRect.left) / zoomLevel;
const dropY = (e.clientY - panelRect.top) / zoomLevel;
const cs2 = window.getComputedStyle(splitPanelContainer);
const dropX = (e.clientX - panelRect.left - (parseFloat(cs2.paddingLeft) || 0)) / zoomLevel;
const dropY = (e.clientY - panelRect.top - (parseFloat(cs2.paddingTop) || 0)) / zoomLevel;
// V2 컴포넌트 매핑 사용
const v2Mapping = createV2ConfigFromColumn({
widgetType: column.widgetType,
columnName: column.columnName,

View File

@ -20,7 +20,6 @@ import {
GeneratedLocation,
RackStructureContext,
} from "./types";
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils";
// 기존 위치 데이터 타입
interface ExistingLocation {
@ -513,27 +512,23 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
return { totalLocations, totalRows, maxLevel };
}, [conditions]);
// 위치 코드 생성 (패턴 기반)
// 위치 코드 생성
const generateLocationCode = useCallback(
(row: number, level: number): { code: string; name: string } => {
const vars = {
warehouse: context?.warehouseCode || "WH001",
warehouseName: context?.warehouseName || "",
floor: context?.floor || "1",
zone: context?.zone || "A",
row,
level,
};
const warehouseCode = context?.warehouseCode || "WH001";
const floor = context?.floor || "1";
const zone = context?.zone || "A";
const codePattern = config.codePattern || DEFAULT_CODE_PATTERN;
const namePattern = config.namePattern || DEFAULT_NAME_PATTERN;
// 코드 생성 (예: WH001-1층D구역-01-1)
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
return {
code: applyLocationPattern(codePattern, vars),
name: applyLocationPattern(namePattern, vars),
};
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}`;
return { code, name };
},
[context, config.codePattern, config.namePattern],
[context],
);
// 미리보기 생성

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
@ -12,47 +12,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { RackStructureComponentConfig, FieldMapping } from "./types";
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN, PATTERN_VARIABLES } from "./patternUtils";
// 패턴 미리보기 서브 컴포넌트
const PatternPreview: React.FC<{
codePattern?: string;
namePattern?: string;
}> = ({ codePattern, namePattern }) => {
const sampleVars = {
warehouse: "WH002",
warehouseName: "2창고",
floor: "2층",
zone: "A구역",
row: 1,
level: 3,
};
const previewCode = useMemo(
() => applyLocationPattern(codePattern || DEFAULT_CODE_PATTERN, sampleVars),
[codePattern],
);
const previewName = useMemo(
() => applyLocationPattern(namePattern || DEFAULT_NAME_PATTERN, sampleVars),
[namePattern],
);
return (
<div className="rounded-md border border-primary/20 bg-primary/5 p-2.5">
<div className="mb-1.5 text-[10px] font-medium text-primary"> (2 / 2 / A구역 / 1 / 3)</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-muted-foreground">:</span>
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewCode}</code>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-muted-foreground">:</span>
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewName}</code>
</div>
</div>
</div>
);
};
interface RackStructureConfigPanelProps {
config: RackStructureComponentConfig;
@ -246,61 +205,6 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
</div>
</div>
{/* 위치코드 패턴 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground">/ </div>
<p className="text-xs text-muted-foreground">
</p>
{/* 위치코드 패턴 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.codePattern || ""}
onChange={(e) => handleChange("codePattern", e.target.value || undefined)}
placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}"
className="h-8 font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
: {"{warehouse}-{floor}{zone}-{row:02}-{level}"}
</p>
</div>
{/* 위치명 패턴 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.namePattern || ""}
onChange={(e) => handleChange("namePattern", e.target.value || undefined)}
placeholder="{zone}-{row:02}열-{level}단"
className="h-8 font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
: {"{zone}-{row:02}열-{level}단"}
</p>
</div>
{/* 실시간 미리보기 */}
<PatternPreview
codePattern={config.codePattern}
namePattern={config.namePattern}
/>
{/* 사용 가능한 변수 목록 */}
<div className="rounded-md border bg-muted/50 p-2">
<div className="mb-1 text-[10px] font-medium text-foreground"> </div>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
{PATTERN_VARIABLES.map((v) => (
<div key={v.token} className="flex items-center gap-1 text-[10px]">
<code className="rounded bg-primary/10 px-1 font-mono text-primary">{v.token}</code>
<span className="text-muted-foreground">{v.description}</span>
</div>
))}
</div>
</div>
</div>
{/* 제한 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground"> </div>

View File

@ -1,7 +0,0 @@
// rack-structure는 v2-rack-structure의 patternUtils를 재사용
export {
applyLocationPattern,
DEFAULT_CODE_PATTERN,
DEFAULT_NAME_PATTERN,
PATTERN_VARIABLES,
} from "../v2-rack-structure/patternUtils";

View File

@ -20,7 +20,6 @@ import {
GeneratedLocation,
RackStructureContext,
} from "./types";
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils";
// 기존 위치 데이터 타입
interface ExistingLocation {
@ -494,27 +493,23 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
return { totalLocations, totalRows, maxLevel };
}, [conditions]);
// 위치 코드 생성 (패턴 기반)
// 위치 코드 생성
const generateLocationCode = useCallback(
(row: number, level: number): { code: string; name: string } => {
const vars = {
warehouse: context?.warehouseCode || "WH001",
warehouseName: context?.warehouseName || "",
floor: context?.floor || "1",
zone: context?.zone || "A",
row,
level,
};
const warehouseCode = context?.warehouseCode || "WH001";
const floor = context?.floor || "1";
const zone = context?.zone || "A";
const codePattern = config.codePattern || DEFAULT_CODE_PATTERN;
const namePattern = config.namePattern || DEFAULT_NAME_PATTERN;
// 코드 생성 (예: WH001-1층D구역-01-1)
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
return {
code: applyLocationPattern(codePattern, vars),
name: applyLocationPattern(namePattern, vars),
};
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}`;
return { code, name };
},
[context, config.codePattern, config.namePattern],
[context],
);
// 미리보기 생성

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
@ -12,47 +12,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { RackStructureComponentConfig, FieldMapping } from "./types";
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN, PATTERN_VARIABLES } from "./patternUtils";
// 패턴 미리보기 서브 컴포넌트
const PatternPreview: React.FC<{
codePattern?: string;
namePattern?: string;
}> = ({ codePattern, namePattern }) => {
const sampleVars = {
warehouse: "WH002",
warehouseName: "2창고",
floor: "2층",
zone: "A구역",
row: 1,
level: 3,
};
const previewCode = useMemo(
() => applyLocationPattern(codePattern || DEFAULT_CODE_PATTERN, sampleVars),
[codePattern],
);
const previewName = useMemo(
() => applyLocationPattern(namePattern || DEFAULT_NAME_PATTERN, sampleVars),
[namePattern],
);
return (
<div className="rounded-md border border-primary/20 bg-primary/5 p-2.5">
<div className="mb-1.5 text-[10px] font-medium text-primary"> (2 / 2 / A구역 / 1 / 3)</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-muted-foreground">:</span>
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewCode}</code>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-muted-foreground">:</span>
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewName}</code>
</div>
</div>
</div>
);
};
interface RackStructureConfigPanelProps {
config: RackStructureComponentConfig;
@ -246,61 +205,6 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
</div>
</div>
{/* 위치코드 패턴 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground">/ </div>
<p className="text-xs text-muted-foreground">
</p>
{/* 위치코드 패턴 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.codePattern || ""}
onChange={(e) => handleChange("codePattern", e.target.value || undefined)}
placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}"
className="h-8 font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
: {"{warehouse}-{floor}{zone}-{row:02}-{level}"}
</p>
</div>
{/* 위치명 패턴 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.namePattern || ""}
onChange={(e) => handleChange("namePattern", e.target.value || undefined)}
placeholder="{zone}-{row:02}열-{level}단"
className="h-8 font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
: {"{zone}-{row:02}열-{level}단"}
</p>
</div>
{/* 실시간 미리보기 */}
<PatternPreview
codePattern={config.codePattern}
namePattern={config.namePattern}
/>
{/* 사용 가능한 변수 목록 */}
<div className="rounded-md border bg-muted/50 p-2">
<div className="mb-1 text-[10px] font-medium text-foreground"> </div>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
{PATTERN_VARIABLES.map((v) => (
<div key={v.token} className="flex items-center gap-1 text-[10px]">
<code className="rounded bg-primary/10 px-1 font-mono text-primary">{v.token}</code>
<span className="text-muted-foreground">{v.description}</span>
</div>
))}
</div>
</div>
</div>
{/* 제한 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground"> </div>

View File

@ -1,81 +0,0 @@
/**
* /
*
* :
* {warehouse} - (: WH002)
* {warehouseName} - (: 2창고)
* {floor} - (: 2층)
* {zone} - (: A구역)
* {row} - (: 1)
* {row:02} - 2 (: 01)
* {row:03} - 3 (: 001)
* {level} - (: 1)
* {level:02} - 2 (: 01)
* {level:03} - 3 (: 001)
*/
interface PatternVariables {
warehouse?: string;
warehouseName?: string;
floor?: string;
zone?: string;
row: number;
level: number;
}
// 기본 패턴 (하드코딩 대체)
export const DEFAULT_CODE_PATTERN = "{warehouse}-{floor}{zone}-{row:02}-{level}";
export const DEFAULT_NAME_PATTERN = "{zone}-{row:02}열-{level}단";
/**
*
*/
export function applyLocationPattern(pattern: string, vars: PatternVariables): string {
let result = pattern;
// zone에 "구역" 포함 여부에 따른 처리 없이 있는 그대로 치환
const simpleVars: Record<string, string | undefined> = {
warehouse: vars.warehouse,
warehouseName: vars.warehouseName,
floor: vars.floor,
zone: vars.zone,
};
// 단순 문자열 변수 치환
for (const [key, value] of Object.entries(simpleVars)) {
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value || "");
}
// 숫자 변수 (row, level) - zero-pad 지원
const numericVars: Record<string, number> = {
row: vars.row,
level: vars.level,
};
for (const [key, value] of Object.entries(numericVars)) {
// {row:02}, {level:03} 같은 zero-pad 패턴
const padRegex = new RegExp(`\\{${key}:(\\d+)\\}`, "g");
result = result.replace(padRegex, (_, padWidth) => {
return value.toString().padStart(parseInt(padWidth), "0");
});
// {row}, {level} 같은 단순 패턴
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value.toString());
}
return result;
}
// 패턴에서 사용 가능한 변수 목록
export const PATTERN_VARIABLES = [
{ token: "{warehouse}", description: "창고 코드", example: "WH002" },
{ token: "{warehouseName}", description: "창고명", example: "2창고" },
{ token: "{floor}", description: "층", example: "2층" },
{ token: "{zone}", description: "구역", example: "A구역" },
{ token: "{row}", description: "열 번호", example: "1" },
{ token: "{row:02}", description: "열 번호 (2자리)", example: "01" },
{ token: "{row:03}", description: "열 번호 (3자리)", example: "001" },
{ token: "{level}", description: "단 번호", example: "1" },
{ token: "{level:02}", description: "단 번호 (2자리)", example: "01" },
{ token: "{level:03}", description: "단 번호 (3자리)", example: "001" },
];

View File

@ -271,8 +271,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
const [resizingCompId, setResizingCompId] = useState<string | null>(null);
const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null);
// 🆕 외부에서 전달받은 선택 상태 사용 (탭 컴포넌트와 동일 구조)
const selectedPanelComponentId = externalSelectedPanelComponentId || null;
// 내부 선택 상태 (외부 prop 없을 때 fallback)
const [internalSelectedCompId, setInternalSelectedCompId] = useState<string | null>(null);
const selectedPanelComponentId = externalSelectedPanelComponentId ?? internalSelectedCompId;
// 🆕 커스텀 모드: 분할패널 내 탭 컴포넌트의 선택 상태 관리
const [nestedTabSelectedCompId, setNestedTabSelectedCompId] = useState<string | undefined>(undefined);
const rafRef = useRef<number | null>(null);
@ -3052,9 +3053,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
</div>
)}
<CardContent className="flex-1 overflow-auto p-4">
<CardContent
className="flex-1 overflow-auto p-4"
{...(isDesignMode ? {
"data-split-panel-container": "true",
"data-component-id": component.id,
"data-panel-side": "left",
} : {})}
>
{/* 좌측 데이터 목록/테이블/커스텀 */}
{console.log("🔍 [SplitPanel] 왼쪽 패널 displayMode:", componentConfig.leftPanel?.displayMode, "isDesignMode:", isDesignMode)}
{componentConfig.leftPanel?.displayMode === "custom" ? (
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
<div
@ -3158,10 +3165,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}}
onClick={(e) => {
e.stopPropagation();
// 패널 컴포넌트 선택 시 탭 내 선택 해제
if (comp.componentType !== "v2-tabs-widget") {
setNestedTabSelectedCompId(undefined);
}
setInternalSelectedCompId(comp.id);
onSelectPanelComponent?.("left", comp.id, comp);
}}
>
@ -3917,7 +3924,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
</div>
)}
<CardContent className="flex-1 overflow-auto p-4">
<CardContent
className="flex-1 overflow-auto p-4"
{...(isDesignMode ? {
"data-split-panel-container": "true",
"data-component-id": component.id,
"data-panel-side": "right",
} : {})}
>
{/* 추가 탭 컨텐츠 */}
{activeTabIndex > 0 ? (
(() => {
@ -4289,6 +4303,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (comp.componentType !== "v2-tabs-widget") {
setNestedTabSelectedCompId(undefined);
}
setInternalSelectedCompId(comp.id);
onSelectPanelComponent?.("right", comp.id, comp);
}}
>

View File

@ -417,12 +417,39 @@ const TabsDesignEditor: React.FC<{
width: displayWidth,
height: displayHeight,
}}
>
<div className="h-full w-full pointer-events-none">
<div className={cn(
"h-full w-full",
comp.componentType !== "v2-split-panel-layout" && comp.componentType !== "split-panel-layout" && "pointer-events-none"
)}>
<DynamicComponentRenderer
component={componentData as any}
isDesignMode={true}
formData={{}}
{...(comp.componentType === "v2-split-panel-layout" || comp.componentType === "split-panel-layout" ? {
onUpdateComponent: (updated: any) => {
if (!onUpdateComponent) return;
const updatedTabs = tabs.map((t) => {
if (t.id !== activeTabId) return t;
return {
...t,
components: (t.components || []).map((c) =>
c.id === comp.id ? { ...c, componentConfig: updated.componentConfig || updated.overrides || c.componentConfig } : c
),
};
});
const configKey = component.componentConfig ? "componentConfig" : "overrides";
const existingConfig = component[configKey] || {};
onUpdateComponent({
...component,
[configKey]: { ...existingConfig, tabs: updatedTabs },
});
},
onSelectPanelComponent: (panelSide: string, compId: string, panelComp: any) => {
onSelectTabComponent?.(activeTabId, comp.id, { ...comp, _selectedPanelSide: panelSide, _selectedPanelCompId: compId, _selectedPanelComp: panelComp } as any);
},
} : {})}
/>
</div>