Checkpoint: Ajout d'un système complet de filtres et de recherche : filtres par date, lieu, statut et capacité pour les sessions ; recherche par nom/description pour les formations ; recherche multi-critères pour les apprenants. Compteurs de résultats inclus.

This commit is contained in:
Manus Sandbox
2025-11-12 09:11:45 -05:00
parent 2c5bc825af
commit 8a77f62723
4 changed files with 269 additions and 20 deletions

View File

@@ -6,14 +6,15 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { Plus, Pencil, Trash2 } from "lucide-react"; import { Plus, Pencil, Trash2, Search, SlidersHorizontal } from "lucide-react";
import { useState } from "react"; import { useState, useMemo } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
export default function AdminApprenants() { export default function AdminApprenants() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
nom: "", nom: "",
prenom: "", prenom: "",
@@ -89,6 +90,21 @@ export default function AdminApprenants() {
} }
}; };
// Filtrer les apprenants
const filteredApprenants = useMemo(() => {
if (!apprenants) return [];
return apprenants.filter(apprenant => {
const matchesSearch = searchTerm === "" ||
apprenant.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
apprenant.prenom.toLowerCase().includes(searchTerm.toLowerCase()) ||
apprenant.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
apprenant.codeEtablissement.toLowerCase().includes(searchTerm.toLowerCase());
return matchesSearch;
});
}, [apprenants, searchTerm]);
return ( return (
<DashboardLayout> <DashboardLayout>
<div className="space-y-6"> <div className="space-y-6">
@@ -177,6 +193,36 @@ export default function AdminApprenants() {
</Dialog> </Dialog>
</div> </div>
{/* Filtres et recherche */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<SlidersHorizontal className="w-5 h-5" />
Recherche
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="search">Rechercher un apprenant</Label>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="search"
placeholder="Nom, prénom, email ou code établissement..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
</div>
{/* Compteur de résultats */}
<div className="mt-4 text-sm text-muted-foreground">
{filteredApprenants.length} apprenant(s) trouvé(s)
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Liste des apprenants</CardTitle> <CardTitle>Liste des apprenants</CardTitle>
@@ -191,7 +237,7 @@ export default function AdminApprenants() {
<Skeleton className="h-10 w-full" /> <Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" /> <Skeleton className="h-10 w-full" />
</div> </div>
) : apprenants && apprenants.length > 0 ? ( ) : filteredApprenants && filteredApprenants.length > 0 ? (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -203,7 +249,7 @@ export default function AdminApprenants() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{apprenants.map((apprenant) => ( {filteredApprenants.map((apprenant) => (
<TableRow key={apprenant.id}> <TableRow key={apprenant.id}>
<TableCell className="font-medium">{apprenant.nom}</TableCell> <TableCell className="font-medium">{apprenant.nom}</TableCell>
<TableCell>{apprenant.prenom}</TableCell> <TableCell>{apprenant.prenom}</TableCell>

View File

@@ -6,16 +6,19 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { Plus, Pencil, Trash2, ExternalLink } from "lucide-react"; import { Plus, Pencil, Trash2, ExternalLink, Search, SlidersHorizontal } from "lucide-react";
import { useState } from "react"; import { useState, useMemo } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
export default function AdminFormations() { export default function AdminFormations() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [filterActif, setFilterActif] = useState<string>("all");
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
nom: "", nom: "",
description: "", description: "",
@@ -94,6 +97,25 @@ export default function AdminFormations() {
return `${window.location.origin}/inscription/${lien}`; return `${window.location.origin}/inscription/${lien}`;
}; };
// Filtrer les formations
const filteredFormations = useMemo(() => {
if (!formations) return [];
return formations.filter(formation => {
// Filtre par recherche
const matchesSearch = searchTerm === "" ||
formation.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
(formation.description && formation.description.toLowerCase().includes(searchTerm.toLowerCase()));
// Filtre par statut actif
const matchesActif = filterActif === "all" ||
(filterActif === "actif" && formation.actif) ||
(filterActif === "inactif" && !formation.actif);
return matchesSearch && matchesActif;
});
}, [formations, searchTerm, filterActif]);
return ( return (
<DashboardLayout> <DashboardLayout>
<div className="space-y-6"> <div className="space-y-6">
@@ -180,6 +202,54 @@ export default function AdminFormations() {
</Dialog> </Dialog>
</div> </div>
{/* Filtres et recherche */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<SlidersHorizontal className="w-5 h-5" />
Filtres et recherche
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Recherche */}
<div className="space-y-2">
<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 description..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
</div>
{/* Filtre par statut */}
<div className="space-y-2">
<Label htmlFor="filterActif">Statut</Label>
<Select value={filterActif} onValueChange={setFilterActif}>
<SelectTrigger id="filterActif">
<SelectValue placeholder="Tous les statuts" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les statuts</SelectItem>
<SelectItem value="actif">Active</SelectItem>
<SelectItem value="inactif">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Compteur de résultats */}
<div className="mt-4 text-sm text-muted-foreground">
{filteredFormations.length} formation(s) trouvée(s)
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Liste des formations</CardTitle> <CardTitle>Liste des formations</CardTitle>
@@ -194,7 +264,7 @@ export default function AdminFormations() {
<Skeleton className="h-10 w-full" /> <Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" /> <Skeleton className="h-10 w-full" />
</div> </div>
) : formations && formations.length > 0 ? ( ) : filteredFormations && filteredFormations.length > 0 ? (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -206,7 +276,7 @@ export default function AdminFormations() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{formations.map((formation) => ( {filteredFormations.map((formation) => (
<TableRow key={formation.id}> <TableRow key={formation.id}>
<TableCell className="font-medium">{formation.nom}</TableCell> <TableCell className="font-medium">{formation.nom}</TableCell>
<TableCell className="max-w-xs truncate">{formation.description || "-"}</TableCell> <TableCell className="max-w-xs truncate">{formation.description || "-"}</TableCell>

View File

@@ -7,8 +7,8 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { Plus, Pencil, Trash2, Users as UsersIcon } from "lucide-react"; import { Plus, Pencil, Trash2, Users as UsersIcon, Search, SlidersHorizontal } from "lucide-react";
import { useState } from "react"; import { useState, useMemo } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -19,6 +19,10 @@ export default function AdminSessions() {
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [filterLieu, setFilterLieu] = useState<string>("all");
const [filterStatut, setFilterStatut] = useState<string>("all");
const [sortBy, setSortBy] = useState<"date" | "lieu" | "places">("date");
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
formationId: "", formationId: "",
nom: "", nom: "",
@@ -127,6 +131,47 @@ export default function AdminSessions() {
return formations?.find(f => f.id === formationId)?.nom || "Formation inconnue"; return formations?.find(f => f.id === formationId)?.nom || "Formation inconnue";
}; };
// Extraire les lieux uniques pour le filtre
const uniqueLieux = useMemo(() => {
if (!sessions) return [];
const lieux = sessions.map(s => s.lieu);
return Array.from(new Set(lieux)).sort();
}, [sessions]);
// Filtrer et trier les sessions
const filteredAndSortedSessions = useMemo(() => {
if (!sessions) return [];
let filtered = sessions.filter(session => {
// Filtre par recherche (nom de session ou formation)
const matchesSearch = searchTerm === "" ||
session.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
getFormationName(session.formationId).toLowerCase().includes(searchTerm.toLowerCase());
// Filtre par lieu
const matchesLieu = filterLieu === "all" || session.lieu === filterLieu;
// Filtre par statut
const matchesStatut = filterStatut === "all" || session.statut === filterStatut;
return matchesSearch && matchesLieu && matchesStatut;
});
// Tri
filtered.sort((a, b) => {
if (sortBy === "date") {
return new Date(a.dateDebut).getTime() - new Date(b.dateDebut).getTime();
} else if (sortBy === "lieu") {
return a.lieu.localeCompare(b.lieu);
} else if (sortBy === "places") {
return a.capaciteMax - b.capaciteMax;
}
return 0;
});
return filtered;
}, [sessions, searchTerm, filterLieu, filterStatut, sortBy, formations]);
const formatDate = (date: Date | string) => { const formatDate = (date: Date | string) => {
return format(new Date(date), "dd/MM/yyyy HH:mm", { locale: fr }); return format(new Date(date), "dd/MM/yyyy HH:mm", { locale: fr });
}; };
@@ -152,13 +197,14 @@ export default function AdminSessions() {
return ( return (
<DashboardLayout> <DashboardLayout>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-center"> <div>
<div> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold tracking-tight">Sessions</h1> <div>
<p className="text-muted-foreground mt-2"> <h1 className="text-3xl font-bold tracking-tight">Sessions</h1>
Gérez les sessions de formation et leurs inscriptions <p className="text-muted-foreground mt-2">
</p> Gérez les sessions de formation
</div> </p>
</div>
<Dialog open={open} onOpenChange={(isOpen) => { <Dialog open={open} onOpenChange={(isOpen) => {
setOpen(isOpen); setOpen(isOpen);
if (!isOpen) resetForm(); if (!isOpen) resetForm();
@@ -292,6 +338,87 @@ export default function AdminSessions() {
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div>
{/* Filtres et recherche */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<SlidersHorizontal className="w-5 h-5" />
Filtres et recherche
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Recherche */}
<div className="space-y-2">
<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 de session ou formation..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
</div>
{/* Filtre par lieu */}
<div className="space-y-2">
<Label htmlFor="filterLieu">Lieu</Label>
<Select value={filterLieu} onValueChange={setFilterLieu}>
<SelectTrigger id="filterLieu">
<SelectValue placeholder="Tous les lieux" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les lieux</SelectItem>
{uniqueLieux.map(lieu => (
<SelectItem key={lieu} value={lieu}>{lieu}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Filtre par statut */}
<div className="space-y-2">
<Label htmlFor="filterStatut">Statut</Label>
<Select value={filterStatut} onValueChange={setFilterStatut}>
<SelectTrigger id="filterStatut">
<SelectValue placeholder="Tous les statuts" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les statuts</SelectItem>
<SelectItem value="ouverte">Ouverte</SelectItem>
<SelectItem value="bloquee">Bloquée</SelectItem>
<SelectItem value="terminee">Terminée</SelectItem>
</SelectContent>
</Select>
</div>
{/* Tri */}
<div className="space-y-2">
<Label htmlFor="sortBy">Trier par</Label>
<Select value={sortBy} onValueChange={(value: any) => setSortBy(value)}>
<SelectTrigger id="sortBy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="date">Date</SelectItem>
<SelectItem value="lieu">Lieu</SelectItem>
<SelectItem value="places">Capacité</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Compteur de résultats */}
<div className="mt-4 text-sm text-muted-foreground">
{filteredAndSortedSessions.length} session(s) trouvée(s)
</div>
</CardContent>
</Card>
</div> </div>
<Card> <Card>
@@ -308,7 +435,7 @@ export default function AdminSessions() {
<Skeleton className="h-10 w-full" /> <Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" /> <Skeleton className="h-10 w-full" />
</div> </div>
) : sessions && sessions.length > 0 ? ( ) : filteredAndSortedSessions && filteredAndSortedSessions.length > 0 ? (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@@ -323,8 +450,8 @@ export default function AdminSessions() {
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{sessions.map((session) => ( {filteredAndSortedSessions.map((session) => (
<TableRow key={session.id}> <TableRow key={session.id}>
<TableCell className="font-medium">{getFormationName(session.formationId)}</TableCell> <TableCell className="font-medium">{getFormationName(session.formationId)}</TableCell>
<TableCell>{session.nom}</TableCell> <TableCell>{session.nom}</TableCell>

View File

@@ -61,3 +61,9 @@
- [x] Corriger la mise à jour de la liste des apprenants après inscription - [x] Corriger la mise à jour de la liste des apprenants après inscription
- [x] Implémenter l'envoi effectif des emails de confirmation - [x] Implémenter l'envoi effectif des emails de confirmation
## Nouvelles fonctionnalités
- [x] Ajouter des filtres de tri sur la page des sessions (date, lieu, places restantes)
- [x] Ajouter des filtres de recherche sur la page des formations
- [x] Ajouter des filtres de recherche sur la page des apprenants