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

@@ -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`,
};
}),
}),
});