**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
400 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|