"use client"; import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { Palette, Grid3X3, Type, Calendar, Hash, CheckSquare, Radio, Save, Undo, Redo, Group, Database, Trash2, Settings, ChevronDown, Code, Building, File, List, AlignLeft, ChevronRight, Copy, Clipboard, } from "lucide-react"; import { ScreenDefinition, ComponentData, LayoutData, GroupState, WebType, TableInfo, GroupComponent, } from "@/types/screen"; import { generateComponentId } from "@/lib/utils/generateId"; import { createGroupComponent, calculateBoundingBox, calculateRelativePositions, restoreAbsolutePositions, getGroupChildren, } from "@/lib/utils/groupingUtils"; import { GroupingToolbar } from "./GroupingToolbar"; import StyleEditor from "./StyleEditor"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { RealtimePreview } from "./RealtimePreview"; interface ScreenDesignerProps { selectedScreen: ScreenDefinition | null; onBackToList: () => void; } export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) { const [layout, setLayout] = useState({ components: [], gridSettings: { columns: 12, gap: 16, padding: 16 }, }); const [selectedComponent, setSelectedComponent] = useState(null); // 실행취소/다시실행을 위한 히스토리 상태 const [history, setHistory] = useState([ { components: [], gridSettings: { columns: 12, gap: 16, padding: 16 }, }, ]); const [historyIndex, setHistoryIndex] = useState(0); // 클립보드 상태 (복사/붙여넣기용) const [clipboard, setClipboard] = useState<{ type: "single" | "multiple" | "group"; data: ComponentData[]; offset: { x: number; y: number }; boundingBox?: { x: number; y: number; width: number; height: number }; } | null>(null); // 히스토리에 상태 저장 const saveToHistory = useCallback( (newLayout: LayoutData) => { setHistory((prevHistory) => { const newHistory = prevHistory.slice(0, historyIndex + 1); newHistory.push(JSON.parse(JSON.stringify(newLayout))); // 깊은 복사 return newHistory.slice(-50); // 최대 50개 히스토리 유지 }); setHistoryIndex((prevIndex) => Math.min(prevIndex + 1, 49)); }, [historyIndex], ); // 실행취소 const undo = useCallback(() => { if (historyIndex > 0) { const newIndex = historyIndex - 1; setHistoryIndex(newIndex); setLayout(JSON.parse(JSON.stringify(history[newIndex]))); setSelectedComponent(null); // 선택 해제 } }, [historyIndex, history]); // 다시실행 const redo = useCallback(() => { if (historyIndex < history.length - 1) { const newIndex = historyIndex + 1; setHistoryIndex(newIndex); setLayout(JSON.parse(JSON.stringify(history[newIndex]))); setSelectedComponent(null); // 선택 해제 } }, [historyIndex, history]); const [dragState, setDragState] = useState({ isDragging: false, draggedComponent: null as ComponentData | null, draggedComponents: [] as ComponentData[], // 다중선택된 컴포넌트들 originalPosition: { x: 0, y: 0 }, currentPosition: { x: 0, y: 0 }, isMultiDrag: false, // 다중 드래그 여부 initialMouse: { x: 0, y: 0 }, grabOffset: { x: 0, y: 0 }, }); const [groupState, setGroupState] = useState({ isGrouping: false, selectedComponents: [], groupTarget: null, groupMode: "create", }); const [tables, setTables] = useState([]); const [expandedTables, setExpandedTables] = useState>(new Set()); // 테이블 검색 및 페이징 상태 추가 const [searchTerm, setSearchTerm] = useState(""); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage] = useState(10); // 드래그 박스(마키) 다중선택 상태 const [selectionState, setSelectionState] = useState({ isSelecting: false, start: { x: 0, y: 0 }, current: { x: 0, y: 0 }, }); // 선택된 컴포넌트를 항상 레이아웃 최신 값으로 참조 (좌표 실시간 반영용) const selectedFromLayout = useMemo(() => { if (!selectedComponent) return null; return layout.components.find((c) => c.id === selectedComponent.id) || null; }, [selectedComponent, layout.components]); // 드래그 중에는 라이브 좌표를 계산하여 속성 패널에 표시 const liveSelectedPosition = useMemo(() => { if (!selectedFromLayout) return { x: 0, y: 0 }; let x = selectedFromLayout.position.x; let y = selectedFromLayout.position.y; if (dragState.isDragging) { const isSelectedInMulti = groupState.selectedComponents.includes(selectedFromLayout.id); if (dragState.isMultiDrag && isSelectedInMulti) { const deltaX = dragState.currentPosition.x - dragState.initialMouse.x; const deltaY = dragState.currentPosition.y - dragState.initialMouse.y; x = selectedFromLayout.position.x + deltaX; y = selectedFromLayout.position.y + deltaY; } else if (dragState.draggedComponent?.id === selectedFromLayout.id) { x = dragState.currentPosition.x - dragState.grabOffset.x; y = dragState.currentPosition.y - dragState.grabOffset.y; } } return { x: Math.round(x), y: Math.round(y) }; }, [ selectedFromLayout, dragState.isDragging, dragState.isMultiDrag, dragState.currentPosition.x, dragState.currentPosition.y, dragState.initialMouse.x, dragState.initialMouse.y, dragState.grabOffset.x, dragState.grabOffset.y, groupState.selectedComponents, ]); // 컴포넌트의 절대 좌표 계산 (그룹 자식은 부모 오프셋을 누적) const getAbsolutePosition = useCallback( (comp: ComponentData) => { let x = comp.position.x; let y = comp.position.y; let cur: ComponentData | undefined = comp; while (cur.parentId) { const parent = layout.components.find((c) => c.id === cur!.parentId); if (!parent) break; x += parent.position.x; y += parent.position.y; cur = parent; } return { x, y }; }, [layout.components], ); // 마키 선택 시작 (캔버스 빈 영역 마우스다운) const handleMarqueeStart = useCallback( (e: React.MouseEvent) => { if (dragState.isDragging) return; // 드래그 중이면 무시 const rect = canvasRef.current?.getBoundingClientRect(); const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; const scrollTop = scrollContainerRef.current?.scrollTop || 0; const x = rect ? e.clientX - rect.left + scrollLeft : 0; const y = rect ? e.clientY - rect.top + scrollTop : 0; setSelectionState({ isSelecting: true, start: { x, y }, current: { x, y } }); // 기존 선택 초기화 (Shift 미사용 시) if (!e.shiftKey) { setGroupState((prev) => ({ ...prev, selectedComponents: [] })); } }, [dragState.isDragging], ); // 마키 이동 const handleMarqueeMove = useCallback( (e: React.MouseEvent) => { if (!selectionState.isSelecting) return; const rect = canvasRef.current?.getBoundingClientRect(); const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; const scrollTop = scrollContainerRef.current?.scrollTop || 0; const x = rect ? e.clientX - rect.left + scrollLeft : 0; const y = rect ? e.clientY - rect.top + scrollTop : 0; setSelectionState((prev) => ({ ...prev, current: { x, y } })); }, [selectionState.isSelecting], ); // 마키 종료 -> 영역 내 컴포넌트 선택 const handleMarqueeEnd = useCallback(() => { if (!selectionState.isSelecting) return; const minX = Math.min(selectionState.start.x, selectionState.current.x); const minY = Math.min(selectionState.start.y, selectionState.current.y); const maxX = Math.max(selectionState.start.x, selectionState.current.x); const maxY = Math.max(selectionState.start.y, selectionState.current.y); const selectedIds = layout.components // 그룹 컨테이너는 제외 .filter((c) => c.type !== "group") .filter((c) => { const abs = getAbsolutePosition(c); const left = abs.x; const top = abs.y; const right = abs.x + c.size.width; const bottom = abs.y + c.size.height; // 영역과 교차 여부 판단 (일부라도 겹치면 선택) return right >= minX && left <= maxX && bottom >= minY && top <= maxY; }) .map((c) => c.id); setGroupState((prev) => ({ ...prev, selectedComponents: Array.from(new Set([...prev.selectedComponents, ...selectedIds])), })); setSelectionState({ isSelecting: false, start: { x: 0, y: 0 }, current: { x: 0, y: 0 } }); }, [selectionState, layout.components, getAbsolutePosition]); // 테이블 데이터 로드 (실제로는 API에서 가져와야 함) useEffect(() => { const fetchTables = async () => { try { const response = await fetch("http://localhost:8080/api/screen-management/tables", { headers: { Authorization: `Bearer ${localStorage.getItem("authToken")}`, }, }); if (response.ok) { const data = await response.json(); if (data.success) { setTables(data.data); } else { console.error("테이블 조회 실패:", data.message); // 임시 데이터로 폴백 setTables(getMockTables()); } } else { console.error("테이블 조회 실패:", response.status); // 임시 데이터로 폴백 setTables(getMockTables()); } } catch (error) { console.error("테이블 조회 중 오류:", error); // 임시 데이터로 폴백 setTables(getMockTables()); } }; fetchTables(); }, []); // 검색된 테이블 필터링 const filteredTables = useMemo(() => { if (!searchTerm.trim()) return tables; return tables.filter( (table) => table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || table.tableLabel.toLowerCase().includes(searchTerm.toLowerCase()) || table.columns.some( (column) => column.columnName.toLowerCase().includes(searchTerm.toLowerCase()) || (column.columnLabel || column.columnName).toLowerCase().includes(searchTerm.toLowerCase()), ), ); }, [tables, searchTerm]); // 페이징된 테이블 const paginatedTables = useMemo(() => { const startIndex = (currentPage - 1) * itemsPerPage; const endIndex = startIndex + itemsPerPage; return filteredTables.slice(startIndex, endIndex); }, [filteredTables, currentPage, itemsPerPage]); // 총 페이지 수 계산 const totalPages = Math.ceil(filteredTables.length / itemsPerPage); // 페이지 변경 핸들러 const handlePageChange = (page: number) => { setCurrentPage(page); setExpandedTables(new Set()); // 페이지 변경 시 확장 상태 초기화 }; // 검색어 변경 핸들러 const handleSearchChange = (value: string) => { setSearchTerm(value); setCurrentPage(1); // 검색 시 첫 페이지로 이동 setExpandedTables(new Set()); // 검색 시 확장 상태 초기화 }; // 임시 테이블 데이터 (API 실패 시 사용) const getMockTables = (): TableInfo[] => [ { tableName: "user_info", tableLabel: "사용자 정보", columns: [ { tableName: "user_info", columnName: "user_id", columnLabel: "사용자 ID", webType: "text", dataType: "VARCHAR", isNullable: "NO", }, { tableName: "user_info", columnName: "user_name", columnLabel: "사용자명", webType: "text", dataType: "VARCHAR", isNullable: "NO", }, { tableName: "user_info", columnName: "email", columnLabel: "이메일", webType: "email", dataType: "VARCHAR", isNullable: "YES", }, { tableName: "user_info", columnName: "phone", columnLabel: "전화번호", webType: "tel", dataType: "VARCHAR", isNullable: "YES", }, { tableName: "user_info", columnName: "birth_date", columnLabel: "생년월일", webType: "date", dataType: "DATE", isNullable: "YES", }, { tableName: "user_info", columnName: "is_active", columnLabel: "활성화", webType: "checkbox", dataType: "BOOLEAN", isNullable: "NO", }, { tableName: "user_info", columnName: "profile_code", columnLabel: "프로필 코드", webType: "code", dataType: "TEXT", isNullable: "YES", }, { tableName: "user_info", columnName: "department", columnLabel: "부서", webType: "entity", dataType: "VARCHAR", isNullable: "YES", }, { tableName: "user_info", columnName: "profile_image", columnLabel: "프로필 이미지", webType: "file", dataType: "VARCHAR", isNullable: "YES", }, ], }, { tableName: "product_info", tableLabel: "제품 정보", columns: [ { tableName: "product_info", columnName: "product_id", columnLabel: "제품 ID", webType: "text", dataType: "VARCHAR", isNullable: "NO", }, { tableName: "product_info", columnName: "product_name", columnLabel: "제품명", webType: "text", dataType: "VARCHAR", isNullable: "NO", }, { tableName: "product_info", columnName: "category", columnLabel: "카테고리", webType: "select", dataType: "VARCHAR", isNullable: "YES", }, { tableName: "product_info", columnName: "price", columnLabel: "가격", webType: "number", dataType: "DECIMAL", isNullable: "YES", }, { tableName: "product_info", columnName: "description", columnLabel: "설명", webType: "textarea", dataType: "TEXT", isNullable: "YES", }, { tableName: "product_info", columnName: "created_date", columnLabel: "생성일", webType: "date", dataType: "TIMESTAMP", isNullable: "NO", }, ], }, { tableName: "order_info", tableLabel: "주문 정보", columns: [ { tableName: "order_info", columnName: "order_id", columnLabel: "주문 ID", webType: "text", dataType: "VARCHAR", isNullable: "NO", }, { tableName: "order_info", columnName: "customer_name", columnLabel: "고객명", webType: "text", dataType: "VARCHAR", isNullable: "NO", }, { tableName: "order_info", columnName: "order_date", columnLabel: "주문일", webType: "date", dataType: "DATE", isNullable: "NO", }, { tableName: "order_info", columnName: "total_amount", columnLabel: "총 금액", webType: "number", dataType: "DECIMAL", isNullable: "NO", }, { tableName: "order_info", columnName: "status", columnLabel: "상태", webType: "select", dataType: "VARCHAR", isNullable: "NO", }, { tableName: "order_info", columnName: "notes", columnLabel: "비고", webType: "textarea", dataType: "TEXT", isNullable: "YES", }, ], }, ]; // 테이블 확장/축소 토글 const toggleTableExpansion = useCallback((tableName: string) => { setExpandedTables((prev) => { const newSet = new Set(prev); if (newSet.has(tableName)) { newSet.delete(tableName); } else { newSet.add(tableName); } return newSet; }); }, []); // 웹타입에 따른 위젯 타입 매핑 const getWidgetTypeFromWebType = useCallback((webType: string): string => { console.log("getWidgetTypeFromWebType - input webType:", webType); switch (webType) { case "text": return "text"; case "email": return "email"; case "tel": return "tel"; case "number": return "number"; case "decimal": return "decimal"; case "date": return "date"; case "datetime": return "datetime"; case "select": return "select"; case "dropdown": return "dropdown"; case "textarea": return "textarea"; case "text_area": return "text_area"; case "checkbox": return "checkbox"; case "boolean": return "boolean"; case "radio": return "radio"; case "code": return "code"; case "entity": return "entity"; case "file": return "file"; default: console.log("getWidgetTypeFromWebType - default case, returning text for:", webType); return "text"; } }, []); // 범용 복사 함수 const copyComponents = useCallback(() => { if (!selectedComponent && groupState.selectedComponents.length === 0) return; let componentsToCopy: ComponentData[] = []; let copyType: "single" | "multiple" | "group" = "single"; if (selectedComponent?.type === "group") { // 그룹 복사 const children = getGroupChildren(layout.components, selectedComponent.id); componentsToCopy = [selectedComponent, ...children]; copyType = "group"; } else if (groupState.selectedComponents.length > 1) { // 다중 선택 복사 componentsToCopy = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); copyType = "multiple"; } else if (selectedComponent) { // 단일 컴포넌트 복사 componentsToCopy = [selectedComponent]; copyType = "single"; } if (componentsToCopy.length === 0) return; // 바운딩 박스 계산 const positions = componentsToCopy.map((comp) => ({ x: comp.position.x, y: comp.position.y, width: comp.size.width, height: comp.size.height, })); const minX = Math.min(...positions.map((p) => p.x)); const minY = Math.min(...positions.map((p) => p.y)); const maxX = Math.max(...positions.map((p) => p.x + p.width)); const maxY = Math.max(...positions.map((p) => p.y + p.height)); setClipboard({ type: copyType, data: componentsToCopy, offset: { x: 20, y: 20 }, boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, }); }, [selectedComponent, groupState.selectedComponents, layout.components]); // 범용 삭제 함수 const deleteComponents = useCallback(() => { if (!selectedComponent && groupState.selectedComponents.length === 0) return; let idsToRemove: string[] = []; if (selectedComponent?.type === "group") { // 그룹 삭제 (자식 컴포넌트 포함) const childrenIds = getGroupChildren(layout.components, selectedComponent.id).map((child) => child.id); idsToRemove = [selectedComponent.id, ...childrenIds]; } else if (groupState.selectedComponents.length > 1) { // 다중 선택 삭제 idsToRemove = [...groupState.selectedComponents]; } else if (selectedComponent) { // 단일 컴포넌트 삭제 idsToRemove = [selectedComponent.id]; } if (idsToRemove.length === 0) return; const newLayout = { ...layout, components: layout.components.filter((comp) => !idsToRemove.includes(comp.id)), }; setLayout(newLayout); saveToHistory(newLayout); // 선택 상태 초기화 setSelectedComponent(null); setGroupState((prev) => ({ ...prev, selectedComponents: [] })); }, [selectedComponent, groupState.selectedComponents, layout, saveToHistory, setGroupState]); // 범용 붙여넣기 함수 const pasteComponents = useCallback( (pastePosition?: { x: number; y: number }) => { if (!clipboard || clipboard.data.length === 0) return; const idMap = new Map(); const newComponents: ComponentData[] = []; // 붙여넣기 위치 결정 let targetPosition = pastePosition; if (!targetPosition && clipboard.boundingBox) { targetPosition = { x: clipboard.boundingBox.x + clipboard.offset.x, y: clipboard.boundingBox.y + clipboard.offset.y, }; } const offsetX = targetPosition ? targetPosition.x - (clipboard.boundingBox?.x || 0) : clipboard.offset.x; const offsetY = targetPosition ? targetPosition.y - (clipboard.boundingBox?.y || 0) : clipboard.offset.y; // 모든 컴포넌트에 대해 새 ID 생성 clipboard.data.forEach((comp) => { const newId = generateComponentId(); idMap.set(comp.id, newId); }); // 컴포넌트 복사 및 ID/위치 업데이트 clipboard.data.forEach((comp) => { const newComp: ComponentData = { ...comp, id: idMap.get(comp.id)!, position: { x: comp.position.x + offsetX, y: comp.position.y + offsetY, }, // 부모 ID가 있고 매핑되는 경우 업데이트 parentId: comp.parentId && idMap.has(comp.parentId) ? idMap.get(comp.parentId)! : undefined, }; newComponents.push(newComp); }); const newLayout = { ...layout, components: [...layout.components, ...newComponents], }; setLayout(newLayout); saveToHistory(newLayout); }, [clipboard, layout, saveToHistory], ); // 캔버스 우클릭 컨텍스트 메뉴 const handleCanvasContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault(); // 우클릭 시 붙여넣기 (클립보드에 데이터가 있는 경우) if (clipboard && clipboard.data.length > 0) { const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; pasteComponents({ x, y }); } }, [clipboard, pasteComponents], ); // 키보드 단축키 지원 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.ctrlKey || e.metaKey) { switch (e.key) { case "z": e.preventDefault(); if (e.shiftKey) { redo(); // Ctrl+Shift+Z 또는 Cmd+Shift+Z } else { undo(); // Ctrl+Z 또는 Cmd+Z } break; case "y": e.preventDefault(); redo(); // Ctrl+Y 또는 Cmd+Y break; case "c": e.preventDefault(); // 선택된 컴포넌트(들) 복사 copyComponents(); break; case "v": e.preventDefault(); // 클립보드 내용 붙여넣기 if (clipboard && clipboard.data.length > 0) { pasteComponents(); } break; } } else if (e.key === "Delete") { e.preventDefault(); // 선택된 컴포넌트(들) 삭제 deleteComponents(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [undo, redo, copyComponents, pasteComponents, deleteComponents, clipboard]); // 컴포넌트 속성 업데이트 함수 const updateComponentProperty = useCallback( (componentId: string, propertyPath: string, value: any) => { const newLayout = { ...layout, components: layout.components.map((comp) => { if (comp.id === componentId) { const newComp = { ...comp }; const pathParts = propertyPath.split("."); let current: any = newComp; for (let i = 0; i < pathParts.length - 1; i++) { current = current[pathParts[i]]; } current[pathParts[pathParts.length - 1]] = value; return newComp; } return comp; }), }; setLayout(newLayout); saveToHistory(newLayout); // 선택된 컴포넌트인 경우 즉시 상태도 동기화하여 입력 즉시 반영되도록 처리 if (selectedComponent && selectedComponent.id === componentId) { const updated = newLayout.components.find((c) => c.id === componentId) || null; if (updated) setSelectedComponent(updated); } }, [layout, saveToHistory, selectedComponent], ); // 그룹 생성 함수 const handleGroupCreate = useCallback( (componentIds: string[], title: string, style?: any) => { const selectedComponents = layout.components.filter((comp) => componentIds.includes(comp.id)); if (selectedComponents.length < 2) { return; } // 경계 박스 계산 const boundingBox = calculateBoundingBox(selectedComponents); // 그룹 컴포넌트 생성 (경계 박스 정보 전달) const groupComponent = createGroupComponent( componentIds, title, { x: boundingBox.minX, y: boundingBox.minY }, { width: boundingBox.width, height: boundingBox.height }, style, ); // 자식 컴포넌트들의 상대 위치 계산 const relativeChildren = calculateRelativePositions( selectedComponents, { x: boundingBox.minX, y: boundingBox.minY, }, groupComponent.id, ); // 새 레이아웃 생성 const newLayout = { ...layout, components: [ // 그룹에 포함되지 않은 기존 컴포넌트들만 유지 ...layout.components.filter((comp) => !componentIds.includes(comp.id)), // 그룹 컴포넌트 추가 groupComponent, // 자식 컴포넌트들도 유지 (parentId로 그룹과 연결) ...relativeChildren, ], }; setLayout(newLayout); saveToHistory(newLayout); }, [layout, saveToHistory], ); // 그룹 해제 함수 const handleGroupUngroup = useCallback( (groupId: string) => { const group = layout.components.find((comp) => comp.id === groupId) as GroupComponent; if (!group || group.type !== "group") { return; } const groupChildren = getGroupChildren(layout.components, groupId); // 자식 컴포넌트들의 절대 위치 복원 const absoluteChildren = restoreAbsolutePositions(groupChildren, group.position); // 새 레이아웃 생성 const newLayout = { ...layout, components: [ // 그룹과 그룹의 자식 컴포넌트들을 제외한 기존 컴포넌트들 ...layout.components.filter((comp) => comp.id !== groupId && comp.parentId !== groupId), // 절대 위치로 복원된 자식 컴포넌트들 ...absoluteChildren, ], }; setLayout(newLayout); saveToHistory(newLayout); }, [layout, saveToHistory], ); // 레이아웃 저장 함수 const saveLayout = useCallback(async () => { try { // TODO: 실제 API 호출로 변경 console.log("레이아웃 저장:", layout); // await saveLayoutAPI(selectedScreen.screenId, layout); } catch (error) { console.error("레이아웃 저장 실패:", error); } }, [layout, selectedScreen]); // 캔버스 참조 (좌표 계산 정확도 향상) const canvasRef = useRef(null); const scrollContainerRef = useRef(null); // 드래그 시작 (새 컴포넌트 추가) const startDrag = useCallback((component: Partial, e: React.DragEvent) => { const canvasRect = canvasRef.current?.getBoundingClientRect(); const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; const scrollTop = scrollContainerRef.current?.scrollTop || 0; const relMouseX = (canvasRect ? e.clientX - canvasRect.left : 0) + scrollLeft; const relMouseY = (canvasRect ? e.clientY - canvasRect.top : 0) + scrollTop; setDragState({ isDragging: true, draggedComponent: component as ComponentData, draggedComponents: [component as ComponentData], originalPosition: { x: 0, y: 0 }, currentPosition: { x: relMouseX, y: relMouseY }, isMultiDrag: false, initialMouse: { x: relMouseX, y: relMouseY }, grabOffset: { x: 0, y: 0 }, }); e.dataTransfer.setData("application/json", JSON.stringify(component)); }, []); // 기존 컴포넌트 드래그 시작 (재배치) const startComponentDrag = useCallback( (component: ComponentData, e: React.DragEvent) => { e.stopPropagation(); // 다중선택된 컴포넌트들이 있는지 확인 const selectedComponents = layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id)); const isMultiDrag = selectedComponents.length > 1 && groupState.selectedComponents.includes(component.id); // 마우스-컴포넌트 그랩 오프셋 계산 (커서와 컴포넌트 좌측상단의 거리) const canvasRect = canvasRef.current?.getBoundingClientRect(); const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; const scrollTop = scrollContainerRef.current?.scrollTop || 0; const relMouseX = (canvasRect ? e.clientX - canvasRect.left : 0) + scrollLeft; const relMouseY = (canvasRect ? e.clientY - canvasRect.top : 0) + scrollTop; const grabOffsetX = relMouseX - component.position.x; const grabOffsetY = relMouseY - component.position.y; if (isMultiDrag) { // 다중 드래그 setDragState({ isDragging: true, draggedComponent: component, draggedComponents: selectedComponents, originalPosition: component.position, currentPosition: { x: relMouseX, y: relMouseY }, isMultiDrag: true, initialMouse: { x: relMouseX, y: relMouseY }, grabOffset: { x: grabOffsetX, y: grabOffsetY }, }); e.dataTransfer.setData( "application/json", JSON.stringify({ ...component, isMoving: true, isMultiDrag: true, selectedComponentIds: groupState.selectedComponents, }), ); } else { // 단일 드래그 setDragState({ isDragging: true, draggedComponent: component, draggedComponents: [component], originalPosition: component.position, currentPosition: { x: relMouseX, y: relMouseY }, isMultiDrag: false, initialMouse: { x: relMouseX, y: relMouseY }, grabOffset: { x: grabOffsetX, y: grabOffsetY }, }); e.dataTransfer.setData("application/json", JSON.stringify({ ...component, isMoving: true })); } }, [layout.components, groupState.selectedComponents], ); // 드래그 중 const onDragOver = useCallback( (e: React.DragEvent) => { e.preventDefault(); if (dragState.isDragging) { const rect = canvasRef.current?.getBoundingClientRect(); // 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준) const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; const scrollTop = scrollContainerRef.current?.scrollTop || 0; const x = rect ? e.clientX - rect.left + scrollLeft : 0; const y = rect ? e.clientY - rect.top + scrollTop : 0; setDragState((prev) => ({ ...prev, currentPosition: { x, y }, })); } }, [dragState.isDragging], ); // 드롭 처리 const onDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); try { const data = JSON.parse(e.dataTransfer.getData("application/json")); if (data.isMoving) { // 기존 컴포넌트 재배치 const rect = canvasRef.current?.getBoundingClientRect(); // 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준) const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; const scrollTop = scrollContainerRef.current?.scrollTop || 0; const mouseX = rect ? e.clientX - rect.left + scrollLeft : 0; const mouseY = rect ? e.clientY - rect.top + scrollTop : 0; if (data.isMultiDrag && data.selectedComponentIds) { // 다중 드래그 처리 // 그랩한 컴포넌트의 시작 위치 기준 델타 계산 (그랩 오프셋 반영) const dropX = mouseX - dragState.grabOffset.x; const dropY = mouseY - dragState.grabOffset.y; const deltaX = dropX - dragState.originalPosition.x; const deltaY = dropY - dragState.originalPosition.y; const newLayout = { ...layout, components: layout.components.map((comp) => { if (data.selectedComponentIds.includes(comp.id)) { return { ...comp, position: { x: comp.position.x + deltaX, y: comp.position.y + deltaY, }, }; } return comp; }), }; setLayout(newLayout); saveToHistory(newLayout); } else { // 단일 드래그 처리 const x = mouseX - dragState.grabOffset.x; const y = mouseY - dragState.grabOffset.y; const newLayout = { ...layout, components: layout.components.map((comp) => comp.id === data.id ? { ...comp, position: { x, y } } : comp, ), }; setLayout(newLayout); saveToHistory(newLayout); } } else { // 새 컴포넌트 추가 const rect = canvasRef.current?.getBoundingClientRect(); // 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준) const scrollLeft = scrollContainerRef.current?.scrollLeft || 0; const scrollTop = scrollContainerRef.current?.scrollTop || 0; const x = rect ? e.clientX - rect.left + scrollLeft : 0; const y = rect ? e.clientY - rect.top + scrollTop : 0; const newComponent: ComponentData = { ...data, id: generateComponentId(), position: { x, y }, } as ComponentData; const newLayout = { ...layout, components: [...layout.components, newComponent], }; setLayout(newLayout); saveToHistory(newLayout); } } catch (error) { console.error("드롭 처리 중 오류:", error); } setDragState({ isDragging: false, draggedComponent: null, draggedComponents: [], originalPosition: { x: 0, y: 0 }, currentPosition: { x: 0, y: 0 }, isMultiDrag: false, initialMouse: { x: 0, y: 0 }, grabOffset: { x: 0, y: 0 }, }); }, [ layout, saveToHistory, dragState.initialMouse.x, dragState.initialMouse.y, dragState.grabOffset.x, dragState.grabOffset.y, ], ); // 드래그 종료 const endDrag = useCallback(() => { setDragState({ isDragging: false, draggedComponent: null, draggedComponents: [], originalPosition: { x: 0, y: 0 }, currentPosition: { x: 0, y: 0 }, isMultiDrag: false, initialMouse: { x: 0, y: 0 }, grabOffset: { x: 0, y: 0 }, }); }, []); // 컴포넌트 클릭 (선택) const handleComponentClick = useCallback( (component: ComponentData, event?: React.MouseEvent) => { const isShiftPressed = event?.shiftKey || false; // 그룹 컨테이너는 다중선택 대상에서 제외 const isGroupContainer = component.type === "group"; if (groupState.isGrouping || isShiftPressed) { // 그룹화 모드이거나 시프트 키를 누른 경우 다중 선택 if (isGroupContainer) { // 그룹 컨테이너 클릭은 다중선택에 포함하지 않고 무시 return; } const isSelected = groupState.selectedComponents.includes(component.id); setGroupState((prev) => ({ ...prev, selectedComponents: isSelected ? prev.selectedComponents.filter((id) => id !== component.id) : [...prev.selectedComponents, component.id], })); // 시프트 키로 선택한 경우 마지막 선택된 컴포넌트를 selectedComponent로 설정 if (isShiftPressed) { setSelectedComponent(component); } } else { // 일반 모드에서는 단일 선택 setSelectedComponent(component); setGroupState((prev) => ({ ...prev, selectedComponents: isGroupContainer ? [] : [component.id], })); } }, [groupState.isGrouping, groupState.selectedComponents], ); // 화면이 선택되지 않았을 때 처리 if (!selectedScreen) { return (

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

화면 목록에서 화면을 선택한 후 설계기를 사용하세요

); } return (
{/* 상단 헤더 */}

{selectedScreen.screenName} - 화면 설계

{selectedScreen.tableName} {clipboard && clipboard.data.length > 0 && ( {clipboard.type === "group" ? "그룹 복사됨" : clipboard.type === "multiple" ? `${clipboard.data.length}개 복사됨` : "컴포넌트 복사됨"} )}
{/* 복사/붙여넣기/삭제 버튼들 */} {(selectedComponent || groupState.selectedComponents.length > 0) && ( <> )} {/* 붙여넣기 버튼 */} {clipboard && clipboard.data.length > 0 && ( )}
{/* 그룹화 툴바 */} groupState.selectedComponents.includes(comp.id))} allComponents={layout.components} onGroupAlign={(mode) => { const selected = layout.components.filter((c) => groupState.selectedComponents.includes(c.id)); if (selected.length < 2) return; let newComponents = [...layout.components]; const minX = Math.min(...selected.map((c) => c.position.x)); const maxX = Math.max(...selected.map((c) => c.position.x + c.size.width)); const minY = Math.min(...selected.map((c) => c.position.y)); const maxY = Math.max(...selected.map((c) => c.position.y + c.size.height)); const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; newComponents = newComponents.map((c) => { if (!groupState.selectedComponents.includes(c.id)) return c; if (mode === "left") return { ...c, position: { x: minX, y: c.position.y } }; if (mode === "right") return { ...c, position: { x: maxX - c.size.width, y: c.position.y } }; if (mode === "centerX") return { ...c, position: { x: Math.round(centerX - c.size.width / 2), y: c.position.y } }; if (mode === "top") return { ...c, position: { x: c.position.x, y: minY } }; if (mode === "bottom") return { ...c, position: { x: c.position.x, y: maxY - c.size.height } }; if (mode === "centerY") return { ...c, position: { x: c.position.x, y: Math.round(centerY - c.size.height / 2) } }; return c; }); const newLayout = { ...layout, components: newComponents }; setLayout(newLayout); saveToHistory(newLayout); }} onGroupDistribute={(orientation) => { const selected = layout.components.filter((c) => groupState.selectedComponents.includes(c.id)); if (selected.length < 3) return; // 균등 분배는 3개 이상 권장 const sorted = [...selected].sort((a, b) => orientation === "horizontal" ? a.position.x - b.position.x : a.position.y - b.position.y, ); if (orientation === "horizontal") { const left = sorted[0].position.x; const right = Math.max(...sorted.map((c) => c.position.x + c.size.width)); const totalWidth = right - left; const gaps = sorted.length - 1; const usedWidth = sorted.reduce((sum, c) => sum + c.size.width, 0); const gapSize = gaps > 0 ? Math.max(0, Math.round((totalWidth - usedWidth) / gaps)) : 0; let cursor = left; sorted.forEach((c, idx) => { c.position.x = cursor; cursor += c.size.width + gapSize; }); } else { const top = sorted[0].position.y; const bottom = Math.max(...sorted.map((c) => c.position.y + c.size.height)); const totalHeight = bottom - top; const gaps = sorted.length - 1; const usedHeight = sorted.reduce((sum, c) => sum + c.size.height, 0); const gapSize = gaps > 0 ? Math.max(0, Math.round((totalHeight - usedHeight) / gaps)) : 0; let cursor = top; sorted.forEach((c, idx) => { c.position.y = cursor; cursor += c.size.height + gapSize; }); } const newLayout = { ...layout, components: [...layout.components] }; setLayout(newLayout); saveToHistory(newLayout); }} /> {/* 메인 컨텐츠 영역 */}
{/* 좌측 사이드바 - 테이블 타입 */}

테이블 타입

{/* 검색 입력창 */}
handleSearchChange(e.target.value)} className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none" />
{/* 검색 결과 정보 */}
총 {filteredTables.length}개 테이블 중 {(currentPage - 1) * itemsPerPage + 1}- {Math.min(currentPage * itemsPerPage, filteredTables.length)}번째

테이블과 컬럼을 드래그하여 캔버스에 배치하세요.

{/* 테이블 목록 */}
{paginatedTables.map((table) => (
{/* 테이블 헤더 */}
startDrag( { type: "container", tableName: table.tableName, label: table.tableLabel, size: { width: 200, height: 80 }, // 픽셀 단위로 변경 }, e, ) } >
{table.tableLabel}
{table.tableName}
{/* 컬럼 목록 */} {expandedTables.has(table.tableName) && (
{table.columns.map((column) => (
{ console.log("Drag start - column:", column.columnName, "webType:", column.webType); const widgetType = getWidgetTypeFromWebType(column.webType || "text"); console.log("Drag start - widgetType:", widgetType); startDrag( { type: "widget", tableName: table.tableName, columnName: column.columnName, widgetType: widgetType as WebType, label: column.columnLabel || column.columnName, size: { width: 150, height: 40 }, // 픽셀 단위로 변경 }, e, ); }} >
{column.webType === "text" && } {column.webType === "email" && } {column.webType === "tel" && } {column.webType === "number" && } {column.webType === "decimal" && } {column.webType === "date" && } {column.webType === "datetime" && } {column.webType === "select" && } {column.webType === "dropdown" && } {column.webType === "textarea" && } {column.webType === "text_area" && } {column.webType === "checkbox" && } {column.webType === "boolean" && } {column.webType === "radio" && } {column.webType === "code" && } {column.webType === "entity" && } {column.webType === "file" && }
{column.columnLabel || column.columnName}
{column.columnName}
))}
)}
))}
{/* 페이징 컨트롤 */} {totalPages > 1 && (
{currentPage} / {totalPages}
)}
{/* 중앙: 캔버스 영역 */}
{layout.components.length === 0 ? (

빈 캔버스

좌측에서 테이블이나 컬럼을 드래그하여 배치하세요

) : (
{/* 그리드 가이드 */}
{Array.from({ length: 12 }).map((_, i) => (
))}
{/* 마키 선택 사각형 */} {selectionState.isSelecting && (
)} {/* 컴포넌트들 - 실시간 미리보기 */} {layout.components .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링 .map((component) => { // 그룹 컴포넌트인 경우 자식 컴포넌트들 가져오기 const children = component.type === "group" ? layout.components.filter((child) => child.parentId === component.id) : []; return ( handleComponentClick(component, e)} onDragStart={(e) => startComponentDrag(component, e)} onDragEnd={endDrag} onGroupToggle={(groupId) => { // 그룹 접기/펼치기 토글 const groupComp = component as GroupComponent; updateComponentProperty(groupId, "collapsed", !groupComp.collapsed); }} > {children.map((child) => ( handleComponentClick(child, e)} onDragStart={(e) => startComponentDrag(child, e)} onDragEnd={endDrag} /> ))} ); })}
)}
{/* 우측: 컴포넌트 스타일 편집 */}

컴포넌트 속성

{selectedComponent ? (
{selectedComponent.type === "container" && "테이블 속성"} {selectedComponent.type === "widget" && "위젯 속성"} {/* 위치 속성 */}
{ const val = (e.target as HTMLInputElement).valueAsNumber; if (Number.isFinite(val)) { updateComponentProperty(selectedComponent.id, "position.x", Math.round(val)); } }} />
{ const val = (e.target as HTMLInputElement).valueAsNumber; if (Number.isFinite(val)) { updateComponentProperty(selectedComponent.id, "position.y", Math.round(val)); } }} />
{/* 크기 속성 */}
{ const val = (e.target as HTMLInputElement).valueAsNumber; if (Number.isFinite(val)) { updateComponentProperty( selectedComponent.id, "size.width", Math.max(20, Math.round(val)), ); } }} />
{ const val = (e.target as HTMLInputElement).valueAsNumber; if (Number.isFinite(val)) { updateComponentProperty( selectedComponent.id, "size.height", Math.max(20, Math.round(val)), ); } }} />
{/* 테이블 정보 */}
{/* 위젯 전용 속성 */} {selectedComponent.type === "widget" && ( <>
updateComponentProperty(selectedComponent.id, "label", e.target.value)} />
updateComponentProperty(selectedComponent.id, "placeholder", e.target.value) } />
updateComponentProperty(selectedComponent.id, "required", e.target.checked) } />
updateComponentProperty(selectedComponent.id, "readonly", e.target.checked) } />
)} {/* 스타일 속성 */}
updateComponentProperty(selectedComponent.id, "style", newStyle)} />
{/* 고급 속성 */}
) : (

컴포넌트를 선택하여 속성을 편집하세요

)}
); }