Files
CrazeAnalytix/components/mkt/MktDataView.tsx
T

347 lines
13 KiB
TypeScript
Raw Normal View History

2026-03-11 12:34:07 +01:00
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 { Settings2 } from 'lucide-react';
2026-03-11 12:34:07 +01:00
// ---------------------------------------------------------------------------
// 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[];
stockMap?: Map<string, number>;
vendorStockMap?: Map<string, { eu: number; uk: number }>;
top50Mode?: 'eu' | 'uk';
velocityMap?: Map<string, number>;
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
2026-03-11 12:34:07 +01:00
}
export default function MktDataView({
rawData,
adsData,
stockMap,
vendorStockMap,
top50Mode = 'eu',
velocityMap,
buyBoxLostMap
}: MktDataViewProps) {
const [includeCOGS, setIncludeCOGS] = useState(true);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
2026-03-11 12:34:07 +01:00
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-mkt-data?file=deals'),
fetch('/api/fetch-mkt-data?file=promos'),
fetch('/api/fetch-mkt-data?file=chargebacks'),
2026-03-11 12:34:07 +01:00
]);
if (cancelled) return;
if (dealsRes.ok) {
const buf = await dealsRes.arrayBuffer();
if (!cancelled) setDealsMap(parseDealsExcel(buf));
} else {
console.warn('[MktDataView] fetch-mkt-data?file=deals failed:', dealsRes.status);
2026-03-11 12:34:07 +01:00
}
if (promosRes.ok) {
const buf = await promosRes.arrayBuffer();
if (!cancelled) setPromosMap(parsePromosExcel(buf));
} else {
console.warn('[MktDataView] fetch-mkt-data?file=promos failed:', promosRes.status);
2026-03-11 12:34:07 +01:00
}
if (chargesRes.ok) {
const buf = await chargesRes.arrayBuffer();
if (!cancelled) setChargebacksMap(parseChargebacksExcel(buf));
} else {
console.warn('[MktDataView] fetch-mkt-data?file=chargebacks failed:', chargesRes.status);
2026-03-11 12:34:07 +01:00
}
} 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
2026-03-11 12:34:07 +01:00
const sellOutMap = new Map<string, number>();
const unitsMap = new Map<string, number>();
rawData.forEach(r => {
2026-03-11 12:34:07 +01:00
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
2026-03-11 12:34:07 +01:00
const adsSpendMap = new Map<string, number>();
let totalAdsPassed = 0;
adsData.forEach(r => {
2026-03-11 12:34:07 +01:00
const asin = r.asin.trim().toUpperCase();
adsSpendMap.set(asin, (adsSpendMap.get(asin) || 0) + r.cost);
totalAdsPassed += r.cost;
2026-03-11 12:34:07 +01:00
});
// Deals/Promos/Chargebacks
const has2025 = rawData.some(r => r.year === 2025) || adsData.some(r => r.year === 2025);
const getDeals = (asin: string) => has2025 ? (dealsMap.get(asin) || 0) : 0;
const getPromos = (asin: string) => has2025 ? (promosMap.get(asin) || 0) : 0;
const getChargebacks = (asin: string) => has2025 ? (chargebacksMap.get(asin) || 0) : 0;
// 1. Start with all sales-based ASINs
const products: Product[] = Array.from(metaMap.entries())
.map(([asin, meta]): Product => {
const spend = adsSpendMap.get(asin) || 0;
adsSpendMap.delete(asin); // Mark as processed
return {
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: spend,
deals: getDeals(asin),
promos: getPromos(asin),
chargebacks: getChargebacks(asin),
chargebacksPrevMonth: 0,
cogs: 0,
};
});
// 2. Add remaining ad spend (including "" ASIN and ASINs without sales)
adsSpendMap.forEach((spend, asin) => {
if (spend === 0) return;
products.push({
id: asin || '__unassigned__',
asin: asin || '—',
sku: '',
name: asin ? asin : 'Unassigned Ad Spend (incl. Sponsored Brands)',
image: '',
category: 'Other',
brand: '',
grossSales: 0,
unitsSold: 0,
ppcSpend: spend,
deals: asin ? getDeals(asin) : 0,
promos: asin ? getPromos(asin) : 0,
chargebacks: asin ? getChargebacks(asin) : 0,
chargebacksPrevMonth: 0,
cogs: 0,
});
});
// Debug check
const finalTotal = products.reduce((sum, p) => sum + p.ppcSpend, 0);
if (Math.abs(finalTotal - totalAdsPassed) > 0.01) {
console.warn(`[MktDataView] PPC Spend Mismatch: Total Ads=${totalAdsPassed.toFixed(2)}, Table Sum=${finalTotal.toFixed(2)}`);
}
return products;
2026-03-11 12:34:07 +01:00
}, [rawData, adsData, dealsMap, promosMap, chargebacksMap]);
const filteredProducts = allProducts;
2026-03-11 12:34:07 +01:00
// ---------------------------------------------------------------------------
// 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 (
2026-03-11 12:34:07 +01:00
<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>
2026-03-11 12:34:07 +01:00
<div className="flex items-center gap-4">
{/* COGS Toggle */}
<div className="flex items-center gap-2 bg-[#0A0C10] px-3 py-1.5 rounded-lg border border-[#1F2433]">
<Settings2 className="w-4 h-4 text-slate-400" />
<label className="flex items-center gap-2 cursor-pointer">
<div className="relative">
2026-03-11 12:34:07 +01:00
<input
type="checkbox"
className="sr-only"
checked={includeCOGS}
onChange={() => setIncludeCOGS(!includeCOGS)}
/>
<div className={`block w-10 h-6 rounded-full transition-colors ${includeCOGS ? 'bg-indigo-600' : 'bg-[#1F2433]'}`}></div>
<div className={`absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${includeCOGS ? 'transform translate-x-4' : ''}`}></div>
</div>
<span className="text-sm font-medium text-slate-300">Include COGS</span>
</label>
</div>
</div>
</div>
<KPICards products={filteredProducts} includeCOGS={includeCOGS} />
<div className="mb-8">
<div className="mb-4 flex items-center justify-between">
<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>
2026-03-11 12:34:07 +01:00
<MasterTable
products={filteredProducts}
includeCOGS={includeCOGS}
onProductClick={setSelectedProduct}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
top50Mode={top50Mode}
velocityMap={velocityMap}
buyBoxLostMap={buyBoxLostMap}
/>
</div>
{/* Modal */}
2026-03-11 12:34:07 +01:00
<WaterfallModal
product={selectedProduct}
includeCOGS={includeCOGS}
onClose={() => setSelectedProduct(null)}
/>
</div>
);
}