mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:35:24 +02:00
feat: add Excel-style column filters and sorting to Forecast table
This commit is contained in:
+229
-35
@@ -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<ForecastViewProps> = ({ asinMetadata }) => {
|
||||
const [budgetFileName, setBudgetFileName] = useState('');
|
||||
// Default cutoff: May (index 4) — adjust as needed
|
||||
const [cutoffMonth, setCutoffMonth] = useState<number>(4);
|
||||
const [sortAsc, setSortAsc] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [columnFilters, setColumnFilters] = useState<Record<string, ColumnFilterCondition>>({});
|
||||
|
||||
const sellInInputRef = useRef<HTMLInputElement>(null);
|
||||
const budgetInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -269,27 +271,120 @@ const ForecastView: React.FC<ForecastViewProps> = ({ 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<ForecastViewProps> = ({ 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<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 data = comparisonRows.map(r => ({
|
||||
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">
|
||||
<thead className="sticky top-0 z-20 bg-slate-950 shadow-sm">
|
||||
<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">Descripción</th>
|
||||
<th className="px-4 py-3 text-right w-[11%]">Real Sell-in</th>
|
||||
<th className="px-4 py-3 text-right w-[13%]">
|
||||
Forecast Período
|
||||
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">
|
||||
Jan–{MONTHS_SHORT[cutoffMonth]}
|
||||
<th className="px-4 py-3 text-left w-[7%]">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span>SKU</span>
|
||||
<ExcelFilter
|
||||
columnKey="sku"
|
||||
title="SKU"
|
||||
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>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right w-[11%]">
|
||||
Budget Año
|
||||
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">12 meses</div>
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<ExcelFilter
|
||||
columnKey="actualUnits"
|
||||
title="Real Sell-in"
|
||||
uniqueValues={columnUniqueValues.actualUnits}
|
||||
currentFilter={columnFilters.actualUnits}
|
||||
onFilterChange={handleColumnFilterChange}
|
||||
/>
|
||||
<span>Real Sell-in</span>
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-center w-[15%] cursor-pointer hover:text-white select-none"
|
||||
onClick={() => setSortAsc(prev => !prev)}
|
||||
>
|
||||
% Cumpl. Período {sortAsc ? '↑' : '↓'}
|
||||
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">real / forecast período</div>
|
||||
<th className="px-4 py-3 text-right w-[13%]">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<ExcelFilter
|
||||
columnKey="periodForecast"
|
||||
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>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center w-[13%]">
|
||||
% Budget Consumido
|
||||
<div className="text-[9px] font-normal normal-case text-slate-600 mt-0.5">real / budget año</div>
|
||||
<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>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
Reference in New Issue
Block a user