From 3333e71c94a4683963e20a85c7e27f4d284dbe44 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 17 May 2026 17:24:07 -0400 Subject: [PATCH] add qr code to certificate --- app/Config/Routes.php | 2 + .../View/CertificateController.php | 246 +++++++++++++----- ...-05-17-000001_CreateCertificateRecords.php | 85 ++++++ app/Models/CertificateRecordModel.php | 71 +++++ app/Views/admin/certificates/audit_log.php | 97 +++++++ app/Views/admin/certificates/index.php | 1 + app/Views/certificates/verify.php | 101 +++++++ 7 files changed, 531 insertions(+), 72 deletions(-) create mode 100644 app/Database/Migrations/2026-05-17-000001_CreateCertificateRecords.php create mode 100644 app/Models/CertificateRecordModel.php create mode 100644 app/Views/admin/certificates/audit_log.php create mode 100644 app/Views/certificates/verify.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 5899b07..6b0e361 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -181,6 +181,8 @@ $routes->get('administrator/trophy/final', 'View\TrophyController::final' // Certificates $routes->get('administrator/certificates', 'View\CertificateController::index', ['filter' => 'auth:admin']); $routes->post('administrator/certificates/generate', 'View\CertificateController::generate', ['filter' => 'auth:admin']); +$routes->get('administrator/certificates/log', 'View\CertificateController::auditLog', ['filter' => 'auth:admin']); +$routes->get('verify/(:segment)', 'View\CertificateController::verify/$1'); diff --git a/app/Controllers/View/CertificateController.php b/app/Controllers/View/CertificateController.php index 383b859..e68575d 100644 --- a/app/Controllers/View/CertificateController.php +++ b/app/Controllers/View/CertificateController.php @@ -5,20 +5,25 @@ 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(); @@ -59,17 +64,46 @@ class CertificateController extends BaseController ]); } + 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 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]); + } + + // ─── 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)) { return redirect()->to('administrator/certificates')->with('error', 'Please select at least one student.'); } - // Sanitize inputs $studentIds = array_filter(array_map('intval', $studentIds)); $certDate = preg_replace('/[^0-9\/\-]/', '', $certDate); @@ -81,6 +115,7 @@ class CertificateController extends BaseController $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') @@ -89,8 +124,6 @@ class CertificateController extends BaseController ->where('sc.class_section_id', (int) $classSectionId) ->where('s.id', $id) ->get()->getRowArray(); - } else { - $row = null; } if (!$row) { @@ -110,7 +143,28 @@ class CertificateController extends BaseController return redirect()->to('administrator/certificates')->with('error', 'No valid students found.'); } - $pdfData = $this->buildPdf($students, $certDate); + $issuedBy = session()->get('user_id'); + $issuedAt = date('Y-m-d H:i:s'); + $certDateDb = $this->parseCertDate($certDate); + + foreach ($students as &$student) { + $certNumber = $this->certRecordModel->nextNumber($schoolYear); + $this->certRecordModel->insert([ + 'certificate_number' => $certNumber, + 'student_id' => $student['id'], + '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 @@ -119,21 +173,72 @@ class CertificateController extends BaseController ->setBody($pdfData); } - // ─── PDF generation ──────────────────────────────────────────────────────── + // ─── 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); + + if (preg_match('/^\d+$/', $clean) && (int)$clean >= 1 && (int)$clean <= 9) { + return 'Grade ' . $clean; + } + if ($lower === 'youth') { + return 'Youth'; + } + if ($lower === 'kg') { + return 'Kindergarten'; + } + + return $clean; + } + + /** + * Generates a QR code PNG for $url and returns its temporary file path. + * Caller must unlink() the file when done. + */ + private function makeQrTempFile(string $url): ?string + { + try { + $result = \Endroid\QrCode\Builder\Builder::create() + ->writer(new \Endroid\QrCode\Writer\PngWriter()) + ->data($url) + ->encoding(new \Endroid\QrCode\Encoding\Encoding('UTF-8')) + ->size(300) + ->margin(2) + ->build(); + + $tmp = tempnam(sys_get_temp_dir(), 'cert_qr_') . '.png'; + $result->saveToFile($tmp); + return $tmp; + } catch (\Throwable $e) { + log_message('error', 'Certificate QR generation failed: ' . $e->getMessage()); + return null; + } + } + + // ─── 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; - // Convert TTF → TCPDF format on first run (cached in TCPDF's own fonts dir). - // K_PATH_FONTS must stay pointing at the TCPDF vendor fonts directory so that - // built-in fonts (helvetica, times, …) remain findable by SetFont(). $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); - // A4 landscape in points: 841.89 × 595.28 $pdf = new \TCPDF('L', 'pt', 'A4', true, 'UTF-8', false); $pdf->SetCreator('Al Rahma Sunday School'); $pdf->SetTitle('Student Certificates'); @@ -144,47 +249,45 @@ class CertificateController extends BaseController $pdf->SetHeaderMargin(0); $pdf->SetFooterMargin(0); - $W = $pdf->getPageWidth(); // 841.89 pt - $H = $pdf->getPageHeight(); // 595.28 pt + $W = $pdf->getPageWidth(); + $H = $pdf->getPageHeight(); + + $tmpFiles = []; foreach ($students as $student) { $pdf->AddPage(); - $name = $student['firstname'] . ' ' . $student['lastname']; - $grade = $this->formatGrade($student['grade'] ?? ''); - $this->drawCertificate($pdf, $W, $H, $name, $grade, $certDate, $imgDir, $edwardianFont, $garamondBold, $ebGaramond); + + $name = $student['firstname'] . ' ' . $student['lastname']; + $grade = $this->formatGrade($student['grade'] ?? ''); + $certNumber = $student['cert_number'] ?? ''; + + // Build verification URL and generate QR temp file + $verifyUrl = base_url('verify/' . rawurlencode($certNumber)); + $qrFile = $certNumber !== '' ? $this->makeQrTempFile($verifyUrl) : null; + if ($qrFile) { + $tmpFiles[] = $qrFile; + } + + $this->drawCertificate( + $pdf, $W, $H, + $name, $grade, $certDate, $certNumber, + $qrFile, + $imgDir, $edwardianFont, $garamondBold, $ebGaramond + ); } - return $pdf->Output('', 'S'); - } + $output = $pdf->Output('', 'S'); - private function formatGrade(string $raw): string - { - $clean = trim($raw); - $lower = strtolower($clean); - - // Numeric grades 1-9 → "Grade N" - if (preg_match('/^\d+$/', $clean) && (int)$clean >= 1 && (int)$clean <= 9) { - return 'Grade ' . $clean; + foreach ($tmpFiles as $f) { + @unlink($f); } - // Youth variants - if ($lower === 'youth') { - return 'Youth'; - } - - // KG → Kindergarten - if ($lower === 'kg') { - return 'Kindergarten'; - } - - return $clean; + return $output; } /** - * Draws one certificate page. Coordinate mapping from the original C# iTextSharp code: - * iTextSharp uses (x, y) with origin at bottom-left, y increases upward. - * TCPDF uses (x, y) with origin at top-left, y increases downward. - * Conversion: y_tcpdf = H - y_iTextSharp + * Coordinate mapping: iTextSharp origin bottom-left → TCPDF origin top-left. + * y_tcpdf ≈ H − y_iTextSharp */ private function drawCertificate( \TCPDF $pdf, @@ -193,32 +296,33 @@ class CertificateController extends BaseController string $name, string $grade, string $certDate, + string $certNumber, + ?string $qrFile, string $imgDir, string $edwardianFont, string $garamondBold, string $ebGaramond ): void { - // ── Title image: iTS SetAbsolutePosition(126, 450), ScaleToFit(600, 300) - // Bottom at y=450 from page bottom → top at H-450 ≈ 145. Place at top of page. - $pdf->Image($imgDir . 'title.png', 126, 0, 600); - - // ── Background image: iTS position(280, 79), ScaleToFit(2500, 340) - // background.png is 336×393 px → ScaleToFit gives ≈291×340 pt. - // Top in TCPDF: H-79-340 = ~176 + // ── 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); - // ── Signature image: iTS position(140, 106), ScaleToFit(90, 90) - // Top in TCPDF: H-106-90 = ~399 - $pdf->Image($imgDir . 'signature.png', 140, 399, 90, 90); + // ── QR code — small, bottom-right, just above the certificate number + $qrSize = 42; // pt + if ($qrFile !== null && file_exists($qrFile)) { + $qrX = $W - 10 - $qrSize; // right-aligned with the cert number text + $qrY = $H - 14 - 3 - $qrSize; // 3 pt gap above the cert number line + $pdf->Image($qrFile, $qrX, $qrY, $qrSize, $qrSize); + } - // ── "Presented to:" — iTS ALIGN_CENTER baseline y=420 → TCPDF y≈155 + // ── "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 — iTS baseline y=334, 38 pt Edwardian Script → TCPDF y≈219 - // Bold effect: fill the glyph AND add a thin stroke around it (Edwardian has no bold variant). + // ── Student name (bold via fill + stroke) $pdf->SetFont($edwardianFont, '', 38); $pdf->SetDrawColor(0, 0, 0); $pdf->setTextRenderingMode(0.4, true, false); @@ -226,69 +330,67 @@ class CertificateController extends BaseController $pdf->Cell($W, 38, $name, 0, 0, 'C'); $pdf->setTextRenderingMode(0, true, false); - // ── Line under name — iTS ALIGN_RIGHT at x=600, y=332 → TCPDF y≈243 + // ── Line under name $pdf->SetFont('times', '', 20); $pdf->SetXY(0, 243); $pdf->Cell(600, 20, '___________________________________', 0, 0, 'R'); - // ── Description — iTS ALIGN_CENTER baseline y=302 → TCPDF y≈273 + // ── Description $pdf->SetFont($ebGaramond, '', 20); $pdf->SetXY(0, 273); $pdf->Cell($W, 20, 'for successfully completing the requirements of', 0, 0, 'C'); - // ── Grade — iTS ALIGN_CENTER baseline y=265 → TCPDF y≈310 + // ── Grade $pdf->SetFont($garamondBold, '', 20); $pdf->SetXY(0, 310); $pdf->Cell($W, 20, $grade, 0, 0, 'C'); - // ── "at" — iTS ALIGN_CENTER baseline y=232 → TCPDF y≈343 + // ── "at" $pdf->SetFont('times', '', 20); $pdf->SetXY(0, 343); $pdf->Cell($W, 20, 'at', 0, 0, 'C'); - // ── School name — iTS ALIGN_CENTER baseline y=200 → TCPDF y≈375 + // ── School name $pdf->SetFont($garamondBold, '', 20); $pdf->SetXY(0, 375); $pdf->Cell($W, 20, 'Al Rahma Sunday School', 0, 0, 'C'); - // ── Certificate date — iTS DrawGradientText at (598, 116), 26 pt Edwardian - // TCPDF y ≈ H-116-26 = ~453 + // ── Date (gradient script) $this->drawGradientText($pdf, $edwardianFont, 26, $certDate, 598, 453); - // ── Date underline — iTS ALIGN_RIGHT at x=742, y=110 → TCPDF y≈465 + // ── Date underline + label $pdf->SetFont('times', '', 20); $pdf->SetXY(0, 463); $pdf->Cell(742, 20, '_________________', 0, 0, 'R'); - - // ── "Date" label — iTS at (690, 78) → TCPDF y≈497, x≈650 $pdf->SetXY(650, 492); $pdf->Cell(80, 20, 'Date', 0, 0, 'C'); - // ── Signature underline — iTS ALIGN_LEFT at x=106, y=110 → TCPDF y≈465 + // ── Signature underline + label $pdf->SetXY(106, 463); $pdf->Cell(200, 20, '_________________', 0, 0, 'L'); - - // ── "Signature" label — iTS at (190, 78) → TCPDF y≈497 $pdf->SetXY(106, 492); $pdf->Cell(168, 20, 'Signature', 0, 0, 'C'); + + // ── Certificate number — bottom-right, small gray + if ($certNumber !== '') { + $pdf->SetFont('helvetica', '', 8); + $pdf->SetTextColor(150, 150, 150); + $pdf->SetXY(0, $H - 14); + $pdf->Cell($W - 10, 12, 'Certificate No. ' . $certNumber, 0, 0, 'R'); + $pdf->SetTextColor(0, 0, 0); + } } - /** - * Mimics the C# DrawGradientText — shadow passes give depth, final pass is solid black. - */ private function drawGradientText(\TCPDF $pdf, string $fontName, float $fontSize, string $text, float $x, float $y): void { $pdf->SetFont($fontName, '', $fontSize); - // Shadow passes (progressively more opaque, slightly offset) for ($i = 0; $i < 6; $i++) { - $alpha = ($i + 1) * 25 / 255; // 0.098 → 0.588 - $pdf->setAlpha($alpha); + $pdf->setAlpha(($i + 1) * 25 / 255); $pdf->SetTextColor(0, 0, 0); $pdf->Text($x + $i / 5, $y + $i / 5, $text); } - // Main solid pass $pdf->setAlpha(1); $pdf->SetTextColor(0, 0, 0); $pdf->Text($x, $y, $text); diff --git a/app/Database/Migrations/2026-05-17-000001_CreateCertificateRecords.php b/app/Database/Migrations/2026-05-17-000001_CreateCertificateRecords.php new file mode 100644 index 0000000..40fc06e --- /dev/null +++ b/app/Database/Migrations/2026-05-17-000001_CreateCertificateRecords.php @@ -0,0 +1,85 @@ +db->tableExists('certificate_records')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'certificate_number' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + ], + 'student_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'student_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 200, + ], + 'grade' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + ], + 'cert_date' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'school_year' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'null' => true, + ], + 'class_section_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'issued_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + ], + 'issued_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('certificate_number'); + $this->forge->addKey(['school_year', 'student_id']); + $this->forge->createTable('certificate_records'); + } + + public function down() + { + $this->forge->dropTable('certificate_records', true); + } +} diff --git a/app/Models/CertificateRecordModel.php b/app/Models/CertificateRecordModel.php new file mode 100644 index 0000000..38633bb --- /dev/null +++ b/app/Models/CertificateRecordModel.php @@ -0,0 +1,71 @@ +transStart(); + + $count = $db->table($this->table) + ->where('school_year', $schoolYear) + ->countAllResults(); + + $seq = str_pad($count + 1, 4, '0', STR_PAD_LEFT); + + $db->transComplete(); + + return 'ARSS-' . $schoolYear . '-' . $seq; + } + + /** Returns paginated records with the issuing admin's name joined. */ + public function getAuditLog(?string $schoolYear = null, int $perPage = 50): array + { + $builder = $this->db->table($this->table . ' cr') + ->select('cr.*, u.firstname AS admin_firstname, u.lastname AS admin_lastname') + ->join('users u', 'u.id = cr.issued_by', 'left') + ->orderBy('cr.issued_at', 'DESC'); + + if ($schoolYear) { + $builder->where('cr.school_year', $schoolYear); + } + + return $builder->get()->getResultArray(); + } + + /** Total certificates issued per school year, ordered by year DESC. */ + public function yearSummary(): array + { + return $this->db->table($this->table) + ->select('school_year, COUNT(*) AS total') + ->groupBy('school_year') + ->orderBy('school_year', 'DESC') + ->get()->getResultArray(); + } +} diff --git a/app/Views/admin/certificates/audit_log.php b/app/Views/admin/certificates/audit_log.php new file mode 100644 index 0000000..7538780 --- /dev/null +++ b/app/Views/admin/certificates/audit_log.php @@ -0,0 +1,97 @@ +extend('layout/management_layout') ?> +section('content') ?> + +
+ +
+
+

Certificate Audit Log

+
Every issued certificate is recorded here for tracking and auditing.
+
+ + Generate Certificates + +
+ + + +
+ +
+
+
+
+
+
+ +
+ + + +
+
+
+
+ +
+
+ +
+
+
+
+ + +
+
+ Issued Certificates + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Certificate #StudentGradeCert DateSchool YearIssued ByIssued At
No certificates issued yet.
+
+
+
+ +
+ +endSection() ?> diff --git a/app/Views/admin/certificates/index.php b/app/Views/admin/certificates/index.php index d1f772a..924aa95 100644 --- a/app/Views/admin/certificates/index.php +++ b/app/Views/admin/certificates/index.php @@ -54,6 +54,7 @@
+
diff --git a/app/Views/certificates/verify.php b/app/Views/certificates/verify.php new file mode 100644 index 0000000..8f1a9ea --- /dev/null +++ b/app/Views/certificates/verify.php @@ -0,0 +1,101 @@ + + + + + + Certificate Verification — Al Rahma Sunday School + + + + + +
+ + +
+ +

Certificate Verification

+

Al Rahma Sunday School

+
+ + + + +
+
+ +
+ + Verified + + +
+ +
+
+
Student Name
+
+
+
+
Grade / Class
+
+
+
+
School Year
+
+
+
+
Certificate Date
+
+ +
+
+
+
Issued By
+
+ +
+
+
+
Issued On
+
+ +
+
+
+ +
+ +
+ + + + +
+
+ + Not Found + +

No certificate matching this code was found in our records.
+ Please check the code and try again.

+
+
+ + + +

+ © Al Rahma Sunday School +

+
+ +