446 lines
17 KiB
PHP
446 lines
17 KiB
PHP
<?= $this->extend('layout/management_layout') ?>
|
|
<?= $this->section('content') ?>
|
|
|
|
<div class="container-fluid py-4">
|
|
|
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
|
<div>
|
|
<h2 class="mb-0"><i class="bi bi-award me-2"></i>Generate Certificates</h2>
|
|
<div class="text-muted small">Select a class, choose students, then generate and print their certificates.</div>
|
|
</div>
|
|
</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; ?>
|
|
|
|
<!-- Filter form -->
|
|
<div class="card shadow-sm mb-4">
|
|
<div class="card-header fw-semibold">Filter Students</div>
|
|
<div class="card-body">
|
|
<form method="get" action="<?= site_url('administrator/certificates') ?>" id="filterForm">
|
|
<div class="row g-3 align-items-end">
|
|
<div class="col-12 col-md-5">
|
|
<label class="form-label fw-semibold mb-1">Class / Section</label>
|
|
<select name="class_section_id" class="form-select" id="classSectionSelect">
|
|
<option value="">— Select a class —</option>
|
|
<?php foreach ($classSections as $cs): ?>
|
|
<option value="<?= esc($cs['class_section_id']) ?>"
|
|
<?= ((string)$selectedClassId === (string)$cs['class_section_id']) ? 'selected' : '' ?>>
|
|
<?= esc($cs['class_section_name']) ?>
|
|
</option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
<div class="col-12 col-md-3">
|
|
<label class="form-label fw-semibold mb-1">School Year</label>
|
|
<input type="text" name="school_year" class="form-control" value="<?= esc($schoolYear) ?>" placeholder="e.g. 2024-2025">
|
|
</div>
|
|
<div class="col-auto">
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="bi bi-funnel me-1"></i>Load Students
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<?php if (!empty($students)): ?>
|
|
<!-- Certificate generation form -->
|
|
<form method="post" action="<?= site_url('administrator/certificates/generate') ?>" id="certForm">
|
|
<?= csrf_field() ?>
|
|
<input type="hidden" name="class_section_id" value="<?= esc($selectedClassId) ?>">
|
|
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
|
|
|
|
<div class="card shadow-sm mb-3">
|
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
|
<span class="fw-semibold">
|
|
Students
|
|
<span class="badge bg-secondary ms-1"><?= count($students) ?></span>
|
|
</span>
|
|
<div class="d-flex align-items-center gap-3">
|
|
<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" id="certDatePicker"
|
|
value="<?= date('Y-m-d') ?>" title="Certificate date">
|
|
<input type="hidden" name="cert_date" id="certDateHidden" value="<?= esc($certDate) ?>">
|
|
</div>
|
|
<div class="form-check mb-0">
|
|
<input class="form-check-input" type="checkbox" id="selectAll">
|
|
<label class="form-check-label" for="selectAll">Select all</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover table-striped align-middle mb-0 no-mgmt-sticky" id="studentsTable">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width:40px;"></th>
|
|
<th>Last Name</th>
|
|
<th>First Name</th>
|
|
<th>Grade / Class</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($students as $s): ?>
|
|
<tr>
|
|
<td class="text-center">
|
|
<input class="form-check-input student-check" type="checkbox"
|
|
name="student_ids[]" value="<?= (int) $s['id'] ?>">
|
|
</td>
|
|
<td><?= esc($s['lastname']) ?></td>
|
|
<td><?= esc($s['firstname']) ?></td>
|
|
<td><?= esc($s['grade'] ?? '') ?></td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-footer d-flex justify-content-between align-items-center">
|
|
<span class="text-muted small" id="selectedCount">0 students selected</span>
|
|
<button type="submit" class="btn btn-success" id="generateBtn" disabled>
|
|
<i class="bi bi-printer me-1"></i>Generate & Print Certificates
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<?php elseif ($selectedClassId !== null && $selectedClassId !== ''): ?>
|
|
<div class="alert alert-warning">No active students found for the selected class and school year.</div>
|
|
<?php else: ?>
|
|
<div class="alert alert-info">Select a class above to load its student roster.</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
const certForm = document.getElementById('certForm');
|
|
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 syncCsrfField() {
|
|
if (!certForm) {
|
|
return '';
|
|
}
|
|
|
|
let tokenInput = certForm.querySelector(`input[name="${cssEscape(currentCsrfTokenName)}"]`);
|
|
if (!tokenInput) {
|
|
tokenInput = document.createElement('input');
|
|
tokenInput.type = 'hidden';
|
|
tokenInput.name = currentCsrfTokenName;
|
|
certForm.appendChild(tokenInput);
|
|
}
|
|
|
|
return tokenInput.value || '';
|
|
}
|
|
|
|
function updateCsrfToken(name, hash) {
|
|
if (!certForm || !name || !hash) {
|
|
return;
|
|
}
|
|
|
|
certForm.querySelectorAll('input[type="hidden"]').forEach((input) => {
|
|
if (input.name === name || input.name === currentCsrfTokenName) {
|
|
input.name = name;
|
|
input.value = hash;
|
|
}
|
|
});
|
|
|
|
let tokenInput = certForm.querySelector(`input[name="${cssEscape(name)}"]`);
|
|
if (!tokenInput) {
|
|
tokenInput = document.createElement('input');
|
|
tokenInput.type = 'hidden';
|
|
tokenInput.name = name;
|
|
certForm.appendChild(tokenInput);
|
|
}
|
|
tokenInput.value = hash;
|
|
currentCsrfTokenName = name;
|
|
}
|
|
|
|
function cssEscape(value) {
|
|
if (window.CSS && typeof window.CSS.escape === 'function') {
|
|
return window.CSS.escape(value);
|
|
}
|
|
return String(value).replace(/(["\\.#:[\],= ])/g, '\\$1');
|
|
}
|
|
|
|
function showInlineError(message) {
|
|
const existing = document.getElementById('certFormError');
|
|
if (existing) {
|
|
existing.remove();
|
|
}
|
|
|
|
const alert = document.createElement('div');
|
|
alert.id = 'certFormError';
|
|
alert.className = 'alert alert-danger alert-dismissible fade show';
|
|
alert.setAttribute('role', 'alert');
|
|
alert.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
|
|
|
const container = document.querySelector('.container-fluid.py-4');
|
|
const firstCard = document.querySelector('.card.shadow-sm.mb-4');
|
|
if (container && firstCard) {
|
|
container.insertBefore(alert, firstCard);
|
|
} else if (container) {
|
|
container.prepend(alert);
|
|
}
|
|
}
|
|
|
|
async function refreshCsrfToken() {
|
|
if (!certForm) {
|
|
return;
|
|
}
|
|
|
|
const response = await fetch(csrfRefreshUrl, {
|
|
method: 'GET',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'Cache-Control': 'no-store',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Unable to refresh CSRF token.');
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (data?.csrf_token && data?.csrf_hash) {
|
|
updateCsrfToken(data.csrf_token, data.csrf_hash);
|
|
} else {
|
|
throw new Error('Invalid CSRF token response.');
|
|
}
|
|
}
|
|
|
|
// Sync date picker → hidden field (MM/DD/YYYY format for the certificate)
|
|
const datePicker = document.getElementById('certDatePicker');
|
|
const dateHidden = document.getElementById('certDateHidden');
|
|
if (datePicker && dateHidden) {
|
|
datePicker.addEventListener('change', function () {
|
|
const d = new Date(this.value + 'T00:00:00');
|
|
if (!isNaN(d)) {
|
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
const dd = String(d.getDate()).padStart(2, '0');
|
|
const yy = d.getFullYear();
|
|
dateHidden.value = mm + '/' + dd + '/' + yy;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Select-all toggle
|
|
const selectAll = document.getElementById('selectAll');
|
|
const checks = document.querySelectorAll('.student-check');
|
|
const generateBtn = document.getElementById('generateBtn');
|
|
const selectedCount = document.getElementById('selectedCount');
|
|
let pdfWindow = null;
|
|
let activePdfBlobUrl = null;
|
|
|
|
function renderPdfWindowShell(targetWindow) {
|
|
if (!targetWindow) {
|
|
return;
|
|
}
|
|
|
|
targetWindow.document.open();
|
|
targetWindow.document.write(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Certificate PDF</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<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" title="Certificate PDF preview"></iframe>
|
|
</div>
|
|
<script>
|
|
window.showCertificatePdf = function (url, filename) {
|
|
const frame = document.getElementById('pdfFrame');
|
|
const status = document.getElementById('viewerStatus');
|
|
if (status) {
|
|
status.textContent = filename ? ('Showing ' + filename) : 'Certificate PDF ready';
|
|
}
|
|
if (frame) {
|
|
frame.src = url;
|
|
}
|
|
document.title = filename || 'Certificate PDF';
|
|
};
|
|
<\/script>
|
|
</body>
|
|
</html>`);
|
|
targetWindow.document.close();
|
|
}
|
|
|
|
function openPdfWindow() {
|
|
if (!pdfWindow || pdfWindow.closed) {
|
|
pdfWindow = window.open('', 'certificatePdfWindow');
|
|
}
|
|
|
|
if (!pdfWindow) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
renderPdfWindowShell(pdfWindow);
|
|
return pdfWindow;
|
|
} catch (error) {
|
|
pdfWindow = window.open('', 'certificatePdfWindow');
|
|
if (!pdfWindow) {
|
|
return null;
|
|
}
|
|
|
|
renderPdfWindowShell(pdfWindow);
|
|
return pdfWindow;
|
|
}
|
|
}
|
|
|
|
function updateState() {
|
|
const chosen = document.querySelectorAll('.student-check:checked').length;
|
|
selectedCount.textContent = chosen + ' student' + (chosen !== 1 ? 's' : '') + ' selected';
|
|
generateBtn.disabled = chosen === 0;
|
|
if (selectAll) {
|
|
selectAll.checked = chosen === checks.length && checks.length > 0;
|
|
selectAll.indeterminate = chosen > 0 && chosen < checks.length;
|
|
}
|
|
}
|
|
|
|
if (selectAll) {
|
|
selectAll.addEventListener('change', function () {
|
|
checks.forEach(c => { c.checked = this.checked; });
|
|
updateState();
|
|
});
|
|
}
|
|
|
|
if (certForm) {
|
|
certForm.addEventListener('submit', async function (event) {
|
|
event.preventDefault();
|
|
|
|
const chosen = document.querySelectorAll('.student-check:checked').length;
|
|
if (chosen === 0) {
|
|
showInlineError('Please select at least one student.');
|
|
return;
|
|
}
|
|
|
|
const previewWindow = openPdfWindow();
|
|
if (!previewWindow) {
|
|
showInlineError('Unable to open the certificate PDF tab. Please allow pop-ups for this site.');
|
|
return;
|
|
}
|
|
|
|
const originalHtml = generateBtn.innerHTML;
|
|
generateBtn.disabled = true;
|
|
generateBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Generating...';
|
|
|
|
try {
|
|
await refreshCsrfToken();
|
|
|
|
const csrfValue = syncCsrfField();
|
|
const formData = new FormData(certForm);
|
|
if (csrfValue) {
|
|
formData.set(currentCsrfTokenName, csrfValue);
|
|
}
|
|
|
|
const response = await fetch(certForm.action, {
|
|
method: 'POST',
|
|
body: formData,
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
});
|
|
|
|
const nextCsrfName = response.headers.get('X-CSRF-TOKEN-NAME') || currentCsrfTokenName;
|
|
const nextCsrfHash = response.headers.get('X-CSRF-TOKEN');
|
|
if (nextCsrfName && nextCsrfHash) {
|
|
updateCsrfToken(nextCsrfName, nextCsrfHash);
|
|
}
|
|
|
|
const contentType = response.headers.get('Content-Type') || '';
|
|
if (!response.ok || !contentType.toLowerCase().includes('application/pdf')) {
|
|
let message = 'Certificate generation failed.';
|
|
try {
|
|
const data = await response.json();
|
|
if (data?.csrf_token && data?.csrf_hash) {
|
|
updateCsrfToken(data.csrf_token, data.csrf_hash);
|
|
}
|
|
if (data?.error) {
|
|
message = data.error;
|
|
}
|
|
} catch (jsonError) {
|
|
const text = await response.text();
|
|
if (text) {
|
|
message = text;
|
|
}
|
|
}
|
|
pdfWindow.close();
|
|
showInlineError(message);
|
|
await refreshCsrfToken();
|
|
return;
|
|
}
|
|
|
|
const disposition = response.headers.get('Content-Disposition') || '';
|
|
const filenameMatch = disposition.match(/filename="?([^"]+)"?/i);
|
|
const filename = filenameMatch ? filenameMatch[1] : 'Certificates.pdf';
|
|
const pdfBlob = await response.blob();
|
|
const blobUrl = URL.createObjectURL(pdfBlob);
|
|
|
|
if (activePdfBlobUrl) {
|
|
URL.revokeObjectURL(activePdfBlobUrl);
|
|
}
|
|
|
|
activePdfBlobUrl = blobUrl;
|
|
previewWindow.showCertificatePdf(blobUrl, filename);
|
|
await refreshCsrfToken();
|
|
} catch (error) {
|
|
showInlineError('Certificate generation failed. Please try again.');
|
|
try {
|
|
await refreshCsrfToken();
|
|
} catch (csrfError) {
|
|
// Leave the current token in place if refresh also fails.
|
|
}
|
|
} finally {
|
|
generateBtn.innerHTML = originalHtml;
|
|
updateState();
|
|
}
|
|
});
|
|
}
|
|
|
|
checks.forEach(c => c.addEventListener('change', updateState));
|
|
updateState();
|
|
})();
|
|
</script>
|
|
|
|
<?= $this->endSection() ?>
|