Add vertical farming UI components and pages

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
This commit is contained in:
Claude 2025-11-22 18:35:57 +00:00
parent b8a3ebb823
commit 2f7f22ca22
No known key found for this signature in database
15 changed files with 2750 additions and 0 deletions

View file

@ -0,0 +1,117 @@
import { EnvironmentAlert } from '../../lib/vertical-farming/types';
interface AlertPanelProps {
alerts: EnvironmentAlert[];
onAcknowledge?: (alert: EnvironmentAlert) => void;
title?: string;
}
export default function AlertPanel({ alerts, onAcknowledge, title = 'Environment Alerts' }: AlertPanelProps) {
const activeAlerts = alerts.filter(a => !a.acknowledged);
const acknowledgedAlerts = alerts.filter(a => a.acknowledged);
const alertStyles: Record<string, { bg: string; border: string; icon: string }> = {
low: { bg: 'bg-blue-50', border: 'border-blue-400', icon: 'arrow-down' },
high: { bg: 'bg-orange-50', border: 'border-orange-400', icon: 'arrow-up' },
critical_low: { bg: 'bg-red-50', border: 'border-red-500', icon: 'exclamation' },
critical_high: { bg: 'bg-red-50', border: 'border-red-500', icon: 'exclamation' },
sensor_fault: { bg: 'bg-gray-50', border: 'border-gray-500', icon: 'warning' },
};
const formatParameter = (param: string) => {
const names: Record<string, string> = {
temperature: 'Temperature',
humidity: 'Humidity',
co2: 'CO2 Level',
ec: 'EC (Nutrient)',
ph: 'pH Level',
ppfd: 'Light (PPFD)',
};
return names[param] || param;
};
if (alerts.length === 0) {
return (
<div className="bg-green-50 rounded-lg p-4 text-center">
<p className="text-green-700 font-medium">All systems operating within normal parameters</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
activeAlerts.some(a => a.type.includes('critical'))
? 'bg-red-100 text-red-800'
: activeAlerts.length > 0
? 'bg-yellow-100 text-yellow-800'
: 'bg-green-100 text-green-800'
}`}>
{activeAlerts.length} Active
</span>
</div>
{activeAlerts.length > 0 && (
<div className="space-y-2">
{activeAlerts.map((alert, idx) => {
const style = alertStyles[alert.type] || alertStyles.low;
return (
<div
key={`${alert.parameter}-${alert.timestamp}-${idx}`}
className={`rounded-lg p-4 border-l-4 ${style.bg} ${style.border}`}
>
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-2">
<span className={`font-semibold ${
alert.type.includes('critical') ? 'text-red-700' : 'text-gray-900'
}`}>
{alert.type.includes('critical') && '!! '}
{formatParameter(alert.parameter)}
{alert.type.includes('low') ? ' LOW' : ' HIGH'}
</span>
</div>
<p className="text-sm text-gray-700 mt-1">
Current: <span className="font-medium">{alert.value}</span>
{' | '}
Threshold: <span className="font-medium">{alert.threshold}</span>
</p>
<p className="text-xs text-gray-500 mt-1">
{new Date(alert.timestamp).toLocaleString()}
</p>
</div>
{onAcknowledge && (
<button
onClick={() => onAcknowledge(alert)}
className="px-3 py-1 text-sm bg-white border border-gray-300 rounded hover:bg-gray-50 transition"
>
Acknowledge
</button>
)}
</div>
</div>
);
})}
</div>
)}
{acknowledgedAlerts.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-gray-600 hover:text-gray-900">
{acknowledgedAlerts.length} acknowledged alert(s)
</summary>
<div className="mt-2 space-y-1 pl-4">
{acknowledgedAlerts.map((alert, idx) => (
<p key={idx} className="text-gray-500">
{formatParameter(alert.parameter)} {alert.type}: {alert.value}
<span className="text-gray-400"> - {new Date(alert.timestamp).toLocaleTimeString()}</span>
</p>
))}
</div>
</details>
)}
</div>
);
}

View file

@ -0,0 +1,105 @@
import { CropBatch, GrowingRecipe } from '../../lib/vertical-farming/types';
interface BatchProgressProps {
batch: CropBatch;
recipe?: GrowingRecipe;
showDetails?: boolean;
}
export default function BatchProgress({ batch, recipe, showDetails = true }: BatchProgressProps) {
const progressPercent = recipe
? Math.min(100, (batch.currentDay / recipe.expectedDays) * 100)
: 0;
const statusColors: Record<string, string> = {
germinating: 'bg-yellow-500',
growing: 'bg-green-500',
ready: 'bg-blue-500',
harvesting: 'bg-purple-500',
completed: 'bg-gray-500',
failed: 'bg-red-500',
};
const healthColor =
batch.healthScore >= 80
? 'text-green-600'
: batch.healthScore >= 60
? 'text-yellow-600'
: 'text-red-600';
return (
<div className="bg-white rounded-lg shadow p-4">
<div className="flex justify-between items-start mb-3">
<div>
<h4 className="font-semibold text-gray-900">
{batch.cropType}
{batch.variety && <span className="text-gray-600"> - {batch.variety}</span>}
</h4>
<p className="text-sm text-gray-600">Batch: {batch.id.slice(0, 12)}...</p>
</div>
<div className="text-right">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-semibold text-white ${statusColors[batch.status]}`}>
{batch.status}
</span>
<p className={`text-sm font-bold mt-1 ${healthColor}`}>
Health: {batch.healthScore}%
</p>
</div>
</div>
<div className="mb-3">
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Progress</span>
<span className="font-medium">
Day {batch.currentDay} {recipe && `/ ${recipe.expectedDays}`}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all ${statusColors[batch.status] || 'bg-green-500'}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{showDetails && (
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-gray-600">Current Stage</p>
<p className="font-medium">{batch.currentStage}</p>
</div>
<div>
<p className="text-gray-600">Plants</p>
<p className="font-medium">{batch.plantCount.toLocaleString()}</p>
</div>
<div>
<p className="text-gray-600">Expected Yield</p>
<p className="font-medium">{batch.expectedYieldKg.toFixed(1)} kg</p>
</div>
<div>
<p className="text-gray-600">Harvest Date</p>
<p className="font-medium">
{new Date(batch.expectedHarvestDate).toLocaleDateString()}
</p>
</div>
</div>
)}
{batch.issues.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200">
<p className="text-sm font-medium text-red-600 mb-1">
{batch.issues.filter(i => !i.resolvedAt).length} Active Issue(s)
</p>
{batch.issues
.filter(i => !i.resolvedAt)
.slice(0, 2)
.map(issue => (
<p key={issue.id} className="text-xs text-gray-600">
- {issue.type}: {issue.description}
</p>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,89 @@
interface EnvironmentGaugeProps {
label: string;
value: number;
unit: string;
target: number;
min: number;
max: number;
compact?: boolean;
}
export default function EnvironmentGauge({
label,
value,
unit,
target,
min,
max,
compact = false,
}: EnvironmentGaugeProps) {
const isLow = value < min;
const isHigh = value > max;
const isOptimal = value >= min && value <= max;
const statusColor = isOptimal
? 'text-green-600'
: isLow || isHigh
? 'text-red-600'
: 'text-yellow-600';
const bgColor = isOptimal
? 'bg-green-100'
: isLow || isHigh
? 'bg-red-100'
: 'bg-yellow-100';
if (compact) {
return (
<div className={`rounded p-1 ${bgColor}`}>
<p className="text-xs text-gray-600">{label}</p>
<p className={`text-sm font-bold ${statusColor}`}>
{typeof value === 'number' ? value.toFixed(0) : value}
{unit && <span className="text-xs">{unit}</span>}
</p>
</div>
);
}
const deviation = value - target;
const deviationPercent = ((value - min) / (max - min)) * 100;
return (
<div className={`rounded-lg p-4 ${bgColor}`}>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-700">{label}</span>
<span className={`text-2xl font-bold ${statusColor}`}>
{value.toFixed(1)}{unit}
</span>
</div>
<div className="relative h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="absolute h-full bg-green-400"
style={{
left: `${((min - (min - 10)) / ((max + 10) - (min - 10))) * 100}%`,
width: `${((max - min) / ((max + 10) - (min - 10))) * 100}%`,
}}
/>
<div
className={`absolute h-4 w-1 -top-1 ${statusColor.replace('text-', 'bg-')}`}
style={{
left: `${Math.min(100, Math.max(0, ((value - (min - 10)) / ((max + 10) - (min - 10))) * 100))}%`,
}}
/>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>{min}{unit}</span>
<span className="font-medium">Target: {target}{unit}</span>
<span>{max}{unit}</span>
</div>
{deviation !== 0 && (
<p className={`text-xs mt-2 ${statusColor}`}>
{deviation > 0 ? '+' : ''}{deviation.toFixed(1)}{unit} from target
</p>
)}
</div>
);
}

View file

@ -0,0 +1,83 @@
import Link from 'next/link';
import { VerticalFarm } from '../../lib/vertical-farming/types';
interface FarmCardProps {
farm: VerticalFarm;
}
export default function FarmCard({ farm }: FarmCardProps) {
const statusColors: Record<string, string> = {
offline: 'bg-gray-100 text-gray-800',
starting: 'bg-yellow-100 text-yellow-800',
operational: 'bg-green-100 text-green-800',
maintenance: 'bg-orange-100 text-orange-800',
emergency: 'bg-red-100 text-red-800',
};
const activeZones = farm.zones.filter(z => z.status !== 'empty' && z.status !== 'cleaning').length;
return (
<Link href={`/vertical-farm/${farm.id}`}>
<a className="block bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition border border-gray-200">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-xl font-bold text-gray-900">{farm.name}</h3>
<p className="text-sm text-gray-600">
{farm.location.city}, {farm.location.country}
</p>
</div>
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${
statusColors[farm.status] || 'bg-gray-100 text-gray-800'
}`}
>
{farm.status.replace('_', ' ')}
</span>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600">Active Zones</p>
<p className="text-2xl font-bold text-green-600">
{activeZones}/{farm.zones.length}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600">Active Plants</p>
<p className="text-2xl font-bold text-green-600">
{farm.specs.currentActivePlants.toLocaleString()}
</p>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">Capacity</span>
<span className="font-medium">{farm.currentCapacityUtilization}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full transition-all"
style={{ width: `${farm.currentCapacityUtilization}%` }}
/>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-200 grid grid-cols-3 gap-2 text-center">
<div>
<p className="text-xs text-gray-600">Area</p>
<p className="text-sm font-semibold">{farm.specs.growingAreaSqm}m²</p>
</div>
<div>
<p className="text-xs text-gray-600">Levels</p>
<p className="text-sm font-semibold">{farm.specs.numberOfLevels}</p>
</div>
<div>
<p className="text-xs text-gray-600">Efficiency</p>
<p className="text-sm font-semibold">{farm.energyEfficiencyScore}%</p>
</div>
</div>
</a>
</Link>
);
}

View file

@ -0,0 +1,111 @@
import { GrowthStage } from '../../lib/vertical-farming/types';
interface GrowthStageIndicatorProps {
stages: GrowthStage[];
currentStage: string;
currentDay: number;
}
export default function GrowthStageIndicator({ stages, currentStage, currentDay }: GrowthStageIndicatorProps) {
const currentStageIndex = stages.findIndex(s => s.name === currentStage);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
{stages.map((stage, idx) => {
const isCompleted = idx < currentStageIndex;
const isCurrent = idx === currentStageIndex;
const isFuture = idx > currentStageIndex;
return (
<div key={stage.name} className="flex-1 flex flex-col items-center relative">
{/* Connector line */}
{idx > 0 && (
<div
className={`absolute top-4 -left-1/2 w-full h-0.5 ${
isCompleted || isCurrent ? 'bg-green-500' : 'bg-gray-200'
}`}
style={{ zIndex: 0 }}
/>
)}
{/* Stage dot */}
<div
className={`relative z-10 w-8 h-8 rounded-full flex items-center justify-center font-semibold text-sm ${
isCompleted
? 'bg-green-500 text-white'
: isCurrent
? 'bg-green-500 text-white ring-4 ring-green-200'
: 'bg-gray-200 text-gray-500'
}`}
>
{isCompleted ? '\\u2713' : idx + 1}
</div>
{/* Stage name */}
<p
className={`mt-2 text-xs text-center font-medium ${
isCurrent ? 'text-green-700' : isCompleted ? 'text-gray-700' : 'text-gray-400'
}`}
>
{stage.name}
</p>
{/* Days range */}
<p className="text-xs text-gray-400">
Day {stage.daysStart}-{stage.daysEnd}
</p>
</div>
);
})}
</div>
{/* Current stage details */}
{currentStageIndex >= 0 && (
<div className="mt-6 bg-green-50 rounded-lg p-4">
<h4 className="font-semibold text-green-800 mb-2">
Current: {stages[currentStageIndex].name} (Day {currentDay})
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<div>
<p className="text-gray-600">Temperature</p>
<p className="font-medium">
{stages[currentStageIndex].temperature.day}°C / {stages[currentStageIndex].temperature.night}°C
</p>
</div>
<div>
<p className="text-gray-600">Humidity</p>
<p className="font-medium">
{stages[currentStageIndex].humidity.day}% / {stages[currentStageIndex].humidity.night}%
</p>
</div>
<div>
<p className="text-gray-600">Light</p>
<p className="font-medium">
{stages[currentStageIndex].lightHours}h @ {stages[currentStageIndex].lightPpfd} PPFD
</p>
</div>
<div>
<p className="text-gray-600">Nutrients</p>
<p className="font-medium">
EC {stages[currentStageIndex].targetEc} / pH {stages[currentStageIndex].targetPh}
</p>
</div>
</div>
{stages[currentStageIndex].actions.length > 0 && (
<div className="mt-3 pt-3 border-t border-green-200">
<p className="text-sm font-medium text-green-700">Scheduled Actions:</p>
{stages[currentStageIndex].actions.map((action, idx) => (
<p key={idx} className="text-sm text-gray-600">
Day {action.day}: {action.action} - {action.description}
{action.automated && <span className="text-green-600 ml-1">(automated)</span>}
</p>
))}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,117 @@
import { useState, useEffect } from 'react';
import { GrowingRecipe } from '../../lib/vertical-farming/types';
interface RecipeSelectorProps {
onSelect: (recipe: GrowingRecipe) => void;
selectedRecipeId?: string;
}
export default function RecipeSelector({ onSelect, selectedRecipeId }: RecipeSelectorProps) {
const [recipes, setRecipes] = useState<GrowingRecipe[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
useEffect(() => {
fetchRecipes();
}, []);
const fetchRecipes = async () => {
try {
const response = await fetch('/api/vertical-farm/recipes');
const data = await response.json();
if (data.success) {
setRecipes(data.recipes);
}
} catch (error) {
console.error('Error fetching recipes:', error);
} finally {
setLoading(false);
}
};
const filteredRecipes = recipes.filter(
r =>
r.name.toLowerCase().includes(filter.toLowerCase()) ||
r.cropType.toLowerCase().includes(filter.toLowerCase())
);
if (loading) {
return (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600 mx-auto" />
<p className="text-gray-600 mt-2">Loading recipes...</p>
</div>
);
}
return (
<div className="space-y-4">
<input
type="text"
placeholder="Search recipes..."
value={filter}
onChange={e => setFilter(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-96 overflow-y-auto">
{filteredRecipes.map(recipe => (
<div
key={recipe.id}
onClick={() => onSelect(recipe)}
className={`p-4 rounded-lg border-2 cursor-pointer transition ${
selectedRecipeId === recipe.id
? 'border-green-500 bg-green-50'
: 'border-gray-200 hover:border-green-300'
}`}
>
<div className="flex justify-between items-start mb-2">
<h4 className="font-semibold text-gray-900">{recipe.name}</h4>
{recipe.rating && (
<span className="text-sm text-yellow-600">
{'*'.repeat(Math.round(recipe.rating))} {recipe.rating.toFixed(1)}
</span>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-gray-600 mb-2">
<div>
<span className="font-medium">Crop:</span> {recipe.cropType}
</div>
<div>
<span className="font-medium">Days:</span> {recipe.expectedDays}
</div>
<div>
<span className="font-medium">Yield:</span> {recipe.expectedYieldGrams}g
</div>
<div>
<span className="font-medium">Uses:</span> {recipe.timesUsed}
</div>
</div>
<div className="flex flex-wrap gap-1">
{recipe.stages.map(stage => (
<span
key={stage.name}
className="px-2 py-0.5 bg-gray-100 rounded text-xs text-gray-700"
>
{stage.name}
</span>
))}
</div>
<div className="mt-2 text-xs text-gray-500">
Source: {recipe.source} | Zone: {recipe.requirements.zoneType}
</div>
</div>
))}
</div>
{filteredRecipes.length === 0 && (
<p className="text-center text-gray-600 py-4">
No recipes found matching "{filter}"
</p>
)}
</div>
);
}

View file

@ -0,0 +1,124 @@
import { ResourceUsage } from '../../lib/vertical-farming/types';
interface ResourceUsageChartProps {
usage: ResourceUsage;
showBenchmarks?: boolean;
}
export default function ResourceUsageChart({ usage, showBenchmarks = true }: ResourceUsageChartProps) {
const formatCurrency = (value: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value);
const efficiencyColor = usage.efficiencyVsBenchmark >= 100
? 'text-green-600'
: usage.efficiencyVsBenchmark >= 80
? 'text-yellow-600'
: 'text-red-600';
return (
<div className="space-y-6">
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-blue-50 rounded-lg p-4">
<p className="text-sm text-blue-700">Electricity</p>
<p className="text-2xl font-bold text-blue-900">{usage.electricityKwh.toLocaleString()} kWh</p>
<p className="text-sm text-blue-600">{formatCurrency(usage.electricityCostUsd)}</p>
</div>
<div className="bg-cyan-50 rounded-lg p-4">
<p className="text-sm text-cyan-700">Water</p>
<p className="text-2xl font-bold text-cyan-900">{usage.waterUsageL.toLocaleString()} L</p>
<p className="text-sm text-cyan-600">{formatCurrency(usage.waterCostUsd)}</p>
</div>
<div className="bg-green-50 rounded-lg p-4">
<p className="text-sm text-green-700">CO2</p>
<p className="text-2xl font-bold text-green-900">{usage.co2UsedKg.toFixed(1)} kg</p>
<p className="text-sm text-green-600">{formatCurrency(usage.co2CostUsd)}</p>
</div>
<div className="bg-purple-50 rounded-lg p-4">
<p className="text-sm text-purple-700">Nutrients</p>
<p className="text-2xl font-bold text-purple-900">{usage.nutrientsUsedL.toFixed(1)} L</p>
<p className="text-sm text-purple-600">{formatCurrency(usage.nutrientCostUsd)}</p>
</div>
</div>
{/* Efficiency Metrics */}
<div className="bg-white rounded-lg shadow p-6">
<h4 className="font-semibold text-gray-900 mb-4">Efficiency Per kg Produce</h4>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<p className="text-3xl font-bold text-blue-600">{usage.kwhPerKgProduce.toFixed(1)}</p>
<p className="text-sm text-gray-600">kWh/kg</p>
{showBenchmarks && (
<p className="text-xs text-gray-500">
Industry: {usage.industryBenchmarkKwhPerKg} kWh/kg
</p>
)}
</div>
<div className="text-center">
<p className="text-3xl font-bold text-cyan-600">{usage.litersPerKgProduce.toFixed(1)}</p>
<p className="text-sm text-gray-600">L/kg</p>
{showBenchmarks && (
<p className="text-xs text-gray-500">
Industry: {usage.industryBenchmarkLitersPerKg} L/kg
</p>
)}
</div>
<div className="text-center">
<p className="text-3xl font-bold text-green-600">{formatCurrency(usage.costPerKgProduce)}</p>
<p className="text-sm text-gray-600">$/kg</p>
</div>
</div>
</div>
{/* Renewable & Recycled */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-white rounded-lg shadow p-4">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600">Renewable Energy</span>
<span className="font-semibold text-green-600">{usage.renewablePercent}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-green-500 h-3 rounded-full"
style={{ width: `${usage.renewablePercent}%` }}
/>
</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600">Water Recycled</span>
<span className="font-semibold text-cyan-600">{usage.waterRecycledPercent}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-cyan-500 h-3 rounded-full"
style={{ width: `${usage.waterRecycledPercent}%` }}
/>
</div>
</div>
</div>
{/* Overall Efficiency */}
{showBenchmarks && (
<div className="bg-gray-50 rounded-lg p-4 text-center">
<p className="text-sm text-gray-600 mb-2">Overall Efficiency vs Industry Benchmark</p>
<p className={`text-4xl font-bold ${efficiencyColor}`}>
{usage.efficiencyVsBenchmark.toFixed(0)}%
</p>
<p className="text-sm text-gray-500 mt-1">
{usage.efficiencyVsBenchmark >= 100
? 'Outperforming industry average'
: usage.efficiencyVsBenchmark >= 80
? 'Near industry average'
: 'Below industry average - optimization needed'}
</p>
</div>
)}
{/* Period Info */}
<p className="text-xs text-gray-500 text-center">
Period: {new Date(usage.periodStart).toLocaleDateString()} - {new Date(usage.periodEnd).toLocaleDateString()}
</p>
</div>
);
}

View file

@ -0,0 +1,112 @@
import { GrowingZone } from '../../lib/vertical-farming/types';
import EnvironmentGauge from './EnvironmentGauge';
interface ZoneDetailCardProps {
zone: GrowingZone;
onClick?: () => void;
isSelected?: boolean;
expanded?: boolean;
}
export default function ZoneDetailCard({ zone, onClick, isSelected, expanded }: ZoneDetailCardProps) {
const statusColors: Record<string, string> = {
empty: 'bg-gray-100 text-gray-600 border-gray-300',
preparing: 'bg-yellow-100 text-yellow-800 border-yellow-400',
planted: 'bg-blue-100 text-blue-800 border-blue-400',
growing: 'bg-green-100 text-green-800 border-green-400',
harvesting: 'bg-purple-100 text-purple-800 border-purple-400',
cleaning: 'bg-orange-100 text-orange-800 border-orange-400',
};
const daysUntilHarvest = zone.expectedHarvestDate
? Math.max(0, Math.ceil((new Date(zone.expectedHarvestDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
: null;
return (
<div
className={`rounded-lg p-4 border-2 transition cursor-pointer ${
statusColors[zone.status] || 'bg-gray-100 border-gray-300'
} ${isSelected ? 'ring-2 ring-green-500' : ''}`}
onClick={onClick}
>
<div className="flex justify-between items-start mb-3">
<div>
<h4 className="font-semibold text-gray-900">{zone.name}</h4>
<p className="text-xs text-gray-600">{zone.growingMethod.replace('_', ' ')}</p>
</div>
<span className="text-xs font-medium uppercase">{zone.status}</span>
</div>
{zone.currentCrop && (
<div className="mb-3">
<p className="text-sm font-medium text-gray-900">{zone.currentCrop}</p>
{daysUntilHarvest !== null && (
<p className="text-xs text-gray-600">
Harvest in {daysUntilHarvest} days
</p>
)}
</div>
)}
<div className="grid grid-cols-2 gap-2 text-xs mb-3">
<div>
<span className="text-gray-600">Plants:</span>
<span className="ml-1 font-medium">{zone.plantIds.length}/{zone.plantPositions}</span>
</div>
<div>
<span className="text-gray-600">Area:</span>
<span className="ml-1 font-medium">{zone.areaSqm}m²</span>
</div>
</div>
{zone.currentEnvironment && (
<div className="space-y-2 pt-3 border-t border-gray-200">
<div className="grid grid-cols-4 gap-1 text-center">
<EnvironmentGauge
label="Temp"
value={zone.currentEnvironment.temperatureC}
unit="°C"
target={zone.environmentTargets.temperatureC.target}
min={zone.environmentTargets.temperatureC.min}
max={zone.environmentTargets.temperatureC.max}
compact
/>
<EnvironmentGauge
label="RH"
value={zone.currentEnvironment.humidityPercent}
unit="%"
target={zone.environmentTargets.humidityPercent.target}
min={zone.environmentTargets.humidityPercent.min}
max={zone.environmentTargets.humidityPercent.max}
compact
/>
<EnvironmentGauge
label="CO2"
value={zone.currentEnvironment.co2Ppm}
unit=""
target={zone.environmentTargets.co2Ppm.target}
min={zone.environmentTargets.co2Ppm.min}
max={zone.environmentTargets.co2Ppm.max}
compact
/>
<EnvironmentGauge
label="PPFD"
value={zone.currentEnvironment.ppfd}
unit=""
target={zone.environmentTargets.lightPpfd.target}
min={zone.environmentTargets.lightPpfd.min}
max={zone.environmentTargets.lightPpfd.max}
compact
/>
</div>
{zone.currentEnvironment.alerts.length > 0 && (
<div className="text-xs text-red-600 font-medium">
{zone.currentEnvironment.alerts.length} active alert(s)
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,44 @@
import { GrowingZone } from '../../lib/vertical-farming/types';
import ZoneDetailCard from './ZoneDetailCard';
interface ZoneGridProps {
zones: GrowingZone[];
onZoneSelect?: (zone: GrowingZone) => void;
selectedZoneId?: string;
}
export default function ZoneGrid({ zones, onZoneSelect, selectedZoneId }: ZoneGridProps) {
const zonesByLevel = zones.reduce((acc, zone) => {
if (!acc[zone.level]) {
acc[zone.level] = [];
}
acc[zone.level].push(zone);
return acc;
}, {} as Record<number, GrowingZone[]>);
const levels = Object.keys(zonesByLevel)
.map(Number)
.sort((a, b) => b - a);
return (
<div className="space-y-6">
{levels.map(level => (
<div key={level}>
<h3 className="text-lg font-semibold text-gray-900 mb-3">
Level {level}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{zonesByLevel[level].map(zone => (
<ZoneDetailCard
key={zone.id}
zone={zone}
onClick={() => onZoneSelect?.(zone)}
isSelected={selectedZoneId === zone.id}
/>
))}
</div>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,289 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Head from 'next/head';
import { FarmAnalytics, ResourceUsage, VerticalFarm } from '../../../lib/vertical-farming/types';
import ResourceUsageChart from '../../../components/vertical-farm/ResourceUsageChart';
export default function FarmAnalyticsPage() {
const router = useRouter();
const { farmId } = router.query;
const [farm, setFarm] = useState<VerticalFarm | null>(null);
const [analytics, setAnalytics] = useState<FarmAnalytics | null>(null);
const [resourceUsage, setResourceUsage] = useState<ResourceUsage | null>(null);
const [loading, setLoading] = useState(true);
const [period, setPeriod] = useState(30);
useEffect(() => {
if (farmId) {
fetchData();
}
}, [farmId, period]);
const fetchData = async () => {
setLoading(true);
try {
const [farmRes, analyticsRes] = await Promise.all([
fetch(`/api/vertical-farm/${farmId}`),
fetch(`/api/vertical-farm/${farmId}/analytics?period=${period}`),
]);
const farmData = await farmRes.json();
const analyticsData = await analyticsRes.json();
if (farmData.success) setFarm(farmData.farm);
if (analyticsData.success) {
setAnalytics(analyticsData.analytics);
if (analyticsData.resourceUsage) {
setResourceUsage(analyticsData.resourceUsage);
}
}
} catch (error) {
console.error('Error fetching analytics:', error);
} finally {
setLoading(false);
}
};
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>Analytics - {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">&larr; Back</a>
</Link>
<h1 className="text-2xl font-bold text-gray-900">Farm Analytics</h1>
</div>
<div className="flex gap-2">
{[7, 30, 90].map(p => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-3 py-1 rounded-lg text-sm ${
period === p
? 'bg-green-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100'
}`}
>
{p}d
</button>
))}
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
{analytics ? (
<div className="space-y-8">
{/* Production Overview */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">Total Yield</p>
<p className="text-3xl font-bold text-green-600">{analytics.totalYieldKg} kg</p>
<p className="text-xs text-gray-500">{period} day period</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">Yield/m²/year</p>
<p className="text-3xl font-bold text-blue-600">{analytics.yieldPerSqmPerYear} kg</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">Crop Cycles</p>
<p className="text-3xl font-bold text-purple-600">{analytics.cropCyclesCompleted}</p>
<p className="text-xs text-gray-500">Avg {analytics.averageCyclesDays.toFixed(0)} days</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">Success Rate</p>
<p className="text-3xl font-bold text-teal-600">{analytics.cropSuccessRate}%</p>
</div>
</div>
{/* Quality Metrics */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-6">Quality Metrics</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="text-center">
<div className="w-24 h-24 mx-auto mb-2 relative">
<svg className="w-24 h-24 transform -rotate-90">
<circle
cx="48"
cy="48"
r="40"
stroke="#e5e7eb"
strokeWidth="8"
fill="none"
/>
<circle
cx="48"
cy="48"
r="40"
stroke="#10b981"
strokeWidth="8"
fill="none"
strokeDasharray={`${(analytics.averageQualityScore / 100) * 251.2} 251.2`}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-xl font-bold text-gray-900">
{analytics.averageQualityScore.toFixed(0)}
</span>
</div>
<p className="text-sm text-gray-600">Quality Score</p>
</div>
<div className="text-center">
<p className="text-4xl font-bold text-green-600">{analytics.gradeAPercent}%</p>
<p className="text-sm text-gray-600">Grade A</p>
</div>
<div className="text-center">
<p className="text-4xl font-bold text-orange-600">{analytics.wastagePercent}%</p>
<p className="text-sm text-gray-600">Wastage</p>
</div>
<div className="text-center">
<p className="text-4xl font-bold text-blue-600">{analytics.spaceUtilization}%</p>
<p className="text-sm text-gray-600">Space Utilization</p>
</div>
</div>
</div>
{/* Financial Overview */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-6">Financial Performance</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="bg-green-50 rounded-lg p-4">
<p className="text-sm text-green-700">Revenue</p>
<p className="text-2xl font-bold text-green-900">
${analytics.revenueUsd.toLocaleString()}
</p>
</div>
<div className="bg-red-50 rounded-lg p-4">
<p className="text-sm text-red-700">Costs</p>
<p className="text-2xl font-bold text-red-900">
${analytics.costUsd.toLocaleString()}
</p>
</div>
<div className="bg-blue-50 rounded-lg p-4">
<p className="text-sm text-blue-700">Profit Margin</p>
<p className="text-2xl font-bold text-blue-900">{analytics.profitMarginPercent}%</p>
</div>
<div className="bg-purple-50 rounded-lg p-4">
<p className="text-sm text-purple-700">Revenue/m²/year</p>
<p className="text-2xl font-bold text-purple-900">
${analytics.revenuePerSqm.toLocaleString()}
</p>
</div>
</div>
</div>
{/* Environmental Impact */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-6">Environmental Impact</h2>
<div className="grid grid-cols-3 gap-6">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-3xl font-bold text-gray-900">
{analytics.carbonFootprintKgPerKg.toFixed(2)}
</p>
<p className="text-sm text-gray-600">kg CO2 / kg produce</p>
<p className="text-xs text-green-600 mt-1">90% less than field farming</p>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-3xl font-bold text-gray-900">
{analytics.waterUseLPerKg.toFixed(1)}
</p>
<p className="text-sm text-gray-600">L water / kg produce</p>
<p className="text-xs text-green-600 mt-1">95% less than field farming</p>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-3xl font-bold text-gray-900">
{analytics.energyUseKwhPerKg.toFixed(1)}
</p>
<p className="text-sm text-gray-600">kWh / kg produce</p>
</div>
</div>
</div>
{/* Top Crops */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="font-semibold text-gray-900 mb-4">Top Crops by Yield</h3>
<div className="space-y-3">
{analytics.topCropsByYield.map((crop, idx) => (
<div key={crop.crop} className="flex justify-between items-center">
<span className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-500">{idx + 1}</span>
<span className="font-medium">{crop.crop}</span>
</span>
<span className="text-green-600 font-semibold">{crop.yieldKg} kg</span>
</div>
))}
</div>
</div>
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="font-semibold text-gray-900 mb-4">Top Crops by Revenue</h3>
<div className="space-y-3">
{analytics.topCropsByRevenue.map((crop, idx) => (
<div key={crop.crop} className="flex justify-between items-center">
<span className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-500">{idx + 1}</span>
<span className="font-medium">{crop.crop}</span>
</span>
<span className="text-blue-600 font-semibold">${crop.revenueUsd}</span>
</div>
))}
</div>
</div>
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="font-semibold text-gray-900 mb-4">Top Crops by Efficiency</h3>
<div className="space-y-3">
{analytics.topCropsByEfficiency.map((crop, idx) => (
<div key={crop.crop} className="flex justify-between items-center">
<span className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-500">{idx + 1}</span>
<span className="font-medium">{crop.crop}</span>
</span>
<span className="text-purple-600 font-semibold">{crop.efficiencyScore}%</span>
</div>
))}
</div>
</div>
</div>
{/* Resource Usage */}
{resourceUsage && (
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-6">Resource Usage</h2>
<ResourceUsageChart usage={resourceUsage} />
</div>
)}
{/* Generated Timestamp */}
<p className="text-center text-sm text-gray-500">
Analytics generated: {new Date(analytics.generatedAt).toLocaleString()}
</p>
</div>
) : (
<div className="bg-white rounded-lg shadow-lg p-12 text-center">
<p className="text-gray-600 mb-4">No analytics data available yet</p>
<p className="text-sm text-gray-500">
Complete some crop batches to see analytics data
</p>
</div>
)}
</main>
</div>
);
}

View file

@ -0,0 +1,327 @@
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">&larr; 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 &rarr;</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>
);
}

View file

@ -0,0 +1,264 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Head from 'next/head';
import { VerticalFarm, CropBatch } from '../../../lib/vertical-farming/types';
import ZoneGrid from '../../../components/vertical-farm/ZoneGrid';
import AlertPanel from '../../../components/vertical-farm/AlertPanel';
export default function FarmDetail() {
const router = useRouter();
const { farmId } = router.query;
const [farm, setFarm] = useState<VerticalFarm | null>(null);
const [batches, setBatches] = useState<CropBatch[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (farmId) {
fetchFarmDetails();
}
}, [farmId]);
const fetchFarmDetails = async () => {
try {
const [farmRes, batchesRes] = await Promise.all([
fetch(`/api/vertical-farm/${farmId}`),
fetch(`/api/vertical-farm/${farmId}/batches`),
]);
const farmData = await farmRes.json();
const batchesData = await batchesRes.json();
if (farmData.success) {
setFarm(farmData.farm);
}
if (batchesData.success) {
setBatches(batchesData.batches);
}
} catch (error) {
console.error('Error fetching farm:', error);
} finally {
setLoading(false);
}
};
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="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto" />
<p className="text-gray-600 mt-4">Loading farm details...</p>
</div>
</div>
);
}
if (!farm) {
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">Farm Not Found</h1>
<Link href="/vertical-farm">
<a className="text-green-600 hover:text-green-700">Back to Dashboard</a>
</Link>
</div>
</div>
);
}
const allAlerts = farm.zones.flatMap(z => z.currentEnvironment?.alerts || []);
const activeBatches = batches.filter(b => b.status !== 'completed' && b.status !== 'failed');
const upcomingHarvests = activeBatches.filter(b => {
const daysUntil = (new Date(b.expectedHarvestDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24);
return daysUntil <= 7 && daysUntil >= 0;
});
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
<Head>
<title>{farm.name} - LocalGreenChain</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">
<a className="text-gray-600 hover:text-gray-900">&larr; Back</a>
</Link>
<h1 className="text-2xl font-bold text-gray-900">{farm.name}</h1>
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${
farm.status === 'operational'
? 'bg-green-100 text-green-800'
: farm.status === 'maintenance'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{farm.status}
</span>
</div>
<nav className="flex gap-3">
<Link href={`/vertical-farm/${farmId}/zones`}>
<a className="px-4 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200">Zones</a>
</Link>
<Link href={`/vertical-farm/${farmId}/batches`}>
<a className="px-4 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200">Batches</a>
</Link>
<Link href={`/vertical-farm/${farmId}/analytics`}>
<a className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">Analytics</a>
</Link>
</nav>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<div className="bg-white rounded-lg shadow p-4">
<p className="text-sm text-gray-600">Active Plants</p>
<p className="text-2xl font-bold text-green-600">{farm.specs.currentActivePlants.toLocaleString()}</p>
</div>
<div className="bg-white rounded-lg shadow p-4">
<p className="text-sm text-gray-600">Growing Area</p>
<p className="text-2xl font-bold text-blue-600">{farm.specs.growingAreaSqm}m²</p>
</div>
<div className="bg-white rounded-lg shadow p-4">
<p className="text-sm text-gray-600">Active Batches</p>
<p className="text-2xl font-bold text-purple-600">{activeBatches.length}</p>
</div>
<div className="bg-white rounded-lg shadow p-4">
<p className="text-sm text-gray-600">Capacity</p>
<p className="text-2xl font-bold text-orange-600">{farm.currentCapacityUtilization}%</p>
</div>
<div className="bg-white rounded-lg shadow p-4">
<p className="text-sm text-gray-600">Efficiency</p>
<p className="text-2xl font-bold text-teal-600">{farm.energyEfficiencyScore}%</p>
</div>
</div>
{/* Alerts */}
{allAlerts.length > 0 && (
<div className="mb-8">
<AlertPanel alerts={allAlerts} />
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Zone Overview */}
<div className="bg-white rounded-lg shadow-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-gray-900">Zone Overview</h2>
<Link href={`/vertical-farm/${farmId}/zones`}>
<a className="text-green-600 hover:text-green-700 text-sm">View All &rarr;</a>
</Link>
</div>
<ZoneGrid zones={farm.zones.slice(0, 6)} />
</div>
{/* Upcoming Harvests */}
{upcomingHarvests.length > 0 && (
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Upcoming Harvests</h2>
<div className="space-y-3">
{upcomingHarvests.map(batch => {
const daysUntil = Math.ceil(
(new Date(batch.expectedHarvestDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
return (
<div
key={batch.id}
className="flex justify-between items-center p-3 bg-purple-50 rounded-lg"
>
<div>
<p className="font-medium text-gray-900">{batch.cropType}</p>
<p className="text-sm text-gray-600">{batch.plantCount} plants</p>
</div>
<div className="text-right">
<p className="font-semibold text-purple-600">
{daysUntil === 0 ? 'Today' : `${daysUntil} day${daysUntil > 1 ? 's' : ''}`}
</p>
<p className="text-sm text-gray-600">{batch.expectedYieldKg.toFixed(1)} kg expected</p>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Farm Info */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Farm Details</h2>
<div className="space-y-3 text-sm">
<div>
<p className="text-gray-600">Location</p>
<p className="font-medium">{farm.location.city}, {farm.location.country}</p>
<p className="text-gray-500">{farm.location.address}</p>
</div>
<div>
<p className="text-gray-600">Building</p>
<p className="font-medium">{farm.specs.buildingType.replace('_', ' ')}</p>
</div>
<div>
<p className="text-gray-600">Levels</p>
<p className="font-medium">{farm.specs.numberOfLevels}</p>
</div>
<div>
<p className="text-gray-600">Automation</p>
<p className="font-medium">{farm.automationLevel.replace('_', ' ')}</p>
</div>
<div>
<p className="text-gray-600">Operational Since</p>
<p className="font-medium">{new Date(farm.operationalSince).toLocaleDateString()}</p>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Quick Actions</h2>
<div className="space-y-2">
<Link href={`/vertical-farm/${farmId}/batches?action=new`}>
<a className="block w-full px-4 py-2 bg-green-100 text-green-700 rounded-lg text-center hover:bg-green-200 transition">
Start New Batch
</a>
</Link>
<button className="w-full px-4 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition">
Record Environment
</button>
<button className="w-full px-4 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition">
Schedule Maintenance
</button>
</div>
</div>
{/* Certifications */}
{farm.specs.certifications.length > 0 && (
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-lg font-bold text-gray-900 mb-3">Certifications</h2>
<div className="flex flex-wrap gap-2">
{farm.specs.certifications.map(cert => (
<span
key={cert}
className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm"
>
{cert.toUpperCase()}
</span>
))}
</div>
</div>
)}
</div>
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,315 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Head from 'next/head';
import { VerticalFarm, GrowingZone, GrowingRecipe } from '../../../lib/vertical-farming/types';
import ZoneGrid from '../../../components/vertical-farm/ZoneGrid';
import ZoneDetailCard from '../../../components/vertical-farm/ZoneDetailCard';
import EnvironmentGauge from '../../../components/vertical-farm/EnvironmentGauge';
import RecipeSelector from '../../../components/vertical-farm/RecipeSelector';
export default function ZoneManagement() {
const router = useRouter();
const { farmId } = router.query;
const [farm, setFarm] = useState<VerticalFarm | null>(null);
const [selectedZone, setSelectedZone] = useState<GrowingZone | null>(null);
const [loading, setLoading] = useState(true);
const [showStartBatch, setShowStartBatch] = useState(false);
const [selectedRecipe, setSelectedRecipe] = useState<GrowingRecipe | null>(null);
const [plantCount, setPlantCount] = useState('');
const [seedBatchId, setSeedBatchId] = useState('');
useEffect(() => {
if (farmId) {
fetchFarm();
}
}, [farmId]);
const fetchFarm = async () => {
try {
const response = await fetch(`/api/vertical-farm/${farmId}`);
const data = await response.json();
if (data.success) {
setFarm(data.farm);
}
} catch (error) {
console.error('Error fetching farm:', error);
} finally {
setLoading(false);
}
};
const handleStartBatch = async () => {
if (!selectedZone || !selectedRecipe) return;
try {
const response = await fetch('/api/vertical-farm/batch/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
farmId,
zoneId: selectedZone.id,
recipeId: selectedRecipe.id,
seedBatchId: seedBatchId || `seed-${Date.now()}`,
plantCount: parseInt(plantCount) || selectedZone.plantPositions,
}),
});
const data = await response.json();
if (data.success) {
setShowStartBatch(false);
setSelectedRecipe(null);
setPlantCount('');
setSeedBatchId('');
fetchFarm();
}
} catch (error) {
console.error('Error starting batch:', 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>
);
}
if (!farm) {
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100 flex items-center justify-center">
<p>Farm not found</p>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
<Head>
<title>Zone Management - {farm.name}</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">&larr; Back</a>
</Link>
<h1 className="text-2xl font-bold text-gray-900">Zone Management</h1>
</div>
<p className="text-gray-600">{farm.name}</p>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Zone Grid */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">All Zones</h2>
<ZoneGrid
zones={farm.zones}
onZoneSelect={setSelectedZone}
selectedZoneId={selectedZone?.id}
/>
</div>
</div>
{/* Zone Detail / Actions */}
<div className="space-y-6">
{selectedZone ? (
<>
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">{selectedZone.name}</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-gray-600">Status</p>
<p className="font-semibold capitalize">{selectedZone.status}</p>
</div>
<div>
<p className="text-gray-600">Method</p>
<p className="font-semibold">{selectedZone.growingMethod}</p>
</div>
<div>
<p className="text-gray-600">Level</p>
<p className="font-semibold">{selectedZone.level}</p>
</div>
<div>
<p className="text-gray-600">Area</p>
<p className="font-semibold">{selectedZone.areaSqm}m²</p>
</div>
<div>
<p className="text-gray-600">Positions</p>
<p className="font-semibold">{selectedZone.plantPositions}</p>
</div>
<div>
<p className="text-gray-600">Active Plants</p>
<p className="font-semibold">{selectedZone.plantIds.length}</p>
</div>
</div>
{selectedZone.currentCrop && (
<div className="pt-4 border-t">
<p className="text-gray-600">Current Crop</p>
<p className="font-semibold text-lg">{selectedZone.currentCrop}</p>
{selectedZone.expectedHarvestDate && (
<p className="text-sm text-gray-600">
Harvest: {new Date(selectedZone.expectedHarvestDate).toLocaleDateString()}
</p>
)}
</div>
)}
</div>
</div>
{/* Environment Readings */}
{selectedZone.currentEnvironment && (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="font-semibold text-gray-900 mb-4">Current Environment</h3>
<div className="space-y-3">
<EnvironmentGauge
label="Temperature"
value={selectedZone.currentEnvironment.temperatureC}
unit="°C"
target={selectedZone.environmentTargets.temperatureC.target}
min={selectedZone.environmentTargets.temperatureC.min}
max={selectedZone.environmentTargets.temperatureC.max}
/>
<EnvironmentGauge
label="Humidity"
value={selectedZone.currentEnvironment.humidityPercent}
unit="%"
target={selectedZone.environmentTargets.humidityPercent.target}
min={selectedZone.environmentTargets.humidityPercent.min}
max={selectedZone.environmentTargets.humidityPercent.max}
/>
<EnvironmentGauge
label="CO2"
value={selectedZone.currentEnvironment.co2Ppm}
unit=" ppm"
target={selectedZone.environmentTargets.co2Ppm.target}
min={selectedZone.environmentTargets.co2Ppm.min}
max={selectedZone.environmentTargets.co2Ppm.max}
/>
</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">
{selectedZone.status === 'empty' && (
<button
onClick={() => setShowStartBatch(true)}
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
Start New Batch
</button>
)}
{(selectedZone.status === 'growing' || selectedZone.status === 'ready') && (
<button className="w-full px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition">
Harvest
</button>
)}
<button className="w-full px-4 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition">
Record Environment
</button>
<button className="w-full px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition">
Edit Zone
</button>
</div>
</div>
</>
) : (
<div className="bg-white rounded-lg shadow-lg p-6 text-center">
<p className="text-gray-600">Select a zone to view details and actions</p>
</div>
)}
</div>
</div>
{/* Start Batch Modal */}
{showStartBatch && selectedZone && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto m-4">
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-gray-900">Start New Batch</h2>
<button
onClick={() => setShowStartBatch(false)}
className="text-gray-400 hover:text-gray-600"
>
&times;
</button>
</div>
<p className="text-gray-600 mb-4">Zone: {selectedZone.name}</p>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Growing Recipe
</label>
<RecipeSelector
onSelect={setSelectedRecipe}
selectedRecipeId={selectedRecipe?.id}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Plant Count
</label>
<input
type="number"
value={plantCount}
onChange={e => setPlantCount(e.target.value)}
placeholder={`Max: ${selectedZone.plantPositions}`}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Seed Batch ID (optional)
</label>
<input
type="text"
value={seedBatchId}
onChange={e => setSeedBatchId(e.target.value)}
placeholder="Auto-generated if empty"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
</div>
<div className="flex gap-4">
<button
onClick={() => setShowStartBatch(false)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleStartBatch}
disabled={!selectedRecipe}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
Start Batch
</button>
</div>
</div>
</div>
</div>
</div>
)}
</main>
</div>
);
}

View file

@ -0,0 +1,161 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Head from 'next/head';
import FarmCard from '../../components/vertical-farm/FarmCard';
import { VerticalFarm } from '../../lib/vertical-farming/types';
export default function VerticalFarmDashboard() {
const [farms, setFarms] = useState<VerticalFarm[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({
totalFarms: 0,
totalPlants: 0,
upcomingHarvests: 0,
activeZones: 0,
});
useEffect(() => {
fetchFarms();
}, []);
const fetchFarms = async () => {
try {
const response = await fetch('/api/vertical-farm/list');
const data = await response.json();
if (data.success) {
setFarms(data.farms);
calculateStats(data.farms);
}
} catch (error) {
console.error('Error fetching farms:', error);
} finally {
setLoading(false);
}
};
const calculateStats = (farmList: VerticalFarm[]) => {
const totalPlants = farmList.reduce((sum, f) => sum + f.specs.currentActivePlants, 0);
const activeZones = farmList.reduce(
(sum, f) => sum + f.zones.filter(z => z.status === 'growing' || z.status === 'planted').length,
0
);
const upcomingHarvests = farmList.reduce(
(sum, f) =>
sum +
f.zones.filter(z => {
if (!z.expectedHarvestDate) return false;
const daysUntil = (new Date(z.expectedHarvestDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24);
return daysUntil <= 7 && daysUntil >= 0;
}).length,
0
);
setStats({
totalFarms: farmList.length,
totalPlants,
upcomingHarvests,
activeZones,
});
};
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
<Head>
<title>Vertical Farms - LocalGreenChain</title>
</Head>
{/* Header */}
<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">
<Link href="/">
<a className="text-2xl font-bold text-green-800">
LocalGreenChain
</a>
</Link>
<nav className="flex gap-4">
<Link href="/vertical-farm/register">
<a className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
Register Farm
</a>
</Link>
</nav>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Vertical Farm Dashboard</h1>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">Total Farms</p>
<p className="text-3xl font-bold text-green-600">{stats.totalFarms}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">Active Plants</p>
<p className="text-3xl font-bold text-green-600">{stats.totalPlants.toLocaleString()}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">Active Zones</p>
<p className="text-3xl font-bold text-blue-600">{stats.activeZones}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600">Harvests This Week</p>
<p className="text-3xl font-bold text-purple-600">{stats.upcomingHarvests}</p>
</div>
</div>
{/* Farms Grid */}
{loading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto" />
<p className="text-gray-600 mt-4">Loading farms...</p>
</div>
) : farms.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{farms.map(farm => (
<FarmCard key={farm.id} farm={farm} />
))}
</div>
) : (
<div className="bg-white rounded-lg shadow-lg p-12 text-center">
<div className="text-6xl mb-4">🌱</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">No Vertical Farms Yet</h2>
<p className="text-gray-600 mb-6">
Start your controlled environment agriculture journey by registering your first vertical farm.
</p>
<Link href="/vertical-farm/register">
<a className="inline-block px-6 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition">
Register Your First Farm
</a>
</Link>
</div>
)}
{/* Quick Actions */}
{farms.length > 0 && (
<div className="mt-8 bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Quick Actions</h2>
<div className="flex flex-wrap gap-4">
<Link href="/vertical-farm/register">
<a className="px-4 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition">
+ Add New Farm
</a>
</Link>
<button className="px-4 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition">
View All Batches
</button>
<button className="px-4 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition">
Export Analytics
</button>
</div>
</div>
)}
</main>
</div>
);
}

View file

@ -0,0 +1,492 @@
import { useState } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Head from 'next/head';
import { VerticalFarm, FacilitySpecs, GrowingZone } from '../../lib/vertical-farming/types';
export default function RegisterVerticalFarm() {
const router = useRouter();
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState({
name: '',
address: '',
city: '',
country: '',
latitude: '',
longitude: '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
totalAreaSqm: '',
growingAreaSqm: '',
numberOfLevels: '1',
ceilingHeightM: '3',
totalGrowingPositions: '',
powerCapacityKw: '',
waterStorageL: '',
buildingType: 'warehouse' as const,
automationLevel: 'semi_automated' as const,
zones: [] as Partial<GrowingZone>[],
});
const updateField = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const addZone = () => {
const newZone: Partial<GrowingZone> = {
id: `zone-${Date.now()}`,
name: `Zone ${formData.zones.length + 1}`,
level: 1,
areaSqm: 10,
lengthM: 5,
widthM: 2,
growingMethod: 'NFT',
plantPositions: 100,
status: 'empty',
plantIds: [],
};
setFormData(prev => ({ ...prev, zones: [...prev.zones, newZone] }));
};
const updateZone = (index: number, updates: Partial<GrowingZone>) => {
setFormData(prev => ({
...prev,
zones: prev.zones.map((z, i) => (i === index ? { ...z, ...updates } : z)),
}));
};
const removeZone = (index: number) => {
setFormData(prev => ({
...prev,
zones: prev.zones.filter((_, i) => i !== index),
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const farmData = {
name: formData.name,
location: {
latitude: parseFloat(formData.latitude),
longitude: parseFloat(formData.longitude),
address: formData.address,
city: formData.city,
country: formData.country,
timezone: formData.timezone,
},
specs: {
totalAreaSqm: parseFloat(formData.totalAreaSqm),
growingAreaSqm: parseFloat(formData.growingAreaSqm),
numberOfLevels: parseInt(formData.numberOfLevels),
ceilingHeightM: parseFloat(formData.ceilingHeightM),
totalGrowingPositions: parseInt(formData.totalGrowingPositions),
currentActivePlants: 0,
powerCapacityKw: parseFloat(formData.powerCapacityKw) || 0,
waterStorageL: parseFloat(formData.waterStorageL) || 0,
backupPowerHours: 0,
certifications: [],
buildingType: formData.buildingType,
insulation: 'standard',
},
zones: formData.zones,
automationLevel: formData.automationLevel,
};
const response = await fetch('/api/vertical-farm/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(farmData),
});
const data = await response.json();
if (data.success) {
router.push(`/vertical-farm/${data.farm.id}`);
} else {
setError(data.error || 'Failed to register farm');
}
} catch (err) {
setError('Network error. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
<Head>
<title>Register Vertical Farm - LocalGreenChain</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">
<Link href="/">
<a className="text-2xl font-bold text-green-800">LocalGreenChain</a>
</Link>
<Link href="/vertical-farm">
<a className="text-gray-600 hover:text-gray-900">Back to Dashboard</a>
</Link>
</div>
</div>
</header>
<main className="max-w-3xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Register Vertical Farm</h1>
{/* Progress Steps */}
<div className="flex items-center justify-center mb-8">
{[1, 2, 3].map(s => (
<div key={s} className="flex items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
step >= s ? 'bg-green-600 text-white' : 'bg-gray-200 text-gray-600'
}`}
>
{s}
</div>
{s < 3 && (
<div className={`w-20 h-1 ${step > s ? 'bg-green-600' : 'bg-gray-200'}`} />
)}
</div>
))}
</div>
<div className="flex justify-center gap-8 text-sm text-gray-600 mb-8">
<span className={step === 1 ? 'font-semibold text-green-600' : ''}>Basic Info</span>
<span className={step === 2 ? 'font-semibold text-green-600' : ''}>Facility Specs</span>
<span className={step === 3 ? 'font-semibold text-green-600' : ''}>Zones</span>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-lg p-6">
{/* Step 1: Basic Info */}
{step === 1 && (
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Basic Information</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Farm Name *</label>
<input
type="text"
required
value={formData.name}
onChange={e => updateField('name', e.target.value)}
placeholder="My Vertical Farm"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Address *</label>
<input
type="text"
required
value={formData.address}
onChange={e => updateField('address', e.target.value)}
placeholder="123 Farm Street"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">City *</label>
<input
type="text"
required
value={formData.city}
onChange={e => updateField('city', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Country *</label>
<input
type="text"
required
value={formData.country}
onChange={e => updateField('country', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Latitude</label>
<input
type="number"
step="any"
value={formData.latitude}
onChange={e => updateField('latitude', e.target.value)}
placeholder="40.7128"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Longitude</label>
<input
type="number"
step="any"
value={formData.longitude}
onChange={e => updateField('longitude', e.target.value)}
placeholder="-74.0060"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
</div>
</div>
)}
{/* Step 2: Facility Specs */}
{step === 2 && (
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Facility Specifications</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Total Area (m²) *</label>
<input
type="number"
required
value={formData.totalAreaSqm}
onChange={e => updateField('totalAreaSqm', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Growing Area (m²) *</label>
<input
type="number"
required
value={formData.growingAreaSqm}
onChange={e => updateField('growingAreaSqm', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Number of Levels *</label>
<input
type="number"
required
min="1"
value={formData.numberOfLevels}
onChange={e => updateField('numberOfLevels', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ceiling Height (m)</label>
<input
type="number"
step="0.1"
value={formData.ceilingHeightM}
onChange={e => updateField('ceilingHeightM', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Total Growing Positions *</label>
<input
type="number"
required
value={formData.totalGrowingPositions}
onChange={e => updateField('totalGrowingPositions', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Power Capacity (kW)</label>
<input
type="number"
value={formData.powerCapacityKw}
onChange={e => updateField('powerCapacityKw', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Water Storage (L)</label>
<input
type="number"
value={formData.waterStorageL}
onChange={e => updateField('waterStorageL', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Building Type</label>
<select
value={formData.buildingType}
onChange={e => updateField('buildingType', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
>
<option value="warehouse">Warehouse</option>
<option value="greenhouse">Greenhouse</option>
<option value="container">Container</option>
<option value="purpose_built">Purpose Built</option>
<option value="retrofit">Retrofit</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Automation Level</label>
<select
value={formData.automationLevel}
onChange={e => updateField('automationLevel', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
>
<option value="manual">Manual</option>
<option value="semi_automated">Semi-Automated</option>
<option value="fully_automated">Fully Automated</option>
</select>
</div>
</div>
</div>
)}
{/* Step 3: Zones */}
{step === 3 && (
<div className="space-y-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900">Growing Zones</h2>
<button
type="button"
onClick={addZone}
className="px-4 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition"
>
+ Add Zone
</button>
</div>
{formData.zones.length === 0 ? (
<div className="text-center py-8 bg-gray-50 rounded-lg">
<p className="text-gray-600">No zones added yet. Click "Add Zone" to create your first growing zone.</p>
</div>
) : (
<div className="space-y-4">
{formData.zones.map((zone, index) => (
<div key={zone.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex justify-between items-start mb-3">
<input
type="text"
value={zone.name}
onChange={e => updateZone(index, { name: e.target.value })}
className="text-lg font-medium border-none focus:ring-0 p-0"
/>
<button
type="button"
onClick={() => removeZone(index)}
className="text-red-600 hover:text-red-800"
>
Remove
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Level</label>
<input
type="number"
min="1"
value={zone.level}
onChange={e => updateZone(index, { level: parseInt(e.target.value) })}
className="w-full px-2 py-1 border border-gray-300 rounded text-sm"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Area (m²)</label>
<input
type="number"
value={zone.areaSqm}
onChange={e => updateZone(index, { areaSqm: parseFloat(e.target.value) })}
className="w-full px-2 py-1 border border-gray-300 rounded text-sm"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Positions</label>
<input
type="number"
value={zone.plantPositions}
onChange={e => updateZone(index, { plantPositions: parseInt(e.target.value) })}
className="w-full px-2 py-1 border border-gray-300 rounded text-sm"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Method</label>
<select
value={zone.growingMethod}
onChange={e => updateZone(index, { growingMethod: e.target.value as any })}
className="w-full px-2 py-1 border border-gray-300 rounded text-sm"
>
<option value="NFT">NFT</option>
<option value="DWC">DWC</option>
<option value="ebb_flow">Ebb & Flow</option>
<option value="aeroponics">Aeroponics</option>
<option value="vertical_towers">Vertical Towers</option>
<option value="rack_system">Rack System</option>
</select>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Navigation Buttons */}
<div className="flex justify-between mt-8 pt-6 border-t border-gray-200">
{step > 1 ? (
<button
type="button"
onClick={() => setStep(step - 1)}
className="px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
>
Previous
</button>
) : (
<div />
)}
{step < 3 ? (
<button
type="button"
onClick={() => setStep(step + 1)}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
Next
</button>
) : (
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition disabled:opacity-50"
>
{loading ? 'Registering...' : 'Register Farm'}
</button>
)}
</div>
</form>
</main>
</div>
);
}