diff --git a/backend/prisma/seed-metrc-demo.js b/backend/prisma/seed-metrc-demo.js new file mode 100644 index 0000000..8f36c5a --- /dev/null +++ b/backend/prisma/seed-metrc-demo.js @@ -0,0 +1,112 @@ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function main() { + console.log('🌱 Seeding METRC demo data...'); + + // 1. Get existing Batches + const batches = await prisma.batch.findMany({ + take: 5 + }); + + if (batches.length === 0) { + console.error('No Batches found. Please run main seed first.'); + return; + } + + // 2. Get existing FacilityPositions with Room info + const positions = await prisma.facilityPosition.findMany({ + take: 50, + include: { + section: { + include: { + room: true + } + } + } + }); + + if (positions.length === 0) { + console.error('No FacilityPositions found. Please run main seed first.'); + return; + } + + console.log(`Found ${batches.length} batches and ${positions.length} positions.`); + + // 3. Clear existing demo plants + const deleted = await prisma.facilityPlant.deleteMany({ + where: { + tagNumber: { startsWith: '1A40603000' } + } + }); + console.log(`Cleared ${deleted.count} existing demo plants.`); + + // 4. Create Plants + let createdCount = 0; + + for (const [index, pos] of positions.entries()) { + const batch = batches[index % batches.length]; + + // METRC Tag format: 1A40603000 + 7 digits + const tagSuffix = (1000000 + index).toString(); + const tag = `1A40603000${tagSuffix}`; + + try { + const plant = await prisma.facilityPlant.create({ + data: { + tagNumber: tag, + // valid fields from schema: + batchId: batch.id, + positionId: pos.id, + address: `${pos.section.room.code}-${pos.section.code}-R${pos.row}C${pos.column}`, + status: 'ACTIVE' + } + }); + + // 5. Create Location History (Audit) + await prisma.plantLocationHistory.create({ + data: { + plantId: plant.id, + fromAddress: null, + toAddress: plant.address, + movedAt: new Date(), + reason: 'Initial planting', + movedById: 'system' + } + }); + + // Maybe a move for some plants + if (index % 3 === 0) { + const oldAddress = `NUR-1-R1C1`; + await prisma.plantLocationHistory.create({ + data: { + plantId: plant.id, + fromAddress: oldAddress, + toAddress: plant.address, + movedAt: new Date(Date.now() - Math.random() * 5 * 24 * 60 * 60 * 1000), + reason: 'Moved to flower', + movedById: 'system' + } + }); + } + + createdCount++; + } catch (error) { + // Ignore unique constraint errors if position already taken (though we cleared matches) + if (!error.message.includes('Unique constraint')) { + console.error(`Failed to create plant ${tag}:`, error.message); + } + } + } + + console.log(`✨ Successfully created ${createdCount} METRC demo plants linked to batches.`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/frontend/src/pages/BatchesPage.tsx b/frontend/src/pages/BatchesPage.tsx index 81c70c5..9ef05c6 100644 --- a/frontend/src/pages/BatchesPage.tsx +++ b/frontend/src/pages/BatchesPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { Plus, MoveRight, Sprout, Leaf, Flower, Archive, Scale, ClipboardList, Bug, Search, ChevronRight, Calendar } from 'lucide-react'; +import { Plus, MoveRight, Sprout, Leaf, Flower, Archive, Scale, ClipboardList, Bug, Search, ChevronRight, Calendar, Cloud, CloudOff, CheckCircle } from 'lucide-react'; import { Batch, batchesApi } from '../lib/batchesApi'; import { useToast } from '../context/ToastContext'; import BatchTransitionModal from '../components/BatchTransitionModal'; @@ -62,6 +62,24 @@ function DaysBadge({ startDate }: { startDate: string }) { ); } +// METRC Sync Status Badge +function MetrcBadge({ synced = true }: { synced?: boolean }) { + if (synced) { + return ( + + + METRC + + ); + } + return ( + + + Pending + + ); +} + export default function BatchesPage() { const { addToast } = useToast(); const [batches, setBatches] = useState([]); @@ -159,6 +177,7 @@ export default function BatchesPage() { {batchCode} +

{batch.strain}

diff --git a/frontend/src/pages/Facility3DViewerPage.tsx b/frontend/src/pages/Facility3DViewerPage.tsx index e006bab..683c519 100644 --- a/frontend/src/pages/Facility3DViewerPage.tsx +++ b/frontend/src/pages/Facility3DViewerPage.tsx @@ -1,23 +1,141 @@ -import { useEffect, useState, Suspense } from 'react'; -import { Canvas } from '@react-three/fiber'; +import { useEffect, useState, Suspense, useRef, useMemo } from 'react'; +import { Canvas, useFrame } from '@react-three/fiber'; +import { OrbitControls, Text, Instances, Instance, Html } from '@react-three/drei'; import { layoutApi, Floor3DData } from '../lib/layoutApi'; -// No drei, no complex icons to isolate crash -// import { OrbitControls } from '@react-three/drei'; import * as THREE from 'three'; +import { Loader2, ArrowLeft } from 'lucide-react'; +import { Link } from 'react-router-dom'; + +// Colors +const COLORS = { + VEG: '#4ade80', // green-400 + FLOWER: '#a855f7', // purple-500 + DRY: '#f59e0b', // amber-500 + CURE: '#78716c', // stone-500 + EMPTY_SLOT: '#374151', // gray-700 + ROOM_FLOOR: '#1f2937', // gray-800 + ROOM_WALL: '#374151', // gray-700 +}; + +function PlantInstances({ positions, onPlantClick }: { positions: any[], onPlantClick: (p: any) => void }) { + if (!positions || positions.length === 0) return null; + + const plants = positions.filter(p => p.plant); + const emptySlots = positions.filter(p => !p.plant); + + return ( + + {/* Active Plants */} + { + e.stopPropagation(); + const index = e.instanceId; + if (index !== undefined && plants[index]) { + onPlantClick(plants[index]); + } + }} + > + + + {plants.map((pos, i) => ( + + ))} + + + {/* Empty Slots (Small dots) */} + + + + {emptySlots.map((pos, i) => ( + + ))} + + + ); +} + +function FacilityScene({ data, onSelectPlant }: { data: Floor3DData, onSelectPlant: (p: any) => void }) { + // Process data into flat list of objects for rendering + const roomMeshes = useMemo(() => { + return data.rooms.map(room => { + // Room creates a floor area + return ( + + {/* Floor Label */} + + {room.name} + + + {/* Floor Plane */} + + + + + + {/* Render sections and positions */} + {room.sections.map(section => { + // Calculate positions for this section + const positions = section.positions.map(pos => { + // Simple grid layout logic for position coordinates relative to section + // Assuming typical rack dimensions + const spacing = 0.5; + const x = section.posX + (pos.column * spacing); + const z = section.posY + (pos.row * spacing); // Z is depth/row + const y = 0.5 + (pos.tier * 0.5); // Y is height/tier + + return { ...pos, x, y, z }; + }); + + return ( + + {/* Section Label */} + + {section.code} + + + + ); + })} + + ); + }); + }, [data, onSelectPlant]); -function SafeScene({ data }: { data: Floor3DData | null }) { return ( <> - - - - - - - - - - + + + + + + {roomMeshes} + + + + ); } @@ -25,45 +143,111 @@ function SafeScene({ data }: { data: Floor3DData | null }) { export default function Facility3DViewerPage() { const [status, setStatus] = useState('Initializing...'); const [floorData, setFloorData] = useState(null); + const [selectedPlant, setSelectedPlant] = useState(null); useEffect(() => { - setStatus('Loading floors...'); - layoutApi.getProperties() - .then(props => { - setStatus('Floors loaded. Fetching 3D data...'); - // Just grab first floor for test - if (props[0]?.buildings[0]?.floors[0]) { - const floorId = props[0].buildings[0].floors[0].id; - return layoutApi.getFloor3D(floorId); - } - return null; - }) - .then(data => { - if (data) { - setFloorData(data); - setStatus('Data loaded. Rendering...'); - } else { - setStatus('No floor data found.'); - } - }) - .catch(err => { - console.error(err); - setStatus('Error loading data: ' + err.message); - }); + loadData(); }, []); - return ( -
-

3D Viewer Safe Mode

-

{status}

+ async function loadData() { + setStatus('Loading layout...'); + try { + const props = await layoutApi.getProperties(); + if (props[0]?.buildings[0]?.floors[0]) { + const floorId = props[0].buildings[0].floors[0].id; + setStatus('Fetching 3D assets...'); + const data = await layoutApi.getFloor3D(floorId); + setFloorData(data); + setStatus(''); + } else { + setStatus('No floor layout found'); + } + } catch (err) { + setStatus('Error: ' + (err as Error).message); + } + } -
- - - - - + return ( +
+ {/* Header Overlay */} +
+
+ + Back + +
+

+ Facility Viewer 3D + BETA +

+

+ {floorData ? `${floorData.floor.name} • ${floorData.stats.occupiedPositions} Plants` : 'Loading...'} +

+
+
+ + {/* Legend */} +
+
Veg
+
Flower
+
Dry
+
Empty
+
+ + {/* Error/Status Overlay */} + {status && ( +
+
+ +

{status}

+
+
+ )} + + {/* Selection Overlay */} + {selectedPlant && ( +
+
+

{selectedPlant.plant.tagNumber}

+ +
+
+
+ Strain: + {selectedPlant.plant.strain || 'Unknown'} +
+
+ Stage: + {selectedPlant.plant.stage || 'N/A'} +
+
+ Batch: + {selectedPlant.plant.batchName || '-'} +
+
+ Location: + R{selectedPlant.row} T{selectedPlant.tier} S{selectedPlant.slot} +
+
+
+ )} + + + + {floorData && ( + + )} + +
); }