refactor: reduce code duplication from 18.51% to ~3%
- Delete unused page-advanced.tsx and page-backup.tsx (dashboard duplicates) - Add useApi hook for centralized API calls with auth token - Add LoadingState, ErrorState, StatusBadge reusable components - Create shared ProfilePage component for admin/employee profiles - Refactor admin and employee profile pages to use shared component This refactoring addresses SonarQube quality gate failure for duplicated lines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
534b6fc50c
commit
c7c2a3f56c
|
|
@ -1,549 +0,0 @@
|
||||||
"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,
|
|
||||||
})) || [];
|
|
||||||
|
|
||||||
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 },
|
|
||||||
{
|
|
||||||
name: "Non spécifié",
|
|
||||||
value: stats.demographics.gender.notSpecified,
|
|
||||||
color: GENDER_COLORS.notSpecified,
|
|
||||||
},
|
|
||||||
].filter((item) => item.value > 0)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const ageChartData = stats.demographics?.ageRanges || [];
|
|
||||||
|
|
||||||
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 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-3xl font-bold text-gray-900">Dashboard Administrateur Avancé</h1>
|
|
||||||
<p className="text-gray-600 mt-2">
|
|
||||||
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 rounded-lg border border-gray-200">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={autoRefresh}
|
|
||||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">Auto-refresh ({refreshInterval}s)</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Export button */}
|
|
||||||
<button
|
|
||||||
onClick={exportToCSV}
|
|
||||||
className="flex items-center gap-2 bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
Export CSV
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Refresh button */}
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter period */}
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
|
||||||
<Filter className="w-4 h-4 text-gray-600" />
|
|
||||||
<span className="text-sm text-gray-600">Période:</span>
|
|
||||||
{["all", "week", "month", "year"].map((period) => (
|
|
||||||
<button
|
|
||||||
key={period}
|
|
||||||
onClick={() => setSelectedPeriod(period)}
|
|
||||||
className={`px-3 py-1 rounded text-sm ${
|
|
||||||
selectedPeriod === period
|
|
||||||
? "bg-blue-600 text-white"
|
|
||||||
: "bg-white text-gray-700 border border-gray-200 hover:bg-gray-50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{period === "all" && "Tout"}
|
|
||||||
{period === "week" && "Semaine"}
|
|
||||||
{period === "month" && "Mois"}
|
|
||||||
{period === "year" && "Année"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</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-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|
||||||
<Gift className="w-5 h-5" />
|
|
||||||
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 répartition par genre */}
|
|
||||||
{genderChartData.length > 0 && (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|
||||||
<UserIcon className="w-5 h-5" />
|
|
||||||
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}
|
|
||||||
fill="#8884d8"
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{genderChartData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Graphique tranches d'âge */}
|
|
||||||
{ageChartData.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 mb-4 flex items-center gap-2">
|
|
||||||
<Calendar className="w-5 h-5" />
|
|
||||||
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-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" />
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 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>
|
|
||||||
{trend && (
|
|
||||||
<div className="flex items-center gap-1 text-green-600 text-sm font-medium">
|
|
||||||
<TrendingUp className="w-4 h-4" />
|
|
||||||
{trend}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,524 +0,0 @@
|
||||||
"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" />
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,223 +1,21 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import ProfilePage from "@/components/ProfilePage";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
|
||||||
import { profileUpdateSchema, ProfileUpdateFormData } from "@/lib/validations";
|
|
||||||
import { Input } from "@/components/ui/Input";
|
|
||||||
import Button from "@/components/Button";
|
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
|
|
||||||
import { Badge } from "@/components/ui/Badge";
|
|
||||||
import { userService } from "@/services/user.service";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { ROUTES } from "@/utils/constants";
|
import { ROUTES } from "@/utils/constants";
|
||||||
import { formatDate } from "@/utils/helpers";
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default function AdminProfilePage() {
|
export default function AdminProfilePage() {
|
||||||
const { user, isAuthenticated, refreshUser } = useAuth();
|
|
||||||
const router = useRouter();
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<ProfileUpdateFormData>({
|
|
||||||
resolver: zodResolver(profileUpdateSchema),
|
|
||||||
defaultValues: {
|
|
||||||
firstName: user?.firstName || "",
|
|
||||||
lastName: user?.lastName || "",
|
|
||||||
phone: user?.phone || "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSubmit = async (data: ProfileUpdateFormData) => {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
await userService.updateProfile(data);
|
|
||||||
await refreshUser();
|
|
||||||
toast.success("Profil mis à jour avec succès");
|
|
||||||
setIsEditing(false);
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || "Erreur lors de la mise à jour du profil");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
reset({
|
|
||||||
firstName: user?.firstName || "",
|
|
||||||
lastName: user?.lastName || "",
|
|
||||||
phone: user?.phone || "",
|
|
||||||
});
|
|
||||||
setIsEditing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<ProfilePage
|
||||||
<div className="max-w-4xl mx-auto">
|
role="admin"
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">Mon profil</h1>
|
roleLabel="Administrateur"
|
||||||
|
badgeVariant="danger"
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
primaryColor="blue"
|
||||||
{/* Profile Info Card */}
|
quickActions={[
|
||||||
<div className="md:col-span-2">
|
{ label: "Dashboard", route: ROUTES.ADMIN_DASHBOARD, variant: "primary" },
|
||||||
<div className="bg-white rounded-xl shadow-md overflow-hidden border border-gray-200">
|
{ label: "Gestion Utilisateurs", route: "/admin/utilisateurs", variant: "secondary" },
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
]}
|
||||||
<h2 className="text-xl font-bold text-gray-900">Informations personnelles</h2>
|
/>
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
{!isEditing ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-600">
|
|
||||||
Prénom
|
|
||||||
</label>
|
|
||||||
<p className="text-lg text-gray-900">{user.firstName}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-600">
|
|
||||||
Nom
|
|
||||||
</label>
|
|
||||||
<p className="text-lg text-gray-900">{user.lastName}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-600">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<p className="text-lg text-gray-900">{user.email}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-600">
|
|
||||||
Téléphone
|
|
||||||
</label>
|
|
||||||
<p className="text-lg text-gray-900">
|
|
||||||
{user.phone || "Non renseigné"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-600">
|
|
||||||
Rôle
|
|
||||||
</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<Badge variant="danger">Administrateur</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="pt-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white font-bold px-6 py-3 rounded-lg transition-all shadow-md"
|
|
||||||
>
|
|
||||||
Modifier mes informations
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<Input
|
|
||||||
id="firstName"
|
|
||||||
type="text"
|
|
||||||
label="Prénom"
|
|
||||||
error={errors.firstName?.message}
|
|
||||||
{...register("firstName")}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id="lastName"
|
|
||||||
type="text"
|
|
||||||
label="Nom"
|
|
||||||
error={errors.lastName?.message}
|
|
||||||
{...register("lastName")}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
label="Email"
|
|
||||||
value={user.email}
|
|
||||||
disabled
|
|
||||||
helperText="L'email ne peut pas être modifié"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id="phone"
|
|
||||||
type="tel"
|
|
||||||
label="Téléphone"
|
|
||||||
error={errors.phone?.message}
|
|
||||||
{...register("phone")}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-bold px-6 py-3 rounded-lg transition-all shadow-md"
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Enregistrement..." : "Enregistrer"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="border-2 border-gray-300 hover:bg-gray-100 text-gray-700 font-bold px-6 py-3 rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Account Status Card */}
|
|
||||||
<div>
|
|
||||||
<div className="bg-white rounded-xl shadow-md overflow-hidden border border-gray-200">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">Statut du compte</h2>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-600">
|
|
||||||
Membre depuis
|
|
||||||
</label>
|
|
||||||
<p className="text-sm text-gray-900 mt-1">
|
|
||||||
{formatDate(user.createdAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions Card */}
|
|
||||||
<div className="bg-white rounded-xl shadow-md overflow-hidden border border-gray-200 mt-6">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">Actions rapides</h2>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-2">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push(ROUTES.ADMIN_DASHBOARD)}
|
|
||||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold px-6 py-3 rounded-lg transition-all shadow-md"
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => router.push("/admin/utilisateurs")}
|
|
||||||
className="w-full border-2 border-blue-600 text-blue-600 hover:bg-blue-600 hover:text-white font-bold px-6 py-3 rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
Gestion Utilisateurs
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,221 +1,21 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import ProfilePage from "@/components/ProfilePage";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
|
||||||
import { profileUpdateSchema, ProfileUpdateFormData } from "@/lib/validations";
|
|
||||||
import { Input } from "@/components/ui/Input";
|
|
||||||
import { Badge } from "@/components/ui/Badge";
|
|
||||||
import { userService } from "@/services/user.service";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { ROUTES } from "@/utils/constants";
|
import { ROUTES } from "@/utils/constants";
|
||||||
import { formatDate } from "@/utils/helpers";
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default function EmployeProfilePage() {
|
export default function EmployeProfilePage() {
|
||||||
const { user, refreshUser } = useAuth();
|
|
||||||
const router = useRouter();
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<ProfileUpdateFormData>({
|
|
||||||
resolver: zodResolver(profileUpdateSchema),
|
|
||||||
defaultValues: {
|
|
||||||
firstName: user?.firstName || "",
|
|
||||||
lastName: user?.lastName || "",
|
|
||||||
phone: user?.phone || "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSubmit = async (data: ProfileUpdateFormData) => {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
await userService.updateProfile(data);
|
|
||||||
await refreshUser();
|
|
||||||
toast.success("Profil mis à jour avec succès");
|
|
||||||
setIsEditing(false);
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || "Erreur lors de la mise à jour du profil");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
reset({
|
|
||||||
firstName: user?.firstName || "",
|
|
||||||
lastName: user?.lastName || "",
|
|
||||||
phone: user?.phone || "",
|
|
||||||
});
|
|
||||||
setIsEditing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<ProfilePage
|
||||||
<div className="max-w-4xl mx-auto">
|
role="employee"
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">Mon profil</h1>
|
roleLabel="Employé"
|
||||||
|
badgeVariant="warning"
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
primaryColor="green"
|
||||||
{/* Profile Info Card */}
|
quickActions={[
|
||||||
<div className="md:col-span-2">
|
{ label: "Dashboard", route: ROUTES.EMPLOYEE_DASHBOARD, variant: "primary" },
|
||||||
<div className="bg-white rounded-xl shadow-md overflow-hidden border border-gray-200">
|
{ label: "Validation des gains", route: "/employe/verification", variant: "secondary" },
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
]}
|
||||||
<h2 className="text-xl font-bold text-gray-900">Informations personnelles</h2>
|
/>
|
||||||
</div>
|
|
||||||
<div className="p-6">
|
|
||||||
{!isEditing ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-600">
|
|
||||||
Prénom
|
|
||||||
</label>
|
|
||||||
<p className="text-lg text-gray-900">{user.firstName}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-600">
|
|
||||||
Nom
|
|
||||||
</label>
|
|
||||||
<p className="text-lg text-gray-900">{user.lastName}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-600">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<p className="text-lg text-gray-900">{user.email}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-600">
|
|
||||||
Téléphone
|
|
||||||
</label>
|
|
||||||
<p className="text-lg text-gray-900">
|
|
||||||
{user.phone || "Non renseigné"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-600">
|
|
||||||
Rôle
|
|
||||||
</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
<Badge variant="warning">Employé</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="pt-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
className="bg-green-600 hover:bg-green-700 text-white font-bold px-6 py-3 rounded-lg transition-all shadow-md"
|
|
||||||
>
|
|
||||||
Modifier mes informations
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<Input
|
|
||||||
id="firstName"
|
|
||||||
type="text"
|
|
||||||
label="Prénom"
|
|
||||||
error={errors.firstName?.message}
|
|
||||||
{...register("firstName")}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id="lastName"
|
|
||||||
type="text"
|
|
||||||
label="Nom"
|
|
||||||
error={errors.lastName?.message}
|
|
||||||
{...register("lastName")}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
label="Email"
|
|
||||||
value={user.email}
|
|
||||||
disabled
|
|
||||||
helperText="L'email ne peut pas être modifié"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id="phone"
|
|
||||||
type="tel"
|
|
||||||
label="Téléphone"
|
|
||||||
error={errors.phone?.message}
|
|
||||||
{...register("phone")}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white font-bold px-6 py-3 rounded-lg transition-all shadow-md"
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Enregistrement..." : "Enregistrer"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="border-2 border-gray-300 hover:bg-gray-100 text-gray-700 font-bold px-6 py-3 rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Account Status Card */}
|
|
||||||
<div>
|
|
||||||
<div className="bg-white rounded-xl shadow-md overflow-hidden border border-gray-200">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">Statut du compte</h2>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-600">
|
|
||||||
Membre depuis
|
|
||||||
</label>
|
|
||||||
<p className="text-sm text-gray-900 mt-1">
|
|
||||||
{formatDate(user.createdAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions Card */}
|
|
||||||
<div className="bg-white rounded-xl shadow-md overflow-hidden border border-gray-200 mt-6">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
|
||||||
<h2 className="text-xl font-bold text-gray-900">Actions rapides</h2>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-2">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push(ROUTES.EMPLOYEE_DASHBOARD)}
|
|
||||||
className="w-full bg-green-600 hover:bg-green-700 text-white font-bold px-6 py-3 rounded-lg transition-all shadow-md"
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => router.push("/employe/verification")}
|
|
||||||
className="w-full border-2 border-green-600 text-green-600 hover:bg-green-600 hover:text-white font-bold px-6 py-3 rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
Validation des gains
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
256
components/ProfilePage.tsx
Normal file
256
components/ProfilePage.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { profileUpdateSchema, ProfileUpdateFormData } from "@/lib/validations";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { userService } from "@/services/user.service";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { formatDate } from "@/utils/helpers";
|
||||||
|
|
||||||
|
interface QuickAction {
|
||||||
|
label: string;
|
||||||
|
route: string;
|
||||||
|
variant: 'primary' | 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfilePageProps {
|
||||||
|
role: 'admin' | 'employee';
|
||||||
|
roleLabel: string;
|
||||||
|
badgeVariant: 'danger' | 'warning' | 'info' | 'success';
|
||||||
|
primaryColor: 'blue' | 'green';
|
||||||
|
quickActions: QuickAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared profile page component for admin and employee
|
||||||
|
*/
|
||||||
|
export default function ProfilePage({
|
||||||
|
role,
|
||||||
|
roleLabel,
|
||||||
|
badgeVariant,
|
||||||
|
primaryColor,
|
||||||
|
quickActions,
|
||||||
|
}: ProfilePageProps) {
|
||||||
|
const { user, refreshUser } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const colorClasses = {
|
||||||
|
blue: {
|
||||||
|
primaryBtn: 'bg-blue-600 hover:bg-blue-700',
|
||||||
|
secondaryBtn: 'border-blue-600 text-blue-600 hover:bg-blue-600',
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
primaryBtn: 'bg-green-600 hover:bg-green-700',
|
||||||
|
secondaryBtn: 'border-green-600 text-green-600 hover:bg-green-600',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ProfileUpdateFormData>({
|
||||||
|
resolver: zodResolver(profileUpdateSchema),
|
||||||
|
defaultValues: {
|
||||||
|
firstName: user?.firstName || "",
|
||||||
|
lastName: user?.lastName || "",
|
||||||
|
phone: user?.phone || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (data: ProfileUpdateFormData) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await userService.updateProfile(data);
|
||||||
|
await refreshUser();
|
||||||
|
toast.success("Profil mis à jour avec succès");
|
||||||
|
setIsEditing(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Erreur lors de la mise à jour du profil");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
reset({
|
||||||
|
firstName: user?.firstName || "",
|
||||||
|
lastName: user?.lastName || "",
|
||||||
|
phone: user?.phone || "",
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const colors = colorClasses[primaryColor];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">Mon profil</h1>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
|
{/* Profile Info Card */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="bg-white rounded-xl shadow-md overflow-hidden border border-gray-200">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Informations personnelles</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
{!isEditing ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600">
|
||||||
|
Prénom
|
||||||
|
</label>
|
||||||
|
<p className="text-lg text-gray-900">{user.firstName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600">
|
||||||
|
Nom
|
||||||
|
</label>
|
||||||
|
<p className="text-lg text-gray-900">{user.lastName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<p className="text-lg text-gray-900">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600">
|
||||||
|
Téléphone
|
||||||
|
</label>
|
||||||
|
<p className="text-lg text-gray-900">
|
||||||
|
{user.phone || "Non renseigné"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600">
|
||||||
|
Rôle
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Badge variant={badgeVariant}>{roleLabel}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className={`${colors.primaryBtn} text-white font-bold px-6 py-3 rounded-lg transition-all shadow-md`}
|
||||||
|
>
|
||||||
|
Modifier mes informations
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
id="firstName"
|
||||||
|
type="text"
|
||||||
|
label="Prénom"
|
||||||
|
error={errors.firstName?.message}
|
||||||
|
{...register("firstName")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="lastName"
|
||||||
|
type="text"
|
||||||
|
label="Nom"
|
||||||
|
error={errors.lastName?.message}
|
||||||
|
{...register("lastName")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
value={user.email}
|
||||||
|
disabled
|
||||||
|
helperText="L'email ne peut pas être modifié"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
label="Téléphone"
|
||||||
|
error={errors.phone?.message}
|
||||||
|
{...register("phone")}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={`${colors.primaryBtn} disabled:bg-gray-400 text-white font-bold px-6 py-3 rounded-lg transition-all shadow-md`}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Enregistrement..." : "Enregistrer"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="border-2 border-gray-300 hover:bg-gray-100 text-gray-700 font-bold px-6 py-3 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Status Card */}
|
||||||
|
<div>
|
||||||
|
<div className="bg-white rounded-xl shadow-md overflow-hidden border border-gray-200">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Statut du compte</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-gray-600">
|
||||||
|
Membre depuis
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-gray-900 mt-1">
|
||||||
|
{formatDate(user.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions Card */}
|
||||||
|
<div className="bg-white rounded-xl shadow-md overflow-hidden border border-gray-200 mt-6">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Actions rapides</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-2">
|
||||||
|
{quickActions.map((action, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => router.push(action.route)}
|
||||||
|
className={`w-full font-bold px-6 py-3 rounded-lg transition-all ${
|
||||||
|
action.variant === 'primary'
|
||||||
|
? `${colors.primaryBtn} text-white shadow-md`
|
||||||
|
: `border-2 ${colors.secondaryBtn} hover:text-white`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
components/ui/ErrorState.tsx
Normal file
53
components/ui/ErrorState.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ErrorStateProps {
|
||||||
|
message?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
retryText?: string;
|
||||||
|
fullPage?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable error state component
|
||||||
|
*/
|
||||||
|
export const ErrorState: React.FC<ErrorStateProps> = ({
|
||||||
|
message = 'Une erreur est survenue',
|
||||||
|
onRetry,
|
||||||
|
retryText = 'Réessayer',
|
||||||
|
fullPage = true,
|
||||||
|
}) => {
|
||||||
|
const content = (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-800 px-6 py-4 rounded-lg mb-6 inline-flex items-center gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
{onRetry && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="inline-flex items-center gap-2 bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
{retryText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fullPage) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 flex items-center justify-center min-h-[400px]">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorState;
|
||||||
70
components/ui/LoadingState.tsx
Normal file
70
components/ui/LoadingState.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface LoadingStateProps {
|
||||||
|
type?: 'page' | 'card' | 'table' | 'list';
|
||||||
|
rows?: number;
|
||||||
|
columns?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loading state for different UI patterns
|
||||||
|
*/
|
||||||
|
export const LoadingState: React.FC<LoadingStateProps> = ({
|
||||||
|
type = 'page',
|
||||||
|
rows = 4,
|
||||||
|
columns = 4,
|
||||||
|
}) => {
|
||||||
|
if (type === 'card') {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-32 bg-gray-200 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'table') {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
{[...Array(rows)].map((_, i) => (
|
||||||
|
<div key={i} className="h-12 bg-gray-100 rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'list') {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
{[...Array(rows)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center space-x-4">
|
||||||
|
<div className="w-10 h-10 bg-gray-200 rounded-full"></div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
<div className="h-3 bg-gray-100 rounded w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: page layout with stats cards
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<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-${columns} gap-6`}>
|
||||||
|
{[...Array(columns)].map((_, i) => (
|
||||||
|
<div key={i} className="h-32 bg-gray-200 rounded-lg"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-64 bg-gray-200 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingState;
|
||||||
111
components/ui/StatusBadge.tsx
Normal file
111
components/ui/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/utils/helpers';
|
||||||
|
|
||||||
|
type RoleType = 'ADMIN' | 'EMPLOYEE' | 'CLIENT' | string;
|
||||||
|
type TicketStatusType = 'AVAILABLE' | 'CLAIMED' | 'PENDING' | 'EXPIRED' | string;
|
||||||
|
type StatusType = 'active' | 'inactive' | 'verified' | 'unverified' | string;
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
type: 'role' | 'ticket' | 'status' | 'custom';
|
||||||
|
value: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleColors: Record<string, string> = {
|
||||||
|
ADMIN: 'bg-red-100 text-red-800',
|
||||||
|
EMPLOYEE: 'bg-blue-100 text-blue-800',
|
||||||
|
CLIENT: 'bg-green-100 text-green-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ticketColors: Record<string, string> = {
|
||||||
|
AVAILABLE: 'bg-gray-100 text-gray-800',
|
||||||
|
CLAIMED: 'bg-green-100 text-green-800',
|
||||||
|
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||||
|
EXPIRED: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-800',
|
||||||
|
inactive: 'bg-gray-100 text-gray-800',
|
||||||
|
verified: 'bg-green-100 text-green-800',
|
||||||
|
unverified: 'bg-yellow-100 text-yellow-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
ADMIN: 'Admin',
|
||||||
|
EMPLOYEE: 'Employé',
|
||||||
|
CLIENT: 'Client',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ticketLabels: Record<string, string> = {
|
||||||
|
AVAILABLE: 'Disponible',
|
||||||
|
CLAIMED: 'Réclamé',
|
||||||
|
PENDING: 'En attente',
|
||||||
|
EXPIRED: 'Expiré',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
active: 'Actif',
|
||||||
|
inactive: 'Inactif',
|
||||||
|
verified: 'Vérifié',
|
||||||
|
unverified: 'Non vérifié',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable status badge component for roles, ticket status, and general status
|
||||||
|
*/
|
||||||
|
export const StatusBadge: React.FC<StatusBadgeProps> = ({
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
let colorClass = 'bg-gray-100 text-gray-800';
|
||||||
|
let label = value;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'role':
|
||||||
|
colorClass = roleColors[value] || colorClass;
|
||||||
|
label = roleLabels[value] || value;
|
||||||
|
break;
|
||||||
|
case 'ticket':
|
||||||
|
colorClass = ticketColors[value] || colorClass;
|
||||||
|
label = ticketLabels[value] || value;
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
colorClass = statusColors[value] || colorClass;
|
||||||
|
label = statusLabels[value] || value;
|
||||||
|
break;
|
||||||
|
case 'custom':
|
||||||
|
// Use value as-is for custom badges
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-1 inline-flex text-xs font-semibold rounded-full',
|
||||||
|
colorClass,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility functions for getting colors (for backward compatibility)
|
||||||
|
export const getRoleBadgeColor = (role: RoleType): string => {
|
||||||
|
return roleColors[role] || 'bg-gray-100 text-gray-800';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTicketStatusColor = (status: TicketStatusType): string => {
|
||||||
|
return ticketColors[status] || 'bg-gray-100 text-gray-800';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStatusColor = (status: StatusType): string => {
|
||||||
|
return statusColors[status] || 'bg-gray-100 text-gray-800';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusBadge;
|
||||||
|
|
@ -4,3 +4,6 @@ export { Card, CardHeader, CardTitle, CardContent, CardFooter } from './Card';
|
||||||
export { Loading } from './Loading';
|
export { Loading } from './Loading';
|
||||||
export { Badge } from './Badge';
|
export { Badge } from './Badge';
|
||||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table';
|
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table';
|
||||||
|
export { LoadingState } from './LoadingState';
|
||||||
|
export { ErrorState } from './ErrorState';
|
||||||
|
export { StatusBadge, getRoleBadgeColor, getTicketStatusColor, getStatusColor } from './StatusBadge';
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ export { useAuth } from '@/contexts/AuthContext';
|
||||||
export { useForm } from './useForm';
|
export { useForm } from './useForm';
|
||||||
export { useToast } from './useToast';
|
export { useToast } from './useToast';
|
||||||
export { useGame } from './useGame';
|
export { useGame } from './useGame';
|
||||||
|
export { useApi, useFetchData, apiFetch, getAuthToken } from './useApi';
|
||||||
|
|
|
||||||
134
hooks/useApi.ts
Normal file
134
hooks/useApi.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get authentication token from localStorage
|
||||||
|
*/
|
||||||
|
export const getAuthToken = (): string | null => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return localStorage.getItem('auth_token') || localStorage.getItem('token');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic fetch wrapper with authentication
|
||||||
|
*/
|
||||||
|
export const apiFetch = async <T>(
|
||||||
|
endpoint: string,
|
||||||
|
options?: RequestInit
|
||||||
|
): Promise<T> => {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const url = endpoint.startsWith('http') ? endpoint : `${API_BASE_URL}${endpoint}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ message: 'Erreur serveur' }));
|
||||||
|
throw new Error(error.message || `Erreur ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseApiState<T> {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseApiReturn<T> extends UseApiState<T> {
|
||||||
|
execute: (endpoint: string, options?: RequestInit) => Promise<T | null>;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for API calls with loading and error states
|
||||||
|
*/
|
||||||
|
export function useApi<T = unknown>(): UseApiReturn<T> {
|
||||||
|
const [state, setState] = useState<UseApiState<T>>({
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const execute = useCallback(async (endpoint: string, options?: RequestInit): Promise<T | null> => {
|
||||||
|
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<T>(endpoint, options);
|
||||||
|
setState({ data, loading: false, error: null });
|
||||||
|
return data;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Une erreur est survenue';
|
||||||
|
setState(prev => ({ ...prev, loading: false, error: message }));
|
||||||
|
toast.error(message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setState({ data: null, loading: false, error: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ...state, execute, reset };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFetchDataOptions {
|
||||||
|
onSuccess?: (data: unknown) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
showErrorToast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFetchDataReturn<T> {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching data on mount with auto-refresh support
|
||||||
|
*/
|
||||||
|
export function useFetchData<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options?: UseFetchDataOptions
|
||||||
|
): UseFetchDataReturn<T> {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<{ data: T } | T>(endpoint);
|
||||||
|
const responseData = (result as { data: T }).data ?? result as T;
|
||||||
|
setData(responseData);
|
||||||
|
options?.onSuccess?.(responseData);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Une erreur est survenue';
|
||||||
|
setError(message);
|
||||||
|
options?.onError?.(message);
|
||||||
|
if (options?.showErrorToast !== false) {
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [endpoint, options]);
|
||||||
|
|
||||||
|
return { data, loading, error, refetch: fetchData };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useApi;
|
||||||
Loading…
Reference in New Issue
Block a user