Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 239d35f8ff | |||
| 3333e71c94 | |||
| af75475214 | |||
| a8ef665239 | |||
| 7c97a60795 | |||
| acca87c77b | |||
| 9aacbe8972 | |||
| 76866ffe1e | |||
| b01a4496e6 | |||
| 1f51be91e4 | |||
| 92016f90d0 | |||
| 45d7895182 | |||
| 596d368b1d |
@@ -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');
|
||||
|
||||
@@ -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
@@ -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']);
|
||||
|
||||
/*
|
||||
* --------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
?>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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() ?>
|
||||
@@ -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 & 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() ?>
|
||||
|
||||
@@ -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() ?>
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
·
|
||||
<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() ?>
|
||||
@@ -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 & 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 ≥ <strong><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></strong></span>
|
||||
<span class="text-muted">Year ≥ <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 —
|
||||
Predicted: <?= $totalPredicted ?> —
|
||||
Actual: <?= $totalActual ?> —
|
||||
Confirmed: <?= $totalConfirmed ?> —
|
||||
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() ?>
|
||||
@@ -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) ?> — <?= (int)$selectedPercentile ?>th percentile —
|
||||
<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 —
|
||||
<?= $totalWinners ?> winners / <?= $totalStudents ?> students —
|
||||
<?= 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 ≥ <?= 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() ?>
|
||||
@@ -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">
|
||||
© <?= date('Y') ?> Al Rahma Sunday School
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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.
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 |
@@ -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`)
|
||||
);
|
||||
@@ -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.
@@ -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.
Reference in New Issue
Block a user