Checkpoint: Application complète de gestion des formations Manager Itinova avec toutes les fonctionnalités : gestion des formations/sessions/apprenants, système d'inscription avec validations, génération d'invitations Outlook, emails automatiques, exports PDF/Excel, et documentation complète.
This commit is contained in:
301
client/src/pages/AdminSessionInscrits.tsx
Normal file
301
client/src/pages/AdminSessionInscrits.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
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 } from "lucide-react";
|
||||
import { useLocation, useRoute } from "wouter";
|
||||
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;
|
||||
|
||||
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";
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{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>
|
||||
{inscriptions && inscriptions.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>
|
||||
{inscriptions.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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user