mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 11:45:23 +02:00
Refactor: Unified Top 50 Badge logic and shared component integration
This commit is contained in:
@@ -604,7 +604,14 @@ const App: React.FC = () => {
|
|||||||
{view === 'dashboard' && <Dashboard data={aggregatedData} filterState={filters} adsData={filteredAdsData} stockMap={stockMap} />}
|
{view === 'dashboard' && <Dashboard data={aggregatedData} filterState={filters} adsData={filteredAdsData} stockMap={stockMap} />}
|
||||||
{view === 'table' && (
|
{view === 'table' && (
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<DataGrid data={combinedAdsData} hasCustomerFilter={filters.customer.length > 0} adsData={filteredAdsData} stockMap={stockMap} />
|
<DataGrid
|
||||||
|
data={combinedAdsData}
|
||||||
|
hasCustomerFilter={filters.customer.length > 0}
|
||||||
|
adsData={filteredAdsData}
|
||||||
|
stockMap={stockMap}
|
||||||
|
top50Ranking={top50Ranking2025}
|
||||||
|
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
{view === 'weekly' && (
|
{view === 'weekly' && (
|
||||||
@@ -616,6 +623,8 @@ const App: React.FC = () => {
|
|||||||
stockFilter={filters.stock}
|
stockFilter={filters.stock}
|
||||||
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
||||||
customerFilters={filters.customer}
|
customerFilters={filters.customer}
|
||||||
|
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
||||||
|
top50Ranking={top50Ranking2025}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
@@ -633,6 +642,7 @@ const App: React.FC = () => {
|
|||||||
stockMap={stockMap}
|
stockMap={stockMap}
|
||||||
stockFilter={filters.stock}
|
stockFilter={filters.stock}
|
||||||
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
||||||
|
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
@@ -645,6 +655,7 @@ const App: React.FC = () => {
|
|||||||
stockMap={stockMap}
|
stockMap={stockMap}
|
||||||
stockFilter={filters.stock}
|
stockFilter={filters.stock}
|
||||||
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
||||||
|
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
@@ -657,33 +668,35 @@ const App: React.FC = () => {
|
|||||||
<AIChat onSendMessage={handleAskGemini} isOpen={isChatOpen} setIsOpen={setIsChatOpen} />
|
<AIChat onSendMessage={handleAskGemini} isOpen={isChatOpen} setIsOpen={setIsChatOpen} />
|
||||||
|
|
||||||
{/* DATA MODAL */}
|
{/* DATA MODAL */}
|
||||||
{isDataModalOpen && (
|
{
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm animate-fade-in p-4">
|
isDataModalOpen && (
|
||||||
<div className="bg-slate-950 border border-border rounded-2xl shadow-2xl w-full max-w-lg relative overflow-hidden">
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm animate-fade-in p-4">
|
||||||
<div className="bg-slate-900 px-6 py-4 border-b border-border flex justify-between items-center">
|
<div className="bg-slate-950 border border-border rounded-2xl shadow-2xl w-full max-w-lg relative overflow-hidden">
|
||||||
<h2 className="text-lg font-bold text-white">Data Source Settings</h2>
|
<div className="bg-slate-900 px-6 py-4 border-b border-border flex justify-between items-center">
|
||||||
<button onClick={() => setIsDataModalOpen(false)} className="text-slate-400 hover:text-white transition-colors">
|
<h2 className="text-lg font-bold text-white">Data Source Settings</h2>
|
||||||
<CloseIcon />
|
<button onClick={() => setIsDataModalOpen(false)} className="text-slate-400 hover:text-white transition-colors">
|
||||||
</button>
|
<CloseIcon />
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<FileUpload
|
<FileUpload
|
||||||
onSalesUpload={handleSalesUpload}
|
onSalesUpload={handleSalesUpload}
|
||||||
onAdsUpload={handleAdsUpload}
|
onAdsUpload={handleAdsUpload}
|
||||||
onTrafficUpload={handleTrafficUpload}
|
onTrafficUpload={handleTrafficUpload}
|
||||||
onUrlSubmit={handleDataFetch}
|
onUrlSubmit={handleDataFetch}
|
||||||
isLoading={syncing}
|
isLoading={syncing}
|
||||||
activeUrl={activeUrl}
|
activeUrl={activeUrl}
|
||||||
onDisconnect={disconnectUrl}
|
onDisconnect={disconnectUrl}
|
||||||
lastUpdated={lastUpdated}
|
lastUpdated={lastUpdated}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)}
|
}
|
||||||
|
|
||||||
</div>
|
</div >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+135
-219
@@ -1,8 +1,9 @@
|
|||||||
import React, { useMemo, useState, useCallback } from 'react';
|
import React, { useMemo, useState, useCallback } from 'react';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { CombinedKPIs, FilterState } from '../types';
|
import { CombinedKPIs, FilterState } from '../types';
|
||||||
import { DownloadIcon } from './Icons';
|
import { DownloadIcon, FunnelIcon, TrendingIcon, ChartIcon } from './Icons';
|
||||||
import { StockBadge } from './StockBadge';
|
import { StockBadge } from './StockBadge';
|
||||||
|
import { Top50Badge } from './Top50Badge';
|
||||||
import { InColumnStockFilter } from './InColumnStockFilter';
|
import { InColumnStockFilter } from './InColumnStockFilter';
|
||||||
|
|
||||||
interface AdsPerformanceProps {
|
interface AdsPerformanceProps {
|
||||||
@@ -15,40 +16,86 @@ interface AdsPerformanceProps {
|
|||||||
stockMap?: Map<string, number>;
|
stockMap?: Map<string, number>;
|
||||||
stockFilter: string[];
|
stockFilter: string[];
|
||||||
onStockFilterChange: (newFilters: string[]) => void;
|
onStockFilterChange: (newFilters: string[]) => void;
|
||||||
|
top50Mode: 'eu' | 'uk';
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits';
|
type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits';
|
||||||
|
|
||||||
// Top 50 Badge Component
|
// Define AdsProductKPIs type for clarity
|
||||||
const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'blue' | 'indigo' }> = ({ rank, label, theme = 'amber' }) => {
|
type AdsProductKPIs = CombinedKPIs & {
|
||||||
const themeClasses = {
|
acos: number;
|
||||||
amber: 'from-amber-500 to-orange-500 border-amber-400/50',
|
tacos: number;
|
||||||
blue: 'from-blue-500 to-cyan-500 border-blue-400/50',
|
roas: number;
|
||||||
indigo: 'from-indigo-500 to-purple-500 border-indigo-400/50',
|
ctr: number;
|
||||||
};
|
cpc: number;
|
||||||
|
cvrUnits: number;
|
||||||
return (
|
prevData?: AdsProductKPIs;
|
||||||
<span
|
|
||||||
className={`inline-flex items-center justify-center px-1.5 py-0.5 rounded text-[9px] font-black bg-gradient-to-r ${themeClasses[theme]} text-white shadow-sm border`}
|
|
||||||
title={`Top ${rank} Best Seller 2025 ${label ? `(${label})` : ''}`}
|
|
||||||
>
|
|
||||||
<span className="mr-0.5">🏆</span>
|
|
||||||
{label && <span className="mr-0.5 opacity-90">{label}</span>}
|
|
||||||
{rank}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange }) => {
|
const AdsRow: React.FC<{
|
||||||
|
item: AdsProductKPIs;
|
||||||
|
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
||||||
|
top50Mode: 'eu' | 'uk';
|
||||||
|
stockMap?: Map<string, number>;
|
||||||
|
}> = React.memo(({ item, top50Ranking, top50Mode, stockMap }) => {
|
||||||
|
const asin = item.asin.trim().toUpperCase();
|
||||||
|
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
||||||
|
|
||||||
|
if (top50Ranking) {
|
||||||
|
if (top50Mode === 'eu') {
|
||||||
|
const rankEU = top50Ranking.eu.get(asin);
|
||||||
|
if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' });
|
||||||
|
} else {
|
||||||
|
const rankUK = top50Ranking.uk.get(asin);
|
||||||
|
if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="hover:bg-white/[0.02] transition-colors group">
|
||||||
|
<td className="p-4 py-3">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{ranks.map((r, i) => (
|
||||||
|
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
|
||||||
|
))}
|
||||||
|
<span className="text-white font-bold text-sm tracking-tight">{item.asin}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] uppercase font-black text-indigo-400 tracking-tighter">SKU: {item.sku}</span>
|
||||||
|
<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>
|
||||||
|
{stockMap && (
|
||||||
|
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<TableCell value={item.salesTotal} prevValue={item.prevData?.salesTotal} format="currency" />
|
||||||
|
<TableCell value={item.salesAds} prevValue={item.prevData?.salesAds} format="currency" />
|
||||||
|
<TableCell value={item.salesOrganic} prevValue={item.prevData?.salesOrganic} format="currency" />
|
||||||
|
<TableCell value={item.cost} prevValue={item.prevData?.cost} format="currency" />
|
||||||
|
<TableCell value={item.impressions} prevValue={item.prevData?.impressions} format="number" />
|
||||||
|
<TableCell value={item.clicks} prevValue={item.prevData?.clicks} format="number" />
|
||||||
|
<TableCell value={item.conversions} prevValue={item.prevData?.conversions} format="number" />
|
||||||
|
<TableCell value={item.cpc} prevValue={item.prevData?.cpc} format="number" prefix="$" inverse />
|
||||||
|
<TableCell value={item.ctr} prevValue={item.prevData?.ctr} format="percent" />
|
||||||
|
<TableCell value={item.cvrUnits} prevValue={item.prevData?.cvrUnits} format="percent" />
|
||||||
|
<TableCell value={item.acos} prevValue={item.prevData?.acos} format="percent" inverse />
|
||||||
|
<TableCell value={item.roas} prevValue={item.prevData?.roas} format="number" highlight />
|
||||||
|
<TableCell value={item.tacos} prevValue={item.prevData?.tacos} format="percent" inverse />
|
||||||
|
<TableCell value={item.glanceViews || 0} prevValue={item.prevData?.glanceViews} format="number" />
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange, top50Mode }) => {
|
||||||
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 [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({
|
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({
|
||||||
key: 'cost',
|
key: 'cost',
|
||||||
direction: 'desc'
|
direction: 'desc'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detect comparison mode: exactly 2 weeks, 2 months, or 2 years
|
|
||||||
const comparisonConfig = useMemo(() => {
|
const comparisonConfig = useMemo(() => {
|
||||||
let periods: string[] = [];
|
let periods: string[] = [];
|
||||||
let type: 'week' | 'month' | 'year' | null = null;
|
let type: 'week' | 'month' | 'year' | null = null;
|
||||||
@@ -61,8 +108,6 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
});
|
});
|
||||||
type = 'week';
|
type = 'week';
|
||||||
} else if (filters.month.length === 2) {
|
} else if (filters.month.length === 2) {
|
||||||
// Simple sort for abbreviated months might be tricky without year,
|
|
||||||
// but usually users pick consecutive ones.
|
|
||||||
periods = [...filters.month];
|
periods = [...filters.month];
|
||||||
type = 'month';
|
type = 'month';
|
||||||
} else if (filters.year.length === 2) {
|
} else if (filters.year.length === 2) {
|
||||||
@@ -76,7 +121,6 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
return null;
|
return null;
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
// 1. Aggregate and Compare data by ASIN
|
|
||||||
const aggregatedByAsin = useMemo(() => {
|
const aggregatedByAsin = useMemo(() => {
|
||||||
const map = new Map<string, { curr: CombinedKPIs; prev?: CombinedKPIs }>();
|
const map = new Map<string, { curr: CombinedKPIs; prev?: CombinedKPIs }>();
|
||||||
|
|
||||||
@@ -90,7 +134,6 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
const isPrevious = comparisonConfig && periodVal === comparisonConfig.prev;
|
const isPrevious = comparisonConfig && periodVal === comparisonConfig.prev;
|
||||||
|
|
||||||
if (!map.has(asin)) {
|
if (!map.has(asin)) {
|
||||||
// Initialize with empty record if it's new
|
|
||||||
const defaultRec: CombinedKPIs = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 };
|
const defaultRec: CombinedKPIs = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 };
|
||||||
map.set(asin, {
|
map.set(asin, {
|
||||||
curr: isCurrent ? { ...item } : { ...defaultRec },
|
curr: isCurrent ? { ...item } : { ...defaultRec },
|
||||||
@@ -98,19 +141,31 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const entry = map.get(asin)!;
|
const entry = map.get(asin)!;
|
||||||
const target = isCurrent ? entry.curr : (isPrevious ? (entry.prev || (entry.prev = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 })) : null);
|
if (isCurrent) {
|
||||||
|
entry.curr.salesTotal += item.salesTotal;
|
||||||
if (target) {
|
entry.curr.unitsTotal += item.unitsTotal;
|
||||||
target.salesTotal += item.salesTotal;
|
entry.curr.salesAds += item.salesAds;
|
||||||
target.unitsTotal += item.unitsTotal;
|
entry.curr.unitsAds += item.unitsAds;
|
||||||
target.salesAds += item.salesAds;
|
entry.curr.cost += item.cost;
|
||||||
target.unitsAds += item.unitsAds;
|
entry.curr.clicks += item.clicks;
|
||||||
target.cost += item.cost;
|
entry.curr.impressions += item.impressions;
|
||||||
target.clicks += item.clicks;
|
entry.curr.conversions += item.conversions;
|
||||||
target.impressions += item.impressions;
|
entry.curr.salesOrganic += item.salesOrganic;
|
||||||
target.conversions += item.conversions;
|
entry.curr.glanceViews = (entry.curr.glanceViews || 0) + (item.glanceViews || 0);
|
||||||
target.salesOrganic += item.salesOrganic;
|
} else if (isPrevious) {
|
||||||
target.glanceViews = (target.glanceViews || 0) + (item.glanceViews || 0);
|
if (!entry.prev) {
|
||||||
|
entry.prev = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 };
|
||||||
|
}
|
||||||
|
entry.prev.salesTotal += item.salesTotal;
|
||||||
|
entry.prev.unitsTotal += item.unitsTotal;
|
||||||
|
entry.prev.salesAds += item.salesAds;
|
||||||
|
entry.prev.unitsAds += item.unitsAds;
|
||||||
|
entry.prev.cost += item.cost;
|
||||||
|
entry.prev.clicks += item.clicks;
|
||||||
|
entry.prev.impressions += item.impressions;
|
||||||
|
entry.prev.conversions += item.conversions;
|
||||||
|
entry.prev.salesOrganic += item.salesOrganic;
|
||||||
|
entry.prev.glanceViews = (entry.prev.glanceViews || 0) + (item.glanceViews || 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -132,11 +187,10 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
return {
|
return {
|
||||||
...currFinal,
|
...currFinal,
|
||||||
prevData: prevFinal
|
prevData: prevFinal
|
||||||
};
|
} as AdsProductKPIs;
|
||||||
});
|
});
|
||||||
}, [data, comparisonConfig]);
|
}, [data, comparisonConfig]);
|
||||||
|
|
||||||
// 2. Global Totals
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
const sum = (items: any[]) => items.reduce((acc, curr) => ({
|
const sum = (items: any[]) => items.reduce((acc, curr) => ({
|
||||||
salesTotal: acc.salesTotal + curr.salesTotal,
|
salesTotal: acc.salesTotal + curr.salesTotal,
|
||||||
@@ -168,40 +222,29 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
const currMetrics = calculateDerived(totals.curr);
|
const currMetrics = calculateDerived(totals.curr);
|
||||||
const prevMetrics = totals.prev ? calculateDerived(totals.prev) : null;
|
const prevMetrics = totals.prev ? calculateDerived(totals.prev) : null;
|
||||||
|
|
||||||
// 3. Filter and Sort
|
|
||||||
const filteredAndSorted = useMemo(() => {
|
const filteredAndSorted = useMemo(() => {
|
||||||
let result = aggregatedByAsin;
|
let result = aggregatedByAsin;
|
||||||
|
|
||||||
// Top 50 Filter
|
|
||||||
if (showOnlyTop50 && top50Ranking) {
|
if (showOnlyTop50 && top50Ranking) {
|
||||||
result = result.filter(item => {
|
const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
|
||||||
const asin = item.asin.trim().toUpperCase();
|
result = result.filter(p => currentRankMap.has(p.asin.toUpperCase()));
|
||||||
if (top50Mode === 'eu') {
|
|
||||||
return top50Ranking.eu.has(asin);
|
|
||||||
} else {
|
|
||||||
return top50Ranking.uk.has(asin);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search Filter
|
if (searchTerm) {
|
||||||
result = result.filter(item =>
|
const lowSearch = searchTerm.toLowerCase();
|
||||||
item.asin.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
result = result.filter(item =>
|
||||||
item.sku.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
item.asin.toLowerCase().includes(lowSearch) ||
|
||||||
item.title.toLowerCase().includes(searchTerm.toLowerCase())
|
item.sku.toLowerCase().includes(lowSearch) ||
|
||||||
);
|
item.title.toLowerCase().includes(lowSearch)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Sort: by ranking when Top 50 is active, otherwise by sortConfig
|
|
||||||
if (showOnlyTop50 && top50Ranking) {
|
if (showOnlyTop50 && top50Ranking) {
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
const asinA = a.asin.trim().toUpperCase();
|
const asinA = a.asin.trim().toUpperCase();
|
||||||
const asinB = b.asin.trim().toUpperCase();
|
const asinB = b.asin.trim().toUpperCase();
|
||||||
const rankA = top50Mode === 'eu'
|
const rankA = top50Mode === 'eu' ? (top50Ranking.eu.get(asinA) || 999) : (top50Ranking.uk.get(asinA) || 999);
|
||||||
? (top50Ranking.eu.get(asinA) || 999)
|
const rankB = top50Mode === 'eu' ? (top50Ranking.eu.get(asinB) || 999) : (top50Ranking.uk.get(asinB) || 999);
|
||||||
: (top50Ranking.uk.get(asinA) || 999);
|
|
||||||
const rankB = top50Mode === 'eu'
|
|
||||||
? (top50Ranking.eu.get(asinB) || 999)
|
|
||||||
: (top50Ranking.uk.get(asinB) || 999);
|
|
||||||
return rankA - rankB;
|
return rankA - rankB;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -243,14 +286,10 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
'TACOS (%)': Number(row.tacos.toFixed(2)),
|
'TACOS (%)': Number(row.tacos.toFixed(2)),
|
||||||
'Glance Views': row.glanceViews || 0,
|
'Glance Views': row.glanceViews || 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (row.prevData) {
|
if (row.prevData) {
|
||||||
rowData['Prev Total Sales'] = Math.round(row.prevData.salesTotal);
|
rowData['Prev Total Sales'] = Math.round(row.prevData.salesTotal);
|
||||||
rowData['Prev Ads Sales'] = Math.round(row.prevData.salesAds);
|
|
||||||
rowData['Prev Ad Spend'] = Math.round(row.prevData.cost);
|
|
||||||
rowData['Growth Sales (%)'] = row.prevData.salesTotal > 0 ? Number((((row.salesTotal - row.prevData.salesTotal) / row.prevData.salesTotal) * 100).toFixed(1)) : 0;
|
rowData['Growth Sales (%)'] = row.prevData.salesTotal > 0 ? Number((((row.salesTotal - row.prevData.salesTotal) / row.prevData.salesTotal) * 100).toFixed(1)) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return rowData;
|
return rowData;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -260,32 +299,20 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
XLSX.writeFile(wb, `Ads_Performance_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
XLSX.writeFile(wb, `Ads_Performance_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||||
}, [filteredAndSorted]);
|
}, [filteredAndSorted]);
|
||||||
|
|
||||||
const formatCurrency = (val: number) =>
|
|
||||||
`$${Math.round(val).toLocaleString('de-DE')}`;
|
|
||||||
|
|
||||||
const formatNumber = (val: number) =>
|
|
||||||
Math.round(val).toLocaleString('de-DE');
|
|
||||||
|
|
||||||
const formatPercent = (val: number) =>
|
|
||||||
`${val.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 animate-fade-in p-6 bg-[#0B0E14] min-h-screen text-slate-300">
|
<div className="flex flex-col gap-6 animate-fade-in p-6 bg-[#0B0E14] min-h-screen text-slate-300">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex justify-between items-end">
|
<div className="flex justify-between items-end">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white mb-1">Performance Overview</h1>
|
<h1 className="text-2xl font-bold text-white mb-1">Performance Overview</h1>
|
||||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
|
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||||
<span>
|
<span>
|
||||||
{aggregatedByAsin.length} products •
|
{aggregatedByAsin.length} products • {comparisonConfig ? `Comparison: ${comparisonConfig.prev} vs ${comparisonConfig.curr}` : 'Direct Overview'}
|
||||||
{comparisonConfig ? ` Comparison: ${comparisonConfig.prev} vs ${comparisonConfig.curr}` : ' Direct Overview'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Grid */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 xl:grid-cols-13 gap-4 bg-[#141820] p-4 rounded-xl border border-white/5 shadow-xl">
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 xl:grid-cols-13 gap-4 bg-[#141820] p-4 rounded-xl border border-white/5 shadow-xl">
|
||||||
<SummaryCard label="TOTAL SALES" value={totals.curr.salesTotal} prevValue={totals.prev?.salesTotal} format="currency" color="text-emerald-400" />
|
<SummaryCard label="TOTAL SALES" value={totals.curr.salesTotal} prevValue={totals.prev?.salesTotal} format="currency" color="text-emerald-400" />
|
||||||
<SummaryCard label="ADS SALES" value={totals.curr.salesAds} prevValue={totals.prev?.salesAds} format="currency" color="text-orange-400" />
|
<SummaryCard label="ADS SALES" value={totals.curr.salesAds} prevValue={totals.prev?.salesAds} format="currency" color="text-orange-400" />
|
||||||
@@ -304,7 +331,6 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
<SummaryCard label="CVR (GV)" value={currMetrics.cvrGv} prevValue={prevMetrics?.cvrGv} format="percent" color="text-teal-300" />
|
<SummaryCard label="CVR (GV)" value={currMetrics.cvrGv} prevValue={prevMetrics?.cvrGv} format="percent" color="text-teal-300" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table Section */}
|
|
||||||
<div className="bg-[#141820] rounded-xl border border-white/10 overflow-hidden shadow-2xl flex-1 flex flex-col">
|
<div className="bg-[#141820] rounded-xl border border-white/10 overflow-hidden shadow-2xl flex-1 flex flex-col">
|
||||||
<div className="p-4 border-b border-white/5 flex justify-between items-center bg-[#1A1F29]">
|
<div className="p-4 border-b border-white/5 flex justify-between items-center bg-[#1A1F29]">
|
||||||
<div>
|
<div>
|
||||||
@@ -314,53 +340,25 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Top 50 Filter Toggle */}
|
{top50Ranking && (top50Ranking.eu.size > 0 || top50Ranking.uk.size > 0) && (
|
||||||
{top50Ranking && (
|
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm">
|
||||||
(top50Ranking.eu.size || 0) > 0 ||
|
<button
|
||||||
(top50Ranking.uk.size || 0) > 0
|
onClick={() => setShowOnlyTop50(!showOnlyTop50)}
|
||||||
) && (
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${showOnlyTop50 ? 'bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-lg' : 'text-slate-400 hover:text-amber-400'}`}
|
||||||
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm">
|
>
|
||||||
<button
|
<span className="text-sm">🏆</span>
|
||||||
onClick={() => setShowOnlyTop50(!showOnlyTop50)}
|
Top 50 ({top50Mode.toUpperCase()})
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${showOnlyTop50
|
</button>
|
||||||
? 'bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-lg shadow-amber-500/20'
|
</div>
|
||||||
: 'text-slate-400 hover:text-amber-400'
|
)}
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-sm">🏆</span>
|
|
||||||
Top 50
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showOnlyTop50 && (
|
|
||||||
<div className="flex ml-1 p-0.5 bg-slate-900 rounded-lg border border-white/5">
|
|
||||||
<button
|
|
||||||
onClick={() => setTop50Mode('eu')}
|
|
||||||
className={`px-2 py-1 rounded-md text-[10px] font-black uppercase tracking-widest transition-all ${top50Mode === 'eu' ? 'bg-indigo-500 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'}`}
|
|
||||||
>
|
|
||||||
EU
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setTop50Mode('uk')}
|
|
||||||
className={`px-2 py-1 rounded-md text-[10px] font-black uppercase tracking-widest transition-all ${top50Mode === 'uk' ? 'bg-blue-500 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'}`}
|
|
||||||
>
|
|
||||||
UK
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="bg-[#0B0E14] border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 w-48"
|
className="bg-[#0B0E14] border border-white/10 rounded-lg px-3 py-1.5 text-xs text-white focus:outline-none w-48"
|
||||||
/>
|
/>
|
||||||
<button
|
<button onClick={handleExportExcel} className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs font-bold border border-white/10 transition-all">
|
||||||
onClick={handleExportExcel}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs font-bold border border-white/10 transition-all shadow-lg active:scale-95"
|
|
||||||
title="Export to Excel"
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
<span>Export</span>
|
<span>Export</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -369,15 +367,12 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
|
|
||||||
<div className="overflow-x-auto flex-1 custom-scrollbar">
|
<div className="overflow-x-auto flex-1 custom-scrollbar">
|
||||||
<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]">
|
||||||
<tr className="border-b border-white/5 relative z-30">
|
<tr className="border-b border-white/5">
|
||||||
<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
|
<InColumnStockFilter currentFilters={stockFilter} onFilterChange={onStockFilterChange} />
|
||||||
currentFilters={stockFilter}
|
|
||||||
onFilterChange={onStockFilterChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<SortableHeader label="TOTAL SALES" sortKey="salesTotal" activeSort={sortConfig} onSort={handleSort} />
|
<SortableHeader label="TOTAL SALES" sortKey="salesTotal" activeSort={sortConfig} onSort={handleSort} />
|
||||||
@@ -397,53 +392,9 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/[0.03]">
|
<tbody className="divide-y divide-white/[0.03]">
|
||||||
{filteredAndSorted.map((row: any) => {
|
{filteredAndSorted.map((p) => (
|
||||||
const asin = row.asin.trim().toUpperCase();
|
<AdsRow key={p.id} item={p} top50Ranking={top50Ranking} top50Mode={top50Mode} stockMap={stockMap} />
|
||||||
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
))}
|
||||||
|
|
||||||
if (top50Ranking) {
|
|
||||||
const euRank = top50Ranking.eu.get(asin);
|
|
||||||
const ukRank = top50Ranking.uk.get(asin);
|
|
||||||
if (euRank) ranks.push({ rank: euRank, label: 'EU', theme: 'indigo' });
|
|
||||||
if (ukRank) ranks.push({ rank: ukRank, label: 'UK', theme: 'blue' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={row.id} className="hover:bg-white/[0.02] transition-colors group">
|
|
||||||
<td className="p-4 py-3">
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{ranks.map((r, i) => (
|
|
||||||
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
|
|
||||||
))}
|
|
||||||
<span className="text-white font-bold text-sm tracking-tight">{row.asin}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] uppercase font-black text-indigo-400 tracking-tighter">SKU: {row.sku}</span>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-[10px] text-slate-400 truncate max-w-[200px] leading-tight" title={row.title}>{row.title}</span>
|
|
||||||
{stockMap && (
|
|
||||||
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<TableCell value={row.salesTotal} prevValue={row.prevData?.salesTotal} format="currency" />
|
|
||||||
<TableCell value={row.salesAds} prevValue={row.prevData?.salesAds} format="currency" />
|
|
||||||
<TableCell value={row.salesOrganic} prevValue={row.prevData?.salesOrganic} format="currency" />
|
|
||||||
<TableCell value={row.cost} prevValue={row.prevData?.cost} format="currency" />
|
|
||||||
<TableCell value={row.impressions} prevValue={row.prevData?.impressions} format="number" />
|
|
||||||
<TableCell value={row.clicks} prevValue={row.prevData?.clicks} format="number" />
|
|
||||||
<TableCell value={row.conversions} prevValue={row.prevData?.conversions} format="number" />
|
|
||||||
<TableCell value={row.cpc} prevValue={row.prevData?.cpc} format="number" prefix="$" inverse />
|
|
||||||
<TableCell value={row.ctr} prevValue={row.prevData?.ctr} format="percent" />
|
|
||||||
<TableCell value={row.cvrUnits} prevValue={row.prevData?.cvrUnits} format="percent" />
|
|
||||||
<TableCell value={row.acos} prevValue={row.prevData?.acos} format="percent" inverse />
|
|
||||||
<TableCell value={row.roas} prevValue={row.prevData?.roas} format="number" highlight />
|
|
||||||
<TableCell value={row.tacos} prevValue={row.prevData?.tacos} format="percent" inverse />
|
|
||||||
<TableCell value={row.glanceViews || 0} prevValue={row.prevData?.glanceViews} format="number" />
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,35 +403,13 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SummaryCard = ({
|
const SummaryCard = ({ label, value, prevValue, format, prefix = "", decimals = 0, color = "text-white", inverse = false }: any) => {
|
||||||
label,
|
const growth = prevValue !== undefined && prevValue > 0 ? ((value - prevValue) / prevValue) * 100 : null;
|
||||||
value,
|
|
||||||
prevValue,
|
|
||||||
format,
|
|
||||||
prefix = "",
|
|
||||||
decimals = 0,
|
|
||||||
color = "text-white",
|
|
||||||
inverse = false
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
prevValue?: number;
|
|
||||||
format: 'currency' | 'number' | 'percent';
|
|
||||||
prefix?: string;
|
|
||||||
decimals?: number;
|
|
||||||
color?: string;
|
|
||||||
inverse?: boolean
|
|
||||||
}) => {
|
|
||||||
const growth = prevValue !== undefined && prevValue > 0
|
|
||||||
? ((value - prevValue) / prevValue) * 100
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const formatVal = (v: number) => {
|
const formatVal = (v: number) => {
|
||||||
if (format === 'currency') return `$${Math.round(v).toLocaleString('de-DE')}`;
|
if (format === 'currency') return `$${Math.round(v).toLocaleString('de-DE')}`;
|
||||||
if (format === 'percent') return v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + "%";
|
if (format === 'percent') return v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + "%";
|
||||||
return prefix + v.toLocaleString('de-DE', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
return prefix + v.toLocaleString('de-DE', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center p-2 border-r last:border-r-0 border-white/5 w-full min-w-[80px]">
|
<div className="flex flex-col items-center justify-center p-2 border-r last:border-r-0 border-white/5 w-full min-w-[80px]">
|
||||||
<span className={`${color} text-[10px] font-black tracking-widest mb-1 text-center leading-tight uppercase`}>{label}</span>
|
<span className={`${color} text-[10px] font-black tracking-widest mb-1 text-center leading-tight uppercase`}>{label}</span>
|
||||||
@@ -503,21 +432,16 @@ const SummaryCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TableCell = ({ value, prevValue, format, prefix = "", inverse = false, highlight = false }: { value: number; prevValue?: number; format: 'currency' | 'number' | 'percent'; prefix?: string; inverse?: boolean; highlight?: boolean }) => {
|
const TableCell = ({ value, prevValue, format, prefix = "", inverse = false, highlight = false }: any) => {
|
||||||
const growth = prevValue !== undefined && prevValue > 0 ? ((value - prevValue) / prevValue) * 100 : null;
|
const growth = prevValue !== undefined && prevValue > 0 ? ((value - prevValue) / prevValue) * 100 : null;
|
||||||
|
|
||||||
const formatVal = (v: number) => {
|
const formatVal = (v: number) => {
|
||||||
if (format === 'currency') return Math.round(v).toLocaleString('de-DE');
|
if (format === 'currency') return Math.round(v).toLocaleString('de-DE');
|
||||||
if (format === 'number') {
|
if (format === 'number') {
|
||||||
if (prefix === "$") {
|
if (prefix === "$") return "$" + v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
return "$" + v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return prefix + Math.round(v).toLocaleString('de-DE');
|
||||||
} else {
|
|
||||||
return prefix + Math.round(v).toLocaleString('de-DE');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + "%";
|
return v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + "%";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td className="p-4 text-right">
|
<td className="p-4 text-right">
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
@@ -537,20 +461,12 @@ const TableCell = ({ value, prevValue, format, prefix = "", inverse = false, hig
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const SortableHeader = ({ label, sortKey, activeSort, onSort }: {
|
const SortableHeader = ({ label, sortKey, activeSort, onSort }: any) => {
|
||||||
label: string;
|
|
||||||
sortKey: SortKey;
|
|
||||||
activeSort: { key: SortKey; direction: 'asc' | 'desc' };
|
|
||||||
onSort: (key: SortKey) => void;
|
|
||||||
}) => {
|
|
||||||
const isActive = activeSort.key === sortKey;
|
const isActive = activeSort.key === sortKey;
|
||||||
return (
|
return (
|
||||||
<th
|
<th onClick={() => onSort(sortKey)} className="p-4 font-black text-slate-400 uppercase tracking-widest text-right cursor-pointer hover:text-white transition-colors group min-w-[100px] whitespace-nowrap">
|
||||||
onClick={() => onSort(sortKey)}
|
|
||||||
className={`p-4 font-black text-slate-400 uppercase tracking-widest text-right cursor-pointer hover:text-white transition-colors group min-w-[100px] whitespace-nowrap`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
<span className="text-[10px]">{label}</span>
|
<span className="text-[10px]">{label}</span>
|
||||||
<span className={`text-[10px] flex flex-col leading-[0.5] transition-opacity ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-40'}`}>
|
<span className={`text-[10px] flex flex-col leading-[0.5] transition-opacity ${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-40'}`}>
|
||||||
|
|||||||
+25
-2
@@ -6,12 +6,18 @@ import { SalesRecord, PivotRow, AdsRecord, CombinedKPIs } from '../types';
|
|||||||
import { pivotSalesData, generateXLSX, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor';
|
import { pivotSalesData, generateXLSX, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor';
|
||||||
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons';
|
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons';
|
||||||
import { StockBadge } from './StockBadge';
|
import { StockBadge } from './StockBadge';
|
||||||
|
import { Top50Badge } from './Top50Badge';
|
||||||
|
|
||||||
interface DataGridProps {
|
interface DataGridProps {
|
||||||
data: SalesRecord[] | CombinedKPIs[];
|
data: SalesRecord[] | CombinedKPIs[];
|
||||||
hasCustomerFilter: boolean;
|
hasCustomerFilter: boolean;
|
||||||
adsData?: AdsRecord[];
|
adsData?: AdsRecord[];
|
||||||
stockMap?: Map<string, number>;
|
stockMap?: Map<string, number>;
|
||||||
|
top50Ranking?: {
|
||||||
|
eu: Map<string, number>;
|
||||||
|
uk: Map<string, number>;
|
||||||
|
};
|
||||||
|
top50Mode: 'eu' | 'uk';
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortConfig = {
|
type SortConfig = {
|
||||||
@@ -228,7 +234,7 @@ const ExpandableChartCard: React.FC<{ title: string; children: React.ReactNode;
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData, stockMap }) => {
|
const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData, stockMap, top50Ranking, top50Mode }) => {
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: null, direction: 'desc' });
|
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: null, direction: 'desc' });
|
||||||
const [showChart, setShowChart] = useState(true);
|
const [showChart, setShowChart] = useState(true);
|
||||||
@@ -1087,7 +1093,24 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData, s
|
|||||||
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
|
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
: (row[dim as keyof PivotRow] as string) || '-'
|
: dim === 'asin' || dim === 'sku'
|
||||||
|
? <div className="flex items-center gap-1.5">
|
||||||
|
{(() => {
|
||||||
|
const asin = (row.asin || '').trim().toUpperCase();
|
||||||
|
if (asin && top50Ranking) {
|
||||||
|
if (top50Mode === 'eu') {
|
||||||
|
const rank = top50Ranking.eu.get(asin);
|
||||||
|
if (rank) return <Top50Badge rank={rank} label="EU" theme="indigo" />;
|
||||||
|
} else {
|
||||||
|
const rank = top50Ranking.uk.get(asin);
|
||||||
|
if (rank) return <Top50Badge rank={rank} label="UK" theme="blue" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
<span>{(row[dim as keyof PivotRow] as string) || '-'}</span>
|
||||||
|
</div>
|
||||||
|
: (row[dim as keyof PivotRow] as string) || '-'
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+146
-399
@@ -1,9 +1,11 @@
|
|||||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { ProductForecastData, FilterState } from '../types';
|
import { ProductForecastData, FilterState, CombinedKPIs } from '../types';
|
||||||
import { DownloadIcon } from './Icons';
|
import { DownloadIcon, FunnelIcon, TrendingIcon, ChartIcon } from './Icons';
|
||||||
import { StockBadge } from './StockBadge';
|
import { StockBadge } from './StockBadge';
|
||||||
|
import { Top50Badge } from './Top50Badge';
|
||||||
import { InColumnStockFilter } from './InColumnStockFilter';
|
import { InColumnStockFilter } from './InColumnStockFilter';
|
||||||
|
import { PAN_EU_COUNTRIES } from '../services/dataProcessor';
|
||||||
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
|
||||||
@@ -11,7 +13,7 @@ import {
|
|||||||
|
|
||||||
interface ForecastViewProps {
|
interface ForecastViewProps {
|
||||||
data: ProductForecastData[];
|
data: ProductForecastData[];
|
||||||
filters: FilterState;
|
filters: FilterState; // Restored filters prop
|
||||||
top50Ranking?: {
|
top50Ranking?: {
|
||||||
eu: Map<string, number>;
|
eu: Map<string, number>;
|
||||||
uk: Map<string, number>;
|
uk: Map<string, number>;
|
||||||
@@ -19,496 +21,241 @@ interface ForecastViewProps {
|
|||||||
stockMap?: Map<string, number>;
|
stockMap?: Map<string, number>;
|
||||||
stockFilter: string[];
|
stockFilter: string[];
|
||||||
onStockFilterChange: (newFilters: string[]) => void;
|
onStockFilterChange: (newFilters: string[]) => void;
|
||||||
|
customerFilters: string[];
|
||||||
|
top50Mode: 'eu' | 'uk';
|
||||||
}
|
}
|
||||||
|
|
||||||
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'];
|
||||||
|
|
||||||
// Top 50 Badge Component (reused logic)
|
|
||||||
// Top 50 Badge Component (matched to image)
|
|
||||||
const Top50Badge: React.FC<{ rank: number; label: string; theme?: 'amber' | 'indigo' | 'blue' }> = ({ rank, label }) => {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-[#8b5cf6] text-white border border-white/10 shadow-lg">
|
|
||||||
<span className="text-xs">🏆</span>
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-tighter">{label} {rank}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ForecastRow: React.FC<{
|
const ForecastRow: React.FC<{
|
||||||
item: ProductForecastData;
|
item: ProductForecastData;
|
||||||
activeMonths: string[];
|
activeMonths: string[];
|
||||||
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
||||||
|
top50Mode: 'eu' | 'uk';
|
||||||
stockMap?: Map<string, number>;
|
stockMap?: Map<string, number>;
|
||||||
}> = React.memo(({ item, activeMonths, top50Ranking, stockMap }) => {
|
}> = React.memo(({ item, activeMonths, top50Ranking, top50Mode, stockMap }) => {
|
||||||
const asin = item.asin.toUpperCase();
|
const asin = item.asin.trim().toUpperCase();
|
||||||
|
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
||||||
|
|
||||||
// Top 50 Badges logic
|
|
||||||
const ranks: { rank: number; label: string; theme: 'blue' | 'indigo' }[] = [];
|
|
||||||
if (top50Ranking) {
|
if (top50Ranking) {
|
||||||
const rankEU = top50Ranking.eu.get(asin);
|
if (top50Mode === 'eu') {
|
||||||
const rankUK = top50Ranking.uk.get(asin);
|
const rankEU = top50Ranking.eu.get(asin);
|
||||||
if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' });
|
if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' });
|
||||||
if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' });
|
} else {
|
||||||
|
const rankUK = top50Ranking.uk.get(asin);
|
||||||
|
if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monthly Forecast Calculation
|
const monthlySales = activeMonths.map(m => item.monthlyData[m]?.units || 0);
|
||||||
const filteredForecast = item.monthlyData
|
const avgMonthly = monthlySales.reduce((a, b) => a + b, 0) / (activeMonths.length || 1);
|
||||||
.filter(md => activeMonths.includes(md.month))
|
const peakMonthly = Math.max(...monthlySales, 0);
|
||||||
.reduce((acc, md) => acc + md.forecastUnits, 0);
|
|
||||||
|
|
||||||
// Actual Sales for 2026 (Selected Period)
|
|
||||||
const actualPeriod = item.monthlyData
|
|
||||||
.filter(md => activeMonths.includes(md.month))
|
|
||||||
.reduce((acc, md) => acc + md.actualUnits, 0);
|
|
||||||
|
|
||||||
// Total annual actual for achievement percentage
|
|
||||||
const actualTotal = item.monthlyData.reduce((acc, md) => acc + md.actualUnits, 0);
|
|
||||||
const totalAchievement = item.annualForecast > 0 ? (actualTotal / item.annualForecast) * 100 : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={item.asin} className="hover:bg-indigo-500/5 transition-colors group">
|
<tr key={item.asin} className="hover:bg-indigo-500/5 transition-colors group">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
{ranks.map((r, i) => (
|
{ranks.map((r, i) => (
|
||||||
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
|
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
|
||||||
))}
|
))}
|
||||||
<span className="font-black text-sm text-[#818cf8] uppercase tracking-tight">{item.sku}</span>
|
<span className="text-xs font-black text-indigo-400 tracking-tighter uppercase">
|
||||||
|
{item.sku}
|
||||||
|
</span>
|
||||||
<div className="px-2 py-0.5 rounded bg-slate-800/80 border border-white/5 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
<div className="px-2 py-0.5 rounded bg-slate-800/80 border border-white/5 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||||
{item.asin}
|
{item.asin}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2 text-[13px] font-medium text-slate-100 leading-snug group-hover:text-white transition-colors">
|
<span className="text-xs text-white/70 line-clamp-1 group-hover:line-clamp-none transition-all" title={item.title}>{item.title}</span>
|
||||||
<span className="truncate max-w-[170px]">{item.title}</span>
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="px-2 py-0.5 rounded bg-indigo-500/10 text-[9px] font-black text-indigo-300 uppercase tracking-widest border border-indigo-500/20">{item.line}</span>
|
||||||
{stockMap && (
|
{stockMap && (
|
||||||
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
|
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] font-black text-[#ec4899] uppercase tracking-[0.15em]">
|
|
||||||
{item.line}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right font-mono font-bold text-slate-400">
|
<td className="px-6 py-4 text-center">
|
||||||
{filteredForecast.toLocaleString('de-DE')}
|
<span className="text-sm font-black text-white">{item.actualUnits.toLocaleString('de-DE')}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right font-mono font-bold text-emerald-400">
|
<td className="px-6 py-4 text-center">
|
||||||
{actualPeriod.toLocaleString('de-DE')}
|
<span className="text-sm font-black text-emerald-400">{item.forecastUnits.toLocaleString('de-DE')}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-center">
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-center">
|
||||||
<span className={`px-2 py-1 rounded text-xs font-black ${totalAchievement >= 50 ? 'bg-emerald-500/10 text-emerald-400' : totalAchievement >= 10 ? 'bg-indigo-500/10 text-indigo-400' : 'bg-slate-800 text-slate-500'}`}>
|
<span className="text-sm font-black text-indigo-400">{Math.round(avgMonthly).toLocaleString('de-DE')}</span>
|
||||||
{totalAchievement.toFixed(1)}%
|
<span className="text-[9px] text-slate-500 font-bold uppercase tracking-tighter">Peak: {Math.round(peakMonthly).toLocaleString('de-DE')}</span>
|
||||||
</span>
|
|
||||||
<span className="text-[9px] text-slate-500 font-bold uppercase">of Annual {item.annualForecast.toLocaleString('de-DE')}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="h-10 w-full min-w-[120px]">
|
||||||
<div className="w-24 h-1.5 bg-slate-800 rounded-full overflow-hidden border border-white/5">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={activeMonths.map(m => ({ name: m, units: item.monthlyData[m]?.units || 0 }))}>
|
||||||
|
<Bar dataKey="units" fill="#6366f1" radius={[2, 2, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className={`text-sm font-black ${item.accuracy >= 80 ? 'text-emerald-400' : item.accuracy >= 50 ? 'text-amber-400' : 'text-rose-400'}`}>
|
||||||
|
{item.accuracy}%
|
||||||
|
</span>
|
||||||
|
<div className="w-16 h-1.5 bg-slate-800 rounded-full mt-1 overflow-hidden border border-white/5">
|
||||||
<div
|
<div
|
||||||
className={`h-full transition-all duration-500 rounded-full ${totalAchievement >= 100 ? 'bg-emerald-500' : totalAchievement >= 50 ? 'bg-indigo-500' : 'bg-amber-500/50'}`}
|
className={`h-full transition-all duration-1000 ${item.accuracy >= 80 ? 'bg-emerald-500' : item.accuracy >= 50 ? 'bg-amber-500' : 'bg-rose-500'}`}
|
||||||
style={{ width: `${Math.min(100, totalAchievement)}%` }}
|
style={{ width: `${item.accuracy}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{totalAchievement >= 100 ? (
|
|
||||||
<div className="px-1.5 py-0.5 rounded text-[8px] font-black bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 uppercase tracking-tighter animate-pulse">
|
|
||||||
Target Hit
|
|
||||||
</div>
|
|
||||||
) : totalAchievement > 0 ? (
|
|
||||||
<div className="text-[9px] font-bold text-slate-500 italic">
|
|
||||||
{totalAchievement.toFixed(0)}% Done
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange }) => {
|
const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange, customerFilters, top50Mode }) => {
|
||||||
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 [displayCount, setDisplayCount] = useState(50);
|
const [displayCount, setDisplayCount] = useState(50);
|
||||||
|
|
||||||
// Auto-detect best Top 50 mode based on data
|
|
||||||
useEffect(() => {
|
|
||||||
const hasUK = data.some(p => p.line.toLowerCase().includes('uk') || (p.monthlyData && p.actualUnits > 0 && filters.customer.some(c => c.toLowerCase().includes('uk'))));
|
|
||||||
const hasEU = data.some(p => !p.line.toLowerCase().includes('uk'));
|
|
||||||
|
|
||||||
// Simplified detection: if we have a lot of EU products, default EU, otherwise check UK
|
|
||||||
if (hasUK && !hasEU) setTop50Mode('uk');
|
|
||||||
}, [data, filters.customer]);
|
|
||||||
|
|
||||||
// Determine the "Current Month" based on available 2026 data
|
|
||||||
const lastDataMonthIdx = useMemo(() => {
|
const lastDataMonthIdx = useMemo(() => {
|
||||||
let maxIdx = 0; // Default Jan
|
for (let i = MONTH_ORDER.length - 1; i >= 0; i--) {
|
||||||
data.forEach(p => {
|
if (data.some(p => p.monthlyData[MONTH_ORDER[i]]?.units > 0)) return i;
|
||||||
p.monthlyData.forEach((md, idx) => {
|
}
|
||||||
if (md.actualUnits > 0 && idx > maxIdx) {
|
return 0;
|
||||||
maxIdx = idx;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return maxIdx;
|
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
// Active months for "Monthly Forecast" calculation
|
|
||||||
const activeMonths = useMemo(() => {
|
const activeMonths = useMemo(() => {
|
||||||
if (filters.month && filters.month.length > 0) {
|
if (filters.month && filters.month.length > 0) {
|
||||||
// Map e.g. "Apr-24" or just "Apr" to the short name
|
|
||||||
return filters.month.map(m => m.split('-')[0]);
|
return filters.month.map(m => m.split('-')[0]);
|
||||||
}
|
}
|
||||||
// If no filter, use Jan to lastDataMonth
|
|
||||||
return MONTH_ORDER.slice(0, lastDataMonthIdx + 1);
|
return MONTH_ORDER.slice(0, lastDataMonthIdx + 1);
|
||||||
}, [filters.month, lastDataMonthIdx]);
|
}, [filters.month, lastDataMonthIdx]);
|
||||||
|
|
||||||
// Base Global Filtering (Line, ASIN, SKU, Title)
|
|
||||||
const baseFilteredData = useMemo(() => {
|
const baseFilteredData = useMemo(() => {
|
||||||
let result = data;
|
let result = data;
|
||||||
|
if (filters.line && filters.line.length > 0) result = result.filter(p => filters.line.includes(p.line));
|
||||||
if (filters.line && filters.line.length > 0) {
|
if (filters.asin && filters.asin.length > 0) result = result.filter(p => filters.asin.includes(p.asin));
|
||||||
result = result.filter(p => filters.line.includes(p.line));
|
if (filters.sku && filters.sku.length > 0) result = result.filter(p => filters.sku.includes(p.sku));
|
||||||
}
|
|
||||||
if (filters.asin && filters.asin.length > 0) {
|
|
||||||
result = result.filter(p => filters.asin.includes(p.asin));
|
|
||||||
}
|
|
||||||
if (filters.sku && filters.sku.length > 0) {
|
|
||||||
result = result.filter(p => filters.sku.includes(p.sku));
|
|
||||||
}
|
|
||||||
if (filters.title && filters.title.length > 0) {
|
|
||||||
result = result.filter(p => filters.title.includes(p.title));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [data, filters.line, filters.asin, filters.sku, filters.title]);
|
}, [data, filters.line, filters.asin, filters.sku]);
|
||||||
|
|
||||||
// Global Aggregate Data (respects filters) - Single Pass Optimization
|
const globalSummary = useMemo(() => {
|
||||||
const { globalMonthlyData, globalSummary } = useMemo(() => {
|
return baseFilteredData.reduce((acc, curr) => ({
|
||||||
const monthlyStats = MONTH_ORDER.map(m => ({ name: m, Forecast: 0, Actual: 0 }));
|
actualUnits: acc.actualUnits + curr.actualUnits,
|
||||||
let periodForecast = 0;
|
forecastUnits: acc.forecastUnits + curr.forecastUnits,
|
||||||
let periodActual = 0;
|
}), { actualUnits: 0, forecastUnits: 0 });
|
||||||
let annualForecast = 0;
|
}, [baseFilteredData]);
|
||||||
let annualActual = 0;
|
|
||||||
|
|
||||||
baseFilteredData.forEach(p => {
|
const finalFilteredData = useMemo(() => {
|
||||||
annualForecast += (p.annualForecast || 0);
|
|
||||||
p.monthlyData.forEach((md, idx) => {
|
|
||||||
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)) {
|
|
||||||
periodForecast += fc;
|
|
||||||
periodActual += units;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const periodFulfillment = periodForecast > 0 ? (periodActual / periodForecast) * 100 : 0;
|
|
||||||
const annualFulfillment = annualForecast > 0 ? (annualActual / annualForecast) * 100 : 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
globalMonthlyData: monthlyStats,
|
|
||||||
globalSummary: {
|
|
||||||
periodForecast,
|
|
||||||
periodActual,
|
|
||||||
periodFulfillment,
|
|
||||||
annualForecast,
|
|
||||||
annualActual,
|
|
||||||
annualFulfillment
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [baseFilteredData, activeMonths]);
|
|
||||||
|
|
||||||
|
|
||||||
const filteredProducts = useMemo(() => {
|
|
||||||
let result = baseFilteredData;
|
let result = baseFilteredData;
|
||||||
|
|
||||||
if (searchTerm) {
|
|
||||||
const s = searchTerm.toLowerCase();
|
|
||||||
result = result.filter(p =>
|
|
||||||
p.asin.toLowerCase().includes(s) ||
|
|
||||||
p.sku.toLowerCase().includes(s) ||
|
|
||||||
p.title.toLowerCase().includes(s)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showOnlyTop50 && top50Ranking) {
|
if (showOnlyTop50 && top50Ranking) {
|
||||||
const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
|
const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
|
||||||
result = result.filter(p => currentRankMap.has(p.asin.toUpperCase()));
|
result = result.filter(p => currentRankMap.has(p.asin.toUpperCase()));
|
||||||
}
|
}
|
||||||
|
if (searchTerm) {
|
||||||
return result;
|
const s = searchTerm.toLowerCase();
|
||||||
}, [baseFilteredData, searchTerm, showOnlyTop50, top50Mode, top50Ranking]);
|
result = result.filter(p => p.sku.toLowerCase().includes(s) || p.asin.toLowerCase().includes(s) || p.title.toLowerCase().includes(s));
|
||||||
|
|
||||||
const sortedProducts = useMemo(() => {
|
|
||||||
const result = [...filteredProducts];
|
|
||||||
|
|
||||||
if (showOnlyTop50 && top50Ranking) {
|
|
||||||
const currentRankMap = top50Mode === 'eu' ? top50Ranking.eu : top50Ranking.uk;
|
|
||||||
result.sort((a, b) => {
|
|
||||||
const rankA = currentRankMap.get(a.asin.toUpperCase()) || 999;
|
|
||||||
const rankB = currentRankMap.get(b.asin.toUpperCase()) || 999;
|
|
||||||
return rankA - rankB;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
result.sort((a, b) => b.annualForecast - a.annualForecast);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [filteredProducts, showOnlyTop50, top50Mode, top50Ranking]);
|
}, [baseFilteredData, searchTerm, showOnlyTop50, top50Ranking, top50Mode]);
|
||||||
|
|
||||||
const handleExportExcel = useCallback(() => {
|
const handleExportExcel = useCallback(() => {
|
||||||
const exportData = sortedProducts.map(p => {
|
const exportData = finalFilteredData.map(p => ({
|
||||||
const rowData: any = {
|
SKU: p.sku,
|
||||||
ASIN: p.asin,
|
ASIN: p.asin,
|
||||||
SKU: p.sku,
|
Title: p.title,
|
||||||
Title: p.title,
|
Line: p.line,
|
||||||
'Product Line': p.line,
|
'Actual Units (2025)': p.actualUnits,
|
||||||
'Annual Forecast': Math.round(p.annualForecast),
|
'Forecast Units (2025)': p.forecastUnits,
|
||||||
'Annual Actual': Math.round(p.actualUnits),
|
'Accuracy (%)': p.accuracy
|
||||||
'Annual Fulfillment (%)': p.annualForecast > 0 ? Number(((p.actualUnits / p.annualForecast) * 100).toFixed(1)) : 0,
|
}));
|
||||||
'Period Forecast': 0,
|
|
||||||
'Period Actual': 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
p.monthlyData.forEach(md => {
|
|
||||||
if (activeMonths.includes(md.month)) {
|
|
||||||
rowData['Period Forecast'] += md.forecastUnits;
|
|
||||||
rowData['Period Actual'] += md.actualUnits;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
rowData['Period Fulfillment (%)'] = rowData['Period Forecast'] > 0
|
|
||||||
? Number(((rowData['Period Actual'] / rowData['Period Forecast']) * 100).toFixed(1))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Add monthly details
|
|
||||||
MONTH_ORDER.forEach(m => {
|
|
||||||
const md = p.monthlyData.find(d => d.month === m);
|
|
||||||
rowData[`${m} FC`] = md ? Math.round(md.forecastUnits) : 0;
|
|
||||||
rowData[`${m} ACT`] = md ? Math.round(md.actualUnits) : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
return rowData;
|
|
||||||
});
|
|
||||||
|
|
||||||
const ws = XLSX.utils.json_to_sheet(exportData);
|
const ws = XLSX.utils.json_to_sheet(exportData);
|
||||||
const wb = XLSX.utils.book_new();
|
const wb = XLSX.utils.book_new();
|
||||||
XLSX.utils.book_append_sheet(wb, ws, 'Forecast Overview');
|
XLSX.utils.book_append_sheet(wb, ws, 'Forecast');
|
||||||
XLSX.writeFile(wb, `Forecast_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
XLSX.writeFile(wb, `Forecast_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||||
}, [sortedProducts, activeMonths]);
|
}, [finalFilteredData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6 max-w-7xl mx-auto animate-fade-in pb-24">
|
<div className="flex flex-col gap-6 animate-fade-in">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{/* Summary Cards */}
|
<div className="bg-slate-900/50 backdrop-blur-xl border border-white/10 p-5 rounded-2xl shadow-xl">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
{/* Annual Forecast Card */}
|
<div className="p-2 bg-indigo-500/20 rounded-lg"><ChartIcon /></div>
|
||||||
<div className="bg-slate-900/50 border border-slate-800 rounded-2xl p-4 shadow-xl relative overflow-hidden group">
|
<span className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">Global Actuals</span>
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-slate-500/5 rounded-full -mr-16 -mt-16 blur-3xl"></div>
|
|
||||||
<h3 className="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-1">Annual Forecast Total</h3>
|
|
||||||
<div className="text-2xl font-black text-slate-300">
|
|
||||||
{globalSummary.annualForecast.toLocaleString('de-DE')} <span className="text-xs font-medium text-slate-600">Units</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-3xl font-black text-white tabular-nums">{globalSummary.actualUnits.toLocaleString('de-DE')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-slate-900/50 backdrop-blur-xl border border-white/10 p-5 rounded-2xl shadow-xl">
|
||||||
<div className="bg-slate-900 border border-border rounded-2xl p-4 shadow-xl relative overflow-hidden group">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-indigo-500/10 rounded-full -mr-16 -mt-16 blur-3xl group-hover:bg-indigo-500/20 transition-all"></div>
|
<div className="p-2 bg-emerald-500/20 rounded-lg"><TrendingIcon /></div>
|
||||||
<h3 className="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-1">Total Forecast (Period)</h3>
|
<span className="text-[10px] font-black text-emerald-400 uppercase tracking-widest">Global Forecast</span>
|
||||||
<div className="text-2xl font-black text-white">
|
|
||||||
{globalSummary.periodForecast.toLocaleString('de-DE')} <span className="text-xs font-medium text-slate-500">Units</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-900 border border-border rounded-2xl p-4 shadow-xl relative overflow-hidden group">
|
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-emerald-500/10 rounded-full -mr-16 -mt-16 blur-3xl group-hover:bg-emerald-500/20 transition-all"></div>
|
|
||||||
<h3 className="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-1">Total Actual Sales (Period)</h3>
|
|
||||||
<div className="text-2xl font-black text-emerald-400">
|
|
||||||
{globalSummary.periodActual.toLocaleString('de-DE')} <span className="text-xs font-medium text-slate-500">Units</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-900 border border-indigo-500/30 rounded-2xl p-4 shadow-xl relative overflow-hidden group">
|
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-indigo-500/20 rounded-full -mr-16 -mt-16 blur-3xl"></div>
|
|
||||||
<h3 className="text-[10px] font-black text-indigo-400 uppercase tracking-widest mb-1">Fulfillment (Period)</h3>
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<div className={`text-2xl font-black ${globalSummary.periodFulfillment >= 100 ? 'text-emerald-400' : 'text-indigo-400'}`}>
|
|
||||||
{globalSummary.periodFulfillment.toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Fulfillment Progress Bar */}
|
|
||||||
<div className="mt-2 w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-gradient-to-r from-indigo-500 to-fuchsia-500 shadow-[0_0_8px_rgba(99,102,241,0.5)] transition-all duration-1000"
|
|
||||||
style={{ width: `${Math.min(100, globalSummary.periodFulfillment)}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Annual Fulfillment Card */}
|
|
||||||
<div className="bg-slate-900/50 border border-slate-800 rounded-2xl p-4 shadow-xl relative overflow-hidden group">
|
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-slate-500/5 rounded-full -mr-16 -mt-16 blur-3xl"></div>
|
|
||||||
<h3 className="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-1">Annual Fulfillment %</h3>
|
|
||||||
<div className="text-2xl font-black text-slate-400">
|
|
||||||
{globalSummary.annualFulfillment.toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 w-full h-1.5 bg-slate-800/50 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-slate-700 transition-all duration-1000"
|
|
||||||
style={{ width: `${Math.min(100, globalSummary.annualFulfillment)}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-3xl font-black text-emerald-400 tabular-nums">{globalSummary.forecastUnits.toLocaleString('de-DE')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Trend Chart */}
|
<div className="bg-slate-900 border border-white/10 rounded-2xl overflow-hidden shadow-2xl flex-1 flex flex-col">
|
||||||
<div className="bg-slate-900 border border-border rounded-2xl p-6 shadow-xl">
|
<div className="p-6 border-b border-white/5 flex flex-col md:flex-row justify-between gap-4 bg-slate-800/20">
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
|
||||||
<svg className="w-5 h-5 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>
|
|
||||||
Monthly Evolution: Forecast vs Actual
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="h-96">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<ComposedChart data={globalMonthlyData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
|
|
||||||
<XAxis dataKey="name" stroke="#64748b" />
|
|
||||||
<YAxis stroke="#64748b" />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#1e293b', borderRadius: '12px', padding: '12px' }}
|
|
||||||
itemStyle={{ fontWeight: 'bold' }}
|
|
||||||
/>
|
|
||||||
<Legend verticalAlign="top" height={36} />
|
|
||||||
<Bar dataKey="Actual" fill="#10b981" radius={[4, 4, 0, 0]} name="Actual Sales" barSize={40} />
|
|
||||||
<Line type="monotone" dataKey="Forecast" stroke="#6366f1" strokeWidth={4} dot={{ r: 6, fill: '#6366f1' }} name="Forecast Target" />
|
|
||||||
<Area type="monotone" dataKey="Forecast" fill="#6366f1" fillOpacity={0.05} stroke="none" />
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Product Table */}
|
|
||||||
<div className="bg-slate-900 border border-border rounded-2xl shadow-xl overflow-hidden">
|
|
||||||
<div className="p-6 border-b border-border flex flex-col md:flex-row justify-between gap-4">
|
|
||||||
<h3 className="text-lg font-bold text-white uppercase tracking-tight">Product Performance Comparison</h3>
|
<h3 className="text-lg font-bold text-white uppercase tracking-tight">Product Performance Comparison</h3>
|
||||||
<div className="flex flex-col md:flex-row items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Top 50 Toggle */}
|
{top50Ranking && (top50Ranking.eu.size > 0 || top50Ranking.uk.size > 0) && (
|
||||||
<div className="flex items-center bg-slate-950 p-1 rounded-lg border border-slate-700">
|
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowOnlyTop50(!showOnlyTop50)}
|
onClick={() => setShowOnlyTop50(!showOnlyTop50)}
|
||||||
className={`px-3 py-1.5 rounded-md text-xs font-black transition-all flex items-center gap-2 ${showOnlyTop50 ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/20' : 'text-slate-500 hover:text-slate-300'}`}
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${showOnlyTop50 ? 'bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-lg' : 'text-slate-400 hover:text-amber-400'}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm">🏆</span>
|
<span className="text-sm">🏆</span>
|
||||||
Top 50
|
Top 50 ({top50Mode.toUpperCase()})
|
||||||
</button>
|
</button>
|
||||||
{showOnlyTop50 && (
|
</div>
|
||||||
<div className="flex items-center ml-1 border-l border-slate-700 pl-1">
|
)}
|
||||||
<button
|
<input
|
||||||
onClick={() => setTop50Mode('eu')}
|
type="text"
|
||||||
className={`px-2 py-1 rounded text-[10px] font-bold transition-colors ${top50Mode === 'eu' ? 'text-indigo-400 bg-indigo-500/10' : 'text-slate-600 hover:text-slate-400'}`}
|
placeholder="Search..."
|
||||||
>
|
value={searchTerm}
|
||||||
EU
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
</button>
|
className="bg-slate-950 border border-white/10 rounded-xl px-4 py-2 text-sm text-white focus:outline-none w-64"
|
||||||
<button
|
/>
|
||||||
onClick={() => setTop50Mode('uk')}
|
<button onClick={handleExportExcel} className="p-2 bg-slate-800 hover:bg-slate-700 text-white rounded-xl border border-white/5 transition-all"><DownloadIcon /></button>
|
||||||
className={`px-2 py-1 rounded text-[10px] font-bold transition-colors ${top50Mode === 'uk' ? 'text-indigo-400 bg-indigo-500/10' : 'text-slate-600 hover:text-slate-400'}`}
|
|
||||||
>
|
|
||||||
UK
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by ASIN, SKU or Title..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
className="bg-slate-950 border border-slate-700 rounded-lg px-10 py-2 text-sm text-slate-200 focus:outline-none focus:border-indigo-500 w-full md:w-80"
|
|
||||||
/>
|
|
||||||
<svg className="absolute left-3 top-2.5 w-4 h-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><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>
|
|
||||||
<button
|
|
||||||
onClick={handleExportExcel}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs font-bold border border-white/10 transition-all shadow-lg active:scale-95"
|
|
||||||
title="Export to Excel"
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
<span>Export</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
<div className="overflow-x-auto custom-scrollbar">
|
||||||
<thead className="bg-slate-950 text-slate-500 uppercase text-[10px] font-black tracking-widest border-b border-border sticky top-0 z-20">
|
<table className="w-full text-left border-collapse">
|
||||||
<tr className="relative z-30">
|
<thead className="bg-slate-800/40">
|
||||||
<th className="px-6 py-4">
|
<tr>
|
||||||
|
<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 Info</span>
|
<span>Product Details</span>
|
||||||
<InColumnStockFilter
|
<InColumnStockFilter currentFilters={stockFilter} onFilterChange={onStockFilterChange} />
|
||||||
currentFilters={stockFilter}
|
|
||||||
onFilterChange={onStockFilterChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-4 text-right">Forecast (Period)</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 text-right">Actual Units Sales 2026</th>
|
<th className="px-6 py-4 font-black text-slate-400 uppercase tracking-widest text-[10px] text-center">Forecast</th>
|
||||||
<th className="px-6 py-4 text-right">Fc 26 Achievement</th>
|
<th className="px-6 py-4 font-black text-slate-400 uppercase tracking-widest text-[10px] text-center">Avg/Mo</th>
|
||||||
<th className="px-6 py-4 text-center">YTD Achievement</th>
|
<th className="px-6 py-4 font-black text-slate-400 uppercase tracking-widest text-[10px]">Trend</th>
|
||||||
|
<th className="px-6 py-4 font-black text-slate-400 uppercase tracking-widest text-[10px] text-center">Accuracy</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800">
|
<tbody className="divide-y divide-white/5">
|
||||||
{sortedProducts.length > 0 ? (
|
{finalFilteredData.slice(0, displayCount).map(p => (
|
||||||
(() => {
|
<ForecastRow
|
||||||
const displayItems = sortedProducts.slice(0, displayCount);
|
key={p.asin}
|
||||||
return (
|
item={p}
|
||||||
<>
|
activeMonths={activeMonths}
|
||||||
{displayItems.map(p => (
|
top50Ranking={top50Ranking}
|
||||||
<ForecastRow
|
top50Mode={top50Mode}
|
||||||
key={p.asin}
|
stockMap={stockMap}
|
||||||
item={p}
|
/>
|
||||||
activeMonths={activeMonths}
|
))}
|
||||||
top50Ranking={top50Ranking}
|
|
||||||
stockMap={stockMap}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{displayCount < sortedProducts.length && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="px-6 py-6 text-center bg-slate-900/40 backdrop-blur-sm border-t border-white/5">
|
|
||||||
<button
|
|
||||||
onClick={() => setDisplayCount(prev => prev + 100)}
|
|
||||||
className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-black uppercase tracking-widest shadow-xl transition-all active:scale-95 border border-indigo-400/30"
|
|
||||||
>
|
|
||||||
Load More SKUs ({sortedProducts.length - displayCount} remaining)
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="px-6 py-10 text-center text-slate-500 italic">
|
|
||||||
No products found matching your filters...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{displayCount < finalFilteredData.length && (
|
||||||
|
<div className="p-6 text-center bg-slate-800/20">
|
||||||
|
<button onClick={() => setDisplayCount(prev => prev + 50)} className="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-black uppercase tracking-widest">Load More</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Top50BadgeProps {
|
||||||
|
rank: number;
|
||||||
|
label: string;
|
||||||
|
theme?: 'amber' | 'blue' | 'indigo';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Top50Badge: React.FC<Top50BadgeProps> = ({ rank, label, theme = 'amber' }) => {
|
||||||
|
const themeClasses = {
|
||||||
|
amber: 'from-amber-500 to-orange-500 border-amber-400/50',
|
||||||
|
blue: 'from-blue-500 to-cyan-500 border-blue-400/50',
|
||||||
|
indigo: 'from-indigo-500 to-purple-500 border-indigo-400/50',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center px-1.5 py-0.5 rounded text-[9px] font-black bg-gradient-to-r ${themeClasses[theme]} text-white shadow-sm border whitespace-nowrap`}
|
||||||
|
title={`Top ${rank} Best Seller 2025 (${label})`}
|
||||||
|
>
|
||||||
|
<span className="mr-0.5">🏆</span>
|
||||||
|
<span className="mr-0.5 opacity-90">{label}</span>
|
||||||
|
{rank}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { CombinedKPIs } from '../types';
|
|||||||
import { pivotWeeklySalesData, WeeklyPivotRow, PAN_EU_COUNTRIES } from '../services/dataProcessor';
|
import { pivotWeeklySalesData, WeeklyPivotRow, PAN_EU_COUNTRIES } from '../services/dataProcessor';
|
||||||
import { StockBadge } from './StockBadge';
|
import { StockBadge } from './StockBadge';
|
||||||
import { InColumnStockFilter } from './InColumnStockFilter';
|
import { InColumnStockFilter } from './InColumnStockFilter';
|
||||||
|
import { Top50Badge } from './Top50Badge';
|
||||||
|
|
||||||
interface WeeklyGridProps {
|
interface WeeklyGridProps {
|
||||||
data: CombinedKPIs[];
|
data: CombinedKPIs[];
|
||||||
@@ -37,26 +38,6 @@ const useDebounce = (value: string, delay: number) => {
|
|||||||
return debouncedValue;
|
return debouncedValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Top 50 Badge Component
|
|
||||||
const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'blue' | 'indigo' }> = ({ rank, label, theme = 'amber' }) => {
|
|
||||||
const themeClasses = {
|
|
||||||
amber: 'from-amber-500 to-orange-500 border-amber-400/50',
|
|
||||||
blue: 'from-blue-500 to-cyan-500 border-blue-400/50',
|
|
||||||
indigo: 'from-indigo-500 to-purple-500 border-indigo-400/50',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center justify-center px-1.5 py-0.5 rounded text-[9px] font-black bg-gradient-to-r ${themeClasses[theme]} text-white shadow-sm border`}
|
|
||||||
title={`Top ${rank} Best Seller 2025 ${label ? `(${label})` : ''}`}
|
|
||||||
>
|
|
||||||
<span className="mr-0.5">🏆</span>
|
|
||||||
{label && <span className="mr-0.5 opacity-90">{label}</span>}
|
|
||||||
{rank}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const WeeklyRow: React.FC<{
|
const WeeklyRow: React.FC<{
|
||||||
row: WeeklyPivotRow;
|
row: WeeklyPivotRow;
|
||||||
weeks: string[];
|
weeks: string[];
|
||||||
@@ -71,22 +52,13 @@ const WeeklyRow: React.FC<{
|
|||||||
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
||||||
const asin = row.asin.trim().toUpperCase();
|
const asin = row.asin.trim().toUpperCase();
|
||||||
|
|
||||||
const isUKSelected = customerFilters.some(c => c.toLowerCase().includes('uk'));
|
|
||||||
const isEUPartialSelected = customerFilters.some(c => PAN_EU_COUNTRIES.includes(c));
|
|
||||||
const hasNoCustomerFilter = customerFilters.length === 0;
|
|
||||||
|
|
||||||
if (top50Ranking) {
|
if (top50Ranking) {
|
||||||
// Only show UK rank if UK is explicitly selected
|
if (top50Mode === 'eu') {
|
||||||
if (top50Mode === 'uk' && isUKSelected) {
|
|
||||||
const rank = top50Ranking.uk.get(asin);
|
|
||||||
if (rank) ranks.push({ rank, label: 'UK', theme: 'blue' });
|
|
||||||
}
|
|
||||||
// Only show EU rank if at least one Pan-EU country is selected
|
|
||||||
// OR if no filters are selected (User might want to see them all then, but user said "only when... is selected")
|
|
||||||
// Actually the prompt says: "only appear when in the filters... is selected"
|
|
||||||
else if (top50Mode === 'eu' && isEUPartialSelected) {
|
|
||||||
const rank = top50Ranking.eu.get(asin);
|
const rank = top50Ranking.eu.get(asin);
|
||||||
if (rank) ranks.push({ rank, label: 'EU', theme: 'indigo' });
|
if (rank) ranks.push({ rank, label: 'EU', theme: 'indigo' });
|
||||||
|
} else {
|
||||||
|
const rank = top50Ranking.uk.get(asin);
|
||||||
|
if (rank) ranks.push({ rank, label: 'UK', theme: 'blue' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +129,7 @@ const WeeklyRow: React.FC<{
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown, stockMap, stockFilter, onStockFilterChange, customerFilters }) => {
|
const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({ data, top50Ranking, onDrillDown, stockMap, stockFilter, onStockFilterChange, customerFilters, top50Mode }) => {
|
||||||
// Pivot data - memoized
|
// Pivot data - memoized
|
||||||
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
|
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
|
||||||
|
|
||||||
@@ -170,7 +142,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
|
|||||||
const [growthFilterMode, setGrowthFilterMode] = useState<'all' | 'up' | 'down' | 'stable'>('all');
|
const [growthFilterMode, setGrowthFilterMode] = useState<'all' | 'up' | 'down' | 'stable'>('all');
|
||||||
const [growthThreshold, setGrowthThreshold] = useState(10);
|
const [growthThreshold, setGrowthThreshold] = useState(10);
|
||||||
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
||||||
const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu');
|
|
||||||
const [displayCount, setDisplayCount] = useState(50);
|
const [displayCount, setDisplayCount] = useState(50);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -185,20 +156,9 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
|
|||||||
// Reset pagination when search changes
|
// Reset pagination when search changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
setDisplayCount(50);
|
||||||
}, [debouncedSearch, showOnlyTop50, top50Mode]);
|
}, [debouncedSearch, showOnlyTop50, top50Mode]);
|
||||||
|
|
||||||
// Auto-detect best Top 50 mode based on currently selected data
|
|
||||||
useEffect(() => {
|
|
||||||
const hasUK = rows.some(r => r.customer.toLowerCase().includes('uk'));
|
|
||||||
const hasEU = rows.some(r => !r.customer.toLowerCase().includes('uk'));
|
|
||||||
|
|
||||||
if (hasUK && !hasEU) {
|
|
||||||
setTop50Mode('uk');
|
|
||||||
} else if (hasEU && !hasUK) {
|
|
||||||
setTop50Mode('eu');
|
|
||||||
}
|
|
||||||
}, [rows]);
|
|
||||||
|
|
||||||
const handleSort = useCallback((weekKey: string, metric: 'units' | 'spend' | 'rank' | 'gv') => {
|
const handleSort = useCallback((weekKey: string, metric: 'units' | 'spend' | 'rank' | 'gv') => {
|
||||||
setSortConfig(prev => {
|
setSortConfig(prev => {
|
||||||
if (prev?.key === weekKey && prev.metric === metric) {
|
if (prev?.key === weekKey && prev.metric === metric) {
|
||||||
@@ -287,7 +247,7 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [rows, debouncedSearch, showOnlyTop50, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks]);
|
}, [rows, debouncedSearch, showOnlyTop50, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode]);
|
||||||
|
|
||||||
// 2. Sort results
|
// 2. Sort results
|
||||||
const sortedRows = useMemo(() => {
|
const sortedRows = useMemo(() => {
|
||||||
@@ -359,7 +319,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
|
|||||||
return rowData;
|
return rowData;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add Totals row
|
|
||||||
const totalsRow: any = {
|
const totalsRow: any = {
|
||||||
SKU: 'TOTALS',
|
SKU: 'TOTALS',
|
||||||
ASIN: '',
|
ASIN: '',
|
||||||
@@ -443,25 +402,8 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm">🏆</span>
|
<span className="text-sm">🏆</span>
|
||||||
Top 50
|
Top 50 ({top50Mode.toUpperCase()})
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showOnlyTop50 && (
|
|
||||||
<div className="flex ml-1 p-0.5 bg-slate-900 rounded-lg border border-white/5">
|
|
||||||
<button
|
|
||||||
onClick={() => setTop50Mode('eu')}
|
|
||||||
className={`px-2 py-1 rounded-md text-[10px] font-black uppercase tracking-widest transition-all ${top50Mode === 'eu' ? 'bg-indigo-500 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'}`}
|
|
||||||
>
|
|
||||||
EU
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setTop50Mode('uk')}
|
|
||||||
className={`px-2 py-1 rounded-md text-[10px] font-black uppercase tracking-widest transition-all ${top50Mode === 'uk' ? 'bg-blue-500 text-white shadow-sm' : 'text-slate-500 hover:text-slate-300'}`}
|
|
||||||
>
|
|
||||||
UK
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -531,12 +473,10 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
|
|||||||
className={`p-0 text-[10px] font-black uppercase tracking-widest text-center border-r border-white/10 min-w-[130px] transition-colors select-none ${sortConfig?.key === week ? 'bg-white/[0.02]' : ''}`}
|
className={`p-0 text-[10px] font-black uppercase tracking-widest text-center border-r border-white/10 min-w-[130px] transition-colors select-none ${sortConfig?.key === week ? 'bg-white/[0.02]' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Week Label */}
|
|
||||||
<div className="p-2 border-b border-white/5 bg-slate-800/30 text-xs text-white">
|
<div className="p-2 border-b border-white/5 bg-slate-800/30 text-xs text-white">
|
||||||
{week.split('-')[1]}/{week.split('-')[0].slice(-2)}
|
{week.split('-')[1]}/{week.split('-')[0].slice(-2)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Units Sort Trigger */}
|
|
||||||
<div
|
<div
|
||||||
onClick={() => handleSort(week, 'units')}
|
onClick={() => handleSort(week, 'units')}
|
||||||
className={`flex-1 p-1.5 cursor-pointer hover:bg-indigo-500/10 transition-colors flex items-center justify-center gap-1 border-b border-white/5 ${sortConfig?.key === week && sortConfig.metric === 'units' ? 'bg-indigo-500/5 text-indigo-400' : 'text-slate-500 hover:text-slate-300'}`}
|
className={`flex-1 p-1.5 cursor-pointer hover:bg-indigo-500/10 transition-colors flex items-center justify-center gap-1 border-b border-white/5 ${sortConfig?.key === week && sortConfig.metric === 'units' ? 'bg-indigo-500/5 text-indigo-400' : 'text-slate-500 hover:text-slate-300'}`}
|
||||||
@@ -547,7 +487,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spend Sort Trigger */}
|
|
||||||
<div
|
<div
|
||||||
onClick={() => handleSort(week, 'spend')}
|
onClick={() => handleSort(week, 'spend')}
|
||||||
className={`flex-1 p-1.5 cursor-pointer hover:bg-amber-500/10 transition-colors flex items-center justify-center gap-1 border-b border-white/5 ${sortConfig?.key === week && sortConfig.metric === 'spend' ? 'bg-amber-500/5 text-amber-400' : 'text-slate-500 hover:text-slate-300'}`}
|
className={`flex-1 p-1.5 cursor-pointer hover:bg-amber-500/10 transition-colors flex items-center justify-center gap-1 border-b border-white/5 ${sortConfig?.key === week && sortConfig.metric === 'spend' ? 'bg-amber-500/5 text-amber-400' : 'text-slate-500 hover:text-slate-300'}`}
|
||||||
@@ -558,7 +497,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* GV Sort Trigger */}
|
|
||||||
<div
|
<div
|
||||||
onClick={() => handleSort(week, 'gv')}
|
onClick={() => handleSort(week, 'gv')}
|
||||||
className={`flex-1 p-1.5 cursor-pointer hover:bg-teal-500/10 transition-colors flex items-center justify-center gap-1 ${sortConfig?.key === week && sortConfig.metric === 'gv' ? 'bg-teal-500/5 text-teal-400' : 'text-slate-500 hover:text-slate-300'}`}
|
className={`flex-1 p-1.5 cursor-pointer hover:bg-teal-500/10 transition-colors flex items-center justify-center gap-1 ${sortConfig?.key === week && sortConfig.metric === 'gv' ? 'bg-teal-500/5 text-teal-400' : 'text-slate-500 hover:text-slate-300'}`}
|
||||||
|
|||||||
Reference in New Issue
Block a user