the-tip-top-frontend/app/admin/dashboard/page.tsx
soufiane b7b08b1961 feat: improve admin dashboard design and add new charts
- Add gradient backgrounds and modern styling to all admin pages
- Add Statut des Tickets donut chart
- Add Types d'Utilisateurs donut chart
- Update headers and card containers with consistent design

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 14:34:29 +01:00

666 lines
24 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import { adminService } from "@/services/admin.service";
import { AdminStatistics } from "@/types";
import Link from "next/link";
import {
Users,
Ticket,
TrendingUp,
AlertCircle,
Gift,
BarChart3,
RefreshCw,
MapPin,
User as UserIcon,
Calendar,
Download,
Filter,
} from "lucide-react";
import {
PieChart,
Pie,
Cell,
BarChart,
Bar,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
// Couleurs pour les graphiques
const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"];
const GENDER_COLORS = {
male: "#3b82f6",
female: "#ec4899",
other: "#8b5cf6",
notSpecified: "#6b7280",
};
export default function AdminDashboardAdvanced() {
const [stats, setStats] = useState<AdminStatistics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [autoRefresh, setAutoRefresh] = useState(false);
const [refreshInterval, setRefreshInterval] = useState(30); // secondes
const [selectedPeriod, setSelectedPeriod] = useState("all"); // all, week, month, year
useEffect(() => {
loadStatistics();
}, []);
// Auto-refresh
useEffect(() => {
if (autoRefresh) {
const interval = setInterval(() => {
loadStatistics();
}, refreshInterval * 1000);
return () => clearInterval(interval);
}
}, [autoRefresh, refreshInterval]);
const loadStatistics = async () => {
try {
setLoading(true);
setError(null);
const data = await adminService.getStatistics();
setStats(data);
} catch (err: any) {
setError(err.message || "Erreur lors du chargement des statistiques");
setStats(null);
} finally {
setLoading(false);
}
};
// Export CSV
const exportToCSV = useCallback(() => {
if (!stats) return;
const csv = [
["Type", "Métrique", "Valeur"],
["Utilisateurs", "Total", stats.users.total],
["Utilisateurs", "Clients", stats.users.clients],
["Utilisateurs", "Employés", stats.users.employees],
["Utilisateurs", "Admins", stats.users.admins],
["Tickets", "Total", stats.tickets.total],
["Tickets", "Distribués", stats.tickets.distributed],
["Tickets", "Utilisés", stats.tickets.used],
["Tickets", "En attente", stats.tickets.pending],
["Lots", "Total distribué", stats.prizes.distributed],
];
// Ajouter les données démographiques
if (stats.demographics?.gender) {
csv.push(["Démographie", "Hommes", stats.demographics.gender.male]);
csv.push(["Démographie", "Femmes", stats.demographics.gender.female]);
}
const csvContent = csv.map((row) => row.join(",")).join("\n");
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", `statistiques_${new Date().toISOString()}.csv`);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [stats]);
if (loading) {
return (
<div className="p-8">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-32 bg-gray-200 rounded"></div>
))}
</div>
</div>
</div>
);
}
if (error || !stats) {
return (
<div className="p-8">
<div className="bg-red-50 border border-red-200 text-red-800 px-6 py-4 rounded-lg mb-6 flex items-center gap-3">
<AlertCircle className="w-5 h-5" />
<span>{error || "Erreur lors du chargement des statistiques"}</span>
</div>
<button
onClick={loadStatistics}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition"
>
Réessayer
</button>
</div>
);
}
// Préparer les données pour les graphiques
const prizeChartData = stats.prizes.byCategory?.map((prize) => ({
name: prize.prizeName,
value: prize.count,
percentage: prize.percentage,
})) || [];
// Ne montrer le graphique genre que s'il y a des données autres que "Non spécifié"
const genderChartData = stats.demographics?.gender
? [
{ name: "Hommes", value: stats.demographics.gender.male, color: GENDER_COLORS.male },
{ name: "Femmes", value: stats.demographics.gender.female, color: GENDER_COLORS.female },
{ name: "Autre", value: stats.demographics.gender.other, color: GENDER_COLORS.other },
].filter((item) => item.value > 0)
: [];
// Ne montrer le graphique âge que s'il y a des données avec des vraies tranches d'âge
const ageChartData = (stats.demographics?.ageRanges || []).filter(
(item) => item.range && !item.range.toLowerCase().includes("non spécifié") && item.count > 0
);
// Ne montrer les villes que s'il y a des vraies villes (pas vides ou "non spécifié")
const topCitiesData = (stats.demographics?.topCities || []).filter(
(city) => city.city && city.city.trim() !== "" && !city.city.toLowerCase().includes("non spécifié") && city.count > 0
);
// Données pour le graphique des statuts de tickets
const ticketStatusData = [
{ name: "Distribués", value: stats.tickets.distributed || 0, color: "#10b981" },
{ name: "En attente", value: stats.tickets.pending || 0, color: "#f59e0b" },
{ name: "Réclamés", value: stats.tickets.claimed || 0, color: "#3b82f6" },
{ name: "Rejetés", value: stats.tickets.rejected || 0, color: "#ef4444" },
].filter(item => item.value > 0);
// Données pour le graphique des utilisateurs par type
const userTypeData = [
{ name: "Clients", value: stats.users.clients || 0, color: "#10b981" },
{ name: "Employés", value: stats.users.employees || 0, color: "#8b5cf6" },
{ name: "Admins", value: stats.users.admins || 0, color: "#3b82f6" },
].filter(item => item.value > 0);
const ticketDistributedPercent = stats.tickets.total > 0
? ((stats.tickets.distributed / stats.tickets.total) * 100).toFixed(1)
: 0;
const ticketUsedPercent = stats.tickets.distributed > 0
? ((stats.tickets.used / stats.tickets.distributed) * 100).toFixed(1)
: 0;
return (
<div className="min-h-full bg-gradient-to-br from-gray-50 via-white to-gray-50 p-8">
{/* Header avec contrôles */}
<div className="mb-8">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<h1 className="text-4xl font-bold text-[#1e3a5f] mb-2">
Dashboard Administrateur
</h1>
<p className="text-gray-600 text-lg">
Statistiques complètes et analyses en temps réel
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* Auto-refresh toggle */}
<label className="flex items-center gap-2 bg-white px-4 py-2.5 rounded-xl border border-gray-200 shadow-sm cursor-pointer hover:shadow-md transition-all">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded accent-blue-600"
/>
<span className="text-sm font-medium text-gray-700">Auto-refresh ({refreshInterval}s)</span>
</label>
{/* Export button */}
<button
onClick={exportToCSV}
className="flex items-center gap-2 bg-gradient-to-r from-green-600 to-green-700 text-white px-5 py-2.5 rounded-xl hover:from-green-700 hover:to-green-800 transition-all shadow-md hover:shadow-lg font-medium"
>
<Download className="w-4 h-4" />
Export CSV
</button>
{/* Refresh button */}
<button
onClick={loadStatistics}
className="flex items-center gap-2 bg-gradient-to-r from-blue-600 to-blue-700 text-white px-5 py-2.5 rounded-xl hover:from-blue-700 hover:to-blue-800 transition-all shadow-md hover:shadow-lg font-medium"
>
<RefreshCw className="w-4 h-4" />
Rafraîchir
</button>
</div>
</div>
{/* Filter period */}
<div className="mt-6 flex items-center gap-3">
<div className="flex items-center gap-2 text-gray-600">
<Filter className="w-4 h-4" />
<span className="text-sm font-medium">Période:</span>
</div>
<div className="flex gap-2">
{["all", "week", "month", "year"].map((period) => (
<button
key={period}
onClick={() => setSelectedPeriod(period)}
className={`px-4 py-2 rounded-xl text-sm font-semibold transition-all ${
selectedPeriod === period
? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-md"
: "bg-white text-gray-600 border border-gray-200 hover:bg-gray-50 hover:shadow-sm"
}`}
>
{period === "all" && "Tout"}
{period === "week" && "Semaine"}
{period === "month" && "Mois"}
{period === "year" && "Année"}
</button>
))}
</div>
</div>
</div>
{/* Stats Cards - Principales */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatCard
title="Utilisateurs"
value={stats?.users?.total || 0}
icon={<Users className="w-6 h-6" />}
color="blue"
link="/admin/utilisateurs"
trend="+12%"
/>
<StatCard
title="Tickets Distribués"
value={stats?.tickets?.distributed || 0}
subtitle={`${ticketDistributedPercent}% du total`}
icon={<Ticket className="w-6 h-6" />}
color="green"
link="/admin/tickets"
trend="+8%"
/>
<StatCard
title="Tickets Utilisés"
value={stats?.tickets?.used || 0}
subtitle={`${ticketUsedPercent}% des distribués`}
icon={<BarChart3 className="w-6 h-6" />}
color="purple"
link="/admin/tickets"
trend="+15%"
/>
<StatCard
title="Lots Gagnés"
value={stats?.prizes?.distributed || 0}
icon={<Gift className="w-6 h-6" />}
color="yellow"
link="/admin/tickets"
trend="+10%"
/>
</div>
{/* Graphiques en ligne */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Graphique répartition des lots */}
{prizeChartData.length > 0 && (
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-6 hover:shadow-lg transition-shadow">
<h2 className="text-xl font-bold text-gray-800 mb-4 flex items-center gap-3">
<div className="w-10 h-10 bg-yellow-100 rounded-xl flex items-center justify-center">
<Gift className="w-5 h-5 text-yellow-600" />
</div>
Répartition des Lots
</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={prizeChartData}
cx="50%"
cy="50%"
labelLine={false}
label={(entry: any) => `${entry.name}: ${entry.percentage}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{prizeChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
)}
{/* Graphique Statut des Tickets */}
{ticketStatusData.length > 0 && (
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-6 hover:shadow-lg transition-shadow">
<h2 className="text-xl font-bold text-gray-800 mb-4 flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-xl flex items-center justify-center">
<Ticket className="w-5 h-5 text-green-600" />
</div>
Statut des Tickets
</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={ticketStatusData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, value }) => `${name}: ${value}`}
outerRadius={80}
innerRadius={40}
fill="#8884d8"
dataKey="value"
>
{ticketStatusData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* Deuxième ligne de graphiques */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Graphique Types d'Utilisateurs */}
{userTypeData.length > 0 && (
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-6 hover:shadow-lg transition-shadow">
<h2 className="text-xl font-bold text-gray-800 mb-4 flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-xl flex items-center justify-center">
<Users className="w-5 h-5 text-blue-600" />
</div>
Types d'Utilisateurs
</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={userTypeData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, value }) => `${name}: ${value}`}
outerRadius={80}
innerRadius={40}
fill="#8884d8"
dataKey="value"
>
{userTypeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
)}
{/* Graphique répartition par genre */}
{genderChartData.length > 0 && (
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-6 hover:shadow-lg transition-shadow">
<h2 className="text-xl font-bold text-gray-800 mb-4 flex items-center gap-3">
<div className="w-10 h-10 bg-pink-100 rounded-xl flex items-center justify-center">
<UserIcon className="w-5 h-5 text-pink-600" />
</div>
Répartition par Genre
</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={genderChartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, value }) => `${name}: ${value}`}
outerRadius={80}
innerRadius={40}
fill="#8884d8"
dataKey="value"
>
{genderChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* Graphique tranches d'âge */}
{ageChartData.length > 0 && (
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-6 mb-8 hover:shadow-lg transition-shadow">
<h2 className="text-xl font-bold text-gray-800 mb-4 flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-xl flex items-center justify-center">
<Calendar className="w-5 h-5 text-purple-600" />
</div>
Répartition par Âge
</h2>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={ageChartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="range" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="count" fill="#3b82f6" name="Nombre de participants" />
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Section détaillée existante */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Statistiques Tickets détaillées */}
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-6 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-800 flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-xl flex items-center justify-center">
<Ticket className="w-5 h-5 text-green-600" />
</div>
Statistiques des Tickets
</h2>
<Link
href="/admin/tickets"
className="text-blue-600 hover:text-blue-700 text-sm font-semibold bg-blue-50 px-3 py-1.5 rounded-lg hover:bg-blue-100 transition-colors"
>
Voir tout
</Link>
</div>
<div className="space-y-3">
<StatRow label="Total des tickets" value={stats?.tickets?.total || 0} />
<StatRow
label="Tickets distribués"
value={stats?.tickets?.distributed || 0}
color="green"
percentage={ticketDistributedPercent}
/>
<StatRow
label="Tickets utilisés"
value={stats?.tickets?.used || 0}
color="purple"
percentage={ticketUsedPercent}
/>
<StatRow label="En attente" value={stats?.tickets?.pending || 0} color="yellow" />
<StatRow label="Réclamés" value={stats?.tickets?.claimed || 0} color="green" />
<StatRow label="Rejetés" value={stats?.tickets?.rejected || 0} color="red" />
</div>
</div>
{/* Utilisateurs */}
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-6 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-800 flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-xl flex items-center justify-center">
<Users className="w-5 h-5 text-blue-600" />
</div>
Utilisateurs
</h2>
<Link
href="/admin/utilisateurs"
className="text-blue-600 hover:text-blue-700 text-sm font-semibold bg-blue-50 px-3 py-1.5 rounded-lg hover:bg-blue-100 transition-colors"
>
Voir tout
</Link>
</div>
<div className="space-y-3">
<StatRow label="Total" value={stats?.users?.total || 0} />
<StatRow label="Clients" value={stats?.users?.clients || 0} color="green" />
<StatRow label="Employés" value={stats?.users?.employees || 0} color="purple" />
<StatRow label="Admins" value={stats?.users?.admins || 0} color="blue" />
</div>
</div>
</div>
{/* Top Villes - affiché uniquement s'il y a des vraies données de villes */}
{topCitiesData.length > 0 && (
<div className="bg-white rounded-2xl shadow-md border border-gray-100 p-6 hover:shadow-lg transition-shadow">
<h3 className="text-xl font-bold text-gray-800 mb-6 flex items-center gap-3">
<div className="w-10 h-10 bg-orange-100 rounded-xl flex items-center justify-center">
<MapPin className="w-5 h-5 text-orange-600" />
</div>
Top 10 Villes des Participants
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{topCitiesData.slice(0, 10).map((city, idx) => (
<CityCard
key={idx}
rank={idx + 1}
city={city.city}
count={city.count}
percentage={city.percentage}
/>
))}
</div>
</div>
)}
</div>
);
}
// Composants réutilisables
interface StatCardProps {
title: string;
value: number;
subtitle?: string;
icon: React.ReactNode;
color: "blue" | "green" | "yellow" | "purple" | "red";
link: string;
trend?: string;
}
function StatCard({ title, value, subtitle, icon, color, link, trend }: StatCardProps) {
const iconColors = {
blue: "bg-blue-100 text-blue-600",
green: "bg-green-100 text-green-600",
yellow: "bg-yellow-100 text-yellow-600",
purple: "bg-purple-100 text-purple-600",
red: "bg-red-100 text-red-600",
};
const valueColors = {
blue: "text-blue-600",
green: "text-green-600",
yellow: "text-yellow-500",
purple: "text-purple-600",
red: "text-red-600",
};
return (
<Link
href={link}
className="bg-white rounded-2xl shadow-md border border-gray-100 p-6 hover:shadow-xl transition-all hover:scale-[1.02] group"
>
<div className="flex items-center justify-between mb-4">
<div className={`p-3 rounded-xl ${iconColors[color]} group-hover:scale-110 transition-transform`}>{icon}</div>
{trend && (
<div className="flex items-center gap-1 text-green-600 text-sm font-semibold bg-green-50 px-2 py-1 rounded-lg">
<TrendingUp className="w-3 h-3" />
{trend}
</div>
)}
</div>
<h3 className="text-sm font-medium text-gray-500 mb-2">{title}</h3>
<p className={`text-4xl font-bold ${valueColors[color]}`}>{(value || 0).toLocaleString("fr-FR")}</p>
{subtitle && <p className="text-xs text-gray-500 mt-2">{subtitle}</p>}
</Link>
);
}
interface StatRowProps {
label: string;
value: number;
color?: "green" | "yellow" | "red" | "purple" | "blue";
percentage?: string | number;
}
function StatRow({ label, value, color, percentage }: StatRowProps) {
const colors = {
green: "text-green-600",
yellow: "text-yellow-600",
red: "text-red-600",
purple: "text-purple-600",
blue: "text-blue-600",
};
return (
<div className="flex items-center justify-between py-2 border-b border-gray-100 last:border-0">
<span className="text-gray-700">{label}</span>
<div className="flex items-center gap-2">
<span className={`font-semibold ${color ? colors[color] : "text-gray-900"}`}>
{(value || 0).toLocaleString("fr-FR")}
</span>
{percentage !== undefined && <span className="text-xs text-gray-500">({percentage}%)</span>}
</div>
</div>
);
}
interface CityCardProps {
rank: number;
city: string;
count: number;
percentage: number;
}
function CityCard({ rank, city, count, percentage }: CityCardProps) {
const rankColors = {
1: "bg-yellow-100 text-yellow-800 border-yellow-300",
2: "bg-gray-100 text-gray-800 border-gray-300",
3: "bg-orange-100 text-orange-800 border-orange-300",
};
const rankColor =
rankColors[rank as keyof typeof rankColors] || "bg-blue-50 text-blue-800 border-blue-200";
return (
<div className={`rounded-lg border-2 p-4 ${rankColor} hover:shadow-md transition`}>
<div className="flex items-center justify-between mb-2">
<span className="text-lg font-bold">#{rank}</span>
<MapPin className="w-4 h-4" />
</div>
<h4 className="font-semibold text-sm mb-1 truncate" title={city}>
{city}
</h4>
<div className="flex items-baseline gap-2">
<span className="text-xl font-bold">{count}</span>
<span className="text-xs">({percentage.toFixed(1)}%)</span>
</div>
</div>
);
}