@@ -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" });
+ });
+}