feat: Add Rackula-inspired layout system with 2D editor
- 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:
parent
1a13087c53
commit
d9d04045cb
14 changed files with 1451 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
91
backend/prisma/seed-plant-types.ts
Normal file
91
backend/prisma/seed-plant-types.ts
Normal 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();
|
||||
});
|
||||
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
266
backend/src/types/layout-schemas.ts
Normal file
266
backend/src/types/layout-schemas.ts
Normal 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;
|
||||
}
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
229
frontend/src/components/layout-editor/LayoutEditor.tsx
Normal file
229
frontend/src/components/layout-editor/LayoutEditor.tsx
Normal 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;
|
||||
214
frontend/src/components/layout-editor/RackVisualizer.tsx
Normal file
214
frontend/src/components/layout-editor/RackVisualizer.tsx
Normal 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;
|
||||
180
frontend/src/components/layout-editor/TypeLibrary.tsx
Normal file
180
frontend/src/components/layout-editor/TypeLibrary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
frontend/src/components/layout-editor/index.ts
Normal file
4
frontend/src/components/layout-editor/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { LayoutEditor } from './LayoutEditor';
|
||||
export { TypeLibrary } from './TypeLibrary';
|
||||
export { RackVisualizer } from './RackVisualizer';
|
||||
export type { RackData, PlantSlotData } from './RackVisualizer';
|
||||
125
frontend/src/lib/layout-schemas.ts
Normal file
125
frontend/src/lib/layout-schemas.ts
Normal 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;
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
99
frontend/src/pages/LayoutEditorPage.tsx
Normal file
99
frontend/src/pages/LayoutEditorPage.tsx
Normal 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;
|
||||
|
|
@ -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 />,
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue