feat: Add Rackula-inspired layout system with 2D editor
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

- Add PlantType model with Zod validation
- Add PlantType CRUD API routes
- Add 2D Layout Editor components (TypeLibrary, RackVisualizer, LayoutEditor)
- Add seed script with 21 common cannabis strains
- Add /layout-editor/:floorId? route
This commit is contained in:
fullsizemalt 2026-01-01 14:12:03 -08:00
parent 1a13087c53
commit d9d04045cb
14 changed files with 1451 additions and 0 deletions

View file

@ -510,6 +510,34 @@ enum SectionType {
FLOOR
}
// Plant Type Library (Rackula-inspired DeviceType pattern)
enum PlantCategory {
VEG
FLOWER
MOTHER
CLONE
SEEDLING
}
model PlantType {
id String @id @default(uuid())
slug String @unique // kebab-case identifier (e.g., "gorilla-glue-4")
name String // Display name
strain String? // Strain name
category PlantCategory // VEG, FLOWER, MOTHER, CLONE, SEEDLING
colour String // Hex color for display (e.g., "#4A90D9")
growthDays Int? // Expected days to harvest
yieldGrams Float? // Expected yield in grams
notes String?
tags String[]
customFields Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("plant_types")
}
model FacilityProperty {
id String @id @default(uuid())
name String

View file

@ -0,0 +1,91 @@
/**
* Seed script for common cannabis plant types
* Run with: npx ts-node prisma/seed-plant-types.ts
*/
import { PrismaClient, PlantCategory } from '@prisma/client';
const prisma = new PrismaClient();
// Common cannabis strains pre-populated for user convenience
const COMMON_STRAINS = [
// Indica-dominant
{ name: 'Gorilla Glue #4', strain: 'GG4', category: 'FLOWER' as PlantCategory, colour: '#4A5568', growthDays: 63, yieldGrams: 500, tags: ['indica', 'high-thc', 'relaxing'] },
{ name: 'Northern Lights', strain: 'NL', category: 'FLOWER' as PlantCategory, colour: '#5B21B6', growthDays: 56, yieldGrams: 450, tags: ['indica', 'classic', 'sleep'] },
{ name: 'Granddaddy Purple', strain: 'GDP', category: 'FLOWER' as PlantCategory, colour: '#7C3AED', growthDays: 60, yieldGrams: 400, tags: ['indica', 'purple', 'grape'] },
{ name: 'Purple Punch', strain: 'PP', category: 'FLOWER' as PlantCategory, colour: '#8B5CF6', growthDays: 56, yieldGrams: 450, tags: ['indica', 'dessert', 'relaxing'] },
// Sativa-dominant
{ name: 'Sour Diesel', strain: 'SD', category: 'FLOWER' as PlantCategory, colour: '#10B981', growthDays: 70, yieldGrams: 550, tags: ['sativa', 'energizing', 'diesel'] },
{ name: 'Jack Herer', strain: 'JH', category: 'FLOWER' as PlantCategory, colour: '#059669', growthDays: 63, yieldGrams: 400, tags: ['sativa', 'creative', 'pine'] },
{ name: 'Green Crack', strain: 'GC', category: 'FLOWER' as PlantCategory, colour: '#34D399', growthDays: 55, yieldGrams: 500, tags: ['sativa', 'energy', 'mango'] },
{ name: 'Super Silver Haze', strain: 'SSH', category: 'FLOWER' as PlantCategory, colour: '#6EE7B7', growthDays: 77, yieldGrams: 600, tags: ['sativa', 'award-winner', 'citrus'] },
// Hybrids
{ name: 'Blue Dream', strain: 'BD', category: 'FLOWER' as PlantCategory, colour: '#3B82F6', growthDays: 65, yieldGrams: 550, tags: ['hybrid', 'balanced', 'berry'] },
{ name: 'Wedding Cake', strain: 'WC', category: 'FLOWER' as PlantCategory, colour: '#F472B6', growthDays: 60, yieldGrams: 500, tags: ['hybrid', 'dessert', 'high-thc'] },
{ name: 'Gelato', strain: 'GLT', category: 'FLOWER' as PlantCategory, colour: '#EC4899', growthDays: 56, yieldGrams: 450, tags: ['hybrid', 'dessert', 'fruity'] },
{ name: 'OG Kush', strain: 'OGK', category: 'FLOWER' as PlantCategory, colour: '#84CC16', growthDays: 56, yieldGrams: 400, tags: ['hybrid', 'classic', 'earthy'] },
{ name: 'Girl Scout Cookies', strain: 'GSC', category: 'FLOWER' as PlantCategory, colour: '#22C55E', growthDays: 63, yieldGrams: 350, tags: ['hybrid', 'dessert', 'mint'] },
{ name: 'White Widow', strain: 'WW', category: 'FLOWER' as PlantCategory, colour: '#E5E7EB', growthDays: 60, yieldGrams: 450, tags: ['hybrid', 'classic', 'potent'] },
// Mother plants (common keeper phenotypes)
{ name: 'Mother - GG4 Elite', strain: 'GG4', category: 'MOTHER' as PlantCategory, colour: '#F59E0B', tags: ['mother', 'keeper', 'high-yield'] },
{ name: 'Mother - OG Kush S1', strain: 'OGK', category: 'MOTHER' as PlantCategory, colour: '#F97316', tags: ['mother', 'clone-source'] },
// Veg stage templates
{ name: 'Veg - Standard', strain: undefined, category: 'VEG' as PlantCategory, colour: '#10B981', growthDays: 28, tags: ['veg', 'standard'] },
{ name: 'Veg - Extended', strain: undefined, category: 'VEG' as PlantCategory, colour: '#059669', growthDays: 42, tags: ['veg', 'extended', 'large-plants'] },
// Clone templates
{ name: 'Clone - Rooting', strain: undefined, category: 'CLONE' as PlantCategory, colour: '#06B6D4', growthDays: 14, tags: ['clone', 'rooting'] },
{ name: 'Clone - Transplant Ready', strain: undefined, category: 'CLONE' as PlantCategory, colour: '#0891B2', growthDays: 21, tags: ['clone', 'ready'] },
// Seedling
{ name: 'Seedling - Week 1-2', strain: undefined, category: 'SEEDLING' as PlantCategory, colour: '#A3E635', growthDays: 14, tags: ['seedling', 'early'] },
];
function generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-');
}
async function main() {
console.log('🌱 Seeding plant types...');
for (const strain of COMMON_STRAINS) {
const slug = generateSlug(strain.name);
// Upsert to avoid duplicates
await prisma.plantType.upsert({
where: { slug },
update: {},
create: {
slug,
name: strain.name,
strain: strain.strain,
category: strain.category,
colour: strain.colour,
growthDays: strain.growthDays,
yieldGrams: strain.yieldGrams,
tags: strain.tags || [],
},
});
console.log(`${strain.name} (${slug})`);
}
console.log(`\n✅ Seeded ${COMMON_STRAINS.length} plant types`);
}
main()
.catch((e) => {
console.error('❌ Seed failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View file

@ -995,4 +995,132 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
}
}
});
// ========================================
// PLANT TYPE LIBRARY ROUTES (Rackula-inspired)
// ========================================
// Get all plant types
fastify.get('/plant-types', {
handler: async (request, reply) => {
try {
const plantTypes = await prisma.plantType.findMany({
orderBy: { name: 'asc' }
});
return plantTypes;
} catch (error) {
fastify.log.error(error);
return reply.status(500).send({ error: 'Failed to fetch plant types' });
}
}
});
// Get plant type by slug
fastify.get('/plant-types/:slug', {
handler: async (request, reply) => {
try {
const { slug } = request.params as { slug: string };
const plantType = await prisma.plantType.findUnique({
where: { slug }
});
if (!plantType) {
return reply.status(404).send({ error: 'Plant type not found' });
}
return plantType;
} catch (error) {
fastify.log.error(error);
return reply.status(500).send({ error: 'Failed to fetch plant type' });
}
}
});
// Create plant type
fastify.post('/plant-types', {
handler: async (request, reply) => {
try {
const { name, strain, category, colour, growthDays, yieldGrams, notes, tags, customFields } = request.body as any;
// Generate slug from name
const baseSlug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-');
// Check for existing slug and make unique if needed
let slug = baseSlug;
let counter = 1;
while (await prisma.plantType.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${counter}`;
counter++;
}
const plantType = await prisma.plantType.create({
data: {
slug,
name,
strain,
category,
colour,
growthDays,
yieldGrams,
notes,
tags: tags || [],
customFields
}
});
return reply.status(201).send(plantType);
} catch (error) {
fastify.log.error(error);
return reply.status(500).send({ error: 'Failed to create plant type' });
}
}
});
// Update plant type
fastify.put('/plant-types/:slug', {
handler: async (request, reply) => {
try {
const { slug } = request.params as { slug: string };
const { name, strain, category, colour, growthDays, yieldGrams, notes, tags, customFields } = request.body as any;
const plantType = await prisma.plantType.update({
where: { slug },
data: {
name,
strain,
category,
colour,
growthDays,
yieldGrams,
notes,
tags,
customFields
}
});
return plantType;
} catch (error) {
fastify.log.error(error);
return reply.status(500).send({ error: 'Failed to update plant type' });
}
}
});
// Delete plant type
fastify.delete('/plant-types/:slug', {
handler: async (request, reply) => {
try {
const { slug } = request.params as { slug: string };
await prisma.plantType.delete({ where: { slug } });
return reply.status(204).send();
} catch (error) {
fastify.log.error(error);
return reply.status(500).send({ error: 'Failed to delete plant type' });
}
}
});
}

View file

@ -0,0 +1,266 @@
/**
* Layout Zod Validation Schemas
* Inspired by Rackula's schema architecture
* Schema v1.0.0 - Flat structure with plant-specific fields
*/
import { z } from 'zod';
// =============================================================================
// Constants & Patterns
// =============================================================================
/**
* Slug pattern: lowercase alphanumeric with hyphens, no leading/trailing/consecutive hyphens
*/
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
/**
* Hex colour pattern: 6-character hex with # prefix
*/
const HEX_COLOUR_PATTERN = /^#[0-9a-fA-F]{6}$/;
// =============================================================================
// Enums
// =============================================================================
/**
* Plant category types for library organization
*/
export const PlantCategorySchema = z.enum([
'VEG',
'FLOWER',
'MOTHER',
'CLONE',
'SEEDLING',
]);
export type PlantCategory = z.infer<typeof PlantCategorySchema>;
/**
* Section subtype - preserves visual diversity while using unified Rack concept
*/
export const SectionSubtypeSchema = z.enum([
'TABLE',
'RACK',
'TRAY',
'HANGER',
'FLOOR',
]);
export type SectionSubtype = z.infer<typeof SectionSubtypeSchema>;
/**
* Position status
*/
export const PositionStatusSchema = z.enum([
'EMPTY',
'OCCUPIED',
'RESERVED',
'DAMAGED',
]);
export type PositionStatus = z.infer<typeof PositionStatusSchema>;
// =============================================================================
// Core Schemas
// =============================================================================
/**
* Slug schema for plant type identification
*/
export const SlugSchema = z
.string()
.min(1, 'Slug is required')
.max(100, 'Slug must be 100 characters or less')
.regex(
SLUG_PATTERN,
'Slug must be lowercase with hyphens only (no leading/trailing/consecutive)'
);
/**
* Plant Type - template definition in library
* Analogous to Rackula's DeviceType
*/
export const PlantTypeSchema = z.object({
// --- Core Identity ---
slug: SlugSchema,
name: z.string().min(1, 'Name is required').max(100),
strain: z.string().max(100).optional(),
// --- Classification ---
category: PlantCategorySchema,
// --- Visual Properties ---
colour: z
.string()
.regex(HEX_COLOUR_PATTERN, 'Color must be valid hex (e.g., #4A90D9)'),
// --- Growth Properties ---
growthDays: z.number().int().positive().optional(),
yieldGrams: z.number().positive().optional(),
// --- Metadata ---
notes: z.string().optional(),
tags: z.array(z.string()).optional(),
customFields: z.record(z.unknown()).optional(),
});
export type PlantType = z.infer<typeof PlantTypeSchema>;
/**
* Placed Plant - instance at a specific position
* Analogous to Rackula's PlacedDevice
*/
export const PlacedPlantSchema = z.object({
// --- Identity ---
id: z.string().uuid(),
plantTypeSlug: z.string().min(1, 'Plant type slug is required'),
// --- Position ---
row: z.number().int().min(0),
column: z.number().int().min(0),
tier: z.number().int().min(1).default(1),
slot: z.number().int().min(1).default(1),
// --- Overrides ---
name: z.string().optional(),
colourOverride: z.string().regex(HEX_COLOUR_PATTERN).optional(),
// --- Metadata ---
notes: z.string().optional(),
customFields: z.record(z.unknown()).optional(),
});
export type PlacedPlant = z.infer<typeof PlacedPlantSchema>;
/**
* Rack/Section - container structure for plants
* Analogous to Rackula's Rack, with subtype for visual variety
*/
export const RackSchema = z.object({
// --- Identity ---
id: z.string().uuid(),
name: z.string().min(1),
code: z.string().max(20).optional(),
// --- Type (hybrid approach) ---
subtype: SectionSubtypeSchema.default('RACK'),
// --- Dimensions ---
rows: z.number().int().min(1).max(100),
columns: z.number().int().min(1).max(100),
tiers: z.number().int().min(1).max(10).default(1),
spacing: z.number().positive().optional(), // inches between positions
// --- Position on floor ---
posX: z.number(),
posY: z.number(),
width: z.number().positive(),
height: z.number().positive(),
// --- Contents ---
plants: z.array(PlacedPlantSchema).default([]),
// --- Metadata ---
notes: z.string().optional(),
});
export type Rack = z.infer<typeof RackSchema>;
/**
* Room Layout - complete room state
*/
export const RoomLayoutSchema = z.object({
// --- Identity ---
id: z.string().uuid(),
name: z.string().min(1),
code: z.string().max(20).optional(),
type: z.enum(['VEG', 'FLOWER', 'DRY', 'CURE', 'MOTHER', 'CLONE', 'FACILITY']),
// --- Position on floor ---
posX: z.number(),
posY: z.number(),
width: z.number().positive(),
height: z.number().positive(),
rotation: z.number().default(0),
// --- Visual ---
color: z.string().regex(HEX_COLOUR_PATTERN).optional(),
// --- Contents ---
racks: z.array(RackSchema).default([]),
});
export type RoomLayout = z.infer<typeof RoomLayoutSchema>;
/**
* Floor Layout - complete floor state for serialization
*/
export const FloorLayoutSchema = z.object({
// --- Metadata ---
version: z.string().default('1.0.0'),
name: z.string(),
// --- Floor dimensions ---
width: z.number().positive(),
height: z.number().positive(),
ceilingHeight: z.number().positive().optional(),
// --- Contents ---
rooms: z.array(RoomLayoutSchema),
// --- Plant Type Library ---
plantTypes: z.array(PlantTypeSchema).default([]),
});
export type FloorLayout = z.infer<typeof FloorLayoutSchema>;
// =============================================================================
// Helper Types for Creation
// =============================================================================
/**
* Helper type for creating a PlantType
*/
export const CreatePlantTypeSchema = PlantTypeSchema.omit({
slug: true,
}).extend({
name: z.string().min(1),
});
export type CreatePlantTypeData = z.infer<typeof CreatePlantTypeSchema>;
/**
* Helper type for updating a PlantType
*/
export const UpdatePlantTypeSchema = PlantTypeSchema.partial().omit({
slug: true,
});
export type UpdatePlantTypeData = z.infer<typeof UpdatePlantTypeSchema>;
// =============================================================================
// Utility Functions
// =============================================================================
/**
* Generate a slug from a name
*/
export function generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-');
}
/**
* Validates that all slugs in an array are unique
*/
export function validateSlugUniqueness(items: { slug: string }[]): string[] {
const slugCounts = new Map<string, number>();
for (const item of items) {
slugCounts.set(item.slug, (slugCounts.get(item.slug) ?? 0) + 1);
}
const duplicates: string[] = [];
for (const [slug, count] of slugCounts) {
if (count > 1) {
duplicates.push(slug);
}
}
return duplicates;
}

View file

@ -34,6 +34,7 @@
"react-router-dom": "^7.10.1",
"tailwind-merge": "^3.4.0",
"three": "0.165.0",
"zod": "^4.3.4",
"zustand": "^4.5.2"
},
"devDependencies": {
@ -9310,6 +9311,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.4.tgz",
"integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.2.tgz",

View file

@ -37,6 +37,7 @@
"react-router-dom": "^7.10.1",
"tailwind-merge": "^3.4.0",
"three": "0.165.0",
"zod": "^4.3.4",
"zustand": "^4.5.2"
},
"devDependencies": {

View file

@ -0,0 +1,229 @@
/**
* LayoutEditor - Main 2D room/floor editor with drag-and-drop
* Focused on intuitive UX for facility layout management
*/
import { useState, useEffect, useCallback } from 'react';
import { TypeLibrary } from './TypeLibrary';
import { RackVisualizer, RackData, PlantSlotData } from './RackVisualizer';
import { cn } from '../../lib/utils';
import { layoutApi, LayoutPlantType, Floor3DData } from '../../lib/layoutApi';
interface LayoutEditorProps {
floorId: string;
className?: string;
}
export function LayoutEditor({ floorId, className }: LayoutEditorProps) {
const [floorData, setFloorData] = useState<Floor3DData | null>(null);
const [plantTypes, setPlantTypes] = useState<LayoutPlantType[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedSlot, setSelectedSlot] = useState<PlantSlotData | null>(null);
const [draggingType, setDraggingType] = useState<LayoutPlantType | null>(null);
// Load floor data and plant types
useEffect(() => {
async function load() {
try {
setLoading(true);
const [floor, types] = await Promise.all([
layoutApi.getFloor3D(floorId),
layoutApi.getPlantTypes(),
]);
setFloorData(floor);
setPlantTypes(types);
} catch (e) {
setError('Failed to load layout data');
console.error(e);
} finally {
setLoading(false);
}
}
load();
}, [floorId]);
// Convert floor data to rack format
const racks: RackData[] = floorData?.rooms.flatMap(room =>
room.sections.map(section => ({
id: section.id,
name: section.name,
code: section.code,
subtype: (section.type as RackData['subtype']) || 'RACK',
rows: section.rows,
columns: section.columns,
tiers: 1, // TODO: get from section
slots: section.positions.map(pos => ({
id: pos.id,
row: pos.row,
column: pos.column,
tier: pos.tier,
status: pos.status as PlantSlotData['status'],
plantType: pos.plant ? plantTypes.find(pt => pt.strain === pos.plant?.strain) : undefined,
tagNumber: pos.plant?.tagNumber,
})),
}))
) || [];
const handleSlotClick = useCallback((slot: PlantSlotData) => {
setSelectedSlot(slot);
}, []);
const handleSlotDrop = useCallback(async (slot: PlantSlotData, plantType: LayoutPlantType) => {
console.log('Dropped', plantType.name, 'onto slot', slot.id);
// TODO: Implement actual placement via API
// For now, just log the action
}, []);
const handleDragStart = useCallback((plantType: LayoutPlantType) => {
setDraggingType(plantType);
}, []);
if (loading) {
return (
<div className={cn('flex items-center justify-center h-full', className)}>
<div className="text-slate-400">Loading layout...</div>
</div>
);
}
if (error) {
return (
<div className={cn('flex items-center justify-center h-full', className)}>
<div className="text-red-400">{error}</div>
</div>
);
}
return (
<div className={cn('flex h-full bg-slate-950', className)}>
{/* Plant Type Sidebar */}
<TypeLibrary
plantTypes={plantTypes}
onDragStart={handleDragStart}
className="w-64 flex-shrink-0"
/>
{/* Main Canvas */}
<div className="flex-1 overflow-auto p-6">
{/* Header */}
<div className="mb-6">
<h1 className="text-xl font-bold text-white">
{floorData?.floor.property} {floorData?.floor.building}
</h1>
<p className="text-sm text-slate-400">
{floorData?.floor.name} {floorData?.stats.occupiedPositions}/{floorData?.stats.totalPositions} positions filled
</p>
</div>
{/* Rooms */}
{floorData?.rooms.map(room => (
<div key={room.id} className="mb-8">
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: room.color || '#3b82f6' }}
/>
{room.name}
<span className="text-xs text-slate-400 font-normal">({room.code})</span>
</h2>
{/* Racks Grid */}
<div className="flex flex-wrap gap-4">
{racks
.filter(r => room.sections.some(s => s.id === r.id))
.map(rack => (
<RackVisualizer
key={rack.id}
rack={rack}
onSlotClick={handleSlotClick}
onSlotDrop={handleSlotDrop}
/>
))}
</div>
</div>
))}
{/* Empty state */}
{(!floorData?.rooms || floorData.rooms.length === 0) && (
<div className="flex flex-col items-center justify-center h-64 text-slate-400">
<svg className="w-16 h-16 mb-4 text-slate-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
<p className="text-lg font-medium">No rooms configured</p>
<p className="text-sm">Add rooms to this floor to start placing plants</p>
</div>
)}
</div>
{/* Selected Slot Panel */}
{selectedSlot && (
<div className="w-72 border-l border-slate-700 bg-slate-900 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Slot Details</h3>
<button
onClick={() => setSelectedSlot(null)}
className="text-slate-400 hover:text-white"
>
</button>
</div>
<div className="space-y-3 text-sm">
<div>
<span className="text-slate-400">Position:</span>
<span className="ml-2 text-white">R{selectedSlot.row} C{selectedSlot.column}</span>
</div>
<div>
<span className="text-slate-400">Status:</span>
<span className={cn(
'ml-2',
selectedSlot.status === 'OCCUPIED' && 'text-emerald-400',
selectedSlot.status === 'EMPTY' && 'text-slate-400',
selectedSlot.status === 'DAMAGED' && 'text-red-400',
selectedSlot.status === 'RESERVED' && 'text-yellow-400',
)}>
{selectedSlot.status}
</span>
</div>
{selectedSlot.plantType && (
<>
<div>
<span className="text-slate-400">Plant:</span>
<span className="ml-2 text-white">{selectedSlot.plantType.name}</span>
</div>
{selectedSlot.tagNumber && (
<div>
<span className="text-slate-400">Tag:</span>
<span className="ml-2 text-white font-mono">{selectedSlot.tagNumber}</span>
</div>
)}
</>
)}
</div>
{/* Actions */}
<div className="mt-6 space-y-2">
{selectedSlot.status === 'EMPTY' && (
<button className="w-full px-3 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm rounded transition-colors">
Place Plant
</button>
)}
{selectedSlot.status === 'OCCUPIED' && (
<>
<button className="w-full px-3 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm rounded transition-colors">
Move Plant
</button>
<button className="w-full px-3 py-2 bg-red-600 hover:bg-red-500 text-white text-sm rounded transition-colors">
Remove Plant
</button>
</>
)}
</div>
</div>
)}
</div>
);
}
export default LayoutEditor;

View file

@ -0,0 +1,214 @@
/**
* RackVisualizer - 2D SVG visualization of a rack/section with plant slots
* Intuitive grid-based layout with drag-drop targets
*/
import { useMemo } from 'react';
import { cn } from '../../lib/utils';
import type { LayoutPlantType } from '../../lib/layoutApi';
export interface PlantSlotData {
id: string;
row: number;
column: number;
tier: number;
status: 'EMPTY' | 'OCCUPIED' | 'RESERVED' | 'DAMAGED';
plantType?: LayoutPlantType;
tagNumber?: string;
}
export interface RackData {
id: string;
name: string;
code: string;
subtype: 'TABLE' | 'RACK' | 'TRAY' | 'HANGER' | 'FLOOR';
rows: number;
columns: number;
tiers: number;
slots: PlantSlotData[];
}
interface RackVisualizerProps {
rack: RackData;
selectedTier?: number;
onSlotClick?: (slot: PlantSlotData) => void;
onSlotDrop?: (slot: PlantSlotData, plantType: LayoutPlantType) => void;
highlightedSlots?: Set<string>;
className?: string;
}
const SLOT_SIZE = 40; // pixels
const SLOT_GAP = 4;
const TIER_TAB_HEIGHT = 24;
// Subtype visual styles
const SUBTYPE_STYLES = {
TABLE: { bg: 'bg-amber-900/20', border: 'border-amber-700', icon: '🪵' },
RACK: { bg: 'bg-slate-800/50', border: 'border-slate-600', icon: '🗄️' },
TRAY: { bg: 'bg-emerald-900/20', border: 'border-emerald-700', icon: '🌿' },
HANGER: { bg: 'bg-purple-900/20', border: 'border-purple-700', icon: '🪝' },
FLOOR: { bg: 'bg-stone-900/20', border: 'border-stone-600', icon: '⬜' },
};
export function RackVisualizer({
rack,
selectedTier = 1,
onSlotClick,
onSlotDrop,
highlightedSlots,
className
}: RackVisualizerProps) {
const style = SUBTYPE_STYLES[rack.subtype] || SUBTYPE_STYLES.RACK;
// Filter slots for selected tier
const tierSlots = useMemo(() => {
return rack.slots.filter(s => s.tier === selectedTier);
}, [rack.slots, selectedTier]);
// Create grid lookup
const slotGrid = useMemo(() => {
const grid = new Map<string, PlantSlotData>();
for (const slot of tierSlots) {
grid.set(`${slot.row}-${slot.column}`, slot);
}
return grid;
}, [tierSlots]);
const gridWidth = rack.columns * (SLOT_SIZE + SLOT_GAP) + SLOT_GAP;
const gridHeight = rack.rows * (SLOT_SIZE + SLOT_GAP) + SLOT_GAP;
const handleDrop = (slot: PlantSlotData, e: React.DragEvent) => {
e.preventDefault();
const data = e.dataTransfer.getData('application/json');
if (data && onSlotDrop) {
try {
const plantType = JSON.parse(data) as LayoutPlantType;
onSlotDrop(slot, plantType);
} catch {
// Invalid data
}
}
};
return (
<div className={cn('inline-block', className)}>
{/* Rack header */}
<div className={cn('flex items-center gap-2 px-3 py-1.5 rounded-t-lg border-b-0', style.bg, style.border, 'border')}>
<span className="text-lg">{style.icon}</span>
<span className="text-sm font-medium text-white">{rack.name}</span>
<span className="text-xs text-slate-400">({rack.code})</span>
{rack.tiers > 1 && (
<span className="ml-auto text-xs text-slate-400">Tier {selectedTier}/{rack.tiers}</span>
)}
</div>
{/* Tier tabs (if multi-tier) */}
{rack.tiers > 1 && (
<div className="flex border-l border-r" style={{ borderColor: style.border.replace('border-', '') }}>
{Array.from({ length: rack.tiers }, (_, i) => i + 1).map(tier => (
<button
key={tier}
className={cn(
'flex-1 text-xs py-1 transition-colors',
tier === selectedTier
? 'bg-emerald-600 text-white'
: 'bg-slate-800 text-slate-400 hover:bg-slate-700'
)}
>
T{tier}
</button>
))}
</div>
)}
{/* Grid */}
<div
className={cn('p-1 rounded-b-lg border', style.bg, style.border)}
style={{ width: gridWidth + 8, minHeight: gridHeight + 8 }}
>
<div
className="relative"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${rack.columns}, ${SLOT_SIZE}px)`,
gridTemplateRows: `repeat(${rack.rows}, ${SLOT_SIZE}px)`,
gap: SLOT_GAP,
}}
>
{Array.from({ length: rack.rows }, (_, rowIdx) =>
Array.from({ length: rack.columns }, (_, colIdx) => {
const row = rowIdx + 1;
const col = colIdx + 1;
const slot = slotGrid.get(`${row}-${col}`);
const isHighlighted = highlightedSlots?.has(slot?.id || '');
return (
<PlantSlot
key={`${row}-${col}`}
slot={slot || { id: `empty-${row}-${col}`, row, column: col, tier: selectedTier, status: 'EMPTY' }}
isHighlighted={isHighlighted}
onClick={onSlotClick}
onDrop={(s, e) => handleDrop(s, e)}
/>
);
})
)}
</div>
</div>
</div>
);
}
interface PlantSlotProps {
slot: PlantSlotData;
isHighlighted?: boolean;
onClick?: (slot: PlantSlotData) => void;
onDrop: (slot: PlantSlotData, e: React.DragEvent) => void;
}
function PlantSlot({ slot, isHighlighted, onClick, onDrop }: PlantSlotProps) {
const isOccupied = slot.status === 'OCCUPIED' && slot.plantType;
const isDamaged = slot.status === 'DAMAGED';
const isReserved = slot.status === 'RESERVED';
const handleDragOver = (e: React.DragEvent) => {
if (slot.status === 'EMPTY') {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
};
return (
<div
onClick={() => onClick?.(slot)}
onDragOver={handleDragOver}
onDrop={(e) => onDrop(slot, e)}
className={cn(
'rounded flex items-center justify-center cursor-pointer transition-all',
'border-2 border-dashed',
slot.status === 'EMPTY' && 'border-slate-600 bg-slate-800/30 hover:border-emerald-500 hover:bg-emerald-900/20',
isOccupied && 'border-solid',
isDamaged && 'border-red-500 bg-red-900/30',
isReserved && 'border-yellow-500 bg-yellow-900/20 border-solid',
isHighlighted && 'ring-2 ring-emerald-400 ring-offset-1 ring-offset-slate-900'
)}
style={{
width: SLOT_SIZE,
height: SLOT_SIZE,
backgroundColor: isOccupied ? slot.plantType?.colour : undefined,
borderColor: isOccupied ? slot.plantType?.colour : undefined,
}}
title={isOccupied ? `${slot.plantType?.name} (${slot.tagNumber || 'No tag'})` : `Empty - R${slot.row}C${slot.column}`}
>
{isOccupied && (
<span className="text-white text-xs font-bold drop-shadow-md">
{slot.plantType?.strain?.substring(0, 3) || '•'}
</span>
)}
{isDamaged && <span className="text-red-400"></span>}
{isReserved && <span className="text-yellow-400 text-xs"></span>}
</div>
);
}
export default RackVisualizer;

View file

@ -0,0 +1,180 @@
/**
* TypeLibrary - Sidebar component displaying plant types for drag-and-drop
* Inspired by Rackula's device panel
*/
import { useState, useMemo } from 'react';
import { cn } from '../../lib/utils';
import type { LayoutPlantType } from '../../lib/layoutApi';
interface TypeLibraryProps {
plantTypes: LayoutPlantType[];
onDragStart: (plantType: LayoutPlantType) => void;
onCreateType?: () => void;
className?: string;
}
const CATEGORY_LABELS: Record<string, string> = {
FLOWER: '🌸 Flower',
VEG: '🌿 Veg',
CLONE: '🌱 Clone',
MOTHER: '👑 Mother',
SEEDLING: '🌾 Seedling',
};
const CATEGORY_ORDER = ['FLOWER', 'VEG', 'CLONE', 'MOTHER', 'SEEDLING'];
export function TypeLibrary({ plantTypes, onDragStart, onCreateType, className }: TypeLibraryProps) {
const [search, setSearch] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['FLOWER']));
// Group by category and filter by search
const groupedTypes = useMemo(() => {
const filtered = plantTypes.filter(pt =>
pt.name.toLowerCase().includes(search.toLowerCase()) ||
pt.strain?.toLowerCase().includes(search.toLowerCase())
);
const groups: Record<string, LayoutPlantType[]> = {};
for (const pt of filtered) {
if (!groups[pt.category]) groups[pt.category] = [];
groups[pt.category].push(pt);
}
return groups;
}, [plantTypes, search]);
const toggleCategory = (cat: string) => {
setExpandedCategories(prev => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
return next;
});
};
return (
<div className={cn('flex flex-col h-full bg-slate-900 border-r border-slate-700', className)}>
{/* Header */}
<div className="p-3 border-b border-slate-700">
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-white">Plant Library</h2>
{onCreateType && (
<button
onClick={onCreateType}
className="text-xs px-2 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded transition-colors"
>
+ New
</button>
)}
</div>
<input
type="text"
placeholder="Search strains..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full px-3 py-1.5 text-sm bg-slate-800 border border-slate-600 rounded text-white placeholder-slate-400 focus:outline-none focus:border-emerald-500"
/>
</div>
{/* Categories */}
<div className="flex-1 overflow-y-auto">
{CATEGORY_ORDER.map(category => {
const types = groupedTypes[category] || [];
if (types.length === 0 && search) return null;
const isExpanded = expandedCategories.has(category);
return (
<div key={category} className="border-b border-slate-800">
<button
onClick={() => toggleCategory(category)}
className="w-full px-3 py-2 flex items-center justify-between text-left hover:bg-slate-800 transition-colors"
>
<span className="text-sm font-medium text-slate-300">
{CATEGORY_LABELS[category] || category}
<span className="ml-2 text-xs text-slate-500">({types.length})</span>
</span>
<svg
className={cn('w-4 h-4 text-slate-400 transition-transform', isExpanded && 'rotate-180')}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isExpanded && (
<div className="pb-2">
{types.map(pt => (
<PlantTypeCard key={pt.slug} plantType={pt} onDragStart={onDragStart} />
))}
{types.length === 0 && (
<p className="px-3 py-2 text-xs text-slate-500 italic">No types in this category</p>
)}
</div>
)}
</div>
);
})}
</div>
{/* Footer hint */}
<div className="p-2 border-t border-slate-700 bg-slate-800/50">
<p className="text-xs text-slate-400 text-center">
Drag types onto sections to place
</p>
</div>
</div>
);
}
interface PlantTypeCardProps {
plantType: LayoutPlantType;
onDragStart: (plantType: LayoutPlantType) => void;
}
function PlantTypeCard({ plantType, onDragStart }: PlantTypeCardProps) {
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setData('application/json', JSON.stringify(plantType));
e.dataTransfer.effectAllowed = 'copy';
onDragStart(plantType);
};
return (
<div
draggable
onDragStart={handleDragStart}
className="mx-2 mb-1 px-3 py-2 bg-slate-800 hover:bg-slate-700 rounded cursor-grab active:cursor-grabbing transition-colors group"
>
<div className="flex items-center gap-2">
{/* Color indicator */}
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: plantType.colour }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{plantType.name}</p>
{plantType.strain && (
<p className="text-xs text-slate-400 truncate">{plantType.strain}</p>
)}
</div>
{/* Drag handle hint */}
<svg className="w-4 h-4 text-slate-500 opacity-0 group-hover:opacity-100 transition-opacity" fill="currentColor" viewBox="0 0 20 20">
<path d="M7 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 2zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 14zm6-8a2 2 0 1 0-.001-4.001A2 2 0 0 0 13 6zm0 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 14z" />
</svg>
</div>
{/* Quick stats */}
{(plantType.growthDays || plantType.yieldGrams) && (
<div className="mt-1 flex gap-3 text-xs text-slate-500">
{plantType.growthDays && <span>{plantType.growthDays}d</span>}
{plantType.yieldGrams && <span>{plantType.yieldGrams}g</span>}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,4 @@
export { LayoutEditor } from './LayoutEditor';
export { TypeLibrary } from './TypeLibrary';
export { RackVisualizer } from './RackVisualizer';
export type { RackData, PlantSlotData } from './RackVisualizer';

View file

@ -0,0 +1,125 @@
/**
* Layout Zod Validation Schemas (Frontend)
* Mirrors backend schemas for client-side validation
* Inspired by Rackula's schema architecture
*/
import { z } from 'zod';
// =============================================================================
// Constants & Patterns
// =============================================================================
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const HEX_COLOUR_PATTERN = /^#[0-9a-fA-F]{6}$/;
// =============================================================================
// Enums
// =============================================================================
export const PlantCategorySchema = z.enum([
'VEG',
'FLOWER',
'MOTHER',
'CLONE',
'SEEDLING',
]);
export type PlantCategory = z.infer<typeof PlantCategorySchema>;
export const SectionSubtypeSchema = z.enum([
'TABLE',
'RACK',
'TRAY',
'HANGER',
'FLOOR',
]);
export type SectionSubtype = z.infer<typeof SectionSubtypeSchema>;
// =============================================================================
// Core Schemas
// =============================================================================
export const SlugSchema = z
.string()
.min(1, 'Slug is required')
.max(100)
.regex(SLUG_PATTERN, 'Invalid slug format');
export const PlantTypeSchema = z.object({
id: z.string().uuid().optional(),
slug: SlugSchema,
name: z.string().min(1).max(100),
strain: z.string().max(100).optional(),
category: PlantCategorySchema,
colour: z.string().regex(HEX_COLOUR_PATTERN),
growthDays: z.number().int().positive().optional(),
yieldGrams: z.number().positive().optional(),
notes: z.string().optional(),
tags: z.array(z.string()).optional(),
customFields: z.record(z.string(), z.unknown()).optional(),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
});
export type PlantType = z.infer<typeof PlantTypeSchema>;
export const CreatePlantTypeSchema = PlantTypeSchema.omit({
id: true,
slug: true,
createdAt: true,
updatedAt: true,
});
export type CreatePlantTypeData = z.infer<typeof CreatePlantTypeSchema>;
export const PlacedPlantSchema = z.object({
id: z.string().uuid(),
plantTypeSlug: z.string().min(1),
row: z.number().int().min(0),
column: z.number().int().min(0),
tier: z.number().int().min(1).default(1),
slot: z.number().int().min(1).default(1),
name: z.string().optional(),
colourOverride: z.string().regex(HEX_COLOUR_PATTERN).optional(),
notes: z.string().optional(),
});
export type PlacedPlant = z.infer<typeof PlacedPlantSchema>;
export const RackSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
code: z.string().max(20).optional(),
subtype: SectionSubtypeSchema.default('RACK'),
rows: z.number().int().min(1).max(100),
columns: z.number().int().min(1).max(100),
tiers: z.number().int().min(1).max(10).default(1),
spacing: z.number().positive().optional(),
posX: z.number(),
posY: z.number(),
width: z.number().positive(),
height: z.number().positive(),
plants: z.array(PlacedPlantSchema).default([]),
});
export type Rack = z.infer<typeof RackSchema>;
// =============================================================================
// Utility Functions
// =============================================================================
export function generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-+/g, '-');
}
// Color palette for plant types
export const PLANT_TYPE_COLORS = [
'#10b981', // Emerald
'#3b82f6', // Blue
'#8b5cf6', // Violet
'#f59e0b', // Amber
'#ef4444', // Red
'#06b6d4', // Cyan
'#ec4899', // Pink
'#84cc16', // Lime
] as const;

