diff --git a/app/Commands/SyncPaypalPayments.php b/app/Commands/SyncPaypalPayments.php index 05667ca..ef23972 100644 --- a/app/Commands/SyncPaypalPayments.php +++ b/app/Commands/SyncPaypalPayments.php @@ -36,13 +36,13 @@ class SyncPaypalPayments extends BaseCommand $this->invoiceModel = new InvoiceModel(); $this->studentModel = new StudentModel(); $this->enrollmentModel = new EnrollmentModel(); - - $this->semester = $this->configModel->getConfig('semester'); - $this->schoolYear = $this->configModel->getConfig('school_year'); } public function run(array $params) { + $this->semester = (string) ($this->configModel->getConfig('semester') ?? ''); + $this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); + $dryRun = CLI::getOption('dry-run'); $reportOnly = CLI::getOption('report-only'); $mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE'); diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 1ec333d..9553317 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -174,6 +174,10 @@ $routes->group('administrator/subject-curriculum', ['filter' => 'auth:update_cur $routes->post('delete/(:num)', 'View\SubjectCurriculumController::delete/$1'); }); +$routes->get('administrator/trophy', 'View\TrophyController::index', ['filter' => 'auth:admin']); +$routes->get('administrator/trophy/winners', 'View\TrophyController::winners', ['filter' => 'auth:admin']); +$routes->get('administrator/trophy/final', 'View\TrophyController::final', ['filter' => 'auth:admin']); + /* diff --git a/app/Config/Services.php b/app/Config/Services.php index c68d299..68aba46 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -36,7 +36,7 @@ class Services extends BaseService /** * Override CI Email service to enforce a global Reply-To. */ - public static function email(array $config = null, bool $getShared = true) + public static function email(?array $config = null, bool $getShared = true) { if ($getShared) { return static::getSharedInstance('email', $config); diff --git a/app/Controllers/View/TrophyController.php b/app/Controllers/View/TrophyController.php new file mode 100644 index 0000000..719cf27 --- /dev/null +++ b/app/Controllers/View/TrophyController.php @@ -0,0 +1,504 @@ +configModel = new ConfigurationModel(); + } + + public function index() + { + $db = \Config\Database::connect(); + + $currentYear = $this->configModel->getConfig('school_year') ?? ''; + $selectedYear = $this->request->getGet('school_year') ?? $currentYear; + + $percentile = (float) ($this->request->getGet('percentile') ?? 75); + $percentile = max(1.0, min(99.0, $percentile)); + + // Available school years from class assignments so the current year can + // still appear even if fall scores are not fully entered yet. + $years = $db->table('student_class') + ->select('school_year') + ->distinct() + ->orderBy('school_year', 'DESC') + ->get() + ->getResultArray(); + $years = array_column($years, 'school_year'); + + // Build a fall-score-based projection. We start from class assignments so + // students without a recorded fall score still appear as "not yet + // projected" rather than disappearing from the page. + $rows = $db->table('student_class sc') + ->select([ + 'sc.student_id', + 'sc.class_section_id', + 'MAX(ss.semester_score) AS fall_score', + 'cs.class_section_name', + 'CONCAT(s.firstname, " ", s.lastname) AS student_name', + 's.school_id', + 's.gender', + ]) + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->join('students s', 's.id = sc.student_id', 'left') + ->join( + 'semester_scores ss', + 'ss.student_id = sc.student_id' + . ' AND ss.class_section_id = sc.class_section_id' + . ' AND ss.school_year = ' . $db->escape($selectedYear) + . ' AND LOWER(ss.semester) = "fall"', + 'left' + ) + ->where('sc.school_year', $selectedYear) + ->orderBy('cs.class_section_name', 'ASC') + ->orderBy('fall_score', 'DESC') + ->orderBy('student_name', 'ASC') + ->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id') + ->get() + ->getResultArray(); + + // Group rows by class section + $sections = []; + foreach ($rows as $row) { + $sid = (int) ($row['class_section_id'] ?? 0); + $name = $row['class_section_name'] ?? 'Section ' . $sid; + if (!isset($sections[$sid])) { + $sections[$sid] = ['name' => $name, 'students' => []]; + } + $sections[$sid]['students'][] = [ + 'student_id' => (int) $row['student_id'], + 'name' => $row['student_name'], + 'school_id' => $row['school_id'], + 'gender' => $row['gender'] ?? '', + 'fall_score' => is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null, + ]; + } + + // Calculate trophy thresholds from fall scores only, then project who is + // currently on track for a year-end trophy. + $classResults = []; + foreach ($sections as $sid => $section) { + usort($section['students'], static function (array $a, array $b): int { + $aScore = $a['fall_score']; + $bScore = $b['fall_score']; + + if ($aScore === null && $bScore === null) { + return strcmp($a['name'], $b['name']); + } + + if ($aScore === null) { + return 1; + } + + if ($bScore === null) { + return -1; + } + + return $bScore <=> $aScore ?: strcmp($a['name'], $b['name']); + }); + + $scores = array_column($section['students'], 'fall_score'); + $calc = $this->calculateThreshold($scores, $percentile); + $threshold = $calc['threshold']; + + $students = array_map(function (array $student) use ($threshold): array { + $student['projected_trophy'] = $threshold !== null + && $student['fall_score'] !== null + && $student['fall_score'] >= $threshold; + return $student; + }, $section['students']); + + $scoredCount = count(array_filter( + array_column($students, 'fall_score'), + static fn ($score) => $score !== null + )); + + $boys = array_filter($students, static fn ($s) => strtolower($s['gender']) === 'male'); + $girls = array_filter($students, static fn ($s) => strtolower($s['gender']) === 'female'); + $trophyBoys = array_filter($boys, static fn ($s) => $s['projected_trophy']); + $trophyGirls = array_filter($girls, static fn ($s) => $s['projected_trophy']); + $nBoys = count($boys); + $nGirls = count($girls); + $nTrophyBoys = count($trophyBoys); + $nTrophyGirls = count($trophyGirls); + $total = count($students); + + $classResults[] = [ + 'section_id' => $sid, + 'section_name' => $section['name'], + 'students' => $students, + 'threshold' => $threshold, + 'trophy_count' => $calc['winners'], + 'student_count' => $total, + 'scored_count' => $scoredCount, + 'method' => $calc['method'], + 'boys' => $nBoys, + 'girls' => $nGirls, + 'trophy_boys' => $nTrophyBoys, + 'trophy_girls' => $nTrophyGirls, + 'pct_boys' => $total > 0 ? round($nBoys / $total * 100) : 0, + 'pct_girls' => $total > 0 ? round($nGirls / $total * 100) : 0, + 'pct_trophy_boys' => $nBoys > 0 ? round($nTrophyBoys / $nBoys * 100) : 0, + 'pct_trophy_girls' => $nGirls > 0 ? round($nTrophyGirls / $nGirls * 100) : 0, + 'pct_trophy_total' => $total > 0 ? round($calc['winners'] / $total * 100): 0, + ]; + } + + return view('administrator/trophy', [ + 'classResults' => $classResults, + 'selectedYear' => $selectedYear, + 'selectedPercentile'=> $percentile, + 'years' => $years, + ]); + } + + public function winners() + { + $db = \Config\Database::connect(); + + $currentYear = $this->configModel->getConfig('school_year') ?? ''; + $selectedYear = $this->request->getGet('school_year') ?? $currentYear; + $percentile = (float) ($this->request->getGet('percentile') ?? 75); + $percentile = max(1.0, min(99.0, $percentile)); + + $years = $db->table('student_class') + ->select('school_year')->distinct() + ->orderBy('school_year', 'DESC') + ->get()->getResultArray(); + $years = array_column($years, 'school_year'); + + $ey = $db->escape($selectedYear); + + $rows = $db->table('student_class sc') + ->select([ + 'sc.student_id', + 'sc.class_section_id', + 'cs.class_section_name', + 'CONCAT(s.firstname, " ", s.lastname) AS student_name', + 's.school_id', + 's.gender', + 'MAX(sf.semester_score) AS fall_score', + 'MAX(sp.semester_score) AS spring_score', + ]) + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->join('students s', 's.id = sc.student_id', 'left') + ->join('semester_scores sf', + 'sf.student_id = sc.student_id AND sf.class_section_id = sc.class_section_id' + . ' AND sf.school_year = ' . $ey . ' AND LOWER(sf.semester) = "fall"', 'left') + ->join('semester_scores sp', + 'sp.student_id = sc.student_id AND sp.class_section_id = sc.class_section_id' + . ' AND sp.school_year = ' . $ey . ' AND LOWER(sp.semester) = "spring"', 'left') + ->where('sc.school_year', $selectedYear) + ->orderBy('cs.class_section_name', 'ASC') + ->orderBy('student_name', 'ASC') + ->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id, s.gender') + ->get()->getResultArray(); + + $sections = []; + foreach ($rows as $row) { + $sid = (int) ($row['class_section_id'] ?? 0); + $name = $row['class_section_name'] ?? 'Section ' . $sid; + if (!isset($sections[$sid])) $sections[$sid] = ['name' => $name, 'students' => []]; + $fall = is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null; + $spring = is_numeric($row['spring_score']) ? (float) $row['spring_score'] : null; + $year = ($fall !== null && $spring !== null) + ? round(($fall + $spring) / 2, 1) + : ($fall ?? $spring); + $sections[$sid]['students'][] = [ + 'name' => $row['student_name'], + 'school_id' => $row['school_id'], + 'gender' => $row['gender'] ?? '', + 'fall_score' => $fall, + 'spring_score' => $spring, + 'year_score' => $year, + ]; + } + + $classResults = []; + foreach ($sections as $section) { + usort($section['students'], static function ($a, $b) { + if ($a['fall_score'] === null && $b['fall_score'] === null) return strcmp($a['name'], $b['name']); + if ($a['fall_score'] === null) return 1; + if ($b['fall_score'] === null) return -1; + return $b['fall_score'] <=> $a['fall_score'] ?: strcmp($a['name'], $b['name']); + }); + + $scores = array_column($section['students'], 'fall_score'); + $calc = $this->calculateThreshold($scores, $percentile); + $threshold = $calc['threshold']; + + $allStudents = $section['students']; + $winners = array_values(array_filter($allStudents, function ($s) use ($threshold) { + return $threshold !== null && $s['fall_score'] !== null && $s['fall_score'] >= $threshold; + })); + + if (empty($winners)) continue; + + $boys = array_filter($allStudents, static fn($s) => strtolower($s['gender']) === 'male'); + $girls = array_filter($allStudents, static fn($s) => strtolower($s['gender']) === 'female'); + $trophyBoys = array_filter($winners, static fn($s) => strtolower($s['gender']) === 'male'); + $trophyGirls = array_filter($winners, static fn($s) => strtolower($s['gender']) === 'female'); + $n = count($allStudents); + $nBoys = count($boys); + $nGirls = count($girls); + $nTB = count($trophyBoys); + $nTG = count($trophyGirls); + + $classResults[] = [ + 'section_name' => $section['name'], + 'threshold' => $threshold, + 'winners' => $winners, + 'student_count' => $n, + 'boys' => $nBoys, + 'girls' => $nGirls, + 'trophy_boys' => $nTB, + 'trophy_girls' => $nTG, + 'pct_boys' => $n > 0 ? round($nBoys / $n * 100) : 0, + 'pct_girls' => $n > 0 ? round($nGirls / $n * 100) : 0, + 'pct_trophy_boys' => $nBoys > 0 ? round($nTB / $nBoys * 100) : 0, + 'pct_trophy_girls' => $nGirls > 0 ? round($nTG / $nGirls * 100) : 0, + 'pct_trophy_total' => $n > 0 ? round(count($winners) / $n * 100) : 0, + ]; + } + + return view('administrator/trophy_winners', [ + 'classResults' => $classResults, + 'selectedYear' => $selectedYear, + 'selectedPercentile' => $percentile, + 'years' => $years, + ]); + } + + public function final() + { + $db = \Config\Database::connect(); + + $currentYear = $this->configModel->getConfig('school_year') ?? ''; + $selectedYear = $this->request->getGet('school_year') ?? $currentYear; + $percentile = (float) ($this->request->getGet('percentile') ?? 75); + $percentile = max(1.0, min(99.0, $percentile)); + + $years = $db->table('student_class') + ->select('school_year')->distinct() + ->orderBy('school_year', 'DESC') + ->get()->getResultArray(); + $years = array_column($years, 'school_year'); + + $ey = $db->escape($selectedYear); + + $rows = $db->table('student_class sc') + ->select([ + 'sc.student_id', + 'sc.class_section_id', + 'cs.class_section_name', + 'CONCAT(s.firstname, " ", s.lastname) AS student_name', + 's.school_id', + 's.gender', + 'MAX(sf.semester_score) AS fall_score', + 'MAX(sp.semester_score) AS spring_score', + ]) + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->join('students s', 's.id = sc.student_id', 'left') + ->join('semester_scores sf', + 'sf.student_id = sc.student_id AND sf.class_section_id = sc.class_section_id' + . ' AND sf.school_year = ' . $ey . ' AND LOWER(sf.semester) = "fall"', 'left') + ->join('semester_scores sp', + 'sp.student_id = sc.student_id AND sp.class_section_id = sc.class_section_id' + . ' AND sp.school_year = ' . $ey . ' AND LOWER(sp.semester) = "spring"', 'left') + ->where('sc.school_year', $selectedYear) + ->orderBy('cs.class_section_name', 'ASC') + ->orderBy('student_name', 'ASC') + ->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id, s.gender') + ->get()->getResultArray(); + + $sections = []; + foreach ($rows as $row) { + $sid = (int) ($row['class_section_id'] ?? 0); + $name = $row['class_section_name'] ?? 'Section ' . $sid; + if (!isset($sections[$sid])) $sections[$sid] = ['name' => $name, 'students' => []]; + + $fall = is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null; + $spring = is_numeric($row['spring_score']) ? (float) $row['spring_score'] : null; + $year = ($fall !== null && $spring !== null) + ? round(($fall + $spring) / 2, 1) + : ($fall ?? $spring); + + $sections[$sid]['students'][] = [ + 'name' => $row['student_name'], + 'school_id' => $row['school_id'], + 'gender' => $row['gender'] ?? '', + 'fall_score' => $fall, + 'spring_score' => $spring, + 'year_score' => $year, + ]; + } + + $classResults = []; + foreach ($sections as $section) { + $students = $section['students']; + + $fallCalc = $this->calculateThreshold(array_column($students, 'fall_score'), $percentile); + $yearCalc = $this->calculateThreshold(array_column($students, 'year_score'), $percentile); + $fallThreshold = $fallCalc['threshold']; + $yearThreshold = $yearCalc['threshold']; + + $annotated = array_map(static function (array $s) use ($fallThreshold, $yearThreshold): array { + $predicted = $fallThreshold !== null && $s['fall_score'] !== null && $s['fall_score'] >= $fallThreshold; + $actual = $yearThreshold !== null && $s['year_score'] !== null && $s['year_score'] >= $yearThreshold; + $status = match (true) { + $predicted && $actual => 'confirmed', + !$predicted && $actual => 'surprise', + $predicted && !$actual => 'missed', + default => 'none', + }; + return $s + compact('predicted', 'actual', 'status'); + }, $students); + + usort($annotated, static function (array $a, array $b): int { + if ($a['year_score'] === null && $b['year_score'] === null) return strcmp($a['name'], $b['name']); + if ($a['year_score'] === null) return 1; + if ($b['year_score'] === null) return -1; + return $b['year_score'] <=> $a['year_score'] ?: strcmp($a['name'], $b['name']); + }); + + $nConfirmed = count(array_filter($annotated, static fn ($s) => $s['status'] === 'confirmed')); + $nSurprise = count(array_filter($annotated, static fn ($s) => $s['status'] === 'surprise')); + $nMissed = count(array_filter($annotated, static fn ($s) => $s['status'] === 'missed')); + $nPredicted = count(array_filter($annotated, static fn ($s) => $s['predicted'])); + $nActual = count(array_filter($annotated, static fn ($s) => $s['actual'])); + $n = count($annotated); + + $classResults[] = [ + 'section_name' => $section['name'], + 'students' => $annotated, + 'student_count' => $n, + 'fall_threshold' => $fallThreshold, + 'year_threshold' => $yearThreshold, + 'predicted_count' => $nPredicted, + 'actual_count' => $nActual, + 'confirmed' => $nConfirmed, + 'surprises' => $nSurprise, + 'missed' => $nMissed, + 'accuracy' => $nPredicted > 0 ? round($nConfirmed / $nPredicted * 100) : ($nActual === 0 ? 100 : 0), + ]; + } + + return view('administrator/trophy_final', [ + 'classResults' => $classResults, + 'selectedYear' => $selectedYear, + 'selectedPercentile' => $percentile, + 'years' => $years, + ]); + } + + private function calculateThreshold(array $scores, float $percentile = 75.0): array + { + $scores = array_values(array_filter( + $scores, + static fn ($v) => is_numeric($v) && $v !== null + )); + $scores = array_map('floatval', $scores); + sort($scores); // ascending + $n = count($scores); + + if ($n === 0) { + return ['threshold' => null, 'winners' => 0, 'method' => 'empty']; + } + + $minWinners = 3; // never fewer than 3, regardless of class size or score count + // Hard maximum: top (100 - percentile)% of class, but never below the minimum. + $maxWinners = max($minWinners, (int) floor($n * (1 - $percentile / 100))); + + $threshold = $this->empiricalPercentile($scores, $percentile); + $winners = $this->countAtOrAbove($scores, $threshold); + + if ($winners < $minWinners) { + // Too few qualify — REDUCE the threshold down to the score of the + // 3rd-ranked student so the minimum of 3 is always met. + // If fewer than 3 students have scores, award all of them. + $target = min($minWinners, $n); + $desc = array_reverse($scores); // descending + $threshold = $desc[$target - 1]; // score at rank $target + $winners = $this->countAtOrAbove($scores, $threshold); // ties included + return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced']; + } + + if ($winners <= $maxWinners) { + return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile']; + } + + // Too many winners — raise threshold to respect the cap. + $result = $this->capByRank($scores, $maxWinners); + + // capByRank may overshoot and drop below the minimum (e.g. bimodal scores + // where only 2 students are in the top cluster). Enforce min=3 here too. + if ($result['winners'] < $minWinners) { + $target = min($minWinners, $n); + $desc = array_reverse($scores); + $threshold = $desc[$target - 1]; + $winners = $this->countAtOrAbove($scores, $threshold); + return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap']; + } + + return $result; + } + + /** + * Raise the threshold until at most $max students qualify. + * Returns the result without checking the minimum — caller must do that. + */ + private function capByRank(array $sortedScores, int $max): array + { + $desc = array_reverse($sortedScores); + $threshold = $desc[$max - 1]; + $winners = $this->countAtOrAbove($sortedScores, $threshold); + + if ($winners <= $max) { + return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct']; + } + + // Ties push it over — walk up through distinct scores. + $unique = array_values(array_unique(array_filter( + $sortedScores, + static fn ($s) => $s > $threshold + ))); + sort($unique); + + foreach ($unique as $candidate) { + $w = $this->countAtOrAbove($sortedScores, $candidate); + if ($w <= $max) { + return ['threshold' => $candidate, 'winners' => $w, 'method' => 'capped_25pct']; + } + } + + // All scores are equal. + return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal']; + } + + private function empiricalPercentile(array $sortedScores, float $p): float + { + $n = count($sortedScores); + if ($n === 0) return 0.0; + $index = ($p / 100.0) * ($n - 1); + $lower = (int) floor($index); + $upper = (int) ceil($index); + if ($lower === $upper) return $sortedScores[$lower]; + return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]); + } + + private function countAtOrAbove(array $scores, float $threshold): int + { + return count(array_filter($scores, static fn ($s) => $s >= $threshold)); + } + +} diff --git a/app/Views/administrator/trophy.php b/app/Views/administrator/trophy.php new file mode 100644 index 0000000..32449c5 --- /dev/null +++ b/app/Views/administrator/trophy.php @@ -0,0 +1,389 @@ +extend('layout/management_layout') ?> +section('content') ?> + + 0 ? round($totalBoys / $totalStudents * 100) : 0; +$pctGirls = $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0; +$pctTrophyBoys = $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0; +$pctTrophyGirls = $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0; +$pctTrophyAll = $totalStudents > 0 ? round($totalTrophies / $totalStudents * 100) : 0; +?> + +
+ + +
+
+

Trophy Projections

+

+ Fall scores are used to calculate a per-class threshold at the + th percentile and project which students are on track + for a year-end trophy. Minimum 3 trophies per class. Names are hidden by default. +

+
+ +
+ + +
+
+ + +
+
+ +
+ + % +
+
+
+ +
+
+ + +
No class or fall-score data found for the selected school year.
+ + + +
+
+
+
+
+
+
Trophies
+ % of students +
+
+
+
+
+
+
+
+
Students
+ classes +
+
+
+
+
+
+
+
+
With Fall Scores
+ 0 ? round($totalScored/$totalStudents*100) : 0 ?>% +
+
+
+
+
+
+
+
+
Boys
+ % of students +
+
+
+
+
+
+
+
+
Girls
+ % of students +
+
+
+
+
+
+
+
+ + / + +
+
Trophy M / F
+ + % +  ·  + % + +
+
+
+
+ + +
+
Class Breakdown
+
+ + + + + + + + + + + + + + + + + + $s !== null + ); + $scoresJson = json_encode(array_values($scores)); + $clsId = 'cls' . $cls['section_id']; + ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassStudentsScored + Trophies + + Boys + + Girls + Trophy BoysTrophy GirlsRateThresholdCustom Threshold
+ + + + + + + + + + + + + + + (%) + + + (%) + 0): ?> (%) 0): ?> (%) +
+
+
+ % +
+ + +
+ $s !== null))) ?>' + data-girls=' $s['gender'] === 'Female' ? $s['fall_score'] : null, $cls['students']), fn($s) => $s !== null))) ?>' + data-total="" + data-nboys="" + data-ngirls=""> + +
+
+
Total + + + + + + (%) + + + (%) + + + (%) + + + (%) + +
+
+
+ % +
+
+
+ + +
+ + + + +endSection() ?> diff --git a/app/Views/administrator/trophy_final.php b/app/Views/administrator/trophy_final.php new file mode 100644 index 0000000..fc45137 --- /dev/null +++ b/app/Views/administrator/trophy_final.php @@ -0,0 +1,598 @@ +extend('layout/management_layout') ?> +section('content') ?> + + 0 ? round($totalConfirmed / $totalPredicted * 100) : ($totalActual === 0 ? 100 : 0); +?> + + + +
+ + +
+
+

+ + Final vs Predicted — +

+

+ Compares the Fall-score prediction (th percentile) + with the year-end result based on the average of Fall & Spring scores. +

+
+
+ + + Back + +
+
+ + +
+
+ + +
+
+ +
+ + % +
+
+
+ +
+
+ + +
No data found for the selected school year.
+ + + +
+ + +
+ ✓ Confirmed Predicted AND got a year-end trophy + ↑ Surprise NOT predicted, but earned a trophy + ↓ Missed Predicted, but fell short year-end + — None No trophy either way +
+ + +
+ 0 ? round($totalPredicted / $totalStudents * 100) . '% of students' : '—'], + [$totalActual, 'Actual (Year)', 'warning', 'dark', $totalStudents > 0 ? round($totalActual / $totalStudents * 100) . '% of students' : '—'], + [$totalConfirmed, 'Confirmed', 'success', null, $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) . '% of predicted' : '—'], + [$totalSurprise, 'Surprises', 'info', 'dark', 'Not in prediction'], + [$totalMissed, 'Missed', 'orange', null, 'Were predicted'], + [$overallAccuracy,'Accuracy', 'dark', null, 'prediction rate'], + ] as [$val, $label, $color, $textClass, $sub]): + $isOrange = $color === 'orange'; + $bgClass = $isOrange ? '' : 'bg-' . $color; + $txClass = $textClass ? 'text-' . $textClass : 'text-white'; + ?> +
+
+
+
+ +
+
+ > + + +
+
+
+ +
+ + + +
+
+
+ + predicted + actual + 0): ?> + confirmed + + 0): ?> + surprise 1 ? 's' : '' ?> + + 0): ?> + missed + +
+
+ Fall ≥ + Year ≥ + + Accuracy: + + % + + +
+
+
+ + + + + + + + + + + + + + + + 'table-success', + 'surprise' => 'table-primary', + 'missed' => 'table-warning', + default => '', + }; + ?> + + + + + + + + + + + + + +
#NameGenderFallSpringYear AvgPredictedActualStatus
+ + + + —' ?>—' ?>—' ?> + Yes' + : 'No' ?> + + Yes' + : 'No' ?> + + print '✓ Confirmed', + 'surprise' => print '↑ Surprise', + 'missed' => print '↓ Missed', + default => print '', + }; ?> +
+
+
+ + + +
+
+ Prediction Accuracy Summary — +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassStudentsPredictedActual✓ Confirmed↑ Surprises↓ MissedAccuracyFall ≥Year ≥
+ % +
Total + % +
+
+
+ + +
+ +
+
+
+ Trophy Counts per Class +
+ +
+
+ +
+
+
+
+
+ Prediction Accuracy per Class +
+ +
+
+
+
+
+ Overall Outcome Breakdown +
+ +
+
+
+
+
+ +
+ + + + + +
+ +section('scripts') ?> + + +endSection() ?> + +endSection() ?> diff --git a/app/Views/administrator/trophy_winners.php b/app/Views/administrator/trophy_winners.php new file mode 100644 index 0000000..2ee5d34 --- /dev/null +++ b/app/Views/administrator/trophy_winners.php @@ -0,0 +1,480 @@ +extend('layout/management_layout') ?> +section('content') ?> + + count($c['winners']), $classResults)); +$totalStudents = array_sum(array_column($classResults, 'student_count')); +$totalBoys = array_sum(array_column($classResults, 'boys')); +$totalGirls = array_sum(array_column($classResults, 'girls')); +$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys')); +$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls')); + +$pctWinners = $totalStudents > 0 ? round($totalWinners / $totalStudents * 100) : 0; +$pctTrophyBoys = $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0; +$pctTrophyGirls = $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0; +$pctBoys = $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0; +$pctGirls = $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0; +?> + + + +
+ + +
+
+

