From 31eed97ec44156384a5d472c9d48265b40976c89 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 11 Dec 2025 14:03:33 +0100 Subject: [PATCH] feat: Integrate PapaParse for CSV handling Adds papaparse as a dependency and updates the data processing service to use it for more robust CSV file parsing. This replaces manual CSV parsing logic with a dedicated library, improving reliability and handling of various CSV formats. Also renames the `MoversIcon` to `TrendingIcon` to better reflect its usage in indicating trending performance metrics. --- App.tsx | 87 +--- components/AIChat.tsx | 179 ++----- components/Dashboard.tsx | 279 +++++----- components/DataGrid.tsx | 922 ++++++++++++--------------------- components/FileUpload.tsx | 9 +- components/Icons.tsx | 6 +- components/ItemGrowthTable.tsx | 198 ------- components/TopMovers.tsx | 326 ++++++++++++ components/TopMoversPage.tsx | 92 ---- index.html | 3 +- package.json | 3 +- services/dataProcessor.ts | 380 ++++++++++++-- services/geminiService.ts | 101 ++-- types.ts | 59 ++- 14 files changed, 1311 insertions(+), 1333 deletions(-) delete mode 100644 components/ItemGrowthTable.tsx create mode 100644 components/TopMovers.tsx delete mode 100644 components/TopMoversPage.tsx diff --git a/App.tsx b/App.tsx index b69cc53..ba1d09c 100644 --- a/App.tsx +++ b/App.tsx @@ -1,15 +1,16 @@ + import React, { useState, useMemo, useEffect, useCallback } from 'react'; import FileUpload from './components/FileUpload'; import Dashboard from './components/Dashboard'; import DataGrid from './components/DataGrid'; +import TopMovers from './components/TopMovers'; import FilterBar from './components/FilterBar'; import AIChat from './components/AIChat'; import CrazeLogo from './components/CrazeLogo'; -import TopMoversPage from './components/TopMoversPage'; // New import import { SalesRecord, FilterState, AggregatedData } from './types'; -import { processCSV, processExcel, filterData, aggregateData, getUniqueValues } from './services/dataProcessor'; +import { processCSV, filterData, aggregateData, getUniqueValues } from './services/dataProcessor'; import { queryGemini } from './services/geminiService'; -import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon } from './components/Icons'; +import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon } from './components/Icons'; import { loadSalesData, saveSalesData, clearSalesData } from './services/storage'; // New Refresh Icon @@ -19,13 +20,6 @@ const RefreshIcon = ({ className }: { className?: string }) => ( ); -// New Movers Icon -const MoversIcon = ({ className }: { className?: string }) => ( - - - -); - // Hardcoded Permanent URL for Auto-Loading // Using the original share link to leverage Dropbox's redirect for robust fetching. const PERMANENT_DROPBOX_URL = "https://www.dropbox.com/scl/fi/b9zxn4z5i7sxwfakk5g5y/Amazon-Sell-Out-2023-2025.csv?rlkey=uoto6v0mm99py8nszy8ldtez8&dl=0"; @@ -34,14 +28,11 @@ const App: React.FC = () => { const [rawData, setRawData] = useState([]); const [loading, setLoading] = useState(true); const [syncing, setSyncing] = useState(false); - const [view, setView] = useState<'dashboard' | 'table' | 'topMovers'>('dashboard'); // Added 'topMovers' + const [view, setView] = useState<'dashboard' | 'table' | 'movers'>('dashboard'); const [isChatOpen, setIsChatOpen] = useState(false); const [activeUrl, setActiveUrl] = useState(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL); const [lastUpdated, setLastUpdated] = useState(null); - // API Key State - const [apiKey, setApiKey] = useState(() => localStorage.getItem('gemini_api_key') || ''); - // Modal State const [isDataModalOpen, setIsDataModalOpen] = useState(false); @@ -56,16 +47,6 @@ const App: React.FC = () => { title: [], }); - // Handle API Key Change - const handleApiKeyChange = (key: string) => { - setApiKey(key); - if (key) { - localStorage.setItem('gemini_api_key', key); - } else { - localStorage.removeItem('gemini_api_key'); - } - }; - // Handle URL Fetch (Auto/Manual) const handleUrlFetch = useCallback(async (url: string) => { setSyncing(true); @@ -79,6 +60,8 @@ const App: React.FC = () => { } // Use a CORS proxy to bypass browser's same-origin policy restrictions. + // This is necessary because Dropbox does not send the required CORS headers + // for direct client-side fetching from another domain. const proxyUrl = `https://corsproxy.io/?${encodeURIComponent(directUrl)}`; const response = await fetch(proxyUrl); @@ -152,24 +135,14 @@ const App: React.FC = () => { const handleFileUpload = async (file: File) => { setSyncing(true); try { - let data: SalesRecord[] = []; - const lowerName = file.name.toLowerCase(); - - if (lowerName.endsWith('.csv')) { - data = await processCSV(file); - } else if (lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')) { - data = await processExcel(file); - } else { - throw new Error("Unsupported file format"); - } - + const data = await processCSV(file); await saveSalesData(data); initializeData(data); setLastUpdated(new Date().toISOString()); setIsDataModalOpen(false); } catch (error) { - console.error("Failed to parse file", error); - alert("Error parsing file. Please check format (CSV or Excel)."); + console.error("Failed to parse CSV", error); + alert("Error parsing CSV. Please check the format."); } finally { setSyncing(false); } @@ -254,7 +227,7 @@ const App: React.FC = () => { }; const handleAskGemini = async (text: string) => { - return await queryGemini(apiKey, text, aggregatedData, filteredData.length); + return await queryGemini(text, aggregatedData, filteredData.length); }; const disconnectUrl = async () => { @@ -329,12 +302,12 @@ const App: React.FC = () => { > Data Grid - @@ -352,37 +325,23 @@ const App: React.FC = () => { ) : ( <> - {/* FilterBar is now relevant for all views including Top Movers */} - {(view === 'dashboard' || view === 'table' || view === 'topMovers') && ( - - )} - +
- {view === 'dashboard' ? ( + {view === 'dashboard' && ( - ) : view === 'table' ? ( - - ) : ( // New Top Movers View - )} + {view === 'table' && } + {view === 'movers' && }
)} - {/* Chat Assistant - Now with API Key Props */} - + {/* Chat Assistant */} + {/* DATA MODAL */} {isDataModalOpen && ( @@ -413,4 +372,4 @@ const App: React.FC = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/components/AIChat.tsx b/components/AIChat.tsx index c31899c..51780c5 100644 --- a/components/AIChat.tsx +++ b/components/AIChat.tsx @@ -1,4 +1,3 @@ - import React, { useState, useRef, useEffect } from 'react'; import { ChatIcon, CloseIcon, SendIcon } from './Icons'; import { ChatMessage } from '../types'; @@ -7,8 +6,6 @@ interface AIChatProps { onSendMessage: (text: string) => Promise; isOpen: boolean; setIsOpen: (open: boolean) => void; - apiKey: string; - onApiKeyChange: (key: string) => void; } const ModelMessage: React.FC<{ text: string }> = ({ text }) => { @@ -58,37 +55,23 @@ const ModelMessage: React.FC<{ text: string }> = ({ text }) => { return <>{elements.length > 0 ? elements :

{text}

}; }; -const SettingsIcon = () => ( - - - -); - -const AIChat: React.FC = ({ onSendMessage, isOpen, setIsOpen, apiKey, onApiKeyChange }) => { +const AIChat: React.FC = ({ onSendMessage, isOpen, setIsOpen }) => { const [messages, setMessages] = useState([ - { role: 'model', text: 'Hello! I am your AI Data Analyst. I can answer questions about your data, analyze trends, and perform calculations.', timestamp: new Date() } + { role: 'model', text: 'Hello! I am your Sales Data Assistant. Ask me anything about the loaded data.', timestamp: new Date() } ]); const [input, setInput] = useState(''); const [isTyping, setIsTyping] = useState(false); - const [showConfig, setShowConfig] = useState(!apiKey); const messagesEndRef = useRef(null); const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; - useEffect(() => { - if (isOpen) scrollToBottom(); - }, [messages, isOpen, showConfig]); - - useEffect(() => { - // If no API key is present when opened, show config - if (!apiKey) setShowConfig(true); - }, [apiKey]); + useEffect(scrollToBottom, [messages, isOpen]); const handleSend = async () => { - if (!input.trim() || !apiKey) return; + if (!input.trim()) return; const userMsg: ChatMessage = { role: 'user', text: input, timestamp: new Date() }; setMessages(prev => [...prev, userMsg]); @@ -108,12 +91,6 @@ const AIChat: React.FC = ({ onSendMessage, isOpen, setIsOpen, apiKe } }; - const handleSaveKey = (e: React.FormEvent) => { - e.preventDefault(); - // Input value is already bound to parent state via local var, but we use form submission to switch view - if (apiKey) setShowConfig(false); - }; - return ( <> {/* Trigger Button */} @@ -135,113 +112,63 @@ const AIChat: React.FC = ({ onSendMessage, isOpen, setIsOpen, apiKe {/* Header */}
-
-

AI Data Assistant

+
+

Data Assistant

-
+ +
+ + {/* Messages */} +
+ {messages.map((msg, idx) => ( +
+
+ {msg.role === 'model' ? : msg.text} +
+
+ ))} + {isTyping && ( +
+
+
+
+
+
+
+ )} +
+
+ + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask about trends, totals..." + className="w-full bg-slate-950 border border-slate-700 text-slate-200 rounded-full py-3 pl-4 pr-12 focus:outline-none focus:border-primary transition-colors placeholder-slate-500 text-sm" + /> -
- - {/* Configuration Screen */} - {showConfig ? ( -
-
-
- -
-

Connect Gemini AI

-

- To enable the AI assistant, please enter your Google Gemini API Key. -

-
- -
-
- - onApiKeyChange(e.target.value)} - placeholder="AIzaSy..." - className="w-full bg-slate-900 border border-slate-700 rounded-lg px-4 py-3 text-white focus:ring-2 focus:ring-indigo-500 outline-none" - required - /> -

- Key is stored locally in your browser. - Get a key here. -

-
- -
-
- ) : ( - <> - {/* Messages */} -
- {messages.map((msg, idx) => ( -
-
- {msg.role === 'model' ? : msg.text} -
-
- ))} - {isTyping && ( -
-
-
-
-
-
-
- )} -
-
- - {/* Input */} -
-
- setInput(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Ask about revenue, growth, units..." - className="w-full bg-slate-950 border border-slate-700 text-slate-200 rounded-full py-3 pl-4 pr-12 focus:outline-none focus:border-primary transition-colors placeholder-slate-500 text-sm" - /> - -
-
- - )}
); }; -export default AIChat; +export default AIChat; \ No newline at end of file diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index 3751849..53e4168 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -1,13 +1,10 @@ - - import React, { useState, useMemo, useEffect } from 'react'; -import { AggregatedData, LineGrowthMetric } from '../types'; // Updated import for LineGrowthMetric +import { AggregatedData, GrowthMetric } from '../types'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, Legend } from 'recharts'; -import { DownloadIcon } from './Icons'; // Import DownloadIcon interface DashboardProps { data: AggregatedData; @@ -17,13 +14,7 @@ interface DashboardProps { const COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0ea5e9']; // Reusable Expandable Card Component -export const ExpandableCard: React.FC<{ - title: string; - children: React.ReactNode; - className?: string; - onExport?: () => void; // Optional export function - exportFileName?: string; // Optional export file name -}> = ({ title, children, className, onExport, exportFileName }) => { +const ExpandableCard: React.FC<{ title: string; children: React.ReactNode; className?: string }> = ({ title, children, className }) => { const [isExpanded, setIsExpanded] = useState(false); const toggleExpand = () => setIsExpanded(!isExpanded); @@ -58,14 +49,6 @@ export const ExpandableCard: React.FC<{

{title}

- {onExport && ( - - )}
{children} @@ -80,26 +63,15 @@ export const ExpandableCard: React.FC<{ >

{title}

-
- {onExport && ( - - )} - -
+
{children}
@@ -325,11 +297,11 @@ const ComparisonTooltip = ({ active, payload, label, metric }: any) => { const GrowthTable: React.FC<{ title: string; - data: LineGrowthMetric[]; // Updated to LineGrowthMetric + data: GrowthMetric[]; type: 'growth' | 'decline'; periods: { current: string; previous: string }; }> = ({ title, data, type, periods }) => { - const [sortConfig, setSortConfig] = useState<{ key: keyof LineGrowthMetric | null; direction: 'asc' | 'desc' }>({ key: null, direction: 'desc' }); + const [sortConfig, setSortConfig] = useState<{ key: keyof GrowthMetric | null; direction: 'asc' | 'desc' }>({ key: null, direction: 'desc' }); const sortedData = useMemo(() => { if (!sortConfig.key) return data; @@ -344,7 +316,7 @@ const GrowthTable: React.FC<{ }); }, [data, sortConfig]); - const requestSort = (key: keyof LineGrowthMetric) => { + const requestSort = (key: keyof GrowthMetric) => { let direction: 'asc' | 'desc' = 'desc'; // If already sorting by this key, toggle direction if (sortConfig.key === key && sortConfig.direction === 'desc') { @@ -353,7 +325,7 @@ const GrowthTable: React.FC<{ setSortConfig({ key, direction }); }; - const getSortIndicator = (key: keyof LineGrowthMetric) => { + const getSortIndicator = (key: keyof GrowthMetric) => { if (sortConfig.key !== key) { return ( @@ -372,112 +344,113 @@ const GrowthTable: React.FC<{ }; return ( -
- - - - - - {/* Sell Out Columns */} - - - - + +
+
requestSort('line')} - > -
Product Line {getSortIndicator('line')}
-
requestSort('previousYearSellOut')} - > -
Sell Out {periods.previous} {getSortIndicator('previousYearSellOut')}
-
requestSort('currentYearSellOut')} - > -
Sell Out {periods.current} {getSortIndicator('currentYearSellOut')}
-
requestSort('sellOutGrowthValue')} - > -
SO Diff {getSortIndicator('sellOutGrowthValue')}
-
requestSort('sellOutGrowthPercentage')} - > -
SO Growth % {getSortIndicator('sellOutGrowthPercentage')}
-
+ + + + + {/* Sell Out Columns */} + + + + - {/* Units Columns */} - - - - - - - - {sortedData.length > 0 ? ( - sortedData.map((item, idx) => ( - - - - {/* Sell Out Columns */} - - - - + {/* Units Columns */} + + + + + + + + {sortedData.length > 0 ? ( + sortedData.map((item, idx) => ( + + + + {/* Sell Out Columns */} + + + + - {/* Units Columns */} - - - - + + + + + )) + ) : ( + + - )) - ) : ( - - - - )} - -
requestSort('line')} + > +
Product Line {getSortIndicator('line')}
+
requestSort('previousYearSellOut')} + > +
Sell Out {periods.previous} {getSortIndicator('previousYearSellOut')}
+
requestSort('currentYearSellOut')} + > +
Sell Out {periods.current} {getSortIndicator('currentYearSellOut')}
+
requestSort('sellOutGrowthValue')} + > +
SO Diff {getSortIndicator('sellOutGrowthValue')}
+
requestSort('sellOutGrowthPercentage')} + > +
SO Growth % {getSortIndicator('sellOutGrowthPercentage')}
+
requestSort('previousYearUnits')} - > -
Units {periods.previous} {getSortIndicator('previousYearUnits')}
-
requestSort('currentYearUnits')} - > -
Units {periods.current} {getSortIndicator('currentYearUnits')}
-
requestSort('unitsGrowthValue')} - > -
Units Diff {getSortIndicator('unitsGrowthValue')}
-
requestSort('unitsGrowthPercentage')} - > -
Units Growth % {getSortIndicator('unitsGrowthPercentage')}
-
{item.line}€{item.previousYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}€{item.currentYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}= 0 ? 'text-emerald-400' : 'text-red-400'}`}> - {item.sellOutGrowthValue > 0 ? '+' : ''}€{item.sellOutGrowthValue.toLocaleString(undefined, {maximumFractionDigits: 0})} - {/* {item.sellOutGrowthValue.toFixed(0)} */} - - = 0 ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}> - {item.sellOutGrowthPercentage.toFixed(1)}% - - requestSort('previousYearUnits')} + > +
Units {periods.previous} {getSortIndicator('previousYearUnits')}
+
requestSort('currentYearUnits')} + > +
Units {periods.current} {getSortIndicator('currentYearUnits')}
+
requestSort('unitsGrowthValue')} + > +
Units Diff {getSortIndicator('unitsGrowthValue')}
+
requestSort('unitsGrowthPercentage')} + > +
Units Growth % {getSortIndicator('unitsGrowthPercentage')}
+
{item.line}€{item.previousYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}€{item.currentYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}= 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {item.sellOutGrowthValue > 0 ? '+' : ''}€{item.sellOutGrowthValue.toLocaleString(undefined, {maximumFractionDigits: 0})} + + = 0 ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}> + {item.sellOutGrowthPercentage.toFixed(1)}% + + {item.previousYearUnits.toLocaleString()}{item.currentYearUnits.toLocaleString()}= 0 ? 'text-violet-400' : 'text-orange-400'}`}> - {item.unitsGrowthValue > 0 ? '+' : ''}{item.unitsGrowthValue.toLocaleString()} - - = 0 ? 'bg-violet-500/10 text-violet-400' : 'bg-orange-500/10 text-orange-400'}`}> - {item.unitsGrowthPercentage.toFixed(1)}% - + {/* Units Columns */} + {item.previousYearUnits.toLocaleString()}{item.currentYearUnits.toLocaleString()}= 0 ? 'text-violet-400' : 'text-orange-400'}`}> + {item.unitsGrowthValue > 0 ? '+' : ''}{item.unitsGrowthValue.toLocaleString()} + + = 0 ? 'bg-violet-500/10 text-violet-400' : 'bg-orange-500/10 text-orange-400'}`}> + {item.unitsGrowthPercentage.toFixed(1)}% + +
+ Insufficient data to calculate {type}. (Select at least 2 distinct years/periods)
- Insufficient data to calculate {type}. (Select at least 2 distinct years/periods) -
-
+ )} + + +
+ ); } @@ -572,30 +545,24 @@ const Dashboard: React.FC = ({ data, contextData }) => { {/* Growth Table */} - +
- +
{/* Decline Table */} - +
- +
{/* Right Column */} @@ -709,4 +676,4 @@ const Dashboard: React.FC = ({ data, contextData }) => { ); }; -export default Dashboard; \ No newline at end of file +export default Dashboard; diff --git a/components/DataGrid.tsx b/components/DataGrid.tsx index 06c5d1f..67b6364 100644 --- a/components/DataGrid.tsx +++ b/components/DataGrid.tsx @@ -1,12 +1,9 @@ - - import React, { useState, useMemo, useEffect } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; -import { SalesRecord, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types'; +import { SalesRecord, PivotRow } from '../types'; import { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries } from '../services/dataProcessor'; -import MultiSelectDropdown from './MultiSelectDropdown'; import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon } from './Icons'; interface DataGridProps { @@ -14,7 +11,7 @@ interface DataGridProps { } type SortConfig = { - key: keyof PivotRow | string | null; // string for dynamic year sorting + key: string | null; direction: 'asc' | 'desc'; }; @@ -26,7 +23,6 @@ type ConditionalFilter = { } const ROWS_PER_PAGE = 50; -const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const CHART_COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0ea5e9']; // Available grouping dimensions @@ -182,7 +178,7 @@ const ComparisonTooltip = ({ active, payload, label }: any) => { return null; }; -// Reusable Expandable Card for the Chart +// Reusable Expandable Chart Card const ExpandableChartCard: React.FC<{ title: string; children: React.ReactNode; className?: string }> = ({ title, children, className }) => { const [isExpanded, setIsExpanded] = useState(false); @@ -257,7 +253,7 @@ const DataGrid: React.FC = ({ data }) => { // 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, b) => parseInt(b) - parseInt(a)); + 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; if (isMultiYear) { @@ -293,9 +289,6 @@ const DataGrid: React.FC = ({ data }) => { return options; }, [years]); - // Default sorting - const effectiveSortKey = sortConfig.key || (years.length > 0 ? `total_${years[0]}` : null); - // Apply Advanced Row Filters THEN Sort const processedRows = useMemo(() => { let result = pivotRows; @@ -325,7 +318,7 @@ const DataGrid: React.FC = ({ data }) => { rowValue = ((curr - prev) / prev) * 100; } else if (filter.metric === 'growth_units') { - if (!prevYear) return true; + if (!prevYear) return true; const curr = row.totalsByYear[latestYear]?.units || 0; const prev = row.totalsByYear[prevYear]?.units || 0; if (prev === 0) return curr > 0; @@ -340,642 +333,413 @@ const DataGrid: React.FC = ({ data }) => { } // 2. Sort - if (effectiveSortKey) { - result = [...result].sort((a, b) => { - let aVal: string | number = 0; - let bVal: string | number = 0; + if (sortConfig.key) { + result.sort((a, b) => { + let valA: number | string = ''; + let valB: number | string = ''; - const sortKeyStr = String(effectiveSortKey); - - if (effectiveDimensions.includes(sortKeyStr)) { - const key = sortKeyStr as keyof PivotRow; - const valA = a[key]; - const valB = b[key]; - if (typeof valA === 'string' || typeof valA === 'number') { - aVal = valA; - } - if (typeof valB === 'string' || typeof valB === 'number') { - bVal = valB; - } - } else if (sortKeyStr.startsWith('total_')) { - const year = sortKeyStr.split('_')[1]; - aVal = a.totalsByYear[year]?.sellOut || 0; - bVal = b.totalsByYear[year]?.sellOut || 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 (typeof aVal === 'string' && typeof bVal === 'string') { - return sortConfig.direction === 'asc' - ? String(aVal).localeCompare(String(bVal)) - : String(bVal).localeCompare(String(aVal)); - } - - if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1; - if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1; + if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1; + if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1; return 0; }); } return result; - }, [pivotRows, rowFilters, effectiveSortKey, sortConfig, effectiveDimensions, years]); + }, [pivotRows, rowFilters, sortConfig, years]); - - // Grand Totals (Calculated on Filtered Rows for context) - const grandTotals = useMemo(() => { - const accTotalsByYear: Record = {}; - const accMonthsByYear: Record> = {}; - - years.forEach(y => { - accTotalsByYear[y] = { sellOut: 0, units: 0 }; - }); - for(let i=0; i<12; i++) { - accMonthsByYear[i] = {}; - years.forEach(y => { - accMonthsByYear[i][y] = { sellOut: 0, units: 0 }; - }); - } - - processedRows.forEach(row => { - // Totals - Object.entries(row.totalsByYear).forEach(([y, val]) => { - const v = val as YearlyData; - if (accTotalsByYear[y]) { - accTotalsByYear[y].sellOut += v.sellOut; - accTotalsByYear[y].units += v.units; - } - }); - - // Months - row.months.forEach((m, idx) => { - Object.entries(m.byYear).forEach(([y, val]) => { - const v = val as YearlyData; - if (accMonthsByYear[idx][y]) { - accMonthsByYear[idx][y].sellOut += v.sellOut; - accMonthsByYear[idx][y].units += v.units; - } - }); - }); - }); - - return { totalsByYear: accTotalsByYear, months: accMonthsByYear }; - }, [processedRows, years]); - - // Pagination - const totalPages = Math.ceil(processedRows.length / ROWS_PER_PAGE); - const currentRows = useMemo(() => { + const paginatedRows = useMemo(() => { const start = (currentPage - 1) * ROWS_PER_PAGE; return processedRows.slice(start, start + ROWS_PER_PAGE); }, [processedRows, currentPage]); - // Handlers + 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 }); - setCurrentPage(1); }; - const handlePrev = () => setCurrentPage(p => Math.max(1, p - 1)); - const handleNext = () => setCurrentPage(p => Math.min(totalPages, p + 1)); - const handleExport = () => generateCSV(processedRows, effectiveDimensions, years); - const getLabel = (val: string) => DIMENSION_OPTIONS.find(d => d.value === val)?.label || val; + const getSortIcon = (key: string) => { + if (sortConfig.key !== key) return ; + return {sortConfig.direction === 'asc' ? '↑' : '↓'}; + }; - const toggleMetric = (metric: 'sellOut' | 'units') => { - setVisibleMetrics(prev => - prev.includes(metric) - ? prev.filter(m => m !== metric) - : [...prev, metric] - ); + const handleExport = () => { + generateCSV(processedRows, effectiveDimensions, years); }; - // Filter Handlers const addFilter = () => { - if (!newFilterMetric || !newFilterValue) return; - setRowFilters(prev => [ - ...prev, - { - id: Date.now().toString(), - metric: newFilterMetric, - operator: newFilterOperator, - value: parseFloat(newFilterValue) - } - ]); - setNewFilterValue(''); - // Don't close builder to allow adding more + 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(prev => prev.filter(f => f.id !== id)); + setRowFilters(rowFilters.filter(f => f.id !== id)); }; - // Render Helpers - const renderGrowth = (current: number, previous: number, size: 'sm' | 'xs' = 'xs') => { - if (previous === 0) return null; - const pct = ((current - previous) / previous) * 100; - const isPositive = pct >= 0; - const textSize = size === 'sm' ? 'text-xs' : 'text-[10px]'; - - return ( - - {isPositive ? '↑' : '↓'}{Math.abs(pct).toFixed(0)}% - - ); - }; - - if (data.length === 0) return null; + // Reset pagination when filters change + useEffect(() => { + setCurrentPage(1); + }, [rowFilters, data, effectiveDimensions]); return ( -
-
- - {/* Header Bar */} -
-
-
-

Dynamic Pivot Table

-

- Comparing {years.join(', ')} • {processedRows.length} Rows - {rowFilters.length > 0 && (Filtered)} -

-
- -
-
- Group By: - d.value)} - onChange={setSelectedDimensions} - className="w-64" +
+ + {/* 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') && ( + + ) + ] + )} + + +
+ + )} - - - - - + {/* 2. Controls & Grid */} +
+ + {/* Toolbar */} +
+ +
+ {/* Dimensions Selector */} +
+ +
+ {DIMENSION_OPTIONS.map(dim => ( + + ))} +
+ + {/* Filter Builder Trigger */} + + + {/* Chart Toggle */} +
- {/* Advanced Filter Builder Panel */} - {(showFilterBuilder || rowFilters.length > 0) && ( -
- - {/* Active Filters List */} - {rowFilters.length > 0 && ( -
- {rowFilters.map(filter => { - const metricLabel = metricOptions.find(o => o.value === filter.metric)?.label || filter.metric; - const opLabel = filter.operator === 'gt' ? '>' : '<'; - return ( -
- {metricLabel} {opLabel} {filter.value} - -
- ); - })} -
- )} - - {/* Filter Creator Inputs */} - {showFilterBuilder && ( -
-
- - -
- -
- - -
- -
- - setNewFilterValue(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && addFilter()} - /> -
- - -
- )} +
+
+ Showing {processedRows.length} rows
- )} + +
- {/* Trend Chart */} - {showChart && chartData.length > 1 && ( - -
-
-
- - -
-
+ {/* Filter Builder Panel */} + {showFilterBuilder && ( +
+
- - - - - - {visibleMetrics.includes('sellOut') && ( - `€${(val / 1000).toFixed(0)}k`} /> - )} - {visibleMetrics.includes('units') && ( - `${(val / 1000).toFixed(0)}k`} /> - )} - - : } /> - - - {isComparisonView ? ( - uniqueYears.map((year, index) => ( - - {visibleMetrics.includes('sellOut') && ( - - )} - {visibleMetrics.includes('units') && ( - - )} - - )) - ) : ( - <> - {visibleMetrics.includes('sellOut') && ( - - )} - {visibleMetrics.includes('units') && ( - - )} - - )} - - + +
+
+ + +
+
+ + setNewFilterValue(e.target.value)} + placeholder="0" + className="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-primary" + /> +
+
- - )} - - {(!showChart || chartData.length <=1) && !isComparisonView && ( -
- {isComparisonView - ? "Not enough weekly data to compare these years." - : "Not enough weekly data points to render a trend chart for the current selection." - } -
+ + {/* Active Filters Chips */} + {rowFilters.length > 0 && ( +
+ {rowFilters.map(filter => { + const metricLabel = metricOptions.find(m => m.value === filter.metric)?.label || filter.metric; + return ( +
+ {metricLabel} {filter.operator === 'gt' ? '>' : '<'} {filter.value} + +
+ ); + })} +
+ )} +
)} - - {/* Table Container */} -
- - - - {/* Dynamic Dimension Headers - STICKY TOP */} - {effectiveDimensions.map((dim, index) => { - const label = getLabel(dim); - const isFirst = index === 0; - const isTitle = dim === 'title'; - + {/* Data Table */} +
+
+ + + {/* Dynamic Dimension Headers */} + {effectiveDimensions.map(dim => { + const label = DIMENSION_OPTIONS.find(d => d.value === dim)?.label || dim; return ( ); })} - {/* Dynamic Total Columns for each Year - STICKY TOP */} + {/* Total Columns per Year */} {years.map(year => ( + - ))} - - {/* Monthly Headers - STICKY TOP */} - {MONTH_NAMES.map(m => ( - + ))} - - {/* Grand Total Row (Sticky Top BELOW Headers) */} - - {/* - Anchor TOTAL label to the first column (sticky left). - This ensures "TOTAL" stays visible on the left even when scrolling horizontally. - */} - - - {/* Spacer for remaining dimensions if any */} - {effectiveDimensions.length > 1 && ( - - ); - })} - - {/* Monthly Grand Totals */} - {MONTH_NAMES.map((_, idx) => ( - - ))} - + + {paginatedRows.map((row) => ( + + {/* Dimension Values */} + {effectiveDimensions.map(dim => ( + + ))} - - {currentRows.map((row, index) => { - const isAlternate = index % 2 === 1; - // Alternate row color: Default dark (slate-950) vs Alternate lighter (slate-800) for high contrast - const rowClass = isAlternate ? 'bg-slate-800' : 'bg-slate-950'; - - return ( - - - {/* Dimensions */} - {effectiveDimensions.map((dim, index) => { - const isFirst = index === 0; - // @ts-ignore - const val = row[dim]; - const isTitle = dim === 'title'; - - const textColor = isTitle ? 'text-white font-semibold' : (isFirst ? 'text-slate-200 font-medium' : 'text-slate-400'); - - let cellContent: React.ReactNode = val; - - if (isTitle && typeof val === 'string') { - cellContent = ( -
- {val} -
- ); - } else { - let displayVal = val; - if (typeof val === 'string' && val.length > 30) { - displayVal = val.substring(0, 30) + '...'; - } - cellContent = displayVal; - } - - return ( - - ); - })} - - {/* Dynamic Total Columns per Year - HIGH CONTRAST BODY (Indigo 900/60) */} - {years.map((year, yIdx) => { - const yData = row.totalsByYear[year] || { sellOut: 0, units: 0 }; - let sellOutGrowth = null; - let unitsGrowth = null; - - if (yIdx < years.length - 1) { - const prevYear = years[yIdx + 1]; - const prevData = row.totalsByYear[prevYear]; - if (prevData) { - sellOutGrowth = renderGrowth(yData.sellOut, prevData.sellOut); - unitsGrowth = renderGrowth(yData.units, prevData.units); - } - } - - return ( - - ); - })} - - {/* Monthly Data Columns (Listing all years) */} - {row.months.map((m, idx) => ( - - ))} + {/* Metric Values */} + {years.map(year => { + const data = row.totalsByYear[year]; + return ( + + + + + ); + })} - )})} + ))} + + {paginatedRows.length === 0 && ( + + + + )}
requestSort(dim)} > - {label} {sortConfig.key === dim && (sortConfig.direction === 'asc' ? '▲' : '▼')} +
+ {label} {getSortIcon(dim)} +
requestSort(`total_${year}`)} + className="px-4 py-3 border-b border-border text-right cursor-pointer hover:text-white bg-slate-950 min-w-[100px]" + onClick={() => requestSort(`total_sellOut_${year}`)} > - Total {year} {sortConfig.key === `total_${year}` && (sortConfig.direction === 'asc' ? '▲' : '▼')} +
+ Sell Out {year} {getSortIcon(`total_sellOut_${year}`)} +
- {m} + requestSort(`total_units_${year}`)} + > +
+ Units {year} {getSortIcon(`total_units_${year}`)} +
- TOTAL ({processedRows.length} Rows) - - )} - - {/* Totals for each year - HIGH CONTRAST (Indigo 800) */} - {years.map((year, yIdx) => { - const currentData = grandTotals.totalsByYear[year] || { sellOut: 0, units: 0 }; - let sellOutGrowth = null; - let unitsGrowth = null; - - // Compare with next year in the list (chronologically previous) - if (yIdx < years.length - 1) { - const prevYear = years[yIdx + 1]; - const prevData = grandTotals.totalsByYear[prevYear]; - if (prevData) { - sellOutGrowth = renderGrowth(currentData.sellOut, prevData.sellOut, 'sm'); - unitsGrowth = renderGrowth(currentData.units, prevData.units, 'sm'); - } - } - - return ( - -
- €{currentData.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })} - {sellOutGrowth} -
-
- {currentData.units.toLocaleString()} u - {unitsGrowth} -
-
- {years.map((year, yIdx) => { - const data = grandTotals.months[idx][year]; - if(!data || (data.sellOut === 0 && data.units === 0)) return null; - - let sellOutGrowth = null; - let unitsGrowth = null; - - if (yIdx < years.length - 1) { - const prevYear = years[yIdx + 1]; - const prevData = grandTotals.months[idx][prevYear]; - if (prevData) { - sellOutGrowth = renderGrowth(data.sellOut, prevData.sellOut); - unitsGrowth = renderGrowth(data.units, prevData.units); - } - } - - return ( -
-
- {year} -
-
-
- €{data.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })} - {sellOutGrowth} -
-
- {data.units.toLocaleString()}u - {unitsGrowth} -
-
-
- ); - })} -
+ {dim === 'title' + ?
{row.title || '-'}
+ : (row[dim as keyof PivotRow] as string) || '-' + } +
- {cellContent} - -
- €{yData.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })} - {sellOutGrowth} -
-
- {yData.units.toLocaleString()} u - {unitsGrowth} -
-
- {years.map((year, yIdx) => { - const yData = m.byYear[year]; - // Skip if year has no data, unless it's the only year selected - if (!yData && years.length > 1) return null; - const sellOut = yData?.sellOut || 0; - const units = yData?.units || 0; - - let sellOutGrowthEl = null; - let unitsGrowthEl = null; - - if (yIdx < years.length - 1) { - const nextYear = years[yIdx + 1]; - const nextData = m.byYear[nextYear]; - if (nextData) { - if (nextData.sellOut > 0) { - sellOutGrowthEl = renderGrowth(sellOut, nextData.sellOut); - } - if (nextData.units > 0) { - unitsGrowthEl = renderGrowth(units, nextData.units); - } - } - } - - return ( -
-
- {year} - {sellOutGrowthEl} -
-
- €{sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })} -
-
-
-
- {units} u - {unitsGrowthEl} -
-
-
- ); - })} -
+ {data ? `€${data.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : '-'} + + {data ? data.units.toLocaleString() : '-'} +
+ No data matches your filters. +
- - {/* Footer */} -
-
- Showing {((currentPage - 1) * ROWS_PER_PAGE) + 1} - {Math.min(currentPage * ROWS_PER_PAGE, processedRows.length)} of {processedRows.length} Rows -
-
- - - Page {currentPage} of {totalPages} - - -
+ + {/* Pagination */} +
+
+ Page {currentPage} of {totalPages || 1} +
+
+ + +
); }; -export default DataGrid; +export default DataGrid; \ No newline at end of file diff --git a/components/FileUpload.tsx b/components/FileUpload.tsx index 7bf1eb4..3859660 100644 --- a/components/FileUpload.tsx +++ b/components/FileUpload.tsx @@ -1,3 +1,4 @@ + import React, { ChangeEvent, useState } from 'react'; import { UploadIcon } from './Icons'; @@ -85,13 +86,13 @@ const FileUpload: React.FC = ({
-

Upload Data File

-

Supports .csv, .xlsx, .xls

+

Upload Local CSV

+

Click to select file

= ({ ); }; -export default FileUpload; \ No newline at end of file +export default FileUpload; diff --git a/components/Icons.tsx b/components/Icons.tsx index a616f15..1c5cf77 100644 --- a/components/Icons.tsx +++ b/components/Icons.tsx @@ -56,8 +56,8 @@ export const FunnelIcon = () => ( ); -export const MoversIcon = () => ( +export const TrendingIcon = () => ( - + -); \ No newline at end of file +); diff --git a/components/ItemGrowthTable.tsx b/components/ItemGrowthTable.tsx deleted file mode 100644 index 26e9c90..0000000 --- a/components/ItemGrowthTable.tsx +++ /dev/null @@ -1,198 +0,0 @@ - -import React, { useState, useMemo } from 'react'; -import { ItemGrowthMetric } from '../types'; - -interface ItemGrowthTableProps { - title: string; - data: ItemGrowthMetric[]; - type: 'growth' | 'decline'; - periods: { current: string; previous: string }; -} - -type SortConfig = { key: keyof ItemGrowthMetric | null; direction: 'asc' | 'desc' }; - -const ItemGrowthTable: React.FC = ({ title, data, type, periods }) => { - const [sortConfig, setSortConfig] = useState({ key: null, direction: 'desc' }); - - const sortedData = useMemo(() => { - if (!sortConfig.key) { - // Default sort by units growth value for initial view - return [...data].sort((a, b) => - type === 'growth' ? b.unitsGrowthValue - a.unitsGrowthValue : a.unitsGrowthValue - b.unitsGrowthValue - ); - } - - return [...data].sort((a, b) => { - const aVal = a[sortConfig.key!] as number | string; - const bVal = b[sortConfig.key!] as number | string; - - if (typeof aVal === 'string' && typeof bVal === 'string') { - return sortConfig.direction === 'asc' - ? (aVal as string).localeCompare(bVal as string) - : (bVal as string).localeCompare(aVal as string); - } - - if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1; - if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1; - return 0; - }); - }, [data, sortConfig, type]); - - const requestSort = (key: keyof ItemGrowthMetric) => { - let direction: 'asc' | 'desc' = 'desc'; - // If already sorting by this key, toggle direction - if (sortConfig.key === key && sortConfig.direction === 'desc') { - direction = 'asc'; - } - setSortConfig({ key, direction }); - }; - - const getSortIndicator = (key: keyof ItemGrowthMetric) => { - if (sortConfig.key !== key) { - return ( - - - - ); - } - return ( - - {sortConfig.direction === 'asc' - ? - : - } - - ); - }; - - return ( -
- - - - - - - - - {/* Sell Out Columns */} - - - - - - {/* Units Columns */} - - - - - - - - {sortedData.length > 0 ? ( - sortedData.map((item, idx) => ( - - - - - - - {/* Sell Out Columns */} - - - - - - {/* Units Columns */} - - - - - - )) - ) : ( - - - - )} - -
requestSort('sku')} - > -
SKU {getSortIndicator('sku')}
-
requestSort('asin')} - > -
ASIN {getSortIndicator('asin')}
-
requestSort('title')} - > -
Product Title {getSortIndicator('title')}
-
requestSort('line')} - > -
Product Line {getSortIndicator('line')}
-
requestSort('previousYearSellOut')} - > -
Sell Out {periods.previous} {getSortIndicator('previousYearSellOut')}
-
requestSort('currentYearSellOut')} - > -
Sell Out {periods.current} {getSortIndicator('currentYearSellOut')}
-
requestSort('sellOutGrowthValue')} - > -
SO Diff {getSortIndicator('sellOutGrowthValue')}
-
requestSort('sellOutGrowthPercentage')} - > -
SO Growth % {getSortIndicator('sellOutGrowthPercentage')}
-
requestSort('previousYearUnits')} - > -
Units {periods.previous} {getSortIndicator('previousYearUnits')}
-
requestSort('currentYearUnits')} - > -
Units {periods.current} {getSortIndicator('currentYearUnits')}
-
requestSort('unitsGrowthValue')} - > -
Units Diff {getSortIndicator('unitsGrowthValue')}
-
requestSort('unitsGrowthPercentage')} - > -
Units Growth % {getSortIndicator('unitsGrowthPercentage')}
-
{item.sku || '-'}{item.asin || '-'}{item.title || '-'}{item.line || '-'}€{item.previousYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}€{item.currentYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}= 0 ? 'text-emerald-400' : 'text-red-400'}`}> - {item.sellOutGrowthValue > 0 ? '+' : ''}€{item.sellOutGrowthValue.toLocaleString(undefined, {maximumFractionDigits: 0})} - - = 0 ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}> - {item.sellOutGrowthPercentage.toFixed(1)}% - - {item.previousYearUnits.toLocaleString()}{item.currentYearUnits.toLocaleString()}= 0 ? 'text-violet-400' : 'text-orange-400'}`}> - {item.unitsGrowthValue > 0 ? '+' : ''}{item.unitsGrowthValue.toLocaleString()} - - = 0 ? 'bg-violet-500/10 text-violet-400' : 'bg-orange-500/10 text-orange-400'}`}> - {item.unitsGrowthPercentage.toFixed(1)}% - -
- Insufficient data to calculate {type}. (Select a customer and at least 2 distinct years/periods) -
-
- ); -} - -export default ItemGrowthTable; diff --git a/components/TopMovers.tsx b/components/TopMovers.tsx new file mode 100644 index 0000000..add5208 --- /dev/null +++ b/components/TopMovers.tsx @@ -0,0 +1,326 @@ + +import React, { useState, useMemo } from 'react'; +import { SalesRecord } from '../types'; +import { DownloadIcon } from './Icons'; + +interface TopMoversProps { + data: SalesRecord[]; +} + +type Metric = 'sellOut' | 'units'; + +interface SkuAggr { + sku: string; + title: string; + line: string; + previousValue: number; + currentValue: number; + diff: number; + pct: number; +} + +// Reusable Table Component +const MoversTable: React.FC<{ + title: string; + data: SkuAggr[]; + metric: Metric; + previousYear: number; + currentYear: number; + type: 'growth' | 'decline'; +}> = ({ title, data, metric, previousYear, currentYear, type }) => { + + const formatValue = (val: number) => { + if (metric === 'sellOut') return `€${val.toLocaleString(undefined, { maximumFractionDigits: 0 })}`; + return val.toLocaleString(); + }; + + const handleExport = () => { + if (!data || data.length === 0) return; + + // Helper to force Comma as thousands separator (US Locale) + const formatForCSV = (val: number) => { + return val.toLocaleString('en-US', { + useGrouping: true, + minimumFractionDigits: metric === 'sellOut' ? 2 : 0, + maximumFractionDigits: metric === 'sellOut' ? 2 : 0, + }); + }; + + // Prepare data for CSV + const csvData = data.map((item, index) => ({ + Rank: index + 1, + Title: item.title, + SKU: item.sku, + 'Product Line': item.line, + [`${previousYear} ${metric === 'sellOut' ? 'Sell Out' : 'Units'}`]: formatForCSV(item.previousValue), + [`${currentYear} ${metric === 'sellOut' ? 'Sell Out' : 'Units'}`]: formatForCSV(item.currentValue), + 'Difference': formatForCSV(item.diff), + '% Change': `${item.pct.toFixed(2)}%` + })); + + // Generate CSV string + // @ts-ignore - Papa is loaded globally via CDN + const csv = Papa.unparse(csvData); + + // Create download link + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + const filename = `${title.replace(/\s+/g, '_')}_${currentYear}_vs_${previousYear}.csv`; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const colorClass = type === 'growth' ? 'text-emerald-400' : 'text-rose-400'; + const bgClass = type === 'growth' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400'; + const headerColor = type === 'growth' ? 'border-emerald-500/30' : 'border-rose-500/30'; + + return ( +
+
+

+ {type === 'growth' ? '🚀 ' : '📉 '} {title} +

+
+ + Top 20 +
+
+ +
+ + + + + + + + + + + + + + {data.map((item, index) => { + const isPositive = item.diff >= 0; + + return ( + + + + + + + + + + + + + + ); + })} + + {data.length === 0 && ( + + + + )} + +
RankSKU DetailsProduct Line{previousYear}{currentYear}Diff% Change
+ {index + 1} + +
+ + {item.title || 'Unknown Title'} + + SKU: {item.sku} +
+
+ + {item.line} + + + {formatValue(item.previousValue)} + + {formatValue(item.currentValue)} + + {isPositive ? '+' : ''}{formatValue(item.diff)} + + + {isPositive ? '↑' : '↓'} {Math.abs(item.pct).toFixed(1)}% + +
+ No records found matching this criteria. +
+
+
+ ); +}; + +const TopMovers: React.FC = ({ data }) => { + const [metric, setMetric] = useState('sellOut'); + const [viewMode, setViewMode] = useState<'growth' | 'decline'>('growth'); + + // 1. Determine comparison years from filtered data + const { currentYear, previousYear, availableYears } = useMemo(() => { + const years = Array.from(new Set(data.map(d => d.year))).sort((a: number, b: number) => b - a); + return { + currentYear: years[0], + previousYear: years[1], + availableYears: years + }; + }, [data]); + + // 2. Aggregation Logic + const { growers, decliners } = useMemo(() => { + if (!currentYear || !previousYear) return { growers: [], decliners: [] }; + + // Map: SKU -> { currentVal, previousVal, metadata } + const map = new Map(); + + data.forEach(row => { + // Only care about the two comparison years + if (row.year !== currentYear && row.year !== previousYear) return; + + if (!map.has(row.sku)) { + map.set(row.sku, { current: 0, previous: 0, title: row.title, line: row.line }); + } + + const entry = map.get(row.sku)!; + const value = metric === 'sellOut' ? row.sellOut : row.units; + + if (row.year === currentYear) { + entry.current += value; + } else { + entry.previous += value; + } + }); + + // Convert to Array and Calculate Deltas + const list: SkuAggr[] = []; + map.forEach((val, sku) => { + // Filter out items that have 0 in BOTH years (irrelevant) + if (val.current === 0 && val.previous === 0) return; + + const diff = val.current - val.previous; + let pct = 0; + if (val.previous !== 0) { + pct = (diff / val.previous) * 100; + } else if (val.current !== 0) { + // Infinite growth (0 -> 100) + pct = 100; + } + + list.push({ + sku, + title: val.title, + line: val.line, + previousValue: val.previous, + currentValue: val.current, + diff, + pct + }); + }); + + // Separate and Sort + const growers = list + .filter(i => i.diff > 0) + .sort((a, b) => b.diff - a.diff) // Descending by Growth + .slice(0, 20); + + const decliners = list + .filter(i => i.diff < 0) + .sort((a, b) => a.diff - b.diff) // Ascending by Decline (Most negative first) + .slice(0, 20); + + return { growers, decliners }; + + }, [data, metric, currentYear, previousYear]); + + if (availableYears.length < 2) { + return ( +
+

Insufficient Data for Comparison

+

+ To see Top Movers, please ensure your filters include at least two different years (e.g., 2024 and 2025). +

+

Current Years Available: {availableYears.join(', ') || 'None'}

+
+ ); + } + + return ( +
+ + {/* Controls Header */} +
+
+

+ Analytics Overview +

+

+ Comparing Performance: {previousYear} vs {currentYear} +

+
+ +
+ {/* Gainers / Losers Toggle */} +
+ + +
+ + {/* Metric Toggle */} +
+ + +
+
+
+ + {/* Single Active Table */} + + +
+ ); +}; + +export default TopMovers; diff --git a/components/TopMoversPage.tsx b/components/TopMoversPage.tsx deleted file mode 100644 index ee50e54..0000000 --- a/components/TopMoversPage.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useState, useMemo } from 'react'; -import { SalesRecord } from '../types'; -import { calculateItemMovers, getUniqueValues, generateItemMoversCSV } from '../services/dataProcessor'; // Import generateItemMoversCSV -import ItemGrowthTable from './ItemGrowthTable'; -import { ExpandableCard } from './Dashboard'; // Re-use ExpandableCard from Dashboard - -interface TopMoversPageProps { - filteredData: SalesRecord[]; // Data already filtered by global customer, year, month, etc. -} - -const TopMoversPage: React.FC = ({ filteredData }) => { - // Local state for the specific comparison year, initially null for auto-selection - const [selectedComparisonYear, setSelectedComparisonYear] = useState(null); - - // Derive available years from the *currently filtered data* for the comparison year dropdown - const availableYearsForComparisonDropdown = useMemo(() => { - const yearsInFilteredData = getUniqueValues(filteredData, 'year'); - // Sort descending for the dropdown - return yearsInFilteredData.sort((a,b) => parseInt(b) - parseInt(a)); - }, [filteredData]); - - // Derive top/bottom movers based on selections - const { topMovers, bottomMovers, comparisonPeriods } = useMemo(() => { - const comparisonYearNum = selectedComparisonYear ? parseInt(selectedComparisonYear) : null; - // Pass the globally filtered data. The global filter bar now handles customer/line/sku/etc filtering. - // We pass null for the local customer override as it is no longer used. - return calculateItemMovers(filteredData, null, comparisonYearNum); - }, [filteredData, selectedComparisonYear]); - - // Handlers for export - const handleExportGainers = () => { - generateItemMoversCSV(topMovers, comparisonPeriods, 'Gainers'); - }; - - const handleExportLosers = () => { - generateItemMoversCSV(bottomMovers, comparisonPeriods, 'Losers'); - }; - - return ( -
-
-

Top Item Movers

- -
- - -
-
- - {/* Top 20 Gainers Table */} - - - - - {/* Top 20 Losers Table */} - - - -
- ); -}; - -export default TopMoversPage; \ No newline at end of file diff --git a/index.html b/index.html index 6e87af2..0c8b41b 100644 --- a/index.html +++ b/index.html @@ -48,7 +48,8 @@ "react/": "https://aistudiocdn.com/react@^19.2.0/", "@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0", "recharts": "https://aistudiocdn.com/recharts@^3.5.0", - "xlsx": "https://esm.sh/xlsx@0.18.5" + "xlsx": "https://esm.sh/xlsx@^0.18.5", + "papaparse": "https://esm.sh/papaparse@^5.5.3" } } diff --git a/package.json b/package.json index db1c383..13ecd5a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "react-dom": "^19.2.0", "@google/genai": "^1.30.0", "recharts": "^3.5.0", - "xlsx": "0.18.5" + "xlsx": "^0.18.5", + "papaparse": "^5.5.3" }, "devDependencies": { "@types/node": "^22.14.0", diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index 8a06ac1..fa069b3 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -1,12 +1,13 @@ -import { SalesRecord, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types'; +import { SalesRecord, AdsRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types'; import * as XLSX from 'xlsx'; +import Papa from 'papaparse'; // 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(); + let clean = value.replace(/[€$£\s]/g, '').trim(); // HEURISTIC: // If it contains a comma, we assume it's likely European format (Decimal separator) @@ -14,17 +15,27 @@ const parseCurrency = (value: string): number => { // 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 + if (clean.includes(',') && !clean.includes('.') && clean.indexOf(',') > clean.length - 4) { + clean = clean.replace(',', '.'); + return parseFloat(clean); + } + else if (clean.includes(',') && clean.includes('.')) { + // Mixed: 1.234,56 + if (clean.indexOf(',') > clean.indexOf('.')) { + clean = clean.replace(/\./g, '').replace(',', '.'); + } else { + // 1,234.56 + clean = clean.replace(/,/g, ''); + } + return parseFloat(clean); + } + else if (clean.includes(',')) { + // Likely EU decimal 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); @@ -41,12 +52,38 @@ const parseUnits = (value: string): number => { const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +// Mapping for Spanish Month Names +const SPANISH_MONTHS: Record = { + 'ene': 'Jan', 'enero': 'Jan', + 'feb': 'Feb', 'febrero': 'Feb', + 'mar': 'Mar', 'marzo': 'Mar', + 'abr': 'Apr', 'abril': 'Apr', + 'may': 'May', 'mayo': 'May', + 'jun': 'Jun', 'junio': 'Jun', + 'jul': 'Jul', 'julio': 'Jul', + 'ago': 'Aug', 'agosto': 'Aug', + 'sep': 'Sep', 'septiembre': 'Sep', 'set': 'Sep', 'setiembre': 'Sep', + 'oct': 'Oct', 'octubre': 'Oct', + 'nov': 'Nov', 'noviembre': 'Nov', + 'dic': 'Dec', 'diciembre': '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) + // If it's a full date string like "2023-04-01" or "01/04/2023" + if (m.includes('/') || m.includes('-')) { + const date = new Date(m); + if (!isNaN(date.getTime())) { + const monthIdx = date.getMonth(); + const yearShort = date.getFullYear().toString().slice(2); + return `${MONTH_ORDER[monthIdx]}-${yearShort}`; + } + } + const numMatch = m.match(/^(\d{1,2})([^\d]|$)/); if (numMatch) { const num = parseInt(numMatch[1]); @@ -55,26 +92,37 @@ const normalizeMonth = (rawMonth: string): string => { // Handle text months "Apr-23", "Apr 23", "April" // Extract first sequence of letters - const alphaMatch = m.match(/([a-zA-Z]+)/); + const alphaMatch = m.match(/([a-zA-Z\u00C0-\u00FF]+)/); // Include accented chars for Spanish if (alphaMatch) { - m = alphaMatch[1]; + let alpha = alphaMatch[1].toLowerCase(); + + // Check Spanish mapping first + if (SPANISH_MONTHS[alpha]) { + m = SPANISH_MONTHS[alpha]; + } else { + // Default to first 3 chars capitalize (English) + if (alpha.length > 3) alpha = alpha.substring(0, 3); + m = alpha.charAt(0).toUpperCase() + alpha.slice(1); + } } - // Take first 3 characters - if (m.length > 3) { - m = m.substring(0, 3); + // Try to grab year from original string to append (e.g. "Apr-23") + const yearMatch = rawMonth.match(/(\d{2,4})/); + if (yearMatch) { + let y = yearMatch[1]; + if (y.length === 4) y = y.slice(2); + // Only append if year is not part of the month name logic + if (!m.includes('-')) { + return `${m}-${y}`; + } } - // 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 = {}; rowKeys.forEach(k => { normalizedRowKeys[k.trim().toLowerCase()] = k; @@ -87,8 +135,6 @@ const getColumnValue = (row: any, aliases: string[]): string => { 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; } @@ -98,37 +144,38 @@ const getColumnValue = (row: any, aliases: string[]): string => { return ''; }; -// Extracted Mapping Function +// --- SALES / SELL OUT MAPPING --- + 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; + // Sanitize year string before parsing (remove commas/dots e.g. "2,023") + let year = parseInt(yearStr.replace(/[,.]/g, '')) || 0; const monthStr = getColumnValue(row, ['MONTH', 'Month', 'Period']); const month = normalizeMonth(monthStr); + + // BACKFILL YEAR if missing but present in Month (e.g. "Apr-23") + if (year === 0 && month.includes('-')) { + const parts = month.split('-'); + if (parts.length === 2) { + const yPart = parts[1]; + // assume 20xx for 2 digits + if (yPart.length === 2) year = 2000 + parseInt(yPart); + else if (yPart.length === 4) year = parseInt(yPart); + } + } + 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'; + const line = getColumnValue(row, ['LINE', 'Line', 'Product Line']) || 'Unassigned'; - // 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' + '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']); @@ -152,24 +199,24 @@ const mapRowToRecord = (row: any, index: number): SalesRecord => { export const processCSV = (fileOrContent: File | string): Promise => { return new Promise((resolve, reject) => { - // @ts-ignore - PapaParse is loaded globally via CDN + // @ts-ignore 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 + }) + // Relaxed filtering: Only exclude rows with absolutely no year info even after backfill + .filter((r: SalesRecord) => r.year > 0); + resolve(data); } catch (err) { reject(err); } }, - error: (error: any) => { - reject(error); - } + error: (error: any) => reject(error) }); }); }; @@ -180,15 +227,13 @@ export const processExcel = async (file: File): Promise => { 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'); + }) + // Relaxed filtering + .filter((r: SalesRecord) => r.year > 0); return data; } catch (error) { @@ -197,14 +242,234 @@ export const processExcel = async (file: File): Promise => { } } +// --- ADS DATA MAPPING --- + +const mapCountryToMarketplace = (country: string): string => { + const c = country.toLowerCase().trim(); + if (c.includes('germany') || c.includes('deutschland')) return 'AMAZON DE'; + if (c.includes('spain') || c.includes('espana') || c.includes('españa')) return 'AMAZON ES'; + if (c.includes('france')) return 'AMAZON FR'; + if (c.includes('italy') || c.includes('italia')) return 'AMAZON IT'; + if (c.includes('kingdom') || c.includes('uk') || c === 'gb') return 'AMAZON UK'; + if (c.includes('netherlands') || c.includes('nederland') || c.includes('holland')) return 'AMAZON NL'; + if (c.includes('sweden')) return 'AMAZON SE'; + if (c.includes('poland')) return 'AMAZON PL'; + if (c.includes('belgium')) return 'AMAZON BE'; + if (c.includes('turkey')) return 'AMAZON TR'; + return country.toUpperCase(); // Fallback +}; + +export const processAdsCSV = (file: File): Promise => { + return new Promise((resolve, reject) => { + // @ts-ignore + Papa.parse(file, { + header: false, // Index-based mapping (A=0, B=1...) + skipEmptyLines: true, + complete: (results: any) => { + try { + const data: AdsRecord[] = []; + const rows = results.data; + const len = rows.length; + + for (let i = 0; i < len; i++) { + const row = rows[i]; + if (!Array.isArray(row) || row.length < 12) continue; + + // Check header row (Column A: Country) + const c0 = String(row[0]).trim(); + if (c0.toLowerCase() === 'country' || c0.toLowerCase() === 'marketplace') continue; + + // Map by Column Index (A=0, B=1... L=11) + const countryRaw = row[0]; + const monthRaw = row[1]; + const asin = row[2]; + const costRaw = row[3]; + const clicksRaw = row[4]; + const impressionsRaw = row[5]; + // G, H, I, J unused/calculated + const unitsRaw = row[10]; // K + const salesRaw = row[11]; // L + + if (!asin || !countryRaw) continue; + + data.push({ + country: mapCountryToMarketplace(String(countryRaw)), + month: normalizeMonth(String(monthRaw)), + asin: String(asin).trim(), + cost: parseCurrency(String(costRaw)), + clicks: parseUnits(String(clicksRaw)), + impressions: parseUnits(String(impressionsRaw)), + attributedSales30d: parseCurrency(String(salesRaw)), + attributedUnits30d: parseUnits(String(unitsRaw)), + }); + } + resolve(data); + } catch (err) { + reject(err); + } + }, + error: (error: any) => reject(error) + }); + }); +}; + +export const processAdsExcel = async (file: File): Promise => { + try { + const arrayBuffer = await file.arrayBuffer(); + const workbook = XLSX.read(arrayBuffer); + const firstSheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[firstSheetName]; + + // Use header: 'A' to strictly map columns by index letter as requested + const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: "A", defval: "" }); + + const data: AdsRecord[] = jsonData.map((row: any) => { + // Check if it's a header row + if (row['A'] === 'Country' && (row['C'] === 'ASIN' || row['C'] === 'Asin')) return null; + + // Map by Column Letter as requested + // A: Country, B: Month, C: ASIN, D: Cost, E: Clicks, F: Impressions + // G: CPC, H: CTR, I: ACOS, J: Conversions + // K: Units, L: Sales + const countryRaw = row['A']; + const monthRaw = row['B']; + const asin = row['C']; + const costRaw = row['D']; + const clicksRaw = row['E']; + const impressionsRaw = row['F']; + const unitsRaw = row['K']; + const salesRaw = row['L']; + + if (!asin || !countryRaw) return null; + + return { + country: mapCountryToMarketplace(String(countryRaw)), + month: normalizeMonth(String(monthRaw)), + asin: String(asin).trim(), + cost: parseCurrency(String(costRaw)), + clicks: parseUnits(String(clicksRaw)), + impressions: parseUnits(String(impressionsRaw)), + attributedSales30d: parseCurrency(String(salesRaw)), + attributedUnits30d: parseUnits(String(unitsRaw)), + }; + }).filter((r): r is AdsRecord => r !== null); + + return data; + } catch (error) { + console.error("Error processing Ads Excel:", error); + throw error; + } +}; + +// --- DATA MERGING --- + +export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecord[]): CombinedKPIs[] => { + // 1. Index Ads Data for fast lookup: Key = ASIN + Marketplace + Month + const adsMap = new Map(); + + adsData.forEach(ad => { + const key = `${ad.asin.toUpperCase()}|${ad.country.toUpperCase()}|${ad.month}`; + // If duplicates exist (e.g. multiple campaigns for same ASIN), sum them up + if (adsMap.has(key)) { + const existing = adsMap.get(key)!; + existing.cost += ad.cost; + existing.clicks += ad.clicks; + existing.impressions += ad.impressions; + existing.attributedSales30d += ad.attributedSales30d; + existing.attributedUnits30d += ad.attributedUnits30d; + } else { + adsMap.set(key, { ...ad }); + } + }); + + // 2. Iterate Sales Data and merge + const mergedData: CombinedKPIs[] = salesData.map(sale => { + const key = `${sale.asin.toUpperCase()}|${sale.customer.toUpperCase()}|${sale.month}`; + const adData = adsMap.get(key) || { + country: sale.customer, + month: sale.month, + asin: sale.asin, + cost: 0, + clicks: 0, + impressions: 0, + attributedSales30d: 0, + attributedUnits30d: 0 + }; + + const salesTotal = sale.sellOut; + const salesAds = adData.attributedSales30d; + // Logic: Organic = Total - Ads. Max(0) to avoid negative if attribution window logic differs vs finance dates + const salesOrganic = Math.max(0, salesTotal - salesAds); + + const unitsTotal = sale.units; + const unitsAds = adData.attributedUnits30d; + const unitsOrganic = Math.max(0, unitsTotal - unitsAds); + + // KPIs + const acos = salesAds > 0 ? (adData.cost / salesAds) * 100 : 0; + const tacos = salesTotal > 0 ? (adData.cost / salesTotal) * 100 : 0; + const roas = adData.cost > 0 ? salesAds / adData.cost : 0; + const ctr = adData.impressions > 0 ? (adData.clicks / adData.impressions) * 100 : 0; + const cpc = adData.clicks > 0 ? adData.cost / adData.clicks : 0; + // CVR (Units / Clicks) + const cvrUnits = adData.clicks > 0 ? (unitsAds / adData.clicks) * 100 : 0; + + const paidSalesShare = salesTotal > 0 ? (salesAds / salesTotal) * 100 : 0; + const organicSalesShare = salesTotal > 0 ? (salesOrganic / salesTotal) * 100 : 0; + + return { + id: sale.id, + marketplace: sale.customer, + month: sale.month, + year: sale.year, + asin: sale.asin, + title: sale.title, + line: sale.line, + sku: sale.sku, + + salesTotal, + unitsTotal, + + salesAds, + unitsAds, + cost: adData.cost, + clicks: adData.clicks, + impressions: adData.impressions, + + salesOrganic, + unitsOrganic, + + paidSalesShare, + organicSalesShare, + + acos, + tacos, + roas, + ctr, + cpc, + cvrUnits + }; + }); + + return mergedData; +}; + + +// --- EXISTING HELPERS --- + export const filterData = (data: SalesRecord[], filters: FilterState): SalesRecord[] => { return data.filter(item => { - // Item month is already normalized - const recordMonth = item.month; + // 1. Month Logic: Handle "Apr-23" matching "Apr" filter + const recordMonth = item.month; // e.g. "Apr-23" + const pureMonth = recordMonth.split('-')[0]; // "Apr" + // 2. Filter Checks 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); + + // Check match against pure month ("Apr") OR full month ("Apr-23") just in case filters evolve + const monthMatch = filters.month.length === 0 || filters.month.includes(pureMonth) || 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); @@ -227,17 +492,22 @@ const calculateSeasonality = (data: SalesRecord[]): { seasonality: SeasonalityPo data.forEach(record => { const monthName = record.month; + // Extract year from record.month if it's in Format "Mon-YY", else use record.year + // record.year is numeric, record.month is "Apr-23". const yearStr = record.year.toString(); yearsSet.add(yearStr); + + // We need to match month name purely (Jan, Feb) for the X Axis, ignoring year + const pureMonth = monthName.split('-')[0]; - if (seasonalityMap.has(monthName)) { + if (seasonalityMap.has(pureMonth)) { // Sell Out - const entrySO = seasonalityMap.get(monthName)!; + const entrySO = seasonalityMap.get(pureMonth)!; const currentValSO = (entrySO[yearStr] as number) || 0; entrySO[yearStr] = currentValSO + record.sellOut; // Units - const entryUnits = seasonalityUnitsMap.get(monthName)!; + const entryUnits = seasonalityUnitsMap.get(pureMonth)!; const currentValUnits = (entryUnits[yearStr] as number) || 0; entryUnits[yearStr] = currentValUnits + record.units; } @@ -629,7 +899,7 @@ export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['tit } const row = map.get(key)!; - const monthPart = record.month; + const monthPart = record.month.split('-')[0]; // Handle "Apr-23" -> "Apr" const monthIdx = MONTH_ORDER.indexOf(monthPart); const yearStr = record.year.toString(); diff --git a/services/geminiService.ts b/services/geminiService.ts index ad75c06..32ebb5e 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -2,39 +2,49 @@ import { GoogleGenAI } from "@google/genai"; import { AggregatedData } from "../types"; +// Declare process to avoid TypeScript errors without causing aggressive bundler shims +declare const process: any; + 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. +You are a senior data analyst assistant for a retail dashboard called "Craze Analytix". +You have access to a detailed report of the currently filtered sales data. +The data includes Sell Out (Revenue in €), Units Sold, Product Lines, Customers/Markets, and Seasonality trends. -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. +Your goal is to answer user questions specific to the provided data. +- If asked about "Trends" or "Seasonality", look at the Monthly Seasonality section. +- If asked about "Growth" or "Decline", look at the Top/Bottom Movers sections. +- If asked about specific Product Lines, look at the Product Line Breakdown. +- Always format numbers clearly (e.g., "€1.2M", "€5,200", "15k units"). +- When comparing years, calculate the percentage difference if not explicitly provided. +- Keep answers professional, concise, and business-focused. `; +// Helper to get API key safely +const getApiKey = (): string | undefined => { + try { + return process.env.API_KEY; + } catch (e) { + return undefined; + } +}; + 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 => { + const apiKey = getApiKey(); + if (!apiKey) { - return "Please provide your Gemini API Key in the settings to enable the AI assistant."; + return "API Key is missing. Please configure your environment variables (API_KEY) or check your .env file."; } try { - // Ensure the key is clean of whitespace - const ai = new GoogleGenAI({ apiKey: apiKey.trim() }); + const ai = new GoogleGenAI({ apiKey }); // --- CONTEXT GENERATION --- // We construct a structured report mirroring the dashboard charts @@ -46,62 +56,64 @@ export const queryGemini = async ( .join('\n'); // 2. Seasonality (Line Chart Data) + // We simplify this to a CSV-like list for the AI to parse trends const seasonalitySummary = context.seasonality.map(p => { + // Extract values for each year in the point const yearValues = context.availableYears.map(y => `${y}: ${formatCurrency(p[y] as number || 0)}`).join(', '); return ` - ${p.name}: [${yearValues}]`; }).join('\n'); - // 3. Growth/Decline + // 3. Top Movers (Growth Table) - Limit to Top 10 const growthSummary = context.topMovers.slice(0, 10).map(m => ` - ${m.line}: +€${m.sellOutGrowthValue.toLocaleString()} (${m.sellOutGrowthPercentage.toFixed(1)}%)` ).join('\n'); + // 4. Declining Movers (Decline Table) - Limit to Top 10 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 - })); + // 5. Product Lines Overview (Bar Charts) - Limit to Top 50 to save tokens but give depth + const topLinesSummary = context.byLine.slice(0, 50).map((l, i) => + ` ${i+1}. ${l.name}: ${formatCurrency(l.value)} | ${formatUnits(l.units)}` + ).join('\n'); + + // 6. Customer Distribution (Customer Chart) + const customerSummary = context.byCustomer.map(c => + ` - ${c.name}: ${formatCurrency(c.value)}` + ).join('\n'); const fullReport = ` -REPORT CONTEXT (Based on Current Filters): ------------------------------------------- -GLOBAL METRICS: +REPORT CONTEXT: +---------------- +GLOBAL TOTALS: Total Sell Out: ${formatCurrency(context.totalSellOut)} Total Units: ${formatUnits(context.totalUnits)} Records Analyzed: ${filteredRecordCount} Years Available: ${context.availableYears.join(', ')} -YEARLY TOTALS: +YEARLY BREAKDOWN: ${yearlySummary} -MONTHLY TRENDS (Seasonality): +MONTHLY SEASONALITY (Revenue Trends): ${seasonalitySummary} -TOP PERFORMERS (Growth YoY): +FASTEST GROWING LINES (Year-over-Year): ${growthSummary} -WORST PERFORMERS (Decline YoY): +DECLINING LINES (Year-over-Year): ${declineSummary} -DETAILED PRODUCT LINE DATA (Use this for specific calculations): -${JSON.stringify(detailedLines, null, 2)} +TOP PRODUCT LINES (Revenue & Units): +${topLinesSummary} + +PERFORMANCE BY CUSTOMER: +${customerSummary} `; 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}` }] - } - ], + model: 'gemini-2.5-flash', + contents: `Context Data:\n${fullReport}\n\nUser Question: ${question}`, config: { systemInstruction: SYSTEM_INSTRUCTION, } @@ -111,11 +123,8 @@ ${JSON.stringify(detailedLines, null, 2)} } 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."; + if (error.message && error.message.includes("Not implemented on this platform")) { + return "System Error: The AI SDK detected a platform mismatch."; } return `Error: ${error.message || "An unexpected error occurred while analyzing the data."}`; diff --git a/types.ts b/types.ts index 69f67b5..bb0361a 100644 --- a/types.ts +++ b/types.ts @@ -1,5 +1,4 @@ - export interface SalesRecord { id: string; customer: string; @@ -25,7 +24,7 @@ export interface FilterState { title: string[]; // Added Title filter } -export interface LineGrowthMetric { // Renamed from GrowthMetric +export interface GrowthMetric { line: string; currentYearSellOut: number; previousYearSellOut: number; @@ -38,23 +37,23 @@ export interface LineGrowthMetric { // Renamed from GrowthMetric unitsGrowthPercentage: number; } +export type LineGrowthMetric = GrowthMetric; + export interface ItemGrowthMetric { sku: string; asin: string; title: string; - line: string; // Keep line for context + line: string; currentYearSellOut: number; previousYearSellOut: number; sellOutGrowthValue: number; sellOutGrowthPercentage: number; - currentYearUnits: number; previousYearUnits: number; unitsGrowthValue: number; unitsGrowthPercentage: number; } - export interface SeasonalityPoint { name: string; // "Jan", "Feb", etc. [year: string]: number | string; // Dynamic keys for years: "2023": 500, "2024": 600 @@ -75,8 +74,8 @@ export interface AggregatedData { seasonality: SeasonalityPoint[]; seasonalityUnits: SeasonalityPoint[]; availableYears: string[]; - topMovers: LineGrowthMetric[]; // Now uses LineGrowthMetric - bottomMovers: LineGrowthMetric[]; // Now uses LineGrowthMetric + topMovers: GrowthMetric[]; + bottomMovers: GrowthMetric[]; comparisonPeriods: { current: string; previous: string }; topLinesSplit: YearlySplitData[]; byCustomerSplit: YearlySplitData[]; @@ -124,4 +123,48 @@ export interface ComparisonTimeSeriesPoint { week: number; name: string; // "W1", "W2", etc. [key: string]: number | string; // Dynamic keys like "2023_sellOut", "2024_units" -} \ No newline at end of file +} + +export interface AdsRecord { + country: string; + month: string; + asin: string; + cost: number; + clicks: number; + impressions: number; + attributedSales30d: number; + attributedUnits30d: number; +} + +export interface CombinedKPIs { + id: string; + marketplace: string; + month: string; + year: number; + asin: string; + title: string; + line: string; + sku: string; + + salesTotal: number; + unitsTotal: number; + + salesAds: number; + unitsAds: number; + cost: number; + clicks: number; + impressions: number; + + salesOrganic: number; + unitsOrganic: number; + + paidSalesShare: number; + organicSalesShare: number; + + acos: number; + tacos: number; + roas: number; + ctr: number; + cpc: number; + cvrUnits: number; +}