recreate project

This commit is contained in:
root
2026-02-10 22:11:06 -05:00
commit 663c0cdbda
10149 changed files with 1379710 additions and 0 deletions
@@ -0,0 +1,582 @@
<?php
// Trophy prediction label (chance to win trophy)
function trophyChanceLabel($risk)
{
return match ($risk) {
'Achieved Trophy' => '<span class="text-success fw-bold">🏅 Trophy Achieved</span>',
'Top 3 Trophy' => '<span class="text-primary fw-bold">🥇 Top 3 Trophy</span>',
'Low' => '<span class="text-success fw-bold">🏆 Trophy within Reach</span>',
'Medium' => '<span class="text-warning fw-bold">⚠️ Needs Strong Effort</span>',
'High' => '<span class="text-danger fw-bold">❌ Unlikely to Win Trophy</span>',
'Unreachable' => '<span class="text-dark fw-bold bg-warning px-2 rounded">❌ Not Possible</span>',
'New student' => '<span class="text-muted fst-italic">⏳ No Data Yet</span>',
default => '<span class="text-body">Unknown</span>',
};
}
// Fail risk label (chance of failing)
function failRiskLabel($risk)
{
return match ($risk) {
'Low' => '<span class="text-success fw-bold">✅ Safe</span>',
'Medium' => '<span class="text-warning fw-bold">⚠️ Some Risk</span>',
'High' => '<span class="text-danger fw-bold">❗ At Risk</span>',
'Unlikely to pass' => '<span class="text-white fw-bold bg-danger px-2 rounded">❌ Cannot Pass</span>',
'New student' => '<span class="text-muted fst-italic">⏳ No Data Yet</span>',
default => '<span class="text-body">Unknown</span>',
};
}
// Status badge (pass/fail/undetermined)
function statusBadge($status)
{
return match ($status) {
'Passed' => '<span class="badge bg-success">✅ Passed</span>',
'Failed' => '<span class="badge bg-danger">❌ Failed</span>',
'Can pass' => '<span class="badge bg-primary">✅ Can pass</span>',
'May fail' => '<span class="badge bg-warning text-dark">⚠️ May fail</span>',
'Undetermined' => '<span class="badge bg-secondary">⏳ Undetermined</span>',
default => '<span class="badge bg-light text-dark">N/A</span>',
};
}
// Helper to output safe numeric (for data-order); returns '' for non-numeric/null
function orderNum($v)
{
return is_numeric($v) ? (float)$v : '';
}
?>
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid"><!-- full width page -->
<h2 class="text-center mt-4 mb-3">Student Success & Risk Report</h2>
<?php
// Build class section map and trophy counts
$sectionMap = [];
if (!empty($classSections) && is_array($classSections)) {
foreach ($classSections as $sec) {
$sectionMap[(string)($sec['class_section_id'] ?? '')] = $sec['class_section_name'] ?? ('Section ' . ($sec['class_section_id'] ?? ''));
}
}
$trophyCounts = [];
$totalTrophies = 0;
if (!empty($students) && is_array($students)) {
foreach ($students as $s) {
if (!empty($s['trophy_awarded'])) {
$cid = (string)($s['class_section_id'] ?? '');
if (!isset($trophyCounts[$cid])) $trophyCounts[$cid] = 0;
$trophyCounts[$cid]++;
$totalTrophies++;
}
}
}
// Build status + risk breakdowns for charts
$statusCounts = ['Passed' => 0, 'Failed' => 0, 'Can pass' => 0, 'May fail' => 0, 'Undetermined' => 0];
$riskBuckets = ['Low' => 0, 'Medium' => 0, 'High' => 0, 'Unlikely to pass' => 0, 'New student' => 0];
foreach ($students as $s) {
$status = $s['status'] ?? 'Undetermined';
if (isset($statusCounts[$status])) {
$statusCounts[$status]++;
} else {
$statusCounts['Undetermined']++;
}
$risk = $s['failure_risk'] ?? 'New student';
if (isset($riskBuckets[$risk])) {
$riskBuckets[$risk]++;
} else {
$riskBuckets['New student']++;
}
}
$totalStudents = array_sum($statusCounts);
$passRate = $totalStudents > 0 ? round(($statusCounts['Passed'] / $totalStudents) * 100, 1) : 0;
$failRate = $totalStudents > 0 ? round(($statusCounts['Failed'] / $totalStudents) * 100, 1) : 0;
$pendingCount = $statusCounts['Undetermined'] + $statusCounts['Can pass'] + $statusCounts['May fail'];
$undeterminedRate = $totalStudents > 0 ? round(($pendingCount / $totalStudents) * 100, 1) : 0;
?>
<div class="mb-4 text-center">
<p class="text-muted small mb-1">Active Students</p>
<div class="display-4 fw-semibold"><?= (int)$totalStudents ?></div>
<p class="text-muted small mb-0">Reflects only currently active students, so removals update the count instantly.</p>
</div>
<form method="get" class="row g-3 mb-4 align-items-end">
<div class="col-md-6 col-lg-4">
<label for="school_year" class="form-label">School Year</label>
<input type="text" class="form-control" id="school_year" name="school_year" value="<?= esc($school_year) ?>">
</div>
<div class="col-md-6 col-lg-4">
<label for="class_section_id" class="form-label">Class Section</label>
<div class="input-group">
<select class="form-select" name="class_section_id" id="class_section_id">
<option value="">-- All Sections --</option>
<?php foreach ($classSections as $section): ?>
<option value="<?= esc($section['class_section_id']) ?>" <?= (string)$selectedClassSectionId === (string)$section['class_section_id'] ? 'selected' : '' ?>>
<?= esc($section['class_section_name']) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-primary">Filter</button>
</div>
</div>
</form>
<div class="row g-3 mb-4">
<div class="col-lg-4">
<div class="card shadow-sm h-100">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<strong>Active Student Snapshot</strong>
<span class="badge bg-primary"><?= (int)$totalStudents ?> active</span>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-3">
<div class="flex-fill">
<div class="small text-muted">Pass rate</div>
<div class="fs-3 text-success fw-semibold"><?= $passRate ?>%</div>
<div class="text-muted small"><?= (int)$statusCounts['Passed'] ?> students</div>
</div>
<div class="flex-fill">
<div class="small text-muted">Failing</div>
<div class="fs-4 text-danger fw-semibold"><?= (int)$statusCounts['Failed'] ?></div>
<div class="text-muted small"><?= $failRate ?>% of roster</div>
</div>
<div class="flex-fill">
<div class="small text-muted">Pending / no data</div>
<div class="fs-4 text-secondary fw-semibold"><?= (int)$pendingCount ?></div>
<div class="text-muted small"><?= $undeterminedRate ?>% of roster</div>
</div>
</div>
<hr>
<p class="mb-1 small text-muted">Analyst notes</p>
<ul class="mb-0 small">
<li>Focus on <?= (int)$riskBuckets['High'] ?> high-risk students; prioritize their action plans.</li>
<li><?= (int)$statusCounts['Failed'] ?> students are currently failing—pair them with tutoring or parental outreach.</li>
<li><?= (int)$totalTrophies ?> trophy-eligible students; celebrate them to boost morale.</li>
</ul>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm h-100">
<div class="card-header py-2"><strong>Pass vs Fail</strong></div>
<div class="card-body">
<canvas id="passFailChart" height="200"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm h-100">
<div class="card-header py-2"><strong>Fail Risk Mix</strong></div>
<div class="card-body">
<canvas id="riskChart" height="200"></canvas>
</div>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header py-2">
<strong>Trophies Summary</strong>
</div>
<div class="card-body p-2">
<div class="small text-muted mb-2">
If a class section has no students meeting the trophy threshold, the top 3 ranked students in that section receive trophies.
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered table-striped mb-2 text-center align-middle no-mgmt-sticky" data-no-mgmt-sticky="1">
<thead class="table-light">
<tr>
<th class="text-start">Class Section</th>
<?php if (!empty($trophyCounts)): ?>
<?php
// Order summary by class section name ascending (AZ)
$orderCids = array_keys($trophyCounts);
usort($orderCids, function ($a, $b) use ($sectionMap) {
$na = $sectionMap[(string)$a] ?? (string)$a;
$nb = $sectionMap[(string)$b] ?? (string)$b;
return strcasecmp($na, $nb);
});
?>
<?php foreach ($orderCids as $cid): ?>
<th><?= esc($sectionMap[(string)$cid] ?? ('Section ' . $cid)) ?></th>
<?php endforeach; ?>
<?php else: ?>
<th class="text-muted fst-italic">No trophies yet.</th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php if (!empty($trophyCounts)): ?>
<!-- Trophy count row -->
<tr>
<th class="text-start">Trophy Counts</th>
<?php foreach ($orderCids as $cid): ?>
<td><?= (int)$trophyCounts[$cid] ?></td>
<?php endforeach; ?>
</tr>
<!-- Total row -->
<tr class="fw-bold table-light">
<th class="text-start">Total Trophies</th>
<td colspan="<?= count($trophyCounts) ?>" class="text-center">
<?= (int)$totalTrophies ?>
</td>
</tr>
<?php else: ?>
<tr>
<td colspan="2" class="text-muted fst-italic">No data available</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="table-responsive"><!-- keeps header styling + horizontal scroll -->
<table id="reportTable" class="table table-bordered table-striped align-middle w-100 no-mgmt-sticky no-dt-fixedheader" data-no-mgmt-sticky="1">
<thead class="table-dark">
<tr>
<th style="width:60px">#</th>
<th>School ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Class</th>
<th>Fall Score</th>
<th>Spring Score</th>
<th>Final Average</th>
<th>Needed for Trophy</th>
<th>Chance to Win Trophy</th>
<th>Needed to Pass (60+)</th>
<th>Risk of Failing</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<?php $i = 1;
foreach ($students as $student): ?>
<tr>
<td class="row-index"><?= $i++ ?></td>
<td><?= esc($student['school_id']) ?></td>
<td>
<?php $sid = (int)($student['student_id'] ?? 0); ?>
<?php if ($sid): ?>
<a href="#" class="text-decoration-none" data-family-student-id="<?= $sid ?>"><?= esc($student['firstname']) ?></a>
<?php else: ?>
<?= esc($student['firstname']) ?>
<?php endif; ?>
</td>
<td>
<?php if (!empty($sid)): ?>
<a href="#" class="text-decoration-none" data-family-student-id="<?= $sid ?>"><?= esc($student['lastname']) ?></a>
<?php else: ?>
<?= esc($student['lastname']) ?>
<?php endif; ?>
</td>
<?php
$cid = (string)($student['class_section_id'] ?? '');
$className = $sectionMap[$cid] ?? (!empty($cid) ? ('Section ' . $cid) : '—');
?>
<td data-order="<?= esc($className) ?>"><?= esc($className) ?></td>
<!-- Numeric columns with data-order to guarantee correct sorting -->
<td data-order="<?= orderNum($student['fall_score']) ?>">
<?= esc($student['fall_score']) ?>
</td>
<td data-order="<?= orderNum($student['spring_score']) ?>">
<?= esc($student['spring_score']) ?>
</td>
<td data-order="<?= orderNum($student['final_average']) ?>">
<?= esc($student['final_average']) ?>
</td>
<td data-order="<?= orderNum($student['required_spring_score']) ?>">
<?= esc($student['required_spring_score']) ?>
</td>
<!-- HTML/badge columns (not sortable) -->
<td><?= trophyChanceLabel($student['winning_risk']) ?></td>
<td data-order="<?= orderNum($student['required_spring_to_pass']) ?>">
<?= esc($student['required_spring_to_pass']) ?>
</td>
<td><?= failRiskLabel($student['failure_risk']) ?></td>
<td><?= statusBadge($student['status']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<style>
.table-dark th {
background-color: #343a40;
color: #fff;
}
.dataTables_wrapper {
width: 100%;
}
#reportTable {
width: 100% !important;
border-collapse: separate;
border-spacing: 0;
}
/* Prevent header text from overlapping rows; allow wrapping */
table.dataTable thead th,
table.dataTable thead td {
white-space: normal !important;
line-height: 1.25;
vertical-align: bottom;
}
/* Ensure body rows have enough height and don't overlap */
#reportTable tbody th,
#reportTable tbody td {
white-space: normal;
line-height: 1.35;
vertical-align: middle;
padding-top: .5rem;
padding-bottom: .5rem;
background-clip: padding-box;
}
/* With scrollX, keep default overflow and remove table margins to prevent overlap */
.dataTables_wrapper .dataTables_scrollHead table {
margin-bottom: 0 !important;
}
.dataTables_wrapper .dataTables_scrollBody table {
margin-top: 0 !important;
}
/* Subtle separation so first data row doesn't appear under the header */
.dataTables_wrapper .dataTables_scrollBody {
padding-top: 4px; /* subtle separation from header */
}
/* Extra separation between header and body containers */
.dataTables_wrapper .dataTables_scrollHead {
margin-bottom: 4px;
}
/* Hard override: never apply sticky headers to DataTables-managed tables */
.dataTables_wrapper table thead th,
.dataTables_wrapper table thead td {
position: static !important;
top: auto !important;
z-index: auto !important;
}
.dataTables_wrapper .dataTables_filter input {
width: 16em;
}
</style>
<!-- DataTables assets are already loaded in management_layout; avoid duplicate includes here. -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
(function() {
const statusCounts = <?= json_encode($statusCounts) ?>;
const riskBuckets = <?= json_encode($riskBuckets) ?>;
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
function renderCharts() {
if (!window.Chart) return false;
const passFailEl = document.getElementById('passFailChart');
const riskEl = document.getElementById('riskChart');
if (!passFailEl || !riskEl) return true;
const pfCtx = passFailEl.getContext('2d');
const riskCtx = riskEl.getContext('2d');
if (window._passFailChart) window._passFailChart.destroy();
if (window._riskChart) window._riskChart.destroy();
window._passFailChart = new Chart(pfCtx, {
type: 'doughnut',
data: {
labels: Object.keys(statusCounts),
datasets: [{
data: Object.values(statusCounts),
backgroundColor: ['#198754', '#dc3545', '#0d6efd', '#ffc107', '#6c757d'],
borderWidth: 0
}]
},
options: {
plugins: {
legend: { position: 'bottom' }
},
responsive: true,
cutout: '65%'
}
});
window._riskChart = new Chart(riskCtx, {
type: 'bar',
data: {
labels: Object.keys(riskBuckets),
datasets: [{
label: 'Students',
data: Object.values(riskBuckets),
backgroundColor: ['#198754', '#ffc107', '#dc3545', '#6610f2', '#adb5bd']
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false }
},
scales: {
y: { beginAtZero: true, ticks: { precision: 0 } }
}
}
});
return true;
}
ready(function() {
if (renderCharts()) return;
var tries = 0,
timer = setInterval(function() {
tries++;
if (renderCharts() || tries > 20) clearInterval(timer);
}, 100);
});
})();
</script>
<script>
(function() {
// Wait until jQuery + DataTables are actually available, then init once.
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
function initReport() {
if (!window.jQuery || !jQuery.fn || !jQuery.fn.dataTable) return false;
var $ = jQuery;
var $table = $('#reportTable');
if (!$table.length) return true; // nothing to do
// Re-init safety
if ($.fn.dataTable.isDataTable($table[0])) {
$table.DataTable().destroy();
}
$.fn.dataTable.ext.errMode = 'console';
var dt = $table.DataTable({
stateSave: true,
pageLength: 100,
lengthMenu: [10, 25, 50, 100],
scrollX: true,
scrollCollapse: true,
autoWidth: true,
orderCellsTop: true,
fixedHeader: false,
// Sort by Last Name (col 3), then First Name (col 2)
order: [
[3, 'asc'],
[2, 'asc']
],
columnDefs: [{
targets: 0,
orderable: false,
searchable: false
}, // row #
{
targets: [9, 11, 12],
orderable: false
}, // Trophy, Fail Risk, Status
// Treat numeric columns as numbers (DataTables also respects data-order attr)
{
targets: [5, 6, 7, 8, 10],
type: 'num'
}
],
dom: '<"row mb-2"<"col-sm-6"l><"col-sm-6"f>>t<"row mt-2"<"col-sm-6"i><"col-sm-6"p>>'
});
// Re-number the first column on sort/search/paginate
$table.on('order.dt search.dt draw.dt', function() {
var info = dt.page.info();
dt.column(0, {
search: 'applied',
order: 'applied',
page: 'current'
})
.nodes()
.each(function(cell, i) {
cell.innerHTML = i + 1 + info.start;
});
});
// Initial numbering + minor layout adjust
dt.draw(false);
// Adjust columns after initial render and once fonts/assets settle
setTimeout(function() {
dt.columns.adjust().draw(false);
}, 0);
setTimeout(function() {
dt.columns.adjust().draw(false);
}, 200);
setTimeout(function() {
dt.columns.adjust().draw(false);
}, 600);
// One more pass on next frame for browsers that lay out fonts asynchronously
if (window.requestAnimationFrame) {
requestAnimationFrame(function(){
dt.columns.adjust().draw(false);
});
}
// Also adjust on window load/resize to avoid header/body misalignment
window.addEventListener('load', function() {
dt.columns.adjust().draw(false);
});
window.addEventListener('resize', function() {
dt.columns.adjust().draw(false);
});
return true;
}
ready(function() {
// Try immediately; if assets are deferred, retry a few times.
if (initReport()) return;
var tries = 0,
timer = setInterval(function() {
tries++;
if (initReport() || tries > 20) clearInterval(timer); // ~2s max
}, 100);
});
})();
</script>
<?= $this->endSection() ?>