"use client"; import { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } 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 { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; 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, getHierarchyData, getChildrenData, getMappingTemplates, createMappingTemplate, type HierarchyData, type DigitalTwinMappingTemplate, } from "@/lib/api/digitalTwin"; import type { MaterialData } from "@/types/digitalTwin"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; import HierarchyConfigPanel, { HierarchyConfig } from "./HierarchyConfigPanel"; import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants"; import { validateSpatialContainment, updateChildrenPositions, getAllDescendants } from "./spatialContainment"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; // 성능 최적화를 위한 디바운스/Blur 처리된 Input 컴포넌트 const DebouncedInput = ({ value, onChange, onCommit, type = "text", debounce = 0, ...props }: React.InputHTMLAttributes & { onCommit?: (value: any) => void; debounce?: number; }) => { const [localValue, setLocalValue] = useState(value); const [isEditing, setIsEditing] = useState(false); useEffect(() => { if (!isEditing) { setLocalValue(value); } }, [value, isEditing]); // 색상 입력 등을 위한 디바운스 커밋 useEffect(() => { if (debounce > 0 && isEditing && onCommit) { const timer = setTimeout(() => { onCommit(type === "number" ? parseFloat(localValue as string) : localValue); }, debounce); return () => clearTimeout(timer); } }, [localValue, debounce, isEditing, onCommit, type]); const handleChange = (e: React.ChangeEvent) => { setLocalValue(e.target.value); if (onChange) onChange(e); }; const handleBlur = (e: React.FocusEvent) => { setIsEditing(false); if (onCommit && debounce === 0) { // 값이 변경되었을 때만 커밋하도록 하면 좋겠지만, // 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨) onCommit(type === "number" ? parseFloat(localValue as string) : localValue); } if (props.onBlur) props.onBlur(e); }; const handleFocus = (e: React.FocusEvent) => { setIsEditing(true); if (props.onFocus) props.onFocus(e); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.currentTarget.blur(); } if (props.onKeyDown) props.onKeyDown(e); }; return ( ); }; // 백엔드 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 [previewPosition, setPreviewPosition] = useState<{ x: number; z: number } | null>(null); // 드래그 프리뷰 위치 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 [mappingTemplates, setMappingTemplates] = useState([]); const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [loadingTemplates, setLoadingTemplates] = useState(false); const [isSaveTemplateDialogOpen, setIsSaveTemplateDialogOpen] = useState(false); const [newTemplateName, setNewTemplateName] = useState(""); const [newTemplateDescription, setNewTemplateDescription] = useState(""); // 동적 계층 구조 설정 const [hierarchyConfig, setHierarchyConfig] = useState(null); const [availableTables, setAvailableTables] = useState>([]); const [loadingTables, setLoadingTables] = useState(false); // 레거시: 테이블 매핑 (구 Area/Location 방식 호환용) const [selectedTables, setSelectedTables] = useState<{ warehouse: string; area: string; location: string; material: string; }>({ warehouse: "", area: "", location: "", material: "", }); const [tableColumns, setTableColumns] = useState<{ warehouse?: { name: string; code: string }; area?: { name: string; code: string; warehouseCode: string }; location?: { name: string; code: string; areaCode: string }; }>({}); const [selectedColumns, setSelectedColumns] = useState<{ warehouseKey: string; warehouseName: string; areaKey: string; areaName: string; areaWarehouseKey: string; locationKey: string; locationName: string; locationAreaKey: string; materialKey?: string; }>({ warehouseKey: "WAREKEY", warehouseName: "WARENAME", areaKey: "AREAKEY", areaName: "AREANAME", areaWarehouseKey: "WAREKEY", locationKey: "LOCAKEY", locationName: "LOCANAME", locationAreaKey: "AREAKEY", materialKey: "LOCAKEY", }); // 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 loadTemplates = async () => { if (!selectedDbConnection) { setMappingTemplates([]); setSelectedTemplateId(""); return; } try { setLoadingTemplates(true); const response = await getMappingTemplates({ externalDbConnectionId: selectedDbConnection, layoutType: "yard-3d", }); if (response.success && response.data) { setMappingTemplates(response.data); } else { setMappingTemplates([]); } } catch (error) { console.error("매핑 템플릿 목록 조회 실패:", error); } finally { setLoadingTemplates(false); } }; loadTemplates(); }, [selectedDbConnection]); // 외부 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: "" }); // warehouse는 HierarchyConfigPanel에서 관리 return; } const loadTables = async () => { try { setLoadingTables(true); // 외부 DB 메타데이터 API 사용 (테이블 + 설명) const response = await ExternalDbConnectionAPI.getTables(selectedDbConnection); if (response.success && response.data) { const rawTables = response.data as any[]; const normalized = rawTables.map((t: any) => typeof t === "string" ? { table_name: t } : { table_name: t.table_name || t.TABLE_NAME || String(t), description: t.description || t.table_description || undefined, }, ); setAvailableTables(normalized); console.log("📋 테이블 목록:", normalized); } else { setAvailableTables([]); console.warn("테이블 목록 조회 실패:", response.message || response.error); } } catch (error) { console.error("테이블 목록 조회 실패:", error); toast({ variant: "destructive", title: "오류", description: "테이블 목록을 불러오는데 실패했습니다.", }); } finally { setLoadingTables(false); } }; loadTables(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedDbConnection]); // 동적 계층 구조 데이터 로드 useEffect(() => { const loadHierarchy = async () => { if (!selectedDbConnection || !hierarchyConfig) { return; } // 필수 필드 검증: 창고가 선택되었는지 확인 if (!hierarchyConfig.warehouseKey) { return; } // 레벨 설정 검증 if (!hierarchyConfig.levels || hierarchyConfig.levels.length === 0) { return; } // 각 레벨의 필수 필드 검증 for (const level of hierarchyConfig.levels) { if (!level.tableName || !level.keyColumn || !level.nameColumn) { return; } } try { const response = await getHierarchyData(selectedDbConnection, hierarchyConfig); if (response.success && response.data) { const { warehouse, levels, materials } = response.data; // 창고 데이터 설정 if (warehouse) { setWarehouses(warehouse); } // 레벨 데이터 설정 // 기존 호환성을 위해 레벨 1 -> Area, 레벨 2 -> Location으로 매핑 // TODO: UI를 동적으로 생성하도록 개선 필요 const level1 = levels.find((l) => l.level === 1); if (level1) { setAvailableAreas(level1.data); } const level2 = levels.find((l) => l.level === 2); if (level2) { setAvailableLocations(level2.data); } console.log("계층 데이터 로드 완료:", response.data); } } catch (error) { console.error("계층 데이터 로드 실패:", error); } }; loadHierarchy(); }, [selectedDbConnection, hierarchyConfig]); // 테이블 컬럼 로드 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 () => { if (!hierarchyConfig?.warehouse?.tableName) { return; } try { setLoadingWarehouses(true); const response = await getWarehouses(selectedDbConnection, hierarchyConfig.warehouse.tableName); 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, hierarchyConfig?.warehouse?.tableName]); // hierarchyConfig.warehouse.tableName 추가 // 창고 선택 시 Area 목록 로드 useEffect(() => { if (!selectedDbConnection || !selectedWarehouse) { setAvailableAreas([]); return; } // Area 테이블명이 설정되지 않으면 API 호출 스킵 if (!selectedTables.area) { 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 연결 ID 복원 if (layout.external_db_connection_id) { setSelectedDbConnection(layout.external_db_connection_id); } // 계층 구조 설정 로드 if (layout.hierarchy_config) { try { // hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용 const config = typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config; setHierarchyConfig(config); // 선택된 테이블 정보도 복원 const newSelectedTables: any = { warehouse: config.warehouse?.tableName || "", area: "", location: "", material: "", }; if (config.levels && config.levels.length > 0) { // 레벨 1 = Area if (config.levels[0]?.tableName) { newSelectedTables.area = config.levels[0].tableName; } // 레벨 2 = Location if (config.levels[1]?.tableName) { newSelectedTables.location = config.levels[1].tableName; } } // 자재 테이블 정보 if (config.material?.tableName) { newSelectedTables.material = config.material.tableName; } setSelectedTables(newSelectedTables); } catch (e) { console.error("계층 구조 설정 파싱 실패:", e); } } // 객체 데이터 변환 (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.loc_type === "STP" ? undefined : obj.material_count, materialPreview: obj.loc_type === "STP" || !obj.material_preview_height ? undefined : { height: parseFloat(obj.material_preview_height) }, parentId: obj.parent_id, displayOrder: obj.display_order, locked: obj.locked, visible: obj.visible !== false, hierarchyLevel: obj.hierarchy_level || 1, parentKey: obj.parent_key, externalKey: obj.external_key, })); 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 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달) const dbConnectionId = layout.external_db_connection_id; const hierarchyConfigParsed = typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config; const materialTableName = hierarchyConfigParsed?.material?.tableName; 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 && dbConnectionId && materialTableName) { const locaKeys = locationObjects.map((obj) => obj.locaKey!); setTimeout(() => { loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName); }, 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(() => { console.log("🔍 useEffect 실행:", { layoutData: !!layoutData, external_db_connection_id: layoutData?.layout?.external_db_connection_id, externalDbConnections: externalDbConnections.length, }); if (!layoutData || !layoutData.layout.external_db_connection_id || externalDbConnections.length === 0) { console.log("🔍 조건 미충족으로 종료"); return; } const layout = layoutData.layout; console.log("🔍 외부 DB 연결 자동 선택 시도"); console.log("🔍 레이아웃의 external_db_connection_id:", layout.external_db_connection_id); console.log("🔍 사용 가능한 연결 목록:", externalDbConnections); const connectionExists = externalDbConnections.some((conn) => conn.id === layout.external_db_connection_id); console.log("🔍 연결 존재 여부:", connectionExists); if (connectionExists) { setSelectedDbConnection(layout.external_db_connection_id); if (layout.warehouse_key) { setSelectedWarehouse(layout.warehouse_key); } console.log("✅ 외부 DB 연결 자동 선택 완료:", layout.external_db_connection_id); } else { console.warn("⚠️ 저장된 외부 DB 연결을 찾을 수 없습니다:", layout.external_db_connection_id); 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 = async (x: number, z: number) => { if (!draggedTool) return; const defaults = getToolDefaults(draggedTool); // Area는 바닥(y=0.05)에, 다른 객체는 중앙 정렬 let 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; let hierarchyLevel = 1; let parentKey: string | undefined = undefined; let externalKey: string | undefined = undefined; if (draggedTool === "area" && draggedAreaData) { objectName = draggedAreaData.AREANAME; areaKey = draggedAreaData.AREAKEY; // 계층 정보 설정 (예: Area는 레벨 1) hierarchyLevel = 1; externalKey = 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; // 계층 정보 설정 (예: Location은 레벨 2) hierarchyLevel = 2; parentKey = draggedLocationData.AREAKEY; externalKey = draggedLocationData.LOCAKEY; } // 기본 크기 설정 let objectSize = defaults.size || { x: 5, y: 5, z: 5 }; // Location 배치 시 자재 개수에 따라 높이 자동 설정 (BED/TMP/DES만 대상, STP는 자재 미적재) if ( (draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") && locaKey && selectedDbConnection && hierarchyConfig?.material ) { try { // 해당 Location의 자재 개수 조회 const countsResponse = await getMaterialCounts(selectedDbConnection, hierarchyConfig.material.tableName, [ locaKey, ]); if (countsResponse.success && countsResponse.data && countsResponse.data.length > 0) { const materialCount = countsResponse.data[0].count; // 자재 개수에 비례해서 높이(Y축) 설정 (최소 5, 최대 30) // 자재 1개 = 높이 5, 자재 10개 = 높이 15, 자재 50개 = 높이 30 const calculatedHeight = Math.min(30, Math.max(5, 5 + materialCount * 0.5)); objectSize = { ...objectSize, y: calculatedHeight, // Y축이 높이! }; // 높이가 높아진 만큼 Y 위치도 올려서 바닥을 뚫지 않게 조정 yPosition = calculatedHeight / 2; } } catch (error) { console.error("자재 개수 조회 실패, 기본 높이 사용:", error); } } const newObject: PlacedObject = { id: nextObjectId, type: draggedTool, name: objectName, position: { x, y: yPosition, z }, size: objectSize, color: OBJECT_COLORS[draggedTool] || DEFAULT_COLOR, // 타입별 기본 색상 areaKey, locaKey, locType, hierarchyLevel, parentKey, externalKey, }; // 공간적 종속성 검증 if (hierarchyConfig && hierarchyLevel > 1) { const validation = validateSpatialContainment( { id: newObject.id, position: newObject.position, size: newObject.size, hierarchyLevel: newObject.hierarchyLevel || 1, parentId: newObject.parentId, }, placedObjects.map((obj) => ({ id: obj.id, position: obj.position, size: obj.size, hierarchyLevel: obj.hierarchyLevel || 1, parentId: obj.parentId, })), ); if (!validation.valid) { toast({ variant: "destructive", title: "배치 오류", description: "하위 영역은 반드시 상위 영역 내부에 배치되어야 합니다.", }); return; } // 부모 ID 설정 및 논리적 유효성 검사 if (validation.parent) { // 1. 부모 객체 찾기 const parentObj = placedObjects.find((obj) => obj.id === validation.parent!.id); // 2. 논리적 키 검사 (DB에서 가져온 데이터인 경우) if (parentObj && parentObj.externalKey && newObject.parentKey) { if (parentObj.externalKey !== newObject.parentKey) { toast({ variant: "destructive", title: "배치 오류", description: `이 Location은 '${newObject.parentKey}' Area에만 배치할 수 있습니다. (현재 선택된 Area: ${parentObj.externalKey})`, }); return; } } newObject.parentId = validation.parent.id; } else if (newObject.parentKey) { // DB 데이터인데 부모 영역 위에 놓이지 않은 경우 toast({ variant: "destructive", title: "배치 오류", description: `이 Location은 '${newObject.parentKey}' Area 내부에 배치해야 합니다.`, }); return; } } setPlacedObjects((prev) => [...prev, newObject]); setSelectedObject(newObject); setNextObjectId((prev) => prev - 1); setHasUnsavedChanges(true); setDraggedTool(null); setDraggedAreaData(null); setDraggedLocationData(null); // Location 배치 시 자재 개수 로드 (BED/TMP/DES만 대상, STP는 자재 미적재) if ( (draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") && locaKey ) { // 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행) setTimeout(() => { loadMaterialCountsForLocations([locaKey!]); }, 100); } }; // Location의 자재 목록 로드 const loadMaterialsForLocation = async (locaKey: string) => { console.log("🔍 자재 조회 시작:", { locaKey, selectedDbConnection, material: hierarchyConfig?.material }); if (!selectedDbConnection || !hierarchyConfig?.material) { console.error("❌ 설정 누락:", { selectedDbConnection, material: hierarchyConfig?.material }); toast({ variant: "destructive", title: "자재 조회 실패", description: "자재 테이블 설정이 필요합니다.", }); return; } try { setLoadingMaterials(true); setShowMaterialPanel(true); const materialConfig = { ...hierarchyConfig.material, locaKey: locaKey, }; console.log("📡 API 호출:", { externalDbConnectionId: selectedDbConnection, materialConfig }); const response = await getMaterials(selectedDbConnection, materialConfig); console.log("📦 API 응답:", response); if (response.success && response.data) { // layerColumn이 있으면 정렬 const sortedMaterials = hierarchyConfig.material.layerColumn ? response.data.sort((a: any, b: any) => { const aLayer = a[hierarchyConfig.material!.layerColumn!] || 0; const bLayer = b[hierarchyConfig.material!.layerColumn!] || 0; return aLayer - bLayer; }) : response.data; 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의 자재 목록 로드 (STP는 자재 미적재이므로 제외) else if ( obj && (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") && obj.locaKey && selectedDbConnection ) { loadMaterialsForLocation(obj.locaKey); } else { setShowMaterialPanel(false); } }; // Location별 자재 개수 로드 (locaKeys를 직접 받음) const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => { const connectionId = dbConnectionId || selectedDbConnection; const tableName = materialTableName || selectedTables.material; if (!connectionId || locaKeys.length === 0) return; try { const response = await getMaterialCounts(connectionId, tableName, locaKeys); console.log("📊 자재 개수 API 응답:", response); if (response.success && response.data) { // 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외) setPlacedObjects((prev) => prev.map((obj) => { if ( !obj.locaKey || obj.type === "location-stp" // STP는 자재 없음 ) { return obj; } // 백엔드 응답 필드명: location_key, count (대소문자 모두 체크) const materialCount = response.data?.find( (mc: any) => mc.LOCAKEY === obj.locaKey || mc.location_key === obj.locaKey || mc.locakey === obj.locaKey ); if (materialCount) { // count 또는 material_count 필드 사용 const count = materialCount.count || materialCount.material_count || 0; const maxLayer = materialCount.max_layer || count; console.log(`📊 ${obj.locaKey}: 자재 ${count}개`); return { ...obj, materialCount: Number(count), materialPreview: { height: maxLayer * 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 handleApplyTemplate = (templateId: string) => { if (!templateId) return; const template = mappingTemplates.find((t) => t.id === templateId); if (!template) { toast({ variant: "destructive", title: "템플릿 적용 실패", description: "선택한 템플릿을 찾을 수 없습니다.", }); return; } const config = template.config as HierarchyConfig; setHierarchyConfig(config); // 선택된 테이블 정보 동기화 const newSelectedTables: any = { warehouse: config.warehouse?.tableName || "", area: "", location: "", material: "", }; if (config.levels && config.levels.length > 0) { // 레벨 1 = Area if (config.levels[0]?.tableName) { newSelectedTables.area = config.levels[0].tableName; } // 레벨 2 = Location if (config.levels[1]?.tableName) { newSelectedTables.location = config.levels[1].tableName; } } if (config.material?.tableName) { newSelectedTables.material = config.material.tableName; } setSelectedTables(newSelectedTables); setSelectedWarehouse(config.warehouseKey || null); setHasUnsavedChanges(true); toast({ title: "템플릿 적용 완료", description: `"${template.name}" 템플릿이 적용되었습니다.`, }); }; // 매핑 템플릿 저장 const handleSaveTemplate = async () => { if (!selectedDbConnection || !hierarchyConfig) { toast({ variant: "destructive", title: "템플릿 저장 불가", description: "외부 DB와 계층 설정을 먼저 완료해주세요.", }); return; } if (!newTemplateName.trim()) { toast({ variant: "destructive", title: "템플릿 이름 필요", description: "템플릿 이름을 입력해주세요.", }); return; } try { const response = await createMappingTemplate({ name: newTemplateName.trim(), description: newTemplateDescription.trim() || undefined, externalDbConnectionId: selectedDbConnection, layoutType: "yard-3d", config: hierarchyConfig, }); if (response.success && response.data) { setMappingTemplates((prev) => [response.data!, ...prev]); setIsSaveTemplateDialogOpen(false); setNewTemplateName(""); setNewTemplateDescription(""); toast({ title: "템플릿 저장 완료", description: `"${response.data.name}" 템플릿이 저장되었습니다.`, }); } else { toast({ variant: "destructive", title: "템플릿 저장 실패", description: response.error || "템플릿을 저장하지 못했습니다.", }); } } catch (error) { console.error("매핑 템플릿 저장 실패:", error); toast({ variant: "destructive", title: "템플릿 저장 실패", description: "템플릿을 저장하는 중 오류가 발생했습니다.", }); } }; // 객체 이동 const handleObjectMove = (objectId: number, newX: number, newZ: number, newY?: number) => { setPlacedObjects((prev) => { const targetObj = prev.find((obj) => obj.id === objectId); if (!targetObj) return prev; const oldPosition = targetObj.position; const newPosition = { x: newX, y: newY !== undefined ? newY : oldPosition.y, z: newZ, }; // 1. 이동 대상 객체 업데이트 let updatedObjects = prev.map((obj) => { if (obj.id === objectId) { return { ...obj, position: newPosition }; } return obj; }); // 2. 하위 계층 객체 이동 시 논리적 키 검증 if (hierarchyConfig && targetObj.hierarchyLevel && targetObj.hierarchyLevel > 1) { const spatialObjects = updatedObjects.map((obj) => ({ id: obj.id, position: obj.position, size: obj.size, hierarchyLevel: obj.hierarchyLevel || 1, parentId: obj.parentId, })); const targetSpatialObj = spatialObjects.find((obj) => obj.id === objectId); if (targetSpatialObj) { const validation = validateSpatialContainment( targetSpatialObj, spatialObjects.filter((obj) => obj.id !== objectId), ); // 새로운 부모 영역 찾기 if (validation.parent) { const newParentObj = prev.find((obj) => obj.id === validation.parent!.id); // DB에서 가져온 데이터인 경우 논리적 키 검증 if (newParentObj && newParentObj.externalKey && targetObj.parentKey) { if (newParentObj.externalKey !== targetObj.parentKey) { toast({ variant: "destructive", title: "이동 불가", description: `이 Location은 '${targetObj.parentKey}' Area에만 배치할 수 있습니다. (현재 선택된 Area: ${newParentObj.externalKey})`, }); return prev; // 이동 취소 } } // 부모 ID 업데이트 updatedObjects = updatedObjects.map((obj) => { if (obj.id === objectId) { return { ...obj, parentId: validation.parent!.id }; } return obj; }); } else if (targetObj.parentKey) { // DB 데이터인데 부모 영역 밖으로 이동하려는 경우 toast({ variant: "destructive", title: "이동 불가", description: `이 Location은 '${targetObj.parentKey}' Area 내부에 있어야 합니다.`, }); return prev; // 이동 취소 } } } // 3. 그룹 이동: 자식 객체들도 함께 이동 const spatialObjects = updatedObjects.map((obj) => ({ id: obj.id, position: obj.position, size: obj.size, hierarchyLevel: obj.hierarchyLevel || 1, parentId: obj.parentId, })); const descendants = getAllDescendants(objectId, spatialObjects); if (descendants.length > 0) { const delta = { x: newPosition.x - oldPosition.x, y: newPosition.y - oldPosition.y, z: newPosition.z - oldPosition.z, }; updatedObjects = updatedObjects.map((obj) => { if (descendants.some((d) => d.id === obj.id)) { return { ...obj, position: { x: obj.position.x + delta.x, y: obj.position.y + delta.y, z: obj.position.z + delta.z, }, }; } return obj; }); } return updatedObjects; }); 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 단위로 스냅 (STP 포함) 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, hierarchyConfig: hierarchyConfig, // 계층 구조 설정 externalDbConnectionId: selectedDbConnection, // 외부 DB 연결 ID warehouseKey: selectedWarehouse, // 선택된 창고 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.loc_type === "STP" ? undefined : obj.material_count, materialPreview: obj.loc_type === "STP" || !obj.material_preview_height ? undefined : { height: parseFloat(obj.material_preview_height) }, 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-blue-600" }, { type: "location-stp" as ToolType, label: "정차", icon: Move, color: "text-gray-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 && (
)}
{/* 계층 설정 패널 (신규) */} {selectedDbConnection && ( { // 새로운 객체로 생성하여 참조 변경 (useEffect 트리거를 위해) setHierarchyConfig({ ...config }); // 레벨 테이블 정보를 selectedTables와 동기화 const newSelectedTables: any = { ...selectedTables }; // 창고 테이블 정보 if (config.warehouse?.tableName) { newSelectedTables.warehouse = config.warehouse.tableName; } if (config.levels && config.levels.length > 0) { // 레벨 1 = Area if (config.levels[0]?.tableName) { newSelectedTables.area = config.levels[0].tableName; } // 레벨 2 = Location if (config.levels[1]?.tableName) { newSelectedTables.location = config.levels[1].tableName; } } // 자재 테이블 정보 if (config.material?.tableName) { newSelectedTables.material = config.material.tableName; } setSelectedTables(newSelectedTables); setHasUnsavedChanges(true); }} availableTables={availableTables} onLoadTables={async () => { // 이미 로드되어 있으므로 스킵 }} onLoadColumns={async (tableName: string) => { try { const response = await ExternalDbConnectionAPI.getTableColumns( selectedDbConnection, tableName, ); if (response.success && response.data) { // 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그) return response.data.map((col: any) => ({ column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col), data_type: col.data_type || col.DATA_TYPE, description: col.description || col.COLUMN_COMMENT || undefined, is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY, })); } return []; } catch (error) { console.error("컬럼 로드 실패:", error); return []; } }} /> )} {/* 창고 선택 (HierarchyConfigPanel 아래로 이동) */} {selectedDbConnection && hierarchyConfig?.warehouse?.tableName && hierarchyConfig?.warehouse?.keyColumn && (
{loadingWarehouses ? (
) : ( )}
)} {/* Area 목록 */} {selectedDbConnection && selectedWarehouse && (

사용 가능한 Area

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

Area가 없습니다

) : (
{availableAreas.map((area) => { // 이미 배치된 Area인지 확인 const isPlaced = placedObjects.some((obj) => obj.areaKey === area.AREAKEY); return (
{ if (isPlaced) return; // Area 정보를 임시 저장 setDraggedTool("area"); setDraggedAreaData(area); }} onDragEnd={() => { setDraggedAreaData(null); }} className={`rounded-lg border p-3 transition-all ${ isPlaced ? "bg-muted text-muted-foreground cursor-not-allowed opacity-60" : "bg-background hover:bg-accent cursor-grab active:cursor-grabbing" }`} >

{area.AREANAME}

{area.AREAKEY}

{isPlaced ? ( 배치됨 ) : ( )}
); })}
)}
)} {/* 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"; } // Location이 이미 배치되었는지 확인 const isLocationPlaced = placedObjects.some( (obj) => (obj.type === "location-bed" || obj.type === "location-stp" || obj.type === "location-temp" || obj.type === "location-dest") && obj.locaKey === location.LOCAKEY, ); return (
{ if (!isLocationPlaced) { setDraggedTool(locationType); setDraggedLocationData(location); } }} onDragEnd={() => { setDraggedLocationData(null); }} className={`rounded-lg border p-3 transition-all ${ isLocationPlaced ? "bg-muted text-muted-foreground cursor-not-allowed opacity-60" : "bg-background hover:bg-accent cursor-grab active:cursor-grabbing" }`} >

{location.LOCANAME || location.LOCAKEY}

{location.LOCAKEY} {location.LOCTYPE}
{isLocationPlaced ? ( ) : locationType === "location-stp" ? ( ) : ( )}
); })}
)}
{/* 배치된 객체 목록 (계층 구조) */}

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

{placedObjects.length === 0 ? (
상단 도구를 드래그하여 배치하세요
) : ( {/* Area별로 그룹핑 */} {(() => { // Area 객체들 const areaObjects = placedObjects.filter((obj) => obj.type === "area"); // Area가 없으면 기존 방식으로 표시 if (areaObjects.length === 0) { return (
{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)})

))}
); } // Area별로 Location들을 그룹핑 return areaObjects.map((areaObj) => { // 이 Area의 자식 Location들 찾기 const childLocations = placedObjects.filter( (obj) => obj.type !== "area" && obj.areaKey === areaObj.areaKey && (obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey), ); return (
{ e.stopPropagation(); handleObjectClick(areaObj.id); }} >
{areaObj.name}
({childLocations.length})
{childLocations.length === 0 ? (

Location이 없습니다

) : (
{childLocations.map((locationObj) => (
handleObjectClick(locationObj.id)} className={`cursor-pointer rounded-lg border p-2 transition-all ${ selectedObject?.id === locationObj.id ? "border-primary bg-primary/10" : "hover:border-primary/50" }`} >
{locationObj.name}

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

{locationObj.locaKey && (

Key: {locationObj.locaKey}

)}
))}
)} ); }); })()} )}
{/* 중앙: 3D 캔버스 */}
{isLoading ? (
) : ( <> handleObjectClick(placement?.id || null)} onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)} focusOnPlacementId={null} onCollisionDetected={() => {}} previewTool={draggedTool} previewPosition={previewPosition} onPreviewPositionUpdate={setPreviewPosition} /> {/* 드래그 중일 때 Canvas 위에 투명한 오버레이 (프리뷰 및 드롭 이벤트 캐치용) */} {draggedTool && (
{ 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; // 그리드에 스냅 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; } setPreviewPosition({ x: snappedX, z: snappedZ }); }} onDragLeave={() => { setPreviewPosition(null); }} onDrop={(e) => { e.preventDefault(); e.stopPropagation(); if (previewPosition) { handleCanvasDrop(previewPosition.x, previewPosition.z); setPreviewPosition(null); } setDraggedTool(null); setDraggedAreaData(null); setDraggedLocationData(null); }} /> )} )}
{/* 우측: 객체 속성 편집 or 자재 목록 */}
{showMaterialPanel && selectedObject ? ( /* 자재 목록 패널 */

자재 목록

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

{loadingMaterials ? (
) : materials.length === 0 ? (
자재가 없습니다
) : ( {materials.map((material, index) => { const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY"; const displayColumns = hierarchyConfig?.material?.displayColumns || []; const layerValue = material[layerColumn] || index + 1; const keyValue = material[keyColumn] || `자재 ${index + 1}`; return (
층 {layerValue} {keyValue}
{displayColumns.length === 0 ? (

표시할 컬럼이 설정되지 않았습니다

) : (
{displayColumns.map((item) => (
{item.label}: {material[item.column] || "-"}
))}
)}
); })}
)}
) : selectedObject ? (

객체 속성

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

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

)}
{/* 매핑 템플릿 저장 다이얼로그 */} 매핑 템플릿 저장 현재 창고/계층/자재 설정을 템플릿으로 저장하여 다른 레이아웃에서 재사용할 수 있습니다.
setNewTemplateName(e.target.value)} placeholder="예: 동연 야드 표준 매핑" className="h-8 text-xs sm:h-10 sm:text-sm" />
setNewTemplateDescription(e.target.value)} placeholder="이 템플릿에 대한 설명을 입력하세요" className="h-8 text-xs sm:h-10 sm:text-sm" />
); }