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
229 lines
7.3 KiB
TypeScript
229 lines
7.3 KiB
TypeScript
/**
|
|
* Data Table Component
|
|
* Sortable and filterable data table for analytics
|
|
*/
|
|
|
|
import { useState, useMemo } from 'react';
|
|
|
|
interface Column {
|
|
key: string;
|
|
header: string;
|
|
sortable?: boolean;
|
|
render?: (value: any, row: any) => React.ReactNode;
|
|
width?: string;
|
|
align?: 'left' | 'center' | 'right';
|
|
}
|
|
|
|
interface DataTableProps {
|
|
data: any[];
|
|
columns: Column[];
|
|
title?: string;
|
|
pageSize?: number;
|
|
showSearch?: boolean;
|
|
searchPlaceholder?: string;
|
|
}
|
|
|
|
type SortDirection = 'asc' | 'desc' | null;
|
|
|
|
export default function DataTable({
|
|
data,
|
|
columns,
|
|
title,
|
|
pageSize = 10,
|
|
showSearch = true,
|
|
searchPlaceholder = 'Search...',
|
|
}: DataTableProps) {
|
|
const [sortKey, setSortKey] = useState<string | null>(null);
|
|
const [sortDir, setSortDir] = useState<SortDirection>(null);
|
|
const [search, setSearch] = useState('');
|
|
const [page, setPage] = useState(0);
|
|
|
|
const filteredData = useMemo(() => {
|
|
if (!search) return data;
|
|
|
|
const searchLower = search.toLowerCase();
|
|
return data.filter((row) =>
|
|
columns.some((col) => {
|
|
const value = row[col.key];
|
|
return String(value).toLowerCase().includes(searchLower);
|
|
})
|
|
);
|
|
}, [data, columns, search]);
|
|
|
|
const sortedData = useMemo(() => {
|
|
if (!sortKey || !sortDir) return filteredData;
|
|
|
|
return [...filteredData].sort((a, b) => {
|
|
const aVal = a[sortKey];
|
|
const bVal = b[sortKey];
|
|
|
|
if (aVal === bVal) return 0;
|
|
if (aVal === null || aVal === undefined) return 1;
|
|
if (bVal === null || bVal === undefined) return -1;
|
|
|
|
const comparison = aVal < bVal ? -1 : 1;
|
|
return sortDir === 'asc' ? comparison : -comparison;
|
|
});
|
|
}, [filteredData, sortKey, sortDir]);
|
|
|
|
const paginatedData = useMemo(() => {
|
|
const start = page * pageSize;
|
|
return sortedData.slice(start, start + pageSize);
|
|
}, [sortedData, page, pageSize]);
|
|
|
|
const totalPages = Math.ceil(sortedData.length / pageSize);
|
|
|
|
const handleSort = (key: string) => {
|
|
if (sortKey === key) {
|
|
if (sortDir === 'asc') setSortDir('desc');
|
|
else if (sortDir === 'desc') {
|
|
setSortKey(null);
|
|
setSortDir(null);
|
|
}
|
|
} else {
|
|
setSortKey(key);
|
|
setSortDir('asc');
|
|
}
|
|
};
|
|
|
|
const getSortIcon = (key: string) => {
|
|
if (sortKey !== key) {
|
|
return (
|
|
<svg className="w-4 h-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
|
</svg>
|
|
);
|
|
}
|
|
if (sortDir === 'asc') {
|
|
return (
|
|
<svg className="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
|
</svg>
|
|
);
|
|
}
|
|
return (
|
|
<svg className="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
const alignClasses = {
|
|
left: 'text-left',
|
|
center: 'text-center',
|
|
right: 'text-right',
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
|
{/* Header */}
|
|
<div className="px-6 py-4 border-b border-gray-200">
|
|
<div className="flex items-center justify-between">
|
|
{title && <h3 className="text-lg font-bold text-gray-900">{title}</h3>}
|
|
{showSearch && (
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
placeholder={searchPlaceholder}
|
|
value={search}
|
|
onChange={(e) => {
|
|
setSearch(e.target.value);
|
|
setPage(0);
|
|
}}
|
|
className="pl-10 pr-4 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
|
/>
|
|
<svg
|
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
{columns.map((col) => (
|
|
<th
|
|
key={col.key}
|
|
className={`px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider ${
|
|
alignClasses[col.align || 'left']
|
|
} ${col.sortable !== false ? 'cursor-pointer hover:bg-gray-100' : ''}`}
|
|
style={{ width: col.width }}
|
|
onClick={() => col.sortable !== false && handleSort(col.key)}
|
|
>
|
|
<div className="flex items-center space-x-1">
|
|
<span>{col.header}</span>
|
|
{col.sortable !== false && getSortIcon(col.key)}
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200">
|
|
{paginatedData.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={columns.length} className="px-6 py-8 text-center text-gray-500">
|
|
No data available
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
paginatedData.map((row, rowIndex) => (
|
|
<tr key={rowIndex} className="hover:bg-gray-50">
|
|
{columns.map((col) => (
|
|
<td
|
|
key={col.key}
|
|
className={`px-6 py-4 whitespace-nowrap text-sm text-gray-900 ${
|
|
alignClasses[col.align || 'left']
|
|
}`}
|
|
>
|
|
{col.render ? col.render(row[col.key], row) : row[col.key]}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
|
<span className="text-sm text-gray-500">
|
|
Showing {page * pageSize + 1} to {Math.min((page + 1) * pageSize, sortedData.length)} of{' '}
|
|
{sortedData.length} results
|
|
</span>
|
|
<div className="flex space-x-2">
|
|
<button
|
|
onClick={() => setPage(page - 1)}
|
|
disabled={page === 0}
|
|
className="px-3 py-1 border border-gray-200 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
|
>
|
|
Previous
|
|
</button>
|
|
<button
|
|
onClick={() => setPage(page + 1)}
|
|
disabled={page >= totalPages - 1}
|
|
className="px-3 py-1 border border-gray-200 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|