feat: Initialize Craze Analytix project structure

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.
This commit is contained in:
Christian
2025-12-11 11:25:26 +01:00
parent 563e6110ce
commit 9ba63ab8f8
23 changed files with 4481 additions and 8 deletions
+817
View File
@@ -0,0 +1,817 @@
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);
};
+123
View File
@@ -0,0 +1,123 @@
import { GoogleGenAI } from "@google/genai";
import { AggregatedData } from "../types";
const SYSTEM_INSTRUCTION = `
You are an expert Data Analyst Assistant for "Craze Analytix".
You have access to a structured dataset of sales performance including Revenue (Sell Out), Units, Product Lines, and Seasonality.
Your Capabilities:
1. **Analyze Trends**: Use the provided Seasonality and Yearly Breakdown data.
2. **Perform Calculations**: You have access to detailed Product Line totals. You MUST calculate growth percentages, market shares, and sums dynamically if the user asks.
3. **Compare**: Compare performance between years (e.g., 2024 vs 2025).
Rules:
- If the user asks for a calculation (e.g., "What is the % share of Line X?"), perform the math using the provided numbers.
- Always format currency as € (e.g., €1,200) and units with 'u' or 'units' (e.g., 500 units).
- Be concise but insightful. Point out significant growth or decline.
- If data is missing for a specific query, state clearly that it is not in the current filtered view.
`;
const formatCurrency = (val: number) => `${val.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}`;
const formatUnits = (val: number) => `${val.toLocaleString()} units`;
export const queryGemini = async (
apiKey: string,
question: string,
context: AggregatedData,
filteredRecordCount: number
): Promise<string> => {
if (!apiKey) {
return "Please provide your Gemini API Key in the settings to enable the AI assistant.";
}
try {
// Ensure the key is clean of whitespace
const ai = new GoogleGenAI({ apiKey: apiKey.trim() });
// --- CONTEXT GENERATION ---
// We construct a structured report mirroring the dashboard charts
// 1. Totals by Year (KPI Cards)
const yearlySummary = Object.entries(context.totalsByYear)
.sort((a, b) => parseInt(b[0]) - parseInt(a[0])) // Descending years
.map(([year, data]) => ` - ${year}: ${formatCurrency(data.sellOut)} | ${formatUnits(data.units)}`)
.join('\n');
// 2. Seasonality (Line Chart Data)
const seasonalitySummary = context.seasonality.map(p => {
const yearValues = context.availableYears.map(y => `${y}: ${formatCurrency(p[y] as number || 0)}`).join(', ');
return ` - ${p.name}: [${yearValues}]`;
}).join('\n');
// 3. Growth/Decline
const growthSummary = context.topMovers.slice(0, 10).map(m =>
` - ${m.line}: +€${m.sellOutGrowthValue.toLocaleString()} (${m.sellOutGrowthPercentage.toFixed(1)}%)`
).join('\n');
const declineSummary = context.bottomMovers.slice(0, 10).map(m =>
` - ${m.line}: -€${Math.abs(m.sellOutGrowthValue).toLocaleString()} (${m.sellOutGrowthPercentage.toFixed(1)}%)`
).join('\n');
// 4. DETAILED BREAKDOWN (For Calculations)
// We provide a JSON-like structure of ALL top product lines so the AI can compute shares/totals.
// We limit this to top 100 to avoid token limits, which covers most relevant data.
const detailedLines = context.byLine.slice(0, 100).map(l => ({
name: l.name,
revenue: l.value,
units: l.units
}));
const fullReport = `
REPORT CONTEXT (Based on Current Filters):
------------------------------------------
GLOBAL METRICS:
Total Sell Out: ${formatCurrency(context.totalSellOut)}
Total Units: ${formatUnits(context.totalUnits)}
Records Analyzed: ${filteredRecordCount}
Years Available: ${context.availableYears.join(', ')}
YEARLY TOTALS:
${yearlySummary}
MONTHLY TRENDS (Seasonality):
${seasonalitySummary}
TOP PERFORMERS (Growth YoY):
${growthSummary}
WORST PERFORMERS (Decline YoY):
${declineSummary}
DETAILED PRODUCT LINE DATA (Use this for specific calculations):
${JSON.stringify(detailedLines, null, 2)}
`;
const response = await ai.models.generateContent({
model: 'gemini-3-pro-preview', // Updated to the latest capable model for complex reasoning
contents: [
{
role: 'user',
parts: [{ text: `Context Data:\n${fullReport}\n\nUser Question: ${question}` }]
}
],
config: {
systemInstruction: SYSTEM_INSTRUCTION,
}
});
return response.text || "I couldn't generate a response based on the data provided.";
} catch (error: any) {
console.error("Gemini API Error:", error);
if (error.message && error.message.includes("403")) {
return "Error 403: Invalid API Key. Please check your key in the settings.";
}
if (error.message && error.message.includes("429")) {
return "Error 429: Quota exceeded. You are sending too many requests.";
}
return `Error: ${error.message || "An unexpected error occurred while analyzing the data."}`;
}
};
+83
View File
@@ -0,0 +1,83 @@
import { SalesRecord } from '../types';
const DB_NAME = 'CrazeAnalytixDB';
const STORE_NAME = 'salesData';
const DB_VERSION = 1;
const initDB = (): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
};
export const saveSalesData = async (data: SalesRecord[]): Promise<void> => {
try {
const db = await initDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
// Store the data array
store.put(data, 'currentData');
// Store the timestamp
store.put(new Date().toISOString(), 'lastUpdated');
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
} catch (error) {
console.error("Error saving to IndexedDB:", error);
// Fallback or silence error (data just won't be cached)
}
};
export const loadSalesData = async (): Promise<{ data: SalesRecord[]; lastUpdated: string | null }> => {
try {
const db = await initDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
const dataReq = store.get('currentData');
const dateReq = store.get('lastUpdated');
transaction.oncomplete = () => {
resolve({
data: dataReq.result || [],
lastUpdated: dateReq.result || null
});
};
transaction.onerror = () => reject(transaction.error);
});
} catch (error) {
console.error("Error loading from IndexedDB:", error);
return { data: [], lastUpdated: null };
}
};
export const clearSalesData = async (): Promise<void> => {
try {
const db = await initDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
store.clear();
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
} catch (e) {
console.error(e);
}
}