add trophy prediction and treshold
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
|
||||
/*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
·
|
||||
<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() ?>
|
||||
@@ -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 & 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 ≥ <strong><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></strong></span>
|
||||
<span class="text-muted">Year ≥ <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 —
|
||||
Predicted: <?= $totalPredicted ?> —
|
||||
Actual: <?= $totalActual ?> —
|
||||
Confirmed: <?= $totalConfirmed ?> —
|
||||
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() ?>
|
||||
@@ -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) ?> — <?= (int)$selectedPercentile ?>th percentile —
|
||||
<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 —
|
||||
<?= $totalWinners ?> winners / <?= $totalStudents ?> students —
|
||||
<?= 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 ≥ <?= 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() ?>
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user