feat: Implement Parametric Room Generation and Volumetric Plants
This commit is contained in:
parent
820d345a0c
commit
1950651102
8 changed files with 504 additions and 18 deletions
|
|
@ -301,7 +301,121 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
|
||||||
// ROOM ROUTES
|
// ROOM ROUTES
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// Create room
|
// Generate Room with Layout (Parametric)
|
||||||
|
fastify.post('/rooms/generate', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
floorId, name, code, type,
|
||||||
|
setupType, // RACK, TABLE
|
||||||
|
tiers, // 1-5
|
||||||
|
racksCount, // Total number of racks
|
||||||
|
rowsPerRack,
|
||||||
|
colsPerRack
|
||||||
|
} = request.body as any;
|
||||||
|
|
||||||
|
const numTiers = tiers || 1;
|
||||||
|
const numRacks = racksCount || 1;
|
||||||
|
const numRows = rowsPerRack || 4;
|
||||||
|
const numCols = colsPerRack || 2;
|
||||||
|
|
||||||
|
// Standard dimensions (approx 4x8 table scaled)
|
||||||
|
const rackWidth = 40;
|
||||||
|
const rackHeight = 80;
|
||||||
|
const aisleWidth = 30;
|
||||||
|
const padding = 20;
|
||||||
|
|
||||||
|
// Calculate room size approx
|
||||||
|
// Simple layout: 2 rows of racks if possible, else 1 long row
|
||||||
|
const racksPerRow = Math.ceil(numRacks / 2);
|
||||||
|
const numRackRows = Math.ceil(numRacks / racksPerRow);
|
||||||
|
|
||||||
|
const roomWidth = (padding * 2) + (racksPerRow * rackWidth) + ((racksPerRow - 1) * aisleWidth);
|
||||||
|
const roomHeight = (padding * 2) + (numRackRows * rackHeight) + ((numRackRows - 1) * aisleWidth);
|
||||||
|
|
||||||
|
// Create the Room
|
||||||
|
const room = await prisma.facilityRoom.create({
|
||||||
|
data: {
|
||||||
|
floorId,
|
||||||
|
name,
|
||||||
|
code,
|
||||||
|
type: type as RoomType,
|
||||||
|
posX: 0, // Default to 0,0 - user can move
|
||||||
|
posY: 0,
|
||||||
|
width: Math.max(roomWidth, 100),
|
||||||
|
height: Math.max(roomHeight, 100),
|
||||||
|
color: type === 'FLOWER' ? '#10b981' : '#3b82f6'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate Racks (Sections)
|
||||||
|
const sectionsToCreate = [];
|
||||||
|
let rackCounter = 1;
|
||||||
|
|
||||||
|
for (let r = 0; r < numRackRows; r++) {
|
||||||
|
for (let c = 0; c < racksPerRow; c++) {
|
||||||
|
if (rackCounter > numRacks) break;
|
||||||
|
|
||||||
|
const sPosX = padding + (c * (rackWidth + aisleWidth));
|
||||||
|
const sPosY = padding + (r * (rackHeight + aisleWidth));
|
||||||
|
|
||||||
|
// Generate positions for this rack
|
||||||
|
const positionsToCreate = [];
|
||||||
|
for (let row = 1; row <= numRows; row++) {
|
||||||
|
for (let col = 1; col <= numCols; col++) {
|
||||||
|
for (let t = 1; t <= numTiers; t++) {
|
||||||
|
positionsToCreate.push({
|
||||||
|
row,
|
||||||
|
column: col,
|
||||||
|
tier: t,
|
||||||
|
slot: 1,
|
||||||
|
status: 'EMPTY'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionsToCreate.push(prisma.facilitySection.create({
|
||||||
|
data: {
|
||||||
|
roomId: room.id,
|
||||||
|
name: `${setupType} ${rackCounter}`,
|
||||||
|
code: `${code}-R${rackCounter}`,
|
||||||
|
type: (setupType || 'RACK') as SectionType,
|
||||||
|
posX: sPosX,
|
||||||
|
posY: sPosY,
|
||||||
|
width: rackWidth,
|
||||||
|
height: rackHeight,
|
||||||
|
rows: numRows,
|
||||||
|
columns: numCols,
|
||||||
|
tiers: numTiers,
|
||||||
|
spacing: 12,
|
||||||
|
positions: {
|
||||||
|
create: positionsToCreate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
rackCounter++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(sectionsToCreate);
|
||||||
|
|
||||||
|
// Fetch full room to return
|
||||||
|
const fullRoom = await prisma.facilityRoom.findUnique({
|
||||||
|
where: { id: room.id },
|
||||||
|
include: { sections: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.status(201).send(fullRoom);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: 'Failed to generate room layout' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
fastify.post('/rooms', {
|
fastify.post('/rooms', {
|
||||||
handler: async (request, reply) => {
|
handler: async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Billboard } from '@react-three/drei';
|
import { Billboard } from '@react-three/drei';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
import { PlantStage } from './types';
|
||||||
|
|
||||||
// Stage-specific icon shapes as SVG paths (camera-facing 2D)
|
// Stage-specific icon shapes as SVG paths (camera-facing 2D)
|
||||||
// These are custom illustrations optimized for clarity at small sizes
|
// These are custom illustrations optimized for clarity at small sizes
|
||||||
|
|
||||||
export type PlantStage = 'CLONE_IN' | 'VEGETATIVE' | 'FLOWERING' | 'DRYING' | 'CURING' | 'HARVESTED' | 'FINISHED';
|
|
||||||
|
|
||||||
interface PlantIconProps {
|
interface PlantIconProps {
|
||||||
stage: PlantStage;
|
stage: PlantStage;
|
||||||
color: string;
|
color: string;
|
||||||
|
|
@ -26,6 +25,7 @@ const STAGE_ICONS: Record<PlantStage, { shape: 'circle' | 'leaf' | 'flower' | 'd
|
||||||
CURING: { shape: 'square' }, // Jar/container square
|
CURING: { shape: 'square' }, // Jar/container square
|
||||||
HARVESTED: { shape: 'ring' }, // Empty circle
|
HARVESTED: { shape: 'ring' }, // Empty circle
|
||||||
FINISHED: { shape: 'ring' }, // Empty circle
|
FINISHED: { shape: 'ring' }, // Empty circle
|
||||||
|
HIDDEN: { shape: 'circle' }, // Placeholder
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create geometry for each shape type
|
// Create geometry for each shape type
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Instances, Instance } from '@react-three/drei';
|
import { Instances, Instance } from '@react-three/drei';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { PlantPosition, VisMode, COLORS } from './types';
|
import { PlantPosition, VisMode, COLORS, PlantStage } from './types';
|
||||||
import { PlantIcon, PlantStage } from './PlantIcon';
|
import { PlantIcon } from './PlantIcon';
|
||||||
|
import { VolumetricPlant } from './VolumetricPlant';
|
||||||
|
|
||||||
// Mock helpers
|
// Mock helpers
|
||||||
const getMockPlantHealth = (_plantId: string) => {
|
const getMockPlantHealth = (_plantId: string) => {
|
||||||
|
|
@ -133,17 +134,17 @@ export function PlantSystem({
|
||||||
}).filter(p => p.stage !== 'HIDDEN' && p.stage !== 'FINISHED'); // Filter out plants that don't exist at this time
|
}).filter(p => p.stage !== 'HIDDEN' && p.stage !== 'FINISHED'); // Filter out plants that don't exist at this time
|
||||||
}, [plants, visMode, highlightSet, dimMode, hasHighlights, timelineDate]);
|
}, [plants, visMode, highlightSet, dimMode, hasHighlights, timelineDate]);
|
||||||
|
|
||||||
// New icon-based rendering
|
// New volumetric/icon based rendering
|
||||||
if (useIcons) {
|
if (useIcons) {
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
{plantData.map(({ pos, color, isHighlighted, shouldDim, stage }, i) => (
|
{plantData.map(({ pos, color, isHighlighted, shouldDim, stage }, i) => (
|
||||||
<PlantIcon
|
<VolumetricPlant
|
||||||
key={pos.id || i}
|
key={pos.id || i}
|
||||||
stage={stage as PlantStage}
|
stage={stage as PlantStage}
|
||||||
color={color}
|
color={color}
|
||||||
position={[pos.x, pos.y + 0.3, pos.z]}
|
position={[pos.x, pos.y + 0.05, pos.z]} // Lower start for volumetric base
|
||||||
scale={1.2}
|
scale={1.0}
|
||||||
isHighlighted={isHighlighted || false}
|
isHighlighted={isHighlighted || false}
|
||||||
isDimmed={shouldDim}
|
isDimmed={shouldDim}
|
||||||
onClick={() => onPlantClick(pos)}
|
onClick={() => onPlantClick(pos)}
|
||||||
|
|
|
||||||
89
frontend/src/components/facility3d/VolumetricPlant.tsx
Normal file
89
frontend/src/components/facility3d/VolumetricPlant.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { useMemo, useRef } from 'react';
|
||||||
|
import { Instance, Instances } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { PlantStage } from './types';
|
||||||
|
|
||||||
|
interface VolumetricPlantProps {
|
||||||
|
stage: PlantStage;
|
||||||
|
color: string;
|
||||||
|
position: [number, number, number];
|
||||||
|
scale?: number;
|
||||||
|
onClick?: () => void;
|
||||||
|
isHighlighted?: boolean;
|
||||||
|
isDimmed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VolumetricPlant({
|
||||||
|
stage,
|
||||||
|
color,
|
||||||
|
position,
|
||||||
|
scale = 1,
|
||||||
|
onClick,
|
||||||
|
isHighlighted,
|
||||||
|
isDimmed
|
||||||
|
}: VolumetricPlantProps) {
|
||||||
|
|
||||||
|
// Geometry decision based on stage
|
||||||
|
// CLONE: Small cylinder
|
||||||
|
// VEG: Bushy sphere
|
||||||
|
// FLOWER: Tall cone (Christmas tree shape)
|
||||||
|
// DRYING: Inverted cone or just color change
|
||||||
|
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
|
const adjustedColor = isDimmed ? '#3f3f46' : color;
|
||||||
|
const highlightScale = isHighlighted ? 1.2 : 1;
|
||||||
|
const baseScale = scale * highlightScale;
|
||||||
|
|
||||||
|
// Use standard primitives
|
||||||
|
if (stage === 'CLONE_IN') {
|
||||||
|
return (
|
||||||
|
<mesh position={position} onClick={(e) => { e.stopPropagation(); onClick?.(); }} scale={[baseScale, baseScale, baseScale]}>
|
||||||
|
<cylinderGeometry args={[0.05, 0.05, 0.2, 8]} />
|
||||||
|
<meshStandardMaterial color={adjustedColor} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === 'VEGETATIVE') {
|
||||||
|
return (
|
||||||
|
<mesh position={[position[0], position[1] + 0.15, position[2]]} onClick={(e) => { e.stopPropagation(); onClick?.(); }} scale={[baseScale, baseScale, baseScale]}>
|
||||||
|
<sphereGeometry args={[0.25, 12, 8]} />
|
||||||
|
<meshStandardMaterial color={adjustedColor} roughness={0.8} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === 'FLOWERING') {
|
||||||
|
return (
|
||||||
|
<group position={[position[0], position[1] + 0.3, position[2]]} onClick={(e) => { e.stopPropagation(); onClick?.(); }} scale={[baseScale, baseScale, baseScale]}>
|
||||||
|
{/* Main Cola */}
|
||||||
|
<mesh position={[0, 0, 0]}>
|
||||||
|
<coneGeometry args={[0.25, 0.8, 12]} />
|
||||||
|
<meshStandardMaterial color={adjustedColor} roughness={0.6} />
|
||||||
|
</mesh>
|
||||||
|
{/* Visual interest / Buds */}
|
||||||
|
<mesh position={[0.1, 0.1, 0.1]}>
|
||||||
|
<sphereGeometry args={[0.08, 6, 4]} />
|
||||||
|
<meshStandardMaterial color="#fcd34d" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-0.1, 0.2, -0.05]}>
|
||||||
|
<sphereGeometry args={[0.08, 6, 4]} />
|
||||||
|
<meshStandardMaterial color="#fcd34d" />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default / Harvested / Etc
|
||||||
|
return (
|
||||||
|
<mesh position={position} onClick={(e) => { e.stopPropagation(); onClick?.(); }} scale={[baseScale, baseScale, baseScale]}>
|
||||||
|
<boxGeometry args={[0.2, 0.2, 0.2]} />
|
||||||
|
<meshStandardMaterial color={adjustedColor} transparent opacity={0.8} />
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimized Batch Renderer needed?
|
||||||
|
// For now, individual components are fine unless we hit >1000 plants in view.
|
||||||
|
// If needed, we can switch to <Instances> for each shape type.
|
||||||
|
|
@ -3,6 +3,8 @@ import type { Room3D, Section3D, Position3D, Floor3DData } from '../../lib/layou
|
||||||
|
|
||||||
export type VisMode = 'STANDARD' | 'HEALTH' | 'YIELD' | 'TEMP' | 'HUMIDITY';
|
export type VisMode = 'STANDARD' | 'HEALTH' | 'YIELD' | 'TEMP' | 'HUMIDITY';
|
||||||
|
|
||||||
|
export type PlantStage = 'CLONE_IN' | 'VEGETATIVE' | 'FLOWERING' | 'DRYING' | 'CURING' | 'HARVESTED' | 'FINISHED' | 'HIDDEN';
|
||||||
|
|
||||||
// Breadcrumb data for hierarchy navigation
|
// Breadcrumb data for hierarchy navigation
|
||||||
export interface PlantBreadcrumb {
|
export interface PlantBreadcrumb {
|
||||||
facility?: string;
|
facility?: string;
|
||||||
|
|
|
||||||
240
frontend/src/components/layout/RoomLayoutWizard.tsx
Normal file
240
frontend/src/components/layout/RoomLayoutWizard.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../ui/dialog';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { Label } from '../ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||||
|
import { Slider } from '../ui/slider';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
import { layoutApi } from '../../lib/layoutApi';
|
||||||
|
import { Loader2, Wand2, Box, Layers, Grid } from 'lucide-react';
|
||||||
|
|
||||||
|
interface RoomLayoutWizardProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
floorId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomLayoutWizard({ isOpen, onClose, onSuccess, floorId }: RoomLayoutWizardProps) {
|
||||||
|
const [step, setStep] = useState(1); // 1: Basics, 2: Layout, 3: Capacity
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
type: 'FLOWER',
|
||||||
|
setupType: 'RACK', // RACK, TABLE
|
||||||
|
racksCount: 4,
|
||||||
|
tiers: 2,
|
||||||
|
rowsPerRack: 4,
|
||||||
|
colsPerRack: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
// Estimates
|
||||||
|
const totalPlants = formData.racksCount * formData.rowsPerRack * formData.colsPerRack * formData.tiers;
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await layoutApi.generateRoom({
|
||||||
|
floorId,
|
||||||
|
code: formData.name.toUpperCase().substring(0, 4), // Auto-code
|
||||||
|
...formData
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate room:', error);
|
||||||
|
// In a real app, show toast
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-2xl bg-zinc-950 border-zinc-800 text-zinc-100">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl flex items-center gap-2">
|
||||||
|
<Wand2 className="w-5 h-5 text-emerald-400" />
|
||||||
|
Parametric Room Generator
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-6">
|
||||||
|
<Tabs value={String(step)} className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-3 bg-zinc-900">
|
||||||
|
<TabsTrigger value="1" disabled={step < 1} onClick={() => setStep(1)}>1. Basics</TabsTrigger>
|
||||||
|
<TabsTrigger value="2" disabled={step < 2} onClick={() => setStep(2)}>2. Layout</TabsTrigger>
|
||||||
|
<TabsTrigger value="3" disabled={step < 3} onClick={() => setStep(3)}>3. Review</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* STEP 1: BASICS */}
|
||||||
|
<TabsContent value="1" className="space-y-4 pt-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Room Name</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Flower Room 1"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="bg-zinc-900 border-zinc-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Room Type</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.type}
|
||||||
|
onValueChange={(val) => setFormData({ ...formData, type: val })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-zinc-900 border-zinc-700">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-zinc-900 border-zinc-700">
|
||||||
|
<SelectItem value="VEGETATIVE">Vegetative</SelectItem>
|
||||||
|
<SelectItem value="FLOWER">Flower</SelectItem>
|
||||||
|
<SelectItem value="DRYING">Drying</SelectItem>
|
||||||
|
<SelectItem value="CURING">Curing</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Setup Type</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<button
|
||||||
|
className={`p-4 rounded-xl border flex flex-col items-center gap-2 transition-all ${formData.setupType === 'RACK' ? 'bg-emerald-900/20 border-emerald-500 text-emerald-400' : 'bg-zinc-900 border-zinc-700 text-zinc-400 hover:bg-zinc-800'}`}
|
||||||
|
onClick={() => setFormData({ ...formData, setupType: 'RACK' })}
|
||||||
|
>
|
||||||
|
<Layers className="w-8 h-8" />
|
||||||
|
<span className="font-medium">Vertical Racks</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`p-4 rounded-xl border flex flex-col items-center gap-2 transition-all ${formData.setupType === 'TABLE' ? 'bg-emerald-900/20 border-emerald-500 text-emerald-400' : 'bg-zinc-900 border-zinc-700 text-zinc-400 hover:bg-zinc-800'}`}
|
||||||
|
onClick={() => setFormData({ ...formData, setupType: 'TABLE' })}
|
||||||
|
>
|
||||||
|
<Grid className="w-8 h-8" />
|
||||||
|
<span className="font-medium">Rolling Tables</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* STEP 2: LAYOUT */}
|
||||||
|
<TabsContent value="2" className="space-y-6 pt-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Label>Number of {formData.setupType === 'RACK' ? 'Racks' : 'Tables'}</Label>
|
||||||
|
<span className="text-xl font-mono text-emerald-400">{formData.racksCount}</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[formData.racksCount]}
|
||||||
|
onValueChange={([val]) => setFormData({ ...formData, racksCount: val })}
|
||||||
|
min={1} max={20} step={1}
|
||||||
|
className="py-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Label>Tiers per Rack</Label>
|
||||||
|
<span className="text-2xl font-mono text-emerald-400">{formData.tiers}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[1, 2, 3, 4, 5].map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setFormData({ ...formData, tiers: t })}
|
||||||
|
className={`flex-1 h-12 rounded-lg border font-bold transition-all ${formData.tiers === t ? 'bg-emerald-500 text-black border-emerald-500' : 'bg-zinc-900 border-zinc-700 text-zinc-400'}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-zinc-800">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Rows (Length)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.rowsPerRack}
|
||||||
|
onChange={(e) => setFormData({ ...formData, rowsPerRack: parseInt(e.target.value) || 4 })}
|
||||||
|
className="bg-zinc-900 border-zinc-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Columns (Example: 2 trays wide)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.colsPerRack}
|
||||||
|
onChange={(e) => setFormData({ ...formData, colsPerRack: parseInt(e.target.value) || 2 })}
|
||||||
|
className="bg-zinc-900 border-zinc-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* STEP 3: REVIEW */}
|
||||||
|
<TabsContent value="3" className="space-y-6 pt-4">
|
||||||
|
<div className="bg-zinc-900/50 rounded-xl p-6 border border-zinc-800 space-y-4">
|
||||||
|
<h3 className="text-lg font-medium text-emerald-400">Total Capacity Estimate</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-zinc-400">Total Plant Sites</p>
|
||||||
|
<p className="text-4xl font-bold text-white">{totalPlants}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-zinc-400">Canopy Sq. Ft (Est)</p>
|
||||||
|
<p className="text-4xl font-bold text-white">
|
||||||
|
{totalPlants * 1.5} <span className="text-sm font-normal text-zinc-500">sq ft</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-zinc-800 pt-4 mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-zinc-500">Units:</span>
|
||||||
|
<span className="text-zinc-200">{formData.racksCount} {formData.setupType}s</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-zinc-500">Tiers:</span>
|
||||||
|
<span className="text-zinc-200">{formData.tiers}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-zinc-500">Grid:</span>
|
||||||
|
<span className="text-zinc-200">{formData.rowsPerRack} x {formData.colsPerRack}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex justify-between sm:justify-between w-full">
|
||||||
|
{step > 1 ? (
|
||||||
|
<Button variant="outline" onClick={() => setStep(step - 1)} className="border-zinc-700 text-zinc-300">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div /> // Spacer
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step < 3 ? (
|
||||||
|
<Button onClick={() => setStep(step + 1)} className="bg-emerald-500 hover:bg-emerald-600 text-black">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleGenerate} disabled={loading} className="bg-emerald-500 hover:bg-emerald-600 text-black w-full sm:w-auto">
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Wand2 className="w-4 h-4 mr-2" />}
|
||||||
|
Generate Room
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -205,6 +205,21 @@ export const layoutApi = {
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async generateRoom(data: {
|
||||||
|
floorId: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
setupType: string;
|
||||||
|
tiers: number;
|
||||||
|
racksCount: number;
|
||||||
|
rowsPerRack: number;
|
||||||
|
colsPerRack: number;
|
||||||
|
}): Promise<LayoutRoom> {
|
||||||
|
const response = await api.post('/layout/rooms/generate', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Rooms
|
// Rooms
|
||||||
async createRoom(data: Partial<LayoutRoom>): Promise<LayoutRoom> {
|
async createRoom(data: Partial<LayoutRoom>): Promise<LayoutRoom> {
|
||||||
const response = await api.post('/layout/rooms', data);
|
const response = await api.post('/layout/rooms', data);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Home, Plus, Thermometer, Droplets, ChevronRight, Activity, Leaf, Flower, ShieldCheck, ArrowRight } from 'lucide-react';
|
import { Home, Plus, Thermometer, Droplets, ChevronRight, Activity, Leaf, Flower, ShieldCheck, ArrowRight, Wand2 } from 'lucide-react';
|
||||||
import api from '../lib/api';
|
import api from '../lib/api';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { Card } from '../components/ui/card';
|
import { Card } from '../components/ui/card';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import { RoomLayoutWizard } from '../components/layout/RoomLayoutWizard';
|
||||||
|
|
||||||
export default function RoomsPage() {
|
export default function RoomsPage() {
|
||||||
const { isManager } = usePermissions();
|
const { isManager } = usePermissions();
|
||||||
const [rooms, setRooms] = useState<any[]>([]);
|
const [rooms, setRooms] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRooms();
|
fetchRooms();
|
||||||
|
|
@ -59,10 +61,19 @@ export default function RoomsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isManager && (
|
{isManager && (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsWizardOpen(true)}
|
||||||
|
className="flex items-center gap-2 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/50 px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all"
|
||||||
|
>
|
||||||
|
<Wand2 size={18} />
|
||||||
|
Generate Zone
|
||||||
|
</button>
|
||||||
<button className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all">
|
<button className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-[var(--color-text-inverse)] px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all">
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
Add Zone
|
Add Zone
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -122,9 +133,17 @@ export default function RoomsPage() {
|
||||||
<Home size={40} className="mx-auto text-slate-300 dark:text-slate-700 mb-4" />
|
<Home size={40} className="mx-auto text-slate-300 dark:text-slate-700 mb-4" />
|
||||||
<p className="text-sm font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest mb-4">No Cultivation Zones Configured</p>
|
<p className="text-sm font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest mb-4">No Cultivation Zones Configured</p>
|
||||||
{isManager && (
|
{isManager && (
|
||||||
<button className="btn btn-primary mt-4">
|
<div className="flex gap-4 justify-center">
|
||||||
<Plus size={16} /> Create First Zone
|
<button
|
||||||
|
onClick={() => setIsWizardOpen(true)}
|
||||||
|
className="flex items-center gap-2 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/50 px-4 py-2.5 rounded-xl font-bold text-sm shadow-lg transition-all"
|
||||||
|
>
|
||||||
|
<Wand2 size={16} /> Generate Zone
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn btn-primary mt-4">
|
||||||
|
<Plus size={16} /> Create Custom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -205,7 +224,13 @@ export default function RoomsPage() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<RoomLayoutWizard
|
||||||
|
isOpen={isWizardOpen}
|
||||||
|
onClose={() => setIsWizardOpen(false)}
|
||||||
|
onSuccess={fetchRooms}
|
||||||
|
floorId="default-floor-id"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue