add trophy prediction and treshold

This commit is contained in:
root
2026-05-16 15:35:56 -04:00
parent acca87c77b
commit 7c97a60795
8 changed files with 1982 additions and 4 deletions
+3 -3
View File
@@ -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');
+4
View File
@@ -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']);
/*
+1 -1
View File
@@ -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);
+504
View File
@@ -0,0 +1,504 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ConfigurationModel;
class TrophyController extends BaseController
{
private const PERCENTILE = 75.0;
protected ConfigurationModel $configModel;
public function __construct()
{
$this->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));
}
}
+389
View File
@@ -0,0 +1,389 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
$classResults = $classResults ?? [];
$selectedYear = $selectedYear ?? '';
$selectedPercentile = $selectedPercentile ?? 75;
$years = $years ?? [];
$totalStudents = array_sum(array_column($classResults, 'student_count'));
$totalScored = array_sum(array_column($classResults, 'scored_count'));
$totalTrophies = array_sum(array_column($classResults, 'trophy_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'));
$pctBoys = $totalStudents > 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;
?>
<div class="container-fluid">
<!-- Header -->
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2">
<div>
<h2 class="mb-1"><i class="bi bi-trophy-fill text-warning me-2"></i>Trophy Projections</h2>
<p class="text-muted mb-0">
Fall scores are used to calculate a per-class threshold at the
<strong><?= (int) $selectedPercentile ?>th percentile</strong> and project which students are on track
for a year-end trophy. Minimum 3 trophies per class. Names are hidden by default.
</p>
</div>
<div class="d-flex gap-2">
<a href="<?= site_url('administrator/trophy/winners?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
class="btn btn-warning btn-sm">
<i class="bi bi-eye-fill me-1"></i>Reveal Winners
</a>
<a href="<?= site_url('administrator/trophy/final?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
class="btn btn-success btn-sm">
<i class="bi bi-bar-chart-steps me-1"></i>Final vs Predicted
</a>
</div>
</div>
<!-- Year filter -->
<form method="get" action="<?= site_url('administrator/trophy') ?>" class="row g-2 align-items-end mb-4">
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">School Year</label>
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
<?php foreach ($years as $yr): ?>
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>>
<?= esc($yr) ?>
</option>
<?php endforeach; ?>
<?php if (empty($years)): ?>
<option value="<?= esc($selectedYear) ?>" selected><?= esc($selectedYear) ?></option>
<?php endif; ?>
</select>
</div>
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">Percentile threshold</label>
<div class="input-group input-group-sm" style="width:130px;">
<input type="number" name="percentile" class="form-control"
min="1" max="99" step="1"
value="<?= (int) $selectedPercentile ?>"
title="Students scoring above this percentile receive a trophy (min 3 per class)">
<span class="input-group-text">%</span>
</div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
</div>
</form>
<?php if (empty($classResults)): ?>
<div class="alert alert-info">No class or fall-score data found for the selected school year.</div>
<?php else: ?>
<!-- ── Global stats ── -->
<div class="row g-3 mb-4">
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm border-warning h-100">
<div class="card-body py-3">
<div class="fs-2 text-warning"><i class="bi bi-trophy-fill"></i></div>
<div class="fs-3 fw-bold"><?= $totalTrophies ?></div>
<div class="text-muted small">Trophies</div>
<span class="badge bg-warning text-dark mt-1"><?= $pctTrophyAll ?>% of students</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2 text-primary"><i class="bi bi-people-fill"></i></div>
<div class="fs-3 fw-bold"><?= $totalStudents ?></div>
<div class="text-muted small">Students</div>
<span class="badge bg-secondary mt-1"><?= count($classResults) ?> classes</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2 text-success"><i class="bi bi-bar-chart-fill"></i></div>
<div class="fs-3 fw-bold"><?= $totalScored ?></div>
<div class="text-muted small">With Fall Scores</div>
<span class="badge bg-success mt-1"><?= $totalStudents > 0 ? round($totalScored/$totalStudents*100) : 0 ?>%</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2" style="color:#4A90E2"><i class="bi bi-gender-male"></i></div>
<div class="fs-3 fw-bold"><?= $totalBoys ?></div>
<div class="text-muted small">Boys</div>
<span class="badge text-white mt-1" style="background:#4A90E2"><?= $pctBoys ?>% of students</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2" style="color:#E47AB0"><i class="bi bi-gender-female"></i></div>
<div class="fs-3 fw-bold"><?= $totalGirls ?></div>
<div class="text-muted small">Girls</div>
<span class="badge text-white mt-1" style="background:#E47AB0"><?= $pctGirls ?>% of students</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2 text-warning"><i class="bi bi-award-fill"></i></div>
<div class="fs-3 fw-bold">
<span style="color:#4A90E2"><?= $totalTrophyBoys ?></span>
<span class="text-muted fs-5">/</span>
<span style="color:#E47AB0"><?= $totalTrophyGirls ?></span>
</div>
<div class="text-muted small">Trophy M / F</div>
<span class="small">
<span style="color:#4A90E2"><?= $pctTrophyBoys ?>%</span>
&nbsp;·&nbsp;
<span style="color:#E47AB0"><?= $pctTrophyGirls ?>%</span>
</span>
</div>
</div>
</div>
</div>
<!-- ── Per-class summary ── -->
<div class="card shadow-sm">
<div class="card-header bg-white fw-semibold py-2">Class Breakdown</div>
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0 no-mgmt-sticky" id="trophy-breakdown-table" data-no-mgmt-sticky>
<thead class="table-light">
<tr>
<th class="ps-3">Class</th>
<th class="text-center">Students</th>
<th class="text-center">Scored</th>
<th class="text-center">
<i class="bi bi-trophy-fill text-warning"></i> Trophies
</th>
<th class="text-center" style="color:#4A90E2">
<i class="bi bi-gender-male"></i> Boys
</th>
<th class="text-center" style="color:#E47AB0">
<i class="bi bi-gender-female"></i> Girls
</th>
<th class="text-center" style="color:#4A90E2">Trophy Boys</th>
<th class="text-center" style="color:#E47AB0">Trophy Girls</th>
<th class="text-center">Rate</th>
<th class="text-end">Threshold</th>
<th class="text-center">Custom Threshold</th>
</tr>
</thead>
<tbody>
<?php foreach ($classResults as $cls):
$scores = array_filter(
array_column($cls['students'], 'fall_score'),
fn($s) => $s !== null
);
$scoresJson = json_encode(array_values($scores));
$clsId = 'cls' . $cls['section_id'];
?>
<tr id="row-<?= $clsId ?>">
<td class="ps-3 fw-semibold"><?= esc($cls['section_name']) ?></td>
<td class="text-center"><?= $cls['student_count'] ?></td>
<td class="text-center">
<?php if ($cls['scored_count'] < 3): ?>
<span class="badge bg-danger" title="Fewer than 3 fall scores — minimum of 3 trophies cannot be enforced">
<?= $cls['scored_count'] ?> <i class="bi bi-exclamation-triangle-fill"></i>
</span>
<?php else: ?>
<?= $cls['scored_count'] ?>
<?php endif; ?>
</td>
<td class="text-center" id="trophy-cell-<?= $clsId ?>">
<span class="badge bg-warning text-dark">
<i class="bi bi-trophy-fill"></i>
<span id="trophy-count-<?= $clsId ?>"><?= $cls['trophy_count'] ?></span>
</span>
</td>
<td class="text-center">
<?= $cls['boys'] ?>
<span class="text-muted small">(<?= $cls['pct_boys'] ?>%)</span>
</td>
<td class="text-center">
<?= $cls['girls'] ?>
<span class="text-muted small">(<?= $cls['pct_girls'] ?>%)</span>
</td>
<td class="text-center" id="trophy-boys-<?= $clsId ?>"><?= $cls['trophy_boys'] ?><?php if ($cls['boys'] > 0): ?> <span class="text-muted small">(<?= $cls['pct_trophy_boys'] ?>%)</span><?php endif; ?></td>
<td class="text-center" id="trophy-girls-<?= $clsId ?>"><?= $cls['trophy_girls'] ?><?php if ($cls['girls'] > 0): ?> <span class="text-muted small">(<?= $cls['pct_trophy_girls'] ?>%)</span><?php endif; ?></td>
<td class="text-center">
<div class="progress" style="height:8px;min-width:60px;">
<div class="progress-bar bg-warning" id="rate-bar-<?= $clsId ?>" style="width:<?= $cls['pct_trophy_total'] ?>%;"></div>
</div>
<span class="small text-muted" id="rate-txt-<?= $clsId ?>"><?= $cls['pct_trophy_total'] ?>%</span>
</td>
<td class="text-end text-muted small" id="auto-threshold-<?= $clsId ?>">
<?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '—' ?>
</td>
<td>
<div class="input-group input-group-sm" style="min-width:150px;">
<input type="number" id="custom-threshold-<?= $clsId ?>"
class="form-control form-control-sm"
step="0.1" min="0"
placeholder="e.g. <?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '80.0' ?>"
data-scores='<?= $scoresJson ?>'
data-clsid="<?= $clsId ?>"
data-boys='<?= json_encode(array_values(array_filter(array_map(fn($s) => $s['gender'] === 'Male' ? $s['fall_score'] : null, $cls['students']), fn($s) => $s !== null))) ?>'
data-girls='<?= json_encode(array_values(array_filter(array_map(fn($s) => $s['gender'] === 'Female' ? $s['fall_score'] : null, $cls['students']), fn($s) => $s !== null))) ?>'
data-total="<?= $cls['student_count'] ?>"
data-nboys="<?= $cls['boys'] ?>"
data-ngirls="<?= $cls['girls'] ?>">
<button class="btn btn-outline-primary btn-sm" type="button"
onclick="applyCustomThreshold('<?= $clsId ?>')">
Apply
</button>
</div>
<div id="custom-note-<?= $clsId ?>" class="text-muted" style="font-size:.7rem;"></div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td class="ps-3">Total</td>
<td class="text-center"><?= $totalStudents ?></td>
<td class="text-center"><?= $totalScored ?></td>
<td class="text-center">
<span class="badge bg-warning text-dark">
<i class="bi bi-trophy-fill"></i> <?= $totalTrophies ?>
</span>
</td>
<td class="text-center">
<?= $totalBoys ?>
<span class="text-muted small fw-normal">(<?= $pctBoys ?>%)</span>
</td>
<td class="text-center">
<?= $totalGirls ?>
<span class="text-muted small fw-normal">(<?= $pctGirls ?>%)</span>
</td>
<td class="text-center">
<?= $totalTrophyBoys ?>
<span class="text-muted small fw-normal">(<?= $pctTrophyBoys ?>%)</span>
</td>
<td class="text-center">
<?= $totalTrophyGirls ?>
<span class="text-muted small fw-normal">(<?= $pctTrophyGirls ?>%)</span>
</td>
<td class="text-center">
<div class="progress" style="height:8px;min-width:60px;">
<div class="progress-bar bg-warning" style="width:<?= $pctTrophyAll ?>%;"></div>
</div>
<span class="small text-muted"><?= $pctTrophyAll ?>%</span>
</td>
<td></td>
<td class="pe-3"></td>
</tr>
</tfoot>
</table>
</div>
</div>
<?php endif; ?>
</div>
<script>
var TROPHY_NS = 'trophy_custom_<?= esc($selectedYear) ?>_<?= (int)$selectedPercentile ?>';
function countAtOrAbove(arr, t) {
return arr.filter(function(s) { return s >= t; }).length;
}
function applyThreshold(clsId, val) {
var input = document.getElementById('custom-threshold-' + clsId);
var note = document.getElementById('custom-note-' + clsId);
var scores = JSON.parse(input.dataset.scores);
var boys = JSON.parse(input.dataset.boys);
var girls = JSON.parse(input.dataset.girls);
var nTotal = parseInt(input.dataset.total);
var nBoys = parseInt(input.dataset.nboys);
var nGirls = parseInt(input.dataset.ngirls);
var MIN = 3;
var winners = countAtOrAbove(scores, val);
var usedThreshold = val;
var forced = false;
if (winners < MIN && scores.length >= MIN) {
var desc = scores.slice().sort(function(a, b) { return b - a; });
usedThreshold = desc[MIN - 1];
winners = countAtOrAbove(scores, usedThreshold);
forced = true;
} else if (winners < MIN) {
winners = scores.length;
usedThreshold = scores.length > 0 ? Math.min.apply(null, scores) : val;
forced = true;
}
var trophyBoys = countAtOrAbove(boys, usedThreshold);
var trophyGirls = countAtOrAbove(girls, usedThreshold);
var rate = nTotal > 0 ? Math.round(winners / nTotal * 100) : 0;
var pctTB = nBoys > 0 ? Math.round(trophyBoys / nBoys * 100) : 0;
var pctTG = nGirls > 0 ? Math.round(trophyGirls / nGirls * 100) : 0;
document.getElementById('trophy-count-' + clsId).textContent = winners;
document.getElementById('trophy-boys-' + clsId).textContent = trophyBoys + (nBoys > 0 ? ' (' + pctTB + '%)' : '');
document.getElementById('trophy-girls-' + clsId).textContent = trophyGirls + (nGirls > 0 ? ' (' + pctTG + '%)' : '');
document.getElementById('rate-bar-' + clsId).style.width = rate + '%';
document.getElementById('rate-txt-' + clsId).textContent = rate + '%';
document.getElementById('auto-threshold-' + clsId).textContent = usedThreshold.toFixed(1);
if (forced) {
note.style.color = '#dc3545';
note.textContent = 'Min 3 enforced → threshold lowered to ' + usedThreshold.toFixed(1);
} else {
note.style.color = '#6c757d';
note.textContent = 'Custom threshold: ' + usedThreshold.toFixed(1);
}
var badge = document.getElementById('trophy-cell-' + clsId).querySelector('.badge');
badge.classList.remove('bg-warning');
badge.classList.add('bg-primary');
}
function applyCustomThreshold(clsId) {
var input = document.getElementById('custom-threshold-' + clsId);
var val = parseFloat(input.value);
if (isNaN(val)) {
document.getElementById('custom-note-' + clsId).textContent = 'Enter a valid number.';
return;
}
applyThreshold(clsId, val);
// Persist so the value survives filter changes within the same percentile/year
try {
var saved = JSON.parse(localStorage.getItem(TROPHY_NS) || '{}');
saved[clsId] = val;
localStorage.setItem(TROPHY_NS, JSON.stringify(saved));
} catch (e) {}
}
// Restore saved custom thresholds on page load
document.addEventListener('DOMContentLoaded', function () {
try {
var saved = JSON.parse(localStorage.getItem(TROPHY_NS) || '{}');
Object.keys(saved).forEach(function (clsId) {
var input = document.getElementById('custom-threshold-' + clsId);
if (!input) return;
input.value = saved[clsId];
applyThreshold(clsId, saved[clsId]);
});
} catch (e) {}
});
// Clear all custom thresholds when the percentile/year form is submitted
document.querySelector('form[action*="trophy"]').addEventListener('submit', function () {
try { localStorage.removeItem(TROPHY_NS); } catch (e) {}
});
</script>
<?= $this->endSection() ?>
+598
View File
@@ -0,0 +1,598 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
$classResults = $classResults ?? [];
$selectedYear = $selectedYear ?? '';
$selectedPercentile = $selectedPercentile ?? 75;
$years = $years ?? [];
$totalStudents = array_sum(array_column($classResults, 'student_count'));
$totalPredicted = array_sum(array_column($classResults, 'predicted_count'));
$totalActual = array_sum(array_column($classResults, 'actual_count'));
$totalConfirmed = array_sum(array_column($classResults, 'confirmed'));
$totalSurprise = array_sum(array_column($classResults, 'surprises'));
$totalMissed = array_sum(array_column($classResults, 'missed'));
$overallAccuracy = $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : ($totalActual === 0 ? 100 : 0);
?>
<style>
.status-confirmed { color:#198754; font-weight:600; }
.status-surprise { color:#0d6efd; font-weight:600; }
.status-missed { color:#fd7e14; font-weight:600; }
.status-none { color:#adb5bd; }
.print-only { display: none !important; }
@media print {
.no-print { display: none !important; }
.print-only { display: block !important; }
.screen-only { display: none !important; }
body { font-size: 11px; }
.container-fluid { padding: 0 !important; }
h2, h3 { font-size: 13px; margin-bottom: .3rem; }
.print-page-break { break-before: page; }
table { width: 100%; border-collapse: collapse; font-size: 10px; }
table th, table td { border: 1px solid #bbb; padding: 3px 5px; }
table thead { background: #333 !important; color: #fff !important;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
table thead th { position: static !important; top: auto !important; box-shadow: none !important; }
.s-confirmed { color: #198754; font-weight: bold; }
.s-surprise { color: #0d6efd; font-weight: bold; }
.s-missed { color: #fd7e14; font-weight: bold; }
}
</style>
<div class="container-fluid">
<!-- Header -->
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2 no-print">
<div>
<h2 class="mb-1">
<i class="bi bi-trophy-fill text-warning me-1"></i>
Final vs Predicted — <?= esc($selectedYear) ?>
</h2>
<p class="text-muted mb-0">
Compares the <strong>Fall-score prediction</strong> (<?= (int)$selectedPercentile ?>th percentile)
with the <strong>year-end result</strong> based on the average of Fall &amp; Spring scores.
</p>
</div>
<div class="d-flex gap-2">
<button onclick="printWithCharts()" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-printer-fill me-1"></i>Print
</button>
<a href="<?= site_url('administrator/trophy?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
</div>
</div>
<!-- Filter -->
<form method="get" action="<?= site_url('administrator/trophy/final') ?>"
class="row g-2 align-items-end mb-4 no-print">
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">School Year</label>
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
<?php foreach ($years as $yr): ?>
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>><?= esc($yr) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">Percentile</label>
<div class="input-group input-group-sm" style="width:110px;">
<input type="number" name="percentile" class="form-control"
min="1" max="99" step="1" value="<?= (int)$selectedPercentile ?>">
<span class="input-group-text">%</span>
</div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
</div>
</form>
<?php if (empty($classResults)): ?>
<div class="alert alert-info no-print">No data found for the selected school year.</div>
<?php else: ?>
<!-- ══ SCREEN VIEW ═══════════════════════════════════════════════════════ -->
<div class="screen-only">
<!-- Legend -->
<div class="d-flex flex-wrap gap-3 mb-3 small">
<span><span class="badge bg-success me-1">✓ Confirmed</span> Predicted AND got a year-end trophy</span>
<span><span class="badge bg-primary me-1">↑ Surprise</span> NOT predicted, but earned a trophy</span>
<span><span class="badge bg-warning text-dark me-1">↓ Missed</span> Predicted, but fell short year-end</span>
<span><span class="badge bg-light text-muted border me-1">— None</span> No trophy either way</span>
</div>
<!-- Global stat tiles -->
<div class="row g-3 mb-4">
<?php foreach ([
[$totalPredicted, 'Predicted (Fall)', 'primary', null, $totalStudents > 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';
?>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100 <?= $isOrange ? 'border-warning' : '' ?>">
<div class="card-body py-3">
<div class="fs-3 fw-bold <?= $isOrange ? 'text-warning' : 'text-' . $color ?>">
<?= $val ?><?= $label === 'Accuracy' ? '%' : '' ?>
</div>
<div class="text-muted small"><?= $label ?></div>
<span class="badge mt-1 <?= $bgClass . ' ' . $txClass ?>"
<?= $isOrange ? 'style="background:#fd7e14"' : '' ?>>
<?= $sub ?>
</span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- Per-class breakdown -->
<?php foreach ($classResults as $cls): ?>
<div class="card shadow-sm mb-4">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2 py-2"
style="background:#f8f9fa;">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="fw-bold fs-6"><?= esc($cls['section_name']) ?></span>
<span class="badge bg-primary"><?= $cls['predicted_count'] ?> predicted</span>
<span class="badge bg-warning text-dark"><?= $cls['actual_count'] ?> actual</span>
<?php if ($cls['confirmed'] > 0): ?>
<span class="badge bg-success"><?= $cls['confirmed'] ?> confirmed</span>
<?php endif; ?>
<?php if ($cls['surprises'] > 0): ?>
<span class="badge bg-info text-dark"><?= $cls['surprises'] ?> surprise<?= $cls['surprises'] > 1 ? 's' : '' ?></span>
<?php endif; ?>
<?php if ($cls['missed'] > 0): ?>
<span class="badge" style="background:#fd7e14"><?= $cls['missed'] ?> missed</span>
<?php endif; ?>
</div>
<div class="d-flex gap-3 small align-items-center">
<span class="text-muted">Fall &#8805; <strong><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></strong></span>
<span class="text-muted">Year &#8805; <strong><?= $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?></strong></span>
<span class="fw-semibold">
Accuracy:
<span class="<?= $cls['accuracy'] >= 80 ? 'text-success' : ($cls['accuracy'] >= 50 ? 'text-warning' : 'text-danger') ?>">
<?= $cls['accuracy'] ?>%
</span>
</span>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0 align-middle" data-no-mgmt-sticky>
<thead class="table-light">
<tr>
<th class="ps-3" style="width:2rem;">#</th>
<th>Name</th>
<th class="text-center" style="width:3rem;">Gender</th>
<th class="text-end">Fall</th>
<th class="text-end">Spring</th>
<th class="text-end">Year Avg</th>
<th class="text-center">Predicted</th>
<th class="text-center">Actual</th>
<th class="text-center">Status</th>
</tr>
</thead>
<tbody>
<?php $rank = 0; foreach ($cls['students'] as $s):
if ($s['status'] === 'none') continue;
$rank++;
$isMale = strtolower($s['gender'] ?? '') === 'male';
$rowBg = match ($s['status']) {
'confirmed' => 'table-success',
'surprise' => 'table-primary',
'missed' => 'table-warning',
default => '',
};
?>
<tr class="<?= $rowBg ?>">
<td class="ps-3 text-muted small"><?= $rank ?></td>
<td class="fw-semibold small"><?= esc($s['name']) ?></td>
<td class="text-center">
<span class="badge" style="background:<?= $isMale ? '#4A90E2' : '#E47AB0' ?>;font-size:.65rem;">
<?= $isMale ? 'M' : 'F' ?>
</span>
</td>
<td class="text-end small"><?= $s['fall_score'] !== null ? number_format($s['fall_score'], 1) : '<span class="text-muted">—</span>' ?></td>
<td class="text-end small"><?= $s['spring_score'] !== null ? number_format($s['spring_score'], 1) : '<span class="text-muted">—</span>' ?></td>
<td class="text-end small fw-semibold"><?= $s['year_score'] !== null ? number_format($s['year_score'], 1) : '<span class="text-muted">—</span>' ?></td>
<td class="text-center small">
<?= $s['predicted']
? '<span class="badge bg-primary">Yes</span>'
: '<span class="badge bg-light text-muted border">No</span>' ?>
</td>
<td class="text-center small">
<?= $s['actual']
? '<span class="badge bg-warning text-dark">Yes</span>'
: '<span class="badge bg-light text-muted border">No</span>' ?>
</td>
<td class="text-center small">
<?php match ($s['status']) {
'confirmed' => print '<span class="badge bg-success">✓ Confirmed</span>',
'surprise' => print '<span class="badge bg-info text-dark">↑ Surprise</span>',
'missed' => print '<span class="badge" style="background:#fd7e14;color:#fff;">↓ Missed</span>',
default => print '<span class="text-muted small">—</span>',
}; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
<!-- Overall accuracy summary table -->
<div class="card shadow-sm mt-2 mb-4">
<div class="card-header bg-dark text-white fw-semibold py-2">
<i class="bi bi-bar-chart-fill me-2"></i>Prediction Accuracy Summary — <?= esc($selectedYear) ?>
</div>
<div class="card-body p-0">
<table class="table table-sm table-bordered mb-0 align-middle" data-no-mgmt-sticky>
<thead class="table-dark">
<tr>
<th class="ps-2">Class</th>
<th class="text-center">Students</th>
<th class="text-center text-primary-emphasis">Predicted</th>
<th class="text-center text-warning-emphasis">Actual</th>
<th class="text-center text-success-emphasis">✓ Confirmed</th>
<th class="text-center" style="color:#6ea8fe">↑ Surprises</th>
<th class="text-center" style="color:#ffda6a">↓ Missed</th>
<th class="text-center">Accuracy</th>
<th class="text-end pe-2">Fall ≥</th>
<th class="text-end pe-2">Year ≥</th>
</tr>
</thead>
<tbody>
<?php foreach ($classResults as $cls): ?>
<tr>
<td class="ps-2 fw-semibold small"><?= esc($cls['section_name']) ?></td>
<td class="text-center small"><?= $cls['student_count'] ?></td>
<td class="text-center small"><span class="badge bg-primary"><?= $cls['predicted_count'] ?></span></td>
<td class="text-center small"><span class="badge bg-warning text-dark"><?= $cls['actual_count'] ?></span></td>
<td class="text-center small"><span class="badge bg-success"><?= $cls['confirmed'] ?></span></td>
<td class="text-center small"><span class="badge bg-info text-dark"><?= $cls['surprises'] ?></span></td>
<td class="text-center small"><span class="badge" style="background:#fd7e14"><?= $cls['missed'] ?></span></td>
<td class="text-center small fw-semibold <?= $cls['accuracy'] >= 80 ? 'text-success' : ($cls['accuracy'] >= 50 ? 'text-warning' : 'text-danger') ?>">
<?= $cls['accuracy'] ?>%
</td>
<td class="text-end pe-2 small text-muted"><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></td>
<td class="text-end pe-2 small text-muted"><?= $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot class="table-secondary fw-semibold">
<tr>
<td class="ps-2">Total</td>
<td class="text-center"><?= $totalStudents ?></td>
<td class="text-center"><span class="badge bg-primary"><?= $totalPredicted ?></span></td>
<td class="text-center"><span class="badge bg-warning text-dark"><?= $totalActual ?></span></td>
<td class="text-center"><span class="badge bg-success"><?= $totalConfirmed ?></span></td>
<td class="text-center"><span class="badge bg-info text-dark"><?= $totalSurprise ?></span></td>
<td class="text-center"><span class="badge" style="background:#fd7e14"><?= $totalMissed ?></span></td>
<td class="text-center fw-semibold <?= $overallAccuracy >= 80 ? 'text-success' : ($overallAccuracy >= 50 ? 'text-warning' : 'text-danger') ?>">
<?= $overallAccuracy ?>%
</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Charts -->
<div class="row g-4 mt-1 mb-4">
<!-- Grouped bar: predicted / actual / confirmed / surprises / missed per class -->
<div class="col-12 col-lg-8">
<div class="border rounded p-3 h-100">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-bar-chart-fill me-1"></i>Trophy Counts per Class
</div>
<canvas id="chart-counts" style="max-height:280px;"></canvas>
</div>
</div>
<!-- Bar: accuracy % per class + doughnut overall breakdown -->
<div class="col-12 col-lg-4">
<div class="row g-3 h-100">
<div class="col-12">
<div class="border rounded p-3">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-bullseye me-1"></i>Prediction Accuracy per Class
</div>
<canvas id="chart-accuracy" style="max-height:130px;"></canvas>
</div>
</div>
<div class="col-12">
<div class="border rounded p-3">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-pie-chart-fill me-1"></i>Overall Outcome Breakdown
</div>
<canvas id="chart-breakdown" style="max-height:130px;"></canvas>
</div>
</div>
</div>
</div>
</div>
</div><!-- /screen-only -->
<!-- ══ PRINT VIEW ════════════════════════════════════════════════════════ -->
<div class="print-only">
<div style="text-align:center;margin-bottom:10px;border-bottom:2px solid #333;padding-bottom:6px;">
<h2 style="margin:0;">Final vs Predicted — <?= esc($selectedYear) ?></h2>
<p style="margin:3px 0 0;font-size:10px;color:#555;">
<?= (int)$selectedPercentile ?>th percentile &mdash;
Predicted: <?= $totalPredicted ?> &mdash;
Actual: <?= $totalActual ?> &mdash;
Confirmed: <?= $totalConfirmed ?> &mdash;
Accuracy: <?= $overallAccuracy ?>%
</p>
</div>
<!-- All students flat table -->
<h3 style="margin-bottom:4px;">Student Detail</h3>
<table data-no-mgmt-sticky>
<thead>
<tr>
<th>#</th>
<th>Class</th>
<th>Name</th>
<th style="text-align:center;">G</th>
<th style="text-align:right;">Fall</th>
<th style="text-align:right;">Spring</th>
<th style="text-align:right;">Year Avg</th>
<th style="text-align:center;">Predicted</th>
<th style="text-align:center;">Actual</th>
<th style="text-align:center;">Status</th>
</tr>
</thead>
<tbody>
<?php
$rank = 0;
foreach ($classResults as $cls):
foreach ($cls['students'] as $s):
if ($s['status'] === 'none') continue;
$rank++;
$isMale = strtolower($s['gender'] ?? '') === 'male';
$statusLabel = match ($s['status']) {
'confirmed' => '✓ Confirmed',
'surprise' => '↑ Surprise',
'missed' => '↓ Missed',
default => '—',
};
$statusClass = 's-' . $s['status'];
?>
<tr>
<td style="text-align:center;"><?= $rank ?></td>
<td><?= esc($cls['section_name']) ?></td>
<td><strong><?= esc($s['name']) ?></strong></td>
<td style="text-align:center;"><?= $isMale ? 'M' : 'F' ?></td>
<td style="text-align:right;"><?= $s['fall_score'] !== null ? number_format($s['fall_score'], 1) : '—' ?></td>
<td style="text-align:right;"><?= $s['spring_score'] !== null ? number_format($s['spring_score'], 1) : '—' ?></td>
<td style="text-align:right;font-weight:bold;"><?= $s['year_score'] !== null ? number_format($s['year_score'], 1) : '—' ?></td>
<td style="text-align:center;"><?= $s['predicted'] ? 'Yes' : 'No' ?></td>
<td style="text-align:center;"><?= $s['actual'] ? 'Yes' : 'No' ?></td>
<td style="text-align:center;" class="<?= $statusClass ?>"><?= $statusLabel ?></td>
</tr>
<?php endforeach; endforeach; ?>
</tbody>
</table>
<!-- Accuracy summary (new page) -->
<div class="print-page-break"></div>
<h3 style="margin-bottom:4px;">Prediction Accuracy Summary</h3>
<table data-no-mgmt-sticky style="margin-bottom:14px;">
<thead>
<tr>
<th>Class</th>
<th style="text-align:center;">Students</th>
<th style="text-align:center;">Predicted</th>
<th style="text-align:center;">Actual</th>
<th style="text-align:center;">✓ Confirmed</th>
<th style="text-align:center;">↑ Surprises</th>
<th style="text-align:center;">↓ Missed</th>
<th style="text-align:center;">Accuracy</th>
<th style="text-align:right;">Fall ≥</th>
<th style="text-align:right;">Year ≥</th>
</tr>
</thead>
<tbody>
<?php foreach ($classResults as $cls): ?>
<tr>
<td><?= esc($cls['section_name']) ?></td>
<td style="text-align:center;"><?= $cls['student_count'] ?></td>
<td style="text-align:center;"><?= $cls['predicted_count'] ?></td>
<td style="text-align:center;"><?= $cls['actual_count'] ?></td>
<td style="text-align:center;" class="s-confirmed"><?= $cls['confirmed'] ?></td>
<td style="text-align:center;" class="s-surprise"><?= $cls['surprises'] ?></td>
<td style="text-align:center;" class="s-missed"><?= $cls['missed'] ?></td>
<td style="text-align:center;font-weight:bold;"><?= $cls['accuracy'] ?>%</td>
<td style="text-align:right;"><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></td>
<td style="text-align:right;"><?= $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<td style="font-weight:bold;">Total</td>
<td style="text-align:center;font-weight:bold;"><?= $totalStudents ?></td>
<td style="text-align:center;font-weight:bold;"><?= $totalPredicted ?></td>
<td style="text-align:center;font-weight:bold;"><?= $totalActual ?></td>
<td style="text-align:center;font-weight:bold;" class="s-confirmed"><?= $totalConfirmed ?></td>
<td style="text-align:center;font-weight:bold;" class="s-surprise"><?= $totalSurprise ?></td>
<td style="text-align:center;font-weight:bold;" class="s-missed"><?= $totalMissed ?></td>
<td style="text-align:center;font-weight:bold;"><?= $overallAccuracy ?>%</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
<!-- Charts as images (populated by JS before printing) -->
<div class="print-page-break"></div>
<h3 style="margin-bottom:6px;">Charts</h3>
<div style="margin-bottom:14px;">
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Trophy Counts per Class</p>
<img id="print-chart-counts" style="width:100%;max-height:220px;object-fit:contain;" src="" alt="">
</div>
<div style="display:flex;gap:16px;margin-bottom:14px;">
<div style="flex:1;">
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Prediction Accuracy per Class</p>
<img id="print-chart-accuracy" style="width:100%;max-height:160px;object-fit:contain;" src="" alt="">
</div>
<div style="flex:1;">
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Overall Outcome Breakdown</p>
<img id="print-chart-breakdown" style="width:100%;max-height:160px;object-fit:contain;" src="" alt="">
</div>
</div>
</div><!-- /print-only -->
<?php endif; ?>
</div>
<?= $this->section('scripts') ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
(function () {
<?php
$cLabels = [];
$cPredicted = [];
$cActual = [];
$cConfirmed = [];
$cSurprise = [];
$cMissed = [];
$cAccuracy = [];
foreach ($classResults as $cls) {
$cLabels[] = $cls['section_name'];
$cPredicted[] = $cls['predicted_count'];
$cActual[] = $cls['actual_count'];
$cConfirmed[] = $cls['confirmed'];
$cSurprise[] = $cls['surprises'];
$cMissed[] = $cls['missed'];
$cAccuracy[] = $cls['accuracy'];
}
?>
var labels = <?= json_encode($cLabels) ?>;
var predicted = <?= json_encode($cPredicted) ?>;
var actual = <?= json_encode($cActual) ?>;
var confirmed = <?= json_encode($cConfirmed) ?>;
var surprises = <?= json_encode($cSurprise) ?>;
var missed = <?= json_encode($cMissed) ?>;
var accuracy = <?= json_encode($cAccuracy) ?>;
/* ── Chart 1: grouped bar counts per class ── */
new Chart(document.getElementById('chart-counts'), {
type: 'bar',
data: {
labels: labels,
datasets: [
{ label: 'Predicted', data: predicted, backgroundColor: '#4A90E2', borderRadius: 3 },
{ label: 'Actual', data: actual, backgroundColor: '#f0a500', borderRadius: 3 },
{ label: 'Confirmed', data: confirmed, backgroundColor: '#28a745', borderRadius: 3 },
{ label: 'Surprises', data: surprises, backgroundColor: '#17a2b8', borderRadius: 3 },
{ label: 'Missed', data: missed, backgroundColor: '#fd7e14', borderRadius: 3 },
]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { position: 'bottom' } },
scales: {
x: { grid: { color: '#f0f0f0' } },
y: { beginAtZero: true, ticks: { stepSize: 1 }, grid: { color: '#f0f0f0' } }
}
}
});
/* ── Chart 2: accuracy % per class ── */
new Chart(document.getElementById('chart-accuracy'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Accuracy %',
data: accuracy,
backgroundColor: accuracy.map(function(a) {
return a >= 80 ? '#28a745' : a >= 50 ? '#f0a500' : '#dc3545';
}),
borderRadius: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true, max: 100,
ticks: { callback: function(v) { return v + '%'; } },
grid: { color: '#f0f0f0' }
}
}
}
});
/* ── Chart 3: doughnut overall outcome breakdown ── */
new Chart(document.getElementById('chart-breakdown'), {
type: 'doughnut',
data: {
labels: ['Confirmed', 'Surprises', 'Missed'],
datasets: [{
data: [<?= $totalConfirmed ?>, <?= $totalSurprise ?>, <?= $totalMissed ?>],
backgroundColor: ['#28a745', '#17a2b8', '#fd7e14'],
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: function(ctx) {
var total = ctx.dataset.data.reduce(function(a, b) { return a + b; }, 0);
var pct = total > 0 ? Math.round(ctx.parsed / total * 100) : 0;
return ctx.label + ': ' + ctx.parsed + ' (' + pct + '%)';
}
}
}
}
}
});
})();
function captureCharts() {
var map = {
'chart-counts': 'print-chart-counts',
'chart-accuracy': 'print-chart-accuracy',
'chart-breakdown': 'print-chart-breakdown',
};
Object.keys(map).forEach(function(canvasId) {
var canvas = document.getElementById(canvasId);
var img = document.getElementById(map[canvasId]);
if (canvas && img) img.src = canvas.toDataURL('image/png');
});
}
function printWithCharts() {
captureCharts();
window.print();
}
window.addEventListener('beforeprint', captureCharts);
</script>
<?= $this->endSection() ?>
<?= $this->endSection() ?>
+480
View File
@@ -0,0 +1,480 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
$classResults = $classResults ?? [];
$selectedYear = $selectedYear ?? '';
$selectedPercentile = $selectedPercentile ?? 75;
$years = $years ?? [];
$totalWinners = array_sum(array_map(fn($c) => 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;
?>
<style>
/* ── Screen: hide print-only blocks ── */
.print-only { display: none !important; }
/* ── Print ── */
@media print {
.no-print { display: none !important; }
.print-only { display: block !important; }
.screen-only { display: none !important; }
body { font-size: 11px; }
.container-fluid { padding: 0 !important; }
h2, h3 { font-size: 14px; margin-bottom: .4rem; }
.print-page-break { break-before: page; }
table { width: 100%; border-collapse: collapse; font-size: 10px; }
table th,
table td { border: 1px solid #bbb; padding: 3px 6px; }
table thead { background: #333 !important; color: #fff !important;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
table thead th { position: static !important; top: auto !important; box-shadow: none !important; }
table tfoot { background: #eee !important;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
.badge-m { color: #4A90E2; font-weight: bold; }
.badge-f { color: #E47AB0; font-weight: bold; }
}
</style>
<div class="container-fluid">
<!-- ══ SCREEN HEADER ══════════════════════════════════════════════════════ -->
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2 no-print">
<div>
<h2 class="mb-1"><i class="bi bi-trophy-fill text-warning me-2"></i>Trophy Winners</h2>
<p class="text-muted mb-0">
<?= esc($selectedYear) ?> &mdash; <?= (int)$selectedPercentile ?>th percentile &mdash;
<strong><?= $totalWinners ?></strong> winners across <strong><?= count($classResults) ?></strong> classes
</p>
</div>
<div class="d-flex gap-2">
<button onclick="window.print()" class="btn btn-primary btn-sm">
<i class="bi bi-printer-fill me-1"></i>Print
</button>
<a href="<?= site_url('administrator/trophy?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
</div>
</div>
<!-- Filter (screen only) -->
<form method="get" action="<?= site_url('administrator/trophy/winners') ?>"
class="row g-2 align-items-end mb-4 no-print">
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">School Year</label>
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
<?php foreach ($years as $yr): ?>
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>><?= esc($yr) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">Percentile</label>
<div class="input-group input-group-sm" style="width:110px;">
<input type="number" name="percentile" class="form-control"
min="1" max="99" step="1" value="<?= (int)$selectedPercentile ?>">
<span class="input-group-text">%</span>
</div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
</div>
</form>
<?php if (empty($classResults)): ?>
<div class="alert alert-info no-print">No projected trophy winners found.</div>
<?php else: ?>
<!-- ══ PRINT VIEW ════════════════════════════════════════════════════════ -->
<div class="print-only">
<!-- Print title -->
<div style="text-align:center;margin-bottom:12px;border-bottom:2px solid #333;padding-bottom:8px;">
<h2 style="margin:0;">Trophy Winners — <?= esc($selectedYear) ?></h2>
<p style="margin:4px 0 0;font-size:10px;color:#555;">
<?= (int)$selectedPercentile ?>th percentile &mdash;
<?= $totalWinners ?> winners / <?= $totalStudents ?> students &mdash;
<?= count($classResults) ?> classes
</p>
</div>
<!-- TABLE 1: All winners -->
<h3 style="margin-bottom:4px;">Winners</h3>
<table data-no-mgmt-sticky>
<thead>
<tr>
<th>#</th>
<th>Class</th>
<th>Name</th>
<th style="text-align:center;">Gender</th>
<th style="text-align:right;">Fall Score</th>
<th style="text-align:right;">Spring Score</th>
<th style="text-align:right;">Year Score</th>
</tr>
</thead>
<tbody>
<?php
$globalRank = 0;
foreach ($classResults as $cls):
foreach ($cls['winners'] as $student):
$globalRank++;
$isMale = strtolower($student['gender'] ?? '') === 'male';
?>
<tr>
<td style="text-align:center;"><?= $globalRank ?></td>
<td><?= esc($cls['section_name']) ?></td>
<td><strong><?= esc($student['name']) ?></strong></td>
<td style="text-align:center;">
<span class="<?= $isMale ? 'badge-m' : 'badge-f' ?>">
<?= $isMale ? 'M' : 'F' ?>
</span>
</td>
<td style="text-align:right;font-weight:bold;">
<?= $student['fall_score'] !== null ? number_format($student['fall_score'], 1) : '—' ?>
</td>
<td style="text-align:right;">
<?= $student['spring_score'] !== null ? number_format($student['spring_score'], 1) : '—' ?>
</td>
<td style="text-align:right;font-weight:bold;color:#155724;">
<?= $student['year_score'] !== null ? number_format($student['year_score'], 1) : '—' ?>
</td>
</tr>
<?php endforeach; endforeach; ?>
</tbody>
<tfoot>
<tr>
<td colspan="2" style="font-weight:bold;">Total</td>
<td colspan="2" style="font-weight:bold;"><?= $totalWinners ?> winners</td>
<td colspan="3" style="text-align:right;font-weight:bold;"><?= $pctWinners ?>% of students</td>
</tr>
</tfoot>
</table>
<!-- TABLE 2: Year summary (new page) -->
<div class="print-page-break"></div>
<h3 style="margin-bottom:4px;">Year Summary — <?= esc($selectedYear) ?></h3>
<table style="margin-bottom:16px;" data-no-mgmt-sticky>
<thead>
<tr>
<th>Class</th>
<th style="text-align:center;">Students</th>
<th style="text-align:center;">Boys</th>
<th style="text-align:center;">Girls</th>
<th style="text-align:center;">Winners</th>
<th style="text-align:center;">Trophy Boys</th>
<th style="text-align:center;">Trophy Girls</th>
<th style="text-align:center;">Rate</th>
<th style="text-align:right;">Fall Threshold</th>
</tr>
</thead>
<tbody>
<?php foreach ($classResults as $cls): ?>
<tr>
<td><?= esc($cls['section_name']) ?></td>
<td style="text-align:center;"><?= $cls['student_count'] ?></td>
<td style="text-align:center;"><?= $cls['boys'] ?> (<?= $cls['pct_boys'] ?>%)</td>
<td style="text-align:center;"><?= $cls['girls'] ?> (<?= $cls['pct_girls'] ?>%)</td>
<td style="text-align:center;font-weight:bold;"><?= count($cls['winners']) ?></td>
<td style="text-align:center;">
<span class="badge-m"><?= $cls['trophy_boys'] ?></span>
<?= $cls['boys'] > 0 ? '(' . $cls['pct_trophy_boys'] . '%)' : '' ?>
</td>
<td style="text-align:center;">
<span class="badge-f"><?= $cls['trophy_girls'] ?></span>
<?= $cls['girls'] > 0 ? '(' . $cls['pct_trophy_girls'] . '%)' : '' ?>
</td>
<td style="text-align:center;"><?= $cls['pct_trophy_total'] ?>%</td>
<td style="text-align:right;"><?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '—' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<td style="font-weight:bold;">Total</td>
<td style="text-align:center;font-weight:bold;"><?= $totalStudents ?></td>
<td style="text-align:center;font-weight:bold;"><?= $totalBoys ?> (<?= $pctBoys ?>%)</td>
<td style="text-align:center;font-weight:bold;"><?= $totalGirls ?> (<?= $pctGirls ?>%)</td>
<td style="text-align:center;font-weight:bold;"><?= $totalWinners ?></td>
<td style="text-align:center;font-weight:bold;">
<span class="badge-m"><?= $totalTrophyBoys ?></span> (<?= $pctTrophyBoys ?>%)
</td>
<td style="text-align:center;font-weight:bold;">
<span class="badge-f"><?= $totalTrophyGirls ?></span> (<?= $pctTrophyGirls ?>%)
</td>
<td style="text-align:center;font-weight:bold;"><?= $pctWinners ?>%</td>
<td></td>
</tr>
</tfoot>
</table>
</div><!-- /print-only -->
<!-- ══ SCREEN VIEW ═══════════════════════════════════════════════════════ -->
<div class="screen-only">
<!-- Per-class cards -->
<?php foreach ($classResults as $cls):
$nW = count($cls['winners']);
?>
<div class="card shadow-sm mb-4">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2 py-2"
style="background:#fff8e1;">
<div class="d-flex align-items-center gap-2">
<span class="fw-bold fs-6"><?= esc($cls['section_name']) ?></span>
<span class="badge bg-warning text-dark">
<i class="bi bi-trophy-fill"></i> <?= $nW ?> winner<?= $nW !== 1 ? 's' : '' ?>
</span>
<?php if ($cls['threshold'] !== null): ?>
<span class="text-muted small">Fall &#8805; <?= number_format((float)$cls['threshold'], 1) ?></span>
<?php endif; ?>
</div>
<div class="d-flex gap-3 small">
<span>
<i class="bi bi-gender-male" style="color:#4A90E2"></i>
<strong style="color:#4A90E2"><?= $cls['trophy_boys'] ?></strong>/<?= $cls['boys'] ?> boys
<?php if ($cls['boys'] > 0): ?><span class="text-muted">(<?= $cls['pct_trophy_boys'] ?>%)</span><?php endif; ?>
</span>
<span>
<i class="bi bi-gender-female" style="color:#E47AB0"></i>
<strong style="color:#E47AB0"><?= $cls['trophy_girls'] ?></strong>/<?= $cls['girls'] ?> girls
<?php if ($cls['girls'] > 0): ?><span class="text-muted">(<?= $cls['pct_trophy_girls'] ?>%)</span><?php endif; ?>
</span>
<span class="text-muted"><?= $nW ?>/<?= $cls['student_count'] ?> (<?= $cls['pct_trophy_total'] ?>%)</span>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0 align-middle" data-no-mgmt-sticky>
<thead class="table-light">
<tr>
<th class="ps-3" style="width:2.2rem;">#</th>
<th>Name</th>
<th class="text-center" style="width:3rem;">Gender</th>
<th class="text-end">Fall Score</th>
<th class="text-end">Spring Score</th>
<th class="text-end pe-3">Year Score</th>
</tr>
</thead>
<tbody>
<?php foreach ($cls['winners'] as $i => $student):
$isMale = strtolower($student['gender'] ?? '') === 'male';
?>
<tr>
<td class="ps-3 text-muted small"><?= $i + 1 ?></td>
<td class="fw-semibold small"><?= esc($student['name']) ?></td>
<td class="text-center">
<span class="badge" style="background:<?= $isMale ? '#4A90E2' : '#E47AB0' ?>;font-size:.65rem;">
<?= $isMale ? 'M' : 'F' ?>
</span>
</td>
<td class="text-end small fw-semibold">
<?= $student['fall_score'] !== null ? number_format($student['fall_score'], 1) : '<span class="text-muted">—</span>' ?>
</td>
<td class="text-end small">
<?= $student['spring_score'] !== null
? '<span class="fw-semibold">' . number_format($student['spring_score'], 1) . '</span>'
: '<span class="text-muted">—</span>' ?>
</td>
<td class="text-end pe-3 small fw-semibold" style="color:#155724;">
<?= $student['year_score'] !== null ? number_format($student['year_score'], 1) : '<span class="text-muted">—</span>' ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
<!-- Overall year stats -->
<div class="card shadow-sm mt-2">
<div class="card-header bg-dark text-white fw-semibold py-2">
<i class="bi bi-bar-chart-fill me-2"></i>Overall Year Summary — <?= esc($selectedYear) ?>
</div>
<div class="card-body">
<div class="row g-3 mb-3">
<?php foreach ([
[$totalWinners, 'Total Winners', 'warning', 'text-dark', $pctWinners . '% of students', null],
[$totalStudents, 'Total Students', 'secondary', 'text-white', count($classResults) . ' classes', null],
[$totalBoys, 'Total Boys', null, null, $pctBoys . '%', '#4A90E2'],
[$totalGirls, 'Total Girls', null, null, $pctGirls . '%', '#E47AB0'],
[$totalTrophyBoys, 'Trophy Boys', null, null, $pctTrophyBoys . '% of boys', '#4A90E2'],
[$totalTrophyGirls, 'Trophy Girls', null, null, $pctTrophyGirls . '% of girls', '#E47AB0'],
] as [$val, $label, $bg, $tc, $sub, $color]): ?>
<div class="col-6 col-sm-4 col-lg-2">
<div class="border rounded text-center p-3 h-100">
<div class="fs-3 fw-bold" <?= isset($color) ? 'style="color:' . $color . '"' : ($bg ? 'class="text-' . $bg . '"' : '') ?>>
<?= $val ?>
</div>
<div class="small text-muted"><?= $label ?></div>
<div class="badge mt-1 <?= $bg ? 'bg-' . $bg . ' ' . $tc : 'text-white' ?>"
<?= isset($color) && !$bg ? 'style="background:' . $color . '"' : '' ?>>
<?= $sub ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="row g-4 mt-1">
<!-- Stacked bar: trophies per class (boys + girls) -->
<div class="col-12 col-lg-7">
<div class="border rounded p-3 h-100">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-trophy-fill text-warning me-1"></i>Trophy Winners per Class
</div>
<canvas id="chart-per-class" style="max-height:260px;"></canvas>
</div>
</div>
<!-- Doughnut: trophy gender split + rate per class -->
<div class="col-12 col-lg-5">
<div class="row g-3 h-100">
<div class="col-12">
<div class="border rounded p-3">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-gender-ambiguous me-1"></i>Winners by Gender
</div>
<canvas id="chart-gender" style="max-height:160px;"></canvas>
</div>
</div>
<div class="col-12">
<div class="border rounded p-3">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-bar-chart-fill me-1"></i>Trophy Rate per Class (%)
</div>
<canvas id="chart-rate" style="max-height:130px;"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div><!-- /screen-only -->
<?php endif; ?>
</div>
<?= $this->section('scripts') ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
(function () {
<?php
$chartLabels = [];
$chartBoys = [];
$chartGirls = [];
$chartRates = [];
foreach ($classResults as $cls) {
$chartLabels[] = $cls['section_name'];
$chartBoys[] = $cls['trophy_boys'];
$chartGirls[] = $cls['trophy_girls'];
$chartRates[] = $cls['pct_trophy_total'];
}
?>
var labels = <?= json_encode($chartLabels) ?>;
var boys = <?= json_encode($chartBoys) ?>;
var girls = <?= json_encode($chartGirls) ?>;
var rates = <?= json_encode($chartRates) ?>;
var BOY_COLOR = '#4A90E2';
var GIRL_COLOR = '#E47AB0';
var RATE_COLOR = '#f0a500';
/* ── Chart 1: stacked horizontal bar per class ── */
new Chart(document.getElementById('chart-per-class'), {
type: 'bar',
data: {
labels: labels,
datasets: [
{ label: 'Boys', data: boys, backgroundColor: BOY_COLOR, stack: 'winners' },
{ label: 'Girls', data: girls, backgroundColor: GIRL_COLOR, stack: 'winners' }
]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { position: 'bottom' } },
scales: {
x: { stacked: true, ticks: { stepSize: 1 }, grid: { color: '#f0f0f0' } },
y: { stacked: true }
}
}
});
/* ── Chart 2: doughnut gender split ── */
new Chart(document.getElementById('chart-gender'), {
type: 'doughnut',
data: {
labels: ['Trophy Boys', 'Trophy Girls'],
datasets: [{
data: [<?= $totalTrophyBoys ?>, <?= $totalTrophyGirls ?>],
backgroundColor: [BOY_COLOR, GIRL_COLOR],
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: function(ctx) {
var total = ctx.dataset.data.reduce(function(a,b){ return a+b; }, 0);
var pct = total > 0 ? Math.round(ctx.parsed / total * 100) : 0;
return ctx.label + ': ' + ctx.parsed + ' (' + pct + '%)';
}
}
}
}
}
});
/* ── Chart 3: bar chart rate per class ── */
new Chart(document.getElementById('chart-rate'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Trophy Rate %',
data: rates,
backgroundColor: rates.map(function(r) {
return r > 30 ? '#dc3545' : r > 20 ? RATE_COLOR : '#28a745';
}),
borderRadius: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true, max: 100,
ticks: { callback: function(v) { return v + '%'; } },
grid: { color: '#f0f0f0' }
}
}
}
});
})();
</script>
<?= $this->endSection() ?>
<?= $this->endSection() ?>
+3
View File
@@ -104,6 +104,9 @@ $role = strtolower(session()->get('role') ?? 'guest');
<div class="dropdown-menu">
<a class="dropdown-item" href="/administrator/class_assignment">Classes List</a>
<a class="dropdown-item" href="/administrator/subject-curriculum">Subject Curriculum</a>
<a class="dropdown-item" href="/administrator/trophy">
<i class="bi bi-trophy-fill text-warning me-1"></i>Trophy Awards
</a>
</div>
</li>