diff --git a/app/admin/dashboard/page-advanced.tsx b/app/admin/dashboard/page-advanced.tsx deleted file mode 100644 index f4b259b..0000000 --- a/app/admin/dashboard/page-advanced.tsx +++ /dev/null @@ -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(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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 ( -
-
-
-
- {[...Array(4)].map((_, i) => ( -
- ))} -
-
-
- ); - } - - if (error || !stats) { - return ( -
-
- - {error || "Erreur lors du chargement des statistiques"} -
- -
- ); - } - - // 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 ( -
- {/* Header avec contrôles */} -
-
-
-

Dashboard Administrateur Avancé

-

- Statistiques complètes et analyses en temps réel -

-
- -
- {/* Auto-refresh toggle */} - - - {/* Export button */} - - - {/* Refresh button */} - -
-
- - {/* Filter period */} -
- - Période: - {["all", "week", "month", "year"].map((period) => ( - - ))} -
-
- - {/* Stats Cards - Principales */} -
- } - color="blue" - link="/admin/utilisateurs" - trend="+12%" - /> - } - color="green" - link="/admin/tickets" - trend="+8%" - /> - } - color="purple" - link="/admin/tickets" - trend="+15%" - /> - } - color="yellow" - link="/admin/tickets" - trend="+10%" - /> -
- - {/* Graphiques en ligne */} -
- {/* Graphique répartition des lots */} - {prizeChartData.length > 0 && ( -
-

- - Répartition des Lots -

- - - `${entry.name}: ${entry.percentage}%`} - outerRadius={80} - fill="#8884d8" - dataKey="value" - > - {prizeChartData.map((entry, index) => ( - - ))} - - - - -
- )} - - {/* Graphique répartition par genre */} - {genderChartData.length > 0 && ( -
-

- - Répartition par Genre -

- - - `${name}: ${value}`} - outerRadius={80} - fill="#8884d8" - dataKey="value" - > - {genderChartData.map((entry, index) => ( - - ))} - - - - -
- )} -
- - {/* Graphique tranches d'âge */} - {ageChartData.length > 0 && ( -
-

- - Répartition par Âge -

- - - - - - - - - - -
- )} - - {/* Section détaillée existante */} -
- {/* Statistiques Tickets détaillées */} -
-
-

- - Statistiques des Tickets -

- - Voir tout → - -
-
- - - - - - -
-
- - {/* Utilisateurs */} -
-
-

- - Utilisateurs -

- - Voir tout → - -
-
- - - - -
-
-
- - {/* Top Villes */} - {stats?.demographics?.topCities && stats.demographics.topCities.length > 0 && ( -
-

- - Top 10 Villes des Participants -

-
- {stats.demographics.topCities.slice(0, 10).map((city, idx) => ( - - ))} -
-
- )} -
- ); -} - -// 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 ( - -
-
{icon}
- {trend && ( -
- - {trend} -
- )} -
-

{title}

-

{(value || 0).toLocaleString("fr-FR")}

- {subtitle &&

{subtitle}

} - - ); -} - -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 ( -
- {label} -
- - {(value || 0).toLocaleString("fr-FR")} - - {percentage !== undefined && ({percentage}%)} -
-
- ); -} - -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 ( -
-
- #{rank} - -
-

- {city} -

-
- {count} - ({percentage.toFixed(1)}%) -
-
- ); -} diff --git a/app/admin/dashboard/page-backup.tsx b/app/admin/dashboard/page-backup.tsx deleted file mode 100644 index b86fc64..0000000 --- a/app/admin/dashboard/page-backup.tsx +++ /dev/null @@ -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(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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 ( -
-
-
-
- {[...Array(4)].map((_, i) => ( -
- ))} -
-
-
- ); - } - - if (error || !stats) { - return ( -
-
- - {error || "Erreur lors du chargement des statistiques"} -
- -
- ); - } - - // 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 ( -
- {/* Header */} -
-
-

Dashboard Administrateur

-

- Statistiques complètes du jeu-concours Thé Tip Top -

-
- -
- - {/* Stats Cards - Principales */} -
- } - color="blue" - link="/admin/utilisateurs" - /> - } - color="green" - link="/admin/tickets" - /> - } - color="purple" - link="/admin/tickets" - /> - } - color="yellow" - link="/admin/tickets" - /> -
- - {/* Section des Tickets */} -
- {/* Statistiques Tickets détaillées */} -
-
-

- - Statistiques des Tickets -

- - Voir tout → - -
-
- - - - - - -
-
- - {/* Utilisateurs */} -
-
-

- - Utilisateurs -

