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
|
||||
- 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!
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue