From 6c5833192ebe1d80e49c8f60d2a19686eaa3b2f3 Mon Sep 17 00:00:00 2001 From: horacioskrp Date: Thu, 3 Sep 2026 17:37:36 +0000 Subject: [PATCH 01/11] =?UTF-8?q?feat(seeders):=20ann=C3=A9es=20pass=C3=A9?= =?UTF-8?q?es=20pour=20l'onglet=20Comparaisons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'onglet « Comparaisons » compare les années académiques, mais la démo n'en avait qu'une : les tendances n'affichaient qu'un point. DemoSeeder génère désormais deux années révolues (2023-2024, 2024-2025) avec juste ce qu'il faut pour alimenter les agrégats pluriannuels — inscriptions (statuts décidés → redoublement/abandon), factures (recouvrement), bulletins verrouillés (réussite) et examen officiel (admission) — via des insertions directes, sans repasser par tout le pipeline d'évaluation. Progression volontaire vers l'année courante (effectif et réussite en hausse, redoublement et abandon en baisse). --- database/seeders/DemoSeeder.php | 218 ++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index def0594..f982494 100644 --- a/database/seeders/DemoSeeder.php +++ b/database/seeders/DemoSeeder.php @@ -92,6 +92,17 @@ class DemoSeeder extends Seeder /** Effectif plancher pour toute classe non listée dans CLASS_SIZES. */ private const MIN_CLASS_SIZE = 20; + /** + * Années passées à générer pour l'onglet « Comparaisons » : sans historique, + * les tendances pluriannuelles n'ont qu'un point. Chaque ligne fixe les cibles + * agrégées de l'année (en %), avec une progression volontaire vers l'année + * courante (effectif et réussite en hausse, redoublement et abandon en baisse). + */ + private const HISTORY = [ + ['year' => '2023-2024', 'effectif' => 360, 'pass' => 63, 'recovery' => 69, 'redoublement' => 15, 'abandon' => 7, 'admission' => 70], + ['year' => '2024-2025', 'effectif' => 440, 'pass' => 67, 'recovery' => 73, 'redoublement' => 12, 'abandon' => 5, 'admission' => 76], + ]; + /** Programme par cycle : code matière => coefficient. */ private const CURRICULUM = [ 'primaire' => [ @@ -181,6 +192,7 @@ public function run(): void $this->step('Personnel et paie', fn () => $this->seedPayroll()); $this->step('Dossiers courants', fn () => $this->seedCasework()); $this->step('Bulletins', fn () => $this->seedReportCards()); + $this->step('Années passées (comparaisons)', fn () => $this->seedHistory()); $this->command?->newLine(); $this->command?->info('Jeu de démonstration prêt.'); @@ -1808,4 +1820,210 @@ private function seedReportCards(): void $builder->build($class, $period, $this->year, null, false, $author); } } + + /* ------------------------------------------------------------------ */ + /* Années passées (onglet Comparaisons) */ + /* ------------------------------------------------------------------ */ + + /** + * Génère des années scolaires révolues avec juste ce qu'il faut pour alimenter + * les tendances pluriannuelles : inscriptions (statuts décidés → taux de + * redoublement/abandon), factures (recouvrement), bulletins verrouillés + * (réussite) et un examen officiel (admission). Les valeurs suivent les cibles + * de {@see self::HISTORY}. Insertions directes, sans repasser par tout le + * pipeline d'évaluation — inutile pour des agrégats d'archive. + */ + private function seedHistory(): void + { + $studentIds = Student::query()->pluck('id')->all(); + $students = Student::query()->get(['id', 'lastname', 'firstname'])->keyBy('id'); + $classIds = $this->classes->pluck('id')->all(); + $classe3e = $this->classes->firstWhere('code', '3ème') ?? $this->classes->first(); + $author = User::query()->role('administrateur')->value('id') ?? User::query()->value('id'); + + if ($studentIds === [] || $classIds === []) { + return; + } + + foreach (self::HISTORY as $h) { + if (AcademicYear::query()->where('year', $h['year'])->exists()) { + continue; // idempotent + } + + $start = (int) substr($h['year'], 0, 4); + + $year = AcademicYear::create([ + 'year' => $h['year'], + 'start_date' => $start . '-09-15', + 'end_date' => ($start + 1) . '-07-10', + 'active' => false, + ]); + + $period = AcademicPeriod::create([ + 'name' => 'Bilan annuel', + 'type' => 'trimestre', + 'weight' => 1, + 'start_date' => $start . '-09-15', + 'end_date' => ($start + 1) . '-07-10', + 'is_current' => false, + 'academic_year_id' => $year->id, + ]); + + $cohort = collect($studentIds)->shuffle()->take(min($h['effectif'], count($studentIds)))->values(); + + $enrollRows = []; + $invRows = []; + $rcRows = []; + + foreach ($cohort as $i => $sid) { + $n = $i + 1; + $classId = $classIds[$n % count($classIds)]; + $enrollId = (string) Str::uuid7(); + $invId = (string) Str::uuid7(); + [$total, $paid, $invStatus] = $this->historyInvoice($h); + $average = $this->historyAverage($h); + $st = $students->get($sid); + + $enrollRows[] = [ + 'id' => $enrollId, + 'school_id' => $this->school->id, + 'student_id' => $sid, + 'class_id' => $classId, + 'academic_year_id' => $year->id, + 'enrollment_code' => 'HINS-' . $start . '-' . str_pad((string) $n, 4, '0', STR_PAD_LEFT), + 'enrolled_by' => $author, + 'enrollment_date' => $start . '-09-15', + 'status' => Enrollment::STATUS_ACTIVE, + 'academic_status' => $this->historyStatus($h), + 'created_at' => now(), + 'updated_at' => now(), + ]; + + $invRows[] = [ + 'id' => $invId, + 'enrollment_id' => $enrollId, + 'invoice_number' => 'HINV-' . $start . '-' . str_pad((string) $n, 4, '0', STR_PAD_LEFT), + 'subtotal' => $total, + 'discount_amount' => 0, + 'total' => $total, + 'amount_paid' => $paid, + 'amount_remaining' => $total - $paid, + 'status' => $invStatus, + 'issued_at' => $start . '-10-01', + 'created_at' => now(), + 'updated_at' => now(), + ]; + + $rcRows[] = [ + 'id' => (string) Str::uuid7(), + 'student_id' => $sid, + 'academic_period_id' => $period->id, + 'class_id' => $classId, + 'academic_year_id' => $year->id, + 'reference' => 'HRC-' . $start . '-' . str_pad((string) $n, 4, '0', STR_PAD_LEFT), + 'average' => $average, + 'rank' => null, + 'mention' => $this->historyMention($average), + 'payload' => json_encode([ + 'historique' => true, + 'student' => ['name' => trim(($st->lastname ?? '') . ' ' . ($st->firstname ?? ''))], + 'average' => $average, + ], JSON_UNESCAPED_UNICODE), + 'locked_at' => now(), + 'generated_by' => $author, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + foreach (array_chunk($enrollRows, 500) as $chunk) { + DB::table('enrollments')->insert($chunk); + } + foreach (array_chunk($invRows, 500) as $chunk) { + DB::table('invoices')->insert($chunk); + } + foreach (array_chunk($rcRows, 500) as $chunk) { + DB::table('report_cards')->insert($chunk); + } + + // Examen officiel de fin d'année + admissions (taux cible). + $exam = OfficialExam::create([ + 'school_id' => $this->school->id, + 'type' => 'bepc', + 'name' => 'BEPC ' . ($start + 1), + 'year' => $start + 1, + 'session' => 'normale', + 'exam_date' => ($start + 1) . '-06-15', + 'center' => 'Lycée de Tokoin', + 'status' => 'termine', + 'academic_year_id' => $year->id, + 'class_id' => $classe3e->id, + ]); + + $regRows = []; + foreach ($cohort->take(60) as $j => $sid) { + $admis = $this->faker->numberBetween(1, 100) <= $h['admission']; + $regRows[] = [ + 'id' => (string) Str::uuid7(), + 'official_exam_id' => $exam->id, + 'student_id' => $sid, + 'registration_number' => 'BEPC-' . ($start + 1) . '-' . str_pad((string) ($j + 1), 4, '0', STR_PAD_LEFT), + 'status' => $admis ? 'admis' : 'echoue', + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + DB::table('official_exam_registrations')->insert($regRows); + } + } + + /** Statut de fin d'année, tiré pour approcher les taux cibles de l'année. */ + private function historyStatus(array $h): string + { + $roll = $this->faker->numberBetween(1, 100); + + return match (true) { + $roll <= $h['abandon'] => 'abandon', + $roll <= $h['abandon'] + 3 => 'transfere', + $roll <= $h['abandon'] + 3 + $h['redoublement'] => 'non_valide', + default => 'valide', + }; + } + + /** + * Facture d'archive : montant fixe, part payée tirée autour du taux de + * recouvrement cible pour que l'agrégat de l'année tombe juste. + * + * @return array{0: int, 1: int, 2: string} + */ + private function historyInvoice(array $h): array + { + $total = 150000; + $share = min(1.0, max(0.0, $h['recovery'] / 100 + $this->gaussian() * 0.18)); + $paid = (int) round($total * $share); + + $status = $paid >= $total ? 'PAID' : ($paid > 0 ? 'PARTIALLY_PAID' : 'ISSUED'); + + return [$total, $paid, $status]; + } + + /** Moyenne d'archive : au-dessus ou en dessous de 10 selon le taux de réussite cible. */ + private function historyAverage(array $h): float + { + return $this->faker->numberBetween(1, 100) <= $h['pass'] + ? round($this->faker->randomFloat(2, 10, 16.5), 2) + : round($this->faker->randomFloat(2, 4, 9.75), 2); + } + + /** Mention d'archive, dérivée de la moyenne (schéma examens officiels). */ + private function historyMention(float $average): string + { + return match (true) { + $average >= 16 => 'tres_bien', + $average >= 14 => 'bien', + $average >= 12 => 'assez_bien', + $average >= 10 => 'passable', + default => '', + }; + } } From 1a8514e31a0ba3d1700b69c3106b5a5fb7131cfc Mon Sep 17 00:00:00 2001 From: horacioskrp Date: Thu, 3 Sep 2026 17:37:50 +0000 Subject: [PATCH 02/11] =?UTF-8?q?feat(stats):=20refonte=20des=20charts=20C?= =?UTF-8?q?omparaisons=20&=20G=C3=A9ographie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comparaisons — ajoute une rangée de KPI avec variation vs année précédente (couleur selon le sens : hausse d'effectif/réussite = vert, hausse d'abandon = rouge). Sépare l'ancien graphe « taux » fourre-tout en deux lectures : « Performance » (réussite, recouvrement) et « Déperdition » (redoublement, abandon). L'année en cours n'est pas tracée pour les taux de fin d'année (non encore décidés) au lieu de plonger à zéro. Tooltips thémés (lisibles en sombre) et palette validée (fin du rouge codé en dur). Géographie — remplace les deux barres bleues déconnectées par un treemap hiérarchique région → préfecture : l'aire porte la magnitude, une rampe séquentielle l'intensité, les libellés l'identité (jamais une couleur par région, indistinguable en daltonisme au-delà de trois ou quatre). Ajoute des KPI de concentration (couverture, part du Grand Lomé, régions, préfectures) et conserve une barre par région. Le backend renvoie les 40 préfectures (au lieu du top 20) pour que la hiérarchie du treemap soit complète et cohérente. --- app/Services/StatisticsService.php | 2 +- resources/js/pages/Statistiques/Index.tsx | 265 ++++++++++++++++++---- 2 files changed, 220 insertions(+), 47 deletions(-) diff --git a/app/Services/StatisticsService.php b/app/Services/StatisticsService.php index b0f3dbc..0c6a819 100644 --- a/app/Services/StatisticsService.php +++ b/app/Services/StatisticsService.php @@ -466,7 +466,7 @@ public function geographyStats(array $filters): array ->selectRaw('students.prefecture AS name, students.region AS region, COUNT(DISTINCT students.id) AS total') ->groupBy('students.prefecture', 'students.region') ->orderByDesc('total') - ->limit(20) + ->limit(40) // les 40 préfectures du Togo : la vue hiérarchique (treemap) doit être complète. ->get() ->map(fn ($r) => ['name' => $r->name, 'region' => $r->region, 'total' => (int) $r->total]); diff --git a/resources/js/pages/Statistiques/Index.tsx b/resources/js/pages/Statistiques/Index.tsx index 5771936..55f8a74 100644 --- a/resources/js/pages/Statistiques/Index.tsx +++ b/resources/js/pages/Statistiques/Index.tsx @@ -2,8 +2,8 @@ import { Head, router } from '@inertiajs/react'; import { BarChart3, Download, FileSpreadsheet, GraduationCap, Layers, MapPin, PieChart as PieIcon, School, TrendingUp, UserCheck, Users, Wallet } from 'lucide-react'; import { useState } from 'react'; import { - Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, Pie, PieChart, - ResponsiveContainer, Tooltip as RTooltip, XAxis, YAxis, + Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, LabelList, Legend, Line, LineChart, Pie, PieChart, + ResponsiveContainer, Tooltip as RTooltip, Treemap, XAxis, YAxis, } from 'recharts'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -109,6 +109,55 @@ function Card({ title, icon, children }: { title: string; icon?: React.ReactNode ); } +/** + * Nœud du treemap géographique. La région (depth 1) est un cadre étiqueté ; la + * préfecture (feuille) est remplie par une rampe séquentielle selon son effectif + * (magnitude → teinte, jamais une couleur par région, indistinguable au-delà de + * trois ou quatre catégories). L'identité passe par les libellés. + */ +function TreemapNode(props: { + x?: number; y?: number; width?: number; height?: number; depth?: number; + name?: string; value?: number; sequential?: readonly string[]; surface?: string; + axis?: string; tick?: string; maxLeaf?: number; +}) { + const { x = 0, y = 0, width = 0, height = 0, depth = 0, name = '', value = 0 } = props; + const seq = props.sequential ?? ['#bfdbfe', '#60a5fa', '#2a78d6', '#1e3a8a']; + const surface = props.surface ?? '#fff'; + + // Racine : ne rien peindre, sinon un grand rectangle recouvrirait tout le treemap. + if (depth === 0) { + return ; + } + + if (depth === 1) { + return ( + + + {width > 64 && height > 18 && ( + {name} + )} + + ); + } + + const ratio = props.maxLeaf && props.maxLeaf > 0 ? value / props.maxLeaf : 0; + const idx = Math.min(seq.length - 1, Math.max(0, Math.round(ratio * (seq.length - 1)))); + const onDark = idx >= seq.length - 2; + const label = name.length > 15 ? `${name.slice(0, 14)}…` : name; + + return ( + + + {width > 46 && height > 26 && ( + <> + {label} + {value} + + )} + + ); +} + /* ---------------- Page ---------------- */ type Tab = 'effectifs' | 'finances' | 'reussite' | 'encadrement' | 'assiduite' | 'comparaisons' | 'geographie'; @@ -159,6 +208,52 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol { key: 'geographie', label: 'Géographie', icon: MapPin }, ]; + /* ---- Comparaisons : l'année active est en cours, ses taux de fin d'année + (redoublement, abandon, admission) ne sont pas encore décidés. On les met à + null pour que les courbes s'arrêtent au lieu de plonger à zéro. ---- */ + const activeYearLabel = academicYears.find((y) => y.active)?.year; + type TrendKey = 'effectif' | 'part_filles' | 'redoublement' | 'abandon' | 'recouvrement' | 'reussite' | 'admission'; + const endOfYearKeys: TrendKey[] = ['redoublement', 'abandon', 'admission']; + const trendSeries = trends.series.map((p) => { + const inProgress = p.year === activeYearLabel; + return { + ...p, + redoublement: inProgress ? null : p.redoublement, + abandon: inProgress ? null : p.abandon, + admission: inProgress ? null : p.admission, + } as Record; + }); + // Dernier point renseigné vs le précédent, par métrique (les taux de fin + // d'année sautent l'année en cours ; les autres non). + const metricDelta = (key: TrendKey) => { + const pts = trendSeries.filter((p) => p[key] != null); + const cur = pts[pts.length - 1]; + const prv = pts[pts.length - 2]; + if (!cur) return null; + const value = cur[key] as number; + const delta = prv ? Math.round((value - (prv[key] as number)) * 10) / 10 : null; + return { year: cur.year as string, value, delta }; + }; + const isPct = (k: TrendKey) => k !== 'effectif'; + const trendKpis: { key: TrendKey; label: string; higherIsBetter: boolean }[] = [ + { key: 'effectif', label: 'Effectif', higherIsBetter: true }, + { key: 'reussite', label: 'Réussite', higherIsBetter: true }, + { key: 'recouvrement', label: 'Recouvrement', higherIsBetter: true }, + { key: 'abandon', label: 'Abandon', higherIsBetter: false }, + ]; + + /* ---- Géographie : concentration et hiérarchie région → préfecture. ---- */ + const grandLomeNames = ['Golfe', 'Agoè-Nyivé']; + const grandLomeTotal = geography.by_prefecture.filter((p) => grandLomeNames.includes(p.name)).reduce((s, p) => s + p.total, 0); + const grandLomeShare = geography.localized > 0 ? Math.round((grandLomeTotal / geography.localized) * 1000) / 10 : 0; + const maxLeaf = geography.by_prefecture.reduce((m, p) => Math.max(m, p.total), 0); + const treemapData = geography.by_region + .map((r) => ({ + name: r.name, + children: geography.by_prefecture.filter((p) => p.region === r.name).map((p) => ({ name: p.name, size: p.total })), + })) + .filter((r) => r.children.length > 0); + return ( @@ -458,37 +553,88 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol {/* ---- Comparaisons pluriannuelles ---- */} {tab === 'comparaisons' && (
- {trends.series.length < 2 && ( + {trends.series.length < 2 ? (
Les tendances se précisent avec au moins deux années académiques renseignées.
+ ) : ( +
+ {trendKpis.map(({ key, label, higherIsBetter }) => { + const d = metricDelta(key); + if (!d) return null; + const up = d.delta != null && d.delta > 0; + const good = d.delta == null ? null : up === higherIsBetter; + const deltaColor = good == null ? 'text-gray-400' : good ? 'text-emerald-600' : 'text-red-500'; + return ( +
+

{label}

+

+ {isPct(key) ? `${d.value}%` : d.value} +

+

+ {d.delta == null ? ( + + ) : ( + + {up ? '↑' : d.delta < 0 ? '↓' : '='} {d.delta > 0 ? '+' : ''}{d.delta}{isPct(key) ? ' pts' : ''} + + )} + · {d.year} +

+
+ ); + })} +
)} +
}> - + + + + + + + + - - - + + + - }> + }> - + + - - + + `${v}%`} /> - - - - + +
+ + }> + + + + + + `${v}%`} /> + + + + + +