- - Voir tout → - -
-
- - - - -
-
-
- - {/* Répartition des Lots par Catégorie */} - {stats?.prizes?.byCategory && stats.prizes.byCategory.length > 0 && ( -
-

- - Répartition des Lots Gagnés par Catégorie -

-
- {stats.prizes.byCategory.map((prize) => ( - - ))} -
-
- )} - - {/* Statistiques Démographiques */} - {stats?.demographics && ( -
-

- - Statistiques Démographiques des Participants -

- -
- {/* Répartition par Genre */} - {stats.demographics.gender && ( -
-

- - Répartition par Genre -

-
- - - - -
-
- )} - - {/* Répartition par Âge */} - {stats.demographics.ageRanges && stats.demographics.ageRanges.length > 0 && ( -
-

- - Répartition par Âge -

-
- {stats.demographics.ageRanges.map((range, idx) => ( - - ))} -
-
- )} -
- - {/* Top Villes */} - {stats.demographics.topCities && stats.demographics.topCities.length > 0 && ( -
-

- - Top 10 Villes des Participants -

-
- {stats.demographics.topCities.slice(0, 10).map((city, idx) => ( - - ))} -
-
- )} -
- )} - -
- ); -} - -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 ( - -
-
{icon}
- -
-

{title}

-

- {(value || 0).toLocaleString("fr-FR")} -

- {subtitle && ( -

{subtitle}

- )} - - ); -} - -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 ( -
- {label} -
- - {(value || 0).toLocaleString("fr-FR")} - - {percentage !== undefined && ( - ({percentage}%) - )} -
-
- ); -} - -// 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 ( -
-
-
-

{name}

- - {type === "PHYSICAL" ? "Physique" : "Réduction"} - -
-
-
-
-

{count}

-

distribués

-
-
-

{percentage.toFixed(1)}%

-
-
- {/* Barre de progression */} -
-
-
-
- ); -} - -// 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 ( -
-
- {label} -
- - {value.toLocaleString("fr-FR")} - - - ({calculatedPercentage.toFixed(1)}%) - -
-
-
-
-
-
- ); -} - -// 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 ( -
-
- #{rank} - -
-

- {city} -

