diff --git a/client/src/App.tsx b/client/src/App.tsx index de2ba01..042f61f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,6 +5,7 @@ import { Route, Switch } from "wouter"; import ErrorBoundary from "./components/ErrorBoundary"; import { ThemeProvider } from "./contexts/ThemeContext"; import Home from "./pages/Home"; +import AdminRapportPublicCible from "./pages/AdminRapportPublicCible"; import Admin from "./pages/Admin"; import AdminFormations from "./pages/AdminFormations"; import AdminSequences from "./pages/AdminSequences"; @@ -16,6 +17,7 @@ function Router() { return ( + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 2cce05c..2f7d668 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -21,7 +21,7 @@ import { } from "@/components/ui/sidebar"; import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const"; import { useIsMobile } from "@/hooks/useMobile"; -import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar } from "lucide-react"; +import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, BarChart3 } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; @@ -32,6 +32,7 @@ const menuItems = [ { icon: GraduationCap, label: "Formations", path: "/admin/formations" }, { icon: Calendar, label: "Séquences", path: "/admin/sequences" }, { icon: Users, label: "Apprenants", path: "/admin/apprenants" }, + { icon: BarChart3, label: "Rapport Public Cible", path: "/admin/rapport-public-cible" }, ]; const SIDEBAR_WIDTH_KEY = "sidebar-width"; diff --git a/client/src/pages/AdminRapportPublicCible.tsx b/client/src/pages/AdminRapportPublicCible.tsx new file mode 100644 index 0000000..8bdae16 --- /dev/null +++ b/client/src/pages/AdminRapportPublicCible.tsx @@ -0,0 +1,280 @@ +import DashboardLayout from "@/components/DashboardLayout"; +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 { BarChart3, TrendingUp, AlertCircle } from "lucide-react"; +import { useMemo } from "react"; + +export default function AdminRapportPublicCible() { + const { data: sequences, isLoading: loadingSequences } = trpc.sequences.list.useQuery(); + const { data: inscriptions, isLoading: loadingInscriptions } = trpc.inscriptions.listAll.useQuery(); + + // Calculer les statistiques d'écart entre public cible et fonction des apprenants + const stats = useMemo(() => { + if (!sequences || !inscriptions) return null; + + const sequencesWithMismatch = sequences.map((sequence) => { + const sequenceInscriptions = inscriptions.filter( + (insc: any) => insc.inscription.sequenceId === sequence.id && insc.inscription.statut === "confirmee" + ); + + const totalInscrits = sequenceInscriptions.length; + const mismatchCount = sequenceInscriptions.filter((insc: any) => { + const apprenantFonction = insc.apprenant.fonction; + const sequencePublicCible = sequence.publicCible; + + // Mapper les fonctions aux publics cibles + const fonctionToPublicCible: Record = { + directeur: "directeur", + chef_service: "chef_service", + autre: "autre", + }; + + return fonctionToPublicCible[apprenantFonction] !== sequencePublicCible; + }).length; + + const matchRate = totalInscrits > 0 ? ((totalInscrits - mismatchCount) / totalInscrits) * 100 : 100; + + return { + sequence, + totalInscrits, + mismatchCount, + matchCount: totalInscrits - mismatchCount, + matchRate, + }; + }); + + // Statistiques globales + const totalInscrits = sequencesWithMismatch.reduce((sum, s) => sum + s.totalInscrits, 0); + const totalMismatches = sequencesWithMismatch.reduce((sum, s) => sum + s.mismatchCount, 0); + const globalMatchRate = totalInscrits > 0 ? ((totalInscrits - totalMismatches) / totalInscrits) * 100 : 100; + + return { + sequencesWithMismatch: sequencesWithMismatch.filter((s) => s.totalInscrits > 0), + totalInscrits, + totalMismatches, + totalMatches: totalInscrits - totalMismatches, + globalMatchRate, + }; + }, [sequences, inscriptions]); + + const getPublicCibleLabel = (publicCible: string) => { + const labels = { + directeur: "Directeur", + chef_service: "Chef de service", + autre: "Autre", + }; + return labels[publicCible as keyof typeof labels] || "Autre"; + }; + + const getPublicCibleBadge = (publicCible: string) => { + const colors = { + directeur: "bg-blue-100 text-blue-800", + chef_service: "bg-green-100 text-green-800", + autre: "bg-gray-100 text-gray-800", + }; + return colors[publicCible as keyof typeof colors] || colors.autre; + }; + + const getMatchRateBadge = (matchRate: number) => { + if (matchRate >= 80) return "bg-green-100 text-green-800"; + if (matchRate >= 50) return "bg-orange-100 text-orange-800"; + return "bg-red-100 text-red-800"; + }; + + if (loadingSequences || loadingInscriptions) { + return ( + +
+
Chargement...
+
+
+ ); + } + + if (!stats) { + return ( + +
+
Aucune donnée disponible
+
+
+ ); + } + + return ( + +
+
+

Rapport d'analyse Public Cible

+

+ Comparaison entre le public cible des séquences et la fonction des apprenants inscrits +

+
+ + {/* Statistiques globales */} +
+ + + Total Inscrits + + + +
{stats.totalInscrits}
+

Inscrits confirmés

+
+
+ + + + Correspondances + + + +
{stats.totalMatches}
+

+ {stats.globalMatchRate.toFixed(1)}% de correspondance +

+
+
+ + + + Écarts + + + +
{stats.totalMismatches}
+

+ {(100 - stats.globalMatchRate).toFixed(1)}% d'écart +

+
+
+ + + + Séquences Analysées + + + +
{stats.sequencesWithMismatch.length}
+

Avec inscrits confirmés

+
+
+
+ + {/* Tableau détaillé par séquence */} + + + Analyse détaillée par séquence + + Taux de correspondance entre le public cible et la fonction des apprenants inscrits + + + + {stats.sequencesWithMismatch.length === 0 ? ( +
+ Aucune séquence avec des inscrits confirmés +
+ ) : ( +
+ + + + Séquence + Public cible + Total Inscrits + Correspondances + Écarts + Taux de correspondance + + + + {stats.sequencesWithMismatch + .sort((a, b) => a.matchRate - b.matchRate) + .map((stat) => ( + + {stat.sequence.nom} + + + {getPublicCibleLabel(stat.sequence.publicCible)} + + + {stat.totalInscrits} + + {stat.matchCount} + + + {stat.mismatchCount} + + + + {stat.matchRate.toFixed(1)}% + + + + ))} + +
+
+ )} +
+
+ + {/* Recommandations */} + + + Recommandations + Actions suggérées pour optimiser la planification des formations + + +
+ {stats.globalMatchRate < 70 && ( +
+ +
+

Taux de correspondance faible

+

+ Le taux de correspondance global est inférieur à 70%. Considérez la création de séquences + spécifiques pour chaque public cible. +

+
+
+ )} + + {stats.sequencesWithMismatch.some((s) => s.matchRate < 50) && ( +
+ +
+

Séquences à revoir

+

+ Certaines séquences ont un taux de correspondance inférieur à 50%. Vérifiez si le public + cible est correctement défini ou si les apprenants sont inscrits aux bonnes séquences. +

+
+
+ )} + + {stats.globalMatchRate >= 80 && ( +
+ +
+

Excellente correspondance

+

+ Le taux de correspondance global est excellent (≥ 80%). La planification des formations + est bien alignée avec les publics cibles. +

+
+
+ )} +
+
+
+
+
+ ); +} diff --git a/client/src/pages/AdminSequenceInscrits.tsx b/client/src/pages/AdminSequenceInscrits.tsx index 14a4ed0..91ffc1a 100644 --- a/client/src/pages/AdminSequenceInscrits.tsx +++ b/client/src/pages/AdminSequenceInscrits.tsx @@ -16,6 +16,24 @@ export default function AdminSequenceInscrits() { const [, setLocation] = useLocation(); const [, params] = useRoute("/admin/sequences/:id/inscrits"); const sequenceId = params?.id ? parseInt(params.id) : 0; + + const getPublicCibleBadge = (publicCible: string) => { + const colors = { + directeur: "bg-blue-100 text-blue-800", + chef_service: "bg-green-100 text-green-800", + autre: "bg-gray-100 text-gray-800", + }; + return colors[publicCible as keyof typeof colors] || colors.autre; + }; + + const getPublicCibleLabel = (publicCible: string) => { + const labels = { + directeur: "Directeur", + chef_service: "Chef de service", + autre: "Autre", + }; + return labels[publicCible as keyof typeof labels] || "Autre"; + }; // États pour les filtres const [searchTerm, setSearchTerm] = useState(""); @@ -300,6 +318,12 @@ export default function AdminSequenceInscrits() { {nbConfirmes} / {sequence.capaciteMax} inscrits confirmés

+
+

Public cible

+ + {getPublicCibleLabel(sequence.publicCible)} + +
diff --git a/client/src/pages/AdminSequences.tsx b/client/src/pages/AdminSequences.tsx index b6ae21f..d5c5e2a 100644 --- a/client/src/pages/AdminSequences.tsx +++ b/client/src/pages/AdminSequences.tsx @@ -288,6 +288,24 @@ export default function AdminSequences() { return colors[statut as keyof typeof colors] || colors.ouverte; }; + const getPublicCibleBadge = (publicCible: string) => { + const colors = { + directeur: "bg-blue-100 text-blue-800", + chef_service: "bg-green-100 text-green-800", + autre: "bg-gray-100 text-gray-800", + }; + return colors[publicCible as keyof typeof colors] || colors.autre; + }; + + const getPublicCibleLabel = (publicCible: string) => { + const labels = { + directeur: "Directeur", + chef_service: "Chef de service", + autre: "Autre", + }; + return labels[publicCible as keyof typeof labels] || "Autre"; + }; + return (
@@ -620,7 +638,11 @@ export default function AdminSequences() {
{sequence.lieu} - {sequence.publicCible === "directeur" ? "Directeur" : sequence.publicCible === "chef_service" ? "Chef de service" : "Autre"} + + + {getPublicCibleLabel(sequence.publicCible)} + + {sequence.capaciteMax} diff --git a/server/db.ts b/server/db.ts index 2894292..fcac569 100644 --- a/server/db.ts +++ b/server/db.ts @@ -285,6 +285,20 @@ export async function createInscription(data: InsertInscription) { return result; } +export async function getAllInscriptions() { + const db = await getDb(); + if (!db) return []; + + const results = await db.select({ + inscription: inscriptions, + apprenant: apprenants, + }) + .from(inscriptions) + .leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id)); + + return results; +} + export async function getInscriptionsBySequence(sequenceId: number) { const db = await getDb(); if (!db) return []; diff --git a/server/routers.ts b/server/routers.ts index 44e45bb..cee4b5d 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -247,6 +247,10 @@ export const appRouter = router({ // ===== INSCRIPTIONS ===== inscriptions: router({ + listAll: adminProcedure.query(async () => { + return db.getAllInscriptions(); + }), + listBySequence: adminProcedure.input(z.object({ sequenceId: z.number() })).query(async ({ input }) => { return db.getInscriptionsBySequence(input.sequenceId); }), diff --git a/todo.md b/todo.md index 37d254a..2838f19 100644 --- a/todo.md +++ b/todo.md @@ -160,3 +160,17 @@ - [x] Ajouter le composant Select pour le filtre publicCible dans l'interface - [x] Implémenter la logique de filtrage par public cible - [x] Tester le filtre avec différentes valeurs + +## Badges de couleur pour le public cible + +- [x] Créer un composant Badge pour afficher le public cible avec des couleurs distinctes +- [x] Intégrer le Badge dans le tableau des séquences (AdminSequences) +- [x] Intégrer le Badge dans la page des inscrits (AdminSequenceInscrits) +- [x] Tester l'affichage des badges avec différents publics cibles + +## Rapport d'analyse public cible vs fonction appre- [x] Créer une nouvelle page AdminRapportPublicCible +- [x] Ajouter la route dans App.tsx +- [x] Ajouter un lien dans le menu de navigation (DashboardLayout- [x] Créer une procédure tRPC pour récupérer les données d'analyse +- [x] Implémenter la logique de comparaison public cible vs fonction apprenants +- [x] Afficher les statistiques et écarts dans l'interface +- [x] Tester le rapport avec différentes données