feat: add Excel-style column filters and sorting to Forecast table

This commit is contained in:
Christian Vidal Wolf
2026-06-17 16:16:41 +02:00
parent 6704093a42
commit d9bbf41c7c
+216 -22
View File
@@ -1,5 +1,7 @@
import React, { useState, useMemo, useCallback, useRef } from 'react'; import React, { useState, useMemo, useCallback, useRef } from 'react';
import * as XLSX from 'xlsx'; 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 = ['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']; const MONTHS_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
@@ -195,9 +197,9 @@ const ForecastView: React.FC<ForecastViewProps> = ({ asinMetadata }) => {
const [budgetFileName, setBudgetFileName] = useState(''); const [budgetFileName, setBudgetFileName] = useState('');
// Default cutoff: May (index 4) — adjust as needed // Default cutoff: May (index 4) — adjust as needed
const [cutoffMonth, setCutoffMonth] = useState<number>(4); const [cutoffMonth, setCutoffMonth] = useState<number>(4);
const [sortAsc, setSortAsc] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [columnFilters, setColumnFilters] = useState<Record<string, ColumnFilterCondition>>({});
const sellInInputRef = useRef<HTMLInputElement>(null); const sellInInputRef = useRef<HTMLInputElement>(null);
const budgetInputRef = useRef<HTMLInputElement>(null); const budgetInputRef = useRef<HTMLInputElement>(null);
@@ -269,18 +271,85 @@ const ForecastView: React.FC<ForecastViewProps> = ({ asinMetadata }) => {
pctAnnual, pctAnnual,
}); });
}); });
rows.sort((a, b) => sortAsc ? a.pctPeriod - b.pctPeriod : b.pctPeriod - a.pctPeriod);
return rows; 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(() => { const filteredRows = useMemo(() => {
if (!searchQuery.trim()) return comparisonRows; let rows = comparisonRows;
// Apply column filters
const hasColumnFilter = Object.keys(columnFilters).some(k => {
const f = columnFilters[k];
return f?.selectedValues || f?.textFilter;
});
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 const terms = searchQuery
.split(/[,;|\n]+/) .split(/[,;|\n]+/)
.map(t => t.trim().toLowerCase()) .map(t => t.trim().toLowerCase())
.filter(Boolean); .filter(Boolean);
if (terms.length === 0) return comparisonRows; if (terms.length > 0) {
return comparisonRows.filter(row => { rows = rows.filter(row => {
return terms.some(term => { return terms.some(term => {
if (row.sku.toLowerCase().includes(term)) return true; if (row.sku.toLowerCase().includes(term)) return true;
if (row.description.toLowerCase().includes(term)) return true; if (row.description.toLowerCase().includes(term)) return true;
@@ -289,7 +358,33 @@ const ForecastView: React.FC<ForecastViewProps> = ({ asinMetadata }) => {
return false; return false;
}); });
}); });
}, [comparisonRows, searchQuery]); }
}
// 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 kpis = useMemo(() => {
const totalActual = comparisonRows.reduce((s, r) => s + r.actualUnits, 0); const totalActual = comparisonRows.reduce((s, r) => s + r.actualUnits, 0);
@@ -301,6 +396,31 @@ const ForecastView: React.FC<ForecastViewProps> = ({ asinMetadata }) => {
return { totalActual, totalPeriodForecast, totalBudgetYear, below80, pctOverall, withForecastCount: withForecast.length }; return { totalActual, totalPeriodForecast, totalBudgetYear, below80, pctOverall, withForecastCount: withForecast.length };
}, [comparisonRows]); }, [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<string, string[]>;
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 handleExport = useCallback(() => {
const data = comparisonRows.map(r => ({ const data = comparisonRows.map(r => ({
SKU: r.sku, SKU: r.sku,
@@ -465,29 +585,103 @@ const ForecastView: React.FC<ForecastViewProps> = ({ asinMetadata }) => {
<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">
<tr className="text-[10px] font-black text-slate-500 uppercase tracking-wider"> <tr className="text-[10px] font-black text-slate-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left w-[7%]">SKU</th> <th className="px-4 py-3 text-left w-[7%]">
<th className="px-4 py-3 text-left">Descripción</th> <div className="flex items-center gap-0.5">
<th className="px-4 py-3 text-right w-[11%]">Real Sell-in</th> <span>SKU</span>
<th className="px-4 py-3 text-right w-[13%]"> <ExcelFilter
Forecast Período columnKey="sku"
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5"> title="SKU"
Jan{MONTHS_SHORT[cutoffMonth]} uniqueValues={columnUniqueValues.sku}
currentFilter={columnFilters.sku}
onFilterChange={handleColumnFilterChange}
/>
</div>
</th>
<th className="px-4 py-3 text-left">
<div className="flex items-center gap-0.5">
<span>Descripción</span>
<ExcelFilter
columnKey="description"
title="Descripción"
uniqueValues={columnUniqueValues.description}
currentFilter={columnFilters.description}
onFilterChange={handleColumnFilterChange}
/>
</div> </div>
</th> </th>
<th className="px-4 py-3 text-right w-[11%]"> <th className="px-4 py-3 text-right w-[11%]">
Budget Año <div className="flex items-center justify-end gap-0.5">
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">12 meses</div> <ExcelFilter
columnKey="actualUnits"
title="Real Sell-in"
uniqueValues={columnUniqueValues.actualUnits}
currentFilter={columnFilters.actualUnits}
onFilterChange={handleColumnFilterChange}
/>
<span>Real Sell-in</span>
</div>
</th> </th>
<th <th className="px-4 py-3 text-right w-[13%]">
className="px-4 py-3 text-center w-[15%] cursor-pointer hover:text-white select-none" <div className="flex items-center justify-end gap-0.5">
onClick={() => setSortAsc(prev => !prev)} <ExcelFilter
> columnKey="periodForecast"
% Cumpl. Período {sortAsc ? '↑' : '↓'} title="Forecast Período"
uniqueValues={columnUniqueValues.periodForecast}
currentFilter={columnFilters.periodForecast}
onFilterChange={handleColumnFilterChange}
/>
<div className="text-right">
<div>Forecast Período</div>
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">
Jan{MONTHS_SHORT[cutoffMonth]}
</div>
</div>
</div>
</th>
<th className="px-4 py-3 text-right w-[11%]">
<div className="flex items-center justify-end gap-0.5">
<ExcelFilter
columnKey="budgetYearTotal"
title="Budget Año"
uniqueValues={columnUniqueValues.budgetYearTotal}
currentFilter={columnFilters.budgetYearTotal}
onFilterChange={handleColumnFilterChange}
/>
<div className="text-right">
<div>Budget Año</div>
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">12 meses</div>
</div>
</div>
</th>
<th className="px-4 py-3 text-center w-[15%]">
<div className="flex items-center justify-center gap-0.5">
<ExcelFilter
columnKey="pctPeriod"
title="% Cumpl. Período"
uniqueValues={columnUniqueValues.pctPeriod}
currentFilter={columnFilters.pctPeriod}
onFilterChange={handleColumnFilterChange}
/>
<div className="text-center">
<div>% Cumpl. Período</div>
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">real / forecast período</div> <div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">real / forecast período</div>
</div>
</div>
</th> </th>
<th className="px-4 py-3 text-center w-[13%]"> <th className="px-4 py-3 text-center w-[13%]">
% Budget Consumido <div className="flex items-center justify-center gap-0.5">
<ExcelFilter
columnKey="pctAnnual"
title="% Budget Consumido"
uniqueValues={columnUniqueValues.pctAnnual}
currentFilter={columnFilters.pctAnnual}
onFilterChange={handleColumnFilterChange}
/>
<div className="text-center">
<div>% Budget Consumido</div>
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">real / budget año</div> <div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">real / budget año</div>
</div>
</div>
</th> </th>
</tr> </tr>
</thead> </thead>