-
- {count} - ({percentage.toFixed(1)}%) -
-
- ); -} - diff --git a/app/admin/profil/page.tsx b/app/admin/profil/page.tsx index 35de397..8183a97 100644 --- a/app/admin/profil/page.tsx +++ b/app/admin/profil/page.tsx @@ -1,223 +1,21 @@ "use client"; -import { useState, useEffect } 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 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 ProfilePage from "@/components/ProfilePage"; import { ROUTES } from "@/utils/constants"; -import { formatDate } from "@/utils/helpers"; export const dynamic = 'force-dynamic'; 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({ - 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 ( -
-
-

Mon profil

- -
- {/* Profile Info Card */} -
-
-
-

Informations personnelles

-
-
- {!isEditing ? ( -
-
- -

{user.firstName}

-
-
- -

{user.lastName}

-
-
- -

{user.email}

-
-
- -

- {user.phone || "Non renseigné"} -

-
-
- -
- Administrateur -
-
-
- -
-
- ) : ( -
- - - - -
- - -
-
- )} -
-
-
- - {/* Account Status Card */} -
-
-
-

Statut du compte

-
-
-
- -

- {formatDate(user.createdAt)} -

-
-
-
- - {/* Quick Actions Card */} -
-
-

Actions rapides

-
-
- - -
-
-
-
-
-
+ ); } diff --git a/app/employe/profil/page.tsx b/app/employe/profil/page.tsx index 85477a6..3b2bf1e 100644 --- a/app/employe/profil/page.tsx +++ b/app/employe/profil/page.tsx @@ -1,221 +1,21 @@ "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 ProfilePage from "@/components/ProfilePage"; import { ROUTES } from "@/utils/constants"; -import { formatDate } from "@/utils/helpers"; export const dynamic = 'force-dynamic'; 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({ - 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 ( -
-
-

Mon profil

- -
- {/* Profile Info Card */} -
-
-
-

Informations personnelles

-
-
- {!isEditing ? ( -
-
- -

{user.firstName}

-
-
- -

{user.lastName}

-
-
- -

{user.email}

-
-
- -

- {user.phone || "Non renseigné"} -

-
-
- -
- Employé -
-
-
- -
-
- ) : ( -
- - - - -
- - -
-
- )} -
-
-
- - {/* Account Status Card */} -
-
-
-

Statut du compte

-
-
-
- -

- {formatDate(user.createdAt)} -

-
-
-
- - {/* Quick Actions Card */} -
-
-

Actions rapides

-
-
- - -
-
-
-
-
-
+ ); } diff --git a/components/ProfilePage.tsx b/components/ProfilePage.tsx new file mode 100644 index 0000000..468f782 --- /dev/null +++ b/components/ProfilePage.tsx @@ -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({ + 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 ( +
+
+

Mon profil

+ +
+ {/* Profile Info Card */} +
+
+
+

Informations personnelles

+
+
+ {!isEditing ? ( +
+
+ +

{user.firstName}

+
+
+ +

{user.lastName}

+
+
+ +

{user.email}

+
+
+ +

+ {user.phone || "Non renseigné"} +

+
+
+ +
+ {roleLabel} +
+
+
+ +
+
+ ) : ( +
+ + + + +
+ + +
+
+ )} +
+
+
+ + {/* Account Status Card */} +
+
+
+

Statut du compte

+
+
+
+ +

+ {formatDate(user.createdAt)} +

+
+
+
+ + {/* Quick Actions Card */} +
+
+

Actions rapides

+
+
+ {quickActions.map((action, index) => ( + + ))} +
+
+
+
+
+
+ ); +} diff --git a/components/ui/ErrorState.tsx b/components/ui/ErrorState.tsx new file mode 100644 index 0000000..6273dc7 --- /dev/null +++ b/components/ui/ErrorState.tsx @@ -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 = ({ + message = 'Une erreur est survenue', + onRetry, + retryText = 'Réessayer', + fullPage = true, +}) => { + const content = ( +
+
+ + {message} +
+ {onRetry && ( +
+ +
+ )} +
+ ); + + if (fullPage) { + return ( +
+ {content} +
+ ); + } + + return content; +}; + +export default ErrorState; diff --git a/components/ui/LoadingState.tsx b/components/ui/LoadingState.tsx new file mode 100644 index 0000000..a6aa3d2 --- /dev/null +++ b/components/ui/LoadingState.tsx @@ -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 = ({ + type = 'page', + rows = 4, + columns = 4, +}) => { + if (type === 'card') { + return ( +
+
+
+ ); + } + + if (type === 'table') { + return ( +
+
+ {[...Array(rows)].map((_, i) => ( +
+ ))} +
+ ); + } + + if (type === 'list') { + return ( +
+ {[...Array(rows)].map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); + } + + // Default: page layout with stats cards + return ( +
+
+
+
+ {[...Array(columns)].map((_, i) => ( +
+ ))} +
+
+
+
+ ); +}; + +export default LoadingState; diff --git a/components/ui/StatusBadge.tsx b/components/ui/StatusBadge.tsx new file mode 100644 index 0000000..2edf8c6 --- /dev/null +++ b/components/ui/StatusBadge.tsx @@ -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 = { + ADMIN: 'bg-red-100 text-red-800', + EMPLOYEE: 'bg-blue-100 text-blue-800', + CLIENT: 'bg-green-100 text-green-800', +}; + +const ticketColors: Record = { + 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 = { + 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 = { + ADMIN: 'Admin', + EMPLOYEE: 'Employé', + CLIENT: 'Client', +}; + +const ticketLabels: Record = { + AVAILABLE: 'Disponible', + CLAIMED: 'Réclamé', + PENDING: 'En attente', + EXPIRED: 'Expiré', +}; + +const statusLabels: Record = { + 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 = ({ + 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 ( + + {label} + + ); +}; + +// 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; diff --git a/components/ui/index.ts b/components/ui/index.ts index a02ddd6..9bdb93c 100644 --- a/components/ui/index.ts +++ b/components/ui/index.ts @@ -4,3 +4,6 @@ export { Card, CardHeader, CardTitle, CardContent, CardFooter } from './Card'; export { Loading } from './Loading'; export { Badge } from './Badge'; export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table'; +export { LoadingState } from './LoadingState'; +export { ErrorState } from './ErrorState'; +export { StatusBadge, getRoleBadgeColor, getTicketStatusColor, getStatusColor } from './StatusBadge'; diff --git a/hooks/index.ts b/hooks/index.ts index 1ab0730..24204cd 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -2,3 +2,4 @@ export { useAuth } from '@/contexts/AuthContext'; export { useForm } from './useForm'; export { useToast } from './useToast'; export { useGame } from './useGame'; +export { useApi, useFetchData, apiFetch, getAuthToken } from './useApi'; diff --git a/hooks/useApi.ts b/hooks/useApi.ts new file mode 100644 index 0000000..09216ab --- /dev/null +++ b/hooks/useApi.ts @@ -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 ( + endpoint: string, + options?: RequestInit +): Promise => { + 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 { + data: T | null; + loading: boolean; + error: string | null; +} + +interface UseApiReturn extends UseApiState { + execute: (endpoint: string, options?: RequestInit) => Promise; + reset: () => void; +} + +/** + * Hook for API calls with loading and error states + */ +export function useApi(): UseApiReturn { + const [state, setState] = useState>({ + data: null, + loading: false, + error: null, + }); + + const execute = useCallback(async (endpoint: string, options?: RequestInit): Promise => { + setState(prev => ({ ...prev, loading: true, error: null })); + + try { + const data = await apiFetch(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 { + data: T | null; + loading: boolean; + error: string | null; + refetch: () => Promise; +} + +/** + * Hook for fetching data on mount with auto-refresh support + */ +export function useFetchData( + endpoint: string, + options?: UseFetchDataOptions +): UseFetchDataReturn { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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;