L'année en cours n'est pas tracée : les décisions de fin d'année (redoublement, abandon) ne sont pas encore arrêtées.

+
+ }>
@@ -496,12 +642,17 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol - {trends.series.map((r) => ( - - - - - ))} + {trendSeries.map((r) => { + const pct = (v: number | string | null) => (v == null ? '—' : `${v}%`); + return ( + + + + + + + ); + })}
AnnéeEffectif% filles Redoubl.AbandonRecouvr. RéussiteAdmission
{r.year}{r.effectif}{r.part_filles}%{r.redoublement}%{r.abandon}%{r.recouvrement}%{r.reussite}%{r.admission}%
{r.year}{r.year === activeYearLabel && en cours}{r.effectif}{r.part_filles}%{pct(r.redoublement)}{pct(r.abandon)}{pct(r.recouvrement)}{pct(r.reussite)}{pct(r.admission)}
@@ -511,39 +662,61 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol {/* ---- Géographie ---- */} {tab === 'geographie' && (
-
- Origine renseignée pour {geography.coverage}% des élèves ({geography.localized} / {geography.total}). Complétez la région/préfecture sur la fiche élève pour affiner. -
{geography.by_region.length === 0 ? ( -
- Aucune origine géographique renseignée. -
+ <> +
+ Origine renseignée pour {geography.coverage}% des élèves ({geography.localized} / {geography.total}). Complétez la région/préfecture sur la fiche élève pour affiner. +
+
+ Aucune origine géographique renseignée. +
+ ) : ( -
- }> - {/* Six régions dépassent ce qu'une palette catégorielle peut distinguer - de façon sûre : on passe en barres étiquetées, une seule teinte. */} - - + <> +
+ + + + +
+ + }> + {/* Hiérarchie région → préfecture : l'aire porte la magnitude, la teinte + (rampe séquentielle) l'intensité, les libellés l'identité — jamais une + couleur par région, indistinguable au-delà de trois ou quatre. */} + + } + > + [`${v} élèves`, 'Effectif']} /> + + +
+ Effectif : + faible + élevé +
+
+ + }> + + - - + [`${v} élèves`, 'Effectif']} /> + + + -
}> - - - - - - - - -
-
+ )}
)} From 7148b73f68e4b84fe5c27234aff18bd99a917d7e Mon Sep 17 00:00:00 2001 From: horacioskrp Date: Thu, 3 Sep 2026 17:55:23 +0000 Subject: [PATCH 03/11] =?UTF-8?q?feat(stats):=20treemap=20g=C3=A9ographiqu?= =?UTF-8?q?e=20color=C3=A9=20par=20r=C3=A9gion=20(lisibilit=C3=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La rampe séquentielle à teinte unique rendait les préfectures à faible effectif presque invisibles (bleu très clair sur fond blanc) et ne distinguait pas les régions. Chaque région reçoit désormais sa propre teinte, déclinée en trois nuances selon l'effectif de la préfecture ; une légende de régions accompagne le treemap et les barres « par région » reprennent les mêmes couleurs. Palette soutenue passée au validateur daltonien de la méthode : contraste ≥ 3:1 sur fond clair et sombre, séparation en vision normale 23,7, et le seul écart en protanopie (ambre↔vert, ΔE 6,2) tombe dans la bande autorisée par les encodages secondaires présents — position, légende, libellés et barres. --- resources/js/pages/Statistiques/Index.tsx | 101 ++++++++++++++-------- 1 file changed, 67 insertions(+), 34 deletions(-) diff --git a/resources/js/pages/Statistiques/Index.tsx b/resources/js/pages/Statistiques/Index.tsx index 55f8a74..ea0961c 100644 --- a/resources/js/pages/Statistiques/Index.tsx +++ b/resources/js/pages/Statistiques/Index.tsx @@ -109,49 +109,52 @@ function Card({ title, icon, children }: { title: string; icon?: React.ReactNode ); } +/** Contraste : texte blanc sur fond foncé, encre sombre sur fond clair. */ +function isDarkHex(hex: string): boolean { + const c = hex.replace('#', ''); + if (c.length < 6) return false; + const r = parseInt(c.slice(0, 2), 16); + const g = parseInt(c.slice(2, 4), 16); + const b = parseInt(c.slice(4, 6), 16); + return 0.299 * r + 0.587 * g + 0.114 * b < 150; +} + /** - * Nœud du treemap géographique. La région (depth 1) est un cadre étiqueté ; la - * préfecture (feuille) est remplie par une rampe séquentielle selon son effectif - * (magnitude → teinte, jamais une couleur par région, indistinguable au-delà de - * trois ou quatre catégories). L'identité passe par les libellés. + * Nœud du treemap géographique. Chaque région porte sa propre teinte (identité), + * la préfecture en reçoit une nuance selon son effectif (magnitude). Position, + * libellés et légende des régions restent des encodages secondaires qui + * sécurisent la lecture. Les couleurs finales sont précalculées en amont et + * passées par les tables `regionColor` / `prefColor`. */ function TreemapNode(props: { - x?: number; y?: number; width?: number; height?: number; depth?: number; - name?: string; value?: number; sequential?: readonly string[]; surface?: string; - axis?: string; tick?: string; maxLeaf?: number; + x?: number; y?: number; width?: number; height?: number; depth?: number; name?: string; value?: number; + surface?: string; regionColor?: Record; prefColor?: Record; }) { const { x = 0, y = 0, width = 0, height = 0, depth = 0, name = '', value = 0 } = props; - const seq = props.sequential ?? ['#bfdbfe', '#60a5fa', '#2a78d6', '#1e3a8a']; - const surface = props.surface ?? '#fff'; + const surface = props.surface ?? '#ffffff'; // Racine : ne rien peindre, sinon un grand rectangle recouvrirait tout le treemap. if (depth === 0) { return ; } + // Région : liseré de sa couleur, pour regrouper visuellement ses préfectures. if (depth === 1) { - return ( - - - {width > 64 && height > 18 && ( - {name} - )} - - ); + const accent = props.regionColor?.[name] ?? '#94a3b8'; + return ; } - const ratio = props.maxLeaf && props.maxLeaf > 0 ? value / props.maxLeaf : 0; - const idx = Math.min(seq.length - 1, Math.max(0, Math.round(ratio * (seq.length - 1)))); - const onDark = idx >= seq.length - 2; + const fill = props.prefColor?.[name] ?? '#94a3b8'; + const onDark = isDarkHex(fill); const label = name.length > 15 ? `${name.slice(0, 14)}…` : name; return ( - + {width > 46 && height > 26 && ( <> - {label} - {value} + {label} + {value} )} @@ -246,7 +249,32 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol const grandLomeNames = ['Golfe', 'Agoè-Nyivé']; const grandLomeTotal = geography.by_prefecture.filter((p) => grandLomeNames.includes(p.name)).reduce((s, p) => s + p.total, 0); const grandLomeShare = geography.localized > 0 ? Math.round((grandLomeTotal / geography.localized) * 1000) / 10 : 0; - const maxLeaf = geography.by_prefecture.reduce((m, p) => Math.max(m, p.total), 0); + + // Une teinte par région (identité), déclinée en trois nuances selon l'effectif + // de la préfecture (magnitude). Le nom de région porte l'identité ; la position + // et la légende sécurisent la lecture, donc des couleurs distinctes sont sûres. + // Teintes soutenues (validées : contraste >= 3:1 sur fond clair et sombre, et + // séparation daltonienne dans la bande autorisée avec encodage secondaire). + const regionFamilies: Record = { + Maritime: ['#60a5fa', '#2563eb', '#1e40af'], // bleu + Plateaux: ['#4ade80', '#16a34a', '#166534'], // vert + Centrale: ['#fbbf24', '#d97706', '#92400e'], // ambre + Kara: ['#a78bfa', '#7c3aed', '#5b21b6'], // violet + Savanes: ['#f472b6', '#db2777', '#9d174d'], // rose + }; + const fallbackFamily: [string, string, string] = ['#cbd5e1', '#64748b', '#334155']; + const regionColor: Record = {}; + const prefColor: Record = {}; + geography.by_region.forEach((r) => { + const fam = regionFamilies[r.name] ?? fallbackFamily; + regionColor[r.name] = fam[1]; + const prefs = geography.by_prefecture.filter((p) => p.region === r.name); + const regionMax = prefs.reduce((m, p) => Math.max(m, p.total), 0) || 1; + prefs.forEach((p) => { + const idx = Math.min(2, Math.max(0, Math.round((p.total / regionMax) * 2))); + prefColor[p.name] = fam[idx]; + }); + }); const treemapData = geography.by_region .map((r) => ({ name: r.name, @@ -681,25 +709,29 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol
}> - {/* Hiérarchie région → préfecture : l'aire porte la magnitude, la teinte - (rampe séquentielle) l'intensité, les libellés l'identité — jamais une - couleur par région, indistinguable au-delà de trois ou quatre. */} - + {/* Hiérarchie région → préfecture : l'aire porte la magnitude, une teinte + par région porte l'identité (déclinée en nuances selon l'effectif), et la + position + la légende + les libellés sécurisent la lecture. */} + } + content={} > [`${v} élèves`, 'Effectif']} /> -
- Effectif : - faible - élevé +
+ {geography.by_region.map((r) => ( + + + {r.name} + + ))} + · nuance = effectif de la préfecture
@@ -710,7 +742,8 @@ export default function StatisticsIndex({ filters, academicYears, classes, enrol [`${v} élèves`, 'Effectif']} /> - + + {geography.by_region.map((r) => )} From 7a9ab8755a48c0a47ddfd58fe3b48bc12fed27da Mon Sep 17 00:00:00 2001 From: horacioskrp Date: Thu, 3 Sep 2026 18:42:02 +0000 Subject: [PATCH 04/11] =?UTF-8?q?fix(stats):=20libell=C3=A9s=20du=20treema?= =?UTF-8?q?p=20en=20graisse=20normale=20(lisibilit=C3=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/js/pages/Statistiques/Index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/pages/Statistiques/Index.tsx b/resources/js/pages/Statistiques/Index.tsx index ea0961c..06af22d 100644 --- a/resources/js/pages/Statistiques/Index.tsx +++ b/resources/js/pages/Statistiques/Index.tsx @@ -153,8 +153,8 @@ function TreemapNode(props: { {width > 46 && height > 26 && ( <> - {label} - {value} + {label} + {value} )} From 4ad02a993af9914be55d05fd8f9521aba9753d2d Mon Sep 17 00:00:00 2001 From: horacioskrp Date: Thu, 3 Sep 2026 18:56:04 +0000 Subject: [PATCH 05/11] =?UTF-8?q?fix(stats):=20normaliser=20les=20libell?= =?UTF-8?q?=C3=A9s=20de=20mentions=20pour=20la=20r=C3=A9partition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La répartition des mentions agrégeait sur des clés normalisées (passable/assez_bien/bien/tres_bien) mais compare au libellé brut stocké dans le bulletin (« Très bien », « Assez bien »…). Résultat : répartition vide dès que le barème n'utilise pas exactement ces slugs. Les libellés sont désormais normalisés (accents, casse, ponctuation ignorés) avant d'être repliés sur les quatre paliers. --- app/Services/StatisticsService.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/Services/StatisticsService.php b/app/Services/StatisticsService.php index 0c6a819..76a88d5 100644 --- a/app/Services/StatisticsService.php +++ b/app/Services/StatisticsService.php @@ -4,6 +4,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; /** * Agrégations statistiques (socle V1) : effectifs & parité, finances & recouvrement, @@ -237,11 +238,23 @@ public function successStats(array $filters): array COUNT(CASE WHEN average >= 10 THEN 1 END) AS pass_count ')->first(); - $mentions = (clone $rc) + // Les mentions sont stockées telles qu'affichées ("Très bien", "Assez bien"…), + // selon le barème de l'école. On les replie vers les quatre paliers standard + // en ignorant accents, casse et ponctuation — sinon la répartition reste vide + // dès que le libellé n'est pas exactement la clé normalisée. + $mentionRows = (clone $rc) ->whereNotNull('mention')->where('mention', '!=', '') ->selectRaw('mention, COUNT(*) AS total') ->groupBy('mention') - ->pluck('total', 'mention'); + ->get(); + + $mentions = ['passable' => 0, 'assez_bien' => 0, 'bien' => 0, 'tres_bien' => 0]; + foreach ($mentionRows as $row) { + $key = Str::of($row->mention)->ascii()->lower()->replaceMatches('/[^a-z0-9]+/', '_')->trim('_')->value(); + if (array_key_exists($key, $mentions)) { + $mentions[$key] += (int) $row->total; + } + } $rcTotal = (int) ($rcStats->total ?? 0); From d243da1f57e11b38b373e3eeac92ebd0d2308469 Mon Sep 17 00:00:00 2001 From: horacioskrp Date: Thu, 3 Sep 2026 18:56:04 +0000 Subject: [PATCH 06/11] =?UTF-8?q?feat(seeders):=20examens=20officiels=20av?= =?UTF-8?q?ec=20r=C3=A9sultats=20et=20bar=C3=A8me=204=20mentions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alimente l'onglet Réussite & examens pour l'année en cours : - barème de démo basculé sur les quatre mentions standard (Passable → Très bien), ce qu'agrège la répartition des mentions ; - examens officiels CEPD, BEPC et BAC de l'année active, avec inscriptions des classes concernées (CM2, 3ème, Terminale) et résultats admis/échoué/absent selon un taux d'admission plausible par examen. Idempotent : ne refait rien si des résultats existent déjà. --- database/seeders/DemoSeeder.php | 95 ++++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 20 deletions(-) diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index f982494..f8d9bae 100644 --- a/database/seeders/DemoSeeder.php +++ b/database/seeders/DemoSeeder.php @@ -447,7 +447,17 @@ private function togoSurname(): string */ private function seedGradingConfig(): void { - GradingConfig::firstOrCreate( + // Les quatre mentions standard (Passable → Très bien) plutôt que le schéma + // « honneurs » par défaut : c'est ce que la répartition des mentions des + // statistiques agrège, et ce que porte un bulletin togolais courant. + $mentions = [ + ['label' => 'Très bien', 'min' => 16], + ['label' => 'Bien', 'min' => 14], + ['label' => 'Assez bien', 'min' => 12], + ['label' => 'Passable', 'min' => 10], + ]; + + GradingConfig::updateOrCreate( ['school_id' => $this->school->id, 'classroom_type_id' => null], [ 'name' => 'Barème par défaut', @@ -457,7 +467,7 @@ private function seedGradingConfig(): void 'class_weight' => 1, 'comp_weight' => 1, 'round_precision' => 2, - 'mentions' => GradingConfig::defaultMentions(), + 'mentions' => $mentions, ], ); @@ -1113,34 +1123,79 @@ private function seedNoteReclamations(): void } /** Inscriptions aux examens officiels enregistrés, pour la classe concernée. */ + /** + * Examens officiels de l'année en cours (CEPD, BEPC, BAC), avec leurs résultats : + * les élèves des classes d'examen (CM2, 3ème, Terminale) sont inscrits puis + * admis / échoués / absents selon un taux d'admission plausible par examen. + */ private function seedExamRegistrations(): void { - $exams = OfficialExam::query()->where('academic_year_id', $this->year->id)->get(); + $blueprint = [ + ['type' => 'cepd', 'name' => 'CEPD', 'class' => 'CM2', 'center' => 'EPP Tokoin', 'admis' => 88, 'serie' => null], + ['type' => 'bepc', 'name' => 'BEPC', 'class' => '3ème', 'center' => 'CEG Tokoin', 'admis' => 78, 'serie' => null], + ['type' => 'bac', 'name' => 'BAC II', 'class' => 'Tle D', 'center' => 'Lycée de Tokoin', 'admis' => 72, 'serie' => 'D'], + ]; + $examYear = (int) substr($this->year->year, 5, 4); + + // Idempotent : si des résultats existent déjà pour l'année, on ne refait rien. + // Sinon, on repart propre (efface d'éventuelles inscriptions « à blanc »). + $existingExamIds = OfficialExam::query()->where('academic_year_id', $this->year->id)->pluck('id'); + if (OfficialExamRegistration::query()->whereIn('official_exam_id', $existingExamIds)->whereIn('status', ['admis', 'echoue', 'absent'])->exists()) { + return; + } + OfficialExamRegistration::query()->whereIn('official_exam_id', $existingExamIds)->delete(); + + foreach ($blueprint as $b) { + $class = $this->classes->firstWhere('code', $b['class']); + + if (! $class) { + continue; + } + + $exam = OfficialExam::updateOrCreate( + ['type' => $b['type'], 'year' => $examYear, 'academic_year_id' => $this->year->id], + [ + 'school_id' => $this->school->id, + 'name' => $b['name'], + 'session' => 'normale', + 'exam_date' => $examYear . '-06-15', + 'center' => $b['center'], + 'status' => 'termine', + 'class_id' => $class->id, + ], + ); - foreach ($exams as $exam) { - $students = Enrollment::query() + $studentIds = Enrollment::query() ->where('academic_year_id', $this->year->id) - ->when($exam->class_id, fn ($q) => $q->where('class_id', $exam->class_id)) + ->where('class_id', $class->id) ->active() ->pluck('student_id'); - foreach ($students as $index => $studentId) { - $exists = OfficialExamRegistration::query() - ->where('official_exam_id', $exam->id) - ->where('student_id', $studentId) - ->exists(); - - if ($exists) { - continue; - } + $rows = []; + foreach ($studentIds as $index => $studentId) { + $draw = $this->faker->numberBetween(1, 100); + [$status, $average, $mention] = match (true) { + $draw <= 3 => ['absent', null, null], + $draw <= $b['admis'] + 3 => ['admis', $avg = round($this->faker->randomFloat(2, 10, 17), 2), $this->historyMention($avg)], + default => ['echoue', round($this->faker->randomFloat(2, 6, 9.75), 2), null], + }; - OfficialExamRegistration::create([ + $rows[] = [ + 'id' => (string) Str::uuid7(), 'official_exam_id' => $exam->id, 'student_id' => $studentId, - 'registration_number' => Str::upper($exam->type) . '-' . $exam->year . '-' . str_pad((string) ($index + 1), 4, '0', STR_PAD_LEFT), - 'serie' => null, - 'status' => 'inscrit', - ]); + 'registration_number' => Str::upper($b['type']) . '-' . $examYear . '-' . str_pad((string) ($index + 1), 4, '0', STR_PAD_LEFT), + 'serie' => $b['serie'], + 'status' => $status, + 'average' => $average, + 'mention' => $mention, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + foreach (array_chunk($rows, 500) as $chunk) { + DB::table('official_exam_registrations')->insert($chunk); } } } From 9d40415af22872f55935926576a5ebd57b966823 Mon Sep 17 00:00:00 2001 From: horacioskrp Date: Thu, 3 Sep 2026 19:08:54 +0000 Subject: [PATCH 07/11] =?UTF-8?q?feat(seeders):=20=C3=A2ge=20attendu=20par?= =?UTF-8?q?=20classe=20(calcul=20du=20sur-=C3=A2ge)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les classes n'avaient pas d'\''âge attendu (colonne expected_age), si bien que le taux de sur-âge des statistiques restait vide. DemoSeeder renseigne désormais l'\''âge normal par niveau (CP1 = 6 ans … Terminale = 18), de façon idempotente. --- database/seeders/DemoSeeder.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index f8d9bae..6ef9195 100644 --- a/database/seeders/DemoSeeder.php +++ b/database/seeders/DemoSeeder.php @@ -177,6 +177,7 @@ public function run(): void return; } + $this->step('Âges attendus des classes', fn () => $this->seedExpectedAges()); $this->step('Barème et modèle de bulletin', fn () => $this->seedGradingConfig()); $this->step('Enseignants', fn () => $this->seedTeachers()); $this->step('Élèves et inscriptions', fn () => $this->seedStudentsAndEnrollments()); @@ -441,6 +442,26 @@ private function togoSurname(): string /* Socle pédagogique */ /* ------------------------------------------------------------------ */ + /** + * Âge normal attendu par classe (l'entrée en CP1 se fait vers 6 ans). Alimente + * le calcul du sur-âge (retard scolaire) des statistiques, resté vide sans lui. + * Idempotent : ne touche que les classes dont l'âge attendu n'est pas renseigné. + */ + private function seedExpectedAges(): void + { + $ages = [ + 'PS' => 3, 'MS' => 4, 'GS' => 5, + 'CP1' => 6, 'CP2' => 7, 'CE1' => 8, 'CE2' => 9, 'CM1' => 10, 'CM2' => 11, + '6ème' => 12, '5ème' => 13, '4ème' => 14, '3ème' => 15, + '2nd A' => 16, '2nd S' => 16, '1ère A4' => 17, '1ère D' => 17, '1ère C' => 17, + 'Tle A4' => 18, 'Tle D' => 18, 'Tle C' => 18, + ]; + + foreach ($ages as $code => $age) { + Classroom::query()->where('code', $code)->whereNull('expected_age')->update(['expected_age' => $age]); + } + } + /** * Barème de notation et modèle de bulletin. * Sans configuration active, moyennes et bulletins n'ont aucune règle de calcul. From 41f8b0d02800f2977ecd35481aabcaaacf51fea7 Mon Sep 17 00:00:00 2001 From: horacioskrp Date: Thu, 3 Sep 2026 19:08:54 +0000 Subject: [PATCH 08/11] =?UTF-8?q?feat(stats):=20refonte=20de=20la=20page?= =?UTF-8?q?=20Statistiques=20=C3=A9l=C3=A8ves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La page utilisait des barres CSS maison, sans mode sombre, avec des couleurs hors palette (rose/violet/emerald codés en dur) — un cran sous la page Statistiques (école). - Vrais graphiques Recharts thémés via chart-theme : donut de répartition par sexe, histogramme des tranches d'\''âge (rampe séquentielle), barres d'\''effectifs par classe triées par effectif — tous avec survol et mode sombre. - Palette CVD-validée (Garçons bleu / Filles orange, comme la page école) au lieu du bleu/rose genré. - KPI enrichis : âge moyen et taux de sur-âge (retard scolaire) en plus des effectifs, parité (% filles + IPS) mise en avant sur le donut. - La vue nationalité (100 % togolaise, sans intérêt) laisse place à la parité. --- .../Eleves/StudentStatsController.php | 31 ++- resources/js/pages/Eleves/Students/Stats.tsx | 201 ++++++++++++------ 2 files changed, 161 insertions(+), 71 deletions(-) diff --git a/app/Http/Controllers/Eleves/StudentStatsController.php b/app/Http/Controllers/Eleves/StudentStatsController.php index edcebd3..5373c0d 100644 --- a/app/Http/Controllers/Eleves/StudentStatsController.php +++ b/app/Http/Controllers/Eleves/StudentStatsController.php @@ -66,8 +66,10 @@ public function index(\Illuminate\Http\Request $request): Response '15 à 18 ans' => 0, 'Plus de 18 ans' => 0, ]; + $ages = []; foreach (Student::whereIn('id', $studentIds)->whereNotNull('birth_date')->pluck('birth_date') as $dob) { - $age = Carbon::parse($dob)->age; + $age = Carbon::parse($dob)->age; + $ages[] = $age; $key = match (true) { $age < 6 => 'Moins de 6 ans', $age <= 10 => '6 à 10 ans', @@ -77,7 +79,25 @@ public function index(\Illuminate\Http\Request $request): Response }; $brackets[$key]++; } - $byAge = collect($brackets)->map(fn ($count, $label) => ['label' => $label, 'count' => $count])->values(); + $byAge = collect($brackets)->map(fn ($count, $label) => ['label' => $label, 'count' => $count])->values(); + $ageMoyen = $ages !== [] ? round(array_sum($ages) / count($ages), 1) : null; + + // Parité (indice IPS = filles / garçons). + $femalePct = $total > 0 ? round($byGender['female'] / $total * 100, 1) : 0.0; + $ips = $byGender['male'] > 0 ? round($byGender['female'] / $byGender['male'], 2) : null; + + // Sur-âge (retard scolaire) : âge de l'élève >= âge attendu de sa classe + 2. + $overAgeThreshold = 2; + $ageRows = Enrollment::query() + ->join('classes', 'classes.id', '=', 'enrollments.class_id') + ->join('students', 'students.id', '=', 'enrollments.student_id') + ->where('enrollments.academic_year_id', $selectedYearId) + ->whereIn('enrollments.academic_status', Enrollment::ACTIVE_ACADEMIC_STATUSES) + ->whereNotNull('students.birth_date') + ->whereNotNull('classes.expected_age') + ->get(['students.birth_date', 'classes.expected_age']); + $overEval = $ageRows->count(); + $overCount = $ageRows->filter(fn ($r) => (Carbon::parse($r->birth_date)->age - (int) $r->expected_age) >= $overAgeThreshold)->count(); // Effectifs par classe (année sélectionnée, scolarité active) $byClass = $selectedYearId @@ -103,6 +123,13 @@ public function index(\Illuminate\Http\Request $request): Response 'byNationality' => $byNationality, 'byAge' => $byAge, 'byClass' => $byClass, + 'parite' => ['female_pct' => $femalePct, 'ips' => $ips], + 'ageMoyen' => $ageMoyen, + 'overAge' => [ + 'evaluated' => $overEval, + 'count' => $overCount, + 'rate' => $overEval > 0 ? round($overCount / $overEval * 100, 1) : 0.0, + ], 'academicYears' => $academicYears->map(fn ($y) => ['id' => $y->id, 'year' => $y->year])->values(), 'selectedYear' => $selectedYear ? ['id' => $selectedYear->id, 'year' => $selectedYear->year] : null, ]); diff --git a/resources/js/pages/Eleves/Students/Stats.tsx b/resources/js/pages/Eleves/Students/Stats.tsx index 0592eaa..5e5ceb5 100644 --- a/resources/js/pages/Eleves/Students/Stats.tsx +++ b/resources/js/pages/Eleves/Students/Stats.tsx @@ -1,11 +1,15 @@ import { Head, router } from '@inertiajs/react'; -import { GraduationCap, Users, UserCheck, UserX, BarChart3, Layers } from 'lucide-react'; +import { BarChart3, CalendarClock, GraduationCap, Layers, UserCheck, Users } from 'lucide-react'; +import { + Bar, BarChart, CartesianGrid, Cell, LabelList, Pie, PieChart, + ResponsiveContainer, Tooltip as RTooltip, XAxis, YAxis, +} from 'recharts'; import { route } from '@/helpers/route'; import AppLayout from '@/layouts/app-layout'; +import { useChartTheme } from '@/lib/chart-theme'; -interface Bar { label: string; count: number; } - -interface YearRef { id: string; year: string; } +interface Bar { label: string; count: number } +interface YearRef { id: string; year: string } interface Props { summary: { enrolled: number; active: number; inactive: number; classes: number }; @@ -13,45 +17,68 @@ interface Props { byNationality: Bar[]; byAge: Bar[]; byClass: Bar[]; + parite: { female_pct: number; ips: number | null }; + ageMoyen: number | null; + overAge: { evaluated: number; count: number; rate: number }; academicYears: YearRef[]; selectedYear: YearRef | null; } -function BarList({ title, items, color }: Readonly<{ title: string; items: Bar[]; color: string }>) { - const max = Math.max(...items.map(i => i.count), 1); +function Kpi({ label, value, sub, icon: Icon, tone }: Readonly<{ label: string; value: string | number; sub?: string; icon: React.ElementType; tone: string }>) { return ( -
-

{title}

- {items.length === 0 || items.every(i => i.count === 0) ? ( -

Aucune donnée

- ) : ( -
- {items.map((i, idx) => ( -
-
- {i.label} - {i.count} -
-
-
-
-
- ))} +
+
+
+

{label}

+

{value}

+ {sub &&

{sub}

}
- )} +
+ +
+
+
+ ); +} + +function Card({ title, icon, children }: Readonly<{ title: string; icon?: React.ReactNode; children: React.ReactNode }>) { + return ( +
+
{icon}

{title}

+ {children}
); } -export default function Stats({ summary, byGender, byNationality, byAge, byClass, academicYears, selectedYear }: Readonly) { +/** Libellés courts des tranches d'âge pour l'axe. */ +const AGE_SHORT: Record = { + 'Moins de 6 ans': '< 6', + '6 à 10 ans': '6–10', + '11 à 14 ans': '11–14', + '15 à 18 ans': '15–18', + 'Plus de 18 ans': '> 18', +}; + +export default function Stats({ summary, byGender, byAge, byClass, parite, ageMoyen, overAge, academicYears, selectedYear }: Readonly) { + const theme = useChartTheme(); + const [BLUE, ORANGE] = theme.series; + + // L'identité (sexe) est portée par la couleur de la catégorie, jamais par son rang. + const genderData = [ + { name: 'Garçons', value: byGender.male, color: BLUE }, + { name: 'Filles', value: byGender.female, color: ORANGE }, + ].filter((d) => d.value > 0); const genderTotal = byGender.male + byGender.female; - const malePct = genderTotal > 0 ? Math.round((byGender.male / genderTotal) * 100) : 0; + + const ageData = byAge.map((b) => ({ ...b, short: AGE_SHORT[b.label] ?? b.label })); + const classData = [...byClass].sort((a, b) => b.count - a.count); + const classChartHeight = Math.max(220, classData.length * 22 + 24); const cards = [ - { label: `Inscrits ${selectedYear?.year ?? ''}`, value: summary.enrolled, color: 'text-blue-600', icon: GraduationCap }, - { label: 'Actifs', value: summary.active, color: 'text-emerald-600', icon: UserCheck }, - { label: 'Inactifs', value: summary.inactive, color: 'text-gray-400', icon: UserX }, - { label: 'Classes', value: summary.classes, color: 'text-violet-600', icon: Layers }, + { label: `Inscrits ${selectedYear?.year ?? ''}`, value: summary.enrolled, sub: `${summary.classes} classes`, tone: 'text-blue-600', icon: GraduationCap }, + { label: 'Actifs', value: summary.active, sub: `${summary.inactive} inactifs`, tone: 'text-emerald-600', icon: UserCheck }, + { label: 'Âge moyen', value: ageMoyen != null ? `${ageMoyen} ans` : '—', sub: 'de la cohorte', tone: 'text-violet-600', icon: CalendarClock }, + { label: 'Sur-âge', value: `${overAge.rate}%`, sub: `${overAge.count} élèves en retard`, tone: 'text-amber-600', icon: Layers }, ]; const changeYear = (id: string) => @@ -60,20 +87,22 @@ export default function Stats({ summary, byGender, byNationality, byAge, byClass return ( -
+
-
+
-

Statistiques élèves

-

Vue démographique et effectifs des élèves inscrits pour l'année sélectionnée.

+

+ Statistiques élèves +

+

Vue démographique et effectifs des élèves inscrits pour l'année sélectionnée.

- +