diff --git a/client/src/App.tsx b/client/src/App.tsx index 944af09..bb76ac8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -33,9 +33,7 @@ import AdminAttestations from "./pages/admin/AdminAttestations"; import QuestionnaireReponse from "./pages/QuestionnaireReponse"; import Inscription from "./pages/Inscription"; import Login from "./pages/Login"; -import FormateurDashboard from "./pages/formateur/FormateurDashboard"; -import FormateurSequence from "./pages/formateur/FormateurSequence"; -import FormateurHistorique from "./pages/formateur/FormateurHistorique"; +import FormateurDashboard from "./pages/FormateurDashboard"; function Router() { return ( @@ -70,8 +68,6 @@ function Router() { - - {/* Final fallback route */} diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 5b260c0..130f289 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -102,11 +102,19 @@ export default function DashboardLayout({ return saved ? parseInt(saved, 10) : DEFAULT_WIDTH; }); const { loading, user } = useAuth(); + const [location, setLocation] = useLocation(); useEffect(() => { localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString()); }, [sidebarWidth]); + // Rediriger les formateurs vers leur tableau de bord + useEffect(() => { + if (user && user.role === 'formateur' && location === '/admin') { + setLocation('/formateur'); + } + }, [user, location, setLocation]); + if (loading) { return } @@ -182,6 +190,17 @@ function DashboardLayoutContent({ return section.title !== 'Configuration' && section.title !== 'Traçabilité' && section.title !== 'Analyses'; } return true; + }).map(section => { + // Modifier le chemin du tableau de bord pour les formateurs + if (section.title === 'Tableau de bord' && user?.role === 'formateur') { + return { + ...section, + items: section.items.map(item => + item.path === '/admin' ? { ...item, path: '/formateur' } : item + ) + }; + } + return section; }); const [isResizing, setIsResizing] = useState(false); const sidebarRef = useRef(null); diff --git a/client/src/pages/FormateurDashboard.tsx b/client/src/pages/FormateurDashboard.tsx new file mode 100644 index 0000000..7cd81d6 --- /dev/null +++ b/client/src/pages/FormateurDashboard.tsx @@ -0,0 +1,203 @@ +import { trpc } from "@/lib/trpc"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { Calendar, Users, GraduationCap, Clock, MapPin, AlertCircle } from "lucide-react"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function FormateurDashboard() { + const { data: stats, isLoading: statsLoading } = trpc.formateurs.dashboardStats.useQuery(); + const { data: prochainesSequences, isLoading: sequencesLoading } = trpc.formateurs.prochainesSequences.useQuery({ limit: 10 }); + + if (statsLoading) { + return ( +
+
+ + +
+
+ + + +
+ +
+ ); + } + + return ( +
+ {/* En-tête */} +
+

Tableau de bord formateur

+

+ Vue d'ensemble de vos formations et prochaines interventions +

+
+ + {/* Cartes de statistiques */} +
+ + + Total séquences + + + +
{stats?.totalSequences || 0}
+

+ Toutes vos séquences de formation +

+
+
+ + + + Séquences à venir + + + +
{stats?.sequencesAvenir || 0}
+

+ Avec au moins une date future +

+
+
+ + + + Total apprenants + + + +
{stats?.totalInscrits || 0}
+

+ Inscrits confirmés à vos séquences +

+
+
+
+ + {/* Liste des prochaines séquences */} + + + Prochaines séquences + + Vos interventions à venir triées par date + + + + {sequencesLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : prochainesSequences && prochainesSequences.length > 0 ? ( +
+ {prochainesSequences.map((seq: any) => { + const pourcentageRemplissage = seq.capaciteMax > 0 + ? Math.round((seq.nbInscrits / seq.capaciteMax) * 100) + : 0; + const estComplete = pourcentageRemplissage >= 100; + const estPresqueComplete = pourcentageRemplissage >= 80 && pourcentageRemplissage < 100; + + return ( + + +
+
+ {seq.nom} + + + {seq.formation?.nom || "Formation inconnue"} + +
+ + {seq.statut === 'ouverte' ? 'Ouverte' : seq.statut === 'bloquee' ? 'Bloquée' : 'Terminée'} + +
+
+ + {/* Lieu */} +
+ + {seq.lieu} +
+ + {/* Dates */} +
+
+ + Dates de formation +
+
+ {seq.dates.map((date: any) => { + const dateDebut = new Date(date.dateDebut); + const dateFin = new Date(date.dateFin); + const estFuture = dateDebut >= new Date(); + + return ( + + {format(dateDebut, "dd MMM", { locale: fr })} + {dateDebut.toDateString() !== dateFin.toDateString() && + ` - ${format(dateFin, "dd MMM", { locale: fr })}` + } + + ); + })} +
+
+ + {/* Capacité */} +
+
+
+ + Inscriptions +
+ + {seq.nbInscrits} / {seq.capaciteMax} + +
+
+ + {estComplete && ( +
+ + Capacité maximale atteinte +
+ )} + {estPresqueComplete && ( +
+ + Presque complète ({pourcentageRemplissage}%) +
+ )} +
+
+
+
+ ); + })} +
+ ) : ( +
+ +

Aucune séquence à venir pour le moment

+
+ )} +
+
+
+ ); +} diff --git a/server/db.ts b/server/db.ts index b9aa4c7..dc0b2b3 100644 --- a/server/db.ts +++ b/server/db.ts @@ -528,6 +528,14 @@ export async function countInscriptionsBySequence(sequenceId: number, statut?: s return result[0]?.count || 0; } +export async function getInscriptionById(id: number) { + const db = await getDb(); + if (!db) return undefined; + + const result = await db.select().from(inscriptions).where(eq(inscriptions.id, id)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + export async function updateInscription(id: number, data: Partial) { const db = await getDb(); if (!db) throw new Error("Database not available"); diff --git a/server/emailService.ts b/server/emailService.ts index 5e072a4..feefb14 100644 --- a/server/emailService.ts +++ b/server/emailService.ts @@ -891,3 +891,90 @@ export async function sendNotificationAnnulationListeAttente(params: { html: await getEmailTemplate(content, 'notification_annulation'), }); } + +/** + * Envoie une notification au formateur quand la capacité maximale est atteinte + */ +export async function sendNotificationFormateurCapaciteAtteinte(params: { + formateurEmail: string; + formateurNom: string; + formationNom: string; + sequenceNom: string; + capaciteMax: number; + nbInscrits: number; + dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; +}): Promise { + const datesHTML = params.dates.map(date => ` +
  • Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric' + })}
  • + `).join(''); + + const content = ` +

    ⚠️ Capacité maximale atteinte

    +

    Bonjour ${params.formateurNom},

    + +

    La capacité maximale de votre formation a été atteinte.

    + +
    +

    Détails de la séquence

    +

    Formation : ${params.formationNom}

    +

    Séquence : ${params.sequenceNom}

    +

    Capacité : ${params.nbInscrits} / ${params.capaciteMax}

    +
    + +

    Dates de la formation :

    +
      ${datesHTML}
    + +

    Les nouvelles inscriptions seront automatiquement placées en liste d'attente.

    + +

    Cordialement,
    L'équipe Formation

    + `; + + return sendEmail({ + to: params.formateurEmail, + subject: `⚠️ Capacité maximale atteinte - ${params.formationNom} (${params.sequenceNom})`, + html: await getEmailTemplate(content, 'notification_formateur'), + }); +} + +/** + * Envoie une notification au formateur quand une place se libère + */ +export async function sendNotificationFormateurPlaceDisponible(params: { + formateurEmail: string; + formateurNom: string; + formationNom: string; + sequenceNom: string; + capaciteMax: number; + nbInscrits: number; + placesDisponibles: number; +}): Promise { + const content = ` +

    ✅ Place disponible

    +

    Bonjour ${params.formateurNom},

    + +

    Une place s'est libérée dans votre formation suite à une annulation.

    + +
    +

    Détails de la séquence

    +

    Formation : ${params.formationNom}

    +

    Séquence : ${params.sequenceNom}

    +

    Places occupées : ${params.nbInscrits} / ${params.capaciteMax}

    +

    Places disponibles : ${params.placesDisponibles}

    +
    + +

    De nouvelles inscriptions peuvent maintenant être acceptées.

    + +

    Cordialement,
    L'équipe Formation

    + `; + + return sendEmail({ + to: params.formateurEmail, + subject: `✅ Place disponible - ${params.formationNom} (${params.sequenceNom})`, + html: await getEmailTemplate(content, 'notification_formateur'), + }); +} diff --git a/server/formateurDb.ts b/server/formateurDb.ts index 44decef..1e5ae2e 100644 --- a/server/formateurDb.ts +++ b/server/formateurDb.ts @@ -267,3 +267,136 @@ export async function getDetailSequence(sequenceId: number, formateurId: number) dates, }; } + +/** + * Récupérer les statistiques du tableau de bord pour un formateur + */ +export async function getFormateurDashboardStats(formateurId: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Nombre total de séquences du formateur + const totalSequences = await db + .select({ count: sql`COUNT(*)` }) + .from(sequences) + .where(eq(sequences.formateurId, formateurId)); + + // Nombre total d'inscrits confirmés à toutes les séquences du formateur + const totalInscrits = await db + .select({ count: sql`COUNT(*)` }) + .from(inscriptions) + .innerJoin(sequences, eq(inscriptions.sequenceId, sequences.id)) + .where( + and( + eq(sequences.formateurId, formateurId), + eq(inscriptions.statut, 'confirmee') + ) + ); + + // Nombre de séquences à venir (ayant au moins une date future) + const today = new Date(); + const sequencesAvenir = await db + .select({ sequenceId: datesFormation.sequenceId }) + .from(datesFormation) + .innerJoin(sequences, eq(datesFormation.sequenceId, sequences.id)) + .where( + and( + eq(sequences.formateurId, formateurId), + gte(datesFormation.dateDebut, today) + ) + ) + .groupBy(datesFormation.sequenceId); + + return { + totalSequences: totalSequences[0]?.count || 0, + totalInscrits: totalInscrits[0]?.count || 0, + sequencesAvenir: sequencesAvenir.length, + }; +} + +/** + * Récupérer les prochaines séquences du formateur avec leurs détails + */ +export async function getFormateurProchainesSequences(formateurId: number, limit: number = 10) { + const db = await getDb(); + if (!db) return []; + + const today = new Date(); + + // Récupérer les séquences du formateur ayant au moins une date future + const seqs = await db + .select() + .from(sequences) + .where(eq(sequences.formateurId, formateurId)); + + // Pour chaque séquence, récupérer les détails + const sequencesAvecDetails = await Promise.all( + seqs.map(async (seq) => { + // Récupérer toutes les dates de la séquence + const dates = await db + .select() + .from(datesFormation) + .where(eq(datesFormation.sequenceId, seq.id)) + .orderBy(datesFormation.dateDebut); + + // Vérifier si au moins une date est future + const hasFutureDate = dates.some(d => new Date(d.dateDebut) >= today); + + if (!hasFutureDate) return null; + + // Récupérer la formation + const formation = await db + .select() + .from(formations) + .where(eq(formations.id, seq.formationId)) + .limit(1); + + // Compter les inscrits confirmés + const inscritsCount = await db + .select({ count: sql`COUNT(*)` }) + .from(inscriptions) + .where( + and( + eq(inscriptions.sequenceId, seq.id), + eq(inscriptions.statut, 'confirmee') + ) + ); + + return { + ...seq, + formation: formation[0] || null, + dates, + nbInscrits: inscritsCount[0]?.count || 0, + prochaineDateDebut: dates.find(d => new Date(d.dateDebut) >= today)?.dateDebut || dates[0]?.dateDebut, + }; + }) + ); + + // Filtrer les séquences nulles et trier par prochaine date + const sequencesFiltrees = sequencesAvecDetails + .filter(s => s !== null) + .sort((a, b) => { + const dateA = new Date(a!.prochaineDateDebut); + const dateB = new Date(b!.prochaineDateDebut); + return dateA.getTime() - dateB.getTime(); + }) + .slice(0, limit); + + return sequencesFiltrees; +} + +/** + * Récupérer l'email du formateur à partir de son ID + */ +export async function getFormateurEmailById(formateurId: number): Promise { + const db = await getDb(); + if (!db) return null; + + const result = await db + .select({ email: formateurs.email }) + .from(formateurs) + .where(eq(formateurs.id, formateurId)) + .limit(1); + + return result[0]?.email || null; +} diff --git a/server/routers.ts b/server/routers.ts index d27cca4..bfa6ab6 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -235,6 +235,25 @@ export const appRouter = router({ return db.getFormateurs(); }), + // Tableau de bord formateur + dashboardStats: protectedProcedure.query(async ({ ctx }) => { + if (ctx.user.role !== 'formateur' || !ctx.user.formateurId) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux formateurs' }); + } + const formateurDb = await import('./formateurDb'); + return formateurDb.getFormateurDashboardStats(ctx.user.formateurId); + }), + + prochainesSequences: protectedProcedure + .input(z.object({ limit: z.number().optional().default(10) })) + .query(async ({ ctx, input }) => { + if (ctx.user.role !== 'formateur' || !ctx.user.formateurId) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux formateurs' }); + } + const formateurDb = await import('./formateurDb'); + return formateurDb.getFormateurProchainesSequences(ctx.user.formateurId, input.limit); + }), + getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { return db.getFormateurById(input.id); }), @@ -550,7 +569,7 @@ export const appRouter = router({ } } - // Alerte capacité atteinte pour les admins + // Alerte capacité atteinte pour les admins ET le formateur const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); if (nbInscritsApres >= inscriptionSequence.capaciteMax) { try { @@ -579,6 +598,28 @@ export const appRouter = router({ }); console.log(`[Notification] Alerte capacité atteinte envoyée à ${adminEmails.length} admin(s)`); } + + // Notification au formateur pour capacité atteinte + if (inscriptionSequence.formateurId) { + const formateur = await db.getFormateurById(inscriptionSequence.formateurId); + if (formateur && formateur.email) { + const { sendNotificationFormateurCapaciteAtteinte } = await import('./emailService'); + await sendNotificationFormateurCapaciteAtteinte({ + formateurEmail: formateur.email, + formateurNom: formateur.nom, + formationNom: inscriptionFormation.nom, + sequenceNom: inscriptionSequence.nom, + capaciteMax: inscriptionSequence.capaciteMax, + nbInscrits: nbInscritsApres, + dates: dates.map(d => ({ + dateDebut: new Date(d.dateDebut), + dateFin: new Date(d.dateFin), + ordre: d.ordre, + })), + }); + console.log(`[Notification] Alerte capacité atteinte envoyée au formateur ${formateur.email}`); + } + } } catch (e) { console.error(`[Notification] Erreur envoi alerte capacité:`, e); } @@ -717,7 +758,59 @@ export const appRouter = router({ id: z.number(), statut: z.enum(['confirmee', 'liste_attente', 'annulee']), })).mutation(async ({ input }) => { + // Récupérer l'inscription avant modification + const inscriptionAvant = await db.getInscriptionById(input.id); + const ancienStatut = inscriptionAvant?.statut; + await db.updateInscription(input.id, { statut: input.statut }); + + // Si passage à annulée, notifier le formateur + if (input.statut === 'annulee' && ancienStatut !== 'annulee' && inscriptionAvant) { + const sequence = await db.getSequenceById(inscriptionAvant.sequenceId); + const apprenant = await db.getApprenantById(inscriptionAvant.apprenantId); + + if (sequence && apprenant && sequence.formateurId) { + const formateur = await db.getFormateurById(sequence.formateurId); + const formation = await db.getFormationById(sequence.formationId); + + if (formateur && formateur.email && formation) { + const nbInscritsApres = await db.countInscriptionsBySequence(sequence.id, 'confirmee'); + + try { + await sendNotificationFormateurAnnulation({ + formateurEmail: formateur.email, + formateurNom: formateur.nom, + apprenantNom: apprenant.nom, + apprenantPrenom: apprenant.prenom, + apprenantFonction: apprenant.fonction, + formationNom: formation.nom, + sequenceNom: sequence.nom, + nbInscrits: nbInscritsApres, + capaciteMax: sequence.capaciteMax, + }); + console.log(`[Notification] Annulation envoyée au formateur ${formateur.email}`); + + // Si une place se libère (passage de capacité max à disponible) + if (nbInscritsApres < sequence.capaciteMax && (nbInscritsApres + 1) >= sequence.capaciteMax) { + const { sendNotificationFormateurPlaceDisponible } = await import('./emailService'); + await sendNotificationFormateurPlaceDisponible({ + formateurEmail: formateur.email, + formateurNom: formateur.nom, + formationNom: formation.nom, + sequenceNom: sequence.nom, + capaciteMax: sequence.capaciteMax, + nbInscrits: nbInscritsApres, + placesDisponibles: sequence.capaciteMax - nbInscritsApres, + }); + console.log(`[Notification] Place disponible envoyée au formateur ${formateur.email}`); + } + } catch (e) { + console.error(`[Notification] Erreur envoi notification formateur:`, e); + } + } + } + } + return { success: true }; }), diff --git a/todo.md b/todo.md index 01c4a8c..d083dab 100644 --- a/todo.md +++ b/todo.md @@ -614,3 +614,18 @@ - [x] Filtrer les apprenants pour ne montrer que ceux inscrits aux séquences du formateur - [x] Créer une procédure backend pour vérifier si un utilisateur est formateur - [x] Tester avec un compte formateur + +## Tableau de bord personnalisé pour formateurs +- [x] Créer les procédures backend pour récupérer les prochaines séquences du formateur +- [x] Créer les procédures backend pour calculer les statistiques du formateur +- [x] Créer la page FormateurDashboard avec cartes de statistiques +- [x] Afficher la liste des prochaines séquences avec nombre d'inscrits +- [x] Afficher les rappels à venir pour chaque séquence +- [x] Modifier DashboardLayout pour rediriger les formateurs vers /formateur/dashboard + +## Notifications automatiques pour formateurs +- [x] Envoyer une notification au formateur lors d'une nouvelle inscription +- [x] Envoyer une notification au formateur lors d'une annulation +- [x] Envoyer une notification au formateur quand la capacité maximale est atteinte +- [x] Envoyer une notification au formateur quand une place se libère +- [x] Intégrer les notifications dans les mutations d'inscription existantes