Compare commits

..

13 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
root 1f51be91e4 fix external kids for events 2026-05-05 22:37:32 -04:00
root 92016f90d0 fix issues on examdraft view and event print page 2026-05-05 01:38:20 -04:00
root 45d7895182 add waiver column to event table 2026-04-19 12:32:30 -04:00
root 596d368b1d fix event issues 2026-04-19 12:05:10 -04:00
61 changed files with 4738 additions and 528 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/*']],
];
}
+17 -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,8 +545,10 @@ $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');
$routes->get('administrator/get-students-with-charges', 'View\EventController::getStudentsWithCharges');
$routes->get('parent/events', 'View\ParentController::parentEventPage', ['filter' => 'auth:parent']);
@@ -744,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);
+49 -29
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 = [
'email' => $this->request->getPost('email'),
'password' => $this->request->getPost('password'),
];
}
$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([
+17 -2
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);
}
}
+9 -9
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();
@@ -63,4 +63,4 @@ class ContactController extends BaseController
return redirect()->to('/parent/contact');
}
}
}
+361 -88
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
{
@@ -41,6 +43,7 @@ class EventController extends ResourceController
protected $categories;
protected $enrollmentModel;
private ?bool $eventChargesHasCreatedBy = null;
private ?bool $eventChargesHasWaiverSigned = null;
public function __construct()
{
@@ -85,6 +88,229 @@ class EventController extends ResourceController
return $this->eventChargesHasCreatedBy;
}
private function eventChargesSupportsWaiverSigned(): bool
{
if ($this->eventChargesHasWaiverSigned !== null) {
return $this->eventChargesHasWaiverSigned;
}
try {
$db = Database::connect();
$this->eventChargesHasWaiverSigned = $db->fieldExists('waiver_signed', 'event_charges');
} catch (\Throwable $e) {
$this->eventChargesHasWaiverSigned = false;
}
return $this->eventChargesHasWaiverSigned;
}
private function getEventChargesReturnTo(): string
{
$fallback = site_url('administrator/event-charges');
$returnTo = trim((string) ($this->request->getPost('return_to') ?? ''));
if ($returnTo === '') {
return $fallback;
}
$base = rtrim((string) base_url(), '/');
if (str_starts_with($returnTo, '/')) {
return $returnTo;
}
if ($base !== '' && str_starts_with($returnTo, $base)) {
return $returnTo;
}
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();
@@ -220,6 +446,59 @@ class EventController extends ResourceController
]);
if ($updated) {
if ($this->request->getPost('add_to_calendar')) {
$calendarModel = new CalendarModel();
$title = trim((string) $this->request->getPost('event_name'));
$date = (string) $this->request->getPost('expiration_date');
$schoolYear = (string) $this->request->getPost('school_year');
$semester = (string) $this->request->getPost('semester');
if ($title !== '' && $date !== '' && $schoolYear !== '') {
$data = [
'title' => $title,
'description' => (string) $this->request->getPost('description'),
'event_type' => 'Event',
'date' => $date,
'notify_parent' => $this->request->getPost('notify_parent') ? 1 : 0,
'notify_teacher' => $this->request->getPost('notify_teacher') ? 1 : 0,
'notify_admin' => $this->request->getPost('notify_admin') ? 1 : 0,
'no_school' => 0,
'school_year' => $schoolYear,
'semester' => $semester ?: $this->semester,
];
if (!$calendarModel->supportsEventType()) {
unset($data['event_type']);
}
$existingByPreviousEvent = $calendarModel
->where('school_year', (string) ($event['school_year'] ?? ''))
->where('date', (string) ($event['expiration_date'] ?? ''))
->where('title', (string) ($event['event_name'] ?? ''));
if ($calendarModel->supportsEventType() && isset($data['event_type'])) {
$existingByPreviousEvent = $existingByPreviousEvent->where('event_type', $data['event_type']);
}
$existingCalendar = $existingByPreviousEvent->first();
if ($existingCalendar) {
$calendarModel->update($existingCalendar['id'], $data);
} else {
$dupQuery = $calendarModel
->where('school_year', $data['school_year'])
->where('date', $data['date'])
->where('title', $data['title']);
if ($calendarModel->supportsEventType() && isset($data['event_type'])) {
$dupQuery = $dupQuery->where('event_type', $data['event_type']);
}
$duplicate = $dupQuery->first();
if (!$duplicate) {
$calendarModel->save($data);
}
}
}
}
$redirect = redirect()->to('/administrator/events')->with('success', 'Event updated successfully');
if ($this->request->getPost('send_email_parent')) {
$emailStatus = $this->broadcastEventToParents((int) $id);
@@ -386,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')
@@ -475,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());
}
@@ -517,6 +747,7 @@ class EventController extends ResourceController
$parentsForInvoice = [];
$supportsCreatedBy = $this->eventChargesSupportsCreatedBy();
$supportsWaiverSigned = $this->eventChargesSupportsWaiverSigned();
foreach ($participations as $studentId => $value) {
$existing = $this->eventChargesModel->where([
@@ -543,6 +774,9 @@ class EventController extends ResourceController
'updated_by' => $userId,
'class_section_id'=> $classSectionId,
];
if ($supportsWaiverSigned) {
$updateData['waiver_signed'] = (int) ($existing['waiver_signed'] ?? 0);
}
if (isset($event['amount']) && ((float)$event['amount'] !== (float)($existing['charged'] ?? 0))) {
$updateData['charged'] = $event['amount'];
}
@@ -561,6 +795,9 @@ class EventController extends ResourceController
'semester' => $semester,
'updated_by' => $userId
];
if ($supportsWaiverSigned) {
$insertData['waiver_signed'] = 0;
}
if ($supportsCreatedBy) {
$insertData['created_by'] = $userId;
}
@@ -577,6 +814,7 @@ class EventController extends ResourceController
$parentPhone = trim($pieces['parent_phone'] ?? '');
$parentEmail = trim((string)($pieces['parent_email'] ?? ''));
$markPaid = (string)($pieces['paid'] ?? '0') === '1';
$waiverSigned = (string)($pieces['waiver_signed'] ?? '0') === '1';
if (!$firstname && !$lastname) {
continue;
@@ -611,7 +849,7 @@ class EventController extends ResourceController
$existingExternal = $this->eventChargesModel->where($matchConditions)->first();
if ($existingExternal) {
$this->eventChargesModel->update($existingExternal['id'], [
$updateData = [
'participation' => 'yes',
'charged' => $event['amount'],
'updated_by' => $userId,
@@ -621,7 +859,11 @@ class EventController extends ResourceController
'external_parent_lastname' => $parentLast !== '' ? $parentLast : ($existingExternal['external_parent_lastname'] ?? null),
'external_parent_phone' => $parentPhone !== '' ? $parentPhone : ($existingExternal['external_parent_phone'] ?? null),
'external_parent_email' => $parentEmail !== '' ? $parentEmail : ($existingExternal['external_parent_email'] ?? null),
]);
];
if ($supportsWaiverSigned) {
$updateData['waiver_signed'] = $waiverSigned ? 1 : 0;
}
$this->eventChargesModel->update($existingExternal['id'], $updateData);
continue;
}
@@ -643,6 +885,9 @@ class EventController extends ResourceController
'semester' => $semester,
'updated_by' => $userId,
];
if ($supportsWaiverSigned) {
$insertData['waiver_signed'] = $waiverSigned ? 1 : 0;
}
if ($supportsCreatedBy) {
$insertData['created_by'] = $userId;
}
@@ -672,13 +917,14 @@ class EventController extends ResourceController
public function removeCharge($chargeId = null)
{
$returnTo = $this->getEventChargesReturnTo();
if (!$chargeId) {
return redirect()->back()->with('error', 'Invalid charge.');
return redirect()->to($returnTo)->with('error', 'Invalid charge.');
}
$charge = $this->eventChargesModel->find($chargeId);
if (!$charge) {
return redirect()->back()->with('error', 'Charge not found.');
return redirect()->to($returnTo)->with('error', 'Charge not found.');
}
$paymentId = (int)($charge['event_payment_id'] ?? 0);
@@ -691,19 +937,20 @@ class EventController extends ResourceController
$this->invoiceController->generateInvoice((string)$charge['parent_id'], (string)($charge['school_year'] ?? $this->schoolYear), (string)($charge['semester'] ?? $this->semester), false);
}
return redirect()->back()->with('success', 'Participation removed, invoices updated.');
return redirect()->to($returnTo)->with('success', 'Participation removed, invoices updated.');
}
public function toggleEventPayment($chargeId = null)
{
$returnTo = $this->getEventChargesReturnTo();
if (!$chargeId) {
return redirect()->back()->with('error', 'Invalid charge.');
return redirect()->to($returnTo)->with('error', 'Invalid charge.');
}
$isPaid = $this->request->getPost('paid') === '1';
$meta = $this->applyEventPaymentStatus((int)$chargeId, $isPaid);
if (!$meta) {
return redirect()->back()->with('error', 'Charge not found.');
return redirect()->to($returnTo)->with('error', 'Charge not found.');
}
if (!empty($meta['invoice_id'])) {
@@ -711,7 +958,33 @@ class EventController extends ResourceController
$this->fixInvoiceStatusAfterCharge((int)$meta['parent_id'], (string)$meta['school_year']);
}
return redirect()->back()->with('success', 'Event payment status updated.');
return redirect()->to($returnTo)->with('success', 'Event payment status updated.');
}
public function toggleWaiverStatus($chargeId = null)
{
$returnTo = $this->getEventChargesReturnTo();
if (!$chargeId) {
return redirect()->to($returnTo)->with('error', 'Invalid charge.');
}
if (!$this->eventChargesSupportsWaiverSigned()) {
return redirect()->to($returnTo)->with('error', 'Waiver tracking is not available until the database migration is applied.');
}
$charge = $this->eventChargesModel->find($chargeId);
if (!$charge) {
return redirect()->to($returnTo)->with('error', 'Charge not found.');
}
$signed = $this->request->getPost('waiver_signed') === '1';
$this->eventChargesModel->update((int) $chargeId, [
'waiver_signed' => $signed ? 1 : 0,
'updated_by' => session()->get('user_id'),
]);
return redirect()->to($returnTo)->with('success', $signed ? 'Waiver marked as signed.' : 'Waiver marked as unsigned.');
}
private function parentHasEnrollment(int $parentId, string $schoolYear): bool
+140 -28
View File
@@ -102,11 +102,9 @@ class ExamDraftController extends BaseController
session()->set('class_section_id', $selectedClass);
}
$allDrafts = $this->examDraftModel
->select($this->draftSelectColumns())
->select('cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
->where($this->authorIdColumn, $teacherId)
$classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments);
$allDrafts = $this->visibleTeacherDraftsQuery($teacherId, $classSectionIds)
->orderBy('created_at', 'DESC')
->findAll();
@@ -117,10 +115,10 @@ class ExamDraftController extends BaseController
$row['final_pdf_file'] = $pdf;
}
}
$row = $this->attachTeacherDraftContext($row, $teacherId);
}
unset($row);
$classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments);
$legacyExams = [];
// Guard against missing schema column in older databases
if ($this->hasIsLegacyColumn) {
@@ -129,8 +127,9 @@ class ExamDraftController extends BaseController
$legacyQuery = $this->examDraftModel
->select($this->draftSelectColumns())
->select('cs.class_section_name')
->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last')
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left')
->where('exam_drafts.is_legacy', 1)
->where('exam_drafts.status', 'legacy')
->where('exam_drafts.final_file IS NOT NULL', null, false);
@@ -143,6 +142,11 @@ class ExamDraftController extends BaseController
->orderBy('cs.class_section_name', 'ASC')
->orderBy('exam_drafts.created_at', 'DESC')
->findAll();
foreach ($legacyExams as &$row) {
$row = $this->attachTeacherDraftContext($row, $teacherId);
}
unset($row);
} else {
// Legacy column absent, show all drafts and skip legacy tab query
$drafts = $allDrafts;
@@ -344,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
@@ -483,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) {
@@ -567,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) {
@@ -686,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;
}
@@ -707,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);
@@ -768,10 +790,11 @@ class ExamDraftController extends BaseController
return $this->response->setStatusCode(401);
}
$drafts = $this->examDraftModel
->select($this->draftSelectColumns())
->select('id, status, acceptance_type, updated_at, reviewed_at')
->where($this->authorIdColumn, $teacherId)
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId($teacherId, $this->schoolYear);
$classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments);
$drafts = $this->visibleTeacherDraftsQuery($teacherId, $classSectionIds)
->select('exam_drafts.id, exam_drafts.status, exam_drafts.acceptance_type, exam_drafts.updated_at, exam_drafts.reviewed_at')
->orderBy('updated_at', 'DESC')
->findAll();
@@ -817,6 +840,58 @@ class ExamDraftController extends BaseController
return $validIds[0] ?? 0;
}
private function visibleTeacherDraftsQuery(int $teacherId, array $classSectionIds)
{
$query = $this->examDraftModel
->select($this->draftSelectColumns())
->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last')
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left');
if (empty($classSectionIds)) {
return $query->where('exam_drafts.' . $this->authorIdColumn, $teacherId);
}
$query->groupStart()
->where('exam_drafts.' . $this->authorIdColumn, $teacherId)
->orGroupStart()
->whereIn('exam_drafts.class_section_id', $classSectionIds)
->where('exam_drafts.' . $this->authorIdColumn . ' !=', $teacherId)
->where('exam_drafts.status !=', 'draft');
if ($this->schoolYear !== '') {
$query->where('exam_drafts.school_year', $this->schoolYear);
}
if ($this->semester !== '') {
$query->where('exam_drafts.semester', $this->semester);
}
$query->groupEnd()
->groupEnd();
return $query;
}
private function attachTeacherDraftContext(array $row, int $viewerId): array
{
$isOwn = $this->draftTeacherId($row) === $viewerId;
$row['is_own_submission'] = $isOwn;
$row['teacher_display_name'] = $isOwn ? 'You' : $this->draftTeacherName($row);
return $row;
}
private function draftTeacherName(array $draft): string
{
$name = trim(((string) ($draft['teacher_first'] ?? '')) . ' ' . ((string) ($draft['teacher_last'] ?? '')));
if ($name !== '') {
return $name;
}
$teacherId = $this->draftTeacherId($draft);
return $teacherId > 0 ? 'Teacher #' . $teacherId : 'Teacher';
}
private function statusBadgeMap(): array
{
return [
@@ -953,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');
@@ -962,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
@@ -1316,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);
+108
View File
@@ -224,6 +224,10 @@ class FilesController extends Controller
throw PageNotFoundException::forPageNotFound();
}
if (!$this->canAccessExamDraftFile($name, $subdir)) {
return $this->response->setStatusCode(403, 'You are not allowed to access this file.');
}
$mime = 'application/octet-stream';
if (function_exists('finfo_open')) {
$fi = finfo_open(FILEINFO_MIME_TYPE);
@@ -317,4 +321,108 @@ 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);
if ($userId <= 0) {
return false;
}
$role = strtolower((string) (session()->get('role') ?? ''));
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.semester, ed.status, ed.' . $authorIdColumn . ' AS draft_author_id')
->where('ed.' . $fileColumn, $filename)
->limit(1)
->get()
->getRowArray();
if (empty($draft)) {
return false;
}
$authorId = (int) ($draft['draft_author_id'] ?? 0);
if ($authorId > 0 && $authorId === $userId) {
return true;
}
$classSectionId = (int) ($draft['class_section_id'] ?? 0);
if ($classSectionId <= 0) {
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);
if ($draftSchoolYear !== '') {
$assignmentQuery->where('school_year', $draftSchoolYear);
}
$hasDraftYearAssignment = $assignmentQuery->limit(1)->countAllResults() > 0;
if (!$hasDraftYearAssignment) {
return false;
}
if ($subdir === 'finals') {
return true;
}
return $draftSemester === '' || $currentSemester === '' || $draftSemester === $currentSemester;
}
}
+25 -6
View File
@@ -1797,16 +1797,34 @@ $existing = $this->studentModel
$activeEventCount = is_array($activeEvents) ? count($activeEvents) : 0;
// Get charges (participation info)
$chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
$chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $semester);
// Build a map: "studentId:eventId" => [ 'participation' => ..., 'date' => ... ]
$charges = [];
$externalParticipantsByEvent = [];
foreach ($chargesList as $charge) {
$key = $charge['student_id'] . ':' . $charge['event_id'];
$charges[$key] = [
'participation' => $charge['participation'],
'date' => $charge['updated_at'] ?? $charge['created_at'] // Use updated_at if available
];
$studentId = $charge['student_id'] ?? null;
$eventId = (int) ($charge['event_id'] ?? 0);
if (!empty($studentId)) {
$key = $studentId . ':' . $eventId;
$charges[$key] = [
'participation' => $charge['participation'],
'date' => $charge['updated_at'] ?? $charge['created_at'], // Use updated_at if available
];
continue;
}
$externalName = trim((string) ($charge['external_firstname'] ?? '') . ' ' . (string) ($charge['external_lastname'] ?? ''));
if ($eventId > 0 && $externalName !== '') {
$externalParticipantsByEvent[$eventId][] = [
'name' => $externalName,
'note' => (string) ($charge['external_note'] ?? ''),
'participation' => (string) ($charge['participation'] ?? ''),
'event_paid' => !empty($charge['event_paid']),
'charged' => (float) ($charge['charged'] ?? ($charge['event_amount'] ?? 0)),
];
}
}
// Get enrolled students
@@ -1815,6 +1833,7 @@ $existing = $this->studentModel
return view('parent/event_participation', [
'activeEvents' => $activeEvents,
'charges' => $charges,
'externalParticipantsByEvent' => $externalParticipantsByEvent,
'yourStudents' => $students,
'activeEventCount' => $activeEventCount,
]);
+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,31 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddWaiverSignedToEventCharges extends Migration
{
public function up()
{
$fields = [
'waiver_signed' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0,
'after' => 'charged',
],
];
if (! $this->db->fieldExists('waiver_signed', 'event_charges')) {
$this->forge->addColumn('event_charges', $fields);
}
}
public function down()
{
if ($this->db->fieldExists('waiver_signed', 'event_charges')) {
$this->forge->dropColumn('event_charges', 'waiver_signed');
}
}
}
@@ -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();
}
}
+1
View File
@@ -15,6 +15,7 @@ class EventChargesModel extends Model
'student_id',
'participation',
'charged',
'waiver_signed',
'event_paid',
'event_payment_id',
'class_section_id',
@@ -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() ?>
@@ -28,7 +28,7 @@
<div class="mb-3">
<label class="form-label">Description</label>
<textarea name="description" class="form-control"></textarea>
<textarea id="event_description" name="description" class="form-control"></textarea>
</div>
<div class="mb-3">
@@ -108,17 +108,37 @@
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script src="<?= base_url('assets/tinymce/tinymce.min.js') ?>"></script>
<script>
(function () {
const toggle = document.getElementById('add_to_calendar');
const recipients = document.getElementById('calendar_recipients');
if (!toggle || !recipients) return;
const description = document.getElementById('event_description');
function sync() {
recipients.style.display = toggle.checked ? '' : 'none';
if (toggle && recipients) {
function sync() {
recipients.style.display = toggle.checked ? '' : 'none';
}
toggle.addEventListener('change', sync);
sync();
}
if (description && window.tinymce) {
tinymce.init({
selector: '#event_description',
base_url: '<?= base_url('assets/tinymce') ?>',
suffix: '.min',
license_key: 'gpl',
height: 320,
menubar: true,
branding: false,
promotion: false,
plugins: 'advlist autolink lists link charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime table help wordcount',
toolbar: 'undo redo | blocks | bold italic underline strikethrough forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link table | removeformat | preview code',
convert_urls: false,
content_style: 'body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; font-size: 14px; }'
});
}
toggle.addEventListener('change', sync);
sync();
})();
</script>
<?= $this->endSection() ?>
+76 -3
View File
@@ -4,6 +4,12 @@
<div class="container mt-4">
<h2>Edit Event</h2>
<?php
$defaultCategories = ['Fun Event-1', 'Fun Event-2', 'Fun Event-3'];
$existingCategories = array_map('strval', $categories ?? []);
$allCategories = array_unique(array_merge($defaultCategories, $existingCategories));
?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger"><?= session()->getFlashdata('error') ?></div>
<?php endif; ?>
@@ -23,7 +29,7 @@
<label class="form-label">Category</label>
<select name="event_category" class="form-control" required>
<option value="" disabled <?= empty($event['event_category']) ? 'selected' : '' ?>>Select category</option>
<?php foreach (($categories ?? []) as $category): ?>
<?php foreach ($allCategories as $category): ?>
<option value="<?= esc($category) ?>" <?= (string)($event['event_category'] ?? '') === (string)$category ? 'selected' : '' ?>>
<?= esc(ucwords($category)) ?>
</option>
@@ -33,7 +39,7 @@
<div class="mb-3">
<label class="form-label">Description</label>
<textarea name="description" class="form-control"><?= esc($event['description']) ?></textarea>
<textarea id="event_description" name="description" class="form-control"><?= esc($event['description']) ?></textarea>
</div>
<div class="mb-3">
@@ -44,7 +50,7 @@
<div class="mb-3">
<label class="form-label">Current Flyer</label><br>
<?php if ($event['flyer']): ?>
<img src="<?= base_url('writable/uploads/' . $event['flyer']) ?>" width="150" class="mb-2">
<img src="<?= base_url('uploads/' . ltrim((string) $event['flyer'], '/')) ?>" width="150" class="mb-2">
<?php else: ?>
<div class="text-muted">No flyer uploaded.</div>
<?php endif; ?>
@@ -70,6 +76,37 @@
<input type="text" name="school_year" class="form-control" value="<?= esc($event['school_year']) ?>" required>
</div>
<div class="card mb-3">
<div class="card-header">School Calendar</div>
<div class="card-body">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" value="1">
<label class="form-check-label" for="add_to_calendar">
Add this event to the school calendar
</label>
</div>
<div id="calendar_recipients" class="ms-3" style="display:none;">
<div class="mb-2 fw-semibold">Visible On Calendars</div>
<div class="d-flex flex-wrap gap-3">
<label class="form-check-label">
<input class="form-check-input me-1" type="checkbox" name="notify_parent" value="1" checked>
Parents
</label>
<label class="form-check-label">
<input class="form-check-input me-1" type="checkbox" name="notify_teacher" value="1" checked>
Teachers
</label>
<label class="form-check-label">
<input class="form-check-input me-1" type="checkbox" name="notify_admin" value="1" checked>
Admins
</label>
</div>
<div class="form-text">If none are selected, the event is visible to everyone.</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">Parent Email Broadcast</div>
<div class="card-body">
@@ -89,3 +126,39 @@
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script src="<?= base_url('assets/tinymce/tinymce.min.js') ?>"></script>
<script>
(function () {
const toggle = document.getElementById('add_to_calendar');
const recipients = document.getElementById('calendar_recipients');
const description = document.getElementById('event_description');
if (toggle && recipients) {
function sync() {
recipients.style.display = toggle.checked ? '' : 'none';
}
toggle.addEventListener('change', sync);
sync();
}
if (description && window.tinymce) {
tinymce.init({
selector: '#event_description',
base_url: '<?= base_url('assets/tinymce') ?>',
suffix: '.min',
license_key: 'gpl',
height: 320,
menubar: true,
branding: false,
promotion: false,
plugins: 'advlist autolink lists link charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime table help wordcount',
toolbar: 'undo redo | blocks | bold italic underline strikethrough forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link table | removeformat | preview code',
convert_urls: false,
content_style: 'body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; font-size: 14px; }'
});
}
})();
</script>
<?= $this->endSection() ?>
+356 -253
View File
@@ -3,6 +3,54 @@
<div class="container mt-5">
<h3>Event Charges</h3>
<style>
.event-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.event-card-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.sortable-header {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font-weight: 600;
text-decoration: none;
}
.sortable-header:hover,
.sortable-header:focus {
color: var(--bs-primary);
text-decoration: none;
}
.sortable-header::after {
content: '↕';
font-size: 0.85rem;
opacity: 0.45;
}
.sortable-header.sorted-asc::after {
content: '↑';
opacity: 1;
}
.sortable-header.sorted-desc::after {
content: '↓';
opacity: 1;
}
@media print {
.no-print {
display: none !important;
}
}
</style>
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= session()->getFlashdata('success') ?></div>
@@ -97,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')) ?>
@@ -128,11 +191,12 @@
</div>
<div class="mt-4">
<?php if (!in_array(strtolower((string)(session()->get('role') ?? '')), ['parent', 'teacher', 'teacher_assistant'], true)): ?>
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#externalParticipantModal">
Add non-school participant
</button>
<div class="form-text">After adding participants, click Submit to save them to the database.</div>
<div id="externalParticipantsHidden"></div>
<?php endif; ?>
<div class="form-text">Enrolled students are saved when you click Submit. Non-school participants are saved immediately and will then appear in all browsers.</div>
</div>
<div class="d-flex flex-wrap gap-2 mt-3">
@@ -160,12 +224,52 @@
<?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>
<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
</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">
<?= esc($eventLabel) ?>
<div class="card-header bg-light fw-semibold event-card-header">
<span><?= esc($eventLabel) ?></span>
<div class="event-card-header-actions no-print">
<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
</a>
</div>
</div>
<div class="card-body p-0">
<?php
@@ -173,18 +277,19 @@
$totalCharged = 0.0;
?>
<div class="table-responsive">
<table id="eventTable_<?= esc($eventId) ?>" class="table table-bordered table-striped mb-0 no-mgmt-sticky">
<table id="eventTable_<?= esc($eventId) ?>" class="table table-bordered table-striped mb-0 no-mgmt-sticky event-charges-table">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Parent Name</th>
<th>Student Name</th>
<th>External Info</th>
<th>Class Section</th>
<th>Charged Amount</th>
<th>Created</th>
<th>Fees Paid</th>
<th>Payment</th>
<th><button type="button" class="sortable-header" data-sort-index="0" data-sort-type="number">ID</button></th>
<th><button type="button" class="sortable-header" data-sort-index="1" data-sort-type="text">Parent Name</button></th>
<th><button type="button" class="sortable-header" data-sort-index="2" data-sort-type="text">Student Name</button></th>
<th><button type="button" class="sortable-header" data-sort-index="3" data-sort-type="text">External Info</button></th>
<th><button type="button" class="sortable-header" data-sort-index="4" data-sort-type="text">Class Section</button></th>
<th><button type="button" class="sortable-header" data-sort-index="5" data-sort-type="number">Charged Amount</button></th>
<th><button type="button" class="sortable-header" data-sort-index="6" data-sort-type="date">Created</button></th>
<th><button type="button" class="sortable-header" data-sort-index="7" data-sort-type="text">Waiver</button></th>
<th><button type="button" class="sortable-header" data-sort-index="8" data-sort-type="text">Fees Paid</button></th>
<th><button type="button" class="sortable-header" data-sort-index="9" data-sort-type="text">Payment</button></th>
<th>Actions</th>
</tr>
</thead>
@@ -193,9 +298,25 @@
<?php
$isParticipating = ($charge['participation'] === 'yes');
$isEventPaid = !empty($charge['event_paid']);
$returnParams = [];
if (!empty($semester)) {
$returnParams['semester'] = $semester;
}
if (!empty($school_year)) {
$returnParams['school_year'] = $school_year;
}
if (!empty($filterEventId)) {
$returnParams['event_id'] = $filterEventId;
}
if (!empty($filterParentId)) {
$returnParams['parent_id'] = $filterParentId;
}
$returnTo = site_url('administrator/event-charges')
. (!empty($returnParams) ? '?' . http_build_query($returnParams) : '')
. '#eventTable_' . (int) ($charge['event_id'] ?? 0);
?>
<tr>
<td><?= esc($charge['id']) ?></td>
<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']
? trim($charge['student_firstname'] . ' ' . $charge['student_lastname'])
@@ -211,44 +332,73 @@
($charge['external_parent_lastname'] ?? '')
);
$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([
$infoPhone,
$infoEmail,
$externalNote,
])));
?>
<td><?= esc($parentColumnName) ?></td>
<td>
<td data-sort-value="<?= esc(strtolower($parentColumnName)) ?>"><?= esc($parentColumnName) ?></td>
<td data-sort-value="<?= esc(strtolower($displayName)) ?>">
<?= esc($displayName) ?>
<?php if ($externalNote): ?>
<small class="text-muted d-block"><?= esc($externalNote) ?></small>
<?php endif; ?>
</td>
<td>
<?php if ($externalParentPhone): ?>
<div class="text-muted small"><?= esc($externalParentPhone) ?></div>
<?php else: ?>
<td data-sort-value="<?= esc(strtolower($externalInfoValue)) ?>">
<?php if ($infoPhone): ?>
<div class="text-muted small"><?= esc($infoPhone) ?></div>
<?php endif; ?>
<?php if ($infoEmail): ?>
<div class="text-muted small"><?= esc($infoEmail) ?></div>
<?php endif; ?>
<?php if (!$infoPhone && !$infoEmail): ?>
<span class="text-muted small">—</span>
<?php endif; ?>
</td>
<td>
<td data-sort-value="<?= esc(strtolower((string) ($classSectionNames[$charge['class_section_id'] ?? ''] ?? ''))) ?>">
<?= esc($classSectionNames[$charge['class_section_id'] ?? ''] ?? '—') ?>
</td>
<td>$<?= esc(number_format($charge['event_amount'] ?? 0, 2)) ?></td>
<td><?= esc(!empty($charge['created_at']) ? local_datetime($charge['created_at'], 'm-d-Y H:i') : '') ?></td>
<td data-sort-value="<?= esc(number_format((float) ($charge['event_amount'] ?? 0), 2, '.', '')) ?>">$<?= esc(number_format($charge['event_amount'] ?? 0, 2)) ?></td>
<td data-sort-value="<?= esc((string) ($charge['created_at'] ?? '')) ?>"><?= esc(!empty($charge['created_at']) ? local_datetime($charge['created_at'], 'm-d-Y H:i') : '') ?></td>
<?php
$feeAmount = (float) ($charge['event_amount'] ?? 0);
$hasBalanceRecord = array_key_exists((int)$charge['parent_id'], $parentBalances);
$parentBalance = $hasBalanceRecord ? (float)$parentBalances[$charge['parent_id']] : null;
$waiverSigned = !empty($charge['waiver_signed']);
$feeIsPaid = $isParticipating && ($isEventPaid || ($hasBalanceRecord && $parentBalance <= 0));
?>
<td class="text-center">
<td class="text-center" data-sort-value="<?= $waiverSigned ? 'signed' : 'unsigned' ?>">
<form method="post" action="<?= site_url('administrator/event-charges/waiver/' . esc($charge['id'])) ?>" class="d-inline-flex align-items-center">
<?= csrf_field() ?>
<input type="hidden" name="return_to" value="<?= esc($returnTo) ?>">
<input type="hidden" name="waiver_signed" value="<?= $waiverSigned ? '1' : '0' ?>">
<input type="checkbox" class="form-check-input" id="eventWaiver_<?= esc($charge['id']) ?>"
<?= $waiverSigned ? 'checked' : '' ?>
onchange="this.form.waiver_signed.value = this.checked ? 1 : 0; this.form.submit();">
<label class="form-check-label ms-2" for="eventWaiver_<?= esc($charge['id']) ?>" aria-hidden="true">
<?= $waiverSigned ? 'Signed' : 'Unsigned' ?>
</label>
</form>
</td>
<td class="text-center" data-sort-value="<?= $feeIsPaid ? 'paid' : 'unpaid' ?>">
<?php if ($feeIsPaid): ?>
<span class="badge bg-success">Paid</span>
<?php else: ?>
<span class="badge bg-danger">Unpaid</span>
<?php endif; ?>
</td>
<td class="text-center">
<td class="text-center" data-sort-value="<?= $isEventPaid ? 'paid' : 'unpaid' ?>">
<form method="post" action="<?= site_url('administrator/event-charges/payment/' . esc($charge['id'])) ?>" class="d-inline-flex align-items-center">
<?= csrf_field() ?>
<input type="hidden" name="return_to" value="<?= esc($returnTo) ?>">
<input type="hidden" name="paid" value="<?= $feeIsPaid ? '1' : '0' ?>">
<input type="checkbox" class="form-check-input" id="eventPayment_<?= esc($charge['id']) ?>"
<?= $isEventPaid ? 'checked' : '' ?>
@@ -263,6 +413,7 @@
</a>
<form action="<?= site_url('administrator/event-charges/remove/' . esc($charge['id'])) ?>" method="post" onsubmit="return confirm('Remove this participation and update the charge?');">
<?= csrf_field() ?>
<input type="hidden" name="return_to" value="<?= esc($returnTo) ?>">
<button type="submit" class="btn btn-outline-danger btn-sm">Remove</button>
</form>
</div>
@@ -280,7 +431,7 @@
<tr>
<th colspan="4">Totals</th>
<th>$<?= esc(number_format($totalCharged, 2)) ?></th>
<th colspan="5">
<th colspan="6">
<span class="badge bg-secondary">
<?= esc($totalParticipants) ?> participating
</span>
@@ -292,9 +443,11 @@
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php if (!in_array(strtolower((string)(session()->get('role') ?? '')), ['parent', 'teacher', 'teacher_assistant'], true)): ?>
<div class="modal fade" id="externalParticipantModal" tabindex="-1" aria-labelledby="externalParticipantModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
@@ -349,11 +502,30 @@
</div>
</div>
</div>
<?php endif; ?>
<?= $this->endSection() ?>
<?= $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();
@@ -426,174 +598,11 @@ function loadStudentsWithCharges() {
loadStudentsWithCharges();
}
const $externalHidden = $('#externalParticipantsHidden');
const storageKey = 'eventChargesExternalParticipants';
let externalDrafts = [];
let editingKey = null;
const escapeHtml = (value) => {
const s = String(value ?? '');
return s.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
function buildExternalRow(entry) {
const key = entry.key;
const isPaid = !!entry.paid;
const firstEsc = escapeHtml(entry.firstname);
const lastEsc = escapeHtml(entry.lastname);
const noteEsc = escapeHtml(entry.note);
const parentFirstEsc = escapeHtml(entry.parentFirstname);
const parentLastEsc = escapeHtml(entry.parentLastname);
const parentPhoneEsc = escapeHtml(entry.parentPhone);
const parentEmailEsc = escapeHtml(entry.parentEmail);
const label = (firstEsc || lastEsc)
? `${firstEsc || ''} ${lastEsc || ''}`.trim()
: 'Unnamed participant';
const parentLabel = (parentFirstEsc || parentLastEsc)
? `<div class="text-muted small">Parent: ${parentFirstEsc} ${parentLastEsc}</div>`
: '';
const phoneLabel = parentPhoneEsc
? `<div class="text-muted small">${parentPhoneEsc}</div>`
: '';
const parentNameForRow = entry.parentDisplayName || '—';
const amountDisplay = ((entry.eventAmount ?? 0) || 0.0).toFixed(2);
const $wrapper = $(`
<div data-key="${key}">
<input type="hidden" name="external_participants[${key}][firstname]" value="${firstEsc}">
<input type="hidden" name="external_participants[${key}][lastname]" value="${lastEsc}">
<input type="hidden" name="external_participants[${key}][note]" value="${noteEsc}">
<input type="hidden" name="external_participants[${key}][parent_firstname]" value="${parentFirstEsc}">
<input type="hidden" name="external_participants[${key}][parent_lastname]" value="${parentLastEsc}">
<input type="hidden" name="external_participants[${key}][parent_phone]" value="${parentPhoneEsc}">
<input type="hidden" name="external_participants[${key}][parent_email]" value="${parentEmailEsc}">
<input type="hidden" name="external_participants[${key}][paid]" value="${isPaid ? '1' : '0'}">
</div>
`);
$externalHidden.append($wrapper);
const $tableBody = $(`#eventTable_${entry.eventId} tbody`);
if (!$tableBody.length) {
return;
}
const createdDisplay = new Date().toLocaleString();
const $previewRow = $(`
<tr class="external-preview table-warning" data-key="${key}" data-event-id="${entry.eventId}">
<td>—</td>
<td>${escapeHtml(parentNameForRow)}</td>
<td>
<strong>${label}</strong>
${noteEsc ? `<div class="text-muted small">${noteEsc}</div>` : ''}
</td>
<td>
${phoneLabel}
${phoneLabel ? '' : '<span class="text-muted small">—</span>'}
</td>
<td>External</td>
<td>$${amountDisplay}</td>
<td>${createdDisplay}</td>
<td class="text-center">
<span class="badge bg-warning text-dark">Pending</span>
${isPaid ? '<span class="badge bg-success ms-1 external-paid-badge">Paid on save</span>' : '<span class="badge bg-secondary ms-1 external-paid-badge d-none">Paid on save</span>'}
</td>
<td class="text-center">
<div class="form-check d-inline-flex align-items-center gap-2 justify-content-center m-0">
<input class="form-check-input external-paid-toggle" type="checkbox" data-key="${key}" ${isPaid ? 'checked' : ''}>
<span class="small text-muted">On save</span>
</div>
</td>
<td class="text-end d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm edit-external-participant" data-key="${key}">
Edit
</button>
<button type="button" class="btn btn-outline-danger btn-sm remove-external-participant" data-key="${key}">
Remove
</button>
</td>
</tr>
`);
$tableBody.append($previewRow);
}
function persistDrafts() {
localStorage.setItem(storageKey, JSON.stringify(externalDrafts));
}
function saveExternalDraft(entry) {
entry.key = entry.key ?? `${Date.now()}_${Math.random().toString(36).slice(2)}`;
entry.paid = !!entry.paid;
removeExternalEntryFromDom(entry.key);
externalDrafts = externalDrafts.filter((existing) => existing.key !== entry.key);
externalDrafts.push(entry);
persistDrafts();
buildExternalRow(entry);
}
function removeExternalEntryFromDom(key) {
$(`tr.external-preview[data-key="${key}"]`).remove();
$externalHidden.find(`div[data-key="${key}"]`).remove();
}
function removeDraft(key) {
externalDrafts = externalDrafts.filter((entry) => entry.key !== key);
persistDrafts();
}
function loadDrafts() {
const raw = localStorage.getItem(storageKey);
if (!raw) {
return;
}
try {
const saved = JSON.parse(raw);
if (!Array.isArray(saved)) {
return;
}
externalDrafts = saved;
const currentEvent = $('#event_id').val();
externalDrafts.forEach((entry) => {
if (String(entry.eventId) === String(currentEvent)) {
buildExternalRow(entry);
}
});
} catch (e) {
console.error('Failed to load external drafts', e);
}
}
function getDraftByKey(key) {
return externalDrafts.find((entry) => entry.key === key);
}
function openExternalModalForEntry(key) {
const entry = getDraftByKey(key);
if (!entry) {
return;
}
editingKey = key;
$('#modalExternalFirstName').val(entry.firstname);
$('#modalExternalLastName').val(entry.lastname);
$('#modalExternalNote').val(entry.note);
$('#modalParentFirstName').val(entry.parentFirstname);
$('#modalParentLastName').val(entry.parentLastname);
$('#modalParentPhone').val(entry.parentPhone);
$('#modalParentEmail').val(entry.parentEmail);
if (modalInstance) {
modalInstance.show();
}
}
const $externalSaveButton = $('#externalParticipantSave');
const modalElement = document.getElementById('externalParticipantModal');
const modalInstance = modalElement ? new bootstrap.Modal(modalElement) : null;
if (modalElement) {
modalElement.addEventListener('hidden.bs.modal', () => {
editingKey = null;
});
}
function clearModalInputs() {
$('#modalExternalFirstName').val('');
$('#modalExternalLastName').val('');
@@ -604,6 +613,154 @@ function loadStudentsWithCharges() {
$('#modalParentEmail').val('');
}
function appendHiddenField(form, name, value) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = value;
form.appendChild(input);
}
function submitExternalParticipant(payload) {
const form = document.createElement('form');
form.method = 'post';
form.action = '<?= site_url('payment/event_charges') ?>';
appendHiddenField(form, '<?= csrf_token() ?>', '<?= csrf_hash() ?>');
appendHiddenField(form, 'school_year', '<?= esc($school_year) ?>');
appendHiddenField(form, 'semester', '<?= esc($semester) ?>');
appendHiddenField(form, 'event_id', payload.eventId);
appendHiddenField(form, 'external_participants[new][firstname]', payload.firstname);
appendHiddenField(form, 'external_participants[new][lastname]', payload.lastname);
appendHiddenField(form, 'external_participants[new][note]', payload.note);
appendHiddenField(form, 'external_participants[new][parent_firstname]', payload.parentFirstname);
appendHiddenField(form, 'external_participants[new][parent_lastname]', payload.parentLastname);
appendHiddenField(form, 'external_participants[new][parent_phone]', payload.parentPhone);
appendHiddenField(form, 'external_participants[new][parent_email]', payload.parentEmail);
appendHiddenField(form, 'external_participants[new][waiver_signed]', '0');
appendHiddenField(form, 'external_participants[new][paid]', '0');
document.body.appendChild(form);
form.submit();
}
function getSortValue(row, columnIndex, sortType) {
const cell = row.children[columnIndex];
if (!cell) {
return sortType === 'number' ? 0 : '';
}
const rawValue = cell.getAttribute('data-sort-value');
const fallbackValue = (cell.textContent || '').trim();
const value = rawValue !== null ? rawValue : fallbackValue;
if (sortType === 'number') {
const parsed = parseFloat(String(value).replace(/[^0-9.-]/g, ''));
return Number.isNaN(parsed) ? 0 : parsed;
}
if (sortType === 'date') {
const time = Date.parse(value);
return Number.isNaN(time) ? 0 : time;
}
return String(value).toLowerCase();
}
function updateSortIndicators(table, columnIndex, direction) {
$(table).find('.sortable-header').each(function() {
const isCurrent = Number($(this).data('sortIndex')) === Number(columnIndex);
$(this)
.toggleClass('sorted-asc', isCurrent && direction === 'asc')
.toggleClass('sorted-desc', isCurrent && direction === 'desc')
.attr('aria-sort', isCurrent ? direction : 'none');
});
}
function sortEventTable(table, columnIndex, sortType, direction) {
if (!table || !table.tBodies.length) {
return;
}
const tbody = table.tBodies[0];
const rows = Array.from(tbody.rows);
rows.sort((a, b) => {
const first = getSortValue(a, columnIndex, sortType);
const second = getSortValue(b, columnIndex, sortType);
if (first < second) {
return direction === 'asc' ? -1 : 1;
}
if (first > second) {
return direction === 'asc' ? 1 : -1;
}
return 0;
});
rows.forEach((row) => tbody.appendChild(row));
table.dataset.sortColumn = String(columnIndex);
table.dataset.sortType = sortType;
table.dataset.sortDirection = direction;
updateSortIndicators(table, columnIndex, direction);
}
function applyActiveSort(table) {
if (!table || !table.dataset || !table.dataset.sortColumn) {
return;
}
sortEventTable(
table,
Number(table.dataset.sortColumn),
table.dataset.sortType || 'text',
table.dataset.sortDirection || 'asc'
);
}
function getTableRowOrder(table) {
if (!table || !table.tBodies.length) {
return [];
}
return Array.from(table.tBodies[0].rows)
.map((row) => String(row.dataset.chargeId || '').trim())
.filter(Boolean);
}
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() {
const $button = $(this);
const table = $button.closest('table').get(0);
const columnIndex = Number($button.data('sortIndex'));
const sortType = String($button.data('sortType') || 'text');
const currentColumn = Number(table.dataset.sortColumn);
const currentDirection = table.dataset.sortDirection || 'asc';
const direction = currentColumn === columnIndex && currentDirection === 'asc' ? 'desc' : 'asc';
sortEventTable(table, columnIndex, sortType, direction);
});
$('.pdf-print-link').on('click', function() {
this.href = buildPdfUrl(this);
});
$('#externalParticipantSave').on('click', function() {
const firstName = capitalizeName($('#modalExternalFirstName').val().trim());
const lastName = capitalizeName($('#modalExternalLastName').val().trim());
@@ -621,7 +778,6 @@ function loadStudentsWithCharges() {
alert('Select an event before adding a non-school participant.');
return;
}
const eventAmount = parseFloat($('#event_id option:selected').data('amount')) || 0;
const explicitParentName = `${parentFirst} ${parentLast}`.trim();
if (!explicitParentName) {
alert('Please enter the external kid parent name.');
@@ -631,9 +787,7 @@ function loadStudentsWithCharges() {
alert('Please enter the external kid parent email.');
return;
}
const parentName = explicitParentName;
const draftEntry = editingKey ? getDraftByKey(editingKey) : null;
const entry = {
submitExternalParticipant({
firstname: firstName,
lastname: lastName,
note: note,
@@ -641,61 +795,10 @@ function loadStudentsWithCharges() {
parentLastname: parentLast,
parentPhone: parentPhone,
parentEmail: parentEmail,
eventId: (draftEntry && draftEntry.eventId) ? draftEntry.eventId : eventId,
eventAmount: (draftEntry && draftEntry.eventAmount) ? draftEntry.eventAmount : eventAmount,
parentDisplayName: (draftEntry && draftEntry.parentDisplayName) ? draftEntry.parentDisplayName : parentName,
paid: (draftEntry && typeof draftEntry.paid !== 'undefined') ? !!draftEntry.paid : false,
};
if (editingKey) {
entry.key = editingKey;
}
saveExternalDraft(entry);
editingKey = null;
eventId: eventId
});
clearModalInputs();
if (modalInstance) {
modalInstance.hide();
}
});
$(document).on('click', '.remove-external-participant', function() {
const $entry = $(this).closest('[data-key]');
const key = $entry.data('key');
removeExternalEntryFromDom(key);
if (key) {
removeDraft(key);
}
});
$(document).on('click', '.edit-external-participant', function() {
const key = $(this).data('key');
if (key) {
openExternalModalForEntry(key);
}
});
$(document).on('change', '.external-paid-toggle', function() {
const key = $(this).data('key');
const paid = !!this.checked;
if (!key) {
return;
}
const entry = getDraftByKey(key);
if (entry) {
entry.paid = paid;
persistDrafts();
}
$externalHidden
.find(`div[data-key="${key}"] input[name="external_participants[${key}][paid]"]`)
.val(paid ? '1' : '0');
const $badge = $(`tr.external-preview[data-key="${key}"] .external-paid-badge`);
if (paid) {
$badge.removeClass('d-none').addClass('bg-success').text('Paid on save');
} else {
$badge.addClass('d-none');
}
});
$('#event-participant-form').on('submit', function() {
localStorage.removeItem(storageKey);
});
loadDrafts();
});
</script>
<?= $this->endSection() ?>
@@ -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>
+1 -1
View File
@@ -16,7 +16,7 @@ $amount = ($amountRaw !== null && $amountRaw !== '') ? number_format((float) $am
<p style="margin:0.25rem 0;"><strong>Amount:</strong> $<?= esc($amount) ?></p>
<?php endif; ?>
<?php if (!empty($event['description'])): ?>
<p style="margin:0.75rem 0;"><?= nl2br(esc((string) $event['description'])) ?></p>
<div style="margin:0.75rem 0;"><?= $event['description'] ?></div>
<?php endif; ?>
<?php if (!empty($flyerUrl)): ?>
<div style="margin:1rem 0;">
+50 -8
View File
@@ -24,6 +24,7 @@
</div>
<?php else: ?>
<?php foreach ($activeEvents as $event): ?>
<?php $externalParticipants = $externalParticipantsByEvent[$event['id']] ?? []; ?>
<div class="card mb-4">
<div class="card-body">
@@ -42,6 +43,53 @@
<?php endif; ?>
</div>
<div class="mb-3">
<h6 class="mb-1">Description</h6>
<?php if (!empty($event['description'])): ?>
<div class="mb-0"><?= $event['description'] ?></div>
<?php else: ?>
<p class="mb-0">No description</p>
<?php endif; ?>
</div>
<?php if (!empty($externalParticipants)): ?>
<div class="mb-3">
<h6 class="mb-2">External Participants</h6>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>Student Name</th>
<th>Status</th>
<th>Fee</th>
</tr>
</thead>
<tbody>
<?php foreach ($externalParticipants as $participant): ?>
<?php
$isPaid = !empty($participant['event_paid']) || ((float) ($participant['charged'] ?? 0) <= 0);
?>
<tr>
<td>
<?= esc($participant['name']) ?> <span class="text-muted">(external)</span>
<?php if (!empty($participant['note'])): ?>
<small class="text-muted d-block"><?= esc($participant['note']) ?></small>
<?php endif; ?>
</td>
<td>
<span class="badge bg-<?= $isPaid ? 'success' : 'danger' ?>">
<?= $isPaid ? 'Paid' : 'Unpaid' ?>
</span>
</td>
<td class="text-nowrap">$<?= esc(number_format((float) ($participant['charged'] ?? 0), 2)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- Participation Table -->
<form method="post" action="<?= site_url('parent/updateParticipation') ?>">
<?= csrf_field() ?>
@@ -51,11 +99,8 @@
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Student First Name</th>
<th>Student Last Name</th>
<th>Student Name</th>
<th class="text-center">Participate</th>
<th>Description</th>
<th>Event Fees</th>
</tr>
</thead>
<tbody>
@@ -65,8 +110,7 @@
$current = $charges[$key]['participation'] ?? '';
?>
<tr>
<td><?= esc($student['firstname']) ?></td>
<td><?= esc($student['lastname']) ?></td>
<td><?= esc(trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''))) ?></td>
<td class="text-center align-middle">
<div class="d-inline-flex align-items-center gap-3">
<div class="form-check">
@@ -83,8 +127,6 @@
</div>
</div>
</td>
<td><?= esc($event['description'] ?: 'No description') ?></td>
<td class="text-nowrap">$<?= esc(number_format((float) ($event['amount'] ?? 0), 2)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
+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>
+6 -1
View File
@@ -89,12 +89,14 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
};
?>
<h2 class="h5 mb-3">Your submissions</h2>
<h2 class="h5 mb-1">Visible submissions</h2>
<p class="text-muted small">Includes your uploads and submitted exam drafts from teachers assigned to the same class-section.</p>
<?php if (!empty($drafts)): ?>
<div class="table-responsive">
<table class="table table-striped align-middle teacher-drafts-table">
<thead>
<tr>
<th>Teacher</th>
<th>Class</th>
<th>Title / type</th>
<th>Ver.</th>
@@ -111,6 +113,7 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
$badgeHtml = $renderBadge($st, $statusBadges);
?>
<tr data-draft-id="<?= (int) ($d['id'] ?? 0) ?>">
<td><?= esc($d['teacher_display_name'] ?? 'Teacher') ?></td>
<td><?= esc($d['class_section_name'] ?? '') ?></td>
<td class="title-cell"><?= esc($d['draft_title'] ?? $d['exam_type'] ?? '') ?></td>
<td><?= esc((string) ($d['version'] ?? '')) ?></td>
@@ -168,6 +171,7 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
<table class="table table-sm table-striped align-middle teacher-drafts-table">
<thead>
<tr>
<th>Teacher</th>
<th>Class</th>
<th>Title</th>
<th>Ver.</th>
@@ -183,6 +187,7 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
<?php foreach ($legacyExams as $d): ?>
<?php $st = strtolower((string) ($d['status'] ?? '')); ?>
<tr>
<td><?= esc($d['teacher_display_name'] ?? 'Teacher') ?></td>
<td><?= esc($d['class_section_name'] ?? '') ?></td>
<td class="title-cell"><?= esc($d['draft_title'] ?? $d['exam_type'] ?? '') ?></td>
<td><?= esc((string) ($d['version'] ?? '')) ?></td>
+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.