From 4e2209bd5d961e6db116b1513208d75ee91dec4b Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 6 Feb 2026 09:51:29 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=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; -- 2.43.0 From f2bee41336e6558647839191774d4df2132f0b0d Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 6 Feb 2026 10:20:45 +0900 Subject: [PATCH 2/2] Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal --- backend-node/src/routes/commonCodeRoutes.ts | 16 - .../src/services/categoryTreeService.ts | 1 + .../src/services/numberingRuleService.ts | 421 +++++++++++------- .../src/services/tableManagementService.ts | 1 + .../screen/InteractiveScreenViewer.tsx | 16 +- .../components/screen/LayerConditionPanel.tsx | 371 +++++++++++++++ .../components/screen/LayerManagerPanel.tsx | 270 +++++++---- .../components/v2-file-upload/types.ts | 16 +- 8 files changed, 820 insertions(+), 292 deletions(-) create mode 100644 frontend/components/screen/LayerConditionPanel.tsx diff --git a/backend-node/src/routes/commonCodeRoutes.ts b/backend-node/src/routes/commonCodeRoutes.ts index 3885d12a..d1205e51 100644 --- a/backend-node/src/routes/commonCodeRoutes.ts +++ b/backend-node/src/routes/commonCodeRoutes.ts @@ -73,20 +73,4 @@ router.get("/categories/:categoryCode/options", (req, res) => commonCodeController.getCodeOptions(req, res) ); -// 계층 구조 코드 조회 (트리 형태) -router.get("/categories/:categoryCode/hierarchy", (req, res) => - commonCodeController.getCodesHierarchy(req, res) -); - -// 자식 코드 조회 (연쇄 선택용) -router.get("/categories/:categoryCode/children", (req, res) => - commonCodeController.getChildCodes(req, res) -); - -// 카테고리 → 공통코드 호환 API (레거시 지원) -// 기존 카테고리 타입이 공통코드로 마이그레이션된 후에도 동작 -router.get("/category-options/:tableName/:columnName", (req, res) => - commonCodeController.getCategoryOptionsAsCode(req, res) -); - export default router; diff --git a/backend-node/src/services/categoryTreeService.ts b/backend-node/src/services/categoryTreeService.ts index 9296eed9..1550a780 100644 --- a/backend-node/src/services/categoryTreeService.ts +++ b/backend-node/src/services/categoryTreeService.ts @@ -43,6 +43,7 @@ export interface CreateCategoryValueInput { icon?: string; isActive?: boolean; isDefault?: boolean; + targetCompanyCode?: string; // 최고 관리자가 특정 회사를 선택할 때 사용 } // 카테고리 값 수정 입력 diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 4749bde5..4f5bf1e9 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -47,11 +47,11 @@ class NumberingRuleService { logger.info("채번 규칙 목록 조회 시작", { companyCode }); const pool = getPool(); - + // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 let query: string; let params: any[]; - + if (companyCode === "*") { // 최고 관리자: 모든 회사 데이터 조회 가능 query = ` @@ -107,7 +107,7 @@ class NumberingRuleService { for (const rule of result.rows) { let partsQuery: string; let partsParams: any[]; - + if (companyCode === "*") { // 최고 관리자: 모든 파트 조회 partsQuery = ` @@ -156,7 +156,7 @@ class NumberingRuleService { /** * 현재 메뉴에서 사용 가능한 규칙 목록 조회 (메뉴 스코프) - * + * * 메뉴 스코프 규칙: * - menuObjid가 제공되면 형제 메뉴의 채번 규칙 포함 * - 우선순위: menu (형제 메뉴) > table > global @@ -166,7 +166,7 @@ class NumberingRuleService { menuObjid?: number ): Promise { let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 - + try { logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", { companyCode, @@ -178,14 +178,17 @@ class NumberingRuleService { // 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외) if (menuObjid) { menuAndChildObjids = await getMenuAndChildObjids(menuObjid); - logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids }); + logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { + menuObjid, + menuAndChildObjids, + }); } // menuObjid가 없으면 global 규칙만 반환 if (!menuObjid || menuAndChildObjids.length === 0) { let query: string; let params: any[]; - + if (companyCode === "*") { // 최고 관리자: 모든 global 규칙 조회 query = ` @@ -239,7 +242,7 @@ class NumberingRuleService { for (const rule of result.rows) { let partsQuery: string; let partsParams: any[]; - + if (companyCode === "*") { partsQuery = ` SELECT @@ -281,7 +284,7 @@ class NumberingRuleService { // 우선순위: menu (형제 메뉴) > table > global let query: string; let params: any[]; - + if (companyCode === "*") { // 최고 관리자: 모든 규칙 조회 query = ` @@ -333,7 +336,7 @@ class NumberingRuleService { logger.info("🔍 채번 규칙 쿼리 실행", { queryPreview: query.substring(0, 200), - paramsTypes: params.map(p => typeof p), + paramsTypes: params.map((p) => typeof p), paramsValues: params, }); @@ -346,7 +349,7 @@ class NumberingRuleService { try { let partsQuery: string; let partsParams: any[]; - + if (companyCode === "*") { partsQuery = ` SELECT @@ -379,7 +382,7 @@ class NumberingRuleService { const partsResult = await pool.query(partsQuery, partsParams); rule.parts = partsResult.rows; - + logger.info("✅ 규칙 파트 조회 성공", { ruleId: rule.ruleId, ruleName: rule.ruleName, @@ -537,11 +540,11 @@ class NumberingRuleService { companyCode: string ): Promise { const pool = getPool(); - + // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 let query: string; let params: any[]; - + if (companyCode === "*") { // 최고 관리자: 모든 규칙 조회 가능 query = ` @@ -598,7 +601,7 @@ class NumberingRuleService { // 파트 정보 조회 let partsQuery: string; let partsParams: any[]; - + if (companyCode === "*") { partsQuery = ` SELECT @@ -836,12 +839,12 @@ class NumberingRuleService { return { ...ruleResult.rows[0], parts }; } catch (error: any) { await client.query("ROLLBACK"); - logger.error("채번 규칙 수정 실패", { + logger.error("채번 규칙 수정 실패", { ruleId, companyCode, error: error.message, stack: error.stack, - updates + updates, }); throw error; } finally { @@ -875,7 +878,7 @@ class NumberingRuleService { * @param formData 폼 데이터 (카테고리 기반 채번 시 사용) */ async previewCode( - ruleId: string, + ruleId: string, companyCode: string, formData?: Record ): Promise { @@ -911,21 +914,26 @@ class NumberingRuleService { case "date": { // 날짜 (다양한 날짜 형식) const dateFormat = autoConfig.dateFormat || "YYYYMMDD"; - + // 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출 - if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) { + if ( + autoConfig.useColumnValue && + autoConfig.sourceColumnName && + formData + ) { const columnValue = formData[autoConfig.sourceColumnName]; if (columnValue) { - const dateValue = columnValue instanceof Date - ? columnValue - : new Date(columnValue); - + const dateValue = + columnValue instanceof Date + ? columnValue + : new Date(columnValue); + if (!isNaN(dateValue.getTime())) { return this.formatDate(dateValue, dateFormat); } } } - + return this.formatDate(new Date(), dateFormat); } @@ -938,63 +946,68 @@ class NumberingRuleService { // 카테고리 기반 코드 생성 const categoryKey = autoConfig.categoryKey; // 예: "item_info.material" const categoryMappings = autoConfig.categoryMappings || []; - + if (!categoryKey || !formData) { - logger.warn("카테고리 키 또는 폼 데이터 없음", { categoryKey, hasFormData: !!formData }); + logger.warn("카테고리 키 또는 폼 데이터 없음", { + categoryKey, + hasFormData: !!formData, + }); return ""; } - + // categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material") - const columnName = categoryKey.includes(".") - ? categoryKey.split(".")[1] + const columnName = categoryKey.includes(".") + ? categoryKey.split(".")[1] : categoryKey; - + // 폼 데이터에서 해당 컬럼의 값 가져오기 const selectedValue = formData[columnName]; - - logger.info("카테고리 파트 처리", { - categoryKey, - columnName, + + logger.info("카테고리 파트 처리", { + categoryKey, + columnName, selectedValue, formDataKeys: Object.keys(formData), - mappingsCount: categoryMappings.length + mappingsCount: categoryMappings.length, }); - + if (!selectedValue) { - logger.warn("카테고리 값이 선택되지 않음", { columnName, formDataKeys: Object.keys(formData) }); + logger.warn("카테고리 값이 선택되지 않음", { + columnName, + formDataKeys: Object.keys(formData), + }); return ""; } - + // 카테고리 매핑에서 해당 값에 대한 형식 찾기 // selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용) const selectedValueStr = String(selectedValue); - const mapping = categoryMappings.find( - (m: any) => { - // ID로 매칭 - if (m.categoryValueId?.toString() === selectedValueStr) return true; - // 라벨로 매칭 - if (m.categoryValueLabel === selectedValueStr) return true; - // valueCode로 매칭 (라벨과 동일할 수 있음) - if (m.categoryValueLabel === selectedValueStr) return true; - return false; - } - ); - + const mapping = categoryMappings.find((m: any) => { + // ID로 매칭 + if (m.categoryValueId?.toString() === selectedValueStr) + return true; + // 라벨로 매칭 + if (m.categoryValueLabel === selectedValueStr) return true; + // valueCode로 매칭 (라벨과 동일할 수 있음) + if (m.categoryValueLabel === selectedValueStr) return true; + return false; + }); + if (mapping) { - logger.info("카테고리 매핑 적용", { - selectedValue, + logger.info("카테고리 매핑 적용", { + selectedValue, format: mapping.format, - categoryValueLabel: mapping.categoryValueLabel + categoryValueLabel: mapping.categoryValueLabel, }); return mapping.format || ""; } - - logger.warn("카테고리 매핑을 찾을 수 없음", { - selectedValue, - availableMappings: categoryMappings.map((m: any) => ({ - id: m.categoryValueId, - label: m.categoryValueLabel - })) + + logger.warn("카테고리 매핑을 찾을 수 없음", { + selectedValue, + availableMappings: categoryMappings.map((m: any) => ({ + id: m.categoryValueId, + label: m.categoryValueLabel, + })), }); return ""; } @@ -1006,7 +1019,12 @@ class NumberingRuleService { }); const previewCode = parts.join(rule.separator || ""); - logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode, hasFormData: !!formData }); + logger.info("코드 미리보기 생성", { + ruleId, + previewCode, + companyCode, + hasFormData: !!formData, + }); return previewCode; } @@ -1018,8 +1036,8 @@ class NumberingRuleService { * @param userInputCode 사용자가 편집한 최종 코드 (수동 입력 부분 추출용) */ async allocateCode( - ruleId: string, - companyCode: string, + ruleId: string, + companyCode: string, formData?: Record, userInputCode?: string ): Promise { @@ -1033,9 +1051,11 @@ class NumberingRuleService { if (!rule) throw new Error("규칙을 찾을 수 없습니다"); // 수동 입력 파트가 있고, 사용자가 입력한 코드가 있으면 수동 입력 부분 추출 - const manualParts = rule.parts.filter((p: any) => p.generationMethod === "manual"); + const manualParts = rule.parts.filter( + (p: any) => p.generationMethod === "manual" + ); let extractedManualValues: string[] = []; - + if (manualParts.length > 0 && userInputCode) { // 프리뷰 코드를 생성해서 ____ 위치 파악 // 🔧 category 파트도 처리하여 올바른 템플릿 생성 @@ -1059,39 +1079,38 @@ class NumberingRuleService { // 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용 const categoryKey = autoConfig.categoryKey; const categoryMappings = autoConfig.categoryMappings || []; - + if (!categoryKey || !formData) { return "CATEGORY"; // 폴백 } - - const columnName = categoryKey.includes(".") - ? categoryKey.split(".")[1] + + const columnName = categoryKey.includes(".") + ? categoryKey.split(".")[1] : categoryKey; const selectedValue = formData[columnName]; - + if (!selectedValue) { return "CATEGORY"; // 폴백 } - + const selectedValueStr = String(selectedValue); - const mapping = categoryMappings.find( - (m: any) => { - if (m.categoryValueId?.toString() === selectedValueStr) return true; - if (m.categoryValueLabel === selectedValueStr) return true; - return false; - } - ); - + const mapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === selectedValueStr) + return true; + if (m.categoryValueLabel === selectedValueStr) return true; + return false; + }); + return mapping?.format || "CATEGORY"; } default: return ""; } }); - + const separator = rule.separator || ""; const previewTemplate = previewParts.join(separator); - + // 사용자 입력 코드에서 수동 입력 부분 추출 // 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출 const templateParts = previewTemplate.split("____"); @@ -1100,19 +1119,23 @@ class NumberingRuleService { for (let i = 0; i < templateParts.length - 1; i++) { const prefix = templateParts[i]; const suffix = templateParts[i + 1]; - + // prefix 이후 부분 추출 if (prefix && remainingCode.startsWith(prefix)) { remainingCode = remainingCode.slice(prefix.length); } - + // suffix 이전까지가 수동 입력 값 if (suffix) { // suffix에서 순번(XXX)이나 날짜 부분을 제외한 실제 구분자 찾기 const suffixStart = suffix.replace(/X+|DATEPART/g, ""); - const manualEndIndex = suffixStart ? remainingCode.indexOf(suffixStart) : remainingCode.length; + const manualEndIndex = suffixStart + ? remainingCode.indexOf(suffixStart) + : remainingCode.length; if (manualEndIndex > 0) { - extractedManualValues.push(remainingCode.slice(0, manualEndIndex)); + extractedManualValues.push( + remainingCode.slice(0, manualEndIndex) + ); remainingCode = remainingCode.slice(manualEndIndex); } } else { @@ -1120,8 +1143,10 @@ class NumberingRuleService { } } } - - logger.info(`수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}`); + + logger.info( + `수동 입력 값 추출: userInputCode=${userInputCode}, previewTemplate=${previewTemplate}, extractedManualValues=${JSON.stringify(extractedManualValues)}` + ); } let manualPartIndex = 0; @@ -1130,7 +1155,10 @@ class NumberingRuleService { .map((part: any) => { if (part.generationMethod === "manual") { // 추출된 수동 입력 값 사용, 없으면 기본값 사용 - const manualValue = extractedManualValues[manualPartIndex] || part.manualConfig?.value || ""; + const manualValue = + extractedManualValues[manualPartIndex] || + part.manualConfig?.value || + ""; manualPartIndex++; return manualValue; } @@ -1155,16 +1183,21 @@ class NumberingRuleService { case "date": { // 날짜 (다양한 날짜 형식) const dateFormat = autoConfig.dateFormat || "YYYYMMDD"; - + // 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출 - if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) { + if ( + autoConfig.useColumnValue && + autoConfig.sourceColumnName && + formData + ) { const columnValue = formData[autoConfig.sourceColumnName]; if (columnValue) { // 날짜 문자열 또는 Date 객체를 Date로 변환 - const dateValue = columnValue instanceof Date - ? columnValue - : new Date(columnValue); - + const dateValue = + columnValue instanceof Date + ? columnValue + : new Date(columnValue); + if (!isNaN(dateValue.getTime())) { logger.info("컬럼 기준 날짜 생성", { sourceColumn: autoConfig.sourceColumnName, @@ -1185,7 +1218,7 @@ class NumberingRuleService { }); } } - + // 기본: 현재 날짜 사용 return this.formatDate(new Date(), dateFormat); } @@ -1199,60 +1232,65 @@ class NumberingRuleService { // 카테고리 기반 코드 생성 (allocateCode용) const categoryKey = autoConfig.categoryKey; // 예: "item_info.material" const categoryMappings = autoConfig.categoryMappings || []; - + if (!categoryKey || !formData) { - logger.warn("allocateCode: 카테고리 키 또는 폼 데이터 없음", { categoryKey, hasFormData: !!formData }); + logger.warn("allocateCode: 카테고리 키 또는 폼 데이터 없음", { + categoryKey, + hasFormData: !!formData, + }); return ""; } - + // categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material") - const columnName = categoryKey.includes(".") - ? categoryKey.split(".")[1] + const columnName = categoryKey.includes(".") + ? categoryKey.split(".")[1] : categoryKey; - + // 폼 데이터에서 해당 컬럼의 값 가져오기 const selectedValue = formData[columnName]; - - logger.info("allocateCode: 카테고리 파트 처리", { - categoryKey, - columnName, + + logger.info("allocateCode: 카테고리 파트 처리", { + categoryKey, + columnName, selectedValue, formDataKeys: Object.keys(formData), - mappingsCount: categoryMappings.length + mappingsCount: categoryMappings.length, }); - + if (!selectedValue) { - logger.warn("allocateCode: 카테고리 값이 선택되지 않음", { columnName, formDataKeys: Object.keys(formData) }); + logger.warn("allocateCode: 카테고리 값이 선택되지 않음", { + columnName, + formDataKeys: Object.keys(formData), + }); return ""; } - + // 카테고리 매핑에서 해당 값에 대한 형식 찾기 const selectedValueStr = String(selectedValue); - const mapping = categoryMappings.find( - (m: any) => { - // ID로 매칭 - if (m.categoryValueId?.toString() === selectedValueStr) return true; - // 라벨로 매칭 - if (m.categoryValueLabel === selectedValueStr) return true; - return false; - } - ); - + const mapping = categoryMappings.find((m: any) => { + // ID로 매칭 + if (m.categoryValueId?.toString() === selectedValueStr) + return true; + // 라벨로 매칭 + if (m.categoryValueLabel === selectedValueStr) return true; + return false; + }); + if (mapping) { - logger.info("allocateCode: 카테고리 매핑 적용", { - selectedValue, + logger.info("allocateCode: 카테고리 매핑 적용", { + selectedValue, format: mapping.format, - categoryValueLabel: mapping.categoryValueLabel + categoryValueLabel: mapping.categoryValueLabel, }); return mapping.format || ""; } - - logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", { - selectedValue, - availableMappings: categoryMappings.map((m: any) => ({ - id: m.categoryValueId, - label: m.categoryValueLabel - })) + + logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", { + selectedValue, + availableMappings: categoryMappings.map((m: any) => ({ + id: m.categoryValueId, + label: m.categoryValueLabel, + })), }); return ""; } @@ -1344,14 +1382,17 @@ class NumberingRuleService { menuObjid?: number ): Promise { try { - logger.info("[테스트] 채번 규칙 목록 조회 시작", { companyCode, menuObjid }); + logger.info("[테스트] 채번 규칙 목록 조회 시작", { + companyCode, + menuObjid, + }); const pool = getPool(); - + // 멀티테넌시: 최고 관리자 vs 일반 회사 let query: string; let params: any[]; - + if (companyCode === "*") { // 최고 관리자: 모든 규칙 조회 query = ` @@ -1508,7 +1549,10 @@ class NumberingRuleService { WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; - const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); + const partsResult = await pool.query(partsQuery, [ + rule.ruleId, + companyCode, + ]); rule.parts = partsResult.rows; logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", { @@ -1556,7 +1600,10 @@ class NumberingRuleService { SELECT rule_id FROM numbering_rules WHERE rule_id = $1 AND company_code = $2 `; - const existingResult = await client.query(existingQuery, [config.ruleId, companyCode]); + const existingResult = await client.query(existingQuery, [ + config.ruleId, + companyCode, + ]); if (existingResult.rows.length > 0) { // 업데이트 @@ -1671,7 +1718,10 @@ class NumberingRuleService { try { await client.query("BEGIN"); - logger.info("테스트 테이블에서 채번 규칙 삭제 시작", { ruleId, companyCode }); + logger.info("테스트 테이블에서 채번 규칙 삭제 시작", { + ruleId, + companyCode, + }); // 파트 먼저 삭제 await client.query( @@ -1779,7 +1829,10 @@ class NumberingRuleService { WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; - const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); + const partsResult = await pool.query(partsQuery, [ + rule.ruleId, + companyCode, + ]); rule.parts = partsResult.rows; logger.info("카테고리 조건 매칭 채번 규칙 찾음", { @@ -1814,7 +1867,11 @@ class NumberingRuleService { AND r.category_value_id IS NULL LIMIT 1 `; - const defaultResult = await pool.query(defaultQuery, [companyCode, tableName, columnName]); + const defaultResult = await pool.query(defaultQuery, [ + companyCode, + tableName, + columnName, + ]); if (defaultResult.rows.length > 0) { const rule = defaultResult.rows[0]; @@ -1831,7 +1888,10 @@ class NumberingRuleService { WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; - const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); + const partsResult = await pool.query(partsQuery, [ + rule.ruleId, + companyCode, + ]); rule.parts = partsResult.rows; logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", { @@ -1891,8 +1951,12 @@ class NumberingRuleService { AND r.column_name = $3 ORDER BY r.category_value_id NULLS FIRST, r.created_at `; - const result = await pool.query(query, [companyCode, tableName, columnName]); - + const result = await pool.query(query, [ + companyCode, + tableName, + columnName, + ]); + // 각 규칙의 파트 정보 조회 for (const rule of result.rows) { const partsQuery = ` @@ -1907,7 +1971,10 @@ class NumberingRuleService { WHERE rule_id = $1 AND company_code = $2 ORDER BY part_order `; - const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); + const partsResult = await pool.query(partsQuery, [ + rule.ruleId, + companyCode, + ]); rule.parts = partsResult.rows; } @@ -1928,11 +1995,21 @@ class NumberingRuleService { async copyRulesForCompany( sourceCompanyCode: string, targetCompanyCode: string - ): Promise<{ copiedCount: number; skippedCount: number; details: string[]; ruleIdMap: Record }> { + ): Promise<{ + copiedCount: number; + skippedCount: number; + details: string[]; + ruleIdMap: Record; + }> { const pool = getPool(); const client = await pool.connect(); - - const result = { copiedCount: 0, skippedCount: 0, details: [] as string[], ruleIdMap: {} as Record }; + + const result = { + copiedCount: 0, + skippedCount: 0, + details: [] as string[], + ruleIdMap: {} as Record, + }; try { await client.query("BEGIN"); @@ -1950,9 +2027,9 @@ class NumberingRuleService { [targetCompanyCode] ); if (deleteResult.rowCount && deleteResult.rowCount > 0) { - logger.info("기존 채번규칙 삭제", { - targetCompanyCode, - deletedCount: deleteResult.rowCount + logger.info("기존 채번규칙 삭제", { + targetCompanyCode, + deletedCount: deleteResult.rowCount, }); } @@ -1962,9 +2039,9 @@ class NumberingRuleService { [sourceCompanyCode] ); - logger.info("원본 채번규칙 조회", { - sourceCompanyCode, - count: sourceRulesResult.rowCount + logger.info("원본 채번규칙 조회", { + sourceCompanyCode, + count: sourceRulesResult.rowCount, }); // 2. 각 채번규칙 복제 @@ -2038,18 +2115,18 @@ class NumberingRuleService { result.ruleIdMap[rule.rule_id] = newRuleId; result.copiedCount++; result.details.push(`복제 완료: ${rule.rule_name}`); - logger.info("채번규칙 복제 완료", { - ruleName: rule.rule_name, + logger.info("채번규칙 복제 완료", { + ruleName: rule.rule_name, oldRuleId: rule.rule_id, - newRuleId + newRuleId, }); } // 3. 화면 레이아웃의 numberingRuleId 참조 업데이트 if (Object.keys(result.ruleIdMap).length > 0) { - logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 시작", { + logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 시작", { targetCompanyCode, - mappingCount: Object.keys(result.ruleIdMap).length + mappingCount: Object.keys(result.ruleIdMap).length, }); // 대상 회사의 모든 화면 레이아웃 조회 @@ -2069,9 +2146,13 @@ class NumberingRuleService { let updated = false; // 각 매핑에 대해 치환 - for (const [oldRuleId, newRuleId] of Object.entries(result.ruleIdMap)) { + for (const [oldRuleId, newRuleId] of Object.entries( + result.ruleIdMap + )) { if (propsStr.includes(`"${oldRuleId}"`)) { - propsStr = propsStr.split(`"${oldRuleId}"`).join(`"${newRuleId}"`); + propsStr = propsStr + .split(`"${oldRuleId}"`) + .join(`"${newRuleId}"`); updated = true; } } @@ -2085,27 +2166,33 @@ class NumberingRuleService { } } - logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 완료", { + logger.info("화면 레이아웃 numberingRuleId 참조 업데이트 완료", { targetCompanyCode, - updatedLayouts + updatedLayouts, }); - result.details.push(`화면 레이아웃 ${updatedLayouts}개의 채번규칙 참조 업데이트`); + result.details.push( + `화면 레이아웃 ${updatedLayouts}개의 채번규칙 참조 업데이트` + ); } await client.query("COMMIT"); - - logger.info("회사별 채번규칙 복제 완료", { - sourceCompanyCode, - targetCompanyCode, + + logger.info("회사별 채번규칙 복제 완료", { + sourceCompanyCode, + targetCompanyCode, copiedCount: result.copiedCount, skippedCount: result.skippedCount, - ruleIdMapCount: Object.keys(result.ruleIdMap).length + ruleIdMapCount: Object.keys(result.ruleIdMap).length, }); return result; } catch (error) { await client.query("ROLLBACK"); - logger.error("회사별 채번규칙 복제 실패", { error, sourceCompanyCode, targetCompanyCode }); + logger.error("회사별 채번규칙 복제 실패", { + error, + sourceCompanyCode, + targetCompanyCode, + }); throw error; } finally { client.release(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 2d4aa581..5fe2f242 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3869,6 +3869,7 @@ export class TableManagementService { columnName: string; displayName: string; dataType: string; + inputType?: string; }> > { return await entityJoinService.getReferenceTableColumns(tableName); diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 4a693867..6b9a092b 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -249,8 +249,18 @@ export const InteractiveScreenViewer: React.FC = ( layers.forEach((layer) => { if (layer.type === "conditional" && layer.condition) { const { targetComponentId, operator, value } = layer.condition; - // 컴포넌트 ID를 키로 데이터 조회 - columnName 매핑이 필요할 수 있음 - const targetValue = finalFormData[targetComponentId]; + + // 1. 컴포넌트 ID로 대상 컴포넌트 찾기 + const targetComponent = allComponents.find((c) => c.id === targetComponentId); + + // 2. 컴포넌트의 columnName으로 formData에서 값 조회 + // columnName이 없으면 컴포넌트 ID로 폴백 + const fieldKey = + (targetComponent as any)?.columnName || + (targetComponent as any)?.componentConfig?.columnName || + targetComponentId; + + const targetValue = finalFormData[fieldKey]; let isMatch = false; switch (operator) { @@ -272,7 +282,7 @@ export const InteractiveScreenViewer: React.FC = ( } } }); - }, [finalFormData, layers, handleLayerAction]); + }, [finalFormData, layers, allComponents, handleLayerAction]); // 개선된 검증 시스템 (선택적 활성화) const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0 diff --git a/frontend/components/screen/LayerConditionPanel.tsx b/frontend/components/screen/LayerConditionPanel.tsx new file mode 100644 index 00000000..4304aa55 --- /dev/null +++ b/frontend/components/screen/LayerConditionPanel.tsx @@ -0,0 +1,371 @@ +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, AlertCircle, Check, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ComponentData, LayerCondition, LayerDefinition } from "@/types/screen-management"; +import { getCodesByCategory, CodeItem } from "@/lib/api/codeManagement"; + +interface LayerConditionPanelProps { + layer: LayerDefinition; + components: ComponentData[]; // 화면의 모든 컴포넌트 + onUpdateCondition: (condition: LayerCondition | undefined) => void; + onClose?: () => void; +} + +// 조건 연산자 옵션 +const OPERATORS = [ + { value: "eq", label: "같음 (=)" }, + { value: "neq", label: "같지 않음 (≠)" }, + { value: "in", label: "포함 (in)" }, +] as const; + +type OperatorType = "eq" | "neq" | "in"; + +export const LayerConditionPanel: React.FC = ({ + layer, + components, + onUpdateCondition, + onClose, +}) => { + // 조건 설정 상태 + const [targetComponentId, setTargetComponentId] = useState( + layer.condition?.targetComponentId || "" + ); + const [operator, setOperator] = useState( + (layer.condition?.operator as OperatorType) || "eq" + ); + const [value, setValue] = useState( + layer.condition?.value?.toString() || "" + ); + const [multiValues, setMultiValues] = useState( + Array.isArray(layer.condition?.value) ? layer.condition.value : [] + ); + + // 코드 목록 로딩 상태 + const [codeOptions, setCodeOptions] = useState([]); + const [isLoadingCodes, setIsLoadingCodes] = useState(false); + const [codeLoadError, setCodeLoadError] = useState(null); + + // 트리거 가능한 컴포넌트 필터링 (셀렉트, 라디오, 코드 타입 등) + const triggerableComponents = useMemo(() => { + return components.filter((comp) => { + const componentType = (comp.componentType || "").toLowerCase(); + const widgetType = ((comp as any).widgetType || "").toLowerCase(); + const webType = ((comp as any).webType || "").toLowerCase(); + const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase(); + + // 셀렉트, 라디오, 코드 타입 컴포넌트만 허용 + const triggerTypes = ["select", "radio", "code", "checkbox", "toggle"]; + const isTriggerType = triggerTypes.some((type) => + componentType.includes(type) || + widgetType.includes(type) || + webType.includes(type) || + inputType.includes(type) + ); + + return isTriggerType; + }); + }, [components]); + + // 선택된 컴포넌트 정보 + const selectedComponent = useMemo(() => { + return components.find((c) => c.id === targetComponentId); + }, [components, targetComponentId]); + + // 선택된 컴포넌트의 코드 카테고리 + const codeCategory = useMemo(() => { + if (!selectedComponent) return null; + + // codeCategory 확인 (다양한 위치에 있을 수 있음) + const category = + (selectedComponent as any).codeCategory || + (selectedComponent as any).componentConfig?.codeCategory || + (selectedComponent as any).webTypeConfig?.codeCategory; + + return category || null; + }, [selectedComponent]); + + // 컴포넌트 선택 시 코드 목록 로드 + useEffect(() => { + if (!codeCategory) { + setCodeOptions([]); + return; + } + + const loadCodes = async () => { + setIsLoadingCodes(true); + setCodeLoadError(null); + + try { + const codes = await getCodesByCategory(codeCategory); + setCodeOptions(codes); + } catch (error: any) { + console.error("코드 목록 로드 실패:", error); + setCodeLoadError(error.message || "코드 목록을 불러올 수 없습니다."); + setCodeOptions([]); + } finally { + setIsLoadingCodes(false); + } + }; + + loadCodes(); + }, [codeCategory]); + + // 조건 저장 + const handleSave = useCallback(() => { + if (!targetComponentId) { + return; + } + + const condition: LayerCondition = { + targetComponentId, + operator, + value: operator === "in" ? multiValues : value, + }; + + onUpdateCondition(condition); + onClose?.(); + }, [targetComponentId, operator, value, multiValues, onUpdateCondition, onClose]); + + // 조건 삭제 + const handleClear = useCallback(() => { + onUpdateCondition(undefined); + setTargetComponentId(""); + setOperator("eq"); + setValue(""); + setMultiValues([]); + onClose?.(); + }, [onUpdateCondition, onClose]); + + // in 연산자용 다중 값 토글 + const toggleMultiValue = useCallback((val: string) => { + setMultiValues((prev) => + prev.includes(val) + ? prev.filter((v) => v !== val) + : [...prev, val] + ); + }, []); + + // 컴포넌트 라벨 가져오기 + const getComponentLabel = (comp: ComponentData) => { + return comp.label || (comp as any).columnName || comp.id; + }; + + return ( +
+
+

