Merge pull request #4 from vespo92/claude/complete-agent-1-tasks-01KZ35GuzCXvU6275R4aGGdB
Complete Agent 1 tasks from report
This commit is contained in:
commit
86bcf54279
16 changed files with 3266 additions and 0 deletions
272
components/analytics/EnvironmentalImpact.tsx
Normal file
272
components/analytics/EnvironmentalImpact.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
import { EnvironmentalImpact as ImpactData, TransportEventType, TransportMethod } from '../../lib/transport/types';
|
||||||
|
|
||||||
|
interface EnvironmentalImpactProps {
|
||||||
|
impact: ImpactData;
|
||||||
|
title?: string;
|
||||||
|
showDetails?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_LABELS: Record<TransportMethod, string> = {
|
||||||
|
walking: 'Walking',
|
||||||
|
bicycle: 'Bicycle',
|
||||||
|
electric_vehicle: 'Electric Vehicle',
|
||||||
|
hybrid_vehicle: 'Hybrid Vehicle',
|
||||||
|
gasoline_vehicle: 'Gas Vehicle',
|
||||||
|
diesel_truck: 'Diesel Truck',
|
||||||
|
electric_truck: 'Electric Truck',
|
||||||
|
refrigerated_truck: 'Refrigerated Truck',
|
||||||
|
rail: 'Rail',
|
||||||
|
ship: 'Ship',
|
||||||
|
air: 'Air Freight',
|
||||||
|
drone: 'Drone',
|
||||||
|
local_delivery: 'Local Delivery',
|
||||||
|
customer_pickup: 'Customer Pickup',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_LABELS: Record<TransportEventType, string> = {
|
||||||
|
seed_acquisition: 'Seed Acquisition',
|
||||||
|
planting: 'Planting',
|
||||||
|
growing_transport: 'Growing Transport',
|
||||||
|
harvest: 'Harvest',
|
||||||
|
processing: 'Processing',
|
||||||
|
distribution: 'Distribution',
|
||||||
|
consumer_delivery: 'Consumer Delivery',
|
||||||
|
seed_saving: 'Seed Saving',
|
||||||
|
seed_sharing: 'Seed Sharing',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCarbonRating(carbon: number): { label: string; color: string; bgColor: string } {
|
||||||
|
if (carbon < 0.5) return { label: 'Excellent', color: 'text-green-600', bgColor: 'bg-green-100' };
|
||||||
|
if (carbon < 1) return { label: 'Very Good', color: 'text-emerald-600', bgColor: 'bg-emerald-100' };
|
||||||
|
if (carbon < 2) return { label: 'Good', color: 'text-lime-600', bgColor: 'bg-lime-100' };
|
||||||
|
if (carbon < 5) return { label: 'Moderate', color: 'text-yellow-600', bgColor: 'bg-yellow-100' };
|
||||||
|
if (carbon < 10) return { label: 'High', color: 'text-orange-600', bgColor: 'bg-orange-100' };
|
||||||
|
return { label: 'Very High', color: 'text-red-600', bgColor: 'bg-red-100' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EnvironmentalImpact({
|
||||||
|
impact,
|
||||||
|
title = 'Environmental Impact Report',
|
||||||
|
showDetails = true,
|
||||||
|
}: EnvironmentalImpactProps) {
|
||||||
|
const rating = getCarbonRating(impact.totalCarbonKg);
|
||||||
|
|
||||||
|
// Sort methods by carbon impact
|
||||||
|
const sortedMethods = Object.entries(impact.breakdownByMethod)
|
||||||
|
.filter(([_, data]) => data.carbon > 0 || data.distance > 0)
|
||||||
|
.sort((a, b) => b[1].carbon - a[1].carbon);
|
||||||
|
|
||||||
|
// Sort events by count
|
||||||
|
const sortedEvents = Object.entries(impact.breakdownByEventType)
|
||||||
|
.filter(([_, data]) => data.count > 0)
|
||||||
|
.sort((a, b) => b[1].count - a[1].count);
|
||||||
|
|
||||||
|
const totalMethods = sortedMethods.length;
|
||||||
|
const totalEvents = sortedEvents.reduce((sum, [_, data]) => sum + data.count, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-teal-600 to-cyan-600 text-white p-6">
|
||||||
|
<h3 className="text-xl font-bold">{title}</h3>
|
||||||
|
<p className="text-teal-200 text-sm mt-1">Complete carbon and food miles analysis</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Main metrics */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="text-center p-4 bg-green-50 rounded-lg">
|
||||||
|
<p className="text-3xl font-bold text-green-600">{impact.totalCarbonKg.toFixed(2)}</p>
|
||||||
|
<p className="text-sm text-gray-500">Total Carbon (kg CO2)</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||||
|
<p className="text-3xl font-bold text-blue-600">{impact.totalFoodMiles.toFixed(1)}</p>
|
||||||
|
<p className="text-sm text-gray-500">Total Food Miles (km)</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-purple-50 rounded-lg">
|
||||||
|
<p className="text-3xl font-bold text-purple-600">{impact.carbonPerKgProduce.toFixed(3)}</p>
|
||||||
|
<p className="text-sm text-gray-500">kg CO2 / kg Produce</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-orange-50 rounded-lg">
|
||||||
|
<p className="text-3xl font-bold text-orange-600">{impact.milesPerKgProduce.toFixed(1)}</p>
|
||||||
|
<p className="text-sm text-gray-500">Miles / kg Produce</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating badge */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className={`px-6 py-3 rounded-full ${rating.bgColor}`}>
|
||||||
|
<span className={`text-lg font-bold ${rating.color}`}>
|
||||||
|
Environmental Rating: {rating.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comparison with conventional */}
|
||||||
|
{impact.comparisonToConventional && (
|
||||||
|
<div className="p-6 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
|
||||||
|
<h4 className="text-lg font-bold text-gray-900 mb-4">
|
||||||
|
Comparison vs Conventional Agriculture
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-24 h-24 mx-auto mb-3 rounded-full bg-green-100 flex items-center justify-center">
|
||||||
|
<span className="text-3xl font-bold text-green-600">
|
||||||
|
{impact.comparisonToConventional.percentageReduction.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">Carbon Reduction</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-3xl font-bold text-green-600">
|
||||||
|
{impact.comparisonToConventional.carbonSaved.toFixed(1)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">kg CO2 Saved</p>
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-1 text-green-600">
|
||||||
|
<span>🌲</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
= {(impact.comparisonToConventional.carbonSaved / 21).toFixed(1)} trees/year
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-3xl font-bold text-blue-600">
|
||||||
|
{impact.comparisonToConventional.milesSaved.toFixed(0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">km Saved</p>
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-1 text-blue-600">
|
||||||
|
<span>🚗</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
= {(impact.comparisonToConventional.milesSaved / 15).toFixed(0)} car trips
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visual bar comparison */}
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-600">Your Carbon Footprint</span>
|
||||||
|
<span className="font-medium text-green-600">{impact.totalCarbonKg.toFixed(2)} kg</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-green-500 rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${100 - impact.comparisonToConventional.percentageReduction}%`
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-600">Conventional Average</span>
|
||||||
|
<span className="font-medium text-gray-500">
|
||||||
|
{(impact.totalCarbonKg + impact.comparisonToConventional.carbonSaved).toFixed(2)} kg
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
|
||||||
|
<div className="h-full bg-gray-400 rounded-full w-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detailed breakdowns */}
|
||||||
|
{showDetails && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* By Transport Method */}
|
||||||
|
{sortedMethods.length > 0 && (
|
||||||
|
<div className="p-4 border border-gray-200 rounded-lg">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-4">By Transport Method</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedMethods.map(([method, data]) => {
|
||||||
|
const percentage = impact.totalCarbonKg > 0
|
||||||
|
? (data.carbon / impact.totalCarbonKg) * 100
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div key={method}>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{METHOD_LABELS[method as TransportMethod] || method}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{data.carbon.toFixed(3)} kg ({percentage.toFixed(0)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="h-full bg-teal-500 rounded-full"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
{data.distance.toFixed(1)} km traveled
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* By Event Type */}
|
||||||
|
{sortedEvents.length > 0 && (
|
||||||
|
<div className="p-4 border border-gray-200 rounded-lg">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-4">By Event Type</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedEvents.map(([eventType, data]) => {
|
||||||
|
const percentage = totalEvents > 0
|
||||||
|
? (data.count / totalEvents) * 100
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div key={eventType}>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{EVENT_LABELS[eventType as TransportEventType] || eventType}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{data.count} events • {data.carbon.toFixed(3)} kg
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="h-full bg-cyan-500 rounded-full"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tips Section */}
|
||||||
|
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<h4 className="font-semibold text-yellow-800 mb-2">Tips to Reduce Impact</h4>
|
||||||
|
<ul className="text-sm text-yellow-700 space-y-1">
|
||||||
|
<li>• Prefer walking, cycling, or electric vehicles for short distances</li>
|
||||||
|
<li>• Consolidate multiple transports into single trips</li>
|
||||||
|
<li>• Source from local producers within 25km when possible</li>
|
||||||
|
<li>• Use rail transport for longer distances when available</li>
|
||||||
|
<li>• Avoid air freight unless absolutely necessary</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary stats */}
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 pt-4 border-t border-gray-200">
|
||||||
|
<span>Transport methods used: {totalMethods}</span>
|
||||||
|
<span>Total events tracked: {totalEvents}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
components/analytics/FoodMilesTracker.tsx
Normal file
231
components/analytics/FoodMilesTracker.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface FoodMilesTrackerProps {
|
||||||
|
data: FoodMilesData[];
|
||||||
|
currentTotal: number;
|
||||||
|
targetMiles?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FoodMilesData {
|
||||||
|
date: string;
|
||||||
|
miles: number;
|
||||||
|
carbonKg: number;
|
||||||
|
eventType?: string;
|
||||||
|
produceType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeRange = '7d' | '30d' | '90d' | '365d';
|
||||||
|
|
||||||
|
export default function FoodMilesTracker({
|
||||||
|
data,
|
||||||
|
currentTotal,
|
||||||
|
targetMiles = 50,
|
||||||
|
}: FoodMilesTrackerProps) {
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
|
||||||
|
|
||||||
|
// Filter data based on time range
|
||||||
|
const filterData = (range: TimeRange): FoodMilesData[] => {
|
||||||
|
const now = new Date();
|
||||||
|
const daysMap: Record<TimeRange, number> = {
|
||||||
|
'7d': 7,
|
||||||
|
'30d': 30,
|
||||||
|
'90d': 90,
|
||||||
|
'365d': 365,
|
||||||
|
};
|
||||||
|
const cutoff = new Date(now.getTime() - daysMap[range] * 24 * 60 * 60 * 1000);
|
||||||
|
return data.filter((d) => new Date(d.date) >= cutoff);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredData = filterData(timeRange);
|
||||||
|
|
||||||
|
// Calculate stats for filtered period
|
||||||
|
const totalMiles = filteredData.reduce((sum, d) => sum + d.miles, 0);
|
||||||
|
const totalCarbon = filteredData.reduce((sum, d) => sum + d.carbonKg, 0);
|
||||||
|
const avgMilesPerDay = filteredData.length > 0 ? totalMiles / filteredData.length : 0;
|
||||||
|
|
||||||
|
// Group data by day for chart
|
||||||
|
const dailyData: Record<string, { miles: number; carbon: number }> = {};
|
||||||
|
filteredData.forEach((d) => {
|
||||||
|
const day = d.date.split('T')[0];
|
||||||
|
if (!dailyData[day]) {
|
||||||
|
dailyData[day] = { miles: 0, carbon: 0 };
|
||||||
|
}
|
||||||
|
dailyData[day].miles += d.miles;
|
||||||
|
dailyData[day].carbon += d.carbonKg;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = Object.entries(dailyData).sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
const maxMiles = Math.max(...chartData.map(([_, d]) => d.miles), 1);
|
||||||
|
|
||||||
|
// Progress towards target
|
||||||
|
const progressPercentage = Math.min((currentTotal / targetMiles) * 100, 100);
|
||||||
|
const isOverTarget = currentTotal > targetMiles;
|
||||||
|
|
||||||
|
// Recent events
|
||||||
|
const recentEvents = [...data].sort(
|
||||||
|
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
|
).slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white p-6">
|
||||||
|
<h3 className="text-xl font-bold">Food Miles Tracker</h3>
|
||||||
|
<p className="text-blue-200 text-sm mt-1">Monitor your produce transportation distances</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Time range selector */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border border-gray-300">
|
||||||
|
{(['7d', '30d', '90d', '365d'] as TimeRange[]).map((range) => (
|
||||||
|
<button
|
||||||
|
key={range}
|
||||||
|
onClick={() => setTimeRange(range)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium ${
|
||||||
|
timeRange === range
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-white text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{range === '7d' ? '7 Days' : range === '30d' ? '30 Days' : range === '90d' ? '90 Days' : '1 Year'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current total with target */}
|
||||||
|
<div className="p-6 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">Current Period Total</p>
|
||||||
|
<p className={`text-4xl font-bold ${isOverTarget ? 'text-orange-600' : 'text-blue-600'}`}>
|
||||||
|
{totalMiles.toFixed(1)} km
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-600">Target</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-400">{targetMiles} km</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-4">
|
||||||
|
<div
|
||||||
|
className={`h-4 rounded-full transition-all ${
|
||||||
|
isOverTarget ? 'bg-orange-500' : 'bg-blue-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${progressPercentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/* Target marker */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 w-0.5 h-6 bg-gray-800 -mt-1"
|
||||||
|
style={{ left: '100%' }}
|
||||||
|
>
|
||||||
|
<div className="absolute -top-5 -left-4 text-xs text-gray-500">Target</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={`text-sm mt-2 ${isOverTarget ? 'text-orange-600' : 'text-green-600'}`}>
|
||||||
|
{isOverTarget
|
||||||
|
? `${(currentTotal - targetMiles).toFixed(1)} km over target`
|
||||||
|
: `${(targetMiles - currentTotal).toFixed(1)} km remaining to target`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{filteredData.length}</p>
|
||||||
|
<p className="text-xs text-gray-500">Transport Events</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-green-50 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-green-600">{totalCarbon.toFixed(2)}</p>
|
||||||
|
<p className="text-xs text-gray-500">kg CO2 Total</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{avgMilesPerDay.toFixed(1)}</p>
|
||||||
|
<p className="text-xs text-gray-500">Avg km/day</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simple bar chart */}
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 mb-4">Daily Food Miles</h4>
|
||||||
|
<div className="flex items-end gap-1 h-32">
|
||||||
|
{chartData.slice(-14).map(([date, values], index) => {
|
||||||
|
const height = (values.miles / maxMiles) * 100;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={date}
|
||||||
|
className="flex-1 flex flex-col items-center group"
|
||||||
|
>
|
||||||
|
<div className="w-full relative flex-1 flex items-end">
|
||||||
|
<div
|
||||||
|
className="w-full bg-blue-500 rounded-t hover:bg-blue-600 transition cursor-pointer"
|
||||||
|
style={{ height: `${Math.max(height, 2)}%` }}
|
||||||
|
title={`${date}: ${values.miles.toFixed(1)} km`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1 rotate-45 origin-top-left">
|
||||||
|
{new Date(date).getDate()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 text-center mt-2">Last 14 days</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent events */}
|
||||||
|
{recentEvents.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 mb-3">Recent Transport Events</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recentEvents.map((event, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{event.produceType || event.eventType || 'Transport'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{new Date(event.date).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-bold text-blue-600">{event.miles.toFixed(1)} km</p>
|
||||||
|
<p className="text-xs text-gray-500">{event.carbonKg.toFixed(3)} kg CO2</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comparison info */}
|
||||||
|
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<h4 className="font-semibold text-green-800 mb-2">Did you know?</h4>
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
The average food item in conventional supply chains travels <strong>2,400 km</strong> before reaching your plate.
|
||||||
|
LocalGreenChain helps reduce this by connecting you with local growers.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 text-sm text-green-600">
|
||||||
|
Your average: <strong>{avgMilesPerDay.toFixed(1)} km/day</strong> vs Conventional: <strong>6.6 km/day</strong>
|
||||||
|
{avgMilesPerDay < 6.6 && (
|
||||||
|
<span className="ml-2 text-green-500">
|
||||||
|
({((1 - avgMilesPerDay / 6.6) * 100).toFixed(0)}% better!)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
components/analytics/SavingsCalculator.tsx
Normal file
273
components/analytics/SavingsCalculator.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
|
||||||
|
interface SavingsCalculatorProps {
|
||||||
|
actualCarbon: number;
|
||||||
|
actualMiles: number;
|
||||||
|
produceWeightKg: number;
|
||||||
|
conventionalData?: ConventionalBaseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConventionalBaseline {
|
||||||
|
avgCarbonPerKg: number; // Default: 2.5 kg CO2 per kg produce
|
||||||
|
avgMilesPerKg: number; // Default: 2400 km average food miles
|
||||||
|
avgWaterLitersPerKg: number; // Default: 500 liters per kg
|
||||||
|
avgFoodWastePercent: number; // Default: 30% waste
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONVENTIONAL: ConventionalBaseline = {
|
||||||
|
avgCarbonPerKg: 2.5,
|
||||||
|
avgMilesPerKg: 2400,
|
||||||
|
avgWaterLitersPerKg: 500,
|
||||||
|
avgFoodWastePercent: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EQUIVALENTS = {
|
||||||
|
carbonPerTree: 21, // kg CO2 absorbed per tree per year
|
||||||
|
carbonPerCarMile: 0.404, // kg CO2 per mile driven
|
||||||
|
carbonPerFlight: 250, // kg CO2 per hour of flight
|
||||||
|
waterPerShower: 75, // liters per 5-min shower
|
||||||
|
carbonPerGallon: 8.89, // kg CO2 per gallon of gasoline
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SavingsCalculator({
|
||||||
|
actualCarbon,
|
||||||
|
actualMiles,
|
||||||
|
produceWeightKg,
|
||||||
|
conventionalData = DEFAULT_CONVENTIONAL,
|
||||||
|
}: SavingsCalculatorProps) {
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
|
||||||
|
// Calculate conventional equivalents
|
||||||
|
const conventionalCarbon = produceWeightKg * conventionalData.avgCarbonPerKg;
|
||||||
|
const conventionalMiles = produceWeightKg * conventionalData.avgMilesPerKg;
|
||||||
|
const conventionalWater = produceWeightKg * conventionalData.avgWaterLitersPerKg;
|
||||||
|
const conventionalWaste = produceWeightKg * (conventionalData.avgFoodWastePercent / 100);
|
||||||
|
|
||||||
|
// Calculate savings
|
||||||
|
const carbonSaved = conventionalCarbon - actualCarbon;
|
||||||
|
const milesSaved = conventionalMiles - actualMiles;
|
||||||
|
const wasteSaved = conventionalWaste * 0.75; // Assume 75% less waste with local
|
||||||
|
|
||||||
|
// Calculate percentage reductions
|
||||||
|
const carbonReduction = conventionalCarbon > 0 ? (carbonSaved / conventionalCarbon) * 100 : 0;
|
||||||
|
const milesReduction = conventionalMiles > 0 ? (milesSaved / conventionalMiles) * 100 : 0;
|
||||||
|
|
||||||
|
// Convert to tangible equivalents
|
||||||
|
const equivalents = useMemo(() => ({
|
||||||
|
treesEquivalent: carbonSaved / EQUIVALENTS.carbonPerTree,
|
||||||
|
carMilesEquivalent: carbonSaved / EQUIVALENTS.carbonPerCarMile,
|
||||||
|
flightHoursEquivalent: carbonSaved / EQUIVALENTS.carbonPerFlight,
|
||||||
|
showersEquivalent: (conventionalWater - conventionalWater * 0.1) / EQUIVALENTS.waterPerShower,
|
||||||
|
gallonsGasoline: carbonSaved / EQUIVALENTS.carbonPerGallon,
|
||||||
|
}), [carbonSaved, conventionalWater]);
|
||||||
|
|
||||||
|
// Calculate annual projections (assuming monthly data)
|
||||||
|
const annualMultiplier = 12;
|
||||||
|
const annualCarbonSaved = carbonSaved * annualMultiplier;
|
||||||
|
const annualMilesSaved = milesSaved * annualMultiplier;
|
||||||
|
|
||||||
|
// Rating based on carbon reduction
|
||||||
|
const getRating = (reduction: number): { stars: number; label: string; color: string } => {
|
||||||
|
if (reduction >= 90) return { stars: 5, label: 'Outstanding', color: 'text-green-600' };
|
||||||
|
if (reduction >= 75) return { stars: 4, label: 'Excellent', color: 'text-emerald-600' };
|
||||||
|
if (reduction >= 50) return { stars: 3, label: 'Great', color: 'text-lime-600' };
|
||||||
|
if (reduction >= 25) return { stars: 2, label: 'Good', color: 'text-yellow-600' };
|
||||||
|
return { stars: 1, label: 'Getting Started', color: 'text-orange-600' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const rating = getRating(carbonReduction);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-green-600 to-emerald-600 text-white p-6">
|
||||||
|
<h3 className="text-xl font-bold">Savings Calculator</h3>
|
||||||
|
<p className="text-green-200 text-sm mt-1">See your environmental impact vs conventional</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Rating display */}
|
||||||
|
<div className="text-center p-6 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg">
|
||||||
|
<div className="flex justify-center gap-1 mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<span
|
||||||
|
key={star}
|
||||||
|
className={`text-2xl ${star <= rating.stars ? 'text-yellow-400' : 'text-gray-300'}`}
|
||||||
|
>
|
||||||
|
⭐
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className={`text-xl font-bold ${rating.color}`}>{rating.label}</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
You've reduced carbon by {carbonReduction.toFixed(0)}% compared to conventional!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main savings metrics */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 bg-green-50 rounded-lg text-center">
|
||||||
|
<p className="text-3xl font-bold text-green-600">{carbonSaved.toFixed(1)}</p>
|
||||||
|
<p className="text-sm text-gray-500">kg CO2 Saved</p>
|
||||||
|
<div className="mt-2 h-2 bg-gray-200 rounded-full">
|
||||||
|
<div
|
||||||
|
className="h-full bg-green-500 rounded-full"
|
||||||
|
style={{ width: `${Math.min(carbonReduction, 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{carbonReduction.toFixed(0)}% reduction</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-blue-50 rounded-lg text-center">
|
||||||
|
<p className="text-3xl font-bold text-blue-600">{milesSaved.toFixed(0)}</p>
|
||||||
|
<p className="text-sm text-gray-500">km Saved</p>
|
||||||
|
<div className="mt-2 h-2 bg-gray-200 rounded-full">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full"
|
||||||
|
style={{ width: `${Math.min(milesReduction, 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{milesReduction.toFixed(0)}% reduction</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comparison table */}
|
||||||
|
<div className="overflow-hidden border border-gray-200 rounded-lg">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900">Metric</th>
|
||||||
|
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900">Your Impact</th>
|
||||||
|
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-500">Conventional</th>
|
||||||
|
<th className="px-4 py-3 text-right text-sm font-semibold text-green-600">Saved</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">Carbon Footprint</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right font-medium">{actualCarbon.toFixed(2)} kg</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right text-gray-500">{conventionalCarbon.toFixed(2)} kg</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right text-green-600 font-medium">
|
||||||
|
-{carbonSaved.toFixed(2)} kg
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">Food Miles</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right font-medium">{actualMiles.toFixed(0)} km</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right text-gray-500">{conventionalMiles.toFixed(0)} km</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right text-green-600 font-medium">
|
||||||
|
-{milesSaved.toFixed(0)} km
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">Est. Food Waste</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right font-medium">
|
||||||
|
{(conventionalWaste * 0.25).toFixed(2)} kg
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right text-gray-500">{conventionalWaste.toFixed(2)} kg</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right text-green-600 font-medium">
|
||||||
|
-{wasteSaved.toFixed(2)} kg
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tangible equivalents */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition"
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-gray-900">What does this mean?</span>
|
||||||
|
<span className="text-gray-500">{showDetails ? '▼' : '▶'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDetails && (
|
||||||
|
<div className="mt-4 grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 bg-green-50 rounded-lg text-center">
|
||||||
|
<span className="text-3xl">🌲</span>
|
||||||
|
<p className="text-xl font-bold text-green-600 mt-2">
|
||||||
|
{equivalents.treesEquivalent.toFixed(1)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">Trees planted (annual equivalent)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-blue-50 rounded-lg text-center">
|
||||||
|
<span className="text-3xl">🚗</span>
|
||||||
|
<p className="text-xl font-bold text-blue-600 mt-2">
|
||||||
|
{equivalents.carMilesEquivalent.toFixed(0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">Car miles avoided</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-purple-50 rounded-lg text-center">
|
||||||
|
<span className="text-3xl">✈️</span>
|
||||||
|
<p className="text-xl font-bold text-purple-600 mt-2">
|
||||||
|
{equivalents.flightHoursEquivalent.toFixed(1)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">Flight hours equivalent</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-cyan-50 rounded-lg text-center">
|
||||||
|
<span className="text-3xl">🚿</span>
|
||||||
|
<p className="text-xl font-bold text-cyan-600 mt-2">
|
||||||
|
{equivalents.showersEquivalent.toFixed(0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">Showers worth of water</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-orange-50 rounded-lg text-center">
|
||||||
|
<span className="text-3xl">⛽</span>
|
||||||
|
<p className="text-xl font-bold text-orange-600 mt-2">
|
||||||
|
{equivalents.gallonsGasoline.toFixed(1)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">Gallons of gas saved</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-yellow-50 rounded-lg text-center">
|
||||||
|
<span className="text-3xl">🌍</span>
|
||||||
|
<p className="text-xl font-bold text-yellow-600 mt-2">
|
||||||
|
{carbonReduction.toFixed(0)}%
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">Better for the planet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Annual projection */}
|
||||||
|
<div className="p-4 bg-gradient-to-r from-emerald-50 to-teal-50 rounded-lg border border-emerald-200">
|
||||||
|
<h4 className="font-semibold text-emerald-800 mb-3">Annual Projection</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-emerald-600">
|
||||||
|
{annualCarbonSaved.toFixed(0)} kg CO2
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">Carbon saved per year</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-teal-600">
|
||||||
|
{annualMilesSaved.toFixed(0)} km
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">Food miles saved per year</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-3">
|
||||||
|
Based on current consumption of {produceWeightKg.toFixed(1)} kg produce
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Call to action */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Keep sourcing local produce to maximize your environmental impact!
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Every kilometer saved makes a difference
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
components/analytics/index.ts
Normal file
5
components/analytics/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export { default as EnvironmentalImpact } from './EnvironmentalImpact';
|
||||||
|
export { default as FoodMilesTracker } from './FoodMilesTracker';
|
||||||
|
export { default as SavingsCalculator } from './SavingsCalculator';
|
||||||
|
export type { FoodMilesData } from './FoodMilesTracker';
|
||||||
|
export type { ConventionalBaseline } from './SavingsCalculator';
|
||||||
163
components/demand/DemandSignalCard.tsx
Normal file
163
components/demand/DemandSignalCard.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { DemandSignal, DemandItem } from '../../lib/demand/types';
|
||||||
|
|
||||||
|
interface DemandSignalCardProps {
|
||||||
|
signal: DemandSignal;
|
||||||
|
onViewDetails?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<DemandSignal['supplyStatus'], { bg: string; text: string; icon: string }> = {
|
||||||
|
surplus: { bg: 'bg-green-100', text: 'text-green-800', icon: '📈' },
|
||||||
|
balanced: { bg: 'bg-blue-100', text: 'text-blue-800', icon: '⚖️' },
|
||||||
|
shortage: { bg: 'bg-yellow-100', text: 'text-yellow-800', icon: '⚠️' },
|
||||||
|
critical: { bg: 'bg-red-100', text: 'text-red-800', icon: '🚨' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatCategory(category: string): string {
|
||||||
|
return category.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DemandSignalCard({ signal, onViewDetails }: DemandSignalCardProps) {
|
||||||
|
const statusStyle = STATUS_COLORS[signal.supplyStatus];
|
||||||
|
|
||||||
|
// Get top demand items
|
||||||
|
const topItems = [...signal.demandItems]
|
||||||
|
.sort((a, b) => b.weeklyDemandKg - a.weeklyDemandKg)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const gapPercentage = signal.totalWeeklyDemandKg > 0
|
||||||
|
? (signal.supplyGapKg / signal.totalWeeklyDemandKg) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold">{signal.region.name}</h3>
|
||||||
|
<p className="text-blue-200 text-sm">
|
||||||
|
{signal.seasonalPeriod.charAt(0).toUpperCase() + signal.seasonalPeriod.slice(1)} Season
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`px-3 py-1 rounded-full ${statusStyle.bg}`}>
|
||||||
|
<span className={`text-sm font-semibold ${statusStyle.text}`}>
|
||||||
|
{statusStyle.icon} {signal.supplyStatus.charAt(0).toUpperCase() + signal.supplyStatus.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Key metrics */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{signal.totalConsumers}</p>
|
||||||
|
<p className="text-xs text-gray-500">Consumers</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-blue-50 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{signal.totalWeeklyDemandKg.toFixed(0)}</p>
|
||||||
|
<p className="text-xs text-gray-500">Weekly Demand (kg)</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||||
|
<p className="text-2xl font-bold text-green-600">{signal.currentSupplyKg.toFixed(0)}</p>
|
||||||
|
<p className="text-xs text-gray-500">Current Supply (kg)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Supply Gap Indicator */}
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700">Supply Coverage</span>
|
||||||
|
<span className={`text-sm font-bold ${gapPercentage > 20 ? 'text-red-600' : 'text-green-600'}`}>
|
||||||
|
{(100 - gapPercentage).toFixed(0)}% covered
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div
|
||||||
|
className={`h-3 rounded-full transition-all ${
|
||||||
|
gapPercentage > 50 ? 'bg-red-500' : gapPercentage > 20 ? 'bg-yellow-500' : 'bg-green-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min(100 - gapPercentage, 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{signal.supplyGapKg > 0 && (
|
||||||
|
<p className="text-xs text-red-600 mt-2">
|
||||||
|
Gap: {signal.supplyGapKg.toFixed(0)} kg needed
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Demand Items */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 mb-3">Top Demanded Items</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{topItems.map((item, index) => (
|
||||||
|
<DemandItemRow key={index} item={item} rank={index + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confidence indicator */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-500">Confidence:</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((level) => (
|
||||||
|
<div
|
||||||
|
key={level}
|
||||||
|
className={`w-2 h-4 rounded-sm ${
|
||||||
|
level <= signal.confidenceLevel / 20 ? 'bg-green-500' : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-600">{signal.confidenceLevel}%</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
Updated: {new Date(signal.timestamp).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Details Button */}
|
||||||
|
{onViewDetails && (
|
||||||
|
<button
|
||||||
|
onClick={onViewDetails}
|
||||||
|
className="w-full mt-4 py-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition"
|
||||||
|
>
|
||||||
|
View Full Details
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DemandItemRow({ item, rank }: { item: DemandItem; rank: number }) {
|
||||||
|
const urgencyColors: Record<DemandItem['urgency'], string> = {
|
||||||
|
immediate: 'bg-red-100 text-red-700',
|
||||||
|
this_week: 'bg-orange-100 text-orange-700',
|
||||||
|
this_month: 'bg-yellow-100 text-yellow-700',
|
||||||
|
next_season: 'bg-blue-100 text-blue-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center p-2 bg-gray-50 rounded-lg hover:bg-gray-100 transition">
|
||||||
|
<span className="w-6 h-6 flex items-center justify-center bg-indigo-100 text-indigo-700 rounded-full text-xs font-bold">
|
||||||
|
{rank}
|
||||||
|
</span>
|
||||||
|
<div className="ml-3 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{item.produceType}</p>
|
||||||
|
<p className="text-xs text-gray-500">{formatCategory(item.category)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-bold text-gray-900">{item.weeklyDemandKg.toFixed(1)} kg</p>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${urgencyColors[item.urgency]}`}>
|
||||||
|
{item.urgency.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.inSeason && (
|
||||||
|
<span className="ml-2 text-green-500" title="In Season">🌿</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
465
components/demand/PreferencesForm.tsx
Normal file
465
components/demand/PreferencesForm.tsx
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ConsumerPreference, ProduceCategory, ProducePreference } from '../../lib/demand/types';
|
||||||
|
|
||||||
|
interface PreferencesFormProps {
|
||||||
|
initialPreferences?: Partial<ConsumerPreference>;
|
||||||
|
onSubmit: (preferences: Partial<ConsumerPreference>) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIETARY_TYPES = [
|
||||||
|
{ value: 'omnivore', label: 'Omnivore' },
|
||||||
|
{ value: 'vegetarian', label: 'Vegetarian' },
|
||||||
|
{ value: 'vegan', label: 'Vegan' },
|
||||||
|
{ value: 'pescatarian', label: 'Pescatarian' },
|
||||||
|
{ value: 'flexitarian', label: 'Flexitarian' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const PRODUCE_CATEGORIES: { value: ProduceCategory; label: string; icon: string }[] = [
|
||||||
|
{ value: 'leafy_greens', label: 'Leafy Greens', icon: '🥬' },
|
||||||
|
{ value: 'root_vegetables', label: 'Root Vegetables', icon: '🥕' },
|
||||||
|
{ value: 'nightshades', label: 'Nightshades', icon: '🍅' },
|
||||||
|
{ value: 'brassicas', label: 'Brassicas', icon: '🥦' },
|
||||||
|
{ value: 'alliums', label: 'Alliums', icon: '🧅' },
|
||||||
|
{ value: 'legumes', label: 'Legumes', icon: '🫘' },
|
||||||
|
{ value: 'squash', label: 'Squash', icon: '🎃' },
|
||||||
|
{ value: 'herbs', label: 'Herbs', icon: '🌿' },
|
||||||
|
{ value: 'microgreens', label: 'Microgreens', icon: '🌱' },
|
||||||
|
{ value: 'sprouts', label: 'Sprouts', icon: '🌾' },
|
||||||
|
{ value: 'mushrooms', label: 'Mushrooms', icon: '🍄' },
|
||||||
|
{ value: 'fruits', label: 'Fruits', icon: '🍎' },
|
||||||
|
{ value: 'berries', label: 'Berries', icon: '🍓' },
|
||||||
|
{ value: 'citrus', label: 'Citrus', icon: '🍊' },
|
||||||
|
{ value: 'tree_fruits', label: 'Tree Fruits', icon: '🍑' },
|
||||||
|
{ value: 'melons', label: 'Melons', icon: '🍈' },
|
||||||
|
{ value: 'edible_flowers', label: 'Edible Flowers', icon: '🌸' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CERTIFICATIONS = [
|
||||||
|
{ value: 'organic', label: 'Organic' },
|
||||||
|
{ value: 'non_gmo', label: 'Non-GMO' },
|
||||||
|
{ value: 'biodynamic', label: 'Biodynamic' },
|
||||||
|
{ value: 'local', label: 'Local' },
|
||||||
|
{ value: 'heirloom', label: 'Heirloom' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DELIVERY_METHODS = [
|
||||||
|
{ value: 'home_delivery', label: 'Home Delivery' },
|
||||||
|
{ value: 'pickup_point', label: 'Pickup Point' },
|
||||||
|
{ value: 'farmers_market', label: 'Farmers Market' },
|
||||||
|
{ value: 'csa', label: 'CSA Box' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const DELIVERY_FREQUENCIES = [
|
||||||
|
{ value: 'daily', label: 'Daily' },
|
||||||
|
{ value: 'twice_weekly', label: 'Twice Weekly' },
|
||||||
|
{ value: 'weekly', label: 'Weekly' },
|
||||||
|
{ value: 'bi_weekly', label: 'Bi-Weekly' },
|
||||||
|
{ value: 'monthly', label: 'Monthly' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function PreferencesForm({
|
||||||
|
initialPreferences,
|
||||||
|
onSubmit,
|
||||||
|
loading = false,
|
||||||
|
}: PreferencesFormProps) {
|
||||||
|
const [activeSection, setActiveSection] = useState<string>('dietary');
|
||||||
|
|
||||||
|
const [dietaryType, setDietaryType] = useState<string[]>(initialPreferences?.dietaryType || ['omnivore']);
|
||||||
|
const [allergies, setAllergies] = useState<string>(initialPreferences?.allergies?.join(', ') || '');
|
||||||
|
const [dislikes, setDislikes] = useState<string>(initialPreferences?.dislikes?.join(', ') || '');
|
||||||
|
|
||||||
|
const [preferredCategories, setPreferredCategories] = useState<ProduceCategory[]>(
|
||||||
|
initialPreferences?.preferredCategories || []
|
||||||
|
);
|
||||||
|
const [certifications, setCertifications] = useState<string[]>(
|
||||||
|
initialPreferences?.certificationPreferences || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const [freshnessImportance, setFreshnessImportance] = useState<number>(
|
||||||
|
initialPreferences?.freshnessImportance || 4
|
||||||
|
);
|
||||||
|
const [priceImportance, setPriceImportance] = useState<number>(
|
||||||
|
initialPreferences?.priceImportance || 3
|
||||||
|
);
|
||||||
|
const [sustainabilityImportance, setSustainabilityImportance] = useState<number>(
|
||||||
|
initialPreferences?.sustainabilityImportance || 4
|
||||||
|
);
|
||||||
|
|
||||||
|
const [deliveryMethods, setDeliveryMethods] = useState<string[]>(
|
||||||
|
initialPreferences?.deliveryPreferences?.method || ['home_delivery']
|
||||||
|
);
|
||||||
|
const [deliveryFrequency, setDeliveryFrequency] = useState<string>(
|
||||||
|
initialPreferences?.deliveryPreferences?.frequency || 'weekly'
|
||||||
|
);
|
||||||
|
|
||||||
|
const [householdSize, setHouseholdSize] = useState<number>(
|
||||||
|
initialPreferences?.householdSize || 2
|
||||||
|
);
|
||||||
|
const [weeklyBudget, setWeeklyBudget] = useState<number | undefined>(
|
||||||
|
initialPreferences?.weeklyBudget
|
||||||
|
);
|
||||||
|
|
||||||
|
const [maxDeliveryRadius, setMaxDeliveryRadius] = useState<number>(
|
||||||
|
initialPreferences?.location?.maxDeliveryRadiusKm || 25
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleCategory = (category: ProduceCategory) => {
|
||||||
|
setPreferredCategories((prev) =>
|
||||||
|
prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCertification = (cert: string) => {
|
||||||
|
setCertifications((prev) =>
|
||||||
|
prev.includes(cert) ? prev.filter((c) => c !== cert) : [...prev, cert]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDeliveryMethod = (method: string) => {
|
||||||
|
setDeliveryMethods((prev) =>
|
||||||
|
prev.includes(method) ? prev.filter((m) => m !== method) : [...prev, method]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const preferences: Partial<ConsumerPreference> = {
|
||||||
|
dietaryType: dietaryType as ConsumerPreference['dietaryType'],
|
||||||
|
allergies: allergies.split(',').map((a) => a.trim()).filter(Boolean),
|
||||||
|
dislikes: dislikes.split(',').map((d) => d.trim()).filter(Boolean),
|
||||||
|
preferredCategories,
|
||||||
|
certificationPreferences: certifications as ConsumerPreference['certificationPreferences'],
|
||||||
|
freshnessImportance: freshnessImportance as 1 | 2 | 3 | 4 | 5,
|
||||||
|
priceImportance: priceImportance as 1 | 2 | 3 | 4 | 5,
|
||||||
|
sustainabilityImportance: sustainabilityImportance as 1 | 2 | 3 | 4 | 5,
|
||||||
|
deliveryPreferences: {
|
||||||
|
method: deliveryMethods as ConsumerPreference['deliveryPreferences']['method'],
|
||||||
|
frequency: deliveryFrequency as ConsumerPreference['deliveryPreferences']['frequency'],
|
||||||
|
preferredDays: ['saturday'], // Default
|
||||||
|
},
|
||||||
|
householdSize,
|
||||||
|
weeklyBudget,
|
||||||
|
location: {
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
|
maxDeliveryRadiusKm: maxDeliveryRadius,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
onSubmit(preferences);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{ id: 'dietary', name: 'Diet', icon: '🥗' },
|
||||||
|
{ id: 'produce', name: 'Produce', icon: '🥬' },
|
||||||
|
{ id: 'quality', name: 'Quality', icon: '⭐' },
|
||||||
|
{ id: 'delivery', name: 'Delivery', icon: '📦' },
|
||||||
|
{ id: 'household', name: 'Household', icon: '🏠' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ImportanceSlider = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
helpText,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
helpText: string;
|
||||||
|
}) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label className="text-sm font-medium text-gray-700">{label}</label>
|
||||||
|
<span className="text-sm text-gray-500">{value}/5</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value))}
|
||||||
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">{helpText}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-lg">
|
||||||
|
{/* Section Tabs */}
|
||||||
|
<div className="flex overflow-x-auto border-b border-gray-200 bg-gray-50">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveSection(section.id)}
|
||||||
|
className={`px-4 py-3 text-sm font-medium whitespace-nowrap transition ${
|
||||||
|
activeSection === section.id
|
||||||
|
? 'border-b-2 border-green-600 text-green-600 bg-white'
|
||||||
|
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="mr-2">{section.icon}</span>
|
||||||
|
{section.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Dietary Section */}
|
||||||
|
{activeSection === 'dietary' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Dietary Preferences</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Dietary Type</label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{DIETARY_TYPES.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setDietaryType((prev) =>
|
||||||
|
prev.includes(type.value)
|
||||||
|
? prev.filter((t) => t !== type.value)
|
||||||
|
: [...prev, type.value]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={`p-2 rounded-lg border-2 text-sm font-medium transition ${
|
||||||
|
dietaryType.includes(type.value)
|
||||||
|
? 'border-green-500 bg-green-50 text-green-700'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Allergies (comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={allergies}
|
||||||
|
onChange={(e) => setAllergies(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||||
|
placeholder="e.g., nuts, shellfish, gluten"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Dislikes (comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={dislikes}
|
||||||
|
onChange={(e) => setDislikes(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||||
|
placeholder="e.g., cilantro, mushrooms"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Produce Section */}
|
||||||
|
{activeSection === 'produce' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Produce Preferences</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Preferred Categories ({preferredCategories.length} selected)
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{PRODUCE_CATEGORIES.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleCategory(category.value)}
|
||||||
|
className={`p-3 rounded-lg border-2 text-sm font-medium transition flex items-center gap-2 ${
|
||||||
|
preferredCategories.includes(category.value)
|
||||||
|
? 'border-green-500 bg-green-50 text-green-700'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{category.icon}</span>
|
||||||
|
<span className="text-xs">{category.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quality Section */}
|
||||||
|
{activeSection === 'quality' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Quality Preferences</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Preferred Certifications
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CERTIFICATIONS.map((cert) => (
|
||||||
|
<button
|
||||||
|
key={cert.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleCertification(cert.value)}
|
||||||
|
className={`px-4 py-2 rounded-full border-2 text-sm font-medium transition ${
|
||||||
|
certifications.includes(cert.value)
|
||||||
|
? 'border-green-500 bg-green-50 text-green-700'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cert.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 pt-4">
|
||||||
|
<ImportanceSlider
|
||||||
|
label="Freshness Importance"
|
||||||
|
value={freshnessImportance}
|
||||||
|
onChange={setFreshnessImportance}
|
||||||
|
helpText="How important is maximum freshness?"
|
||||||
|
/>
|
||||||
|
<ImportanceSlider
|
||||||
|
label="Price Importance"
|
||||||
|
value={priceImportance}
|
||||||
|
onChange={setPriceImportance}
|
||||||
|
helpText="How price-sensitive are you?"
|
||||||
|
/>
|
||||||
|
<ImportanceSlider
|
||||||
|
label="Sustainability Importance"
|
||||||
|
value={sustainabilityImportance}
|
||||||
|
onChange={setSustainabilityImportance}
|
||||||
|
helpText="How important is environmental impact?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delivery Section */}
|
||||||
|
{activeSection === 'delivery' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Delivery Preferences</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Preferred Delivery Methods
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{DELIVERY_METHODS.map((method) => (
|
||||||
|
<button
|
||||||
|
key={method.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDeliveryMethod(method.value)}
|
||||||
|
className={`p-3 rounded-lg border-2 text-sm font-medium transition ${
|
||||||
|
deliveryMethods.includes(method.value)
|
||||||
|
? 'border-green-500 bg-green-50 text-green-700'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{method.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Delivery Frequency</label>
|
||||||
|
<select
|
||||||
|
value={deliveryFrequency}
|
||||||
|
onChange={(e) => setDeliveryFrequency(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
{DELIVERY_FREQUENCIES.map((freq) => (
|
||||||
|
<option key={freq.value} value={freq.value}>
|
||||||
|
{freq.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Max Delivery Radius: {maxDeliveryRadius} km
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="5"
|
||||||
|
max="100"
|
||||||
|
value={maxDeliveryRadius}
|
||||||
|
onChange={(e) => setMaxDeliveryRadius(parseInt(e.target.value))}
|
||||||
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Shorter radius = fresher produce, fewer food miles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Household Section */}
|
||||||
|
{activeSection === 'household' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Household Info</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Household Size: {householdSize} {householdSize === 1 ? 'person' : 'people'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={householdSize}
|
||||||
|
onChange={(e) => setHouseholdSize(parseInt(e.target.value))}
|
||||||
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Weekly Budget (optional)
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-2 text-gray-500">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={weeklyBudget || ''}
|
||||||
|
onChange={(e) => setWeeklyBudget(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||||
|
placeholder="e.g., 100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Helps us recommend produce within your budget
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 px-4 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Saving...' : 'Save Preferences'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
components/demand/RecommendationList.tsx
Normal file
206
components/demand/RecommendationList.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import { PlantingRecommendation, RiskFactor } from '../../lib/demand/types';
|
||||||
|
|
||||||
|
interface RecommendationListProps {
|
||||||
|
recommendations: PlantingRecommendation[];
|
||||||
|
onSelect?: (recommendation: PlantingRecommendation) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISK_COLORS: Record<RiskFactor['severity'], string> = {
|
||||||
|
low: 'bg-green-100 text-green-800',
|
||||||
|
medium: 'bg-yellow-100 text-yellow-800',
|
||||||
|
high: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const OVERALL_RISK_COLORS: Record<string, string> = {
|
||||||
|
low: 'text-green-600 bg-green-50 border-green-200',
|
||||||
|
medium: 'text-yellow-600 bg-yellow-50 border-yellow-200',
|
||||||
|
high: 'text-red-600 bg-red-50 border-red-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatCategory(category: string): string {
|
||||||
|
return category.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecommendationList({
|
||||||
|
recommendations,
|
||||||
|
onSelect,
|
||||||
|
loading = false,
|
||||||
|
}: RecommendationListProps) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-4">Planting Recommendations</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="animate-pulse">
|
||||||
|
<div className="h-32 bg-gray-200 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recommendations.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-4">Planting Recommendations</h3>
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-4xl mb-3">🌱</div>
|
||||||
|
<p className="text-gray-500">No recommendations available yet.</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
Check back later or update your preferences to get personalized recommendations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">Planting Recommendations</h3>
|
||||||
|
<span className="text-sm text-gray-500">{recommendations.length} suggestions</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recommendations.map((rec) => (
|
||||||
|
<RecommendationCard key={rec.id} recommendation={rec} onSelect={onSelect} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecommendationCard({
|
||||||
|
recommendation: rec,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
recommendation: PlantingRecommendation;
|
||||||
|
onSelect?: (recommendation: PlantingRecommendation) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition ${
|
||||||
|
onSelect ? 'cursor-pointer' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelect?.(rec)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-semibold text-gray-900">{rec.produceType}</h4>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{rec.variety && <span className="mr-2">{rec.variety}</span>}
|
||||||
|
<span className="text-gray-400">|</span>
|
||||||
|
<span className="ml-2">{formatCategory(rec.category)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`px-3 py-1 rounded-full border ${OVERALL_RISK_COLORS[rec.overallRisk]}`}>
|
||||||
|
<span className="text-sm font-medium capitalize">{rec.overallRisk} Risk</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Key metrics */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="text-center p-2 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-gray-900">{rec.recommendedQuantity}</p>
|
||||||
|
<p className="text-xs text-gray-500">{rec.quantityUnit}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 bg-blue-50 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-blue-600">{rec.expectedYieldKg.toFixed(1)} kg</p>
|
||||||
|
<p className="text-xs text-gray-500">Expected Yield</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-2 bg-green-50 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-green-600">${rec.projectedRevenue.toFixed(0)}</p>
|
||||||
|
<p className="text-xs text-gray-500">Est. Revenue</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timing */}
|
||||||
|
<div className="flex items-center justify-between text-sm p-3 bg-yellow-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Plant by:</span>
|
||||||
|
<span className="font-semibold text-gray-900 ml-2">{formatDate(rec.plantByDate)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400">|</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Harvest:</span>
|
||||||
|
<span className="font-semibold text-gray-900 ml-2">
|
||||||
|
{formatDate(rec.expectedHarvestStart)} - {formatDate(rec.expectedHarvestEnd)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Market opportunity */}
|
||||||
|
<div className="p-3 border border-gray-200 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-600">Market Opportunity</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((level) => (
|
||||||
|
<div
|
||||||
|
key={level}
|
||||||
|
className={`w-2 h-4 rounded-sm ${
|
||||||
|
level <= rec.marketConfidence / 20 ? 'bg-green-500' : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
))}
|
||||||
|
<span className="text-xs text-gray-500 ml-1">{rec.marketConfidence}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-gray-600">Projected demand:</span>
|
||||||
|
<span className="font-semibold text-gray-900 ml-1">{rec.projectedDemandKg.toFixed(0)} kg</span>
|
||||||
|
<span className="text-gray-400 mx-2">@</span>
|
||||||
|
<span className="font-semibold text-green-600">${rec.projectedPricePerKg.toFixed(2)}/kg</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Risk factors */}
|
||||||
|
{rec.riskFactors.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-700 mb-2">Risk Factors</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{rec.riskFactors.slice(0, 3).map((risk, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={`text-xs px-2 py-1 rounded-full ${RISK_COLORS[risk.severity]}`}
|
||||||
|
title={risk.description}
|
||||||
|
>
|
||||||
|
{risk.type}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{rec.riskFactors.length > 3 && (
|
||||||
|
<span className="text-xs px-2 py-1 rounded-full bg-gray-100 text-gray-600">
|
||||||
|
+{rec.riskFactors.length - 3} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Explanation */}
|
||||||
|
<p className="text-sm text-gray-600 italic">"{rec.explanation}"</p>
|
||||||
|
|
||||||
|
{/* Growing days indicator */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500 pt-2 border-t border-gray-100">
|
||||||
|
<span>Growing period: {rec.growingDays} days</span>
|
||||||
|
<span>Yield confidence: {rec.yieldConfidence}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
308
components/demand/SeasonalCalendar.tsx
Normal file
308
components/demand/SeasonalCalendar.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { DemandItem, ProduceCategory } from '../../lib/demand/types';
|
||||||
|
|
||||||
|
interface SeasonalCalendarProps {
|
||||||
|
items: SeasonalItem[];
|
||||||
|
currentSeason?: 'spring' | 'summer' | 'fall' | 'winter';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeasonalItem {
|
||||||
|
produceType: string;
|
||||||
|
category: ProduceCategory;
|
||||||
|
seasonalAvailability: {
|
||||||
|
spring: boolean;
|
||||||
|
summer: boolean;
|
||||||
|
fall: boolean;
|
||||||
|
winter: boolean;
|
||||||
|
};
|
||||||
|
peakSeason?: 'spring' | 'summer' | 'fall' | 'winter';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEASONS = ['spring', 'summer', 'fall', 'winter'] as const;
|
||||||
|
|
||||||
|
const SEASON_COLORS: Record<string, { bg: string; border: string; text: string; icon: string }> = {
|
||||||
|
spring: { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-800', icon: '🌸' },
|
||||||
|
summer: { bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-800', icon: '☀️' },
|
||||||
|
fall: { bg: 'bg-orange-100', border: 'border-orange-300', text: 'text-orange-800', icon: '🍂' },
|
||||||
|
winter: { bg: 'bg-blue-100', border: 'border-blue-300', text: 'text-blue-800', icon: '❄️' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORY_ICONS: Record<ProduceCategory, string> = {
|
||||||
|
leafy_greens: '🥬',
|
||||||
|
root_vegetables: '🥕',
|
||||||
|
nightshades: '🍅',
|
||||||
|
brassicas: '🥦',
|
||||||
|
alliums: '🧅',
|
||||||
|
legumes: '🫘',
|
||||||
|
squash: '🎃',
|
||||||
|
herbs: '🌿',
|
||||||
|
microgreens: '🌱',
|
||||||
|
sprouts: '🌾',
|
||||||
|
mushrooms: '🍄',
|
||||||
|
fruits: '🍎',
|
||||||
|
berries: '🍓',
|
||||||
|
citrus: '🍊',
|
||||||
|
tree_fruits: '🍑',
|
||||||
|
melons: '🍈',
|
||||||
|
edible_flowers: '🌸',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatCategory(category: string): string {
|
||||||
|
return category.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentSeason(): 'spring' | 'summer' | 'fall' | 'winter' {
|
||||||
|
const month = new Date().getMonth();
|
||||||
|
if (month >= 2 && month <= 4) return 'spring';
|
||||||
|
if (month >= 5 && month <= 7) return 'summer';
|
||||||
|
if (month >= 8 && month <= 10) return 'fall';
|
||||||
|
return 'winter';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SeasonalCalendar({
|
||||||
|
items,
|
||||||
|
currentSeason = getCurrentSeason(),
|
||||||
|
}: SeasonalCalendarProps) {
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<ProduceCategory | 'all'>('all');
|
||||||
|
const [viewMode, setViewMode] = useState<'calendar' | 'list'>('calendar');
|
||||||
|
|
||||||
|
// Get unique categories
|
||||||
|
const categories = Array.from(new Set(items.map((item) => item.category)));
|
||||||
|
|
||||||
|
// Filter items by category
|
||||||
|
const filteredItems = selectedCategory === 'all'
|
||||||
|
? items
|
||||||
|
: items.filter((item) => item.category === selectedCategory);
|
||||||
|
|
||||||
|
// Group items by season for current view
|
||||||
|
const itemsBySeason: Record<string, SeasonalItem[]> = {
|
||||||
|
spring: [],
|
||||||
|
summer: [],
|
||||||
|
fall: [],
|
||||||
|
winter: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
filteredItems.forEach((item) => {
|
||||||
|
SEASONS.forEach((season) => {
|
||||||
|
if (item.seasonalAvailability[season]) {
|
||||||
|
itemsBySeason[season].push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get items available now
|
||||||
|
const availableNow = filteredItems.filter(
|
||||||
|
(item) => item.seasonalAvailability[currentSeason]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-emerald-600 to-teal-600 text-white p-4">
|
||||||
|
<h3 className="text-xl font-bold">Seasonal Availability</h3>
|
||||||
|
<p className="text-emerald-200 text-sm">
|
||||||
|
Currently: {SEASON_COLORS[currentSeason].icon} {currentSeason.charAt(0).toUpperCase() + currentSeason.slice(1)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="p-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
{/* Category filter */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-gray-600">Category:</label>
|
||||||
|
<select
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => setSelectedCategory(e.target.value as ProduceCategory | 'all')}
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat} value={cat}>
|
||||||
|
{CATEGORY_ICONS[cat]} {formatCategory(cat)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View toggle */}
|
||||||
|
<div className="flex rounded-lg overflow-hidden border border-gray-300">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('calendar')}
|
||||||
|
className={`px-3 py-1 text-sm ${
|
||||||
|
viewMode === 'calendar'
|
||||||
|
? 'bg-green-600 text-white'
|
||||||
|
: 'bg-white text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Calendar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`px-3 py-1 text-sm ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-green-600 text-white'
|
||||||
|
: 'bg-white text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Available Now Section */}
|
||||||
|
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<h4 className="font-semibold text-green-800 mb-3 flex items-center gap-2">
|
||||||
|
<span>{SEASON_COLORS[currentSeason].icon}</span>
|
||||||
|
Available Now ({availableNow.length} items)
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availableNow.slice(0, 12).map((item, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="px-3 py-1 bg-white border border-green-300 text-green-800 text-sm rounded-full"
|
||||||
|
>
|
||||||
|
{CATEGORY_ICONS[item.category]} {item.produceType}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{availableNow.length > 12 && (
|
||||||
|
<span className="px-3 py-1 bg-green-200 text-green-800 text-sm rounded-full">
|
||||||
|
+{availableNow.length - 12} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar View */}
|
||||||
|
{viewMode === 'calendar' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{SEASONS.map((season) => {
|
||||||
|
const seasonStyle = SEASON_COLORS[season];
|
||||||
|
const isCurrentSeason = season === currentSeason;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={season}
|
||||||
|
className={`rounded-lg border-2 ${
|
||||||
|
isCurrentSeason ? seasonStyle.border : 'border-gray-200'
|
||||||
|
} overflow-hidden`}
|
||||||
|
>
|
||||||
|
<div className={`p-3 ${seasonStyle.bg} ${seasonStyle.text}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-bold capitalize">
|
||||||
|
{seasonStyle.icon} {season}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm">
|
||||||
|
{itemsBySeason[season].length} items
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 max-h-48 overflow-y-auto">
|
||||||
|
{itemsBySeason[season].length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 text-center py-4">
|
||||||
|
No items in this season
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{itemsBySeason[season].map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`text-sm p-1 rounded flex items-center gap-1 ${
|
||||||
|
item.peakSeason === season ? 'font-medium bg-gray-100' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{CATEGORY_ICONS[item.category]}</span>
|
||||||
|
<span>{item.produceType}</span>
|
||||||
|
{item.peakSeason === season && (
|
||||||
|
<span className="text-yellow-500 ml-auto" title="Peak season">⭐</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List View */}
|
||||||
|
{viewMode === 'list' && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="text-left py-2 px-3 text-sm font-semibold text-gray-900">Item</th>
|
||||||
|
<th className="text-left py-2 px-3 text-sm font-semibold text-gray-900">Category</th>
|
||||||
|
{SEASONS.map((season) => (
|
||||||
|
<th
|
||||||
|
key={season}
|
||||||
|
className={`text-center py-2 px-3 text-sm font-semibold ${
|
||||||
|
season === currentSeason ? 'bg-green-50 text-green-800' : 'text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{SEASON_COLORS[season].icon}
|
||||||
|
<br />
|
||||||
|
<span className="text-xs capitalize">{season}</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredItems.map((item, index) => (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className="border-b border-gray-100 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td className="py-2 px-3 text-sm">
|
||||||
|
<span className="mr-1">{CATEGORY_ICONS[item.category]}</span>
|
||||||
|
{item.produceType}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-sm text-gray-500">
|
||||||
|
{formatCategory(item.category)}
|
||||||
|
</td>
|
||||||
|
{SEASONS.map((season) => (
|
||||||
|
<td
|
||||||
|
key={season}
|
||||||
|
className={`text-center py-2 px-3 ${
|
||||||
|
season === currentSeason ? 'bg-green-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.seasonalAvailability[season] ? (
|
||||||
|
<span className={item.peakSeason === season ? 'text-yellow-500' : 'text-green-500'}>
|
||||||
|
{item.peakSeason === season ? '⭐' : '✓'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-300">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-200 flex items-center justify-center gap-6 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-green-500">✓</span>
|
||||||
|
<span className="text-gray-600">Available</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-yellow-500">⭐</span>
|
||||||
|
<span className="text-gray-600">Peak Season</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-gray-300">-</span>
|
||||||
|
<span className="text-gray-600">Not Available</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
components/demand/SupplyGapChart.tsx
Normal file
187
components/demand/SupplyGapChart.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { DemandItem, DemandSignal } from '../../lib/demand/types';
|
||||||
|
|
||||||
|
interface SupplyGapChartProps {
|
||||||
|
demandSignal: DemandSignal;
|
||||||
|
showTopN?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCategory(category: string): string {
|
||||||
|
return category.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SupplyGapChart({ demandSignal, showTopN = 10 }: SupplyGapChartProps) {
|
||||||
|
// Sort items by gap (largest gaps first)
|
||||||
|
const sortedItems = [...demandSignal.demandItems]
|
||||||
|
.filter((item) => item.gapKg > 0)
|
||||||
|
.sort((a, b) => b.gapKg - a.gapKg)
|
||||||
|
.slice(0, showTopN);
|
||||||
|
|
||||||
|
// Find the max for scaling
|
||||||
|
const maxDemand = Math.max(...demandSignal.demandItems.map((item) => item.weeklyDemandKg));
|
||||||
|
|
||||||
|
// Calculate overall stats
|
||||||
|
const totalDemand = demandSignal.totalWeeklyDemandKg;
|
||||||
|
const totalSupply = demandSignal.currentSupplyKg;
|
||||||
|
const totalGap = demandSignal.supplyGapKg;
|
||||||
|
const coveragePercentage = totalDemand > 0 ? ((totalSupply / totalDemand) * 100) : 100;
|
||||||
|
|
||||||
|
if (demandSignal.demandItems.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-4">Supply vs Demand</h3>
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-4xl mb-3">📊</div>
|
||||||
|
<p className="text-gray-500">No demand data available.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-2">Supply vs Demand</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">{demandSignal.region.name} - {demandSignal.seasonalPeriod}</p>
|
||||||
|
|
||||||
|
{/* Overall summary */}
|
||||||
|
<div className="mb-6 p-4 bg-gradient-to-r from-blue-50 to-green-50 rounded-lg">
|
||||||
|
<div className="grid grid-cols-4 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{totalDemand.toFixed(0)}</p>
|
||||||
|
<p className="text-xs text-gray-500">Weekly Demand (kg)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{totalSupply.toFixed(0)}</p>
|
||||||
|
<p className="text-xs text-gray-500">Current Supply (kg)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={`text-2xl font-bold ${totalGap > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||||
|
{totalGap.toFixed(0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">Gap (kg)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={`text-2xl font-bold ${
|
||||||
|
coveragePercentage >= 100 ? 'text-green-600' :
|
||||||
|
coveragePercentage >= 80 ? 'text-yellow-600' : 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{coveragePercentage.toFixed(0)}%
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">Coverage</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coverage bar */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-4 transition-all ${
|
||||||
|
coveragePercentage >= 100 ? 'bg-green-500' :
|
||||||
|
coveragePercentage >= 80 ? 'bg-yellow-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min(coveragePercentage, 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gap items chart */}
|
||||||
|
{sortedItems.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900">Largest Supply Gaps</h4>
|
||||||
|
{sortedItems.map((item, index) => (
|
||||||
|
<SupplyGapBar key={index} item={item} maxDemand={maxDemand} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Items with surplus */}
|
||||||
|
{demandSignal.demandItems.filter(item => item.gapKg <= 0).length > 0 && (
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 mb-3">Well Supplied Items</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{demandSignal.demandItems
|
||||||
|
.filter(item => item.gapKg <= 0)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((item, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="px-3 py-1 bg-green-100 text-green-800 text-sm rounded-full"
|
||||||
|
>
|
||||||
|
{item.produceType}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-200 flex items-center justify-center gap-6 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-blue-500 rounded"></div>
|
||||||
|
<span className="text-gray-600">Demand</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
||||||
|
<span className="text-gray-600">Supply</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-red-400 rounded"></div>
|
||||||
|
<span className="text-gray-600">Gap</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SupplyGapBar({ item, maxDemand }: { item: DemandItem; maxDemand: number }) {
|
||||||
|
const demandWidth = (item.weeklyDemandKg / maxDemand) * 100;
|
||||||
|
const supplyWidth = (item.matchedSupply / maxDemand) * 100;
|
||||||
|
const gapPercentage = item.weeklyDemandKg > 0
|
||||||
|
? ((item.gapKg / item.weeklyDemandKg) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-900">{item.produceType}</span>
|
||||||
|
<span className="text-xs text-gray-500 ml-2">{formatCategory(item.category)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className={`text-sm font-bold ${gapPercentage > 50 ? 'text-red-600' : 'text-yellow-600'}`}>
|
||||||
|
-{item.gapKg.toFixed(1)} kg
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 ml-1">({gapPercentage.toFixed(0)}% gap)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stacked bar */}
|
||||||
|
<div className="relative h-6 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
{/* Supply (filled portion) */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 h-full bg-green-500 transition-all"
|
||||||
|
style={{ width: `${supplyWidth}%` }}
|
||||||
|
></div>
|
||||||
|
{/* Gap (unfilled portion shown as demand outline) */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 h-full border-2 border-blue-500 rounded-full pointer-events-none"
|
||||||
|
style={{ width: `${demandWidth}%` }}
|
||||||
|
></div>
|
||||||
|
{/* Gap indicator */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 h-full bg-red-300 opacity-50"
|
||||||
|
style={{
|
||||||
|
left: `${supplyWidth}%`,
|
||||||
|
width: `${demandWidth - supplyWidth}%`
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Values */}
|
||||||
|
<div className="flex justify-between mt-1 text-xs text-gray-500">
|
||||||
|
<span>Supply: {item.matchedSupply.toFixed(0)} kg</span>
|
||||||
|
<span>Demand: {item.weeklyDemandKg.toFixed(0)} kg</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
components/demand/index.ts
Normal file
6
components/demand/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export { default as DemandSignalCard } from './DemandSignalCard';
|
||||||
|
export { default as PreferencesForm } from './PreferencesForm';
|
||||||
|
export { default as RecommendationList } from './RecommendationList';
|
||||||
|
export { default as SupplyGapChart } from './SupplyGapChart';
|
||||||
|
export { default as SeasonalCalendar } from './SeasonalCalendar';
|
||||||
|
export type { SeasonalItem } from './SeasonalCalendar';
|
||||||
191
components/transport/CarbonFootprintCard.tsx
Normal file
191
components/transport/CarbonFootprintCard.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { EnvironmentalImpact, TransportMethod } from '../../lib/transport/types';
|
||||||
|
|
||||||
|
interface CarbonFootprintCardProps {
|
||||||
|
impact: EnvironmentalImpact;
|
||||||
|
showComparison?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_LABELS: Record<TransportMethod, string> = {
|
||||||
|
walking: 'Walking',
|
||||||
|
bicycle: 'Bicycle',
|
||||||
|
electric_vehicle: 'Electric Vehicle',
|
||||||
|
hybrid_vehicle: 'Hybrid Vehicle',
|
||||||
|
gasoline_vehicle: 'Gas Vehicle',
|
||||||
|
diesel_truck: 'Diesel Truck',
|
||||||
|
electric_truck: 'Electric Truck',
|
||||||
|
refrigerated_truck: 'Refrigerated Truck',
|
||||||
|
rail: 'Rail',
|
||||||
|
ship: 'Ship',
|
||||||
|
air: 'Air Freight',
|
||||||
|
drone: 'Drone',
|
||||||
|
local_delivery: 'Local Delivery',
|
||||||
|
customer_pickup: 'Customer Pickup',
|
||||||
|
};
|
||||||
|
|
||||||
|
const METHOD_COLORS: Record<string, string> = {
|
||||||
|
walking: 'bg-green-500',
|
||||||
|
bicycle: 'bg-green-400',
|
||||||
|
electric_vehicle: 'bg-emerald-400',
|
||||||
|
hybrid_vehicle: 'bg-lime-400',
|
||||||
|
electric_truck: 'bg-teal-400',
|
||||||
|
drone: 'bg-cyan-400',
|
||||||
|
rail: 'bg-blue-400',
|
||||||
|
ship: 'bg-blue-500',
|
||||||
|
local_delivery: 'bg-yellow-400',
|
||||||
|
customer_pickup: 'bg-orange-400',
|
||||||
|
gasoline_vehicle: 'bg-orange-500',
|
||||||
|
diesel_truck: 'bg-red-400',
|
||||||
|
refrigerated_truck: 'bg-red-500',
|
||||||
|
air: 'bg-red-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CarbonFootprintCard({ impact, showComparison = true }: CarbonFootprintCardProps) {
|
||||||
|
// Calculate breakdown percentages
|
||||||
|
const totalCarbon = impact.totalCarbonKg;
|
||||||
|
const methodBreakdown = Object.entries(impact.breakdownByMethod)
|
||||||
|
.filter(([_, data]) => data.carbon > 0)
|
||||||
|
.sort((a, b) => b[1].carbon - a[1].carbon);
|
||||||
|
|
||||||
|
const getCarbonRating = (carbon: number): { label: string; color: string; icon: string } => {
|
||||||
|
if (carbon < 0.5) return { label: 'Excellent', color: 'text-green-600', icon: '🌟' };
|
||||||
|
if (carbon < 2) return { label: 'Good', color: 'text-lime-600', icon: '✓' };
|
||||||
|
if (carbon < 5) return { label: 'Moderate', color: 'text-yellow-600', icon: '⚡' };
|
||||||
|
if (carbon < 10) return { label: 'High', color: 'text-orange-600', icon: '⚠️' };
|
||||||
|
return { label: 'Very High', color: 'text-red-600', icon: '🚨' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const rating = getCarbonRating(totalCarbon);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-green-600 to-emerald-600 text-white p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold">Carbon Footprint</h3>
|
||||||
|
<p className="text-green-100 text-sm mt-1">Environmental impact analysis</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-3xl font-bold">{totalCarbon.toFixed(2)}</div>
|
||||||
|
<div className="text-green-100 text-sm">kg CO2e</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating badge */}
|
||||||
|
<div className="mt-4 inline-flex items-center px-3 py-1 bg-white bg-opacity-20 rounded-full">
|
||||||
|
<span className="mr-2">{rating.icon}</span>
|
||||||
|
<span className="font-semibold">{rating.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Key metrics */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 bg-blue-50 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600">Total Food Miles</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{impact.totalFoodMiles.toFixed(1)}</p>
|
||||||
|
<p className="text-xs text-gray-500">kilometers</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-green-50 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600">Carbon Intensity</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">
|
||||||
|
{impact.carbonPerKgProduce.toFixed(3)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">kg CO2 / kg produce</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comparison with conventional */}
|
||||||
|
{showComparison && impact.comparisonToConventional && (
|
||||||
|
<div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-3 flex items-center">
|
||||||
|
<span className="mr-2">📊</span>
|
||||||
|
vs. Conventional Agriculture
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-600">Carbon Saved</span>
|
||||||
|
<span className="font-semibold text-green-600">
|
||||||
|
{impact.comparisonToConventional.carbonSaved.toFixed(2)} kg
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-green-500 h-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(impact.comparisonToConventional.percentageReduction, 100)}%`,
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-600">Miles Saved</span>
|
||||||
|
<span className="font-semibold text-blue-600">
|
||||||
|
{impact.comparisonToConventional.milesSaved.toFixed(1)} km
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-green-200 text-center">
|
||||||
|
<span className="text-2xl font-bold text-green-600">
|
||||||
|
{impact.comparisonToConventional.percentageReduction.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-600 ml-2">reduction vs conventional</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Breakdown by transport method */}
|
||||||
|
{methodBreakdown.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-3">Breakdown by Transport Method</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{methodBreakdown.map(([method, data]) => {
|
||||||
|
const percentage = totalCarbon > 0 ? (data.carbon / totalCarbon) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={method} className="flex items-center">
|
||||||
|
<div className="w-32 text-sm text-gray-600 truncate">
|
||||||
|
{METHOD_LABELS[method as TransportMethod] || method}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 mx-3">
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-4">
|
||||||
|
<div
|
||||||
|
className={`${METHOD_COLORS[method] || 'bg-gray-400'} h-4 rounded-full transition-all`}
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-24 text-right text-sm">
|
||||||
|
<span className="font-medium text-gray-900">{data.carbon.toFixed(3)} kg</span>
|
||||||
|
<span className="text-gray-500 ml-1">({percentage.toFixed(0)}%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<h4 className="font-semibold text-yellow-800 mb-2">Tips to Reduce Impact</h4>
|
||||||
|
<ul className="text-sm text-yellow-700 space-y-1">
|
||||||
|
{totalCarbon > 5 && (
|
||||||
|
<li>• Consider electric or hybrid vehicles for transport</li>
|
||||||
|
)}
|
||||||
|
{impact.totalFoodMiles > 50 && (
|
||||||
|
<li>• Source produce from closer locations when possible</li>
|
||||||
|
)}
|
||||||
|
<li>• Consolidate shipments to reduce trips</li>
|
||||||
|
<li>• Use bicycle delivery for short distances</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
components/transport/JourneyMap.tsx
Normal file
231
components/transport/JourneyMap.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { TransportEvent, TransportLocation } from '../../lib/transport/types';
|
||||||
|
|
||||||
|
interface JourneyMapProps {
|
||||||
|
plantId: string;
|
||||||
|
events: TransportEvent[];
|
||||||
|
currentLocation?: TransportLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapPoint {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
label: string;
|
||||||
|
eventType: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JourneyMap({ plantId, events, currentLocation }: JourneyMapProps) {
|
||||||
|
const [selectedPoint, setSelectedPoint] = useState<MapPoint | null>(null);
|
||||||
|
|
||||||
|
// Extract unique locations from events
|
||||||
|
const mapPoints: MapPoint[] = [];
|
||||||
|
const seenCoords = new Set<string>();
|
||||||
|
|
||||||
|
events.forEach((event) => {
|
||||||
|
const fromKey = `${event.fromLocation.latitude},${event.fromLocation.longitude}`;
|
||||||
|
const toKey = `${event.toLocation.latitude},${event.toLocation.longitude}`;
|
||||||
|
|
||||||
|
if (!seenCoords.has(fromKey)) {
|
||||||
|
seenCoords.add(fromKey);
|
||||||
|
mapPoints.push({
|
||||||
|
lat: event.fromLocation.latitude,
|
||||||
|
lng: event.fromLocation.longitude,
|
||||||
|
label: event.fromLocation.facilityName || event.fromLocation.city || 'Origin',
|
||||||
|
eventType: event.eventType,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seenCoords.has(toKey)) {
|
||||||
|
seenCoords.add(toKey);
|
||||||
|
mapPoints.push({
|
||||||
|
lat: event.toLocation.latitude,
|
||||||
|
lng: event.toLocation.longitude,
|
||||||
|
label: event.toLocation.facilityName || event.toLocation.city || 'Destination',
|
||||||
|
eventType: event.eventType,
|
||||||
|
timestamp: event.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate map bounds
|
||||||
|
const lats = mapPoints.map((p) => p.lat);
|
||||||
|
const lngs = mapPoints.map((p) => p.lng);
|
||||||
|
const minLat = Math.min(...lats);
|
||||||
|
const maxLat = Math.max(...lats);
|
||||||
|
const minLng = Math.min(...lngs);
|
||||||
|
const maxLng = Math.max(...lngs);
|
||||||
|
|
||||||
|
// Calculate center and scale for SVG
|
||||||
|
const centerLat = (minLat + maxLat) / 2;
|
||||||
|
const centerLng = (minLng + maxLng) / 2;
|
||||||
|
const latRange = Math.max(maxLat - minLat, 0.01);
|
||||||
|
const lngRange = Math.max(maxLng - minLng, 0.01);
|
||||||
|
|
||||||
|
// Convert geo coords to SVG coords
|
||||||
|
const toSvgCoords = (lat: number, lng: number) => {
|
||||||
|
const x = ((lng - minLng) / lngRange) * 280 + 60;
|
||||||
|
const y = ((maxLat - lat) / latRange) * 180 + 60;
|
||||||
|
return { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate total distance
|
||||||
|
const totalDistance = events.reduce((sum, e) => sum + e.distanceKm, 0);
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-4">Journey Map</h3>
|
||||||
|
<div className="bg-gray-100 rounded-lg p-8 text-center">
|
||||||
|
<div className="text-4xl mb-2">🗺️</div>
|
||||||
|
<p className="text-gray-500">No journey data available yet.</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">Transport events will appear here as they're recorded.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">Journey Map</h3>
|
||||||
|
<span className="text-sm text-gray-500">{mapPoints.length} locations</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SVG Map */}
|
||||||
|
<div className="relative bg-gradient-to-br from-blue-50 to-green-50 rounded-lg overflow-hidden">
|
||||||
|
<svg viewBox="0 0 400 300" className="w-full h-64">
|
||||||
|
{/* Grid lines */}
|
||||||
|
<defs>
|
||||||
|
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#e5e7eb" strokeWidth="0.5" />
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||||
|
|
||||||
|
{/* Draw paths between consecutive events */}
|
||||||
|
{events.map((event, index) => {
|
||||||
|
const from = toSvgCoords(event.fromLocation.latitude, event.fromLocation.longitude);
|
||||||
|
const to = toSvgCoords(event.toLocation.latitude, event.toLocation.longitude);
|
||||||
|
return (
|
||||||
|
<g key={`path-${index}`}>
|
||||||
|
<line
|
||||||
|
x1={from.x}
|
||||||
|
y1={from.y}
|
||||||
|
x2={to.x}
|
||||||
|
y2={to.y}
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="5,5"
|
||||||
|
className="animate-pulse"
|
||||||
|
/>
|
||||||
|
{/* Arrow head */}
|
||||||
|
<circle cx={to.x} cy={to.y} r="4" fill="#10b981" />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Draw location points */}
|
||||||
|
{mapPoints.map((point, index) => {
|
||||||
|
const { x, y } = toSvgCoords(point.lat, point.lng);
|
||||||
|
const isSelected = selectedPoint?.lat === point.lat && selectedPoint?.lng === point.lng;
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={`point-${index}`}
|
||||||
|
onClick={() => setSelectedPoint(point)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
r={isSelected ? 12 : 8}
|
||||||
|
fill={index === 0 ? '#10b981' : index === mapPoints.length - 1 ? '#f59e0b' : '#3b82f6'}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
className="transition-all hover:r-12"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={y - 15}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="text-xs fill-gray-600 font-medium"
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Current location marker */}
|
||||||
|
{currentLocation && (
|
||||||
|
<g>
|
||||||
|
{(() => {
|
||||||
|
const { x, y } = toSvgCoords(currentLocation.latitude, currentLocation.longitude);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx={x} cy={y} r="15" fill="#ef4444" fillOpacity="0.3" className="animate-ping" />
|
||||||
|
<circle cx={x} cy={y} r="8" fill="#ef4444" stroke="white" strokeWidth="2" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="absolute bottom-2 left-2 bg-white bg-opacity-90 rounded-lg p-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-green-500"></span>
|
||||||
|
<span>Origin</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-blue-500"></span>
|
||||||
|
<span>Waypoint</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-yellow-500"></span>
|
||||||
|
<span>Current</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected point details */}
|
||||||
|
{selectedPoint && (
|
||||||
|
<div className="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-semibold text-gray-900">{selectedPoint.label}</h4>
|
||||||
|
<button onClick={() => setSelectedPoint(null)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Event: {selectedPoint.eventType.replace(/_/g, ' ')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{new Date(selectedPoint.timestamp).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Coords: {selectedPoint.lat.toFixed(4)}, {selectedPoint.lng.toFixed(4)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary stats */}
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div className="p-3 bg-blue-50 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-blue-600">{totalDistance.toFixed(1)} km</p>
|
||||||
|
<p className="text-xs text-gray-500">Total Distance</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-green-50 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-green-600">{mapPoints.length}</p>
|
||||||
|
<p className="text-xs text-gray-500">Locations</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-purple-50 rounded-lg">
|
||||||
|
<p className="text-lg font-bold text-purple-600">{events.length}</p>
|
||||||
|
<p className="text-xs text-gray-500">Transports</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
components/transport/QRCodeDisplay.tsx
Normal file
217
components/transport/QRCodeDisplay.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { TransportQRData } from '../../lib/transport/types';
|
||||||
|
|
||||||
|
interface QRCodeDisplayProps {
|
||||||
|
qrData: TransportQRData;
|
||||||
|
size?: number;
|
||||||
|
showDetails?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple QR code matrix generator (basic implementation)
|
||||||
|
function generateQRMatrix(data: string, size: number = 21): boolean[][] {
|
||||||
|
// This is a simplified representation - in production you'd use a library like 'qrcode'
|
||||||
|
const matrix: boolean[][] = Array(size)
|
||||||
|
.fill(null)
|
||||||
|
.map(() => Array(size).fill(false));
|
||||||
|
|
||||||
|
// Add finder patterns (corners)
|
||||||
|
const addFinderPattern = (row: number, col: number) => {
|
||||||
|
for (let r = 0; r < 7; r++) {
|
||||||
|
for (let c = 0; c < 7; c++) {
|
||||||
|
if (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4)) {
|
||||||
|
if (row + r < size && col + c < size) {
|
||||||
|
matrix[row + r][col + c] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addFinderPattern(0, 0);
|
||||||
|
addFinderPattern(0, size - 7);
|
||||||
|
addFinderPattern(size - 7, 0);
|
||||||
|
|
||||||
|
// Add some data-based pattern
|
||||||
|
const hash = data.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||||
|
for (let i = 8; i < size - 8; i++) {
|
||||||
|
for (let j = 8; j < size - 8; j++) {
|
||||||
|
matrix[i][j] = ((i * j + hash) % 3) === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QRCodeDisplay({ qrData, size = 200, showDetails = true }: QRCodeDisplayProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
|
const qrString = JSON.stringify(qrData);
|
||||||
|
const matrix = generateQRMatrix(qrString);
|
||||||
|
const cellSize = size / matrix.length;
|
||||||
|
|
||||||
|
const handleCopyLink = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(qrData.quickLookupUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
setDownloading(true);
|
||||||
|
|
||||||
|
// Create SVG for download
|
||||||
|
const svgContent = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||||
|
<rect width="100%" height="100%" fill="white"/>
|
||||||
|
${matrix
|
||||||
|
.map((row, i) =>
|
||||||
|
row
|
||||||
|
.map((cell, j) =>
|
||||||
|
cell
|
||||||
|
? `<rect x="${j * cellSize}" y="${i * cellSize}" width="${cellSize}" height="${cellSize}" fill="black"/>`
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const blob = new Blob([svgContent], { type: 'image/svg+xml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `qr-${qrData.plantId || qrData.batchId || 'code'}.svg`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
setDownloading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white p-4">
|
||||||
|
<h3 className="text-lg font-bold">Traceability QR Code</h3>
|
||||||
|
<p className="text-purple-200 text-sm">Scan to verify authenticity</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="p-4 bg-white border-2 border-gray-200 rounded-lg shadow-inner">
|
||||||
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||||
|
<rect width="100%" height="100%" fill="white" />
|
||||||
|
{matrix.map((row, i) =>
|
||||||
|
row.map((cell, j) =>
|
||||||
|
cell ? (
|
||||||
|
<rect
|
||||||
|
key={`${i}-${j}`}
|
||||||
|
x={j * cellSize}
|
||||||
|
y={i * cellSize}
|
||||||
|
width={cellSize}
|
||||||
|
height={cellSize}
|
||||||
|
fill="black"
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex gap-3 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<span className="text-green-600">✓</span> Copied!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>📋</span> Copy Link
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={downloading}
|
||||||
|
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium transition flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{downloading ? (
|
||||||
|
<>Downloading...</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>⬇️</span> Download
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
{showDetails && (
|
||||||
|
<div className="space-y-3 border-t border-gray-200 pt-4">
|
||||||
|
{qrData.plantId && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-500">Plant ID</span>
|
||||||
|
<span className="text-sm font-mono text-gray-900">{qrData.plantId.slice(0, 12)}...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{qrData.batchId && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-500">Batch ID</span>
|
||||||
|
<span className="text-sm font-mono text-gray-900">{qrData.batchId.slice(0, 12)}...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-500">Last Event</span>
|
||||||
|
<span className="text-sm text-gray-900 capitalize">
|
||||||
|
{qrData.lastEventType.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-500">Last Updated</span>
|
||||||
|
<span className="text-sm text-gray-900">{formatDate(qrData.lastEventTimestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-500">Current Custodian</span>
|
||||||
|
<span className="text-sm text-gray-900">{qrData.currentCustodian}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-500">Verification Code</span>
|
||||||
|
<span className="text-sm font-mono text-green-600">{qrData.verificationCode}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Blockchain info */}
|
||||||
|
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<span>🔗</span>
|
||||||
|
<span className="font-medium">Blockchain Verified</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-1 font-mono break-all">
|
||||||
|
{qrData.blockchainAddress}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
324
components/transport/TransportEventForm.tsx
Normal file
324
components/transport/TransportEventForm.tsx
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { TransportEventType, TransportMethod, TransportLocation } from '../../lib/transport/types';
|
||||||
|
|
||||||
|
interface TransportEventFormProps {
|
||||||
|
onSubmit: (data: TransportEventFormData) => void;
|
||||||
|
plantId?: string;
|
||||||
|
batchId?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
defaultEventType?: TransportEventType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransportEventFormData {
|
||||||
|
eventType: TransportEventType;
|
||||||
|
fromLocation: Partial<TransportLocation>;
|
||||||
|
toLocation: Partial<TransportLocation>;
|
||||||
|
transportMethod: TransportMethod;
|
||||||
|
notes?: string;
|
||||||
|
plantIds?: string[];
|
||||||
|
seedBatchId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_TYPES: { value: TransportEventType; label: string; icon: string }[] = [
|
||||||
|
{ value: 'seed_acquisition', label: 'Seed Acquisition', icon: '🌱' },
|
||||||
|
{ value: 'planting', label: 'Planting', icon: '🌿' },
|
||||||
|
{ value: 'growing_transport', label: 'Growing Transport', icon: '🚚' },
|
||||||
|
{ value: 'harvest', label: 'Harvest', icon: '🥬' },
|
||||||
|
{ value: 'processing', label: 'Processing', icon: '⚙️' },
|
||||||
|
{ value: 'distribution', label: 'Distribution', icon: '📦' },
|
||||||
|
{ value: 'consumer_delivery', label: 'Consumer Delivery', icon: '🏠' },
|
||||||
|
{ value: 'seed_saving', label: 'Seed Saving', icon: '💾' },
|
||||||
|
{ value: 'seed_sharing', label: 'Seed Sharing', icon: '🤝' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TRANSPORT_METHODS: { value: TransportMethod; label: string; carbonInfo: string }[] = [
|
||||||
|
{ value: 'walking', label: 'Walking', carbonInfo: '0 kg CO2/km' },
|
||||||
|
{ value: 'bicycle', label: 'Bicycle', carbonInfo: '0 kg CO2/km' },
|
||||||
|
{ value: 'electric_vehicle', label: 'Electric Vehicle', carbonInfo: '0.02 kg CO2/km' },
|
||||||
|
{ value: 'hybrid_vehicle', label: 'Hybrid Vehicle', carbonInfo: '0.08 kg CO2/km' },
|
||||||
|
{ value: 'gasoline_vehicle', label: 'Gasoline Vehicle', carbonInfo: '0.12 kg CO2/km' },
|
||||||
|
{ value: 'diesel_truck', label: 'Diesel Truck', carbonInfo: '0.15 kg CO2/km' },
|
||||||
|
{ value: 'electric_truck', label: 'Electric Truck', carbonInfo: '0.03 kg CO2/km' },
|
||||||
|
{ value: 'refrigerated_truck', label: 'Refrigerated Truck', carbonInfo: '0.25 kg CO2/km' },
|
||||||
|
{ value: 'rail', label: 'Rail', carbonInfo: '0.01 kg CO2/km' },
|
||||||
|
{ value: 'ship', label: 'Ship', carbonInfo: '0.008 kg CO2/km' },
|
||||||
|
{ value: 'air', label: 'Air Freight', carbonInfo: '0.5 kg CO2/km' },
|
||||||
|
{ value: 'drone', label: 'Drone', carbonInfo: '0.01 kg CO2/km' },
|
||||||
|
{ value: 'local_delivery', label: 'Local Delivery', carbonInfo: '0.05 kg CO2/km' },
|
||||||
|
{ value: 'customer_pickup', label: 'Customer Pickup', carbonInfo: '0.1 kg CO2/km' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LOCATION_TYPES = [
|
||||||
|
'farm',
|
||||||
|
'greenhouse',
|
||||||
|
'vertical_farm',
|
||||||
|
'warehouse',
|
||||||
|
'hub',
|
||||||
|
'market',
|
||||||
|
'consumer',
|
||||||
|
'seed_bank',
|
||||||
|
'other',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function TransportEventForm({
|
||||||
|
onSubmit,
|
||||||
|
plantId,
|
||||||
|
batchId,
|
||||||
|
loading = false,
|
||||||
|
defaultEventType = 'harvest',
|
||||||
|
}: TransportEventFormProps) {
|
||||||
|
const [eventType, setEventType] = useState<TransportEventType>(defaultEventType);
|
||||||
|
const [transportMethod, setTransportMethod] = useState<TransportMethod>('electric_vehicle');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [useCurrentLocation, setUseCurrentLocation] = useState(false);
|
||||||
|
const [gettingLocation, setGettingLocation] = useState(false);
|
||||||
|
|
||||||
|
const [fromLocation, setFromLocation] = useState<Partial<TransportLocation>>({
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
|
locationType: 'farm',
|
||||||
|
city: '',
|
||||||
|
region: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [toLocation, setToLocation] = useState<Partial<TransportLocation>>({
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
|
locationType: 'market',
|
||||||
|
city: '',
|
||||||
|
region: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const getCurrentLocation = async (target: 'from' | 'to') => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
alert('Geolocation is not supported by your browser');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGettingLocation(true);
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
const update = {
|
||||||
|
latitude: position.coords.latitude,
|
||||||
|
longitude: position.coords.longitude,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (target === 'from') {
|
||||||
|
setFromLocation((prev) => ({ ...prev, ...update }));
|
||||||
|
} else {
|
||||||
|
setToLocation((prev) => ({ ...prev, ...update }));
|
||||||
|
}
|
||||||
|
setGettingLocation(false);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('Error getting location:', error);
|
||||||
|
alert('Unable to get your location. Please enter coordinates manually.');
|
||||||
|
setGettingLocation(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
eventType,
|
||||||
|
fromLocation,
|
||||||
|
toLocation,
|
||||||
|
transportMethod,
|
||||||
|
notes: notes || undefined,
|
||||||
|
plantIds: plantId ? [plantId] : undefined,
|
||||||
|
seedBatchId: batchId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const LocationInput = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onGetCurrent,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: Partial<TransportLocation>;
|
||||||
|
onChange: (loc: Partial<TransportLocation>) => void;
|
||||||
|
onGetCurrent: () => void;
|
||||||
|
}) => (
|
||||||
|
<div className="space-y-3 p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-medium text-gray-900">{label}</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onGetCurrent}
|
||||||
|
disabled={gettingLocation}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 disabled:text-gray-400"
|
||||||
|
>
|
||||||
|
{gettingLocation ? 'Getting...' : '📍 Use Current'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">Latitude</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={value.latitude || ''}
|
||||||
|
onChange={(e) => onChange({ ...value, latitude: parseFloat(e.target.value) || 0 })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500"
|
||||||
|
placeholder="0.0000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">Longitude</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
value={value.longitude || ''}
|
||||||
|
onChange={(e) => onChange({ ...value, longitude: parseFloat(e.target.value) || 0 })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500"
|
||||||
|
placeholder="0.0000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">City</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.city || ''}
|
||||||
|
onChange={(e) => onChange({ ...value, city: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500"
|
||||||
|
placeholder="City name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">Region</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value.region || ''}
|
||||||
|
onChange={(e) => onChange({ ...value, region: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500"
|
||||||
|
placeholder="State/Province"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">Location Type</label>
|
||||||
|
<select
|
||||||
|
value={value.locationType || 'other'}
|
||||||
|
onChange={(e) => onChange({ ...value, locationType: e.target.value as any })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
{LOCATION_TYPES.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-lg p-6 space-y-6">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">Record Transport Event</h3>
|
||||||
|
|
||||||
|
{/* Event Type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Event Type</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{EVENT_TYPES.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEventType(type.value)}
|
||||||
|
className={`p-3 rounded-lg border-2 text-sm font-medium transition ${
|
||||||
|
eventType === type.value
|
||||||
|
? 'border-green-500 bg-green-50 text-green-700'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-lg mr-1">{type.icon}</span>
|
||||||
|
<span className="hidden sm:inline">{type.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Locations */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<LocationInput
|
||||||
|
label="From Location"
|
||||||
|
value={fromLocation}
|
||||||
|
onChange={setFromLocation}
|
||||||
|
onGetCurrent={() => getCurrentLocation('from')}
|
||||||
|
/>
|
||||||
|
<LocationInput
|
||||||
|
label="To Location"
|
||||||
|
value={toLocation}
|
||||||
|
onChange={setToLocation}
|
||||||
|
onGetCurrent={() => getCurrentLocation('to')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transport Method */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Transport Method</label>
|
||||||
|
<select
|
||||||
|
value={transportMethod}
|
||||||
|
onChange={(e) => setTransportMethod(e.target.value as TransportMethod)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
{TRANSPORT_METHODS.map((method) => (
|
||||||
|
<option key={method.value} value={method.value}>
|
||||||
|
{method.label} - {method.carbonInfo}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Carbon emissions are calculated based on distance and transport method
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Notes (optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
||||||
|
placeholder="Any additional details about this transport event..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IDs info */}
|
||||||
|
{(plantId || batchId) && (
|
||||||
|
<div className="p-3 bg-blue-50 rounded-lg text-sm">
|
||||||
|
{plantId && (
|
||||||
|
<p className="text-blue-700">
|
||||||
|
<strong>Plant ID:</strong> {plantId}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{batchId && (
|
||||||
|
<p className="text-blue-700">
|
||||||
|
<strong>Batch ID:</strong> {batchId}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 px-4 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Recording...' : 'Record Transport Event'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
components/transport/TransportTimeline.tsx
Normal file
181
components/transport/TransportTimeline.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { TransportEvent, TransportEventType } from '../../lib/transport/types';
|
||||||
|
|
||||||
|
interface TransportTimelineProps {
|
||||||
|
plantId: string;
|
||||||
|
events: TransportEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_ICONS: Record<TransportEventType, string> = {
|
||||||
|
seed_acquisition: '🌱',
|
||||||
|
planting: '🌿',
|
||||||
|
growing_transport: '🚚',
|
||||||
|
harvest: '🥬',
|
||||||
|
processing: '⚙️',
|
||||||
|
distribution: '📦',
|
||||||
|
consumer_delivery: '🏠',
|
||||||
|
seed_saving: '💾',
|
||||||
|
seed_sharing: '🤝',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_COLORS: Record<TransportEventType, string> = {
|
||||||
|
seed_acquisition: 'bg-green-500',
|
||||||
|
planting: 'bg-emerald-500',
|
||||||
|
growing_transport: 'bg-blue-500',
|
||||||
|
harvest: 'bg-yellow-500',
|
||||||
|
processing: 'bg-orange-500',
|
||||||
|
distribution: 'bg-purple-500',
|
||||||
|
consumer_delivery: 'bg-pink-500',
|
||||||
|
seed_saving: 'bg-teal-500',
|
||||||
|
seed_sharing: 'bg-indigo-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatEventType(type: TransportEventType): string {
|
||||||
|
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocation(location: { city?: string; region?: string; address?: string }): string {
|
||||||
|
if (location.city && location.region) {
|
||||||
|
return `${location.city}, ${location.region}`;
|
||||||
|
}
|
||||||
|
return location.city || location.region || location.address || 'Unknown location';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TransportTimeline({ plantId, events }: TransportTimelineProps) {
|
||||||
|
if (events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-4">Transport Timeline</h3>
|
||||||
|
<p className="text-gray-500 text-center py-8">No transport events recorded for this plant yet.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedEvents = [...events].sort(
|
||||||
|
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">Transport Timeline</h3>
|
||||||
|
<span className="text-sm text-gray-500">Plant ID: {plantId.slice(0, 8)}...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{/* Vertical line */}
|
||||||
|
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-gray-200"></div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{sortedEvents.map((event, index) => (
|
||||||
|
<div key={event.id} className="relative flex items-start">
|
||||||
|
{/* Event icon */}
|
||||||
|
<div
|
||||||
|
className={`relative z-10 flex items-center justify-center w-12 h-12 rounded-full ${EVENT_COLORS[event.eventType]} text-white text-xl shadow-lg`}
|
||||||
|
>
|
||||||
|
{EVENT_ICONS[event.eventType]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event content */}
|
||||||
|
<div className="ml-4 flex-1 min-w-0">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h4 className="font-semibold text-gray-900">
|
||||||
|
{formatEventType(event.eventType)}
|
||||||
|
</h4>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||||
|
event.status === 'verified'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: event.status === 'delivered'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: event.status === 'in_transit'
|
||||||
|
? 'bg-yellow-100 text-yellow-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{event.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 mb-3">{formatDate(event.timestamp)}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">From:</span>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{formatLocation(event.fromLocation)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">To:</span>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{formatLocation(event.toLocation)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-200 grid grid-cols-3 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Distance</span>
|
||||||
|
<p className="font-medium text-gray-900">{event.distanceKm.toFixed(1)} km</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Duration</span>
|
||||||
|
<p className="font-medium text-gray-900">{event.durationMinutes} min</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Carbon</span>
|
||||||
|
<p className="font-medium text-gray-900">
|
||||||
|
{event.carbonFootprintKg.toFixed(3)} kg
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
|
Transport: {event.transportMethod.replace(/_/g, ' ')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{event.notes && (
|
||||||
|
<p className="mt-2 text-sm text-gray-600 italic">"{event.notes}"</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{events.length}</p>
|
||||||
|
<p className="text-sm text-gray-500">Total Events</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">
|
||||||
|
{events.reduce((sum, e) => sum + e.distanceKm, 0).toFixed(1)} km
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">Total Distance</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-green-600">
|
||||||
|
{events.reduce((sum, e) => sum + e.carbonFootprintKg, 0).toFixed(2)} kg
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">Total Carbon</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
components/transport/index.ts
Normal file
6
components/transport/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export { default as TransportTimeline } from './TransportTimeline';
|
||||||
|
export { default as JourneyMap } from './JourneyMap';
|
||||||
|
export { default as CarbonFootprintCard } from './CarbonFootprintCard';
|
||||||
|
export { default as QRCodeDisplay } from './QRCodeDisplay';
|
||||||
|
export { default as TransportEventForm } from './TransportEventForm';
|
||||||
|
export type { TransportEventFormData } from './TransportEventForm';
|
||||||
Loading…
Reference in a new issue