diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7ff1da6..56f8b05 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/prisma/seed-plant-types.ts b/backend/prisma/seed-plant-types.ts new file mode 100644 index 0000000..4474ad2 --- /dev/null +++ b/backend/prisma/seed-plant-types.ts @@ -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(); + }); diff --git a/backend/src/routes/layout.routes.ts b/backend/src/routes/layout.routes.ts index 8e2cb32..df108b2 100644 --- a/backend/src/routes/layout.routes.ts +++ b/backend/src/routes/layout.routes.ts @@ -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' }); + } + } + }); } diff --git a/backend/src/types/layout-schemas.ts b/backend/src/types/layout-schemas.ts new file mode 100644 index 0000000..7078214 --- /dev/null +++ b/backend/src/types/layout-schemas.ts @@ -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; + +/** + * 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; + +/** + * Position status + */ +export const PositionStatusSchema = z.enum([ + 'EMPTY', + 'OCCUPIED', + 'RESERVED', + 'DAMAGED', +]); +export type PositionStatus = z.infer; + +// ============================================================================= +// 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +/** + * 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; + +// ============================================================================= +// 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; + +/** + * Helper type for updating a PlantType + */ +export const UpdatePlantTypeSchema = PlantTypeSchema.partial().omit({ + slug: true, +}); +export type UpdatePlantTypeData = z.infer; + +// ============================================================================= +// 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(); + + 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; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f0aa6e4..d592bb9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 2cdc537..861f419 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/components/layout-editor/LayoutEditor.tsx b/frontend/src/components/layout-editor/LayoutEditor.tsx new file mode 100644 index 0000000..617351b --- /dev/null +++ b/frontend/src/components/layout-editor/LayoutEditor.tsx @@ -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(null); + const [plantTypes, setPlantTypes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedSlot, setSelectedSlot] = useState(null); + const [draggingType, setDraggingType] = useState(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 ( +
+
Loading layout...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+ {/* Plant Type Sidebar */} + + + {/* Main Canvas */} +
+ {/* Header */} +
+

+ {floorData?.floor.property} — {floorData?.floor.building} +

+

+ {floorData?.floor.name} • {floorData?.stats.occupiedPositions}/{floorData?.stats.totalPositions} positions filled +

