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

1530 lines
56 KiB
TypeScript
Raw Normal View History

2025-09-01 11:48:12 +09:00
"use client";
2025-09-01 16:40:24 +09:00
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
2025-09-01 11:48:12 +09:00
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
2025-09-01 15:22:47 +09:00
2025-09-01 11:48:12 +09:00
import {
Palette,
Grid3X3,
Type,
Calendar,
Hash,
CheckSquare,
Radio,
Save,
Undo,
Redo,
Group,
Database,
Trash2,
Settings,
ChevronDown,
Code,
Building,
File,
List,
AlignLeft,
ChevronRight,
2025-09-01 11:48:12 +09:00
} from "lucide-react";
import {
ScreenDefinition,
ComponentData,
LayoutData,
GroupState,
WebType,
TableInfo,
2025-09-01 15:22:47 +09:00
GroupComponent,
2025-09-01 11:48:12 +09:00
} from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
2025-09-01 15:22:47 +09:00
import {
createGroupComponent,
calculateBoundingBox,
calculateRelativePositions,
restoreAbsolutePositions,
getGroupChildren,
} from "@/lib/utils/groupingUtils";
import { GroupingToolbar } from "./GroupingToolbar";
2025-09-01 11:48:12 +09:00
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";
2025-09-01 11:48:12 +09:00
interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null;
onBackToList: () => void;
2025-09-01 11:48:12 +09:00
}
interface ComponentMoveState {
isMoving: boolean;
movingComponent: ComponentData | null;
originalPosition: { x: number; y: number };
currentPosition: { x: number; y: number };
}
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
2025-09-01 11:48:12 +09:00
const [layout, setLayout] = useState<LayoutData>({
components: [],
gridSettings: { columns: 12, gap: 16, padding: 16 },
});
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
2025-09-01 15:22:47 +09:00
// 실행취소/다시실행을 위한 히스토리 상태
const [history, setHistory] = useState<LayoutData[]>([
{
components: [],
gridSettings: { columns: 12, gap: 16, padding: 16 },
},
]);
const [historyIndex, setHistoryIndex] = useState(0);
// 히스토리에 상태 저장
const saveToHistory = useCallback(
(newLayout: LayoutData) => {
setHistory((prevHistory) => {
const newHistory = prevHistory.slice(0, historyIndex + 1);
newHistory.push(JSON.parse(JSON.stringify(newLayout))); // 깊은 복사
return newHistory.slice(-50); // 최대 50개 히스토리 유지
});
setHistoryIndex((prevIndex) => Math.min(prevIndex + 1, 49));
},
[historyIndex],
);
// 실행취소
const undo = useCallback(() => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
setLayout(JSON.parse(JSON.stringify(history[newIndex])));
setSelectedComponent(null); // 선택 해제
}
}, [historyIndex, history]);
// 다시실행
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
setLayout(JSON.parse(JSON.stringify(history[newIndex])));
setSelectedComponent(null); // 선택 해제
}
}, [historyIndex, history]);
// 키보드 단축키 지원
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;
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [undo, redo]);
const [dragState, setDragState] = useState({
2025-09-01 11:48:12 +09:00
isDragging: false,
draggedComponent: null as ComponentData | null,
2025-09-01 16:40:24 +09:00
draggedComponents: [] as ComponentData[], // 다중선택된 컴포넌트들
originalPosition: { x: 0, y: 0 },
currentPosition: { x: 0, y: 0 },
2025-09-01 16:40:24 +09:00
isMultiDrag: false, // 다중 드래그 여부
initialMouse: { x: 0, y: 0 },
grabOffset: { x: 0, y: 0 },
2025-09-01 11:48:12 +09:00
});
const [groupState, setGroupState] = useState<GroupState>({
isGrouping: false,
selectedComponents: [],
groupTarget: null,
groupMode: "create",
});
2025-09-01 15:22:47 +09:00
const [tables, setTables] = useState<TableInfo[]>([]);
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
2025-09-01 11:48:12 +09:00
// 테이블 검색 및 페이징 상태 추가
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage] = useState(10);
2025-09-01 16:40:24 +09:00
// 드래그 박스(마키) 다중선택 상태
const [selectionState, setSelectionState] = useState({
isSelecting: false,
start: { x: 0, y: 0 },
current: { x: 0, y: 0 },
});
// 컴포넌트의 절대 좌표 계산 (그룹 자식은 부모 오프셋을 누적)
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());
}
};
2025-09-01 11:48:12 +09:00
fetchTables();
}, []);
2025-09-01 11:48:12 +09:00
// 검색된 테이블 필터링
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",
},
],
},
];
2025-09-01 11:48:12 +09:00
// 테이블 확장/축소 토글
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;
});
2025-09-01 11:48:12 +09:00
}, []);
// 웹타입에 따른 위젯 타입 매핑
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";
}
2025-09-01 11:48:12 +09:00
}, []);
// 컴포넌트 제거 함수
const removeComponent = useCallback(
(componentId: string) => {
2025-09-01 15:22:47 +09:00
const newLayout = {
...layout,
components: layout.components.filter((comp) => comp.id !== componentId),
};
setLayout(newLayout);
saveToHistory(newLayout);
if (selectedComponent?.id === componentId) {
setSelectedComponent(null);
}
},
2025-09-01 15:22:47 +09:00
[layout, selectedComponent, saveToHistory],
);
2025-09-01 11:48:12 +09:00
// 컴포넌트 속성 업데이트 함수
2025-09-01 15:22:47 +09:00
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;
2025-09-01 11:48:12 +09:00
}
2025-09-01 15:22:47 +09:00
return comp;
}),
};
setLayout(newLayout);
saveToHistory(newLayout);
},
[layout, saveToHistory],
);
2025-09-01 15:22:47 +09:00
// 그룹 생성 함수
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);
2025-09-01 15:57:49 +09:00
// 그룹 컴포넌트 생성 (경계 박스 정보 전달)
2025-09-01 15:22:47 +09:00
const groupComponent = createGroupComponent(
componentIds,
title,
{ x: boundingBox.minX, y: boundingBox.minY },
2025-09-01 15:57:49 +09:00
{ width: boundingBox.width, height: boundingBox.height },
2025-09-01 15:22:47 +09:00
style,
);
// 자식 컴포넌트들의 상대 위치 계산
2025-09-01 15:57:49 +09:00
const relativeChildren = calculateRelativePositions(
selectedComponents,
{
x: boundingBox.minX,
y: boundingBox.minY,
},
groupComponent.id,
);
2025-09-01 15:22:47 +09:00
// 새 레이아웃 생성
const newLayout = {
...layout,
components: [
2025-09-01 15:57:49 +09:00
// 그룹에 포함되지 않은 기존 컴포넌트들만 유지
...layout.components.filter((comp) => !componentIds.includes(comp.id)),
// 그룹 컴포넌트 추가
2025-09-01 15:22:47 +09:00
groupComponent,
2025-09-01 15:57:49 +09:00
// 자식 컴포넌트들도 유지 (parentId로 그룹과 연결)
2025-09-01 15:22:47 +09:00
...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: [
2025-09-01 15:57:49 +09:00
// 그룹과 그룹의 자식 컴포넌트들을 제외한 기존 컴포넌트들
...layout.components.filter((comp) => comp.id !== groupId && comp.parentId !== groupId),
2025-09-01 15:22:47 +09:00
// 절대 위치로 복원된 자식 컴포넌트들
...absoluteChildren,
],
};
setLayout(newLayout);
saveToHistory(newLayout);
},
[layout, saveToHistory],
);
2025-09-01 11:48:12 +09:00
// 레이아웃 저장 함수
const saveLayout = useCallback(async () => {
try {
// TODO: 실제 API 호출로 변경
console.log("레이아웃 저장:", layout);
// await saveLayoutAPI(selectedScreen.screenId, layout);
} catch (error) {
console.error("레이아웃 저장 실패:", error);
}
}, [layout, selectedScreen]);
2025-09-01 11:48:12 +09:00
2025-09-01 16:40:24 +09:00
// 캔버스 참조 (좌표 계산 정확도 향상)
const canvasRef = useRef<HTMLDivElement | null>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
// 드래그 시작 (새 컴포넌트 추가)
const startDrag = useCallback((component: Partial<ComponentData>, e: React.DragEvent) => {
2025-09-01 16:40:24 +09:00
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({
2025-09-01 11:48:12 +09:00
isDragging: true,
draggedComponent: component as ComponentData,
2025-09-01 16:40:24 +09:00
draggedComponents: [component as ComponentData],
originalPosition: { x: 0, y: 0 },
2025-09-01 16:40:24 +09:00
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));
2025-09-01 11:48:12 +09:00
}, []);
// 기존 컴포넌트 드래그 시작 (재배치)
2025-09-01 16:40:24 +09:00
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],
);
2025-09-01 11:48:12 +09:00
// 드래그 중
const onDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
if (dragState.isDragging) {
2025-09-01 16:40:24 +09:00
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 },
}));
}
2025-09-01 11:48:12 +09:00
},
[dragState.isDragging],
2025-09-01 11:48:12 +09:00
);
// 드롭 처리
2025-09-01 15:22:47 +09:00
const onDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
2025-09-01 15:22:47 +09:00
try {
const data = JSON.parse(e.dataTransfer.getData("application/json"));
2025-09-01 15:22:47 +09:00
if (data.isMoving) {
// 기존 컴포넌트 재배치
2025-09-01 16:40:24 +09:00
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);
}
2025-09-01 15:22:47 +09:00
} else {
// 새 컴포넌트 추가
2025-09-01 16:40:24 +09:00
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;
2025-09-01 15:22:47 +09:00
const newComponent: ComponentData = {
...data,
id: generateComponentId(),
position: { x, y },
} as ComponentData;
2025-09-01 15:22:47 +09:00
const newLayout = {
...layout,
components: [...layout.components, newComponent],
};
setLayout(newLayout);
saveToHistory(newLayout);
}
} catch (error) {
console.error("드롭 처리 중 오류:", error);
}
2025-09-01 15:22:47 +09:00
setDragState({
isDragging: false,
draggedComponent: null,
2025-09-01 16:40:24 +09:00
draggedComponents: [],
2025-09-01 15:22:47 +09:00
originalPosition: { x: 0, y: 0 },
currentPosition: { x: 0, y: 0 },
2025-09-01 16:40:24 +09:00
isMultiDrag: false,
initialMouse: { x: 0, y: 0 },
grabOffset: { x: 0, y: 0 },
2025-09-01 15:22:47 +09:00
});
},
2025-09-01 16:40:24 +09:00
[
layout,
saveToHistory,
dragState.initialMouse.x,
dragState.initialMouse.y,
dragState.grabOffset.x,
dragState.grabOffset.y,
],
2025-09-01 15:22:47 +09:00
);
// 드래그 종료
const endDrag = useCallback(() => {
setDragState({
isDragging: false,
draggedComponent: null,
2025-09-01 16:40:24 +09:00
draggedComponents: [],
originalPosition: { x: 0, y: 0 },
currentPosition: { x: 0, y: 0 },
2025-09-01 16:40:24 +09:00
isMultiDrag: false,
initialMouse: { x: 0, y: 0 },
grabOffset: { x: 0, y: 0 },
});
}, []);
// 컴포넌트 클릭 (선택)
2025-09-01 15:22:47 +09:00
const handleComponentClick = useCallback(
2025-09-01 16:40:24 +09:00
(component: ComponentData, event?: React.MouseEvent) => {
const isShiftPressed = event?.shiftKey || false;
// 그룹 컨테이너는 다중선택 대상에서 제외
const isGroupContainer = component.type === "group";
if (groupState.isGrouping || isShiftPressed) {
// 그룹화 모드이거나 시프트 키를 누른 경우 다중 선택
if (isGroupContainer) {
// 그룹 컨테이너 클릭은 다중선택에 포함하지 않고 무시
return;
}
2025-09-01 15:22:47 +09:00
const isSelected = groupState.selectedComponents.includes(component.id);
setGroupState((prev) => ({
...prev,
selectedComponents: isSelected
? prev.selectedComponents.filter((id) => id !== component.id)
: [...prev.selectedComponents, component.id],
}));
2025-09-01 16:40:24 +09:00
// 시프트 키로 선택한 경우 마지막 선택된 컴포넌트를 selectedComponent로 설정
if (isShiftPressed) {
setSelectedComponent(component);
}
2025-09-01 15:22:47 +09:00
} else {
// 일반 모드에서는 단일 선택
setSelectedComponent(component);
setGroupState((prev) => ({
...prev,
2025-09-01 16:40:24 +09:00
selectedComponents: isGroupContainer ? [] : [component.id],
2025-09-01 15:22:47 +09:00
}));
}
},
2025-09-01 16:40:24 +09:00
[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>
);
}
2025-09-01 11:48:12 +09:00
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} - </h2>
<Badge variant="outline" className="font-mono">
{selectedScreen.tableName}
</Badge>
</div>
<div className="flex items-center space-x-2">
2025-09-01 15:22:47 +09:00
<Button
variant={groupState.isGrouping ? "default" : "outline"}
size="sm"
onClick={() => setGroupState((prev) => ({ ...prev, isGrouping: !prev.isGrouping }))}
2025-09-01 16:40:24 +09:00
title="그룹화 모드 토글 (일반 모드에서도 Shift+클릭으로 다중선택 가능)"
2025-09-01 15:22:47 +09:00
>
<Group className="mr-2 h-4 w-4" />
{groupState.isGrouping ? "그룹화 모드" : "일반 모드"}
</Button>
<Button variant="outline" size="sm" onClick={undo} disabled={historyIndex <= 0} title="실행 취소 (Ctrl+Z)">
<Undo className="mr-2 h-4 w-4" />
</Button>
2025-09-01 15:22:47 +09:00
<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} className="bg-blue-600 hover:bg-blue-700">
<Save className="mr-2 h-4 w-4" />
</Button>
</div>
2025-09-01 11:48:12 +09:00
</div>
2025-09-01 15:22:47 +09:00
{/* 그룹화 툴바 */}
<GroupingToolbar
groupState={groupState}
onGroupStateChange={setGroupState}
onGroupCreate={handleGroupCreate}
onGroupUngroup={handleGroupUngroup}
selectedComponents={layout.components.filter((comp) => groupState.selectedComponents.includes(comp.id))}
allComponents={layout.components}
2025-09-01 16:40:24 +09:00
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);
}}
2025-09-01 15:22:47 +09:00
/>
{/* 메인 컨텐츠 영역 */}
<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">
{/* 테이블 헤더 */}
2025-09-01 11:48:12 +09:00
<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,
2025-09-01 16:40:24 +09:00
size: { width: 200, height: 80 }, // 픽셀 단위로 변경
},
e,
)
}
2025-09-01 11:48:12 +09:00
>
<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,
2025-09-01 16:40:24 +09:00
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>
{/* 중앙: 캔버스 영역 */}
2025-09-01 16:40:24 +09:00
<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}
2025-09-01 16:40:24 +09:00
ref={canvasRef}
onMouseDown={handleMarqueeStart}
onMouseMove={handleMarqueeMove}
onMouseUp={handleMarqueeEnd}
>
{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]">
2025-09-01 11:48:12 +09:00
{/* 그리드 가이드 */}
<div className="pointer-events-none absolute inset-0">
<div className="grid h-full grid-cols-12 gap-1">
2025-09-01 11:48:12 +09:00
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="border-r border-gray-200 last:border-r-0" />
))}
</div>
</div>
2025-09-01 16:40:24 +09:00
{/* 마키 선택 사각형 */}
{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`,
}}
/>
)}
{/* 컴포넌트들 - 실시간 미리보기 */}
2025-09-01 15:57:49 +09:00
{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)
}
2025-09-01 16:40:24 +09:00
onClick={(e) => handleComponentClick(component, e)}
2025-09-01 15:57:49 +09:00
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)}
2025-09-01 16:40:24 +09:00
onClick={(e) => handleComponentClick(child, e)}
2025-09-01 15:57:49 +09:00
onDragStart={(e) => startComponentDrag(child, e)}
onDragEnd={endDrag}
/>
))}
</RealtimePreview>
);
})}
2025-09-01 11:48:12 +09:00
</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>
2025-09-01 11:48:12 +09:00
{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={selectedComponent.position.x}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, "position.x", parseInt(e.target.value))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="positionY">Y </Label>
<Input
id="positionY"
type="number"
min="0"
value={selectedComponent.position.y}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, "position.y", parseInt(e.target.value))
}
/>
</div>
2025-09-01 11:48:12 +09:00
</div>
{/* 크기 속성 */}
<div className="grid grid-cols-2 gap-4">
2025-09-01 11:48:12 +09:00
<div className="space-y-2">
<Label htmlFor="width"> ()</Label>
2025-09-01 11:48:12 +09:00
<Input
id="width"
type="number"
min="1"
max="12"
value={selectedComponent.size.width}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, "size.width", parseInt(e.target.value))
}
2025-09-01 11:48:12 +09:00
/>
</div>
<div className="space-y-2">
<Label htmlFor="height"> ()</Label>
2025-09-01 11:48:12 +09:00
<Input
id="height"
type="number"
min="20"
value={selectedComponent.size.height}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, "size.height", parseInt(e.target.value))
}
2025-09-01 11:48:12 +09:00
/>
</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"
2025-09-01 11:48:12 +09:00
/>
</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 || ""}
2025-09-01 11:48:12 +09:00
onChange={(e) =>
updateComponentProperty(selectedComponent.id, "placeholder", e.target.value)
2025-09-01 11:48:12 +09:00
}
/>
</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={() => removeComponent(selectedComponent.id)}
className="w-full"
>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
</div>
2025-09-01 11:48:12 +09:00
) : (
<div className="py-8 text-center text-gray-500">
<Settings className="mx-auto mb-2 h-12 w-12 text-gray-300" />
2025-09-01 11:48:12 +09:00
<p> </p>
</div>
)}
</div>
</div>
2025-09-01 11:48:12 +09:00
</div>
</div>
);
}