mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 18:05:23 +02:00
280 lines
14 KiB
TypeScript
280 lines
14 KiB
TypeScript
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 = () => (
|
|
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-bold bg-yellow-500/10 border border-yellow-500/30 text-yellow-400">
|
|
🇩🇪 DE
|
|
</span>
|
|
);
|
|
|
|
const MarketGapReport: React.FC<MarketGapReportProps> = ({ 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<number | null>(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<string>();
|
|
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<string, { title: string; sku: string; line: string }>();
|
|
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<string, { sellOut: number; units: number }>();
|
|
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 (
|
|
<div className="max-w-5xl mx-auto pb-24 px-4 animate-fade-in space-y-6">
|
|
|
|
{/* Header Card */}
|
|
<div className="bg-surface border border-border rounded-xl p-6 shadow-lg flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-indigo-400 flex items-center gap-2">
|
|
🌍 Market Gap Report
|
|
</h2>
|
|
<p className="text-sm text-slate-400 mt-1">
|
|
Top 5 productos vendidos en <span className="text-yellow-400 font-semibold">🇩🇪 Alemania</span> que{' '}
|
|
<span className="text-rose-400 font-semibold">nunca se han vendido</span> en{' '}
|
|
<span className="text-red-400 font-semibold">🇪🇸 España</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
{/* Year selector */}
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Año:</label>
|
|
<div className="flex bg-slate-900 rounded-lg p-0.5 border border-slate-800">
|
|
{availableYears.map(y => (
|
|
<button
|
|
key={y}
|
|
onClick={() => setSelectedYear(y)}
|
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all ${effectiveYear === y
|
|
? 'bg-indigo-600 text-white shadow'
|
|
: 'text-slate-400 hover:text-white'
|
|
}`}
|
|
>
|
|
{y}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Export */}
|
|
<button
|
|
onClick={handleExport}
|
|
disabled={top5.length === 0}
|
|
className="flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm font-medium border border-slate-700 transition-colors disabled:opacity-40"
|
|
>
|
|
<DownloadIcon />
|
|
Exportar Excel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Insight banner */}
|
|
{top5.length > 0 && (
|
|
<div className="bg-indigo-500/5 border border-indigo-500/20 rounded-xl px-5 py-3 text-sm text-indigo-300">
|
|
💡 <span className="font-semibold">Oportunidad de expansión:</span> Estos {top5.length} productos ya funcionan en DE y podrían tener potencial en el mercado español.
|
|
</div>
|
|
)}
|
|
|
|
{/* Table */}
|
|
<div className="bg-surface border border-border rounded-xl shadow-lg overflow-hidden">
|
|
<div className="px-6 py-4 border-b border-border bg-slate-900/50 flex justify-between items-center">
|
|
<h3 className="text-lg font-bold text-indigo-300 flex items-center gap-2">
|
|
📊 Top 5 — Solo Alemania ({effectiveYear})
|
|
</h3>
|
|
<span className="text-xs text-slate-500 uppercase font-semibold tracking-wider">
|
|
Ranking por Sell-Out (€)
|
|
</span>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm border-collapse">
|
|
<thead>
|
|
<tr className="bg-slate-950 text-slate-400 uppercase text-xs font-semibold tracking-wider">
|
|
<th className="px-5 py-3 border-b border-border w-12 text-center">#</th>
|
|
<th className="px-5 py-3 border-b border-border">Producto</th>
|
|
<th className="px-5 py-3 border-b border-border">Línea</th>
|
|
<th className="px-5 py-3 border-b border-border text-right">Sell-Out DE</th>
|
|
<th className="px-5 py-3 border-b border-border text-right">Units DE</th>
|
|
<th className="px-5 py-3 border-b border-border text-center">Link</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{top5.map((product) => (
|
|
<tr key={product.asin} className="hover:bg-slate-800/50 transition-colors group">
|
|
{/* Rank */}
|
|
<td className="px-5 py-4 text-center">
|
|
<span className={`inline-flex items-center justify-center w-8 h-8 rounded-full font-black text-sm ${product.rank === 1
|
|
? 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/40'
|
|
: product.rank === 2
|
|
? 'bg-slate-400/10 text-slate-300 border border-slate-500/30'
|
|
: product.rank === 3
|
|
? 'bg-orange-500/10 text-orange-400 border border-orange-500/30'
|
|
: 'bg-slate-800 text-slate-400 border border-slate-700'
|
|
}`}>
|
|
{product.rank}
|
|
</span>
|
|
</td>
|
|
|
|
{/* Product details */}
|
|
<td className="px-5 py-4">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-white font-medium leading-snug max-w-sm truncate" title={product.title}>
|
|
{product.title || 'Unknown Title'}
|
|
</span>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
{product.sku && (
|
|
<span className="text-xs text-slate-500 font-mono">SKU: {product.sku}</span>
|
|
)}
|
|
<span className="text-xs text-slate-600 font-mono">ASIN: {product.asin}</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
{/* Line */}
|
|
<td className="px-5 py-4">
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-800 text-slate-300 border border-slate-700">
|
|
{product.line}
|
|
</span>
|
|
</td>
|
|
|
|
{/* DE Sell Out */}
|
|
<td className="px-5 py-4 text-right">
|
|
<div className="flex flex-col items-end gap-0.5">
|
|
<span className="text-emerald-400 font-bold text-base">{fmt(product.deSellOut)}</span>
|
|
<FlagDE />
|
|
</div>
|
|
</td>
|
|
|
|
{/* DE Units */}
|
|
<td className="px-5 py-4 text-right text-slate-300 font-medium">
|
|
{product.deUnits.toLocaleString('de-DE')} uds.
|
|
</td>
|
|
|
|
{/* Amazon PDP Link */}
|
|
<td className="px-5 py-4 text-center">
|
|
<a
|
|
href={`https://www.amazon.de/dp/${product.asin}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 bg-orange-500/10 hover:bg-orange-500/20 border border-orange-500/30 text-orange-400 text-xs font-medium rounded-lg transition-colors"
|
|
title={`Ver ${product.asin} en Amazon.de`}
|
|
>
|
|
Amazon.de ↗
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
|
|
{top5.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-16 text-center text-slate-500 italic">
|
|
No se encontraron productos exclusivos de DE en {effectiveYear}.<br />
|
|
<span className="text-xs mt-1 block">Verifica que los datos contienen registros de Amazon DE y Amazon ES.</span>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Footer note */}
|
|
{top5.length > 0 && (
|
|
<div className="px-6 py-3 bg-slate-900/40 border-t border-border text-xs text-slate-500">
|
|
* Se excluyen todos los ASINs con <span className="text-rose-400 font-medium">cualquier venta histórica</span> en Amazon ES, independientemente del año seleccionado.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MarketGapReport;
|