Compare commits

...

17 Commits

Author SHA1 Message Date
root c294d7bed7 fix logo 2026-05-26 23:14:55 -04:00
root 5ed65a867c report cadr and trophy 2026-05-26 05:11:10 -04:00
root 22d6f960ea move qr code inside the certificate 2026-05-26 03:09:48 -04:00
root cc0b83c1b9 fix attendance spring days 2026-05-26 03:03:03 -04:00
root 31976752da fix font 2026-05-26 02:13:39 -04:00
root 0bf8d13777 fix certificates 2026-05-26 01:44:57 -04:00
root 4ae62e37a3 test new server 2026-05-22 00:58:43 -04:00
root d8fdbeb4c3 fix certificate qr 2026-05-21 18:37:39 -04:00
root 239d35f8ff fix the event print and certificate qr 2026-05-21 17:00:38 -04:00
root 3333e71c94 add qr code to certificate 2026-05-17 17:24:07 -04:00
root af75475214 add certificate generation 2026-05-17 17:04:47 -04:00
root a8ef665239 fix event parent list 2026-05-16 16:03:46 -04:00
root 7c97a60795 add trophy prediction and treshold 2026-05-16 15:35:56 -04:00
root acca87c77b fix tracking attendance 2026-05-09 15:51:34 -04:00
root 9aacbe8972 fix draft exam upload 2026-05-08 00:33:40 -04:00
root 76866ffe1e update login scurity 2026-05-08 00:17:42 -04:00
root b01a4496e6 fixexam files 2026-05-06 18:45:13 -04:00
100 changed files with 6636 additions and 465 deletions
+3 -3
View File
@@ -36,13 +36,13 @@ class SyncPaypalPayments extends BaseCommand
$this->invoiceModel = new InvoiceModel();
$this->studentModel = new StudentModel();
$this->enrollmentModel = new EnrollmentModel();
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
}
public function run(array $params)
{
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$dryRun = CLI::getOption('dry-run');
$reportOnly = CLI::getOption('report-only');
$mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE');
+2
View File
@@ -9,6 +9,7 @@ use App\Listeners\SchoolEventListener;
use App\Listeners\AttendanceConsequenceListener;
use App\Listeners\WhatsappInviteListener;
use App\Listeners\BelowSixtyEmailListener;
use App\Listeners\DecisionEmailListener;
// Create an instance so we can use $this->emailService like your other handlers
$waListener = new WhatsappInviteListener(service('emailService'));
@@ -118,6 +119,7 @@ Events::on('attendance.follow_up', [AttendanceConsequenceListener::class, 'fol
Events::on('attendance.final_warning', [AttendanceConsequenceListener::class, 'finalWarning']);
Events::on('attendance.dismissal', [AttendanceConsequenceListener::class, 'dismissal']);
Events::on('below60.email', [BelowSixtyEmailListener::class, 'handle']);
Events::on('below60.decision_email', [DecisionEmailListener::class, 'handle']);
//Whatsapp Event listener
Events::on('whatsapp_invites.send', [$waListener, 'handle']);
+6 -1
View File
@@ -24,6 +24,8 @@ class Filters extends BaseConfig
'honeypot' => Honeypot::class,
'invalidchars' => InvalidChars::class,
'secureheaders' => SecureHeaders::class,
'sanitizeinput' => \App\Filters\SanitizeInputFilter::class,
'apiratelimit' => \App\Filters\ApiRateLimitFilter::class,
'auth' => \App\Filters\AuthFilter::class, // Define the alias for your auth filter
'apiAuth' => \App\Filters\ApiAuthFilter::class, // JWT-based API authentication
'cleanupScheduler' => \App\Filters\CleanupScheduler::class,
@@ -42,6 +44,8 @@ class Filters extends BaseConfig
public array $globals = [
'before' => [
'timezone',
'sanitizeinput',
'invalidchars',
'csrf' => ['except' => [
// Webhooks / integrations
'api/paypal-webhook',
@@ -80,6 +84,7 @@ class Filters extends BaseConfig
]],
],
'after' => [
'secureheaders',
'toolbar',
'cleanupScheduler' => ['except' => ['cleanup/*']],
],
@@ -112,7 +117,7 @@ class Filters extends BaseConfig
* @var array<string, array<string, list<string>>>
*/
public array $filters = [
'apiratelimit' => ['before' => ['api/*', 'index.php/api/*']],
];
}
+26 -2
View File
@@ -174,6 +174,18 @@ $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->get('administrator/certificates/csrf-token', 'View\CertificateController::csrfToken', ['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('administrator/certificates/reprint/(:any)', 'View\CertificateController::reprint/$1', ['filter' => 'auth:read']);
$routes->get('verify/(:segment)', 'View\CertificateController::verify/$1');
/*
@@ -393,8 +405,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']);
@@ -511,6 +523,14 @@ $routes->post('grading/below-60/email', 'View\GradingController::sendBelowSixtyE
$routes->post('grading/below-60/status', 'View\GradingController::updateBelowSixtyStatus', ['filter' => 'auth:read']);
$routes->get('grading/below-60/schedule', 'View\GradingController::scheduleBelowSixty', ['filter' => 'auth:read']);
$routes->post('grading/below-60/schedule', 'View\GradingController::saveBelowSixtyMeeting', ['filter' => 'auth:read']);
$routes->get('grading/decisions', 'View\GradingController::allDecisions', ['filter' => 'auth:read']);
$routes->post('grading/decisions/generate', 'View\GradingController::generateAllDecisions', ['filter' => 'auth:read']);
$routes->get('grading/below-60/decisions', 'View\GradingController::belowSixtyDecisions', ['filter' => 'auth:read']);
$routes->post('grading/below-60/decisions/save', 'View\GradingController::saveBelowSixtyDecision', ['filter' => 'auth:read']);
$routes->get('grading/below-60/decisions/student-details', 'View\GradingController::studentDecisionDetails', ['filter' => 'auth:read']);
$routes->get('grading/below-60/decisions/email/preview', 'View\GradingController::previewDecisionEmail', ['filter' => 'auth:read']);
$routes->get('grading/below-60/decisions/email/edit', 'View\GradingController::editDecisionEmail', ['filter' => 'auth:read']);
$routes->post('grading/below-60/decisions/email', 'View\GradingController::sendDecisionEmail', ['filter' => 'auth:read']);
// Final part
@@ -535,6 +555,7 @@ $routes->post('payment/event_charges', 'View\EventController::eventUpdate');
// Parent event participation
$routes->get('administrator/event-charges', 'View\EventController::eventShow');
$routes->get('administrator/event-charges/pdf', 'View\EventController::eventChargesPdf');
$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1');
$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1');
$routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1');
@@ -745,6 +766,9 @@ $routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorCo
$routes->get('/administrator/exam-drafts', 'View\ExamDraftController::adminIndex', ['filter' => 'auth:admin']);
$routes->post('/administrator/exam-drafts/review', 'View\ExamDraftController::adminReview', ['filter' => 'auth:admin']);
$routes->post('/administrator/exam-drafts/upload-legacy', 'View\ExamDraftController::adminUploadLegacy', ['filter' => 'auth:admin']);
$routes->get('/principal/exam-drafts', 'View\ExamDraftController::principalIndex', ['filter' => 'auth:admin']);
$routes->post('/principal/exam-drafts/review', 'View\ExamDraftController::principalReview', ['filter' => 'auth:admin']);
$routes->post('/principal/exam-drafts/upload-legacy', 'View\ExamDraftController::principalUploadLegacy', ['filter' => 'auth:admin']);
/*
* --------------------------------------------------------------------
+1 -1
View File
@@ -36,7 +36,7 @@ class Services extends BaseService
/**
* Override CI Email service to enforce a global Reply-To.
*/
public static function email(array $config = null, bool $getShared = true)
public static function email(?array $config = null, bool $getShared = true)
{
if ($getShared) {
return static::getSharedInstance('email', $config);
+49 -29
View File
@@ -5,7 +5,6 @@ namespace App\Controllers;
use App\Models\LoginActivityModel;
use App\Models\UserModel;
use App\Models\UserRoleModel;
use CodeIgniter\Controller;
use CodeIgniter\Events\Events;
use App\Models\IpAttemptModel;
use App\Models\PasswordResetModel;
@@ -19,7 +18,7 @@ require_once APPPATH . 'Helpers/pbkdf2_helper.php';
require_once APPPATH . 'Helpers/jwt_helper.php';
class AuthController extends Controller
class AuthController extends BaseController
{
protected $configModel;
protected $userModel;
@@ -83,9 +82,23 @@ class AuthController extends Controller
log_message('info', 'Processing login form submission.');
$redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? $this->request->getGet('redirect_to') ?? ''));
$validator = \Config\Services::validation();
$requestData = sanitize_request_value($this->request->getPost(['email', 'password']));
$validator->setRules([
'email' => 'required|valid_email|max_length[254]',
'password' => 'required|max_length[255]',
]);
if (! $validator->run($requestData)) {
return redirect()
->back()
->with('error', 'Please enter a valid email and password.')
->withInput();
}
// Step 1: Get email, password, and IP from the request
$email = $this->request->getPost('email');
$password = $this->request->getPost('password');
$email = strtolower((string) $requestData['email']);
$password = (string) $requestData['password'];
$ip = $this->request->getIPAddress();
log_message('info', 'Login attempt from IP: ' . $ip . ' for email: ' . $email);
@@ -139,26 +152,29 @@ class AuthController extends Controller
// JSON API: POST /api/login
public function apiLogin()
{
$requestData = $this->request->getJSON(true);
if (!$requestData) {
// fallback to form vars
$requestData = [
'email' => $this->request->getPost('email'),
'password' => $this->request->getPost('password'),
];
}
$requestData = sanitize_request_value($this->request->getJSON(true) ?: [
'email' => $this->request->getPost('email'),
'password' => $this->request->getPost('password'),
]);
$email = $requestData['email'] ?? '';
$password = $requestData['password'] ?? '';
$ip = $this->request->getIPAddress();
$validation = \Config\Services::validation();
$validation->setRules([
'email' => 'required|valid_email|max_length[254]',
'password' => 'required|max_length[255]',
]);
if (!$email || !$password) {
return $this->response->setStatusCode(400)->setJSON([
if (! $validation->run($requestData)) {
return $this->response->setStatusCode(422)->setJSON([
'status' => false,
'message' => 'Email and password are required.'
'message' => 'Validation failed.',
'errors' => $validation->getErrors(),
]);
}
$email = strtolower((string) ($requestData['email'] ?? ''));
$password = (string) ($requestData['password'] ?? '');
$ip = $this->request->getIPAddress();
if ($this->isIpBlocked($ip)) {
return $this->response->setStatusCode(429)->setJSON([
'status' => false,
@@ -220,7 +236,15 @@ class AuthController extends Controller
'exp' => $exp,
];
$secret = env('JWT_SECRET', 'change-me-in-env');
try {
$secret = require_env('JWT_SECRET');
} catch (\RuntimeException $e) {
return $this->response->setStatusCode(500)->setJSON([
'status' => false,
'message' => 'JWT configuration is missing.',
]);
}
$token = jwt_encode($payload, $secret, 'HS256');
return $this->response->setJSON([
@@ -240,18 +264,14 @@ class AuthController extends Controller
*/
public function apiRegister()
{
$requestData = $this->request->getJSON(true);
if (!$requestData) {
// fallback to form vars
$requestData = $this->request->getPost();
}
$requestData = sanitize_request_value($this->request->getJSON(true) ?: $this->request->getPost());
// Basic validation
$rules = [
'firstname' => 'required|min_length[2]|max_length[30]',
'lastname' => 'required|min_length[2]|max_length[30]',
'email' => 'required|valid_email|is_unique[users.email]',
'password' => 'required|min_length[8]',
'firstname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]",
'lastname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]",
'email' => 'required|valid_email|max_length[254]|is_unique[users.email]',
'password' => 'required|min_length[8]|max_length[255]',
'cellphone' => 'required|min_length[10]|max_length[20]',
];
@@ -332,7 +352,7 @@ class AuthController extends Controller
'exp' => $exp,
];
$secret = env('JWT_SECRET', 'change-me-in-env');
$secret = require_env('JWT_SECRET');
$token = jwt_encode($payload, $secret, 'HS256');
return $this->response->setStatusCode(201)->setJSON([
+17 -2
View File
@@ -36,7 +36,7 @@ abstract class BaseController extends Controller
*
* @var list<string>
*/
protected $helpers = [];
protected $helpers = ['security'];
/** @var ApiClient */
protected ApiClient $api;
@@ -73,6 +73,21 @@ abstract class BaseController extends Controller
// Assuming the user role is stored in the session
return session()->get('role');
}
protected function validated(array $rules, ?array $data = null): array
{
$validation = service('validation');
$payload = $data ?? ($this->request->getJSON(true) ?: $this->request->getPost());
$payload = sanitize_request_value($payload);
$validation->setRules($rules);
if (! $validation->run($payload)) {
throw new \InvalidArgumentException(json_encode($validation->getErrors(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: 'Validation failed');
}
return $payload;
}
}
?>
?>
+10 -3
View File
@@ -20,15 +20,22 @@ class ProofreadController extends ResourceController
}
// Accept form-urlencoded payload to play nicely with CSRF protection
$text = (string) ($this->request->getPost('text') ?? '');
if ($text === '' || mb_strlen($text) > 20000) {
$validation = service('validation');
$payload = sanitize_request_value($this->request->getPost(['text']));
$validation->setRules([
'text' => 'required|min_length[1]|max_length[20000]',
]);
if (! $validation->run($payload)) {
return $this->respond([
'ok' => false,
'error' => 'Invalid text (empty or too long).',
'error' => 'Invalid text payload.',
'csrfHash' => csrf_hash(),
], 422);
}
$text = (string) $payload['text'];
$client = \Config\Services::curlrequest(['timeout' => 10]);
try {
@@ -2003,6 +2003,58 @@ class AttendanceTrackingController extends BaseController
return [$subject, $body];
}
private function buildFallbackTemplate(string $code, string $variant, array $context): array
{
$studentName = (string) ($context['{{student_name}}'] ?? 'the student');
$incident = (string) ($context['{{incident_date}}'] ?? date('Y-m-d'));
$parentName = (string) ($context['{{parent_name}}'] ?? 'Parent/Guardian');
$phone = (string) ($context['{{school_phone}}'] ?? 'the school office');
$voicemail = (string) ($context['{{voicemail_phone}}'] ?? 'your voicemail');
$subject = match (true) {
str_starts_with($code, 'ABS_1') => 'Attendance Notice: Unreported Absence',
str_starts_with($code, 'ABS_2') => 'Attendance Follow-Up: Two Consecutive Absences',
str_starts_with($code, 'ABS_3') => 'Attendance Follow-Up: Three Absences',
str_starts_with($code, 'LATE_2') => 'Attendance Notice: Repeated Lateness',
str_starts_with($code, 'LATE_3'),
str_starts_with($code, 'LATE_4'),
str_starts_with($code, 'MIX') => 'Attendance Follow-Up: Repeated Lateness and Absence',
default => 'Attendance Notice',
};
$intro = "<p>Insha Allah this email finds you well";
if ($variant === 'answered') {
$intro .= ", <strong>{$parentName}</strong>. Jazakum Allahu khayran for taking the time to speak with us today.</p>";
} elseif ($variant === 'no_answer') {
$intro .= ".</p><p>We tried to reach you but could not connect and left a voice message at <strong>{$voicemail}</strong>.</p>";
} else {
$intro .= ".</p>";
}
$details = match (true) {
str_starts_with($code, 'ABS_1')
=> "<p>This is to let you know that <strong>{$studentName}</strong> was absent on <strong>{$incident}</strong> without prior notice.</p>",
str_starts_with($code, 'ABS_2')
=> "<p>This is a reminder that <strong>{$studentName}</strong> has had repeated absences, most recently on <strong>{$incident}</strong>, without prior notice.</p>",
str_starts_with($code, 'ABS_3')
=> "<p>This is a reminder that <strong>{$studentName}</strong> has been absent three times, most recently on <strong>{$incident}</strong>, without prior notice.</p>",
str_starts_with($code, 'LATE_2')
=> "<p>This is a reminder that <strong>{$studentName}</strong> has been late multiple times, most recently on <strong>{$incident}</strong>.</p>",
str_starts_with($code, 'LATE_3'),
str_starts_with($code, 'LATE_4')
=> "<p>This is a follow-up that <strong>{$studentName}</strong> has had repeated lateness concerns, most recently on <strong>{$incident}</strong>.</p>",
str_starts_with($code, 'MIX')
=> "<p>This is a follow-up that <strong>{$studentName}</strong> has had a mix of repeated lateness and absence concerns, most recently on <strong>{$incident}</strong>.</p>",
default
=> "<p>We'd like to inform you about <strong>{$studentName}</strong>'s recent attendance concern dated <strong>{$incident}</strong>.</p>",
};
$closing = "<p>If your child will be absent or late due to illness or family commitments, please let us know ahead of time.</p>"
. "<p>You can email/call/text us at <strong>{$phone}</strong>.</p>";
return [$subject, $intro . $details . $closing];
}
public function compose()
{
@@ -2033,8 +2085,8 @@ class AttendanceTrackingController extends BaseController
$rendered = $this->renderTemplate($code, $variant, $ctx);
if (!$rendered) {
return redirect()->to(site_url('attendance/violations'))
->with('error', 'Template not found for ' . $code . ' (' . $variant . ').');
log_message('warning', 'Attendance email template missing; using fallback compose body. code=' . $code . ' variant=' . $variant);
$rendered = $this->buildFallbackTemplate($code, $variant, $ctx);
}
[$subject, $body] = $rendered;
@@ -0,0 +1,557 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ClassSectionModel;
use App\Models\ConfigurationModel;
use App\Models\CertificateRecordModel;
use App\Models\StudentDecisionModel;
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();
$selectedCsid = $this->request->getGet('class_section_id');
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
// ── All enrolled students across every class section ───────────────────
$allEnrolled = $db->table('student_class sc')
->select('s.id AS student_id, s.firstname, s.lastname, sc.class_section_id, cs.class_section_name')
->join('students s', 's.id = sc.student_id')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
->where('s.is_active', 1)
->where('sc.school_year', $schoolYear)
->orderBy('s.firstname', 'ASC')
->orderBy('s.lastname', 'ASC')
->get()->getResultArray();
$allIds = array_unique(array_column($allEnrolled, 'student_id'));
// ── Semester scores ────────────────────────────────────────────────────
$allScoreMap = [];
if (!empty($allIds)) {
foreach ($db->table('semester_scores')
->select('student_id, semester, semester_score')
->whereIn('student_id', $allIds)
->where('school_year', $schoolYear)
->where('semester_score IS NOT NULL', null, false)
->get()->getResultArray() as $sr) {
$allScoreMap[(int)$sr['student_id']][ucfirst(strtolower($sr['semester']))] =
is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null;
}
}
// ── Below-60 manual decisions ──────────────────────────────────────────
$allBelowMap = [];
if (!empty($allIds)) {
foreach ($db->table('below_sixty_decisions')
->whereIn('student_id', $allIds)
->where('school_year', $schoolYear)
->get()->getResultArray() as $b) {
$allBelowMap[(int)$b['student_id']][ucfirst(strtolower($b['semester']))] = (string)$b['decision'];
}
}
// ── Certificate records (most recent per student) ──────────────────────
$certsByStudent = [];
if (!empty($allIds)) {
foreach ($db->table('certificate_records')
->select('student_id, certificate_number, issued_at')
->whereIn('student_id', $allIds)
->where('school_year', $schoolYear)
->orderBy('issued_at', 'DESC')
->get()->getResultArray() as $c) {
$sid = (int)$c['student_id'];
if (!isset($certsByStudent[$sid])) {
$certsByStudent[$sid] = $c['certificate_number'];
}
}
}
// ── Build per-student decisions + per-class buckets ────────────────────
$decisionsByStudent = [];
$studentsByClass = []; // [csid => [student rows...]]
$statsPerClass = []; // [csid => {name, total, pass, cert}]
foreach ($allEnrolled as $row) {
$sid = (int)$row['student_id'];
$csid = (int)$row['class_section_id'];
// Decisions per semester
foreach ($allScoreMap[$sid] ?? [] as $sem => $score) {
if ($score === null) continue;
if ($score >= 60) {
$dec = 'Pass'; $src = 'auto';
} elseif (!empty($allBelowMap[$sid][$sem])) {
$dec = $allBelowMap[$sid][$sem]; $src = 'manual';
} else {
$dec = ''; $src = 'pending';
}
$decisionsByStudent[$sid][$sem] = ['decision' => $dec, 'source' => $src];
}
// Per-class stats
if (!isset($statsPerClass[$csid])) {
$statsPerClass[$csid] = ['name' => $row['class_section_name'], 'total' => 0, 'pass' => 0, 'cert' => 0];
}
$statsPerClass[$csid]['total']++;
$sems = $allScoreMap[$sid] ?? [];
$isPass = !empty($sems);
foreach ($sems as $sem => $score) {
if ($score === null) { $isPass = false; break; }
if ($score >= 60) continue;
$md = $allBelowMap[$sid][$sem] ?? '';
if ($md === '' || $md !== 'Pass') { $isPass = false; break; }
}
if ($isPass) $statsPerClass[$csid]['pass']++;
if (isset($certsByStudent[$sid])) $statsPerClass[$csid]['cert']++;
// Group students by class
$studentsByClass[$csid][] = $row;
}
// ── Determine default active tab ───────────────────────────────────────
$firstCsid = !empty($allEnrolled) ? (int)$allEnrolled[0]['class_section_id'] : null;
if ($selectedCsid === null && $firstCsid !== null) {
$selectedCsid = (string)$firstCsid;
}
return view('admin/certificates/index', [
'studentsByClass' => $studentsByClass,
'selectedClassId' => $selectedCsid,
'schoolYear' => $schoolYear,
'certDate' => date('m/d/Y'),
'decisionsByStudent' => $decisionsByStudent,
'certsByStudent' => $certsByStudent,
'statsPerClass' => $statsPerClass,
]);
}
public function auditLog()
{
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$records = $this->certRecordModel->getAuditLog($schoolYear);
$yearSummary = $this->certRecordModel->yearSummary();
return view('admin/certificates/audit_log', [
'records' => $records,
'schoolYear' => $schoolYear,
'yearSummary' => $yearSummary,
]);
}
public function csrfToken()
{
return $this->response
->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->setHeader('Pragma', 'no-cache')
->setJSON([
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
// ─── Public verification page ──────────────────────────────────────────────
public function verify(string $certNumber)
{
$record = \Config\Database::connect()
->table('certificate_records cr')
->select('cr.*, u.firstname AS admin_firstname, u.lastname AS admin_lastname')
->join('users u', 'u.id = cr.issued_by', 'left')
->where('cr.certificate_number', strtoupper($certNumber))
->get()->getRowArray();
return view('certificates/verify', ['record' => $record ?: null]);
}
// ─── Reprint an existing certificate ──────────────────────────────────────
public function reprint(string $certNumber)
{
$record = \Config\Database::connect()
->table('certificate_records')
->where('certificate_number', strtoupper($certNumber))
->get()->getRowArray();
if (!$record) {
return redirect()->to('administrator/certificates/log')
->with('error', 'Certificate not found: ' . $certNumber);
}
$certDateFormatted = '';
if (!empty($record['cert_date'])) {
$ts = strtotime((string)$record['cert_date']);
if ($ts) {
$certDateFormatted = date('m/d/Y', $ts);
}
}
$student = [
'firstname' => (string)($record['student_name'] ?? ''),
'lastname' => '',
'grade' => (string)($record['grade'] ?? ''),
'cert_number' => (string)($record['certificate_number'] ?? ''),
];
// student_name is stored as "Firstname Lastname" — split for the PDF builder
$parts = explode(' ', trim($student['firstname']), 2);
if (count($parts) === 2) {
$student['firstname'] = $parts[0];
$student['lastname'] = $parts[1];
}
$pdfData = $this->buildPdf([$student], $certDateFormatted ?: date('m/d/Y'));
$filename = 'Certificate_' . strtoupper($certNumber) . '.pdf';
return $this->response
->setHeader('Content-Type', 'application/pdf')
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
->setBody($pdfData);
}
// ─── PDF generation ────────────────────────────────────────────────────────
public function generate()
{
$studentIds = $this->request->getPost('student_ids') ?? [];
$certDate = trim($this->request->getPost('cert_date') ?? date('m/d/Y'));
$classSectionId = $this->request->getPost('class_section_id');
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
if (empty($studentIds)) {
if ($this->request->isAJAX()) {
return $this->response
->setStatusCode(422)
->setJSON([
'ok' => false,
'error' => 'Please select at least one student.',
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
return redirect()->to('administrator/certificates')->with('error', 'Please select at least one student.');
}
$studentIds = array_filter(array_map('intval', $studentIds));
$certDate = preg_replace('/[^0-9\/\-]/', '', $certDate);
if (empty($studentIds)) {
if ($this->request->isAJAX()) {
return $this->response
->setStatusCode(422)
->setJSON([
'ok' => false,
'error' => 'Invalid student selection.',
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
return redirect()->to('administrator/certificates')->with('error', 'Invalid student selection.');
}
$db = \Config\Database::connect();
$students = [];
foreach ($studentIds as $id) {
$row = null;
if ($classSectionId) {
$row = $db->table('student_class sc')
->select('s.id, s.firstname, s.lastname, cs.class_section_name AS grade')
->join('students s', 's.id = sc.student_id')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
->where('sc.class_section_id', (int) $classSectionId)
->where('s.id', $id)
->get()->getRowArray();
}
if (!$row) {
$s = $db->table('students')
->select('id, firstname, lastname, registration_grade AS grade')
->where('id', $id)
->get()->getRowArray();
$row = $s ?: null;
}
if ($row) {
$students[] = $row;
}
}
if (empty($students)) {
if ($this->request->isAJAX()) {
return $this->response
->setStatusCode(422)
->setJSON([
'ok' => false,
'error' => 'No valid students found.',
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
return redirect()->to('administrator/certificates')->with('error', 'No valid students found.');
}
$issuedBy = session()->get('user_id');
$issuedAt = date('Y-m-d H:i:s');
$certDateDb = $this->parseCertDate($certDate);
// Load any existing certificates for these students this school year
$db = \Config\Database::connect();
$existingCerts = $db->table('certificate_records')
->select('student_id, certificate_number')
->whereIn('student_id', array_column($students, 'id'))
->where('school_year', $schoolYear)
->get()->getResultArray();
$existingCertMap = [];
foreach ($existingCerts as $ec) {
$existingCertMap[(int)$ec['student_id']] = $ec['certificate_number'];
}
foreach ($students as &$student) {
$sid = (int)$student['id'];
if (isset($existingCertMap[$sid])) {
// Reuse existing certificate number — do not create a new record
$student['cert_number'] = $existingCertMap[$sid];
} else {
$certNumber = $this->certRecordModel->nextNumber($schoolYear);
$this->certRecordModel->insert([
'certificate_number' => $certNumber,
'student_id' => $sid,
'student_name' => $student['firstname'] . ' ' . $student['lastname'],
'grade' => $this->formatGrade($student['grade'] ?? ''),
'cert_date' => $certDateDb,
'school_year' => $schoolYear,
'class_section_id' => $classSectionId ?: null,
'issued_by' => $issuedBy,
'issued_at' => $issuedAt,
]);
$student['cert_number'] = $certNumber;
}
}
unset($student);
$pdfData = $this->buildPdf($students, $certDate);
$filename = 'Certificates_' . date('Ymd_His') . '.pdf';
return $this->response
->setHeader('Content-Type', 'application/pdf')
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
->setHeader('X-CSRF-TOKEN-NAME', csrf_token())
->setHeader('X-CSRF-TOKEN', csrf_hash())
->setBody($pdfData);
}
// ─── Helpers ───────────────────────────────────────────────────────────────
private function parseCertDate(string $certDate): ?string
{
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $certDate, $m)) {
return $m[3] . '-' . $m[1] . '-' . $m[2];
}
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $certDate)) {
return $certDate;
}
return null;
}
private function formatGrade(string $raw): string
{
$clean = trim($raw);
$lower = strtolower($clean);
// Strip section suffix: "Grade 1-A" → "Grade 1", "Grade 2-B" → "Grade 2"
if (preg_match('/^grade\s*(\d+)/i', $clean, $m)) {
return 'Grade ' . (int)$m[1];
}
if ($lower === 'youth') {
return 'Youth';
}
if ($lower === 'kg' || $lower === 'kindergarten') {
return 'Kindergarten';
}
// Raw number or number-section (e.g. "1", "1-A", "2-B") → keep number only
if (preg_match('/^(\d+)([- ][A-Za-z0-9]+)?$/', $clean, $m)) {
return 'Grade ' . (int)$m[1];
}
return $clean;
}
// ─── PDF rendering ─────────────────────────────────────────────────────────
private function buildPdf(array $students, string $certDate): string
{
$fontDir = FCPATH . 'assets' . DIRECTORY_SEPARATOR . 'certificates' . DIRECTORY_SEPARATOR . 'fonts' . DIRECTORY_SEPARATOR;
$imgDir = FCPATH . 'assets' . DIRECTORY_SEPARATOR . 'certificates' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR;
$edwardianFont = \TCPDF_FONTS::addTTFfont($fontDir . 'Edwardian Script ITC Regular.ttf', 'TrueTypeUnicode', '', 32);
$garamondBold = \TCPDF_FONTS::addTTFfont($fontDir . 'Garamond Bold.ttf', 'TrueTypeUnicode', '', 32);
$ebGaramond = \TCPDF_FONTS::addTTFfont($fontDir . 'EBGaramond-Regular.ttf', 'TrueTypeUnicode', '', 32);
$pdf = new \TCPDF('L', 'pt', 'A4', true, 'UTF-8', false);
$pdf->SetCreator('Al Rahma Sunday School');
$pdf->SetTitle('Student Certificates');
$pdf->SetMargins(0, 0, 0, true);
$pdf->SetAutoPageBreak(false, 0);
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
$pdf->SetHeaderMargin(0);
$pdf->SetFooterMargin(0);
$W = $pdf->getPageWidth();
$H = $pdf->getPageHeight();
foreach ($students as $student) {
$pdf->AddPage();
$name = $student['firstname'] . ' ' . $student['lastname'];
$grade = $this->formatGrade($student['grade'] ?? '');
$certNumber = $student['cert_number'] ?? '';
// Build verification URL for an inline TCPDF QR code.
$verifyUrl = site_url('verify/' . rawurlencode($certNumber));
$this->drawCertificate(
$pdf, $W, $H,
$name, $grade, $certDate, $certNumber,
$verifyUrl,
$imgDir, $edwardianFont, $garamondBold, $ebGaramond
);
}
return $pdf->Output('', 'S');
}
/**
* Coordinate mapping: iTextSharp origin bottom-left → TCPDF origin top-left.
* y_tcpdf ≈ H y_iTextSharp
*/
private function drawCertificate(
\TCPDF $pdf,
float $W,
float $H,
string $name,
string $grade,
string $certDate,
string $certNumber,
?string $verifyUrl,
string $imgDir,
string $edwardianFont,
string $garamondBold,
string $ebGaramond
): void {
// ── Images
$pdf->Image($imgDir . 'title.png', 126, 0, 600);
$pdf->Image($imgDir . 'background.png', 280, 176, 291, 340);
$pdf->Image($imgDir . 'signature.png', 140, 410, 90, 80);
// ── "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');
// ── QR code — left-aligned with "Presented to:", vertically centred on that line
$qrSize = 42; // pt
if (!empty($verifyUrl)) {
$qrX = 120; // ~1 cm from left edge
$qrY = 171 + (24 - $qrSize) / 2; // vertically centred on "Presented to:" row
$style = [
'border' => false,
'padding' => 0,
'fgcolor' => [0, 0, 0],
'bgcolor' => false,
];
$pdf->write2DBarcode($verifyUrl, 'QRCODE,L', $qrX, $qrY, $qrSize, $qrSize, $style, 'N');
}
// ── Student name — center based on actual string width
$pdf->SetFont($edwardianFont, '', 38);
$nameX = ($W - $pdf->GetStringWidth($name)) / 2;
$this->drawGradientText($pdf, $edwardianFont, 38, $name, $nameX, 229.5);
// ── 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, 586, 456);
// ── Date underline + label
$pdf->SetFont('times', '', 20);
$pdf->SetXY(0, 463);
$pdf->Cell(742, 20, '_________________', 0, 0, 'R');
$pdf->SetXY(650, 492);
$pdf->Cell(80, 20, 'Date', 0, 0, 'C');
// ── Signature underline + label
$pdf->SetXY(106, 463);
$pdf->Cell(200, 20, '_________________', 0, 0, 'L');
$pdf->SetXY(106, 492);
$pdf->Cell(168, 20, 'Signature', 0, 0, 'C');
// ── Certificate number — 1.4 cm from bottom, 2.5 cm from left
if ($certNumber !== '') {
$pdf->SetFont('helvetica', '', 8);
$pdf->SetTextColor(150, 150, 150);
$pdf->SetXY(70.86, $H - 39.68);
$pdf->Cell(200, 12, 'Certificate No. ' . $certNumber, 0, 0, 'L');
$pdf->SetTextColor(0, 0, 0);
}
}
private function drawGradientText(\TCPDF $pdf, string $fontName, float $fontSize, string $text, float $x, float $y): void
{
$pdf->SetFont($fontName, '', $fontSize);
for ($i = 0; $i < 6; $i++) {
$pdf->setAlpha(($i + 1) * 25 / 255);
$pdf->SetTextColor(0, 0, 0);
$pdf->Text($x + $i / 4, $y + $i / 4, $text);
}
$pdf->setAlpha(1);
$pdf->SetTextColor(0, 0, 0);
$pdf->Text($x, $y, $text);
}
}
+9 -9
View File
@@ -24,10 +24,10 @@ class ContactController extends BaseController
// Define validation rules
$validation->setRules([
'name' => 'required|min_length[3]',
'email' => 'required|valid_email',
'subject' => 'required|min_length[3]',
'message' => 'required|min_length[10]'
'name' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[3]|max_length[100]",
'email' => 'required|valid_email|max_length[254]',
'subject' => 'required|min_length[3]|max_length[150]',
'message' => 'required|min_length[10]|max_length[5000]'
]);
if (!$validation->withRequest($this->request)->run()) {
@@ -37,10 +37,10 @@ class ContactController extends BaseController
}
// Process form data
$name = $this->request->getPost('name');
$email = strtolower($this->request->getPost('email'));
$subject = $this->request->getPost('subject');
$message = $this->request->getPost('message');
$name = esc((string) $this->request->getPost('name'));
$email = esc(strtolower((string) $this->request->getPost('email')));
$subject = esc((string) $this->request->getPost('subject'));
$message = nl2br(esc((string) $this->request->getPost('message')));
// Initialize the EmailController
$emailController = new \App\Controllers\View\EmailController();
@@ -63,4 +63,4 @@ class ContactController extends BaseController
return redirect()->to('/parent/contact');
}
}
}
+221 -80
View File
@@ -17,9 +17,11 @@ use App\Models\PaymentModel;
use App\Models\CalendarModel;
use App\Services\EmailService;
use Config\Database;
#use ???
use App\Controllers\View\InvoiceController;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\RESTful\ResourceController;
use Dompdf\Dompdf;
use Dompdf\Options;
class EventController extends ResourceController
{
@@ -121,6 +123,194 @@ class EventController extends ResourceController
return $fallback;
}
private function loadEventChargesListingData(bool $includeParents = true): array
{
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$semester = $this->request->getGet('semester') ?? $this->semester;
$filterEventId = (int) ($this->request->getGet('event_id') ?? 0);
$filterParentId = (int) ($this->request->getGet('parent_id') ?? 0);
$parents = [];
if ($includeParents) {
$parents = $this->userModel->getParents();
usort($parents, static function (array $a, array $b): int {
$aLabel = trim(preg_replace('/\s+/', ' ', (string) ($a['firstname'] ?? '') . ' ' . (string) ($a['lastname'] ?? '')));
$bLabel = trim(preg_replace('/\s+/', ' ', (string) ($b['firstname'] ?? '') . ' ' . (string) ($b['lastname'] ?? '')));
$nameCmp = strnatcasecmp($aLabel, $bLabel);
if ($nameCmp !== 0) {
return $nameCmp;
}
return strnatcasecmp((string) ($a['school_id'] ?? ''), (string) ($b['school_id'] ?? ''));
});
}
$events = $this->eventModel->getActiveEvents($schoolYear);
$chargesBuilder = $this->eventChargesModel
->select('event_charges.*,
users.firstname AS parent_firstname, users.lastname AS parent_lastname, users.cellphone AS parent_cellphone,
students.firstname AS student_firstname, students.lastname AS student_lastname,
events.event_name, events.description AS event_description, events.amount AS event_amount')
->join('users', 'users.id = event_charges.parent_id', 'left')
->join('students', 'students.id = event_charges.student_id', 'left')
->join('events', 'events.id = event_charges.event_id', 'left')
->where('event_charges.school_year', $schoolYear)
->where('event_charges.semester', $semester);
if ($filterEventId > 0) {
$chargesBuilder->where('event_charges.event_id', $filterEventId);
}
if ($filterParentId > 0) {
$chargesBuilder->where('event_charges.parent_id', $filterParentId);
}
$charges = $chargesBuilder
->orderBy('event_charges.created_at', 'DESC')
->findAll();
foreach ($charges as &$charge) {
if (empty($charge['class_section_id']) && !empty($charge['student_id'])) {
$sections = $this->studentClassModel->getClassSectionIdsByStudentId(
(int) $charge['student_id'],
$schoolYear
);
if (!empty($sections)) {
$charge['class_section_id'] = $sections[0];
}
}
}
unset($charge);
$parentBalances = [];
try {
$balanceRows = $this->invoiceModel
->select('parent_id, COALESCE(SUM(balance),0) AS total_balance')
->where('school_year', $schoolYear)
->where('semester', $semester)
->groupBy('parent_id')
->findAll();
foreach ($balanceRows as $row) {
$parentBalances[(int) ($row['parent_id'] ?? 0)] = (float) ($row['total_balance'] ?? 0.0);
}
} catch (\Throwable $e) {
log_message('error', 'Failed to load parent balances: ' . $e->getMessage());
}
$sectionIds = array_unique(array_filter(array_column($charges, 'class_section_id')));
$classSectionNames = [];
if (!empty($sectionIds)) {
$sections = $this->classSectionModel
->select('class_section_id, class_section_name')
->whereIn('class_section_id', $sectionIds)
->findAll();
foreach ($sections as $section) {
$classSectionNames[(int) ($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? '';
}
}
return [
'charges' => $charges,
'parents' => $parents,
'events' => $events,
'school_year' => $schoolYear,
'semester' => $semester,
'filterEventId' => $filterEventId,
'filterParentId' => $filterParentId,
'parentBalances' => $parentBalances,
'classSectionNames' => $classSectionNames,
];
}
private function buildGroupedEventCharges(array $charges): array
{
$grouped = [];
foreach ($charges as $charge) {
$eventId = (int) ($charge['event_id'] ?? 0);
if (!isset($grouped[$eventId])) {
$grouped[$eventId] = [
'label' => $charge['event_name'] ?? 'N/A',
'rows' => [],
];
}
$grouped[$eventId]['rows'][] = $charge;
}
return $grouped;
}
private function applyGroupedEventChargeRowOrder(array $groupedCharges): array
{
$rowOrder = $this->request->getGet('row_order');
if (!is_array($rowOrder) || empty($rowOrder)) {
return $groupedCharges;
}
foreach ($groupedCharges as $eventId => &$group) {
$rawOrder = $rowOrder[$eventId] ?? $rowOrder[(string) $eventId] ?? null;
if (!is_string($rawOrder) || trim($rawOrder) === '') {
continue;
}
$orderedIds = array_values(array_filter(array_map(
static fn ($value): int => (int) trim((string) $value),
explode(',', $rawOrder)
)));
if (empty($orderedIds) || empty($group['rows']) || !is_array($group['rows'])) {
continue;
}
$rowsById = [];
foreach ($group['rows'] as $row) {
$rowsById[(int) ($row['id'] ?? 0)] = $row;
}
$reorderedRows = [];
foreach ($orderedIds as $chargeId) {
if (isset($rowsById[$chargeId])) {
$reorderedRows[] = $rowsById[$chargeId];
unset($rowsById[$chargeId]);
}
}
foreach ($group['rows'] as $row) {
$chargeId = (int) ($row['id'] ?? 0);
if (isset($rowsById[$chargeId])) {
$reorderedRows[] = $row;
unset($rowsById[$chargeId]);
}
}
$group['rows'] = $reorderedRows;
}
unset($group);
return $groupedCharges;
}
private function buildEventChargesPdfFilename(array $data): string
{
$label = 'event_participant_lists';
if (!empty($data['filterEventId'])) {
foreach (($data['events'] ?? []) as $event) {
if ((int) ($event['id'] ?? 0) === (int) $data['filterEventId']) {
$label = (string) ($event['event_name'] ?? $label);
break;
}
}
}
$safeLabel = trim((string) preg_replace('/[^A-Za-z0-9]+/', '_', strtolower($label)), '_');
if ($safeLabel === '') {
$safeLabel = 'event_participant_lists';
}
return $safeLabel . '_' . date('Ymd_His') . '.pdf';
}
public function index()
{
$eventModel = new EventModel();
@@ -475,74 +665,7 @@ class EventController extends ResourceController
// Optionally keep your eventShow / eventUpdate for legacy administrator event charges
public function eventShow()
{
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$semester = $this->request->getGet('semester') ?? $this->semester;
$parents = $this->userModel->getParents();
$events = $this->eventModel->getActiveEvents($schoolYear);
$filterEventId = (int) ($this->request->getGet('event_id') ?? 0);
$filterParentId = (int) ($this->request->getGet('parent_id') ?? 0);
$chargesBuilder = $this->eventChargesModel
->select('event_charges.*,
users.firstname AS parent_firstname, users.lastname AS parent_lastname,
students.firstname AS student_firstname, students.lastname AS student_lastname,
events.event_name, events.description AS event_description, events.amount AS event_amount')
->join('users', 'users.id = event_charges.parent_id', 'left')
->join('students', 'students.id = event_charges.student_id', 'left')
->join('events', 'events.id = event_charges.event_id', 'left')
->where('event_charges.school_year', $schoolYear)
->where('event_charges.semester', $semester);
if ($filterEventId > 0) {
$chargesBuilder->where('event_charges.event_id', $filterEventId);
}
$charges = $chargesBuilder
->orderBy('event_charges.created_at', 'DESC')
->findAll();
foreach ($charges as &$charge) {
if (empty($charge['class_section_id']) && !empty($charge['student_id'])) {
$sections = $this->studentClassModel->getClassSectionIdsByStudentId(
(int)$charge['student_id'],
$schoolYear
);
if (!empty($sections)) {
$charge['class_section_id'] = $sections[0];
}
}
}
unset($charge);
$parentBalances = [];
try {
$balanceRows = $this->invoiceModel
->select('parent_id, COALESCE(SUM(balance),0) AS total_balance')
->where('school_year', $schoolYear)
->where('semester', $semester)
->groupBy('parent_id')
->findAll();
foreach ($balanceRows as $row) {
$parentBalances[(int)($row['parent_id'] ?? 0)] = (float)($row['total_balance'] ?? 0.0);
}
} catch (\Throwable $e) {
log_message('error', 'Failed to load parent balances: ' . $e->getMessage());
}
$sectionIds = array_unique(array_filter(array_column($charges, 'class_section_id')));
$classSectionNames = [];
if (!empty($sectionIds)) {
$sections = $this->classSectionModel
->select('class_section_id, class_section_name')
->whereIn('class_section_id', $sectionIds)
->findAll();
foreach ($sections as $section) {
$classSectionNames[(int)($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? '';
}
}
$data = $this->loadEventChargesListingData();
$semesterOptions = (new EventChargesModel())
->select('semester')
@@ -564,19 +687,37 @@ class EventController extends ResourceController
$schoolYearOptions = [$this->schoolYear];
}
return view('administrator/events/event_charges', [
'charges' => $charges,
'parents' => $parents,
'events' => $events,
'school_year' => $schoolYear,
'semester' => $semester,
'filterEventId' => $filterEventId,
'filterParentId' => $filterParentId,
'parentBalances' => $parentBalances,
'classSectionNames' => $classSectionNames,
return view('administrator/events/event_charges', array_merge($data, [
'semesterOptions' => $semesterOptions,
'schoolYearOptions' => $schoolYearOptions,
]);
]));
}
public function eventChargesPdf(): ResponseInterface
{
$data = $this->loadEventChargesListingData(false);
$groupedCharges = $this->buildGroupedEventCharges($data['charges']);
$groupedCharges = $this->applyGroupedEventChargeRowOrder($groupedCharges);
$generatedAt = date('m-d-Y h:i A');
$html = view('administrator/events/event_charges_pdf', array_merge($data, [
'groupedCharges' => $groupedCharges,
'generatedAt' => $generatedAt,
]));
$options = new Options();
$options->set('isRemoteEnabled', true);
$options->set('defaultFont', 'DejaVu Sans');
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->setPaper('A4', 'landscape');
$dompdf->render();
return $this->response
->setHeader('Content-Type', 'application/pdf')
->setHeader('Content-Disposition', 'inline; filename="' . $this->buildEventChargesPdfFilename($data) . '"')
->setBody($dompdf->output());
}
+72 -17
View File
@@ -348,6 +348,16 @@ class ExamDraftController extends BaseController
}
public function adminIndex()
{
return $this->reviewIndex();
}
public function principalIndex()
{
return $this->reviewIndex();
}
public function reviewIndex()
{
if ($this->reviewerIdColumn !== '') {
$allDrafts = $this->examDraftModel
@@ -487,10 +497,23 @@ class ExamDraftController extends BaseController
'visibleClasses' => $visibleClasses,
'classDraftGroups' => $classDraftGroups,
'newSubmissionClasses' => $newSubmissionClasses,
'reviewActionUrl' => base_url($this->reviewRoutePrefix() . '/exam-drafts/review'),
'legacyUploadUrl' => base_url($this->reviewRoutePrefix() . '/exam-drafts/upload-legacy'),
'reviewPortalLabel' => $this->reviewPortalLabel(),
]);
}
public function adminUploadLegacy()
{
return $this->reviewUploadLegacy();
}
public function principalUploadLegacy()
{
return $this->reviewUploadLegacy();
}
public function reviewUploadLegacy()
{
$adminId = (int) (session()->get('user_id') ?? 0);
if ($adminId <= 0) {
@@ -571,13 +594,23 @@ class ExamDraftController extends BaseController
}
if ($saved > 0) {
return redirect()->to('/administrator/exam-drafts')->with('success', 'Old exam uploaded successfully.');
return redirect()->to($this->reviewDashboardPath())->with('success', 'Old exam uploaded successfully.');
}
return redirect()->back()->withInput()->with('error', 'Unable to save the old exam.');
}
public function adminReview()
{
return $this->reviewSubmission();
}
public function principalReview()
{
return $this->reviewSubmission();
}
public function reviewSubmission()
{
$draftId = (int) ($this->request->getPost('draft_id') ?? 0);
if ($draftId <= 0) {
@@ -690,11 +723,6 @@ class ExamDraftController extends BaseController
if ($this->hasAcceptanceTypeColumn) {
$newRow['acceptance_type'] = $status === 'accepted' ? $acceptanceType : null;
}
$teacherFile = $this->draftTeacherFile($draft);
if (!empty($teacherFile)) {
$newRow[$this->authorFileColumn] = $teacherFile;
$newRow[$this->authorFilenameColumn] = $this->draftTeacherFilename($draft) ?? $teacherFile;
}
if ($this->hasIsLegacyColumn && !empty($draft['is_legacy'])) {
$newRow['is_legacy'] = 1;
}
@@ -711,16 +739,6 @@ class ExamDraftController extends BaseController
return redirect()->back()->with('success', 'Review saved successfully.');
}
return redirect()->back()->with('error', 'Unable to save the review.');
} elseif (strtolower($status) === 'accepted' && !empty($this->draftTeacherFile($draft))) {
$copied = $this->copyDraftToFinal($this->draftTeacherFile($draft));
if ($copied !== null) {
$update['final_file'] = $copied;
$update['final_filename'] = $this->draftTeacherFilename($draft) ?? $this->draftTeacherFile($draft);
$pdfName = $this->ensurePdfExists($copied, pathinfo($copied, PATHINFO_EXTENSION));
if ($pdfName !== null && $this->hasFinalPdfColumn) {
$update['final_pdf_file'] = $pdfName;
}
}
}
if ($status === 'legacy') {
$this->prepareLegacyPdfVersion($update, $draft);
@@ -1010,7 +1028,7 @@ class ExamDraftController extends BaseController
. '<tr><td style="padding:4px 8px;"><strong>Status</strong></td><td style="padding:4px 8px;">' . esc((string) ($draft['status'] ?? 'submitted')) . '</td></tr>'
. '<tr><td style="padding:4px 8px;"><strong>Submitted at</strong></td><td style="padding:4px 8px;">' . esc((string) $submittedAt) . '</td></tr>'
. '</table>'
. '<p>Review drafts: <a href="' . esc(base_url('administrator/exam-drafts')) . '">Admin exam drafts</a></p>';
. '<p>Review drafts: <a href="' . esc(base_url($this->reviewRoutePrefix() . '/exam-drafts')) . '">' . esc($this->reviewPortalLabel()) . ' exam drafts</a></p>';
$mailer = \Config\Services::emailService();
$mailer->send($principalEmail, $subject, $body, 'notifications');
@@ -1019,6 +1037,38 @@ class ExamDraftController extends BaseController
}
}
private function reviewDashboardPath(): string
{
return '/' . $this->reviewRoutePrefix() . '/exam-drafts';
}
private function reviewRoutePrefix(): string
{
return $this->isPrincipalReviewer() ? 'principal' : 'administrator';
}
private function reviewPortalLabel(): string
{
return $this->isPrincipalReviewer() ? 'Principal' : 'Administrator';
}
private function isPrincipalReviewer(): bool
{
$roles = session()->get('roles');
if (!is_array($roles)) {
$roles = $roles === null ? [] : [$roles];
}
foreach ($roles as $role) {
$normalized = strtolower(trim(str_replace([' ', '-'], '_', (string) $role)));
if ($normalized === 'principal') {
return true;
}
}
return false;
}
private function nextDraftVersion(array $draft): int
{
$query = $this->examDraftModel
@@ -1373,6 +1423,11 @@ class ExamDraftController extends BaseController
$base = pathinfo($sourcePath, PATHINFO_FILENAME);
$targetPath = $targetDir . '/' . $base . '.pdf';
if (!function_exists('exec')) {
log_message('warning', 'ExamDraftController::convertDocToPdf skipped because exec() is unavailable.');
return is_file($targetPath) ? basename($targetPath) : null;
}
// Attempt conversion via LibreOffice if available
$cmd = 'soffice --headless --convert-to pdf --outdir ' . escapeshellarg($targetDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null';
@\exec($cmd);
+62 -7
View File
@@ -322,6 +322,23 @@ class FilesController extends Controller
return 'teacher_file';
}
private function resolveExamDraftAuthorIdColumn($db): string
{
try {
$fields = $db->getFieldNames('exam_drafts');
if (in_array('teacher_id', $fields, true)) {
return 'teacher_id';
}
if (in_array('author_id', $fields, true)) {
return 'author_id';
}
} catch (\Throwable $e) {
log_message('error', 'FilesController::resolveExamDraftAuthorIdColumn error: ' . $e->getMessage());
}
return 'teacher_id';
}
private function canAccessExamDraftFile(string $filename, string $subdir): bool
{
$userId = (int) (session()->get('user_id') ?? 0);
@@ -330,15 +347,16 @@ class FilesController extends Controller
}
$role = strtolower((string) (session()->get('role') ?? ''));
if ($role === 'admin') {
if (in_array($role, ['admin', 'administrator', 'principal'], true)) {
return true;
}
$db = Database::connect();
$fileColumn = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
$authorIdColumn = $this->resolveExamDraftAuthorIdColumn($db);
$draft = $db->table('exam_drafts ed')
->select('ed.class_section_id, ed.school_year, ed.teacher_id, ed.author_id')
->select('ed.class_section_id, ed.school_year, ed.semester, ed.status, ed.' . $authorIdColumn . ' AS draft_author_id')
->where('ed.' . $fileColumn, $filename)
->limit(1)
->get()
@@ -348,7 +366,7 @@ class FilesController extends Controller
return false;
}
$authorId = (int) ($draft['teacher_id'] ?? $draft['author_id'] ?? 0);
$authorId = (int) ($draft['draft_author_id'] ?? 0);
if ($authorId > 0 && $authorId === $userId) {
return true;
}
@@ -358,16 +376,53 @@ class FilesController extends Controller
return false;
}
$status = strtolower(trim((string) ($draft['status'] ?? '')));
if ($subdir === 'drafts' && $status === 'draft') {
return false;
}
$currentSchoolYear = trim((string) (session()->get('school_year') ?? ''));
$currentSemester = trim((string) (session()->get('semester') ?? ''));
$draftSchoolYear = trim((string) ($draft['school_year'] ?? ''));
$draftSemester = trim((string) ($draft['semester'] ?? ''));
$hasCurrentAssignment = $db->table('teacher_class')
->select('id')
->where('teacher_id', $userId)
->where('class_section_id', $classSectionId);
if ($currentSchoolYear !== '') {
$hasCurrentAssignment->where('school_year', $currentSchoolYear);
}
$hasCurrentAssignment = $hasCurrentAssignment->limit(1)->countAllResults() > 0;
if ($hasCurrentAssignment) {
if ($subdir === 'finals') {
return true;
}
return $draftSchoolYear === '' || $currentSchoolYear === '' || $draftSchoolYear === $currentSchoolYear;
}
$assignmentQuery = $db->table('teacher_class')
->select('id')
->where('teacher_id', $userId)
->where('class_section_id', $classSectionId);
$schoolYear = trim((string) ($draft['school_year'] ?? ''));
if ($schoolYear !== '') {
$assignmentQuery->where('school_year', $schoolYear);
if ($draftSchoolYear !== '') {
$assignmentQuery->where('school_year', $draftSchoolYear);
}
return $assignmentQuery->limit(1)->countAllResults() > 0;
$hasDraftYearAssignment = $assignmentQuery->limit(1)->countAllResults() > 0;
if (!$hasDraftYearAssignment) {
return false;
}
if ($subdir === 'finals') {
return true;
}
return $draftSemester === '' || $currentSemester === '' || $draftSemester === $currentSemester;
}
}
+728 -24
View File
@@ -28,6 +28,8 @@ use App\Models\PlacementLevelModel;
use App\Models\PlacementBatchModel;
use App\Models\PlacementScoreModel;
use App\Models\GradingLockModel;
use App\Models\BelowSixtyDecisionModel;
use App\Models\StudentDecisionModel;
use App\Services\NavbarService;
//use App\Models\ScoreModel;
@@ -1074,7 +1076,8 @@ class GradingController extends Controller
$requestedSemester = trim((string)($this->request->getGet('semester') ?? ''));
$requestedYear = trim((string)($this->request->getGet('school_year') ?? ''));
$semester = $requestedSemester !== '' ? $requestedSemester : ($configuredSemester !== '' ? $configuredSemester : 'Fall');
$isYearMode = (strtolower($requestedSemester) === 'year');
$semester = $isYearMode ? 'year' : ($requestedSemester !== '' ? $requestedSemester : ($configuredSemester !== '' ? $configuredSemester : 'Fall'));
$schoolYear = $requestedYear !== '' ? $requestedYear : $configuredYear;
$schoolYears = $this->getSchoolYearsForScores($schoolYear);
@@ -1088,6 +1091,8 @@ class GradingController extends Controller
'schoolYear' => $schoolYear,
'schoolYears' => $schoolYears,
'canViewGrading' => $canViewGrading,
'isYearMode' => $isYearMode,
'showAllSemesterOption' => true,
]);
}
@@ -1760,14 +1765,16 @@ class GradingController extends Controller
private function fetchBelowSixtyRows(string $schoolYear, string $semester): array
{
$semesterKey = strtolower(trim($semester));
$rows = $this->db->table('semester_scores ss')
$isYearMode = strtolower(trim($semester)) === 'year';
$semesterKey = $isYearMode ? '' : strtolower(trim($semester));
$builder = $this->db->table('semester_scores ss')
->select([
's.id AS student_id',
's.school_id',
's.firstname',
's.lastname',
'cs.class_section_name',
'ss.semester',
'ss.homework_avg',
'ss.project_avg',
'ss.participation_score',
@@ -1782,11 +1789,17 @@ class GradingController extends Controller
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->where("LOWER(TRIM(ss.semester))", $semesterKey)
->where('ss.semester_score IS NOT NULL', null, false)
->where('ss.semester_score <', 60)
->where('ss.semester_score <', 60);
if (!$isYearMode) {
$builder->where("LOWER(TRIM(ss.semester))", $semesterKey);
}
$rows = $builder
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->orderBy('ss.semester', 'ASC')
->get()
->getResultArray();
@@ -1802,19 +1815,22 @@ class GradingController extends Controller
$commentMap = [];
if (!empty($studentIds)) {
$commentRows = $this->db->table('score_comments')
->select('student_id, comment, created_at')
$commentBuilder = $this->db->table('score_comments')
->select('student_id, semester, comment, created_at')
->where('score_type', 'general')
->where('school_year', $schoolYear)
->where("LOWER(TRIM(semester))", $semesterKey)
->whereIn('student_id', $studentIds)
->orderBy('created_at', 'DESC')
->get()
->getResultArray();
->orderBy('created_at', 'DESC');
if (!$isYearMode) {
$commentBuilder->where("LOWER(TRIM(semester))", $semesterKey);
}
$commentRows = $commentBuilder->get()->getResultArray();
foreach ($commentRows as $row) {
$sid = (int)($row['student_id'] ?? 0);
if ($sid > 0 && !isset($commentMap[$sid])) {
$commentMap[$sid] = (string)($row['comment'] ?? '');
$sem = strtolower(trim((string)($row['semester'] ?? '')));
$key = $sid . '_' . $sem;
if ($sid > 0 && !isset($commentMap[$key])) {
$commentMap[$key] = (string)($row['comment'] ?? '');
}
}
}
@@ -1822,21 +1838,24 @@ class GradingController extends Controller
$statusMap = [];
$noteMap = [];
if (!empty($studentIds)) {
$flagRows = $this->db->table('current_flag')
->select('student_id, flag_state, open_description, close_description')
$flagBuilder = $this->db->table('current_flag')
->select('student_id, semester, flag_state, open_description, close_description')
->where('flag', 'grade')
->where('school_year', $schoolYear)
->where("LOWER(TRIM(semester))", $semesterKey)
->whereIn('student_id', $studentIds)
->get()
->getResultArray();
->whereIn('student_id', $studentIds);
if (!$isYearMode) {
$flagBuilder->where("LOWER(TRIM(semester))", $semesterKey);
}
$flagRows = $flagBuilder->get()->getResultArray();
foreach ($flagRows as $row) {
$sid = (int)($row['student_id'] ?? 0);
if ($sid <= 0) continue;
$statusMap[$sid] = (string)($row['flag_state'] ?? '');
$sem = strtolower(trim((string)($row['semester'] ?? '')));
$key = $sid . '_' . $sem;
$statusMap[$key] = (string)($row['flag_state'] ?? '');
$openNote = trim((string)($row['open_description'] ?? ''));
$closeNote = trim((string)($row['close_description'] ?? ''));
$noteMap[$sid] = [
$noteMap[$key] = [
'open' => $openNote,
'closed' => $closeNote,
];
@@ -1845,10 +1864,12 @@ class GradingController extends Controller
foreach ($rows as &$row) {
$sid = (int)($row['student_id'] ?? 0);
$row['comment'] = $commentMap[$sid] ?? '';
$flagState = strtolower(trim((string)($statusMap[$sid] ?? '')));
$sem = strtolower(trim((string)($row['semester'] ?? '')));
$key = $sid . '_' . $sem;
$row['comment'] = $commentMap[$key] ?? '';
$flagState = strtolower(trim((string)($statusMap[$key] ?? '')));
$row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open';
$noteBag = $noteMap[$sid] ?? ['open' => '', 'closed' => ''];
$noteBag = $noteMap[$key] ?? ['open' => '', 'closed' => ''];
$rawNote = $row['status'] === 'Closed' ? (string)$noteBag['closed'] : (string)$noteBag['open'];
if ($rawNote !== '') {
$lines = preg_split('/\R/', $rawNote);
@@ -1954,6 +1975,73 @@ class GradingController extends Controller
return false;
}
private function fetchAllSemestersForStudent(int $studentId, string $schoolYear): array
{
$rows = $this->db->table('semester_scores ss')
->select([
'ss.semester',
'cs.class_section_name',
'ss.homework_avg',
'ss.project_avg',
'ss.participation_score',
'COALESCE(ss.test_avg, ss.quiz_avg) AS test_avg',
'ss.ptap_score',
'ss.attendance_score',
'ss.midterm_exam_score',
'ss.final_exam_score',
'ss.semester_score',
])
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
->where('ss.student_id', $studentId)
->where('ss.school_year', $schoolYear)
->orderBy('ss.semester', 'ASC')
->get()
->getResultArray();
$semesters = [];
foreach ($rows as $sr) {
$sem = ucfirst(strtolower(trim((string)($sr['semester'] ?? ''))));
$semesters[$sem] = [
'semester' => $sem,
'class_section_name' => $sr['class_section_name'] ?? '',
'homework_avg' => $sr['homework_avg'] ?? null,
'project_avg' => $sr['project_avg'] ?? null,
'participation_score' => $sr['participation_score'] ?? null,
'test_avg' => $sr['test_avg'] ?? null,
'ptap_score' => $sr['ptap_score'] ?? null,
'attendance_score' => $sr['attendance_score'] ?? null,
'midterm_exam_score' => $sr['midterm_exam_score'] ?? null,
'final_exam_score' => $sr['final_exam_score'] ?? null,
'semester_score' => $sr['semester_score'] ?? null,
'comments' => [],
];
}
if (!empty($semesters)) {
$commentRows = $this->db->table('score_comments')
->select('semester, score_type, comment, created_at')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('comment IS NOT NULL', null, false)
->where('comment !=', '')
->orderBy('semester', 'ASC')
->orderBy('score_type', 'ASC')
->orderBy('created_at', 'DESC')
->get()
->getResultArray();
foreach ($commentRows as $c) {
$sem = ucfirst(strtolower(trim((string)($c['semester'] ?? ''))));
$type = strtolower(trim((string)($c['score_type'] ?? 'general')));
if (isset($semesters[$sem]) && !isset($semesters[$sem]['comments'][$type])) {
$semesters[$sem]['comments'][$type] = (string)($c['comment'] ?? '');
}
}
}
return array_values($semesters);
}
private function fetchBelowSixtyParentName(int $studentId): string
{
$parentName = 'Parent/Guardian';
@@ -2124,6 +2212,622 @@ class GradingController extends Controller
}
public function belowSixtyDecisions()
{
$configuredSemester = (string) $this->semester;
$configuredYear = (string) $this->schoolYear;
$semester = trim((string)($this->request->getGet('semester') ?? ''));
$schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
if ($semester === '') $semester = $configuredSemester !== '' ? $configuredSemester : 'Fall';
if ($schoolYear === '') $schoolYear = $configuredYear;
$schoolYears = $this->getSchoolYearsForScores($schoolYear);
$rows = $this->fetchBelowSixtyRows($schoolYear, $semester);
$decisionModel = new BelowSixtyDecisionModel();
$studentIds = array_values(array_unique(array_filter(
array_map(static fn($r) => (int)($r['student_id'] ?? 0), $rows),
static fn($id) => $id > 0
)));
$decisionMap = [];
if (!empty($studentIds)) {
$dRows = $decisionModel
->whereIn('student_id', $studentIds)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
foreach ($dRows as $d) {
$decisionMap[(int)$d['student_id']] = $d;
}
}
foreach ($rows as &$row) {
$sid = (int)($row['student_id'] ?? 0);
$row['decision'] = $decisionMap[$sid]['decision'] ?? '';
$row['decision_notes'] = $decisionMap[$sid]['notes'] ?? '';
}
unset($row);
// Load consolidated decisions from student_decisions for this term
$sdModel = new StudentDecisionModel();
$sdRows = $sdModel
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
$sdMap = [];
foreach ($sdRows as $sd) {
$sdMap[(int)$sd['student_id']] = $sd;
}
// Load the most recent certificate per student for this school_year
$studentIds = array_values(array_unique(array_filter(
array_map(static fn($r) => (int)($r['student_id'] ?? 0), $rows),
static fn($id) => $id > 0
)));
$certMap = [];
if (!empty($studentIds)) {
$certRows = $this->db->table('certificate_records')
->select('student_id, certificate_number, issued_at')
->where('school_year', $schoolYear)
->whereIn('student_id', $studentIds)
->orderBy('issued_at', 'DESC')
->get()->getResultArray();
foreach ($certRows as $cr) {
$sid = (int)($cr['student_id'] ?? 0);
if ($sid > 0 && !isset($certMap[$sid])) {
$certMap[$sid] = (string)($cr['certificate_number'] ?? '');
}
}
}
foreach ($rows as &$row) {
$sid = (int)($row['student_id'] ?? 0);
$row['consolidated_decision'] = $sdMap[$sid]['decision'] ?? null;
$row['certificate_number'] = $certMap[$sid] ?? '';
}
unset($row);
$canViewGrading = $this->userHasMenuUrl('grading');
return view('grading/below_sixty_decisions', [
'rows' => $rows,
'semester' => $semester,
'schoolYear' => $schoolYear,
'schoolYears' => $schoolYears,
'canViewGrading' => $canViewGrading,
]);
}
public function saveBelowSixtyDecision()
{
$studentId = (int)$this->request->getPost('student_id');
$semester = trim((string)$this->request->getPost('semester'));
$schoolYear = trim((string)$this->request->getPost('school_year'));
$decision = trim((string)$this->request->getPost('decision'));
$notes = trim((string)$this->request->getPost('notes'));
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
return redirect()->back()->with('error', 'Missing required data.');
}
$allowed = ['', 'Pass', 'Repeat Class', 'Make-up exam in fall', 'Deferred decision', 'Expel', 'Withdrawn'];
if (!in_array($decision, $allowed, true)) {
return redirect()->back()->with('error', 'Invalid decision value.');
}
$decisionModel = new BelowSixtyDecisionModel();
$existing = $decisionModel
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$userId = (int)(session()->get('user_id') ?? 0) ?: null;
$payload = [
'decision' => $decision !== '' ? $decision : null,
'notes' => $notes !== '' ? $notes : null,
'decided_by' => $userId,
];
if ($existing) {
$decisionModel->update((int)$existing['id'], $payload);
} else {
$payload['student_id'] = $studentId;
$payload['semester'] = $semester;
$payload['school_year'] = $schoolYear;
$decisionModel->insert($payload);
}
$query = http_build_query(['semester' => $semester, 'school_year' => $schoolYear]);
return redirect()->to(base_url('grading/below-60/decisions') . ($query ? '?' . $query : ''))
->with('status', 'Decision saved.');
}
public function studentDecisionDetails()
{
$studentId = (int)$this->request->getGet('student_id');
$schoolYear = trim((string)$this->request->getGet('school_year'));
if ($studentId <= 0 || $schoolYear === '') {
return $this->response->setJSON(['error' => 'Missing student or school year.'])->setStatusCode(400);
}
return $this->response->setJSON([
'semesters' => $this->fetchAllSemestersForStudent($studentId, $schoolYear),
]);
}
public function previewDecisionEmail()
{
$studentId = (int)$this->request->getGet('student_id');
$semester = trim((string)$this->request->getGet('semester'));
$schoolYear = trim((string)$this->request->getGet('school_year'));
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
return $this->response->setJSON(['error' => 'Missing student or term.'])->setStatusCode(400);
}
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
if (empty($row)) {
return $this->response->setJSON(['error' => 'Student not found.'])->setStatusCode(404);
}
$decisionModel = new BelowSixtyDecisionModel();
$decisionRow = $decisionModel
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$decision = (string)($decisionRow['decision'] ?? '');
$notes = (string)($decisionRow['notes'] ?? '');
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
$parentName = $this->fetchBelowSixtyParentName($studentId);
$subject = 'Academic Decision';
if ($studentName !== '') $subject .= ' — ' . $studentName;
if ($semester !== '' || $schoolYear !== '') {
$subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
}
$scores = [
'homework_avg' => $row['homework_avg'] ?? null,
'project_avg' => $row['project_avg'] ?? null,
'participation_score' => $row['participation_score'] ?? null,
'test_avg' => $row['test_avg'] ?? null,
'ptap_score' => $row['ptap_score'] ?? null,
'attendance_score' => $row['attendance_score'] ?? null,
'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
'semester_score' => $row['semester_score'] ?? null,
];
// Fetch all semesters' scores + comments for the email
$allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear);
$html = view('emails/below_sixty_decision', [
'title' => $subject,
'parent_name' => $parentName,
'student_name' => $studentName !== '' ? $studentName : 'your student',
'class_section_name' => $row['class_section_name'] ?? '',
'semester' => $semester,
'school_year' => $schoolYear,
'decision' => $decision,
'notes' => $notes,
'scores' => $scores,
'all_semesters' => array_values($allSemesters),
], ['saveData' => true]);
return $this->response->setJSON([
'subject' => $subject,
'html' => $html,
'student_id' => $studentId,
]);
}
public function editDecisionEmail()
{
$studentId = (int)$this->request->getGet('student_id');
$semester = trim((string)$this->request->getGet('semester'));
$schoolYear = trim((string)$this->request->getGet('school_year'));
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
return redirect()->back()->with('error', 'Missing student or term.');
}
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
if (empty($row)) {
return redirect()->back()->with('error', 'Student record not found for the selected term.');
}
$decisionModel = new BelowSixtyDecisionModel();
$decisionRow = $decisionModel
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$decision = (string)($decisionRow['decision'] ?? '');
$notes = (string)($decisionRow['notes'] ?? '');
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
$parentName = $this->fetchBelowSixtyParentName($studentId);
$subject = 'Academic Decision';
if ($studentName !== '') $subject .= ' — ' . $studentName;
if ($semester !== '' || $schoolYear !== '') {
$subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
}
$scores = [
'homework_avg' => $row['homework_avg'] ?? null,
'project_avg' => $row['project_avg'] ?? null,
'participation_score' => $row['participation_score'] ?? null,
'test_avg' => $row['test_avg'] ?? null,
'ptap_score' => $row['ptap_score'] ?? null,
'attendance_score' => $row['attendance_score'] ?? null,
'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
'semester_score' => $row['semester_score'] ?? null,
];
$html = view('emails/below_sixty_decision', [
'title' => $subject,
'parent_name' => $parentName,
'student_name' => $studentName !== '' ? $studentName : 'your student',
'class_section_name' => $row['class_section_name'] ?? '',
'semester' => $semester,
'school_year' => $schoolYear,
'decision' => $decision,
'notes' => $notes,
'scores' => $scores,
], ['saveData' => true]);
return view('grading/below_sixty_decision_email_editor', [
'studentId' => $studentId,
'studentName' => $studentName,
'semester' => $semester,
'schoolYear' => $schoolYear,
'subject' => $subject,
'html' => $html,
'decision' => $decision,
]);
}
public function sendDecisionEmail()
{
$studentId = (int)$this->request->getPost('student_id');
$semester = trim((string)$this->request->getPost('semester'));
$schoolYear = trim((string)$this->request->getPost('school_year'));
$subjectInput= trim((string)$this->request->getPost('subject'));
$htmlInput = (string)($this->request->getPost('html') ?? '');
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
return redirect()->back()->with('error', 'Missing student or term.');
}
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
if (empty($row)) {
return redirect()->back()->with('error', 'Student record not found for the selected term.');
}
$decisionModel = new BelowSixtyDecisionModel();
$decisionRow = $decisionModel
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
$subject = $subjectInput !== '' ? $subjectInput : ('Academic Decision — ' . $studentName . ' (' . trim($semester . ' ' . $schoolYear) . ')');
$allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear);
$payload = [
'student_id' => $studentId,
'student_name' => $studentName,
'class_section_name' => $row['class_section_name'] ?? '',
'semester' => $semester,
'school_year' => $schoolYear,
'decision' => (string)($decisionRow['decision'] ?? ''),
'notes' => (string)($decisionRow['notes'] ?? ''),
'subject' => $subject,
'all_semesters' => $allSemesters,
'scores' => [
'homework_avg' => $row['homework_avg'] ?? null,
'project_avg' => $row['project_avg'] ?? null,
'participation_score' => $row['participation_score'] ?? null,
'test_avg' => $row['test_avg'] ?? null,
'ptap_score' => $row['ptap_score'] ?? null,
'attendance_score' => $row['attendance_score'] ?? null,
'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
'semester_score' => $row['semester_score'] ?? null,
],
];
if (trim($htmlInput) !== '') {
$payload['html'] = $htmlInput;
}
\CodeIgniter\Events\Events::trigger('below60.decision_email', $payload);
$query = http_build_query(['semester' => $semester, 'school_year' => $schoolYear]);
return redirect()->to(base_url('grading/below-60/decisions') . ($query ? '?' . $query : ''))
->with('status', 'Decision email sent to parent(s).');
}
public function allDecisions()
{
$configuredYear = (string)$this->schoolYear;
$schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
if ($schoolYear === '') $schoolYear = $configuredYear;
$schoolYears = $this->getSchoolYearsForScores($schoolYear);
// Load saved year decisions (semester='year') for this school year
$decModel = new StudentDecisionModel();
$saved = $decModel
->where('semester', 'year')
->where('school_year', $schoolYear)
->findAll();
$savedMap = [];
foreach ($saved as $s) {
$savedMap[(int)$s['student_id']] = $s;
}
// Fetch Fall and Spring semester_scores per student for this school year
$allScoreRows = $this->db->table('semester_scores ss')
->select([
's.id AS student_id',
's.school_id',
's.firstname',
's.lastname',
'cs.class_section_name',
'LOWER(TRIM(ss.semester)) AS sem_key',
'ss.semester_score',
])
->join('students s', 's.id = ss.student_id', 'inner')
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
->where('ss.semester_score IS NOT NULL', null, false)
->orderBy('cs.class_section_name', 'ASC')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()->getResultArray();
// Group by student: keep one base info row + fall/spring scores
$studentMap = [];
foreach ($allScoreRows as $sr) {
$sid = (int)$sr['student_id'];
if (!isset($studentMap[$sid])) {
$studentMap[$sid] = [
'student_id' => $sid,
'school_id' => $sr['school_id'] ?? '',
'firstname' => $sr['firstname'] ?? '',
'lastname' => $sr['lastname'] ?? '',
'class_section_name' => $sr['class_section_name'] ?? '',
'fall_score' => null,
'spring_score' => null,
];
}
$semKey = strtolower(trim((string)($sr['sem_key'] ?? '')));
$val = is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null;
if ($semKey === 'fall') {
$studentMap[$sid]['fall_score'] = $val;
} elseif ($semKey === 'spring') {
$studentMap[$sid]['spring_score'] = $val;
}
}
// Pull below-60 decisions for either semester (use worst available)
$belowDecModel = new BelowSixtyDecisionModel();
$belowRows = $belowDecModel
->where('school_year', $schoolYear)
->findAll();
$belowMap = [];
foreach ($belowRows as $b) {
$sid = (int)$b['student_id'];
// prefer a non-empty decision over empty
if (!isset($belowMap[$sid]) || (string)($belowMap[$sid]['decision'] ?? '') === '') {
$belowMap[$sid] = $b;
}
}
// Build final rows with year_score = (fall + spring) / 2
$rows = [];
foreach ($studentMap as $sid => $info) {
$fall = $info['fall_score'];
$spring = $info['spring_score'];
if ($fall !== null && $spring !== null) {
$yearScore = round(($fall + $spring) / 2, 2);
} elseif ($fall !== null) {
$yearScore = $fall;
} elseif ($spring !== null) {
$yearScore = $spring;
} else {
$yearScore = null;
}
if (isset($savedMap[$sid])) {
$decision = (string)($savedMap[$sid]['decision'] ?? '');
$source = (string)($savedMap[$sid]['source'] ?? 'auto');
$notes = (string)($savedMap[$sid]['notes'] ?? '');
} elseif ($yearScore !== null && $yearScore >= 60) {
$decision = 'Pass';
$source = 'auto';
$notes = '';
} elseif ($yearScore !== null && isset($belowMap[$sid])) {
$decision = (string)($belowMap[$sid]['decision'] ?? '');
$source = $decision !== '' ? 'manual' : 'pending';
$notes = (string)($belowMap[$sid]['notes'] ?? '');
} else {
$decision = '';
$source = 'pending';
$notes = '';
}
$rows[] = [
'student_id' => $sid,
'school_id' => $info['school_id'],
'firstname' => $info['firstname'],
'lastname' => $info['lastname'],
'class_section_name' => $info['class_section_name'],
'fall_score' => $fall,
'spring_score' => $spring,
'year_score' => $yearScore,
'decision' => $decision,
'source' => $source,
'notes' => $notes,
'saved' => isset($savedMap[$sid]),
];
}
$generated = !empty($saved);
return view('grading/all_decisions', [
'rows' => $rows,
'schoolYear' => $schoolYear,
'schoolYears' => $schoolYears,
'generated' => $generated,
]);
}
public function generateAllDecisions()
{
$schoolYear = trim((string)$this->request->getPost('school_year'));
if ($schoolYear === '') {
return redirect()->back()->with('error', 'Missing school year.');
}
// Fetch Fall and Spring scores per student
$allScoreRows = $this->db->table('semester_scores ss')
->select([
's.id AS student_id',
's.firstname',
's.lastname',
'cs.class_section_name',
'LOWER(TRIM(ss.semester)) AS sem_key',
'ss.semester_score',
])
->join('students s', 's.id = ss.student_id', 'inner')
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
->where('ss.semester_score IS NOT NULL', null, false)
->get()->getResultArray();
if (empty($allScoreRows)) {
return redirect()->back()->with('error', 'No semester scores found for this school year.');
}
// Group by student
$studentMap = [];
foreach ($allScoreRows as $sr) {
$sid = (int)$sr['student_id'];
if (!isset($studentMap[$sid])) {
$studentMap[$sid] = [
'firstname' => $sr['firstname'] ?? '',
'lastname' => $sr['lastname'] ?? '',
'class_section_name' => $sr['class_section_name'] ?? '',
'fall_score' => null,
'spring_score' => null,
];
}
$semKey = strtolower(trim((string)($sr['sem_key'] ?? '')));
$val = is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null;
if ($semKey === 'fall') {
$studentMap[$sid]['fall_score'] = $val;
} elseif ($semKey === 'spring') {
$studentMap[$sid]['spring_score'] = $val;
}
}
// Pull below-60 decisions for any semester of this year
$belowDecModel = new BelowSixtyDecisionModel();
$belowRows = $belowDecModel
->where('school_year', $schoolYear)
->findAll();
$belowMap = [];
foreach ($belowRows as $b) {
$sid = (int)$b['student_id'];
if (!isset($belowMap[$sid]) || (string)($belowMap[$sid]['decision'] ?? '') === '') {
$belowMap[$sid] = $b;
}
}
// Load existing year decisions to upsert
$decModel = new StudentDecisionModel();
$existing = $decModel
->where('semester', 'year')
->where('school_year', $schoolYear)
->findAll();
$existingMap = [];
foreach ($existing as $e) {
$existingMap[(int)$e['student_id']] = $e;
}
$userId = (int)(session()->get('user_id') ?? 0) ?: null;
$savedCount = 0;
foreach ($studentMap as $sid => $info) {
$fall = $info['fall_score'];
$spring = $info['spring_score'];
if ($fall !== null && $spring !== null) {
$yearScore = round(($fall + $spring) / 2, 2);
} elseif ($fall !== null) {
$yearScore = $fall;
} elseif ($spring !== null) {
$yearScore = $spring;
} else {
continue;
}
if ($yearScore >= 60) {
$decision = 'Pass';
$source = 'auto';
$notes = null;
} elseif (isset($belowMap[$sid]) && (string)($belowMap[$sid]['decision'] ?? '') !== '') {
$decision = (string)$belowMap[$sid]['decision'];
$source = 'manual';
$notes = ($belowMap[$sid]['notes'] ?? '') !== '' ? (string)$belowMap[$sid]['notes'] : null;
} else {
$decision = null;
$source = 'pending';
$notes = null;
}
$payload = [
'student_id' => $sid,
'semester' => 'year',
'school_year' => $schoolYear,
'class_section_name' => $info['class_section_name'] ?? null,
'semester_score' => $yearScore,
'decision' => $decision,
'source' => $source,
'notes' => $notes,
'generated_by' => $userId,
];
if (isset($existingMap[$sid])) {
$decModel->update($existingMap[$sid]['id'], $payload);
} else {
$decModel->insert($payload);
}
$savedCount++;
}
$query = http_build_query(['school_year' => $schoolYear]);
return redirect()->to(base_url('grading/decisions') . '?' . $query)
->with('status', "Decisions generated for {$savedCount} students.");
}
public function getScoreComment()
{
// Get all students for the current semester and school year
+12 -29
View File
@@ -73,19 +73,6 @@ class ReportCardsController extends PrintablesBaseController
$semesters = array_values(array_filter(array_map(static fn($r) => (string)($r['semester'] ?? ''), $rs)));
} catch (\Throwable $e) {
}
try {
$rs2 = $this->db->table('student_class')
->select('DISTINCT semester', false)
->where('school_year', $year)
->where('semester IS NOT NULL', null, false)
->orderBy('semester', 'ASC')
->get()->getResultArray();
foreach ($rs2 as $r) {
$val = (string)($r['semester'] ?? '');
if ($val !== '' && !in_array($val, $semesters, true)) $semesters[] = $val;
}
} catch (\Throwable $e) {
}
// Ensure standard options are present even if data is missing (so Spring can be selected)
$defaults = array_values(array_filter([(string)($this->semester ?? ''), 'Fall', 'Spring'], static fn($v) => $v !== ''));
foreach ($defaults as $d) {
@@ -1566,34 +1553,30 @@ class ReportCardsController extends PrintablesBaseController
$semRange = $this->semesterRangeService->getSemesterRange($refYear, ucfirst($normSemester));
if ($semRange) {
try {
$events = $this->calendarModel->getEventsBySchoolYearAndSemester($refYear, $normSemester);
// Fetch all events for the year (no semester filter) so that
// no-school events stored with a different semester label or
// NULL semester are still picked up. Date-range filtering below.
$events = $this->calendarModel->getEventsBySchoolYearAndSemester($refYear, null);
$noSchoolDays = [];
foreach ($events as $event) {
if (empty($event['no_school'])) {
continue;
}
$dateStr = substr((string)($event['date'] ?? ''), 0, 10);
if ($dateStr === '') {
continue;
}
try {
$eventDate = new \DateTime($dateStr);
} catch (\Throwable) {
continue;
}
if ($eventDate->format('N') !== '7') {
continue;
}
if ($dateStr < $semRange[0] || $dateStr > $semRange[1]) {
if ($dateStr === '' || $dateStr < $semRange[0] || $dateStr > $semRange[1]) {
continue;
}
$noSchoolDays[$dateStr] = true;
}
$sundays = $this->listSundays($semRange[0], $semRange[1]);
$anchor = $this->resolveAnchorSunday();
$limit = $semRange[1];
if ($anchor !== '' && $anchor >= $semRange[0]) {
$limit = min($anchor, $semRange[1]);
// Only cap at "today" while the semester is still in progress.
// Once the semester end date has passed, count all its Sundays.
if ($semRange[1] > date('Y-m-d')) {
$anchor = $this->resolveAnchorSunday();
if ($anchor !== '' && $anchor >= $semRange[0]) {
$limit = min($anchor, $semRange[1]);
}
}
$totalSemesterDays = 0;
foreach ($sundays as $date) {
+504
View File
@@ -0,0 +1,504 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ConfigurationModel;
class TrophyController extends BaseController
{
private const PERCENTILE = 75.0;
protected ConfigurationModel $configModel;
public function __construct()
{
$this->configModel = new ConfigurationModel();
}
public function index()
{
$db = \Config\Database::connect();
$currentYear = $this->configModel->getConfig('school_year') ?? '';
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
$percentile = max(1.0, min(99.0, $percentile));
// Available school years from class assignments so the current year can
// still appear even if fall scores are not fully entered yet.
$years = $db->table('student_class')
->select('school_year')
->distinct()
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
$years = array_column($years, 'school_year');
// Build a fall-score-based projection. We start from class assignments so
// students without a recorded fall score still appear as "not yet
// projected" rather than disappearing from the page.
$rows = $db->table('student_class sc')
->select([
'sc.student_id',
'sc.class_section_id',
'MAX(ss.semester_score) AS fall_score',
'cs.class_section_name',
'CONCAT(s.firstname, " ", s.lastname) AS student_name',
's.school_id',
's.gender',
])
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('students s', 's.id = sc.student_id', 'left')
->join(
'semester_scores ss',
'ss.student_id = sc.student_id'
. ' AND ss.class_section_id = sc.class_section_id'
. ' AND ss.school_year = ' . $db->escape($selectedYear)
. ' AND LOWER(ss.semester) = "fall"',
'left'
)
->where('sc.school_year', $selectedYear)
->orderBy('cs.class_section_name', 'ASC')
->orderBy('fall_score', 'DESC')
->orderBy('student_name', 'ASC')
->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id')
->get()
->getResultArray();
// Group rows by class section
$sections = [];
foreach ($rows as $row) {
$sid = (int) ($row['class_section_id'] ?? 0);
$name = $row['class_section_name'] ?? 'Section ' . $sid;
if (!isset($sections[$sid])) {
$sections[$sid] = ['name' => $name, 'students' => []];
}
$sections[$sid]['students'][] = [
'student_id' => (int) $row['student_id'],
'name' => $row['student_name'],
'school_id' => $row['school_id'],
'gender' => $row['gender'] ?? '',
'fall_score' => is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null,
];
}
// Calculate trophy thresholds from fall scores only, then project who is
// currently on track for a year-end trophy.
$classResults = [];
foreach ($sections as $sid => $section) {
usort($section['students'], static function (array $a, array $b): int {
$aScore = $a['fall_score'];
$bScore = $b['fall_score'];
if ($aScore === null && $bScore === null) {
return strcmp($a['name'], $b['name']);
}
if ($aScore === null) {
return 1;
}
if ($bScore === null) {
return -1;
}
return $bScore <=> $aScore ?: strcmp($a['name'], $b['name']);
});
$scores = array_column($section['students'], 'fall_score');
$calc = $this->calculateThreshold($scores, $percentile);
$threshold = $calc['threshold'];
$students = array_map(function (array $student) use ($threshold): array {
$student['projected_trophy'] = $threshold !== null
&& $student['fall_score'] !== null
&& $student['fall_score'] >= $threshold;
return $student;
}, $section['students']);
$scoredCount = count(array_filter(
array_column($students, 'fall_score'),
static fn ($score) => $score !== null
));
$boys = array_filter($students, static fn ($s) => strtolower($s['gender']) === 'male');
$girls = array_filter($students, static fn ($s) => strtolower($s['gender']) === 'female');
$trophyBoys = array_filter($boys, static fn ($s) => $s['projected_trophy']);
$trophyGirls = array_filter($girls, static fn ($s) => $s['projected_trophy']);
$nBoys = count($boys);
$nGirls = count($girls);
$nTrophyBoys = count($trophyBoys);
$nTrophyGirls = count($trophyGirls);
$total = count($students);
$classResults[] = [
'section_id' => $sid,
'section_name' => $section['name'],
'students' => $students,
'threshold' => $threshold,
'trophy_count' => $calc['winners'],
'student_count' => $total,
'scored_count' => $scoredCount,
'method' => $calc['method'],
'boys' => $nBoys,
'girls' => $nGirls,
'trophy_boys' => $nTrophyBoys,
'trophy_girls' => $nTrophyGirls,
'pct_boys' => $total > 0 ? round($nBoys / $total * 100) : 0,
'pct_girls' => $total > 0 ? round($nGirls / $total * 100) : 0,
'pct_trophy_boys' => $nBoys > 0 ? round($nTrophyBoys / $nBoys * 100) : 0,
'pct_trophy_girls' => $nGirls > 0 ? round($nTrophyGirls / $nGirls * 100) : 0,
'pct_trophy_total' => $total > 0 ? round($calc['winners'] / $total * 100): 0,
];
}
return view('administrator/trophy', [
'classResults' => $classResults,
'selectedYear' => $selectedYear,
'selectedPercentile'=> $percentile,
'years' => $years,
]);
}
public function winners()
{
$db = \Config\Database::connect();
$currentYear = $this->configModel->getConfig('school_year') ?? '';
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
$percentile = max(1.0, min(99.0, $percentile));
$years = $db->table('student_class')
->select('school_year')->distinct()
->orderBy('school_year', 'DESC')
->get()->getResultArray();
$years = array_column($years, 'school_year');
$ey = $db->escape($selectedYear);
$rows = $db->table('student_class sc')
->select([
'sc.student_id',
'sc.class_section_id',
'cs.class_section_name',
'CONCAT(s.firstname, " ", s.lastname) AS student_name',
's.school_id',
's.gender',
'MAX(sf.semester_score) AS fall_score',
'MAX(sp.semester_score) AS spring_score',
])
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('students s', 's.id = sc.student_id', 'left')
->join('semester_scores sf',
'sf.student_id = sc.student_id AND sf.class_section_id = sc.class_section_id'
. ' AND sf.school_year = ' . $ey . ' AND LOWER(sf.semester) = "fall"', 'left')
->join('semester_scores sp',
'sp.student_id = sc.student_id AND sp.class_section_id = sc.class_section_id'
. ' AND sp.school_year = ' . $ey . ' AND LOWER(sp.semester) = "spring"', 'left')
->where('sc.school_year', $selectedYear)
->orderBy('cs.class_section_name', 'ASC')
->orderBy('student_name', 'ASC')
->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id, s.gender')
->get()->getResultArray();
$sections = [];
foreach ($rows as $row) {
$sid = (int) ($row['class_section_id'] ?? 0);
$name = $row['class_section_name'] ?? 'Section ' . $sid;
if (!isset($sections[$sid])) $sections[$sid] = ['name' => $name, 'students' => []];
$fall = is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null;
$spring = is_numeric($row['spring_score']) ? (float) $row['spring_score'] : null;
$year = ($fall !== null && $spring !== null)
? round(($fall + $spring) / 2, 1)
: ($fall ?? $spring);
$sections[$sid]['students'][] = [
'name' => $row['student_name'],
'school_id' => $row['school_id'],
'gender' => $row['gender'] ?? '',
'fall_score' => $fall,
'spring_score' => $spring,
'year_score' => $year,
];
}
$classResults = [];
foreach ($sections as $section) {
usort($section['students'], static function ($a, $b) {
if ($a['fall_score'] === null && $b['fall_score'] === null) return strcmp($a['name'], $b['name']);
if ($a['fall_score'] === null) return 1;
if ($b['fall_score'] === null) return -1;
return $b['fall_score'] <=> $a['fall_score'] ?: strcmp($a['name'], $b['name']);
});
$scores = array_column($section['students'], 'fall_score');
$calc = $this->calculateThreshold($scores, $percentile);
$threshold = $calc['threshold'];
$allStudents = $section['students'];
$winners = array_values(array_filter($allStudents, function ($s) use ($threshold) {
return $threshold !== null && $s['fall_score'] !== null && $s['fall_score'] >= $threshold;
}));
if (empty($winners)) continue;
$boys = array_filter($allStudents, static fn($s) => strtolower($s['gender']) === 'male');
$girls = array_filter($allStudents, static fn($s) => strtolower($s['gender']) === 'female');
$trophyBoys = array_filter($winners, static fn($s) => strtolower($s['gender']) === 'male');
$trophyGirls = array_filter($winners, static fn($s) => strtolower($s['gender']) === 'female');
$n = count($allStudents);
$nBoys = count($boys);
$nGirls = count($girls);
$nTB = count($trophyBoys);
$nTG = count($trophyGirls);
$classResults[] = [
'section_name' => $section['name'],
'threshold' => $threshold,
'winners' => $winners,
'student_count' => $n,
'boys' => $nBoys,
'girls' => $nGirls,
'trophy_boys' => $nTB,
'trophy_girls' => $nTG,
'pct_boys' => $n > 0 ? round($nBoys / $n * 100) : 0,
'pct_girls' => $n > 0 ? round($nGirls / $n * 100) : 0,
'pct_trophy_boys' => $nBoys > 0 ? round($nTB / $nBoys * 100) : 0,
'pct_trophy_girls' => $nGirls > 0 ? round($nTG / $nGirls * 100) : 0,
'pct_trophy_total' => $n > 0 ? round(count($winners) / $n * 100) : 0,
];
}
return view('administrator/trophy_winners', [
'classResults' => $classResults,
'selectedYear' => $selectedYear,
'selectedPercentile' => $percentile,
'years' => $years,
]);
}
public function final()
{
$db = \Config\Database::connect();
$currentYear = $this->configModel->getConfig('school_year') ?? '';
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
$percentile = max(1.0, min(99.0, $percentile));
$years = $db->table('student_class')
->select('school_year')->distinct()
->orderBy('school_year', 'DESC')
->get()->getResultArray();
$years = array_column($years, 'school_year');
$ey = $db->escape($selectedYear);
$rows = $db->table('student_class sc')
->select([
'sc.student_id',
'sc.class_section_id',
'cs.class_section_name',
'CONCAT(s.firstname, " ", s.lastname) AS student_name',
's.school_id',
's.gender',
'MAX(sf.semester_score) AS fall_score',
'MAX(sp.semester_score) AS spring_score',
])
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('students s', 's.id = sc.student_id', 'left')
->join('semester_scores sf',
'sf.student_id = sc.student_id AND sf.class_section_id = sc.class_section_id'
. ' AND sf.school_year = ' . $ey . ' AND LOWER(sf.semester) = "fall"', 'left')
->join('semester_scores sp',
'sp.student_id = sc.student_id AND sp.class_section_id = sc.class_section_id'
. ' AND sp.school_year = ' . $ey . ' AND LOWER(sp.semester) = "spring"', 'left')
->where('sc.school_year', $selectedYear)
->orderBy('cs.class_section_name', 'ASC')
->orderBy('student_name', 'ASC')
->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id, s.gender')
->get()->getResultArray();
$sections = [];
foreach ($rows as $row) {
$sid = (int) ($row['class_section_id'] ?? 0);
$name = $row['class_section_name'] ?? 'Section ' . $sid;
if (!isset($sections[$sid])) $sections[$sid] = ['name' => $name, 'students' => []];
$fall = is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null;
$spring = is_numeric($row['spring_score']) ? (float) $row['spring_score'] : null;
$year = ($fall !== null && $spring !== null)
? round(($fall + $spring) / 2, 1)
: ($fall ?? $spring);
$sections[$sid]['students'][] = [
'name' => $row['student_name'],
'school_id' => $row['school_id'],
'gender' => $row['gender'] ?? '',
'fall_score' => $fall,
'spring_score' => $spring,
'year_score' => $year,
];
}
$classResults = [];
foreach ($sections as $section) {
$students = $section['students'];
$fallCalc = $this->calculateThreshold(array_column($students, 'fall_score'), $percentile);
$yearCalc = $this->calculateThreshold(array_column($students, 'year_score'), $percentile);
$fallThreshold = $fallCalc['threshold'];
$yearThreshold = $yearCalc['threshold'];
$annotated = array_map(static function (array $s) use ($fallThreshold, $yearThreshold): array {
$predicted = $fallThreshold !== null && $s['fall_score'] !== null && $s['fall_score'] >= $fallThreshold;
$actual = $yearThreshold !== null && $s['year_score'] !== null && $s['year_score'] >= $yearThreshold;
$status = match (true) {
$predicted && $actual => 'confirmed',
!$predicted && $actual => 'surprise',
$predicted && !$actual => 'missed',
default => 'none',
};
return $s + compact('predicted', 'actual', 'status');
}, $students);
usort($annotated, static function (array $a, array $b): int {
if ($a['year_score'] === null && $b['year_score'] === null) return strcmp($a['name'], $b['name']);
if ($a['year_score'] === null) return 1;
if ($b['year_score'] === null) return -1;
return $b['year_score'] <=> $a['year_score'] ?: strcmp($a['name'], $b['name']);
});
$nConfirmed = count(array_filter($annotated, static fn ($s) => $s['status'] === 'confirmed'));
$nSurprise = count(array_filter($annotated, static fn ($s) => $s['status'] === 'surprise'));
$nMissed = count(array_filter($annotated, static fn ($s) => $s['status'] === 'missed'));
$nPredicted = count(array_filter($annotated, static fn ($s) => $s['predicted']));
$nActual = count(array_filter($annotated, static fn ($s) => $s['actual']));
$n = count($annotated);
$classResults[] = [
'section_name' => $section['name'],
'students' => $annotated,
'student_count' => $n,
'fall_threshold' => $fallThreshold,
'year_threshold' => $yearThreshold,
'predicted_count' => $nPredicted,
'actual_count' => $nActual,
'confirmed' => $nConfirmed,
'surprises' => $nSurprise,
'missed' => $nMissed,
'accuracy' => $nPredicted > 0 ? round($nConfirmed / $nPredicted * 100) : ($nActual === 0 ? 100 : 0),
];
}
return view('administrator/trophy_final', [
'classResults' => $classResults,
'selectedYear' => $selectedYear,
'selectedPercentile' => $percentile,
'years' => $years,
]);
}
private function calculateThreshold(array $scores, float $percentile = 75.0): array
{
$scores = array_values(array_filter(
$scores,
static fn ($v) => is_numeric($v) && $v !== null
));
$scores = array_map('floatval', $scores);
sort($scores); // ascending
$n = count($scores);
if ($n === 0) {
return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
}
$minWinners = 3; // never fewer than 3, regardless of class size or score count
// Hard maximum: top (100 - percentile)% of class, but never below the minimum.
$maxWinners = max($minWinners, (int) floor($n * (1 - $percentile / 100)));
$threshold = $this->empiricalPercentile($scores, $percentile);
$winners = $this->countAtOrAbove($scores, $threshold);
if ($winners < $minWinners) {
// Too few qualify — REDUCE the threshold down to the score of the
// 3rd-ranked student so the minimum of 3 is always met.
// If fewer than 3 students have scores, award all of them.
$target = min($minWinners, $n);
$desc = array_reverse($scores); // descending
$threshold = $desc[$target - 1]; // score at rank $target
$winners = $this->countAtOrAbove($scores, $threshold); // ties included
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
}
if ($winners <= $maxWinners) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
}
// Too many winners — raise threshold to respect the cap.
$result = $this->capByRank($scores, $maxWinners);
// capByRank may overshoot and drop below the minimum (e.g. bimodal scores
// where only 2 students are in the top cluster). Enforce min=3 here too.
if ($result['winners'] < $minWinners) {
$target = min($minWinners, $n);
$desc = array_reverse($scores);
$threshold = $desc[$target - 1];
$winners = $this->countAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
}
return $result;
}
/**
* Raise the threshold until at most $max students qualify.
* Returns the result without checking the minimum — caller must do that.
*/
private function capByRank(array $sortedScores, int $max): array
{
$desc = array_reverse($sortedScores);
$threshold = $desc[$max - 1];
$winners = $this->countAtOrAbove($sortedScores, $threshold);
if ($winners <= $max) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
}
// Ties push it over — walk up through distinct scores.
$unique = array_values(array_unique(array_filter(
$sortedScores,
static fn ($s) => $s > $threshold
)));
sort($unique);
foreach ($unique as $candidate) {
$w = $this->countAtOrAbove($sortedScores, $candidate);
if ($w <= $max) {
return ['threshold' => $candidate, 'winners' => $w, 'method' => 'capped_25pct'];
}
}
// All scores are equal.
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
}
private function empiricalPercentile(array $sortedScores, float $p): float
{
$n = count($sortedScores);
if ($n === 0) return 0.0;
$index = ($p / 100.0) * ($n - 1);
$lower = (int) floor($index);
$upper = (int) ceil($index);
if ($lower === $upper) return $sortedScores[$lower];
return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]);
}
private function countAtOrAbove(array $scores, float $threshold): int
{
return count(array_filter($scores, static fn ($s) => $s >= $threshold));
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateCertificateRecords extends Migration
{
public function up()
{
if ($this->db->tableExists('certificate_records')) {
return;
}
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'certificate_number' => [
'type' => 'VARCHAR',
'constraint' => 30,
],
'student_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
],
'student_name' => [
'type' => 'VARCHAR',
'constraint' => 200,
],
'grade' => [
'type' => 'VARCHAR',
'constraint' => 100,
'null' => true,
],
'cert_date' => [
'type' => 'DATE',
'null' => true,
],
'school_year' => [
'type' => 'VARCHAR',
'constraint' => 20,
'null' => true,
],
'class_section_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true,
],
'issued_by' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true,
],
'issued_at' => [
'type' => 'DATETIME',
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey('certificate_number');
$this->forge->addKey(['school_year', 'student_id']);
$this->forge->createTable('certificate_records');
}
public function down()
{
$this->forge->dropTable('certificate_records', true);
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateBelowSixtyDecisions extends Migration
{
public function up()
{
if ($this->db->tableExists('below_sixty_decisions')) {
return;
}
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'student_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
],
'semester' => [
'type' => 'VARCHAR',
'constraint' => 20,
],
'school_year' => [
'type' => 'VARCHAR',
'constraint' => 20,
],
'decision' => [
'type' => 'VARCHAR',
'constraint' => 100,
'null' => true,
],
'notes' => [
'type' => 'TEXT',
'null' => true,
],
'decided_by' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['student_id', 'semester', 'school_year']);
$this->forge->addKey(['school_year', 'semester']);
$this->forge->createTable('below_sixty_decisions');
}
public function down()
{
$this->forge->dropTable('below_sixty_decisions', true);
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateStudentDecisions extends Migration
{
public function up()
{
if ($this->db->tableExists('student_decisions')) {
return;
}
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'student_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
],
'semester' => [
'type' => 'VARCHAR',
'constraint' => 20,
],
'school_year' => [
'type' => 'VARCHAR',
'constraint' => 20,
],
'class_section_name' => [
'type' => 'VARCHAR',
'constraint' => 100,
'null' => true,
],
'semester_score' => [
'type' => 'DECIMAL',
'constraint' => '8,2',
'null' => true,
],
'decision' => [
'type' => 'VARCHAR',
'constraint' => 100,
'null' => true,
],
'source' => [
'type' => 'ENUM',
'constraint' => ['auto', 'manual', 'pending'],
'default' => 'auto',
],
'notes' => [
'type' => 'TEXT',
'null' => true,
],
'generated_by' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['student_id', 'semester', 'school_year']);
$this->forge->addKey(['school_year', 'semester']);
$this->forge->createTable('student_decisions');
}
public function down()
{
$this->forge->dropTable('student_decisions', true);
}
}
+8 -1
View File
@@ -11,6 +11,8 @@ class ApiAuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
helper('security');
$authorization = $request->getHeaderLine('Authorization');
if (!$authorization || stripos($authorization, 'Bearer ') !== 0) {
return $this->unauthorized('Missing or invalid Authorization header');
@@ -21,7 +23,12 @@ class ApiAuthFilter implements FilterInterface
return $this->unauthorized('Bearer token is required');
}
$secret = env('JWT_SECRET', 'change-me-in-env');
try {
$secret = require_env('JWT_SECRET');
} catch (\RuntimeException $e) {
return $this->unauthorized('JWT configuration is missing');
}
$payload = jwt_decode($token, $secret);
if (!$payload || empty($payload['sub'])) {
+3 -1
View File
@@ -13,13 +13,15 @@ class ApiDocsAuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
helper('security');
$authHeader = $request->getHeaderLine('Authorization');
if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
$token = trim(substr($authHeader, 7));
try {
$key = getenv('JWT_SECRET') ?: 'your_default_secret';
$key = require_env('JWT_SECRET');
$decoded = JWT::decode($token, new Key($key, 'HS256'));
if (!isset($decoded->roles) || empty($decoded->roles->admin)) {
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
class ApiRateLimitFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$throttler = service('throttler');
$response = Services::response();
$maxRequests = isset($arguments[0]) ? max(1, (int) $arguments[0]) : 60;
$windowSeconds = isset($arguments[1]) ? max(1, (int) $arguments[1]) : MINUTE;
$userId = session()->get('user_id');
$identifier = $userId ? 'user:' . $userId : 'ip:' . $request->getIPAddress();
$route = trim($request->getUri()->getPath(), '/');
$key = 'api-rate-' . sha1($request->getMethod() . '|' . $route . '|' . $identifier);
if (! $throttler->check($key, $maxRequests, $windowSeconds)) {
return $response
->setStatusCode(429)
->setHeader('Retry-After', (string) $windowSeconds)
->setJSON([
'status' => false,
'message' => 'Too many requests. Please try again later.',
]);
}
return null;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
return $response;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
class SanitizeInputFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
helper('security');
$sanitizedGet = sanitize_request_value($request->getGet() ?? []);
$sanitizedPost = sanitize_request_value($request->getPost() ?? []);
$request->setGlobal('get', $sanitizedGet);
$request->setGlobal('post', $sanitizedPost);
$request->setGlobal('request', array_merge(is_array($sanitizedGet) ? $sanitizedGet : [], is_array($sanitizedPost) ? $sanitizedPost : []));
$request->setGlobal('cookie', sanitize_request_value($request->getCookie() ?? []));
$contentType = strtolower($request->getHeaderLine('Content-Type'));
$body = $request->getBody();
if (str_contains($contentType, 'application/json') && trim($body) !== '') {
$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($decoded)) {
return Services::response()
->setStatusCode(400)
->setJSON([
'status' => false,
'message' => 'Malformed JSON payload.',
]);
}
$request->setBody((string) json_encode(sanitize_request_value($decoded), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
return null;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
return $response;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
if (! function_exists('sanitize_request_value')) {
/**
* Recursively normalize request values without applying output encoding.
*/
function sanitize_request_value($value)
{
if (is_array($value)) {
foreach ($value as $key => $item) {
$value[$key] = sanitize_request_value($item);
}
return $value;
}
if (! is_string($value)) {
return $value;
}
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? $value;
return trim($value);
}
}
if (! function_exists('require_env')) {
/**
* Fetch an environment variable and fail closed when it is missing.
*/
function require_env(string $key): string
{
$value = env($key);
if ($value === null || $value === '') {
throw new RuntimeException(sprintf('Missing required environment variable: %s', $key));
}
return (string) $value;
}
}
+2 -1
View File
@@ -16,8 +16,9 @@ class StaffTimeOffLinkService
public function __construct(?string $secret = null, ?int $ttlSeconds = null)
{
helper('jwt');
helper('security');
$this->secret = $secret ?: (string)env('JWT_SECRET', 'change-me-in-env');
$this->secret = $secret ?: require_env('JWT_SECRET');
$this->ttl = ($ttlSeconds !== null && $ttlSeconds > 0) ? $ttlSeconds : self::DEFAULT_TTL;
}
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace App\Listeners;
use Config\Services;
class DecisionEmailListener
{
public static function handle(array $payload): void
{
$studentId = (int)($payload['student_id'] ?? 0);
$studentName = (string)($payload['student_name'] ?? '');
$classSection= (string)($payload['class_section_name'] ?? '');
$semester = (string)($payload['semester'] ?? '');
$schoolYear = (string)($payload['school_year'] ?? '');
$decision = (string)($payload['decision'] ?? '');
$notes = (string)($payload['notes'] ?? '');
$scores = is_array($payload['scores'] ?? null) ? $payload['scores'] : [];
$allSemesters = is_array($payload['all_semesters'] ?? null) ? $payload['all_semesters'] : [];
if ($studentId <= 0) {
log_message('warning', 'DecisionEmail: missing student_id');
return;
}
$db = \Config\Database::connect();
$rows = $db->query(
"SELECT u.firstname, u.lastname, u.email, fg.is_primary
FROM family_students fs
JOIN family_guardians fg ON fg.family_id = fs.family_id
JOIN users u ON u.id = fg.user_id
WHERE fs.student_id = ?
AND fg.receive_emails = 1
AND u.email IS NOT NULL AND u.email != ''
ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
[$studentId]
)->getResultArray();
if (empty($rows)) {
log_message('warning', 'DecisionEmail: no guardian emails for student_id=' . $studentId);
return;
}
$emails = [];
foreach ($rows as $row) {
$em = trim((string)($row['email'] ?? ''));
if ($em !== '') $emails[$em] = true;
}
$emails = array_keys($emails);
$primary = $rows[0] ?? [];
$parentName = trim((string)($primary['firstname'] ?? '') . ' ' . (string)($primary['lastname'] ?? ''));
$subject = (string)($payload['subject'] ?? '');
if ($subject === '') {
$subject = 'Academic Decision';
if ($studentName !== '') $subject .= ' — ' . $studentName;
if ($semester !== '' || $schoolYear !== '') {
$subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
}
}
$emailData = [
'title' => $subject,
'parent_name' => $parentName !== '' ? $parentName : 'Parent/Guardian',
'student_name' => $studentName !== '' ? $studentName : 'your student',
'class_section_name' => $classSection,
'semester' => $semester,
'school_year' => $schoolYear,
'decision' => $decision,
'notes' => $notes,
'scores' => $scores,
'all_semesters' => $allSemesters,
];
$html = trim((string)($payload['html'] ?? ''));
if ($html === '') {
$html = view('emails/below_sixty_decision', $emailData, ['saveData' => true]);
}
$okAny = false;
foreach ($emails as $to) {
$ok = self::sendViaEmailService($to, $subject, $html);
if (!$ok) {
$ok = self::sendViaCiEmail($to, $subject, $html);
}
$okAny = $okAny || $ok;
}
if ($okAny) {
log_message('info', 'DecisionEmail: sent for student_id=' . $studentId);
} else {
log_message('error', 'DecisionEmail: failed for student_id=' . $studentId);
}
}
protected static function sendViaEmailService(string $to, string $subject, string $html): bool
{
try {
$svc = function_exists('service') ? service('emailService') : null;
if (!$svc && method_exists(Services::class, 'emailService')) {
$svc = Services::emailService();
}
if (!$svc) return false;
return (bool)$svc->send($to, $subject, $html, 'general');
} catch (\Throwable $e) {
log_message('debug', 'DecisionEmail sendViaEmailService failed: ' . $e->getMessage());
return false;
}
}
protected static function sendViaCiEmail(string $to, string $subject, string $html): bool
{
try {
$email = Services::email();
$cfg = config('Email');
$fromEmail = $cfg->fromEmail ?? $cfg->SMTPUser ?? 'no-reply@example.com';
$fromName = $cfg->fromName ?? 'Al Rahma Sunday School';
$email->setTo($to);
$email->setFrom($fromEmail, $fromName);
$email->setSubject($subject);
$email->setMessage($html);
$email->setMailType('html');
$ok = $email->send();
if (!$ok) {
$dbg = method_exists($email, 'printDebugger') ? $email->printDebugger(['headers', 'subject']) : 'no debugger';
log_message('debug', 'DecisionEmail CI send failed: ' . print_r($dbg, true));
}
return $ok;
} catch (\Throwable $e) {
log_message('debug', 'DecisionEmail sendViaCiEmail exception: ' . $e->getMessage());
return false;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class BelowSixtyDecisionModel extends Model
{
protected $table = 'below_sixty_decisions';
protected $primaryKey = 'id';
protected $allowedFields = [
'student_id',
'semester',
'school_year',
'decision',
'notes',
'decided_by',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class CertificateRecordModel extends Model
{
protected $table = 'certificate_records';
protected $primaryKey = 'id';
protected $useTimestamps = true;
protected $allowedFields = [
'certificate_number',
'student_id',
'student_name',
'grade',
'cert_date',
'school_year',
'class_section_id',
'issued_by',
'issued_at',
];
/**
* Generates the next certificate number for a given school year.
* Format: ARSS-{school_year}-{4-digit sequence} e.g. ARSS-2025-2026-0003
* Uses a transaction + row count to guarantee uniqueness within a request.
* Academic Records and Student Services (ARSS)
*/
public function nextNumber(string $schoolYear): string
{
$db = \Config\Database::connect();
$db->transStart();
$count = $db->table($this->table)
->where('school_year', $schoolYear)
->countAllResults();
$seq = str_pad($count + 1, 4, '0', STR_PAD_LEFT);
$db->transComplete();
return 'ARSS-' . $schoolYear . '-' . $seq;
}
/** Returns paginated records with the issuing admin's name joined. */
public function getAuditLog(?string $schoolYear = null, int $perPage = 50): array
{
$builder = $this->db->table($this->table . ' cr')
->select('cr.*, u.firstname AS admin_firstname, u.lastname AS admin_lastname')
->join('users u', 'u.id = cr.issued_by', 'left')
->orderBy('cr.issued_at', 'DESC');
if ($schoolYear) {
$builder->where('cr.school_year', $schoolYear);
}
return $builder->get()->getResultArray();
}
/** Total certificates issued per school year, ordered by year DESC. */
public function yearSummary(): array
{
return $this->db->table($this->table)
->select('school_year, COUNT(*) AS total')
->groupBy('school_year')
->orderBy('school_year', 'DESC')
->get()->getResultArray();
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class StudentDecisionModel extends Model
{
protected $table = 'student_decisions';
protected $primaryKey = 'id';
protected $allowedFields = [
'student_id',
'semester',
'school_year',
'class_section_name',
'semester_score',
'decision',
'source',
'notes',
'generated_by',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
}
+17 -2
View File
@@ -72,11 +72,26 @@ class SemesterRangeService
$y1 = (int)$m[1];
$y2 = (int)$m[2];
$normalized = ucfirst(strtolower(trim($semester)));
$fallStartCfg = (string)($this->configModel->getConfig('fall_semester_start') ?? '');
$fallEndCfg = (string)($this->configModel->getConfig('fall_end_date') ?? '');
$springStartCfg = (string)($this->configModel->getConfig('spring_semester_start') ?? '');
$springEndCfg = (string)($this->configModel->getConfig('last_school_day') ?? '');
$md = static fn(string $cfg, int $year, string $fallback): string =>
$cfg !== '' ? sprintf('%04d-%s', $year, date('m-d', strtotime($cfg))) : $fallback;
if ($normalized === 'Fall') {
return [sprintf('%04d-09-21', $y1), sprintf('%04d-01-18', $y2)];
return [
$md($fallStartCfg, $y1, sprintf('%04d-09-21', $y1)),
$md($fallEndCfg, $y2, sprintf('%04d-01-18', $y2)),
];
}
if ($normalized === 'Spring') {
return [sprintf('%04d-01-25', $y2), sprintf('%04d-05-31', $y2)];
return [
$md($springStartCfg, $y2, sprintf('%04d-01-25', $y2)),
$md($springEndCfg, $y2, sprintf('%04d-05-31', $y2)),
];
}
return null;
}
+1 -1
View File
@@ -44,7 +44,7 @@
<!-- Navbar Start -->
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
</a>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
@@ -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() ?>
+424
View File
@@ -0,0 +1,424 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid">
<div class="wrapper">
<?php
// ── Group sections by grade (mirrors daily_attendance logic) ─────────────────
$gradeGroups = []; // sortKey => ['label'=>, 'slug'=>, 'csids'=>[]]
foreach ($statsPerClass as $csid => $cs) {
$name = $cs['name'];
$lower = strtolower(trim($name));
if (preg_match('/grade\s*(\d+)/i', $name, $m)) {
$n = (int)$m[1];
$label = 'Grade ' . $n;
$sortKey = '1_' . str_pad($n, 3, '0', STR_PAD_LEFT);
$slug = 'grade' . $n;
} elseif (strpos($lower, 'kg') !== false || strpos($lower, 'kindergarten') !== false) {
$label = 'KG';
$sortKey = '0_kg';
$slug = 'kg';
} elseif (strpos($lower, 'arabic') !== false) {
$label = 'Arabic';
$sortKey = '2_arabic';
$slug = 'arabic';
} elseif (strpos($lower, 'youth') !== false) {
$label = 'Youth';
$sortKey = '5_youth';
$slug = 'youth';
} else {
$label = $name;
$sortKey = '3_' . $lower;
$slug = preg_replace('/[^a-z0-9]+/', '-', $lower);
}
if (!isset($gradeGroups[$sortKey])) {
$gradeGroups[$sortKey] = ['label' => $label, 'slug' => $slug, 'csids' => []];
}
$gradeGroups[$sortKey]['csids'][] = $csid;
}
ksort($gradeGroups);
$gradeKeys = array_keys($gradeGroups);
$defaultKey = $gradeKeys[0] ?? null;
$decisionBadge = [
'Pass' => 'success',
'Repeat Class' => 'danger',
'Make-up exam in fall' => 'info',
'Deferred decision' => 'info',
'Expel' => 'danger',
'Withdrawn' => 'secondary',
];
?>
<h2 class="text-center mt-4 mb-3"><i class="bi bi-award me-2"></i>Generate Certificates</h2>
<!-- School year filter -->
<div class="d-flex justify-content-end mb-3">
<form method="get" action="<?= site_url('administrator/certificates') ?>" class="d-flex gap-2 align-items-center">
<label class="form-label mb-0 me-1 text-muted small">School Year</label>
<input type="text" name="school_year" class="form-control form-control-sm" style="width:130px;"
value="<?= esc($schoolYear) ?>" placeholder="e.g. 2024-2025">
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-arrow-repeat me-1"></i>Reload
</button>
</form>
</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; ?>
<?php if (empty($gradeGroups)): ?>
<div class="alert alert-info">No classes found for <?= esc($schoolYear) ?>.</div>
<?php else: ?>
<!-- Grade tabs -->
<ul class="nav nav-tabs justify-content-center" id="certTabs" role="tablist" style="flex-wrap:wrap;row-gap:.25rem;">
<?php foreach ($gradeGroups as $key => $group): ?>
<?php
$slug = $group['slug'];
$label = $group['label'];
$total = array_sum(array_map(fn($id) => $statsPerClass[$id]['total'] ?? 0, $group['csids']));
$grpPass = array_sum(array_map(fn($id) => $statsPerClass[$id]['pass'] ?? 0, $group['csids']));
$grpCert = array_sum(array_map(fn($id) => $statsPerClass[$id]['cert'] ?? 0, $group['csids']));
$fullyDone = $grpPass > 0 && $grpCert >= $grpPass;
$hasPass = $grpPass > 0;
$isActive = ($key === $defaultKey);
$statusTitle = $hasPass
? ($fullyDone ? 'Fully generated (' . $grpCert . '/' . $grpPass . ')' : 'Not fully generated (' . $grpCert . '/' . $grpPass . ')')
: 'No eligible students';
?>
<li class="nav-item" role="presentation">
<a class="nav-link <?= $isActive ? 'active' : '' ?>"
id="cert-<?= esc($slug) ?>-tab"
data-bs-toggle="tab"
href="#cert-<?= esc($slug) ?>"
role="tab"
aria-controls="cert-<?= esc($slug) ?>"
aria-selected="<?= $isActive ? 'true' : 'false' ?>">
<span class="cert-status-dot <?= !$hasPass ? 'no-eligible' : ($fullyDone ? 'done' : 'pending') ?>"
title="<?= esc($statusTitle) ?>"></span>
<?= esc($label) ?>
<span class="badge bg-secondary ms-1"><?= $total ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
<!-- Tab content -->
<div class="tab-content mt-3" id="certTabContent">
<?php foreach ($gradeGroups as $key => $group): ?>
<?php $isActive = ($key === $defaultKey); ?>
<div class="tab-pane fade <?= $isActive ? 'show active' : '' ?>"
id="cert-<?= esc($group['slug']) ?>"
role="tabpanel"
aria-labelledby="cert-<?= esc($group['slug']) ?>-tab">
<?php foreach ($group['csids'] as $csid): ?>
<?php
$cs = $statsPerClass[$csid];
$students = $studentsByClass[$csid] ?? [];
$csPass = $cs['pass'];
$csCert = $cs['cert'];
$csRemain = max(0, $csPass - $csCert);
$formId = 'certForm-' . $csid;
$tableId = 'studentsTable-' . $csid;
?>
<h4 class="mt-4 mb-2 text-center"><?= esc($cs['name']) ?></h4>
<?php if (empty($students)): ?>
<p class="text-muted text-center">No active students.</p>
<?php else: ?>
<form method="post" action="<?= site_url('administrator/certificates/generate') ?>"
id="<?= esc($formId) ?>" class="cert-form mb-5">
<?= csrf_field() ?>
<input type="hidden" name="class_section_id" value="<?= (int)$csid ?>">
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
<!-- Stats + date picker -->
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<div class="d-flex align-items-center gap-4 flex-wrap">
<span class="fw-semibold">
Students <span class="badge bg-secondary ms-1"><?= count($students) ?></span>
</span>
<span class="text-muted small">
<strong class="text-success"><?= $csPass ?></strong> Pass
</span>
<span class="text-muted small">
<strong class="text-primary"><?= $csCert ?></strong> Generated
</span>
<span class="text-muted small">
<strong class="<?= $csRemain > 0 ? 'text-warning' : 'text-muted' ?>"><?= $csRemain ?></strong> Remaining
</span>
</div>
<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 cert-date-picker"
value="<?= date('Y-m-d') ?>" title="Certificate date">
<input type="hidden" name="cert_date" class="cert-date-hidden" value="<?= esc($certDate) ?>">
</div>
</div>
<!-- Student table -->
<div class="table-responsive">
<table class="table table-hover table-striped align-middle mb-0 cert-students-table"
id="<?= esc($tableId) ?>">
<thead class="table-light">
<tr>
<th style="width:40px;" class="text-center">
<input class="form-check-input cert-select-all" type="checkbox">
</th>
<th>First Name</th>
<th>Last Name</th>
<th>Decision</th>
<th>Certificate No.</th>
</tr>
</thead>
<tbody>
<?php foreach ($students as $s): ?>
<?php
$sid = (int)$s['student_id'];
$stuDec = $decisionsByStudent[$sid] ?? [];
$certNo = $certsByStudent[$sid] ?? null;
$allDecs = array_map(fn($d) => $d['decision'], $stuDec);
$hasPending = in_array('', $allDecs, true);
$unique = array_values(array_unique(array_filter($allDecs, fn($d) => $d !== '')));
$isPass = !empty($stuDec) && !$hasPending && $unique === ['Pass'];
$displayDecs = $isPass ? ['Pass'] : array_values(array_filter($unique, fn($d) => $d !== 'Pass'));
?>
<tr>
<td class="text-center">
<input class="form-check-input cert-student-check" type="checkbox"
name="student_ids[]" value="<?= $sid ?>"
<?= $isPass ? '' : 'disabled' ?>>
</td>
<td><?= esc($s['firstname']) ?></td>
<td><?= esc($s['lastname']) ?></td>
<td>
<?php if (empty($stuDec)): ?>
<span class="text-muted small">—</span>
<?php elseif ($hasPending || empty($unique) || empty($displayDecs)): ?>
<span class="badge bg-warning text-dark">Pending</span>
<?php else: ?>
<?php foreach ($displayDecs as $dec): $color = $decisionBadge[$dec] ?? 'secondary'; ?>
<span class="badge bg-<?= esc($color) ?> me-1"><?= esc($dec) ?></span>
<?php endforeach; ?>
<?php endif; ?>
</td>
<td>
<?php if ($certNo): ?>
<a href="<?= site_url('administrator/certificates/reprint/' . rawurlencode($certNo)) ?>"
target="_blank" class="font-monospace small">
<?= esc($certNo) ?>
</a>
<?php else: ?>
<span class="text-muted">—</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Footer -->
<div class="d-flex justify-content-between align-items-center mt-2">
<span class="text-muted small cert-selected-count">0 students selected</span>
<button type="submit" class="btn btn-success cert-generate-btn" disabled>
<i class="bi bi-printer me-1"></i>Generate &amp; Print Certificates
</button>
</div>
</form>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div><!-- .wrapper -->
</div><!-- .container-fluid -->
<script>
(function () {
const csrfRefreshUrl = <?= json_encode(site_url('administrator/certificates/csrf-token'), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
let currentCsrfTokenName = <?= json_encode(csrf_token(), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
function cssEscape(v) {
return window.CSS?.escape ? window.CSS.escape(v) : String(v).replace(/(["\\.#:[\],= ])/g, '\\$1');
}
function updateCsrfInForm(form, name, hash) {
if (!form || !name || !hash) return;
form.querySelectorAll('input[type="hidden"]').forEach(inp => {
if (inp.name === name || inp.name === currentCsrfTokenName) { inp.name = name; inp.value = hash; }
});
let inp = form.querySelector(`input[name="${cssEscape(name)}"]`);
if (!inp) { inp = document.createElement('input'); inp.type = 'hidden'; inp.name = name; form.appendChild(inp); }
inp.value = hash;
currentCsrfTokenName = name;
}
async function refreshCsrf(form) {
const r = await fetch(csrfRefreshUrl, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Cache-Control': 'no-store' } });
if (!r.ok) throw new Error('CSRF refresh failed');
const d = await r.json();
if (d?.csrf_token && d?.csrf_hash) updateCsrfInForm(form, d.csrf_token, d.csrf_hash);
}
let pdfWindow = null, activePdfBlobUrl = null;
function openPdfWindow() {
if (!pdfWindow || pdfWindow.closed) pdfWindow = window.open('', 'certificatePdfWindow');
if (!pdfWindow) return null;
pdfWindow.document.open();
pdfWindow.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Certificate PDF</title>
<style>html,body{margin:0;height:100%;background:#f3f4f6;font-family:Arial,sans-serif}.viewer-shell{display:flex;flex-direction:column;height:100%}.viewer-status{padding:12px 16px;background:#111827;color:#fff;font-size:14px}.viewer-frame{flex:1;width:100%;border:0;background:#cbd5e1}</style></head>
<body><div class="viewer-shell"><div class="viewer-status" id="viewerStatus">Preparing certificate PDF...</div><iframe class="viewer-frame" id="pdfFrame"></iframe></div>
<script>window.showCertificatePdf=function(url,fn){const f=document.getElementById('pdfFrame'),s=document.getElementById('viewerStatus');if(s)s.textContent=fn?'Showing '+fn:'Certificate PDF ready';if(f)f.src=url;document.title=fn||'Certificate PDF'};<\/script></body></html>`);
pdfWindow.document.close();
return pdfWindow;
}
function showError(pane, msg) {
let el = pane.querySelector('.cert-inline-error');
if (!el) { el = document.createElement('div'); el.className = 'alert alert-danger alert-dismissible fade show cert-inline-error'; pane.prepend(el); }
el.innerHTML = `${msg}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
}
document.querySelectorAll('.cert-form').forEach(function (form) {
const pane = form.closest('.tab-pane') || form.parentElement;
const selectAll = form.querySelector('.cert-select-all');
const checks = form.querySelectorAll('.cert-student-check');
const eligibleChecks = form.querySelectorAll('.cert-student-check:not([disabled])');
const generateBtn = form.querySelector('.cert-generate-btn');
const selectedCount = form.querySelector('.cert-selected-count');
const datePicker = form.querySelector('.cert-date-picker');
const dateHidden = form.querySelector('.cert-date-hidden');
if (datePicker && dateHidden) {
datePicker.addEventListener('change', function () {
const d = new Date(this.value + 'T00:00:00');
if (!isNaN(d)) dateHidden.value = String(d.getMonth()+1).padStart(2,'0')+'/'+String(d.getDate()).padStart(2,'0')+'/'+d.getFullYear();
});
}
function updateState() {
const chosen = form.querySelectorAll('.cert-student-check:checked').length;
if (selectedCount) selectedCount.textContent = chosen + ' student' + (chosen !== 1 ? 's' : '') + ' selected';
if (generateBtn) generateBtn.disabled = chosen === 0;
if (selectAll) {
const ec = eligibleChecks.length;
selectAll.checked = ec > 0 && chosen === ec;
selectAll.indeterminate = chosen > 0 && chosen < ec;
}
}
if (selectAll) {
selectAll.addEventListener('change', function () {
eligibleChecks.forEach(c => { c.checked = this.checked; });
updateState();
});
}
checks.forEach(c => c.addEventListener('change', updateState));
updateState();
form.addEventListener('submit', async function (e) {
e.preventDefault();
const chosen = form.querySelectorAll('.cert-student-check:checked').length;
if (chosen === 0) { showError(pane, 'Please select at least one student.'); return; }
const win = openPdfWindow();
if (!win) { showError(pane, 'Unable to open PDF tab — please allow pop-ups.'); return; }
const origHtml = generateBtn.innerHTML;
generateBtn.disabled = true;
generateBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Generating...';
try {
await refreshCsrf(form);
const csrfVal = form.querySelector(`input[name="${cssEscape(currentCsrfTokenName)}"]`)?.value || '';
const fd = new FormData(form);
if (csrfVal) fd.set(currentCsrfTokenName, csrfVal);
const resp = await fetch(form.action, { method: 'POST', body: fd, credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const nn = resp.headers.get('X-CSRF-TOKEN-NAME'), nh = resp.headers.get('X-CSRF-TOKEN');
if (nn && nh) updateCsrfInForm(form, nn, nh);
const ct = resp.headers.get('Content-Type') || '';
if (!resp.ok || !ct.toLowerCase().includes('application/pdf')) {
let msg = 'Certificate generation failed.';
try { const d = await resp.json(); if (d?.error) msg = d.error; } catch (_) { try { msg = await resp.text() || msg; } catch (_2) {} }
win.close(); showError(pane, msg);
await refreshCsrf(form).catch(() => {});
return;
}
const disp = resp.headers.get('Content-Disposition') || '';
const fn = (disp.match(/filename="?([^"]+)"?/i) || [])[1] || 'Certificates.pdf';
const url = URL.createObjectURL(await resp.blob());
if (activePdfBlobUrl) URL.revokeObjectURL(activePdfBlobUrl);
activePdfBlobUrl = url;
win.showCertificatePdf(url, fn);
const activePane = form.closest('.tab-pane');
if (activePane?.id) window.location.hash = activePane.id;
window.location.reload();
} catch (_) {
showError(pane, 'Certificate generation failed. Please try again.');
await refreshCsrf(form).catch(() => {});
} finally {
generateBtn.innerHTML = origHtml;
updateState();
}
});
});
})();
</script>
<?= $this->section('scripts') ?>
<script>
// Restore tab from hash — runs after Bootstrap is loaded
(function () {
const hash = window.location.hash;
if (!hash) return;
const tabLink = document.querySelector('a[href="' + hash + '"][data-bs-toggle="tab"]');
if (tabLink && window.bootstrap?.Tab) {
new bootstrap.Tab(tabLink).show();
}
})();
</script>
<style>
.cert-status-dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
margin-right: 5px;
vertical-align: middle;
flex-shrink: 0;
}
.cert-status-dot.done { background-color: #198754; }
.cert-status-dot.pending { background-color: #dc3545; }
.cert-status-dot.no-eligible { background-color: #adb5bd; }
</style>
<script>
(function () {
if (!window.$ || !$.fn?.DataTable) return;
$(function () {
document.querySelectorAll('.cert-students-table').forEach(function (tbl) {
if ($.fn.DataTable.isDataTable(tbl)) return;
try {
$(tbl).DataTable({
order: [[1, 'asc'], [2, 'asc']],
pageLength: 100,
lengthMenu: [25, 50, 100, 200],
columnDefs: [{ orderable: false, targets: [0, 3, 4] }]
});
} catch (_) {}
});
});
})();
</script>
<?= $this->endSection() ?>
<?= $this->endSection() ?>
+114 -120
View File
@@ -145,9 +145,24 @@
$<?= esc(number_format($selectedEvent['amount'] ?? 0, 2)) ?> fee
</span>
</div>
<p class="mb-1 text-muted small">
<?= esc($selectedEvent['description'] ?: 'No description provided for this event.') ?>
</p>
<?php $eventDescription = trim((string) ($selectedEvent['description'] ?? '')); ?>
<div class="mb-1">
<button type="button"
class="btn btn-link btn-sm px-0 py-0 text-decoration-none event-description-toggle"
data-bs-toggle="collapse"
data-bs-target="#selectedEventDescription"
aria-expanded="false"
aria-controls="selectedEventDescription"
data-label-collapsed="Show description"
data-label-expanded="Hide description">
Show description
</button>
<div class="collapse mt-2" id="selectedEventDescription">
<div class="text-muted small" style="white-space: pre-line;">
<?= esc($eventDescription !== '' ? $eventDescription : 'No description provided for this event.') ?>
</div>
</div>
</div>
<?php if (!empty($selectedEvent['expiration_date'])): ?>
<small class="text-secondary">
Expires: <?= esc(local_date($selectedEvent['expiration_date'], 'm-d-Y')) ?>
@@ -209,23 +224,51 @@
<?php if (empty($grouped)): ?>
<div class="alert alert-info">No charges found.</div>
<?php else: ?>
<?php
$printBaseParams = array_filter([
'school_year' => $school_year,
'semester' => $semester,
'parent_id' => $filterParentId > 0 ? $filterParentId : null,
'event_id' => $filterEventId > 0 ? $filterEventId : null,
], static fn ($value) => $value !== null && $value !== '');
$printAllUrl = site_url('administrator/event-charges/pdf');
if (!empty($printBaseParams)) {
$printAllUrl .= '?' . http_build_query($printBaseParams);
}
?>
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3 no-print">
<p class="text-muted mb-0">Click a column header to sort the participants list.</p>
<button type="button" class="btn btn-outline-secondary" id="printAllEventCharges">
<a href="<?= esc($printAllUrl) ?>"
class="btn btn-outline-secondary pdf-print-link"
data-base-href="<?= esc($printAllUrl) ?>"
data-print-scope="all">
Print All Participant Lists
</button>
</a>
</div>
<div id="eventChargeCards">
<?php foreach ($grouped as $eventId => $data): ?>
<?php $eventLabel = $data['label']; ?>
<?php $rows = $data['rows']; ?>
<?php
$singlePrintParams = array_filter([
'school_year' => $school_year,
'semester' => $semester,
'parent_id' => $filterParentId > 0 ? $filterParentId : null,
'event_id' => $eventId,
], static fn ($value) => $value !== null && $value !== '');
$singlePrintUrl = site_url('administrator/event-charges/pdf') . '?' . http_build_query($singlePrintParams);
?>
<div class="card mb-4 event-card" data-event-id="<?= esc($eventId) ?>">
<div class="card-header bg-light fw-semibold event-card-header">
<span><?= esc($eventLabel) ?></span>
<div class="event-card-header-actions no-print">
<button type="button" class="btn btn-outline-secondary btn-sm print-event-card" data-event-id="<?= esc($eventId) ?>">
<a href="<?= esc($singlePrintUrl) ?>"
class="btn btn-outline-secondary btn-sm pdf-print-link"
data-base-href="<?= esc($singlePrintUrl) ?>"
data-print-scope="single"
data-event-id="<?= esc($eventId) ?>">
Print This List
</button>
</a>
</div>
</div>
<div class="card-body p-0">
@@ -272,7 +315,7 @@
. (!empty($returnParams) ? '?' . http_build_query($returnParams) : '')
. '#eventTable_' . (int) ($charge['event_id'] ?? 0);
?>
<tr>
<tr data-charge-id="<?= esc((string) ($charge['id'] ?? '')) ?>">
<td data-sort-value="<?= esc((string) ($charge['id'] ?? '')) ?>"><?= esc($charge['id']) ?></td>
<?php
$studentName = $charge['student_firstname']
@@ -290,11 +333,15 @@
);
$externalParentPhone = $charge['external_parent_phone'] ?? '';
$externalParentEmail = $charge['external_parent_email'] ?? '';
$parentPhone = $charge['parent_cellphone'] ?? '';
$standardParentName = trim($charge['parent_firstname'] . ' ' . $charge['parent_lastname']);
$parentColumnName = $externalParentLabel ?: ($standardParentName ?: '—');
$isExternalParticipant = $externalName !== '';
$infoPhone = $isExternalParticipant ? $externalParentPhone : $parentPhone;
$infoEmail = $isExternalParticipant ? $externalParentEmail : '';
$externalInfoValue = trim(implode(' ', array_filter([
$externalParentPhone,
$externalParentEmail,
$infoPhone,
$infoEmail,
$externalNote,
])));
?>
@@ -306,13 +353,13 @@
<?php endif; ?>
</td>
<td data-sort-value="<?= esc(strtolower($externalInfoValue)) ?>">
<?php if ($externalParentPhone): ?>
<div class="text-muted small"><?= esc($externalParentPhone) ?></div>
<?php if ($infoPhone): ?>
<div class="text-muted small"><?= esc($infoPhone) ?></div>
<?php endif; ?>
<?php if ($externalParentEmail): ?>
<div class="text-muted small"><?= esc($externalParentEmail) ?></div>
<?php if ($infoEmail): ?>
<div class="text-muted small"><?= esc($infoEmail) ?></div>
<?php endif; ?>
<?php if (!$externalParentPhone && !$externalParentEmail): ?>
<?php if (!$infoPhone && !$infoEmail): ?>
<span class="text-muted small">—</span>
<?php endif; ?>
</td>
@@ -461,6 +508,24 @@
<?= $this->section('scripts') ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.event-description-toggle').forEach(function (button) {
const targetSelector = button.getAttribute('data-bs-target');
const target = targetSelector ? document.querySelector(targetSelector) : null;
if (!target) {
return;
}
target.addEventListener('show.bs.collapse', function () {
button.textContent = button.dataset.labelExpanded || 'Hide description';
});
target.addEventListener('hide.bs.collapse', function () {
button.textContent = button.dataset.labelCollapsed || 'Show description';
});
});
});
function loadStudentsWithCharges() {
let parentId = $('#parent_id').val();
let eventId = $('#event_id').val();
@@ -556,15 +621,6 @@ function loadStudentsWithCharges() {
form.appendChild(input);
}
function escapeHtml(value) {
const s = String(value ?? '');
return s.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function submitExternalParticipant(payload) {
const form = document.createElement('form');
form.method = 'post';
@@ -653,89 +709,41 @@ function loadStudentsWithCharges() {
);
}
function buildPrintableCard(card) {
const clone = card.cloneNode(true);
clone.querySelectorAll('.no-print').forEach((node) => node.remove());
clone.querySelectorAll('.sortable-header').forEach((button) => {
const headerText = button.textContent || '';
const textNode = document.createTextNode(headerText);
button.parentNode.replaceChild(textNode, button);
});
clone.querySelectorAll('form').forEach((form) => {
const cell = form.closest('td');
if (!cell) {
form.remove();
return;
}
const label = form.querySelector('.form-check-label');
const checkbox = form.querySelector('input[type="checkbox"]');
const text = label
? label.textContent.trim()
: (checkbox && checkbox.checked ? 'Paid' : 'Unpaid');
cell.textContent = text || '—';
});
clone.querySelectorAll('tr').forEach((row) => {
if (row.children.length > 10) {
row.removeChild(row.lastElementChild);
}
});
return clone.outerHTML;
}
function openPrintWindow(title, contentHtml) {
const printWindow = window.open('', '_blank', 'noopener,noreferrer,width=1200,height=900');
if (!printWindow) {
alert('Unable to open the print preview window. Please allow pop-ups for this site.');
return;
function getTableRowOrder(table) {
if (!table || !table.tBodies.length) {
return [];
}
const style = `
<style>
body { font-family: Arial, sans-serif; margin: 24px; color: #212529; }
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
p { margin-top: 0; color: #6c757d; }
.card { border: 1px solid #dee2e6; border-radius: 0.5rem; margin-bottom: 1.5rem; overflow: hidden; }
.card-header { background: #f8f9fa; padding: 0.9rem 1rem; font-weight: 700; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { border: 1px solid #dee2e6; padding: 0.55rem 0.7rem; text-align: left; vertical-align: top; }
.table thead th, .table tfoot th { background: #f8f9fa; }
.badge { display: inline-block; padding: 0.2rem 0.45rem; border-radius: 999px; font-size: 0.8rem; font-weight: 700; }
.bg-success { background: #198754; color: #fff; }
.bg-danger { background: #dc3545; color: #fff; }
.bg-secondary { background: #6c757d; color: #fff; }
.text-muted { color: #6c757d; }
.small { font-size: 0.875rem; }
@media print {
body { margin: 0; }
}
</style>
`;
printWindow.document.open();
printWindow.document.write(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${escapeHtml(title)}</title>
${style}
</head>
<body>
<h1>${escapeHtml(title)}</h1>
<p>Generated on ${escapeHtml(new Date().toLocaleString())}</p>
${contentHtml}
<script>
window.onload = function() {
window.print();
};
<\/script>
</body>
</html>
`);
printWindow.document.close();
return Array.from(table.tBodies[0].rows)
.map((row) => String(row.dataset.chargeId || '').trim())
.filter(Boolean);
}
function printEventCards(cards, title) {
const printable = cards.map((card) => buildPrintableCard(card)).join('');
openPrintWindow(title, printable);
function buildPdfUrl(link) {
const baseHref = String(link.dataset.baseHref || link.getAttribute('href') || '');
const url = new URL(baseHref, window.location.origin);
const scope = String(link.dataset.printScope || 'single');
if (scope === 'all') {
document.querySelectorAll('.event-card').forEach((card) => {
const eventId = String(card.dataset.eventId || '').trim();
const table = card.querySelector('.event-charges-table');
const rowOrder = getTableRowOrder(table);
if (eventId && rowOrder.length) {
url.searchParams.set(`row_order[${eventId}]`, rowOrder.join(','));
}
});
return url.toString();
}
const eventId = String(link.dataset.eventId || '').trim();
const table = eventId ? document.getElementById(`eventTable_${eventId}`) : null;
const rowOrder = getTableRowOrder(table);
if (eventId && rowOrder.length) {
url.searchParams.set(`row_order[${eventId}]`, rowOrder.join(','));
}
return url.toString();
}
$('.sortable-header').on('click', function() {
@@ -749,22 +757,8 @@ function loadStudentsWithCharges() {
sortEventTable(table, columnIndex, sortType, direction);
});
$('.print-event-card').on('click', function() {
const eventId = String($(this).data('eventId'));
const card = document.querySelector(`.event-card[data-event-id="${eventId}"]`);
if (!card) {
return;
}
const title = $(card).find('.card-header span').first().text().trim() || 'Event Participants';
printEventCards([card], `${title} Participant List`);
});
$('#printAllEventCharges').on('click', function() {
const cards = Array.from(document.querySelectorAll('.event-card'));
if (!cards.length) {
return;
}
printEventCards(cards, 'All Event Participant Lists');
$('.pdf-print-link').on('click', function() {
this.href = buildPdfUrl(this);
});
$('#externalParticipantSave').on('click', function() {
@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Event Participant Lists</title>
<style>
@page {
margin: 24px;
}
body {
font-family: DejaVu Sans, sans-serif;
font-size: 11px;
color: #1f2933;
}
h1 {
margin: 0 0 6px;
font-size: 20px;
}
.meta {
margin: 0 0 18px;
color: #52606d;
font-size: 10px;
}
.event-card {
margin-bottom: 22px;
page-break-inside: avoid;
}
.event-title {
margin: 0 0 8px;
padding: 8px 10px;
background: #f5f7fa;
border: 1px solid #d9e2ec;
font-size: 14px;
font-weight: 700;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
border: 1px solid #d9e2ec;
padding: 6px 7px;
vertical-align: top;
word-wrap: break-word;
}
th {
background: #f5f7fa;
text-align: left;
font-weight: 700;
}
tfoot th {
background: #f5f7fa;
}
.small {
color: #52606d;
font-size: 10px;
}
.status-paid {
color: #166534;
font-weight: 700;
}
.status-unpaid {
color: #b42318;
font-weight: 700;
}
.status-neutral {
color: #52606d;
font-weight: 700;
}
</style>
</head>
<body>
<?php
$title = !empty($filterEventId) ? 'Event Participant List' : 'All Event Participant Lists';
if (!empty($filterParentId)) {
$title .= ' for Selected Parent';
}
?>
<h1><?= esc($title) ?></h1>
<p class="meta">
Generated on <?= esc($generatedAt) ?>
| School Year: <?= esc($school_year ?: 'N/A') ?>
| Semester: <?= esc($semester ?: 'N/A') ?>
</p>
<?php if (empty($groupedCharges)): ?>
<p>No charges found for the selected filters.</p>
<?php else: ?>
<?php foreach ($groupedCharges as $eventId => $data): ?>
<?php
$rows = $data['rows'] ?? [];
$totalParticipants = 0;
$totalCharged = 0.0;
?>
<section class="event-card">
<h2 class="event-title"><?= esc($data['label'] ?? 'N/A') ?></h2>
<table>
<thead>
<tr>
<th style="width: 4%;">ID</th>
<th style="width: 15%;">Parent Name</th>
<th style="width: 14%;">Student Name</th>
<th style="width: 15%;">External Info</th>
<th style="width: 11%;">Class Section</th>
<th style="width: 8%;">Charged Amount</th>
<th style="width: 10%;">Created</th>
<th style="width: 7%;">Waiver</th>
<th style="width: 7%;">Fees Paid</th>
<th style="width: 9%;">Payment</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $charge): ?>
<?php
$isParticipating = ($charge['participation'] ?? '') === 'yes';
$isEventPaid = !empty($charge['event_paid']);
$feeAmount = (float) ($charge['event_amount'] ?? 0);
$hasBalanceRecord = array_key_exists((int) ($charge['parent_id'] ?? 0), $parentBalances);
$parentBalance = $hasBalanceRecord ? (float) $parentBalances[(int) $charge['parent_id']] : null;
$waiverSigned = !empty($charge['waiver_signed']);
$feeIsPaid = $isParticipating && ($isEventPaid || ($hasBalanceRecord && $parentBalance <= 0));
$studentName = !empty($charge['student_firstname'])
? trim(($charge['student_firstname'] ?? '') . ' ' . ($charge['student_lastname'] ?? ''))
: '';
$externalName = trim(($charge['external_firstname'] ?? '') . ' ' . ($charge['external_lastname'] ?? ''));
$displayName = $studentName ?: ($externalName ?: '—');
$externalNote = trim((string) ($charge['external_note'] ?? ''));
$externalParentLabel = trim(($charge['external_parent_firstname'] ?? '') . ' ' . ($charge['external_parent_lastname'] ?? ''));
$externalParentPhone = trim((string) ($charge['external_parent_phone'] ?? ''));
$externalParentEmail = trim((string) ($charge['external_parent_email'] ?? ''));
$parentPhone = trim((string) ($charge['parent_cellphone'] ?? ''));
$standardParentName = trim(($charge['parent_firstname'] ?? '') . ' ' . ($charge['parent_lastname'] ?? ''));
$parentColumnName = $externalParentLabel ?: ($standardParentName ?: '—');
$isExternalParticipant = $externalName !== '';
$infoPhone = $isExternalParticipant ? $externalParentPhone : $parentPhone;
$infoEmail = $isExternalParticipant ? $externalParentEmail : '';
$classSectionName = $classSectionNames[$charge['class_section_id'] ?? ''] ?? '—';
if ($isParticipating) {
$totalParticipants++;
$totalCharged += $feeAmount;
}
?>
<tr>
<td><?= esc($charge['id'] ?? '') ?></td>
<td><?= esc($parentColumnName) ?></td>
<td>
<?= esc($displayName) ?>
<?php if ($externalNote !== ''): ?>
<div class="small"><?= esc($externalNote) ?></div>
<?php endif; ?>
</td>
<td>
<?php if ($infoPhone !== ''): ?>
<div class="small"><?= esc($infoPhone) ?></div>
<?php endif; ?>
<?php if ($infoEmail !== ''): ?>
<div class="small"><?= esc($infoEmail) ?></div>
<?php endif; ?>
<?php if ($infoPhone === '' && $infoEmail === ''): ?>
<span class="small">—</span>
<?php endif; ?>
</td>
<td><?= esc($classSectionName) ?></td>
<td>$<?= esc(number_format($feeAmount, 2)) ?></td>
<td><?= esc(!empty($charge['created_at']) ? local_datetime($charge['created_at'], 'm-d-Y H:i') : '') ?></td>
<td class="<?= $waiverSigned ? 'status-paid' : 'status-neutral' ?>">
<?= $waiverSigned ? 'Signed' : 'Unsigned' ?>
</td>
<td class="<?= $feeIsPaid ? 'status-paid' : 'status-unpaid' ?>">
<?= $feeIsPaid ? 'Paid' : 'Unpaid' ?>
</td>
<td class="<?= $isEventPaid ? 'status-paid' : 'status-unpaid' ?>">
<?= $isEventPaid ? 'Paid' : 'Unpaid' ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<th colspan="5">Totals</th>
<th>$<?= esc(number_format($totalCharged, 2)) ?></th>
<th colspan="4"><?= esc($totalParticipants) ?> participating</th>
</tr>
</tfoot>
</table>
</section>
<?php endforeach; ?>
<?php endif; ?>
</body>
</html>
+6 -3
View File
@@ -13,6 +13,9 @@ $schoolYear = $schoolYear ?? '';
$semester = $semester ?? '';
$maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
$allowedExtensions = $allowedExtensions ?? ['doc', 'docx', 'pdf'];
$reviewActionUrl = $reviewActionUrl ?? base_url('administrator/exam-drafts/review');
$legacyUploadUrl = $legacyUploadUrl ?? base_url('administrator/exam-drafts/upload-legacy');
$reviewPortalLabel = $reviewPortalLabel ?? 'Administrator';
$renderBadge = static function (string $status, array $badges): string {
$b = $badges[$status] ?? ['label' => $status, 'class' => 'bg-secondary text-white'];
@@ -27,7 +30,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<div>
<h1 class="h3 mb-1">Exam draft submissions</h1>
<p class="text-muted mb-0 small">
<?= esc($semester ?: 'Semester') ?> <?= esc($schoolYear ?: '') ?>
<?= esc($reviewPortalLabel) ?> review portal · <?= esc($semester ?: 'Semester') ?> <?= esc($schoolYear ?: '') ?>
</p>
</div>
<div class="text-muted small d-flex flex-column align-items-lg-end">
@@ -205,7 +208,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
</div>
</td>
<td style="min-width: 280px;">
<?= form_open_multipart(base_url('administrator/exam-drafts/review'), ['class' => 'vstack gap-2', 'id' => $formId]) ?>
<?= form_open_multipart($reviewActionUrl, ['class' => 'vstack gap-2', 'id' => $formId]) ?>
<?= csrf_field() ?>
<input type="hidden" name="draft_id" value="<?= (int) ($draft['id'] ?? 0) ?>">
<?php $selectedStatus = ''; ?>
@@ -276,7 +279,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<span class="badge bg-primary-subtle text-primary">Admin only</span>
</div>
<div class="card-body">
<?= form_open_multipart(base_url('administrator/exam-drafts/upload-legacy'), ['class' => 'row g-3']) ?>
<?= form_open_multipart($legacyUploadUrl, ['class' => 'row g-3']) ?>
<?= csrf_field() ?>
<div class="col-md-4">
<label class="form-label small">Class sections</label>
+389
View File
@@ -0,0 +1,389 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
$classResults = $classResults ?? [];
$selectedYear = $selectedYear ?? '';
$selectedPercentile = $selectedPercentile ?? 75;
$years = $years ?? [];
$totalStudents = array_sum(array_column($classResults, 'student_count'));
$totalScored = array_sum(array_column($classResults, 'scored_count'));
$totalTrophies = array_sum(array_column($classResults, 'trophy_count'));
$totalBoys = array_sum(array_column($classResults, 'boys'));
$totalGirls = array_sum(array_column($classResults, 'girls'));
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
$pctBoys = $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0;
$pctGirls = $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0;
$pctTrophyBoys = $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0;
$pctTrophyGirls = $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0;
$pctTrophyAll = $totalStudents > 0 ? round($totalTrophies / $totalStudents * 100) : 0;
?>
<div class="container-fluid">
<!-- Header -->
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2">
<div>
<h2 class="mb-1"><i class="bi bi-trophy-fill text-warning me-2"></i>Trophy Projections</h2>
<p class="text-muted mb-0">
Fall scores are used to calculate a per-class threshold at the
<strong><?= (int) $selectedPercentile ?>th percentile</strong> and project which students are on track
for a year-end trophy. Minimum 3 trophies per class. Names are hidden by default.
</p>
</div>
<div class="d-flex gap-2">
<a href="<?= site_url('administrator/trophy/winners?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
class="btn btn-warning btn-sm">
<i class="bi bi-eye-fill me-1"></i>Reveal Winners
</a>
<a href="<?= site_url('administrator/trophy/final?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
class="btn btn-success btn-sm">
<i class="bi bi-bar-chart-steps me-1"></i>Final vs Predicted
</a>
</div>
</div>
<!-- Year filter -->
<form method="get" action="<?= site_url('administrator/trophy') ?>" class="row g-2 align-items-end mb-4">
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">School Year</label>
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
<?php foreach ($years as $yr): ?>
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>>
<?= esc($yr) ?>
</option>
<?php endforeach; ?>
<?php if (empty($years)): ?>
<option value="<?= esc($selectedYear) ?>" selected><?= esc($selectedYear) ?></option>
<?php endif; ?>
</select>
</div>
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">Percentile threshold</label>
<div class="input-group input-group-sm" style="width:130px;">
<input type="number" name="percentile" class="form-control"
min="1" max="99" step="1"
value="<?= (int) $selectedPercentile ?>"
title="Students scoring above this percentile receive a trophy (min 3 per class)">
<span class="input-group-text">%</span>
</div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
</div>
</form>
<?php if (empty($classResults)): ?>
<div class="alert alert-info">No class or fall-score data found for the selected school year.</div>
<?php else: ?>
<!-- ── Global stats ── -->
<div class="row g-3 mb-4">
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm border-warning h-100">
<div class="card-body py-3">
<div class="fs-2 text-warning"><i class="bi bi-trophy-fill"></i></div>
<div class="fs-3 fw-bold"><?= $totalTrophies ?></div>
<div class="text-muted small">Trophies</div>
<span class="badge bg-warning text-dark mt-1"><?= $pctTrophyAll ?>% of students</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2 text-primary"><i class="bi bi-people-fill"></i></div>
<div class="fs-3 fw-bold"><?= $totalStudents ?></div>
<div class="text-muted small">Students</div>
<span class="badge bg-secondary mt-1"><?= count($classResults) ?> classes</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2 text-success"><i class="bi bi-bar-chart-fill"></i></div>
<div class="fs-3 fw-bold"><?= $totalScored ?></div>
<div class="text-muted small">With Fall Scores</div>
<span class="badge bg-success mt-1"><?= $totalStudents > 0 ? round($totalScored/$totalStudents*100) : 0 ?>%</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2" style="color:#4A90E2"><i class="bi bi-gender-male"></i></div>
<div class="fs-3 fw-bold"><?= $totalBoys ?></div>
<div class="text-muted small">Boys</div>
<span class="badge text-white mt-1" style="background:#4A90E2"><?= $pctBoys ?>% of students</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2" style="color:#E47AB0"><i class="bi bi-gender-female"></i></div>
<div class="fs-3 fw-bold"><?= $totalGirls ?></div>
<div class="text-muted small">Girls</div>
<span class="badge text-white mt-1" style="background:#E47AB0"><?= $pctGirls ?>% of students</span>
</div>
</div>
</div>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100">
<div class="card-body py-3">
<div class="fs-2 text-warning"><i class="bi bi-award-fill"></i></div>
<div class="fs-3 fw-bold">
<span style="color:#4A90E2"><?= $totalTrophyBoys ?></span>
<span class="text-muted fs-5">/</span>
<span style="color:#E47AB0"><?= $totalTrophyGirls ?></span>
</div>
<div class="text-muted small">Trophy M / F</div>
<span class="small">
<span style="color:#4A90E2"><?= $pctTrophyBoys ?>%</span>
&nbsp;·&nbsp;
<span style="color:#E47AB0"><?= $pctTrophyGirls ?>%</span>
</span>
</div>
</div>
</div>
</div>
<!-- ── Per-class summary ── -->
<div class="card shadow-sm">
<div class="card-header bg-white fw-semibold py-2">Class Breakdown</div>
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0 no-mgmt-sticky" id="trophy-breakdown-table" data-no-mgmt-sticky>
<thead class="table-light">
<tr>
<th class="ps-3">Class</th>
<th class="text-center">Students</th>
<th class="text-center">Scored</th>
<th class="text-center">
<i class="bi bi-trophy-fill text-warning"></i> Trophies
</th>
<th class="text-center" style="color:#4A90E2">
<i class="bi bi-gender-male"></i> Boys
</th>
<th class="text-center" style="color:#E47AB0">
<i class="bi bi-gender-female"></i> Girls
</th>
<th class="text-center" style="color:#4A90E2">Trophy Boys</th>
<th class="text-center" style="color:#E47AB0">Trophy Girls</th>
<th class="text-center">Rate</th>
<th class="text-end">Threshold</th>
<th class="text-center">Custom Threshold</th>
</tr>
</thead>
<tbody>
<?php foreach ($classResults as $cls):
$scores = array_filter(
array_column($cls['students'], 'fall_score'),
fn($s) => $s !== null
);
$scoresJson = json_encode(array_values($scores));
$clsId = 'cls' . $cls['section_id'];
?>
<tr id="row-<?= $clsId ?>">
<td class="ps-3 fw-semibold"><?= esc($cls['section_name']) ?></td>
<td class="text-center"><?= $cls['student_count'] ?></td>
<td class="text-center">
<?php if ($cls['scored_count'] < 3): ?>
<span class="badge bg-danger" title="Fewer than 3 fall scores — minimum of 3 trophies cannot be enforced">
<?= $cls['scored_count'] ?> <i class="bi bi-exclamation-triangle-fill"></i>
</span>
<?php else: ?>
<?= $cls['scored_count'] ?>
<?php endif; ?>
</td>
<td class="text-center" id="trophy-cell-<?= $clsId ?>">
<span class="badge bg-warning text-dark">
<i class="bi bi-trophy-fill"></i>
<span id="trophy-count-<?= $clsId ?>"><?= $cls['trophy_count'] ?></span>
</span>
</td>
<td class="text-center">
<?= $cls['boys'] ?>
<span class="text-muted small">(<?= $cls['pct_boys'] ?>%)</span>
</td>
<td class="text-center">
<?= $cls['girls'] ?>
<span class="text-muted small">(<?= $cls['pct_girls'] ?>%)</span>
</td>
<td class="text-center" id="trophy-boys-<?= $clsId ?>"><?= $cls['trophy_boys'] ?><?php if ($cls['boys'] > 0): ?> <span class="text-muted small">(<?= $cls['pct_trophy_boys'] ?>%)</span><?php endif; ?></td>
<td class="text-center" id="trophy-girls-<?= $clsId ?>"><?= $cls['trophy_girls'] ?><?php if ($cls['girls'] > 0): ?> <span class="text-muted small">(<?= $cls['pct_trophy_girls'] ?>%)</span><?php endif; ?></td>
<td class="text-center">
<div class="progress" style="height:8px;min-width:60px;">
<div class="progress-bar bg-warning" id="rate-bar-<?= $clsId ?>" style="width:<?= $cls['pct_trophy_total'] ?>%;"></div>
</div>
<span class="small text-muted" id="rate-txt-<?= $clsId ?>"><?= $cls['pct_trophy_total'] ?>%</span>
</td>
<td class="text-end text-muted small" id="auto-threshold-<?= $clsId ?>">
<?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '—' ?>
</td>
<td>
<div class="input-group input-group-sm" style="min-width:150px;">
<input type="number" id="custom-threshold-<?= $clsId ?>"
class="form-control form-control-sm"
step="0.1" min="0"
placeholder="e.g. <?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '80.0' ?>"
data-scores='<?= $scoresJson ?>'
data-clsid="<?= $clsId ?>"
data-boys='<?= json_encode(array_values(array_filter(array_map(fn($s) => $s['gender'] === 'Male' ? $s['fall_score'] : null, $cls['students']), fn($s) => $s !== null))) ?>'
data-girls='<?= json_encode(array_values(array_filter(array_map(fn($s) => $s['gender'] === 'Female' ? $s['fall_score'] : null, $cls['students']), fn($s) => $s !== null))) ?>'
data-total="<?= $cls['student_count'] ?>"
data-nboys="<?= $cls['boys'] ?>"
data-ngirls="<?= $cls['girls'] ?>">
<button class="btn btn-outline-primary btn-sm" type="button"
onclick="applyCustomThreshold('<?= $clsId ?>')">
Apply
</button>
</div>
<div id="custom-note-<?= $clsId ?>" class="text-muted" style="font-size:.7rem;"></div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td class="ps-3">Total</td>
<td class="text-center"><?= $totalStudents ?></td>
<td class="text-center"><?= $totalScored ?></td>
<td class="text-center">
<span class="badge bg-warning text-dark">
<i class="bi bi-trophy-fill"></i> <?= $totalTrophies ?>
</span>
</td>
<td class="text-center">
<?= $totalBoys ?>
<span class="text-muted small fw-normal">(<?= $pctBoys ?>%)</span>
</td>
<td class="text-center">
<?= $totalGirls ?>
<span class="text-muted small fw-normal">(<?= $pctGirls ?>%)</span>
</td>
<td class="text-center">
<?= $totalTrophyBoys ?>
<span class="text-muted small fw-normal">(<?= $pctTrophyBoys ?>%)</span>
</td>
<td class="text-center">
<?= $totalTrophyGirls ?>
<span class="text-muted small fw-normal">(<?= $pctTrophyGirls ?>%)</span>
</td>
<td class="text-center">
<div class="progress" style="height:8px;min-width:60px;">
<div class="progress-bar bg-warning" style="width:<?= $pctTrophyAll ?>%;"></div>
</div>
<span class="small text-muted"><?= $pctTrophyAll ?>%</span>
</td>
<td></td>
<td class="pe-3"></td>
</tr>
</tfoot>
</table>
</div>
</div>
<?php endif; ?>
</div>
<script>
var TROPHY_NS = 'trophy_custom_<?= esc($selectedYear) ?>_<?= (int)$selectedPercentile ?>';
function countAtOrAbove(arr, t) {
return arr.filter(function(s) { return s >= t; }).length;
}
function applyThreshold(clsId, val) {
var input = document.getElementById('custom-threshold-' + clsId);
var note = document.getElementById('custom-note-' + clsId);
var scores = JSON.parse(input.dataset.scores);
var boys = JSON.parse(input.dataset.boys);
var girls = JSON.parse(input.dataset.girls);
var nTotal = parseInt(input.dataset.total);
var nBoys = parseInt(input.dataset.nboys);
var nGirls = parseInt(input.dataset.ngirls);
var MIN = 3;
var winners = countAtOrAbove(scores, val);
var usedThreshold = val;
var forced = false;
if (winners < MIN && scores.length >= MIN) {
var desc = scores.slice().sort(function(a, b) { return b - a; });
usedThreshold = desc[MIN - 1];
winners = countAtOrAbove(scores, usedThreshold);
forced = true;
} else if (winners < MIN) {
winners = scores.length;
usedThreshold = scores.length > 0 ? Math.min.apply(null, scores) : val;
forced = true;
}
var trophyBoys = countAtOrAbove(boys, usedThreshold);
var trophyGirls = countAtOrAbove(girls, usedThreshold);
var rate = nTotal > 0 ? Math.round(winners / nTotal * 100) : 0;
var pctTB = nBoys > 0 ? Math.round(trophyBoys / nBoys * 100) : 0;
var pctTG = nGirls > 0 ? Math.round(trophyGirls / nGirls * 100) : 0;
document.getElementById('trophy-count-' + clsId).textContent = winners;
document.getElementById('trophy-boys-' + clsId).textContent = trophyBoys + (nBoys > 0 ? ' (' + pctTB + '%)' : '');
document.getElementById('trophy-girls-' + clsId).textContent = trophyGirls + (nGirls > 0 ? ' (' + pctTG + '%)' : '');
document.getElementById('rate-bar-' + clsId).style.width = rate + '%';
document.getElementById('rate-txt-' + clsId).textContent = rate + '%';
document.getElementById('auto-threshold-' + clsId).textContent = usedThreshold.toFixed(1);
if (forced) {
note.style.color = '#dc3545';
note.textContent = 'Min 3 enforced → threshold lowered to ' + usedThreshold.toFixed(1);
} else {
note.style.color = '#6c757d';
note.textContent = 'Custom threshold: ' + usedThreshold.toFixed(1);
}
var badge = document.getElementById('trophy-cell-' + clsId).querySelector('.badge');
badge.classList.remove('bg-warning');
badge.classList.add('bg-primary');
}
function applyCustomThreshold(clsId) {
var input = document.getElementById('custom-threshold-' + clsId);
var val = parseFloat(input.value);
if (isNaN(val)) {
document.getElementById('custom-note-' + clsId).textContent = 'Enter a valid number.';
return;
}
applyThreshold(clsId, val);
// Persist so the value survives filter changes within the same percentile/year
try {
var saved = JSON.parse(localStorage.getItem(TROPHY_NS) || '{}');
saved[clsId] = val;
localStorage.setItem(TROPHY_NS, JSON.stringify(saved));
} catch (e) {}
}
// Restore saved custom thresholds on page load
document.addEventListener('DOMContentLoaded', function () {
try {
var saved = JSON.parse(localStorage.getItem(TROPHY_NS) || '{}');
Object.keys(saved).forEach(function (clsId) {
var input = document.getElementById('custom-threshold-' + clsId);
if (!input) return;
input.value = saved[clsId];
applyThreshold(clsId, saved[clsId]);
});
} catch (e) {}
});
// Clear all custom thresholds when the percentile/year form is submitted
document.querySelector('form[action*="trophy"]').addEventListener('submit', function () {
try { localStorage.removeItem(TROPHY_NS); } catch (e) {}
});
</script>
<?= $this->endSection() ?>
+598
View File
@@ -0,0 +1,598 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
$classResults = $classResults ?? [];
$selectedYear = $selectedYear ?? '';
$selectedPercentile = $selectedPercentile ?? 75;
$years = $years ?? [];
$totalStudents = array_sum(array_column($classResults, 'student_count'));
$totalPredicted = array_sum(array_column($classResults, 'predicted_count'));
$totalActual = array_sum(array_column($classResults, 'actual_count'));
$totalConfirmed = array_sum(array_column($classResults, 'confirmed'));
$totalSurprise = array_sum(array_column($classResults, 'surprises'));
$totalMissed = array_sum(array_column($classResults, 'missed'));
$overallAccuracy = $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : ($totalActual === 0 ? 100 : 0);
?>
<style>
.status-confirmed { color:#198754; font-weight:600; }
.status-surprise { color:#0d6efd; font-weight:600; }
.status-missed { color:#fd7e14; font-weight:600; }
.status-none { color:#adb5bd; }
.print-only { display: none !important; }
@media print {
.no-print { display: none !important; }
.print-only { display: block !important; }
.screen-only { display: none !important; }
body { font-size: 11px; }
.container-fluid { padding: 0 !important; }
h2, h3 { font-size: 13px; margin-bottom: .3rem; }
.print-page-break { break-before: page; }
table { width: 100%; border-collapse: collapse; font-size: 10px; }
table th, table td { border: 1px solid #bbb; padding: 3px 5px; }
table thead { background: #333 !important; color: #fff !important;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
table thead th { position: static !important; top: auto !important; box-shadow: none !important; }
.s-confirmed { color: #198754; font-weight: bold; }
.s-surprise { color: #0d6efd; font-weight: bold; }
.s-missed { color: #fd7e14; font-weight: bold; }
}
</style>
<div class="container-fluid">
<!-- Header -->
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2 no-print">
<div>
<h2 class="mb-1">
<i class="bi bi-trophy-fill text-warning me-1"></i>
Final vs Predicted — <?= esc($selectedYear) ?>
</h2>
<p class="text-muted mb-0">
Compares the <strong>Fall-score prediction</strong> (<?= (int)$selectedPercentile ?>th percentile)
with the <strong>year-end result</strong> based on the average of Fall &amp; Spring scores.
</p>
</div>
<div class="d-flex gap-2">
<button onclick="printWithCharts()" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-printer-fill me-1"></i>Print
</button>
<a href="<?= site_url('administrator/trophy?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
</div>
</div>
<!-- Filter -->
<form method="get" action="<?= site_url('administrator/trophy/final') ?>"
class="row g-2 align-items-end mb-4 no-print">
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">School Year</label>
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
<?php foreach ($years as $yr): ?>
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>><?= esc($yr) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">Percentile</label>
<div class="input-group input-group-sm" style="width:110px;">
<input type="number" name="percentile" class="form-control"
min="1" max="99" step="1" value="<?= (int)$selectedPercentile ?>">
<span class="input-group-text">%</span>
</div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
</div>
</form>
<?php if (empty($classResults)): ?>
<div class="alert alert-info no-print">No data found for the selected school year.</div>
<?php else: ?>
<!-- ══ SCREEN VIEW ═══════════════════════════════════════════════════════ -->
<div class="screen-only">
<!-- Legend -->
<div class="d-flex flex-wrap gap-3 mb-3 small">
<span><span class="badge bg-success me-1">✓ Confirmed</span> Predicted AND got a year-end trophy</span>
<span><span class="badge bg-primary me-1">↑ Surprise</span> NOT predicted, but earned a trophy</span>
<span><span class="badge bg-warning text-dark me-1">↓ Missed</span> Predicted, but fell short year-end</span>
<span><span class="badge bg-light text-muted border me-1">— None</span> No trophy either way</span>
</div>
<!-- Global stat tiles -->
<div class="row g-3 mb-4">
<?php foreach ([
[$totalPredicted, 'Predicted (Fall)', 'primary', null, $totalStudents > 0 ? round($totalPredicted / $totalStudents * 100) . '% of students' : '—'],
[$totalActual, 'Actual (Year)', 'warning', 'dark', $totalStudents > 0 ? round($totalActual / $totalStudents * 100) . '% of students' : '—'],
[$totalConfirmed, 'Confirmed', 'success', null, $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) . '% of predicted' : '—'],
[$totalSurprise, 'Surprises', 'info', 'dark', 'Not in prediction'],
[$totalMissed, 'Missed', 'orange', null, 'Were predicted'],
[$overallAccuracy,'Accuracy', 'dark', null, 'prediction rate'],
] as [$val, $label, $color, $textClass, $sub]):
$isOrange = $color === 'orange';
$bgClass = $isOrange ? '' : 'bg-' . $color;
$txClass = $textClass ? 'text-' . $textClass : 'text-white';
?>
<div class="col-6 col-sm-4 col-lg-2">
<div class="card text-center shadow-sm h-100 <?= $isOrange ? 'border-warning' : '' ?>">
<div class="card-body py-3">
<div class="fs-3 fw-bold <?= $isOrange ? 'text-warning' : 'text-' . $color ?>">
<?= $val ?><?= $label === 'Accuracy' ? '%' : '' ?>
</div>
<div class="text-muted small"><?= $label ?></div>
<span class="badge mt-1 <?= $bgClass . ' ' . $txClass ?>"
<?= $isOrange ? 'style="background:#fd7e14"' : '' ?>>
<?= $sub ?>
</span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<!-- Per-class breakdown -->
<?php foreach ($classResults as $cls): ?>
<div class="card shadow-sm mb-4">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2 py-2"
style="background:#f8f9fa;">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="fw-bold fs-6"><?= esc($cls['section_name']) ?></span>
<span class="badge bg-primary"><?= $cls['predicted_count'] ?> predicted</span>
<span class="badge bg-warning text-dark"><?= $cls['actual_count'] ?> actual</span>
<?php if ($cls['confirmed'] > 0): ?>
<span class="badge bg-success"><?= $cls['confirmed'] ?> confirmed</span>
<?php endif; ?>
<?php if ($cls['surprises'] > 0): ?>
<span class="badge bg-info text-dark"><?= $cls['surprises'] ?> surprise<?= $cls['surprises'] > 1 ? 's' : '' ?></span>
<?php endif; ?>
<?php if ($cls['missed'] > 0): ?>
<span class="badge" style="background:#fd7e14"><?= $cls['missed'] ?> missed</span>
<?php endif; ?>
</div>
<div class="d-flex gap-3 small align-items-center">
<span class="text-muted">Fall &#8805; <strong><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></strong></span>
<span class="text-muted">Year &#8805; <strong><?= $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?></strong></span>
<span class="fw-semibold">
Accuracy:
<span class="<?= $cls['accuracy'] >= 80 ? 'text-success' : ($cls['accuracy'] >= 50 ? 'text-warning' : 'text-danger') ?>">
<?= $cls['accuracy'] ?>%
</span>
</span>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0 align-middle" data-no-mgmt-sticky>
<thead class="table-light">
<tr>
<th class="ps-3" style="width:2rem;">#</th>
<th>Name</th>
<th class="text-center" style="width:3rem;">Gender</th>
<th class="text-end">Fall</th>
<th class="text-end">Spring</th>
<th class="text-end">Year Avg</th>
<th class="text-center">Predicted</th>
<th class="text-center">Actual</th>
<th class="text-center">Status</th>
</tr>
</thead>
<tbody>
<?php $rank = 0; foreach ($cls['students'] as $s):
if ($s['status'] === 'none') continue;
$rank++;
$isMale = strtolower($s['gender'] ?? '') === 'male';
$rowBg = match ($s['status']) {
'confirmed' => 'table-success',
'surprise' => 'table-primary',
'missed' => 'table-warning',
default => '',
};
?>
<tr class="<?= $rowBg ?>">
<td class="ps-3 text-muted small"><?= $rank ?></td>
<td class="fw-semibold small"><?= esc($s['name']) ?></td>
<td class="text-center">
<span class="badge" style="background:<?= $isMale ? '#4A90E2' : '#E47AB0' ?>;font-size:.65rem;">
<?= $isMale ? 'M' : 'F' ?>
</span>
</td>
<td class="text-end small"><?= $s['fall_score'] !== null ? number_format($s['fall_score'], 1) : '<span class="text-muted">—</span>' ?></td>
<td class="text-end small"><?= $s['spring_score'] !== null ? number_format($s['spring_score'], 1) : '<span class="text-muted">—</span>' ?></td>
<td class="text-end small fw-semibold"><?= $s['year_score'] !== null ? number_format($s['year_score'], 1) : '<span class="text-muted">—</span>' ?></td>
<td class="text-center small">
<?= $s['predicted']
? '<span class="badge bg-primary">Yes</span>'
: '<span class="badge bg-light text-muted border">No</span>' ?>
</td>
<td class="text-center small">
<?= $s['actual']
? '<span class="badge bg-warning text-dark">Yes</span>'
: '<span class="badge bg-light text-muted border">No</span>' ?>
</td>
<td class="text-center small">
<?php match ($s['status']) {
'confirmed' => print '<span class="badge bg-success">✓ Confirmed</span>',
'surprise' => print '<span class="badge bg-info text-dark">↑ Surprise</span>',
'missed' => print '<span class="badge" style="background:#fd7e14;color:#fff;">↓ Missed</span>',
default => print '<span class="text-muted small">—</span>',
}; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
<!-- Overall accuracy summary table -->
<div class="card shadow-sm mt-2 mb-4">
<div class="card-header bg-dark text-white fw-semibold py-2">
<i class="bi bi-bar-chart-fill me-2"></i>Prediction Accuracy Summary — <?= esc($selectedYear) ?>
</div>
<div class="card-body p-0">
<table class="table table-sm table-bordered mb-0 align-middle" data-no-mgmt-sticky>
<thead class="table-dark">
<tr>
<th class="ps-2">Class</th>
<th class="text-center">Students</th>
<th class="text-center text-primary-emphasis">Predicted</th>
<th class="text-center text-warning-emphasis">Actual</th>
<th class="text-center text-success-emphasis">✓ Confirmed</th>
<th class="text-center" style="color:#6ea8fe">↑ Surprises</th>
<th class="text-center" style="color:#ffda6a">↓ Missed</th>
<th class="text-center">Accuracy</th>
<th class="text-end pe-2">Fall ≥</th>
<th class="text-end pe-2">Year ≥</th>
</tr>
</thead>
<tbody>
<?php foreach ($classResults as $cls): ?>
<tr>
<td class="ps-2 fw-semibold small"><?= esc($cls['section_name']) ?></td>
<td class="text-center small"><?= $cls['student_count'] ?></td>
<td class="text-center small"><span class="badge bg-primary"><?= $cls['predicted_count'] ?></span></td>
<td class="text-center small"><span class="badge bg-warning text-dark"><?= $cls['actual_count'] ?></span></td>
<td class="text-center small"><span class="badge bg-success"><?= $cls['confirmed'] ?></span></td>
<td class="text-center small"><span class="badge bg-info text-dark"><?= $cls['surprises'] ?></span></td>
<td class="text-center small"><span class="badge" style="background:#fd7e14"><?= $cls['missed'] ?></span></td>
<td class="text-center small fw-semibold <?= $cls['accuracy'] >= 80 ? 'text-success' : ($cls['accuracy'] >= 50 ? 'text-warning' : 'text-danger') ?>">
<?= $cls['accuracy'] ?>%
</td>
<td class="text-end pe-2 small text-muted"><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></td>
<td class="text-end pe-2 small text-muted"><?= $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot class="table-secondary fw-semibold">
<tr>
<td class="ps-2">Total</td>
<td class="text-center"><?= $totalStudents ?></td>
<td class="text-center"><span class="badge bg-primary"><?= $totalPredicted ?></span></td>
<td class="text-center"><span class="badge bg-warning text-dark"><?= $totalActual ?></span></td>
<td class="text-center"><span class="badge bg-success"><?= $totalConfirmed ?></span></td>
<td class="text-center"><span class="badge bg-info text-dark"><?= $totalSurprise ?></span></td>
<td class="text-center"><span class="badge" style="background:#fd7e14"><?= $totalMissed ?></span></td>
<td class="text-center fw-semibold <?= $overallAccuracy >= 80 ? 'text-success' : ($overallAccuracy >= 50 ? 'text-warning' : 'text-danger') ?>">
<?= $overallAccuracy ?>%
</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Charts -->
<div class="row g-4 mt-1 mb-4">
<!-- Grouped bar: predicted / actual / confirmed / surprises / missed per class -->
<div class="col-12 col-lg-8">
<div class="border rounded p-3 h-100">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-bar-chart-fill me-1"></i>Trophy Counts per Class
</div>
<canvas id="chart-counts" style="max-height:280px;"></canvas>
</div>
</div>
<!-- Bar: accuracy % per class + doughnut overall breakdown -->
<div class="col-12 col-lg-4">
<div class="row g-3 h-100">
<div class="col-12">
<div class="border rounded p-3">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-bullseye me-1"></i>Prediction Accuracy per Class
</div>
<canvas id="chart-accuracy" style="max-height:130px;"></canvas>
</div>
</div>
<div class="col-12">
<div class="border rounded p-3">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-pie-chart-fill me-1"></i>Overall Outcome Breakdown
</div>
<canvas id="chart-breakdown" style="max-height:130px;"></canvas>
</div>
</div>
</div>
</div>
</div>
</div><!-- /screen-only -->
<!-- ══ PRINT VIEW ════════════════════════════════════════════════════════ -->
<div class="print-only">
<div style="text-align:center;margin-bottom:10px;border-bottom:2px solid #333;padding-bottom:6px;">
<h2 style="margin:0;">Final vs Predicted — <?= esc($selectedYear) ?></h2>
<p style="margin:3px 0 0;font-size:10px;color:#555;">
<?= (int)$selectedPercentile ?>th percentile &mdash;
Predicted: <?= $totalPredicted ?> &mdash;
Actual: <?= $totalActual ?> &mdash;
Confirmed: <?= $totalConfirmed ?> &mdash;
Accuracy: <?= $overallAccuracy ?>%
</p>
</div>
<!-- All students flat table -->
<h3 style="margin-bottom:4px;">Student Detail</h3>
<table data-no-mgmt-sticky>
<thead>
<tr>
<th>#</th>
<th>Class</th>
<th>Name</th>
<th style="text-align:center;">G</th>
<th style="text-align:right;">Fall</th>
<th style="text-align:right;">Spring</th>
<th style="text-align:right;">Year Avg</th>
<th style="text-align:center;">Predicted</th>
<th style="text-align:center;">Actual</th>
<th style="text-align:center;">Status</th>
</tr>
</thead>
<tbody>
<?php
$rank = 0;
foreach ($classResults as $cls):
foreach ($cls['students'] as $s):
if (!in_array($s['status'], ['confirmed', 'surprise'], true)) continue;
$rank++;
$isMale = strtolower($s['gender'] ?? '') === 'male';
$statusLabel = match ($s['status']) {
'confirmed' => '✓ Confirmed',
'surprise' => '↑ Surprise',
'missed' => '↓ Missed',
default => '—',
};
$statusClass = 's-' . $s['status'];
?>
<tr>
<td style="text-align:center;"><?= $rank ?></td>
<td><?= esc($cls['section_name']) ?></td>
<td><strong><?= esc($s['name']) ?></strong></td>
<td style="text-align:center;"><?= $isMale ? 'M' : 'F' ?></td>
<td style="text-align:right;"><?= $s['fall_score'] !== null ? number_format($s['fall_score'], 1) : '—' ?></td>
<td style="text-align:right;"><?= $s['spring_score'] !== null ? number_format($s['spring_score'], 1) : '—' ?></td>
<td style="text-align:right;font-weight:bold;"><?= $s['year_score'] !== null ? number_format($s['year_score'], 1) : '—' ?></td>
<td style="text-align:center;"><?= $s['predicted'] ? 'Yes' : 'No' ?></td>
<td style="text-align:center;"><?= $s['actual'] ? 'Yes' : 'No' ?></td>
<td style="text-align:center;" class="<?= $statusClass ?>"><?= $statusLabel ?></td>
</tr>
<?php endforeach; endforeach; ?>
</tbody>
</table>
<!-- Accuracy summary (new page) -->
<div class="print-page-break"></div>
<h3 style="margin-bottom:4px;">Prediction Accuracy Summary</h3>
<table data-no-mgmt-sticky style="margin-bottom:14px;">
<thead>
<tr>
<th>Class</th>
<th style="text-align:center;">Students</th>
<th style="text-align:center;">Predicted</th>
<th style="text-align:center;">Actual</th>
<th style="text-align:center;">✓ Confirmed</th>
<th style="text-align:center;">↑ Surprises</th>
<th style="text-align:center;">↓ Missed</th>
<th style="text-align:center;">Accuracy</th>
<th style="text-align:right;">Fall ≥</th>
<th style="text-align:right;">Year ≥</th>
</tr>
</thead>
<tbody>
<?php foreach ($classResults as $cls): ?>
<tr>
<td><?= esc($cls['section_name']) ?></td>
<td style="text-align:center;"><?= $cls['student_count'] ?></td>
<td style="text-align:center;"><?= $cls['predicted_count'] ?></td>
<td style="text-align:center;"><?= $cls['actual_count'] ?></td>
<td style="text-align:center;" class="s-confirmed"><?= $cls['confirmed'] ?></td>
<td style="text-align:center;" class="s-surprise"><?= $cls['surprises'] ?></td>
<td style="text-align:center;" class="s-missed"><?= $cls['missed'] ?></td>
<td style="text-align:center;font-weight:bold;"><?= $cls['accuracy'] ?>%</td>
<td style="text-align:right;"><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></td>
<td style="text-align:right;"><?= $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<td style="font-weight:bold;">Total</td>
<td style="text-align:center;font-weight:bold;"><?= $totalStudents ?></td>
<td style="text-align:center;font-weight:bold;"><?= $totalPredicted ?></td>
<td style="text-align:center;font-weight:bold;"><?= $totalActual ?></td>
<td style="text-align:center;font-weight:bold;" class="s-confirmed"><?= $totalConfirmed ?></td>
<td style="text-align:center;font-weight:bold;" class="s-surprise"><?= $totalSurprise ?></td>
<td style="text-align:center;font-weight:bold;" class="s-missed"><?= $totalMissed ?></td>
<td style="text-align:center;font-weight:bold;"><?= $overallAccuracy ?>%</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
<!-- Charts as images (populated by JS before printing) -->
<div class="print-page-break"></div>
<h3 style="margin-bottom:6px;">Charts</h3>
<div style="margin-bottom:14px;">
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Trophy Counts per Class</p>
<img id="print-chart-counts" style="width:100%;max-height:220px;object-fit:contain;" src="" alt="">
</div>
<div style="display:flex;gap:16px;margin-bottom:14px;">
<div style="flex:1;">
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Prediction Accuracy per Class</p>
<img id="print-chart-accuracy" style="width:100%;max-height:160px;object-fit:contain;" src="" alt="">
</div>
<div style="flex:1;">
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Overall Outcome Breakdown</p>
<img id="print-chart-breakdown" style="width:100%;max-height:160px;object-fit:contain;" src="" alt="">
</div>
</div>
</div><!-- /print-only -->
<?php endif; ?>
</div>
<?= $this->section('scripts') ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
(function () {
<?php
$cLabels = [];
$cPredicted = [];
$cActual = [];
$cConfirmed = [];
$cSurprise = [];
$cMissed = [];
$cAccuracy = [];
foreach ($classResults as $cls) {
$cLabels[] = $cls['section_name'];
$cPredicted[] = $cls['predicted_count'];
$cActual[] = $cls['actual_count'];
$cConfirmed[] = $cls['confirmed'];
$cSurprise[] = $cls['surprises'];
$cMissed[] = $cls['missed'];
$cAccuracy[] = $cls['accuracy'];
}
?>
var labels = <?= json_encode($cLabels) ?>;
var predicted = <?= json_encode($cPredicted) ?>;
var actual = <?= json_encode($cActual) ?>;
var confirmed = <?= json_encode($cConfirmed) ?>;
var surprises = <?= json_encode($cSurprise) ?>;
var missed = <?= json_encode($cMissed) ?>;
var accuracy = <?= json_encode($cAccuracy) ?>;
/* ── Chart 1: grouped bar counts per class ── */
new Chart(document.getElementById('chart-counts'), {
type: 'bar',
data: {
labels: labels,
datasets: [
{ label: 'Predicted', data: predicted, backgroundColor: '#4A90E2', borderRadius: 3 },
{ label: 'Actual', data: actual, backgroundColor: '#f0a500', borderRadius: 3 },
{ label: 'Confirmed', data: confirmed, backgroundColor: '#28a745', borderRadius: 3 },
{ label: 'Surprises', data: surprises, backgroundColor: '#17a2b8', borderRadius: 3 },
{ label: 'Missed', data: missed, backgroundColor: '#fd7e14', borderRadius: 3 },
]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { position: 'bottom' } },
scales: {
x: { grid: { color: '#f0f0f0' } },
y: { beginAtZero: true, ticks: { stepSize: 1 }, grid: { color: '#f0f0f0' } }
}
}
});
/* ── Chart 2: accuracy % per class ── */
new Chart(document.getElementById('chart-accuracy'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Accuracy %',
data: accuracy,
backgroundColor: accuracy.map(function(a) {
return a >= 80 ? '#28a745' : a >= 50 ? '#f0a500' : '#dc3545';
}),
borderRadius: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true, max: 100,
ticks: { callback: function(v) { return v + '%'; } },
grid: { color: '#f0f0f0' }
}
}
}
});
/* ── Chart 3: doughnut overall outcome breakdown ── */
new Chart(document.getElementById('chart-breakdown'), {
type: 'doughnut',
data: {
labels: ['Confirmed', 'Surprises', 'Missed'],
datasets: [{
data: [<?= $totalConfirmed ?>, <?= $totalSurprise ?>, <?= $totalMissed ?>],
backgroundColor: ['#28a745', '#17a2b8', '#fd7e14'],
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: function(ctx) {
var total = ctx.dataset.data.reduce(function(a, b) { return a + b; }, 0);
var pct = total > 0 ? Math.round(ctx.parsed / total * 100) : 0;
return ctx.label + ': ' + ctx.parsed + ' (' + pct + '%)';
}
}
}
}
}
});
})();
function captureCharts() {
var map = {
'chart-counts': 'print-chart-counts',
'chart-accuracy': 'print-chart-accuracy',
'chart-breakdown': 'print-chart-breakdown',
};
Object.keys(map).forEach(function(canvasId) {
var canvas = document.getElementById(canvasId);
var img = document.getElementById(map[canvasId]);
if (canvas && img) img.src = canvas.toDataURL('image/png');
});
}
function printWithCharts() {
captureCharts();
window.print();
}
window.addEventListener('beforeprint', captureCharts);
</script>
<?= $this->endSection() ?>
<?= $this->endSection() ?>
+480
View File
@@ -0,0 +1,480 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
$classResults = $classResults ?? [];
$selectedYear = $selectedYear ?? '';
$selectedPercentile = $selectedPercentile ?? 75;
$years = $years ?? [];
$totalWinners = array_sum(array_map(fn($c) => count($c['winners']), $classResults));
$totalStudents = array_sum(array_column($classResults, 'student_count'));
$totalBoys = array_sum(array_column($classResults, 'boys'));
$totalGirls = array_sum(array_column($classResults, 'girls'));
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
$pctWinners = $totalStudents > 0 ? round($totalWinners / $totalStudents * 100) : 0;
$pctTrophyBoys = $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0;
$pctTrophyGirls = $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0;
$pctBoys = $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0;
$pctGirls = $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0;
?>
<style>
/* ── Screen: hide print-only blocks ── */
.print-only { display: none !important; }
/* ── Print ── */
@media print {
.no-print { display: none !important; }
.print-only { display: block !important; }
.screen-only { display: none !important; }
body { font-size: 11px; }
.container-fluid { padding: 0 !important; }
h2, h3 { font-size: 14px; margin-bottom: .4rem; }
.print-page-break { break-before: page; }
table { width: 100%; border-collapse: collapse; font-size: 10px; }
table th,
table td { border: 1px solid #bbb; padding: 3px 6px; }
table thead { background: #333 !important; color: #fff !important;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
table thead th { position: static !important; top: auto !important; box-shadow: none !important; }
table tfoot { background: #eee !important;
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
.badge-m { color: #4A90E2; font-weight: bold; }
.badge-f { color: #E47AB0; font-weight: bold; }
}
</style>
<div class="container-fluid">
<!-- ══ SCREEN HEADER ══════════════════════════════════════════════════════ -->
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2 no-print">
<div>
<h2 class="mb-1"><i class="bi bi-trophy-fill text-warning me-2"></i>Trophy Winners</h2>
<p class="text-muted mb-0">
<?= esc($selectedYear) ?> &mdash; <?= (int)$selectedPercentile ?>th percentile &mdash;
<strong><?= $totalWinners ?></strong> winners across <strong><?= count($classResults) ?></strong> classes
</p>
</div>
<div class="d-flex gap-2">
<button onclick="window.print()" class="btn btn-primary btn-sm">
<i class="bi bi-printer-fill me-1"></i>Print
</button>
<a href="<?= site_url('administrator/trophy?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
</div>
</div>
<!-- Filter (screen only) -->
<form method="get" action="<?= site_url('administrator/trophy/winners') ?>"
class="row g-2 align-items-end mb-4 no-print">
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">School Year</label>
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
<?php foreach ($years as $yr): ?>
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>><?= esc($yr) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-auto">
<label class="form-label mb-1 small fw-semibold">Percentile</label>
<div class="input-group input-group-sm" style="width:110px;">
<input type="number" name="percentile" class="form-control"
min="1" max="99" step="1" value="<?= (int)$selectedPercentile ?>">
<span class="input-group-text">%</span>
</div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
</div>
</form>
<?php if (empty($classResults)): ?>
<div class="alert alert-info no-print">No projected trophy winners found.</div>
<?php else: ?>
<!-- ══ PRINT VIEW ════════════════════════════════════════════════════════ -->
<div class="print-only">
<!-- Print title -->
<div style="text-align:center;margin-bottom:12px;border-bottom:2px solid #333;padding-bottom:8px;">
<h2 style="margin:0;">Trophy Winners — <?= esc($selectedYear) ?></h2>
<p style="margin:4px 0 0;font-size:10px;color:#555;">
<?= (int)$selectedPercentile ?>th percentile &mdash;
<?= $totalWinners ?> winners / <?= $totalStudents ?> students &mdash;
<?= count($classResults) ?> classes
</p>
</div>
<!-- TABLE 1: All winners -->
<h3 style="margin-bottom:4px;">Winners</h3>
<table data-no-mgmt-sticky>
<thead>
<tr>
<th>#</th>
<th>Class</th>
<th>Name</th>
<th style="text-align:center;">Gender</th>
<th style="text-align:right;">Fall Score</th>
<th style="text-align:right;">Spring Score</th>
<th style="text-align:right;">Year Score</th>
</tr>
</thead>
<tbody>
<?php
$globalRank = 0;
foreach ($classResults as $cls):
foreach ($cls['winners'] as $student):
$globalRank++;
$isMale = strtolower($student['gender'] ?? '') === 'male';
?>
<tr>
<td style="text-align:center;"><?= $globalRank ?></td>
<td><?= esc($cls['section_name']) ?></td>
<td><strong><?= esc($student['name']) ?></strong></td>
<td style="text-align:center;">
<span class="<?= $isMale ? 'badge-m' : 'badge-f' ?>">
<?= $isMale ? 'M' : 'F' ?>
</span>
</td>
<td style="text-align:right;font-weight:bold;">
<?= $student['fall_score'] !== null ? number_format($student['fall_score'], 1) : '—' ?>
</td>
<td style="text-align:right;">
<?= $student['spring_score'] !== null ? number_format($student['spring_score'], 1) : '—' ?>
</td>
<td style="text-align:right;font-weight:bold;color:#155724;">
<?= $student['year_score'] !== null ? number_format($student['year_score'], 1) : '—' ?>
</td>
</tr>
<?php endforeach; endforeach; ?>
</tbody>
<tfoot>
<tr>
<td colspan="2" style="font-weight:bold;">Total</td>
<td colspan="2" style="font-weight:bold;"><?= $totalWinners ?> winners</td>
<td colspan="3" style="text-align:right;font-weight:bold;"><?= $pctWinners ?>% of students</td>
</tr>
</tfoot>
</table>
<!-- TABLE 2: Year summary (new page) -->
<div class="print-page-break"></div>
<h3 style="margin-bottom:4px;">Year Summary — <?= esc($selectedYear) ?></h3>
<table style="margin-bottom:16px;" data-no-mgmt-sticky>
<thead>
<tr>
<th>Class</th>
<th style="text-align:center;">Students</th>
<th style="text-align:center;">Boys</th>
<th style="text-align:center;">Girls</th>
<th style="text-align:center;">Winners</th>
<th style="text-align:center;">Trophy Boys</th>
<th style="text-align:center;">Trophy Girls</th>
<th style="text-align:center;">Rate</th>
<th style="text-align:right;">Fall Threshold</th>
</tr>
</thead>
<tbody>
<?php foreach ($classResults as $cls): ?>
<tr>
<td><?= esc($cls['section_name']) ?></td>
<td style="text-align:center;"><?= $cls['student_count'] ?></td>
<td style="text-align:center;"><?= $cls['boys'] ?> (<?= $cls['pct_boys'] ?>%)</td>
<td style="text-align:center;"><?= $cls['girls'] ?> (<?= $cls['pct_girls'] ?>%)</td>
<td style="text-align:center;font-weight:bold;"><?= count($cls['winners']) ?></td>
<td style="text-align:center;">
<span class="badge-m"><?= $cls['trophy_boys'] ?></span>
<?= $cls['boys'] > 0 ? '(' . $cls['pct_trophy_boys'] . '%)' : '' ?>
</td>
<td style="text-align:center;">
<span class="badge-f"><?= $cls['trophy_girls'] ?></span>
<?= $cls['girls'] > 0 ? '(' . $cls['pct_trophy_girls'] . '%)' : '' ?>
</td>
<td style="text-align:center;"><?= $cls['pct_trophy_total'] ?>%</td>
<td style="text-align:right;"><?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '—' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<td style="font-weight:bold;">Total</td>
<td style="text-align:center;font-weight:bold;"><?= $totalStudents ?></td>
<td style="text-align:center;font-weight:bold;"><?= $totalBoys ?> (<?= $pctBoys ?>%)</td>
<td style="text-align:center;font-weight:bold;"><?= $totalGirls ?> (<?= $pctGirls ?>%)</td>
<td style="text-align:center;font-weight:bold;"><?= $totalWinners ?></td>
<td style="text-align:center;font-weight:bold;">
<span class="badge-m"><?= $totalTrophyBoys ?></span> (<?= $pctTrophyBoys ?>%)
</td>
<td style="text-align:center;font-weight:bold;">
<span class="badge-f"><?= $totalTrophyGirls ?></span> (<?= $pctTrophyGirls ?>%)
</td>
<td style="text-align:center;font-weight:bold;"><?= $pctWinners ?>%</td>
<td></td>
</tr>
</tfoot>
</table>
</div><!-- /print-only -->
<!-- ══ SCREEN VIEW ═══════════════════════════════════════════════════════ -->
<div class="screen-only">
<!-- Per-class cards -->
<?php foreach ($classResults as $cls):
$nW = count($cls['winners']);
?>
<div class="card shadow-sm mb-4">
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2 py-2"
style="background:#fff8e1;">
<div class="d-flex align-items-center gap-2">
<span class="fw-bold fs-6"><?= esc($cls['section_name']) ?></span>
<span class="badge bg-warning text-dark">
<i class="bi bi-trophy-fill"></i> <?= $nW ?> winner<?= $nW !== 1 ? 's' : '' ?>
</span>
<?php if ($cls['threshold'] !== null): ?>
<span class="text-muted small">Fall &#8805; <?= number_format((float)$cls['threshold'], 1) ?></span>
<?php endif; ?>
</div>
<div class="d-flex gap-3 small">
<span>
<i class="bi bi-gender-male" style="color:#4A90E2"></i>
<strong style="color:#4A90E2"><?= $cls['trophy_boys'] ?></strong>/<?= $cls['boys'] ?> boys
<?php if ($cls['boys'] > 0): ?><span class="text-muted">(<?= $cls['pct_trophy_boys'] ?>%)</span><?php endif; ?>
</span>
<span>
<i class="bi bi-gender-female" style="color:#E47AB0"></i>
<strong style="color:#E47AB0"><?= $cls['trophy_girls'] ?></strong>/<?= $cls['girls'] ?> girls
<?php if ($cls['girls'] > 0): ?><span class="text-muted">(<?= $cls['pct_trophy_girls'] ?>%)</span><?php endif; ?>
</span>
<span class="text-muted"><?= $nW ?>/<?= $cls['student_count'] ?> (<?= $cls['pct_trophy_total'] ?>%)</span>
</div>
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0 align-middle" data-no-mgmt-sticky>
<thead class="table-light">
<tr>
<th class="ps-3" style="width:2.2rem;">#</th>
<th>Name</th>
<th class="text-center" style="width:3rem;">Gender</th>
<th class="text-end">Fall Score</th>
<th class="text-end">Spring Score</th>
<th class="text-end pe-3">Year Score</th>
</tr>
</thead>
<tbody>
<?php foreach ($cls['winners'] as $i => $student):
$isMale = strtolower($student['gender'] ?? '') === 'male';
?>
<tr>
<td class="ps-3 text-muted small"><?= $i + 1 ?></td>
<td class="fw-semibold small"><?= esc($student['name']) ?></td>
<td class="text-center">
<span class="badge" style="background:<?= $isMale ? '#4A90E2' : '#E47AB0' ?>;font-size:.65rem;">
<?= $isMale ? 'M' : 'F' ?>
</span>
</td>
<td class="text-end small fw-semibold">
<?= $student['fall_score'] !== null ? number_format($student['fall_score'], 1) : '<span class="text-muted">—</span>' ?>
</td>
<td class="text-end small">
<?= $student['spring_score'] !== null
? '<span class="fw-semibold">' . number_format($student['spring_score'], 1) . '</span>'
: '<span class="text-muted">—</span>' ?>
</td>
<td class="text-end pe-3 small fw-semibold" style="color:#155724;">
<?= $student['year_score'] !== null ? number_format($student['year_score'], 1) : '<span class="text-muted">—</span>' ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
<!-- Overall year stats -->
<div class="card shadow-sm mt-2">
<div class="card-header bg-dark text-white fw-semibold py-2">
<i class="bi bi-bar-chart-fill me-2"></i>Overall Year Summary — <?= esc($selectedYear) ?>
</div>
<div class="card-body">
<div class="row g-3 mb-3">
<?php foreach ([
[$totalWinners, 'Total Winners', 'warning', 'text-dark', $pctWinners . '% of students', null],
[$totalStudents, 'Total Students', 'secondary', 'text-white', count($classResults) . ' classes', null],
[$totalBoys, 'Total Boys', null, null, $pctBoys . '%', '#4A90E2'],
[$totalGirls, 'Total Girls', null, null, $pctGirls . '%', '#E47AB0'],
[$totalTrophyBoys, 'Trophy Boys', null, null, $pctTrophyBoys . '% of boys', '#4A90E2'],
[$totalTrophyGirls, 'Trophy Girls', null, null, $pctTrophyGirls . '% of girls', '#E47AB0'],
] as [$val, $label, $bg, $tc, $sub, $color]): ?>
<div class="col-6 col-sm-4 col-lg-2">
<div class="border rounded text-center p-3 h-100">
<div class="fs-3 fw-bold" <?= isset($color) ? 'style="color:' . $color . '"' : ($bg ? 'class="text-' . $bg . '"' : '') ?>>
<?= $val ?>
</div>
<div class="small text-muted"><?= $label ?></div>
<div class="badge mt-1 <?= $bg ? 'bg-' . $bg . ' ' . $tc : 'text-white' ?>"
<?= isset($color) && !$bg ? 'style="background:' . $color . '"' : '' ?>>
<?= $sub ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="row g-4 mt-1">
<!-- Stacked bar: trophies per class (boys + girls) -->
<div class="col-12 col-lg-7">
<div class="border rounded p-3 h-100">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-trophy-fill text-warning me-1"></i>Trophy Winners per Class
</div>
<canvas id="chart-per-class" style="max-height:260px;"></canvas>
</div>
</div>
<!-- Doughnut: trophy gender split + rate per class -->
<div class="col-12 col-lg-5">
<div class="row g-3 h-100">
<div class="col-12">
<div class="border rounded p-3">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-gender-ambiguous me-1"></i>Winners by Gender
</div>
<canvas id="chart-gender" style="max-height:160px;"></canvas>
</div>
</div>
<div class="col-12">
<div class="border rounded p-3">
<div class="small fw-semibold text-muted mb-2">
<i class="bi bi-bar-chart-fill me-1"></i>Trophy Rate per Class (%)
</div>
<canvas id="chart-rate" style="max-height:130px;"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div><!-- /screen-only -->
<?php endif; ?>
</div>
<?= $this->section('scripts') ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
(function () {
<?php
$chartLabels = [];
$chartBoys = [];
$chartGirls = [];
$chartRates = [];
foreach ($classResults as $cls) {
$chartLabels[] = $cls['section_name'];
$chartBoys[] = $cls['trophy_boys'];
$chartGirls[] = $cls['trophy_girls'];
$chartRates[] = $cls['pct_trophy_total'];
}
?>
var labels = <?= json_encode($chartLabels) ?>;
var boys = <?= json_encode($chartBoys) ?>;
var girls = <?= json_encode($chartGirls) ?>;
var rates = <?= json_encode($chartRates) ?>;
var BOY_COLOR = '#4A90E2';
var GIRL_COLOR = '#E47AB0';
var RATE_COLOR = '#f0a500';
/* ── Chart 1: stacked horizontal bar per class ── */
new Chart(document.getElementById('chart-per-class'), {
type: 'bar',
data: {
labels: labels,
datasets: [
{ label: 'Boys', data: boys, backgroundColor: BOY_COLOR, stack: 'winners' },
{ label: 'Girls', data: girls, backgroundColor: GIRL_COLOR, stack: 'winners' }
]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { position: 'bottom' } },
scales: {
x: { stacked: true, ticks: { stepSize: 1 }, grid: { color: '#f0f0f0' } },
y: { stacked: true }
}
}
});
/* ── Chart 2: doughnut gender split ── */
new Chart(document.getElementById('chart-gender'), {
type: 'doughnut',
data: {
labels: ['Trophy Boys', 'Trophy Girls'],
datasets: [{
data: [<?= $totalTrophyBoys ?>, <?= $totalTrophyGirls ?>],
backgroundColor: [BOY_COLOR, GIRL_COLOR],
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: function(ctx) {
var total = ctx.dataset.data.reduce(function(a,b){ return a+b; }, 0);
var pct = total > 0 ? Math.round(ctx.parsed / total * 100) : 0;
return ctx.label + ': ' + ctx.parsed + ' (' + pct + '%)';
}
}
}
}
}
});
/* ── Chart 3: bar chart rate per class ── */
new Chart(document.getElementById('chart-rate'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Trophy Rate %',
data: rates,
backgroundColor: rates.map(function(r) {
return r > 30 ? '#dc3545' : r > 20 ? RATE_COLOR : '#28a745';
}),
borderRadius: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true, max: 100,
ticks: { callback: function(v) { return v + '%'; } },
grid: { color: '#f0f0f0' }
}
}
}
});
})();
</script>
<?= $this->endSection() ?>
<?= $this->endSection() ?>
+1 -1
View File
@@ -48,7 +48,7 @@
<!-- Navbar Start -->
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
</a>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
+1 -1
View File
@@ -14,7 +14,7 @@
<?= csrf_field() ?>
<div class="text-center mb-4">
<a href="<?= base_url('/') ?>">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 180px; height: 120px;">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;">
</a>
</div>
+1 -1
View File
@@ -48,7 +48,7 @@
<!-- Navbar Start -->
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
</a>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
+101
View File
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Certificate Verification Al Rahma Sunday School</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.7.2/font/bootstrap-icons.min.css">
<style>
body { background: #f4f6f9; }
.verify-card { max-width: 520px; margin: 60px auto; }
.badge-valid { background: #198754; }
.badge-invalid { background: #dc3545; }
.cert-logo { width: 70px; height: 70px; border-radius: 50%; object-fit: cover; }
.field-label { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: #6c757d; }
.field-value { font-size: 1.05rem; font-weight: 500; }
</style>
</head>
<body>
<div class="verify-card px-3">
<!-- Header -->
<div class="text-center mb-4 mt-5">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="Al Rahma Sunday School" class="cert-logo mb-3">
<h4 class="fw-bold mb-0">Certificate Verification</h4>
<p class="text-muted small">Al Rahma Sunday School</p>
</div>
<?php if ($record): ?>
<!-- Valid -->
<div class="card shadow-sm border-0">
<div class="card-body p-4">
<div class="d-flex align-items-center gap-2 mb-4">
<span class="badge badge-valid text-white px-3 py-2 fs-6">
<i class="bi bi-patch-check-fill me-1"></i>Verified
</span>
<code class="text-muted"><?= esc($record['certificate_number']) ?></code>
</div>
<div class="row g-3">
<div class="col-12">
<div class="field-label">Student Name</div>
<div class="field-value"><?= esc($record['student_name']) ?></div>
</div>
<div class="col-6">
<div class="field-label">Grade / Class</div>
<div class="field-value"><?= esc($record['grade'] ?? '—') ?></div>
</div>
<div class="col-6">
<div class="field-label">School Year</div>
<div class="field-value"><?= esc($record['school_year'] ?? '—') ?></div>
</div>
<div class="col-6">
<div class="field-label">Certificate Date</div>
<div class="field-value">
<?= $record['cert_date'] ? esc(date('F j, Y', strtotime($record['cert_date']))) : '—' ?>
</div>
</div>
<div class="col-6">
<div class="field-label">Issued By</div>
<div class="field-value">
<?= esc(trim(($record['admin_firstname'] ?? '') . ' ' . ($record['admin_lastname'] ?? '')) ?: '—') ?>
</div>
</div>
<div class="col-12">
<div class="field-label">Issued On</div>
<div class="field-value">
<?= $record['issued_at'] ? esc(date('F j, Y \a\t g:i A', strtotime($record['issued_at']))) : '—' ?>
</div>
</div>
</div>
</div>
<div class="card-footer text-muted small text-center py-2">
This certificate was officially issued by Al Rahma Sunday School.
</div>
</div>
<?php else: ?>
<!-- Not found -->
<div class="card shadow-sm border-0">
<div class="card-body p-4 text-center">
<span class="badge badge-invalid text-white px-3 py-2 fs-6 mb-3 d-inline-block">
<i class="bi bi-x-circle-fill me-1"></i>Not Found
</span>
<p class="mb-0">No certificate matching this code was found in our records.<br>
Please check the code and try again.</p>
</div>
</div>
<?php endif; ?>
<p class="text-center text-muted small mt-4">
&copy; <?= date('Y') ?> Al Rahma Sunday School
</p>
</div>
</body>
</html>
+1 -1
View File
@@ -45,7 +45,7 @@
<!-- Navbar Start -->
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
</a>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
+1 -1
View File
@@ -44,7 +44,7 @@
<!-- Navbar Start -->
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
</a>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
+152
View File
@@ -0,0 +1,152 @@
<?= $this->extend('layout/email_layout') ?>
<?= $this->section('content') ?>
<div style="font-size:16px; font-family:Arial, Helvetica, sans-serif; color:#333;">
<p style="margin:0 0 12px 0;line-height:1.5;">
Dear <?= esc($parent_name ?? 'Parent/Guardian') ?>,
</p>
<p style="margin:0 0 12px 0;line-height:1.5;">
We are writing to share the school's decision regarding
<strong><?= esc($student_name ?? 'your student') ?></strong>
<?php if (!empty($class_section_name)): ?>(<?= esc($class_section_name) ?>)<?php endif; ?>
for the <?= esc(trim(($school_year ?? ''))) ?> school year.
</p>
<?php if (!empty($decision)): ?>
<table style="width:100%; border-collapse:collapse; margin:0 0 16px; font-size:15px;">
<tr>
<td style="border:1px solid #ddd; padding:10px; font-weight:bold; background:#f8f9fa; width:35%;">Decision</td>
<td style="border:1px solid #ddd; padding:10px; font-weight:bold; color:#2c5282;"><?= esc($decision) ?></td>
</tr>
</table>
<?php endif; ?>
<?php if (!empty($notes)): ?>
<p style="margin:0 0 6px 0;line-height:1.5;"><strong>Comments:</strong></p>
<p style="margin:0 0 16px 0;line-height:1.6;background:#f8f9fa;padding:10px 14px;border-left:4px solid #4a90d9;">
<?= nl2br(esc($notes)) ?>
</p>
<?php endif; ?>
<?php
$scoreLabels = [
'homework_avg' => 'Homework Avg',
'project_avg' => 'Project Avg',
'participation_score' => 'Participation',
'test_avg' => 'Test Avg',
'ptap_score' => 'PTAP Score',
'attendance_score' => 'Attendance',
'midterm_exam_score' => 'Midterm Score',
'final_exam_score' => 'Final Exam',
'semester_score' => 'Semester Score',
];
$allSemesters = is_array($all_semesters ?? null) ? $all_semesters : [];
$hasSemesters = !empty($allSemesters);
?>
<?php if ($hasSemesters): ?>
<p style="margin:0 0 8px 0;line-height:1.5;"><strong>Score Summary — <?= esc($school_year ?? '') ?></strong></p>
<?php foreach ($allSemesters as $sem): ?>
<?php
$semLabel = (string)($sem['semester'] ?? '');
$hasAnyScore = false;
foreach ($scoreLabels as $key => $_) {
$v = $sem[$key] ?? null;
if ($v !== null && $v !== '') { $hasAnyScore = true; break; }
}
?>
<p style="margin:0 0 4px 0;font-weight:bold;font-size:14px;"><?= esc($semLabel) ?> Semester</p>
<table style="width:100%; border-collapse:collapse; margin:0 0 12px; font-size:14px;">
<thead>
<tr style="background:#f8f9fa;">
<th style="border:1px solid #ddd; padding:6px; text-align:left;">Item</th>
<th style="border:1px solid #ddd; padding:6px; text-align:left;">Score</th>
</tr>
</thead>
<tbody>
<?php foreach ($scoreLabels as $key => $label): ?>
<?php $val = $sem[$key] ?? null; if ($val === null || $val === '') continue; ?>
<tr>
<td style="border:1px solid #ddd; padding:6px;"><?= esc($label) ?></td>
<td style="border:1px solid #ddd; padding:6px;<?= $key === 'semester_score' ? ' font-weight:bold;' : '' ?>">
<?= esc(number_format((float)$val, 2, '.', '')) ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (!$hasAnyScore): ?>
<tr><td colspan="2" style="border:1px solid #ddd; padding:6px; color:#999;">No scores recorded.</td></tr>
<?php endif; ?>
</tbody>
</table>
<?php
$semComments = is_array($sem['comments'] ?? null) ? $sem['comments'] : [];
$commentTypeLabels = [
'general' => 'General',
'attendance' => 'Attendance',
'attendance_comment' => 'Attendance',
'midterm' => 'Midterm',
'final' => 'Final Exam',
'ptap' => 'PTAP',
];
// Deduplicate by label+text
$seenCmt = [];
$dedupedCmt = [];
foreach ($semComments as $ctype => $ctext) {
$ctext = trim((string)$ctext);
if ($ctext === '') continue;
$clabel = $commentTypeLabels[$ctype] ?? ucfirst($ctype);
$key = $clabel . '|' . $ctext;
if (!isset($seenCmt[$key])) { $seenCmt[$key] = true; $dedupedCmt[] = [$clabel, $ctext]; }
}
?>
<?php if (!empty($dedupedCmt)): ?>
<p style="margin:0 0 4px 0;font-size:13px;font-weight:bold;">Comments</p>
<?php foreach ($dedupedCmt as [$clabel, $ctext]): ?>
<p style="margin:0 0 8px 0;font-size:13px;color:#444;line-height:1.5;background:#f8f9fa;padding:6px 10px;border-left:3px solid #aaa;">
<strong><?= esc($clabel) ?>:</strong> <?= nl2br(esc($ctext)) ?>
</p>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach; ?>
<?php else: ?>
<?php
// Fallback: single semester scores block
$scores = is_array($scores ?? null) ? $scores : [];
$hasScores = (bool) array_filter($scores, static fn($v) => $v !== null && $v !== '');
?>
<?php if ($hasScores): ?>
<p style="margin:0 0 6px 0;line-height:1.5;"><strong>Score Summary:</strong></p>
<table style="width:100%; border-collapse:collapse; margin:0 0 16px; font-size:14px;">
<thead>
<tr style="background:#f8f9fa;">
<th style="border:1px solid #ddd; padding:6px; text-align:left;">Item</th>
<th style="border:1px solid #ddd; padding:6px; text-align:left;">Score</th>
</tr>
</thead>
<tbody>
<?php foreach ($scoreLabels as $key => $label): ?>
<?php $val = $scores[$key] ?? null; if ($val === null || $val === '') continue; ?>
<tr>
<td style="border:1px solid #ddd; padding:6px;"><?= esc($label) ?></td>
<td style="border:1px solid #ddd; padding:6px;"><?= esc(number_format((float)$val, 2, '.', '')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php endif; ?>
<p style="margin:0 0 12px 0;line-height:1.5;">
Please do not hesitate to contact the school if you have any questions or concerns.
</p>
<p style="margin:0;line-height:1.5;">
Thank you,<br>
Al Rahma Sunday School
</p>
</div>
<?= $this->endSection() ?>
+1 -1
View File
@@ -4,7 +4,7 @@
<div class="modal-content rounded-4 shadow border-0" style="max-width: 600px; margin: auto;">
<div class="modal-body text-center p-5">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="Logo"
style="width: 180px; height: 120px;" class="mb-4">
style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;" class="mb-4">
<h5 class="modal-title text-danger mb-3" id="blockedLabel">Access Blocked</h5>
<p class="lead mb-3">Too many password reset attempts have been made.</p>
<p>Please try again after 24 hours or contact support if you need urgent assistance.</p>
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="bg-white p-5 rounded-5 shadow registration-form container" style="max-width: 600px; width: 100%;">
<div class="text-center mb-4">
<a href="<?= base_url('/') ?>">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 180px; height: 120px;">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;">
</a>
</div>
+1 -1
View File
@@ -44,7 +44,7 @@
<!-- Navbar Start -->
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
</a>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
+212
View File
@@ -0,0 +1,212 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid">
<div class="wrapper">
<h2 class="text-center mt-4 mb-4">Student Decisions — All Students</h2>
<!-- School Year filter only -->
<form method="get" class="row g-2 align-items-center justify-content-center mb-3">
<div class="col-auto"><label class="form-label mb-0">School year</label></div>
<div class="col-auto">
<select name="school_year" class="form-select form-select-sm" style="min-width: 180px;">
<?php $years = isset($schoolYears) && is_array($schoolYears) ? $schoolYears : []; ?>
<?php foreach ($years as $y):
$val = is_array($y) && isset($y['school_year']) ? (string)$y['school_year'] : (string)$y; ?>
<option value="<?= esc($val) ?>" <?= ($schoolYear === $val ? 'selected' : '') ?>><?= esc($val) ?></option>
<?php endforeach; ?>
<?php if (empty($years) && $schoolYear !== ''): ?>
<option value="<?= esc($schoolYear) ?>" selected><?= esc($schoolYear) ?></option>
<?php endif; ?>
</select>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-secondary btn-sm">Apply</button>
<a class="btn btn-outline-secondary btn-sm" href="?">Reset</a>
</div>
</form>
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div class="text-muted">
<?= esc($schoolYear ?? '') ?>
<?php if ($generated): ?>
<span class="badge bg-success ms-2">Saved</span>
<?php else: ?>
<span class="badge bg-warning text-dark ms-2">Not yet generated</span>
<?php endif; ?>
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-outline-secondary btn-sm" href="<?= base_url('grading') ?>">
Grading
</a>
</div>
</div>
<?php if (!empty(session()->getFlashdata('status'))): ?>
<div class="alert alert-success alert-dismissible fade show">
<?= esc(session()->getFlashdata('status')) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<?php if (!empty(session()->getFlashdata('error'))): ?>
<div class="alert alert-danger alert-dismissible fade show">
<?= esc(session()->getFlashdata('error')) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<!-- Generate / Regenerate button -->
<form method="post" action="<?= site_url('grading/decisions/generate') ?>" class="mb-3">
<?= csrf_field() ?>
<input type="hidden" name="school_year" value="<?= esc($schoolYear ?? '') ?>">
<button type="submit" class="btn btn-primary">
<?= $generated ? 'Regenerate Decisions' : 'Generate Decisions' ?>
</button>
<span class="text-muted small ms-2">
Year score ≥ 60 → <strong>Pass</strong> (auto) &nbsp;|&nbsp;
Year score &lt; 60 → pulled from below-60 decisions
</span>
</form>
<?php if (empty($rows)): ?>
<div class="alert alert-info">No semester scores found for this school year.</div>
<?php else: ?>
<?php
$decisionBadge = [
'Pass' => 'success',
'Repeat Class' => 'danger',
'Make-up exam in fall' => 'info',
'Deferred decision' => 'info',
'Expel' => 'danger',
'Withdrawn' => 'secondary',
];
$sourceBadge = [
'auto' => ['bg-success-subtle text-success-emphasis', 'Auto'],
'manual' => ['bg-primary-subtle text-primary-emphasis', 'Manual'],
'pending' => ['bg-warning-subtle text-warning-emphasis', 'Pending'],
];
$stats = ['Pass' => 0, 'Other' => 0, 'Pending' => 0];
foreach ($rows as $r) {
if ($r['decision'] === 'Pass') $stats['Pass']++;
elseif ($r['decision'] === '' || $r['source'] === 'pending') $stats['Pending']++;
else $stats['Other']++;
}
?>
<!-- Summary cards -->
<div class="d-flex gap-3 mb-3 flex-wrap">
<div class="card text-center px-4 py-2 border-success">
<div class="fs-4 fw-bold text-success"><?= $stats['Pass'] ?></div>
<div class="text-muted small">Pass</div>
</div>
<div class="card text-center px-4 py-2 border-primary">
<div class="fs-4 fw-bold text-primary"><?= $stats['Other'] ?></div>
<div class="text-muted small">Other decision</div>
</div>
<div class="card text-center px-4 py-2 border-warning">
<div class="fs-4 fw-bold text-warning"><?= $stats['Pending'] ?></div>
<div class="text-muted small">Pending</div>
</div>
<div class="card text-center px-4 py-2">
<div class="fs-4 fw-bold"><?= count($rows) ?></div>
<div class="text-muted small">Total</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-bordered table-striped align-middle w-100 all-decisions-dt"
data-no-mgmt-sticky data-no-dt-fixedheader>
<thead class="table-light">
<tr>
<th>Student Name</th>
<th>Section</th>
<th class="text-center">Fall Score</th>
<th class="text-center">Spring Score</th>
<th class="text-center">Year Score</th>
<th class="text-center">Decision</th>
<th class="text-center">Source</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row): ?>
<?php
$yearScore = $row['year_score'];
$decision = (string)($row['decision'] ?? '');
$source = (string)($row['source'] ?? 'pending');
$yearVal = is_numeric($yearScore) ? (float)$yearScore : null;
$rowClass = '';
if ($yearVal !== null && $yearVal < 60) {
$rowClass = $yearVal < 50 ? 'grade-red' : 'grade-orange';
}
$badge = $decisionBadge[$decision] ?? null;
[$srcCls, $srcLabel] = $sourceBadge[$source] ?? ['bg-light text-muted', $source];
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
$fmt = fn($v) => is_numeric($v) ? number_format((float)$v, 2) : '—';
?>
<tr class="<?= esc($rowClass) ?>">
<td><?= esc($studentName ?: 'N/A') ?></td>
<td><?= esc($row['class_section_name'] ?? '—') ?></td>
<td class="text-center"><?= esc($fmt($row['fall_score'] ?? null)) ?></td>
<td class="text-center"><?= esc($fmt($row['spring_score'] ?? null)) ?></td>
<td class="text-center fw-semibold">
<?= esc($fmt($yearVal)) ?>
</td>
<td class="text-center">
<?php if ($decision !== '' && $badge): ?>
<span class="badge bg-<?= esc($badge) ?>"><?= esc($decision) ?></span>
<?php elseif ($decision !== ''): ?>
<span class="badge bg-secondary"><?= esc($decision) ?></span>
<?php else: ?>
<span class="text-muted small">—</span>
<?php endif; ?>
</td>
<td class="text-center">
<span class="badge <?= esc($srcCls) ?>"><?= esc($srcLabel) ?></span>
</td>
<td class="text-muted small"><?= nl2br(esc((string)($row['notes'] ?? ''))) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Legend -->
<div class="mt-2 mb-4 d-flex gap-2 flex-wrap align-items-center">
<?php foreach ($decisionBadge as $label => $color): ?>
<span class="badge bg-<?= esc($color) ?> px-2 py-1"><?= esc($label) ?></span>
<?php endforeach; ?>
<span class="text-muted small ms-2">— decision colour key</span>
</div>
<?php endif; ?>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<style>
.grade-orange td { background: #fff3cd !important; color: #8a5d00; }
.grade-red td { background: #f8d7da !important; color: #842029; }
</style>
<script>
(function () {
if (!window.$ || !$.fn || !$.fn.DataTable) return;
$(function () {
const tbl = $('.all-decisions-dt');
if (!tbl.length) return;
try {
tbl.DataTable({
order: [[1, 'asc'], [0, 'asc']],
pageLength: 100,
lengthMenu: [25, 50, 100, 200],
columnDefs: [{ orderable: false, targets: [7] }]
});
} catch (_) {}
});
})();
</script>
<?= $this->endSection() ?>
+25 -9
View File
@@ -9,15 +9,21 @@
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div class="text-muted">
<?= esc(ucfirst($semester ?? '')) ?> • <?= esc($schoolYear ?? '') ?>
<?= !empty($isYearMode) ? 'Whole Year' : esc(ucfirst($semester ?? '')) ?> • <?= esc($schoolYear ?? '') ?>
</div>
<?php if (!empty($canViewGrading)): ?>
<a class="btn btn-outline-secondary btn-sm" href="<?= base_url('grading') ?>">
Back to Grading
<div class="d-flex gap-2">
<?php if (empty($isYearMode)): ?>
<a class="btn btn-outline-primary btn-sm"
href="<?= site_url('grading/below-60/decisions?' . http_build_query(['semester' => $semester ?? '', 'school_year' => $schoolYear ?? ''])) ?>">
Decisions
</a>
<?php else: ?>
<span class="text-muted small">You do not have access to the Grading page.</span>
<?php endif; ?>
<?php endif; ?>
<?php if (!empty($canViewGrading)): ?>
<a class="btn btn-outline-secondary btn-sm" href="<?= base_url('grading') ?>">
Back to Grading
</a>
<?php endif; ?>
</div>
</div>
<?php
@@ -43,6 +49,7 @@
<tr>
<th>Student Name</th>
<th>Section</th>
<?php if (!empty($isYearMode)): ?><th>Semester</th><?php endif; ?>
<th>Hwk Avg</th>
<th>Project Avg</th>
<th>Participation</th>
@@ -50,10 +57,12 @@
<th>PTAP Score</th>
<th>Attendance</th>
<th>Midterm Score</th>
<th><?= strcasecmp($semester ?? '', 'fall') === 0 ? '1st Semester Score' : 'Semester Score' ?></th>
<th><?= !empty($isYearMode) ? 'Semester Score' : (strcasecmp($semester ?? '', 'fall') === 0 ? '1st Semester Score' : 'Semester Score') ?></th>
<?php if (empty($isYearMode)): ?>
<th>Status</th>
<th>Email Parent</th>
<th>Schedule Meeting</th>
<?php endif; ?>
</tr>
</thead>
<tbody>
@@ -72,9 +81,13 @@
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
?>
<?php $isClosed = ($row['status'] ?? 'Open') === 'Closed'; ?>
<?php $rowSemester = ucfirst(strtolower(trim((string)($row['semester'] ?? ($semester ?? ''))))); ?>
<tr class="<?= esc($scoreClass) ?>">
<td><?= esc($studentName !== '' ? $studentName : 'N/A') ?></td>
<td><?= esc($row['class_section_name'] ?? '—') ?></td>
<?php if (!empty($isYearMode)): ?>
<td class="text-center"><?= esc($rowSemester) ?></td>
<?php endif; ?>
<td class="text-center"><?= $displayScore($row['homework_avg'] ?? null) ?></td>
<td class="text-center"><?= $displayScore($row['project_avg'] ?? null) ?></td>
<td class="text-center"><?= $displayScore($row['participation_score'] ?? null) ?></td>
@@ -83,6 +96,7 @@
<td class="text-center"><?= $displayScore($row['attendance_score'] ?? null) ?></td>
<td class="text-center"><?= $displayScore($row['midterm_exam_score'] ?? null) ?></td>
<td class="text-center"><?= $displayScore($row['semester_score'] ?? null) ?></td>
<?php if (empty($isYearMode)): ?>
<td class="text-center">
<form method="post" action="<?= site_url('grading/below-60/status') ?>" class="d-flex align-items-center gap-2 justify-content-center">
<?= csrf_field() ?>
@@ -117,6 +131,7 @@
</a>
<?php endif; ?>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
@@ -143,8 +158,9 @@
const table = $('.below-sixty-dt');
if (!table.length) return;
try {
const semesterOffset = <?= !empty($isYearMode) ? '1' : '0' ?>;
table.DataTable({
order: [[8, 'asc']],
order: [[8 + semesterOffset, 'asc']],
pageLength: 100,
lengthMenu: [10, 25, 50, 100, 200]
});
@@ -0,0 +1,100 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid">
<div class="wrapper">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div>
<h2 class="h4 mb-1">Send Decision Email</h2>
<div class="text-muted">
<?= esc($studentName !== '' ? $studentName : 'Student') ?> • <?= esc($semester ?? '') ?> • <?= esc($schoolYear ?? '') ?>
<?php if (!empty($decision)): ?>
&nbsp;— <span class="fw-semibold"><?= esc($decision) ?></span>
<?php endif; ?>
</div>
</div>
<a class="btn btn-outline-secondary btn-sm"
href="<?= site_url('grading/below-60/decisions?' . http_build_query(['semester' => $semester ?? '', 'school_year' => $schoolYear ?? ''])) ?>">
← Back to Decisions
</a>
</div>
<form method="post" action="<?= site_url('grading/below-60/decisions/email') ?>" class="card shadow-sm">
<?= csrf_field() ?>
<input type="hidden" name="student_id" value="<?= esc((string)($studentId ?? '')) ?>">
<input type="hidden" name="semester" value="<?= esc((string)($semester ?? '')) ?>">
<input type="hidden" name="school_year" value="<?= esc((string)($schoolYear ?? '')) ?>">
<input type="hidden" name="html" id="decision_html"
value="<?= htmlspecialchars((string)($html ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
<div class="card-body">
<div class="mb-3">
<label class="form-label" for="decisionSubject">Subject</label>
<input type="text" class="form-control" id="decisionSubject" name="subject"
value="<?= esc((string)($subject ?? '')) ?>" required>
</div>
<div class="mb-3">
<label class="form-label" for="decisionEditor">Body (Rich Text)</label>
<textarea class="form-control" id="decisionEditor" rows="16"><?= (string)($html ?? '') ?></textarea>
<div class="form-text">Review and edit the message before sending.</div>
<noscript>
<div class="alert alert-warning mt-2">
JavaScript is disabled. The message will be sent from the hidden <code>html</code> field.
</div>
</noscript>
</div>
<div class="d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary"
href="<?= site_url('grading/below-60/decisions?' . http_build_query(['semester' => $semester ?? '', 'school_year' => $schoolYear ?? ''])) ?>">
Cancel
</a>
<button type="submit" class="btn btn-primary">Send Email</button>
</div>
</div>
</form>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script src="<?= base_url('assets/tinymce/tinymce.min.js') ?>"></script>
<script>
(function () {
const form = document.querySelector('form[action$="grading/below-60/decisions/email"]');
const hiddenHtml = document.getElementById('decision_html');
if (!window.tinymce) return;
tinymce.init({
selector: '#decisionEditor',
base_url: '<?= base_url('assets/tinymce') ?>',
suffix: '.min',
license_key: 'gpl',
height: 460,
menubar: true,
branding: false,
promotion: false,
plugins: 'advlist autolink lists link image charmap preview anchor ' +
'searchreplace visualblocks code fullscreen insertdatetime media table ' +
'help wordcount emoticons codesample',
toolbar: 'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough forecolor backcolor | ' +
'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | ' +
'link image media table | emoticons codesample | removeformat | preview code',
convert_urls: false,
paste_data_images: true,
content_style: 'body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; font-size: 14px; }',
setup(editor) {
editor.on('keyup change undo redo SetContent', function () {
if (hiddenHtml) hiddenHtml.value = editor.getContent({ format: 'html' });
});
if (form) {
form.addEventListener('submit', function () {
if (hiddenHtml) hiddenHtml.value = editor.getContent({ format: 'html' });
});
}
}
});
})();
</script>
<?= $this->endSection() ?>
+534
View File
@@ -0,0 +1,534 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid">
<div class="wrapper below-sixty-decisions-wrapper">
<h2 class="text-center mt-4 mb-4">Below 60 — Decisions</h2>
<?= $this->include('partials/academic_filter') ?>
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div class="text-muted">
<?= esc(ucfirst($semester ?? '')) ?> • <?= esc($schoolYear ?? '') ?>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm"
href="<?= site_url('grading/below-60?' . http_build_query(['semester' => $semester, 'school_year' => $schoolYear])) ?>">
← Back to Below 60
</a>
<a class="btn btn-outline-primary btn-sm"
href="<?= site_url('grading/decisions?' . http_build_query(['semester' => $semester, 'school_year' => $schoolYear])) ?>">
All Decisions
</a>
<?php if (!empty($canViewGrading)): ?>
<a class="btn btn-outline-secondary btn-sm" href="<?= base_url('grading') ?>">
Grading
</a>
<?php endif; ?>
</div>
</div>
<?php if (!empty(session()->getFlashdata('status'))): ?>
<div class="alert alert-success alert-dismissible fade show" role="alert">
<?= esc(session()->getFlashdata('status')) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<?php endif; ?>
<?php if (!empty(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; ?>
<?php
$decisionOptions = [
'' => '— No decision yet —',
'Pass' => 'Pass',
'Repeat Class' => 'Repeat Class',
'Make-up exam in fall' => 'Make-up exam in fall',
'Deferred decision' => 'Deferred decision',
'Expel' => 'Expel',
'Withdrawn' => 'Withdrawn',
];
$decisionBadge = [
'Pass' => 'success',
'Repeat Class' => 'danger',
'Make-up exam in fall' => 'info',
'Deferred decision' => 'info',
'Expel' => 'danger',
'Withdrawn' => 'secondary',
];
$displayScore = function ($value) {
if ($value === null || $value === '') return '—';
if (is_numeric($value)) return esc(number_format((float)$value, 2, '.', ''));
return esc($value);
};
?>
<?php if (empty($rows)): ?>
<div class="alert alert-success text-center d-inline-block">
No students below 60 for this selection.
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-bordered table-striped align-middle w-100 decisions-dt" data-no-mgmt-sticky data-no-dt-fixedheader>
<thead class="table-light">
<tr>
<th style="min-width:160px">Student Name</th>
<th>Section</th>
<th class="text-center">Score</th>
<th style="min-width:300px">Comments / Rationale &amp; Decision</th>
<th style="min-width:150px" class="text-center">Below-60 Decision</th>
<th style="min-width:130px" class="text-center">Final Decision</th>
<th style="min-width:130px" class="text-center">Certificate</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row): ?>
<?php
$scoreRaw = $row['semester_score'] ?? null;
$scoreVal = is_numeric($scoreRaw) ? (float)$scoreRaw : null;
$rowClass = '';
if ($scoreVal !== null) {
$rowClass = $scoreVal < 50 ? 'grade-red' : 'grade-orange';
}
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
$currentDecision = (string)($row['decision'] ?? '');
$currentNotes = (string)($row['decision_notes'] ?? '');
$badge = $decisionBadge[$currentDecision] ?? null;
?>
<tr class="<?= esc($rowClass) ?>">
<td><?= esc($studentName !== '' ? $studentName : 'N/A') ?></td>
<td><?= esc($row['class_section_name'] ?? '—') ?></td>
<td class="text-center">
<div class="fw-semibold"><?= $displayScore($scoreRaw) ?></div>
<button type="button"
class="btn btn-outline-secondary btn-xs mt-1 btn-show-details"
style="font-size:0.72rem;padding:1px 7px;"
data-student-id="<?= (int)($row['student_id'] ?? 0) ?>"
data-student-name="<?= esc($studentName !== '' ? $studentName : 'N/A') ?>"
data-school-year="<?= esc((string)($schoolYear ?? '')) ?>">
Details
</button>
</td>
<td>
<form method="post" action="<?= site_url('grading/below-60/decisions/save') ?>">
<?= csrf_field() ?>
<input type="hidden" name="student_id" value="<?= esc((string)($row['student_id'] ?? '')) ?>">
<input type="hidden" name="semester" value="<?= esc((string)($semester ?? '')) ?>">
<input type="hidden" name="school_year" value="<?= esc((string)($schoolYear ?? '')) ?>">
<textarea name="notes"
class="form-control form-control-sm decision-notes"
rows="3"
placeholder="Add comments or rationale…"><?= esc($currentNotes) ?></textarea>
<div class="d-flex gap-2 mt-2 align-items-center">
<select name="decision" class="form-select form-select-sm decision-select flex-grow-1">
<?php foreach ($decisionOptions as $val => $label): ?>
<option value="<?= esc($val) ?>" <?= $currentDecision === $val ? 'selected' : '' ?>>
<?= esc($label) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-sm btn-primary">Save</button>
</div>
</form>
</td>
<td class="text-center align-middle">
<?php if ($currentDecision !== '' && $badge): ?>
<span class="badge bg-<?= esc($badge) ?> fs-6 px-3 py-2 d-block mb-2"><?= esc($currentDecision) ?></span>
<button type="button"
class="btn btn-sm btn-outline-primary btn-send-email"
data-student-id="<?= (int)($row['student_id'] ?? 0) ?>"
data-semester="<?= esc((string)($semester ?? '')) ?>"
data-school-year="<?= esc((string)($schoolYear ?? '')) ?>">
Send Email
</button>
<?php else: ?>
<span class="text-muted small">Pending</span>
<?php endif; ?>
</td>
<?php
// Final (consolidated) decision from student_decisions table
$finalDecision = $row['consolidated_decision'] ?? null;
$finalBadge = $finalDecision !== null ? ($decisionBadge[$finalDecision] ?? 'secondary') : null;
?>
<td class="text-center align-middle">
<?php if ($finalDecision !== null && $finalDecision !== ''): ?>
<span class="badge bg-<?= esc($finalBadge) ?> px-2 py-1"><?= esc($finalDecision) ?></span>
<?php else: ?>
<a href="<?= site_url('grading/decisions?' . http_build_query(['semester' => $semester ?? '', 'school_year' => $schoolYear ?? ''])) ?>"
class="text-muted small">Generate</a>
<?php endif; ?>
</td>
<?php $certNumber = (string)($row['certificate_number'] ?? ''); ?>
<td class="text-center align-middle">
<?php if ($certNumber !== ''): ?>
<a href="<?= site_url('administrator/certificates/reprint/' . rawurlencode($certNumber)) ?>"
target="_blank"
class="font-monospace text-decoration-none fw-semibold"
title="Click to reprint certificate">
<?= esc($certNumber) ?>
</a>
<?php else: ?>
<span class="text-muted small">—</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="mt-3 mb-4 d-flex gap-2 flex-wrap">
<?php foreach ($decisionBadge as $label => $color): ?>
<span class="badge bg-<?= esc($color) ?> px-3 py-2"><?= esc($label) ?></span>
<?php endforeach; ?>
<span class="text-muted small align-self-center ms-1">— decision colour key</span>
</div>
<?php endif; ?>
</div>
</div>
<!-- Score details modal -->
<div class="modal fade" id="detailsModal" tabindex="-1" aria-labelledby="detailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="detailsModalLabel">Score Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="detailsModalBody">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Email editor modal -->
<div class="modal fade" id="decisionEmailModal" tabindex="-1" aria-labelledby="decisionEmailModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="decisionEmailModalLabel">Send Decision Email</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div id="emailModalLoading" class="modal-body text-center py-5" style="display:none;">
<div class="spinner-border text-primary" role="status"></div>
<div class="mt-2 text-muted">Loading email template…</div>
</div>
<div id="emailModalError" class="modal-body" style="display:none;">
<div class="alert alert-danger mb-0" id="emailModalErrorMsg"></div>
</div>
<form id="decisionEmailForm" method="post"
action="<?= site_url('grading/below-60/decisions/email') ?>"
style="display:none;">
<?= csrf_field() ?>
<input type="hidden" name="student_id" id="emailStudentId">
<input type="hidden" name="semester" id="emailSemester">
<input type="hidden" name="school_year" id="emailSchoolYear">
<input type="hidden" name="html" id="emailHtmlHidden">
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold" for="emailSubjectInput">Subject</label>
<input type="text" class="form-control" id="emailSubjectInput" name="subject" required>
</div>
<div class="mb-1">
<label class="form-label fw-semibold">Body</label>
</div>
<textarea id="decisionEmailEditor" rows="18"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary" id="emailSendBtn">Send Email</button>
</div>
</form>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<style>
.grade-orange td { background: #fff3cd !important; color: #8a5d00; }
.grade-red td { background: #f8d7da !important; color: #842029; }
.decision-notes { font-size: 0.85rem; resize: vertical; min-height: 60px; }
.decisions-dt td { vertical-align: top; }
</style>
<script src="<?= base_url('assets/tinymce/tinymce.min.js') ?>"></script>
<script>
(function () {
// DataTable
if (window.$ && $.fn && $.fn.DataTable) {
$(function () {
const tbl = $('.decisions-dt');
if (!tbl.length) return;
try {
tbl.DataTable({
order: [[2, 'asc']],
pageLength: 100,
lengthMenu: [10, 25, 50, 100, 200],
columnDefs: [{ orderable: false, targets: [3] }]
});
} catch (_) {}
});
}
// ── Details modal ────────────────────────────────────────────
const detailsModal = document.getElementById('detailsModal');
const detailsModalBody = document.getElementById('detailsModalBody');
const detailsModalTitle= document.getElementById('detailsModalLabel');
const SCORE_LABELS = {
homework_avg: 'Homework Avg',
project_avg: 'Project Avg',
participation_score: 'Participation',
test_avg: 'Test Avg',
ptap_score: 'PTAP Score',
attendance_score: 'Attendance',
midterm_exam_score: 'Midterm Score',
final_exam_score: 'Final Exam',
semester_score: 'Semester Score',
};
const COMMENT_TYPE_LABELS = {
general: 'General',
attendance: 'Attendance',
attendance_comment: 'Attendance',
midterm: 'Midterm',
final: 'Final Exam',
ptap: 'PTAP',
};
function fmtScore(v) {
if (v === null || v === '' || v === undefined) return '—';
const n = parseFloat(v);
return isNaN(n) ? v : n.toFixed(2);
}
function esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function buildDetailsHtml(semesters) {
if (!semesters || semesters.length === 0) {
return '<div class="alert alert-warning mb-0">No score data found.</div>';
}
let html = '';
semesters.forEach(function (sem) {
html += '<h6 class="fw-bold mt-3 mb-2">' + esc(sem.semester || '') + ' Semester';
if (sem.class_section_name) html += ' <span class="text-muted fw-normal fs-6">— ' + esc(sem.class_section_name) + '</span>';
html += '</h6>';
// Scores table
html += '<table class="table table-sm table-bordered mb-2">';
html += '<thead class="table-light"><tr><th>Item</th><th class="text-center">Score</th></tr></thead><tbody>';
let hasRow = false;
Object.entries(SCORE_LABELS).forEach(function ([key, label]) {
const v = sem[key];
if (v === null || v === '' || v === undefined) return;
const bold = key === 'semester_score' ? ' fw-bold' : '';
html += '<tr><td>' + label + '</td><td class="text-center' + bold + '">' + fmtScore(v) + '</td></tr>';
hasRow = true;
});
if (!hasRow) html += '<tr><td colspan="2" class="text-muted">No scores recorded.</td></tr>';
html += '</tbody></table>';
// Comments section
const comments = sem.comments || {};
const commentEntries = Object.entries(comments).filter(function ([, v]) { return v && v.trim(); });
// Deduplicate attendance + attendance_comment (show once)
const seen = {};
const deduped = [];
commentEntries.forEach(function ([type, text]) {
const label = COMMENT_TYPE_LABELS[type] || type;
const key = label + '|' + text.trim();
if (!seen[key]) { seen[key] = true; deduped.push([label, text]); }
});
if (deduped.length > 0) {
html += '<div class="mb-3">';
html += '<p class="fw-semibold mb-1" style="font-size:0.9rem;">Comments</p>';
deduped.forEach(function ([label, text]) {
html += '<div class="mb-2 p-2 bg-light rounded border-start border-3 border-secondary">';
html += '<span class="badge bg-secondary me-1" style="font-size:0.7rem;">' + esc(label) + '</span>';
html += '<span class="text-dark" style="font-size:0.9rem;">' + esc(text).replace(/\n/g, '<br>') + '</span>';
html += '</div>';
});
html += '</div>';
}
});
return html;
}
if (detailsModal) {
document.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-show-details');
if (!btn) return;
const studentId = btn.dataset.studentId;
const studentName= btn.dataset.studentName;
const schoolYear = btn.dataset.schoolYear;
detailsModalTitle.textContent = studentName + ' — Score Details';
detailsModalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"></div></div>';
bootstrap.Modal.getOrCreateInstance(detailsModal).show();
const url = '<?= site_url('grading/below-60/decisions/student-details') ?>'
+ '?student_id=' + encodeURIComponent(studentId)
+ '&school_year=' + encodeURIComponent(schoolYear);
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) {
detailsModalBody.innerHTML = '<div class="alert alert-danger">' + data.error + '</div>';
return;
}
detailsModalBody.innerHTML = buildDetailsHtml(data.semesters);
})
.catch(function () {
detailsModalBody.innerHTML = '<div class="alert alert-danger">Failed to load details.</div>';
});
});
}
// ── Email modal ───────────────────────────────────────────────
const modal = document.getElementById('decisionEmailModal');
const loadingPane = document.getElementById('emailModalLoading');
const errorPane = document.getElementById('emailModalError');
const errorMsg = document.getElementById('emailModalErrorMsg');
const form = document.getElementById('decisionEmailForm');
const subjectInput = document.getElementById('emailSubjectInput');
const htmlHidden = document.getElementById('emailHtmlHidden');
const studentIdIn = document.getElementById('emailStudentId');
const semesterIn = document.getElementById('emailSemester');
const schoolYearIn = document.getElementById('emailSchoolYear');
if (!modal) return;
function showPane(which) {
loadingPane.style.display = which === 'loading' ? '' : 'none';
errorPane.style.display = which === 'error' ? '' : 'none';
form.style.display = which === 'form' ? '' : 'none';
}
function destroyEditor() {
if (window.tinymce) {
tinymce.remove('#decisionEmailEditor');
}
}
function initEditor(html) {
if (!window.tinymce) return;
tinymce.init({
selector: '#decisionEmailEditor',
base_url: '<?= base_url('assets/tinymce') ?>',
suffix: '.min',
license_key: 'gpl',
height: 460,
menubar: true,
branding: false,
promotion: false,
plugins: 'advlist autolink lists link image charmap preview anchor ' +
'searchreplace visualblocks code fullscreen insertdatetime media table ' +
'help wordcount emoticons codesample',
toolbar: 'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough forecolor backcolor | ' +
'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | ' +
'link image media table | emoticons codesample | removeformat | preview code',
convert_urls: false,
paste_data_images: true,
content_style: 'body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; font-size: 14px; }',
setup(editor) {
editor.on('init', function () {
editor.setContent(html);
if (htmlHidden) htmlHidden.value = html;
});
editor.on('keyup change undo redo SetContent', function () {
if (htmlHidden) htmlHidden.value = editor.getContent({ format: 'html' });
});
}
});
}
// Sync hidden field before form submit
form.addEventListener('submit', function () {
if (window.tinymce) {
const ed = tinymce.get('decisionEmailEditor');
if (ed && htmlHidden) htmlHidden.value = ed.getContent({ format: 'html' });
}
});
// Destroy editor when modal closes
modal.addEventListener('hidden.bs.modal', function () {
destroyEditor();
showPane('loading');
});
// Open modal when "Send Email" is clicked
document.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-send-email');
if (!btn) return;
const studentId = btn.dataset.studentId;
const semester = btn.dataset.semester;
const schoolYear = btn.dataset.schoolYear;
studentIdIn.value = studentId;
semesterIn.value = semester;
schoolYearIn.value = schoolYear;
showPane('loading');
// Open the modal immediately (shows spinner)
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
bsModal.show();
// Fetch the pre-rendered email
const url = '<?= site_url('grading/below-60/decisions/email/preview') ?>'
+ '?student_id=' + encodeURIComponent(studentId)
+ '&semester=' + encodeURIComponent(semester)
+ '&school_year='+ encodeURIComponent(schoolYear);
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (res) { return res.json(); })
.then(function (data) {
if (data.error) {
errorMsg.textContent = data.error;
showPane('error');
return;
}
subjectInput.value = data.subject || '';
// Reset textarea content before init
document.getElementById('decisionEmailEditor').value = data.html || '';
showPane('form');
initEditor(data.html || '');
})
.catch(function () {
errorMsg.textContent = 'Failed to load email template. Please try again.';
showPane('error');
});
});
})();
</script>
<?= $this->endSection() ?>
+1 -1
View File
@@ -412,7 +412,7 @@
<!-- Navbar Start -->
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 60px; width: 80px;">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 60px; width: 60px; border-radius: 50%; object-fit: cover;">
<h1 class="m-0 ms-2 green-title"></h1>
</a>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
+1 -1
View File
@@ -57,7 +57,7 @@
<body>
<div class="email-container">
<div class="email-nopicture">
<img src="https://alrahmaisgl.org/assets/images/alrahma_logo.png" alt="" style="width: 150px; height: 110px">
<img src="https://alrahmaisgl.org/assets/images/alrahma_logo.png" alt="" style="width: 110px; height: 110px; border-radius: 50%; object-fit: cover;">
</div>
<div class="email-body">
+1
View File
@@ -172,6 +172,7 @@
#navbarManagement[data-mgmt-menu-mode="dark"] .navbar-toggler-icon { filter: invert(1) brightness(2); }
.logout-btn { border-color: var(--mgmt-primary) !important; color: var(--mgmt-primary) !important; }
.logout-btn:hover { background-color: var(--mgmt-primary) !important; color: #fff !important; }
.school-logo-circle { border-radius: 50%; object-fit: cover; }
/* Sidebar (hover to reveal) */
#navbarManagement.mgmt-sidebar {
+1 -1
View File
@@ -1,7 +1,7 @@
<!-- navbar.php -->
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" class="school-logo-circle" style="height: 40px; width: 40px; object-fit: cover; border-radius: 50%;">
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
</a>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
+3
View File
@@ -54,6 +54,9 @@ $classSectionVal = (string)($classSectionId ?? ($_GET['class_section_id'] ?? '')
<div class="col-auto">
<select name="semester" class="form-select form-select-sm" style="min-width: 140px;">
<option value="">—</option>
<?php if (!empty($showAllSemesterOption)): ?>
<option value="year" <?= (strcasecmp($semVal, 'year') === 0 ? 'selected' : '') ?>>Whole Year</option>
<?php endif; ?>
<option value="Fall" <?= (strcasecmp($semVal, 'Fall') === 0 ? 'selected' : '') ?>>Fall</option>
<option value="Spring" <?= (strcasecmp($semVal, 'Spring') === 0 ? 'selected' : '') ?>>Spring</option>
</select>
+1 -1
View File
@@ -56,7 +56,7 @@
<div class="d-flex align-items-center w-100">
<!-- Brand (left) -->
<a class="navbar-brand col-md-3 col-lg-2 mr-0 px-1" href="/administrator/administratordashboard">
<img src="<?= base_url('assets/images/logo.png') ?>" alt="School Icon" style="width: 50px; height: 40px; margin-right: 8px;">
<img src="<?= base_url('assets/images/logo.png') ?>" alt="School Icon" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover; margin-right: 8px;">
<strong>School Management Dashboard</strong>
</a>
+3
View File
@@ -104,6 +104,9 @@ $role = strtolower(session()->get('role') ?? 'guest');
<div class="dropdown-menu">
<a class="dropdown-item" href="/administrator/class_assignment">Classes List</a>
<a class="dropdown-item" href="/administrator/subject-curriculum">Subject Curriculum</a>
<a class="dropdown-item" href="/administrator/trophy">
<i class="bi bi-trophy-fill text-warning me-1"></i>Trophy Awards
</a>
</div>
</li>
+1 -1
View File
@@ -15,7 +15,7 @@ Test PASSWORD: 9JC?].qM
<div class="mb-4">
<a href="<?= base_url('/parent_dashboard') ?>">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="Alrahma Logo"
style="width: 180px; height: 120px;">
style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;">
</a>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="bg-white p-5 rounded-5 shadow registration-form container" style="max-width: 600px; width: 100%;">
<div class="text-center mb-4">
<a href="<?= base_url('/') ?>">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 180px; height: 120px;">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;">
</a>
</div>
+1 -1
View File
@@ -43,7 +43,7 @@
<!-- Navbar Start -->
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
</a>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
+1 -1
View File
@@ -48,7 +48,7 @@
<!-- Navbar Start -->
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
</a>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
+1 -1
View File
@@ -48,7 +48,7 @@
<!-- Navbar Start -->
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
</a>
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
+1 -1
View File
@@ -14,7 +14,7 @@
<div class="text-center mb-4">
<a href="<?= base_url('/') ?>">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt=""
style="width: 180px; height: 120px;">
style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;">
</a>
</div>
+5 -3
View File
@@ -8,7 +8,7 @@
<div class="text-center mb-4">
<a href="<?= base_url('/') ?>">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt=""
style="width: 180px; height: 120px;">
style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;">
</a>
</div>
@@ -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)"
@@ -4,7 +4,7 @@
<div class="modal-content rounded-4 shadow border-0" style="max-width: 600px; margin: auto;">
<div class="modal-body text-center">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="Logo" style="width: 180px; height: 120px;">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="Logo" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;">
<h5 class="modal-title text-success" id="emailConfirmLabel">Check Your Email</h5>
<p class="lead mt-3">
A link to reset the password has been sent to this email:<br>
+1 -1
View File
@@ -9,7 +9,7 @@
<div class="modal fade" id="successModal" tabindex="-1" aria-labelledby="successModalLabel" aria-hidden="true">
<div class="text-center mb-4">
<a href="<?= base_url('/') ?>">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 180px; height: 120px;">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;">
</a>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-success shadow">
+1 -1
View File
@@ -20,7 +20,7 @@
<div class="text-center mb-4">
<a href="<?= base_url('/') ?>">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt=""
style="width: 180px; height: 120px;">
style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;">
</a>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@
<?= csrf_field() ?>
<div class="text-center mb-4">
<a href="<?= base_url('/') ?>">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 180px; height: 120px;"></a>
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;"></a>
</div>
<h3 class="text-center text-success" style="font-family: Arial, sans-serif;">Reset Your Password</h3>
<br>
@@ -6,7 +6,7 @@
<?= csrf_field(); ?>
<div class="text-center mb-4">
<a href="<?= base_url('/') ?>">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 180px; height: 120px;">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;">
</a>
</div>
+1 -1
View File
@@ -8,7 +8,7 @@
<?= csrf_field(); ?>
<div class="text-center mb-4">
<a href="<?= base_url('/') ?>">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 180px; height: 120px;">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;">
</a>
</div>
+8 -5
View File
@@ -1,12 +1,15 @@
<?php
helper('security');
// Database configuration
$host = 'localhost';
$dbname = 'u280815660_school';
$username = 'u280815660_melabidi';
$password = '>tNxlRzP/W8';
$host = env('database.default.hostname', 'localhost');
$dbname = require_env('database.default.database');
$username = require_env('database.default.username');
$password = require_env('database.default.password');
$port = (int) env('database.default.port', 3306);
// Create connection
$conn = @new mysqli($host, $username, $password, $dbname);
$conn = @new mysqli($host, $username, $password, $dbname, $port);
// Check connection
if ($conn->connect_error) {
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

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