Merge pull request #5 from vespo92/claude/complete-agent-1-tasks-01Mua4Y3mZ6bVyQtLEHRBHTn
Complete Agent 1 tasks from report
This commit is contained in:
commit
56956005d9
15 changed files with 2750 additions and 0 deletions
117
components/vertical-farm/AlertPanel.tsx
Normal file
117
components/vertical-farm/AlertPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
components/vertical-farm/BatchProgress.tsx
Normal file
105
components/vertical-farm/BatchProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
components/vertical-farm/EnvironmentGauge.tsx
Normal file
89
components/vertical-farm/EnvironmentGauge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
components/vertical-farm/FarmCard.tsx
Normal file
83
components/vertical-farm/FarmCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
components/vertical-farm/GrowthStageIndicator.tsx
Normal file
111
components/vertical-farm/GrowthStageIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
components/vertical-farm/RecipeSelector.tsx
Normal file
117
components/vertical-farm/RecipeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
components/vertical-farm/ResourceUsageChart.tsx
Normal file
124
components/vertical-farm/ResourceUsageChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
components/vertical-farm/ZoneDetailCard.tsx
Normal file
112
components/vertical-farm/ZoneDetailCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
components/vertical-farm/ZoneGrid.tsx
Normal file
44
components/vertical-farm/ZoneGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
289
pages/vertical-farm/[farmId]/analytics.tsx
Normal file
289
pages/vertical-farm/[farmId]/analytics.tsx
Normal 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">← 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>
|
||||
);
|
||||
}
|
||||
327
pages/vertical-farm/[farmId]/batches.tsx
Normal file
327
pages/vertical-farm/[farmId]/batches.tsx
Normal 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">← 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>
|
||||
);
|
||||
}
|
||||
264
pages/vertical-farm/[farmId]/index.tsx
Normal file
264
pages/vertical-farm/[farmId]/index.tsx
Normal 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">← 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 →</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>
|
||||
);
|
||||
}
|
||||
315
pages/vertical-farm/[farmId]/zones.tsx
Normal file
315
pages/vertical-farm/[farmId]/zones.tsx
Normal 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">← 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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
161
pages/vertical-farm/index.tsx
Normal file
161
pages/vertical-farm/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
492
pages/vertical-farm/register.tsx
Normal file
492
pages/vertical-farm/register.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue