ERP-node/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx

1539 lines
59 KiB
TypeScript

"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: () => (
<div className="bg-muted flex h-full items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
),
});
interface DigitalTwinEditorProps {
layoutId: number;
layoutName: string;
onBack: () => void;
}
export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: DigitalTwinEditorProps) {
const { toast } = useToast();
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
const [selectedObject, setSelectedObject] = useState<PlacedObject | null>(null);
const [draggedTool, setDraggedTool] = useState<ToolType | null>(null);
const [draggedAreaData, setDraggedAreaData] = useState<Area | null>(null); // 드래그 중인 Area 정보
const [draggedLocationData, setDraggedLocationData] = useState<Location | null>(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<number | null>(null);
const [selectedWarehouse, setSelectedWarehouse] = useState<string | null>(null);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [availableAreas, setAvailableAreas] = useState<Area[]>([]);
const [availableLocations, setAvailableLocations] = useState<Location[]>([]);
const [nextObjectId, setNextObjectId] = useState(-1);
const [loadingWarehouses, setLoadingWarehouses] = useState(false);
const [loadingAreas, setLoadingAreas] = useState(false);
const [loadingLocations, setLoadingLocations] = useState(false);
const [materials, setMaterials] = useState<MaterialData[]>([]);
const [loadingMaterials, setLoadingMaterials] = useState(false);
const [showMaterialPanel, setShowMaterialPanel] = useState(false);
// 테이블 매핑 관련 상태
const [availableTables, setAvailableTables] = useState<string[]>([]);
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<PlacedObject> => {
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<PlacedObject>) => {
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 (
<div className="bg-background flex h-full flex-col">
{/* 상단 툴바 */}
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<div>
<h2 className="text-lg font-semibold">{layoutName}</h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
<div className="flex items-center gap-2">
{hasUnsavedChanges && <span className="text-warning text-sm font-medium"> </span>}
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasUnsavedChanges}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
{/* 도구 팔레트 */}
<div className="bg-muted flex items-center justify-center gap-2 border-b p-4">
<span className="text-muted-foreground text-sm font-medium">:</span>
{[
{ 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 (
<div
key={tool.type}
draggable
onDragStart={() => 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} 드래그하여 배치`}
>
<Icon className={`h-4 w-4 ${tool.color}`} />
<span className="text-xs">{tool.label}</span>
</div>
);
})}
</div>
{/* 메인 영역 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측: 외부 DB 선택 + 객체 목록 */}
<div className="flex h-full w-[20%] flex-col border-r">
{/* 스크롤 영역 */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* 외부 DB 선택 */}
<div>
<Label className="mb-2 block text-sm font-semibold"> </Label>
<Select
value={selectedDbConnection?.toString() || ""}
onValueChange={(value) => {
setSelectedDbConnection(parseInt(value));
setSelectedWarehouse(null);
setHasUnsavedChanges(true);
}}
>
<SelectTrigger className="h-10 text-sm">
<SelectValue placeholder="DB 선택..." />
</SelectTrigger>
<SelectContent>
{externalDbConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()} className="text-sm">
{conn.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 테이블 매핑 선택 */}
{selectedDbConnection && (
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
{loadingTables ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
</div>
) : (
<>
<div>
<Label className="text-muted-foreground mb-1 block text-xs"> </Label>
<Select
value={selectedTables.warehouse}
onValueChange={(value) => {
setSelectedTables({ ...selectedTables, warehouse: value });
loadColumnsForTable(value, "warehouse");
setHasUnsavedChanges(true);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="테이블 선택..." />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-xs">
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 창고 컬럼 매핑 */}
{selectedTables.warehouse && tableColumns.warehouse && (
<div className="ml-2 space-y-2 border-l-2 pl-2">
<div>
<Label className="text-muted-foreground mb-1 block text-[10px]">ID </Label>
<Select
value={selectedColumns.warehouseKey}
onValueChange={(value) => setSelectedColumns({ ...selectedColumns, warehouseKey: value })}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{tableColumns.warehouse.map((col) => (
<SelectItem key={col} value={col} className="text-xs">{col}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-muted-foreground mb-1 block text-[10px]"> </Label>
<Select
value={selectedColumns.warehouseName}
onValueChange={(value) => setSelectedColumns({ ...selectedColumns, warehouseName: value })}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{tableColumns.warehouse.map((col) => (
<SelectItem key={col} value={col} className="text-xs">{col}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<div>
<Label className="text-muted-foreground mb-1 block text-xs"> (: Area)</Label>
<Select
value={selectedTables.area}
onValueChange={(value) => {
setSelectedTables({ ...selectedTables, area: value });
loadColumnsForTable(value, "area");
setHasUnsavedChanges(true);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="테이블 선택..." />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-xs">
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-muted-foreground mb-1 block text-xs"> (: Location)</Label>
<Select
value={selectedTables.location}
onValueChange={(value) => {
setSelectedTables({ ...selectedTables, location: value });
loadColumnsForTable(value, "location");
setHasUnsavedChanges(true);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="테이블 선택..." />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-xs">
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-muted-foreground mb-1 block text-xs"> </Label>
<Select
value={selectedTables.material}
onValueChange={(value) => {
setSelectedTables({ ...selectedTables, material: value });
loadColumnsForTable(value, "material");
setHasUnsavedChanges(true);
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder="테이블 선택..." />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table} value={table} className="text-xs">
{table}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
</div>
)}
{/* 창고 선택 */}
{selectedDbConnection && selectedTables.warehouse && (
<div>
<Label className="mb-2 block text-sm font-semibold"></Label>
{loadingWarehouses ? (
<div className="flex h-10 items-center justify-center">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
</div>
) : (
<Select
value={selectedWarehouse || ""}
onValueChange={(value) => {
setSelectedWarehouse(value);
setHasUnsavedChanges(true);
}}
>
<SelectTrigger className="h-10 text-sm">
<SelectValue placeholder="창고 선택..." />
</SelectTrigger>
<SelectContent>
{warehouses.map((wh: any) => (
<SelectItem
key={wh[selectedColumns.warehouseKey] || wh.WAREKEY}
value={wh[selectedColumns.warehouseKey] || wh.WAREKEY}
className="text-sm"
>
{wh[selectedColumns.warehouseName] || wh.WARENAME || wh[selectedColumns.warehouseKey] || wh.WAREKEY}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{/* Area 목록 */}
{selectedDbConnection && selectedWarehouse && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> Area</h4>
{loadingAreas && <Loader2 className="text-muted-foreground h-3 w-3 animate-spin" />}
</div>
{availableAreas.length === 0 ? (
<p className="text-muted-foreground text-xs">Area가 </p>
) : (
<div className="space-y-2">
{availableAreas.map((area) => (
<div
key={area.AREAKEY}
draggable
onDragStart={() => {
// 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"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{area.AREANAME}</p>
<p className="text-muted-foreground text-xs">{area.AREAKEY}</p>
</div>
<Grid3x3 className="text-muted-foreground h-4 w-4" />
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Location 목록 (Area 클릭 시 표시) */}
{availableLocations.length > 0 && (
<div className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> Location</h4>
{loadingLocations && <Loader2 className="text-muted-foreground h-3 w-3 animate-spin" />}
</div>
<div className="space-y-2">
{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 (
<div
key={location.LOCAKEY}
draggable
onDragStart={() => {
// 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"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{location.LOCANAME || location.LOCAKEY}</p>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span>{location.LOCAKEY}</span>
<span className="bg-muted rounded px-1.5 py-0.5">{location.LOCTYPE}</span>
</div>
</div>
<Package className="text-muted-foreground h-4 w-4" />
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* 배치된 객체 목록 */}
<div className="flex-1 overflow-y-auto p-4">
<h3 className="mb-3 text-sm font-semibold"> ({placedObjects.length})</h3>
{placedObjects.length === 0 ? (
<div className="text-muted-foreground text-center text-sm"> </div>
) : (
<div className="space-y-2">
{placedObjects.map((obj) => (
<div
key={obj.id}
onClick={() => 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"
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{obj.name}</span>
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: obj.color }} />
</div>
<p className="text-muted-foreground mt-1 text-xs">
: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)})
</p>
{obj.areaKey && <p className="text-muted-foreground mt-1 text-xs">Area: {obj.areaKey}</p>}
</div>
))}
</div>
)}
</div>
</div>
{/* 중앙: 3D 캔버스 */}
<div
className="h-full flex-1 bg-gray-100"
onDragOver={(e) => 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 ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
) : (
<Yard3DCanvas
placements={placements}
selectedPlacementId={selectedObject?.id || null}
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)}
focusOnPlacementId={null}
onCollisionDetected={() => {}}
/>
)}
</div>
{/* 우측: 객체 속성 편집 or 자재 목록 */}
<div className="h-full w-[25%] overflow-y-auto border-l">
{showMaterialPanel && selectedObject ? (
/* 자재 목록 패널 */
<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} ({selectedObject.locaKey})
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => setShowMaterialPanel(false)}>
</Button>
</div>
{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-sm">
</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>
) : selectedObject ? (
<div className="p-4">
<h3 className="mb-4 text-lg font-semibold"> </h3>
<div className="space-y-4">
{/* 이름 */}
<div>
<Label htmlFor="object-name" className="text-sm">
</Label>
<Input
id="object-name"
value={selectedObject.name}
onChange={(e) => handleObjectUpdate({ name: e.target.value })}
className="mt-1.5 h-9 text-sm"
/>
</div>
{/* 위치 */}
<div>
<Label className="text-sm"></Label>
<div className="mt-1.5 grid grid-cols-2 gap-2">
<div>
<Label htmlFor="pos-x" className="text-muted-foreground text-xs">
X
</Label>
<Input
id="pos-x"
type="number"
value={selectedObject.position.x.toFixed(1)}
onChange={(e) =>
handleObjectUpdate({
position: {
...selectedObject.position,
x: parseFloat(e.target.value),
},
})
}
className="h-9 text-sm"
/>
</div>
<div>
<Label htmlFor="pos-z" className="text-muted-foreground text-xs">
Z
</Label>
<Input
id="pos-z"
type="number"
value={selectedObject.position.z.toFixed(1)}
onChange={(e) =>
handleObjectUpdate({
position: {
...selectedObject.position,
z: parseFloat(e.target.value),
},
})
}
className="h-9 text-sm"
/>
</div>
</div>
</div>
{/* 크기 */}
<div>
<Label className="text-sm"></Label>
<div className="mt-1.5 grid grid-cols-3 gap-2">
<div>
<Label htmlFor="size-x" className="text-muted-foreground text-xs">
W (5 )
</Label>
<Input
id="size-x"
type="number"
step="5"
min="5"
value={selectedObject.size.x}
onChange={(e) =>
handleObjectUpdate({
size: {
...selectedObject.size,
x: parseFloat(e.target.value),
},
})
}
className="h-9 text-sm"
/>
</div>
<div>
<Label htmlFor="size-y" className="text-muted-foreground text-xs">
H
</Label>
<Input
id="size-y"
type="number"
value={selectedObject.size.y}
onChange={(e) =>
handleObjectUpdate({
size: {
...selectedObject.size,
y: parseFloat(e.target.value),
},
})
}
className="h-9 text-sm"
/>
</div>
<div>
<Label htmlFor="size-z" className="text-muted-foreground text-xs">
D (5 )
</Label>
<Input
id="size-z"
type="number"
step="5"
min="5"
value={selectedObject.size.z}
onChange={(e) =>
handleObjectUpdate({
size: {
...selectedObject.size,
z: parseFloat(e.target.value),
},
})
}
className="h-9 text-sm"
/>
</div>
</div>
</div>
{/* 색상 */}
<div>
<Label htmlFor="object-color" className="text-sm">
</Label>
<Input
id="object-color"
type="color"
value={selectedObject.color}
onChange={(e) => handleObjectUpdate({ color: e.target.value })}
className="mt-1.5 h-9"
/>
</div>
{/* 삭제 버튼 */}
<Button variant="destructive" size="sm" onClick={handleObjectDelete} className="w-full">
</Button>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center p-4 text-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
)}
</div>
</div>
</div>
);
}