265 lines
11 KiB
TypeScript
265 lines
11 KiB
TypeScript
import { Card } from './Card';
|
|
import {
|
|
BarChart,
|
|
Bar,
|
|
LineChart,
|
|
Line,
|
|
AreaChart,
|
|
Area,
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
Legend
|
|
} from 'recharts';
|
|
|
|
export interface ChartRow {
|
|
dimension: string;
|
|
value: number;
|
|
series?: string;
|
|
}
|
|
|
|
export interface ChartConfig {
|
|
chart_uid: string;
|
|
key: string;
|
|
rows: ChartRow[];
|
|
}
|
|
|
|
export interface AnalyticsChartProps {
|
|
data?: unknown[];
|
|
gridCols?: number;
|
|
}
|
|
|
|
export function AnalyticsChart({ data, gridCols }: AnalyticsChartProps) {
|
|
if (!data || !Array.isArray(data) || data.length === 0) return null;
|
|
|
|
const charts = data as ChartConfig[];
|
|
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#14B8A6'];
|
|
|
|
const gridClass = gridCols === 1
|
|
? "grid grid-cols-1 gap-6 w-full"
|
|
: gridCols === 2
|
|
? "grid grid-cols-1 lg:grid-cols-2 gap-6 w-full"
|
|
: "grid grid-cols-1 lg:grid-cols-3 gap-6 w-full";
|
|
|
|
return (
|
|
<div className={gridClass}>
|
|
{charts.map((chart, idx) => {
|
|
const title = (chart.key || `Chart ${idx + 1}`)
|
|
.replace(/_/g, ' ')
|
|
.replace(/\b\w/g, c => c.toUpperCase());
|
|
|
|
const hasSeries = chart.rows?.some(r => r.series);
|
|
let finalData: any[] = [];
|
|
let seriesKeys: string[] = [];
|
|
|
|
if (hasSeries) {
|
|
const grouped = new Map<string, any>();
|
|
const sKeys = new Set<string>();
|
|
chart.rows?.forEach(r => {
|
|
const dim = String(r.dimension || '').trim() || 'Unknown';
|
|
const s = String(r.series || '').trim() || 'Unknown';
|
|
sKeys.add(s);
|
|
|
|
if (!grouped.has(dim)) {
|
|
grouped.set(dim, { dimension: dim });
|
|
}
|
|
const entry = grouped.get(dim);
|
|
entry[s] = Number(r.value || 0);
|
|
});
|
|
finalData = Array.from(grouped.values());
|
|
seriesKeys = Array.from(sKeys).sort(); // Sort series keys for consistency
|
|
} else {
|
|
finalData = (chart.rows || []).map(r => ({
|
|
...r,
|
|
dimension: String(r.dimension || '').trim() || 'Unknown',
|
|
value: Number(r.value || 0)
|
|
}));
|
|
seriesKeys = ['value'];
|
|
}
|
|
|
|
if (finalData.length === 0) return null;
|
|
|
|
const lowerKey = String(chart.key || '').toLowerCase();
|
|
let chartType = idx % 3; // 0 = Bar, 1 = Line, 2 = Area
|
|
|
|
if (lowerKey.includes('status')) {
|
|
chartType = 3; // Pie
|
|
} else if (lowerKey.includes('route')) {
|
|
chartType = 4; // Donut
|
|
} else if (lowerKey.includes('brand') || lowerKey.includes('product')) {
|
|
chartType = 5; // Table
|
|
} else if (lowerKey.includes('monthly') || lowerKey.includes('yearly')) {
|
|
chartType = 0; // Bar
|
|
}
|
|
|
|
// Determine if it should span full width
|
|
const isFullWidth = (gridCols === 1) || (!gridCols && charts.length <= 2) || hasSeries || lowerKey.includes('monthly') || lowerKey.includes('yearly') || finalData.length > 8;
|
|
|
|
const fullSpanClass = gridCols === 2 ? 'lg:col-span-2' : 'lg:col-span-3';
|
|
|
|
// Calculate a dynamic width if there's a lot of data to allow horizontal scrolling
|
|
const minChartWidth = chartType === 0 || chartType === 1 || chartType === 2
|
|
? Math.max(100, finalData.length * (hasSeries ? seriesKeys.length * 15 + 40 : 60))
|
|
: 100;
|
|
|
|
const renderChartContent = () => {
|
|
return (
|
|
<>
|
|
<defs>
|
|
<linearGradient id={`barGradient-${idx}-0`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#1F4D36" stopOpacity={1} />
|
|
<stop offset="100%" stopColor="#1F4D36" stopOpacity={0.6} />
|
|
</linearGradient>
|
|
<linearGradient id={`barGradient-${idx}-1`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#3B82F6" stopOpacity={1} />
|
|
<stop offset="100%" stopColor="#3B82F6" stopOpacity={0.6} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E2E8F0" />
|
|
<XAxis
|
|
dataKey="dimension"
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tick={{ fontSize: 12, fill: '#64748B' }}
|
|
dy={10}
|
|
/>
|
|
<YAxis
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tick={{ fontSize: 12, fill: '#64748B' }}
|
|
allowDecimals={false}
|
|
/>
|
|
<Tooltip
|
|
cursor={{ fill: '#F8FAFC', stroke: '#E2E8F0', strokeWidth: 1, strokeDasharray: '3 3' }}
|
|
contentStyle={{ borderRadius: '8px', border: '1px solid #E2E8F0', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', fontSize: '14px', fontFamily: 'inherit' }}
|
|
labelStyle={{ fontWeight: 'bold', color: '#0F172A', marginBottom: '4px' }}
|
|
/>
|
|
{hasSeries && <Legend wrapperStyle={{ paddingTop: '20px' }} />}
|
|
{seriesKeys.map((key, i) => {
|
|
const color = colors[i % colors.length];
|
|
if (chartType === 1) {
|
|
return (
|
|
<Line
|
|
key={key}
|
|
type="monotone"
|
|
dataKey={key}
|
|
name={hasSeries ? key : "Value"}
|
|
stroke={color}
|
|
strokeWidth={3}
|
|
dot={{ r: 4, strokeWidth: 2 }}
|
|
activeDot={{ r: 6, strokeWidth: 0 }}
|
|
/>
|
|
);
|
|
} else if (chartType === 2) {
|
|
return (
|
|
<Area
|
|
key={key}
|
|
type="monotone"
|
|
dataKey={key}
|
|
name={hasSeries ? key : "Value"}
|
|
stroke={color}
|
|
fill={color}
|
|
fillOpacity={0.2}
|
|
strokeWidth={2}
|
|
/>
|
|
);
|
|
} else {
|
|
const barFill = i === 0 ? `url(#barGradient-${idx}-0)` : (i === 1 ? `url(#barGradient-${idx}-1)` : color);
|
|
return (
|
|
<Bar
|
|
key={key}
|
|
dataKey={key}
|
|
name={hasSeries ? key : "Value"}
|
|
fill={barFill}
|
|
radius={[4, 4, 0, 0]}
|
|
maxBarSize={40}
|
|
/>
|
|
);
|
|
}
|
|
})}
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Card key={chart.chart_uid || idx} title={title} className={`shadow-sm border border-gray-100 !bg-[var(--tiles-card-bg)] ${isFullWidth ? fullSpanClass : ''}`}>
|
|
<div className={`h-[320px] w-full mt-4 overflow-y-hidden ${minChartWidth > 100 ? 'overflow-x-auto scrollbar-slim' : ''}`}>
|
|
{chartType === 5 ? (
|
|
<div className="w-full h-full overflow-y-auto pr-2 scrollbar-slim">
|
|
<table className="w-full text-left text-sm text-gray-600 border-collapse">
|
|
<thead className="bg-gray-50/80 text-gray-700 sticky top-0 z-10 shadow-sm">
|
|
<tr>
|
|
<th className="px-4 py-3 font-semibold border-b border-gray-200">Dimension</th>
|
|
{seriesKeys.map(k => (
|
|
<th key={k} className="px-4 py-3 font-semibold border-b border-gray-200 text-right">{hasSeries ? k : 'Value'}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{finalData.filter(row => seriesKeys.some(k => row[k] > 0)).map((row, rIdx) => (
|
|
<tr key={rIdx} className="hover:bg-gray-50 transition-colors">
|
|
<td className="px-4 py-3 font-medium text-gray-900">{row.dimension}</td>
|
|
{seriesKeys.map(k => (
|
|
<td key={k} className="px-4 py-3 tabular-nums text-right">{row[k]}</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<div style={{ minWidth: minChartWidth > 100 ? `${minChartWidth}px` : '100%', height: '100%' }}>
|
|
<ResponsiveContainer width="100%" height="100%" className="focus:outline-none [&_.recharts-wrapper]:outline-none [&_.recharts-surface]:outline-none" style={{ outline: 'none' }}>
|
|
{chartType === 3 || chartType === 4 ? (
|
|
<PieChart margin={{ top: 10, right: 10, left: 10, bottom: 10 }} className="focus:outline-none outline-none" style={{ outline: 'none' }}>
|
|
<Tooltip
|
|
contentStyle={{ borderRadius: '8px', border: '1px solid #E2E8F0', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', fontSize: '14px', fontFamily: 'inherit' }}
|
|
itemStyle={{ color: '#0F172A', fontWeight: '500' }}
|
|
/>
|
|
<Legend wrapperStyle={{ paddingTop: '20px' }} />
|
|
<Pie
|
|
data={finalData}
|
|
dataKey={seriesKeys[0] || "value"}
|
|
nameKey="dimension"
|
|
cx="50%"
|
|
cy="50%"
|
|
outerRadius={100}
|
|
innerRadius={chartType === 4 ? 65 : 0}
|
|
style={{ outline: 'none' }}
|
|
activeShape={false}
|
|
className="focus:outline-none outline-none"
|
|
>
|
|
{finalData.map((_, index) => (
|
|
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} style={{ outline: 'none' }} className="focus:outline-none outline-none" />
|
|
))}
|
|
</Pie>
|
|
</PieChart>
|
|
) : chartType === 1 ? (
|
|
<LineChart data={finalData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }} className="focus:outline-none outline-none" style={{ outline: 'none' }}>
|
|
{renderChartContent()}
|
|
</LineChart>
|
|
) : chartType === 2 ? (
|
|
<AreaChart data={finalData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }} className="focus:outline-none outline-none" style={{ outline: 'none' }}>
|
|
{renderChartContent()}
|
|
</AreaChart>
|
|
) : (
|
|
<BarChart data={finalData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }} className="focus:outline-none outline-none" style={{ outline: 'none' }}>
|
|
{renderChartContent()}
|
|
</BarChart>
|
|
)}
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|