mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:25:22 +02:00
feat: Integrate PapaParse for CSV handling
Adds papaparse as a dependency and updates the data processing service to use it for more robust CSV file parsing. This replaces manual CSV parsing logic with a dedicated library, improving reliability and handling of various CSV formats. Also renames the `MoversIcon` to `TrendingIcon` to better reflect its usage in indicating trending performance metrics.
This commit is contained in:
+53
-126
@@ -1,4 +1,3 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { ChatIcon, CloseIcon, SendIcon } from './Icons';
|
||||
import { ChatMessage } from '../types';
|
||||
@@ -7,8 +6,6 @@ interface AIChatProps {
|
||||
onSendMessage: (text: string) => Promise<string>;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
apiKey: string;
|
||||
onApiKeyChange: (key: string) => void;
|
||||
}
|
||||
|
||||
const ModelMessage: React.FC<{ text: string }> = ({ text }) => {
|
||||
@@ -58,37 +55,23 @@ const ModelMessage: React.FC<{ text: string }> = ({ text }) => {
|
||||
return <>{elements.length > 0 ? elements : <p>{text}</p>}</>;
|
||||
};
|
||||
|
||||
const SettingsIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 1 1 0-9h.75c.704 0 1.402-.03 2.09-.09a1.692 1.692 0 0 1 1.624 1.374c.11 1.054.547 2.028 1.218 2.822.67.793 1.644 1.23 2.697 1.34a1.694 1.694 0 0 1 1.374 1.625c.06.688.09 1.386.09 2.09v.75a4.5 4.5 0 1 1-9 0v-.75c0-.704-.03-1.402-.09-2.09a1.692 1.692 0 0 1-1.374-1.624 11.264 11.264 0 0 0-1.34-2.698 11.263 11.263 0 0 0-2.822-1.217A1.692 1.692 0 0 1 10.34 15.84Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
|
||||
const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen, apiKey, onApiKeyChange }) => {
|
||||
const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen }) => {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([
|
||||
{ role: 'model', text: 'Hello! I am your AI Data Analyst. I can answer questions about your data, analyze trends, and perform calculations.', timestamp: new Date() }
|
||||
{ role: 'model', text: 'Hello! I am your Sales Data Assistant. Ask me anything about the loaded data.', timestamp: new Date() }
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [showConfig, setShowConfig] = useState(!apiKey);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) scrollToBottom();
|
||||
}, [messages, isOpen, showConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
// If no API key is present when opened, show config
|
||||
if (!apiKey) setShowConfig(true);
|
||||
}, [apiKey]);
|
||||
useEffect(scrollToBottom, [messages, isOpen]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || !apiKey) return;
|
||||
if (!input.trim()) return;
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', text: input, timestamp: new Date() };
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
@@ -108,12 +91,6 @@ const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen, apiKe
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveKey = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Input value is already bound to parent state via local var, but we use form submission to switch view
|
||||
if (apiKey) setShowConfig(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Trigger Button */}
|
||||
@@ -135,113 +112,63 @@ const AIChat: React.FC<AIChatProps> = ({ onSendMessage, isOpen, setIsOpen, apiKe
|
||||
{/* Header */}
|
||||
<div className="bg-primary/10 p-4 border-b border-border flex justify-between items-center backdrop-blur">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${apiKey ? 'bg-green-400 animate-pulse' : 'bg-red-500'}`}></div>
|
||||
<h3 className="font-bold text-slate-100">AI Data Assistant</h3>
|
||||
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div>
|
||||
<h3 className="font-bold text-slate-100">Data Assistant</h3>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setIsOpen(false)} className="text-slate-400 hover:text-white transition-colors">
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-950/50">
|
||||
{messages.map((msg, idx) => (
|
||||
<div key={idx} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
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 trends, totals..."
|
||||
className="w-full bg-slate-950 border border-slate-700 text-slate-200 rounded-full py-3 pl-4 pr-12 focus:outline-none focus:border-primary transition-colors placeholder-slate-500 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowConfig(!showConfig)}
|
||||
className="text-slate-400 hover:text-white transition-colors p-1 rounded hover:bg-white/10"
|
||||
title="API Settings"
|
||||
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"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
<button onClick={() => setIsOpen(false)} className="text-slate-400 hover:text-white transition-colors p-1 rounded hover:bg-white/10">
|
||||
<CloseIcon />
|
||||
<SendIcon />
|
||||
</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;
|
||||
export default AIChat;
|
||||
+123
-156
@@ -1,13 +1,10 @@
|
||||
|
||||
|
||||
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { AggregatedData, LineGrowthMetric } from '../types'; // Updated import for LineGrowthMetric
|
||||
import { AggregatedData, GrowthMetric } from '../types';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
LineChart, Line, Legend
|
||||
} from 'recharts';
|
||||
import { DownloadIcon } from './Icons'; // Import DownloadIcon
|
||||
|
||||
interface DashboardProps {
|
||||
data: AggregatedData;
|
||||
@@ -17,13 +14,7 @@ interface DashboardProps {
|
||||
const COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#8b5cf6', '#0ea5e9'];
|
||||
|
||||
// Reusable Expandable Card Component
|
||||
export const ExpandableCard: React.FC<{
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onExport?: () => void; // Optional export function
|
||||
exportFileName?: string; // Optional export file name
|
||||
}> = ({ title, children, className, onExport, exportFileName }) => {
|
||||
const ExpandableCard: React.FC<{ title: string; children: React.ReactNode; className?: string }> = ({ title, children, className }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const toggleExpand = () => setIsExpanded(!isExpanded);
|
||||
@@ -58,14 +49,6 @@ export const ExpandableCard: React.FC<{
|
||||
|
||||
<div className="flex justify-between items-center mb-4 border-b border-slate-800 pb-4 shrink-0">
|
||||
<h3 className="text-xl font-bold text-slate-100 uppercase tracking-wide">{title}</h3>
|
||||
{onExport && (
|
||||
<button
|
||||
onClick={onExport}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors shadow-sm"
|
||||
>
|
||||
<DownloadIcon /> Export CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto bg-slate-900 rounded-xl p-6 border border-border custom-scrollbar">
|
||||
{children}
|
||||
@@ -80,26 +63,15 @@ export const ExpandableCard: React.FC<{
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wide">{title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{onExport && (
|
||||
<button
|
||||
onClick={onExport}
|
||||
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-indigo-400 transition-opacity"
|
||||
title={`Export ${exportFileName || title} to CSV`}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleExpand}
|
||||
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-primary transition-opacity"
|
||||
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>
|
||||
<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="flex-1 min-h-[250px] cursor-pointer" onClick={toggleExpand}>{children}</div>
|
||||
</div>
|
||||
@@ -325,11 +297,11 @@ const ComparisonTooltip = ({ active, payload, label, metric }: any) => {
|
||||
|
||||
const GrowthTable: React.FC<{
|
||||
title: string;
|
||||
data: LineGrowthMetric[]; // Updated to LineGrowthMetric
|
||||
data: GrowthMetric[];
|
||||
type: 'growth' | 'decline';
|
||||
periods: { current: string; previous: string };
|
||||
}> = ({ title, data, type, periods }) => {
|
||||
const [sortConfig, setSortConfig] = useState<{ key: keyof LineGrowthMetric | null; direction: 'asc' | 'desc' }>({ key: null, direction: 'desc' });
|
||||
const [sortConfig, setSortConfig] = useState<{ key: keyof GrowthMetric | null; direction: 'asc' | 'desc' }>({ key: null, direction: 'desc' });
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortConfig.key) return data;
|
||||
@@ -344,7 +316,7 @@ const GrowthTable: React.FC<{
|
||||
});
|
||||
}, [data, sortConfig]);
|
||||
|
||||
const requestSort = (key: keyof LineGrowthMetric) => {
|
||||
const requestSort = (key: keyof GrowthMetric) => {
|
||||
let direction: 'asc' | 'desc' = 'desc';
|
||||
// If already sorting by this key, toggle direction
|
||||
if (sortConfig.key === key && sortConfig.direction === 'desc') {
|
||||
@@ -353,7 +325,7 @@ const GrowthTable: React.FC<{
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
const getSortIndicator = (key: keyof LineGrowthMetric) => {
|
||||
const getSortIndicator = (key: keyof GrowthMetric) => {
|
||||
if (sortConfig.key !== key) {
|
||||
return (
|
||||
<svg className="w-2.5 h-2.5 ml-1 text-slate-600 opacity-0 group-hover:opacity-50" fill="currentColor" viewBox="0 0 20 20">
|
||||
@@ -372,112 +344,113 @@ const GrowthTable: React.FC<{
|
||||
};
|
||||
|
||||
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>
|
||||
<ExpandableCard title={title} className="h-full">
|
||||
<div className="overflow-auto h-full relative">
|
||||
<table className="w-full text-left text-sm h-full border-separate border-spacing-0">
|
||||
<thead className="bg-slate-950 text-xs uppercase font-semibold text-slate-500 sticky top-0 z-10 shadow-sm">
|
||||
<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 */}
|
||||
<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})}
|
||||
</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>
|
||||
{/* 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>
|
||||
))
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -572,30 +545,24 @@ const Dashboard: React.FC<DashboardProps> = ({ data, contextData }) => {
|
||||
</ExpandableCard>
|
||||
|
||||
{/* Growth Table */}
|
||||
<ExpandableCard
|
||||
title={contextData ? "Fastest Growing Lines (Full Line Context)" : "Fastest Growing Lines (YoY - €)"}
|
||||
className="h-[400px]" // Provide a default height for the card
|
||||
>
|
||||
<div className="h-72">
|
||||
<GrowthTable
|
||||
title={contextData ? "Fastest Growing Lines (Full Line Context)" : "Fastest Growing Lines (YoY - €)"}
|
||||
data={displayData.topMovers}
|
||||
type="growth"
|
||||
periods={displayData.comparisonPeriods}
|
||||
/>
|
||||
</ExpandableCard>
|
||||
</div>
|
||||
|
||||
{/* Decline Table */}
|
||||
<ExpandableCard
|
||||
title={contextData ? "Declining Lines (Full Line Context)" : "Declining Lines (YoY - €)"}
|
||||
className="h-[400px]" // Provide a default height for the card
|
||||
>
|
||||
<div className="h-72">
|
||||
<GrowthTable
|
||||
title={contextData ? "Declining Lines (Full Line Context)" : "Declining Lines (YoY - €)"}
|
||||
data={displayData.bottomMovers}
|
||||
type="decline"
|
||||
periods={displayData.comparisonPeriods}
|
||||
/>
|
||||
</ExpandableCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
@@ -709,4 +676,4 @@ const Dashboard: React.FC<DashboardProps> = ({ data, contextData }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
export default Dashboard;
|
||||
|
||||
+343
-579
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
|
||||
import React, { ChangeEvent, useState } from 'react';
|
||||
import { UploadIcon } from './Icons';
|
||||
|
||||
@@ -85,13 +86,13 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
<UploadIcon />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-slate-200">Upload Data File</h3>
|
||||
<p className="text-xs text-slate-500 mt-1">Supports .csv, .xlsx, .xls</p>
|
||||
<h3 className="font-semibold text-slate-200">Upload Local CSV</h3>
|
||||
<p className="text-xs text-slate-500 mt-1">Click to select file</p>
|
||||
</div>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".csv, .xlsx, .xls"
|
||||
accept=".csv"
|
||||
onChange={handleChange}
|
||||
disabled={isLoading}
|
||||
className="hidden"
|
||||
@@ -138,4 +139,4 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUpload;
|
||||
export default FileUpload;
|
||||
|
||||
@@ -56,8 +56,8 @@ export const FunnelIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const MoversIcon = () => (
|
||||
export const TrendingIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18 9 11.25l4.306 4.305a11.164 11.164 0 0 0 5.814-5.815L21.75 6m0 0-3.5-3.5m3.5 3.5-3.5 3.5" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18 9 11.25l4.306 4.307a11.95 11.95 0 0 1 5.814-5.519l2.74-1.22m0 0-5.94-2.28m5.94 2.28-2.28 5.941" />
|
||||
</svg>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ItemGrowthMetric } from '../types';
|
||||
|
||||
interface ItemGrowthTableProps {
|
||||
title: string;
|
||||
data: ItemGrowthMetric[];
|
||||
type: 'growth' | 'decline';
|
||||
periods: { current: string; previous: string };
|
||||
}
|
||||
|
||||
type SortConfig = { key: keyof ItemGrowthMetric | null; direction: 'asc' | 'desc' };
|
||||
|
||||
const ItemGrowthTable: React.FC<ItemGrowthTableProps> = ({ title, data, type, periods }) => {
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: null, direction: 'desc' });
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortConfig.key) {
|
||||
// Default sort by units growth value for initial view
|
||||
return [...data].sort((a, b) =>
|
||||
type === 'growth' ? b.unitsGrowthValue - a.unitsGrowthValue : a.unitsGrowthValue - b.unitsGrowthValue
|
||||
);
|
||||
}
|
||||
|
||||
return [...data].sort((a, b) => {
|
||||
const aVal = a[sortConfig.key!] as number | string;
|
||||
const bVal = b[sortConfig.key!] as number | string;
|
||||
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
return sortConfig.direction === 'asc'
|
||||
? (aVal as string).localeCompare(bVal as string)
|
||||
: (bVal as string).localeCompare(aVal as string);
|
||||
}
|
||||
|
||||
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [data, sortConfig, type]);
|
||||
|
||||
const requestSort = (key: keyof ItemGrowthMetric) => {
|
||||
let direction: 'asc' | 'desc' = 'desc';
|
||||
// If already sorting by this key, toggle direction
|
||||
if (sortConfig.key === key && sortConfig.direction === 'desc') {
|
||||
direction = 'asc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
const getSortIndicator = (key: keyof ItemGrowthMetric) => {
|
||||
if (sortConfig.key !== key) {
|
||||
return (
|
||||
<svg className="w-2.5 h-2.5 ml-1 text-slate-600 opacity-0 group-hover:opacity-50" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-2.5 h-2.5 ml-1 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||
{sortConfig.direction === 'asc'
|
||||
? <path fillRule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
: <path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full relative">
|
||||
<table className="w-full text-left text-sm h-full border-separate border-spacing-0">
|
||||
<thead className="bg-slate-950 text-xs uppercase font-semibold text-slate-500 sticky top-0 z-10 shadow-sm">
|
||||
<tr>
|
||||
<th
|
||||
className="px-4 py-3 bg-slate-950 min-w-[100px] cursor-pointer hover:text-slate-300 group select-none transition-colors"
|
||||
onClick={() => requestSort('sku')}
|
||||
>
|
||||
<div className="flex items-center">SKU {getSortIndicator('sku')}</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 bg-slate-950 min-w-[100px] cursor-pointer hover:text-slate-300 group select-none transition-colors"
|
||||
onClick={() => requestSort('asin')}
|
||||
>
|
||||
<div className="flex items-center">ASIN {getSortIndicator('asin')}</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 bg-slate-950 min-w-[200px] cursor-pointer hover:text-slate-300 group select-none transition-colors"
|
||||
onClick={() => requestSort('title')}
|
||||
>
|
||||
<div className="flex items-center">Product Title {getSortIndicator('title')}</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 bg-slate-950 min-w-[120px] cursor-pointer hover:text-slate-300 group select-none transition-colors"
|
||||
onClick={() => requestSort('line')}
|
||||
>
|
||||
<div className="flex items-center">Product Line {getSortIndicator('line')}</div>
|
||||
</th>
|
||||
|
||||
{/* Sell Out Columns */}
|
||||
<th
|
||||
className="px-4 py-3 text-right bg-slate-950 text-slate-400 cursor-pointer hover:text-slate-300 group select-none transition-colors"
|
||||
onClick={() => requestSort('previousYearSellOut')}
|
||||
>
|
||||
<div className="flex items-center justify-end">Sell Out {periods.previous} {getSortIndicator('previousYearSellOut')}</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right bg-slate-950 text-slate-200 cursor-pointer hover:text-white group select-none transition-colors"
|
||||
onClick={() => requestSort('currentYearSellOut')}
|
||||
>
|
||||
<div className="flex items-center justify-end">Sell Out {periods.current} {getSortIndicator('currentYearSellOut')}</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
|
||||
onClick={() => requestSort('sellOutGrowthValue')}
|
||||
>
|
||||
<div className="flex items-center justify-end">SO Diff {getSortIndicator('sellOutGrowthValue')}</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
|
||||
onClick={() => requestSort('sellOutGrowthPercentage')}
|
||||
>
|
||||
<div className="flex items-center justify-end">SO Growth % {getSortIndicator('sellOutGrowthPercentage')}</div>
|
||||
</th>
|
||||
|
||||
{/* Units Columns */}
|
||||
<th
|
||||
className="px-4 py-3 text-right bg-slate-950 text-slate-400 cursor-pointer hover:text-slate-300 group select-none transition-colors"
|
||||
onClick={() => requestSort('previousYearUnits')}
|
||||
>
|
||||
<div className="flex items-center justify-end">Units {periods.previous} {getSortIndicator('previousYearUnits')}</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right bg-slate-950 text-slate-200 cursor-pointer hover:text-white group select-none transition-colors"
|
||||
onClick={() => requestSort('currentYearUnits')}
|
||||
>
|
||||
<div className="flex items-center justify-end">Units {periods.current} {getSortIndicator('currentYearUnits')}</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
|
||||
onClick={() => requestSort('unitsGrowthValue')}
|
||||
>
|
||||
<div className="flex items-center justify-end">Units Diff {getSortIndicator('unitsGrowthValue')}</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-right bg-slate-950 cursor-pointer hover:text-slate-300 group select-none transition-colors"
|
||||
onClick={() => requestSort('unitsGrowthPercentage')}
|
||||
>
|
||||
<div className="flex items-center justify-end">Units Growth % {getSortIndicator('unitsGrowthPercentage')}</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border text-slate-300">
|
||||
{sortedData.length > 0 ? (
|
||||
sortedData.map((item, idx) => (
|
||||
<tr key={idx} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-2 font-medium">{item.sku || '-'}</td>
|
||||
<td className="px-4 py-2">{item.asin || '-'}</td>
|
||||
<td className="px-4 py-2">{item.title || '-'}</td>
|
||||
<td className="px-4 py-2">{item.line || '-'}</td>
|
||||
|
||||
{/* Sell Out Columns */}
|
||||
<td className="px-4 py-2 text-right text-slate-400">€{item.previousYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}</td>
|
||||
<td className="px-4 py-2 text-right font-medium text-slate-200">€{item.currentYearSellOut.toLocaleString(undefined, {maximumFractionDigits: 0})}</td>
|
||||
<td className={`px-4 py-2 text-right font-medium ${item.sellOutGrowthValue >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{item.sellOutGrowthValue > 0 ? '+' : ''}€{item.sellOutGrowthValue.toLocaleString(undefined, {maximumFractionDigits: 0})}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${item.sellOutGrowthPercentage >= 0 ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'}`}>
|
||||
{item.sellOutGrowthPercentage.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Units Columns */}
|
||||
<td className="px-4 py-2 text-right text-slate-400">{item.previousYearUnits.toLocaleString()}</td>
|
||||
<td className="px-4 py-2 text-right font-medium text-slate-200">{item.currentYearUnits.toLocaleString()}</td>
|
||||
<td className={`px-4 py-2 text-right font-medium ${item.unitsGrowthValue >= 0 ? 'text-violet-400' : 'text-orange-400'}`}>
|
||||
{item.unitsGrowthValue > 0 ? '+' : ''}{item.unitsGrowthValue.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${item.unitsGrowthPercentage >= 0 ? 'bg-violet-500/10 text-violet-400' : 'bg-orange-500/10 text-orange-400'}`}>
|
||||
{item.unitsGrowthPercentage.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={13} className="px-4 py-6 text-center text-slate-500 italic">
|
||||
Insufficient data to calculate {type}. (Select a customer and at least 2 distinct years/periods)
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemGrowthTable;
|
||||
@@ -0,0 +1,326 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { SalesRecord } from '../types';
|
||||
import { DownloadIcon } from './Icons';
|
||||
|
||||
interface TopMoversProps {
|
||||
data: SalesRecord[];
|
||||
}
|
||||
|
||||
type Metric = 'sellOut' | 'units';
|
||||
|
||||
interface SkuAggr {
|
||||
sku: string;
|
||||
title: string;
|
||||
line: string;
|
||||
previousValue: number;
|
||||
currentValue: number;
|
||||
diff: number;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
// Reusable Table Component
|
||||
const MoversTable: React.FC<{
|
||||
title: string;
|
||||
data: SkuAggr[];
|
||||
metric: Metric;
|
||||
previousYear: number;
|
||||
currentYear: number;
|
||||
type: 'growth' | 'decline';
|
||||
}> = ({ title, data, metric, previousYear, currentYear, type }) => {
|
||||
|
||||
const formatValue = (val: number) => {
|
||||
if (metric === 'sellOut') return `€${val.toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
|
||||
return val.toLocaleString();
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
// Helper to force Comma as thousands separator (US Locale)
|
||||
const formatForCSV = (val: number) => {
|
||||
return val.toLocaleString('en-US', {
|
||||
useGrouping: true,
|
||||
minimumFractionDigits: metric === 'sellOut' ? 2 : 0,
|
||||
maximumFractionDigits: metric === 'sellOut' ? 2 : 0,
|
||||
});
|
||||
};
|
||||
|
||||
// Prepare data for CSV
|
||||
const csvData = data.map((item, index) => ({
|
||||
Rank: index + 1,
|
||||
Title: item.title,
|
||||
SKU: item.sku,
|
||||
'Product Line': item.line,
|
||||
[`${previousYear} ${metric === 'sellOut' ? 'Sell Out' : 'Units'}`]: formatForCSV(item.previousValue),
|
||||
[`${currentYear} ${metric === 'sellOut' ? 'Sell Out' : 'Units'}`]: formatForCSV(item.currentValue),
|
||||
'Difference': formatForCSV(item.diff),
|
||||
'% Change': `${item.pct.toFixed(2)}%`
|
||||
}));
|
||||
|
||||
// Generate CSV string
|
||||
// @ts-ignore - Papa is loaded globally via CDN
|
||||
const csv = Papa.unparse(csvData);
|
||||
|
||||
// Create download link
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
const filename = `${title.replace(/\s+/g, '_')}_${currentYear}_vs_${previousYear}.csv`;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const colorClass = type === 'growth' ? 'text-emerald-400' : 'text-rose-400';
|
||||
const bgClass = type === 'growth' ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400';
|
||||
const headerColor = type === 'growth' ? 'border-emerald-500/30' : 'border-rose-500/30';
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-border rounded-xl shadow-lg overflow-hidden flex flex-col h-full">
|
||||
<div className={`px-6 py-4 border-b ${headerColor} bg-slate-900/50 flex justify-between items-center`}>
|
||||
<h3 className={`text-lg font-bold flex items-center gap-2 ${colorClass}`}>
|
||||
{type === 'growth' ? '🚀 ' : '📉 '} {title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-medium border border-slate-700 transition-colors"
|
||||
title="Export to CSV"
|
||||
>
|
||||
<DownloadIcon />
|
||||
<span className="hidden sm:inline">Export</span>
|
||||
</button>
|
||||
<span className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Top 20</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-950 text-slate-400 uppercase text-xs font-semibold tracking-wider">
|
||||
<th className="px-6 py-3 border-b border-border w-16 text-center">Rank</th>
|
||||
<th className="px-6 py-3 border-b border-border">SKU Details</th>
|
||||
<th className="px-6 py-3 border-b border-border">Product Line</th>
|
||||
<th className="px-6 py-3 border-b border-border text-right">{previousYear}</th>
|
||||
<th className="px-6 py-3 border-b border-border text-right">{currentYear}</th>
|
||||
<th className="px-6 py-3 border-b border-border text-right">Diff</th>
|
||||
<th className="px-6 py-3 border-b border-border text-right">% Change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{data.map((item, index) => {
|
||||
const isPositive = item.diff >= 0;
|
||||
|
||||
return (
|
||||
<tr key={item.sku} className="hover:bg-slate-800/50 transition-colors group">
|
||||
<td className="px-6 py-3 text-center font-mono text-slate-500 font-bold">
|
||||
{index + 1}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white font-medium text-base truncate max-w-xs" title={item.title}>
|
||||
{item.title || 'Unknown Title'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 font-mono mt-0.5">SKU: {item.sku}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-slate-400">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-800 text-slate-300 border border-slate-700">
|
||||
{item.line}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-3 text-right text-slate-500">
|
||||
{formatValue(item.previousValue)}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-3 text-right font-bold text-slate-200 group-hover:text-white">
|
||||
{formatValue(item.currentValue)}
|
||||
</td>
|
||||
|
||||
<td className={`px-6 py-3 text-right font-medium ${isPositive ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
{isPositive ? '+' : ''}{formatValue(item.diff)}
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-3 text-right">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-bold w-20 justify-center ${bgClass}`}>
|
||||
{isPositive ? '↑' : '↓'} {Math.abs(item.pct).toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{data.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-slate-500 italic">
|
||||
No records found matching this criteria.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TopMovers: React.FC<TopMoversProps> = ({ data }) => {
|
||||
const [metric, setMetric] = useState<Metric>('sellOut');
|
||||
const [viewMode, setViewMode] = useState<'growth' | 'decline'>('growth');
|
||||
|
||||
// 1. Determine comparison years from filtered data
|
||||
const { currentYear, previousYear, availableYears } = useMemo(() => {
|
||||
const years = Array.from(new Set(data.map(d => d.year))).sort((a: number, b: number) => b - a);
|
||||
return {
|
||||
currentYear: years[0],
|
||||
previousYear: years[1],
|
||||
availableYears: years
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
// 2. Aggregation Logic
|
||||
const { growers, decliners } = useMemo(() => {
|
||||
if (!currentYear || !previousYear) return { growers: [], decliners: [] };
|
||||
|
||||
// Map: SKU -> { currentVal, previousVal, metadata }
|
||||
const map = new Map<string, { current: number; previous: number; title: string; line: string }>();
|
||||
|
||||
data.forEach(row => {
|
||||
// Only care about the two comparison years
|
||||
if (row.year !== currentYear && row.year !== previousYear) return;
|
||||
|
||||
if (!map.has(row.sku)) {
|
||||
map.set(row.sku, { current: 0, previous: 0, title: row.title, line: row.line });
|
||||
}
|
||||
|
||||
const entry = map.get(row.sku)!;
|
||||
const value = metric === 'sellOut' ? row.sellOut : row.units;
|
||||
|
||||
if (row.year === currentYear) {
|
||||
entry.current += value;
|
||||
} else {
|
||||
entry.previous += value;
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to Array and Calculate Deltas
|
||||
const list: SkuAggr[] = [];
|
||||
map.forEach((val, sku) => {
|
||||
// Filter out items that have 0 in BOTH years (irrelevant)
|
||||
if (val.current === 0 && val.previous === 0) return;
|
||||
|
||||
const diff = val.current - val.previous;
|
||||
let pct = 0;
|
||||
if (val.previous !== 0) {
|
||||
pct = (diff / val.previous) * 100;
|
||||
} else if (val.current !== 0) {
|
||||
// Infinite growth (0 -> 100)
|
||||
pct = 100;
|
||||
}
|
||||
|
||||
list.push({
|
||||
sku,
|
||||
title: val.title,
|
||||
line: val.line,
|
||||
previousValue: val.previous,
|
||||
currentValue: val.current,
|
||||
diff,
|
||||
pct
|
||||
});
|
||||
});
|
||||
|
||||
// Separate and Sort
|
||||
const growers = list
|
||||
.filter(i => i.diff > 0)
|
||||
.sort((a, b) => b.diff - a.diff) // Descending by Growth
|
||||
.slice(0, 20);
|
||||
|
||||
const decliners = list
|
||||
.filter(i => i.diff < 0)
|
||||
.sort((a, b) => a.diff - b.diff) // Ascending by Decline (Most negative first)
|
||||
.slice(0, 20);
|
||||
|
||||
return { growers, decliners };
|
||||
|
||||
}, [data, metric, currentYear, previousYear]);
|
||||
|
||||
if (availableYears.length < 2) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 bg-slate-900 rounded-xl border border-border p-8">
|
||||
<h3 className="text-xl font-bold text-slate-300 mb-2">Insufficient Data for Comparison</h3>
|
||||
<p className="text-slate-500 text-center max-w-md">
|
||||
To see Top Movers, please ensure your filters include at least <b>two different years</b> (e.g., 2024 and 2025).
|
||||
</p>
|
||||
<p className="mt-4 text-xs text-slate-600">Current Years Available: {availableYears.join(', ') || 'None'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-7xl mx-auto pb-24 animate-fade-in">
|
||||
|
||||
{/* Controls Header */}
|
||||
<div className="bg-surface border border-border rounded-xl p-6 shadow-sm flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-blue-400 flex items-center gap-2">
|
||||
Analytics Overview
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Comparing Performance: <span className="font-mono text-indigo-300 font-bold">{previousYear}</span> vs <span className="font-mono text-indigo-300 font-bold">{currentYear}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-center">
|
||||
{/* Gainers / Losers Toggle */}
|
||||
<div className="bg-slate-900 p-1 rounded-lg border border-slate-800 flex text-sm">
|
||||
<button
|
||||
onClick={() => setViewMode('growth')}
|
||||
className={`px-4 py-2 rounded-md transition-colors font-medium flex items-center gap-2 ${viewMode === 'growth' ? 'bg-emerald-600/20 text-emerald-400 border border-emerald-500/50 shadow-sm' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<span>🚀 Top Gainers</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('decline')}
|
||||
className={`px-4 py-2 rounded-md transition-colors font-medium flex items-center gap-2 ${viewMode === 'decline' ? 'bg-rose-600/20 text-rose-400 border border-rose-500/50 shadow-sm' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<span>📉 Top Losers</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Metric Toggle */}
|
||||
<div className="bg-slate-900 p-1 rounded-lg border border-slate-800 flex text-sm">
|
||||
<button
|
||||
onClick={() => setMetric('sellOut')}
|
||||
className={`px-4 py-2 rounded-md transition-colors font-medium ${metric === 'sellOut' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
Sell Out (€)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMetric('units')}
|
||||
className={`px-4 py-2 rounded-md transition-colors font-medium ${metric === 'units' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
Units
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Single Active Table */}
|
||||
<MoversTable
|
||||
title={viewMode === 'growth' ? "Fastest Growing SKUs" : "Biggest Declining SKUs"}
|
||||
data={viewMode === 'growth' ? growers : decliners}
|
||||
metric={metric}
|
||||
previousYear={previousYear}
|
||||
currentYear={currentYear}
|
||||
type={viewMode}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopMovers;
|
||||
@@ -1,92 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { SalesRecord } from '../types';
|
||||
import { calculateItemMovers, getUniqueValues, generateItemMoversCSV } from '../services/dataProcessor'; // Import generateItemMoversCSV
|
||||
import ItemGrowthTable from './ItemGrowthTable';
|
||||
import { ExpandableCard } from './Dashboard'; // Re-use ExpandableCard from Dashboard
|
||||
|
||||
interface TopMoversPageProps {
|
||||
filteredData: SalesRecord[]; // Data already filtered by global customer, year, month, etc.
|
||||
}
|
||||
|
||||
const TopMoversPage: React.FC<TopMoversPageProps> = ({ filteredData }) => {
|
||||
// Local state for the specific comparison year, initially null for auto-selection
|
||||
const [selectedComparisonYear, setSelectedComparisonYear] = useState<string | null>(null);
|
||||
|
||||
// Derive available years from the *currently filtered data* for the comparison year dropdown
|
||||
const availableYearsForComparisonDropdown = useMemo(() => {
|
||||
const yearsInFilteredData = getUniqueValues(filteredData, 'year');
|
||||
// Sort descending for the dropdown
|
||||
return yearsInFilteredData.sort((a,b) => parseInt(b) - parseInt(a));
|
||||
}, [filteredData]);
|
||||
|
||||
// Derive top/bottom movers based on selections
|
||||
const { topMovers, bottomMovers, comparisonPeriods } = useMemo(() => {
|
||||
const comparisonYearNum = selectedComparisonYear ? parseInt(selectedComparisonYear) : null;
|
||||
// Pass the globally filtered data. The global filter bar now handles customer/line/sku/etc filtering.
|
||||
// We pass null for the local customer override as it is no longer used.
|
||||
return calculateItemMovers(filteredData, null, comparisonYearNum);
|
||||
}, [filteredData, selectedComparisonYear]);
|
||||
|
||||
// Handlers for export
|
||||
const handleExportGainers = () => {
|
||||
generateItemMoversCSV(topMovers, comparisonPeriods, 'Gainers');
|
||||
};
|
||||
|
||||
const handleExportLosers = () => {
|
||||
generateItemMoversCSV(bottomMovers, comparisonPeriods, 'Losers');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-7xl mx-auto animate-fade-in pb-24">
|
||||
<div className="flex flex-wrap justify-between items-center gap-4 mb-6">
|
||||
<h2 className="text-2xl font-bold text-white">Top Item Movers</h2>
|
||||
|
||||
<div className="bg-slate-900 border border-border rounded-xl px-4 py-2 flex items-center gap-3">
|
||||
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider whitespace-nowrap">Comparison Year</label>
|
||||
<select
|
||||
value={selectedComparisonYear || ''}
|
||||
onChange={(e) => setSelectedComparisonYear(e.target.value || null)}
|
||||
className="bg-surface border border-border hover:border-slate-600 text-sm rounded-lg py-1.5 px-3 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors text-white"
|
||||
>
|
||||
<option value="">Auto (Latest 2 Years)</option>
|
||||
{availableYearsForComparisonDropdown.map(year => (
|
||||
<option key={year} value={year}>{year}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top 20 Gainers Table */}
|
||||
<ExpandableCard
|
||||
title={`Top 20 Gainers (${comparisonPeriods.current} vs ${comparisonPeriods.previous})`}
|
||||
className="h-[500px]"
|
||||
onExport={handleExportGainers} // Pass export handler
|
||||
exportFileName={`Top_20_Gainers_${comparisonPeriods.current}_vs_${comparisonPeriods.previous}`}
|
||||
>
|
||||
<ItemGrowthTable
|
||||
title={`Top 20 Gainers (${comparisonPeriods.current} vs ${comparisonPeriods.previous})`}
|
||||
data={topMovers}
|
||||
type="growth"
|
||||
periods={comparisonPeriods}
|
||||
/>
|
||||
</ExpandableCard>
|
||||
|
||||
{/* Top 20 Losers Table */}
|
||||
<ExpandableCard
|
||||
title={`Top 20 Losers (${comparisonPeriods.current} vs ${comparisonPeriods.previous})`}
|
||||
className="h-[500px]"
|
||||
onExport={handleExportLosers} // Pass export handler
|
||||
exportFileName={`Top_20_Losers_${comparisonPeriods.current}_vs_${comparisonPeriods.previous}`}
|
||||
>
|
||||
<ItemGrowthTable
|
||||
title={`Top 20 Losers (${comparisonPeriods.current} vs ${comparisonPeriods.previous})`}
|
||||
data={bottomMovers}
|
||||
type="decline"
|
||||
periods={comparisonPeriods}
|
||||
/>
|
||||
</ExpandableCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopMoversPage;
|
||||
Reference in New Issue
Block a user