Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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é
Expand Down
6 changes: 3 additions & 3 deletions app/Exports/StatisticsExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']])),
];
}

Expand Down
71 changes: 65 additions & 6 deletions app/Http/Controllers/Eleves/StudentStatsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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',
Expand All @@ -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', [
Expand All @@ -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',
};
}
}
97 changes: 77 additions & 20 deletions app/Services/StatisticsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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;

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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',
};
}
}
Loading
Loading