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:
@@ -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} />
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
123
client/src/pages/AdminEtablissements.tsx
Normal file
123
client/src/pages/AdminEtablissements.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
server/db.ts
19
server/db.ts
@@ -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
110
server/etablissementsDb.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
7
todo.md
7
todo.md
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user