Compare commits
No commits in common. "9597494685d987846dc10cd10b57ae1690ce4ad1" and "979a5ddd9a021e2f0d0392a9060c18d4dcb25ada" have entirely different histories.
9597494685
...
979a5ddd9a
|
|
@ -22,15 +22,6 @@ const router = Router();
|
||||||
// 모든 role 라우트에 인증 미들웨어 적용
|
// 모든 role 라우트에 인증 미들웨어 적용
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자 권한 그룹 조회 (/:id 보다 먼저 정의해야 함)
|
|
||||||
*/
|
|
||||||
// 현재 사용자가 속한 권한 그룹 조회
|
|
||||||
router.get("/user/my-groups", getUserRoleGroups);
|
|
||||||
|
|
||||||
// 특정 사용자가 속한 권한 그룹 조회
|
|
||||||
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 권한 그룹 CRUD
|
* 권한 그룹 CRUD
|
||||||
*/
|
*/
|
||||||
|
|
@ -76,4 +67,13 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions);
|
||||||
// 메뉴 권한 설정
|
// 메뉴 권한 설정
|
||||||
router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions);
|
router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 권한 그룹 조회
|
||||||
|
*/
|
||||||
|
// 현재 사용자가 속한 권한 그룹 조회
|
||||||
|
router.get("/user/my-groups", getUserRoleGroups);
|
||||||
|
|
||||||
|
// 특정 사용자가 속한 권한 그룹 조회
|
||||||
|
router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -916,7 +916,7 @@ export function CanvasElement({
|
||||||
) : element.type === "widget" && element.subtype === "weather" ? (
|
) : element.type === "widget" && element.subtype === "weather" ? (
|
||||||
// 날씨 위젯 렌더링
|
// 날씨 위젯 렌더링
|
||||||
<div className="widget-interactive-area h-full w-full">
|
<div className="widget-interactive-area h-full w-full">
|
||||||
<WeatherWidget element={element} city="서울" refreshInterval={600000} />
|
<WeatherWidget city="서울" refreshInterval={600000} />
|
||||||
</div>
|
</div>
|
||||||
) : element.type === "widget" && element.subtype === "exchange" ? (
|
) : element.type === "widget" && element.subtype === "exchange" ? (
|
||||||
// 환율 위젯 렌더링
|
// 환율 위젯 렌더링
|
||||||
|
|
|
||||||
|
|
@ -2146,9 +2146,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="bg-muted sticky top-0">
|
<TableHeader className="bg-muted sticky top-0">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[70px] whitespace-nowrap px-3 py-3 text-sm">층</TableHead>
|
<TableHead className="w-[60px] text-xs">층</TableHead>
|
||||||
{(hierarchyConfig?.material?.displayColumns || []).map((col) => (
|
{(hierarchyConfig?.material?.displayColumns || []).map((col) => (
|
||||||
<TableHead key={col.column} className="px-3 py-3 text-sm">
|
<TableHead key={col.column} className="text-xs">
|
||||||
{col.label}
|
{col.label}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
|
|
@ -2163,9 +2163,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={material[keyColumn] || `material-${index}`}>
|
<TableRow key={material[keyColumn] || `material-${index}`}>
|
||||||
<TableCell className="whitespace-nowrap px-3 py-3 text-sm font-medium">{layerNumber}단</TableCell>
|
<TableCell className="text-xs font-medium">{layerNumber}단</TableCell>
|
||||||
{displayColumns.map((col) => (
|
{displayColumns.map((col) => (
|
||||||
<TableCell key={col.column} className="px-3 py-3 text-sm">
|
<TableCell key={col.column} className="text-xs">
|
||||||
{material[col.column] || "-"}
|
{material[col.column] || "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useRef } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw, Maximize, Minimize } from "lucide-react";
|
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -12,7 +12,6 @@ import type { PlacedObject, MaterialData } from "@/types/digitalTwin";
|
||||||
import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin";
|
import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin";
|
||||||
import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
|
import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
import { apiCall } from "@/lib/api/client";
|
|
||||||
|
|
||||||
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
@ -27,9 +26,6 @@ interface DigitalTwinViewerProps {
|
||||||
layoutId: number;
|
layoutId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 외부 업체 역할 코드
|
|
||||||
const EXTERNAL_VENDOR_ROLE = "LSTHIRA_EXTERNAL_VENDOR";
|
|
||||||
|
|
||||||
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
|
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
|
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
|
||||||
|
|
@ -47,73 +43,6 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
const [filterType, setFilterType] = useState<string>("all");
|
const [filterType, setFilterType] = useState<string>("all");
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
// 외부 업체 모드
|
|
||||||
const [isExternalMode, setIsExternalMode] = useState(false);
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
||||||
const [layoutKey, setLayoutKey] = useState(0); // 레이아웃 강제 리렌더링용
|
|
||||||
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null); // 마지막 갱신 시간
|
|
||||||
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 = () => {
|
|
||||||
const isNowFullscreen = !!document.fullscreenElement;
|
|
||||||
setIsFullscreen(isNowFullscreen);
|
|
||||||
|
|
||||||
// 전체화면 종료 시 레이아웃 강제 리렌더링
|
|
||||||
if (!isNowFullscreen) {
|
|
||||||
setTimeout(() => {
|
|
||||||
setLayoutKey((prev) => prev + 1);
|
|
||||||
window.dispatchEvent(new Event("resize"));
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
||||||
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 레이아웃 데이터 로드 함수
|
// 레이아웃 데이터 로드 함수
|
||||||
const loadLayout = async () => {
|
const loadLayout = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -215,8 +144,6 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 마지막 갱신 시간 기록
|
|
||||||
setLastRefreshedAt(new Date());
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.error || "레이아웃 조회 실패");
|
throw new Error(response.error || "레이아웃 조회 실패");
|
||||||
}
|
}
|
||||||
|
|
@ -253,155 +180,6 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [layoutId]);
|
}, [layoutId]);
|
||||||
|
|
||||||
// 10초 주기 자동 갱신 (중앙 관제 화면 자동 새로고침)
|
|
||||||
useEffect(() => {
|
|
||||||
const AUTO_REFRESH_INTERVAL = 10000; // 10초
|
|
||||||
|
|
||||||
const silentRefresh = async () => {
|
|
||||||
// 로딩 중이거나 새로고침 중이면 스킵
|
|
||||||
if (isLoading || isRefreshing) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 레이아웃 데이터 조용히 갱신
|
|
||||||
const response = await getLayoutById(layoutId);
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
const { layout, objects } = response.data;
|
|
||||||
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// 외부 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 {
|
|
||||||
// 자동 갱신 시에는 에러 로그 생략
|
|
||||||
}
|
|
||||||
return { id: obj.id, count: 0 };
|
|
||||||
});
|
|
||||||
|
|
||||||
const materialCounts = await Promise.all(materialCountPromises);
|
|
||||||
|
|
||||||
// materialCount 업데이트
|
|
||||||
const updatedObjects = loadedObjects.map((obj) => {
|
|
||||||
const countData = materialCounts.find((m) => m.id === obj.id);
|
|
||||||
if (countData && countData.count > 0) {
|
|
||||||
return { ...obj, materialCount: countData.count };
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
});
|
|
||||||
|
|
||||||
setPlacedObjects(updatedObjects);
|
|
||||||
} else {
|
|
||||||
setPlacedObjects(loadedObjects);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 선택된 객체가 있으면 자재 목록도 갱신
|
|
||||||
if (selectedObject && dbConnectionId && hierarchyConfigData?.material) {
|
|
||||||
const currentObj = loadedObjects.find((o) => o.id === selectedObject.id);
|
|
||||||
if (
|
|
||||||
currentObj &&
|
|
||||||
(currentObj.type === "location-bed" ||
|
|
||||||
currentObj.type === "location-temp" ||
|
|
||||||
currentObj.type === "location-dest") &&
|
|
||||||
currentObj.locaKey
|
|
||||||
) {
|
|
||||||
const matResponse = await getMaterials(dbConnectionId, {
|
|
||||||
tableName: hierarchyConfigData.material.tableName,
|
|
||||||
keyColumn: hierarchyConfigData.material.keyColumn,
|
|
||||||
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
|
|
||||||
layerColumn: hierarchyConfigData.material.layerColumn,
|
|
||||||
locaKey: currentObj.locaKey,
|
|
||||||
});
|
|
||||||
if (matResponse.success && matResponse.data) {
|
|
||||||
const layerColumn = hierarchyConfigData.material.layerColumn || "LOLAYER";
|
|
||||||
const sortedMaterials = matResponse.data.sort(
|
|
||||||
(a: any, b: any) => (b[layerColumn] || 0) - (a[layerColumn] || 0),
|
|
||||||
);
|
|
||||||
setMaterials(sortedMaterials);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 마지막 갱신 시간 기록
|
|
||||||
setLastRefreshedAt(new Date());
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 자동 갱신 실패 시 조용히 무시 (사용자 경험 방해 안 함)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 10초마다 자동 갱신
|
|
||||||
const intervalId = setInterval(silentRefresh, AUTO_REFRESH_INTERVAL);
|
|
||||||
|
|
||||||
// 컴포넌트 언마운트 시 인터벌 정리
|
|
||||||
return () => clearInterval(intervalId);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [layoutId, isLoading, isRefreshing, selectedObject]);
|
|
||||||
|
|
||||||
// Location의 자재 목록 로드
|
// Location의 자재 목록 로드
|
||||||
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
|
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
|
||||||
if (!hierarchyConfig?.material) {
|
if (!hierarchyConfig?.material) {
|
||||||
|
|
@ -422,8 +200,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
});
|
});
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER";
|
const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER";
|
||||||
// 층 내림차순 정렬 (높은 층이 위로)
|
const sortedMaterials = response.data.sort((a: any, b: any) => (a[layerColumn] || 0) - (b[layerColumn] || 0));
|
||||||
const sortedMaterials = response.data.sort((a: any, b: any) => (b[layerColumn] || 0) - (a[layerColumn] || 0));
|
|
||||||
setMaterials(sortedMaterials);
|
setMaterials(sortedMaterials);
|
||||||
} else {
|
} else {
|
||||||
setMaterials([]);
|
setMaterials([]);
|
||||||
|
|
@ -557,28 +334,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
<div className="flex items-center justify-between border-b p-4">
|
<div className="flex items-center justify-between border-b p-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
|
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
|
||||||
<div className="flex items-center gap-3">
|
<p className="text-muted-foreground text-sm">읽기 전용 뷰</p>
|
||||||
<p className="text-muted-foreground text-sm">{isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"}</p>
|
|
||||||
{lastRefreshedAt && (
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
마지막 갱신: {lastRefreshedAt.toLocaleTimeString("ko-KR")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* 전체 화면 버튼 - 외부 업체 모드에서만 표시 */}
|
|
||||||
{isExternalMode && (
|
|
||||||
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -590,12 +347,10 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
{isRefreshing ? "갱신 중..." : "새로고침"}
|
{isRefreshing ? "갱신 중..." : "새로고침"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메인 영역 */}
|
{/* 메인 영역 */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* 좌측: 검색/필터 - 외부 모드에서는 숨김 */}
|
{/* 좌측: 검색/필터 */}
|
||||||
{!isExternalMode && (
|
|
||||||
<div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
|
<div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
|
|
@ -820,15 +575,9 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 중앙 + 우측 컨테이너 (전체화면 시 함께 표시) */}
|
|
||||||
<div
|
|
||||||
ref={canvasContainerRef}
|
|
||||||
className={`relative flex flex-1 overflow-hidden ${isFullscreen ? "bg-background" : ""}`}
|
|
||||||
>
|
|
||||||
{/* 중앙: 3D 캔버스 */}
|
{/* 중앙: 3D 캔버스 */}
|
||||||
<div className="relative min-w-0 flex-1">
|
<div className="relative flex-1">
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<Yard3DCanvas
|
<Yard3DCanvas
|
||||||
placements={canvasPlacements}
|
placements={canvasPlacements}
|
||||||
|
|
@ -841,7 +590,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측: 정보 패널 */}
|
{/* 우측: 정보 패널 */}
|
||||||
<div className="h-full w-[480px] min-w-[480px] flex-shrink-0 overflow-y-auto border-l">
|
<div className="h-full w-[480px] flex-shrink-0 overflow-y-auto border-l">
|
||||||
{selectedObject ? (
|
{selectedObject ? (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
|
@ -891,15 +640,20 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="mb-2 block text-sm font-semibold">자재 목록 ({materials.length}개)</Label>
|
<Label className="mb-2 block text-sm font-semibold">
|
||||||
|
자재 목록 ({materials.length}개)
|
||||||
|
</Label>
|
||||||
{/* 테이블 형태로 전체 조회 */}
|
{/* 테이블 형태로 전체 조회 */}
|
||||||
<div className="h-[580px] overflow-auto rounded-lg border">
|
<div className="max-h-[400px] overflow-auto rounded-lg border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-xs">
|
||||||
<thead className="bg-muted sticky top-0">
|
<thead className="bg-muted sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="border-b px-3 py-3 text-left font-semibold whitespace-nowrap">층</th>
|
<th className="border-b px-2 py-2 text-left font-semibold">층</th>
|
||||||
{(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => (
|
{(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => (
|
||||||
<th key={colConfig.column} className="border-b px-3 py-3 text-left font-semibold">
|
<th
|
||||||
|
key={colConfig.column}
|
||||||
|
className="border-b px-2 py-2 text-left font-semibold"
|
||||||
|
>
|
||||||
{colConfig.label}
|
{colConfig.label}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
|
@ -914,11 +668,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
key={`${material.STKKEY}-${index}`}
|
key={`${material.STKKEY}-${index}`}
|
||||||
className="hover:bg-accent border-b transition-colors last:border-0"
|
className="hover:bg-accent border-b transition-colors last:border-0"
|
||||||
>
|
>
|
||||||
<td className="px-3 py-3 font-medium whitespace-nowrap">
|
<td className="px-2 py-2 font-medium">
|
||||||
{material[layerColumn]}단
|
{material[layerColumn]}단
|
||||||
</td>
|
</td>
|
||||||
{displayColumns.map((colConfig: any) => (
|
{displayColumns.map((colConfig: any) => (
|
||||||
<td key={colConfig.column} className="px-3 py-3">
|
<td key={colConfig.column} className="px-2 py-2">
|
||||||
{material[colConfig.column] || "-"}
|
{material[colConfig.column] || "-"}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|
@ -939,20 +693,6 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 풀스크린 모드일 때 종료 버튼 */}
|
|
||||||
{isFullscreen && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={toggleFullscreen}
|
|
||||||
className="bg-background/80 absolute top-4 right-4 z-50 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<Minimize className="mr-2 h-4 w-4" />
|
|
||||||
종료
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue