mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 10:05:23 +02:00
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:
+24
@@ -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?
|
||||
@@ -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;
|
||||
@@ -1,11 +1,20 @@
|
||||
<div align="center">
|
||||
|
||||
<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>
|
||||
|
||||
# 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`
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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."}`;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user