diff --git a/CHANGELOG.md b/CHANGELOG.md index aa6dc4f..f7c9fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ Toutes les évolutions notables de **Dalibi** sont consignées ici. Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le versionnage respecte [SemVer](https://semver.org/lang/fr/). +## [1.2.0] — 2026-09-03 + +Refonte du module **Statistiques** : chaque onglet gagne des graphiques plus lisibles, +accessibles (palette validée en vision déficiente, mode sombre), interactifs et, là où +c'est pertinent, ventilés par sexe et filtrables. + +### Ajouté +- **Comparaisons** : cartes de variation vs année précédente, séparation en deux lectures + claires — *Performance* (réussite, recouvrement) et *Déperdition* (redoublement, + abandon) — l'année en cours n'étant pas tracée pour les taux de fin d'année encore + indécis. Le jeu de démonstration sème deux années passées pour donner de vraies tendances. +- **Géographie** : treemap hiérarchique région → préfecture, une teinte par région + (nuancée selon l'effectif) et une légende ; KPI de concentration (couverture, part du + Grand Lomé, régions, préfectures). +- **Réussite & examens** : examens officiels (CEPD, BEPC, BAC) avec résultats + admis / échoué / absent et taux d'admission ; **répartition des mentions** enfin + alimentée (quatre paliers). +- **Effectifs & parité** : **pyramide des âges** divergente garçons / filles, **origine + géographique** (top villes) ventilée par sexe, et **filtre par cycle** (Maternelle / + Primaire / Collège / Lycée) avec tri sur le graphe des classes. +- **Statistiques élèves** (module Élèves) refondue : vrais graphiques thémés, mode sombre, + donut de parité, histogramme d'âge, effectifs par classe filtrables et empilés par sexe, + KPI d'âge moyen et de sur-âge. +- Âge attendu par classe renseigné, ce qui active le calcul du **sur-âge** (retard scolaire). + +### Corrigé +- **Répartition des mentions vide** : les libellés (« Très bien », « Assez bien »…) sont + désormais normalisés avant d'être agrégés sur les quatre paliers standard. +- **Lisibilité de la géographie** : couleurs par région (les faibles effectifs, en teinte + unique quasi blanche, étaient invisibles) et libellés du treemap en graisse normale. + ## [1.1.0] — 2026-09-03 ### Ajouté diff --git a/app/Exports/StatisticsExport.php b/app/Exports/StatisticsExport.php index 3949dca..f27bcf6 100644 --- a/app/Exports/StatisticsExport.php +++ b/app/Exports/StatisticsExport.php @@ -58,9 +58,9 @@ private function enrollment(): array ['Élèves en sur-âge', $d['over_age']['count']], ]), new SheetFromArray('Effectifs par classe', ['Classe', 'Garçons', 'Filles', 'Total'], - $this->rows($d['by_class'], fn ($c) => [$c->name, $c->male, $c->female, $c->total])), - new SheetFromArray('Origine (villes)', ['Ville', 'Élèves'], - $this->rows($d['by_city'], fn ($c) => [$c->city, $c->total])), + $this->rows($d['by_class'], fn ($c) => [$c['name'], $c['male'], $c['female'], $c['total']])), + new SheetFromArray('Origine (villes)', ['Ville', 'Garçons', 'Filles', 'Total'], + $this->rows($d['by_city'], fn ($c) => [$c['city'], $c['male'], $c['female'], $c['total']])), ]; } diff --git a/app/Http/Controllers/Eleves/StudentStatsController.php b/app/Http/Controllers/Eleves/StudentStatsController.php index edcebd3..5f8b80a 100644 --- a/app/Http/Controllers/Eleves/StudentStatsController.php +++ b/app/Http/Controllers/Eleves/StudentStatsController.php @@ -9,6 +9,7 @@ use App\Models\Student; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Inertia\Inertia; use Inertia\Response; @@ -66,8 +67,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,19 +80,53 @@ 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; - // Effectifs par classe (année sélectionnée, scolarité active) + // 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), enrichis du + // cycle, du sexe et de la capacité pour permettre filtre et empilage côté client. $byClass = $selectedYearId ? Enrollment::query() ->join('classes', 'classes.id', '=', 'enrollments.class_id') + ->join('students', 'students.id', '=', 'enrollments.student_id') + ->leftJoin('classroom_types', 'classroom_types.id', '=', 'classes.classroom_type_id') ->where('enrollments.academic_year_id', $selectedYearId) ->whereIn('enrollments.academic_status', Enrollment::ACTIVE_ACADEMIC_STATUSES) - ->select('classes.name as label', DB::raw('COUNT(*) as count')) - ->groupBy('classes.name') + ->selectRaw("classes.name AS label, classroom_types.name AS cycle, classes.capacity AS capacity, + classes.expected_age AS level, + COUNT(*) AS total, + COUNT(CASE WHEN students.gender = 'male' THEN 1 END) AS male, + COUNT(CASE WHEN students.gender = 'female' THEN 1 END) AS female") + ->groupBy('classes.name', 'classroom_types.name', 'classes.capacity', 'classes.expected_age') + ->orderByRaw('classes.expected_age NULLS LAST') ->orderBy('classes.name') ->get() - ->map(fn ($r) => ['label' => $r->label, 'count' => (int) $r->count]) + ->map(fn ($r) => [ + 'label' => $r->label, + 'cycle' => $this->cycleLabel($r->cycle), + 'capacity' => (int) $r->capacity, + 'level' => $r->level !== null ? (int) $r->level : null, + 'count' => (int) $r->total, + 'male' => (int) $r->male, + 'female' => (int) $r->female, + ]) : collect(); return Inertia::render('Eleves/Students/Stats', [ @@ -103,8 +140,30 @@ 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, ]); } + + /** Cycle court à partir du libellé du type de classe (pour le filtre). */ + private function cycleLabel(?string $type): string + { + $t = Str::lower($type ?? ''); + + return match (true) { + str_contains($t, 'maternelle') => 'Maternelle', + str_contains($t, 'primaire') => 'Primaire', + str_contains($t, 'collège') => 'Collège', + str_contains($t, 'technique') => 'Lycée technique', + str_contains($t, 'lycée') => 'Lycée', + default => 'Autre', + }; + } } diff --git a/app/Services/StatisticsService.php b/app/Services/StatisticsService.php index b0f3dbc..681de02 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, @@ -47,16 +48,25 @@ public function enrollmentStats(array $filters): array $other = (int) ($byGender['other'] ?? 0); $total = $male + $female + $other; - // Effectifs par classe + // Effectifs par classe (avec cycle, pour le filtre côté client) $byClass = (clone $base) ->join('classes', 'enrollments.class_id', '=', 'classes.id') - ->selectRaw('classes.name AS name, + ->leftJoin('classroom_types', 'classroom_types.id', '=', 'classes.classroom_type_id') + ->selectRaw('classes.name AS name, classroom_types.name AS cycle, COUNT(DISTINCT CASE WHEN students.gender = ? THEN students.id END) AS male, COUNT(DISTINCT CASE WHEN students.gender = ? THEN students.id END) AS female, COUNT(DISTINCT students.id) AS total', ['male', 'female']) - ->groupBy('classes.name') - ->orderByDesc('total') - ->get(); + ->groupBy('classes.name', 'classroom_types.name', 'classes.expected_age') + ->orderByRaw('classes.expected_age NULLS LAST') + ->orderBy('classes.name') + ->get() + ->map(fn ($r) => [ + 'name' => $r->name, + 'cycle' => $this->cycleLabel($r->cycle), + 'male' => (int) $r->male, + 'female' => (int) $r->female, + 'total' => (int) $r->total, + ]); // Répartition par statut académique (promotion / redoublement / abandon) $status = (clone $base) @@ -72,28 +82,48 @@ public function enrollmentStats(array $filters): array $decided = $valide + $nonValide + $abandon; // décisions de fin d'année $enrolTotal = $valide + $nonValide + $abandon + $transfere + $enCours; - // Distribution des âges (calcul PHP, portable pgsql/sqlite) - $ages = (clone $base) + // Distribution des âges PAR SEXE (calcul PHP, portable pgsql/sqlite) — + // alimente la pyramide des âges garçons/filles. + $ageRows = (clone $base) ->whereNotNull('students.birth_date') - ->pluck('students.birth_date') - ->map(fn ($d) => Carbon::parse($d)->age) - ->filter(fn ($a) => $a >= 2 && $a <= 30); + ->get(['students.birth_date', 'students.gender']); $ageBuckets = []; - foreach ($ages as $age) { - $ageBuckets[$age] = ($ageBuckets[$age] ?? 0) + 1; + $ages = []; + foreach ($ageRows as $r) { + $age = Carbon::parse($r->birth_date)->age; + if ($age < 2 || $age > 30) { + continue; + } + $ages[] = $age; + $ageBuckets[$age] ??= ['male' => 0, 'female' => 0]; + if ($r->gender === 'male') { + $ageBuckets[$age]['male']++; + } elseif ($r->gender === 'female') { + $ageBuckets[$age]['female']++; + } } ksort($ageBuckets); - $ageDistribution = collect($ageBuckets)->map(fn ($n, $a) => ['age' => (int) $a, 'total' => $n])->values(); - - // Origine géographique (top villes) + $ageDistribution = collect($ageBuckets)->map(fn ($g, $a) => [ + 'age' => (int) $a, + 'male' => $g['male'], + 'female' => $g['female'], + 'total' => $g['male'] + $g['female'], + ])->values(); + $ages = collect($ages); + + // Origine géographique (top villes), ventilée par sexe $byCity = (clone $base) ->whereNotNull('students.city') ->where('students.city', '!=', '') - ->selectRaw('students.city AS city, COUNT(DISTINCT students.id) AS total') + ->selectRaw("students.city AS city, + COUNT(DISTINCT students.id) AS total, + COUNT(DISTINCT CASE WHEN students.gender = 'male' THEN students.id END) AS male, + COUNT(DISTINCT CASE WHEN students.gender = 'female' THEN students.id END) AS female") ->groupBy('students.city') ->orderByDesc('total') ->limit(10) - ->get(); + ->get() + ->map(fn ($r) => ['city' => $r->city, 'male' => (int) $r->male, 'female' => (int) $r->female, 'total' => (int) $r->total]); $rate = fn (int $n, int $d) => $d > 0 ? round($n / $d * 100, 1) : 0.0; @@ -237,11 +267,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); @@ -466,7 +508,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]); @@ -497,4 +539,19 @@ public function section(string $section, array $filters): array default => $this->enrollmentStats($filters), }; } + + /** Cycle court à partir du libellé du type de classe (pour le filtre des charts). */ + private function cycleLabel(?string $type): string + { + $t = Str::lower($type ?? ''); + + return match (true) { + str_contains($t, 'maternelle') => 'Maternelle', + str_contains($t, 'primaire') => 'Primaire', + str_contains($t, 'collège') => 'Collège', + str_contains($t, 'technique') => 'Lycée technique', + str_contains($t, 'lycée') => 'Lycée', + default => 'Autre', + }; + } } diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index def0594..6ef9195 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' => [ @@ -166,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()); @@ -181,6 +193,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.'); @@ -429,13 +442,43 @@ 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. */ 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', @@ -445,7 +488,7 @@ private function seedGradingConfig(): void 'class_weight' => 1, 'comp_weight' => 1, 'round_precision' => 2, - 'mentions' => GradingConfig::defaultMentions(), + 'mentions' => $mentions, ], ); @@ -1101,34 +1144,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; + } - foreach ($exams as $exam) { - $students = Enrollment::query() + $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, + ], + ); + + $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; - } - - OfficialExamRegistration::create([ + $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], + }; + + $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); } } } @@ -1808,4 +1896,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 => '', + }; + } } diff --git a/package.json b/package.json index b93871c..5e01440 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://www.schemastore.org/package.json", "private": true, - "version": "1.1.0", + "version": "1.2.0", "type": "module", "scripts": { "build": "vite build", diff --git a/resources/js/pages/Eleves/Students/Stats.tsx b/resources/js/pages/Eleves/Students/Stats.tsx index 0592eaa..d6712ee 100644 --- a/resources/js/pages/Eleves/Students/Stats.tsx +++ b/resources/js/pages/Eleves/Students/Stats.tsx @@ -1,57 +1,113 @@ 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 { useMemo, useState } from 'react'; +import { + Bar, BarChart, CartesianGrid, Cell, LabelList, Legend, 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 ClassRow { label: string; cycle: string; capacity: number; level: number | null; count: number; male: number; female: number } +interface YearRef { id: string; year: string } interface Props { summary: { enrolled: number; active: number; inactive: number; classes: number }; byGender: { male: number; female: number }; byNationality: Bar[]; byAge: Bar[]; - byClass: Bar[]; + byClass: ClassRow[]; + 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); +/** Infobulle riche du graphe classes : total, capacité (taux de remplissage), sexe. */ +function ClassTooltip({ active, payload, theme, blue, orange }: Readonly<{ + active?: boolean; payload?: { payload: ClassRow }[]; + theme: ReturnType; blue: string; orange: string; +}>) { + if (!active || !payload?.length) return null; + const d = payload[0].payload; + const fill = d.capacity > 0 ? Math.round((d.count / d.capacity) * 100) : null; return ( -
-

{title}

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

Aucune donnée

- ) : ( -
- {items.map((i, idx) => ( -
-
- {i.label} - {i.count} -
-
-
-
-
- ))} +
+
{d.label}
+
Total : {d.count}{d.capacity > 0 ? ` / ${d.capacity} places${fill != null ? ` · ${fill}%` : ''}` : ''}
+
Garçons : {d.male}
+
Filles : {d.female}
+
+ ); +} + +function Kpi({ label, value, sub, icon: Icon, tone }: Readonly<{ label: string; value: string | number; sub?: string; icon: React.ElementType; tone: string }>) { + return ( +
+
+
+

{label}

+

{value}

+ {sub &&

{sub}

}
- )} +
+ +
+
); } -export default function Stats({ summary, byGender, byNationality, byAge, byClass, academicYears, selectedYear }: Readonly) { +function Card({ title, icon, children }: Readonly<{ title: string; icon?: React.ReactNode; children: React.ReactNode }>) { + return ( +
+
{icon}

{title}

+ {children} +
+ ); +} + +/** 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 })); + + // Filtres (côté client, instantané) : cycle + tri. byClass arrive déjà ordonné + // par niveau, donc « Par niveau » conserve l'ordre reçu. + const cycles = useMemo(() => [...new Set(byClass.map((c) => c.cycle))], [byClass]); + const [cycle, setCycle] = useState('Tous'); + const [sort, setSort] = useState<'niveau' | 'effectif'>('niveau'); + const classData = useMemo(() => { + const filtered = byClass.filter((c) => cycle === 'Tous' || c.cycle === cycle); + return sort === 'effectif' ? [...filtered].sort((a, b) => b.count - a.count) : filtered; + }, [byClass, cycle, sort]); + const classChartHeight = Math.max(200, classData.length * 26 + 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 +116,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.

- +