Components: - FarmCard: Farm summary display with status and metrics - ZoneGrid: Multi-level zone layout visualization - ZoneDetailCard: Individual zone details with environment readings - EnvironmentGauge: Real-time environmental parameter display - BatchProgress: Crop batch progress tracking with health scores - RecipeSelector: Growing recipe browser and selector - AlertPanel: Environment alerts display and management - GrowthStageIndicator: Visual growth stage progress tracker - ResourceUsageChart: Energy/water usage analytics visualization Pages: - /vertical-farm: Dashboard with farm listing and stats - /vertical-farm/register: Multi-step farm registration form - /vertical-farm/[farmId]: Farm detail view with zones and alerts - /vertical-farm/[farmId]/zones: Zone management with batch starting - /vertical-farm/[farmId]/batches: Batch management and harvesting - /vertical-farm/[farmId]/analytics: Farm analytics and performance metrics
327 lines
13 KiB
TypeScript
327 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useRouter } from 'next/router';
|
|
import Link from 'next/link';
|
|
import Head from 'next/head';
|
|
import { CropBatch, GrowingRecipe, VerticalFarm } from '../../../lib/vertical-farming/types';
|
|
import BatchProgress from '../../../components/vertical-farm/BatchProgress';
|
|
import GrowthStageIndicator from '../../../components/vertical-farm/GrowthStageIndicator';
|
|
|
|
export default function BatchManagement() {
|
|
const router = useRouter();
|
|
const { farmId } = router.query;
|
|
const [farm, setFarm] = useState<VerticalFarm | null>(null);
|
|
const [batches, setBatches] = useState<CropBatch[]>([]);
|
|
const [recipes, setRecipes] = useState<Map<string, GrowingRecipe>>(new Map());
|
|
const [selectedBatch, setSelectedBatch] = useState<CropBatch | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('active');
|
|
|
|
useEffect(() => {
|
|
if (farmId) {
|
|
fetchData();
|
|
}
|
|
}, [farmId]);
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
const [farmRes, batchesRes, recipesRes] = await Promise.all([
|
|
fetch(`/api/vertical-farm/${farmId}`),
|
|
fetch(`/api/vertical-farm/${farmId}/batches`),
|
|
fetch('/api/vertical-farm/recipes'),
|
|
]);
|
|
|
|
const farmData = await farmRes.json();
|
|
const batchesData = await batchesRes.json();
|
|
const recipesData = await recipesRes.json();
|
|
|
|
if (farmData.success) setFarm(farmData.farm);
|
|
if (batchesData.success) setBatches(batchesData.batches);
|
|
if (recipesData.success) {
|
|
const recipeMap = new Map<string, GrowingRecipe>();
|
|
recipesData.recipes.forEach((r: GrowingRecipe) => recipeMap.set(r.id, r));
|
|
setRecipes(recipeMap);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching data:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const filteredBatches = batches.filter(batch => {
|
|
if (filter === 'active') {
|
|
return batch.status !== 'completed' && batch.status !== 'failed';
|
|
}
|
|
if (filter === 'completed') {
|
|
return batch.status === 'completed' || batch.status === 'failed';
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const handleHarvest = async (batch: CropBatch) => {
|
|
const yieldKg = prompt('Enter actual yield in kg:', batch.expectedYieldKg.toString());
|
|
if (!yieldKg) return;
|
|
|
|
const grade = prompt('Enter quality grade (A, B, C):', 'A');
|
|
if (!grade) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/vertical-farm/batch/${batch.id}/harvest`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
actualYieldKg: parseFloat(yieldKg),
|
|
qualityGrade: grade,
|
|
}),
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
fetchData();
|
|
setSelectedBatch(null);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error completing harvest:', error);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
|
|
<Head>
|
|
<title>Crop Batches - {farm?.name || 'Vertical Farm'}</title>
|
|
</Head>
|
|
|
|
<header className="bg-white shadow-sm">
|
|
<div className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Link href={`/vertical-farm/${farmId}`}>
|
|
<a className="text-gray-600 hover:text-gray-900">← Back</a>
|
|
</Link>
|
|
<h1 className="text-2xl font-bold text-gray-900">Crop Batches</h1>
|
|
</div>
|
|
<Link href={`/vertical-farm/${farmId}/zones`}>
|
|
<a className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
|
|
Start New Batch
|
|
</a>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="max-w-7xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
|
|
{/* Filter Tabs */}
|
|
<div className="flex gap-4 mb-6">
|
|
{(['active', 'completed', 'all'] as const).map(f => (
|
|
<button
|
|
key={f}
|
|
onClick={() => setFilter(f)}
|
|
className={`px-4 py-2 rounded-lg font-medium transition ${
|
|
filter === f
|
|
? 'bg-green-600 text-white'
|
|
: 'bg-white text-gray-700 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
{f.charAt(0).toUpperCase() + f.slice(1)}
|
|
{f === 'active' && (
|
|
<span className="ml-2 px-2 py-0.5 bg-white bg-opacity-20 rounded-full text-sm">
|
|
{batches.filter(b => b.status !== 'completed' && b.status !== 'failed').length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
{/* Batch List */}
|
|
<div className="lg:col-span-2 space-y-4">
|
|
{filteredBatches.length === 0 ? (
|
|
<div className="bg-white rounded-lg shadow p-8 text-center">
|
|
<p className="text-gray-600 mb-4">No {filter} batches found</p>
|
|
<Link href={`/vertical-farm/${farmId}/zones`}>
|
|
<a className="text-green-600 hover:text-green-700">Start a new batch →</a>
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
filteredBatches.map(batch => (
|
|
<div
|
|
key={batch.id}
|
|
onClick={() => setSelectedBatch(batch)}
|
|
className={`cursor-pointer transition ${
|
|
selectedBatch?.id === batch.id ? 'ring-2 ring-green-500' : ''
|
|
}`}
|
|
>
|
|
<BatchProgress
|
|
batch={batch}
|
|
recipe={recipes.get(batch.recipeId)}
|
|
showDetails={selectedBatch?.id !== batch.id}
|
|
/>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Batch Detail */}
|
|
<div className="space-y-6">
|
|
{selectedBatch ? (
|
|
<>
|
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
|
{selectedBatch.cropType}
|
|
{selectedBatch.variety && ` - ${selectedBatch.variety}`}
|
|
</h2>
|
|
|
|
<div className="space-y-4 text-sm">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<p className="text-gray-600">Batch ID</p>
|
|
<p className="font-mono text-xs">{selectedBatch.id}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-gray-600">Status</p>
|
|
<p className="font-semibold capitalize">{selectedBatch.status}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-gray-600">Planted</p>
|
|
<p className="font-medium">
|
|
{new Date(selectedBatch.plantingDate).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-gray-600">Expected Harvest</p>
|
|
<p className="font-medium">
|
|
{new Date(selectedBatch.expectedHarvestDate).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-gray-600">Plant Count</p>
|
|
<p className="font-medium">{selectedBatch.plantCount.toLocaleString()}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-gray-600">Health Score</p>
|
|
<p
|
|
className={`font-bold ${
|
|
selectedBatch.healthScore >= 80
|
|
? 'text-green-600'
|
|
: selectedBatch.healthScore >= 60
|
|
? 'text-yellow-600'
|
|
: 'text-red-600'
|
|
}`}
|
|
>
|
|
{selectedBatch.healthScore}%
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{selectedBatch.actualHarvestDate && (
|
|
<div className="pt-4 border-t">
|
|
<h4 className="font-medium text-gray-900 mb-2">Harvest Results</h4>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<p className="text-gray-600">Actual Yield</p>
|
|
<p className="font-bold text-green-600">
|
|
{selectedBatch.actualYieldKg?.toFixed(1)} kg
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-gray-600">Quality Grade</p>
|
|
<p className="font-bold">{selectedBatch.qualityGrade}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Growth Stage Progress */}
|
|
{recipes.get(selectedBatch.recipeId) && (
|
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
|
<h3 className="font-semibold text-gray-900 mb-4">Growth Progress</h3>
|
|
<GrowthStageIndicator
|
|
stages={recipes.get(selectedBatch.recipeId)!.stages}
|
|
currentStage={selectedBatch.currentStage}
|
|
currentDay={selectedBatch.currentDay}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Issues */}
|
|
{selectedBatch.issues.length > 0 && (
|
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
|
<h3 className="font-semibold text-gray-900 mb-4">
|
|
Issues ({selectedBatch.issues.filter(i => !i.resolvedAt).length} active)
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{selectedBatch.issues.map(issue => (
|
|
<div
|
|
key={issue.id}
|
|
className={`p-3 rounded-lg ${
|
|
issue.resolvedAt ? 'bg-gray-100' : 'bg-red-50'
|
|
}`}
|
|
>
|
|
<div className="flex justify-between">
|
|
<span className="font-medium capitalize">{issue.type}</span>
|
|
<span
|
|
className={`text-xs font-semibold ${
|
|
issue.severity === 'critical'
|
|
? 'text-red-600'
|
|
: issue.severity === 'severe'
|
|
? 'text-orange-600'
|
|
: 'text-yellow-600'
|
|
}`}
|
|
>
|
|
{issue.severity}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-gray-600">{issue.description}</p>
|
|
{issue.resolvedAt && (
|
|
<p className="text-xs text-green-600 mt-1">
|
|
Resolved: {issue.resolution}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
|
<h3 className="font-semibold text-gray-900 mb-4">Actions</h3>
|
|
<div className="space-y-2">
|
|
{selectedBatch.status === 'ready' && (
|
|
<button
|
|
onClick={() => handleHarvest(selectedBatch)}
|
|
className="w-full px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
|
>
|
|
Complete Harvest
|
|
</button>
|
|
)}
|
|
<button className="w-full px-4 py-2 bg-yellow-100 text-yellow-700 rounded-lg hover:bg-yellow-200">
|
|
Report Issue
|
|
</button>
|
|
<button className="w-full px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">
|
|
View Environment Log
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="bg-white rounded-lg shadow-lg p-6 text-center">
|
|
<p className="text-gray-600">Select a batch to view details</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|