Compare commits
7 Commits
a0180d66a2
...
943d00bbbd
| Author | SHA1 | Date |
|---|---|---|
|
|
943d00bbbd | |
|
|
f10ceb5f7c | |
|
|
119afcaf42 | |
|
|
216e1366ef | |
|
|
711f2670de | |
|
|
b80d6cb85e | |
|
|
90b7c2b0f0 |
|
|
@ -73,6 +73,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
const [draggedTool, setDraggedTool] = useState<ToolType | null>(null);
|
const [draggedTool, setDraggedTool] = useState<ToolType | null>(null);
|
||||||
const [draggedAreaData, setDraggedAreaData] = useState<Area | null>(null); // 드래그 중인 Area 정보
|
const [draggedAreaData, setDraggedAreaData] = useState<Area | null>(null); // 드래그 중인 Area 정보
|
||||||
const [draggedLocationData, setDraggedLocationData] = useState<Location | null>(null); // 드래그 중인 Location 정보
|
const [draggedLocationData, setDraggedLocationData] = useState<Location | null>(null); // 드래그 중인 Location 정보
|
||||||
|
const [previewPosition, setPreviewPosition] = useState<{ x: number; z: number } | null>(null); // 드래그 프리뷰 위치
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
|
@ -656,13 +657,13 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
};
|
};
|
||||||
|
|
||||||
// 캔버스에 드롭
|
// 캔버스에 드롭
|
||||||
const handleCanvasDrop = (x: number, z: number) => {
|
const handleCanvasDrop = async (x: number, z: number) => {
|
||||||
if (!draggedTool) return;
|
if (!draggedTool) return;
|
||||||
|
|
||||||
const defaults = getToolDefaults(draggedTool);
|
const defaults = getToolDefaults(draggedTool);
|
||||||
|
|
||||||
// Area는 바닥(y=0.05)에, 다른 객체는 중앙 정렬
|
// Area는 바닥(y=0.05)에, 다른 객체는 중앙 정렬
|
||||||
const yPosition = draggedTool === "area" ? 0.05 : (defaults.size?.y || 1) / 2;
|
let yPosition = draggedTool === "area" ? 0.05 : (defaults.size?.y || 1) / 2;
|
||||||
|
|
||||||
// 외부 DB 데이터에서 드래그한 경우 해당 정보 사용
|
// 외부 DB 데이터에서 드래그한 경우 해당 정보 사용
|
||||||
let objectName = defaults.name || "새 객체";
|
let objectName = defaults.name || "새 객체";
|
||||||
|
|
@ -696,12 +697,51 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
externalKey = draggedLocationData.LOCAKEY;
|
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 = {
|
const newObject: PlacedObject = {
|
||||||
id: nextObjectId,
|
id: nextObjectId,
|
||||||
type: draggedTool,
|
type: draggedTool,
|
||||||
name: objectName,
|
name: objectName,
|
||||||
position: { x, y: yPosition, z },
|
position: { x, y: yPosition, z },
|
||||||
size: defaults.size || { x: 5, y: 5, z: 5 },
|
size: objectSize,
|
||||||
color: defaults.color || "#9ca3af",
|
color: defaults.color || "#9ca3af",
|
||||||
areaKey,
|
areaKey,
|
||||||
locaKey,
|
locaKey,
|
||||||
|
|
@ -739,9 +779,32 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 부모 ID 설정
|
// 부모 ID 설정 및 논리적 유효성 검사
|
||||||
if (validation.parent) {
|
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;
|
newObject.parentId = validation.parent.id;
|
||||||
|
} else if (newObject.parentKey) {
|
||||||
|
// DB 데이터인데 부모 영역 위에 놓이지 않은 경우
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "배치 오류",
|
||||||
|
description: `이 Location은 '${newObject.parentKey}' Area 내부에 배치해야 합니다.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -770,7 +833,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
|
|
||||||
// Location의 자재 목록 로드
|
// Location의 자재 목록 로드
|
||||||
const loadMaterialsForLocation = async (locaKey: string) => {
|
const loadMaterialsForLocation = async (locaKey: string) => {
|
||||||
|
console.log("🔍 자재 조회 시작:", { locaKey, selectedDbConnection, material: hierarchyConfig?.material });
|
||||||
|
|
||||||
if (!selectedDbConnection || !hierarchyConfig?.material) {
|
if (!selectedDbConnection || !hierarchyConfig?.material) {
|
||||||
|
console.error("❌ 설정 누락:", { selectedDbConnection, material: hierarchyConfig?.material });
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "자재 조회 실패",
|
title: "자재 조회 실패",
|
||||||
|
|
@ -782,10 +848,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
try {
|
try {
|
||||||
setLoadingMaterials(true);
|
setLoadingMaterials(true);
|
||||||
setShowMaterialPanel(true);
|
setShowMaterialPanel(true);
|
||||||
const response = await getMaterials(selectedDbConnection, {
|
|
||||||
|
const materialConfig = {
|
||||||
...hierarchyConfig.material,
|
...hierarchyConfig.material,
|
||||||
locaKey: locaKey,
|
locaKey: locaKey,
|
||||||
});
|
};
|
||||||
|
console.log("📡 API 호출:", { externalDbConnectionId: selectedDbConnection, materialConfig });
|
||||||
|
|
||||||
|
const response = await getMaterials(selectedDbConnection, materialConfig);
|
||||||
|
console.log("📦 API 응답:", response);
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
// layerColumn이 있으면 정렬
|
// layerColumn이 있으면 정렬
|
||||||
const sortedMaterials = hierarchyConfig.material.layerColumn
|
const sortedMaterials = hierarchyConfig.material.layerColumn
|
||||||
|
|
@ -925,7 +996,59 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
return obj;
|
return obj;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 그룹 이동: 자식 객체들도 함께 이동
|
// 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) => ({
|
const spatialObjects = updatedObjects.map((obj) => ({
|
||||||
id: obj.id,
|
id: obj.id,
|
||||||
position: obj.position,
|
position: obj.position,
|
||||||
|
|
@ -1452,79 +1575,191 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 배치된 객체 목록 */}
|
{/* 배치된 객체 목록 (계층 구조) */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto border-t p-4">
|
||||||
<h3 className="mb-3 text-sm font-semibold">배치된 객체 ({placedObjects.length})</h3>
|
<h3 className="mb-3 text-sm font-semibold">배치된 객체 ({placedObjects.length})</h3>
|
||||||
|
|
||||||
{placedObjects.length === 0 ? (
|
{placedObjects.length === 0 ? (
|
||||||
<div className="text-muted-foreground text-center text-sm">상단 도구를 드래그하여 배치하세요</div>
|
<div className="text-muted-foreground text-center text-sm">상단 도구를 드래그하여 배치하세요</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<Accordion type="multiple" className="w-full">
|
||||||
{placedObjects.map((obj) => (
|
{/* Area별로 그룹핑 */}
|
||||||
<div
|
{(() => {
|
||||||
key={obj.id}
|
// Area 객체들
|
||||||
onClick={() => handleObjectClick(obj.id)}
|
const areaObjects = placedObjects.filter((obj) => obj.type === "area");
|
||||||
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
|
||||||
selectedObject?.id === obj.id ? "border-primary bg-primary/10" : "hover:border-primary/50"
|
// Area가 없으면 기존 방식으로 표시
|
||||||
}`}
|
if (areaObjects.length === 0) {
|
||||||
>
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-2">
|
||||||
<span className="text-sm font-medium">{obj.name}</span>
|
{placedObjects.map((obj) => (
|
||||||
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: obj.color }} />
|
<div
|
||||||
</div>
|
key={obj.id}
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
onClick={() => handleObjectClick(obj.id)}
|
||||||
위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)})
|
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
||||||
</p>
|
selectedObject?.id === obj.id
|
||||||
{obj.areaKey && <p className="text-muted-foreground mt-1 text-xs">Area: {obj.areaKey}</p>}
|
? "border-primary bg-primary/10"
|
||||||
</div>
|
: "hover:border-primary/50"
|
||||||
))}
|
}`}
|
||||||
</div>
|
>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 중앙: 3D 캔버스 */}
|
{/* 중앙: 3D 캔버스 */}
|
||||||
<div
|
<div className="relative h-full flex-1 bg-gray-100">
|
||||||
className="h-full flex-1 bg-gray-100"
|
{isLoading ? (
|
||||||
onDragOver={(e) => e.preventDefault()}
|
<div className="flex h-full items-center justify-center">
|
||||||
onDrop={(e) => {
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
e.preventDefault();
|
</div>
|
||||||
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;
|
<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 단위)
|
// 그리드 크기 (5 단위)
|
||||||
const gridSize = 5;
|
const gridSize = 5;
|
||||||
|
|
||||||
// 그리드에 스냅
|
// 그리드에 스냅
|
||||||
// Area(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에
|
let snappedX = Math.round(rawX / gridSize) * gridSize;
|
||||||
let snappedX = Math.round(rawX / gridSize) * gridSize;
|
let snappedZ = Math.round(rawZ / gridSize) * gridSize;
|
||||||
let snappedZ = Math.round(rawZ / gridSize) * gridSize;
|
|
||||||
|
|
||||||
// 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외)
|
// 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외)
|
||||||
if (draggedTool !== "area") {
|
if (draggedTool !== "area") {
|
||||||
snappedX += gridSize / 2;
|
snappedX += gridSize / 2;
|
||||||
snappedZ += gridSize / 2;
|
snappedZ += gridSize / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCanvasDrop(snappedX, snappedZ);
|
setPreviewPosition({ x: snappedX, z: snappedZ });
|
||||||
}}
|
}}
|
||||||
>
|
onDragLeave={() => {
|
||||||
{isLoading ? (
|
setPreviewPosition(null);
|
||||||
<div className="flex h-full items-center justify-center">
|
}}
|
||||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
onDrop={(e) => {
|
||||||
</div>
|
e.preventDefault();
|
||||||
) : (
|
e.stopPropagation();
|
||||||
<Yard3DCanvas
|
if (previewPosition) {
|
||||||
placements={placements}
|
handleCanvasDrop(previewPosition.x, previewPosition.z);
|
||||||
selectedPlacementId={selectedObject?.id || null}
|
setPreviewPosition(null);
|
||||||
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
}
|
||||||
onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)}
|
setDraggedTool(null);
|
||||||
focusOnPlacementId={null}
|
setDraggedAreaData(null);
|
||||||
onCollisionDetected={() => {}}
|
setDraggedLocationData(null);
|
||||||
/>
|
}}
|
||||||
)}
|
/>
|
||||||
</div>
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 우측: 객체 속성 편집 or 자재 목록 */}
|
{/* 우측: 객체 속성 편집 or 자재 목록 */}
|
||||||
<div className="h-full w-[25%] overflow-y-auto border-l">
|
<div className="h-full w-[25%] overflow-y-auto border-l">
|
||||||
|
|
|
||||||
|
|
@ -67,12 +67,51 @@ export default function HierarchyConfigPanel({
|
||||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||||
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({});
|
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({});
|
||||||
|
|
||||||
// 외부에서 변경된 경우 동기화
|
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hierarchyConfig) {
|
if (hierarchyConfig) {
|
||||||
setLocalConfig(hierarchyConfig);
|
setLocalConfig(hierarchyConfig);
|
||||||
|
|
||||||
|
// 저장된 설정의 테이블들에 대한 컬럼 자동 로드
|
||||||
|
const loadSavedColumns = async () => {
|
||||||
|
const tablesToLoad: string[] = [];
|
||||||
|
|
||||||
|
// 창고 테이블
|
||||||
|
if (hierarchyConfig.warehouse?.tableName) {
|
||||||
|
tablesToLoad.push(hierarchyConfig.warehouse.tableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 계층 레벨 테이블들
|
||||||
|
hierarchyConfig.levels?.forEach((level) => {
|
||||||
|
if (level.tableName) {
|
||||||
|
tablesToLoad.push(level.tableName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 자재 테이블
|
||||||
|
if (hierarchyConfig.material?.tableName) {
|
||||||
|
tablesToLoad.push(hierarchyConfig.material.tableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 제거 후 로드
|
||||||
|
const uniqueTables = [...new Set(tablesToLoad)];
|
||||||
|
for (const tableName of uniqueTables) {
|
||||||
|
if (!columnsCache[tableName]) {
|
||||||
|
try {
|
||||||
|
const columns = await onLoadColumns(tableName);
|
||||||
|
setColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (externalDbConnectionId) {
|
||||||
|
loadSavedColumns();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [hierarchyConfig]);
|
}, [hierarchyConfig, externalDbConnectionId]);
|
||||||
|
|
||||||
// 테이블 선택 시 컬럼 로드
|
// 테이블 선택 시 컬럼 로드
|
||||||
const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => {
|
const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ interface Yard3DCanvasProps {
|
||||||
gridSize?: number; // 그리드 크기 (기본값: 5)
|
gridSize?: number; // 그리드 크기 (기본값: 5)
|
||||||
onCollisionDetected?: () => void; // 충돌 감지 시 콜백
|
onCollisionDetected?: () => void; // 충돌 감지 시 콜백
|
||||||
focusOnPlacementId?: number | null; // 카메라가 포커스할 요소 ID
|
focusOnPlacementId?: number | null; // 카메라가 포커스할 요소 ID
|
||||||
|
previewTool?: string | null; // 드래그 중인 도구 타입
|
||||||
|
previewPosition?: { x: number; z: number } | null; // 프리뷰 위치
|
||||||
|
onPreviewPositionUpdate?: (position: { x: number; z: number } | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
|
// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
|
||||||
|
|
@ -1007,10 +1010,26 @@ function Scene({
|
||||||
gridSize = 5,
|
gridSize = 5,
|
||||||
onCollisionDetected,
|
onCollisionDetected,
|
||||||
focusOnPlacementId,
|
focusOnPlacementId,
|
||||||
|
previewTool,
|
||||||
|
previewPosition,
|
||||||
}: Yard3DCanvasProps) {
|
}: Yard3DCanvasProps) {
|
||||||
const [isDraggingAny, setIsDraggingAny] = useState(false);
|
const [isDraggingAny, setIsDraggingAny] = useState(false);
|
||||||
const orbitControlsRef = useRef<any>(null);
|
const orbitControlsRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// 프리뷰 박스 크기 계산
|
||||||
|
const getPreviewSize = (tool: string) => {
|
||||||
|
if (tool === "area") return { x: 20, y: 0.1, z: 20 };
|
||||||
|
return { x: 5, y: 5, z: 5 };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 프리뷰 박스 색상
|
||||||
|
const getPreviewColor = (tool: string) => {
|
||||||
|
if (tool === "area") return "#3b82f6";
|
||||||
|
if (tool === "location-bed") return "#10b981";
|
||||||
|
if (tool === "location-stp") return "#f59e0b";
|
||||||
|
return "#9ca3af";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 카메라 포커스 컨트롤러 */}
|
{/* 카메라 포커스 컨트롤러 */}
|
||||||
|
|
@ -1069,6 +1088,30 @@ function Scene({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 드래그 프리뷰 박스 */}
|
||||||
|
{previewTool && previewPosition && (
|
||||||
|
<Box
|
||||||
|
args={[
|
||||||
|
getPreviewSize(previewTool).x,
|
||||||
|
getPreviewSize(previewTool).y,
|
||||||
|
getPreviewSize(previewTool).z,
|
||||||
|
]}
|
||||||
|
position={[
|
||||||
|
previewPosition.x,
|
||||||
|
previewTool === "area" ? 0.05 : getPreviewSize(previewTool).y / 2,
|
||||||
|
previewPosition.z,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={getPreviewColor(previewTool)}
|
||||||
|
transparent
|
||||||
|
opacity={0.4}
|
||||||
|
roughness={0.5}
|
||||||
|
metalness={0.1}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 카메라 컨트롤 */}
|
{/* 카메라 컨트롤 */}
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
ref={orbitControlsRef}
|
ref={orbitControlsRef}
|
||||||
|
|
@ -1095,6 +1138,9 @@ export default function Yard3DCanvas({
|
||||||
gridSize = 5,
|
gridSize = 5,
|
||||||
onCollisionDetected,
|
onCollisionDetected,
|
||||||
focusOnPlacementId,
|
focusOnPlacementId,
|
||||||
|
previewTool,
|
||||||
|
previewPosition,
|
||||||
|
onPreviewPositionUpdate,
|
||||||
}: Yard3DCanvasProps) {
|
}: Yard3DCanvasProps) {
|
||||||
const handleCanvasClick = (e: any) => {
|
const handleCanvasClick = (e: any) => {
|
||||||
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
|
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
|
||||||
|
|
@ -1123,6 +1169,8 @@ export default function Yard3DCanvas({
|
||||||
gridSize={gridSize}
|
gridSize={gridSize}
|
||||||
onCollisionDetected={onCollisionDetected}
|
onCollisionDetected={onCollisionDetected}
|
||||||
focusOnPlacementId={focusOnPlacementId}
|
focusOnPlacementId={focusOnPlacementId}
|
||||||
|
previewTool={previewTool}
|
||||||
|
previewPosition={previewPosition}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue