432 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|