642 lines
23 KiB
TypeScript
642 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ArrowLeft, Save, Loader2, Grid3x3, Combine, Move, Box, Package } from "lucide-react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import dynamic from "next/dynamic";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="bg-muted flex h-full items-center justify-center">
|
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
|
</div>
|
|
),
|
|
});
|
|
|
|
interface DigitalTwinEditorProps {
|
|
layoutId: number;
|
|
layoutName: string;
|
|
onBack: () => void;
|
|
}
|
|
|
|
type ToolType = "yard" | "gantry-crane" | "mobile-crane" | "rack" | "plate-stack";
|
|
|
|
interface PlacedObject {
|
|
id: number;
|
|
type: ToolType;
|
|
name: string;
|
|
position: { x: number; y: number; z: number };
|
|
size: { x: number; y: number; z: number };
|
|
color: string;
|
|
// 데이터 바인딩 정보
|
|
externalDbConnectionId?: number;
|
|
dataBindingConfig?: any;
|
|
}
|
|
|
|
export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: DigitalTwinEditorProps) {
|
|
const { toast } = useToast();
|
|
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
|
|
const [selectedObject, setSelectedObject] = useState<PlacedObject | null>(null);
|
|
const [draggedTool, setDraggedTool] = useState<ToolType | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
const [externalDbConnections, setExternalDbConnections] = useState<any[]>([]);
|
|
const [selectedDbConnection, setSelectedDbConnection] = useState<number | null>(null);
|
|
const [nextObjectId, setNextObjectId] = useState(-1);
|
|
|
|
// 외부 DB 연결 목록 로드
|
|
useEffect(() => {
|
|
const loadExternalDbConnections = async () => {
|
|
try {
|
|
// TODO: 실제 API 호출
|
|
// const response = await externalDbConnectionApi.getConnections({ is_active: 'Y' });
|
|
|
|
// 임시 데이터
|
|
setExternalDbConnections([
|
|
{ id: 1, name: "DO_DY (동연 야드)", db_type: "mariadb" },
|
|
{ id: 2, name: "GY_YARD (광양 야드)", db_type: "mariadb" },
|
|
]);
|
|
} catch (error) {
|
|
console.error("외부 DB 연결 목록 조회 실패:", error);
|
|
}
|
|
};
|
|
|
|
loadExternalDbConnections();
|
|
}, []);
|
|
|
|
// 레이아웃 데이터 로드
|
|
useEffect(() => {
|
|
const loadLayout = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
// TODO: 실제 API 호출
|
|
// const response = await digitalTwinApi.getLayout(layoutId);
|
|
|
|
// 임시 데이터
|
|
setPlacedObjects([]);
|
|
} catch (error) {
|
|
console.error("레이아웃 로드 실패:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
loadLayout();
|
|
}, [layoutId]);
|
|
|
|
// 도구 타입별 기본 설정
|
|
const getToolDefaults = (type: ToolType): Partial<PlacedObject> => {
|
|
switch (type) {
|
|
case "yard":
|
|
return {
|
|
name: "영역",
|
|
size: { x: 20, y: 0.1, z: 20 }, // 4x4 칸
|
|
color: "#3b82f6", // 파란색
|
|
};
|
|
case "gantry-crane":
|
|
return {
|
|
name: "겐트리 크레인",
|
|
size: { x: 5, y: 8, z: 5 }, // 1x1 칸
|
|
color: "#22c55e", // 녹색
|
|
};
|
|
case "mobile-crane":
|
|
return {
|
|
name: "크레인",
|
|
size: { x: 5, y: 6, z: 5 }, // 1x1 칸
|
|
color: "#eab308", // 노란색
|
|
};
|
|
case "rack":
|
|
return {
|
|
name: "랙",
|
|
size: { x: 5, y: 3, z: 5 }, // 1x1 칸
|
|
color: "#a855f7", // 보라색
|
|
};
|
|
case "plate-stack":
|
|
return {
|
|
name: "후판 스택",
|
|
size: { x: 5, y: 2, z: 5 }, // 1x1 칸
|
|
color: "#ef4444", // 빨간색
|
|
};
|
|
}
|
|
};
|
|
|
|
// 도구 드래그 시작
|
|
const handleToolDragStart = (toolType: ToolType) => {
|
|
setDraggedTool(toolType);
|
|
};
|
|
|
|
// 캔버스에 드롭
|
|
const handleCanvasDrop = (x: number, z: number) => {
|
|
if (!draggedTool) return;
|
|
|
|
const defaults = getToolDefaults(draggedTool);
|
|
|
|
// 야드는 바닥(y=0.05)에, 다른 객체는 중앙 정렬
|
|
const yPosition = draggedTool === "yard" ? 0.05 : (defaults.size?.y || 1) / 2;
|
|
|
|
const newObject: PlacedObject = {
|
|
id: nextObjectId,
|
|
type: draggedTool,
|
|
name: defaults.name || "새 객체",
|
|
position: { x, y: yPosition, z },
|
|
size: defaults.size || { x: 5, y: 5, z: 5 },
|
|
color: defaults.color || "#9ca3af",
|
|
};
|
|
|
|
setPlacedObjects((prev) => [...prev, newObject]);
|
|
setSelectedObject(newObject);
|
|
setNextObjectId((prev) => prev - 1);
|
|
setHasUnsavedChanges(true);
|
|
setDraggedTool(null);
|
|
};
|
|
|
|
// 객체 클릭
|
|
const handleObjectClick = (objectId: number | null) => {
|
|
if (objectId === null) {
|
|
setSelectedObject(null);
|
|
return;
|
|
}
|
|
|
|
const obj = placedObjects.find((o) => o.id === objectId);
|
|
setSelectedObject(obj || null);
|
|
};
|
|
|
|
// 객체 이동
|
|
const handleObjectMove = (objectId: number, newX: number, newZ: number, newY?: number) => {
|
|
// Yard3DCanvas에서 이미 스냅+오프셋이 완료된 좌표를 받음
|
|
// 그대로 저장하면 됨
|
|
setPlacedObjects((prev) =>
|
|
prev.map((obj) => {
|
|
if (obj.id === objectId) {
|
|
const newPosition = { ...obj.position, x: newX, z: newZ };
|
|
if (newY !== undefined) {
|
|
newPosition.y = newY;
|
|
}
|
|
return { ...obj, position: newPosition };
|
|
}
|
|
return obj;
|
|
}),
|
|
);
|
|
|
|
if (selectedObject?.id === objectId) {
|
|
setSelectedObject((prev) => {
|
|
if (!prev) return null;
|
|
const newPosition = { ...prev.position, x: newX, z: newZ };
|
|
if (newY !== undefined) {
|
|
newPosition.y = newY;
|
|
}
|
|
return { ...prev, position: newPosition };
|
|
});
|
|
}
|
|
|
|
setHasUnsavedChanges(true);
|
|
};
|
|
|
|
// 객체 속성 업데이트
|
|
const handleObjectUpdate = (updates: Partial<PlacedObject>) => {
|
|
if (!selectedObject) return;
|
|
|
|
let finalUpdates = { ...updates };
|
|
|
|
// 크기 변경 시에만 5 단위로 스냅하고 위치 조정 (position 변경은 제외)
|
|
if (updates.size && !updates.position) {
|
|
// placedObjects 배열에서 실제 저장된 객체를 가져옴 (selectedObject 상태가 아닌)
|
|
const actualObject = placedObjects.find((obj) => obj.id === selectedObject.id);
|
|
if (!actualObject) return;
|
|
|
|
const oldSize = actualObject.size;
|
|
const newSize = { ...oldSize, ...updates.size };
|
|
|
|
// W, D를 5 단위로 스냅
|
|
newSize.x = Math.max(5, Math.round(newSize.x / 5) * 5);
|
|
newSize.z = Math.max(5, Math.round(newSize.z / 5) * 5);
|
|
|
|
// H는 자유롭게 (야드 제외)
|
|
if (actualObject.type !== "yard") {
|
|
newSize.y = Math.max(0.1, newSize.y);
|
|
}
|
|
|
|
// 크기 차이 계산
|
|
const deltaX = newSize.x - oldSize.x;
|
|
const deltaZ = newSize.z - oldSize.z;
|
|
const deltaY = newSize.y - oldSize.y;
|
|
|
|
// 위치 조정: 왼쪽/뒤쪽/바닥 모서리 고정, 오른쪽/앞쪽/위쪽으로만 늘어남
|
|
// Three.js는 중심점 기준이므로 크기 차이의 절반만큼 위치 이동
|
|
// actualObject.position (실제 배열의 position)을 기준으로 계산
|
|
const newPosition = {
|
|
...actualObject.position,
|
|
x: actualObject.position.x + deltaX / 2, // 오른쪽으로 늘어남
|
|
y: actualObject.position.y + deltaY / 2, // 위쪽으로 늘어남 (바닥 고정)
|
|
z: actualObject.position.z + deltaZ / 2, // 앞쪽으로 늘어남
|
|
};
|
|
|
|
finalUpdates = {
|
|
...finalUpdates,
|
|
size: newSize,
|
|
position: newPosition,
|
|
};
|
|
}
|
|
|
|
setPlacedObjects((prev) => prev.map((obj) => (obj.id === selectedObject.id ? { ...obj, ...finalUpdates } : obj)));
|
|
|
|
setSelectedObject((prev) => (prev ? { ...prev, ...finalUpdates } : null));
|
|
setHasUnsavedChanges(true);
|
|
};
|
|
|
|
// 객체 삭제
|
|
const handleObjectDelete = () => {
|
|
if (!selectedObject) return;
|
|
|
|
setPlacedObjects((prev) => prev.filter((obj) => obj.id !== selectedObject.id));
|
|
setSelectedObject(null);
|
|
setHasUnsavedChanges(true);
|
|
};
|
|
|
|
// 저장
|
|
const handleSave = async () => {
|
|
if (!selectedDbConnection) {
|
|
toast({
|
|
title: "외부 DB 선택 필요",
|
|
description: "외부 데이터베이스 연결을 선택하세요.",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
// TODO: 실제 API 호출
|
|
// await digitalTwinApi.saveLayout(layoutId, {
|
|
// externalDbConnectionId: selectedDbConnection,
|
|
// objects: placedObjects,
|
|
// });
|
|
|
|
toast({
|
|
title: "저장 완료",
|
|
description: "레이아웃이 성공적으로 저장되었습니다.",
|
|
});
|
|
|
|
setHasUnsavedChanges(false);
|
|
} catch (error) {
|
|
console.error("저장 실패:", error);
|
|
toast({
|
|
title: "저장 실패",
|
|
description: "레이아웃 저장에 실패했습니다.",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-background flex h-full flex-col">
|
|
{/* 상단 툴바 */}
|
|
<div className="flex items-center justify-between border-b p-4">
|
|
<div className="flex items-center gap-4">
|
|
<Button variant="ghost" size="sm" onClick={onBack}>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
목록으로
|
|
</Button>
|
|
<div>
|
|
<h2 className="text-lg font-semibold">{layoutName}</h2>
|
|
<p className="text-muted-foreground text-sm">디지털 트윈 야드 편집</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{hasUnsavedChanges && <span className="text-warning text-sm font-medium">미저장 변경사항 있음</span>}
|
|
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasUnsavedChanges}>
|
|
{isSaving ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
저장 중...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="mr-2 h-4 w-4" />
|
|
저장
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 도구 팔레트 */}
|
|
<div className="bg-muted flex items-center justify-center gap-2 border-b p-4">
|
|
<span className="text-muted-foreground text-sm font-medium">도구:</span>
|
|
{[
|
|
{ type: "yard" as ToolType, label: "영역", icon: Grid3x3, color: "text-blue-500" },
|
|
// { type: "gantry-crane" as ToolType, label: "겐트리", icon: Combine, color: "text-green-500" },
|
|
{ type: "mobile-crane" as ToolType, label: "크레인", icon: Move, color: "text-yellow-500" },
|
|
{ type: "rack" as ToolType, label: "랙", icon: Box, color: "text-purple-500" },
|
|
{ type: "plate-stack" as ToolType, label: "후판", icon: Package, color: "text-red-500" },
|
|
].map((tool) => {
|
|
const Icon = tool.icon;
|
|
return (
|
|
<div
|
|
key={tool.type}
|
|
draggable
|
|
onDragStart={() => handleToolDragStart(tool.type)}
|
|
className="bg-background hover:bg-accent flex cursor-grab items-center gap-1 rounded-md border px-3 py-2 transition-colors active:cursor-grabbing"
|
|
title={`${tool.label} 드래그하여 배치`}
|
|
>
|
|
<Icon className={`h-4 w-4 ${tool.color}`} />
|
|
<span className="text-xs">{tool.label}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 메인 영역 */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* 좌측: 외부 DB 선택 + 객체 목록 */}
|
|
<div className="flex h-full w-[20%] flex-col border-r">
|
|
{/* 외부 DB 선택 */}
|
|
<div className="border-b p-4">
|
|
<Label className="mb-2 block text-sm font-semibold">외부 데이터베이스</Label>
|
|
<Select
|
|
value={selectedDbConnection?.toString() || ""}
|
|
onValueChange={(value) => {
|
|
setSelectedDbConnection(parseInt(value));
|
|
setHasUnsavedChanges(true);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-10 text-sm">
|
|
<SelectValue placeholder="DB 선택..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{externalDbConnections.map((conn) => (
|
|
<SelectItem key={conn.id} value={conn.id.toString()} className="text-sm">
|
|
{conn.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-muted-foreground mt-1.5 text-xs">실시간 데이터를 가져올 데이터베이스를 선택하세요</p>
|
|
</div>
|
|
|
|
{/* 배치된 객체 목록 */}
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
<h3 className="mb-3 text-sm font-semibold">배치된 객체 ({placedObjects.length})</h3>
|
|
|
|
{placedObjects.length === 0 ? (
|
|
<div className="text-muted-foreground text-center text-sm">상단 도구를 드래그하여 배치하세요</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{placedObjects.map((obj) => (
|
|
<div
|
|
key={obj.id}
|
|
onClick={() => handleObjectClick(obj.id)}
|
|
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
|
selectedObject?.id === obj.id ? "border-primary bg-primary/10" : "hover:border-primary/50"
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">{obj.name}</span>
|
|
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: obj.color }} />
|
|
</div>
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
|
위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)})
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 중앙: 3D 캔버스 */}
|
|
<div
|
|
className="h-full flex-1 bg-gray-100"
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100;
|
|
const rawZ = ((e.clientY - rect.top) / rect.height - 0.5) * 100;
|
|
|
|
// 그리드 크기 (5 단위)
|
|
const gridSize = 5;
|
|
|
|
// 그리드에 스냅
|
|
// 야드(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에
|
|
let snappedX = Math.round(rawX / gridSize) * gridSize;
|
|
let snappedZ = Math.round(rawZ / gridSize) * gridSize;
|
|
|
|
// 5x5 객체는 타일 중앙으로 오프셋 (야드는 제외)
|
|
if (draggedTool !== "yard") {
|
|
snappedX += gridSize / 2;
|
|
snappedZ += gridSize / 2;
|
|
}
|
|
|
|
handleCanvasDrop(snappedX, snappedZ);
|
|
}}
|
|
>
|
|
{isLoading ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
|
</div>
|
|
) : (
|
|
<Yard3DCanvas
|
|
placements={placedObjects.map((obj) => ({
|
|
id: obj.id,
|
|
yard_layout_id: layoutId,
|
|
material_code: null,
|
|
material_name: obj.name,
|
|
name: obj.name, // 객체 이름 (야드 이름 표시용)
|
|
quantity: null,
|
|
unit: null,
|
|
position_x: obj.position.x,
|
|
position_y: obj.position.y,
|
|
position_z: obj.position.z,
|
|
size_x: obj.size.x,
|
|
size_y: obj.size.y,
|
|
size_z: obj.size.z,
|
|
color: obj.color,
|
|
data_source_type: obj.type,
|
|
data_source_config: null,
|
|
data_binding: null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
}))}
|
|
selectedPlacementId={selectedObject?.id || null}
|
|
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
|
onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)}
|
|
focusOnPlacementId={null}
|
|
onCollisionDetected={() => {}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* 우측: 객체 속성 편집 */}
|
|
<div className="h-full w-[25%] overflow-y-auto border-l">
|
|
{selectedObject ? (
|
|
<div className="p-4">
|
|
<h3 className="mb-4 text-lg font-semibold">객체 속성</h3>
|
|
|
|
<div className="space-y-4">
|
|
{/* 이름 */}
|
|
<div>
|
|
<Label htmlFor="object-name" className="text-sm">
|
|
이름
|
|
</Label>
|
|
<Input
|
|
id="object-name"
|
|
value={selectedObject.name}
|
|
onChange={(e) => handleObjectUpdate({ name: e.target.value })}
|
|
className="mt-1.5 h-9 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* 위치 */}
|
|
<div>
|
|
<Label className="text-sm">위치</Label>
|
|
<div className="mt-1.5 grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label htmlFor="pos-x" className="text-muted-foreground text-xs">
|
|
X
|
|
</Label>
|
|
<Input
|
|
id="pos-x"
|
|
type="number"
|
|
value={selectedObject.position.x.toFixed(1)}
|
|
onChange={(e) =>
|
|
handleObjectUpdate({
|
|
position: {
|
|
...selectedObject.position,
|
|
x: parseFloat(e.target.value),
|
|
},
|
|
})
|
|
}
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="pos-z" className="text-muted-foreground text-xs">
|
|
Z
|
|
</Label>
|
|
<Input
|
|
id="pos-z"
|
|
type="number"
|
|
value={selectedObject.position.z.toFixed(1)}
|
|
onChange={(e) =>
|
|
handleObjectUpdate({
|
|
position: {
|
|
...selectedObject.position,
|
|
z: parseFloat(e.target.value),
|
|
},
|
|
})
|
|
}
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 크기 */}
|
|
<div>
|
|
<Label className="text-sm">크기</Label>
|
|
<div className="mt-1.5 grid grid-cols-3 gap-2">
|
|
<div>
|
|
<Label htmlFor="size-x" className="text-muted-foreground text-xs">
|
|
W (5 단위)
|
|
</Label>
|
|
<Input
|
|
id="size-x"
|
|
type="number"
|
|
step="5"
|
|
min="5"
|
|
value={selectedObject.size.x}
|
|
onChange={(e) =>
|
|
handleObjectUpdate({
|
|
size: {
|
|
...selectedObject.size,
|
|
x: parseFloat(e.target.value),
|
|
},
|
|
})
|
|
}
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="size-y" className="text-muted-foreground text-xs">
|
|
H
|
|
</Label>
|
|
<Input
|
|
id="size-y"
|
|
type="number"
|
|
value={selectedObject.size.y}
|
|
onChange={(e) =>
|
|
handleObjectUpdate({
|
|
size: {
|
|
...selectedObject.size,
|
|
y: parseFloat(e.target.value),
|
|
},
|
|
})
|
|
}
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="size-z" className="text-muted-foreground text-xs">
|
|
D (5 단위)
|
|
</Label>
|
|
<Input
|
|
id="size-z"
|
|
type="number"
|
|
step="5"
|
|
min="5"
|
|
value={selectedObject.size.z}
|
|
onChange={(e) =>
|
|
handleObjectUpdate({
|
|
size: {
|
|
...selectedObject.size,
|
|
z: parseFloat(e.target.value),
|
|
},
|
|
})
|
|
}
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 색상 */}
|
|
<div>
|
|
<Label htmlFor="object-color" className="text-sm">
|
|
색상
|
|
</Label>
|
|
<Input
|
|
id="object-color"
|
|
type="color"
|
|
value={selectedObject.color}
|
|
onChange={(e) => handleObjectUpdate({ color: e.target.value })}
|
|
className="mt-1.5 h-9"
|
|
/>
|
|
</div>
|
|
|
|
{/* 삭제 버튼 */}
|
|
<Button variant="destructive" size="sm" onClick={handleObjectDelete} className="w-full">
|
|
객체 삭제
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center p-4 text-center">
|
|
<p className="text-muted-foreground text-sm">객체를 선택하면 속성을 편집할 수 있습니다</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|