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, 399, 90, 90); // ── 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 (bold via fill + stroke) $pdf->SetFont($edwardianFont, '', 38); $pdf->SetDrawColor(0, 0, 0); $pdf->setTextRenderingMode(0.4, true, false); $pdf->SetXY(0, 219); $pdf->Cell($W, 38, $name, 0, 0, 'C'); $pdf->setTextRenderingMode(0, true, false); // ── 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, 598, 453); // ── 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 / 5, $y + $i / 5, $text); } $pdf->setAlpha(1); $pdf->SetTextColor(0, 0, 0); $pdf->Text($x, $y, $text); } }