mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 14:45:24 +02:00
feat: add multi-product search by SKU/ASIN/title to Forecast view
This commit is contained in:
@@ -898,7 +898,7 @@ const App: React.FC = () => {
|
||||
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<div className={view === 'forecast' ? '' : 'hidden'}>
|
||||
<ForecastView />
|
||||
<ForecastView asinMetadata={globalAsinMetadata} />
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
|
||||
@@ -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>
|
||||
{filteredRows.length === 0 && (
|
||||
<div className="flex items-center justify-center py-20 text-slate-500 text-sm">
|
||||
{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>
|
||||
{comparisonRows.length === 0 && (
|
||||
<div className="flex items-center justify-center py-20 text-slate-500 text-sm">
|
||||
Sin datos de comparación
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user