localgreenchain/components/analytics/DataTable.tsx
Claude 816c3b3f2e
Implement Agent 7: Advanced Analytics Dashboard
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
2025-11-23 04:02:07 +00:00

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