feat: Initialize Craze Analytix project structure

Sets up the project with Vite, React, Tailwind CSS, Gemini AI integration, and necessary dependencies for data analysis. Includes initial configuration for TypeScript, Tailwind, and project metadata.
This commit is contained in:
Christian
2025-12-11 11:25:26 +01:00
parent 563e6110ce
commit 9ba63ab8f8
23 changed files with 4481 additions and 8 deletions
+247
View File
@@ -0,0 +1,247 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChatIcon, CloseIcon, SendIcon } from './Icons';
import { ChatMessage } from '../types';
interface AIChatProps {
onSendMessage: (text: string) => Promise<string>;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
apiKey: string;
onApiKeyChange: (key: string) => void;
}
const ModelMessage: React.FC<{ text: string }> = ({ text }) => {
const elements: React.ReactNode[] = [];
let listItems: React.ReactNode[] = [];
const flushList = () => {
if (listItems.length > 0) {
elements.push(
<ul key={`ul-${elements.length}`} className="list-disc list-inside space-y-1 my-2 pl-2">
{listItems}
</ul>
);
listItems = [];
}
};
const parseBold = (content: string, keyPrefix: string) => {
const parts = content.split(/(\*\*.*?\*\*)/g);
return parts.map((part, i) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={`${keyPrefix}-${i}`}>{part.slice(2, -2)}</strong>;
}
return part;
});
}
text.split('\n').forEach((line, index) => {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('* ') || trimmedLine.startsWith('- ')) {
const content = trimmedLine.substring(2);
listItems.push(<li key={index}>{parseBold(content, `li-${index}`)}</li>);
} else {
flushList();
if (line.trim() !== '') {
elements.push(
<p key={`p-${index}`} className="my-1">
{parseBold(line, `p-${index}`)}
</p>
);
}
}
});
flushList(); // Flush any remaining list items at the end
return <>{elements.length > 0 ? elements : <p>{text}</p>}</>;
};
const SettingsIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 1 1 0-9h.75c.704 0 1.402-.03 2.09-.09a1.692 1.692 0 0 1 1.624 1.374c.11 1.054.547 2.028 1.218 2.822.67.793 1.644 1.23 2.697 1.34a1.694 1.694 0 0 1 1.374 1.625c.06.688.09 1.386.09 2.09v.75a4.5 4.5 0 1 1-9 0v-.75c0-.704-.03-1.402-.09-2.09a1.692 1.692 0 0 1-1.374-1.624 11.264 11.264 0 0 0-1.34-2.698 11.263 11.263 0 0 0-2.822-1.217A1.692 1.692 0 0 1 10.34 15.84Z" />
</svg>
);
const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen, apiKey, onApiKeyChange }) => {
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'model', text: 'Hello! I am your AI Data Analyst. I can answer questions about your data, analyze trends, and perform calculations.', timestamp: new Date() }
]);
const [input, setInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [showConfig, setShowConfig] = useState(!apiKey);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
if (isOpen) scrollToBottom();
}, [messages, isOpen, showConfig]);
useEffect(() => {
// If no API key is present when opened, show config
if (!apiKey) setShowConfig(true);
}, [apiKey]);
const handleSend = async () => {
if (!input.trim() || !apiKey) return;
const userMsg: ChatMessage = { role: 'user', text: input, timestamp: new Date() };
setMessages(prev => [...prev, userMsg]);
setInput('');
setIsTyping(true);
const responseText = await onSendMessage(userMsg.text);
setIsTyping(false);
setMessages(prev => [...prev, { role: 'model', text: responseText, timestamp: new Date() }]);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleSaveKey = (e: React.FormEvent) => {
e.preventDefault();
// Input value is already bound to parent state via local var, but we use form submission to switch view
if (apiKey) setShowConfig(false);
};
return (
<>
{/* Trigger Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`fixed bottom-6 right-6 z-50 p-4 rounded-full shadow-2xl transition-all duration-300 hover:scale-110
${isOpen ? 'bg-slate-700 rotate-90 opacity-0 pointer-events-none' : 'bg-primary text-white rotate-0 opacity-100'}`}
>
<ChatIcon />
</button>
{/* Chat Window */}
<div
className={`fixed z-50 bg-slate-900 border border-border shadow-2xl transition-all duration-300 flex flex-col overflow-hidden
${isOpen
? 'bottom-6 right-6 w-96 h-[600px] rounded-2xl opacity-100 translate-y-0'
: 'bottom-6 right-6 w-96 h-0 opacity-0 translate-y-10 pointer-events-none'}`}
>
{/* Header */}
<div className="bg-primary/10 p-4 border-b border-border flex justify-between items-center backdrop-blur">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${apiKey ? 'bg-green-400 animate-pulse' : 'bg-red-500'}`}></div>
<h3 className="font-bold text-slate-100">AI Data Assistant</h3>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowConfig(!showConfig)}
className="text-slate-400 hover:text-white transition-colors p-1 rounded hover:bg-white/10"
title="API Settings"
>
<SettingsIcon />
</button>
<button onClick={() => setIsOpen(false)} className="text-slate-400 hover:text-white transition-colors p-1 rounded hover:bg-white/10">
<CloseIcon />
</button>
</div>
</div>
{/* Configuration Screen */}
{showConfig ? (
<div className="flex-1 p-6 flex flex-col justify-center bg-slate-950">
<div className="mb-6 text-center">
<div className="w-12 h-12 bg-indigo-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-indigo-400">
<ChatIcon />
</div>
<h3 className="text-lg font-bold text-white mb-2">Connect Gemini AI</h3>
<p className="text-sm text-slate-400">
To enable the AI assistant, please enter your Google Gemini API Key.
</p>
</div>
<form onSubmit={handleSaveKey} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-500 uppercase mb-1">API Key</label>
<input
type="password"
value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
placeholder="AIzaSy..."
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-4 py-3 text-white focus:ring-2 focus:ring-indigo-500 outline-none"
required
/>
<p className="text-[10px] text-slate-500 mt-2">
Key is stored locally in your browser.
<a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noreferrer" className="text-indigo-400 hover:underline ml-1">Get a key here.</a>
</p>
</div>
<button
type="submit"
className="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-3 rounded-lg transition-colors"
>
Save & Start Chatting
</button>
</form>
</div>
) : (
<>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-950/50 custom-scrollbar">
{messages.map((msg, idx) => (
<div key={idx} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] rounded-2xl p-3 text-sm leading-relaxed shadow-sm
${msg.role === 'user'
? 'bg-primary text-white rounded-br-none'
: 'bg-slate-800 text-slate-200 border border-border rounded-bl-none'}`}
>
{msg.role === 'model' ? <ModelMessage text={msg.text} /> : msg.text}
</div>
</div>
))}
{isTyping && (
<div className="flex justify-start">
<div className="bg-slate-800 border border-border rounded-2xl rounded-bl-none p-4 flex gap-1 items-center">
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce delay-100"></div>
<div className="w-2 h-2 bg-slate-500 rounded-full animate-bounce delay-200"></div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 bg-slate-900 border-t border-border">
<div className="relative">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about revenue, growth, units..."
className="w-full bg-slate-950 border border-slate-700 text-slate-200 rounded-full py-3 pl-4 pr-12 focus:outline-none focus:border-primary transition-colors placeholder-slate-500 text-sm"
/>
<button
onClick={handleSend}
disabled={!input.trim() || isTyping}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 bg-primary text-white rounded-full hover:bg-indigo-400 disabled:opacity-50 transition-colors"
>
<SendIcon />
</button>
</div>
</div>
</>
)}
</div>
</>
);
};
export default AIChat;
+56
View File
@@ -0,0 +1,56 @@
import React, { useState, useEffect } from 'react';
// Permanent logo URL provided by user
const DEFAULT_LOGO = "https://i.ibb.co/jkMPwJfj/logo-Photoroom.png";
const CrazeLogo = () => {
const [logoSrc, setLogoSrc] = useState<string>(DEFAULT_LOGO);
useEffect(() => {
// Check if user has uploaded a custom override locally
// Using v2 key to reset any previous cached logos and force the new default
const saved = localStorage.getItem('craze_custom_logo_v2');
if (saved) {
setLogoSrc(saved);
}
}, []);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const reader = new FileReader();
reader.onload = (ev) => {
const result = ev.target?.result as string;
setLogoSrc(result);
localStorage.setItem('craze_custom_logo_v2', result);
};
reader.readAsDataURL(e.target.files[0]);
}
};
return (
<div className="w-full h-full relative group flex items-center justify-start">
{/* Invisible file input for manual override */}
<input
type="file"
accept="image/png, image/jpeg, image/jpg"
onChange={handleFileChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-20"
title="Click to replace this permanent logo with a custom file"
/>
{/* The Image - Scaled 3x from the left */}
<img
src={logoSrc}
alt="Craze Analytix Logo"
className="h-full w-auto object-contain object-left drop-shadow-lg scale-[3] origin-left transition-transform duration-300"
onError={(e) => {
// Fallback if external URL fails
console.warn("Failed to load external logo, reverting to placeholder or text");
e.currentTarget.style.display = 'none';
}}
/>
</div>
);
};
export default CrazeLogo;
+712
View File
@@ -0,0 +1,712 @@
import React, { useState, useMemo, useEffect } from 'react';
import { AggregatedData, LineGrowthMetric } from '../types'; // Updated import for LineGrowthMetric
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
LineChart, Line, Legend
} from 'recharts';
import { DownloadIcon } from './Icons'; // Import DownloadIcon
interface DashboardProps {
data: AggregatedData;
contextData?: AggregatedData | null;
}
const COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0ea5e9'];
// Reusable Expandable Card Component
export const ExpandableCard: React.FC<{
title: string;
children: React.ReactNode;
className?: string;
onExport?: () => void; // Optional export function
exportFileName?: string; // Optional export file name
}> = ({ title, children, className, onExport, exportFileName }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = () => setIsExpanded(!isExpanded);
// Auto-scroll to top of sticky filter bar when expanded
useEffect(() => {
if (isExpanded) {
// Approximate height of the Header to scroll past (Logo + padding)
// This ensures the Sticky FilterBar snaps to the top of the viewport
const scrollTarget = 250;
if (window.scrollY < scrollTarget) {
window.scrollTo({ top: scrollTarget, behavior: 'smooth' });
}
}
}, [isExpanded]);
if (isExpanded) {
return (
<div className="fixed inset-0 z-50 bg-slate-950 px-6 pb-6 pt-32 flex flex-col animate-fade-in overflow-hidden">
{/* Fixed Close Button - Positioned TOP RIGHT ON TOP OF FILTER BAR with z-[100] */}
<button
onClick={toggleExpand}
className="fixed top-3 right-3 z-[100] p-2 bg-red-600/90 hover:bg-red-500 border border-red-400 rounded-full text-white transition-all shadow-2xl hover:scale-110 flex items-center gap-2 group"
title="Exit Fullscreen"
>
<span className="text-xs font-bold hidden group-hover:inline pr-1">CLOSE</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
<div className="flex justify-between items-center mb-4 border-b border-slate-800 pb-4 shrink-0">
<h3 className="text-xl font-bold text-slate-100 uppercase tracking-wide">{title}</h3>
{onExport && (
<button
onClick={onExport}
className="flex items-center gap-2 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
>
<DownloadIcon /> Export CSV
</button>
)}
</div>
<div className="flex-1 overflow-auto bg-slate-900 rounded-xl p-6 border border-border custom-scrollbar">
{children}
</div>
</div>
);
}
return (
<div
className={`bg-surface border border-border rounded-xl p-6 shadow-sm flex flex-col group relative transition-all duration-300 hover:shadow-primary/5 ${className}`}
>
<div className="flex justify-between items-start mb-4">
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wide">{title}</h3>
<div className="flex items-center gap-2">
{onExport && (
<button
onClick={onExport}
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-indigo-400 transition-opacity"
title={`Export ${exportFileName || title} to CSV`}
>
<DownloadIcon />
</button>
)}
<button
onClick={toggleExpand}
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-primary transition-opacity"
title="Expand to Fullscreen"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
</button>
</div>
</div>
<div className="flex-1 min-h-[250px] cursor-pointer" onClick={toggleExpand}>{children}</div>
</div>
);
};
const MultiYearKPICard: React.FC<{
title: string;
metric: 'sellOut' | 'units';
data: AggregatedData['totalsByYear'];
availableYears: string[];
contextData?: AggregatedData['totalsByYear']; // Added context data
}> = ({ title, metric, data, availableYears, contextData }) => {
// Sort years descending to show most recent first
const sortedYears = [...availableYears].sort((a, b) => parseInt(b) - parseInt(a));
const formatValue = (val: number) => {
if (metric === 'sellOut') {
return `${val.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
}
return val.toLocaleString();
};
// Determine title based on context
const displayTitle = contextData ? `${title} (Selected Item)` : title;
return (
<div className="bg-gradient-to-br from-surface to-slate-900 border border-border rounded-xl p-6 flex flex-col justify-center">
<h3 className="text-sm font-medium text-slate-400 mb-3">{displayTitle}</h3>
{sortedYears.length === 0 && <p className="text-3xl font-bold text-white">0</p>}
{sortedYears.length > 0 && (
<div className="space-y-4">
{sortedYears.map((year, index) => {
const currentValue = data[year] ? data[year][metric] : 0;
const contextValue = contextData && contextData[year] ? contextData[year][metric] : 0;
let growthElement = null;
let contextGrowthElement = null;
// Year-over-Year Growth comparison
if (index < sortedYears.length - 1) {
const prevYear = sortedYears[index + 1];
// Main Item Growth
const prevValue = data[prevYear] ? data[prevYear][metric] : 0;
if (prevValue > 0) {
const pct = ((currentValue - prevValue) / prevValue) * 100;
growthElement = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct > 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
// Context Item Growth (Product Line)
if (contextData) {
const prevContextValue = contextData[prevYear] ? contextData[prevYear][metric] : 0;
if (prevContextValue > 0) {
const pct = ((contextValue - prevContextValue) / prevContextValue) * 100;
contextGrowthElement = (
<span className={`text-[10px] ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct > 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
}
}
return (
<div key={year} className="flex flex-col border-b border-slate-800 pb-2 last:border-0">
<div className="flex justify-between items-end">
<div className="flex flex-col">
<span className="text-xs text-slate-500 font-mono mb-0.5">{year}</span>
<div className="flex items-center">
<span className="text-xl font-bold text-slate-200 leading-none">
{formatValue(currentValue)}
</span>
{growthElement}
</div>
</div>
</div>
{/* Context Row (Product Line Total) */}
{contextData && contextValue > 0 && (
<div className="mt-1 flex flex-wrap items-center justify-between bg-slate-800/50 p-2 rounded text-xs">
<span className="text-slate-400 mr-2">Total Product Line:</span>
<div className="flex items-center gap-1">
<span className="text-slate-300 font-medium">{formatValue(contextValue)}</span>
{contextGrowthElement}
{/* Share of Line % */}
<span className="text-indigo-400 font-bold border-l border-slate-700 pl-2 ml-2 whitespace-nowrap">
{((currentValue / contextValue) * 100).toFixed(1)}% Share
</span>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
};
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-slate-900 border border-border p-3 rounded shadow-xl text-sm max-w-xs z-50">
<p className="font-bold text-slate-100 mb-2 border-b border-slate-700 pb-1">{label}</p>
{payload.map((p: any) => (
<p key={p.name} className="text-slate-300 flex justify-between gap-4" style={{ color: p.color }}>
<span>{p.name}:</span>
<span className="font-mono font-semibold">
{p.name.toString().toLowerCase().includes('sell out') || p.name.toString().toLowerCase().includes('year') || typeof p.value === 'number' && p.value > 1000
? `${Number(p.value).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}`
: Number(p.value).toLocaleString()}
</span>
</p>
))}
</div>
);
}
return null;
};
// Tooltip specifically for the Seasonality Chart to show YoY %
const SeasonalityTooltip = ({ active, payload, label, metric }: any) => {
if (active && payload && payload.length) {
// Sort payload by year (name) to ensure we compare correctly
const sortedPayload = [...payload].sort((a, b) => parseInt(a.name) - parseInt(b.name));
const isCurrency = metric === 'sellOut';
return (
<div className="bg-slate-900 border border-border p-3 rounded shadow-xl text-sm max-w-xs z-50">
<p className="font-bold text-slate-100 mb-2 border-b border-slate-700 pb-1">{label}</p>
{sortedPayload.map((p: any, index: number) => {
let growthEl = null;
// If there is a previous year in the list, calculate % change
if (index > 0) {
const prev = sortedPayload[index - 1];
const prevVal = Number(prev.value);
const currVal = Number(p.value);
if (prevVal > 0) {
const pct = ((currVal - prevVal) / prevVal) * 100;
growthEl = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct > 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
}
return (
<div key={p.name} className="flex justify-between items-center gap-2 mb-1">
<span style={{ color: p.color }}>{p.name}:</span>
<div className="flex items-center">
<span className="font-mono font-semibold text-slate-200">
{isCurrency ? '€' : ''}{Number(p.value).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}
</span>
{growthEl}
</div>
</div>
);
})}
</div>
);
}
return null;
};
// Tooltip for the Top 10 Comparison Chart
const ComparisonTooltip = ({ active, payload, label, metric }: any) => {
if (active && payload && payload.length) {
// Sort payload by the dataKey (which usually contains the year, e.g., "2023_value" or just "2023")
const sortedPayload = [...payload].sort((a, b) => {
const yearA = parseInt(a.dataKey.split('_')[0]);
const yearB = parseInt(b.dataKey.split('_')[0]);
return yearA - yearB;
});
const isCurrency = metric === 'sellOut';
return (
<div className="bg-slate-900 border border-border p-3 rounded shadow-xl text-sm max-w-xs z-50">
<p className="font-bold text-slate-100 mb-2 border-b border-slate-700 pb-1">{label}</p>
{sortedPayload.map((p: any, index: number) => {
const year = p.dataKey.split('_')[0];
let growthEl = null;
if (index > 0) {
const prev = sortedPayload[index - 1];
const prevVal = Number(prev.value);
const currVal = Number(p.value);
if (prevVal > 0) {
const pct = ((currVal - prevVal) / prevVal) * 100;
growthEl = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct > 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
}
return (
<div key={year} className="flex justify-between items-center gap-2 mb-1">
<span style={{ color: p.color }}>{year}:</span>
<div className="flex items-center">
<span className="font-mono font-semibold text-slate-200">
{isCurrency ? '€' : ''}{Number(p.value).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}
</span>
{growthEl}
</div>
</div>
);
})}
</div>
);
}
return null;
};
const GrowthTable: React.FC<{
title: string;
data: LineGrowthMetric[]; // Updated to LineGrowthMetric
type: 'growth' | 'decline';
periods: { current: string; previous: string };
}> = ({ title, data, type, periods }) => {
const [sortConfig, setSortConfig] = useState<{ key: keyof LineGrowthMetric | null; direction: 'asc' | 'desc' }>({ key: null, direction: 'desc' });
const sortedData = useMemo(() => {
if (!sortConfig.key) return data;
return [...data].sort((a, b) => {
const aVal = a[sortConfig.key!] as number | string;
const bVal = b[sortConfig.key!] as number | string;
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
}, [data, sortConfig]);
const requestSort = (key: keyof LineGrowthMetric) => {
let direction: 'asc' | 'desc' = 'desc';
// If already sorting by this key, toggle direction
if (sortConfig.key === key && sortConfig.direction === 'desc') {
direction = 'asc';
}
setSortConfig({ key, direction });
};
const getSortIndicator = (key: keyof LineGrowthMetric) => {
if (sortConfig.key !== key) {
return (
<svg className="w-2.5 h-2.5 ml-1 text-slate-600 opacity-0 group-hover:opacity-50" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
);
}
return (
<svg className="w-2.5 h-2.5 ml-1 text-primary" fill="currentColor" viewBox="0 0 20 20">
{sortConfig.direction === 'asc'
? <path fillRule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clipRule="evenodd" />
: <path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
}
</svg>
);
};
return (
<div className="overflow-auto h-full relative">
<table className="w-full text-left text-sm h-full border-separate border-spacing-0">
<thead className="bg-slate-950 text-xs uppercase font-semibold text-slate-500 sticky top-0 z-10 shadow-sm">
<tr>
<th
className="px-4 py-3 bg-slate-950 min-w-[150px] cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('line')}
>
<div className="flex items-center">Product Line {getSortIndicator('line')}</div>
</th>
{/* Sell Out Columns */}
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-400 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('previousYearSellOut')}
>
<div className="flex items-center justify-end">Sell Out {periods.previous} {getSortIndicator('previousYearSellOut')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-200 cursor-pointer hover:text-white group select-none transition-colors"
onClick={() => requestSort('currentYearSellOut')}
>
<div className="flex items-center justify-end">Sell Out {periods.current} {getSortIndicator('currentYearSellOut')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('sellOutGrowthValue')}
>
<div className="flex items-center justify-end">SO Diff {getSortIndicator('sellOutGrowthValue')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('sellOutGrowthPercentage')}
>
<div className="flex items-center justify-end">SO Growth % {getSortIndicator('sellOutGrowthPercentage')}</div>
</th>
{/* Units Columns */}
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-400 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('previousYearUnits')}
>
<div className="flex items-center justify-end">Units {periods.previous} {getSortIndicator('previousYearUnits')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-200 cursor-pointer hover:text-white group select-none transition-colors"
onClick={() => requestSort('currentYearUnits')}
>
<div className="flex items-center justify-end">Units {periods.current} {getSortIndicator('currentYearUnits')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('unitsGrowthValue')}
>
<div className="flex items-center justify-end">Units Diff {getSortIndicator('unitsGrowthValue')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('unitsGrowthPercentage')}
>
<div className="flex items-center justify-end">Units Growth % {getSortIndicator('unitsGrowthPercentage')}</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border text-slate-300">
{sortedData.length > 0 ? (
sortedData.map((item, idx) => (
<tr key={idx} className="hover:bg-slate-800/50">
<td className="px-4 py-2 font-medium">{item.line}</td>
{/* Sell Out Columns */}
<td className="px-4 py-2 text-right text-slate-400">{item.previousYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}</td>
<td className="px-4 py-2 text-right font-medium text-slate-200">{item.currentYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}</td>
<td className={`px-4 py-2 text-right font-medium ${item.sellOutGrowthValue >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{item.sellOutGrowthValue > 0 ? '+' : ''}{item.sellOutGrowthValue.toLocaleString(undefined, {maximumFractionDigits: 0})}
{/* {item.sellOutGrowthValue.toFixed(0)} */}
</td>
<td className="px-4 py-2 text-right">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${item.sellOutGrowthPercentage >= 0 ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>
{item.sellOutGrowthPercentage.toFixed(1)}%
</span>
</td>
{/* Units Columns */}
<td className="px-4 py-2 text-right text-slate-400">{item.previousYearUnits.toLocaleString()}</td>
<td className="px-4 py-2 text-right font-medium text-slate-200">{item.currentYearUnits.toLocaleString()}</td>
<td className={`px-4 py-2 text-right font-medium ${item.unitsGrowthValue >= 0 ? 'text-violet-400' : 'text-orange-400'}`}>
{item.unitsGrowthValue > 0 ? '+' : ''}{item.unitsGrowthValue.toLocaleString()}
</td>
<td className="px-4 py-2 text-right">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${item.unitsGrowthPercentage >= 0 ? 'bg-violet-500/10 text-violet-400' : 'bg-orange-500/10 text-orange-400'}`}>
{item.unitsGrowthPercentage.toFixed(1)}%
</span>
</td>
</tr>
))
) : (
<tr>
<td colSpan={9} className="px-4 py-6 text-center text-slate-500 italic">
Insufficient data to calculate {type}. (Select at least 2 distinct years/periods)
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
const Dashboard: React.FC<DashboardProps> = ({ data, contextData }) => {
const [seasonalityMetric, setSeasonalityMetric] = useState<'sellOut' | 'units'>('sellOut');
const [top10Metric, setTop10Metric] = useState<'sellOut' | 'units'>('sellOut');
// Decide which data source to use for Product Line charts
// If contextData is provided (drill down), we use that to show the "Total Line" view.
// Otherwise we use the standard filtered data.
const displayData = contextData || data;
// Calculate dynamic height for the All Product Lines chart to enable scrolling
// Assume ~60px per product line to give it enough space, minimum 300px
const chartHeight = Math.max(displayData.topLinesSplit.length * 60, 300);
return (
<div className="p-6 space-y-6 max-w-7xl mx-auto animate-fade-in pb-24">
{/* KPI Section - Pass both specific data and context data */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<MultiYearKPICard
title="Sell Out Revenue"
metric="sellOut"
data={data.totalsByYear}
availableYears={data.availableYears}
contextData={contextData ? contextData.totalsByYear : undefined}
/>
<MultiYearKPICard
title="Units Sold"
metric="units"
data={data.totalsByYear}
availableYears={data.availableYears}
contextData={contextData ? contextData.totalsByYear : undefined}
/>
</div>
{/* Main Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-6 flex flex-col">
{/* All Product Lines Revenue Chart */}
<ExpandableCard title={contextData ? "Total Product Line Performance (Context)" : "Product Lines (Revenue)"} className="h-96">
<div className="flex flex-col h-full">
<div className="flex justify-between items-center mb-2">
{contextData && <span className="text-xs text-indigo-400 font-semibold uppercase tracking-wider">Showing Full Product Line Data</span>}
<div className="bg-slate-900 p-1 rounded-lg border border-slate-800 flex text-xs ml-auto">
<button
onClick={(e) => { e.stopPropagation(); setTop10Metric('sellOut'); }}
className={`px-3 py-1 rounded-md transition-colors ${top10Metric === 'sellOut' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'}`}
>
Sell Out ()
</button>
<button
onClick={(e) => { e.stopPropagation(); setTop10Metric('units'); }}
className={`px-3 py-1 rounded-md transition-colors ${top10Metric === 'units' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'}`}
>
Units
</button>
</div>
</div>
{/* Scrollable Container */}
<div className="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
<div style={{ height: `${chartHeight}px` }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={displayData.topLinesSplit} layout="vertical" margin={{ left: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" horizontal={false} />
<XAxis
type="number"
stroke="#64748b"
tickFormatter={(val) => top10Metric === 'sellOut' ? `${(val/1000).toFixed(0)}k` : val.toLocaleString()}
orientation='top'
/>
<YAxis dataKey="name" type="category" width={100} stroke="#94a3b8" tick={{fontSize: 12}} />
<Tooltip content={(props: any) => <ComparisonTooltip {...props} metric={top10Metric} />} cursor={{fill: '#1e293b'}} />
{displayData.availableYears.map((year, index) => (
<Bar
key={year}
dataKey={`${year}_${top10Metric === 'sellOut' ? 'value' : 'units'}`}
name={year}
fill={COLORS[index % COLORS.length]}
radius={[0, 4, 4, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</ExpandableCard>
{/* Growth Table */}
<ExpandableCard
title={contextData ? "Fastest Growing Lines (Full Line Context)" : "Fastest Growing Lines (YoY - €)"}
className="h-[400px]" // Provide a default height for the card
>
<GrowthTable
title={contextData ? "Fastest Growing Lines (Full Line Context)" : "Fastest Growing Lines (YoY - €)"}
data={displayData.topMovers}
type="growth"
periods={displayData.comparisonPeriods}
/>
</ExpandableCard>
{/* Decline Table */}
<ExpandableCard
title={contextData ? "Declining Lines (Full Line Context)" : "Declining Lines (YoY - €)"}
className="h-[400px]" // Provide a default height for the card
>
<GrowthTable
title={contextData ? "Declining Lines (Full Line Context)" : "Declining Lines (YoY - €)"}
data={displayData.bottomMovers}
type="decline"
periods={displayData.comparisonPeriods}
/>
</ExpandableCard>
</div>
{/* Right Column */}
<div className="space-y-6">
{/* Units Chart (Split by Year) */}
<ExpandableCard title={contextData ? "Total Product Line Units (Context)" : "Units Sold by Product Line (Overview)"} className="h-96">
<div className="flex flex-col h-full">
{contextData && <div className="text-xs text-indigo-400 font-semibold uppercase tracking-wider mb-2 text-right">Showing Full Product Line Data</div>}
<div className="flex-1">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={displayData.byLineOverviewSplit} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
<XAxis dataKey="name" stroke="#64748b" tick={{fontSize: 10}} interval={0} angle={-15} textAnchor="end" height={60} />
<YAxis
stroke="#64748b"
tickFormatter={(val) => {
if (val >= 1000000) return `${(val / 1000000).toFixed(1)}M`;
if (val >= 1000) return `${(val / 1000).toFixed(0)}k`;
return val;
}}
/>
<Tooltip content={(props: any) => <ComparisonTooltip {...props} metric="units" />} cursor={{fill: '#1e293b'}} />
{displayData.availableYears.map((year, index) => (
<Bar
key={year}
dataKey={year}
name={year}
fill={COLORS[index % COLORS.length]}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</div>
</ExpandableCard>
{/* Seasonality Chart - ALWAYS uses specific filtered data 'data' */}
<ExpandableCard title="Monthly Sales Seasonality (Selected Items)" className="h-96">
<div className="flex flex-col h-full">
<div className="flex justify-end mb-2">
<div className="bg-slate-900 p-1 rounded-lg border border-slate-800 flex text-xs">
<button
onClick={(e) => { e.stopPropagation(); setSeasonalityMetric('sellOut'); }}
className={`px-3 py-1 rounded-md transition-colors ${seasonalityMetric === 'sellOut' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'}`}
>
Sell Out ()
</button>
<button
onClick={(e) => { e.stopPropagation(); setSeasonalityMetric('units'); }}
className={`px-3 py-1 rounded-md transition-colors ${seasonalityMetric === 'units' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'}`}
>
Units
</button>
</div>
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={seasonalityMetric === 'sellOut' ? data.seasonality : data.seasonalityUnits}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="name" stroke="#64748b" />
<YAxis
stroke="#64748b"
tickFormatter={(val) => seasonalityMetric === 'sellOut' ? `${(val/1000).toFixed(0)}k` : val.toLocaleString()}
/>
<Tooltip content={(props: any) => <SeasonalityTooltip {...props} metric={seasonalityMetric} />} />
<Legend />
{data.availableYears.map((year, index) => (
<Line
key={year}
type="monotone"
dataKey={year}
name={year}
stroke={COLORS[index % COLORS.length]}
strokeWidth={3}
dot={{ r: 4, strokeWidth: 2 }}
activeDot={{ r: 6 }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
</ExpandableCard>
{/* Country Chart - Uses Specific Data 'data' usually, unless we want to broaden it. Kept specific for now. */}
<ExpandableCard title="Revenue Distribution by Customer" className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.byCustomerSplit}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
<XAxis dataKey="name" stroke="#64748b" />
<YAxis stroke="#64748b" tickFormatter={(val) => `${(val/1000).toFixed(0)}k`}/>
<Tooltip content={(props: any) => <ComparisonTooltip {...props} metric="sellOut" />} cursor={{fill: '#1e293b'}} />
{data.availableYears.map((year, index) => (
<Bar
key={year}
dataKey={year}
name={year}
fill={COLORS[index % COLORS.length]}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</ExpandableCard>
</div>
</div>
</div>
);
};
export default Dashboard;
+981
View File
@@ -0,0 +1,981 @@
import React, { useState, useMemo, useEffect } from 'react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
} from 'recharts';
import { SalesRecord, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types';
import { pivotSalesData, generateCSV, aggregateForTimeSeries, aggregateForComparisonTimeSeries } from '../services/dataProcessor';
import MultiSelectDropdown from './MultiSelectDropdown';
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon } from './Icons';
interface DataGridProps {
data: SalesRecord[];
}
type SortConfig = {
key: keyof PivotRow | string | null; // string for dynamic year sorting
direction: 'asc' | 'desc';
};
type ConditionalFilter = {
id: string;
metric: string;
operator: 'gt' | 'lt';
value: number;
}
const ROWS_PER_PAGE = 50;
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const CHART_COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0ea5e9'];
// Available grouping dimensions
const DIMENSION_OPTIONS = [
{ label: 'Product Line', value: 'line' },
{ label: 'Customer', value: 'customer' },
{ label: 'SKU', value: 'sku' },
{ label: 'Title', value: 'title' },
{ label: 'ASIN', value: 'asin' },
];
// Tooltip for single-period view with Week-over-Week comparison
const WoWTooltip = ({ active, payload, label, data }: any) => {
if (active && payload && payload.length && data) {
const currentIndex = data.findIndex((d: any) => d.name === label);
const prevData = currentIndex > 0 ? data[currentIndex - 1] : null;
return (
<div className="bg-slate-900 border border-border p-3 rounded shadow-xl text-sm max-w-xs z-50">
<p className="font-bold text-slate-100 mb-2 border-b border-slate-700 pb-1">{label}</p>
{payload.map((p: any) => {
let wowEl = null;
if (prevData) {
const prevValue = prevData[p.dataKey];
const currentValue = p.value;
if (prevValue != null && prevValue > 0) {
const pct = ((currentValue - prevValue) / prevValue) * 100;
wowEl = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
}
return (
<div key={p.name} className="flex justify-between items-center gap-2 mb-1">
<span style={{ color: p.color }}>{p.name}:</span>
<div className="flex items-center">
<span className="font-mono font-semibold text-slate-200">
{p.dataKey === 'sellOut'
? `${Number(p.value).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}`
: `${Number(p.value).toLocaleString()} u`}
</span>
{wowEl}
</div>
</div>
);
})}
</div>
);
}
return null;
}
// Tooltip for multi-year comparison view
const ComparisonTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
interface YearData {
sellOut?: number;
units?: number;
color?: string;
}
const dataByYear: { [year: string]: YearData } = {};
payload.forEach((p: any) => {
const nameParts = p.name.split(' ');
if (nameParts.length < 2) return;
const year = nameParts[nameParts.length - 1];
const metric = nameParts.slice(0, nameParts.length - 1).join(' ');
if (!dataByYear[year]) {
dataByYear[year] = {};
}
// Use the color from the Sell Out line for consistency for that year block
if (metric.toLowerCase().includes('so')) {
dataByYear[year].sellOut = p.value;
dataByYear[year].color = p.stroke || p.color;
} else if (metric.toLowerCase().includes('units')) {
dataByYear[year].units = p.value;
if(!dataByYear[year].color) { // fallback color from units line
dataByYear[year].color = p.stroke || p.color;
}
}
});
const sortedYears = Object.keys(dataByYear).sort((a, b) => parseInt(b) - parseInt(a));
return (
<div className="bg-slate-900/90 backdrop-blur-sm border border-border p-3 rounded-lg shadow-xl text-sm w-56 z-50">
<p className="font-bold text-slate-100 mb-2 border-b border-slate-700 pb-1">{label}</p>
{sortedYears.map((year, index) => {
const yearData = dataByYear[year];
const prevYear = sortedYears[index + 1];
const prevYearData = prevYear ? dataByYear[prevYear] : null;
let sellOutGrowthEl = null;
if (prevYearData && prevYearData.sellOut != null && prevYearData.sellOut !== 0 && yearData.sellOut != null) {
const pct = ((yearData.sellOut - prevYearData.sellOut) / prevYearData.sellOut) * 100;
sellOutGrowthEl = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
let unitsGrowthEl = null;
if (prevYearData && prevYearData.units != null && prevYearData.units !== 0 && yearData.units != null) {
const pct = ((yearData.units - prevYearData.units) / prevYearData.units) * 100;
unitsGrowthEl = (
<span className={`text-xs ml-2 font-bold ${pct >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{pct >= 0 ? '▲' : '▼'} {Math.abs(pct).toFixed(1)}%
</span>
);
}
return (
<div key={year} className="mt-2">
<p className="font-bold text-slate-200 text-base">{year}</p>
{yearData.sellOut != null && (
<div className="flex justify-between items-center gap-2 pl-1 mt-1">
<span className="font-medium" style={{ color: yearData.color }}>Sell Out:</span>
<div className="flex items-center">
<span className="font-mono font-semibold" style={{ color: yearData.color }}>
{Number(yearData.sellOut).toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}
</span>
{sellOutGrowthEl}
</div>
</div>
)}
{yearData.units != null && (
<div className="flex justify-between items-center gap-2 pl-1 mt-1">
<span className="font-medium" style={{ color: yearData.color }}>Units:</span>
<div className="flex items-center">
<span className="font-mono font-semibold" style={{ color: yearData.color }}>
{Number(yearData.units).toLocaleString()} u
</span>
{unitsGrowthEl}
</div>
</div>
)}
</div>
);
})}
</div>
);
}
return null;
};
// Reusable Expandable Card for the Chart
const ExpandableChartCard: React.FC<{ title: string; children: React.ReactNode; className?: string }> = ({ title, children, className }) => {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpand = () => setIsExpanded(!isExpanded);
if (isExpanded) {
return (
<div className="fixed inset-0 z-[80] bg-slate-950 px-6 pb-6 pt-16 flex flex-col animate-fade-in overflow-hidden">
<div className="flex justify-between items-center mb-4 border-b border-slate-800 pb-4 shrink-0">
<h3 className="text-xl font-bold text-slate-100 uppercase tracking-wide">{title}</h3>
<button
onClick={toggleExpand}
className="p-2 bg-red-600/90 hover:bg-red-500 border border-red-400 rounded-full text-white transition-all shadow-2xl hover:scale-110 flex items-center gap-2 group"
title="Exit Fullscreen"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2.5} stroke="currentColor" className="w-5 h-5"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="flex-1 overflow-auto bg-slate-900 rounded-xl p-6 border border-border custom-scrollbar">
{children}
</div>
</div>
);
}
return (
<div className={`bg-surface border-b border-border group relative transition-all duration-300 ${className}`}>
<div className="p-4 flex justify-between items-start">
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wide">{title}</h3>
<button
onClick={toggleExpand}
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-primary transition-opacity"
title="Expand to Fullscreen"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5"><path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" /></svg>
</button>
</div>
<div className="cursor-pointer px-4 pb-4" onClick={toggleExpand}>
{children}
</div>
</div>
);
};
const DataGrid: React.FC<DataGridProps> = ({ data }) => {
const [currentPage, setCurrentPage] = useState(1);
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: null, direction: 'desc' });
const [showChart, setShowChart] = useState(true);
const [visibleMetrics, setVisibleMetrics] = useState<('sellOut' | 'units')[]>(['sellOut', 'units']);
// State for dynamic grouping
const [selectedDimensions, setSelectedDimensions] = useState<string[]>(['line', 'customer', 'sku', 'title']);
// State for Advanced Filtering
const [showFilterBuilder, setShowFilterBuilder] = useState(false);
const [rowFilters, setRowFilters] = useState<ConditionalFilter[]>([]);
// Temp state for new filter inputs
const [newFilterMetric, setNewFilterMetric] = useState<string>('');
const [newFilterOperator, setNewFilterOperator] = useState<'gt' | 'lt'>('gt');
const [newFilterValue, setNewFilterValue] = useState<string>('');
// Effective dimensions for rendering
const effectiveDimensions = useMemo(() =>
selectedDimensions.length > 0 ? selectedDimensions : ['customer'],
[selectedDimensions]);
// Transform flat data into Pivot structure
const { rows: pivotRows, years } = useMemo(() => {
return pivotSalesData(data, effectiveDimensions);
}, [data, effectiveDimensions]);
// Data for the time series chart, supporting single and multi-year comparison
const { chartData, uniqueYears, isComparisonView, chartTitle } = useMemo(() => {
const yearsInView = Array.from(new Set(data.map(d => d.year.toString()))).sort((a, b) => parseInt(b) - parseInt(a));
const isMultiYear = yearsInView.length > 1;
if (isMultiYear) {
return {
chartData: aggregateForComparisonTimeSeries(data),
uniqueYears: yearsInView,
isComparisonView: true,
chartTitle: `Weekly Sales Comparison: ${yearsInView.join(' vs ')}`
};
} else {
return {
chartData: aggregateForTimeSeries(data),
uniqueYears: yearsInView,
isComparisonView: false,
chartTitle: `Weekly Sales Evolution ${yearsInView[0] || ''}`
};
}
}, [data]);
// Filter Options based on available data
const metricOptions = useMemo(() => {
const options = [];
// Totals
years.forEach(y => {
options.push({ label: `Total Sell Out ${y} (€)`, value: `total_sellOut_${y}` });
options.push({ label: `Total Units ${y}`, value: `total_units_${y}` });
});
// Growth (Latest vs Previous)
if (years.length >= 2) {
options.push({ label: `Growth % Sell Out (${years[0]} vs ${years[1]})`, value: 'growth_sellOut' });
options.push({ label: `Growth % Units (${years[0]} vs ${years[1]})`, value: 'growth_units' });
}
return options;
}, [years]);
// Default sorting
const effectiveSortKey = sortConfig.key || (years.length > 0 ? `total_${years[0]}` : null);
// Apply Advanced Row Filters THEN Sort
const processedRows = useMemo(() => {
let result = pivotRows;
// 1. Filter
if (rowFilters.length > 0) {
const latestYear = years[0];
const prevYear = years[1];
result = result.filter(row => {
return rowFilters.every(filter => {
let rowValue = 0;
if (filter.metric.startsWith('total_sellOut_')) {
const y = filter.metric.split('_')[2];
rowValue = row.totalsByYear[y]?.sellOut || 0;
}
else if (filter.metric.startsWith('total_units_')) {
const y = filter.metric.split('_')[2];
rowValue = row.totalsByYear[y]?.units || 0;
}
else if (filter.metric === 'growth_sellOut') {
if (!prevYear) return true;
const curr = row.totalsByYear[latestYear]?.sellOut || 0;
const prev = row.totalsByYear[prevYear]?.sellOut || 0;
if (prev === 0) return curr > 0;
rowValue = ((curr - prev) / prev) * 100;
}
else if (filter.metric === 'growth_units') {
if (!prevYear) return true;
const curr = row.totalsByYear[latestYear]?.units || 0;
const prev = row.totalsByYear[prevYear]?.units || 0;
if (prev === 0) return curr > 0;
rowValue = ((curr - prev) / prev) * 100;
}
if (filter.operator === 'gt') return rowValue > filter.value;
if (filter.operator === 'lt') return rowValue < filter.value;
return true;
});
});
}
// 2. Sort
if (effectiveSortKey) {
result = [...result].sort((a, b) => {
let aVal: string | number = 0;
let bVal: string | number = 0;
const sortKeyStr = String(effectiveSortKey);
if (effectiveDimensions.includes(sortKeyStr)) {
const key = sortKeyStr as keyof PivotRow;
const valA = a[key];
const valB = b[key];
if (typeof valA === 'string' || typeof valA === 'number') {
aVal = valA;
}
if (typeof valB === 'string' || typeof valB === 'number') {
bVal = valB;
}
} else if (sortKeyStr.startsWith('total_')) {
const year = sortKeyStr.split('_')[1];
aVal = a.totalsByYear[year]?.sellOut || 0;
bVal = b.totalsByYear[year]?.sellOut || 0;
}
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortConfig.direction === 'asc'
? String(aVal).localeCompare(String(bVal))
: String(bVal).localeCompare(String(aVal));
}
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
}
return result;
}, [pivotRows, rowFilters, effectiveSortKey, sortConfig, effectiveDimensions, years]);
// Grand Totals (Calculated on Filtered Rows for context)
const grandTotals = useMemo(() => {
const accTotalsByYear: Record<string, YearlyData> = {};
const accMonthsByYear: Record<number, Record<string, YearlyData>> = {};
years.forEach(y => {
accTotalsByYear[y] = { sellOut: 0, units: 0 };
});
for(let i=0; i<12; i++) {
accMonthsByYear[i] = {};
years.forEach(y => {
accMonthsByYear[i][y] = { sellOut: 0, units: 0 };
});
}
processedRows.forEach(row => {
// Totals
Object.entries(row.totalsByYear).forEach(([y, val]) => {
const v = val as YearlyData;
if (accTotalsByYear[y]) {
accTotalsByYear[y].sellOut += v.sellOut;
accTotalsByYear[y].units += v.units;
}
});
// Months
row.months.forEach((m, idx) => {
Object.entries(m.byYear).forEach(([y, val]) => {
const v = val as YearlyData;
if (accMonthsByYear[idx][y]) {
accMonthsByYear[idx][y].sellOut += v.sellOut;
accMonthsByYear[idx][y].units += v.units;
}
});
});
});
return { totalsByYear: accTotalsByYear, months: accMonthsByYear };
}, [processedRows, years]);
// Pagination
const totalPages = Math.ceil(processedRows.length / ROWS_PER_PAGE);
const currentRows = useMemo(() => {
const start = (currentPage - 1) * ROWS_PER_PAGE;
return processedRows.slice(start, start + ROWS_PER_PAGE);
}, [processedRows, currentPage]);
// Handlers
const requestSort = (key: string) => {
let direction: 'asc' | 'desc' = 'desc';
if (sortConfig.key === key && sortConfig.direction === 'desc') {
direction = 'asc';
}
setSortConfig({ key, direction });
setCurrentPage(1);
};
const handlePrev = () => setCurrentPage(p => Math.max(1, p - 1));
const handleNext = () => setCurrentPage(p => Math.min(totalPages, p + 1));
const handleExport = () => generateCSV(processedRows, effectiveDimensions, years);
const getLabel = (val: string) => DIMENSION_OPTIONS.find(d => d.value === val)?.label || val;
const toggleMetric = (metric: 'sellOut' | 'units') => {
setVisibleMetrics(prev =>
prev.includes(metric)
? prev.filter(m => m !== metric)
: [...prev, metric]
);
};
// Filter Handlers
const addFilter = () => {
if (!newFilterMetric || !newFilterValue) return;
setRowFilters(prev => [
...prev,
{
id: Date.now().toString(),
metric: newFilterMetric,
operator: newFilterOperator,
value: parseFloat(newFilterValue)
}
]);
setNewFilterValue('');
// Don't close builder to allow adding more
};
const removeFilter = (id: string) => {
setRowFilters(prev => prev.filter(f => f.id !== id));
};
// Render Helpers
const renderGrowth = (current: number, previous: number, size: 'sm' | 'xs' = 'xs') => {
if (previous === 0) return null;
const pct = ((current - previous) / previous) * 100;
const isPositive = pct >= 0;
const textSize = size === 'sm' ? 'text-xs' : 'text-[10px]';
return (
<span className={`${textSize} font-bold ml-1.5 ${isPositive ? 'text-emerald-400' : 'text-rose-400'} bg-slate-800 border border-slate-700 px-1 rounded inline-block`}>
{isPositive ? '↑' : '↓'}{Math.abs(pct).toFixed(0)}%
</span>
);
};
if (data.length === 0) return null;
return (
<div className="max-w-[100vw] mx-auto px-4 pb-24 h-screen flex flex-col">
<div className="bg-surface border border-border rounded-xl shadow-lg flex flex-col flex-1">
{/* Header Bar */}
<div className="p-4 border-b border-border bg-slate-900 flex flex-col gap-4 shrink-0 rounded-t-xl z-50">
<div className="flex flex-wrap gap-4 justify-between items-center">
<div>
<h3 className="text-lg font-bold text-slate-100">Dynamic Pivot Table</h3>
<p className="text-xs text-slate-500">
Comparing {years.join(', ')} {processedRows.length} Rows
{rowFilters.length > 0 && <span className="text-indigo-400 ml-1">(Filtered)</span>}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-400 font-medium">Group By:</span>
<MultiSelectDropdown
label="Variables"
selected={selectedDimensions}
options={DIMENSION_OPTIONS.map(d => d.value)}
onChange={setSelectedDimensions}
className="w-64"
/>
</div>
<button
onClick={() => setShowFilterBuilder(!showFilterBuilder)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm border
${showFilterBuilder || rowFilters.length > 0 ? 'bg-indigo-900/50 border-indigo-500 text-indigo-300' : 'bg-slate-800 border-border text-slate-300 hover:text-white hover:bg-slate-700'}`}
>
<FunnelIcon />
{rowFilters.length > 0 ? `${rowFilters.length} Active` : 'Filter Rows'}
</button>
<button
onClick={() => setShowChart(!showChart)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm border
${showChart ? 'bg-indigo-900/50 border-indigo-500 text-indigo-300' : 'bg-slate-800 border-border text-slate-300 hover:text-white hover:bg-slate-700'}`}
>
<ChartIcon />
{showChart ? 'Hide Trend' : 'Show Trend'}
</button>
<button
onClick={handleExport}
className="flex items-center gap-2 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
>
<DownloadIcon /> Export CSV
</button>
</div>
</div>
{/* Advanced Filter Builder Panel */}
{(showFilterBuilder || rowFilters.length > 0) && (
<div className="bg-slate-950/50 p-4 rounded-lg border border-border space-y-3 animate-fade-in">
{/* Active Filters List */}
{rowFilters.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{rowFilters.map(filter => {
const metricLabel = metricOptions.find(o => o.value === filter.metric)?.label || filter.metric;
const opLabel = filter.operator === 'gt' ? '>' : '<';
return (
<div key={filter.id} className="flex items-center gap-2 bg-indigo-500/10 border border-indigo-500/30 text-indigo-300 px-3 py-1 rounded-full text-xs font-medium">
<span>{metricLabel} {opLabel} {filter.value}</span>
<button onClick={() => removeFilter(filter.id)} className="hover:text-white"><CloseIcon /></button>
</div>
);
})}
</div>
)}
{/* Filter Creator Inputs */}
{showFilterBuilder && (
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-slate-500 font-semibold uppercase">Metric</label>
<select
className="bg-slate-900 border border-slate-700 text-slate-200 text-sm rounded-md px-3 py-2 focus:ring-1 focus:ring-primary outline-none min-w-[220px]"
value={newFilterMetric}
onChange={(e) => setNewFilterMetric(e.target.value)}
>
<option value="">Select Metric...</option>
{metricOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-slate-500 font-semibold uppercase">Operator</label>
<select
className="bg-slate-900 border border-slate-700 text-slate-200 text-sm rounded-md px-3 py-2 focus:ring-1 focus:ring-primary outline-none"
value={newFilterOperator}
onChange={(e) => setNewFilterOperator(e.target.value as 'gt' | 'lt')}
>
<option value="gt">Greater Than ({'>'})</option>
<option value="lt">Less Than ({'<'})</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-slate-500 font-semibold uppercase">Value</label>
<input
type="number"
placeholder="0"
className="w-full bg-slate-900 border border-slate-700 text-slate-200 text-sm rounded-md px-3 py-2 focus:ring-1 focus:ring-primary outline-none w-32"
value={newFilterValue}
onChange={(e) => setNewFilterValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addFilter()}
/>
</div>
<button
onClick={addFilter}
disabled={!newFilterMetric || !newFilterValue}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:hover:bg-indigo-600 text-white rounded-md text-sm font-medium transition-colors"
>
Apply
</button>
</div>
)}
</div>
)}
</div>
{/* Trend Chart */}
{showChart && chartData.length > 1 && (
<ExpandableChartCard title={chartTitle} className="mb-6">
<div className="flex flex-col h-full min-h-[350px]">
<div className="flex justify-end mb-2 shrink-0">
<div className="bg-slate-900 p-1 rounded-lg border border-slate-800 flex text-xs font-medium">
<label className="flex items-center gap-2 px-3 py-1 rounded-md transition-colors cursor-pointer has-[:checked]:bg-indigo-600 has-[:checked]:text-white has-[:checked]:shadow-sm text-slate-400 hover:text-white">
<input type="checkbox" checked={visibleMetrics.includes('sellOut')} onChange={() => toggleMetric('sellOut')} className="hidden" />
Sell Out ()
</label>
<label className="flex items-center gap-2 px-3 py-1 rounded-md transition-colors cursor-pointer has-[:checked]:bg-teal-600 has-[:checked]:text-white has-[:checked]:shadow-sm text-slate-400 hover:text-white">
<input type="checkbox" checked={visibleMetrics.includes('units')} onChange={() => toggleMetric('units')} className="hidden" />
Units
</label>
</div>
</div>
<div className="flex-1 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="name" stroke="#64748b" tick={{ fontSize: 12 }} />
{visibleMetrics.includes('sellOut') && (
<YAxis yAxisId="left" stroke={CHART_COLORS[0]} tickFormatter={(val) => `${(val / 1000).toFixed(0)}k`} />
)}
{visibleMetrics.includes('units') && (
<YAxis yAxisId="right" orientation="right" stroke={isComparisonView ? CHART_COLORS[1] : CHART_COLORS[2]} tickFormatter={(val) => `${(val / 1000).toFixed(0)}k`} />
)}
<Tooltip content={isComparisonView ? <ComparisonTooltip /> : <WoWTooltip data={chartData} />} />
<Legend />
{isComparisonView ? (
uniqueYears.map((year, index) => (
<React.Fragment key={year}>
{visibleMetrics.includes('sellOut') && (
<Line yAxisId="left" type="monotone" dataKey={`${year}_sellOut`} name={`SO ${year}`} stroke={CHART_COLORS[index % CHART_COLORS.length]} strokeWidth={2} dot={false} activeDot={{ r: 6 }} />
)}
{visibleMetrics.includes('units') && (
<Line yAxisId="right" type="monotone" dataKey={`${year}_units`} name={`Units ${year}`} stroke={CHART_COLORS[index % CHART_COLORS.length]} strokeWidth={2} strokeDasharray="5 5" dot={false} activeDot={{ r: 6 }} />
)}
</React.Fragment>
))
) : (
<>
{visibleMetrics.includes('sellOut') && (
<Line yAxisId="left" type="monotone" dataKey="sellOut" name="Sell Out" stroke={CHART_COLORS[0]} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 6 }} />
)}
{visibleMetrics.includes('units') && (
<Line yAxisId="right" type="monotone" dataKey="units" name="Units" stroke={CHART_COLORS[2]} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 6 }} />
)}
</>
)}
</LineChart>
</ResponsiveContainer>
</div>
</div>
</ExpandableChartCard>
)}
{(!showChart || chartData.length <=1) && !isComparisonView && (
<div className="p-4 border-b border-border text-center text-sm text-slate-500 italic bg-slate-900/30 mb-6">
{isComparisonView
? "Not enough weekly data to compare these years."
: "Not enough weekly data points to render a trend chart for the current selection."
}
</div>
)}
{/* Table Container */}
<div className="overflow-auto flex-1 relative custom-scrollbar bg-slate-950">
<table className="w-max text-left text-sm text-slate-300 border-collapse">
<thead className="bg-slate-950 text-sm uppercase font-semibold text-slate-400 z-30 shadow-md">
<tr className="border-b border-slate-800">
{/* Dynamic Dimension Headers - STICKY TOP */}
{effectiveDimensions.map((dim, index) => {
const label = getLabel(dim);
const isFirst = index === 0;
const isTitle = dim === 'title';
return (
<th
key={dim}
className={`p-3 border-r border-slate-800 cursor-pointer hover:text-white sticky top-0 bg-slate-950
${isTitle ? 'min-w-[300px]' : 'min-w-[150px]'}
${isFirst ? 'left-0 z-40 bg-slate-900' : 'z-30'}`}
onClick={() => requestSort(dim)}
>
{label} {sortConfig.key === dim && (sortConfig.direction === 'asc' ? '▲' : '▼')}
</th>
);
})}
{/* Dynamic Total Columns for each Year - STICKY TOP */}
{years.map(year => (
<th
key={`total_${year}`}
className="p-3 w-40 border-r border-indigo-500 text-right cursor-pointer text-white bg-indigo-700 hover:bg-indigo-600 transition-colors shadow-md sticky top-0 z-30"
onClick={() => requestSort(`total_${year}`)}
>
Total {year} {sortConfig.key === `total_${year}` && (sortConfig.direction === 'asc' ? '▲' : '▼')}
</th>
))}
{/* Monthly Headers - STICKY TOP */}
{MONTH_NAMES.map(m => (
<th key={m} className="p-2 min-w-[150px] text-center border-r border-slate-800 bg-slate-900 sticky top-0 z-30">
{m}
</th>
))}
</tr>
{/* Grand Total Row (Sticky Top BELOW Headers) */}
<tr className="border-b-2 border-indigo-400 bg-slate-950 text-white font-bold shadow-lg z-40 sticky top-[48px]">
{/*
Anchor TOTAL label to the first column (sticky left).
This ensures "TOTAL" stays visible on the left even when scrolling horizontally.
*/}
<td
className="p-3 border-r border-slate-800 text-left sticky left-0 z-50 bg-slate-950 whitespace-nowrap"
>
TOTAL ({processedRows.length} Rows)
</td>
{/* Spacer for remaining dimensions if any */}
{effectiveDimensions.length > 1 && (
<td
colSpan={effectiveDimensions.length - 1}
className="p-3 border-r border-slate-800 bg-slate-950"
/>
)}
{/* Totals for each year - HIGH CONTRAST (Indigo 800) */}
{years.map((year, yIdx) => {
const currentData = grandTotals.totalsByYear[year] || { sellOut: 0, units: 0 };
let sellOutGrowth = null;
let unitsGrowth = null;
// Compare with next year in the list (chronologically previous)
if (yIdx < years.length - 1) {
const prevYear = years[yIdx + 1];
const prevData = grandTotals.totalsByYear[prevYear];
if (prevData) {
sellOutGrowth = renderGrowth(currentData.sellOut, prevData.sellOut, 'sm');
unitsGrowth = renderGrowth(currentData.units, prevData.units, 'sm');
}
}
return (
<td key={`grand_${year}`} className="p-3 border-r border-indigo-500 text-right bg-indigo-800">
<div className="flex justify-end items-center mb-1">
<span className="text-base text-white">{currentData.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
{sellOutGrowth}
</div>
<div className="flex justify-end items-center">
<span className="text-sm text-violet-300 font-normal">{currentData.units.toLocaleString()} u</span>
{unitsGrowth}
</div>
</td>
);
})}
{/* Monthly Grand Totals */}
{MONTH_NAMES.map((_, idx) => (
<td key={`grand_m_${idx}`} className="p-2 border-r border-slate-800 text-right min-w-[150px] bg-slate-900">
{years.map((year, yIdx) => {
const data = grandTotals.months[idx][year];
if(!data || (data.sellOut === 0 && data.units === 0)) return null;
let sellOutGrowth = null;
let unitsGrowth = null;
if (yIdx < years.length - 1) {
const prevYear = years[yIdx + 1];
const prevData = grandTotals.months[idx][prevYear];
if (prevData) {
sellOutGrowth = renderGrowth(data.sellOut, prevData.sellOut);
unitsGrowth = renderGrowth(data.units, prevData.units);
}
}
return (
<div key={year} className="flex justify-between items-center text-xs mb-1 border-b border-white/5 last:border-0 pb-1 last:pb-0">
<div className="flex items-center text-slate-400 mr-2 min-w-[32px]">
<span>{year}</span>
</div>
<div className="text-right flex-1">
<div className="flex items-center justify-end">
<span>{data.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
{sellOutGrowth}
</div>
<div className="flex items-center justify-end text-violet-400 font-mono mt-0.5">
<span>{data.units.toLocaleString()}u</span>
{unitsGrowth}
</div>
</div>
</div>
);
})}
</td>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{currentRows.map((row, index) => {
const isAlternate = index % 2 === 1;
// Alternate row color: Default dark (slate-950) vs Alternate lighter (slate-800) for high contrast
const rowClass = isAlternate ? 'bg-slate-800' : 'bg-slate-950';
return (
<tr key={row.id} className={`${rowClass} hover:bg-slate-700 transition-colors group`}>
{/* Dimensions */}
{effectiveDimensions.map((dim, index) => {
const isFirst = index === 0;
// @ts-ignore
const val = row[dim];
const isTitle = dim === 'title';
const textColor = isTitle ? 'text-white font-semibold' : (isFirst ? 'text-slate-200 font-medium' : 'text-slate-400');
let cellContent: React.ReactNode = val;
if (isTitle && typeof val === 'string') {
cellContent = (
<div className="overflow-x-auto whitespace-nowrap custom-scrollbar pb-1">
{val}
</div>
);
} else {
let displayVal = val;
if (typeof val === 'string' && val.length > 30) {
displayVal = val.substring(0, 30) + '...';
}
cellContent = displayVal;
}
return (
<td
key={dim}
className={`p-3 border-r border-slate-800 text-sm ${textColor}
${isTitle ? 'min-w-[300px] max-w-[300px]' : 'truncate max-w-[220px]'}
${isFirst ? `sticky left-0 z-20 ${rowClass} group-hover:bg-slate-700` : ''}
`}
title={val}
>
{cellContent}
</td>
);
})}
{/* Dynamic Total Columns per Year - HIGH CONTRAST BODY (Indigo 900/60) */}
{years.map((year, yIdx) => {
const yData = row.totalsByYear[year] || { sellOut: 0, units: 0 };
let sellOutGrowth = null;
let unitsGrowth = null;
if (yIdx < years.length - 1) {
const prevYear = years[yIdx + 1];
const prevData = row.totalsByYear[prevYear];
if (prevData) {
sellOutGrowth = renderGrowth(yData.sellOut, prevData.sellOut);
unitsGrowth = renderGrowth(yData.units, prevData.units);
}
}
return (
<td key={`row_tot_${year}`} className="p-3 border-r border-indigo-500/40 text-right font-bold text-white bg-indigo-900/60">
<div className="flex justify-end items-center mb-1">
<span className="text-base">{yData.sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
{sellOutGrowth}
</div>
<div className="flex justify-end items-center text-sm text-violet-200 font-normal">
<span>{yData.units.toLocaleString()} u</span>
{unitsGrowth}
</div>
</td>
);
})}
{/* Monthly Data Columns (Listing all years) */}
{row.months.map((m, idx) => (
<td key={idx} className="p-2 border-r border-slate-800 text-right min-w-[150px]">
{years.map((year, yIdx) => {
const yData = m.byYear[year];
// Skip if year has no data, unless it's the only year selected
if (!yData && years.length > 1) return null;
const sellOut = yData?.sellOut || 0;
const units = yData?.units || 0;
let sellOutGrowthEl = null;
let unitsGrowthEl = null;
if (yIdx < years.length - 1) {
const nextYear = years[yIdx + 1];
const nextData = m.byYear[nextYear];
if (nextData) {
if (nextData.sellOut > 0) {
sellOutGrowthEl = renderGrowth(sellOut, nextData.sellOut);
}
if (nextData.units > 0) {
unitsGrowthEl = renderGrowth(units, nextData.units);
}
}
}
return (
<div key={year} className="mb-2 last:mb-0 border-b border-slate-800 last:border-0 pb-1 last:pb-0">
<div className="flex justify-between items-center text-xs text-slate-500 mb-0.5">
<span>{year}</span>
{sellOutGrowthEl}
</div>
<div className="flex justify-end items-center text-sm font-medium text-slate-300">
{sellOut.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</div>
<div className="flex justify-between items-center mt-0.5">
<div className="flex-1"></div>
<div className="flex items-center gap-1">
<span className="text-xs text-violet-400 font-mono">{units} u</span>
{unitsGrowthEl}
</div>
</div>
</div>
);
})}
</td>
))}
</tr>
)})}
</tbody>
</table>
</div>
{/* Footer */}
<div className="bg-slate-900 border-t border-border p-3 flex justify-between items-center text-sm shrink-0 rounded-b-xl z-40">
<div className="text-slate-500">
Showing {((currentPage - 1) * ROWS_PER_PAGE) + 1} - {Math.min(currentPage * ROWS_PER_PAGE, processedRows.length)} of {processedRows.length} Rows
</div>
<div className="flex gap-2">
<button
onClick={handlePrev}
disabled={currentPage === 1}
className="px-3 py-1 rounded bg-slate-800 border border-border hover:bg-slate-700 disabled:opacity-50 transition-colors"
>
Previous
</button>
<span className="flex items-center px-2 text-slate-300">
Page {currentPage} of {totalPages}
</span>
<button
onClick={handleNext}
disabled={currentPage === totalPages}
className="px-3 py-1 rounded bg-slate-800 border border-border hover:bg-slate-700 disabled:opacity-50 transition-colors"
>
Next
</button>
</div>
</div>
</div>
</div>
);
};
export default DataGrid;
+141
View File
@@ -0,0 +1,141 @@
import React, { ChangeEvent, useState } from 'react';
import { UploadIcon } from './Icons';
interface FileUploadProps {
onFileUpload: (file: File) => void;
onUrlSubmit: (url: string) => void;
isLoading: boolean;
activeUrl?: string | null;
onDisconnect?: () => void;
lastUpdated?: string | null;
}
const FileUpload: React.FC<FileUploadProps> = ({
onFileUpload,
onUrlSubmit,
isLoading,
activeUrl,
onDisconnect,
lastUpdated
}) => {
const [url, setUrl] = useState('');
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
onFileUpload(e.target.files[0]);
}
};
const handleUrlSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (url.trim()) {
onUrlSubmit(url.trim());
}
};
const handleSyncNow = () => {
if (activeUrl) onUrlSubmit(activeUrl);
};
return (
<div className="flex flex-col gap-6 p-2">
{/* Active Connection Status */}
{activeUrl && (
<div className="bg-emerald-900/20 border border-emerald-500/30 rounded-xl p-6 text-center animate-fade-in">
<div className="flex items-center justify-center gap-2 mb-2 text-emerald-400">
<div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></div>
<span className="font-bold text-sm uppercase tracking-wide">Cloud Sync Active</span>
</div>
<p className="text-slate-300 text-sm mb-1 truncate max-w-sm mx-auto opacity-80">{activeUrl}</p>
{lastUpdated && <p className="text-xs text-slate-500 mb-4">Last updated: {new Date(lastUpdated).toLocaleString()}</p>}
<div className="flex gap-3 justify-center">
<button
onClick={handleSyncNow}
disabled={isLoading}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2 disabled:opacity-50"
>
{isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
Syncing...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" /></svg>
Sync Now
</>
)}
</button>
<button
onClick={onDisconnect}
className="px-4 py-2 bg-slate-800 hover:bg-red-900/30 text-slate-300 hover:text-red-400 border border-slate-700 hover:border-red-800 rounded-lg text-sm font-medium transition-colors"
>
Disconnect
</button>
</div>
</div>
)}
{/* Manual File Upload */}
<div className="rounded-xl border border-dashed border-slate-700 bg-slate-900/50 hover:bg-slate-900 transition-colors group">
<label htmlFor="file-upload" className="cursor-pointer flex flex-col items-center gap-3 p-6">
<div className="p-3 bg-indigo-500/10 rounded-full text-indigo-400 group-hover:scale-110 transition-transform">
<UploadIcon />
</div>
<div className="text-center">
<h3 className="font-semibold text-slate-200">Upload Data File</h3>
<p className="text-xs text-slate-500 mt-1">Supports .csv, .xlsx, .xls</p>
</div>
<input
id="file-upload"
type="file"
accept=".csv, .xlsx, .xls"
onChange={handleChange}
disabled={isLoading}
className="hidden"
/>
</label>
</div>
<div className="flex items-center gap-4">
<div className="h-px bg-slate-800 flex-1"></div>
<span className="text-slate-600 text-xs font-bold uppercase">OR</span>
<div className="h-px bg-slate-800 flex-1"></div>
</div>
{/* URL Connection */}
<div className="bg-slate-900 border border-slate-800 rounded-xl p-5">
<h3 className="text-sm font-bold text-slate-200 mb-1">{activeUrl ? 'Change Source URL' : 'Connect Cloud CSV'}</h3>
<p className="text-xs text-slate-500 mb-3">Direct link to CSV (e.g. Dropbox dl=1). Auto-refreshes daily at 7 AM.</p>
<form onSubmit={handleUrlSubmit} className="flex gap-2">
<input
type="url"
placeholder="https://..."
value={url}
onChange={(e) => setUrl(e.target.value)}
className="flex-1 bg-slate-950 border border-slate-700 text-slate-200 rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 text-sm"
required
/>
<button
type="submit"
disabled={isLoading || !url.trim()}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white font-medium rounded-lg text-sm transition-colors disabled:opacity-50 whitespace-nowrap"
>
Connect
</button>
</form>
</div>
{isLoading && !activeUrl && (
<div className="flex items-center justify-center gap-2 text-indigo-400 py-2">
<div className="w-4 h-4 border-2 border-indigo-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-sm font-medium">Processing data...</span>
</div>
)}
</div>
);
};
export default FileUpload;
+78
View File
@@ -0,0 +1,78 @@
import React from 'react';
import { FilterState } from '../types';
import MultiSelectDropdown from './MultiSelectDropdown';
interface FilterBarProps {
filters: FilterState;
onFilterChange: (key: keyof FilterState, value: string[]) => void;
options: {
customer: string[];
year: string[];
month: string[];
line: string[];
asin: string[];
sku: string[];
title: string[];
};
}
const FilterBar: React.FC<FilterBarProps> = ({ filters, onFilterChange, options }) => {
return (
<div className="bg-slate-950 border-b border-border sticky top-0 z-[60] p-4 shadow-xl">
<div className="max-w-7xl mx-auto flex flex-wrap gap-4 items-end">
<MultiSelectDropdown
label="Customer"
selected={filters.customer}
options={options.customer}
onChange={(v) => onFilterChange('customer', v)}
className="flex-1"
/>
<MultiSelectDropdown
label="Year"
selected={filters.year}
options={options.year}
onChange={(v) => onFilterChange('year', v)}
className="flex-1"
/>
<MultiSelectDropdown
label="Month"
selected={filters.month}
options={options.month}
onChange={(v) => onFilterChange('month', v)}
className="flex-1"
/>
<MultiSelectDropdown
label="Product Line"
selected={filters.line}
options={options.line}
onChange={(v) => onFilterChange('line', v)}
className="flex-1"
/>
<MultiSelectDropdown
label="SKU"
selected={filters.sku}
options={options.sku}
onChange={(v) => onFilterChange('sku', v)}
className="flex-1"
/>
<MultiSelectDropdown
label="Title"
selected={filters.title}
options={options.title}
onChange={(v) => onFilterChange('title', v)}
className="flex-1"
/>
<MultiSelectDropdown
label="ASIN"
selected={filters.asin}
options={options.asin}
onChange={(v) => onFilterChange('asin', v)}
className="flex-1"
/>
</div>
</div>
);
};
export default FilterBar;
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
export const UploadIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-8 h-8">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
);
export const ChatIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12h1.5m1.5 0v-1.5m3 0h3m0 3h.008v.008h-.008v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z" />
</svg>
);
export const CloseIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
);
export const SendIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12 3.269 3.126A59.768 59.768 0 0 1 21.485 12 59.77 59.77 0 0 1 3.27 20.876L5.999 12Zm0 0h7.5" />
</svg>
);
export const ChartIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 0 0 6 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0 1 18 16.5h-2.25m-7.5 0h7.5m-7.5 0-1 3m8.5-3 1 3m0 0 .5 1.5m-.5-1.5h-9.5m0 0-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" />
</svg>
);
export const TableIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3h-7.5c-.621 0-1.125-.504-1.125-1.125m16.875-12.75a1.125 1.125 0 0 0-1.125-1.125H3.375a1.125 1.125 0 0 0-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25v1.5c0 .621.504 1.125 1.125 1.125m17.25-2.625v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0h17.25" />
</svg>
);
export const SearchIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
);
export const DownloadIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" />
</svg>
);
export const FunnelIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
</svg>
);
export const MoversIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18 9 11.25l4.306 4.305a11.164 11.164 0 0 0 5.814-5.815L21.75 6m0 0-3.5-3.5m3.5 3.5-3.5 3.5" />
</svg>
);
+198
View File
@@ -0,0 +1,198 @@
import React, { useState, useMemo } from 'react';
import { ItemGrowthMetric } from '../types';
interface ItemGrowthTableProps {
title: string;
data: ItemGrowthMetric[];
type: 'growth' | 'decline';
periods: { current: string; previous: string };
}
type SortConfig = { key: keyof ItemGrowthMetric | null; direction: 'asc' | 'desc' };
const ItemGrowthTable: React.FC<ItemGrowthTableProps> = ({ title, data, type, periods }) => {
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: null, direction: 'desc' });
const sortedData = useMemo(() => {
if (!sortConfig.key) {
// Default sort by units growth value for initial view
return [...data].sort((a, b) =>
type === 'growth' ? b.unitsGrowthValue - a.unitsGrowthValue : a.unitsGrowthValue - b.unitsGrowthValue
);
}
return [...data].sort((a, b) => {
const aVal = a[sortConfig.key!] as number | string;
const bVal = b[sortConfig.key!] as number | string;
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortConfig.direction === 'asc'
? (aVal as string).localeCompare(bVal as string)
: (bVal as string).localeCompare(aVal as string);
}
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
}, [data, sortConfig, type]);
const requestSort = (key: keyof ItemGrowthMetric) => {
let direction: 'asc' | 'desc' = 'desc';
// If already sorting by this key, toggle direction
if (sortConfig.key === key && sortConfig.direction === 'desc') {
direction = 'asc';
}
setSortConfig({ key, direction });
};
const getSortIndicator = (key: keyof ItemGrowthMetric) => {
if (sortConfig.key !== key) {
return (
<svg className="w-2.5 h-2.5 ml-1 text-slate-600 opacity-0 group-hover:opacity-50" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
);
}
return (
<svg className="w-2.5 h-2.5 ml-1 text-primary" fill="currentColor" viewBox="0 0 20 20">
{sortConfig.direction === 'asc'
? <path fillRule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clipRule="evenodd" />
: <path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
}
</svg>
);
};
return (
<div className="overflow-auto h-full relative">
<table className="w-full text-left text-sm h-full border-separate border-spacing-0">
<thead className="bg-slate-950 text-xs uppercase font-semibold text-slate-500 sticky top-0 z-10 shadow-sm">
<tr>
<th
className="px-4 py-3 bg-slate-950 min-w-[100px] cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('sku')}
>
<div className="flex items-center">SKU {getSortIndicator('sku')}</div>
</th>
<th
className="px-4 py-3 bg-slate-950 min-w-[100px] cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('asin')}
>
<div className="flex items-center">ASIN {getSortIndicator('asin')}</div>
</th>
<th
className="px-4 py-3 bg-slate-950 min-w-[200px] cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('title')}
>
<div className="flex items-center">Product Title {getSortIndicator('title')}</div>
</th>
<th
className="px-4 py-3 bg-slate-950 min-w-[120px] cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('line')}
>
<div className="flex items-center">Product Line {getSortIndicator('line')}</div>
</th>
{/* Sell Out Columns */}
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-400 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('previousYearSellOut')}
>
<div className="flex items-center justify-end">Sell Out {periods.previous} {getSortIndicator('previousYearSellOut')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-200 cursor-pointer hover:text-white group select-none transition-colors"
onClick={() => requestSort('currentYearSellOut')}
>
<div className="flex items-center justify-end">Sell Out {periods.current} {getSortIndicator('currentYearSellOut')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('sellOutGrowthValue')}
>
<div className="flex items-center justify-end">SO Diff {getSortIndicator('sellOutGrowthValue')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('sellOutGrowthPercentage')}
>
<div className="flex items-center justify-end">SO Growth % {getSortIndicator('sellOutGrowthPercentage')}</div>
</th>
{/* Units Columns */}
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-400 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('previousYearUnits')}
>
<div className="flex items-center justify-end">Units {periods.previous} {getSortIndicator('previousYearUnits')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 text-slate-200 cursor-pointer hover:text-white group select-none transition-colors"
onClick={() => requestSort('currentYearUnits')}
>
<div className="flex items-center justify-end">Units {periods.current} {getSortIndicator('currentYearUnits')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('unitsGrowthValue')}
>
<div className="flex items-center justify-end">Units Diff {getSortIndicator('unitsGrowthValue')}</div>
</th>
<th
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
onClick={() => requestSort('unitsGrowthPercentage')}
>
<div className="flex items-center justify-end">Units Growth % {getSortIndicator('unitsGrowthPercentage')}</div>
</th>
</tr>
</thead>
<tbody className="divide-y divide-border text-slate-300">
{sortedData.length > 0 ? (
sortedData.map((item, idx) => (
<tr key={idx} className="hover:bg-slate-800/50">
<td className="px-4 py-2 font-medium">{item.sku || '-'}</td>
<td className="px-4 py-2">{item.asin || '-'}</td>
<td className="px-4 py-2">{item.title || '-'}</td>
<td className="px-4 py-2">{item.line || '-'}</td>
{/* Sell Out Columns */}
<td className="px-4 py-2 text-right text-slate-400">{item.previousYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}</td>
<td className="px-4 py-2 text-right font-medium text-slate-200">{item.currentYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}</td>
<td className={`px-4 py-2 text-right font-medium ${item.sellOutGrowthValue >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{item.sellOutGrowthValue > 0 ? '+' : ''}{item.sellOutGrowthValue.toLocaleString(undefined, {maximumFractionDigits: 0})}
</td>
<td className="px-4 py-2 text-right">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${item.sellOutGrowthPercentage >= 0 ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>
{item.sellOutGrowthPercentage.toFixed(1)}%
</span>
</td>
{/* Units Columns */}
<td className="px-4 py-2 text-right text-slate-400">{item.previousYearUnits.toLocaleString()}</td>
<td className="px-4 py-2 text-right font-medium text-slate-200">{item.currentYearUnits.toLocaleString()}</td>
<td className={`px-4 py-2 text-right font-medium ${item.unitsGrowthValue >= 0 ? 'text-violet-400' : 'text-orange-400'}`}>
{item.unitsGrowthValue > 0 ? '+' : ''}{item.unitsGrowthValue.toLocaleString()}
</td>
<td className="px-4 py-2 text-right">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${item.unitsGrowthPercentage >= 0 ? 'bg-violet-500/10 text-violet-400' : 'bg-orange-500/10 text-orange-400'}`}>
{item.unitsGrowthPercentage.toFixed(1)}%
</span>
</td>
</tr>
))
) : (
<tr>
<td colSpan={13} className="px-4 py-6 text-center text-slate-500 italic">
Insufficient data to calculate {type}. (Select a customer and at least 2 distinct years/periods)
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
export default ItemGrowthTable;
+151
View File
@@ -0,0 +1,151 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { SearchIcon } from './Icons';
interface MultiSelectDropdownProps {
label: string;
selected: string[];
options: string[];
onChange: (newSelected: string[]) => void;
className?: string;
}
const MultiSelectDropdown: React.FC<MultiSelectDropdownProps> = ({ label, selected, options, onChange, className }) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Focus input when opening
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
if (!isOpen) {
setSearchTerm(''); // Reset search when closing
}
}, [isOpen]);
const filteredOptions = useMemo(() => {
if (!searchTerm) return options;
return options.filter(opt => opt.toLowerCase().includes(searchTerm.toLowerCase()));
}, [options, searchTerm]);
const toggleOption = (option: string) => {
if (selected.includes(option)) {
onChange(selected.filter((item) => item !== option));
} else {
onChange([...selected, option]);
}
};
const handleSelectAll = () => {
// If searching, only select/deselect visible options
if (searchTerm) {
const allFilteredSelected = filteredOptions.every(opt => selected.includes(opt));
if (allFilteredSelected) {
// Deselect all filtered options
onChange(selected.filter(item => !filteredOptions.includes(item)));
} else {
// Select all filtered options (add unique ones)
const newSelected = Array.from(new Set([...selected, ...filteredOptions]));
onChange(newSelected);
}
} else {
// Standard behavior
if (selected.length === options.length) {
onChange([]); // Deselect all
} else {
onChange([...options]); // Select all
}
}
};
const handleClear = () => {
onChange([]);
};
return (
<div className={`flex flex-col min-w-[150px] relative ${className}`} ref={dropdownRef}>
<label className="text-xs font-semibold text-slate-400 mb-1 uppercase tracking-wider">{label}</label>
<button
onClick={() => setIsOpen(!isOpen)}
className={`w-full text-left bg-surface border border-border hover:border-slate-600 text-sm rounded-lg py-2 px-3 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors flex justify-between items-center
${selected.length > 0 ? 'text-white font-medium border-primary/50' : 'text-slate-400'}`}
>
<span className="truncate">
{selected.length === 0
? (label === 'Customer' ? 'All Customers' : `All ${label}s`)
: `${selected.length} selected`}
</span>
<svg className={`fill-current h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z" />
</svg>
</button>
{isOpen && (
<div className="absolute top-[calc(100%+4px)] left-0 w-64 max-h-96 overflow-hidden bg-slate-900 border border-slate-700 rounded-xl shadow-2xl z-[100] animate-fade-in flex flex-col">
{/* Search Bar */}
<div className="p-2 border-b border-slate-800 sticky top-0 bg-slate-900 z-10">
<div className="relative">
<span className="absolute left-2.5 top-2.5 text-slate-500">
<SearchIcon />
</span>
<input
ref={inputRef}
type="text"
placeholder={`Search ${label}...`}
className="w-full bg-slate-950 border border-slate-700 text-slate-200 text-sm rounded-md py-1.5 pl-9 pr-2 focus:outline-none focus:border-primary"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="flex justify-between py-2 px-2 border-b border-slate-800 bg-slate-900/50">
<button onClick={handleSelectAll} className="text-xs text-primary hover:text-indigo-400 font-medium px-2">
{searchTerm
? (filteredOptions.every(opt => selected.includes(opt)) ? 'Unselect Results' : 'Select Results')
: (selected.length === options.length ? 'Unselect All' : 'Select All')
}
</button>
<button onClick={handleClear} className="text-xs text-slate-400 hover:text-white px-2">
Clear
</button>
</div>
<div className="space-y-1 overflow-y-auto custom-scrollbar p-2 max-h-60">
{filteredOptions.map((opt) => (
<label key={opt} className="flex items-center space-x-3 p-2 rounded hover:bg-slate-800 cursor-pointer group">
<input
type="checkbox"
checked={selected.includes(opt)}
onChange={() => toggleOption(opt)}
className="form-checkbox h-4 w-4 text-primary rounded border-slate-600 bg-slate-800 focus:ring-primary focus:ring-offset-slate-900 transition duration-150 ease-in-out"
/>
<span className={`text-sm break-all group-hover:text-white ${selected.includes(opt) ? 'text-white' : 'text-slate-400'}`}>
{opt}
</span>
</label>
))}
{filteredOptions.length === 0 && <div className="p-4 text-center text-xs text-slate-500">No matches found</div>}
</div>
</div>
)}
</div>
);
};
export default MultiSelectDropdown;
+92
View File
@@ -0,0 +1,92 @@
import React, { useState, useMemo } from 'react';
import { SalesRecord } from '../types';
import { calculateItemMovers, getUniqueValues, generateItemMoversCSV } from '../services/dataProcessor'; // Import generateItemMoversCSV
import ItemGrowthTable from './ItemGrowthTable';
import { ExpandableCard } from './Dashboard'; // Re-use ExpandableCard from Dashboard
interface TopMoversPageProps {
filteredData: SalesRecord[]; // Data already filtered by global customer, year, month, etc.
}
const TopMoversPage: React.FC<TopMoversPageProps> = ({ filteredData }) => {
// Local state for the specific comparison year, initially null for auto-selection
const [selectedComparisonYear, setSelectedComparisonYear] = useState<string | null>(null);
// Derive available years from the *currently filtered data* for the comparison year dropdown
const availableYearsForComparisonDropdown = useMemo(() => {
const yearsInFilteredData = getUniqueValues(filteredData, 'year');
// Sort descending for the dropdown
return yearsInFilteredData.sort((a,b) => parseInt(b) - parseInt(a));
}, [filteredData]);
// Derive top/bottom movers based on selections
const { topMovers, bottomMovers, comparisonPeriods } = useMemo(() => {
const comparisonYearNum = selectedComparisonYear ? parseInt(selectedComparisonYear) : null;
// Pass the globally filtered data. The global filter bar now handles customer/line/sku/etc filtering.
// We pass null for the local customer override as it is no longer used.
return calculateItemMovers(filteredData, null, comparisonYearNum);
}, [filteredData, selectedComparisonYear]);
// Handlers for export
const handleExportGainers = () => {
generateItemMoversCSV(topMovers, comparisonPeriods, 'Gainers');
};
const handleExportLosers = () => {
generateItemMoversCSV(bottomMovers, comparisonPeriods, 'Losers');
};
return (
<div className="p-6 space-y-6 max-w-7xl mx-auto animate-fade-in pb-24">
<div className="flex flex-wrap justify-between items-center gap-4 mb-6">
<h2 className="text-2xl font-bold text-white">Top Item Movers</h2>
<div className="bg-slate-900 border border-border rounded-xl px-4 py-2 flex items-center gap-3">
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap">Comparison Year</label>
<select
value={selectedComparisonYear || ''}
onChange={(e) => setSelectedComparisonYear(e.target.value || null)}
className="bg-surface border border-border hover:border-slate-600 text-sm rounded-lg py-1.5 px-3 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors text-white"
>
<option value="">Auto (Latest 2 Years)</option>
{availableYearsForComparisonDropdown.map(year => (
<option key={year} value={year}>{year}</option>
))}
</select>
</div>
</div>
{/* Top 20 Gainers Table */}
<ExpandableCard
title={`Top 20 Gainers (${comparisonPeriods.current} vs ${comparisonPeriods.previous})`}
className="h-[500px]"
onExport={handleExportGainers} // Pass export handler
exportFileName={`Top_20_Gainers_${comparisonPeriods.current}_vs_${comparisonPeriods.previous}`}
>
<ItemGrowthTable
title={`Top 20 Gainers (${comparisonPeriods.current} vs ${comparisonPeriods.previous})`}
data={topMovers}
type="growth"
periods={comparisonPeriods}
/>
</ExpandableCard>
{/* Top 20 Losers Table */}
<ExpandableCard
title={`Top 20 Losers (${comparisonPeriods.current} vs ${comparisonPeriods.previous})`}
className="h-[500px]"
onExport={handleExportLosers} // Pass export handler
exportFileName={`Top_20_Losers_${comparisonPeriods.current}_vs_${comparisonPeriods.previous}`}
>
<ItemGrowthTable
title={`Top 20 Losers (${comparisonPeriods.current} vs ${comparisonPeriods.previous})`}
data={bottomMovers}
type="decline"
periods={comparisonPeriods}
/>
</ExpandableCard>
</div>
);
};
export default TopMoversPage;