Files
alrahma_sunday_school/app/Controllers/View/CertificateController.php
T
2026-05-30 03:19:10 -04:00

664 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ClassSectionModel;
use App\Models\ConfigurationModel;
use App\Models\CertificateRecordModel;
class CertificateController extends BaseController
{
protected $classSectionModel;
protected $configModel;
protected $certRecordModel;
protected $schoolYear;
public function __construct()
{
$this->classSectionModel = new ClassSectionModel();
$this->configModel = new ConfigurationModel();
$this->certRecordModel = new CertificateRecordModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
}
// ─── Admin UI ──────────────────────────────────────────────────────────────
public function index()
{
$db = \Config\Database::connect();
$selectedCsid = $this->request->getGet('class_section_id');
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
// ── All enrolled students across every class section ───────────────────
$allEnrolled = $db->table('student_class sc')
->select('s.id AS student_id, s.firstname, s.lastname, sc.class_section_id, cs.class_section_name')
->join('students s', 's.id = sc.student_id')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
->where('s.is_active', 1)
->where('sc.school_year', $schoolYear)
->orderBy('s.firstname', 'ASC')
->orderBy('s.lastname', 'ASC')
->get()
->getResultArray();
$allIds = array_values(array_unique(array_map('intval', array_column($allEnrolled, 'student_id'))));
// ── Saved generated YEAR decisions from student_decisions ──────────────
//
// Source of truth:
// student_decisions.year_score
// student_decisions.decision
//
// Do NOT recalculate certificate decisions here from semester_scores.
$decisionsByStudent = [];
if (!empty($allIds)) {
$decisionRows = $db->table('student_decisions')
->whereIn('student_id', $allIds)
->where('school_year', $schoolYear)
->get()
->getResultArray();
foreach ($decisionRows as $d) {
$sid = (int)($d['student_id'] ?? 0);
if ($sid <= 0) {
continue;
}
$decision = trim((string)($d['decision'] ?? ''));
$source = trim((string)($d['source'] ?? ''));
if ($source === '') {
$source = $decision === '' ? 'pending' : 'manual';
}
$decisionsByStudent[$sid]['Year'] = [
'decision' => $decision,
'source' => $source,
'notes' => (string)($d['notes'] ?? ''),
'year_score' => $d['year_score'] ?? null,
];
}
}
// ── Certificate records: most recent per student ───────────────────────
$certsByStudent = [];
if (!empty($allIds)) {
foreach ($db->table('certificate_records')
->select('student_id, certificate_number, issued_at')
->whereIn('student_id', $allIds)
->where('school_year', $schoolYear)
->orderBy('issued_at', 'DESC')
->get()
->getResultArray() as $c) {
$sid = (int)$c['student_id'];
if (!isset($certsByStudent[$sid])) {
$certsByStudent[$sid] = $c['certificate_number'];
}
}
}
// ── Build per-class buckets and stats ──────────────────────────────────
$studentsByClass = [];
$statsPerClass = [];
foreach ($allEnrolled as $row) {
$sid = (int)$row['student_id'];
$csid = (int)$row['class_section_id'];
if (!isset($statsPerClass[$csid])) {
$statsPerClass[$csid] = [
'name' => $row['class_section_name'],
'total' => 0,
'pass' => 0,
'cert' => 0,
];
}
$statsPerClass[$csid]['total']++;
// If no generated decision exists yet, explicitly mark pending.
if (!isset($decisionsByStudent[$sid])) {
$decisionsByStudent[$sid]['Decision'] = [
'decision' => '',
'source' => 'pending',
'notes' => '',
'year_score' => null,
];
}
// Certificate eligibility:
// A student is eligible only if the saved final generated decision is Pass.
$studentDecisions = $decisionsByStudent[$sid] ?? [];
$isPass = !empty($studentDecisions);
foreach ($studentDecisions as $decisionRow) {
$decision = trim((string)($decisionRow['decision'] ?? ''));
if (strcasecmp($decision, 'Pass') !== 0) {
$isPass = false;
break;
}
}
if ($isPass) {
$statsPerClass[$csid]['pass']++;
}
if (isset($certsByStudent[$sid])) {
$statsPerClass[$csid]['cert']++;
}
$studentsByClass[$csid][] = $row;
}
// ── Determine default active tab ───────────────────────────────────────
$firstCsid = !empty($allEnrolled) ? (int)$allEnrolled[0]['class_section_id'] : null;
if ($selectedCsid === null && $firstCsid !== null) {
$selectedCsid = (string)$firstCsid;
}
return view('admin/certificates/index', [
'studentsByClass' => $studentsByClass,
'selectedClassId' => $selectedCsid,
'schoolYear' => $schoolYear,
'certDate' => date('m/d/Y'),
'decisionsByStudent' => $decisionsByStudent,
'certsByStudent' => $certsByStudent,
'statsPerClass' => $statsPerClass,
]);
}
public function auditLog()
{
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$records = $this->certRecordModel->getAuditLog($schoolYear);
$yearSummary = $this->certRecordModel->yearSummary();
return view('admin/certificates/audit_log', [
'records' => $records,
'schoolYear' => $schoolYear,
'yearSummary' => $yearSummary,
]);
}
public function csrfToken()
{
return $this->response
->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->setHeader('Pragma', 'no-cache')
->setJSON([
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
// ─── Public verification page ──────────────────────────────────────────────
public function verify(string $certNumber)
{
$record = \Config\Database::connect()
->table('certificate_records cr')
->select('cr.*, u.firstname AS admin_firstname, u.lastname AS admin_lastname')
->join('users u', 'u.id = cr.issued_by', 'left')
->groupStart()
->where('cr.verification_token', $certNumber)
->orWhere('cr.certificate_number', strtoupper($certNumber))
->groupEnd()
->get()
->getRowArray();
return view('certificates/verify', [
'record' => $record ?: null,
]);
}
// ─── Reprint an existing certificate ──────────────────────────────────────
public function reprint(string $certNumber)
{
$record = \Config\Database::connect()
->table('certificate_records')
->where('certificate_number', strtoupper($certNumber))
->get()
->getRowArray();
if (!$record) {
return redirect()->to('administrator/certificates/log')
->with('error', 'Certificate not found: ' . $certNumber);
}
$certDateFormatted = '';
if (!empty($record['cert_date'])) {
$ts = strtotime((string)$record['cert_date']);
if ($ts) {
$certDateFormatted = date('m/d/Y', $ts);
}
}
$student = [
'firstname' => (string)($record['student_name'] ?? ''),
'lastname' => '',
'grade' => (string)($record['grade'] ?? ''),
'cert_number' => (string)($record['certificate_number'] ?? ''),
'verify_token' => $this->ensureVerificationTokenForRecord($record),
];
// student_name is stored as "Firstname Lastname" — split for the PDF builder.
$parts = explode(' ', trim($student['firstname']), 2);
if (count($parts) === 2) {
$student['firstname'] = $parts[0];
$student['lastname'] = $parts[1];
}
$pdfData = $this->buildPdf([$student], $certDateFormatted ?: date('m/d/Y'));
$filename = 'Certificate_' . strtoupper($certNumber) . '.pdf';
return $this->response
->setHeader('Content-Type', 'application/pdf')
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
->setBody($pdfData);
}
// ─── PDF generation ────────────────────────────────────────────────────────
public function generate()
{
$studentIds = $this->request->getPost('student_ids') ?? [];
$certDate = trim($this->request->getPost('cert_date') ?? date('m/d/Y'));
$classSectionId = $this->request->getPost('class_section_id');
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
if (empty($studentIds)) {
if ($this->request->isAJAX()) {
return $this->response
->setStatusCode(422)
->setJSON([
'ok' => false,
'error' => 'Please select at least one student.',
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
return redirect()->to('administrator/certificates')
->with('error', 'Please select at least one student.');
}
$studentIds = array_filter(array_map('intval', $studentIds));
$certDate = preg_replace('/[^0-9\/\-]/', '', $certDate);
if (empty($studentIds)) {
if ($this->request->isAJAX()) {
return $this->response
->setStatusCode(422)
->setJSON([
'ok' => false,
'error' => 'Invalid student selection.',
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
return redirect()->to('administrator/certificates')
->with('error', 'Invalid student selection.');
}
$db = \Config\Database::connect();
$students = [];
foreach ($studentIds as $id) {
$row = null;
if ($classSectionId) {
$row = $db->table('student_class sc')
->select('s.id, s.firstname, s.lastname, cs.class_section_name AS grade')
->join('students s', 's.id = sc.student_id')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
->where('sc.class_section_id', (int)$classSectionId)
->where('sc.school_year', $schoolYear)
->where('s.id', $id)
->get()
->getRowArray();
}
if (!$row) {
$s = $db->table('students')
->select('id, firstname, lastname, registration_grade AS grade')
->where('id', $id)
->get()
->getRowArray();
$row = $s ?: null;
}
if ($row) {
$students[] = $row;
}
}
if (empty($students)) {
if ($this->request->isAJAX()) {
return $this->response
->setStatusCode(422)
->setJSON([
'ok' => false,
'error' => 'No valid students found.',
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
return redirect()->to('administrator/certificates')
->with('error', 'No valid students found.');
}
$issuedAt = date('Y-m-d H:i:s');
// Load existing certificates for these students this school year.
$existingCerts = $db->table('certificate_records')
->select('id, student_id, certificate_number, verification_token')
->whereIn('student_id', array_column($students, 'id'))
->where('school_year', $schoolYear)
->get()
->getResultArray();
$existingCertMap = [];
foreach ($existingCerts as $ec) {
$existingCertMap[(int)$ec['student_id']] = $ec;
}
foreach ($students as &$student) {
$sid = (int)$student['id'];
if (isset($existingCertMap[$sid])) {
// Reuse existing certificate number — do not create a new record.
$existing = $existingCertMap[$sid];
$student['cert_number'] = (string)($existing['certificate_number'] ?? '');
$student['verify_token'] = $this->ensureVerificationTokenForRecord($existing);
} else {
$certNumber = $this->certRecordModel->nextNumber($schoolYear);
$verifyToken = $this->certRecordModel->generateVerificationToken();
$this->certRecordModel->insert([
'certificate_number' => $certNumber,
'verification_token' => $verifyToken,
'student_id' => $sid,
'student_name' => $student['firstname'] . ' ' . $student['lastname'],
'grade' => $this->formatGrade($student['grade'] ?? ''),
'school_year' => $schoolYear,
'class_section_id' => $classSectionId ?: null,
'issued_at' => $issuedAt,
]);
$student['cert_number'] = $certNumber;
$student['verify_token'] = $verifyToken;
}
}
unset($student);
$pdfData = $this->buildPdf($students, $certDate);
$filename = 'Certificates_' . date('Ymd_His') . '.pdf';
return $this->response
->setHeader('Content-Type', 'application/pdf')
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
->setHeader('X-CSRF-TOKEN-NAME', csrf_token())
->setHeader('X-CSRF-TOKEN', csrf_hash())
->setBody($pdfData);
}
// ─── Helpers ───────────────────────────────────────────────────────────────
private function parseCertDate(string $certDate): ?string
{
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $certDate, $m)) {
return $m[3] . '-' . $m[1] . '-' . $m[2];
}
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $certDate)) {
return $certDate;
}
return null;
}
private function formatGrade(string $raw): string
{
$clean = trim($raw);
$lower = strtolower($clean);
// Strip section suffix: "Grade 1-A" → "Grade 1", "Grade 2-B" → "Grade 2".
if (preg_match('/^grade\s*(\d+)/i', $clean, $m)) {
return 'Grade ' . (int)$m[1];
}
if ($lower === 'youth') {
return 'Youth';
}
if ($lower === 'kg' || $lower === 'kindergarten') {
return 'Kindergarten';
}
// Raw number or number-section: "1", "1-A", "2-B" → keep number only.
if (preg_match('/^(\d+)([- ][A-Za-z0-9]+)?$/', $clean, $m)) {
return 'Grade ' . (int)$m[1];
}
return $clean;
}
private function ensureVerificationTokenForRecord(array $record): string
{
$token = trim((string)($record['verification_token'] ?? ''));
if ($token !== '') {
return $token;
}
$recordId = (int)($record['id'] ?? 0);
if ($recordId <= 0) {
return '';
}
$token = $this->certRecordModel->generateVerificationToken();
$this->certRecordModel->update($recordId, [
'verification_token' => $token,
]);
return $token;
}
// ─── PDF rendering ─────────────────────────────────────────────────────────
private function buildPdf(array $students, string $certDate): string
{
$fontDir = FCPATH . 'assets' . DIRECTORY_SEPARATOR . 'certificates' . DIRECTORY_SEPARATOR . 'fonts' . DIRECTORY_SEPARATOR;
$imgDir = FCPATH . 'assets' . DIRECTORY_SEPARATOR . 'certificates' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR;
$edwardianFont = \TCPDF_FONTS::addTTFfont($fontDir . 'Edwardian Script ITC Regular.ttf', 'TrueTypeUnicode', '', 32);
$garamondBold = \TCPDF_FONTS::addTTFfont($fontDir . 'Garamond Bold.ttf', 'TrueTypeUnicode', '', 32);
$ebGaramond = \TCPDF_FONTS::addTTFfont($fontDir . 'EBGaramond-Regular.ttf', 'TrueTypeUnicode', '', 32);
$pdf = new \TCPDF('L', 'pt', 'A4', true, 'UTF-8', false);
$pdf->SetCreator('Al Rahma Sunday School');
$pdf->SetTitle('Student Certificates');
$pdf->SetMargins(0, 0, 0, true);
$pdf->SetAutoPageBreak(false, 0);
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
$pdf->SetHeaderMargin(0);
$pdf->SetFooterMargin(0);
$W = $pdf->getPageWidth();
$H = $pdf->getPageHeight();
foreach ($students as $student) {
$pdf->AddPage();
$name = trim((string)($student['firstname'] ?? '') . ' ' . (string)($student['lastname'] ?? ''));
$grade = $this->formatGrade($student['grade'] ?? '');
$certNumber = $student['cert_number'] ?? '';
$verifyToken = $student['verify_token'] ?? '';
$verifyUrl = $verifyToken !== ''
? site_url('verify/' . rawurlencode($verifyToken))
: null;
$this->drawCertificate(
$pdf,
$W,
$H,
$name,
$grade,
$certDate,
$certNumber,
$verifyUrl,
$imgDir,
$edwardianFont,
$garamondBold,
$ebGaramond
);
}
return $pdf->Output('', 'S');
}
/**
* Coordinate mapping: iTextSharp origin bottom-left → TCPDF origin top-left.
* y_tcpdf ≈ H y_iTextSharp
*/
private function drawCertificate(
\TCPDF $pdf,
float $W,
float $H,
string $name,
string $grade,
string $certDate,
string $certNumber,
?string $verifyUrl,
string $imgDir,
string $edwardianFont,
string $garamondBold,
string $ebGaramond
): void {
// ── Images
$pdf->Image($imgDir . 'title.png', 126, 0, 600);
$pdf->Image($imgDir . 'background.png', 280, 176, 291, 340);
$pdf->Image($imgDir . 'signature.png', 140, 410, 90, 80);
// ── "Presented to:"
$pdf->SetFont('times', 'B', 24);
$pdf->SetTextColor(0, 0, 0);
$pdf->SetXY(0, 151);
$pdf->Cell($W, 24, 'Presented to:', 0, 0, 'C');
// ── QR code — left-aligned with "Presented to:", vertically centred on that line.
$qrSize = 42;
if (!empty($verifyUrl)) {
$qrX = 120;
$qrY = 171 + (24 - $qrSize) / 2;
$style = [
'border' => false,
'padding' => 0,
'fgcolor' => [0, 0, 0],
'bgcolor' => false,
];
$pdf->write2DBarcode($verifyUrl, 'QRCODE,L', $qrX, $qrY, $qrSize, $qrSize, $style, 'N');
}
// ── Student name — center based on actual string width.
$pdf->SetFont($edwardianFont, '', 38);
$nameX = ($W - $pdf->GetStringWidth($name)) / 2;
$this->drawGradientText($pdf, $edwardianFont, 38, $name, $nameX, 221.5);
// ── Line under name
$pdf->SetFont('times', '', 20);
$pdf->SetXY(0, 236);
$pdf->Cell(600, 20, '___________________________________', 0, 0, 'R');
// ── Description
$pdf->SetFont($ebGaramond, '', 20);
$pdf->SetXY(0, 273);
$pdf->Cell($W, 20, 'for successfully completing the requirements of', 0, 0, 'C');
// ── Grade
$pdf->SetFont($garamondBold, '', 20);
$pdf->SetXY(0, 310);
$pdf->Cell($W, 20, $grade, 0, 0, 'C');
// ── "at"
$pdf->SetFont('times', '', 20);
$pdf->SetXY(0, 343);
$pdf->Cell($W, 20, 'at', 0, 0, 'C');
// ── School name
$pdf->SetFont($garamondBold, '', 20);
$pdf->SetXY(0, 375);
$pdf->Cell($W, 20, 'Al Rahma Sunday School', 0, 0, 'C');
// ── Date
$this->drawGradientText($pdf, $edwardianFont, 26, $certDate, 586, 456);
// ── Date underline + label
$pdf->SetFont('times', '', 20);
$pdf->SetXY(0, 463);
$pdf->Cell(742, 20, '_________________', 0, 0, 'R');
$pdf->SetXY(650, 492);
$pdf->Cell(80, 20, 'Date', 0, 0, 'C');
// ── Signature underline + label
$pdf->SetXY(106, 463);
$pdf->Cell(200, 20, '_________________', 0, 0, 'L');
$pdf->SetXY(106, 492);
$pdf->Cell(168, 20, 'Signature', 0, 0, 'C');
// ── Certificate number — 1.4 cm from bottom, 2.5 cm from left.
if ($certNumber !== '') {
$pdf->SetFont('helvetica', '', 8);
$pdf->SetTextColor(150, 150, 150);
$pdf->SetXY(70.86, $H - 39.68);
$pdf->Cell(200, 12, 'Certificate No. ' . $certNumber, 0, 0, 'L');
$pdf->SetTextColor(0, 0, 0);
}
}
private function drawGradientText(
\TCPDF $pdf,
string $fontName,
float $fontSize,
string $text,
float $x,
float $y
): void {
$pdf->SetFont($fontName, '', $fontSize);
for ($i = 0; $i < 6; $i++) {
$pdf->setAlpha(($i + 1) * 25 / 255);
$pdf->SetTextColor(0, 0, 0);
$pdf->Text($x + $i / 4, $y + $i / 4, $text);
}
$pdf->setAlpha(1);
$pdf->SetTextColor(0, 0, 0);
$pdf->Text($x, $y, $text);
}
}