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

1918 lines
71 KiB
TypeScript

"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 { screenApi } from "@/lib/api/screen";
import { toast } from "sonner";
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<LayoutData>({
components: [],
gridSettings: { columns: 12, gap: 16, padding: 16 },
});
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
// 실행취소/다시실행을 위한 히스토리 상태
const [history, setHistory] = useState<LayoutData[]>([
{
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));
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<GroupState>({
isGrouping: false,
selectedComponents: [],
groupTarget: null,
groupMode: "create",
});
// 그룹 생성 다이얼로그 상태
const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false);
const [tables, setTables] = useState<TableInfo[]>([]);
const [expandedTables, setExpandedTables] = useState<Set<string>>(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<string, string>();
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: {
...child.position,
x: child.position.x + group.position.x,
y: child.position.y + group.position.y,
z: child.position.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;
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 () => {
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) {
setLayout(savedLayout);
// 히스토리 초기화
setHistory([savedLayout]);
setHistoryIndex(0);
setHasUnsavedChanges(false); // 로드 완료 시 변경사항 플래그 해제
toast.success("레이아웃을 불러왔습니다.");
} else {
// 저장된 레이아웃이 없는 경우 기본 레이아웃 유지
const defaultLayout = {
components: [],
gridSettings: { columns: 12, gap: 16, padding: 16 },
};
setLayout(defaultLayout);
setHistory([defaultLayout]);
setHistoryIndex(0);
setHasUnsavedChanges(false);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
// 에러 시에도 기본 레이아웃으로 초기화
const defaultLayout = {
components: [],
gridSettings: { columns: 12, gap: 16, padding: 16 },
};
setLayout(defaultLayout);
setHistory([defaultLayout]);
setHistoryIndex(0);
setHasUnsavedChanges(false);
toast.error("레이아웃 로드에 실패했습니다. 새 레이아웃으로 시작합니다.");
} finally {
setIsLoading(false);
}
}, [selectedScreen]);
// 화면 선택 시 레이아웃 로드
useEffect(() => {
if (selectedScreen) {
loadLayout();
}
}, [selectedScreen, loadLayout]);
// 캔버스 참조 (좌표 계산 정확도 향상)
const canvasRef = useRef<HTMLDivElement | null>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
// 드래그 시작 (새 컴포넌트 추가)
const startDrag = useCallback((component: Partial<ComponentData>, 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) {
// 그룹 컨테이너는 다중선택에서 제외하고 단일 선택으로 처리
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 (
<div className="flex h-full items-center justify-center">
<div className="text-center text-gray-500">
<Palette className="mx-auto mb-4 h-16 w-16 text-gray-300" />
<p className="mb-4 text-lg"> </p>
<p className="mb-6 text-sm"> </p>
<Button onClick={onBackToList} variant="outline">
</Button>
</div>
</div>
);
}
return (
<div className="flex h-full w-full flex-col">
{/* 상단 헤더 */}
<div className="flex items-center justify-between border-b bg-white p-4 shadow-sm">
<div className="flex items-center space-x-4">
<h2 className="text-xl font-semibold text-gray-900">
{selectedScreen.screenName} -
{isLoading && <span className="ml-2 text-sm text-gray-500">( ...)</span>}
</h2>
<Badge variant="outline" className="font-mono">
{selectedScreen.tableName}
</Badge>
{clipboard && clipboard.data.length > 0 && (
<Badge variant="secondary" className="text-xs">
<Clipboard className="mr-1 h-3 w-3" />
{clipboard.type === "group"
? "그룹 복사됨"
: clipboard.type === "multiple"
? `${clipboard.data.length}개 복사됨`
: "컴포넌트 복사됨"}
</Badge>
)}
</div>
<div className="flex items-center space-x-2">
<Button
variant={groupState.isGrouping ? "default" : "outline"}
size="sm"
onClick={() => setGroupState((prev) => ({ ...prev, isGrouping: !prev.isGrouping }))}
title="그룹화 모드 토글 (일반 모드에서도 Shift+클릭으로 다중선택 가능)"
>
<Group className="mr-2 h-4 w-4" />
{groupState.isGrouping ? "그룹화 모드" : "일반 모드"}
</Button>
{/* 복사/붙여넣기/삭제 버튼들 */}
{(selectedComponent || groupState.selectedComponents.length > 0) && (
<>
<Button variant="outline" size="sm" onClick={copyComponents} title="복사 (Ctrl+C)">
<Copy className="mr-2 h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={deleteComponents} title="삭제 (Delete 키)">
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</>
)}
{/* 붙여넣기 버튼 */}
{clipboard && clipboard.data.length > 0 && (
<Button variant="outline" size="sm" onClick={() => pasteComponents()} title="붙여넣기 (Ctrl+V)">
<Clipboard className="mr-2 h-4 w-4" />
</Button>
)}
<Button variant="outline" size="sm" onClick={undo} disabled={historyIndex <= 0} title="실행 취소 (Ctrl+Z)">
<Undo className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={redo}
disabled={historyIndex >= history.length - 1}
title="다시 실행 (Ctrl+Y)"
>
<Redo className="mr-2 h-4 w-4" />
</Button>
<Button
onClick={saveLayout}
disabled={isSaving || !selectedScreen}
className={`${hasUnsavedChanges ? "bg-orange-600 hover:bg-orange-700" : "bg-blue-600 hover:bg-blue-700"}`}
>
<Save className="mr-2 h-4 w-4" />
{isSaving ? "저장 중..." : hasUnsavedChanges ? "저장 *" : "저장"}
</Button>
</div>
</div>
{/* 그룹화 툴바 */}
<GroupingToolbar
groupState={groupState}
onGroupStateChange={setGroupState}
onGroupCreate={handleGroupCreate}
onGroupUngroup={handleGroupUngroup}
selectedComponents={layout.components.filter((comp) => 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}
/>
{/* 메인 컨텐츠 영역 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측 사이드바 - 테이블 타입 */}
<div className="flex w-80 flex-col border-r bg-gray-50">
<div className="border-b bg-white p-4">
<h3 className="mb-4 text-lg font-medium"> </h3>
{/* 검색 입력창 */}
<div className="mb-4">
<input
type="text"
placeholder="테이블명, 컬럼명으로 검색..."
value={searchTerm}
onChange={(e) => 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"
/>
</div>
{/* 검색 결과 정보 */}
<div className="mb-2 text-sm text-gray-600">
{filteredTables.length} {(currentPage - 1) * itemsPerPage + 1}-
{Math.min(currentPage * itemsPerPage, filteredTables.length)}
</div>
<p className="mb-4 text-sm text-gray-600"> .</p>
</div>
{/* 테이블 목록 */}
<div className="flex-1 overflow-y-auto">
{paginatedTables.map((table) => (
<div key={table.tableName} className="border-b bg-white">
{/* 테이블 헤더 */}
<div
className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-100"
draggable
onDragStart={(e) =>
startDrag(
{
type: "container",
tableName: table.tableName,
label: table.tableLabel,
size: { width: 200, height: 80 }, // 픽셀 단위로 변경
},
e,
)
}
>
<div className="flex items-center space-x-2">
<Database className="h-4 w-4 text-blue-600" />
<div>
<div className="text-sm font-medium">{table.tableLabel}</div>
<div className="text-xs text-gray-500">{table.tableName}</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => toggleTableExpansion(table.tableName)}
className="h-6 w-6 p-0"
>
{expandedTables.has(table.tableName) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</div>
{/* 컬럼 목록 */}
{expandedTables.has(table.tableName) && (
<div className="bg-gray-25 border-t">
{table.columns.map((column) => (
<div
key={column.columnName}
className="flex cursor-pointer items-center space-x-2 p-2 pl-6 hover:bg-gray-100"
draggable
onDragStart={(e) => {
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,
);
}}
>
<div className="flex-shrink-0">
{column.webType === "text" && <Type className="h-3 w-3 text-blue-600" />}
{column.webType === "email" && <Type className="h-3 w-3 text-blue-600" />}
{column.webType === "tel" && <Type className="h-3 w-3 text-blue-600" />}
{column.webType === "number" && <Hash className="h-3 w-3 text-green-600" />}
{column.webType === "decimal" && <Hash className="h-3 w-3 text-green-600" />}
{column.webType === "date" && <Calendar className="h-3 w-3 text-purple-600" />}
{column.webType === "datetime" && <Calendar className="h-3 w-3 text-purple-600" />}
{column.webType === "select" && <List className="h-3 w-3 text-orange-600" />}
{column.webType === "dropdown" && <List className="h-3 w-3 text-orange-600" />}
{column.webType === "textarea" && <AlignLeft className="h-3 w-3 text-indigo-600" />}
{column.webType === "text_area" && <AlignLeft className="h-3 w-3 text-indigo-600" />}
{column.webType === "checkbox" && <CheckSquare className="h-3 w-3 text-blue-600" />}
{column.webType === "boolean" && <CheckSquare className="h-3 w-3 text-blue-600" />}
{column.webType === "radio" && <Radio className="h-3 w-3 text-blue-600" />}
{column.webType === "code" && <Code className="h-3 w-3 text-gray-600" />}
{column.webType === "entity" && <Building className="h-3 w-3 text-cyan-600" />}
{column.webType === "file" && <File className="h-3 w-3 text-yellow-600" />}
</div>
<div className="flex-1">
<div className="text-sm font-medium">{column.columnLabel || column.columnName}</div>
<div className="text-xs text-gray-500">{column.columnName}</div>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
{/* 페이징 컨트롤 */}
{totalPages > 1 && (
<div className="border-t bg-white p-4">
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
</Button>
<div className="text-sm text-gray-600">
{currentPage} / {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
</Button>
</div>
</div>
)}
</div>
{/* 중앙: 캔버스 영역 */}
<div className="flex-1 bg-white" ref={scrollContainerRef}>
<div className="h-full w-full overflow-auto p-6">
<div
className="min-h-full rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4"
onDrop={onDrop}
onDragOver={onDragOver}
ref={canvasRef}
onMouseDown={handleMarqueeStart}
onMouseMove={handleMarqueeMove}
onMouseUp={handleMarqueeEnd}
onContextMenu={handleCanvasContextMenu}
>
{layout.components.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-center text-gray-500">
<Grid3X3 className="mx-auto mb-4 h-16 w-16 text-gray-300" />
<p className="mb-2 text-lg font-medium"> </p>
<p className="text-sm"> </p>
</div>
</div>
) : (
<div className="relative min-h-[600px]">
{/* 그리드 가이드 */}
<div className="pointer-events-none absolute inset-0">
<div className="grid h-full grid-cols-12 gap-1">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="border-r border-gray-200 last:border-r-0" />
))}
</div>
</div>
{/* 마키 선택 사각형 */}
{selectionState.isSelecting && (
<div
className="pointer-events-none absolute z-50 border border-blue-400 bg-blue-200/20"
style={{
left: `${Math.min(selectionState.start.x, selectionState.current.x)}px`,
top: `${Math.min(selectionState.start.y, selectionState.current.y)}px`,
width: `${Math.abs(selectionState.current.x - selectionState.start.x)}px`,
height: `${Math.abs(selectionState.current.y - selectionState.start.y)}px`,
}}
/>
)}
{/* 컴포넌트들 - 실시간 미리보기 */}
{layout.components
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
.map((component) => {
// 그룹 컴포넌트인 경우 자식 컴포넌트들 가져오기
const children =
component.type === "group"
? layout.components.filter((child) => child.parentId === component.id)
: [];
return (
<RealtimePreview
key={component.id}
component={component}
isSelected={
selectedComponent?.id === component.id ||
groupState.selectedComponents.includes(component.id)
}
onClick={(e) => 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) => (
<RealtimePreview
key={child.id}
component={child}
isSelected={groupState.selectedComponents.includes(child.id)}
onClick={(e) => handleComponentClick(child, e)}
onDragStart={(e) => startComponentDrag(child, e)}
onDragEnd={endDrag}
/>
))}
</RealtimePreview>
);
})}
</div>
)}
</div>
</div>
</div>
{/* 우측: 컴포넌트 스타일 편집 */}
<div className="w-80 border-l bg-gray-50">
<div className="h-full p-4">
<h3 className="mb-4 text-lg font-medium"> </h3>
{selectedComponent ? (
<div className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">
{selectedComponent.type === "container" && "테이블 속성"}
{selectedComponent.type === "widget" && "위젯 속성"}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 위치 속성 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="positionX">X </Label>
<Input
id="positionX"
type="number"
min="0"
value={liveSelectedPosition.x}
onChange={(e) => {
const val = (e.target as HTMLInputElement).valueAsNumber;
if (Number.isFinite(val)) {
updateComponentProperty(selectedComponent.id, "position.x", Math.round(val));
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="positionY">Y </Label>
<Input
id="positionY"
type="number"
min="0"
value={liveSelectedPosition.y}
onChange={(e) => {
const val = (e.target as HTMLInputElement).valueAsNumber;
if (Number.isFinite(val)) {
updateComponentProperty(selectedComponent.id, "position.y", Math.round(val));
}
}}
/>
</div>
</div>
{/* 크기 속성 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="width"> ()</Label>
<Input
id="width"
type="number"
min="20"
value={selectedComponent.size.width}
onChange={(e) => {
const val = (e.target as HTMLInputElement).valueAsNumber;
if (Number.isFinite(val)) {
updateComponentProperty(
selectedComponent.id,
"size.width",
Math.max(20, Math.round(val)),
);
}
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="height"> ()</Label>
<Input
id="height"
type="number"
min="20"
value={selectedComponent.size.height}
onChange={(e) => {
const val = (e.target as HTMLInputElement).valueAsNumber;
if (Number.isFinite(val)) {
updateComponentProperty(
selectedComponent.id,
"size.height",
Math.max(20, Math.round(val)),
);
}
}}
/>
</div>
</div>
{/* 테이블 정보 */}
<Separator />
<div className="space-y-2">
<Label htmlFor="tableName"></Label>
<Input id="tableName" value={selectedComponent.tableName || ""} readOnly className="bg-gray-50" />
</div>
{/* 위젯 전용 속성 */}
{selectedComponent.type === "widget" && (
<>
<div className="space-y-2">
<Label htmlFor="columnName"></Label>
<Input
id="columnName"
value={selectedComponent.columnName || ""}
readOnly
className="bg-gray-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="widgetType"> </Label>
<Input
id="widgetType"
value={selectedComponent.widgetType || ""}
readOnly
className="bg-gray-50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="widgetLabel"></Label>
<Input
id="widgetLabel"
value={selectedComponent.label || ""}
onChange={(e) => updateComponentProperty(selectedComponent.id, "label", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="widgetPlaceholder"></Label>
<Input
id="widgetPlaceholder"
value={selectedComponent.placeholder || ""}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, "placeholder", e.target.value)
}
/>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="required"
checked={selectedComponent.required || false}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, "required", e.target.checked)
}
/>
<Label htmlFor="required"></Label>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="readonly"
checked={selectedComponent.readonly || false}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, "readonly", e.target.checked)
}
/>
<Label htmlFor="readonly"> </Label>
</div>
</div>
</>
)}
{/* 스타일 속성 */}
<Separator />
<div>
<Label className="text-sm font-medium"> </Label>
<StyleEditor
style={selectedComponent.style || {}}
onStyleChange={(newStyle) => updateComponentProperty(selectedComponent.id, "style", newStyle)}
/>
</div>
{/* 고급 속성 */}
<Separator />
<div className="space-y-2">
<Label htmlFor="parentId"> ID</Label>
<Input id="parentId" value={selectedComponent.parentId || ""} readOnly className="bg-gray-50" />
</div>
<Button variant="destructive" size="sm" onClick={deleteComponents} className="w-full">
<Trash2 className="mr-2 h-4 w-4" />
{selectedComponent.type === "group"
? "그룹 삭제"
: groupState.selectedComponents.length > 1
? `${groupState.selectedComponents.length}개 삭제`
: "컴포넌트 삭제"}
</Button>
</CardContent>
</Card>
</div>
) : (
<div className="py-8 text-center text-gray-500">
<Settings className="mx-auto mb-2 h-12 w-12 text-gray-300" />
<p> </p>
</div>
)}
</div>
</div>
</div>
</div>
);
}