From a7845f5b0679935fe27fde4a7b3f7e06941394d9 Mon Sep 17 00:00:00 2001 From: manus-admin Date: Fri, 2 Oct 2026 11:11:50 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20am=C3=A9liorer=20inscription=20et=20exp?= =?UTF-8?q?ort=20apprenants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/AdminApprenantDetail.tsx | 4 +- client/src/pages/AdminApprenants.tsx | 44 +++++++- client/src/pages/AdminCalendrier.tsx | 2 +- client/src/pages/AdminRapportPublicCible.tsx | 2 + client/src/pages/AdminSequenceInscrits.tsx | 26 ++++- client/src/pages/AdminSequences.tsx | 8 +- client/src/pages/Inscription.tsx | 67 ++++++----- .../__tests__/registrationExperience.test.ts | 104 ++++++++++++++++++ server/apprenantExportService.ts | 35 ++++++ server/db.ts | 51 +++++++++ server/exportService.ts | 10 +- server/routers.ts | 12 ++ shared/registration.ts | 55 +++++++++ 13 files changed, 370 insertions(+), 50 deletions(-) create mode 100644 server/__tests__/registrationExperience.test.ts create mode 100644 server/apprenantExportService.ts create mode 100644 shared/registration.ts diff --git a/client/src/pages/AdminApprenantDetail.tsx b/client/src/pages/AdminApprenantDetail.tsx index 0759783..b7640d2 100644 --- a/client/src/pages/AdminApprenantDetail.tsx +++ b/client/src/pages/AdminApprenantDetail.tsx @@ -69,7 +69,7 @@ export default function AdminApprenantDetail() { case 'chef_service': return 'Chefs de service'; case 'tous': - return 'Tous'; + return 'Tous managers'; default: return 'Autre'; } @@ -120,7 +120,7 @@ export default function AdminApprenantDetail() {
Fonction
-
{getFonctionLabel(apprenant.fonction)}
+
{getFonctionLabel(apprenant.fonction ?? "")}
diff --git a/client/src/pages/AdminApprenants.tsx b/client/src/pages/AdminApprenants.tsx index 62e4d19..5c08d2a 100644 --- a/client/src/pages/AdminApprenants.tsx +++ b/client/src/pages/AdminApprenants.tsx @@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label"; 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 { Plus, Pencil, Trash2, Search, SlidersHorizontal, Eye } from "lucide-react"; +import { Plus, Pencil, Trash2, Search, SlidersHorizontal, Eye, Download } from "lucide-react"; import { useState, useMemo } from "react"; import { useLocation } from "wouter"; import { toast } from "sonner"; @@ -67,6 +67,27 @@ export default function AdminApprenants() { }, }); + const exportExcelMutation = trpc.apprenants.exportExcel.useMutation({ + onSuccess: (result) => { + const blob = new Blob( + [Uint8Array.from(atob(result.buffer), (character) => character.charCodeAt(0))], + { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, + ); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = result.filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + toast.success("Export Excel des apprenants téléchargé"); + }, + onError: (error) => { + toast.error("Erreur lors de l'export : " + error.message); + }, + }); + const resetForm = () => { setFormData({ nom: "", prenom: "", email: "", codeEtablissement: "", fonction: "autre" }); setEditingId(null); @@ -99,6 +120,10 @@ export default function AdminApprenants() { } }; + const handleExportExcel = () => { + exportExcelMutation.mutate(); + }; + // Filtrer les apprenants const filteredApprenants = useMemo(() => { if (!apprenants) return []; @@ -108,7 +133,7 @@ export default function AdminApprenants() { apprenant.nom.toLowerCase().includes(searchTerm.toLowerCase()) || apprenant.prenom.toLowerCase().includes(searchTerm.toLowerCase()) || apprenant.email.toLowerCase().includes(searchTerm.toLowerCase()) || - apprenant.codeEtablissement.toLowerCase().includes(searchTerm.toLowerCase()); + (apprenant.codeEtablissement ?? "").toLowerCase().includes(searchTerm.toLowerCase()); const matchesFonction = filterFonction === "all" || apprenant.fonction === filterFonction; @@ -127,7 +152,16 @@ export default function AdminApprenants() {

{isAdmin && ( - { +
+ + { setOpen(isOpen); if (!isOpen) resetForm(); }}> @@ -178,6 +212,7 @@ export default function AdminApprenants() { value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} placeholder="jean.dupont@example.com" + autoComplete="off" required />
@@ -215,7 +250,8 @@ export default function AdminApprenants() { -
+ + )} diff --git a/client/src/pages/AdminCalendrier.tsx b/client/src/pages/AdminCalendrier.tsx index a08e834..7e6474b 100644 --- a/client/src/pages/AdminCalendrier.tsx +++ b/client/src/pages/AdminCalendrier.tsx @@ -154,7 +154,7 @@ export default function AdminCalendrier() { const labels: Record = { directeurs: "Directeurs", chefs_service: "Chefs de service", - tous: "Tous", + tous: "Tous managers", autre: "Autre", }; return labels[publicCible] || publicCible; diff --git a/client/src/pages/AdminRapportPublicCible.tsx b/client/src/pages/AdminRapportPublicCible.tsx index 7cbf2d9..dbfe561 100644 --- a/client/src/pages/AdminRapportPublicCible.tsx +++ b/client/src/pages/AdminRapportPublicCible.tsx @@ -68,6 +68,7 @@ export default function AdminRapportPublicCible() { const labels = { directeur: "Directeur", chef_service: "Chef de service", + tous: "Tous managers", autre: "Autre", }; return labels[publicCible as keyof typeof labels] || "Autre"; @@ -77,6 +78,7 @@ export default function AdminRapportPublicCible() { const colors = { directeur: "bg-blue-100 text-blue-800", chef_service: "bg-green-100 text-green-800", + tous: "bg-purple-100 text-purple-800", autre: "bg-gray-100 text-gray-800", }; return colors[publicCible as keyof typeof colors] || colors.autre; diff --git a/client/src/pages/AdminSequenceInscrits.tsx b/client/src/pages/AdminSequenceInscrits.tsx index bc73daa..a92e174 100644 --- a/client/src/pages/AdminSequenceInscrits.tsx +++ b/client/src/pages/AdminSequenceInscrits.tsx @@ -20,6 +20,18 @@ import { DialogTitle, } from "@/components/ui/dialog"; +type EmailActionType = "teaser" | "rappel1" | "rappel2" | "rappel3" | "rappel4" | "rappel5" | "rappel6"; + +const EMAIL_ACTION_LABELS: Record = { + teaser: "teaser", + rappel1: "de rappel 1", + rappel2: "de rappel 2", + rappel3: "de rappel 3", + rappel4: "de rappel 4", + rappel5: "de rappel 5", + rappel6: "de rappel 6", +}; + export default function AdminSequenceInscrits() { const [, setLocation] = useLocation(); const [, params] = useRoute("/admin/sequences/:id/inscrits"); @@ -29,6 +41,7 @@ export default function AdminSequenceInscrits() { const colors = { directeur: "bg-blue-100 text-blue-800", chef_service: "bg-green-100 text-green-800", + tous: "bg-purple-100 text-purple-800", autre: "bg-gray-100 text-gray-800", }; return colors[publicCible as keyof typeof colors] || colors.autre; @@ -38,6 +51,7 @@ export default function AdminSequenceInscrits() { const labels = { directeur: "Directeur", chef_service: "Chef de service", + tous: "Tous managers", autre: "Autre", }; return labels[publicCible as keyof typeof labels] || "Autre"; @@ -52,7 +66,7 @@ export default function AdminSequenceInscrits() { const [isAddInscritOpen, setIsAddInscritOpen] = useState(false); const [selectedApprenantId, setSelectedApprenantId] = useState(null); const [showPreviewModal, setShowPreviewModal] = useState(false); - const [previewType, setPreviewType] = useState<"teaser" | "rappel" | "rappel_j1">("teaser"); + const [previewType, setPreviewType] = useState("teaser"); const utils = trpc.useUtils(); const { data: sequence, isLoading: loadingSequence } = trpc.sequences.getById.useQuery({ id: sequenceId }); @@ -168,8 +182,8 @@ export default function AdminSequenceInscrits() { exportFeuilleMutation.mutate({ sequenceId }); }; - const handleSendEmails = (type: "teaser" | "rappel" | "rappel_j1") => { - const typeLabel = type === "teaser" ? "teaser" : type === "rappel" ? "de rappel J-7" : "de rappel J-1"; + const handleSendEmails = (type: EmailActionType) => { + const typeLabel = EMAIL_ACTION_LABELS[type]; if (confirm(`Êtes-vous sûr de vouloir envoyer les emails ${typeLabel} à tous les inscrits confirmés ?`)) { sendEmailsMutation.mutate({ sequenceId, type }); } @@ -569,7 +583,7 @@ export default function AdminSequenceInscrits() { {item.apprenant.prenom} {item.apprenant.email} {item.apprenant.codeEtablissement} - {getFonctionLabel(item.apprenant.fonction)} + {getFonctionLabel(item.apprenant.fonction ?? "")} {formatDate(item.inscription.dateInscription)} @@ -617,7 +631,7 @@ export default function AdminSequenceInscrits() { sequenceId={sequenceId} onSuccess={() => { setIsAddInscritOpen(false); - utils.inscriptions.getBySequence.invalidate({ sequenceId }); + utils.inscriptions.listBySequence.invalidate({ sequenceId }); toast.success("Apprenant inscrit avec succès"); }} onCancel={() => setIsAddInscritOpen(false)} @@ -630,7 +644,7 @@ export default function AdminSequenceInscrits() { - Aperçu de l'email {previewType === "teaser" ? "teaser" : previewType === "rappel" ? "de rappel J-7" : "de rappel J-1"} + Aperçu de l'email {EMAIL_ACTION_LABELS[previewType]} {loadingPreview ? ( diff --git a/client/src/pages/AdminSequences.tsx b/client/src/pages/AdminSequences.tsx index 8d558e8..0b90aa4 100644 --- a/client/src/pages/AdminSequences.tsx +++ b/client/src/pages/AdminSequences.tsx @@ -249,7 +249,7 @@ export default function AdminSequences() { capaciteMax: parseInt(formData.capaciteMax), dateBlocage: convertLocalToUTC(formData.dateBlocage), statut: formData.statut, - formateurId: formData.formateur ? parseInt(formData.formateur) : null, + formateurId: formData.formateur || null, dates: formData.dates.map(d => ({ ...d, dateDebut: convertLocalToUTC(d.dateDebut), @@ -429,7 +429,7 @@ export default function AdminSequences() { const labels = { directeur: "Directeur", chef_service: "Chef de service", - tous: "Tous", + tous: "Tous managers", autre: "Autre", }; return labels[publicCible as keyof typeof labels] || "Autre"; @@ -535,7 +535,7 @@ export default function AdminSequences() { Directeur Chef de service - Tous + Tous managers Autre @@ -1029,7 +1029,7 @@ export default function AdminSequences() { Directeur Chef de service - Tous + Tous managers Autre diff --git a/client/src/pages/Inscription.tsx b/client/src/pages/Inscription.tsx index bbb2db9..11f0ec8 100644 --- a/client/src/pages/Inscription.tsx +++ b/client/src/pages/Inscription.tsx @@ -12,6 +12,7 @@ import { fr } from "date-fns/locale"; import { CheckCircle, XCircle, AlertCircle, Calendar, MapPin, Users } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { APP_LOGO, APP_TITLE } from "@/const"; +import { sortSequencesForRegistration } from "@shared/registration"; export default function Inscription() { const [, params] = useRoute("/inscription/:lien"); @@ -70,10 +71,11 @@ export default function Inscription() { "chef_service": "chef_service", "autre": "autre" }; - - if (publicCibleMap[selectedSequence.publicCible] !== formData.fonction) { + + const cibleFonction = publicCibleMap[selectedSequence.publicCible]; + if (cibleFonction && cibleFonction !== formData.fonction) { const publicCibleLabel = selectedSequence.publicCible === "directeur" ? "Directeurs" : - selectedSequence.publicCible === "chef_service" ? "Chefs de service" : "Autre"; + selectedSequence.publicCible === "chef_service" ? "Chefs de service" : "Tous managers"; const fonctionLabel = formData.fonction === "directeur" ? "Directeur" : formData.fonction === "chef_service" ? "Chef de service" : "Autre"; @@ -116,24 +118,13 @@ export default function Inscription() { } }; - // Calculer les places disponibles pour chaque sequence + // Les sessions disponibles sont affichées en premier, puis les listes d'attente et les sessions fermées. const sessionsAvecPlaces = useMemo(() => { - if (!sequences) return []; - - return sequences.map(sequence => { - const now = new Date(); - const dateBlocage = new Date(sequence.dateBlocage); - const isBloquee = sequence.statut === 'bloquee' || now >= dateBlocage; - const placesRestantes = Math.max(0, sequence.capaciteMax - (sequence.nbInscrits || 0)); - - return { - ...sequence, - isBloquee, - placesRestantes, - }; - }); + return sortSequencesForRegistration(sequences ?? []); }, [sequences]); + const selectedSequence = sessionsAvecPlaces.find((sequence) => sequence.id === selectedSequenceId); + const formatDate = (dateValue: Date | string) => { if (!dateValue) return "N/A"; @@ -259,9 +250,9 @@ export default function Inscription() { {/* Sessions disponibles */} - Sessions disponibles + 1. Choisissez votre session - Sélectionnez une sequence pour vous inscrire (capacité : 12 participants maximum) + Les sessions avec des places disponibles sont affichées en premier. @@ -270,14 +261,13 @@ export default function Inscription() { {sessionsAvecPlaces.map((sequence) => (
!sequence.isBloquee && setSelectedSequenceId(sequence.id)} >
@@ -299,7 +289,7 @@ export default function Inscription() {
- Public cible : {sequence.publicCible === "directeur" ? "Directeurs" : sequence.publicCible === "chef_service" ? "Chefs de service" : "Autre"} + Public cible : {sequence.publicCible === "directeur" ? "Directeurs" : sequence.publicCible === "chef_service" ? "Chefs de service" : sequence.publicCible === "tous" ? "Tous managers" : "Autre"}
@@ -326,6 +316,24 @@ export default function Inscription() { )}
+
))} @@ -341,13 +349,16 @@ export default function Inscription() { {selectedSequenceId && ( - Vos informations + 2. Vos informations Complétez le formulaire pour finaliser votre inscription
+
+ Session sélectionnée : {selectedSequence?.nom} +
@@ -414,12 +425,12 @@ export default function Inscription() { diff --git a/server/__tests__/registrationExperience.test.ts b/server/__tests__/registrationExperience.test.ts new file mode 100644 index 0000000..62c1015 --- /dev/null +++ b/server/__tests__/registrationExperience.test.ts @@ -0,0 +1,104 @@ +import { readFileSync } from "node:fs"; +import * as XLSX from "xlsx"; +import { describe, expect, it } from "vitest"; +import { generateApprenantsExcelExport } from "../apprenantExportService"; +import { sortSequencesForRegistration } from "../../shared/registration"; + +const inscriptionSource = readFileSync( + new URL("../../client/src/pages/Inscription.tsx", import.meta.url), + "utf8", +); +const sequencesSource = readFileSync( + new URL("../../client/src/pages/AdminSequences.tsx", import.meta.url), + "utf8", +); + +describe("parcours d'inscription", () => { + it("place les sessions avec places disponibles avant les sessions complètes ou fermées", () => { + const now = new Date("2026-10-01T10:00:00.000Z"); + const sorted = sortSequencesForRegistration( + [ + { + id: 1, + nom: "Session fermée", + statut: "bloquee", + dateBlocage: null, + capaciteMax: 12, + nbInscrits: 0, + dates: [{ dateDebut: "2026-10-04T09:00:00.000Z" }], + }, + { + id: 2, + nom: "Session complète", + statut: "ouverte", + dateBlocage: null, + capaciteMax: 12, + nbInscrits: 12, + dates: [{ dateDebut: "2026-10-03T09:00:00.000Z" }], + }, + { + id: 3, + nom: "Session disponible tardive", + statut: "ouverte", + dateBlocage: null, + capaciteMax: 12, + nbInscrits: 3, + dates: [{ dateDebut: "2026-10-05T09:00:00.000Z" }], + }, + { + id: 4, + nom: "Session disponible prioritaire", + statut: "ouverte", + dateBlocage: null, + capaciteMax: 12, + nbInscrits: 2, + dates: [{ dateDebut: "2026-10-02T09:00:00.000Z" }], + }, + ], + now, + ); + + expect(sorted.map((sequence) => sequence.id)).toEqual([4, 3, 2, 1]); + expect(sorted[0]).toMatchObject({ isBloquee: false, placesRestantes: 10 }); + expect(sorted[3]).toMatchObject({ isBloquee: true }); + }); + + it("présente un bouton explicite de sélection de session et le libellé Tous managers", () => { + expect(inscriptionSource).toContain("Choisir cette session"); + expect(inscriptionSource).toContain("Session sélectionnée"); + expect(inscriptionSource).toContain("Tous managers"); + expect(sequencesSource).toContain("Tous managers"); + }); +}); + +describe("export Excel des apprenants", () => { + it("génère les colonnes nom, prénom et formation suivie", () => { + const buffer = generateApprenantsExcelExport([ + { nom: "Durand", prenom: "Alice", formationSuivie: "Manager de proximité" }, + { nom: "Martin", prenom: "Bruno", formationSuivie: "Gestion de projet | Communication" }, + ]); + + const workbook = XLSX.read(buffer, { type: "buffer" }); + expect(workbook.SheetNames).toEqual(["Apprenants"]); + + const rows = XLSX.utils.sheet_to_json(workbook.Sheets.Apprenants, { + header: 1, + raw: false, + }); + expect(rows).toEqual([ + ["Nom", "Prénom", "Formation suivie"], + ["Durand", "Alice", "Manager de proximité"], + ["Martin", "Bruno", "Gestion de projet | Communication"], + ]); + }); + + it("neutralise les valeurs interprétables comme formules Excel", () => { + const buffer = generateApprenantsExcelExport([ + { nom: "=HYPERLINK(\"https://example.org\")", prenom: "Alice", formationSuivie: "Formation test" }, + ]); + + const workbook = XLSX.read(buffer, { type: "buffer" }); + const sheet = workbook.Sheets.Apprenants; + expect(sheet.A2?.v).toBe("'=HYPERLINK(\"https://example.org\")"); + }); +}); diff --git a/server/apprenantExportService.ts b/server/apprenantExportService.ts new file mode 100644 index 0000000..c0821ea --- /dev/null +++ b/server/apprenantExportService.ts @@ -0,0 +1,35 @@ +import * as XLSX from "xlsx"; + +export type ApprenantExcelExport = { + nom: string; + prenom: string; + formationSuivie: string; +}; + +/** + * Empêche qu'une valeur saisie par un utilisateur soit interprétée comme une formule + * lorsqu'elle est ouverte dans Excel ou LibreOffice. + */ +function spreadsheetSafeText(value: string): string { + return /^[=+\-@]/.test(value) ? `'${value}` : value; +} + +/** Génère la liste des apprenants et des formations auxquelles ils sont inscrits. */ +export function generateApprenantsExcelExport(apprenants: readonly ApprenantExcelExport[]): Buffer { + const rows = [ + ["Nom", "Prénom", "Formation suivie"], + ...apprenants.map((apprenant) => [ + spreadsheetSafeText(apprenant.nom), + spreadsheetSafeText(apprenant.prenom), + spreadsheetSafeText(apprenant.formationSuivie), + ]), + ]; + + const worksheet = XLSX.utils.aoa_to_sheet(rows); + worksheet["!cols"] = [{ wch: 24 }, { wch: 24 }, { wch: 48 }]; + + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, "Apprenants"); + + return Buffer.from(XLSX.write(workbook, { type: "buffer", bookType: "xlsx" })); +} diff --git a/server/db.ts b/server/db.ts index edf8800..93139e5 100644 --- a/server/db.ts +++ b/server/db.ts @@ -381,6 +381,57 @@ export async function getApprenants() { return apprenantsWithInscriptions; } +/** + * Retourne une ligne par apprenant avec les formations non annulées auxquelles il est inscrit. + * La requête joint les tables en une fois afin d'éviter une requête par apprenant à l'export. + */ +export async function getApprenantsWithFormationNames() { + const db = await getDb(); + if (!db) return []; + + const rows = await db + .select({ + id: apprenants.id, + nom: apprenants.nom, + prenom: apprenants.prenom, + formationNom: formations.nom, + inscriptionStatut: inscriptions.statut, + }) + .from(apprenants) + .leftJoin(inscriptions, eq(inscriptions.apprenantId, apprenants.id)) + .leftJoin(sequences, eq(inscriptions.sequenceId, sequences.id)) + .leftJoin(formations, eq(sequences.formationId, formations.id)); + + const byApprenant = new Map }>(); + for (const row of rows) { + const entry = byApprenant.get(row.id) ?? { + nom: row.nom, + prenom: row.prenom, + formations: new Set(), + }; + + if (row.inscriptionStatut !== "annulee" && row.formationNom) { + entry.formations.add(row.formationNom); + } + byApprenant.set(row.id, entry); + } + + return [...byApprenant.values()] + .map((apprenant) => ({ + nom: apprenant.nom, + prenom: apprenant.prenom, + formationSuivie: [...apprenant.formations] + .sort((left, right) => left.localeCompare(right, "fr", { sensitivity: "base" })) + .join(" | "), + })) + .sort((left, right) => { + const nomOrder = left.nom.localeCompare(right.nom, "fr", { sensitivity: "base" }); + return nomOrder !== 0 + ? nomOrder + : left.prenom.localeCompare(right.prenom, "fr", { sensitivity: "base" }); + }); +} + export async function getApprenantsByFormateur(formateurId: number) { const db = await getDb(); if (!db) return []; diff --git a/server/exportService.ts b/server/exportService.ts index c6889a4..43115d6 100644 --- a/server/exportService.ts +++ b/server/exportService.ts @@ -80,7 +80,7 @@ export function generateExcelExport(sequenceInfo: SequenceInfo): Buffer { { 'Information': 'Formation', 'Valeur': sequenceInfo.formationNom }, { 'Information': 'Séquence', 'Valeur': sequenceInfo.sequenceNom }, { 'Information': 'Lieu', 'Valeur': sequenceInfo.lieu }, - { 'Information': 'Public cible', 'Valeur': sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre' }, + { 'Information': 'Public cible', 'Valeur': sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous managers' : 'Autre' }, { 'Information': 'Nombre d\'inscrits', 'Valeur': sequenceInfo.inscriptions.length.toString() }, ]; @@ -126,7 +126,7 @@ export function generatePDFExport(sequenceInfo: SequenceInfo): Buffer { yPos += 6; doc.text(`Lieu : ${sequenceInfo.lieu}`, 14, yPos); yPos += 6; - doc.text(`Public cible : ${sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos); + doc.text(`Public cible : ${sequenceInfo.publicCible === 'directeur' ? 'Directeurs' : sequenceInfo.publicCible === 'chef_service' ? 'Chefs de service' : sequenceInfo.publicCible === 'tous' ? 'Tous managers' : 'Autre'}`, 14, yPos); yPos += 8; // Dates de formation @@ -260,7 +260,7 @@ export function generateFeuillePresenceJour( const publicCibleLabel = feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : - feuilleInfo.publicCible === 'tous' ? 'Tous' : 'Autre'; + feuilleInfo.publicCible === 'tous' ? 'Tous managers' : 'Autre'; const fonctionLabel = (f: string | null) => f === 'directeur' ? 'Directeur' : f === 'chef_service' ? 'Chef de service' : 'Autre'; @@ -434,7 +434,7 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe } // Fonction pour générer une page de feuille de présence pour une journée - const generatePageForDate = (dateInfo: { dateDebut: Date; dateFin: Date; ordre: number }, isFirstPage: boolean) => { + const generatePageForDate = (dateInfo: { id: number; dateDebut: Date; dateFin: Date; ordre: number }, isFirstPage: boolean) => { if (!isFirstPage) { doc.addPage(); } @@ -464,7 +464,7 @@ export function generateFeuillePresence(feuilleInfo: FeuillePresenceInfo): Buffe yPos += 6; } - doc.text(`Public cible : ${feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : feuilleInfo.publicCible === 'tous' ? 'Tous' : 'Autre'}`, 14, yPos); + doc.text(`Public cible : ${feuilleInfo.publicCible === 'directeur' ? 'Directeurs' : feuilleInfo.publicCible === 'chef_service' ? 'Chefs de service' : feuilleInfo.publicCible === 'tous' ? 'Tous managers' : 'Autre'}`, 14, yPos); yPos += 8; // Date de la journée diff --git a/server/routers.ts b/server/routers.ts index 3f926c0..c5981e1 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -12,6 +12,7 @@ import { TRPCError } from "@trpc/server"; import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation, sendResetPasswordEmail } from "./emailService"; import { logNotification } from "./notificationLogsDb"; import { generateExcelExport, generatePDFExport, generateFeuillePresenceParJour } from "./exportService"; +import { generateApprenantsExcelExport } from "./apprenantExportService"; import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel"; import crypto from "crypto"; import bcrypt from "bcryptjs"; @@ -326,6 +327,17 @@ export const appRouter = router({ return { success: true }; }), + exportExcel: adminProcedure.mutation(async () => { + const apprenants = await db.getApprenantsWithFormationNames(); + const buffer = generateApprenantsExcelExport(apprenants); + const exportDate = new Date().toISOString().slice(0, 10); + + return { + buffer: buffer.toString("base64"), + filename: `apprenants_${exportDate}.xlsx`, + }; + }), + getInscriptions: protectedProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { const results = await db.getInscriptionsByApprenant(input.id); // Récupérer les informations de formation et séquence pour chaque inscription diff --git a/shared/registration.ts b/shared/registration.ts new file mode 100644 index 0000000..2743f38 --- /dev/null +++ b/shared/registration.ts @@ -0,0 +1,55 @@ +/** + * Détermine l'ordre d'affichage des séquences sur le lien d'inscription public. + * Les sessions ouvertes avec des places disponibles sont toujours proposées en premier. + */ +export type RegistrationSequence = { + id: number; + nom: string; + statut: string; + dateBlocage?: Date | string | null; + capaciteMax: number; + nbInscrits?: number | null; + dates?: ReadonlyArray<{ dateDebut: Date | string }>; +}; + +export type RegistrationSequenceWithAvailability = T & { + isBloquee: boolean; + placesRestantes: number; +}; + +function firstDateTimestamp(sequence: RegistrationSequence): number { + const firstDate = sequence.dates?.[0]?.dateDebut; + if (!firstDate) return Number.MAX_SAFE_INTEGER; + + const timestamp = new Date(firstDate).getTime(); + return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER; +} + +function displayPriority(sequence: RegistrationSequenceWithAvailability): number { + if (sequence.isBloquee) return 2; + return sequence.placesRestantes > 0 ? 0 : 1; +} + +export function sortSequencesForRegistration( + sequences: readonly T[], + now = new Date(), +): RegistrationSequenceWithAvailability[] { + return sequences + .map((sequence) => { + const dateBlocage = sequence.dateBlocage ? new Date(sequence.dateBlocage) : null; + const hasReachedBlockingDate = dateBlocage !== null && now >= dateBlocage; + const isBloquee = sequence.statut !== "ouverte" || hasReachedBlockingDate; + const placesRestantes = Math.max(0, sequence.capaciteMax - (sequence.nbInscrits ?? 0)); + + return { ...sequence, isBloquee, placesRestantes }; + }) + .sort((left, right) => { + const priorityDifference = displayPriority(left) - displayPriority(right); + if (priorityDifference !== 0) return priorityDifference; + + const dateDifference = firstDateTimestamp(left) - firstDateTimestamp(right); + if (dateDifference !== 0) return dateDifference; + + return left.nom.localeCompare(right.nom, "fr", { sensitivity: "base" }); + }); +}