Files
vexplor/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx
2025-11-25 15:06:55 +09:00

303 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}