799 lines
33 KiB
TypeScript
799 lines
33 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useMemo, useRef } from "react";
|
|
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw, Maximize, Minimize } from "lucide-react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import dynamic from "next/dynamic";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import type { PlacedObject, MaterialData } from "@/types/digitalTwin";
|
|
import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin";
|
|
import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
|
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
|
import { apiCall } from "@/lib/api/client";
|
|
|
|
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 DigitalTwinViewerProps {
|
|
layoutId: number;
|
|
}
|
|
|
|
// 외부 업체 역할 코드
|
|
const EXTERNAL_VENDOR_ROLE = "LSTHIRA_EXTERNAL_VENDOR";
|
|
|
|
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
|
|
const { toast } = useToast();
|
|
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
|
|
const [selectedObject, setSelectedObject] = useState<PlacedObject | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
|
const [loadingMaterials, setLoadingMaterials] = useState(false);
|
|
const [showInfoPanel, setShowInfoPanel] = useState(false);
|
|
const [externalDbConnectionId, setExternalDbConnectionId] = useState<number | null>(null);
|
|
const [layoutName, setLayoutName] = useState<string>("");
|
|
const [hierarchyConfig, setHierarchyConfig] = useState<any>(null);
|
|
|
|
// 검색 및 필터
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [filterType, setFilterType] = useState<string>("all");
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
|
|
// 외부 업체 모드
|
|
const [isExternalMode, setIsExternalMode] = useState(false);
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// 외부 업체 역할 체크
|
|
useEffect(() => {
|
|
const checkExternalRole = async () => {
|
|
try {
|
|
const response = await apiCall<any[]>("GET", "/roles/user/my-groups");
|
|
console.log("=== 사용자 권한 그룹 조회 ===");
|
|
console.log("API 응답:", response);
|
|
console.log("찾는 역할:", EXTERNAL_VENDOR_ROLE);
|
|
|
|
if (response.success && response.data) {
|
|
console.log("권한 그룹 목록:", response.data);
|
|
|
|
// 사용자의 권한 그룹 중 LSTHIRA_EXTERNAL_VENDOR가 있는지 확인
|
|
const hasExternalRole = response.data.some(
|
|
(group: any) => {
|
|
console.log("체크 중인 그룹:", group.authCode, group.authName);
|
|
return group.authCode === EXTERNAL_VENDOR_ROLE || group.authName === EXTERNAL_VENDOR_ROLE;
|
|
}
|
|
);
|
|
|
|
console.log("외부 업체 역할 보유:", hasExternalRole);
|
|
setIsExternalMode(hasExternalRole);
|
|
}
|
|
} catch (error) {
|
|
console.error("역할 조회 실패:", error);
|
|
}
|
|
};
|
|
|
|
checkExternalRole();
|
|
}, []);
|
|
|
|
// 전체 화면 토글 (3D 캔버스 영역만)
|
|
const toggleFullscreen = () => {
|
|
if (!document.fullscreenElement) {
|
|
// 3D 캔버스 컨테이너만 풀스크린
|
|
canvasContainerRef.current?.requestFullscreen();
|
|
setIsFullscreen(true);
|
|
} else {
|
|
document.exitFullscreen();
|
|
setIsFullscreen(false);
|
|
}
|
|
};
|
|
|
|
// 전체 화면 변경 감지
|
|
useEffect(() => {
|
|
const handleFullscreenChange = () => {
|
|
setIsFullscreen(!!document.fullscreenElement);
|
|
};
|
|
|
|
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
|
}, []);
|
|
|
|
// 레이아웃 데이터 로드 함수
|
|
const loadLayout = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const response = await getLayoutById(layoutId);
|
|
|
|
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);
|
|
|
|
// 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,
|
|
};
|
|
});
|
|
|
|
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 };
|
|
});
|
|
|
|
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 || "레이아웃 조회 실패");
|
|
}
|
|
} 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]);
|
|
|
|
// Location의 자재 목록 로드
|
|
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
|
|
if (!hierarchyConfig?.material) {
|
|
console.warn("hierarchyConfig.material이 없습니다. 자재 로드를 건너뜁니다.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoadingMaterials(true);
|
|
setShowInfoPanel(true);
|
|
|
|
const response = await getMaterials(externalDbConnectionId, {
|
|
tableName: hierarchyConfig.material.tableName,
|
|
keyColumn: hierarchyConfig.material.keyColumn,
|
|
locationKeyColumn: hierarchyConfig.material.locationKeyColumn,
|
|
layerColumn: hierarchyConfig.material.layerColumn,
|
|
locaKey: locaKey,
|
|
});
|
|
if (response.success && response.data) {
|
|
const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER";
|
|
const sortedMaterials = response.data.sort((a: any, b: any) => (a[layerColumn] || 0) - (b[layerColumn] || 0));
|
|
setMaterials(sortedMaterials);
|
|
} else {
|
|
setMaterials([]);
|
|
}
|
|
} catch (error) {
|
|
console.error("자재 로드 실패:", error);
|
|
setMaterials([]);
|
|
} finally {
|
|
setLoadingMaterials(false);
|
|
}
|
|
};
|
|
|
|
// 객체 클릭
|
|
const handleObjectClick = (objectId: number | null) => {
|
|
if (objectId === null) {
|
|
setSelectedObject(null);
|
|
setShowInfoPanel(false);
|
|
return;
|
|
}
|
|
|
|
const obj = placedObjects.find((o) => o.id === objectId);
|
|
setSelectedObject(obj || null);
|
|
|
|
// Location을 클릭한 경우, 자재 정보 표시 (STP는 자재 미적재이므로 제외)
|
|
if (
|
|
obj &&
|
|
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
|
obj.locaKey &&
|
|
externalDbConnectionId
|
|
) {
|
|
setShowInfoPanel(true);
|
|
loadMaterialsForLocation(obj.locaKey, externalDbConnectionId);
|
|
} else {
|
|
setShowInfoPanel(true);
|
|
setMaterials([]);
|
|
}
|
|
};
|
|
|
|
// 타입별 개수 계산 (useMemo로 최적화)
|
|
const typeCounts = useMemo(() => {
|
|
const counts: Record<string, number> = {
|
|
all: placedObjects.length,
|
|
area: 0,
|
|
"location-bed": 0,
|
|
"location-stp": 0,
|
|
"location-temp": 0,
|
|
"location-dest": 0,
|
|
"crane-mobile": 0,
|
|
rack: 0,
|
|
};
|
|
|
|
placedObjects.forEach((obj) => {
|
|
if (counts[obj.type] !== undefined) {
|
|
counts[obj.type]++;
|
|
}
|
|
});
|
|
|
|
return counts;
|
|
}, [placedObjects]);
|
|
|
|
// 필터링된 객체 목록 (useMemo로 최적화)
|
|
const filteredObjects = useMemo(() => {
|
|
return placedObjects.filter((obj) => {
|
|
// 타입 필터
|
|
if (filterType !== "all" && obj.type !== filterType) {
|
|
return false;
|
|
}
|
|
|
|
// 검색 쿼리
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
return (
|
|
obj.name.toLowerCase().includes(query) ||
|
|
obj.areaKey?.toLowerCase().includes(query) ||
|
|
obj.locaKey?.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}, [placedObjects, filterType, searchQuery]);
|
|
|
|
// 객체 타입별 기본 색상 (useMemo로 최적화)
|
|
const getObjectColor = useMemo(() => {
|
|
return (type: string, savedColor?: string): string => {
|
|
// 저장된 색상이 있으면 우선 사용
|
|
if (savedColor) return savedColor;
|
|
// 없으면 타입별 기본 색상 사용
|
|
return OBJECT_COLORS[type] || DEFAULT_COLOR;
|
|
};
|
|
}, []);
|
|
|
|
// 3D 캔버스용 placements 변환 (useMemo로 최적화)
|
|
const canvasPlacements = useMemo(() => {
|
|
return placedObjects.map((obj) => ({
|
|
id: obj.id,
|
|
name: obj.name,
|
|
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,
|
|
material_count: obj.materialCount,
|
|
material_preview_height: obj.materialPreview?.height,
|
|
yard_layout_id: undefined,
|
|
material_code: null,
|
|
material_name: null,
|
|
quantity: null,
|
|
unit: null,
|
|
data_source_config: undefined,
|
|
data_binding: undefined,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
}));
|
|
}, [placedObjects]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-background flex h-full flex-col">
|
|
{/* 상단 헤더 */}
|
|
<div className="flex items-center justify-between border-b p-4">
|
|
<div>
|
|
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
|
|
<p className="text-muted-foreground text-sm">
|
|
{isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* 전체 화면 버튼 */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={toggleFullscreen}
|
|
title={isFullscreen ? "전체 화면 종료" : "전체 화면"}
|
|
>
|
|
{isFullscreen ? (
|
|
<Minimize className="mr-2 h-4 w-4" />
|
|
) : (
|
|
<Maximize className="mr-2 h-4 w-4" />
|
|
)}
|
|
{isFullscreen ? "종료" : "전체 화면"}
|
|
</Button>
|
|
<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>
|
|
</div>
|
|
|
|
{/* 메인 영역 */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* 좌측: 검색/필터 - 외부 모드에서는 숨김 */}
|
|
{!isExternalMode && (
|
|
<div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
|
|
<div className="space-y-4 p-4">
|
|
{/* 검색 */}
|
|
<div>
|
|
<Label className="mb-2 block text-sm font-semibold">검색</Label>
|
|
<div className="relative">
|
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
|
<Input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="이름, Area, Location 검색..."
|
|
className="h-10 pl-9 text-sm"
|
|
/>
|
|
{searchQuery && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute top-1/2 right-1 h-7 w-7 -translate-y-1/2 p-0"
|
|
onClick={() => setSearchQuery("")}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 타입 필터 */}
|
|
<div>
|
|
<Label className="mb-2 block text-sm font-semibold">타입 필터</Label>
|
|
<Select value={filterType} onValueChange={setFilterType}>
|
|
<SelectTrigger className="h-10 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체 ({typeCounts.all})</SelectItem>
|
|
<SelectItem value="area">Area ({typeCounts.area})</SelectItem>
|
|
<SelectItem value="location-bed">베드(BED) ({typeCounts["location-bed"]})</SelectItem>
|
|
<SelectItem value="location-stp">정차포인트(STP) ({typeCounts["location-stp"]})</SelectItem>
|
|
<SelectItem value="location-temp">임시베드(TMP) ({typeCounts["location-temp"]})</SelectItem>
|
|
<SelectItem value="location-dest">지정착지(DES) ({typeCounts["location-dest"]})</SelectItem>
|
|
<SelectItem value="crane-mobile">크레인 ({typeCounts["crane-mobile"]})</SelectItem>
|
|
<SelectItem value="rack">랙 ({typeCounts.rack})</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 필터 초기화 */}
|
|
{(searchQuery || filterType !== "all") && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9 w-full text-sm"
|
|
onClick={() => {
|
|
setSearchQuery("");
|
|
setFilterType("all");
|
|
}}
|
|
>
|
|
필터 초기화
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 객체 목록 */}
|
|
<div className="flex-1 overflow-y-auto border-t p-4">
|
|
<Label className="mb-2 block text-sm font-semibold">객체 목록 ({filteredObjects.length})</Label>
|
|
{filteredObjects.length === 0 ? (
|
|
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
|
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
|
|
</div>
|
|
) : (
|
|
(() => {
|
|
// Area 객체가 있는 경우 계층 트리 아코디언 적용
|
|
const areaObjects = filteredObjects.filter((obj) => obj.type === "area");
|
|
|
|
// 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 = "랙";
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// Area가 있는 경우: Area → Location 계층 아코디언
|
|
return (
|
|
<Accordion type="multiple" className="w-full">
|
|
{areaObjects.map((areaObj) => {
|
|
const childLocations = filteredObjects.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>
|
|
<span
|
|
className="inline-block h-2 w-2 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">
|
|
{locationObj.type === "location-stp" ? (
|
|
<ParkingCircle className="h-3 w-3" />
|
|
) : (
|
|
<Package className="h-3 w-3" />
|
|
)}
|
|
<span className="text-xs font-medium">{locationObj.name}</span>
|
|
</div>
|
|
<span
|
|
className="inline-block 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]">
|
|
Location: <span className="font-medium">{locationObj.locaKey}</span>
|
|
</p>
|
|
)}
|
|
{locationObj.materialCount !== undefined && locationObj.materialCount > 0 && (
|
|
<p className="mt-0.5 text-[10px] text-yellow-600">
|
|
자재: <span className="font-semibold">{locationObj.materialCount}개</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
);
|
|
})}
|
|
</Accordion>
|
|
);
|
|
})()
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 중앙: 3D 캔버스 */}
|
|
<div
|
|
ref={canvasContainerRef}
|
|
className={`relative flex-1 ${isFullscreen ? "bg-background" : ""}`}
|
|
>
|
|
{!isLoading && (
|
|
<Yard3DCanvas
|
|
placements={canvasPlacements}
|
|
selectedPlacementId={selectedObject?.id || null}
|
|
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
|
focusOnPlacementId={null}
|
|
onCollisionDetected={() => {}}
|
|
/>
|
|
)}
|
|
{/* 풀스크린 모드일 때 종료 버튼 */}
|
|
{isFullscreen && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={toggleFullscreen}
|
|
className="absolute top-4 right-4 z-50 bg-background/80 backdrop-blur-sm"
|
|
>
|
|
<Minimize className="mr-2 h-4 w-4" />
|
|
종료
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 우측: 정보 패널 - 외부 모드에서는 숨김 */}
|
|
{!isExternalMode && (
|
|
<div className="h-full w-[480px] flex-shrink-0 overflow-y-auto border-l">
|
|
{selectedObject ? (
|
|
<div className="p-4">
|
|
<div className="mb-4">
|
|
<h3 className="text-lg font-semibold">상세 정보</h3>
|
|
<p className="text-muted-foreground text-xs">{selectedObject.name}</p>
|
|
</div>
|
|
|
|
{/* 기본 정보 */}
|
|
<div className="bg-muted space-y-3 rounded-lg p-3">
|
|
<div>
|
|
<Label className="text-muted-foreground text-xs">타입</Label>
|
|
<p className="text-sm font-medium">{selectedObject.type}</p>
|
|
</div>
|
|
{selectedObject.areaKey && (
|
|
<div>
|
|
<Label className="text-muted-foreground text-xs">Area Key</Label>
|
|
<p className="text-sm font-medium">{selectedObject.areaKey}</p>
|
|
</div>
|
|
)}
|
|
{selectedObject.locaKey && (
|
|
<div>
|
|
<Label className="text-muted-foreground text-xs">Location Key</Label>
|
|
<p className="text-sm font-medium">{selectedObject.locaKey}</p>
|
|
</div>
|
|
)}
|
|
{selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && (
|
|
<div>
|
|
<Label className="text-muted-foreground text-xs">자재 개수</Label>
|
|
<p className="text-sm font-medium">{selectedObject.materialCount}개</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 자재 목록 (Location인 경우) - 테이블 형태 */}
|
|
{(selectedObject.type === "location-bed" ||
|
|
selectedObject.type === "location-stp" ||
|
|
selectedObject.type === "location-temp" ||
|
|
selectedObject.type === "location-dest") && (
|
|
<div className="mt-4">
|
|
{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-center text-sm">
|
|
{externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<Label className="mb-2 block text-sm font-semibold">
|
|
자재 목록 ({materials.length}개)
|
|
</Label>
|
|
{/* 테이블 형태로 전체 조회 */}
|
|
<div className="max-h-[400px] overflow-auto rounded-lg border">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted sticky top-0">
|
|
<tr>
|
|
<th className="whitespace-nowrap border-b px-3 py-3 text-left font-semibold">층</th>
|
|
{(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => (
|
|
<th
|
|
key={colConfig.column}
|
|
className="border-b px-3 py-3 text-left font-semibold"
|
|
>
|
|
{colConfig.label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{materials.map((material, index) => {
|
|
const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
|
|
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
|
|
return (
|
|
<tr
|
|
key={`${material.STKKEY}-${index}`}
|
|
className="hover:bg-accent border-b transition-colors last:border-0"
|
|
>
|
|
<td className="whitespace-nowrap px-3 py-3 font-medium">
|
|
{material[layerColumn]}단
|
|
</td>
|
|
{displayColumns.map((colConfig: any) => (
|
|
<td key={colConfig.column} className="px-3 py-3">
|
|
{material[colConfig.column] || "-"}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center p-4">
|
|
<p className="text-muted-foreground text-sm">객체를 선택하세요</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|