841 lines
31 KiB
TypeScript
841 lines
31 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState, useCallback, useEffect } from "react";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
|
|
import { Separator } from "@/components/ui/separator";
|
||
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
|
|
import {
|
||
|
|
Palette,
|
||
|
|
Grid3X3,
|
||
|
|
Type,
|
||
|
|
Calendar,
|
||
|
|
Hash,
|
||
|
|
CheckSquare,
|
||
|
|
Radio,
|
||
|
|
FileText,
|
||
|
|
Save,
|
||
|
|
Undo,
|
||
|
|
Redo,
|
||
|
|
Eye,
|
||
|
|
Group,
|
||
|
|
Ungroup,
|
||
|
|
Database,
|
||
|
|
Trash2,
|
||
|
|
} from "lucide-react";
|
||
|
|
import {
|
||
|
|
ScreenDefinition,
|
||
|
|
ComponentData,
|
||
|
|
LayoutData,
|
||
|
|
DragState,
|
||
|
|
GroupState,
|
||
|
|
ComponentType,
|
||
|
|
WebType,
|
||
|
|
WidgetComponent,
|
||
|
|
ColumnInfo,
|
||
|
|
} from "@/types/screen";
|
||
|
|
import { generateComponentId } from "@/lib/utils/generateId";
|
||
|
|
import ContainerComponent from "./layout/ContainerComponent";
|
||
|
|
import RowComponent from "./layout/RowComponent";
|
||
|
|
import ColumnComponent from "./layout/ColumnComponent";
|
||
|
|
import WidgetFactory from "./WidgetFactory";
|
||
|
|
import TableTypeSelector from "./TableTypeSelector";
|
||
|
|
import ScreenPreview from "./ScreenPreview";
|
||
|
|
import TemplateManager from "./TemplateManager";
|
||
|
|
import StyleEditor from "./StyleEditor";
|
||
|
|
import { Label } from "@/components/ui/label";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
|
||
|
|
interface ScreenDesignerProps {
|
||
|
|
screen: ScreenDefinition;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ComponentMoveState {
|
||
|
|
isMoving: boolean;
|
||
|
|
movingComponent: ComponentData | null;
|
||
|
|
originalPosition: { x: number; y: number };
|
||
|
|
currentPosition: { x: number; y: number };
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function ScreenDesigner({ screen }: ScreenDesignerProps) {
|
||
|
|
const [layout, setLayout] = useState<LayoutData>({
|
||
|
|
components: [],
|
||
|
|
gridSettings: { columns: 12, gap: 16, padding: 16 },
|
||
|
|
});
|
||
|
|
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
|
||
|
|
const [dragState, setDragState] = useState<DragState>({
|
||
|
|
isDragging: false,
|
||
|
|
draggedItem: null,
|
||
|
|
draggedComponent: null,
|
||
|
|
dragSource: "toolbox",
|
||
|
|
dropTarget: null,
|
||
|
|
dragOffset: { x: 0, y: 0 },
|
||
|
|
});
|
||
|
|
const [groupState, setGroupState] = useState<GroupState>({
|
||
|
|
isGrouping: false,
|
||
|
|
selectedComponents: [],
|
||
|
|
groupTarget: null,
|
||
|
|
groupMode: "create",
|
||
|
|
});
|
||
|
|
const [moveState, setMoveState] = useState<ComponentMoveState>({
|
||
|
|
isMoving: false,
|
||
|
|
movingComponent: null,
|
||
|
|
originalPosition: { x: 0, y: 0 },
|
||
|
|
currentPosition: { x: 0, y: 0 },
|
||
|
|
});
|
||
|
|
|
||
|
|
// 기본 컴포넌트 정의
|
||
|
|
const basicComponents = [
|
||
|
|
{ type: "text", label: "텍스트 입력", color: "bg-blue-500", icon: "Type" },
|
||
|
|
{ type: "number", label: "숫자 입력", color: "bg-green-500", icon: "Hash" },
|
||
|
|
{ type: "date", label: "날짜 선택", color: "bg-purple-500", icon: "Calendar" },
|
||
|
|
{ type: "select", label: "선택 박스", color: "bg-orange-500", icon: "CheckSquare" },
|
||
|
|
{ type: "textarea", label: "텍스트 영역", color: "bg-indigo-500", icon: "FileText" },
|
||
|
|
{ type: "checkbox", label: "체크박스", color: "bg-pink-500", icon: "CheckSquare" },
|
||
|
|
{ type: "radio", label: "라디오 버튼", color: "bg-yellow-500", icon: "Radio" },
|
||
|
|
{ type: "file", label: "파일 업로드", color: "bg-red-500", icon: "FileText" },
|
||
|
|
{ type: "code", label: "코드 입력", color: "bg-gray-500", icon: "Hash" },
|
||
|
|
{ type: "entity", label: "엔티티 선택", color: "bg-teal-500", icon: "Database" },
|
||
|
|
];
|
||
|
|
|
||
|
|
const layoutComponents = [
|
||
|
|
{ type: "container", label: "컨테이너", color: "bg-gray-500" },
|
||
|
|
{ type: "row", label: "행", color: "bg-yellow-500" },
|
||
|
|
{ type: "column", label: "열", color: "bg-red-500" },
|
||
|
|
{ type: "group", label: "그룹", color: "bg-teal-500" },
|
||
|
|
];
|
||
|
|
|
||
|
|
// 드래그 시작
|
||
|
|
const startDrag = useCallback((componentType: ComponentType, source: "toolbox" | "canvas") => {
|
||
|
|
let componentData: ComponentData;
|
||
|
|
|
||
|
|
if (componentType === "widget") {
|
||
|
|
// 위젯 컴포넌트 생성
|
||
|
|
componentData = {
|
||
|
|
id: generateComponentId(componentType),
|
||
|
|
type: "widget",
|
||
|
|
position: { x: 0, y: 0 },
|
||
|
|
size: { width: 6, height: 50 },
|
||
|
|
label: "새 위젯",
|
||
|
|
tableName: "",
|
||
|
|
columnName: "",
|
||
|
|
widgetType: "text",
|
||
|
|
required: false,
|
||
|
|
readonly: false,
|
||
|
|
};
|
||
|
|
} else if (componentType === "container") {
|
||
|
|
// 컨테이너 컴포넌트 생성
|
||
|
|
componentData = {
|
||
|
|
id: generateComponentId(componentType),
|
||
|
|
type: "container",
|
||
|
|
position: { x: 0, y: 0 },
|
||
|
|
size: { width: 12, height: 100 },
|
||
|
|
title: "새 컨테이너",
|
||
|
|
children: [],
|
||
|
|
};
|
||
|
|
} else if (componentType === "row") {
|
||
|
|
// 행 컴포넌트 생성
|
||
|
|
componentData = {
|
||
|
|
id: generateComponentId(componentType),
|
||
|
|
type: "row",
|
||
|
|
position: { x: 0, y: 0 },
|
||
|
|
size: { width: 12, height: 100 },
|
||
|
|
children: [],
|
||
|
|
};
|
||
|
|
} else if (componentType === "column") {
|
||
|
|
// 열 컴포넌트 생성
|
||
|
|
componentData = {
|
||
|
|
id: generateComponentId(componentType),
|
||
|
|
type: "column",
|
||
|
|
position: { x: 0, y: 0 },
|
||
|
|
size: { width: 6, height: 100 },
|
||
|
|
children: [],
|
||
|
|
};
|
||
|
|
} else if (componentType === "group") {
|
||
|
|
// 그룹 컴포넌트 생성
|
||
|
|
componentData = {
|
||
|
|
id: generateComponentId(componentType),
|
||
|
|
type: "group",
|
||
|
|
position: { x: 0, y: 0 },
|
||
|
|
size: { width: 12, height: 200 },
|
||
|
|
title: "새 그룹",
|
||
|
|
children: [],
|
||
|
|
};
|
||
|
|
} else {
|
||
|
|
throw new Error(`지원하지 않는 컴포넌트 타입: ${componentType}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
setDragState((prev) => ({
|
||
|
|
...prev,
|
||
|
|
isDragging: true,
|
||
|
|
draggedComponent: componentData,
|
||
|
|
dragOffset: { x: 0, y: 0 },
|
||
|
|
}));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 드래그 종료
|
||
|
|
const endDrag = useCallback(() => {
|
||
|
|
setDragState({
|
||
|
|
isDragging: false,
|
||
|
|
draggedComponent: null,
|
||
|
|
dragOffset: { x: 0, y: 0 },
|
||
|
|
});
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 컴포넌트 추가
|
||
|
|
const addComponent = useCallback((component: ComponentData, position: { x: number; y: number }) => {
|
||
|
|
const newComponent = {
|
||
|
|
...component,
|
||
|
|
id: generateComponentId(component.type),
|
||
|
|
position,
|
||
|
|
};
|
||
|
|
|
||
|
|
setLayout((prev) => ({
|
||
|
|
...prev,
|
||
|
|
components: [...prev.components, newComponent],
|
||
|
|
}));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 컴포넌트 선택
|
||
|
|
const selectComponent = useCallback((component: ComponentData) => {
|
||
|
|
setSelectedComponent(component);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 컴포넌트 삭제
|
||
|
|
const removeComponent = useCallback((componentId: string) => {
|
||
|
|
setLayout((prev) => ({
|
||
|
|
...prev,
|
||
|
|
components: prev.components.filter((c) => c.id !== componentId),
|
||
|
|
}));
|
||
|
|
setSelectedComponent(null);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 컴포넌트 속성 업데이트
|
||
|
|
const updateComponentProperty = useCallback((componentId: string, path: string, value: any) => {
|
||
|
|
setLayout((prev) => ({
|
||
|
|
...prev,
|
||
|
|
components: prev.components.map((c) => {
|
||
|
|
if (c.id === componentId) {
|
||
|
|
const newComponent = { ...c } as any;
|
||
|
|
const keys = path.split(".");
|
||
|
|
let current = newComponent;
|
||
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
||
|
|
current = current[keys[i]];
|
||
|
|
}
|
||
|
|
current[keys[keys.length - 1]] = value;
|
||
|
|
return newComponent;
|
||
|
|
}
|
||
|
|
return c;
|
||
|
|
}),
|
||
|
|
}));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 레이아웃 저장
|
||
|
|
const saveLayout = useCallback(() => {
|
||
|
|
console.log("레이아웃 저장:", layout);
|
||
|
|
// TODO: API 호출로 레이아웃 저장
|
||
|
|
}, [layout]);
|
||
|
|
|
||
|
|
// 컴포넌트 재배치 시작
|
||
|
|
const startComponentMove = useCallback((component: ComponentData, e: React.MouseEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
e.stopPropagation();
|
||
|
|
|
||
|
|
setMoveState({
|
||
|
|
isMoving: true,
|
||
|
|
movingComponent: component,
|
||
|
|
originalPosition: { ...component.position },
|
||
|
|
currentPosition: { ...component.position },
|
||
|
|
});
|
||
|
|
|
||
|
|
setDragState((prev) => ({
|
||
|
|
...prev,
|
||
|
|
isDragging: true,
|
||
|
|
draggedComponent: component,
|
||
|
|
dragOffset: { x: 0, y: 0 },
|
||
|
|
}));
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 컴포넌트 재배치 중
|
||
|
|
const handleComponentMove = useCallback(
|
||
|
|
(e: MouseEvent) => {
|
||
|
|
if (!moveState.isMoving || !moveState.movingComponent) return;
|
||
|
|
|
||
|
|
const canvas = document.getElementById("design-canvas");
|
||
|
|
if (!canvas) return;
|
||
|
|
|
||
|
|
const rect = canvas.getBoundingClientRect();
|
||
|
|
const x = Math.floor((e.clientX - rect.left) / 50);
|
||
|
|
const y = Math.floor((e.clientY - rect.top) / 50);
|
||
|
|
|
||
|
|
setMoveState((prev) => ({
|
||
|
|
...prev,
|
||
|
|
currentPosition: { x, y },
|
||
|
|
}));
|
||
|
|
},
|
||
|
|
[moveState.isMoving, moveState.movingComponent],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 컴포넌트 재배치 완료
|
||
|
|
const endComponentMove = useCallback(() => {
|
||
|
|
if (!moveState.isMoving || !moveState.movingComponent) return;
|
||
|
|
|
||
|
|
const { movingComponent, currentPosition } = moveState;
|
||
|
|
|
||
|
|
// 위치 업데이트
|
||
|
|
setLayout((prev) => ({
|
||
|
|
...prev,
|
||
|
|
components: prev.components.map((c) => (c.id === movingComponent.id ? { ...c, position: currentPosition } : c)),
|
||
|
|
}));
|
||
|
|
|
||
|
|
// 상태 초기화
|
||
|
|
setMoveState({
|
||
|
|
isMoving: false,
|
||
|
|
movingComponent: null,
|
||
|
|
originalPosition: { x: 0, y: 0 },
|
||
|
|
currentPosition: { x: 0, y: 0 },
|
||
|
|
});
|
||
|
|
|
||
|
|
setDragState((prev) => ({
|
||
|
|
...prev,
|
||
|
|
isDragging: false,
|
||
|
|
draggedComponent: null,
|
||
|
|
dragOffset: { x: 0, y: 0 },
|
||
|
|
}));
|
||
|
|
}, [moveState]);
|
||
|
|
|
||
|
|
// 마우스 이벤트 리스너 등록/해제
|
||
|
|
useEffect(() => {
|
||
|
|
if (moveState.isMoving) {
|
||
|
|
document.addEventListener("mousemove", handleComponentMove);
|
||
|
|
document.addEventListener("mouseup", endComponentMove);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
document.removeEventListener("mousemove", handleComponentMove);
|
||
|
|
document.removeEventListener("mouseup", endComponentMove);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}, [moveState.isMoving, handleComponentMove, endComponentMove]);
|
||
|
|
|
||
|
|
// 컴포넌트 렌더링
|
||
|
|
const renderComponent = useCallback(
|
||
|
|
(component: ComponentData) => {
|
||
|
|
const isSelected = selectedComponent === component;
|
||
|
|
const isMoving = moveState.isMoving && moveState.movingComponent?.id === component.id;
|
||
|
|
const currentPosition = isMoving ? moveState.currentPosition : component.position;
|
||
|
|
|
||
|
|
switch (component.type) {
|
||
|
|
case "container":
|
||
|
|
return (
|
||
|
|
<ContainerComponent
|
||
|
|
key={component.id}
|
||
|
|
component={{
|
||
|
|
...component,
|
||
|
|
position: currentPosition,
|
||
|
|
}}
|
||
|
|
isSelected={isSelected}
|
||
|
|
onClick={() => selectComponent(component)}
|
||
|
|
onMouseDown={(e) => startComponentMove(component, e)}
|
||
|
|
isMoving={isMoving}
|
||
|
|
>
|
||
|
|
{/* 컨테이너 내부의 자식 컴포넌트들 */}
|
||
|
|
{layout.components.filter((c) => c.parentId === component.id).map(renderComponent)}
|
||
|
|
</ContainerComponent>
|
||
|
|
);
|
||
|
|
case "row":
|
||
|
|
return (
|
||
|
|
<RowComponent
|
||
|
|
key={component.id}
|
||
|
|
component={{
|
||
|
|
...component,
|
||
|
|
position: currentPosition,
|
||
|
|
}}
|
||
|
|
isSelected={isSelected}
|
||
|
|
onClick={() => selectComponent(component)}
|
||
|
|
onMouseDown={(e) => startComponentMove(component, e)}
|
||
|
|
isMoving={isMoving}
|
||
|
|
>
|
||
|
|
{/* 행 내부의 자식 컴포넌트들 */}
|
||
|
|
{layout.components.filter((c) => c.parentId === component.id).map(renderComponent)}
|
||
|
|
</RowComponent>
|
||
|
|
);
|
||
|
|
case "column":
|
||
|
|
return (
|
||
|
|
<ColumnComponent
|
||
|
|
key={component.id}
|
||
|
|
component={{
|
||
|
|
...component,
|
||
|
|
position: currentPosition,
|
||
|
|
}}
|
||
|
|
isSelected={isSelected}
|
||
|
|
onClick={() => selectComponent(component)}
|
||
|
|
onMouseDown={(e) => startComponentMove(component, e)}
|
||
|
|
isMoving={isMoving}
|
||
|
|
>
|
||
|
|
{/* 열 내부의 자식 컴포넌트들 */}
|
||
|
|
{layout.components.filter((c) => c.parentId === component.id).map(renderComponent)}
|
||
|
|
</ColumnComponent>
|
||
|
|
);
|
||
|
|
case "widget":
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={component.id}
|
||
|
|
className={`absolute cursor-move rounded border-2 border-transparent hover:border-blue-300 ${
|
||
|
|
isSelected ? "border-blue-500" : ""
|
||
|
|
} ${isMoving ? "z-50" : ""}`}
|
||
|
|
style={{
|
||
|
|
left: `${currentPosition.x * 50}px`,
|
||
|
|
top: `${currentPosition.y * 50}px`,
|
||
|
|
width: `${component.size.width * 50}px`,
|
||
|
|
height: `${component.size.height}px`,
|
||
|
|
}}
|
||
|
|
onClick={() => selectComponent(component)}
|
||
|
|
onMouseDown={(e) => startComponentMove(component, e)}
|
||
|
|
>
|
||
|
|
<WidgetFactory widget={component} />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
default:
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[selectedComponent, moveState, selectComponent, startComponentMove, layout.components],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 테이블 타입에서 컬럼 선택 시 위젯 생성
|
||
|
|
const handleColumnSelect = useCallback(
|
||
|
|
(column: ColumnInfo) => {
|
||
|
|
const widgetComponent: WidgetComponent = {
|
||
|
|
id: generateComponentId("widget"),
|
||
|
|
type: "widget",
|
||
|
|
position: { x: 0, y: 0 },
|
||
|
|
size: { width: 6, height: 50 },
|
||
|
|
parentId: undefined,
|
||
|
|
tableName: column.tableName,
|
||
|
|
columnName: column.columnName,
|
||
|
|
widgetType: column.webType || "text",
|
||
|
|
label: column.columnLabel || column.columnName,
|
||
|
|
placeholder: `${column.columnLabel || column.columnName}을(를) 입력하세요`,
|
||
|
|
required: column.isNullable === "NO",
|
||
|
|
readonly: false,
|
||
|
|
validationRules: [],
|
||
|
|
displayProperties: {},
|
||
|
|
style: {
|
||
|
|
// 웹 타입별 기본 스타일
|
||
|
|
...(column.webType === "date" && {
|
||
|
|
backgroundColor: "#fef3c7",
|
||
|
|
border: "1px solid #f59e0b",
|
||
|
|
}),
|
||
|
|
...(column.webType === "number" && {
|
||
|
|
backgroundColor: "#dbeafe",
|
||
|
|
border: "1px solid #3b82f6",
|
||
|
|
}),
|
||
|
|
...(column.webType === "select" && {
|
||
|
|
backgroundColor: "#f3e8ff",
|
||
|
|
border: "1px solid #8b5cf6",
|
||
|
|
}),
|
||
|
|
...(column.webType === "checkbox" && {
|
||
|
|
backgroundColor: "#dcfce7",
|
||
|
|
border: "1px solid #22c55e",
|
||
|
|
}),
|
||
|
|
...(column.webType === "radio" && {
|
||
|
|
backgroundColor: "#fef3c7",
|
||
|
|
border: "1px solid #f59e0b",
|
||
|
|
}),
|
||
|
|
...(column.webType === "textarea" && {
|
||
|
|
backgroundColor: "#f1f5f9",
|
||
|
|
border: "1px solid #64748b",
|
||
|
|
}),
|
||
|
|
...(column.webType === "file" && {
|
||
|
|
backgroundColor: "#fef2f2",
|
||
|
|
border: "1px solid #ef4444",
|
||
|
|
}),
|
||
|
|
...(column.webType === "code" && {
|
||
|
|
backgroundColor: "#fef2f2",
|
||
|
|
border: "1px solid #ef4444",
|
||
|
|
fontFamily: "monospace",
|
||
|
|
}),
|
||
|
|
...(column.webType === "entity" && {
|
||
|
|
backgroundColor: "#f0f9ff",
|
||
|
|
border: "1px solid #0ea5e9",
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
// 현재 캔버스의 빈 위치 찾기
|
||
|
|
const occupiedPositions = new Set();
|
||
|
|
layout.components.forEach((comp) => {
|
||
|
|
for (let x = comp.position.x; x < comp.position.x + comp.size.width; x++) {
|
||
|
|
for (let y = comp.position.y; y < comp.position.y + comp.size.height; y++) {
|
||
|
|
occupiedPositions.add(`${x},${y}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// 빈 위치 찾기
|
||
|
|
let newX = 0,
|
||
|
|
newY = 0;
|
||
|
|
for (let y = 0; y < 20; y++) {
|
||
|
|
for (let x = 0; x < 12; x++) {
|
||
|
|
let canPlace = true;
|
||
|
|
for (let dx = 0; dx < widgetComponent.size.width; dx++) {
|
||
|
|
for (let dy = 0; dy < Math.ceil(widgetComponent.size.height / 50); dy++) {
|
||
|
|
if (occupiedPositions.has(`${x + dx},${y + dy}`)) {
|
||
|
|
canPlace = false;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!canPlace) break;
|
||
|
|
}
|
||
|
|
if (canPlace) {
|
||
|
|
newX = x;
|
||
|
|
newY = y;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (newX !== 0 || newY !== 0) break;
|
||
|
|
}
|
||
|
|
|
||
|
|
widgetComponent.position = { x: newX, y: newY };
|
||
|
|
addComponent(widgetComponent, { x: newX, y: newY });
|
||
|
|
},
|
||
|
|
[layout.components, addComponent],
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex h-full space-x-4">
|
||
|
|
{/* 왼쪽 툴바 */}
|
||
|
|
<div className="w-64 space-y-4">
|
||
|
|
{/* 기본 컴포넌트 */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader className="pb-3">
|
||
|
|
<CardTitle className="text-sm font-medium">기본 컴포넌트</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-2">
|
||
|
|
{basicComponents.map((component) => (
|
||
|
|
<div
|
||
|
|
key={component.type}
|
||
|
|
className={`flex cursor-pointer items-center rounded border p-2 hover:bg-gray-50 ${
|
||
|
|
dragState.isDragging && dragState.draggedComponent?.type === "widget"
|
||
|
|
? "border-blue-500 bg-blue-50"
|
||
|
|
: "border-gray-200"
|
||
|
|
}`}
|
||
|
|
draggable
|
||
|
|
onDragStart={() => startDrag(component.type as ComponentType, "toolbox")}
|
||
|
|
onDragEnd={endDrag}
|
||
|
|
>
|
||
|
|
<div className={`h-3 w-3 rounded-full ${component.color} mr-2`} />
|
||
|
|
<span className="text-sm">{component.label}</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* 레이아웃 컴포넌트 */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader className="pb-3">
|
||
|
|
<CardTitle className="text-sm font-medium">레이아웃 컴포넌트</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-2">
|
||
|
|
{layoutComponents.map((component) => (
|
||
|
|
<div
|
||
|
|
key={component.type}
|
||
|
|
className={`flex cursor-pointer items-center rounded border p-2 hover:bg-gray-50 ${
|
||
|
|
dragState.isDragging && dragState.draggedComponent?.type === component.type
|
||
|
|
? "border-blue-500 bg-blue-50"
|
||
|
|
: "border-gray-200"
|
||
|
|
}`}
|
||
|
|
draggable
|
||
|
|
onDragStart={() => startDrag(component.type as ComponentType, "toolbox")}
|
||
|
|
onDragEnd={endDrag}
|
||
|
|
>
|
||
|
|
<div className={`h-3 w-3 rounded-full ${component.color} mr-2`} />
|
||
|
|
<span className="text-sm">{component.label}</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 중앙 메인 영역 */}
|
||
|
|
<div className="flex-1">
|
||
|
|
<Tabs defaultValue="design" className="h-full">
|
||
|
|
<TabsList className="grid w-full grid-cols-3">
|
||
|
|
<TabsTrigger value="design" className="flex items-center gap-2">
|
||
|
|
<Palette className="h-4 w-4" />
|
||
|
|
화면 설계
|
||
|
|
</TabsTrigger>
|
||
|
|
<TabsTrigger value="table" className="flex items-center gap-2">
|
||
|
|
<Database className="h-4 w-4" />
|
||
|
|
테이블 타입
|
||
|
|
</TabsTrigger>
|
||
|
|
<TabsTrigger value="preview" className="flex items-center gap-2">
|
||
|
|
<Eye className="h-4 w-4" />
|
||
|
|
미리보기
|
||
|
|
</TabsTrigger>
|
||
|
|
</TabsList>
|
||
|
|
|
||
|
|
{/* 화면 설계 탭 */}
|
||
|
|
<TabsContent value="design" className="h-full">
|
||
|
|
<Card className="h-full">
|
||
|
|
<CardHeader className="pb-3">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<CardTitle className="text-sm font-medium">{screen.screenName} - 캔버스</CardTitle>
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<Button variant="outline" size="sm" onClick={() => console.log("실행 취소")}>
|
||
|
|
<Undo className="mr-1 h-4 w-4" />
|
||
|
|
실행 취소
|
||
|
|
</Button>
|
||
|
|
<Button variant="outline" size="sm" onClick={() => console.log("다시 실행")}>
|
||
|
|
<Redo className="mr-1 h-4 w-4" />
|
||
|
|
다시 실행
|
||
|
|
</Button>
|
||
|
|
<Button onClick={saveLayout}>
|
||
|
|
<Save className="mr-1 h-4 w-4" />
|
||
|
|
저장
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="h-full">
|
||
|
|
<div
|
||
|
|
id="design-canvas"
|
||
|
|
className="relative h-full w-full rounded-lg border-2 border-dashed border-gray-300 bg-gray-50"
|
||
|
|
onDrop={(e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
if (dragState.draggedComponent && dragState.draggedComponent.type === "widget") {
|
||
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
||
|
|
const x = Math.floor((e.clientX - rect.left) / 50);
|
||
|
|
const y = Math.floor((e.clientY - rect.top) / 50);
|
||
|
|
|
||
|
|
// 위젯 컴포넌트의 경우 기본 컴포넌트에서 타입 정보를 가져옴
|
||
|
|
const basicComponent = basicComponents.find(
|
||
|
|
(c) => c.type === (dragState.draggedComponent as any).widgetType,
|
||
|
|
);
|
||
|
|
if (basicComponent) {
|
||
|
|
const widgetComponent: ComponentData = {
|
||
|
|
...dragState.draggedComponent,
|
||
|
|
position: { x, y },
|
||
|
|
label: basicComponent.label,
|
||
|
|
widgetType: basicComponent.type as WebType,
|
||
|
|
} as WidgetComponent;
|
||
|
|
addComponent(widgetComponent, { x, y });
|
||
|
|
}
|
||
|
|
} else if (dragState.draggedComponent) {
|
||
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
||
|
|
const x = Math.floor((e.clientX - rect.left) / 50);
|
||
|
|
const y = Math.floor((e.clientY - rect.top) / 50);
|
||
|
|
addComponent(dragState.draggedComponent, { x, y });
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
onDragOver={(e) => e.preventDefault()}
|
||
|
|
>
|
||
|
|
{/* 그리드 가이드 */}
|
||
|
|
<div className="pointer-events-none absolute inset-0">
|
||
|
|
<div className="grid h-full grid-cols-12">
|
||
|
|
{Array.from({ length: 12 }).map((_, i) => (
|
||
|
|
<div key={i} className="border-r border-gray-200 last:border-r-0" />
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 컴포넌트들 렌더링 */}
|
||
|
|
{layout.components.length > 0 ? (
|
||
|
|
layout.components
|
||
|
|
.filter((c) => !c.parentId) // 최상위 컴포넌트만 렌더링
|
||
|
|
.map(renderComponent)
|
||
|
|
) : (
|
||
|
|
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
||
|
|
<div className="text-center">
|
||
|
|
<Palette className="mx-auto mb-4 h-16 w-16" />
|
||
|
|
<p>왼쪽 툴바에서 컴포넌트를 드래그하여 배치하세요</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</TabsContent>
|
||
|
|
|
||
|
|
{/* 테이블 타입 탭 */}
|
||
|
|
<TabsContent value="table" className="h-full">
|
||
|
|
<TableTypeSelector
|
||
|
|
onTableSelect={(tableName) => console.log("테이블 선택:", tableName)}
|
||
|
|
onColumnSelect={handleColumnSelect}
|
||
|
|
className="h-full"
|
||
|
|
/>
|
||
|
|
</TabsContent>
|
||
|
|
|
||
|
|
{/* 미리보기 탭 */}
|
||
|
|
<TabsContent value="preview" className="h-full">
|
||
|
|
<ScreenPreview layout={layout} screenName={screen.screenName} />
|
||
|
|
</TabsContent>
|
||
|
|
</Tabs>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 오른쪽 속성 패널 */}
|
||
|
|
<div className="w-80">
|
||
|
|
<Card className="h-full">
|
||
|
|
<CardHeader className="pb-3">
|
||
|
|
<CardTitle className="text-sm font-medium">속성</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{selectedComponent ? (
|
||
|
|
<Tabs defaultValue="general" className="w-full">
|
||
|
|
<TabsList className="grid w-full grid-cols-3">
|
||
|
|
<TabsTrigger value="general">일반</TabsTrigger>
|
||
|
|
<TabsTrigger value="style">
|
||
|
|
<Palette className="mr-1 h-3 w-3" />
|
||
|
|
스타일
|
||
|
|
</TabsTrigger>
|
||
|
|
<TabsTrigger value="advanced">고급</TabsTrigger>
|
||
|
|
</TabsList>
|
||
|
|
|
||
|
|
{/* 일반 속성 탭 */}
|
||
|
|
<TabsContent value="general" className="space-y-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="componentId">ID</Label>
|
||
|
|
<Input id="componentId" value={selectedComponent.id} readOnly className="bg-gray-50" />
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="componentType">타입</Label>
|
||
|
|
<Input id="componentType" value={selectedComponent.type} readOnly className="bg-gray-50" />
|
||
|
|
</div>
|
||
|
|
<div className="grid grid-cols-2 gap-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="positionX">X 위치</Label>
|
||
|
|
<Input
|
||
|
|
id="positionX"
|
||
|
|
type="number"
|
||
|
|
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"
|
||
|
|
value={selectedComponent.position.y}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateComponentProperty(selectedComponent.id, "position.y", parseInt(e.target.value))
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</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="1"
|
||
|
|
max="12"
|
||
|
|
value={selectedComponent.size.width}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateComponentProperty(selectedComponent.id, "size.width", parseInt(e.target.value))
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="height">높이</Label>
|
||
|
|
<Input
|
||
|
|
id="height"
|
||
|
|
type="number"
|
||
|
|
min="20"
|
||
|
|
value={selectedComponent.size.height}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateComponentProperty(selectedComponent.id, "size.height", parseInt(e.target.value))
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 위젯 전용 속성 */}
|
||
|
|
{selectedComponent.type === "widget" && (
|
||
|
|
<>
|
||
|
|
<Separator />
|
||
|
|
<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>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</TabsContent>
|
||
|
|
|
||
|
|
{/* 스타일 속성 탭 */}
|
||
|
|
<TabsContent value="style" className="space-y-4">
|
||
|
|
<StyleEditor
|
||
|
|
style={selectedComponent.style || {}}
|
||
|
|
onStyleChange={(newStyle) => updateComponentProperty(selectedComponent.id, "style", newStyle)}
|
||
|
|
/>
|
||
|
|
</TabsContent>
|
||
|
|
|
||
|
|
{/* 고급 속성 탭 */}
|
||
|
|
<TabsContent value="advanced" className="space-y-4">
|
||
|
|
<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>
|
||
|
|
</TabsContent>
|
||
|
|
</Tabs>
|
||
|
|
) : (
|
||
|
|
<div className="py-8 text-center text-gray-500">
|
||
|
|
<Palette className="mx-auto mb-2 h-12 w-12 text-gray-300" />
|
||
|
|
<p>컴포넌트를 선택하여 속성을 편집하세요</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|