fix certificates
This commit is contained in:
@@ -1,445 +1,424 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="container-fluid">
|
||||
<div class="wrapper">
|
||||
|
||||
<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>
|
||||
<?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 (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: ?>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
</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 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>
|
||||
|
||||
<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>
|
||||
<!-- 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>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) => $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'));
|
||||
?>
|
||||
<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>
|
||||
<?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): $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>
|
||||
|
||||
<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>
|
||||
<!-- 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 & Print Certificates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</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; ?>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
</div><!-- .wrapper -->
|
||||
</div><!-- .container-fluid -->
|
||||
|
||||
<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) ?>;
|
||||
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 cssEscape(v) {
|
||||
return window.CSS?.escape ? window.CSS.escape(v) : String(v).replace(/(["\\.#:[\],= ])/g, '\\$1');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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 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;
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
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, activePdfBlobUrl = null;
|
||||
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;
|
||||
}
|
||||
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>`;
|
||||
}
|
||||
|
||||
function updateState() {
|
||||
const chosen = document.querySelectorAll('.student-check:checked').length;
|
||||
selectedCount.textContent = chosen + ' student' + (chosen !== 1 ? 's' : '') + ' selected';
|
||||
generateBtn.disabled = chosen === 0;
|
||||
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.checked = chosen === checks.length && checks.length > 0;
|
||||
selectAll.indeterminate = chosen > 0 && chosen < checks.length;
|
||||
selectAll.addEventListener('change', function () {
|
||||
eligibleChecks.forEach(c => { c.checked = this.checked; });
|
||||
updateState();
|
||||
});
|
||||
}
|
||||
}
|
||||
checks.forEach(c => c.addEventListener('change', updateState));
|
||||
updateState();
|
||||
|
||||
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;
|
||||
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" role="status" aria-hidden="true"></span>Generating...';
|
||||
|
||||
generateBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></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();
|
||||
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'), 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 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.
|
||||
}
|
||||
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 = originalHtml;
|
||||
generateBtn.innerHTML = origHtml;
|
||||
updateState();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checks.forEach(c => c.addEventListener('change', updateState));
|
||||
updateState();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?= $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, 3, 4] }]
|
||||
});
|
||||
} catch (_) {}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
Reference in New Issue
Block a user