"use client"; import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { Group, Database, Trash2, Copy, Clipboard } from "lucide-react"; import { ScreenDefinition, ComponentData, LayoutData, GroupState, WebType, TableInfo, GroupComponent, Position, } from "@/types/screen"; import { generateComponentId } from "@/lib/utils/generateId"; import { createGroupComponent, calculateBoundingBox, calculateRelativePositions, restoreAbsolutePositions, getGroupChildren, } from "@/lib/utils/groupingUtils"; import { calculateGridInfo, snapToGrid, snapSizeToGrid, generateGridLines, GridSettings as GridUtilSettings, } from "@/lib/utils/gridUtils"; import { GroupingToolbar } from "./GroupingToolbar"; import { screenApi } from "@/lib/api/screen"; import { toast } from "sonner"; import StyleEditor from "./StyleEditor"; import { RealtimePreview } from "./RealtimePreview"; import FloatingPanel from "./FloatingPanel"; import DesignerToolbar from "./DesignerToolbar"; import TablesPanel from "./panels/TablesPanel"; import PropertiesPanel from "./panels/PropertiesPanel"; import GridPanel from "./panels/GridPanel"; import { usePanelState, PanelConfig } from "@/hooks/usePanelState"; interface ScreenDesignerProps { selectedScreen: ScreenDefinition | null; onBackToList: () => void; } // 패널 설정 const panelConfigs: PanelConfig[] = [ { id: "tables", title: "테이블 목록", defaultPosition: "left", defaultWidth: 320, defaultHeight: 600, shortcutKey: "t", }, { id: "properties", title: "속성 편집", defaultPosition: "right", defaultWidth: 320, defaultHeight: 500, shortcutKey: "p", }, { id: "styles", title: "스타일 편집", defaultPosition: "right", defaultWidth: 320, defaultHeight: 400, shortcutKey: "s", }, { id: "grid", title: "격자 설정", defaultPosition: "right", defaultWidth: 280, defaultHeight: 450, shortcutKey: "g", }, ]; export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) { // 패널 상태 관리 const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels } = usePanelState(panelConfigs); const [layout, setLayout] = useState({ components: [], gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }, }); const [isSaving, setIsSaving] = useState(false); const [isLoading, setIsLoading] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [selectedComponent, setSelectedComponent] = useState(null); // 실행취소/다시실행을 위한 히스토리 상태 const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); // 그룹 상태 const [groupState, setGroupState] = useState({ selectedComponents: [], isGrouping: false, }); // 드래그 상태 const [dragState, setDragState] = useState({ isDragging: false, draggedComponent: null as ComponentData | null, originalPosition: { x: 0, y: 0 }, currentPosition: { x: 0, y: 0 }, grabOffset: { x: 0, y: 0 }, }); // 테이블 데이터 const [tables, setTables] = useState([]); const [searchTerm, setSearchTerm] = useState(""); // 클립보드 const [clipboard, setClipboard] = useState<{ type: "single" | "multiple" | "group"; data: ComponentData[]; } | null>(null); // 그룹 생성 다이얼로그 const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false); const canvasRef = useRef(null); // 격자 정보 계산 const gridInfo = useMemo(() => { if (!canvasRef.current || !layout.gridSettings) return null; return calculateGridInfo(canvasRef.current, layout.gridSettings); }, [layout.gridSettings]); // 격자 라인 생성 const gridLines = useMemo(() => { if (!gridInfo || !layout.gridSettings?.showGrid) return []; return generateGridLines(gridInfo, layout.gridSettings); }, [gridInfo, layout.gridSettings]); // 필터된 테이블 목록 const filteredTables = useMemo(() => { if (!searchTerm) return tables; return tables.filter( (table) => table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())), ); }, [tables, searchTerm]); // 히스토리에 저장 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)); setHasUnsavedChanges(true); }, [historyIndex], ); // 실행취소 const undo = useCallback(() => { if (historyIndex > 0) { setHistoryIndex((prev) => prev - 1); setLayout(history[historyIndex - 1]); } }, [history, historyIndex]); // 다시실행 const redo = useCallback(() => { if (historyIndex < history.length - 1) { setHistoryIndex((prev) => prev + 1); setLayout(history[historyIndex + 1]); } }, [history, historyIndex]); // 컴포넌트 속성 업데이트 const updateComponentProperty = useCallback( (componentId: string, path: string, value: any) => { const pathParts = path.split("."); const updatedComponents = layout.components.map((comp) => { if (comp.id !== componentId) return comp; const newComp = { ...comp }; let current: any = newComp; for (let i = 0; i < pathParts.length - 1; i++) { if (!current[pathParts[i]]) { current[pathParts[i]] = {}; } current = current[pathParts[i]]; } current[pathParts[pathParts.length - 1]] = value; // 크기 변경 시 격자 스냅 적용 if ((path === "size.width" || path === "size.height") && layout.gridSettings?.snapToGrid && gridInfo) { const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings); newComp.size = snappedSize; } return newComp; }); const newLayout = { ...layout, components: updatedComponents }; setLayout(newLayout); saveToHistory(newLayout); }, [layout, gridInfo, saveToHistory], ); // 테이블 데이터 로드 useEffect(() => { if (selectedScreen?.tableName) { const loadTables = async () => { try { setIsLoading(true); const response = await screenApi.getTableInfo([selectedScreen.tableName]); setTables(response.data || []); } catch (error) { // console.error("테이블 정보 로드 실패:", error); toast.error("테이블 정보를 불러오는데 실패했습니다."); } finally { setIsLoading(false); } }; loadTables(); } }, [selectedScreen?.tableName]); // 화면 레이아웃 로드 useEffect(() => { if (selectedScreen?.screenId) { const loadLayout = async () => { try { setIsLoading(true); const response = await screenApi.getScreenLayout(selectedScreen.screenId); if (response.success && response.data) { setLayout(response.data); setHistory([response.data]); setHistoryIndex(0); setHasUnsavedChanges(false); } } catch (error) { // console.error("레이아웃 로드 실패:", error); toast.error("화면 레이아웃을 불러오는데 실패했습니다."); } finally { setIsLoading(false); } }; loadLayout(); } }, [selectedScreen?.screenId]); // 저장 const handleSave = useCallback(async () => { if (!selectedScreen?.screenId) return; try { setIsSaving(true); // 🔍 디버깅: 저장할 레이아웃 데이터 확인 console.log("🔍 레이아웃 저장 요청:", { screenId: selectedScreen.screenId, componentsCount: layout.components.length, components: layout.components.map(c => ({ id: c.id, type: c.type, webTypeConfig: (c as any).webTypeConfig, })), }); const response = await screenApi.saveScreenLayout(selectedScreen.screenId, layout); if (response.success) { toast.success("화면이 저장되었습니다."); setHasUnsavedChanges(false); } else { toast.error("저장에 실패했습니다."); } } catch (error) { // console.error("저장 실패:", error); toast.error("저장 중 오류가 발생했습니다."); } finally { setIsSaving(false); } }, [selectedScreen?.screenId, layout]); // 드래그 앤 드롭 처리 const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); }, []); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); const dragData = e.dataTransfer.getData("application/json"); if (!dragData) return; try { const { type, table, column } = JSON.parse(dragData); 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 }, size: { width: 300, height: 200 }, }; } else if (type === "column") { // 컬럼 위젯 생성 newComponent = { id: generateComponentId(), type: "widget", label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명 tableName: table.tableName, columnName: column.columnName, widgetType: column.widgetType, dataType: column.dataType, required: column.required, position: { x, y, z: 1 }, size: { width: 200, height: 40 }, }; } else { return; } // 격자 스냅 적용 if (layout.gridSettings?.snapToGrid && gridInfo) { newComponent.position = snapToGrid(newComponent.position, gridInfo, layout.gridSettings as GridUtilSettings); newComponent.size = snapSizeToGrid(newComponent.size, gridInfo, layout.gridSettings as GridUtilSettings); } const newLayout = { ...layout, components: [...layout.components, newComponent], }; setLayout(newLayout); saveToHistory(newLayout); setSelectedComponent(newComponent); // 속성 패널 자동 열기 openPanel("properties"); } catch (error) { // console.error("드롭 처리 실패:", error); } }, [layout, gridInfo, saveToHistory, openPanel], ); // 컴포넌트 클릭 처리 const handleComponentClick = useCallback( (component: ComponentData, event?: React.MouseEvent) => { event?.stopPropagation(); setSelectedComponent(component); // 속성 패널 자동 열기 openPanel("properties"); }, [openPanel], ); // 컴포넌트 삭제 const deleteComponent = useCallback(() => { if (!selectedComponent) return; const newComponents = layout.components.filter((comp) => comp.id !== selectedComponent.id); const newLayout = { ...layout, components: newComponents }; setLayout(newLayout); saveToHistory(newLayout); setSelectedComponent(null); }, [selectedComponent, layout, saveToHistory]); // 컴포넌트 복사 const copyComponent = useCallback(() => { if (!selectedComponent) return; setClipboard({ type: "single", data: [{ ...selectedComponent, id: generateComponentId() }], }); toast.success("컴포넌트가 복사되었습니다."); }, [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, ...relativeChildren, ], }; setLayout(newLayout); saveToHistory(newLayout); setGroupState((prev) => ({ ...prev, selectedComponents: [] })); }, [layout, saveToHistory], ); // 키보드 이벤트 처리 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Delete 키로 컴포넌트 삭제 if (e.key === "Delete" && selectedComponent) { deleteComponent(); } // Ctrl+C로 복사 if (e.ctrlKey && e.key === "c" && selectedComponent) { copyComponent(); } // Ctrl+Z로 실행취소 if (e.ctrlKey && e.key === "z" && !e.shiftKey) { e.preventDefault(); undo(); } // Ctrl+Y 또는 Ctrl+Shift+Z로 다시실행 if ((e.ctrlKey && e.key === "y") || (e.ctrlKey && e.shiftKey && e.key === "z")) { e.preventDefault(); redo(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [selectedComponent, deleteComponent, copyComponent, undo, redo]); if (!selectedScreen) { return (