Trophy Winners

+

+ th percentile — + winners across classes +

+
+
+ + + Back + +
+
+ + +
+
+ + +
+
+ +
+ + % +
+
+
+ +
+
+ + +
No projected trophy winners found.
+ + + + + + +
+ + + +
+
+
+ + + winner + + + Fall ≥ + +
+
+ + + / boys + 0): ?>(%) + + + + / girls + 0): ?>(%) + + / (%) +
+
+
+ + + + + + + + + + + + + $student): + $isMale = strtolower($student['gender'] ?? '') === 'male'; + ?> + + + + + + + + + + +
#NameGenderFall ScoreSpring ScoreYear Score
+ + + + + —' ?> + + ' . number_format($student['spring_score'], 1) . '' + : '' ?> + + —' ?> +
+
+
+ + + +
+
+ Overall Year Summary — +
+
+
+ +
+
+
> + +
+
+
> + +
+
+
+ +
+ +
+ +
+
+
+ Trophy Winners per Class +
+ +
+
+ +
+
+
+
+
+ Winners by Gender +
+ +
+
+
+
+
+ Trophy Rate per Class (%) +
+ +
+
+
+
+
+
+
+ +
+ + +
+ +section('scripts') ?> + + +endSection() ?> + +endSection() ?> diff --git a/app/Views/partials/navbar_back.php b/app/Views/partials/navbar_back.php index f5d4013..8fbb2ad 100644 --- a/app/Views/partials/navbar_back.php +++ b/app/Views/partials/navbar_back.php @@ -104,6 +104,9 @@ $role = strtolower(session()->get('role') ?? 'guest');