2026-01-21 13:41:52 +01:00
|
|
|
import React, { useMemo, useState } from 'react';
|
2026-01-21 13:19:13 +01:00
|
|
|
import { CombinedKPIs } from '../types';
|
|
|
|
|
import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor';
|
|
|
|
|
|
|
|
|
|
interface WeeklyGridProps {
|
|
|
|
|
data: CombinedKPIs[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 13:41:52 +01:00
|
|
|
type SortConfig = {
|
|
|
|
|
key: string; // weekKey
|
|
|
|
|
direction: 'asc' | 'desc';
|
|
|
|
|
} | null;
|
|
|
|
|
|
2026-01-21 13:19:13 +01:00
|
|
|
const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
|
|
|
|
|
const { rows, weeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
|
|
|
|
|
|
2026-01-21 13:41:52 +01:00
|
|
|
// Default sort: most recent week, descending
|
|
|
|
|
const [sortConfig, setSortConfig] = useState<SortConfig>(() => {
|
|
|
|
|
if (weeks.length > 0) {
|
|
|
|
|
return { key: weeks[0], direction: 'desc' };
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handleSort = (weekKey: string) => {
|
|
|
|
|
setSortConfig(prev => {
|
|
|
|
|
if (prev?.key === weekKey) {
|
|
|
|
|
return { key: weekKey, direction: prev.direction === 'asc' ? 'desc' : 'asc' };
|
|
|
|
|
}
|
|
|
|
|
return { key: weekKey, direction: 'desc' };
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-21 15:05:59 +01:00
|
|
|
// Sort rows globally (Ungrouped)
|
|
|
|
|
const sortedRows = useMemo(() => {
|
|
|
|
|
const result = [...rows];
|
2026-01-21 13:41:52 +01:00
|
|
|
if (sortConfig) {
|
2026-01-21 15:05:59 +01:00
|
|
|
result.sort((a, b) => {
|
2026-01-21 13:41:52 +01:00
|
|
|
const valA = a.unitsByWeek[sortConfig.key] || 0;
|
|
|
|
|
const valB = b.unitsByWeek[sortConfig.key] || 0;
|
|
|
|
|
if (sortConfig.direction === 'asc') {
|
|
|
|
|
return valA - valB;
|
|
|
|
|
}
|
|
|
|
|
return valB - valA;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-21 15:05:59 +01:00
|
|
|
return result;
|
2026-01-21 13:41:52 +01:00
|
|
|
}, [rows, sortConfig]);
|
2026-01-21 13:31:09 +01:00
|
|
|
|
2026-01-21 13:19:13 +01:00
|
|
|
// Calculate totals per week
|
|
|
|
|
const weekTotals = useMemo(() => {
|
2026-01-21 15:05:59 +01:00
|
|
|
const totals: { [weekKey: string]: { units: number, spend: number } } = {};
|
2026-01-21 13:19:13 +01:00
|
|
|
weeks.forEach(week => {
|
2026-01-21 15:05:59 +01:00
|
|
|
totals[week] = rows.reduce((acc, row) => {
|
|
|
|
|
acc.units += (row.unitsByWeek[week] || 0);
|
|
|
|
|
acc.spend += (row.spendByWeek[week] || 0);
|
|
|
|
|
return acc;
|
|
|
|
|
}, { units: 0, spend: 0 });
|
2026-01-21 13:19:13 +01:00
|
|
|
});
|
|
|
|
|
return totals;
|
|
|
|
|
}, [rows, weeks]);
|
|
|
|
|
|
|
|
|
|
const renderGrowth = (current: number, previous: number) => {
|
|
|
|
|
if (!previous || previous === 0) return null;
|
|
|
|
|
const pct = ((current - previous) / previous) * 100;
|
|
|
|
|
const isPositive = pct >= 0;
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-21 15:05:59 +01:00
|
|
|
<span className={`text-[7px] font-bold ${isPositive ? 'text-emerald-400' : 'text-red-400'}`}>
|
2026-01-21 13:19:13 +01:00
|
|
|
{isPositive ? '▲' : '▼'}{Math.abs(pct).toFixed(0)}%
|
2026-01-21 15:05:59 +01:00
|
|
|
</span>
|
2026-01-21 13:19:13 +01:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-21 13:31:09 +01:00
|
|
|
<div className="bg-slate-900 border border-white/10 rounded-xl overflow-hidden animate-fade-in shadow-2xl">
|
2026-01-21 13:19:13 +01:00
|
|
|
<div className="overflow-x-auto overflow-y-auto max-h-[calc(100vh-280px)]">
|
2026-01-21 13:31:09 +01:00
|
|
|
<table className="w-full text-left border-collapse min-w-[max-content]">
|
2026-01-21 13:19:13 +01:00
|
|
|
<thead className="sticky top-0 z-20">
|
|
|
|
|
<tr className="bg-slate-900 border-b border-white/10">
|
2026-01-21 13:31:09 +01:00
|
|
|
<th className="p-2 text-[9px] font-black text-slate-400 uppercase tracking-widest sticky left-0 z-30 bg-slate-900 border-r border-white/10 min-w-[200px]">Product Details</th>
|
2026-01-21 13:19:13 +01:00
|
|
|
{weeks.map(week => (
|
2026-01-21 13:41:52 +01:00
|
|
|
<th
|
|
|
|
|
key={week}
|
2026-01-21 15:05:59 +01:00
|
|
|
className={`p-2 text-[9px] font-black uppercase tracking-widest text-center border-r border-white/10 min-w-[100px] cursor-pointer hover:bg-white/5 transition-colors select-none ${sortConfig?.key === week ? 'text-indigo-400 bg-white/[0.02]' : 'text-slate-400'}`}
|
2026-01-21 13:41:52 +01:00
|
|
|
onClick={() => handleSort(week)}
|
|
|
|
|
>
|
2026-01-21 15:05:59 +01:00
|
|
|
<div className="flex flex-col items-center gap-0.5">
|
2026-01-21 13:41:52 +01:00
|
|
|
<span>{week.split('-')[1]}/{week.split('-')[0].slice(-2)}</span>
|
|
|
|
|
{sortConfig?.key === week && (
|
|
|
|
|
<span className="text-[10px]">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
|
|
|
|
)}
|
2026-01-21 15:05:59 +01:00
|
|
|
<div className="text-[7px] font-normal text-slate-500 mt-0.5">Units / Spend</div>
|
2026-01-21 13:41:52 +01:00
|
|
|
</div>
|
2026-01-21 13:19:13 +01:00
|
|
|
</th>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
2026-01-21 13:31:09 +01:00
|
|
|
<tr className="bg-indigo-950/40 backdrop-blur-md border-b border-white/10">
|
|
|
|
|
<th className="p-2 text-xs font-black text-white sticky left-0 z-30 bg-indigo-900/60 border-r border-white/10">TOTALS</th>
|
2026-01-21 13:19:13 +01:00
|
|
|
{weeks.map((week, idx) => (
|
2026-01-21 13:31:09 +01:00
|
|
|
<th key={week} className="p-2 text-xs font-black text-white text-center border-r border-white/10">
|
|
|
|
|
<div className="flex flex-col items-center">
|
2026-01-21 15:05:59 +01:00
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<span>{weekTotals[week].units.toLocaleString('de-DE')}</span>
|
|
|
|
|
{renderGrowth(weekTotals[week].units, weekTotals[weeks[idx + 1]]?.units)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-[9px] text-indigo-400">
|
|
|
|
|
€{weekTotals[week].spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
|
|
|
|
</div>
|
2026-01-21 13:31:09 +01:00
|
|
|
</div>
|
2026-01-21 13:19:13 +01:00
|
|
|
</th>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-white/5">
|
2026-01-21 15:05:59 +01:00
|
|
|
{sortedRows.map((row) => (
|
|
|
|
|
<tr key={row.id} className="hover:bg-white/[0.02] transition-colors group">
|
|
|
|
|
<td className="p-2 py-1.5 sticky left-0 z-10 bg-slate-900 md:bg-slate-900/95 backdrop-blur-sm group-hover:bg-slate-800 border-r border-white/10">
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="text-[10px] font-black text-indigo-400 uppercase tracking-tighter truncate w-[180px]">{row.sku}</span>
|
|
|
|
|
<span className="text-[9px] text-white/60 truncate w-[180px] leading-tight mb-0.5" title={row.title}>{row.title}</span>
|
|
|
|
|
<span className="text-[7px] text-fuchsia-400/70 font-bold uppercase tracking-widest">{row.line}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
{weeks.map((week, idx) => {
|
|
|
|
|
const val = row.unitsByWeek[week] || 0;
|
|
|
|
|
const prevVal = row.unitsByWeek[weeks[idx + 1]] || 0;
|
|
|
|
|
const spend = row.spendByWeek[week] || 0;
|
|
|
|
|
return (
|
|
|
|
|
<td key={week} className={`p-2 py-1 text-center border-r border-white/5 align-middle ${sortConfig?.key === week ? 'bg-white/[0.01]' : ''}`}>
|
|
|
|
|
<div className="flex flex-col items-center justify-center gap-0">
|
|
|
|
|
<div className="flex items-center gap-0.5">
|
|
|
|
|
<span className={`text-xs font-bold ${val > 0 ? (sortConfig?.key === week ? 'text-indigo-400' : 'text-white') : 'text-slate-700'}`}>
|
|
|
|
|
{val > 0 ? val.toLocaleString('de-DE') : '-'}
|
|
|
|
|
</span>
|
|
|
|
|
{val > 0 && renderGrowth(val, prevVal)}
|
|
|
|
|
</div>
|
|
|
|
|
{spend > 0 && (
|
|
|
|
|
<span className="text-[9px] text-indigo-400/80 font-medium">
|
|
|
|
|
€{spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-01-21 13:31:09 +01:00
|
|
|
</div>
|
2026-01-21 13:19:13 +01:00
|
|
|
</td>
|
2026-01-21 15:05:59 +01:00
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</tr>
|
2026-01-21 13:19:13 +01:00
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default WeeklyGrid;
|