Checkpoint: Tableau de bord personnalisé pour formateurs avec statistiques (total séquences, séquences à venir, total apprenants) et liste des prochaines interventions avec détails (dates, lieu, capacité). Redirection automatique des formateurs vers /formateur au lieu de /admin. Notifications automatiques par email pour les formateurs lors de nouvelles inscriptions, annulations, capacité maximale atteinte et places disponibles. Menus Configuration, Traçabilité et Analyses masqués pour les formateurs.
This commit is contained in:
@@ -33,9 +33,7 @@ import AdminAttestations from "./pages/admin/AdminAttestations";
|
|||||||
import QuestionnaireReponse from "./pages/QuestionnaireReponse";
|
import QuestionnaireReponse from "./pages/QuestionnaireReponse";
|
||||||
import Inscription from "./pages/Inscription";
|
import Inscription from "./pages/Inscription";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import FormateurDashboard from "./pages/formateur/FormateurDashboard";
|
import FormateurDashboard from "./pages/FormateurDashboard";
|
||||||
import FormateurSequence from "./pages/formateur/FormateurSequence";
|
|
||||||
import FormateurHistorique from "./pages/formateur/FormateurHistorique";
|
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
return (
|
return (
|
||||||
@@ -70,8 +68,6 @@ function Router() {
|
|||||||
<Route path={"/admin/questionnaires/:id"} component={AdminQuestionnaireEdit} />
|
<Route path={"/admin/questionnaires/:id"} component={AdminQuestionnaireEdit} />
|
||||||
<Route path={"/questionnaire/:token"} component={QuestionnaireReponse} />
|
<Route path={"/questionnaire/:token"} component={QuestionnaireReponse} />
|
||||||
<Route path={"/formateur"} component={FormateurDashboard} />
|
<Route path={"/formateur"} component={FormateurDashboard} />
|
||||||
<Route path={"/formateur/sequence/:id"} component={FormateurSequence} />
|
|
||||||
<Route path={"/formateur/historique"} component={FormateurHistorique} />
|
|
||||||
<Route path={"/404"} component={NotFound} />
|
<Route path={"/404"} component={NotFound} />
|
||||||
{/* Final fallback route */}
|
{/* Final fallback route */}
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
|
|||||||
@@ -102,11 +102,19 @@ export default function DashboardLayout({
|
|||||||
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
|
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
|
||||||
});
|
});
|
||||||
const { loading, user } = useAuth();
|
const { loading, user } = useAuth();
|
||||||
|
const [location, setLocation] = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
|
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
|
||||||
}, [sidebarWidth]);
|
}, [sidebarWidth]);
|
||||||
|
|
||||||
|
// Rediriger les formateurs vers leur tableau de bord
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && user.role === 'formateur' && location === '/admin') {
|
||||||
|
setLocation('/formateur');
|
||||||
|
}
|
||||||
|
}, [user, location, setLocation]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <DashboardLayoutSkeleton />
|
return <DashboardLayoutSkeleton />
|
||||||
}
|
}
|
||||||
@@ -182,6 +190,17 @@ function DashboardLayoutContent({
|
|||||||
return section.title !== 'Configuration' && section.title !== 'Traçabilité' && section.title !== 'Analyses';
|
return section.title !== 'Configuration' && section.title !== 'Traçabilité' && section.title !== 'Analyses';
|
||||||
}
|
}
|
||||||
return true;
|
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 [isResizing, setIsResizing] = useState(false);
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|||||||
203
client/src/pages/FormateurDashboard.tsx
Normal file
203
client/src/pages/FormateurDashboard.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="container py-8 space-y-8">
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-10 w-96 mb-2" />
|
||||||
|
<Skeleton className="h-6 w-64" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
<Skeleton className="h-32" />
|
||||||
|
<Skeleton className="h-32" />
|
||||||
|
<Skeleton className="h-32" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-96" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container py-8 space-y-8">
|
||||||
|
{/* En-tête */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Tableau de bord formateur</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Vue d'ensemble de vos formations et prochaines interventions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cartes de statistiques */}
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total séquences</CardTitle>
|
||||||
|
<GraduationCap className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats?.totalSequences || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Toutes vos séquences de formation
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Séquences à venir</CardTitle>
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats?.sequencesAvenir || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Avec au moins une date future
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total apprenants</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats?.totalInscrits || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Inscrits confirmés à vos séquences
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des prochaines séquences */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Prochaines séquences</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Vos interventions à venir triées par date
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{sequencesLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-32" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : prochainesSequences && prochainesSequences.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{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 (
|
||||||
|
<Card key={seq.id} className="overflow-hidden">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-lg">{seq.nom}</CardTitle>
|
||||||
|
<CardDescription className="flex items-center gap-2">
|
||||||
|
<GraduationCap className="h-4 w-4" />
|
||||||
|
{seq.formation?.nom || "Formation inconnue"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant={seq.statut === 'ouverte' ? 'default' : seq.statut === 'bloquee' ? 'secondary' : 'outline'}>
|
||||||
|
{seq.statut === 'ouverte' ? 'Ouverte' : seq.statut === 'bloquee' ? 'Bloquée' : 'Terminée'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Lieu */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
<span>{seq.lieu}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>Dates de formation</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 ml-6">
|
||||||
|
{seq.dates.map((date: any) => {
|
||||||
|
const dateDebut = new Date(date.dateDebut);
|
||||||
|
const dateFin = new Date(date.dateFin);
|
||||||
|
const estFuture = dateDebut >= new Date();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={date.id}
|
||||||
|
variant={estFuture ? "default" : "outline"}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{format(dateDebut, "dd MMM", { locale: fr })}
|
||||||
|
{dateDebut.toDateString() !== dateFin.toDateString() &&
|
||||||
|
` - ${format(dateFin, "dd MMM", { locale: fr })}`
|
||||||
|
}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Capacité */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span className="font-medium">Inscriptions</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{seq.nbInscrits} / {seq.capaciteMax}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Progress
|
||||||
|
value={pourcentageRemplissage}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
{estComplete && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-orange-600">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
<span>Capacité maximale atteinte</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{estPresqueComplete && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-yellow-600">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
<span>Presque complète ({pourcentageRemplissage}%)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
<Calendar className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>Aucune séquence à venir pour le moment</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -528,6 +528,14 @@ export async function countInscriptionsBySequence(sequenceId: number, statut?: s
|
|||||||
return result[0]?.count || 0;
|
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<InsertInscription>) {
|
export async function updateInscription(id: number, data: Partial<InsertInscription>) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) throw new Error("Database not available");
|
if (!db) throw new Error("Database not available");
|
||||||
|
|||||||
@@ -891,3 +891,90 @@ export async function sendNotificationAnnulationListeAttente(params: {
|
|||||||
html: await getEmailTemplate(content, 'notification_annulation'),
|
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<boolean> {
|
||||||
|
const datesHTML = params.dates.map(date => `
|
||||||
|
<li>Date ${date.ordre} : ${date.dateDebut.toLocaleDateString('fr-FR', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
})}</li>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<h2>⚠️ Capacité maximale atteinte</h2>
|
||||||
|
<p>Bonjour ${params.formateurNom},</p>
|
||||||
|
|
||||||
|
<p>La capacité maximale de votre formation a été atteinte.</p>
|
||||||
|
|
||||||
|
<div class="info-box" style="background-color: #fee2e2; border-left-color: #ef4444;">
|
||||||
|
<h3>Détails de la séquence</h3>
|
||||||
|
<p><strong>Formation :</strong> ${params.formationNom}</p>
|
||||||
|
<p><strong>Séquence :</strong> ${params.sequenceNom}</p>
|
||||||
|
<p><strong>Capacité :</strong> ${params.nbInscrits} / ${params.capaciteMax}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Dates de la formation :</h4>
|
||||||
|
<ul>${datesHTML}</ul>
|
||||||
|
|
||||||
|
<p>Les nouvelles inscriptions seront automatiquement placées en liste d'attente.</p>
|
||||||
|
|
||||||
|
<p>Cordialement,<br/>L'équipe Formation</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
const content = `
|
||||||
|
<h2>✅ Place disponible</h2>
|
||||||
|
<p>Bonjour ${params.formateurNom},</p>
|
||||||
|
|
||||||
|
<p>Une place s'est libérée dans votre formation suite à une annulation.</p>
|
||||||
|
|
||||||
|
<div class="info-box" style="background-color: #d1fae5; border-left-color: #10b981;">
|
||||||
|
<h3>Détails de la séquence</h3>
|
||||||
|
<p><strong>Formation :</strong> ${params.formationNom}</p>
|
||||||
|
<p><strong>Séquence :</strong> ${params.sequenceNom}</p>
|
||||||
|
<p><strong>Places occupées :</strong> ${params.nbInscrits} / ${params.capaciteMax}</p>
|
||||||
|
<p><strong>Places disponibles :</strong> ${params.placesDisponibles}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>De nouvelles inscriptions peuvent maintenant être acceptées.</p>
|
||||||
|
|
||||||
|
<p>Cordialement,<br/>L'équipe Formation</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return sendEmail({
|
||||||
|
to: params.formateurEmail,
|
||||||
|
subject: `✅ Place disponible - ${params.formationNom} (${params.sequenceNom})`,
|
||||||
|
html: await getEmailTemplate(content, 'notification_formateur'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -267,3 +267,136 @@ export async function getDetailSequence(sequenceId: number, formateurId: number)
|
|||||||
dates,
|
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<number>`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<number>`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<number>`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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -235,6 +235,25 @@ export const appRouter = router({
|
|||||||
return db.getFormateurs();
|
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 }) => {
|
getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
||||||
return db.getFormateurById(input.id);
|
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');
|
const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
|
||||||
if (nbInscritsApres >= inscriptionSequence.capaciteMax) {
|
if (nbInscritsApres >= inscriptionSequence.capaciteMax) {
|
||||||
try {
|
try {
|
||||||
@@ -579,6 +598,28 @@ export const appRouter = router({
|
|||||||
});
|
});
|
||||||
console.log(`[Notification] Alerte capacité atteinte envoyée à ${adminEmails.length} admin(s)`);
|
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) {
|
} catch (e) {
|
||||||
console.error(`[Notification] Erreur envoi alerte capacité:`, e);
|
console.error(`[Notification] Erreur envoi alerte capacité:`, e);
|
||||||
}
|
}
|
||||||
@@ -717,7 +758,59 @@ export const appRouter = router({
|
|||||||
id: z.number(),
|
id: z.number(),
|
||||||
statut: z.enum(['confirmee', 'liste_attente', 'annulee']),
|
statut: z.enum(['confirmee', 'liste_attente', 'annulee']),
|
||||||
})).mutation(async ({ input }) => {
|
})).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 });
|
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 };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
15
todo.md
15
todo.md
@@ -614,3 +614,18 @@
|
|||||||
- [x] Filtrer les apprenants pour ne montrer que ceux inscrits aux séquences du formateur
|
- [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] Créer une procédure backend pour vérifier si un utilisateur est formateur
|
||||||
- [x] Tester avec un compte 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
|
||||||
|
|||||||
Reference in New Issue
Block a user