feat: Digital Twin Editor 테이블 매핑 UI 및 백엔드 API 구현

This commit is contained in:
dohyeons
2025-11-20 10:15:58 +09:00
parent eeed671436
commit 33350a4d46
13 changed files with 3007 additions and 462 deletions

View File

@@ -7,7 +7,7 @@ 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 { yardLayoutApi } from "@/lib/api/yardLayoutApi";
import { getLayouts, createLayout, deleteLayout } from "@/lib/api/digitalTwin";
import type { YardManagementConfig } from "../types";
interface YardLayout {
@@ -40,9 +40,16 @@ export default function YardManagement3DWidget({
const loadLayouts = async () => {
try {
setIsLoading(true);
const response = await yardLayoutApi.getAllLayouts();
if (response.success) {
setLayouts(response.data as YardLayout[]);
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);
@@ -81,11 +88,21 @@ export default function YardManagement3DWidget({
// 새 레이아웃 생성
const handleCreateLayout = async (name: string) => {
try {
const response = await yardLayoutApi.createLayout({ name });
if (response.success) {
const response = await createLayout({
layoutName: name,
description: "",
});
if (response.success && response.data) {
await loadLayouts();
setIsCreateModalOpen(false);
setEditingLayout(response.data as YardLayout);
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);
@@ -110,7 +127,7 @@ export default function YardManagement3DWidget({
if (!deleteLayoutId) return;
try {
const response = await yardLayoutApi.deleteLayout(deleteLayoutId);
const response = await deleteLayout(deleteLayoutId);
if (response.success) {
// 삭제된 레이아웃이 현재 선택된 레이아웃이면 설정 초기화
if (config?.layoutId === deleteLayoutId && onConfigChange) {

View File

@@ -1,16 +1,21 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Search } from "lucide-react";
import { Loader2, Search, Filter, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import type { PlacedObject, MaterialData } from "@/types/digitalTwin";
import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin";
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center bg-muted">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<div className="bg-muted flex h-full items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
),
});
@@ -19,292 +24,478 @@ interface DigitalTwinViewerProps {
layoutId: number;
}
// 임시 타입 정의
interface Material {
id: number;
plate_no: string; // 후판번호
steel_grade: string; // 강종
thickness: number; // 두께
width: number; // 폭
length: number; // 길이
weight: number; // 중량
location: string; // 위치
status: string; // 상태
arrival_date: string; // 입고일자
}
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
const [searchTerm, setSearchTerm] = useState("");
const [selectedYard, setSelectedYard] = useState<string>("all");
const [selectedStatus, setSelectedStatus] = useState<string>("all");
const [dateRange, setDateRange] = useState({ from: "", to: "" });
const [selectedMaterial, setSelectedMaterial] = useState<Material | null>(null);
const [materials, setMaterials] = useState<Material[]>([]);
const { toast } = useToast();
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
const [selectedObject, setSelectedObject] = useState<PlacedObject | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [materials, setMaterials] = useState<MaterialData[]>([]);
const [loadingMaterials, setLoadingMaterials] = useState(false);
const [showInfoPanel, setShowInfoPanel] = useState(false);
const [externalDbConnectionId, setExternalDbConnectionId] = useState<number | null>(null);
const [layoutName, setLayoutName] = useState<string>("");
// 검색 및 필터
const [searchQuery, setSearchQuery] = useState("");
const [filterType, setFilterType] = useState<string>("all");
// 레이아웃 데이터 로드
useEffect(() => {
const loadData = async () => {
const loadLayout = async () => {
try {
setIsLoading(true);
// TODO: 실제 API 호출
// const response = await digitalTwinApi.getLayoutData(layoutId);
// 임시 데이터
setMaterials([
{
id: 1,
plate_no: "P-2024-001",
steel_grade: "SM490A",
thickness: 25,
width: 2000,
length: 6000,
weight: 2355,
location: "A동-101",
status: "입고",
arrival_date: "2024-11-15",
},
{
id: 2,
plate_no: "P-2024-002",
steel_grade: "SS400",
thickness: 30,
width: 2500,
length: 8000,
weight: 4710,
location: "B동-205",
status: "가공중",
arrival_date: "2024-11-16",
},
]);
const response = await getLayoutById(layoutId);
if (response.success && response.data) {
const { layout, objects } = response.data;
// 레이아웃 정보 저장
setLayoutName(layout.layoutName);
setExternalDbConnectionId(layout.externalDbConnectionId);
// 객체 데이터 변환
const loadedObjects: PlacedObject[] = objects.map((obj: any) => ({
id: obj.id,
type: obj.object_type,
name: obj.object_name,
position: {
x: parseFloat(obj.position_x),
y: parseFloat(obj.position_y),
z: parseFloat(obj.position_z),
},
size: {
x: parseFloat(obj.size_x),
y: parseFloat(obj.size_y),
z: parseFloat(obj.size_z),
},
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
color: obj.color,
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.material_count,
materialPreview: obj.material_preview_height
? { height: parseFloat(obj.material_preview_height) }
: undefined,
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
visible: obj.visible !== false,
}));
setPlacedObjects(loadedObjects);
} else {
throw new Error(response.error || "레이아웃 조회 실패");
}
} catch (error) {
console.error("디지털 트윈 데이터 로드 실패:", error);
console.error("레이아웃 로드 실패:", error);
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
toast({
variant: "destructive",
title: "오류",
description: errorMessage,
});
} finally {
setIsLoading(false);
}
};
loadData();
}, [layoutId]);
loadLayout();
}, [layoutId, toast]);
// 필터링된 자재 목록
const filteredMaterials = useMemo(() => {
return materials.filter((material) => {
// 검색어 필터
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
const matchSearch =
material.plate_no.toLowerCase().includes(searchLower) ||
material.steel_grade.toLowerCase().includes(searchLower) ||
material.location.toLowerCase().includes(searchLower);
if (!matchSearch) return false;
// Location의 자재 목록 로드
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
try {
setLoadingMaterials(true);
setShowInfoPanel(true);
const response = await getMaterials(externalDbConnectionId, locaKey);
if (response.success && response.data) {
const sortedMaterials = response.data.sort((a, b) => a.LOLAYER - b.LOLAYER);
setMaterials(sortedMaterials);
} else {
setMaterials([]);
}
} catch (error) {
console.error("자재 로드 실패:", error);
setMaterials([]);
} finally {
setLoadingMaterials(false);
}
};
// 야드 필터
if (selectedYard !== "all" && !material.location.startsWith(selectedYard)) {
// 객체 클릭
const handleObjectClick = (objectId: number | null) => {
if (objectId === null) {
setSelectedObject(null);
setShowInfoPanel(false);
return;
}
const obj = placedObjects.find((o) => o.id === objectId);
setSelectedObject(obj || null);
// Location을 클릭한 경우, 자재 정보 표시
if (
obj &&
(obj.type === "location-bed" ||
obj.type === "location-stp" ||
obj.type === "location-temp" ||
obj.type === "location-dest") &&
obj.locaKey &&
externalDbConnectionId
) {
setShowInfoPanel(true);
loadMaterialsForLocation(obj.locaKey, externalDbConnectionId);
} else {
setShowInfoPanel(true);
setMaterials([]);
}
};
// 타입별 개수 계산 (useMemo로 최적화)
const typeCounts = useMemo(() => {
const counts: Record<string, number> = {
all: placedObjects.length,
area: 0,
"location-bed": 0,
"location-stp": 0,
"location-temp": 0,
"location-dest": 0,
"crane-mobile": 0,
rack: 0,
};
placedObjects.forEach((obj) => {
if (counts[obj.type] !== undefined) {
counts[obj.type]++;
}
});
return counts;
}, [placedObjects]);
// 필터링된 객체 목록 (useMemo로 최적화)
const filteredObjects = useMemo(() => {
return placedObjects.filter((obj) => {
// 타입 필터
if (filterType !== "all" && obj.type !== filterType) {
return false;
}
// 상태 필터
if (selectedStatus !== "all" && material.status !== selectedStatus) {
return false;
}
// 날짜 필터
if (dateRange.from && material.arrival_date < dateRange.from) {
return false;
}
if (dateRange.to && material.arrival_date > dateRange.to) {
return false;
// 검색 쿼리
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
obj.name.toLowerCase().includes(query) ||
obj.areaKey?.toLowerCase().includes(query) ||
obj.locaKey?.toLowerCase().includes(query)
);
}
return true;
});
}, [materials, searchTerm, selectedYard, selectedStatus, dateRange]);
}, [placedObjects, filterType, searchQuery]);
// 3D 객체 클릭 핸들러
const handleObjectClick = (objectId: number) => {
const material = materials.find((m) => m.id === objectId);
setSelectedMaterial(material || null);
};
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
);
}
return (
<div className="flex h-full w-full overflow-hidden">
{/* 좌측: 필터 패널 */}
<div className="flex h-full w-[20%] flex-col border-r">
{/* 검색바 */}
<div className="border-b p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="후판번호, 강종, 위치 검색..."
className="h-10 pl-10 text-sm"
/>
</div>
<div className="bg-background flex h-full flex-col">
{/* 상단 헤더 */}
<div className="flex items-center justify-between border-b p-4">
<div>
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
{/* 필터 옵션 */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{/* 야드 선택 */}
{/* 메인 영역 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측: 검색/필터 */}
<div className="flex h-full w-[20%] flex-col border-r">
<div className="space-y-4 p-4">
{/* 검색 */}
<div>
<h4 className="mb-2 text-sm font-semibold"></h4>
<div className="space-y-1">
{["all", "A동", "B동", "C동", "겐트리"].map((yard) => (
<button
key={yard}
onClick={() => setSelectedYard(yard)}
className={`block w-full rounded px-3 py-2 text-left text-sm transition-colors ${
selectedYard === yard
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
<Label className="mb-2 block text-sm font-semibold"></Label>
<div className="relative">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="이름, Area, Location 검색..."
className="h-10 pl-9 text-sm"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 p-0"
onClick={() => setSearchQuery("")}
>
{yard === "all" ? "전체" : yard}
</button>
))}
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* 상태 필터 */}
{/* 타입 필터 */}
<div>
<h4 className="mb-2 text-sm font-semibold"></h4>
<div className="space-y-1">
{["all", "입고", "가공중", "출고대기", "출고완료"].map((status) => (
<button
key={status}
onClick={() => setSelectedStatus(status)}
className={`block w-full rounded px-3 py-2 text-left text-sm transition-colors ${
selectedStatus === status
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
>
{status === "all" ? "전체" : status}
</button>
))}
</div>
<Label className="mb-2 block text-sm font-semibold"> </Label>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="h-10 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> ({typeCounts.all})</SelectItem>
<SelectItem value="area">Area ({typeCounts.area})</SelectItem>
<SelectItem value="location-bed">(BED) ({typeCounts["location-bed"]})</SelectItem>
<SelectItem value="location-stp">(STP) ({typeCounts["location-stp"]})</SelectItem>
<SelectItem value="location-temp">(TMP) ({typeCounts["location-temp"]})</SelectItem>
<SelectItem value="location-dest">(DES) ({typeCounts["location-dest"]})</SelectItem>
<SelectItem value="crane-mobile"> ({typeCounts["crane-mobile"]})</SelectItem>
<SelectItem value="rack"> ({typeCounts.rack})</SelectItem>
</SelectContent>
</Select>
</div>
{/* 기간 필터 */}
<div>
<h4 className="mb-2 text-sm font-semibold"> </h4>
{/* 필터 초기화 */}
{(searchQuery || filterType !== "all") && (
<Button
variant="outline"
size="sm"
className="h-9 w-full text-sm"
onClick={() => {
setSearchQuery("");
setFilterType("all");
}}
>
</Button>
)}
</div>
{/* 객체 목록 */}
<div className="flex-1 overflow-y-auto border-t p-4">
<Label className="mb-2 block text-sm font-semibold">
({filteredObjects.length})
</Label>
{filteredObjects.length === 0 ? (
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
</div>
) : (
<div className="space-y-2">
<Input
type="date"
value={dateRange.from}
onChange={(e) => setDateRange((prev) => ({ ...prev, from: e.target.value }))}
className="h-9 text-sm"
placeholder="시작일"
/>
<Input
type="date"
value={dateRange.to}
onChange={(e) => setDateRange((prev) => ({ ...prev, to: e.target.value }))}
className="h-9 text-sm"
placeholder="종료일"
/>
</div>
</div>
</div>
</div>
</div>
{filteredObjects.map((obj) => {
// 타입별 레이블
let typeLabel = obj.type;
if (obj.type === "location-bed") typeLabel = "베드(BED)";
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
else if (obj.type === "crane-mobile") typeLabel = "크레인";
else if (obj.type === "area") typeLabel = "Area";
else if (obj.type === "rack") typeLabel = "랙";
{/* 중앙: 3D 캔버스 */}
<div className="h-full flex-1 bg-gray-100">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<Yard3DCanvas
placements={[]} // TODO: 실제 배치 데이터
selectedPlacementId={selectedMaterial?.id || null}
onPlacementClick={(placement) => {
if (placement) {
handleObjectClick(placement.id);
} else {
setSelectedMaterial(null);
}
}}
onPlacementDrag={() => {}} // 뷰어 모드에서는 드래그 비활성화
focusOnPlacementId={null}
onCollisionDetected={() => {}}
/>
)}
</div>
{/* 우측: 상세정보 패널 (후판 목록 테이블) */}
<div className="h-full w-[30%] overflow-y-auto border-l">
<div className="p-4">
<h3 className="mb-4 text-lg font-semibold"> </h3>
{filteredMaterials.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-sm text-muted-foreground"> .</p>
</div>
) : (
<div className="space-y-2">
{filteredMaterials.map((material) => (
<div
key={material.id}
onClick={() => setSelectedMaterial(material)}
className={`cursor-pointer rounded-lg border p-3 transition-all ${
selectedMaterial?.id === material.id
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold">{material.plate_no}</span>
<span
className={`rounded-full px-2 py-0.5 text-xs ${
material.status === "입고"
? "bg-blue-100 text-blue-700"
: material.status === "가공중"
? "bg-yellow-100 text-yellow-700"
: material.status === "출고대기"
? "bg-orange-100 text-orange-700"
: "bg-green-100 text-green-700"
return (
<div
key={obj.id}
onClick={() => handleObjectClick(obj.id)}
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
selectedObject?.id === obj.id
? "ring-primary bg-primary/5 ring-2"
: "hover:shadow-sm"
}`}
>
{material.status}
</span>
</div>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{obj.name}</p>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: obj.color }}
/>
<span>{typeLabel}</span>
</div>
</div>
</div>
{/* 추가 정보 */}
<div className="mt-2 space-y-1">
{obj.areaKey && (
<p className="text-muted-foreground text-xs">
Area: <span className="font-medium">{obj.areaKey}</span>
</p>
)}
{obj.locaKey && (
<p className="text-muted-foreground text-xs">
Location: <span className="font-medium">{obj.locaKey}</span>
</p>
)}
{obj.materialCount !== undefined && obj.materialCount > 0 && (
<p className="text-xs text-yellow-600">
: <span className="font-semibold">{obj.materialCount}</span>
</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
<div className="flex justify-between">
<span>:</span>
<span className="font-medium text-foreground">{material.steel_grade}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-medium text-foreground">
{material.thickness}×{material.width}×{material.length}
</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-medium text-foreground">{material.weight.toLocaleString()} kg</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-medium text-foreground">{material.location}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-medium text-foreground">{material.arrival_date}</span>
</div>
</div>
</div>
))}
</div>
{/* 중앙: 3D 캔버스 */}
<div className="relative flex-1">
{!isLoading && (
<Yard3DCanvas
placements={useMemo(
() =>
placedObjects.map((obj) => ({
id: obj.id,
name: obj.name,
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,
material_count: obj.materialCount,
material_preview_height: obj.materialPreview?.height,
yard_layout_id: undefined,
material_code: null,
material_name: null,
quantity: null,
unit: null,
data_source_config: undefined,
data_binding: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})),
[placedObjects],
)}
selectedPlacementId={selectedObject?.id || null}
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
focusOnPlacementId={null}
onCollisionDetected={() => {}}
/>
)}
</div>
{/* 우측: 정보 패널 */}
{showInfoPanel && selectedObject && (
<div className="h-full w-[25%] overflow-y-auto border-l">
<div className="p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold"> </h3>
<p className="text-muted-foreground text-xs">{selectedObject.name}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => setShowInfoPanel(false)}>
<X className="h-4 w-4" />
</Button>
</div>
{/* 기본 정보 */}
<div className="bg-muted space-y-3 rounded-lg p-3">
<div>
<Label className="text-muted-foreground text-xs"></Label>
<p className="text-sm font-medium">{selectedObject.type}</p>
</div>
{selectedObject.areaKey && (
<div>
<Label className="text-muted-foreground text-xs">Area Key</Label>
<p className="text-sm font-medium">{selectedObject.areaKey}</p>
</div>
)}
{selectedObject.locaKey && (
<div>
<Label className="text-muted-foreground text-xs">Location Key</Label>
<p className="text-sm font-medium">{selectedObject.locaKey}</p>
</div>
)}
{selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && (
<div>
<Label className="text-muted-foreground text-xs"> </Label>
<p className="text-sm font-medium">{selectedObject.materialCount}</p>
</div>
)}
</div>
{/* 자재 목록 (Location인 경우) */}
{(selectedObject.type === "location-bed" ||
selectedObject.type === "location-stp" ||
selectedObject.type === "location-temp" ||
selectedObject.type === "location-dest") && (
<div className="mt-4">
<Label className="mb-2 block text-sm font-semibold"> </Label>
{loadingMaterials ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
) : materials.length === 0 ? (
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
{externalDbConnectionId
? "자재가 없습니다"
: "외부 DB 연결이 설정되지 않았습니다"}
</div>
) : (
<div className="space-y-2">
{materials.map((material, index) => (
<div
key={`${material.STKKEY}-${index}`}
className="bg-muted hover:bg-accent rounded-lg border p-3 transition-colors"
>
<div className="mb-2 flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{material.STKKEY}</p>
<p className="text-muted-foreground mt-0.5 text-xs">
: {material.LOLAYER} | Area: {material.AREAKEY}
</p>
</div>
</div>
<div className="text-muted-foreground grid grid-cols-2 gap-2 text-xs">
{material.STKWIDT && (
<div>
: <span className="font-medium">{material.STKWIDT}</span>
</div>
)}
{material.STKLENG && (
<div>
: <span className="font-medium">{material.STKLENG}</span>
</div>
)}
{material.STKHEIG && (
<div>
: <span className="font-medium">{material.STKHEIG}</span>
</div>
)}
{material.STKWEIG && (
<div>
: <span className="font-medium">{material.STKWEIG}</span>
</div>
)}
</div>
{material.STKRMKS && (
<p className="text-muted-foreground mt-2 text-xs italic">{material.STKRMKS}</p>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { Canvas, useThree } from "@react-three/fiber";
import { OrbitControls, Grid, Box, Text } from "@react-three/drei";
import { Suspense, useRef, useState, useEffect } from "react";
import { Suspense, useRef, useState, useEffect, useMemo } from "react";
import * as THREE from "three";
interface YardPlacement {
@@ -23,6 +23,8 @@ interface YardPlacement {
data_source_type?: string | null;
data_source_config?: any;
data_binding?: any;
material_count?: number; // Location의 자재 개수
material_preview_height?: number; // 자재 스택 높이 (시각적)
}
interface Yard3DCanvasProps {
@@ -103,7 +105,7 @@ function MaterialBox({
if (!allPlacements || allPlacements.length === 0) {
// 다른 객체가 없으면 기본 높이
const objectType = placement.data_source_type as string | null;
const defaultY = objectType === "yard" ? 0.05 : (placement.size_y || gridSize) / 2;
const defaultY = objectType === "area" ? 0.05 : (placement.size_y || gridSize) / 2;
return {
hasCollision: false,
adjustedY: defaultY,
@@ -122,11 +124,11 @@ function MaterialBox({
const myMaxZ = z + mySizeZ / 2;
const objectType = placement.data_source_type as string | null;
const defaultY = objectType === "yard" ? 0.05 : mySizeY / 2;
const defaultY = objectType === "area" ? 0.05 : mySizeY / 2;
let maxYBelow = defaultY;
// 야드는 스택되지 않음 (항상 바닥에 배치)
if (objectType === "yard") {
// Area는 스택되지 않음 (항상 바닥에 배치)
if (objectType === "area") {
return {
hasCollision: false,
adjustedY: defaultY,
@@ -385,8 +387,8 @@ function MaterialBox({
// 타입별 렌더링
const renderObjectByType = () => {
switch (objectType) {
case "yard":
// 야드: 투명한 바닥 + 두꺼운 외곽선 (mesh) + 이름 텍스트
case "area":
// Area: 투명한 바닥 + 두꺼운 외곽선 (mesh) + 이름 텍스트
const borderThickness = 0.3; // 외곽선 두께
return (
<>
@@ -440,7 +442,7 @@ function MaterialBox({
</>
)}
{/* 야드 이름 텍스트 */}
{/* Area 이름 텍스트 */}
{placement.name && (
<Text
position={[0, 0.15, 0]}
@@ -458,6 +460,124 @@ function MaterialBox({
</>
);
case "location-bed":
case "location-temp":
case "location-dest":
// 베드 타입 Location: 초록색 상자
return (
<>
<Box args={[boxWidth, boxHeight, boxDepth]}>
<meshStandardMaterial
color={placement.color}
roughness={0.5}
metalness={0.3}
emissive={isSelected ? placement.color : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
/>
</Box>
{/* 대표 자재 스택 (자재가 있을 때만) */}
{placement.material_count !== undefined &&
placement.material_count > 0 &&
placement.material_preview_height && (
<Box
args={[boxWidth * 0.7, placement.material_preview_height, boxDepth * 0.7]}
position={[0, boxHeight / 2 + placement.material_preview_height / 2, 0]}
>
<meshStandardMaterial
color="#ef4444"
roughness={0.6}
metalness={0.2}
emissive={isSelected ? "#ef4444" : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
transparent
opacity={0.7}
/>
</Box>
)}
{/* Location 이름 */}
{placement.name && (
<Text
position={[0, boxHeight / 2 + 0.3, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
color="#ffffff"
anchorX="center"
anchorY="middle"
outlineWidth={0.03}
outlineColor="#000000"
>
{placement.name}
</Text>
)}
{/* 자재 개수 */}
{placement.material_count !== undefined && placement.material_count > 0 && (
<Text
position={[0, boxHeight / 2 + 0.6, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
color="#fbbf24"
anchorX="center"
anchorY="middle"
outlineWidth={0.03}
outlineColor="#000000"
>
{`자재: ${placement.material_count}`}
</Text>
)}
</>
);
case "location-stp":
// 정차포인트(STP): 주황색 낮은 플랫폼
return (
<>
<Box args={[boxWidth, boxHeight, boxDepth]}>
<meshStandardMaterial
color={placement.color}
roughness={0.6}
metalness={0.2}
emissive={isSelected ? placement.color : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
/>
</Box>
{/* Location 이름 */}
{placement.name && (
<Text
position={[0, boxHeight / 2 + 0.3, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
color="#ffffff"
anchorX="center"
anchorY="middle"
outlineWidth={0.03}
outlineColor="#000000"
>
{placement.name}
</Text>
)}
{/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */}
{placement.material_count !== undefined && placement.material_count > 0 && (
<Text
position={[0, boxHeight / 2 + 0.6, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
color="#fbbf24"
anchorX="center"
anchorY="middle"
outlineWidth={0.03}
outlineColor="#000000"
>
{`자재: ${placement.material_count}`}
</Text>
)}
</>
);
// case "gantry-crane":
// // 겐트리 크레인: 기둥 2개 + 상단 빔
// return (
@@ -505,7 +625,7 @@ function MaterialBox({
// </group>
// );
case "mobile-crane":
case "crane-mobile":
// 이동식 크레인: 하부(트랙) + 회전대 + 캐빈 + 붐대 + 카운터웨이트 + 후크
return (
<group>