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
|
||||
// ========================================
|
||||
|
||||
// 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', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Billboard } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import { PlantStage } from './types';
|
||||
|
||||
// Stage-specific icon shapes as SVG paths (camera-facing 2D)
|
||||
// These are custom illustrations optimized for clarity at small sizes
|
||||
|
||||
export type PlantStage = 'CLONE_IN' | 'VEGETATIVE' | 'FLOWERING' | 'DRYING' | 'CURING' | 'HARVESTED' | 'FINISHED';
|
||||
|
||||
interface PlantIconProps {
|
||||
stage: PlantStage;
|
||||
color: string;
|
||||
|
|
@ -26,6 +25,7 @@ const STAGE_ICONS: Record<PlantStage, { shape: 'circle' | 'leaf' | 'flower' | 'd
|
|||
CURING: { shape: 'square' }, // Jar/container square
|
||||
HARVESTED: { shape: 'ring' }, // Empty circle
|
||||
FINISHED: { shape: 'ring' }, // Empty circle
|
||||
HIDDEN: { shape: 'circle' }, // Placeholder
|
||||
};
|
||||
|
||||
// Create geometry for each shape type
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Instances, Instance } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import { PlantPosition, VisMode, COLORS } from './types';
|
||||
import { PlantIcon, PlantStage } from './PlantIcon';
|
||||
import { PlantPosition, VisMode, COLORS, PlantStage } from './types';
|
||||
import { PlantIcon } from './PlantIcon';
|
||||
import { VolumetricPlant } from './VolumetricPlant';
|
||||
|
||||
// Mock helpers
|
||||
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
|
||||
}, [plants, visMode, highlightSet, dimMode, hasHighlights, timelineDate]);
|
||||
|
||||
// New icon-based rendering
|
||||
// New volumetric/icon based rendering
|
||||
if (useIcons) {
|
||||
return (
|
||||
<group>
|
||||
{plantData.map(({ pos, color, isHighlighted, shouldDim, stage }, i) => (
|
||||
<PlantIcon
|
||||
<VolumetricPlant
|
||||
key={pos.id || i}
|
||||
stage={stage as PlantStage}
|
||||
color={color}
|
||||
position={[pos.x, pos.y + 0.3, pos.z]}
|
||||
scale={1.2}
|
||||
position={[pos.x, pos.y + 0.05, pos.z]} // Lower start for volumetric base
|
||||
scale={1.0}
|
||||
isHighlighted={isHighlighted || false}
|
||||
isDimmed={shouldDim}
|
||||
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 PlantStage = 'CLONE_IN' | 'VEGETATIVE' | 'FLOWERING' | 'DRYING' | 'CURING' | 'HARVESTED' | 'FINISHED' | 'HIDDEN';
|
||||
|
||||
// Breadcrumb data for hierarchy navigation
|
||||
export interface PlantBreadcrumb {
|
||||
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;
|
||||
},
|
||||
|
||||
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
|
||||
async createRoom(data: Partial<LayoutRoom>): Promise<LayoutRoom> {
|
||||
const response = await api.post('/layout/rooms', data);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
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 { usePermissions } from '../hooks/usePermissions';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { cn } from '../lib/utils';
|
||||
import { RoomLayoutWizard } from '../components/layout/RoomLayoutWizard';
|
||||
|
||||
export default function RoomsPage() {
|
||||
const { isManager } = usePermissions();
|
||||
const [rooms, setRooms] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
|
|
@ -59,10 +61,19 @@ export default function RoomsPage() {
|
|||
</div>
|
||||
|
||||
{isManager && (
|
||||
<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} />
|
||||
Add Zone
|
||||
</button>
|
||||
<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">
|
||||
<Plus size={18} />
|
||||
Add Zone
|
||||
</button>
|
||||
</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" />
|
||||
<p className="text-sm font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest mb-4">No Cultivation Zones Configured</p>
|
||||
{isManager && (
|
||||
<button className="btn btn-primary mt-4">
|
||||
<Plus size={16} /> Create First Zone
|
||||
</button>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<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 className="btn btn-primary mt-4">
|
||||
<Plus size={16} /> Create Custom
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -205,7 +224,13 @@ export default function RoomsPage() {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RoomLayoutWizard
|
||||
isOpen={isWizardOpen}
|
||||
onClose={() => setIsWizardOpen(false)}
|
||||
onSuccess={fetchRooms}
|
||||
floorId="default-floor-id"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue