diff --git a/App.tsx b/App.tsx index c3906d6..9529c4f 100644 --- a/App.tsx +++ b/App.tsx @@ -24,6 +24,7 @@ const ForecastView = lazy(() => import('./components/ForecastView')); const VendorDataView = lazy(() => import('./components/VendorDataView')); const ExperimentsView = lazy(() => import('./components/ExperimentsView')); // ExperimentDetail and ExperimentForm are now integrated into ExperimentsView +const MarketGapReport = lazy(() => import('./components/MarketGapReport')); // Loading fallback component const LoadingSpinner = () => ( @@ -50,7 +51,7 @@ const App: React.FC = () => { const [trafficData, setTrafficData] = useState([]); const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); - const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'movers' | 'ads' | 'forecast' | 'vendor' | 'experiments'>('dashboard'); + const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'movers' | 'ads' | 'forecast' | 'vendor' | 'experiments' | 'gap'>('dashboard'); const [forecastData, setForecastData] = useState([]); const [isChatOpen, setIsChatOpen] = useState(false); const [activeUrl, setActiveUrl] = useState(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL); @@ -829,6 +830,13 @@ const App: React.FC = () => { > 🧪 Experiments + @@ -953,6 +961,12 @@ const App: React.FC = () => { /> + + }> +
+ +
+
)} diff --git a/components/MarketGapReport.tsx b/components/MarketGapReport.tsx new file mode 100644 index 0000000..e2922a0 --- /dev/null +++ b/components/MarketGapReport.tsx @@ -0,0 +1,279 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import * as XLSX from 'xlsx'; +import { SalesRecord } from '../types'; +import { DownloadIcon } from './Icons'; + +interface MarketGapReportProps { + data: SalesRecord[]; +} + +interface GapProduct { + rank: number; + asin: string; + title: string; + sku: string; + line: string; + deSellOut: number; + deUnits: number; +} + +const FlagDE = () => ( + + 🇩🇪 DE + +); + +const MarketGapReport: React.FC = ({ data }) => { + const availableYears = useMemo(() => { + const years = Array.from(new Set(data.map(r => r.year))).sort((a, b) => b - a); + return years; + }, [data]); + + const [selectedYear, setSelectedYear] = useState(null); + + const effectiveYear = selectedYear ?? availableYears[0] ?? new Date().getFullYear(); + + const top5 = useMemo((): GapProduct[] => { + if (data.length === 0) return []; + + // 1. Build set of ASINs ever sold in Amazon ES (all years) + const esAsins = new Set(); + data.forEach(r => { + if (r.customer?.toLowerCase().includes('amazon es')) { + esAsins.add(r.asin.trim().toUpperCase()); + } + }); + + // 2. Build metadata map (title, sku, line) from all years — prefer longest title + const metaMap = new Map(); + data.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, { title: r.title || r.articleName || asin, sku: r.sku || '', line: r.line || 'Unassigned' }); + } + }); + + // 3. Aggregate DE sales for the selected year + const deMap = new Map(); + data.forEach(r => { + if (r.year !== effectiveYear) return; + if (!r.customer?.toLowerCase().includes('amazon de')) return; + const asin = r.asin.trim().toUpperCase(); + const entry = deMap.get(asin) || { sellOut: 0, units: 0 }; + entry.sellOut += r.sellOut || 0; + entry.units += r.units || 0; + deMap.set(asin, entry); + }); + + // 4. Filter out ASINs sold in ES, sort desc by sellOut, top 5 + const results: GapProduct[] = []; + deMap.forEach((val, asin) => { + if (esAsins.has(asin)) return; + if (val.sellOut <= 0) return; + const meta = metaMap.get(asin) || { title: asin, sku: '', line: 'Unassigned' }; + results.push({ + rank: 0, + asin, + title: meta.title, + sku: meta.sku, + line: meta.line, + deSellOut: val.sellOut, + deUnits: val.units, + }); + }); + + results.sort((a, b) => b.deSellOut - a.deSellOut); + return results.slice(0, 5).map((r, i) => ({ ...r, rank: i + 1 })); + }, [data, effectiveYear]); + + const handleExport = useCallback(() => { + if (top5.length === 0) return; + const exportData = top5.map(p => ({ + Rank: p.rank, + ASIN: p.asin, + Title: p.title, + SKU: p.sku, + 'Product Line': p.line, + [`DE Sell Out ${effectiveYear} (€)`]: Number(p.deSellOut.toFixed(2)), + [`DE Units ${effectiveYear}`]: p.deUnits, + 'Amazon DE PDP': `https://www.amazon.de/dp/${p.asin}`, + })); + const ws = XLSX.utils.json_to_sheet(exportData); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'DE-Only Top 5'); + XLSX.writeFile(wb, `DE_Only_Top5_${effectiveYear}.xlsx`); + }, [top5, effectiveYear]); + + const fmt = (n: number) => + `€${n.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; + + return ( +
+ + {/* Header Card */} +
+
+

+ 🌍 Market Gap Report +

+

+ Top 5 productos vendidos en 🇩🇪 Alemania que{' '} + nunca se han vendido en{' '} + 🇪🇸 España +

+
+ +
+ {/* Year selector */} +
+ +
+ {availableYears.map(y => ( + + ))} +
+
+ + {/* Export */} + +
+
+ + {/* Insight banner */} + {top5.length > 0 && ( +
+ 💡 Oportunidad de expansión: Estos {top5.length} productos ya funcionan en DE y podrían tener potencial en el mercado español. +
+ )} + + {/* Table */} +
+
+

+ 📊 Top 5 — Solo Alemania ({effectiveYear}) +

+ + Ranking por Sell-Out (€) + +
+ +
+ + + + + + + + + + + + + {top5.map((product) => ( + + {/* Rank */} + + + {/* Product details */} + + + {/* Line */} + + + {/* DE Sell Out */} + + + {/* DE Units */} + + + {/* Amazon PDP Link */} + + + ))} + + {top5.length === 0 && ( + + + + )} + +
#ProductoLíneaSell-Out DEUnits DELink
+ + {product.rank} + + +
+ + {product.title || 'Unknown Title'} + +
+ {product.sku && ( + SKU: {product.sku} + )} + ASIN: {product.asin} +
+
+
+ + {product.line} + + +
+ {fmt(product.deSellOut)} + +
+
+ {product.deUnits.toLocaleString('de-DE')} uds. + + + Amazon.de ↗ + +
+ No se encontraron productos exclusivos de DE en {effectiveYear}.
+ Verifica que los datos contienen registros de Amazon DE y Amazon ES. +
+
+ + {/* Footer note */} + {top5.length > 0 && ( +
+ * Se excluyen todos los ASINs con cualquier venta histórica en Amazon ES, independientemente del año seleccionado. +
+ )} +
+
+ ); +}; + +export default MarketGapReport;