Files
formation-manager-itinova/client/src/pages/AdminSequenceInscrits.tsx
Manus Sandbox d73db2820c Checkpoint: Implémentation de trois fonctionnalités majeures :
1. Personnalisation des templates d'emails avec variables {{lieu}} et {{formateur}} (déjà disponible)
2. Aperçu avant envoi avec données réelles d'un apprenant (modal avec iframe)
3. Email de rappel J-1 avec bouton d'envoi et d'aperçu

Les templates personnalisés supportent maintenant toutes les variables disponibles.
L'aperçu permet de vérifier le rendu final avant l'envoi groupé.
Le rappel J-1 complète le système de communication avec les apprenants.
2025-11-30 13:33:32 -05:00

660 lines
25 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, Eye } 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";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export default function AdminSequenceInscrits() {
const [, setLocation] = useLocation();
const [, params] = useRoute("/admin/sequences/:id/inscrits");
const sequenceId = params?.id ? parseInt(params.id) : 0;
const getPublicCibleBadge = (publicCible: string) => {
const colors = {
directeur: "bg-blue-100 text-blue-800",
chef_service: "bg-green-100 text-green-800",
autre: "bg-gray-100 text-gray-800",
};
return colors[publicCible as keyof typeof colors] || colors.autre;
};
const getPublicCibleLabel = (publicCible: string) => {
const labels = {
directeur: "Directeur",
chef_service: "Chef de service",
autre: "Autre",
};
return labels[publicCible as keyof typeof labels] || "Autre";
};
// États pour les filtres
const [searchTerm, setSearchTerm] = useState("");
const [filterStatut, setFilterStatut] = useState<string>("all");
const [filterEtablissement, setFilterEtablissement] = useState<string>("all");
const [filterFonction, setFilterFonction] = useState<string>("all");
const [sortBy, setSortBy] = useState<"date" | "nom">("date");
const [showPreviewModal, setShowPreviewModal] = useState(false);
const [previewType, setPreviewType] = useState<"teaser" | "rappel">("teaser");
const utils = trpc.useUtils();
const { data: sequence, isLoading: loadingSequence } = trpc.sequences.getById.useQuery({ id: sequenceId });
const { data: inscriptions, isLoading: loadingInscriptions } = trpc.inscriptions.listBySequence.useQuery({ sequenceId });
const { data: formations } = trpc.formations.list.useQuery();
const { data: emailPreview, isLoading: loadingPreview } = trpc.inscriptions.emailPreview.useQuery(
{ sequenceId, type: previewType },
{ enabled: showPreviewModal }
);
const updateStatutMutation = trpc.inscriptions.updateStatut.useMutation({
onSuccess: () => {
utils.inscriptions.listBySequence.invalidate({ sequenceId });
toast.success("Statut mis à jour avec succès");
},
onError: (error) => {
toast.error("Erreur lors de la mise à jour : " + error.message);
},
});
const sendEmailsMutation = trpc.inscriptions.sendGroupEmail.useMutation({
onSuccess: (result) => {
if (result.sent > 0) {
toast.success(result.message || `${result.sent} email(s) envoyé(s) avec succès`);
} else {
toast.warning("Aucun email envoyé. Vérifiez qu'il y a des apprenants confirmés.");
}
},
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.buffer), 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 = result.filename;
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.buffer), c => c.charCodeAt(0))], {
type: 'application/pdf'
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = result.filename;
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.buffer), c => c.charCodeAt(0))], {
type: 'application/pdf'
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = result.filename;
a.click();
window.URL.revokeObjectURL(url);
toast.success("Feuille de présence téléchargée");
},
onError: (error) => {
toast.error("Erreur lors de l'export : " + error.message);
},
});
const handleExportExcel = () => {
exportExcelMutation.mutate({ sequenceId });
};
const handleExportPDF = () => {
exportPDFMutation.mutate({ sequenceId });
};
const handleExportFeuille = () => {
exportFeuilleMutation.mutate({ sequenceId });
};
const handleSendEmails = (type: "teaser" | "rappel" | "rappel_j1") => {
const typeLabel = type === "teaser" ? "teaser" : type === "rappel" ? "de rappel J-7" : "de rappel J-1";
if (confirm(`Êtes-vous sûr de vouloir envoyer les emails ${typeLabel} à tous les inscrits confirmés ?`)) {
sendEmailsMutation.mutate({ sequenceId, type });
}
};
// Filtrage et tri
const filteredAndSortedInscriptions = useMemo(() => {
if (!inscriptions) return [];
let filtered = inscriptions.filter((item) => {
if (!item.apprenant) return false;
const matchSearch =
item.apprenant.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.apprenant.prenom.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.apprenant.email.toLowerCase().includes(searchTerm.toLowerCase());
const matchStatut = filterStatut === "all" || item.inscription.statut === filterStatut;
const matchEtablissement = filterEtablissement === "all" || item.apprenant.codeEtablissement === filterEtablissement;
const matchFonction = filterFonction === "all" || item.apprenant.fonction === filterFonction;
return matchSearch && matchStatut && matchEtablissement && matchFonction;
});
filtered.sort((a, b) => {
if (sortBy === "date") {
return new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime();
} else {
return (a.apprenant?.nom || "").localeCompare(b.apprenant?.nom || "");
}
});
return filtered;
}, [inscriptions, searchTerm, filterStatut, filterEtablissement, filterFonction, sortBy]);
const uniqueEtablissements = useMemo(() => {
if (!inscriptions) return [];
return Array.from(new Set(inscriptions.map((i) => i.apprenant?.codeEtablissement).filter(Boolean)));
}, [inscriptions]);
const getFormationName = () => {
if (!sequence || !formations) return "N/A";
return formations.find((f) => f.id === sequence.formationId)?.nom || "N/A";
};
const formatDate = (dateValue: any) => {
if (!dateValue) return "N/A";
try {
// Si c'est déjà un objet Date JavaScript
if (dateValue instanceof Date) {
const day = String(dateValue.getDate()).padStart(2, '0');
const month = String(dateValue.getMonth() + 1).padStart(2, '0');
const year = dateValue.getFullYear();
const hours = String(dateValue.getHours()).padStart(2, '0');
const minutes = String(dateValue.getMinutes()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}`;
}
// Parser la date MySQL (chaîne)
const dateStr = String(dateValue);
const match = dateStr.match(/(\d{4})-(\d{2})-(\d{2})[T ]?(\d{2}):(\d{2})/);
if (!match) {
console.warn('Format de date non reconnu:', dateValue);
return "N/A";
}
const [, year, month, day, hours, minutes] = match;
return `${day}/${month}/${year} ${hours}:${minutes}`;
} catch (error) {
console.error('Erreur de formatage de date:', error, dateValue);
return "N/A";
}
};
const getStatutBadge = (statut: string) => {
const colors = {
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 {
color: colors[statut as keyof typeof colors] || colors.confirmee,
label: labels[statut as keyof typeof labels] || statut,
};
};
const getFonctionLabel = (fonction: string) => {
const labels = {
directeur: "Directeur",
chef_service: "Chef de service",
autre: "Autre",
};
return labels[fonction as keyof typeof labels] || fonction;
};
if (loadingSequence || loadingInscriptions) {
return (
<DashboardLayout>
<div className="container mx-auto py-8 space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
</div>
</DashboardLayout>
);
}
if (!sequence) {
return (
<DashboardLayout>
<div className="container mx-auto py-8">
<Card>
<CardHeader>
<CardTitle>Séquence introuvable</CardTitle>
<CardDescription>La séquence demandée n'existe pas.</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => setLocation("/admin/sequences")}>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour aux séquences
</Button>
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}
const nbConfirmes = inscriptions?.filter((i) => i.inscription.statut === "confirmee").length || 0;
const nbListeAttente = inscriptions?.filter((i) => i.inscription.statut === "liste_attente").length || 0;
return (
<DashboardLayout>
<div className="container mx-auto py-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<Button variant="ghost" onClick={() => setLocation("/admin/sequences")} className="mb-2">
<ArrowLeft className="h-4 w-4 mr-2" />
Retour aux séquences
</Button>
<h1 className="text-3xl font-bold">Inscrits à la séquence</h1>
<p className="text-muted-foreground mt-1">
{getFormationName()} - {sequence.nom}
</p>
</div>
</div>
{/* Informations de la séquence */}
<Card>
<CardHeader>
<CardTitle>Informations de la séquence</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground">Formation</p>
<p className="text-base">{getFormationName()}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Nom de la séquence</p>
<p className="text-base">{sequence.nom}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Lieu</p>
<p className="text-base">{sequence.lieu}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Capacité</p>
<p className="text-base">
{nbConfirmes} / {sequence.capaciteMax} inscrits confirmés
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Public cible</p>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getPublicCibleBadge(sequence.publicCible)}`}>
{getPublicCibleLabel(sequence.publicCible)}
</span>
</div>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Dates de formation</p>
<div className="space-y-2">
{sequence.dates.map((date: any) => (
<div key={date.id} className="flex items-center gap-2 text-sm">
<span className="font-medium">Date {date.ordre}:</span>
<span>
Du {formatDate(date.dateDebut)} au {formatDate(date.dateFin)}
</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* Actions */}
<Card>
<CardHeader>
<CardTitle>Actions</CardTitle>
<CardDescription>Envoi d'emails et exports</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => {
setPreviewType("teaser");
setShowPreviewModal(true);
}}
disabled={nbConfirmes === 0}
>
<Eye className="h-4 w-4 mr-2" />
Aperçu teaser
</Button>
<Button
variant="outline"
onClick={() => handleSendEmails("teaser")}
disabled={sendEmailsMutation.isPending || nbConfirmes === 0}
>
<Mail className="h-4 w-4 mr-2" />
Envoyer email teaser
</Button>
<Button
variant="outline"
onClick={() => {
setPreviewType("rappel");
setShowPreviewModal(true);
}}
disabled={nbConfirmes === 0}
>
<Eye className="h-4 w-4 mr-2" />
Aperçu rappel J-7
</Button>
<Button
variant="outline"
onClick={() => handleSendEmails("rappel")}
disabled={sendEmailsMutation.isPending || nbConfirmes === 0}
>
<Mail className="h-4 w-4 mr-2" />
Envoyer rappel J-7
</Button>
<Button
variant="outline"
onClick={() => {
setPreviewType("rappel_j1");
setShowPreviewModal(true);
}}
disabled={nbConfirmes === 0}
>
<Eye className="h-4 w-4 mr-2" />
Aperçu rappel J-1
</Button>
<Button
variant="outline"
onClick={() => handleSendEmails("rappel_j1")}
disabled={sendEmailsMutation.isPending || nbConfirmes === 0}
>
<Mail className="h-4 w-4 mr-2" />
Envoyer rappel J-1
</Button>
<Button
variant="outline"
onClick={handleExportExcel}
disabled={exportExcelMutation.isPending}
>
<Download className="h-4 w-4 mr-2" />
Export Excel
</Button>
<Button
variant="outline"
onClick={handleExportPDF}
disabled={exportPDFMutation.isPending}
>
<Download className="h-4 w-4 mr-2" />
Export PDF
</Button>
<Button
variant="outline"
onClick={handleExportFeuille}
disabled={exportFeuilleMutation.isPending}
>
<Download className="h-4 w-4 mr-2" />
Feuille de présence
</Button>
</div>
</CardContent>
</Card>
{/* Filtres */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
Filtres et recherche
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="space-y-2">
<Label>Recherche</Label>
<Input
placeholder="Nom, prénom, email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Statut</Label>
<Select value={filterStatut} onValueChange={setFilterStatut}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous</SelectItem>
<SelectItem value="confirmee">Confirmée</SelectItem>
<SelectItem value="liste_attente">Liste d'attente</SelectItem>
<SelectItem value="annulee">Annulée</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Établissement</Label>
<Select value={filterEtablissement} onValueChange={setFilterEtablissement}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous</SelectItem>
{uniqueEtablissements.map((etab) => (
<SelectItem key={etab} value={etab as string}>
{etab}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Fonction</Label>
<Select value={filterFonction} onValueChange={setFilterFonction}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes</SelectItem>
<SelectItem value="directeur">Directeur</SelectItem>
<SelectItem value="chef_service">Chef de service</SelectItem>
<SelectItem value="autre">Autre</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Trier par</Label>
<Select value={sortBy} onValueChange={(v: any) => setSortBy(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="date">Date d'inscription</SelectItem>
<SelectItem value="nom">Nom</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-sm text-muted-foreground mt-4">
{filteredAndSortedInscriptions.length} inscription(s) trouvée(s) sur {inscriptions?.length || 0} au total
</p>
</CardContent>
</Card>
{/* Liste des inscrits */}
<Card>
<CardHeader>
<CardTitle>Liste des inscrits</CardTitle>
<CardDescription>
{nbConfirmes} confirmé(s), {nbListeAttente} en liste d'attente
</CardDescription>
</CardHeader>
<CardContent>
{filteredAndSortedInscriptions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Aucune inscription trouvée
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Prénom</TableHead>
<TableHead>Email</TableHead>
<TableHead>Code étab.</TableHead>
<TableHead>Fonction</TableHead>
<TableHead>Date inscription</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAndSortedInscriptions.map((item) => {
if (!item.apprenant) return null;
const statutInfo = getStatutBadge(item.inscription.statut);
return (
<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>{getFonctionLabel(item.apprenant.fonction)}</TableCell>
<TableCell className="whitespace-nowrap">
{formatDate(item.inscription.dateInscription)}
</TableCell>
<TableCell>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statutInfo.color}`}>
{statutInfo.label}
</span>
</TableCell>
<TableCell className="text-right">
<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>
)}
</CardContent>
</Card>
</div>
{/* Modal d'aperçu d'email */}
<Dialog open={showPreviewModal} onOpenChange={setShowPreviewModal}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Aperçu de l'email {previewType === "teaser" ? "teaser" : previewType === "rappel" ? "de rappel J-7" : "de rappel J-1"}
</DialogTitle>
<DialogDescription>
{loadingPreview ? (
"Chargement de l'aperçu..."
) : emailPreview ? (
`Destinataire : ${emailPreview.recipient.prenom} ${emailPreview.recipient.nom} (${emailPreview.recipient.email})`
) : (
"Erreur lors du chargement de l'aperçu"
)}
</DialogDescription>
</DialogHeader>
{loadingPreview ? (
<div className="flex justify-center p-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
) : emailPreview ? (
<div className="space-y-4">
<div className="bg-muted p-3 rounded-md">
<p className="text-sm font-medium">Sujet :</p>
<p className="text-sm">{emailPreview.subject}</p>
</div>
<div className="border rounded-md p-4 bg-white">
<iframe
srcDoc={emailPreview.html}
className="w-full h-[500px] border-0"
title="Aperçu de l'email"
/>
</div>
</div>
) : (
<div className="text-center p-8 text-muted-foreground">
Impossible de charger l'aperçu. Vérifiez qu'il y a des apprenants confirmés.
</div>
)}
</DialogContent>
</Dialog>
</DashboardLayout>
);
}