"use client"; import { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck } 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"; import type { PlacedObject, ToolType, Warehouse, Area, Location, ObjectType } from "@/types/digitalTwin"; import { getWarehouses, getAreas, getLocations, getLayoutById, updateLayout, getMaterialCounts, getMaterials, } from "@/lib/api/digitalTwin"; import type { MaterialData } from "@/types/digitalTwin"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; // 백엔드 DB 객체 타입 (snake_case) interface DbObject { id: number; object_type: ObjectType; object_name: string; position_x: string; position_y: string; position_z: string; size_x: string; size_y: string; size_z: string; rotation?: string; color: string; area_key?: string; loca_key?: string; loc_type?: string; material_count?: number; material_preview_height?: string; parent_id?: number; display_order?: number; locked?: boolean; visible?: boolean; } const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, loading: () => (
), }); interface DigitalTwinEditorProps { layoutId: number; layoutName: string; onBack: () => void; } export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: DigitalTwinEditorProps) { const { toast } = useToast(); const [placedObjects, setPlacedObjects] = useState([]); const [selectedObject, setSelectedObject] = useState(null); const [draggedTool, setDraggedTool] = useState(null); const [draggedAreaData, setDraggedAreaData] = useState(null); // 드래그 중인 Area 정보 const [draggedLocationData, setDraggedLocationData] = useState(null); // 드래그 중인 Location 정보 const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [externalDbConnections, setExternalDbConnections] = useState<{ id: number; name: string; db_type: string }[]>( [], ); const [selectedDbConnection, setSelectedDbConnection] = useState(null); const [selectedWarehouse, setSelectedWarehouse] = useState(null); const [warehouses, setWarehouses] = useState([]); const [availableAreas, setAvailableAreas] = useState([]); const [availableLocations, setAvailableLocations] = useState([]); const [nextObjectId, setNextObjectId] = useState(-1); const [loadingWarehouses, setLoadingWarehouses] = useState(false); const [loadingAreas, setLoadingAreas] = useState(false); const [loadingLocations, setLoadingLocations] = useState(false); const [materials, setMaterials] = useState([]); const [loadingMaterials, setLoadingMaterials] = useState(false); const [showMaterialPanel, setShowMaterialPanel] = useState(false); // 테이블 매핑 관련 상태 const [availableTables, setAvailableTables] = useState([]); const [loadingTables, setLoadingTables] = useState(false); const [selectedTables, setSelectedTables] = useState({ warehouse: "", area: "", location: "", material: "", }); const [tableColumns, setTableColumns] = useState<{ [key: string]: string[] }>({}); const [selectedColumns, setSelectedColumns] = useState({ warehouseKey: "WAREKEY", warehouseName: "WARENAME", areaKey: "AREAKEY", areaName: "AREANAME", locationKey: "LOCAKEY", locationName: "LOCANAME", materialKey: "STKKEY", }); // placedObjects를 YardPlacement 형식으로 변환 (useMemo로 최적화) const placements = useMemo(() => { const now = new Date().toISOString(); // 한 번만 생성 return 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: now, // 고정된 값 사용 updated_at: now, // 고정된 값 사용 material_count: obj.materialCount, material_preview_height: obj.materialPreview?.height, })); }, [placedObjects, layoutId]); // 외부 DB 연결 목록 로드 useEffect(() => { const loadExternalDbConnections = async () => { try { const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" }); console.log("🔍 외부 DB 연결 목록 (is_active=Y):", connections); console.log("🔍 연결 ID들:", connections.map(c => c.id)); setExternalDbConnections( connections.map((conn) => ({ id: conn.id!, name: conn.connection_name, db_type: conn.db_type, })), ); } catch (error) { console.error("외부 DB 연결 목록 조회 실패:", error); toast({ variant: "destructive", title: "오류", description: "외부 DB 연결 목록을 불러오는데 실패했습니다.", }); } }; loadExternalDbConnections(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 컴포넌트 마운트 시 한 번만 실행 // 외부 DB 선택 시 테이블 목록 로드 useEffect(() => { if (!selectedDbConnection) { setAvailableTables([]); setSelectedTables({ warehouse: "", area: "", location: "", material: "" }); return; } const loadTables = async () => { try { setLoadingTables(true); const { getTables } = await import("@/lib/api/digitalTwin"); const response = await getTables(selectedDbConnection); if (response.success && response.data) { const tableNames = response.data.map((t) => t.table_name); setAvailableTables(tableNames); console.log("📋 테이블 목록:", tableNames); } } catch (error) { console.error("테이블 목록 조회 실패:", error); toast({ variant: "destructive", title: "오류", description: "테이블 목록을 불러오는데 실패했습니다.", }); } finally { setLoadingTables(false); } }; loadTables(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDbConnection]); // 테이블 컬럼 로드 const loadColumnsForTable = async (tableName: string, type: "warehouse" | "area" | "location" | "material") => { if (!selectedDbConnection || !tableName) return; try { const { getTablePreview } = await import("@/lib/api/digitalTwin"); const response = await getTablePreview(selectedDbConnection, tableName); console.log(`📊 ${type} 테이블 미리보기:`, response); if (response.success && response.data && response.data.length > 0) { const columns = Object.keys(response.data[0]); setTableColumns(prev => ({ ...prev, [type]: columns })); // 자동 매핑 시도 (기본값 설정) if (type === "warehouse") { const keyCol = columns.find(c => c.includes("KEY") || c.includes("ID")) || columns[0]; const nameCol = columns.find(c => c.includes("NAME") || c.includes("NAM")) || columns[1] || columns[0]; setSelectedColumns(prev => ({ ...prev, warehouseKey: keyCol, warehouseName: nameCol })); } } else { console.warn(`⚠️ ${tableName} 테이블에 데이터가 없습니다.`); toast({ variant: "default", // destructive 대신 default로 변경 (단순 알림) title: "데이터 없음", description: `${tableName} 테이블에 데이터가 없습니다.`, }); } } catch (error) { console.error(`컬럼 로드 실패 (${tableName}):`, error); } }; // 외부 DB 선택 시 창고 목록 로드 (테이블이 선택되어 있을 때만) useEffect(() => { if (!selectedDbConnection || !selectedTables.warehouse) { setWarehouses([]); setSelectedWarehouse(null); return; } const loadWarehouses = async () => { try { setLoadingWarehouses(true); const response = await getWarehouses(selectedDbConnection, selectedTables.warehouse); console.log("📦 창고 API 응답:", response); if (response.success && response.data) { console.log("📦 창고 데이터:", response.data); setWarehouses(response.data); } else { // 외부 DB 연결이 유효하지 않으면 선택 초기화 console.warn("외부 DB 연결이 유효하지 않습니다:", selectedDbConnection); setSelectedDbConnection(null); toast({ variant: "destructive", title: "외부 DB 연결 오류", description: "저장된 외부 DB 연결을 찾을 수 없습니다. 다시 선택해주세요.", }); } } catch (error: any) { console.error("창고 목록 조회 실패:", error); // 외부 DB 연결이 존재하지 않으면 선택 초기화 if (error.response?.status === 500 && error.response?.data?.error?.includes("연결 정보를 찾을 수 없습니다")) { setSelectedDbConnection(null); toast({ variant: "destructive", title: "외부 DB 연결 오류", description: "저장된 외부 DB 연결을 찾을 수 없습니다. 다시 선택해주세요.", }); } else { toast({ variant: "destructive", title: "오류", description: "창고 목록을 불러오는데 실패했습니다.", }); } } finally { setLoadingWarehouses(false); } }; loadWarehouses(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDbConnection, selectedTables.warehouse]); // toast 제거, warehouse 테이블 추가 // 창고 선택 시 Area 목록 로드 useEffect(() => { if (!selectedDbConnection || !selectedWarehouse) { setAvailableAreas([]); return; } const loadAreas = async () => { try { setLoadingAreas(true); const response = await getAreas(selectedDbConnection, selectedTables.area, selectedWarehouse); if (response.success && response.data) { setAvailableAreas(response.data); } } catch (error) { console.error("Area 목록 조회 실패:", error); toast({ variant: "destructive", title: "오류", description: "Area 목록을 불러오는데 실패했습니다.", }); } finally { setLoadingAreas(false); } }; loadAreas(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDbConnection, selectedWarehouse, selectedTables.area]); // toast 제거, area 테이블 추가 // 레이아웃 데이터 로드 const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null); useEffect(() => { const loadLayout = async () => { try { setIsLoading(true); const response = await getLayoutById(layoutId); if (response.success && response.data) { const { layout, objects } = response.data; setLayoutData({ layout, objects }); // 레이아웃 데이터 저장 // 객체 데이터 변환 (DB -> PlacedObject) const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ 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); // 다음 임시 ID 설정 (기존 ID 중 최소값 - 1) const minId = Math.min(...loadedObjects.map((o) => o.id), 0); setNextObjectId(minId - 1); setHasUnsavedChanges(false); toast({ title: "레이아웃 불러오기 완료", description: `${loadedObjects.length}개의 객체를 불러왔습니다.`, }); // Location 객체들의 자재 개수 로드 const locationObjects = loadedObjects.filter( (obj) => (obj.type === "location-bed" || obj.type === "location-stp" || obj.type === "location-temp" || obj.type === "location-dest") && obj.locaKey, ); if (locationObjects.length > 0) { const locaKeys = locationObjects.map((obj) => obj.locaKey!); setTimeout(() => { loadMaterialCountsForLocations(locaKeys); }, 100); } } else { throw new Error(response.error || "레이아웃 조회 실패"); } } catch (error) { console.error("레이아웃 로드 실패:", error); const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다."; toast({ variant: "destructive", title: "오류", description: errorMessage, }); } finally { setIsLoading(false); } }; loadLayout(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [layoutId]); // toast 제거 // 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시) useEffect(() => { if (!layoutData || !layoutData.layout.externalDbConnectionId || externalDbConnections.length === 0) { return; } const layout = layoutData.layout; console.log("🔍 외부 DB 연결 자동 선택 시도"); console.log("🔍 레이아웃의 externalDbConnectionId:", layout.externalDbConnectionId); console.log("🔍 사용 가능한 연결 목록:", externalDbConnections); const connectionExists = externalDbConnections.some( (conn) => conn.id === layout.externalDbConnectionId, ); console.log("🔍 연결 존재 여부:", connectionExists); if (connectionExists) { setSelectedDbConnection(layout.externalDbConnectionId); if (layout.warehouseKey) { setSelectedWarehouse(layout.warehouseKey); } console.log("✅ 외부 DB 연결 자동 선택 완료:", layout.externalDbConnectionId); } else { console.warn("⚠️ 저장된 외부 DB 연결을 찾을 수 없습니다:", layout.externalDbConnectionId); console.warn("⚠️ 사용 가능한 연결 ID들:", externalDbConnections.map(c => c.id)); toast({ variant: "destructive", title: "외부 DB 연결 오류", description: "저장된 외부 DB 연결을 찾을 수 없습니다. 다시 선택해주세요.", }); } }, [layoutData, externalDbConnections]); // layoutData와 externalDbConnections가 모두 준비되면 실행 // 도구 타입별 기본 설정 const getToolDefaults = (type: ToolType): Partial => { switch (type) { case "area": return { name: "영역", size: { x: 20, y: 0.1, z: 20 }, // 4x4 칸 color: "#3b82f6", // 파란색 }; case "location-bed": return { name: "베드(BED)", size: { x: 5, y: 2, z: 5 }, // 1x1 칸 color: "#10b981", // 에메랄드 }; case "location-stp": return { name: "정차포인트(STP)", size: { x: 5, y: 0.5, z: 5 }, // 1x1 칸, 낮은 높이 color: "#f59e0b", // 주황색 }; case "location-temp": return { name: "임시베드(TMP)", size: { x: 5, y: 2, z: 5 }, // 베드와 동일 color: "#10b981", // 베드와 동일 }; case "location-dest": return { name: "지정착지(DES)", size: { x: 5, y: 2, z: 5 }, // 베드와 동일 color: "#10b981", // 베드와 동일 }; // case "crane-gantry": // return { // name: "겐트리 크레인", // size: { x: 5, y: 8, z: 5 }, // 1x1 칸 // color: "#22c55e", // 녹색 // }; case "crane-mobile": 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 "material": // 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); // Area는 바닥(y=0.05)에, 다른 객체는 중앙 정렬 const yPosition = draggedTool === "area" ? 0.05 : (defaults.size?.y || 1) / 2; // 외부 DB 데이터에서 드래그한 경우 해당 정보 사용 let objectName = defaults.name || "새 객체"; let areaKey: string | undefined = undefined; let locaKey: string | undefined = undefined; let locType: string | undefined = undefined; if (draggedTool === "area" && draggedAreaData) { objectName = draggedAreaData.AREANAME; areaKey = draggedAreaData.AREAKEY; } else if ( (draggedTool === "location-bed" || draggedTool === "location-stp" || draggedTool === "location-temp" || draggedTool === "location-dest") && draggedLocationData ) { objectName = draggedLocationData.LOCANAME || draggedLocationData.LOCAKEY; areaKey = draggedLocationData.AREAKEY; locaKey = draggedLocationData.LOCAKEY; locType = draggedLocationData.LOCTYPE; } const newObject: PlacedObject = { id: nextObjectId, type: draggedTool, name: objectName, position: { x, y: yPosition, z }, size: defaults.size || { x: 5, y: 5, z: 5 }, color: defaults.color || "#9ca3af", areaKey, locaKey, locType, }; setPlacedObjects((prev) => [...prev, newObject]); setSelectedObject(newObject); setNextObjectId((prev) => prev - 1); setHasUnsavedChanges(true); setDraggedTool(null); setDraggedAreaData(null); setDraggedLocationData(null); // Location 배치 시 자재 개수 로드 if ( (draggedTool === "location-bed" || draggedTool === "location-stp" || draggedTool === "location-temp" || draggedTool === "location-dest") && locaKey ) { // 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행) setTimeout(() => { loadMaterialCountsForLocations(); }, 100); } }; // Location의 자재 목록 로드 const loadMaterialsForLocation = async (locaKey: string) => { if (!selectedDbConnection) return; try { setLoadingMaterials(true); setShowMaterialPanel(true); const response = await getMaterials(selectedDbConnection, selectedTables.material, locaKey); if (response.success && response.data) { // LOLAYER 순으로 정렬 const sortedMaterials = response.data.sort((a, b) => a.LOLAYER - b.LOLAYER); setMaterials(sortedMaterials); } else { setMaterials([]); toast({ variant: "destructive", title: "자재 조회 실패", description: response.error || "자재 목록을 불러올 수 없습니다.", }); } } catch (error) { console.error("자재 로드 실패:", error); setMaterials([]); toast({ variant: "destructive", title: "오류", description: "자재 목록을 불러오는데 실패했습니다.", }); } finally { setLoadingMaterials(false); } }; // 객체 클릭 const handleObjectClick = (objectId: number | null) => { if (objectId === null) { setSelectedObject(null); setShowMaterialPanel(false); return; } const obj = placedObjects.find((o) => o.id === objectId); setSelectedObject(obj || null); // Area를 클릭한 경우, 해당 Area의 Location 목록 로드 if (obj && obj.type === "area" && obj.areaKey && selectedDbConnection) { loadLocationsForArea(obj.areaKey); setShowMaterialPanel(false); } // Location을 클릭한 경우, 해당 Location의 자재 목록 로드 else if ( obj && (obj.type === "location-bed" || obj.type === "location-stp" || obj.type === "location-temp" || obj.type === "location-dest") && obj.locaKey && selectedDbConnection ) { loadMaterialsForLocation(obj.locaKey); } else { setShowMaterialPanel(false); } }; // Location별 자재 개수 로드 (locaKeys를 직접 받음) const loadMaterialCountsForLocations = async (locaKeys: string[]) => { if (!selectedDbConnection || locaKeys.length === 0) return; try { const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys); if (response.success && response.data) { // 각 Location 객체에 자재 개수 업데이트 setPlacedObjects((prev) => prev.map((obj) => { const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey); if (materialCount) { return { ...obj, materialCount: materialCount.material_count, materialPreview: { height: materialCount.max_layer * 1.5, // 층당 1.5 높이 (시각적) }, }; } return obj; }), ); } } catch (error) { console.error("자재 개수 로드 실패:", error); } }; // 특정 Area의 Location 목록 로드 const loadLocationsForArea = async (areaKey: string) => { if (!selectedDbConnection) return; try { setLoadingLocations(true); const response = await getLocations(selectedDbConnection, selectedTables.location, areaKey); if (response.success && response.data) { setAvailableLocations(response.data); toast({ title: "Location 로드 완료", description: `${response.data.length}개 Location을 불러왔습니다.`, }); } } catch (error) { console.error("Location 목록 조회 실패:", error); toast({ variant: "destructive", title: "오류", description: "Location 목록을 불러오는데 실패했습니다.", }); } finally { setLoadingLocations(false); } }; // 객체 이동 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) => { 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는 자유롭게 (Area 제외) if (actualObject.type !== "area") { 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; } if (!selectedWarehouse) { toast({ title: "창고 선택 필요", description: "창고를 선택하세요.", variant: "destructive", }); return; } setIsSaving(true); try { const response = await updateLayout(layoutId, { layoutName: layoutName, description: undefined, objects: placedObjects.map((obj, index) => ({ ...obj, displayOrder: index, // 현재 순서대로 저장 })), }); if (response.success) { toast({ title: "저장 완료", description: `${placedObjects.length}개의 객체가 저장되었습니다.`, }); setHasUnsavedChanges(false); // 저장 후 DB에서 할당된 ID로 객체 업데이트 (필요 시) // 현재는 updateLayout이 기존 객체 삭제 후 재생성하므로 // 레이아웃 다시 불러오기 const reloadResponse = await getLayoutById(layoutId); if (reloadResponse.success && reloadResponse.data) { const { objects } = reloadResponse.data; const reloadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({ 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(reloadedObjects); } } else { throw new Error(response.error || "레이아웃 저장 실패"); } } catch (error) { console.error("저장 실패:", error); const errorMessage = error instanceof Error ? error.message : "레이아웃 저장에 실패했습니다."; toast({ title: "저장 실패", description: errorMessage, variant: "destructive", }); } finally { setIsSaving(false); } }; return (
{/* 상단 툴바 */}

{layoutName}

디지털 트윈 야드 편집

{hasUnsavedChanges && 미저장 변경사항 있음}
{/* 도구 팔레트 */}
도구: {[ { type: "area" as ToolType, label: "영역", icon: Grid3x3, color: "text-blue-500" }, { type: "location-bed" as ToolType, label: "베드", icon: Package, color: "text-emerald-500" }, { type: "location-stp" as ToolType, label: "정차", icon: Move, color: "text-orange-500" }, // { type: "crane-gantry" as ToolType, label: "겐트리", icon: Combine, color: "text-green-500" }, { type: "crane-mobile" as ToolType, label: "크레인", icon: Truck, color: "text-yellow-500" }, { type: "rack" as ToolType, label: "랙", icon: Box, color: "text-purple-500" }, ].map((tool) => { const Icon = tool.icon; return (
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} 드래그하여 배치`} > {tool.label}
); })}
{/* 메인 영역 */}
{/* 좌측: 외부 DB 선택 + 객체 목록 */}
{/* 스크롤 영역 */}
{/* 외부 DB 선택 */}
{/* 테이블 매핑 선택 */} {selectedDbConnection && (
{loadingTables ? (
) : ( <>
{/* 창고 컬럼 매핑 */} {selectedTables.warehouse && tableColumns.warehouse && (
)}
)}
)} {/* 창고 선택 */} {selectedDbConnection && selectedTables.warehouse && (
{loadingWarehouses ? (
) : ( )}
)} {/* Area 목록 */} {selectedDbConnection && selectedWarehouse && (

사용 가능한 Area

{loadingAreas && }
{availableAreas.length === 0 ? (

Area가 없습니다

) : (
{availableAreas.map((area) => (
{ // Area 정보를 임시 저장 setDraggedTool("area"); setDraggedAreaData(area); }} onDragEnd={() => { setDraggedAreaData(null); }} className="bg-background hover:bg-accent cursor-grab rounded-lg border p-3 transition-all active:cursor-grabbing" >

{area.AREANAME}

{area.AREAKEY}

))}
)}
)} {/* Location 목록 (Area 클릭 시 표시) */} {availableLocations.length > 0 && (

사용 가능한 Location

{loadingLocations && }
{availableLocations.map((location) => { // Location 타입에 따라 ObjectType 결정 let locationType: ToolType = "location-bed"; if (location.LOCTYPE === "STP") { locationType = "location-stp"; } else if (location.LOCTYPE === "TMP") { locationType = "location-temp"; } else if (location.LOCTYPE === "DES") { locationType = "location-dest"; } return (
{ // Location 정보를 임시 저장 setDraggedTool(locationType); setDraggedLocationData(location); }} onDragEnd={() => { setDraggedLocationData(null); }} className="bg-background hover:bg-accent cursor-grab rounded-lg border p-3 transition-all active:cursor-grabbing" >

{location.LOCANAME || location.LOCAKEY}

{location.LOCAKEY} {location.LOCTYPE}
); })}
)}
{/* 배치된 객체 목록 */}

배치된 객체 ({placedObjects.length})

{placedObjects.length === 0 ? (
상단 도구를 드래그하여 배치하세요
) : (
{placedObjects.map((obj) => (
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" }`} >
{obj.name}

위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)})

