Checkpoint: Refonte majeure : transformation du système de sessions en séquences avec support de jusqu'à 4 dates par séquence. L'inscription à une séquence inscrit automatiquement l'apprenant à toutes les dates. Mise à jour complète du backend (schéma DB, procédures tRPC, emails, exports) et du frontend (AdminSequences, AdminSequenceInscrits, Inscription, Admin). Toutes les fonctionnalités existantes (filtres, statistiques, exports PDF/Excel, emails automatiques) sont préservées et adaptées au nouveau modèle.

This commit is contained in:
Manus Sandbox
2025-11-12 09:57:58 -05:00
parent ca9b1c3d37
commit 5c9b6637c4
20 changed files with 2445 additions and 1410 deletions

View File

@@ -7,9 +7,9 @@ import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home";
import Admin from "./pages/Admin";
import AdminFormations from "./pages/AdminFormations";
import AdminSessions from "./pages/AdminSessions";
import AdminSequences from "./pages/AdminSequences";
import AdminApprenants from "./pages/AdminApprenants";
import AdminSessionInscrits from "./pages/AdminSessionInscrits";
import AdminSequenceInscrits from "./pages/AdminSequenceInscrits";
import Inscription from "./pages/Inscription";
function Router() {
@@ -19,9 +19,9 @@ function Router() {
<Route path={"/inscription/:lien"} component={Inscription} />
<Route path={"/admin"} component={Admin} />
<Route path={"/admin/formations"} component={AdminFormations} />
<Route path={"/admin/sessions"} component={AdminSessions} />
<Route path={"/admin/sequences"} component={AdminSequences} />
<Route path={"/admin/apprenants"} component={AdminApprenants} />
<Route path={"/admin/sessions/:id/inscrits"} component={AdminSessionInscrits} />
<Route path={"/admin/sequences/:id/inscrits"} component={AdminSequenceInscrits} />
<Route path={"/404"} component={NotFound} />
{/* Final fallback route */}
<Route component={NotFound} />

View File

@@ -30,7 +30,7 @@ import { Button } from "./ui/button";
const menuItems = [
{ icon: LayoutDashboard, label: "Tableau de bord", path: "/admin" },
{ icon: GraduationCap, label: "Formations", path: "/admin/formations" },
{ icon: Calendar, label: "Sessions", path: "/admin/sessions" },
{ icon: Calendar, label: "Séquences", path: "/admin/sequences" },
{ icon: Users, label: "Apprenants", path: "/admin/apprenants" },
];

View File

@@ -8,7 +8,7 @@ import { useMemo } from "react";
export default function Admin() {
const { data: formations, isLoading: loadingFormations } = trpc.formations.list.useQuery();
const { data: sessions, isLoading: loadingSessions } = trpc.sessions.list.useQuery();
const { data: sequences, isLoading: loadingSessions } = trpc.sequences.list.useQuery();
const { data: apprenants, isLoading: loadingApprenants } = trpc.apprenants.list.useQuery();
// Statistiques par fonction
@@ -34,8 +34,8 @@ export default function Admin() {
loading: loadingFormations,
},
{
title: "Sessions ouvertes",
value: sessions?.filter(s => s.statut === "ouverte").length || 0,
title: "Séquences ouvertes",
value: sequences?.filter(s => s.statut === "ouverte").length || 0,
icon: <Calendar className="w-8 h-8 text-green-500" />,
loading: loadingSessions,
},
@@ -46,8 +46,8 @@ export default function Admin() {
loading: loadingApprenants,
},
{
title: "Sessions terminées",
value: sessions?.filter(s => s.statut === "terminee").length || 0,
title: "Séquences terminées",
value: sequences?.filter(s => s.statut === "terminee").length || 0,
icon: <CheckCircle className="w-8 h-8 text-orange-500" />,
loading: loadingSessions,
},
@@ -132,7 +132,7 @@ export default function Admin() {
<CardHeader>
<CardTitle>Bienvenue dans l'espace d'administration</CardTitle>
<CardDescription>
Gérez vos formations, sessions et apprenants depuis le menu de navigation.
Gérez vos formations, sequences et apprenants depuis le menu de navigation.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -140,7 +140,7 @@ export default function Admin() {
<h3 className="font-semibold">Fonctionnalités principales :</h3>
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
<li>Créer et gérer les formations avec liens d'inscription uniques</li>
<li>Organiser les sessions avec limitation à 12 participants</li>
<li>Organiser les sequences avec limitation à 12 participants</li>
<li>Gérer les inscriptions avec blocage automatique à J-15</li>
<li>Exporter les listes d'inscrits en PDF ou Excel</li>
<li>Générer des invitations Outlook et envoyer des emails automatiques</li>

View File

@@ -0,0 +1,512 @@
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 } 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 AdminSequenceInscrits() {
const [, setLocation] = useLocation();
const [, params] = useRoute("/admin/sequences/:id/inscrits");
const sequenceId = 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 [filterFonction, setFilterFonction] = useState<string>("all");
const [sortBy, setSortBy] = useState<"date" | "nom">("date");
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 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) => {
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.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") => {
if (confirm(`Êtes-vous sûr de vouloir envoyer les emails ${type === "teaser" ? "teaser" : "de rappel J-7"} à 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 = (date: Date) => {
return new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
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>
<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={() => handleSendEmails("teaser")}
disabled={sendEmailsMutation.isPending || nbConfirmes === 0}
>
<Mail className="h-4 w-4 mr-2" />
Envoyer email teaser
</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={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>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,731 @@
import { useState, useMemo } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { trpc } from "@/lib/trpc";
import { Calendar, Edit, Eye, Plus, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { useLocation } from "wouter";
import { format } from "date-fns";
interface DateFormation {
dateDebut: string;
dateFin: string;
ordre: number;
}
export default function AdminSequences() {
const [, setLocation] = useLocation();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [editingSequence, setEditingSequence] = useState<any>(null);
const [searchTerm, setSearchTerm] = useState("");
const [filterLieu, setFilterLieu] = useState<string>("all");
const [filterStatut, setFilterStatut] = useState<string>("all");
const [sortBy, setSortBy] = useState<"date" | "lieu" | "capacite">("date");
const [formData, setFormData] = useState({
formationId: "",
nom: "",
lieu: "",
capaciteMax: "12",
dateBlocage: "",
statut: "ouverte" as "ouverte" | "bloquee" | "terminee",
dates: [
{ dateDebut: "", dateFin: "", ordre: 1 }
] as DateFormation[],
});
const utils = trpc.useUtils();
const { data: formations } = trpc.formations.list.useQuery();
const { data: sequences, isLoading } = trpc.sequences.list.useQuery();
const createMutation = trpc.sequences.create.useMutation({
onSuccess: () => {
toast.success("Séquence créée avec succès");
setIsCreateOpen(false);
resetForm();
utils.sequences.list.invalidate();
},
onError: (error) => {
toast.error(`Erreur : ${error.message}`);
},
});
const updateMutation = trpc.sequences.update.useMutation({
onSuccess: () => {
toast.success("Séquence modifiée avec succès");
setIsEditOpen(false);
setEditingSequence(null);
utils.sequences.list.invalidate();
},
onError: (error) => {
toast.error(`Erreur : ${error.message}`);
},
});
const deleteMutation = trpc.sequences.delete.useMutation({
onSuccess: () => {
toast.success("Séquence supprimée avec succès");
utils.sequences.list.invalidate();
},
onError: (error) => {
toast.error(`Erreur : ${error.message}`);
},
});
const resetForm = () => {
setFormData({
formationId: "",
nom: "",
lieu: "",
capaciteMax: "12",
dateBlocage: "",
statut: "ouverte",
dates: [{ dateDebut: "", dateFin: "", ordre: 1 }],
});
};
const handleEdit = (sequence: any) => {
setEditingSequence(sequence);
setFormData({
formationId: sequence.formationId.toString(),
nom: sequence.nom,
lieu: sequence.lieu,
capaciteMax: sequence.capaciteMax.toString(),
dateBlocage: format(new Date(sequence.dateBlocage), "yyyy-MM-dd'T'HH:mm"),
statut: sequence.statut,
dates: sequence.dates.map((d: any) => ({
dateDebut: format(new Date(d.dateDebut), "yyyy-MM-dd'T'HH:mm"),
dateFin: format(new Date(d.dateFin), "yyyy-MM-dd'T'HH:mm"),
ordre: d.ordre,
})),
});
setIsEditOpen(true);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.dates.length) {
toast.error("Veuillez ajouter au moins une date de formation");
return;
}
const data = {
formationId: parseInt(formData.formationId),
nom: formData.nom,
lieu: formData.lieu,
capaciteMax: parseInt(formData.capaciteMax),
dateBlocage: formData.dateBlocage,
statut: formData.statut,
dates: formData.dates,
};
if (editingSequence) {
updateMutation.mutate({ id: editingSequence.id, ...data });
} else {
createMutation.mutate(data);
}
};
const handleDelete = (id: number) => {
if (confirm("Êtes-vous sûr de vouloir supprimer cette séquence ?")) {
deleteMutation.mutate({ id });
}
};
const addDate = () => {
if (formData.dates.length >= 4) {
toast.error("Maximum 4 dates par séquence");
return;
}
setFormData({
...formData,
dates: [...formData.dates, { dateDebut: "", dateFin: "", ordre: formData.dates.length + 1 }],
});
};
const removeDate = (index: number) => {
if (formData.dates.length <= 1) {
toast.error("Au moins une date est requise");
return;
}
const newDates = formData.dates.filter((_, i) => i !== index);
// Réordonner les dates
newDates.forEach((d, i) => d.ordre = i + 1);
setFormData({ ...formData, dates: newDates });
};
const updateDate = (index: number, field: "dateDebut" | "dateFin", value: string) => {
const newDates = [...formData.dates];
newDates[index][field] = value;
setFormData({ ...formData, dates: newDates });
};
// Filtrage et tri
const filteredAndSortedSequences = useMemo(() => {
if (!sequences) return [];
let filtered = sequences.filter((seq) => {
const matchSearch = seq.nom.toLowerCase().includes(searchTerm.toLowerCase());
const matchLieu = filterLieu === "all" || seq.lieu === filterLieu;
const matchStatut = filterStatut === "all" || seq.statut === filterStatut;
return matchSearch && matchLieu && matchStatut;
});
filtered.sort((a, b) => {
if (sortBy === "date") {
const dateA = a.dates[0] ? new Date(a.dates[0].dateDebut).getTime() : 0;
const dateB = b.dates[0] ? new Date(b.dates[0].dateDebut).getTime() : 0;
return dateA - dateB;
} else if (sortBy === "lieu") {
return a.lieu.localeCompare(b.lieu);
} else {
return b.capaciteMax - a.capaciteMax;
}
});
return filtered;
}, [sequences, searchTerm, filterLieu, filterStatut, sortBy]);
const uniqueLieux = useMemo(() => {
if (!sequences) return [];
return Array.from(new Set(sequences.map((s) => s.lieu)));
}, [sequences]);
const getFormationName = (formationId: number) => {
return formations?.find((f) => f.id === formationId)?.nom || "N/A";
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const getStatutBadge = (statut: string) => {
const colors = {
ouverte: "bg-green-100 text-green-800",
bloquee: "bg-orange-100 text-orange-800",
terminee: "bg-gray-100 text-gray-800",
};
return colors[statut as keyof typeof colors] || colors.ouverte;
};
return (
<DashboardLayout>
<div className="container mx-auto py-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Gestion des Séquences</h1>
<p className="text-muted-foreground mt-1">
Créez et gérez les séquences de formation avec leurs dates
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button onClick={resetForm}>
<Plus className="h-4 w-4 mr-2" />
Nouvelle Séquence
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Créer une nouvelle séquence</DialogTitle>
<DialogDescription>
Remplissez les informations de la séquence et ajoutez jusqu'à 4 dates de formation
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="formationId">Formation *</Label>
<Select
value={formData.formationId}
onValueChange={(value) => setFormData({ ...formData, formationId: value })}
required
>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez une formation" />
</SelectTrigger>
<SelectContent>
{formations?.map((formation) => (
<SelectItem key={formation.id} value={formation.id.toString()}>
{formation.nom}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="nom">Nom de la séquence *</Label>
<Input
id="nom"
value={formData.nom}
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
placeholder="Ex: Séquence 1 - Février 2026"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="lieu">Lieu *</Label>
<Input
id="lieu"
value={formData.lieu}
onChange={(e) => setFormData({ ...formData, lieu: e.target.value })}
placeholder="Ex: Salle de formation A"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="capaciteMax">Capacité maximale *</Label>
<Input
id="capaciteMax"
type="number"
min="1"
max="50"
value={formData.capaciteMax}
onChange={(e) => setFormData({ ...formData, capaciteMax: e.target.value })}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="dateBlocage">Date de blocage (J-15) *</Label>
<Input
id="dateBlocage"
type="datetime-local"
value={formData.dateBlocage}
onChange={(e) => setFormData({ ...formData, dateBlocage: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="statut">Statut *</Label>
<Select
value={formData.statut}
onValueChange={(value: any) => setFormData({ ...formData, statut: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ouverte">Ouverte</SelectItem>
<SelectItem value="bloquee">Bloquée</SelectItem>
<SelectItem value="terminee">Terminée</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4 border-t pt-4">
<div className="flex items-center justify-between">
<Label className="text-base">Dates de formation ({formData.dates.length}/4)</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addDate}
disabled={formData.dates.length >= 4}
>
<Plus className="h-4 w-4 mr-2" />
Ajouter une date
</Button>
</div>
{formData.dates.map((date, index) => (
<Card key={index}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm">Date {date.ordre}</CardTitle>
{formData.dates.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeDate(index)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Début *</Label>
<Input
type="datetime-local"
value={date.dateDebut}
onChange={(e) => updateDate(index, "dateDebut", e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Fin *</Label>
<Input
type="datetime-local"
value={date.dateFin}
onChange={(e) => updateDate(index, "dateFin", e.target.value)}
required
/>
</div>
</div>
</CardContent>
</Card>
))}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={() => setIsCreateOpen(false)}>
Annuler
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? "Création..." : "Créer"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
{/* Filtres */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Filtres et recherche</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label>Recherche</Label>
<Input
placeholder="Nom de la séquence..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Lieu</Label>
<Select value={filterLieu} onValueChange={setFilterLieu}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les lieux</SelectItem>
{uniqueLieux.map((lieu) => (
<SelectItem key={lieu} value={lieu}>
{lieu}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Statut</Label>
<Select value={filterStatut} onValueChange={setFilterStatut}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les statuts</SelectItem>
<SelectItem value="ouverte">Ouverte</SelectItem>
<SelectItem value="bloquee">Bloquée</SelectItem>
<SelectItem value="terminee">Terminée</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</SelectItem>
<SelectItem value="lieu">Lieu</SelectItem>
<SelectItem value="capacite">Capacité</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<p className="text-sm text-muted-foreground mt-4">
{filteredAndSortedSequences.length} séquence(s) trouvée(s)
</p>
</CardContent>
</Card>
{/* Liste des séquences */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Liste des séquences
</CardTitle>
<CardDescription>
{sequences?.length || 0} séquence(s) au total
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Chargement...</div>
) : filteredAndSortedSequences.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Aucune séquence trouvée
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Formation</TableHead>
<TableHead>Nom</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Lieu</TableHead>
<TableHead>Capacité</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAndSortedSequences.map((sequence) => (
<TableRow key={sequence.id}>
<TableCell className="font-medium">{getFormationName(sequence.formationId)}</TableCell>
<TableCell>{sequence.nom}</TableCell>
<TableCell>
<div className="space-y-1 text-sm">
{sequence.dates.map((date: any) => (
<div key={date.id}>
<span className="font-medium">Date {date.ordre}:</span>{" "}
{formatDate(date.dateDebut)}
</div>
))}
</div>
</TableCell>
<TableCell className="max-w-xs truncate">{sequence.lieu}</TableCell>
<TableCell>{sequence.capaciteMax}</TableCell>
<TableCell>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatutBadge(sequence.statut)}`}>
{sequence.statut}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setLocation(`/admin/sequences/${sequence.id}/inscrits`)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(sequence)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(sequence.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Dialog d'édition */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Modifier la séquence</DialogTitle>
<DialogDescription>
Modifiez les informations de la séquence et ses dates
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-formationId">Formation *</Label>
<Select
value={formData.formationId}
onValueChange={(value) => setFormData({ ...formData, formationId: value })}
required
>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez une formation" />
</SelectTrigger>
<SelectContent>
{formations?.map((formation) => (
<SelectItem key={formation.id} value={formation.id.toString()}>
{formation.nom}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-nom">Nom de la séquence *</Label>
<Input
id="edit-nom"
value={formData.nom}
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-lieu">Lieu *</Label>
<Input
id="edit-lieu"
value={formData.lieu}
onChange={(e) => setFormData({ ...formData, lieu: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-capaciteMax">Capacité maximale *</Label>
<Input
id="edit-capaciteMax"
type="number"
min="1"
max="50"
value={formData.capaciteMax}
onChange={(e) => setFormData({ ...formData, capaciteMax: e.target.value })}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-dateBlocage">Date de blocage (J-15) *</Label>
<Input
id="edit-dateBlocage"
type="datetime-local"
value={formData.dateBlocage}
onChange={(e) => setFormData({ ...formData, dateBlocage: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-statut">Statut *</Label>
<Select
value={formData.statut}
onValueChange={(value: any) => setFormData({ ...formData, statut: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ouverte">Ouverte</SelectItem>
<SelectItem value="bloquee">Bloquée</SelectItem>
<SelectItem value="terminee">Terminée</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-4 border-t pt-4">
<div className="flex items-center justify-between">
<Label className="text-base">Dates de formation ({formData.dates.length}/4)</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addDate}
disabled={formData.dates.length >= 4}
>
<Plus className="h-4 w-4 mr-2" />
Ajouter une date
</Button>
</div>
{formData.dates.map((date, index) => (
<Card key={index}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm">Date {date.ordre}</CardTitle>
{formData.dates.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeDate(index)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Début *</Label>
<Input
type="datetime-local"
value={date.dateDebut}
onChange={(e) => updateDate(index, "dateDebut", e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Fin *</Label>
<Input
type="datetime-local"
value={date.dateFin}
onChange={(e) => updateDate(index, "dateFin", e.target.value)}
required
/>
</div>
</div>
</CardContent>
</Card>
))}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={() => setIsEditOpen(false)}>
Annuler
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Modification..." : "Modifier"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</DashboardLayout>
);
}

View File

@@ -1,452 +0,0 @@
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 [filterFonction, setFilterFonction] = 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;
// Filtre par fonction
const matchesFonction = filterFonction === "all" ||
inscription.apprenant?.fonction === filterFonction;
return matchesSearch && matchesStatut && matchesEtablissement && matchesFonction;
});
// 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, filterFonction, 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-5 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>
{/* Filtre par fonction */}
<div className="space-y-2">
<Label htmlFor="filterFonction">Fonction</Label>
<Select value={filterFonction} onValueChange={setFilterFonction}>
<SelectTrigger id="filterFonction">
<SelectValue placeholder="Toutes les fonctions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes les fonctions</SelectItem>
<SelectItem value="directeur">Directeur</SelectItem>
<SelectItem value="chef_service">Chef de service</SelectItem>
<SelectItem value="autre">Autre</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>
);
}

View File

@@ -1,504 +0,0 @@
import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { trpc } from "@/lib/trpc";
import { Plus, Pencil, Trash2, Users as UsersIcon, Search, SlidersHorizontal } from "lucide-react";
import { useState, useMemo } from "react";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { useLocation } from "wouter";
export default function AdminSessions() {
const [, setLocation] = useLocation();
const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [filterLieu, setFilterLieu] = useState<string>("all");
const [filterStatut, setFilterStatut] = useState<string>("all");
const [sortBy, setSortBy] = useState<"date" | "lieu" | "places">("date");
const [formData, setFormData] = useState({
formationId: "",
nom: "",
dateDebut: "",
dateFin: "",
lieu: "",
capaciteMax: "12",
dateBlocage: "",
statut: "ouverte" as "ouverte" | "bloquee" | "terminee",
});
const utils = trpc.useUtils();
const { data: sessions, isLoading } = trpc.sessions.list.useQuery();
const { data: formations } = trpc.formations.list.useQuery();
const createMutation = trpc.sessions.create.useMutation({
onSuccess: () => {
utils.sessions.list.invalidate();
toast.success("Session créée avec succès");
setOpen(false);
resetForm();
},
onError: (error) => {
toast.error("Erreur lors de la création : " + error.message);
},
});
const updateMutation = trpc.sessions.update.useMutation({
onSuccess: () => {
utils.sessions.list.invalidate();
toast.success("Session mise à jour avec succès");
setOpen(false);
resetForm();
},
onError: (error) => {
toast.error("Erreur lors de la mise à jour : " + error.message);
},
});
const deleteMutation = trpc.sessions.delete.useMutation({
onSuccess: () => {
utils.sessions.list.invalidate();
toast.success("Session supprimée avec succès");
},
onError: (error) => {
toast.error("Erreur lors de la suppression : " + error.message);
},
});
const resetForm = () => {
setFormData({
formationId: "",
nom: "",
dateDebut: "",
dateFin: "",
lieu: "",
capaciteMax: "12",
dateBlocage: "",
statut: "ouverte",
});
setEditingId(null);
};
const handleEdit = (session: any) => {
setFormData({
formationId: session.formationId.toString(),
nom: session.nom,
dateDebut: format(new Date(session.dateDebut), "yyyy-MM-dd'T'HH:mm"),
dateFin: format(new Date(session.dateFin), "yyyy-MM-dd'T'HH:mm"),
lieu: session.lieu,
capaciteMax: session.capaciteMax.toString(),
dateBlocage: format(new Date(session.dateBlocage), "yyyy-MM-dd'T'HH:mm"),
statut: session.statut,
});
setEditingId(session.id);
setOpen(true);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data = {
formationId: parseInt(formData.formationId),
nom: formData.nom,
dateDebut: formData.dateDebut,
dateFin: formData.dateFin,
lieu: formData.lieu,
capaciteMax: parseInt(formData.capaciteMax),
dateBlocage: formData.dateBlocage,
statut: formData.statut,
};
if (editingId) {
updateMutation.mutate({ id: editingId, ...data });
} else {
createMutation.mutate(data);
}
};
const handleDelete = (id: number) => {
if (confirm("Êtes-vous sûr de vouloir supprimer cette session ?")) {
deleteMutation.mutate({ id });
}
};
const getFormationName = (formationId: number) => {
return formations?.find(f => f.id === formationId)?.nom || "Formation inconnue";
};
// Extraire les lieux uniques pour le filtre
const uniqueLieux = useMemo(() => {
if (!sessions) return [];
const lieux = sessions.map(s => s.lieu);
return Array.from(new Set(lieux)).sort();
}, [sessions]);
// Filtrer et trier les sessions
const filteredAndSortedSessions = useMemo(() => {
if (!sessions) return [];
let filtered = sessions.filter(session => {
// Filtre par recherche (nom de session ou formation)
const matchesSearch = searchTerm === "" ||
session.nom.toLowerCase().includes(searchTerm.toLowerCase()) ||
getFormationName(session.formationId).toLowerCase().includes(searchTerm.toLowerCase());
// Filtre par lieu
const matchesLieu = filterLieu === "all" || session.lieu === filterLieu;
// Filtre par statut
const matchesStatut = filterStatut === "all" || session.statut === filterStatut;
return matchesSearch && matchesLieu && matchesStatut;
});
// Tri
filtered.sort((a, b) => {
if (sortBy === "date") {
return new Date(a.dateDebut).getTime() - new Date(b.dateDebut).getTime();
} else if (sortBy === "lieu") {
return a.lieu.localeCompare(b.lieu);
} else if (sortBy === "places") {
return a.capaciteMax - b.capaciteMax;
}
return 0;
});
return filtered;
}, [sessions, searchTerm, filterLieu, filterStatut, sortBy, formations]);
const formatDate = (date: Date | string) => {
return format(new Date(date), "dd/MM/yyyy HH:mm", { locale: fr });
};
const getStatutBadge = (statut: string) => {
const styles = {
ouverte: "bg-green-100 text-green-800",
bloquee: "bg-orange-100 text-orange-800",
terminee: "bg-gray-100 text-gray-800",
};
const labels = {
ouverte: "Ouverte",
bloquee: "Bloquée",
terminee: "Terminé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>
);
};
return (
<DashboardLayout>
<div className="space-y-6">
<div>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Sessions</h1>
<p className="text-muted-foreground mt-2">
Gérez les sessions de formation
</p>
</div>
<Dialog open={open} onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) resetForm();
}}>
<DialogTrigger asChild>
<Button>
<Plus className="w-4 h-4 mr-2" />
Nouvelle session
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>
{editingId ? "Modifier la session" : "Nouvelle session"}
</DialogTitle>
<DialogDescription>
Créez une session avec dates, lieu et capacité
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="formationId">Formation *</Label>
<Select
value={formData.formationId}
onValueChange={(value) => setFormData({ ...formData, formationId: value })}
required
>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez une formation" />
</SelectTrigger>
<SelectContent>
{formations?.map((formation) => (
<SelectItem key={formation.id} value={formation.id.toString()}>
{formation.nom}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="nom">Nom de la session *</Label>
<Input
id="nom"
value={formData.nom}
onChange={(e) => setFormData({ ...formData, nom: e.target.value })}
placeholder="Session 1 - Février 2026"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="dateDebut">Date de début *</Label>
<Input
id="dateDebut"
type="datetime-local"
value={formData.dateDebut}
onChange={(e) => setFormData({ ...formData, dateDebut: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="dateFin">Date de fin *</Label>
<Input
id="dateFin"
type="datetime-local"
value={formData.dateFin}
onChange={(e) => setFormData({ ...formData, dateFin: e.target.value })}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lieu">Lieu *</Label>
<Input
id="lieu"
value={formData.lieu}
onChange={(e) => setFormData({ ...formData, lieu: e.target.value })}
placeholder="Salle de formation A, Bâtiment principal"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="capaciteMax">Capacité maximale *</Label>
<Input
id="capaciteMax"
type="number"
min="1"
value={formData.capaciteMax}
onChange={(e) => setFormData({ ...formData, capaciteMax: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="dateBlocage">Date de blocage (J-15) *</Label>
<Input
id="dateBlocage"
type="datetime-local"
value={formData.dateBlocage}
onChange={(e) => setFormData({ ...formData, dateBlocage: e.target.value })}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="statut">Statut</Label>
<Select
value={formData.statut}
onValueChange={(value: any) => setFormData({ ...formData, statut: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ouverte">Ouverte</SelectItem>
<SelectItem value="bloquee">Bloquée</SelectItem>
<SelectItem value="terminee">Terminée</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Annuler
</Button>
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
{editingId ? "Mettre à jour" : "Créer"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</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 de session ou formation..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
</div>
{/* Filtre par lieu */}
<div className="space-y-2">
<Label htmlFor="filterLieu">Lieu</Label>
<Select value={filterLieu} onValueChange={setFilterLieu}>
<SelectTrigger id="filterLieu">
<SelectValue placeholder="Tous les lieux" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les lieux</SelectItem>
{uniqueLieux.map(lieu => (
<SelectItem key={lieu} value={lieu}>{lieu}</SelectItem>
))}
</SelectContent>
</Select>
</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="ouverte">Ouverte</SelectItem>
<SelectItem value="bloquee">Bloquée</SelectItem>
<SelectItem value="terminee">Terminée</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</SelectItem>
<SelectItem value="lieu">Lieu</SelectItem>
<SelectItem value="places">Capacité</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Compteur de résultats */}
<div className="mt-4 text-sm text-muted-foreground">
{filteredAndSortedSessions.length} session(s) trouvée(s)
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Liste des sessions</CardTitle>
<CardDescription>
Gérez vos sessions et consultez les inscriptions
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : filteredAndSortedSessions && filteredAndSortedSessions.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Formation</TableHead>
<TableHead>Nom</TableHead>
<TableHead>Date début</TableHead>
<TableHead>Date fin</TableHead>
<TableHead>Lieu</TableHead>
<TableHead>Capacité</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAndSortedSessions.map((session) => (
<TableRow key={session.id}>
<TableCell className="font-medium">{getFormationName(session.formationId)}</TableCell>
<TableCell>{session.nom}</TableCell>
<TableCell className="whitespace-nowrap">{formatDate(session.dateDebut)}</TableCell>
<TableCell className="whitespace-nowrap">{formatDate(session.dateFin)}</TableCell>
<TableCell className="max-w-xs truncate">{session.lieu}</TableCell>
<TableCell>{session.capaciteMax}</TableCell>
<TableCell>{getStatutBadge(session.statut)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => setLocation(`/admin/sessions/${session.id}/inscrits`)}
title="Voir les inscrits"
>
<UsersIcon className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(session)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(session.id)}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
Aucune session pour le moment. Créez-en une pour commencer.
</div>
)}
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

View File

@@ -881,7 +881,7 @@ export default function ComponentsShowcase() {
<X className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Your session has expired. Please log in again.
Your sequence has expired. Please log in again.
</AlertDescription>
</Alert>
</div>

View File

@@ -31,7 +31,7 @@ export default function Home() {
{
icon: <Calendar className="w-8 h-8 text-green-600" />,
title: "Sessions planifiées",
description: "Organisez des sessions avec dates, lieux et capacité maximale de 12 participants. Blocage automatique à J-15.",
description: "Organisez des sequences avec dates, lieux et capacité maximale de 12 participants. Blocage automatique à J-15.",
},
{
icon: <Users className="w-8 h-8 text-purple-600" />,
@@ -80,7 +80,7 @@ export default function Home() {
</h2>
<p className="text-xl text-gray-600 mb-8">
Solution complète pour gérer l'organisation interne des formations destinées à environ 150 apprenants.
Inscriptions, sessions, communications automatisées et exports simplifiés.
Inscriptions, sequences, communications automatisées et exports simplifiés.
</p>
<div className="flex gap-4 justify-center">
{isAuthenticated && user?.role === 'admin' && (
@@ -132,7 +132,7 @@ export default function Home() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p>Créez des formations et des sessions avec toutes les informations nécessaires (dates, lieu, capacité). Chaque formation dispose d'un lien unique d'inscription à partager avec les apprenants.</p>
<p>Créez des formations et des sequences avec toutes les informations nécessaires (dates, lieu, capacité). Chaque formation dispose d'un lien unique d'inscription à partager avec les apprenants.</p>
<p>Gérez les inscriptions, envoyez des emails groupés (teaser, rappels), et exportez les listes d'inscrits en Excel ou PDF pour vos besoins administratifs.</p>
</CardContent>
</Card>
@@ -145,7 +145,7 @@ export default function Home() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p>Cliquez sur le lien d'inscription fourni par la DRH, consultez les sessions disponibles avec les dates et lieux, puis inscrivez-vous en quelques clics.</p>
<p>Cliquez sur le lien d'inscription fourni par la DRH, consultez les sequences disponibles avec les dates et lieux, puis inscrivez-vous en quelques clics.</p>
<p>Recevez immédiatement un email de confirmation avec une invitation Outlook (.ics) qui bloque automatiquement votre agenda. Un rappel vous sera envoyé 7 jours avant la formation.</p>
</CardContent>
</Card>
@@ -158,8 +158,8 @@ export default function Home() {
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p>Le système applique automatiquement toutes les contraintes : capacité maximale de 12 participants par session, prévention des doubles inscriptions, et gestion de la liste d'attente.</p>
<p>Les inscriptions et désinscriptions sont bloquées automatiquement 15 jours avant le début de la session pour garantir la stabilité des groupes.</p>
<p>Le système applique automatiquement toutes les contraintes : capacité maximale de 12 participants par sequence, prévention des doubles inscriptions, et gestion de la liste d'attente.</p>
<p>Les inscriptions et désinscriptions sont bloquées automatiquement 15 jours avant le début de la sequence pour garantir la stabilité des groupes.</p>
</CardContent>
</Card>
</div>

View File

@@ -24,12 +24,12 @@ export default function Inscription() {
codeEtablissement: "",
fonction: "autre" as "directeur" | "chef_service" | "autre",
});
const [selectedSessionId, setSelectedSessionId] = useState<number | null>(null);
const [selectedSequenceId, setSelectedSequenceId] = useState<number | null>(null);
const [inscriptionSuccess, setInscriptionSuccess] = useState(false);
const [inscriptionStatut, setInscriptionStatut] = useState<string>("");
const { data: formation, isLoading: loadingFormation } = trpc.formations.getByLien.useQuery({ lien });
const { data: sessions, isLoading: loadingSessions } = trpc.sessions.listByFormation.useQuery(
const { data: sequences, isLoading: loadingSessions } = trpc.sequences.listByFormation.useQuery(
{ formationId: formation?.id || 0 },
{ enabled: !!formation?.id }
);
@@ -39,7 +39,7 @@ export default function Inscription() {
onSuccess: (data) => {
setInscriptionSuccess(true);
setInscriptionStatut(data.statut);
utils.sessions.listByFormation.invalidate({ formationId: formation?.id || 0 });
utils.sequences.listByFormation.invalidate({ formationId: formation?.id || 0 });
utils.apprenants.list.invalidate(); // Invalider la liste des apprenants
if (data.statut === "confirmee") {
toast.success("Inscription confirmée avec succès !");
@@ -57,8 +57,8 @@ export default function Inscription() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedSessionId) {
toast.error("Veuillez sélectionner une session");
if (!selectedSequenceId) {
toast.error("Veuillez sélectionner une sequence");
return;
}
@@ -81,32 +81,32 @@ export default function Inscription() {
apprenantId = newApprenant.id;
}
// Inscrire l'apprenant à la session
// Inscrire l'apprenant à la sequence
await inscriptionMutation.mutateAsync({
apprenantId,
sessionId: selectedSessionId,
sequenceId: selectedSequenceId,
});
} catch (error: any) {
console.error("Erreur lors de l'inscription:", error);
}
};
// Calculer les places disponibles pour chaque session
// Calculer les places disponibles pour chaque sequence
const sessionsAvecPlaces = useMemo(() => {
if (!sessions) return [];
if (!sequences) return [];
return sessions.map(session => {
return sequences.map(sequence => {
const now = new Date();
const dateBlocage = new Date(session.dateBlocage);
const isBloquee = session.statut === 'bloquee' || now >= dateBlocage;
const dateBlocage = new Date(sequence.dateBlocage);
const isBloquee = sequence.statut === 'bloquee' || now >= dateBlocage;
return {
...session,
...sequence,
isBloquee,
placesRestantes: session.capaciteMax,
placesRestantes: sequence.capaciteMax,
};
});
}, [sessions]);
}, [sequences]);
const formatDate = (date: Date | string) => {
return format(new Date(date), "EEEE d MMMM yyyy 'à' HH:mm", { locale: fr });
@@ -202,49 +202,55 @@ export default function Inscription() {
<CardHeader>
<CardTitle>Sessions disponibles</CardTitle>
<CardDescription>
Sélectionnez une session pour vous inscrire (capacité : 12 participants maximum)
Sélectionnez une sequence pour vous inscrire (capacité : 12 participants maximum)
</CardDescription>
</CardHeader>
<CardContent>
{sessionsAvecPlaces && sessionsAvecPlaces.length > 0 ? (
<div className="space-y-3">
{sessionsAvecPlaces.map((session) => (
{sessionsAvecPlaces.map((sequence) => (
<div
key={session.id}
key={sequence.id}
className={`border rounded-lg p-4 cursor-pointer transition-all ${
selectedSessionId === session.id
selectedSequenceId === sequence.id
? "border-blue-500 bg-blue-50"
: session.isBloquee
: sequence.isBloquee
? "border-gray-300 bg-gray-50 cursor-not-allowed opacity-60"
: "border-gray-300 hover:border-blue-300 hover:bg-blue-50/50"
}`}
onClick={() => !session.isBloquee && setSelectedSessionId(session.id)}
onClick={() => !sequence.isBloquee && setSelectedSequenceId(sequence.id)}
>
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<h3 className="font-semibold text-lg">{session.nom}</h3>
<h3 className="font-semibold text-lg">{sequence.nom}</h3>
<div className="space-y-1 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
<span>{formatDate(session.dateDebut)}</span>
<div className="space-y-0.5">
{sequence.dates.map((date: any) => (
<div key={date.id}>
<span className="font-medium">Date {date.ordre}:</span> {formatDate(date.dateDebut)}
</div>
))}
</div>
</div>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
<span>{session.lieu}</span>
<span>{sequence.lieu}</span>
</div>
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>{session.placesRestantes} places disponibles</span>
<span>{sequence.placesRestantes} places disponibles</span>
</div>
</div>
</div>
<div>
{session.isBloquee ? (
{sequence.isBloquee ? (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-gray-200 text-gray-800">
<XCircle className="w-3 h-3 mr-1" />
Fermée
</span>
) : session.placesRestantes === 0 ? (
) : sequence.placesRestantes === 0 ? (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
<AlertCircle className="w-3 h-3 mr-1" />
Complète
@@ -262,14 +268,14 @@ export default function Inscription() {
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
Aucune session disponible pour le moment.
Aucune sequence disponible pour le moment.
</div>
)}
</CardContent>
</Card>
{/* Formulaire d'inscription */}
{selectedSessionId && (
{selectedSequenceId && (
<Card>
<CardHeader>
<CardTitle>Vos informations</CardTitle>
@@ -338,7 +344,7 @@ export default function Inscription() {
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm text-amber-800">
<strong>Important :</strong> Les inscriptions seront bloquées 15 jours avant le début de la session.
<strong>Important :</strong> Les inscriptions seront bloquées 15 jours avant le début de la sequence.
Après cette date, aucune modification ne sera possible.
</p>
</div>

View File

@@ -0,0 +1,32 @@
CREATE TABLE `datesFormation` (
`id` int AUTO_INCREMENT NOT NULL,
`sequenceId` int NOT NULL,
`dateDebut` datetime NOT NULL,
`dateFin` datetime NOT NULL,
`ordre` int NOT NULL,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `datesFormation_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `sequences` (
`id` int AUTO_INCREMENT NOT NULL,
`formationId` int NOT NULL,
`nom` varchar(255) NOT NULL,
`lieu` varchar(500) NOT NULL,
`capaciteMax` int NOT NULL DEFAULT 12,
`dateBlocage` datetime NOT NULL,
`statut` enum('ouverte','bloquee','terminee') NOT NULL DEFAULT 'ouverte',
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `sequences_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
DROP TABLE `sessions`;--> statement-breakpoint
ALTER TABLE `inscriptions` DROP INDEX `inscriptions_apprenantId_sessionId_unique`;--> statement-breakpoint
ALTER TABLE `inscriptions` MODIFY COLUMN `statut` enum('confirmee','liste_attente','annulee') NOT NULL;--> statement-breakpoint
ALTER TABLE `inscriptions` ADD `sequenceId` int NOT NULL;--> statement-breakpoint
ALTER TABLE `inscriptions` DROP COLUMN `sessionId`;--> statement-breakpoint
ALTER TABLE `inscriptions` DROP COLUMN `invitationEnvoyee`;--> statement-breakpoint
ALTER TABLE `inscriptions` DROP COLUMN `emailConfirmationEnvoye`;--> statement-breakpoint
ALTER TABLE `inscriptions` DROP COLUMN `emailTeaserEnvoye`;--> statement-breakpoint
ALTER TABLE `inscriptions` DROP COLUMN `emailRappelEnvoye`;

View File

@@ -0,0 +1,485 @@
{
"version": "5",
"dialect": "mysql",
"id": "0352a8eb-52dd-4dee-814f-96378a8fce64",
"prevId": "c625e855-6929-4091-a755-d300ea81b53a",
"tables": {
"apprenants": {
"name": "apprenants",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prenom": {
"name": "prenom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"codeEtablissement": {
"name": "codeEtablissement",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fonction": {
"name": "fonction",
"type": "enum('directeur','chef_service','autre')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"apprenants_id": {
"name": "apprenants_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"apprenants_email_unique": {
"name": "apprenants_email_unique",
"columns": [
"email"
]
}
},
"checkConstraint": {}
},
"datesFormation": {
"name": "datesFormation",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"sequenceId": {
"name": "sequenceId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dateDebut": {
"name": "dateDebut",
"type": "datetime",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dateFin": {
"name": "dateFin",
"type": "datetime",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ordre": {
"name": "ordre",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"datesFormation_id": {
"name": "datesFormation_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"formations": {
"name": "formations",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lienUnique": {
"name": "lienUnique",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"actif": {
"name": "actif",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"formations_id": {
"name": "formations_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"formations_lienUnique_unique": {
"name": "formations_lienUnique_unique",
"columns": [
"lienUnique"
]
}
},
"checkConstraint": {}
},
"inscriptions": {
"name": "inscriptions",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"apprenantId": {
"name": "apprenantId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sequenceId": {
"name": "sequenceId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"statut": {
"name": "statut",
"type": "enum('confirmee','liste_attente','annulee')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dateInscription": {
"name": "dateInscription",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"inscriptions_id": {
"name": "inscriptions_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"sequences": {
"name": "sequences",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"formationId": {
"name": "formationId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lieu": {
"name": "lieu",
"type": "varchar(500)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"capaciteMax": {
"name": "capaciteMax",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 12
},
"dateBlocage": {
"name": "dateBlocage",
"type": "datetime",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"statut": {
"name": "statut",
"type": "enum('ouverte','bloquee','terminee')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'ouverte'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"sequences_id": {
"name": "sequences_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"openId": {
"name": "openId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loginMethod": {
"name": "loginMethod",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('user','admin')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_openId_unique": {
"name": "users_openId_unique",
"columns": [
"openId"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@@ -22,6 +22,13 @@
"when": 1762957612797,
"tag": "0002_fuzzy_sugar_man",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1762958719297,
"tag": "0003_steady_mystique",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,4 @@
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, boolean, datetime, unique } from "drizzle-orm/mysql-core";
import { boolean, datetime, int, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
/**
* Core user table backing auth flow.
@@ -60,56 +60,61 @@ export type Apprenant = typeof apprenants.$inferSelect;
export type InsertApprenant = typeof apprenants.$inferInsert;
/**
* Table des sessions de formation
* Table des séquences de formation
* Une séquence peut contenir jusqu'à 4 dates de formation
*/
export const sessions = mysqlTable("sessions", {
export const sequences = mysqlTable("sequences", {
id: int("id").autoincrement().primaryKey(),
formationId: int("formationId").notNull(),
nom: varchar("nom", { length: 255 }).notNull(),
/** Date de début de la session */
dateDebut: datetime("dateDebut").notNull(),
/** Date de fin de la session */
dateFin: datetime("dateFin").notNull(),
/** Lieu de la formation */
lieu: varchar("lieu", { length: 500 }).notNull(),
/** Capacité maximale (12 par défaut) */
capaciteMax: int("capaciteMax").default(12).notNull(),
/** Date de blocage des inscriptions (J-15) */
/** Date de blocage des inscriptions (J-15 avant la première date) */
dateBlocage: datetime("dateBlocage").notNull(),
/** Statut de la session */
/** Statut de la séquence */
statut: mysqlEnum("statut", ["ouverte", "bloquee", "terminee"]).default("ouverte").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type Session = typeof sessions.$inferSelect;
export type InsertSession = typeof sessions.$inferInsert;
export type Sequence = typeof sequences.$inferSelect;
export type InsertSequence = typeof sequences.$inferInsert;
/**
* Table des dates de formation pour chaque séquence
* Une séquence peut avoir jusqu'à 4 dates
*/
export const datesFormation = mysqlTable("datesFormation", {
id: int("id").autoincrement().primaryKey(),
sequenceId: int("sequenceId").notNull(),
/** Date de début de cette journée de formation */
dateDebut: datetime("dateDebut").notNull(),
/** Date de fin de cette journée de formation */
dateFin: datetime("dateFin").notNull(),
/** Ordre de la date dans la séquence (1 à 4) */
ordre: int("ordre").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type DateFormation = typeof datesFormation.$inferSelect;
export type InsertDateFormation = typeof datesFormation.$inferInsert;
/**
* Table des inscriptions
* L'inscription à une séquence inscrit automatiquement à toutes ses dates
*/
export const inscriptions = mysqlTable("inscriptions", {
id: int("id").autoincrement().primaryKey(),
apprenantId: int("apprenantId").notNull(),
sessionId: int("sessionId").notNull(),
sequenceId: int("sequenceId").notNull(),
/** Statut de l'inscription */
statut: mysqlEnum("statut", ["confirmee", "liste_attente", "annulee"]).default("confirmee").notNull(),
/** Date d'inscription */
statut: mysqlEnum("statut", ["confirmee", "liste_attente", "annulee"]).notNull(),
dateInscription: timestamp("dateInscription").defaultNow().notNull(),
/** Invitation Outlook envoyée */
invitationEnvoyee: boolean("invitationEnvoyee").default(false).notNull(),
/** Email de confirmation envoyé */
emailConfirmationEnvoye: boolean("emailConfirmationEnvoye").default(false).notNull(),
/** Email teaser envoyé */
emailTeaserEnvoye: boolean("emailTeaserEnvoye").default(false).notNull(),
/** Email rappel J-7 envoyé */
emailRappelEnvoye: boolean("emailRappelEnvoye").default(false).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
// Contrainte d'unicité pour éviter les doubles inscriptions
uniqueInscription: unique().on(table.apprenantId, table.sessionId),
}));
});
export type Inscription = typeof inscriptions.$inferSelect;
export type InsertInscription = typeof inscriptions.$inferInsert;

21
migrate-to-sequences.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
# Script pour remplacer "session" par "sequence" dans les fichiers frontend
cd /home/ubuntu/formation-manager-itinova
# Remplacements dans les fichiers TypeScript/TSX
find client/src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i \
-e 's/\bsessions\b/sequences/g' \
-e 's/\bsession\b/sequence/g' \
-e 's/\bSession\b/Sequence/g' \
-e 's/\bSESSION\b/SEQUENCE/g' \
-e 's/sessionId/sequenceId/g' \
-e 's/SessionId/SequenceId/g' \
-e 's/sessionNom/sequenceNom/g' \
-e 's/SessionNom/SequenceNom/g' \
-e 's/AdminSequences/AdminSequences/g' \
-e 's/AdminSequenceInscrits/AdminSequenceInscrits/g' \
{} \;
echo "Migration terminée !"

View File

@@ -1,16 +1,22 @@
import { eq, and, sql, lt, gt } from "drizzle-orm";
import { eq, and, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser,
users,
formations,
apprenants,
sessions,
sequences,
datesFormation,
inscriptions,
InsertFormation,
InsertApprenant,
InsertSession,
InsertInscription
InsertSequence,
InsertDateFormation,
InsertInscription,
Sequence,
DateFormation,
Apprenant,
Formation
} from "../drizzle/schema";
import { ENV } from './_core/env';
@@ -100,230 +106,256 @@ export async function getUserByOpenId(openId: string) {
return result.length > 0 ? result[0] : undefined;
}
// ===== FORMATIONS =====
// ==================== FORMATIONS ====================
export async function getAllFormations() {
export async function createFormation(data: InsertFormation) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(formations).values(data);
return result;
}
export async function getFormations() {
const db = await getDb();
if (!db) return [];
return db.select().from(formations).orderBy(formations.createdAt);
return await db.select().from(formations);
}
export async function getFormationById(id: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(formations).where(eq(formations.id, id)).limit(1);
return result[0];
return result.length > 0 ? result[0] : undefined;
}
export async function getFormationByLien(lien: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(formations).where(eq(formations.lienUnique, lien)).limit(1);
return result[0];
}
export async function createFormation(data: InsertFormation) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(formations).values(data);
return result;
return result.length > 0 ? result[0] : undefined;
}
export async function updateFormation(id: number, data: Partial<InsertFormation>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(formations).set(data).where(eq(formations.id, id));
}
export async function deleteFormation(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(formations).where(eq(formations.id, id));
}
// ===== APPRENANTS =====
// ==================== SÉQUENCES ====================
export async function getAllApprenants() {
export async function createSequence(data: InsertSequence) {
const db = await getDb();
if (!db) return [];
return db.select().from(apprenants).orderBy(apprenants.nom, apprenants.prenom);
if (!db) throw new Error("Database not available");
const result = await db.insert(sequences).values(data);
return result;
}
export async function getApprenantById(id: number) {
export async function getSequences() {
const db = await getDb();
if (!db) return [];
return await db.select().from(sequences);
}
export async function getSequenceById(id: number): Promise<Sequence | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(sequences).where(eq(sequences.id, id)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function getSequencesByFormation(formationId: number) {
const db = await getDb();
if (!db) return [];
return await db.select().from(sequences).where(eq(sequences.formationId, formationId));
}
export async function updateSequence(id: number, data: Partial<InsertSequence>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(sequences).set(data).where(eq(sequences.id, id));
}
export async function deleteSequence(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(sequences).where(eq(sequences.id, id));
}
// ==================== DATES DE FORMATION ====================
export async function createDateFormation(data: InsertDateFormation) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(datesFormation).values(data);
return result;
}
export async function getDatesBySequence(sequenceId: number): Promise<DateFormation[]> {
const db = await getDb();
if (!db) return [];
const results = await db.select()
.from(datesFormation)
.where(eq(datesFormation.sequenceId, sequenceId))
.orderBy(datesFormation.ordre);
return results;
}
export async function deleteDatesBySequence(sequenceId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(datesFormation).where(eq(datesFormation.sequenceId, sequenceId));
}
// ==================== APPRENANTS ====================
export async function createApprenant(data: InsertApprenant) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(apprenants).values(data);
return result;
}
export async function getApprenants() {
const db = await getDb();
if (!db) return [];
return await db.select().from(apprenants);
}
export async function getApprenantById(id: number): Promise<Apprenant | undefined> {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(apprenants).where(eq(apprenants.id, id)).limit(1);
return result[0];
return result.length > 0 ? result[0] : undefined;
}
export async function getApprenantByEmail(email: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(apprenants).where(eq(apprenants.email, email)).limit(1);
return result[0];
}
export async function createApprenant(data: InsertApprenant) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(apprenants).values(data);
return result;
return result.length > 0 ? result[0] : undefined;
}
export async function updateApprenant(id: number, data: Partial<InsertApprenant>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(apprenants).set(data).where(eq(apprenants.id, id));
}
export async function deleteApprenant(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(apprenants).where(eq(apprenants.id, id));
}
// ===== SESSIONS =====
// ==================== INSCRIPTIONS ====================
export async function getAllSessions() {
const db = await getDb();
if (!db) return [];
return db.select().from(sessions).orderBy(sessions.dateDebut);
}
export async function getSessionsByFormation(formationId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(sessions).where(eq(sessions.formationId, formationId)).orderBy(sessions.dateDebut);
}
export async function getSessionById(id: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(sessions).where(eq(sessions.id, id)).limit(1);
return result[0];
}
export async function createSession(data: InsertSession) {
export async function createInscription(data: InsertInscription) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(sessions).values(data);
const result = await db.insert(inscriptions).values(data);
return result;
}
export async function updateSession(id: number, data: Partial<InsertSession>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(sessions).set(data).where(eq(sessions.id, id));
}
export async function deleteSession(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(sessions).where(eq(sessions.id, id));
}
// ===== INSCRIPTIONS =====
export async function getInscriptionsBySession(sessionId: number) {
export async function getInscriptionsBySequence(sequenceId: number) {
const db = await getDb();
if (!db) return [];
const result = await db
.select({
inscription: inscriptions,
apprenant: apprenants,
})
.from(inscriptions)
.leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
.where(eq(inscriptions.sessionId, sessionId))
.orderBy(inscriptions.dateInscription);
const results = await db.select({
inscription: inscriptions,
apprenant: apprenants,
})
.from(inscriptions)
.leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
.where(eq(inscriptions.sequenceId, sequenceId));
return result;
return results;
}
export async function getInscriptionsByApprenant(apprenantId: number) {
const db = await getDb();
if (!db) return [];
const result = await db
.select({
inscription: inscriptions,
session: sessions,
formation: formations,
})
.from(inscriptions)
.leftJoin(sessions, eq(inscriptions.sessionId, sessions.id))
.leftJoin(formations, eq(sessions.formationId, formations.id))
.where(eq(inscriptions.apprenantId, apprenantId))
.orderBy(inscriptions.dateInscription);
const results = await db.select({
inscription: inscriptions,
sequence: sequences,
})
.from(inscriptions)
.leftJoin(sequences, eq(inscriptions.sequenceId, sequences.id))
.where(eq(inscriptions.apprenantId, apprenantId));
return result;
return results;
}
export async function getInscriptionByApprenantAndSession(apprenantId: number, sessionId: number) {
export async function checkExistingInscription(apprenantId: number, sequenceId: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db
.select()
const result = await db.select()
.from(inscriptions)
.where(and(
eq(inscriptions.apprenantId, apprenantId),
eq(inscriptions.sessionId, sessionId)
eq(inscriptions.sequenceId, sequenceId)
))
.limit(1);
return result[0];
return result.length > 0 ? result[0] : undefined;
}
export async function countInscriptionsConfirmees(sessionId: number) {
export async function countInscriptionsBySequence(sequenceId: number, statut?: string) {
const db = await getDb();
if (!db) return 0;
const result = await db
.select({ count: sql<number>`count(*)` })
const conditions = [eq(inscriptions.sequenceId, sequenceId)];
if (statut) {
conditions.push(eq(inscriptions.statut, statut as any));
}
const result = await db.select({ count: sql<number>`count(*)` })
.from(inscriptions)
.where(and(
eq(inscriptions.sessionId, sessionId),
eq(inscriptions.statut, "confirmee")
));
.where(and(...conditions));
return result[0]?.count || 0;
}
export async function createInscription(data: InsertInscription) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(inscriptions).values(data);
return result;
}
export async function updateInscription(id: number, data: Partial<InsertInscription>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(inscriptions).set(data).where(eq(inscriptions.id, id));
}
export async function deleteInscription(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(inscriptions).where(eq(inscriptions.id, id));
}
export async function getSessionsAvecInscriptions(formationId: number) {
const db = await getDb();
if (!db) return [];
const result = await db
.select({
session: sessions,
nbInscrits: sql<number>`count(CASE WHEN ${inscriptions.statut} = 'confirmee' THEN 1 END)`,
})
.from(sessions)
.leftJoin(inscriptions, eq(sessions.id, inscriptions.sessionId))
.where(eq(sessions.formationId, formationId))
.groupBy(sessions.id)
.orderBy(sessions.dateDebut);
return result;
}

View File

@@ -67,6 +67,7 @@ function getEmailTemplate(content: string): string {
.footer { background-color: #f3f4f6; padding: 20px; text-align: center; font-size: 12px; color: #6b7280; }
.button { display: inline-block; padding: 12px 24px; background-color: #2563eb; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0; }
.info-box { background-color: #dbeafe; border-left: 4px solid #2563eb; padding: 15px; margin: 15px 0; }
.date-item { margin: 10px 0; padding: 10px; background-color: white; border-radius: 4px; }
</style>
</head>
<body>
@@ -88,7 +89,7 @@ function getEmailTemplate(content: string): string {
}
/**
* Envoie un email de confirmation d'inscription avec invitation Outlook
* Envoie un email de confirmation d'inscription avec invitations Outlook pour toutes les dates
*/
export async function sendInscriptionConfirmation(params: {
apprenantEmail: string;
@@ -96,9 +97,8 @@ export async function sendInscriptionConfirmation(params: {
apprenantPrenom: string;
apprenantFonction: string;
formationNom: string;
sessionNom: string;
dateDebut: Date;
dateFin: Date;
sequenceNom: string;
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
lieu: string;
statut: 'confirmee' | 'liste_attente';
}): Promise<boolean> {
@@ -106,6 +106,29 @@ export async function sendInscriptionConfirmation(params: {
const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' :
params.apprenantFonction === 'chef_service' ? 'Chef de service' : 'Autre';
// Générer la liste des dates
const datesHTML = params.dates.map(date => `
<div class="date-item">
<strong>Date ${date.ordre} :</strong><br/>
Du ${date.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}<br/>
au ${date.dateFin.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
`).join('');
const content = `
<h2>Confirmation d'inscription</h2>
<p>Bonjour ${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom},</p>
@@ -118,49 +141,36 @@ export async function sendInscriptionConfirmation(params: {
<div class="info-box">
<h3>Détails de la formation</h3>
<p><strong>Formation :</strong> ${params.formationNom}</p>
<p><strong>Session :</strong> ${params.sessionNom}</p>
<p><strong>Date de début :</strong> ${params.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}</p>
<p><strong>Date de fin :</strong> ${params.dateFin.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}</p>
<p><strong>Séquence :</strong> ${params.sequenceNom}</p>
<p><strong>Lieu :</strong> ${params.lieu}</p>
<h4>Dates de formation (${params.dates.length} séance${params.dates.length > 1 ? 's' : ''}) :</h4>
${datesHTML}
</div>
${isConfirmed
? `<p>Une invitation Outlook est jointe à cet email. Merci de l'ajouter à votre calendrier pour bloquer votre agenda.</p>
<p><strong>Important :</strong> Les inscriptions seront bloquées 15 jours avant le début de la session. Après cette date, aucune modification ne sera possible.</p>`
? `<p>Des invitations Outlook sont jointes à cet email pour chaque date de formation. Merci de les ajouter à votre calendrier pour bloquer votre agenda.</p>
<p><strong>Important :</strong> Les inscriptions seront bloquées 15 jours avant le début de la séquence. Après cette date, aucune modification ne sera possible.</p>`
: '<p>Nous vous contacterons dès qu\'une place se libère.</p>'
}
<p>À bientôt pour cette formation !</p>
`;
const attachments = isConfirmed ? [{
filename: 'invitation.ics',
// Générer une invitation ICS pour chaque date
const attachments = isConfirmed ? params.dates.map((date, index) => ({
filename: `invitation_date_${date.ordre}.ics`,
content: generateFormationICS({
formationNom: params.formationNom,
sessionNom: params.sessionNom,
dateDebut: params.dateDebut,
dateFin: params.dateFin,
sessionNom: `${params.sequenceNom} - Date ${date.ordre}`,
dateDebut: date.dateDebut,
dateFin: date.dateFin,
lieu: params.lieu,
apprenantNom: params.apprenantNom,
apprenantPrenom: params.apprenantPrenom,
apprenantEmail: params.apprenantEmail,
}),
contentType: 'text/calendar',
}] : undefined;
})) : undefined;
return sendEmail({
to: params.apprenantEmail,
@@ -173,7 +183,7 @@ export async function sendInscriptionConfirmation(params: {
}
/**
* Envoie un email teaser pour une session
* Envoie un email teaser pour une séquence
*/
export async function sendTeaserEmail(params: {
apprenantEmail: string;
@@ -181,13 +191,24 @@ export async function sendTeaserEmail(params: {
apprenantNom: string;
apprenantFonction: string;
formationNom: string;
sessionNom: string;
dateDebut: Date;
sequenceNom: string;
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
}): Promise<boolean> {
const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' :
params.apprenantFonction === 'chef_service' ? 'Chef de service' : '';
const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom;
const datesHTML = params.dates.map(date => `
<div class="date-item">
<strong>Date ${date.ordre} :</strong> ${date.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
`).join('');
const content = `
<h2>Votre formation approche !</h2>
<p>Bonjour ${salutation},</p>
@@ -195,13 +216,9 @@ export async function sendTeaserEmail(params: {
<p>Nous sommes ravis de vous accueillir prochainement pour la formation <strong>${params.formationNom}</strong>.</p>
<div class="info-box">
<p><strong>Session :</strong> ${params.sessionNom}</p>
<p><strong>Date :</strong> ${params.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}</p>
<p><strong>Séquence :</strong> ${params.sequenceNom}</p>
<h4>Dates :</h4>
${datesHTML}
</div>
<p>Préparez-vous à vivre une expérience enrichissante qui vous permettra de développer vos compétences managériales.</p>
@@ -225,14 +242,28 @@ export async function sendRappelJ7Email(params: {
apprenantNom: string;
apprenantFonction: string;
formationNom: string;
sessionNom: string;
dateDebut: Date;
sequenceNom: string;
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
lieu: string;
}): Promise<boolean> {
const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' :
params.apprenantFonction === 'chef_service' ? 'Chef de service' : '';
const salutation = fonctionLabel ? `${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom}` : params.apprenantPrenom;
const datesHTML = params.dates.map(date => `
<div class="date-item">
<strong>Date ${date.ordre} :</strong><br/>
${date.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
`).join('');
const content = `
<h2>Rappel : Votre formation dans 7 jours</h2>
<p>Bonjour ${salutation},</p>
@@ -241,16 +272,10 @@ export async function sendRappelJ7Email(params: {
<div class="info-box">
<h3>Informations pratiques</h3>
<p><strong>Session :</strong> ${params.sessionNom}</p>
<p><strong>Date :</strong> ${params.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}</p>
<p><strong>Séquence :</strong> ${params.sequenceNom}</p>
<p><strong>Lieu :</strong> ${params.lieu}</p>
<h4>Dates :</h4>
${datesHTML}
</div>
<p><strong>Merci de vous présenter à l'heure indiquée.</strong></p>
@@ -268,14 +293,14 @@ export async function sendRappelJ7Email(params: {
}
/**
* Envoie des emails groupés à tous les inscrits d'une session
* Envoie des emails groupés à tous les inscrits d'une séquence
*/
export async function sendGroupEmail(params: {
recipients: Array<{ email: string; prenom: string; nom: string; fonction: string }>;
formationNom: string;
sessionNom: string;
sequenceNom: string;
type: 'teaser' | 'rappel';
dateDebut: Date;
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
lieu?: string;
}): Promise<{ sent: number; failed: number }> {
let sent = 0;
@@ -290,8 +315,8 @@ export async function sendGroupEmail(params: {
apprenantNom: recipient.nom,
apprenantFonction: recipient.fonction,
formationNom: params.formationNom,
sessionNom: params.sessionNom,
dateDebut: params.dateDebut,
sequenceNom: params.sequenceNom,
dates: params.dates,
});
} else {
await sendRappelJ7Email({
@@ -300,8 +325,8 @@ export async function sendGroupEmail(params: {
apprenantNom: recipient.nom,
apprenantFonction: recipient.fonction,
formationNom: params.formationNom,
sessionNom: params.sessionNom,
dateDebut: params.dateDebut,
sequenceNom: params.sequenceNom,
dates: params.dates,
lieu: params.lieu || '',
});
}

View File

@@ -16,23 +16,35 @@ interface InscriptionExport {
dateInscription: Date;
}
interface SessionInfo {
interface SequenceInfo {
formationNom: string;
sessionNom: string;
dateDebut: Date;
dateFin: Date;
sequenceNom: string;
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
lieu: string;
inscriptions: InscriptionExport[];
}
interface ApprenantPresence {
nom: string;
prenom: string;
codeEtablissement: string;
fonction: string;
}
interface FeuillePresenceInfo {
formationNom: string;
sequenceNom: string;
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
lieu: string;
apprenants: ApprenantPresence[];
}
/**
* Génère un fichier Excel avec la liste des inscrits
*/
export function generateExcelExport(
sessionInfo: SessionInfo,
inscriptions: InscriptionExport[]
): Buffer {
export function generateExcelExport(sequenceInfo: SequenceInfo): Buffer {
// Préparer les données
const data = inscriptions.map(i => ({
const data = sequenceInfo.inscriptions.map(i => ({
'Nom': i.nom,
'Prénom': i.prenom,
'Email': i.email,
@@ -45,31 +57,42 @@ export function generateExcelExport(
// Créer le workbook
const wb = XLSX.utils.book_new();
// Créer la feuille avec les informations de session
// Créer la feuille avec les informations de séquence
const infoData = [
['Formation', sessionInfo.formationNom],
['Session', sessionInfo.sessionNom],
['Date de début', sessionInfo.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})],
['Date de fin', sessionInfo.dateFin.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})],
['Lieu', sessionInfo.lieu],
['Nombre d\'inscrits', inscriptions.length.toString()],
['Formation', sequenceInfo.formationNom],
['Séquence', sequenceInfo.sequenceNom],
['Lieu', sequenceInfo.lieu],
['Nombre de dates', sequenceInfo.dates.length.toString()],
[],
['DATES DE FORMATION'],
];
// Ajouter les dates
sequenceInfo.dates.forEach(date => {
infoData.push([
`Date ${date.ordre}`,
`Du ${date.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})} au ${date.dateFin.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}`
]);
});
infoData.push([]);
infoData.push(['Nombre d\'inscrits', sequenceInfo.inscriptions.length.toString()]);
infoData.push([]);
// Créer la feuille principale
const ws = XLSX.utils.aoa_to_sheet(infoData);
@@ -88,38 +111,45 @@ export function generateExcelExport(
/**
* Génère un fichier PDF avec la liste des inscrits
*/
export function generatePDFExport(
sessionInfo: SessionInfo,
inscriptions: InscriptionExport[]
): Buffer {
export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer {
const doc = new jsPDF();
// Titre
doc.setFontSize(18);
doc.text('Liste des inscrits', 14, 20);
// Informations de session
// Informations de séquence
doc.setFontSize(10);
let yPos = 35;
doc.text(`Formation : ${sessionInfo.formationNom}`, 14, yPos);
doc.text(`Formation : ${sequenceInfo.formationNom}`, 14, yPos);
yPos += 6;
doc.text(`Session : ${sessionInfo.sessionNom}`, 14, yPos);
doc.text(`Séquence : ${sequenceInfo.sequenceNom}`, 14, yPos);
yPos += 6;
doc.text(`Date : ${sessionInfo.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}`, 14, yPos);
doc.text(`Lieu : ${sequenceInfo.lieu}`, 14, yPos);
yPos += 6;
doc.text(`Lieu : ${sessionInfo.lieu}`, 14, yPos);
yPos += 6;
doc.text(`Nombre d'inscrits : ${inscriptions.length}`, 14, yPos);
doc.text(`Nombre de dates : ${sequenceInfo.dates.length}`, 14, yPos);
yPos += 8;
// Dates de formation
doc.setFontSize(9);
sequenceInfo.dates.forEach(date => {
doc.text(`Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}`, 14, yPos);
yPos += 5;
});
yPos += 3;
doc.setFontSize(10);
doc.text(`Nombre d'inscrits : ${sequenceInfo.inscriptions.length}`, 14, yPos);
// Tableau des inscrits
const tableData = inscriptions.map(i => [
const tableData = sequenceInfo.inscriptions.map(i => [
i.nom,
i.prenom,
i.email,
@@ -142,37 +172,39 @@ export function generatePDFExport(
/**
* Génère une feuille de présence PDF
*/
export function generateFeuillePresence(
sessionInfo: SessionInfo,
inscriptions: InscriptionExport[]
): Buffer {
export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffer {
const doc = new jsPDF();
// Titre
doc.setFontSize(18);
doc.text('Feuille de présence', 14, 20);
// Informations de session
// Informations de séquence
doc.setFontSize(10);
let yPos = 35;
doc.text(`Formation : ${sessionInfo.formationNom}`, 14, yPos);
doc.text(`Formation : ${feuilleInfo.formationNom}`, 14, yPos);
yPos += 6;
doc.text(`Session : ${sessionInfo.sessionNom}`, 14, yPos);
doc.text(`Séquence : ${feuilleInfo.sequenceNom}`, 14, yPos);
yPos += 6;
doc.text(`Date : ${sessionInfo.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}`, 14, yPos);
yPos += 6;
doc.text(`Lieu : ${sessionInfo.lieu}`, 14, yPos);
doc.text(`Lieu : ${feuilleInfo.lieu}`, 14, yPos);
yPos += 8;
// Tableau de présence (uniquement les inscrits confirmés)
const inscritsConfirmes = inscriptions.filter(i => i.statut === 'confirmee');
const tableData = inscritsConfirmes.map(i => [
// Dates de formation
doc.setFontSize(9);
feuilleInfo.dates.forEach(date => {
doc.text(`Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}`, 14, yPos);
yPos += 5;
});
// Tableau de présence
const tableData = feuilleInfo.apprenants.map(i => [
i.nom,
i.prenom,
i.codeEtablissement,

View File

@@ -32,7 +32,7 @@ export const appRouter = router({
// ===== FORMATIONS =====
formations: router({
list: adminProcedure.query(async () => {
return db.getAllFormations();
return db.getFormations();
}),
getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
@@ -74,7 +74,7 @@ export const appRouter = router({
// ===== APPRENANTS =====
apprenants: router({
list: adminProcedure.query(async () => {
return db.getAllApprenants();
return db.getApprenants();
}),
getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
@@ -115,75 +115,133 @@ export const appRouter = router({
}),
}),
// ===== SESSIONS =====
sessions: router({
// ===== SÉQUENCES =====
sequences: router({
list: adminProcedure.query(async () => {
return db.getAllSessions();
const seqs = await db.getSequences();
// Récupérer les dates pour chaque séquence
const sequencesAvecDates = await Promise.all(
seqs.map(async (seq) => {
const dates = await db.getDatesBySequence(seq.id);
return { ...seq, dates };
})
);
return sequencesAvecDates;
}),
listByFormation: publicProcedure.input(z.object({ formationId: z.number() })).query(async ({ input }) => {
return db.getSessionsByFormation(input.formationId);
}),
listAvecInscriptions: adminProcedure.input(z.object({ formationId: z.number() })).query(async ({ input }) => {
return db.getSessionsAvecInscriptions(input.formationId);
const seqs = await db.getSequencesByFormation(input.formationId);
// Récupérer les dates et le nombre d'inscrits pour chaque séquence
const sequencesAvecDates = await Promise.all(
seqs.map(async (seq) => {
const dates = await db.getDatesBySequence(seq.id);
const nbInscrits = await db.countInscriptionsBySequence(seq.id, 'confirmee');
return { ...seq, dates, nbInscrits };
})
);
return sequencesAvecDates;
}),
getById: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
return db.getSessionById(input.id);
const seq = await db.getSequenceById(input.id);
if (!seq) return null;
const dates = await db.getDatesBySequence(input.id);
return { ...seq, dates };
}),
create: adminProcedure.input(z.object({
formationId: z.number(),
nom: z.string().min(1),
dateDebut: z.string(),
dateFin: z.string(),
lieu: z.string().min(1),
capaciteMax: z.number().default(12),
dateBlocage: z.string(),
statut: z.enum(["ouverte", "bloquee", "terminee"]).optional(),
dates: z.array(z.object({
dateDebut: z.string(),
dateFin: z.string(),
ordre: z.number(),
})).min(1).max(4),
})).mutation(async ({ input }) => {
await db.createSession({
...input,
dateDebut: new Date(input.dateDebut),
dateFin: new Date(input.dateFin),
dateBlocage: new Date(input.dateBlocage),
const { dates, ...sequenceData } = input;
// Créer la séquence
const result = await db.createSequence({
...sequenceData,
dateBlocage: new Date(sequenceData.dateBlocage),
});
return { success: true };
// Récupérer l'ID de la séquence créée
const sequenceId = Number(result[0].insertId);
// Créer les dates de formation
for (const date of dates) {
await db.createDateFormation({
sequenceId,
dateDebut: new Date(date.dateDebut),
dateFin: new Date(date.dateFin),
ordre: date.ordre,
});
}
return { success: true, sequenceId };
}),
update: adminProcedure.input(z.object({
id: z.number(),
formationId: z.number().optional(),
nom: z.string().min(1).optional(),
dateDebut: z.string().optional(),
dateFin: z.string().optional(),
lieu: z.string().min(1).optional(),
capaciteMax: z.number().optional(),
dateBlocage: z.string().optional(),
statut: z.enum(["ouverte", "bloquee", "terminee"]).optional(),
dates: z.array(z.object({
dateDebut: z.string(),
dateFin: z.string(),
ordre: z.number(),
})).min(1).max(4).optional(),
})).mutation(async ({ input }) => {
const { id, ...data } = input;
const updateData: any = { ...data };
const { id, dates, ...sequenceData } = input;
if (data.dateDebut) updateData.dateDebut = new Date(data.dateDebut);
if (data.dateFin) updateData.dateFin = new Date(data.dateFin);
if (data.dateBlocage) updateData.dateBlocage = new Date(data.dateBlocage);
// Mettre à jour la séquence
const updateData: any = { ...sequenceData };
if (sequenceData.dateBlocage) {
updateData.dateBlocage = new Date(sequenceData.dateBlocage);
}
await db.updateSequence(id, updateData);
// Si des dates sont fournies, les mettre à jour
if (dates) {
// Supprimer les anciennes dates
await db.deleteDatesBySequence(id);
// Créer les nouvelles dates
for (const date of dates) {
await db.createDateFormation({
sequenceId: id,
dateDebut: new Date(date.dateDebut),
dateFin: new Date(date.dateFin),
ordre: date.ordre,
});
}
}
await db.updateSession(id, updateData);
return { success: true };
}),
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
await db.deleteSession(input.id);
// Supprimer d'abord les dates associées
await db.deleteDatesBySequence(input.id);
// Puis supprimer la séquence
await db.deleteSequence(input.id);
return { success: true };
}),
}),
// ===== INSCRIPTIONS =====
inscriptions: router({
listBySession: adminProcedure.input(z.object({ sessionId: z.number() })).query(async ({ input }) => {
return db.getInscriptionsBySession(input.sessionId);
listBySequence: adminProcedure.input(z.object({ sequenceId: z.number() })).query(async ({ input }) => {
return db.getInscriptionsBySequence(input.sequenceId);
}),
listByApprenant: publicProcedure.input(z.object({ apprenantId: z.number() })).query(async ({ input }) => {
@@ -192,65 +250,72 @@ export const appRouter = router({
checkExisting: publicProcedure.input(z.object({
apprenantId: z.number(),
sessionId: z.number(),
sequenceId: z.number(),
})).query(async ({ input }) => {
return db.getInscriptionByApprenantAndSession(input.apprenantId, input.sessionId);
return db.checkExistingInscription(input.apprenantId, input.sequenceId);
}),
inscrire: publicProcedure.input(z.object({
apprenantId: z.number(),
sessionId: z.number(),
sequenceId: z.number(),
})).mutation(async ({ input }) => {
// Vérifier si la session existe
const session = await db.getSessionById(input.sessionId);
if (!session) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
// Vérifier si la séquence existe
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
}
// Vérifier si la session est bloquée
// Vérifier si la séquence est bloquée
const now = new Date();
if (session.statut === 'bloquee' || now >= new Date(session.dateBlocage)) {
if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Les inscriptions sont fermées pour cette session (J-15 dépassé)'
message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)'
});
}
// Vérifier si l'apprenant existe déjà une inscription
const existing = await db.getInscriptionByApprenantAndSession(input.apprenantId, input.sessionId);
// Vérifier si l'apprenant a déjà une inscription
const existing = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Vous êtes déjà inscrit à cette session'
message: 'Vous êtes déjà inscrit à cette séquence'
});
}
// Vérifier la capacité
const nbInscrits = await db.countInscriptionsConfirmees(input.sessionId);
const statut = nbInscrits >= session.capaciteMax ? 'liste_attente' : 'confirmee';
const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
const statut = nbInscrits >= sequence.capaciteMax ? 'liste_attente' : 'confirmee';
await db.createInscription({
apprenantId: input.apprenantId,
sessionId: input.sessionId,
sequenceId: input.sequenceId,
statut,
});
// Envoyer l'email de confirmation avec invitation Outlook
const inscriptionSession = await db.getSessionById(input.sessionId);
// Envoyer l'email de confirmation avec invitations Outlook pour toutes les dates
const inscriptionSequence = await db.getSequenceById(input.sequenceId);
const inscriptionApprenant = await db.getApprenantById(input.apprenantId);
const inscriptionFormation = inscriptionSession ? await db.getFormationById(inscriptionSession.formationId) : null;
const inscriptionFormation = inscriptionSequence ? await db.getFormationById(inscriptionSequence.formationId) : null;
const dates = inscriptionSequence ? await db.getDatesBySequence(inscriptionSequence.id) : [];
if (inscriptionSession && inscriptionApprenant && inscriptionFormation) {
if (inscriptionSequence && inscriptionApprenant && inscriptionFormation && dates.length > 0) {
// Utiliser la première date pour l'email principal
const premiereDate = dates[0];
await sendInscriptionConfirmation({
apprenantEmail: inscriptionApprenant.email,
apprenantNom: inscriptionApprenant.nom,
apprenantPrenom: inscriptionApprenant.prenom,
apprenantFonction: inscriptionApprenant.fonction,
formationNom: inscriptionFormation.nom,
sessionNom: inscriptionSession.nom,
dateDebut: new Date(inscriptionSession.dateDebut),
dateFin: new Date(inscriptionSession.dateFin),
lieu: inscriptionSession.lieu,
sequenceNom: inscriptionSequence.nom,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
lieu: inscriptionSequence.lieu,
statut,
});
}
@@ -260,25 +325,25 @@ export const appRouter = router({
desinscrire: publicProcedure.input(z.object({
apprenantId: z.number(),
sessionId: z.number(),
sequenceId: z.number(),
})).mutation(async ({ input }) => {
// Vérifier si la session existe
const session = await db.getSessionById(input.sessionId);
if (!session) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
// Vérifier si la séquence existe
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
}
// Vérifier si la session est bloquée
// Vérifier si la séquence est bloquée
const now = new Date();
if (session.statut === 'bloquee' || now >= new Date(session.dateBlocage)) {
if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Les désinscriptions sont fermées pour cette session (J-15 dépassé)'
message: 'Les désinscriptions sont fermées pour cette séquence (J-15 dépassé)'
});
}
// Trouver l'inscription
const inscription = await db.getInscriptionByApprenantAndSession(input.apprenantId, input.sessionId);
const inscription = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
if (!inscription) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Inscription introuvable' });
}
@@ -296,21 +361,26 @@ export const appRouter = router({
return { success: true };
}),
sendGroupEmails: adminProcedure.input(z.object({
sessionId: z.number(),
sendGroupEmail: adminProcedure.input(z.object({
sequenceId: z.number(),
type: z.enum(['teaser', 'rappel']),
})).mutation(async ({ input }) => {
const session = await db.getSessionById(input.sessionId);
if (!session) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
}
const formation = await db.getFormationById(session.formationId);
const formation = await db.getFormationById(sequence.formationId);
if (!formation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
}
const inscriptions = await db.getInscriptionsBySession(input.sessionId);
const dates = await db.getDatesBySequence(sequence.id);
if (dates.length === 0) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune date trouvée pour cette séquence' });
}
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
const recipients = inscriptions
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
.map(i => ({
@@ -323,23 +393,29 @@ export const appRouter = router({
const result = await sendGroupEmail({
recipients,
formationNom: formation.nom,
sessionNom: session.nom,
sequenceNom: sequence.nom,
type: input.type,
dateDebut: new Date(session.dateDebut),
lieu: session.lieu,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
lieu: sequence.lieu,
});
return result;
}),
exportExcel: adminProcedure.input(z.object({ sessionId: z.number() })).mutation(async ({ input }) => {
const session = await db.getSessionById(input.sessionId);
if (!session) throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
exportExcel: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
const formation = await db.getFormationById(session.formationId);
const formation = await db.getFormationById(sequence.formationId);
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
const inscriptions = await db.getInscriptionsBySession(input.sessionId);
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
const dates = await db.getDatesBySequence(input.sequenceId);
const data = inscriptions
.filter(i => i.apprenant)
.map(i => ({
@@ -349,31 +425,37 @@ export const appRouter = router({
codeEtablissement: i.apprenant!.codeEtablissement,
fonction: i.apprenant!.fonction,
statut: i.inscription.statut,
dateInscription: new Date(i.inscription.dateInscription),
dateInscription: i.inscription.dateInscription,
}));
const buffer = generateExcelExport(
{
formationNom: formation.nom,
sessionNom: session.nom,
dateDebut: new Date(session.dateDebut),
dateFin: new Date(session.dateFin),
lieu: session.lieu,
},
data
);
const buffer = await generateExcelExport({
formationNom: formation.nom,
sequenceNom: sequence.nom,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
lieu: sequence.lieu,
inscriptions: data,
});
return { data: buffer.toString('base64') };
return {
buffer: buffer.toString('base64'),
filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.xlsx`,
};
}),
exportPDF: adminProcedure.input(z.object({ sessionId: z.number() })).mutation(async ({ input }) => {
const session = await db.getSessionById(input.sessionId);
if (!session) throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
exportPDF: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
const formation = await db.getFormationById(session.formationId);
const formation = await db.getFormationById(sequence.formationId);
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
const inscriptions = await db.getInscriptionsBySession(input.sessionId);
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
const dates = await db.getDatesBySequence(input.sequenceId);
const data = inscriptions
.filter(i => i.apprenant)
.map(i => ({
@@ -383,55 +465,62 @@ export const appRouter = router({
codeEtablissement: i.apprenant!.codeEtablissement,
fonction: i.apprenant!.fonction,
statut: i.inscription.statut,
dateInscription: new Date(i.inscription.dateInscription),
dateInscription: i.inscription.dateInscription,
}));
const buffer = generatePDFExport(
{
formationNom: formation.nom,
sessionNom: session.nom,
dateDebut: new Date(session.dateDebut),
dateFin: new Date(session.dateFin),
lieu: session.lieu,
},
data
);
const buffer = await generatePDFExport({
formationNom: formation.nom,
sequenceNom: sequence.nom,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
lieu: sequence.lieu,
inscriptions: data,
});
return { data: buffer.toString('base64') };
return {
buffer: buffer.toString('base64'),
filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.pdf`,
};
}),
exportFeuillePresence: adminProcedure.input(z.object({ sessionId: z.number() })).mutation(async ({ input }) => {
const session = await db.getSessionById(input.sessionId);
if (!session) throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
exportFeuillePresence: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
const formation = await db.getFormationById(session.formationId);
const formation = await db.getFormationById(sequence.formationId);
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
const inscriptions = await db.getInscriptionsBySession(input.sessionId);
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
const dates = await db.getDatesBySequence(input.sequenceId);
const data = inscriptions
.filter(i => i.apprenant)
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
.map(i => ({
nom: i.apprenant!.nom,
prenom: i.apprenant!.prenom,
email: i.apprenant!.email,
codeEtablissement: i.apprenant!.codeEtablissement,
fonction: i.apprenant!.fonction,
statut: i.inscription.statut,
dateInscription: new Date(i.inscription.dateInscription),
}));
const buffer = generateFeuillePresence(
{
formationNom: formation.nom,
sessionNom: session.nom,
dateDebut: new Date(session.dateDebut),
dateFin: new Date(session.dateFin),
lieu: session.lieu,
},
data
);
const buffer = await generateFeuillePresence({
formationNom: formation.nom,
sequenceNom: sequence.nom,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
lieu: sequence.lieu,
apprenants: data,
});
return { data: buffer.toString('base64') };
return {
buffer: buffer.toString('base64'),
filename: `feuille_presence_${sequence.nom.replace(/\s+/g, '_')}.pdf`,
};
}),
}),
});

14
todo.md
View File

@@ -82,3 +82,17 @@
- [x] Créer des statistiques de répartition par fonction sur le tableau de bord
- [x] Ajouter des graphiques de visualisation des données
- [x] Personnaliser les templates d'emails avec la fonction de l'apprenant
## Refonte : Sessions → Séquences avec dates multiples
- [x] Renommer "Session" en "Séquence" dans le schéma de base de données
- [x] Créer une table pour gérer jusqu'à 4 dates par séquence
- [x] Mettre à jour toutes les procédures tRPC pour utiliser "séquence"
- [x] Renommer toutes les interfaces et pages (AdminSessions → AdminSequences)
- [x] Adapter le formulaire de création/édition pour gérer 4 dates
- [x] Mettre à jour la page d'inscription pour afficher toutes les dates d'une séquence
- [x] Modifier la logique d'inscription pour inscrire à toutes les dates
- [x] Mettre à jour les exports PDF/Excel avec toutes les dates
- [x] Adapter les emails pour inclure toutes les dates de la séquence
- [x] Mettre à jour les invitations Outlook pour créer 4 événements si nécessaire
- [x] Renommer partout dans l'interface utilisateur (labels, titres, descriptions)