+ {getFormationName()} - {sequence.nom} +
+Formation
+{getFormationName()}
+Nom de la séquence
+{sequence.nom}
+Lieu
+{sequence.lieu}
+Capacité
++ {nbConfirmes} / {sequence.capaciteMax} inscrits confirmés +
+Dates de formation
++ {filteredAndSortedInscriptions.length} inscription(s) trouvée(s) sur {inscriptions?.length || 0} au total +
++ Créez et gérez les séquences de formation avec leurs dates +
++ {filteredAndSortedSequences.length} séquence(s) trouvée(s) +
+- {getFormationName(session.formationId)} - {session.nom} -
- )}- {session.capaciteMax - inscritsConfirmes.length} places restantes -
-- En attente de places -
-- Toutes statuts confondus -
-- Gérez les sessions de formation -
-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.
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.
+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.
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.
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.
+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.
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.
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.
-Les inscriptions et désinscriptions sont bloquées automatiquement 15 jours avant le début de la session pour garantir la stabilité des groupes.
+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.
+Les inscriptions et désinscriptions sont bloquées automatiquement 15 jours avant le début de la sequence pour garantir la stabilité des groupes.
- Important : Les inscriptions seront bloquées 15 jours avant le début de la session. + Important : Les inscriptions seront bloquées 15 jours avant le début de la sequence. Après cette date, aucune modification ne sera possible.
Bonjour ${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom},
@@ -118,49 +141,36 @@ export async function sendInscriptionConfirmation(params: {Formation : ${params.formationNom}
-Session : ${params.sessionNom}
-Date de début : ${params.dateDebut.toLocaleDateString('fr-FR', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })}
-Date de fin : ${params.dateFin.toLocaleDateString('fr-FR', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })}
+Séquence : ${params.sequenceNom}
Lieu : ${params.lieu}
+Une invitation Outlook est jointe à cet email. Merci de l'ajouter à votre calendrier pour bloquer votre agenda.
-Important : Les inscriptions seront bloquées 15 jours avant le début de la session. Après cette date, aucune modification ne sera possible.
` + ? `Des invitations Outlook sont jointes à cet email pour chaque date de formation. Merci de les ajouter à votre calendrier pour bloquer votre agenda.
+Important : Les inscriptions seront bloquées 15 jours avant le début de la séquence. Après cette date, aucune modification ne sera possible.
` : 'Nous vous contacterons dès qu\'une place se libère.
' }À bientôt pour cette formation !
`; - 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 }>; }): PromiseBonjour ${salutation},
@@ -195,13 +216,9 @@ export async function sendTeaserEmail(params: {Nous sommes ravis de vous accueillir prochainement pour la formation ${params.formationNom}.
Session : ${params.sessionNom}
-Date : ${params.dateDebut.toLocaleDateString('fr-FR', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric' - })}
+Séquence : ${params.sequenceNom}
+Préparez-vous à vivre une expérience enrichissante qui vous permettra de développer vos compétences managériales.
@@ -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; }): PromiseBonjour ${salutation},
@@ -241,16 +272,10 @@ export async function sendRappelJ7Email(params: {Session : ${params.sessionNom}
-Date : ${params.dateDebut.toLocaleDateString('fr-FR', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - })}
+Séquence : ${params.sequenceNom}
Lieu : ${params.lieu}
+Merci de vous présenter à l'heure indiquée.
@@ -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 || '', }); } diff --git a/server/exportService.ts b/server/exportService.ts index a576d74..cf55d3a 100644 --- a/server/exportService.ts +++ b/server/exportService.ts @@ -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, diff --git a/server/routers.ts b/server/routers.ts index bbe902e..2e85150 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -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`, + }; }), }), }); diff --git a/todo.md b/todo.md index 6901242..7d1d6c9 100644 --- a/todo.md +++ b/todo.md @@ -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)