Add comprehensive analytics system with: - Analytics data layer (aggregator, metrics, trends, cache) - 6 API endpoints (overview, plants, transport, farms, sustainability, export) - 6 chart components (LineChart, BarChart, PieChart, AreaChart, Gauge, Heatmap) - 5 dashboard widgets (KPICard, TrendIndicator, DataTable, DateRangePicker, FilterPanel) - 5 dashboard pages (overview, plants, transport, farms, sustainability) - Export functionality (CSV, JSON) Dependencies added: recharts, d3, date-fns Also includes minor fixes: - Fix EnvironmentalForm spread type error - Fix AgentOrchestrator Map iteration issues - Fix next.config.js image domains undefined error - Add downlevelIteration to tsconfig
165 lines
5.6 KiB
TypeScript
165 lines
5.6 KiB
TypeScript
/**
|
|
* Filter Panel Component
|
|
* Provides filtering options for analytics data
|
|
*/
|
|
|
|
import { useState } from 'react';
|
|
|
|
interface FilterOption {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
|
|
interface FilterConfig {
|
|
key: string;
|
|
label: string;
|
|
type: 'select' | 'multiselect' | 'search';
|
|
options?: FilterOption[];
|
|
}
|
|
|
|
interface FilterPanelProps {
|
|
filters: FilterConfig[];
|
|
values: Record<string, any>;
|
|
onChange: (values: Record<string, any>) => void;
|
|
onReset?: () => void;
|
|
}
|
|
|
|
export default function FilterPanel({
|
|
filters,
|
|
values,
|
|
onChange,
|
|
onReset,
|
|
}: FilterPanelProps) {
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
const handleChange = (key: string, value: any) => {
|
|
onChange({ ...values, [key]: value });
|
|
};
|
|
|
|
const handleMultiSelect = (key: string, value: string) => {
|
|
const current = values[key] || [];
|
|
const updated = current.includes(value)
|
|
? current.filter((v: string) => v !== value)
|
|
: [...current, value];
|
|
handleChange(key, updated);
|
|
};
|
|
|
|
const activeFilterCount = Object.values(values).filter(
|
|
(v) => v && (Array.isArray(v) ? v.length > 0 : true)
|
|
).length;
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow border border-gray-200">
|
|
{/* Header */}
|
|
<button
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50"
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
<svg
|
|
className="w-5 h-5 text-gray-500"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
|
/>
|
|
</svg>
|
|
<span className="font-medium text-gray-700">Filters</span>
|
|
{activeFilterCount > 0 && (
|
|
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">
|
|
{activeFilterCount} active
|
|
</span>
|
|
)}
|
|
</div>
|
|
<svg
|
|
className={`w-5 h-5 text-gray-400 transform transition-transform ${
|
|
isExpanded ? 'rotate-180' : ''
|
|
}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Filter content */}
|
|
{isExpanded && (
|
|
<div className="px-4 py-4 border-t border-gray-200 space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{filters.map((filter) => (
|
|
<div key={filter.key}>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
{filter.label}
|
|
</label>
|
|
{filter.type === 'select' && filter.options && (
|
|
<select
|
|
value={values[filter.key] || ''}
|
|
onChange={(e) => handleChange(filter.key, e.target.value || null)}
|
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
>
|
|
<option value="">All</option>
|
|
{filter.options.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
{filter.type === 'multiselect' && filter.options && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{filter.options.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => handleMultiSelect(filter.key, opt.value)}
|
|
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
|
(values[filter.key] || []).includes(opt.value)
|
|
? 'bg-green-500 text-white border-green-500'
|
|
: 'bg-white text-gray-600 border-gray-200 hover:border-green-300'
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{filter.type === 'search' && (
|
|
<input
|
|
type="text"
|
|
value={values[filter.key] || ''}
|
|
onChange={(e) => handleChange(filter.key, e.target.value || null)}
|
|
placeholder={`Search ${filter.label.toLowerCase()}...`}
|
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex justify-end space-x-2 pt-2 border-t border-gray-100">
|
|
{onReset && (
|
|
<button
|
|
onClick={onReset}
|
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
|
|
>
|
|
Reset filters
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setIsExpanded(false)}
|
|
className="px-4 py-2 text-sm bg-green-500 text-white rounded-lg hover:bg-green-600"
|
|
>
|
|
Apply
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|