feat: 3D Facility Viewer with React Three Fiber
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

- 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:
fullsizemalt 2025-12-12 22:38:06 -08:00
parent 1258aebb9f
commit 36dbeb23c3
6 changed files with 560 additions and 11 deletions

View file

@ -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;
} }
}; };

View file

@ -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,

View file

@ -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' },
] ]
}, },

View file

@ -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' },

View 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>
);
}

View file

@ -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 />,
},
]); ]);