feat: Initialize Craze Analytix project structure

Sets up the project with Vite, React, Tailwind CSS, Gemini AI integration, and necessary dependencies for data analysis. Includes initial configuration for TypeScript, Tailwind, and project metadata.
This commit is contained in:
Christian
2025-12-11 11:25:26 +01:00
parent 563e6110ce
commit 9ba63ab8f8
23 changed files with 4481 additions and 8 deletions
+24
View File
@@ -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?
+416
View File
@@ -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 }) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className || "w-5 h-5"}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
);
// New Movers Icon
const MoversIcon = ({ className }: { className?: string }) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className={className}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18 9 11.25l4.306 4.305a11.164 11.164 0 0 0 5.814-5.815L21.75 6m0 0-3.5-3.5m3.5 3.5-3.5 3.5" />
</svg>
);
// 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<SalesRecord[]>([]);
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<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
// API Key State
const [apiKey, setApiKey] = useState<string>(() => localStorage.getItem('gemini_api_key') || '');
// Modal State
const [isDataModalOpen, setIsDataModalOpen] = useState(false);
// Filters State
const [filters, setFilters] = useState<FilterState>({
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 (
<div className="min-h-screen flex flex-col bg-background text-slate-200">
{/* Header */}
<header className="bg-slate-950/90 backdrop-blur border-b border-border py-4 px-6 relative z-40 shadow-2xl">
<div className="max-w-7xl mx-auto flex justify-between items-center">
<div className="flex items-center gap-8">
{/* Logo Container (Horizontal Box) - Persistent User Image */}
<div className="h-16 w-64 relative flex-shrink-0">
<CrazeLogo />
</div>
{/* Title & Status */}
<div className="hidden lg:block border-l border-slate-700 pl-6">
<h1 className="text-xl font-bold tracking-tight text-white text-shadow-sm">Analytics Dashboard</h1>
{activeUrl && lastUpdated && (
<p className="text-[10px] text-emerald-400 mt-1 flex items-center gap-1 uppercase font-bold tracking-wider">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
Live Sync Active
</p>
)}
</div>
</div>
<div className="flex items-center gap-4">
{/* Main Action: Data Source Button */}
<button
onClick={() => setIsDataModalOpen(true)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold shadow-lg transition-all border
${activeUrl
? 'bg-slate-800 text-emerald-400 border-emerald-500/50 hover:bg-slate-700'
: 'bg-indigo-600 text-white border-transparent hover:bg-indigo-500'}`}
>
{syncing ? <div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div> : <UploadIcon />}
<span className="hidden md:inline">{activeUrl ? 'Data Settings' : 'Connect Data'}</span>
</button>
{/* NEW REFRESH BUTTON */}
<button
onClick={() => activeUrl && handleUrlFetch(activeUrl)}
disabled={syncing}
title="Refresh Data"
className="p-3 rounded-lg bg-slate-800 border border-border text-slate-400 hover:text-white hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RefreshIcon className={`w-5 h-5 ${syncing ? 'animate-spin' : ''}`} />
</button>
{/* View Switcher */}
<div className="flex bg-slate-900 rounded-lg p-1 border border-border">
<button
onClick={() => setView('dashboard')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all
${view === 'dashboard' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<ChartIcon /> <span className="hidden sm:inline">Dashboard</span>
</button>
<button
onClick={() => setView('table')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all
${view === 'table' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<TableIcon /> <span className="hidden sm:inline">Data Grid</span>
</button>
<button // New Top Movers Button
onClick={() => setView('topMovers')}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all
${view === 'topMovers' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<MoversIcon className="w-5 h-5" /> <span className="hidden sm:inline">Top Movers</span>
</button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="flex-1 relative">
{loading ? (
// Initial loading spinner
<div className="flex flex-col items-center justify-center h-[80vh] gap-4">
<div className="w-16 h-16 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<h2 className="text-xl font-bold text-slate-300">Loading Dashboard...</h2>
<p className="text-sm text-slate-500">Syncing with Dropbox...</p>
</div>
) : (
<>
{/* FilterBar is now relevant for all views including Top Movers */}
{(view === 'dashboard' || view === 'table' || view === 'topMovers') && (
<FilterBar filters={filters} onFilterChange={handleFilterChange} options={filterOptions} />
)}
<div className="mt-6">
{view === 'dashboard' ? (
<Dashboard
data={aggregatedData}
contextData={contextAggregatedData} // Pass context data
/>
) : view === 'table' ? (
<DataGrid data={filteredData} />
) : ( // New Top Movers View
<TopMoversPage
filteredData={filteredData} // Pass globally filtered data
/>
)}
</div>
</>
)}
</main>
{/* Chat Assistant - Now with API Key Props */}
<AIChat
onSendMessage={handleAskGemini}
isOpen={isChatOpen}
setIsOpen={setIsChatOpen}
apiKey={apiKey}
onApiKeyChange={handleApiKeyChange}
/>
{/* DATA MODAL */}
{isDataModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm animate-fade-in p-4">
<div className="bg-slate-950 border border-border rounded-2xl shadow-2xl w-full max-w-lg relative overflow-hidden">
<div className="bg-slate-900 px-6 py-4 border-b border-border flex justify-between items-center">
<h2 className="text-lg font-bold text-white">Data Source Settings</h2>
<button onClick={() => setIsDataModalOpen(false)} className="text-slate-400 hover:text-white transition-colors">
<CloseIcon />
</button>
</div>
<div className="p-6">
<FileUpload
onFileUpload={handleFileUpload}
onUrlSubmit={handleUrlFetch}
isLoading={syncing}
activeUrl={activeUrl}
onDisconnect={disconnectUrl}
lastUpdated={lastUpdated}
/>
</div>
</div>
</div>
)}
</div>
);
};
export default App;
+17 -8
View File
@@ -1,11 +1,20 @@
<div align="center"> <div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" /> <img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
<h1>Built with AI Studio</h2>
<p>The fastest path from prompt to production with Gemini.</p>
<a href="https://aistudio.google.com/apps">Start building</a>
</div> </div>
# 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`
+247
View File
@@ -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<string>;
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(
<ul key={`ul-${elements.length}`} className="list-disc list-inside space-y-1 my-2 pl-2">
{listItems}
</ul>
);
listItems = [];
}
};
const parseBold = (content: string, keyPrefix: string) => {
const parts = content.split(/(\*\*.*?\*\*)/g);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={`${keyPrefix}-${i}`}>{part.slice(2, -2)}</strong>;
}
return part;
});
}
text.split('\n').forEach((line, index) => {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('* ') || trimmedLine.startsWith('- ')) {
const content = trimmedLine.substring(2);
listItems.push(<li key={index}>{parseBold(content, `li-${index}`)}</li>);
} else {
flushList();
if (line.trim() !== '') {
elements.push(
<p key={`p-${index}`} className="my-1">
{parseBold(line, `p-${index}`)}
</p>
);
}
}
});
flushList(); // Flush any remaining list items at the end
return <>{elements.length > 0 ? elements : <p>{text}</p>}</>;
};
const SettingsIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 1 1 0-9h.75c.704 0 1.402-.03 2.09-.09a1.692 1.692 0 0 1 1.624 1.374c.11 1.054.547 2.028 1.218 2.822.67.793 1.644 1.23 2.697 1.34a1.694 1.694 0 0 1 1.374 1.625c.06.688.09 1.386.09 2.09v.75a4.5 4.5 0 1 1-9 0v-.75c0-.704-.03-1.402-.09-2.09a1.692 1.692 0 0 1-1.374-1.624 11.264 11.264 0 0 0-1.34-2.698 11.263 11.263 0 0 0-2.822-1.217A1.692 1.692 0 0 1 10.34 15.84Z" />
</svg>
);
const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen, apiKey, onApiKeyChange }) => {
const [messages, setMessages] = useState<ChatMessage[]>([
{ 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<HTMLDivElement>(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 */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`fixed bottom-6 right-6 z-50 p-4 rounded-full shadow-2xl transition-all duration-300 hover:scale-110
${isOpen ? 'bg-slate-700 rotate-90 opacity-0 pointer-events-none' : 'bg-primary text-white rotate-0 opacity-100'}`}
>
<ChatIcon />
</button>
{/* Chat Window */}
<div
className={`fixed z-50 bg-slate-900 border border-border shadow-2xl transition-all duration-300 flex flex-col overflow-hidden
${isOpen
? 'bottom-6 right-6 w-96 h-[600px] rounded-2xl opacity-100 translate-y-0'
: 'bottom-6 right-6 w-96 h-0 opacity-0 translate-y-10 pointer-events-none'}`}
>
{/* Header */}
<div className="bg-primary/10 p-4 border-b border-border flex justify-between items-center backdrop-blur">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${apiKey ? 'bg-green-400 animate-pulse' : 'bg-red-500'}`}></div>
<h3 className="font-bold text-slate-100">AI Data Assistant</h3>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowConfig(!showConfig)}
className="text-slate-400 hover:text-white transition-colors p-1 rounded hover:bg-white/10"
title="API Settings"
>
<SettingsIcon />
</button>
<button onClick={() => setIsOpen(false)} className="text-slate-400 hover:text-white transition-colors p-1 rounded hover:bg-white/10">
<CloseIcon />
</button>
</div>
</div>
{/* Configuration Screen */}
{showConfig ? (
<div className="flex-1 p-6 flex flex-col justify-center bg-slate-950">
<div className="mb-6 text-center">
<div className="w-12 h-12 bg-indigo-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-indigo-400">
<ChatIcon />
</div>
<h3 className="text-lg font-bold text-white mb-2">Connect Gemini AI</h3>
<p className="text-sm text-slate-400">
To enable the AI assistant, please enter your Google Gemini API Key.
</p>
</div>
<form onSubmit={handleSaveKey} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase mb-1">API Key</label>
<input
type="password"
value={apiKey}
onChange={(e) => 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
/>
<p className="text-[10px] text-slate-500 mt-2">
Key is stored locally in your browser.
<a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noreferrer" className="text-indigo-400 hover:underline ml-1">Get a key here.</a>
</p>
</div>
<button
type="submit"
className="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 rounded-lg transition-colors"
>
Save & Start Chatting
</button>
</form>
</div>
) : (
<>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-950/50 custom-scrollbar">
{messages.map((msg, idx) => (
<div key={idx} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] rounded-2xl p-3 text-sm leading-relaxed shadow-sm
${msg.role === 'user'
? 'bg-primary text-white rounded-br-none'
: 'bg-slate-800 text-slate-200 border border-border rounded-bl-none'}`}
>
{msg.role === 'model' ? <ModelMessage text={msg.text} /> : msg.text}
</div>
</div>
))}
{isTyping && (
<div className="flex justify-start">
<div className="bg-slate-800 border border-border rounded-2xl rounded-bl-none p-4 flex gap-1 items-center">
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce delay-100"></div>
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce delay-200"></div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 bg-slate-900 border-t border-border">
<div className="relative">
<input
type="text"
value={input}
onChange={(e) => 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"
/>
<button
onClick={handleSend}
disabled={!input.trim() || isTyping}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 bg-primary text-white rounded-full hover:bg-indigo-400 disabled:opacity-50 transition-colors"
>
<SendIcon />
</button>
</div>
</div>
</>
)}
</div>
</>
);
};
export default AIChat;
+56
View File
@@ -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<string>(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<HTMLInputElement>) => {
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 (
<div className="w-full h-full relative group flex items-center justify-start">
{/* Invisible file input for manual override */}
<input
type="file"
accept="image/png, image/jpeg, image/jpg"
onChange={handleFileChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-20"
title="Click to replace this permanent logo with a custom file"
/>
{/* The Image - Scaled 3x from the left */}
<img
src={logoSrc}
alt="Craze Analytix Logo"
className="h-full w-auto object-contain object-left drop-shadow-lg scale-[3] origin-left transition-transform duration-300"
onError={(e) => {
// Fallback if external URL fails
console.warn("Failed to load external logo, reverting to placeholder or text");
e.currentTarget.style.display = 'none';
}}
/>
</div>
);
};
export default CrazeLogo;
+712
View File
@@ -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 (
<div className="fixed inset-0 z-50 bg-slate-950 px-6 pb-6 pt-32 flex flex-col animate-fade-in overflow-hidden">
{/* Fixed Close Button - Positioned TOP RIGHT ON TOP OF FILTER BAR with z-[100] */}
<button
onClick={toggleExpand}
className="fixed top-3 right-3 z-[100] p-2 bg-red-600/90 hover:bg-red-500 border border-red-400 rounded-full text-white transition-all shadow-2xl hover:scale-110 flex items-center gap-2 group"
title="Exit Fullscreen"
>
<span className="text-xs font-bold hidden group-hover:inline pr-1">CLOSE</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
<div className="flex justify-between items-center mb-4 border-b border-slate-800 pb-4 shrink-0">
<h3 className="text-xl font-bold text-slate-100 uppercase tracking-wide">{title}</h3>
{onExport && (
<button
onClick={onExport}
className="flex items-center gap-2 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
>
<DownloadIcon /> Export CSV
</button>
)}
</div>
<div className="flex-1 overflow-auto bg-slate-900 rounded-xl p-6 border border-border custom-scrollbar">
{children}
</div>
</div>
);
}
return (
<div
className={`bg-surface border border-border rounded-xl p-6 shadow-sm flex flex-col group relative transition-all duration-300 hover:shadow-primary/5 ${className}`}
>
<div className="flex justify-between items-start mb-4">
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wide">{title}</h3>
<div className="flex items-center gap-2">
{onExport && (
<button
onClick={onExport}
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-indigo-400 transition-opacity"
title={`Export ${exportFileName || title} to CSV`}
>
<DownloadIcon />
</button>
)}
<button
onClick={toggleExpand}
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-primary transition-opacity"
title="Expand to Fullscreen"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
</button>
</div>
</div>
<div className="flex-1 min-h-[250px] cursor-pointer" onClick={toggleExpand}>{children}</div>
</div>
);
};
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 (
<div className="bg-gradient-to-br from-surface to-slate-900 border border-border rounded-xl p-6 flex flex-col justify-center">
<h3 className="text-sm font-medium text-slate-400 mb-3">{displayTitle}</h3>
{sortedYears.length === 0 && <p className="text-3xl font-bold text-white">0</p>}
{sortedYears.length > 0 && (
<div className="space-y-4">
{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 = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct > 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
// 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 = (
<span className={`text-[10px] ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct > 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
}
}
return (
<div key={year} className="flex flex-col border-b border-slate-800 pb-2 last:border-0">
<div className="flex justify-between items-end">
<div className="flex flex-col">
<span className="text-xs text-slate-500 font-mono mb-0.5">{year}</span>
<div className="flex items-center">
<span className="text-xl font-bold text-slate-200 leading-none">
{formatValue(currentValue)}
</span>
{growthElement}
</div>
</div>
</div>
{/* Context Row (Product Line Total) */}
{contextData && contextValue > 0 && (
<div className="mt-1 flex flex-wrap items-center justify-between bg-slate-800/50 p-2 rounded text-xs">
<span className="text-slate-400 mr-2">Total Product Line:</span>
<div className="flex items-center gap-1">
<span className="text-slate-300 font-medium">{formatValue(contextValue)}</span>
{contextGrowthElement}
{/* Share of Line % */}
<span className="text-indigo-400 font-bold border-l border-slate-700 pl-2 ml-2 whitespace-nowrap">
{((currentValue / contextValue) * 100).toFixed(1)}% Share
</span>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
};
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-slate-900 border border-border p-3 rounded shadow-xl text-sm max-w-xs z-50">
<p className="font-bold text-slate-100 mb-2 border-b border-slate-700 pb-1">{label}</p>
{payload.map((p: any) => (
<p key={p.name} className="text-slate-300 flex justify-between gap-4" style={{ color: p.color }}>
<span>{p.name}:</span>
<span className="font-mono font-semibold">
{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()}
</span>
</p>
))}
</div>
);
}
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 (
<div className="bg-slate-900 border border-border p-3 rounded shadow-xl text-sm max-w-xs z-50">
<p className="font-bold text-slate-100 mb-2 border-b border-slate-700 pb-1">{label}</p>
{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 = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct > 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
}
return (
<div key={p.name} className="flex justify-between items-center gap-2 mb-1">
<span style={{ color: p.color }}>{p.name}:</span>
<div className="flex items-center">
<span className="font-mono font-semibold text-slate-200">
{isCurrency ? '€' : ''}{Number(p.value).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}
</span>
{growthEl}
</div>
</div>
);
})}
</div>
);
}
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 (
<div className="bg-slate-900 border border-border p-3 rounded shadow-xl text-sm max-w-xs z-50">
<p className="font-bold text-slate-100 mb-2 border-b border-slate-700 pb-1">{label}</p>
{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 = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct > 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
}
return (
<div key={year} className="flex justify-between items-center gap-2 mb-1">
<span style={{ color: p.color }}>{year}:</span>
<div className="flex items-center">
<span className="font-mono font-semibold text-slate-200">
{isCurrency ? '€' : ''}{Number(p.value).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}
</span>
{growthEl}
</div>
</div>
);
})}
</div>
);
}
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 (
<svg className="w-2.5 h-2.5 ml-1 text-slate-600 opacity-0 group-hover:opacity-50" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
);
}
return (
<svg className="w-2.5 h-2.5 ml-1 text-primary" fill="currentColor" viewBox="0 0 20 20">
{sortConfig.direction === 'asc'
? <path fillRule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clipRule="evenodd" />
: <path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
}
</svg>
);
};
return (
<div className="overflow-auto h-full relative">
<table className="w-full text-left text-sm h-full border-separate border-spacing-0">
<thead className="bg-slate-950 text-xs uppercase font-semibold text-slate-500 sticky top-0 z-10 shadow-sm">
<tr>
<th
className="px-4 py-3 bg-slate-950 min-w-[150px] cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('line')}
>
<div className="flex items-center">Product Line {getSortIndicator('line')}</div>
</th>
{/* Sell Out Columns */}
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-400 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('previousYearSellOut')}
>
<div className="flex items-center justify-end">Sell Out {periods.previous} {getSortIndicator('previousYearSellOut')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-200 cursor-pointer hover:text-white group select-none transition-colors"
onClick={() => requestSort('currentYearSellOut')}
>
<div className="flex items-center justify-end">Sell Out {periods.current} {getSortIndicator('currentYearSellOut')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('sellOutGrowthValue')}
>
<div className="flex items-center justify-end">SO Diff {getSortIndicator('sellOutGrowthValue')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('sellOutGrowthPercentage')}
>
<div className="flex items-center justify-end">SO Growth % {getSortIndicator('sellOutGrowthPercentage')}</div>
</th>
{/* Units Columns */}
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-400 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('previousYearUnits')}
>
<div className="flex items-center justify-end">Units {periods.previous} {getSortIndicator('previousYearUnits')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-200 cursor-pointer hover:text-white group select-none transition-colors"
onClick={() => requestSort('currentYearUnits')}
>
<div className="flex items-center justify-end">Units {periods.current} {getSortIndicator('currentYearUnits')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('unitsGrowthValue')}
>
<div className="flex items-center justify-end">Units Diff {getSortIndicator('unitsGrowthValue')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('unitsGrowthPercentage')}
>
<div className="flex items-center justify-end">Units Growth % {getSortIndicator('unitsGrowthPercentage')}</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border text-slate-300">
{sortedData.length > 0 ? (
sortedData.map((item, idx) => (
<tr key={idx} className="hover:bg-slate-800/50">
<td className="px-4 py-2 font-medium">{item.line}</td>
{/* Sell Out Columns */}
<td className="px-4 py-2 text-right text-slate-400">{item.previousYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}</td>
<td className="px-4 py-2 text-right font-medium text-slate-200">{item.currentYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}</td>
<td className={`px-4 py-2 text-right font-medium ${item.sellOutGrowthValue >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{item.sellOutGrowthValue > 0 ? '+' : ''}{item.sellOutGrowthValue.toLocaleString(undefined, {maximumFractionDigits: 0})}
{/* {item.sellOutGrowthValue.toFixed(0)} */}
</td>
<td className="px-4 py-2 text-right">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${item.sellOutGrowthPercentage >= 0 ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>
{item.sellOutGrowthPercentage.toFixed(1)}%
</span>
</td>
{/* Units Columns */}
<td className="px-4 py-2 text-right text-slate-400">{item.previousYearUnits.toLocaleString()}</td>
<td className="px-4 py-2 text-right font-medium text-slate-200">{item.currentYearUnits.toLocaleString()}</td>
<td className={`px-4 py-2 text-right font-medium ${item.unitsGrowthValue >= 0 ? 'text-violet-400' : 'text-orange-400'}`}>
{item.unitsGrowthValue > 0 ? '+' : ''}{item.unitsGrowthValue.toLocaleString()}
</td>
<td className="px-4 py-2 text-right">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${item.unitsGrowthPercentage >= 0 ? 'bg-violet-500/10 text-violet-400' : 'bg-orange-500/10 text-orange-400'}`}>
{item.unitsGrowthPercentage.toFixed(1)}%
</span>
</td>
</tr>
))
) : (
<tr>
<td colSpan={9} className="px-4 py-6 text-center text-slate-500 italic">
Insufficient data to calculate {type}. (Select at least 2 distinct years/periods)
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
const Dashboard: React.FC<DashboardProps> = ({ 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 (
<div className="p-6 space-y-6 max-w-7xl mx-auto animate-fade-in pb-24">
{/* KPI Section - Pass both specific data and context data */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<MultiYearKPICard
title="Sell Out Revenue"
metric="sellOut"
data={data.totalsByYear}
availableYears={data.availableYears}
contextData={contextData ? contextData.totalsByYear : undefined}
/>
<MultiYearKPICard
title="Units Sold"
metric="units"
data={data.totalsByYear}
availableYears={data.availableYears}
contextData={contextData ? contextData.totalsByYear : undefined}
/>
</div>
{/* Main Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-6 flex flex-col">
{/* All Product Lines Revenue Chart */}
<ExpandableCard title={contextData ? "Total Product Line Performance (Context)" : "Product Lines (Revenue)"} className="h-96">
<div className="flex flex-col h-full">
<div className="flex justify-between items-center mb-2">
{contextData && <span className="text-xs text-indigo-400 font-semibold uppercase tracking-wider">Showing Full Product Line Data</span>}
<div className="bg-slate-900 p-1 rounded-lg border border-slate-800 flex text-xs ml-auto">
<button
onClick={(e) => { e.stopPropagation(); setTop10Metric('sellOut'); }}
className={`px-3 py-1 rounded-md transition-colors ${top10Metric === 'sellOut' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'}`}
>
Sell Out ()
</button>
<button
onClick={(e) => { e.stopPropagation(); setTop10Metric('units'); }}
className={`px-3 py-1 rounded-md transition-colors ${top10Metric === 'units' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'}`}
>
Units
</button>
</div>
</div>
{/* Scrollable Container */}
<div className="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
<div style={{ height: `${chartHeight}px` }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={displayData.topLinesSplit} layout="vertical" margin={{ left: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" horizontal={false} />
<XAxis
type="number"
stroke="#64748b"
tickFormatter={(val) => top10Metric === 'sellOut' ? `${(val/1000).toFixed(0)}k` : val.toLocaleString()}
orientation='top'
/>
<YAxis dataKey="name" type="category" width={100} stroke="#94a3b8" tick={{fontSize: 12}} />
<Tooltip content={(props: any) => <ComparisonTooltip {...props} metric={top10Metric} />} cursor={{fill: '#1e293b'}} />
{displayData.availableYears.map((year, index) => (
<Bar
key={year}
dataKey={`${year}_${top10Metric === 'sellOut' ? 'value' : 'units'}`}
name={year}
fill={COLORS[index % COLORS.length]}
radius={[0, 4, 4, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</ExpandableCard>
{/* Growth Table */}
<ExpandableCard
title={contextData ? "Fastest Growing Lines (Full Line Context)" : "Fastest Growing Lines (YoY - €)"}
className="h-[400px]" // Provide a default height for the card
>
<GrowthTable
title={contextData ? "Fastest Growing Lines (Full Line Context)" : "Fastest Growing Lines (YoY - €)"}
data={displayData.topMovers}
type="growth"
periods={displayData.comparisonPeriods}
/>
</ExpandableCard>
{/* Decline Table */}
<ExpandableCard
title={contextData ? "Declining Lines (Full Line Context)" : "Declining Lines (YoY - €)"}
className="h-[400px]" // Provide a default height for the card
>
<GrowthTable
title={contextData ? "Declining Lines (Full Line Context)" : "Declining Lines (YoY - €)"}
data={displayData.bottomMovers}
type="decline"
periods={displayData.comparisonPeriods}
/>
</ExpandableCard>
</div>
{/* Right Column */}
<div className="space-y-6">
{/* Units Chart (Split by Year) */}
<ExpandableCard title={contextData ? "Total Product Line Units (Context)" : "Units Sold by Product Line (Overview)"} className="h-96">
<div className="flex flex-col h-full">
{contextData && <div className="text-xs text-indigo-400 font-semibold uppercase tracking-wider mb-2 text-right">Showing Full Product Line Data</div>}
<div className="flex-1">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={displayData.byLineOverviewSplit} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
<XAxis dataKey="name" stroke="#64748b" tick={{fontSize: 10}} interval={0} angle={-15} textAnchor="end" height={60} />
<YAxis
stroke="#64748b"
tickFormatter={(val) => {
if (val >= 1000000) return `${(val / 1000000).toFixed(1)}M`;
if (val >= 1000) return `${(val / 1000).toFixed(0)}k`;
return val;
}}
/>
<Tooltip content={(props: any) => <ComparisonTooltip {...props} metric="units" />} cursor={{fill: '#1e293b'}} />
{displayData.availableYears.map((year, index) => (
<Bar
key={year}
dataKey={year}
name={year}
fill={COLORS[index % COLORS.length]}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</div>
</ExpandableCard>
{/* Seasonality Chart - ALWAYS uses specific filtered data 'data' */}
<ExpandableCard title="Monthly Sales Seasonality (Selected Items)" className="h-96">
<div className="flex flex-col h-full">
<div className="flex justify-end mb-2">
<div className="bg-slate-900 p-1 rounded-lg border border-slate-800 flex text-xs">
<button
onClick={(e) => { e.stopPropagation(); setSeasonalityMetric('sellOut'); }}
className={`px-3 py-1 rounded-md transition-colors ${seasonalityMetric === 'sellOut' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'}`}
>
Sell Out ()
</button>
<button
onClick={(e) => { e.stopPropagation(); setSeasonalityMetric('units'); }}
className={`px-3 py-1 rounded-md transition-colors ${seasonalityMetric === 'units' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'}`}
>
Units
</button>
</div>
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={seasonalityMetric === 'sellOut' ? data.seasonality : data.seasonalityUnits}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="name" stroke="#64748b" />
<YAxis
stroke="#64748b"
tickFormatter={(val) => seasonalityMetric === 'sellOut' ? `${(val/1000).toFixed(0)}k` : val.toLocaleString()}
/>
<Tooltip content={(props: any) => <SeasonalityTooltip {...props} metric={seasonalityMetric} />} />
<Legend />
{data.availableYears.map((year, index) => (
<Line
key={year}
type="monotone"
dataKey={year}
name={year}
stroke={COLORS[index % COLORS.length]}
strokeWidth={3}
dot={{ r: 4, strokeWidth: 2 }}
activeDot={{ r: 6 }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
</ExpandableCard>
{/* Country Chart - Uses Specific Data 'data' usually, unless we want to broaden it. Kept specific for now. */}
<ExpandableCard title="Revenue Distribution by Customer" className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.byCustomerSplit}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
<XAxis dataKey="name" stroke="#64748b" />
<YAxis stroke="#64748b" tickFormatter={(val) => `${(val/1000).toFixed(0)}k`}/>
<Tooltip content={(props: any) => <ComparisonTooltip {...props} metric="sellOut" />} cursor={{fill: '#1e293b'}} />
{data.availableYears.map((year, index) => (
<Bar
key={year}
dataKey={year}
name={year}
fill={COLORS[index % COLORS.length]}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</ExpandableCard>
</div>
</div>
</div>
);
};
export default Dashboard;
+981
View File
@@ -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 (
<div className="bg-slate-900 border border-border p-3 rounded shadow-xl text-sm max-w-xs z-50">
<p className="font-bold text-slate-100 mb-2 border-b border-slate-700 pb-1">{label}</p>
{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 = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
}
return (
<div key={p.name} className="flex justify-between items-center gap-2 mb-1">
<span style={{ color: p.color }}>{p.name}:</span>
<div className="flex items-center">
<span className="font-mono font-semibold text-slate-200">
{p.dataKey === 'sellOut'
? `${Number(p.value).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}`
: `${Number(p.value).toLocaleString()} u`}
</span>
{wowEl}
</div>
</div>
);
})}
</div>
);
}
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 (
<div className="bg-slate-900/90 backdrop-blur-sm border border-border p-3 rounded-lg shadow-xl text-sm w-56 z-50">
<p className="font-bold text-slate-100 mb-2 border-b border-slate-700 pb-1">{label}</p>
{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 = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
let unitsGrowthEl = null;
if (prevYearData && prevYearData.units != null && prevYearData.units !== 0 && yearData.units != null) {
const pct = ((yearData.units - prevYearData.units) / prevYearData.units) * 100;
unitsGrowthEl = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
return (
<div key={year} className="mt-2">
<p className="font-bold text-slate-200 text-base">{year}</p>
{yearData.sellOut != null && (
<div className="flex justify-between items-center gap-2 pl-1 mt-1">
<span className="font-medium" style={{ color: yearData.color }}>Sell Out:</span>
<div className="flex items-center">
<span className="font-mono font-semibold" style={{ color: yearData.color }}>
{Number(yearData.sellOut).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}
</span>
{sellOutGrowthEl}
</div>
</div>
)}
{yearData.units != null && (
<div className="flex justify-between items-center gap-2 pl-1 mt-1">
<span className="font-medium" style={{ color: yearData.color }}>Units:</span>
<div className="flex items-center">
<span className="font-mono font-semibold" style={{ color: yearData.color }}>
{Number(yearData.units).toLocaleString()} u
</span>
{unitsGrowthEl}
</div>
</div>
)}
</div>
);
})}
</div>
);
}
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 (
<div className="fixed inset-0 z-[80] bg-slate-950 px-6 pb-6 pt-16 flex flex-col animate-fade-in overflow-hidden">
<div className="flex justify-between items-center mb-4 border-b border-slate-800 pb-4 shrink-0">
<h3 className="text-xl font-bold text-slate-100 uppercase tracking-wide">{title}</h3>
<button
onClick={toggleExpand}
className="p-2 bg-red-600/90 hover:bg-red-500 border border-red-400 rounded-full text-white transition-all shadow-2xl hover:scale-110 flex items-center gap-2 group"
title="Exit Fullscreen"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-5 h-5"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="flex-1 overflow-auto bg-slate-900 rounded-xl p-6 border border-border custom-scrollbar">
{children}
</div>
</div>
);
}
return (
<div className={`bg-surface border-b border-border group relative transition-all duration-300 ${className}`}>
<div className="p-4 flex justify-between items-start">
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wide">{title}</h3>
<button
onClick={toggleExpand}
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-primary transition-opacity"
title="Expand to Fullscreen"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5"><path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" /></svg>
</button>
</div>
<div className="cursor-pointer px-4 pb-4" onClick={toggleExpand}>
{children}
</div>
</div>
);
};
const DataGrid: React.FC<DataGridProps> = ({ data }) => {
const [currentPage, setCurrentPage] = useState(1);
const [sortConfig, setSortConfig] = useState<SortConfig>({ 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<string[]>(['line', 'customer', 'sku', 'title']);
// State for Advanced Filtering
const [showFilterBuilder, setShowFilterBuilder] = useState(false);
const [rowFilters, setRowFilters] = useState<ConditionalFilter[]>([]);
// Temp state for new filter inputs
const [newFilterMetric, setNewFilterMetric] = useState<string>('');
const [newFilterOperator, setNewFilterOperator] = useState<'gt' | 'lt'>('gt');
const [newFilterValue, setNewFilterValue] = useState<string>('');
// 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<string, YearlyData> = {};
const accMonthsByYear: Record<number, Record<string, YearlyData>> = {};
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 (
<span className={`${textSize} font-bold ml-1.5 ${isPositive ? 'text-emerald-400' : 'text-rose-400'} bg-slate-800 border border-slate-700 px-1 rounded inline-block`}>
{isPositive ? '↑' : '↓'}{Math.abs(pct).toFixed(0)}%
</span>
);
};
if (data.length === 0) return null;
return (
<div className="max-w-[100vw] mx-auto px-4 pb-24 h-screen flex flex-col">
<div className="bg-surface border border-border rounded-xl shadow-lg flex flex-col flex-1">
{/* Header Bar */}
<div className="p-4 border-b border-border bg-slate-900 flex flex-col gap-4 shrink-0 rounded-t-xl z-50">
<div className="flex flex-wrap gap-4 justify-between items-center">
<div>
<h3 className="text-lg font-bold text-slate-100">Dynamic Pivot Table</h3>
<p className="text-xs text-slate-500">
Comparing {years.join(', ')} {processedRows.length} Rows
{rowFilters.length > 0 && <span className="text-indigo-400 ml-1">(Filtered)</span>}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-400 font-medium">Group By:</span>
<MultiSelectDropdown
label="Variables"
selected={selectedDimensions}
options={DIMENSION_OPTIONS.map(d => d.value)}
onChange={setSelectedDimensions}
className="w-64"
/>
</div>
<button
onClick={() => setShowFilterBuilder(!showFilterBuilder)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm border
${showFilterBuilder || rowFilters.length > 0 ? 'bg-indigo-900/50 border-indigo-500 text-indigo-300' : 'bg-slate-800 border-border text-slate-300 hover:text-white hover:bg-slate-700'}`}
>
<FunnelIcon />
{rowFilters.length > 0 ? `${rowFilters.length} Active` : 'Filter Rows'}
</button>
<button
onClick={() => setShowChart(!showChart)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm border
${showChart ? 'bg-indigo-900/50 border-indigo-500 text-indigo-300' : 'bg-slate-800 border-border text-slate-300 hover:text-white hover:bg-slate-700'}`}
>
<ChartIcon />
{showChart ? 'Hide Trend' : 'Show Trend'}
</button>
<button
onClick={handleExport}
className="flex items-center gap-2 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
>
<DownloadIcon /> Export CSV
</button>
</div>
</div>
{/* Advanced Filter Builder Panel */}
{(showFilterBuilder || rowFilters.length > 0) && (
<div className="bg-slate-950/50 p-4 rounded-lg border border-border space-y-3 animate-fade-in">
{/* Active Filters List */}
{rowFilters.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{rowFilters.map(filter => {
const metricLabel = metricOptions.find(o => o.value === filter.metric)?.label || filter.metric;
const opLabel = filter.operator === 'gt' ? '>' : '<';
return (
<div key={filter.id} className="flex items-center gap-2 bg-indigo-500/10 border border-indigo-500/30 text-indigo-300 px-3 py-1 rounded-full text-xs font-medium">
<span>{metricLabel} {opLabel} {filter.value}</span>
<button onClick={() => removeFilter(filter.id)} className="hover:text-white"><CloseIcon /></button>
</div>
);
})}
</div>
)}
{/* Filter Creator Inputs */}
{showFilterBuilder && (
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-slate-500 font-semibold uppercase">Metric</label>
<select
className="bg-slate-900 border border-slate-700 text-slate-200 text-sm rounded-md px-3 py-2 focus:ring-1 focus:ring-primary outline-none min-w-[220px]"
value={newFilterMetric}
onChange={(e) => setNewFilterMetric(e.target.value)}
>
<option value="">Select Metric...</option>
{metricOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-slate-500 font-semibold uppercase">Operator</label>
<select
className="bg-slate-900 border border-slate-700 text-slate-200 text-sm rounded-md px-3 py-2 focus:ring-1 focus:ring-primary outline-none"
value={newFilterOperator}
onChange={(e) => setNewFilterOperator(e.target.value as 'gt' | 'lt')}
>
<option value="gt">Greater Than ({'>'})</option>
<option value="lt">Less Than ({'<'})</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-slate-500 font-semibold uppercase">Value</label>
<input
type="number"
placeholder="0"
className="w-full bg-slate-900 border border-slate-700 text-slate-200 text-sm rounded-md px-3 py-2 focus:ring-1 focus:ring-primary outline-none w-32"
value={newFilterValue}
onChange={(e) => setNewFilterValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addFilter()}
/>
</div>
<button
onClick={addFilter}
disabled={!newFilterMetric || !newFilterValue}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:hover:bg-indigo-600 text-white rounded-md text-sm font-medium transition-colors"
>
Apply
</button>
</div>
)}
</div>
)}
</div>
{/* Trend Chart */}
{showChart && chartData.length > 1 && (
<ExpandableChartCard title={chartTitle} className="mb-6">
<div className="flex flex-col h-full min-h-[350px]">
<div className="flex justify-end mb-2 shrink-0">
<div className="bg-slate-900 p-1 rounded-lg border border-slate-800 flex text-xs font-medium">
<label className="flex items-center gap-2 px-3 py-1 rounded-md transition-colors cursor-pointer has-[:checked]:bg-indigo-600 has-[:checked]:text-white has-[:checked]:shadow-sm text-slate-400 hover:text-white">
<input type="checkbox" checked={visibleMetrics.includes('sellOut')} onChange={() => toggleMetric('sellOut')} className="hidden" />
Sell Out ()
</label>
<label className="flex items-center gap-2 px-3 py-1 rounded-md transition-colors cursor-pointer has-[:checked]:bg-teal-600 has-[:checked]:text-white has-[:checked]:shadow-sm text-slate-400 hover:text-white">
<input type="checkbox" checked={visibleMetrics.includes('units')} onChange={() => toggleMetric('units')} className="hidden" />
Units
</label>
</div>
</div>
<div className="flex-1 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="name" stroke="#64748b" tick={{ fontSize: 12 }} />
{visibleMetrics.includes('sellOut') && (
<YAxis yAxisId="left" stroke={CHART_COLORS[0]} tickFormatter={(val) => `${(val / 1000).toFixed(0)}k`} />
)}
{visibleMetrics.includes('units') && (
<YAxis yAxisId="right" orientation="right" stroke={isComparisonView ? CHART_COLORS[1] : CHART_COLORS[2]} tickFormatter={(val) => `${(val / 1000).toFixed(0)}k`} />
)}
<Tooltip content={isComparisonView ? <ComparisonTooltip /> : <WoWTooltip data={chartData} />} />
<Legend />
{isComparisonView ? (
uniqueYears.map((year, index) => (
<React.Fragment key={year}>
{visibleMetrics.includes('sellOut') && (
<Line yAxisId="left" type="monotone" dataKey={`${year}_sellOut`} name={`SO ${year}`} stroke={CHART_COLORS[index % CHART_COLORS.length]} strokeWidth={2} dot={false} activeDot={{ r: 6 }} />
)}
{visibleMetrics.includes('units') && (
<Line yAxisId="right" type="monotone" dataKey={`${year}_units`} name={`Units ${year}`} stroke={CHART_COLORS[index % CHART_COLORS.length]} strokeWidth={2} strokeDasharray="5 5" dot={false} activeDot={{ r: 6 }} />
)}
</React.Fragment>
))
) : (
<>
{visibleMetrics.includes('sellOut') && (
<Line yAxisId="left" type="monotone" dataKey="sellOut" name="Sell Out" stroke={CHART_COLORS[0]} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 6 }} />
)}
{visibleMetrics.includes('units') && (
<Line yAxisId="right" type="monotone" dataKey="units" name="Units" stroke={CHART_COLORS[2]} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 6 }} />
)}
</>
)}
</LineChart>
</ResponsiveContainer>
</div>
</div>
</ExpandableChartCard>
)}
{(!showChart || chartData.length <=1) && !isComparisonView && (
<div className="p-4 border-b border-border text-center text-sm text-slate-500 italic bg-slate-900/30 mb-6">
{isComparisonView
? "Not enough weekly data to compare these years."
: "Not enough weekly data points to render a trend chart for the current selection."
}
</div>
)}
{/* Table Container */}
<div className="overflow-auto flex-1 relative custom-scrollbar bg-slate-950">
<table className="w-max text-left text-sm text-slate-300 border-collapse">
<thead className="bg-slate-950 text-sm uppercase font-semibold text-slate-400 z-30 shadow-md">
<tr className="border-b border-slate-800">
{/* Dynamic Dimension Headers - STICKY TOP */}
{effectiveDimensions.map((dim, index) => {
const label = getLabel(dim);
const isFirst = index === 0;
const isTitle = dim === 'title';
return (
<th
key={dim}
className={`p-3 border-r border-slate-800 cursor-pointer hover:text-white sticky top-0 bg-slate-950
${isTitle ? 'min-w-[300px]' : 'min-w-[150px]'}
${isFirst ? 'left-0 z-40 bg-slate-900' : 'z-30'}`}
onClick={() => requestSort(dim)}
>
{label} {sortConfig.key === dim && (sortConfig.direction === 'asc' ? '▲' : '▼')}
</th>
);
})}
{/* Dynamic Total Columns for each Year - STICKY TOP */}
{years.map(year => (
<th
key={`total_${year}`}
className="p-3 w-40 border-r border-indigo-500 text-right cursor-pointer text-white bg-indigo-700 hover:bg-indigo-600 transition-colors shadow-md sticky top-0 z-30"
onClick={() => requestSort(`total_${year}`)}
>
Total {year} {sortConfig.key === `total_${year}` && (sortConfig.direction === 'asc' ? '▲' : '▼')}
</th>
))}
{/* Monthly Headers - STICKY TOP */}
{MONTH_NAMES.map(m => (
<th key={m} className="p-2 min-w-[150px] text-center border-r border-slate-800 bg-slate-900 sticky top-0 z-30">
{m}
</th>
))}
</tr>
{/* Grand Total Row (Sticky Top BELOW Headers) */}
<tr className="border-b-2 border-indigo-400 bg-slate-950 text-white font-bold shadow-lg z-40 sticky top-[48px]">
{/*
Anchor TOTAL label to the first column (sticky left).
This ensures "TOTAL" stays visible on the left even when scrolling horizontally.
*/}
<td
className="p-3 border-r border-slate-800 text-left sticky left-0 z-50 bg-slate-950 whitespace-nowrap"
>
TOTAL ({processedRows.length} Rows)
</td>
{/* Spacer for remaining dimensions if any */}
{effectiveDimensions.length > 1 && (
<td
colSpan={effectiveDimensions.length - 1}
className="p-3 border-r border-slate-800 bg-slate-950"
/>
)}
{/* 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 (
<td key={`grand_${year}`} className="p-3 border-r border-indigo-500 text-right bg-indigo-800">
<div className="flex justify-end items-center mb-1">
<span className="text-base text-white">{currentData.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
{sellOutGrowth}
</div>
<div className="flex justify-end items-center">
<span className="text-sm text-violet-300 font-normal">{currentData.units.toLocaleString()} u</span>
{unitsGrowth}
</div>
</td>
);
})}
{/* Monthly Grand Totals */}
{MONTH_NAMES.map((_, idx) => (
<td key={`grand_m_${idx}`} className="p-2 border-r border-slate-800 text-right min-w-[150px] bg-slate-900">
{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 (
<div key={year} className="flex justify-between items-center text-xs mb-1 border-b border-white/5 last:border-0 pb-1 last:pb-0">
<div className="flex items-center text-slate-400 mr-2 min-w-[32px]">
<span>{year}</span>
</div>
<div className="text-right flex-1">
<div className="flex items-center justify-end">
<span>{data.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
{sellOutGrowth}
</div>
<div className="flex items-center justify-end text-violet-400 font-mono mt-0.5">
<span>{data.units.toLocaleString()}u</span>
{unitsGrowth}
</div>
</div>
</div>
);
})}
</td>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{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 (
<tr key={row.id} className={`${rowClass} hover:bg-slate-700 transition-colors group`}>
{/* 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 = (
<div className="overflow-x-auto whitespace-nowrap custom-scrollbar pb-1">
{val}
</div>
);
} else {
let displayVal = val;
if (typeof val === 'string' && val.length > 30) {
displayVal = val.substring(0, 30) + '...';
}
cellContent = displayVal;
}
return (
<td
key={dim}
className={`p-3 border-r border-slate-800 text-sm ${textColor}
${isTitle ? 'min-w-[300px] max-w-[300px]' : 'truncate max-w-[220px]'}
${isFirst ? `sticky left-0 z-20 ${rowClass} group-hover:bg-slate-700` : ''}
`}
title={val}
>
{cellContent}
</td>
);
})}
{/* 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 (
<td key={`row_tot_${year}`} className="p-3 border-r border-indigo-500/40 text-right font-bold text-white bg-indigo-900/60">
<div className="flex justify-end items-center mb-1">
<span className="text-base">{yData.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
{sellOutGrowth}
</div>
<div className="flex justify-end items-center text-sm text-violet-200 font-normal">
<span>{yData.units.toLocaleString()} u</span>
{unitsGrowth}
</div>
</td>
);
})}
{/* Monthly Data Columns (Listing all years) */}
{row.months.map((m, idx) => (
<td key={idx} className="p-2 border-r border-slate-800 text-right min-w-[150px]">
{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 (
<div key={year} className="mb-2 last:mb-0 border-b border-slate-800 last:border-0 pb-1 last:pb-0">
<div className="flex justify-between items-center text-xs text-slate-500 mb-0.5">
<span>{year}</span>
{sellOutGrowthEl}
</div>
<div className="flex justify-end items-center text-sm font-medium text-slate-300">
{sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</div>
<div className="flex justify-between items-center mt-0.5">
<div className="flex-1"></div>
<div className="flex items-center gap-1">
<span className="text-xs text-violet-400 font-mono">{units} u</span>
{unitsGrowthEl}
</div>
</div>
</div>
);
})}
</td>
))}
</tr>
)})}
</tbody>
</table>
</div>
{/* Footer */}
<div className="bg-slate-900 border-t border-border p-3 flex justify-between items-center text-sm shrink-0 rounded-b-xl z-40">
<div className="text-slate-500">
Showing {((currentPage - 1) * ROWS_PER_PAGE) + 1} - {Math.min(currentPage * ROWS_PER_PAGE, processedRows.length)} of {processedRows.length} Rows
</div>
<div className="flex gap-2">
<button
onClick={handlePrev}
disabled={currentPage === 1}
className="px-3 py-1 rounded bg-slate-800 border border-border hover:bg-slate-700 disabled:opacity-50 transition-colors"
>
Previous
</button>
<span className="flex items-center px-2 text-slate-300">
Page {currentPage} of {totalPages}
</span>
<button
onClick={handleNext}
disabled={currentPage === totalPages}
className="px-3 py-1 rounded bg-slate-800 border border-border hover:bg-slate-700 disabled:opacity-50 transition-colors"
>
Next
</button>
</div>
</div>
</div>
</div>
);
};
export default DataGrid;
+141
View File
@@ -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<FileUploadProps> = ({
onFileUpload,
onUrlSubmit,
isLoading,
activeUrl,
onDisconnect,
lastUpdated
}) => {
const [url, setUrl] = useState('');
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
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 (
<div className="flex flex-col gap-6 p-2">
{/* Active Connection Status */}
{activeUrl && (
<div className="bg-emerald-900/20 border border-emerald-500/30 rounded-xl p-6 text-center animate-fade-in">
<div className="flex items-center justify-center gap-2 mb-2 text-emerald-400">
<div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></div>
<span className="font-bold text-sm uppercase tracking-wide">Cloud Sync Active</span>
</div>
<p className="text-slate-300 text-sm mb-1 truncate max-w-sm mx-auto opacity-80">{activeUrl}</p>
{lastUpdated && <p className="text-xs text-slate-500 mb-4">Last updated: {new Date(lastUpdated).toLocaleString()}</p>}
<div className="flex gap-3 justify-center">
<button
onClick={handleSyncNow}
disabled={isLoading}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2 disabled:opacity-50"
>
{isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
Syncing...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" /></svg>
Sync Now
</>
)}
</button>
<button
onClick={onDisconnect}
className="px-4 py-2 bg-slate-800 hover:bg-red-900/30 text-slate-300 hover:text-red-400 border border-slate-700 hover:border-red-800 rounded-lg text-sm font-medium transition-colors"
>
Disconnect
</button>
</div>
</div>
)}
{/* Manual File Upload */}
<div className="rounded-xl border border-dashed border-slate-700 bg-slate-900/50 hover:bg-slate-900 transition-colors group">
<label htmlFor="file-upload" className="cursor-pointer flex flex-col items-center gap-3 p-6">
<div className="p-3 bg-indigo-500/10 rounded-full text-indigo-400 group-hover:scale-110 transition-transform">
<UploadIcon />
</div>
<div className="text-center">
<h3 className="font-semibold text-slate-200">Upload Data File</h3>
<p className="text-xs text-slate-500 mt-1">Supports .csv, .xlsx, .xls</p>
</div>
<input
id="file-upload"
type="file"
accept=".csv, .xlsx, .xls"
onChange={handleChange}
disabled={isLoading}
className="hidden"
/>
</label>
</div>
<div className="flex items-center gap-4">
<div className="h-px bg-slate-800 flex-1"></div>
<span className="text-slate-600 text-xs font-bold uppercase">OR</span>
<div className="h-px bg-slate-800 flex-1"></div>
</div>
{/* URL Connection */}
<div className="bg-slate-900 border border-slate-800 rounded-xl p-5">
<h3 className="text-sm font-bold text-slate-200 mb-1">{activeUrl ? 'Change Source URL' : 'Connect Cloud CSV'}</h3>
<p className="text-xs text-slate-500 mb-3">Direct link to CSV (e.g. Dropbox dl=1). Auto-refreshes daily at 7 AM.</p>
<form onSubmit={handleUrlSubmit} className="flex gap-2">
<input
type="url"
placeholder="https://..."
value={url}
onChange={(e) => 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
/>
<button
type="submit"
disabled={isLoading || !url.trim()}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg text-sm transition-colors disabled:opacity-50 whitespace-nowrap"
>
Connect
</button>
</form>
</div>
{isLoading && !activeUrl && (
<div className="flex items-center justify-center gap-2 text-indigo-400 py-2">
<div className="w-4 h-4 border-2 border-indigo-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-sm font-medium">Processing data...</span>
</div>
)}
</div>
);
};
export default FileUpload;
+78
View File
@@ -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<FilterBarProps> = ({ filters, onFilterChange, options }) => {
return (
<div className="bg-slate-950 border-b border-border sticky top-0 z-[60] p-4 shadow-xl">
<div className="max-w-7xl mx-auto flex flex-wrap gap-4 items-end">
<MultiSelectDropdown
label="Customer"
selected={filters.customer}
options={options.customer}
onChange={(v) => onFilterChange('customer', v)}
className="flex-1"
/>
<MultiSelectDropdown
label="Year"
selected={filters.year}
options={options.year}
onChange={(v) => onFilterChange('year', v)}
className="flex-1"
/>
<MultiSelectDropdown
label="Month"
selected={filters.month}
options={options.month}
onChange={(v) => onFilterChange('month', v)}
className="flex-1"
/>
<MultiSelectDropdown
label="Product Line"
selected={filters.line}
options={options.line}
onChange={(v) => onFilterChange('line', v)}
className="flex-1"
/>
<MultiSelectDropdown
label="SKU"
selected={filters.sku}
options={options.sku}
onChange={(v) => onFilterChange('sku', v)}
className="flex-1"
/>
<MultiSelectDropdown
label="Title"
selected={filters.title}
options={options.title}
onChange={(v) => onFilterChange('title', v)}
className="flex-1"
/>
<MultiSelectDropdown
label="ASIN"
selected={filters.asin}
options={options.asin}
onChange={(v) => onFilterChange('asin', v)}
className="flex-1"
/>
</div>
</div>
);
};
export default FilterBar;
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
export const UploadIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-8 h-8">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
);
export const ChatIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12h1.5m1.5 0v-1.5m3 0h3m0 3h.008v.008h-.008v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" />
</svg>
);
export const CloseIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
);
export const SendIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12 3.269 3.126A59.768 59.768 0 0 1 21.485 12 59.77 59.77 0 0 1 3.27 20.876L5.999 12Zm0 0h7.5" />
</svg>
);
export const ChartIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 0 0 6 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0 1 18 16.5h-2.25m-7.5 0h7.5m-7.5 0-1 3m8.5-3 1 3m0 0 .5 1.5m-.5-1.5h-9.5m0 0-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" />
</svg>
);
export const TableIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3h-7.5c-.621 0-1.125-.504-1.125-1.125m16.875-12.75a1.125 1.125 0 0 0-1.125-1.125H3.375a1.125 1.125 0 0 0-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25v1.5c0 .621.504 1.125 1.125 1.125m17.25-2.625v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0h17.25" />
</svg>
);
export const SearchIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
);
export const DownloadIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
);
export const FunnelIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
</svg>
);
export const MoversIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18 9 11.25l4.306 4.305a11.164 11.164 0 0 0 5.814-5.815L21.75 6m0 0-3.5-3.5m3.5 3.5-3.5 3.5" />
</svg>
);
+198
View File
@@ -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<ItemGrowthTableProps> = ({ title, data, type, periods }) => {
const [sortConfig, setSortConfig] = useState<SortConfig>({ 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 (
<svg className="w-2.5 h-2.5 ml-1 text-slate-600 opacity-0 group-hover:opacity-50" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
);
}
return (
<svg className="w-2.5 h-2.5 ml-1 text-primary" fill="currentColor" viewBox="0 0 20 20">
{sortConfig.direction === 'asc'
? <path fillRule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clipRule="evenodd" />
: <path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
}
</svg>
);
};
return (
<div className="overflow-auto h-full relative">
<table className="w-full text-left text-sm h-full border-separate border-spacing-0">
<thead className="bg-slate-950 text-xs uppercase font-semibold text-slate-500 sticky top-0 z-10 shadow-sm">
<tr>
<th
className="px-4 py-3 bg-slate-950 min-w-[100px] cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('sku')}
>
<div className="flex items-center">SKU {getSortIndicator('sku')}</div>
</th>
<th
className="px-4 py-3 bg-slate-950 min-w-[100px] cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('asin')}
>
<div className="flex items-center">ASIN {getSortIndicator('asin')}</div>
</th>
<th
className="px-4 py-3 bg-slate-950 min-w-[200px] cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('title')}
>
<div className="flex items-center">Product Title {getSortIndicator('title')}</div>
</th>
<th
className="px-4 py-3 bg-slate-950 min-w-[120px] cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('line')}
>
<div className="flex items-center">Product Line {getSortIndicator('line')}</div>
</th>
{/* Sell Out Columns */}
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-400 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('previousYearSellOut')}
>
<div className="flex items-center justify-end">Sell Out {periods.previous} {getSortIndicator('previousYearSellOut')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-200 cursor-pointer hover:text-white group select-none transition-colors"
onClick={() => requestSort('currentYearSellOut')}
>
<div className="flex items-center justify-end">Sell Out {periods.current} {getSortIndicator('currentYearSellOut')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('sellOutGrowthValue')}
>
<div className="flex items-center justify-end">SO Diff {getSortIndicator('sellOutGrowthValue')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('sellOutGrowthPercentage')}
>
<div className="flex items-center justify-end">SO Growth % {getSortIndicator('sellOutGrowthPercentage')}</div>
</th>
{/* Units Columns */}
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-400 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('previousYearUnits')}
>
<div className="flex items-center justify-end">Units {periods.previous} {getSortIndicator('previousYearUnits')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-200 cursor-pointer hover:text-white group select-none transition-colors"
onClick={() => requestSort('currentYearUnits')}
>
<div className="flex items-center justify-end">Units {periods.current} {getSortIndicator('currentYearUnits')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('unitsGrowthValue')}
>
<div className="flex items-center justify-end">Units Diff {getSortIndicator('unitsGrowthValue')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('unitsGrowthPercentage')}
>
<div className="flex items-center justify-end">Units Growth % {getSortIndicator('unitsGrowthPercentage')}</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border text-slate-300">
{sortedData.length > 0 ? (
sortedData.map((item, idx) => (
<tr key={idx} className="hover:bg-slate-800/50">
<td className="px-4 py-2 font-medium">{item.sku || '-'}</td>
<td className="px-4 py-2">{item.asin || '-'}</td>
<td className="px-4 py-2">{item.title || '-'}</td>
<td className="px-4 py-2">{item.line || '-'}</td>
{/* Sell Out Columns */}
<td className="px-4 py-2 text-right text-slate-400">{item.previousYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}</td>
<td className="px-4 py-2 text-right font-medium text-slate-200">{item.currentYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}</td>
<td className={`px-4 py-2 text-right font-medium ${item.sellOutGrowthValue >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{item.sellOutGrowthValue > 0 ? '+' : ''}{item.sellOutGrowthValue.toLocaleString(undefined, {maximumFractionDigits: 0})}
</td>
<td className="px-4 py-2 text-right">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${item.sellOutGrowthPercentage >= 0 ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>
{item.sellOutGrowthPercentage.toFixed(1)}%
</span>
</td>
{/* Units Columns */}
<td className="px-4 py-2 text-right text-slate-400">{item.previousYearUnits.toLocaleString()}</td>
<td className="px-4 py-2 text-right font-medium text-slate-200">{item.currentYearUnits.toLocaleString()}</td>
<td className={`px-4 py-2 text-right font-medium ${item.unitsGrowthValue >= 0 ? 'text-violet-400' : 'text-orange-400'}`}>
{item.unitsGrowthValue > 0 ? '+' : ''}{item.unitsGrowthValue.toLocaleString()}
</td>
<td className="px-4 py-2 text-right">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${item.unitsGrowthPercentage >= 0 ? 'bg-violet-500/10 text-violet-400' : 'bg-orange-500/10 text-orange-400'}`}>
{item.unitsGrowthPercentage.toFixed(1)}%
</span>
</td>
</tr>
))
) : (
<tr>
<td colSpan={13} className="px-4 py-6 text-center text-slate-500 italic">
Insufficient data to calculate {type}. (Select a customer and at least 2 distinct years/periods)
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
export default ItemGrowthTable;
+151
View File
@@ -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<MultiSelectDropdownProps> = ({ label, selected, options, onChange, className }) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div className={`flex flex-col min-w-[150px] relative ${className}`} ref={dropdownRef}>
<label className="text-xs font-semibold text-slate-400 mb-1 uppercase tracking-wider">{label}</label>
<button
onClick={() => setIsOpen(!isOpen)}
className={`w-full text-left bg-surface border border-border hover:border-slate-600 text-sm rounded-lg py-2 px-3 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors flex justify-between items-center
${selected.length > 0 ? 'text-white font-medium border-primary/50' : 'text-slate-400'}`}
>
<span className="truncate">
{selected.length === 0
? (label === 'Customer' ? 'All Customers' : `All ${label}s`)
: `${selected.length} selected`}
</span>
<svg className={`fill-current h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" />
</svg>
</button>
{isOpen && (
<div className="absolute top-[calc(100%+4px)] left-0 w-64 max-h-96 overflow-hidden bg-slate-900 border border-slate-700 rounded-xl shadow-2xl z-[100] animate-fade-in flex flex-col">
{/* Search Bar */}
<div className="p-2 border-b border-slate-800 sticky top-0 bg-slate-900 z-10">
<div className="relative">
<span className="absolute left-2.5 top-2.5 text-slate-500">
<SearchIcon />
</span>
<input
ref={inputRef}
type="text"
placeholder={`Search ${label}...`}
className="w-full bg-slate-950 border border-slate-700 text-slate-200 text-sm rounded-md py-1.5 pl-9 pr-2 focus:outline-none focus:border-primary"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="flex justify-between py-2 px-2 border-b border-slate-800 bg-slate-900/50">
<button onClick={handleSelectAll} className="text-xs text-primary hover:text-indigo-400 font-medium px-2">
{searchTerm
? (filteredOptions.every(opt => selected.includes(opt)) ? 'Unselect Results' : 'Select Results')
: (selected.length === options.length ? 'Unselect All' : 'Select All')
}
</button>
<button onClick={handleClear} className="text-xs text-slate-400 hover:text-white px-2">
Clear
</button>
</div>
<div className="space-y-1 overflow-y-auto custom-scrollbar p-2 max-h-60">
{filteredOptions.map((opt) => (
<label key={opt} className="flex items-center space-x-3 p-2 rounded hover:bg-slate-800 cursor-pointer group">
<input
type="checkbox"
checked={selected.includes(opt)}
onChange={() => toggleOption(opt)}
className="form-checkbox h-4 w-4 text-primary rounded border-slate-600 bg-slate-800 focus:ring-primary focus:ring-offset-slate-900 transition duration-150 ease-in-out"
/>
<span className={`text-sm break-all group-hover:text-white ${selected.includes(opt) ? 'text-white' : 'text-slate-400'}`}>
{opt}
</span>
</label>
))}
{filteredOptions.length === 0 && <div className="p-4 text-center text-xs text-slate-500">No matches found</div>}
</div>
</div>
)}
</div>
);
};
export default MultiSelectDropdown;
+92
View File
@@ -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<TopMoversPageProps> = ({ filteredData }) => {
// Local state for the specific comparison year, initially null for auto-selection
const [selectedComparisonYear, setSelectedComparisonYear] = useState<string | null>(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 (
<div className="p-6 space-y-6 max-w-7xl mx-auto animate-fade-in pb-24">
<div className="flex flex-wrap justify-between items-center gap-4 mb-6">
<h2 className="text-2xl font-bold text-white">Top Item Movers</h2>
<div className="bg-slate-900 border border-border rounded-xl px-4 py-2 flex items-center gap-3">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap">Comparison Year</label>
<select
value={selectedComparisonYear || ''}
onChange={(e) => setSelectedComparisonYear(e.target.value || null)}
className="bg-surface border border-border hover:border-slate-600 text-sm rounded-lg py-1.5 px-3 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors text-white"
>
<option value="">Auto (Latest 2 Years)</option>
{availableYearsForComparisonDropdown.map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
</div>
</div>
{/* Top 20 Gainers Table */}
<ExpandableCard
title={`Top 20 Gainers (${comparisonPeriods.current} vs ${comparisonPeriods.previous})`}
className="h-[500px]"
onExport={handleExportGainers} // Pass export handler
exportFileName={`Top_20_Gainers_${comparisonPeriods.current}_vs_${comparisonPeriods.previous}`}
>
<ItemGrowthTable
title={`Top 20 Gainers (${comparisonPeriods.current} vs ${comparisonPeriods.previous})`}
data={topMovers}
type="growth"
periods={comparisonPeriods}
/>
</ExpandableCard>
{/* Top 20 Losers Table */}
<ExpandableCard
title={`Top 20 Losers (${comparisonPeriods.current} vs ${comparisonPeriods.previous})`}
className="h-[500px]"
onExport={handleExportLosers} // Pass export handler
exportFileName={`Top_20_Losers_${comparisonPeriods.current}_vs_${comparisonPeriods.previous}`}
>
<ItemGrowthTable
title={`Top 20 Losers (${comparisonPeriods.current} vs ${comparisonPeriods.previous})`}
data={bottomMovers}
type="decline"
periods={comparisonPeriods}
/>
</ExpandableCard>
</div>
);
};
export default TopMoversPage;
+59
View File
@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SAS Analytics Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
background: '#020617', // slate-950
surface: '#0f172a', // slate-900
border: '#1e293b', // slate-800
primary: '#6366f1', // indigo-500
secondary: '#64748b', // slate-500
}
}
}
}
</script>
<!-- PapaParse for CSV parsing -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script>
<style>
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #020617;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
</style>
<script type="importmap">
{
"imports": {
"react": "https://aistudiocdn.com/react@^19.2.0",
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0",
"recharts": "https://aistudiocdn.com/recharts@^3.5.0",
"xlsx": "https://esm.sh/xlsx@0.18.5"
}
}
</script>
</head>
<body class="bg-background text-slate-200 antialiased overflow-y-auto">
<div id="root"></div>
</body>
</html>
+15
View File
@@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
);
+5
View File
@@ -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": []
}
+24
View File
@@ -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"
}
}
+817
View File
@@ -0,0 +1,817 @@
import { SalesRecord, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types';
import * as XLSX from 'xlsx';
// Helper to parse currency values handling both EU (1.234,56) and US/Standard (1,234.56 or 1234.56) formats
const parseCurrency = (value: string): number => {
if (!value) return 0;
// Remove currency symbol and whitespace
let clean = value.replace(/[€\s]/g, '').trim();
// HEURISTIC:
// If it contains a comma, we assume it's likely European format (Decimal separator)
// UNLESS it also contains a dot and the comma is before the dot (e.g. 1,000.50 - US format)
// But given the context (DE data), comma is usually decimal.
// Case A: European Format (e.g., "277.179,09" or "50,00")
if (clean.includes(',')) {
// If it has dots (thousands), remove them
clean = clean.replace(/\./g, '');
// Replace decimal comma with dot
clean = clean.replace(',', '.');
return parseFloat(clean);
}
// Case B: Standard/US Format or Clean Number (e.g. "277179.09" or "1000")
// Just remove any potential thousands separator commas (if any exist and we didn't catch them above)
// and parse.
clean = clean.replace(/,/g, '');
const num = parseFloat(clean);
return isNaN(num) ? 0 : num;
};
const parseUnits = (value: string): number => {
if(!value) return 0;
// Remove dots (thousands separators in EU) and commas (thousands in US) just to be safe for integers
const clean = value.replace(/[\.,]/g, '');
const num = parseInt(clean, 10);
return isNaN(num) ? 0 : num;
}
const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
// Robust Month Normalizer
const normalizeMonth = (rawMonth: string): string => {
if (!rawMonth) return '';
let m = rawMonth.trim();
// Handle numeric months "01", "1", "01-2023" (start with digits)
const numMatch = m.match(/^(\d{1,2})([^\d]|$)/);
if (numMatch) {
const num = parseInt(numMatch[1]);
if (num >= 1 && num <= 12) return MONTH_ORDER[num - 1];
}
// Handle text months "Apr-23", "Apr 23", "April"
// Extract first sequence of letters
const alphaMatch = m.match(/([a-zA-Z]+)/);
if (alphaMatch) {
m = alphaMatch[1];
}
// Take first 3 characters
if (m.length > 3) {
m = m.substring(0, 3);
}
// Capitalize first letter, lowercase rest
m = m.charAt(0).toUpperCase() + m.slice(1).toLowerCase();
return m;
};
// Robust CSV Column Value Extractor
// Handles case-insensitivity, trimming, multiple potential header aliases, AND ignores empty values to find fallbacks.
const getColumnValue = (row: any, aliases: string[]): string => {
const rowKeys = Object.keys(row);
// Create a map of normalized keys in the row to the actual keys
const normalizedRowKeys: Record<string, string> = {};
rowKeys.forEach(k => {
normalizedRowKeys[k.trim().toLowerCase()] = k;
});
for (const alias of aliases) {
const lookup = alias.trim().toLowerCase();
if (normalizedRowKeys[lookup]) {
const actualKey = normalizedRowKeys[lookup];
const val = row[actualKey];
if (val !== undefined && val !== null) {
const strVal = String(val).trim();
// CRITICAL FIX: Only return if the value is NOT empty.
// This allows falling back to the next alias if the first matching column exists but is empty.
if (strVal.length > 0) {
return strVal;
}
}
}
}
return '';
};
// Extracted Mapping Function
const mapRowToRecord = (row: any, index: number): SalesRecord => {
const customer = getColumnValue(row, ['NEW CUSTOMER', 'Customer', 'Client', 'Account', 'Partner', 'COUNTRY', 'Country', 'Market']) || 'Unknown';
const yearStr = getColumnValue(row, ['YEAR', 'Year', 'D']);
const year = parseInt(yearStr) || 0;
const monthStr = getColumnValue(row, ['MONTH', 'Month', 'Period']);
const month = normalizeMonth(monthStr);
const weekStr = getColumnValue(row, ['WEEK', 'Week', 'CW', 'Semana', 'KW', 'E']);
const weekNum = weekStr ? parseInt(weekStr.replace(/cw/i, '').trim(), 10) : NaN;
const week = isNaN(weekNum) ? undefined : weekNum;
const line = getColumnValue(row, ['LINE', 'Line', 'Product Line']) || 'Other';
// Updated ASIN priority list based on user feedback
const asin = getColumnValue(row, [
'CUSTOMER REFERENCE',
'AMAZON ASIN',
'ASIN',
'Asin',
'PRODUCT ID',
'ITEM IDENTIFIER',
'ASIN NO.',
'Product ASIN',
'IDENTIFIER'
]);
const sku = getColumnValue(row, ['RAW ARTICLE NO.', 'SKU', 'Sku', 'Item No']);
// Prioritize 'Title' column, fallback to 'Article Name' columns
const title = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Title', 'TITLE', 'Product Title', 'Article Name', 'ArticleName']);
// Legacy/Backup field
const articleName = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Article Name', 'ArticleName', 'Title']);
const unitsRaw = getColumnValue(row, ['UNITS', 'Units', 'Quantity', 'Qty']);
const sellOutRaw = getColumnValue(row, ['AMOUNT', 'Sell Out', 'SellOut', 'Revenue', 'Sales', 'Turnover']);
return {
id: `row-${index}`,
customer,
year,
month,
week,
asin,
sku,
title,
articleName,
units: parseUnits(unitsRaw),
sellOut: parseCurrency(sellOutRaw),
line
};
};
export const processCSV = (fileOrContent: File | string): Promise<SalesRecord[]> => {
return new Promise((resolve, reject) => {
// @ts-ignore - PapaParse is loaded globally via CDN
Papa.parse(fileOrContent, {
header: true,
// delimiter: ";", // Allow auto-detect
skipEmptyLines: true,
complete: (results: any) => {
try {
const data: SalesRecord[] = results.data.map((row: any, index: number) => {
return mapRowToRecord(row, index);
}).filter((r: SalesRecord) => r.year !== 2022 && r.line && r.line !== 'Other'); // Validation: Exclude 2022 and require line
resolve(data);
} catch (err) {
reject(err);
}
},
error: (error: any) => {
reject(error);
}
});
});
};
export const processExcel = async (file: File): Promise<SalesRecord[]> => {
try {
const arrayBuffer = await file.arrayBuffer();
const workbook = XLSX.read(arrayBuffer);
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
// Convert to JSON
// raw: false attempts to format the cell (e.g. dates), but for robustness we often prefer raw values or defval
// Using { defval: "" } ensures empty cells are present as empty strings if needed, but key logic handles missing keys.
const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
const data: SalesRecord[] = jsonData.map((row: any, index: number) => {
return mapRowToRecord(row, index);
}).filter((r: SalesRecord) => r.year !== 2022 && r.line && r.line !== 'Other');
return data;
} catch (error) {
console.error("Error processing Excel file:", error);
throw error;
}
}
export const filterData = (data: SalesRecord[], filters: FilterState): SalesRecord[] => {
return data.filter(item => {
// Item month is already normalized
const recordMonth = item.month;
const customerMatch = filters.customer.length === 0 || filters.customer.includes(item.customer);
const yearMatch = filters.year.length === 0 || filters.year.includes(item.year.toString());
const monthMatch = filters.month.length === 0 || filters.month.includes(recordMonth);
const lineMatch = filters.line.length === 0 || filters.line.includes(item.line);
const asinMatch = filters.asin.length === 0 || filters.asin.includes(item.asin);
const skuMatch = filters.sku.length === 0 || filters.sku.includes(item.sku);
const titleMatch = filters.title.length === 0 || filters.title.includes(item.title);
return customerMatch && yearMatch && monthMatch && lineMatch && asinMatch && skuMatch && titleMatch;
});
};
const calculateSeasonality = (data: SalesRecord[]): { seasonality: SeasonalityPoint[], seasonalityUnits: SeasonalityPoint[], years: string[] } => {
const seasonalityMap = new Map<string, SeasonalityPoint>();
const seasonalityUnitsMap = new Map<string, SeasonalityPoint>();
const yearsSet = new Set<string>();
// Initialize all months
MONTH_ORDER.forEach(m => {
seasonalityMap.set(m, { name: m });
seasonalityUnitsMap.set(m, { name: m });
});
data.forEach(record => {
const monthName = record.month;
const yearStr = record.year.toString();
yearsSet.add(yearStr);
if (seasonalityMap.has(monthName)) {
// Sell Out
const entrySO = seasonalityMap.get(monthName)!;
const currentValSO = (entrySO[yearStr] as number) || 0;
entrySO[yearStr] = currentValSO + record.sellOut;
// Units
const entryUnits = seasonalityUnitsMap.get(monthName)!;
const currentValUnits = (entryUnits[yearStr] as number) || 0;
entryUnits[yearStr] = currentValUnits + record.units;
}
});
const seasonality = Array.from(seasonalityMap.values());
const seasonalityUnits = Array.from(seasonalityUnitsMap.values());
const years = Array.from(yearsSet).sort();
return { seasonality, seasonalityUnits, years };
};
const calculateTopLinesSplit = (data: SalesRecord[]): YearlySplitData[] => {
// 1. Identify Lines by Sell Out (Sort desc)
const lineTotals = new Map<string, number>();
data.forEach(item => {
lineTotals.set(item.line, (lineTotals.get(item.line) || 0) + item.sellOut);
});
// Return ALL lines
const topLines = Array.from(lineTotals.entries())
.sort((a, b) => b[1] - a[1])
.map(([line]) => line);
// 2. Aggregate data by Year
const resultMap = new Map<string, YearlySplitData>();
topLines.forEach(line => {
resultMap.set(line, { name: line });
});
data.forEach(item => {
if (resultMap.has(item.line)) {
const entry = resultMap.get(item.line)!;
const keyVal = `${item.year}_value`;
const keyUnits = `${item.year}_units`;
entry[keyVal] = ((entry[keyVal] as number) || 0) + item.sellOut;
entry[keyUnits] = ((entry[keyUnits] as number) || 0) + item.units;
}
});
return Array.from(resultMap.values());
};
const calculateGenericSplit = (data: SalesRecord[], groupField: keyof SalesRecord, valueField: 'sellOut' | 'units', limit?: number): YearlySplitData[] => {
const totals = new Map<string, number>();
data.forEach(item => {
const key = String(item[groupField]);
totals.set(key, (totals.get(key) || 0) + item[valueField]);
});
let sortedKeys = Array.from(totals.entries()).sort((a,b) => b[1] - a[1]).map(e => e[0]);
if (limit) sortedKeys = sortedKeys.slice(0, limit);
const keySet = new Set(sortedKeys);
const resultMap = new Map<string, YearlySplitData>();
sortedKeys.forEach(k => resultMap.set(k, { name: k }));
data.forEach(item => {
const key = String(item[groupField]);
if (keySet.has(key)) {
const entry = resultMap.get(key)!;
const yearKey = item.year.toString();
entry[yearKey] = ((entry[yearKey] as number) || 0) + item[valueField];
}
});
return Array.from(resultMap.values());
};
// Renamed from calculateMovers
export const calculateLineMovers = (data: SalesRecord[]): { topMovers: LineGrowthMetric[], bottomMovers: LineGrowthMetric[], comparisonPeriods: { current: string, previous: string } } => {
const lineYearMap = new Map<string, Map<number, { sellOut: number; units: number }>>();
const allYears = new Set<number>();
data.forEach(item => {
if (!lineYearMap.has(item.line)) {
lineYearMap.set(item.line, new Map());
}
const yearMap = lineYearMap.get(item.line)!;
const current = yearMap.get(item.year) || { sellOut: 0, units: 0 };
yearMap.set(item.year, {
sellOut: current.sellOut + item.sellOut,
units: current.units + item.units
});
allYears.add(item.year);
});
const sortedYears = Array.from(allYears).sort((a, b) => b - a);
if (sortedYears.length < 2) {
return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: 'N/A', previous: 'N/A' } };
}
const currentYear = sortedYears[0];
const prevYear = sortedYears[1];
const metrics: LineGrowthMetric[] = [];
lineYearMap.forEach((yearMap, line) => {
const currData = yearMap.get(currentYear) || { sellOut: 0, units: 0 };
const prevData = yearMap.get(prevYear) || { sellOut: 0, units: 0 };
// Sell Out Growth
let sellOutGrowthValue = 0;
let sellOutGrowthPercentage = 0;
if (prevData.sellOut > 0) {
sellOutGrowthValue = currData.sellOut - prevData.sellOut;
sellOutGrowthPercentage = (sellOutGrowthValue / prevData.sellOut) * 100;
} else if (currData.sellOut > 0) {
sellOutGrowthValue = currData.sellOut;
sellOutGrowthPercentage = 100;
} else if (currData.sellOut === 0 && prevData.sellOut > 0) {
sellOutGrowthValue = -prevData.sellOut;
sellOutGrowthPercentage = -100;
}
// Unit Growth
let unitsGrowthValue = 0;
let unitsGrowthPercentage = 0;
if (prevData.units > 0) {
unitsGrowthValue = currData.units - prevData.units;
unitsGrowthPercentage = (unitsGrowthValue / prevData.units) * 100;
} else if (currData.units > 0) {
unitsGrowthValue = currData.units;
unitsGrowthPercentage = 100;
} else if (currData.units === 0 && prevData.units > 0) {
unitsGrowthValue = -prevData.units;
unitsGrowthPercentage = -100;
}
if (currData.sellOut > 0 || prevData.sellOut > 0) {
metrics.push({
line,
currentYearSellOut: currData.sellOut,
previousYearSellOut: prevData.sellOut,
sellOutGrowthValue,
sellOutGrowthPercentage,
currentYearUnits: currData.units,
previousYearUnits: prevData.units,
unitsGrowthValue,
unitsGrowthPercentage
});
}
});
const topMovers = metrics
.filter(m => m.sellOutGrowthValue > 0)
.sort((a, b) => b.sellOutGrowthValue - a.sellOutGrowthValue);
const bottomMovers = metrics
.filter(m => m.sellOutGrowthValue < 0)
.sort((a, b) => a.sellOutGrowthValue - b.sellOutGrowthValue);
return {
topMovers,
bottomMovers,
comparisonPeriods: { current: currentYear.toString(), previous: prevYear.toString() }
};
};
const createItemKey = (record: SalesRecord) => {
// A robust key combining all identifiers
return `${record.sku || 'NO_SKU'}||${record.asin || 'NO_ASIN'}||${record.title || 'NO_TITLE'}`;
}
export const calculateItemMovers = (
currentFilteredData: SalesRecord[],
selectedCustomerFromPage: string | null,
currentComparisonYearFromPage: number | null
): { topMovers: ItemGrowthMetric[], bottomMovers: ItemGrowthMetric[], comparisonPeriods: { current: string, previous: string } } => {
let dataToProcess = currentFilteredData;
// Apply customer filter if selected on the Top Movers page
if (selectedCustomerFromPage) {
dataToProcess = dataToProcess.filter(item => item.customer === selectedCustomerFromPage);
}
if (dataToProcess.length === 0) {
return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: 'N/A', previous: 'N/A' } };
}
// Map to store item data aggregated by year
const itemYearMap = new Map<string, Map<number, { sellOut: number; units: number, sku: string, asin: string, title: string, line: string }>>();
const allYearsInFilteredData = new Set<number>();
dataToProcess.forEach(item => {
const itemKey = createItemKey(item);
if (!itemYearMap.has(itemKey)) {
itemYearMap.set(itemKey, new Map());
}
const yearMap = itemYearMap.get(itemKey)!;
const current = yearMap.get(item.year) || { sellOut: 0, units: 0, sku: item.sku, asin: item.asin, title: item.title, line: item.line };
yearMap.set(item.year, {
sellOut: current.sellOut + item.sellOut,
units: current.units + item.units,
sku: item.sku,
asin: item.asin,
title: item.title,
line: item.line
});
allYearsInFilteredData.add(item.year);
});
const sortedYearsInFilteredData = Array.from(allYearsInFilteredData).sort((a, b) => b - a); // Descending (most recent first)
let currentYear: number;
let prevYear: number;
if (currentComparisonYearFromPage) {
// If a specific comparison year is provided by the user on the Top Movers page
currentYear = currentComparisonYearFromPage;
const currentYearIndex = sortedYearsInFilteredData.indexOf(currentYear);
if (currentYearIndex === -1 || currentYearIndex === sortedYearsInFilteredData.length - 1) {
// Specified year not found in filtered data or it's the oldest year (no previous year for comparison)
return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: currentYear.toString(), previous: 'N/A' } };
}
prevYear = sortedYearsInFilteredData[currentYearIndex + 1]; // The year directly before the currentComparisonYear
} else {
// Default to the two most recent years from the *filtered data* if no specific year is chosen
if (sortedYearsInFilteredData.length < 2) {
return { topMovers: [], bottomMovers: [], comparisonPeriods: { current: 'N/A', previous: 'N/A' } };
}
currentYear = sortedYearsInFilteredData[0]; // Most recent
prevYear = sortedYearsInFilteredData[1]; // Second most recent
}
const metrics: ItemGrowthMetric[] = [];
itemYearMap.forEach((yearMap) => {
const currData = yearMap.get(currentYear) || { sellOut: 0, units: 0, sku: '', asin: '', title: '', line: '' };
const prevData = yearMap.get(prevYear) || { sellOut: 0, units: 0, sku: '', asin: '', title: '', line: '' };
// Only include items that had some activity in at least one of the comparison years
if ((currData.sellOut === 0 && currData.units === 0) && (prevData.sellOut === 0 && prevData.units === 0)) {
return;
}
// Use metadata from current year, if not available use previous (for sku/asin/title/line)
const itemMeta = currData.sku ? currData : prevData;
// Sell Out Growth
let sellOutGrowthValue = currData.sellOut - prevData.sellOut;
let sellOutGrowthPercentage = 0;
if (prevData.sellOut !== 0) {
sellOutGrowthPercentage = (sellOutGrowthValue / prevData.sellOut) * 100;
} else if (currData.sellOut > 0) {
sellOutGrowthPercentage = 100; // Growth from zero
} else if (currData.sellOut === 0 && prevData.sellOut > 0) {
sellOutGrowthPercentage = -100; // Decline to zero
}
// Unit Growth
let unitsGrowthValue = currData.units - prevData.units;
let unitsGrowthPercentage = 0;
if (prevData.units !== 0) {
unitsGrowthPercentage = (unitsGrowthValue / prevData.units) * 100;
} else if (currData.units > 0) {
unitsGrowthPercentage = 100; // Growth from zero
} else if (currData.units === 0 && prevData.units > 0) {
unitsGrowthPercentage = -100; // Decline to zero
}
metrics.push({
sku: itemMeta.sku,
asin: itemMeta.asin,
title: itemMeta.title,
line: itemMeta.line,
currentYearSellOut: currData.sellOut,
previousYearSellOut: prevData.sellOut,
sellOutGrowthValue,
sellOutGrowthPercentage,
currentYearUnits: currData.units,
previousYearUnits: prevData.units,
unitsGrowthValue,
unitsGrowthPercentage
});
});
const topMovers = metrics
.sort((a, b) => b.unitsGrowthValue - a.unitsGrowthValue) // Sort by unitsGrowthValue
.slice(0, 20); // Top 20 Gainers
const bottomMovers = metrics
.sort((a, b) => a.unitsGrowthValue - b.unitsGrowthValue) // Sort by unitsGrowthValue
.slice(0, 20); // Top 20 Losers
return {
topMovers,
bottomMovers,
comparisonPeriods: { current: currentYear.toString(), previous: prevYear.toString() }
};
};
export const aggregateData = (data: SalesRecord[]): AggregatedData => {
const totalSellOut = data.reduce((acc, curr) => acc + curr.sellOut, 0);
const totalUnits = data.reduce((acc, curr) => acc + curr.units, 0);
const totalsByYear: Record<string, { sellOut: number; units: number }> = {};
data.forEach(item => {
const y = item.year.toString();
if (!totalsByYear[y]) totalsByYear[y] = { sellOut: 0, units: 0 };
totalsByYear[y].sellOut += item.sellOut;
totalsByYear[y].units += item.units;
});
const lineMap = new Map<string, { value: number; units: number }>();
data.forEach(item => {
const current = lineMap.get(item.line) || { value: 0, units: 0 };
lineMap.set(item.line, {
value: current.value + item.sellOut,
units: current.units + item.units
});
});
const byLine = Array.from(lineMap.entries())
.map(([name, data]) => ({ name, value: data.value, units: data.units }))
.sort((a, b) => b.value - a.value);
const customerMap = new Map<string, number>();
data.forEach(item => {
customerMap.set(item.customer, (customerMap.get(item.customer) || 0) + item.sellOut);
});
const byCustomer = Array.from(customerMap.entries())
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
const { seasonality, seasonalityUnits, years } = calculateSeasonality(data);
const { topMovers, bottomMovers, comparisonPeriods } = calculateLineMovers(data); // Use calculateLineMovers
const topLinesSplit = calculateTopLinesSplit(data);
const byCustomerSplit = calculateGenericSplit(data, 'customer', 'sellOut');
const byLineOverviewSplit = calculateGenericSplit(data, 'line', 'units', 10);
return {
totalSellOut,
totalUnits,
totalsByYear,
byLine,
byCustomer,
seasonality,
seasonalityUnits,
availableYears: years,
topMovers,
bottomMovers,
comparisonPeriods,
topLinesSplit,
byCustomerSplit,
byLineOverviewSplit
};
};
export const getUniqueValues = (data: SalesRecord[], field: keyof SalesRecord): string[] => {
const values = new Set(data.map(item => String(item[field])));
return Array.from(values).sort();
};
export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['title', 'customer', 'line', 'sku']): { rows: PivotRow[], years: string[] } => {
// 1. Determine all years present in the data for columns
const yearsSet = new Set(data.map(d => d.year));
const years = Array.from(yearsSet).sort((a,b) => b-a).map(String);
const map = new Map<string, PivotRow>();
data.forEach(record => {
// Group by Dynamic Dimensions
const keyParts = dimensions.map(dim => String(record[dim as keyof SalesRecord] || ''));
const key = keyParts.join('||');
if (!map.has(key)) {
map.set(key, {
id: key,
customer: dimensions.includes('customer') ? record.customer : '',
line: dimensions.includes('line') ? record.line : '',
title: dimensions.includes('title') ? record.title : '',
articleName: dimensions.includes('articleName') ? record.articleName : '',
sku: dimensions.includes('sku') ? record.sku : '',
asin: dimensions.includes('asin') ? record.asin : '',
// Initialize 12 months with empty year maps
months: Array(12).fill(null).map((_, i) => ({
monthIndex: i,
byYear: {}
})),
totalsByYear: {}
});
}
const row = map.get(key)!;
const monthPart = record.month;
const monthIdx = MONTH_ORDER.indexOf(monthPart);
const yearStr = record.year.toString();
// 1. Update Row Totals for Year
if (!row.totalsByYear[yearStr]) {
row.totalsByYear[yearStr] = { sellOut: 0, units: 0 };
}
row.totalsByYear[yearStr].sellOut += record.sellOut;
row.totalsByYear[yearStr].units += record.units;
// 2. Update Monthly Data
if (monthIdx !== -1) {
const m = row.months[monthIdx];
if (!m.byYear[yearStr]) {
m.byYear[yearStr] = { sellOut: 0, units: 0 };
}
m.byYear[yearStr].sellOut += record.sellOut;
m.byYear[yearStr].units += record.units;
}
});
return {
rows: Array.from(map.values()),
years
};
};
export const generateCSV = (rows: PivotRow[], dimensions: string[], years: string[]) => {
// Flatten PivotRows into CSV-friendly objects
const flatData = rows.map(row => {
const flatRow: any = {};
// Add Dimension Columns
dimensions.forEach(dim => {
// Map internal key to nicer Header if needed
let header = dim;
if (dim === 'line') header = 'Product Line';
if (dim === 'title') header = 'Title';
if (dim === 'customer') header = 'Customer';
flatRow[header] = row[dim as keyof PivotRow];
});
// Add Yearly Totals
years.forEach(year => {
const data = row.totalsByYear[year];
flatRow[`Total Sell Out ${year}`] = data?.sellOut || 0;
flatRow[`Total Units ${year}`] = data?.units || 0;
});
// Add Monthly Data
row.months.forEach(m => {
const monthName = MONTH_ORDER[m.monthIndex];
years.forEach(year => {
const data = m.byYear[year];
flatRow[`${monthName} ${year} Sell Out`] = data?.sellOut || 0;
flatRow[`${monthName} ${year} Units`] = data?.units || 0;
});
});
return flatRow;
});
// Generate CSV string
// @ts-ignore
const csv = Papa.unparse(flatData);
// Trigger Download
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `sales_export_${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
export const generateItemMoversCSV = (
data: ItemGrowthMetric[],
periods: { current: string; previous: string },
type: 'Gainers' | 'Losers'
) => {
const flatData = data.map(item => ({
SKU: item.sku || '-',
ASIN: item.asin || '-',
'Product Title': item.title || '-',
'Product Line': item.line || '-',
[`Sell Out ${periods.previous}`]: item.previousYearSellOut.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}),
[`Sell Out ${periods.current}`]: item.currentYearSellOut.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}),
'SO Diff': item.sellOutGrowthValue.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}),
'SO Growth %': item.sellOutGrowthPercentage.toLocaleString('de-DE', {minimumFractionDigits: 1, maximumFractionDigits: 1}) + '%',
[`Units ${periods.previous}`]: item.previousYearUnits.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}),
[`Units ${periods.current}`]: item.currentYearUnits.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}),
'Units Diff': item.unitsGrowthValue.toLocaleString('de-DE', {minimumFractionDigits: 0, maximumFractionDigits: 0}),
'Units Growth %': item.unitsGrowthPercentage.toLocaleString('de-DE', {minimumFractionDigits: 1, maximumFractionDigits: 1}) + '%',
}));
// @ts-ignore
const csv = Papa.unparse(flatData);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${type}_${periods.current}_vs_${periods.previous}_${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
export const aggregateForTimeSeries = (data: SalesRecord[]): TimeSeriesData[] => {
const map = new Map<string, { sellOut: number; units: number }>();
const recordsWithWeek = data.filter(r => r.week != null && r.year != null && r.week >= 1 && r.week <= 53);
if (recordsWithWeek.length === 0) return []; // No weekly data to process
recordsWithWeek.forEach(record => {
// Create a sortable key YYYY-WW
const weekStr = record.week!.toString().padStart(2, '0');
const key = `${record.year}-${weekStr}`;
const current = map.get(key) || { sellOut: 0, units: 0 };
current.sellOut += record.sellOut;
current.units += record.units;
map.set(key, current);
});
// Convert map to array and sort chronologically
return Array.from(map.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, values]) => {
const [year, weekNum] = key.split('-');
const yearShort = year.substring(2);
return {
name: `W${weekNum} '${yearShort}`,
sellOut: values.sellOut,
units: values.units
};
});
};
export const aggregateForComparisonTimeSeries = (data: SalesRecord[]): ComparisonTimeSeriesPoint[] => {
const map = new Map<number, { [key: string]: number }>(); // Key is week number
const years = Array.from(new Set(data.map(d => d.year)));
// Initialize map for all 53 possible weeks to ensure a consistent X-axis
for (let i = 1; i <= 53; i++) {
const initialWeekData: { [key: string]: number } = {};
years.forEach(year => {
initialWeekData[`${year}_sellOut`] = 0;
initialWeekData[`${year}_units`] = 0;
});
map.set(i, initialWeekData);
}
data.forEach(record => {
if (record.week != null && record.year != null && record.week >= 1 && record.week <= 53) {
const weekData = map.get(record.week)!;
const sellOutKey = `${record.year}_sellOut`;
const unitsKey = `${record.year}_units`;
weekData[sellOutKey] = (weekData[sellOutKey] || 0) + record.sellOut;
weekData[unitsKey] = (weekData[unitsKey] || 0) + record.units;
map.set(record.week, weekData);
}
});
// Convert map to array, filter out weeks with no data across all years, and sort
return Array.from(map.entries())
.map(([week, values]) => ({
week,
name: `W${week}`,
...values,
}))
.filter(d => {
// Check if there is any non-zero value for this week
return Object.values(d).some(val => typeof val === 'number' && val > 0);
})
.sort((a, b) => a.week - b.week);
};
+123
View File
@@ -0,0 +1,123 @@
import { GoogleGenAI } from "@google/genai";
import { AggregatedData } from "../types";
const SYSTEM_INSTRUCTION = `
You are an expert Data Analyst Assistant for "Craze Analytix".
You have access to a structured dataset of sales performance including Revenue (Sell Out), Units, Product Lines, and Seasonality.
Your Capabilities:
1. **Analyze Trends**: Use the provided Seasonality and Yearly Breakdown data.
2. **Perform Calculations**: You have access to detailed Product Line totals. You MUST calculate growth percentages, market shares, and sums dynamically if the user asks.
3. **Compare**: Compare performance between years (e.g., 2024 vs 2025).
Rules:
- If the user asks for a calculation (e.g., "What is the % share of Line X?"), perform the math using the provided numbers.
- Always format currency as € (e.g., €1,200) and units with 'u' or 'units' (e.g., 500 units).
- Be concise but insightful. Point out significant growth or decline.
- If data is missing for a specific query, state clearly that it is not in the current filtered view.
`;
const formatCurrency = (val: number) => `${val.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}`;
const formatUnits = (val: number) => `${val.toLocaleString()} units`;
export const queryGemini = async (
apiKey: string,
question: string,
context: AggregatedData,
filteredRecordCount: number
): Promise<string> => {
if (!apiKey) {
return "Please provide your Gemini API Key in the settings to enable the AI assistant.";
}
try {
// Ensure the key is clean of whitespace
const ai = new GoogleGenAI({ apiKey: apiKey.trim() });
// --- CONTEXT GENERATION ---
// We construct a structured report mirroring the dashboard charts
// 1. Totals by Year (KPI Cards)
const yearlySummary = Object.entries(context.totalsByYear)
.sort((a, b) => parseInt(b[0]) - parseInt(a[0])) // Descending years
.map(([year, data]) => ` - ${year}: ${formatCurrency(data.sellOut)} | ${formatUnits(data.units)}`)
.join('\n');
// 2. Seasonality (Line Chart Data)
const seasonalitySummary = context.seasonality.map(p => {
const yearValues = context.availableYears.map(y => `${y}: ${formatCurrency(p[y] as number || 0)}`).join(', ');
return ` - ${p.name}: [${yearValues}]`;
}).join('\n');
// 3. Growth/Decline
const growthSummary = context.topMovers.slice(0, 10).map(m =>
` - ${m.line}: +€${m.sellOutGrowthValue.toLocaleString()} (${m.sellOutGrowthPercentage.toFixed(1)}%)`
).join('\n');
const declineSummary = context.bottomMovers.slice(0, 10).map(m =>
` - ${m.line}: -€${Math.abs(m.sellOutGrowthValue).toLocaleString()} (${m.sellOutGrowthPercentage.toFixed(1)}%)`
).join('\n');
// 4. DETAILED BREAKDOWN (For Calculations)
// We provide a JSON-like structure of ALL top product lines so the AI can compute shares/totals.
// We limit this to top 100 to avoid token limits, which covers most relevant data.
const detailedLines = context.byLine.slice(0, 100).map(l => ({
name: l.name,
revenue: l.value,
units: l.units
}));
const fullReport = `
REPORT CONTEXT (Based on Current Filters):
------------------------------------------
GLOBAL METRICS:
Total Sell Out: ${formatCurrency(context.totalSellOut)}
Total Units: ${formatUnits(context.totalUnits)}
Records Analyzed: ${filteredRecordCount}
Years Available: ${context.availableYears.join(', ')}
YEARLY TOTALS:
${yearlySummary}
MONTHLY TRENDS (Seasonality):
${seasonalitySummary}
TOP PERFORMERS (Growth YoY):
${growthSummary}
WORST PERFORMERS (Decline YoY):
${declineSummary}
DETAILED PRODUCT LINE DATA (Use this for specific calculations):
${JSON.stringify(detailedLines, null, 2)}
`;
const response = await ai.models.generateContent({
model: 'gemini-3-pro-preview', // Updated to the latest capable model for complex reasoning
contents: [
{
role: 'user',
parts: [{ text: `Context Data:\n${fullReport}\n\nUser Question: ${question}` }]
}
],
config: {
systemInstruction: SYSTEM_INSTRUCTION,
}
});
return response.text || "I couldn't generate a response based on the data provided.";
} catch (error: any) {
console.error("Gemini API Error:", error);
if (error.message && error.message.includes("403")) {
return "Error 403: Invalid API Key. Please check your key in the settings.";
}
if (error.message && error.message.includes("429")) {
return "Error 429: Quota exceeded. You are sending too many requests.";
}
return `Error: ${error.message || "An unexpected error occurred while analyzing the data."}`;
}
};
+83
View File
@@ -0,0 +1,83 @@
import { SalesRecord } from '../types';
const DB_NAME = 'CrazeAnalytixDB';
const STORE_NAME = 'salesData';
const DB_VERSION = 1;
const initDB = (): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
};
export const saveSalesData = async (data: SalesRecord[]): Promise<void> => {
try {
const db = await initDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
// Store the data array
store.put(data, 'currentData');
// Store the timestamp
store.put(new Date().toISOString(), 'lastUpdated');
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
} catch (error) {
console.error("Error saving to IndexedDB:", error);
// Fallback or silence error (data just won't be cached)
}
};
export const loadSalesData = async (): Promise<{ data: SalesRecord[]; lastUpdated: string | null }> => {
try {
const db = await initDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
const dataReq = store.get('currentData');
const dateReq = store.get('lastUpdated');
transaction.oncomplete = () => {
resolve({
data: dataReq.result || [],
lastUpdated: dateReq.result || null
});
};
transaction.onerror = () => reject(transaction.error);
});
} catch (error) {
console.error("Error loading from IndexedDB:", error);
return { data: [], lastUpdated: null };
}
};
export const clearSalesData = async (): Promise<void> => {
try {
const db = await initDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
store.clear();
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
} catch (e) {
console.error(e);
}
}
+29
View File
@@ -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
}
}
+127
View File
@@ -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<string, { sellOut: number; units: number }>;
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<string, YearlyData>;
}
export interface PivotRow {
id: string;
customer: string;
line: string;
title: string; // Added Title
articleName: string;
sku: string;
asin: string;
// Dynamic buckets
totalsByYear: Record<string, YearlyData>;
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"
}
+23
View File
@@ -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, '.'),
}
}
};
});