fix(dataProcessor): parse DD/M/YY dates from column C in sell-out CSV

Add support for European day-first date format (e.g. "23/2/26" = 23 Feb 2026)
in the sell-out CSV pipeline so months and years are correctly extracted from
column C of Amazon Sell Out 2023-2025.csv.

- normalizeMonth: detect DD/MM/YY when first part > 12, return "Mon-YY"
- mapRowToRecord: add 'C', 'Date', 'Fecha', 'DATA' to month column aliases
- validateSellOutHeaders: accept date columns as substitute for YEAR + MONTH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-03-03 13:07:59 +01:00
co-authored by Claude Sonnet 4.6
parent ad1695d360
commit 2eed94b94b
10 changed files with 112 additions and 747 deletions
-279
View File
@@ -1,279 +0,0 @@
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;