{obj.areaKey &&

Area: {obj.areaKey}

}
))}
)}
{/* 중앙: 3D 캔버스 */}
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; // 그리드에 스냅 // Area(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에 let snappedX = Math.round(rawX / gridSize) * gridSize; let snappedZ = Math.round(rawZ / gridSize) * gridSize; // 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외) if (draggedTool !== "area") { snappedX += gridSize / 2; snappedZ += gridSize / 2; } handleCanvasDrop(snappedX, snappedZ); }} > {isLoading ? (
) : ( handleObjectClick(placement?.id || null)} onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)} focusOnPlacementId={null} onCollisionDetected={() => {}} /> )}
{/* 우측: 객체 속성 편집 or 자재 목록 */}
{showMaterialPanel && selectedObject ? ( /* 자재 목록 패널 */

자재 목록

{selectedObject.name} ({selectedObject.locaKey})

{loadingMaterials ? (
) : materials.length === 0 ? (
자재가 없습니다
) : (
{materials.map((material, index) => (

{material.STKKEY}

층: {material.LOLAYER} | Area: {material.AREAKEY}

{material.STKWIDT && (
폭: {material.STKWIDT}
)} {material.STKLENG && (
길이: {material.STKLENG}
)} {material.STKHEIG && (
높이: {material.STKHEIG}
)} {material.STKWEIG && (
무게: {material.STKWEIG}
)}
{material.STKRMKS && (

{material.STKRMKS}

)}
))}
)}
) : selectedObject ? (

객체 속성

{/* 이름 */}
handleObjectUpdate({ name: e.target.value })} className="mt-1.5 h-9 text-sm" />
{/* 위치 */}
handleObjectUpdate({ position: { ...selectedObject.position, x: parseFloat(e.target.value), }, }) } className="h-9 text-sm" />
handleObjectUpdate({ position: { ...selectedObject.position, z: parseFloat(e.target.value), }, }) } className="h-9 text-sm" />
{/* 크기 */}
handleObjectUpdate({ size: { ...selectedObject.size, x: parseFloat(e.target.value), }, }) } className="h-9 text-sm" />
handleObjectUpdate({ size: { ...selectedObject.size, y: parseFloat(e.target.value), }, }) } className="h-9 text-sm" />
handleObjectUpdate({ size: { ...selectedObject.size, z: parseFloat(e.target.value), }, }) } className="h-9 text-sm" />
{/* 색상 */}
handleObjectUpdate({ color: e.target.value })} className="mt-1.5 h-9" />
{/* 삭제 버튼 */}
) : (

객체를 선택하면 속성을 편집할 수 있습니다

)}
); }