+
+ + {/* Rooms */} + {floorData?.rooms.map(room => ( +
+

+ + {room.name} + ({room.code}) +

+ + {/* Racks Grid */} +
+ {racks + .filter(r => room.sections.some(s => s.id === r.id)) + .map(rack => ( + + ))} +
+
+ ))} + + {/* Empty state */} + {(!floorData?.rooms || floorData.rooms.length === 0) && ( +
+ + + +

No rooms configured

+

Add rooms to this floor to start placing plants

+
+ )} +
+ + {/* Selected Slot Panel */} + {selectedSlot && ( +
+
+

Slot Details

+ +
+ +
+
+ Position: + R{selectedSlot.row} C{selectedSlot.column} +
+
+ Status: + + {selectedSlot.status} + +
+ {selectedSlot.plantType && ( + <> +
+ Plant: + {selectedSlot.plantType.name} +
+ {selectedSlot.tagNumber && ( +
+ Tag: + {selectedSlot.tagNumber} +
+ )} + + )} +
+ + {/* Actions */} +
+ {selectedSlot.status === 'EMPTY' && ( + + )} + {selectedSlot.status === 'OCCUPIED' && ( + <> + + + + )} +
+
+ )} +
+ ); +} + +export default LayoutEditor; diff --git a/frontend/src/components/layout-editor/RackVisualizer.tsx b/frontend/src/components/layout-editor/RackVisualizer.tsx new file mode 100644 index 0000000..27b6934 --- /dev/null +++ b/frontend/src/components/layout-editor/RackVisualizer.tsx @@ -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; + 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(); + 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 ( +
+ {/* Rack header */} +
+ {style.icon} + {rack.name} + ({rack.code}) + {rack.tiers > 1 && ( + Tier {selectedTier}/{rack.tiers} + )} +
+ + {/* Tier tabs (if multi-tier) */} + {rack.tiers > 1 && ( +
+ {Array.from({ length: rack.tiers }, (_, i) => i + 1).map(tier => ( + + ))} +
+ )} + + {/* Grid */} +
+
+ {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 ( + handleDrop(s, e)} + /> + ); + }) + )} +
+
+
+ ); +} + +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 ( +
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 && ( + + {slot.plantType?.strain?.substring(0, 3) || '•'} + + )} + {isDamaged && āœ•} + {isReserved && ā—†} +
+ ); +} + +export default RackVisualizer; diff --git a/frontend/src/components/layout-editor/TypeLibrary.tsx b/frontend/src/components/layout-editor/TypeLibrary.tsx new file mode 100644 index 0000000..0e54336 --- /dev/null +++ b/frontend/src/components/layout-editor/TypeLibrary.tsx @@ -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 = { + 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>(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 = {}; + 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 ( +
+ {/* Header */} +
+
+

Plant Library

+ {onCreateType && ( + + )} +
+ 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" + /> +
+ + {/* Categories */} +
+ {CATEGORY_ORDER.map(category => { + const types = groupedTypes[category] || []; + if (types.length === 0 && search) return null; + + const isExpanded = expandedCategories.has(category); + + return ( +
+ + + {isExpanded && ( +
+ {types.map(pt => ( + + ))} + {types.length === 0 && ( +

No types in this category

+ )} +
+ )} +
+ ); + })} +
+ + {/* Footer hint */} +
+

+ Drag types onto sections to place +

+
+
+ ); +} + +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 ( +
+
+ {/* Color indicator */} +
+ +
+

{plantType.name}

+ {plantType.strain && ( +

{plantType.strain}

+ )} +
+ + {/* Drag handle hint */} + + + +
+ + {/* Quick stats */} + {(plantType.growthDays || plantType.yieldGrams) && ( +
+ {plantType.growthDays && {plantType.growthDays}d} + {plantType.yieldGrams && {plantType.yieldGrams}g} +
+ )} +
+ ); +} diff --git a/frontend/src/components/layout-editor/index.ts b/frontend/src/components/layout-editor/index.ts new file mode 100644 index 0000000..9239e56 --- /dev/null +++ b/frontend/src/components/layout-editor/index.ts @@ -0,0 +1,4 @@ +export { LayoutEditor } from './LayoutEditor'; +export { TypeLibrary } from './TypeLibrary'; +export { RackVisualizer } from './RackVisualizer'; +export type { RackData, PlantSlotData } from './RackVisualizer'; diff --git a/frontend/src/lib/layout-schemas.ts b/frontend/src/lib/layout-schemas.ts new file mode 100644 index 0000000..8ce7444 --- /dev/null +++ b/frontend/src/lib/layout-schemas.ts @@ -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; + +export const SectionSubtypeSchema = z.enum([ + 'TABLE', + 'RACK', + 'TRAY', + 'HANGER', + 'FLOOR', +]); +export type SectionSubtype = z.infer; + +// ============================================================================= +// 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; + +export const CreatePlantTypeSchema = PlantTypeSchema.omit({ + id: true, + slug: true, + createdAt: true, + updatedAt: true, +}); +export type CreatePlantTypeData = z.infer; + +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; + +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; + +// ============================================================================= +// 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; diff --git a/frontend/src/lib/layoutApi.ts b/frontend/src/lib/layoutApi.ts index 33f5272..56132ad 100644 --- a/frontend/src/lib/layoutApi.ts +++ b/frontend/src/lib/layoutApi.ts @@ -263,6 +263,63 @@ export const layoutApi = { async getSection(id: string): Promise { const response = await api.get(`/layout/sections/${id}`); return response.data; + }, + + // ======================================== + // Plant Type Library (Rackula-inspired) + // ======================================== + + async getPlantTypes(): Promise { + const response = await api.get('/layout/plant-types'); + return response.data; + }, + + async getPlantType(slug: string): Promise { + const response = await api.get(`/layout/plant-types/${slug}`); + return response.data; + }, + + async createPlantType(data: CreatePlantTypeData): Promise { + const response = await api.post('/layout/plant-types', data); + return response.data; + }, + + async updatePlantType(slug: string, data: Partial): Promise { + const response = await api.put(`/layout/plant-types/${slug}`, data); + return response.data; + }, + + async deletePlantType(slug: string): Promise { + 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; + 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; +} + diff --git a/frontend/src/pages/LayoutEditorPage.tsx b/frontend/src/pages/LayoutEditorPage.tsx new file mode 100644 index 0000000..914d004 --- /dev/null +++ b/frontend/src/pages/LayoutEditorPage.tsx @@ -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([]); + const [selectedFloorId, setSelectedFloorId] = useState(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 ( +
+
Loading...
+
+ ); + } + + // Floor selector if no floor specified + if (!selectedFloorId) { + return ( +
+

Select a Floor

+ + {properties.length === 0 ? ( +
+

No facility layouts found.

+

Create a property first to get started.

+
+ ) : ( +
+ {properties.map(prop => ( +
+

{prop.name}

+ {prop.buildings?.map(building => ( +
+

{building.name}

+
+ {building.floors?.map(floor => ( + + ))} +
+
+ ))} +
+ ))} +
+ )} +
+ ); + } + + return ( +
+ +
+ ); +} + +export default LayoutEditorPage; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index f8d167f..1f737c5 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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: , }, + // 2D Layout Editor (Rackula-inspired) + { + path: '/layout-editor/:floorId?', + element: ( + + +
+
+ }> + +
+
+ ), + errorElement: , + }, ]);