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 (
+
+
+
+ );
+ }
+
+ 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