mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 14:05:24 +02:00
Fix Ads data mismatch, optimize Forecast performance, and add Excel-style in-column stock filtering
This commit is contained in:
@@ -609,7 +609,13 @@ const App: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
{view === 'weekly' && (
|
{view === 'weekly' && (
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<WeeklyGrid data={filteredAdsData.length > 0 ? mergeSalesAndAdsData(filteredData, filteredAdsData, undefined, trafficData) : filteredData} onDrillDown={handleSkuDrillDown} stockMap={stockMap} />
|
<WeeklyGrid
|
||||||
|
data={filteredAdsData.length > 0 ? mergeSalesAndAdsData(filteredData, filteredAdsData, undefined, trafficData) : filteredData}
|
||||||
|
onDrillDown={handleSkuDrillDown}
|
||||||
|
stockMap={stockMap}
|
||||||
|
stockFilter={filters.stock}
|
||||||
|
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
{view === 'movers' && (
|
{view === 'movers' && (
|
||||||
@@ -619,12 +625,26 @@ const App: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
{view === 'ads' && (
|
{view === 'ads' && (
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<AdsPerformance data={filteredAdsData} filters={filters} onSkuDrillDown={handleSkuDrillDown} stockMap={stockMap} />
|
<AdsPerformance
|
||||||
|
data={combinedAdsData}
|
||||||
|
filters={filters}
|
||||||
|
top50Ranking={top50Ranking2025}
|
||||||
|
stockMap={stockMap}
|
||||||
|
stockFilter={filters.stock}
|
||||||
|
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
{view === 'forecast' && (
|
{view === 'forecast' && (
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<ForecastView data={forecastData} stockMap={stockMap} />
|
<ForecastView
|
||||||
|
data={forecastData}
|
||||||
|
filters={filters}
|
||||||
|
top50Ranking={top50Ranking2025}
|
||||||
|
stockMap={stockMap}
|
||||||
|
stockFilter={filters.stock}
|
||||||
|
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as XLSX from 'xlsx';
|
|||||||
import { CombinedKPIs, FilterState } from '../types';
|
import { CombinedKPIs, FilterState } from '../types';
|
||||||
import { DownloadIcon } from './Icons';
|
import { DownloadIcon } from './Icons';
|
||||||
import { StockBadge } from './StockBadge';
|
import { StockBadge } from './StockBadge';
|
||||||
|
import { InColumnStockFilter } from './InColumnStockFilter';
|
||||||
|
|
||||||
interface AdsPerformanceProps {
|
interface AdsPerformanceProps {
|
||||||
data: CombinedKPIs[];
|
data: CombinedKPIs[];
|
||||||
@@ -12,6 +13,8 @@ interface AdsPerformanceProps {
|
|||||||
uk: Map<string, number>;
|
uk: Map<string, number>;
|
||||||
};
|
};
|
||||||
stockMap?: Map<string, number>;
|
stockMap?: Map<string, number>;
|
||||||
|
stockFilter: string[];
|
||||||
|
onStockFilterChange: (newFilters: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits';
|
type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits';
|
||||||
@@ -36,7 +39,7 @@ const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'bl
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ranking, stockMap }) => {
|
const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange }) => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
||||||
const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu');
|
const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu');
|
||||||
@@ -368,7 +371,15 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
<table className="w-full text-left border-collapse text-[11px]">
|
<table className="w-full text-left border-collapse text-[11px]">
|
||||||
<thead className="sticky top-0 z-20 bg-[#1A1F29] shadow-sm">
|
<thead className="sticky top-0 z-20 bg-[#1A1F29] shadow-sm">
|
||||||
<tr className="border-b border-white/5">
|
<tr className="border-b border-white/5">
|
||||||
<th className="p-4 font-black text-slate-400 uppercase tracking-widest min-w-[250px]">PRODUCT</th>
|
<th className="p-4 font-black text-slate-400 uppercase tracking-widest min-w-[250px]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>PRODUCT</span>
|
||||||
|
<InColumnStockFilter
|
||||||
|
currentFilters={stockFilter}
|
||||||
|
onFilterChange={onStockFilterChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
<SortableHeader label="TOTAL SALES" sortKey="salesTotal" activeSort={sortConfig} onSort={handleSort} />
|
<SortableHeader label="TOTAL SALES" sortKey="salesTotal" activeSort={sortConfig} onSort={handleSort} />
|
||||||
<SortableHeader label="ADS SALES" sortKey="salesAds" activeSort={sortConfig} onSort={handleSort} />
|
<SortableHeader label="ADS SALES" sortKey="salesAds" activeSort={sortConfig} onSort={handleSort} />
|
||||||
<SortableHeader label="ORGANIC SALES" sortKey="salesOrganic" activeSort={sortConfig} onSort={handleSort} />
|
<SortableHeader label="ORGANIC SALES" sortKey="salesOrganic" activeSort={sortConfig} onSort={handleSort} />
|
||||||
|
|||||||
+35
-24
@@ -3,6 +3,7 @@ import * as XLSX from 'xlsx';
|
|||||||
import { ProductForecastData, FilterState } from '../types';
|
import { ProductForecastData, FilterState } from '../types';
|
||||||
import { DownloadIcon } from './Icons';
|
import { DownloadIcon } from './Icons';
|
||||||
import { StockBadge } from './StockBadge';
|
import { StockBadge } from './StockBadge';
|
||||||
|
import { InColumnStockFilter } from './InColumnStockFilter';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
LineChart, Line, Legend, ComposedChart, Area
|
LineChart, Line, Legend, ComposedChart, Area
|
||||||
@@ -16,6 +17,8 @@ interface ForecastViewProps {
|
|||||||
uk: Map<string, number>;
|
uk: Map<string, number>;
|
||||||
};
|
};
|
||||||
stockMap?: Map<string, number>;
|
stockMap?: Map<string, number>;
|
||||||
|
stockFilter: string[];
|
||||||
|
onStockFilterChange: (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'];
|
||||||
@@ -31,7 +34,7 @@ const Top50Badge: React.FC<{ rank: number; label: string; theme?: 'amber' | 'ind
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking, stockMap }) => {
|
const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange }) => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
||||||
const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu');
|
const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu');
|
||||||
@@ -88,35 +91,31 @@ const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking
|
|||||||
return result;
|
return result;
|
||||||
}, [data, filters.line, filters.asin, filters.sku, filters.title]);
|
}, [data, filters.line, filters.asin, filters.sku, filters.title]);
|
||||||
|
|
||||||
// Global Aggregate Data (respects filters)
|
// Global Aggregate Data (respects filters) - Single Pass Optimization
|
||||||
const globalMonthlyData = useMemo(() => {
|
const { globalMonthlyData, globalSummary } = useMemo(() => {
|
||||||
return MONTH_ORDER.map(m => {
|
const monthlyStats = MONTH_ORDER.map(m => ({ name: m, Forecast: 0, Actual: 0 }));
|
||||||
let forecast = 0;
|
|
||||||
let actual = 0;
|
|
||||||
baseFilteredData.forEach(p => {
|
|
||||||
const monthPoint = p.monthlyData.find(md => md.month === m);
|
|
||||||
if (monthPoint) {
|
|
||||||
forecast += monthPoint.forecastUnits;
|
|
||||||
actual += monthPoint.actualUnits;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { name: m, Forecast: forecast, Actual: actual };
|
|
||||||
});
|
|
||||||
}, [baseFilteredData]);
|
|
||||||
|
|
||||||
const globalSummary = useMemo(() => {
|
|
||||||
let periodForecast = 0;
|
let periodForecast = 0;
|
||||||
let periodActual = 0;
|
let periodActual = 0;
|
||||||
let annualForecast = 0;
|
let annualForecast = 0;
|
||||||
let annualActual = 0;
|
let annualActual = 0;
|
||||||
|
|
||||||
baseFilteredData.forEach(p => {
|
baseFilteredData.forEach(p => {
|
||||||
annualForecast += p.annualForecast;
|
annualForecast += (p.annualForecast || 0);
|
||||||
p.monthlyData.forEach(md => {
|
p.monthlyData.forEach((md, idx) => {
|
||||||
annualActual += md.actualUnits;
|
const units = (md.actualUnits || 0);
|
||||||
|
const fc = (md.forecastUnits || 0);
|
||||||
|
|
||||||
|
annualActual += units;
|
||||||
|
|
||||||
|
// Update monthly stats (assuming MONTH_ORDER matches p.monthlyData order)
|
||||||
|
if (monthlyStats[idx]) {
|
||||||
|
monthlyStats[idx].Forecast += fc;
|
||||||
|
monthlyStats[idx].Actual += units;
|
||||||
|
}
|
||||||
|
|
||||||
if (activeMonths.includes(md.month)) {
|
if (activeMonths.includes(md.month)) {
|
||||||
periodForecast += md.forecastUnits;
|
periodForecast += fc;
|
||||||
periodActual += md.actualUnits;
|
periodActual += units;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -125,15 +124,19 @@ const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking
|
|||||||
const annualFulfillment = annualForecast > 0 ? (annualActual / annualForecast) * 100 : 0;
|
const annualFulfillment = annualForecast > 0 ? (annualActual / annualForecast) * 100 : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
globalMonthlyData: monthlyStats,
|
||||||
|
globalSummary: {
|
||||||
periodForecast,
|
periodForecast,
|
||||||
periodActual,
|
periodActual,
|
||||||
periodFulfillment,
|
periodFulfillment,
|
||||||
annualForecast,
|
annualForecast,
|
||||||
annualActual,
|
annualActual,
|
||||||
annualFulfillment
|
annualFulfillment
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [baseFilteredData, activeMonths]);
|
}, [baseFilteredData, activeMonths]);
|
||||||
|
|
||||||
|
|
||||||
const filteredProducts = useMemo(() => {
|
const filteredProducts = useMemo(() => {
|
||||||
let result = baseFilteredData;
|
let result = baseFilteredData;
|
||||||
|
|
||||||
@@ -357,7 +360,15 @@ const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking
|
|||||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
<table className="w-full text-left text-sm whitespace-nowrap">
|
||||||
<thead className="bg-slate-950 text-slate-500 uppercase text-[10px] font-black tracking-widest border-b border-border">
|
<thead className="bg-slate-950 text-slate-500 uppercase text-[10px] font-black tracking-widest border-b border-border">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4">Product Info</th>
|
<th className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>Product Info</span>
|
||||||
|
<InColumnStockFilter
|
||||||
|
currentFilters={stockFilter}
|
||||||
|
onFilterChange={onStockFilterChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
<th className="px-6 py-4 text-right">Forecast (Period)</th>
|
<th className="px-6 py-4 text-right">Forecast (Period)</th>
|
||||||
<th className="px-6 py-4 text-right">Actual Units Sales 2026</th>
|
<th className="px-6 py-4 text-right">Actual Units Sales 2026</th>
|
||||||
<th className="px-6 py-4 text-right">Fc 26 Achievement</th>
|
<th className="px-6 py-4 text-right">Fc 26 Achievement</th>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface InColumnStockFilterProps {
|
||||||
|
currentFilters: string[];
|
||||||
|
onFilterChange: (newFilters: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STOCK_OPTIONS = [
|
||||||
|
'Out of Stock (0)',
|
||||||
|
'In Stock (>0)',
|
||||||
|
'Low Stock (<10)',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const InColumnStockFilter: React.FC<InColumnStockFilterProps> = ({ currentFilters, onFilterChange }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleFilter = (option: string) => {
|
||||||
|
let newFilters = [...currentFilters];
|
||||||
|
if (newFilters.includes(option)) {
|
||||||
|
newFilters = newFilters.filter(f => f !== option);
|
||||||
|
} else {
|
||||||
|
newFilters.push(option);
|
||||||
|
}
|
||||||
|
onFilterChange(newFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
onFilterChange([]);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block ml-1" ref={containerRef}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
}}
|
||||||
|
className={`p-1 rounded hover:bg-white/10 transition-colors ${currentFilters.length > 0 ? 'text-indigo-400 bg-indigo-500/10' : 'text-slate-500'}`}
|
||||||
|
title="Filter by Stock"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute left-0 mt-2 w-48 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl z-[100] 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">
|
||||||
|
<span>Stock Filter</span>
|
||||||
|
{currentFilters.length > 0 && (
|
||||||
|
<button onClick={clearFilters} className="text-indigo-400 hover:text-indigo-300">Clear</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{STOCK_OPTIONS.map(option => {
|
||||||
|
const isSelected = currentFilters.includes(option);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFilter(option);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg cursor-pointer transition-colors ${isSelected ? 'bg-indigo-500/20 text-indigo-300' : 'text-slate-300 hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-colors ${isSelected ? 'bg-indigo-500 border-indigo-400' : 'border-slate-600'}`}>
|
||||||
|
{isSelected && <svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={4}><path d="M5 13l4 4L19 7" /></svg>}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-bold">{option}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import * as XLSX from 'xlsx';
|
|||||||
import { CombinedKPIs } from '../types';
|
import { CombinedKPIs } from '../types';
|
||||||
import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor';
|
import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor';
|
||||||
import { StockBadge } from './StockBadge';
|
import { StockBadge } from './StockBadge';
|
||||||
|
import { InColumnStockFilter } from './InColumnStockFilter';
|
||||||
|
|
||||||
interface WeeklyGridProps {
|
interface WeeklyGridProps {
|
||||||
data: CombinedKPIs[];
|
data: CombinedKPIs[];
|
||||||
@@ -12,6 +13,8 @@ interface WeeklyGridProps {
|
|||||||
};
|
};
|
||||||
onDrillDown?: (sku: string) => void;
|
onDrillDown?: (sku: string) => void;
|
||||||
stockMap?: Map<string, number>;
|
stockMap?: Map<string, number>;
|
||||||
|
stockFilter: string[];
|
||||||
|
onStockFilterChange: (newFilters: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortConfig = {
|
type SortConfig = {
|
||||||
@@ -53,7 +56,7 @@ const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'bl
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown, stockMap }) => {
|
const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown, stockMap, stockFilter, onStockFilterChange }) => {
|
||||||
// Pivot data - memoized
|
// Pivot data - memoized
|
||||||
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
|
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
|
||||||
|
|
||||||
@@ -408,6 +411,10 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
|
|||||||
>
|
>
|
||||||
<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}
|
||||||
|
/>
|
||||||
{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' ? '↑' : '↓'}
|
||||||
|
|||||||
Reference in New Issue
Block a user