mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:45:23 +02:00
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:
co-authored by
Claude Sonnet 4.6
parent
5d0edfba5f
commit
49498c5c51
@@ -926,7 +926,7 @@ const App: React.FC = () => {
|
||||
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<div className={view === 'mkt' ? '' : 'hidden'}>
|
||||
<MktDataView />
|
||||
<MktDataView rawData={rawData} adsData={adsData} />
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,253 @@
|
||||
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>
|
||||
@@ -37,7 +265,9 @@ export default function MktDataView() {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user