mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:15:24 +02:00
feat: Integrate PapaParse for CSV handling
Adds papaparse as a dependency and updates the data processing service to use it for more robust CSV file parsing. This replaces manual CSV parsing logic with a dedicated library, improving reliability and handling of various CSV formats. Also renames the `MoversIcon` to `TrendingIcon` to better reflect its usage in indicating trending performance metrics.
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
|
||||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import FileUpload from './components/FileUpload';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import DataGrid from './components/DataGrid';
|
||||
import TopMovers from './components/TopMovers';
|
||||
import FilterBar from './components/FilterBar';
|
||||
import AIChat from './components/AIChat';
|
||||
import CrazeLogo from './components/CrazeLogo';
|
||||
import TopMoversPage from './components/TopMoversPage'; // New import
|
||||
import { SalesRecord, FilterState, AggregatedData } from './types';
|
||||
import { processCSV, processExcel, filterData, aggregateData, getUniqueValues } from './services/dataProcessor';
|
||||
import { processCSV, filterData, aggregateData, getUniqueValues } from './services/dataProcessor';
|
||||
import { queryGemini } from './services/geminiService';
|
||||
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon } from './components/Icons';
|
||||
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon } from './components/Icons';
|
||||
import { loadSalesData, saveSalesData, clearSalesData } from './services/storage';
|
||||
|
||||
// New Refresh Icon
|
||||
@@ -19,13 +20,6 @@ const RefreshIcon = ({ className }: { className?: string }) => (
|
||||
</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";
|
||||
@@ -34,14 +28,11 @@ 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 [view, setView] = useState<'dashboard' | 'table' | 'movers'>('dashboard');
|
||||
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);
|
||||
|
||||
@@ -56,16 +47,6 @@ const App: React.FC = () => {
|
||||
title: [],
|
||||
});
|
||||
|
||||
// Handle API Key Change
|
||||
const handleApiKeyChange = (key: string) => {
|
||||
setApiKey(key);
|
||||
if (key) {
|
||||
localStorage.setItem('gemini_api_key', key);
|
||||
} else {
|
||||
localStorage.removeItem('gemini_api_key');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle URL Fetch (Auto/Manual)
|
||||
const handleUrlFetch = useCallback(async (url: string) => {
|
||||
setSyncing(true);
|
||||
@@ -79,6 +60,8 @@ const App: React.FC = () => {
|
||||
}
|
||||
|
||||
// Use a CORS proxy to bypass browser's same-origin policy restrictions.
|
||||
// This is necessary because Dropbox does not send the required CORS headers
|
||||
// for direct client-side fetching from another domain.
|
||||
const proxyUrl = `https://corsproxy.io/?${encodeURIComponent(directUrl)}`;
|
||||
|
||||
const response = await fetch(proxyUrl);
|
||||
@@ -152,24 +135,14 @@ const App: React.FC = () => {
|
||||
const handleFileUpload = async (file: File) => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
let data: SalesRecord[] = [];
|
||||
const lowerName = file.name.toLowerCase();
|
||||
|
||||
if (lowerName.endsWith('.csv')) {
|
||||
data = await processCSV(file);
|
||||
} else if (lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')) {
|
||||
data = await processExcel(file);
|
||||
} else {
|
||||
throw new Error("Unsupported file format");
|
||||
}
|
||||
|
||||
const data = await processCSV(file);
|
||||
await saveSalesData(data);
|
||||
initializeData(data);
|
||||
setLastUpdated(new Date().toISOString());
|
||||
setIsDataModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse file", error);
|
||||
alert("Error parsing file. Please check format (CSV or Excel).");
|
||||
console.error("Failed to parse CSV", error);
|
||||
alert("Error parsing CSV. Please check the format.");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
@@ -254,7 +227,7 @@ const App: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleAskGemini = async (text: string) => {
|
||||
return await queryGemini(apiKey, text, aggregatedData, filteredData.length);
|
||||
return await queryGemini(text, aggregatedData, filteredData.length);
|
||||
};
|
||||
|
||||
const disconnectUrl = async () => {
|
||||
@@ -329,12 +302,12 @@ const App: React.FC = () => {
|
||||
>
|
||||
<TableIcon /> <span className="hidden sm:inline">Data Grid</span>
|
||||
</button>
|
||||
<button // New Top Movers Button
|
||||
onClick={() => setView('topMovers')}
|
||||
<button
|
||||
onClick={() => setView('movers')}
|
||||
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'}`}
|
||||
${view === 'movers' ? '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>
|
||||
<TrendingIcon /> <span className="hidden sm:inline">Top Movers</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,37 +325,23 @@ const App: React.FC = () => {
|
||||
</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' ? (
|
||||
{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
|
||||
contextData={contextAggregatedData}
|
||||
/>
|
||||
)}
|
||||
{view === 'table' && <DataGrid data={filteredData} />}
|
||||
{view === 'movers' && <TopMovers data={filteredData} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Chat Assistant - Now with API Key Props */}
|
||||
<AIChat
|
||||
onSendMessage={handleAskGemini}
|
||||
isOpen={isChatOpen}
|
||||
setIsOpen={setIsChatOpen}
|
||||
apiKey={apiKey}
|
||||
onApiKeyChange={handleApiKeyChange}
|
||||
/>
|
||||
{/* Chat Assistant */}
|
||||
<AIChat onSendMessage={handleAskGemini} isOpen={isChatOpen} setIsOpen={setIsChatOpen} />
|
||||
|
||||
{/* DATA MODAL */}
|
||||
{isDataModalOpen && (
|
||||
|
||||
+9
-82
@@ -1,4 +1,3 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { ChatIcon, CloseIcon, SendIcon } from './Icons';
|
||||
import { ChatMessage } from '../types';
|
||||
@@ -7,8 +6,6 @@ interface AIChatProps {
|
||||
onSendMessage: (text: string) => Promise<string>;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
apiKey: string;
|
||||
onApiKeyChange: (key: string) => void;
|
||||
}
|
||||
|
||||
const ModelMessage: React.FC<{ text: string }> = ({ text }) => {
|
||||
@@ -58,37 +55,23 @@ const ModelMessage: React.FC<{ text: string }> = ({ text }) => {
|
||||
return <>{elements.length > 0 ? elements : <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 AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen }) => {
|
||||
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() }
|
||||
{ role: 'model', text: 'Hello! I am your Sales Data Assistant. Ask me anything about the loaded data.', timestamp: new Date() }
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [showConfig, setShowConfig] = useState(!apiKey);
|
||||
const messagesEndRef = useRef<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]);
|
||||
useEffect(scrollToBottom, [messages, isOpen]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || !apiKey) return;
|
||||
if (!input.trim()) return;
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', text: input, timestamp: new Date() };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
@@ -108,12 +91,6 @@ const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen, apiKe
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveKey = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Input value is already bound to parent state via local var, but we use form submission to switch view
|
||||
if (apiKey) setShowConfig(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Trigger Button */}
|
||||
@@ -135,64 +112,16 @@ const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen, apiKe
|
||||
{/* 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 className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div>
|
||||
<h3 className="font-bold text-slate-100">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">
|
||||
<button onClick={() => setIsOpen(false)} className="text-slate-400 hover:text-white transition-colors">
|
||||
<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">
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-950/50">
|
||||
{messages.map((msg, idx) => (
|
||||
<div key={idx} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
@@ -225,7 +154,7 @@ const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen, apiKe
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about revenue, growth, units..."
|
||||
placeholder="Ask about trends, totals..."
|
||||
className="w-full bg-slate-950 border border-slate-700 text-slate-200 rounded-full py-3 pl-4 pr-12 focus:outline-none focus:border-primary transition-colors placeholder-slate-500 text-sm"
|
||||
/>
|
||||
<button
|
||||
@@ -237,8 +166,6 @@ const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen, apiKe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
+12
-45
@@ -1,13 +1,10 @@
|
||||
|
||||
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { AggregatedData, LineGrowthMetric } from '../types'; // Updated import for LineGrowthMetric
|
||||
import { AggregatedData, GrowthMetric } from '../types';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
LineChart, Line, Legend
|
||||
} from 'recharts';
|
||||
import { DownloadIcon } from './Icons'; // Import DownloadIcon
|
||||
|
||||
interface DashboardProps {
|
||||
data: AggregatedData;
|
||||
@@ -17,13 +14,7 @@ interface DashboardProps {
|
||||
const COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0ea5e9'];
|
||||
|
||||
// Reusable Expandable Card Component
|
||||
export const ExpandableCard: React.FC<{
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onExport?: () => void; // Optional export function
|
||||
exportFileName?: string; // Optional export file name
|
||||
}> = ({ title, children, className, onExport, exportFileName }) => {
|
||||
const ExpandableCard: React.FC<{ title: string; children: React.ReactNode; className?: string }> = ({ title, children, className }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const toggleExpand = () => setIsExpanded(!isExpanded);
|
||||
@@ -58,14 +49,6 @@ export const ExpandableCard: React.FC<{
|
||||
|
||||
<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}
|
||||
@@ -80,16 +63,6 @@ export const ExpandableCard: React.FC<{
|
||||
>
|
||||
<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"
|
||||
@@ -100,7 +73,6 @@ export const ExpandableCard: React.FC<{
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-[250px] cursor-pointer" onClick={toggleExpand}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -325,11 +297,11 @@ const ComparisonTooltip = ({ active, payload, label, metric }: any) => {
|
||||
|
||||
const GrowthTable: React.FC<{
|
||||
title: string;
|
||||
data: LineGrowthMetric[]; // Updated to LineGrowthMetric
|
||||
data: GrowthMetric[];
|
||||
type: 'growth' | 'decline';
|
||||
periods: { current: string; previous: string };
|
||||
}> = ({ title, data, type, periods }) => {
|
||||
const [sortConfig, setSortConfig] = useState<{ key: keyof LineGrowthMetric | null; direction: 'asc' | 'desc' }>({ key: null, direction: 'desc' });
|
||||
const [sortConfig, setSortConfig] = useState<{ key: keyof GrowthMetric | null; direction: 'asc' | 'desc' }>({ key: null, direction: 'desc' });
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortConfig.key) return data;
|
||||
@@ -344,7 +316,7 @@ const GrowthTable: React.FC<{
|
||||
});
|
||||
}, [data, sortConfig]);
|
||||
|
||||
const requestSort = (key: keyof LineGrowthMetric) => {
|
||||
const requestSort = (key: keyof GrowthMetric) => {
|
||||
let direction: 'asc' | 'desc' = 'desc';
|
||||
// If already sorting by this key, toggle direction
|
||||
if (sortConfig.key === key && sortConfig.direction === 'desc') {
|
||||
@@ -353,7 +325,7 @@ const GrowthTable: React.FC<{
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
const getSortIndicator = (key: keyof LineGrowthMetric) => {
|
||||
const getSortIndicator = (key: keyof GrowthMetric) => {
|
||||
if (sortConfig.key !== key) {
|
||||
return (
|
||||
<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">
|
||||
@@ -372,6 +344,7 @@ const GrowthTable: React.FC<{
|
||||
};
|
||||
|
||||
return (
|
||||
<ExpandableCard title={title} className="h-full">
|
||||
<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">
|
||||
@@ -447,7 +420,6 @@ const GrowthTable: React.FC<{
|
||||
<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'}`}>
|
||||
@@ -478,6 +450,7 @@ const GrowthTable: React.FC<{
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -572,30 +545,24 @@ const Dashboard: React.FC<DashboardProps> = ({ data, contextData }) => {
|
||||
</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
|
||||
>
|
||||
<div className="h-72">
|
||||
<GrowthTable
|
||||
title={contextData ? "Fastest Growing Lines (Full Line Context)" : "Fastest Growing Lines (YoY - €)"}
|
||||
data={displayData.topMovers}
|
||||
type="growth"
|
||||
periods={displayData.comparisonPeriods}
|
||||
/>
|
||||
</ExpandableCard>
|
||||
</div>
|
||||
|
||||
{/* Decline Table */}
|
||||
<ExpandableCard
|
||||
title={contextData ? "Declining Lines (Full Line Context)" : "Declining Lines (YoY - €)"}
|
||||
className="h-[400px]" // Provide a default height for the card
|
||||
>
|
||||
<div className="h-72">
|
||||
<GrowthTable
|
||||
title={contextData ? "Declining Lines (Full Line Context)" : "Declining Lines (YoY - €)"}
|
||||
data={displayData.bottomMovers}
|
||||
type="decline"
|
||||
periods={displayData.comparisonPeriods}
|
||||
/>
|
||||
</ExpandableCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
|
||||
+286
-522
@@ -1,12 +1,9 @@
|
||||
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { SalesRecord, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types';
|
||||
import { SalesRecord, PivotRow } from '../types';
|
||||
import { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries } from '../services/dataProcessor';
|
||||
import MultiSelectDropdown from './MultiSelectDropdown';
|
||||
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon } from './Icons';
|
||||
|
||||
interface DataGridProps {
|
||||
@@ -14,7 +11,7 @@ interface DataGridProps {
|
||||
}
|
||||
|
||||
type SortConfig = {
|
||||
key: keyof PivotRow | string | null; // string for dynamic year sorting
|
||||
key: string | null;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
@@ -26,7 +23,6 @@ type ConditionalFilter = {
|
||||
}
|
||||
|
||||
const ROWS_PER_PAGE = 50;
|
||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const CHART_COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0ea5e9'];
|
||||
|
||||
// Available grouping dimensions
|
||||
@@ -182,7 +178,7 @@ const ComparisonTooltip = ({ active, payload, label }: any) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Reusable Expandable Card for the Chart
|
||||
// Reusable Expandable Chart Card
|
||||
const ExpandableChartCard: React.FC<{ title: string; children: React.ReactNode; className?: string }> = ({ title, children, className }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
@@ -257,7 +253,7 @@ const DataGrid: React.FC<DataGridProps> = ({ data }) => {
|
||||
|
||||
// Data for the time series chart, supporting single and multi-year comparison
|
||||
const { chartData, uniqueYears, isComparisonView, chartTitle } = useMemo(() => {
|
||||
const yearsInView = Array.from(new Set(data.map(d => d.year.toString()))).sort((a, b) => parseInt(b) - parseInt(a));
|
||||
const yearsInView = Array.from(new Set(data.map(d => d.year.toString()))).sort((a: string, b: string) => parseInt(b) - parseInt(a));
|
||||
const isMultiYear = yearsInView.length > 1;
|
||||
|
||||
if (isMultiYear) {
|
||||
@@ -293,9 +289,6 @@ const DataGrid: React.FC<DataGridProps> = ({ data }) => {
|
||||
return options;
|
||||
}, [years]);
|
||||
|
||||
// Default sorting
|
||||
const effectiveSortKey = sortConfig.key || (years.length > 0 ? `total_${years[0]}` : null);
|
||||
|
||||
// Apply Advanced Row Filters THEN Sort
|
||||
const processedRows = useMemo(() => {
|
||||
let result = pivotRows;
|
||||
@@ -340,120 +333,65 @@ const DataGrid: React.FC<DataGridProps> = ({ data }) => {
|
||||
}
|
||||
|
||||
// 2. Sort
|
||||
if (effectiveSortKey) {
|
||||
result = [...result].sort((a, b) => {
|
||||
let aVal: string | number = 0;
|
||||
let bVal: string | number = 0;
|
||||
if (sortConfig.key) {
|
||||
result.sort((a, b) => {
|
||||
let valA: number | string = '';
|
||||
let valB: number | string = '';
|
||||
|
||||
const sortKeyStr = String(effectiveSortKey);
|
||||
|
||||
if (effectiveDimensions.includes(sortKeyStr)) {
|
||||
const key = sortKeyStr as keyof PivotRow;
|
||||
const valA = a[key];
|
||||
const valB = b[key];
|
||||
if (typeof valA === 'string' || typeof valA === 'number') {
|
||||
aVal = valA;
|
||||
// Handle sorting by dimensions
|
||||
if (['customer', 'line', 'sku', 'title', 'articleName', 'asin'].includes(sortConfig.key as string)) {
|
||||
valA = a[sortConfig.key as keyof PivotRow] as string || '';
|
||||
valB = b[sortConfig.key as keyof PivotRow] as string || '';
|
||||
}
|
||||
if (typeof valB === 'string' || typeof valB === 'number') {
|
||||
bVal = valB;
|
||||
// Handle sorting by Total Metrics (total_sellOut_2023)
|
||||
else if ((sortConfig.key as string).startsWith('total_')) {
|
||||
const parts = (sortConfig.key as string).split('_');
|
||||
// parts[1] = metric (sellOut/units), parts[2] = year
|
||||
if (parts.length === 3) {
|
||||
const y = parts[2];
|
||||
const m = parts[1] as 'sellOut' | 'units';
|
||||
valA = a.totalsByYear[y]?.[m] || 0;
|
||||
valB = b.totalsByYear[y]?.[m] || 0;
|
||||
}
|
||||
} 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;
|
||||
if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [pivotRows, rowFilters, effectiveSortKey, sortConfig, effectiveDimensions, years]);
|
||||
}, [pivotRows, rowFilters, sortConfig, years]);
|
||||
|
||||
|
||||
// Grand Totals (Calculated on Filtered Rows for context)
|
||||
const grandTotals = useMemo(() => {
|
||||
const accTotalsByYear: Record<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 paginatedRows = useMemo(() => {
|
||||
const start = (currentPage - 1) * ROWS_PER_PAGE;
|
||||
return processedRows.slice(start, start + ROWS_PER_PAGE);
|
||||
}, [processedRows, currentPage]);
|
||||
|
||||
// Handlers
|
||||
const totalPages = Math.ceil(processedRows.length / ROWS_PER_PAGE);
|
||||
|
||||
const requestSort = (key: string) => {
|
||||
let direction: 'asc' | 'desc' = 'desc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'desc') {
|
||||
direction = 'asc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handlePrev = () => setCurrentPage(p => Math.max(1, p - 1));
|
||||
const handleNext = () => setCurrentPage(p => Math.min(totalPages, p + 1));
|
||||
const handleExport = () => generateCSV(processedRows, effectiveDimensions, years);
|
||||
const getLabel = (val: string) => DIMENSION_OPTIONS.find(d => d.value === val)?.label || val;
|
||||
const getSortIcon = (key: string) => {
|
||||
if (sortConfig.key !== key) return <span className="text-slate-600 ml-1">⇅</span>;
|
||||
return <span className="text-primary ml-1">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>;
|
||||
};
|
||||
|
||||
const toggleMetric = (metric: 'sellOut' | 'units') => {
|
||||
setVisibleMetrics(prev =>
|
||||
prev.includes(metric)
|
||||
? prev.filter(m => m !== metric)
|
||||
: [...prev, metric]
|
||||
);
|
||||
const handleExport = () => {
|
||||
generateCSV(processedRows, effectiveDimensions, years);
|
||||
};
|
||||
|
||||
// Filter Handlers
|
||||
const addFilter = () => {
|
||||
if (!newFilterMetric || !newFilterValue) return;
|
||||
setRowFilters(prev => [
|
||||
...prev,
|
||||
if (newFilterMetric && newFilterValue) {
|
||||
setRowFilters([
|
||||
...rowFilters,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
metric: newFilterMetric,
|
||||
@@ -461,113 +399,181 @@ const DataGrid: React.FC<DataGridProps> = ({ data }) => {
|
||||
value: parseFloat(newFilterValue)
|
||||
}
|
||||
]);
|
||||
setNewFilterMetric('');
|
||||
setNewFilterValue('');
|
||||
// Don't close builder to allow adding more
|
||||
setShowFilterBuilder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFilter = (id: string) => {
|
||||
setRowFilters(prev => prev.filter(f => f.id !== id));
|
||||
setRowFilters(rowFilters.filter(f => f.id !== id));
|
||||
};
|
||||
|
||||
// Render Helpers
|
||||
const renderGrowth = (current: number, previous: number, size: 'sm' | 'xs' = 'xs') => {
|
||||
if (previous === 0) return null;
|
||||
const pct = ((current - previous) / previous) * 100;
|
||||
const isPositive = pct >= 0;
|
||||
const textSize = size === 'sm' ? 'text-xs' : 'text-[10px]';
|
||||
// Reset pagination when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [rowFilters, data, effectiveDimensions]);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
<div className="space-y-6 max-w-[95vw] mx-auto animate-fade-in pb-24">
|
||||
|
||||
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"
|
||||
{/* 1. Time Series Chart Section */}
|
||||
{showChart && chartData.length > 0 && (
|
||||
<ExpandableChartCard title={chartTitle} className="rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#64748b"
|
||||
tick={{fontSize: 12}}
|
||||
interval={isComparisonView ? 2 : 'preserveStartEnd'}
|
||||
/>
|
||||
</div>
|
||||
<YAxis
|
||||
stroke="#64748b"
|
||||
tickFormatter={(val) => `€${(val/1000).toFixed(0)}k`}
|
||||
/>
|
||||
<Tooltip content={(props: any) => isComparisonView ? <ComparisonTooltip {...props} /> : <WoWTooltip {...props} data={chartData} />} />
|
||||
<Legend />
|
||||
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{isComparisonView ? (
|
||||
uniqueYears.flatMap((year, idx) => [
|
||||
visibleMetrics.includes('sellOut') && (
|
||||
<Line
|
||||
key={`${year}_so`}
|
||||
type="monotone"
|
||||
dataKey={`${year}_sellOut`}
|
||||
name={`Sell Out ${year}`}
|
||||
stroke={CHART_COLORS[idx % CHART_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
),
|
||||
visibleMetrics.includes('units') && (
|
||||
<Line
|
||||
key={`${year}_units`}
|
||||
type="monotone"
|
||||
dataKey={`${year}_units`}
|
||||
name={`Units ${year}`}
|
||||
stroke={CHART_COLORS[idx % CHART_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
/>
|
||||
)
|
||||
])
|
||||
) : (
|
||||
[
|
||||
visibleMetrics.includes('sellOut') && (
|
||||
<Line
|
||||
key="so"
|
||||
type="monotone"
|
||||
dataKey="sellOut"
|
||||
name="Sell Out"
|
||||
stroke="#6366f1"
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
/>
|
||||
),
|
||||
visibleMetrics.includes('units') && (
|
||||
<Line
|
||||
key="units"
|
||||
type="monotone"
|
||||
dataKey="units"
|
||||
name="Units"
|
||||
stroke="#10b981"
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
yAxisId={0} // Using same axis for simplicity, usually needs dual axis
|
||||
/>
|
||||
)
|
||||
]
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ExpandableChartCard>
|
||||
)}
|
||||
|
||||
{/* Filter Creator Inputs */}
|
||||
{/* 2. Controls & Grid */}
|
||||
<div className="bg-surface border border-border rounded-xl shadow-lg overflow-hidden flex flex-col">
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="p-4 border-b border-border bg-slate-900/50 flex flex-col lg:flex-row gap-4 justify-between items-start lg:items-center">
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 w-full lg:w-auto">
|
||||
{/* Dimensions Selector */}
|
||||
<div className="relative group z-30">
|
||||
<button className="flex items-center gap-2 px-3 py-2 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg text-sm font-medium transition-colors">
|
||||
<span className="text-slate-300">Group By:</span>
|
||||
<span className="text-white font-bold">{effectiveDimensions.length} Columns</span>
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /></svg>
|
||||
</button>
|
||||
<div className="absolute top-full left-0 mt-2 w-48 bg-slate-900 border border-slate-700 rounded-xl shadow-xl p-2 hidden group-hover:block animate-fade-in">
|
||||
{DIMENSION_OPTIONS.map(dim => (
|
||||
<label key={dim.value} className="flex items-center gap-2 p-2 hover:bg-slate-800 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDimensions.includes(dim.value)}
|
||||
onChange={() => {
|
||||
if (selectedDimensions.includes(dim.value)) {
|
||||
setSelectedDimensions(selectedDimensions.filter(d => d !== dim.value));
|
||||
} else {
|
||||
setSelectedDimensions([...selectedDimensions, dim.value]);
|
||||
}
|
||||
}}
|
||||
className="rounded border-slate-600 bg-slate-800 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm text-slate-300">{dim.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Builder Trigger */}
|
||||
<button
|
||||
onClick={() => setShowFilterBuilder(!showFilterBuilder)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${showFilterBuilder || rowFilters.length > 0 ? 'bg-indigo-600/20 text-indigo-400 border-indigo-500/50' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'}`}
|
||||
>
|
||||
<FunnelIcon />
|
||||
<span>Filter Rows {rowFilters.length > 0 && `(${rowFilters.length})`}</span>
|
||||
</button>
|
||||
|
||||
{/* Chart Toggle */}
|
||||
<button
|
||||
onClick={() => setShowChart(!showChart)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${showChart ? 'bg-indigo-600/20 text-indigo-400 border-indigo-500/50' : 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'}`}
|
||||
>
|
||||
<ChartIcon />
|
||||
<span className="hidden sm:inline">{showChart ? 'Hide Chart' : 'Show Chart'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 w-full lg:w-auto justify-between lg:justify-end">
|
||||
<div className="text-xs text-slate-500 font-mono">
|
||||
Showing {processedRows.length} rows
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-bold shadow-lg shadow-emerald-900/20 transition-all"
|
||||
>
|
||||
<DownloadIcon />
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Builder Panel */}
|
||||
{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>
|
||||
<div className="bg-slate-900 border-b border-border p-4 animate-fade-in">
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
||||
<div className="flex-1 w-full">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase mb-1 block">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)}
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Select Metric...</option>
|
||||
{metricOptions.map(opt => (
|
||||
@@ -575,399 +581,157 @@ const DataGrid: React.FC<DataGridProps> = ({ data }) => {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-slate-500 font-semibold uppercase">Operator</label>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase mb-1 block">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')}
|
||||
className="bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="gt">Greater Than ({'>'})</option>
|
||||
<option value="lt">Less Than ({'<'})</option>
|
||||
<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>
|
||||
<div className="w-32">
|
||||
<label className="text-xs font-semibold text-slate-500 uppercase mb-1 block">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()}
|
||||
placeholder="0"
|
||||
className="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</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"
|
||||
className="px-4 py-2 bg-primary hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Filters Chips */}
|
||||
{rowFilters.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-4 pt-4 border-t border-slate-800">
|
||||
{rowFilters.map(filter => {
|
||||
const metricLabel = metricOptions.find(m => m.value === filter.metric)?.label || filter.metric;
|
||||
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} {filter.operator === 'gt' ? '>' : '<'} {filter.value}</span>
|
||||
<button onClick={() => removeFilter(filter.id)} className="hover:text-white">
|
||||
<CloseIcon />
|
||||
</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';
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="overflow-x-auto min-h-[400px]">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead className="bg-slate-950 text-slate-400 uppercase text-xs font-semibold tracking-wider sticky top-0 z-20 shadow-sm">
|
||||
<tr>
|
||||
{/* Dynamic Dimension Headers */}
|
||||
{effectiveDimensions.map(dim => {
|
||||
const label = DIMENSION_OPTIONS.find(d => d.value === dim)?.label || dim;
|
||||
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'}`}
|
||||
className="px-4 py-3 border-b border-border cursor-pointer hover:text-white group bg-slate-950 min-w-[150px]"
|
||||
onClick={() => requestSort(dim)}
|
||||
>
|
||||
{label} {sortConfig.key === dim && (sortConfig.direction === 'asc' ? '▲' : '▼')}
|
||||
<div className="flex items-center">
|
||||
{label} {getSortIcon(dim)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Dynamic Total Columns for each Year - STICKY TOP */}
|
||||
{/* Total Columns per Year */}
|
||||
{years.map(year => (
|
||||
<React.Fragment key={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}`)}
|
||||
className="px-4 py-3 border-b border-border text-right cursor-pointer hover:text-white bg-slate-950 min-w-[100px]"
|
||||
onClick={() => requestSort(`total_sellOut_${year}`)}
|
||||
>
|
||||
Total {year} {sortConfig.key === `total_${year}` && (sortConfig.direction === 'asc' ? '▲' : '▼')}
|
||||
</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}
|
||||
Sell Out {year} {getSortIcon(`total_sellOut_${year}`)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end text-violet-400 font-mono mt-0.5">
|
||||
<span>{data.units.toLocaleString()}u</span>
|
||||
{unitsGrowth}
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 border-b border-border text-right cursor-pointer hover:text-white bg-slate-950 min-w-[100px]"
|
||||
onClick={() => requestSort(`total_units_${year}`)}
|
||||
>
|
||||
<div className="flex items-center justify-end">
|
||||
Units {year} {getSortIcon(`total_units_${year}`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</td>
|
||||
</th>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</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) + '...';
|
||||
<tbody className="divide-y divide-border text-slate-300">
|
||||
{paginatedRows.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-slate-800/50 transition-colors group">
|
||||
{/* Dimension Values */}
|
||||
{effectiveDimensions.map(dim => (
|
||||
<td key={dim} className="px-4 py-3 font-medium text-slate-200 break-words max-w-xs">
|
||||
{dim === 'title'
|
||||
? <div className="line-clamp-2" title={row.title}>{row.title || '-'}</div>
|
||||
: (row[dim as keyof PivotRow] as string) || '-'
|
||||
}
|
||||
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>
|
||||
))}
|
||||
|
||||
{/* Metric Values */}
|
||||
{years.map(year => {
|
||||
const data = row.totalsByYear[year];
|
||||
return (
|
||||
<React.Fragment key={year}>
|
||||
<td className="px-4 py-3 text-right font-medium text-white group-hover:text-emerald-400 transition-colors">
|
||||
{data ? `€${data.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-slate-400">
|
||||
{data ? data.units.toLocaleString() : '-'}
|
||||
</td>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
)})}
|
||||
))}
|
||||
|
||||
{paginatedRows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={effectiveDimensions.length + (years.length * 2)} className="px-6 py-12 text-center text-slate-500 italic">
|
||||
No data matches your filters.
|
||||
</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
|
||||
{/* Pagination */}
|
||||
<div className="p-4 border-t border-border bg-slate-900/50 flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<div className="text-sm text-slate-500">
|
||||
Page {currentPage} of {totalPages || 1}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 rounded bg-slate-800 border border-border hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
||||
className="px-3 py-1 bg-slate-800 border border-slate-700 rounded text-sm text-slate-300 disabled:opacity-50 hover:bg-slate-700"
|
||||
>
|
||||
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"
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="px-3 py-1 bg-slate-800 border border-slate-700 rounded text-sm text-slate-300 disabled:opacity-50 hover:bg-slate-700"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import React, { ChangeEvent, useState } from 'react';
|
||||
import { UploadIcon } from './Icons';
|
||||
|
||||
@@ -85,13 +86,13 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
<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>
|
||||
<h3 className="font-semibold text-slate-200">Upload Local CSV</h3>
|
||||
<p className="text-xs text-slate-500 mt-1">Click to select file</p>
|
||||
</div>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".csv, .xlsx, .xls"
|
||||
accept=".csv"
|
||||
onChange={handleChange}
|
||||
disabled={isLoading}
|
||||
className="hidden"
|
||||
|
||||
@@ -56,8 +56,8 @@ export const FunnelIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const MoversIcon = () => (
|
||||
export const TrendingIcon = () => (
|
||||
<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" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18 9 11.25l4.306 4.307a11.95 11.95 0 0 1 5.814-5.519l2.74-1.22m0 0-5.94-2.28m5.94 2.28-2.28 5.941" />
|
||||
</svg>
|
||||
);
|
||||
@@ -1,198 +0,0 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ItemGrowthMetric } from '../types';
|
||||
|
||||
interface ItemGrowthTableProps {
|
||||
title: string;
|
||||
data: ItemGrowthMetric[];
|
||||
type: 'growth' | 'decline';
|
||||
periods: { current: string; previous: string };
|
||||
}
|
||||
|
||||
type SortConfig = { key: keyof ItemGrowthMetric | null; direction: 'asc' | 'desc' };
|
||||
|
||||
const ItemGrowthTable: React.FC<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,326 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { SalesRecord } from '../types';
|
||||
import { DownloadIcon } from './Icons';
|
||||
|
||||
interface TopMoversProps {
|
||||
data: SalesRecord[];
|
||||
}
|
||||
|
||||
type Metric = 'sellOut' | 'units';
|
||||
|
||||
interface SkuAggr {
|
||||
sku: string;
|
||||
title: string;
|
||||
line: string;
|
||||
previousValue: number;
|
||||
currentValue: number;
|
||||
diff: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
// Reusable Table Component
|
||||
const MoversTable: React.FC<{
|
||||
title: string;
|
||||
data: SkuAggr[];
|
||||
metric: Metric;
|
||||
previousYear: number;
|
||||
currentYear: number;
|
||||
type: 'growth' | 'decline';
|
||||
}> = ({ title, data, metric, previousYear, currentYear, type }) => {
|
||||
|
||||
const formatValue = (val: number) => {
|
||||
if (metric === 'sellOut') return `€${val.toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
|
||||
return val.toLocaleString();
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
// Helper to force Comma as thousands separator (US Locale)
|
||||
const formatForCSV = (val: number) => {
|
||||
return val.toLocaleString('en-US', {
|
||||
useGrouping: true,
|
||||
minimumFractionDigits: metric === 'sellOut' ? 2 : 0,
|
||||
maximumFractionDigits: metric === 'sellOut' ? 2 : 0,
|
||||
});
|
||||
};
|
||||
|
||||
// Prepare data for CSV
|
||||
const csvData = data.map((item, index) => ({
|
||||
Rank: index + 1,
|
||||
Title: item.title,
|
||||
SKU: item.sku,
|
||||
'Product Line': item.line,
|
||||
[`${previousYear} ${metric === 'sellOut' ? 'Sell Out' : 'Units'}`]: formatForCSV(item.previousValue),
|
||||
[`${currentYear} ${metric === 'sellOut' ? 'Sell Out' : 'Units'}`]: formatForCSV(item.currentValue),
|
||||
'Difference': formatForCSV(item.diff),
|
||||
'% Change': `${item.pct.toFixed(2)}%`
|
||||
}));
|
||||
|
||||
// Generate CSV string
|
||||
// @ts-ignore - Papa is loaded globally via CDN
|
||||
const csv = Papa.unparse(csvData);
|
||||
|
||||
// Create download link
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
const filename = `${title.replace(/\s+/g, '_')}_${currentYear}_vs_${previousYear}.csv`;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const colorClass = type === 'growth' ? 'text-emerald-400' : 'text-rose-400';
|
||||
const bgClass = type === 'growth' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400';
|
||||
const headerColor = type === 'growth' ? 'border-emerald-500/30' : 'border-rose-500/30';
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-border rounded-xl shadow-lg overflow-hidden flex flex-col h-full">
|
||||
<div className={`px-6 py-4 border-b ${headerColor} bg-slate-900/50 flex justify-between items-center`}>
|
||||
<h3 className={`text-lg font-bold flex items-center gap-2 ${colorClass}`}>
|
||||
{type === 'growth' ? '🚀 ' : '📉 '} {title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-medium border border-slate-700 transition-colors"
|
||||
title="Export to CSV"
|
||||
>
|
||||
<DownloadIcon />
|
||||
<span className="hidden sm:inline">Export</span>
|
||||
</button>
|
||||
<span className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Top 20</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-950 text-slate-400 uppercase text-xs font-semibold tracking-wider">
|
||||
<th className="px-6 py-3 border-b border-border w-16 text-center">Rank</th>
|
||||
<th className="px-6 py-3 border-b border-border">SKU Details</th>
|
||||
<th className="px-6 py-3 border-b border-border">Product Line</th>
|
||||
<th className="px-6 py-3 border-b border-border text-right">{previousYear}</th>
|
||||
<th className="px-6 py-3 border-b border-border text-right">{currentYear}</th>
|
||||
<th className="px-6 py-3 border-b border-border text-right">Diff</th>
|
||||
<th className="px-6 py-3 border-b border-border text-right">% Change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{data.map((item, index) => {
|
||||
const isPositive = item.diff >= 0;
|
||||
|
||||
return (
|
||||
<tr key={item.sku} className="hover:bg-slate-800/50 transition-colors group">
|
||||
<td className="px-6 py-3 text-center font-mono text-slate-500 font-bold">
|
||||
{index + 1}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white font-medium text-base truncate max-w-xs" title={item.title}>
|
||||
{item.title || 'Unknown Title'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 font-mono mt-0.5">SKU: {item.sku}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-slate-400">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-800 text-slate-300 border border-slate-700">
|
||||
{item.line}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-3 text-right text-slate-500">
|
||||
{formatValue(item.previousValue)}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-3 text-right font-bold text-slate-200 group-hover:text-white">
|
||||
{formatValue(item.currentValue)}
|
||||
</td>
|
||||
|
||||
<td className={`px-6 py-3 text-right font-medium ${isPositive ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
{isPositive ? '+' : ''}{formatValue(item.diff)}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-3 text-right">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-bold w-20 justify-center ${bgClass}`}>
|
||||
{isPositive ? '↑' : '↓'} {Math.abs(item.pct).toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{data.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-slate-500 italic">
|
||||
No records found matching this criteria.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TopMovers: React.FC<TopMoversProps> = ({ data }) => {
|
||||
const [metric, setMetric] = useState<Metric>('sellOut');
|
||||
const [viewMode, setViewMode] = useState<'growth' | 'decline'>('growth');
|
||||
|
||||
// 1. Determine comparison years from filtered data
|
||||
const { currentYear, previousYear, availableYears } = useMemo(() => {
|
||||
const years = Array.from(new Set(data.map(d => d.year))).sort((a: number, b: number) => b - a);
|
||||
return {
|
||||
currentYear: years[0],
|
||||
previousYear: years[1],
|
||||
availableYears: years
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
// 2. Aggregation Logic
|
||||
const { growers, decliners } = useMemo(() => {
|
||||
if (!currentYear || !previousYear) return { growers: [], decliners: [] };
|
||||
|
||||
// Map: SKU -> { currentVal, previousVal, metadata }
|
||||
const map = new Map<string, { current: number; previous: number; title: string; line: string }>();
|
||||
|
||||
data.forEach(row => {
|
||||
// Only care about the two comparison years
|
||||
if (row.year !== currentYear && row.year !== previousYear) return;
|
||||
|
||||
if (!map.has(row.sku)) {
|
||||
map.set(row.sku, { current: 0, previous: 0, title: row.title, line: row.line });
|
||||
}
|
||||
|
||||
const entry = map.get(row.sku)!;
|
||||
const value = metric === 'sellOut' ? row.sellOut : row.units;
|
||||
|
||||
if (row.year === currentYear) {
|
||||
entry.current += value;
|
||||
} else {
|
||||
entry.previous += value;
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to Array and Calculate Deltas
|
||||
const list: SkuAggr[] = [];
|
||||
map.forEach((val, sku) => {
|
||||
// Filter out items that have 0 in BOTH years (irrelevant)
|
||||
if (val.current === 0 && val.previous === 0) return;
|
||||
|
||||
const diff = val.current - val.previous;
|
||||
let pct = 0;
|
||||
if (val.previous !== 0) {
|
||||
pct = (diff / val.previous) * 100;
|
||||
} else if (val.current !== 0) {
|
||||
// Infinite growth (0 -> 100)
|
||||
pct = 100;
|
||||
}
|
||||
|
||||
list.push({
|
||||
sku,
|
||||
title: val.title,
|
||||
line: val.line,
|
||||
previousValue: val.previous,
|
||||
currentValue: val.current,
|
||||
diff,
|
||||
pct
|
||||
});
|
||||
});
|
||||
|
||||
// Separate and Sort
|
||||
const growers = list
|
||||
.filter(i => i.diff > 0)
|
||||
.sort((a, b) => b.diff - a.diff) // Descending by Growth
|
||||
.slice(0, 20);
|
||||
|
||||
const decliners = list
|
||||
.filter(i => i.diff < 0)
|
||||
.sort((a, b) => a.diff - b.diff) // Ascending by Decline (Most negative first)
|
||||
.slice(0, 20);
|
||||
|
||||
return { growers, decliners };
|
||||
|
||||
}, [data, metric, currentYear, previousYear]);
|
||||
|
||||
if (availableYears.length < 2) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 bg-slate-900 rounded-xl border border-border p-8">
|
||||
<h3 className="text-xl font-bold text-slate-300 mb-2">Insufficient Data for Comparison</h3>
|
||||
<p className="text-slate-500 text-center max-w-md">
|
||||
To see Top Movers, please ensure your filters include at least <b>two different years</b> (e.g., 2024 and 2025).
|
||||
</p>
|
||||
<p className="mt-4 text-xs text-slate-600">Current Years Available: {availableYears.join(', ') || 'None'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-7xl mx-auto pb-24 animate-fade-in">
|
||||
|
||||
{/* Controls Header */}
|
||||
<div className="bg-surface border border-border rounded-xl p-6 shadow-sm flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-blue-400 flex items-center gap-2">
|
||||
Analytics Overview
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Comparing Performance: <span className="font-mono text-indigo-300 font-bold">{previousYear}</span> vs <span className="font-mono text-indigo-300 font-bold">{currentYear}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-center">
|
||||
{/* Gainers / Losers Toggle */}
|
||||
<div className="bg-slate-900 p-1 rounded-lg border border-slate-800 flex text-sm">
|
||||
<button
|
||||
onClick={() => setViewMode('growth')}
|
||||
className={`px-4 py-2 rounded-md transition-colors font-medium flex items-center gap-2 ${viewMode === 'growth' ? 'bg-emerald-600/20 text-emerald-400 border border-emerald-500/50 shadow-sm' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<span>🚀 Top Gainers</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('decline')}
|
||||
className={`px-4 py-2 rounded-md transition-colors font-medium flex items-center gap-2 ${viewMode === 'decline' ? 'bg-rose-600/20 text-rose-400 border border-rose-500/50 shadow-sm' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<span>📉 Top Losers</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Metric Toggle */}
|
||||
<div className="bg-slate-900 p-1 rounded-lg border border-slate-800 flex text-sm">
|
||||
<button
|
||||
onClick={() => setMetric('sellOut')}
|
||||
className={`px-4 py-2 rounded-md transition-colors font-medium ${metric === 'sellOut' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
Sell Out (€)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMetric('units')}
|
||||
className={`px-4 py-2 rounded-md transition-colors font-medium ${metric === 'units' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
Units
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Single Active Table */}
|
||||
<MoversTable
|
||||
title={viewMode === 'growth' ? "Fastest Growing SKUs" : "Biggest Declining SKUs"}
|
||||
data={viewMode === 'growth' ? growers : decliners}
|
||||
metric={metric}
|
||||
previousYear={previousYear}
|
||||
currentYear={currentYear}
|
||||
type={viewMode}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopMovers;
|
||||
@@ -1,92 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { SalesRecord } from '../types';
|
||||
import { calculateItemMovers, getUniqueValues, generateItemMoversCSV } from '../services/dataProcessor'; // Import generateItemMoversCSV
|
||||
import ItemGrowthTable from './ItemGrowthTable';
|
||||
import { ExpandableCard } from './Dashboard'; // Re-use ExpandableCard from Dashboard
|
||||
|
||||
interface TopMoversPageProps {
|
||||
filteredData: SalesRecord[]; // Data already filtered by global customer, year, month, etc.
|
||||
}
|
||||
|
||||
const TopMoversPage: React.FC<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;
|
||||
+2
-1
@@ -48,7 +48,8 @@
|
||||
"react/": "https://aistudiocdn.com/react@^19.2.0/",
|
||||
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0",
|
||||
"recharts": "https://aistudiocdn.com/recharts@^3.5.0",
|
||||
"xlsx": "https://esm.sh/xlsx@0.18.5"
|
||||
"xlsx": "https://esm.sh/xlsx@^0.18.5",
|
||||
"papaparse": "https://esm.sh/papaparse@^5.5.3"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"@google/genai": "^1.30.0",
|
||||
"recharts": "^3.5.0",
|
||||
"xlsx": "0.18.5"
|
||||
"xlsx": "^0.18.5",
|
||||
"papaparse": "^5.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
|
||||
+324
-54
@@ -1,12 +1,13 @@
|
||||
import { SalesRecord, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types';
|
||||
import { SalesRecord, AdsRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types';
|
||||
import * as XLSX from 'xlsx';
|
||||
import Papa from 'papaparse';
|
||||
|
||||
// Helper to parse currency values handling both EU (1.234,56) and US/Standard (1,234.56 or 1234.56) formats
|
||||
const parseCurrency = (value: string): number => {
|
||||
if (!value) return 0;
|
||||
|
||||
// Remove currency symbol and whitespace
|
||||
let clean = value.replace(/[€\s]/g, '').trim();
|
||||
let clean = value.replace(/[€$£\s]/g, '').trim();
|
||||
|
||||
// HEURISTIC:
|
||||
// If it contains a comma, we assume it's likely European format (Decimal separator)
|
||||
@@ -14,17 +15,27 @@ const parseCurrency = (value: string): number => {
|
||||
// But given the context (DE data), comma is usually decimal.
|
||||
|
||||
// Case A: European Format (e.g., "277.179,09" or "50,00")
|
||||
if (clean.includes(',')) {
|
||||
// If it has dots (thousands), remove them
|
||||
clean = clean.replace(/\./g, '');
|
||||
// Replace decimal comma with dot
|
||||
if (clean.includes(',') && !clean.includes('.') && clean.indexOf(',') > clean.length - 4) {
|
||||
clean = clean.replace(',', '.');
|
||||
return parseFloat(clean);
|
||||
}
|
||||
else if (clean.includes(',') && clean.includes('.')) {
|
||||
// Mixed: 1.234,56
|
||||
if (clean.indexOf(',') > clean.indexOf('.')) {
|
||||
clean = clean.replace(/\./g, '').replace(',', '.');
|
||||
} else {
|
||||
// 1,234.56
|
||||
clean = clean.replace(/,/g, '');
|
||||
}
|
||||
return parseFloat(clean);
|
||||
}
|
||||
else if (clean.includes(',')) {
|
||||
// Likely EU decimal
|
||||
clean = clean.replace(',', '.');
|
||||
return parseFloat(clean);
|
||||
}
|
||||
|
||||
// Case B: Standard/US Format or Clean Number (e.g. "277179.09" or "1000")
|
||||
// Just remove any potential thousands separator commas (if any exist and we didn't catch them above)
|
||||
// and parse.
|
||||
clean = clean.replace(/,/g, '');
|
||||
const num = parseFloat(clean);
|
||||
|
||||
@@ -41,12 +52,38 @@ const parseUnits = (value: string): number => {
|
||||
|
||||
const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
// Mapping for Spanish Month Names
|
||||
const SPANISH_MONTHS: Record<string, string> = {
|
||||
'ene': 'Jan', 'enero': 'Jan',
|
||||
'feb': 'Feb', 'febrero': 'Feb',
|
||||
'mar': 'Mar', 'marzo': 'Mar',
|
||||
'abr': 'Apr', 'abril': 'Apr',
|
||||
'may': 'May', 'mayo': 'May',
|
||||
'jun': 'Jun', 'junio': 'Jun',
|
||||
'jul': 'Jul', 'julio': 'Jul',
|
||||
'ago': 'Aug', 'agosto': 'Aug',
|
||||
'sep': 'Sep', 'septiembre': 'Sep', 'set': 'Sep', 'setiembre': 'Sep',
|
||||
'oct': 'Oct', 'octubre': 'Oct',
|
||||
'nov': 'Nov', 'noviembre': 'Nov',
|
||||
'dic': 'Dec', 'diciembre': 'Dec'
|
||||
};
|
||||
|
||||
// Robust Month Normalizer
|
||||
const normalizeMonth = (rawMonth: string): string => {
|
||||
if (!rawMonth) return '';
|
||||
let m = rawMonth.trim();
|
||||
|
||||
// Handle numeric months "01", "1", "01-2023" (start with digits)
|
||||
// If it's a full date string like "2023-04-01" or "01/04/2023"
|
||||
if (m.includes('/') || m.includes('-')) {
|
||||
const date = new Date(m);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const monthIdx = date.getMonth();
|
||||
const yearShort = date.getFullYear().toString().slice(2);
|
||||
return `${MONTH_ORDER[monthIdx]}-${yearShort}`;
|
||||
}
|
||||
}
|
||||
|
||||
const numMatch = m.match(/^(\d{1,2})([^\d]|$)/);
|
||||
if (numMatch) {
|
||||
const num = parseInt(numMatch[1]);
|
||||
@@ -55,26 +92,37 @@ const normalizeMonth = (rawMonth: string): string => {
|
||||
|
||||
// Handle text months "Apr-23", "Apr 23", "April"
|
||||
// Extract first sequence of letters
|
||||
const alphaMatch = m.match(/([a-zA-Z]+)/);
|
||||
const alphaMatch = m.match(/([a-zA-Z\u00C0-\u00FF]+)/); // Include accented chars for Spanish
|
||||
if (alphaMatch) {
|
||||
m = alphaMatch[1];
|
||||
let alpha = alphaMatch[1].toLowerCase();
|
||||
|
||||
// Check Spanish mapping first
|
||||
if (SPANISH_MONTHS[alpha]) {
|
||||
m = SPANISH_MONTHS[alpha];
|
||||
} else {
|
||||
// Default to first 3 chars capitalize (English)
|
||||
if (alpha.length > 3) alpha = alpha.substring(0, 3);
|
||||
m = alpha.charAt(0).toUpperCase() + alpha.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Take first 3 characters
|
||||
if (m.length > 3) {
|
||||
m = m.substring(0, 3);
|
||||
// Try to grab year from original string to append (e.g. "Apr-23")
|
||||
const yearMatch = rawMonth.match(/(\d{2,4})/);
|
||||
if (yearMatch) {
|
||||
let y = yearMatch[1];
|
||||
if (y.length === 4) y = y.slice(2);
|
||||
// Only append if year is not part of the month name logic
|
||||
if (!m.includes('-')) {
|
||||
return `${m}-${y}`;
|
||||
}
|
||||
}
|
||||
// Capitalize first letter, lowercase rest
|
||||
m = m.charAt(0).toUpperCase() + m.slice(1).toLowerCase();
|
||||
|
||||
return m;
|
||||
};
|
||||
|
||||
// Robust CSV Column Value Extractor
|
||||
// Handles case-insensitivity, trimming, multiple potential header aliases, AND ignores empty values to find fallbacks.
|
||||
const getColumnValue = (row: any, aliases: string[]): string => {
|
||||
const rowKeys = Object.keys(row);
|
||||
// Create a map of normalized keys in the row to the actual keys
|
||||
const normalizedRowKeys: Record<string, string> = {};
|
||||
rowKeys.forEach(k => {
|
||||
normalizedRowKeys[k.trim().toLowerCase()] = k;
|
||||
@@ -87,8 +135,6 @@ const getColumnValue = (row: any, aliases: string[]): string => {
|
||||
const val = row[actualKey];
|
||||
if (val !== undefined && val !== null) {
|
||||
const strVal = String(val).trim();
|
||||
// CRITICAL FIX: Only return if the value is NOT empty.
|
||||
// This allows falling back to the next alias if the first matching column exists but is empty.
|
||||
if (strVal.length > 0) {
|
||||
return strVal;
|
||||
}
|
||||
@@ -98,37 +144,38 @@ const getColumnValue = (row: any, aliases: string[]): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
// Extracted Mapping Function
|
||||
// --- SALES / SELL OUT MAPPING ---
|
||||
|
||||
const mapRowToRecord = (row: any, index: number): SalesRecord => {
|
||||
const customer = getColumnValue(row, ['NEW CUSTOMER', 'Customer', 'Client', 'Account', 'Partner', 'COUNTRY', 'Country', 'Market']) || 'Unknown';
|
||||
const yearStr = getColumnValue(row, ['YEAR', 'Year', 'D']);
|
||||
const year = parseInt(yearStr) || 0;
|
||||
// Sanitize year string before parsing (remove commas/dots e.g. "2,023")
|
||||
let year = parseInt(yearStr.replace(/[,.]/g, '')) || 0;
|
||||
const monthStr = getColumnValue(row, ['MONTH', 'Month', 'Period']);
|
||||
const month = normalizeMonth(monthStr);
|
||||
|
||||
// BACKFILL YEAR if missing but present in Month (e.g. "Apr-23")
|
||||
if (year === 0 && month.includes('-')) {
|
||||
const parts = month.split('-');
|
||||
if (parts.length === 2) {
|
||||
const yPart = parts[1];
|
||||
// assume 20xx for 2 digits
|
||||
if (yPart.length === 2) year = 2000 + parseInt(yPart);
|
||||
else if (yPart.length === 4) year = parseInt(yPart);
|
||||
}
|
||||
}
|
||||
|
||||
const weekStr = getColumnValue(row, ['WEEK', 'Week', 'CW', 'Semana', 'KW', 'E']);
|
||||
const weekNum = weekStr ? parseInt(weekStr.replace(/cw/i, '').trim(), 10) : NaN;
|
||||
const week = isNaN(weekNum) ? undefined : weekNum;
|
||||
const line = getColumnValue(row, ['LINE', 'Line', 'Product Line']) || 'Other';
|
||||
const line = getColumnValue(row, ['LINE', 'Line', 'Product Line']) || 'Unassigned';
|
||||
|
||||
// Updated ASIN priority list based on user feedback
|
||||
const asin = getColumnValue(row, [
|
||||
'CUSTOMER REFERENCE',
|
||||
'AMAZON ASIN',
|
||||
'ASIN',
|
||||
'Asin',
|
||||
'PRODUCT ID',
|
||||
'ITEM IDENTIFIER',
|
||||
'ASIN NO.',
|
||||
'Product ASIN',
|
||||
'IDENTIFIER'
|
||||
'CUSTOMER REFERENCE', 'AMAZON ASIN', 'ASIN', 'Asin', 'PRODUCT ID', 'ITEM IDENTIFIER', 'ASIN NO.', 'Product ASIN', 'IDENTIFIER'
|
||||
]);
|
||||
|
||||
const sku = getColumnValue(row, ['RAW ARTICLE NO.', 'SKU', 'Sku', 'Item No']);
|
||||
|
||||
// Prioritize 'Title' column, fallback to 'Article Name' columns
|
||||
const title = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Title', 'TITLE', 'Product Title', 'Article Name', 'ArticleName']);
|
||||
|
||||
// Legacy/Backup field
|
||||
const articleName = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Article Name', 'ArticleName', 'Title']);
|
||||
|
||||
const unitsRaw = getColumnValue(row, ['UNITS', 'Units', 'Quantity', 'Qty']);
|
||||
@@ -152,24 +199,24 @@ const mapRowToRecord = (row: any, index: number): SalesRecord => {
|
||||
|
||||
export const processCSV = (fileOrContent: File | string): Promise<SalesRecord[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// @ts-ignore - PapaParse is loaded globally via CDN
|
||||
// @ts-ignore
|
||||
Papa.parse(fileOrContent, {
|
||||
header: true,
|
||||
// delimiter: ";", // Allow auto-detect
|
||||
skipEmptyLines: true,
|
||||
complete: (results: any) => {
|
||||
try {
|
||||
const data: SalesRecord[] = results.data.map((row: any, index: number) => {
|
||||
return mapRowToRecord(row, index);
|
||||
}).filter((r: SalesRecord) => r.year !== 2022 && r.line && r.line !== 'Other'); // Validation: Exclude 2022 and require line
|
||||
})
|
||||
// Relaxed filtering: Only exclude rows with absolutely no year info even after backfill
|
||||
.filter((r: SalesRecord) => r.year > 0);
|
||||
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
},
|
||||
error: (error: any) => {
|
||||
reject(error);
|
||||
}
|
||||
error: (error: any) => reject(error)
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -180,15 +227,13 @@ export const processExcel = async (file: File): Promise<SalesRecord[]> => {
|
||||
const workbook = XLSX.read(arrayBuffer);
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
|
||||
// Convert to JSON
|
||||
// raw: false attempts to format the cell (e.g. dates), but for robustness we often prefer raw values or defval
|
||||
// Using { defval: "" } ensures empty cells are present as empty strings if needed, but key logic handles missing keys.
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
|
||||
|
||||
const data: SalesRecord[] = jsonData.map((row: any, index: number) => {
|
||||
return mapRowToRecord(row, index);
|
||||
}).filter((r: SalesRecord) => r.year !== 2022 && r.line && r.line !== 'Other');
|
||||
})
|
||||
// Relaxed filtering
|
||||
.filter((r: SalesRecord) => r.year > 0);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
@@ -197,14 +242,234 @@ export const processExcel = async (file: File): Promise<SalesRecord[]> => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- ADS DATA MAPPING ---
|
||||
|
||||
const mapCountryToMarketplace = (country: string): string => {
|
||||
const c = country.toLowerCase().trim();
|
||||
if (c.includes('germany') || c.includes('deutschland')) return 'AMAZON DE';
|
||||
if (c.includes('spain') || c.includes('espana') || c.includes('españa')) return 'AMAZON ES';
|
||||
if (c.includes('france')) return 'AMAZON FR';
|
||||
if (c.includes('italy') || c.includes('italia')) return 'AMAZON IT';
|
||||
if (c.includes('kingdom') || c.includes('uk') || c === 'gb') return 'AMAZON UK';
|
||||
if (c.includes('netherlands') || c.includes('nederland') || c.includes('holland')) return 'AMAZON NL';
|
||||
if (c.includes('sweden')) return 'AMAZON SE';
|
||||
if (c.includes('poland')) return 'AMAZON PL';
|
||||
if (c.includes('belgium')) return 'AMAZON BE';
|
||||
if (c.includes('turkey')) return 'AMAZON TR';
|
||||
return country.toUpperCase(); // Fallback
|
||||
};
|
||||
|
||||
export const processAdsCSV = (file: File): Promise<AdsRecord[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// @ts-ignore
|
||||
Papa.parse(file, {
|
||||
header: false, // Index-based mapping (A=0, B=1...)
|
||||
skipEmptyLines: true,
|
||||
complete: (results: any) => {
|
||||
try {
|
||||
const data: AdsRecord[] = [];
|
||||
const rows = results.data;
|
||||
const len = rows.length;
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const row = rows[i];
|
||||
if (!Array.isArray(row) || row.length < 12) continue;
|
||||
|
||||
// Check header row (Column A: Country)
|
||||
const c0 = String(row[0]).trim();
|
||||
if (c0.toLowerCase() === 'country' || c0.toLowerCase() === 'marketplace') continue;
|
||||
|
||||
// Map by Column Index (A=0, B=1... L=11)
|
||||
const countryRaw = row[0];
|
||||
const monthRaw = row[1];
|
||||
const asin = row[2];
|
||||
const costRaw = row[3];
|
||||
const clicksRaw = row[4];
|
||||
const impressionsRaw = row[5];
|
||||
// G, H, I, J unused/calculated
|
||||
const unitsRaw = row[10]; // K
|
||||
const salesRaw = row[11]; // L
|
||||
|
||||
if (!asin || !countryRaw) continue;
|
||||
|
||||
data.push({
|
||||
country: mapCountryToMarketplace(String(countryRaw)),
|
||||
month: normalizeMonth(String(monthRaw)),
|
||||
asin: String(asin).trim(),
|
||||
cost: parseCurrency(String(costRaw)),
|
||||
clicks: parseUnits(String(clicksRaw)),
|
||||
impressions: parseUnits(String(impressionsRaw)),
|
||||
attributedSales30d: parseCurrency(String(salesRaw)),
|
||||
attributedUnits30d: parseUnits(String(unitsRaw)),
|
||||
});
|
||||
}
|
||||
resolve(data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
},
|
||||
error: (error: any) => reject(error)
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const processAdsExcel = async (file: File): Promise<AdsRecord[]> => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer);
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
|
||||
// Use header: 'A' to strictly map columns by index letter as requested
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: "A", defval: "" });
|
||||
|
||||
const data: AdsRecord[] = jsonData.map((row: any) => {
|
||||
// Check if it's a header row
|
||||
if (row['A'] === 'Country' && (row['C'] === 'ASIN' || row['C'] === 'Asin')) return null;
|
||||
|
||||
// Map by Column Letter as requested
|
||||
// A: Country, B: Month, C: ASIN, D: Cost, E: Clicks, F: Impressions
|
||||
// G: CPC, H: CTR, I: ACOS, J: Conversions
|
||||
// K: Units, L: Sales
|
||||
const countryRaw = row['A'];
|
||||
const monthRaw = row['B'];
|
||||
const asin = row['C'];
|
||||
const costRaw = row['D'];
|
||||
const clicksRaw = row['E'];
|
||||
const impressionsRaw = row['F'];
|
||||
const unitsRaw = row['K'];
|
||||
const salesRaw = row['L'];
|
||||
|
||||
if (!asin || !countryRaw) return null;
|
||||
|
||||
return {
|
||||
country: mapCountryToMarketplace(String(countryRaw)),
|
||||
month: normalizeMonth(String(monthRaw)),
|
||||
asin: String(asin).trim(),
|
||||
cost: parseCurrency(String(costRaw)),
|
||||
clicks: parseUnits(String(clicksRaw)),
|
||||
impressions: parseUnits(String(impressionsRaw)),
|
||||
attributedSales30d: parseCurrency(String(salesRaw)),
|
||||
attributedUnits30d: parseUnits(String(unitsRaw)),
|
||||
};
|
||||
}).filter((r): r is AdsRecord => r !== null);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Error processing Ads Excel:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// --- DATA MERGING ---
|
||||
|
||||
export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecord[]): CombinedKPIs[] => {
|
||||
// 1. Index Ads Data for fast lookup: Key = ASIN + Marketplace + Month
|
||||
const adsMap = new Map<string, AdsRecord>();
|
||||
|
||||
adsData.forEach(ad => {
|
||||
const key = `${ad.asin.toUpperCase()}|${ad.country.toUpperCase()}|${ad.month}`;
|
||||
// If duplicates exist (e.g. multiple campaigns for same ASIN), sum them up
|
||||
if (adsMap.has(key)) {
|
||||
const existing = adsMap.get(key)!;
|
||||
existing.cost += ad.cost;
|
||||
existing.clicks += ad.clicks;
|
||||
existing.impressions += ad.impressions;
|
||||
existing.attributedSales30d += ad.attributedSales30d;
|
||||
existing.attributedUnits30d += ad.attributedUnits30d;
|
||||
} else {
|
||||
adsMap.set(key, { ...ad });
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Iterate Sales Data and merge
|
||||
const mergedData: CombinedKPIs[] = salesData.map(sale => {
|
||||
const key = `${sale.asin.toUpperCase()}|${sale.customer.toUpperCase()}|${sale.month}`;
|
||||
const adData = adsMap.get(key) || {
|
||||
country: sale.customer,
|
||||
month: sale.month,
|
||||
asin: sale.asin,
|
||||
cost: 0,
|
||||
clicks: 0,
|
||||
impressions: 0,
|
||||
attributedSales30d: 0,
|
||||
attributedUnits30d: 0
|
||||
};
|
||||
|
||||
const salesTotal = sale.sellOut;
|
||||
const salesAds = adData.attributedSales30d;
|
||||
// Logic: Organic = Total - Ads. Max(0) to avoid negative if attribution window logic differs vs finance dates
|
||||
const salesOrganic = Math.max(0, salesTotal - salesAds);
|
||||
|
||||
const unitsTotal = sale.units;
|
||||
const unitsAds = adData.attributedUnits30d;
|
||||
const unitsOrganic = Math.max(0, unitsTotal - unitsAds);
|
||||
|
||||
// KPIs
|
||||
const acos = salesAds > 0 ? (adData.cost / salesAds) * 100 : 0;
|
||||
const tacos = salesTotal > 0 ? (adData.cost / salesTotal) * 100 : 0;
|
||||
const roas = adData.cost > 0 ? salesAds / adData.cost : 0;
|
||||
const ctr = adData.impressions > 0 ? (adData.clicks / adData.impressions) * 100 : 0;
|
||||
const cpc = adData.clicks > 0 ? adData.cost / adData.clicks : 0;
|
||||
// CVR (Units / Clicks)
|
||||
const cvrUnits = adData.clicks > 0 ? (unitsAds / adData.clicks) * 100 : 0;
|
||||
|
||||
const paidSalesShare = salesTotal > 0 ? (salesAds / salesTotal) * 100 : 0;
|
||||
const organicSalesShare = salesTotal > 0 ? (salesOrganic / salesTotal) * 100 : 0;
|
||||
|
||||
return {
|
||||
id: sale.id,
|
||||
marketplace: sale.customer,
|
||||
month: sale.month,
|
||||
year: sale.year,
|
||||
asin: sale.asin,
|
||||
title: sale.title,
|
||||
line: sale.line,
|
||||
sku: sale.sku,
|
||||
|
||||
salesTotal,
|
||||
unitsTotal,
|
||||
|
||||
salesAds,
|
||||
unitsAds,
|
||||
cost: adData.cost,
|
||||
clicks: adData.clicks,
|
||||
impressions: adData.impressions,
|
||||
|
||||
salesOrganic,
|
||||
unitsOrganic,
|
||||
|
||||
paidSalesShare,
|
||||
organicSalesShare,
|
||||
|
||||
acos,
|
||||
tacos,
|
||||
roas,
|
||||
ctr,
|
||||
cpc,
|
||||
cvrUnits
|
||||
};
|
||||
});
|
||||
|
||||
return mergedData;
|
||||
};
|
||||
|
||||
|
||||
// --- EXISTING HELPERS ---
|
||||
|
||||
export const filterData = (data: SalesRecord[], filters: FilterState): SalesRecord[] => {
|
||||
return data.filter(item => {
|
||||
// Item month is already normalized
|
||||
const recordMonth = item.month;
|
||||
// 1. Month Logic: Handle "Apr-23" matching "Apr" filter
|
||||
const recordMonth = item.month; // e.g. "Apr-23"
|
||||
const pureMonth = recordMonth.split('-')[0]; // "Apr"
|
||||
|
||||
// 2. Filter Checks
|
||||
const customerMatch = filters.customer.length === 0 || filters.customer.includes(item.customer);
|
||||
const yearMatch = filters.year.length === 0 || filters.year.includes(item.year.toString());
|
||||
const monthMatch = filters.month.length === 0 || filters.month.includes(recordMonth);
|
||||
|
||||
// Check match against pure month ("Apr") OR full month ("Apr-23") just in case filters evolve
|
||||
const monthMatch = filters.month.length === 0 || filters.month.includes(pureMonth) || filters.month.includes(recordMonth);
|
||||
|
||||
const lineMatch = filters.line.length === 0 || filters.line.includes(item.line);
|
||||
const asinMatch = filters.asin.length === 0 || filters.asin.includes(item.asin);
|
||||
const skuMatch = filters.sku.length === 0 || filters.sku.includes(item.sku);
|
||||
@@ -227,17 +492,22 @@ const calculateSeasonality = (data: SalesRecord[]): { seasonality: SeasonalityPo
|
||||
|
||||
data.forEach(record => {
|
||||
const monthName = record.month;
|
||||
// Extract year from record.month if it's in Format "Mon-YY", else use record.year
|
||||
// record.year is numeric, record.month is "Apr-23".
|
||||
const yearStr = record.year.toString();
|
||||
yearsSet.add(yearStr);
|
||||
|
||||
if (seasonalityMap.has(monthName)) {
|
||||
// We need to match month name purely (Jan, Feb) for the X Axis, ignoring year
|
||||
const pureMonth = monthName.split('-')[0];
|
||||
|
||||
if (seasonalityMap.has(pureMonth)) {
|
||||
// Sell Out
|
||||
const entrySO = seasonalityMap.get(monthName)!;
|
||||
const entrySO = seasonalityMap.get(pureMonth)!;
|
||||
const currentValSO = (entrySO[yearStr] as number) || 0;
|
||||
entrySO[yearStr] = currentValSO + record.sellOut;
|
||||
|
||||
// Units
|
||||
const entryUnits = seasonalityUnitsMap.get(monthName)!;
|
||||
const entryUnits = seasonalityUnitsMap.get(pureMonth)!;
|
||||
const currentValUnits = (entryUnits[yearStr] as number) || 0;
|
||||
entryUnits[yearStr] = currentValUnits + record.units;
|
||||
}
|
||||
@@ -629,7 +899,7 @@ export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['tit
|
||||
}
|
||||
|
||||
const row = map.get(key)!;
|
||||
const monthPart = record.month;
|
||||
const monthPart = record.month.split('-')[0]; // Handle "Apr-23" -> "Apr"
|
||||
const monthIdx = MONTH_ORDER.indexOf(monthPart);
|
||||
const yearStr = record.year.toString();
|
||||
|
||||
|
||||
+55
-46
@@ -2,39 +2,49 @@
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
import { AggregatedData } from "../types";
|
||||
|
||||
// Declare process to avoid TypeScript errors without causing aggressive bundler shims
|
||||
declare const process: any;
|
||||
|
||||
const SYSTEM_INSTRUCTION = `
|
||||
You are an expert Data Analyst Assistant for "Craze Analytix".
|
||||
You have access to a structured dataset of sales performance including Revenue (Sell Out), Units, Product Lines, and Seasonality.
|
||||
You are a senior data analyst assistant for a retail dashboard called "Craze Analytix".
|
||||
You have access to a detailed report of the currently filtered sales data.
|
||||
The data includes Sell Out (Revenue in €), Units Sold, Product Lines, Customers/Markets, and Seasonality trends.
|
||||
|
||||
Your Capabilities:
|
||||
1. **Analyze Trends**: Use the provided Seasonality and Yearly Breakdown data.
|
||||
2. **Perform Calculations**: You have access to detailed Product Line totals. You MUST calculate growth percentages, market shares, and sums dynamically if the user asks.
|
||||
3. **Compare**: Compare performance between years (e.g., 2024 vs 2025).
|
||||
|
||||
Rules:
|
||||
- If the user asks for a calculation (e.g., "What is the % share of Line X?"), perform the math using the provided numbers.
|
||||
- Always format currency as € (e.g., €1,200) and units with 'u' or 'units' (e.g., 500 units).
|
||||
- Be concise but insightful. Point out significant growth or decline.
|
||||
- If data is missing for a specific query, state clearly that it is not in the current filtered view.
|
||||
Your goal is to answer user questions specific to the provided data.
|
||||
- If asked about "Trends" or "Seasonality", look at the Monthly Seasonality section.
|
||||
- If asked about "Growth" or "Decline", look at the Top/Bottom Movers sections.
|
||||
- If asked about specific Product Lines, look at the Product Line Breakdown.
|
||||
- Always format numbers clearly (e.g., "€1.2M", "€5,200", "15k units").
|
||||
- When comparing years, calculate the percentage difference if not explicitly provided.
|
||||
- Keep answers professional, concise, and business-focused.
|
||||
`;
|
||||
|
||||
// Helper to get API key safely
|
||||
const getApiKey = (): string | undefined => {
|
||||
try {
|
||||
return process.env.API_KEY;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (val: number) => `€${val.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}`;
|
||||
const formatUnits = (val: number) => `${val.toLocaleString()} units`;
|
||||
|
||||
export const queryGemini = async (
|
||||
apiKey: string,
|
||||
question: string,
|
||||
context: AggregatedData,
|
||||
filteredRecordCount: number
|
||||
): Promise<string> => {
|
||||
|
||||
const apiKey = getApiKey();
|
||||
|
||||
if (!apiKey) {
|
||||
return "Please provide your Gemini API Key in the settings to enable the AI assistant.";
|
||||
return "API Key is missing. Please configure your environment variables (API_KEY) or check your .env file.";
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure the key is clean of whitespace
|
||||
const ai = new GoogleGenAI({ apiKey: apiKey.trim() });
|
||||
const ai = new GoogleGenAI({ apiKey });
|
||||
|
||||
// --- CONTEXT GENERATION ---
|
||||
// We construct a structured report mirroring the dashboard charts
|
||||
@@ -46,62 +56,64 @@ export const queryGemini = async (
|
||||
.join('\n');
|
||||
|
||||
// 2. Seasonality (Line Chart Data)
|
||||
// We simplify this to a CSV-like list for the AI to parse trends
|
||||
const seasonalitySummary = context.seasonality.map(p => {
|
||||
// Extract values for each year in the point
|
||||
const yearValues = context.availableYears.map(y => `${y}: ${formatCurrency(p[y] as number || 0)}`).join(', ');
|
||||
return ` - ${p.name}: [${yearValues}]`;
|
||||
}).join('\n');
|
||||
|
||||
// 3. Growth/Decline
|
||||
// 3. Top Movers (Growth Table) - Limit to Top 10
|
||||
const growthSummary = context.topMovers.slice(0, 10).map(m =>
|
||||
` - ${m.line}: +€${m.sellOutGrowthValue.toLocaleString()} (${m.sellOutGrowthPercentage.toFixed(1)}%)`
|
||||
).join('\n');
|
||||
|
||||
// 4. Declining Movers (Decline Table) - Limit to Top 10
|
||||
const declineSummary = context.bottomMovers.slice(0, 10).map(m =>
|
||||
` - ${m.line}: -€${Math.abs(m.sellOutGrowthValue).toLocaleString()} (${m.sellOutGrowthPercentage.toFixed(1)}%)`
|
||||
).join('\n');
|
||||
|
||||
// 4. DETAILED BREAKDOWN (For Calculations)
|
||||
// We provide a JSON-like structure of ALL top product lines so the AI can compute shares/totals.
|
||||
// We limit this to top 100 to avoid token limits, which covers most relevant data.
|
||||
const detailedLines = context.byLine.slice(0, 100).map(l => ({
|
||||
name: l.name,
|
||||
revenue: l.value,
|
||||
units: l.units
|
||||
}));
|
||||
// 5. Product Lines Overview (Bar Charts) - Limit to Top 50 to save tokens but give depth
|
||||
const topLinesSummary = context.byLine.slice(0, 50).map((l, i) =>
|
||||
` ${i+1}. ${l.name}: ${formatCurrency(l.value)} | ${formatUnits(l.units)}`
|
||||
).join('\n');
|
||||
|
||||
// 6. Customer Distribution (Customer Chart)
|
||||
const customerSummary = context.byCustomer.map(c =>
|
||||
` - ${c.name}: ${formatCurrency(c.value)}`
|
||||
).join('\n');
|
||||
|
||||
const fullReport = `
|
||||
REPORT CONTEXT (Based on Current Filters):
|
||||
------------------------------------------
|
||||
GLOBAL METRICS:
|
||||
REPORT CONTEXT:
|
||||
----------------
|
||||
GLOBAL TOTALS:
|
||||
Total Sell Out: ${formatCurrency(context.totalSellOut)}
|
||||
Total Units: ${formatUnits(context.totalUnits)}
|
||||
Records Analyzed: ${filteredRecordCount}
|
||||
Years Available: ${context.availableYears.join(', ')}
|
||||
|
||||
YEARLY TOTALS:
|
||||
YEARLY BREAKDOWN:
|
||||
${yearlySummary}
|
||||
|
||||
MONTHLY TRENDS (Seasonality):
|
||||
MONTHLY SEASONALITY (Revenue Trends):
|
||||
${seasonalitySummary}
|
||||
|
||||
TOP PERFORMERS (Growth YoY):
|
||||
FASTEST GROWING LINES (Year-over-Year):
|
||||
${growthSummary}
|
||||
|
||||
WORST PERFORMERS (Decline YoY):
|
||||
DECLINING LINES (Year-over-Year):
|
||||
${declineSummary}
|
||||
|
||||
DETAILED PRODUCT LINE DATA (Use this for specific calculations):
|
||||
${JSON.stringify(detailedLines, null, 2)}
|
||||
TOP PRODUCT LINES (Revenue & Units):
|
||||
${topLinesSummary}
|
||||
|
||||
PERFORMANCE BY CUSTOMER:
|
||||
${customerSummary}
|
||||
`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-3-pro-preview', // Updated to the latest capable model for complex reasoning
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: `Context Data:\n${fullReport}\n\nUser Question: ${question}` }]
|
||||
}
|
||||
],
|
||||
model: 'gemini-2.5-flash',
|
||||
contents: `Context Data:\n${fullReport}\n\nUser Question: ${question}`,
|
||||
config: {
|
||||
systemInstruction: SYSTEM_INSTRUCTION,
|
||||
}
|
||||
@@ -111,11 +123,8 @@ ${JSON.stringify(detailedLines, null, 2)}
|
||||
} catch (error: any) {
|
||||
console.error("Gemini API Error:", error);
|
||||
|
||||
if (error.message && error.message.includes("403")) {
|
||||
return "Error 403: Invalid API Key. Please check your key in the settings.";
|
||||
}
|
||||
if (error.message && error.message.includes("429")) {
|
||||
return "Error 429: Quota exceeded. You are sending too many requests.";
|
||||
if (error.message && error.message.includes("Not implemented on this platform")) {
|
||||
return "System Error: The AI SDK detected a platform mismatch.";
|
||||
}
|
||||
|
||||
return `Error: ${error.message || "An unexpected error occurred while analyzing the data."}`;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
|
||||
export interface SalesRecord {
|
||||
id: string;
|
||||
customer: string;
|
||||
@@ -25,7 +24,7 @@ export interface FilterState {
|
||||
title: string[]; // Added Title filter
|
||||
}
|
||||
|
||||
export interface LineGrowthMetric { // Renamed from GrowthMetric
|
||||
export interface GrowthMetric {
|
||||
line: string;
|
||||
currentYearSellOut: number;
|
||||
previousYearSellOut: number;
|
||||
@@ -38,23 +37,23 @@ export interface LineGrowthMetric { // Renamed from GrowthMetric
|
||||
unitsGrowthPercentage: number;
|
||||
}
|
||||
|
||||
export type LineGrowthMetric = GrowthMetric;
|
||||
|
||||
export interface ItemGrowthMetric {
|
||||
sku: string;
|
||||
asin: string;
|
||||
title: string;
|
||||
line: string; // Keep line for context
|
||||
line: string;
|
||||
currentYearSellOut: number;
|
||||
previousYearSellOut: number;
|
||||
sellOutGrowthValue: number;
|
||||
sellOutGrowthPercentage: number;
|
||||
|
||||
currentYearUnits: number;
|
||||
previousYearUnits: number;
|
||||
unitsGrowthValue: number;
|
||||
unitsGrowthPercentage: number;
|
||||
}
|
||||
|
||||
|
||||
export interface SeasonalityPoint {
|
||||
name: string; // "Jan", "Feb", etc.
|
||||
[year: string]: number | string; // Dynamic keys for years: "2023": 500, "2024": 600
|
||||
@@ -75,8 +74,8 @@ export interface AggregatedData {
|
||||
seasonality: SeasonalityPoint[];
|
||||
seasonalityUnits: SeasonalityPoint[];
|
||||
availableYears: string[];
|
||||
topMovers: LineGrowthMetric[]; // Now uses LineGrowthMetric
|
||||
bottomMovers: LineGrowthMetric[]; // Now uses LineGrowthMetric
|
||||
topMovers: GrowthMetric[];
|
||||
bottomMovers: GrowthMetric[];
|
||||
comparisonPeriods: { current: string; previous: string };
|
||||
topLinesSplit: YearlySplitData[];
|
||||
byCustomerSplit: YearlySplitData[];
|
||||
@@ -125,3 +124,47 @@ export interface ComparisonTimeSeriesPoint {
|
||||
name: string; // "W1", "W2", etc.
|
||||
[key: string]: number | string; // Dynamic keys like "2023_sellOut", "2024_units"
|
||||
}
|
||||
|
||||
export interface AdsRecord {
|
||||
country: string;
|
||||
month: string;
|
||||
asin: string;
|
||||
cost: number;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
attributedSales30d: number;
|
||||
attributedUnits30d: number;
|
||||
}
|
||||
|
||||
export interface CombinedKPIs {
|
||||
id: string;
|
||||
marketplace: string;
|
||||
month: string;
|
||||
year: number;
|
||||
asin: string;
|
||||
title: string;
|
||||
line: string;
|
||||
sku: string;
|
||||
|
||||
salesTotal: number;
|
||||
unitsTotal: number;
|
||||
|
||||
salesAds: number;
|
||||
unitsAds: number;
|
||||
cost: number;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
|
||||
salesOrganic: number;
|
||||
unitsOrganic: number;
|
||||
|
||||
paidSalesShare: number;
|
||||
organicSalesShare: number;
|
||||
|
||||
acos: number;
|
||||
tacos: number;
|
||||
roas: number;
|
||||
ctr: number;
|
||||
cpc: number;
|
||||
cvrUnits: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user