diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..b69cc53 --- /dev/null +++ b/App.tsx @@ -0,0 +1,416 @@ +import React, { useState, useMemo, useEffect, useCallback } from 'react'; +import FileUpload from './components/FileUpload'; +import Dashboard from './components/Dashboard'; +import DataGrid from './components/DataGrid'; +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 { queryGemini } from './services/geminiService'; +import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon } from './components/Icons'; +import { loadSalesData, saveSalesData, clearSalesData } from './services/storage'; + +// New Refresh Icon +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"; + +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 [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); + + // Filters State + const [filters, setFilters] = useState({ + customer: [], + year: [], + month: [], + line: [], + asin: [], + sku: [], + 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); + try { + let directUrl = url; + // Create a direct download link for Dropbox if it's a share link. + if (url.includes('dropbox.com/') && !url.includes('dl.dropboxusercontent.com')) { + const urlObject = new URL(url); + urlObject.searchParams.set('dl', '1'); + directUrl = urlObject.toString(); + } + + // Use a CORS proxy to bypass browser's same-origin policy restrictions. + const proxyUrl = `https://corsproxy.io/?${encodeURIComponent(directUrl)}`; + + const response = await fetch(proxyUrl); + if (!response.ok) throw new Error(`Failed to fetch CSV from URL: ${response.status} ${response.statusText}`); + + const csvText = await response.text(); + const data = await processCSV(csvText); + + await saveSalesData(data); + + initializeData(data); + setActiveUrl(url); // Store the original user-facing URL + const now = new Date().toISOString(); + setLastUpdated(now); + localStorage.setItem('craze_last_updated', now); + localStorage.setItem('craze_csv_url', url); + setIsDataModalOpen(false); // Close modal on success + } catch (error) { + console.error("Failed to fetch/parse CSV from URL", error); + // Don't alert on auto-fetch to avoid spamming the user on startup if offline + // alert("Error syncing data. Please check the URL."); + throw error; // re-throw to be caught by caller + } finally { + setSyncing(false); + setLoading(false); + } + }, []); + + const initializeData = (data: SalesRecord[]) => { + setRawData(data); + setFilters({ + customer: [], + year: [], + month: [], + line: [], + asin: [], + sku: [], + title: [], + }); + }; + + // 1. Initial Load from Cache (IndexedDB) or Auto-Fetch Permanent URL + useEffect(() => { + const initApp = async () => { + setLoading(true); + + const currentStoredUrl = localStorage.getItem('craze_csv_url'); + if (currentStoredUrl !== PERMANENT_DROPBOX_URL) { + localStorage.setItem('craze_csv_url', PERMANENT_DROPBOX_URL); + setActiveUrl(PERMANENT_DROPBOX_URL); + } + + const { data, lastUpdated: date } = await loadSalesData(); + + if (data && data.length > 0) { + console.log("Loaded data from cache:", data.length, "rows"); + initializeData(data); + setLastUpdated(date); + setLoading(false); + } else { + console.log("No cache found. Auto-fetching from Permanent URL..."); + handleUrlFetch(PERMANENT_DROPBOX_URL).catch(e => { + console.error("Initial fetch failed."); + }); + } + }; + initApp(); + }, [handleUrlFetch]); + + // Handle uploaded file (Manual) + 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"); + } + + 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)."); + } finally { + setSyncing(false); + } + }; + + // 2. Schedule Auto-Refresh (Background) + useEffect(() => { + const checkAndRefresh = () => { + const now = new Date(); + const today = now.toISOString().split('T')[0]; // YYYY-MM-DD + const lastRefreshDate = localStorage.getItem('craze_last_refresh_date'); + + // Refresh if it's after 7 AM and we haven't refreshed today + if (now.getHours() >= 7 && lastRefreshDate !== today) { + console.log("Triggering daily data refresh..."); + handleUrlFetch(PERMANENT_DROPBOX_URL).then(() => { + localStorage.setItem('craze_last_refresh_date', today); + console.log("Daily refresh successful."); + }).catch(err => { + console.error("Daily refresh failed, will retry later.", err); + }); + } + }; + + // Check immediately on load in case the user opens the app after 7 AM + checkAndRefresh(); + + // And then check periodically (e.g., every 15 minutes) in case app is left open across midnight + const interval = setInterval(checkAndRefresh, 15 * 60 * 1000); + + return () => clearInterval(interval); + }, [handleUrlFetch]); + + + // Derive Data + const filteredData = useMemo(() => filterData(rawData, filters), [rawData, filters]); + const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]); + + // Derive Context Data (Product Line Context when drilling down) + const contextAggregatedData = useMemo(() => { + // Check if we are filtering by specific items (SKU, ASIN, Title) + const hasItemFilters = filters.sku.length > 0 || filters.asin.length > 0 || filters.title.length > 0; + + if (!hasItemFilters || filteredData.length === 0) { + return null; + } + + // 1. Identify the Product Lines associated with the currently filtered items + const activeLines = Array.from(new Set(filteredData.map(r => r.line))); + + // 2. Create a "broad" filter: Keep Year/Customer/Month, but CLEAR Item filters, and restrict to these Lines + const contextFilters: FilterState = { + ...filters, + line: activeLines, // Force these lines + sku: [], // Clear specific item filters + asin: [], + title: [] + }; + + // 3. Process this broader dataset + const broadData = filterData(rawData, contextFilters); + return aggregateData(broadData); + + }, [rawData, filters, filteredData]); + + + // Derive Options for Filter Dropdowns + const filterOptions = useMemo(() => { + return { + customer: getUniqueValues(rawData, 'customer'), + year: getUniqueValues(rawData, 'year'), + month: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + line: getUniqueValues(rawData, 'line'), + asin: getUniqueValues(rawData, 'asin'), + sku: getUniqueValues(rawData, 'sku'), + title: getUniqueValues(rawData, 'title'), + }; + }, [rawData]); + + const handleFilterChange = (key: keyof FilterState, value: string[]) => { + setFilters(prev => ({ ...prev, [key]: value })); + }; + + const handleAskGemini = async (text: string) => { + return await queryGemini(apiKey, text, aggregatedData, filteredData.length); + }; + + const disconnectUrl = async () => { + // Allow disconnecting to clear data, but the app will likely re-connect on next reload due to "Permanent" requirement + localStorage.removeItem('craze_csv_url'); + await clearSalesData(); + setActiveUrl(null); + setRawData([]); + }; + + return ( +
+ + {/* Header */} +
+
+
+ {/* Logo Container (Horizontal Box) - Persistent User Image */} +
+ +
+ + {/* Title & Status */} +
+

