Checkpoint: Ajout d'une fonctionnalité complète de gestion des utilisateurs : ajout du champ isActive dans le schéma users, création des procédures tRPC (list, update, toggleStatus, delete), développement de la page AdminUsers avec tableau, filtres (recherche, rôle, statut), formulaires de modification, activation/désactivation et suppression des comptes, intégration dans le menu de navigation avec l'icône UserCog
This commit is contained in:
@@ -11,6 +11,7 @@ import AdminFormations from "./pages/AdminFormations";
|
||||
import AdminSequences from "./pages/AdminSequences";
|
||||
import AdminApprenants from "./pages/AdminApprenants";
|
||||
import AdminSequenceInscrits from "./pages/AdminSequenceInscrits";
|
||||
import AdminUsers from "./pages/AdminUsers";
|
||||
import Inscription from "./pages/Inscription";
|
||||
|
||||
function Router() {
|
||||
@@ -24,6 +25,7 @@ function Router() {
|
||||
<Route path={"/admin/sequences"} component={AdminSequences} />
|
||||
<Route path={"/admin/apprenants"} component={AdminApprenants} />
|
||||
<Route path={"/admin/sequences/:id/inscrits"} component={AdminSequenceInscrits} />
|
||||
<Route path={"/admin/users"} component={AdminUsers} />
|
||||
<Route path={"/404"} component={NotFound} />
|
||||
{/* Final fallback route */}
|
||||
<Route component={NotFound} />
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, BarChart3 } from "lucide-react";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, BarChart3, UserCog } from "lucide-react";
|
||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||
@@ -32,6 +32,7 @@ const menuItems = [
|
||||
{ icon: GraduationCap, label: "Formations", path: "/admin/formations" },
|
||||
{ icon: Calendar, label: "Séquences", path: "/admin/sequences" },
|
||||
{ icon: Users, label: "Apprenants", path: "/admin/apprenants" },
|
||||
{ icon: UserCog, label: "Utilisateurs", path: "/admin/users" },
|
||||
{ icon: BarChart3, label: "Rapport Public Cible", path: "/admin/rapport-public-cible" },
|
||||
];
|
||||
|
||||
|
||||
373
client/src/pages/AdminUsers.tsx
Normal file
373
client/src/pages/AdminUsers.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import { useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { Pencil, Trash2, UserCheck, UserX, Search } from "lucide-react";
|
||||
|
||||
export default function AdminUsers() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterRole, setFilterRole] = useState<string>("all");
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<any>(null);
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
role: "user" as "user" | "admin",
|
||||
});
|
||||
|
||||
const { data: users = [], refetch } = trpc.users.list.useQuery();
|
||||
const updateMutation = trpc.users.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Utilisateur modifié avec succès");
|
||||
refetch();
|
||||
setEditDialogOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur : ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const toggleStatusMutation = trpc.users.toggleStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Statut modifié avec succès");
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur : ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.users.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Utilisateur supprimé avec succès");
|
||||
refetch();
|
||||
setDeleteDialogOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur : ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (user: any) => {
|
||||
setSelectedUser(user);
|
||||
setEditForm({
|
||||
name: user.name || "",
|
||||
email: user.email || "",
|
||||
role: user.role,
|
||||
});
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
updateMutation.mutate({
|
||||
id: selectedUser.id,
|
||||
name: editForm.name || undefined,
|
||||
email: editForm.email || undefined,
|
||||
role: editForm.role,
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleStatus = (user: any) => {
|
||||
toggleStatusMutation.mutate({
|
||||
id: user.id,
|
||||
isActive: !user.isActive,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (user: any) => {
|
||||
setSelectedUser(user);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!selectedUser) return;
|
||||
deleteMutation.mutate({ id: selectedUser.id });
|
||||
};
|
||||
|
||||
// Filtrage des utilisateurs
|
||||
const filteredUsers = users.filter((user) => {
|
||||
const matchesSearch =
|
||||
searchTerm === "" ||
|
||||
user.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesRole = filterRole === "all" || user.role === filterRole;
|
||||
const matchesStatus =
|
||||
filterStatus === "all" ||
|
||||
(filterStatus === "active" && user.isActive) ||
|
||||
(filterStatus === "inactive" && !user.isActive);
|
||||
|
||||
return matchesSearch && matchesRole && matchesStatus;
|
||||
});
|
||||
|
||||
const formatDate = (date: Date | string | null) => {
|
||||
if (!date) return "N/A";
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold mb-2">Gestion des Utilisateurs</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gérez les comptes utilisateurs de l'application
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<div className="mb-6 flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Label htmlFor="search">Rechercher</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="search"
|
||||
placeholder="Nom ou email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[200px]">
|
||||
<Label htmlFor="filterRole">Rôle</Label>
|
||||
<Select value={filterRole} onValueChange={setFilterRole}>
|
||||
<SelectTrigger id="filterRole">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les rôles</SelectItem>
|
||||
<SelectItem value="admin">Administrateur</SelectItem>
|
||||
<SelectItem value="user">Utilisateur</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-[200px]">
|
||||
<Label htmlFor="filterStatus">Statut</Label>
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger id="filterStatus">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||
<SelectItem value="active">Actif</SelectItem>
|
||||
<SelectItem value="inactive">Inactif</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compteur de résultats */}
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredUsers.length} utilisateur(s) trouvé(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tableau des utilisateurs */}
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Rôle</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead>Dernière connexion</TableHead>
|
||||
<TableHead>Créé le</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||
Aucun utilisateur trouvé
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.id}</TableCell>
|
||||
<TableCell>{user.name || "N/A"}</TableCell>
|
||||
<TableCell>{user.email || "N/A"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
|
||||
{user.role === "admin" ? "Administrateur" : "Utilisateur"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.isActive ? "default" : "destructive"}>
|
||||
{user.isActive ? "Actif" : "Inactif"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(user.lastSignedIn)}</TableCell>
|
||||
<TableCell>{formatDate(user.createdAt)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(user)}
|
||||
title="Modifier"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleToggleStatus(user)}
|
||||
title={user.isActive ? "Désactiver" : "Activer"}
|
||||
>
|
||||
{user.isActive ? (
|
||||
<UserX className="h-4 w-4 text-orange-500" />
|
||||
) : (
|
||||
<UserCheck className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(user)}
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Dialog de modification */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
||||
<DialogDescription>
|
||||
Modifiez les informations de l'utilisateur
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="editName">Nom</Label>
|
||||
<Input
|
||||
id="editName"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
placeholder="Nom de l'utilisateur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="editEmail">Email</Label>
|
||||
<Input
|
||||
id="editEmail"
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||
placeholder="email@exemple.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="editRole">Rôle</Label>
|
||||
<Select
|
||||
value={editForm.role}
|
||||
onValueChange={(value: "user" | "admin") =>
|
||||
setEditForm({ ...editForm, role: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="editRole">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">Utilisateur</SelectItem>
|
||||
<SelectItem value="admin">Administrateur</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button onClick={handleSaveEdit} disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog de suppression */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmer la suppression</DialogTitle>
|
||||
<DialogDescription>
|
||||
Êtes-vous sûr de vouloir supprimer l'utilisateur{" "}
|
||||
<strong>{selectedUser?.name || selectedUser?.email}</strong> ?
|
||||
Cette action est irréversible.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? "Suppression..." : "Supprimer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user