diff --git a/components/WeeklyGrid.tsx b/components/WeeklyGrid.tsx index 4793123..aede8d6 100644 --- a/components/WeeklyGrid.tsx +++ b/components/WeeklyGrid.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useMemo, useState, useEffect, useCallback } from 'react'; import * as XLSX from 'xlsx'; import { CombinedKPIs } from '../types'; import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor'; @@ -14,11 +14,27 @@ type SortConfig = { } | null; const ROWS_PER_PAGE = 50; +const MAX_VISIBLE_WEEKS = 8; // Limit visible weeks for performance + +// Debounce hook for search +const useDebounce = (value: string, delay: number) => { + const [debouncedValue, setDebouncedValue] = useState(value); + useEffect(() => { + const handler = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(handler); + }, [value, delay]); + return debouncedValue; +}; const WeeklyGrid: React.FC = ({ data }) => { - const { rows, weeks } = useMemo(() => pivotWeeklySalesData(data), [data]); + // Pivot data - memoized + const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]); + + // Limit visible weeks for performance + const weeks = useMemo(() => allWeeks.slice(0, MAX_VISIBLE_WEEKS), [allWeeks]); const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearch = useDebounce(searchTerm, 300); const [currentPage, setCurrentPage] = useState(1); const [growthFilterMode, setGrowthFilterMode] = useState<'all' | 'up' | 'down' | 'stable'>('all'); const [growthThreshold, setGrowthThreshold] = useState(10); @@ -34,26 +50,41 @@ const WeeklyGrid: React.FC = ({ data }) => { // Reset pagination when search changes useEffect(() => { setCurrentPage(1); - }, [searchTerm]); + }, [debouncedSearch]); - const handleSort = (weekKey: string, metric: 'units' | 'spend') => { + const handleSort = useCallback((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 + // Calculate totals in a SINGLE PASS (O(rows) instead of O(weeks × rows)) + const weekTotals = useMemo(() => { + const totals: { [weekKey: string]: { units: number, spend: number } } = {}; + // Initialize all weeks + weeks.forEach(week => { + totals[week] = { units: 0, spend: 0 }; + }); + // Single pass through rows + rows.forEach(row => { + weeks.forEach(week => { + totals[week].units += (row.unitsByWeek[week] || 0); + totals[week].spend += (row.spendByWeek[week] || 0); + }); + }); + return totals; + }, [rows, weeks]); + + // 1. Filter by search term (using debounced value) const filteredRows = useMemo(() => { let result = rows; - // 1. Search Filter - if (searchTerm) { - const lowSearch = searchTerm.toLowerCase(); + // Search Filter + if (debouncedSearch) { + const lowSearch = debouncedSearch.toLowerCase(); result = result.filter(r => r.sku.toLowerCase().includes(lowSearch) || r.asin.toLowerCase().includes(lowSearch) || @@ -62,7 +93,7 @@ const WeeklyGrid: React.FC = ({ data }) => { ); } - // 2. Growth Filter (if active) + // Growth Filter (if active) if (growthFilterMode !== 'all' && sortConfig) { const currentWeek = sortConfig.key; const currentWeekIdx = weeks.indexOf(currentWeek); @@ -73,16 +104,13 @@ const WeeklyGrid: React.FC = ({ data }) => { 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 + return growthFilterMode === 'up'; } - // 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; @@ -92,22 +120,23 @@ const WeeklyGrid: React.FC = ({ data }) => { } return result; - }, [rows, searchTerm, growthFilterMode, growthThreshold, sortConfig, weeks]); + }, [rows, debouncedSearch, growthFilterMode, growthThreshold, sortConfig, weeks]); // 2. Sort results const sortedRows = useMemo(() => { + if (!sortConfig) return filteredRows; + 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; - }); - } + const metricKey = sortConfig.metric === 'units' ? 'unitsByWeek' : 'spendByWeek'; + const weekKey = sortConfig.key; + const direction = sortConfig.direction; + + result.sort((a, b) => { + const valA = a[metricKey][weekKey] || 0; + const valB = b[metricKey][weekKey] || 0; + return direction === 'asc' ? valA - valB : valB - valA; + }); + return result; }, [filteredRows, sortConfig]); @@ -119,20 +148,7 @@ const WeeklyGrid: React.FC = ({ data }) => { 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) => { + const renderGrowth = useCallback((current: number, previous: number) => { if (!previous || previous === 0) return null; const pct = ((current - previous) / previous) * 100; const isPositive = pct >= 0; @@ -142,11 +158,10 @@ const WeeklyGrid: React.FC = ({ data }) => { {isPositive ? '▲' : '▼'}{Math.abs(pct).toFixed(0)}% ); - }; + }, []); // Export to Excel - const handleExportExcel = () => { - // Prepare data for export - use sortedRows to match on-screen order + const handleExportExcel = useCallback(() => { const exportData = sortedRows.map(row => { const rowData: { [key: string]: string | number } = { SKU: row.sku, @@ -154,7 +169,7 @@ const WeeklyGrid: React.FC = ({ data }) => { Title: row.title, Line: row.line, }; - weeks.forEach(week => { + allWeeks.forEach(week => { rowData[`${week} Units`] = row.unitsByWeek[week] || 0; rowData[`${week} Spend`] = row.spendByWeek[week] || 0; }); @@ -165,7 +180,7 @@ const WeeklyGrid: React.FC = ({ data }) => { 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`); - }; + }, [sortedRows, allWeeks]); return (
@@ -299,11 +314,11 @@ const WeeklyGrid: React.FC = ({ data }) => {
- {weekTotals[week].units.toLocaleString('de-DE')} - {renderGrowth(weekTotals[week].units, weekTotals[weeks[idx + 1]]?.units)} + {weekTotals[week]?.units.toLocaleString('de-DE') || 0} + {renderGrowth(weekTotals[week]?.units || 0, weekTotals[weeks[idx + 1]]?.units || 0)}
- €{weekTotals[week].spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} + €{(weekTotals[week]?.spend || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
@@ -317,7 +332,7 @@ const WeeklyGrid: React.FC = ({ data }) => {
- {row.sku} + {row.sku || '-'} {row.asin}
{row.title} @@ -351,7 +366,7 @@ const WeeklyGrid: React.FC = ({ data }) => { ) : ( - No SKUs found matching "{searchTerm}" + No SKUs found matching "{debouncedSearch}" )}