diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 2c7e7865..8c837697 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1505,14 +1505,17 @@ class DataService { } } - // 3. 고아 레코드 삭제: 기존 레코드 중 이번에 처리되지 않은 것 삭제 - for (const existingRow of existingRecords.rows) { - const existId = existingRow[pkColumn]; - if (!processedIds.has(existId)) { - const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; - await pool.query(deleteQuery, [existId]); - deleted++; - console.log(`🗑️ DELETE orphan: ${pkColumn} = ${existId}`); + // 3. 고아 레코드 삭제: deleteOrphans=true일 때만 (EDIT 모드) + // CREATE 모드에서는 기존 레코드를 건드리지 않음 + if (deleteOrphans) { + for (const existingRow of existingRecords.rows) { + const existId = existingRow[pkColumn]; + if (!processedIds.has(existId)) { + const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`; + await pool.query(deleteQuery, [existId]); + deleted++; + console.log(`🗑️ DELETE orphan: ${pkColumn} = ${existId}`); + } } } diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index e3e8d920..de2c5b61 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -449,6 +449,15 @@ function AppLayoutInner({ children }: AppLayoutProps) { ); }; + // 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (인증 대기 없이 즉시 렌더링) + if (isPreviewMode) { + return ( +
+ {children} +
+ ); + } + // 사용자 정보가 없으면 로딩 표시 if (!user) { return ( @@ -461,15 +470,6 @@ function AppLayoutInner({ children }: AppLayoutProps) { ); } - // 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 - if (isPreviewMode) { - return ( -
- {children} -
- ); - } - // UI 변환된 메뉴 데이터 const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo); diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index a530c024..5f62ade4 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1,7140 +1 @@ -"use client"; - -import { useState, useCallback, useEffect, useMemo, useRef } from "react"; -import { cn } from "@/lib/utils"; -import { Database, Cog } from "lucide-react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { - ScreenDefinition, - ComponentData, - LayoutData, - GroupState, - TableInfo, - Position, - ColumnInfo, - GridSettings, - ScreenResolution, - SCREEN_RESOLUTIONS, -} from "@/types/screen"; -import { generateComponentId } from "@/lib/utils/generateId"; -import { - getComponentIdFromWebType, - createV2ConfigFromColumn, - getV2ConfigFromWebType, -} from "@/lib/utils/webTypeMapping"; -import { - createGroupComponent, - calculateBoundingBox, - calculateRelativePositions, - restoreAbsolutePositions, -} from "@/lib/utils/groupingUtils"; -import { - adjustGridColumnsFromSize, - updateSizeFromGridColumns, - calculateWidthFromColumns, - snapSizeToGrid, - snapToGrid, -} from "@/lib/utils/gridUtils"; -import { - alignComponents, - distributeComponents, - matchComponentSize, - toggleAllLabels, - nudgeComponents, - AlignMode, - DistributeDirection, - MatchSizeMode, -} from "@/lib/utils/alignmentUtils"; -import { KeyboardShortcutsModal } from "./modals/KeyboardShortcutsModal"; - -// 10px 단위 스냅 함수 -const snapTo10px = (value: number): number => { - return Math.round(value / 10) * 10; -}; - -const snapPositionTo10px = (position: Position): Position => { - return { - x: snapTo10px(position.x), - y: snapTo10px(position.y), - z: position.z, - }; -}; - -const snapSizeTo10px = (size: { width: number; height: number }): { width: number; height: number } => { - return { - width: snapTo10px(size.width), - height: snapTo10px(size.height), - }; -}; - -// calculateGridInfo 더미 함수 (하위 호환성을 위해 유지) -const calculateGridInfo = (width: number, height: number, settings: any) => { - return { - columnWidth: 10, - totalWidth: width, - totalHeight: height, - columns: settings.columns || 12, - gap: settings.gap || 0, - padding: settings.padding || 0, - }; -}; -import { GroupingToolbar } from "./GroupingToolbar"; -import { screenApi, tableTypeApi } from "@/lib/api/screen"; -import { tableManagementApi } from "@/lib/api/tableManagement"; -import { ExternalRestApiConnectionAPI } from "@/lib/api/externalRestApiConnection"; -import { toast } from "sonner"; -import { MenuAssignmentModal } from "./MenuAssignmentModal"; -import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal"; -import { initializeComponents } from "@/lib/registry/components"; -import { ScreenFileAPI } from "@/lib/api/screenFile"; -import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan"; -import { convertV2ToLegacy, convertLegacyToV2, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; - -// V2 API 사용 플래그 (true: V2, false: 기존) -const USE_V2_API = true; - -import StyleEditor from "./StyleEditor"; -import { RealtimePreview } from "./RealtimePreviewDynamic"; -import FloatingPanel from "./FloatingPanel"; -import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; -import { MultilangSettingsModal } from "./modals/MultilangSettingsModal"; -import DesignerToolbar from "./DesignerToolbar"; -import TablesPanel from "./panels/TablesPanel"; -import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel"; -import { ComponentsPanel } from "./panels/ComponentsPanel"; -import PropertiesPanel from "./panels/PropertiesPanel"; -import DetailSettingsPanel from "./panels/DetailSettingsPanel"; -import ResolutionPanel from "./panels/ResolutionPanel"; -import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; -import { FlowButtonGroup } from "./widgets/FlowButtonGroup"; -import { FlowVisibilityConfig } from "@/types/control-management"; -import { - areAllButtons, - generateGroupId, - groupButtons, - ungroupButtons, - findAllButtonGroups, -} from "@/lib/utils/flowButtonGroupUtils"; -import { FlowButtonGroupDialog } from "./dialogs/FlowButtonGroupDialog"; -import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; -import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; - -// 새로운 통합 UI 컴포넌트 -import { SlimToolbar } from "./toolbar/SlimToolbar"; -import { V2PropertiesPanel } from "./panels/V2PropertiesPanel"; - -// 컴포넌트 초기화 (새 시스템) -import "@/lib/registry/components"; -// 성능 최적화 도구 초기화 (필요시 사용) -import "@/lib/registry/utils/performanceOptimizer"; - -interface ScreenDesignerProps { - selectedScreen: ScreenDefinition | null; - onBackToList: () => void; - onScreenUpdate?: (updatedScreen: Partial) => void; -} - -import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext"; -import { LayerManagerPanel } from "./LayerManagerPanel"; -import { LayerType, LayerDefinition } from "@/types/screen-management"; - -// 패널 설정 업데이트 -const panelConfigs: PanelConfig[] = [ - { - id: "v2", - title: "패널", - defaultPosition: "left", - defaultWidth: 240, - defaultHeight: 700, - shortcutKey: "p", - }, - { - id: "layer", - title: "레이어", - defaultPosition: "right", - defaultWidth: 240, - defaultHeight: 500, - shortcutKey: "l", - }, -]; - -export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) { - const [layout, setLayout] = useState({ - components: [], - gridSettings: { - columns: 12, - gap: 16, - padding: 0, - snapToGrid: true, - showGrid: false, // 기본값 false로 변경 - gridColor: "#d1d5db", - gridOpacity: 0.5, - }, - }); - const [isSaving, setIsSaving] = useState(false); - const [isGeneratingMultilang, setIsGeneratingMultilang] = useState(false); - const [showMultilangSettingsModal, setShowMultilangSettingsModal] = useState(false); - - // 🆕 화면에 할당된 메뉴 OBJID - const [menuObjid, setMenuObjid] = useState(undefined); - - // 메뉴 할당 모달 상태 - const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false); - - // 단축키 도움말 모달 상태 - const [showShortcutsModal, setShowShortcutsModal] = useState(false); - - // 파일첨부 상세 모달 상태 - const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false); - const [selectedFileComponent, setSelectedFileComponent] = useState(null); - - // 해상도 설정 상태 - const [screenResolution, setScreenResolution] = useState( - SCREEN_RESOLUTIONS[0], // 기본값: Full HD - ); - - // 🆕 패널 상태 관리 (usePanelState 훅) - const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels, updatePanelPosition, updatePanelSize } = - usePanelState(panelConfigs); - - const [selectedComponent, setSelectedComponent] = useState(null); - - // 🆕 탭 내부 컴포넌트 선택 상태 (중첩 구조 지원) - const [selectedTabComponentInfo, setSelectedTabComponentInfo] = useState<{ - tabsComponentId: string; // 탭 컴포넌트 ID - tabId: string; // 탭 ID - componentId: string; // 탭 내부 컴포넌트 ID - component: any; // 탭 내부 컴포넌트 데이터 - // 🆕 중첩 구조용: 부모 분할 패널 정보 - parentSplitPanelId?: string | null; - parentPanelSide?: "left" | "right" | null; - } | null>(null); - - // 🆕 분할 패널 내부 컴포넌트 선택 상태 - const [selectedPanelComponentInfo, setSelectedPanelComponentInfo] = useState<{ - splitPanelId: string; // 분할 패널 컴포넌트 ID - panelSide: "left" | "right"; // 좌측/우측 패널 - componentId: string; // 패널 내부 컴포넌트 ID - component: any; // 패널 내부 컴포넌트 데이터 - } | null>(null); - - // 컴포넌트 선택 시 통합 패널 자동 열기 - const handleComponentSelect = useCallback( - (component: ComponentData | null) => { - setSelectedComponent(component); - // 일반 컴포넌트 선택 시 탭 내부 컴포넌트/분할 패널 내부 컴포넌트 선택 해제 - if (component) { - setSelectedTabComponentInfo(null); - setSelectedPanelComponentInfo(null); - } - - // 컴포넌트가 선택되면 통합 패널 자동 열기 - if (component) { - openPanel("v2"); - } - }, - [openPanel], - ); - - // 🆕 탭 내부 컴포넌트 선택 핸들러 (중첩 구조 지원) - const handleSelectTabComponent = useCallback( - ( - tabsComponentId: string, - tabId: string, - compId: string, - comp: any, - // 🆕 중첩 구조용: 부모 분할 패널 정보 (선택적) - parentSplitPanelId?: string | null, - parentPanelSide?: "left" | "right" | null, - ) => { - if (!compId) { - // 탭 영역 빈 공간 클릭 시 선택 해제 - setSelectedTabComponentInfo(null); - return; - } - - setSelectedTabComponentInfo({ - tabsComponentId, - tabId, - componentId: compId, - component: comp, - parentSplitPanelId: parentSplitPanelId || null, - parentPanelSide: parentPanelSide || null, - }); - // 탭 내부 컴포넌트 선택 시 일반 컴포넌트/분할 패널 내부 컴포넌트 선택 해제 - setSelectedComponent(null); - setSelectedPanelComponentInfo(null); - openPanel("v2"); - }, - [openPanel], - ); - - // 🆕 분할 패널 내부 컴포넌트 선택 핸들러 - const handleSelectPanelComponent = useCallback( - (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => { - // 🐛 디버깅: 전달받은 comp 확인 - console.log("🐛 [handleSelectPanelComponent] comp:", { - compId, - componentType: comp?.componentType, - selectedTable: comp?.componentConfig?.selectedTable, - fieldMapping: comp?.componentConfig?.fieldMapping, - fieldMappingKeys: comp?.componentConfig?.fieldMapping ? Object.keys(comp.componentConfig.fieldMapping) : [], - }); - - if (!compId) { - // 패널 영역 빈 공간 클릭 시 선택 해제 - setSelectedPanelComponentInfo(null); - return; - } - - setSelectedPanelComponentInfo({ - splitPanelId, - panelSide, - componentId: compId, - component: comp, - }); - // 분할 패널 내부 컴포넌트 선택 시 일반 컴포넌트/탭 내부 컴포넌트 선택 해제 - setSelectedComponent(null); - setSelectedTabComponentInfo(null); - openPanel("v2"); - }, - [openPanel], - ); - - // 🆕 중첩된 탭 컴포넌트 선택 이벤트 리스너 (분할 패널 안의 탭 안의 컴포넌트) - useEffect(() => { - const handleNestedTabComponentSelect = (event: CustomEvent) => { - const { tabsComponentId, tabId, componentId, component, parentSplitPanelId, parentPanelSide } = event.detail; - - if (!componentId) { - setSelectedTabComponentInfo(null); - return; - } - - console.log("🎯 중첩된 탭 컴포넌트 선택:", event.detail); - - setSelectedTabComponentInfo({ - tabsComponentId, - tabId, - componentId, - component, - parentSplitPanelId, - parentPanelSide, - }); - setSelectedComponent(null); - setSelectedPanelComponentInfo(null); - openPanel("v2"); - }; - - window.addEventListener("nested-tab-component-select", handleNestedTabComponentSelect as EventListener); - - return () => { - window.removeEventListener("nested-tab-component-select", handleNestedTabComponentSelect as EventListener); - }; - }, [openPanel]); - - // 클립보드 상태 - const [clipboard, setClipboard] = useState([]); - - // 실행취소/다시실행을 위한 히스토리 상태 - const [history, setHistory] = useState([]); - const [historyIndex, setHistoryIndex] = useState(-1); - - // 그룹 상태 - const [groupState, setGroupState] = useState({ - selectedComponents: [], - isGrouping: false, - }); - - // 드래그 상태 - const [dragState, setDragState] = useState({ - isDragging: false, - draggedComponent: null as ComponentData | null, - draggedComponents: [] as ComponentData[], // 다중 드래그를 위한 컴포넌트 배열 - originalPosition: { x: 0, y: 0, z: 1 }, - currentPosition: { x: 0, y: 0, z: 1 }, - grabOffset: { x: 0, y: 0 }, - justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용 - }); - - // Pan 모드 상태 (스페이스바 + 드래그) - const [isPanMode, setIsPanMode] = useState(false); - const [panState, setPanState] = useState({ - isPanning: false, - startX: 0, - startY: 0, - outerScrollLeft: 0, - outerScrollTop: 0, - innerScrollLeft: 0, - innerScrollTop: 0, - }); - const canvasContainerRef = useRef(null); - - // Zoom 상태 - const [zoomLevel, setZoomLevel] = useState(1); // 1 = 100% - const MIN_ZOOM = 0.1; // 10% - const MAX_ZOOM = 3; // 300% - const zoomRafRef = useRef(null); // 줌 RAF throttle용 - - // 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태 - const [forceRenderTrigger, setForceRenderTrigger] = useState(0); - - // 파일 컴포넌트 데이터 복원 함수 (실제 DB에서 조회) - const restoreFileComponentsData = useCallback( - async (components: ComponentData[]) => { - if (!selectedScreen?.screenId) return; - - // console.log("🔄 파일 컴포넌트 데이터 복원 시작:", components.length); - - try { - // 실제 DB에서 화면의 모든 파일 정보 조회 - const fileResponse = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId); - - if (!fileResponse.success) { - // console.warn("⚠️ 파일 정보 조회 실패:", fileResponse); - return; - } - - const { componentFiles } = fileResponse; - - if (typeof window !== "undefined") { - // 전역 파일 상태 초기화 - const globalFileState: { [key: string]: any[] } = {}; - let restoredCount = 0; - - // DB에서 조회한 파일 정보를 전역 상태로 복원 - Object.keys(componentFiles).forEach((componentId) => { - const files = componentFiles[componentId]; - if (files && files.length > 0) { - globalFileState[componentId] = files; - restoredCount++; - - // localStorage에도 백업 - const backupKey = `fileComponent_${componentId}_files`; - localStorage.setItem(backupKey, JSON.stringify(files)); - - console.log("📁 DB에서 파일 컴포넌트 데이터 복원:", { - componentId: componentId, - fileCount: files.length, - files: files.map((f) => ({ objid: f.objid, name: f.realFileName })), - }); - } - }); - - // 전역 상태 업데이트 - (window as any).globalFileState = globalFileState; - - // 모든 파일 컴포넌트에 복원 완료 이벤트 발생 - Object.keys(globalFileState).forEach((componentId) => { - const files = globalFileState[componentId]; - const syncEvent = new CustomEvent("globalFileStateChanged", { - detail: { - componentId: componentId, - files: files, - fileCount: files.length, - timestamp: Date.now(), - isRestore: true, - }, - }); - window.dispatchEvent(syncEvent); - }); - - if (restoredCount > 0) { - toast.success( - `${restoredCount}개 파일 컴포넌트 데이터가 DB에서 복원되었습니다. (총 ${fileResponse.totalFiles}개 파일)`, - ); - } - } - } catch (error) { - // console.error("❌ 파일 컴포넌트 데이터 복원 실패:", error); - toast.error("파일 데이터 복원 중 오류가 발생했습니다."); - } - }, - [selectedScreen?.screenId], - ); - - // 드래그 선택 상태 - const [selectionDrag, setSelectionDrag] = useState({ - isSelecting: false, - startPoint: { x: 0, y: 0, z: 1 }, - currentPoint: { x: 0, y: 0, z: 1 }, - wasSelecting: false, // 방금 전에 드래그 선택이 진행 중이었는지 추적 - }); - - // 테이블 데이터 - 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); - - const canvasRef = useRef(null); - - // 10px 격자 라인 생성 (시각적 가이드용) - const gridLines = useMemo(() => { - if (!layout.gridSettings?.showGrid) return []; - - const width = screenResolution.width; - const height = screenResolution.height; - const lines: Array<{ type: "vertical" | "horizontal"; position: number }> = []; - - // 10px 단위로 격자 라인 생성 - for (let x = 0; x <= width; x += 10) { - lines.push({ type: "vertical", position: x }); - } - for (let y = 0; y <= height; y += 10) { - lines.push({ type: "horizontal", position: y }); - } - - return lines; - }, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]); - - // 🆕 레이어 활성 상태 관리 (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(() => { - const placed = new Set(); - // 🔧 화면의 메인 테이블명을 fallback으로 사용 - const screenTableName = selectedScreen?.tableName; - - const collectColumns = (components: ComponentData[]) => { - components.forEach((comp) => { - const anyComp = comp as any; - - // 🔧 tableName과 columnName을 여러 위치에서 찾기 (최상위, componentConfig, 또는 화면 테이블명) - const tableName = anyComp.tableName || anyComp.componentConfig?.tableName || screenTableName; - const columnName = anyComp.columnName || anyComp.componentConfig?.columnName; - - // widget 타입 또는 component 타입에서 columnName 확인 (tableName은 화면 테이블명으로 fallback) - if ((comp.type === "widget" || comp.type === "component") && tableName && columnName) { - const key = `${tableName}.${columnName}`; - placed.add(key); - } - - // 자식 컴포넌트도 확인 (재귀) - if (comp.children && comp.children.length > 0) { - collectColumns(comp.children); - } - }); - }; - - collectColumns(layout.components); - return placed; - }, [layout.components, selectedScreen?.tableName]); - - // 히스토리에 저장 - const saveToHistory = useCallback( - (newLayout: LayoutData) => { - setHistory((prev) => { - const newHistory = prev.slice(0, historyIndex + 1); - newHistory.push(newLayout); - return newHistory.slice(-50); // 최대 50개까지만 저장 - }); - setHistoryIndex((prev) => Math.min(prev + 1, 49)); - }, - [historyIndex], - ); - - // 🆕 탭 내부 컴포넌트 설정 업데이트 핸들러 (중첩 구조 지원) - const handleUpdateTabComponentConfig = useCallback( - (path: string, value: any) => { - if (!selectedTabComponentInfo) return; - - const { tabsComponentId, tabId, componentId, parentSplitPanelId, parentPanelSide } = selectedTabComponentInfo; - - // 탭 컴포넌트 업데이트 함수 (재사용) - const updateTabsComponent = (tabsComponent: any) => { - const currentConfig = tabsComponent.componentConfig || {}; - const tabs = currentConfig.tabs || []; - - const updatedTabs = tabs.map((tab: any) => { - if (tab.id === tabId) { - return { - ...tab, - components: (tab.components || []).map((comp: any) => { - if (comp.id === componentId) { - if (path.startsWith("componentConfig.")) { - const configPath = path.replace("componentConfig.", ""); - return { - ...comp, - componentConfig: { ...comp.componentConfig, [configPath]: value }, - }; - } else if (path.startsWith("style.")) { - const stylePath = path.replace("style.", ""); - return { ...comp, style: { ...comp.style, [stylePath]: value } }; - } else if (path.startsWith("size.")) { - const sizePath = path.replace("size.", ""); - return { ...comp, size: { ...comp.size, [sizePath]: value } }; - } else { - return { ...comp, [path]: value }; - } - } - return comp; - }), - }; - } - return tab; - }); - - return { ...tabsComponent, componentConfig: { ...currentConfig, tabs: updatedTabs } }; - }; - - setLayout((prevLayout) => { - let newLayout; - let updatedTabs; - - if (parentSplitPanelId && parentPanelSide) { - // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => { - if (c.id === parentSplitPanelId) { - const splitConfig = (c as any).componentConfig || {}; - const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = splitConfig[panelKey] || {}; - const panelComponents = panelConfig.components || []; - - const tabsComponent = panelComponents.find((pc: any) => pc.id === tabsComponentId); - if (!tabsComponent) return c; - - const updatedTabsComponent = updateTabsComponent(tabsComponent); - updatedTabs = updatedTabsComponent.componentConfig.tabs; - - return { - ...c, - componentConfig: { - ...splitConfig, - [panelKey]: { - ...panelConfig, - components: panelComponents.map((pc: any) => - pc.id === tabsComponentId ? updatedTabsComponent : pc, - ), - }, - }, - }; - } - return c; - }), - }; - } else { - // 일반 구조: 최상위 탭 업데이트 - const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId); - if (!tabsComponent) return prevLayout; - - const updatedTabsComponent = updateTabsComponent(tabsComponent); - updatedTabs = updatedTabsComponent.componentConfig.tabs; - - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => (c.id === tabsComponentId ? updatedTabsComponent : c)), - }; - } - - // 선택된 컴포넌트 정보도 업데이트 - if (updatedTabs) { - const updatedComp = updatedTabs - .find((t: any) => t.id === tabId) - ?.components?.find((c: any) => c.id === componentId); - if (updatedComp) { - setSelectedTabComponentInfo((prev) => (prev ? { ...prev, component: updatedComp } : null)); - } - } - - return newLayout; - }); - }, - [selectedTabComponentInfo], - ); - - // 실행취소 - const undo = useCallback(() => { - setHistoryIndex((prevIndex) => { - if (prevIndex > 0) { - const newIndex = prevIndex - 1; - setHistory((prevHistory) => { - if (prevHistory[newIndex]) { - setLayout(prevHistory[newIndex]); - } - return prevHistory; - }); - return newIndex; - } - return prevIndex; - }); - }, []); - - // 다시실행 - const redo = useCallback(() => { - setHistoryIndex((prevIndex) => { - let newIndex = prevIndex; - setHistory((prevHistory) => { - if (prevIndex < prevHistory.length - 1) { - newIndex = prevIndex + 1; - if (prevHistory[newIndex]) { - setLayout(prevHistory[newIndex]); - } - } - return prevHistory; - }); - return newIndex; - }); - }, []); - - // 컴포넌트 속성 업데이트 - const updateComponentProperty = useCallback( - (componentId: string, path: string, value: any) => { - // 🔥 함수형 업데이트로 변경하여 최신 layout 사용 - setLayout((prevLayout) => { - const targetComponent = prevLayout.components.find((comp) => comp.id === componentId); - const isLayoutComponent = targetComponent?.type === "layout"; - - // 🆕 그룹 설정 변경 시 같은 그룹의 모든 버튼에 일괄 적용 - const isGroupSetting = path === "webTypeConfig.flowVisibilityConfig.groupAlign"; - - let affectedComponents: string[] = [componentId]; // 기본적으로 현재 컴포넌트만 - - if (isGroupSetting && targetComponent) { - const flowConfig = (targetComponent as any).webTypeConfig?.flowVisibilityConfig; - const currentGroupId = flowConfig?.groupId; - - if (currentGroupId) { - // 같은 그룹의 모든 버튼 찾기 - affectedComponents = prevLayout.components - .filter((comp) => { - const compConfig = (comp as any).webTypeConfig?.flowVisibilityConfig; - return compConfig?.groupId === currentGroupId && compConfig?.enabled; - }) - .map((comp) => comp.id); - - console.log("🔄 그룹 설정 일괄 적용:", { - groupId: currentGroupId, - setting: path.split(".").pop(), - value, - affectedButtons: affectedComponents, - }); - } - } - - // 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동 - const positionDelta = { x: 0, y: 0 }; - if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) { - const oldPosition = targetComponent.position; - let newPosition = { ...oldPosition }; - - if (path === "position.x") { - newPosition.x = value; - positionDelta.x = value - oldPosition.x; - } else if (path === "position.y") { - newPosition.y = value; - positionDelta.y = value - oldPosition.y; - } else if (path === "position") { - newPosition = value; - positionDelta.x = value.x - oldPosition.x; - positionDelta.y = value.y - oldPosition.y; - } - - console.log("📐 레이아웃 이동 감지:", { - layoutId: componentId, - oldPosition, - newPosition, - positionDelta, - }); - } - - const pathParts = path.split("."); - const updatedComponents = prevLayout.components.map((comp) => { - // 🆕 그룹 설정이면 같은 그룹의 모든 버튼에 적용 - const shouldUpdate = isGroupSetting ? affectedComponents.includes(comp.id) : comp.id === componentId; - - if (!shouldUpdate) { - // 레이아웃 이동 시 존에 속한 컴포넌트들도 함께 이동 - if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) { - // 이 레이아웃의 존에 속한 컴포넌트인지 확인 - const isInLayoutZone = comp.parentId === componentId && comp.zoneId; - if (isInLayoutZone) { - console.log("🔄 존 컴포넌트 함께 이동:", { - componentId: comp.id, - zoneId: comp.zoneId, - oldPosition: comp.position, - delta: positionDelta, - }); - - return { - ...comp, - position: { - ...comp.position, - x: comp.position.x + positionDelta.x, - y: comp.position.y + positionDelta.y, - }, - }; - } - } - return comp; - } - - // 중첩 경로를 고려한 안전한 복사 - const newComp = { ...comp }; - - // 경로를 따라 내려가면서 각 레벨을 새 객체로 복사 - let current: any = newComp; - for (let i = 0; i < pathParts.length - 1; i++) { - const key = pathParts[i]; - - // 다음 레벨이 없거나 객체가 아니면 새 객체 생성 - if (!current[key] || typeof current[key] !== "object" || Array.isArray(current[key])) { - current[key] = {}; - } else { - // 기존 객체를 복사하여 불변성 유지 - current[key] = { ...current[key] }; - } - current = current[key]; - } - - // 최종 값 설정 - const finalKey = pathParts[pathParts.length - 1]; - current[finalKey] = value; - - // 🔧 style 관련 업데이트 디버그 로그 - if (path.includes("style") || path.includes("labelDisplay")) { - console.log("🎨 style 업데이트 제대로 렌더링된거다 내가바꿈:", { - componentId: comp.id, - path, - value, - updatedStyle: newComp.style, - pathIncludesLabelDisplay: path.includes("labelDisplay"), - }); - } - - // 🆕 labelDisplay 변경 시 강제 리렌더링 트리거 (조건문 밖으로 이동) - if (path === "style.labelDisplay") { - console.log("⏰⏰⏰ labelDisplay 변경 감지! forceRenderTrigger 실행 예정"); - } - - // 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화) - if (path === "size.width" || path === "size.height" || path === "size") { - // 🔧 style 객체를 새로 복사하여 불변성 유지 - newComp.style = { ...(newComp.style || {}) }; - - if (path === "size.width") { - newComp.style.width = `${value}px`; - } else if (path === "size.height") { - newComp.style.height = `${value}px`; - } else if (path === "size") { - // size 객체 전체가 변경된 경우 - if (value.width !== undefined) { - newComp.style.width = `${value.width}px`; - } - if (value.height !== undefined) { - newComp.style.height = `${value.height}px`; - } - } - - console.log("🔄 size 변경 → style 동기화:", { - componentId: newComp.id, - path, - value, - updatedStyle: newComp.style, - }); - } - - // gridColumns 변경 시 크기 자동 업데이트 제거 (격자 시스템 제거됨) - // if (path === "gridColumns" && prevLayout.gridSettings) { - // const updatedSize = updateSizeFromGridColumns(newComp, prevLayout.gridSettings as GridUtilSettings); - // newComp.size = updatedSize; - // } - - // 크기 변경 시 격자 스냅 적용 제거 (직접 입력 시 불필요) - // 드래그/리사이즈 시에는 별도 로직에서 처리됨 - // if ( - // (path === "size.width" || path === "size.height") && - // prevLayout.gridSettings?.snapToGrid && - // newComp.type !== "group" - // ) { - // const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - // columns: prevLayout.gridSettings.columns, - // gap: prevLayout.gridSettings.gap, - // padding: prevLayout.gridSettings.padding, - // snapToGrid: prevLayout.gridSettings.snapToGrid || false, - // }); - // const snappedSize = snapSizeToGrid( - // newComp.size, - // currentGridInfo, - // prevLayout.gridSettings as GridUtilSettings, - // ); - // newComp.size = snappedSize; - // - // const adjustedColumns = adjustGridColumnsFromSize( - // newComp, - // currentGridInfo, - // prevLayout.gridSettings as GridUtilSettings, - // ); - // if (newComp.gridColumns !== adjustedColumns) { - // newComp.gridColumns = adjustedColumns; - // } - // } - - // gridColumns 변경 시 크기를 격자에 맞게 자동 조정 제거 (격자 시스템 제거됨) - // if (path === "gridColumns" && prevLayout.gridSettings?.snapToGrid && newComp.type !== "group") { - // const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - // columns: prevLayout.gridSettings.columns, - // gap: prevLayout.gridSettings.gap, - // padding: prevLayout.gridSettings.padding, - // snapToGrid: prevLayout.gridSettings.snapToGrid || false, - // }); - // - // const newWidth = calculateWidthFromColumns( - // newComp.gridColumns, - // currentGridInfo, - // prevLayout.gridSettings as GridUtilSettings, - // ); - // newComp.size = { - // ...newComp.size, - // width: newWidth, - // }; - // } - - // 위치 변경 시 격자 스냅 적용 (그룹 내부 컴포넌트 포함) - if ( - (path === "position.x" || path === "position.y" || path === "position") && - layout.gridSettings?.snapToGrid - ) { - // 현재 해상도에 맞는 격자 정보 계산 - const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }); - - // 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용 - if (newComp.parentId && currentGridInfo) { - const { columnWidth } = currentGridInfo; - const { gap } = layout.gridSettings; - - // 그룹 내부 패딩 고려한 격자 정렬 - const padding = 16; - const effectiveX = newComp.position.x - padding; - const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16))); - const snappedX = padding + columnIndex * (columnWidth + (gap || 16)); - - // Y 좌표는 10px 단위로 스냅 - const effectiveY = newComp.position.y - padding; - const rowIndex = Math.round(effectiveY / 10); - const snappedY = padding + rowIndex * 10; - - // 크기도 외부 격자와 동일하게 스냅 - const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기 - const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth)); - const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기 - // 높이는 사용자가 입력한 값 그대로 사용 (스냅 제거) - const snappedHeight = Math.max(10, newComp.size.height); - - newComp.position = { - x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 - y: Math.max(padding, snappedY), - z: newComp.position.z || 1, - }; - - newComp.size = { - width: snappedWidth, - height: snappedHeight, - }; - } else if (newComp.type !== "group") { - // 그룹이 아닌 일반 컴포넌트만 격자 스냅 적용 - const snappedPosition = snapPositionTo10px( - newComp.position, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ); - newComp.position = snappedPosition; - } - } - - return newComp; - }); - - // 🔥 새로운 layout 생성 - const newLayout = { ...prevLayout, components: updatedComponents }; - - saveToHistory(newLayout); - - // selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트 - setSelectedComponent((prevSelected) => { - if (prevSelected && prevSelected.id === componentId) { - const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId); - if (updatedSelectedComponent) { - // 🔧 완전히 새로운 객체를 만들어서 React가 변경을 감지하도록 함 - const newSelectedComponent = JSON.parse(JSON.stringify(updatedSelectedComponent)); - return newSelectedComponent; - } - } - return prevSelected; - }); - - // webTypeConfig 업데이트 후 레이아웃 상태 확인 - if (path === "webTypeConfig") { - const updatedComponent = newLayout.components.find((c) => c.id === componentId); - console.log("🔄 레이아웃 업데이트 후 컴포넌트 상태:", { - componentId, - updatedComponent: updatedComponent - ? { - id: updatedComponent.id, - type: updatedComponent.type, - webTypeConfig: updatedComponent.type === "widget" ? (updatedComponent as any).webTypeConfig : null, - } - : null, - layoutComponentsCount: newLayout.components.length, - timestamp: new Date().toISOString(), - }); - } - - return newLayout; - }); - }, - [saveToHistory], - ); - - // 컴포넌트 시스템 초기화 - useEffect(() => { - const initComponents = async () => { - try { - // console.log("🚀 컴포넌트 시스템 초기화 시작..."); - await initializeComponents(); - // console.log("✅ 컴포넌트 시스템 초기화 완료"); - } catch (error) { - // console.error("❌ 컴포넌트 시스템 초기화 실패:", error); - } - }; - - initComponents(); - }, []); - - // 화면 선택 시 파일 복원 - useEffect(() => { - if (selectedScreen?.screenId) { - restoreScreenFiles(); - } - }, [selectedScreen?.screenId]); - - // 화면의 모든 파일 컴포넌트 파일 복원 - const restoreScreenFiles = useCallback(async () => { - if (!selectedScreen?.screenId) return; - - try { - // console.log("🔄 화면 파일 복원 시작:", selectedScreen.screenId); - - // 해당 화면의 모든 파일 조회 - const response = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId); - - if (response.success && response.componentFiles) { - // console.log("📁 복원할 파일 데이터:", response.componentFiles); - - // 각 컴포넌트별로 파일 복원 (전역 상태와 localStorage 우선 적용) - Object.entries(response.componentFiles).forEach(([componentId, serverFiles]) => { - if (Array.isArray(serverFiles) && serverFiles.length > 0) { - // 🎯 전역 상태와 localStorage에서 현재 파일 상태 확인 - const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; - const currentGlobalFiles = globalFileState[componentId] || []; - - let currentLocalStorageFiles: any[] = []; - if (typeof window !== "undefined") { - try { - const storedFiles = localStorage.getItem(`fileComponent_${componentId}_files`); - if (storedFiles) { - currentLocalStorageFiles = JSON.parse(storedFiles); - } - } catch (e) { - // console.warn("localStorage 파일 파싱 실패:", e); - } - } - - // 🎯 우선순위: 전역 상태 > localStorage > 서버 데이터 - let finalFiles = serverFiles; - if (currentGlobalFiles.length > 0) { - finalFiles = currentGlobalFiles; - // console.log(`📂 컴포넌트 ${componentId} 전역 상태 우선 적용:`, finalFiles.length, "개"); - } else if (currentLocalStorageFiles.length > 0) { - finalFiles = currentLocalStorageFiles; - // console.log(`📂 컴포넌트 ${componentId} localStorage 우선 적용:`, finalFiles.length, "개"); - } else { - // console.log(`📂 컴포넌트 ${componentId} 서버 데이터 적용:`, finalFiles.length, "개"); - } - - // 전역 상태에 파일 저장 - globalFileState[componentId] = finalFiles; - if (typeof window !== "undefined") { - (window as any).globalFileState = globalFileState; - } - - // localStorage에도 백업 - if (typeof window !== "undefined") { - localStorage.setItem(`fileComponent_${componentId}_files`, JSON.stringify(finalFiles)); - } - } - }); - - // 레이아웃의 컴포넌트들에 파일 정보 적용 (전역 상태 우선) - setLayout((prevLayout) => { - const updatedComponents = prevLayout.components.map((comp) => { - // 🎯 전역 상태에서 최신 파일 정보 가져오기 - const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; - const finalFiles = globalFileState[comp.id] || []; - - if (finalFiles.length > 0) { - return { - ...comp, - uploadedFiles: finalFiles, - lastFileUpdate: Date.now(), - }; - } - return comp; - }); - - return { - ...prevLayout, - components: updatedComponents, - }; - }); - - // console.log("✅ 화면 파일 복원 완료"); - } - } catch (error) { - // console.error("❌ 화면 파일 복원 오류:", error); - } - }, [selectedScreen?.screenId]); - - // 전역 파일 상태 변경 이벤트 리스너 - useEffect(() => { - const handleGlobalFileStateChange = (event: CustomEvent) => { - // console.log("🔄 ScreenDesigner: 전역 파일 상태 변경 감지", event.detail); - setForceRenderTrigger((prev) => prev + 1); - }; - - if (typeof window !== "undefined") { - window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); - - return () => { - window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener); - }; - } - }, []); - - // 화면의 기본 테이블/REST API 정보 로드 - useEffect(() => { - const loadScreenDataSource = async () => { - console.log("🔍 [ScreenDesigner] 데이터 소스 로드 시작:", { - screenId: selectedScreen?.screenId, - screenName: selectedScreen?.screenName, - dataSourceType: selectedScreen?.dataSourceType, - tableName: selectedScreen?.tableName, - restApiConnectionId: selectedScreen?.restApiConnectionId, - restApiEndpoint: selectedScreen?.restApiEndpoint, - restApiJsonPath: selectedScreen?.restApiJsonPath, - // 전체 selectedScreen 객체도 출력 - fullScreen: selectedScreen, - }); - - // REST API 데이터 소스인 경우 - // 1. dataSourceType이 "restapi"인 경우 - // 2. tableName이 restapi_ 또는 _restapi_로 시작하는 경우 - // 3. restApiConnectionId가 있는 경우 - const isRestApi = - selectedScreen?.dataSourceType === "restapi" || - selectedScreen?.tableName?.startsWith("restapi_") || - selectedScreen?.tableName?.startsWith("_restapi_") || - !!selectedScreen?.restApiConnectionId; - - console.log("🔍 [ScreenDesigner] REST API 여부:", { isRestApi }); - - if (isRestApi && (selectedScreen?.restApiConnectionId || selectedScreen?.tableName)) { - try { - // 연결 ID 추출 (restApiConnectionId가 없으면 tableName에서 추출) - let connectionId = selectedScreen?.restApiConnectionId; - if (!connectionId && selectedScreen?.tableName) { - const match = selectedScreen.tableName.match(/restapi_(\d+)/); - connectionId = match ? parseInt(match[1]) : undefined; - } - - if (!connectionId) { - throw new Error("REST API 연결 ID를 찾을 수 없습니다."); - } - - console.log("🌐 [ScreenDesigner] REST API 데이터 로드:", { connectionId }); - - const restApiData = await ExternalRestApiConnectionAPI.fetchData( - connectionId, - selectedScreen?.restApiEndpoint, - selectedScreen?.restApiJsonPath || "response", // 기본값을 response로 변경 - ); - - // REST API 응답에서 컬럼 정보 생성 - const columns: ColumnInfo[] = restApiData.columns.map((col) => ({ - tableName: `restapi_${connectionId}`, - columnName: col.columnName, - columnLabel: col.columnLabel, - dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType, - webType: col.dataType === "number" ? "number" : "text", - input_type: "text", - widgetType: col.dataType === "number" ? "number" : "text", - isNullable: "YES", - required: false, - })); - - const tableInfo: TableInfo = { - tableName: `restapi_${connectionId}`, - tableLabel: restApiData.connectionInfo.connectionName || "REST API 데이터", - columns, - }; - - console.log("✅ [ScreenDesigner] REST API 컬럼 로드 완료:", { - tableName: tableInfo.tableName, - tableLabel: tableInfo.tableLabel, - columnsCount: columns.length, - columns: columns.map((c) => c.columnName), - }); - - setTables([tableInfo]); - console.log("REST API 데이터 소스 로드 완료:", { - connectionName: restApiData.connectionInfo.connectionName, - columnsCount: columns.length, - rowsCount: restApiData.total, - }); - } catch (error) { - console.error("REST API 데이터 소스 로드 실패:", error); - toast.error("REST API 데이터를 불러오는데 실패했습니다."); - setTables([]); - } - return; - } - - // 데이터베이스 데이터 소스인 경우 (기존 로직) - const tableName = selectedScreen?.tableName; - if (!tableName) { - setTables([]); - return; - } - - try { - // 테이블 라벨 조회 - const tableListResponse = await tableManagementApi.getTableList(); - const currentTable = - tableListResponse.success && tableListResponse.data - ? tableListResponse.data.find((t) => t.tableName === tableName) - : null; - const tableLabel = currentTable?.displayName || tableName; - - // 현재 화면의 테이블 컬럼 정보 조회 (캐시 버스팅으로 최신 데이터 가져오기) - const columnsResponse = await tableTypeApi.getColumns(tableName, true); - - const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => { - // widgetType 결정: inputType(entity 등) > webType > widget_type - const inputType = col.inputType || col.input_type; - const widgetType = inputType || col.widgetType || col.widget_type || col.webType || col.web_type; - - // detailSettings 파싱 (문자열이면 JSON 파싱) - let detailSettings = col.detailSettings || col.detail_settings; - if (typeof detailSettings === "string") { - // JSON 형식인 경우에만 파싱 시도 (중괄호로 시작하는 경우) - if (detailSettings.trim().startsWith("{")) { - try { - detailSettings = JSON.parse(detailSettings); - } catch (e) { - console.warn("detailSettings 파싱 실패:", e); - detailSettings = {}; - } - } else { - // JSON이 아닌 일반 문자열인 경우 빈 객체로 처리 - detailSettings = {}; - } - } - - // 엔티티 타입 디버깅 - if (inputType === "entity" || widgetType === "entity") { - console.log("🔍 엔티티 컬럼 감지:", { - columnName: col.columnName || col.column_name, - inputType, - widgetType, - detailSettings, - referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table, - }); - } - - return { - tableName: col.tableName || tableName, - columnName: col.columnName || col.column_name, - columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, - dataType: col.dataType || col.data_type || col.dbType, - webType: col.webType || col.web_type, - input_type: inputType, - inputType: inputType, - widgetType, - isNullable: col.isNullable || col.is_nullable, - required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", - columnDefault: col.columnDefault || col.column_default, - characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, - codeCategory: col.codeCategory || col.code_category, - codeValue: col.codeValue || col.code_value, - // 엔티티 타입용 참조 테이블 정보 (detailSettings에서 추출) - referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table, - referenceColumn: detailSettings?.referenceColumn || col.referenceColumn || col.reference_column, - displayColumn: detailSettings?.displayColumn || col.displayColumn || col.display_column, - // detailSettings 전체 보존 (V2 컴포넌트용) - detailSettings, - }; - }); - - const tableInfo: TableInfo = { - tableName, - tableLabel, - columns, - }; - - setTables([tableInfo]); // 현재 화면의 테이블만 저장 (원래대로) - } catch (error) { - console.error("화면 테이블 정보 로드 실패:", error); - setTables([]); - } - }; - - loadScreenDataSource(); - }, [ - selectedScreen?.tableName, - selectedScreen?.screenName, - selectedScreen?.dataSourceType, - selectedScreen?.restApiConnectionId, - selectedScreen?.restApiEndpoint, - selectedScreen?.restApiJsonPath, - ]); - - // 테이블 선택 핸들러 - 사이드바에서 테이블 선택 시 호출 - const handleTableSelect = useCallback( - async (tableName: string) => { - console.log("📊 테이블 선택:", tableName); - - try { - // 테이블 라벨 조회 - const tableListResponse = await tableManagementApi.getTableList(); - const currentTable = - tableListResponse.success && tableListResponse.data - ? tableListResponse.data.find((t: any) => (t.tableName || t.table_name) === tableName) - : null; - const tableLabel = currentTable?.displayName || currentTable?.table_label || tableName; - - // 테이블 컬럼 정보 조회 - const columnsResponse = await tableTypeApi.getColumns(tableName, true); - - const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => { - const inputType = col.inputType || col.input_type; - const widgetType = inputType || col.widgetType || col.widget_type || col.webType || col.web_type; - - let detailSettings = col.detailSettings || col.detail_settings; - if (typeof detailSettings === "string") { - try { - detailSettings = JSON.parse(detailSettings); - } catch (e) { - detailSettings = {}; - } - } - - return { - tableName: col.tableName || tableName, - columnName: col.columnName || col.column_name, - columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, - dataType: col.dataType || col.data_type || col.dbType, - webType: col.webType || col.web_type, - input_type: inputType, - inputType: inputType, - widgetType, - isNullable: col.isNullable || col.is_nullable, - required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", - columnDefault: col.columnDefault || col.column_default, - characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, - codeCategory: col.codeCategory || col.code_category, - codeValue: col.codeValue || col.code_value, - referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table, - referenceColumn: detailSettings?.referenceColumn || col.referenceColumn || col.reference_column, - displayColumn: detailSettings?.displayColumn || col.displayColumn || col.display_column, - detailSettings, - }; - }); - - const tableInfo: TableInfo = { - tableName, - tableLabel, - columns, - }; - - setTables([tableInfo]); - toast.success(`테이블 "${tableLabel}" 선택됨`); - - // 기존 테이블과 다른 테이블 선택 시, 기존 컴포넌트 중 다른 테이블 컬럼은 제거 - if (tables.length > 0 && tables[0].tableName !== tableName) { - setLayout((prev) => { - const newComponents = prev.components.filter((comp) => { - // 테이블 컬럼 기반 컴포넌트인지 확인 - if (comp.tableName && comp.tableName !== tableName) { - console.log("🗑️ 다른 테이블 컴포넌트 제거:", comp.tableName, comp.columnName); - return false; - } - return true; - }); - - if (newComponents.length < prev.components.length) { - toast.info( - `이전 테이블(${tables[0].tableName})의 컴포넌트가 ${prev.components.length - newComponents.length}개 제거되었습니다.`, - ); - } - - return { - ...prev, - components: newComponents, - }; - }); - } - } catch (error) { - console.error("테이블 정보 로드 실패:", error); - toast.error("테이블 정보를 불러오는데 실패했습니다."); - } - }, - [tables], - ); - - // 화면 레이아웃 로드 - useEffect(() => { - if (selectedScreen?.screenId) { - // 현재 화면 ID를 전역 변수로 설정 (파일 업로드 시 사용) - if (typeof window !== "undefined") { - (window as any).__CURRENT_SCREEN_ID__ = selectedScreen.screenId; - } - - const loadLayout = async () => { - try { - // 🆕 화면에 할당된 메뉴 조회 - const menuInfo = await screenApi.getScreenMenu(selectedScreen.screenId); - if (menuInfo) { - setMenuObjid(menuInfo.menuObjid); - console.log("🔗 화면에 할당된 메뉴:", menuInfo); - } else { - console.warn("⚠️ 화면에 할당된 메뉴가 없습니다"); - } - - // V2 API 사용 여부에 따라 분기 - let response: any; - if (USE_V2_API) { - const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId); - - // 🐛 디버깅: API 응답에서 fieldMapping.id 확인 - const splitPanelInV2 = v2Response?.components?.find((c: any) => c.url?.includes("v2-split-panel-layout")); - const finishedTimelineInV2 = splitPanelInV2?.overrides?.rightPanel?.components?.find( - (c: any) => c.id === "finished_timeline", - ); - console.log("🐛 [API 응답 RAW] finished_timeline:", JSON.stringify(finishedTimelineInV2, null, 2)); - console.log("🐛 [API 응답] finished_timeline fieldMapping:", { - fieldMapping: JSON.stringify(finishedTimelineInV2?.componentConfig?.fieldMapping), - fieldMappingKeys: finishedTimelineInV2?.componentConfig?.fieldMapping - ? Object.keys(finishedTimelineInV2?.componentConfig?.fieldMapping) - : [], - hasId: !!finishedTimelineInV2?.componentConfig?.fieldMapping?.id, - idValue: finishedTimelineInV2?.componentConfig?.fieldMapping?.id, - }); - - response = v2Response ? convertV2ToLegacy(v2Response) : null; - } else { - response = await screenApi.getLayout(selectedScreen.screenId); - } - - if (response) { - // 🔄 마이그레이션 필요 여부 확인 (V2는 스킵) - let layoutToUse = response; - - if (!USE_V2_API && needsMigration(response)) { - const canvasWidth = response.screenResolution?.width || 1920; - layoutToUse = safeMigrateLayout(response, canvasWidth); - } - - // 🔄 webTypeConfig를 autoGeneration으로 변환 - const { convertLayoutComponents } = await import("@/lib/utils/webTypeConfigConverter"); - const convertedComponents = convertLayoutComponents(layoutToUse.components); - - // 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화) - const layoutWithDefaultGrid = { - ...layoutToUse, - components: convertedComponents, // 변환된 컴포넌트 사용 - gridSettings: { - columns: layoutToUse.gridSettings?.columns || 12, // DB 값 우선, 없으면 기본값 12 - gap: layoutToUse.gridSettings?.gap ?? 16, // DB 값 우선, 없으면 기본값 16 - padding: 0, // padding은 항상 0으로 강제 - snapToGrid: layoutToUse.gridSettings?.snapToGrid ?? true, // DB 값 우선 - showGrid: layoutToUse.gridSettings?.showGrid ?? false, // DB 값 우선 - gridColor: layoutToUse.gridSettings?.gridColor || "#d1d5db", - gridOpacity: layoutToUse.gridSettings?.gridOpacity ?? 0.5, - }, - }; - - // 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용 - if (layoutToUse.screenResolution) { - setScreenResolution(layoutToUse.screenResolution); - // console.log("💾 저장된 해상도 불러옴:", layoutToUse.screenResolution); - } else { - // 기본 해상도 (Full HD) - const defaultResolution = - SCREEN_RESOLUTIONS.find((r) => r.name === "Full HD (1920×1080)") || SCREEN_RESOLUTIONS[0]; - setScreenResolution(defaultResolution); - // console.log("🔧 기본 해상도 적용:", defaultResolution); - } - - // 🔍 디버깅: 로드된 버튼 컴포넌트의 action 확인 - const buttonComponents = layoutWithDefaultGrid.components.filter((c: any) => - c.componentType?.startsWith("button"), - ); - console.log( - "🔍 [로드] 버튼 컴포넌트 action 확인:", - buttonComponents.map((c: any) => ({ - id: c.id, - type: c.componentType, - actionType: c.componentConfig?.action?.type, - fullAction: c.componentConfig?.action, - })), - ); - - setLayout(layoutWithDefaultGrid); - setHistory([layoutWithDefaultGrid]); - setHistoryIndex(0); - - // 파일 컴포넌트 데이터 복원 (비동기) - restoreFileComponentsData(layoutWithDefaultGrid.components); - } - } catch (error) { - // console.error("레이아웃 로드 실패:", error); - toast.error("화면 레이아웃을 불러오는데 실패했습니다."); - } - }; - loadLayout(); - } - }, [selectedScreen?.screenId]); - - // 스페이스바 키 이벤트 처리 (Pan 모드) + 전역 마우스 이벤트 - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // 입력 필드에서는 스페이스바 무시 (activeElement로 정확하게 체크) - const activeElement = document.activeElement; - if ( - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement || - activeElement?.getAttribute("contenteditable") === "true" || - activeElement?.getAttribute("role") === "textbox" - ) { - return; - } - - // e.target도 함께 체크 (이중 방어) - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { - return; - } - - if (e.code === "Space") { - e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단 - if (!isPanMode) { - setIsPanMode(true); - // body에 커서 스타일 추가 - document.body.style.cursor = "grab"; - } - } - }; - - const handleKeyUp = (e: KeyboardEvent) => { - // 입력 필드에서는 스페이스바 무시 - const activeElement = document.activeElement; - if ( - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement || - activeElement?.getAttribute("contenteditable") === "true" || - activeElement?.getAttribute("role") === "textbox" - ) { - return; - } - - if (e.code === "Space") { - e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단 - setIsPanMode(false); - setPanState((prev) => ({ ...prev, isPanning: false })); - // body 커서 스타일 복원 - document.body.style.cursor = "default"; - } - }; - - const handleMouseDown = (e: MouseEvent) => { - if (isPanMode) { - e.preventDefault(); - // 외부와 내부 스크롤 컨테이너 모두 저장 - setPanState({ - isPanning: true, - startX: e.pageX, - startY: e.pageY, - outerScrollLeft: canvasContainerRef.current?.scrollLeft || 0, - outerScrollTop: canvasContainerRef.current?.scrollTop || 0, - innerScrollLeft: canvasRef.current?.scrollLeft || 0, - innerScrollTop: canvasRef.current?.scrollTop || 0, - }); - // 드래그 중 커서 변경 - document.body.style.cursor = "grabbing"; - } - }; - - const handleMouseMove = (e: MouseEvent) => { - if (isPanMode && panState.isPanning) { - e.preventDefault(); - const dx = e.pageX - panState.startX; - const dy = e.pageY - panState.startY; - - // 외부 컨테이너 스크롤 - if (canvasContainerRef.current) { - canvasContainerRef.current.scrollLeft = panState.outerScrollLeft - dx; - canvasContainerRef.current.scrollTop = panState.outerScrollTop - dy; - } - - // 내부 캔버스 스크롤 - if (canvasRef.current) { - canvasRef.current.scrollLeft = panState.innerScrollLeft - dx; - canvasRef.current.scrollTop = panState.innerScrollTop - dy; - } - } - }; - - const handleMouseUp = () => { - if (isPanMode) { - setPanState((prev) => ({ ...prev, isPanning: false })); - // 드래그 종료 시 커서 복원 - document.body.style.cursor = "grab"; - } - }; - - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); - window.addEventListener("mousedown", handleMouseDown); - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - window.removeEventListener("mousedown", handleMouseDown); - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - }, [ - isPanMode, - panState.isPanning, - panState.startX, - panState.startY, - panState.outerScrollLeft, - panState.outerScrollTop, - panState.innerScrollLeft, - panState.innerScrollTop, - ]); - - // 마우스 휠로 줌 제어 (RAF throttle 적용으로 깜빡임 방지) - useEffect(() => { - const handleWheel = (e: WheelEvent) => { - // 캔버스 컨테이너 내에서만 동작 - if (canvasContainerRef.current && canvasContainerRef.current.contains(e.target as Node)) { - // Shift 키를 누르지 않은 경우에만 줌 (Shift + 휠은 수평 스크롤용) - if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { - // 기본 스크롤 동작 방지 - e.preventDefault(); - - const delta = e.deltaY; - const zoomFactor = 0.001; // 줌 속도 조절 - - // RAF throttle: 프레임당 한 번만 상태 업데이트 - if (zoomRafRef.current !== null) { - cancelAnimationFrame(zoomRafRef.current); - } - zoomRafRef.current = requestAnimationFrame(() => { - setZoomLevel((prevZoom) => { - const newZoom = prevZoom - delta * zoomFactor; - return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom)); - }); - zoomRafRef.current = null; - }); - } - } - }; - - // passive: false로 설정하여 preventDefault() 가능하게 함 - canvasContainerRef.current?.addEventListener("wheel", handleWheel, { passive: false }); - - const containerRef = canvasContainerRef.current; - return () => { - containerRef?.removeEventListener("wheel", handleWheel); - if (zoomRafRef.current !== null) { - cancelAnimationFrame(zoomRafRef.current); - } - }; - }, [MIN_ZOOM, MAX_ZOOM]); - - // 격자 설정 업데이트 (컴포넌트 자동 조정 제거됨) - const updateGridSettings = useCallback( - (newGridSettings: GridSettings) => { - const newLayout = { ...layout, gridSettings: newGridSettings }; - // 🆕 격자 설정 변경 시 컴포넌트 크기/위치 자동 조정 로직 제거됨 - // 사용자가 명시적으로 "격자 재조정" 버튼을 클릭해야만 조정됨 - setLayout(newLayout); - saveToHistory(newLayout); - }, - [layout, saveToHistory], - ); - - // 해상도 변경 핸들러 (컴포넌트 크기/위치 유지) - const handleResolutionChange = useCallback( - (newResolution: ScreenResolution) => { - const oldWidth = screenResolution.width; - const oldHeight = screenResolution.height; - const newWidth = newResolution.width; - const newHeight = newResolution.height; - - console.log("📱 해상도 변경:", { - from: `${oldWidth}x${oldHeight}`, - to: `${newWidth}x${newHeight}`, - componentsCount: layout.components.length, - }); - - setScreenResolution(newResolution); - - // 해상도만 변경하고 컴포넌트 크기/위치는 그대로 유지 - const updatedLayout = { - ...layout, - screenResolution: newResolution, - }; - - setLayout(updatedLayout); - saveToHistory(updatedLayout); - - toast.success("해상도가 변경되었습니다.", { - description: `${oldWidth}×${oldHeight} → ${newWidth}×${newHeight}`, - }); - - console.log("✅ 해상도 변경 완료 (컴포넌트 크기/위치 유지)"); - }, - [layout, saveToHistory, screenResolution], - ); - - // 강제 격자 재조정 핸들러 (해상도 변경 후 수동 격자 맞춤용) - const handleForceGridUpdate = useCallback(() => { - if (!layout.gridSettings?.snapToGrid || layout.components.length === 0) { - // console.log("격자 재조정 생략: 스냅 비활성화 또는 컴포넌트 없음"); - return; - } - - console.log("🔄 격자 강제 재조정 시작:", { - componentsCount: layout.components.length, - resolution: `${screenResolution.width}x${screenResolution.height}`, - gridSettings: layout.gridSettings, - }); - - // 현재 해상도로 격자 정보 계산 - const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }); - - const gridUtilSettings = { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: true, - }; - - const adjustedComponents = layout.components.map((comp) => { - const snappedPosition = snapToGrid(comp.position, currentGridInfo, gridUtilSettings); - const snappedSize = snapSizeToGrid(comp.size, currentGridInfo, gridUtilSettings); - - // gridColumns가 없거나 범위를 벗어나면 자동 조정 - let adjustedGridColumns = comp.gridColumns; - if (!adjustedGridColumns || adjustedGridColumns < 1 || adjustedGridColumns > layout.gridSettings!.columns) { - adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, currentGridInfo, gridUtilSettings); - } - - return { - ...comp, - position: snappedPosition, - size: snappedSize, - gridColumns: adjustedGridColumns, - }; - }); - - const newLayout = { ...layout, components: adjustedComponents }; - setLayout(newLayout); - saveToHistory(newLayout); - - console.log("✅ 격자 강제 재조정 완료:", { - adjustedComponents: adjustedComponents.length, - gridInfo: { - columnWidth: currentGridInfo.columnWidth.toFixed(2), - totalWidth: currentGridInfo.totalWidth, - columns: layout.gridSettings.columns, - }, - }); - - toast.success(`${adjustedComponents.length}개 컴포넌트가 격자에 맞게 재정렬되었습니다.`); - }, [layout, screenResolution, saveToHistory]); - - // === 정렬/배분/동일크기/라벨토글/Nudge 핸들러 === - - // 컴포넌트 정렬 - const handleGroupAlign = useCallback( - (mode: AlignMode) => { - if (groupState.selectedComponents.length < 2) { - toast.warning("2개 이상의 컴포넌트를 선택해주세요."); - return; - } - saveToHistory(layout); - const newComponents = alignComponents(layout.components, groupState.selectedComponents, mode); - setLayout((prev) => ({ ...prev, components: newComponents })); - - const modeNames: Record = { - left: "좌측", right: "우측", centerX: "가로 중앙", - top: "상단", bottom: "하단", centerY: "세로 중앙", - }; - toast.success(`${modeNames[mode]} 정렬 완료`); - }, - [groupState.selectedComponents, layout, saveToHistory] - ); - - // 컴포넌트 균등 배분 - const handleGroupDistribute = useCallback( - (direction: DistributeDirection) => { - if (groupState.selectedComponents.length < 3) { - toast.warning("3개 이상의 컴포넌트를 선택해주세요."); - return; - } - saveToHistory(layout); - const newComponents = distributeComponents(layout.components, groupState.selectedComponents, direction); - setLayout((prev) => ({ ...prev, components: newComponents })); - toast.success(`${direction === "horizontal" ? "가로" : "세로"} 균등 배분 완료`); - }, - [groupState.selectedComponents, layout, saveToHistory] - ); - - // 동일 크기 맞추기 - const handleMatchSize = useCallback( - (mode: MatchSizeMode) => { - if (groupState.selectedComponents.length < 2) { - toast.warning("2개 이상의 컴포넌트를 선택해주세요."); - return; - } - saveToHistory(layout); - const newComponents = matchComponentSize( - layout.components, - groupState.selectedComponents, - mode, - selectedComponent?.id - ); - setLayout((prev) => ({ ...prev, components: newComponents })); - - const modeNames: Record = { - width: "너비", height: "높이", both: "크기", - }; - toast.success(`${modeNames[mode]} 맞추기 완료`); - }, - [groupState.selectedComponents, layout, selectedComponent?.id, saveToHistory] - ); - - // 라벨 일괄 토글 (선택된 컴포넌트가 있으면 선택된 것만, 없으면 전체) - const handleToggleAllLabels = useCallback(() => { - saveToHistory(layout); - - const selectedIds = groupState.selectedComponents; - const isPartial = selectedIds.length > 0; - - // 토글 대상 컴포넌트 필터 - const targetComponents = layout.components.filter((c) => { - if (!c.label || ["group", "datatable"].includes(c.type)) return false; - if (isPartial) return selectedIds.includes(c.id); - return true; - }); - - const hadHidden = targetComponents.some( - (c) => (c.style as any)?.labelDisplay === false - ); - - const newComponents = toggleAllLabels(layout.components, selectedIds); - setLayout((prev) => ({ ...prev, components: newComponents })); - - // 강제 리렌더링 트리거 - setForceRenderTrigger((prev) => prev + 1); - - const scope = isPartial ? `선택된 ${targetComponents.length}개` : "모든"; - toast.success(hadHidden ? `${scope} 라벨 표시` : `${scope} 라벨 숨기기`); - }, [layout, saveToHistory, groupState.selectedComponents]); - - // Nudge (화살표 키 이동) - const handleNudge = useCallback( - (direction: "up" | "down" | "left" | "right", distance: number) => { - const targetIds = - groupState.selectedComponents.length > 0 - ? groupState.selectedComponents - : selectedComponent - ? [selectedComponent.id] - : []; - - if (targetIds.length === 0) return; - - const newComponents = nudgeComponents(layout.components, targetIds, direction, distance); - setLayout((prev) => ({ ...prev, components: newComponents })); - - // 선택된 컴포넌트 업데이트 - if (selectedComponent && targetIds.includes(selectedComponent.id)) { - const updated = newComponents.find((c) => c.id === selectedComponent.id); - if (updated) setSelectedComponent(updated); - } - }, - [groupState.selectedComponents, selectedComponent, layout.components] - ); - - // 저장 - const handleSave = useCallback(async () => { - if (!selectedScreen?.screenId) { - console.error("❌ 저장 실패: selectedScreen 또는 screenId가 없습니다.", selectedScreen); - toast.error("화면 정보가 없습니다."); - return; - } - - try { - setIsSaving(true); - - // 분할 패널 컴포넌트의 rightPanel.tableName 자동 설정 - const updatedComponents = layout.components.map((comp) => { - if (comp.type === "component" && comp.componentType === "split-panel-layout") { - const config = comp.componentConfig || {}; - const rightPanel = config.rightPanel || {}; - const leftPanel = config.leftPanel || {}; - const relationshipType = rightPanel.relation?.type || "detail"; - - // 관계 타입이 detail이면 rightPanel.tableName을 leftPanel.tableName과 동일하게 설정 - if (relationshipType === "detail" && leftPanel.tableName) { - console.log("🔧 분할 패널 자동 수정:", { - componentId: comp.id, - leftTableName: leftPanel.tableName, - rightTableName: leftPanel.tableName, - }); - - return { - ...comp, - componentConfig: { - ...config, - rightPanel: { - ...rightPanel, - tableName: leftPanel.tableName, - }, - }, - }; - } - } - return comp; - }); - - // 해상도 정보를 포함한 레이아웃 데이터 생성 - // 현재 선택된 테이블을 화면의 기본 테이블로 저장 - 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, // 화면의 기본 테이블 - }; - // 🔍 버튼 컴포넌트들의 action.type 확인 - const buttonComponents = layoutWithResolution.components.filter( - (c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary", - ); - // 💾 저장 로그 (디버그 완료 - 간소화) - // console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length }); - // 분할 패널 디버그 로그 (주석 처리) - - // V2 API 사용 여부에 따라 분기 - if (USE_V2_API) { - // 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리) - const v2Layout = convertLegacyToV2(layoutWithResolution); - await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); - // console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트"); - } else { - await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); - } - - // console.log("✅ 저장 성공!"); - toast.success("화면이 저장되었습니다."); - - // 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영) - if (onScreenUpdate && currentMainTableName) { - onScreenUpdate({ tableName: currentMainTableName }); - } - - // 저장 성공 후 메뉴 할당 모달 열기 - setShowMenuAssignmentModal(true); - } catch (error) { - console.error("❌ 저장 실패:", error); - toast.error("저장 중 오류가 발생했습니다."); - } finally { - setIsSaving(false); - } - }, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]); - - // 다국어 자동 생성 핸들러 - const handleGenerateMultilang = useCallback(async () => { - if (!selectedScreen?.screenId) { - toast.error("화면 정보가 없습니다."); - return; - } - - setIsGeneratingMultilang(true); - - try { - // 공통 유틸 사용하여 라벨 추출 - const { extractMultilangLabels, extractTableNames, applyMultilangMappings } = await import( - "@/lib/utils/multilangLabelExtractor" - ); - const { apiClient } = await import("@/lib/api/client"); - - // 테이블별 컬럼 라벨 로드 - const tableNames = extractTableNames(layout.components); - const columnLabelMap: Record> = {}; - - for (const tableName of tableNames) { - try { - const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); - if (response.data?.success && response.data?.data) { - const columns = response.data.data.columns || response.data.data; - if (Array.isArray(columns)) { - columnLabelMap[tableName] = {}; - columns.forEach((col: any) => { - const colName = col.columnName || col.column_name || col.name; - const colLabel = col.displayName || col.columnLabel || col.column_label || colName; - if (colName) { - columnLabelMap[tableName][colName] = colLabel; - } - }); - } - } - } catch (error) { - console.error(`컬럼 라벨 조회 실패 (${tableName}):`, error); - } - } - - // 라벨 추출 (다국어 설정과 동일한 로직) - const extractedLabels = extractMultilangLabels(layout.components, columnLabelMap); - const labels = extractedLabels.map((l) => ({ - componentId: l.componentId, - label: l.label, - type: l.type, - })); - - if (labels.length === 0) { - toast.info("다국어로 변환할 라벨이 없습니다."); - setIsGeneratingMultilang(false); - return; - } - - // API 호출 - const { generateScreenLabelKeys } = await import("@/lib/api/multilang"); - const response = await generateScreenLabelKeys({ - screenId: selectedScreen.screenId, - menuObjId: menuObjid?.toString(), - labels, - }); - - if (response.success && response.data) { - // 자동 매핑 적용 - const updatedComponents = applyMultilangMappings(layout.components, response.data); - - // 레이아웃 업데이트 - const updatedLayout = { - ...layout, - components: updatedComponents, - screenResolution: screenResolution, - }; - - setLayout(updatedLayout); - - // 자동 저장 (매핑 정보가 손실되지 않도록) - try { - if (USE_V2_API) { - const v2Layout = convertLegacyToV2(updatedLayout); - await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); - } else { - await screenApi.saveLayout(selectedScreen.screenId, updatedLayout); - } - toast.success(`${response.data.length}개의 다국어 키가 생성되고 자동 저장되었습니다.`); - } catch (saveError) { - console.error("다국어 매핑 저장 실패:", saveError); - toast.warning(`${response.data.length}개의 다국어 키가 생성되었습니다. 저장 버튼을 눌러 매핑을 저장하세요.`); - } - } else { - toast.error(response.error?.details || "다국어 키 생성에 실패했습니다."); - } - } catch (error) { - console.error("다국어 생성 실패:", error); - toast.error("다국어 키 생성 중 오류가 발생했습니다."); - } finally { - setIsGeneratingMultilang(false); - } - }, [selectedScreen, layout, screenResolution, menuObjid]); - - // 템플릿 드래그 처리 - const handleTemplateDrop = useCallback( - (e: React.DragEvent, template: TemplateComponent) => { - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - const dropX = e.clientX - rect.left; - const dropY = e.clientY - rect.top; - - // 현재 해상도에 맞는 격자 정보 계산 - const currentGridInfo = layout.gridSettings - ? calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }) - : null; - - // 격자 스냅 적용 - const snappedPosition = - layout.gridSettings?.snapToGrid && currentGridInfo - ? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings) - : { x: dropX, y: dropY, z: 1 }; - - console.log("🎨 템플릿 드롭:", { - templateName: template.name, - componentsCount: template.components.length, - dropPosition: { x: dropX, y: dropY }, - snappedPosition, - }); - - // 템플릿의 모든 컴포넌트들을 생성 - // 먼저 ID 매핑을 생성 (parentId 참조를 위해) - const idMapping: Record = {}; - template.components.forEach((templateComp, index) => { - const newId = generateComponentId(); - if (index === 0) { - // 첫 번째 컴포넌트(컨테이너)는 "form-container"로 매핑 - idMapping["form-container"] = newId; - } - idMapping[templateComp.parentId || `temp_${index}`] = newId; - }); - - const newComponents: ComponentData[] = template.components.map((templateComp, index) => { - const componentId = index === 0 ? idMapping["form-container"] : generateComponentId(); - - // 템플릿 컴포넌트의 상대 위치를 드롭 위치 기준으로 조정 - const absoluteX = snappedPosition.x + templateComp.position.x; - const absoluteY = snappedPosition.y + templateComp.position.y; - - // 격자 스냅 적용 - const finalPosition = - layout.gridSettings?.snapToGrid && currentGridInfo - ? snapPositionTo10px( - { x: absoluteX, y: absoluteY, z: 1 }, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ) - : { x: absoluteX, y: absoluteY, z: 1 }; - - if (templateComp.type === "container") { - // 그리드 컬럼 기반 크기 계산 - const gridColumns = - typeof templateComp.size.width === "number" && templateComp.size.width <= 12 ? templateComp.size.width : 4; // 기본 4컬럼 - - const calculatedSize = - currentGridInfo && layout.gridSettings?.snapToGrid - ? (() => { - const newWidth = calculateWidthFromColumns( - gridColumns, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ); - return { - width: newWidth, - height: templateComp.size.height, - }; - })() - : { width: 400, height: templateComp.size.height }; // 폴백 크기 - - return { - id: componentId, - type: "container", - label: templateComp.label, - tableName: selectedScreen?.tableName || "", - title: templateComp.title || templateComp.label, - position: finalPosition, - size: calculatedSize, - gridColumns, - style: { - labelDisplay: true, - labelFontSize: "14px", - labelColor: "#212121", - labelFontWeight: "600", - labelMarginBottom: "8px", - ...templateComp.style, - }, - }; - } else if (templateComp.type === "datatable") { - // 데이터 테이블 컴포넌트 생성 - const gridColumns = 6; // 기본값: 6컬럼 (50% 너비) - - // gridColumns에 맞는 크기 계산 - const calculatedSize = - currentGridInfo && layout.gridSettings?.snapToGrid - ? (() => { - const newWidth = calculateWidthFromColumns( - gridColumns, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ); - return { - width: newWidth, - height: templateComp.size.height, // 높이는 템플릿 값 유지 - }; - })() - : templateComp.size; - - console.log("📊 데이터 테이블 생성 시 크기 계산:", { - gridColumns, - templateSize: templateComp.size, - calculatedSize, - hasGridInfo: !!currentGridInfo, - hasGridSettings: !!layout.gridSettings, - }); - - return { - id: componentId, - type: "datatable", - label: templateComp.label, - tableName: selectedScreen?.tableName || "", - position: finalPosition, - size: calculatedSize, - title: templateComp.label, - columns: [], // 초기에는 빈 배열, 나중에 설정 - filters: [], // 초기에는 빈 배열, 나중에 설정 - pagination: { - enabled: true, - pageSize: 10, - pageSizeOptions: [5, 10, 20, 50], - showPageSizeSelector: true, - showPageInfo: true, - showFirstLast: true, - }, - showSearchButton: true, - searchButtonText: "검색", - enableExport: true, - enableRefresh: true, - enableAdd: true, - enableEdit: true, - enableDelete: true, - addButtonText: "추가", - editButtonText: "수정", - deleteButtonText: "삭제", - addModalConfig: { - title: "새 데이터 추가", - description: `${templateComp.label}에 새로운 데이터를 추가합니다.`, - width: "lg", - layout: "two-column", - gridColumns: 2, - fieldOrder: [], // 초기에는 빈 배열, 나중에 컬럼 추가 시 설정 - requiredFields: [], - hiddenFields: [], - advancedFieldConfigs: {}, // 초기에는 빈 객체, 나중에 컬럼별 설정 - submitButtonText: "추가", - cancelButtonText: "취소", - }, - gridColumns, - style: { - labelDisplay: true, - labelFontSize: "14px", - labelColor: "#212121", - labelFontWeight: "600", - labelMarginBottom: "8px", - ...templateComp.style, - }, - } as ComponentData; - } else if (templateComp.type === "file") { - // 파일 첨부 컴포넌트 생성 - const gridColumns = 6; // 기본값: 6컬럼 - - const calculatedSize = - currentGridInfo && layout.gridSettings?.snapToGrid - ? (() => { - const newWidth = calculateWidthFromColumns( - gridColumns, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ); - return { - width: newWidth, - height: templateComp.size.height, - }; - })() - : templateComp.size; - - return { - id: componentId, - type: "file", - label: templateComp.label, - position: finalPosition, - size: calculatedSize, - gridColumns, - fileConfig: { - accept: ["image/*", ".pdf", ".doc", ".docx", ".xls", ".xlsx"], - multiple: true, - maxSize: 10, // 10MB - maxFiles: 5, - docType: "DOCUMENT", - docTypeName: "일반 문서", - targetObjid: selectedScreen?.screenId || "", - showPreview: true, - showProgress: true, - dragDropText: "파일을 드래그하여 업로드하세요", - uploadButtonText: "파일 선택", - autoUpload: true, - chunkedUpload: false, - }, - uploadedFiles: [], - style: { - labelDisplay: true, - labelFontSize: "14px", - labelColor: "#212121", - labelFontWeight: "600", - labelMarginBottom: "8px", - ...templateComp.style, - }, - } as ComponentData; - } else if (templateComp.type === "area") { - // 영역 컴포넌트 생성 - const gridColumns = 6; // 기본값: 6컬럼 (50% 너비) - - const calculatedSize = - currentGridInfo && layout.gridSettings?.snapToGrid - ? (() => { - const newWidth = calculateWidthFromColumns( - gridColumns, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ); - return { - width: newWidth, - height: templateComp.size.height, - }; - })() - : templateComp.size; - - return { - id: componentId, - type: "area", - label: templateComp.label, - position: finalPosition, - size: calculatedSize, - gridColumns, - layoutType: (templateComp as any).layoutType || "box", - title: (templateComp as any).title || templateComp.label, - description: (templateComp as any).description, - layoutConfig: (templateComp as any).layoutConfig || {}, - areaStyle: { - backgroundColor: "#ffffff", - borderWidth: 1, - borderStyle: "solid", - borderColor: "#e5e7eb", - borderRadius: 8, - padding: 0, - margin: 0, - shadow: "sm", - ...(templateComp as any).areaStyle, - }, - children: [], - style: { - labelDisplay: true, - labelFontSize: "14px", - labelColor: "#212121", - labelFontWeight: "600", - labelMarginBottom: "8px", - ...templateComp.style, - }, - } as ComponentData; - } else { - // 위젯 컴포넌트 - const widgetType = templateComp.widgetType || "text"; - - // 웹타입별 기본 그리드 컬럼 수 계산 - const getDefaultGridColumnsForTemplate = (wType: string): number => { - const widthMap: Record = { - text: 4, - email: 4, - tel: 3, - url: 4, - textarea: 6, - number: 2, - decimal: 2, - date: 3, - datetime: 3, - time: 2, - select: 3, - radio: 3, - checkbox: 2, - boolean: 2, - code: 3, - entity: 4, - file: 4, - image: 3, - button: 2, - label: 2, - }; - return widthMap[wType] || 3; - }; - - // 웹타입별 기본 설정 생성 - const getDefaultWebTypeConfig = (wType: string) => { - switch (wType) { - case "date": - return { - format: "YYYY-MM-DD" as const, - showTime: false, - placeholder: templateComp.placeholder || "날짜를 선택하세요", - }; - case "select": - case "dropdown": - return { - options: [ - { label: "옵션 1", value: "option1" }, - { label: "옵션 2", value: "option2" }, - { label: "옵션 3", value: "option3" }, - ], - multiple: false, - searchable: false, - placeholder: templateComp.placeholder || "옵션을 선택하세요", - }; - case "text": - return { - format: "none" as const, - placeholder: templateComp.placeholder || "텍스트를 입력하세요", - multiline: false, - }; - case "email": - return { - format: "email" as const, - placeholder: templateComp.placeholder || "이메일을 입력하세요", - multiline: false, - }; - case "tel": - return { - format: "phone" as const, - placeholder: templateComp.placeholder || "전화번호를 입력하세요", - multiline: false, - }; - case "textarea": - return { - rows: 3, - placeholder: templateComp.placeholder || "텍스트를 입력하세요", - resizable: true, - wordWrap: true, - }; - default: - return { - placeholder: templateComp.placeholder || "입력하세요", - }; - } - }; - - // 위젯 크기도 격자에 맞게 조정 - const widgetSize = - currentGridInfo && layout.gridSettings?.snapToGrid - ? { - width: calculateWidthFromColumns(1, currentGridInfo, layout.gridSettings as GridUtilSettings), - height: templateComp.size.height, - } - : templateComp.size; - - return { - id: componentId, - type: "widget", - widgetType: widgetType as any, - label: templateComp.label, - placeholder: templateComp.placeholder, - columnName: `field_${index + 1}`, - parentId: templateComp.parentId ? idMapping[templateComp.parentId] : undefined, - position: finalPosition, - size: widgetSize, - required: templateComp.required || false, - readonly: templateComp.readonly || false, - gridColumns: getDefaultGridColumnsForTemplate(widgetType), - webTypeConfig: getDefaultWebTypeConfig(widgetType), - style: { - labelDisplay: true, - labelFontSize: "14px", - labelColor: "#212121", - labelFontWeight: "600", - labelMarginBottom: "8px", - ...templateComp.style, - }, - } as ComponentData; - } - }); - - // 🆕 현재 활성 레이어에 컴포넌트 추가 - const componentsWithLayerId = newComponents.map((comp) => ({ - ...comp, - layerId: activeLayerId || "default-layer", - })); - - // 레이아웃에 새 컴포넌트들 추가 - const newLayout = { - ...layout, - components: [...layout.components, ...componentsWithLayerId], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - // 첫 번째 컴포넌트 선택 - if (componentsWithLayerId.length > 0) { - setSelectedComponent(componentsWithLayerId[0]); - } - - toast.success(`${template.name} 템플릿이 추가되었습니다.`); - }, - [layout, selectedScreen, saveToHistory, activeLayerId], - ); - - // 레이아웃 드래그 처리 - const handleLayoutDrop = useCallback( - (e: React.DragEvent, layoutData: any) => { - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - // 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산 - const dropX = (e.clientX - rect.left) / zoomLevel; - const dropY = (e.clientY - rect.top) / zoomLevel; - - // 현재 해상도에 맞는 격자 정보 계산 - const currentGridInfo = layout.gridSettings - ? calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }) - : null; - - // 격자 스냅 적용 - const snappedPosition = - layout.gridSettings?.snapToGrid && currentGridInfo - ? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings) - : { x: dropX, y: dropY, z: 1 }; - - console.log("🏗️ 레이아웃 드롭 (줌 보정):", { - zoomLevel, - layoutType: layoutData.layoutType, - zonesCount: layoutData.zones.length, - mouseRaw: { x: e.clientX - rect.left, y: e.clientY - rect.top }, - dropPosition: { x: dropX, y: dropY }, - snappedPosition, - }); - - // 레이아웃 컴포넌트 생성 - const newLayoutComponent: ComponentData = { - id: layoutData.id, - type: "layout", - layoutType: layoutData.layoutType, - layoutConfig: layoutData.layoutConfig, - zones: layoutData.zones.map((zone: any) => ({ - ...zone, - id: `${layoutData.id}_${zone.id}`, // 레이아웃 ID를 접두사로 추가 - })), - children: [], - position: snappedPosition, - size: layoutData.size, - label: layoutData.label, - allowedComponentTypes: layoutData.allowedComponentTypes, - dropZoneConfig: layoutData.dropZoneConfig, - layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 - } as ComponentData; - - // 레이아웃에 새 컴포넌트 추가 - const newLayout = { - ...layout, - components: [...layout.components, newLayoutComponent], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - // 레이아웃 컴포넌트 선택 - setSelectedComponent(newLayoutComponent); - - toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`); - }, - [layout, screenResolution, saveToHistory, zoomLevel, activeLayerId], - ); - - // handleZoneComponentDrop은 handleComponentDrop으로 대체됨 - - // 존 클릭 핸들러 - const handleZoneClick = useCallback((zoneId: string) => { - // console.log("🎯 존 클릭:", zoneId); - // 필요시 존 선택 로직 추가 - }, []); - - // 웹타입별 기본 설정 생성 함수를 상위로 이동 - const getDefaultWebTypeConfig = useCallback((webType: string) => { - switch (webType) { - case "button": - return { - actionType: "custom", - variant: "default", - confirmationMessage: "", - popupTitle: "", - popupContent: "", - navigateUrl: "", - }; - case "date": - return { - format: "YYYY-MM-DD", - showTime: false, - placeholder: "날짜를 선택하세요", - }; - case "number": - return { - format: "integer", - placeholder: "숫자를 입력하세요", - }; - case "select": - return { - options: [ - { label: "옵션 1", value: "option1" }, - { label: "옵션 2", value: "option2" }, - { label: "옵션 3", value: "option3" }, - ], - multiple: false, - searchable: false, - placeholder: "옵션을 선택하세요", - }; - case "file": - return { - accept: ["*/*"], - maxSize: 10485760, // 10MB - multiple: false, - showPreview: true, - autoUpload: false, - }; - default: - return {}; - } - }, []); - - // 컴포넌트 드래그 처리 (캔버스 레벨 드롭) - const handleComponentDrop = useCallback( - (e: React.DragEvent, component?: any, zoneId?: string, layoutId?: string) => { - // 존별 드롭인 경우 dragData에서 컴포넌트 정보 추출 - if (!component) { - const dragData = e.dataTransfer.getData("application/json"); - if (!dragData) return; - - try { - const parsedData = JSON.parse(dragData); - if (parsedData.type === "component") { - component = parsedData.component; - } else { - return; - } - } catch (error) { - // console.error("드래그 데이터 파싱 오류:", error); - return; - } - } - - // 🎯 리피터 컨테이너 내부 드롭 처리 - const dropTarget = e.target as HTMLElement; - const repeatContainer = dropTarget.closest('[data-repeat-container="true"]'); - if (repeatContainer) { - const containerId = repeatContainer.getAttribute("data-component-id"); - if (containerId) { - // 해당 리피터 컨테이너 찾기 - const targetComponent = layout.components.find((c) => c.id === containerId); - const compType = (targetComponent as any)?.componentType; - // v2-repeat-container 또는 repeat-container 모두 지원 - if (targetComponent && (compType === "repeat-container" || compType === "v2-repeat-container")) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const currentChildren = currentConfig.children || []; - - // 새 자식 컴포넌트 생성 - const newChild = { - id: `slot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: component.id || component.componentType || "text-display", - label: component.name || component.label || "새 컴포넌트", - fieldName: "", - position: { x: 0, y: currentChildren.length * 40 }, - size: component.defaultSize || { width: 200, height: 32 }, - componentConfig: component.defaultConfig || {}, - }; - - // 컴포넌트 업데이트 - const updatedComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - children: [...currentChildren, newChild], - }, - }; - - const newLayout = { - ...layout, - components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)), - }; - - setLayout(newLayout); - saveToHistory(newLayout); - return; // 리피터 컨테이너 처리 완료 - } - } - } - - // 🎯 탭 컨테이너 내부 드롭 처리 (중첩 구조 지원) - const tabsContainer = dropTarget.closest('[data-tabs-container="true"]'); - if (tabsContainer) { - const containerId = tabsContainer.getAttribute("data-component-id"); - const activeTabId = tabsContainer.getAttribute("data-active-tab-id"); - if (containerId && activeTabId) { - // 1. 먼저 최상위 레이아웃에서 탭 컴포넌트 찾기 - let targetComponent = layout.components.find((c) => c.id === containerId); - let parentSplitPanelId: string | null = null; - let parentPanelSide: "left" | "right" | null = null; - - // 2. 최상위에 없으면 분할 패널 안에서 중첩된 탭 찾기 - if (!targetComponent) { - for (const comp of layout.components) { - const compType = (comp as any)?.componentType; - if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") { - const config = (comp as any).componentConfig || {}; - - // 좌측 패널에서 찾기 - const leftComponents = config.leftPanel?.components || []; - const foundInLeft = leftComponents.find((c: any) => c.id === containerId); - if (foundInLeft) { - targetComponent = foundInLeft; - parentSplitPanelId = comp.id; - parentPanelSide = "left"; - break; - } - - // 우측 패널에서 찾기 - const rightComponents = config.rightPanel?.components || []; - const foundInRight = rightComponents.find((c: any) => c.id === containerId); - if (foundInRight) { - targetComponent = foundInRight; - parentSplitPanelId = comp.id; - parentPanelSide = "right"; - break; - } - } - } - } - - const compType = (targetComponent as any)?.componentType; - if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget")) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const tabs = currentConfig.tabs || []; - - // 활성 탭의 드롭 위치 계산 - const tabContentRect = tabsContainer.getBoundingClientRect(); - const dropX = (e.clientX - tabContentRect.left) / zoomLevel; - const dropY = (e.clientY - tabContentRect.top) / zoomLevel; - - // 새 컴포넌트 생성 - const componentType = component.id || component.componentType || "v2-text-display"; - - console.log("🎯 탭에 컴포넌트 드롭:", { - componentId: component.id, - componentType: componentType, - componentName: component.name, - isNested: !!parentSplitPanelId, - parentSplitPanelId, - parentPanelSide, - // 🆕 위치 디버깅 - clientX: e.clientX, - clientY: e.clientY, - tabContentRect: { left: tabContentRect.left, top: tabContentRect.top }, - zoomLevel, - calculatedPosition: { x: dropX, y: dropY }, - }); - - const newTabComponent = { - id: `tab_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: componentType, - label: component.name || component.label || "새 컴포넌트", - position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, - size: component.defaultSize || { width: 200, height: 100 }, - componentConfig: component.defaultConfig || {}, - }; - - // 해당 탭에 컴포넌트 추가 - const updatedTabs = tabs.map((tab: any) => { - if (tab.id === activeTabId) { - return { - ...tab, - components: [...(tab.components || []), newTabComponent], - }; - } - return tab; - }); - - const updatedTabsComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - tabs: updatedTabs, - }, - }; - - let newLayout; - - if (parentSplitPanelId && parentPanelSide) { - // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 - newLayout = { - ...layout, - components: layout.components.map((c) => { - if (c.id === parentSplitPanelId) { - const splitConfig = (c as any).componentConfig || {}; - const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = splitConfig[panelKey] || {}; - const panelComponents = panelConfig.components || []; - - return { - ...c, - componentConfig: { - ...splitConfig, - [panelKey]: { - ...panelConfig, - components: panelComponents.map((pc: any) => - pc.id === containerId ? updatedTabsComponent : pc, - ), - }, - }, - }; - } - return c; - }), - }; - toast.success("컴포넌트가 중첩된 탭에 추가되었습니다"); - } else { - // 일반 구조: 최상위 탭 업데이트 - newLayout = { - ...layout, - components: layout.components.map((c) => (c.id === containerId ? updatedTabsComponent : c)), - }; - toast.success("컴포넌트가 탭에 추가되었습니다"); - } - - setLayout(newLayout); - saveToHistory(newLayout); - return; // 탭 컨테이너 처리 완료 - } - } - } - - // 🎯 분할 패널 커스텀 모드 컨테이너 내부 드롭 처리 - const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]'); - if (splitPanelContainer) { - const containerId = splitPanelContainer.getAttribute("data-component-id"); - const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right" - if (containerId && panelSide) { - const targetComponent = layout.components.find((c) => c.id === containerId); - const compType = (targetComponent as any)?.componentType; - if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = currentConfig[panelKey] || {}; - const currentComponents = panelConfig.components || []; - - // 드롭 위치 계산 - const panelRect = splitPanelContainer.getBoundingClientRect(); - const dropX = (e.clientX - panelRect.left) / zoomLevel; - const dropY = (e.clientY - panelRect.top) / zoomLevel; - - // 새 컴포넌트 생성 - const componentType = component.id || component.componentType || "v2-text-display"; - - console.log("🎯 분할 패널에 컴포넌트 드롭:", { - componentId: component.id, - componentType: componentType, - panelSide: panelSide, - dropPosition: { x: dropX, y: dropY }, - }); - - const newPanelComponent = { - id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: componentType, - label: component.name || component.label || "새 컴포넌트", - position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, - size: component.defaultSize || { width: 200, height: 100 }, - componentConfig: component.defaultConfig || {}, - }; - - const updatedPanelConfig = { - ...panelConfig, - components: [...currentComponents, newPanelComponent], - }; - - const updatedComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - [panelKey]: updatedPanelConfig, - }, - }; - - const newLayout = { - ...layout, - components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)), - }; - - setLayout(newLayout); - saveToHistory(newLayout); - toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`); - return; // 분할 패널 처리 완료 - } - } - } - - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - // 컴포넌트 크기 정보 - const componentWidth = component.defaultSize?.width || 120; - const componentHeight = component.defaultSize?.height || 36; - - // 🔥 중요: 줌 레벨과 transform-origin을 고려한 마우스 위치 계산 - // 1. 캔버스가 scale() 변환되어 있음 (transform-origin: top center) - // 2. 캔버스가 justify-center로 중앙 정렬되어 있음 - - // 실제 캔버스 논리적 크기 - const canvasLogicalWidth = screenResolution.width; - - // 화면상 캔버스 실제 크기 (스케일 적용 후) - const canvasVisualWidth = canvasLogicalWidth * zoomLevel; - - // 중앙 정렬로 인한 왼쪽 오프셋 계산 - // rect.left는 이미 중앙 정렬된 위치를 반영하고 있음 - - // 마우스의 캔버스 내 상대 위치 (스케일 보정) - const mouseXInCanvas = (e.clientX - rect.left) / zoomLevel; - const mouseYInCanvas = (e.clientY - rect.top) / zoomLevel; - - // 방법 1: 마우스 포인터를 컴포넌트 중심으로 - const dropX_centered = mouseXInCanvas - componentWidth / 2; - const dropY_centered = mouseYInCanvas - componentHeight / 2; - - // 방법 2: 마우스 포인터를 컴포넌트 좌상단으로 - const dropX_topleft = mouseXInCanvas; - const dropY_topleft = mouseYInCanvas; - - // 사용자가 원하는 방식으로 변경: 마우스 포인터가 좌상단에 오도록 - const dropX = dropX_topleft; - const dropY = dropY_topleft; - - console.log("🎯 위치 계산 디버깅 (줌 레벨 + 중앙정렬 반영):", { - "1. 줌 레벨": zoomLevel, - "2. 마우스 위치 (화면)": { clientX: e.clientX, clientY: e.clientY }, - "3. 캔버스 위치 (rect)": { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, - "4. 캔버스 논리적 크기": { width: canvasLogicalWidth, height: screenResolution.height }, - "5. 캔버스 시각적 크기": { width: canvasVisualWidth, height: screenResolution.height * zoomLevel }, - "6. 마우스 캔버스 내 상대위치 (줌 전)": { x: e.clientX - rect.left, y: e.clientY - rect.top }, - "7. 마우스 캔버스 내 상대위치 (줌 보정)": { x: mouseXInCanvas, y: mouseYInCanvas }, - "8. 컴포넌트 크기": { width: componentWidth, height: componentHeight }, - "9a. 중심 방식": { x: dropX_centered, y: dropY_centered }, - "9b. 좌상단 방식": { x: dropX_topleft, y: dropY_topleft }, - "10. 최종 선택": { dropX, dropY }, - }); - - // 현재 해상도에 맞는 격자 정보 계산 - const currentGridInfo = layout.gridSettings - ? calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }) - : null; - - // 캔버스 경계 내로 위치 제한 (조건부 레이어 편집 시 displayRegion 크기 기준) - const currentLayerId = activeLayerIdRef.current || 1; - const activeLayerRegion = currentLayerId > 1 ? layerRegions[currentLayerId] : null; - const canvasBoundW = activeLayerRegion ? activeLayerRegion.width : screenResolution.width; - const canvasBoundH = activeLayerRegion ? activeLayerRegion.height : screenResolution.height; - const boundedX = Math.max(0, Math.min(dropX, canvasBoundW - componentWidth)); - const boundedY = Math.max(0, Math.min(dropY, canvasBoundH - componentHeight)); - - // 격자 스냅 적용 - const snappedPosition = - layout.gridSettings?.snapToGrid && currentGridInfo - ? snapPositionTo10px( - { x: boundedX, y: boundedY, z: 1 }, - currentGridInfo, - layout.gridSettings as GridUtilSettings, - ) - : { x: boundedX, y: boundedY, z: 1 }; - - console.log("🧩 컴포넌트 드롭:", { - componentName: component.name, - webType: component.webType, - rawPosition: { x: dropX, y: dropY }, - boundedPosition: { x: boundedX, y: boundedY }, - snappedPosition, - }); - - // 새 컴포넌트 생성 (새 컴포넌트 시스템 지원) - console.log("🔍 ScreenDesigner handleComponentDrop:", { - componentName: component.name, - componentId: component.id, - webType: component.webType, - category: component.category, - defaultConfig: component.defaultConfig, - defaultSize: component.defaultSize, - }); - - // 컴포넌트별 gridColumns 설정 및 크기 계산 - let componentSize = component.defaultSize; - const isCardDisplay = component.id === "card-display"; - const isTableList = component.id === "table-list"; - - // 컴포넌트 타입별 기본 그리드 컬럼 수 설정 - const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수 - let gridColumns = 1; // 기본값 - - // 특수 컴포넌트 - if (isCardDisplay) { - gridColumns = Math.round(currentGridColumns * 0.667); // 약 66.67% - } else if (isTableList) { - gridColumns = currentGridColumns; // 테이블은 전체 너비 - } else { - // 웹타입별 적절한 그리드 컬럼 수 설정 - const webType = component.webType; - const componentId = component.id; - - // 웹타입별 기본 비율 매핑 (12컬럼 기준 비율) - const gridColumnsRatioMap: Record = { - // 입력 컴포넌트 (INPUT 카테고리) - "text-input": 4 / 12, // 텍스트 입력 (33%) - "number-input": 2 / 12, // 숫자 입력 (16.67%) - "email-input": 4 / 12, // 이메일 입력 (33%) - "tel-input": 3 / 12, // 전화번호 입력 (25%) - "date-input": 3 / 12, // 날짜 입력 (25%) - "datetime-input": 4 / 12, // 날짜시간 입력 (33%) - "time-input": 2 / 12, // 시간 입력 (16.67%) - "textarea-basic": 6 / 12, // 텍스트 영역 (50%) - "select-basic": 3 / 12, // 셀렉트 (25%) - "checkbox-basic": 2 / 12, // 체크박스 (16.67%) - "radio-basic": 3 / 12, // 라디오 (25%) - "file-basic": 4 / 12, // 파일 (33%) - "file-upload": 4 / 12, // 파일 업로드 (33%) - "slider-basic": 3 / 12, // 슬라이더 (25%) - "toggle-switch": 2 / 12, // 토글 스위치 (16.67%) - "repeater-field-group": 6 / 12, // 반복 필드 그룹 (50%) - - // 표시 컴포넌트 (DISPLAY 카테고리) - "label-basic": 2 / 12, // 라벨 (16.67%) - "text-display": 3 / 12, // 텍스트 표시 (25%) - "card-display": 8 / 12, // 카드 (66.67%) - "badge-basic": 1 / 12, // 배지 (8.33%) - "alert-basic": 6 / 12, // 알림 (50%) - "divider-basic": 1, // 구분선 (100%) - "divider-line": 1, // 구분선 (100%) - "accordion-basic": 1, // 아코디언 (100%) - "table-list": 1, // 테이블 리스트 (100%) - "image-display": 4 / 12, // 이미지 표시 (33%) - "split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%) - "flow-widget": 1, // 플로우 위젯 (100%) - - // 액션 컴포넌트 (ACTION 카테고리) - "button-basic": 1 / 12, // 버튼 (8.33%) - "button-primary": 1 / 12, // 프라이머리 버튼 (8.33%) - "button-secondary": 1 / 12, // 세컨더리 버튼 (8.33%) - "icon-button": 1 / 12, // 아이콘 버튼 (8.33%) - - // 레이아웃 컴포넌트 - "container-basic": 6 / 12, // 컨테이너 (50%) - "section-basic": 1, // 섹션 (100%) - "panel-basic": 6 / 12, // 패널 (50%) - - // 기타 - "image-basic": 4 / 12, // 이미지 (33%) - "icon-basic": 1 / 12, // 아이콘 (8.33%) - "progress-bar": 4 / 12, // 프로그레스 바 (33%) - "chart-basic": 6 / 12, // 차트 (50%) - }; - - // defaultSize에 gridColumnSpan이 "full"이면 전체 컬럼 사용 - if (component.defaultSize?.gridColumnSpan === "full") { - gridColumns = currentGridColumns; - } else { - // componentId 또는 webType으로 비율 찾기, 없으면 기본값 25% - const ratio = gridColumnsRatioMap[componentId] || gridColumnsRatioMap[webType] || 0.25; - // 현재 격자 컬럼 수에 비율을 곱하여 계산 (최소 1, 최대 currentGridColumns) - gridColumns = Math.max(1, Math.min(currentGridColumns, Math.round(ratio * currentGridColumns))); - } - - console.log("🎯 컴포넌트 타입별 gridColumns 설정:", { - componentId, - webType, - gridColumns, - }); - } - - // 10px 단위로 너비 스냅 - if (layout.gridSettings?.snapToGrid) { - componentSize = { - ...component.defaultSize, - width: snapTo10px(component.defaultSize.width), - height: snapTo10px(component.defaultSize.height), - }; - } - - console.log("🎨 최종 컴포넌트 크기:", { - componentId: component.id, - componentName: component.name, - defaultSize: component.defaultSize, - finalSize: componentSize, - gridColumns, - }); - - // 반복 필드 그룹인 경우 테이블의 첫 번째 컬럼을 기본 필드로 추가 - let enhancedDefaultConfig = { ...component.defaultConfig }; - if ( - component.id === "repeater-field-group" && - tables && - tables.length > 0 && - tables[0].columns && - tables[0].columns.length > 0 - ) { - const firstColumn = tables[0].columns[0]; - enhancedDefaultConfig = { - ...enhancedDefaultConfig, - fields: [ - { - name: firstColumn.columnName, - label: firstColumn.columnLabel || firstColumn.columnName, - type: (firstColumn.widgetType as any) || "text", - required: firstColumn.required || false, - placeholder: `${firstColumn.columnLabel || firstColumn.columnName}을(를) 입력하세요`, - }, - ], - }; - } - - // gridColumns에 맞춰 width를 퍼센트로 계산 - const widthPercent = (gridColumns / currentGridColumns) * 100; - - console.log("🎨 [컴포넌트 생성] 너비 계산:", { - componentName: component.name, - componentId: component.id, - currentGridColumns, - gridColumns, - widthPercent: `${widthPercent}%`, - calculatedWidth: `${Math.round(widthPercent * 100) / 100}%`, - }); - - // 🆕 라벨을 기반으로 기본 columnName 생성 (한글 → 스네이크 케이스) - // 예: "창고코드" → "warehouse_code" 또는 그대로 유지 - const generateDefaultColumnName = (label: string): string => { - // 한글 라벨의 경우 그대로 사용 (나중에 사용자가 수정 가능) - // 영문의 경우 스네이크 케이스로 변환 - if (/[가-힣]/.test(label)) { - // 한글이 포함된 경우: 공백을 언더스코어로, 소문자로 변환 - return label.replace(/\s+/g, "_").toLowerCase(); - } - // 영문의 경우: 카멜케이스/파스칼케이스를 스네이크 케이스로 변환 - return label - .replace(/([a-z])([A-Z])/g, "$1_$2") - .replace(/\s+/g, "_") - .toLowerCase(); - }; - - const newComponent: ComponentData = { - id: generateComponentId(), - type: "component", // ✅ 새 컴포넌트 시스템 사용 - label: component.name, - columnName: generateDefaultColumnName(component.name), // 🆕 기본 columnName 자동 생성 - widgetType: component.webType, - componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용) - position: snappedPosition, - size: componentSize, - gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용 - layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 - componentConfig: { - type: component.id, // 새 컴포넌트 시스템의 ID 사용 - webType: component.webType, // 웹타입 정보 추가 - ...enhancedDefaultConfig, - }, - webTypeConfig: getDefaultWebTypeConfig(component.webType), - style: { - labelDisplay: true, // 🆕 라벨 기본 표시 (사용자가 끄고 싶으면 체크 해제) - labelFontSize: "14px", - labelColor: "#212121", - labelFontWeight: "500", - labelMarginBottom: "4px", - width: `${componentSize.width}px`, // size와 동기화 (픽셀 단위) - height: `${componentSize.height}px`, // size와 동기화 (픽셀 단위) - }, - }; - - // 레이아웃에 컴포넌트 추가 - const newLayout: LayoutData = { - ...layout, - components: [...layout.components, newComponent], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - // 새 컴포넌트 선택 - setSelectedComponent(newComponent); - // 🔧 테이블 패널 유지를 위해 자동 속성 패널 열기 비활성화 - // openPanel("properties"); - - toast.success(`${component.name} 컴포넌트가 추가되었습니다.`); - }, - [layout, selectedScreen, saveToHistory, activeLayerId], - ); - - // 드래그 앤 드롭 처리 - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - }, []); - - const handleDrop = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - - const dragData = e.dataTransfer.getData("application/json"); - // console.log("🎯 드롭 이벤트:", { dragData }); - if (!dragData) { - // console.log("❌ 드래그 데이터가 없습니다"); - return; - } - - try { - const parsedData = JSON.parse(dragData); - // console.log("📋 파싱된 데이터:", parsedData); - - // 템플릿 드래그인 경우 - if (parsedData.type === "template") { - handleTemplateDrop(e, parsedData.template); - return; - } - - // 레이아웃 드래그인 경우 - if (parsedData.type === "layout") { - handleLayoutDrop(e, parsedData.layout); - return; - } - - // 컴포넌트 드래그인 경우 - if (parsedData.type === "component") { - handleComponentDrop(e, parsedData.component); - return; - } - - // 기존 테이블/컬럼 드래그 처리 - const { type, table, column } = parsedData; - - // 드롭 대상이 폼 컨테이너인지 확인 - const dropTarget = e.target as HTMLElement; - const formContainer = dropTarget.closest('[data-form-container="true"]'); - - // 🎯 리피터 컨테이너 내부에 컬럼 드롭 시 처리 - const repeatContainer = dropTarget.closest('[data-repeat-container="true"]'); - if (repeatContainer && type === "column" && column) { - const containerId = repeatContainer.getAttribute("data-component-id"); - if (containerId) { - const targetComponent = layout.components.find((c) => c.id === containerId); - const rcType = (targetComponent as any)?.componentType; - if (targetComponent && (rcType === "repeat-container" || rcType === "v2-repeat-container")) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const currentChildren = currentConfig.children || []; - - // 새 자식 컴포넌트 생성 (컬럼 기반) - const newChild = { - id: `slot_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: column.widgetType || "text-display", - label: column.columnLabel || column.columnName, - fieldName: column.columnName, - position: { x: 0, y: currentChildren.length * 40 }, - size: { width: 200, height: 32 }, - componentConfig: {}, - }; - - const updatedComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - children: [...currentChildren, newChild], - }, - }; - - const newLayout = { - ...layout, - components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)), - }; - - setLayout(newLayout); - saveToHistory(newLayout); - return; - } - } - } - - // 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원) - const tabsContainer = dropTarget.closest('[data-tabs-container="true"]'); - if (tabsContainer && type === "column" && column) { - const containerId = tabsContainer.getAttribute("data-component-id"); - const activeTabId = tabsContainer.getAttribute("data-active-tab-id"); - if (containerId && activeTabId) { - // 1. 먼저 최상위 레이아웃에서 탭 컴포넌트 찾기 - let targetComponent = layout.components.find((c) => c.id === containerId); - let parentSplitPanelId: string | null = null; - let parentPanelSide: "left" | "right" | null = null; - - // 2. 최상위에 없으면 분할 패널 안에서 중첩된 탭 찾기 - if (!targetComponent) { - for (const comp of layout.components) { - const compType = (comp as any)?.componentType; - if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") { - const config = (comp as any).componentConfig || {}; - - // 좌측 패널에서 찾기 - const leftComponents = config.leftPanel?.components || []; - const foundInLeft = leftComponents.find((c: any) => c.id === containerId); - if (foundInLeft) { - targetComponent = foundInLeft; - parentSplitPanelId = comp.id; - parentPanelSide = "left"; - break; - } - - // 우측 패널에서 찾기 - const rightComponents = config.rightPanel?.components || []; - const foundInRight = rightComponents.find((c: any) => c.id === containerId); - if (foundInRight) { - targetComponent = foundInRight; - parentSplitPanelId = comp.id; - parentPanelSide = "right"; - break; - } - } - } - } - - const compType = (targetComponent as any)?.componentType; - if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget")) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const tabs = currentConfig.tabs || []; - - // 드롭 위치 계산 - const tabContentRect = tabsContainer.getBoundingClientRect(); - const dropX = (e.clientX - tabContentRect.left) / zoomLevel; - const dropY = (e.clientY - tabContentRect.top) / zoomLevel; - - // 🆕 V2 컴포넌트 매핑 사용 (일반 캔버스와 동일) - const v2Mapping = createV2ConfigFromColumn({ - widgetType: column.widgetType, - columnName: column.columnName, - columnLabel: column.columnLabel, - codeCategory: column.codeCategory, - inputType: column.inputType, - required: column.required, - detailSettings: column.detailSettings, - referenceTable: column.referenceTable, - referenceColumn: column.referenceColumn, - displayColumn: column.displayColumn, - }); - - // 웹타입별 기본 크기 계산 - const getTabComponentSize = (widgetType: string) => { - const sizeMap: Record = { - text: { width: 200, height: 36 }, - number: { width: 150, height: 36 }, - decimal: { width: 150, height: 36 }, - date: { width: 180, height: 36 }, - datetime: { width: 200, height: 36 }, - select: { width: 200, height: 36 }, - category: { width: 200, height: 36 }, - code: { width: 200, height: 36 }, - entity: { width: 220, height: 36 }, - boolean: { width: 120, height: 36 }, - checkbox: { width: 120, height: 36 }, - textarea: { width: 300, height: 100 }, - file: { width: 250, height: 80 }, - }; - return sizeMap[widgetType] || { width: 200, height: 36 }; - }; - - const componentSize = getTabComponentSize(column.widgetType); - - const newTabComponent = { - id: `tab_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: v2Mapping.componentType, - label: column.columnLabel || column.columnName, - position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, - size: componentSize, - inputType: column.inputType || column.widgetType, - widgetType: column.widgetType, - componentConfig: { - ...v2Mapping.componentConfig, - columnName: column.columnName, - tableName: column.tableName, - inputType: column.inputType || column.widgetType, - }, - }; - - // 해당 탭에 컴포넌트 추가 - const updatedTabs = tabs.map((tab: any) => { - if (tab.id === activeTabId) { - return { - ...tab, - components: [...(tab.components || []), newTabComponent], - }; - } - return tab; - }); - - const updatedTabsComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - tabs: updatedTabs, - }, - }; - - let newLayout; - - if (parentSplitPanelId && parentPanelSide) { - // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 - newLayout = { - ...layout, - components: layout.components.map((c) => { - if (c.id === parentSplitPanelId) { - const splitConfig = (c as any).componentConfig || {}; - const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = splitConfig[panelKey] || {}; - const panelComponents = panelConfig.components || []; - - return { - ...c, - componentConfig: { - ...splitConfig, - [panelKey]: { - ...panelConfig, - components: panelComponents.map((pc: any) => - pc.id === containerId ? updatedTabsComponent : pc, - ), - }, - }, - }; - } - return c; - }), - }; - toast.success("컬럼이 중첩된 탭에 추가되었습니다"); - } else { - // 일반 구조: 최상위 탭 업데이트 - newLayout = { - ...layout, - components: layout.components.map((c) => (c.id === containerId ? updatedTabsComponent : c)), - }; - toast.success("컬럼이 탭에 추가되었습니다"); - } - - setLayout(newLayout); - saveToHistory(newLayout); - return; - } - } - } - - // 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리 - const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]'); - if (splitPanelContainer && type === "column" && column) { - const containerId = splitPanelContainer.getAttribute("data-component-id"); - const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right" - if (containerId && panelSide) { - const targetComponent = layout.components.find((c) => c.id === containerId); - const compType = (targetComponent as any)?.componentType; - if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = currentConfig[panelKey] || {}; - const currentComponents = panelConfig.components || []; - - // 드롭 위치 계산 - const panelRect = splitPanelContainer.getBoundingClientRect(); - const dropX = (e.clientX - panelRect.left) / zoomLevel; - const dropY = (e.clientY - panelRect.top) / zoomLevel; - - // V2 컴포넌트 매핑 사용 - const v2Mapping = createV2ConfigFromColumn({ - widgetType: column.widgetType, - columnName: column.columnName, - columnLabel: column.columnLabel, - codeCategory: column.codeCategory, - inputType: column.inputType, - required: column.required, - detailSettings: column.detailSettings, - referenceTable: column.referenceTable, - referenceColumn: column.referenceColumn, - displayColumn: column.displayColumn, - }); - - // 웹타입별 기본 크기 계산 - const getPanelComponentSize = (widgetType: string) => { - const sizeMap: Record = { - text: { width: 200, height: 36 }, - number: { width: 150, height: 36 }, - decimal: { width: 150, height: 36 }, - date: { width: 180, height: 36 }, - datetime: { width: 200, height: 36 }, - select: { width: 200, height: 36 }, - category: { width: 200, height: 36 }, - code: { width: 200, height: 36 }, - entity: { width: 220, height: 36 }, - boolean: { width: 120, height: 36 }, - checkbox: { width: 120, height: 36 }, - textarea: { width: 300, height: 100 }, - file: { width: 250, height: 80 }, - }; - return sizeMap[widgetType] || { width: 200, height: 36 }; - }; - - const componentSize = getPanelComponentSize(column.widgetType); - - const newPanelComponent = { - id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: v2Mapping.componentType, - label: column.columnLabel || column.columnName, - position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, - size: componentSize, - inputType: column.inputType || column.widgetType, - widgetType: column.widgetType, - componentConfig: { - ...v2Mapping.componentConfig, - columnName: column.columnName, - tableName: column.tableName, - inputType: column.inputType || column.widgetType, - }, - }; - - const updatedPanelConfig = { - ...panelConfig, - components: [...currentComponents, newPanelComponent], - }; - - const updatedComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - [panelKey]: updatedPanelConfig, - }, - }; - - const newLayout = { - ...layout, - components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)), - }; - - setLayout(newLayout); - saveToHistory(newLayout); - toast.success(`컬럼이 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`); - return; - } - } - } - - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - let newComponent: ComponentData; - - if (type === "table") { - // 테이블 컨테이너 생성 - newComponent = { - id: generateComponentId(), - type: "container", - label: table.tableLabel || table.tableName, // 테이블 라벨 우선, 없으면 테이블명 - tableName: table.tableName, - position: { x, y, z: 1 } as Position, - size: { width: 300, height: 200 }, - layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 - style: { - labelDisplay: true, - labelFontSize: "14px", - labelColor: "#212121", - labelFontWeight: "600", - labelMarginBottom: "8px", - }, - }; - } else if (type === "column") { - // console.log("🔄 컬럼 드롭 처리:", { webType: column.widgetType, columnName: column.columnName }); - - // 웹타입별 기본 너비 계산 (10px 단위 고정) - const getDefaultWidth = (widgetType: string): number => { - const widthMap: Record = { - // 텍스트 입력 계열 - text: 200, - email: 200, - tel: 150, - url: 250, - textarea: 300, - - // 숫자/날짜 입력 - number: 120, - decimal: 120, - date: 150, - datetime: 180, - time: 120, - - // 선택 입력 - select: 180, - radio: 180, - checkbox: 120, - boolean: 120, - - // 코드/참조 - code: 180, - entity: 200, - - // 파일/이미지 - file: 250, - image: 200, - - // 기타 - button: 100, - label: 100, - }; - - return widthMap[widgetType] || 200; // 기본값 200px - }; - - // 웹타입별 기본 높이 계산 - const getDefaultHeight = (widgetType: string): number => { - const heightMap: Record = { - textarea: 120, // 텍스트 영역은 3줄 (40 * 3) - checkbox: 80, // 체크박스 그룹 (40 * 2) - radio: 80, // 라디오 버튼 (40 * 2) - file: 240, // 파일 업로드 (40 * 6) - }; - - return heightMap[widgetType] || 30; // 기본값 30px로 변경 - }; - - // 웹타입별 기본 설정 생성 - const getDefaultWebTypeConfig = (widgetType: string) => { - switch (widgetType) { - case "date": - return { - format: "YYYY-MM-DD" as const, - showTime: false, - placeholder: "날짜를 선택하세요", - }; - case "datetime": - return { - format: "YYYY-MM-DD HH:mm" as const, - showTime: true, - placeholder: "날짜와 시간을 선택하세요", - }; - case "number": - return { - format: "integer" as const, - placeholder: "숫자를 입력하세요", - }; - case "decimal": - return { - format: "decimal" as const, - step: 0.01, - decimalPlaces: 2, - placeholder: "소수를 입력하세요", - }; - case "select": - case "dropdown": - return { - options: [ - { label: "옵션 1", value: "option1" }, - { label: "옵션 2", value: "option2" }, - { label: "옵션 3", value: "option3" }, - ], - multiple: false, - searchable: false, - placeholder: "옵션을 선택하세요", - }; - case "text": - return { - format: "none" as const, - placeholder: "텍스트를 입력하세요", - multiline: false, - }; - case "email": - return { - format: "email" as const, - placeholder: "이메일을 입력하세요", - multiline: false, - }; - case "tel": - return { - format: "phone" as const, - placeholder: "전화번호를 입력하세요", - multiline: false, - }; - case "textarea": - return { - rows: 3, - placeholder: "텍스트를 입력하세요", - resizable: true, - autoResize: false, - wordWrap: true, - }; - case "checkbox": - case "boolean": - return { - defaultChecked: false, - labelPosition: "right" as const, - checkboxText: "", - trueValue: true, - falseValue: false, - indeterminate: false, - }; - case "radio": - return { - options: [ - { label: "옵션 1", value: "option1" }, - { label: "옵션 2", value: "option2" }, - ], - layout: "vertical" as const, - defaultValue: "", - allowNone: false, - }; - case "file": - return { - accept: "", - multiple: false, - maxSize: 10, - maxFiles: 1, - preview: true, - dragDrop: true, - allowedExtensions: [], - }; - case "code": - return { - codeCategory: "", // 기본값, 실제로는 컬럼 정보에서 가져옴 - placeholder: "선택하세요", - options: [], // 기본 빈 배열, 실제로는 API에서 로드 - }; - case "entity": - return { - entityName: "", - displayField: "name", - valueField: "id", - searchable: true, - multiple: false, - allowClear: true, - placeholder: "엔터티를 선택하세요", - apiEndpoint: "", - filters: [], - displayFormat: "simple" as const, - }; - case "table": - return { - tableName: "", - displayMode: "table" as const, - showHeader: true, - showFooter: true, - pagination: { - enabled: true, - pageSize: 10, - showPageSizeSelector: true, - showPageInfo: true, - showFirstLast: true, - }, - columns: [], - searchable: true, - sortable: true, - filterable: true, - exportable: true, - }; - default: - return undefined; - } - }; - - // 폼 컨테이너에 드롭한 경우 - if (formContainer) { - const formContainerId = formContainer.getAttribute("data-component-id"); - const formContainerComponent = layout.components.find((c) => c.id === formContainerId); - - if (formContainerComponent) { - // 폼 내부에서의 상대적 위치 계산 - const containerRect = formContainer.getBoundingClientRect(); - const relativeX = e.clientX - containerRect.left; - const relativeY = e.clientY - containerRect.top; - - // 🆕 V2 컴포넌트 매핑 사용 - const v2Mapping = createV2ConfigFromColumn({ - widgetType: column.widgetType, - columnName: column.columnName, - columnLabel: column.columnLabel, - codeCategory: column.codeCategory, - inputType: column.inputType, - required: column.required, - detailSettings: column.detailSettings, // 엔티티 참조 정보 전달 - // column_labels 직접 필드도 전달 - referenceTable: column.referenceTable, - referenceColumn: column.referenceColumn, - displayColumn: column.displayColumn, - }); - - // 웹타입별 기본 너비 계산 (10px 단위 고정) - const componentWidth = getDefaultWidth(column.widgetType); - - console.log("🎯 폼 컨테이너 V2 컴포넌트 생성:", { - widgetType: column.widgetType, - v2Type: v2Mapping.componentType, - componentWidth, - }); - - // 엔티티 조인 컬럼인 경우 읽기 전용으로 설정 - const isEntityJoinColumn = column.isEntityJoin === true; - - newComponent = { - id: generateComponentId(), - type: "component", // ✅ V2 컴포넌트 시스템 사용 - label: column.columnLabel || column.columnName, - tableName: table.tableName, - columnName: column.columnName, - required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 - readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 - parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 - 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 && { - codeCategory: column.codeCategory, - }), - // 엔티티 조인 정보 저장 - ...(isEntityJoinColumn && { - isEntityJoin: true, - entityJoinTable: column.entityJoinTable, - entityJoinColumn: column.entityJoinColumn, - }), - style: { - labelDisplay: true, // 🆕 라벨 기본 표시 - labelFontSize: "12px", - labelColor: "#212121", - labelFontWeight: "500", - labelMarginBottom: "6px", - }, - componentConfig: { - type: v2Mapping.componentType, // v2-input, v2-select 등 - ...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 - ...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화 - }, - }; - } else { - return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소 - } - } else { - // 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용 - const v2Mapping = createV2ConfigFromColumn({ - widgetType: column.widgetType, - columnName: column.columnName, - columnLabel: column.columnLabel, - codeCategory: column.codeCategory, - inputType: column.inputType, - required: column.required, - detailSettings: column.detailSettings, // 엔티티 참조 정보 전달 - // column_labels 직접 필드도 전달 - referenceTable: column.referenceTable, - referenceColumn: column.referenceColumn, - displayColumn: column.displayColumn, - }); - - // 웹타입별 기본 너비 계산 (10px 단위 고정) - const componentWidth = getDefaultWidth(column.widgetType); - - console.log("🎯 캔버스 V2 컴포넌트 생성:", { - widgetType: column.widgetType, - v2Type: v2Mapping.componentType, - componentWidth, - }); - - // 엔티티 조인 컬럼인 경우 읽기 전용으로 설정 - const isEntityJoinColumn = column.isEntityJoin === true; - - newComponent = { - id: generateComponentId(), - type: "component", // ✅ V2 컴포넌트 시스템 사용 - label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명 - tableName: table.tableName, - columnName: column.columnName, - required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 - readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 - 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 && { - codeCategory: column.codeCategory, - }), - // 엔티티 조인 정보 저장 - ...(isEntityJoinColumn && { - isEntityJoin: true, - entityJoinTable: column.entityJoinTable, - entityJoinColumn: column.entityJoinColumn, - }), - style: { - labelDisplay: true, // 🆕 라벨 기본 표시 - labelFontSize: "14px", - labelColor: "#000000", // 순수한 검정 - labelFontWeight: "500", - labelMarginBottom: "8px", - }, - componentConfig: { - type: v2Mapping.componentType, // v2-input, v2-select 등 - ...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 - ...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화 - }, - }; - } - } else { - return; - } - - // 10px 단위 스냅 적용 (그룹 컴포넌트 제외) - if (layout.gridSettings?.snapToGrid && newComponent.type !== "group") { - newComponent.position = snapPositionTo10px(newComponent.position); - newComponent.size = snapSizeTo10px(newComponent.size); - - console.log("🧲 새 컴포넌트 10px 스냅 적용:", { - type: newComponent.type, - snappedPosition: newComponent.position, - snappedSize: newComponent.size, - }); - } - - const newLayout = { - ...layout, - components: [...layout.components, newComponent], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - setSelectedComponent(newComponent); - - // 🔧 테이블 패널 유지를 위해 자동 속성 패널 열기 비활성화 - // openPanel("properties"); - } catch (error) { - // console.error("드롭 처리 실패:", error); - } - }, - [layout, saveToHistory], - ); - - // 파일 컴포넌트 업데이트 처리 - const handleFileComponentUpdate = useCallback( - (updates: Partial) => { - if (!selectedFileComponent) return; - - const updatedComponents = layout.components.map((comp) => - comp.id === selectedFileComponent.id ? { ...comp, ...updates } : comp, - ); - - const newLayout = { ...layout, components: updatedComponents }; - setLayout(newLayout); - saveToHistory(newLayout); - - // selectedFileComponent도 업데이트 - setSelectedFileComponent((prev) => (prev ? { ...prev, ...updates } : null)); - - // selectedComponent가 같은 컴포넌트라면 업데이트 - if (selectedComponent?.id === selectedFileComponent.id) { - setSelectedComponent((prev) => (prev ? { ...prev, ...updates } : null)); - } - }, - [selectedFileComponent, layout, saveToHistory, selectedComponent], - ); - - // 파일첨부 모달 닫기 - const handleFileAttachmentModalClose = useCallback(() => { - setShowFileAttachmentModal(false); - setSelectedFileComponent(null); - }, []); - - // 컴포넌트 더블클릭 처리 - const handleComponentDoubleClick = useCallback((component: ComponentData, event?: React.MouseEvent) => { - event?.stopPropagation(); - - // 파일 컴포넌트인 경우 상세 모달 열기 - if (component.type === "file") { - setSelectedFileComponent(component); - setShowFileAttachmentModal(true); - return; - } - - // 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가 - // console.log("더블클릭된 컴포넌트:", component.type, component.id); - }, []); - - // 컴포넌트 클릭 처리 (다중선택 지원) - const handleComponentClick = useCallback( - (component: ComponentData, event?: React.MouseEvent) => { - event?.stopPropagation(); - - // 드래그가 끝난 직후라면 클릭을 무시 (다중 선택 유지) - if (dragState.justFinishedDrag) { - return; - } - - // 🔧 layout.components에서 최신 버전의 컴포넌트 찾기 - const latestComponent = layout.components.find((c) => c.id === component.id); - if (!latestComponent) { - console.warn("⚠️ 컴포넌트를 찾을 수 없습니다:", component.id); - return; - } - - const isShiftPressed = event?.shiftKey || false; - const isCtrlPressed = event?.ctrlKey || event?.metaKey || false; - const isGroupContainer = latestComponent.type === "group"; - - if (isShiftPressed || isCtrlPressed || groupState.isGrouping) { - // 다중 선택 모드 - if (isGroupContainer) { - // 그룹 컨테이너는 단일 선택으로 처리 - handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 - setGroupState((prev) => ({ - ...prev, - selectedComponents: [latestComponent.id], - isGrouping: false, - })); - return; - } - - const isSelected = groupState.selectedComponents.includes(latestComponent.id); - setGroupState((prev) => ({ - ...prev, - selectedComponents: isSelected - ? prev.selectedComponents.filter((id) => id !== latestComponent.id) - : [...prev.selectedComponents, latestComponent.id], - })); - - // 마지막 선택된 컴포넌트를 selectedComponent로 설정 - if (!isSelected) { - // console.log("🎯 컴포넌트 선택 (다중 모드):", latestComponent.id); - handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 - } - } else { - // 단일 선택 모드 - // console.log("🎯 컴포넌트 선택 (단일 모드):", latestComponent.id); - handleComponentSelect(latestComponent); // 🔧 최신 버전 사용 - setGroupState((prev) => ({ - ...prev, - selectedComponents: [latestComponent.id], - })); - } - }, - [ - handleComponentSelect, - groupState.isGrouping, - groupState.selectedComponents, - dragState.justFinishedDrag, - layout.components, - ], - ); - - // 컴포넌트 드래그 시작 - const startComponentDrag = useCallback( - (component: ComponentData, event: React.MouseEvent | React.DragEvent) => { - event.preventDefault(); - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - // 새로운 드래그 시작 시 justFinishedDrag 플래그 해제 - if (dragState.justFinishedDrag) { - setDragState((prev) => ({ - ...prev, - justFinishedDrag: false, - })); - } - - // 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산 - // 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요 - const relativeMouseX = (event.clientX - rect.left) / zoomLevel; - const relativeMouseY = (event.clientY - rect.top) / zoomLevel; - - // 다중 선택된 컴포넌트들 확인 - const isDraggedComponentSelected = groupState.selectedComponents.includes(component.id); - let componentsToMove = isDraggedComponentSelected - ? layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)) - : [component]; - - // 레이아웃 컴포넌트인 경우 존에 속한 컴포넌트들도 함께 이동 - if (component.type === "layout") { - const zoneComponents = layout.components.filter((comp) => comp.parentId === component.id && comp.zoneId); - - console.log("🏗️ 레이아웃 드래그 - 존 컴포넌트들 포함:", { - layoutId: component.id, - zoneComponentsCount: zoneComponents.length, - zoneComponents: zoneComponents.map((c) => ({ id: c.id, zoneId: c.zoneId })), - }); - - // 중복 제거하여 추가 - const allComponentIds = new Set(componentsToMove.map((c) => c.id)); - const additionalComponents = zoneComponents.filter((c) => !allComponentIds.has(c.id)); - componentsToMove = [...componentsToMove, ...additionalComponents]; - } - - setDragState({ - isDragging: true, - draggedComponent: component, // 주 드래그 컴포넌트 (마우스 위치 기준) - draggedComponents: componentsToMove, // 함께 이동할 모든 컴포넌트들 - originalPosition: { - x: component.position.x, - y: component.position.y, - z: (component.position as Position).z || 1, - }, - currentPosition: { - x: component.position.x, - y: component.position.y, - z: (component.position as Position).z || 1, - }, - grabOffset: { - x: relativeMouseX - component.position.x, - y: relativeMouseY - component.position.y, - }, - justFinishedDrag: false, - }); - }, - [groupState.selectedComponents, layout.components, dragState.justFinishedDrag, zoomLevel], - ); - - // 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트) - const updateDragPosition = useCallback( - (event: MouseEvent) => { - if (!dragState.isDragging || !dragState.draggedComponent || !canvasRef.current) return; - - const rect = canvasRef.current.getBoundingClientRect(); - - // 🔥 중요: 줌 레벨을 고려한 마우스 위치 계산 - // 캔버스가 scale() 변환되어 있기 때문에 마우스 위치도 역변환 필요 - const relativeMouseX = (event.clientX - rect.left) / zoomLevel; - const relativeMouseY = (event.clientY - rect.top) / zoomLevel; - - // 컴포넌트 크기 가져오기 - const draggedComp = layout.components.find((c) => c.id === dragState.draggedComponent.id); - const componentWidth = draggedComp?.size?.width || 100; - const componentHeight = draggedComp?.size?.height || 40; - - // 경계 제한 적용 - const rawX = relativeMouseX - dragState.grabOffset.x; - const rawY = relativeMouseY - dragState.grabOffset.y; - - // 조건부 레이어 편집 시 displayRegion 크기 기준 경계 제한 - const dragLayerId = activeLayerIdRef.current || 1; - const dragLayerRegion = dragLayerId > 1 ? layerRegions[dragLayerId] : null; - const dragBoundW = dragLayerRegion ? dragLayerRegion.width : screenResolution.width; - const dragBoundH = dragLayerRegion ? dragLayerRegion.height : screenResolution.height; - - const newPosition = { - x: Math.max(0, Math.min(rawX, dragBoundW - componentWidth)), - y: Math.max(0, Math.min(rawY, dragBoundH - componentHeight)), - z: (dragState.draggedComponent.position as Position).z || 1, - }; - - // 드래그 상태 업데이트 - setDragState((prev) => { - const newState = { - ...prev, - currentPosition: { ...newPosition }, // 새로운 객체 생성 - }; - return newState; - }); - - // 성능 최적화: 드래그 중에는 상태 업데이트만 하고, - // 실제 레이아웃 업데이트는 endDrag에서 처리 - // 속성 패널에서는 dragState.currentPosition을 참조하여 실시간 표시 - }, - [dragState.isDragging, dragState.draggedComponent, dragState.grabOffset, zoomLevel], - ); - - // 드래그 종료 - const endDrag = useCallback( - (mouseEvent?: MouseEvent) => { - if (dragState.isDragging && dragState.draggedComponent) { - // 🎯 탭 컨테이너로의 드롭 처리 (기존 컴포넌트 이동, 중첩 구조 지원) - if (mouseEvent) { - const dropTarget = document.elementFromPoint(mouseEvent.clientX, mouseEvent.clientY) as HTMLElement; - const tabsContainer = dropTarget?.closest('[data-tabs-container="true"]'); - - if (tabsContainer) { - const containerId = tabsContainer.getAttribute("data-component-id"); - const activeTabId = tabsContainer.getAttribute("data-active-tab-id"); - - if (containerId && activeTabId) { - // 1. 먼저 최상위 레이아웃에서 탭 컴포넌트 찾기 - let targetComponent = layout.components.find((c) => c.id === containerId); - let parentSplitPanelId: string | null = null; - let parentPanelSide: "left" | "right" | null = null; - - // 2. 최상위에 없으면 분할 패널 안에서 중첩된 탭 찾기 - if (!targetComponent) { - for (const comp of layout.components) { - const compType = (comp as any)?.componentType; - if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") { - const config = (comp as any).componentConfig || {}; - - // 좌측 패널에서 찾기 - const leftComponents = config.leftPanel?.components || []; - const foundInLeft = leftComponents.find((c: any) => c.id === containerId); - if (foundInLeft) { - targetComponent = foundInLeft; - parentSplitPanelId = comp.id; - parentPanelSide = "left"; - break; - } - - // 우측 패널에서 찾기 - const rightComponents = config.rightPanel?.components || []; - const foundInRight = rightComponents.find((c: any) => c.id === containerId); - if (foundInRight) { - targetComponent = foundInRight; - parentSplitPanelId = comp.id; - parentPanelSide = "right"; - break; - } - } - } - } - - const compType = (targetComponent as any)?.componentType; - - // 자기 자신을 자신에게 드롭하는 것 방지 - if ( - targetComponent && - (compType === "tabs-widget" || compType === "v2-tabs-widget") && - dragState.draggedComponent !== containerId - ) { - const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent); - if (draggedComponent) { - const currentConfig = (targetComponent as any).componentConfig || {}; - const tabs = currentConfig.tabs || []; - - // 탭 컨텐츠 영역 기준 드롭 위치 계산 - const tabContentRect = tabsContainer.getBoundingClientRect(); - const dropX = (mouseEvent.clientX - tabContentRect.left) / zoomLevel; - const dropY = (mouseEvent.clientY - tabContentRect.top) / zoomLevel; - - // 기존 컴포넌트를 탭 내부 컴포넌트로 변환 - const newTabComponent = { - id: `tab_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - componentType: (draggedComponent as any).componentType || draggedComponent.type, - label: (draggedComponent as any).label || "컴포넌트", - position: { x: Math.max(0, dropX), y: Math.max(0, dropY) }, - size: draggedComponent.size || { width: 200, height: 100 }, - componentConfig: (draggedComponent as any).componentConfig || {}, - style: (draggedComponent as any).style || {}, - }; - - // 해당 탭에 컴포넌트 추가 - const updatedTabs = tabs.map((tab: any) => { - if (tab.id === activeTabId) { - return { - ...tab, - components: [...(tab.components || []), newTabComponent], - }; - } - return tab; - }); - - const updatedTabsComponent = { - ...targetComponent, - componentConfig: { - ...currentConfig, - tabs: updatedTabs, - }, - }; - - let newLayout; - - if (parentSplitPanelId && parentPanelSide) { - // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 - newLayout = { - ...layout, - components: layout.components - .filter((c) => c.id !== dragState.draggedComponent) - .map((c) => { - if (c.id === parentSplitPanelId) { - const splitConfig = (c as any).componentConfig || {}; - const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = splitConfig[panelKey] || {}; - const panelComponents = panelConfig.components || []; - - return { - ...c, - componentConfig: { - ...splitConfig, - [panelKey]: { - ...panelConfig, - components: panelComponents.map((pc: any) => - pc.id === containerId ? updatedTabsComponent : pc, - ), - }, - }, - }; - } - return c; - }), - }; - toast.success("컴포넌트가 중첩된 탭으로 이동되었습니다"); - } else { - // 일반 구조: 최상위 탭 업데이트 - newLayout = { - ...layout, - components: layout.components - .filter((c) => c.id !== dragState.draggedComponent) - .map((c) => { - if (c.id === containerId) { - return updatedTabsComponent; - } - return c; - }), - }; - toast.success("컴포넌트가 탭으로 이동되었습니다"); - } - - setLayout(newLayout); - saveToHistory(newLayout); - setSelectedComponent(null); - - // 드래그 상태 초기화 후 종료 - setDragState({ - isDragging: false, - draggedComponent: null, - draggedComponents: [], - originalPosition: { x: 0, y: 0, z: 1 }, - currentPosition: { x: 0, y: 0, z: 1 }, - grabOffset: { x: 0, y: 0 }, - justFinishedDrag: true, - }); - - setTimeout(() => { - setDragState((prev) => ({ ...prev, justFinishedDrag: false })); - }, 100); - - return; // 탭으로 이동 완료, 일반 드래그 종료 로직 스킵 - } - } - } - } - } - - // 주 드래그 컴포넌트의 최종 위치 계산 - const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent); - let finalPosition = dragState.currentPosition; - - // 현재 해상도에 맞는 격자 정보 계산 - const currentGridInfo = layout.gridSettings - ? calculateGridInfo(screenResolution.width, screenResolution.height, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }) - : null; - - // 일반 컴포넌트 및 플로우 버튼 그룹에 격자 스냅 적용 (일반 그룹 제외) - if (draggedComponent?.type !== "group" && layout.gridSettings?.snapToGrid && currentGridInfo) { - finalPosition = snapPositionTo10px( - { - x: dragState.currentPosition.x, - y: dragState.currentPosition.y, - z: dragState.currentPosition.z ?? 1, - }, - currentGridInfo, - { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }, - ); - } - - // 스냅으로 인한 추가 이동 거리 계산 - const snapDeltaX = finalPosition.x - dragState.currentPosition.x; - const snapDeltaY = finalPosition.y - dragState.currentPosition.y; - - // 원래 이동 거리 + 스냅 조정 거리 - const totalDeltaX = dragState.currentPosition.x - dragState.originalPosition.x + snapDeltaX; - const totalDeltaY = dragState.currentPosition.y - dragState.originalPosition.y + snapDeltaY; - - // 다중 컴포넌트들의 최종 위치 업데이트 - const updatedComponents = layout.components.map((comp) => { - const isDraggedComponent = dragState.draggedComponents.some((dragComp) => dragComp.id === comp.id); - if (isDraggedComponent) { - const originalComponent = dragState.draggedComponents.find((dragComp) => dragComp.id === comp.id)!; - let newPosition = { - x: originalComponent.position.x + totalDeltaX, - y: originalComponent.position.y + totalDeltaY, - z: originalComponent.position.z || 1, - }; - - // 캔버스 경계 제한 (컴포넌트가 화면 밖으로 나가지 않도록) - const componentWidth = comp.size?.width || 100; - const componentHeight = comp.size?.height || 40; - - // 최소 위치: 0, 최대 위치: 캔버스 크기 - 컴포넌트 크기 - newPosition.x = Math.max(0, Math.min(newPosition.x, screenResolution.width - componentWidth)); - newPosition.y = Math.max(0, Math.min(newPosition.y, screenResolution.height - componentHeight)); - - // 그룹 내부 컴포넌트인 경우 패딩을 고려한 격자 스냅 적용 - if (comp.parentId && layout.gridSettings?.snapToGrid && gridInfo) { - const { columnWidth } = gridInfo; - const { gap } = layout.gridSettings; - - // 그룹 내부 패딩 고려한 격자 정렬 - const padding = 16; - const effectiveX = newPosition.x - padding; - const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16))); - const snappedX = padding + columnIndex * (columnWidth + (gap || 16)); - - // Y 좌표는 20px 단위로 스냅 - const effectiveY = newPosition.y - padding; - const rowIndex = Math.round(effectiveY / 20); - const snappedY = padding + rowIndex * 20; - - // 크기도 외부 격자와 동일하게 스냅 - const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기 - const widthInColumns = Math.max(1, Math.round(comp.size.width / fullColumnWidth)); - const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기 - // 높이는 사용자가 입력한 값 그대로 사용 (스냅 제거) - const snappedHeight = Math.max(40, comp.size.height); - - newPosition = { - x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 - y: Math.max(padding, snappedY), - z: newPosition.z, - }; - - // 크기도 업데이트 - const newSize = { - width: snappedWidth, - height: snappedHeight, - }; - - return { - ...comp, - position: newPosition as Position, - size: newSize, - }; - } - - return { - ...comp, - position: newPosition as Position, - }; - } - return comp; - }); - - const newLayout = { ...layout, components: updatedComponents }; - setLayout(newLayout); - - // 선택된 컴포넌트도 업데이트 (PropertiesPanel 동기화용) - if (selectedComponent && dragState.draggedComponents.some((c) => c.id === selectedComponent.id)) { - const updatedSelectedComponent = updatedComponents.find((c) => c.id === selectedComponent.id); - if (updatedSelectedComponent) { - console.log("🔄 ScreenDesigner: 선택된 컴포넌트 위치 업데이트", { - componentId: selectedComponent.id, - oldPosition: selectedComponent.position, - newPosition: updatedSelectedComponent.position, - }); - setSelectedComponent(updatedSelectedComponent); - } - } - - // 히스토리에 저장 - saveToHistory(newLayout); - } - - setDragState({ - isDragging: false, - draggedComponent: null, - draggedComponents: [], - originalPosition: { x: 0, y: 0, z: 1 }, - currentPosition: { x: 0, y: 0, z: 1 }, - grabOffset: { x: 0, y: 0 }, - justFinishedDrag: true, - }); - - // 짧은 시간 후 justFinishedDrag 플래그 해제 - setTimeout(() => { - setDragState((prev) => ({ - ...prev, - justFinishedDrag: false, - })); - }, 100); - }, - [dragState, layout, saveToHistory], - ); - - // 드래그 선택 시작 - const startSelectionDrag = useCallback( - (event: React.MouseEvent) => { - if (dragState.isDragging) return; // 컴포넌트 드래그 중이면 무시 - - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - - // zoom 스케일을 고려한 좌표 변환 - const startPoint = { - x: (event.clientX - rect.left) / zoomLevel, - y: (event.clientY - rect.top) / zoomLevel, - z: 1, - }; - - setSelectionDrag({ - isSelecting: true, - startPoint, - currentPoint: startPoint, - wasSelecting: false, - }); - }, - [dragState.isDragging, zoomLevel], - ); - - // 드래그 선택 업데이트 - const updateSelectionDrag = useCallback( - (event: MouseEvent) => { - if (!selectionDrag.isSelecting || !canvasRef.current) return; - - const rect = canvasRef.current.getBoundingClientRect(); - // zoom 스케일을 고려한 좌표 변환 - const currentPoint = { - x: (event.clientX - rect.left) / zoomLevel, - y: (event.clientY - rect.top) / zoomLevel, - z: 1, - }; - - setSelectionDrag((prev) => ({ - ...prev, - currentPoint, - })); - - // 선택 영역 내의 컴포넌트들 찾기 - const selectionRect = { - left: Math.min(selectionDrag.startPoint.x, currentPoint.x), - top: Math.min(selectionDrag.startPoint.y, currentPoint.y), - right: Math.max(selectionDrag.startPoint.x, currentPoint.x), - bottom: Math.max(selectionDrag.startPoint.y, currentPoint.y), - }; - - // 🆕 visibleComponents만 선택 대상으로 (현재 활성 레이어의 컴포넌트만) - const selectedIds = visibleComponents - .filter((comp) => { - const compRect = { - left: comp.position.x, - top: comp.position.y, - right: comp.position.x + comp.size.width, - bottom: comp.position.y + comp.size.height, - }; - - return ( - compRect.left < selectionRect.right && - compRect.right > selectionRect.left && - compRect.top < selectionRect.bottom && - compRect.bottom > selectionRect.top - ); - }) - .map((comp) => comp.id); - - setGroupState((prev) => ({ - ...prev, - selectedComponents: selectedIds, - })); - }, - [selectionDrag.isSelecting, selectionDrag.startPoint, visibleComponents, zoomLevel], - ); - - // 드래그 선택 종료 - const endSelectionDrag = useCallback(() => { - // 최소 드래그 거리 확인 (5픽셀) - const minDragDistance = 5; - const dragDistance = Math.sqrt( - Math.pow(selectionDrag.currentPoint.x - selectionDrag.startPoint.x, 2) + - Math.pow(selectionDrag.currentPoint.y - selectionDrag.startPoint.y, 2), - ); - - const wasActualDrag = dragDistance > minDragDistance; - - setSelectionDrag({ - isSelecting: false, - startPoint: { x: 0, y: 0, z: 1 }, - currentPoint: { x: 0, y: 0, z: 1 }, - wasSelecting: wasActualDrag, // 실제 드래그였을 때만 클릭 이벤트 무시 - }); - - // 짧은 시간 후 wasSelecting을 false로 리셋 - setTimeout(() => { - setSelectionDrag((prev) => ({ - ...prev, - wasSelecting: false, - })); - }, 100); - }, [selectionDrag.currentPoint, selectionDrag.startPoint]); - - // 컴포넌트 삭제 (단일/다중 선택 지원) - const deleteComponent = useCallback(() => { - // 다중 선택된 컴포넌트가 있는 경우 - if (groupState.selectedComponents.length > 0) { - // console.log("🗑️ 다중 컴포넌트 삭제:", groupState.selectedComponents.length, "개"); - - let newComponents = [...layout.components]; - - // 각 선택된 컴포넌트를 삭제 처리 - groupState.selectedComponents.forEach((componentId) => { - const component = layout.components.find((comp) => comp.id === componentId); - if (!component) return; - - if (component.type === "group") { - // 그룹 삭제 시: 자식 컴포넌트들의 절대 위치 복원 - const childComponents = newComponents.filter((comp) => comp.parentId === component.id); - const restoredChildren = restoreAbsolutePositions(childComponents, component.position); - - newComponents = newComponents - .map((comp) => { - if (comp.parentId === component.id) { - // 복원된 절대 위치로 업데이트 - const restoredChild = restoredChildren.find((restored) => restored.id === comp.id); - return restoredChild || { ...comp, parentId: undefined }; - } - return comp; - }) - .filter((comp) => comp.id !== component.id); // 그룹 컴포넌트 제거 - } else { - // 일반 컴포넌트 삭제 - newComponents = newComponents.filter((comp) => comp.id !== component.id); - } - }); - - const newLayout = { ...layout, components: newComponents }; - setLayout(newLayout); - saveToHistory(newLayout); - - // 선택 상태 초기화 - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [] })); - - toast.success(`${groupState.selectedComponents.length}개 컴포넌트가 삭제되었습니다.`); - return; - } - - // 단일 선택된 컴포넌트 삭제 - if (!selectedComponent) return; - - // console.log("🗑️ 단일 컴포넌트 삭제:", selectedComponent.id); - - let newComponents; - - if (selectedComponent.type === "group") { - // 그룹 삭제 시: 자식 컴포넌트들의 절대 위치 복원 후 그룹 삭제 - const childComponents = layout.components.filter((comp) => comp.parentId === selectedComponent.id); - const restoredChildren = restoreAbsolutePositions(childComponents, selectedComponent.position); - - newComponents = layout.components - .map((comp) => { - if (comp.parentId === selectedComponent.id) { - // 복원된 절대 위치로 업데이트 - const restoredChild = restoredChildren.find((restored) => restored.id === comp.id); - return restoredChild || { ...comp, parentId: undefined }; - } - return comp; - }) - .filter((comp) => comp.id !== selectedComponent.id); // 그룹 컴포넌트 제거 - } else { - // 일반 컴포넌트 삭제 - newComponents = layout.components.filter((comp) => comp.id !== selectedComponent.id); - } - - const newLayout = { ...layout, components: newComponents }; - - setLayout(newLayout); - saveToHistory(newLayout); - setSelectedComponent(null); - toast.success("컴포넌트가 삭제되었습니다."); - }, [selectedComponent, groupState.selectedComponents, layout, saveToHistory]); - - // 컴포넌트 복사 - const copyComponent = useCallback(() => { - if (groupState.selectedComponents.length > 0) { - // 다중 선택된 컴포넌트들 복사 - const componentsToCopy = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); - setClipboard(componentsToCopy); - // console.log("다중 컴포넌트 복사:", componentsToCopy.length, "개"); - toast.success(`${componentsToCopy.length}개 컴포넌트가 복사되었습니다.`); - } else if (selectedComponent) { - // 단일 컴포넌트 복사 - setClipboard([selectedComponent]); - // console.log("단일 컴포넌트 복사:", selectedComponent.id); - toast.success("컴포넌트가 복사되었습니다."); - } - }, [selectedComponent, groupState.selectedComponents, layout.components]); - - // 컴포넌트 붙여넣기 - const pasteComponent = useCallback(() => { - if (clipboard.length === 0) { - toast.warning("복사된 컴포넌트가 없습니다."); - return; - } - - const newComponents: ComponentData[] = []; - const offset = 20; // 붙여넣기 시 위치 오프셋 - - clipboard.forEach((clipComponent, index) => { - const newComponent: ComponentData = { - ...clipComponent, - id: generateComponentId(), - position: { - x: clipComponent.position.x + offset + index * 10, - y: clipComponent.position.y + offset + index * 10, - z: clipComponent.position.z || 1, - } as Position, - parentId: undefined, // 붙여넣기 시 부모 관계 해제 - layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기 - }; - newComponents.push(newComponent); - }); - - const newLayout = { - ...layout, - components: [...layout.components, ...newComponents], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - // 붙여넣은 컴포넌트들을 선택 상태로 만들기 - setGroupState((prev) => ({ - ...prev, - selectedComponents: newComponents.map((comp) => comp.id), - })); - - // console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개"); - toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`); - }, [clipboard, layout, saveToHistory, activeLayerId]); - - // 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로) - // 🆕 플로우 버튼 그룹 다이얼로그 상태 - const [groupDialogOpen, setGroupDialogOpen] = useState(false); - - const handleFlowButtonGroup = useCallback(() => { - const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); - - // 선택된 컴포넌트가 없거나 1개 이하면 그룹화 불가 - if (selectedComponents.length < 2) { - toast.error("그룹으로 묶을 버튼을 2개 이상 선택해주세요"); - return; - } - - // 모두 버튼인지 확인 - if (!areAllButtons(selectedComponents)) { - toast.error("버튼 컴포넌트만 그룹으로 묶을 수 있습니다"); - return; - } - - // 🆕 다이얼로그 열기 - setGroupDialogOpen(true); - }, [layout, groupState.selectedComponents]); - - // 🆕 그룹 생성 확인 핸들러 - const handleGroupConfirm = useCallback( - (settings: { - direction: "horizontal" | "vertical"; - gap: number; - align: "start" | "center" | "end" | "space-between" | "space-around"; - }) => { - const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); - - // 고유한 그룹 ID 생성 - const newGroupId = generateGroupId(); - - // 🔧 그룹 위치 및 버튼 재배치 계산 - const align = settings.align; - const direction = settings.direction; - const gap = settings.gap; - - const groupY = Math.min(...selectedComponents.map((b) => b.position.y)); - let anchorButton; // 기준이 되는 버튼 - let groupX: number; - - // align에 따라 기준 버튼과 그룹 시작점 결정 - if (direction === "horizontal") { - if (align === "end") { - // 끝점 정렬: 가장 오른쪽 버튼이 기준 - anchorButton = selectedComponents.reduce((max, btn) => { - const rightEdge = btn.position.x + (btn.size?.width || 100); - const maxRightEdge = max.position.x + (max.size?.width || 100); - return rightEdge > maxRightEdge ? btn : max; - }); - - // 전체 그룹 너비 계산 - const totalWidth = selectedComponents.reduce((total, btn, index) => { - const buttonWidth = btn.size?.width || 100; - const gapWidth = index < selectedComponents.length - 1 ? gap : 0; - return total + buttonWidth + gapWidth; - }, 0); - - // 그룹 시작점 = 기준 버튼의 오른쪽 끝 - 전체 그룹 너비 - groupX = anchorButton.position.x + (anchorButton.size?.width || 100) - totalWidth; - } else if (align === "center") { - // 중앙 정렬: 버튼들의 중심점을 기준으로 - const minX = Math.min(...selectedComponents.map((b) => b.position.x)); - const maxX = Math.max(...selectedComponents.map((b) => b.position.x + (b.size?.width || 100))); - const centerX = (minX + maxX) / 2; - - const totalWidth = selectedComponents.reduce((total, btn, index) => { - const buttonWidth = btn.size?.width || 100; - const gapWidth = index < selectedComponents.length - 1 ? gap : 0; - return total + buttonWidth + gapWidth; - }, 0); - - groupX = centerX - totalWidth / 2; - anchorButton = selectedComponents[0]; // 중앙 정렬은 첫 번째 버튼 기준 - } else { - // 시작점 정렬: 가장 왼쪽 버튼이 기준 - anchorButton = selectedComponents.reduce((min, btn) => { - return btn.position.x < min.position.x ? btn : min; - }); - groupX = anchorButton.position.x; - } - } else { - // 세로 정렬: 가장 위쪽 버튼이 기준 - anchorButton = selectedComponents.reduce((min, btn) => { - return btn.position.y < min.position.y ? btn : min; - }); - groupX = Math.min(...selectedComponents.map((b) => b.position.x)); - } - - // 🔧 버튼들의 위치를 그룹 기준으로 재배치 - // 기준 버튼의 절대 위치를 유지하고, FlexBox가 나머지를 자동 정렬 - const groupedButtons = selectedComponents.map((button) => { - const currentConfig = (button as any).webTypeConfig?.flowVisibilityConfig || {}; - - // 모든 버튼을 그룹 시작점에 배치 - // FlexBox가 자동으로 정렬하여 기준 버튼의 위치가 유지됨 - const newPosition = { - x: groupX, - y: groupY, - z: button.position.z || 1, - }; - - return { - ...button, - position: newPosition, - webTypeConfig: { - ...(button as any).webTypeConfig, - flowVisibilityConfig: { - ...currentConfig, - enabled: true, - layoutBehavior: "auto-compact", - groupId: newGroupId, - groupDirection: settings.direction, - groupGap: settings.gap, - groupAlign: settings.align, - }, - }, - }; - }); - - // 레이아웃 업데이트 - const updatedComponents = layout.components.map((comp) => { - const grouped = groupedButtons.find((gb) => gb.id === comp.id); - return grouped || comp; - }); - - const newLayout = { - ...layout, - components: updatedComponents, - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - toast.success(`${selectedComponents.length}개의 버튼이 플로우 그룹으로 묶였습니다`, { - description: `그룹 ID: ${newGroupId} / ${settings.direction === "horizontal" ? "가로" : "세로"} / ${settings.gap}px 간격`, - }); - - console.log("✅ 플로우 버튼 그룹 생성 완료:", { - groupId: newGroupId, - buttonCount: selectedComponents.length, - buttons: selectedComponents.map((b) => ({ id: b.id, position: b.position })), - groupPosition: { x: groupX, y: groupY }, - settings, - }); - }, - [layout, groupState.selectedComponents, saveToHistory], - ); - - // 🆕 플로우 버튼 그룹 해제 - const handleFlowButtonUngroup = useCallback(() => { - const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); - - if (selectedComponents.length === 0) { - toast.error("그룹 해제할 버튼을 선택해주세요"); - return; - } - - // 버튼이 아닌 것 필터링 - const buttons = selectedComponents.filter((comp) => areAllButtons([comp])); - - if (buttons.length === 0) { - toast.error("선택된 버튼 중 그룹화된 버튼이 없습니다"); - return; - } - - // 그룹 해제 - const ungroupedButtons = ungroupButtons(buttons); - - // 레이아웃 업데이트 + 플로우 표시 제어 초기화 - const updatedComponents = layout.components.map((comp, index) => { - const ungrouped = ungroupedButtons.find((ub) => ub.id === comp.id); - - if (ungrouped) { - // 원래 위치 복원 또는 현재 위치 유지 + 간격 추가 - const buttonIndex = buttons.findIndex((b) => b.id === comp.id); - const basePosition = comp.position; - - // 버튼들을 오른쪽으로 조금씩 이동 (겹치지 않도록) - const offsetX = buttonIndex * 120; // 각 버튼당 120px 간격 - - // 그룹 해제된 버튼의 플로우 표시 제어를 끄고 설정 초기화 - return { - ...ungrouped, - position: { - x: basePosition.x + offsetX, - y: basePosition.y, - z: basePosition.z || 1, - }, - webTypeConfig: { - ...ungrouped.webTypeConfig, - flowVisibilityConfig: { - enabled: false, - targetFlowComponentId: null, - mode: "whitelist", - visibleSteps: [], - hiddenSteps: [], - layoutBehavior: "auto-compact", - groupId: null, - groupDirection: "horizontal", - groupGap: 8, - groupAlign: "start", - }, - }, - }; - } - - return comp; - }); - - const newLayout = { - ...layout, - components: updatedComponents, - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - toast.success(`${buttons.length}개의 버튼 그룹이 해제되고 플로우 표시 제어가 비활성화되었습니다`); - }, [layout, groupState.selectedComponents, saveToHistory]); - - // 그룹 생성 (임시 비활성화) - const handleGroupCreate = useCallback( - (componentIds: string[], title: string, style?: any) => { - // console.log("그룹 생성 기능이 임시 비활성화되었습니다."); - toast.info("그룹 기능이 임시 비활성화되었습니다."); - return; - - // 격자 정보 계산 - const currentGridInfo = - gridInfo || - calculateGridInfo( - 1200, - 800, - layout.gridSettings || { - columns: 12, - gap: 16, - padding: 0, - snapToGrid: true, - showGrid: false, - gridColor: "#d1d5db", - gridOpacity: 0.5, - }, - ); - - console.log("🔧 그룹 생성 시작:", { - selectedCount: selectedComponents.length, - snapToGrid: true, - }); - - // 컴포넌트 크기 조정 기반 그룹 크기 계산 - const calculateOptimalGroupSize = () => { - if (!currentGridInfo || !layout.gridSettings?.snapToGrid) { - // 격자 스냅이 비활성화된 경우 기본 패딩 사용 - const boundingBox = calculateBoundingBox(selectedComponents); - const padding = 40; - return { - boundingBox, - groupPosition: { x: boundingBox.minX - padding, y: boundingBox.minY - padding, z: 1 }, - groupSize: { width: boundingBox.width + padding * 2, height: boundingBox.height + padding * 2 }, - gridColumns: 1, - scaledComponents: selectedComponents, // 크기 조정 없음 - padding: padding, - }; - } - - const { columnWidth } = currentGridInfo; - const gap = layout.gridSettings?.gap || 16; - const contentBoundingBox = calculateBoundingBox(selectedComponents); - - // 1. 간단한 접근: 컴포넌트들의 시작점에서 가장 가까운 격자 시작점 찾기 - const startColumn = Math.floor(contentBoundingBox.minX / (columnWidth + gap)); - - // 2. 컴포넌트들의 끝점까지 포함할 수 있는 컬럼 수 계산 - const groupStartX = startColumn * (columnWidth + gap); - const availableWidthFromStart = contentBoundingBox.maxX - groupStartX; - const currentWidthInColumns = Math.ceil(availableWidthFromStart / (columnWidth + gap)); - - // 2. 그룹은 격자에 정확히 맞게 위치와 크기 설정 - const padding = 20; - const groupX = startColumn * (columnWidth + gap); // 격자 시작점에 정확히 맞춤 - const groupY = contentBoundingBox.minY - padding; - const groupWidth = currentWidthInColumns * columnWidth + (currentWidthInColumns - 1) * gap; // 컬럼 크기 + gap - const groupHeight = contentBoundingBox.height + padding * 2; - - // 4. 내부 컴포넌트들을 그룹 크기에 맞게 스케일링 - const availableWidth = groupWidth - padding * 2; // 패딩 제외한 실제 사용 가능 너비 - const scaleFactorX = availableWidth / contentBoundingBox.width; - - const scaledComponents = selectedComponents.map((comp) => { - // 컴포넌트의 원래 위치에서 컨텐츠 영역 시작점까지의 상대 위치 계산 - const relativeX = comp.position.x - contentBoundingBox.minX; - const relativeY = comp.position.y - contentBoundingBox.minY; - - return { - ...comp, - position: { - x: padding + relativeX * scaleFactorX, // 패딩 + 스케일된 상대 위치 - y: padding + relativeY, // Y는 스케일링 없이 패딩만 적용 - z: comp.position.z || 1, - }, - size: { - width: comp.size.width * scaleFactorX, // X 방향 스케일링 - height: comp.size.height, // Y는 원본 크기 유지 - }, - }; - }); - - console.log("🎯 컴포넌트 크기 조정 기반 그룹 생성:", { - originalBoundingBox: contentBoundingBox, - gridCalculation: { - columnWidthPlusGap: columnWidth + gap, - startColumn: `Math.floor(${contentBoundingBox.minX} / ${columnWidth + gap}) = ${startColumn}`, - groupStartX: `${startColumn} * ${columnWidth + gap} = ${groupStartX}`, - availableWidthFromStart: `${contentBoundingBox.maxX} - ${groupStartX} = ${availableWidthFromStart}`, - currentWidthInColumns: `Math.ceil(${availableWidthFromStart} / ${columnWidth + gap}) = ${currentWidthInColumns}`, - finalGroupX: `${startColumn} * ${columnWidth + gap} = ${groupX}`, - actualGroupWidth: `${currentWidthInColumns}컬럼 * ${columnWidth}px + ${currentWidthInColumns - 1}gap * ${gap}px = ${groupWidth}px`, - }, - groupPosition: { x: groupX, y: groupY }, - groupSize: { width: groupWidth, height: groupHeight }, - scaleFactorX, - availableWidth, - padding, - scaledComponentsCount: scaledComponents.length, - scaledComponentsDetails: scaledComponents.map((comp) => { - const original = selectedComponents.find((c) => c.id === comp.id); - return { - id: comp.id, - originalPos: original?.position, - scaledPos: comp.position, - originalSize: original?.size, - scaledSize: comp.size, - deltaX: comp.position.x - (original?.position.x || 0), - deltaY: comp.position.y - (original?.position.y || 0), - }; - }), - }); - - return { - boundingBox: contentBoundingBox, - groupPosition: { x: groupX, y: groupY, z: 1 }, - groupSize: { width: groupWidth, height: groupHeight }, - gridColumns: currentWidthInColumns, - scaledComponents: scaledComponents, // 스케일된 컴포넌트들 - padding: padding, - }; - }; - - const { - boundingBox, - groupPosition, - groupSize: optimizedGroupSize, - gridColumns, - scaledComponents, - padding, - } = calculateOptimalGroupSize(); - - // 스케일된 컴포넌트들로 상대 위치 계산 (이미 최적화되어 추가 격자 정렬 불필요) - const relativeChildren = calculateRelativePositions( - scaledComponents, - groupPosition, - "temp", // 임시 그룹 ID - ); - - console.log("📏 최적화된 그룹 생성 (컴포넌트 스케일링):", { - gridColumns, - groupSize: optimizedGroupSize, - groupPosition, - scaledComponentsCount: scaledComponents.length, - padding, - strategy: "내부 컴포넌트 크기 조정으로 격자 정확 맞춤", - }); - - // 그룹 컴포넌트 생성 (gridColumns 속성 포함) - const groupComponent = createGroupComponent(componentIds, title, groupPosition, optimizedGroupSize, style); - - // 그룹에 계산된 gridColumns 속성 추가 - groupComponent.gridColumns = gridColumns; - - // 실제 그룹 ID로 자식들 업데이트 - const finalChildren = relativeChildren.map((child) => ({ - ...child, - parentId: groupComponent.id, - })); - - const newLayout = { - ...layout, - components: [ - ...layout.components.filter((comp) => !componentIds.includes(comp.id)), - groupComponent, - ...finalChildren, - ], - }; - - setLayout(newLayout); - saveToHistory(newLayout); - setGroupState((prev) => ({ - ...prev, - selectedComponents: [groupComponent.id], - isGrouping: false, - })); - setSelectedComponent(groupComponent); - - console.log("🎯 최적화된 그룹 생성 완료:", { - groupId: groupComponent.id, - childrenCount: finalChildren.length, - position: groupPosition, - size: optimizedGroupSize, - gridColumns: groupComponent.gridColumns, - componentsScaled: !!scaledComponents.length, - gridAligned: true, - }); - - toast.success(`그룹이 생성되었습니다 (${finalChildren.length}개 컴포넌트)`); - }, - [layout, saveToHistory], - ); - - // 그룹 생성 함수 (다이얼로그 표시) - const createGroup = useCallback(() => { - if (groupState.selectedComponents.length < 2) { - toast.warning("그룹을 만들려면 2개 이상의 컴포넌트를 선택해야 합니다."); - return; - } - - // console.log("🔄 그룹 생성 다이얼로그 표시"); - setShowGroupCreateDialog(true); - }, [groupState.selectedComponents]); - - // 그룹 해제 함수 (임시 비활성화) - const ungroupComponents = useCallback(() => { - // console.log("그룹 해제 기능이 임시 비활성화되었습니다."); - toast.info("그룹 해제 기능이 임시 비활성화되었습니다."); - return; - - const groupId = selectedComponent.id; - - // 자식 컴포넌트들의 절대 위치 복원 - const childComponents = layout.components.filter((comp) => comp.parentId === groupId); - const restoredChildren = restoreAbsolutePositions(childComponents, selectedComponent.position); - - // 자식 컴포넌트들의 위치 복원 및 parentId 제거 - const updatedComponents = layout.components - .map((comp) => { - if (comp.parentId === groupId) { - const restoredChild = restoredChildren.find((restored) => restored.id === comp.id); - return restoredChild || { ...comp, parentId: undefined }; - } - return comp; - }) - .filter((comp) => comp.id !== groupId); // 그룹 컴포넌트 제거 - - const newLayout = { ...layout, components: updatedComponents }; - setLayout(newLayout); - saveToHistory(newLayout); - - // 선택 상태 초기화 - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [] })); - }, [selectedComponent, layout, saveToHistory]); - - // 마우스 이벤트 처리 (드래그 및 선택) - 성능 최적화 - useEffect(() => { - let animationFrameId: number; - - const handleMouseMove = (e: MouseEvent) => { - if (dragState.isDragging) { - // requestAnimationFrame으로 부드러운 애니메이션 - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - animationFrameId = requestAnimationFrame(() => { - updateDragPosition(e); - }); - } else if (selectionDrag.isSelecting) { - updateSelectionDrag(e); - } - }; - - const handleMouseUp = (e: MouseEvent) => { - if (dragState.isDragging) { - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - endDrag(e); - } else if (selectionDrag.isSelecting) { - endSelectionDrag(); - } - }; - - if (dragState.isDragging || selectionDrag.isSelecting) { - document.addEventListener("mousemove", handleMouseMove, { passive: true }); - document.addEventListener("mouseup", handleMouseUp); - - return () => { - if (animationFrameId) { - cancelAnimationFrame(animationFrameId); - } - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - } - }, [ - dragState.isDragging, - selectionDrag.isSelecting, - updateDragPosition, - endDrag, - updateSelectionDrag, - endSelectionDrag, - ]); - - // 키보드 이벤트 처리 (브라우저 기본 기능 완전 차단) - useEffect(() => { - const handleKeyDown = async (e: KeyboardEvent) => { - // console.log("🎯 키 입력 감지:", { key: e.key, ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, metaKey: e.metaKey }); - - // 🚫 브라우저 기본 단축키 완전 차단 목록 - const browserShortcuts = [ - // 검색 관련 - { ctrl: true, key: "f" }, // 페이지 내 검색 - { ctrl: true, key: "g" }, // 다음 검색 결과 - { ctrl: true, shift: true, key: "g" }, // 이전 검색 결과 - { ctrl: true, key: "h" }, // 검색 기록 - - // 탭/창 관리 - { ctrl: true, key: "t" }, // 새 탭 - { ctrl: true, key: "w" }, // 탭 닫기 - { ctrl: true, shift: true, key: "t" }, // 닫힌 탭 복원 - { ctrl: true, key: "n" }, // 새 창 - { ctrl: true, shift: true, key: "n" }, // 시크릿 창 - - // 페이지 관리 - { ctrl: true, key: "r" }, // 새로고침 - { ctrl: true, shift: true, key: "r" }, // 강제 새로고침 - { ctrl: true, key: "d" }, // 북마크 추가 - { ctrl: true, shift: true, key: "d" }, // 모든 탭 북마크 - - // 편집 관련 (필요시에만 허용) - { ctrl: true, key: "s" }, // 저장 (필요시 차단 해제) - { ctrl: true, key: "p" }, // 인쇄 - { ctrl: true, key: "o" }, // 파일 열기 - { ctrl: true, key: "v" }, // 붙여넣기 (브라우저 기본 동작 차단) - - // 개발자 도구 - { key: "F12" }, // 개발자 도구 - { ctrl: true, shift: true, key: "i" }, // 개발자 도구 - { ctrl: true, shift: true, key: "c" }, // 요소 검사 - { ctrl: true, shift: true, key: "j" }, // 콘솔 - { ctrl: true, key: "u" }, // 소스 보기 - - // 기타 - { ctrl: true, key: "j" }, // 다운로드 - { ctrl: true, shift: true, key: "delete" }, // 브라우징 데이터 삭제 - { ctrl: true, key: "+" }, // 확대 - { ctrl: true, key: "-" }, // 축소 - { ctrl: true, key: "0" }, // 확대/축소 초기화 - ]; - - // 브라우저 기본 단축키 체크 및 차단 - const isBrowserShortcut = browserShortcuts.some((shortcut) => { - const ctrlMatch = shortcut.ctrl ? e.ctrlKey || e.metaKey : true; - const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey; - const keyMatch = e.key?.toLowerCase() === shortcut.key?.toLowerCase(); - return ctrlMatch && shiftMatch && keyMatch; - }); - - if (isBrowserShortcut) { - // console.log("🚫 브라우저 기본 단축키 차단:", e.key); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - } - - // ✅ 애플리케이션 전용 단축키 처리 - - // 1. 그룹 관련 단축키 - if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "g" && !e.shiftKey) { - // console.log("🔄 그룹 생성 단축키"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - - if (groupState.selectedComponents.length >= 2) { - // console.log("✅ 그룹 생성 실행"); - createGroup(); - } else { - // console.log("⚠️ 선택된 컴포넌트가 부족함 (2개 이상 필요)"); - } - return false; - } - - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "g") { - // console.log("🔄 그룹 해제 단축키"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - - if (selectedComponent && selectedComponent.type === "group") { - // console.log("✅ 그룹 해제 실행"); - ungroupComponents(); - } else { - // console.log("⚠️ 선택된 그룹이 없음"); - } - return false; - } - - // 2. 전체 선택 (애플리케이션 내에서만) - if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "a") { - // console.log("🔄 전체 선택 (애플리케이션 내)"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - const allComponentIds = layout.components.map((comp) => comp.id); - setGroupState((prev) => ({ ...prev, selectedComponents: allComponentIds })); - return false; - } - - // 3. 실행취소/다시실행 - if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "z" && !e.shiftKey) { - // console.log("🔄 실행취소"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - undo(); - return false; - } - - if ( - ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "y") || - ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key?.toLowerCase() === "z") - ) { - // console.log("🔄 다시실행"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - redo(); - return false; - } - - // 4. 복사 (컴포넌트 복사) - if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "c") { - // console.log("🔄 컴포넌트 복사"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - copyComponent(); - return false; - } - - // 5. 붙여넣기 (컴포넌트 붙여넣기) - if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "v") { - // console.log("🔄 컴포넌트 붙여넣기"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - pasteComponent(); - return false; - } - - // 6. 삭제 (단일/다중 선택 지원) - if (e.key === "Delete" && (selectedComponent || groupState.selectedComponents.length > 0)) { - // console.log("🗑️ 컴포넌트 삭제 (단축키)"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - deleteComponent(); - return false; - } - - // 7. 선택 해제 - if (e.key === "Escape") { - // console.log("🔄 선택 해제"); - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [], isGrouping: false })); - return false; - } - - // 8. 저장 (Ctrl+S는 레이아웃 저장용으로 사용) - if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "s") { - // console.log("💾 레이아웃 저장"); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - - // 레이아웃 저장 실행 - if (layout.components.length > 0 && selectedScreen?.screenId) { - setIsSaving(true); - try { - // 해상도 정보를 포함한 레이아웃 데이터 생성 - const layoutWithResolution = { - ...layout, - screenResolution: screenResolution, - }; - console.log("⚡ 자동 저장할 레이아웃 데이터:", { - componentsCount: layoutWithResolution.components.length, - gridSettings: layoutWithResolution.gridSettings, - screenResolution: layoutWithResolution.screenResolution, - }); - // V2 API 사용 여부에 따라 분기 - if (USE_V2_API) { - const v2Layout = convertLegacyToV2(layoutWithResolution); - await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); - } else { - await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); - } - toast.success("레이아웃이 저장되었습니다."); - } catch (error) { - // console.error("레이아웃 저장 실패:", error); - toast.error("레이아웃 저장에 실패했습니다."); - } finally { - setIsSaving(false); - } - } else { - // console.log("⚠️ 저장할 컴포넌트가 없습니다"); - toast.warning("저장할 컴포넌트가 없습니다."); - } - return false; - } - - // === 9. 화살표 키 Nudge (컴포넌트 미세 이동) === - if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { - // 입력 필드에서는 무시 - const active = document.activeElement; - if ( - active instanceof HTMLInputElement || - active instanceof HTMLTextAreaElement || - active?.getAttribute("contenteditable") === "true" - ) { - return; - } - - if (selectedComponent || groupState.selectedComponents.length > 0) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - const distance = e.shiftKey ? 10 : 1; // Shift 누르면 10px - const dirMap: Record = { - ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", - }; - handleNudge(dirMap[e.key], distance); - return false; - } - } - - // === 10. 정렬 단축키 (Alt + 키) - 다중 선택 시 === - if (e.altKey && !e.ctrlKey && !e.metaKey) { - const alignKey = e.key?.toLowerCase(); - const alignMap: Record = { - l: "left", r: "right", c: "centerX", - t: "top", b: "bottom", m: "centerY", - }; - - if (alignMap[alignKey] && groupState.selectedComponents.length >= 2) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - handleGroupAlign(alignMap[alignKey]); - return false; - } - - // 균등 배분 (Alt+H: 가로, Alt+V: 세로) - if (alignKey === "h" && groupState.selectedComponents.length >= 3) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - handleGroupDistribute("horizontal"); - return false; - } - if (alignKey === "v" && groupState.selectedComponents.length >= 3) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - handleGroupDistribute("vertical"); - return false; - } - - // 동일 크기 맞추기 (Alt+W: 너비, Alt+E: 높이) - if (alignKey === "w" && groupState.selectedComponents.length >= 2) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - handleMatchSize("width"); - return false; - } - if (alignKey === "e" && groupState.selectedComponents.length >= 2) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - handleMatchSize("height"); - return false; - } - } - - // === 11. 라벨 일괄 토글 (Alt+Shift+L) === - if (e.altKey && e.shiftKey && e.key?.toLowerCase() === "l") { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - handleToggleAllLabels(); - return false; - } - - // === 12. 단축키 도움말 (? 키) === - if (e.key === "?" && !e.ctrlKey && !e.metaKey && !e.altKey) { - // 입력 필드에서는 무시 - const active = document.activeElement; - if ( - active instanceof HTMLInputElement || - active instanceof HTMLTextAreaElement || - active?.getAttribute("contenteditable") === "true" - ) { - return; - } - e.preventDefault(); - setShowShortcutsModal(true); - return false; - } - }; - - // window 레벨에서 캡처 단계에서 가장 먼저 처리 - window.addEventListener("keydown", handleKeyDown, { capture: true, passive: false }); - return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); - }, [ - selectedComponent, - deleteComponent, - copyComponent, - pasteComponent, - undo, - redo, - createGroup, - ungroupComponents, - groupState.selectedComponents, - layout, - selectedScreen, - handleNudge, - handleGroupAlign, - handleGroupDistribute, - handleMatchSize, - handleToggleAllLabels, - ]); - - // 플로우 위젯 높이 자동 업데이트 이벤트 리스너 - useEffect(() => { - const handleComponentSizeUpdate = (event: CustomEvent) => { - const { componentId, height } = event.detail; - - // 해당 컴포넌트 찾기 - const targetComponent = layout.components.find((c) => c.id === componentId); - if (!targetComponent) { - return; - } - - // 이미 같은 높이면 업데이트 안함 - if (targetComponent.size?.height === height) { - return; - } - - // 컴포넌트 높이 업데이트 - const updatedComponents = layout.components.map((comp) => { - if (comp.id === componentId) { - return { - ...comp, - size: { - ...comp.size, - width: comp.size?.width || 100, - height: height, - }, - }; - } - return comp; - }); - - const newLayout = { - ...layout, - components: updatedComponents, - }; - - setLayout(newLayout); - - // 선택된 컴포넌트도 업데이트 - if (selectedComponent?.id === componentId) { - const updatedComponent = updatedComponents.find((c) => c.id === componentId); - if (updatedComponent) { - setSelectedComponent(updatedComponent); - } - } - }; - - window.addEventListener("updateComponentSize", handleComponentSizeUpdate as EventListener); - return () => { - window.removeEventListener("updateComponentSize", handleComponentSizeUpdate as EventListener); - }; - }, [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 ( -
-
-
- -
-

