mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 10:55:24 +02:00
Sets up the project with Vite, React, Tailwind CSS, Gemini AI integration, and necessary dependencies for data analysis. Includes initial configuration for TypeScript, Tailwind, and project metadata.
817 lines
31 KiB
TypeScript
817 lines
31 KiB
TypeScript
import { SalesRecord, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types';
|
|
import * as XLSX from 'xlsx';
|
|
|
|
// Helper to parse currency values handling both EU (1.234,56) and US/Standard (1,234.56 or 1234.56) formats
|
|
const parseCurrency = (value: string): number => {
|
|
if (!value) return 0;
|
|
|
|
// Remove currency symbol and whitespace
|
|
let clean = value.replace(/[€\s]/g, '').trim();
|
|
|
|
// HEURISTIC:
|
|
// If it contains a comma, we assume it's likely European format (Decimal separator)
|
|
// UNLESS it also contains a dot and the comma is before the dot (e.g. 1,000.50 - US format)
|
|
// But given the context (DE data), comma is usually decimal.
|
|
|
|
// Case A: European Format (e.g., "277.179,09" or "50,00")
|
|
if (clean.includes(',')) {
|
|
// If it has dots (thousands), remove them
|
|
clean = clean.replace(/\./g, '');
|
|
// Replace decimal comma with dot
|
|
clean = clean.replace(',', '.');
|
|
return parseFloat(clean);
|
|
}
|
|
|
|
// Case B: Standard/US Format or Clean Number (e.g. "277179.09" or "1000")
|
|
// Just remove any potential thousands separator commas (if any exist and we didn't catch them above)
|
|
// and parse.
|
|
clean = clean.replace(/,/g, '');
|
|
const num = parseFloat(clean);
|
|
|
|
return isNaN(num) ? 0 : num;
|
|
};
|
|
|
|
const parseUnits = (value: string): number => {
|
|
if(!value) return 0;
|
|
// Remove dots (thousands separators in EU) and commas (thousands in US) just to be safe for integers
|
|
const clean = value.replace(/[\.,]/g, '');
|
|
const num = parseInt(clean, 10);
|
|
return isNaN(num) ? 0 : num;
|
|
}
|
|
|
|
const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
|
|
// Robust Month Normalizer
|
|
const normalizeMonth = (rawMonth: string): string => {
|
|
if (!rawMonth) return '';
|
|
let m = rawMonth.trim();
|
|
|
|
// Handle numeric months "01", "1", "01-2023" (start with digits)
|
|
const numMatch = m.match(/^(\d{1,2})([^\d]|$)/);
|
|
if (numMatch) {
|
|
const num = parseInt(numMatch[1]);
|
|
if (num >= 1 && num <= 12) return MONTH_ORDER[num - 1];
|
|
}
|
|
|
|
// Handle text months "Apr-23", "Apr 23", "April"
|
|
// Extract first sequence of letters
|
|
const alphaMatch = m.match(/([a-zA-Z]+)/);
|
|
if (alphaMatch) {
|
|
m = alphaMatch[1];
|
|
}
|
|
|
|
// Take first 3 characters
|
|
if (m.length > 3) {
|
|
m = m.substring(0, 3);
|
|
}
|
|
// Capitalize first letter, lowercase rest
|
|
m = m.charAt(0).toUpperCase() + m.slice(1).toLowerCase();
|
|
|
|
return m;
|
|
};
|
|
|
|
// Robust CSV Column Value Extractor
|
|
// Handles case-insensitivity, trimming, multiple potential header aliases, AND ignores empty values to find fallbacks.
|
|
const getColumnValue = (row: any, aliases: string[]): string => {
|
|
const rowKeys = Object.keys(row);
|
|
// Create a map of normalized keys in the row to the actual keys
|
|
const normalizedRowKeys: Record<string, string> = {};
|
|
rowKeys.forEach(k => {
|
|
normalizedRowKeys[k.trim().toLowerCase()] = k;
|
|
});
|
|
|
|
for (const alias of aliases) {
|
|
const lookup = alias.trim().toLowerCase();
|
|
if (normalizedRowKeys[lookup]) {
|
|
const actualKey = normalizedRowKeys[lookup];
|
|
const val = row[actualKey];
|
|
if (val !== undefined && val !== null) {
|
|
const strVal = String(val).trim();
|
|
// CRITICAL FIX: Only return if the value is NOT empty.
|
|
// This allows falling back to the next alias if the first matching column exists but is empty.
|
|
if (strVal.length > 0) {
|
|
return strVal;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
|
|
// Extracted Mapping Function
|
|
const mapRowToRecord = (row: any, index: number): SalesRecord => {
|
|
const customer = getColumnValue(row, ['NEW CUSTOMER', 'Customer', 'Client', 'Account', 'Partner', 'COUNTRY', 'Country', 'Market']) || 'Unknown';
|
|
const yearStr = getColumnValue(row, ['YEAR', 'Year', 'D']);
|
|
const year = parseInt(yearStr) || 0;
|
|
const monthStr = getColumnValue(row, ['MONTH', 'Month', 'Period']);
|
|
const month = normalizeMonth(monthStr);
|
|
const weekStr = getColumnValue(row, ['WEEK', 'Week', 'CW', 'Semana', 'KW', 'E']);
|
|
const weekNum = weekStr ? parseInt(weekStr.replace(/cw/i, '').trim(), 10) : NaN;
|
|
const week = isNaN(weekNum) ? undefined : weekNum;
|
|
const line = getColumnValue(row, ['LINE', 'Line', 'Product Line']) || 'Other';
|
|
|
|
// Updated ASIN priority list based on user feedback
|
|
const asin = getColumnValue(row, [
|
|
'CUSTOMER REFERENCE',
|
|
'AMAZON ASIN',
|
|
'ASIN',
|
|
'Asin',
|
|
'PRODUCT ID',
|
|
'ITEM IDENTIFIER',
|
|
'ASIN NO.',
|
|
'Product ASIN',
|
|
'IDENTIFIER'
|
|
]);
|
|
|
|
const sku = getColumnValue(row, ['RAW ARTICLE NO.', 'SKU', 'Sku', 'Item No']);
|
|
|
|
// Prioritize 'Title' column, fallback to 'Article Name' columns
|
|
const title = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Title', 'TITLE', 'Product Title', 'Article Name', 'ArticleName']);
|
|
|
|
// Legacy/Backup field
|
|
const articleName = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Article Name', 'ArticleName', 'Title']);
|
|
|
|
const unitsRaw = getColumnValue(row, ['UNITS', 'Units', 'Quantity', 'Qty']);
|
|
const sellOutRaw = getColumnValue(row, ['AMOUNT', 'Sell Out', 'SellOut', 'Revenue', 'Sales', 'Turnover']);
|
|
|
|
return {
|
|
id: `row-${index}`,
|
|
customer,
|
|
year,
|
|
month,
|
|
week,
|
|
asin,
|
|
sku,
|
|
title,
|
|
articleName,
|
|
units: parseUnits(unitsRaw),
|
|
sellOut: parseCurrency(sellOutRaw),
|
|
line
|
|
};
|
|
};
|
|
|
|
export const processCSV = (fileOrContent: File | string): Promise<SalesRecord[]> => {
|
|
return new Promise((resolve, reject) => {
|
|
// @ts-ignore - PapaParse is loaded globally via CDN
|
|
Papa.parse(fileOrContent, {
|
|
header: true,
|
|
// delimiter: ";", // Allow auto-detect
|
|
skipEmptyLines: true,
|
|
complete: (results: any) => {
|
|
try {
|
|
const data: SalesRecord[] = results.data.map((row: any, index: number) => {
|
|
return mapRowToRecord(row, index);
|
|
}).filter((r: SalesRecord) => r.year !== 2022 && r.line && r.line !== 'Other'); // Validation: Exclude 2022 and require line
|
|
resolve(data);
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
},
|
|
error: (error: any) => {
|
|
reject(error);
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
export const processExcel = async (file: File): Promise<SalesRecord[]> => {
|
|
try {
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const workbook = XLSX.read(arrayBuffer);
|
|
const firstSheetName = workbook.SheetNames[0];
|
|
const worksheet = workbook.Sheets[firstSheetName];
|
|
|
|
// Convert to JSON
|
|
// raw: false attempts to format the cell (e.g. dates), but for robustness we often prefer raw values or defval
|
|
// Using { defval: "" } ensures empty cells are present as empty strings if needed, but key logic handles missing keys.
|
|
const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
|
|
|
|
const data: SalesRecord[] = jsonData.map((row: any, index: number) => {
|
|
return mapRowToRecord(row, index);
|
|
}).filter((r: SalesRecord) => r.year !== 2022 && r.line && r.line !== 'Other');
|
|
|
|
return data;
|
|
} catch (error) {
|
|
console.error("Error processing Excel file:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export const filterData = (data: SalesRecord[], filters: FilterState): SalesRecord[] => {
|
|
return data.filter(item => {
|
|
// Item month is already normalized
|
|
const recordMonth = item.month;
|
|
|
|
const customerMatch = filters.customer.length === 0 || filters.customer.includes(item.customer);
|
|
const yearMatch = filters.year.length === 0 || filters.year.includes(item.year.toString());
|
|
const monthMatch = filters.month.length === 0 || filters.month.includes(recordMonth);
|
|
const lineMatch = filters.line.length === 0 || filters.line.includes(item.line);
|
|
const asinMatch = filters.asin.length === 0 || filters.asin.includes(item.asin);
|
|
const skuMatch = filters.sku.length === 0 || filters.sku.includes(item.sku);
|
|
const titleMatch = filters.title.length === 0 || filters.title.includes(item.title);
|
|
|
|
return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch;
|
|
});
|
|
};
|
|
|
|
const calculateSeasonality = (data: SalesRecord[]): { seasonality: SeasonalityPoint[], seasonalityUnits: SeasonalityPoint[], years: string[] } => {
|
|
const seasonalityMap = new Map<string, SeasonalityPoint>();
|
|
const seasonalityUnitsMap = new Map<string, SeasonalityPoint>();
|
|
const yearsSet = new Set<string>();
|
|
|
|
// Initialize all months
|
|
MONTH_ORDER.forEach(m => {
|
|
seasonalityMap.set(m, { name: m });
|
|
seasonalityUnitsMap.set(m, { name: m });
|
|
});
|
|
|
|
data.forEach(record => {
|
|
const monthName = record.month;
|
|
const yearStr = record.year.toString();
|
|
yearsSet.add(yearStr);
|
|
|
|
if (seasonalityMap.has(monthName)) {
|
|
// Sell Out
|
|
const entrySO = seasonalityMap.get(monthName)!;
|
|
const currentValSO = (entrySO[yearStr] as number) || 0;
|
|
entrySO[yearStr] = currentValSO + record.sellOut;
|
|
|
|
// Units
|
|
const entryUnits = seasonalityUnitsMap.get(monthName)!;
|
|
const currentValUnits = (entryUnits[yearStr] as number) || 0;
|
|
entryUnits[yearStr] = currentValUnits + record.units;
|
|
}
|
|
});
|
|
|
|
const seasonality = Array.from(seasonalityMap.values());
|
|
const seasonalityUnits = Array.from(seasonalityUnitsMap.values());
|
|
const years = Array.from(yearsSet).sort();
|
|
|
|
return { seasonality, seasonalityUnits, years };
|
|
};
|
|
|
|
const calculateTopLinesSplit = (data: SalesRecord[]): YearlySplitData[] => {
|
|
// 1. Identify Lines by Sell Out (Sort desc)
|
|
const lineTotals = new Map<string, number>();
|
|
data.forEach(item => {
|
|
lineTotals.set(item.line, (lineTotals.get(item.line) || 0) + item.sellOut);
|
|
});
|
|
|
|
// Return ALL lines
|
|
const topLines = Array.from(lineTotals.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([line]) => line);
|
|
|
|
// 2. Aggregate data by Year
|
|
const resultMap = new Map<string, YearlySplitData>();
|
|
|
|
topLines.forEach(line => {
|
|
resultMap.set(line, { name: line });
|
|
});
|
|
|
|
data.forEach(item => {
|
|
if (resultMap.has(item.line)) {
|
|
const entry = resultMap.get(item.line)!;
|
|
const keyVal = `${item.year}_value`;
|
|
const keyUnits = `${item.year}_units`;
|
|
|
|
entry[keyVal] = ((entry[keyVal] as number) || 0) + item.sellOut;
|
|
entry[keyUnits] = ((entry[keyUnits] as number) || 0) + item.units;
|
|
}
|
|
});
|
|
|
|
return Array.from(resultMap.values());
|
|
};
|
|
|
|
const calculateGenericSplit = (data: SalesRecord[], groupField: keyof SalesRecord, valueField: 'sellOut' | 'units', limit?: number): YearlySplitData[] => {
|
|
const totals = new Map<string, number>();
|
|
data.forEach(item => {
|
|
const key = String(item[groupField]);
|
|
totals.set(key, (totals.get(key) || 0) + item[valueField]);
|
|
});
|
|
|
|
let sortedKeys = Array.from(totals.entries()).sort((a,b) => b[1] - a[1]).map(e => e[0]);
|
|
if (limit) sortedKeys = sortedKeys.slice(0, limit);
|
|
const keySet = new Set(sortedKeys);
|
|
|
|
const resultMap = new Map<string, YearlySplitData>();
|
|
sortedKeys.forEach(k => resultMap.set(k, { name: k }));
|
|
|
|
data.forEach(item => {
|
|
const key = String(item[groupField]);
|
|
if (keySet.has(key)) {
|
|
const entry = resultMap.get(key)!;
|
|
const yearKey = item.year.toString();
|
|
entry[yearKey] = ((entry[yearKey] as number) || 0) + item[valueField];
|
|
}
|
|
});
|
|
|
|
return Array.from(resultMap.values());
|
|
};
|
|
|
|
// Renamed from calculateMovers
|
|
export const calculateLineMovers = (data: SalesRecord[]): { topMovers: LineGrowthMetric[], bottomMovers: LineGrowthMetric[], comparisonPeriods: { current: string, previous: string } } => {
|
|
const lineYearMap = new Map<string, Map<number, { sellOut: number; units: number }>>();
|
|
const allYears = new Set<number>();
|
|
|
|
data.forEach(item => {
|
|
if (!lineYearMap.has(item.line)) {
|
|
lineYearMap.set(item.line, new Map());
|
|
}
|
|
const yearMap = lineYearMap.get(item.line)!;
|
|
const current = yearMap.get(item.year) || { sellOut: 0, units: 0 };
|
|
yearMap.set(item.year, {
|
|
sellOut: current.sellOut + item.sellOut,
|
|
units: current.units + item.units
|
|
});
|
|
allYears.add(item.year);
|
|
});
|
|
|
|
const sortedYears = Array.from(allYears).sort((a, b) => b - a);
|
|
|
|
if (sortedYears.length < 2) {
|
|
return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: 'N/A', previous: 'N/A' } };
|
|
}
|
|
|
|
const currentYear = sortedYears[0];
|
|
const prevYear = sortedYears[1];
|
|
|
|
const metrics: LineGrowthMetric[] = [];
|
|
|
|
lineYearMap.forEach((yearMap, line) => {
|
|
const currData = yearMap.get(currentYear) || { sellOut: 0, units: 0 };
|
|
const prevData = yearMap.get(prevYear) || { sellOut: 0, units: 0 };
|
|
|
|
// Sell Out Growth
|
|
let sellOutGrowthValue = 0;
|
|
let sellOutGrowthPercentage = 0;
|
|
if (prevData.sellOut > 0) {
|
|
sellOutGrowthValue = currData.sellOut - prevData.sellOut;
|
|
sellOutGrowthPercentage = (sellOutGrowthValue / prevData.sellOut) * 100;
|
|
} else if (currData.sellOut > 0) {
|
|
sellOutGrowthValue = currData.sellOut;
|
|
sellOutGrowthPercentage = 100;
|
|
} else if (currData.sellOut === 0 && prevData.sellOut > 0) {
|
|
sellOutGrowthValue = -prevData.sellOut;
|
|
sellOutGrowthPercentage = -100;
|
|
}
|
|
|
|
// Unit Growth
|
|
let unitsGrowthValue = 0;
|
|
let unitsGrowthPercentage = 0;
|
|
if (prevData.units > 0) {
|
|
unitsGrowthValue = currData.units - prevData.units;
|
|
unitsGrowthPercentage = (unitsGrowthValue / prevData.units) * 100;
|
|
} else if (currData.units > 0) {
|
|
unitsGrowthValue = currData.units;
|
|
unitsGrowthPercentage = 100;
|
|
} else if (currData.units === 0 && prevData.units > 0) {
|
|
unitsGrowthValue = -prevData.units;
|
|
unitsGrowthPercentage = -100;
|
|
}
|
|
|
|
if (currData.sellOut > 0 || prevData.sellOut > 0) {
|
|
metrics.push({
|
|
line,
|
|
currentYearSellOut: currData.sellOut,
|
|
previousYearSellOut: prevData.sellOut,
|
|
sellOutGrowthValue,
|
|
sellOutGrowthPercentage,
|
|
currentYearUnits: currData.units,
|
|
previousYearUnits: prevData.units,
|
|
unitsGrowthValue,
|
|
unitsGrowthPercentage
|
|
});
|
|
}
|
|
});
|
|
|
|
const topMovers = metrics
|
|
.filter(m => m.sellOutGrowthValue > 0)
|
|
.sort((a, b) => b.sellOutGrowthValue - a.sellOutGrowthValue);
|
|
|
|
const bottomMovers = metrics
|
|
.filter(m => m.sellOutGrowthValue < 0)
|
|
.sort((a, b) => a.sellOutGrowthValue - b.sellOutGrowthValue);
|
|
|
|
return {
|
|
topMovers,
|
|
bottomMovers,
|
|
comparisonPeriods: { current: currentYear.toString(), previous: prevYear.toString() }
|
|
};
|
|
};
|
|
|
|
|
|
const createItemKey = (record: SalesRecord) => {
|
|
// A robust key combining all identifiers
|
|
return `${record.sku || 'NO_SKU'}||${record.asin || 'NO_ASIN'}||${record.title || 'NO_TITLE'}`;
|
|
}
|
|
|
|
export const calculateItemMovers = (
|
|
currentFilteredData: SalesRecord[],
|
|
selectedCustomerFromPage: string | null,
|
|
currentComparisonYearFromPage: number | null
|
|
): { topMovers: ItemGrowthMetric[], bottomMovers: ItemGrowthMetric[], comparisonPeriods: { current: string, previous: string } } => {
|
|
|
|
let dataToProcess = currentFilteredData;
|
|
|
|
// Apply customer filter if selected on the Top Movers page
|
|
if (selectedCustomerFromPage) {
|
|
dataToProcess = dataToProcess.filter(item => item.customer === selectedCustomerFromPage);
|
|
}
|
|
|
|
if (dataToProcess.length === 0) {
|
|
return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: 'N/A', previous: 'N/A' } };
|
|
}
|
|
|
|
// Map to store item data aggregated by year
|
|
const itemYearMap = new Map<string, Map<number, { sellOut: number; units: number, sku: string, asin: string, title: string, line: string }>>();
|
|
const allYearsInFilteredData = new Set<number>();
|
|
|
|
dataToProcess.forEach(item => {
|
|
const itemKey = createItemKey(item);
|
|
if (!itemYearMap.has(itemKey)) {
|
|
itemYearMap.set(itemKey, new Map());
|
|
}
|
|
const yearMap = itemYearMap.get(itemKey)!;
|
|
const current = yearMap.get(item.year) || { sellOut: 0, units: 0, sku: item.sku, asin: item.asin, title: item.title, line: item.line };
|
|
yearMap.set(item.year, {
|
|
sellOut: current.sellOut + item.sellOut,
|
|
units: current.units + item.units,
|
|
sku: item.sku,
|
|
asin: item.asin,
|
|
title: item.title,
|
|
line: item.line
|
|
});
|
|
allYearsInFilteredData.add(item.year);
|
|
});
|
|
|
|
const sortedYearsInFilteredData = Array.from(allYearsInFilteredData).sort((a, b) => b - a); // Descending (most recent first)
|
|
|
|
let currentYear: number;
|
|
let prevYear: number;
|
|
|
|
if (currentComparisonYearFromPage) {
|
|
// If a specific comparison year is provided by the user on the Top Movers page
|
|
currentYear = currentComparisonYearFromPage;
|
|
const currentYearIndex = sortedYearsInFilteredData.indexOf(currentYear);
|
|
if (currentYearIndex === -1 || currentYearIndex === sortedYearsInFilteredData.length - 1) {
|
|
// Specified year not found in filtered data or it's the oldest year (no previous year for comparison)
|
|
return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: currentYear.toString(), previous: 'N/A' } };
|
|
}
|
|
prevYear = sortedYearsInFilteredData[currentYearIndex + 1]; // The year directly before the currentComparisonYear
|
|
} else {
|
|
// Default to the two most recent years from the *filtered data* if no specific year is chosen
|
|
if (sortedYearsInFilteredData.length < 2) {
|
|
return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: 'N/A', previous: 'N/A' } };
|
|
}
|
|
currentYear = sortedYearsInFilteredData[0]; // Most recent
|
|
prevYear = sortedYearsInFilteredData[1]; // Second most recent
|
|
}
|
|
|
|
const metrics: ItemGrowthMetric[] = [];
|
|
|
|
itemYearMap.forEach((yearMap) => {
|
|
const currData = yearMap.get(currentYear) || { sellOut: 0, units: 0, sku: '', asin: '', title: '', line: '' };
|
|
const prevData = yearMap.get(prevYear) || { sellOut: 0, units: 0, sku: '', asin: '', title: '', line: '' };
|
|
|
|
// Only include items that had some activity in at least one of the comparison years
|
|
if ((currData.sellOut === 0 && currData.units === 0) && (prevData.sellOut === 0 && prevData.units === 0)) {
|
|
return;
|
|
}
|
|
|
|
// Use metadata from current year, if not available use previous (for sku/asin/title/line)
|
|
const itemMeta = currData.sku ? currData : prevData;
|
|
|
|
|
|
// Sell Out Growth
|
|
let sellOutGrowthValue = currData.sellOut - prevData.sellOut;
|
|
let sellOutGrowthPercentage = 0;
|
|
if (prevData.sellOut !== 0) {
|
|
sellOutGrowthPercentage = (sellOutGrowthValue / prevData.sellOut) * 100;
|
|
} else if (currData.sellOut > 0) {
|
|
sellOutGrowthPercentage = 100; // Growth from zero
|
|
} else if (currData.sellOut === 0 && prevData.sellOut > 0) {
|
|
sellOutGrowthPercentage = -100; // Decline to zero
|
|
}
|
|
|
|
// Unit Growth
|
|
let unitsGrowthValue = currData.units - prevData.units;
|
|
let unitsGrowthPercentage = 0;
|
|
if (prevData.units !== 0) {
|
|
unitsGrowthPercentage = (unitsGrowthValue / prevData.units) * 100;
|
|
} else if (currData.units > 0) {
|
|
unitsGrowthPercentage = 100; // Growth from zero
|
|
} else if (currData.units === 0 && prevData.units > 0) {
|
|
unitsGrowthPercentage = -100; // Decline to zero
|
|
}
|
|
|
|
metrics.push({
|
|
sku: itemMeta.sku,
|
|
asin: itemMeta.asin,
|
|
title: itemMeta.title,
|
|
line: itemMeta.line,
|
|
currentYearSellOut: currData.sellOut,
|
|
previousYearSellOut: prevData.sellOut,
|
|
sellOutGrowthValue,
|
|
sellOutGrowthPercentage,
|
|
currentYearUnits: currData.units,
|
|
previousYearUnits: prevData.units,
|
|
unitsGrowthValue,
|
|
unitsGrowthPercentage
|
|
});
|
|
});
|
|
|
|
const topMovers = metrics
|
|
.sort((a, b) => b.unitsGrowthValue - a.unitsGrowthValue) // Sort by unitsGrowthValue
|
|
.slice(0, 20); // Top 20 Gainers
|
|
|
|
const bottomMovers = metrics
|
|
.sort((a, b) => a.unitsGrowthValue - b.unitsGrowthValue) // Sort by unitsGrowthValue
|
|
.slice(0, 20); // Top 20 Losers
|
|
|
|
return {
|
|
topMovers,
|
|
bottomMovers,
|
|
comparisonPeriods: { current: currentYear.toString(), previous: prevYear.toString() }
|
|
};
|
|
};
|
|
|
|
|
|
export const aggregateData = (data: SalesRecord[]): AggregatedData => {
|
|
const totalSellOut = data.reduce((acc, curr) => acc + curr.sellOut, 0);
|
|
const totalUnits = data.reduce((acc, curr) => acc + curr.units, 0);
|
|
|
|
const totalsByYear: Record<string, { sellOut: number; units: number }> = {};
|
|
data.forEach(item => {
|
|
const y = item.year.toString();
|
|
if (!totalsByYear[y]) totalsByYear[y] = { sellOut: 0, units: 0 };
|
|
totalsByYear[y].sellOut += item.sellOut;
|
|
totalsByYear[y].units += item.units;
|
|
});
|
|
|
|
const lineMap = new Map<string, { value: number; units: number }>();
|
|
data.forEach(item => {
|
|
const current = lineMap.get(item.line) || { value: 0, units: 0 };
|
|
lineMap.set(item.line, {
|
|
value: current.value + item.sellOut,
|
|
units: current.units + item.units
|
|
});
|
|
});
|
|
const byLine = Array.from(lineMap.entries())
|
|
.map(([name, data]) => ({ name, value: data.value, units: data.units }))
|
|
.sort((a, b) => b.value - a.value);
|
|
|
|
const customerMap = new Map<string, number>();
|
|
data.forEach(item => {
|
|
customerMap.set(item.customer, (customerMap.get(item.customer) || 0) + item.sellOut);
|
|
});
|
|
const byCustomer = Array.from(customerMap.entries())
|
|
.map(([name, value]) => ({ name, value }))
|
|
.sort((a, b) => b.value - a.value);
|
|
|
|
const { seasonality, seasonalityUnits, years } = calculateSeasonality(data);
|
|
const { topMovers, bottomMovers, comparisonPeriods } = calculateLineMovers(data); // Use calculateLineMovers
|
|
const topLinesSplit = calculateTopLinesSplit(data);
|
|
const byCustomerSplit = calculateGenericSplit(data, 'customer', 'sellOut');
|
|
const byLineOverviewSplit = calculateGenericSplit(data, 'line', 'units', 10);
|
|
|
|
return {
|
|
totalSellOut,
|
|
totalUnits,
|
|
totalsByYear,
|
|
byLine,
|
|
byCustomer,
|
|
seasonality,
|
|
seasonalityUnits,
|
|
availableYears: years,
|
|
topMovers,
|
|
bottomMovers,
|
|
comparisonPeriods,
|
|
topLinesSplit,
|
|
byCustomerSplit,
|
|
byLineOverviewSplit
|
|
};
|
|
};
|
|
|
|
export const getUniqueValues = (data: SalesRecord[], field: keyof SalesRecord): string[] => {
|
|
const values = new Set(data.map(item => String(item[field])));
|
|
return Array.from(values).sort();
|
|
};
|
|
|
|
export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['title', 'customer', 'line', 'sku']): { rows: PivotRow[], years: string[] } => {
|
|
// 1. Determine all years present in the data for columns
|
|
const yearsSet = new Set(data.map(d => d.year));
|
|
const years = Array.from(yearsSet).sort((a,b) => b-a).map(String);
|
|
|
|
const map = new Map<string, PivotRow>();
|
|
|
|
data.forEach(record => {
|
|
// Group by Dynamic Dimensions
|
|
const keyParts = dimensions.map(dim => String(record[dim as keyof SalesRecord] || ''));
|
|
const key = keyParts.join('||');
|
|
|
|
if (!map.has(key)) {
|
|
map.set(key, {
|
|
id: key,
|
|
customer: dimensions.includes('customer') ? record.customer : '',
|
|
line: dimensions.includes('line') ? record.line : '',
|
|
title: dimensions.includes('title') ? record.title : '',
|
|
articleName: dimensions.includes('articleName') ? record.articleName : '',
|
|
sku: dimensions.includes('sku') ? record.sku : '',
|
|
asin: dimensions.includes('asin') ? record.asin : '',
|
|
// Initialize 12 months with empty year maps
|
|
months: Array(12).fill(null).map((_, i) => ({
|
|
monthIndex: i,
|
|
byYear: {}
|
|
})),
|
|
totalsByYear: {}
|
|
});
|
|
}
|
|
|
|
const row = map.get(key)!;
|
|
const monthPart = record.month;
|
|
const monthIdx = MONTH_ORDER.indexOf(monthPart);
|
|
const yearStr = record.year.toString();
|
|
|
|
// 1. Update Row Totals for Year
|
|
if (!row.totalsByYear[yearStr]) {
|
|
row.totalsByYear[yearStr] = { sellOut: 0, units: 0 };
|
|
}
|
|
row.totalsByYear[yearStr].sellOut += record.sellOut;
|
|
row.totalsByYear[yearStr].units += record.units;
|
|
|
|
// 2. Update Monthly Data
|
|
if (monthIdx !== -1) {
|
|
const m = row.months[monthIdx];
|
|
if (!m.byYear[yearStr]) {
|
|
m.byYear[yearStr] = { sellOut: 0, units: 0 };
|
|
}
|
|
m.byYear[yearStr].sellOut += record.sellOut;
|
|
m.byYear[yearStr].units += record.units;
|
|
}
|
|
});
|
|
|
|
return {
|
|
rows: Array.from(map.values()),
|
|
years
|
|
};
|
|
};
|
|
|
|
export const generateCSV = (rows: PivotRow[], dimensions: string[], years: string[]) => {
|
|
// Flatten PivotRows into CSV-friendly objects
|
|
const flatData = rows.map(row => {
|
|
const flatRow: any = {};
|
|
|
|
// Add Dimension Columns
|
|
dimensions.forEach(dim => {
|
|
// Map internal key to nicer Header if needed
|
|
let header = dim;
|
|
if (dim === 'line') header = 'Product Line';
|
|
if (dim === 'title') header = 'Title';
|
|
if (dim === 'customer') header = 'Customer';
|
|
|
|
flatRow[header] = row[dim as keyof PivotRow];
|
|
});
|
|
|
|
// Add Yearly Totals
|
|
years.forEach(year => {
|
|
const data = row.totalsByYear[year];
|
|
flatRow[`Total Sell Out ${year}`] = data?.sellOut || 0;
|
|
flatRow[`Total Units ${year}`] = data?.units || 0;
|
|
});
|
|
|
|
// Add Monthly Data
|
|
row.months.forEach(m => {
|
|
const monthName = MONTH_ORDER[m.monthIndex];
|
|
years.forEach(year => {
|
|
const data = m.byYear[year];
|
|
flatRow[`${monthName} ${year} Sell Out`] = data?.sellOut || 0;
|
|
flatRow[`${monthName} ${year} Units`] = data?.units || 0;
|
|
});
|
|
});
|
|
|
|
return flatRow;
|
|
});
|
|
|
|
// Generate CSV string
|
|
// @ts-ignore
|
|
const csv = Papa.unparse(flatData);
|
|
|
|
// Trigger Download
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.setAttribute('download', `sales_export_${new Date().toISOString().split('T')[0]}.csv`);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
};
|
|
|
|
export const generateItemMoversCSV = (
|
|
data: ItemGrowthMetric[],
|
|
periods: { current: string; previous: string },
|
|
type: 'Gainers' | 'Losers'
|
|
) => {
|
|
const flatData = data.map(item => ({
|
|
SKU: item.sku || '-',
|
|
ASIN: item.asin || '-',
|
|
'Product Title': item.title || '-',
|
|
'Product Line': item.line || '-',
|
|
[`Sell Out ${periods.previous}`]: item.previousYearSellOut.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}),
|
|
[`Sell Out ${periods.current}`]: item.currentYearSellOut.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}),
|
|
'SO Diff': item.sellOutGrowthValue.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}),
|
|
'SO Growth %': item.sellOutGrowthPercentage.toLocaleString('de-DE', {minimumFractionDigits: 1, maximumFractionDigits: 1}) + '%',
|
|
[`Units ${periods.previous}`]: item.previousYearUnits.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}),
|
|
[`Units ${periods.current}`]: item.currentYearUnits.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}),
|
|
'Units Diff': item.unitsGrowthValue.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}),
|
|
'Units Growth %': item.unitsGrowthPercentage.toLocaleString('de-DE', {minimumFractionDigits: 1, maximumFractionDigits: 1}) + '%',
|
|
}));
|
|
|
|
// @ts-ignore
|
|
const csv = Papa.unparse(flatData);
|
|
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.setAttribute('download', `${type}_${periods.current}_vs_${periods.previous}_${new Date().toISOString().split('T')[0]}.csv`);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
};
|
|
|
|
|
|
export const aggregateForTimeSeries = (data: SalesRecord[]): TimeSeriesData[] => {
|
|
const map = new Map<string, { sellOut: number; units: number }>();
|
|
const recordsWithWeek = data.filter(r => r.week != null && r.year != null && r.week >= 1 && r.week <= 53);
|
|
|
|
if (recordsWithWeek.length === 0) return []; // No weekly data to process
|
|
|
|
recordsWithWeek.forEach(record => {
|
|
// Create a sortable key YYYY-WW
|
|
const weekStr = record.week!.toString().padStart(2, '0');
|
|
const key = `${record.year}-${weekStr}`;
|
|
|
|
const current = map.get(key) || { sellOut: 0, units: 0 };
|
|
current.sellOut += record.sellOut;
|
|
current.units += record.units;
|
|
map.set(key, current);
|
|
});
|
|
|
|
// Convert map to array and sort chronologically
|
|
return Array.from(map.entries())
|
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
.map(([key, values]) => {
|
|
const [year, weekNum] = key.split('-');
|
|
const yearShort = year.substring(2);
|
|
|
|
return {
|
|
name: `W${weekNum} '${yearShort}`,
|
|
sellOut: values.sellOut,
|
|
units: values.units
|
|
};
|
|
});
|
|
};
|
|
|
|
export const aggregateForComparisonTimeSeries = (data: SalesRecord[]): ComparisonTimeSeriesPoint[] => {
|
|
const map = new Map<number, { [key: string]: number }>(); // Key is week number
|
|
const years = Array.from(new Set(data.map(d => d.year)));
|
|
|
|
// Initialize map for all 53 possible weeks to ensure a consistent X-axis
|
|
for (let i = 1; i <= 53; i++) {
|
|
const initialWeekData: { [key: string]: number } = {};
|
|
years.forEach(year => {
|
|
initialWeekData[`${year}_sellOut`] = 0;
|
|
initialWeekData[`${year}_units`] = 0;
|
|
});
|
|
map.set(i, initialWeekData);
|
|
}
|
|
|
|
data.forEach(record => {
|
|
if (record.week != null && record.year != null && record.week >= 1 && record.week <= 53) {
|
|
const weekData = map.get(record.week)!;
|
|
|
|
const sellOutKey = `${record.year}_sellOut`;
|
|
const unitsKey = `${record.year}_units`;
|
|
|
|
weekData[sellOutKey] = (weekData[sellOutKey] || 0) + record.sellOut;
|
|
weekData[unitsKey] = (weekData[unitsKey] || 0) + record.units;
|
|
|
|
map.set(record.week, weekData);
|
|
}
|
|
});
|
|
|
|
// Convert map to array, filter out weeks with no data across all years, and sort
|
|
return Array.from(map.entries())
|
|
.map(([week, values]) => ({
|
|
week,
|
|
name: `W${week}`,
|
|
...values,
|
|
}))
|
|
.filter(d => {
|
|
// Check if there is any non-zero value for this week
|
|
return Object.values(d).some(val => typeof val === 'number' && val > 0);
|
|
})
|
|
.sort((a, b) => a.week - b.week);
|
|
}; |