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:
280
client/src/pages/AdminRapportPublicCible.tsx
Normal file
280
client/src/pages/AdminRapportPublicCible.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user