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

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

View File

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

View File

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

View File

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