feat: 3D Facility Viewer with React Three Fiber
- Added Facility3DViewerPage with interactive 3D scene - Shows floors, rooms, sections, and plant positions - Color-coded by room type and position status - Orbit controls for rotation/zoom/pan - Floor selector dropdown - Stats panel showing totals - Added /layout/floors/:id/3d API endpoint - Fixed TypeScript issues in DocumentsPage
This commit is contained in:
parent
1258aebb9f
commit
36dbeb23c3
6 changed files with 560 additions and 11 deletions
|
|
@ -68,7 +68,7 @@ export const documentsApi = {
|
|||
},
|
||||
|
||||
async getDocument(id: string): Promise<Document> {
|
||||
const response = await api.get(`/api/documents/${id}`);
|
||||
const response = await api.get(`/documents/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
|
@ -78,36 +78,36 @@ export const documentsApi = {
|
|||
},
|
||||
|
||||
async updateDocument(id: string, data: UpdateDocumentData): Promise<Document> {
|
||||
const response = await api.patch(`/api/documents/${id}`, data);
|
||||
const response = await api.patch(`/documents/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteDocument(id: string): Promise<void> {
|
||||
await api.delete(`/api/documents/${id}`);
|
||||
await api.delete(`/documents/${id}`);
|
||||
},
|
||||
|
||||
async getVersions(id: string): Promise<DocumentVersion[]> {
|
||||
const response = await api.get(`/api/documents/${id}/versions`);
|
||||
const response = await api.get(`/documents/${id}/versions`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async submitForApproval(id: string): Promise<Document> {
|
||||
const response = await api.post(`/api/documents/${id}/submit`);
|
||||
const response = await api.post(`/documents/${id}/submit`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async approveDocument(id: string): Promise<Document> {
|
||||
const response = await api.post(`/api/documents/${id}/approve`);
|
||||
const response = await api.post(`/documents/${id}/approve`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async rejectDocument(id: string, reason?: string): Promise<Document> {
|
||||
const response = await api.post(`/api/documents/${id}/reject`, { reason });
|
||||
const response = await api.post(`/documents/${id}/reject`, { reason });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async acknowledgeDocument(id: string): Promise<{ success: boolean; acknowledgedAt: string }> {
|
||||
const response = await api.post(`/api/documents/${id}/acknowledge`);
|
||||
const response = await api.post(`/documents/${id}/acknowledge`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ export const documentsApi = {
|
|||
acknowledged: DocumentAck[];
|
||||
pending: { id: string; name: string }[];
|
||||
}> {
|
||||
const response = await api.get(`/api/documents/${id}/acks`);
|
||||
const response = await api.get(`/documents/${id}/acks`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
|
@ -126,3 +126,4 @@ export const documentsApi = {
|
|||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,71 @@ export interface LayoutSection {
|
|||
positions?: LayoutPosition[];
|
||||
}
|
||||
|
||||
// 3D Visualization Types
|
||||
export interface Position3D {
|
||||
id: string;
|
||||
row: number;
|
||||
column: number;
|
||||
tier: number;
|
||||
status: string;
|
||||
plant: {
|
||||
id: string;
|
||||
tagNumber: string;
|
||||
status: string;
|
||||
batchId: string | null;
|
||||
batchName: string | null;
|
||||
strain: string | null;
|
||||
stage: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Section3D {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: string;
|
||||
posX: number;
|
||||
posY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rows: number;
|
||||
columns: number;
|
||||
positions: Position3D[];
|
||||
}
|
||||
|
||||
export interface Room3D {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: string;
|
||||
posX: number;
|
||||
posY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string | null;
|
||||
sections: Section3D[];
|
||||
}
|
||||
|
||||
export interface Floor3DData {
|
||||
floor: {
|
||||
id: string;
|
||||
name: string;
|
||||
number: number;
|
||||
width: number;
|
||||
height: number;
|
||||
building: string;
|
||||
property: string;
|
||||
};
|
||||
rooms: Room3D[];
|
||||
stats: {
|
||||
totalRooms: number;
|
||||
totalSections: number;
|
||||
totalPositions: number;
|
||||
occupiedPositions: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ========================================
|
||||
// API Functions
|
||||
// ========================================
|
||||
|
|
@ -115,6 +180,13 @@ export const layoutApi = {
|
|||
return response.data;
|
||||
},
|
||||
|
||||
// 3D Visualization
|
||||
async getFloor3D(id: string): Promise<Floor3DData> {
|
||||
const response = await api.get(`/layout/floors/${id}/3d`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
async saveFloorLayout(floorId: string, rooms: LayoutRoom[]): Promise<LayoutFloor> {
|
||||
const roomsForApi = rooms.map(r => ({
|
||||
id: r.id,
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||
items: [
|
||||
{ id: 'audit', label: 'Audit Log', path: '/compliance/audit', icon: ClipboardList },
|
||||
{ id: 'documents', label: 'SOP Library', path: '/compliance/documents', icon: FileText },
|
||||
{ id: 'facility-3d', label: '3D Facility View', path: '/facility-3d', icon: Grid3X3, minRole: 'MANAGER' },
|
||||
{ id: 'layout', label: 'Layout Designer', path: '/layout-designer', icon: Grid3X3, minRole: 'ADMIN' },
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ import { useState, useEffect } from 'react';
|
|||
import {
|
||||
FileText, Search, Plus, Clock, User, Check, X,
|
||||
AlertCircle, Eye, Edit, FolderOpen, Book,
|
||||
ClipboardList, FileCheck, HelpCircle, History, Download, Loader2
|
||||
ClipboardList, FileCheck, HelpCircle, History, Download, Loader2,
|
||||
LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { documentsApi, Document, DocumentType, DocumentStatus, DocumentVersion } from '../lib/documentsApi';
|
||||
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
||||
|
||||
const TYPE_CONFIG: Record<DocumentType, { icon: React.ElementType; badge: string; label: string }> = {
|
||||
const TYPE_CONFIG: Record<DocumentType, { icon: LucideIcon; badge: string; label: string }> = {
|
||||
SOP: { icon: Book, badge: 'badge-accent', label: 'SOP' },
|
||||
POLICY: { icon: FileCheck, badge: 'badge-accent', label: 'Policy' },
|
||||
FORM: { icon: ClipboardList, badge: 'badge-accent', label: 'Form' },
|
||||
|
|
@ -16,6 +17,7 @@ const TYPE_CONFIG: Record<DocumentType, { icon: React.ElementType; badge: string
|
|||
OTHER: { icon: FileText, badge: 'badge', label: 'Document' }
|
||||
};
|
||||
|
||||
|
||||
const STATUS_CONFIG: Record<DocumentStatus, { badge: string; label: string }> = {
|
||||
DRAFT: { badge: 'badge', label: 'Draft' },
|
||||
PENDING_APPROVAL: { badge: 'badge-warning', label: 'Pending' },
|
||||
|
|
|
|||
452
frontend/src/pages/Facility3DViewerPage.tsx
Normal file
452
frontend/src/pages/Facility3DViewerPage.tsx
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import { OrbitControls, Text, PerspectiveCamera } from '@react-three/drei';
|
||||
import { Loader2, Grid3X3, ChevronDown, ZoomIn, ZoomOut, RotateCcw, Building } from 'lucide-react';
|
||||
import { layoutApi, Floor3DData, Room3D, Section3D, Position3D } from '../lib/layoutApi';
|
||||
import * as THREE from 'three';
|
||||
|
||||
// Color mapping for room types
|
||||
const ROOM_COLORS: Record<string, string> = {
|
||||
VEG: '#22c55e', // Green
|
||||
FLOWER: '#a855f7', // Purple
|
||||
MOTHER: '#3b82f6', // Blue
|
||||
DRY: '#f97316', // Orange
|
||||
CURE: '#92400e', // Brown
|
||||
CLONE: '#14b8a6', // Teal
|
||||
FACILITY: '#6b7280' // Gray
|
||||
};
|
||||
|
||||
// Status colors for positions
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
EMPTY: '#4b5563',
|
||||
OCCUPIED: '#22c55e',
|
||||
RESERVED: '#3b82f6',
|
||||
DAMAGED: '#dc2626'
|
||||
};
|
||||
|
||||
// Scale factor - convert pixels to 3D units
|
||||
const SCALE = 0.01;
|
||||
|
||||
// Ground plane component
|
||||
function Ground({ width, height }: { width: number; height: number }) {
|
||||
return (
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[width * SCALE / 2, -0.01, height * SCALE / 2]}>
|
||||
<planeGeometry args={[width * SCALE, height * SCALE]} />
|
||||
<meshStandardMaterial color="#1f2937" />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid overlay
|
||||
function GridOverlay({ width, height }: { width: number; height: number }) {
|
||||
return (
|
||||
<gridHelper
|
||||
args={[Math.max(width, height) * SCALE, 20, '#374151', '#374151']}
|
||||
position={[width * SCALE / 2, 0.001, height * SCALE / 2]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Room box component
|
||||
function Room3DBox({ room, onClick, isSelected }: {
|
||||
room: Room3D;
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const color = ROOM_COLORS[room.type] || '#6b7280';
|
||||
const mesh = useRef<THREE.Mesh>(null);
|
||||
|
||||
// Center the room
|
||||
const x = room.posX * SCALE + (room.width * SCALE) / 2;
|
||||
const z = room.posY * SCALE + (room.height * SCALE) / 2;
|
||||
const roomHeight = 2; // Room height in 3D units
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Floor */}
|
||||
<mesh
|
||||
ref={mesh}
|
||||
position={[x, 0.02, z]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
onClick={onClick}
|
||||
>
|
||||
<planeGeometry args={[room.width * SCALE, room.height * SCALE]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
opacity={isSelected ? 0.6 : 0.3}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Walls (outline) */}
|
||||
<lineSegments position={[x, roomHeight / 2, z]}>
|
||||
<edgesGeometry attach="geometry" args={[new THREE.BoxGeometry(room.width * SCALE, roomHeight, room.height * SCALE)]} />
|
||||
<lineBasicMaterial attach="material" color={isSelected ? '#ffffff' : color} linewidth={2} />
|
||||
</lineSegments>
|
||||
|
||||
{/* Room label */}
|
||||
<Text
|
||||
position={[x, 0.1, z]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={0.3}
|
||||
color="white"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
>
|
||||
{room.code}
|
||||
</Text>
|
||||
|
||||
{/* Sections */}
|
||||
{room.sections.map(section => (
|
||||
<Section3DBox
|
||||
key={section.id}
|
||||
section={section}
|
||||
roomX={room.posX}
|
||||
roomY={room.posY}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// Section (table/rack) component
|
||||
function Section3DBox({ section, roomX, roomY }: {
|
||||
section: Section3D;
|
||||
roomX: number;
|
||||
roomY: number;
|
||||
}) {
|
||||
const x = (roomX + section.posX) * SCALE + (section.width * SCALE) / 2;
|
||||
const z = (roomY + section.posY) * SCALE + (section.height * SCALE) / 2;
|
||||
const tableHeight = 0.5;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Table surface */}
|
||||
<mesh position={[x, tableHeight, z]}>
|
||||
<boxGeometry args={[section.width * SCALE, 0.05, section.height * SCALE]} />
|
||||
<meshStandardMaterial color="#374151" />
|
||||
</mesh>
|
||||
|
||||
{/* Positions (plants) */}
|
||||
{section.positions.map((pos, idx) => {
|
||||
const cellWidth = section.width / section.columns;
|
||||
const cellHeight = section.height / section.rows;
|
||||
const posX = (roomX + section.posX + (pos.column - 0.5) * cellWidth) * SCALE;
|
||||
const posZ = (roomY + section.posY + (pos.row - 0.5) * cellHeight) * SCALE;
|
||||
|
||||
return (
|
||||
<PlantPosition
|
||||
key={pos.id}
|
||||
position={pos}
|
||||
x={posX}
|
||||
z={posZ}
|
||||
y={tableHeight + 0.1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// Plant position component
|
||||
function PlantPosition({ position, x, y, z }: {
|
||||
position: Position3D;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}) {
|
||||
const color = STATUS_COLORS[position.status] || STATUS_COLORS.EMPTY;
|
||||
const isOccupied = position.status === 'OCCUPIED';
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<mesh
|
||||
position={[x, y + (isOccupied ? 0.15 : 0), z]}
|
||||
onPointerOver={() => setHovered(true)}
|
||||
onPointerOut={() => setHovered(false)}
|
||||
>
|
||||
{isOccupied ? (
|
||||
<cylinderGeometry args={[0.05, 0.03, 0.3, 8]} />
|
||||
) : (
|
||||
<sphereGeometry args={[0.03, 8, 8]} />
|
||||
)}
|
||||
<meshStandardMaterial
|
||||
color={hovered ? '#ffffff' : color}
|
||||
emissive={hovered ? color : '#000000'}
|
||||
emissiveIntensity={hovered ? 0.5 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
// Camera controller for auto-framing
|
||||
function CameraController({ floorWidth, floorHeight }: { floorWidth: number; floorHeight: number }) {
|
||||
const { camera } = useThree();
|
||||
|
||||
useEffect(() => {
|
||||
const maxDim = Math.max(floorWidth, floorHeight) * SCALE;
|
||||
camera.position.set(maxDim * 0.8, maxDim * 1.2, maxDim * 0.8);
|
||||
camera.lookAt(floorWidth * SCALE / 2, 0, floorHeight * SCALE / 2);
|
||||
}, [camera, floorWidth, floorHeight]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Main 3D Scene
|
||||
function Facility3DScene({ data, selectedRoomId, onSelectRoom }: {
|
||||
data: Floor3DData;
|
||||
selectedRoomId: string | null;
|
||||
onSelectRoom: (id: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Lighting */}
|
||||
<ambientLight intensity={0.4} />
|
||||
<directionalLight position={[10, 20, 10]} intensity={0.8} castShadow />
|
||||
<pointLight position={[0, 10, 0]} intensity={0.3} />
|
||||
|
||||
{/* Ground and grid */}
|
||||
<Ground width={data.floor.width} height={data.floor.height} />
|
||||
<GridOverlay width={data.floor.width} height={data.floor.height} />
|
||||
|
||||
{/* Rooms */}
|
||||
{data.rooms.map(room => (
|
||||
<Room3DBox
|
||||
key={room.id}
|
||||
room={room}
|
||||
isSelected={selectedRoomId === room.id}
|
||||
onClick={() => onSelectRoom(selectedRoomId === room.id ? null : room.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Controls and camera */}
|
||||
<CameraController floorWidth={data.floor.width} floorHeight={data.floor.height} />
|
||||
<OrbitControls
|
||||
enablePan={true}
|
||||
enableZoom={true}
|
||||
enableRotate={true}
|
||||
maxPolarAngle={Math.PI / 2.1}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Floor selector dropdown
|
||||
function FloorSelector({
|
||||
floors,
|
||||
currentFloorId,
|
||||
onSelect
|
||||
}: {
|
||||
floors: { id: string; name: string; buildingName: string }[];
|
||||
currentFloorId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const current = floors.find(f => f.id === currentFloorId);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-secondary rounded-lg border border-default hover:border-strong transition-colors"
|
||||
>
|
||||
<Building size={16} className="text-tertiary" />
|
||||
<span className="text-sm font-medium text-primary">
|
||||
{current ? `${current.buildingName} - ${current.name}` : 'Select Floor'}
|
||||
</span>
|
||||
<ChevronDown size={16} className={`text-tertiary transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-64 bg-secondary border border-default rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto">
|
||||
{floors.map(floor => (
|
||||
<button
|
||||
key={floor.id}
|
||||
onClick={() => { onSelect(floor.id); setIsOpen(false); }}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-tertiary transition-colors ${floor.id === currentFloorId ? 'bg-accent-muted text-accent' : 'text-primary'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{floor.buildingName}</span>
|
||||
<span className="text-tertiary ml-2">›</span>
|
||||
<span className="ml-2">{floor.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stats panel
|
||||
function StatsPanel({ stats }: { stats: Floor3DData['stats'] }) {
|
||||
return (
|
||||
<div className="flex gap-4 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-tertiary">Rooms:</span>
|
||||
<span className="font-medium text-primary">{stats.totalRooms}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-tertiary">Tables:</span>
|
||||
<span className="font-medium text-primary">{stats.totalSections}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-tertiary">Positions:</span>
|
||||
<span className="font-medium text-primary">{stats.totalPositions}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-tertiary">Occupied:</span>
|
||||
<span className="font-medium text-success">{stats.occupiedPositions}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main component
|
||||
export default function Facility3DViewerPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [floors, setFloors] = useState<{ id: string; name: string; buildingName: string }[]>([]);
|
||||
const [currentFloorId, setCurrentFloorId] = useState<string | null>(null);
|
||||
const [floorData, setFloorData] = useState<Floor3DData | null>(null);
|
||||
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
|
||||
|
||||
// Load properties and floors
|
||||
useEffect(() => {
|
||||
const loadFloors = async () => {
|
||||
try {
|
||||
const properties = await layoutApi.getProperties();
|
||||
const allFloors: { id: string; name: string; buildingName: string }[] = [];
|
||||
|
||||
properties.forEach(prop => {
|
||||
prop.buildings.forEach(building => {
|
||||
building.floors.forEach(floor => {
|
||||
allFloors.push({
|
||||
id: floor.id,
|
||||
name: floor.name,
|
||||
buildingName: building.name
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
setFloors(allFloors);
|
||||
if (allFloors.length > 0) {
|
||||
setCurrentFloorId(allFloors[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load facility data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFloors();
|
||||
}, []);
|
||||
|
||||
// Load 3D data when floor changes
|
||||
useEffect(() => {
|
||||
if (!currentFloorId) return;
|
||||
|
||||
const load3DData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await layoutApi.getFloor3D(currentFloorId);
|
||||
setFloorData(data);
|
||||
} catch (err) {
|
||||
setError('Failed to load 3D floor data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load3DData();
|
||||
}, [currentFloorId]);
|
||||
|
||||
if (loading && !floorData) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-primary">
|
||||
<div className="text-center">
|
||||
<Loader2 size={32} className="animate-spin text-accent mx-auto mb-4" />
|
||||
<p className="text-tertiary">Loading facility...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !floorData) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-primary">
|
||||
<div className="text-center">
|
||||
<Grid3X3 size={48} className="text-danger mx-auto mb-4" />
|
||||
<p className="text-danger">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!floorData) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-primary">
|
||||
<div className="text-center">
|
||||
<Building size={48} className="text-tertiary mx-auto mb-4" />
|
||||
<p className="text-secondary">No facility data available</p>
|
||||
<p className="text-tertiary text-sm mt-2">Create a facility in Settings to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-primary">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-default">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-lg font-semibold text-primary flex items-center gap-2">
|
||||
<Grid3X3 size={20} className="text-accent" />
|
||||
3D Facility View
|
||||
</h1>
|
||||
<FloorSelector
|
||||
floors={floors}
|
||||
currentFloorId={currentFloorId}
|
||||
onSelect={setCurrentFloorId}
|
||||
/>
|
||||
</div>
|
||||
<StatsPanel stats={floorData.stats} />
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 px-4 py-2 border-b border-default bg-secondary text-xs">
|
||||
<span className="text-tertiary">Rooms:</span>
|
||||
{Object.entries(ROOM_COLORS).map(([type, color]) => (
|
||||
<div key={type} className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: color }} />
|
||||
<span className="text-secondary">{type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 3D Canvas */}
|
||||
<div className="flex-1 relative">
|
||||
<Canvas shadows>
|
||||
<Facility3DScene
|
||||
data={floorData}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onSelectRoom={setSelectedRoomId}
|
||||
/>
|
||||
</Canvas>
|
||||
|
||||
{/* Info overlay */}
|
||||
{selectedRoomId && (
|
||||
<div className="absolute bottom-4 left-4 bg-secondary/90 backdrop-blur border border-default rounded-lg p-4">
|
||||
<h3 className="font-medium text-primary">
|
||||
{floorData.rooms.find(r => r.id === selectedRoomId)?.name}
|
||||
</h3>
|
||||
<p className="text-xs text-tertiary mt-1">
|
||||
{floorData.rooms.find(r => r.id === selectedRoomId)?.sections.length || 0} tables
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -40,6 +40,10 @@ const EnvironmentDashboard = lazy(() => import('./pages/EnvironmentDashboard'));
|
|||
const FinancialDashboard = lazy(() => import('./pages/FinancialDashboard'));
|
||||
const InsightsDashboard = lazy(() => import('./pages/InsightsDashboard'));
|
||||
|
||||
// 3D Facility Viewer
|
||||
const Facility3DViewerPage = lazy(() => import('./pages/Facility3DViewerPage'));
|
||||
|
||||
|
||||
// Loading spinner component for Suspense fallbacks
|
||||
const PageLoader = () => (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
|
|
@ -201,4 +205,21 @@ export const router = createBrowserRouter([
|
|||
),
|
||||
errorElement: <RouterErrorPage />,
|
||||
},
|
||||
// 3D Facility Viewer - Full screen
|
||||
{
|
||||
path: '/facility-3d',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<Suspense fallback={
|
||||
<div className="h-screen w-screen bg-slate-950 flex items-center justify-center">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
}>
|
||||
<Facility3DViewerPage />
|
||||
</Suspense>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
errorElement: <RouterErrorPage />,
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue