390 lines
20 KiB
PHP
390 lines
20 KiB
PHP
<?= $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() ?>
|