화면을 선택하세요

-

설계할 화면을 먼저 선택해주세요.

-
-
- ); - } - - // 🔧 ScreenDesigner 렌더링 확인 (디버그 완료 - 주석 처리) - // console.log("🏠 ScreenDesigner 렌더!", Date.now()); - - return ( - - - -
- {/* 상단 슬림 툴바 */} - setShowMultilangSettingsModal(true)} - isPanelOpen={panelStates.v2?.isOpen || false} - onTogglePanel={() => togglePanel("v2")} - selectedCount={groupState.selectedComponents.length} - onAlign={handleGroupAlign} - onDistribute={handleGroupDistribute} - onMatchSize={handleMatchSize} - onToggleLabels={handleToggleAllLabels} - onShowShortcuts={() => setShowShortcutsModal(true)} - /> - {/* 메인 컨테이너 (패널들 + 캔버스) */} -
- {/* 통합 패널 - 좌측 사이드바 제거 후 너비 300px로 확장 */} - {panelStates.v2?.isOpen && ( -
-
-

패널

- -
-
- - - - 컴포넌트 - - - 레이어 - - - 편집 - - - - - { - const dragData = { - type: column ? "column" : "table", - table, - column, - }; - e.dataTransfer.setData("application/json", JSON.stringify(dragData)); - }} - selectedTableName={selectedScreen?.tableName} - placedColumns={placedColumns} - onTableSelect={handleTableSelect} - showTableSelector={true} - /> - - - {/* 🆕 레이어 관리 탭 */} - - - - - - {/* 탭 내부 컴포넌트 선택 시에도 V2PropertiesPanel 사용 */} - {selectedTabComponentInfo ? ( - (() => { - const tabComp = selectedTabComponentInfo.component; - - // 탭 내부 컴포넌트를 ComponentData 형식으로 변환 - const tabComponentAsComponentData: ComponentData = { - id: tabComp.id, - type: "component", - componentType: tabComp.componentType, - label: tabComp.label, - position: tabComp.position || { x: 0, y: 0 }, - size: tabComp.size || { width: 200, height: 100 }, - componentConfig: tabComp.componentConfig || {}, - style: tabComp.style || {}, - } as ComponentData; - - // 탭 내부 컴포넌트용 속성 업데이트 핸들러 (중첩 구조 지원) - const updateTabComponentProperty = (componentId: string, path: string, value: any) => { - const { tabsComponentId, tabId, parentSplitPanelId, parentPanelSide } = - selectedTabComponentInfo; - - console.log("🔧 updateTabComponentProperty 호출:", { - componentId, - path, - value, - parentSplitPanelId, - parentPanelSide, - }); - - // 🆕 안전한 깊은 경로 업데이트 헬퍼 함수 - const setNestedValue = (obj: any, pathStr: string, val: any): any => { - // 깊은 복사로 시작 - const result = JSON.parse(JSON.stringify(obj)); - const parts = pathStr.split("."); - let current = result; - - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i]; - if (!current[part] || typeof current[part] !== "object") { - current[part] = {}; - } - current = current[part]; - } - current[parts[parts.length - 1]] = val; - return result; - }; - - // 탭 컴포넌트 업데이트 함수 - const updateTabsComponent = (tabsComponent: any) => { - const currentConfig = JSON.parse(JSON.stringify(tabsComponent.componentConfig || {})); - const tabs = currentConfig.tabs || []; - - const updatedTabs = tabs.map((tab: any) => { - if (tab.id === tabId) { - return { - ...tab, - components: (tab.components || []).map((comp: any) => { - if (comp.id !== componentId) return comp; - - // 🆕 안전한 깊은 경로 업데이트 사용 - const updatedComp = setNestedValue(comp, path, value); - console.log("🔧 컴포넌트 업데이트 결과:", updatedComp); - return updatedComp; - }), - }; - } - return tab; - }); - - return { - ...tabsComponent, - componentConfig: { ...currentConfig, tabs: updatedTabs }, - }; - }; - - setLayout((prevLayout) => { - let newLayout; - let updatedTabs; - - if (parentSplitPanelId && parentPanelSide) { - // 🆕 중첩 구조: 분할 패널 안의 탭 업데이트 - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => { - if (c.id === parentSplitPanelId) { - const splitConfig = (c as any).componentConfig || {}; - const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = splitConfig[panelKey] || {}; - const panelComponents = panelConfig.components || []; - - const tabsComponent = panelComponents.find( - (pc: any) => pc.id === tabsComponentId, - ); - if (!tabsComponent) return c; - - const updatedTabsComponent = updateTabsComponent(tabsComponent); - updatedTabs = updatedTabsComponent.componentConfig.tabs; - - return { - ...c, - componentConfig: { - ...splitConfig, - [panelKey]: { - ...panelConfig, - components: panelComponents.map((pc: any) => - pc.id === tabsComponentId ? updatedTabsComponent : pc, - ), - }, - }, - }; - } - return c; - }), - }; - } else { - // 일반 구조: 최상위 탭 업데이트 - const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId); - if (!tabsComponent) return prevLayout; - - const updatedTabsComponent = updateTabsComponent(tabsComponent); - updatedTabs = updatedTabsComponent.componentConfig.tabs; - - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => - c.id === tabsComponentId ? updatedTabsComponent : c, - ), - }; - } - - // 선택된 컴포넌트 정보 업데이트 - if (updatedTabs) { - const updatedComp = updatedTabs - .find((t: any) => t.id === tabId) - ?.components?.find((c: any) => c.id === componentId); - if (updatedComp) { - setSelectedTabComponentInfo((prev) => - prev ? { ...prev, component: updatedComp } : null, - ); - } - } - - return newLayout; - }); - }; - - // 탭 내부 컴포넌트 삭제 핸들러 (중첩 구조 지원) - const deleteTabComponent = (componentId: string) => { - const { tabsComponentId, tabId, parentSplitPanelId, parentPanelSide } = - selectedTabComponentInfo; - - // 탭 컴포넌트에서 특정 컴포넌트 삭제 - const updateTabsComponentForDelete = (tabsComponent: any) => { - const currentConfig = tabsComponent.componentConfig || {}; - const tabs = currentConfig.tabs || []; - - const updatedTabs = tabs.map((tab: any) => { - if (tab.id === tabId) { - return { - ...tab, - components: (tab.components || []).filter((c: any) => c.id !== componentId), - }; - } - return tab; - }); - - return { - ...tabsComponent, - componentConfig: { ...currentConfig, tabs: updatedTabs }, - }; - }; - - setLayout((prevLayout) => { - let newLayout; - - if (parentSplitPanelId && parentPanelSide) { - // 🆕 중첩 구조: 분할 패널 안의 탭에서 삭제 - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => { - if (c.id === parentSplitPanelId) { - const splitConfig = (c as any).componentConfig || {}; - const panelKey = parentPanelSide === "left" ? "leftPanel" : "rightPanel"; - const panelConfig = splitConfig[panelKey] || {}; - const panelComponents = panelConfig.components || []; - - const tabsComponent = panelComponents.find( - (pc: any) => pc.id === tabsComponentId, - ); - if (!tabsComponent) return c; - - const updatedTabsComponent = updateTabsComponentForDelete(tabsComponent); - - return { - ...c, - componentConfig: { - ...splitConfig, - [panelKey]: { - ...panelConfig, - components: panelComponents.map((pc: any) => - pc.id === tabsComponentId ? updatedTabsComponent : pc, - ), - }, - }, - }; - } - return c; - }), - }; - } else { - // 일반 구조: 최상위 탭에서 삭제 - const tabsComponent = prevLayout.components.find((c) => c.id === tabsComponentId); - if (!tabsComponent) return prevLayout; - - const updatedTabsComponent = updateTabsComponentForDelete(tabsComponent); - - newLayout = { - ...prevLayout, - components: prevLayout.components.map((c) => - c.id === tabsComponentId ? updatedTabsComponent : c, - ), - }; - } - - setSelectedTabComponentInfo(null); - return newLayout; - }); - }; - - return ( -
-
- 탭 내부 컴포넌트 - -
-
- 0 ? tables[0] : undefined} - currentTableName={selectedScreen?.tableName} - currentScreenCompanyCode={selectedScreen?.companyCode} - onStyleChange={(style) => { - updateTabComponentProperty(tabComp.id, "style", style); - }} - allComponents={layout.components} - menuObjid={menuObjid} - /> -
-
- ); - })() - ) : selectedPanelComponentInfo ? ( - // 🆕 분할 패널 내부 컴포넌트 선택 시 V2PropertiesPanel 사용 - (() => { - const panelComp = selectedPanelComponentInfo.component; - - // 분할 패널 내부 컴포넌트를 ComponentData 형식으로 변환 - const panelComponentAsComponentData: ComponentData = { - id: panelComp.id, - type: "component", - componentType: panelComp.componentType, - label: panelComp.label, - position: panelComp.position || { x: 0, y: 0 }, - size: panelComp.size || { width: 200, height: 100 }, - componentConfig: panelComp.componentConfig || {}, - style: panelComp.style || {}, - } as ComponentData; - - // 분할 패널 내부 컴포넌트용 속성 업데이트 핸들러 - const updatePanelComponentProperty = (componentId: string, path: string, value: any) => { - const { splitPanelId, panelSide } = selectedPanelComponentInfo; - const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; - - console.log("🔧 updatePanelComponentProperty 호출:", { - componentId, - path, - value, - splitPanelId, - panelSide, - }); - - // 🆕 안전한 깊은 경로 업데이트 헬퍼 함수 - const setNestedValue = (obj: any, pathStr: string, val: any): any => { - const result = JSON.parse(JSON.stringify(obj)); - const parts = pathStr.split("."); - let current = result; - - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i]; - if (!current[part] || typeof current[part] !== "object") { - current[part] = {}; - } - current = current[part]; - } - current[parts[parts.length - 1]] = val; - return result; - }; - - setLayout((prevLayout) => { - const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId); - if (!splitPanelComponent) return prevLayout; - - const currentConfig = (splitPanelComponent as any).componentConfig || {}; - const panelConfig = currentConfig[panelKey] || {}; - const components = panelConfig.components || []; - - // 해당 컴포넌트 찾기 - const targetCompIndex = components.findIndex((c: any) => c.id === componentId); - if (targetCompIndex === -1) return prevLayout; - - // 🆕 안전한 깊은 경로 업데이트 사용 - const targetComp = components[targetCompIndex]; - const updatedComp = - path === "style" - ? { ...targetComp, style: value } - : setNestedValue(targetComp, path, value); - - console.log("🔧 분할 패널 컴포넌트 업데이트 결과:", updatedComp); - - const updatedComponents = [ - ...components.slice(0, targetCompIndex), - updatedComp, - ...components.slice(targetCompIndex + 1), - ]; - - const updatedComponent = { - ...splitPanelComponent, - componentConfig: { - ...currentConfig, - [panelKey]: { - ...panelConfig, - components: updatedComponents, - }, - }, - }; - - // selectedPanelComponentInfo 업데이트 - setSelectedPanelComponentInfo((prev) => - prev ? { ...prev, component: updatedComp } : null, - ); - - return { - ...prevLayout, - components: prevLayout.components.map((c) => - c.id === splitPanelId ? updatedComponent : c, - ), - }; - }); - }; - - // 분할 패널 내부 컴포넌트 삭제 핸들러 - const deletePanelComponent = (componentId: string) => { - const { splitPanelId, panelSide } = selectedPanelComponentInfo; - const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel"; - - setLayout((prevLayout) => { - const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId); - if (!splitPanelComponent) return prevLayout; - - const currentConfig = (splitPanelComponent as any).componentConfig || {}; - const panelConfig = currentConfig[panelKey] || {}; - const components = panelConfig.components || []; - - const updatedComponents = components.filter((c: any) => c.id !== componentId); - - const updatedComponent = { - ...splitPanelComponent, - componentConfig: { - ...currentConfig, - [panelKey]: { - ...panelConfig, - components: updatedComponents, - }, - }, - }; - - setSelectedPanelComponentInfo(null); - - return { - ...prevLayout, - components: prevLayout.components.map((c) => - c.id === splitPanelId ? updatedComponent : c, - ), - }; - }); - }; - - return ( -
-
- - 분할 패널 ({selectedPanelComponentInfo.panelSide === "left" ? "좌측" : "우측"}) - 컴포넌트 - - -
-
- 0 ? tables[0] : undefined} - currentTableName={selectedScreen?.tableName} - currentScreenCompanyCode={selectedScreen?.companyCode} - onStyleChange={(style) => { - updatePanelComponentProperty(panelComp.id, "style", style); - }} - allComponents={layout.components} - menuObjid={menuObjid} - /> -
-
- ); - })() - ) : ( - 0 ? tables[0] : undefined} - currentTableName={selectedScreen?.tableName} - currentScreenCompanyCode={selectedScreen?.companyCode} - dragState={dragState} - onStyleChange={(style) => { - if (selectedComponent) { - updateComponentProperty(selectedComponent.id, "style", style); - } - }} - allComponents={layout.components} // 🆕 플로우 위젯 감지용 - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 - /> - )} -
-
-
-
- )} - - {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - GPU 가속 스크롤 적용 */} -
- {/* Pan 모드 안내 - 제거됨 */} - {/* 줌 레벨 표시 */} -
- 🔍 {Math.round(zoomLevel * 100)}% -
- {/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */} - {(() => { - // 선택된 컴포넌트들 - const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id)); - - // 버튼 컴포넌트만 필터링 - const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp])); - - // 플로우 그룹에 속한 버튼이 있는지 확인 - const hasFlowGroupButton = selectedButtons.some((btn) => { - const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig; - return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId; - }); - - // 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시 - const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton); - - if (!shouldShow) return null; - - return ( -
-
-
- - - - - - {selectedButtons.length}개 버튼 선택됨 -
- - {/* 그룹 생성 버튼 (2개 이상 선택 시) */} - {selectedButtons.length >= 2 && ( - - )} - - {/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */} - {hasFlowGroupButton && ( - - )} - - {/* 상태 표시 */} - {hasFlowGroupButton &&

✓ 플로우 그룹 버튼

} -
-
- ); - })()} - {/* 🆕 활성 레이어 인디케이터 (기본 레이어가 아닌 경우 표시) */} - {activeLayerId > 1 && ( -
-
- 레이어 {activeLayerId} 편집 중 -
- )} - - {/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */} - {(() => { - // 🆕 조건부 레이어 편집 시 캔버스 크기를 displayRegion에 맞춤 - const activeRegion = activeLayerId > 1 ? layerRegions[activeLayerId] : null; - const canvasW = activeRegion ? activeRegion.width : screenResolution.width; - const canvasH = activeRegion ? activeRegion.height : screenResolution.height; - - return ( -
- {/* 실제 작업 캔버스 (해상도 크기 또는 조건부 레이어 영역 크기) */} -
-
{ - if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) { - setSelectedComponent(null); - setGroupState((prev) => ({ ...prev, selectedComponents: [] })); - } - }} - onMouseDown={(e) => { - // Pan 모드가 아닐 때만 다중 선택 시작 - if (e.target === e.currentTarget && !isPanMode) { - startSelectionDrag(e); - } - }} - onDragOver={(e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - }} - onDropCapture={(e) => { - // 캡처 단계에서 드롭 이벤트를 처리하여 자식 요소 드롭도 감지 - e.preventDefault(); - handleDrop(e); - }} - > - {/* 격자 라인 */} - {gridLines.map((line, index) => ( -
- ))} - - {/* 컴포넌트들 */} - {(() => { - // 🆕 플로우 버튼 그룹 감지 및 처리 - // visibleComponents를 사용하여 활성 레이어의 컴포넌트만 표시 - const topLevelComponents = visibleComponents.filter((component) => !component.parentId); - - // auto-compact 모드의 버튼들을 그룹별로 묶기 - const buttonGroups: Record = {}; - const processedButtonIds = new Set(); - - topLevelComponents.forEach((component) => { - const isButton = - component.type === "button" || - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)); - - if (isButton) { - const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as - | FlowVisibilityConfig - | undefined; - - if ( - flowConfig?.enabled && - flowConfig.layoutBehavior === "auto-compact" && - flowConfig.groupId - ) { - if (!buttonGroups[flowConfig.groupId]) { - buttonGroups[flowConfig.groupId] = []; - } - buttonGroups[flowConfig.groupId].push(component); - processedButtonIds.add(component.id); - } - } - }); - - // 그룹에 속하지 않은 일반 컴포넌트들 - const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); - - // 🔧 렌더링 확인 로그 (디버그 완료 - 주석 처리) - // console.log("🔄 ScreenDesigner 렌더링:", { componentsCount: regularComponents.length, forceRenderTrigger, timestamp: Date.now() }); - - return ( - <> - {/* 일반 컴포넌트들 */} - {regularComponents.map((component) => { - const children = - component.type === "group" - ? layout.components.filter((child) => child.parentId === component.id) - : []; - - // 드래그 중 시각적 피드백 (다중 선택 지원) - const isDraggingThis = - dragState.isDragging && dragState.draggedComponent?.id === component.id; - const isBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === component.id); - - let displayComponent = component; - - if (isBeingDragged) { - if (isDraggingThis) { - // 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트 - displayComponent = { - ...component, - position: dragState.currentPosition, - style: { - ...component.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } else { - // 다른 선택된 컴포넌트들: 상대적 위치로 실시간 업데이트 - const originalComponent = dragState.draggedComponents.find( - (dragComp) => dragComp.id === component.id, - ); - if (originalComponent) { - const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; - const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; - - displayComponent = { - ...component, - position: { - x: originalComponent.position.x + deltaX, - y: originalComponent.position.y + deltaY, - z: originalComponent.position.z || 1, - } as Position, - style: { - ...component.style, - opacity: 0.8, - transition: "none", - zIndex: 40, // 주 컴포넌트보다 약간 낮게 - }, - }; - } - } - } - - // 전역 파일 상태도 key에 포함하여 실시간 리렌더링 - const globalFileState = - typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; - const globalFiles = globalFileState[component.id] || []; - const componentFiles = (component as any).uploadedFiles || []; - const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`; - // 🆕 style 변경 시 리렌더링을 위한 key 추가 - const styleKey = - component.style?.labelDisplay !== undefined - ? `label-${component.style.labelDisplay}` - : ""; - const fullKey = `${component.id}-${fileStateKey}-${styleKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`; - - // 🔧 v2-input 계열 컴포넌트 key 변경 로그 (디버그 완료 - 주석 처리) - // if (component.id.includes("v2-") || component.widgetType?.includes("v2-")) { console.log("🔑 RealtimePreview key:", { id: component.id, styleKey, labelDisplay: component.style?.labelDisplay, forceRenderTrigger, fullKey }); } - - // 🆕 labelDisplay 변경 시 새 객체로 강제 변경 감지 - const componentWithLabel = { - ...displayComponent, - _labelDisplayKey: component.style?.labelDisplay, - }; - - return ( - handleComponentClick(component, e)} - onDoubleClick={(e) => handleComponentDoubleClick(component, e)} - onDragStart={(e) => startComponentDrag(component, e)} - onDragEnd={endDrag} - selectedScreen={selectedScreen} - tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달 - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영) - onConfigChange={(config) => { - // console.log("📤 테이블 설정 변경을 상세설정에 반영:", config); - - // 컴포넌트의 componentConfig 업데이트 - const updatedComponents = layout.components.map((comp) => { - if (comp.id === component.id) { - return { - ...comp, - componentConfig: { - ...comp.componentConfig, - ...config, - }, - }; - } - return comp; - }); - - const newLayout = { - ...layout, - components: updatedComponents, - }; - - setLayout(newLayout); - saveToHistory(newLayout); - - console.log("✅ 컴포넌트 설정 업데이트 완료:", { - componentId: component.id, - updatedConfig: config, - }); - }} - // 🆕 컴포넌트 전체 업데이트 핸들러 (탭 내부 컴포넌트 위치 조정 등) - onUpdateComponent={(updatedComponent) => { - const updatedComponents = layout.components.map((comp) => - comp.id === updatedComponent.id ? updatedComponent : comp, - ); - - const newLayout = { - ...layout, - components: updatedComponents, - }; - - setLayout(newLayout); - saveToHistory(newLayout); - }} - // 🆕 리사이즈 핸들러 (10px 스냅 적용됨) - onResize={(componentId, newSize) => { - setLayout((prevLayout) => { - const updatedComponents = prevLayout.components.map((comp) => - comp.id === componentId ? { ...comp, size: newSize } : comp, - ); - - const newLayout = { - ...prevLayout, - components: updatedComponents, - }; - - // saveToHistory는 별도로 호출 (prevLayout 기반) - setTimeout(() => saveToHistory(newLayout), 0); - return newLayout; - }); - }} - // 🆕 탭 내부 컴포넌트 선택 핸들러 - onSelectTabComponent={(tabId, compId, comp) => - handleSelectTabComponent(component.id, tabId, compId, comp) - } - selectedTabComponentId={ - selectedTabComponentInfo?.tabsComponentId === component.id - ? selectedTabComponentInfo.componentId - : undefined - } - // 🆕 분할 패널 내부 컴포넌트 선택 핸들러 - onSelectPanelComponent={(panelSide, compId, comp) => - handleSelectPanelComponent(component.id, panelSide, compId, comp) - } - selectedPanelComponentId={ - selectedPanelComponentInfo?.splitPanelId === component.id - ? selectedPanelComponentInfo.componentId - : undefined - } - > - {/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} - {(component.type === "group" || - component.type === "container" || - component.type === "area" || - component.type === "component") && - layout.components - .filter((child) => child.parentId === component.id) - .map((child) => { - // 자식 컴포넌트에도 드래그 피드백 적용 - const isChildDraggingThis = - dragState.isDragging && dragState.draggedComponent?.id === child.id; - const isChildBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === child.id); - - let displayChild = child; - - if (isChildBeingDragged) { - if (isChildDraggingThis) { - // 주 드래그 자식 컴포넌트 - displayChild = { - ...child, - position: dragState.currentPosition, - style: { - ...child.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } else { - // 다른 선택된 자식 컴포넌트들 - const originalChildComponent = dragState.draggedComponents.find( - (dragComp) => dragComp.id === child.id, - ); - if (originalChildComponent) { - const deltaX = dragState.currentPosition.x - dragState.originalPosition.x; - const deltaY = dragState.currentPosition.y - dragState.originalPosition.y; - - displayChild = { - ...child, - position: { - x: originalChildComponent.position.x + deltaX, - y: originalChildComponent.position.y + deltaY, - z: originalChildComponent.position.z || 1, - } as Position, - style: { - ...child.style, - opacity: 0.8, - transition: "none", - zIndex: 8888, - }, - }; - } - } - } - - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...displayChild, - position: { - x: displayChild.position.x - component.position.x, - y: displayChild.position.y - component.position.y, - z: displayChild.position.z || 1, - }, - }; - - return ( - f.objid) || [])}`} - component={relativeChildComponent} - isSelected={ - selectedComponent?.id === child.id || - groupState.selectedComponents.includes(child.id) - } - isDesignMode={true} // 편집 모드로 설정 - onClick={(e) => handleComponentClick(child, e)} - onDoubleClick={(e) => handleComponentDoubleClick(child, e)} - onDragStart={(e) => startComponentDrag(child, e)} - onDragEnd={endDrag} - selectedScreen={selectedScreen} - tableName={selectedScreen?.tableName} // 🆕 디자인 모드에서도 옵션 로딩을 위해 전달 - // onZoneComponentDrop 제거 - onZoneClick={handleZoneClick} - // 설정 변경 핸들러 (자식 컴포넌트용) - onConfigChange={(config) => { - // console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config); - // TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요 - }} - // 🆕 자식 컴포넌트 리사이즈 핸들러 - onResize={(componentId, newSize) => { - setLayout((prevLayout) => { - const updatedComponents = prevLayout.components.map((comp) => - comp.id === componentId ? { ...comp, size: newSize } : comp, - ); - - const newLayout = { - ...prevLayout, - components: updatedComponents, - }; - - setTimeout(() => saveToHistory(newLayout), 0); - return newLayout; - }); - }} - /> - ); - })} - - ); - })} - - {/* 🆕 플로우 버튼 그룹들 */} - {Object.entries(buttonGroups).map(([groupId, buttons]) => { - if (buttons.length === 0) return null; - - const firstButton = buttons[0]; - const groupConfig = (firstButton as any).webTypeConfig - ?.flowVisibilityConfig as FlowVisibilityConfig; - - // 🔧 그룹의 위치 및 크기 계산 - // 모든 버튼이 같은 위치(groupX, groupY)에 배치되어 있으므로 - // 첫 번째 버튼의 위치를 그룹 시작점으로 사용 - const direction = groupConfig.groupDirection || "horizontal"; - const gap = groupConfig.groupGap ?? 8; - const align = groupConfig.groupAlign || "start"; - - const groupPosition = { - x: buttons[0].position.x, - y: buttons[0].position.y, - z: buttons[0].position.z || 2, - }; - - // 버튼들의 실제 크기 계산 - let groupWidth = 0; - let groupHeight = 0; - - if (direction === "horizontal") { - // 가로 정렬: 모든 버튼의 너비 + 간격 - groupWidth = buttons.reduce((total, button, index) => { - const buttonWidth = button.size?.width || 100; - const gapWidth = index < buttons.length - 1 ? gap : 0; - return total + buttonWidth + gapWidth; - }, 0); - groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); - } else { - // 세로 정렬 - groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); - groupHeight = buttons.reduce((total, button, index) => { - const buttonHeight = button.size?.height || 40; - const gapHeight = index < buttons.length - 1 ? gap : 0; - return total + buttonHeight + gapHeight; - }, 0); - } - - // 🆕 그룹 전체가 선택되었는지 확인 - const isGroupSelected = buttons.every( - (btn) => - selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id), - ); - const hasAnySelected = buttons.some( - (btn) => - selectedComponent?.id === btn.id || groupState.selectedComponents.includes(btn.id), - ); - - return ( -
- { - // 드래그 피드백 - const isDraggingThis = - dragState.isDragging && dragState.draggedComponent?.id === button.id; - const isBeingDragged = - dragState.isDragging && - dragState.draggedComponents.some((dragComp) => dragComp.id === button.id); - - let displayButton = button; - - if (isBeingDragged) { - if (isDraggingThis) { - displayButton = { - ...button, - position: dragState.currentPosition, - style: { - ...button.style, - opacity: 0.8, - transform: "scale(1.02)", - transition: "none", - zIndex: 50, - }, - }; - } - } - - // 🔧 그룹 내부에서는 상대 위치 사용 (wrapper로 처리) - const relativeButton = { - ...displayButton, - position: { - x: 0, - y: 0, - z: displayButton.position.z || 1, - }, - }; - - return ( -
{ - // 클릭이 아닌 드래그인 경우에만 드래그 시작 - e.preventDefault(); - e.stopPropagation(); - - const startX = e.clientX; - const startY = e.clientY; - let isDragging = false; - let dragStarted = false; - - const handleMouseMove = (moveEvent: MouseEvent) => { - const deltaX = Math.abs(moveEvent.clientX - startX); - const deltaY = Math.abs(moveEvent.clientY - startY); - - // 5픽셀 이상 움직이면 드래그로 간주 - if ((deltaX > 5 || deltaY > 5) && !dragStarted) { - isDragging = true; - dragStarted = true; - - // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택 - if (!e.shiftKey) { - const buttonIds = buttons.map((b) => b.id); - setGroupState((prev) => ({ - ...prev, - selectedComponents: buttonIds, - })); - } - - // 드래그 시작 - startComponentDrag(button, e as any); - } - }; - - const handleMouseUp = () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - - // 드래그가 아니면 클릭으로 처리 - if (!isDragging) { - // Shift 키를 누르지 않았으면 같은 그룹의 버튼들도 모두 선택 - if (!e.shiftKey) { - const buttonIds = buttons.map((b) => b.id); - setGroupState((prev) => ({ - ...prev, - selectedComponents: buttonIds, - })); - } - handleComponentClick(button, e); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - }} - onDoubleClick={(e) => { - e.stopPropagation(); - handleComponentDoubleClick(button, e); - }} - className={ - selectedComponent?.id === button.id || - groupState.selectedComponents.includes(button.id) - ? "outline-1 outline-offset-1 outline-blue-400" - : "" - } - > - {/* 그룹 내부에서는 DynamicComponentRenderer로 직접 렌더링 */} -
- {}} - /> -
-
- ); - }} - /> -
- ); - })} - - ); - })()} - - {/* 드래그 선택 영역 */} - {selectionDrag.isSelecting && ( -
- )} - - {/* 빈 캔버스 안내 */} - {layout.components.length === 0 && ( -
-
-
- -
-

캔버스가 비어있습니다

-

- 좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요 -

-
-

- 단축키: T(테이블), M(템플릿), P(속성), S(스타일), - R(격자), D(상세설정), E(해상도) -

-

- 편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), - Ctrl+Z(실행취소), Delete(삭제) -

-

- ⚠️ - 브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다 -

-
-
-
- )} -
-
-
- ); /* 🔥 줌 래퍼 닫기 */ - })()} -
-
{" "} - {/* 메인 컨테이너 닫기 */} - {/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */} - - {/* 모달들 */} - {/* 메뉴 할당 모달 */} - {showMenuAssignmentModal && selectedScreen && ( - setShowMenuAssignmentModal(false)} - onAssignmentComplete={() => { - // 모달을 즉시 닫지 않고, MenuAssignmentModal이 3초 후 자동으로 닫히도록 함 - // setShowMenuAssignmentModal(false); - // toast.success("메뉴에 화면이 할당되었습니다."); - }} - onBackToList={onBackToList} - /> - )} - {/* 파일첨부 상세 모달 */} - {showFileAttachmentModal && selectedFileComponent && ( - { - setShowFileAttachmentModal(false); - setSelectedFileComponent(null); - }} - component={selectedFileComponent} - screenId={selectedScreen.screenId} - /> - )} - {/* 다국어 설정 모달 */} - setShowMultilangSettingsModal(false)} - components={layout.components} - onSave={async (updates) => { - if (updates.length === 0) { - toast.info("저장할 변경사항이 없습니다."); - return; - } - - try { - // 공통 유틸 사용하여 매핑 적용 - const { applyMultilangMappings } = await import("@/lib/utils/multilangLabelExtractor"); - - // 매핑 형식 변환 - const mappings = updates.map((u) => ({ - componentId: u.componentId, - keyId: u.langKeyId, - langKey: u.langKey, - })); - - // 레이아웃 업데이트 - const updatedComponents = applyMultilangMappings(layout.components, mappings); - setLayout((prev) => ({ - ...prev, - components: updatedComponents, - })); - - toast.success(`${updates.length}개 항목의 다국어 설정이 저장되었습니다.`); - } catch (error) { - console.error("다국어 설정 저장 실패:", error); - toast.error("다국어 설정 저장 중 오류가 발생했습니다."); - } - }} - /> - {/* 단축키 도움말 모달 */} - setShowShortcutsModal(false)} - /> -
- - - - ); -} +서 \ No newline at end of file diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 3a2b83f9..e73a78c6 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -686,11 +686,15 @@ export const SelectedItemsDetailInputComponent: React.FC !!r.id); try { const mappingResult = await dataApi.upsertGroupedRecords( mainTable, itemParentKeys, mappingRecords, + { deleteOrphans: mappingHasDbIds }, ); } catch (err) { console.error(`❌ ${mainTable} 저장 실패:`, err); @@ -751,11 +755,13 @@ export const SelectedItemsDetailInputComponent: React.FC !!r.id); try { const detailResult = await dataApi.upsertGroupedRecords( detailTable, itemParentKeys, priceRecords, + { deleteOrphans: priceHasDbIds }, ); if (!detailResult.success) { @@ -767,12 +773,14 @@ export const SelectedItemsDetailInputComponent: React.FC