ERP-node/frontend/components/screen/ScreenDesigner.tsx

7519 lines
318 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Database, Cog } from "lucide-react";
import { cn } from "@/lib/utils";
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<ScreenDefinition>) => void;
// POP 모드 지원
isPop?: boolean;
defaultDevicePreview?: "mobile" | "tablet";
}
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,
isPop = false,
defaultDevicePreview = "tablet"
}: ScreenDesignerProps) {
// POP 모드 여부에 따른 API 분기
const USE_POP_API = isPop;
const [layout, setLayout] = useState<LayoutData>({
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<number | undefined>(undefined);
// 메뉴 할당 모달 상태
const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false);
// 단축키 도움말 모달 상태
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
// 파일첨부 상세 모달 상태
const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false);
const [selectedFileComponent, setSelectedFileComponent] = useState<ComponentData | null>(null);
// 해상도 설정 상태
const [screenResolution, setScreenResolution] = useState<ScreenResolution>(
SCREEN_RESOLUTIONS[0], // 기본값: Full HD
);
// 🆕 패널 상태 관리 (usePanelState 훅)
const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels, updatePanelPosition, updatePanelSize } =
usePanelState(panelConfigs);
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(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<ComponentData[]>([]);
// 실행취소/다시실행을 위한 히스토리 상태
const [history, setHistory] = useState<LayoutData[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// 그룹 상태
const [groupState, setGroupState] = useState<GroupState>({
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<HTMLDivElement>(null);
// Zoom 상태
const [zoomLevel, setZoomLevel] = useState(1); // 1 = 100%
const MIN_ZOOM = 0.1; // 10%
const MAX_ZOOM = 3; // 300%
const zoomRafRef = useRef<number | null>(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<TableInfo[]>([]);
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<HTMLDivElement>(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]);
// 🆕 현재 편집 중인 레이어 ID (DB의 layer_id, 1 = 기본 레이어)
const [activeLayerId, setActiveLayerIdLocal] = useState<number>(1);
const activeLayerIdRef = useRef<number>(1);
const setActiveLayerIdWithRef = useCallback((id: number) => {
setActiveLayerIdLocal(id);
activeLayerIdRef.current = id;
}, []);
// 🆕 좌측 패널 탭 상태 관리
const [leftPanelTab, setLeftPanelTab] = useState<string>("components");
// 🆕 조건부 영역(Zone) 목록 (DB screen_conditional_zones 기반)
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
// 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정)
const [regionDrag, setRegionDrag] = useState<{
isDrawing: boolean; // 새 영역 그리기 모드
isDragging: boolean; // 기존 영역 이동 모드
isResizing: boolean; // 기존 영역 리사이즈 모드
targetLayerId: string | null; // 대상 Zone ID (문자열)
startX: number;
startY: number;
currentX: number;
currentY: number;
resizeHandle: string | null; // 리사이즈 핸들 위치
originalRegion: { x: number; y: number; width: number; height: number } | null;
}>({
isDrawing: false,
isDragging: false,
isResizing: false,
targetLayerId: null,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
resizeHandle: null,
originalRegion: null,
});
// 🆕 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용)
const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null);
// 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기
useEffect(() => {
if (activeLayerId <= 1 || !selectedScreen?.screenId) {
setActiveLayerZone(null);
return;
}
// 레이어의 condition_config에서 zone_id를 가져와서 zones에서 찾기
const findZone = async () => {
try {
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, activeLayerId);
const zoneId = layerData?.conditionConfig?.zone_id;
if (zoneId) {
const zone = zones.find(z => z.zone_id === zoneId);
setActiveLayerZone(zone || null);
} else {
setActiveLayerZone(null);
}
} catch {
setActiveLayerZone(null);
}
};
findZone();
}, [activeLayerId, selectedScreen?.screenId, zones]);
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
const visibleComponents = useMemo(() => {
return layout.components;
}, [layout.components]);
// 이미 배치된 컬럼 목록 계산
const placedColumns = useMemo(() => {
const placed = new Set<string>();
// 🔧 화면의 메인 테이블명을 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/POP API 사용 여부에 따라 분기
let response: any;
if (USE_POP_API) {
// POP 모드: screen_layouts_pop 테이블 사용
const popResponse = await screenApi.getLayoutPop(selectedScreen.screenId);
response = popResponse ? convertV2ToLegacy(popResponse) : null;
console.log("📱 POP 레이아웃 로드:", popResponse?.components?.length || 0, "개 컴포넌트");
} else if (USE_V2_API) {
// 데스크톱 V2 모드: screen_layouts_v2 테이블 사용
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);
// 🆕 조건부 영역(Zone) 로드
try {
const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId);
setZones(loadedZones);
} catch { /* Zone 로드 실패 무시 */ }
}
} 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<AlignMode, string> = {
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<MatchSizeMode, string> = {
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 layoutWithResolution = {
...layout,
components: updatedComponents,
screenResolution: screenResolution,
mainTableName: currentMainTableName, // 화면의 기본 테이블
};
// V2/POP API 사용 여부에 따라 분기
const v2Layout = convertLegacyToV2(layoutWithResolution);
if (USE_POP_API) {
// POP 모드: screen_layouts_pop 테이블에 저장
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
// 레이어 기반 저장: 현재 활성 레이어의 layout만 저장
const currentLayerId = activeLayerIdRef.current || 1;
await screenApi.saveLayoutV2(selectedScreen.screenId, {
...v2Layout,
layerId: currentLayerId,
});
} 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]);
// POP 미리보기 핸들러 (새 창에서 열기)
const handlePopPreview = useCallback(() => {
if (!selectedScreen?.screenId) {
toast.error("화면 정보가 없습니다.");
return;
}
const deviceType = defaultDevicePreview || "tablet";
const previewUrl = `/pop/screens/${selectedScreen.screenId}?preview=true&device=${deviceType}`;
window.open(previewUrl, "_blank", "width=800,height=900");
}, [selectedScreen, defaultDevicePreview]);
// 다국어 자동 생성 핸들러
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<string, Record<string, string>> = {};
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 {
const v2Layout = convertLegacyToV2(updatedLayout);
if (USE_POP_API) {
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
// 현재 활성 레이어 ID 포함 (레이어별 저장)
const currentLayerId = activeLayerIdRef.current || 1;
await screenApi.saveLayoutV2(selectedScreen.screenId, {
...v2Layout,
layerId: currentLayerId,
});
} 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<string, string> = {};
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<string, number> = {
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;
}
});
// 🆕 현재 활성 레이어에 컴포넌트 추가 (ref 사용으로 클로저 문제 방지)
const componentsWithLayerId = newComponents.map((comp) => ({
...comp,
layerId: activeLayerIdRef.current || 1,
}));
// 레이아웃에 새 컴포넌트들 추가
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],
);
// 레이아웃 드래그 처리
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: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
} as ComponentData;
// 레이아웃에 새 컴포넌트 추가
const newLayout = {
...layout,
components: [...layout.components, newLayoutComponent],
};
setLayout(newLayout);
saveToHistory(newLayout);
// 레이아웃 컴포넌트 선택
setSelectedComponent(newLayoutComponent);
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
},
[layout, screenResolution, saveToHistory, zoomLevel],
);
// 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;
// 캔버스 경계 내로 위치 제한 (조건부 레이어 편집 시 Zone 크기 기준)
const currentLayerId = activeLayerIdRef.current || 1;
const activeLayerRegion = currentLayerId > 1 ? activeLayerZone : 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<string, number> = {
// 입력 컴포넌트 (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: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
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],
);
// 드래그 앤 드롭 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDrop = useCallback(
async (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;
}
// 🆕 조건부 영역(Zone) 생성 드래그인 경우 → DB screen_conditional_zones에 저장
if (parsedData.type === "create-zone" && selectedScreen?.screenId) {
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
const dropX = Math.round((e.clientX - canvasRect.left) / zoomLevel);
const dropY = Math.round((e.clientY - canvasRect.top) / zoomLevel);
try {
await screenApi.createZone(selectedScreen.screenId, {
zone_name: "조건부 영역",
x: Math.max(0, dropX - 400),
y: Math.max(0, dropY),
width: Math.min(800, screenResolution.width),
height: 200,
});
// Zone 목록 새로고침
const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId);
setZones(loadedZones);
toast.success("조건부 영역이 생성되었습니다.");
} catch (error) {
console.error("Zone 생성 실패:", error);
toast.error("조건부 영역 생성에 실패했습니다.");
}
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<string, { width: number; height: number }> = {
text: { width: 200, height: 36 },
number: { width: 150, height: 36 },
decimal: { width: 150, height: 36 },
date: { width: 180, height: 36 },
datetime: { width: 200, height: 36 },
select: { width: 200, height: 36 },
category: { width: 200, height: 36 },
code: { width: 200, height: 36 },
entity: { width: 220, height: 36 },
boolean: { width: 120, height: 36 },
checkbox: { width: 120, height: 36 },
textarea: { width: 300, height: 100 },
file: { width: 250, height: 80 },
};
return sizeMap[widgetType] || { width: 200, height: 36 };
};
const componentSize = 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<string, { width: number; height: number }> = {
text: { width: 200, height: 36 },
number: { width: 150, height: 36 },
decimal: { width: 150, height: 36 },
date: { width: 180, height: 36 },
datetime: { width: 200, height: 36 },
select: { width: 200, height: 36 },
category: { width: 200, height: 36 },
code: { width: 200, height: 36 },
entity: { width: 220, height: 36 },
boolean: { width: 120, height: 36 },
checkbox: { width: 120, height: 36 },
textarea: { width: 300, height: 100 },
file: { width: 250, height: 80 },
};
return sizeMap[widgetType] || { width: 200, height: 36 };
};
const componentSize = getPanelComponentSize(column.widgetType);
const newPanelComponent = {
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
componentType: v2Mapping.componentType,
label: column.columnLabel || column.columnName,
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
size: componentSize,
inputType: column.inputType || column.widgetType,
widgetType: column.widgetType,
componentConfig: {
...v2Mapping.componentConfig,
columnName: column.columnName,
tableName: column.tableName,
inputType: column.inputType || column.widgetType,
},
};
const updatedPanelConfig = {
...panelConfig,
components: [...currentComponents, newPanelComponent],
};
const updatedComponent = {
...targetComponent,
componentConfig: {
...currentConfig,
[panelKey]: updatedPanelConfig,
},
};
const newLayout = {
...layout,
components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)),
};
setLayout(newLayout);
saveToHistory(newLayout);
toast.success(`컬럼이 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
return;
}
}
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
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: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
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<string, number> = {
// 텍스트 입력 계열
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<string, number> = {
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: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
// 코드 타입인 경우 코드 카테고리 정보 추가
...(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: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
// 코드 타입인 경우 코드 카테고리 정보 추가
...(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<ComponentData>) => {
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;
// 조건부 레이어 편집 시 Zone 크기 기준 경계 제한
const dragLayerId = activeLayerIdRef.current || 1;
const dragLayerRegion = dragLayerId > 1 ? activeLayerZone : 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: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 붙여넣기 (ref 사용)
};
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]);
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
// 🆕 플로우 버튼 그룹 다이얼로그 상태
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/POP API 사용 여부에 따라 분기
const v2Layout = convertLegacyToV2(layoutWithResolution);
if (USE_POP_API) {
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
// 현재 활성 레이어 ID 포함 (레이어별 저장)
const currentLayerId = activeLayerIdRef.current || 1;
await screenApi.saveLayoutV2(selectedScreen.screenId, {
...v2Layout,
layerId: currentLayerId,
});
} 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<string, "up" | "down" | "left" | "right"> = {
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<string, AlignMode> = {
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]);
// 🆕 조건부 영역 드래그 핸들러 (이동/리사이즈, DB 기반)
const handleRegionMouseDown = useCallback((
e: React.MouseEvent,
layerId: string,
mode: "move" | "resize",
handle?: string,
) => {
e.stopPropagation();
e.preventDefault();
const zoneId = Number(layerId); // layerId는 실제로 zoneId
const zone = zones.find(z => z.zone_id === zoneId);
if (!zone) return;
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
const x = (e.clientX - canvasRect.left) / zoomLevel;
const y = (e.clientY - canvasRect.top) / zoomLevel;
setRegionDrag({
isDrawing: false,
isDragging: mode === "move",
isResizing: mode === "resize",
targetLayerId: String(zoneId),
startX: x,
startY: y,
currentX: x,
currentY: y,
resizeHandle: handle || null,
originalRegion: { x: zone.x, y: zone.y, width: zone.width, height: zone.height },
});
}, [zones, zoomLevel]);
// 🆕 캔버스 마우스 이벤트 (영역 이동/리사이즈)
const handleRegionCanvasMouseMove = useCallback((e: React.MouseEvent) => {
if (!regionDrag.isDragging && !regionDrag.isResizing) return;
if (!regionDrag.targetLayerId) return;
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
const x = (e.clientX - canvasRect.left) / zoomLevel;
const y = (e.clientY - canvasRect.top) / zoomLevel;
if (regionDrag.isDragging && regionDrag.originalRegion) {
const dx = x - regionDrag.startX;
const dy = y - regionDrag.startY;
const newRegion = {
x: Math.max(0, Math.round(regionDrag.originalRegion.x + dx)),
y: Math.max(0, Math.round(regionDrag.originalRegion.y + dy)),
width: regionDrag.originalRegion.width,
height: regionDrag.originalRegion.height,
};
const zoneId = Number(regionDrag.targetLayerId);
setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z));
} else if (regionDrag.isResizing && regionDrag.originalRegion) {
const dx = x - regionDrag.startX;
const dy = y - regionDrag.startY;
const orig = regionDrag.originalRegion;
const newRegion = { ...orig };
const handle = regionDrag.resizeHandle;
if (handle?.includes("e")) newRegion.width = Math.max(50, Math.round(orig.width + dx));
if (handle?.includes("s")) newRegion.height = Math.max(30, Math.round(orig.height + dy));
if (handle?.includes("w")) {
newRegion.x = Math.max(0, Math.round(orig.x + dx));
newRegion.width = Math.max(50, Math.round(orig.width - dx));
}
if (handle?.includes("n")) {
newRegion.y = Math.max(0, Math.round(orig.y + dy));
newRegion.height = Math.max(30, Math.round(orig.height - dy));
}
const zoneId = Number(regionDrag.targetLayerId);
setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z));
}
}, [regionDrag, zoomLevel]);
const handleRegionCanvasMouseUp = useCallback(async () => {
// 드래그 완료 시 DB에 Zone 저장
if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId) {
const zoneId = Number(regionDrag.targetLayerId);
const zone = zones.find(z => z.zone_id === zoneId);
if (zone) {
try {
await screenApi.updateZone(zoneId, {
x: zone.x, y: zone.y, width: zone.width, height: zone.height,
});
} catch {
console.error("Zone 저장 실패");
}
}
}
// 드래그 상태 초기화
setRegionDrag({
isDrawing: false,
isDragging: false,
isResizing: false,
targetLayerId: null,
startX: 0, startY: 0, currentX: 0, currentY: 0,
resizeHandle: null,
originalRegion: null,
});
}, [regionDrag, zones]);
// 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영
// Zone 기반이므로 displayRegion 보존 불필요
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
setLayout((prevLayout) => ({
...prevLayout,
layers: newLayers,
}));
}, []);
// 🆕 활성 레이어 변경 핸들러
const handleActiveLayerChange = useCallback((newActiveLayerId: number) => {
setActiveLayerIdWithRef(newActiveLayerId);
}, [setActiveLayerIdWithRef]);
// 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성
// 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠
const initialLayers = useMemo<LayerDefinition[]>(() => {
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 (
<div className="bg-background flex h-full items-center justify-center">
<div className="space-y-4 text-center">
<div className="bg-muted mx-auto flex h-16 w-16 items-center justify-center rounded-full">
<Database className="text-muted-foreground h-8 w-8" />
</div>
<h3 className="text-foreground text-lg font-semibold"> </h3>
<p className="text-muted-foreground max-w-sm text-sm"> .</p>
</div>
</div>
);
}
// 🔧 ScreenDesigner 렌더링 확인 (디버그 완료 - 주석 처리)
// console.log("🏠 ScreenDesigner 렌더!", Date.now());
return (
<ScreenPreviewProvider isPreviewMode={false}>
<LayerProvider
initialLayers={initialLayers}
onLayersChange={handleLayersChange}
onActiveLayerChange={handleActiveLayerChange}
>
<TableOptionsProvider>
<div className="bg-background flex h-full w-full flex-col">
{/* 상단 슬림 툴바 */}
<SlimToolbar
screenName={selectedScreen?.screenName}
tableName={selectedScreen?.tableName}
screenResolution={screenResolution}
onBack={onBackToList}
onSave={handleSave}
isSaving={isSaving}
onPreview={isPop ? handlePopPreview : undefined}
onResolutionChange={setScreenResolution}
gridSettings={layout.gridSettings}
onGridSettingsChange={updateGridSettings}
onGenerateMultilang={handleGenerateMultilang}
isGeneratingMultilang={isGeneratingMultilang}
onOpenMultilangSettings={() => 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)}
/>
{/* 메인 컨테이너 (패널들 + 캔버스) */}
<div className="flex flex-1 overflow-hidden">
{/* 통합 패널 - 좌측 사이드바 제거 후 너비 300px로 확장 */}
{panelStates.v2?.isOpen && (
<div className="border-border bg-card flex h-full w-[300px] flex-col overflow-hidden border-r shadow-sm">
<div className="border-border flex shrink-0 items-center justify-between border-b px-4 py-3">
<h3 className="text-foreground text-sm font-semibold"></h3>
<button
onClick={() => closePanel("v2")}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Tabs value={leftPanelTab} onValueChange={setLeftPanelTab} className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-3 gap-1">
<TabsTrigger value="components" className="text-xs">
</TabsTrigger>
<TabsTrigger value="layers" className="text-xs">
</TabsTrigger>
<TabsTrigger value="properties" className="text-xs">
</TabsTrigger>
</TabsList>
<TabsContent value="components" className="mt-0 flex-1 overflow-hidden">
<ComponentsPanel
tables={filteredTables}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onTableDragStart={(e, table, column) => {
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}
/>
</TabsContent>
{/* 🆕 레이어 관리 탭 (DB 기반) */}
<TabsContent value="layers" className="mt-0 flex-1 overflow-hidden">
<LayerManagerPanel
screenId={selectedScreen?.screenId || null}
activeLayerId={Number(activeLayerIdRef.current) || 1}
onLayerChange={async (layerId) => {
if (!selectedScreen?.screenId) return;
try {
// 1. 현재 레이어 저장
const curId = Number(activeLayerIdRef.current) || 1;
const v2Layout = convertLegacyToV2({ ...layout, screenResolution });
await screenApi.saveLayoutV2(selectedScreen.screenId, { ...v2Layout, layerId: curId });
// 2. 새 레이어 로드
const data = await screenApi.getLayerLayout(selectedScreen.screenId, layerId);
if (data && data.components) {
const legacy = convertV2ToLegacy(data);
if (legacy) {
setLayout((prev) => ({ ...prev, components: legacy.components }));
} else {
setLayout((prev) => ({ ...prev, components: [] }));
}
} else {
setLayout((prev) => ({ ...prev, components: [] }));
}
setActiveLayerIdWithRef(layerId);
setSelectedComponent(null);
} catch (error) {
console.error("레이어 전환 실패:", error);
toast.error("레이어 전환에 실패했습니다.");
}
}}
components={layout.components}
zones={zones}
onZonesChange={setZones}
/>
</TabsContent>
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
{/* 탭 내부 컴포넌트 선택 시에도 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 (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-muted-foreground text-xs"> </span>
<button
className="text-muted-foreground hover:text-foreground text-xs"
onClick={() => setSelectedTabComponentInfo(null)}
>
</button>
</div>
<div className="flex-1 overflow-hidden">
<V2PropertiesPanel
selectedComponent={tabComponentAsComponentData}
tables={tables}
onUpdateProperty={updateTabComponentProperty}
onDeleteComponent={deleteTabComponent}
currentTable={tables.length > 0 ? tables[0] : undefined}
currentTableName={selectedScreen?.tableName}
currentScreenCompanyCode={selectedScreen?.companyCode}
onStyleChange={(style) => {
updateTabComponentProperty(tabComp.id, "style", style);
}}
allComponents={layout.components}
menuObjid={menuObjid}
/>
</div>
</div>
);
})()
) : selectedPanelComponentInfo ? (
// 🆕 분할 패널 내부 컴포넌트 선택 시 V2PropertiesPanel 사용
(() => {
const panelComp = selectedPanelComponentInfo.component;
// 분할 패널 내부 컴포넌트를 ComponentData 형식으로 변환
const panelComponentAsComponentData: ComponentData = {
id: panelComp.id,
type: "component",
componentType: panelComp.componentType,
label: panelComp.label,
position: panelComp.position || { x: 0, y: 0 },
size: panelComp.size || { width: 200, height: 100 },
componentConfig: panelComp.componentConfig || {},
style: panelComp.style || {},
} as ComponentData;
// 분할 패널 내부 컴포넌트용 속성 업데이트 핸들러
const updatePanelComponentProperty = (componentId: string, path: string, value: any) => {
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
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 (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-muted-foreground text-xs">
({selectedPanelComponentInfo.panelSide === "left" ? "좌측" : "우측"})
</span>
<button
className="text-muted-foreground hover:text-foreground text-xs"
onClick={() => setSelectedPanelComponentInfo(null)}
>
</button>
</div>
<div className="flex-1 overflow-hidden">
<V2PropertiesPanel
selectedComponent={panelComponentAsComponentData}
tables={tables}
onUpdateProperty={updatePanelComponentProperty}
onDeleteComponent={deletePanelComponent}
currentTable={tables.length > 0 ? tables[0] : undefined}
currentTableName={selectedScreen?.tableName}
currentScreenCompanyCode={selectedScreen?.companyCode}
onStyleChange={(style) => {
updatePanelComponentProperty(panelComp.id, "style", style);
}}
allComponents={layout.components}
menuObjid={menuObjid}
/>
</div>
</div>
);
})()
) : (
<V2PropertiesPanel
selectedComponent={selectedComponent || undefined}
tables={tables}
onUpdateProperty={updateComponentProperty}
onDeleteComponent={deleteComponent}
onCopyComponent={copyComponent}
currentTable={tables.length > 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 전달
/>
)}
</TabsContent>
</Tabs>
</div>
</div>
)}
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - GPU 가속 스크롤 적용 */}
<div
ref={canvasContainerRef}
className="bg-muted relative flex-1 overflow-auto px-16 py-6"
style={{ willChange: "scroll-position" }}
>
{/* Pan 모드 안내 - 제거됨 */}
{/* 줌 레벨 표시 */}
<div className="bg-card text-foreground border-border pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg border px-4 py-2 text-sm font-medium shadow-md">
🔍 {Math.round(zoomLevel * 100)}%
</div>
{/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */}
{(() => {
// 선택된 컴포넌트들
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 (
<div className="bg-card border-border fixed right-6 bottom-20 z-50 rounded-lg border shadow-lg">
<div className="flex flex-col gap-2 p-3">
<div className="text-muted-foreground mb-1 flex items-center gap-2 text-xs">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.29 7 12 12 20.71 7"></polyline>
<line x1="12" y1="22" x2="12" y2="12"></line>
</svg>
<span className="font-medium">{selectedButtons.length} </span>
</div>
{/* 그룹 생성 버튼 (2개 이상 선택 시) */}
{selectedButtons.length >= 2 && (
<Button
size="sm"
variant="default"
onClick={handleFlowButtonGroup}
disabled={selectedButtons.length < 2}
className="flex items-center gap-2 text-xs"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
<line x1="15" y1="3" x2="15" y2="21"></line>
</svg>
</Button>
)}
{/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */}
{hasFlowGroupButton && (
<Button
size="sm"
variant="outline"
onClick={handleFlowButtonUngroup}
className="flex items-center gap-2 text-xs"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</Button>
)}
{/* 상태 표시 */}
{hasFlowGroupButton && <p className="mt-1 text-[10px] text-blue-600"> </p>}
</div>
</div>
);
})()}
{/* 🆕 활성 레이어 인디케이터 (기본 레이어가 아닌 경우 표시) */}
{activeLayerId > 1 && (
<div className="sticky top-0 z-30 flex items-center justify-center gap-2 border-b bg-amber-50 px-4 py-1.5 backdrop-blur-sm dark:bg-amber-950/30">
<div className="h-2 w-2 rounded-full bg-amber-500" />
<span className="text-xs font-medium">
{activeLayerId}
{activeLayerZone && (
<span className="ml-2 text-amber-600">
(: {activeLayerZone.width} x {activeLayerZone.height}px - {activeLayerZone.zone_name})
</span>
)}
{!activeLayerZone && (
<span className="ml-2 text-red-500">
( - Zone을 )
</span>
)}
</span>
</div>
)}
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
{(() => {
// 🆕 조건부 레이어 편집 시 캔버스 크기를 Zone에 맞춤
const activeRegion = activeLayerId > 1 ? activeLayerZone : null;
const canvasW = activeRegion ? activeRegion.width : screenResolution.width;
const canvasH = activeRegion ? activeRegion.height : screenResolution.height;
return (
<div
className="flex justify-center"
style={{
width: "100%",
minHeight: canvasH * zoomLevel,
contain: "layout style", // 레이아웃 재계산 범위 제한
}}
>
{/* 실제 작업 캔버스 (해상도 크기 또는 조건부 레이어 영역 크기) */}
<div
className={cn(
"bg-background border shadow-lg",
activeRegion ? "border-amber-400 border-2" : "border-border"
)}
style={{
width: `${canvasW}px`,
height: `${canvasH}px`,
minWidth: `${canvasW}px`,
maxWidth: `${canvasW}px`,
minHeight: `${canvasH}px`,
flexShrink: 0,
transform: `scale3d(${zoomLevel}, ${zoomLevel}, 1)`,
transformOrigin: "top center", // 중앙 기준으로 스케일
willChange: "transform", // GPU 가속 레이어 생성
backfaceVisibility: "hidden" as const, // 리페인트 최적화
}}
>
<div
ref={canvasRef}
className="bg-background relative h-full w-full overflow-visible"
onClick={(e) => {
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);
}
}}
onMouseMove={(e) => {
// 영역 이동/리사이즈 처리
if (regionDrag.isDragging || regionDrag.isResizing) {
handleRegionCanvasMouseMove(e);
}
}}
onMouseUp={() => {
if (regionDrag.isDragging || regionDrag.isResizing) {
handleRegionCanvasMouseUp();
}
}}
onMouseLeave={() => {
if (regionDrag.isDragging || regionDrag.isResizing) {
handleRegionCanvasMouseUp();
}
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDropCapture={(e) => {
// 캡처 단계에서 드롭 이벤트를 처리하여 자식 요소 드롭도 감지
e.preventDefault();
handleDrop(e);
}}
>
{/* 격자 라인 */}
{gridLines.map((line, index) => (
<div
key={index}
className="bg-border pointer-events-none absolute"
style={{
left: line.type === "vertical" ? `${line.position}px` : 0,
top: line.type === "horizontal" ? `${line.position}px` : 0,
width: line.type === "vertical" ? "1px" : "100%",
height: line.type === "horizontal" ? "1px" : "100%",
opacity: layout.gridSettings?.gridOpacity || 0.3,
}}
/>
))}
{/* 컴포넌트들 */}
{(() => {
// 🆕 플로우 버튼 그룹 감지 및 처리
// visibleComponents를 사용하여 활성 레이어의 컴포넌트만 표시
const topLevelComponents = visibleComponents.filter((component) => !component.parentId);
// auto-compact 모드의 버튼들을 그룹별로 묶기
const buttonGroups: Record<string, ComponentData[]> = {};
const processedButtonIds = new Set<string>();
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 (
<>
{/* 조건부 영역(Zone) (기본 레이어에서만 표시, DB 기반) */}
{/* 내부는 pointerEvents: none으로 아래 컴포넌트 클릭/드래그 통과 */}
{activeLayerId === 1 && zones.map((zone) => {
const layerId = zone.zone_id; // 렌더링용 ID
const region = zone;
const resizeHandles = ["nw", "ne", "sw", "se", "n", "s", "e", "w"];
const handleCursors: Record<string, string> = {
nw: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", se: "nwse-resize",
n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize",
};
const handlePositions: Record<string, React.CSSProperties> = {
nw: { top: -4, left: -4 }, ne: { top: -4, right: -4 },
sw: { bottom: -4, left: -4 }, se: { bottom: -4, right: -4 },
n: { top: -4, left: "50%", transform: "translateX(-50%)" },
s: { bottom: -4, left: "50%", transform: "translateX(-50%)" },
e: { top: "50%", right: -4, transform: "translateY(-50%)" },
w: { top: "50%", left: -4, transform: "translateY(-50%)" },
};
// 테두리 두께 (이동 핸들 영역)
const borderWidth = 6;
return (
<div
key={`region-${layerId}`}
className="absolute"
style={{
left: `${region.x}px`,
top: `${region.y}px`,
width: `${region.width}px`,
height: `${region.height}px`,
border: "2px dashed hsl(var(--primary))",
borderRadius: "4px",
backgroundColor: "hsl(var(--primary) / 0.05)",
zIndex: 50,
pointerEvents: "none", // 내부 클릭은 아래 컴포넌트로 통과
}}
>
{/* 테두리 이동 핸들: 상/하/좌/우 얇은 영역만 pointerEvents 활성 */}
{/* 상단 */}
<div
className="absolute left-0 right-0 top-0"
style={{ height: borderWidth, cursor: "move", pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
/>
{/* 하단 */}
<div
className="absolute bottom-0 left-0 right-0"
style={{ height: borderWidth, cursor: "move", pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
/>
{/* 좌측 */}
<div
className="absolute bottom-0 left-0 top-0"
style={{ width: borderWidth, cursor: "move", pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
/>
{/* 우측 */}
<div
className="absolute bottom-0 right-0 top-0"
style={{ width: borderWidth, cursor: "move", pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
/>
{/* 라벨 */}
<span
className="absolute left-2 top-1 select-none text-[10px] font-medium text-primary"
style={{ pointerEvents: "auto", cursor: "move" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
>
Zone {zone.zone_id} - {zone.zone_name}
</span>
{/* 리사이즈 핸들 */}
{resizeHandles.map((handle) => (
<div
key={handle}
className="absolute z-10 h-2 w-2 rounded-sm border border-primary bg-background"
style={{ ...handlePositions[handle], cursor: handleCursors[handle], pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "resize", handle)}
/>
))}
{/* 삭제 버튼 */}
<button
className="absolute -right-1 -top-3 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[8px] text-destructive-foreground hover:bg-destructive/80"
style={{ pointerEvents: "auto" }}
onClick={async (e) => {
e.stopPropagation();
if (!selectedScreen?.screenId) return;
try {
await screenApi.deleteZone(zone.zone_id);
setZones((prev) => prev.filter(z => z.zone_id !== zone.zone_id));
toast.success("조건부 영역이 삭제되었습니다.");
} catch { toast.error("Zone 삭제 실패"); }
}}
title="영역 삭제"
>
x
</button>
</div>
);
})}
{/* 일반 컴포넌트들 */}
{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 (
<RealtimePreview
key={fullKey}
component={componentWithLabel}
isSelected={
selectedComponent?.id === component.id ||
groupState.selectedComponents.includes(component.id)
}
isDesignMode={true} // 편집 모드로 설정
onClick={(e) => 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 (
<RealtimePreview
key={`${child.id}-${(child as any).uploadedFiles?.length || 0}-${JSON.stringify((child as any).uploadedFiles?.map((f: any) => 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;
});
}}
/>
);
})}
</RealtimePreview>
);
})}
{/* 🆕 플로우 버튼 그룹들 */}
{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 (
<div
key={`flow-button-group-${groupId}`}
style={{
position: "absolute",
left: `${groupPosition.x}px`,
top: `${groupPosition.y}px`,
zIndex: groupPosition.z,
width: `${groupWidth}px`, // 🆕 명시적 너비
height: `${groupHeight}px`, // 🆕 명시적 높이
pointerEvents: "none", // 그룹 컨테이너는 이벤트 차단하여 개별 버튼 클릭 가능
}}
className={hasAnySelected ? "rounded outline-2 outline-offset-2 outline-blue-500" : ""}
>
<FlowButtonGroup
buttons={buttons}
groupConfig={groupConfig}
isDesignMode={true}
renderButton={(button, isVisible) => {
// 드래그 피드백
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 (
<div
key={button.id}
style={{
position: "relative",
opacity: isVisible ? 1 : 0.5,
display: "inline-block",
width: button.size?.width || 100,
height: button.size?.height || 40,
pointerEvents: "auto", // 개별 버튼은 이벤트 활성화
cursor: "pointer",
}}
onMouseDown={(e) => {
// 클릭이 아닌 드래그인 경우에만 드래그 시작
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로 직접 렌더링 */}
<div style={{ width: "100%", height: "100%", pointerEvents: "none" }}>
<DynamicComponentRenderer
component={relativeButton}
isDesignMode={true}
formData={{}}
tableName={selectedScreen?.tableName}
onDataflowComplete={() => {}}
/>
</div>
</div>
);
}}
/>
</div>
);
})}
</>
);
})()}
{/* 드래그 선택 영역 */}
{selectionDrag.isSelecting && (
<div
className="border-primary bg-primary/5 pointer-events-none absolute rounded-md border-2 border-dashed"
style={{
left: `${Math.min(selectionDrag.startPoint.x, selectionDrag.currentPoint.x)}px`,
top: `${Math.min(selectionDrag.startPoint.y, selectionDrag.currentPoint.y)}px`,
width: `${Math.abs(selectionDrag.currentPoint.x - selectionDrag.startPoint.x)}px`,
height: `${Math.abs(selectionDrag.currentPoint.y - selectionDrag.startPoint.y)}px`,
}}
/>
)}
{/* 빈 캔버스 안내 */}
{layout.components.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="max-w-2xl space-y-4 px-6 text-center">
<div className="bg-muted mx-auto flex h-16 w-16 items-center justify-center rounded-full">
<Database className="text-muted-foreground h-8 w-8" />
</div>
<h3 className="text-foreground text-xl font-semibold"> </h3>
<p className="text-muted-foreground text-sm">
/ 릿
</p>
<div className="text-muted-foreground space-y-2 text-xs">
<p>
<span className="font-medium">:</span> T(), M(릿), P(), S(),
R(), D(), E()
</p>
<p>
<span className="font-medium">:</span> Ctrl+C(), Ctrl+V(), Ctrl+S(),
Ctrl+Z(), Delete()
</p>
<p className="text-warning flex items-center justify-center gap-2">
<span></span>
<span> </span>
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
); /* 🔥 줌 래퍼 닫기 */
})()}
</div>
</div>{" "}
{/* 메인 컨테이너 닫기 */}
{/* 🆕 플로우 버튼 그룹 생성 다이얼로그 */}
<FlowButtonGroupDialog
open={groupDialogOpen}
onOpenChange={setGroupDialogOpen}
buttonCount={groupState.selectedComponents.length}
onConfirm={handleGroupConfirm}
/>
{/* 모달들 */}
{/* 메뉴 할당 모달 */}
{showMenuAssignmentModal && selectedScreen && (
<MenuAssignmentModal
screenInfo={selectedScreen}
isOpen={showMenuAssignmentModal}
onClose={() => setShowMenuAssignmentModal(false)}
onAssignmentComplete={() => {
// 모달을 즉시 닫지 않고, MenuAssignmentModal이 3초 후 자동으로 닫히도록 함
// setShowMenuAssignmentModal(false);
// toast.success("메뉴에 화면이 할당되었습니다.");
}}
onBackToList={onBackToList}
/>
)}
{/* 파일첨부 상세 모달 */}
{showFileAttachmentModal && selectedFileComponent && (
<FileAttachmentDetailModal
isOpen={showFileAttachmentModal}
onClose={() => {
setShowFileAttachmentModal(false);
setSelectedFileComponent(null);
}}
component={selectedFileComponent}
screenId={selectedScreen.screenId}
/>
)}
{/* 다국어 설정 모달 */}
<MultilangSettingsModal
isOpen={showMultilangSettingsModal}
onClose={() => 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("다국어 설정 저장 중 오류가 발생했습니다.");
}
}}
/>
{/* 단축키 도움말 모달 */}
<KeyboardShortcutsModal
isOpen={showShortcutsModal}
onClose={() => setShowShortcutsModal(false)}
/>
</div>
</TableOptionsProvider>
</LayerProvider>
</ScreenPreviewProvider>
);
}