View file

@ -263,6 +263,63 @@ export const layoutApi = {
async getSection(id: string): Promise<LayoutSection> {
const response = await api.get(`/layout/sections/${id}`);
return response.data;
},
// ========================================
// Plant Type Library (Rackula-inspired)
// ========================================
async getPlantTypes(): Promise<LayoutPlantType[]> {
const response = await api.get('/layout/plant-types');
return response.data;
},
async getPlantType(slug: string): Promise<LayoutPlantType> {
const response = await api.get(`/layout/plant-types/${slug}`);
return response.data;
},
async createPlantType(data: CreatePlantTypeData): Promise<LayoutPlantType> {
const response = await api.post('/layout/plant-types', data);
return response.data;
},
async updatePlantType(slug: string, data: Partial<CreatePlantTypeData>): Promise<LayoutPlantType> {
const response = await api.put(`/layout/plant-types/${slug}`, data);
return response.data;
},
async deletePlantType(slug: string): Promise<void> {
await api.delete(`/layout/plant-types/${slug}`);
}
};
// Plant Type Library Types
export interface LayoutPlantType {
id: string;
slug: string;
name: string;
strain?: string;
category: 'VEG' | 'FLOWER' | 'MOTHER' | 'CLONE' | 'SEEDLING';
colour: string;
growthDays?: number;
yieldGrams?: number;
notes?: string;
tags?: string[];
customFields?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
}
export interface CreatePlantTypeData {
name: string;
strain?: string;
category: 'VEG' | 'FLOWER' | 'MOTHER' | 'CLONE' | 'SEEDLING';
colour: string;
growthDays?: number;
yieldGrams?: number;
notes?: string;
tags?: string[];
customFields?: Record<string, unknown>;
}

