feat: wire MKT tab to real data sources

Replace all mock data in the Profitability Dashboard with live data:
- Add 3 new API routes (fetch-deals, fetch-promos, fetch-chargebacks) proxying Dropbox Excel files
- MktDataView now accepts rawData/adsData props and fetches+parses the 3 new files on mount
- SELL OUT and units aggregated globally across all marketplaces from rawData
- PPC Spend aggregated globally from adsData; ACoS calculated as spend/sales
- Deals (col A→C), Promos (col E→K), Chargebacks (col AN→B) parsed and summed per ASIN
- Category filter populated from product line metadata
- Loading skeleton shown while files are fetching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-03-11 12:34:07 +01:00
co-authored by Claude Sonnet 4.6
parent 5d0edfba5f
commit 49498c5c51
5 changed files with 364 additions and 23 deletions
+1 -1
View File
@@ -926,7 +926,7 @@ const App: React.FC = () => {
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'mkt' ? '' : 'hidden'}>
<MktDataView />
<MktDataView rawData={rawData} adsData={adsData} />
</div>
</Suspense>
+37
View File
@@ -0,0 +1,37 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
const CHARGEBACKS_DROPBOX_URL = "https://www.dropbox.com/scl/fi/m2qr2cxhtgkc48acjrrgx/Amazon-Operational-Chargebacks-2025.xlsx?rlkey=vlrz4qfydsjtikgh0ylivocjq&st=zdhmjvaz&dl=1";
export default async function handler(req: VercelRequest, res: VercelResponse) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
try {
console.log('[fetch-chargebacks] Fetching Chargebacks from Dropbox...');
const response = await fetch(CHARGEBACKS_DROPBOX_URL, {
cache: 'no-store',
headers: {
'Pragma': 'no-cache',
'Cache-Control': 'no-cache'
}
});
if (!response.ok) {
throw new Error(`Dropbox responded with ${response.status}`);
}
const buffer = await response.arrayBuffer();
console.log('[fetch-chargebacks] Successfully fetched Chargebacks Excel, size:', buffer.byteLength);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.status(200).send(Buffer.from(buffer));
} catch (error: any) {
console.error('[fetch-chargebacks] Error:', error);
res.status(500).json({ error: error.message });
}
}
+37
View File
@@ -0,0 +1,37 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
const DEALS_DROPBOX_URL = "https://www.dropbox.com/scl/fi/3mm5m4e04myhqt59piefs/Deals-Amazon-2025.xlsx?rlkey=6llr0xhs6di0c3d8o96ashzmv&st=ggu0z7hu&dl=1";
export default async function handler(req: VercelRequest, res: VercelResponse) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
try {
console.log('[fetch-deals] Fetching Deals from Dropbox...');
const response = await fetch(DEALS_DROPBOX_URL, {
cache: 'no-store',
headers: {
'Pragma': 'no-cache',
'Cache-Control': 'no-cache'
}
});
if (!response.ok) {
throw new Error(`Dropbox responded with ${response.status}`);
}
const buffer = await response.arrayBuffer();
console.log('[fetch-deals] Successfully fetched Deals Excel, size:', buffer.byteLength);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.status(200).send(Buffer.from(buffer));
} catch (error: any) {
console.error('[fetch-deals] Error:', error);
res.status(500).json({ error: error.message });
}
}
+37
View File
@@ -0,0 +1,37 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
const PROMOS_DROPBOX_URL = "https://www.dropbox.com/scl/fi/ufhej9do9w839oyyfrpk3/Promos_Amazon_2025_all-countries.xlsx?rlkey=4foano9dt5h3sl58l35vqg89d&st=wu0t56wh&dl=1";
export default async function handler(req: VercelRequest, res: VercelResponse) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
try {
console.log('[fetch-promos] Fetching Promos from Dropbox...');
const response = await fetch(PROMOS_DROPBOX_URL, {
cache: 'no-store',
headers: {
'Pragma': 'no-cache',
'Cache-Control': 'no-cache'
}
});
if (!response.ok) {
throw new Error(`Dropbox responded with ${response.status}`);
}
const buffer = await response.arrayBuffer();
console.log('[fetch-promos] Successfully fetched Promos Excel, size:', buffer.byteLength);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.status(200).send(Buffer.from(buffer));
} catch (error: any) {
console.error('[fetch-promos] Error:', error);
res.status(500).json({ error: error.message });
}
}
+252 -22
View File
@@ -1,43 +1,273 @@
import React, { useState, useMemo } from 'react';
import { mockProducts } from './data';
import React, { useState, useMemo, useEffect } from 'react';
import * as XLSX from 'xlsx';
import { SalesRecord, AdsRecord } from '../../types';
import { Product } from './types';
import { KPICards } from './KPICards';
import { MasterTable } from './MasterTable';
import { WaterfallModal } from './WaterfallModal';
import { Filter, Settings2 } from 'lucide-react';
export default function MktDataView() {
// ---------------------------------------------------------------------------
// Excel parsing helpers (all files use raw: true so numbers come back as JS numbers)
// ---------------------------------------------------------------------------
function parseNumericCell(val: unknown): number {
if (typeof val === 'number') return isNaN(val) ? 0 : val;
if (typeof val === 'string') {
const clean = val.replace(/[€$£\s]/g, '').trim();
if (!clean) return 0;
// EU format: "1.234,56" — comma is decimal, dot is thousands
if (clean.includes(',') && clean.includes('.') && clean.indexOf(',') > clean.indexOf('.')) {
return parseFloat(clean.replace(/\./g, '').replace(',', '.')) || 0;
}
// EU format: "263,83" — only comma, treat as decimal
if (clean.includes(',') && !clean.includes('.')) {
return parseFloat(clean.replace(',', '.')) || 0;
}
return parseFloat(clean.replace(/,/g, '')) || 0;
}
return 0;
}
// Deals file: ASIN = col A (idx 0), total deal cost = col C (idx 2)
function parseDealsExcel(buffer: ArrayBuffer): Map<string, number> {
const wb = XLSX.read(buffer, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { header: 1, raw: true });
const map = new Map<string, number>();
for (let i = 1; i < rows.length; i++) {
const row = rows[i] as unknown[];
const asin = row[0];
const cost = row[2];
if (asin && typeof asin === 'string' && asin.trim()) {
const key = asin.trim().toUpperCase();
map.set(key, (map.get(key) || 0) + parseNumericCell(cost));
}
}
return map;
}
// Promos file: ASIN = col E (idx 4), promo cost = col K (idx 10)
function parsePromosExcel(buffer: ArrayBuffer): Map<string, number> {
const wb = XLSX.read(buffer, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { header: 1, raw: true });
const map = new Map<string, number>();
for (let i = 1; i < rows.length; i++) {
const row = rows[i] as unknown[];
const asin = row[4];
const cost = row[10];
if (asin && typeof asin === 'string' && asin.trim()) {
const key = asin.trim().toUpperCase();
map.set(key, (map.get(key) || 0) + parseNumericCell(cost));
}
}
return map;
}
// Chargebacks file: ASIN = col AN (idx 39), chargeback cost = col B (idx 1)
function parseChargebacksExcel(buffer: ArrayBuffer): Map<string, number> {
const wb = XLSX.read(buffer, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { header: 1, raw: true });
const map = new Map<string, number>();
for (let i = 1; i < rows.length; i++) {
const row = rows[i] as unknown[];
const asin = row[39];
const cost = row[1];
if (asin && typeof asin === 'string' && asin.trim()) {
const key = asin.trim().toUpperCase();
map.set(key, (map.get(key) || 0) + parseNumericCell(cost));
}
}
return map;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface MktDataViewProps {
rawData: SalesRecord[];
adsData: AdsRecord[];
}
export default function MktDataView({ rawData, adsData }: MktDataViewProps) {
const [includeCOGS, setIncludeCOGS] = useState(true);
const [selectedCategory, setSelectedCategory] = useState<string>('All');
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const categories = ['All', ...Array.from(new Set(mockProducts.map(p => p.category)))];
const [mktLoading, setMktLoading] = useState(true);
const [dealsMap, setDealsMap] = useState<Map<string, number>>(new Map());
const [promosMap, setPromosMap] = useState<Map<string, number>>(new Map());
const [chargebacksMap, setChargebacksMap] = useState<Map<string, number>>(new Map());
// Fetch the 3 new marketing data files once on mount
useEffect(() => {
let cancelled = false;
const fetchAll = async () => {
setMktLoading(true);
try {
const [dealsRes, promosRes, chargesRes] = await Promise.all([
fetch('/api/fetch-deals'),
fetch('/api/fetch-promos'),
fetch('/api/fetch-chargebacks'),
]);
if (cancelled) return;
if (dealsRes.ok) {
const buf = await dealsRes.arrayBuffer();
if (!cancelled) setDealsMap(parseDealsExcel(buf));
} else {
console.warn('[MktDataView] fetch-deals failed:', dealsRes.status);
}
if (promosRes.ok) {
const buf = await promosRes.arrayBuffer();
if (!cancelled) setPromosMap(parsePromosExcel(buf));
} else {
console.warn('[MktDataView] fetch-promos failed:', promosRes.status);
}
if (chargesRes.ok) {
const buf = await chargesRes.arrayBuffer();
if (!cancelled) setChargebacksMap(parseChargebacksExcel(buf));
} else {
console.warn('[MktDataView] fetch-chargebacks failed:', chargesRes.status);
}
} catch (e) {
console.error('[MktDataView] Error fetching marketing data:', e);
} finally {
if (!cancelled) setMktLoading(false);
}
};
fetchAll();
return () => { cancelled = true; };
}, []);
// Build Product[] from real data sources
const allProducts = useMemo((): Product[] => {
if (rawData.length === 0) return [];
// ASIN → best metadata (longest title wins)
const metaMap = new Map<string, { sku: string; title: string; line: string }>();
rawData.forEach(r => {
const asin = r.asin.trim().toUpperCase();
const existing = metaMap.get(asin);
if (!existing || (r.title && r.title.length > (existing.title?.length || 0))) {
metaMap.set(asin, { sku: r.sku || '', title: r.title || '', line: r.line || 'Other' });
}
});
// ASIN → global sell-out and units (all marketplaces summed)
const sellOutMap = new Map<string, number>();
const unitsMap = new Map<string, number>();
rawData.forEach(r => {
const asin = r.asin.trim().toUpperCase();
sellOutMap.set(asin, (sellOutMap.get(asin) || 0) + r.sellOut);
unitsMap.set(asin, (unitsMap.get(asin) || 0) + r.units);
});
// ASIN → global ad spend (all marketplaces summed)
const adsSpendMap = new Map<string, number>();
adsData.forEach(r => {
const asin = r.asin.trim().toUpperCase();
adsSpendMap.set(asin, (adsSpendMap.get(asin) || 0) + r.cost);
});
return Array.from(metaMap.entries())
.map(([asin, meta]): Product => ({
id: asin,
asin,
sku: meta.sku,
name: meta.title || asin,
image: '',
category: meta.line,
brand: '',
grossSales: sellOutMap.get(asin) || 0,
unitsSold: unitsMap.get(asin) || 0,
ppcSpend: adsSpendMap.get(asin) || 0,
deals: dealsMap.get(asin) || 0,
promos: promosMap.get(asin) || 0,
chargebacks: chargebacksMap.get(asin) || 0,
chargebacksPrevMonth: 0,
cogs: 0,
}))
// Only show products that have actual sales
.filter(p => p.grossSales > 0);
}, [rawData, adsData, dealsMap, promosMap, chargebacksMap]);
const categories = useMemo(
() => ['All', ...Array.from(new Set(allProducts.map(p => p.category))).sort()],
[allProducts]
);
const filteredProducts = useMemo(() => {
if (selectedCategory === 'All') return mockProducts;
return mockProducts.filter(p => p.category === selectedCategory);
}, [selectedCategory]);
if (selectedCategory === 'All') return allProducts;
return allProducts.filter(p => p.category === selectedCategory);
}, [allProducts, selectedCategory]);
// Reset category filter when product list changes (e.g. rawData load)
useEffect(() => {
setSelectedCategory('All');
}, [rawData]);
// ---------------------------------------------------------------------------
// Loading skeleton — shown while MKT files are being fetched or rawData is empty
// ---------------------------------------------------------------------------
if (rawData.length === 0 || mktLoading) {
return (
<div className="font-sans text-slate-200 px-4 md:px-6 pb-24">
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-2xl font-bold text-white">Profitability Dashboard</h2>
<p className="text-slate-400">Overview of product performance and margins.</p>
</div>
</div>
{/* Skeleton KPI cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-[#13161F] rounded-xl border border-[#1F2433] p-6 h-32 animate-pulse" />
))}
</div>
{/* Skeleton table */}
<div className="bg-[#13161F] rounded-xl border border-[#1F2433] overflow-hidden">
{[...Array(8)].map((_, i) => (
<div key={i} className="px-6 py-4 border-b border-[#1F2433] animate-pulse flex gap-4">
<div className="h-4 bg-[#1F2433] rounded w-1/3" />
<div className="h-4 bg-[#1F2433] rounded w-1/6 ml-auto" />
<div className="h-4 bg-[#1F2433] rounded w-1/6" />
<div className="h-4 bg-[#1F2433] rounded w-1/6" />
</div>
))}
</div>
</div>
);
}
return (
<div className="font-sans text-slate-200">
<div className="font-sans text-slate-200 px-4 md:px-6 pb-24">
{/* Header Controls */}
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-2xl font-bold text-white">Profitability Dashboard</h2>
<p className="text-slate-400">Overview of product performance and margins.</p>
</div>
<div className="flex items-center gap-4">
{/* Category Filter */}
<div className="flex items-center gap-2 bg-[#0A0C10] px-3 py-1.5 rounded-lg border border-[#1F2433]">
<Filter className="w-4 h-4 text-slate-400" />
<select
<select
className="bg-transparent text-sm font-medium text-slate-300 outline-none cursor-pointer"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
>
{categories.map(cat => (
<option key={cat} value={cat} className="bg-[#13161F]">{cat === 'All' ? 'All Categories' : cat}</option>
<option key={cat} value={cat} className="bg-[#13161F]">
{cat === 'All' ? 'All Categories' : cat}
</option>
))}
</select>
</div>
@@ -47,9 +277,9 @@ export default function MktDataView() {
<Settings2 className="w-4 h-4 text-slate-400" />
<label className="flex items-center gap-2 cursor-pointer">
<div className="relative">
<input
type="checkbox"
className="sr-only"
<input
type="checkbox"
className="sr-only"
checked={includeCOGS}
onChange={() => setIncludeCOGS(!includeCOGS)}
/>
@@ -69,18 +299,18 @@ export default function MktDataView() {
<h3 className="text-lg font-semibold text-white">Product Performance</h3>
<p className="text-sm text-slate-400">Click on a product to view the waterfall breakdown.</p>
</div>
<MasterTable
products={filteredProducts}
includeCOGS={includeCOGS}
onProductClick={setSelectedProduct}
<MasterTable
products={filteredProducts}
includeCOGS={includeCOGS}
onProductClick={setSelectedProduct}
/>
</div>
{/* Modal */}
<WaterfallModal
product={selectedProduct}
includeCOGS={includeCOGS}
onClose={() => setSelectedProduct(null)}
<WaterfallModal
product={selectedProduct}
includeCOGS={includeCOGS}
onClose={() => setSelectedProduct(null)}
/>
</div>
);