feat: optimize Weekly Sales performance and implement Pan-EU default filter

- Implemented pagination and local search in WeeklyGrid to handle large datasets.
- Refactored Weekly Sales tab to show units and ad spend per SKU.
- Added default Pan-EU country filter (DE, FR, IT, ES) when no country is selected.
This commit is contained in:
Christian Vidal Wolf
2026-01-21 16:28:56 +01:00
parent e93da747a3
commit e2e5314b55
2 changed files with 166 additions and 85 deletions
+91 -11
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState, useEffect } from 'react';
import { CombinedKPIs } from '../types'; import { CombinedKPIs } from '../types';
import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor'; import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor';
@@ -11,9 +11,14 @@ type SortConfig = {
direction: 'asc' | 'desc'; direction: 'asc' | 'desc';
} | null; } | null;
const ROWS_PER_PAGE = 50;
const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => { const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
const { rows, weeks } = useMemo(() => pivotWeeklySalesData(data), [data]); const { rows, weeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
// Default sort: most recent week, descending // Default sort: most recent week, descending
const [sortConfig, setSortConfig] = useState<SortConfig>(() => { const [sortConfig, setSortConfig] = useState<SortConfig>(() => {
if (weeks.length > 0) { if (weeks.length > 0) {
@@ -22,6 +27,11 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
return null; return null;
}); });
// Reset pagination when search changes
useEffect(() => {
setCurrentPage(1);
}, [searchTerm]);
const handleSort = (weekKey: string) => { const handleSort = (weekKey: string) => {
setSortConfig(prev => { setSortConfig(prev => {
if (prev?.key === weekKey) { if (prev?.key === weekKey) {
@@ -31,9 +41,20 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
}); });
}; };
// Sort rows globally (Ungrouped) // 1. Filter by search term
const filteredRows = useMemo(() => {
if (!searchTerm) return rows;
const lowSearch = searchTerm.toLowerCase();
return rows.filter(r =>
r.sku.toLowerCase().includes(lowSearch) ||
r.title.toLowerCase().includes(lowSearch) ||
r.line.toLowerCase().includes(lowSearch)
);
}, [rows, searchTerm]);
// 2. Sort results
const sortedRows = useMemo(() => { const sortedRows = useMemo(() => {
const result = [...rows]; const result = [...filteredRows];
if (sortConfig) { if (sortConfig) {
result.sort((a, b) => { result.sort((a, b) => {
const valA = a.unitsByWeek[sortConfig.key] || 0; const valA = a.unitsByWeek[sortConfig.key] || 0;
@@ -45,20 +66,28 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
}); });
} }
return result; return result;
}, [rows, sortConfig]); }, [filteredRows, sortConfig]);
// Calculate totals per week // 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 (always based on full filtered dataset, not just page)
const weekTotals = useMemo(() => { const weekTotals = useMemo(() => {
const totals: { [weekKey: string]: { units: number, spend: number } } = {}; const totals: { [weekKey: string]: { units: number, spend: number } } = {};
weeks.forEach(week => { weeks.forEach(week => {
totals[week] = rows.reduce((acc, row) => { totals[week] = filteredRows.reduce((acc, row) => {
acc.units += (row.unitsByWeek[week] || 0); acc.units += (row.unitsByWeek[week] || 0);
acc.spend += (row.spendByWeek[week] || 0); acc.spend += (row.spendByWeek[week] || 0);
return acc; return acc;
}, { units: 0, spend: 0 }); }, { units: 0, spend: 0 });
}); });
return totals; return totals;
}, [rows, weeks]); }, [filteredRows, weeks]);
const renderGrowth = (current: number, previous: number) => { const renderGrowth = (current: number, previous: number) => {
if (!previous || previous === 0) return null; if (!previous || previous === 0) return null;
@@ -73,8 +102,50 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
}; };
return ( return (
<div className="bg-slate-900 border border-white/10 rounded-xl overflow-hidden animate-fade-in shadow-2xl"> <div className="flex flex-col gap-4 animate-fade-in">
<div className="overflow-x-auto overflow-y-auto max-h-[calc(100vh-280px)]"> {/* Toolbar: Search & Pagination */}
<div className="flex flex-col md:flex-row justify-between items-center gap-4 bg-slate-900/50 p-4 border border-white/10 rounded-xl">
<div className="relative w-full md:w-96">
<input
type="text"
placeholder="Search SKU, Title, or Line..."
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>
<div className="flex items-center gap-3">
<span className="text-xs text-slate-500">
Showing {Math.min(sortedRows.length, (currentPage - 1) * ROWS_PER_PAGE + 1)}-{Math.min(sortedRows.length, currentPage * ROWS_PER_PAGE)} of {sortedRows.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">
<div className="overflow-x-auto overflow-y-auto max-h-[calc(100vh-360px)] custom-scrollbar">
<table className="w-full text-left border-collapse min-w-[max-content]"> <table className="w-full text-left border-collapse min-w-[max-content]">
<thead className="sticky top-0 z-20"> <thead className="sticky top-0 z-20">
<tr className="bg-slate-900 border-b border-white/10"> <tr className="bg-slate-900 border-b border-white/10">
@@ -113,7 +184,8 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
{sortedRows.map((row) => ( {paginatedRows.length > 0 ? (
paginatedRows.map((row) => (
<tr key={row.id} className="hover:bg-white/[0.02] transition-colors group"> <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"> <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"> <div className="flex flex-col">
@@ -145,11 +217,19 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
); );
})} })}
</tr> </tr>
))} ))
) : (
<tr>
<td colSpan={weeks.length + 1} className="p-10 text-center text-slate-500 italic text-sm">
No SKUs found matching "{searchTerm}"
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div>
); );
}; };
+8 -7
View File
@@ -152,7 +152,8 @@ const getColumnValue = (row: any, aliases: string[]): string => {
return ''; return '';
}; };
// Allowed Customers Whitelist // Allowed Customers Whitelist
const ALLOWED_CUSTOMERS = ['Amazon DE', 'Amazon FR', 'Amazon ES', 'Amazon IT', 'Amazon UK', 'Amazon SC']; export const PAN_EU_COUNTRIES = ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES'];
const ALLOWED_CUSTOMERS = [...PAN_EU_COUNTRIES, 'Amazon UK', 'Amazon SC'];
const isAllowedCustomer = (customer: string): boolean => { const isAllowedCustomer = (customer: string): boolean => {
if (!customer) return false; if (!customer) return false;
@@ -550,8 +551,9 @@ export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecor
export const filterAdsData = (adsData: AdsRecord[], filters: FilterState): AdsRecord[] => { export const filterAdsData = (adsData: AdsRecord[], filters: FilterState): AdsRecord[] => {
return adsData.filter(ad => { return adsData.filter(ad => {
// Country/Customer match (ads use 'country', sales use 'customer') // Country/Customer match (ads use 'country', sales use 'customer')
const countryMatch = filters.customer.length === 0 || const countryMatch = filters.customer.length === 0
filters.customer.some(c => c.toUpperCase() === ad.country.toUpperCase()); ? PAN_EU_COUNTRIES.some(c => c.toUpperCase() === ad.country.toUpperCase())
: filters.customer.some(c => c.toUpperCase() === ad.country.toUpperCase());
// Year match // Year match
const yearMatch = filters.year.length === 0 || const yearMatch = filters.year.length === 0 ||
@@ -576,7 +578,9 @@ export const filterData = (data: SalesRecord[], filters: FilterState): SalesReco
const pureMonth = recordMonth.split('-')[0]; // "Apr" const pureMonth = recordMonth.split('-')[0]; // "Apr"
// 2. Filter Checks // 2. Filter Checks
const customerMatch = filters.customer.length === 0 || filters.customer.includes(item.customer); const customerMatch = filters.customer.length === 0
? PAN_EU_COUNTRIES.includes(item.customer)
: filters.customer.includes(item.customer);
const yearMatch = filters.year.length === 0 || filters.year.includes(item.year.toString()); const yearMatch = filters.year.length === 0 || filters.year.includes(item.year.toString());
// Check match against pure month ("Apr") OR full month ("Apr-23") just in case filters evolve // Check match against pure month ("Apr") OR full month ("Apr-23") just in case filters evolve
@@ -998,9 +1002,6 @@ export const applyPanEUGrouping = (
return data; return data;
} }
// Define Pan-EU countries
const PAN_EU_COUNTRIES = ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES'];
// Replace Pan-EU country names with "Pan-EU" for grouping // Replace Pan-EU country names with "Pan-EU" for grouping
return data.map(record => { return data.map(record => {
if (PAN_EU_COUNTRIES.includes(record.customer)) { if (PAN_EU_COUNTRIES.includes(record.customer)) {