From 4e2209bd5d961e6db116b1513208d75ee91dec4b Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 6 Feb 2026 09:51:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InteractiveScreenViewer 컴포넌트에 레이어 시스템을 도입하여, 레이어의 활성화 및 조건부 표시 로직을 추가하였습니다. - ScreenDesigner 컴포넌트에서 레이어 상태 관리 및 레이어 정보 저장 기능을 구현하였습니다. - 레이어 정의 및 조건부 표시 설정을 위한 새로운 타입과 스키마를 추가하여, 레이어 기반의 UI 구성 요소를 보다 유연하게 관리할 수 있도록 하였습니다. - 레이어별 컴포넌트 렌더링 로직을 추가하여, 모달 및 드로어 형태의 레이어를 효과적으로 처리할 수 있도록 개선하였습니다. - 전반적으로 레이어 시스템을 통해 사용자 경험을 향상시키고, UI 구성의 유연성을 높였습니다. --- .../screen/InteractiveScreenViewer.tsx | 228 +++++++++++- .../components/screen/LayerManagerPanel.tsx | 331 +++++++++++++++++ frontend/components/screen/ScreenDesigner.tsx | 154 ++++++-- .../screen/panels/V2PropertiesPanel.tsx | 23 +- frontend/contexts/LayerContext.tsx | 337 ++++++++++++++++++ .../v2-table-list/TableListConfigPanel.tsx | 136 ++++--- frontend/lib/schemas/componentConfig.ts | 119 ++++++- frontend/types/screen-management.ts | 152 +++++--- 8 files changed, 1336 insertions(+), 144 deletions(-) create mode 100644 frontend/components/screen/LayerManagerPanel.tsx create mode 100644 frontend/contexts/LayerContext.tsx diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index fbcf8243..4a693867 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useEffect, useMemo } from "react"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; @@ -16,7 +16,7 @@ import { useAuth } from "@/hooks/useAuth"; import { uploadFilesAndCreateData } from "@/lib/api/file"; import { toast } from "sonner"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; -import { CascadingDropdownConfig } from "@/types/screen-management"; +import { CascadingDropdownConfig, LayerDefinition } from "@/types/screen-management"; import { ComponentData, WidgetComponent, @@ -164,6 +164,8 @@ interface InteractiveScreenViewerProps { enableAutoSave?: boolean; showToastMessages?: boolean; }; + // 🆕 레이어 시스템 지원 + layers?: LayerDefinition[]; } export const InteractiveScreenViewer: React.FC = ({ @@ -178,6 +180,7 @@ export const InteractiveScreenViewer: React.FC = ( tableColumns = [], showValidationPanel = false, validationOptions = {}, + layers = [], // 🆕 레이어 목록 }) => { // component가 없으면 빈 div 반환 if (!component) { @@ -206,9 +209,71 @@ export const InteractiveScreenViewer: React.FC = ( // 팝업 전용 formData 상태 const [popupFormData, setPopupFormData] = useState>({}); + // 🆕 레이어 상태 관리 (런타임용) + const [activeLayerIds, setActiveLayerIds] = useState([]); + + // 🆕 초기 레이어 설정 (visible인 레이어들) + useEffect(() => { + if (layers.length > 0) { + const initialActiveLayers = layers.filter((l) => l.isVisible).map((l) => l.id); + setActiveLayerIds(initialActiveLayers); + } + }, [layers]); + + // 🆕 레이어 제어 액션 핸들러 + const handleLayerAction = useCallback((action: string, layerId: string) => { + setActiveLayerIds((prev) => { + switch (action) { + case "show": + return [...new Set([...prev, layerId])]; + case "hide": + return prev.filter((id) => id !== layerId); + case "toggle": + return prev.includes(layerId) + ? prev.filter((id) => id !== layerId) + : [...prev, layerId]; + case "exclusive": + // 해당 레이어만 표시 (모달/드로어 같은 특수 레이어 처리에 활용) + return [...prev, layerId]; + default: + return prev; + } + }); + }, []); + // 통합된 폼 데이터 const finalFormData = { ...localFormData, ...externalFormData }; + // 🆕 조건부 레이어 로직 (formData 변경 시 자동 평가) + useEffect(() => { + layers.forEach((layer) => { + if (layer.type === "conditional" && layer.condition) { + const { targetComponentId, operator, value } = layer.condition; + // 컴포넌트 ID를 키로 데이터 조회 - columnName 매핑이 필요할 수 있음 + const targetValue = finalFormData[targetComponentId]; + + let isMatch = false; + switch (operator) { + case "eq": + isMatch = targetValue == value; + break; + case "neq": + isMatch = targetValue != value; + break; + case "in": + isMatch = Array.isArray(value) && value.includes(targetValue); + break; + } + + if (isMatch) { + handleLayerAction("show", layer.id); + } else { + handleLayerAction("hide", layer.id); + } + } + }); + }, [finalFormData, layers, handleLayerAction]); + // 개선된 검증 시스템 (선택적 활성화) const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0 ? useFormValidation( @@ -1395,7 +1460,6 @@ export const InteractiveScreenViewer: React.FC = ( > = ( ))} - , + ); } @@ -2124,6 +2188,159 @@ export const InteractiveScreenViewer: React.FC = ( } : component; + // 🆕 레이어별 컴포넌트 렌더링 함수 + const renderLayerComponents = useCallback((layer: LayerDefinition) => { + // 활성화되지 않은 레이어는 렌더링하지 않음 + if (!activeLayerIds.includes(layer.id)) return null; + + // 모달 레이어 처리 + if (layer.type === "modal") { + const modalStyle: React.CSSProperties = { + ...(layer.overlayConfig?.backgroundColor && { backgroundColor: layer.overlayConfig.backgroundColor }), + ...(layer.overlayConfig?.backdropBlur && { backdropFilter: `blur(${layer.overlayConfig.backdropBlur}px)` }), + }; + + return ( + handleLayerAction("hide", layer.id)}> + + + {layer.name} + +
+ {layer.components.map((comp) => ( +
+ +
+ ))} +
+
+
+ ); + } + + // 드로어 레이어 처리 + if (layer.type === "drawer") { + const drawerPosition = layer.overlayConfig?.position || "right"; + const drawerWidth = layer.overlayConfig?.width || "400px"; + const drawerHeight = layer.overlayConfig?.height || "100%"; + + const drawerPositionStyles: Record = { + right: { right: 0, top: 0, width: drawerWidth, height: "100%" }, + left: { left: 0, top: 0, width: drawerWidth, height: "100%" }, + bottom: { bottom: 0, left: 0, width: "100%", height: drawerHeight }, + top: { top: 0, left: 0, width: "100%", height: drawerHeight }, + }; + + return ( +
handleLayerAction("hide", layer.id)} + > + {/* 백드롭 */} +
+ {/* 드로어 패널 */} +
e.stopPropagation()} + > +
+

