Transport components: - TransportTimeline: Visual timeline of transport events with status badges - JourneyMap: SVG-based map visualization of plant journey locations - CarbonFootprintCard: Carbon metrics display with comparison charts - QRCodeDisplay: QR code generation for traceability verification - TransportEventForm: Form for recording transport events Demand components: - DemandSignalCard: Regional demand signal with supply status indicators - PreferencesForm: Multi-section consumer preference input form - RecommendationList: Planting recommendations with risk assessment - SupplyGapChart: Supply vs demand visualization with gap indicators - SeasonalCalendar: Seasonal produce availability calendar view Analytics components: - EnvironmentalImpact: Comprehensive carbon and food miles analysis - FoodMilesTracker: Food miles tracking with daily charts and targets - SavingsCalculator: Environmental savings vs conventional agriculture All components follow existing patterns, use Tailwind CSS, and are fully typed.
163 lines
6.5 KiB
TypeScript
163 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
}
|