조건부 표시 설정

+ {layer.condition && ( + + 설정됨 + + )} +
+ + {/* 트리거 컴포넌트 선택 */} +
+ + + + {/* 코드 카테고리 표시 */} + {codeCategory && ( +
+ 카테고리: + + {codeCategory} + +
+ )} +
+ + {/* 연산자 선택 */} + {targetComponentId && ( +
+ + +
+ )} + + {/* 조건 값 선택 */} + {targetComponentId && ( +
+ + + {isLoadingCodes ? ( +
+ + 코드 목록 로딩 중... +
+ ) : codeLoadError ? ( +
+ + {codeLoadError} +
+ ) : codeOptions.length > 0 ? ( + // 코드 카테고리가 있는 경우 - 선택 UI + operator === "in" ? ( + // 다중 선택 (in 연산자) +
+ {codeOptions.map((code) => ( +
toggleMultiValue(code.codeValue)} + > +
+ {multiValues.includes(code.codeValue) && ( + + )} +
+ {code.codeName} + ({code.codeValue}) +
+ ))} +
+ ) : ( + // 단일 선택 (eq, neq 연산자) + + ) + ) : ( + // 코드 카테고리가 없는 경우 - 직접 입력 + setValue(e.target.value)} + placeholder="조건 값 입력..." + className="h-8 text-xs" + /> + )} + + {/* 선택된 값 표시 (in 연산자) */} + {operator === "in" && multiValues.length > 0 && ( +
+ {multiValues.map((val) => { + const code = codeOptions.find((c) => c.codeValue === val); + return ( + + {code?.codeName || val} + toggleMultiValue(val)} + /> + + ); + })} +
+ )} +
+ )} + + {/* 현재 조건 요약 */} + {targetComponentId && (value || multiValues.length > 0) && ( +
+ 요약: + + "{getComponentLabel(selectedComponent!)}" 값이{" "} + {operator === "eq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 같으면`} + {operator === "neq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 다르면`} + {operator === "in" && `[${multiValues.map(v => codeOptions.find(c => c.codeValue === v)?.codeName || v).join(", ")}] 중 하나이면`} + {" "}이 레이어 표시 + +
+ )} + + {/* 버튼 */} +
+ + +
+
+ ); +}; diff --git a/frontend/components/screen/LayerManagerPanel.tsx b/frontend/components/screen/LayerManagerPanel.tsx index cd482602..05fb36f3 100644 --- a/frontend/components/screen/LayerManagerPanel.tsx +++ b/frontend/components/screen/LayerManagerPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useCallback } from "react"; import { useLayer } from "@/contexts/LayerContext"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -10,6 +10,11 @@ import { DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { Badge } from "@/components/ui/badge"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { Eye, EyeOff, @@ -22,10 +27,13 @@ import { SplitSquareVertical, PanelRight, ChevronDown, + ChevronRight, Settings2, + Zap, } from "lucide-react"; import { cn } from "@/lib/utils"; -import { LayerType, LayerDefinition, ComponentData } from "@/types/screen-management"; +import { LayerType, LayerDefinition, ComponentData, LayerCondition } from "@/types/screen-management"; +import { LayerConditionPanel } from "./LayerConditionPanel"; // 레이어 타입별 아이콘 const getLayerTypeIcon = (type: LayerType) => { @@ -78,137 +86,196 @@ function getLayerTypeColor(type: LayerType): string { interface LayerItemProps { layer: LayerDefinition; isActive: boolean; - componentCount: number; // 🆕 실제 컴포넌트 수 (layout.components 기반) + componentCount: number; // 실제 컴포넌트 수 (layout.components 기반) + allComponents: ComponentData[]; // 조건 설정에 필요한 전체 컴포넌트 onSelect: () => void; onToggleVisibility: () => void; onToggleLock: () => void; onRemove: () => void; onUpdateName: (name: string) => void; + onUpdateCondition: (condition: LayerCondition | undefined) => void; } const LayerItem: React.FC = ({ layer, isActive, componentCount, + allComponents, onSelect, onToggleVisibility, onToggleLock, onRemove, onUpdateName, + onUpdateCondition, }) => { const [isEditing, setIsEditing] = useState(false); + const [isConditionOpen, setIsConditionOpen] = useState(false); + + // 조건부 레이어인지 확인 + const isConditionalLayer = layer.type === "conditional"; + // 조건 설정 여부 + const hasCondition = !!layer.condition; return ( -
- {/* 드래그 핸들 */} - +
+ {/* 레이어 메인 영역 */} +
+ {/* 드래그 핸들 */} + - {/* 레이어 정보 */} -
-
- {/* 레이어 타입 아이콘 */} - - {getLayerTypeIcon(layer.type)} - + {/* 레이어 정보 */} +
+
+ {/* 레이어 타입 아이콘 */} + + {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} + + )} +
- {/* 레이어 이름 */} - {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()} - /> - ) : ( - { + {/* 레이어 메타 정보 */} +
+ + {getLayerTypeLabel(layer.type)} + + + {componentCount}개 컴포넌트 + + {/* 조건 설정됨 표시 */} + {hasCondition && ( + + + 조건 + + )} +
+
+ + {/* 액션 버튼들 */} +
+ {/* 조건부 레이어일 때 조건 설정 버튼 */} + {isConditionalLayer && ( + )} -
- - {/* 레이어 메타 정보 */} -
- - {getLayerTypeLabel(layer.type)} - - - {componentCount}개 컴포넌트 - -
-
- - {/* 액션 버튼들 */} -
- - - - - {layer.type !== "base" && ( + - )} + + + + {layer.type !== "base" && ( + + )} +
+ + {/* 조건 설정 패널 (조건부 레이어만) */} + {isConditionalLayer && isConditionOpen && ( +
+ setIsConditionOpen(false)} + /> +
+ )}
); }; @@ -229,6 +296,11 @@ export const LayerManagerPanel: React.FC = ({ components updateLayer, } = useLayer(); + // 레이어 조건 업데이트 핸들러 + const handleUpdateCondition = useCallback((layerId: string, condition: LayerCondition | undefined) => { + updateLayer(layerId, { condition }); + }, [updateLayer]); + // 🆕 각 레이어별 컴포넌트 수 계산 (layout.components 기반) const componentCountByLayer = useMemo(() => { const counts: Record = {}; @@ -311,11 +383,13 @@ export const LayerManagerPanel: React.FC = ({ components layer={layer} isActive={activeLayerId === layer.id} componentCount={componentCountByLayer[layer.id] || 0} + allComponents={components} onSelect={() => setActiveLayerId(layer.id)} onToggleVisibility={() => toggleLayerVisibility(layer.id)} onToggleLock={() => toggleLayerLock(layer.id)} onRemove={() => removeLayer(layer.id)} onUpdateName={(name) => updateLayer(layer.id, { name })} + onUpdateCondition={(condition) => handleUpdateCondition(layer.id, condition)} /> )) )} diff --git a/frontend/lib/registry/components/v2-file-upload/types.ts b/frontend/lib/registry/components/v2-file-upload/types.ts index 9147b88d..4625f7e0 100644 --- a/frontend/lib/registry/components/v2-file-upload/types.ts +++ b/frontend/lib/registry/components/v2-file-upload/types.ts @@ -30,7 +30,7 @@ export interface FileInfo { type?: string; // docType과 동일 uploadedAt?: string; // regdate와 동일 _file?: File; // 로컬 파일 객체 (업로드 전) - + // 대표 이미지 설정 isRepresentative?: boolean; // 대표 이미지로 설정 여부 } @@ -45,24 +45,24 @@ export interface FileUploadConfig extends ComponentConfig { accept?: string; maxSize?: number; // bytes maxFiles?: number; // 최대 파일 수 - + // 공통 설정 disabled?: boolean; required?: boolean; readonly?: boolean; helperText?: string; - + // 스타일 관련 variant?: "default" | "outlined" | "filled"; size?: "sm" | "md" | "lg"; - + // V2 추가 설정 showPreview?: boolean; // 미리보기 표시 여부 showFileList?: boolean; // 파일 목록 표시 여부 showFileSize?: boolean; // 파일 크기 표시 여부 allowDelete?: boolean; // 삭제 허용 여부 allowDownload?: boolean; // 다운로드 허용 여부 - + // 이벤트 관련 onChange?: (value: any) => void; onFocus?: () => void; @@ -83,10 +83,10 @@ export interface FileUploadProps { config?: FileUploadConfig; className?: string; style?: React.CSSProperties; - + // 파일 관련 uploadedFiles?: FileInfo[]; - + // 이벤트 핸들러 onChange?: (value: any) => void; onFocus?: () => void; @@ -100,7 +100,7 @@ export interface FileUploadProps { /** * 파일 업로드 상태 타입 */ -export type FileUploadStatus = 'idle' | 'uploading' | 'success' | 'error'; +export type FileUploadStatus = "idle" | "uploading" | "success" | "error"; /** * 파일 업로드 응답 타입 -- 2.43.0