Implement Vendor Stock Filtering across Weekly Sales, Ads, and Forecast views

This commit is contained in:
Christian Vidal Wolf
2026-01-28 10:18:08 +01:00
parent 97071c7b62
commit 5915bb4c0e
7 changed files with 182 additions and 33 deletions
+18 -3
View File
@@ -54,7 +54,6 @@ const App: React.FC = () => {
// Modal State // Modal State
const [isDataModalOpen, setIsDataModalOpen] = useState(false); const [isDataModalOpen, setIsDataModalOpen] = useState(false);
// Filters State
const [filters, setFilters] = useState<FilterState>({ const [filters, setFilters] = useState<FilterState>({
customer: [], customer: [],
year: [], year: [],
@@ -63,7 +62,9 @@ const App: React.FC = () => {
asin: [], asin: [],
sku: [], sku: [],
title: [], title: [],
week: [],
stock: [], stock: [],
vendorStock: [],
}); });
// Handle Data Fetch (Simplified) // Handle Data Fetch (Simplified)
@@ -229,6 +230,7 @@ const App: React.FC = () => {
title: [], title: [],
week: [], week: [],
stock: [], stock: [],
vendorStock: [],
}); });
}; };
@@ -405,8 +407,15 @@ const App: React.FC = () => {
return metaMap; return metaMap;
}, [rawData]); }, [rawData]);
const filteredData = useMemo(() => filterData(rawData, filters, stockMap), [rawData, filters, stockMap]); const top50Mode = useMemo(() => {
const filteredAdsData = useMemo(() => filterAdsData(adsData, filters, globalAsinMetadata, stockMap), [adsData, filters, globalAsinMetadata, stockMap]); const hasUK = filters.customer.includes('Amazon UK');
const hasEU = filters.customer.some(c => ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES'].includes(c));
if (hasUK) return 'uk';
return 'eu';
}, [filters.customer]);
const filteredData = useMemo(() => filterData(rawData, filters, stockMap, vendorStockMap, top50Mode), [rawData, filters, stockMap, vendorStockMap, top50Mode]);
const filteredAdsData = useMemo(() => filterAdsData(adsData, filters, globalAsinMetadata, stockMap, vendorStockMap, top50Mode), [adsData, filters, globalAsinMetadata, stockMap, vendorStockMap, top50Mode]);
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]); const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
// Calculate country-aware Top 50 Best Sellers for 2025 // Calculate country-aware Top 50 Best Sellers for 2025
@@ -641,6 +650,8 @@ const App: React.FC = () => {
vendorStockMap={vendorStockMap} vendorStockMap={vendorStockMap}
stockFilter={filters.stock} stockFilter={filters.stock}
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))} onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
vendorStockFilter={filters.vendorStock}
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
customerFilters={filters.customer} customerFilters={filters.customer}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
top50Ranking={top50Ranking2025} top50Ranking={top50Ranking2025}
@@ -662,6 +673,8 @@ const App: React.FC = () => {
vendorStockMap={vendorStockMap} vendorStockMap={vendorStockMap}
stockFilter={filters.stock} stockFilter={filters.stock}
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))} onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
vendorStockFilter={filters.vendorStock}
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
/> />
</Suspense> </Suspense>
@@ -676,6 +689,8 @@ const App: React.FC = () => {
vendorStockMap={vendorStockMap} vendorStockMap={vendorStockMap}
stockFilter={filters.stock} stockFilter={filters.stock}
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))} onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
vendorStockFilter={filters.vendorStock}
onVendorStockFilterChange={(s) => setFilters(prev => ({ ...prev, vendorStock: s }))}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'} top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
/> />
</Suspense> </Suspense>
+34 -2
View File
@@ -19,6 +19,8 @@ interface AdsPerformanceProps {
onStockFilterChange: (newFilters: string[]) => void; onStockFilterChange: (newFilters: string[]) => void;
top50Mode: 'eu' | 'uk'; top50Mode: 'eu' | 'uk';
vendorStockMap?: Map<string, { eu: number; uk: number }>; vendorStockMap?: Map<string, { eu: number; uk: number }>;
vendorStockFilter: string[];
onVendorStockFilterChange: (newFilters: string[]) => void;
} }
type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits'; type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits';
@@ -64,7 +66,9 @@ const AdsRow: React.FC<{
))} ))}
<span className="text-white font-bold text-sm tracking-tight">{item.asin}</span> <span className="text-white font-bold text-sm tracking-tight">{item.asin}</span>
</div> </div>
<div className="flex items-center gap-2">
<span className="text-[10px] uppercase font-black text-indigo-400 tracking-tighter">SKU: {item.sku}</span> <span className="text-[10px] uppercase font-black text-indigo-400 tracking-tighter">SKU: {item.sku}</span>
</div>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<span className="text-[10px] text-slate-400 truncate max-w-[200px] leading-tight" title={item.title}>{item.title}</span> <span className="text-[10px] text-slate-400 truncate max-w-[200px] leading-tight" title={item.title}>{item.title}</span>
{stockMap && ( {stockMap && (
@@ -92,7 +96,18 @@ const AdsRow: React.FC<{
); );
}); });
const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ranking, stockMap, vendorStockMap, stockFilter, onStockFilterChange, top50Mode }) => { const AdsPerformance: React.FC<AdsPerformanceProps> = ({
data,
filters,
top50Ranking,
stockMap,
vendorStockMap,
stockFilter,
onStockFilterChange,
vendorStockFilter,
onVendorStockFilterChange,
top50Mode
}) => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showOnlyTop50, setShowOnlyTop50] = useState(false); const [showOnlyTop50, setShowOnlyTop50] = useState(false);
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({ const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({
@@ -376,7 +391,24 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
<th className="p-4 font-black text-slate-400 uppercase tracking-widest min-w-[250px]"> <th className="p-4 font-black text-slate-400 uppercase tracking-widest min-w-[250px]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>PRODUCT</span> <span>PRODUCT</span>
<InColumnStockFilter currentFilters={stockFilter} onFilterChange={onStockFilterChange} /> <div className="flex items-center gap-1 scale-75 origin-left">
<InColumnStockFilter
currentFilters={stockFilter}
onFilterChange={onStockFilterChange}
title="Warehouse Stock"
/>
<InColumnStockFilter
currentFilters={vendorStockFilter}
onFilterChange={onVendorStockFilterChange}
title="Vendor Stock"
options={[
'Out of Stock (0)',
'In Stock (>0)',
'In Stock (>20)',
'Low Stock (<10)',
]}
/>
</div>
</div> </div>
</th> </th>
<SortableHeader label="TOTAL SALES" sortKey="salesTotal" activeSort={sortConfig} onSort={handleSort} /> <SortableHeader label="TOTAL SALES" sortKey="salesTotal" activeSort={sortConfig} onSort={handleSort} />
+33 -13
View File
@@ -12,18 +12,10 @@ import {
LineChart, Line, Legend, ComposedChart, Area LineChart, Line, Legend, ComposedChart, Area
} from 'recharts'; } from 'recharts';
interface ForecastViewProps {
data: ProductForecastData[];
filters: FilterState; // Restored filters prop
top50Ranking?: {
eu: Map<string, number>;
uk: Map<string, number>;
};
stockMap?: Map<string, number>;
vendorStockMap?: Map<string, { eu: number; uk: number }>;
stockFilter: string[];
onStockFilterChange: (newFilters: string[]) => void;
top50Mode: 'eu' | 'uk'; top50Mode: 'eu' | 'uk';
vendorStockMap ?: Map<string, { eu: number; uk: number }>;
vendorStockFilter: string[];
onVendorStockFilterChange: (newFilters: string[]) => void;
} }
const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
@@ -116,7 +108,18 @@ const ForecastRow: React.FC<{
); );
}); });
const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking, stockMap, vendorStockMap, stockFilter, onStockFilterChange, top50Mode }) => { const ForecastView: React.FC<ForecastViewProps> = ({
data,
filters,
top50Ranking,
stockMap,
vendorStockMap,
stockFilter,
onStockFilterChange,
vendorStockFilter,
onVendorStockFilterChange,
top50Mode
}) => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showOnlyTop50, setShowOnlyTop50] = useState(false); const [showOnlyTop50, setShowOnlyTop50] = useState(false);
const [displayCount, setDisplayCount] = useState(50); const [displayCount, setDisplayCount] = useState(50);
@@ -231,7 +234,24 @@ const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking
<th className="px-6 py-4 font-black text-slate-400 uppercase tracking-widest text-[10px] min-w-[300px]"> <th className="px-6 py-4 font-black text-slate-400 uppercase tracking-widest text-[10px] min-w-[300px]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>Product Details</span> <span>Product Details</span>
<InColumnStockFilter currentFilters={stockFilter} onFilterChange={onStockFilterChange} /> <div className="flex items-center gap-1 scale-75 origin-left">
<InColumnStockFilter
currentFilters={stockFilter}
onFilterChange={onStockFilterChange}
title="Warehouse Stock"
/>
<InColumnStockFilter
currentFilters={vendorStockFilter}
onFilterChange={onVendorStockFilterChange}
title="Vendor Stock"
options={[
'Out of Stock (0)',
'In Stock (>0)',
'In Stock (>20)',
'Low Stock (<10)',
]}
/>
</div>
</div> </div>
</th> </th>
<th className="px-6 py-4 font-black text-slate-400 uppercase tracking-widest text-[10px] text-center">Actuals</th> <th className="px-6 py-4 font-black text-slate-400 uppercase tracking-widest text-[10px] text-center">Actuals</th>
+11 -4
View File
@@ -3,15 +3,22 @@ import React, { useState, useRef, useEffect } from 'react';
interface InColumnStockFilterProps { interface InColumnStockFilterProps {
currentFilters: string[]; currentFilters: string[];
onFilterChange: (newFilters: string[]) => void; onFilterChange: (newFilters: string[]) => void;
title?: string;
options?: string[];
} }
const STOCK_OPTIONS = [ const DEFAULT_STOCK_OPTIONS = [
'Out of Stock (0)', 'Out of Stock (0)',
'In Stock (>0)', 'In Stock (>0)',
'Low Stock (<10)', 'Low Stock (<10)',
]; ];
export const InColumnStockFilter: React.FC<InColumnStockFilterProps> = ({ currentFilters, onFilterChange }) => { export const InColumnStockFilter: React.FC<InColumnStockFilterProps> = ({
currentFilters,
onFilterChange,
title = "Stock Filter",
options = DEFAULT_STOCK_OPTIONS
}) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -59,12 +66,12 @@ export const InColumnStockFilter: React.FC<InColumnStockFilterProps> = ({ curren
{isOpen && ( {isOpen && (
<div className="absolute left-0 mt-2 w-48 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl z-[999] p-2 animate-in fade-in zoom-in duration-150"> <div className="absolute left-0 mt-2 w-48 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl z-[999] p-2 animate-in fade-in zoom-in duration-150">
<div className="px-2 py-1.5 text-[10px] font-black text-slate-500 uppercase tracking-widest border-b border-slate-800 mb-1 flex justify-between items-center"> <div className="px-2 py-1.5 text-[10px] font-black text-slate-500 uppercase tracking-widest border-b border-slate-800 mb-1 flex justify-between items-center">
<span>Stock Filter</span> <span>{title}</span>
{currentFilters.length > 0 && ( {currentFilters.length > 0 && (
<button onClick={clearFilters} className="text-indigo-400 hover:text-indigo-300">Clear</button> <button onClick={clearFilters} className="text-indigo-400 hover:text-indigo-300">Clear</button>
)} )}
</div> </div>
{STOCK_OPTIONS.map(option => { {options.map(option => {
const isSelected = currentFilters.includes(option); const isSelected = currentFilters.includes(option);
return ( return (
<div <div
+29 -1
View File
@@ -19,6 +19,8 @@ interface WeeklyGridProps {
onStockFilterChange: (newFilters: string[]) => void; onStockFilterChange: (newFilters: string[]) => void;
customerFilters: string[]; customerFilters: string[];
vendorStockMap?: Map<string, { eu: number; uk: number }>; vendorStockMap?: Map<string, { eu: number; uk: number }>;
vendorStockFilter: string[];
onVendorStockFilterChange: (newFilters: string[]) => void;
} }
type SortConfig = { type SortConfig = {
@@ -133,7 +135,19 @@ const WeeklyRow: React.FC<{
); );
}); });
const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({ data, top50Ranking, onDrillDown, stockMap, vendorStockMap, stockFilter, onStockFilterChange, customerFilters, top50Mode }) => { const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
data,
top50Ranking,
onDrillDown,
stockMap,
vendorStockMap,
stockFilter,
onStockFilterChange,
vendorStockFilter,
onVendorStockFilterChange,
customerFilters,
top50Mode
}) => {
// Pivot data - memoized // Pivot data - memoized
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]); const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
@@ -460,10 +474,24 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({ da
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>Product Details</span> <span>Product Details</span>
<div className="flex items-center gap-1">
<InColumnStockFilter <InColumnStockFilter
currentFilters={stockFilter} currentFilters={stockFilter}
onFilterChange={onStockFilterChange} onFilterChange={onStockFilterChange}
title="Warehouse Stock"
/> />
<InColumnStockFilter
currentFilters={vendorStockFilter}
onFilterChange={onVendorStockFilterChange}
title="Vendor Stock"
options={[
'Out of Stock (0)',
'In Stock (>0)',
'In Stock (>20)',
'Low Stock (<10)',
]}
/>
</div>
{sortConfig?.metric === 'rank' && ( {sortConfig?.metric === 'rank' && (
<span className="text-amber-500 font-black text-sm animate-bounce-subtle"> <span className="text-amber-500 font-black text-sm animate-bounce-subtle">
{sortConfig.direction === 'asc' ? '↑' : '↓'} {sortConfig.direction === 'asc' ? '↑' : '↓'}
+50 -4
View File
@@ -743,12 +743,50 @@ const checkStockFilter = (sku: string, filters: string[], stockMap?: Map<string,
}); });
}; };
const checkVendorStockFilter = (asin: string, filters: string[], vendorStockMap?: Map<string, { eu: number; uk: number }>, mode: 'eu' | 'uk' = 'eu'): boolean => {
if (!filters || !Array.isArray(filters) || filters.length === 0) return true;
if (!vendorStockMap) return true;
const data = vendorStockMap.get(asin);
const stockValue = data ? (mode === 'uk' ? data.uk : data.eu) : 0;
return filters.some(f => {
if (f === 'Out of Stock (0)') return stockValue === 0;
if (f === 'In Stock (>20)') return stockValue > 20;
if (f === 'In Stock (>0)') return stockValue > 0;
if (f === 'Low Stock (<10)') return stockValue < 10;
const input = f.trim().toLowerCase();
if (input.includes('-')) {
const [start, end] = input.split('-').map(s => parseFloat(s.trim()));
if (!isNaN(start) && !isNaN(end)) return stockValue >= start && stockValue <= end;
} else if (input.startsWith('<=')) {
const val = parseFloat(input.substring(2).trim());
if (!isNaN(val)) return stockValue <= val;
} else if (input.startsWith('>=')) {
const val = parseFloat(input.substring(2).trim());
if (!isNaN(val)) return stockValue >= val;
} else if (input.startsWith('<')) {
const val = parseFloat(input.substring(1).trim());
if (!isNaN(val)) return stockValue < val;
} else if (input.startsWith('>')) {
const val = parseFloat(input.substring(1).trim());
if (!isNaN(val)) return stockValue > val;
} else if (!isNaN(parseFloat(input))) {
return stockValue === parseFloat(input);
}
return false;
});
};
// Filter Ads Data by Country, Year, Week, ASIN, SKU, and Product Line // Filter Ads Data by Country, Year, Week, ASIN, SKU, and Product Line
export const filterAdsData = ( export const filterAdsData = (
adsData: AdsRecord[], adsData: AdsRecord[],
filters: FilterState, filters: FilterState,
asinMetadata?: Map<string, { sku: string; line: string }>, asinMetadata?: Map<string, { sku: string; line: string }>,
stockMap?: Map<string, number> stockMap?: Map<string, number>,
vendorStockMap?: Map<string, { eu: number; uk: number }>,
top50Mode: 'eu' | 'uk' = 'eu'
): AdsRecord[] => { ): AdsRecord[] => {
return adsData.filter(ad => { return adsData.filter(ad => {
const asin = ad.asin.trim().toUpperCase(); const asin = ad.asin.trim().toUpperCase();
@@ -785,12 +823,19 @@ export const filterAdsData = (
// Stock match // Stock match
const stockMatch = checkStockFilter(meta?.sku || '', filters.stock, stockMap); const stockMatch = checkStockFilter(meta?.sku || '', filters.stock, stockMap);
const vendorStockMatch = checkVendorStockFilter(asin, filters.vendorStock, vendorStockMap, top50Mode);
return countryMatch && yearMatch && weekMatch && asinMatch && skuMatch && lineMatch && stockMatch; return countryMatch && yearMatch && weekMatch && asinMatch && skuMatch && lineMatch && stockMatch && vendorStockMatch;
}); });
}; };
export const filterData = (data: SalesRecord[], filters: FilterState, stockMap?: Map<string, number>): SalesRecord[] => { export const filterData = (
data: SalesRecord[],
filters: FilterState,
stockMap?: Map<string, number>,
vendorStockMap?: Map<string, { eu: number; uk: number }>,
top50Mode: 'eu' | 'uk' = 'eu'
): SalesRecord[] => {
return data.filter(item => { return data.filter(item => {
// 1. Month Logic: Handle "Apr-23" matching "Apr" filter // 1. Month Logic: Handle "Apr-23" matching "Apr" filter
const recordMonth = item.month; // e.g. "Apr-23" const recordMonth = item.month; // e.g. "Apr-23"
@@ -817,8 +862,9 @@ export const filterData = (data: SalesRecord[], filters: FilterState, stockMap?:
// Stock match // Stock match
const stockMatch = checkStockFilter(item.sku, filters.stock, stockMap); const stockMatch = checkStockFilter(item.sku, filters.stock, stockMap);
const vendorStockMatch = checkVendorStockFilter(item.asin, filters.vendorStock, vendorStockMap, top50Mode);
return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch && weekMatch && stockMatch; return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch && weekMatch && stockMatch && vendorStockMatch;
}); });
}; };
+1
View File
@@ -24,6 +24,7 @@ export interface FilterState {
title: string[]; // Added Title filter title: string[]; // Added Title filter
week: string[]; // Added Week filter week: string[]; // Added Week filter
stock: string[]; // Added Stock filter stock: string[]; // Added Stock filter
vendorStock: string[]; // Added Vendor Stock filter
} }
export interface GrowthMetric { export interface GrowthMetric {