mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 14:35:22 +02:00
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:
+158
-78
@@ -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,81 +102,132 @@ 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 */}
|
||||||
<table className="w-full text-left border-collapse min-w-[max-content]">
|
<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">
|
||||||
<thead className="sticky top-0 z-20">
|
<div className="relative w-full md:w-96">
|
||||||
<tr className="bg-slate-900 border-b border-white/10">
|
<input
|
||||||
<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>
|
type="text"
|
||||||
{weeks.map(week => (
|
placeholder="Search SKU, Title, or Line..."
|
||||||
<th
|
value={searchTerm}
|
||||||
key={week}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
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'}`}
|
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"
|
||||||
onClick={() => handleSort(week)}
|
/>
|
||||||
>
|
<svg className="absolute left-3 top-2.5 w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
<span>{week.split('-')[1]}/{week.split('-')[0].slice(-2)}</span>
|
</svg>
|
||||||
{sortConfig?.key === week && (
|
</div>
|
||||||
<span className="text-[10px]">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
|
||||||
)}
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-[7px] font-normal text-slate-500 mt-0.5">Units / Spend</div>
|
<span className="text-xs text-slate-500">
|
||||||
</div>
|
Showing {Math.min(sortedRows.length, (currentPage - 1) * ROWS_PER_PAGE + 1)}-{Math.min(sortedRows.length, currentPage * ROWS_PER_PAGE)} of {sortedRows.length}
|
||||||
</th>
|
</span>
|
||||||
))}
|
<div className="flex items-center gap-1">
|
||||||
</tr>
|
<button
|
||||||
<tr className="bg-indigo-950/40 backdrop-blur-md border-b border-white/10">
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
<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>
|
disabled={currentPage === 1}
|
||||||
{weeks.map((week, idx) => (
|
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"
|
||||||
<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">
|
<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>
|
||||||
<div className="flex items-center gap-1">
|
</button>
|
||||||
<span>{weekTotals[week].units.toLocaleString('de-DE')}</span>
|
<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">
|
||||||
{renderGrowth(weekTotals[week].units, weekTotals[weeks[idx + 1]]?.units)}
|
{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]">
|
||||||
|
<thead className="sticky top-0 z-20">
|
||||||
|
<tr className="bg-slate-900 border-b border-white/10">
|
||||||
|
<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>
|
||||||
|
{weeks.map(week => (
|
||||||
|
<th
|
||||||
|
key={week}
|
||||||
|
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'}`}
|
||||||
|
onClick={() => handleSort(week)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
|
<span>{week.split('-')[1]}/{week.split('-')[0].slice(-2)}</span>
|
||||||
|
{sortConfig?.key === week && (
|
||||||
|
<span className="text-[10px]">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
||||||
|
)}
|
||||||
|
<div className="text-[7px] font-normal text-slate-500 mt-0.5">Units / Spend</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] text-indigo-400">
|
</th>
|
||||||
€{weekTotals[week].spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
|
))}
|
||||||
|
</tr>
|
||||||
|
<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>
|
||||||
|
{weeks.map((week, idx) => (
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</th>
|
||||||
</th>
|
))}
|
||||||
))}
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody className="divide-y divide-white/5">
|
||||||
<tbody className="divide-y divide-white/5">
|
{paginatedRows.length > 0 ? (
|
||||||
{sortedRows.map((row) => (
|
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">
|
||||||
<span className="text-[10px] font-black text-indigo-400 uppercase tracking-tighter truncate w-[180px]">{row.sku}</span>
|
<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-[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>
|
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
);
|
{weeks.map((week, idx) => {
|
||||||
})}
|
const val = row.unitsByWeek[week] || 0;
|
||||||
</tr>
|
const prevVal = row.unitsByWeek[weeks[idx + 1]] || 0;
|
||||||
))}
|
const spend = row.spendByWeek[week] || 0;
|
||||||
</tbody>
|
return (
|
||||||
</table>
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user