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

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>
);
}