{layer.name}

+ +
+
+ {layer.components.map((comp) => ( +
+ +
+ ))} +
+
+
+ ); + } + + // 일반/조건부 레이어 (base, conditional) + return ( +
+ {layer.components.map((comp) => ( +
+ +
+ ))} +
+ ); + }, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo]); + return ( @@ -2147,6 +2364,9 @@ export const InteractiveScreenViewer: React.FC = (
+ {/* 🆕 레이어 렌더링 */} + {layers.length > 0 && layers.map(renderLayerComponents)} + {/* 개선된 검증 패널 (선택적 표시) */} {showValidationPanel && enhancedValidation && (
diff --git a/frontend/components/screen/LayerManagerPanel.tsx b/frontend/components/screen/LayerManagerPanel.tsx new file mode 100644 index 00000000..cd482602 --- /dev/null +++ b/frontend/components/screen/LayerManagerPanel.tsx @@ -0,0 +1,331 @@ +import React, { useState, useMemo } from "react"; +import { useLayer } from "@/contexts/LayerContext"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { Badge } from "@/components/ui/badge"; +import { + Eye, + EyeOff, + Lock, + Unlock, + Plus, + Trash2, + GripVertical, + Layers, + SplitSquareVertical, + PanelRight, + ChevronDown, + Settings2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { LayerType, LayerDefinition, ComponentData } from "@/types/screen-management"; + +// 레이어 타입별 아이콘 +const getLayerTypeIcon = (type: LayerType) => { + switch (type) { + case "base": + return ; + case "conditional": + return ; + case "modal": + return ; + case "drawer": + return ; + default: + return ; + } +}; + +// 레이어 타입별 라벨 +function getLayerTypeLabel(type: LayerType): string { + switch (type) { + case "base": + return "기본"; + case "conditional": + return "조건부"; + case "modal": + return "모달"; + case "drawer": + return "드로어"; + default: + return type; + } +} + +// 레이어 타입별 색상 +function getLayerTypeColor(type: LayerType): string { + switch (type) { + case "base": + return "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"; + case "conditional": + return "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300"; + case "modal": + return "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300"; + case "drawer": + return "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"; + default: + return "bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-300"; + } +} + +interface LayerItemProps { + layer: LayerDefinition; + isActive: boolean; + componentCount: number; // 🆕 실제 컴포넌트 수 (layout.components 기반) + onSelect: () => void; + onToggleVisibility: () => void; + onToggleLock: () => void; + onRemove: () => void; + onUpdateName: (name: string) => void; +} + +const LayerItem: React.FC = ({ + layer, + isActive, + componentCount, + onSelect, + onToggleVisibility, + onToggleLock, + onRemove, + onUpdateName, +}) => { + const [isEditing, setIsEditing] = useState(false); + + return ( +
+ {/* 드래그 핸들 */} + + + {/* 레이어 정보 */} +
+
+ {/* 레이어 타입 아이콘 */} + + {getLayerTypeIcon(layer.type)} + + + {/* 레이어 이름 */} + {isEditing ? ( + onUpdateName(e.target.value)} + onBlur={() => setIsEditing(false)} + onKeyDown={(e) => { + if (e.key === "Enter") setIsEditing(false); + }} + className="flex-1 bg-transparent outline-none border-b border-primary text-sm" + autoFocus + onClick={(e) => e.stopPropagation()} + /> + ) : ( + { + e.stopPropagation(); + setIsEditing(true); + }} + > + {layer.name} + + )} +
+ + {/* 레이어 메타 정보 */} +
+ + {getLayerTypeLabel(layer.type)} + + + {componentCount}개 컴포넌트 + +
+
+ + {/* 액션 버튼들 */} +
+ + + + + {layer.type !== "base" && ( + + )} +
+
+ ); +}; + +interface LayerManagerPanelProps { + components?: ComponentData[]; // layout.components를 전달받음 +} + +export const LayerManagerPanel: React.FC = ({ components = [] }) => { + const { + layers, + activeLayerId, + setActiveLayerId, + addLayer, + removeLayer, + toggleLayerVisibility, + toggleLayerLock, + updateLayer, + } = useLayer(); + + // 🆕 각 레이어별 컴포넌트 수 계산 (layout.components 기반) + const componentCountByLayer = useMemo(() => { + const counts: Record = {}; + + // 모든 레이어를 0으로 초기화 + layers.forEach(layer => { + counts[layer.id] = 0; + }); + + // layout.components에서 layerId별로 카운트 + components.forEach(comp => { + const layerId = comp.layerId || "default-layer"; + if (counts[layerId] !== undefined) { + counts[layerId]++; + } else { + // layerId가 존재하지 않는 레이어인 경우 default-layer로 카운트 + if (counts["default-layer"] !== undefined) { + counts["default-layer"]++; + } + } + }); + + return counts; + }, [components, layers]); + + return ( +
+ {/* 헤더 */} +
+
+ +

레이어

+ + {layers.length} + +
+ + {/* 레이어 추가 드롭다운 */} + + + + + + addLayer("conditional", "조건부 레이어")}> + + 조건부 레이어 + + + addLayer("modal", "모달 레이어")}> + + 모달 레이어 + + addLayer("drawer", "드로어 레이어")}> + + 드로어 레이어 + + + +
+ + {/* 레이어 목록 */} + +
+ {layers.length === 0 ? ( +
+ 레이어가 없습니다. +
+ 위의 + 버튼으로 추가하세요. +
+ ) : ( + layers + .slice() + .reverse() // 상위 레이어가 위에 표시 + .map((layer) => ( + setActiveLayerId(layer.id)} + onToggleVisibility={() => toggleLayerVisibility(layer.id)} + onToggleLock={() => toggleLayerLock(layer.id)} + onRemove={() => removeLayer(layer.id)} + onUpdateName={(name) => updateLayer(layer.id, { name })} + /> + )) + )} +
+
+ + {/* 도움말 */} +
+

더블클릭: 이름 편집 | 드래그: 순서 변경

+
+
+ ); +}; diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index c6ad7437..b4255d00 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -123,9 +123,12 @@ interface ScreenDesignerProps { onScreenUpdate?: (updatedScreen: Partial) => void; } -// 패널 설정 (통합 패널 1개) +import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext"; +import { LayerManagerPanel } from "./LayerManagerPanel"; +import { LayerType, LayerDefinition } from "@/types/screen-management"; + +// 패널 설정 업데이트 const panelConfigs: PanelConfig[] = [ - // 통합 패널 (컴포넌트 + 편집 탭) { id: "v2", title: "패널", @@ -134,12 +137,17 @@ const panelConfigs: PanelConfig[] = [ defaultHeight: 700, shortcutKey: "p", }, + { + id: "layer", + title: "레이어", + defaultPosition: "right", + defaultWidth: 240, + defaultHeight: 500, + shortcutKey: "l", + }, ]; export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) { - // 패널 상태 관리 - const { panelStates, togglePanel, openPanel, closePanel } = usePanelState(panelConfigs); - const [layout, setLayout] = useState({ components: [], gridSettings: { @@ -171,6 +179,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU SCREEN_RESOLUTIONS[0], // 기본값: Full HD ); + // 🆕 패널 상태 관리 (usePanelState 훅) + const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels, updatePanelPosition, updatePanelSize } = + usePanelState(panelConfigs); + const [selectedComponent, setSelectedComponent] = useState(null); // 🆕 탭 내부 컴포넌트 선택 상태 (중첩 구조 지원) @@ -438,6 +450,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU const [tables, setTables] = useState([]); const [searchTerm, setSearchTerm] = useState(""); + // 🆕 검색어로 필터링된 테이블 목록 + const filteredTables = useMemo(() => { + if (!searchTerm.trim()) return tables; + const term = searchTerm.toLowerCase(); + return tables.filter( + (table) => + table.tableName.toLowerCase().includes(term) || + table.columns?.some((col) => col.columnName.toLowerCase().includes(term)), + ); + }, [tables, searchTerm]); + // 그룹 생성 다이얼로그 const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false); @@ -462,15 +485,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU return lines; }, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]); - // 필터된 테이블 목록 - const filteredTables = useMemo(() => { - if (!searchTerm) return tables; - return tables.filter( - (table) => - table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || - table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())), - ); - }, [tables, searchTerm]); + // 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리) + const [activeLayerId, setActiveLayerIdLocal] = useState("default-layer"); + + // 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반) + // 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시 + // layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리 + const visibleComponents = useMemo(() => { + // 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시 + if (!activeLayerId) { + return layout.components; + } + + // 활성 레이어에 속한 컴포넌트만 필터링 + return layout.components.filter((comp) => { + // layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리 + const compLayerId = comp.layerId || "default-layer"; + return compLayerId === activeLayerId; + }); + }, [layout.components, activeLayerId]); // 이미 배치된 컬럼 목록 계산 const placedColumns = useMemo(() => { @@ -1798,9 +1831,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU // 현재 선택된 테이블을 화면의 기본 테이블로 저장 const currentMainTableName = tables.length > 0 ? tables[0].tableName : null; + // 🆕 레이어 정보도 함께 저장 (레이어가 있으면 레이어의 컴포넌트로 업데이트) + const updatedLayers = layout.layers?.map((layer) => ({ + ...layer, + components: layer.components.map((comp) => { + // 분할 패널 업데이트 로직 적용 + const updatedComp = updatedComponents.find((uc) => uc.id === comp.id); + return updatedComp || comp; + }), + })); + const layoutWithResolution = { ...layout, components: updatedComponents, + layers: updatedLayers, // 🆕 레이어 정보 포함 screenResolution: screenResolution, mainTableName: currentMainTableName, // 화면의 기본 테이블 }; @@ -2339,23 +2383,29 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU } }); + // 🆕 현재 활성 레이어에 컴포넌트 추가 + const componentsWithLayerId = newComponents.map((comp) => ({ + ...comp, + layerId: activeLayerId || "default-layer", + })); + // 레이아웃에 새 컴포넌트들 추가 const newLayout = { ...layout, - components: [...layout.components, ...newComponents], + components: [...layout.components, ...componentsWithLayerId], }; setLayout(newLayout); saveToHistory(newLayout); // 첫 번째 컴포넌트 선택 - if (newComponents.length > 0) { - setSelectedComponent(newComponents[0]); + if (componentsWithLayerId.length > 0) { + setSelectedComponent(componentsWithLayerId[0]); } toast.success(`${template.name} 템플릿이 추가되었습니다.`); }, - [layout, selectedScreen, saveToHistory], + [layout, selectedScreen, saveToHistory, activeLayerId], ); // 레이아웃 드래그 처리 @@ -2409,6 +2459,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU label: layoutData.label, allowedComponentTypes: layoutData.allowedComponentTypes, dropZoneConfig: layoutData.dropZoneConfig, + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 } as ComponentData; // 레이아웃에 새 컴포넌트 추가 @@ -2425,7 +2476,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`); }, - [layout, screenResolution, saveToHistory, zoomLevel], + [layout, screenResolution, saveToHistory, zoomLevel, activeLayerId], ); // handleZoneComponentDrop은 handleComponentDrop으로 대체됨 @@ -3016,6 +3067,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU position: snappedPosition, size: componentSize, gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용 + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 componentConfig: { type: component.id, // 새 컴포넌트 시스템의 ID 사용 webType: component.webType, // 웹타입 정보 추가 @@ -3049,7 +3101,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU toast.success(`${component.name} 컴포넌트가 추가되었습니다.`); }, - [layout, selectedScreen, saveToHistory], + [layout, selectedScreen, saveToHistory, activeLayerId], ); // 드래그 앤 드롭 처리 @@ -3421,6 +3473,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU tableName: table.tableName, position: { x, y, z: 1 } as Position, size: { width: 300, height: 200 }, + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 style: { labelDisplay: true, labelFontSize: "14px", @@ -3671,6 +3724,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU componentType: v2Mapping.componentType, // v2-input, v2-select 등 position: { x: relativeX, y: relativeY, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && column.codeCategory && { @@ -3737,6 +3791,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU componentType: v2Mapping.componentType, // v2-input, v2-select 등 position: { x, y, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 // 코드 타입인 경우 코드 카테고리 정보 추가 ...(column.widgetType === "code" && column.codeCategory && { @@ -4388,7 +4443,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU bottom: Math.max(selectionDrag.startPoint.y, currentPoint.y), }; - const selectedIds = layout.components + // 🆕 visibleComponents만 선택 대상으로 (현재 활성 레이어의 컴포넌트만) + const selectedIds = visibleComponents .filter((comp) => { const compRect = { left: comp.position.x, @@ -4411,7 +4467,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU selectedComponents: selectedIds, })); }, - [selectionDrag.isSelecting, selectionDrag.startPoint, layout.components, zoomLevel], + [selectionDrag.isSelecting, selectionDrag.startPoint, visibleComponents, zoomLevel], ); // 드래그 선택 종료 @@ -4558,6 +4614,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU z: clipComponent.position.z || 1, } as Position, parentId: undefined, // 붙여넣기 시 부모 관계 해제 + layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기 }; newComponents.push(newComponent); }); @@ -4578,7 +4635,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU // console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개"); toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`); - }, [clipboard, layout, saveToHistory]); + }, [clipboard, layout, saveToHistory, activeLayerId]); // 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로) // 🆕 플로우 버튼 그룹 다이얼로그 상태 @@ -5374,6 +5431,36 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU }; }, [layout, selectedComponent]); + // 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영 + // 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음 + const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => { + setLayout((prevLayout) => ({ + ...prevLayout, + layers: newLayers, + // components는 그대로 유지 - layerId 속성으로 레이어 구분 + // components: prevLayout.components (기본값으로 유지됨) + })); + }, []); + + // 🆕 활성 레이어 변경 핸들러 + const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => { + setActiveLayerIdLocal(newActiveLayerId); + }, []); + + // 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성 + // 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠 + const initialLayers = useMemo(() => { + if (layout.layers && layout.layers.length > 0) { + // 기존 레이어 구조 사용 (layer.components는 무시하고 빈 배열로 설정) + return layout.layers.map(layer => ({ + ...layer, + components: [], // layout.components + layerId 방식 사용 + })); + } + // layers가 없으면 기본 레이어 생성 (components는 빈 배열) + return [createDefaultLayer()]; + }, [layout.layers]); + if (!selectedScreen) { return (
@@ -5393,7 +5480,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU return ( - + +
{/* 상단 슬림 툴바 */}
- + 컴포넌트 + + 레이어 + 편집 @@ -5457,6 +5552,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU /> + {/* 🆕 레이어 관리 탭 */} + + + + {/* 탭 내부 컴포넌트 선택 시에도 V2PropertiesPanel 사용 */} {selectedTabComponentInfo ? ( @@ -6088,7 +6188,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU {/* 컴포넌트들 */} {(() => { // 🆕 플로우 버튼 그룹 감지 및 처리 - const topLevelComponents = layout.components.filter((component) => !component.parentId); + // visibleComponents를 사용하여 활성 레이어의 컴포넌트만 표시 + const topLevelComponents = visibleComponents.filter((component) => !component.parentId); // auto-compact 모드의 버튼들을 그룹별로 묶기 const buttonGroups: Record = {}; @@ -6740,6 +6841,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU />
+ ); } diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index 9c949514..cb6547c5 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -208,17 +208,14 @@ export const V2PropertiesPanel: React.FC = ({ if (componentId?.startsWith("v2-")) { const v2ConfigPanels: Record void }>> = { "v2-input": require("@/components/v2/config-panels/V2InputConfigPanel").V2InputConfigPanel, - "v2-select": require("@/components/v2/config-panels/V2SelectConfigPanel") - .V2SelectConfigPanel, + "v2-select": require("@/components/v2/config-panels/V2SelectConfigPanel").V2SelectConfigPanel, "v2-date": require("@/components/v2/config-panels/V2DateConfigPanel").V2DateConfigPanel, "v2-list": require("@/components/v2/config-panels/V2ListConfigPanel").V2ListConfigPanel, - "v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel") - .V2LayoutConfigPanel, + "v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel").V2LayoutConfigPanel, "v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel, "v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel, "v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel, - "v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel") - .V2HierarchyConfigPanel, + "v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel, }; const V2ConfigPanel = v2ConfigPanels[componentId]; @@ -823,7 +820,11 @@ export const V2PropertiesPanel: React.FC = ({
{ handleUpdate("style.labelText", e.target.value); handleUpdate("label", e.target.value); // label도 함께 업데이트 @@ -870,10 +871,7 @@ export const V2PropertiesPanel: React.FC = ({ handleUpdate("labelDisplay", boolValue); // labelText도 설정 (처음 켤 때 라벨 텍스트가 없을 수 있음) if (boolValue && !selectedComponent.style?.labelText) { - const labelValue = - selectedComponent.label || - selectedComponent.componentConfig?.label || - ""; + const labelValue = selectedComponent.label || selectedComponent.componentConfig?.label || ""; if (labelValue) { handleUpdate("style.labelText", labelValue); } @@ -963,8 +961,7 @@ export const V2PropertiesPanel: React.FC = ({ } // 🆕 3.5. V2 컴포넌트 - 반드시 다른 체크보다 먼저 처리 - const v2ComponentType = - (selectedComponent as any).componentType || selectedComponent.componentConfig?.type || ""; + const v2ComponentType = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type || ""; if (v2ComponentType.startsWith("v2-")) { const configPanel = renderComponentConfigPanel(); if (configPanel) { diff --git a/frontend/contexts/LayerContext.tsx b/frontend/contexts/LayerContext.tsx new file mode 100644 index 00000000..6e0f67cd --- /dev/null +++ b/frontend/contexts/LayerContext.tsx @@ -0,0 +1,337 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode, useMemo } from "react"; +import { LayerDefinition, LayerType, ComponentData } from "@/types/screen-management"; +import { v4 as uuidv4 } from "uuid"; + +interface LayerContextType { + // 레이어 상태 + layers: LayerDefinition[]; + activeLayerId: string | null; + activeLayer: LayerDefinition | null; + + // 레이어 관리 + setLayers: (layers: LayerDefinition[]) => void; + setActiveLayerId: (id: string | null) => void; + addLayer: (type: LayerType, name?: string) => void; + removeLayer: (id: string) => void; + updateLayer: (id: string, updates: Partial) => void; + moveLayer: (dragIndex: number, hoverIndex: number) => void; + toggleLayerVisibility: (id: string) => void; + toggleLayerLock: (id: string) => void; + getLayerById: (id: string) => LayerDefinition | undefined; + + // 컴포넌트 관리 (레이어별) + addComponentToLayer: (layerId: string, component: ComponentData) => void; + removeComponentFromLayer: (layerId: string, componentId: string) => void; + updateComponentInLayer: (layerId: string, componentId: string, updates: Partial) => void; + moveComponentToLayer: (componentId: string, fromLayerId: string, toLayerId: string) => void; + + // 컴포넌트 조회 + getAllComponents: () => ComponentData[]; + getComponentById: (componentId: string) => { component: ComponentData; layerId: string } | null; + getComponentsInActiveLayer: () => ComponentData[]; + + // 레이어 가시성 (런타임용) + runtimeVisibleLayers: string[]; + setRuntimeVisibleLayers: React.Dispatch>; + showLayer: (layerId: string) => void; + hideLayer: (layerId: string) => void; + toggleLayerRuntime: (layerId: string) => void; +} + +const LayerContext = createContext(undefined); + +export const useLayer = () => { + const context = useContext(LayerContext); + if (!context) { + throw new Error("useLayer must be used within a LayerProvider"); + } + return context; +}; + +// LayerProvider가 없을 때 사용할 기본 컨텍스트 (선택적 사용) +export const useLayerOptional = () => { + return useContext(LayerContext); +}; + +interface LayerProviderProps { + children: ReactNode; + initialLayers?: LayerDefinition[]; + onLayersChange?: (layers: LayerDefinition[]) => void; + onActiveLayerChange?: (activeLayerId: string | null) => void; // 🆕 활성 레이어 변경 콜백 +} + +// 기본 레이어 생성 헬퍼 +export const createDefaultLayer = (components?: ComponentData[]): LayerDefinition => ({ + id: "default-layer", + name: "기본 레이어", + type: "base", + zIndex: 0, + isVisible: true, + isLocked: false, + components: components || [], +}); + +export const LayerProvider: React.FC = ({ + children, + initialLayers = [], + onLayersChange, + onActiveLayerChange, +}) => { + // 초기 레이어가 없으면 기본 레이어 생성 + const effectiveInitialLayers = initialLayers.length > 0 + ? initialLayers + : [createDefaultLayer()]; + + const [layers, setLayersState] = useState(effectiveInitialLayers); + const [activeLayerIdState, setActiveLayerIdState] = useState( + effectiveInitialLayers.length > 0 ? effectiveInitialLayers[0].id : null, + ); + + // 🆕 활성 레이어 변경 시 콜백 호출 + const setActiveLayerId = useCallback((id: string | null) => { + setActiveLayerIdState(id); + onActiveLayerChange?.(id); + }, [onActiveLayerChange]); + + // 활성 레이어 ID (내부 상태 사용) + const activeLayerId = activeLayerIdState; + + // 런타임 가시성 상태 (편집기에서의 isVisible과 별개) + const [runtimeVisibleLayers, setRuntimeVisibleLayers] = useState( + effectiveInitialLayers.filter(l => l.isVisible).map(l => l.id) + ); + + // 레이어 변경 시 콜백 호출 + const setLayers = useCallback((newLayers: LayerDefinition[]) => { + setLayersState(newLayers); + onLayersChange?.(newLayers); + }, [onLayersChange]); + + // 활성 레이어 계산 + const activeLayer = useMemo(() => { + return layers.find(l => l.id === activeLayerId) || null; + }, [layers, activeLayerId]); + + const addLayer = useCallback( + (type: LayerType, name?: string) => { + const newLayer: LayerDefinition = { + id: uuidv4(), + name: name || `새 레이어 ${layers.length + 1}`, + type, + zIndex: layers.length, + isVisible: true, + isLocked: false, + components: [], + // 모달/드로어 기본 설정 + ...(type === "modal" || type === "drawer" ? { + overlayConfig: { + backdrop: true, + closeOnBackdropClick: true, + width: type === "drawer" ? "320px" : "600px", + height: type === "drawer" ? "100%" : "auto", + }, + } : {}), + }; + + setLayers([...layers, newLayer]); + setActiveLayerId(newLayer.id); + // 새 레이어는 런타임에서도 기본적으로 표시 + setRuntimeVisibleLayers(prev => [...prev, newLayer.id]); + }, + [layers, setLayers], + ); + + const removeLayer = useCallback( + (id: string) => { + // 기본 레이어는 삭제 불가 + const layer = layers.find(l => l.id === id); + if (layer?.type === "base") { + console.warn("기본 레이어는 삭제할 수 없습니다."); + return; + } + + const filtered = layers.filter((layer) => layer.id !== id); + setLayers(filtered); + + if (activeLayerId === id) { + setActiveLayerId(filtered.length > 0 ? filtered[0].id : null); + } + + setRuntimeVisibleLayers(prev => prev.filter(lid => lid !== id)); + }, + [layers, activeLayerId, setLayers], + ); + + const updateLayer = useCallback((id: string, updates: Partial) => { + setLayers(layers.map((layer) => (layer.id === id ? { ...layer, ...updates } : layer))); + }, [layers, setLayers]); + + const moveLayer = useCallback((dragIndex: number, hoverIndex: number) => { + const newLayers = [...layers]; + const [removed] = newLayers.splice(dragIndex, 1); + newLayers.splice(hoverIndex, 0, removed); + // Update zIndex based on new order + setLayers(newLayers.map((layer, index) => ({ ...layer, zIndex: index }))); + }, [layers, setLayers]); + + const toggleLayerVisibility = useCallback((id: string) => { + setLayers(layers.map((layer) => (layer.id === id ? { ...layer, isVisible: !layer.isVisible } : layer))); + }, [layers, setLayers]); + + const toggleLayerLock = useCallback((id: string) => { + setLayers(layers.map((layer) => (layer.id === id ? { ...layer, isLocked: !layer.isLocked } : layer))); + }, [layers, setLayers]); + + const getLayerById = useCallback( + (id: string) => { + return layers.find((layer) => layer.id === id); + }, + [layers], + ); + + // ===== 컴포넌트 관리 함수 ===== + + const addComponentToLayer = useCallback((layerId: string, component: ComponentData) => { + setLayers(layers.map(layer => { + if (layer.id === layerId) { + return { + ...layer, + components: [...layer.components, component], + }; + } + return layer; + })); + }, [layers, setLayers]); + + const removeComponentFromLayer = useCallback((layerId: string, componentId: string) => { + setLayers(layers.map(layer => { + if (layer.id === layerId) { + return { + ...layer, + components: layer.components.filter(c => c.id !== componentId), + }; + } + return layer; + })); + }, [layers, setLayers]); + + const updateComponentInLayer = useCallback((layerId: string, componentId: string, updates: Partial) => { + setLayers(layers.map(layer => { + if (layer.id === layerId) { + return { + ...layer, + components: layer.components.map(c => + c.id === componentId ? { ...c, ...updates } as ComponentData : c + ), + }; + } + return layer; + })); + }, [layers, setLayers]); + + const moveComponentToLayer = useCallback((componentId: string, fromLayerId: string, toLayerId: string) => { + if (fromLayerId === toLayerId) return; + + const fromLayer = layers.find(l => l.id === fromLayerId); + const component = fromLayer?.components.find(c => c.id === componentId); + + if (!component) return; + + setLayers(layers.map(layer => { + if (layer.id === fromLayerId) { + return { + ...layer, + components: layer.components.filter(c => c.id !== componentId), + }; + } + if (layer.id === toLayerId) { + return { + ...layer, + components: [...layer.components, component], + }; + } + return layer; + })); + }, [layers, setLayers]); + + // ===== 컴포넌트 조회 함수 ===== + + const getAllComponents = useCallback((): ComponentData[] => { + return layers.flatMap(layer => layer.components); + }, [layers]); + + const getComponentById = useCallback((componentId: string): { component: ComponentData; layerId: string } | null => { + for (const layer of layers) { + const component = layer.components.find(c => c.id === componentId); + if (component) { + return { component, layerId: layer.id }; + } + } + return null; + }, [layers]); + + const getComponentsInActiveLayer = useCallback((): ComponentData[] => { + const layer = layers.find(l => l.id === activeLayerId); + return layer?.components || []; + }, [layers, activeLayerId]); + + // ===== 런타임 레이어 가시성 관리 ===== + + const showLayer = useCallback((layerId: string) => { + setRuntimeVisibleLayers(prev => [...new Set([...prev, layerId])]); + }, []); + + const hideLayer = useCallback((layerId: string) => { + setRuntimeVisibleLayers(prev => prev.filter(id => id !== layerId)); + }, []); + + const toggleLayerRuntime = useCallback((layerId: string) => { + setRuntimeVisibleLayers(prev => + prev.includes(layerId) + ? prev.filter(id => id !== layerId) + : [...prev, layerId] + ); + }, []); + + return ( + + {children} + + ); +}; diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx index 977830ca..8f14a250 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx @@ -10,7 +10,19 @@ import { TableListConfig, ColumnConfig } from "./types"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { tableTypeApi } from "@/lib/api/screen"; import { tableManagementApi } from "@/lib/api/tableManagement"; -import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2 } from "lucide-react"; +import { + Plus, + Trash2, + ArrowUp, + ArrowDown, + ChevronsUpDown, + Check, + Lock, + Unlock, + Database, + Table2, + Link2, +} from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { cn } from "@/lib/utils"; @@ -35,7 +47,7 @@ export const TableListConfigPanel: React.FC = ({ }) => { // config가 undefined인 경우 빈 객체로 초기화 const config = configProp || {}; - + // console.log("🔍 TableListConfigPanel props:", { // config, // configType: typeof config, @@ -202,12 +214,12 @@ export const TableListConfigPanel: React.FC = ({ try { const result = await tableManagementApi.getColumnList(targetTableName); console.log("🔧 tableManagementApi 응답:", result); - - if (result.success && result.data) { + + if (result.success && result.data) { // API 응답 구조: { columns: [...], total, page, ... } const columns = Array.isArray(result.data) ? result.data : result.data.columns; console.log("🔧 컬럼 배열:", columns); - + if (columns && Array.isArray(columns)) { setAvailableColumns( columns.map((col: any) => ({ @@ -779,7 +791,9 @@ export const TableListConfigPanel: React.FC = ({ checked={config.toolbar?.showEditMode ?? false} onCheckedChange={(checked) => handleNestedChange("toolbar", "showEditMode", checked)} /> - +
= ({ checked={config.toolbar?.showExcel ?? false} onCheckedChange={(checked) => handleNestedChange("toolbar", "showExcel", checked)} /> - +
= ({ checked={config.toolbar?.showPdf ?? false} onCheckedChange={(checked) => handleNestedChange("toolbar", "showPdf", checked)} /> - +
= ({ checked={config.toolbar?.showCopy ?? false} onCheckedChange={(checked) => handleNestedChange("toolbar", "showCopy", checked)} /> - +
= ({ checked={config.toolbar?.showSearch ?? false} onCheckedChange={(checked) => handleNestedChange("toolbar", "showSearch", checked)} /> - +
= ({ checked={config.toolbar?.showFilter ?? false} onCheckedChange={(checked) => handleNestedChange("toolbar", "showFilter", checked)} /> - +
= ({ checked={config.toolbar?.showRefresh ?? false} onCheckedChange={(checked) => handleNestedChange("toolbar", "showRefresh", checked)} /> - +
= ({ checked={config.toolbar?.showPaginationRefresh ?? true} onCheckedChange={(checked) => handleNestedChange("toolbar", "showPaginationRefresh", checked)} /> - +
@@ -1159,7 +1187,7 @@ export const TableListConfigPanel: React.FC = ({

컬럼 선택

-

표시할 컬럼을 선택하세요

+

표시할 컬럼을 선택하세요


{availableColumns.length > 0 ? ( @@ -1176,7 +1204,10 @@ export const TableListConfigPanel: React.FC = ({ onClick={() => { if (isAdded) { // 컬럼 제거 - handleChange("columns", config.columns?.filter((c) => c.columnName !== column.columnName) || []); + handleChange( + "columns", + config.columns?.filter((c) => c.columnName !== column.columnName) || [], + ); } else { // 컬럼 추가 addColumn(column.columnName); @@ -1187,7 +1218,10 @@ export const TableListConfigPanel: React.FC = ({ checked={isAdded} onCheckedChange={() => { if (isAdded) { - handleChange("columns", config.columns?.filter((c) => c.columnName !== column.columnName) || []); + handleChange( + "columns", + config.columns?.filter((c) => c.columnName !== column.columnName) || [], + ); } else { addColumn(column.columnName); } @@ -1196,7 +1230,9 @@ export const TableListConfigPanel: React.FC = ({ /> {column.label || column.columnName} - {column.input_type || column.dataType} + + {column.input_type || column.dataType} +
); })} @@ -1211,13 +1247,13 @@ export const TableListConfigPanel: React.FC = ({

Entity 조인 컬럼

-

연관 테이블의 컬럼을 선택하세요

+

연관 테이블의 컬럼을 선택하세요


{entityJoinColumns.joinTables.map((joinTable, tableIndex) => (
-
+
{joinTable.tableName} @@ -1225,56 +1261,65 @@ export const TableListConfigPanel: React.FC = ({
- {joinTable.availableColumns.map((column, colIndex) => { - const matchingJoinColumn = entityJoinColumns.availableColumns.find( - (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, - ); + {joinTable.availableColumns.map((column, colIndex) => { + const matchingJoinColumn = entityJoinColumns.availableColumns.find( + (jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName, + ); - const isAlreadyAdded = config.columns?.some( - (col) => col.columnName === matchingJoinColumn?.joinAlias, - ); + const isAlreadyAdded = config.columns?.some( + (col) => col.columnName === matchingJoinColumn?.joinAlias, + ); if (!matchingJoinColumn) return null; - return ( -
{ + onClick={() => { if (isAlreadyAdded) { // 컬럼 제거 - handleChange("columns", config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || []); + handleChange( + "columns", + config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || [], + ); } else { // 컬럼 추가 addEntityColumn(matchingJoinColumn); } }} > - { if (isAlreadyAdded) { - handleChange("columns", config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || []); - } else { + handleChange( + "columns", + config.columns?.filter((c) => c.columnName !== matchingJoinColumn.joinAlias) || + [], + ); + } else { addEntityColumn(matchingJoinColumn); - } - }} + } + }} className="pointer-events-none h-3.5 w-3.5" /> - + {column.columnLabel} - {column.inputType || column.dataType} -
- ); - })} -
-
+ + {column.inputType || column.dataType} + +
+ ); + })} +
+
))} -
- + + )} )} @@ -1301,7 +1346,6 @@ export const TableListConfigPanel: React.FC = ({ onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)} /> - ); diff --git a/frontend/lib/schemas/componentConfig.ts b/frontend/lib/schemas/componentConfig.ts index 8353ac05..82037cd0 100644 --- a/frontend/lib/schemas/componentConfig.ts +++ b/frontend/lib/schemas/componentConfig.ts @@ -148,9 +148,53 @@ export const componentV2Schema = z.object({ overrides: z.record(z.string(), z.any()).default({}), }); -export const layoutV2Schema = z.object({ - version: z.string().default("2.0"), +// ============================================ +// 레이어 스키마 정의 +// ============================================ +export const layerTypeSchema = z.enum(["base", "conditional", "modal", "drawer"]); + +export const layerSchema = z.object({ + id: z.string(), + name: z.string(), + type: layerTypeSchema, + zIndex: z.number().default(0), + isVisible: z.boolean().default(true), // 초기 표시 여부 + isLocked: z.boolean().default(false), // 편집 잠금 여부 + + // 조건부 표시 로직 + condition: z + .object({ + targetComponentId: z.string(), + operator: z.enum(["eq", "neq", "in"]), + value: z.any(), + }) + .optional(), + + // 모달/드로어 전용 설정 + overlayConfig: z + .object({ + backdrop: z.boolean().default(true), + closeOnBackdropClick: z.boolean().default(true), + width: z.union([z.string(), z.number()]).optional(), + height: z.union([z.string(), z.number()]).optional(), + // 모달/드로어 스타일링 + backgroundColor: z.string().optional(), + backdropBlur: z.number().optional(), + // 드로어 전용 + position: z.enum(["left", "right", "top", "bottom"]).optional(), + }) + .optional(), + + // 해당 레이어에 속한 컴포넌트들 components: z.array(componentV2Schema).default([]), +}); + +export type Layer = z.infer; + +export const layoutV2Schema = z.object({ + version: z.string().default("2.1"), + layers: z.array(layerSchema).default([]), // 신규 필드 + components: z.array(componentV2Schema).default([]), // 하위 호환성 유지 updatedAt: z.string().optional(), screenResolution: z .object({ @@ -952,23 +996,78 @@ export function saveComponentV2(component: ComponentV2 & { config?: Record }> } { - const parsed = layoutV2Schema.parse(layoutData || { version: "2.0", components: [] }); +export function loadLayoutV2(layoutData: any): LayoutV2 & { + components: Array }>; + layers: Array }> }>; +} { + const parsed = layoutV2Schema.parse(layoutData || { version: "2.1", components: [], layers: [] }); + + // 마이그레이션: components만 있고 layers가 없는 경우 Default Layer 생성 + if ((!parsed.layers || parsed.layers.length === 0) && parsed.components && parsed.components.length > 0) { + const defaultLayer: Layer = { + id: "default-layer", + name: "기본 레이어", + type: "base", + zIndex: 0, + isVisible: true, + isLocked: false, + components: parsed.components, + }; + parsed.layers = [defaultLayer]; + } + + // 모든 레이어의 컴포넌트 로드 + const loadedLayers = parsed.layers.map((layer) => ({ + ...layer, + components: layer.components.map(loadComponentV2), + })); + + // 하위 호환성을 위한 components 배열 (모든 레이어의 컴포넌트 합침) + const allComponents = loadedLayers.flatMap((layer) => layer.components); return { ...parsed, - components: parsed.components.map(loadComponentV2), + layers: loadedLayers, + components: allComponents, }; } // ============================================ // V2 레이아웃 저장 (전체 컴포넌트 차이값 추출) // ============================================ -export function saveLayoutV2(components: Array }>): LayoutV2 { +export function saveLayoutV2( + components: Array }>, + layers?: Array }> }>, +): LayoutV2 { + // 레이어가 있는 경우 레이어 구조 저장 + if (layers && layers.length > 0) { + const savedLayers = layers.map((layer) => ({ + ...layer, + components: layer.components.map(saveComponentV2), + })); + + return { + version: "2.1", + layers: savedLayers, + components: savedLayers.flatMap((l) => l.components), // 하위 호환성 + }; + } + + // 레이어가 없는 경우 (기존 방식) - Default Layer로 감싸서 저장 + const savedComponents = components.map(saveComponentV2); + const defaultLayer: Layer = { + id: "default-layer", + name: "기본 레이어", + type: "base", + zIndex: 0, + isVisible: true, + isLocked: false, + components: savedComponents, + }; + return { - version: "2.0", - components: components.map(saveComponentV2), + version: "2.1", + layers: [defaultLayer], + components: savedComponents, }; } diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index 67e8a934..4fa22259 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -38,6 +38,9 @@ export interface BaseComponent { gridColumnStart?: number; // 시작 컬럼 (1-12) gridRowIndex?: number; // 행 인덱스 + // 🆕 레이어 시스템 + layerId?: string; // 컴포넌트가 속한 레이어 ID + parentId?: string; label?: string; required?: boolean; @@ -102,13 +105,13 @@ export interface WidgetComponent extends BaseComponent { entityConfig?: EntityTypeConfig; buttonConfig?: ButtonTypeConfig; arrayConfig?: ArrayTypeConfig; - + // 🆕 자동 입력 설정 (테이블 조회 기반) autoFill?: { enabled: boolean; // 자동 입력 활성화 sourceTable: string; // 조회할 테이블 (예: company_mng) filterColumn: string; // 필터링할 컬럼 (예: company_code) - userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보 필드 + userField: "companyCode" | "userId" | "deptCode"; // 사용자 정보 필드 displayColumn: string; // 표시할 컬럼 (예: company_name) }; } @@ -148,12 +151,12 @@ export interface DataTableComponent extends BaseComponent { searchable?: boolean; sortable?: boolean; filters?: DataTableFilter[]; - + // 🆕 현재 사용자 정보로 자동 필터링 autoFilter?: { enabled: boolean; // 자동 필터 활성화 여부 filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code) - userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드 + userField: "companyCode" | "userId" | "deptCode"; // 사용자 정보에서 가져올 필드 }; // 🆕 컬럼 값 기반 데이터 필터링 @@ -307,13 +310,13 @@ export interface SelectTypeConfig { required?: boolean; readonly?: boolean; emptyMessage?: string; - + /** 🆕 연쇄 드롭다운 관계 코드 (관계 관리에서 정의한 코드) */ cascadingRelationCode?: string; - + /** 🆕 연쇄 드롭다운 부모 필드명 (화면 내 다른 필드의 columnName) */ cascadingParentField?: string; - + /** @deprecated 직접 설정 방식 - cascadingRelationCode 사용 권장 */ cascading?: CascadingDropdownConfig; } @@ -402,10 +405,10 @@ export interface EntityTypeConfig { /** * 🆕 연쇄 드롭다운(Cascading Dropdown) 설정 - * + * * 부모 필드의 값에 따라 자식 드롭다운의 옵션이 동적으로 변경됩니다. * 예: 창고 선택 → 해당 창고의 위치만 표시 - * + * * @example * // 창고 → 위치 연쇄 드롭다운 * { @@ -420,34 +423,34 @@ export interface EntityTypeConfig { export interface CascadingDropdownConfig { /** 연쇄 드롭다운 활성화 여부 */ enabled: boolean; - + /** 부모 필드명 (이 필드의 값에 따라 옵션이 필터링됨) */ parentField: string; - + /** 옵션을 조회할 테이블명 */ sourceTable: string; - + /** 부모 값과 매칭할 컬럼명 (sourceTable의 컬럼) */ parentKeyColumn: string; - + /** 드롭다운 value로 사용할 컬럼명 */ valueColumn: string; - + /** 드롭다운 label로 표시할 컬럼명 */ labelColumn: string; - + /** 추가 필터 조건 (선택사항) */ additionalFilters?: Record; - + /** 부모 값이 없을 때 표시할 메시지 */ emptyParentMessage?: string; - + /** 옵션이 없을 때 표시할 메시지 */ noOptionsMessage?: string; - + /** 로딩 중 표시할 메시지 */ loadingMessage?: string; - + /** 부모 값 변경 시 자동으로 값 초기화 */ clearOnParentChange?: boolean; } @@ -472,23 +475,23 @@ export interface ButtonTypeConfig { export interface QuickInsertColumnMapping { /** 저장할 테이블의 대상 컬럼명 */ targetColumn: string; - + /** 값 소스 타입 */ sourceType: "component" | "leftPanel" | "fixed" | "currentUser"; - + // sourceType별 추가 설정 /** component: 값을 가져올 컴포넌트 ID */ sourceComponentId?: string; - + /** component: 컴포넌트의 columnName (formData 접근용) */ sourceColumnName?: string; - + /** leftPanel: 좌측 선택 데이터의 컬럼명 */ sourceColumn?: string; - + /** fixed: 고정값 */ fixedValue?: any; - + /** currentUser: 사용자 정보 필드 */ userField?: "userId" | "userName" | "companyCode" | "deptCode"; } @@ -499,13 +502,13 @@ export interface QuickInsertColumnMapping { export interface QuickInsertAfterAction { /** 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트) */ refreshData?: boolean; - + /** 초기화할 컴포넌트 ID 목록 */ clearComponents?: string[]; - + /** 성공 메시지 표시 여부 */ showSuccessMessage?: boolean; - + /** 커스텀 성공 메시지 */ successMessage?: string; } @@ -516,20 +519,20 @@ export interface QuickInsertAfterAction { export interface QuickInsertDuplicateCheck { /** 중복 체크 활성화 */ enabled: boolean; - + /** 중복 체크할 컬럼들 */ columns: string[]; - + /** 중복 시 에러 메시지 */ errorMessage?: string; } /** * 즉시 저장(quickInsert) 버튼 액션 설정 - * + * * 화면에서 entity 타입 선택박스로 데이터를 선택한 후, * 버튼 클릭 시 특정 테이블에 즉시 INSERT하는 기능 - * + * * @example * ```typescript * const config: QuickInsertConfig = { @@ -557,13 +560,13 @@ export interface QuickInsertDuplicateCheck { export interface QuickInsertConfig { /** 저장할 대상 테이블명 */ targetTable: string; - + /** 컬럼 매핑 설정 */ columnMappings: QuickInsertColumnMapping[]; - + /** 저장 후 동작 설정 */ afterInsert?: QuickInsertAfterAction; - + /** 중복 체크 설정 (선택사항) */ duplicateCheck?: QuickInsertDuplicateCheck; } @@ -678,15 +681,15 @@ export interface DataTableFilter { export interface ColumnFilter { id: string; columnName: string; // 필터링할 컬럼명 - operator: - | "equals" - | "not_equals" - | "in" - | "not_in" - | "contains" - | "starts_with" - | "ends_with" - | "is_null" + operator: + | "equals" + | "not_equals" + | "in" + | "not_in" + | "contains" + | "starts_with" + | "ends_with" + | "is_null" | "is_not_null" | "greater_than" | "less_than" @@ -836,12 +839,71 @@ export interface GroupState { groupTitle?: string; } +// ============================================ +// 레이어 시스템 타입 정의 +// ============================================ + +/** + * 레이어 타입 + * - base: 기본 레이어 (항상 표시) + * - conditional: 조건부 레이어 (특정 조건 만족 시 표시) + * - modal: 모달 레이어 (팝업 형태) + * - drawer: 드로어 레이어 (사이드 패널 형태) + */ +export type LayerType = "base" | "conditional" | "modal" | "drawer"; + +/** + * 레이어 조건부 표시 설정 + */ +export interface LayerCondition { + targetComponentId: string; // 트리거가 되는 컴포넌트 ID + operator: "eq" | "neq" | "in"; // 비교 연산자 + value: any; // 비교할 값 +} + +/** + * 레이어 오버레이 설정 (모달/드로어용) + */ +export interface LayerOverlayConfig { + backdrop: boolean; // 배경 어둡게 처리 여부 + closeOnBackdropClick: boolean; // 배경 클릭 시 닫기 여부 + width?: string | number; // 너비 + height?: string | number; // 높이 + // 모달/드로어 스타일링 + backgroundColor?: string; // 컨텐츠 배경색 + backdropBlur?: number; // 배경 블러 (px) + // 드로어 전용 + position?: "left" | "right" | "top" | "bottom"; // 드로어 위치 +} + +/** + * 레이어 정의 + */ +export interface LayerDefinition { + id: string; + name: string; + type: LayerType; + zIndex: number; + isVisible: boolean; // 초기 표시 여부 + isLocked: boolean; // 편집 잠금 여부 + + // 조건부 표시 로직 + condition?: LayerCondition; + + // 모달/드로어 전용 설정 + overlayConfig?: LayerOverlayConfig; + + // 해당 레이어에 속한 컴포넌트들 + components: ComponentData[]; +} + /** * 레이아웃 데이터 */ export interface LayoutData { screenId: number; - components: ComponentData[]; + components: ComponentData[]; // @deprecated - use layers instead (kept for backward compatibility) + layers?: LayerDefinition[]; // 🆕 레이어 목록 gridSettings?: GridSettings; metadata?: LayoutMetadata; screenResolution?: ScreenResolution;