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.
206 lines
7.6 KiB
TypeScript
206 lines
7.6 KiB
TypeScript
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>
|
|
);
|
|
}
|