303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||
import { Plus, Check, Trash2 } from "lucide-react";
|
||
import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal";
|
||
import DigitalTwinEditor from "./yard-3d/DigitalTwinEditor";
|
||
import DigitalTwinViewer from "./yard-3d/DigitalTwinViewer";
|
||
import { getLayouts, createLayout, deleteLayout } from "@/lib/api/digitalTwin";
|
||
import type { YardManagementConfig } from "../types";
|
||
|
||
interface YardLayout {
|
||
id: number;
|
||
name: string;
|
||
description: string;
|
||
placement_count: number;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
interface YardManagement3DWidgetProps {
|
||
isEditMode?: boolean;
|
||
config?: YardManagementConfig;
|
||
onConfigChange?: (config: YardManagementConfig) => void;
|
||
}
|
||
|
||
export default function YardManagement3DWidget({
|
||
isEditMode = false,
|
||
config,
|
||
onConfigChange,
|
||
}: YardManagement3DWidgetProps) {
|
||
const [layouts, setLayouts] = useState<YardLayout[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||
const [editingLayout, setEditingLayout] = useState<YardLayout | null>(null);
|
||
const [deleteLayoutId, setDeleteLayoutId] = useState<number | null>(null);
|
||
|
||
// 레이아웃 목록 로드
|
||
const loadLayouts = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const response = await getLayouts();
|
||
if (response.success && response.data) {
|
||
setLayouts(
|
||
response.data.map((layout: any) => ({
|
||
id: layout.id,
|
||
name: layout.layout_name,
|
||
description: layout.description || "",
|
||
placement_count: layout.object_count || 0,
|
||
created_at: layout.created_at,
|
||
updated_at: layout.updated_at,
|
||
})),
|
||
);
|
||
}
|
||
} catch (error) {
|
||
console.error("야드 레이아웃 목록 조회 실패:", error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (isEditMode) {
|
||
loadLayouts();
|
||
}
|
||
}, [isEditMode]);
|
||
|
||
// 레이아웃 목록이 로드되었고, 설정이 없으면 첫 번째 레이아웃 자동 선택
|
||
useEffect(() => {
|
||
if (isEditMode && layouts.length > 0 && !config?.layoutId && onConfigChange) {
|
||
// console.log("🔧 첫 번째 야드 레이아웃 자동 선택:", layouts[0]);
|
||
onConfigChange({
|
||
layoutId: layouts[0].id,
|
||
layoutName: layouts[0].name,
|
||
});
|
||
}
|
||
}, [isEditMode, layouts, config?.layoutId, onConfigChange]);
|
||
|
||
// 레이아웃 선택 (편집 모드에서만)
|
||
const handleSelectLayout = (layout: YardLayout) => {
|
||
if (onConfigChange) {
|
||
onConfigChange({
|
||
layoutId: layout.id,
|
||
layoutName: layout.name,
|
||
});
|
||
}
|
||
};
|
||
|
||
// 새 레이아웃 생성
|
||
const handleCreateLayout = async (name: string) => {
|
||
try {
|
||
const response = await createLayout({
|
||
layoutName: name,
|
||
description: "",
|
||
});
|
||
if (response.success && response.data) {
|
||
await loadLayouts();
|
||
setIsCreateModalOpen(false);
|
||
setEditingLayout({
|
||
id: response.data.id,
|
||
name: response.data.layout_name,
|
||
description: response.data.description || "",
|
||
placement_count: 0,
|
||
created_at: response.data.created_at,
|
||
updated_at: response.data.updated_at,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("야드 레이아웃 생성 실패:", error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// 편집 완료
|
||
const handleEditComplete = () => {
|
||
if (editingLayout && onConfigChange) {
|
||
onConfigChange({
|
||
layoutId: editingLayout.id,
|
||
layoutName: editingLayout.name,
|
||
});
|
||
}
|
||
setEditingLayout(null);
|
||
loadLayouts();
|
||
};
|
||
|
||
// 레이아웃 삭제
|
||
const handleDeleteLayout = async () => {
|
||
if (!deleteLayoutId) return;
|
||
|
||
try {
|
||
const response = await deleteLayout(deleteLayoutId);
|
||
if (response.success) {
|
||
// 삭제된 레이아웃이 현재 선택된 레이아웃이면 설정 초기화
|
||
if (config?.layoutId === deleteLayoutId && onConfigChange) {
|
||
onConfigChange({ layoutId: 0, layoutName: "" });
|
||
}
|
||
await loadLayouts();
|
||
}
|
||
} catch (error) {
|
||
console.error("레이아웃 삭제 실패:", error);
|
||
} finally {
|
||
setDeleteLayoutId(null);
|
||
}
|
||
};
|
||
|
||
// 편집 모드: 편집 중인 경우 DigitalTwinEditor 표시
|
||
if (isEditMode && editingLayout) {
|
||
return (
|
||
// 대시보드 위젯 선택/사이드바 오픈과 독립적으로 동작해야 하므로
|
||
// widget-interactive-area 클래스를 부여하고, 마우스 이벤트 전파를 막아준다.
|
||
<div
|
||
className="widget-interactive-area h-full w-full"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<DigitalTwinEditor layoutId={editingLayout.id} layoutName={editingLayout.name} onBack={handleEditComplete} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 편집 모드: 레이아웃 선택 UI
|
||
if (isEditMode) {
|
||
return (
|
||
<div className="widget-interactive-area bg-background flex h-full w-full flex-col">
|
||
<div className="flex items-center justify-between border-b p-4">
|
||
<div>
|
||
<h3 className="text-foreground text-sm font-semibold">3D 필드 선택</h3>
|
||
<p className="text-muted-foreground mt-1 text-xs">
|
||
{config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 3D필드를 선택하세요"}
|
||
</p>
|
||
</div>
|
||
<Button onClick={() => setIsCreateModalOpen(true)} size="sm">
|
||
<Plus className="mr-1 h-4 w-4" />
|
||
새로운 3D필드 생성
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-auto p-4">
|
||
{isLoading ? (
|
||
<div className="flex h-full items-center justify-center">
|
||
<div className="text-muted-foreground text-sm">로딩 중...</div>
|
||
</div>
|
||
) : layouts.length === 0 ? (
|
||
<div className="flex h-full items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="mb-2 text-4xl">🏗️</div>
|
||
<div className="text-foreground text-sm">생성된 3D필드가 없습니다</div>
|
||
<div className="text-muted-foreground mt-1 text-xs">먼저 3D필드가 생성하세요</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-3">
|
||
{layouts.map((layout) => (
|
||
<div
|
||
key={layout.id}
|
||
className={`rounded-lg border p-3 transition-all ${
|
||
config?.layoutId === layout.id ? "border-primary bg-primary/10" : "border-border bg-background"
|
||
}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<button onClick={() => handleSelectLayout(layout)} className="flex-1 text-left hover:opacity-80">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-foreground font-medium">{layout.name}</span>
|
||
{config?.layoutId === layout.id && <Check className="text-primary h-4 w-4" />}
|
||
</div>
|
||
{layout.description && <p className="text-muted-foreground mt-1 text-xs">{layout.description}</p>}
|
||
<div className="text-muted-foreground mt-2 text-xs">배치된 자재: {layout.placement_count}개</div>
|
||
</button>
|
||
<div className="flex gap-1">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setEditingLayout(layout);
|
||
}}
|
||
>
|
||
편집
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="text-destructive hover:bg-destructive/10"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setDeleteLayoutId(layout.id);
|
||
}}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 생성 모달 */}
|
||
<YardLayoutCreateModal
|
||
isOpen={isCreateModalOpen}
|
||
onClose={() => setIsCreateModalOpen(false)}
|
||
onCreate={handleCreateLayout}
|
||
/>
|
||
|
||
{/* 삭제 확인 모달 */}
|
||
<Dialog
|
||
open={deleteLayoutId !== null}
|
||
onOpenChange={(open) => {
|
||
if (!open) setDeleteLayoutId(null);
|
||
}}
|
||
>
|
||
<DialogContent onPointerDown={(e) => e.stopPropagation()} className="sm:max-w-[425px]">
|
||
<DialogHeader>
|
||
<DialogTitle>야드 레이아웃 삭제</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<p className="text-foreground text-sm">
|
||
이 야드 레이아웃을 삭제하시겠습니까?
|
||
<br />
|
||
레이아웃 내의 모든 배치 정보도 함께 삭제됩니다.
|
||
<br />
|
||
<span className="text-destructive font-semibold">이 작업은 되돌릴 수 없습니다.</span>
|
||
</p>
|
||
<div className="flex justify-end gap-2">
|
||
<Button variant="outline" onClick={() => setDeleteLayoutId(null)}>
|
||
취소
|
||
</Button>
|
||
<Button onClick={handleDeleteLayout} className="bg-destructive hover:bg-destructive/90">
|
||
삭제
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 뷰 모드: 선택된 레이아웃의 3D 뷰어 표시
|
||
if (!config?.layoutId) {
|
||
console.warn("⚠️ 야드관리 위젯: layoutId가 설정되지 않음", { config, isEditMode });
|
||
return (
|
||
<div className="bg-muted flex h-full w-full items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="mb-2 text-4xl">🏗️</div>
|
||
<div className="text-foreground text-sm font-medium">3D필드가 설정되지 않았습니다</div>
|
||
<div className="text-muted-foreground mt-1 text-xs">대시보드 편집에서 3D필드를 선택하세요</div>
|
||
<div className="text-destructive mt-2 text-xs">디버그: config={JSON.stringify(config)}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 선택된 레이아웃의 디지털 트윈 뷰어 표시
|
||
return (
|
||
<div className="h-full w-full">
|
||
<DigitalTwinViewer layoutId={config.layoutId} />
|
||
</div>
|
||
);
|
||
}
|