feat: Revamp Analytics Dashboard
- Replace StatsDashboard with new AnalyticsDashboard component - Add Recharts charts: Category Pie, ABV Area, Age Bar, Top Distilleries Bar, Price vs Quality Scatter - Update fetching logic to include tasting ratings for analysis - Enhance UI with new KPI cards and dark mode styling
This commit is contained in:
406
src/components/AnalyticsDashboard.tsx
Normal file
406
src/components/AnalyticsDashboard.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
PieChart, Pie, Cell, ScatterChart, Scatter, ZAxis, Legend, AreaChart, Area
|
||||
} from 'recharts';
|
||||
import { TrendingUp, CreditCard, Star, Home, BarChart3, Droplets, Clock, Activity, DollarSign } from 'lucide-react';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
|
||||
interface Bottle {
|
||||
id: string;
|
||||
name: string;
|
||||
distillery?: string;
|
||||
purchase_price?: number | null;
|
||||
rating?: number | null; // Calculated avg rating
|
||||
category?: string;
|
||||
abv?: number;
|
||||
age?: number;
|
||||
status?: string | null;
|
||||
tastings?: { rating: number }[];
|
||||
}
|
||||
|
||||
interface AnalyticsDashboardProps {
|
||||
bottles: Bottle[];
|
||||
}
|
||||
|
||||
// Custom Colors
|
||||
const COLORS = [
|
||||
'#f97316', // Orange 500
|
||||
'#a855f7', // Purple 500
|
||||
'#3b82f6', // Blue 500
|
||||
'#10b981', // Emerald 500
|
||||
'#ef4444', // Red 500
|
||||
'#eab308', // Yellow 500
|
||||
'#ec4899', // Pink 500
|
||||
'#6366f1', // Indigo 500
|
||||
];
|
||||
|
||||
export default function AnalyticsDashboard({ bottles }: AnalyticsDashboardProps) {
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
// --- Process Data ---
|
||||
const stats = useMemo(() => {
|
||||
const enrichedBottles = bottles.map(b => {
|
||||
const ratings = b.tastings?.map(t => t.rating) || [];
|
||||
const avgRating = ratings.length > 0
|
||||
? ratings.reduce((a, b) => a + b, 0) / ratings.length
|
||||
: null;
|
||||
return { ...b, rating: avgRating };
|
||||
});
|
||||
|
||||
// 1. High Level Metrics
|
||||
const totalValue = enrichedBottles.reduce((sum, b) => sum + (Number(b.purchase_price) || 0), 0);
|
||||
const bottlesWithRating = enrichedBottles.filter(b => b.rating !== null);
|
||||
const avgCollectionRating = bottlesWithRating.length > 0
|
||||
? bottlesWithRating.reduce((sum, b) => sum + (b.rating || 0), 0) / bottlesWithRating.length
|
||||
: 0;
|
||||
|
||||
// 2. Category Distribution
|
||||
const catMap = new Map<string, number>();
|
||||
enrichedBottles.forEach(b => {
|
||||
const cat = b.category || 'Unknown';
|
||||
catMap.set(cat, (catMap.get(cat) || 0) + 1);
|
||||
});
|
||||
const categoryData = Array.from(catMap.entries())
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
// 3. Status Distribution
|
||||
const statusMap = new Map<string, number>();
|
||||
enrichedBottles.forEach(b => {
|
||||
const s = b.status || 'sealed';
|
||||
statusMap.set(s, (statusMap.get(s) || 0) + 1);
|
||||
});
|
||||
const statusData = Array.from(statusMap.entries())
|
||||
.map(([name, value]) => ({ name: name.charAt(0).toUpperCase() + name.slice(1), value }));
|
||||
|
||||
// 4. Distillery Top 10
|
||||
const distMap = new Map<string, number>();
|
||||
enrichedBottles.forEach(b => {
|
||||
if (b.distillery) {
|
||||
distMap.set(b.distillery, (distMap.get(b.distillery) || 0) + 1);
|
||||
}
|
||||
});
|
||||
const distilleryData = Array.from(distMap.entries())
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 10);
|
||||
|
||||
// 5. ABV Buckets
|
||||
const abvBuckets = {
|
||||
'< 40%': 0,
|
||||
'40-43%': 0,
|
||||
'43-46%': 0,
|
||||
'46-50%': 0,
|
||||
'50-55%': 0,
|
||||
'55-60%': 0,
|
||||
'> 60%': 0,
|
||||
};
|
||||
enrichedBottles.forEach(b => {
|
||||
if (!b.abv) return;
|
||||
if (b.abv < 40) abvBuckets['< 40%']++;
|
||||
else if (b.abv <= 43) abvBuckets['40-43%']++;
|
||||
else if (b.abv <= 46) abvBuckets['43-46%']++;
|
||||
else if (b.abv <= 50) abvBuckets['46-50%']++;
|
||||
else if (b.abv <= 55) abvBuckets['50-55%']++;
|
||||
else if (b.abv <= 60) abvBuckets['55-60%']++;
|
||||
else abvBuckets['> 60%']++;
|
||||
});
|
||||
const abvData = Object.entries(abvBuckets).map(([name, value]) => ({ name, value }));
|
||||
|
||||
// 6. Age Buckets
|
||||
const ageBuckets = {
|
||||
'NAS': 0,
|
||||
'< 10y': 0,
|
||||
'10-12y': 0,
|
||||
'13-18y': 0,
|
||||
'19-25y': 0,
|
||||
'> 25y': 0
|
||||
};
|
||||
enrichedBottles.forEach(b => {
|
||||
if (!b.age) {
|
||||
ageBuckets['NAS']++;
|
||||
return;
|
||||
}
|
||||
if (b.age < 10) ageBuckets['< 10y']++;
|
||||
else if (b.age <= 12) ageBuckets['10-12y']++;
|
||||
else if (b.age <= 18) ageBuckets['13-18y']++;
|
||||
else if (b.age <= 25) ageBuckets['19-25y']++;
|
||||
else ageBuckets['> 25y']++;
|
||||
});
|
||||
const ageData = Object.entries(ageBuckets).map(([name, value]) => ({ name, value }));
|
||||
|
||||
// 7. Price vs Quality
|
||||
const scatterData = enrichedBottles
|
||||
.filter(b => b.purchase_price && b.rating)
|
||||
.map(b => ({
|
||||
x: b.purchase_price,
|
||||
y: b.rating,
|
||||
name: b.name,
|
||||
z: 1 // size
|
||||
}));
|
||||
|
||||
return {
|
||||
totalValue,
|
||||
avgCollectionRating,
|
||||
totalCount: bottles.length,
|
||||
topDistillery: distilleryData[0]?.name || 'N/A',
|
||||
categoryData,
|
||||
statusData,
|
||||
distilleryData,
|
||||
abvData,
|
||||
ageData,
|
||||
scatterData
|
||||
};
|
||||
}, [bottles]);
|
||||
|
||||
|
||||
// Helper for Custom Tooltip
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-800 p-3 rounded-xl shadow-xl">
|
||||
<p className="font-bold text-white mb-1">{label}</p>
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<p key={index} className="text-sm" style={{ color: entry.color }}>
|
||||
{entry.name}: {entry.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Scatter Tooltip
|
||||
const ScatterTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-800 p-3 rounded-xl shadow-xl max-w-[200px]">
|
||||
<p className="font-bold text-white mb-1 text-xs">{data.name}</p>
|
||||
<p className="text-xs text-zinc-400">Price: <span className="text-green-500 font-bold">{data.x}€</span></p>
|
||||
<p className="text-xs text-zinc-400">Rating: <span className="text-orange-500 font-bold">{data.y?.toFixed(1)}</span></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-top-4 duration-500">
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
|
||||
<div className="flex items-center gap-2 mb-2 text-zinc-500">
|
||||
<CreditCard size={18} className="text-green-500" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">Total Value</span>
|
||||
</div>
|
||||
<div className="text-2xl md:text-3xl font-black text-white">
|
||||
{stats.totalValue.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
|
||||
<div className="flex items-center gap-2 mb-2 text-zinc-500">
|
||||
<Home size={18} className="text-blue-500" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">Bottles</span>
|
||||
</div>
|
||||
<div className="text-2xl md:text-3xl font-black text-white">
|
||||
{stats.totalCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
|
||||
<div className="flex items-center gap-2 mb-2 text-zinc-500">
|
||||
<Star size={18} className="text-orange-500" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">Avg Rating</span>
|
||||
</div>
|
||||
<div className="text-2xl md:text-3xl font-black text-white">
|
||||
{stats.avgCollectionRating.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
|
||||
<div className="flex items-center gap-2 mb-2 text-zinc-500">
|
||||
<Activity size={18} className="text-purple-500" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">Favorite</span>
|
||||
</div>
|
||||
<div className="text-xl md:text-2xl font-black text-white truncate" title={stats.topDistillery}>
|
||||
{stats.topDistillery}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 1: Categories & Status */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
{/* Category Distribution */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
|
||||
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
|
||||
<BarChart3 size={20} className="text-zinc-500" />
|
||||
Categories
|
||||
</h3>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={stats.categoryData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
>
|
||||
{stats.categoryData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '20px' }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Distribution */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
|
||||
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
|
||||
<Activity size={20} className="text-zinc-500" />
|
||||
Status
|
||||
</h3>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={stats.statusData} layout="vertical" margin={{ left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#27272a" />
|
||||
<XAxis type="number" stroke="#52525b" fontSize={12} />
|
||||
<YAxis dataKey="name" type="category" stroke="#a1a1aa" fontSize={12} width={80} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar dataKey="value" name="Bottles" radius={[0, 4, 4, 0]}>
|
||||
{stats.statusData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Distillery Top 10 */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
|
||||
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
|
||||
<Home size={20} className="text-zinc-500" />
|
||||
Top Distilleries
|
||||
</h3>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={stats.distilleryData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#27272a" />
|
||||
<XAxis dataKey="name" stroke="#a1a1aa" fontSize={12} interval={0} angle={-45} textAnchor="end" height={60} />
|
||||
<YAxis stroke="#52525b" fontSize={12} />
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#27272a' }} />
|
||||
<Bar dataKey="value" name="Bottles" fill="#f97316" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Technical Specs (ABV & Age) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
{/* ABV */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
|
||||
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
|
||||
<Droplets size={20} className="text-zinc-500" />
|
||||
Strength (ABV)
|
||||
</h3>
|
||||
<div className="h-[250px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={stats.abvData}>
|
||||
<defs>
|
||||
<linearGradient id="colorAbv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#27272a" />
|
||||
<XAxis dataKey="name" stroke="#a1a1aa" fontSize={10} />
|
||||
<YAxis stroke="#52525b" fontSize={10} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Area type="monotone" dataKey="value" name="Bottles" stroke="#8b5cf6" fillOpacity={1} fill="url(#colorAbv)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Age */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
|
||||
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
|
||||
<Clock size={20} className="text-zinc-500" />
|
||||
Age Statements
|
||||
</h3>
|
||||
<div className="h-[250px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={stats.ageData}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#27272a" />
|
||||
<XAxis dataKey="name" stroke="#a1a1aa" fontSize={10} />
|
||||
<YAxis stroke="#52525b" fontSize={10} />
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#27272a' }} />
|
||||
<Bar dataKey="value" name="Bottles" fill="#10b981" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Price vs Quality */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-bold text-white flex items-center gap-2">
|
||||
<DollarSign size={20} className="text-zinc-500" />
|
||||
Price vs. Quality
|
||||
</h3>
|
||||
<span className="text-xs text-zinc-500 px-2 py-1 bg-zinc-800 rounded-lg">Excludes free/unrated bottles</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 mb-6">
|
||||
Find hidden gems: Low price (left) but high rating (top).
|
||||
</p>
|
||||
<div className="h-[400px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#27272a" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="x"
|
||||
name="Price"
|
||||
unit="€"
|
||||
stroke="#52525b"
|
||||
fontSize={12}
|
||||
label={{ value: 'Price (€)', position: 'insideBottom', offset: -10, fill: '#71717a', fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="y"
|
||||
name="Rating"
|
||||
domain={[0, 100]}
|
||||
stroke="#52525b"
|
||||
fontSize={12}
|
||||
label={{ value: 'Rating (0-100)', angle: -90, position: 'insideLeft', fill: '#71717a', fontSize: 12 }}
|
||||
/>
|
||||
<ZAxis type="number" dataKey="z" range={[50, 400]} />
|
||||
<Tooltip content={<ScatterTooltip />} cursor={{ strokeDasharray: '3 3' }} />
|
||||
<Scatter name="Bottles" data={stats.scatterData} fill="#ec4899" fillOpacity={0.6} stroke="#fff" strokeWidth={1} />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user