Checkpoint: Implémentation des trois améliorations demandées : ajout de filtres par fonction sur les pages apprenants et inscrits, création d'un graphique circulaire de répartition par fonction sur le tableau de bord avec recharts, et personnalisation des templates d'emails (confirmation, teaser, rappel J-7) incluant la fonction de l'apprenant dans la salutation.

This commit is contained in:
Manus Sandbox
2025-11-12 09:35:58 -05:00
parent 9d591b64ba
commit ca9b1c3d37
8 changed files with 156 additions and 23 deletions

View File

@@ -1,14 +1,31 @@
import DashboardLayout from "@/components/DashboardLayout"; import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { GraduationCap, Calendar, Users, CheckCircle } from "lucide-react"; import { GraduationCap, Calendar, Users, CheckCircle, PieChart as PieChartIcon } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts";
import { useMemo } from "react";
export default function Admin() { export default function Admin() {
const { data: formations, isLoading: loadingFormations } = trpc.formations.list.useQuery(); const { data: formations, isLoading: loadingFormations } = trpc.formations.list.useQuery();
const { data: sessions, isLoading: loadingSessions } = trpc.sessions.list.useQuery(); const { data: sessions, isLoading: loadingSessions } = trpc.sessions.list.useQuery();
const { data: apprenants, isLoading: loadingApprenants } = trpc.apprenants.list.useQuery(); const { data: apprenants, isLoading: loadingApprenants } = trpc.apprenants.list.useQuery();
// Statistiques par fonction
const statsFonction = useMemo(() => {
if (!apprenants) return [];
const directeurs = apprenants.filter(a => a.fonction === "directeur").length;
const chefsService = apprenants.filter(a => a.fonction === "chef_service").length;
const autres = apprenants.filter(a => a.fonction === "autre").length;
return [
{ name: "Directeurs", value: directeurs, color: "#3b82f6" },
{ name: "Chefs de service", value: chefsService, color: "#10b981" },
{ name: "Autres", value: autres, color: "#f59e0b" },
].filter(item => item.value > 0);
}, [apprenants]);
const stats = [ const stats = [
{ {
title: "Formations actives", title: "Formations actives",
@@ -66,6 +83,51 @@ export default function Admin() {
))} ))}
</div> </div>
{/* Graphique de répartition par fonction */}
{statsFonction.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChartIcon className="w-5 h-5" />
Répartition des apprenants par fonction
</CardTitle>
<CardDescription>
Distribution des {apprenants?.length || 0} apprenants selon leur fonction
</CardDescription>
</CardHeader>
<CardContent>
{loadingApprenants ? (
<div className="h-80 flex items-center justify-center">
<Skeleton className="h-64 w-64 rounded-full" />
</div>
) : (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={statsFonction}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
outerRadius={100}
fill="#8884d8"
dataKey="value"
>
{statsFonction.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
)}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Bienvenue dans l'espace d'administration</CardTitle> <CardTitle>Bienvenue dans l'espace d'administration</CardTitle>

View File

@@ -16,6 +16,7 @@ export default function AdminApprenants() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [filterFonction, setFilterFonction] = useState<string>("all");
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
nom: "", nom: "",
prenom: "", prenom: "",
@@ -104,9 +105,11 @@ export default function AdminApprenants() {
apprenant.email.toLowerCase().includes(searchTerm.toLowerCase()) || apprenant.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
apprenant.codeEtablissement.toLowerCase().includes(searchTerm.toLowerCase()); apprenant.codeEtablissement.toLowerCase().includes(searchTerm.toLowerCase());
return matchesSearch; const matchesFonction = filterFonction === "all" || apprenant.fonction === filterFonction;
return matchesSearch && matchesFonction;
}); });
}, [apprenants, searchTerm]); }, [apprenants, searchTerm, filterFonction]);
return ( return (
<DashboardLayout> <DashboardLayout>
@@ -218,6 +221,7 @@ export default function AdminApprenants() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="search">Rechercher un apprenant</Label> <Label htmlFor="search">Rechercher un apprenant</Label>
<div className="relative"> <div className="relative">
@@ -232,6 +236,22 @@ export default function AdminApprenants() {
</div> </div>
</div> </div>
<div className="space-y-2">
<Label htmlFor="filterFonction">Filtrer par fonction</Label>
<Select value={filterFonction} onValueChange={setFilterFonction}>
<SelectTrigger id="filterFonction">
<SelectValue placeholder="Toutes les fonctions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes les fonctions</SelectItem>
<SelectItem value="directeur">Directeur</SelectItem>
<SelectItem value="chef_service">Chef de service</SelectItem>
<SelectItem value="autre">Autre</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Compteur de résultats */} {/* Compteur de résultats */}
<div className="mt-4 text-sm text-muted-foreground"> <div className="mt-4 text-sm text-muted-foreground">
{filteredApprenants.length} apprenant(s) trouvé(s) {filteredApprenants.length} apprenant(s) trouvé(s)

View File

@@ -21,6 +21,7 @@ export default function AdminSessionInscrits() {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [filterStatut, setFilterStatut] = useState<string>("all"); const [filterStatut, setFilterStatut] = useState<string>("all");
const [filterEtablissement, setFilterEtablissement] = useState<string>("all"); const [filterEtablissement, setFilterEtablissement] = useState<string>("all");
const [filterFonction, setFilterFonction] = useState<string>("all");
const [sortBy, setSortBy] = useState<"date" | "nom">("date"); const [sortBy, setSortBy] = useState<"date" | "nom">("date");
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -151,7 +152,11 @@ export default function AdminSessionInscrits() {
const matchesEtablissement = filterEtablissement === "all" || const matchesEtablissement = filterEtablissement === "all" ||
inscription.apprenant?.codeEtablissement === filterEtablissement; inscription.apprenant?.codeEtablissement === filterEtablissement;
return matchesSearch && matchesStatut && matchesEtablissement; // Filtre par fonction
const matchesFonction = filterFonction === "all" ||
inscription.apprenant?.fonction === filterFonction;
return matchesSearch && matchesStatut && matchesEtablissement && matchesFonction;
}); });
// Tri // Tri
@@ -165,7 +170,7 @@ export default function AdminSessionInscrits() {
}); });
return filtered; return filtered;
}, [inscriptions, searchTerm, filterStatut, filterEtablissement, sortBy]); }, [inscriptions, searchTerm, filterStatut, filterEtablissement, filterFonction, sortBy]);
const getStatutBadge = (statut: string) => { const getStatutBadge = (statut: string) => {
const styles = { const styles = {
@@ -213,7 +218,7 @@ export default function AdminSessionInscrits() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Recherche */} {/* Recherche */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="search">Rechercher</Label> <Label htmlFor="search">Rechercher</Label>
@@ -261,6 +266,22 @@ export default function AdminSessionInscrits() {
</Select> </Select>
</div> </div>
{/* Filtre par fonction */}
<div className="space-y-2">
<Label htmlFor="filterFonction">Fonction</Label>
<Select value={filterFonction} onValueChange={setFilterFonction}>
<SelectTrigger id="filterFonction">
<SelectValue placeholder="Toutes les fonctions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes les fonctions</SelectItem>
<SelectItem value="directeur">Directeur</SelectItem>
<SelectItem value="chef_service">Chef de service</SelectItem>
<SelectItem value="autre">Autre</SelectItem>
</SelectContent>
</Select>
</div>
{/* Tri */} {/* Tri */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="sortBy">Trier par</Label> <Label htmlFor="sortBy">Trier par</Label>

View File

@@ -70,7 +70,7 @@
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-hook-form": "^7.64.0", "react-hook-form": "^7.64.0",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"recharts": "^2.15.2", "recharts": "^2.15.4",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "^1.4.0", "streamdown": "^1.4.0",
"superjson": "^1.13.3", "superjson": "^1.13.3",

2
pnpm-lock.yaml generated
View File

@@ -188,7 +188,7 @@ importers:
specifier: ^3.0.6 specifier: ^3.0.6
version: 3.0.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 3.0.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
recharts: recharts:
specifier: ^2.15.2 specifier: ^2.15.4
version: 2.15.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 2.15.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
sonner: sonner:
specifier: ^2.0.7 specifier: ^2.0.7

View File

@@ -94,6 +94,7 @@ export async function sendInscriptionConfirmation(params: {
apprenantEmail: string; apprenantEmail: string;
apprenantNom: string; apprenantNom: string;
apprenantPrenom: string; apprenantPrenom: string;
apprenantFonction: string;
formationNom: string; formationNom: string;
sessionNom: string; sessionNom: string;
dateDebut: Date; dateDebut: Date;
@@ -102,10 +103,12 @@ export async function sendInscriptionConfirmation(params: {
statut: 'confirmee' | 'liste_attente'; statut: 'confirmee' | 'liste_attente';
}): Promise<boolean> { }): Promise<boolean> {
const isConfirmed = params.statut === 'confirmee'; const isConfirmed = params.statut === 'confirmee';
const fonctionLabel = params.apprenantFonction === 'directeur' ? 'Directeur' :
params.apprenantFonction === 'chef_service' ? 'Chef de service' : 'Autre';
const content = ` const content = `
<h2>Confirmation d'inscription</h2> <h2>Confirmation d'inscription</h2>
<p>Bonjour ${params.apprenantPrenom} ${params.apprenantNom},</p> <p>Bonjour ${fonctionLabel} ${params.apprenantPrenom} ${params.apprenantNom},</p>
${isConfirmed ${isConfirmed
? '<p><strong>Votre inscription a été confirmée !</strong></p>' ? '<p><strong>Votre inscription a été confirmée !</strong></p>'
@@ -175,13 +178,19 @@ export async function sendInscriptionConfirmation(params: {
export async function sendTeaserEmail(params: { export async function sendTeaserEmail(params: {
apprenantEmail: string; apprenantEmail: string;
apprenantPrenom: string; apprenantPrenom: string;
apprenantNom: string;
apprenantFonction: string;
formationNom: string; formationNom: string;
sessionNom: string; sessionNom: string;
dateDebut: Date; dateDebut: Date;
}): Promise<boolean> { }): 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 content = ` const content = `
<h2>Votre formation approche !</h2> <h2>Votre formation approche !</h2>
<p>Bonjour ${params.apprenantPrenom},</p> <p>Bonjour ${salutation},</p>
<p>Nous sommes ravis de vous accueillir prochainement pour la formation <strong>${params.formationNom}</strong>.</p> <p>Nous sommes ravis de vous accueillir prochainement pour la formation <strong>${params.formationNom}</strong>.</p>
@@ -213,14 +222,20 @@ export async function sendTeaserEmail(params: {
export async function sendRappelJ7Email(params: { export async function sendRappelJ7Email(params: {
apprenantEmail: string; apprenantEmail: string;
apprenantPrenom: string; apprenantPrenom: string;
apprenantNom: string;
apprenantFonction: string;
formationNom: string; formationNom: string;
sessionNom: string; sessionNom: string;
dateDebut: Date; dateDebut: Date;
lieu: string; lieu: string;
}): Promise<boolean> { }): 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 content = ` const content = `
<h2>Rappel : Votre formation dans 7 jours</h2> <h2>Rappel : Votre formation dans 7 jours</h2>
<p>Bonjour ${params.apprenantPrenom},</p> <p>Bonjour ${salutation},</p>
<p>Nous vous rappelons que votre formation <strong>${params.formationNom}</strong> commence dans 7 jours.</p> <p>Nous vous rappelons que votre formation <strong>${params.formationNom}</strong> commence dans 7 jours.</p>
@@ -256,7 +271,7 @@ 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 session
*/ */
export async function sendGroupEmail(params: { export async function sendGroupEmail(params: {
recipients: Array<{ email: string; prenom: string }>; recipients: Array<{ email: string; prenom: string; nom: string; fonction: string }>;
formationNom: string; formationNom: string;
sessionNom: string; sessionNom: string;
type: 'teaser' | 'rappel'; type: 'teaser' | 'rappel';
@@ -272,6 +287,8 @@ export async function sendGroupEmail(params: {
await sendTeaserEmail({ await sendTeaserEmail({
apprenantEmail: recipient.email, apprenantEmail: recipient.email,
apprenantPrenom: recipient.prenom, apprenantPrenom: recipient.prenom,
apprenantNom: recipient.nom,
apprenantFonction: recipient.fonction,
formationNom: params.formationNom, formationNom: params.formationNom,
sessionNom: params.sessionNom, sessionNom: params.sessionNom,
dateDebut: params.dateDebut, dateDebut: params.dateDebut,
@@ -280,6 +297,8 @@ export async function sendGroupEmail(params: {
await sendRappelJ7Email({ await sendRappelJ7Email({
apprenantEmail: recipient.email, apprenantEmail: recipient.email,
apprenantPrenom: recipient.prenom, apprenantPrenom: recipient.prenom,
apprenantNom: recipient.nom,
apprenantFonction: recipient.fonction,
formationNom: params.formationNom, formationNom: params.formationNom,
sessionNom: params.sessionNom, sessionNom: params.sessionNom,
dateDebut: params.dateDebut, dateDebut: params.dateDebut,

View File

@@ -245,6 +245,7 @@ export const appRouter = router({
apprenantEmail: inscriptionApprenant.email, apprenantEmail: inscriptionApprenant.email,
apprenantNom: inscriptionApprenant.nom, apprenantNom: inscriptionApprenant.nom,
apprenantPrenom: inscriptionApprenant.prenom, apprenantPrenom: inscriptionApprenant.prenom,
apprenantFonction: inscriptionApprenant.fonction,
formationNom: inscriptionFormation.nom, formationNom: inscriptionFormation.nom,
sessionNom: inscriptionSession.nom, sessionNom: inscriptionSession.nom,
dateDebut: new Date(inscriptionSession.dateDebut), dateDebut: new Date(inscriptionSession.dateDebut),
@@ -315,6 +316,8 @@ export const appRouter = router({
.map(i => ({ .map(i => ({
email: i.apprenant!.email, email: i.apprenant!.email,
prenom: i.apprenant!.prenom, prenom: i.apprenant!.prenom,
nom: i.apprenant!.nom,
fonction: i.apprenant!.fonction,
})); }));
const result = await sendGroupEmail({ const result = await sendGroupEmail({

View File

@@ -74,3 +74,11 @@
- [x] Mettre à jour le schéma de base de données - [x] Mettre à jour le schéma de base de données
- [x] Mettre à jour les formulaires d'inscription et de gestion - [x] Mettre à jour les formulaires d'inscription et de gestion
- [x] Ajouter la fonction dans les exports PDF/Excel - [x] Ajouter la fonction dans les exports PDF/Excel
## Améliorations supplémentaires
- [x] Ajouter un filtre par fonction sur la page des apprenants
- [x] Ajouter un filtre par fonction sur la page des inscrits
- [x] Créer des statistiques de répartition par fonction sur le tableau de bord
- [x] Ajouter des graphiques de visualisation des données
- [x] Personnaliser les templates d'emails avec la fonction de l'apprenant