-
-
-
테이블 타입
- {selectedScreen && (
-
-
선택된 화면
-
{selectedScreen.screenName}
-
-
- {selectedScreen.tableName}
-
-
- )}
-
+ {/* 메인 캔버스 영역 (전체 화면) */}
+
{
+ if (e.target === e.currentTarget && !selectionDrag.wasSelecting) {
+ setSelectedComponent(null);
+ setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
+ }
+ }}
+ onMouseDown={(e) => {
+ if (e.target === e.currentTarget) {
+ startSelectionDrag(e);
+ }
+ }}
+ onDrop={handleDrop}
+ onDragOver={handleDragOver}
+ >
+ {/* 격자 라인 */}
+ {gridLines.map((line, index) => (
+
+ ))}
- {/* 검색 입력창 */}
-
- 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"
- />
-
+ {/* 컴포넌트들 */}
+ {layout.components
+ .filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
+ .map((component) => {
+ const children =
+ component.type === "group" ? layout.components.filter((child) => child.parentId === component.id) : [];
- {/* 검색 결과 정보 */}
-
- 총 {filteredTables.length}개 테이블 중 {(currentPage - 1) * itemsPerPage + 1}-
- {Math.min(currentPage * itemsPerPage, filteredTables.length)}번째
-
+ // 드래그 중 시각적 피드백 (다중 선택 지원)
+ const isDraggingThis = dragState.isDragging && dragState.draggedComponent?.id === component.id;
+ const isBeingDragged =
+ dragState.isDragging && dragState.draggedComponents.some((dragComp) => dragComp.id === component.id);
-
테이블과 컬럼을 드래그하여 캔버스에 배치하세요.
-
+ let displayComponent = component;
- {/* 테이블 목록 */}
-
- {paginatedTables.length === 0 ? (
-
-
-
-
- {selectedScreen ? "테이블 정보를 불러오는 중..." : "화면을 선택해주세요"}
-
-
- {selectedScreen
- ? `${selectedScreen.tableName} 테이블의 컬럼 정보를 조회하고 있습니다.`
- : "화면을 선택하면 해당 테이블의 컬럼 정보가 표시됩니다."}
-
-
-
- ) : (
- paginatedTables.map((table) => (
-
- {/* 테이블 헤더 */}
-
- startDrag(
- {
- type: "container",
- tableName: table.tableName,
- label: table.tableLabel,
- size: { width: 200, height: 80 }, // 픽셀 단위로 변경
+ if (isBeingDragged) {
+ if (isDraggingThis) {
+ // 주 드래그 컴포넌트: 마우스 위치 기반으로 실시간 위치 업데이트
+ displayComponent = {
+ ...component,
+ position: dragState.currentPosition,
+ style: {
+ ...component.style,
+ opacity: 0.8,
+ transform: "scale(1.02)",
+ transition: "none",
+ zIndex: 9999,
+ },
+ };
+ } 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: 8888, // 주 컴포넌트보다 약간 낮게
+ },
+ };
+ }
+ }
+ }
+
+ return (
+
handleComponentClick(component, e)}
+ onDragStart={(e) => startComponentDrag(component, e)}
+ onDragEnd={endDrag}
+ >
+ {children.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: 9999,
},
- e,
- )
+ };
+ } 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,
+ },
+ };
+ }
}
- >
-
-
-
-
{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}
-
-
- ))}
-
- )}
-
- ))
- )}
-
+ return (
+
handleComponentClick(child, e)}
+ onDragStart={(e) => startComponentDrag(child, e)}
+ onDragEnd={endDrag}
+ />
+ );
+ })}
+
+ );
+ })}
- {/* 페이징 컨트롤 */}
- {totalPages > 1 && (
-
-
-
+ {/* 드래그 선택 영역 */}
+ {selectionDrag.isSelecting && (
+
+ )}
-
- {currentPage} / {totalPages}
-
-
-
-
-
- )}
-
-
- {/* 중앙: 캔버스 영역 */}
-
-
-
- {/* 항상 격자와 캔버스 표시 */}
-
- {/* 동적 그리드 가이드 */}
-
-
- {Array.from({ length: layout.gridSettings?.columns || 12 }).map((_, i) => (
-
- ))}
-
-
- {/* 격자 스냅이 활성화된 경우 추가 가이드라인 */}
- {layout.gridSettings?.snapToGrid && gridInfo && (
-
- {generateGridLines(
- canvasRef.current?.clientWidth || 800,
- canvasRef.current?.clientHeight || 600,
- layout.gridSettings as GridUtilSettings,
- ).verticalLines.map((x, i) => (
-
- ))}
- {generateGridLines(
- canvasRef.current?.clientWidth || 800,
- canvasRef.current?.clientHeight || 600,
- layout.gridSettings as GridUtilSettings,
- ).horizontalLines.map((y, 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}
- />
- ))}
-
- );
- })}
-
+ {/* 빈 캔버스 안내 */}
+ {layout.components.length === 0 && (
+
+
+
+
캔버스가 비어있습니다
+
좌측 테이블 패널에서 테이블이나 컬럼을 드래그하여 화면을 설계하세요
+
+ 단축키: T(테이블), P(속성), S(스타일), R(격자) | Ctrl+G(그룹생성), Ctrl+Shift+G(그룹해제)
+
+
+ 편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), Ctrl+Z(실행취소), Delete(삭제)
+
+
+ ⚠️ 브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다
+
-
-
- {/* 우측: 컴포넌트 스타일 편집 */}
-
-
- {/* 격자 설정 */}
-
-
-
컴포넌트 속성
-
- {selectedComponent ? (
-
-
-
-
- {selectedComponent.type === "container" && "테이블 속성"}
- {selectedComponent.type === "widget" && "위젯 속성"}
-
-
-
- {/* 위치 속성 */}
-
-
-
-
{
- const val = (e.target as HTMLInputElement).valueAsNumber;
- if (Number.isFinite(val)) {
- let newX = Math.round(val);
-
- // 격자 스냅이 활성화된 경우 격자에 맞춤
- if (layout.gridSettings?.snapToGrid && gridInfo) {
- const snappedPos = snapToGrid(
- {
- x: newX,
- y: selectedComponent.position.y,
- z: selectedComponent.position.z || 1,
- } as Required
,
- gridInfo,
- layout.gridSettings as GridUtilSettings,
- );
- newX = snappedPos.x;
- }
-
- updateComponentProperty(selectedComponent.id, "position.x", newX);
- }
- }}
- />
-
-
-
-
{
- const val = (e.target as HTMLInputElement).valueAsNumber;
- if (Number.isFinite(val)) {
- let newY = Math.round(val);
-
- // 격자 스냅이 활성화된 경우 격자에 맞춤
- if (layout.gridSettings?.snapToGrid && gridInfo) {
- const snappedPos = snapToGrid(
- {
- x: selectedComponent.position.x,
- y: newY,
- z: selectedComponent.position.z || 1,
- } as Required
,
- gridInfo,
- layout.gridSettings as GridUtilSettings,
- );
- newY = snappedPos.y;
- }
-
- updateComponentProperty(selectedComponent.id, "position.y", newY);
- }
- }}
- />
-
-
-
- {/* 크기 속성 */}
-
-
-
- {layout.gridSettings?.snapToGrid && gridInfo ? (
- // 격자 스냅이 활성화된 경우 컬럼 단위로 조정
-
-
{
- const { columnWidth } = gridInfo;
- const { gap } = layout.gridSettings;
- return Math.max(
- 1,
- Math.round((selectedComponent.size.width + gap) / (columnWidth + gap)),
- );
- })()}
- onChange={(e) => {
- const gridColumns = Math.max(
- 1,
- Math.min(layout.gridSettings!.columns, parseInt(e.target.value) || 1),
- );
- const { columnWidth } = gridInfo;
- const { gap } = layout.gridSettings!;
- const newWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
- updateComponentProperty(selectedComponent.id, "size.width", newWidth);
- }}
- />
-
실제 너비: {selectedComponent.size.width}px
-
- ) : (
- // 격자 스냅이 비활성화된 경우 픽셀 단위로 조정
-
{
- const val = (e.target as HTMLInputElement).valueAsNumber;
- if (Number.isFinite(val)) {
- const newWidth = Math.max(20, Math.round(val));
- updateComponentProperty(selectedComponent.id, "size.width", newWidth);
- }
- }}
- />
- )}
-
-
-
- {
- const val = (e.target as HTMLInputElement).valueAsNumber;
- if (Number.isFinite(val)) {
- let newHeight = Math.max(20, Math.round(val));
-
- // 격자 스냅이 활성화된 경우 20px 단위로 조정
- if (layout.gridSettings?.snapToGrid) {
- newHeight = Math.max(40, Math.round(newHeight / 20) * 20);
- }
-
- updateComponentProperty(selectedComponent.id, "size.height", newHeight);
- }
- }}
- />
-
-
-
- {/* 테이블 정보 */}
-
-
-
-
-
-
- {/* 위젯 전용 속성 */}
- {selectedComponent.type === "widget" && (
- <>
-
-
-
-
-
-
-
-
-
-
- updateComponentProperty(selectedComponent.id, "label", e.target.value)}
- />
-
-
-
-
- updateComponentProperty(selectedComponent.id, "placeholder", e.target.value)
- }
- />
-
-
- >
- )}
-
- {/* 스타일 속성 */}
-
-
-
- updateComponentProperty(selectedComponent.id, "style", newStyle)}
- />
-
-
- {/* 고급 속성 */}
-
-
-
-
-
-
-
-
-
- ) : (
-
-
-
컴포넌트를 선택하여 속성을 편집하세요
-
- )}
-
-
+ )}
+
+ {/* 플로팅 패널들 */}
+
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}
+ >
+ {
+ if (selectedComponent) {
+ updateComponentProperty(selectedComponent.id, path, value);
+ }
+ }}
+ onDeleteComponent={deleteComponent}
+ onCopyComponent={copyComponent}
+ />
+
+
+
closePanel("styles")}
+ position="right"
+ width={320}
+ height={400}
+ >
+ {selectedComponent ? (
+
+ updateComponentProperty(selectedComponent.id, "style", newStyle)}
+ />
+
+ ) : (
+
+ 컴포넌트를 선택하여 스타일을 편집하세요
+
+ )}
+
+
+
closePanel("grid")}
+ position="right"
+ width={280}
+ height={450}
+ >
+ {
+ const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true };
+ updateGridSettings(defaultSettings);
+ }}
+ />
+
+
+ {/* 그룹 생성 툴바 (필요시) */}
+ {groupState.selectedComponents.length > 1 && (
+
+ groupState.selectedComponents.includes(comp.id))}
+ allComponents={layout.components}
+ groupState={groupState}
+ onGroupStateChange={setGroupState}
+ onGroupCreate={(componentIds: string[], title: string, style?: any) => {
+ handleGroupCreate(componentIds, title, style);
+ }}
+ onGroupUngroup={() => {
+ // TODO: 그룹 해제 구현
+ }}
+ showCreateDialog={showGroupCreateDialog}
+ onShowCreateDialogChange={setShowGroupCreateDialog}
+ />
+
+ )}
);
}
diff --git a/frontend/components/screen/ScreenDesigner_new.tsx b/frontend/components/screen/ScreenDesigner_new.tsx
new file mode 100644
index 00000000..55b651b4
--- /dev/null
+++ b/frontend/components/screen/ScreenDesigner_new.tsx
@@ -0,0 +1,667 @@
+"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);
+ 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.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.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 && (
+
+
+
+ )}
+
+ );
+}
+
diff --git a/frontend/components/screen/ScreenDesigner_old.tsx b/frontend/components/screen/ScreenDesigner_old.tsx
new file mode 100644
index 00000000..ec019066
--- /dev/null
+++ b/frontend/components/screen/ScreenDesigner_old.tsx
@@ -0,0 +1,2157 @@
+"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([
+ {
+ components: [],
+ gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true },
+ },
+ ]);
+ 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));
+ setHasUnsavedChanges(true); // 변경사항 표시
+ },
+ [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 [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false);
+
+ // 캔버스 컨테이너 참조
+ const canvasRef = useRef(null);
+
+ // 격자 정보 계산
+ const gridInfo = useMemo(() => {
+ if (!layout.gridSettings) return null;
+
+ // canvasRef가 없거나 크기가 0인 경우 기본값 사용
+ let width = 800;
+ let height = 600;
+
+ if (canvasRef.current) {
+ const rect = canvasRef.current.getBoundingClientRect();
+ width = Math.max(rect.width || 800, 800);
+ height = Math.max(rect.height || 600, 600);
+ }
+
+ return calculateGridInfo(width, height, layout.gridSettings as GridUtilSettings);
+ }, [layout.gridSettings]);
+
+ // 격자 설정 변경 핸들러
+ const handleGridSettingsChange = useCallback(
+ (newGridSettings: GridUtilSettings) => {
+ let updatedComponents = layout.components;
+
+ // 격자 스냅이 활성화되어 있고 격자 정보가 있으며 컴포넌트가 있는 경우 기존 컴포넌트들을 새 격자에 맞춤
+ if (newGridSettings.snapToGrid && gridInfo && layout.components.length > 0) {
+ // 현재 캔버스 크기 가져오기
+ let canvasWidth = 800;
+ let canvasHeight = 600;
+
+ if (canvasRef.current) {
+ const rect = canvasRef.current.getBoundingClientRect();
+ canvasWidth = Math.max(rect.width || 800, 800);
+ canvasHeight = Math.max(rect.height || 600, 600);
+ }
+
+ const newGridInfo = calculateGridInfo(canvasWidth, canvasHeight, newGridSettings);
+
+ updatedComponents = layout.components.map((comp) => {
+ // 그룹의 자식 컴포넌트는 건드리지 않음 (그룹에서 처리)
+ if (comp.parentId) return comp;
+
+ // 기존 격자에서의 상대적 위치 계산 (격자 컬럼 단위)
+ const oldGridInfo = gridInfo;
+ const oldColumnWidth = oldGridInfo.columnWidth;
+ const oldGap = layout.gridSettings?.gap || 16;
+ const oldPadding = layout.gridSettings?.padding || 16;
+
+ // 기존 위치를 격자 컬럼/행 단위로 변환
+ const oldGridX = Math.round((comp.position.x - oldPadding) / (oldColumnWidth + oldGap));
+ const oldGridY = Math.round((comp.position.y - oldPadding) / 20); // 20px 단위
+
+ // 기존 크기를 격자 컬럼 단위로 변환
+ const oldGridColumns = Math.max(1, Math.round((comp.size.width + oldGap) / (oldColumnWidth + oldGap)));
+ const oldGridRows = Math.max(2, Math.round(comp.size.height / 20)); // 20px 단위
+
+ // 새 격자에서의 위치와 크기 계산
+ const newColumnWidth = newGridInfo.columnWidth;
+ const newGap = newGridSettings.gap;
+ const newPadding = newGridSettings.padding;
+
+ // 새 위치 계산 (격자 비율 유지)
+ const newX = newPadding + oldGridX * (newColumnWidth + newGap);
+ const newY = newPadding + oldGridY * 20;
+
+ // 새 크기 계산 (격자 비율 유지)
+ const newWidth = oldGridColumns * newColumnWidth + (oldGridColumns - 1) * newGap;
+ const newHeight = oldGridRows * 20;
+
+ return {
+ ...comp,
+ position: { x: newX, y: newY, z: comp.position.z || 1 },
+ size: { width: newWidth, height: newHeight },
+ };
+ });
+ }
+
+ const newLayout = {
+ ...layout,
+ components: updatedComponents,
+ gridSettings: newGridSettings,
+ };
+ setLayout(newLayout);
+ saveToHistory(newLayout);
+ },
+ [layout, saveToHistory, gridInfo],
+ );
+
+ 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 fetchScreenTable = async () => {
+ if (!selectedScreen?.tableName) {
+ setTables([]);
+ return;
+ }
+
+ try {
+ console.log(`=== 테이블 정보 조회 시작: ${selectedScreen.tableName} ===`);
+ const startTime = performance.now();
+
+ // 최적화된 단일 테이블 조회 API 사용
+ const response = await fetch(`http://localhost:8080/api/screen-management/tables/${selectedScreen.tableName}`, {
+ headers: {
+ Authorization: `Bearer ${localStorage.getItem("authToken")}`,
+ },
+ });
+
+ const endTime = performance.now();
+ console.log(`테이블 조회 완료: ${(endTime - startTime).toFixed(2)}ms`);
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.success && data.data) {
+ setTables([data.data]);
+ console.log(`테이블 ${selectedScreen.tableName} 로드 완료, 컬럼 ${data.data.columns.length}개`);
+ } else {
+ console.error("테이블 조회 실패:", data.message);
+ // 선택된 화면의 테이블에 대한 임시 데이터 생성
+ setTables([createMockTableForScreen(selectedScreen.tableName)]);
+ }
+ } else if (response.status === 404) {
+ console.warn(`테이블 ${selectedScreen.tableName}을 찾을 수 없습니다.`);
+ // 테이블이 존재하지 않는 경우 임시 데이터 생성
+ setTables([createMockTableForScreen(selectedScreen.tableName)]);
+ } else {
+ console.error("테이블 조회 실패:", response.status);
+ // 선택된 화면의 테이블에 대한 임시 데이터 생성
+ setTables([createMockTableForScreen(selectedScreen.tableName)]);
+ }
+ } catch (error) {
+ console.error("테이블 조회 중 오류:", error);
+ // 선택된 화면의 테이블에 대한 임시 데이터 생성
+ setTables([createMockTableForScreen(selectedScreen.tableName)]);
+ }
+ };
+
+ fetchScreenTable();
+ }, [selectedScreen?.tableName]);
+
+ // 검색된 테이블 필터링
+ 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 실패 시 사용)
+ // 사용하지 않는 getMockTables 함수 제거됨
+
+ // 특정 테이블에 대한 임시 데이터 생성
+ const createMockTableForScreen = (tableName: string): TableInfo => {
+ // 기본 컬럼들 생성
+ const baseColumns = [
+ {
+ tableName,
+ columnName: "id",
+ columnLabel: "ID",
+ webType: "number" as WebType,
+ dataType: "BIGINT",
+ isNullable: "NO",
+ },
+ {
+ tableName,
+ columnName: "name",
+ columnLabel: "이름",
+ webType: "text" as WebType,
+ dataType: "VARCHAR",
+ isNullable: "NO",
+ },
+ {
+ tableName,
+ columnName: "description",
+ columnLabel: "설명",
+ webType: "textarea" as WebType,
+ dataType: "TEXT",
+ isNullable: "YES",
+ },
+ {
+ tableName,
+ columnName: "created_date",
+ columnLabel: "생성일",
+ webType: "date" as WebType,
+ dataType: "TIMESTAMP",
+ isNullable: "NO",
+ },
+ {
+ tableName,
+ columnName: "updated_date",
+ columnLabel: "수정일",
+ webType: "date" as WebType,
+ dataType: "TIMESTAMP",
+ isNullable: "YES",
+ },
+ ];
+
+ return {
+ tableName,
+ tableLabel: `${tableName} (임시)`,
+ columns: baseColumns,
+ };
+ };
+
+ // 테이블 확장/축소 토글
+ 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;
+ case "g":
+ case "G":
+ e.preventDefault();
+ if (e.shiftKey) {
+ // Ctrl+Shift+G: 그룹 해제
+ const selectedComponents = layout.components.filter((comp) =>
+ groupState.selectedComponents.includes(comp.id),
+ );
+ if (selectedComponents.length === 1 && selectedComponents[0].type === "group") {
+ // 그룹 해제 로직을 직접 실행
+ const group = selectedComponents[0] as any;
+ const groupChildren = layout.components.filter((comp) => comp.parentId === group.id);
+
+ // 자식 컴포넌트들의 절대 위치 복원
+ const absoluteChildren = groupChildren.map((child) => ({
+ ...child,
+ position: {
+ x: child.position.x + group.position.x,
+ y: child.position.y + group.position.y,
+ z: (child.position as any).z || 1,
+ },
+ parentId: undefined,
+ }));
+
+ const newLayout = {
+ ...layout,
+ components: [
+ ...layout.components.filter((comp) => comp.id !== group.id && comp.parentId !== group.id),
+ ...absoluteChildren,
+ ],
+ };
+
+ setLayout(newLayout);
+ saveToHistory(newLayout);
+ setGroupState((prev) => ({
+ ...prev,
+ selectedComponents: [],
+ isGrouping: false,
+ }));
+ }
+ } else {
+ // Ctrl+G: 그룹 생성 다이얼로그 열기
+ const selectedComponents = layout.components.filter((comp) =>
+ groupState.selectedComponents.includes(comp.id),
+ );
+ if (selectedComponents.length >= 2) {
+ setShowGroupCreateDialog(true);
+ }
+ }
+ break;
+ }
+ } else if (e.key === "Delete") {
+ e.preventDefault();
+ // 선택된 컴포넌트(들) 삭제
+ deleteComponents();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [
+ undo,
+ redo,
+ copyComponents,
+ pasteComponents,
+ deleteComponents,
+ clipboard,
+ layout,
+ groupState,
+ saveToHistory,
+ setLayout,
+ setGroupState,
+ setShowGroupCreateDialog,
+ ]);
+
+ // 컴포넌트 속성 업데이트 함수
+ 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;
+
+ // 크기 변경 시 격자 스냅 적용
+ if (
+ (propertyPath === "size.width" || propertyPath === "size.height") &&
+ layout.gridSettings?.snapToGrid &&
+ gridInfo
+ ) {
+ const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings);
+ newComp.size = snappedSize;
+ }
+
+ 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, gridInfo],
+ );
+
+ // 그룹 생성 함수
+ 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 () => {
+ if (!selectedScreen) {
+ toast.error("저장할 화면이 선택되지 않았습니다.");
+ return;
+ }
+
+ try {
+ setIsSaving(true);
+ await screenApi.saveLayout(selectedScreen.screenId, layout);
+ setHasUnsavedChanges(false); // 저장 완료 시 변경사항 플래그 해제
+ toast.success("레이아웃이 성공적으로 저장되었습니다.");
+ } catch (error) {
+ console.error("레이아웃 저장 실패:", error);
+ toast.error("레이아웃 저장에 실패했습니다.");
+ } finally {
+ setIsSaving(false);
+ }
+ }, [layout, selectedScreen]);
+
+ // 레이아웃 로드 함수
+ const loadLayout = useCallback(async () => {
+ if (!selectedScreen) return;
+
+ try {
+ setIsLoading(true);
+ const savedLayout = await screenApi.getLayout(selectedScreen.screenId);
+
+ if (savedLayout && savedLayout.components) {
+ // 격자 설정이 없는 경우 기본값 추가
+ if (!savedLayout.gridSettings) {
+ savedLayout.gridSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true };
+ } else if (savedLayout.gridSettings.snapToGrid === undefined) {
+ savedLayout.gridSettings.snapToGrid = true;
+ }
+
+ setLayout(savedLayout);
+ // 히스토리 초기화
+ setHistory([savedLayout]);
+ setHistoryIndex(0);
+ setHasUnsavedChanges(false); // 로드 완료 시 변경사항 플래그 해제
+ toast.success("레이아웃을 불러왔습니다.");
+ } else {
+ // 저장된 레이아웃이 없는 경우 기본 레이아웃 유지
+ const defaultLayout = {
+ components: [],
+ gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true },
+ };
+ setLayout(defaultLayout);
+ setHistory([defaultLayout]);
+ setHistoryIndex(0);
+ setHasUnsavedChanges(false);
+ }
+ } catch (error) {
+ console.error("레이아웃 로드 실패:", error);
+ // 에러 시에도 기본 레이아웃으로 초기화
+ const defaultLayout = {
+ components: [],
+ gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true },
+ };
+ setLayout(defaultLayout);
+ setHistory([defaultLayout]);
+ setHistoryIndex(0);
+ setHasUnsavedChanges(false);
+ toast.error("레이아웃 로드에 실패했습니다. 새 레이아웃으로 시작합니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [selectedScreen]);
+
+ // 화면 선택 시 레이아웃 로드
+ useEffect(() => {
+ if (selectedScreen) {
+ loadLayout();
+ }
+ }, [selectedScreen, loadLayout]);
+
+ // 스크롤 컨테이너 참조 (좌표 계산 정확도 향상)
+ 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)) {
+ let newX = comp.position.x + deltaX;
+ let newY = comp.position.y + deltaY;
+
+ // 격자 스냅 적용
+ if (layout.gridSettings?.snapToGrid && gridInfo) {
+ const snappedPosition = snapToGrid(
+ { x: newX, y: newY, z: comp.position.z || 1 } as Required,
+ gridInfo,
+ layout.gridSettings as GridUtilSettings,
+ );
+ newX = snappedPosition.x;
+ newY = snappedPosition.y;
+ }
+
+ return {
+ ...comp,
+ position: {
+ x: newX,
+ y: newY,
+ z: comp.position.z || 1,
+ },
+ };
+ }
+ return comp;
+ }),
+ };
+ setLayout(newLayout);
+ saveToHistory(newLayout);
+ } else {
+ // 단일 드래그 처리
+ let x = mouseX - dragState.grabOffset.x;
+ let y = mouseY - dragState.grabOffset.y;
+
+ // 격자 스냅 적용
+ if (layout.gridSettings?.snapToGrid && gridInfo) {
+ const snappedPosition = snapToGrid(
+ { x, y, z: 1 } as Required,
+ gridInfo,
+ layout.gridSettings as GridUtilSettings,
+ );
+ x = snappedPosition.x;
+ y = snappedPosition.y;
+ }
+
+ const newLayout = {
+ ...layout,
+ components: layout.components.map((comp) =>
+ comp.id === data.id ? { ...comp, position: { x, y, z: comp.position.z || 1 } } : comp,
+ ),
+ };
+ setLayout(newLayout);
+ saveToHistory(newLayout);
+ }
+ } else {
+ // 새 컴포넌트 추가
+ const rect = canvasRef.current?.getBoundingClientRect();
+ // 스크롤 오프셋 추가하여 컨텐츠 좌표로 변환 (상위 스크롤 컨테이너 기준)
+ const scrollLeft = scrollContainerRef.current?.scrollLeft || 0;
+ const scrollTop = scrollContainerRef.current?.scrollTop || 0;
+ let x = rect ? e.clientX - rect.left + scrollLeft : 0;
+ let y = rect ? e.clientY - rect.top + scrollTop : 0;
+
+ // 격자 스냅 적용
+ if (layout.gridSettings?.snapToGrid && gridInfo) {
+ const snappedPosition = snapToGrid(
+ { x, y, z: 1 } as Required,
+ gridInfo,
+ layout.gridSettings as GridUtilSettings,
+ );
+ x = snappedPosition.x;
+ y = snappedPosition.y;
+ }
+
+ // 기본 크기를 격자에 맞춰 설정
+ let defaultWidth = data.size?.width || 200;
+ const defaultHeight = data.size?.height || 100;
+
+ if (layout.gridSettings?.snapToGrid && gridInfo) {
+ const { columnWidth } = gridInfo;
+ const { gap } = layout.gridSettings;
+ // 기본적으로 1컬럼 너비로 설정
+ const gridColumns = 1;
+ defaultWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
+ }
+
+ const newComponent: ComponentData = {
+ ...data,
+ id: generateComponentId(),
+ position: { x, y, z: 1 },
+ size: { width: defaultWidth, height: defaultHeight },
+ } 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,
+ gridInfo,
+ ],
+ );
+
+ // 드래그 종료
+ const endDrag = useCallback(() => {
+ // 격자 스냅 적용
+ if (dragState.isDragging && dragState.draggedComponent && gridInfo && layout.gridSettings?.snapToGrid) {
+ const component = dragState.draggedComponent;
+ const snappedPosition = snapToGrid(dragState.currentPosition, gridInfo, layout.gridSettings as GridUtilSettings);
+
+ // 스냅된 위치로 컴포넌트 업데이트
+ if (snappedPosition.x !== dragState.currentPosition.x || snappedPosition.y !== dragState.currentPosition.y) {
+ const updatedComponents = layout.components.map((comp) =>
+ comp.id === component.id ? { ...comp, position: snappedPosition } : comp,
+ );
+
+ const newLayout = { ...layout, components: updatedComponents };
+ setLayout(newLayout);
+ saveToHistory(newLayout);
+ }
+ }
+
+ 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 },
+ });
+ }, [dragState, gridInfo, layout, saveToHistory]);
+
+ // 컴포넌트 클릭 (선택)
+ const handleComponentClick = useCallback(
+ (component: ComponentData, event?: React.MouseEvent) => {
+ const isShiftPressed = event?.shiftKey || false;
+ const isGroupContainer = component.type === "group";
+
+ if (groupState.isGrouping || isShiftPressed) {
+ // 그룹화 모드이거나 시프트 키를 누른 경우 다중 선택
+ if (isGroupContainer) {
+ // 그룹 컨테이너는 다중선택에서 제외하고 단일 선택으로 처리
+ setSelectedComponent(component);
+ setGroupState((prev) => ({
+ ...prev,
+ selectedComponents: [component.id],
+ isGrouping: false, // 그룹 선택 시 그룹화 모드 해제
+ }));
+ 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: [component.id], // 그룹도 선택 가능하도록 수정
+ }));
+ }
+ },
+ [groupState.isGrouping, groupState.selectedComponents],
+ );
+
+ // 화면이 선택되지 않았을 때 처리
+ if (!selectedScreen) {
+ return (
+
+
+
+
설계할 화면을 선택해주세요
+
화면 목록에서 화면을 선택한 후 설계기를 사용하세요
+
+
+
+ );
+ }
+
+ return (
+
+ {/* 상단 툴바 */}
+
{
+ // TODO: 미리보기 기능 구현
+ toast.info("미리보기 기능은 준비 중입니다.");
+ }}
+ onTogglePanel={togglePanel}
+ panelStates={panelStates}
+ canUndo={historyIndex > 0}
+ canRedo={historyIndex < history.length - 1}
+ isSaving={isSaving}
+ />
+
+ {/* 메인 캔버스 영역 (전체 화면) */}
+ {
+ if (e.target === e.currentTarget) {
+ closeAllPanels();
+ setSelectedComponent(null);
+ setGroupState(prev => ({ ...prev, selectedComponents: [] }));
+ }
+ }}
+ onDrop={handleDrop}
+ onDragOver={handleDragOver}
+
+ {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);
+ }}
+ showCreateDialog={showGroupCreateDialog}
+ onShowCreateDialogChange={setShowGroupCreateDialog}
+ />
+
+ {/* 메인 컨텐츠 영역 */}
+
+ {/* 좌측 사이드바 - 테이블 타입 */}
+
+
+
+
테이블 타입
+ {selectedScreen && (
+
+
선택된 화면
+
{selectedScreen.screenName}
+
+
+ {selectedScreen.tableName}
+
+
+ )}
+
+
+ {/* 검색 입력창 */}
+
+ 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.length === 0 ? (
+
+
+
+
+ {selectedScreen ? "테이블 정보를 불러오는 중..." : "화면을 선택해주세요"}
+
+
+ {selectedScreen
+ ? `${selectedScreen.tableName} 테이블의 컬럼 정보를 조회하고 있습니다.`
+ : "화면을 선택하면 해당 테이블의 컬럼 정보가 표시됩니다."}
+
+
+
+ ) : (
+ 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}
+
+
+
+
+
+ )}
+
+
+ {/* 중앙: 캔버스 영역 */}
+
+
+
+ {/* 항상 격자와 캔버스 표시 */}
+
+ {/* 동적 그리드 가이드 */}
+
+
+ {Array.from({ length: layout.gridSettings?.columns || 12 }).map((_, i) => (
+
+ ))}
+
+
+ {/* 격자 스냅이 활성화된 경우 추가 가이드라인 */}
+ {layout.gridSettings?.snapToGrid && gridInfo && (
+
+ {generateGridLines(
+ canvasRef.current?.clientWidth || 800,
+ canvasRef.current?.clientHeight || 600,
+ layout.gridSettings as GridUtilSettings,
+ ).verticalLines.map((x, i) => (
+
+ ))}
+ {generateGridLines(
+ canvasRef.current?.clientWidth || 800,
+ canvasRef.current?.clientHeight || 600,
+ layout.gridSettings as GridUtilSettings,
+ ).horizontalLines.map((y, 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)) {
+ let newX = Math.round(val);
+
+ // 격자 스냅이 활성화된 경우 격자에 맞춤
+ if (layout.gridSettings?.snapToGrid && gridInfo) {
+ const snappedPos = snapToGrid(
+ {
+ x: newX,
+ y: selectedComponent.position.y,
+ z: selectedComponent.position.z || 1,
+ } as Required
,
+ gridInfo,
+ layout.gridSettings as GridUtilSettings,
+ );
+ newX = snappedPos.x;
+ }
+
+ updateComponentProperty(selectedComponent.id, "position.x", newX);
+ }
+ }}
+ />
+
+
+
+
{
+ const val = (e.target as HTMLInputElement).valueAsNumber;
+ if (Number.isFinite(val)) {
+ let newY = Math.round(val);
+
+ // 격자 스냅이 활성화된 경우 격자에 맞춤
+ if (layout.gridSettings?.snapToGrid && gridInfo) {
+ const snappedPos = snapToGrid(
+ {
+ x: selectedComponent.position.x,
+ y: newY,
+ z: selectedComponent.position.z || 1,
+ } as Required
,
+ gridInfo,
+ layout.gridSettings as GridUtilSettings,
+ );
+ newY = snappedPos.y;
+ }
+
+ updateComponentProperty(selectedComponent.id, "position.y", newY);
+ }
+ }}
+ />
+
+
+
+ {/* 크기 속성 */}
+
+
+
+ {layout.gridSettings?.snapToGrid && gridInfo ? (
+ // 격자 스냅이 활성화된 경우 컬럼 단위로 조정
+
+
{
+ const { columnWidth } = gridInfo;
+ const { gap } = layout.gridSettings;
+ return Math.max(
+ 1,
+ Math.round((selectedComponent.size.width + gap) / (columnWidth + gap)),
+ );
+ })()}
+ onChange={(e) => {
+ const gridColumns = Math.max(
+ 1,
+ Math.min(layout.gridSettings!.columns, parseInt(e.target.value) || 1),
+ );
+ const { columnWidth } = gridInfo;
+ const { gap } = layout.gridSettings!;
+ const newWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
+ updateComponentProperty(selectedComponent.id, "size.width", newWidth);
+ }}
+ />
+
실제 너비: {selectedComponent.size.width}px
+
+ ) : (
+ // 격자 스냅이 비활성화된 경우 픽셀 단위로 조정
+
{
+ const val = (e.target as HTMLInputElement).valueAsNumber;
+ if (Number.isFinite(val)) {
+ const newWidth = Math.max(20, Math.round(val));
+ updateComponentProperty(selectedComponent.id, "size.width", newWidth);
+ }
+ }}
+ />
+ )}
+
+
+
+ {
+ const val = (e.target as HTMLInputElement).valueAsNumber;
+ if (Number.isFinite(val)) {
+ let newHeight = Math.max(20, Math.round(val));
+
+ // 격자 스냅이 활성화된 경우 20px 단위로 조정
+ if (layout.gridSettings?.snapToGrid) {
+ newHeight = Math.max(40, Math.round(newHeight / 20) * 20);
+ }
+
+ updateComponentProperty(selectedComponent.id, "size.height", newHeight);
+ }
+ }}
+ />
+
+
+
+ {/* 테이블 정보 */}
+
+
+
+
+
+
+ {/* 위젯 전용 속성 */}
+ {selectedComponent.type === "widget" && (
+ <>
+
+
+
+
+
+
+
+
+
+
+ updateComponentProperty(selectedComponent.id, "label", e.target.value)}
+ />
+
+
+
+
+ updateComponentProperty(selectedComponent.id, "placeholder", e.target.value)
+ }
+ />
+
+
+ >
+ )}
+
+ {/* 스타일 속성 */}
+
+
+
+ updateComponentProperty(selectedComponent.id, "style", newStyle)}
+ />
+
+
+ {/* 고급 속성 */}
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
컴포넌트를 선택하여 속성을 편집하세요
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/components/screen/panels/GridPanel.tsx b/frontend/components/screen/panels/GridPanel.tsx
new file mode 100644
index 00000000..b8e600bc
--- /dev/null
+++ b/frontend/components/screen/panels/GridPanel.tsx
@@ -0,0 +1,223 @@
+"use client";
+
+import React from "react";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { Slider } from "@/components/ui/slider";
+import { Grid3X3, RotateCcw, Eye, EyeOff, Zap } from "lucide-react";
+import { GridSettings } from "@/types/screen";
+
+interface GridPanelProps {
+ gridSettings: GridSettings;
+ onGridSettingsChange: (settings: GridSettings) => void;
+ onResetGrid: () => void;
+}
+
+export const GridPanel: React.FC