Analytics Dashboard

+ {activeUrl && lastUpdated && ( +

+ + Live Sync Active +

+ )} +
+
+ +
+ + {/* Main Action: Data Source Button */} + + + {/* NEW REFRESH BUTTON */} + + + {/* View Switcher */} +
+ + + +
+
+
+
+ + {/* Main Content */} +
+ {loading ? ( + // Initial loading spinner +
+
+

Loading Dashboard...

+

Syncing with Dropbox...

+
+ ) : ( + <> + {/* FilterBar is now relevant for all views including Top Movers */} + {(view === 'dashboard' || view === 'table' || view === 'topMovers') && ( + + )} + +
+ {view === 'dashboard' ? ( + + ) : view === 'table' ? ( + + ) : ( // New Top Movers View + + )} +
+ + )} +
+ + {/* Chat Assistant - Now with API Key Props */} + + + {/* DATA MODAL */} + {isDataModalOpen && ( +
+
+
+

Data Source Settings

+ +
+ +
+ +
+
+
+ )} + +
+ ); +}; + +export default App; \ No newline at end of file diff --git a/README.md b/README.md index 2241000..8fee996 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@
- GHBanner - -

Built with AI Studio

- -

The fastest path from prompt to production with Gemini.

- - Start building -
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/112o4Bbq7Nkh63MALtcroOOBq0Qn1hX9G + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/components/AIChat.tsx b/components/AIChat.tsx new file mode 100644 index 0000000..c31899c --- /dev/null +++ b/components/AIChat.tsx @@ -0,0 +1,247 @@ + +import React, { useState, useRef, useEffect } from 'react'; +import { ChatIcon, CloseIcon, SendIcon } from './Icons'; +import { ChatMessage } from '../types'; + +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 }) => { + const elements: React.ReactNode[] = []; + let listItems: React.ReactNode[] = []; + + const flushList = () => { + if (listItems.length > 0) { + elements.push( +
    + {listItems} +
+ ); + listItems = []; + } + }; + + const parseBold = (content: string, keyPrefix: string) => { + const parts = content.split(/(\*\*.*?\*\*)/g); + return parts.map((part, i) => { + if (part.startsWith('**') && part.endsWith('**')) { + return {part.slice(2, -2)}; + } + return part; + }); + } + + text.split('\n').forEach((line, index) => { + const trimmedLine = line.trim(); + if (trimmedLine.startsWith('* ') || trimmedLine.startsWith('- ')) { + const content = trimmedLine.substring(2); + listItems.push(
  • {parseBold(content, `li-${index}`)}
  • ); + } else { + flushList(); + if (line.trim() !== '') { + elements.push( +

    + {parseBold(line, `p-${index}`)} +

    + ); + } + } + }); + + flushList(); // Flush any remaining list items at the end + + return <>{elements.length > 0 ? elements :

    {text}

    }; +}; + +const SettingsIcon = () => ( + + + +); + + +const AIChat: React.FC = ({ onSendMessage, isOpen, setIsOpen, apiKey, onApiKeyChange }) => { + 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() } + ]); + 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]); + + const handleSend = async () => { + if (!input.trim() || !apiKey) return; + + const userMsg: ChatMessage = { role: 'user', text: input, timestamp: new Date() }; + setMessages(prev => [...prev, userMsg]); + setInput(''); + setIsTyping(true); + + const responseText = await onSendMessage(userMsg.text); + + setIsTyping(false); + setMessages(prev => [...prev, { role: 'model', text: responseText, timestamp: new Date() }]); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + 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 */} + + + {/* Chat Window */} +
    + {/* Header */} +
    +
    +
    +

    AI Data Assistant

    +
    +
    + + +
    +
    + + {/* 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; diff --git a/components/CrazeLogo.tsx b/components/CrazeLogo.tsx new file mode 100644 index 0000000..4a440d3 --- /dev/null +++ b/components/CrazeLogo.tsx @@ -0,0 +1,56 @@ +import React, { useState, useEffect } from 'react'; + +// Permanent logo URL provided by user +const DEFAULT_LOGO = "https://i.ibb.co/jkMPwJfj/logo-Photoroom.png"; + +const CrazeLogo = () => { + const [logoSrc, setLogoSrc] = useState(DEFAULT_LOGO); + + useEffect(() => { + // Check if user has uploaded a custom override locally + // Using v2 key to reset any previous cached logos and force the new default + const saved = localStorage.getItem('craze_custom_logo_v2'); + if (saved) { + setLogoSrc(saved); + } + }, []); + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + const reader = new FileReader(); + reader.onload = (ev) => { + const result = ev.target?.result as string; + setLogoSrc(result); + localStorage.setItem('craze_custom_logo_v2', result); + }; + reader.readAsDataURL(e.target.files[0]); + } + }; + + return ( +
    + {/* Invisible file input for manual override */} + + + {/* The Image - Scaled 3x from the left */} + Craze Analytix Logo { + // Fallback if external URL fails + console.warn("Failed to load external logo, reverting to placeholder or text"); + e.currentTarget.style.display = 'none'; + }} + /> +
    + ); +}; + +export default CrazeLogo; \ No newline at end of file diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx new file mode 100644 index 0000000..3751849 --- /dev/null +++ b/components/Dashboard.tsx @@ -0,0 +1,712 @@ + + + +import React, { useState, useMemo, useEffect } from 'react'; +import { AggregatedData, LineGrowthMetric } from '../types'; // Updated import for LineGrowthMetric +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + LineChart, Line, Legend +} from 'recharts'; +import { DownloadIcon } from './Icons'; // Import DownloadIcon + +interface DashboardProps { + data: AggregatedData; + contextData?: AggregatedData | null; +} + +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 [isExpanded, setIsExpanded] = useState(false); + + const toggleExpand = () => setIsExpanded(!isExpanded); + + // Auto-scroll to top of sticky filter bar when expanded + useEffect(() => { + if (isExpanded) { + // Approximate height of the Header to scroll past (Logo + padding) + // This ensures the Sticky FilterBar snaps to the top of the viewport + const scrollTarget = 250; + if (window.scrollY < scrollTarget) { + window.scrollTo({ top: scrollTarget, behavior: 'smooth' }); + } + } + }, [isExpanded]); + + if (isExpanded) { + return ( +
    + + {/* Fixed Close Button - Positioned TOP RIGHT ON TOP OF FILTER BAR with z-[100] */} + + +
    +

    {title}

    + {onExport && ( + + )} +
    +
    + {children} +
    +
    + ); + } + + return ( +
    +
    +

    {title}

    +
    + {onExport && ( + + )} + +
    +
    +
    {children}
    +
    + ); +}; + +const MultiYearKPICard: React.FC<{ + title: string; + metric: 'sellOut' | 'units'; + data: AggregatedData['totalsByYear']; + availableYears: string[]; + contextData?: AggregatedData['totalsByYear']; // Added context data +}> = ({ title, metric, data, availableYears, contextData }) => { + // Sort years descending to show most recent first + const sortedYears = [...availableYears].sort((a, b) => parseInt(b) - parseInt(a)); + + const formatValue = (val: number) => { + if (metric === 'sellOut') { + return `€${val.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}`; + } + return val.toLocaleString(); + }; + + // Determine title based on context + const displayTitle = contextData ? `${title} (Selected Item)` : title; + + return ( +
    +

    {displayTitle}

    + + {sortedYears.length === 0 &&

    0

    } + + {sortedYears.length > 0 && ( +
    + {sortedYears.map((year, index) => { + const currentValue = data[year] ? data[year][metric] : 0; + const contextValue = contextData && contextData[year] ? contextData[year][metric] : 0; + + let growthElement = null; + let contextGrowthElement = null; + + // Year-over-Year Growth comparison + if (index < sortedYears.length - 1) { + const prevYear = sortedYears[index + 1]; + + // Main Item Growth + const prevValue = data[prevYear] ? data[prevYear][metric] : 0; + if (prevValue > 0) { + const pct = ((currentValue - prevValue) / prevValue) * 100; + growthElement = ( + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {pct > 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}% + + ); + } + + // Context Item Growth (Product Line) + if (contextData) { + const prevContextValue = contextData[prevYear] ? contextData[prevYear][metric] : 0; + if (prevContextValue > 0) { + const pct = ((contextValue - prevContextValue) / prevContextValue) * 100; + contextGrowthElement = ( + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {pct > 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}% + + ); + } + } + } + + return ( +
    +
    +
    + {year} +
    + + {formatValue(currentValue)} + + {growthElement} +
    +
    +
    + + {/* Context Row (Product Line Total) */} + {contextData && contextValue > 0 && ( +
    + Total Product Line: +
    + {formatValue(contextValue)} + {contextGrowthElement} + {/* Share of Line % */} + + {((currentValue / contextValue) * 100).toFixed(1)}% Share + +
    +
    + )} +
    + ); + })} +
    + )} +
    + ); +}; + +const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + return ( +
    +

    {label}

    + {payload.map((p: any) => ( +

    + {p.name}: + + {p.name.toString().toLowerCase().includes('sell out') || p.name.toString().toLowerCase().includes('year') || typeof p.value === 'number' && p.value > 1000 + ? `€${Number(p.value).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}` + : Number(p.value).toLocaleString()} + +

    + ))} +
    + ); + } + return null; +}; + +// Tooltip specifically for the Seasonality Chart to show YoY % +const SeasonalityTooltip = ({ active, payload, label, metric }: any) => { + if (active && payload && payload.length) { + // Sort payload by year (name) to ensure we compare correctly + const sortedPayload = [...payload].sort((a, b) => parseInt(a.name) - parseInt(b.name)); + const isCurrency = metric === 'sellOut'; + + return ( +
    +

    {label}

    + {sortedPayload.map((p: any, index: number) => { + let growthEl = null; + // If there is a previous year in the list, calculate % change + if (index > 0) { + const prev = sortedPayload[index - 1]; + const prevVal = Number(prev.value); + const currVal = Number(p.value); + if (prevVal > 0) { + const pct = ((currVal - prevVal) / prevVal) * 100; + growthEl = ( + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {pct > 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}% + + ); + } + } + + return ( +
    + {p.name}: +
    + + {isCurrency ? '€' : ''}{Number(p.value).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})} + + {growthEl} +
    +
    + ); + })} +
    + ); + } + return null; +}; + +// Tooltip for the Top 10 Comparison Chart +const ComparisonTooltip = ({ active, payload, label, metric }: any) => { + if (active && payload && payload.length) { + // Sort payload by the dataKey (which usually contains the year, e.g., "2023_value" or just "2023") + const sortedPayload = [...payload].sort((a, b) => { + const yearA = parseInt(a.dataKey.split('_')[0]); + const yearB = parseInt(b.dataKey.split('_')[0]); + return yearA - yearB; + }); + const isCurrency = metric === 'sellOut'; + + return ( +
    +

    {label}

    + {sortedPayload.map((p: any, index: number) => { + const year = p.dataKey.split('_')[0]; + let growthEl = null; + + if (index > 0) { + const prev = sortedPayload[index - 1]; + const prevVal = Number(prev.value); + const currVal = Number(p.value); + if (prevVal > 0) { + const pct = ((currVal - prevVal) / prevVal) * 100; + growthEl = ( + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {pct > 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}% + + ); + } + } + + return ( +
    + {year}: +
    + + {isCurrency ? '€' : ''}{Number(p.value).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})} + + {growthEl} +
    +
    + ); + })} +
    + ); + } + return null; + }; + +const GrowthTable: React.FC<{ + title: string; + data: LineGrowthMetric[]; // Updated to LineGrowthMetric + 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 sortedData = useMemo(() => { + if (!sortConfig.key) return data; + + return [...data].sort((a, b) => { + const aVal = a[sortConfig.key!] as number | string; + const bVal = b[sortConfig.key!] as number | string; + + if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1; + if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1; + return 0; + }); + }, [data, sortConfig]); + + const requestSort = (key: keyof LineGrowthMetric) => { + 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 LineGrowthMetric) => { + 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('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)}% + + {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) +
    +
    + ); +} + +const Dashboard: React.FC = ({ data, contextData }) => { + const [seasonalityMetric, setSeasonalityMetric] = useState<'sellOut' | 'units'>('sellOut'); + const [top10Metric, setTop10Metric] = useState<'sellOut' | 'units'>('sellOut'); + + // Decide which data source to use for Product Line charts + // If contextData is provided (drill down), we use that to show the "Total Line" view. + // Otherwise we use the standard filtered data. + const displayData = contextData || data; + + // Calculate dynamic height for the All Product Lines chart to enable scrolling + // Assume ~60px per product line to give it enough space, minimum 300px + const chartHeight = Math.max(displayData.topLinesSplit.length * 60, 300); + + return ( +
    + + {/* KPI Section - Pass both specific data and context data */} +
    + + +
    + + {/* Main Grid */} +
    + + {/* Left Column */} +
    + {/* All Product Lines Revenue Chart */} + +
    +
    + {contextData && Showing Full Product Line Data} +
    + + +
    +
    + {/* Scrollable Container */} +
    +
    + + + + top10Metric === 'sellOut' ? `€${(val/1000).toFixed(0)}k` : val.toLocaleString()} + orientation='top' + /> + + } cursor={{fill: '#1e293b'}} /> + + {displayData.availableYears.map((year, index) => ( + + ))} + + +
    +
    +
    +
    + + {/* Growth Table */} + + + + + {/* Decline Table */} + + + +
    + + {/* Right Column */} +
    + {/* Units Chart (Split by Year) */} + +
    + {contextData &&
    Showing Full Product Line Data
    } +
    + + + + + { + if (val >= 1000000) return `${(val / 1000000).toFixed(1)}M`; + if (val >= 1000) return `${(val / 1000).toFixed(0)}k`; + return val; + }} + /> + } cursor={{fill: '#1e293b'}} /> + + {displayData.availableYears.map((year, index) => ( + + ))} + + +
    +
    +
    + + {/* Seasonality Chart - ALWAYS uses specific filtered data 'data' */} + +
    +
    +
    + + +
    +
    +
    + + + + + seasonalityMetric === 'sellOut' ? `€${(val/1000).toFixed(0)}k` : val.toLocaleString()} + /> + } /> + + {data.availableYears.map((year, index) => ( + + ))} + + +
    +
    +
    + + {/* Country Chart - Uses Specific Data 'data' usually, unless we want to broaden it. Kept specific for now. */} + + + + + + `€${(val/1000).toFixed(0)}k`}/> + } cursor={{fill: '#1e293b'}} /> + + {data.availableYears.map((year, index) => ( + + ))} + + + +
    +
    +
    + ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/components/DataGrid.tsx b/components/DataGrid.tsx new file mode 100644 index 0000000..06c5d1f --- /dev/null +++ b/components/DataGrid.tsx @@ -0,0 +1,981 @@ + + +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 { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries } from '../services/dataProcessor'; +import MultiSelectDropdown from './MultiSelectDropdown'; +import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon } from './Icons'; + +interface DataGridProps { + data: SalesRecord[]; +} + +type SortConfig = { + key: keyof PivotRow | string | null; // string for dynamic year sorting + direction: 'asc' | 'desc'; +}; + +type ConditionalFilter = { + id: string; + metric: string; + operator: 'gt' | 'lt'; + value: number; +} + +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 +const DIMENSION_OPTIONS = [ + { label: 'Product Line', value: 'line' }, + { label: 'Customer', value: 'customer' }, + { label: 'SKU', value: 'sku' }, + { label: 'Title', value: 'title' }, + { label: 'ASIN', value: 'asin' }, +]; + +// Tooltip for single-period view with Week-over-Week comparison +const WoWTooltip = ({ active, payload, label, data }: any) => { + if (active && payload && payload.length && data) { + const currentIndex = data.findIndex((d: any) => d.name === label); + const prevData = currentIndex > 0 ? data[currentIndex - 1] : null; + + return ( +
    +

    {label}

    + {payload.map((p: any) => { + let wowEl = null; + if (prevData) { + const prevValue = prevData[p.dataKey]; + const currentValue = p.value; + if (prevValue != null && prevValue > 0) { + const pct = ((currentValue - prevValue) / prevValue) * 100; + wowEl = ( + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}% + + ); + } + } + + return ( +
    + {p.name}: +
    + + {p.dataKey === 'sellOut' + ? `€${Number(p.value).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}` + : `${Number(p.value).toLocaleString()} u`} + + {wowEl} +
    +
    + ); + })} +
    + ); + } + return null; +} + +// Tooltip for multi-year comparison view +const ComparisonTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + + interface YearData { + sellOut?: number; + units?: number; + color?: string; + } + + const dataByYear: { [year: string]: YearData } = {}; + + payload.forEach((p: any) => { + const nameParts = p.name.split(' '); + if (nameParts.length < 2) return; + + const year = nameParts[nameParts.length - 1]; + const metric = nameParts.slice(0, nameParts.length - 1).join(' '); + + if (!dataByYear[year]) { + dataByYear[year] = {}; + } + // Use the color from the Sell Out line for consistency for that year block + if (metric.toLowerCase().includes('so')) { + dataByYear[year].sellOut = p.value; + dataByYear[year].color = p.stroke || p.color; + } else if (metric.toLowerCase().includes('units')) { + dataByYear[year].units = p.value; + if(!dataByYear[year].color) { // fallback color from units line + dataByYear[year].color = p.stroke || p.color; + } + } + }); + + const sortedYears = Object.keys(dataByYear).sort((a, b) => parseInt(b) - parseInt(a)); + + return ( +
    +

    {label}

    + {sortedYears.map((year, index) => { + const yearData = dataByYear[year]; + const prevYear = sortedYears[index + 1]; + const prevYearData = prevYear ? dataByYear[prevYear] : null; + + let sellOutGrowthEl = null; + if (prevYearData && prevYearData.sellOut != null && prevYearData.sellOut !== 0 && yearData.sellOut != null) { + const pct = ((yearData.sellOut - prevYearData.sellOut) / prevYearData.sellOut) * 100; + sellOutGrowthEl = ( + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}% + + ); + } + + let unitsGrowthEl = null; + if (prevYearData && prevYearData.units != null && prevYearData.units !== 0 && yearData.units != null) { + const pct = ((yearData.units - prevYearData.units) / prevYearData.units) * 100; + unitsGrowthEl = ( + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}% + + ); + } + + return ( +
    +

    {year}

    + + {yearData.sellOut != null && ( +
    + Sell Out: +
    + + €{Number(yearData.sellOut).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})} + + {sellOutGrowthEl} +
    +
    + )} + + {yearData.units != null && ( +
    + Units: +
    + + {Number(yearData.units).toLocaleString()} u + + {unitsGrowthEl} +
    +
    + )} +
    + ); + })} +
    + ); + } + return null; +}; + +// Reusable Expandable Card for the Chart +const ExpandableChartCard: React.FC<{ title: string; children: React.ReactNode; className?: string }> = ({ title, children, className }) => { + const [isExpanded, setIsExpanded] = useState(false); + + const toggleExpand = () => setIsExpanded(!isExpanded); + + if (isExpanded) { + return ( +
    +
    +

    {title}

    + +
    +
    + {children} +
    +
    + ); + } + + return ( +
    +
    +

    {title}

    + +
    +
    + {children} +
    +
    + ); +}; + + +const DataGrid: React.FC = ({ data }) => { + const [currentPage, setCurrentPage] = useState(1); + const [sortConfig, setSortConfig] = useState({ key: null, direction: 'desc' }); + const [showChart, setShowChart] = useState(true); + const [visibleMetrics, setVisibleMetrics] = useState<('sellOut' | 'units')[]>(['sellOut', 'units']); + + // State for dynamic grouping + const [selectedDimensions, setSelectedDimensions] = useState(['line', 'customer', 'sku', 'title']); + + // State for Advanced Filtering + const [showFilterBuilder, setShowFilterBuilder] = useState(false); + const [rowFilters, setRowFilters] = useState([]); + // Temp state for new filter inputs + const [newFilterMetric, setNewFilterMetric] = useState(''); + const [newFilterOperator, setNewFilterOperator] = useState<'gt' | 'lt'>('gt'); + const [newFilterValue, setNewFilterValue] = useState(''); + + // Effective dimensions for rendering + const effectiveDimensions = useMemo(() => + selectedDimensions.length > 0 ? selectedDimensions : ['customer'], + [selectedDimensions]); + + // Transform flat data into Pivot structure + const { rows: pivotRows, years } = useMemo(() => { + return pivotSalesData(data, effectiveDimensions); + }, [data, effectiveDimensions]); + + // Data for the time series chart, supporting single and multi-year comparison + const { chartData, uniqueYears, isComparisonView, chartTitle } = useMemo(() => { + const yearsInView = Array.from(new Set(data.map(d => d.year.toString()))).sort((a, b) => parseInt(b) - parseInt(a)); + const isMultiYear = yearsInView.length > 1; + + if (isMultiYear) { + return { + chartData: aggregateForComparisonTimeSeries(data), + uniqueYears: yearsInView, + isComparisonView: true, + chartTitle: `Weekly Sales Comparison: ${yearsInView.join(' vs ')}` + }; + } else { + return { + chartData: aggregateForTimeSeries(data), + uniqueYears: yearsInView, + isComparisonView: false, + chartTitle: `Weekly Sales Evolution ${yearsInView[0] || ''}` + }; + } + }, [data]); + + // Filter Options based on available data + const metricOptions = useMemo(() => { + const options = []; + // Totals + years.forEach(y => { + options.push({ label: `Total Sell Out ${y} (€)`, value: `total_sellOut_${y}` }); + options.push({ label: `Total Units ${y}`, value: `total_units_${y}` }); + }); + // Growth (Latest vs Previous) + if (years.length >= 2) { + options.push({ label: `Growth % Sell Out (${years[0]} vs ${years[1]})`, value: 'growth_sellOut' }); + options.push({ label: `Growth % Units (${years[0]} vs ${years[1]})`, value: 'growth_units' }); + } + return options; + }, [years]); + + // 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; + + // 1. Filter + if (rowFilters.length > 0) { + const latestYear = years[0]; + const prevYear = years[1]; + + result = result.filter(row => { + return rowFilters.every(filter => { + let rowValue = 0; + + if (filter.metric.startsWith('total_sellOut_')) { + const y = filter.metric.split('_')[2]; + rowValue = row.totalsByYear[y]?.sellOut || 0; + } + else if (filter.metric.startsWith('total_units_')) { + const y = filter.metric.split('_')[2]; + rowValue = row.totalsByYear[y]?.units || 0; + } + else if (filter.metric === 'growth_sellOut') { + if (!prevYear) return true; + const curr = row.totalsByYear[latestYear]?.sellOut || 0; + const prev = row.totalsByYear[prevYear]?.sellOut || 0; + if (prev === 0) return curr > 0; + rowValue = ((curr - prev) / prev) * 100; + } + else if (filter.metric === 'growth_units') { + if (!prevYear) return true; + const curr = row.totalsByYear[latestYear]?.units || 0; + const prev = row.totalsByYear[prevYear]?.units || 0; + if (prev === 0) return curr > 0; + rowValue = ((curr - prev) / prev) * 100; + } + + if (filter.operator === 'gt') return rowValue > filter.value; + if (filter.operator === 'lt') return rowValue < filter.value; + return true; + }); + }); + } + + // 2. Sort + if (effectiveSortKey) { + result = [...result].sort((a, b) => { + let aVal: string | number = 0; + let bVal: string | number = 0; + + 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; + } + + 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; + return 0; + }); + } + + return result; + }, [pivotRows, rowFilters, effectiveSortKey, sortConfig, effectiveDimensions, 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 start = (currentPage - 1) * ROWS_PER_PAGE; + return processedRows.slice(start, start + ROWS_PER_PAGE); + }, [processedRows, currentPage]); + + // Handlers + 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 toggleMetric = (metric: 'sellOut' | 'units') => { + setVisibleMetrics(prev => + prev.includes(metric) + ? prev.filter(m => m !== metric) + : [...prev, metric] + ); + }; + + // 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 + }; + + const removeFilter = (id: string) => { + setRowFilters(prev => prev.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; + + 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" + /> +
    + + + + + + +
    +
    + + {/* 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()} + /> +
    + + +
    + )} +
    + )} +
    + + {/* Trend Chart */} + {showChart && chartData.length > 1 && ( + +
    +
    +
    + + +
    +
    +
    + + + + + + {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') && ( + + )} + + )} + + +
    +
    +
    + )} + + {(!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." + } +
    + )} + + + {/* Table Container */} +
    + + + + {/* Dynamic Dimension Headers - STICKY TOP */} + {effectiveDimensions.map((dim, index) => { + const label = getLabel(dim); + const isFirst = index === 0; + const isTitle = dim === 'title'; + + return ( + + ); + })} + + {/* Dynamic Total Columns for each Year - STICKY TOP */} + {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) => ( + + ))} + + + + + {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) => ( + + ))} + + )})} + +
    requestSort(dim)} + > + {label} {sortConfig.key === dim && (sortConfig.direction === 'asc' ? '▲' : '▼')} + requestSort(`total_${year}`)} + > + Total {year} {sortConfig.key === `total_${year}` && (sortConfig.direction === 'asc' ? '▲' : '▼')} + + {m} +
    + 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} +
    +
    +
    + ); + })} +
    + {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} +
    +
    +
    + ); + })} +
    +
    + + {/* Footer */} +
    +
    + Showing {((currentPage - 1) * ROWS_PER_PAGE) + 1} - {Math.min(currentPage * ROWS_PER_PAGE, processedRows.length)} of {processedRows.length} Rows +
    +
    + + + Page {currentPage} of {totalPages} + + +
    +
    +
    +
    + ); +}; + +export default DataGrid; diff --git a/components/FileUpload.tsx b/components/FileUpload.tsx new file mode 100644 index 0000000..7bf1eb4 --- /dev/null +++ b/components/FileUpload.tsx @@ -0,0 +1,141 @@ +import React, { ChangeEvent, useState } from 'react'; +import { UploadIcon } from './Icons'; + +interface FileUploadProps { + onFileUpload: (file: File) => void; + onUrlSubmit: (url: string) => void; + isLoading: boolean; + activeUrl?: string | null; + onDisconnect?: () => void; + lastUpdated?: string | null; +} + +const FileUpload: React.FC = ({ + onFileUpload, + onUrlSubmit, + isLoading, + activeUrl, + onDisconnect, + lastUpdated +}) => { + const [url, setUrl] = useState(''); + + const handleChange = (e: ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + onFileUpload(e.target.files[0]); + } + }; + + const handleUrlSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (url.trim()) { + onUrlSubmit(url.trim()); + } + }; + + const handleSyncNow = () => { + if (activeUrl) onUrlSubmit(activeUrl); + }; + + return ( +
    + + {/* Active Connection Status */} + {activeUrl && ( +
    +
    +
    + Cloud Sync Active +
    +

    {activeUrl}

    + {lastUpdated &&

    Last updated: {new Date(lastUpdated).toLocaleString()}

    } + +
    + + +
    +
    + )} + + {/* Manual File Upload */} +
    + +
    + +
    +
    + OR +
    +
    + + {/* URL Connection */} +
    +

    {activeUrl ? 'Change Source URL' : 'Connect Cloud CSV'}

    +

    Direct link to CSV (e.g. Dropbox dl=1). Auto-refreshes daily at 7 AM.

    +
    + setUrl(e.target.value)} + className="flex-1 bg-slate-950 border border-slate-700 text-slate-200 rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 text-sm" + required + /> + +
    +
    + + {isLoading && !activeUrl && ( +
    +
    + Processing data... +
    + )} +
    + ); +}; + +export default FileUpload; \ No newline at end of file diff --git a/components/FilterBar.tsx b/components/FilterBar.tsx new file mode 100644 index 0000000..10d2115 --- /dev/null +++ b/components/FilterBar.tsx @@ -0,0 +1,78 @@ + +import React from 'react'; +import { FilterState } from '../types'; +import MultiSelectDropdown from './MultiSelectDropdown'; + +interface FilterBarProps { + filters: FilterState; + onFilterChange: (key: keyof FilterState, value: string[]) => void; + options: { + customer: string[]; + year: string[]; + month: string[]; + line: string[]; + asin: string[]; + sku: string[]; + title: string[]; + }; +} + +const FilterBar: React.FC = ({ filters, onFilterChange, options }) => { + return ( +
    +
    + onFilterChange('customer', v)} + className="flex-1" + /> + onFilterChange('year', v)} + className="flex-1" + /> + onFilterChange('month', v)} + className="flex-1" + /> + onFilterChange('line', v)} + className="flex-1" + /> + onFilterChange('sku', v)} + className="flex-1" + /> + onFilterChange('title', v)} + className="flex-1" + /> + onFilterChange('asin', v)} + className="flex-1" + /> +
    +
    + ); +}; + +export default FilterBar; diff --git a/components/Icons.tsx b/components/Icons.tsx new file mode 100644 index 0000000..a616f15 --- /dev/null +++ b/components/Icons.tsx @@ -0,0 +1,63 @@ + +import React from 'react'; + +export const UploadIcon = () => ( + + + +); + +export const ChatIcon = () => ( + + + + +); + +export const CloseIcon = () => ( + + + +); + +export const SendIcon = () => ( + + + +); + +export const ChartIcon = () => ( + + + +); + +export const TableIcon = () => ( + + + +); + +export const SearchIcon = () => ( + + + +); + +export const DownloadIcon = () => ( + + + +); + +export const FunnelIcon = () => ( + + + +); + +export const MoversIcon = () => ( + + + +); \ No newline at end of file diff --git a/components/ItemGrowthTable.tsx b/components/ItemGrowthTable.tsx new file mode 100644 index 0000000..26e9c90 --- /dev/null +++ b/components/ItemGrowthTable.tsx @@ -0,0 +1,198 @@ + +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/MultiSelectDropdown.tsx b/components/MultiSelectDropdown.tsx new file mode 100644 index 0000000..c7993e3 --- /dev/null +++ b/components/MultiSelectDropdown.tsx @@ -0,0 +1,151 @@ + +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { SearchIcon } from './Icons'; + +interface MultiSelectDropdownProps { + label: string; + selected: string[]; + options: string[]; + onChange: (newSelected: string[]) => void; + className?: string; +} + +const MultiSelectDropdown: React.FC = ({ label, selected, options, onChange, className }) => { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const dropdownRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Focus input when opening + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + if (!isOpen) { + setSearchTerm(''); // Reset search when closing + } + }, [isOpen]); + + const filteredOptions = useMemo(() => { + if (!searchTerm) return options; + return options.filter(opt => opt.toLowerCase().includes(searchTerm.toLowerCase())); + }, [options, searchTerm]); + + const toggleOption = (option: string) => { + if (selected.includes(option)) { + onChange(selected.filter((item) => item !== option)); + } else { + onChange([...selected, option]); + } + }; + + const handleSelectAll = () => { + // If searching, only select/deselect visible options + if (searchTerm) { + const allFilteredSelected = filteredOptions.every(opt => selected.includes(opt)); + if (allFilteredSelected) { + // Deselect all filtered options + onChange(selected.filter(item => !filteredOptions.includes(item))); + } else { + // Select all filtered options (add unique ones) + const newSelected = Array.from(new Set([...selected, ...filteredOptions])); + onChange(newSelected); + } + } else { + // Standard behavior + if (selected.length === options.length) { + onChange([]); // Deselect all + } else { + onChange([...options]); // Select all + } + } + }; + + const handleClear = () => { + onChange([]); + }; + + return ( +
    + + + + + {isOpen && ( +
    + + {/* Search Bar */} +
    +
    + + + + setSearchTerm(e.target.value)} + /> +
    +
    + +
    + + +
    + +
    + {filteredOptions.map((opt) => ( + + ))} + {filteredOptions.length === 0 &&
    No matches found
    } +
    +
    + )} +
    + ); +}; + +export default MultiSelectDropdown; diff --git a/components/TopMoversPage.tsx b/components/TopMoversPage.tsx new file mode 100644 index 0000000..ee50e54 --- /dev/null +++ b/components/TopMoversPage.tsx @@ -0,0 +1,92 @@ +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 new file mode 100644 index 0000000..6e87af2 --- /dev/null +++ b/index.html @@ -0,0 +1,59 @@ + + + + + + SAS Analytics Dashboard + + + + + + + + +
    + + \ No newline at end of file diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..6ca5361 --- /dev/null +++ b/index.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error("Could not find root element to mount to"); +} + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); \ No newline at end of file diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..28b9f7d --- /dev/null +++ b/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "Craze Analytix", + "description": "A high-performance, dark-mode analytics dashboard for analyzing CRAZE sales data with CSV upload capabilities and Gemini AI integration.", + "requestFramePermissions": [] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..db1c383 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "craze-analytix", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0", + "@google/genai": "^1.30.0", + "recharts": "^3.5.0", + "xlsx": "0.18.5" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts new file mode 100644 index 0000000..8a06ac1 --- /dev/null +++ b/services/dataProcessor.ts @@ -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 = {}; + 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 => { + 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 => { + 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(); + const seasonalityUnitsMap = new Map(); + const yearsSet = new Set(); + + // 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(); + 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(); + + 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(); + 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(); + 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>(); + const allYears = new Set(); + + 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>(); + const allYearsInFilteredData = new Set(); + + 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 = {}; + 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(); + 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(); + 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(); + + 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(); + 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(); // 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); +}; \ No newline at end of file diff --git a/services/geminiService.ts b/services/geminiService.ts new file mode 100644 index 0000000..ad75c06 --- /dev/null +++ b/services/geminiService.ts @@ -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 => { + + 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."}`; + } +}; diff --git a/services/storage.ts b/services/storage.ts new file mode 100644 index 0000000..98c8993 --- /dev/null +++ b/services/storage.ts @@ -0,0 +1,83 @@ + +import { SalesRecord } from '../types'; + +const DB_NAME = 'CrazeAnalytixDB'; +const STORE_NAME = 'salesData'; +const DB_VERSION = 1; + +const initDB = (): Promise => { + 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 => { + 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 => { + 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); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c6eed5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "types": [ + "node" + ], + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} \ No newline at end of file diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..69f67b5 --- /dev/null +++ b/types.ts @@ -0,0 +1,127 @@ + + +export interface SalesRecord { + id: string; + customer: string; + year: number; + month: string; // "Apr-23" + week?: number; + asin: string; + sku: string; + title: string; // Added Product Title + articleName: string; // Kept for backward compatibility if mixed files used + units: number; + sellOut: number; // Parsed numeric value + line: string; +} + +export interface FilterState { + customer: string[]; + year: string[]; + month: string[]; + line: string[]; + asin: string[]; + sku: string[]; + title: string[]; // Added Title filter +} + +export interface LineGrowthMetric { // Renamed from GrowthMetric + line: string; + currentYearSellOut: number; + previousYearSellOut: number; + sellOutGrowthValue: number; + sellOutGrowthPercentage: number; + + currentYearUnits: number; + previousYearUnits: number; + unitsGrowthValue: number; + unitsGrowthPercentage: number; +} + +export interface ItemGrowthMetric { + sku: string; + asin: string; + title: string; + line: string; // Keep line for context + 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 +} + +export interface YearlySplitData { + name: string; + // Dynamic keys like "2023", "2024" or "2023_value", "2023_units" + [key: string]: number | string; +} + +export interface AggregatedData { + totalSellOut: number; + totalUnits: number; + totalsByYear: Record; + byLine: { name: string; value: number; units: number }[]; + byCustomer: { name: string; value: number }[]; + seasonality: SeasonalityPoint[]; + seasonalityUnits: SeasonalityPoint[]; + availableYears: string[]; + topMovers: LineGrowthMetric[]; // Now uses LineGrowthMetric + bottomMovers: LineGrowthMetric[]; // Now uses LineGrowthMetric + comparisonPeriods: { current: string; previous: string }; + topLinesSplit: YearlySplitData[]; + byCustomerSplit: YearlySplitData[]; + byLineOverviewSplit: YearlySplitData[]; +} + +export interface ChatMessage { + role: 'user' | 'model'; + text: string; + timestamp: Date; +} + +// New Interfaces for Dynamic Pivot Grid +export interface YearlyData { + sellOut: number; + units: number; +} + +export interface MonthlyPivot { + monthIndex: number; + byYear: Record; +} + +export interface PivotRow { + id: string; + customer: string; + line: string; + title: string; // Added Title + articleName: string; + sku: string; + asin: string; + + // Dynamic buckets + totalsByYear: Record; + months: MonthlyPivot[]; // Always 12 elements +} + +export interface TimeSeriesData { + name: string; // e.g., "Apr '23" or "W21 '23" + sellOut: number; + units: number; +} + +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 diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ee5fb8d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,23 @@ +import path from 'path'; +import { defineConfig, loadEnv } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, '.', ''); + return { + server: { + port: 3000, + host: '0.0.0.0', + }, + plugins: [react()], + define: { + 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY) + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + } + } + }; +});