From 9ba63ab8f8064e1275e2dfe0e9009a14c06ba226 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 11 Dec 2025 11:25:26 +0100 Subject: [PATCH] feat: Initialize Craze Analytix project structure Sets up the project with Vite, React, Tailwind CSS, Gemini AI integration, and necessary dependencies for data analysis. Includes initial configuration for TypeScript, Tailwind, and project metadata. --- .gitignore | 24 + App.tsx | 416 ++++++++++++ README.md | 25 +- components/AIChat.tsx | 247 ++++++++ components/CrazeLogo.tsx | 56 ++ components/Dashboard.tsx | 712 +++++++++++++++++++++ components/DataGrid.tsx | 981 +++++++++++++++++++++++++++++ components/FileUpload.tsx | 141 +++++ components/FilterBar.tsx | 78 +++ components/Icons.tsx | 63 ++ components/ItemGrowthTable.tsx | 198 ++++++ components/MultiSelectDropdown.tsx | 151 +++++ components/TopMoversPage.tsx | 92 +++ index.html | 59 ++ index.tsx | 15 + metadata.json | 5 + package.json | 24 + services/dataProcessor.ts | 817 ++++++++++++++++++++++++ services/geminiService.ts | 123 ++++ services/storage.ts | 83 +++ tsconfig.json | 29 + types.ts | 127 ++++ vite.config.ts | 23 + 23 files changed, 4481 insertions(+), 8 deletions(-) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 components/AIChat.tsx create mode 100644 components/CrazeLogo.tsx create mode 100644 components/Dashboard.tsx create mode 100644 components/DataGrid.tsx create mode 100644 components/FileUpload.tsx create mode 100644 components/FilterBar.tsx create mode 100644 components/Icons.tsx create mode 100644 components/ItemGrowthTable.tsx create mode 100644 components/MultiSelectDropdown.tsx create mode 100644 components/TopMoversPage.tsx create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 services/dataProcessor.ts create mode 100644 services/geminiService.ts create mode 100644 services/storage.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts 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, '.'), + } + } + }; +});