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.
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|