diff --git a/components/DataGrid.tsx b/components/DataGrid.tsx
index 67b6364..803c15c 100644
--- a/components/DataGrid.tsx
+++ b/components/DataGrid.tsx
@@ -1,18 +1,18 @@
import React, { useState, useMemo, useEffect } from 'react';
import {
- LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
+ LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
} from 'recharts';
import { SalesRecord, PivotRow } from '../types';
import { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries } from '../services/dataProcessor';
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon } from './Icons';
interface DataGridProps {
- data: SalesRecord[];
+ data: SalesRecord[];
}
type SortConfig = {
- key: string | null;
- direction: 'asc' | 'desc';
+ key: string | null;
+ direction: 'asc' | 'desc';
};
type ConditionalFilter = {
@@ -27,11 +27,11 @@ const CHART_COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0
// Available grouping dimensions
const DIMENSION_OPTIONS = [
- { label: 'Product Line', value: 'line' },
- { label: 'Customer', value: 'customer' },
- { label: 'SKU', value: 'sku' },
- { label: 'Title', value: 'title' },
- { label: 'ASIN', value: 'asin' },
+ { label: 'Product Line', value: 'line' },
+ { label: 'Customer', value: 'customer' },
+ { label: 'SKU', value: 'sku' },
+ { label: 'Title', value: 'title' },
+ { label: 'ASIN', value: 'asin' },
];
// Tooltip for single-period view with Week-over-Week comparison
@@ -41,7 +41,7 @@ const WoWTooltip = ({ active, payload, label, data }: any) => {
const prevData = currentIndex > 0 ? data[currentIndex - 1] : null;
return (
-
+
{label}
{payload.map((p: any) => {
let wowEl = null;
@@ -60,15 +60,15 @@ const WoWTooltip = ({ active, payload, label, data }: any) => {
return (
-
{p.name}:
-
+
{p.name}:
+
{p.dataKey === 'sellOut'
- ? `€${Number(p.value).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}`
- : `${Number(p.value).toLocaleString()} u`}
+ ? `€${Number(p.value).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`
+ : `${Number(p.value).toLocaleString()} u`}
{wowEl}
-
+
);
})}
@@ -81,7 +81,7 @@ const WoWTooltip = ({ active, payload, label, data }: any) => {
// Tooltip for multi-year comparison view
const ComparisonTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
-
+
interface YearData {
sellOut?: number;
units?: number;
@@ -93,7 +93,7 @@ const ComparisonTooltip = ({ active, payload, label }: any) => {
payload.forEach((p: any) => {
const nameParts = p.name.split(' ');
if (nameParts.length < 2) return;
-
+
const year = nameParts[nameParts.length - 1];
const metric = nameParts.slice(0, nameParts.length - 1).join(' ');
@@ -101,14 +101,14 @@ const ComparisonTooltip = ({ active, payload, label }: any) => {
dataByYear[year] = {};
}
// Use the color from the Sell Out line for consistency for that year block
- if (metric.toLowerCase().includes('so')) {
+ if (metric.toLowerCase().includes('sell out')) {
dataByYear[year].sellOut = p.value;
dataByYear[year].color = p.stroke || p.color;
} else if (metric.toLowerCase().includes('units')) {
dataByYear[year].units = p.value;
- if(!dataByYear[year].color) { // fallback color from units line
+ if (!dataByYear[year].color) { // fallback color from units line
dataByYear[year].color = p.stroke || p.color;
- }
+ }
}
});
@@ -141,17 +141,17 @@ const ComparisonTooltip = ({ active, payload, label }: any) => {
);
}
-
+
return (
{year}
-
+
{yearData.sellOut != null && (
Sell Out:
- €{Number(yearData.sellOut).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}
+ €{Number(yearData.sellOut).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
{sellOutGrowthEl}
@@ -180,566 +180,566 @@ const ComparisonTooltip = ({ active, payload, label }: any) => {
// Reusable Expandable Chart Card
const ExpandableChartCard: React.FC<{ title: string; children: React.ReactNode; className?: string }> = ({ title, children, className }) => {
- const [isExpanded, setIsExpanded] = useState(false);
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const toggleExpand = () => setIsExpanded(!isExpanded);
+
+ if (isExpanded) {
+ return (
+
+ );
+ }
- const toggleExpand = () => setIsExpanded(!isExpanded);
-
- if (isExpanded) {
return (
-
-
-
{title}
-
+
-
- {children}
-
-
);
- }
-
- return (
-
- );
};
const DataGrid: React.FC
= ({ data }) => {
- const [currentPage, setCurrentPage] = useState(1);
- const [sortConfig, setSortConfig] = useState({ key: null, direction: 'desc' });
- const [showChart, setShowChart] = useState(true);
- const [visibleMetrics, setVisibleMetrics] = useState<('sellOut' | 'units')[]>(['sellOut', 'units']);
-
- // State for dynamic grouping
- const [selectedDimensions, setSelectedDimensions] = useState(['line', 'customer', 'sku', 'title']);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [sortConfig, setSortConfig] = useState({ key: null, direction: 'desc' });
+ const [showChart, setShowChart] = useState(true);
+ const [visibleMetrics, setVisibleMetrics] = useState<('sellOut' | 'units')[]>(['sellOut', 'units']);
- // State for Advanced Filtering
- const [showFilterBuilder, setShowFilterBuilder] = useState(false);
- const [rowFilters, setRowFilters] = useState([]);
- // Temp state for new filter inputs
- const [newFilterMetric, setNewFilterMetric] = useState('');
- const [newFilterOperator, setNewFilterOperator] = useState<'gt' | 'lt'>('gt');
- const [newFilterValue, setNewFilterValue] = useState('');
+ // State for dynamic grouping
+ const [selectedDimensions, setSelectedDimensions] = useState(['line', 'customer', 'sku', 'title']);
- // Effective dimensions for rendering
- const effectiveDimensions = useMemo(() =>
- selectedDimensions.length > 0 ? selectedDimensions : ['customer'],
- [selectedDimensions]);
+ // State for Advanced Filtering
+ const [showFilterBuilder, setShowFilterBuilder] = useState(false);
+ const [rowFilters, setRowFilters] = useState([]);
+ // Temp state for new filter inputs
+ const [newFilterMetric, setNewFilterMetric] = useState('');
+ const [newFilterOperator, setNewFilterOperator] = useState<'gt' | 'lt'>('gt');
+ const [newFilterValue, setNewFilterValue] = useState('');
- // Transform flat data into Pivot structure
- const { rows: pivotRows, years } = useMemo(() => {
- return pivotSalesData(data, effectiveDimensions);
- }, [data, effectiveDimensions]);
-
- // Data for the time series chart, supporting single and multi-year comparison
- const { chartData, uniqueYears, isComparisonView, chartTitle } = useMemo(() => {
- const yearsInView = Array.from(new Set(data.map(d => d.year.toString()))).sort((a: string, b: string) => parseInt(b) - parseInt(a));
- const isMultiYear = yearsInView.length > 1;
+ // Effective dimensions for rendering
+ const effectiveDimensions = useMemo(() =>
+ selectedDimensions.length > 0 ? selectedDimensions : ['customer'],
+ [selectedDimensions]);
- if (isMultiYear) {
- return {
- chartData: aggregateForComparisonTimeSeries(data),
- uniqueYears: yearsInView,
- isComparisonView: true,
- chartTitle: `Weekly Sales Comparison: ${yearsInView.join(' vs ')}`
- };
- } else {
- return {
- chartData: aggregateForTimeSeries(data),
- uniqueYears: yearsInView,
- isComparisonView: false,
- chartTitle: `Weekly Sales Evolution ${yearsInView[0] || ''}`
- };
- }
- }, [data]);
+ // Transform flat data into Pivot structure
+ const { rows: pivotRows, years } = useMemo(() => {
+ return pivotSalesData(data, effectiveDimensions);
+ }, [data, effectiveDimensions]);
- // Filter Options based on available data
- const metricOptions = useMemo(() => {
- const options = [];
- // Totals
- years.forEach(y => {
- options.push({ label: `Total Sell Out ${y} (€)`, value: `total_sellOut_${y}` });
- options.push({ label: `Total Units ${y}`, value: `total_units_${y}` });
- });
- // Growth (Latest vs Previous)
- if (years.length >= 2) {
- options.push({ label: `Growth % Sell Out (${years[0]} vs ${years[1]})`, value: 'growth_sellOut' });
- options.push({ label: `Growth % Units (${years[0]} vs ${years[1]})`, value: 'growth_units' });
- }
- return options;
- }, [years]);
+ // Data for the time series chart, supporting single and multi-year comparison
+ const { chartData, uniqueYears, isComparisonView, chartTitle } = useMemo(() => {
+ const yearsInView = Array.from(new Set(data.map(d => d.year.toString()))).sort((a: string, b: string) => parseInt(b) - parseInt(a));
+ const isMultiYear = yearsInView.length > 1;
- // Apply Advanced Row Filters THEN Sort
- const processedRows = useMemo(() => {
- let result = pivotRows;
+ if (isMultiYear) {
+ return {
+ chartData: aggregateForComparisonTimeSeries(data),
+ uniqueYears: yearsInView,
+ isComparisonView: true,
+ chartTitle: `Weekly Sales Comparison: ${yearsInView.join(' vs ')}`
+ };
+ } else {
+ return {
+ chartData: aggregateForTimeSeries(data),
+ uniqueYears: yearsInView,
+ isComparisonView: false,
+ chartTitle: `Weekly Sales Evolution ${yearsInView[0] || ''}`
+ };
+ }
+ }, [data]);
- // 1. Filter
- if (rowFilters.length > 0) {
- const latestYear = years[0];
- const prevYear = years[1];
+ // Filter Options based on available data
+ const metricOptions = useMemo(() => {
+ const options = [];
+ // Totals
+ years.forEach(y => {
+ options.push({ label: `Total Sell Out ${y} (€)`, value: `total_sellOut_${y}` });
+ options.push({ label: `Total Units ${y}`, value: `total_units_${y}` });
+ });
+ // Growth (Latest vs Previous)
+ if (years.length >= 2) {
+ options.push({ label: `Growth % Sell Out (${years[0]} vs ${years[1]})`, value: 'growth_sellOut' });
+ options.push({ label: `Growth % Units (${years[0]} vs ${years[1]})`, value: 'growth_units' });
+ }
+ return options;
+ }, [years]);
- result = result.filter(row => {
- return rowFilters.every(filter => {
- let rowValue = 0;
+ // Apply Advanced Row Filters THEN Sort
+ const processedRows = useMemo(() => {
+ let result = pivotRows;
- if (filter.metric.startsWith('total_sellOut_')) {
- const y = filter.metric.split('_')[2];
- rowValue = row.totalsByYear[y]?.sellOut || 0;
- }
- else if (filter.metric.startsWith('total_units_')) {
- const y = filter.metric.split('_')[2];
- rowValue = row.totalsByYear[y]?.units || 0;
- }
- else if (filter.metric === 'growth_sellOut') {
- if (!prevYear) return true;
- const curr = row.totalsByYear[latestYear]?.sellOut || 0;
- const prev = row.totalsByYear[prevYear]?.sellOut || 0;
- if (prev === 0) return curr > 0;
- rowValue = ((curr - prev) / prev) * 100;
- }
- else if (filter.metric === 'growth_units') {
- if (!prevYear) return true;
- const curr = row.totalsByYear[latestYear]?.units || 0;
- const prev = row.totalsByYear[prevYear]?.units || 0;
- if (prev === 0) return curr > 0;
- rowValue = ((curr - prev) / prev) * 100;
- }
+ // 1. Filter
+ if (rowFilters.length > 0) {
+ const latestYear = years[0];
+ const prevYear = years[1];
- if (filter.operator === 'gt') return rowValue > filter.value;
- if (filter.operator === 'lt') return rowValue < filter.value;
- return true;
+ result = result.filter(row => {
+ return rowFilters.every(filter => {
+ let rowValue = 0;
+
+ if (filter.metric.startsWith('total_sellOut_')) {
+ const y = filter.metric.split('_')[2];
+ rowValue = row.totalsByYear[y]?.sellOut || 0;
+ }
+ else if (filter.metric.startsWith('total_units_')) {
+ const y = filter.metric.split('_')[2];
+ rowValue = row.totalsByYear[y]?.units || 0;
+ }
+ else if (filter.metric === 'growth_sellOut') {
+ if (!prevYear) return true;
+ const curr = row.totalsByYear[latestYear]?.sellOut || 0;
+ const prev = row.totalsByYear[prevYear]?.sellOut || 0;
+ if (prev === 0) return curr > 0;
+ rowValue = ((curr - prev) / prev) * 100;
+ }
+ else if (filter.metric === 'growth_units') {
+ if (!prevYear) return true;
+ const curr = row.totalsByYear[latestYear]?.units || 0;
+ const prev = row.totalsByYear[prevYear]?.units || 0;
+ if (prev === 0) return curr > 0;
+ rowValue = ((curr - prev) / prev) * 100;
+ }
+
+ if (filter.operator === 'gt') return rowValue > filter.value;
+ if (filter.operator === 'lt') return rowValue < filter.value;
+ return true;
+ });
});
- });
- }
+ }
- // 2. Sort
- if (sortConfig.key) {
- result.sort((a, b) => {
- let valA: number | string = '';
- let valB: number | string = '';
+ // 2. Sort
+ if (sortConfig.key) {
+ result.sort((a, b) => {
+ let valA: number | string = '';
+ let valB: number | string = '';
- // Handle sorting by dimensions
- if (['customer', 'line', 'sku', 'title', 'articleName', 'asin'].includes(sortConfig.key as string)) {
- valA = a[sortConfig.key as keyof PivotRow] as string || '';
- valB = b[sortConfig.key as keyof PivotRow] as string || '';
- }
- // Handle sorting by Total Metrics (total_sellOut_2023)
- else if ((sortConfig.key as string).startsWith('total_')) {
- const parts = (sortConfig.key as string).split('_');
- // parts[1] = metric (sellOut/units), parts[2] = year
- if (parts.length === 3) {
- const y = parts[2];
- const m = parts[1] as 'sellOut' | 'units';
- valA = a.totalsByYear[y]?.[m] || 0;
- valB = b.totalsByYear[y]?.[m] || 0;
- }
- }
+ // Handle sorting by dimensions
+ if (['customer', 'line', 'sku', 'title', 'articleName', 'asin'].includes(sortConfig.key as string)) {
+ valA = a[sortConfig.key as keyof PivotRow] as string || '';
+ valB = b[sortConfig.key as keyof PivotRow] as string || '';
+ }
+ // Handle sorting by Total Metrics (total_sellOut_2023)
+ else if ((sortConfig.key as string).startsWith('total_')) {
+ const parts = (sortConfig.key as string).split('_');
+ // parts[1] = metric (sellOut/units), parts[2] = year
+ if (parts.length === 3) {
+ const y = parts[2];
+ const m = parts[1] as 'sellOut' | 'units';
+ valA = a.totalsByYear[y]?.[m] || 0;
+ valB = b.totalsByYear[y]?.[m] || 0;
+ }
+ }
- if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
- if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
- return 0;
- });
- }
+ if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
+ if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
+ return 0;
+ });
+ }
- return result;
- }, [pivotRows, rowFilters, sortConfig, years]);
+ return result;
+ }, [pivotRows, rowFilters, sortConfig, years]);
- const paginatedRows = useMemo(() => {
- const start = (currentPage - 1) * ROWS_PER_PAGE;
- return processedRows.slice(start, start + ROWS_PER_PAGE);
- }, [processedRows, currentPage]);
+ const paginatedRows = useMemo(() => {
+ const start = (currentPage - 1) * ROWS_PER_PAGE;
+ return processedRows.slice(start, start + ROWS_PER_PAGE);
+ }, [processedRows, currentPage]);
- const totalPages = Math.ceil(processedRows.length / ROWS_PER_PAGE);
+ const totalPages = Math.ceil(processedRows.length / ROWS_PER_PAGE);
- const requestSort = (key: string) => {
- let direction: 'asc' | 'desc' = 'desc';
- if (sortConfig.key === key && sortConfig.direction === 'desc') {
- direction = 'asc';
- }
- setSortConfig({ key, direction });
- };
+ const requestSort = (key: string) => {
+ let direction: 'asc' | 'desc' = 'desc';
+ if (sortConfig.key === key && sortConfig.direction === 'desc') {
+ direction = 'asc';
+ }
+ setSortConfig({ key, direction });
+ };
- const getSortIcon = (key: string) => {
- if (sortConfig.key !== key) return ⇅;
- return {sortConfig.direction === 'asc' ? '↑' : '↓'};
- };
+ const getSortIcon = (key: string) => {
+ if (sortConfig.key !== key) return ⇅;
+ return {sortConfig.direction === 'asc' ? '↑' : '↓'};
+ };
- const handleExport = () => {
- generateCSV(processedRows, effectiveDimensions, years);
- };
+ const handleExport = () => {
+ generateCSV(processedRows, effectiveDimensions, years);
+ };
- const addFilter = () => {
- if (newFilterMetric && newFilterValue) {
- setRowFilters([
- ...rowFilters,
- {
- id: Date.now().toString(),
- metric: newFilterMetric,
- operator: newFilterOperator,
- value: parseFloat(newFilterValue)
- }
- ]);
- setNewFilterMetric('');
- setNewFilterValue('');
- setShowFilterBuilder(false);
- }
- };
+ const addFilter = () => {
+ if (newFilterMetric && newFilterValue) {
+ setRowFilters([
+ ...rowFilters,
+ {
+ id: Date.now().toString(),
+ metric: newFilterMetric,
+ operator: newFilterOperator,
+ value: parseFloat(newFilterValue)
+ }
+ ]);
+ setNewFilterMetric('');
+ setNewFilterValue('');
+ setShowFilterBuilder(false);
+ }
+ };
- const removeFilter = (id: string) => {
- setRowFilters(rowFilters.filter(f => f.id !== id));
- };
+ const removeFilter = (id: string) => {
+ setRowFilters(rowFilters.filter(f => f.id !== id));
+ };
- // Reset pagination when filters change
- useEffect(() => {
- setCurrentPage(1);
- }, [rowFilters, data, effectiveDimensions]);
+ // Reset pagination when filters change
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [rowFilters, data, effectiveDimensions]);
- return (
-
-
- {/* 1. Time Series Chart Section */}
- {showChart && chartData.length > 0 && (
-
-
-
-
-
-
- `€${(val/1000).toFixed(0)}k`}
- />
- isComparisonView ? : } />
-
-
- {isComparisonView ? (
- uniqueYears.flatMap((year, idx) => [
- visibleMetrics.includes('sellOut') && (
-
- ),
- visibleMetrics.includes('units') && (
-
- )
- ])
- ) : (
- [
- visibleMetrics.includes('sellOut') && (
-
- ),
- visibleMetrics.includes('units') && (
-
- )
- ]
- )}
-
-
-
-
- )}
+ return (
+
- {/* 2. Controls & Grid */}
-
-
- {/* Toolbar */}
-
-
-
- {/* Dimensions Selector */}
-
-
-
+
`€${(val / 1000).toFixed(0)}k`}
+ />
+ isComparisonView ? : } />
+
- {/* Filter Builder Trigger */}
-
-
- {/* Chart Toggle */}
-
-
+ {isComparisonView ? (
+ uniqueYears.flatMap((year, idx) => [
+ visibleMetrics.includes('sellOut') && (
+
+ ),
+ visibleMetrics.includes('units') && (
+
+ )
+ ])
+ ) : (
+ [
+ visibleMetrics.includes('sellOut') && (
+
+ ),
+ visibleMetrics.includes('units') && (
+
+ )
+ ]
+ )}
+
+
+
+
+ )}
-
-
- Showing {processedRows.length} rows
-
-
-
-
-
- {/* Filter Builder Panel */}
- {showFilterBuilder && (
-
-
-
-
-
-
- );
+ );
};
export default DataGrid;
\ No newline at end of file
diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts
index 530f1f8..c6120ea 100644
--- a/services/dataProcessor.ts
+++ b/services/dataProcessor.ts
@@ -224,8 +224,8 @@ export const processCSV = (fileOrContent: File | string): Promise
const data: SalesRecord[] = results.data.map((row: any, index: number) => {
return mapRowToRecord(row, index);
})
- // Filter: Valid Year AND Allowed Customer
- .filter((r: SalesRecord) => r.year > 0 && isAllowedCustomer(r.customer));
+ // Filter: Valid Year > 2023 (exclude incomplete 2023 data) AND Allowed Customer
+ .filter((r: SalesRecord) => r.year > 2023 && isAllowedCustomer(r.customer));
resolve(data);
} catch (err) {