View file

@ -0,0 +1,99 @@
/**
* Layout Editor Page - 2D facility layout management
*/
import { useParams, useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { LayoutEditor } from '../components/layout-editor';
import { layoutApi, LayoutProperty } from '../lib/layoutApi';
export function LayoutEditorPage() {
const { floorId } = useParams<{ floorId: string }>();
const navigate = useNavigate();
const [properties, setProperties] = useState<LayoutProperty[]>([]);
const [selectedFloorId, setSelectedFloorId] = useState<string | undefined>(floorId);
const [loading, setLoading] = useState(!floorId);
// Load properties for floor selector
useEffect(() => {
async function load() {
try {
const props = await layoutApi.getProperties();
setProperties(props);
// Auto-select first floor if none specified
if (!floorId && props.length > 0 && props[0].buildings?.length > 0) {
const firstFloor = props[0].buildings[0]?.floors?.[0];
if (firstFloor) {
setSelectedFloorId(firstFloor.id);
navigate(`/layout-editor/${firstFloor.id}`, { replace: true });
}
}
} catch (e) {
console.error('Failed to load properties:', e);
} finally {
setLoading(false);
}
}
load();
}, [floorId, navigate]);
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-slate-950">
<div className="text-slate-400">Loading...</div>
</div>
);
}
// Floor selector if no floor specified
if (!selectedFloorId) {
return (
<div className="flex flex-col items-center justify-center h-screen bg-slate-950 text-white">
<h1 className="text-2xl font-bold mb-6">Select a Floor</h1>
{properties.length === 0 ? (
<div className="text-slate-400">
<p>No facility layouts found.</p>
<p className="text-sm mt-2">Create a property first to get started.</p>
</div>
) : (
<div className="space-y-4 max-w-md w-full">
{properties.map(prop => (
<div key={prop.id} className="bg-slate-900 rounded-lg p-4 border border-slate-700">
<h2 className="font-semibold text-lg mb-2">{prop.name}</h2>
{prop.buildings?.map(building => (
<div key={building.id} className="ml-4 mt-2">
<h3 className="text-sm text-slate-400">{building.name}</h3>
<div className="ml-4 mt-1 space-y-1">
{building.floors?.map(floor => (
<button
key={floor.id}
onClick={() => {
setSelectedFloorId(floor.id);
navigate(`/layout-editor/${floor.id}`);
}}
className="block w-full text-left px-3 py-2 bg-slate-800 hover:bg-emerald-600 rounded transition-colors text-sm"
>
{floor.name}
</button>
))}
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
);
}
return (
<div className="h-screen overflow-hidden">
<LayoutEditor floorId={selectedFloorId} className="h-full" />
</div>
);
}
export default LayoutEditorPage;

View file

@ -46,6 +46,9 @@ const MetrcDashboardPage = lazy(() => import('./pages/MetrcDashboardPage'));
// 3D Facility Viewer
const Facility3DViewerPage = lazy(() => import('./pages/Facility3DViewerPage'));
// 2D Layout Editor (Rackula-inspired)
const LayoutEditorPage = lazy(() => import('./pages/LayoutEditorPage'));
// Loading spinner component for Suspense fallbacks
const PageLoader = () => (
@ -229,5 +232,21 @@ export const router = createBrowserRouter([
),
errorElement: <RouterErrorPage />,
},
// 2D Layout Editor (Rackula-inspired)
{
path: '/layout-editor/:floorId?',
element: (
<ProtectedRoute>
<Suspense fallback={
<div className="h-screen w-screen bg-slate-950 flex items-center justify-center">
<div className="animate-spin w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full" />
</div>
}>
<LayoutEditorPage />
</Suspense>
</ProtectedRoute>
),
errorElement: <RouterErrorPage />,
},
]);