mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:25:23 +02:00
1. Changed weekTotals to use 'rows' instead of 'filteredRows' so totals always show complete sums regardless of search/growth filters 2. Added second pass in mergeSalesAndAdsData to include ads records for ASINs that have ad spend but no corresponding sales data
367 lines
21 KiB
TypeScript
367 lines
21 KiB
TypeScript
import React, { useMemo, useState, useEffect } from 'react';
|
|
import * as XLSX from 'xlsx';
|
|
import { CombinedKPIs } from '../types';
|
|
import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor';
|
|
|
|
interface WeeklyGridProps {
|
|
data: CombinedKPIs[];
|
|
}
|
|
|
|
type SortConfig = {
|
|
key: string; // weekKey
|
|
direction: 'asc' | 'desc';
|
|
metric: 'units' | 'spend';
|
|
} | null;
|
|
|
|
const ROWS_PER_PAGE = 50;
|
|
|
|
const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
|
|
const { rows, weeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
|
|
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [growthFilterMode, setGrowthFilterMode] = useState<'all' | 'up' | 'down' | 'stable'>('all');
|
|
const [growthThreshold, setGrowthThreshold] = useState(10);
|
|
|
|
// Default sort: most recent week, descending, units
|
|
const [sortConfig, setSortConfig] = useState<SortConfig>(() => {
|
|
if (weeks.length > 0) {
|
|
return { key: weeks[0], direction: 'desc', metric: 'units' };
|
|
}
|
|
return null;
|
|
});
|
|
|
|
// Reset pagination when search changes
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [searchTerm]);
|
|
|
|
const handleSort = (weekKey: string, metric: 'units' | 'spend') => {
|
|
setSortConfig(prev => {
|
|
if (prev?.key === weekKey && prev.metric === metric) {
|
|
// Toggle direction if same week & same metric
|
|
return { key: weekKey, direction: prev.direction === 'asc' ? 'desc' : 'asc', metric };
|
|
}
|
|
// Switch to new week/metric with default DESC
|
|
return { key: weekKey, direction: 'desc', metric };
|
|
});
|
|
};
|
|
|
|
// 1. Filter by search term
|
|
const filteredRows = useMemo(() => {
|
|
let result = rows;
|
|
|
|
// 1. Search Filter
|
|
if (searchTerm) {
|
|
const lowSearch = searchTerm.toLowerCase();
|
|
result = result.filter(r =>
|
|
r.sku.toLowerCase().includes(lowSearch) ||
|
|
r.asin.toLowerCase().includes(lowSearch) ||
|
|
r.title.toLowerCase().includes(lowSearch) ||
|
|
r.line.toLowerCase().includes(lowSearch)
|
|
);
|
|
}
|
|
|
|
// 2. Growth Filter (if active)
|
|
if (growthFilterMode !== 'all' && sortConfig) {
|
|
const currentWeek = sortConfig.key;
|
|
const currentWeekIdx = weeks.indexOf(currentWeek);
|
|
const prevWeek = weeks[currentWeekIdx + 1];
|
|
|
|
if (prevWeek) {
|
|
result = result.filter(r => {
|
|
const currentUnits = r.unitsByWeek[currentWeek] || 0;
|
|
const prevUnits = r.unitsByWeek[prevWeek] || 0;
|
|
|
|
// Handle edge case: units were 0 in previous week
|
|
if (prevUnits === 0) {
|
|
if (currentUnits === 0) return growthFilterMode === 'stable';
|
|
return growthFilterMode === 'up'; // Gained from 0
|
|
}
|
|
|
|
// Calculate percentage growth
|
|
const growth = ((currentUnits - prevUnits) / prevUnits) * 100;
|
|
|
|
// For 'down', we use the absolute threshold to check if it dropped BY at least that amount
|
|
if (growthFilterMode === 'up') return growth >= growthThreshold;
|
|
if (growthFilterMode === 'down') return growth <= -Math.abs(growthThreshold);
|
|
if (growthFilterMode === 'stable') return Math.abs(growth) < growthThreshold;
|
|
return true;
|
|
});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}, [rows, searchTerm, growthFilterMode, growthThreshold, sortConfig, weeks]);
|
|
|
|
// 2. Sort results
|
|
const sortedRows = useMemo(() => {
|
|
const result = [...filteredRows];
|
|
if (sortConfig) {
|
|
result.sort((a, b) => {
|
|
const metricKey = sortConfig.metric === 'units' ? 'unitsByWeek' : 'spendByWeek';
|
|
const valA = a[metricKey][sortConfig.key] || 0;
|
|
const valB = b[metricKey][sortConfig.key] || 0;
|
|
if (sortConfig.direction === 'asc') {
|
|
return valA - valB;
|
|
}
|
|
return valB - valA;
|
|
});
|
|
}
|
|
return result;
|
|
}, [filteredRows, sortConfig]);
|
|
|
|
// 3. Paginate
|
|
const paginatedRows = useMemo(() => {
|
|
const start = (currentPage - 1) * ROWS_PER_PAGE;
|
|
return sortedRows.slice(start, start + ROWS_PER_PAGE);
|
|
}, [sortedRows, currentPage]);
|
|
|
|
const totalPages = Math.ceil(sortedRows.length / ROWS_PER_PAGE);
|
|
|
|
// Calculate totals per week - use unfiltered 'rows' to show complete totals
|
|
const weekTotals = useMemo(() => {
|
|
const totals: { [weekKey: string]: { units: number, spend: number } } = {};
|
|
weeks.forEach(week => {
|
|
totals[week] = rows.reduce((acc, row) => {
|
|
acc.units += (row.unitsByWeek[week] || 0);
|
|
acc.spend += (row.spendByWeek[week] || 0);
|
|
return acc;
|
|
}, { units: 0, spend: 0 });
|
|
});
|
|
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 (
|
|
<span className={`text-[10px] font-bold ${isPositive ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
{isPositive ? '▲' : '▼'}{Math.abs(pct).toFixed(0)}%
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// Export to Excel
|
|
const handleExportExcel = () => {
|
|
// Prepare data for export - use sortedRows to match on-screen order
|
|
const exportData = sortedRows.map(row => {
|
|
const rowData: { [key: string]: string | number } = {
|
|
SKU: row.sku,
|
|
ASIN: row.asin,
|
|
Title: row.title,
|
|
Line: row.line,
|
|
};
|
|
weeks.forEach(week => {
|
|
rowData[`${week} Units`] = row.unitsByWeek[week] || 0;
|
|
rowData[`${week} Spend`] = row.spendByWeek[week] || 0;
|
|
});
|
|
return rowData;
|
|
});
|
|
|
|
const ws = XLSX.utils.json_to_sheet(exportData);
|
|
const wb = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(wb, ws, 'Weekly Sales');
|
|
XLSX.writeFile(wb, `Weekly_Sales_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4 animate-fade-in">
|
|
{/* Toolbar: Search & Pagination */}
|
|
<div className="flex flex-col lg:flex-row justify-between items-center gap-4 bg-slate-900 border border-white/10 p-4 rounded-xl shadow-lg">
|
|
<div className="flex flex-wrap items-center gap-4 w-full lg:w-auto">
|
|
{/* Search Input */}
|
|
<div className="relative w-full md:w-80">
|
|
<input
|
|
type="text"
|
|
placeholder="Search SKU, Title, or ASIN..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full bg-slate-950 border border-white/10 rounded-lg px-4 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-all pl-10"
|
|
/>
|
|
<svg className="absolute left-3 top-2.5 w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<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>
|
|
|
|
{/* Growth Filter */}
|
|
<div className="flex items-center gap-2 bg-slate-950/50 p-1.5 px-3 rounded-lg border border-white/5">
|
|
<span className="text-[11px] font-black text-slate-500 uppercase tracking-widest">Growth Filter</span>
|
|
<select
|
|
value={growthFilterMode}
|
|
onChange={(e) => setGrowthFilterMode(e.target.value as any)}
|
|
className="bg-slate-900 border border-white/10 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
|
>
|
|
<option value="all">All Products</option>
|
|
<option value="up">Gaining</option>
|
|
<option value="down">Dropping</option>
|
|
<option value="stable">Stable</option>
|
|
</select>
|
|
{growthFilterMode !== 'all' && (
|
|
<div className="flex items-center gap-1.5 ml-1 border-l border-white/10 pl-3">
|
|
<span className="text-[10px] text-slate-500 font-bold">Thresh:</span>
|
|
<input
|
|
type="number"
|
|
value={growthThreshold}
|
|
onChange={(e) => setGrowthThreshold(Number(e.target.value))}
|
|
className="w-12 bg-slate-900 border border-white/10 rounded px-1.5 py-1 text-xs text-white text-center focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
|
/>
|
|
<span className="text-[10px] text-slate-500">%</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Export Button */}
|
|
<button
|
|
onClick={handleExportExcel}
|
|
className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1.5 rounded-lg text-xs font-bold transition-colors border border-emerald-500/50"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
Export Excel
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm text-slate-500 whitespace-nowrap">
|
|
Showing {Math.min(filteredRows.length, (currentPage - 1) * ROWS_PER_PAGE + 1)}-{Math.min(filteredRows.length, currentPage * ROWS_PER_PAGE)} of {filteredRows.length}
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
disabled={currentPage === 1}
|
|
className="p-2 bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:hover:bg-slate-800 rounded-lg transition-colors border border-white/5"
|
|
>
|
|
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
|
</button>
|
|
<span className="bg-slate-950 border border-white/10 px-3 py-1.5 rounded-lg text-xs font-bold text-white min-w-[60px] text-center">
|
|
{currentPage} / {Math.max(1, totalPages)}
|
|
</span>
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
|
disabled={currentPage >= totalPages}
|
|
className="p-2 bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:hover:bg-slate-800 rounded-lg transition-colors border border-white/5"
|
|
>
|
|
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-slate-900 border border-white/10 rounded-xl overflow-hidden shadow-2xl flex-1 min-h-0">
|
|
<div className="overflow-x-auto overflow-y-auto h-full max-h-[calc(100vh-220px)] custom-scrollbar">
|
|
<table className="w-full text-left border-collapse min-w-[max-content]">
|
|
<thead className="sticky top-0 z-20 bg-slate-900">
|
|
<tr className="bg-slate-900 border-b border-white/10">
|
|
<th className="p-3 text-[11px] font-black text-slate-400 uppercase tracking-widest sticky left-0 z-40 bg-slate-900 border-r border-white/10 min-w-[240px]">Product Details</th>
|
|
{weeks.map(week => (
|
|
<th
|
|
key={week}
|
|
className={`p-0 text-[10px] font-black uppercase tracking-widest text-center border-r border-white/10 min-w-[130px] transition-colors select-none ${sortConfig?.key === week ? 'bg-white/[0.02]' : ''}`}
|
|
>
|
|
<div className="flex flex-col h-full">
|
|
{/* Week Label */}
|
|
<div className="p-2 border-b border-white/5 bg-slate-800/30 text-xs text-white">
|
|
{week.split('-')[1]}/{week.split('-')[0].slice(-2)}
|
|
</div>
|
|
|
|
{/* Units Sort Trigger */}
|
|
<div
|
|
onClick={() => handleSort(week, 'units')}
|
|
className={`flex-1 p-1.5 cursor-pointer hover:bg-indigo-500/10 transition-colors flex items-center justify-center gap-1 border-b border-white/5 ${sortConfig?.key === week && sortConfig.metric === 'units' ? 'bg-indigo-500/5 text-indigo-400' : 'text-slate-500 hover:text-slate-300'}`}
|
|
>
|
|
<span className="text-[9px]">Units</span>
|
|
{sortConfig?.key === week && sortConfig.metric === 'units' && (
|
|
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Spend Sort Trigger */}
|
|
<div
|
|
onClick={() => handleSort(week, 'spend')}
|
|
className={`flex-1 p-1.5 cursor-pointer hover:bg-amber-500/10 transition-colors flex items-center justify-center gap-1 ${sortConfig?.key === week && sortConfig.metric === 'spend' ? 'bg-amber-500/5 text-amber-400' : 'text-slate-500 hover:text-slate-300'}`}
|
|
>
|
|
<span className="text-[9px]">Spend</span>
|
|
{sortConfig?.key === week && sortConfig.metric === 'spend' && (
|
|
<span className="text-xs font-bold leading-none">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
<tr className="bg-indigo-950 border-b border-white/10">
|
|
<th className="p-3 text-sm font-black text-white sticky left-0 z-40 bg-indigo-950 border-r border-white/10">TOTALS</th>
|
|
{weeks.map((week, idx) => (
|
|
<th key={week} className="p-3 text-sm font-black text-white text-center border-r border-white/10">
|
|
<div className="flex flex-col items-center">
|
|
<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-[10px] text-indigo-400 font-bold">
|
|
€{weekTotals[week].spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
|
</div>
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-white/5">
|
|
{paginatedRows.length > 0 ? (
|
|
paginatedRows.map((row) => (
|
|
<tr key={row.id} className="hover:bg-white/[0.02] transition-colors group">
|
|
<td className="p-3 py-2 sticky left-0 z-10 bg-slate-900 group-hover:bg-slate-800 border-r border-white/10">
|
|
<div className="flex flex-col">
|
|
<div className="flex items-center gap-2 mb-0.5">
|
|
<span className="text-xs font-black text-indigo-400 uppercase tracking-tighter truncate max-w-[120px]">{row.sku}</span>
|
|
<span className="text-[10px] font-bold text-slate-500 bg-slate-800 px-1.5 py-0.5 rounded border border-white/5">{row.asin}</span>
|
|
</div>
|
|
<span className="text-[11px] text-white/70 truncate w-[210px] leading-tight mb-1" title={row.title}>{row.title}</span>
|
|
<span className="text-[9px] text-fuchsia-400/80 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-3 py-2 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.5">
|
|
<div className="flex items-center gap-1">
|
|
<span className={`text-sm font-bold ${val > 0 ? (sortConfig?.key === week && sortConfig.metric === 'units' ? '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-[11px] font-medium ${sortConfig?.key === week && sortConfig.metric === 'spend' ? 'text-amber-300' : 'text-indigo-400/80'}`}>
|
|
€{spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td colSpan={weeks.length + 1} className="p-10 text-center text-slate-500 italic text-base">
|
|
No SKUs found matching "{searchTerm}"
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default WeeklyGrid;
|