Files
formation-manager-itinova/client/src/pages/AdminAnalytics.tsx
Manus fc4df8ed36 Checkpoint: Correction des erreurs sur la page rapport-public-cible et harmonisation de la charte graphique des titres :
**Corrections d'erreurs :**

1. **Erreurs SQL DATE_FORMAT corrigées** (analyticsDb.ts) :
   - Remplacement des sous-requêtes corrélées par des JOIN dans getTauxRemplissageByMonth()
   - Utilisation de COUNT(CASE WHEN...) au lieu de SUM(SELECT COUNT(*))
   - Ajout de leftJoin pour éviter les erreurs de sous-requêtes

2. **Erreur React "Cannot read properties of null" corrigée** (AdminRapportPublicCible.tsx) :
   - Ajout de l'opérateur null-safe `?.` pour accéder à `insc.apprenant?.fonction`
   - Vérification explicite si la fonction est null/undefined
   - Gestion du cas "public cible = tous" qui correspond toujours

3. **Tests réussis** :
   - Page rapport-public-cible : affiche correctement les statistiques et recommandations
   - Tableau de bord analytique : graphiques et statistiques fonctionnels
   - Page suivi questionnaires : affiche correctement les données

**Harmonisation charte graphique :**

- Application du style bleu clair (#6B9FE8) avec ombre portée à tous les titres des 32 pages
- Création de la classe CSS `.page-title` réutilisable dans index.css
- Cohérence visuelle sur toute l'application

**Résultat :**
 Toutes les erreurs SQL et React sont corrigées
 Les pages fonctionnent correctement sans erreur
 Charte graphique harmonisée sur toutes les pages
2026-01-20 11:46:56 -05:00

400 lines
15 KiB
TypeScript

import { useState, useMemo } from "react";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { BarChart3, TrendingUp, Users, Building2, Calendar, PieChart, Download, FileText, FileSpreadsheet } from "lucide-react";
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
PieChart as RePieChart,
Pie,
Cell,
} from "recharts";
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
export default function AdminAnalytics() {
const [periodFilter, setPeriodFilter] = useState<string>("all");
// Calculer les dates de début et fin selon le filtre
const { startDate, endDate } = useMemo(() => {
const now = new Date();
let start: Date | undefined;
let end: Date | undefined = now;
switch (periodFilter) {
case "last_month":
start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
break;
case "last_quarter":
start = new Date(now.getFullYear(), now.getMonth() - 3, 1);
break;
case "last_year":
start = new Date(now.getFullYear() - 1, now.getMonth(), 1);
break;
case "all":
default:
start = undefined;
end = undefined;
}
return {
startDate: start?.toISOString(),
endDate: end?.toISOString(),
};
}, [periodFilter]);
const { data: globalStats, isLoading: loadingGlobal } = trpc.analytics.globalStats.useQuery();
const { data: inscriptionsByMonth, isLoading: loadingInscriptions } = trpc.analytics.inscriptionsByMonth.useQuery({
startDate,
endDate,
});
const { data: tauxRemplissage, isLoading: loadingTaux } = trpc.analytics.tauxRemplissageByMonth.useQuery();
const { data: participationEtablissement, isLoading: loadingEtablissement } = trpc.analytics.participationByEtablissement.useQuery();
const { data: participationFonction, isLoading: loadingFonction } = trpc.analytics.participationByFonction.useQuery();
// Formater les données pour les graphiques
const inscriptionsData = inscriptionsByMonth?.map(item => ({
mois: item.mois,
Confirmées: Number(item.confirmees),
"Liste d'attente": Number(item.listeAttente),
Annulées: Number(item.annulees),
Total: Number(item.total),
})) || [];
const tauxRemplissageData = tauxRemplissage?.map(item => ({
mois: item.mois,
"Taux de remplissage (%)": Number(item.tauxRemplissage) || 0,
"Capacité totale": Number(item.capaciteTotale),
"Inscrits confirmés": Number(item.inscritsConfirmes),
})) || [];
const etablissementData = participationEtablissement?.slice(0, 10).map(item => ({
etablissement: item.codeEtablissement,
"Total inscriptions": Number(item.totalInscriptions),
Confirmées: Number(item.inscriptionsConfirmees),
"Liste d'attente": Number(item.inscriptionsListeAttente),
})) || [];
const fonctionData = participationFonction?.map(item => ({
name: item.fonction === 'directeur' ? 'Directeurs' :
item.fonction === 'chef_service' ? 'Chefs de service' : 'Autres',
value: Number(item.totalApprenants),
confirmees: Number(item.inscriptionsConfirmees),
})) || [];
const exportExcelMutation = trpc.analytics.exportExcel.useMutation();
const exportPDFMutation = trpc.analytics.exportPDF.useMutation();
const handleExportExcel = async () => {
try {
toast.info("Génération du rapport Excel en cours...");
const result = await exportExcelMutation.mutateAsync({ startDate, endDate });
// Convertir base64 en blob et télécharger
const blob = new Blob(
[Uint8Array.from(atob(result.data), c => c.charCodeAt(0))],
{ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = result.filename;
a.click();
URL.revokeObjectURL(url);
toast.success("Rapport Excel téléchargé avec succès");
} catch (error: any) {
toast.error("Erreur lors de l'export Excel: " + error.message);
}
};
const handleExportPDF = async () => {
try {
toast.info("Génération du rapport PDF en cours...");
const result = await exportPDFMutation.mutateAsync({ startDate, endDate });
// Convertir base64 en blob et télécharger
const blob = new Blob(
[Uint8Array.from(atob(result.data), c => c.charCodeAt(0))],
{ type: 'application/pdf' }
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = result.filename;
a.click();
URL.revokeObjectURL(url);
toast.success("Rapport PDF téléchargé avec succès");
} catch (error: any) {
toast.error("L'export PDF sera implémenté dans une prochaine version");
}
};
return (
<DashboardLayout>
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="page-title">Tableau de bord analytique</h1>
<p className="text-muted-foreground">
Statistiques et indicateurs de performance des formations
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleExportPDF}>
<FileText className="h-4 w-4 mr-2" />
Export PDF
</Button>
<Button variant="outline" size="sm" onClick={handleExportExcel}>
<FileSpreadsheet className="h-4 w-4 mr-2" />
Export Excel
</Button>
</div>
</div>
{/* Filtres de période */}
<div className="flex items-center gap-4 mb-4">
<label className="text-sm font-medium">Période :</label>
<Select value={periodFilter} onValueChange={setPeriodFilter}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Sélectionner une période" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes les données</SelectItem>
<SelectItem value="last_month">Dernier mois</SelectItem>
<SelectItem value="last_quarter">Dernier trimestre</SelectItem>
<SelectItem value="last_year">Dernière année</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Statistiques globales */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Formations</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{globalStats?.totalFormations || 0}</div>
<p className="text-xs text-muted-foreground">
{globalStats?.totalSequences || 0} séquences au 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">Séquences ouvertes</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{globalStats?.sequencesOuvertes || 0}</div>
<p className="text-xs text-muted-foreground">
Disponibles pour inscription
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Apprenants</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{globalStats?.totalApprenants || 0}</div>
<p className="text-xs text-muted-foreground">
{globalStats?.totalInscriptions || 0} 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 remplissage</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{globalStats?.tauxRemplissageMoyen || 0}%</div>
<p className="text-xs text-muted-foreground">
Moyenne globale
</p>
</CardContent>
</Card>
</div>
{/* Graphiques */}
<div className="grid gap-6 md:grid-cols-2 mb-6">
{/* Évolution des inscriptions */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Évolution des inscriptions par mois
</CardTitle>
<CardDescription>
Répartition des inscriptions confirmées, en liste d'attente et annulées
</CardDescription>
</CardHeader>
<CardContent>
{loadingInscriptions ? (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Chargement...</p>
</div>
) : inscriptionsData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={inscriptionsData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="mois" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="Confirmées" stroke="#10b981" strokeWidth={2} />
<Line type="monotone" dataKey="Liste d'attente" stroke="#f59e0b" strokeWidth={2} />
<Line type="monotone" dataKey="Annulées" stroke="#ef4444" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Aucune donnée disponible</p>
</div>
)}
</CardContent>
</Card>
{/* Taux de remplissage */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Taux de remplissage par mois
</CardTitle>
<CardDescription>
Pourcentage de places occupées par rapport à la capacité totale
</CardDescription>
</CardHeader>
<CardContent>
{loadingTaux ? (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Chargement...</p>
</div>
) : tauxRemplissageData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={tauxRemplissageData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="mois" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="Taux de remplissage (%)" fill="#3b82f6" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Aucune donnée disponible</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* Participation par établissement et fonction */}
<div className="grid gap-6 md:grid-cols-2">
{/* Top 10 établissements */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5" />
Top 10 établissements
</CardTitle>
<CardDescription>
Établissements avec le plus d'inscriptions
</CardDescription>
</CardHeader>
<CardContent>
{loadingEtablissement ? (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Chargement...</p>
</div>
) : etablissementData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={etablissementData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis dataKey="etablissement" type="category" width={80} />
<Tooltip />
<Legend />
<Bar dataKey="Confirmées" fill="#10b981" />
<Bar dataKey="Liste d'attente" fill="#f59e0b" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Aucune donnée disponible</p>
</div>
)}
</CardContent>
</Card>
{/* Répartition par fonction */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChart className="h-5 w-5" />
Répartition par fonction
</CardTitle>
<CardDescription>
Distribution des inscriptions par type de fonction
</CardDescription>
</CardHeader>
<CardContent>
{loadingFonction ? (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Chargement...</p>
</div>
) : fonctionData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<RePieChart>
<Pie
data={fonctionData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{fonctionData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</RePieChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center">
<p className="text-muted-foreground">Aucune donnée disponible</p>
</div>
)}
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}