feat: add Reseed Demo Plants button to DevTools (realistic plant layout)
This commit is contained in:
parent
916aedb278
commit
f91fbc2237
3 changed files with 215 additions and 1 deletions
165
backend/src/routes/admin.routes.ts
Normal file
165
backend/src/routes/admin.routes.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
|
||||||
|
const DEMO_PREFIX = '[DEMO]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin routes for demo/testing operations
|
||||||
|
* These should only be available in development or testing environments
|
||||||
|
*/
|
||||||
|
export async function adminRoutes(fastify: FastifyInstance) {
|
||||||
|
// Auth check - require OWNER or ADMIN role
|
||||||
|
fastify.addHook('onRequest', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
const user = request.user as { role?: string };
|
||||||
|
if (!['OWNER', 'ADMIN'].includes(user?.role || '')) {
|
||||||
|
return reply.code(403).send({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(401).send({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reseed demo plants in a realistic layout
|
||||||
|
* - Each batch stays in appropriate room based on stage
|
||||||
|
* - Veg batches → Veg rooms, Flower batches → Flower rooms, etc.
|
||||||
|
*/
|
||||||
|
fastify.post('/reseed-plants', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const prisma = fastify.prisma;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fastify.log.info('🌿 Reseeding plants in realistic layout...');
|
||||||
|
|
||||||
|
// Clear existing plants
|
||||||
|
await prisma.facilityPlant.deleteMany({});
|
||||||
|
await prisma.facilityPosition.updateMany({
|
||||||
|
data: { status: 'EMPTY' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all demo batches
|
||||||
|
const batches = await prisma.batch.findMany({
|
||||||
|
where: { name: { startsWith: DEMO_PREFIX } },
|
||||||
|
orderBy: { startDate: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (batches.length === 0) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'No demo batches found. Run seed-demo.js first.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section mapping by room type
|
||||||
|
const sectionMapping: Record<string, string[]> = {
|
||||||
|
VEG_PRIMARY: ['T1', 'T2', 'T3', 'T4'],
|
||||||
|
MOTHER: ['M1', 'M2'],
|
||||||
|
FLOWER_A: ['A1', 'A2', 'A3', 'A4', 'A5', 'A6'],
|
||||||
|
FLOWER_B: ['B1', 'B2', 'B3', 'B4', 'B5', 'B6'],
|
||||||
|
DRY: ['D1', 'D2'],
|
||||||
|
CURE: ['C1', 'C2'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const results: { batch: string; planted: number }[] = [];
|
||||||
|
|
||||||
|
for (const batch of batches) {
|
||||||
|
let targetSections: string[] = [];
|
||||||
|
let fillPercentage = 0.85;
|
||||||
|
|
||||||
|
switch (batch.stage) {
|
||||||
|
case 'CLONE_IN':
|
||||||
|
case 'VEGETATIVE':
|
||||||
|
targetSections = sectionMapping.VEG_PRIMARY;
|
||||||
|
fillPercentage = 0.9;
|
||||||
|
break;
|
||||||
|
case 'FLOWERING':
|
||||||
|
if (batch.strain === 'Gorilla Glue #4') {
|
||||||
|
targetSections = sectionMapping.FLOWER_A;
|
||||||
|
} else if (batch.strain === 'Wedding Cake') {
|
||||||
|
targetSections = sectionMapping.FLOWER_B;
|
||||||
|
} else {
|
||||||
|
targetSections = ['A4', 'A5', 'A6'];
|
||||||
|
}
|
||||||
|
fillPercentage = 0.95;
|
||||||
|
break;
|
||||||
|
case 'DRYING':
|
||||||
|
targetSections = sectionMapping.DRY;
|
||||||
|
fillPercentage = 0.6;
|
||||||
|
break;
|
||||||
|
case 'CURING':
|
||||||
|
targetSections = sectionMapping.CURE;
|
||||||
|
fillPercentage = 0.5;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sections
|
||||||
|
const sections = await prisma.facilitySection.findMany({
|
||||||
|
where: { code: { in: targetSections } },
|
||||||
|
include: { positions: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect available positions
|
||||||
|
let allPositions: any[] = [];
|
||||||
|
for (const section of sections) {
|
||||||
|
const available = section.positions.filter((p: any) => p.status === 'EMPTY');
|
||||||
|
allPositions = allPositions.concat(
|
||||||
|
available.map((p: any) => ({ ...p, sectionCode: section.code }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCount = Math.min(
|
||||||
|
Math.floor(allPositions.length * fillPercentage),
|
||||||
|
batch.plantCount || allPositions.length
|
||||||
|
);
|
||||||
|
|
||||||
|
const plantsToCreate = [];
|
||||||
|
const positionIds = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < targetCount && i < allPositions.length; i++) {
|
||||||
|
const pos = allPositions[i];
|
||||||
|
const tagPrefix = batch.strain?.substring(0, 2).toUpperCase() || 'XX';
|
||||||
|
const tagNumber = `1A4-${tagPrefix}-${batch.id.substring(0, 4).toUpperCase()}-${String(i + 1).padStart(4, '0')}`;
|
||||||
|
|
||||||
|
plantsToCreate.push({
|
||||||
|
tagNumber,
|
||||||
|
batchId: batch.id,
|
||||||
|
positionId: pos.id,
|
||||||
|
address: `${pos.sectionCode}-R${pos.row}-C${pos.column}`,
|
||||||
|
status: 'ACTIVE'
|
||||||
|
});
|
||||||
|
positionIds.push(pos.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plantsToCreate.length > 0) {
|
||||||
|
await prisma.facilityPlant.createMany({
|
||||||
|
data: plantsToCreate,
|
||||||
|
skipDuplicates: true
|
||||||
|
});
|
||||||
|
await prisma.facilityPosition.updateMany({
|
||||||
|
where: { id: { in: positionIds } },
|
||||||
|
data: { status: 'OCCUPIED' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ batch: batch.name || batch.id, planted: plantsToCreate.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPlants = await prisma.facilityPlant.count();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Plants reseeded in realistic layout',
|
||||||
|
totalPlants,
|
||||||
|
batches: results
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Failed to reseed plants',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,10 @@ server.register(financialRoutes, { prefix: '/api/financial' });
|
||||||
server.register(insightsRoutes, { prefix: '/api/insights' });
|
server.register(insightsRoutes, { prefix: '/api/insights' });
|
||||||
server.register(uploadRoutes, { prefix: '/api/upload' });
|
server.register(uploadRoutes, { prefix: '/api/upload' });
|
||||||
|
|
||||||
|
// Admin routes (demo/testing)
|
||||||
|
import { adminRoutes } from './routes/admin.routes';
|
||||||
|
server.register(adminRoutes, { prefix: '/api/admin' });
|
||||||
|
|
||||||
server.get('/api/healthz', async (request, reply) => {
|
server.get('/api/healthz', async (request, reply) => {
|
||||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { Bug, ChevronUp, ChevronDown, User, X } from 'lucide-react';
|
import { Bug, ChevronUp, ChevronDown, User, X, Sprout, Loader2 } from 'lucide-react';
|
||||||
import api from '../../lib/api';
|
import api from '../../lib/api';
|
||||||
|
|
||||||
interface DevUser {
|
interface DevUser {
|
||||||
|
|
@ -64,6 +64,29 @@ export function DevTools() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [isReseeding, setIsReseeding] = useState(false);
|
||||||
|
const [reseedResult, setReseedResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleReseedPlants = async () => {
|
||||||
|
if (!user) {
|
||||||
|
setError('Must be logged in as OWNER or ADMIN to reseed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsReseeding(true);
|
||||||
|
setError(null);
|
||||||
|
setReseedResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.post('/admin/reseed-plants');
|
||||||
|
setReseedResult(`✅ ${response.data.totalPlants} plants placed`);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Reseed failed');
|
||||||
|
} finally {
|
||||||
|
setIsReseeding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-20 md:bottom-4 right-4 z-50">
|
<div className="fixed bottom-20 md:bottom-4 right-4 z-50">
|
||||||
{/* Toggle Button */}
|
{/* Toggle Button */}
|
||||||
|
|
@ -144,6 +167,28 @@ export function DevTools() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Demo Actions */}
|
||||||
|
{user && ['OWNER', 'ADMIN'].includes(user.role) && (
|
||||||
|
<div className="px-4 py-3 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<p className="text-[10px] uppercase text-slate-400 font-semibold mb-2">Demo Actions</p>
|
||||||
|
<button
|
||||||
|
onClick={handleReseedPlants}
|
||||||
|
disabled={isReseeding}
|
||||||
|
className="w-full flex items-center justify-center gap-2 p-2 rounded-lg text-sm bg-green-600 hover:bg-green-700 text-white transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isReseeding ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sprout size={16} />
|
||||||
|
)}
|
||||||
|
Reseed Demo Plants
|
||||||
|
</button>
|
||||||
|
{reseedResult && (
|
||||||
|
<p className="text-xs text-green-600 mt-2 text-center">{reseedResult}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-4 pb-3">
|
<div className="px-4 pb-3">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue