From d9bbf41c7cff625ec64ff46e5b8520b1658f8676 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Wed, 17 Jun 2026 16:16:41 +0200 Subject: [PATCH] feat: add Excel-style column filters and sorting to Forecast table --- components/ForecastView.tsx | 264 +++++++++++++++++++++++++++++++----- 1 file changed, 229 insertions(+), 35 deletions(-) diff --git a/components/ForecastView.tsx b/components/ForecastView.tsx index e8e9a76..6d9650c 100644 --- a/components/ForecastView.tsx +++ b/components/ForecastView.tsx @@ -1,5 +1,7 @@ import React, { useState, useMemo, useCallback, useRef } from 'react'; import * as XLSX from 'xlsx'; +import { ExcelFilter } from './ExcelFilter'; +import { ColumnFilterCondition } from '../types'; const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; const MONTHS_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; @@ -195,9 +197,9 @@ const ForecastView: React.FC = ({ asinMetadata }) => { const [budgetFileName, setBudgetFileName] = useState(''); // Default cutoff: May (index 4) — adjust as needed const [cutoffMonth, setCutoffMonth] = useState(4); - const [sortAsc, setSortAsc] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(''); + const [columnFilters, setColumnFilters] = useState>({}); const sellInInputRef = useRef(null); const budgetInputRef = useRef(null); @@ -269,27 +271,120 @@ const ForecastView: React.FC = ({ asinMetadata }) => { pctAnnual, }); }); - rows.sort((a, b) => sortAsc ? a.pctPeriod - b.pctPeriod : b.pctPeriod - a.pctPeriod); return rows; - }, [budgetData, sellInData, cutoffMonth, sortAsc, skuMetadata]); + }, [budgetData, sellInData, cutoffMonth, skuMetadata]); + + const passesTextFilter = useCallback((value: string, filter?: ColumnFilterCondition): boolean => { + if (!filter) return true; + if (filter.selectedValues && filter.selectedValues.length > 0) { + if (!filter.selectedValues.includes(value)) return false; + } + if (filter.textFilter) { + const { operator, value: filterVal } = filter.textFilter; + if (!filterVal) return true; + const v = value.toLowerCase(); + const fv = filterVal.toLowerCase(); + switch (operator) { + case 'equals': return v === fv; + case 'notEquals': return v !== fv; + case 'contains': return v.includes(fv); + case 'notContains': return !v.includes(fv); + case 'startsWith': return v.startsWith(fv); + case 'notStartsWith': return !v.startsWith(fv); + case 'endsWith': return v.endsWith(fv); + case 'notEndsWith': return !v.endsWith(fv); + default: return true; + } + } + return true; + }, []); + + const passesNumericFilter = useCallback((value: number, filter?: ColumnFilterCondition): boolean => { + if (!filter) return true; + if (filter.textFilter) { + const { operator, value: filterVal } = filter.textFilter; + const fv = parseFloat(filterVal); + if (isNaN(fv)) return true; + switch (operator) { + case 'equals': return value === fv; + case 'notEquals': return value !== fv; + case 'gt': return value > fv; + case 'gte': return value >= fv; + case 'lt': return value < fv; + case 'lte': return value <= fv; + default: return true; + } + } + return true; + }, []); 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; - }); + let rows = comparisonRows; + + // Apply column filters + const hasColumnFilter = Object.keys(columnFilters).some(k => { + const f = columnFilters[k]; + return f?.selectedValues || f?.textFilter; }); - }, [comparisonRows, searchQuery]); + if (hasColumnFilter) { + rows = rows.filter(row => { + return Object.entries(columnFilters).every(([key, filter]) => { + if (!filter) return true; + if (key === 'sku') return passesTextFilter(row.sku, filter); + if (key === 'description') return passesTextFilter(row.description, filter); + if (key === 'actualUnits') return passesNumericFilter(row.actualUnits, filter); + if (key === 'periodForecast') return passesNumericFilter(row.periodForecast, filter); + if (key === 'budgetYearTotal') return passesNumericFilter(row.budgetYearTotal, filter); + if (key === 'pctPeriod') return passesNumericFilter(row.pctPeriod, filter); + if (key === 'pctAnnual') return passesNumericFilter(row.pctAnnual, filter); + return true; + }); + }); + } + + // Apply search filter + if (searchQuery.trim()) { + const terms = searchQuery + .split(/[,;|\n]+/) + .map(t => t.trim().toLowerCase()) + .filter(Boolean); + if (terms.length > 0) { + rows = rows.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; + }); + }); + } + } + + // Apply column sort (first column with sort wins) + const sortEntry = Object.entries(columnFilters).find(([_, f]) => f?.sort); + if (sortEntry) { + const [sortKey, sortFilter] = sortEntry; + const dir = sortFilter.sort === 'desc' ? -1 : 1; + rows = [...rows].sort((a, b) => { + let va: any, vb: any; + switch (sortKey) { + case 'sku': va = a.sku; vb = b.sku; break; + case 'description': va = a.description; vb = b.description; break; + case 'actualUnits': va = a.actualUnits; vb = b.actualUnits; break; + case 'periodForecast': va = a.periodForecast; vb = b.periodForecast; break; + case 'budgetYearTotal': va = a.budgetYearTotal; vb = b.budgetYearTotal; break; + case 'pctPeriod': va = a.pctPeriod; vb = b.pctPeriod; break; + case 'pctAnnual': va = a.pctAnnual; vb = b.pctAnnual; break; + default: return 0; + } + if (typeof va === 'string') return dir * va.localeCompare(vb); + return dir * ((va as number) - (vb as number)); + }); + } + + return rows; + }, [comparisonRows, searchQuery, columnFilters, passesTextFilter, passesNumericFilter]); const kpis = useMemo(() => { const totalActual = comparisonRows.reduce((s, r) => s + r.actualUnits, 0); @@ -301,6 +396,31 @@ const ForecastView: React.FC = ({ asinMetadata }) => { return { totalActual, totalPeriodForecast, totalBudgetYear, below80, pctOverall, withForecastCount: withForecast.length }; }, [comparisonRows]); + const handleColumnFilterChange = useCallback((columnKey: string, condition: ColumnFilterCondition | undefined) => { + setColumnFilters(prev => { + const next = { ...prev }; + if (condition) { + next[columnKey] = condition; + } else { + delete next[columnKey]; + } + return next; + }); + }, []); + + const columnUniqueValues = useMemo(() => { + if (comparisonRows.length === 0) return { sku: [], description: [] } as Record; + return { + sku: Array.from(new Set(comparisonRows.map(r => r.sku))).sort() as string[], + description: Array.from(new Set(comparisonRows.map(r => r.description))).sort() as string[], + actualUnits: Array.from(new Set(comparisonRows.map(r => String(r.actualUnits)))).sort((a, b) => Number(a) - Number(b)) as string[], + periodForecast: Array.from(new Set(comparisonRows.map(r => String(r.periodForecast)))).sort((a, b) => Number(a) - Number(b)) as string[], + budgetYearTotal: Array.from(new Set(comparisonRows.map(r => String(r.budgetYearTotal)))).sort((a, b) => Number(a) - Number(b)) as string[], + pctPeriod: Array.from(new Set(comparisonRows.map(r => r.periodForecast > 0 ? r.pctPeriod.toFixed(1) + '%' : '—'))).sort() as string[], + pctAnnual: Array.from(new Set(comparisonRows.map(r => r.budgetYearTotal > 0 ? r.pctAnnual.toFixed(1) + '%' : '—'))).sort() as string[], + }; + }, [comparisonRows]); + const handleExport = useCallback(() => { const data = comparisonRows.map(r => ({ SKU: r.sku, @@ -465,29 +585,103 @@ const ForecastView: React.FC = ({ asinMetadata }) => { - - - - + - + +
SKUDescripciónReal Sell-in - Forecast Período -
- Jan–{MONTHS_SHORT[cutoffMonth]} +
+
+ SKU + +
+
+
+ Descripción +
- Budget Año -
12 meses
+
+ + Real Sell-in +
setSortAsc(prev => !prev)} - > - % Cumpl. Período {sortAsc ? '↑' : '↓'} -
real / forecast período
+
+
+ +
+
Forecast Período
+
+ Jan–{MONTHS_SHORT[cutoffMonth]} +
+
+
+
+
+ +
+
Budget Año
+
12 meses
+
+
+
+
+ +
+
% Cumpl. Período
+
real / forecast período
+
+
- % Budget Consumido -
real / budget año
+
+ +
+
% Budget Consumido
+
real / budget año
+
+