fix: resolve API prefix double-url bugs and stabilize 3D viewer
This commit is contained in:
parent
2036011fdc
commit
86ad94f812
4 changed files with 128 additions and 425 deletions
|
|
@ -19,194 +19,51 @@
|
||||||
|
|
||||||
- Password hashing with bcrypt
|
- Password hashing with bcrypt
|
||||||
- JWT token generation (access 15m + refresh 7d)
|
- JWT token generation (access 15m + refresh 7d)
|
||||||
- Updated login endpoint with proper tokens
|
- Updated login endpoint with prop## Features Modified, Added, or Removed:
|
||||||
- Added refresh & logout endpoints
|
|
||||||
- Created 4 test users (all roles)
|
|
||||||
- **Time**: 1 hour
|
|
||||||
|
|
||||||
### 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
|
## Dependencies and APIs
|
||||||
- Touch-friendly base styles (44px+ targets)
|
|
||||||
- 777 Wolfpack branding integration
|
|
||||||
- Mobile-optimized LoginPage
|
|
||||||
- Splash screen component
|
|
||||||
- **Time**: 45 minutes
|
|
||||||
|
|
||||||
---
|
- **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
|
## Existing Blockers and Bugs
|
||||||
- ✅ 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
|
|
||||||
|
|
||||||
### 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
|
## Next Steps to Solve the Problem
|
||||||
- ✅ Tailwind CSS with mobile-first breakpoints
|
|
||||||
- ✅ Touch-friendly base styles
|
|
||||||
- ✅ 777 Wolfpack branding
|
|
||||||
- ✅ Responsive LoginPage
|
|
||||||
- ✅ Splash screen component
|
|
||||||
- ✅ Dark mode support
|
|
||||||
|
|
||||||
---
|
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).
|
||||||
## 🔐 **Test Users (All Ready)**
|
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.
|
||||||
| Email | Password | Role | Rate |
|
sh auth middleware - your choice!
|
||||||
|-------|----------|------|------|
|
|
||||||
| <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!
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,27 +50,21 @@ export const messagingApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async markRead(id: string): Promise<void> {
|
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 }> {
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAcknowledgements(id: string): Promise<{
|
async getAcknowledgements(id: string): Promise<any> {
|
||||||
announcement: { id: string; title: string };
|
const response = await api.get(`/messaging/announcements/${id}/acks`);
|
||||||
requiresAck: boolean;
|
|
||||||
totalUsers: number;
|
|
||||||
acknowledged: number;
|
|
||||||
pending: { id: string; name: string; email: string }[];
|
|
||||||
}> {
|
|
||||||
const response = await api.get(`/api/messaging/announcements/${id}/acks`);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Shift Notes
|
// 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 });
|
const response = await api.get('/messaging/shift-notes', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
@ -86,7 +80,9 @@ export const messagingApi = {
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
async markShiftNoteRead(id: string): Promise<void> {
|
async markShiftNoteRead(id: string): Promise<void> {
|
||||||
await api.post(`/api/messaging/shift-notes/${id}/read`);
|
await api.post(`/messaging/shift-notes/${id}/read`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export async function uploadPhoto(
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', compressed.file, file.name);
|
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' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
onUploadProgress: (progressEvent) => {
|
onUploadProgress: (progressEvent) => {
|
||||||
if (progressEvent.total && onProgress) {
|
if (progressEvent.total && onProgress) {
|
||||||
|
|
@ -82,7 +82,7 @@ export async function uploadPhotos(
|
||||||
formData.append('files', photo.file, files[index].name);
|
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' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
onUploadProgress: (progressEvent) => {
|
onUploadProgress: (progressEvent) => {
|
||||||
if (progressEvent.total && onProgress) {
|
if (progressEvent.total && onProgress) {
|
||||||
|
|
@ -93,6 +93,7 @@ export async function uploadPhotos(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,7 +117,7 @@ export function getPhotoUrl(path: string, size: 'thumb' | 'medium' | 'full' = 'm
|
||||||
* Delete a photo
|
* Delete a photo
|
||||||
*/
|
*/
|
||||||
export async function deletePhoto(photoId: string): Promise<void> {
|
export async function deletePhoto(photoId: string): Promise<void> {
|
||||||
await api.delete(`/api/upload/photo/${photoId}`);
|
await api.delete(`/upload/photo/${photoId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef, Suspense } from 'react';
|
||||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
import { Canvas, useThree } from '@react-three/fiber';
|
||||||
import { OrbitControls, Text, PerspectiveCamera } from '@react-three/drei';
|
import { OrbitControls, Text } from '@react-three/drei';
|
||||||
import { Loader2, Grid3X3, ChevronDown, ZoomIn, ZoomOut, RotateCcw, Building } from 'lucide-react';
|
import { Loader2, Grid, ChevronDown, Building, AlertTriangle } from 'lucide-react';
|
||||||
import { layoutApi, Floor3DData, Room3D, Section3D, Position3D } from '../lib/layoutApi';
|
import { layoutApi, Floor3DData, Room3D, Section3D, Position3D } from '../lib/layoutApi';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
|
||||||
// Color mapping for room types
|
// Color mapping for room types - with fallback
|
||||||
const ROOM_COLORS: Record<string, string> = {
|
const ROOM_COLORS: Record<string, string> = {
|
||||||
VEG: '#22c55e', // Green
|
VEG: '#22c55e', // Green
|
||||||
FLOWER: '#a855f7', // Purple
|
FLOWER: '#a855f7', // Purple
|
||||||
|
|
@ -13,7 +13,8 @@ const ROOM_COLORS: Record<string, string> = {
|
||||||
DRY: '#f97316', // Orange
|
DRY: '#f97316', // Orange
|
||||||
CURE: '#92400e', // Brown
|
CURE: '#92400e', // Brown
|
||||||
CLONE: '#14b8a6', // Teal
|
CLONE: '#14b8a6', // Teal
|
||||||
FACILITY: '#6b7280' // Gray
|
FACILITY: '#6b7280', // Gray
|
||||||
|
DEFAULT: '#9ca3af' // Fallback
|
||||||
};
|
};
|
||||||
|
|
||||||
// Status colors for positions
|
// Status colors for positions
|
||||||
|
|
@ -21,14 +22,14 @@ const STATUS_COLORS: Record<string, string> = {
|
||||||
EMPTY: '#4b5563',
|
EMPTY: '#4b5563',
|
||||||
OCCUPIED: '#22c55e',
|
OCCUPIED: '#22c55e',
|
||||||
RESERVED: '#3b82f6',
|
RESERVED: '#3b82f6',
|
||||||
DAMAGED: '#dc2626'
|
DAMAGED: '#dc2626',
|
||||||
|
DEFAULT: '#6b7280'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scale factor - convert pixels to 3D units
|
|
||||||
const SCALE = 0.01;
|
const SCALE = 0.01;
|
||||||
|
|
||||||
// Ground plane component
|
|
||||||
function Ground({ width, height }: { width: number; height: number }) {
|
function Ground({ width, height }: { width: number; height: number }) {
|
||||||
|
if (!width || !height) return null;
|
||||||
return (
|
return (
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[width * SCALE / 2, -0.01, height * SCALE / 2]}>
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[width * SCALE / 2, -0.01, height * SCALE / 2]}>
|
||||||
<planeGeometry args={[width * SCALE, height * SCALE]} />
|
<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 }) {
|
function GridOverlay({ width, height }: { width: number; height: number }) {
|
||||||
|
if (!width || !height) return null;
|
||||||
return (
|
return (
|
||||||
<gridHelper
|
<gridHelper
|
||||||
args={[Math.max(width, height) * SCALE, 20, '#374151', '#374151']}
|
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 }) {
|
||||||
function Room3DBox({ room, onClick, isSelected }: {
|
if (!room) return null;
|
||||||
room: Room3D;
|
const color = ROOM_COLORS[room.type] || ROOM_COLORS.DEFAULT;
|
||||||
onClick: () => void;
|
|
||||||
isSelected: boolean;
|
|
||||||
}) {
|
|
||||||
const color = ROOM_COLORS[room.type] || '#6b7280';
|
|
||||||
const mesh = useRef<THREE.Mesh>(null);
|
const mesh = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
// Center the room
|
// Safely calculate position
|
||||||
const x = room.posX * SCALE + (room.width * SCALE) / 2;
|
const x = (room.posX || 0) * SCALE + ((room.width || 0) * SCALE) / 2;
|
||||||
const z = room.posY * SCALE + (room.height * SCALE) / 2;
|
const z = (room.posY || 0) * SCALE + ((room.height || 0) * SCALE) / 2;
|
||||||
const roomHeight = 2; // Room height in 3D units
|
const roomHeight = 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Floor */}
|
{/* Floor */}
|
||||||
<mesh
|
<mesh ref={mesh} position={[x, 0.02, z]} rotation={[-Math.PI / 2, 0, 0]} onClick={onClick}>
|
||||||
ref={mesh}
|
<planeGeometry args={[(room.width || 0) * SCALE, (room.height || 0) * SCALE]} />
|
||||||
position={[x, 0.02, z]}
|
<meshStandardMaterial color={color} opacity={isSelected ? 0.6 : 0.3} transparent />
|
||||||
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>
|
</mesh>
|
||||||
|
|
||||||
{/* Walls (outline) */}
|
{/* Walls (outline) */}
|
||||||
<lineSegments position={[x, roomHeight / 2, z]}>
|
<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} />
|
<lineBasicMaterial attach="material" color={isSelected ? '#ffffff' : color} linewidth={2} />
|
||||||
</lineSegments>
|
</lineSegments>
|
||||||
|
|
||||||
{/* Room label */}
|
{/* Room label */}
|
||||||
<Text
|
{room.code && (
|
||||||
position={[x, 0.1, z]}
|
<Text position={[x, 0.1, z]} rotation={[-Math.PI / 2, 0, 0]} fontSize={0.3} color="white" anchorX="center" anchorY="middle">
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
|
||||||
fontSize={0.3}
|
|
||||||
color="white"
|
|
||||||
anchorX="center"
|
|
||||||
anchorY="middle"
|
|
||||||
>
|
|
||||||
{room.code}
|
{room.code}
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Sections */}
|
{/* Sections */}
|
||||||
{room.sections.map(section => (
|
{room.sections?.map(section => (
|
||||||
<Section3DBox
|
<Section3DBox key={section.id} section={section} roomX={room.posX || 0} roomY={room.posY || 0} />
|
||||||
key={section.id}
|
|
||||||
section={section}
|
|
||||||
roomX={room.posX}
|
|
||||||
roomY={room.posY}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section (table/rack) component
|
function Section3DBox({ section, roomX, roomY }: { section: Section3D; roomX: number; roomY: number }) {
|
||||||
function Section3DBox({ section, roomX, roomY }: {
|
if (!section) return null;
|
||||||
section: Section3D;
|
const x = (roomX + (section.posX || 0)) * SCALE + ((section.width || 0) * SCALE) / 2;
|
||||||
roomX: number;
|
const z = (roomY + (section.posY || 0)) * SCALE + ((section.height || 0) * SCALE) / 2;
|
||||||
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;
|
const tableHeight = 0.5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{/* Table surface */}
|
{/* Table surface */}
|
||||||
<mesh position={[x, tableHeight, z]}>
|
<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" />
|
<meshStandardMaterial color="#374151" />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Positions (plants) */}
|
{/* Positions (plants) */}
|
||||||
{section.positions.map((pos, idx) => {
|
{section.positions?.map(pos => {
|
||||||
const cellWidth = section.width / section.columns;
|
const rows = section.rows || 1;
|
||||||
const cellHeight = section.height / section.rows;
|
const cols = section.columns || 1;
|
||||||
const posX = (roomX + section.posX + (pos.column - 0.5) * cellWidth) * SCALE;
|
const cellWidth = (section.width || 0) / cols;
|
||||||
const posZ = (roomY + section.posY + (pos.row - 0.5) * cellHeight) * SCALE;
|
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 (
|
return <PlantPosition key={pos.id} position={pos} x={posX} z={posZ} y={tableHeight + 0.1} />;
|
||||||
<PlantPosition
|
|
||||||
key={pos.id}
|
|
||||||
position={pos}
|
|
||||||
x={posX}
|
|
||||||
z={posZ}
|
|
||||||
y={tableHeight + 0.1}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plant position component
|
function PlantPosition({ position, x, y, z }: { position: Position3D; x: number; y: number; z: number }) {
|
||||||
function PlantPosition({ position, x, y, z }: {
|
if (!position) return null;
|
||||||
position: Position3D;
|
const color = STATUS_COLORS[position.status] || STATUS_COLORS.DEFAULT;
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
z: number;
|
|
||||||
}) {
|
|
||||||
const color = STATUS_COLORS[position.status] || STATUS_COLORS.EMPTY;
|
|
||||||
const isOccupied = position.status === 'OCCUPIED';
|
const isOccupied = position.status === 'OCCUPIED';
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh position={[x, y + (isOccupied ? 0.15 : 0), z]} onPointerOver={() => setHovered(true)} onPointerOut={() => setHovered(false)}>
|
||||||
position={[x, y + (isOccupied ? 0.15 : 0), z]}
|
{isOccupied ? <cylinderGeometry args={[0.05, 0.03, 0.3, 8]} /> : <sphereGeometry args={[0.03, 8, 8]} />}
|
||||||
onPointerOver={() => setHovered(true)}
|
<meshStandardMaterial color={hovered ? '#ffffff' : color} emissive={hovered ? color : '#000000'} emissiveIntensity={hovered ? 0.5 : 0} />
|
||||||
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>
|
</mesh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Camera controller for auto-framing
|
|
||||||
function CameraController({ floorWidth, floorHeight }: { floorWidth: number; floorHeight: number }) {
|
function CameraController({ floorWidth, floorHeight }: { floorWidth: number; floorHeight: number }) {
|
||||||
const { camera } = useThree();
|
const { camera } = useThree();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!floorWidth || !floorHeight) return;
|
||||||
const maxDim = Math.max(floorWidth, floorHeight) * SCALE;
|
const maxDim = Math.max(floorWidth, floorHeight) * SCALE;
|
||||||
camera.position.set(maxDim * 0.8, maxDim * 1.2, maxDim * 0.8);
|
camera.position.set(maxDim * 0.8, maxDim * 1.2, maxDim * 0.8);
|
||||||
camera.lookAt(floorWidth * SCALE / 2, 0, floorHeight * SCALE / 2);
|
camera.lookAt(floorWidth * SCALE / 2, 0, floorHeight * SCALE / 2);
|
||||||
}, [camera, floorWidth, floorHeight]);
|
}, [camera, floorWidth, floorHeight]);
|
||||||
|
|
||||||
return null;
|
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 }: {
|
if (!data?.floor) return null;
|
||||||
data: Floor3DData;
|
|
||||||
selectedRoomId: string | null;
|
|
||||||
onSelectRoom: (id: string | null) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Lighting */}
|
{/* Lighting */}
|
||||||
<ambientLight intensity={0.4} />
|
<ambientLight intensity={0.5} />
|
||||||
<directionalLight position={[10, 20, 10]} intensity={0.8} castShadow />
|
<directionalLight position={[10, 20, 10]} intensity={0.8} />
|
||||||
<pointLight position={[0, 10, 0]} intensity={0.3} />
|
|
||||||
|
|
||||||
{/* Ground and grid */}
|
{/* Ground and grid */}
|
||||||
<Ground width={data.floor.width} height={data.floor.height} />
|
<Ground width={data.floor.width} height={data.floor.height} />
|
||||||
<GridOverlay width={data.floor.width} height={data.floor.height} />
|
<GridOverlay width={data.floor.width} height={data.floor.height} />
|
||||||
|
|
||||||
{/* Rooms */}
|
{/* Rooms */}
|
||||||
{data.rooms.map(room => (
|
{data.rooms?.map(room => (
|
||||||
<Room3DBox
|
<Room3DBox
|
||||||
key={room.id}
|
key={room.id}
|
||||||
room={room}
|
room={room}
|
||||||
|
|
@ -221,35 +165,18 @@ function Facility3DScene({ data, selectedRoomId, onSelectRoom }: {
|
||||||
|
|
||||||
{/* Controls and camera */}
|
{/* Controls and camera */}
|
||||||
<CameraController floorWidth={data.floor.width} floorHeight={data.floor.height} />
|
<CameraController floorWidth={data.floor.width} floorHeight={data.floor.height} />
|
||||||
<OrbitControls
|
<OrbitControls enablePan={true} enableZoom={true} enableRotate={true} />
|
||||||
enablePan={true}
|
|
||||||
enableZoom={true}
|
|
||||||
enableRotate={true}
|
|
||||||
maxPolarAngle={Math.PI / 2.1}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floor selector dropdown
|
function FloorSelector({ floors, currentFloorId, onSelect }: { floors: any[]; currentFloorId: string | null; onSelect: (id: string) => void; }) {
|
||||||
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 [isOpen, setIsOpen] = useState(false);
|
||||||
const current = floors.find(f => f.id === currentFloorId);
|
const current = floors.find(f => f.id === currentFloorId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<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">
|
||||||
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" />
|
<Building size={16} className="text-tertiary" />
|
||||||
<span className="text-sm font-medium text-primary">
|
<span className="text-sm font-medium text-primary">
|
||||||
{current ? `${current.buildingName} - ${current.name}` : 'Select Floor'}
|
{current ? `${current.buildingName} - ${current.name}` : 'Select Floor'}
|
||||||
|
|
@ -260,12 +187,7 @@ function FloorSelector({
|
||||||
{isOpen && (
|
{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">
|
<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 => (
|
{floors.map(floor => (
|
||||||
<button
|
<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'}`}>
|
||||||
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="font-medium">{floor.buildingName}</span>
|
||||||
<span className="text-tertiary ml-2">›</span>
|
<span className="text-tertiary ml-2">›</span>
|
||||||
<span className="ml-2">{floor.name}</span>
|
<span className="ml-2">{floor.name}</span>
|
||||||
|
|
@ -277,123 +199,77 @@ function FloorSelector({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats panel
|
function StatsPanel({ stats }: { stats: any }) {
|
||||||
function StatsPanel({ stats }: { stats: Floor3DData['stats'] }) {
|
if (!stats) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 text-xs">
|
<div className="flex gap-4 text-xs">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1"><span className="text-tertiary">Rooms:</span><span className="font-medium text-primary">{stats.totalRooms || 0}</span></div>
|
||||||
<span className="text-tertiary">Rooms:</span>
|
<div className="flex items-center gap-1"><span className="text-tertiary">Tables:</span><span className="font-medium text-primary">{stats.totalSections || 0}</span></div>
|
||||||
<span className="font-medium text-primary">{stats.totalRooms}</span>
|
<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>
|
<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 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main component
|
|
||||||
export default function Facility3DViewerPage() {
|
export default function Facility3DViewerPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [currentFloorId, setCurrentFloorId] = useState<string | null>(null);
|
||||||
const [floorData, setFloorData] = useState<Floor3DData | null>(null);
|
const [floorData, setFloorData] = useState<Floor3DData | null>(null);
|
||||||
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
|
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Load properties and floors
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadFloors = async () => {
|
const loadFloors = async () => {
|
||||||
try {
|
try {
|
||||||
const properties = await layoutApi.getProperties();
|
const properties = await layoutApi.getProperties();
|
||||||
const allFloors: { id: string; name: string; buildingName: string }[] = [];
|
const allFloors: any[] = [];
|
||||||
|
|
||||||
properties.forEach(prop => {
|
properties.forEach(prop => {
|
||||||
prop.buildings.forEach(building => {
|
prop.buildings.forEach(building => {
|
||||||
building.floors.forEach(floor => {
|
building.floors.forEach(floor => {
|
||||||
allFloors.push({
|
allFloors.push({ id: floor.id, name: floor.name, buildingName: building.name });
|
||||||
id: floor.id,
|
|
||||||
name: floor.name,
|
|
||||||
buildingName: building.name
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
setFloors(allFloors);
|
setFloors(allFloors);
|
||||||
if (allFloors.length > 0) {
|
if (allFloors.length > 0) setCurrentFloorId(allFloors[0].id);
|
||||||
setCurrentFloorId(allFloors[0].id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
setError('Failed to load facility data');
|
setError('Failed to load facility data');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadFloors();
|
loadFloors();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load 3D data when floor changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentFloorId) return;
|
if (!currentFloorId) return;
|
||||||
|
|
||||||
const load3DData = async () => {
|
const load3DData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await layoutApi.getFloor3D(currentFloorId);
|
const data = await layoutApi.getFloor3D(currentFloorId);
|
||||||
setFloorData(data);
|
setFloorData(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
setError('Failed to load 3D floor data');
|
setError('Failed to load 3D floor data');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
load3DData();
|
load3DData();
|
||||||
}, [currentFloorId]);
|
}, [currentFloorId]);
|
||||||
|
|
||||||
if (loading && !floorData) {
|
if (loading && !floorData) {
|
||||||
return (
|
return <div className="h-screen flex items-center justify-center bg-primary"><Loader2 size={32} className="animate-spin text-accent" /></div>;
|
||||||
<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) {
|
if (error && !floorData) {
|
||||||
return (
|
return <div className="h-screen flex items-center justify-center bg-primary text-danger"><AlertTriangle size={32} className="mb-2" /><p>{error}</p></div>;
|
||||||
<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) {
|
if (!floorData) {
|
||||||
return (
|
return <div className="h-screen flex items-center justify-center bg-primary text-secondary">No data</div>;
|
||||||
<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 (
|
return (
|
||||||
|
|
@ -401,49 +277,22 @@ export default function Facility3DViewerPage() {
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-default">
|
<div className="flex items-center justify-between p-4 border-b border-default">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-lg font-semibold text-primary flex items-center gap-2">
|
<h1 className="text-lg font-semibold text-primary flex items-center gap-2"><Grid size={20} className="text-accent" />3D Facility VIew</h1>
|
||||||
<Grid3X3 size={20} className="text-accent" />
|
<FloorSelector floors={floors} currentFloorId={currentFloorId} onSelect={setCurrentFloorId} />
|
||||||
3D Facility View
|
|
||||||
</h1>
|
|
||||||
<FloorSelector
|
|
||||||
floors={floors}
|
|
||||||
currentFloorId={currentFloorId}
|
|
||||||
onSelect={setCurrentFloorId}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<StatsPanel stats={floorData.stats} />
|
<StatsPanel stats={floorData.stats} />
|
||||||
</div>
|
</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 */}
|
{/* 3D Canvas */}
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Canvas shadows>
|
<Canvas>
|
||||||
<Facility3DScene
|
<Suspense fallback={null}>
|
||||||
data={floorData}
|
<Facility3DScene data={floorData} selectedRoomId={selectedRoomId} onSelectRoom={setSelectedRoomId} />
|
||||||
selectedRoomId={selectedRoomId}
|
</Suspense>
|
||||||
onSelectRoom={setSelectedRoomId}
|
|
||||||
/>
|
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
||||||
{/* Info overlay */}
|
|
||||||
{selectedRoomId && (
|
{selectedRoomId && (
|
||||||
<div className="absolute bottom-4 left-4 bg-secondary/90 backdrop-blur border border-default rounded-lg p-4">
|
<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">
|
<h3 className="font-medium text-primary">{floorData.rooms.find(r => r.id === selectedRoomId)?.name}</h3>
|
||||||
{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>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue