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> {
|
async getDocument(id: string): Promise<Document> {
|
||||||
const response = await api.get(`/api/documents/${id}`);
|
const response = await api.get(`/documents/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -78,36 +78,36 @@ export const documentsApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateDocument(id: string, data: UpdateDocumentData): Promise<Document> {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteDocument(id: string): Promise<void> {
|
async deleteDocument(id: string): Promise<void> {
|
||||||
await api.delete(`/api/documents/${id}`);
|
await api.delete(`/documents/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getVersions(id: string): Promise<DocumentVersion[]> {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async submitForApproval(id: string): Promise<Document> {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async approveDocument(id: string): Promise<Document> {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async rejectDocument(id: string, reason?: string): Promise<Document> {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async acknowledgeDocument(id: string): Promise<{ success: boolean; acknowledgedAt: string }> {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -117,7 +117,7 @@ export const documentsApi = {
|
||||||
acknowledged: DocumentAck[];
|
acknowledged: DocumentAck[];
|
||||||
pending: { id: string; name: string }[];
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -126,3 +126,4 @@ export const documentsApi = {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,71 @@ export interface LayoutSection {
|
||||||
positions?: LayoutPosition[];
|
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
|
// API Functions
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -115,6 +180,13 @@ export const layoutApi = {
|
||||||
return response.data;
|
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> {
|
async saveFloorLayout(floorId: string, rooms: LayoutRoom[]): Promise<LayoutFloor> {
|
||||||
const roomsForApi = rooms.map(r => ({
|
const roomsForApi = rooms.map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||||
items: [
|
items: [
|
||||||
{ id: 'audit', label: 'Audit Log', path: '/compliance/audit', icon: ClipboardList },
|
{ id: 'audit', label: 'Audit Log', path: '/compliance/audit', icon: ClipboardList },
|
||||||
{ id: 'documents', label: 'SOP Library', path: '/compliance/documents', icon: FileText },
|
{ 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' },
|
{ id: 'layout', label: 'Layout Designer', path: '/layout-designer', icon: Grid3X3, minRole: 'ADMIN' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@ import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
FileText, Search, Plus, Clock, User, Check, X,
|
FileText, Search, Plus, Clock, User, Check, X,
|
||||||
AlertCircle, Eye, Edit, FolderOpen, Book,
|
AlertCircle, Eye, Edit, FolderOpen, Book,
|
||||||
ClipboardList, FileCheck, HelpCircle, History, Download, Loader2
|
ClipboardList, FileCheck, HelpCircle, History, Download, Loader2,
|
||||||
|
LucideIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { documentsApi, Document, DocumentType, DocumentStatus, DocumentVersion } from '../lib/documentsApi';
|
import { documentsApi, Document, DocumentType, DocumentStatus, DocumentVersion } from '../lib/documentsApi';
|
||||||
import { PageHeader, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
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' },
|
SOP: { icon: Book, badge: 'badge-accent', label: 'SOP' },
|
||||||
POLICY: { icon: FileCheck, badge: 'badge-accent', label: 'Policy' },
|
POLICY: { icon: FileCheck, badge: 'badge-accent', label: 'Policy' },
|
||||||
FORM: { icon: ClipboardList, badge: 'badge-accent', label: 'Form' },
|
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' }
|
OTHER: { icon: FileText, badge: 'badge', label: 'Document' }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<DocumentStatus, { badge: string; label: string }> = {
|
const STATUS_CONFIG: Record<DocumentStatus, { badge: string; label: string }> = {
|
||||||
DRAFT: { badge: 'badge', label: 'Draft' },
|
DRAFT: { badge: 'badge', label: 'Draft' },
|
||||||
PENDING_APPROVAL: { badge: 'badge-warning', label: 'Pending' },
|
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 FinancialDashboard = lazy(() => import('./pages/FinancialDashboard'));
|
||||||
const InsightsDashboard = lazy(() => import('./pages/InsightsDashboard'));
|
const InsightsDashboard = lazy(() => import('./pages/InsightsDashboard'));
|
||||||
|
|
||||||
|
// 3D Facility Viewer
|
||||||
|
const Facility3DViewerPage = lazy(() => import('./pages/Facility3DViewerPage'));
|
||||||
|
|
||||||
|
|
||||||
// Loading spinner component for Suspense fallbacks
|
// Loading spinner component for Suspense fallbacks
|
||||||
const PageLoader = () => (
|
const PageLoader = () => (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
|
|
@ -201,4 +205,21 @@ export const router = createBrowserRouter([
|
||||||
),
|
),
|
||||||
errorElement: <RouterErrorPage />,
|
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