530 lines
17 KiB
TypeScript
530 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } 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,
|
|
} from "lucide-react";
|
|
|
|
export default function AdminDashboard() {
|
|
const [stats, setStats] = useState<AdminStatistics | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadStatistics();
|
|
}, []);
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// Calculer les pourcentages pour les tickets
|
|
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="p-8 bg-gray-50 min-h-screen">
|
|
{/* Header */}
|
|
<div className="mb-8 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900">Dashboard Administrateur</h1>
|
|
<p className="text-gray-600 mt-2">
|
|
Statistiques complètes du jeu-concours Thé Tip Top
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={loadStatistics}
|
|
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Rafraîchir
|
|
</button>
|
|
</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"
|
|
/>
|
|
<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"
|
|
/>
|
|
<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"
|
|
/>
|
|
<StatCard
|
|
title="Lots Gagnés"
|
|
value={stats?.prizes?.distributed || 0}
|
|
icon={<Gift className="w-6 h-6" />}
|
|
color="yellow"
|
|
link="/admin/tickets"
|
|
/>
|
|
</div>
|
|
|
|
{/* Section des Tickets */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
|
{/* Statistiques Tickets détaillées */}
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
|
<Ticket className="w-5 h-5" />
|
|
Statistiques des Tickets
|
|
</h2>
|
|
<Link
|
|
href="/admin/tickets"
|
|
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
|
>
|
|
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-lg shadow-sm border border-gray-200 p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
|
<Users className="w-5 h-5" />
|
|
Utilisateurs
|
|
</h2>
|
|
<Link
|
|
href="/admin/utilisateurs"
|
|
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
|
>
|
|
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" />
|
|
<StatRow
|
|
label="Emails vérifiés"
|
|
value={stats?.users?.verifiedEmails || 0}
|
|
color="green"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Répartition des Lots par Catégorie */}
|
|
{stats?.prizes?.byCategory && stats.prizes.byCategory.length > 0 && (
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
|
<h2 className="text-xl font-semibold text-gray-900 flex items-center gap-2 mb-6">
|
|
<Gift className="w-5 h-5" />
|
|
Répartition des Lots Gagnés par Catégorie
|
|
</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{stats.prizes.byCategory.map((prize) => (
|
|
<PrizeCard
|
|
key={prize.prizeId}
|
|
name={prize.prizeName}
|
|
type={prize.prizeType}
|
|
count={prize.count}
|
|
percentage={prize.percentage}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Statistiques Démographiques */}
|
|
{stats?.demographics && (
|
|
<div className="space-y-6">
|
|
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
|
<UserIcon className="w-6 h-6" />
|
|
Statistiques Démographiques des Participants
|
|
</h2>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Répartition par Genre */}
|
|
{stats.demographics.gender && (
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|
<UserIcon className="w-5 h-5" />
|
|
Répartition par Genre
|
|
</h3>
|
|
<div className="space-y-3">
|
|
<DemoStatRow
|
|
label="Hommes"
|
|
value={stats.demographics.gender.male}
|
|
total={stats.users.total}
|
|
color="blue"
|
|
/>
|
|
<DemoStatRow
|
|
label="Femmes"
|
|
value={stats.demographics.gender.female}
|
|
total={stats.users.total}
|
|
color="pink"
|
|
/>
|
|
<DemoStatRow
|
|
label="Autre"
|
|
value={stats.demographics.gender.other}
|
|
total={stats.users.total}
|
|
color="purple"
|
|
/>
|
|
<DemoStatRow
|
|
label="Non spécifié"
|
|
value={stats.demographics.gender.notSpecified}
|
|
total={stats.users.total}
|
|
color="gray"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Répartition par Âge */}
|
|
{stats.demographics.ageRanges && stats.demographics.ageRanges.length > 0 && (
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|
<Calendar className="w-5 h-5" />
|
|
Répartition par Âge
|
|
</h3>
|
|
<div className="space-y-3">
|
|
{stats.demographics.ageRanges.map((range, idx) => (
|
|
<DemoStatRow
|
|
key={idx}
|
|
label={range.range}
|
|
value={range.count}
|
|
percentage={range.percentage}
|
|
color="green"
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Top Villes */}
|
|
{stats.demographics.topCities && stats.demographics.topCities.length > 0 && (
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|
<MapPin className="w-5 h-5" />
|
|
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">
|
|
{stats.demographics.topCities.slice(0, 10).map((city, idx) => (
|
|
<CityCard
|
|
key={idx}
|
|
rank={idx + 1}
|
|
city={city.city}
|
|
count={city.count}
|
|
percentage={city.percentage}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface StatCardProps {
|
|
title: string;
|
|
value: number;
|
|
subtitle?: string;
|
|
icon: React.ReactNode;
|
|
color: "blue" | "green" | "yellow" | "purple" | "red";
|
|
link: string;
|
|
}
|
|
|
|
function StatCard({ title, value, subtitle, icon, color, link }: StatCardProps) {
|
|
const colors = {
|
|
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",
|
|
};
|
|
|
|
return (
|
|
<Link
|
|
href={link}
|
|
className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition"
|
|
>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className={`p-3 rounded-lg ${colors[color]}`}>{icon}</div>
|
|
<TrendingUp className="w-5 h-5 text-gray-400" />
|
|
</div>
|
|
<h3 className="text-sm font-medium text-gray-600 mb-1">{title}</h3>
|
|
<p className="text-3xl font-bold text-gray-900">
|
|
{(value || 0).toLocaleString("fr-FR")}
|
|
</p>
|
|
{subtitle && (
|
|
<p className="text-xs text-gray-500 mt-1">{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>
|
|
);
|
|
}
|
|
|
|
// Composant pour afficher une carte de lot
|
|
interface PrizeCardProps {
|
|
name: string;
|
|
type: string;
|
|
count: number;
|
|
percentage: number;
|
|
}
|
|
|
|
function PrizeCard({ name, type, count, percentage }: PrizeCardProps) {
|
|
const typeColors = {
|
|
PHYSICAL: "bg-purple-100 text-purple-800",
|
|
DISCOUNT: "bg-green-100 text-green-800",
|
|
};
|
|
|
|
const color = typeColors[type as keyof typeof typeColors] || "bg-gray-100 text-gray-800";
|
|
|
|
return (
|
|
<div className="bg-gradient-to-br from-gray-50 to-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex-1">
|
|
<h4 className="font-semibold text-gray-900 text-sm mb-1">{name}</h4>
|
|
<span className={`text-xs px-2 py-1 rounded ${color}`}>
|
|
{type === "PHYSICAL" ? "Physique" : "Réduction"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-end justify-between mt-3">
|
|
<div>
|
|
<p className="text-2xl font-bold text-gray-900">{count}</p>
|
|
<p className="text-xs text-gray-500">distribués</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-lg font-semibold text-blue-600">{percentage.toFixed(1)}%</p>
|
|
</div>
|
|
</div>
|
|
{/* Barre de progression */}
|
|
<div className="mt-3 bg-gray-200 rounded-full h-2 overflow-hidden">
|
|
<div
|
|
className="bg-blue-600 h-full rounded-full transition-all"
|
|
style={{ width: `${Math.min(percentage, 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Composant pour les statistiques démographiques
|
|
interface DemoStatRowProps {
|
|
label: string;
|
|
value: number;
|
|
total?: number;
|
|
percentage?: number;
|
|
color?: "blue" | "pink" | "purple" | "gray" | "green";
|
|
}
|
|
|
|
function DemoStatRow({ label, value, total, percentage, color }: DemoStatRowProps) {
|
|
const calculatedPercentage = percentage !== undefined
|
|
? percentage
|
|
: total && total > 0
|
|
? (value / total) * 100
|
|
: 0;
|
|
|
|
const colors = {
|
|
blue: "bg-blue-500",
|
|
pink: "bg-pink-500",
|
|
purple: "bg-purple-500",
|
|
gray: "bg-gray-500",
|
|
green: "bg-green-500",
|
|
};
|
|
|
|
const barColor = color ? colors[color] : "bg-blue-500";
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-gray-700">{label}</span>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-gray-900">
|
|
{value.toLocaleString("fr-FR")}
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
({calculatedPercentage.toFixed(1)}%)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="bg-gray-200 rounded-full h-2 overflow-hidden">
|
|
<div
|
|
className={`${barColor} h-full rounded-full transition-all`}
|
|
style={{ width: `${Math.min(calculatedPercentage, 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Composant pour afficher une carte de ville
|
|
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>
|
|
);
|
|
}
|
|
|