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.
660 lines
25 KiB
TypeScript
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>
|
|
);
|
|
}
|