mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:35:24 +02:00
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
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';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
|
|
|
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'),
|
|
]);
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
} 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
|
|
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
|
|
const adsSpendMap = new Map<string, number>();
|
|
let totalAdsPassed = 0;
|
|
adsData.forEach(r => {
|
|
const asin = r.asin.trim().toUpperCase();
|
|
adsSpendMap.set(asin, (adsSpendMap.get(asin) || 0) + r.cost);
|
|
totalAdsPassed += r.cost;
|
|
});
|
|
|
|
// 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;
|
|
}, [rawData, adsData, dealsMap, promosMap, chargebacksMap]);
|
|
|
|
const filteredProducts = allProducts;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 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">
|
|
{/* 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">
|
|
<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>
|
|
<MasterTable
|
|
products={filteredProducts}
|
|
includeCOGS={includeCOGS}
|
|
onProductClick={setSelectedProduct}
|
|
/>
|
|
</div>
|
|
|
|
{/* Modal */}
|
|
<WaterfallModal
|
|
product={selectedProduct}
|
|
includeCOGS={includeCOGS}
|
|
onClose={() => setSelectedProduct(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|