feat: 분할 패널 내부 컴포넌트 선택 기능 추가

- RealtimePreviewDynamic, ScreenDesigner, DynamicComponentRenderer, SplitPanelLayoutComponent 및 관련 파일에서 분할 패널 내부 컴포넌트 선택 콜백 및 상태 관리 기능을 추가하였습니다.
- 커스텀 모드에서 패널 내부에 컴포넌트를 자유롭게 배치할 수 있는 기능을 구현하였습니다.
- 선택된 패널 컴포넌트의 상태를 관리하고, 관련 UI 요소를 업데이트하여 사용자 경험을 향상시켰습니다.
- 패널의 표시 모드에 'custom' 옵션을 추가하여 사용자 정의 배치 기능을 지원합니다.
This commit is contained in:
kjs
2026-01-30 16:34:05 +09:00
parent 152558d593
commit 17e212118c
11 changed files with 1814 additions and 50 deletions

View File

@@ -39,6 +39,8 @@ interface RealtimePreviewProps {
onUpdateComponent?: (updatedComponent: any) => void; // 🆕 컴포넌트 업데이트 콜백
onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백
selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
// 버튼 액션을 위한 props
@@ -140,6 +142,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백
onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
onResize, // 🆕 리사이즈 콜백
}) => {
// 🆕 화면 다국어 컨텍스트
@@ -640,6 +644,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
onUpdateComponent={onUpdateComponent}
onSelectTabComponent={onSelectTabComponent}
selectedTabComponentId={selectedTabComponentId}
onSelectPanelComponent={onSelectPanelComponent}
selectedPanelComponentId={selectedPanelComponentId}
/>
</div>

View File

@@ -177,13 +177,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
component: any; // 탭 내부 컴포넌트 데이터
} | null>(null);
// 🆕 분할 패널 내부 컴포넌트 선택 상태
const [selectedPanelComponentInfo, setSelectedPanelComponentInfo] = useState<{
splitPanelId: string; // 분할 패널 컴포넌트 ID
panelSide: "left" | "right"; // 좌측/우측 패널
componentId: string; // 패널 내부 컴포넌트 ID
component: any; // 패널 내부 컴포넌트 데이터
} | null>(null);
// 컴포넌트 선택 시 통합 패널 자동 열기
const handleComponentSelect = useCallback(
(component: ComponentData | null) => {
setSelectedComponent(component);
// 일반 컴포넌트 선택 시 탭 내부 컴포넌트 선택 해제
// 일반 컴포넌트 선택 시 탭 내부 컴포넌트/분할 패널 내부 컴포넌트 선택 해제
if (component) {
setSelectedTabComponentInfo(null);
setSelectedPanelComponentInfo(null);
}
// 컴포넌트가 선택되면 통합 패널 자동 열기
@@ -209,8 +218,32 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
componentId: compId,
component: comp,
});
// 탭 내부 컴포넌트 선택 시 일반 컴포넌트 선택 해제
// 탭 내부 컴포넌트 선택 시 일반 컴포넌트/분할 패널 내부 컴포넌트 선택 해제
setSelectedComponent(null);
setSelectedPanelComponentInfo(null);
openPanel("v2");
},
[openPanel],
);
// 🆕 분할 패널 내부 컴포넌트 선택 핸들러
const handleSelectPanelComponent = useCallback(
(splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => {
if (!compId) {
// 패널 영역 빈 공간 클릭 시 선택 해제
setSelectedPanelComponentInfo(null);
return;
}
setSelectedPanelComponentInfo({
splitPanelId,
panelSide,
componentId: compId,
component: comp,
});
// 분할 패널 내부 컴포넌트 선택 시 일반 컴포넌트/탭 내부 컴포넌트 선택 해제
setSelectedComponent(null);
setSelectedTabComponentInfo(null);
openPanel("v2");
},
[openPanel],
@@ -2509,6 +2542,72 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
}
}
// 🎯 분할 패널 커스텀 모드 컨테이너 내부 드롭 처리
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;
@@ -2996,6 +3095,105 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
}
}
// 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
if (splitPanelContainer && type === "column" && column) {
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;
// V2 컴포넌트 매핑 사용
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 getPanelComponentSize = (widgetType: string) => {
const sizeMap: Record<string, { width: number; height: number }> = {
text: { width: 200, height: 36 },
number: { width: 150, height: 36 },
decimal: { width: 150, height: 36 },
date: { width: 180, height: 36 },
datetime: { width: 200, height: 36 },
select: { width: 200, height: 36 },
category: { width: 200, height: 36 },
code: { width: 200, height: 36 },
entity: { width: 220, height: 36 },
boolean: { width: 120, height: 36 },
checkbox: { width: 120, height: 36 },
textarea: { width: 300, height: 100 },
file: { width: 250, height: 80 },
};
return sizeMap[widgetType] || { width: 200, height: 36 };
};
const componentSize = getPanelComponentSize(column.widgetType);
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: componentSize,
inputType: column.inputType || column.widgetType,
widgetType: column.widgetType,
componentConfig: {
...v2Mapping.componentConfig,
columnName: column.columnName,
tableName: column.tableName,
inputType: column.inputType || column.widgetType,
},
};
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;
@@ -5123,6 +5321,158 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
</div>
);
})()
) : selectedPanelComponentInfo ? (
// 🆕 분할 패널 내부 컴포넌트 선택 시 V2PropertiesPanel 사용
(() => {
const panelComp = selectedPanelComponentInfo.component;
// 분할 패널 내부 컴포넌트를 ComponentData 형식으로 변환
const panelComponentAsComponentData: ComponentData = {
id: panelComp.id,
type: "component",
componentType: panelComp.componentType,
label: panelComp.label,
position: panelComp.position || { x: 0, y: 0 },
size: panelComp.size || { width: 200, height: 100 },
componentConfig: panelComp.componentConfig || {},
style: panelComp.style || {},
} as ComponentData;
// 분할 패널 내부 컴포넌트용 속성 업데이트 핸들러
const updatePanelComponentProperty = (componentId: string, path: string, value: any) => {
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
setLayout((prevLayout) => {
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
if (!splitPanelComponent) return prevLayout;
const currentConfig = (splitPanelComponent as any).componentConfig || {};
const panelConfig = currentConfig[panelKey] || {};
const components = panelConfig.components || [];
// 해당 컴포넌트 찾기
const targetCompIndex = components.findIndex((c: any) => c.id === componentId);
if (targetCompIndex === -1) return prevLayout;
// 컴포넌트 속성 업데이트
const targetComp = components[targetCompIndex];
const updatedComp = path === "style"
? { ...targetComp, style: value }
: path.includes(".")
? (() => {
const parts = path.split(".");
let obj = { ...targetComp };
let current: any = obj;
for (let i = 0; i < parts.length - 1; i++) {
current[parts[i]] = { ...current[parts[i]] };
current = current[parts[i]];
}
current[parts[parts.length - 1]] = value;
return obj;
})()
: { ...targetComp, [path]: value };
const updatedComponents = [
...components.slice(0, targetCompIndex),
updatedComp,
...components.slice(targetCompIndex + 1),
];
const updatedComponent = {
...splitPanelComponent,
componentConfig: {
...currentConfig,
[panelKey]: {
...panelConfig,
components: updatedComponents,
},
},
};
// selectedPanelComponentInfo 업데이트
setSelectedPanelComponentInfo(prev =>
prev ? { ...prev, component: updatedComp } : null
);
return {
...prevLayout,
components: prevLayout.components.map((c) =>
c.id === splitPanelId ? updatedComponent : c
),
};
});
};
// 분할 패널 내부 컴포넌트 삭제 핸들러
const deletePanelComponent = (componentId: string) => {
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
setLayout((prevLayout) => {
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
if (!splitPanelComponent) return prevLayout;
const currentConfig = (splitPanelComponent as any).componentConfig || {};
const panelConfig = currentConfig[panelKey] || {};
const components = panelConfig.components || [];
const updatedComponents = components.filter((c: any) => c.id !== componentId);
const updatedComponent = {
...splitPanelComponent,
componentConfig: {
...currentConfig,
[panelKey]: {
...panelConfig,
components: updatedComponents,
},
},
};
setSelectedPanelComponentInfo(null);
return {
...prevLayout,
components: prevLayout.components.map((c) =>
c.id === splitPanelId ? updatedComponent : c
),
};
});
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-xs text-muted-foreground">
({selectedPanelComponentInfo.panelSide === "left" ? "좌측" : "우측"})
</span>
<button
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => setSelectedPanelComponentInfo(null)}
>
</button>
</div>
<div className="flex-1 overflow-hidden">
<V2PropertiesPanel
selectedComponent={panelComponentAsComponentData}
tables={tables}
onUpdateProperty={updatePanelComponentProperty}
onDeleteComponent={deletePanelComponent}
currentTable={tables.length > 0 ? tables[0] : undefined}
currentTableName={selectedScreen?.tableName}
currentScreenCompanyCode={selectedScreen?.companyCode}
onStyleChange={(style) => {
updatePanelComponentProperty(panelComp.id, "style", style);
}}
allComponents={layout.components}
menuObjid={menuObjid}
/>
</div>
</div>
);
})()
) : (
<V2PropertiesPanel
selectedComponent={selectedComponent || undefined}
@@ -5514,6 +5864,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
? selectedTabComponentInfo.componentId
: undefined
}
// 🆕 분할 패널 내부 컴포넌트 선택 핸들러
onSelectPanelComponent={(panelSide, compId, comp) =>
handleSelectPanelComponent(component.id, panelSide, compId, comp)
}
selectedPanelComponentId={
selectedPanelComponentInfo?.splitPanelId === component.id
? selectedPanelComponentInfo.componentId
: undefined
}
>
{/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
{(component.type === "group" ||