Checkpoint: Ajout des badges de couleur pour le public cible (bleu pour Directeurs, vert pour Chefs de service, gris pour Autre) dans les tableaux des séquences et création d'un rapport d'analyse comparant le public cible des séquences avec la fonction des apprenants inscrits pour identifier les écarts et optimiser la planification des formations

This commit is contained in:
Manus Sandbox
2025-11-17 08:44:49 -05:00
parent 01e2b8b2d4
commit 6f5178b909
8 changed files with 363 additions and 2 deletions

View File

@@ -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 (
<Switch>
<Route path={"/"} component={Home} />
<Route path={"/admin/rapport-public-cible"} component={AdminRapportPublicCible} />
<Route path={"/inscription/:lien"} component={Inscription} />
<Route path={"/admin"} component={Admin} />
<Route path={"/admin/formations"} component={AdminFormations} />

View File

@@ -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";

View File

@@ -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<string, string> = {
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 (
<DashboardLayout>
<div className="container mx-auto py-8">
<div className="text-center py-8 text-muted-foreground">Chargement...</div>
</div>
</DashboardLayout>
);
}
if (!stats) {
return (
<DashboardLayout>
<div className="container mx-auto py-8">
<div className="text-center py-8 text-muted-foreground">Aucune donnée disponible</div>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="container mx-auto py-8 space-y-6">
<div>
<h1 className="text-3xl font-bold">Rapport d'analyse Public Cible</h1>
<p className="text-muted-foreground mt-1">
Comparaison entre le public cible des séquences et la fonction des apprenants inscrits
</p>
</div>
{/* Statistiques globales */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Inscrits</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalInscrits}</div>
<p className="text-xs text-muted-foreground">Inscrits confirmés</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Correspondances</CardTitle>
<TrendingUp className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.totalMatches}</div>
<p className="text-xs text-muted-foreground">
{stats.globalMatchRate.toFixed(1)}% de correspondance
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Écarts</CardTitle>
<AlertCircle className="h-4 w-4 text-orange-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-orange-600">{stats.totalMismatches}</div>
<p className="text-xs text-muted-foreground">
{(100 - stats.globalMatchRate).toFixed(1)}% d'écart
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Séquences Analysées</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.sequencesWithMismatch.length}</div>
<p className="text-xs text-muted-foreground">Avec inscrits confirmés</p>
</CardContent>
</Card>
</div>
{/* Tableau détaillé par séquence */}
<Card>
<CardHeader>
<CardTitle>Analyse détaillée par séquence</CardTitle>
<CardDescription>
Taux de correspondance entre le public cible et la fonction des apprenants inscrits
</CardDescription>
</CardHeader>
<CardContent>
{stats.sequencesWithMismatch.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Aucune séquence avec des inscrits confirmés
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Séquence</TableHead>
<TableHead>Public cible</TableHead>
<TableHead className="text-right">Total Inscrits</TableHead>
<TableHead className="text-right">Correspondances</TableHead>
<TableHead className="text-right">Écarts</TableHead>
<TableHead className="text-right">Taux de correspondance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.sequencesWithMismatch
.sort((a, b) => a.matchRate - b.matchRate)
.map((stat) => (
<TableRow key={stat.sequence.id}>
<TableCell className="font-medium">{stat.sequence.nom}</TableCell>
<TableCell>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getPublicCibleBadge(stat.sequence.publicCible)}`}
>
{getPublicCibleLabel(stat.sequence.publicCible)}
</span>
</TableCell>
<TableCell className="text-right">{stat.totalInscrits}</TableCell>
<TableCell className="text-right text-green-600 font-medium">
{stat.matchCount}
</TableCell>
<TableCell className="text-right text-orange-600 font-medium">
{stat.mismatchCount}
</TableCell>
<TableCell className="text-right">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getMatchRateBadge(stat.matchRate)}`}
>
{stat.matchRate.toFixed(1)}%
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Recommandations */}
<Card>
<CardHeader>
<CardTitle>Recommandations</CardTitle>
<CardDescription>Actions suggérées pour optimiser la planification des formations</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{stats.globalMatchRate < 70 && (
<div className="flex gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
<AlertCircle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-900">Taux de correspondance faible</p>
<p className="text-sm text-red-700 mt-1">
Le taux de correspondance global est inférieur à 70%. Considérez la création de séquences
spécifiques pour chaque public cible.
</p>
</div>
</div>
)}
{stats.sequencesWithMismatch.some((s) => s.matchRate < 50) && (
<div className="flex gap-3 p-4 bg-orange-50 border border-orange-200 rounded-lg">
<AlertCircle className="h-5 w-5 text-orange-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-orange-900">Séquences à revoir</p>
<p className="text-sm text-orange-700 mt-1">
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.
</p>
</div>
</div>
)}
{stats.globalMatchRate >= 80 && (
<div className="flex gap-3 p-4 bg-green-50 border border-green-200 rounded-lg">
<TrendingUp className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-green-900">Excellente correspondance</p>
<p className="text-sm text-green-700 mt-1">
Le taux de correspondance global est excellent ( 80%). La planification des formations
est bien alignée avec les publics cibles.
</p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

View File

@@ -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
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Public cible</p>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getPublicCibleBadge(sequence.publicCible)}`}>
{getPublicCibleLabel(sequence.publicCible)}
</span>
</div>
</div>
<div>

View File

@@ -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 (
<DashboardLayout>
<div className="container mx-auto py-8 space-y-6">
@@ -620,7 +638,11 @@ export default function AdminSequences() {
</div>
</TableCell>
<TableCell className="max-w-xs truncate">{sequence.lieu}</TableCell>
<TableCell>{sequence.publicCible === "directeur" ? "Directeur" : sequence.publicCible === "chef_service" ? "Chef de service" : "Autre"}</TableCell>
<TableCell>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getPublicCibleBadge(sequence.publicCible)}`}>
{getPublicCibleLabel(sequence.publicCible)}
</span>
</TableCell>
<TableCell>{sequence.capaciteMax}</TableCell>
<TableCell>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatutBadge(sequence.statut)}`}>

View File

@@ -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 [];

View File

@@ -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);
}),

14
todo.md
View File

@@ -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