Files
formation-manager-itinova/client/src/pages/AdminSessionInscrits.tsx

432 lines
17 KiB
TypeScript

import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { trpc } from "@/lib/trpc";
import { ArrowLeft, Download, Mail, Search, SlidersHorizontal } from "lucide-react";
import { useLocation, useRoute } from "wouter";
import { useState, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
export default function AdminSessionInscrits() {
const [, setLocation] = useLocation();
const [, params] = useRoute("/admin/sessions/:id/inscrits");
const sessionId = params?.id ? parseInt(params.id) : 0;
// États pour les filtres
const [searchTerm, setSearchTerm] = useState("");
const [filterStatut, setFilterStatut] = useState<string>("all");
const [filterEtablissement, setFilterEtablissement] = useState<string>("all");
const [sortBy, setSortBy] = useState<"date" | "nom">("date");
const utils = trpc.useUtils();
const { data: session, isLoading: loadingSession } = trpc.sessions.getById.useQuery({ id: sessionId });
const { data: inscriptions, isLoading: loadingInscriptions } = trpc.inscriptions.listBySession.useQuery({ sessionId });
const { data: formations } = trpc.formations.list.useQuery();
const updateStatutMutation = trpc.inscriptions.updateStatut.useMutation({
onSuccess: () => {
utils.inscriptions.listBySession.invalidate({ sessionId });
toast.success("Statut mis à jour avec succès");
},
onError: (error) => {
toast.error("Erreur lors de la mise à jour : " + error.message);
},
});
const sendEmailsMutation = trpc.inscriptions.sendGroupEmails.useMutation({
onSuccess: (result) => {
toast.success(`Emails envoyés : ${result.sent} réussis, ${result.failed} échecs`);
},
onError: (error) => {
toast.error("Erreur lors de l'envoi : " + error.message);
},
});
const handleStatutChange = (inscriptionId: number, newStatut: string) => {
updateStatutMutation.mutate({
id: inscriptionId,
statut: newStatut as "confirmee" | "liste_attente" | "annulee",
});
};
const exportExcelMutation = trpc.inscriptions.exportExcel.useMutation({
onSuccess: (result) => {
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `inscrits-session-${sessionId}.xlsx`;
a.click();
window.URL.revokeObjectURL(url);
toast.success("Export Excel téléchargé");
},
onError: (error) => {
toast.error("Erreur lors de l'export : " + error.message);
},
});
const exportPDFMutation = trpc.inscriptions.exportPDF.useMutation({
onSuccess: (result) => {
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], {
type: 'application/pdf'
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `inscrits-session-${sessionId}.pdf`;
a.click();
window.URL.revokeObjectURL(url);
toast.success("Export PDF téléchargé");
},
onError: (error) => {
toast.error("Erreur lors de l'export : " + error.message);
},
});
const exportFeuilleMutation = trpc.inscriptions.exportFeuillePresence.useMutation({
onSuccess: (result) => {
const blob = new Blob([Uint8Array.from(atob(result.data), c => c.charCodeAt(0))], {
type: 'application/pdf'
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `feuille-presence-session-${sessionId}.pdf`;
a.click();
window.URL.revokeObjectURL(url);
toast.success("Feuille de présence téléchargée");
},
onError: (error) => {
toast.error("Erreur lors de la génération : " + error.message);
},
});
const handleExportExcel = () => {
exportExcelMutation.mutate({ sessionId });
};
const handleExportPDF = () => {
exportPDFMutation.mutate({ sessionId });
};
const handleGenerateFeuillePresence = () => {
exportFeuilleMutation.mutate({ sessionId });
};
const getFormationName = (formationId: number) => {
return formations?.find(f => f.id === formationId)?.nom || "Formation inconnue";
};
// Extraire les codes établissement uniques
const uniqueEtablissements = useMemo(() => {
if (!inscriptions) return [];
const codes = inscriptions
.filter(i => i.apprenant)
.map(i => i.apprenant!.codeEtablissement);
return Array.from(new Set(codes)).sort();
}, [inscriptions]);
// Filtrer et trier les inscriptions
const filteredAndSortedInscriptions = useMemo(() => {
if (!inscriptions) return [];
let filtered = inscriptions.filter(inscription => {
// Filtre par recherche (nom, prénom, email)
const matchesSearch = searchTerm === "" ||
inscription.apprenant?.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
inscription.apprenant?.prenom.toLowerCase().includes(searchTerm.toLowerCase()) ||
inscription.apprenant?.email.toLowerCase().includes(searchTerm.toLowerCase());
// Filtre par statut
const matchesStatut = filterStatut === "all" || inscription.inscription.statut === filterStatut;
// Filtre par établissement
const matchesEtablissement = filterEtablissement === "all" ||
inscription.apprenant?.codeEtablissement === filterEtablissement;
return matchesSearch && matchesStatut && matchesEtablissement;
});
// Tri
filtered.sort((a, b) => {
if (sortBy === "date") {
return new Date(b.inscription.dateInscription).getTime() - new Date(a.inscription.dateInscription).getTime();
} else if (sortBy === "nom") {
return (a.apprenant?.nom || "").localeCompare(b.apprenant?.nom || "");
}
return 0;
});
return filtered;
}, [inscriptions, searchTerm, filterStatut, filterEtablissement, sortBy]);
const getStatutBadge = (statut: string) => {
const styles = {
confirmee: "bg-green-100 text-green-800",
liste_attente: "bg-orange-100 text-orange-800",
annulee: "bg-gray-100 text-gray-800",
};
const labels = {
confirmee: "Confirmée",
liste_attente: "Liste d'attente",
annulee: "Annulée",
};
return (
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${styles[statut as keyof typeof styles]}`}>
{labels[statut as keyof typeof labels]}
</span>
);
};
const inscritsConfirmes = inscriptions?.filter(i => i.inscription.statut === "confirmee") || [];
const listeAttente = inscriptions?.filter(i => i.inscription.statut === "liste_attente") || [];
return (
<DashboardLayout>
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => setLocation("/admin/sessions")}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div className="flex-1">
<h1 className="text-3xl font-bold tracking-tight">Inscrits à la session</h1>
{session && (
<p className="text-muted-foreground mt-2">
{getFormationName(session.formationId)} - {session.nom}
</p>
)} </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 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, prénom ou email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
</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="confirmee">Confirmée</SelectItem>
<SelectItem value="liste_attente">Liste d'attente</SelectItem>
<SelectItem value="annulee">Annulée</SelectItem>
</SelectContent>
</Select>
</div>
{/* Filtre par établissement */}
<div className="space-y-2">
<Label htmlFor="filterEtablissement">Établissement</Label>
<Select value={filterEtablissement} onValueChange={setFilterEtablissement}>
<SelectTrigger id="filterEtablissement">
<SelectValue placeholder="Tous les établissements" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les établissements</SelectItem>
{uniqueEtablissements.map(code => (
<SelectItem key={code} value={code}>{code}</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 d'inscription</SelectItem>
<SelectItem value="nom">Nom</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Compteur de résultats */}
<div className="mt-4 text-sm text-muted-foreground">
{filteredAndSortedInscriptions.length} inscrit(s) trouvé(s) sur {inscriptions?.length || 0} total
</div>
</CardContent>
</Card>
{loadingSession || loadingInscriptions ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : session ? (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Places confirmées</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{inscritsConfirmes.length} / {session.capaciteMax}
</div>
<p className="text-xs text-muted-foreground mt-1">
{session.capaciteMax - inscritsConfirmes.length} places restantes
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Liste d'attente</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{listeAttente.length}</div>
<p className="text-xs text-muted-foreground mt-1">
En attente de places
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Total inscriptions</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{inscriptions?.length || 0}</div>
<p className="text-xs text-muted-foreground mt-1">
Toutes statuts confondus
</p>
</CardContent>
</Card>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleExportExcel}>
<Download className="w-4 h-4 mr-2" />
Exporter Excel
</Button>
<Button variant="outline" onClick={handleExportPDF}>
<Download className="w-4 h-4 mr-2" />
Exporter PDF
</Button>
<Button variant="outline" onClick={handleGenerateFeuillePresence}>
<Download className="w-4 h-4 mr-2" />
Feuille de présence
</Button>
<Button
variant="outline"
onClick={() => sendEmailsMutation.mutate({ sessionId, type: 'teaser' })}
disabled={sendEmailsMutation.isPending}
>
<Mail className="w-4 h-4 mr-2" />
Envoyer teaser
</Button>
<Button
variant="outline"
onClick={() => sendEmailsMutation.mutate({ sessionId, type: 'rappel' })}
disabled={sendEmailsMutation.isPending}
>
<Mail className="w-4 h-4 mr-2" />
Envoyer rappel J-7
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Liste des inscrits</CardTitle>
<CardDescription>
Gérez les inscriptions et leurs statuts
</CardDescription>
</CardHeader>
<CardContent>
{filteredAndSortedInscriptions && filteredAndSortedInscriptions.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Prénom</TableHead>
<TableHead>Email</TableHead>
<TableHead>Code établissement</TableHead>
<TableHead>Date d'inscription</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAndSortedInscriptions.map((item) => (
<TableRow key={item.inscription.id}>
<TableCell className="font-medium">{item.apprenant?.nom}</TableCell>
<TableCell>{item.apprenant?.prenom}</TableCell>
<TableCell>{item.apprenant?.email}</TableCell>
<TableCell>{item.apprenant?.codeEtablissement}</TableCell>
<TableCell className="whitespace-nowrap">
{new Date(item.inscription.dateInscription).toLocaleDateString('fr-FR')}
</TableCell>
<TableCell>{getStatutBadge(item.inscription.statut)}</TableCell>
<TableCell>
<Select
value={item.inscription.statut}
onValueChange={(value) => handleStatutChange(item.inscription.id, value)}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="confirmee">Confirmée</SelectItem>
<SelectItem value="liste_attente">Liste d'attente</SelectItem>
<SelectItem value="annulee">Annulée</SelectItem>
</SelectContent>
</Select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-muted-foreground">
Aucune inscription pour cette session.
</div>
)}
</CardContent>
</Card>
</>
) : (
<div className="text-center py-8 text-muted-foreground">
Session introuvable.
</div>
)}
</div>
</DashboardLayout>
);
}