Merge branch 'main' into feature/screen-management

This commit is contained in:
kjs 2025-11-25 13:05:20 +09:00
commit 943d00bbbd
3 changed files with 391 additions and 69 deletions

View File

@ -73,6 +73,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
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);
@ -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;
const defaults = getToolDefaults(draggedTool);
// 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 데이터에서 드래그한 경우 해당 정보 사용
let objectName = defaults.name || "새 객체";
@ -696,12 +697,51 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
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: defaults.size || { x: 5, y: 5, z: 5 },
size: objectSize,
color: defaults.color || "#9ca3af",
areaKey,
locaKey,
@ -739,9 +779,32 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
return;
}
// 부모 ID 설정
// 부모 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;
}
}
@ -770,7 +833,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
// 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: "자재 조회 실패",
@ -782,10 +848,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
try {
setLoadingMaterials(true);
setShowMaterialPanel(true);
const response = await getMaterials(selectedDbConnection, {
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
@ -925,7 +996,59 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
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) => ({
id: obj.id,
position: obj.position,
@ -1452,79 +1575,191 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
)}
</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>
{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>
<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="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;
{/* 중앙: 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;
// 그리드 크기 (5 단위)
const gridSize = 5;
// 그리드에 스냅
// Area(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에
let snappedX = Math.round(rawX / gridSize) * gridSize;
let snappedZ = Math.round(rawZ / gridSize) * gridSize;
// 그리드에 스냅
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;
}
// 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>
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">

View File

@ -67,12 +67,51 @@ export default function HierarchyConfigPanel({
const [loadingColumns, setLoadingColumns] = useState(false);
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({});
// 외부에서 변경된 경우 동기화
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
useEffect(() => {
if (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) => {

View File

@ -35,6 +35,9 @@ interface Yard3DCanvasProps {
gridSize?: number; // 그리드 크기 (기본값: 5)
onCollisionDetected?: () => void; // 충돌 감지 시 콜백
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,
onCollisionDetected,
focusOnPlacementId,
previewTool,
previewPosition,
}: Yard3DCanvasProps) {
const [isDraggingAny, setIsDraggingAny] = useState(false);
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 (
<>
{/* 카메라 포커스 컨트롤러 */}
@ -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
ref={orbitControlsRef}
@ -1095,6 +1138,9 @@ export default function Yard3DCanvas({
gridSize = 5,
onCollisionDetected,
focusOnPlacementId,
previewTool,
previewPosition,
onPreviewPositionUpdate,
}: Yard3DCanvasProps) {
const handleCanvasClick = (e: any) => {
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
@ -1123,6 +1169,8 @@ export default function Yard3DCanvas({
gridSize={gridSize}
onCollisionDetected={onCollisionDetected}
focusOnPlacementId={focusOnPlacementId}
previewTool={previewTool}
previewPosition={previewPosition}
/>
</Suspense>
</Canvas>