localgreenchain/components/demand/SupplyGapChart.tsx
Claude 0cce5e2345
Add UI components for transport tracking, demand visualization, and analytics
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.
2025-11-22 18:34:51 +00:00

187 lines
7 KiB
TypeScript

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>
);
}