mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 15:25:23 +02:00
Replace fetch-deals, fetch-promos, fetch-chargebacks with a single /api/fetch-mkt-data?file=deals|promos|chargebacks endpoint. Update MktDataView fetch calls accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
318 lines
12 KiB
TypeScript
318 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 { Filter, 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 [selectedCategory, setSelectedCategory] = useState<string>('All');
|
|
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 (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 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 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
|
|
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>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|