feat: add multi-product search by SKU/ASIN/title to Forecast view

This commit is contained in:
Christian Vidal Wolf
2026-06-17 16:10:00 +02:00
parent 6e2db6be1a
commit 6704093a42
2 changed files with 70 additions and 9 deletions
+1 -1
View File
@@ -898,7 +898,7 @@ const App: React.FC = () => {
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'forecast' ? '' : 'hidden'}>
<ForecastView />
<ForecastView asinMetadata={globalAsinMetadata} />
</div>
</Suspense>
+67 -6
View File
@@ -23,6 +23,8 @@ interface BudgetRow {
interface ComparisonRow {
sku: string;
description: string;
asins: string[];
title: string;
actualUnits: number;
periodForecast: number;
budgetYearTotal: number;
@@ -182,7 +184,11 @@ const ComparisonTableRow: React.FC<{ row: ComparisonRow }> = React.memo(({ row }
// ── Main Component ───────────────────────────────────────────────────────────
const ForecastView: React.FC = () => {
interface ForecastViewProps {
asinMetadata?: Map<string, { sku: string; title: string; line: string }>;
}
const ForecastView: React.FC<ForecastViewProps> = ({ asinMetadata }) => {
const [sellInData, setSellInData] = useState<Map<string, SellInRow> | null>(null);
const [budgetData, setBudgetData] = useState<Map<string, BudgetRow> | null>(null);
const [sellInFileName, setSellInFileName] = useState('');
@@ -191,6 +197,7 @@ const ForecastView: React.FC = () => {
const [cutoffMonth, setCutoffMonth] = useState<number>(4);
const [sortAsc, setSortAsc] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const sellInInputRef = useRef<HTMLInputElement>(null);
const budgetInputRef = useRef<HTMLInputElement>(null);
@@ -217,6 +224,23 @@ const ForecastView: React.FC = () => {
reader.readAsArrayBuffer(file);
}, []);
const skuMetadata = useMemo(() => {
const map = new Map<string, { asins: string[]; title: string }>();
if (!asinMetadata) return map;
asinMetadata.forEach((meta, asin) => {
const existing = map.get(meta.sku);
if (existing) {
existing.asins.push(asin);
if (meta.title && meta.title.length > (existing.title?.length || 0)) {
existing.title = meta.title;
}
} else {
map.set(meta.sku, { asins: [asin], title: meta.title || '' });
}
});
return map;
}, [asinMetadata]);
const comparisonRows = useMemo((): ComparisonRow[] => {
if (!budgetData) return [];
const rows: ComparisonRow[] = [];
@@ -231,9 +255,12 @@ const ForecastView: React.FC = () => {
const budgetDiscrepancy = sellIn != null && Math.abs(budgetYearTotal - sellInBudget) > 1;
const pctPeriod = periodForecast > 0 ? (actualUnits / periodForecast) * 100 : 0;
const pctAnnual = budgetYearTotal > 0 ? (actualUnits / budgetYearTotal) * 100 : 0;
const meta = skuMetadata.get(budgetRow.sku);
rows.push({
sku: budgetRow.sku,
description: budgetRow.description,
asins: meta?.asins ?? [],
title: meta?.title ?? '',
actualUnits,
periodForecast,
budgetYearTotal,
@@ -244,7 +271,25 @@ const ForecastView: React.FC = () => {
});
rows.sort((a, b) => sortAsc ? a.pctPeriod - b.pctPeriod : b.pctPeriod - a.pctPeriod);
return rows;
}, [budgetData, sellInData, cutoffMonth, sortAsc]);
}, [budgetData, sellInData, cutoffMonth, sortAsc, skuMetadata]);
const filteredRows = useMemo(() => {
if (!searchQuery.trim()) return comparisonRows;
const terms = searchQuery
.split(/[,;|\n]+/)
.map(t => t.trim().toLowerCase())
.filter(Boolean);
if (terms.length === 0) return comparisonRows;
return comparisonRows.filter(row => {
return terms.some(term => {
if (row.sku.toLowerCase().includes(term)) return true;
if (row.description.toLowerCase().includes(term)) return true;
if (row.title.toLowerCase().includes(term)) return true;
if (row.asins.some(asin => asin.toLowerCase().includes(term))) return true;
return false;
});
});
}, [comparisonRows, searchQuery]);
const kpis = useMemo(() => {
const totalActual = comparisonRows.reduce((s, r) => s + r.actualUnits, 0);
@@ -405,6 +450,17 @@ const ForecastView: React.FC = () => {
{/* ── Comparison Table ── */}
{isReady && (
<div className="bg-slate-900 border border-white/10 rounded-2xl overflow-hidden shadow-2xl flex-1 flex flex-col min-h-0">
<div className="sticky top-0 z-20 bg-slate-950 border-b border-slate-800/50">
<div className="px-4 py-2">
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Buscar por SKU, ASIN, o título... (separar varios con coma)"
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
</div>
<div className="flex-1 overflow-auto min-h-0 custom-scrollbar">
<table className="w-full text-left border-collapse text-xs md:text-sm">
<thead className="sticky top-0 z-20 bg-slate-950 shadow-sm">
@@ -436,16 +492,21 @@ const ForecastView: React.FC = () => {
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{comparisonRows.map(row => (
{filteredRows.map(row => (
<ComparisonTableRow key={row.sku} row={row} />
))}
</tbody>
</table>
{comparisonRows.length === 0 && (
{filteredRows.length === 0 && (
<div className="flex items-center justify-center py-20 text-slate-500 text-sm">
Sin datos de comparación
{searchQuery.trim() ? 'No se encontraron productos con esos criterios' : 'Sin datos de comparación'}
</div>
)}
{searchQuery.trim() && filteredRows.length > 0 && (
<div className="sticky bottom-0 bg-slate-950 border-t border-slate-800/50 px-4 py-1.5 text-[10px] text-slate-500 text-right">
{filteredRows.length} de {comparisonRows.length} SKUs
</div>
)}
</table>
</div>
</div>
)}