화면을 선택하세요

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

); } return (
{/* 상단 툴바 */} { toast.info("미리보기 기능은 준비 중입니다."); }} onTogglePanel={togglePanel} panelStates={panelStates} canUndo={historyIndex > 0} canRedo={historyIndex < history.length - 1} isSaving={isSaving} /> {/* 메인 캔버스 영역 (전체 화면) */}
{ if (e.target === e.currentTarget) { setSelectedComponent(null); setGroupState((prev) => ({ ...prev, selectedComponents: [] })); } }} onDrop={handleDrop} onDragOver={handleDragOver} > {/* 격자 라인 */} {gridLines.map((line, index) => (
))} {/* 컴포넌트들 */} {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)} > {children.map((child) => ( handleComponentClick(child, e)} /> ))} ); })} {/* 빈 캔버스 안내 */} {layout.components.length === 0 && (

캔버스가 비어있습니다

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

단축키: T(테이블), P(속성), S(스타일), G(격자)

)}
{/* 플로팅 패널들 */} closePanel("tables")} position="left" width={320} height={600} > { const dragData = { type: column ? "column" : "table", table, column, }; e.dataTransfer.setData("application/json", JSON.stringify(dragData)); }} selectedTableName={selectedScreen.tableName} /> closePanel("properties")} position="right" width={320} height={500} > closePanel("styles")} position="right" width={320} height={400} > {selectedComponent ? (
updateComponentProperty(selectedComponent.id, "style", newStyle)} />
) : (
컴포넌트를 선택하여 스타일을 편집하세요
)}
closePanel("grid")} position="right" width={280} height={450} > { const newLayout = { ...layout, gridSettings: settings }; setLayout(newLayout); saveToHistory(newLayout); }} onResetGrid={() => { const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }; const newLayout = { ...layout, gridSettings: defaultSettings }; setLayout(newLayout); saveToHistory(newLayout); }} /> {/* 그룹 생성 툴바 (필요시) */} {groupState.selectedComponents.length > 1 && (
)}
); }