Files
alrahma_sunday_school/app/Controllers/View/CertificateController.php
T
2026-05-26 02:13:39 -04:00

558 lines
23 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;
use App\Models\StudentDecisionModel;
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_unique(array_column($allEnrolled, 'student_id'));
// ── Semester scores ────────────────────────────────────────────────────
$allScoreMap = [];
if (!empty($allIds)) {
foreach ($db->table('semester_scores')
->select('student_id, semester, semester_score')
->whereIn('student_id', $allIds)
->where('school_year', $schoolYear)
->where('semester_score IS NOT NULL', null, false)
->get()->getResultArray() as $sr) {
$allScoreMap[(int)$sr['student_id']][ucfirst(strtolower($sr['semester']))] =
is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null;
}
}
// ── Below-60 manual decisions ──────────────────────────────────────────
$allBelowMap = [];
if (!empty($allIds)) {
foreach ($db->table('below_sixty_decisions')
->whereIn('student_id', $allIds)
->where('school_year', $schoolYear)
->get()->getResultArray() as $b) {
$allBelowMap[(int)$b['student_id']][ucfirst(strtolower($b['semester']))] = (string)$b['decision'];
}
}
// ── 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-student decisions + per-class buckets ────────────────────
$decisionsByStudent = [];
$studentsByClass = []; // [csid => [student rows...]]
$statsPerClass = []; // [csid => {name, total, pass, cert}]
foreach ($allEnrolled as $row) {
$sid = (int)$row['student_id'];
$csid = (int)$row['class_section_id'];
// Decisions per semester
foreach ($allScoreMap[$sid] ?? [] as $sem => $score) {
if ($score === null) continue;
if ($score >= 60) {
$dec = 'Pass'; $src = 'auto';
} elseif (!empty($allBelowMap[$sid][$sem])) {
$dec = $allBelowMap[$sid][$sem]; $src = 'manual';
} else {
$dec = ''; $src = 'pending';
}
$decisionsByStudent[$sid][$sem] = ['decision' => $dec, 'source' => $src];
}
// Per-class stats
if (!isset($statsPerClass[$csid])) {
$statsPerClass[$csid] = ['name' => $row['class_section_name'], 'total' => 0, 'pass' => 0, 'cert' => 0];
}
$statsPerClass[$csid]['total']++;
$sems = $allScoreMap[$sid] ?? [];
$isPass = !empty($sems);
foreach ($sems as $sem => $score) {
if ($score === null) { $isPass = false; break; }
if ($score >= 60) continue;
$md = $allBelowMap[$sid][$sem] ?? '';
if ($md === '' || $md !== 'Pass') { $isPass = false; break; }
}
if ($isPass) $statsPerClass[$csid]['pass']++;
if (isset($certsByStudent[$sid])) $statsPerClass[$csid]['cert']++;
// Group students by class
$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')
->where('cr.certificate_number', strtoupper($certNumber))
->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'] ?? ''),
];
// 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('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.');
}
$issuedBy = session()->get('user_id');
$issuedAt = date('Y-m-d H:i:s');
$certDateDb = $this->parseCertDate($certDate);
// Load any existing certificates for these students this school year
$db = \Config\Database::connect();
$existingCerts = $db->table('certificate_records')
->select('student_id, certificate_number')
->whereIn('student_id', array_column($students, 'id'))
->where('school_year', $schoolYear)
->get()->getResultArray();
$existingCertMap = [];
foreach ($existingCerts as $ec) {
$existingCertMap[(int)$ec['student_id']] = $ec['certificate_number'];
}
foreach ($students as &$student) {
$sid = (int)$student['id'];
if (isset($existingCertMap[$sid])) {
// Reuse existing certificate number — do not create a new record
$student['cert_number'] = $existingCertMap[$sid];
} else {
$certNumber = $this->certRecordModel->nextNumber($schoolYear);
$this->certRecordModel->insert([
'certificate_number' => $certNumber,
'student_id' => $sid,
'student_name' => $student['firstname'] . ' ' . $student['lastname'],
'grade' => $this->formatGrade($student['grade'] ?? ''),
'cert_date' => $certDateDb,
'school_year' => $schoolYear,
'class_section_id' => $classSectionId ?: null,
'issued_by' => $issuedBy,
'issued_at' => $issuedAt,
]);
$student['cert_number'] = $certNumber;
}
}
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 (e.g. "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;
}
// ─── 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 = $student['firstname'] . ' ' . $student['lastname'];
$grade = $this->formatGrade($student['grade'] ?? '');
$certNumber = $student['cert_number'] ?? '';
// Build verification URL for an inline TCPDF QR code.
$verifyUrl = site_url('verify/' . rawurlencode($certNumber));
$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);
// ── QR code — bottom-right corner: 1.5 cm from bottom, 2.5 cm from right
$qrSize = 42; // pt
if (!empty($verifyUrl)) {
$qrX = $W - 70.87 - $qrSize; // 2.5 cm from right edge
$qrY = $H - 42.52 - $qrSize; // 1.5 cm from bottom edge
$style = [
'border' => false,
'padding' => 0,
'fgcolor' => [0, 0, 0],
'bgcolor' => false,
];
$pdf->write2DBarcode($verifyUrl, 'QRCODE,L', $qrX, $qrY, $qrSize, $qrSize, $style, 'N');
}
// ── "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');
// ── 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, 229.5);
// ── Line under name
$pdf->SetFont('times', '', 20);
$pdf->SetXY(0, 243);
$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 (gradient script)
$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);
}
}