1992 lines
77 KiB
TypeScript
1992 lines
77 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, Check } 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,
|
|
type HierarchyData,
|
|
} from "@/lib/api/digitalTwin";
|
|
import type { MaterialData } from "@/types/digitalTwin";
|
|
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
|
import HierarchyConfigPanel, { HierarchyConfig } from "./HierarchyConfigPanel";
|
|
import { validateSpatialContainment, updateChildrenPositions, getAllDescendants } from "./spatialContainment";
|
|
|
|
// 백엔드 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 [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<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 [hierarchyConfig, setHierarchyConfig] = useState<HierarchyConfig | null>(null);
|
|
const [availableTables, setAvailableTables] = useState<string[]>([]);
|
|
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 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);
|
|
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]);
|
|
|
|
// 동적 계층 구조 데이터 로드
|
|
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.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,
|
|
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 객체들의 자재 개수 로드
|
|
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(() => {
|
|
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<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 = 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 배치 시 자재 개수에 따라 높이 자동 설정
|
|
if (
|
|
(draggedTool === "location-bed" ||
|
|
draggedTool === "location-stp" ||
|
|
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: defaults.color || "#9ca3af",
|
|
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 배치 시 자재 개수 로드
|
|
if (
|
|
(draggedTool === "location-bed" ||
|
|
draggedTool === "location-stp" ||
|
|
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의 자재 목록 로드
|
|
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) => {
|
|
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<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,
|
|
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.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 space-y-6 overflow-y-auto p-4">
|
|
{/* 외부 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>
|
|
|
|
{/* 이 레이아웃의 창고 선택 */}
|
|
{hierarchyConfig?.warehouse?.tableName && hierarchyConfig?.warehouse?.keyColumn && (
|
|
<div>
|
|
<Label className="text-muted-foreground mb-1 block text-xs">이 레이아웃의 창고</Label>
|
|
{loadingWarehouses ? (
|
|
<div className="flex h-9 items-center justify-center rounded-md border">
|
|
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
|
</div>
|
|
) : (
|
|
<Select
|
|
value={selectedWarehouse || ""}
|
|
onValueChange={(value) => {
|
|
setSelectedWarehouse(value);
|
|
// hierarchyConfig 업데이트 (없으면 새로 생성)
|
|
setHierarchyConfig((prev) => ({
|
|
warehouseKey: value,
|
|
levels: prev?.levels || [],
|
|
material: prev?.material,
|
|
}));
|
|
setHasUnsavedChanges(true);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-9 text-xs">
|
|
<SelectValue placeholder="창고 선택..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{warehouses.map((wh: any) => {
|
|
const keyCol = hierarchyConfig.warehouse!.keyColumn;
|
|
const nameCol = hierarchyConfig.warehouse!.nameColumn;
|
|
return (
|
|
<SelectItem key={wh[keyCol]} value={wh[keyCol]} className="text-xs">
|
|
{wh[nameCol] || wh[keyCol]}
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 계층 설정 패널 (신규) */}
|
|
{selectedDbConnection && (
|
|
<HierarchyConfigPanel
|
|
externalDbConnectionId={selectedDbConnection}
|
|
hierarchyConfig={hierarchyConfig}
|
|
onHierarchyConfigChange={(config) => {
|
|
// 새로운 객체로 생성하여 참조 변경 (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) {
|
|
// 객체 배열을 문자열 배열로 변환
|
|
return response.data.map((col: any) =>
|
|
typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col),
|
|
);
|
|
}
|
|
return [];
|
|
} catch (error) {
|
|
console.error("컬럼 로드 실패:", error);
|
|
return [];
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 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) => {
|
|
// 이미 배치된 Area인지 확인
|
|
const isPlaced = placedObjects.some((obj) => obj.areaKey === area.AREAKEY);
|
|
|
|
return (
|
|
<div
|
|
key={area.AREAKEY}
|
|
draggable={!isPlaced}
|
|
onDragStart={() => {
|
|
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"
|
|
}`}
|
|
>
|
|
<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>
|
|
{isPlaced ? (
|
|
<span className="text-muted-foreground text-xs">배치됨</span>
|
|
) : (
|
|
<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";
|
|
}
|
|
|
|
// 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 (
|
|
<div
|
|
key={location.LOCAKEY}
|
|
draggable={!isLocationPlaced}
|
|
onDragStart={() => {
|
|
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"
|
|
}`}
|
|
>
|
|
<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>
|
|
{isLocationPlaced ? (
|
|
<Check className="h-4 w-4 text-green-500" />
|
|
) : (
|
|
<Package className="text-muted-foreground h-4 w-4" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 배치된 객체 목록 (계층 구조) */}
|
|
<div className="flex-1 overflow-y-auto border-t 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>
|
|
) : (
|
|
<Accordion type="multiple" className="w-full">
|
|
{/* Area별로 그룹핑 */}
|
|
{(() => {
|
|
// Area 객체들
|
|
const areaObjects = placedObjects.filter((obj) => obj.type === "area");
|
|
|
|
// Area가 없으면 기존 방식으로 표시
|
|
if (areaObjects.length === 0) {
|
|
return (
|
|
<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>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<AccordionItem key={areaObj.id} value={`area-${areaObj.id}`} className="border-b">
|
|
<AccordionTrigger className="px-2 py-3 hover:no-underline">
|
|
<div
|
|
className={`flex w-full items-center justify-between pr-2 ${
|
|
selectedObject?.id === areaObj.id ? "text-primary font-semibold" : ""
|
|
}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleObjectClick(areaObj.id);
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Grid3x3 className="h-4 w-4" />
|
|
<span className="text-sm font-medium">{areaObj.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-xs">({childLocations.length})</span>
|
|
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: areaObj.color }} />
|
|
</div>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-2 pb-3">
|
|
{childLocations.length === 0 ? (
|
|
<p className="text-muted-foreground py-2 text-center text-xs">
|
|
Location이 없습니다
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{childLocations.map((locationObj) => (
|
|
<div
|
|
key={locationObj.id}
|
|
onClick={() => 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"
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Package className="h-3 w-3" />
|
|
<span className="text-xs font-medium">{locationObj.name}</span>
|
|
</div>
|
|
<div
|
|
className="h-2.5 w-2.5 rounded-full"
|
|
style={{ backgroundColor: locationObj.color }}
|
|
/>
|
|
</div>
|
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
|
위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})
|
|
</p>
|
|
{locationObj.locaKey && (
|
|
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
|
Key: {locationObj.locaKey}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
);
|
|
});
|
|
})()}
|
|
</Accordion>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 중앙: 3D 캔버스 */}
|
|
<div className="relative h-full flex-1 bg-gray-100">
|
|
{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={() => {}}
|
|
previewTool={draggedTool}
|
|
previewPosition={previewPosition}
|
|
onPreviewPositionUpdate={setPreviewPosition}
|
|
/>
|
|
{/* 드래그 중일 때 Canvas 위에 투명한 오버레이 (프리뷰 및 드롭 이벤트 캐치용) */}
|
|
{draggedTool && (
|
|
<div
|
|
className="pointer-events-auto absolute inset-0"
|
|
style={{ zIndex: 10 }}
|
|
onDragOver={(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;
|
|
|
|
// 그리드에 스냅
|
|
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);
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</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>
|
|
) : (
|
|
<Accordion type="single" collapsible className="w-full">
|
|
{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 (
|
|
<AccordionItem key={`${keyValue}-${index}`} value={`item-${index}`} className="border-b">
|
|
<AccordionTrigger className="px-2 py-3 hover:no-underline">
|
|
<div className="flex w-full items-center justify-between pr-2">
|
|
<span className="text-sm font-medium">층 {layerValue}</span>
|
|
<span className="text-muted-foreground max-w-[150px] truncate text-xs">{keyValue}</span>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="px-2 pb-3">
|
|
{displayColumns.length === 0 ? (
|
|
<p className="text-muted-foreground py-2 text-center text-xs">
|
|
표시할 컬럼이 설정되지 않았습니다
|
|
</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{displayColumns.map((item) => (
|
|
<div key={item.column} className="flex justify-between gap-2 text-xs">
|
|
<span className="text-muted-foreground shrink-0">{item.label}:</span>
|
|
<span className="text-right font-medium break-all">
|
|
{material[item.column] || "-"}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
);
|
|
})}
|
|
</Accordion>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|