Compare commits

..

9 Commits

Author SHA1 Message Date
root 239d35f8ff fix the event print and certificate qr 2026-05-21 17:00:38 -04:00
root 3333e71c94 add qr code to certificate 2026-05-17 17:24:07 -04:00
root af75475214 add certificate generation 2026-05-17 17:04:47 -04:00
root a8ef665239 fix event parent list 2026-05-16 16:03:46 -04:00
root 7c97a60795 add trophy prediction and treshold 2026-05-16 15:35:56 -04:00
root acca87c77b fix tracking attendance 2026-05-09 15:51:34 -04:00
root 9aacbe8972 fix draft exam upload 2026-05-08 00:33:40 -04:00
root 76866ffe1e update login scurity 2026-05-08 00:17:42 -04:00
root b01a4496e6 fixexam files 2026-05-06 18:45:13 -04:00
53 changed files with 4025 additions and 358 deletions
+3 -3
View File
@@ -36,13 +36,13 @@ class SyncPaypalPayments extends BaseCommand
$this->invoiceModel = new InvoiceModel();
$this->studentModel = new StudentModel();
$this->enrollmentModel = new EnrollmentModel();
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
}
public function run(array $params)
{
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$dryRun = CLI::getOption('dry-run');
$reportOnly = CLI::getOption('report-only');
$mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE');
+6 -1
View File
@@ -24,6 +24,8 @@ class Filters extends BaseConfig
'honeypot' => Honeypot::class,
'invalidchars' => InvalidChars::class,
'secureheaders' => SecureHeaders::class,
'sanitizeinput' => \App\Filters\SanitizeInputFilter::class,
'apiratelimit' => \App\Filters\ApiRateLimitFilter::class,
'auth' => \App\Filters\AuthFilter::class, // Define the alias for your auth filter
'apiAuth' => \App\Filters\ApiAuthFilter::class, // JWT-based API authentication
'cleanupScheduler' => \App\Filters\CleanupScheduler::class,
@@ -42,6 +44,8 @@ class Filters extends BaseConfig
public array $globals = [
'before' => [
'timezone',
'sanitizeinput',
'invalidchars',
'csrf' => ['except' => [
// Webhooks / integrations
'api/paypal-webhook',
@@ -80,6 +84,7 @@ class Filters extends BaseConfig
]],
],
'after' => [
'secureheaders',
'toolbar',
'cleanupScheduler' => ['except' => ['cleanup/*']],
],
@@ -112,7 +117,7 @@ class Filters extends BaseConfig
* @var array<string, array<string, list<string>>>
*/
public array $filters = [
'apiratelimit' => ['before' => ['api/*', 'index.php/api/*']],
];
}
+16 -2
View File
@@ -174,6 +174,16 @@ $routes->group('administrator/subject-curriculum', ['filter' => 'auth:update_cur
$routes->post('delete/(:num)', 'View\SubjectCurriculumController::delete/$1');
});
$routes->get('administrator/trophy', 'View\TrophyController::index', ['filter' => 'auth:admin']);
$routes->get('administrator/trophy/winners', 'View\TrophyController::winners', ['filter' => 'auth:admin']);
$routes->get('administrator/trophy/final', 'View\TrophyController::final', ['filter' => 'auth:admin']);
// 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');
/*
@@ -393,8 +403,8 @@ $routes->get('print-requests/file/(:segment)', 'PrintRequests::serveFile/$1', ['
$routes->get('print-requests/file/(:segment)/(:alpha)', 'PrintRequests::serveFile/$1/$2', ['filter' => 'auth:teacher,teacher_assistant,admin']);
$routes->get('uploads/print_requests/(:segment)', 'PrintRequests::serveFile/$1', ['filter' => 'auth:teacher,teacher_assistant,admin']);
$routes->get('exam-drafts/files/teacher/(:segment)', 'View\FilesController::examDraftTeacher/$1', ['filter' => 'auth:teacher,teacher_assistant,admin']);
$routes->get('exam-drafts/files/final/(:segment)', 'View\FilesController::examDraftFinal/$1', ['filter' => 'auth:teacher,teacher_assistant,admin']);
$routes->get('exam-drafts/files/teacher/(:segment)', 'View\FilesController::examDraftTeacher/$1', ['filter' => 'auth:teacher,teacher_assistant,admin,administrator,principal']);
$routes->get('exam-drafts/files/final/(:segment)', 'View\FilesController::examDraftFinal/$1', ['filter' => 'auth:teacher,teacher_assistant,admin,administrator,principal']);
@@ -535,6 +545,7 @@ $routes->post('payment/event_charges', 'View\EventController::eventUpdate');
// Parent event participation
$routes->get('administrator/event-charges', 'View\EventController::eventShow');
$routes->get('administrator/event-charges/pdf', 'View\EventController::eventChargesPdf');
$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1');
$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1');
$routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1');
@@ -745,6 +756,9 @@ $routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorCo
$routes->get('/administrator/exam-drafts', 'View\ExamDraftController::adminIndex', ['filter' => 'auth:admin']);
$routes->post('/administrator/exam-drafts/review', 'View\ExamDraftController::adminReview', ['filter' => 'auth:admin']);
$routes->post('/administrator/exam-drafts/upload-legacy', 'View\ExamDraftController::adminUploadLegacy', ['filter' => 'auth:admin']);
$routes->get('/principal/exam-drafts', 'View\ExamDraftController::principalIndex', ['filter' => 'auth:admin']);
$routes->post('/principal/exam-drafts/review', 'View\ExamDraftController::principalReview', ['filter' => 'auth:admin']);
$routes->post('/principal/exam-drafts/upload-legacy', 'View\ExamDraftController::principalUploadLegacy', ['filter' => 'auth:admin']);
/*
* --------------------------------------------------------------------
+1 -1
View File
@@ -36,7 +36,7 @@ class Services extends BaseService
/**
* Override CI Email service to enforce a global Reply-To.
*/
public static function email(array $config = null, bool $getShared = true)
public static function email(?array $config = null, bool $getShared = true)
{
if ($getShared) {
return static::getSharedInstance('email', $config);
+47 -27
View File
@@ -5,7 +5,6 @@ namespace App\Controllers;
use App\Models\LoginActivityModel;
use App\Models\UserModel;
use App\Models\UserRoleModel;
use CodeIgniter\Controller;
use CodeIgniter\Events\Events;
use App\Models\IpAttemptModel;
use App\Models\PasswordResetModel;
@@ -19,7 +18,7 @@ require_once APPPATH . 'Helpers/pbkdf2_helper.php';
require_once APPPATH . 'Helpers/jwt_helper.php';
class AuthController extends Controller
class AuthController extends BaseController
{
protected $configModel;
protected $userModel;
@@ -83,9 +82,23 @@ class AuthController extends Controller
log_message('info', 'Processing login form submission.');
$redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? $this->request->getGet('redirect_to') ?? ''));
$validator = \Config\Services::validation();
$requestData = sanitize_request_value($this->request->getPost(['email', 'password']));
$validator->setRules([
'email' => 'required|valid_email|max_length[254]',
'password' => 'required|max_length[255]',
]);
if (! $validator->run($requestData)) {
return redirect()
->back()
->with('error', 'Please enter a valid email and password.')
->withInput();
}
// Step 1: Get email, password, and IP from the request
$email = $this->request->getPost('email');
$password = $this->request->getPost('password');
$email = strtolower((string) $requestData['email']);
$password = (string) $requestData['password'];
$ip = $this->request->getIPAddress();
log_message('info', 'Login attempt from IP: ' . $ip . ' for email: ' . $email);
@@ -139,26 +152,29 @@ class AuthController extends Controller
// JSON API: POST /api/login
public function apiLogin()
{
$requestData = $this->request->getJSON(true);
if (!$requestData) {
// fallback to form vars
$requestData = [
$requestData = sanitize_request_value($this->request->getJSON(true) ?: [
'email' => $this->request->getPost('email'),
'password' => $this->request->getPost('password'),
];
}
]);
$email = $requestData['email'] ?? '';
$password = $requestData['password'] ?? '';
$ip = $this->request->getIPAddress();
$validation = \Config\Services::validation();
$validation->setRules([
'email' => 'required|valid_email|max_length[254]',
'password' => 'required|max_length[255]',
]);
if (!$email || !$password) {
return $this->response->setStatusCode(400)->setJSON([
if (! $validation->run($requestData)) {
return $this->response->setStatusCode(422)->setJSON([
'status' => false,
'message' => 'Email and password are required.'
'message' => 'Validation failed.',
'errors' => $validation->getErrors(),
]);
}
$email = strtolower((string) ($requestData['email'] ?? ''));
$password = (string) ($requestData['password'] ?? '');
$ip = $this->request->getIPAddress();
if ($this->isIpBlocked($ip)) {
return $this->response->setStatusCode(429)->setJSON([
'status' => false,
@@ -220,7 +236,15 @@ class AuthController extends Controller
'exp' => $exp,
];
$secret = env('JWT_SECRET', 'change-me-in-env');
try {
$secret = require_env('JWT_SECRET');
} catch (\RuntimeException $e) {
return $this->response->setStatusCode(500)->setJSON([
'status' => false,
'message' => 'JWT configuration is missing.',
]);
}
$token = jwt_encode($payload, $secret, 'HS256');
return $this->response->setJSON([
@@ -240,18 +264,14 @@ class AuthController extends Controller
*/
public function apiRegister()
{
$requestData = $this->request->getJSON(true);
if (!$requestData) {
// fallback to form vars
$requestData = $this->request->getPost();
}
$requestData = sanitize_request_value($this->request->getJSON(true) ?: $this->request->getPost());
// Basic validation
$rules = [
'firstname' => 'required|min_length[2]|max_length[30]',
'lastname' => 'required|min_length[2]|max_length[30]',
'email' => 'required|valid_email|is_unique[users.email]',
'password' => 'required|min_length[8]',
'firstname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]",
'lastname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]",
'email' => 'required|valid_email|max_length[254]|is_unique[users.email]',
'password' => 'required|min_length[8]|max_length[255]',
'cellphone' => 'required|min_length[10]|max_length[20]',
];
@@ -332,7 +352,7 @@ class AuthController extends Controller
'exp' => $exp,
];
$secret = env('JWT_SECRET', 'change-me-in-env');
$secret = require_env('JWT_SECRET');
$token = jwt_encode($payload, $secret, 'HS256');
return $this->response->setStatusCode(201)->setJSON([
+16 -1
View File
@@ -36,7 +36,7 @@ abstract class BaseController extends Controller
*
* @var list<string>
*/
protected $helpers = [];
protected $helpers = ['security'];
/** @var ApiClient */
protected ApiClient $api;
@@ -73,6 +73,21 @@ abstract class BaseController extends Controller
// Assuming the user role is stored in the session
return session()->get('role');
}
protected function validated(array $rules, ?array $data = null): array
{
$validation = service('validation');
$payload = $data ?? ($this->request->getJSON(true) ?: $this->request->getPost());
$payload = sanitize_request_value($payload);
$validation->setRules($rules);
if (! $validation->run($payload)) {
throw new \InvalidArgumentException(json_encode($validation->getErrors(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: 'Validation failed');
}
return $payload;
}
}
?>
+10 -3
View File
@@ -20,15 +20,22 @@ class ProofreadController extends ResourceController
}
// Accept form-urlencoded payload to play nicely with CSRF protection
$text = (string) ($this->request->getPost('text') ?? '');
if ($text === '' || mb_strlen($text) > 20000) {
$validation = service('validation');
$payload = sanitize_request_value($this->request->getPost(['text']));
$validation->setRules([
'text' => 'required|min_length[1]|max_length[20000]',
]);
if (! $validation->run($payload)) {
return $this->respond([
'ok' => false,
'error' => 'Invalid text (empty or too long).',
'error' => 'Invalid text payload.',
'csrfHash' => csrf_hash(),
], 422);
}
$text = (string) $payload['text'];
$client = \Config\Services::curlrequest(['timeout' => 10]);
try {
@@ -2003,6 +2003,58 @@ class AttendanceTrackingController extends BaseController
return [$subject, $body];
}
private function buildFallbackTemplate(string $code, string $variant, array $context): array
{
$studentName = (string) ($context['{{student_name}}'] ?? 'the student');
$incident = (string) ($context['{{incident_date}}'] ?? date('Y-m-d'));
$parentName = (string) ($context['{{parent_name}}'] ?? 'Parent/Guardian');
$phone = (string) ($context['{{school_phone}}'] ?? 'the school office');
$voicemail = (string) ($context['{{voicemail_phone}}'] ?? 'your voicemail');
$subject = match (true) {
str_starts_with($code, 'ABS_1') => 'Attendance Notice: Unreported Absence',
str_starts_with($code, 'ABS_2') => 'Attendance Follow-Up: Two Consecutive Absences',
str_starts_with($code, 'ABS_3') => 'Attendance Follow-Up: Three Absences',
str_starts_with($code, 'LATE_2') => 'Attendance Notice: Repeated Lateness',
str_starts_with($code, 'LATE_3'),
str_starts_with($code, 'LATE_4'),
str_starts_with($code, 'MIX') => 'Attendance Follow-Up: Repeated Lateness and Absence',
default => 'Attendance Notice',
};
$intro = "<p>Insha Allah this email finds you well";
if ($variant === 'answered') {
$intro .= ", <strong>{$parentName}</strong>. Jazakum Allahu khayran for taking the time to speak with us today.</p>";
} elseif ($variant === 'no_answer') {
$intro .= ".</p><p>We tried to reach you but could not connect and left a voice message at <strong>{$voicemail}</strong>.</p>";
} else {
$intro .= ".</p>";
}
$details = match (true) {
str_starts_with($code, 'ABS_1')
=> "<p>This is to let you know that <strong>{$studentName}</strong> was absent on <strong>{$incident}</strong> without prior notice.</p>",
str_starts_with($code, 'ABS_2')
=> "<p>This is a reminder that <strong>{$studentName}</strong> has had repeated absences, most recently on <strong>{$incident}</strong>, without prior notice.</p>",
str_starts_with($code, 'ABS_3')
=> "<p>This is a reminder that <strong>{$studentName}</strong> has been absent three times, most recently on <strong>{$incident}</strong>, without prior notice.</p>",
str_starts_with($code, 'LATE_2')
=> "<p>This is a reminder that <strong>{$studentName}</strong> has been late multiple times, most recently on <strong>{$incident}</strong>.</p>",
str_starts_with($code, 'LATE_3'),
str_starts_with($code, 'LATE_4')
=> "<p>This is a follow-up that <strong>{$studentName}</strong> has had repeated lateness concerns, most recently on <strong>{$incident}</strong>.</p>",
str_starts_with($code, 'MIX')
=> "<p>This is a follow-up that <strong>{$studentName}</strong> has had a mix of repeated lateness and absence concerns, most recently on <strong>{$incident}</strong>.</p>",
default
=> "<p>We'd like to inform you about <strong>{$studentName}</strong>'s recent attendance concern dated <strong>{$incident}</strong>.</p>",
};
$closing = "<p>If your child will be absent or late due to illness or family commitments, please let us know ahead of time.</p>"
. "<p>You can email/call/text us at <strong>{$phone}</strong>.</p>";
return [$subject, $intro . $details . $closing];
}
public function compose()
{
@@ -2033,8 +2085,8 @@ class AttendanceTrackingController extends BaseController
$rendered = $this->renderTemplate($code, $variant, $ctx);
if (!$rendered) {
return redirect()->to(site_url('attendance/violations'))
->with('error', 'Template not found for ' . $code . ' (' . $variant . ').');
log_message('warning', 'Attendance email template missing; using fallback compose body. code=' . $code . ' variant=' . $variant);
$rendered = $this->buildFallbackTemplate($code, $variant, $ctx);
}
[$subject, $body] = $rendered;
@@ -0,0 +1,398 @@
<?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();
$classSectionId = $this->request->getGet('class_section_id');
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$classSections = $db->table('classSection cs')
->select('cs.class_section_id, cs.class_section_name')
->join('student_class sc', 'sc.class_section_id = cs.class_section_id')
->join('students s', 's.id = sc.student_id')
->where('s.is_active', 1)
->where('sc.school_year', $schoolYear)
->groupBy('cs.class_section_id, cs.class_section_name')
->having('COUNT(s.id) >', 1)
->orderBy('cs.class_section_name', 'ASC')
->get()->getResultArray();
$students = [];
if ($classSectionId) {
$students = $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.is_active', 1)
->where('sc.school_year', $schoolYear)
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()->getResultArray();
}
return view('admin/certificates/index', [
'classSections' => $classSections,
'students' => $students,
'selectedClassId' => $classSectionId,
'schoolYear' => $schoolYear,
'certDate' => date('m/d/Y'),
]);
}
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.');
}
$studentIds = array_filter(array_map('intval', $studentIds));
$certDate = preg_replace('/[^0-9\/\-]/', '', $certDate);
if (empty($studentIds)) {
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)) {
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);
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
->setHeader('Content-Type', 'application/pdf')
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
->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);
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;
$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();
$tmpFiles = [];
foreach ($students as $student) {
$pdf->AddPage();
$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
);
}
$output = $pdf->Output('', 'S');
foreach ($tmpFiles as $f) {
@unlink($f);
}
return $output;
}
/**
* 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 $qrFile,
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 ($qrFile !== null && file_exists($qrFile)) {
$qrX = $W - 70.87 - $qrSize; // 2.5 cm from right edge
$qrY = $H - 42.52 - $qrSize; // 1.5 cm from bottom edge
$pdf->Image($qrFile, $qrX, $qrY, $qrSize, $qrSize);
}
// ── "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.87, $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);
}
}
+8 -8
View File
@@ -24,10 +24,10 @@ class ContactController extends BaseController
// Define validation rules
$validation->setRules([
'name' => 'required|min_length[3]',
'email' => 'required|valid_email',
'subject' => 'required|min_length[3]',
'message' => 'required|min_length[10]'
'name' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[3]|max_length[100]",
'email' => 'required|valid_email|max_length[254]',
'subject' => 'required|min_length[3]|max_length[150]',
'message' => 'required|min_length[10]|max_length[5000]'
]);
if (!$validation->withRequest($this->request)->run()) {
@@ -37,10 +37,10 @@ class ContactController extends BaseController
}
// Process form data
$name = $this->request->getPost('name');
$email = strtolower($this->request->getPost('email'));
$subject = $this->request->getPost('subject');
$message = $this->request->getPost('message');
$name = esc((string) $this->request->getPost('name'));
$email = esc(strtolower((string) $this->request->getPost('email')));
$subject = esc((string) $this->request->getPost('subject'));
$message = nl2br(esc((string) $this->request->getPost('message')));
// Initialize the EmailController
$emailController = new \App\Controllers\View\EmailController();
+221 -80
View File
@@ -17,9 +17,11 @@ use App\Models\PaymentModel;
use App\Models\CalendarModel;
use App\Services\EmailService;
use Config\Database;
#use ???
use App\Controllers\View\InvoiceController;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\RESTful\ResourceController;
use Dompdf\Dompdf;
use Dompdf\Options;
class EventController extends ResourceController
{
@@ -121,6 +123,194 @@ class EventController extends ResourceController
return $fallback;
}
private function loadEventChargesListingData(bool $includeParents = true): array
{
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$semester = $this->request->getGet('semester') ?? $this->semester;
$filterEventId = (int) ($this->request->getGet('event_id') ?? 0);
$filterParentId = (int) ($this->request->getGet('parent_id') ?? 0);
$parents = [];
if ($includeParents) {
$parents = $this->userModel->getParents();
usort($parents, static function (array $a, array $b): int {
$aLabel = trim(preg_replace('/\s+/', ' ', (string) ($a['firstname'] ?? '') . ' ' . (string) ($a['lastname'] ?? '')));
$bLabel = trim(preg_replace('/\s+/', ' ', (string) ($b['firstname'] ?? '') . ' ' . (string) ($b['lastname'] ?? '')));
$nameCmp = strnatcasecmp($aLabel, $bLabel);
if ($nameCmp !== 0) {
return $nameCmp;
}
return strnatcasecmp((string) ($a['school_id'] ?? ''), (string) ($b['school_id'] ?? ''));
});
}
$events = $this->eventModel->getActiveEvents($schoolYear);
$chargesBuilder = $this->eventChargesModel
->select('event_charges.*,
users.firstname AS parent_firstname, users.lastname AS parent_lastname, users.cellphone AS parent_cellphone,
students.firstname AS student_firstname, students.lastname AS student_lastname,
events.event_name, events.description AS event_description, events.amount AS event_amount')
->join('users', 'users.id = event_charges.parent_id', 'left')
->join('students', 'students.id = event_charges.student_id', 'left')
->join('events', 'events.id = event_charges.event_id', 'left')
->where('event_charges.school_year', $schoolYear)
->where('event_charges.semester', $semester);
if ($filterEventId > 0) {
$chargesBuilder->where('event_charges.event_id', $filterEventId);
}
if ($filterParentId > 0) {
$chargesBuilder->where('event_charges.parent_id', $filterParentId);
}
$charges = $chargesBuilder
->orderBy('event_charges.created_at', 'DESC')
->findAll();
foreach ($charges as &$charge) {
if (empty($charge['class_section_id']) && !empty($charge['student_id'])) {
$sections = $this->studentClassModel->getClassSectionIdsByStudentId(
(int) $charge['student_id'],
$schoolYear
);
if (!empty($sections)) {
$charge['class_section_id'] = $sections[0];
}
}
}
unset($charge);
$parentBalances = [];
try {
$balanceRows = $this->invoiceModel
->select('parent_id, COALESCE(SUM(balance),0) AS total_balance')
->where('school_year', $schoolYear)
->where('semester', $semester)
->groupBy('parent_id')
->findAll();
foreach ($balanceRows as $row) {
$parentBalances[(int) ($row['parent_id'] ?? 0)] = (float) ($row['total_balance'] ?? 0.0);
}
} catch (\Throwable $e) {
log_message('error', 'Failed to load parent balances: ' . $e->getMessage());
}
$sectionIds = array_unique(array_filter(array_column($charges, 'class_section_id')));
$classSectionNames = [];
if (!empty($sectionIds)) {
$sections = $this->classSectionModel
->select('class_section_id, class_section_name')
->whereIn('class_section_id', $sectionIds)
->findAll();
foreach ($sections as $section) {
$classSectionNames[(int) ($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? '';
}
}
return [
'charges' => $charges,
'parents' => $parents,
'events' => $events,
'school_year' => $schoolYear,
'semester' => $semester,
'filterEventId' => $filterEventId,
'filterParentId' => $filterParentId,
'parentBalances' => $parentBalances,
'classSectionNames' => $classSectionNames,
];
}
private function buildGroupedEventCharges(array $charges): array
{
$grouped = [];
foreach ($charges as $charge) {
$eventId = (int) ($charge['event_id'] ?? 0);
if (!isset($grouped[$eventId])) {
$grouped[$eventId] = [
'label' => $charge['event_name'] ?? 'N/A',
'rows' => [],
];
}
$grouped[$eventId]['rows'][] = $charge;
}
return $grouped;
}
private function applyGroupedEventChargeRowOrder(array $groupedCharges): array
{
$rowOrder = $this->request->getGet('row_order');
if (!is_array($rowOrder) || empty($rowOrder)) {
return $groupedCharges;
}
foreach ($groupedCharges as $eventId => &$group) {
$rawOrder = $rowOrder[$eventId] ?? $rowOrder[(string) $eventId] ?? null;
if (!is_string($rawOrder) || trim($rawOrder) === '') {
continue;
}
$orderedIds = array_values(array_filter(array_map(
static fn ($value): int => (int) trim((string) $value),
explode(',', $rawOrder)
)));
if (empty($orderedIds) || empty($group['rows']) || !is_array($group['rows'])) {
continue;
}
$rowsById = [];
foreach ($group['rows'] as $row) {
$rowsById[(int) ($row['id'] ?? 0)] = $row;
}
$reorderedRows = [];
foreach ($orderedIds as $chargeId) {
if (isset($rowsById[$chargeId])) {
$reorderedRows[] = $rowsById[$chargeId];
unset($rowsById[$chargeId]);
}
}
foreach ($group['rows'] as $row) {
$chargeId = (int) ($row['id'] ?? 0);
if (isset($rowsById[$chargeId])) {
$reorderedRows[] = $row;
unset($rowsById[$chargeId]);
}
}
$group['rows'] = $reorderedRows;
}
unset($group);
return $groupedCharges;
}
private function buildEventChargesPdfFilename(array $data): string
{
$label = 'event_participant_lists';
if (!empty($data['filterEventId'])) {
foreach (($data['events'] ?? []) as $event) {
if ((int) ($event['id'] ?? 0) === (int) $data['filterEventId']) {
$label = (string) ($event['event_name'] ?? $label);
break;
}
}
}
$safeLabel = trim((string) preg_replace('/[^A-Za-z0-9]+/', '_', strtolower($label)), '_');
if ($safeLabel === '') {
$safeLabel = 'event_participant_lists';
}
return $safeLabel . '_' . date('Ymd_His') . '.pdf';
}
public function index()
{
$eventModel = new EventModel();
@@ -475,74 +665,7 @@ class EventController extends ResourceController
// Optionally keep your eventShow / eventUpdate for legacy administrator event charges
public function eventShow()
{
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$semester = $this->request->getGet('semester') ?? $this->semester;
$parents = $this->userModel->getParents();
$events = $this->eventModel->getActiveEvents($schoolYear);
$filterEventId = (int) ($this->request->getGet('event_id') ?? 0);
$filterParentId = (int) ($this->request->getGet('parent_id') ?? 0);
$chargesBuilder = $this->eventChargesModel
->select('event_charges.*,
users.firstname AS parent_firstname, users.lastname AS parent_lastname,
students.firstname AS student_firstname, students.lastname AS student_lastname,
events.event_name, events.description AS event_description, events.amount AS event_amount')
->join('users', 'users.id = event_charges.parent_id', 'left')
->join('students', 'students.id = event_charges.student_id', 'left')
->join('events', 'events.id = event_charges.event_id', 'left')
->where('event_charges.school_year', $schoolYear)
->where('event_charges.semester', $semester);
if ($filterEventId > 0) {
$chargesBuilder->where('event_charges.event_id', $filterEventId);
}
$charges = $chargesBuilder
->orderBy('event_charges.created_at', 'DESC')
->findAll();
foreach ($charges as &$charge) {
if (empty($charge['class_section_id']) && !empty($charge['student_id'])) {
$sections = $this->studentClassModel->getClassSectionIdsByStudentId(
(int)$charge['student_id'],
$schoolYear
);
if (!empty($sections)) {
$charge['class_section_id'] = $sections[0];
}
}
}
unset($charge);
$parentBalances = [];
try {
$balanceRows = $this->invoiceModel
->select('parent_id, COALESCE(SUM(balance),0) AS total_balance')
->where('school_year', $schoolYear)
->where('semester', $semester)
->groupBy('parent_id')
->findAll();
foreach ($balanceRows as $row) {
$parentBalances[(int)($row['parent_id'] ?? 0)] = (float)($row['total_balance'] ?? 0.0);
}
} catch (\Throwable $e) {
log_message('error', 'Failed to load parent balances: ' . $e->getMessage());
}
$sectionIds = array_unique(array_filter(array_column($charges, 'class_section_id')));
$classSectionNames = [];
if (!empty($sectionIds)) {
$sections = $this->classSectionModel
->select('class_section_id, class_section_name')
->whereIn('class_section_id', $sectionIds)
->findAll();
foreach ($sections as $section) {
$classSectionNames[(int)($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? '';
}
}
$data = $this->loadEventChargesListingData();
$semesterOptions = (new EventChargesModel())
->select('semester')
@@ -564,19 +687,37 @@ class EventController extends ResourceController
$schoolYearOptions = [$this->schoolYear];
}
return view('administrator/events/event_charges', [
'charges' => $charges,
'parents' => $parents,
'events' => $events,
'school_year' => $schoolYear,
'semester' => $semester,
'filterEventId' => $filterEventId,
'filterParentId' => $filterParentId,
'parentBalances' => $parentBalances,
'classSectionNames' => $classSectionNames,
return view('administrator/events/event_charges', array_merge($data, [
'semesterOptions' => $semesterOptions,
'schoolYearOptions' => $schoolYearOptions,
]);
]));
}
public function eventChargesPdf(): ResponseInterface
{
$data = $this->loadEventChargesListingData(false);
$groupedCharges = $this->buildGroupedEventCharges($data['charges']);
$groupedCharges = $this->applyGroupedEventChargeRowOrder($groupedCharges);
$generatedAt = date('m-d-Y h:i A');
$html = view('administrator/events/event_charges_pdf', array_merge($data, [
'groupedCharges' => $groupedCharges,
'generatedAt' => $generatedAt,
]));
$options = new Options();
$options->set('isRemoteEnabled', true);
$options->set('defaultFont', 'DejaVu Sans');
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->setPaper('A4', 'landscape');
$dompdf->render();
return $this->response
->setHeader('Content-Type', 'application/pdf')
->setHeader('Content-Disposition', 'inline; filename="' . $this->buildEventChargesPdfFilename($data) . '"')
->setBody($dompdf->output());
}
+72 -17
View File
@@ -348,6 +348,16 @@ class ExamDraftController extends BaseController
}
public function adminIndex()
{
return $this->reviewIndex();
}
public function principalIndex()
{
return $this->reviewIndex();
}
public function reviewIndex()
{
if ($this->reviewerIdColumn !== '') {
$allDrafts = $this->examDraftModel
@@ -487,10 +497,23 @@ class ExamDraftController extends BaseController
'visibleClasses' => $visibleClasses,
'classDraftGroups' => $classDraftGroups,
'newSubmissionClasses' => $newSubmissionClasses,
'reviewActionUrl' => base_url($this->reviewRoutePrefix() . '/exam-drafts/review'),
'legacyUploadUrl' => base_url($this->reviewRoutePrefix() . '/exam-drafts/upload-legacy'),
'reviewPortalLabel' => $this->reviewPortalLabel(),
]);
}
public function adminUploadLegacy()
{
return $this->reviewUploadLegacy();
}
public function principalUploadLegacy()
{
return $this->reviewUploadLegacy();
}
public function reviewUploadLegacy()
{
$adminId = (int) (session()->get('user_id') ?? 0);
if ($adminId <= 0) {
@@ -571,13 +594,23 @@ class ExamDraftController extends BaseController
}
if ($saved > 0) {
return redirect()->to('/administrator/exam-drafts')->with('success', 'Old exam uploaded successfully.');
return redirect()->to($this->reviewDashboardPath())->with('success', 'Old exam uploaded successfully.');
}
return redirect()->back()->withInput()->with('error', 'Unable to save the old exam.');
}
public function adminReview()
{
return $this->reviewSubmission();
}
public function principalReview()
{
return $this->reviewSubmission();
}
public function reviewSubmission()
{
$draftId = (int) ($this->request->getPost('draft_id') ?? 0);
if ($draftId <= 0) {
@@ -690,11 +723,6 @@ class ExamDraftController extends BaseController
if ($this->hasAcceptanceTypeColumn) {
$newRow['acceptance_type'] = $status === 'accepted' ? $acceptanceType : null;
}
$teacherFile = $this->draftTeacherFile($draft);
if (!empty($teacherFile)) {
$newRow[$this->authorFileColumn] = $teacherFile;
$newRow[$this->authorFilenameColumn] = $this->draftTeacherFilename($draft) ?? $teacherFile;
}
if ($this->hasIsLegacyColumn && !empty($draft['is_legacy'])) {
$newRow['is_legacy'] = 1;
}
@@ -711,16 +739,6 @@ class ExamDraftController extends BaseController
return redirect()->back()->with('success', 'Review saved successfully.');
}
return redirect()->back()->with('error', 'Unable to save the review.');
} elseif (strtolower($status) === 'accepted' && !empty($this->draftTeacherFile($draft))) {
$copied = $this->copyDraftToFinal($this->draftTeacherFile($draft));
if ($copied !== null) {
$update['final_file'] = $copied;
$update['final_filename'] = $this->draftTeacherFilename($draft) ?? $this->draftTeacherFile($draft);
$pdfName = $this->ensurePdfExists($copied, pathinfo($copied, PATHINFO_EXTENSION));
if ($pdfName !== null && $this->hasFinalPdfColumn) {
$update['final_pdf_file'] = $pdfName;
}
}
}
if ($status === 'legacy') {
$this->prepareLegacyPdfVersion($update, $draft);
@@ -1010,7 +1028,7 @@ class ExamDraftController extends BaseController
. '<tr><td style="padding:4px 8px;"><strong>Status</strong></td><td style="padding:4px 8px;">' . esc((string) ($draft['status'] ?? 'submitted')) . '</td></tr>'
. '<tr><td style="padding:4px 8px;"><strong>Submitted at</strong></td><td style="padding:4px 8px;">' . esc((string) $submittedAt) . '</td></tr>'
. '</table>'
. '<p>Review drafts: <a href="' . esc(base_url('administrator/exam-drafts')) . '">Admin exam drafts</a></p>';
. '<p>Review drafts: <a href="' . esc(base_url($this->reviewRoutePrefix() . '/exam-drafts')) . '">' . esc($this->reviewPortalLabel()) . ' exam drafts</a></p>';
$mailer = \Config\Services::emailService();
$mailer->send($principalEmail, $subject, $body, 'notifications');
@@ -1019,6 +1037,38 @@ class ExamDraftController extends BaseController
}
}
private function reviewDashboardPath(): string
{
return '/' . $this->reviewRoutePrefix() . '/exam-drafts';
}
private function reviewRoutePrefix(): string
{
return $this->isPrincipalReviewer() ? 'principal' : 'administrator';
}
private function reviewPortalLabel(): string
{
return $this->isPrincipalReviewer() ? 'Principal' : 'Administrator';
}
private function isPrincipalReviewer(): bool
{
$roles = session()->get('roles');
if (!is_array($roles)) {
$roles = $roles === null ? [] : [$roles];
}
foreach ($roles as $role) {
$normalized = strtolower(trim(str_replace([' ', '-'], '_', (string) $role)));
if ($normalized === 'principal') {
return true;
}
}
return false;
}
private function nextDraftVersion(array $draft): int
{
$query = $this->examDraftModel
@@ -1373,6 +1423,11 @@ class ExamDraftController extends BaseController
$base = pathinfo($sourcePath, PATHINFO_FILENAME);
$targetPath = $targetDir . '/' . $base . '.pdf';
if (!function_exists('exec')) {
log_message('warning', 'ExamDraftController::convertDocToPdf skipped because exec() is unavailable.');
return is_file($targetPath) ? basename($targetPath) : null;
}
// Attempt conversion via LibreOffice if available
$cmd = 'soffice --headless --convert-to pdf --outdir ' . escapeshellarg($targetDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null';
@\exec($cmd);
+62 -7
View File
@@ -322,6 +322,23 @@ class FilesController extends Controller
return 'teacher_file';
}
private function resolveExamDraftAuthorIdColumn($db): string
{
try {
$fields = $db->getFieldNames('exam_drafts');
if (in_array('teacher_id', $fields, true)) {
return 'teacher_id';
}
if (in_array('author_id', $fields, true)) {
return 'author_id';
}
} catch (\Throwable $e) {
log_message('error', 'FilesController::resolveExamDraftAuthorIdColumn error: ' . $e->getMessage());
}
return 'teacher_id';
}
private function canAccessExamDraftFile(string $filename, string $subdir): bool
{
$userId = (int) (session()->get('user_id') ?? 0);
@@ -330,15 +347,16 @@ class FilesController extends Controller
}
$role = strtolower((string) (session()->get('role') ?? ''));
if ($role === 'admin') {
if (in_array($role, ['admin', 'administrator', 'principal'], true)) {
return true;
}
$db = Database::connect();
$fileColumn = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
$authorIdColumn = $this->resolveExamDraftAuthorIdColumn($db);
$draft = $db->table('exam_drafts ed')
->select('ed.class_section_id, ed.school_year, ed.teacher_id, ed.author_id')
->select('ed.class_section_id, ed.school_year, ed.semester, ed.status, ed.' . $authorIdColumn . ' AS draft_author_id')
->where('ed.' . $fileColumn, $filename)
->limit(1)
->get()
@@ -348,7 +366,7 @@ class FilesController extends Controller
return false;
}
$authorId = (int) ($draft['teacher_id'] ?? $draft['author_id'] ?? 0);
$authorId = (int) ($draft['draft_author_id'] ?? 0);
if ($authorId > 0 && $authorId === $userId) {
return true;
}
@@ -358,16 +376,53 @@ class FilesController extends Controller
return false;
}
$status = strtolower(trim((string) ($draft['status'] ?? '')));
if ($subdir === 'drafts' && $status === 'draft') {
return false;
}
$currentSchoolYear = trim((string) (session()->get('school_year') ?? ''));
$currentSemester = trim((string) (session()->get('semester') ?? ''));
$draftSchoolYear = trim((string) ($draft['school_year'] ?? ''));
$draftSemester = trim((string) ($draft['semester'] ?? ''));
$hasCurrentAssignment = $db->table('teacher_class')
->select('id')
->where('teacher_id', $userId)
->where('class_section_id', $classSectionId);
if ($currentSchoolYear !== '') {
$hasCurrentAssignment->where('school_year', $currentSchoolYear);
}
$hasCurrentAssignment = $hasCurrentAssignment->limit(1)->countAllResults() > 0;
if ($hasCurrentAssignment) {
if ($subdir === 'finals') {
return true;
}
return $draftSchoolYear === '' || $currentSchoolYear === '' || $draftSchoolYear === $currentSchoolYear;
}
$assignmentQuery = $db->table('teacher_class')
->select('id')
->where('teacher_id', $userId)
->where('class_section_id', $classSectionId);
$schoolYear = trim((string) ($draft['school_year'] ?? ''));
if ($schoolYear !== '') {
$assignmentQuery->where('school_year', $schoolYear);
if ($draftSchoolYear !== '') {
$assignmentQuery->where('school_year', $draftSchoolYear);
}
return $assignmentQuery->limit(1)->countAllResults() > 0;
$hasDraftYearAssignment = $assignmentQuery->limit(1)->countAllResults() > 0;
if (!$hasDraftYearAssignment) {
return false;
}
if ($subdir === 'finals') {
return true;
}
return $draftSemester === '' || $currentSemester === '' || $draftSemester === $currentSemester;
}
}
+504
View File
@@ -0,0 +1,504 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ConfigurationModel;
class TrophyController extends BaseController
{
private const PERCENTILE = 75.0;
protected ConfigurationModel $configModel;
public function __construct()
{
$this->configModel = new ConfigurationModel();
}
public function index()
{
$db = \Config\Database::connect();
$currentYear = $this->configModel->getConfig('school_year') ?? '';
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
$percentile = max(1.0, min(99.0, $percentile));
// Available school years from class assignments so the current year can
// still appear even if fall scores are not fully entered yet.
$years = $db->table('student_class')
->select('school_year')
->distinct()
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
$years = array_column($years, 'school_year');
// Build a fall-score-based projection. We start from class assignments so
// students without a recorded fall score still appear as "not yet
// projected" rather than disappearing from the page.
$rows = $db->table('student_class sc')
->select([
'sc.student_id',
'sc.class_section_id',
'MAX(ss.semester_score) AS fall_score',
'cs.class_section_name',
'CONCAT(s.firstname, " ", s.lastname) AS student_name',
's.school_id',
's.gender',
])
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('students s', 's.id = sc.student_id', 'left')
->join(
'semester_scores ss',
'ss.student_id = sc.student_id'
. ' AND ss.class_section_id = sc.class_section_id'
. ' AND ss.school_year = ' . $db->escape($selectedYear)
. ' AND LOWER(ss.semester) = "fall"',
'left'
)
->where('sc.school_year', $selectedYear)
->orderBy('cs.class_section_name', 'ASC')
->orderBy('fall_score', 'DESC')
->orderBy('student_name', 'ASC')
->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id')
->get()
->getResultArray();
// Group rows by class section
$sections = [];
foreach ($rows as $row) {
$sid = (int) ($row['class_section_id'] ?? 0);
$name = $row['class_section_name'] ?? 'Section ' . $sid;
if (!isset($sections[$sid])) {
$sections[$sid] = ['name' => $name, 'students' => []];
}
$sections[$sid]['students'][] = [
'student_id' => (int) $row['student_id'],
'name' => $row['student_name'],
'school_id' => $row['school_id'],
'gender' => $row['gender'] ?? '',
'fall_score' => is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null,
];
}
// Calculate trophy thresholds from fall scores only, then project who is
// currently on track for a year-end trophy.
$classResults = [];
foreach ($sections as $sid => $section) {
usort($section['students'], static function (array $a, array $b): int {
$aScore = $a['fall_score'];
$bScore = $b['fall_score'];
if ($aScore === null && $bScore === null) {
return strcmp($a['name'], $b['name']);
}
if ($aScore === null) {
return 1;
}
if ($bScore === null) {
return -1;
}
return $bScore <=> $aScore ?: strcmp($a['name'], $b['name']);
});
$scores = array_column($section['students'], 'fall_score');
$calc = $this->calculateThreshold($scores, $percentile);
$threshold = $calc['threshold'];
$students = array_map(function (array $student) use ($threshold): array {
$student['projected_trophy'] = $threshold !== null
&& $student['fall_score'] !== null
&& $student['fall_score'] >= $threshold;
return $student;
}, $section['students']);
$scoredCount = count(array_filter(
array_column($students, 'fall_score'),
static fn ($score) => $score !== null
));
$boys = array_filter($students, static fn ($s) => strtolower($s['gender']) === 'male');
$girls = array_filter($students, static fn ($s) => strtolower($s['gender']) === 'female');
$trophyBoys = array_filter($boys, static fn ($s) => $s['projected_trophy']);
$trophyGirls = array_filter($girls, static fn ($s) => $s['projected_trophy']);
$nBoys = count($boys);
$nGirls = count($girls);
$nTrophyBoys = count($trophyBoys);
$nTrophyGirls = count($trophyGirls);
$total = count($students);
$classResults[] = [
'section_id' => $sid,
'section_name' => $section['name'],
'students' => $students,
'threshold' => $threshold,
'trophy_count' => $calc['winners'],
'student_count' => $total,
'scored_count' => $scoredCount,
'method' => $calc['method'],
'boys' => $nBoys,
'girls' => $nGirls,
'trophy_boys' => $nTrophyBoys,
'trophy_girls' => $nTrophyGirls,
'pct_boys' => $total > 0 ? round($nBoys / $total * 100) : 0,
'pct_girls' => $total > 0 ? round($nGirls / $total * 100) : 0,
'pct_trophy_boys' => $nBoys > 0 ? round($nTrophyBoys / $nBoys * 100) : 0,
'pct_trophy_girls' => $nGirls > 0 ? round($nTrophyGirls / $nGirls * 100) : 0,
'pct_trophy_total' => $total > 0 ? round($calc['winners'] / $total * 100): 0,
];
}
return view('administrator/trophy', [
'classResults' => $classResults,
'selectedYear' => $selectedYear,
'selectedPercentile'=> $percentile,
'years' => $years,
]);
}
public function winners()
{
$db = \Config\Database::connect();
$currentYear = $this->configModel->getConfig('school_year') ?? '';
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
$percentile = max(1.0, min(99.0, $percentile));
$years = $db->table('student_class')
->select('school_year')->distinct()
->orderBy('school_year', 'DESC')
->get()->getResultArray();
$years = array_column($years, 'school_year');
$ey = $db->escape($selectedYear);
$rows = $db->table('student_class sc')
->select([
'sc.student_id',
'sc.class_section_id',
'cs.class_section_name',
'CONCAT(s.firstname, " ", s.lastname) AS student_name',
's.school_id',
's.gender',
'MAX(sf.semester_score) AS fall_score',
'MAX(sp.semester_score) AS spring_score',
])
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('students s', 's.id = sc.student_id', 'left')
->join('semester_scores sf',
'sf.student_id = sc.student_id AND sf.class_section_id = sc.class_section_id'
. ' AND sf.school_year = ' . $ey . ' AND LOWER(sf.semester) = "fall"', 'left')
->join('semester_scores sp',
'sp.student_id = sc.student_id AND sp.class_section_id = sc.class_section_id'
. ' AND sp.school_year = ' . $ey . ' AND LOWER(sp.semester) = "spring"', 'left')
->where('sc.school_year', $selectedYear)
->orderBy('cs.class_section_name', 'ASC')
->orderBy('student_name', 'ASC')
->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id, s.gender')
->get()->getResultArray();
$sections = [];
foreach ($rows as $row) {
$sid = (int) ($row['class_section_id'] ?? 0);
$name = $row['class_section_name'] ?? 'Section ' . $sid;
if (!isset($sections[$sid])) $sections[$sid] = ['name' => $name, 'students' => []];
$fall = is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null;
$spring = is_numeric($row['spring_score']) ? (float) $row['spring_score'] : null;
$year = ($fall !== null && $spring !== null)
? round(($fall + $spring) / 2, 1)
: ($fall ?? $spring);
$sections[$sid]['students'][] = [
'name' => $row['student_name'],
'school_id' => $row['school_id'],
'gender' => $row['gender'] ?? '',
'fall_score' => $fall,
'spring_score' => $spring,
'year_score' => $year,
];
}
$classResults = [];
foreach ($sections as $section) {
usort($section['students'], static function ($a, $b) {
if ($a['fall_score'] === null && $b['fall_score'] === null) return strcmp($a['name'], $b['name']);
if ($a['fall_score'] === null) return 1;
if ($b['fall_score'] === null) return -1;
return $b['fall_score'] <=> $a['fall_score'] ?: strcmp($a['name'], $b['name']);
});
$scores = array_column($section['students'], 'fall_score');
$calc = $this->calculateThreshold($scores, $percentile);
$threshold = $calc['threshold'];
$allStudents = $section['students'];
$winners = array_values(array_filter($allStudents, function ($s) use ($threshold) {
return $threshold !== null && $s['fall_score'] !== null && $s['fall_score'] >= $threshold;
}));
if (empty($winners)) continue;
$boys = array_filter($allStudents, static fn($s) => strtolower($s['gender']) === 'male');
$girls = array_filter($allStudents, static fn($s) => strtolower($s['gender']) === 'female');
$trophyBoys = array_filter($winners, static fn($s) => strtolower($s['gender']) === 'male');
$trophyGirls = array_filter($winners, static fn($s) => strtolower($s['gender']) === 'female');
$n = count($allStudents);
$nBoys = count($boys);
$nGirls = count($girls);
$nTB = count($trophyBoys);
$nTG = count($trophyGirls);
$classResults[] = [
'section_name' => $section['name'],
'threshold' => $threshold,
'winners' => $winners,
'student_count' => $n,
'boys' => $nBoys,
'girls' => $nGirls,
'trophy_boys' => $nTB,
'trophy_girls' => $nTG,
'pct_boys' => $n > 0 ? round($nBoys / $n * 100) : 0,
'pct_girls' => $n > 0 ? round($nGirls / $n * 100) : 0,
'pct_trophy_boys' => $nBoys > 0 ? round($nTB / $nBoys * 100) : 0,
'pct_trophy_girls' => $nGirls > 0 ? round($nTG / $nGirls * 100) : 0,
'pct_trophy_total' => $n > 0 ? round(count($winners) / $n * 100) : 0,
];
}
return view('administrator/trophy_winners', [
'classResults' => $classResults,
'selectedYear' => $selectedYear,
'selectedPercentile' => $percentile,
'years' => $years,
]);
}
public function final()
{
$db = \Config\Database::connect();
$currentYear = $this->configModel->getConfig('school_year') ?? '';
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
$percentile = max(1.0, min(99.0, $percentile));
$years = $db->table('student_class')
->select('school_year')->distinct()
->orderBy('school_year', 'DESC')
->get()->getResultArray();
$years = array_column($years, 'school_year');
$ey = $db->escape($selectedYear);
$rows = $db->table('student_class sc')
->select([
'sc.student_id',
'sc.class_section_id',
'cs.class_section_name',
'CONCAT(s.firstname, " ", s.lastname) AS student_name',
's.school_id',
's.gender',
'MAX(sf.semester_score) AS fall_score',
'MAX(sp.semester_score) AS spring_score',
])
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('students s', 's.id = sc.student_id', 'left')
->join('semester_scores sf',
'sf.student_id = sc.student_id AND sf.class_section_id = sc.class_section_id'
. ' AND sf.school_year = ' . $ey . ' AND LOWER(sf.semester) = "fall"', 'left')
->join('semester_scores sp',
'sp.student_id = sc.student_id AND sp.class_section_id = sc.class_section_id'
. ' AND sp.school_year = ' . $ey . ' AND LOWER(sp.semester) = "spring"', 'left')
->where('sc.school_year', $selectedYear)
->orderBy('cs.class_section_name', 'ASC')
->orderBy('student_name', 'ASC')
->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id, s.gender')
->get()->getResultArray();
$sections = [];
foreach ($rows as $row) {
$sid = (int) ($row['class_section_id'] ?? 0);
$name = $row['class_section_name'] ?? 'Section ' . $sid;
if (!isset($sections[$sid])) $sections[$sid] = ['name' => $name, 'students' => []];
$fall = is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null;
$spring = is_numeric($row['spring_score']) ? (float) $row['spring_score'] : null;
$year = ($fall !== null && $spring !== null)
? round(($fall + $spring) / 2, 1)
: ($fall ?? $spring);
$sections[$sid]['students'][] = [
'name' => $row['student_name'],
'school_id' => $row['school_id'],
'gender' => $row['gender'] ?? '',
'fall_score' => $fall,
'spring_score' => $spring,
'year_score' => $year,
];
}
$classResults = [];
foreach ($sections as $section) {
$students = $section['students'];
$fallCalc = $this->calculateThreshold(array_column($students, 'fall_score'), $percentile);
$yearCalc = $this->calculateThreshold(array_column($students, 'year_score'), $percentile);
$fallThreshold = $fallCalc['threshold'];
$yearThreshold = $yearCalc['threshold'];
$annotated = array_map(static function (array $s) use ($fallThreshold, $yearThreshold): array {
$predicted = $fallThreshold !== null && $s['fall_score'] !== null && $s['fall_score'] >= $fallThreshold;
$actual = $yearThreshold !== null && $s['year_score'] !== null && $s['year_score'] >= $yearThreshold;
$status = match (true) {
$predicted && $actual => 'confirmed',
!$predicted && $actual => 'surprise',
$predicted && !$actual => 'missed',
default => 'none',
};
return $s + compact('predicted', 'actual', 'status');
}, $students);
usort($annotated, static function (array $a, array $b): int {
if ($a['year_score'] === null && $b['year_score'] === null) return strcmp($a['name'], $b['name']);
if ($a['year_score'] === null) return 1;
if ($b['year_score'] === null) return -1;
return $b['year_score'] <=> $a['year_score'] ?: strcmp($a['name'], $b['name']);
});
$nConfirmed = count(array_filter($annotated, static fn ($s) => $s['status'] === 'confirmed'));
$nSurprise = count(array_filter($annotated, static fn ($s) => $s['status'] === 'surprise'));
$nMissed = count(array_filter($annotated, static fn ($s) => $s['status'] === 'missed'));
$nPredicted = count(array_filter($annotated, static fn ($s) => $s['predicted']));
$nActual = count(array_filter($annotated, static fn ($s) => $s['actual']));
$n = count($annotated);
$classResults[] = [
'section_name' => $section['name'],
'students' => $annotated,
'student_count' => $n,
'fall_threshold' => $fallThreshold,
'year_threshold' => $yearThreshold,
'predicted_count' => $nPredicted,
'actual_count' => $nActual,
'confirmed' => $nConfirmed,
'surprises' => $nSurprise,
'missed' => $nMissed,
'accuracy' => $nPredicted > 0 ? round($nConfirmed / $nPredicted * 100) : ($nActual === 0 ? 100 : 0),
];
}
return view('administrator/trophy_final', [
'classResults' => $classResults,
'selectedYear' => $selectedYear,
'selectedPercentile' => $percentile,
'years' => $years,
]);
}
private function calculateThreshold(array $scores, float $percentile = 75.0): array
{
$scores = array_values(array_filter(
$scores,
static fn ($v) => is_numeric($v) && $v !== null
));
$scores = array_map('floatval', $scores);
sort($scores); // ascending
$n = count($scores);
if ($n === 0) {
return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
}
$minWinners = 3; // never fewer than 3, regardless of class size or score count
// Hard maximum: top (100 - percentile)% of class, but never below the minimum.
$maxWinners = max($minWinners, (int) floor($n * (1 - $percentile / 100)));
$threshold = $this->empiricalPercentile($scores, $percentile);
$winners = $this->countAtOrAbove($scores, $threshold);
if ($winners < $minWinners) {
// Too few qualify — REDUCE the threshold down to the score of the
// 3rd-ranked student so the minimum of 3 is always met.
// If fewer than 3 students have scores, award all of them.
$target = min($minWinners, $n);
$desc = array_reverse($scores); // descending
$threshold = $desc[$target - 1]; // score at rank $target
$winners = $this->countAtOrAbove($scores, $threshold); // ties included
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
}
if ($winners <= $maxWinners) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
}
// Too many winners — raise threshold to respect the cap.
$result = $this->capByRank($scores, $maxWinners);
// capByRank may overshoot and drop below the minimum (e.g. bimodal scores
// where only 2 students are in the top cluster). Enforce min=3 here too.
if ($result['winners'] < $minWinners) {
$target = min($minWinners, $n);
$desc = array_reverse($scores);
$threshold = $desc[$target - 1];
$winners = $this->countAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
}
return $result;
}
/**
* Raise the threshold until at most $max students qualify.
* Returns the result without checking the minimum — caller must do that.
*/
private function capByRank(array $sortedScores, int $max): array
{
$desc = array_reverse($sortedScores);
$threshold = $desc[$max - 1];
$winners = $this->countAtOrAbove($sortedScores, $threshold);
if ($winners <= $max) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
}
// Ties push it over — walk up through distinct scores.
$unique = array_values(array_unique(array_filter(
$sortedScores,
static fn ($s) => $s > $threshold
)));
sort($unique);
foreach ($unique as $candidate) {
$w = $this->countAtOrAbove($sortedScores, $candidate);
if ($w <= $max) {
return ['threshold' => $candidate, 'winners' => $w, 'method' => 'capped_25pct'];
}
}
// All scores are equal.
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
}
private function empiricalPercentile(array $sortedScores, float $p): float
{
$n = count($sortedScores);
if ($n === 0) return 0.0;
$index = ($p / 100.0) * ($n - 1);
$lower = (int) floor($index);
$upper = (int) ceil($index);
if ($lower === $upper) return $sortedScores[$lower];
return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]);
}
private function countAtOrAbove(array $scores, float $threshold): int
{
return count(array_filter($scores, static fn ($s) => $s >= $threshold));
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateCertificateRecords extends Migration
{
public function up()
{
if ($this->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);
}
}
+8 -1
View File
@@ -11,6 +11,8 @@ class ApiAuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
helper('security');
$authorization = $request->getHeaderLine('Authorization');
if (!$authorization || stripos($authorization, 'Bearer ') !== 0) {
return $this->unauthorized('Missing or invalid Authorization header');
@@ -21,7 +23,12 @@ class ApiAuthFilter implements FilterInterface
return $this->unauthorized('Bearer token is required');
}
$secret = env('JWT_SECRET', 'change-me-in-env');
try {
$secret = require_env('JWT_SECRET');
} catch (\RuntimeException $e) {
return $this->unauthorized('JWT configuration is missing');
}
$payload = jwt_decode($token, $secret);
if (!$payload || empty($payload['sub'])) {
+3 -1
View File
@@ -13,13 +13,15 @@ class ApiDocsAuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
helper('security');
$authHeader = $request->getHeaderLine('Authorization');
if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
$token = trim(substr($authHeader, 7));
try {
$key = getenv('JWT_SECRET') ?: 'your_default_secret';
$key = require_env('JWT_SECRET');
$decoded = JWT::decode($token, new Key($key, 'HS256'));
if (!isset($decoded->roles) || empty($decoded->roles->admin)) {
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
class ApiRateLimitFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$throttler = service('throttler');
$response = Services::response();
$maxRequests = isset($arguments[0]) ? max(1, (int) $arguments[0]) : 60;
$windowSeconds = isset($arguments[1]) ? max(1, (int) $arguments[1]) : MINUTE;
$userId = session()->get('user_id');
$identifier = $userId ? 'user:' . $userId : 'ip:' . $request->getIPAddress();
$route = trim($request->getUri()->getPath(), '/');
$key = 'api-rate-' . sha1($request->getMethod() . '|' . $route . '|' . $identifier);
if (! $throttler->check($key, $maxRequests, $windowSeconds)) {
return $response
->setStatusCode(429)
->setHeader('Retry-After', (string) $windowSeconds)
->setJSON([
'status' => false,
'message' => 'Too many requests. Please try again later.',
]);
}
return null;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
return $response;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
class SanitizeInputFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
helper('security');
$sanitizedGet = sanitize_request_value($request->getGet() ?? []);
$sanitizedPost = sanitize_request_value($request->getPost() ?? []);
$request->setGlobal('get', $sanitizedGet);
$request->setGlobal('post', $sanitizedPost);
$request->setGlobal('request', array_merge(is_array($sanitizedGet) ? $sanitizedGet : [], is_array($sanitizedPost) ? $sanitizedPost : []));
$request->setGlobal('cookie', sanitize_request_value($request->getCookie() ?? []));
$contentType = strtolower($request->getHeaderLine('Content-Type'));
$body = $request->getBody();
if (str_contains($contentType, 'application/json') && trim($body) !== '') {
$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($decoded)) {
return Services::response()
->setStatusCode(400)
->setJSON([
'status' => false,
'message' => 'Malformed JSON payload.',
]);
}
$request->setBody((string) json_encode(sanitize_request_value($decoded), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
return null;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
return $response;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
if (! function_exists('sanitize_request_value')) {
/**
* Recursively normalize request values without applying output encoding.
*/
function sanitize_request_value($value)
{
if (is_array($value)) {
foreach ($value as $key => $item) {
$value[$key] = sanitize_request_value($item);
}
return $value;
}
if (! is_string($value)) {
return $value;
}
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? $value;
return trim($value);
}
}
if (! function_exists('require_env')) {
/**
* Fetch an environment variable and fail closed when it is missing.
*/
function require_env(string $key): string
{
$value = env($key);
if ($value === null || $value === '') {
throw new RuntimeException(sprintf('Missing required environment variable: %s', $key));
}
return (string) $value;
}
}
+2 -1
View File
@@ -16,8 +16,9 @@ class StaffTimeOffLinkService
public function __construct(?string $secret = null, ?int $ttlSeconds = null)
{
helper('jwt');
helper('security');
$this->secret = $secret ?: (string)env('JWT_SECRET', 'change-me-in-env');
$this->secret = $secret ?: require_env('JWT_SECRET');
$this->ttl = ($ttlSeconds !== null && $ttlSeconds > 0) ? $ttlSeconds : self::DEFAULT_TTL;
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class CertificateRecordModel extends Model
{
protected $table = 'certificate_records';
protected $primaryKey = 'id';
protected $useTimestamps = true;
protected $allowedFields = [
'certificate_number',
'student_id',
'student_name',
'grade',
'cert_date',
'school_year',
'class_section_id',
'issued_by',
'issued_at',
];
/**
* Generates the next certificate number for a given school year.
* Format: ARSS-{school_year}-{4-digit sequence} e.g. ARSS-2025-2026-0003
* Uses a transaction + row count to guarantee uniqueness within a request.
* Academic Records and Student Services (ARSS)
*/
public function nextNumber(string $schoolYear): string
{
$db = \Config\Database::connect();
$db->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();
}
}
@@ -0,0 +1,97 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid py-4">
<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-journal-check me-2"></i>Certificate Audit Log</h2>
<div class="text-muted small">Every issued certificate is recorded here for tracking and auditing.</div>
</div>
<a href="<?= site_url('administrator/certificates') ?>" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-award me-1"></i>Generate Certificates
</a>
</div>
<!-- Year summary cards -->
<?php if (!empty($yearSummary)): ?>
<div class="row g-3 mb-4">
<?php foreach ($yearSummary as $yr): ?>
<div class="col-auto">
<div class="card shadow-sm text-center px-4 py-2" style="min-width:150px;">
<div class="fw-bold fs-4"><?= (int) $yr['total'] ?></div>
<div class="text-muted small"><?= esc($yr['school_year']) ?></div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Year filter -->
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<form method="get" action="<?= site_url('administrator/certificates/log') ?>" class="row g-2 align-items-center">
<div class="col-auto">
<label class="col-form-label fw-semibold">School Year</label>
</div>
<div class="col-auto">
<select name="school_year" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">— All years —</option>
<?php foreach ($yearSummary as $yr): ?>
<option value="<?= esc($yr['school_year']) ?>"
<?= ($yr['school_year'] === $schoolYear) ? 'selected' : '' ?>>
<?= esc($yr['school_year']) ?> (<?= (int) $yr['total'] ?>)
</option>
<?php endforeach; ?>
</select>
</div>
</form>
</div>
</div>
<!-- Records table -->
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Issued Certificates
<span class="badge bg-secondary ms-1"><?= count($records) ?></span>
</span>
</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">
<thead class="table-light">
<tr>
<th>Certificate #</th>
<th>Student</th>
<th>Grade</th>
<th>Cert Date</th>
<th>School Year</th>
<th>Issued By</th>
<th>Issued At</th>
</tr>
</thead>
<tbody>
<?php if (empty($records)): ?>
<tr><td colspan="7" class="text-center text-muted py-4">No certificates issued yet.</td></tr>
<?php else: ?>
<?php foreach ($records as $r): ?>
<tr>
<td><code><?= esc($r['certificate_number']) ?></code></td>
<td><?= esc($r['student_name']) ?></td>
<td><?= esc($r['grade'] ?? '—') ?></td>
<td><?= $r['cert_date'] ? esc(date('m/d/Y', strtotime($r['cert_date']))) : '—' ?></td>
<td><?= esc($r['school_year'] ?? '—') ?></td>
<td><?= esc(trim(($r['admin_firstname'] ?? '') . ' ' . ($r['admin_lastname'] ?? '')) ?: '—') ?></td>
<td><?= esc($r['issued_at'] ? date('m/d/Y g:i A', strtotime($r['issued_at'])) : '—') ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
+168
View File
@@ -0,0 +1,168 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid py-4">
<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>
</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; ?>
<!-- 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>
</div>
</form>
</div>
</div>
<?php if (!empty($students)): ?>
<!-- Certificate generation form -->
<form method="post" action="<?= site_url('administrator/certificates/generate') ?>" id="certForm" target="_blank">
<?= 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>
</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>
</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>
<i class="bi bi-printer me-1"></i>Generate &amp; Print Certificates
</button>
</div>
</div>
</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; ?>
</div>
<script>
(function () {
// 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');
function updateState() {
const chosen = document.querySelectorAll('.student-check:checked').length;
selectedCount.textContent = chosen + ' student' + (chosen !== 1 ? 's' : '') + ' selected';
generateBtn.disabled = chosen === 0;
if (selectAll) {
selectAll.checked = chosen === checks.length && checks.length > 0;
selectAll.indeterminate = chosen > 0 && chosen < checks.length;
}
}
if (selectAll) {
selectAll.addEventListener('change', function () {
checks.forEach(c => { c.checked = this.checked; });
updateState();
});
}
checks.forEach(c => c.addEventListener('change', updateState));
updateState();
})();
</script>
<?= $this->endSection() ?>
+113 -119
View File
@@ -145,9 +145,24 @@
$<?= esc(number_format($selectedEvent['amount'] ?? 0, 2)) ?> fee
</span>
</div>
<p class="mb-1 text-muted small">
<?= esc($selectedEvent['description'] ?: 'No description provided for this event.') ?>
</p>
<?php $eventDescription = trim((string) ($selectedEvent['description'] ?? '')); ?>
<div class="mb-1">
<button type="button"
class="btn btn-link btn-sm px-0 py-0 text-decoration-none event-description-toggle"
data-bs-toggle="collapse"
data-bs-target="#selectedEventDescription"
aria-expanded="false"
aria-controls="selectedEventDescription"
data-label-collapsed="Show description"
data-label-expanded="Hide description">
Show description
</button>
<div class="collapse mt-2" id="selectedEventDescription">
<div class="text-muted small" style="white-space: pre-line;">
<?= esc($eventDescription !== '' ? $eventDescription : 'No description provided for this event.') ?>
</div>
</div>
</div>
<?php if (!empty($selectedEvent['expiration_date'])): ?>
<small class="text-secondary">
Expires: <?= esc(local_date($selectedEvent['expiration_date'], 'm-d-Y')) ?>
@@ -209,23 +224,51 @@
<?php if (empty($grouped)): ?>
<div class="alert alert-info">No charges found.</div>
<?php else: ?>
<?php
$printBaseParams = array_filter([
'school_year' => $school_year,
'semester' => $semester,
'parent_id' => $filterParentId > 0 ? $filterParentId : null,
'event_id' => $filterEventId > 0 ? $filterEventId : null,
], static fn ($value) => $value !== null && $value !== '');
$printAllUrl = site_url('administrator/event-charges/pdf');
if (!empty($printBaseParams)) {
$printAllUrl .= '?' . http_build_query($printBaseParams);
}
?>
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3 no-print">
<p class="text-muted mb-0">Click a column header to sort the participants list.</p>
<button type="button" class="btn btn-outline-secondary" id="printAllEventCharges">
<a href="<?= esc($printAllUrl) ?>"
class="btn btn-outline-secondary pdf-print-link"
data-base-href="<?= esc($printAllUrl) ?>"
data-print-scope="all">
Print All Participant Lists
</button>
</a>
</div>
<div id="eventChargeCards">
<?php foreach ($grouped as $eventId => $data): ?>
<?php $eventLabel = $data['label']; ?>
<?php $rows = $data['rows']; ?>
<?php
$singlePrintParams = array_filter([
'school_year' => $school_year,
'semester' => $semester,
'parent_id' => $filterParentId > 0 ? $filterParentId : null,
'event_id' => $eventId,
], static fn ($value) => $value !== null && $value !== '');
$singlePrintUrl = site_url('administrator/event-charges/pdf') . '?' . http_build_query($singlePrintParams);
?>
<div class="card mb-4 event-card" data-event-id="<?= esc($eventId) ?>">
<div class="card-header bg-light fw-semibold event-card-header">
<span><?= esc($eventLabel) ?></span>
<div class="event-card-header-actions no-print">
<button type="button" class="btn btn-outline-secondary btn-sm print-event-card" data-event-id="<?= esc($eventId) ?>">
<a href="<?= esc($singlePrintUrl) ?>"
class="btn btn-outline-secondary btn-sm pdf-print-link"
data-base-href="<?= esc($singlePrintUrl) ?>"
data-print-scope="single"
data-event-id="<?= esc($eventId) ?>">
Print This List
</button>
</a>
</div>
</div>
<div class="card-body p-0">
@@ -272,7 +315,7 @@
. (!empty($returnParams) ? '?' . http_build_query($returnParams) : '')
. '#eventTable_' . (int) ($charge['event_id'] ?? 0);
?>
<tr>
<tr data-charge-id="<?= esc((string) ($charge['id'] ?? '')) ?>">
<td data-sort-value="<?= esc((string) ($charge['id'] ?? '')) ?>"><?= esc($charge['id']) ?></td>
<?php
$studentName = $charge['student_firstname']
@@ -290,11 +333,15 @@
);
$externalParentPhone = $charge['external_parent_phone'] ?? '';
$externalParentEmail = $charge['external_parent_email'] ?? '';
$parentPhone = $charge['parent_cellphone'] ?? '';
$standardParentName = trim($charge['parent_firstname'] . ' ' . $charge['parent_lastname']);
$parentColumnName = $externalParentLabel ?: ($standardParentName ?: '—');
$isExternalParticipant = $externalName !== '';
$infoPhone = $isExternalParticipant ? $externalParentPhone : $parentPhone;
$infoEmail = $isExternalParticipant ? $externalParentEmail : '';
$externalInfoValue = trim(implode(' ', array_filter([
$externalParentPhone,
$externalParentEmail,
$infoPhone,
$infoEmail,
$externalNote,
])));
?>
@@ -306,13 +353,13 @@
<?php endif; ?>
</td>
<td data-sort-value="<?= esc(strtolower($externalInfoValue)) ?>">
<?php if ($externalParentPhone): ?>
<div class="text-muted small"><?= esc($externalParentPhone) ?></div>
<?php if ($infoPhone): ?>
<div class="text-muted small"><?= esc($infoPhone) ?></div>
<?php endif; ?>
<?php if ($externalParentEmail): ?>
<div class="text-muted small"><?= esc($externalParentEmail) ?></div>
<?php if ($infoEmail): ?>
<div class="text-muted small"><?= esc($infoEmail) ?></div>
<?php endif; ?>
<?php if (!$externalParentPhone && !$externalParentEmail): ?>
<?php if (!$infoPhone && !$infoEmail): ?>
<span class="text-muted small">—</span>
<?php endif; ?>
</td>
@@ -461,6 +508,24 @@
<?= $this->section('scripts') ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.event-description-toggle').forEach(function (button) {
const targetSelector = button.getAttribute('data-bs-target');
const target = targetSelector ? document.querySelector(targetSelector) : null;
if (!target) {
return;
}
target.addEventListener('show.bs.collapse', function () {
button.textContent = button.dataset.labelExpanded || 'Hide description';
});
target.addEventListener('hide.bs.collapse', function () {
button.textContent = button.dataset.labelCollapsed || 'Show description';
});
});
});
function loadStudentsWithCharges() {
let parentId = $('#parent_id').val();
let eventId = $('#event_id').val();
@@ -556,15 +621,6 @@ function loadStudentsWithCharges() {
form.appendChild(input);
}
function escapeHtml(value) {
const s = String(value ?? '');
return s.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function submitExternalParticipant(payload) {
const form = document.createElement('form');
form.method = 'post';
@@ -653,89 +709,41 @@ function loadStudentsWithCharges() {
);
}
function buildPrintableCard(card) {
const clone = card.cloneNode(true);
clone.querySelectorAll('.no-print').forEach((node) => node.remove());
clone.querySelectorAll('.sortable-header').forEach((button) => {
const headerText = button.textContent || '';
const textNode = document.createTextNode(headerText);
button.parentNode.replaceChild(textNode, button);
});
clone.querySelectorAll('form').forEach((form) => {
const cell = form.closest('td');
if (!cell) {
form.remove();
return;
}
const label = form.querySelector('.form-check-label');
const checkbox = form.querySelector('input[type="checkbox"]');
const text = label
? label.textContent.trim()
: (checkbox && checkbox.checked ? 'Paid' : 'Unpaid');
cell.textContent = text || '—';
});
clone.querySelectorAll('tr').forEach((row) => {
if (row.children.length > 10) {
row.removeChild(row.lastElementChild);
}
});
return clone.outerHTML;
function getTableRowOrder(table) {
if (!table || !table.tBodies.length) {
return [];
}
function openPrintWindow(title, contentHtml) {
const printWindow = window.open('', '_blank', 'noopener,noreferrer,width=1200,height=900');
if (!printWindow) {
alert('Unable to open the print preview window. Please allow pop-ups for this site.');
return;
}
const style = `
<style>
body { font-family: Arial, sans-serif; margin: 24px; color: #212529; }
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
p { margin-top: 0; color: #6c757d; }
.card { border: 1px solid #dee2e6; border-radius: 0.5rem; margin-bottom: 1.5rem; overflow: hidden; }
.card-header { background: #f8f9fa; padding: 0.9rem 1rem; font-weight: 700; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { border: 1px solid #dee2e6; padding: 0.55rem 0.7rem; text-align: left; vertical-align: top; }
.table thead th, .table tfoot th { background: #f8f9fa; }
.badge { display: inline-block; padding: 0.2rem 0.45rem; border-radius: 999px; font-size: 0.8rem; font-weight: 700; }
.bg-success { background: #198754; color: #fff; }
.bg-danger { background: #dc3545; color: #fff; }
.bg-secondary { background: #6c757d; color: #fff; }
.text-muted { color: #6c757d; }
.small { font-size: 0.875rem; }
@media print {
body { margin: 0; }
}
</style>
`;
printWindow.document.open();
printWindow.document.write(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${escapeHtml(title)}</title>
${style}
</head>
<body>
<h1>${escapeHtml(title)}</h1>
<p>Generated on ${escapeHtml(new Date().toLocaleString())}</p>
${contentHtml}
<script>
window.onload = function() {
window.print();
};
<\/script>
</body>
</html>
`);
printWindow.document.close();
return Array.from(table.tBodies[0].rows)
.map((row) => String(row.dataset.chargeId || '').trim())
.filter(Boolean);
}
function printEventCards(cards, title) {
const printable = cards.map((card) => buildPrintableCard(card)).join('');
openPrintWindow(title, printable);
function buildPdfUrl(link) {
const baseHref = String(link.dataset.baseHref || link.getAttribute('href') || '');
const url = new URL(baseHref, window.location.origin);
const scope = String(link.dataset.printScope || 'single');
if (scope === 'all') {
document.querySelectorAll('.event-card').forEach((card) => {
const eventId = String(card.dataset.eventId || '').trim();
const table = card.querySelector('.event-charges-table');
const rowOrder = getTableRowOrder(table);
if (eventId && rowOrder.length) {
url.searchParams.set(`row_order[${eventId}]`, rowOrder.join(','));
}
});
return url.toString();
}
const eventId = String(link.dataset.eventId || '').trim();
const table = eventId ? document.getElementById(`eventTable_${eventId}`) : null;
const rowOrder = getTableRowOrder(table);
if (eventId && rowOrder.length) {
url.searchParams.set(`row_order[${eventId}]`, rowOrder.join(','));
}
return url.toString();
}
$('.sortable-header').on('click', function() {
@@ -749,22 +757,8 @@ function loadStudentsWithCharges() {
sortEventTable(table, columnIndex, sortType, direction);
});
$('.print-event-card').on('click', function() {
const eventId = String($(this).data('eventId'));
const card = document.querySelector(`.event-card[data-event-id="${eventId}"]`);
if (!card) {
return;
}
const title = $(card).find('.card-header span').first().text().trim() || 'Event Participants';
printEventCards([card], `${title} Participant List`);
});
$('#printAllEventCharges').on('click', function() {
const cards = Array.from(document.querySelectorAll('.event-card'));
if (!cards.length) {
return;
}
printEventCards(cards, 'All Event Participant Lists');
$('.pdf-print-link').on('click', function() {
this.href = buildPdfUrl(this);
});
$('#externalParticipantSave').on('click', function() {
@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Event Participant Lists</title>
<style>
@page {
margin: 24px;
}
body {
font-family: DejaVu Sans, sans-serif;
font-size: 11px;
color: #1f2933;
}
h1 {
margin: 0 0 6px;
font-size: 20px;
}
.meta {
margin: 0 0 18px;
color: #52606d;
font-size: 10px;
}
.event-card {
margin-bottom: 22px;
page-break-inside: avoid;
}
.event-title {
margin: 0 0 8px;
padding: 8px 10px;
background: #f5f7fa;
border: 1px solid #d9e2ec;
font-size: 14px;
font-weight: 700;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
border: 1px solid #d9e2ec;
padding: 6px 7px;
vertical-align: top;
word-wrap: break-word;
}
th {
background: #f5f7fa;
text-align: left;
font-weight: 700;
}
tfoot th {
background: #f5f7fa;
}
.small {
color: #52606d;
font-size: 10px;
}
.status-paid {
color: #166534;
font-weight: 700;
}
.status-unpaid {
color: #b42318;
font-weight: 700;
}
.status-neutral {
color: #52606d;
font-weight: 700;
}
</style>
</head>
<body>
<?php
$title = !empty($filterEventId) ? 'Event Participant List' : 'All Event Participant Lists';
if (!empty($filterParentId)) {
$title .= ' for Selected Parent';
}
?>
<h1><?= esc($title) ?></h1>
<p class="meta">
Generated on <?= esc($generatedAt) ?>
| School Year: <?= esc($school_year ?: 'N/A') ?>
| Semester: <?= esc($semester ?: 'N/A') ?>
</p>
<?php if (empty($groupedCharges)): ?>
<p>No charges found for the selected filters.</p>
<?php else: ?>
<?php foreach ($groupedCharges as $eventId => $data): ?>
<?php
$rows = $data['rows'] ?? [];
$totalParticipants = 0;
$totalCharged = 0.0;
?>
<section class="event-card">
<h2 class="event-title"><?= esc($data['label'] ?? 'N/A') ?></h2>
<table>
<thead>
<tr>
<th style="width: 4%;">ID</th>
<th style="width: 15%;">Parent Name</th>
<th style="width: 14%;">Student Name</th>
<th style="width: 15%;">External Info</th>
<th style="width: 11%;">Class Section</th>
<th style="width: 8%;">Charged Amount</th>
<th style="width: 10%;">Created</th>
<th style="width: 7%;">Waiver</th>
<th style="width: 7%;">Fees Paid</th>
<th style="width: 9%;">Payment</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $charge): ?>
<?php
$isParticipating = ($charge['participation'] ?? '') === 'yes';
$isEventPaid = !empty($charge['event_paid']);
$feeAmount = (float) ($charge['event_amount'] ?? 0);
$hasBalanceRecord = array_key_exists((int) ($charge['parent_id'] ?? 0), $parentBalances);
$parentBalance = $hasBalanceRecord ? (float) $parentBalances[(int) $charge['parent_id']] : null;
$waiverSigned = !empty($charge['waiver_signed']);
$feeIsPaid = $isParticipating && ($isEventPaid || ($hasBalanceRecord && $parentBalance <= 0));
$studentName = !empty($charge['student_firstname'])
? trim(($charge['student_firstname'] ?? '') . ' ' . ($charge['student_lastname'] ?? ''))
: '';
$externalName = trim(($charge['external_firstname'] ?? '') . ' ' . ($charge['external_lastname'] ?? ''));
$displayName = $studentName ?: ($externalName ?: '—');
$externalNote = trim((string) ($charge['external_note'] ?? ''));
$externalParentLabel = trim(($charge['external_parent_firstname'] ?? '') . ' ' . ($charge['external_parent_lastname'] ?? ''));
$externalParentPhone = trim((string) ($charge['external_parent_phone'] ?? ''));
$externalParentEmail = trim((string) ($charge['external_parent_email'] ?? ''));
$parentPhone = trim((string) ($charge['parent_cellphone'] ?? ''));
$standardParentName = trim(($charge['parent_firstname'] ?? '') . ' ' . ($charge['parent_lastname'] ?? ''));
$parentColumnName = $externalParentLabel ?: ($standardParentName ?: '—');
$isExternalParticipant = $externalName !== '';
$infoPhone = $isExternalParticipant ? $externalParentPhone : $parentPhone;
$infoEmail = $isExternalParticipant ? $externalParentEmail : '';
$classSectionName = $classSectionNames[$charge['class_section_id'] ?? ''] ?? '—';
if ($isParticipating) {
$totalParticipants++;
$totalCharged += $feeAmount;
}
?>
<tr>
<td><?= esc($charge['id'] ?? '') ?></td>
<td><?= esc($parentColumnName) ?></td>
<td>
<?= esc($displayName) ?>
<?php if ($externalNote !== ''): ?>
<div class="small"><?= esc($externalNote) ?></div>
<?php endif; ?>
</td>
<td>
<?php if ($infoPhone !== ''): ?>
<div class="small"><?= esc($infoPhone) ?></div>
<?php endif; ?>
<?php if ($infoEmail !== ''): ?>
<div class="small"><?= esc($infoEmail) ?></div>
<?php endif; ?>
<?php if ($infoPhone === '' && $infoEmail === ''): ?>
<span class="small">—</span>
<?php endif; ?>
</td>
<td><?= esc($classSectionName) ?></td>
<td>$<?= esc(number_format($feeAmount, 2)) ?></td>
<td><?= esc(!empty($charge['created_at']) ? local_datetime($charge['created_at'], 'm-d-Y H:i') : '') ?></td>
<td class="<?= $waiverSigned ? 'status-paid' : 'status-neutral' ?>">
<?= $waiverSigned ? 'Signed' : 'Unsigned' ?>
</td>
<td class="<?= $feeIsPaid ? 'status-paid' : 'status-unpaid' ?>">
<?= $feeIsPaid ? 'Paid' : 'Unpaid' ?>
</td>
<td class="<?= $isEventPaid ? 'status-paid' : 'status-unpaid' ?>">
<?= $isEventPaid ? 'Paid' : 'Unpaid' ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<th colspan="5">Totals</th>
<th>$<?= esc(number_format($totalCharged, 2)) ?></th>
<th colspan="4"><?= esc($totalParticipants) ?> participating</th>
</tr>
</tfoot>
</table>
</section>
<?php endforeach; ?>
<?php endif; ?>
</body>
</html>
+6 -3
View File
@@ -13,6 +13,9 @@ $schoolYear = $schoolYear ?? '';
$semester = $semester ?? '';
$maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
$allowedExtensions = $allowedExtensions ?? ['doc', 'docx', 'pdf'];
$reviewActionUrl = $reviewActionUrl ?? base_url('administrator/exam-drafts/review');
$legacyUploadUrl = $legacyUploadUrl ?? base_url('administrator/exam-drafts/upload-legacy');
$reviewPortalLabel = $reviewPortalLabel ?? 'Administrator';
$renderBadge = static function (string $status, array $badges): string {
$b = $badges[$status] ?? ['label' => $status, 'class' => 'bg-secondary text-white'];
@@ -27,7 +30,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<div>
<h1 class="h3 mb-1">Exam draft submissions</h1>
<p class="text-muted mb-0 small">
<?= esc($semester ?: 'Semester') ?> <?= esc($schoolYear ?: '') ?>
<?= esc($reviewPortalLabel) ?> review portal · <?= esc($semester ?: 'Semester') ?> <?= esc($schoolYear ?: '') ?>
</p>
</div>
<div class="text-muted small d-flex flex-column align-items-lg-end">
@@ -205,7 +208,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
</div>
</td>
<td style="min-width: 280px;">
<?= form_open_multipart(base_url('administrator/exam-drafts/review'), ['class' => 'vstack gap-2', 'id' => $formId]) ?>
<?= form_open_multipart($reviewActionUrl, ['class' => 'vstack gap-2', 'id' => $formId]) ?>
<?= csrf_field() ?>
<input type="hidden" name="draft_id" value="<?= (int) ($draft['id'] ?? 0) ?>">
<?php $selectedStatus = ''; ?>
@@ -276,7 +279,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<span class="badge bg-primary-subtle text-primary">Admin only</span>
</div>
<div class="card-body">
<?= form_open_multipart(base_url('administrator/exam-drafts/upload-legacy'), ['class' => 'row g-3']) ?>
<?= form_open_multipart($legacyUploadUrl, ['class' => 'row g-3']) ?>
<?= csrf_field() ?>
<div class="col-md-4">
<label class="form-label small">Class sections</label>
+389
View File
@@ -0,0 +1,389 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
$classResults = $classResults ?? [];
$selectedYear = $selectedYear ?? '';
$selectedPercentile = $selectedPercentile ?? 75;
$years = $years ?? [];
$totalStudents = array_sum(array_column($classResults, 'student_count'));
$totalScored = array_sum(array_column($classResults, 'scored_count'));
$totalTrophies = array_sum(array_column($classResults, 'trophy_count'));
$totalBoys = array_sum(array_column($classResults, 'boys'));
$totalGirls = array_sum(array_column($classResults, 'girls'));
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
$pctBoys = $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0;
$pctGirls = $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0;
$pctTrophyBoys = $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0;
$pctTrophyGirls = $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0;
$pctTrophyAll = $totalStudents > 0 ? round($totalTrophies / $totalStudents * 100) : 0;
?>
<div class="container-fluid">
<!-- Header -->
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2">
<div>
<h2 class="mb-1"><i class="bi bi-trophy-fill text-warning me-2"></i>Trophy Projections</h2>
<p class="text-muted mb-0">
Fall scores are used to calculate a per-class threshold at the
<strong><?= (int) $selectedPercentile ?>th percentile</strong> and project which students are on track
for a year-end trophy. Minimum 3 trophies per class. Names are hidden by default.
</p>
</div>
<div class="d-flex gap-2">
<a href="<?= site_url('administrator/trophy/winners?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
class="btn btn-warning btn-sm">
<i class="bi bi-eye-fill me-1"></i>Reveal Winners
</a>
<a href="<?= site_url('administrator/trophy/final?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
class="btn btn-success btn-sm">
<i class="bi bi-bar-chart-steps me-1"></i>Final vs Predicted
</a>
</div>
</div>
<!-- Year filter -->
<form method="get" action="<?= site_url('administrator/trophy') ?>" class="row g-2 align-items-end mb-4">
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">School Year</label>
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
<?php foreach ($years as $yr): ?>
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>>
<?= esc($yr) ?>
</option>
<?php endforeach; ?>
<?php if (empty($years)): ?>
<option value="<?= esc($selectedYear) ?>" selected><?= esc($selectedYear) ?></option>
<?php endif; ?>
</select>
</div>
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">Percentile threshold</label>
<div class="input-group input-group-sm" style="width:130px;">
<input type="number" name="percentile" class="form-control"
min="1" max="99" step="1"
value="<?= (int) $selectedPercentile ?>"
title="Students scoring above this percentile receive a trophy (min 3 per class)">
<span class="input-group-text">%</span>
</div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
</div>
</form>
<?php if (empty($classResults)): ?>
<div class="alert alert-info">No class or fall-score data found for the selected school year.</div>
<?php else: ?>
<!-- ── Global stats ── -->
<div class="row g-3 mb-4">
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm border-warning h-100">
<div class="card-body py-3">
<div class="fs-2 text-warning"><i class="bi bi-trophy-fill"></i></div>
<div class="fs-3 fw-bold"><?= $totalTrophies ?></div>
<div class="text-muted small">Trophies</div>
<span class="badge bg-warning text-dark mt-1"><?= $pctTrophyAll ?>% of students</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2 text-primary"><i class="bi bi-people-fill"></i></div>
<div class="fs-3 fw-bold"><?= $totalStudents ?></div>
<div class="text-muted small">Students</div>
<span class="badge bg-secondary mt-1"><?= count($classResults) ?> classes</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2 text-success"><i class="bi bi-bar-chart-fill"></i></div>
<div class="fs-3 fw-bold"><?= $totalScored ?></div>
<div class="text-muted small">With Fall Scores</div>
<span class="badge bg-success mt-1"><?= $totalStudents > 0 ? round($totalScored/$totalStudents*100) : 0 ?>%</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2" style="color:#4A90E2"><i class="bi bi-gender-male"></i></div>
<div class="fs-3 fw-bold"><?= $totalBoys ?></div>
<div class="text-muted small">Boys</div>
<span class="badge text-white mt-1" style="background:#4A90E2"><?= $pctBoys ?>% of students</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2" style="color:#E47AB0"><i class="bi bi-gender-female"></i></div>
<div class="fs-3 fw-bold"><?= $totalGirls ?></div>
<div class="text-muted small">Girls</div>
<span class="badge text-white mt-1" style="background:#E47AB0"><?= $pctGirls ?>% of students</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2 text-warning"><i class="bi bi-award-fill"></i></div>
<div class="fs-3 fw-bold">
<span style="color:#4A90E2"><?= $totalTrophyBoys ?></span>
<span class="text-muted fs-5">/</span>
<span style="color:#E47AB0"><?= $totalTrophyGirls ?></span>
</div>
<div class="text-muted small">Trophy M / F</div>
<span class="small">
<span style="color:#4A90E2"><?= $pctTrophyBoys ?>%</span>
&nbsp;·&nbsp;
<span style="color:#E47AB0"><?= $pctTrophyGirls ?>%</span>
</span>
</div>
</div>
</div>
</div>
<!-- ── Per-class summary ── -->
<div class="card shadow-sm">
<div class="card-header bg-white fw-semibold py-2">Class Breakdown</div>
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0 no-mgmt-sticky" id="trophy-breakdown-table" data-no-mgmt-sticky>
<thead class="table-light">
<tr>
<th class="ps-3">Class</th>
<th class="text-center">Students</th>
<th class="text-center">Scored</th>
<th class="text-center">
<i class="bi bi-trophy-fill text-warning"></i> Trophies
</th>
<th class="text-center" style="color:#4A90E2">
<i class="bi bi-gender-male"></i> Boys
</th>
<th class="text-center" style="color:#E47AB0">
<i class="bi bi-gender-female"></i> Girls
</th>
<th class="text-center" style="color:#4A90E2">Trophy Boys</th>
<th class="text-center" style="color:#E47AB0">Trophy Girls</th>
<th class="text-center">Rate</th>
<th class="text-end">Threshold</th>
<th class="text-center">Custom Threshold</th>
</tr>
</thead>
<tbody>
<?php foreach ($classResults as $cls):
$scores = array_filter(
array_column($cls['students'], 'fall_score'),
fn($s) => $s !== null
);
$scoresJson = json_encode(array_values($scores));
$clsId = 'cls' . $cls['section_id'];
?>
<tr id="row-<?= $clsId ?>">
<td class="ps-3 fw-semibold"><?= esc($cls['section_name']) ?></td>
<td class="text-center"><?= $cls['student_count'] ?></td>
<td class="text-center">
<?php if ($cls['scored_count'] < 3): ?>
<span class="badge bg-danger" title="Fewer than 3 fall scores — minimum of 3 trophies cannot be enforced">
<?= $cls['scored_count'] ?> <i class="bi bi-exclamation-triangle-fill"></i>
</span>
<?php else: ?>
<?= $cls['scored_count'] ?>
<?php endif; ?>
</td>
<td class="text-center" id="trophy-cell-<?= $clsId ?>">
<span class="badge bg-warning text-dark">
<i class="bi bi-trophy-fill"></i>
<span id="trophy-count-<?= $clsId ?>"><?= $cls['trophy_count'] ?></span>
</span>
</td>
<td class="text-center">
<?= $cls['boys'] ?>
<span class="text-muted small">(<?= $cls['pct_boys'] ?>%)</span>
</td>
<td class="text-center">
<?= $cls['girls'] ?>
<span class="text-muted small">(<?= $cls['pct_girls'] ?>%)</span>
</td>
<td class="text-center" id="trophy-boys-<?= $clsId ?>"><?= $cls['trophy_boys'] ?><?php if ($cls['boys'] > 0): ?> <span class="text-muted small">(<?= $cls['pct_trophy_boys'] ?>%)</span><?php endif; ?></td>
<td class="text-center" id="trophy-girls-<?= $clsId ?>"><?= $cls['trophy_girls'] ?><?php if ($cls['girls'] > 0): ?> <span class="text-muted small">(<?= $cls['pct_trophy_girls'] ?>%)</span><?php endif; ?></td>
<td class="text-center">
<div class="progress" style="height:8px;min-width:60px;">
<div class="progress-bar bg-warning" id="rate-bar-<?= $clsId ?>" style="width:<?= $cls['pct_trophy_total'] ?>%;"></div>
</div>
<span class="small text-muted" id="rate-txt-<?= $clsId ?>"><?= $cls['pct_trophy_total'] ?>%</span>
</td>
<td class="text-end text-muted small" id="auto-threshold-<?= $clsId ?>">
<?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '—' ?>
</td>
<td>
<div class="input-group input-group-sm" style="min-width:150px;">
<input type="number" id="custom-threshold-<?= $clsId ?>"
class="form-control form-control-sm"
step="0.1" min="0"
placeholder="e.g. <?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '80.0' ?>"
data-scores='<?= $scoresJson ?>'
data-clsid="<?= $clsId ?>"
data-boys='<?= json_encode(array_values(array_filter(array_map(fn($s) => $s['gender'] === 'Male' ? $s['fall_score'] : null, $cls['students']), fn($s) => $s !== null))) ?>'
data-girls='<?= json_encode(array_values(array_filter(array_map(fn($s) => $s['gender'] === 'Female' ? $s['fall_score'] : null, $cls['students']), fn($s) => $s !== null))) ?>'
data-total="<?= $cls['student_count'] ?>"
data-nboys="<?= $cls['boys'] ?>"
data-ngirls="<?= $cls['girls'] ?>">
<button class="btn btn-outline-primary btn-sm" type="button"
onclick="applyCustomThreshold('<?= $clsId ?>')">
Apply
</button>
</div>
<div id="custom-note-<?= $clsId ?>" class="text-muted" style="font-size:.7rem;"></div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td class="ps-3">Total</td>
<td class="text-center"><?= $totalStudents ?></td>
<td class="text-center"><?= $totalScored ?></td>
<td class="text-center">
<span class="badge bg-warning text-dark">
<i class="bi bi-trophy-fill"></i> <?= $totalTrophies ?>
</span>
</td>
<td class="text-center">
<?= $totalBoys ?>
<span class="text-muted small fw-normal">(<?= $pctBoys ?>%)</span>
</td>
<td class="text-center">
<?= $totalGirls ?>
<span class="text-muted small fw-normal">(<?= $pctGirls ?>%)</span>
</td>
<td class="text-center">
<?= $totalTrophyBoys ?>
<span class="text-muted small fw-normal">(<?= $pctTrophyBoys ?>%)</span>
</td>
<td class="text-center">
<?= $totalTrophyGirls ?>
<span class="text-muted small fw-normal">(<?= $pctTrophyGirls ?>%)</span>
</td>
<td class="text-center">
<div class="progress" style="height:8px;min-width:60px;">
<div class="progress-bar bg-warning" style="width:<?= $pctTrophyAll ?>%;"></div>
</div>
<span class="small text-muted"><?= $pctTrophyAll ?>%</span>
</td>
<td></td>
<td class="pe-3"></td>
</tr>
</tfoot>
</table>
</div>
</div>
<?php endif; ?>
</div>
<script>
var TROPHY_NS = 'trophy_custom_<?= esc($selectedYear) ?>_<?= (int)$selectedPercentile ?>';
function countAtOrAbove(arr, t) {
return arr.filter(function(s) { return s >= t; }).length;
}
function applyThreshold(clsId, val) {
var input = document.getElementById('custom-threshold-' + clsId);
var note = document.getElementById('custom-note-' + clsId);
var scores = JSON.parse(input.dataset.scores);
var boys = JSON.parse(input.dataset.boys);
var girls = JSON.parse(input.dataset.girls);
var nTotal = parseInt(input.dataset.total);
var nBoys = parseInt(input.dataset.nboys);
var nGirls = parseInt(input.dataset.ngirls);
var MIN = 3;
var winners = countAtOrAbove(scores, val);
var usedThreshold = val;
var forced = false;
if (winners < MIN && scores.length >= MIN) {
var desc = scores.slice().sort(function(a, b) { return b - a; });
usedThreshold = desc[MIN - 1];
winners = countAtOrAbove(scores, usedThreshold);
forced = true;
} else if (winners < MIN) {
winners = scores.length;
usedThreshold = scores.length > 0 ? Math.min.apply(null, scores) : val;
forced = true;
}
var trophyBoys = countAtOrAbove(boys, usedThreshold);
var trophyGirls = countAtOrAbove(girls, usedThreshold);
var rate = nTotal > 0 ? Math.round(winners / nTotal * 100) : 0;
var pctTB = nBoys > 0 ? Math.round(trophyBoys / nBoys * 100) : 0;
var pctTG = nGirls > 0 ? Math.round(trophyGirls / nGirls * 100) : 0;
document.getElementById('trophy-count-' + clsId).textContent = winners;
document.getElementById('trophy-boys-' + clsId).textContent = trophyBoys + (nBoys > 0 ? ' (' + pctTB + '%)' : '');
document.getElementById('trophy-girls-' + clsId).textContent = trophyGirls + (nGirls > 0 ? ' (' + pctTG + '%)' : '');
document.getElementById('rate-bar-' + clsId).style.width = rate + '%';
document.getElementById('rate-txt-' + clsId).textContent = rate + '%';
document.getElementById('auto-threshold-' + clsId).textContent = usedThreshold.toFixed(1);
if (forced) {
note.style.color = '#dc3545';
note.textContent = 'Min 3 enforced → threshold lowered to ' + usedThreshold.toFixed(1);
} else {
note.style.color = '#6c757d';
note.textContent = 'Custom threshold: ' + usedThreshold.toFixed(1);
}
var badge = document.getElementById('trophy-cell-' + clsId).querySelector('.badge');
badge.classList.remove('bg-warning');
badge.classList.add('bg-primary');
}
function applyCustomThreshold(clsId) {
var input = document.getElementById('custom-threshold-' + clsId);
var val = parseFloat(input.value);
if (isNaN(val)) {
document.getElementById('custom-note-' + clsId).textContent = 'Enter a valid number.';
return;
}
applyThreshold(clsId, val);
// Persist so the value survives filter changes within the same percentile/year
try {
var saved = JSON.parse(localStorage.getItem(TROPHY_NS) || '{}');
saved[clsId] = val;
localStorage.setItem(TROPHY_NS, JSON.stringify(saved));
} catch (e) {}
}
// Restore saved custom thresholds on page load
document.addEventListener('DOMContentLoaded', function () {
try {
var saved = JSON.parse(localStorage.getItem(TROPHY_NS) || '{}');
Object.keys(saved).forEach(function (clsId) {
var input = document.getElementById('custom-threshold-' + clsId);
if (!input) return;
input.value = saved[clsId];
applyThreshold(clsId, saved[clsId]);
});
} catch (e) {}
});
// Clear all custom thresholds when the percentile/year form is submitted
document.querySelector('form[action*="trophy"]').addEventListener('submit', function () {
try { localStorage.removeItem(TROPHY_NS); } catch (e) {}
});
</script>
<?= $this->endSection() ?>
+598
View File
@@ -0,0 +1,598 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
$classResults = $classResults ?? [];
$selectedYear = $selectedYear ?? '';
$selectedPercentile = $selectedPercentile ?? 75;
$years = $years ?? [];
$totalStudents = array_sum(array_column($classResults, 'student_count'));
$totalPredicted = array_sum(array_column($classResults, 'predicted_count'));
$totalActual = array_sum(array_column($classResults, 'actual_count'));
$totalConfirmed = array_sum(array_column($classResults, 'confirmed'));
$totalSurprise = array_sum(array_column($classResults, 'surprises'));
$totalMissed = array_sum(array_column($classResults, 'missed'));
$overallAccuracy = $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : ($totalActual === 0 ? 100 : 0);
?>
<style>
.status-confirmed { color:#198754; font-weight:600; }
.status-surprise { color:#0d6efd; font-weight:600; }
.status-missed { color:#fd7e14; font-weight:600; }
.status-none { color:#adb5bd; }
.print-only { display: none !important; }
@media print {
.no-print { display: none !important; }
.print-only { display: block !important; }
.screen-only { display: none !important; }
body { font-size: 11px; }
.container-fluid { padding: 0 !important; }
h2, h3 { font-size: 13px; margin-bottom: .3rem; }
.print-page-break { break-before: page; }
table { width: 100%; border-collapse: collapse; font-size: 10px; }
table th, table td { border: 1px solid #bbb; padding: 3px 5px; }
table thead { background: #333 !important; color: #fff !important;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
table thead th { position: static !important; top: auto !important; box-shadow: none !important; }
.s-confirmed { color: #198754; font-weight: bold; }
.s-surprise { color: #0d6efd; font-weight: bold; }
.s-missed { color: #fd7e14; font-weight: bold; }
}
</style>
<div class="container-fluid">
<!-- Header -->
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2 no-print">
<div>
<h2 class="mb-1">
<i class="bi bi-trophy-fill text-warning me-1"></i>
Final vs Predicted — <?= esc($selectedYear) ?>
</h2>
<p class="text-muted mb-0">
Compares the <strong>Fall-score prediction</strong> (<?= (int)$selectedPercentile ?>th percentile)
with the <strong>year-end result</strong> based on the average of Fall &amp; Spring scores.
</p>
</div>
<div class="d-flex gap-2">
<button onclick="printWithCharts()" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-printer-fill me-1"></i>Print
</button>
<a href="<?= site_url('administrator/trophy?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
</div>
</div>
<!-- Filter -->
<form method="get" action="<?= site_url('administrator/trophy/final') ?>"
class="row g-2 align-items-end mb-4 no-print">
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">School Year</label>
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
<?php foreach ($years as $yr): ?>
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>><?= esc($yr) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">Percentile</label>
<div class="input-group input-group-sm" style="width:110px;">
<input type="number" name="percentile" class="form-control"
min="1" max="99" step="1" value="<?= (int)$selectedPercentile ?>">
<span class="input-group-text">%</span>
</div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
</div>
</form>
<?php if (empty($classResults)): ?>
<div class="alert alert-info no-print">No data found for the selected school year.</div>
<?php else: ?>
<!-- ══ SCREEN VIEW ═══════════════════════════════════════════════════════ -->
<div class="screen-only">
<!-- Legend -->
<div class="d-flex flex-wrap gap-3 mb-3 small">
<span><span class="badge bg-success me-1">✓ Confirmed</span> Predicted AND got a year-end trophy</span>
<span><span class="badge bg-primary me-1">↑ Surprise</span> NOT predicted, but earned a trophy</span>
<span><span class="badge bg-warning text-dark me-1">↓ Missed</span> Predicted, but fell short year-end</span>
<span><span class="badge bg-light text-muted border me-1">— None</span> No trophy either way</span>
</div>
<!-- Global stat tiles -->
<div class="row g-3 mb-4">
<?php foreach ([
[$totalPredicted, 'Predicted (Fall)', 'primary', null, $totalStudents > 0 ? round($totalPredicted / $totalStudents * 100) . '% of students' : '—'],
[$totalActual, 'Actual (Year)', 'warning', 'dark', $totalStudents > 0 ? round($totalActual / $totalStudents * 100) . '% of students' : '—'],
[$totalConfirmed, 'Confirmed', 'success', null, $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) . '% of predicted' : '—'],
[$totalSurprise, 'Surprises', 'info', 'dark', 'Not in prediction'],
[$totalMissed, 'Missed', 'orange', null, 'Were predicted'],
[$overallAccuracy,'Accuracy', 'dark', null, 'prediction rate'],
] as [$val, $label, $color, $textClass, $sub]):
$isOrange = $color === 'orange';
$bgClass = $isOrange ? '' : 'bg-' . $color;
$txClass = $textClass ? 'text-' . $textClass : 'text-white';
?>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100 <?= $isOrange ? 'border-warning' : '' ?>">
<div class="card-body py-3">
<div class="fs-3 fw-bold <?= $isOrange ? 'text-warning' : 'text-' . $color ?>">
<?= $val ?><?= $label === 'Accuracy' ? '%' : '' ?>
</div>
<div class="text-muted small"><?= $label ?></div>
<span class="badge mt-1 <?= $bgClass . ' ' . $txClass ?>"
<?= $isOrange ? 'style="background:#fd7e14"' : '' ?>>
<?= $sub ?>
</span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- Per-class breakdown -->
<?php foreach ($classResults as $cls): ?>
<div class="card shadow-sm mb-4">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2 py-2"
style="background:#f8f9fa;">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="fw-bold fs-6"><?= esc($cls['section_name']) ?></span>
<span class="badge bg-primary"><?= $cls['predicted_count'] ?> predicted</span>
<span class="badge bg-warning text-dark"><?= $cls['actual_count'] ?> actual</span>
<?php if ($cls['confirmed'] > 0): ?>
<span class="badge bg-success"><?= $cls['confirmed'] ?> confirmed</span>
<?php endif; ?>
<?php if ($cls['surprises'] > 0): ?>
<span class="badge bg-info text-dark"><?= $cls['surprises'] ?> surprise<?= $cls['surprises'] > 1 ? 's' : '' ?></span>
<?php endif; ?>
<?php if ($cls['missed'] > 0): ?>
<span class="badge" style="background:#fd7e14"><?= $cls['missed'] ?> missed</span>
<?php endif; ?>
</div>
<div class="d-flex gap-3 small align-items-center">
<span class="text-muted">Fall &#8805; <strong><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></strong></span>
<span class="text-muted">Year &#8805; <strong><?= $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?></strong></span>
<span class="fw-semibold">
Accuracy:
<span class="<?= $cls['accuracy'] >= 80 ? 'text-success' : ($cls['accuracy'] >= 50 ? 'text-warning' : 'text-danger') ?>">
<?= $cls['accuracy'] ?>%
</span>
</span>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0 align-middle" data-no-mgmt-sticky>
<thead class="table-light">
<tr>
<th class="ps-3" style="width:2rem;">#</th>
<th>Name</th>
<th class="text-center" style="width:3rem;">Gender</th>
<th class="text-end">Fall</th>
<th class="text-end">Spring</th>
<th class="text-end">Year Avg</th>
<th class="text-center">Predicted</th>
<th class="text-center">Actual</th>
<th class="text-center">Status</th>
</tr>
</thead>
<tbody>
<?php $rank = 0; foreach ($cls['students'] as $s):
if ($s['status'] === 'none') continue;
$rank++;
$isMale = strtolower($s['gender'] ?? '') === 'male';
$rowBg = match ($s['status']) {
'confirmed' => 'table-success',
'surprise' => 'table-primary',
'missed' => 'table-warning',
default => '',
};
?>
<tr class="<?= $rowBg ?>">
<td class="ps-3 text-muted small"><?= $rank ?></td>
<td class="fw-semibold small"><?= esc($s['name']) ?></td>
<td class="text-center">
<span class="badge" style="background:<?= $isMale ? '#4A90E2' : '#E47AB0' ?>;font-size:.65rem;">
<?= $isMale ? 'M' : 'F' ?>
</span>
</td>
<td class="text-end small"><?= $s['fall_score'] !== null ? number_format($s['fall_score'], 1) : '<span class="text-muted">—</span>' ?></td>
<td class="text-end small"><?= $s['spring_score'] !== null ? number_format($s['spring_score'], 1) : '<span class="text-muted">—</span>' ?></td>
<td class="text-end small fw-semibold"><?= $s['year_score'] !== null ? number_format($s['year_score'], 1) : '<span class="text-muted">—</span>' ?></td>
<td class="text-center small">
<?= $s['predicted']
? '<span class="badge bg-primary">Yes</span>'
: '<span class="badge bg-light text-muted border">No</span>' ?>
</td>
<td class="text-center small">
<?= $s['actual']
? '<span class="badge bg-warning text-dark">Yes</span>'
: '<span class="badge bg-light text-muted border">No</span>' ?>
</td>
<td class="text-center small">
<?php match ($s['status']) {
'confirmed' => print '<span class="badge bg-success">✓ Confirmed</span>',
'surprise' => print '<span class="badge bg-info text-dark">↑ Surprise</span>',
'missed' => print '<span class="badge" style="background:#fd7e14;color:#fff;">↓ Missed</span>',
default => print '<span class="text-muted small">—</span>',
}; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
<!-- Overall accuracy summary table -->
<div class="card shadow-sm mt-2 mb-4">
<div class="card-header bg-dark text-white fw-semibold py-2">
<i class="bi bi-bar-chart-fill me-2"></i>Prediction Accuracy Summary — <?= esc($selectedYear) ?>
</div>
<div class="card-body p-0">
<table class="table table-sm table-bordered mb-0 align-middle" data-no-mgmt-sticky>
<thead class="table-dark">
<tr>
<th class="ps-2">Class</th>
<th class="text-center">Students</th>
<th class="text-center text-primary-emphasis">Predicted</th>
<th class="text-center text-warning-emphasis">Actual</th>
<th class="text-center text-success-emphasis">✓ Confirmed</th>
<th class="text-center" style="color:#6ea8fe">↑ Surprises</th>
<th class="text-center" style="color:#ffda6a">↓ Missed</th>
<th class="text-center">Accuracy</th>
<th class="text-end pe-2">Fall ≥</th>
<th class="text-end pe-2">Year ≥</th>
</tr>
</thead>
<tbody>
<?php foreach ($classResults as $cls): ?>
<tr>
<td class="ps-2 fw-semibold small"><?= esc($cls['section_name']) ?></td>
<td class="text-center small"><?= $cls['student_count'] ?></td>
<td class="text-center small"><span class="badge bg-primary"><?= $cls['predicted_count'] ?></span></td>
<td class="text-center small"><span class="badge bg-warning text-dark"><?= $cls['actual_count'] ?></span></td>
<td class="text-center small"><span class="badge bg-success"><?= $cls['confirmed'] ?></span></td>
<td class="text-center small"><span class="badge bg-info text-dark"><?= $cls['surprises'] ?></span></td>
<td class="text-center small"><span class="badge" style="background:#fd7e14"><?= $cls['missed'] ?></span></td>
<td class="text-center small fw-semibold <?= $cls['accuracy'] >= 80 ? 'text-success' : ($cls['accuracy'] >= 50 ? 'text-warning' : 'text-danger') ?>">
<?= $cls['accuracy'] ?>%
</td>
<td class="text-end pe-2 small text-muted"><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></td>
<td class="text-end pe-2 small text-muted"><?= $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot class="table-secondary fw-semibold">
<tr>
<td class="ps-2">Total</td>
<td class="text-center"><?= $totalStudents ?></td>
<td class="text-center"><span class="badge bg-primary"><?= $totalPredicted ?></span></td>
<td class="text-center"><span class="badge bg-warning text-dark"><?= $totalActual ?></span></td>
<td class="text-center"><span class="badge bg-success"><?= $totalConfirmed ?></span></td>
<td class="text-center"><span class="badge bg-info text-dark"><?= $totalSurprise ?></span></td>
<td class="text-center"><span class="badge" style="background:#fd7e14"><?= $totalMissed ?></span></td>
<td class="text-center fw-semibold <?= $overallAccuracy >= 80 ? 'text-success' : ($overallAccuracy >= 50 ? 'text-warning' : 'text-danger') ?>">
<?= $overallAccuracy ?>%
</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Charts -->
<div class="row g-4 mt-1 mb-4">
<!-- Grouped bar: predicted / actual / confirmed / surprises / missed per class -->
<div class="col-12 col-lg-8">
<div class="border rounded p-3 h-100">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-bar-chart-fill me-1"></i>Trophy Counts per Class
</div>
<canvas id="chart-counts" style="max-height:280px;"></canvas>
</div>
</div>
<!-- Bar: accuracy % per class + doughnut overall breakdown -->
<div class="col-12 col-lg-4">
<div class="row g-3 h-100">
<div class="col-12">
<div class="border rounded p-3">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-bullseye me-1"></i>Prediction Accuracy per Class
</div>
<canvas id="chart-accuracy" style="max-height:130px;"></canvas>
</div>
</div>
<div class="col-12">
<div class="border rounded p-3">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-pie-chart-fill me-1"></i>Overall Outcome Breakdown
</div>
<canvas id="chart-breakdown" style="max-height:130px;"></canvas>
</div>
</div>
</div>
</div>
</div>
</div><!-- /screen-only -->
<!-- ══ PRINT VIEW ════════════════════════════════════════════════════════ -->
<div class="print-only">
<div style="text-align:center;margin-bottom:10px;border-bottom:2px solid #333;padding-bottom:6px;">
<h2 style="margin:0;">Final vs Predicted — <?= esc($selectedYear) ?></h2>
<p style="margin:3px 0 0;font-size:10px;color:#555;">
<?= (int)$selectedPercentile ?>th percentile &mdash;
Predicted: <?= $totalPredicted ?> &mdash;
Actual: <?= $totalActual ?> &mdash;
Confirmed: <?= $totalConfirmed ?> &mdash;
Accuracy: <?= $overallAccuracy ?>%
</p>
</div>
<!-- All students flat table -->
<h3 style="margin-bottom:4px;">Student Detail</h3>
<table data-no-mgmt-sticky>
<thead>
<tr>
<th>#</th>
<th>Class</th>
<th>Name</th>
<th style="text-align:center;">G</th>
<th style="text-align:right;">Fall</th>
<th style="text-align:right;">Spring</th>
<th style="text-align:right;">Year Avg</th>
<th style="text-align:center;">Predicted</th>
<th style="text-align:center;">Actual</th>
<th style="text-align:center;">Status</th>
</tr>
</thead>
<tbody>
<?php
$rank = 0;
foreach ($classResults as $cls):
foreach ($cls['students'] as $s):
if ($s['status'] === 'none') continue;
$rank++;
$isMale = strtolower($s['gender'] ?? '') === 'male';
$statusLabel = match ($s['status']) {
'confirmed' => '✓ Confirmed',
'surprise' => '↑ Surprise',
'missed' => '↓ Missed',
default => '—',
};
$statusClass = 's-' . $s['status'];
?>
<tr>
<td style="text-align:center;"><?= $rank ?></td>
<td><?= esc($cls['section_name']) ?></td>
<td><strong><?= esc($s['name']) ?></strong></td>
<td style="text-align:center;"><?= $isMale ? 'M' : 'F' ?></td>
<td style="text-align:right;"><?= $s['fall_score'] !== null ? number_format($s['fall_score'], 1) : '—' ?></td>
<td style="text-align:right;"><?= $s['spring_score'] !== null ? number_format($s['spring_score'], 1) : '—' ?></td>
<td style="text-align:right;font-weight:bold;"><?= $s['year_score'] !== null ? number_format($s['year_score'], 1) : '—' ?></td>
<td style="text-align:center;"><?= $s['predicted'] ? 'Yes' : 'No' ?></td>
<td style="text-align:center;"><?= $s['actual'] ? 'Yes' : 'No' ?></td>
<td style="text-align:center;" class="<?= $statusClass ?>"><?= $statusLabel ?></td>
</tr>
<?php endforeach; endforeach; ?>
</tbody>
</table>
<!-- Accuracy summary (new page) -->
<div class="print-page-break"></div>
<h3 style="margin-bottom:4px;">Prediction Accuracy Summary</h3>
<table data-no-mgmt-sticky style="margin-bottom:14px;">
<thead>
<tr>
<th>Class</th>
<th style="text-align:center;">Students</th>
<th style="text-align:center;">Predicted</th>
<th style="text-align:center;">Actual</th>
<th style="text-align:center;">✓ Confirmed</th>
<th style="text-align:center;">↑ Surprises</th>
<th style="text-align:center;">↓ Missed</th>
<th style="text-align:center;">Accuracy</th>
<th style="text-align:right;">Fall ≥</th>
<th style="text-align:right;">Year ≥</th>
</tr>
</thead>
<tbody>
<?php foreach ($classResults as $cls): ?>
<tr>
<td><?= esc($cls['section_name']) ?></td>
<td style="text-align:center;"><?= $cls['student_count'] ?></td>
<td style="text-align:center;"><?= $cls['predicted_count'] ?></td>
<td style="text-align:center;"><?= $cls['actual_count'] ?></td>
<td style="text-align:center;" class="s-confirmed"><?= $cls['confirmed'] ?></td>
<td style="text-align:center;" class="s-surprise"><?= $cls['surprises'] ?></td>
<td style="text-align:center;" class="s-missed"><?= $cls['missed'] ?></td>
<td style="text-align:center;font-weight:bold;"><?= $cls['accuracy'] ?>%</td>
<td style="text-align:right;"><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></td>
<td style="text-align:right;"><?= $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<td style="font-weight:bold;">Total</td>
<td style="text-align:center;font-weight:bold;"><?= $totalStudents ?></td>
<td style="text-align:center;font-weight:bold;"><?= $totalPredicted ?></td>
<td style="text-align:center;font-weight:bold;"><?= $totalActual ?></td>
<td style="text-align:center;font-weight:bold;" class="s-confirmed"><?= $totalConfirmed ?></td>
<td style="text-align:center;font-weight:bold;" class="s-surprise"><?= $totalSurprise ?></td>
<td style="text-align:center;font-weight:bold;" class="s-missed"><?= $totalMissed ?></td>
<td style="text-align:center;font-weight:bold;"><?= $overallAccuracy ?>%</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
<!-- Charts as images (populated by JS before printing) -->
<div class="print-page-break"></div>
<h3 style="margin-bottom:6px;">Charts</h3>
<div style="margin-bottom:14px;">
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Trophy Counts per Class</p>
<img id="print-chart-counts" style="width:100%;max-height:220px;object-fit:contain;" src="" alt="">
</div>
<div style="display:flex;gap:16px;margin-bottom:14px;">
<div style="flex:1;">
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Prediction Accuracy per Class</p>
<img id="print-chart-accuracy" style="width:100%;max-height:160px;object-fit:contain;" src="" alt="">
</div>
<div style="flex:1;">
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Overall Outcome Breakdown</p>
<img id="print-chart-breakdown" style="width:100%;max-height:160px;object-fit:contain;" src="" alt="">
</div>
</div>
</div><!-- /print-only -->
<?php endif; ?>
</div>
<?= $this->section('scripts') ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
(function () {
<?php
$cLabels = [];
$cPredicted = [];
$cActual = [];
$cConfirmed = [];
$cSurprise = [];
$cMissed = [];
$cAccuracy = [];
foreach ($classResults as $cls) {
$cLabels[] = $cls['section_name'];
$cPredicted[] = $cls['predicted_count'];
$cActual[] = $cls['actual_count'];
$cConfirmed[] = $cls['confirmed'];
$cSurprise[] = $cls['surprises'];
$cMissed[] = $cls['missed'];
$cAccuracy[] = $cls['accuracy'];
}
?>
var labels = <?= json_encode($cLabels) ?>;
var predicted = <?= json_encode($cPredicted) ?>;
var actual = <?= json_encode($cActual) ?>;
var confirmed = <?= json_encode($cConfirmed) ?>;
var surprises = <?= json_encode($cSurprise) ?>;
var missed = <?= json_encode($cMissed) ?>;
var accuracy = <?= json_encode($cAccuracy) ?>;
/* ── Chart 1: grouped bar counts per class ── */
new Chart(document.getElementById('chart-counts'), {
type: 'bar',
data: {
labels: labels,
datasets: [
{ label: 'Predicted', data: predicted, backgroundColor: '#4A90E2', borderRadius: 3 },
{ label: 'Actual', data: actual, backgroundColor: '#f0a500', borderRadius: 3 },
{ label: 'Confirmed', data: confirmed, backgroundColor: '#28a745', borderRadius: 3 },
{ label: 'Surprises', data: surprises, backgroundColor: '#17a2b8', borderRadius: 3 },
{ label: 'Missed', data: missed, backgroundColor: '#fd7e14', borderRadius: 3 },
]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { position: 'bottom' } },
scales: {
x: { grid: { color: '#f0f0f0' } },
y: { beginAtZero: true, ticks: { stepSize: 1 }, grid: { color: '#f0f0f0' } }
}
}
});
/* ── Chart 2: accuracy % per class ── */
new Chart(document.getElementById('chart-accuracy'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Accuracy %',
data: accuracy,
backgroundColor: accuracy.map(function(a) {
return a >= 80 ? '#28a745' : a >= 50 ? '#f0a500' : '#dc3545';
}),
borderRadius: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true, max: 100,
ticks: { callback: function(v) { return v + '%'; } },
grid: { color: '#f0f0f0' }
}
}
}
});
/* ── Chart 3: doughnut overall outcome breakdown ── */
new Chart(document.getElementById('chart-breakdown'), {
type: 'doughnut',
data: {
labels: ['Confirmed', 'Surprises', 'Missed'],
datasets: [{
data: [<?= $totalConfirmed ?>, <?= $totalSurprise ?>, <?= $totalMissed ?>],
backgroundColor: ['#28a745', '#17a2b8', '#fd7e14'],
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: function(ctx) {
var total = ctx.dataset.data.reduce(function(a, b) { return a + b; }, 0);
var pct = total > 0 ? Math.round(ctx.parsed / total * 100) : 0;
return ctx.label + ': ' + ctx.parsed + ' (' + pct + '%)';
}
}
}
}
}
});
})();
function captureCharts() {
var map = {
'chart-counts': 'print-chart-counts',
'chart-accuracy': 'print-chart-accuracy',
'chart-breakdown': 'print-chart-breakdown',
};
Object.keys(map).forEach(function(canvasId) {
var canvas = document.getElementById(canvasId);
var img = document.getElementById(map[canvasId]);
if (canvas && img) img.src = canvas.toDataURL('image/png');
});
}
function printWithCharts() {
captureCharts();
window.print();
}
window.addEventListener('beforeprint', captureCharts);
</script>
<?= $this->endSection() ?>
<?= $this->endSection() ?>
+480
View File
@@ -0,0 +1,480 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
$classResults = $classResults ?? [];
$selectedYear = $selectedYear ?? '';
$selectedPercentile = $selectedPercentile ?? 75;
$years = $years ?? [];
$totalWinners = array_sum(array_map(fn($c) => count($c['winners']), $classResults));
$totalStudents = array_sum(array_column($classResults, 'student_count'));
$totalBoys = array_sum(array_column($classResults, 'boys'));
$totalGirls = array_sum(array_column($classResults, 'girls'));
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
$pctWinners = $totalStudents > 0 ? round($totalWinners / $totalStudents * 100) : 0;
$pctTrophyBoys = $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0;
$pctTrophyGirls = $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0;
$pctBoys = $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0;
$pctGirls = $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0;
?>
<style>
/* ── Screen: hide print-only blocks ── */
.print-only { display: none !important; }
/* ── Print ── */
@media print {
.no-print { display: none !important; }
.print-only { display: block !important; }
.screen-only { display: none !important; }
body { font-size: 11px; }
.container-fluid { padding: 0 !important; }
h2, h3 { font-size: 14px; margin-bottom: .4rem; }
.print-page-break { break-before: page; }
table { width: 100%; border-collapse: collapse; font-size: 10px; }
table th,
table td { border: 1px solid #bbb; padding: 3px 6px; }
table thead { background: #333 !important; color: #fff !important;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
table thead th { position: static !important; top: auto !important; box-shadow: none !important; }
table tfoot { background: #eee !important;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
.badge-m { color: #4A90E2; font-weight: bold; }
.badge-f { color: #E47AB0; font-weight: bold; }
}
</style>
<div class="container-fluid">
<!-- ══ SCREEN HEADER ══════════════════════════════════════════════════════ -->
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2 no-print">
<div>
<h2 class="mb-1"><i class="bi bi-trophy-fill text-warning me-2"></i>Trophy Winners</h2>
<p class="text-muted mb-0">
<?= esc($selectedYear) ?> &mdash; <?= (int)$selectedPercentile ?>th percentile &mdash;
<strong><?= $totalWinners ?></strong> winners across <strong><?= count($classResults) ?></strong> classes
</p>
</div>
<div class="d-flex gap-2">
<button onclick="window.print()" class="btn btn-primary btn-sm">
<i class="bi bi-printer-fill me-1"></i>Print
</button>
<a href="<?= site_url('administrator/trophy?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
</div>
</div>
<!-- Filter (screen only) -->
<form method="get" action="<?= site_url('administrator/trophy/winners') ?>"
class="row g-2 align-items-end mb-4 no-print">
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">School Year</label>
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
<?php foreach ($years as $yr): ?>
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>><?= esc($yr) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">Percentile</label>
<div class="input-group input-group-sm" style="width:110px;">
<input type="number" name="percentile" class="form-control"
min="1" max="99" step="1" value="<?= (int)$selectedPercentile ?>">
<span class="input-group-text">%</span>
</div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
</div>
</form>
<?php if (empty($classResults)): ?>
<div class="alert alert-info no-print">No projected trophy winners found.</div>
<?php else: ?>
<!-- ══ PRINT VIEW ════════════════════════════════════════════════════════ -->
<div class="print-only">
<!-- Print title -->
<div style="text-align:center;margin-bottom:12px;border-bottom:2px solid #333;padding-bottom:8px;">
<h2 style="margin:0;">Trophy Winners — <?= esc($selectedYear) ?></h2>
<p style="margin:4px 0 0;font-size:10px;color:#555;">
<?= (int)$selectedPercentile ?>th percentile &mdash;
<?= $totalWinners ?> winners / <?= $totalStudents ?> students &mdash;
<?= count($classResults) ?> classes
</p>
</div>
<!-- TABLE 1: All winners -->
<h3 style="margin-bottom:4px;">Winners</h3>
<table data-no-mgmt-sticky>
<thead>
<tr>
<th>#</th>
<th>Class</th>
<th>Name</th>
<th style="text-align:center;">Gender</th>
<th style="text-align:right;">Fall Score</th>
<th style="text-align:right;">Spring Score</th>
<th style="text-align:right;">Year Score</th>
</tr>
</thead>
<tbody>
<?php
$globalRank = 0;
foreach ($classResults as $cls):
foreach ($cls['winners'] as $student):
$globalRank++;
$isMale = strtolower($student['gender'] ?? '') === 'male';
?>
<tr>
<td style="text-align:center;"><?= $globalRank ?></td>
<td><?= esc($cls['section_name']) ?></td>
<td><strong><?= esc($student['name']) ?></strong></td>
<td style="text-align:center;">
<span class="<?= $isMale ? 'badge-m' : 'badge-f' ?>">
<?= $isMale ? 'M' : 'F' ?>
</span>
</td>
<td style="text-align:right;font-weight:bold;">
<?= $student['fall_score'] !== null ? number_format($student['fall_score'], 1) : '—' ?>
</td>
<td style="text-align:right;">
<?= $student['spring_score'] !== null ? number_format($student['spring_score'], 1) : '—' ?>
</td>
<td style="text-align:right;font-weight:bold;color:#155724;">
<?= $student['year_score'] !== null ? number_format($student['year_score'], 1) : '—' ?>
</td>
</tr>
<?php endforeach; endforeach; ?>
</tbody>
<tfoot>
<tr>
<td colspan="2" style="font-weight:bold;">Total</td>
<td colspan="2" style="font-weight:bold;"><?= $totalWinners ?> winners</td>
<td colspan="3" style="text-align:right;font-weight:bold;"><?= $pctWinners ?>% of students</td>
</tr>
</tfoot>
</table>
<!-- TABLE 2: Year summary (new page) -->
<div class="print-page-break"></div>
<h3 style="margin-bottom:4px;">Year Summary — <?= esc($selectedYear) ?></h3>
<table style="margin-bottom:16px;" data-no-mgmt-sticky>
<thead>
<tr>
<th>Class</th>
<th style="text-align:center;">Students</th>
<th style="text-align:center;">Boys</th>
<th style="text-align:center;">Girls</th>
<th style="text-align:center;">Winners</th>
<th style="text-align:center;">Trophy Boys</th>
<th style="text-align:center;">Trophy Girls</th>
<th style="text-align:center;">Rate</th>
<th style="text-align:right;">Fall Threshold</th>
</tr>
</thead>
<tbody>
<?php foreach ($classResults as $cls): ?>
<tr>
<td><?= esc($cls['section_name']) ?></td>
<td style="text-align:center;"><?= $cls['student_count'] ?></td>
<td style="text-align:center;"><?= $cls['boys'] ?> (<?= $cls['pct_boys'] ?>%)</td>
<td style="text-align:center;"><?= $cls['girls'] ?> (<?= $cls['pct_girls'] ?>%)</td>
<td style="text-align:center;font-weight:bold;"><?= count($cls['winners']) ?></td>
<td style="text-align:center;">
<span class="badge-m"><?= $cls['trophy_boys'] ?></span>
<?= $cls['boys'] > 0 ? '(' . $cls['pct_trophy_boys'] . '%)' : '' ?>
</td>
<td style="text-align:center;">
<span class="badge-f"><?= $cls['trophy_girls'] ?></span>
<?= $cls['girls'] > 0 ? '(' . $cls['pct_trophy_girls'] . '%)' : '' ?>
</td>
<td style="text-align:center;"><?= $cls['pct_trophy_total'] ?>%</td>
<td style="text-align:right;"><?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '—' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<td style="font-weight:bold;">Total</td>
<td style="text-align:center;font-weight:bold;"><?= $totalStudents ?></td>
<td style="text-align:center;font-weight:bold;"><?= $totalBoys ?> (<?= $pctBoys ?>%)</td>
<td style="text-align:center;font-weight:bold;"><?= $totalGirls ?> (<?= $pctGirls ?>%)</td>
<td style="text-align:center;font-weight:bold;"><?= $totalWinners ?></td>
<td style="text-align:center;font-weight:bold;">
<span class="badge-m"><?= $totalTrophyBoys ?></span> (<?= $pctTrophyBoys ?>%)
</td>
<td style="text-align:center;font-weight:bold;">
<span class="badge-f"><?= $totalTrophyGirls ?></span> (<?= $pctTrophyGirls ?>%)
</td>
<td style="text-align:center;font-weight:bold;"><?= $pctWinners ?>%</td>
<td></td>
</tr>
</tfoot>
</table>
</div><!-- /print-only -->
<!-- ══ SCREEN VIEW ═══════════════════════════════════════════════════════ -->
<div class="screen-only">
<!-- Per-class cards -->
<?php foreach ($classResults as $cls):
$nW = count($cls['winners']);
?>
<div class="card shadow-sm mb-4">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2 py-2"
style="background:#fff8e1;">
<div class="d-flex align-items-center gap-2">
<span class="fw-bold fs-6"><?= esc($cls['section_name']) ?></span>
<span class="badge bg-warning text-dark">
<i class="bi bi-trophy-fill"></i> <?= $nW ?> winner<?= $nW !== 1 ? 's' : '' ?>
</span>
<?php if ($cls['threshold'] !== null): ?>
<span class="text-muted small">Fall &#8805; <?= number_format((float)$cls['threshold'], 1) ?></span>
<?php endif; ?>
</div>
<div class="d-flex gap-3 small">
<span>
<i class="bi bi-gender-male" style="color:#4A90E2"></i>
<strong style="color:#4A90E2"><?= $cls['trophy_boys'] ?></strong>/<?= $cls['boys'] ?> boys
<?php if ($cls['boys'] > 0): ?><span class="text-muted">(<?= $cls['pct_trophy_boys'] ?>%)</span><?php endif; ?>
</span>
<span>
<i class="bi bi-gender-female" style="color:#E47AB0"></i>
<strong style="color:#E47AB0"><?= $cls['trophy_girls'] ?></strong>/<?= $cls['girls'] ?> girls
<?php if ($cls['girls'] > 0): ?><span class="text-muted">(<?= $cls['pct_trophy_girls'] ?>%)</span><?php endif; ?>
</span>
<span class="text-muted"><?= $nW ?>/<?= $cls['student_count'] ?> (<?= $cls['pct_trophy_total'] ?>%)</span>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0 align-middle" data-no-mgmt-sticky>
<thead class="table-light">
<tr>
<th class="ps-3" style="width:2.2rem;">#</th>
<th>Name</th>
<th class="text-center" style="width:3rem;">Gender</th>
<th class="text-end">Fall Score</th>
<th class="text-end">Spring Score</th>
<th class="text-end pe-3">Year Score</th>
</tr>
</thead>
<tbody>
<?php foreach ($cls['winners'] as $i => $student):
$isMale = strtolower($student['gender'] ?? '') === 'male';
?>
<tr>
<td class="ps-3 text-muted small"><?= $i + 1 ?></td>
<td class="fw-semibold small"><?= esc($student['name']) ?></td>
<td class="text-center">
<span class="badge" style="background:<?= $isMale ? '#4A90E2' : '#E47AB0' ?>;font-size:.65rem;">
<?= $isMale ? 'M' : 'F' ?>
</span>
</td>
<td class="text-end small fw-semibold">
<?= $student['fall_score'] !== null ? number_format($student['fall_score'], 1) : '<span class="text-muted">—</span>' ?>
</td>
<td class="text-end small">
<?= $student['spring_score'] !== null
? '<span class="fw-semibold">' . number_format($student['spring_score'], 1) . '</span>'
: '<span class="text-muted">—</span>' ?>
</td>
<td class="text-end pe-3 small fw-semibold" style="color:#155724;">
<?= $student['year_score'] !== null ? number_format($student['year_score'], 1) : '<span class="text-muted">—</span>' ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
<!-- Overall year stats -->
<div class="card shadow-sm mt-2">
<div class="card-header bg-dark text-white fw-semibold py-2">
<i class="bi bi-bar-chart-fill me-2"></i>Overall Year Summary — <?= esc($selectedYear) ?>
</div>
<div class="card-body">
<div class="row g-3 mb-3">
<?php foreach ([
[$totalWinners, 'Total Winners', 'warning', 'text-dark', $pctWinners . '% of students', null],
[$totalStudents, 'Total Students', 'secondary', 'text-white', count($classResults) . ' classes', null],
[$totalBoys, 'Total Boys', null, null, $pctBoys . '%', '#4A90E2'],
[$totalGirls, 'Total Girls', null, null, $pctGirls . '%', '#E47AB0'],
[$totalTrophyBoys, 'Trophy Boys', null, null, $pctTrophyBoys . '% of boys', '#4A90E2'],
[$totalTrophyGirls, 'Trophy Girls', null, null, $pctTrophyGirls . '% of girls', '#E47AB0'],
] as [$val, $label, $bg, $tc, $sub, $color]): ?>
<div class="col-6 col-sm-4 col-lg-2">
<div class="border rounded text-center p-3 h-100">
<div class="fs-3 fw-bold" <?= isset($color) ? 'style="color:' . $color . '"' : ($bg ? 'class="text-' . $bg . '"' : '') ?>>
<?= $val ?>
</div>
<div class="small text-muted"><?= $label ?></div>
<div class="badge mt-1 <?= $bg ? 'bg-' . $bg . ' ' . $tc : 'text-white' ?>"
<?= isset($color) && !$bg ? 'style="background:' . $color . '"' : '' ?>>
<?= $sub ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="row g-4 mt-1">
<!-- Stacked bar: trophies per class (boys + girls) -->
<div class="col-12 col-lg-7">
<div class="border rounded p-3 h-100">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-trophy-fill text-warning me-1"></i>Trophy Winners per Class
</div>
<canvas id="chart-per-class" style="max-height:260px;"></canvas>
</div>
</div>
<!-- Doughnut: trophy gender split + rate per class -->
<div class="col-12 col-lg-5">
<div class="row g-3 h-100">
<div class="col-12">
<div class="border rounded p-3">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-gender-ambiguous me-1"></i>Winners by Gender
</div>
<canvas id="chart-gender" style="max-height:160px;"></canvas>
</div>
</div>
<div class="col-12">
<div class="border rounded p-3">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-bar-chart-fill me-1"></i>Trophy Rate per Class (%)
</div>
<canvas id="chart-rate" style="max-height:130px;"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div><!-- /screen-only -->
<?php endif; ?>
</div>
<?= $this->section('scripts') ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
(function () {
<?php
$chartLabels = [];
$chartBoys = [];
$chartGirls = [];
$chartRates = [];
foreach ($classResults as $cls) {
$chartLabels[] = $cls['section_name'];
$chartBoys[] = $cls['trophy_boys'];
$chartGirls[] = $cls['trophy_girls'];
$chartRates[] = $cls['pct_trophy_total'];
}
?>
var labels = <?= json_encode($chartLabels) ?>;
var boys = <?= json_encode($chartBoys) ?>;
var girls = <?= json_encode($chartGirls) ?>;
var rates = <?= json_encode($chartRates) ?>;
var BOY_COLOR = '#4A90E2';
var GIRL_COLOR = '#E47AB0';
var RATE_COLOR = '#f0a500';
/* ── Chart 1: stacked horizontal bar per class ── */
new Chart(document.getElementById('chart-per-class'), {
type: 'bar',
data: {
labels: labels,
datasets: [
{ label: 'Boys', data: boys, backgroundColor: BOY_COLOR, stack: 'winners' },
{ label: 'Girls', data: girls, backgroundColor: GIRL_COLOR, stack: 'winners' }
]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { position: 'bottom' } },
scales: {
x: { stacked: true, ticks: { stepSize: 1 }, grid: { color: '#f0f0f0' } },
y: { stacked: true }
}
}
});
/* ── Chart 2: doughnut gender split ── */
new Chart(document.getElementById('chart-gender'), {
type: 'doughnut',
data: {
labels: ['Trophy Boys', 'Trophy Girls'],
datasets: [{
data: [<?= $totalTrophyBoys ?>, <?= $totalTrophyGirls ?>],
backgroundColor: [BOY_COLOR, GIRL_COLOR],
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: function(ctx) {
var total = ctx.dataset.data.reduce(function(a,b){ return a+b; }, 0);
var pct = total > 0 ? Math.round(ctx.parsed / total * 100) : 0;
return ctx.label + ': ' + ctx.parsed + ' (' + pct + '%)';
}
}
}
}
}
});
/* ── Chart 3: bar chart rate per class ── */
new Chart(document.getElementById('chart-rate'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Trophy Rate %',
data: rates,
backgroundColor: rates.map(function(r) {
return r > 30 ? '#dc3545' : r > 20 ? RATE_COLOR : '#28a745';
}),
borderRadius: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true, max: 100,
ticks: { callback: function(v) { return v + '%'; } },
grid: { color: '#f0f0f0' }
}
}
}
});
})();
</script>
<?= $this->endSection() ?>
<?= $this->endSection() ?>
+101
View File
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Certificate Verification Al Rahma Sunday School</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.7.2/font/bootstrap-icons.min.css">
<style>
body { background: #f4f6f9; }
.verify-card { max-width: 520px; margin: 60px auto; }
.badge-valid { background: #198754; }
.badge-invalid { background: #dc3545; }
.cert-logo { max-height: 70px; }
.field-label { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: #6c757d; }
.field-value { font-size: 1.05rem; font-weight: 500; }
</style>
</head>
<body>
<div class="verify-card px-3">
<!-- Header -->
<div class="text-center mb-4 mt-5">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="Al Rahma Sunday School" class="cert-logo mb-3">
<h4 class="fw-bold mb-0">Certificate Verification</h4>
<p class="text-muted small">Al Rahma Sunday School</p>
</div>
<?php if ($record): ?>
<!-- Valid -->
<div class="card shadow-sm border-0">
<div class="card-body p-4">
<div class="d-flex align-items-center gap-2 mb-4">
<span class="badge badge-valid text-white px-3 py-2 fs-6">
<i class="bi bi-patch-check-fill me-1"></i>Verified
</span>
<code class="text-muted"><?= esc($record['certificate_number']) ?></code>
</div>
<div class="row g-3">
<div class="col-12">
<div class="field-label">Student Name</div>
<div class="field-value"><?= esc($record['student_name']) ?></div>
</div>
<div class="col-6">
<div class="field-label">Grade / Class</div>
<div class="field-value"><?= esc($record['grade'] ?? '—') ?></div>
</div>
<div class="col-6">
<div class="field-label">School Year</div>
<div class="field-value"><?= esc($record['school_year'] ?? '—') ?></div>
</div>
<div class="col-6">
<div class="field-label">Certificate Date</div>
<div class="field-value">
<?= $record['cert_date'] ? esc(date('F j, Y', strtotime($record['cert_date']))) : '—' ?>
</div>
</div>
<div class="col-6">
<div class="field-label">Issued By</div>
<div class="field-value">
<?= esc(trim(($record['admin_firstname'] ?? '') . ' ' . ($record['admin_lastname'] ?? '')) ?: '—') ?>
</div>
</div>
<div class="col-12">
<div class="field-label">Issued On</div>
<div class="field-value">
<?= $record['issued_at'] ? esc(date('F j, Y \a\t g:i A', strtotime($record['issued_at']))) : '—' ?>
</div>
</div>
</div>
</div>
<div class="card-footer text-muted small text-center py-2">
This certificate was officially issued by Al Rahma Sunday School.
</div>
</div>
<?php else: ?>
<!-- Not found -->
<div class="card shadow-sm border-0">
<div class="card-body p-4 text-center">
<span class="badge badge-invalid text-white px-3 py-2 fs-6 mb-3 d-inline-block">
<i class="bi bi-x-circle-fill me-1"></i>Not Found
</span>
<p class="mb-0">No certificate matching this code was found in our records.<br>
Please check the code and try again.</p>
</div>
</div>
<?php endif; ?>
<p class="text-center text-muted small mt-4">
&copy; <?= date('Y') ?> Al Rahma Sunday School
</p>
</div>
</body>
</html>
+3
View File
@@ -104,6 +104,9 @@ $role = strtolower(session()->get('role') ?? 'guest');
<div class="dropdown-menu">
<a class="dropdown-item" href="/administrator/class_assignment">Classes List</a>
<a class="dropdown-item" href="/administrator/subject-curriculum">Subject Curriculum</a>
<a class="dropdown-item" href="/administrator/trophy">
<i class="bi bi-trophy-fill text-warning me-1"></i>Trophy Awards
</a>
</div>
</li>
+4 -2
View File
@@ -18,7 +18,8 @@
<div class="form-group mb-3">
<label for="email" class="form-label"></label>
<input type="email" class="form-control item" id="email" name="email"
placeholder="Enter your email" maxlength="50" value="<?= old('email') ?>" required>
placeholder="Enter your email" maxlength="254" autocomplete="username"
inputmode="email" value="<?= old('email') ?>" required>
<div id="email-error" class="text-danger small mt-1"></div>
</div>
@@ -30,7 +31,8 @@
id="password"
name="password"
placeholder="Enter your password"
maxlength="30"
maxlength="255"
autocomplete="current-password"
required>
<span class="position-absolute top-50 end-0 translate-middle-y me-3"
onclick="togglePasswordVisibility('password', this)"
+8 -5
View File
@@ -1,12 +1,15 @@
<?php
helper('security');
// Database configuration
$host = 'localhost';
$dbname = 'u280815660_school';
$username = 'u280815660_melabidi';
$password = '>tNxlRzP/W8';
$host = env('database.default.hostname', 'localhost');
$dbname = require_env('database.default.database');
$username = require_env('database.default.username');
$password = require_env('database.default.password');
$port = (int) env('database.default.port', 3306);
// Create connection
$conn = @new mysqli($host, $username, $password, $dbname);
$conn = @new mysqli($host, $username, $password, $dbname, $port);
// Check connection
if ($conn->connect_error) {
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

-10
View File
@@ -1,10 +0,0 @@
CREATE TABLE IF NOT EXISTS `ip_attempts` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`ip_address` VARCHAR(45) NOT NULL,
`attempts` INT DEFAULT 0 NOT NULL,
`last_attempt_at` DATETIME NOT NULL,
`blocked_until` DATETIME DEFAULT NULL,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY (`ip_address`)
);
-59
View File
@@ -1,59 +0,0 @@
CREATE TABLE
IF NOT EXISTS `users`
(
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
`password` varchar
(255) NOT NULL,
`lastname` varchar
(255) NOT NULL,
`firstname` varchar
(255) NOT NULL,
`gender` enum
('Male','Female') CHARACTER
SET utf8mb3
COLLATE utf8mb3_general_ci DEFAULT NULL,
`cellphone` varchar
(25) NOT NULL,
`email` varchar
(255) NOT NULL,
`address_street` varchar
(255) NOT NULL,
`apt` varchar
(25) DEFAULT NULL,
`city` varchar
(255) NOT NULL,
`state` varchar
(25) NOT NULL,
`zip` varchar
(25) NOT NULL,
`accept_school_policy` tinyint
(1) NOT NULL,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`token` varchar
(255) DEFAULT NULL,
`is_verified` tinyint
(1) NOT NULL DEFAULT '0',
`account_id` varchar
(255) DEFAULT NULL,
`status` varchar
(10) NOT NULL DEFAULT 'Inactive',
`user_type` enum
('primary','secondary','tertiary') CHARACTER
SET utf8mb3
COLLATE utf8mb3_general_ci DEFAULT 'primary',
`semester` varchar
(255) NOT NULL,
`school_year` varchar
(9) DEFAULT NULL,
`rfid_tag` varchar
(100) CHARACTER
SET utf8mb3
COLLATE utf8mb3_general_ci DEFAULT NULL,
`failed_attempts` INT DEFAULT 0 NOT NULL,
`last_failed_at` DATETIME DEFAULT NULL,
`is_suspended` TINYINT
(1) DEFAULT 0 NOT NULL,
PRIMARY KEY
(`id`)
);
@@ -0,0 +1,35 @@
<?php
namespace Tests\App\Filters;
use App\Filters\ApiRateLimitFilter;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\URI;
use CodeIgniter\HTTP\UserAgent;
use CodeIgniter\Test\CIUnitTestCase;
use Config\App;
use Config\Services;
class ApiRateLimitFilterTest extends CIUnitTestCase
{
protected function setUp(): void
{
parent::setUp();
Services::resetSingle('throttler');
Services::resetSingle('session');
}
public function testBlocksRequestWhenThresholdIsExceeded(): void
{
$request = new IncomingRequest(config(App::class), new URI('https://example.test/api/health'), 'php://input', new UserAgent());
$filter = new ApiRateLimitFilter();
$first = $filter->before($request, [1, 60]);
$second = $filter->before($request, [1, 60]);
$this->assertNull($first);
$this->assertInstanceOf(Response::class, $second);
$this->assertSame(429, $second->getStatusCode());
}
}
@@ -0,0 +1,48 @@
<?php
namespace Tests\App\Filters;
use App\Filters\SanitizeInputFilter;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\Response;
use CodeIgniter\HTTP\URI;
use CodeIgniter\HTTP\UserAgent;
use CodeIgniter\Test\CIUnitTestCase;
use Config\App;
use Config\Services;
class SanitizeInputFilterTest extends CIUnitTestCase
{
public function testSanitizesGetPostAndJsonPayloads(): void
{
$request = new IncomingRequest(config(App::class), new URI('https://example.test/api/login'), 'php://input', new UserAgent());
$request->setHeader('Content-Type', 'application/json');
$request->setGlobal('get', ['q' => " hello\x00 "]);
$request->setGlobal('post', ['email' => " user@example.com \n"]);
$request->setGlobal('request', ['email' => " user@example.com \n"]);
$request->setGlobal('cookie', ['token' => " abc\t"]);
$request->setBody("{\"email\":\" user@example.com \",\"name\":\" Bob\\u0000 \"}");
$filter = new SanitizeInputFilter();
$result = $filter->before($request);
$this->assertNull($result);
$this->assertSame('hello', $request->getGet('q'));
$this->assertSame('user@example.com', $request->getPost('email'));
$this->assertSame('abc', $request->getCookie('token'));
$this->assertSame(['email' => 'user@example.com', 'name' => 'Bob'], $request->getJSON(true));
}
public function testRejectsMalformedJsonPayload(): void
{
$request = new IncomingRequest(config(App::class), new URI('https://example.test/api/login'), 'php://input', new UserAgent());
$request->setHeader('Content-Type', 'application/json');
$request->setBody('{invalid');
$filter = new SanitizeInputFilter();
$result = $filter->before($request);
$this->assertInstanceOf(Response::class, $result);
$this->assertSame(400, $result->getStatusCode());
}
}
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
<?php
// TCPDF FONT FILE DESCRIPTION
$type='TrueTypeUnicode';
$name='EdwardianScriptITC';
$up=-141;
$ut=39;
$dw=500;
$diff='';
$originalsize=64056;
$enc='';
$file='edwardianscriptitc.z';
$ctg='edwardianscriptitc.ctg.z';
$desc=array('Flags'=>32,'FontBBox'=>'[-322 -328 1692 851]','ItalicAngle'=>0,'Ascent'=>851,'Descent'=>-328,'Leading'=>0,'CapHeight'=>716,'XHeight'=>276,'StemV'=>34,'StemH'=>15,'AvgWidth'=>255,'MaxWidth'=>1291,'MissingWidth'=>500);
$cw=array(0=>500,32=>177,33=>287,34=>478,35=>478,36=>478,37=>535,38=>889,39=>321,40=>439,41=>439,42=>278,43=>523,44=>223,45=>358,46=>223,47=>529,48=>478,49=>478,50=>478,51=>478,52=>478,53=>478,54=>478,55=>478,56=>478,57=>478,58=>223,59=>223,60=>523,61=>523,62=>523,63=>436,64=>680,65=>908,66=>929,67=>797,68=>847,69=>841,70=>660,71=>734,72=>863,73=>638,74=>563,75=>881,76=>759,77=>978,78=>871,79=>793,80=>769,81=>702,82=>925,83=>707,84=>587,85=>901,86=>749,87=>924,88=>1004,89=>931,90=>653,91=>439,92=>529,93=>439,94=>500,95=>500,96=>500,97=>344,98=>263,99=>242,100=>344,101=>246,102=>133,103=>335,104=>313,105=>168,106=>151,107=>301,108=>160,109=>544,110=>391,111=>290,112=>265,113=>289,114=>278,115=>195,116=>165,117=>313,118=>273,119=>424,120=>348,121=>308,122=>279,123=>439,124=>500,125=>439,126=>667,160=>177,161=>287,162=>478,163=>478,164=>508,165=>478,166=>500,167=>478,168=>500,169=>768,170=>344,171=>358,172=>601,173=>523,174=>768,175=>500,176=>478,177=>523,178=>318,179=>318,180=>500,181=>348,182=>827,183=>231,184=>500,185=>318,186=>344,187=>358,188=>667,189=>650,190=>659,191=>436,192=>908,193=>908,194=>908,195=>908,196=>908,197=>908,198=>1291,199=>797,200=>841,201=>841,202=>841,203=>841,204=>638,205=>638,206=>638,207=>638,208=>847,209=>871,210=>793,211=>793,212=>793,213=>793,214=>793,215=>523,216=>793,217=>901,218=>901,219=>901,220=>901,221=>931,222=>748,223=>291,224=>344,225=>344,226=>344,227=>344,228=>344,229=>344,230=>440,231=>242,232=>246,233=>246,234=>246,235=>246,236=>168,237=>168,238=>168,239=>168,240=>290,241=>391,242=>290,243=>290,244=>290,245=>290,246=>290,247=>523,248=>290,249=>313,250=>313,251=>313,252=>313,253=>308,254=>261,255=>308,305=>168,338=>1168,339=>416,352=>707,353=>195,376=>931,402=>478,710=>500,711=>500,713=>500,728=>500,729=>500,730=>360,731=>500,732=>500,733=>500,916=>500,937=>679,956=>348,960=>361,8211=>445,8212=>597,8216=>209,8217=>209,8218=>209,8220=>301,8221=>301,8222=>301,8224=>478,8225=>478,8226=>669,8230=>668,8240=>731,8249=>249,8250=>249,8260=>68,8364=>478,8482=>775,8486=>679,8706=>290,8710=>500,8719=>694,8721=>478,8725=>68,8729=>231,8730=>506,8734=>713,8747=>350,8776=>523,8800=>523,8804=>523,8805=>523,8946=>523,9674=>500,61441=>311,61442=>308);
// --- EOF ---
Binary file not shown.
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
<?php
// TCPDF FONT FILE DESCRIPTION
$type='TrueTypeUnicode';
$name='Garamond-Bold';
$up=-133;
$ut=20;
$dw=770;
$diff='';
$originalsize=68396;
$enc='';
$file='garamondb.z';
$ctg='garamondb.ctg.z';
$desc=array('Flags'=>32,'FontBBox'=>'[-115 -230 1247 825]','ItalicAngle'=>0,'Ascent'=>825,'Descent'=>-230,'Leading'=>0,'CapHeight'=>627,'XHeight'=>459,'StemV'=>123,'StemH'=>53,'AvgWidth'=>479,'MaxWidth'=>1260,'MissingWidth'=>770);
$cw=array(0=>770,32=>280,33=>280,34=>400,35=>560,36=>560,37=>880,38=>760,39=>220,40=>400,41=>400,42=>340,43=>560,44=>280,45=>300,46=>280,47=>440,48=>560,49=>560,50=>560,51=>560,52=>560,53=>560,54=>560,55=>560,56=>560,57=>560,58=>280,59=>280,60=>560,61=>560,62=>560,63=>420,64=>720,65=>660,66=>640,67=>660,68=>760,69=>600,70=>560,71=>720,72=>780,73=>360,74=>400,75=>740,76=>540,77=>860,78=>720,79=>760,80=>600,81=>760,82=>660,83=>520,84=>600,85=>700,86=>640,87=>940,88=>700,89=>680,90=>620,91=>280,92=>540,93=>280,94=>560,95=>500,96=>400,97=>520,98=>600,99=>500,100=>600,101=>520,102=>360,103=>540,104=>660,105=>320,106=>300,107=>600,108=>320,109=>940,110=>660,111=>600,112=>640,113=>600,114=>460,115=>460,116=>340,117=>600,118=>540,119=>820,120=>620,121=>560,122=>480,123=>280,124=>560,125=>280,126=>560,160=>500,163=>400,164=>560,166=>560,167=>400,168=>600,169=>760,171=>340,172=>560,173=>520,174=>760,175=>360,176=>400,177=>560,178=>360,179=>320,181=>600,182=>660,183=>280,184=>520,185=>360,187=>340,188=>300,189=>520,190=>460,191=>320,192=>660,193=>640,194=>640,195=>560,196=>700,197=>600,198=>1080,199=>560,200=>780,201=>780,202=>740,203=>700,204=>860,205=>780,206=>760,207=>780,208=>600,209=>660,210=>600,211=>680,212=>820,213=>700,214=>780,215=>760,216=>1000,217=>1000,218=>680,219=>1000,220=>600,221=>660,222=>1000,223=>660,224=>520,225=>600,226=>560,227=>500,228=>600,229=>520,230=>800,231=>480,232=>660,233=>660,234=>600,235=>600,236=>740,237=>660,238=>600,239=>660,240=>640,241=>500,242=>500,243=>560,244=>760,245=>620,246=>660,247=>640,248=>940,249=>960,250=>600,251=>860,252=>540,253=>500,254=>860,255=>600,1025=>600,1029=>520,1030=>360,1031=>360,1032=>400,1040=>660,1041=>640,1042=>640,1043=>560,1044=>700,1045=>600,1046=>1080,1047=>560,1048=>780,1049=>780,1050=>740,1051=>700,1052=>860,1053=>780,1054=>760,1055=>780,1056=>600,1057=>660,1058=>600,1059=>680,1060=>820,1061=>700,1062=>780,1063=>760,1064=>1000,1065=>1000,1066=>680,1067=>1000,1068=>600,1069=>660,1070=>1000,1071=>660,1072=>520,1073=>600,1074=>560,1075=>500,1076=>600,1077=>520,1078=>800,1079=>480,1080=>660,1081=>660,1082=>600,1083=>600,1084=>740,1085=>660,1086=>600,1087=>660,1088=>640,1089=>500,1090=>500,1091=>560,1092=>760,1093=>620,1094=>660,1095=>640,1096=>940,1097=>960,1098=>600,1099=>860,1100=>540,1101=>500,1102=>860,1103=>600,1105=>520,1109=>460,1110=>320,1111=>320,1112=>300,8211=>500,8212=>1000,8216=>260,8217=>260,8218=>260,8220=>440,8221=>440,8222=>440,8224=>540,8225=>540,8226=>620,8230=>1000,8240=>1260,8249=>200,8250=>200,8470=>360,8482=>860,8729=>280);
// --- EOF ---
Binary file not shown.