fix: resolve API prefix double-url bugs and stabilize 3D viewer
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

This commit is contained in:
fullsizemalt 2025-12-12 23:28:59 -08:00
parent 2036011fdc
commit 86ad94f812
4 changed files with 128 additions and 425 deletions

View file

@ -19,194 +19,51 @@
- Password hashing with bcrypt
- JWT token generation (access 15m + refresh 7d)
- Updated login endpoint with proper tokens
- Added refresh & logout endpoints
- Created 4 test users (all roles)
- **Time**: 1 hour
- Updated login endpoint with prop## Features Modified, Added, or Removed:
### Sprint 2.5: Mobile-First Foundation ✅
- **`backend/src/controllers/walkthrough.controller.ts` (Modified):**
- **Added `getTodaysWalkthrough`:** Logic to fetch the walkthrough created today (00:00 - 23:59).
- **Improved Error Handling:** Added checks for user existence before creating walkthroughs to prevent 500 errors with stale tokens (Foreign Key violations).
- **`backend/src/routes/walkthrough.routes.ts` (Modified):**
- Added `GET /today` endpoint.
- **`backend/src/routes/layout.routes.ts` (Modified):**
- **Added 3D Data Endpoint:** `GET /floors/:id/3d` returns optimized hierarchy (Floor -> Rooms -> Sections -> Positions) for 3D rendering.
- **`frontend/src/lib/layoutApi.ts` (Modified):**
- Added `getFloor3D` method and `Floor3DData` types.
- **Fixed API Prefix:** Removed double `/api` prefix from all calls.
- **`frontend/src/lib/{auditApi, documentsApi, messagingApi, uploadApi}.ts` (Modified):**
- **Fixed API Prefix:** Removed double `/api` prefix from all calls.
- **`frontend/src/pages/Facility3DViewerPage.tsx` (New)::**
- **Interactive 3D Scene:** Uses React Three Fiber to render the facility.
- **Features:** Orbit controls, zoom/pan, room color coding, plant positions, floor selection, stats panel.
- **`frontend/src/pages/DocumentsPage.tsx` (Modified):**
- Fixed TypeScript validation error for dynamic Lucide icons. (Imported `LucideIcon` type).
- **`docs/SPEC-KIT-AUDIT.md` (New):**
- Comprehensive audit of all 20+ specs vs. implementation code and demo data.
- Mobile-first Tailwind config
- Touch-friendly base styles (44px+ targets)
- 777 Wolfpack branding integration
- Mobile-optimized LoginPage
- Splash screen component
- **Time**: 45 minutes
## Dependencies and APIs
---
- **New Endpoint:** `GET /api/layout/floors/:id/3d` (Optimized 3D data).
- **New Endpoint:** `GET /api/walkthroughs/today` (Check status).
- **Fix:** Removed `/api` prefix from front-end API libraries (was causing `/api/api/...` 404s).
## 🏗️ **What's Built**
## Design Decisions
### Backend (Fully Functional)
- **3D Viewer Strategy:** Implemented a read-only 3D viewer first (`Facility3DViewerPage`) separate from the complex `LayoutDesigner`. This fulfills the urgent need to "visualize plant locations" without blocking on a complex editor refactor.
- **Stale Token Handling:** explicitly check for user existence in `createWalkthrough` to return 401 instead of 500 when the DB is re-seeded but client has old token.
- ✅ Fastify server with TypeScript
- ✅ PostgreSQL + Prisma ORM
- ✅ Bcrypt password hashing
- ✅ JWT authentication (access + refresh tokens)
- ✅ Auth endpoints: `/login`, `/refresh`, `/logout`, `/me`
- ✅ Seed script with hashed passwords
- ✅ Health check working
## Existing Blockers and Bugs
### Frontend (Mobile-First Foundation)
1. **Stale JWT Tokens:** Users may need to log out and back in after a DB re-seed (now handled gracefully with 401).
2. **Layout Designer Complexity:** The drag-and-drop designer is still complex; we are testing if the 3D viewer + simplified input forms is a better direction.
- ✅ Vite + React + TypeScript
- ✅ Tailwind CSS with mobile-first breakpoints
- ✅ Touch-friendly base styles
- ✅ 777 Wolfpack branding
- ✅ Responsive LoginPage
- ✅ Splash screen component
- ✅ Dark mode support
## Next Steps to Solve the Problem
---
## 🔐 **Test Users (All Ready)**
| Email | Password | Role | Rate |
|-------|----------|------|------|
| <admin@runfoo.run> | password123 | OWNER | $50/hr |
| <manager@runfoo.run> | password123 | MANAGER | $35/hr |
| <grower@runfoo.run> | password123 | GROWER | $30/hr |
| <staff@runfoo.run> | password123 | STAFF | $20/hr |
All passwords are **bcrypt hashed** in the database.
---
## 📱 **Mobile-First Features**
### Responsive Breakpoints
```
xs: 375px (Large phones)
sm: 640px (Small tablets portrait)
md: 768px (Tablets portrait - PRIMARY TARGET)
lg: 1024px (Tablets landscape)
xl: 1280px (Desktop)
2xl: 1536px (Large desktop)
```
### Touch Optimizations
- **Minimum tap targets**: 44px (buttons are 56px)
- **Font size**: 16px minimum (prevents iOS zoom)
- **Input height**: 44px minimum
- **Smooth scrolling**: Enabled
- **Tap highlights**: Removed
- **Touch manipulation**: Optimized
### 777 Wolfpack Branding
- Logo: `/frontend/public/assets/logo-777-wolfpack.jpg`
- Displayed on login page
- "777 Wolfpack Edition" subtitle
- Team name in footer
- Blue/slate color scheme
---
## ⏭️ **What's Next**
### Immediate Priorities
#### 1. Complete Mobile-First UI (1-1.5 hours)
- [ ] Mobile navigation (bottom nav mobile, side nav tablet+)
- [ ] Responsive Dashboard layout
- [ ] Mobile-optimized Rooms page
- [ ] Mobile-optimized Batches page
- [ ] Touch-friendly Timeclock page
#### 2. Finish Sprint 2 Auth (1 hour)
- [ ] Auth middleware (`authenticate`, `authorize`)
- [ ] Protect all API routes
- [ ] RBAC enforcement
- [ ] Frontend token management
#### 3. Deploy & Test (30 min)
- [ ] Deploy to nexus-vector
- [ ] Re-seed database with new users
- [ ] Test on actual iPad/tablet
- [ ] Get 777 Wolfpack team feedback
---
## 🚀 **Deployment Ready**
### What Needs to Happen
1. **Push to Forgejo** (when it's back up)
2. **Re-seed database** on nexus-vector with new users
3. **Rebuild containers** to get new frontend assets
4. **Test login** with new token format
### Commands
```bash
# On nexus-vector
cd /srv/containers/ca-grow-ops-manager
git pull origin main
docker compose build
docker compose exec backend npx prisma db seed
docker compose up -d
```
---
## 📊 **Progress Metrics**
| Category | Progress | Status |
|----------|----------|--------|
| Backend Infrastructure | 100% | ✅ Complete |
| Authentication Core | 80% | 🟡 Needs middleware |
| Mobile-First UI | 30% | 🟡 In Progress |
| RBAC | 0% | ⏳ Planned |
| Testing | 0% | ⏳ Planned |
**Overall Phase 1**: ~60% Complete
---
## 🎯 **Key Achievements**
1. **Solid Backend Foundation**
- Production-ready auth with bcrypt + JWT
- Proper token management (access + refresh)
- Clean TypeScript architecture
2. **Mobile-First Approach**
- Touch-optimized from the ground up
- Tablet-first for cultivation floor
- 777 Wolfpack branding integrated
3. **Clean Documentation**
- Sprint plans documented
- Credentials documented
- Progress tracked
4. **Short, Focused Sprints**
- Each sprint ~30-60 minutes
- Clear deliverables
- Thorough documentation
---
## 💡 **Lessons Learned**
1. **Mobile-first is critical** for cultivation floor apps
2. **Touch targets matter** - 44px minimum is non-negotiable
3. **Branding early** helps team buy-in (777 Wolfpack)
4. **Short sprints work** - easier to maintain context across sessions
5. **Documentation is key** - helps recover from terminated sessions
---
## 🙏 **Thank You!**
Great collaboration! The 777 Wolfpack team is going to have a solid, mobile-optimized cultivation management tool.
**Next session**: Continue mobile-first refactor or finish auth middleware - your choice!
1. **Verify 3D Viewer:** User to test `https://777wolfpack.runfoo.run/facility-3d`.
2. **Verify Walkthrough:** Confirm "Already Completed" status and that "Oops" error is resolved (via re-login).
3. **Simplify Layout Tools:** Based on feedback, potentially replace the Drag-and-Drop designer with a "Wizard" style form that populates the 3D view.
4. **METRC Integration:** Begin Phase 2 (Metrc) based on the audit findings.
sh auth middleware - your choice!
---

View file

@ -50,27 +50,21 @@ export const messagingApi = {
},
async markRead(id: string): Promise<void> {
await api.post(`/api/messaging/announcements/${id}/read`);
await api.post(`/messaging/announcements/${id}/read`);
},
async acknowledge(id: string): Promise<{ success: boolean; acknowledgedAt: string }> {
const response = await api.post(`/api/messaging/announcements/${id}/acknowledge`);
const response = await api.post(`/messaging/announcements/${id}/acknowledge`);
return response.data;
},
async getAcknowledgements(id: string): Promise<{
announcement: { id: string; title: string };
requiresAck: boolean;
totalUsers: number;
acknowledged: number;
pending: { id: string; name: string; email: string }[];
}> {
const response = await api.get(`/api/messaging/announcements/${id}/acks`);
async getAcknowledgements(id: string): Promise<any> {
const response = await api.get(`/messaging/announcements/${id}/acks`);
return response.data;
},
// Shift Notes
async getShiftNotes(params?: { roomId?: string; batchId?: string; limit?: number }): Promise<ShiftNote[]> {
async getShiftNotes(params?: { date?: string; limit?: number; roomId?: string; batchId?: string }): Promise<ShiftNote[]> {
const response = await api.get('/messaging/shift-notes', { params });
return response.data;
},
@ -86,7 +80,9 @@ export const messagingApi = {
return response.data;
},
async markShiftNoteRead(id: string): Promise<void> {
await api.post(`/api/messaging/shift-notes/${id}/read`);
await api.post(`/messaging/shift-notes/${id}/read`);
}
};

View file

@ -52,7 +52,7 @@ export async function uploadPhoto(
const formData = new FormData();
formData.append('file', compressed.file, file.name);
const response = await api.post<UploadedPhoto>('/api/upload/photo', formData, {
const response = await api.post<UploadedPhoto>('/upload/photo', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
if (progressEvent.total && onProgress) {
@ -82,7 +82,7 @@ export async function uploadPhotos(
formData.append('files', photo.file, files[index].name);
});
const response = await api.post<BulkUploadResult>('/api/upload/photos', formData, {
const response = await api.post<BulkUploadResult>('/upload/photos', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progressEvent) => {
if (progressEvent.total && onProgress) {
@ -93,6 +93,7 @@ export async function uploadPhotos(
}
});
return response.data;
}
@ -116,7 +117,7 @@ export function getPhotoUrl(path: string, size: 'thumb' | 'medium' | 'full' = 'm
* Delete a photo
*/
export async function deletePhoto(photoId: string): Promise<void> {
await api.delete(`/api/upload/photo/${photoId}`);
await api.delete(`/upload/photo/${photoId}`);
}
/**

View file

@ -1,11 +1,11 @@
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 { useEffect, useState, useRef, Suspense } from 'react';
import { Canvas, useThree } from '@react-three/fiber';
import { OrbitControls, Text } from '@react-three/drei';
import { Loader2, Grid, ChevronDown, Building, AlertTriangle } from 'lucide-react';
import { layoutApi, Floor3DData, Room3D, Section3D, Position3D } from '../lib/layoutApi';
import * as THREE from 'three';
// Color mapping for room types
// Color mapping for room types - with fallback
const ROOM_COLORS: Record<string, string> = {
VEG: '#22c55e', // Green
FLOWER: '#a855f7', // Purple
@ -13,7 +13,8 @@ const ROOM_COLORS: Record<string, string> = {
DRY: '#f97316', // Orange
CURE: '#92400e', // Brown
CLONE: '#14b8a6', // Teal
FACILITY: '#6b7280' // Gray
FACILITY: '#6b7280', // Gray
DEFAULT: '#9ca3af' // Fallback
};
// Status colors for positions
@ -21,14 +22,14 @@ const STATUS_COLORS: Record<string, string> = {
EMPTY: '#4b5563',
OCCUPIED: '#22c55e',
RESERVED: '#3b82f6',
DAMAGED: '#dc2626'
DAMAGED: '#dc2626',
DEFAULT: '#6b7280'
};
// Scale factor - convert pixels to 3D units
const SCALE = 0.01;
// Ground plane component
function Ground({ width, height }: { width: number; height: number }) {
if (!width || !height) return null;
return (
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[width * SCALE / 2, -0.01, height * SCALE / 2]}>
<planeGeometry args={[width * SCALE, height * SCALE]} />
@ -37,8 +38,8 @@ function Ground({ width, height }: { width: number; height: number }) {
);
}
// Grid overlay
function GridOverlay({ width, height }: { width: number; height: number }) {
if (!width || !height) return null;
return (
<gridHelper
args={[Math.max(width, height) * SCALE, 20, '#374151', '#374151']}
@ -47,170 +48,113 @@ function GridOverlay({ width, height }: { width: number; height: number }) {
);
}
// Room box component
function Room3DBox({ room, onClick, isSelected }: {
room: Room3D;
onClick: () => void;
isSelected: boolean;
}) {
const color = ROOM_COLORS[room.type] || '#6b7280';
function Room3DBox({ room, onClick, isSelected }: { room: Room3D; onClick: () => void; isSelected: boolean }) {
if (!room) return null;
const color = ROOM_COLORS[room.type] || ROOM_COLORS.DEFAULT;
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
// Safely calculate position
const x = (room.posX || 0) * SCALE + ((room.width || 0) * SCALE) / 2;
const z = (room.posY || 0) * SCALE + ((room.height || 0) * SCALE) / 2;
const roomHeight = 2;
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 ref={mesh} position={[x, 0.02, z]} rotation={[-Math.PI / 2, 0, 0]} onClick={onClick}>
<planeGeometry args={[(room.width || 0) * SCALE, (room.height || 0) * 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)]} />
<edgesGeometry attach="geometry" args={[new THREE.BoxGeometry((room.width || 0) * SCALE, roomHeight, (room.height || 0) * 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>
{room.code && (
<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}
/>
{room.sections?.map(section => (
<Section3DBox key={section.id} section={section} roomX={room.posX || 0} roomY={room.posY || 0} />
))}
</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;
function Section3DBox({ section, roomX, roomY }: { section: Section3D; roomX: number; roomY: number }) {
if (!section) return null;
const x = (roomX + (section.posX || 0)) * SCALE + ((section.width || 0) * SCALE) / 2;
const z = (roomY + (section.posY || 0)) * SCALE + ((section.height || 0) * 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]} />
<boxGeometry args={[(section.width || 0) * SCALE, 0.05, (section.height || 0) * 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;
{section.positions?.map(pos => {
const rows = section.rows || 1;
const cols = section.columns || 1;
const cellWidth = (section.width || 0) / cols;
const cellHeight = (section.height || 0) / rows;
const posX = (roomX + (section.posX || 0) + ((pos.column || 1) - 0.5) * cellWidth) * SCALE;
const posZ = (roomY + (section.posY || 0) + ((pos.row || 1) - 0.5) * cellHeight) * SCALE;
return (
<PlantPosition
key={pos.id}
position={pos}
x={posX}
z={posZ}
y={tableHeight + 0.1}
/>
);
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;
function PlantPosition({ position, x, y, z }: { position: Position3D; x: number; y: number; z: number }) {
if (!position) return null;
const color = STATUS_COLORS[position.status] || STATUS_COLORS.DEFAULT;
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 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(() => {
if (!floorWidth || !floorHeight) return;
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;
}) {
function Facility3DScene({ data, selectedRoomId, onSelectRoom }: { data: Floor3DData; selectedRoomId: string | null; onSelectRoom: (id: string | null) => void; }) {
if (!data?.floor) return null;
return (
<>
{/* Lighting */}
<ambientLight intensity={0.4} />
<directionalLight position={[10, 20, 10]} intensity={0.8} castShadow />
<pointLight position={[0, 10, 0]} intensity={0.3} />
<ambientLight intensity={0.5} />
<directionalLight position={[10, 20, 10]} intensity={0.8} />
{/* 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 => (
{data.rooms?.map(room => (
<Room3DBox
key={room.id}
room={room}
@ -221,35 +165,18 @@ function Facility3DScene({ data, selectedRoomId, onSelectRoom }: {
{/* Controls and camera */}
<CameraController floorWidth={data.floor.width} floorHeight={data.floor.height} />
<OrbitControls
enablePan={true}
enableZoom={true}
enableRotate={true}
maxPolarAngle={Math.PI / 2.1}
/>
<OrbitControls enablePan={true} enableZoom={true} enableRotate={true} />
</>
);
}
// Floor selector dropdown
function FloorSelector({
floors,
currentFloorId,
onSelect
}: {
floors: { id: string; name: string; buildingName: string }[];
currentFloorId: string | null;
onSelect: (id: string) => void;
}) {
function FloorSelector({ floors, currentFloorId, onSelect }: { floors: any[]; 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"
>
<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'}
@ -260,12 +187,7 @@ function FloorSelector({
{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'
}`}
>
<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>
@ -277,123 +199,77 @@ function FloorSelector({
);
}
// Stats panel
function StatsPanel({ stats }: { stats: Floor3DData['stats'] }) {
function StatsPanel({ stats }: { stats: any }) {
if (!stats) return null;
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 className="flex items-center gap-1"><span className="text-tertiary">Rooms:</span><span className="font-medium text-primary">{stats.totalRooms || 0}</span></div>
<div className="flex items-center gap-1"><span className="text-tertiary">Tables:</span><span className="font-medium text-primary">{stats.totalSections || 0}</span></div>
<div className="flex items-center gap-1"><span className="text-tertiary">Positions:</span><span className="font-medium text-primary">{stats.totalPositions || 0}</span></div>
<div className="flex items-center gap-1"><span className="text-tertiary">Occupied:</span><span className="font-medium text-success">{stats.occupiedPositions || 0}</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 [floors, setFloors] = useState<any[]>([]);
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 }[] = [];
const allFloors: any[] = [];
properties.forEach(prop => {
prop.buildings.forEach(building => {
building.floors.forEach(floor => {
allFloors.push({
id: floor.id,
name: floor.name,
buildingName: building.name
});
allFloors.push({ id: floor.id, name: floor.name, buildingName: building.name });
});
});
});
setFloors(allFloors);
if (allFloors.length > 0) {
setCurrentFloorId(allFloors[0].id);
}
if (allFloors.length > 0) setCurrentFloorId(allFloors[0].id);
} catch (err) {
console.error(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) {
console.error(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>
);
return <div className="h-screen flex items-center justify-center bg-primary"><Loader2 size={32} className="animate-spin text-accent" /></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>
);
return <div className="h-screen flex items-center justify-center bg-primary text-danger"><AlertTriangle size={32} className="mb-2" /><p>{error}</p></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 items-center justify-center bg-primary text-secondary">No data</div>;
}
return (
@ -401,49 +277,22 @@ export default function Facility3DViewerPage() {
{/* 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}
/>
<h1 className="text-lg font-semibold text-primary flex items-center gap-2"><Grid 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>
<Suspense fallback={null}>
<Facility3DScene data={floorData} selectedRoomId={selectedRoomId} onSelectRoom={setSelectedRoomId} />
</Suspense>
</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>
<h3 className="font-medium text-primary">{floorData.rooms.find(r => r.id === selectedRoomId)?.name}</h3>
</div>
)}
</div>