- Add activeClients and inactiveClients to AdminStatistics type - Add pie chart showing client status (active/inactive) - Add detailed stats rows for active/inactive clients 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
697 lines
26 KiB
TypeScript
697 lines
26 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);
|
|
|
|
// Données pour le graphique des clients actifs/inactifs
|
|
const clientStatusData = [
|
|
{ name: "Actifs", value: stats.users.activeClients || 0, color: "#10b981" },
|
|
{ name: "Inactifs", value: stats.users.inactiveClients || 0, color: "#ef4444" },
|
|
].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="bg-gradient-to-r from-[#1e3a5f] to-[#2d5a8f] rounded-2xl p-6 mb-6">
|
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-white mb-1">
|
|
Dashboard Administrateur
|
|
</h1>
|
|
<p className="text-blue-200">
|
|
Statistiques complètes et analyses en temps réel
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{/* Refresh button */}
|
|
<button
|
|
onClick={loadStatistics}
|
|
className="flex items-center gap-2 bg-white text-[#1e3a5f] px-5 py-2.5 rounded-xl hover:bg-gray-100 transition-all shadow-lg font-semibold"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Rafraîchir
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter period */}
|
|
<div className="flex items-center gap-3 bg-white rounded-xl p-3 shadow-sm border border-gray-100">
|
|
<div className="flex items-center gap-2 text-gray-600 px-2">
|
|
<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-5 py-2 rounded-lg text-sm font-semibold transition-all ${
|
|
selectedPeriod === period
|
|
? "bg-gradient-to-r from-[#1e3a5f] to-[#2d5a8f] text-white shadow-md"
|
|
: "text-gray-600 hover:bg-gray-100"
|
|
}`}
|
|
>
|
|
{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 Clients Actifs/Inactifs */}
|
|
{clientStatusData.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-emerald-100 rounded-xl flex items-center justify-center">
|
|
<UserIcon className="w-5 h-5 text-emerald-600" />
|
|
</div>
|
|
Statut des Clients
|
|
</h2>
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<PieChart>
|
|
<Pie
|
|
data={clientStatusData}
|
|
cx="50%"
|
|
cy="50%"
|
|
labelLine={false}
|
|
label={({ name, value }) => `${name}: ${value}`}
|
|
outerRadius={80}
|
|
innerRadius={40}
|
|
fill="#8884d8"
|
|
dataKey="value"
|
|
>
|
|
{clientStatusData.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="Clients actifs" value={stats?.users?.activeClients || 0} color="green" />
|
|
<StatRow label="Clients inactifs" value={stats?.users?.inactiveClients || 0} color="red" />
|
|
<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 gradients = {
|
|
blue: "from-blue-500 to-blue-600",
|
|
green: "from-emerald-500 to-emerald-600",
|
|
yellow: "from-amber-500 to-orange-500",
|
|
purple: "from-purple-500 to-purple-600",
|
|
red: "from-red-500 to-red-600",
|
|
};
|
|
|
|
const bgColors = {
|
|
blue: "from-blue-50 to-blue-100 border-blue-200",
|
|
green: "from-emerald-50 to-emerald-100 border-emerald-200",
|
|
yellow: "from-amber-50 to-orange-100 border-amber-200",
|
|
purple: "from-purple-50 to-purple-100 border-purple-200",
|
|
red: "from-red-50 to-red-100 border-red-200",
|
|
};
|
|
|
|
return (
|
|
<Link
|
|
href={link}
|
|
className={`bg-gradient-to-br ${bgColors[color]} rounded-2xl border p-6 hover:shadow-xl transition-all hover:scale-[1.02] group relative overflow-hidden`}
|
|
>
|
|
<div className="absolute top-0 right-0 w-32 h-32 transform translate-x-8 -translate-y-8">
|
|
<div className={`w-full h-full bg-gradient-to-br ${gradients[color]} rounded-full opacity-10`}></div>
|
|
</div>
|
|
<div className="relative">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className={`p-3 rounded-xl bg-gradient-to-br ${gradients[color]} text-white shadow-lg group-hover:scale-110 transition-transform`}>
|
|
{icon}
|
|
</div>
|
|
{trend && (
|
|
<div className="flex items-center gap-1 text-emerald-700 text-sm font-semibold bg-emerald-100 px-2.5 py-1 rounded-full">
|
|
<TrendingUp className="w-3 h-3" />
|
|
{trend}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-gray-600 mb-1">{title}</h3>
|
|
<p className="text-4xl font-bold text-gray-900">{(value || 0).toLocaleString("fr-FR")}</p>
|
|
{subtitle && <p className="text-xs text-gray-500 mt-2">{subtitle}</p>}
|
|
</div>
|
|
</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 rankStyles = {
|
|
1: { bg: "from-amber-400 to-yellow-500", badge: "bg-amber-500" },
|
|
2: { bg: "from-gray-300 to-gray-400", badge: "bg-gray-400" },
|
|
3: { bg: "from-orange-400 to-amber-500", badge: "bg-orange-500" },
|
|
};
|
|
|
|
const style = rankStyles[rank as keyof typeof rankStyles] || { bg: "from-blue-400 to-blue-500", badge: "bg-blue-500" };
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl border border-gray-100 p-4 hover:shadow-lg transition-all group">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className={`w-8 h-8 rounded-lg bg-gradient-to-br ${style.bg} flex items-center justify-center text-white font-bold text-sm shadow-md`}>
|
|
{rank}
|
|
</span>
|
|
<MapPin className="w-4 h-4 text-gray-400 group-hover:text-orange-500 transition-colors" />
|
|
</div>
|
|
<h4 className="font-bold text-gray-900 mb-1 truncate" title={city}>
|
|
{city}
|
|
</h4>
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-2xl font-bold text-gray-900">{count.toLocaleString('fr-FR')}</span>
|
|
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">{percentage.toFixed(1)}%</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|