diff --git a/App.tsx b/App.tsx index b36772f..3743157 100644 --- a/App.tsx +++ b/App.tsx @@ -926,7 +926,7 @@ const App: React.FC = () => { }>
- +
diff --git a/api/fetch-chargebacks.ts b/api/fetch-chargebacks.ts new file mode 100644 index 0000000..ffcb10e --- /dev/null +++ b/api/fetch-chargebacks.ts @@ -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 }); + } +} diff --git a/api/fetch-deals.ts b/api/fetch-deals.ts new file mode 100644 index 0000000..968c942 --- /dev/null +++ b/api/fetch-deals.ts @@ -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 }); + } +} diff --git a/api/fetch-promos.ts b/api/fetch-promos.ts new file mode 100644 index 0000000..d284ced --- /dev/null +++ b/api/fetch-promos.ts @@ -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 }); + } +} diff --git a/components/mkt/MktDataView.tsx b/components/mkt/MktDataView.tsx index e6d6088..69884a9 100644 --- a/components/mkt/MktDataView.tsx +++ b/components/mkt/MktDataView.tsx @@ -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 { + const wb = XLSX.read(buffer, { type: 'array' }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(ws, { header: 1, raw: true }); + const map = new Map(); + 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 { + const wb = XLSX.read(buffer, { type: 'array' }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(ws, { header: 1, raw: true }); + const map = new Map(); + 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 { + const wb = XLSX.read(buffer, { type: 'array' }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(ws, { header: 1, raw: true }); + const map = new Map(); + 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('All'); const [selectedProduct, setSelectedProduct] = useState(null); - const categories = ['All', ...Array.from(new Set(mockProducts.map(p => p.category)))]; + const [mktLoading, setMktLoading] = useState(true); + const [dealsMap, setDealsMap] = useState>(new Map()); + const [promosMap, setPromosMap] = useState>(new Map()); + const [chargebacksMap, setChargebacksMap] = useState>(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(); + 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(); + const unitsMap = new Map(); + 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(); + 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 ( +
+
+
+

Profitability Dashboard

+

Overview of product performance and margins.

+
+
+ {/* Skeleton KPI cards */} +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+ {/* Skeleton table */} +
+ {[...Array(8)].map((_, i) => ( +
+
+
+
+
+
+ ))} +
+
+ ); + } return ( -
+
{/* Header Controls */}

Profitability Dashboard

Overview of product performance and margins.

- +
{/* Category Filter */}
-
@@ -47,9 +277,9 @@ export default function MktDataView() {
{/* Modal */} - setSelectedProduct(null)} + setSelectedProduct(null)} />
);