From 1aacd829f2d07894aad01a2bb4815909811de742 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 9 Feb 2026 16:46:50 +0900 Subject: [PATCH] 123 --- frontend/components/screen/ScreenDesigner.tsx | 7141 ++++++++++++++++- 1 file changed, 7140 insertions(+), 1 deletion(-) diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 5f62ade4..a530c024 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1 +1,7140 @@ -서 \ No newline at end of file +"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)} + /> +
+ + + + ); +}