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:
Manus Sandbox
2025-11-12 08:38:33 -05:00
parent 641200f3a4
commit 0fc35596d9
25 changed files with 4391 additions and 52 deletions

View 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>
);
}