3D 야드 위젯 새로고침 버튼구현

This commit is contained in:
dohyeons 2025-12-15 09:46:26 +09:00
parent ac3de6ab07
commit 95cbd62b1a
2 changed files with 379 additions and 329 deletions

View File

@ -2,7 +2,19 @@
import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react";
import {
ArrowLeft,
Save,
Loader2,
Grid3x3,
Move,
Box,
Package,
Truck,
Check,
ParkingCircle,
RefreshCw,
} 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";
@ -78,7 +90,7 @@ const DebouncedInput = ({
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
setIsEditing(false);
if (onCommit && debounce === 0) {
// 값이 변경되었을 때만 커밋하도록 하면 좋겠지만,
// 값이 변경되었을 때만 커밋하도록 하면 좋겠지만,
// 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨)
onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
}
@ -545,150 +557,170 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
// 레이아웃 데이터 로드
const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
useEffect(() => {
const loadLayout = async () => {
try {
setIsLoading(true);
const response = await getLayoutById(layoutId);
// 레이아웃 로드 함수
const loadLayout = async () => {
try {
setIsLoading(true);
const response = await getLayoutById(layoutId);
if (response.success && response.data) {
const { layout, objects } = response.data;
setLayoutData({ layout, objects }); // 레이아웃 데이터 저장
if (response.success && response.data) {
const { layout, objects } = response.data;
setLayoutData({ layout, objects }); // 레이아웃 데이터 저장
// 외부 DB 연결 ID 복원
if (layout.external_db_connection_id) {
setSelectedDbConnection(layout.external_db_connection_id);
}
// 계층 구조 설정 로드
if (layout.hierarchy_config) {
try {
// hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용
const config =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
setHierarchyConfig(config);
// 선택된 테이블 정보도 복원
const newSelectedTables: any = {
warehouse: config.warehouse?.tableName || "",
area: "",
location: "",
material: "",
};
if (config.levels && config.levels.length > 0) {
// 레벨 1 = Area
if (config.levels[0]?.tableName) {
newSelectedTables.area = config.levels[0].tableName;
}
// 레벨 2 = Location
if (config.levels[1]?.tableName) {
newSelectedTables.location = config.levels[1].tableName;
}
}
// 자재 테이블 정보
if (config.material?.tableName) {
newSelectedTables.material = config.material.tableName;
}
setSelectedTables(newSelectedTables);
} catch (e) {
console.error("계층 구조 설정 파싱 실패:", e);
}
}
// 객체 데이터 변환 (DB -> PlacedObject)
const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({
id: obj.id,
type: obj.object_type,
name: obj.object_name,
position: {
x: parseFloat(obj.position_x),
y: parseFloat(obj.position_y),
z: parseFloat(obj.position_z),
},
size: {
x: parseFloat(obj.size_x),
y: parseFloat(obj.size_y),
z: parseFloat(obj.size_z),
},
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
color: obj.color,
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview:
obj.loc_type === "STP" || !obj.material_preview_height
? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
visible: obj.visible !== false,
hierarchyLevel: obj.hierarchy_level || 1,
parentKey: obj.parent_key,
externalKey: obj.external_key,
}));
setPlacedObjects(loadedObjects);
// 다음 임시 ID 설정 (기존 ID 중 최소값 - 1)
const minId = Math.min(...loadedObjects.map((o) => o.id), 0);
setNextObjectId(minId - 1);
setHasUnsavedChanges(false);
toast({
title: "레이아웃 불러오기 완료",
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
});
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
const dbConnectionId = layout.external_db_connection_id;
const hierarchyConfigParsed =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
const materialTableName = hierarchyConfigParsed?.material?.tableName;
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" ||
obj.type === "location-stp" ||
obj.type === "location-temp" ||
obj.type === "location-dest") &&
obj.locaKey,
);
if (locationObjects.length > 0 && dbConnectionId && materialTableName) {
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
setTimeout(() => {
loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName);
}, 100);
}
} else {
throw new Error(response.error || "레이아웃 조회 실패");
// 외부 DB 연결 ID 복원
if (layout.external_db_connection_id) {
setSelectedDbConnection(layout.external_db_connection_id);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
toast({
variant: "destructive",
title: "오류",
description: errorMessage,
});
} finally {
setIsLoading(false);
}
};
// 계층 구조 설정 로드
if (layout.hierarchy_config) {
try {
// hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용
const config =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
setHierarchyConfig(config);
// 선택된 테이블 정보도 복원
const newSelectedTables: any = {
warehouse: config.warehouse?.tableName || "",
area: "",
location: "",
material: "",
};
if (config.levels && config.levels.length > 0) {
// 레벨 1 = Area
if (config.levels[0]?.tableName) {
newSelectedTables.area = config.levels[0].tableName;
}
// 레벨 2 = Location
if (config.levels[1]?.tableName) {
newSelectedTables.location = config.levels[1].tableName;
}
}
// 자재 테이블 정보
if (config.material?.tableName) {
newSelectedTables.material = config.material.tableName;
}
setSelectedTables(newSelectedTables);
} catch (e) {
console.error("계층 구조 설정 파싱 실패:", e);
}
}
// 객체 데이터 변환 (DB -> PlacedObject)
const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({
id: obj.id,
type: obj.object_type,
name: obj.object_name,
position: {
x: parseFloat(obj.position_x),
y: parseFloat(obj.position_y),
z: parseFloat(obj.position_z),
},
size: {
x: parseFloat(obj.size_x),
y: parseFloat(obj.size_y),
z: parseFloat(obj.size_z),
},
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
color: obj.color,
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview:
obj.loc_type === "STP" || !obj.material_preview_height
? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
visible: obj.visible !== false,
hierarchyLevel: obj.hierarchy_level || 1,
parentKey: obj.parent_key,
externalKey: obj.external_key,
}));
setPlacedObjects(loadedObjects);
// 다음 임시 ID 설정 (기존 ID 중 최소값 - 1)
const minId = Math.min(...loadedObjects.map((o) => o.id), 0);
setNextObjectId(minId - 1);
setHasUnsavedChanges(false);
toast({
title: "레이아웃 불러오기 완료",
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
});
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
const dbConnectionId = layout.external_db_connection_id;
const hierarchyConfigParsed =
typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config;
const materialTableName = hierarchyConfigParsed?.material?.tableName;
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" ||
obj.type === "location-stp" ||
obj.type === "location-temp" ||
obj.type === "location-dest") &&
obj.locaKey,
);
if (locationObjects.length > 0 && dbConnectionId && materialTableName) {
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
setTimeout(() => {
loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName);
}, 100);
}
} else {
throw new Error(response.error || "레이아웃 조회 실패");
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
toast({
variant: "destructive",
title: "오류",
description: errorMessage,
});
} finally {
setIsLoading(false);
}
};
// 위젯 새로고침 핸들러
const handleRefresh = async () => {
if (hasUnsavedChanges) {
const confirmed = window.confirm(
"저장되지 않은 변경사항이 있습니다. 새로고침하면 변경사항이 사라집니다. 계속하시겠습니까?",
);
if (!confirmed) return;
}
setIsRefreshing(true);
setSelectedObject(null);
setMaterials([]);
await loadLayout();
setIsRefreshing(false);
toast({
title: "새로고침 완료",
description: "데이터가 갱신되었습니다.",
});
};
// 초기 로드
useEffect(() => {
loadLayout();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId]); // toast 제거
}, [layoutId]);
// 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시)
useEffect(() => {
@ -1052,7 +1084,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
};
// Location별 자재 개수 로드 (locaKeys를 직접 받음)
const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => {
const loadMaterialCountsForLocations = async (
locaKeys: string[],
dbConnectionId?: number,
materialTableName?: string,
) => {
const connectionId = dbConnectionId || selectedDbConnection;
const tableName = materialTableName || selectedTables.material;
if (!connectionId || locaKeys.length === 0) return;
@ -1060,7 +1096,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
try {
const response = await getMaterialCounts(connectionId, tableName, locaKeys);
console.log("📊 자재 개수 API 응답:", response);
if (response.success && response.data) {
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
setPlacedObjects((prev) =>
@ -1073,10 +1109,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
}
// 백엔드 응답 필드명: location_key, count (대소문자 모두 체크)
const materialCount = response.data?.find(
(mc: any) =>
mc.LOCAKEY === obj.locaKey ||
mc.location_key === obj.locaKey ||
mc.locakey === obj.locaKey
(mc: any) => mc.LOCAKEY === obj.locaKey || mc.location_key === obj.locaKey || mc.locakey === obj.locaKey,
);
if (materialCount) {
// count 또는 material_count 필드 사용
@ -1527,6 +1560,16 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<div className="flex items-center gap-2">
{hasUnsavedChanges && <span className="text-warning text-sm font-medium"> </span>}
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing || isLoading}
title="새로고침"
>
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
{isRefreshing ? "갱신 중..." : "새로고침"}
</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasUnsavedChanges}>
{isSaving ? (
<>
@ -1620,27 +1663,20 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</Button>
</div>
<div className="flex gap-2">
<Select
value={selectedTemplateId}
onValueChange={(val) => setSelectedTemplateId(val)}
>
<Select value={selectedTemplateId} onValueChange={(val) => setSelectedTemplateId(val)}>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder={loadingTemplates ? "로딩 중..." : "템플릿 선택..."} />
</SelectTrigger>
<SelectContent>
{mappingTemplates.length === 0 ? (
<div className="text-muted-foreground px-2 py-1 text-xs">
릿
</div>
<div className="text-muted-foreground px-2 py-1 text-xs"> 릿 </div>
) : (
mappingTemplates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id} className="text-xs">
<div className="flex flex-col">
<span>{tpl.name}</span>
{tpl.description && (
<span className="text-muted-foreground text-[10px]">
{tpl.description}
</span>
<span className="text-muted-foreground text-[10px]">{tpl.description}</span>
)}
</div>
</SelectItem>
@ -1704,17 +1740,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
}}
onLoadColumns={async (tableName: string) => {
try {
const response = await ExternalDbConnectionAPI.getTableColumns(
selectedDbConnection,
tableName,
);
const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName);
if (response.success && response.data) {
// 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그)
return response.data.map((col: any) => ({
column_name:
typeof col === "string"
? col
: col.column_name || col.COLUMN_NAME || String(col),
column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col),
data_type: col.data_type || col.DATA_TYPE,
description: col.description || col.COLUMN_COMMENT || undefined,
is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY,
@ -2354,10 +2384,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
>
</Button>
<Button
onClick={handleSaveTemplate}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Button onClick={handleSaveTemplate} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>

View File

@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react";
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
@ -41,130 +41,144 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// 검색 및 필터
const [searchQuery, setSearchQuery] = useState("");
const [filterType, setFilterType] = useState<string>("all");
const [isRefreshing, setIsRefreshing] = useState(false);
// 레이아웃 데이터 로드
useEffect(() => {
const loadLayout = async () => {
try {
setIsLoading(true);
const response = await getLayoutById(layoutId);
// 레이아웃 데이터 로드 함수
const loadLayout = async () => {
try {
setIsLoading(true);
const response = await getLayoutById(layoutId);
if (response.success && response.data) {
const { layout, objects } = response.data;
if (response.success && response.data) {
const { layout, objects } = response.data;
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
setLayoutName(layout.layout_name || layout.layoutName);
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
setExternalDbConnectionId(dbConnectionId);
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
setLayoutName(layout.layout_name || layout.layoutName);
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
setExternalDbConnectionId(dbConnectionId);
// hierarchy_config 저장
let hierarchyConfigData: any = null;
if (layout.hierarchy_config) {
hierarchyConfigData =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
setHierarchyConfig(hierarchyConfigData);
}
// hierarchy_config 저장
let hierarchyConfigData: any = null;
if (layout.hierarchy_config) {
hierarchyConfigData =
typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config;
setHierarchyConfig(hierarchyConfigData);
}
// 객체 데이터 변환
const loadedObjects: PlacedObject[] = objects.map((obj: any) => {
const objectType = obj.object_type;
return {
id: obj.id,
type: objectType,
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: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview:
obj.loc_type === "STP" || !obj.material_preview_height
? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
visible: obj.visible !== false,
hierarchyLevel: obj.hierarchy_level,
parentKey: obj.parent_key,
externalKey: obj.external_key,
};
// 객체 데이터 변환
const loadedObjects: PlacedObject[] = objects.map((obj: any) => {
const objectType = obj.object_type;
return {
id: obj.id,
type: objectType,
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: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview:
obj.loc_type === "STP" || !obj.material_preview_height
? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
visible: obj.visible !== false,
hierarchyLevel: obj.hierarchy_level,
parentKey: obj.parent_key,
externalKey: obj.external_key,
};
});
setPlacedObjects(loadedObjects);
// 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회
if (dbConnectionId && hierarchyConfigData?.material) {
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.locaKey,
);
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
const materialCountPromises = locationObjects.map(async (obj) => {
try {
const matResponse = await getMaterials(dbConnectionId, {
tableName: hierarchyConfigData.material.tableName,
keyColumn: hierarchyConfigData.material.keyColumn,
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
layerColumn: hierarchyConfigData.material.layerColumn,
locaKey: obj.locaKey!,
});
if (matResponse.success && matResponse.data) {
return { id: obj.id, count: matResponse.data.length };
}
} catch (e) {
console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e);
}
return { id: obj.id, count: 0 };
});
setPlacedObjects(loadedObjects);
const materialCounts = await Promise.all(materialCountPromises);
// 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회
if (dbConnectionId && hierarchyConfigData?.material) {
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.locaKey
);
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
const materialCountPromises = locationObjects.map(async (obj) => {
try {
const matResponse = await getMaterials(dbConnectionId, {
tableName: hierarchyConfigData.material.tableName,
keyColumn: hierarchyConfigData.material.keyColumn,
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
layerColumn: hierarchyConfigData.material.layerColumn,
locaKey: obj.locaKey!,
});
if (matResponse.success && matResponse.data) {
return { id: obj.id, count: matResponse.data.length };
}
} catch (e) {
console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e);
// materialCount 업데이트
setPlacedObjects((prev) =>
prev.map((obj) => {
const countData = materialCounts.find((m) => m.id === obj.id);
if (countData && countData.count > 0) {
return { ...obj, materialCount: countData.count };
}
return { id: obj.id, count: 0 };
});
const materialCounts = await Promise.all(materialCountPromises);
// materialCount 업데이트
setPlacedObjects((prev) =>
prev.map((obj) => {
const countData = materialCounts.find((m) => m.id === obj.id);
if (countData && countData.count > 0) {
return { ...obj, materialCount: countData.count };
}
return obj;
})
);
}
} else {
throw new Error(response.error || "레이아웃 조회 실패");
return obj;
}),
);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
toast({
variant: "destructive",
title: "오류",
description: errorMessage,
});
} finally {
setIsLoading(false);
} 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);
}
};
// 위젯 새로고침 핸들러
const handleRefresh = async () => {
setIsRefreshing(true);
setSelectedObject(null);
setMaterials([]);
setShowInfoPanel(false);
await loadLayout();
setIsRefreshing(false);
toast({
title: "새로고침 완료",
description: "데이터가 갱신되었습니다.",
});
};
// 초기 로드
useEffect(() => {
loadLayout();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId]); // toast 제거 - 무한 루프 방지
}, [layoutId]);
// Location의 자재 목록 로드
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
@ -322,6 +336,16 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing || isLoading}
title="새로고침"
>
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
{isRefreshing ? "갱신 중..." : "새로고침"}
</Button>
</div>
{/* 메인 영역 */}
@ -404,59 +428,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// Area가 없으면 기존 평면 리스트 유지
if (areaObjects.length === 0) {
return (
<div className="space-y-2">
{filteredObjects.map((obj) => {
let typeLabel = obj.type;
if (obj.type === "location-bed") typeLabel = "베드(BED)";
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
else if (obj.type === "crane-mobile") typeLabel = "크레인";
else if (obj.type === "area") typeLabel = "Area";
else if (obj.type === "rack") typeLabel = "랙";
<div className="space-y-2">
{filteredObjects.map((obj) => {
let typeLabel = obj.type;
if (obj.type === "location-bed") typeLabel = "베드(BED)";
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
else if (obj.type === "crane-mobile") typeLabel = "크레인";
else if (obj.type === "area") typeLabel = "Area";
else if (obj.type === "rack") typeLabel = "랙";
return (
<div
key={obj.id}
onClick={() => handleObjectClick(obj.id)}
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{obj.name}</p>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: obj.color }}
/>
<span>{typeLabel}</span>
return (
<div
key={obj.id}
onClick={() => handleObjectClick(obj.id)}
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{obj.name}</p>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: obj.color }}
/>
<span>{typeLabel}</span>
</div>
</div>
</div>
<div className="mt-2 space-y-1">
{obj.areaKey && (
<p className="text-muted-foreground text-xs">
Area: <span className="font-medium">{obj.areaKey}</span>
</p>
)}
{obj.locaKey && (
<p className="text-muted-foreground text-xs">
Location: <span className="font-medium">{obj.locaKey}</span>
</p>
)}
{obj.materialCount !== undefined && obj.materialCount > 0 && (
<p className="text-xs text-yellow-600">
: <span className="font-semibold">{obj.materialCount}</span>
</p>
)}
</div>
</div>
</div>
</div>
<div className="mt-2 space-y-1">
{obj.areaKey && (
<p className="text-muted-foreground text-xs">
Area: <span className="font-medium">{obj.areaKey}</span>
</p>
)}
{obj.locaKey && (
<p className="text-muted-foreground text-xs">
Location: <span className="font-medium">{obj.locaKey}</span>
</p>
)}
{obj.materialCount !== undefined && obj.materialCount > 0 && (
<p className="text-xs text-yellow-600">
: <span className="font-semibold">{obj.materialCount}</span>
</p>
)}
</div>
);
})}
</div>
);
})}
</div>
);
}
// Area가 있는 경우: Area → Location 계층 아코디언
@ -525,8 +549,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
/>
</div>
<p className="text-muted-foreground mt-1 text-[10px]">
: ({locationObj.position.x.toFixed(1)},{" "}
{locationObj.position.z.toFixed(1)})
: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})
</p>
{locationObj.locaKey && (
<p className="text-muted-foreground mt-0.5 text-[10px]">