mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:55:23 +02:00
feat: move filtered totals to Data Grid table header
- DataGrid.tsx: Integrated aggregated totals into a new sticky header row - DataGrid.tsx: Aligned Sell Out and Units totals directly above their respective columns - DataGrid.tsx: Added YoY growth indicators to the header totals - DataGrid.tsx: Improved performance with memoized total calculations - DataGrid.tsx: Fixed syntax errors and TypeScript warnings
This commit is contained in:
+319
-307
@@ -282,7 +282,7 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
|
|||||||
|
|
||||||
const { years } = useMemo(() => {
|
const { years } = useMemo(() => {
|
||||||
// We still need unique years for columns
|
// We still need unique years for columns
|
||||||
const yearsSet = new Set(data.map(d => String((d as any).year)));
|
const yearsSet = new Set<string>(data.map(d => String(d.year)));
|
||||||
return { years: Array.from(yearsSet).sort((a, b) => parseInt(b) - parseInt(a)) };
|
return { years: Array.from(yearsSet).sort((a, b) => parseInt(b) - parseInt(a)) };
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -451,6 +451,57 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
|
|||||||
return result;
|
return result;
|
||||||
}, [pivotRows, rowFilters, sortConfig, years]);
|
}, [pivotRows, rowFilters, sortConfig, years]);
|
||||||
|
|
||||||
|
// Calculate aggregated totals for the header row
|
||||||
|
const filteredTotalsByYear = useMemo(() => {
|
||||||
|
const result: { [year: string]: { sellOut: number, units: number, sellOutGrowth?: number, unitsGrowth?: number } } = {};
|
||||||
|
|
||||||
|
years.forEach((year, index) => {
|
||||||
|
const totals = processedRows.reduce(
|
||||||
|
(acc, row) => {
|
||||||
|
const data = row.totalsByYear[year];
|
||||||
|
if (data) {
|
||||||
|
acc.sellOut += data.sellOut;
|
||||||
|
acc.units += data.units;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ sellOut: 0, units: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
result[year] = totals;
|
||||||
|
|
||||||
|
// Calculate YoY growth if previous year exists
|
||||||
|
const prevYear = years[index + 1];
|
||||||
|
if (prevYear) {
|
||||||
|
const prevYearTotals = processedRows.reduce(
|
||||||
|
(acc, row) => {
|
||||||
|
const data = row.totalsByYear[prevYear];
|
||||||
|
if (data) {
|
||||||
|
acc.sellOut += data.sellOut;
|
||||||
|
acc.units += data.units;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ sellOut: 0, units: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (prevYearTotals.sellOut > 0) {
|
||||||
|
result[year].sellOutGrowth = ((totals.sellOut - prevYearTotals.sellOut) / prevYearTotals.sellOut) * 100;
|
||||||
|
} else if (totals.sellOut > 0) {
|
||||||
|
result[year].sellOutGrowth = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevYearTotals.units > 0) {
|
||||||
|
result[year].unitsGrowth = ((totals.units - prevYearTotals.units) / prevYearTotals.units) * 100;
|
||||||
|
} else if (totals.units > 0) {
|
||||||
|
result[year].unitsGrowth = 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [processedRows, years]);
|
||||||
|
|
||||||
const paginatedRows = useMemo(() => {
|
const paginatedRows = useMemo(() => {
|
||||||
const start = (currentPage - 1) * ROWS_PER_PAGE;
|
const start = (currentPage - 1) * ROWS_PER_PAGE;
|
||||||
return processedRows.slice(start, start + ROWS_PER_PAGE);
|
return processedRows.slice(start, start + ROWS_PER_PAGE);
|
||||||
@@ -765,338 +816,299 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filter Totals Summary */}
|
|
||||||
<div className="bg-gradient-to-r from-indigo-900/20 to-purple-900/20 border-y border-indigo-500/30 px-4 py-3">
|
{/* Ads Performance Summary - Shows when ads data is loaded */}
|
||||||
<div className="flex flex-wrap items-center gap-6">
|
{adsSummary && showAdsMetrics && (
|
||||||
|
<div className="bg-gradient-to-r from-fuchsia-900/20 to-indigo-900/20 border-y border-fuchsia-500/30 px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<svg className="w-5 h-5 text-indigo-400" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" /></svg>
|
<span className="w-2 h-2 rounded-full bg-fuchsia-400 animate-pulse"></span>
|
||||||
<span className="text-xs font-bold text-indigo-300 uppercase tracking-wide">Filtered Totals:</span>
|
<span className="text-xs font-bold text-fuchsia-300 uppercase tracking-wide">Advertising Data:</span>
|
||||||
|
<span className="text-xs text-slate-400">{adsSummary.recordCount.toLocaleString('de-DE')} records</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
||||||
|
<span className="text-xs text-slate-400 mr-2">Ad Spend:</span>
|
||||||
|
<span className="text-sm font-bold text-fuchsia-400">€{adsSummary.totalSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
||||||
|
<span className="text-xs text-slate-400 mr-2">Attr. Sales:</span>
|
||||||
|
<span className="text-sm font-bold text-emerald-400">€{adsSummary.attributedSales.toLocaleString('de-DE', { maximumFractionDigits: 0 })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
||||||
|
<span className="text-xs text-slate-400 mr-2">ACOS:</span>
|
||||||
|
<span className={`text-sm font-bold ${adsSummary.acos <= 30 ? 'text-emerald-400' : adsSummary.acos <= 50 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||||
|
{adsSummary.acos.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
||||||
|
<span className="text-xs text-slate-400 mr-2">ROAS:</span>
|
||||||
|
<span className={`text-sm font-bold ${adsSummary.roas >= 3 ? 'text-emerald-400' : adsSummary.roas >= 2 ? 'text-amber-400' : 'text-red-400'}`}>
|
||||||
|
{adsSummary.roas.toFixed(2)}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{years.map((year, index) => {
|
|
||||||
const yearTotals = processedRows.reduce(
|
|
||||||
(acc, row) => {
|
|
||||||
const data = row.totalsByYear[year];
|
|
||||||
if (data) {
|
|
||||||
acc.sellOut += data.sellOut;
|
|
||||||
acc.units += data.units;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ sellOut: 0, units: 0 }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate YoY growth if previous year exists
|
|
||||||
const prevYear = years[index + 1];
|
|
||||||
let sellOutGrowth = null;
|
|
||||||
let unitsGrowth = null;
|
|
||||||
|
|
||||||
if (prevYear) {
|
|
||||||
const prevYearTotals = processedRows.reduce(
|
|
||||||
(acc, row) => {
|
|
||||||
const data = row.totalsByYear[prevYear];
|
|
||||||
if (data) {
|
|
||||||
acc.sellOut += data.sellOut;
|
|
||||||
acc.units += data.units;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ sellOut: 0, units: 0 }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (prevYearTotals.sellOut > 0) {
|
|
||||||
sellOutGrowth = ((yearTotals.sellOut - prevYearTotals.sellOut) / prevYearTotals.sellOut) * 100;
|
|
||||||
} else if (yearTotals.sellOut > 0) {
|
|
||||||
sellOutGrowth = 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevYearTotals.units > 0) {
|
|
||||||
unitsGrowth = ((yearTotals.units - prevYearTotals.units) / prevYearTotals.units) * 100;
|
|
||||||
} else if (yearTotals.units > 0) {
|
|
||||||
unitsGrowth = 100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={year} className="flex items-center gap-4 px-4 py-2 bg-slate-900/50 border border-slate-700/50 rounded-lg">
|
|
||||||
<span className="text-xs font-bold text-slate-400">{year}</span>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-slate-500">S.O:</span>
|
|
||||||
<span className="text-sm font-bold text-emerald-400">
|
|
||||||
€{yearTotals.sellOut.toLocaleString('de-DE', { maximumFractionDigits: 0 })}
|
|
||||||
</span>
|
|
||||||
{sellOutGrowth !== null && (
|
|
||||||
<span className={`text-xs font-bold ${sellOutGrowth >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
||||||
{sellOutGrowth >= 0 ? '↑' : '↓'} {Math.abs(sellOutGrowth).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-slate-500">Units:</span>
|
|
||||||
<span className="text-sm font-bold text-blue-400">
|
|
||||||
{yearTotals.units.toLocaleString('de-DE')}
|
|
||||||
</span>
|
|
||||||
{unitsGrowth !== null && (
|
|
||||||
<span className={`text-xs font-bold ${unitsGrowth >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
||||||
{unitsGrowth >= 0 ? '↑' : '↓'} {Math.abs(unitsGrowth).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
{/* Ads Performance Summary - Shows when ads data is loaded */}
|
<div className="overflow-x-auto min-h-[400px]">
|
||||||
{adsSummary && showAdsMetrics && (
|
<table className="w-full text-left text-sm border-collapse">
|
||||||
<div className="bg-gradient-to-r from-fuchsia-900/20 to-indigo-900/20 border-y border-fuchsia-500/30 px-4 py-3">
|
<thead className="bg-slate-950 text-slate-400 uppercase text-xs font-semibold tracking-wider sticky top-0 z-20 shadow-sm border-b border-white/10">
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
{/* NEW: Filtered Totals Header Row */}
|
||||||
<div className="flex items-center gap-2">
|
<tr className="bg-indigo-950/20 border-b border-indigo-500/30">
|
||||||
<span className="w-2 h-2 rounded-full bg-fuchsia-400 animate-pulse"></span>
|
<th colSpan={effectiveDimensions.length} className="px-4 py-3 bg-indigo-900/10">
|
||||||
<span className="text-xs font-bold text-fuchsia-300 uppercase tracking-wide">Advertising Data:</span>
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-slate-400">{adsSummary.recordCount.toLocaleString('de-DE')} records</span>
|
<svg className="w-4 h-4 text-indigo-400" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||||
</div>
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
</svg>
|
||||||
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
<span className="text-[10px] font-black text-indigo-300 tracking-widest uppercase">Filtered Totals</span>
|
||||||
<span className="text-xs text-slate-400 mr-2">Ad Spend:</span>
|
|
||||||
<span className="text-sm font-bold text-fuchsia-400">€{adsSummary.totalSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
</th>
|
||||||
<span className="text-xs text-slate-400 mr-2">Attr. Sales:</span>
|
{years.map((year, index) => {
|
||||||
<span className="text-sm font-bold text-emerald-400">€{adsSummary.attributedSales.toLocaleString('de-DE', { maximumFractionDigits: 0 })}</span>
|
const totals = filteredTotalsByYear[year];
|
||||||
</div>
|
const prevYear = years[index + 1];
|
||||||
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
return (
|
||||||
<span className="text-xs text-slate-400 mr-2">ACOS:</span>
|
<React.Fragment key={`totals-${year}`}>
|
||||||
<span className={`text-sm font-bold ${adsSummary.acos <= 30 ? 'text-emerald-400' : adsSummary.acos <= 50 ? 'text-amber-400' : 'text-red-400'}`}>
|
<th className="px-4 py-3 text-right border-x border-indigo-500/10 bg-indigo-900/5">
|
||||||
{adsSummary.acos.toFixed(1)}%
|
<div className="flex flex-col items-end">
|
||||||
</span>
|
<span className="text-emerald-400 font-bold text-sm">
|
||||||
</div>
|
€{totals.sellOut.toLocaleString('de-DE', { maximumFractionDigits: 0 })}
|
||||||
<div className="px-3 py-1 bg-slate-900/50 rounded-lg">
|
</span>
|
||||||
<span className="text-xs text-slate-400 mr-2">ROAS:</span>
|
{totals.sellOutGrowth !== undefined && (
|
||||||
<span className={`text-sm font-bold ${adsSummary.roas >= 3 ? 'text-emerald-400' : adsSummary.roas >= 2 ? 'text-amber-400' : 'text-red-400'}`}>
|
<span className={`text-[9px] font-black ${totals.sellOutGrowth >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
{adsSummary.roas.toFixed(2)}x
|
{totals.sellOutGrowth >= 0 ? '↑' : '↓'} {Math.abs(totals.sellOutGrowth).toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Data Table */}
|
|
||||||
<div className="overflow-x-auto min-h-[400px]">
|
|
||||||
<table className="w-full text-left text-sm border-collapse">
|
|
||||||
<thead className="bg-slate-950 text-slate-400 uppercase text-xs font-semibold tracking-wider sticky top-0 z-20 shadow-sm">
|
|
||||||
<tr>
|
|
||||||
{/* Dynamic Dimension Headers */}
|
|
||||||
{effectiveDimensions.map(dim => {
|
|
||||||
const label = DIMENSION_OPTIONS.find(d => d.value === dim)?.label || dim;
|
|
||||||
return (
|
|
||||||
<th
|
|
||||||
key={dim}
|
|
||||||
className="px-4 py-3 border-b border-border cursor-pointer hover:text-white group bg-slate-950 min-w-[150px]"
|
|
||||||
onClick={() => requestSort(dim)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{label} {getSortIcon(dim)}
|
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
);
|
<th className="px-4 py-3 text-right border-r border-indigo-500/10 bg-indigo-900/5">
|
||||||
})}
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="text-blue-400 font-bold text-sm">
|
||||||
|
{totals.units.toLocaleString('de-DE')}
|
||||||
|
</span>
|
||||||
|
{totals.unitsGrowth !== undefined && (
|
||||||
|
<span className={`text-[9px] font-black ${totals.unitsGrowth >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{totals.unitsGrowth >= 0 ? '↑' : '↓'} {Math.abs(totals.unitsGrowth).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{prevYear && <th colSpan={2} className="bg-indigo-900/5 border-r border-indigo-500/10"></th>}
|
||||||
|
{showAdsMetrics && adsSummary && <th colSpan={2} className="bg-fuchsia-900/5 border-r border-fuchsia-500/10"></th>}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
{/* Dynamic Dimension Headers */}
|
||||||
|
{effectiveDimensions.map(dim => {
|
||||||
|
const label = DIMENSION_OPTIONS.find(d => d.value === dim)?.label || dim;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={dim}
|
||||||
|
className="px-4 py-3 border-b border-border cursor-pointer hover:text-white group bg-slate-950 min-w-[150px]"
|
||||||
|
onClick={() => requestSort(dim)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{label} {getSortIcon(dim)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Data Columns: Year Groups */}
|
{/* Data Columns: Year Groups */}
|
||||||
|
{years.map((year, index) => {
|
||||||
|
const prevYear = years[index + 1]; // Since years are sorted desc: 2025, 2024... next index is prev year
|
||||||
|
return (
|
||||||
|
<React.Fragment key={year}>
|
||||||
|
{/* Sell Out & Units */}
|
||||||
|
<th
|
||||||
|
className="px-4 py-3 border-b border-border text-right cursor-pointer hover:text-white bg-slate-950 min-w-[100px]"
|
||||||
|
onClick={() => requestSort(`total_sellOut_${year}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
Sell Out {year} {getSortIcon(`total_sellOut_${year}`)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-4 py-3 border-b border-border text-right cursor-pointer hover:text-white bg-slate-950 min-w-[80px]"
|
||||||
|
onClick={() => requestSort(`total_units_${year}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
Units {year} {getSortIcon(`total_units_${year}`)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
|
||||||
|
{/* Growth Columns (if prev year exists) */}
|
||||||
|
{prevYear && (
|
||||||
|
<>
|
||||||
|
<th
|
||||||
|
className="px-2 py-3 border-b border-border text-center bg-slate-950/50 min-w-[80px] cursor-pointer hover:text-white"
|
||||||
|
onClick={() => requestSort(`growth_sellOut_${year}_${prevYear}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center text-[10px] text-slate-500 uppercase">
|
||||||
|
S.O. Δ% {getSortIcon(`growth_sellOut_${year}_${prevYear}`)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-2 py-3 border-b border-border text-center bg-slate-950/50 min-w-[80px] cursor-pointer hover:text-white"
|
||||||
|
onClick={() => requestSort(`growth_units_${year}_${prevYear}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center text-[10px] text-slate-500 uppercase">
|
||||||
|
Units Δ% {getSortIcon(`growth_units_${year}_${prevYear}`)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ads Columns - Only show when ads data exists and toggle is on */}
|
||||||
|
{showAdsMetrics && adsSummary && (
|
||||||
|
<>
|
||||||
|
<th
|
||||||
|
className="px-3 py-3 border-b border-fuchsia-500/30 text-right bg-fuchsia-950/30 min-w-[90px] cursor-pointer hover:bg-fuchsia-900/40 transition-colors"
|
||||||
|
onClick={() => requestSort(`adSpend_${year}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end text-fuchsia-400 text-[10px] uppercase">
|
||||||
|
Ad Spend {year} {getSortIcon(`adSpend_${year}`)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-3 py-3 border-b border-fuchsia-500/30 text-right bg-fuchsia-950/30 min-w-[70px] cursor-pointer hover:bg-fuchsia-900/40 transition-colors"
|
||||||
|
onClick={() => requestSort(`tacos_${year}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end text-fuchsia-400 text-[10px] uppercase">
|
||||||
|
TACOS {year} {getSortIcon(`tacos_${year}`)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border text-slate-300">
|
||||||
|
{paginatedRows.map((row) => (
|
||||||
|
<tr key={row.id} className="hover:bg-slate-800/50 transition-colors group">
|
||||||
|
{/* Dimension Values */}
|
||||||
|
{effectiveDimensions.map(dim => (
|
||||||
|
<td key={dim} className="px-4 py-3 font-medium text-slate-200 break-words max-w-xs">
|
||||||
|
{dim === 'title'
|
||||||
|
? <div className="line-clamp-2" title={row.title}>{row.title || '-'}</div>
|
||||||
|
: (row[dim as keyof PivotRow] as string) || '-'
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Metric Values */}
|
||||||
{years.map((year, index) => {
|
{years.map((year, index) => {
|
||||||
const prevYear = years[index + 1]; // Since years are sorted desc: 2025, 2024... next index is prev year
|
const data = row.totalsByYear[year];
|
||||||
|
const prevYear = years[index + 1];
|
||||||
|
const prevData = prevYear ? row.totalsByYear[prevYear] : null;
|
||||||
|
|
||||||
|
// Calculate Growth
|
||||||
|
let sellOutGrowth = null;
|
||||||
|
let unitsGrowth = null;
|
||||||
|
|
||||||
|
if (prevYear) {
|
||||||
|
const currSO = data?.sellOut || 0;
|
||||||
|
const prevSO = prevData?.sellOut || 0;
|
||||||
|
if (prevSO !== 0) sellOutGrowth = ((currSO - prevSO) / prevSO) * 100;
|
||||||
|
else if (currSO > 0) sellOutGrowth = 100; // New entry
|
||||||
|
|
||||||
|
const currUnits = data?.units || 0;
|
||||||
|
const prevUnits = prevData?.units || 0;
|
||||||
|
if (prevUnits !== 0) unitsGrowth = ((currUnits - prevUnits) / prevUnits) * 100;
|
||||||
|
else if (currUnits > 0) unitsGrowth = 100;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={year}>
|
<React.Fragment key={year}>
|
||||||
{/* Sell Out & Units */}
|
<td className="px-4 py-3 text-right font-medium text-white group-hover:text-emerald-400 transition-colors">
|
||||||
<th
|
{data ? `€${data.sellOut.toLocaleString('de-DE', { maximumFractionDigits: 0 })}` : '-'}
|
||||||
className="px-4 py-3 border-b border-border text-right cursor-pointer hover:text-white bg-slate-950 min-w-[100px]"
|
</td>
|
||||||
onClick={() => requestSort(`total_sellOut_${year}`)}
|
<td className="px-4 py-3 text-right text-slate-400">
|
||||||
>
|
{data ? data.units.toLocaleString('de-DE') : '-'}
|
||||||
<div className="flex items-center justify-end">
|
</td>
|
||||||
Sell Out {year} {getSortIcon(`total_sellOut_${year}`)}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="px-4 py-3 border-b border-border text-right cursor-pointer hover:text-white bg-slate-950 min-w-[80px]"
|
|
||||||
onClick={() => requestSort(`total_units_${year}`)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
Units {year} {getSortIcon(`total_units_${year}`)}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
|
|
||||||
{/* Growth Columns (if prev year exists) */}
|
{/* Growth Cells */}
|
||||||
{prevYear && (
|
{prevYear && (
|
||||||
<>
|
<>
|
||||||
<th
|
<td className={`px-2 py-3 text-center font-bold text-xs ${sellOutGrowth !== null ? (sellOutGrowth >= 0 ? 'text-emerald-500' : 'text-red-500') : 'text-slate-600'}`}>
|
||||||
className="px-2 py-3 border-b border-border text-center bg-slate-950/50 min-w-[80px] cursor-pointer hover:text-white"
|
{sellOutGrowth !== null ? (
|
||||||
onClick={() => requestSort(`growth_sellOut_${year}_${prevYear}`)}
|
<span className="flex items-center justify-center gap-1">
|
||||||
>
|
{sellOutGrowth >= 0 ? '↑' : '↓'} {Math.abs(sellOutGrowth).toFixed(0)}%
|
||||||
<div className="flex items-center justify-center text-[10px] text-slate-500 uppercase">
|
</span>
|
||||||
S.O. Δ% {getSortIcon(`growth_sellOut_${year}_${prevYear}`)}
|
) : '-'}
|
||||||
</div>
|
</td>
|
||||||
</th>
|
<td className={`px-2 py-3 text-center font-bold text-xs ${unitsGrowth !== null ? (unitsGrowth >= 0 ? 'text-emerald-500' : 'text-red-500') : 'text-slate-600'}`}>
|
||||||
<th
|
{unitsGrowth !== null ? (
|
||||||
className="px-2 py-3 border-b border-border text-center bg-slate-950/50 min-w-[80px] cursor-pointer hover:text-white"
|
<span className="flex items-center justify-center gap-1">
|
||||||
onClick={() => requestSort(`growth_units_${year}_${prevYear}`)}
|
{unitsGrowth >= 0 ? '↑' : '↓'} {Math.abs(unitsGrowth).toFixed(0)}%
|
||||||
>
|
</span>
|
||||||
<div className="flex items-center justify-center text-[10px] text-slate-500 uppercase">
|
) : '-'}
|
||||||
Units Δ% {getSortIcon(`growth_units_${year}_${prevYear}`)}
|
</td>
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ads Columns - Only show when ads data exists and toggle is on */}
|
{/* Ads Data Cells */}
|
||||||
{showAdsMetrics && adsSummary && (
|
{showAdsMetrics && adsSummary && (() => {
|
||||||
<>
|
const adsYearData = row.adsByYear?.[year];
|
||||||
<th
|
return (
|
||||||
className="px-3 py-3 border-b border-fuchsia-500/30 text-right bg-fuchsia-950/30 min-w-[90px] cursor-pointer hover:bg-fuchsia-900/40 transition-colors"
|
<>
|
||||||
onClick={() => requestSort(`adSpend_${year}`)}
|
<td className="px-3 py-3 text-right text-fuchsia-400 font-medium bg-fuchsia-950/10">
|
||||||
>
|
{adsYearData ? `€${adsYearData.adSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}` : '-'}
|
||||||
<div className="flex items-center justify-end text-fuchsia-400 text-[10px] uppercase">
|
</td>
|
||||||
Ad Spend {year} {getSortIcon(`adSpend_${year}`)}
|
<td className={`px-3 py-3 text-right font-bold text-xs bg-fuchsia-950/10 ${adsYearData
|
||||||
</div>
|
? (adsYearData.tacos <= 10 ? 'text-emerald-400' : adsYearData.tacos <= 20 ? 'text-amber-400' : 'text-red-400')
|
||||||
</th>
|
: 'text-slate-600'
|
||||||
<th
|
}`}>
|
||||||
className="px-3 py-3 border-b border-fuchsia-500/30 text-right bg-fuchsia-950/30 min-w-[70px] cursor-pointer hover:bg-fuchsia-900/40 transition-colors"
|
{adsYearData ? `${adsYearData.tacos.toFixed(1)}%` : '-'}
|
||||||
onClick={() => requestSort(`tacos_${year}`)}
|
</td>
|
||||||
>
|
</>
|
||||||
<div className="flex items-center justify-end text-fuchsia-400 text-[10px] uppercase">
|
);
|
||||||
TACOS {year} {getSortIcon(`tacos_${year}`)}
|
})()}
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody className="divide-y divide-border text-slate-300">
|
|
||||||
{paginatedRows.map((row) => (
|
|
||||||
<tr key={row.id} className="hover:bg-slate-800/50 transition-colors group">
|
|
||||||
{/* Dimension Values */}
|
|
||||||
{effectiveDimensions.map(dim => (
|
|
||||||
<td key={dim} className="px-4 py-3 font-medium text-slate-200 break-words max-w-xs">
|
|
||||||
{dim === 'title'
|
|
||||||
? <div className="line-clamp-2" title={row.title}>{row.title || '-'}</div>
|
|
||||||
: (row[dim as keyof PivotRow] as string) || '-'
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Metric Values */}
|
{paginatedRows.length === 0 && (
|
||||||
{years.map((year, index) => {
|
<tr>
|
||||||
const data = row.totalsByYear[year];
|
<td colSpan={effectiveDimensions.length + (years.length * 2)} className="px-6 py-12 text-center text-slate-500 italic">
|
||||||
const prevYear = years[index + 1];
|
No data matches your filters.
|
||||||
const prevData = prevYear ? row.totalsByYear[prevYear] : null;
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
// Calculate Growth
|
{/* Pagination */}
|
||||||
let sellOutGrowth = null;
|
<div className="p-4 border-t border-border bg-slate-900/50 flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||||
let unitsGrowth = null;
|
<div className="text-sm text-slate-500">
|
||||||
|
Page {currentPage} of {totalPages || 1}
|
||||||
if (prevYear) {
|
|
||||||
const currSO = data?.sellOut || 0;
|
|
||||||
const prevSO = prevData?.sellOut || 0;
|
|
||||||
if (prevSO !== 0) sellOutGrowth = ((currSO - prevSO) / prevSO) * 100;
|
|
||||||
else if (currSO > 0) sellOutGrowth = 100; // New entry
|
|
||||||
|
|
||||||
const currUnits = data?.units || 0;
|
|
||||||
const prevUnits = prevData?.units || 0;
|
|
||||||
if (prevUnits !== 0) unitsGrowth = ((currUnits - prevUnits) / prevUnits) * 100;
|
|
||||||
else if (currUnits > 0) unitsGrowth = 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={year}>
|
|
||||||
<td className="px-4 py-3 text-right font-medium text-white group-hover:text-emerald-400 transition-colors">
|
|
||||||
{data ? `€${data.sellOut.toLocaleString('de-DE', { maximumFractionDigits: 0 })}` : '-'}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right text-slate-400">
|
|
||||||
{data ? data.units.toLocaleString('de-DE') : '-'}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Growth Cells */}
|
|
||||||
{prevYear && (
|
|
||||||
<>
|
|
||||||
<td className={`px-2 py-3 text-center font-bold text-xs ${sellOutGrowth !== null ? (sellOutGrowth >= 0 ? 'text-emerald-500' : 'text-red-500') : 'text-slate-600'}`}>
|
|
||||||
{sellOutGrowth !== null ? (
|
|
||||||
<span className="flex items-center justify-center gap-1">
|
|
||||||
{sellOutGrowth >= 0 ? '↑' : '↓'} {Math.abs(sellOutGrowth).toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
) : '-'}
|
|
||||||
</td>
|
|
||||||
<td className={`px-2 py-3 text-center font-bold text-xs ${unitsGrowth !== null ? (unitsGrowth >= 0 ? 'text-emerald-500' : 'text-red-500') : 'text-slate-600'}`}>
|
|
||||||
{unitsGrowth !== null ? (
|
|
||||||
<span className="flex items-center justify-center gap-1">
|
|
||||||
{unitsGrowth >= 0 ? '↑' : '↓'} {Math.abs(unitsGrowth).toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
) : '-'}
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Ads Data Cells */}
|
|
||||||
{showAdsMetrics && adsSummary && (() => {
|
|
||||||
const adsYearData = row.adsByYear?.[year];
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<td className="px-3 py-3 text-right text-fuchsia-400 font-medium bg-fuchsia-950/10">
|
|
||||||
{adsYearData ? `€${adsYearData.adSpend.toLocaleString('de-DE', { maximumFractionDigits: 0 })}` : '-'}
|
|
||||||
</td>
|
|
||||||
<td className={`px-3 py-3 text-right font-bold text-xs bg-fuchsia-950/10 ${adsYearData
|
|
||||||
? (adsYearData.tacos <= 10 ? 'text-emerald-400' : adsYearData.tacos <= 20 ? 'text-amber-400' : 'text-red-400')
|
|
||||||
: 'text-slate-600'
|
|
||||||
}`}>
|
|
||||||
{adsYearData ? `${adsYearData.tacos.toFixed(1)}%` : '-'}
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{paginatedRows.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={effectiveDimensions.length + (years.length * 2)} className="px-6 py-12 text-center text-slate-500 italic">
|
|
||||||
No data matches your filters.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
{/* Pagination */}
|
<button
|
||||||
<div className="p-4 border-t border-border bg-slate-900/50 flex flex-col sm:flex-row justify-between items-center gap-4">
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
<div className="text-sm text-slate-500">
|
disabled={currentPage === 1}
|
||||||
Page {currentPage} of {totalPages || 1}
|
className="px-3 py-1 bg-slate-800 border border-slate-700 rounded text-sm text-slate-300 disabled:opacity-50 hover:bg-slate-700"
|
||||||
</div>
|
>
|
||||||
<div className="flex gap-2">
|
Previous
|
||||||
<button
|
</button>
|
||||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
<button
|
||||||
disabled={currentPage === 1}
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
className="px-3 py-1 bg-slate-800 border border-slate-700 rounded text-sm text-slate-300 disabled:opacity-50 hover:bg-slate-700"
|
disabled={currentPage === totalPages || totalPages === 0}
|
||||||
>
|
className="px-3 py-1 bg-slate-800 border border-slate-700 rounded text-sm text-slate-300 disabled:opacity-50 hover:bg-slate-700"
|
||||||
Previous
|
>
|
||||||
</button>
|
Next
|
||||||
<button
|
</button>
|
||||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={currentPage === totalPages || totalPages === 0}
|
|
||||||
className="px-3 py-1 bg-slate-800 border border-slate-700 rounded text-sm text-slate-300 disabled:opacity-50 hover:bg-slate-700"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user