mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 11:55:22 +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 />}>
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<div className={view === 'forecast' ? '' : 'hidden'}>
|
<div className={view === 'forecast' ? '' : 'hidden'}>
|
||||||
<ForecastView />
|
<ForecastView asinMetadata={globalAsinMetadata} />
|
||||||
</div>
|
</div>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ interface BudgetRow {
|
|||||||
interface ComparisonRow {
|
interface ComparisonRow {
|
||||||
sku: string;
|
sku: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
asins: string[];
|
||||||
|
title: string;
|
||||||
actualUnits: number;
|
actualUnits: number;
|
||||||
periodForecast: number;
|
periodForecast: number;
|
||||||
budgetYearTotal: number;
|
budgetYearTotal: number;
|
||||||
@@ -182,7 +184,11 @@ const ComparisonTableRow: React.FC<{ row: ComparisonRow }> = React.memo(({ row }
|
|||||||
|
|
||||||
// ── Main Component ───────────────────────────────────────────────────────────
|
// ── 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 [sellInData, setSellInData] = useState<Map<string, SellInRow> | null>(null);
|
||||||
const [budgetData, setBudgetData] = useState<Map<string, BudgetRow> | null>(null);
|
const [budgetData, setBudgetData] = useState<Map<string, BudgetRow> | null>(null);
|
||||||
const [sellInFileName, setSellInFileName] = useState('');
|
const [sellInFileName, setSellInFileName] = useState('');
|
||||||
@@ -191,6 +197,7 @@ const ForecastView: React.FC = () => {
|
|||||||
const [cutoffMonth, setCutoffMonth] = useState<number>(4);
|
const [cutoffMonth, setCutoffMonth] = useState<number>(4);
|
||||||
const [sortAsc, setSortAsc] = useState(true);
|
const [sortAsc, setSortAsc] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
const sellInInputRef = useRef<HTMLInputElement>(null);
|
const sellInInputRef = useRef<HTMLInputElement>(null);
|
||||||
const budgetInputRef = useRef<HTMLInputElement>(null);
|
const budgetInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -217,6 +224,23 @@ const ForecastView: React.FC = () => {
|
|||||||
reader.readAsArrayBuffer(file);
|
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[] => {
|
const comparisonRows = useMemo((): ComparisonRow[] => {
|
||||||
if (!budgetData) return [];
|
if (!budgetData) return [];
|
||||||
const rows: ComparisonRow[] = [];
|
const rows: ComparisonRow[] = [];
|
||||||
@@ -231,9 +255,12 @@ const ForecastView: React.FC = () => {
|
|||||||
const budgetDiscrepancy = sellIn != null && Math.abs(budgetYearTotal - sellInBudget) > 1;
|
const budgetDiscrepancy = sellIn != null && Math.abs(budgetYearTotal - sellInBudget) > 1;
|
||||||
const pctPeriod = periodForecast > 0 ? (actualUnits / periodForecast) * 100 : 0;
|
const pctPeriod = periodForecast > 0 ? (actualUnits / periodForecast) * 100 : 0;
|
||||||
const pctAnnual = budgetYearTotal > 0 ? (actualUnits / budgetYearTotal) * 100 : 0;
|
const pctAnnual = budgetYearTotal > 0 ? (actualUnits / budgetYearTotal) * 100 : 0;
|
||||||
|
const meta = skuMetadata.get(budgetRow.sku);
|
||||||
rows.push({
|
rows.push({
|
||||||
sku: budgetRow.sku,
|
sku: budgetRow.sku,
|
||||||
description: budgetRow.description,
|
description: budgetRow.description,
|
||||||
|
asins: meta?.asins ?? [],
|
||||||
|
title: meta?.title ?? '',
|
||||||
actualUnits,
|
actualUnits,
|
||||||
periodForecast,
|
periodForecast,
|
||||||
budgetYearTotal,
|
budgetYearTotal,
|
||||||
@@ -244,7 +271,25 @@ const ForecastView: React.FC = () => {
|
|||||||
});
|
});
|
||||||
rows.sort((a, b) => sortAsc ? a.pctPeriod - b.pctPeriod : b.pctPeriod - a.pctPeriod);
|
rows.sort((a, b) => sortAsc ? a.pctPeriod - b.pctPeriod : b.pctPeriod - a.pctPeriod);
|
||||||
return rows;
|
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 kpis = useMemo(() => {
|
||||||
const totalActual = comparisonRows.reduce((s, r) => s + r.actualUnits, 0);
|
const totalActual = comparisonRows.reduce((s, r) => s + r.actualUnits, 0);
|
||||||
@@ -405,6 +450,17 @@ const ForecastView: React.FC = () => {
|
|||||||
{/* ── Comparison Table ── */}
|
{/* ── Comparison Table ── */}
|
||||||
{isReady && (
|
{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="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">
|
<div className="flex-1 overflow-auto min-h-0 custom-scrollbar">
|
||||||
<table className="w-full text-left border-collapse text-xs md:text-sm">
|
<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">
|
<thead className="sticky top-0 z-20 bg-slate-950 shadow-sm">
|
||||||
@@ -436,16 +492,21 @@ const ForecastView: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
{comparisonRows.map(row => (
|
{filteredRows.map(row => (
|
||||||
<ComparisonTableRow key={row.sku} row={row} />
|
<ComparisonTableRow key={row.sku} row={row} />
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user