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:
soufiane 2025-11-30 16:06:40 +01:00
parent 534b6fc50c
commit c7c2a3f56c
11 changed files with 652 additions and 1499 deletions

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
); );
} }

View File

@ -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
View 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>
);
}

View 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;

View 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;

View 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;

View File

@ -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';

View File

@ -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
View 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;