Files
2026-05-30 03:19:10 -04:00

643 lines
25 KiB
PHP

<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid">
<div class="wrapper">
<?php
// ── Group sections by grade (mirrors daily_attendance logic) ─────────────────
$gradeGroups = []; // sortKey => ['label'=>, 'slug'=>, 'csids'=>[]]
foreach ($statsPerClass as $csid => $cs) {
$name = $cs['name'];
$lower = strtolower(trim($name));
if (preg_match('/grade\s*(\d+)/i', $name, $m)) {
$n = (int)$m[1];
$label = 'Grade ' . $n;
$sortKey = '1_' . str_pad($n, 3, '0', STR_PAD_LEFT);
$slug = 'grade' . $n;
} elseif (strpos($lower, 'kg') !== false || strpos($lower, 'kindergarten') !== false) {
$label = 'KG';
$sortKey = '0_kg';
$slug = 'kg';
} elseif (strpos($lower, 'arabic') !== false) {
$label = 'Arabic';
$sortKey = '2_arabic';
$slug = 'arabic';
} elseif (strpos($lower, 'youth') !== false) {
$label = 'Youth';
$sortKey = '5_youth';
$slug = 'youth';
} else {
$label = $name;
$sortKey = '3_' . $lower;
$slug = preg_replace('/[^a-z0-9]+/', '-', $lower);
}
if (!isset($gradeGroups[$sortKey])) {
$gradeGroups[$sortKey] = ['label' => $label, 'slug' => $slug, 'csids' => []];
}
$gradeGroups[$sortKey]['csids'][] = $csid;
}
ksort($gradeGroups);
$gradeKeys = array_keys($gradeGroups);
$defaultKey = $gradeKeys[0] ?? null;
$decisionBadge = [
'Pass' => 'success',
'Repeat Class' => 'danger',
'Make-up exam in fall' => 'info',
'Deferred decision' => 'info',
'Expel' => 'danger',
'Withdrawn' => 'secondary',
];
?>
<h2 class="text-center mt-4 mb-3">
<i class="bi bi-award me-2"></i>Generate Certificates
</h2>
<!-- School year filter -->
<div class="d-flex justify-content-end mb-3">
<form method="get" action="<?= site_url('administrator/certificates') ?>" class="d-flex gap-2 align-items-center">
<label class="form-label mb-0 me-1 text-muted small">School Year</label>
<input type="text"
name="school_year"
class="form-control form-control-sm"
style="width:130px;"
value="<?= esc($schoolYear) ?>"
placeholder="e.g. 2024-2025">
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-arrow-repeat me-1"></i>Reload
</button>
</form>
</div>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<?= esc(session()->getFlashdata('error')) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<?php if (empty($gradeGroups)): ?>
<div class="alert alert-info">No classes found for <?= esc($schoolYear) ?>.</div>
<?php else: ?>
<!-- Grade tabs -->
<ul class="nav nav-tabs justify-content-center" id="certTabs" role="tablist" style="flex-wrap:wrap;row-gap:.25rem;">
<?php foreach ($gradeGroups as $key => $group): ?>
<?php
$slug = $group['slug'];
$label = $group['label'];
$total = array_sum(array_map(fn($id) => $statsPerClass[$id]['total'] ?? 0, $group['csids']));
$grpPass = array_sum(array_map(fn($id) => $statsPerClass[$id]['pass'] ?? 0, $group['csids']));
$grpCert = array_sum(array_map(fn($id) => $statsPerClass[$id]['cert'] ?? 0, $group['csids']));
$fullyDone = $grpPass > 0 && $grpCert >= $grpPass;
$hasPass = $grpPass > 0;
$isActive = ($key === $defaultKey);
$statusTitle = $hasPass
? ($fullyDone ? 'Fully generated (' . $grpCert . '/' . $grpPass . ')' : 'Not fully generated (' . $grpCert . '/' . $grpPass . ')')
: 'No eligible students';
?>
<li class="nav-item" role="presentation">
<a class="nav-link <?= $isActive ? 'active' : '' ?>"
id="cert-<?= esc($slug) ?>-tab"
data-bs-toggle="tab"
href="#cert-<?= esc($slug) ?>"
role="tab"
aria-controls="cert-<?= esc($slug) ?>"
aria-selected="<?= $isActive ? 'true' : 'false' ?>">
<span class="cert-status-dot <?= !$hasPass ? 'no-eligible' : ($fullyDone ? 'done' : 'pending') ?>"
title="<?= esc($statusTitle) ?>"></span>
<?= esc($label) ?>
<span class="badge bg-secondary ms-1"><?= $total ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
<!-- Tab content -->
<div class="tab-content mt-3" id="certTabContent">
<?php foreach ($gradeGroups as $key => $group): ?>
<?php $isActive = ($key === $defaultKey); ?>
<div class="tab-pane fade <?= $isActive ? 'show active' : '' ?>"
id="cert-<?= esc($group['slug']) ?>"
role="tabpanel"
aria-labelledby="cert-<?= esc($group['slug']) ?>-tab">
<?php foreach ($group['csids'] as $csid): ?>
<?php
$cs = $statsPerClass[$csid];
$students = $studentsByClass[$csid] ?? [];
$csPass = $cs['pass'];
$csCert = $cs['cert'];
$csRemain = max(0, $csPass - $csCert);
$formId = 'certForm-' . $csid;
$tableId = 'studentsTable-' . $csid;
?>
<h4 class="mt-4 mb-2 text-center"><?= esc($cs['name']) ?></h4>
<?php if (empty($students)): ?>
<p class="text-muted text-center">No active students.</p>
<?php else: ?>
<form method="post"
action="<?= site_url('administrator/certificates/generate') ?>"
id="<?= esc($formId) ?>"
class="cert-form mb-5">
<?= csrf_field() ?>
<input type="hidden" name="class_section_id" value="<?= (int)$csid ?>">
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
<!-- Stats + date picker -->
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<div class="d-flex align-items-center gap-4 flex-wrap">
<span class="fw-semibold">
Students <span class="badge bg-secondary ms-1"><?= count($students) ?></span>
</span>
<span class="text-muted small">
<strong class="text-success"><?= $csPass ?></strong> Pass
</span>
<span class="text-muted small">
<strong class="text-primary"><?= $csCert ?></strong> Generated
</span>
<span class="text-muted small">
<strong class="<?= $csRemain > 0 ? 'text-warning' : 'text-muted' ?>">
<?= $csRemain ?>
</strong>
Remaining
</span>
</div>
<div class="input-group input-group-sm" style="width:200px;">
<span class="input-group-text"><i class="bi bi-calendar3"></i></span>
<input type="date"
class="form-control cert-date-picker"
value="<?= date('Y-m-d') ?>"
title="Certificate date">
<input type="hidden"
name="cert_date"
class="cert-date-hidden"
value="<?= esc($certDate) ?>">
</div>
</div>
<!-- Student table -->
<div class="table-responsive">
<table class="table table-hover table-striped align-middle mb-0 cert-students-table"
id="<?= esc($tableId) ?>">
<thead class="table-light">
<tr>
<th style="width:40px;" class="text-center">
<input class="form-check-input cert-select-all" type="checkbox">
</th>
<th>First Name</th>
<th>Last Name</th>
<th class="text-center">Year Score</th>
<th>Decision</th>
<th>Certificate No.</th>
</tr>
</thead>
<tbody>
<?php foreach ($students as $s): ?>
<?php
$sid = (int)$s['student_id'];
$stuDec = $decisionsByStudent[$sid] ?? [];
$certNo = $certsByStudent[$sid] ?? null;
$allDecs = array_map(
fn($d) => trim((string)($d['decision'] ?? '')),
$stuDec
);
$hasPending = in_array('', $allDecs, true);
$unique = array_values(array_unique(array_filter(
$allDecs,
fn($d) => $d !== ''
)));
$isPass = !empty($stuDec) && !$hasPending && $unique === ['Pass'];
$displayDecs = $isPass
? ['Pass']
: array_values(array_filter($unique, fn($d) => $d !== 'Pass'));
$yearScore = null;
foreach ($stuDec as $d) {
if (
array_key_exists('year_score', $d)
&& $d['year_score'] !== null
&& $d['year_score'] !== ''
) {
$yearScore = $d['year_score'];
break;
}
}
$fmtYearScore = is_numeric($yearScore)
? number_format((float)$yearScore, 2)
: '—';
?>
<tr>
<td class="text-center">
<input class="form-check-input cert-student-check"
type="checkbox"
name="student_ids[]"
value="<?= $sid ?>"
<?= $isPass ? '' : 'disabled' ?>>
</td>
<td><?= esc($s['firstname']) ?></td>
<td><?= esc($s['lastname']) ?></td>
<td class="text-center fw-semibold">
<?= esc($fmtYearScore) ?>
</td>
<td>
<?php if (empty($stuDec)): ?>
<span class="text-muted small">—</span>
<?php elseif ($hasPending || empty($unique) || empty($displayDecs)): ?>
<span class="badge bg-warning text-dark">Pending</span>
<?php else: ?>
<?php foreach ($displayDecs as $dec): ?>
<?php $color = $decisionBadge[$dec] ?? 'secondary'; ?>
<span class="badge bg-<?= esc($color) ?> me-1">
<?= esc($dec) ?>
</span>
<?php endforeach; ?>
<?php endif; ?>
</td>
<td>
<?php if ($certNo): ?>
<a href="<?= site_url('administrator/certificates/reprint/' . rawurlencode($certNo)) ?>"
target="_blank"
class="font-monospace small">
<?= esc($certNo) ?>
</a>
<?php else: ?>
<span class="text-muted">—</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Footer -->
<div class="d-flex justify-content-between align-items-center mt-2">
<span class="text-muted small cert-selected-count">0 students selected</span>
<button type="submit" class="btn btn-success cert-generate-btn" disabled>
<i class="bi bi-printer me-1"></i>Generate &amp; Print Certificates
</button>
</div>
</form>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div><!-- .wrapper -->
</div><!-- .container-fluid -->
<script>
(function () {
const csrfRefreshUrl = <?= json_encode(site_url('administrator/certificates/csrf-token'), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
let currentCsrfTokenName = <?= json_encode(csrf_token(), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
function cssEscape(v) {
return window.CSS?.escape ? window.CSS.escape(v) : String(v).replace(/(["\\.#:[\],= ])/g, '\\$1');
}
function updateCsrfInForm(form, name, hash) {
if (!form || !name || !hash) return;
form.querySelectorAll('input[type="hidden"]').forEach(inp => {
if (inp.name === name || inp.name === currentCsrfTokenName) {
inp.name = name;
inp.value = hash;
}
});
let inp = form.querySelector(`input[name="${cssEscape(name)}"]`);
if (!inp) {
inp = document.createElement('input');
inp.type = 'hidden';
inp.name = name;
form.appendChild(inp);
}
inp.value = hash;
currentCsrfTokenName = name;
}
async function refreshCsrf(form) {
const r = await fetch(csrfRefreshUrl, {
method: 'GET',
credentials: 'same-origin',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Cache-Control': 'no-store'
}
});
if (!r.ok) throw new Error('CSRF refresh failed');
const d = await r.json();
if (d?.csrf_token && d?.csrf_hash) {
updateCsrfInForm(form, d.csrf_token, d.csrf_hash);
}
}
let pdfWindow = null;
let activePdfBlobUrl = null;
function openPdfWindow() {
if (!pdfWindow || pdfWindow.closed) {
pdfWindow = window.open('', 'certificatePdfWindow');
}
if (!pdfWindow) return null;
pdfWindow.document.open();
pdfWindow.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Certificate PDF</title>
<style>
html,body{margin:0;height:100%;background:#f3f4f6;font-family:Arial,sans-serif}
.viewer-shell{display:flex;flex-direction:column;height:100%}
.viewer-status{padding:12px 16px;background:#111827;color:#fff;font-size:14px}
.viewer-frame{flex:1;width:100%;border:0;background:#cbd5e1}
</style></head>
<body>
<div class="viewer-shell">
<div class="viewer-status" id="viewerStatus">Preparing certificate PDF...</div>
<iframe class="viewer-frame" id="pdfFrame"></iframe>
</div>
<script>
window.showCertificatePdf=function(url,fn){
const f=document.getElementById('pdfFrame'),s=document.getElementById('viewerStatus');
if(s)s.textContent=fn?'Showing '+fn:'Certificate PDF ready';
if(f)f.src=url;
document.title=fn||'Certificate PDF';
};
<\/script>
</body></html>`);
pdfWindow.document.close();
return pdfWindow;
}
function showError(pane, msg) {
let el = pane.querySelector('.cert-inline-error');
if (!el) {
el = document.createElement('div');
el.className = 'alert alert-danger alert-dismissible fade show cert-inline-error';
pane.prepend(el);
}
el.innerHTML = `${msg}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
}
document.querySelectorAll('.cert-form').forEach(function (form) {
const pane = form.closest('.tab-pane') || form.parentElement;
const selectAll = form.querySelector('.cert-select-all');
const checks = form.querySelectorAll('.cert-student-check');
const eligibleChecks = form.querySelectorAll('.cert-student-check:not([disabled])');
const generateBtn = form.querySelector('.cert-generate-btn');
const selectedCount = form.querySelector('.cert-selected-count');
const datePicker = form.querySelector('.cert-date-picker');
const dateHidden = form.querySelector('.cert-date-hidden');
if (datePicker && dateHidden) {
datePicker.addEventListener('change', function () {
const d = new Date(this.value + 'T00:00:00');
if (!isNaN(d)) {
dateHidden.value =
String(d.getMonth() + 1).padStart(2, '0') + '/' +
String(d.getDate()).padStart(2, '0') + '/' +
d.getFullYear();
}
});
}
function updateState() {
const chosen = form.querySelectorAll('.cert-student-check:checked').length;
if (selectedCount) {
selectedCount.textContent = chosen + ' student' + (chosen !== 1 ? 's' : '') + ' selected';
}
if (generateBtn) {
generateBtn.disabled = chosen === 0;
}
if (selectAll) {
const ec = eligibleChecks.length;
selectAll.checked = ec > 0 && chosen === ec;
selectAll.indeterminate = chosen > 0 && chosen < ec;
}
}
if (selectAll) {
selectAll.addEventListener('change', function () {
eligibleChecks.forEach(c => {
c.checked = this.checked;
});
updateState();
});
}
checks.forEach(c => c.addEventListener('change', updateState));
updateState();
form.addEventListener('submit', async function (e) {
e.preventDefault();
const chosen = form.querySelectorAll('.cert-student-check:checked').length;
if (chosen === 0) {
showError(pane, 'Please select at least one student.');
return;
}
const win = openPdfWindow();
if (!win) {
showError(pane, 'Unable to open PDF tab — please allow pop-ups.');
return;
}
const origHtml = generateBtn.innerHTML;
generateBtn.disabled = true;
generateBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Generating...';
try {
await refreshCsrf(form);
const csrfVal = form.querySelector(`input[name="${cssEscape(currentCsrfTokenName)}"]`)?.value || '';
const fd = new FormData(form);
if (csrfVal) {
fd.set(currentCsrfTokenName, csrfVal);
}
const resp = await fetch(form.action, {
method: 'POST',
body: fd,
credentials: 'same-origin',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
const nn = resp.headers.get('X-CSRF-TOKEN-NAME');
const nh = resp.headers.get('X-CSRF-TOKEN');
if (nn && nh) {
updateCsrfInForm(form, nn, nh);
}
const ct = resp.headers.get('Content-Type') || '';
if (!resp.ok || !ct.toLowerCase().includes('application/pdf')) {
let msg = 'Certificate generation failed.';
try {
const d = await resp.json();
if (d?.error) {
msg = d.error;
}
} catch (_) {
try {
msg = await resp.text() || msg;
} catch (_2) {}
}
win.close();
showError(pane, msg);
await refreshCsrf(form).catch(() => {});
return;
}
const disp = resp.headers.get('Content-Disposition') || '';
const fn = (disp.match(/filename="?([^"]+)"?/i) || [])[1] || 'Certificates.pdf';
const url = URL.createObjectURL(await resp.blob());
if (activePdfBlobUrl) {
URL.revokeObjectURL(activePdfBlobUrl);
}
activePdfBlobUrl = url;
win.showCertificatePdf(url, fn);
const activePane = form.closest('.tab-pane');
if (activePane?.id) {
window.location.hash = activePane.id;
}
window.location.reload();
} catch (_) {
showError(pane, 'Certificate generation failed. Please try again.');
await refreshCsrf(form).catch(() => {});
} finally {
generateBtn.innerHTML = origHtml;
updateState();
}
});
});
})();
</script>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
// Restore tab from hash — runs after Bootstrap is loaded
(function () {
const hash = window.location.hash;
if (!hash) return;
const tabLink = document.querySelector('a[href="' + hash + '"][data-bs-toggle="tab"]');
if (tabLink && window.bootstrap?.Tab) {
new bootstrap.Tab(tabLink).show();
}
})();
</script>
<style>
.cert-status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
vertical-align: middle;
flex-shrink: 0;
}
.cert-status-dot.done {
background-color: #198754;
}
.cert-status-dot.pending {
background-color: #dc3545;
}
.cert-status-dot.no-eligible {
background-color: #adb5bd;
}
</style>
<script>
(function () {
if (!window.$ || !$.fn?.DataTable) return;
$(function () {
document.querySelectorAll('.cert-students-table').forEach(function (tbl) {
if ($.fn.DataTable.isDataTable(tbl)) return;
try {
$(tbl).DataTable({
order: [[1, 'asc'], [2, 'asc']],
pageLength: 100,
lengthMenu: [25, 50, 100, 200],
columnDefs: [
{ orderable: false, targets: [0, 4, 5] }
]
});
} catch (_) {}
});
});
})();
</script>
<?= $this->endSection() ?>