Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89913d7473 | |||
| 079c869477 | |||
| 9ee75fe4cc | |||
| fcfa56b3f5 | |||
| 39ab0d113e | |||
| ce56c96cdd | |||
| b4a06903e2 | |||
| 0654668530 | |||
| 3737b3522d | |||
| c294d7bed7 | |||
| 5ed65a867c | |||
| 22d6f960ea | |||
| cc0b83c1b9 | |||
| 31976752da | |||
| 0bf8d13777 | |||
| 4ae62e37a3 | |||
| d8fdbeb4c3 | |||
| 239d35f8ff | |||
| 3333e71c94 | |||
| af75475214 | |||
| a8ef665239 | |||
| 7c97a60795 | |||
| acca87c77b | |||
| 9aacbe8972 | |||
| 76866ffe1e | |||
| b01a4496e6 |
@@ -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');
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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/*']],
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use CodeIgniter\Format\XMLFormatter;
|
||||
|
||||
class Format extends BaseConfig
|
||||
{
|
||||
public int $jsonEncodeDepth = 512;
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Available Response Formats
|
||||
|
||||
+29
-2
@@ -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
|
||||
@@ -518,6 +538,9 @@ $routes->get('grading/(:segment)/(:num)/(:num)', 'View\GradingController::show/$
|
||||
$routes->post('grading/update', 'View\GradingController::update');
|
||||
$routes->get('grading', 'View\GradingController::grading');
|
||||
$routes->post('grading/toggle-score-lock', 'View\GradingController::toggleScoreLock', ['filter' => 'auth:read']);
|
||||
$routes->get('grading/toggle-score-lock', static function () {
|
||||
return 'GET route hit. Something is calling this route incorrectly.';
|
||||
});
|
||||
$routes->post('grading/lock-all-scores', 'View\GradingController::lockAllScores', ['filter' => 'auth:read']);
|
||||
$routes->post('grading/release-scores', 'View\GradingController::toggleParentScoresRelease', ['filter' => 'auth:read']);
|
||||
$routes->post('grading/refresh-semester-scores', 'View\GradingController::refreshSemesterScores', ['filter' => 'auth:read']);
|
||||
@@ -535,6 +558,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 +769,9 @@ $routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorCo
|
||||
$routes->get('/administrator/exam-drafts', 'View\ExamDraftController::adminIndex', ['filter' => 'auth:admin']);
|
||||
$routes->post('/administrator/exam-drafts/review', 'View\ExamDraftController::adminReview', ['filter' => 'auth:admin']);
|
||||
$routes->post('/administrator/exam-drafts/upload-legacy', 'View\ExamDraftController::adminUploadLegacy', ['filter' => 'auth:admin']);
|
||||
$routes->get('/principal/exam-drafts', 'View\ExamDraftController::principalIndex', ['filter' => 'auth:admin']);
|
||||
$routes->post('/principal/exam-drafts/review', 'View\ExamDraftController::principalReview', ['filter' => 'auth:admin']);
|
||||
$routes->post('/principal/exam-drafts/upload-legacy', 'View\ExamDraftController::principalUploadLegacy', ['filter' => 'auth:admin']);
|
||||
|
||||
/*
|
||||
* --------------------------------------------------------------------
|
||||
|
||||
@@ -36,7 +36,7 @@ class Services extends BaseService
|
||||
/**
|
||||
* Override CI Email service to enforce a global Reply-To.
|
||||
*/
|
||||
public static function email(array $config = null, bool $getShared = true)
|
||||
public static function email(?array $config = null, bool $getShared = true)
|
||||
{
|
||||
if ($getShared) {
|
||||
return static::getSharedInstance('email', $config);
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Controllers;
|
||||
use App\Models\LoginActivityModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\UserRoleModel;
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\Events\Events;
|
||||
use App\Models\IpAttemptModel;
|
||||
use App\Models\PasswordResetModel;
|
||||
@@ -19,7 +18,7 @@ require_once APPPATH . 'Helpers/pbkdf2_helper.php';
|
||||
require_once APPPATH . 'Helpers/jwt_helper.php';
|
||||
|
||||
|
||||
class AuthController extends Controller
|
||||
class AuthController extends BaseController
|
||||
{
|
||||
protected $configModel;
|
||||
protected $userModel;
|
||||
@@ -83,9 +82,23 @@ class AuthController extends Controller
|
||||
log_message('info', 'Processing login form submission.');
|
||||
$redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? $this->request->getGet('redirect_to') ?? ''));
|
||||
|
||||
$validator = \Config\Services::validation();
|
||||
$requestData = sanitize_request_value($this->request->getPost(['email', 'password']));
|
||||
$validator->setRules([
|
||||
'email' => 'required|valid_email|max_length[254]',
|
||||
'password' => 'required|max_length[255]',
|
||||
]);
|
||||
|
||||
if (! $validator->run($requestData)) {
|
||||
return redirect()
|
||||
->back()
|
||||
->with('error', 'Please enter a valid email and password.')
|
||||
->withInput();
|
||||
}
|
||||
|
||||
// Step 1: Get email, password, and IP from the request
|
||||
$email = $this->request->getPost('email');
|
||||
$password = $this->request->getPost('password');
|
||||
$email = strtolower((string) $requestData['email']);
|
||||
$password = (string) $requestData['password'];
|
||||
$ip = $this->request->getIPAddress();
|
||||
|
||||
log_message('info', 'Login attempt from IP: ' . $ip . ' for email: ' . $email);
|
||||
@@ -139,26 +152,29 @@ class AuthController extends Controller
|
||||
// JSON API: POST /api/login
|
||||
public function apiLogin()
|
||||
{
|
||||
$requestData = $this->request->getJSON(true);
|
||||
if (!$requestData) {
|
||||
// fallback to form vars
|
||||
$requestData = [
|
||||
'email' => $this->request->getPost('email'),
|
||||
'password' => $this->request->getPost('password'),
|
||||
];
|
||||
}
|
||||
$requestData = sanitize_request_value($this->request->getJSON(true) ?: [
|
||||
'email' => $this->request->getPost('email'),
|
||||
'password' => $this->request->getPost('password'),
|
||||
]);
|
||||
|
||||
$email = $requestData['email'] ?? '';
|
||||
$password = $requestData['password'] ?? '';
|
||||
$ip = $this->request->getIPAddress();
|
||||
$validation = \Config\Services::validation();
|
||||
$validation->setRules([
|
||||
'email' => 'required|valid_email|max_length[254]',
|
||||
'password' => 'required|max_length[255]',
|
||||
]);
|
||||
|
||||
if (!$email || !$password) {
|
||||
return $this->response->setStatusCode(400)->setJSON([
|
||||
if (! $validation->run($requestData)) {
|
||||
return $this->response->setStatusCode(422)->setJSON([
|
||||
'status' => false,
|
||||
'message' => 'Email and password are required.'
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validation->getErrors(),
|
||||
]);
|
||||
}
|
||||
|
||||
$email = strtolower((string) ($requestData['email'] ?? ''));
|
||||
$password = (string) ($requestData['password'] ?? '');
|
||||
$ip = $this->request->getIPAddress();
|
||||
|
||||
if ($this->isIpBlocked($ip)) {
|
||||
return $this->response->setStatusCode(429)->setJSON([
|
||||
'status' => false,
|
||||
@@ -220,7 +236,15 @@ class AuthController extends Controller
|
||||
'exp' => $exp,
|
||||
];
|
||||
|
||||
$secret = env('JWT_SECRET', 'change-me-in-env');
|
||||
try {
|
||||
$secret = require_env('JWT_SECRET');
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->response->setStatusCode(500)->setJSON([
|
||||
'status' => false,
|
||||
'message' => 'JWT configuration is missing.',
|
||||
]);
|
||||
}
|
||||
|
||||
$token = jwt_encode($payload, $secret, 'HS256');
|
||||
|
||||
return $this->response->setJSON([
|
||||
@@ -240,18 +264,14 @@ class AuthController extends Controller
|
||||
*/
|
||||
public function apiRegister()
|
||||
{
|
||||
$requestData = $this->request->getJSON(true);
|
||||
if (!$requestData) {
|
||||
// fallback to form vars
|
||||
$requestData = $this->request->getPost();
|
||||
}
|
||||
$requestData = sanitize_request_value($this->request->getJSON(true) ?: $this->request->getPost());
|
||||
|
||||
// Basic validation
|
||||
$rules = [
|
||||
'firstname' => 'required|min_length[2]|max_length[30]',
|
||||
'lastname' => 'required|min_length[2]|max_length[30]',
|
||||
'email' => 'required|valid_email|is_unique[users.email]',
|
||||
'password' => 'required|min_length[8]',
|
||||
'firstname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]",
|
||||
'lastname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]",
|
||||
'email' => 'required|valid_email|max_length[254]|is_unique[users.email]',
|
||||
'password' => 'required|min_length[8]|max_length[255]',
|
||||
'cellphone' => 'required|min_length[10]|max_length[20]',
|
||||
];
|
||||
|
||||
@@ -332,7 +352,7 @@ class AuthController extends Controller
|
||||
'exp' => $exp,
|
||||
];
|
||||
|
||||
$secret = env('JWT_SECRET', 'change-me-in-env');
|
||||
$secret = require_env('JWT_SECRET');
|
||||
$token = jwt_encode($payload, $secret, 'HS256');
|
||||
|
||||
return $this->response->setStatusCode(201)->setJSON([
|
||||
|
||||
@@ -36,7 +36,7 @@ abstract class BaseController extends Controller
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $helpers = [];
|
||||
protected $helpers = ['security'];
|
||||
|
||||
/** @var ApiClient */
|
||||
protected ApiClient $api;
|
||||
@@ -73,6 +73,21 @@ abstract class BaseController extends Controller
|
||||
// Assuming the user role is stored in the session
|
||||
return session()->get('role');
|
||||
}
|
||||
|
||||
protected function validated(array $rules, ?array $data = null): array
|
||||
{
|
||||
$validation = service('validation');
|
||||
$payload = $data ?? ($this->request->getJSON(true) ?: $this->request->getPost());
|
||||
$payload = sanitize_request_value($payload);
|
||||
|
||||
$validation->setRules($rules);
|
||||
|
||||
if (! $validation->run($payload)) {
|
||||
throw new \InvalidArgumentException(json_encode($validation->getErrors(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: 'Validation failed');
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
?>
|
||||
|
||||
@@ -10,8 +10,13 @@ class ProofreadController extends ResourceController
|
||||
{
|
||||
// Basic per-IP throttling: 10 requests per minute
|
||||
$throttler = service('throttler');
|
||||
$key = 'proofread-' . $this->request->getIPAddress();
|
||||
if (!$throttler->check($key, 10, MINUTE)) {
|
||||
|
||||
// Cache/throttler keys cannot contain reserved characters.
|
||||
// Raw IPv6 addresses contain ":", so hash the IP first.
|
||||
$ip = $this->request->getIPAddress();
|
||||
$key = 'proofread-' . hash('sha256', $ip);
|
||||
|
||||
if (! $throttler->check($key, 10, MINUTE)) {
|
||||
return $this->respond([
|
||||
'ok' => false,
|
||||
'error' => 'Too many requests. Try again in a minute.',
|
||||
@@ -20,29 +25,51 @@ 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) {
|
||||
$payload = sanitize_request_value($this->request->getPost(['text']));
|
||||
|
||||
$validation = service('validation');
|
||||
$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);
|
||||
}
|
||||
|
||||
$client = \Config\Services::curlrequest(['timeout' => 10]);
|
||||
$text = (string) $payload['text'];
|
||||
|
||||
$client = \Config\Services::curlrequest([
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
try {
|
||||
$resp = $client->post('https://api.languagetool.org/v2/check', [
|
||||
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
],
|
||||
'form_params' => [
|
||||
'text' => $text,
|
||||
'language' => 'en-US',
|
||||
],
|
||||
]);
|
||||
|
||||
$result = json_decode($resp->getBody(), true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return $this->respond([
|
||||
'ok' => false,
|
||||
'error' => 'Invalid response from proofread service.',
|
||||
'csrfHash' => csrf_hash(),
|
||||
], 502);
|
||||
}
|
||||
|
||||
return $this->respond([
|
||||
'ok' => true,
|
||||
'result' => json_decode($resp->getBody(), true),
|
||||
'result' => $result,
|
||||
'csrfHash' => csrf_hash(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -53,4 +80,4 @@ class ProofreadController extends ResourceController
|
||||
], 502);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,664 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\CertificateRecordModel;
|
||||
|
||||
class CertificateController extends BaseController
|
||||
{
|
||||
protected $classSectionModel;
|
||||
protected $configModel;
|
||||
protected $certRecordModel;
|
||||
protected $schoolYear;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->certRecordModel = new CertificateRecordModel();
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
}
|
||||
|
||||
// ─── Admin UI ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function index()
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$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_values(array_unique(array_map('intval', array_column($allEnrolled, 'student_id'))));
|
||||
|
||||
// ── Saved generated YEAR decisions from student_decisions ──────────────
|
||||
//
|
||||
// Source of truth:
|
||||
// student_decisions.year_score
|
||||
// student_decisions.decision
|
||||
//
|
||||
// Do NOT recalculate certificate decisions here from semester_scores.
|
||||
$decisionsByStudent = [];
|
||||
|
||||
if (!empty($allIds)) {
|
||||
$decisionRows = $db->table('student_decisions')
|
||||
->whereIn('student_id', $allIds)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($decisionRows as $d) {
|
||||
$sid = (int)($d['student_id'] ?? 0);
|
||||
|
||||
if ($sid <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decision = trim((string)($d['decision'] ?? ''));
|
||||
$source = trim((string)($d['source'] ?? ''));
|
||||
|
||||
if ($source === '') {
|
||||
$source = $decision === '' ? 'pending' : 'manual';
|
||||
}
|
||||
|
||||
$decisionsByStudent[$sid]['Year'] = [
|
||||
'decision' => $decision,
|
||||
'source' => $source,
|
||||
'notes' => (string)($d['notes'] ?? ''),
|
||||
'year_score' => $d['year_score'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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-class buckets and stats ──────────────────────────────────
|
||||
$studentsByClass = [];
|
||||
$statsPerClass = [];
|
||||
|
||||
foreach ($allEnrolled as $row) {
|
||||
$sid = (int)$row['student_id'];
|
||||
$csid = (int)$row['class_section_id'];
|
||||
|
||||
if (!isset($statsPerClass[$csid])) {
|
||||
$statsPerClass[$csid] = [
|
||||
'name' => $row['class_section_name'],
|
||||
'total' => 0,
|
||||
'pass' => 0,
|
||||
'cert' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$statsPerClass[$csid]['total']++;
|
||||
|
||||
// If no generated decision exists yet, explicitly mark pending.
|
||||
if (!isset($decisionsByStudent[$sid])) {
|
||||
$decisionsByStudent[$sid]['Decision'] = [
|
||||
'decision' => '',
|
||||
'source' => 'pending',
|
||||
'notes' => '',
|
||||
'year_score' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// Certificate eligibility:
|
||||
// A student is eligible only if the saved final generated decision is Pass.
|
||||
$studentDecisions = $decisionsByStudent[$sid] ?? [];
|
||||
$isPass = !empty($studentDecisions);
|
||||
|
||||
foreach ($studentDecisions as $decisionRow) {
|
||||
$decision = trim((string)($decisionRow['decision'] ?? ''));
|
||||
|
||||
if (strcasecmp($decision, 'Pass') !== 0) {
|
||||
$isPass = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isPass) {
|
||||
$statsPerClass[$csid]['pass']++;
|
||||
}
|
||||
|
||||
if (isset($certsByStudent[$sid])) {
|
||||
$statsPerClass[$csid]['cert']++;
|
||||
}
|
||||
|
||||
$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')
|
||||
->groupStart()
|
||||
->where('cr.verification_token', $certNumber)
|
||||
->orWhere('cr.certificate_number', strtoupper($certNumber))
|
||||
->groupEnd()
|
||||
->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'] ?? ''),
|
||||
'verify_token' => $this->ensureVerificationTokenForRecord($record),
|
||||
];
|
||||
|
||||
// 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('sc.school_year', $schoolYear)
|
||||
->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.');
|
||||
}
|
||||
|
||||
$issuedAt = date('Y-m-d H:i:s');
|
||||
|
||||
// Load existing certificates for these students this school year.
|
||||
$existingCerts = $db->table('certificate_records')
|
||||
->select('id, student_id, certificate_number, verification_token')
|
||||
->whereIn('student_id', array_column($students, 'id'))
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$existingCertMap = [];
|
||||
|
||||
foreach ($existingCerts as $ec) {
|
||||
$existingCertMap[(int)$ec['student_id']] = $ec;
|
||||
}
|
||||
|
||||
foreach ($students as &$student) {
|
||||
$sid = (int)$student['id'];
|
||||
|
||||
if (isset($existingCertMap[$sid])) {
|
||||
// Reuse existing certificate number — do not create a new record.
|
||||
$existing = $existingCertMap[$sid];
|
||||
|
||||
$student['cert_number'] = (string)($existing['certificate_number'] ?? '');
|
||||
$student['verify_token'] = $this->ensureVerificationTokenForRecord($existing);
|
||||
} else {
|
||||
$certNumber = $this->certRecordModel->nextNumber($schoolYear);
|
||||
$verifyToken = $this->certRecordModel->generateVerificationToken();
|
||||
|
||||
$this->certRecordModel->insert([
|
||||
'certificate_number' => $certNumber,
|
||||
'verification_token' => $verifyToken,
|
||||
'student_id' => $sid,
|
||||
'student_name' => $student['firstname'] . ' ' . $student['lastname'],
|
||||
'grade' => $this->formatGrade($student['grade'] ?? ''),
|
||||
'school_year' => $schoolYear,
|
||||
'class_section_id' => $classSectionId ?: null,
|
||||
'issued_at' => $issuedAt,
|
||||
]);
|
||||
|
||||
$student['cert_number'] = $certNumber;
|
||||
$student['verify_token'] = $verifyToken;
|
||||
}
|
||||
}
|
||||
|
||||
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: "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;
|
||||
}
|
||||
|
||||
private function ensureVerificationTokenForRecord(array $record): string
|
||||
{
|
||||
$token = trim((string)($record['verification_token'] ?? ''));
|
||||
|
||||
if ($token !== '') {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$recordId = (int)($record['id'] ?? 0);
|
||||
|
||||
if ($recordId <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$token = $this->certRecordModel->generateVerificationToken();
|
||||
|
||||
$this->certRecordModel->update($recordId, [
|
||||
'verification_token' => $token,
|
||||
]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
// ─── 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 = trim((string)($student['firstname'] ?? '') . ' ' . (string)($student['lastname'] ?? ''));
|
||||
$grade = $this->formatGrade($student['grade'] ?? '');
|
||||
$certNumber = $student['cert_number'] ?? '';
|
||||
$verifyToken = $student['verify_token'] ?? '';
|
||||
|
||||
$verifyUrl = $verifyToken !== ''
|
||||
? site_url('verify/' . rawurlencode($verifyToken))
|
||||
: null;
|
||||
|
||||
$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;
|
||||
|
||||
if (!empty($verifyUrl)) {
|
||||
$qrX = 120;
|
||||
$qrY = 171 + (24 - $qrSize) / 2;
|
||||
|
||||
$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, 221.5);
|
||||
|
||||
// ── Line under name
|
||||
$pdf->SetFont('times', '', 20);
|
||||
$pdf->SetXY(0, 236);
|
||||
$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
|
||||
$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);
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,10 @@ class ContactController extends BaseController
|
||||
|
||||
// Define validation rules
|
||||
$validation->setRules([
|
||||
'name' => 'required|min_length[3]',
|
||||
'email' => 'required|valid_email',
|
||||
'subject' => 'required|min_length[3]',
|
||||
'message' => 'required|min_length[10]'
|
||||
'name' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[3]|max_length[100]",
|
||||
'email' => 'required|valid_email|max_length[254]',
|
||||
'subject' => 'required|min_length[3]|max_length[150]',
|
||||
'message' => 'required|min_length[10]|max_length[5000]'
|
||||
]);
|
||||
|
||||
if (!$validation->withRequest($this->request)->run()) {
|
||||
@@ -37,10 +37,10 @@ class ContactController extends BaseController
|
||||
}
|
||||
|
||||
// Process form data
|
||||
$name = $this->request->getPost('name');
|
||||
$email = strtolower($this->request->getPost('email'));
|
||||
$subject = $this->request->getPost('subject');
|
||||
$message = $this->request->getPost('message');
|
||||
$name = esc((string) $this->request->getPost('name'));
|
||||
$email = esc(strtolower((string) $this->request->getPost('email')));
|
||||
$subject = esc((string) $this->request->getPost('subject'));
|
||||
$message = nl2br(esc((string) $this->request->getPost('message')));
|
||||
|
||||
// Initialize the EmailController
|
||||
$emailController = new \App\Controllers\View\EmailController();
|
||||
@@ -63,4 +63,4 @@ class ContactController extends BaseController
|
||||
|
||||
return redirect()->to('/parent/contact');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@ use App\Models\PaymentModel;
|
||||
use App\Models\CalendarModel;
|
||||
use App\Services\EmailService;
|
||||
use Config\Database;
|
||||
#use ???
|
||||
use App\Controllers\View\InvoiceController;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use CodeIgniter\RESTful\ResourceController;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
|
||||
class EventController extends ResourceController
|
||||
{
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
@@ -858,6 +845,7 @@ class ReportCardsController extends PrintablesBaseController
|
||||
$studentName = trim(($data['student']['firstname'] ?? '') . ' ' . ($data['student']['lastname'] ?? ''));
|
||||
$gradeLabel = (string)($data['grade'] ?? ($data['class_section_name'] ?? 'N/A'));
|
||||
$today = (string)($data['report_date_display'] ?? date('m-d-Y'));
|
||||
$termRank = trim((string)($data['term_rank_display'] ?? ''));
|
||||
$firstSemScore = $data['first_semester_score'] ?? null;
|
||||
$secondSemScore = $data['second_semester_score'] ?? ($data['total_score'] ?? null);
|
||||
$finalAverage = $data['final_average'] ?? null;
|
||||
@@ -993,54 +981,91 @@ class ReportCardsController extends PrintablesBaseController
|
||||
$gradeY = $pdf->GetY();
|
||||
$gradePad = max(0, 2 - (4 * 0.3528)); // reduce indentation by 4pt
|
||||
|
||||
$drawGradeCell = static function (\FPDF $pdf, float $x, float $y, float $w, float $h, string $label, string $value, float $pad) {
|
||||
$pdf->Rect($x, $y, $w, $h);
|
||||
$pdf->SetXY($x + $pad, $y + 3);
|
||||
$pdf->SetFont('Helvetica', 'B', 11);
|
||||
$pdf->Write(5, $label . ' ');
|
||||
$labelWidth = $pdf->GetStringWidth($label . ' ');
|
||||
$pdf->SetFont('Helvetica', '', 12);
|
||||
$pdf->SetXY($x + 2 + $labelWidth, $y + 3);
|
||||
$pdf->Write(5, $value);
|
||||
};
|
||||
$drawGradeCell = static function (
|
||||
\FPDF $pdf,
|
||||
float $x,
|
||||
float $y,
|
||||
float $w,
|
||||
float $h,
|
||||
string $label,
|
||||
string $value,
|
||||
float $pad
|
||||
) {
|
||||
$pdf->Rect($x, $y, $w, $h);
|
||||
$pdf->SetXY($x + $pad, $y + 3);
|
||||
$pdf->SetFont('Helvetica', 'B', 11);
|
||||
$pdf->Write(5, $label . ' ');
|
||||
$labelWidth = $pdf->GetStringWidth($label . ' ');
|
||||
$pdf->SetFont('Helvetica', '', 12);
|
||||
$pdf->SetXY($x + 2 + $labelWidth, $y + 3);
|
||||
$pdf->Write(5, $value);
|
||||
};
|
||||
$drawRankCell = static function (
|
||||
\FPDF $pdf,
|
||||
float $x,
|
||||
float $y,
|
||||
float $w,
|
||||
float $h,
|
||||
string $rankValue,
|
||||
float $pad
|
||||
) {
|
||||
$rankValue = trim($rankValue) !== '' ? trim($rankValue) : 'N/A';
|
||||
|
||||
if ($semNum === 2) {
|
||||
$firstLabel = ' 1st Semester Grade:';
|
||||
$firstValue = $numFmt($firstSemScore) . '/100';
|
||||
$secondLabel = ' 2nd Semester Grade:';
|
||||
$secondValue = $numFmt($effectiveSecond) . '/100';
|
||||
$drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $firstLabel, $firstValue, $gradePad);
|
||||
$drawGradeCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $secondLabel, $secondValue, $gradePad);
|
||||
} else {
|
||||
$gradeLabel = ' 1st Semester Grade:';
|
||||
$gradeValue = $numFmt($effectiveSecond) . '/100';
|
||||
$drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $gradeLabel, $gradeValue, $gradePad);
|
||||
}
|
||||
$pdf->SetY($gradeY + $gradeCellHeight);
|
||||
$pdf->Rect($x, $y, $w, $h);
|
||||
$pdf->SetXY($x + $pad, $y + 3);
|
||||
$pdf->SetFont('Helvetica', 'B', 11);
|
||||
$pdf->Write(5, ' Class Rank: ');
|
||||
|
||||
$finalScoreRowHeight = 12;
|
||||
$finalScoreEndY = null;
|
||||
// Show Final Score only for second semester / Spring
|
||||
if ($semNum === 2) {
|
||||
$finalLabel = 'Final Score****:';
|
||||
$finalValue = (is_string($finalScoreVal) ? $finalScoreVal : $numFmt($finalScoreVal)) . '/100';
|
||||
$finalCellWidth = 65; // match Total Semester Days column width
|
||||
$finalCellHeight = $finalScoreRowHeight;
|
||||
$finalX = $pdf->GetX();
|
||||
$finalY = $pdf->GetY();
|
||||
$pdf->Rect($finalX, $finalY, $finalCellWidth, $finalCellHeight);
|
||||
$finalPad = max(0, 2 - (4 * 0.3528)); // reduce indentation by 4pt
|
||||
$pdf->SetXY($finalX + $finalPad, $finalY + 3);
|
||||
$pdf->SetFont('Helvetica', 'B', 11);
|
||||
$pdf->Write(5, ' ' . $finalLabel . ' ');
|
||||
$finalLabelWidth = $pdf->GetStringWidth(' ' . $finalLabel . ' ');
|
||||
$pdf->SetFont('Helvetica', '', 12);
|
||||
$pdf->SetXY($finalX + 2 + $finalLabelWidth, $finalY + 3);
|
||||
$pdf->Write(5, $finalValue);
|
||||
$pdf->Ln($finalCellHeight);
|
||||
$finalScoreEndY = $finalY + $finalCellHeight;
|
||||
}
|
||||
$scoresEndY = $pdf->GetY();
|
||||
$labelWidth = $pdf->GetStringWidth(' Class Rank: ');
|
||||
$pdf->SetFont('Helvetica', '', 12);
|
||||
$pdf->SetXY($x + 2 + $labelWidth, $y + 3);
|
||||
$pdf->Write(5, $rankValue);
|
||||
};
|
||||
if ($semNum === 2) {
|
||||
$firstLabel = ' 1st Semester Grade:';
|
||||
$firstValue = $numFmt($firstSemScore) . '/100';
|
||||
|
||||
$secondLabel = ' 2nd Semester Grade:';
|
||||
$secondValue = $numFmt($effectiveSecond) . '/100';
|
||||
|
||||
$drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $firstLabel, $firstValue, $gradePad);
|
||||
$drawGradeCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $secondLabel, $secondValue, $gradePad);
|
||||
} else {
|
||||
$gradeLabel = ' 1st Semester Grade:';
|
||||
$gradeValue = $numFmt($effectiveSecond) . '/100';
|
||||
|
||||
$drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $gradeLabel, $gradeValue, $gradePad);
|
||||
|
||||
// Fall: Ranking goes to the right of 1st Semester Grade.
|
||||
$drawRankCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $termRank, $gradePad);
|
||||
}
|
||||
|
||||
$pdf->SetY($gradeY + $gradeCellHeight);
|
||||
|
||||
$finalScoreRowHeight = 12;
|
||||
$finalScoreEndY = null;
|
||||
|
||||
// Show Final Score only for second semester / Spring.
|
||||
if ($semNum === 2) {
|
||||
$finalLabel = 'Final Score****:';
|
||||
$finalValue = (is_string($finalScoreVal) ? $finalScoreVal : $numFmt($finalScoreVal)) . '/100';
|
||||
|
||||
$finalCellWidth = 65;
|
||||
$finalCellHeight = $finalScoreRowHeight;
|
||||
$finalX = $pdf->GetX();
|
||||
$finalY = $pdf->GetY();
|
||||
$finalPad = max(0, 2 - (4 * 0.3528));
|
||||
|
||||
$drawGradeCell($pdf, $finalX, $finalY, $finalCellWidth, $finalCellHeight, ' ' . $finalLabel, $finalValue, $finalPad);
|
||||
|
||||
// Spring: Ranking goes to the right of Final Score.
|
||||
$drawRankCell($pdf, $finalX + $finalCellWidth, $finalY, $finalCellWidth, $finalCellHeight, $termRank, $finalPad);
|
||||
|
||||
$pdf->SetY($finalY + $finalCellHeight);
|
||||
$finalScoreEndY = $finalY + $finalCellHeight;
|
||||
}
|
||||
|
||||
$scoresEndY = $pdf->GetY();
|
||||
|
||||
// Legend anchored near bottom with a 3mm buffer (dynamic placement)
|
||||
$examLegendLabel = ($semNum === 1) ? 'Midterm Exam' : 'Final Exam';
|
||||
@@ -1559,6 +1584,20 @@ class ReportCardsController extends PrintablesBaseController
|
||||
$firstSemesterScore = $secondSemesterScore;
|
||||
}
|
||||
|
||||
$rankingScore = $secondSemesterScore;
|
||||
if ($normSemester === 'spring' && is_numeric($firstSemesterScore) && is_numeric($secondSemesterScore)) {
|
||||
$rankingScore = round(((float)$firstSemesterScore + (float)$secondSemesterScore) / 2, 1);
|
||||
}
|
||||
|
||||
$termRanking = $this->calculateTermRanking(
|
||||
$studentId,
|
||||
$sectionCode,
|
||||
$sectionId,
|
||||
$refYear,
|
||||
$refSemester !== '' ? $refSemester : (string)($score['semester'] ?? ''),
|
||||
$rankingScore
|
||||
);
|
||||
|
||||
// Total semester days from attendance records (max total_attendance within same term)
|
||||
$totalSemesterDays = null;
|
||||
|
||||
@@ -1566,34 +1605,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) {
|
||||
@@ -1689,12 +1724,228 @@ class ReportCardsController extends PrintablesBaseController
|
||||
'second_semester_score' => $secondSemesterScore,
|
||||
'first_semester_score' => $firstSemesterScore,
|
||||
'final_average' => $finalAverage,
|
||||
'term_rank' => $termRanking,
|
||||
'term_rank_display' => $termRanking['display'] ?? null,
|
||||
'comments' => $commentMap,
|
||||
'total_score' => $secondSemesterScore,
|
||||
'total_attendance_days' => $totalSemesterDays,
|
||||
];
|
||||
}
|
||||
|
||||
private function calculateTermRanking(
|
||||
int $studentId,
|
||||
int $sectionCode,
|
||||
?int $sectionId,
|
||||
string $schoolYear,
|
||||
?string $semester,
|
||||
?float $studentScore
|
||||
): ?array {
|
||||
if ($studentId <= 0 || $schoolYear === '' || $studentScore === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sectionIds = array_values(array_unique(array_filter([
|
||||
$sectionCode > 0 ? $sectionCode : null,
|
||||
$sectionId && $sectionId > 0 ? $sectionId : null,
|
||||
])));
|
||||
|
||||
if (empty($sectionIds)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$semesterForRank = trim((string)$semester);
|
||||
$rankByFinalScore = $this->normalizeSemester($semesterForRank) === 'spring';
|
||||
|
||||
$builder = $this->db->table('semester_scores ss')
|
||||
->select('ss.student_id, ss.semester, ss.semester_score, ss.updated_at, ss.id, s.firstname, s.lastname')
|
||||
->join('students s', 's.id = ss.student_id', 'inner')
|
||||
->where('s.is_active', 1)
|
||||
->where('ss.school_year', $schoolYear)
|
||||
->whereIn('ss.class_section_id', $sectionIds)
|
||||
->orderBy('ss.updated_at', 'DESC')
|
||||
->orderBy('ss.id', 'DESC');
|
||||
|
||||
if ($semesterForRank !== '') {
|
||||
$this->applySemesterFilter($builder, $semesterForRank, 'ss.semester');
|
||||
}
|
||||
|
||||
$rows = $builder->get()->getResultArray();
|
||||
|
||||
if (empty($rows)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scoresByStudent = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$sid = (int)($row['student_id'] ?? 0);
|
||||
|
||||
if ($sid <= 0 || isset($scoresByStudent[$sid])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scoreVal = $row['semester_score'] ?? null;
|
||||
|
||||
if (!is_numeric($scoreVal)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rawScore = (float)$scoreVal;
|
||||
|
||||
$scoresByStudent[$sid] = [
|
||||
'student_id' => $sid,
|
||||
'score' => $rawScore,
|
||||
'rank_score' => round($rawScore, 1),
|
||||
'firstname' => trim((string)($row['firstname'] ?? '')),
|
||||
'lastname' => trim((string)($row['lastname'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($scoresByStudent) || !isset($scoresByStudent[$studentId])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For Spring, rank by final year average:
|
||||
// (first semester score + second semester score) / 2.
|
||||
if ($rankByFinalScore) {
|
||||
$studentIds = array_keys($scoresByStudent);
|
||||
|
||||
$firstRowsBuilder = $this->db->table('semester_scores ss')
|
||||
->select('ss.student_id, ss.semester, ss.semester_score, ss.updated_at, ss.id')
|
||||
->where('ss.school_year', $schoolYear)
|
||||
->whereIn('ss.student_id', $studentIds)
|
||||
->whereIn('ss.class_section_id', $sectionIds)
|
||||
->orderBy('ss.updated_at', 'DESC')
|
||||
->orderBy('ss.id', 'DESC');
|
||||
|
||||
if ($semesterForRank !== '') {
|
||||
$this->applySemesterExclusion($firstRowsBuilder, $semesterForRank, 'ss.semester');
|
||||
}
|
||||
|
||||
$firstRows = $firstRowsBuilder->get()->getResultArray();
|
||||
|
||||
$firstScoresByStudent = [];
|
||||
|
||||
foreach ($firstRows as $row) {
|
||||
$sid = (int)($row['student_id'] ?? 0);
|
||||
|
||||
if ($sid <= 0 || isset($firstScoresByStudent[$sid])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scoreVal = $row['semester_score'] ?? null;
|
||||
|
||||
if (!is_numeric($scoreVal)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$firstScoresByStudent[$sid] = (float)$scoreVal;
|
||||
}
|
||||
|
||||
foreach ($scoresByStudent as $sid => &$rankRow) {
|
||||
if (!isset($firstScoresByStudent[$sid])) {
|
||||
unset($scoresByStudent[$sid]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$avg = ((float)$firstScoresByStudent[$sid] + (float)$rankRow['score']) / 2;
|
||||
|
||||
$rankRow['score'] = $avg;
|
||||
$rankRow['rank_score'] = round($avg, 1);
|
||||
}
|
||||
|
||||
unset($rankRow);
|
||||
|
||||
if (empty($scoresByStudent) || !isset($scoresByStudent[$studentId])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Force the selected student to use the exact score already computed for the report.
|
||||
// Then rank using the displayed precision: one decimal.
|
||||
$scoresByStudent[$studentId]['score'] = (float)$studentScore;
|
||||
$scoresByStudent[$studentId]['rank_score'] = round((float)$studentScore, 1);
|
||||
} else {
|
||||
// Fall: also force selected student to match the report-card computed score.
|
||||
$scoresByStudent[$studentId]['score'] = (float)$studentScore;
|
||||
$scoresByStudent[$studentId]['rank_score'] = round((float)$studentScore, 1);
|
||||
}
|
||||
|
||||
$rankable = array_values($scoresByStudent);
|
||||
|
||||
usort($rankable, static function (array $a, array $b): int {
|
||||
// Highest displayed/rank score first.
|
||||
$scoreCmp = $b['rank_score'] <=> $a['rank_score'];
|
||||
|
||||
if ($scoreCmp !== 0) {
|
||||
return $scoreCmp;
|
||||
}
|
||||
|
||||
// Tie-breakers only control display order.
|
||||
// They do NOT change rank.
|
||||
$lastCmp = strcasecmp($a['lastname'], $b['lastname']);
|
||||
|
||||
if ($lastCmp !== 0) {
|
||||
return $lastCmp;
|
||||
}
|
||||
|
||||
$firstCmp = strcasecmp($a['firstname'], $b['firstname']);
|
||||
|
||||
if ($firstCmp !== 0) {
|
||||
return $firstCmp;
|
||||
}
|
||||
|
||||
return $a['student_id'] <=> $b['student_id'];
|
||||
});
|
||||
|
||||
$position = null;
|
||||
$previousScore = null;
|
||||
|
||||
foreach ($rankable as $index => $row) {
|
||||
$currentScore = (float)$row['rank_score'];
|
||||
|
||||
// Competition ranking:
|
||||
// 1, 1, 3, 4...
|
||||
// Same score = same rank.
|
||||
if ($previousScore === null || abs($currentScore - $previousScore) > 0.0001) {
|
||||
$position = $index + 1;
|
||||
$previousScore = $currentScore;
|
||||
}
|
||||
|
||||
if ((int)$row['student_id'] === $studentId) {
|
||||
$total = count($rankable);
|
||||
|
||||
return [
|
||||
'position' => $position,
|
||||
'total' => $total,
|
||||
'score' => $currentScore,
|
||||
'display' => $this->formatOrdinal($position) . ' out of ' . $total,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function formatOrdinal(?int $value): string
|
||||
{
|
||||
$n = (int)$value;
|
||||
if ($n <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$mod100 = $n % 100;
|
||||
if ($mod100 >= 11 && $mod100 <= 13) {
|
||||
return $n . 'th';
|
||||
}
|
||||
|
||||
return match ($n % 10) {
|
||||
1 => $n . 'st',
|
||||
2 => $n . 'nd',
|
||||
3 => $n . 'rd',
|
||||
default => $n . 'th',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve teacher & TA names for a class section (by PK or code),
|
||||
* relaxing semester/year if needed. Returns ['teacher_name' => string, 'ta_names' => string[]].
|
||||
|
||||
@@ -231,18 +231,36 @@ class ScoreCommentController extends BaseController
|
||||
$rawCommentsBuilder->groupEnd();
|
||||
}
|
||||
|
||||
if (!$isAllSections && $classSectionId > 0) {
|
||||
// Prefer the exact class-section row over legacy NULL class-section rows.
|
||||
// Without this, duplicate rows can overwrite each other unpredictably.
|
||||
$rawCommentsBuilder
|
||||
->orderBy("CASE WHEN class_section_id = {$classSectionId} THEN 0 ELSE 1 END", '', false)
|
||||
->orderBy('id', 'DESC');
|
||||
} else {
|
||||
$rawCommentsBuilder->orderBy('id', 'DESC');
|
||||
}
|
||||
|
||||
$rawComments = $rawCommentsBuilder->findAll();
|
||||
|
||||
$existingAttendance = [];
|
||||
foreach ($rawComments as $c) {
|
||||
$sid = (int)$c['student_id'];
|
||||
$typ = (string)$c['score_type'];
|
||||
$sid = (int) $c['student_id'];
|
||||
$typ = strtolower(trim((string) $c['score_type']));
|
||||
|
||||
// The query is ordered with the best row first. Do not let legacy/older
|
||||
// duplicate rows overwrite the selected comment/review.
|
||||
if (isset($comments[$sid][$typ])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$comments[$sid][$typ] = [
|
||||
'comment' => $c['comment'] ?? null,
|
||||
'comment_review' => $c['comment_review'] ?? null,
|
||||
'commented_by' => $c['commented_by'] ?? null,
|
||||
'reviewed_by' => $c['reviewed_by'] ?? null,
|
||||
];
|
||||
|
||||
if ($typ === 'attendance') {
|
||||
$existingAttendance[$sid] = $c;
|
||||
}
|
||||
@@ -435,6 +453,16 @@ class ScoreCommentController extends BaseController
|
||||
$existingQuery->groupEnd();
|
||||
}
|
||||
|
||||
if (is_int($classSectionId) && $classSectionId > 0) {
|
||||
// Prefer updating the real class-section row. If only a legacy NULL row
|
||||
// exists, it will be picked next and migrated to the class section below.
|
||||
$existingQuery
|
||||
->orderBy("CASE WHEN class_section_id = {$classSectionId} THEN 0 ELSE 1 END", '', false)
|
||||
->orderBy('id', 'DESC');
|
||||
} else {
|
||||
$existingQuery->orderBy('id', 'DESC');
|
||||
}
|
||||
|
||||
$existing = $existingQuery->first();
|
||||
|
||||
$data = [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddVerificationTokenToCertificateRecords extends Migration
|
||||
{
|
||||
private const INDEX_NAME = 'uniq_certificate_verification_token';
|
||||
|
||||
public function up()
|
||||
{
|
||||
if (!$this->db->tableExists('certificate_records')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->db->fieldExists('verification_token', 'certificate_records')) {
|
||||
$this->forge->addColumn('certificate_records', [
|
||||
'verification_token' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 64,
|
||||
'null' => true,
|
||||
'after' => 'certificate_number',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = $this->db->table('certificate_records')
|
||||
->select('id, verification_token')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (trim((string)($row['verification_token'] ?? '')) !== '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->db->table('certificate_records')
|
||||
->where('id', (int)$row['id'])
|
||||
->update(['verification_token' => $this->generateToken()]);
|
||||
}
|
||||
|
||||
$this->forge->addUniqueKey('verification_token', self::INDEX_NAME);
|
||||
$this->forge->processIndexes('certificate_records');
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if (!$this->db->tableExists('certificate_records')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->db->fieldExists('verification_token', 'certificate_records')) {
|
||||
$this->forge->dropKey('certificate_records', self::INDEX_NAME);
|
||||
$this->forge->dropColumn('certificate_records', 'verification_token');
|
||||
}
|
||||
}
|
||||
|
||||
private function generateToken(): string
|
||||
{
|
||||
do {
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$exists = $this->db->table('certificate_records')
|
||||
->select('id')
|
||||
->where('verification_token', $token)
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
} while ($exists);
|
||||
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ class ApiAuthFilter implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
helper('security');
|
||||
|
||||
$authorization = $request->getHeaderLine('Authorization');
|
||||
if (!$authorization || stripos($authorization, 'Bearer ') !== 0) {
|
||||
return $this->unauthorized('Missing or invalid Authorization header');
|
||||
@@ -21,7 +23,12 @@ class ApiAuthFilter implements FilterInterface
|
||||
return $this->unauthorized('Bearer token is required');
|
||||
}
|
||||
|
||||
$secret = env('JWT_SECRET', 'change-me-in-env');
|
||||
try {
|
||||
$secret = require_env('JWT_SECRET');
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->unauthorized('JWT configuration is missing');
|
||||
}
|
||||
|
||||
$payload = jwt_decode($token, $secret);
|
||||
|
||||
if (!$payload || empty($payload['sub'])) {
|
||||
|
||||
@@ -13,13 +13,15 @@ class ApiDocsAuthFilter implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
helper('security');
|
||||
|
||||
$authHeader = $request->getHeaderLine('Authorization');
|
||||
|
||||
if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
|
||||
$token = trim(substr($authHeader, 7));
|
||||
|
||||
try {
|
||||
$key = getenv('JWT_SECRET') ?: 'your_default_secret';
|
||||
$key = require_env('JWT_SECRET');
|
||||
$decoded = JWT::decode($token, new Key($key, 'HS256'));
|
||||
|
||||
if (!isset($decoded->roles) || empty($decoded->roles->admin)) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Services;
|
||||
|
||||
class ApiRateLimitFilter implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
$throttler = service('throttler');
|
||||
$response = Services::response();
|
||||
|
||||
$maxRequests = isset($arguments[0]) ? max(1, (int) $arguments[0]) : 60;
|
||||
$windowSeconds = isset($arguments[1]) ? max(1, (int) $arguments[1]) : MINUTE;
|
||||
|
||||
$userId = session()->get('user_id');
|
||||
$identifier = $userId ? 'user:' . $userId : 'ip:' . $request->getIPAddress();
|
||||
$route = trim($request->getUri()->getPath(), '/');
|
||||
$key = 'api-rate-' . sha1($request->getMethod() . '|' . $route . '|' . $identifier);
|
||||
|
||||
if (! $throttler->check($key, $maxRequests, $windowSeconds)) {
|
||||
return $response
|
||||
->setStatusCode(429)
|
||||
->setHeader('Retry-After', (string) $windowSeconds)
|
||||
->setJSON([
|
||||
'status' => false,
|
||||
'message' => 'Too many requests. Please try again later.',
|
||||
]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Services;
|
||||
|
||||
class SanitizeInputFilter implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
helper('security');
|
||||
|
||||
$sanitizedGet = sanitize_request_value($request->getGet() ?? []);
|
||||
$sanitizedPost = sanitize_request_value($request->getPost() ?? []);
|
||||
$request->setGlobal('get', $sanitizedGet);
|
||||
$request->setGlobal('post', $sanitizedPost);
|
||||
$request->setGlobal('request', array_merge(is_array($sanitizedGet) ? $sanitizedGet : [], is_array($sanitizedPost) ? $sanitizedPost : []));
|
||||
$request->setGlobal('cookie', sanitize_request_value($request->getCookie() ?? []));
|
||||
|
||||
$contentType = strtolower($request->getHeaderLine('Content-Type'));
|
||||
$body = $request->getBody();
|
||||
|
||||
if (str_contains($contentType, 'application/json') && trim($body) !== '') {
|
||||
$decoded = json_decode($body, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($decoded)) {
|
||||
return Services::response()
|
||||
->setStatusCode(400)
|
||||
->setJSON([
|
||||
'status' => false,
|
||||
'message' => 'Malformed JSON payload.',
|
||||
]);
|
||||
}
|
||||
|
||||
$request->setBody((string) json_encode(sanitize_request_value($decoded), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
if (! function_exists('sanitize_request_value')) {
|
||||
/**
|
||||
* Recursively normalize request values without applying output encoding.
|
||||
*/
|
||||
function sanitize_request_value($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $key => $item) {
|
||||
$value[$key] = sanitize_request_value($item);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? $value;
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('require_env')) {
|
||||
/**
|
||||
* Fetch an environment variable and fail closed when it is missing.
|
||||
*/
|
||||
function require_env(string $key): string
|
||||
{
|
||||
$value = env($key);
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
throw new RuntimeException(sprintf('Missing required environment variable: %s', $key));
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,9 @@ class StaffTimeOffLinkService
|
||||
public function __construct(?string $secret = null, ?int $ttlSeconds = null)
|
||||
{
|
||||
helper('jwt');
|
||||
helper('security');
|
||||
|
||||
$this->secret = $secret ?: (string)env('JWT_SECRET', 'change-me-in-env');
|
||||
$this->secret = $secret ?: require_env('JWT_SECRET');
|
||||
$this->ttl = ($ttlSeconds !== null && $ttlSeconds > 0) ? $ttlSeconds : self::DEFAULT_TTL;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<?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,
|
||||
'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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?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',
|
||||
'verification_token',
|
||||
'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;
|
||||
}
|
||||
|
||||
public function generateVerificationToken(): string
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
do {
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$exists = $db->table($this->table)
|
||||
->select('id')
|
||||
->where('verification_token', $token)
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
} while ($exists);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/** Returns paginated records with the issuing admin's name joined. */
|
||||
public function getAuditLog(?string $schoolYear = null, int $perPage = 50): array
|
||||
{
|
||||
$builder = $this->db->table($this->table . ' cr')
|
||||
->select('cr.*, u.firstname AS admin_firstname, u.lastname AS admin_lastname')
|
||||
->join('users u', 'u.id = cr.issued_by', 'left')
|
||||
->orderBy('cr.issued_at', 'DESC');
|
||||
|
||||
if ($schoolYear) {
|
||||
$builder->where('cr.school_year', $schoolYear);
|
||||
}
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
/** Total certificates issued per school year, ordered by year DESC. */
|
||||
public function yearSummary(): array
|
||||
{
|
||||
return $this->db->table($this->table)
|
||||
->select('school_year, COUNT(*) AS total')
|
||||
->groupBy('school_year')
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()->getResultArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class StudentDecisionModel extends Model
|
||||
{
|
||||
protected $table = 'student_decisions';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
protected $allowedFields = [
|
||||
'student_id',
|
||||
'school_year',
|
||||
'class_section_name',
|
||||
'year_score',
|
||||
'decision',
|
||||
'source',
|
||||
'notes',
|
||||
'generated_by',
|
||||
];
|
||||
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
}
|
||||
@@ -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
@@ -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() ?>
|
||||
@@ -0,0 +1,643 @@
|
||||
<?= $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 class="text-center">Year Score</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) => trim((string)($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'));
|
||||
|
||||
$yearScore = null;
|
||||
|
||||
foreach ($stuDec as $d) {
|
||||
if (
|
||||
array_key_exists('year_score', $d)
|
||||
&& $d['year_score'] !== null
|
||||
&& $d['year_score'] !== ''
|
||||
) {
|
||||
$yearScore = $d['year_score'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$fmtYearScore = is_numeric($yearScore)
|
||||
? number_format((float)$yearScore, 2)
|
||||
: '—';
|
||||
?>
|
||||
|
||||
<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 class="text-center fw-semibold">
|
||||
<?= esc($fmtYearScore) ?>
|
||||
</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): ?>
|
||||
<?php $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 & 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;
|
||||
let 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');
|
||||
const 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->endSection() ?>
|
||||
|
||||
<?= $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, 4, 5] }
|
||||
]
|
||||
});
|
||||
} catch (_) {}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -13,6 +13,9 @@ $schoolYear = $schoolYear ?? '';
|
||||
$semester = $semester ?? '';
|
||||
$maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
|
||||
$allowedExtensions = $allowedExtensions ?? ['doc', 'docx', 'pdf'];
|
||||
$reviewActionUrl = $reviewActionUrl ?? base_url('administrator/exam-drafts/review');
|
||||
$legacyUploadUrl = $legacyUploadUrl ?? base_url('administrator/exam-drafts/upload-legacy');
|
||||
$reviewPortalLabel = $reviewPortalLabel ?? 'Administrator';
|
||||
|
||||
$renderBadge = static function (string $status, array $badges): string {
|
||||
$b = $badges[$status] ?? ['label' => $status, 'class' => 'bg-secondary text-white'];
|
||||
@@ -27,7 +30,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
|
||||
<div>
|
||||
<h1 class="h3 mb-1">Exam draft submissions</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
<?= esc($semester ?: 'Semester') ?> <?= esc($schoolYear ?: '') ?>
|
||||
<?= esc($reviewPortalLabel) ?> review portal · <?= esc($semester ?: 'Semester') ?> <?= esc($schoolYear ?: '') ?>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-muted small d-flex flex-column align-items-lg-end">
|
||||
@@ -205,7 +208,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
|
||||
</div>
|
||||
</td>
|
||||
<td style="min-width: 280px;">
|
||||
<?= form_open_multipart(base_url('administrator/exam-drafts/review'), ['class' => 'vstack gap-2', 'id' => $formId]) ?>
|
||||
<?= form_open_multipart($reviewActionUrl, ['class' => 'vstack gap-2', 'id' => $formId]) ?>
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="draft_id" value="<?= (int) ($draft['id'] ?? 0) ?>">
|
||||
<?php $selectedStatus = ''; ?>
|
||||
@@ -276,7 +279,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
|
||||
<span class="badge bg-primary-subtle text-primary">Admin only</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?= form_open_multipart(base_url('administrator/exam-drafts/upload-legacy'), ['class' => 'row g-3']) ?>
|
||||
<?= form_open_multipart($legacyUploadUrl, ['class' => 'row g-3']) ?>
|
||||
<?= csrf_field() ?>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Class sections</label>
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<?php
|
||||
$classResults = $classResults ?? [];
|
||||
$selectedYear = $selectedYear ?? '';
|
||||
$selectedPercentile = $selectedPercentile ?? 75;
|
||||
$years = $years ?? [];
|
||||
|
||||
$totalStudents = array_sum(array_column($classResults, 'student_count'));
|
||||
$totalScored = array_sum(array_column($classResults, 'scored_count'));
|
||||
$totalTrophies = array_sum(array_column($classResults, 'trophy_count'));
|
||||
$totalBoys = array_sum(array_column($classResults, 'boys'));
|
||||
$totalGirls = array_sum(array_column($classResults, 'girls'));
|
||||
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
|
||||
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
|
||||
|
||||
$pctBoys = $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0;
|
||||
$pctGirls = $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0;
|
||||
$pctTrophyBoys = $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0;
|
||||
$pctTrophyGirls = $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0;
|
||||
$pctTrophyAll = $totalStudents > 0 ? round($totalTrophies / $totalStudents * 100) : 0;
|
||||
?>
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2">
|
||||
<div>
|
||||
<h2 class="mb-1"><i class="bi bi-trophy-fill text-warning me-2"></i>Trophy Projections</h2>
|
||||
<p class="text-muted mb-0">
|
||||
Fall scores are used to calculate a per-class threshold at the
|
||||
<strong><?= (int) $selectedPercentile ?>th percentile</strong> and project which students are on track
|
||||
for a year-end trophy. Minimum 3 trophies per class. Names are hidden by default.
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?= site_url('administrator/trophy/winners?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
|
||||
class="btn btn-warning btn-sm">
|
||||
<i class="bi bi-eye-fill me-1"></i>Reveal Winners
|
||||
</a>
|
||||
<a href="<?= site_url('administrator/trophy/final?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
|
||||
class="btn btn-success btn-sm">
|
||||
<i class="bi bi-bar-chart-steps me-1"></i>Final vs Predicted
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Year filter -->
|
||||
<form method="get" action="<?= site_url('administrator/trophy') ?>" class="row g-2 align-items-end mb-4">
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small fw-semibold">School Year</label>
|
||||
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
|
||||
<?php foreach ($years as $yr): ?>
|
||||
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>>
|
||||
<?= esc($yr) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($years)): ?>
|
||||
<option value="<?= esc($selectedYear) ?>" selected><?= esc($selectedYear) ?></option>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small fw-semibold">Percentile threshold</label>
|
||||
<div class="input-group input-group-sm" style="width:130px;">
|
||||
<input type="number" name="percentile" class="form-control"
|
||||
min="1" max="99" step="1"
|
||||
value="<?= (int) $selectedPercentile ?>"
|
||||
title="Students scoring above this percentile receive a trophy (min 3 per class)">
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (empty($classResults)): ?>
|
||||
<div class="alert alert-info">No class or fall-score data found for the selected school year.</div>
|
||||
<?php else: ?>
|
||||
|
||||
<!-- ── Global stats ── -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card text-center shadow-sm border-warning h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2 text-warning"><i class="bi bi-trophy-fill"></i></div>
|
||||
<div class="fs-3 fw-bold"><?= $totalTrophies ?></div>
|
||||
<div class="text-muted small">Trophies</div>
|
||||
<span class="badge bg-warning text-dark mt-1"><?= $pctTrophyAll ?>% of students</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card text-center shadow-sm h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2 text-primary"><i class="bi bi-people-fill"></i></div>
|
||||
<div class="fs-3 fw-bold"><?= $totalStudents ?></div>
|
||||
<div class="text-muted small">Students</div>
|
||||
<span class="badge bg-secondary mt-1"><?= count($classResults) ?> classes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card text-center shadow-sm h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2 text-success"><i class="bi bi-bar-chart-fill"></i></div>
|
||||
<div class="fs-3 fw-bold"><?= $totalScored ?></div>
|
||||
<div class="text-muted small">With Fall Scores</div>
|
||||
<span class="badge bg-success mt-1"><?= $totalStudents > 0 ? round($totalScored/$totalStudents*100) : 0 ?>%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card text-center shadow-sm h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2" style="color:#4A90E2"><i class="bi bi-gender-male"></i></div>
|
||||
<div class="fs-3 fw-bold"><?= $totalBoys ?></div>
|
||||
<div class="text-muted small">Boys</div>
|
||||
<span class="badge text-white mt-1" style="background:#4A90E2"><?= $pctBoys ?>% of students</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card text-center shadow-sm h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2" style="color:#E47AB0"><i class="bi bi-gender-female"></i></div>
|
||||
<div class="fs-3 fw-bold"><?= $totalGirls ?></div>
|
||||
<div class="text-muted small">Girls</div>
|
||||
<span class="badge text-white mt-1" style="background:#E47AB0"><?= $pctGirls ?>% of students</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card text-center shadow-sm h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2 text-warning"><i class="bi bi-award-fill"></i></div>
|
||||
<div class="fs-3 fw-bold">
|
||||
<span style="color:#4A90E2"><?= $totalTrophyBoys ?></span>
|
||||
<span class="text-muted fs-5">/</span>
|
||||
<span style="color:#E47AB0"><?= $totalTrophyGirls ?></span>
|
||||
</div>
|
||||
<div class="text-muted small">Trophy M / F</div>
|
||||
<span class="small">
|
||||
<span style="color:#4A90E2"><?= $pctTrophyBoys ?>%</span>
|
||||
·
|
||||
<span style="color:#E47AB0"><?= $pctTrophyGirls ?>%</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Per-class summary ── -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white fw-semibold py-2">Class Breakdown</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm align-middle mb-0 no-mgmt-sticky" id="trophy-breakdown-table" data-no-mgmt-sticky>
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Class</th>
|
||||
<th class="text-center">Students</th>
|
||||
<th class="text-center">Scored</th>
|
||||
<th class="text-center">
|
||||
<i class="bi bi-trophy-fill text-warning"></i> Trophies
|
||||
</th>
|
||||
<th class="text-center" style="color:#4A90E2">
|
||||
<i class="bi bi-gender-male"></i> Boys
|
||||
</th>
|
||||
<th class="text-center" style="color:#E47AB0">
|
||||
<i class="bi bi-gender-female"></i> Girls
|
||||
</th>
|
||||
<th class="text-center" style="color:#4A90E2">Trophy Boys</th>
|
||||
<th class="text-center" style="color:#E47AB0">Trophy Girls</th>
|
||||
<th class="text-center">Rate</th>
|
||||
<th class="text-end">Threshold</th>
|
||||
<th class="text-center">Custom Threshold</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($classResults as $cls):
|
||||
$scores = array_filter(
|
||||
array_column($cls['students'], 'fall_score'),
|
||||
fn($s) => $s !== null
|
||||
);
|
||||
$scoresJson = json_encode(array_values($scores));
|
||||
$clsId = 'cls' . $cls['section_id'];
|
||||
?>
|
||||
<tr id="row-<?= $clsId ?>">
|
||||
<td class="ps-3 fw-semibold"><?= esc($cls['section_name']) ?></td>
|
||||
<td class="text-center"><?= $cls['student_count'] ?></td>
|
||||
<td class="text-center">
|
||||
<?php if ($cls['scored_count'] < 3): ?>
|
||||
<span class="badge bg-danger" title="Fewer than 3 fall scores — minimum of 3 trophies cannot be enforced">
|
||||
<?= $cls['scored_count'] ?> <i class="bi bi-exclamation-triangle-fill"></i>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<?= $cls['scored_count'] ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-center" id="trophy-cell-<?= $clsId ?>">
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-trophy-fill"></i>
|
||||
<span id="trophy-count-<?= $clsId ?>"><?= $cls['trophy_count'] ?></span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?= $cls['boys'] ?>
|
||||
<span class="text-muted small">(<?= $cls['pct_boys'] ?>%)</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?= $cls['girls'] ?>
|
||||
<span class="text-muted small">(<?= $cls['pct_girls'] ?>%)</span>
|
||||
</td>
|
||||
<td class="text-center" id="trophy-boys-<?= $clsId ?>"><?= $cls['trophy_boys'] ?><?php if ($cls['boys'] > 0): ?> <span class="text-muted small">(<?= $cls['pct_trophy_boys'] ?>%)</span><?php endif; ?></td>
|
||||
<td class="text-center" id="trophy-girls-<?= $clsId ?>"><?= $cls['trophy_girls'] ?><?php if ($cls['girls'] > 0): ?> <span class="text-muted small">(<?= $cls['pct_trophy_girls'] ?>%)</span><?php endif; ?></td>
|
||||
<td class="text-center">
|
||||
<div class="progress" style="height:8px;min-width:60px;">
|
||||
<div class="progress-bar bg-warning" id="rate-bar-<?= $clsId ?>" style="width:<?= $cls['pct_trophy_total'] ?>%;"></div>
|
||||
</div>
|
||||
<span class="small text-muted" id="rate-txt-<?= $clsId ?>"><?= $cls['pct_trophy_total'] ?>%</span>
|
||||
</td>
|
||||
<td class="text-end text-muted small" id="auto-threshold-<?= $clsId ?>">
|
||||
<?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '—' ?>
|
||||
</td>
|
||||
<td>
|
||||
<div class="input-group input-group-sm" style="min-width:150px;">
|
||||
<input type="number" id="custom-threshold-<?= $clsId ?>"
|
||||
class="form-control form-control-sm"
|
||||
step="0.1" min="0"
|
||||
placeholder="e.g. <?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '80.0' ?>"
|
||||
data-scores='<?= $scoresJson ?>'
|
||||
data-clsid="<?= $clsId ?>"
|
||||
data-boys='<?= json_encode(array_values(array_filter(array_map(fn($s) => $s['gender'] === 'Male' ? $s['fall_score'] : null, $cls['students']), fn($s) => $s !== null))) ?>'
|
||||
data-girls='<?= json_encode(array_values(array_filter(array_map(fn($s) => $s['gender'] === 'Female' ? $s['fall_score'] : null, $cls['students']), fn($s) => $s !== null))) ?>'
|
||||
data-total="<?= $cls['student_count'] ?>"
|
||||
data-nboys="<?= $cls['boys'] ?>"
|
||||
data-ngirls="<?= $cls['girls'] ?>">
|
||||
<button class="btn btn-outline-primary btn-sm" type="button"
|
||||
onclick="applyCustomThreshold('<?= $clsId ?>')">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
<div id="custom-note-<?= $clsId ?>" class="text-muted" style="font-size:.7rem;"></div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot class="table-light fw-semibold">
|
||||
<tr>
|
||||
<td class="ps-3">Total</td>
|
||||
<td class="text-center"><?= $totalStudents ?></td>
|
||||
<td class="text-center"><?= $totalScored ?></td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-trophy-fill"></i> <?= $totalTrophies ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?= $totalBoys ?>
|
||||
<span class="text-muted small fw-normal">(<?= $pctBoys ?>%)</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?= $totalGirls ?>
|
||||
<span class="text-muted small fw-normal">(<?= $pctGirls ?>%)</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?= $totalTrophyBoys ?>
|
||||
<span class="text-muted small fw-normal">(<?= $pctTrophyBoys ?>%)</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?= $totalTrophyGirls ?>
|
||||
<span class="text-muted small fw-normal">(<?= $pctTrophyGirls ?>%)</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="progress" style="height:8px;min-width:60px;">
|
||||
<div class="progress-bar bg-warning" style="width:<?= $pctTrophyAll ?>%;"></div>
|
||||
</div>
|
||||
<span class="small text-muted"><?= $pctTrophyAll ?>%</span>
|
||||
</td>
|
||||
<td></td>
|
||||
<td class="pe-3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
var TROPHY_NS = 'trophy_custom_<?= esc($selectedYear) ?>_<?= (int)$selectedPercentile ?>';
|
||||
|
||||
function countAtOrAbove(arr, t) {
|
||||
return arr.filter(function(s) { return s >= t; }).length;
|
||||
}
|
||||
|
||||
function applyThreshold(clsId, val) {
|
||||
var input = document.getElementById('custom-threshold-' + clsId);
|
||||
var note = document.getElementById('custom-note-' + clsId);
|
||||
|
||||
var scores = JSON.parse(input.dataset.scores);
|
||||
var boys = JSON.parse(input.dataset.boys);
|
||||
var girls = JSON.parse(input.dataset.girls);
|
||||
var nTotal = parseInt(input.dataset.total);
|
||||
var nBoys = parseInt(input.dataset.nboys);
|
||||
var nGirls = parseInt(input.dataset.ngirls);
|
||||
var MIN = 3;
|
||||
|
||||
var winners = countAtOrAbove(scores, val);
|
||||
var usedThreshold = val;
|
||||
var forced = false;
|
||||
|
||||
if (winners < MIN && scores.length >= MIN) {
|
||||
var desc = scores.slice().sort(function(a, b) { return b - a; });
|
||||
usedThreshold = desc[MIN - 1];
|
||||
winners = countAtOrAbove(scores, usedThreshold);
|
||||
forced = true;
|
||||
} else if (winners < MIN) {
|
||||
winners = scores.length;
|
||||
usedThreshold = scores.length > 0 ? Math.min.apply(null, scores) : val;
|
||||
forced = true;
|
||||
}
|
||||
|
||||
var trophyBoys = countAtOrAbove(boys, usedThreshold);
|
||||
var trophyGirls = countAtOrAbove(girls, usedThreshold);
|
||||
var rate = nTotal > 0 ? Math.round(winners / nTotal * 100) : 0;
|
||||
var pctTB = nBoys > 0 ? Math.round(trophyBoys / nBoys * 100) : 0;
|
||||
var pctTG = nGirls > 0 ? Math.round(trophyGirls / nGirls * 100) : 0;
|
||||
|
||||
document.getElementById('trophy-count-' + clsId).textContent = winners;
|
||||
document.getElementById('trophy-boys-' + clsId).textContent = trophyBoys + (nBoys > 0 ? ' (' + pctTB + '%)' : '');
|
||||
document.getElementById('trophy-girls-' + clsId).textContent = trophyGirls + (nGirls > 0 ? ' (' + pctTG + '%)' : '');
|
||||
document.getElementById('rate-bar-' + clsId).style.width = rate + '%';
|
||||
document.getElementById('rate-txt-' + clsId).textContent = rate + '%';
|
||||
document.getElementById('auto-threshold-' + clsId).textContent = usedThreshold.toFixed(1);
|
||||
|
||||
if (forced) {
|
||||
note.style.color = '#dc3545';
|
||||
note.textContent = 'Min 3 enforced → threshold lowered to ' + usedThreshold.toFixed(1);
|
||||
} else {
|
||||
note.style.color = '#6c757d';
|
||||
note.textContent = 'Custom threshold: ' + usedThreshold.toFixed(1);
|
||||
}
|
||||
|
||||
var badge = document.getElementById('trophy-cell-' + clsId).querySelector('.badge');
|
||||
badge.classList.remove('bg-warning');
|
||||
badge.classList.add('bg-primary');
|
||||
}
|
||||
|
||||
function applyCustomThreshold(clsId) {
|
||||
var input = document.getElementById('custom-threshold-' + clsId);
|
||||
var val = parseFloat(input.value);
|
||||
if (isNaN(val)) {
|
||||
document.getElementById('custom-note-' + clsId).textContent = 'Enter a valid number.';
|
||||
return;
|
||||
}
|
||||
applyThreshold(clsId, val);
|
||||
// Persist so the value survives filter changes within the same percentile/year
|
||||
try {
|
||||
var saved = JSON.parse(localStorage.getItem(TROPHY_NS) || '{}');
|
||||
saved[clsId] = val;
|
||||
localStorage.setItem(TROPHY_NS, JSON.stringify(saved));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Restore saved custom thresholds on page load
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
try {
|
||||
var saved = JSON.parse(localStorage.getItem(TROPHY_NS) || '{}');
|
||||
Object.keys(saved).forEach(function (clsId) {
|
||||
var input = document.getElementById('custom-threshold-' + clsId);
|
||||
if (!input) return;
|
||||
input.value = saved[clsId];
|
||||
applyThreshold(clsId, saved[clsId]);
|
||||
});
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
// Clear all custom thresholds when the percentile/year form is submitted
|
||||
document.querySelector('form[action*="trophy"]').addEventListener('submit', function () {
|
||||
try { localStorage.removeItem(TROPHY_NS); } catch (e) {}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,974 @@
|
||||
<?= $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);
|
||||
|
||||
// Winner gender breakdown.
|
||||
// "Winner" means actual year-end trophy winner.
|
||||
$totalWinnerBoys = 0;
|
||||
$totalWinnerGirls = 0;
|
||||
$totalWinnerOther = 0;
|
||||
|
||||
// Sticker names.
|
||||
// 2 columns x 10 rows = 20 stickers per page.
|
||||
// Stickers print NAME ONLY.
|
||||
$winnerStickerNames = [];
|
||||
|
||||
foreach ($classResults as $cls) {
|
||||
foreach (($cls['students'] ?? []) as $s) {
|
||||
if (empty($s['actual'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$gender = strtolower(trim((string)($s['gender'] ?? '')));
|
||||
|
||||
if (in_array($gender, ['male', 'm', 'boy', 'boys'], true)) {
|
||||
$totalWinnerBoys++;
|
||||
} elseif (in_array($gender, ['female', 'f', 'girl', 'girls'], true)) {
|
||||
$totalWinnerGirls++;
|
||||
} else {
|
||||
$totalWinnerOther++;
|
||||
}
|
||||
|
||||
$name = trim((string)($s['name'] ?? ''));
|
||||
|
||||
if ($name !== '') {
|
||||
$winnerStickerNames[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$totalWinners = $totalWinnerBoys + $totalWinnerGirls + $totalWinnerOther;
|
||||
|
||||
$winnerBoysPct = $totalWinners > 0 ? round(($totalWinnerBoys / $totalWinners) * 100, 1) : 0;
|
||||
$winnerGirlsPct = $totalWinners > 0 ? round(($totalWinnerGirls / $totalWinners) * 100, 1) : 0;
|
||||
$winnerOtherPct = $totalWinners > 0 ? round(($totalWinnerOther / $totalWinners) * 100, 1) : 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; }
|
||||
.winner-sticker-print-area { display: none; }
|
||||
|
||||
@page {
|
||||
size: Letter portrait;
|
||||
margin: 0.35in;
|
||||
}
|
||||
|
||||
@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; }
|
||||
|
||||
body.print-stickers-mode {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
body.print-stickers-mode * {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body.print-stickers-mode header,
|
||||
body.print-stickers-mode nav,
|
||||
body.print-stickers-mode aside,
|
||||
body.print-stickers-mode footer,
|
||||
body.print-stickers-mode .navbar,
|
||||
body.print-stickers-mode .sidebar,
|
||||
body.print-stickers-mode .topbar,
|
||||
body.print-stickers-mode .app-header,
|
||||
body.print-stickers-mode .main-header,
|
||||
body.print-stickers-mode .layout-header,
|
||||
body.print-stickers-mode .management-header,
|
||||
body.print-stickers-mode .page-header,
|
||||
body.print-stickers-mode .breadcrumb,
|
||||
body.print-stickers-mode .brand,
|
||||
body.print-stickers-mode .logo,
|
||||
body.print-stickers-mode .header,
|
||||
body.print-stickers-mode .no-print,
|
||||
body.print-stickers-mode .screen-only,
|
||||
body.print-stickers-mode .print-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.print-stickers-mode .container-fluid > *:not(#winnerStickerPrintArea) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.print-stickers-mode .container-fluid {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
body.print-stickers-mode #winnerStickerPrintArea {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
position: static !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
body.print-stickers-mode #winnerStickerPrintArea,
|
||||
body.print-stickers-mode #winnerStickerPrintArea * {
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
body.print-stickers-mode .sticker-page {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(10, 1fr);
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
height: 10.3in;
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
}
|
||||
|
||||
body.print-stickers-mode .sticker-page:last-child {
|
||||
page-break-after: auto;
|
||||
break-after: auto;
|
||||
}
|
||||
|
||||
body.print-stickers-mode .sticker-cell {
|
||||
display: flex !important;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
border: none;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body.print-stickers-mode .sticker-name {
|
||||
display: block !important;
|
||||
font-size: 20pt;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
body.print-stickers-mode .sticker-empty {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2 no-print">
|
||||
<div>
|
||||
<h2 class="mb-1">
|
||||
<i class="bi bi-trophy-fill text-warning me-1"></i>
|
||||
Final vs Predicted — <?= esc($selectedYear) ?>
|
||||
</h2>
|
||||
<p class="text-muted mb-0">
|
||||
Compares the <strong>Fall-score prediction</strong> (<?= (int)$selectedPercentile ?>th percentile)
|
||||
with the <strong>year-end result</strong> based on the average of Fall & Spring scores.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button onclick="printWithCharts()" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-printer-fill me-1"></i>Print
|
||||
</button>
|
||||
|
||||
<button onclick="printWinnerStickers()" class="btn btn-warning btn-sm">
|
||||
<i class="bi bi-tags-fill me-1"></i>Print Winner Stickers
|
||||
</button>
|
||||
|
||||
<a href="<?= site_url('administrator/trophy?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<form method="get" action="<?= site_url('administrator/trophy/final') ?>"
|
||||
class="row g-2 align-items-end mb-4 no-print">
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small fw-semibold">School Year</label>
|
||||
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
|
||||
<?php foreach ($years as $yr): ?>
|
||||
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>>
|
||||
<?= esc($yr) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small fw-semibold">Percentile</label>
|
||||
<div class="input-group input-group-sm" style="width:110px;">
|
||||
<input type="number"
|
||||
name="percentile"
|
||||
class="form-control"
|
||||
min="1"
|
||||
max="99"
|
||||
step="1"
|
||||
value="<?= (int)$selectedPercentile ?>">
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (empty($classResults)): ?>
|
||||
<div class="alert alert-info no-print">No data found for the selected school year.</div>
|
||||
<?php else: ?>
|
||||
|
||||
<!-- ══ SCREEN VIEW ═══════════════════════════════════════════════════════ -->
|
||||
<div class="screen-only">
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="d-flex flex-wrap gap-3 mb-3 small">
|
||||
<span><span class="badge bg-success me-1">✓ Confirmed</span> Predicted AND got a year-end trophy</span>
|
||||
<span><span class="badge bg-primary me-1">↑ Surprise</span> NOT predicted, but earned a trophy</span>
|
||||
<span><span class="badge bg-warning text-dark me-1">↓ Missed</span> Predicted, but fell short year-end</span>
|
||||
<span><span class="badge bg-light text-muted border me-1">— None</span> No trophy either way</span>
|
||||
</div>
|
||||
|
||||
<!-- Global stat tiles -->
|
||||
<div class="row g-3 mb-4">
|
||||
<?php foreach ([
|
||||
[$totalPredicted, 'Predicted (Fall)', 'primary', null, $totalStudents > 0 ? round($totalPredicted / $totalStudents * 100) . '% of students' : '—'],
|
||||
[$totalActual, 'Actual (Year)', 'warning', 'dark', $totalStudents > 0 ? round($totalActual / $totalStudents * 100) . '% of students' : '—'],
|
||||
[$totalConfirmed, 'Confirmed', 'success', null, $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) . '% of predicted' : '—'],
|
||||
[$totalSurprise, 'Surprises', 'info', 'dark', 'Not in prediction'],
|
||||
[$totalMissed, 'Missed', 'orange', null, 'Were predicted'],
|
||||
[$overallAccuracy,'Accuracy', 'dark', null, 'prediction rate'],
|
||||
] as [$val, $label, $color, $textClass, $sub]):
|
||||
$isOrange = $color === 'orange';
|
||||
$bgClass = $isOrange ? '' : 'bg-' . $color;
|
||||
$txClass = $textClass ? 'text-' . $textClass : 'text-white';
|
||||
?>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card text-center shadow-sm h-100 <?= $isOrange ? 'border-warning' : '' ?>">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-3 fw-bold <?= $isOrange ? 'text-warning' : 'text-' . $color ?>">
|
||||
<?= $val ?><?= $label === 'Accuracy' ? '%' : '' ?>
|
||||
</div>
|
||||
<div class="text-muted small"><?= $label ?></div>
|
||||
<span class="badge mt-1 <?= $bgClass . ' ' . $txClass ?>"
|
||||
<?= $isOrange ? 'style="background:#fd7e14"' : '' ?>>
|
||||
<?= $sub ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Per-class breakdown -->
|
||||
<?php foreach ($classResults as $cls): ?>
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2 py-2"
|
||||
style="background:#f8f9fa;">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="fw-bold fs-6"><?= esc($cls['section_name']) ?></span>
|
||||
<span class="badge bg-primary"><?= $cls['predicted_count'] ?> predicted</span>
|
||||
<span class="badge bg-warning text-dark"><?= $cls['actual_count'] ?> actual</span>
|
||||
|
||||
<?php if ($cls['confirmed'] > 0): ?>
|
||||
<span class="badge bg-success"><?= $cls['confirmed'] ?> confirmed</span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($cls['surprises'] > 0): ?>
|
||||
<span class="badge bg-info text-dark"><?= $cls['surprises'] ?> surprise<?= $cls['surprises'] > 1 ? 's' : '' ?></span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($cls['missed'] > 0): ?>
|
||||
<span class="badge" style="background:#fd7e14"><?= $cls['missed'] ?> missed</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-3 small align-items-center">
|
||||
<span class="text-muted">
|
||||
Fall ≥
|
||||
<strong><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></strong>
|
||||
</span>
|
||||
<span class="text-muted">
|
||||
Year ≥
|
||||
<strong><?= $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?></strong>
|
||||
</span>
|
||||
<span class="fw-semibold">
|
||||
Accuracy:
|
||||
<span class="<?= $cls['accuracy'] >= 80 ? 'text-success' : ($cls['accuracy'] >= 50 ? 'text-warning' : 'text-danger') ?>">
|
||||
<?= $cls['accuracy'] ?>%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0 align-middle" data-no-mgmt-sticky>
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3" style="width:2rem;">#</th>
|
||||
<th>Name</th>
|
||||
<th class="text-center" style="width:3rem;">Gender</th>
|
||||
<th class="text-end">Fall</th>
|
||||
<th class="text-end">Spring</th>
|
||||
<th class="text-end">Year Avg</th>
|
||||
<th class="text-center">Predicted</th>
|
||||
<th class="text-center">Actual</th>
|
||||
<th class="text-center">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<?php $rank = 0; foreach ($cls['students'] as $s):
|
||||
if ($s['status'] === 'none') continue;
|
||||
$rank++;
|
||||
|
||||
$genderNorm = strtolower(trim((string)($s['gender'] ?? '')));
|
||||
$isMale = in_array($genderNorm, ['male', 'm', 'boy'], true);
|
||||
|
||||
$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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Winner gender summary -->
|
||||
<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-gender-ambiguous me-2"></i>Winner Gender Breakdown
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-stretch">
|
||||
<div class="col-12 col-lg-3">
|
||||
<div class="border rounded p-3 text-center h-100">
|
||||
<div class="text-muted small mb-1">Total Winners</div>
|
||||
<div class="display-6 fw-bold"><?= (int)$totalWinners ?></div>
|
||||
<div class="small text-muted">Actual year-end trophy winners</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="border rounded p-3 text-center h-100">
|
||||
<div class="text-muted small mb-1">Boys</div>
|
||||
<div class="display-6 fw-bold text-primary"><?= (int)$totalWinnerBoys ?></div>
|
||||
<div class="fw-semibold"><?= number_format($winnerBoysPct, 1) ?>%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="border rounded p-3 text-center h-100">
|
||||
<div class="text-muted small mb-1">Girls</div>
|
||||
<div class="display-6 fw-bold" style="color:#E47AB0;"><?= (int)$totalWinnerGirls ?></div>
|
||||
<div class="fw-semibold"><?= number_format($winnerGirlsPct, 1) ?>%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-3">
|
||||
<div class="border rounded p-3 text-center h-100">
|
||||
<div class="text-muted small mb-1">Unknown / Other</div>
|
||||
<div class="display-6 fw-bold text-secondary"><?= (int)$totalWinnerOther ?></div>
|
||||
<div class="fw-semibold"><?= number_format($winnerOtherPct, 1) ?>%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="progress" style="height:26px;">
|
||||
<?php if ($totalWinners > 0): ?>
|
||||
<div class="progress-bar bg-primary"
|
||||
role="progressbar"
|
||||
style="width: <?= $winnerBoysPct ?>%;"
|
||||
aria-valuenow="<?= $winnerBoysPct ?>"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
Boys <?= number_format($winnerBoysPct, 1) ?>%
|
||||
</div>
|
||||
|
||||
<div class="progress-bar"
|
||||
role="progressbar"
|
||||
style="width: <?= $winnerGirlsPct ?>%; background:#E47AB0;"
|
||||
aria-valuenow="<?= $winnerGirlsPct ?>"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
Girls <?= number_format($winnerGirlsPct, 1) ?>%
|
||||
</div>
|
||||
|
||||
<?php if ($totalWinnerOther > 0): ?>
|
||||
<div class="progress-bar bg-secondary"
|
||||
role="progressbar"
|
||||
style="width: <?= $winnerOtherPct ?>%;"
|
||||
aria-valuenow="<?= $winnerOtherPct ?>"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
Other <?= number_format($winnerOtherPct, 1) ?>%
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<div class="progress-bar bg-secondary"
|
||||
role="progressbar"
|
||||
style="width: 100%;"
|
||||
aria-valuenow="0"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
No winners
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /screen-only -->
|
||||
|
||||
<!-- ══ PRINT VIEW ════════════════════════════════════════════════════════ -->
|
||||
<div class="print-only">
|
||||
|
||||
<div style="text-align:center;margin-bottom:10px;border-bottom:2px solid #333;padding-bottom:6px;">
|
||||
<h2 style="margin:0;">Final vs Predicted — <?= esc($selectedYear) ?></h2>
|
||||
<p style="margin:3px 0 0;font-size:10px;color:#555;">
|
||||
<?= (int)$selectedPercentile ?>th percentile —
|
||||
Predicted: <?= $totalPredicted ?> —
|
||||
Actual: <?= $totalActual ?> —
|
||||
Confirmed: <?= $totalConfirmed ?> —
|
||||
Accuracy: <?= $overallAccuracy ?>%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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++;
|
||||
$genderNorm = strtolower(trim((string)($s['gender'] ?? '')));
|
||||
$isMale = in_array($genderNorm, ['male', 'm', 'boy'], true);
|
||||
$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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 style="margin-top:10px;border:1px solid #bbb;padding:8px;">
|
||||
<p style="font-size:11px;font-weight:bold;margin:0 0 6px;">Winner Gender Breakdown</p>
|
||||
|
||||
<table data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group</th>
|
||||
<th style="text-align:center;">Winners</th>
|
||||
<th style="text-align:center;">Percentage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Boys</td>
|
||||
<td style="text-align:center;"><?= (int)$totalWinnerBoys ?></td>
|
||||
<td style="text-align:center;"><?= number_format($winnerBoysPct, 1) ?>%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Girls</td>
|
||||
<td style="text-align:center;"><?= (int)$totalWinnerGirls ?></td>
|
||||
<td style="text-align:center;"><?= number_format($winnerGirlsPct, 1) ?>%</td>
|
||||
</tr>
|
||||
<?php if ($totalWinnerOther > 0): ?>
|
||||
<tr>
|
||||
<td>Unknown / Other</td>
|
||||
<td style="text-align:center;"><?= (int)$totalWinnerOther ?></td>
|
||||
<td style="text-align:center;"><?= number_format($winnerOtherPct, 1) ?>%</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td style="font-weight:bold;">Total Winners</td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= (int)$totalWinners ?></td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $totalWinners > 0 ? '100.0%' : '0.0%' ?></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div><!-- /print-only -->
|
||||
|
||||
<!-- Winner sticker print area: names only, no header, no score, no class -->
|
||||
<div id="winnerStickerPrintArea" class="winner-sticker-print-area">
|
||||
<?php if (!empty($winnerStickerNames)): ?>
|
||||
<?php foreach (array_chunk($winnerStickerNames, 20) as $chunk): ?>
|
||||
<div class="sticker-page">
|
||||
<?php foreach ($chunk as $winnerName): ?>
|
||||
<div class="sticker-cell">
|
||||
<div class="sticker-name"><?= esc($winnerName) ?></div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php for ($i = 0, $remaining = 20 - count($chunk); $i < $remaining; $i++): ?>
|
||||
<div class="sticker-cell sticker-empty"></div>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?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) ?>;
|
||||
|
||||
if (document.getElementById('chart-counts')) {
|
||||
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' } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.getElementById('chart-accuracy')) {
|
||||
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' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.getElementById('chart-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() {
|
||||
document.body.classList.remove('print-stickers-mode');
|
||||
captureCharts();
|
||||
window.print();
|
||||
}
|
||||
|
||||
function printWinnerStickers() {
|
||||
document.body.classList.add('print-stickers-mode');
|
||||
|
||||
setTimeout(function () {
|
||||
window.print();
|
||||
|
||||
setTimeout(function () {
|
||||
document.body.classList.remove('print-stickers-mode');
|
||||
}, 1000);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
window.addEventListener('beforeprint', function () {
|
||||
if (!document.body.classList.contains('print-stickers-mode')) {
|
||||
captureCharts();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,480 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<?php
|
||||
$classResults = $classResults ?? [];
|
||||
$selectedYear = $selectedYear ?? '';
|
||||
$selectedPercentile = $selectedPercentile ?? 75;
|
||||
$years = $years ?? [];
|
||||
|
||||
$totalWinners = array_sum(array_map(fn($c) => count($c['winners']), $classResults));
|
||||
$totalStudents = array_sum(array_column($classResults, 'student_count'));
|
||||
$totalBoys = array_sum(array_column($classResults, 'boys'));
|
||||
$totalGirls = array_sum(array_column($classResults, 'girls'));
|
||||
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
|
||||
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
|
||||
|
||||
$pctWinners = $totalStudents > 0 ? round($totalWinners / $totalStudents * 100) : 0;
|
||||
$pctTrophyBoys = $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0;
|
||||
$pctTrophyGirls = $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0;
|
||||
$pctBoys = $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0;
|
||||
$pctGirls = $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0;
|
||||
?>
|
||||
|
||||
<style>
|
||||
/* ── Screen: hide print-only blocks ── */
|
||||
.print-only { display: none !important; }
|
||||
|
||||
/* ── Print ── */
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.print-only { display: block !important; }
|
||||
.screen-only { display: none !important; }
|
||||
|
||||
body { font-size: 11px; }
|
||||
.container-fluid { padding: 0 !important; }
|
||||
h2, h3 { font-size: 14px; margin-bottom: .4rem; }
|
||||
.print-page-break { break-before: page; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 10px; }
|
||||
table th,
|
||||
table td { border: 1px solid #bbb; padding: 3px 6px; }
|
||||
table thead { background: #333 !important; color: #fff !important;
|
||||
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
table thead th { position: static !important; top: auto !important; box-shadow: none !important; }
|
||||
table tfoot { background: #eee !important;
|
||||
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.badge-m { color: #4A90E2; font-weight: bold; }
|
||||
.badge-f { color: #E47AB0; font-weight: bold; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- ══ SCREEN HEADER ══════════════════════════════════════════════════════ -->
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2 no-print">
|
||||
<div>
|
||||
<h2 class="mb-1"><i class="bi bi-trophy-fill text-warning me-2"></i>Trophy Winners</h2>
|
||||
<p class="text-muted mb-0">
|
||||
<?= esc($selectedYear) ?> — <?= (int)$selectedPercentile ?>th percentile —
|
||||
<strong><?= $totalWinners ?></strong> winners across <strong><?= count($classResults) ?></strong> classes
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button onclick="window.print()" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-printer-fill me-1"></i>Print
|
||||
</button>
|
||||
<a href="<?= site_url('administrator/trophy?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter (screen only) -->
|
||||
<form method="get" action="<?= site_url('administrator/trophy/winners') ?>"
|
||||
class="row g-2 align-items-end mb-4 no-print">
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small fw-semibold">School Year</label>
|
||||
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
|
||||
<?php foreach ($years as $yr): ?>
|
||||
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>><?= esc($yr) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small fw-semibold">Percentile</label>
|
||||
<div class="input-group input-group-sm" style="width:110px;">
|
||||
<input type="number" name="percentile" class="form-control"
|
||||
min="1" max="99" step="1" value="<?= (int)$selectedPercentile ?>">
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (empty($classResults)): ?>
|
||||
<div class="alert alert-info no-print">No projected trophy winners found.</div>
|
||||
<?php else: ?>
|
||||
|
||||
<!-- ══ PRINT VIEW ════════════════════════════════════════════════════════ -->
|
||||
<div class="print-only">
|
||||
|
||||
<!-- Print title -->
|
||||
<div style="text-align:center;margin-bottom:12px;border-bottom:2px solid #333;padding-bottom:8px;">
|
||||
<h2 style="margin:0;">Trophy Winners — <?= esc($selectedYear) ?></h2>
|
||||
<p style="margin:4px 0 0;font-size:10px;color:#555;">
|
||||
<?= (int)$selectedPercentile ?>th percentile —
|
||||
<?= $totalWinners ?> winners / <?= $totalStudents ?> students —
|
||||
<?= count($classResults) ?> classes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- TABLE 1: All winners -->
|
||||
<h3 style="margin-bottom:4px;">Winners</h3>
|
||||
<table data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Class</th>
|
||||
<th>Name</th>
|
||||
<th style="text-align:center;">Gender</th>
|
||||
<th style="text-align:right;">Fall Score</th>
|
||||
<th style="text-align:right;">Spring Score</th>
|
||||
<th style="text-align:right;">Year Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$globalRank = 0;
|
||||
foreach ($classResults as $cls):
|
||||
foreach ($cls['winners'] as $student):
|
||||
$globalRank++;
|
||||
$isMale = strtolower($student['gender'] ?? '') === 'male';
|
||||
?>
|
||||
<tr>
|
||||
<td style="text-align:center;"><?= $globalRank ?></td>
|
||||
<td><?= esc($cls['section_name']) ?></td>
|
||||
<td><strong><?= esc($student['name']) ?></strong></td>
|
||||
<td style="text-align:center;">
|
||||
<span class="<?= $isMale ? 'badge-m' : 'badge-f' ?>">
|
||||
<?= $isMale ? 'M' : 'F' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td style="text-align:right;font-weight:bold;">
|
||||
<?= $student['fall_score'] !== null ? number_format($student['fall_score'], 1) : '—' ?>
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<?= $student['spring_score'] !== null ? number_format($student['spring_score'], 1) : '—' ?>
|
||||
</td>
|
||||
<td style="text-align:right;font-weight:bold;color:#155724;">
|
||||
<?= $student['year_score'] !== null ? number_format($student['year_score'], 1) : '—' ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2" style="font-weight:bold;">Total</td>
|
||||
<td colspan="2" style="font-weight:bold;"><?= $totalWinners ?> winners</td>
|
||||
<td colspan="3" style="text-align:right;font-weight:bold;"><?= $pctWinners ?>% of students</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<!-- TABLE 2: Year summary (new page) -->
|
||||
<div class="print-page-break"></div>
|
||||
|
||||
<h3 style="margin-bottom:4px;">Year Summary — <?= esc($selectedYear) ?></h3>
|
||||
<table style="margin-bottom:16px;" data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Class</th>
|
||||
<th style="text-align:center;">Students</th>
|
||||
<th style="text-align:center;">Boys</th>
|
||||
<th style="text-align:center;">Girls</th>
|
||||
<th style="text-align:center;">Winners</th>
|
||||
<th style="text-align:center;">Trophy Boys</th>
|
||||
<th style="text-align:center;">Trophy Girls</th>
|
||||
<th style="text-align:center;">Rate</th>
|
||||
<th style="text-align:right;">Fall Threshold</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($classResults as $cls): ?>
|
||||
<tr>
|
||||
<td><?= esc($cls['section_name']) ?></td>
|
||||
<td style="text-align:center;"><?= $cls['student_count'] ?></td>
|
||||
<td style="text-align:center;"><?= $cls['boys'] ?> (<?= $cls['pct_boys'] ?>%)</td>
|
||||
<td style="text-align:center;"><?= $cls['girls'] ?> (<?= $cls['pct_girls'] ?>%)</td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= count($cls['winners']) ?></td>
|
||||
<td style="text-align:center;">
|
||||
<span class="badge-m"><?= $cls['trophy_boys'] ?></span>
|
||||
<?= $cls['boys'] > 0 ? '(' . $cls['pct_trophy_boys'] . '%)' : '' ?>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<span class="badge-f"><?= $cls['trophy_girls'] ?></span>
|
||||
<?= $cls['girls'] > 0 ? '(' . $cls['pct_trophy_girls'] . '%)' : '' ?>
|
||||
</td>
|
||||
<td style="text-align:center;"><?= $cls['pct_trophy_total'] ?>%</td>
|
||||
<td style="text-align:right;"><?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '—' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td style="font-weight:bold;">Total</td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $totalStudents ?></td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $totalBoys ?> (<?= $pctBoys ?>%)</td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $totalGirls ?> (<?= $pctGirls ?>%)</td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $totalWinners ?></td>
|
||||
<td style="text-align:center;font-weight:bold;">
|
||||
<span class="badge-m"><?= $totalTrophyBoys ?></span> (<?= $pctTrophyBoys ?>%)
|
||||
</td>
|
||||
<td style="text-align:center;font-weight:bold;">
|
||||
<span class="badge-f"><?= $totalTrophyGirls ?></span> (<?= $pctTrophyGirls ?>%)
|
||||
</td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $pctWinners ?>%</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
</div><!-- /print-only -->
|
||||
|
||||
<!-- ══ SCREEN VIEW ═══════════════════════════════════════════════════════ -->
|
||||
<div class="screen-only">
|
||||
|
||||
<!-- Per-class cards -->
|
||||
<?php foreach ($classResults as $cls):
|
||||
$nW = count($cls['winners']);
|
||||
?>
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2 py-2"
|
||||
style="background:#fff8e1;">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="fw-bold fs-6"><?= esc($cls['section_name']) ?></span>
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-trophy-fill"></i> <?= $nW ?> winner<?= $nW !== 1 ? 's' : '' ?>
|
||||
</span>
|
||||
<?php if ($cls['threshold'] !== null): ?>
|
||||
<span class="text-muted small">Fall ≥ <?= number_format((float)$cls['threshold'], 1) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="d-flex gap-3 small">
|
||||
<span>
|
||||
<i class="bi bi-gender-male" style="color:#4A90E2"></i>
|
||||
<strong style="color:#4A90E2"><?= $cls['trophy_boys'] ?></strong>/<?= $cls['boys'] ?> boys
|
||||
<?php if ($cls['boys'] > 0): ?><span class="text-muted">(<?= $cls['pct_trophy_boys'] ?>%)</span><?php endif; ?>
|
||||
</span>
|
||||
<span>
|
||||
<i class="bi bi-gender-female" style="color:#E47AB0"></i>
|
||||
<strong style="color:#E47AB0"><?= $cls['trophy_girls'] ?></strong>/<?= $cls['girls'] ?> girls
|
||||
<?php if ($cls['girls'] > 0): ?><span class="text-muted">(<?= $cls['pct_trophy_girls'] ?>%)</span><?php endif; ?>
|
||||
</span>
|
||||
<span class="text-muted"><?= $nW ?>/<?= $cls['student_count'] ?> (<?= $cls['pct_trophy_total'] ?>%)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0 align-middle" data-no-mgmt-sticky>
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3" style="width:2.2rem;">#</th>
|
||||
<th>Name</th>
|
||||
<th class="text-center" style="width:3rem;">Gender</th>
|
||||
<th class="text-end">Fall Score</th>
|
||||
<th class="text-end">Spring Score</th>
|
||||
<th class="text-end pe-3">Year Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($cls['winners'] as $i => $student):
|
||||
$isMale = strtolower($student['gender'] ?? '') === 'male';
|
||||
?>
|
||||
<tr>
|
||||
<td class="ps-3 text-muted small"><?= $i + 1 ?></td>
|
||||
<td class="fw-semibold small"><?= esc($student['name']) ?></td>
|
||||
<td class="text-center">
|
||||
<span class="badge" style="background:<?= $isMale ? '#4A90E2' : '#E47AB0' ?>;font-size:.65rem;">
|
||||
<?= $isMale ? 'M' : 'F' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end small fw-semibold">
|
||||
<?= $student['fall_score'] !== null ? number_format($student['fall_score'], 1) : '<span class="text-muted">—</span>' ?>
|
||||
</td>
|
||||
<td class="text-end small">
|
||||
<?= $student['spring_score'] !== null
|
||||
? '<span class="fw-semibold">' . number_format($student['spring_score'], 1) . '</span>'
|
||||
: '<span class="text-muted">—</span>' ?>
|
||||
</td>
|
||||
<td class="text-end pe-3 small fw-semibold" style="color:#155724;">
|
||||
<?= $student['year_score'] !== null ? number_format($student['year_score'], 1) : '<span class="text-muted">—</span>' ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Overall year stats -->
|
||||
<div class="card shadow-sm mt-2">
|
||||
<div class="card-header bg-dark text-white fw-semibold py-2">
|
||||
<i class="bi bi-bar-chart-fill me-2"></i>Overall Year Summary — <?= esc($selectedYear) ?>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<?php foreach ([
|
||||
[$totalWinners, 'Total Winners', 'warning', 'text-dark', $pctWinners . '% of students', null],
|
||||
[$totalStudents, 'Total Students', 'secondary', 'text-white', count($classResults) . ' classes', null],
|
||||
[$totalBoys, 'Total Boys', null, null, $pctBoys . '%', '#4A90E2'],
|
||||
[$totalGirls, 'Total Girls', null, null, $pctGirls . '%', '#E47AB0'],
|
||||
[$totalTrophyBoys, 'Trophy Boys', null, null, $pctTrophyBoys . '% of boys', '#4A90E2'],
|
||||
[$totalTrophyGirls, 'Trophy Girls', null, null, $pctTrophyGirls . '% of girls', '#E47AB0'],
|
||||
] as [$val, $label, $bg, $tc, $sub, $color]): ?>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="border rounded text-center p-3 h-100">
|
||||
<div class="fs-3 fw-bold" <?= isset($color) ? 'style="color:' . $color . '"' : ($bg ? 'class="text-' . $bg . '"' : '') ?>>
|
||||
<?= $val ?>
|
||||
</div>
|
||||
<div class="small text-muted"><?= $label ?></div>
|
||||
<div class="badge mt-1 <?= $bg ? 'bg-' . $bg . ' ' . $tc : 'text-white' ?>"
|
||||
<?= isset($color) && !$bg ? 'style="background:' . $color . '"' : '' ?>>
|
||||
<?= $sub ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mt-1">
|
||||
<!-- Stacked bar: trophies per class (boys + girls) -->
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="small fw-semibold text-muted mb-2">
|
||||
<i class="bi bi-trophy-fill text-warning me-1"></i>Trophy Winners per Class
|
||||
</div>
|
||||
<canvas id="chart-per-class" style="max-height:260px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Doughnut: trophy gender split + rate per class -->
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="row g-3 h-100">
|
||||
<div class="col-12">
|
||||
<div class="border rounded p-3">
|
||||
<div class="small fw-semibold text-muted mb-2">
|
||||
<i class="bi bi-gender-ambiguous me-1"></i>Winners by Gender
|
||||
</div>
|
||||
<canvas id="chart-gender" style="max-height:160px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="border rounded p-3">
|
||||
<div class="small fw-semibold text-muted mb-2">
|
||||
<i class="bi bi-bar-chart-fill me-1"></i>Trophy Rate per Class (%)
|
||||
</div>
|
||||
<canvas id="chart-rate" style="max-height:130px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /screen-only -->
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
<?php
|
||||
$chartLabels = [];
|
||||
$chartBoys = [];
|
||||
$chartGirls = [];
|
||||
$chartRates = [];
|
||||
foreach ($classResults as $cls) {
|
||||
$chartLabels[] = $cls['section_name'];
|
||||
$chartBoys[] = $cls['trophy_boys'];
|
||||
$chartGirls[] = $cls['trophy_girls'];
|
||||
$chartRates[] = $cls['pct_trophy_total'];
|
||||
}
|
||||
?>
|
||||
var labels = <?= json_encode($chartLabels) ?>;
|
||||
var boys = <?= json_encode($chartBoys) ?>;
|
||||
var girls = <?= json_encode($chartGirls) ?>;
|
||||
var rates = <?= json_encode($chartRates) ?>;
|
||||
|
||||
var BOY_COLOR = '#4A90E2';
|
||||
var GIRL_COLOR = '#E47AB0';
|
||||
var RATE_COLOR = '#f0a500';
|
||||
|
||||
/* ── Chart 1: stacked horizontal bar per class ── */
|
||||
new Chart(document.getElementById('chart-per-class'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{ label: 'Boys', data: boys, backgroundColor: BOY_COLOR, stack: 'winners' },
|
||||
{ label: 'Girls', data: girls, backgroundColor: GIRL_COLOR, stack: 'winners' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { stacked: true, ticks: { stepSize: 1 }, grid: { color: '#f0f0f0' } },
|
||||
y: { stacked: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Chart 2: doughnut gender split ── */
|
||||
new Chart(document.getElementById('chart-gender'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Trophy Boys', 'Trophy Girls'],
|
||||
datasets: [{
|
||||
data: [<?= $totalTrophyBoys ?>, <?= $totalTrophyGirls ?>],
|
||||
backgroundColor: [BOY_COLOR, GIRL_COLOR],
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
var total = ctx.dataset.data.reduce(function(a,b){ return a+b; }, 0);
|
||||
var pct = total > 0 ? Math.round(ctx.parsed / total * 100) : 0;
|
||||
return ctx.label + ': ' + ctx.parsed + ' (' + pct + '%)';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Chart 3: bar chart rate per class ── */
|
||||
new Chart(document.getElementById('chart-rate'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Trophy Rate %',
|
||||
data: rates,
|
||||
backgroundColor: rates.map(function(r) {
|
||||
return r > 30 ? '#dc3545' : r > 20 ? RATE_COLOR : '#28a745';
|
||||
}),
|
||||
borderRadius: 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true, max: 100,
|
||||
ticks: { callback: function(v) { return v + '%'; } },
|
||||
grid: { color: '#f0f0f0' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<!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-12">
|
||||
<div class="field-label">Issued On</div>
|
||||
<div class="field-value">
|
||||
<?= $record['issued_at'] ? esc(date('F j, Y \a\t g:i A', strtotime($record['issued_at']))) : '—' ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="card-footer text-muted small text-center py-2">
|
||||
This certificate was officially issued by Al Rahma Sunday School.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
|
||||
<!-- Not found -->
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-4 text-center">
|
||||
<span class="badge badge-invalid text-white px-3 py-2 fs-6 mb-3 d-inline-block">
|
||||
<i class="bi bi-x-circle-fill me-1"></i>Not Found
|
||||
</span>
|
||||
<p class="mb-0">No certificate matching this code was found in our records.<br>
|
||||
Please check the code and try again.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="text-center text-muted small mt-4">
|
||||
© <?= date('Y') ?> Al Rahma Sunday School
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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() ?>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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) |
|
||||
Year score < 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() ?>
|
||||
@@ -9,15 +9,26 @@
|
||||
|
||||
<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' : 'Fall' ?> • <?= esc($schoolYear ?? '') ?>
|
||||
</div>
|
||||
|
||||
<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' => 'year',
|
||||
'school_year' => $schoolYear ?? '',
|
||||
])) ?>">
|
||||
Decisions
|
||||
</a>
|
||||
<?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>
|
||||
<?php if (!empty($canViewGrading)): ?>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="<?= base_url('grading') ?>">
|
||||
Back to Grading
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="text-muted small">You do not have access to the Grading page.</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
@@ -25,11 +36,17 @@
|
||||
if ($value === null || $value === '') {
|
||||
return '—';
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return esc(number_format((float)$value, 2, '.', ''));
|
||||
}
|
||||
|
||||
return esc($value);
|
||||
};
|
||||
|
||||
// Fall mode uses Fall.
|
||||
// Whole Year mode uses year.
|
||||
$actionSemester = !empty($isYearMode) ? 'year' : 'fall';
|
||||
?>
|
||||
|
||||
<?php if (empty($rows)): ?>
|
||||
@@ -38,30 +55,28 @@
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive below-sixty-table">
|
||||
<table class="table table-bordered table-striped align-middle w-100 no-mgmt-sticky below-sixty-dt" data-no-mgmt-sticky data-no-dt-fixedheader>
|
||||
<table class="table table-bordered table-striped align-middle w-100 no-mgmt-sticky below-sixty-dt"
|
||||
data-no-mgmt-sticky
|
||||
data-no-dt-fixedheader>
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Student Name</th>
|
||||
<th>Section</th>
|
||||
<th>Hwk Avg</th>
|
||||
<th>Project Avg</th>
|
||||
<th>Participation</th>
|
||||
<th>Test Avg</th>
|
||||
<th>PTAP Score</th>
|
||||
<th>Attendance</th>
|
||||
<th>Midterm Score</th>
|
||||
<th><?= strcasecmp($semester ?? '', 'fall') === 0 ? '1st Semester Score' : 'Semester Score' ?></th>
|
||||
<th class="text-center">Score</th>
|
||||
<th>Status</th>
|
||||
<th>Email Parent</th>
|
||||
<th>Schedule Meeting</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<?php foreach ($rows as $row): ?>
|
||||
<?php
|
||||
$scoreRaw = $row['semester_score'] ?? null;
|
||||
$scoreVal = is_numeric($scoreRaw) ? (float)$scoreRaw : null;
|
||||
|
||||
$scoreClass = '';
|
||||
|
||||
if ($scoreVal !== null) {
|
||||
if ($scoreVal < 50) {
|
||||
$scoreClass = 'grade-red';
|
||||
@@ -69,50 +84,92 @@
|
||||
$scoreClass = 'grade-orange';
|
||||
}
|
||||
}
|
||||
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||
|
||||
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||
$studentLabel = $studentName !== '' ? $studentName : 'N/A';
|
||||
$isClosed = ($row['status'] ?? 'Open') === 'Closed';
|
||||
?>
|
||||
<?php $isClosed = ($row['status'] ?? 'Open') === 'Closed'; ?>
|
||||
|
||||
<tr class="<?= esc($scoreClass) ?>">
|
||||
<td><?= esc($studentName !== '' ? $studentName : 'N/A') ?></td>
|
||||
<td><?= esc($studentLabel) ?></td>
|
||||
<td><?= esc($row['class_section_name'] ?? '—') ?></td>
|
||||
<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>
|
||||
<td class="text-center"><?= $displayScore($row['test_avg'] ?? null) ?></td>
|
||||
<td class="text-center"><?= $displayScore($row['ptap_score'] ?? null) ?></td>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<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($studentLabel) ?>"
|
||||
data-school-year="<?= esc((string)($schoolYear ?? '')) ?>">
|
||||
Details
|
||||
</button>
|
||||
</td>
|
||||
|
||||
<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() ?>
|
||||
<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 ?? '')) ?>">
|
||||
<select name="status" class="form-select form-select-sm" style="width: 110px;">
|
||||
<option value="Open" <?= ($row['status'] ?? 'Open') === 'Open' ? 'selected' : '' ?>>Open</option>
|
||||
<option value="Closed" <?= ($row['status'] ?? '') === 'Closed' ? 'selected' : '' ?>>Closed</option>
|
||||
|
||||
<input type="hidden"
|
||||
name="student_id"
|
||||
value="<?= esc((string)($row['student_id'] ?? '')) ?>">
|
||||
|
||||
<input type="hidden"
|
||||
name="semester"
|
||||
value="<?= esc($actionSemester) ?>">
|
||||
|
||||
<input type="hidden"
|
||||
name="school_year"
|
||||
value="<?= esc((string)($schoolYear ?? '')) ?>">
|
||||
|
||||
<select name="status"
|
||||
class="form-select form-select-sm"
|
||||
style="width: 110px;">
|
||||
<option value="Open" <?= ($row['status'] ?? 'Open') === 'Open' ? 'selected' : '' ?>>
|
||||
Open
|
||||
</option>
|
||||
<option value="Closed" <?= ($row['status'] ?? '') === 'Closed' ? 'selected' : '' ?>>
|
||||
Closed
|
||||
</option>
|
||||
</select>
|
||||
<input type="text" name="note" class="form-control form-control-sm" style="width: 140px;" placeholder="Note (optional)" value="<?= esc((string)($row['note'] ?? '')) ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Update</button>
|
||||
|
||||
<input type="text"
|
||||
name="note"
|
||||
class="form-control form-control-sm"
|
||||
style="width: 140px;"
|
||||
placeholder="Note (optional)"
|
||||
value="<?= esc((string)($row['note'] ?? '')) ?>">
|
||||
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
Update
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
<td class="text-center">
|
||||
<?php if ($isClosed): ?>
|
||||
<button type="button" class="btn btn-sm btn-secondary" disabled>Send Email</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" disabled>
|
||||
Send Email
|
||||
</button>
|
||||
<?php else: ?>
|
||||
<a class="btn btn-sm btn-outline-primary"
|
||||
href="<?= site_url('grading/below-60/email/edit?student_id=' . (int)($row['student_id'] ?? 0) . '&semester=' . rawurlencode((string)($semester ?? '')) . '&school_year=' . rawurlencode((string)($schoolYear ?? ''))) ?>">
|
||||
href="<?= site_url('grading/below-60/email/edit?student_id=' . (int)($row['student_id'] ?? 0) . '&semester=' . rawurlencode($actionSemester) . '&school_year=' . rawurlencode((string)($schoolYear ?? ''))) ?>">
|
||||
Send Email
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
|
||||
<td class="text-center">
|
||||
<?php if ($isClosed): ?>
|
||||
<button class="btn btn-sm btn-secondary" disabled>Schedule</button>
|
||||
<button class="btn btn-sm btn-secondary" disabled>
|
||||
Schedule
|
||||
</button>
|
||||
<?php else: ?>
|
||||
<a class="btn btn-sm btn-outline-secondary"
|
||||
href="<?= site_url('grading/below-60/schedule?student_id=' . (int)($row['student_id'] ?? 0) . '&semester=' . rawurlencode((string)($semester ?? '')) . '&school_year=' . rawurlencode((string)($schoolYear ?? ''))) ?>">
|
||||
href="<?= site_url('grading/below-60/schedule?student_id=' . (int)($row['student_id'] ?? 0) . '&semester=' . rawurlencode($actionSemester) . '&school_year=' . rawurlencode((string)($schoolYear ?? ''))) ?>">
|
||||
Schedule
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
@@ -126,30 +183,266 @@
|
||||
</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>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<style>
|
||||
.grade-orange td { background: #fff3cd !important; color: #8a5d00; font-weight: 600; }
|
||||
.grade-red td { background: #f8d7da !important; color: #842029; font-weight: 600; }
|
||||
.below-sixty-wrapper { padding-top: 0.75rem; }
|
||||
.below-sixty-title { position: relative; z-index: 1; margin-bottom: 1.25rem; }
|
||||
.below-sixty-table { margin-top: 0.75rem; }
|
||||
.grade-orange td {
|
||||
background: #fff3cd !important;
|
||||
color: #8a5d00;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.grade-red td {
|
||||
background: #f8d7da !important;
|
||||
color: #842029;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.below-sixty-wrapper {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.below-sixty-title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.below-sixty-table {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.below-sixty-dt td {
|
||||
vertical-align: top;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
if (!window.$ || !$.fn || !$.fn.DataTable) return;
|
||||
$(function() {
|
||||
(function () {
|
||||
function normalizeSemesterFilter() {
|
||||
const semesterSelect = document.querySelector('select[name="semester"]');
|
||||
|
||||
if (!semesterSelect) return;
|
||||
|
||||
const wholeYearSelected = <?= !empty($isYearMode) ? 'true' : 'false' ?>;
|
||||
|
||||
semesterSelect.innerHTML = '';
|
||||
semesterSelect.add(new Option('Fall', 'fall', !wholeYearSelected, !wholeYearSelected));
|
||||
semesterSelect.add(new Option('Whole Year', 'year', wholeYearSelected, wholeYearSelected));
|
||||
semesterSelect.value = wholeYearSelected ? 'year' : 'fall';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', normalizeSemesterFilter);
|
||||
|
||||
if (window.$ && $.fn && $.fn.DataTable) {
|
||||
$(function () {
|
||||
const table = $('.below-sixty-dt');
|
||||
|
||||
if (!table.length) return;
|
||||
|
||||
try {
|
||||
table.DataTable({
|
||||
order: [[8, 'asc']],
|
||||
order: [[2, 'asc']],
|
||||
pageLength: 100,
|
||||
lengthMenu: [10, 25, 50, 100, 200]
|
||||
lengthMenu: [10, 25, 50, 100, 200],
|
||||
columnDefs: [
|
||||
{ orderable: false, targets: [3, 4, 5] }
|
||||
]
|
||||
});
|
||||
} catch (_) {}
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
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(value) {
|
||||
if (value === null || value === '' || value === undefined) return '—';
|
||||
|
||||
const parsed = parseFloat(value);
|
||||
|
||||
return isNaN(parsed) ? value : parsed.toFixed(2);
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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>';
|
||||
|
||||
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 hasScoreRow = false;
|
||||
|
||||
Object.entries(SCORE_LABELS).forEach(function (entry) {
|
||||
const key = entry[0];
|
||||
const label = entry[1];
|
||||
const value = sem[key];
|
||||
|
||||
if (value === null || value === '' || value === undefined) return;
|
||||
|
||||
const bold = key === 'semester_score' ? ' fw-bold' : '';
|
||||
|
||||
html += '<tr><td>' + esc(label) + '</td><td class="text-center' + bold + '">' + esc(fmtScore(value)) + '</td></tr>';
|
||||
hasScoreRow = true;
|
||||
});
|
||||
|
||||
if (!hasScoreRow) {
|
||||
html += '<tr><td colspan="2" class="text-muted">No scores recorded.</td></tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
|
||||
const comments = sem.comments || {};
|
||||
|
||||
const commentEntries = Object.entries(comments).filter(function (entry) {
|
||||
return entry[1] && String(entry[1]).trim();
|
||||
});
|
||||
|
||||
const seen = {};
|
||||
const deduped = [];
|
||||
|
||||
commentEntries.forEach(function (entry) {
|
||||
const type = entry[0];
|
||||
const text = String(entry[1]);
|
||||
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 (entry) {
|
||||
const label = entry[0];
|
||||
const text = entry[1];
|
||||
|
||||
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 (response) {
|
||||
return response.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (data.error) {
|
||||
detailsModalBody.innerHTML = '<div class="alert alert-danger">' + esc(data.error) + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
detailsModalBody.innerHTML = buildDetailsHtml(data.semesters);
|
||||
})
|
||||
.catch(function () {
|
||||
detailsModalBody.innerHTML = '<div class="alert alert-danger">Failed to load details.</div>';
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
<?= $this->endSection() ?>
|
||||
@@ -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)): ?>
|
||||
— <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() ?>
|
||||
@@ -0,0 +1,685 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<?php
|
||||
// This page is Whole Year only.
|
||||
// Do not expose semester filter here.
|
||||
$semester = 'year';
|
||||
?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="wrapper below-sixty-decisions-wrapper">
|
||||
<h2 class="text-center mt-4 mb-4">Below 60 — Whole Year Decisions</h2>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<div class="text-muted">
|
||||
Whole Year • <?= esc($schoolYear ?? '') ?>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
href="<?= site_url('grading/below-60?' . http_build_query([
|
||||
'semester' => 'year',
|
||||
'school_year' => $schoolYear ?? '',
|
||||
])) ?>">
|
||||
← Back to Below 60
|
||||
</a>
|
||||
|
||||
<a class="btn btn-outline-primary btn-sm"
|
||||
href="<?= site_url('grading/decisions?' . http_build_query([
|
||||
'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 the whole year.
|
||||
</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">Year Score</th>
|
||||
<th style="min-width:300px">Comments / Rationale & 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
|
||||
// Whole-year score. Prefer year_score if the controller provides it.
|
||||
// Fall/Spring details remain available through the Details modal.
|
||||
$scoreRaw = $row['year_score'] ?? $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'] ?? ''));
|
||||
$studentLabel = $studentName !== '' ? $studentName : 'N/A';
|
||||
$currentDecision = (string)($row['decision'] ?? '');
|
||||
$currentNotes = (string)($row['decision_notes'] ?? '');
|
||||
$badge = $decisionBadge[$currentDecision] ?? null;
|
||||
|
||||
$finalDecision = $row['consolidated_decision'] ?? null;
|
||||
$finalBadge = $finalDecision !== null ? ($decisionBadge[$finalDecision] ?? 'secondary') : null;
|
||||
|
||||
$certNumber = (string)($row['certificate_number'] ?? '');
|
||||
?>
|
||||
|
||||
<tr class="<?= esc($rowClass) ?>">
|
||||
<td><?= esc($studentLabel) ?></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($studentLabel) ?>"
|
||||
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="year">
|
||||
|
||||
<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="year"
|
||||
data-school-year="<?= esc((string)($schoolYear ?? '')) ?>">
|
||||
Send Email
|
||||
</button>
|
||||
<?php else: ?>
|
||||
<span class="text-muted small">Pending</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
|
||||
<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([
|
||||
'school_year' => $schoolYear ?? '',
|
||||
])) ?>"
|
||||
class="text-muted small">
|
||||
Generate
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
|
||||
<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" value="year">
|
||||
<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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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>';
|
||||
|
||||
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>' + esc(label) + '</td><td class="text-center' + bold + '">' + esc(fmtScore(v)) + '</td></tr>';
|
||||
hasRow = true;
|
||||
});
|
||||
|
||||
if (!hasRow) {
|
||||
html += '<tr><td colspan="2" class="text-muted">No scores recorded.</td></tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
|
||||
const comments = sem.comments || {};
|
||||
|
||||
const commentEntries = Object.entries(comments).filter(function ([, v]) {
|
||||
return v && String(v).trim();
|
||||
});
|
||||
|
||||
const seen = {};
|
||||
const deduped = [];
|
||||
|
||||
commentEntries.forEach(function ([type, text]) {
|
||||
const label = COMMENT_TYPE_LABELS[type] || type;
|
||||
const key = label + '|' + String(text).trim();
|
||||
|
||||
if (!seen[key]) {
|
||||
seen[key] = true;
|
||||
deduped.push([label, String(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">' + esc(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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function () {
|
||||
if (window.tinymce) {
|
||||
const ed = tinymce.get('decisionEmailEditor');
|
||||
|
||||
if (ed && htmlHidden) {
|
||||
htmlHidden.value = ed.getContent({ format: 'html' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
modal.addEventListener('hidden.bs.modal', function () {
|
||||
destroyEditor();
|
||||
showPane('loading');
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('.btn-send-email');
|
||||
|
||||
if (!btn) return;
|
||||
|
||||
const studentId = btn.dataset.studentId;
|
||||
const schoolYear = btn.dataset.schoolYear;
|
||||
|
||||
studentIdIn.value = studentId;
|
||||
semesterIn.value = 'year';
|
||||
schoolYearIn.value = schoolYear;
|
||||
|
||||
showPane('loading');
|
||||
|
||||
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
|
||||
bsModal.show();
|
||||
|
||||
const url = '<?= site_url('grading/below-60/decisions/email/preview') ?>'
|
||||
+ '?student_id=' + encodeURIComponent(studentId)
|
||||
+ '&semester=year'
|
||||
+ '&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 || '';
|
||||
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() ?>
|
||||
@@ -32,7 +32,7 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="d-flex justify-content-between mt-4 flex-wrap gap-2">
|
||||
<button type="submit" class="btn btn-success" <?= $lockAttr ?>>
|
||||
<button type="submit" form="commentsForm" class="btn btn-success" <?= $lockAttr ?>>
|
||||
<i class="bi bi-save"></i> Save All Changes
|
||||
</button>
|
||||
<a href="<?= base_url('/grading') ?>" class="btn btn-secondary">
|
||||
@@ -177,7 +177,10 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
|
||||
</td>
|
||||
<?php elseif ($semester === 'Spring'): ?>
|
||||
<td>
|
||||
<?php $finalCommentId = 'final-comment-' . $studentId; ?>
|
||||
<?php $finalReviewId = 'final-review-' . $studentId; ?>
|
||||
<textarea
|
||||
id="<?= esc($finalCommentId) ?>"
|
||||
name="comments[<?= $studentId ?>][final]"
|
||||
rows="2"
|
||||
class="form-control"
|
||||
@@ -186,7 +189,6 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
|
||||
maxlength="350"
|
||||
placeholder="Enter Final comment" <?= $lockAttr ?>><?= esc($comments[$studentId]['final']['comment'] ?? '') ?></textarea>
|
||||
</td>
|
||||
<?php $finalReviewId = 'final-review-' . $studentId; ?>
|
||||
<td>
|
||||
<div class="d-flex gap-2 align-items-start">
|
||||
<textarea
|
||||
@@ -196,6 +198,13 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
|
||||
class="form-control review-field"
|
||||
placeholder="Enter Final review" <?= $lockAttr ?>><?= esc($comments[$studentId]['final']['comment_review'] ?? '') ?></textarea>
|
||||
<div class="d-flex flex-column gap-1 align-items-stretch">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary btn-sm copy-final-btn"
|
||||
data-copy-source="#<?= esc($finalCommentId) ?>"
|
||||
data-copy-target="#<?= esc($finalReviewId) ?>" <?= $lockAttr ?>>
|
||||
Paste
|
||||
</button>
|
||||
<div class="small text-muted proofread-status" data-status-for="<?= esc($finalReviewId) ?>"></div>
|
||||
<ul class="proofread-list small mb-0" data-list-for="<?= esc($finalReviewId) ?>"></ul>
|
||||
</div>
|
||||
@@ -474,18 +483,9 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
|
||||
});
|
||||
};
|
||||
|
||||
document.querySelectorAll('.copy-ptap-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const source = document.querySelector(this.dataset.copySource || '');
|
||||
const target = document.querySelector(this.dataset.copyTarget || '');
|
||||
if (!source || !target) return;
|
||||
target.value = source.value;
|
||||
target.style.height = 'auto';
|
||||
target.style.height = (target.scrollHeight) + 'px';
|
||||
target.focus();
|
||||
});
|
||||
});
|
||||
wireCopyButtons('.copy-ptap-btn');
|
||||
wireCopyButtons('.copy-midterm-btn');
|
||||
wireCopyButtons('.copy-final-btn');
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
container_name: mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: password
|
||||
MYSQL_DATABASE: school
|
||||
MYSQL_USER: root
|
||||
MYSQL_PASSWORD: password
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
|
||||
phpmyadmin:
|
||||
image: phpmyadmin/phpmyadmin:latest
|
||||
platform: linux/amd64
|
||||
container_name: phpmyadmin
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PMA_HOST: mysql
|
||||
PMA_PORT: 3306
|
||||
ports:
|
||||
- "8081:80"
|
||||
depends_on:
|
||||
- mysql
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,10 @@ body.centered {
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 200px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS `ip_attempts` (
|
||||
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`ip_address` VARCHAR(45) NOT NULL,
|
||||
`attempts` INT DEFAULT 0 NOT NULL,
|
||||
`last_attempt_at` DATETIME NOT NULL,
|
||||
`blocked_until` DATETIME DEFAULT NULL,
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY (`ip_address`)
|
||||
);
|
||||
@@ -1,59 +0,0 @@
|
||||
CREATE TABLE
|
||||
IF NOT EXISTS `users`
|
||||
(
|
||||
`id` int UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`password` varchar
|
||||
(255) NOT NULL,
|
||||
`lastname` varchar
|
||||
(255) NOT NULL,
|
||||
`firstname` varchar
|
||||
(255) NOT NULL,
|
||||
`gender` enum
|
||||
('Male','Female') CHARACTER
|
||||
SET utf8mb3
|
||||
COLLATE utf8mb3_general_ci DEFAULT NULL,
|
||||
`cellphone` varchar
|
||||
(25) NOT NULL,
|
||||
`email` varchar
|
||||
(255) NOT NULL,
|
||||
`address_street` varchar
|
||||
(255) NOT NULL,
|
||||
`apt` varchar
|
||||
(25) DEFAULT NULL,
|
||||
`city` varchar
|
||||
(255) NOT NULL,
|
||||
`state` varchar
|
||||
(25) NOT NULL,
|
||||
`zip` varchar
|
||||
(25) NOT NULL,
|
||||
`accept_school_policy` tinyint
|
||||
(1) NOT NULL,
|
||||
`created_at` datetime DEFAULT NULL,
|
||||
`updated_at` datetime DEFAULT NULL,
|
||||
`token` varchar
|
||||
(255) DEFAULT NULL,
|
||||
`is_verified` tinyint
|
||||
(1) NOT NULL DEFAULT '0',
|
||||
`account_id` varchar
|
||||
(255) DEFAULT NULL,
|
||||
`status` varchar
|
||||
(10) NOT NULL DEFAULT 'Inactive',
|
||||
`user_type` enum
|
||||
('primary','secondary','tertiary') CHARACTER
|
||||
SET utf8mb3
|
||||
COLLATE utf8mb3_general_ci DEFAULT 'primary',
|
||||
`semester` varchar
|
||||
(255) NOT NULL,
|
||||
`school_year` varchar
|
||||
(9) DEFAULT NULL,
|
||||
`rfid_tag` varchar
|
||||
(100) CHARACTER
|
||||
SET utf8mb3
|
||||
COLLATE utf8mb3_general_ci DEFAULT NULL,
|
||||
`failed_attempts` INT DEFAULT 0 NOT NULL,
|
||||
`last_failed_at` DATETIME DEFAULT NULL,
|
||||
`is_suspended` TINYINT
|
||||
(1) DEFAULT 0 NOT NULL,
|
||||
PRIMARY KEY
|
||||
(`id`)
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Filters;
|
||||
|
||||
use App\Filters\ApiRateLimitFilter;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
use CodeIgniter\HTTP\Response;
|
||||
use CodeIgniter\HTTP\URI;
|
||||
use CodeIgniter\HTTP\UserAgent;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use Config\App;
|
||||
use Config\Services;
|
||||
|
||||
class ApiRateLimitFilterTest extends CIUnitTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Services::resetSingle('throttler');
|
||||
Services::resetSingle('session');
|
||||
}
|
||||
|
||||
public function testBlocksRequestWhenThresholdIsExceeded(): void
|
||||
{
|
||||
$request = new IncomingRequest(config(App::class), new URI('https://example.test/api/health'), 'php://input', new UserAgent());
|
||||
$filter = new ApiRateLimitFilter();
|
||||
|
||||
$first = $filter->before($request, [1, 60]);
|
||||
$second = $filter->before($request, [1, 60]);
|
||||
|
||||
$this->assertNull($first);
|
||||
$this->assertInstanceOf(Response::class, $second);
|
||||
$this->assertSame(429, $second->getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Filters;
|
||||
|
||||
use App\Filters\SanitizeInputFilter;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
use CodeIgniter\HTTP\Response;
|
||||
use CodeIgniter\HTTP\URI;
|
||||
use CodeIgniter\HTTP\UserAgent;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use Config\App;
|
||||
use Config\Services;
|
||||
|
||||
class SanitizeInputFilterTest extends CIUnitTestCase
|
||||
{
|
||||
public function testSanitizesGetPostAndJsonPayloads(): void
|
||||
{
|
||||
$request = new IncomingRequest(config(App::class), new URI('https://example.test/api/login'), 'php://input', new UserAgent());
|
||||
$request->setHeader('Content-Type', 'application/json');
|
||||
$request->setGlobal('get', ['q' => " hello\x00 "]);
|
||||
$request->setGlobal('post', ['email' => " user@example.com \n"]);
|
||||
$request->setGlobal('request', ['email' => " user@example.com \n"]);
|
||||
$request->setGlobal('cookie', ['token' => " abc\t"]);
|
||||
$request->setBody("{\"email\":\" user@example.com \",\"name\":\" Bob\\u0000 \"}");
|
||||
|
||||
$filter = new SanitizeInputFilter();
|
||||
$result = $filter->before($request);
|
||||
|
||||
$this->assertNull($result);
|
||||
$this->assertSame('hello', $request->getGet('q'));
|
||||
$this->assertSame('user@example.com', $request->getPost('email'));
|
||||
$this->assertSame('abc', $request->getCookie('token'));
|
||||
$this->assertSame(['email' => 'user@example.com', 'name' => 'Bob'], $request->getJSON(true));
|
||||
}
|
||||
|
||||
public function testRejectsMalformedJsonPayload(): void
|
||||
{
|
||||
$request = new IncomingRequest(config(App::class), new URI('https://example.test/api/login'), 'php://input', new UserAgent());
|
||||
$request->setHeader('Content-Type', 'application/json');
|
||||
$request->setBody('{invalid');
|
||||
|
||||
$filter = new SanitizeInputFilter();
|
||||
$result = $filter->before($request);
|
||||
|
||||
$this->assertInstanceOf(Response::class, $result);
|
||||
$this->assertSame(400, $result->getStatusCode());
|
||||
}
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user