Checkpoint: Ajout de badges Actif/Inactif dans la liste des apprenants et création d'une page dédiée aux établissements avec statistiques de participation

This commit is contained in:
Manus Sandbox
2025-11-23 17:28:12 -05:00
parent 9a38fc9dd3
commit a0049436cc
8 changed files with 287 additions and 2 deletions

View File

@@ -19,6 +19,7 @@ import AdminEmailConfig from "./pages/AdminEmailConfig";
import AdminCalendrier from "./pages/AdminCalendrier"; import AdminCalendrier from "./pages/AdminCalendrier";
import AdminRappels from "./pages/AdminRappels"; import AdminRappels from "./pages/AdminRappels";
import AdminAnalytics from "./pages/AdminAnalytics"; import AdminAnalytics from "./pages/AdminAnalytics";
import AdminEtablissements from "./pages/AdminEtablissements";
import Inscription from "./pages/Inscription"; import Inscription from "./pages/Inscription";
import Login from "./pages/Login"; import Login from "./pages/Login";
@@ -42,6 +43,7 @@ function Router() {
<Route path={"/admin/calendrier"} component={AdminCalendrier} /> <Route path={"/admin/calendrier"} component={AdminCalendrier} />
<Route path={"/admin/rappels"} component={AdminRappels} /> <Route path={"/admin/rappels"} component={AdminRappels} />
<Route path={"/admin/analytics"} component={AdminAnalytics} /> <Route path={"/admin/analytics"} component={AdminAnalytics} />
<Route path={"/admin/etablissements"} component={AdminEtablissements} />
<Route path={"/404"} component={NotFound} /> <Route path={"/404"} component={NotFound} />
{/* Final fallback route */} {/* Final fallback route */}
<Route component={NotFound} /> <Route component={NotFound} />

View File

@@ -21,7 +21,7 @@ import {
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const"; import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile"; import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp } from "lucide-react"; import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2 } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react"; import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
@@ -34,6 +34,7 @@ const menuItems = [
{ icon: CalendarDays, label: "Calendrier", path: "/admin/calendrier" }, { icon: CalendarDays, label: "Calendrier", path: "/admin/calendrier" },
{ icon: Bell, label: "Rappels", path: "/admin/rappels" }, { icon: Bell, label: "Rappels", path: "/admin/rappels" },
{ icon: Users, label: "Apprenants", path: "/admin/apprenants" }, { icon: Users, label: "Apprenants", path: "/admin/apprenants" },
{ icon: Building2, label: "Établissements", path: "/admin/etablissements" },
{ icon: UserCog, label: "Utilisateurs", path: "/admin/users" }, { icon: UserCog, label: "Utilisateurs", path: "/admin/users" },
{ icon: Mail, label: "Templates d'emails", path: "/admin/email-templates" }, { icon: Mail, label: "Templates d'emails", path: "/admin/email-templates" },
{ icon: Settings, label: "Configuration SMTP", path: "/admin/email-config" }, { icon: Settings, label: "Configuration SMTP", path: "/admin/email-config" },

View File

@@ -284,6 +284,7 @@ export default function AdminApprenants() {
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead>Code établissement</TableHead> <TableHead>Code établissement</TableHead>
<TableHead>Fonction</TableHead> <TableHead>Fonction</TableHead>
<TableHead>Statut</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -304,6 +305,15 @@ export default function AdminApprenants() {
apprenant.fonction === "chef_service" ? "Chef de service" : "Autre"} apprenant.fonction === "chef_service" ? "Chef de service" : "Autre"}
</span> </span>
</TableCell> </TableCell>
<TableCell>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
apprenant.inscriptions && apprenant.inscriptions.length > 0
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}`}>
{apprenant.inscriptions && apprenant.inscriptions.length > 0 ? "Actif" : "Inactif"}
</span>
</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button

View File

@@ -0,0 +1,123 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { trpc } from "@/lib/trpc";
import { Building2, Users, TrendingUp, Activity } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
export default function AdminEtablissements() {
const { data: etablissements, isLoading } = trpc.etablissements.list.useQuery();
const totalApprenants = etablissements?.reduce((sum, etab) => sum + etab.nombreApprenants, 0) || 0;
const totalActifs = etablissements?.reduce((sum, etab) => sum + etab.apprenantsActifs, 0) || 0;
const tauxGlobal = totalApprenants > 0 ? Math.round((totalActifs / totalApprenants) * 100) : 0;
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Établissements</h1>
<p className="text-muted-foreground mt-2">
Vue d'ensemble des établissements et de leur participation
</p>
</div>
{/* Statistiques globales */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Établissements</CardTitle>
<Building2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{etablissements?.length || 0}</div>
<p className="text-xs text-muted-foreground">Nombre total</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Apprenants</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalApprenants}</div>
<p className="text-xs text-muted-foreground">Tous établissements</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Apprenants actifs</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalActifs}</div>
<p className="text-xs text-muted-foreground">Avec inscriptions</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Taux de participation</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{tauxGlobal}%</div>
<p className="text-xs text-muted-foreground">Global</p>
</CardContent>
</Card>
</div>
{/* Tableau des établissements */}
<Card>
<CardHeader>
<CardTitle>Liste des établissements</CardTitle>
<CardDescription>
Statistiques de participation par établissement
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : etablissements && etablissements.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Code établissement</TableHead>
<TableHead className="text-center">Nombre d'apprenants</TableHead>
<TableHead className="text-center">Apprenants actifs</TableHead>
<TableHead className="text-center">Taux de participation</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{etablissements.map((etab) => (
<TableRow key={etab.codeEtablissement}>
<TableCell className="font-medium">{etab.codeEtablissement}</TableCell>
<TableCell className="text-center">{etab.nombreApprenants}</TableCell>
<TableCell className="text-center">{etab.apprenantsActifs}</TableCell>
<TableCell className="text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
etab.tauxParticipation >= 70 ? "bg-green-100 text-green-800" :
etab.tauxParticipation >= 40 ? "bg-orange-100 text-orange-800" :
"bg-red-100 text-red-800"
}`}>
{etab.tauxParticipation}%
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-center text-muted-foreground py-8">
Aucun établissement trouvé
</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -280,7 +280,24 @@ export async function getApprenants() {
const db = await getDb(); const db = await getDb();
if (!db) return []; if (!db) return [];
return await db.select().from(apprenants); const apprenantsData = await db.select().from(apprenants);
// Pour chaque apprenant, récupérer ses inscriptions
const apprenantsWithInscriptions = await Promise.all(
apprenantsData.map(async (apprenant) => {
const inscriptionsData = await db
.select()
.from(inscriptions)
.where(eq(inscriptions.apprenantId, apprenant.id));
return {
...apprenant,
inscriptions: inscriptionsData,
};
})
);
return apprenantsWithInscriptions;
} }
export async function getApprenantById(id: number): Promise<Apprenant | undefined> { export async function getApprenantById(id: number): Promise<Apprenant | undefined> {

110
server/etablissementsDb.ts Normal file
View File

@@ -0,0 +1,110 @@
import { eq, sql } from "drizzle-orm";
import { getDb } from "./db";
import { apprenants, inscriptions } from "../drizzle/schema";
export async function getEtablissementsStats() {
const db = await getDb();
if (!db) return [];
// Récupérer tous les apprenants groupés par établissement
const apprenantsData = await db.select().from(apprenants);
// Grouper par code établissement
const etablissementsMap = new Map<string, {
codeEtablissement: string;
nombreApprenants: number;
apprenantsActifs: number;
apprenantIds: number[];
}>();
for (const apprenant of apprenantsData) {
const code = apprenant.codeEtablissement;
if (!etablissementsMap.has(code)) {
etablissementsMap.set(code, {
codeEtablissement: code,
nombreApprenants: 0,
apprenantsActifs: 0,
apprenantIds: [],
});
}
const etab = etablissementsMap.get(code)!;
etab.nombreApprenants++;
etab.apprenantIds.push(apprenant.id);
}
// Pour chaque établissement, compter les apprenants actifs
const etablissementsStats = await Promise.all(
Array.from(etablissementsMap.values()).map(async (etab) => {
// Compter le nombre d'apprenants ayant au moins une inscription
const apprenantsActifsCount = await Promise.all(
etab.apprenantIds.map(async (apprenantId) => {
const inscriptionsData = await db
.select()
.from(inscriptions)
.where(eq(inscriptions.apprenantId, apprenantId))
.limit(1);
return inscriptionsData.length > 0 ? 1 : 0;
})
);
const nombreActifs = apprenantsActifsCount.reduce((sum, count) => sum + count, 0);
const tauxParticipation = etab.nombreApprenants > 0
? Math.round((nombreActifs / etab.nombreApprenants) * 100)
: 0;
return {
codeEtablissement: etab.codeEtablissement,
nombreApprenants: etab.nombreApprenants,
apprenantsActifs: nombreActifs,
tauxParticipation,
};
})
);
// Trier par nombre d'apprenants décroissant
return etablissementsStats.sort((a, b) => b.nombreApprenants - a.nombreApprenants);
}
export async function getEtablissementDetail(codeEtablissement: string) {
const db = await getDb();
if (!db) return null;
// Récupérer tous les apprenants de cet établissement
const apprenantsData = await db
.select()
.from(apprenants)
.where(eq(apprenants.codeEtablissement, codeEtablissement));
// Pour chaque apprenant, récupérer ses inscriptions
const apprenantsWithInscriptions = await Promise.all(
apprenantsData.map(async (apprenant) => {
const inscriptionsData = await db
.select()
.from(inscriptions)
.where(eq(inscriptions.apprenantId, apprenant.id));
return {
...apprenant,
inscriptions: inscriptionsData,
};
})
);
const nombreApprenants = apprenantsData.length;
const apprenantsActifs = apprenantsWithInscriptions.filter(
(a) => a.inscriptions.length > 0
).length;
const tauxParticipation = nombreApprenants > 0
? Math.round((apprenantsActifs / nombreApprenants) * 100)
: 0;
return {
codeEtablissement,
nombreApprenants,
apprenantsActifs,
tauxParticipation,
apprenants: apprenantsWithInscriptions,
};
}

View File

@@ -890,6 +890,21 @@ export const appRouter = router({
}; };
}), }),
}), }),
// ===== ÉTABLISSEMENTS =====
etablissements: router({
list: adminProcedure.query(async () => {
const { getEtablissementsStats } = await import("./etablissementsDb");
return getEtablissementsStats();
}),
getDetail: adminProcedure
.input(z.object({ codeEtablissement: z.string() }))
.query(async ({ input }) => {
const { getEtablissementDetail } = await import("./etablissementsDb");
return getEtablissementDetail(input.codeEtablissement);
}),
}),
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View File

@@ -560,3 +560,10 @@
- [x] Afficher l'icône corbeille en rouge - [x] Afficher l'icône corbeille en rouge
- [x] Afficher l'icône œil en bleu - [x] Afficher l'icône œil en bleu
- [x] Coloriser la fonction (Directeurs, Chefs de service) comme dans les séquences - [x] Coloriser la fonction (Directeurs, Chefs de service) comme dans les séquences
## Badges de statut apprenants et vue établissements
- [x] Ajouter des badges "Actif" (vert) / "Inactif" (gris) dans la liste des apprenants
- [x] Créer les requêtes backend pour récupérer les statistiques par établissement
- [x] Créer une page "Établissements" avec liste et statistiques
- [x] Ajouter le lien dans le menu de navigation