Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 239d35f8ff | |||
| 3333e71c94 | |||
| af75475214 | |||
| a8ef665239 | |||
| 7c97a60795 | |||
| acca87c77b | |||
| 9aacbe8972 | |||
| 76866ffe1e | |||
| b01a4496e6 | |||
| 1f51be91e4 | |||
| 92016f90d0 | |||
| 45d7895182 | |||
| 596d368b1d | |||
| 35988e9ce1 | |||
| 5fc4d8be30 | |||
| dcfd1f9542 | |||
| b99f816261 | |||
| 931e30b6f1 | |||
| 189bb1a628 | |||
| f04934039e | |||
| c13dcb07d1 | |||
| dd1d492c30 | |||
| a448288878 | |||
| 821e9cdc81 |
@@ -1,184 +0,0 @@
|
||||
#--------------------------------------------------------------------
|
||||
# Example Environment Configuration file
|
||||
#
|
||||
# This file can be used as a starting point for your own
|
||||
# custom .env files, and contains most of the possible settings
|
||||
# available in a default install.
|
||||
#
|
||||
# By default, all of the settings are commented out. If you want
|
||||
# to override the setting, you must un-comment it by removing the '#'
|
||||
# at the beginning of the line.
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# ENVIRONMENT
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
CI_ENVIRONMENT = development
|
||||
|
||||
# Which profile to use if none is supplied to sendEmail()
|
||||
# Options: default, communication, payment, ...
|
||||
MAIL_PROFILE_DEFAULT=default
|
||||
|
||||
# ===== DEFAULT (system) =====
|
||||
# ---- Legacy fallbacks (kept for compatibility) ----
|
||||
SMTP_HOST = smtp.gmail.com
|
||||
SMTP_USER = alrahma.sunday.school@gmail.com
|
||||
SMTP_PASS = "psnp emdq dykw ypul"
|
||||
SMTP_PORT = 587
|
||||
SMTP_ENCRYPTION = tls # was ssl; use tls for port 587
|
||||
|
||||
# Reply-To for all mail profiles
|
||||
MAIL_DEFAULT_REPLY_TO=alrahma.isgl@gmail.com
|
||||
MAIL_DEFAULT_REPLY_TO_NAME="Al Rahma Sunday School"
|
||||
MAIL_COMMUNICATION_REPLY_TO=alrahma.isgl@gmail.com
|
||||
MAIL_COMMUNICATION_REPLY_TO_NAME="School Communications"
|
||||
MAIL_PAYMENT_REPLY_TO=alrahma.isgl@gmail.com
|
||||
MAIL_PAYMENT_REPLY_TO_NAME="School Payments"
|
||||
|
||||
# Optional sender directory you already use elsewhere
|
||||
MAIL_SENDERS="{\"general\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma No-Reply\"},\
|
||||
\"registration\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma Register Office\"},\
|
||||
\"notifications\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma Notifications\"},\
|
||||
\"finance\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma Finance Office\"}}"
|
||||
|
||||
# Principal notification recipient for TimeOff requests (comma-separated allowed)
|
||||
PRINCIPAL_EMAIL="melabidi@alrahmaisgl.org"
|
||||
MAIL_PARENT_REPORT_TO="alrahma.isgl@gmail.com"
|
||||
MAIL_FROM_ADDRESS = "alrahma.sunday.school@gmail.com"
|
||||
|
||||
session.expiration = 43200
|
||||
#--------------------------------------------------------------------
|
||||
# APP
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
# If you have trouble with `.`, you could also use `_`.
|
||||
app_baseURL = 'http://localhost:8080/'
|
||||
# app.forceGlobalSecureRequests = true
|
||||
# app.CSPEnabled = false
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# DATABASE
|
||||
#--------------------------------------------------------------------
|
||||
database.default.hostname = 127.0.0.1
|
||||
database.default.database = school
|
||||
database.default.username = root
|
||||
database.default.password = rootpassword
|
||||
database.default.DBDriver = MySQLi
|
||||
database.default.DBPrefix =
|
||||
database.default.port = 3306
|
||||
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# CONTENT SECURITY POLICY
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
# contentsecuritypolicy.reportOnly = false
|
||||
# contentsecuritypolicy.defaultSrc = 'none'
|
||||
# contentsecuritypolicy.scriptSrc = 'self'
|
||||
# contentsecuritypolicy.styleSrc = 'self'
|
||||
# contentsecuritypolicy.imageSrc = 'self'
|
||||
# contentsecuritypolicy.baseURI = null
|
||||
# contentsecuritypolicy.childSrc = null
|
||||
# contentsecuritypolicy.connectSrc = 'self'
|
||||
# contentsecuritypolicy.fontSrc = null
|
||||
# contentsecuritypolicy.formAction = null
|
||||
# contentsecuritypolicy.frameAncestors = null
|
||||
# contentsecuritypolicy.frameSrc = null
|
||||
# contentsecuritypolicy.mediaSrc = null
|
||||
# contentsecuritypolicy.objectSrc = null
|
||||
# contentsecuritypolicy.pluginTypes = null
|
||||
# contentsecuritypolicy.reportURI = null
|
||||
# contentsecuritypolicy.sandbox = false
|
||||
# contentsecuritypolicy.upgradeInsecureRequests = false
|
||||
# contentsecuritypolicy.styleNonceTag = '{csp-style-nonce}'
|
||||
# contentsecuritypolicy.scriptNonceTag = '{csp-script-nonce}'
|
||||
# contentsecuritypolicy.autoNonce = true
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# COOKIE
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
# cookie.prefix = ''
|
||||
# cookie.expires = 0
|
||||
# cookie.path = '/'
|
||||
# cookie.domain = ''
|
||||
# cookie.secure = false
|
||||
# cookie.httponly = false
|
||||
# cookie.samesite = 'Lax'
|
||||
# cookie.raw = false
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# ENCRYPTION
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
# encryption.key =
|
||||
# encryption.driver = OpenSSL
|
||||
# encryption.blockSize = 16
|
||||
# encryption.digest = SHA512
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# HONEYPOT
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
# honeypot.hidden = 'true'
|
||||
# honeypot.label = 'Fill This Field'
|
||||
# honeypot.name = 'honeypot'
|
||||
# honeypot.template = '<label>{label}</label><input type="text" name="{name}" value=""/>'
|
||||
# honeypot.container = '<div style="display:none">{template}</div>'
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# SECURITY
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
# security.csrfProtection = 'cookie'
|
||||
# security.tokenRandomize = false
|
||||
# security.tokenName = 'csrf_token_name'
|
||||
# security.headerName = 'X-CSRF-TOKEN'
|
||||
# security.cookieName = 'csrf_cookie_name'
|
||||
# security.expires = 7200
|
||||
# security.regenerate = true
|
||||
# security.redirect = false
|
||||
# security.samesite = 'Lax'
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# SESSION
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
# session.driver = 'CodeIgniter\Session\Handlers\FileHandler'
|
||||
# session.cookieName = 'ci_session'
|
||||
# session.expiration = 7200
|
||||
# session.savePath = null
|
||||
# session.matchIP = false
|
||||
# session.timeToUpdate = 300
|
||||
# session.regenerateDestroy = false
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# LOGGER
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
logger.threshold = 4
|
||||
MAIL_DEBUG = true
|
||||
MAIL_TIMEOUT = 15
|
||||
|
||||
#--------------------------------------------------------------------
|
||||
# CURLRequest
|
||||
#--------------------------------------------------------------------
|
||||
|
||||
# curlrequest.shareOptions = false
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
mail.protocol = smtp
|
||||
mail.SMTPHost = ${SMTP_HOST}
|
||||
mail.SMTPUser = ${SMTP_USER}
|
||||
mail.SMTPPass = ${SMTP_PASS}
|
||||
mail.SMTPPort = ${SMTP_PORT}
|
||||
mail.SMTPCrypto = tls
|
||||
|
||||
mail.fromEmail = ${SMTP_USER}
|
||||
mail.fromName = "Al Rahma Sunday School"
|
||||
mail.mailType = text
|
||||
mail.charset = UTF-8
|
||||
@@ -23,17 +23,32 @@
|
||||
|
||||
/public/build/
|
||||
/public/hot/
|
||||
/public/uploads/**
|
||||
!/public/uploads/**/index.html
|
||||
!/public/uploads/.gitkeep
|
||||
|
||||
/uploads/**
|
||||
!/uploads/**/index.html
|
||||
!/uploads/.gitkeep
|
||||
|
||||
docker-compose.override.yml
|
||||
docker-compose.local.yml
|
||||
|
||||
*.log
|
||||
.swp
|
||||
.swo
|
||||
*~
|
||||
.tmp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
.codex/
|
||||
|
||||
/coverage/
|
||||
/build/
|
||||
/builds
|
||||
/phpunit.xml.cache
|
||||
/.phpunit.result.cache
|
||||
/writable/reports/*
|
||||
/writable/test.txt
|
||||
|
||||
@@ -36,13 +36,13 @@ class SyncPaypalPayments extends BaseCommand
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->enrollmentModel = new EnrollmentModel();
|
||||
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
}
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
|
||||
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
|
||||
|
||||
$dryRun = CLI::getOption('dry-run');
|
||||
$reportOnly = CLI::getOption('report-only');
|
||||
$mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE');
|
||||
|
||||
@@ -24,6 +24,8 @@ class Filters extends BaseConfig
|
||||
'honeypot' => Honeypot::class,
|
||||
'invalidchars' => InvalidChars::class,
|
||||
'secureheaders' => SecureHeaders::class,
|
||||
'sanitizeinput' => \App\Filters\SanitizeInputFilter::class,
|
||||
'apiratelimit' => \App\Filters\ApiRateLimitFilter::class,
|
||||
'auth' => \App\Filters\AuthFilter::class, // Define the alias for your auth filter
|
||||
'apiAuth' => \App\Filters\ApiAuthFilter::class, // JWT-based API authentication
|
||||
'cleanupScheduler' => \App\Filters\CleanupScheduler::class,
|
||||
@@ -42,6 +44,8 @@ class Filters extends BaseConfig
|
||||
public array $globals = [
|
||||
'before' => [
|
||||
'timezone',
|
||||
'sanitizeinput',
|
||||
'invalidchars',
|
||||
'csrf' => ['except' => [
|
||||
// Webhooks / integrations
|
||||
'api/paypal-webhook',
|
||||
@@ -80,6 +84,7 @@ class Filters extends BaseConfig
|
||||
]],
|
||||
],
|
||||
'after' => [
|
||||
'secureheaders',
|
||||
'toolbar',
|
||||
'cleanupScheduler' => ['except' => ['cleanup/*']],
|
||||
],
|
||||
@@ -112,7 +117,7 @@ class Filters extends BaseConfig
|
||||
* @var array<string, array<string, list<string>>>
|
||||
*/
|
||||
public array $filters = [
|
||||
|
||||
'apiratelimit' => ['before' => ['api/*', 'index.php/api/*']],
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
@@ -174,6 +174,16 @@ $routes->group('administrator/subject-curriculum', ['filter' => 'auth:update_cur
|
||||
$routes->post('delete/(:num)', 'View\SubjectCurriculumController::delete/$1');
|
||||
});
|
||||
|
||||
$routes->get('administrator/trophy', 'View\TrophyController::index', ['filter' => 'auth:admin']);
|
||||
$routes->get('administrator/trophy/winners', 'View\TrophyController::winners', ['filter' => 'auth:admin']);
|
||||
$routes->get('administrator/trophy/final', 'View\TrophyController::final', ['filter' => 'auth:admin']);
|
||||
|
||||
// Certificates
|
||||
$routes->get('administrator/certificates', 'View\CertificateController::index', ['filter' => 'auth:admin']);
|
||||
$routes->post('administrator/certificates/generate', 'View\CertificateController::generate', ['filter' => 'auth:admin']);
|
||||
$routes->get('administrator/certificates/log', 'View\CertificateController::auditLog', ['filter' => 'auth:admin']);
|
||||
$routes->get('verify/(:segment)', 'View\CertificateController::verify/$1');
|
||||
|
||||
|
||||
|
||||
/*
|
||||
@@ -393,8 +403,8 @@ $routes->get('print-requests/file/(:segment)', 'PrintRequests::serveFile/$1', ['
|
||||
$routes->get('print-requests/file/(:segment)/(:alpha)', 'PrintRequests::serveFile/$1/$2', ['filter' => 'auth:teacher,teacher_assistant,admin']);
|
||||
$routes->get('uploads/print_requests/(:segment)', 'PrintRequests::serveFile/$1', ['filter' => 'auth:teacher,teacher_assistant,admin']);
|
||||
|
||||
$routes->get('exam-drafts/files/teacher/(:segment)', 'View\FilesController::examDraftTeacher/$1', ['filter' => 'auth:teacher,teacher_assistant,admin']);
|
||||
$routes->get('exam-drafts/files/final/(:segment)', 'View\FilesController::examDraftFinal/$1', ['filter' => 'auth:teacher,teacher_assistant,admin']);
|
||||
$routes->get('exam-drafts/files/teacher/(:segment)', 'View\FilesController::examDraftTeacher/$1', ['filter' => 'auth:teacher,teacher_assistant,admin,administrator,principal']);
|
||||
$routes->get('exam-drafts/files/final/(:segment)', 'View\FilesController::examDraftFinal/$1', ['filter' => 'auth:teacher,teacher_assistant,admin,administrator,principal']);
|
||||
|
||||
|
||||
|
||||
@@ -535,9 +545,13 @@ $routes->post('payment/event_charges', 'View\EventController::eventUpdate');
|
||||
|
||||
// Parent event participation
|
||||
$routes->get('administrator/event-charges', 'View\EventController::eventShow');
|
||||
$routes->get('administrator/event-charges/pdf', 'View\EventController::eventChargesPdf');
|
||||
$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1');
|
||||
$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1');
|
||||
$routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1');
|
||||
$routes->get('administrator/get-students-with-charges', 'View\EventController::getStudentsWithCharges');
|
||||
|
||||
$routes->get('parent/events', 'View\ParentController::parentEventPage');
|
||||
$routes->get('parent/events', 'View\ParentController::parentEventPage', ['filter' => 'auth:parent']);
|
||||
// parent event participation page
|
||||
$routes->post('parent/updateParticipation', 'View\ParentController::updateParticipation'); // handle parent participation updates
|
||||
|
||||
@@ -742,6 +756,9 @@ $routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorCo
|
||||
$routes->get('/administrator/exam-drafts', 'View\ExamDraftController::adminIndex', ['filter' => 'auth:admin']);
|
||||
$routes->post('/administrator/exam-drafts/review', 'View\ExamDraftController::adminReview', ['filter' => 'auth:admin']);
|
||||
$routes->post('/administrator/exam-drafts/upload-legacy', 'View\ExamDraftController::adminUploadLegacy', ['filter' => 'auth:admin']);
|
||||
$routes->get('/principal/exam-drafts', 'View\ExamDraftController::principalIndex', ['filter' => 'auth:admin']);
|
||||
$routes->post('/principal/exam-drafts/review', 'View\ExamDraftController::principalReview', ['filter' => 'auth:admin']);
|
||||
$routes->post('/principal/exam-drafts/upload-legacy', 'View\ExamDraftController::principalUploadLegacy', ['filter' => 'auth:admin']);
|
||||
|
||||
/*
|
||||
* --------------------------------------------------------------------
|
||||
|
||||
@@ -36,7 +36,7 @@ class Services extends BaseService
|
||||
/**
|
||||
* Override CI Email service to enforce a global Reply-To.
|
||||
*/
|
||||
public static function email(array $config = null, bool $getShared = true)
|
||||
public static function email(?array $config = null, bool $getShared = true)
|
||||
{
|
||||
if ($getShared) {
|
||||
return static::getSharedInstance('email', $config);
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Controllers;
|
||||
use App\Models\LoginActivityModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\UserRoleModel;
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\Events\Events;
|
||||
use App\Models\IpAttemptModel;
|
||||
use App\Models\PasswordResetModel;
|
||||
@@ -19,7 +18,7 @@ require_once APPPATH . 'Helpers/pbkdf2_helper.php';
|
||||
require_once APPPATH . 'Helpers/jwt_helper.php';
|
||||
|
||||
|
||||
class AuthController extends Controller
|
||||
class AuthController extends BaseController
|
||||
{
|
||||
protected $configModel;
|
||||
protected $userModel;
|
||||
@@ -56,17 +55,50 @@ class AuthController extends Controller
|
||||
|
||||
public function loginMask()
|
||||
{
|
||||
// Serve the login view directly here
|
||||
return view('user/login'); // Adjust the view path as needed
|
||||
$redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getGet('redirect_to') ?? ''));
|
||||
|
||||
if (session()->get('is_logged_in')) {
|
||||
if ($redirectTo !== null) {
|
||||
return redirect()->to($redirectTo);
|
||||
}
|
||||
|
||||
$roles = session()->get('roles') ?? [];
|
||||
if (empty($roles) && session()->get('role')) {
|
||||
$roles = [session()->get('role')];
|
||||
}
|
||||
|
||||
if (!empty($roles)) {
|
||||
return $this->redirectToDashboard($roles);
|
||||
}
|
||||
}
|
||||
|
||||
return view('user/login', [
|
||||
'redirect_to' => $redirectTo ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
public function login()
|
||||
{
|
||||
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);
|
||||
@@ -98,7 +130,7 @@ class AuthController extends Controller
|
||||
$this->logLoginAttempt($user['id'], $user['email'], $ip, $this->request->getUserAgent());
|
||||
|
||||
// ✅ Step 7: Call loginUser() to set session and redirect
|
||||
return $this->loginUser($user);
|
||||
return $this->loginUser($user, $redirectTo);
|
||||
} else {
|
||||
// Step 8: Password mismatch — log failed attempt
|
||||
$this->handleFailedLogin($user['id'], $user['email'], $ip);
|
||||
@@ -120,26 +152,29 @@ class AuthController extends Controller
|
||||
// JSON API: POST /api/login
|
||||
public function apiLogin()
|
||||
{
|
||||
$requestData = $this->request->getJSON(true);
|
||||
if (!$requestData) {
|
||||
// fallback to form vars
|
||||
$requestData = [
|
||||
$requestData = sanitize_request_value($this->request->getJSON(true) ?: [
|
||||
'email' => $this->request->getPost('email'),
|
||||
'password' => $this->request->getPost('password'),
|
||||
];
|
||||
}
|
||||
]);
|
||||
|
||||
$email = $requestData['email'] ?? '';
|
||||
$password = $requestData['password'] ?? '';
|
||||
$ip = $this->request->getIPAddress();
|
||||
$validation = \Config\Services::validation();
|
||||
$validation->setRules([
|
||||
'email' => 'required|valid_email|max_length[254]',
|
||||
'password' => 'required|max_length[255]',
|
||||
]);
|
||||
|
||||
if (!$email || !$password) {
|
||||
return $this->response->setStatusCode(400)->setJSON([
|
||||
if (! $validation->run($requestData)) {
|
||||
return $this->response->setStatusCode(422)->setJSON([
|
||||
'status' => false,
|
||||
'message' => 'Email and password are required.'
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validation->getErrors(),
|
||||
]);
|
||||
}
|
||||
|
||||
$email = strtolower((string) ($requestData['email'] ?? ''));
|
||||
$password = (string) ($requestData['password'] ?? '');
|
||||
$ip = $this->request->getIPAddress();
|
||||
|
||||
if ($this->isIpBlocked($ip)) {
|
||||
return $this->response->setStatusCode(429)->setJSON([
|
||||
'status' => false,
|
||||
@@ -201,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([
|
||||
@@ -221,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]',
|
||||
];
|
||||
|
||||
@@ -313,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([
|
||||
@@ -491,7 +530,7 @@ class AuthController extends Controller
|
||||
return true;
|
||||
}
|
||||
|
||||
private function loginUser($user)
|
||||
private function loginUser($user, ?string $redirectTo = null)
|
||||
{
|
||||
$userRoleModel = new UserRoleModel();
|
||||
$roles = $userRoleModel->select('roles.name')
|
||||
@@ -525,9 +564,17 @@ class AuthController extends Controller
|
||||
if (count($roleNames) === 1) {
|
||||
// One role → set and redirect directly
|
||||
session()->set('role', $roleNames[0]);
|
||||
if ($redirectTo !== null) {
|
||||
return redirect()->to($redirectTo);
|
||||
}
|
||||
return $this->redirectToDashboard([$roleNames[0]]);
|
||||
} else {
|
||||
// Multiple roles → redirect to role selection view
|
||||
if ($redirectTo !== null) {
|
||||
session()->set('post_login_redirect', $redirectTo);
|
||||
} else {
|
||||
session()->remove('post_login_redirect');
|
||||
}
|
||||
return redirect()->to('/select-role');
|
||||
}
|
||||
|
||||
@@ -592,12 +639,17 @@ private function redirectToDashboard(array $roles)
|
||||
{
|
||||
$selectedRole = $this->request->getPost('selected_role');
|
||||
$availableRoles = session()->get('roles');
|
||||
$redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? session()->get('post_login_redirect') ?? ''));
|
||||
|
||||
if (!$selectedRole || !in_array($selectedRole, $availableRoles)) {
|
||||
return redirect()->to('/select-role')->with('error', 'Invalid role selected.');
|
||||
}
|
||||
|
||||
session()->set('role', $selectedRole);
|
||||
session()->remove('post_login_redirect');
|
||||
if ($redirectTo !== null) {
|
||||
return redirect()->to($redirectTo);
|
||||
}
|
||||
return $this->redirectToDashboard([$selectedRole]);
|
||||
}
|
||||
|
||||
@@ -609,7 +661,44 @@ private function redirectToDashboard(array $roles)
|
||||
return redirect()->to('/login')->with('error', 'No roles available.');
|
||||
}
|
||||
|
||||
return view('auth/select_role', ['roles' => $roles]);
|
||||
return view('auth/select_role', [
|
||||
'roles' => $roles,
|
||||
'redirect_to' => (string) (session()->get('post_login_redirect') ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
private function sanitizeRedirectTarget(string $redirectTo): ?string
|
||||
{
|
||||
$redirectTo = trim($redirectTo);
|
||||
if ($redirectTo === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('#^https?://#i', $redirectTo)) {
|
||||
$appHost = (string) parse_url(base_url('/'), PHP_URL_HOST);
|
||||
$targetHost = (string) parse_url($redirectTo, PHP_URL_HOST);
|
||||
$targetPath = (string) parse_url($redirectTo, PHP_URL_PATH);
|
||||
$targetQuery = (string) parse_url($redirectTo, PHP_URL_QUERY);
|
||||
|
||||
if ($appHost === '' || $targetHost === '' || strcasecmp($appHost, $targetHost) !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$redirectTo = $targetPath !== '' ? $targetPath : '/';
|
||||
if ($targetQuery !== '') {
|
||||
$redirectTo .= '?' . $targetQuery;
|
||||
}
|
||||
}
|
||||
|
||||
if (!str_starts_with($redirectTo, '/')) {
|
||||
$redirectTo = '/' . ltrim($redirectTo, '/');
|
||||
}
|
||||
|
||||
if (str_starts_with($redirectTo, '//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $redirectTo;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ abstract class BaseController extends Controller
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $helpers = [];
|
||||
protected $helpers = ['security'];
|
||||
|
||||
/** @var ApiClient */
|
||||
protected ApiClient $api;
|
||||
@@ -73,6 +73,21 @@ abstract class BaseController extends Controller
|
||||
// Assuming the user role is stored in the session
|
||||
return session()->get('role');
|
||||
}
|
||||
|
||||
protected function validated(array $rules, ?array $data = null): array
|
||||
{
|
||||
$validation = service('validation');
|
||||
$payload = $data ?? ($this->request->getJSON(true) ?: $this->request->getPost());
|
||||
$payload = sanitize_request_value($payload);
|
||||
|
||||
$validation->setRules($rules);
|
||||
|
||||
if (! $validation->run($payload)) {
|
||||
throw new \InvalidArgumentException(json_encode($validation->getErrors(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: 'Validation failed');
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -20,15 +20,22 @@ class ProofreadController extends ResourceController
|
||||
}
|
||||
|
||||
// Accept form-urlencoded payload to play nicely with CSRF protection
|
||||
$text = (string) ($this->request->getPost('text') ?? '');
|
||||
if ($text === '' || mb_strlen($text) > 20000) {
|
||||
$validation = service('validation');
|
||||
$payload = sanitize_request_value($this->request->getPost(['text']));
|
||||
$validation->setRules([
|
||||
'text' => 'required|min_length[1]|max_length[20000]',
|
||||
]);
|
||||
|
||||
if (! $validation->run($payload)) {
|
||||
return $this->respond([
|
||||
'ok' => false,
|
||||
'error' => 'Invalid text (empty or too long).',
|
||||
'error' => 'Invalid text payload.',
|
||||
'csrfHash' => csrf_hash(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$text = (string) $payload['text'];
|
||||
|
||||
$client = \Config\Services::curlrequest(['timeout' => 10]);
|
||||
|
||||
try {
|
||||
|
||||
@@ -2003,6 +2003,58 @@ class AttendanceTrackingController extends BaseController
|
||||
return [$subject, $body];
|
||||
}
|
||||
|
||||
private function buildFallbackTemplate(string $code, string $variant, array $context): array
|
||||
{
|
||||
$studentName = (string) ($context['{{student_name}}'] ?? 'the student');
|
||||
$incident = (string) ($context['{{incident_date}}'] ?? date('Y-m-d'));
|
||||
$parentName = (string) ($context['{{parent_name}}'] ?? 'Parent/Guardian');
|
||||
$phone = (string) ($context['{{school_phone}}'] ?? 'the school office');
|
||||
$voicemail = (string) ($context['{{voicemail_phone}}'] ?? 'your voicemail');
|
||||
|
||||
$subject = match (true) {
|
||||
str_starts_with($code, 'ABS_1') => 'Attendance Notice: Unreported Absence',
|
||||
str_starts_with($code, 'ABS_2') => 'Attendance Follow-Up: Two Consecutive Absences',
|
||||
str_starts_with($code, 'ABS_3') => 'Attendance Follow-Up: Three Absences',
|
||||
str_starts_with($code, 'LATE_2') => 'Attendance Notice: Repeated Lateness',
|
||||
str_starts_with($code, 'LATE_3'),
|
||||
str_starts_with($code, 'LATE_4'),
|
||||
str_starts_with($code, 'MIX') => 'Attendance Follow-Up: Repeated Lateness and Absence',
|
||||
default => 'Attendance Notice',
|
||||
};
|
||||
|
||||
$intro = "<p>Insha Allah this email finds you well";
|
||||
if ($variant === 'answered') {
|
||||
$intro .= ", <strong>{$parentName}</strong>. Jazakum Allahu khayran for taking the time to speak with us today.</p>";
|
||||
} elseif ($variant === 'no_answer') {
|
||||
$intro .= ".</p><p>We tried to reach you but could not connect and left a voice message at <strong>{$voicemail}</strong>.</p>";
|
||||
} else {
|
||||
$intro .= ".</p>";
|
||||
}
|
||||
|
||||
$details = match (true) {
|
||||
str_starts_with($code, 'ABS_1')
|
||||
=> "<p>This is to let you know that <strong>{$studentName}</strong> was absent on <strong>{$incident}</strong> without prior notice.</p>",
|
||||
str_starts_with($code, 'ABS_2')
|
||||
=> "<p>This is a reminder that <strong>{$studentName}</strong> has had repeated absences, most recently on <strong>{$incident}</strong>, without prior notice.</p>",
|
||||
str_starts_with($code, 'ABS_3')
|
||||
=> "<p>This is a reminder that <strong>{$studentName}</strong> has been absent three times, most recently on <strong>{$incident}</strong>, without prior notice.</p>",
|
||||
str_starts_with($code, 'LATE_2')
|
||||
=> "<p>This is a reminder that <strong>{$studentName}</strong> has been late multiple times, most recently on <strong>{$incident}</strong>.</p>",
|
||||
str_starts_with($code, 'LATE_3'),
|
||||
str_starts_with($code, 'LATE_4')
|
||||
=> "<p>This is a follow-up that <strong>{$studentName}</strong> has had repeated lateness concerns, most recently on <strong>{$incident}</strong>.</p>",
|
||||
str_starts_with($code, 'MIX')
|
||||
=> "<p>This is a follow-up that <strong>{$studentName}</strong> has had a mix of repeated lateness and absence concerns, most recently on <strong>{$incident}</strong>.</p>",
|
||||
default
|
||||
=> "<p>We'd like to inform you about <strong>{$studentName}</strong>'s recent attendance concern dated <strong>{$incident}</strong>.</p>",
|
||||
};
|
||||
|
||||
$closing = "<p>If your child will be absent or late due to illness or family commitments, please let us know ahead of time.</p>"
|
||||
. "<p>You can email/call/text us at <strong>{$phone}</strong>.</p>";
|
||||
|
||||
return [$subject, $intro . $details . $closing];
|
||||
}
|
||||
|
||||
|
||||
public function compose()
|
||||
{
|
||||
@@ -2033,8 +2085,8 @@ class AttendanceTrackingController extends BaseController
|
||||
$rendered = $this->renderTemplate($code, $variant, $ctx);
|
||||
|
||||
if (!$rendered) {
|
||||
return redirect()->to(site_url('attendance/violations'))
|
||||
->with('error', 'Template not found for ' . $code . ' (' . $variant . ').');
|
||||
log_message('warning', 'Attendance email template missing; using fallback compose body. code=' . $code . ' variant=' . $variant);
|
||||
$rendered = $this->buildFallbackTemplate($code, $variant, $ctx);
|
||||
}
|
||||
|
||||
[$subject, $body] = $rendered;
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\CertificateRecordModel;
|
||||
|
||||
class CertificateController extends BaseController
|
||||
{
|
||||
protected $classSectionModel;
|
||||
protected $configModel;
|
||||
protected $certRecordModel;
|
||||
protected $schoolYear;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->certRecordModel = new CertificateRecordModel();
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
}
|
||||
|
||||
// ─── Admin UI ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function index()
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$classSectionId = $this->request->getGet('class_section_id');
|
||||
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||
|
||||
$classSections = $db->table('classSection cs')
|
||||
->select('cs.class_section_id, cs.class_section_name')
|
||||
->join('student_class sc', 'sc.class_section_id = cs.class_section_id')
|
||||
->join('students s', 's.id = sc.student_id')
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->groupBy('cs.class_section_id, cs.class_section_name')
|
||||
->having('COUNT(s.id) >', 1)
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
$students = [];
|
||||
if ($classSectionId) {
|
||||
$students = $db->table('student_class sc')
|
||||
->select('s.id, s.firstname, s.lastname, cs.class_section_name AS grade')
|
||||
->join('students s', 's.id = sc.student_id')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
||||
->where('sc.class_section_id', (int) $classSectionId)
|
||||
->where('s.is_active', 1)
|
||||
->where('sc.school_year', $schoolYear)
|
||||
->orderBy('s.lastname', 'ASC')
|
||||
->orderBy('s.firstname', 'ASC')
|
||||
->get()->getResultArray();
|
||||
}
|
||||
|
||||
return view('admin/certificates/index', [
|
||||
'classSections' => $classSections,
|
||||
'students' => $students,
|
||||
'selectedClassId' => $classSectionId,
|
||||
'schoolYear' => $schoolYear,
|
||||
'certDate' => date('m/d/Y'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function auditLog()
|
||||
{
|
||||
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||
$records = $this->certRecordModel->getAuditLog($schoolYear);
|
||||
$yearSummary = $this->certRecordModel->yearSummary();
|
||||
|
||||
return view('admin/certificates/audit_log', [
|
||||
'records' => $records,
|
||||
'schoolYear' => $schoolYear,
|
||||
'yearSummary' => $yearSummary,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Public verification page ──────────────────────────────────────────────
|
||||
|
||||
public function verify(string $certNumber)
|
||||
{
|
||||
$record = \Config\Database::connect()
|
||||
->table('certificate_records cr')
|
||||
->select('cr.*, u.firstname AS admin_firstname, u.lastname AS admin_lastname')
|
||||
->join('users u', 'u.id = cr.issued_by', 'left')
|
||||
->where('cr.certificate_number', strtoupper($certNumber))
|
||||
->get()->getRowArray();
|
||||
|
||||
return view('certificates/verify', ['record' => $record ?: null]);
|
||||
}
|
||||
|
||||
// ─── PDF generation ────────────────────────────────────────────────────────
|
||||
|
||||
public function generate()
|
||||
{
|
||||
$studentIds = $this->request->getPost('student_ids') ?? [];
|
||||
$certDate = trim($this->request->getPost('cert_date') ?? date('m/d/Y'));
|
||||
$classSectionId = $this->request->getPost('class_section_id');
|
||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return redirect()->to('administrator/certificates')->with('error', 'Please select at least one student.');
|
||||
}
|
||||
|
||||
$studentIds = array_filter(array_map('intval', $studentIds));
|
||||
$certDate = preg_replace('/[^0-9\/\-]/', '', $certDate);
|
||||
|
||||
if (empty($studentIds)) {
|
||||
return redirect()->to('administrator/certificates')->with('error', 'Invalid student selection.');
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$students = [];
|
||||
|
||||
foreach ($studentIds as $id) {
|
||||
$row = null;
|
||||
if ($classSectionId) {
|
||||
$row = $db->table('student_class sc')
|
||||
->select('s.id, s.firstname, s.lastname, cs.class_section_name AS grade')
|
||||
->join('students s', 's.id = sc.student_id')
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
||||
->where('sc.class_section_id', (int) $classSectionId)
|
||||
->where('s.id', $id)
|
||||
->get()->getRowArray();
|
||||
}
|
||||
|
||||
if (!$row) {
|
||||
$s = $db->table('students')
|
||||
->select('id, firstname, lastname, registration_grade AS grade')
|
||||
->where('id', $id)
|
||||
->get()->getRowArray();
|
||||
$row = $s ?: null;
|
||||
}
|
||||
|
||||
if ($row) {
|
||||
$students[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($students)) {
|
||||
return redirect()->to('administrator/certificates')->with('error', 'No valid students found.');
|
||||
}
|
||||
|
||||
$issuedBy = session()->get('user_id');
|
||||
$issuedAt = date('Y-m-d H:i:s');
|
||||
$certDateDb = $this->parseCertDate($certDate);
|
||||
|
||||
foreach ($students as &$student) {
|
||||
$certNumber = $this->certRecordModel->nextNumber($schoolYear);
|
||||
$this->certRecordModel->insert([
|
||||
'certificate_number' => $certNumber,
|
||||
'student_id' => $student['id'],
|
||||
'student_name' => $student['firstname'] . ' ' . $student['lastname'],
|
||||
'grade' => $this->formatGrade($student['grade'] ?? ''),
|
||||
'cert_date' => $certDateDb,
|
||||
'school_year' => $schoolYear,
|
||||
'class_section_id' => $classSectionId ?: null,
|
||||
'issued_by' => $issuedBy,
|
||||
'issued_at' => $issuedAt,
|
||||
]);
|
||||
$student['cert_number'] = $certNumber;
|
||||
}
|
||||
unset($student);
|
||||
|
||||
$pdfData = $this->buildPdf($students, $certDate);
|
||||
$filename = 'Certificates_' . date('Ymd_His') . '.pdf';
|
||||
|
||||
return $this->response
|
||||
->setHeader('Content-Type', 'application/pdf')
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
|
||||
->setBody($pdfData);
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function parseCertDate(string $certDate): ?string
|
||||
{
|
||||
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $certDate, $m)) {
|
||||
return $m[3] . '-' . $m[1] . '-' . $m[2];
|
||||
}
|
||||
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $certDate)) {
|
||||
return $certDate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function formatGrade(string $raw): string
|
||||
{
|
||||
$clean = trim($raw);
|
||||
$lower = strtolower($clean);
|
||||
|
||||
if (preg_match('/^\d+$/', $clean) && (int)$clean >= 1 && (int)$clean <= 9) {
|
||||
return 'Grade ' . $clean;
|
||||
}
|
||||
if ($lower === 'youth') {
|
||||
return 'Youth';
|
||||
}
|
||||
if ($lower === 'kg') {
|
||||
return 'Kindergarten';
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a QR code PNG for $url and returns its temporary file path.
|
||||
* Caller must unlink() the file when done.
|
||||
*/
|
||||
private function makeQrTempFile(string $url): ?string
|
||||
{
|
||||
try {
|
||||
$result = \Endroid\QrCode\Builder\Builder::create()
|
||||
->writer(new \Endroid\QrCode\Writer\PngWriter())
|
||||
->data($url)
|
||||
->encoding(new \Endroid\QrCode\Encoding\Encoding('UTF-8'))
|
||||
->size(300)
|
||||
->margin(2)
|
||||
->build();
|
||||
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'cert_qr_') . '.png';
|
||||
$result->saveToFile($tmp);
|
||||
return $tmp;
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Certificate QR generation failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PDF rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
private function buildPdf(array $students, string $certDate): string
|
||||
{
|
||||
$fontDir = FCPATH . 'assets' . DIRECTORY_SEPARATOR . 'certificates' . DIRECTORY_SEPARATOR . 'fonts' . DIRECTORY_SEPARATOR;
|
||||
$imgDir = FCPATH . 'assets' . DIRECTORY_SEPARATOR . 'certificates' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR;
|
||||
|
||||
$edwardianFont = \TCPDF_FONTS::addTTFfont($fontDir . 'Edwardian Script ITC Regular.ttf', 'TrueTypeUnicode', '', 32);
|
||||
$garamondBold = \TCPDF_FONTS::addTTFfont($fontDir . 'Garamond Bold.ttf', 'TrueTypeUnicode', '', 32);
|
||||
$ebGaramond = \TCPDF_FONTS::addTTFfont($fontDir . 'EBGaramond-Regular.ttf', 'TrueTypeUnicode', '', 32);
|
||||
|
||||
$pdf = new \TCPDF('L', 'pt', 'A4', true, 'UTF-8', false);
|
||||
$pdf->SetCreator('Al Rahma Sunday School');
|
||||
$pdf->SetTitle('Student Certificates');
|
||||
$pdf->SetMargins(0, 0, 0, true);
|
||||
$pdf->SetAutoPageBreak(false, 0);
|
||||
$pdf->setPrintHeader(false);
|
||||
$pdf->setPrintFooter(false);
|
||||
$pdf->SetHeaderMargin(0);
|
||||
$pdf->SetFooterMargin(0);
|
||||
|
||||
$W = $pdf->getPageWidth();
|
||||
$H = $pdf->getPageHeight();
|
||||
|
||||
$tmpFiles = [];
|
||||
|
||||
foreach ($students as $student) {
|
||||
$pdf->AddPage();
|
||||
|
||||
$name = $student['firstname'] . ' ' . $student['lastname'];
|
||||
$grade = $this->formatGrade($student['grade'] ?? '');
|
||||
$certNumber = $student['cert_number'] ?? '';
|
||||
|
||||
// Build verification URL and generate QR temp file
|
||||
$verifyUrl = base_url('verify/' . rawurlencode($certNumber));
|
||||
$qrFile = $certNumber !== '' ? $this->makeQrTempFile($verifyUrl) : null;
|
||||
if ($qrFile) {
|
||||
$tmpFiles[] = $qrFile;
|
||||
}
|
||||
|
||||
$this->drawCertificate(
|
||||
$pdf, $W, $H,
|
||||
$name, $grade, $certDate, $certNumber,
|
||||
$qrFile,
|
||||
$imgDir, $edwardianFont, $garamondBold, $ebGaramond
|
||||
);
|
||||
}
|
||||
|
||||
$output = $pdf->Output('', 'S');
|
||||
|
||||
foreach ($tmpFiles as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinate mapping: iTextSharp origin bottom-left → TCPDF origin top-left.
|
||||
* y_tcpdf ≈ H − y_iTextSharp
|
||||
*/
|
||||
private function drawCertificate(
|
||||
\TCPDF $pdf,
|
||||
float $W,
|
||||
float $H,
|
||||
string $name,
|
||||
string $grade,
|
||||
string $certDate,
|
||||
string $certNumber,
|
||||
?string $qrFile,
|
||||
string $imgDir,
|
||||
string $edwardianFont,
|
||||
string $garamondBold,
|
||||
string $ebGaramond
|
||||
): void {
|
||||
// ── Images
|
||||
$pdf->Image($imgDir . 'title.png', 126, 0, 600);
|
||||
$pdf->Image($imgDir . 'background.png', 280, 176, 291, 340);
|
||||
$pdf->Image($imgDir . 'signature.png', 140, 399, 90, 90);
|
||||
|
||||
// ── QR code — bottom-right corner: 1.5 cm from bottom, 2.5 cm from right
|
||||
$qrSize = 42; // pt
|
||||
if ($qrFile !== null && file_exists($qrFile)) {
|
||||
$qrX = $W - 70.87 - $qrSize; // 2.5 cm from right edge
|
||||
$qrY = $H - 42.52 - $qrSize; // 1.5 cm from bottom edge
|
||||
$pdf->Image($qrFile, $qrX, $qrY, $qrSize, $qrSize);
|
||||
}
|
||||
|
||||
// ── "Presented to:"
|
||||
$pdf->SetFont('times', 'B', 24);
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
$pdf->SetXY(0, 151);
|
||||
$pdf->Cell($W, 24, 'Presented to:', 0, 0, 'C');
|
||||
|
||||
// ── Student name (bold via fill + stroke)
|
||||
$pdf->SetFont($edwardianFont, '', 38);
|
||||
$pdf->SetDrawColor(0, 0, 0);
|
||||
$pdf->setTextRenderingMode(0.4, true, false);
|
||||
$pdf->SetXY(0, 219);
|
||||
$pdf->Cell($W, 38, $name, 0, 0, 'C');
|
||||
$pdf->setTextRenderingMode(0, true, false);
|
||||
|
||||
// ── Line under name
|
||||
$pdf->SetFont('times', '', 20);
|
||||
$pdf->SetXY(0, 243);
|
||||
$pdf->Cell(600, 20, '___________________________________', 0, 0, 'R');
|
||||
|
||||
// ── Description
|
||||
$pdf->SetFont($ebGaramond, '', 20);
|
||||
$pdf->SetXY(0, 273);
|
||||
$pdf->Cell($W, 20, 'for successfully completing the requirements of', 0, 0, 'C');
|
||||
|
||||
// ── Grade
|
||||
$pdf->SetFont($garamondBold, '', 20);
|
||||
$pdf->SetXY(0, 310);
|
||||
$pdf->Cell($W, 20, $grade, 0, 0, 'C');
|
||||
|
||||
// ── "at"
|
||||
$pdf->SetFont('times', '', 20);
|
||||
$pdf->SetXY(0, 343);
|
||||
$pdf->Cell($W, 20, 'at', 0, 0, 'C');
|
||||
|
||||
// ── School name
|
||||
$pdf->SetFont($garamondBold, '', 20);
|
||||
$pdf->SetXY(0, 375);
|
||||
$pdf->Cell($W, 20, 'Al Rahma Sunday School', 0, 0, 'C');
|
||||
|
||||
// ── Date (gradient script)
|
||||
$this->drawGradientText($pdf, $edwardianFont, 26, $certDate, 598, 453);
|
||||
|
||||
// ── Date underline + label
|
||||
$pdf->SetFont('times', '', 20);
|
||||
$pdf->SetXY(0, 463);
|
||||
$pdf->Cell(742, 20, '_________________', 0, 0, 'R');
|
||||
$pdf->SetXY(650, 492);
|
||||
$pdf->Cell(80, 20, 'Date', 0, 0, 'C');
|
||||
|
||||
// ── Signature underline + label
|
||||
$pdf->SetXY(106, 463);
|
||||
$pdf->Cell(200, 20, '_________________', 0, 0, 'L');
|
||||
$pdf->SetXY(106, 492);
|
||||
$pdf->Cell(168, 20, 'Signature', 0, 0, 'C');
|
||||
|
||||
// ── Certificate number — 1.4 cm from bottom, 2.5 cm from left
|
||||
if ($certNumber !== '') {
|
||||
$pdf->SetFont('helvetica', '', 8);
|
||||
$pdf->SetTextColor(150, 150, 150);
|
||||
$pdf->SetXY(70.87, $H - 39.68);
|
||||
$pdf->Cell(200, 12, 'Certificate No. ' . $certNumber, 0, 0, 'L');
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private function drawGradientText(\TCPDF $pdf, string $fontName, float $fontSize, string $text, float $x, float $y): void
|
||||
{
|
||||
$pdf->SetFont($fontName, '', $fontSize);
|
||||
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$pdf->setAlpha(($i + 1) * 25 / 255);
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
$pdf->Text($x + $i / 5, $y + $i / 5, $text);
|
||||
}
|
||||
|
||||
$pdf->setAlpha(1);
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
$pdf->Text($x, $y, $text);
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,10 @@ class ContactController extends BaseController
|
||||
|
||||
// Define validation rules
|
||||
$validation->setRules([
|
||||
'name' => 'required|min_length[3]',
|
||||
'email' => 'required|valid_email',
|
||||
'subject' => 'required|min_length[3]',
|
||||
'message' => 'required|min_length[10]'
|
||||
'name' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[3]|max_length[100]",
|
||||
'email' => 'required|valid_email|max_length[254]',
|
||||
'subject' => 'required|min_length[3]|max_length[150]',
|
||||
'message' => 'required|min_length[10]|max_length[5000]'
|
||||
]);
|
||||
|
||||
if (!$validation->withRequest($this->request)->run()) {
|
||||
@@ -37,10 +37,10 @@ class ContactController extends BaseController
|
||||
}
|
||||
|
||||
// Process form data
|
||||
$name = $this->request->getPost('name');
|
||||
$email = strtolower($this->request->getPost('email'));
|
||||
$subject = $this->request->getPost('subject');
|
||||
$message = $this->request->getPost('message');
|
||||
$name = esc((string) $this->request->getPost('name'));
|
||||
$email = esc(strtolower((string) $this->request->getPost('email')));
|
||||
$subject = esc((string) $this->request->getPost('subject'));
|
||||
$message = nl2br(esc((string) $this->request->getPost('message')));
|
||||
|
||||
// Initialize the EmailController
|
||||
$emailController = new \App\Controllers\View\EmailController();
|
||||
|
||||
@@ -750,7 +750,9 @@ class DiscountController extends BaseController
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
$newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
|
||||
$discountableTotal = $tuitionSubtotal + $additionalSubtotal;
|
||||
$nonDiscountableTotal = $eventSubtotal;
|
||||
$newTotal = round($discountableTotal + $nonDiscountableTotal, 2);
|
||||
|
||||
// ---- Payments / Discounts / Refunds ----
|
||||
$db = $this->db;
|
||||
@@ -794,7 +796,8 @@ class DiscountController extends BaseController
|
||||
->get()->getRowArray();
|
||||
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
|
||||
|
||||
$newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid);
|
||||
$appliedDiscount = min($totalDisc, $discountableTotal);
|
||||
$newBalance = max(0.0, $newTotal - $appliedDiscount - $totalPaid - $totalRefundPaid);
|
||||
$newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid');
|
||||
|
||||
$updateData = [
|
||||
|
||||
@@ -170,6 +170,163 @@ class EmailController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one email with a single To recipient and many CC recipients.
|
||||
*
|
||||
* @param string $recipient
|
||||
* @param string[] $ccRecipients
|
||||
* @param string $subject
|
||||
* @param string $htmlMessage
|
||||
* @param string|null $profile
|
||||
* @param string|null $replyToEmail
|
||||
* @param string|null $replyToName
|
||||
* @param array $attachments
|
||||
*/
|
||||
public function sendEmailWithCc(
|
||||
string $recipient,
|
||||
array $ccRecipients,
|
||||
string $subject,
|
||||
string $htmlMessage,
|
||||
?string $profile = null,
|
||||
?string $replyToEmail = null,
|
||||
?string $replyToName = null,
|
||||
array $attachments = []
|
||||
): bool {
|
||||
$ccRecipients = array_values(array_unique(array_filter(array_map(static function ($email) {
|
||||
$email = trim((string) $email);
|
||||
return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
|
||||
}, $ccRecipients))));
|
||||
|
||||
if ($ccRecipients === []) {
|
||||
return $this->sendEmail($recipient, $subject, $htmlMessage, $profile, $replyToEmail, $replyToName, $attachments);
|
||||
}
|
||||
|
||||
$autoload = APPPATH . '../vendor/autoload.php';
|
||||
if (is_file($autoload)) {
|
||||
require_once $autoload;
|
||||
}
|
||||
|
||||
$profile = $this->resolveProfile($profile);
|
||||
$cfg = $this->getProfileConfig($profile);
|
||||
|
||||
if (empty($cfg['host']) || empty($cfg['user']) || $cfg['pass'] === '') {
|
||||
log_message('error', "[mail:$profile] Missing SMTP config (host/user/pass). Check .env MAIL_{$this->envKeyFromProfile($profile)}_* or MAIL_DEFAULT_*.");
|
||||
return false;
|
||||
}
|
||||
|
||||
$debugEnabled = (bool) env('MAIL_DEBUG', false);
|
||||
$timeout = (int) env('MAIL_TIMEOUT', 15);
|
||||
$keepAlive = (bool) env('MAIL_KEEPALIVE', false);
|
||||
$verifyPeer = filter_var(env('MAIL_VERIFY_PEER', 'true'), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
$targetHost = $cfg['host'];
|
||||
$targetPort = (int) $cfg['port'];
|
||||
|
||||
$resolved = @gethostbyname($targetHost);
|
||||
if (!$resolved || $resolved === $targetHost) {
|
||||
log_message('debug', "[mail:$profile] DNS resolve note: host=$targetHost, resolved=$resolved");
|
||||
}
|
||||
|
||||
$sockOk = @fsockopen($targetHost, $targetPort, $errno, $errstr, 5);
|
||||
if (!$sockOk) {
|
||||
log_message('error', "[mail:$profile] Socket preflight failed to {$targetHost}:{$targetPort} (errno=$errno, err=$errstr). Likely firewall/port/encryption mismatch or wrong host.");
|
||||
} else {
|
||||
fclose($sockOk);
|
||||
}
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
|
||||
try {
|
||||
if ($debugEnabled) {
|
||||
ob_start();
|
||||
}
|
||||
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $cfg['host'];
|
||||
$mail->Port = $cfg['port'];
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = $cfg['user'];
|
||||
$mail->Password = $cfg['pass'];
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->Timeout = $timeout;
|
||||
$mail->SMTPKeepAlive = $keepAlive;
|
||||
$mail->SMTPAutoTLS = true;
|
||||
|
||||
if ($cfg['encryption'] === 'ssl') {
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
|
||||
} else {
|
||||
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
}
|
||||
|
||||
$mail->SMTPOptions = [
|
||||
'ssl' => [
|
||||
'verify_peer' => $verifyPeer,
|
||||
'verify_peer_name' => $verifyPeer,
|
||||
'allow_self_signed' => !$verifyPeer,
|
||||
],
|
||||
];
|
||||
|
||||
$mail->SMTPDebug = $debugEnabled ? 3 : 0;
|
||||
$mail->Debugoutput = 'error_log';
|
||||
|
||||
$mail->setFrom($cfg['fromEmail'], $cfg['fromName']);
|
||||
if (!empty($cfg['returnPath'])) {
|
||||
$mail->Sender = $cfg['returnPath'];
|
||||
}
|
||||
|
||||
$mail->clearReplyTos();
|
||||
$rtEmail = env('MAIL_DEFAULT_REPLY_TO');
|
||||
$rtName = env('MAIL_DEFAULT_REPLY_TO_NAME');
|
||||
if (!$rtEmail || !filter_var($rtEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
$rtEmail = $replyToEmail ?: ($cfg['replyTo'] ?: $cfg['fromEmail']);
|
||||
}
|
||||
if (!$rtName) {
|
||||
$rtName = $replyToName ?: ($cfg['replyToName'] ?: $cfg['fromName']);
|
||||
}
|
||||
$rtName = $this->sanitizeReplyToName($rtName, $cfg['fromName']);
|
||||
if ($rtEmail) {
|
||||
$mail->addReplyTo($rtEmail, $rtName);
|
||||
}
|
||||
|
||||
if (!empty($cfg['dkim']['domain']) && !empty($cfg['dkim']['private']) && !empty($cfg['dkim']['selector'])) {
|
||||
$mail->DKIM_domain = $cfg['dkim']['domain'];
|
||||
$mail->DKIM_private = $cfg['dkim']['private'];
|
||||
$mail->DKIM_selector = $cfg['dkim']['selector'];
|
||||
$mail->DKIM_identity = $cfg['fromEmail'];
|
||||
}
|
||||
|
||||
$mail->addAddress($recipient);
|
||||
foreach ($ccRecipients as $ccRecipient) {
|
||||
$mail->addCC($ccRecipient);
|
||||
}
|
||||
|
||||
foreach ($attachments as $att) {
|
||||
if (!empty($att['path']) && is_file($att['path'])) {
|
||||
$mail->addAttachment($att['path'], $att['name'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = $subject;
|
||||
$mail->Body = $htmlMessage;
|
||||
|
||||
$ok = $mail->send();
|
||||
|
||||
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
|
||||
if ($ok) {
|
||||
log_message('info', "[mail:$profile] Sent to {$recipient} with " . count($ccRecipients) . " CC recipient(s), subj='{$subject}' via {$cfg['host']}:{$cfg['port']}/{$cfg['encryption']}");
|
||||
return true;
|
||||
}
|
||||
|
||||
log_message('error', "[mail:$profile] Failed: {$mail->ErrorInfo}. Debug: {$dbg}");
|
||||
return false;
|
||||
} catch (Exception $e) {
|
||||
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
|
||||
log_message('error', "[mail:$profile] Exception: {$e->getMessage()} | PHPMailer: {$mail->ErrorInfo} | Debug: {$dbg}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve null/alias profile names to a canonical env prefix. */
|
||||
private function resolveProfile(?string $profile): string
|
||||
{
|
||||
|
||||
@@ -102,11 +102,9 @@ class ExamDraftController extends BaseController
|
||||
session()->set('class_section_id', $selectedClass);
|
||||
}
|
||||
|
||||
$allDrafts = $this->examDraftModel
|
||||
->select($this->draftSelectColumns())
|
||||
->select('cs.class_section_name')
|
||||
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
|
||||
->where($this->authorIdColumn, $teacherId)
|
||||
$classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments);
|
||||
|
||||
$allDrafts = $this->visibleTeacherDraftsQuery($teacherId, $classSectionIds)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll();
|
||||
|
||||
@@ -117,10 +115,10 @@ class ExamDraftController extends BaseController
|
||||
$row['final_pdf_file'] = $pdf;
|
||||
}
|
||||
}
|
||||
$row = $this->attachTeacherDraftContext($row, $teacherId);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
$classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments);
|
||||
$legacyExams = [];
|
||||
// Guard against missing schema column in older databases
|
||||
if ($this->hasIsLegacyColumn) {
|
||||
@@ -129,8 +127,9 @@ class ExamDraftController extends BaseController
|
||||
|
||||
$legacyQuery = $this->examDraftModel
|
||||
->select($this->draftSelectColumns())
|
||||
->select('cs.class_section_name')
|
||||
->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last')
|
||||
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
|
||||
->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left')
|
||||
->where('exam_drafts.is_legacy', 1)
|
||||
->where('exam_drafts.status', 'legacy')
|
||||
->where('exam_drafts.final_file IS NOT NULL', null, false);
|
||||
@@ -143,6 +142,11 @@ class ExamDraftController extends BaseController
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderBy('exam_drafts.created_at', 'DESC')
|
||||
->findAll();
|
||||
|
||||
foreach ($legacyExams as &$row) {
|
||||
$row = $this->attachTeacherDraftContext($row, $teacherId);
|
||||
}
|
||||
unset($row);
|
||||
} else {
|
||||
// Legacy column absent, show all drafts and skip legacy tab query
|
||||
$drafts = $allDrafts;
|
||||
@@ -182,7 +186,7 @@ class ExamDraftController extends BaseController
|
||||
}
|
||||
|
||||
$classSectionId = (int) ($this->request->getPost('class_section_id') ?? session()->get('class_section_id') ?? 0);
|
||||
$examType = trim((string) $this->request->getPost('exam_type'));
|
||||
$examType = $this->normalizeExamType($this->request->getPost('exam_type'));
|
||||
$authorComment = trim((string) $this->request->getPost('author_comment'));
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
@@ -226,7 +230,11 @@ class ExamDraftController extends BaseController
|
||||
->orderBy('version', 'DESC')
|
||||
->first();
|
||||
|
||||
$title = $examType ?: 'Exam Draft';
|
||||
if ($examType === '') {
|
||||
return redirect()->back()->withInput()->with('error', 'Select an exam type before submitting.');
|
||||
}
|
||||
|
||||
$title = $examType;
|
||||
|
||||
if ($saveAsDraft) {
|
||||
if ($teacherFile === null && (empty($existingDraft) || empty($this->draftTeacherFile($existingDraft)))) {
|
||||
@@ -235,7 +243,7 @@ class ExamDraftController extends BaseController
|
||||
if (!empty($existingDraft) && strtolower((string) ($existingDraft['status'] ?? '')) === 'draft') {
|
||||
$draftRowId = (int) ($existingDraft['id'] ?? 0);
|
||||
$updateDraft = [
|
||||
'exam_type' => $examType ?: null,
|
||||
'exam_type' => $examType,
|
||||
'draft_title' => $title,
|
||||
'author_comment' => $authorComment === '' ? null : $authorComment,
|
||||
'status' => 'draft',
|
||||
@@ -263,7 +271,7 @@ class ExamDraftController extends BaseController
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'exam_type' => $examType ?: null,
|
||||
'exam_type' => $examType,
|
||||
'draft_title' => $title,
|
||||
'author_comment' => $authorComment === '' ? null : $authorComment,
|
||||
'status' => 'draft',
|
||||
@@ -280,9 +288,6 @@ class ExamDraftController extends BaseController
|
||||
}
|
||||
|
||||
$existingStatus = strtolower((string) ($existingDraft['status'] ?? ''));
|
||||
if ($examType === '') {
|
||||
return redirect()->back()->withInput()->with('error', 'Select an exam type before submitting.');
|
||||
}
|
||||
if ($teacherFile === null) {
|
||||
return redirect()->back()->withInput()->with('error', 'Upload a DOC/DOCX file to submit the exam draft.');
|
||||
}
|
||||
@@ -291,7 +296,7 @@ class ExamDraftController extends BaseController
|
||||
if (!empty($existingDraft) && $existingStatus === 'submitted') {
|
||||
$draftRowId = (int) ($existingDraft['id'] ?? 0);
|
||||
$updateSubmit = [
|
||||
'exam_type' => $examType ?: null,
|
||||
'exam_type' => $examType,
|
||||
'draft_title' => $title,
|
||||
'author_comment' => $authorComment === '' ? null : $authorComment,
|
||||
'status' => 'submitted',
|
||||
@@ -322,7 +327,7 @@ class ExamDraftController extends BaseController
|
||||
'class_section_id' => $classSectionId,
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
'exam_type' => $examType ?: null,
|
||||
'exam_type' => $examType,
|
||||
'draft_title' => $title,
|
||||
'author_comment' => $authorComment === '' ? null : $authorComment,
|
||||
'status' => 'submitted',
|
||||
@@ -343,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
|
||||
@@ -482,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) {
|
||||
@@ -499,7 +527,7 @@ class ExamDraftController extends BaseController
|
||||
$classSectionIds = array_values(array_filter(array_map('intval', $classSectionIds)));
|
||||
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear));
|
||||
$semester = trim((string) ($this->request->getPost('semester') ?? $this->semester));
|
||||
$examType = trim((string) $this->request->getPost('exam_type'));
|
||||
$examType = $this->normalizeExamType($this->request->getPost('exam_type'));
|
||||
|
||||
if (empty($classSectionIds)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Select at least one class section.');
|
||||
@@ -510,6 +538,9 @@ class ExamDraftController extends BaseController
|
||||
if ($semester === '') {
|
||||
return redirect()->back()->withInput()->with('error', 'Semester is required.');
|
||||
}
|
||||
if ($examType === '') {
|
||||
return redirect()->back()->withInput()->with('error', 'Exam type is required.');
|
||||
}
|
||||
|
||||
$file = $this->request->getFile('old_exam_file');
|
||||
if (!$file || !$file->isValid()) {
|
||||
@@ -534,8 +565,8 @@ class ExamDraftController extends BaseController
|
||||
$this->authorIdColumn => $adminId, // store under admin user since legacy uploads are admin-only
|
||||
'semester' => ucfirst(strtolower($semester)),
|
||||
'school_year' => $schoolYear,
|
||||
'exam_type' => $examType === '' ? null : $examType,
|
||||
'draft_title' => $examType === '' ? 'Legacy Exam' : $examType,
|
||||
'exam_type' => $examType,
|
||||
'draft_title' => $examType,
|
||||
'author_comment' => null,
|
||||
'final_file' => $stored,
|
||||
'final_filename' => $file->getClientName(),
|
||||
@@ -563,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) {
|
||||
@@ -646,13 +687,18 @@ class ExamDraftController extends BaseController
|
||||
$baseVersion = 1;
|
||||
}
|
||||
$reviewRevision = $this->nextReviewRevision($draft, $baseVersion);
|
||||
$reviewExamType = $this->normalizeExamType($draft['exam_type'] ?? $draft['draft_title'] ?? '');
|
||||
if ($reviewExamType === '') {
|
||||
return redirect()->back()->with('error', 'This draft is missing an exam type. Update the exam type before reviewing.');
|
||||
}
|
||||
|
||||
$newRow = [
|
||||
$this->authorIdColumn => $this->draftTeacherId($draft),
|
||||
'class_section_id' => (int) ($draft['class_section_id'] ?? 0),
|
||||
'semester' => (string) ($draft['semester'] ?? ''),
|
||||
'school_year' => (string) ($draft['school_year'] ?? ''),
|
||||
'exam_type' => $draft['exam_type'] ?? null,
|
||||
'draft_title' => $draft['draft_title'] ?? $draft['exam_type'] ?? 'Exam Draft',
|
||||
'exam_type' => $reviewExamType,
|
||||
'draft_title' => $draft['draft_title'] ?? $reviewExamType,
|
||||
'author_comment' => $draft['author_comment'] ?? null,
|
||||
'status' => $status,
|
||||
'reviewed_at' => $update['reviewed_at'],
|
||||
@@ -677,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;
|
||||
}
|
||||
@@ -698,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);
|
||||
@@ -759,10 +790,11 @@ class ExamDraftController extends BaseController
|
||||
return $this->response->setStatusCode(401);
|
||||
}
|
||||
|
||||
$drafts = $this->examDraftModel
|
||||
->select($this->draftSelectColumns())
|
||||
->select('id, status, acceptance_type, updated_at, reviewed_at')
|
||||
->where($this->authorIdColumn, $teacherId)
|
||||
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId($teacherId, $this->schoolYear);
|
||||
$classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments);
|
||||
|
||||
$drafts = $this->visibleTeacherDraftsQuery($teacherId, $classSectionIds)
|
||||
->select('exam_drafts.id, exam_drafts.status, exam_drafts.acceptance_type, exam_drafts.updated_at, exam_drafts.reviewed_at')
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->findAll();
|
||||
|
||||
@@ -808,6 +840,58 @@ class ExamDraftController extends BaseController
|
||||
return $validIds[0] ?? 0;
|
||||
}
|
||||
|
||||
private function visibleTeacherDraftsQuery(int $teacherId, array $classSectionIds)
|
||||
{
|
||||
$query = $this->examDraftModel
|
||||
->select($this->draftSelectColumns())
|
||||
->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last')
|
||||
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
|
||||
->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left');
|
||||
|
||||
if (empty($classSectionIds)) {
|
||||
return $query->where('exam_drafts.' . $this->authorIdColumn, $teacherId);
|
||||
}
|
||||
|
||||
$query->groupStart()
|
||||
->where('exam_drafts.' . $this->authorIdColumn, $teacherId)
|
||||
->orGroupStart()
|
||||
->whereIn('exam_drafts.class_section_id', $classSectionIds)
|
||||
->where('exam_drafts.' . $this->authorIdColumn . ' !=', $teacherId)
|
||||
->where('exam_drafts.status !=', 'draft');
|
||||
|
||||
if ($this->schoolYear !== '') {
|
||||
$query->where('exam_drafts.school_year', $this->schoolYear);
|
||||
}
|
||||
if ($this->semester !== '') {
|
||||
$query->where('exam_drafts.semester', $this->semester);
|
||||
}
|
||||
|
||||
$query->groupEnd()
|
||||
->groupEnd();
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function attachTeacherDraftContext(array $row, int $viewerId): array
|
||||
{
|
||||
$isOwn = $this->draftTeacherId($row) === $viewerId;
|
||||
$row['is_own_submission'] = $isOwn;
|
||||
$row['teacher_display_name'] = $isOwn ? 'You' : $this->draftTeacherName($row);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function draftTeacherName(array $draft): string
|
||||
{
|
||||
$name = trim(((string) ($draft['teacher_first'] ?? '')) . ' ' . ((string) ($draft['teacher_last'] ?? '')));
|
||||
if ($name !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$teacherId = $this->draftTeacherId($draft);
|
||||
return $teacherId > 0 ? 'Teacher #' . $teacherId : 'Teacher';
|
||||
}
|
||||
|
||||
private function statusBadgeMap(): array
|
||||
{
|
||||
return [
|
||||
@@ -944,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');
|
||||
@@ -953,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
|
||||
@@ -1307,9 +1423,14 @@ 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);
|
||||
@\exec($cmd);
|
||||
|
||||
return is_file($targetPath) ? basename($targetPath) : null;
|
||||
}
|
||||
@@ -1358,6 +1479,11 @@ class ExamDraftController extends BaseController
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeExamType($value): string
|
||||
{
|
||||
return trim((string) $value);
|
||||
}
|
||||
|
||||
private function copyDraftToFinal(string $draftFilename): ?string
|
||||
{
|
||||
$source = $this->fullUploadPath(self::TEACHER_UPLOAD_DIR, $draftFilename);
|
||||
|
||||
@@ -224,6 +224,10 @@ class FilesController extends Controller
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
if (!$this->canAccessExamDraftFile($name, $subdir)) {
|
||||
return $this->response->setStatusCode(403, 'You are not allowed to access this file.');
|
||||
}
|
||||
|
||||
$mime = 'application/octet-stream';
|
||||
if (function_exists('finfo_open')) {
|
||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
||||
@@ -317,4 +321,108 @@ class FilesController extends Controller
|
||||
}
|
||||
return 'teacher_file';
|
||||
}
|
||||
|
||||
private function resolveExamDraftAuthorIdColumn($db): string
|
||||
{
|
||||
try {
|
||||
$fields = $db->getFieldNames('exam_drafts');
|
||||
if (in_array('teacher_id', $fields, true)) {
|
||||
return 'teacher_id';
|
||||
}
|
||||
if (in_array('author_id', $fields, true)) {
|
||||
return 'author_id';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'FilesController::resolveExamDraftAuthorIdColumn error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return 'teacher_id';
|
||||
}
|
||||
|
||||
private function canAccessExamDraftFile(string $filename, string $subdir): bool
|
||||
{
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = strtolower((string) (session()->get('role') ?? ''));
|
||||
if (in_array($role, ['admin', 'administrator', 'principal'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$db = Database::connect();
|
||||
$fileColumn = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
|
||||
$authorIdColumn = $this->resolveExamDraftAuthorIdColumn($db);
|
||||
|
||||
$draft = $db->table('exam_drafts ed')
|
||||
->select('ed.class_section_id, ed.school_year, ed.semester, ed.status, ed.' . $authorIdColumn . ' AS draft_author_id')
|
||||
->where('ed.' . $fileColumn, $filename)
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if (empty($draft)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$authorId = (int) ($draft['draft_author_id'] ?? 0);
|
||||
if ($authorId > 0 && $authorId === $userId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$classSectionId = (int) ($draft['class_section_id'] ?? 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = strtolower(trim((string) ($draft['status'] ?? '')));
|
||||
if ($subdir === 'drafts' && $status === 'draft') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentSchoolYear = trim((string) (session()->get('school_year') ?? ''));
|
||||
$currentSemester = trim((string) (session()->get('semester') ?? ''));
|
||||
$draftSchoolYear = trim((string) ($draft['school_year'] ?? ''));
|
||||
$draftSemester = trim((string) ($draft['semester'] ?? ''));
|
||||
|
||||
$hasCurrentAssignment = $db->table('teacher_class')
|
||||
->select('id')
|
||||
->where('teacher_id', $userId)
|
||||
->where('class_section_id', $classSectionId);
|
||||
|
||||
if ($currentSchoolYear !== '') {
|
||||
$hasCurrentAssignment->where('school_year', $currentSchoolYear);
|
||||
}
|
||||
|
||||
$hasCurrentAssignment = $hasCurrentAssignment->limit(1)->countAllResults() > 0;
|
||||
|
||||
if ($hasCurrentAssignment) {
|
||||
if ($subdir === 'finals') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $draftSchoolYear === '' || $currentSchoolYear === '' || $draftSchoolYear === $currentSchoolYear;
|
||||
}
|
||||
|
||||
$assignmentQuery = $db->table('teacher_class')
|
||||
->select('id')
|
||||
->where('teacher_id', $userId)
|
||||
->where('class_section_id', $classSectionId);
|
||||
|
||||
if ($draftSchoolYear !== '') {
|
||||
$assignmentQuery->where('school_year', $draftSchoolYear);
|
||||
}
|
||||
|
||||
$hasDraftYearAssignment = $assignmentQuery->limit(1)->countAllResults() > 0;
|
||||
if (!$hasDraftYearAssignment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($subdir === 'finals') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $draftSemester === '' || $currentSemester === '' || $draftSemester === $currentSemester;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,17 +68,45 @@ public function financialReport()
|
||||
}
|
||||
|
||||
// === Invoices ===
|
||||
// Business rule: one active invoice per parent per school year.
|
||||
// If legacy data has multiple invoices, show only the latest one to avoid reporting older invoices as "Unpaid"
|
||||
// after new charges/payments update the current invoice.
|
||||
$db = \Config\Database::connect();
|
||||
if (!empty($schoolYear)) {
|
||||
$latestSub = $db->table('invoices')
|
||||
->select('parent_id, MAX(id) AS max_id')
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('parent_id')
|
||||
->getCompiledSelect();
|
||||
|
||||
$invBuilder = $db->table('invoices')
|
||||
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name", false)
|
||||
->join("($latestSub) latest", 'latest.max_id = invoices.id', 'inner', false)
|
||||
->join('users', 'users.id = invoices.parent_id', 'left');
|
||||
|
||||
if ($hasFrom) {
|
||||
$invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom);
|
||||
}
|
||||
if ($hasTo) {
|
||||
$invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo);
|
||||
}
|
||||
|
||||
$invoices = $invBuilder->get()->getResultArray();
|
||||
} else {
|
||||
$invoices = $invoiceModel
|
||||
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
|
||||
->join('users', 'users.id = invoices.parent_id')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
$invoiceIds = array_values(array_unique(array_filter(array_map(static function ($inv) {
|
||||
$id = (int)($inv['id'] ?? 0);
|
||||
return $id > 0 ? $id : null;
|
||||
}, $invoices))));
|
||||
|
||||
// Helper to build a fresh, filtered PaymentModel each time (so filters don't get lost between queries)
|
||||
$buildPaymentModel = function () use ($schoolYear, $dateFrom, $dateTo) {
|
||||
$pm = new PaymentModel();
|
||||
if ($schoolYear) {
|
||||
$pm->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($dateFrom)) {
|
||||
$pm->where('DATE(payment_date) >=', $dateFrom);
|
||||
}
|
||||
@@ -114,7 +142,13 @@ public function financialReport()
|
||||
};
|
||||
|
||||
// === Payments aggregated by invoice_id (for the "Paid" column) ===
|
||||
$payments = $applyPaymentFilters($buildPaymentModel())
|
||||
$paymentsQuery = $applyPaymentFilters($buildPaymentModel());
|
||||
if (!empty($invoiceIds)) {
|
||||
$paymentsQuery->whereIn('invoice_id', $invoiceIds);
|
||||
} else {
|
||||
$paymentsQuery->where('invoice_id', -1);
|
||||
}
|
||||
$payments = $paymentsQuery
|
||||
->select('invoice_id, SUM(paid_amount) AS paid_amount')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy('invoice_id')
|
||||
@@ -138,6 +172,7 @@ public function financialReport()
|
||||
SUM(paid_amount) AS amount
|
||||
")
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->whereIn('invoice_id', $invoiceIds ?: [-1])
|
||||
->groupBy('invoice_id, method')
|
||||
->findAll();
|
||||
|
||||
@@ -161,6 +196,7 @@ public function financialReport()
|
||||
SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN paid_amount ELSE 0 END) AS total_credit
|
||||
")
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->whereIn('invoice_id', $invoiceIds ?: [-1])
|
||||
->first() ?? [];
|
||||
|
||||
$paymentTotals = [
|
||||
|
||||
@@ -381,18 +381,24 @@ class InvoiceController extends ResourceController
|
||||
return false;
|
||||
}
|
||||
|
||||
public function generateInvoice(string $parentId = null)
|
||||
public function generateInvoice(
|
||||
string $parentId = null,
|
||||
?string $schoolYearOverride = null,
|
||||
?string $semesterOverride = null,
|
||||
bool $recalculateDiscounts = true
|
||||
)
|
||||
{
|
||||
$isAjax = $this->request->isAJAX() || str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json');
|
||||
if ($parentId == null) {
|
||||
$parentId = (int)$this->request->getPost('parent_id');
|
||||
}
|
||||
$schoolYear = (string) $this->schoolYear;
|
||||
$schoolYear = (string) ($schoolYearOverride ?: $this->schoolYear);
|
||||
$semester = (string) ($semesterOverride ?: $this->semester);
|
||||
|
||||
// Fetch enrolled + withdrawn students
|
||||
$enrollments = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
if (empty($enrollments)) {
|
||||
@@ -444,21 +450,23 @@ class InvoiceController extends ResourceController
|
||||
$tuitionFee = $fees['tuition_fee'];
|
||||
|
||||
// ✅ Fetch event charges
|
||||
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $this->schoolYear);
|
||||
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
|
||||
$eventchargeTotal = array_sum(array_column($eventsList, 'charged'));
|
||||
|
||||
$totalDiscount = 0.0;
|
||||
if ($recalculateDiscounts) {
|
||||
$totalDiscount = $this->recalculateAndUpdateDiscount(
|
||||
$parentId,
|
||||
$this->schoolYear,
|
||||
$schoolYear,
|
||||
$tuitionFee,
|
||||
$enrollments
|
||||
);
|
||||
}
|
||||
|
||||
$semester = $this->semester;
|
||||
// ✅ Refunds PAID to the parent for this year (Partial/Paid)
|
||||
$refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $this->schoolYear);
|
||||
$refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $schoolYear);
|
||||
|
||||
$totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $this->schoolYear);
|
||||
$totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $schoolYear);
|
||||
|
||||
$discountedTuition = max(0, $tuitionFee);
|
||||
$totalAmount = $discountedTuition + $eventchargeTotal;
|
||||
@@ -469,16 +477,21 @@ class InvoiceController extends ResourceController
|
||||
- $refundPaid // approved refunds paid to parent
|
||||
- $totalPaid; // payments received
|
||||
|
||||
// Your invoice fetch
|
||||
$existingInvoices = $this->invoiceModel->getInvoicesByParentId($parentId, $this->schoolYear);
|
||||
// Business rule: single invoice per parent per school year.
|
||||
// If legacy duplicates exist, prefer the invoice that already has a discount applied,
|
||||
// otherwise use the latest invoice for the parent/year.
|
||||
$invoice = $this->selectActiveInvoiceForParentYear((int)$parentId, $schoolYear);
|
||||
|
||||
$updated = false;
|
||||
|
||||
$updatedIds = [];
|
||||
if (!empty($existingInvoices)) {
|
||||
foreach ($existingInvoices as $invoice) {
|
||||
if (!isset($invoice['id'])) {
|
||||
continue;
|
||||
if (!empty($invoice) && isset($invoice['id'])) {
|
||||
$paymentExclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
$paymentsHasStatus = false;
|
||||
$paymentsHasVoid = false;
|
||||
try {
|
||||
$paymentsHasStatus = $this->db->fieldExists('status', 'payments');
|
||||
$paymentsHasVoid = $this->db->fieldExists('is_void', 'payments');
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
// Preserve applied additional charges and recalc this invoice only
|
||||
@@ -487,7 +500,7 @@ class InvoiceController extends ResourceController
|
||||
$rows = $this->db->table('additional_charges')
|
||||
->select('charge_type, amount')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('status', 'applied')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
@@ -520,7 +533,7 @@ class InvoiceController extends ResourceController
|
||||
$r = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$invRefunds = (float)($r['tot'] ?? 0.0);
|
||||
@@ -528,8 +541,33 @@ class InvoiceController extends ResourceController
|
||||
log_message('error', 'refund sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Payments recorded on this invoice (denormalized field kept in sync by PaymentController)
|
||||
// Payments recorded on this invoice (sum payments to avoid stale invoice.paid_amount)
|
||||
$paidOnInv = 0.0;
|
||||
try {
|
||||
$qb = $this->db->table('payments')
|
||||
->select('COALESCE(SUM(paid_amount),0) AS tot')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('paid_amount >', 0);
|
||||
|
||||
if ($paymentsHasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', $paymentExclude)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($paymentsHasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$row = $qb->get()->getRowArray();
|
||||
$paidOnInv = (float)($row['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'payment sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
$paidOnInv = (float)($invoice['paid_amount'] ?? 0.0);
|
||||
}
|
||||
|
||||
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
|
||||
$newStatus = ($newBalance <= 0.00001)
|
||||
@@ -547,7 +585,6 @@ class InvoiceController extends ResourceController
|
||||
$updatedIds[] = (int)$invoice['id'];
|
||||
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
|
||||
$updated = true;
|
||||
}
|
||||
} else {
|
||||
// Generate invoice number
|
||||
$schoolId = $this->userModel->getSchoolIdByUserId($parentId);
|
||||
@@ -578,7 +615,7 @@ class InvoiceController extends ResourceController
|
||||
// Initial balance equals the created total; discounts/refunds/payments will adjust later
|
||||
'balance' => $totalAmount,
|
||||
'status' => 'Unpaid',
|
||||
'school_year' => $this->schoolYear,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'issue_date' => $issueUtc,
|
||||
'due_date' => $dueUtc,
|
||||
@@ -615,6 +652,47 @@ class InvoiceController extends ResourceController
|
||||
->with('success', $updated ? 'Invoice updated.' : 'Invoice created.');
|
||||
}
|
||||
|
||||
private function selectActiveInvoiceForParentYear(int $parentId, string $schoolYear): ?array
|
||||
{
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer invoices flagged as having discounts.
|
||||
$invoice = $this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('has_discount', 1)
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
if (!empty($invoice)) {
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
// Fallback: prefer invoices with discount_usages rows.
|
||||
try {
|
||||
$row = $this->db->table('invoices i')
|
||||
->select('i.*')
|
||||
->join('discount_usages du', 'du.invoice_id = i.id', 'inner')
|
||||
->where('i.parent_id', $parentId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->orderBy('i.id', 'DESC')
|
||||
->get()
|
||||
->getRowArray();
|
||||
if (!empty($row)) {
|
||||
return $row;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
// Final fallback: latest invoice for parent/year.
|
||||
return $this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function recalculateAndUpdateDiscount(
|
||||
int $parentId,
|
||||
string $schoolYear,
|
||||
@@ -678,7 +756,7 @@ class InvoiceController extends ResourceController
|
||||
log_message('error', 'additional_charges sum failed for discount recalculation invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$baseTotal = round($tuitionFee + $eventchargeTotal + $extrasSum, 2);
|
||||
$baseTotal = round($tuitionFee + $extrasSum, 2);
|
||||
|
||||
// Recalculate discount
|
||||
if ($discountUsage['discount_type'] === 'percent') {
|
||||
@@ -1266,6 +1344,12 @@ class InvoiceController extends ResourceController
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($studentName === 'N/A') {
|
||||
$externalName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? ''));
|
||||
if ($externalName !== '') {
|
||||
$studentName = $externalName . ' (external)';
|
||||
}
|
||||
}
|
||||
|
||||
$dt = $toLocal($event['created_at'] ?? null, false);
|
||||
$amount = (float)($event['charged'] ?? 0.0);
|
||||
@@ -1612,12 +1696,24 @@ private function getGradeLevel($grade): array
|
||||
$invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null;
|
||||
}
|
||||
|
||||
$invoiceEventCharges = [];
|
||||
foreach ($invoices as $invoice) {
|
||||
$year = $invoice['school_year'] ?? $this->schoolYear;
|
||||
$sem = $invoice['semester'] ?? $this->semester;
|
||||
$invoiceEventCharges[(int)$invoice['id']] = $this->chargesModel->getChargesWithEventInfo(
|
||||
$invoice['parent_id'],
|
||||
$year,
|
||||
$sem
|
||||
);
|
||||
}
|
||||
|
||||
return view('/parent/invoice_payment', [
|
||||
'invoices' => $invoices,
|
||||
'schoolYears' => $schoolYears,
|
||||
'selectedYear' => $selectedYear,
|
||||
'currentSchoolYear' => $currentSchoolYear,
|
||||
'dueDate' => $this->dueDate
|
||||
'dueDate' => $this->dueDate,
|
||||
'invoiceEventCharges' => $invoiceEventCharges,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1797,16 +1797,34 @@ $existing = $this->studentModel
|
||||
$activeEventCount = is_array($activeEvents) ? count($activeEvents) : 0;
|
||||
|
||||
// Get charges (participation info)
|
||||
$chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
|
||||
$chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $semester);
|
||||
|
||||
// Build a map: "studentId:eventId" => [ 'participation' => ..., 'date' => ... ]
|
||||
$charges = [];
|
||||
$externalParticipantsByEvent = [];
|
||||
foreach ($chargesList as $charge) {
|
||||
$key = $charge['student_id'] . ':' . $charge['event_id'];
|
||||
$studentId = $charge['student_id'] ?? null;
|
||||
$eventId = (int) ($charge['event_id'] ?? 0);
|
||||
|
||||
if (!empty($studentId)) {
|
||||
$key = $studentId . ':' . $eventId;
|
||||
$charges[$key] = [
|
||||
'participation' => $charge['participation'],
|
||||
'date' => $charge['updated_at'] ?? $charge['created_at'] // Use updated_at if available
|
||||
'date' => $charge['updated_at'] ?? $charge['created_at'], // Use updated_at if available
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$externalName = trim((string) ($charge['external_firstname'] ?? '') . ' ' . (string) ($charge['external_lastname'] ?? ''));
|
||||
if ($eventId > 0 && $externalName !== '') {
|
||||
$externalParticipantsByEvent[$eventId][] = [
|
||||
'name' => $externalName,
|
||||
'note' => (string) ($charge['external_note'] ?? ''),
|
||||
'participation' => (string) ($charge['participation'] ?? ''),
|
||||
'event_paid' => !empty($charge['event_paid']),
|
||||
'charged' => (float) ($charge['charged'] ?? ($charge['event_amount'] ?? 0)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Get enrolled students
|
||||
@@ -1815,6 +1833,7 @@ $existing = $this->studentModel
|
||||
return view('parent/event_participation', [
|
||||
'activeEvents' => $activeEvents,
|
||||
'charges' => $charges,
|
||||
'externalParticipantsByEvent' => $externalParticipantsByEvent,
|
||||
'yourStudents' => $students,
|
||||
'activeEventCount' => $activeEventCount,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\ConfigurationModel;
|
||||
|
||||
class TrophyController extends BaseController
|
||||
{
|
||||
private const PERCENTILE = 75.0;
|
||||
|
||||
protected ConfigurationModel $configModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->configModel = new ConfigurationModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
$currentYear = $this->configModel->getConfig('school_year') ?? '';
|
||||
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
|
||||
|
||||
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
|
||||
$percentile = max(1.0, min(99.0, $percentile));
|
||||
|
||||
// Available school years from class assignments so the current year can
|
||||
// still appear even if fall scores are not fully entered yet.
|
||||
$years = $db->table('student_class')
|
||||
->select('school_year')
|
||||
->distinct()
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
$years = array_column($years, 'school_year');
|
||||
|
||||
// Build a fall-score-based projection. We start from class assignments so
|
||||
// students without a recorded fall score still appear as "not yet
|
||||
// projected" rather than disappearing from the page.
|
||||
$rows = $db->table('student_class sc')
|
||||
->select([
|
||||
'sc.student_id',
|
||||
'sc.class_section_id',
|
||||
'MAX(ss.semester_score) AS fall_score',
|
||||
'cs.class_section_name',
|
||||
'CONCAT(s.firstname, " ", s.lastname) AS student_name',
|
||||
's.school_id',
|
||||
's.gender',
|
||||
])
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
||||
->join('students s', 's.id = sc.student_id', 'left')
|
||||
->join(
|
||||
'semester_scores ss',
|
||||
'ss.student_id = sc.student_id'
|
||||
. ' AND ss.class_section_id = sc.class_section_id'
|
||||
. ' AND ss.school_year = ' . $db->escape($selectedYear)
|
||||
. ' AND LOWER(ss.semester) = "fall"',
|
||||
'left'
|
||||
)
|
||||
->where('sc.school_year', $selectedYear)
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderBy('fall_score', 'DESC')
|
||||
->orderBy('student_name', 'ASC')
|
||||
->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
// Group rows by class section
|
||||
$sections = [];
|
||||
foreach ($rows as $row) {
|
||||
$sid = (int) ($row['class_section_id'] ?? 0);
|
||||
$name = $row['class_section_name'] ?? 'Section ' . $sid;
|
||||
if (!isset($sections[$sid])) {
|
||||
$sections[$sid] = ['name' => $name, 'students' => []];
|
||||
}
|
||||
$sections[$sid]['students'][] = [
|
||||
'student_id' => (int) $row['student_id'],
|
||||
'name' => $row['student_name'],
|
||||
'school_id' => $row['school_id'],
|
||||
'gender' => $row['gender'] ?? '',
|
||||
'fall_score' => is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate trophy thresholds from fall scores only, then project who is
|
||||
// currently on track for a year-end trophy.
|
||||
$classResults = [];
|
||||
foreach ($sections as $sid => $section) {
|
||||
usort($section['students'], static function (array $a, array $b): int {
|
||||
$aScore = $a['fall_score'];
|
||||
$bScore = $b['fall_score'];
|
||||
|
||||
if ($aScore === null && $bScore === null) {
|
||||
return strcmp($a['name'], $b['name']);
|
||||
}
|
||||
|
||||
if ($aScore === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($bScore === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return $bScore <=> $aScore ?: strcmp($a['name'], $b['name']);
|
||||
});
|
||||
|
||||
$scores = array_column($section['students'], 'fall_score');
|
||||
$calc = $this->calculateThreshold($scores, $percentile);
|
||||
$threshold = $calc['threshold'];
|
||||
|
||||
$students = array_map(function (array $student) use ($threshold): array {
|
||||
$student['projected_trophy'] = $threshold !== null
|
||||
&& $student['fall_score'] !== null
|
||||
&& $student['fall_score'] >= $threshold;
|
||||
return $student;
|
||||
}, $section['students']);
|
||||
|
||||
$scoredCount = count(array_filter(
|
||||
array_column($students, 'fall_score'),
|
||||
static fn ($score) => $score !== null
|
||||
));
|
||||
|
||||
$boys = array_filter($students, static fn ($s) => strtolower($s['gender']) === 'male');
|
||||
$girls = array_filter($students, static fn ($s) => strtolower($s['gender']) === 'female');
|
||||
$trophyBoys = array_filter($boys, static fn ($s) => $s['projected_trophy']);
|
||||
$trophyGirls = array_filter($girls, static fn ($s) => $s['projected_trophy']);
|
||||
$nBoys = count($boys);
|
||||
$nGirls = count($girls);
|
||||
$nTrophyBoys = count($trophyBoys);
|
||||
$nTrophyGirls = count($trophyGirls);
|
||||
$total = count($students);
|
||||
|
||||
$classResults[] = [
|
||||
'section_id' => $sid,
|
||||
'section_name' => $section['name'],
|
||||
'students' => $students,
|
||||
'threshold' => $threshold,
|
||||
'trophy_count' => $calc['winners'],
|
||||
'student_count' => $total,
|
||||
'scored_count' => $scoredCount,
|
||||
'method' => $calc['method'],
|
||||
'boys' => $nBoys,
|
||||
'girls' => $nGirls,
|
||||
'trophy_boys' => $nTrophyBoys,
|
||||
'trophy_girls' => $nTrophyGirls,
|
||||
'pct_boys' => $total > 0 ? round($nBoys / $total * 100) : 0,
|
||||
'pct_girls' => $total > 0 ? round($nGirls / $total * 100) : 0,
|
||||
'pct_trophy_boys' => $nBoys > 0 ? round($nTrophyBoys / $nBoys * 100) : 0,
|
||||
'pct_trophy_girls' => $nGirls > 0 ? round($nTrophyGirls / $nGirls * 100) : 0,
|
||||
'pct_trophy_total' => $total > 0 ? round($calc['winners'] / $total * 100): 0,
|
||||
];
|
||||
}
|
||||
|
||||
return view('administrator/trophy', [
|
||||
'classResults' => $classResults,
|
||||
'selectedYear' => $selectedYear,
|
||||
'selectedPercentile'=> $percentile,
|
||||
'years' => $years,
|
||||
]);
|
||||
}
|
||||
|
||||
public function winners()
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
$currentYear = $this->configModel->getConfig('school_year') ?? '';
|
||||
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
|
||||
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
|
||||
$percentile = max(1.0, min(99.0, $percentile));
|
||||
|
||||
$years = $db->table('student_class')
|
||||
->select('school_year')->distinct()
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()->getResultArray();
|
||||
$years = array_column($years, 'school_year');
|
||||
|
||||
$ey = $db->escape($selectedYear);
|
||||
|
||||
$rows = $db->table('student_class sc')
|
||||
->select([
|
||||
'sc.student_id',
|
||||
'sc.class_section_id',
|
||||
'cs.class_section_name',
|
||||
'CONCAT(s.firstname, " ", s.lastname) AS student_name',
|
||||
's.school_id',
|
||||
's.gender',
|
||||
'MAX(sf.semester_score) AS fall_score',
|
||||
'MAX(sp.semester_score) AS spring_score',
|
||||
])
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
||||
->join('students s', 's.id = sc.student_id', 'left')
|
||||
->join('semester_scores sf',
|
||||
'sf.student_id = sc.student_id AND sf.class_section_id = sc.class_section_id'
|
||||
. ' AND sf.school_year = ' . $ey . ' AND LOWER(sf.semester) = "fall"', 'left')
|
||||
->join('semester_scores sp',
|
||||
'sp.student_id = sc.student_id AND sp.class_section_id = sc.class_section_id'
|
||||
. ' AND sp.school_year = ' . $ey . ' AND LOWER(sp.semester) = "spring"', 'left')
|
||||
->where('sc.school_year', $selectedYear)
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderBy('student_name', 'ASC')
|
||||
->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id, s.gender')
|
||||
->get()->getResultArray();
|
||||
|
||||
$sections = [];
|
||||
foreach ($rows as $row) {
|
||||
$sid = (int) ($row['class_section_id'] ?? 0);
|
||||
$name = $row['class_section_name'] ?? 'Section ' . $sid;
|
||||
if (!isset($sections[$sid])) $sections[$sid] = ['name' => $name, 'students' => []];
|
||||
$fall = is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null;
|
||||
$spring = is_numeric($row['spring_score']) ? (float) $row['spring_score'] : null;
|
||||
$year = ($fall !== null && $spring !== null)
|
||||
? round(($fall + $spring) / 2, 1)
|
||||
: ($fall ?? $spring);
|
||||
$sections[$sid]['students'][] = [
|
||||
'name' => $row['student_name'],
|
||||
'school_id' => $row['school_id'],
|
||||
'gender' => $row['gender'] ?? '',
|
||||
'fall_score' => $fall,
|
||||
'spring_score' => $spring,
|
||||
'year_score' => $year,
|
||||
];
|
||||
}
|
||||
|
||||
$classResults = [];
|
||||
foreach ($sections as $section) {
|
||||
usort($section['students'], static function ($a, $b) {
|
||||
if ($a['fall_score'] === null && $b['fall_score'] === null) return strcmp($a['name'], $b['name']);
|
||||
if ($a['fall_score'] === null) return 1;
|
||||
if ($b['fall_score'] === null) return -1;
|
||||
return $b['fall_score'] <=> $a['fall_score'] ?: strcmp($a['name'], $b['name']);
|
||||
});
|
||||
|
||||
$scores = array_column($section['students'], 'fall_score');
|
||||
$calc = $this->calculateThreshold($scores, $percentile);
|
||||
$threshold = $calc['threshold'];
|
||||
|
||||
$allStudents = $section['students'];
|
||||
$winners = array_values(array_filter($allStudents, function ($s) use ($threshold) {
|
||||
return $threshold !== null && $s['fall_score'] !== null && $s['fall_score'] >= $threshold;
|
||||
}));
|
||||
|
||||
if (empty($winners)) continue;
|
||||
|
||||
$boys = array_filter($allStudents, static fn($s) => strtolower($s['gender']) === 'male');
|
||||
$girls = array_filter($allStudents, static fn($s) => strtolower($s['gender']) === 'female');
|
||||
$trophyBoys = array_filter($winners, static fn($s) => strtolower($s['gender']) === 'male');
|
||||
$trophyGirls = array_filter($winners, static fn($s) => strtolower($s['gender']) === 'female');
|
||||
$n = count($allStudents);
|
||||
$nBoys = count($boys);
|
||||
$nGirls = count($girls);
|
||||
$nTB = count($trophyBoys);
|
||||
$nTG = count($trophyGirls);
|
||||
|
||||
$classResults[] = [
|
||||
'section_name' => $section['name'],
|
||||
'threshold' => $threshold,
|
||||
'winners' => $winners,
|
||||
'student_count' => $n,
|
||||
'boys' => $nBoys,
|
||||
'girls' => $nGirls,
|
||||
'trophy_boys' => $nTB,
|
||||
'trophy_girls' => $nTG,
|
||||
'pct_boys' => $n > 0 ? round($nBoys / $n * 100) : 0,
|
||||
'pct_girls' => $n > 0 ? round($nGirls / $n * 100) : 0,
|
||||
'pct_trophy_boys' => $nBoys > 0 ? round($nTB / $nBoys * 100) : 0,
|
||||
'pct_trophy_girls' => $nGirls > 0 ? round($nTG / $nGirls * 100) : 0,
|
||||
'pct_trophy_total' => $n > 0 ? round(count($winners) / $n * 100) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
return view('administrator/trophy_winners', [
|
||||
'classResults' => $classResults,
|
||||
'selectedYear' => $selectedYear,
|
||||
'selectedPercentile' => $percentile,
|
||||
'years' => $years,
|
||||
]);
|
||||
}
|
||||
|
||||
public function final()
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
$currentYear = $this->configModel->getConfig('school_year') ?? '';
|
||||
$selectedYear = $this->request->getGet('school_year') ?? $currentYear;
|
||||
$percentile = (float) ($this->request->getGet('percentile') ?? 75);
|
||||
$percentile = max(1.0, min(99.0, $percentile));
|
||||
|
||||
$years = $db->table('student_class')
|
||||
->select('school_year')->distinct()
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()->getResultArray();
|
||||
$years = array_column($years, 'school_year');
|
||||
|
||||
$ey = $db->escape($selectedYear);
|
||||
|
||||
$rows = $db->table('student_class sc')
|
||||
->select([
|
||||
'sc.student_id',
|
||||
'sc.class_section_id',
|
||||
'cs.class_section_name',
|
||||
'CONCAT(s.firstname, " ", s.lastname) AS student_name',
|
||||
's.school_id',
|
||||
's.gender',
|
||||
'MAX(sf.semester_score) AS fall_score',
|
||||
'MAX(sp.semester_score) AS spring_score',
|
||||
])
|
||||
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
|
||||
->join('students s', 's.id = sc.student_id', 'left')
|
||||
->join('semester_scores sf',
|
||||
'sf.student_id = sc.student_id AND sf.class_section_id = sc.class_section_id'
|
||||
. ' AND sf.school_year = ' . $ey . ' AND LOWER(sf.semester) = "fall"', 'left')
|
||||
->join('semester_scores sp',
|
||||
'sp.student_id = sc.student_id AND sp.class_section_id = sc.class_section_id'
|
||||
. ' AND sp.school_year = ' . $ey . ' AND LOWER(sp.semester) = "spring"', 'left')
|
||||
->where('sc.school_year', $selectedYear)
|
||||
->orderBy('cs.class_section_name', 'ASC')
|
||||
->orderBy('student_name', 'ASC')
|
||||
->groupBy('sc.student_id, sc.class_section_id, cs.class_section_name, s.firstname, s.lastname, s.school_id, s.gender')
|
||||
->get()->getResultArray();
|
||||
|
||||
$sections = [];
|
||||
foreach ($rows as $row) {
|
||||
$sid = (int) ($row['class_section_id'] ?? 0);
|
||||
$name = $row['class_section_name'] ?? 'Section ' . $sid;
|
||||
if (!isset($sections[$sid])) $sections[$sid] = ['name' => $name, 'students' => []];
|
||||
|
||||
$fall = is_numeric($row['fall_score']) ? (float) $row['fall_score'] : null;
|
||||
$spring = is_numeric($row['spring_score']) ? (float) $row['spring_score'] : null;
|
||||
$year = ($fall !== null && $spring !== null)
|
||||
? round(($fall + $spring) / 2, 1)
|
||||
: ($fall ?? $spring);
|
||||
|
||||
$sections[$sid]['students'][] = [
|
||||
'name' => $row['student_name'],
|
||||
'school_id' => $row['school_id'],
|
||||
'gender' => $row['gender'] ?? '',
|
||||
'fall_score' => $fall,
|
||||
'spring_score' => $spring,
|
||||
'year_score' => $year,
|
||||
];
|
||||
}
|
||||
|
||||
$classResults = [];
|
||||
foreach ($sections as $section) {
|
||||
$students = $section['students'];
|
||||
|
||||
$fallCalc = $this->calculateThreshold(array_column($students, 'fall_score'), $percentile);
|
||||
$yearCalc = $this->calculateThreshold(array_column($students, 'year_score'), $percentile);
|
||||
$fallThreshold = $fallCalc['threshold'];
|
||||
$yearThreshold = $yearCalc['threshold'];
|
||||
|
||||
$annotated = array_map(static function (array $s) use ($fallThreshold, $yearThreshold): array {
|
||||
$predicted = $fallThreshold !== null && $s['fall_score'] !== null && $s['fall_score'] >= $fallThreshold;
|
||||
$actual = $yearThreshold !== null && $s['year_score'] !== null && $s['year_score'] >= $yearThreshold;
|
||||
$status = match (true) {
|
||||
$predicted && $actual => 'confirmed',
|
||||
!$predicted && $actual => 'surprise',
|
||||
$predicted && !$actual => 'missed',
|
||||
default => 'none',
|
||||
};
|
||||
return $s + compact('predicted', 'actual', 'status');
|
||||
}, $students);
|
||||
|
||||
usort($annotated, static function (array $a, array $b): int {
|
||||
if ($a['year_score'] === null && $b['year_score'] === null) return strcmp($a['name'], $b['name']);
|
||||
if ($a['year_score'] === null) return 1;
|
||||
if ($b['year_score'] === null) return -1;
|
||||
return $b['year_score'] <=> $a['year_score'] ?: strcmp($a['name'], $b['name']);
|
||||
});
|
||||
|
||||
$nConfirmed = count(array_filter($annotated, static fn ($s) => $s['status'] === 'confirmed'));
|
||||
$nSurprise = count(array_filter($annotated, static fn ($s) => $s['status'] === 'surprise'));
|
||||
$nMissed = count(array_filter($annotated, static fn ($s) => $s['status'] === 'missed'));
|
||||
$nPredicted = count(array_filter($annotated, static fn ($s) => $s['predicted']));
|
||||
$nActual = count(array_filter($annotated, static fn ($s) => $s['actual']));
|
||||
$n = count($annotated);
|
||||
|
||||
$classResults[] = [
|
||||
'section_name' => $section['name'],
|
||||
'students' => $annotated,
|
||||
'student_count' => $n,
|
||||
'fall_threshold' => $fallThreshold,
|
||||
'year_threshold' => $yearThreshold,
|
||||
'predicted_count' => $nPredicted,
|
||||
'actual_count' => $nActual,
|
||||
'confirmed' => $nConfirmed,
|
||||
'surprises' => $nSurprise,
|
||||
'missed' => $nMissed,
|
||||
'accuracy' => $nPredicted > 0 ? round($nConfirmed / $nPredicted * 100) : ($nActual === 0 ? 100 : 0),
|
||||
];
|
||||
}
|
||||
|
||||
return view('administrator/trophy_final', [
|
||||
'classResults' => $classResults,
|
||||
'selectedYear' => $selectedYear,
|
||||
'selectedPercentile' => $percentile,
|
||||
'years' => $years,
|
||||
]);
|
||||
}
|
||||
|
||||
private function calculateThreshold(array $scores, float $percentile = 75.0): array
|
||||
{
|
||||
$scores = array_values(array_filter(
|
||||
$scores,
|
||||
static fn ($v) => is_numeric($v) && $v !== null
|
||||
));
|
||||
$scores = array_map('floatval', $scores);
|
||||
sort($scores); // ascending
|
||||
$n = count($scores);
|
||||
|
||||
if ($n === 0) {
|
||||
return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
|
||||
}
|
||||
|
||||
$minWinners = 3; // never fewer than 3, regardless of class size or score count
|
||||
// Hard maximum: top (100 - percentile)% of class, but never below the minimum.
|
||||
$maxWinners = max($minWinners, (int) floor($n * (1 - $percentile / 100)));
|
||||
|
||||
$threshold = $this->empiricalPercentile($scores, $percentile);
|
||||
$winners = $this->countAtOrAbove($scores, $threshold);
|
||||
|
||||
if ($winners < $minWinners) {
|
||||
// Too few qualify — REDUCE the threshold down to the score of the
|
||||
// 3rd-ranked student so the minimum of 3 is always met.
|
||||
// If fewer than 3 students have scores, award all of them.
|
||||
$target = min($minWinners, $n);
|
||||
$desc = array_reverse($scores); // descending
|
||||
$threshold = $desc[$target - 1]; // score at rank $target
|
||||
$winners = $this->countAtOrAbove($scores, $threshold); // ties included
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
|
||||
}
|
||||
|
||||
if ($winners <= $maxWinners) {
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
|
||||
}
|
||||
|
||||
// Too many winners — raise threshold to respect the cap.
|
||||
$result = $this->capByRank($scores, $maxWinners);
|
||||
|
||||
// capByRank may overshoot and drop below the minimum (e.g. bimodal scores
|
||||
// where only 2 students are in the top cluster). Enforce min=3 here too.
|
||||
if ($result['winners'] < $minWinners) {
|
||||
$target = min($minWinners, $n);
|
||||
$desc = array_reverse($scores);
|
||||
$threshold = $desc[$target - 1];
|
||||
$winners = $this->countAtOrAbove($scores, $threshold);
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise the threshold until at most $max students qualify.
|
||||
* Returns the result without checking the minimum — caller must do that.
|
||||
*/
|
||||
private function capByRank(array $sortedScores, int $max): array
|
||||
{
|
||||
$desc = array_reverse($sortedScores);
|
||||
$threshold = $desc[$max - 1];
|
||||
$winners = $this->countAtOrAbove($sortedScores, $threshold);
|
||||
|
||||
if ($winners <= $max) {
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
|
||||
}
|
||||
|
||||
// Ties push it over — walk up through distinct scores.
|
||||
$unique = array_values(array_unique(array_filter(
|
||||
$sortedScores,
|
||||
static fn ($s) => $s > $threshold
|
||||
)));
|
||||
sort($unique);
|
||||
|
||||
foreach ($unique as $candidate) {
|
||||
$w = $this->countAtOrAbove($sortedScores, $candidate);
|
||||
if ($w <= $max) {
|
||||
return ['threshold' => $candidate, 'winners' => $w, 'method' => 'capped_25pct'];
|
||||
}
|
||||
}
|
||||
|
||||
// All scores are equal.
|
||||
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
|
||||
}
|
||||
|
||||
private function empiricalPercentile(array $sortedScores, float $p): float
|
||||
{
|
||||
$n = count($sortedScores);
|
||||
if ($n === 0) return 0.0;
|
||||
$index = ($p / 100.0) * ($n - 1);
|
||||
$lower = (int) floor($index);
|
||||
$upper = (int) ceil($index);
|
||||
if ($lower === $upper) return $sortedScores[$lower];
|
||||
return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]);
|
||||
}
|
||||
|
||||
private function countAtOrAbove(array $scores, float $threshold): int
|
||||
{
|
||||
return count(array_filter($scores, static fn ($s) => $s >= $threshold));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddEventPaidFlagToCharges extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$fields = [
|
||||
'event_paid' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'default' => 0,
|
||||
'after' => 'charged',
|
||||
],
|
||||
];
|
||||
|
||||
$this->forge->addColumn('event_charges', $fields);
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
$this->forge->dropColumn('event_charges', 'event_paid');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddClassSectionToEventCharges extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$fields = [
|
||||
'class_section_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'null' => true,
|
||||
'after' => 'event_paid',
|
||||
],
|
||||
];
|
||||
|
||||
if (! $this->db->fieldExists('class_section_id', 'event_charges')) {
|
||||
$this->forge->addColumn('event_charges', $fields);
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if ($this->db->fieldExists('class_section_id', 'event_charges')) {
|
||||
$this->forge->dropColumn('event_charges', 'class_section_id');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddEventPaymentRefToEventCharges extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$fields = [
|
||||
'event_payment_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'null' => true,
|
||||
'after' => 'class_section_id',
|
||||
],
|
||||
];
|
||||
|
||||
if (! $this->db->fieldExists('event_payment_id', 'event_charges')) {
|
||||
$this->forge->addColumn('event_charges', $fields);
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if ($this->db->fieldExists('event_payment_id', 'event_charges')) {
|
||||
$this->forge->dropColumn('event_charges', 'event_payment_id');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddExternalParticipantFieldsToEventCharges extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$fields = [
|
||||
'external_firstname' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 150,
|
||||
'null' => true,
|
||||
'after' => 'event_payment_id',
|
||||
],
|
||||
'external_lastname' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 150,
|
||||
'null' => true,
|
||||
],
|
||||
'external_note' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 254,
|
||||
'null' => true,
|
||||
],
|
||||
];
|
||||
|
||||
if (! $this->db->fieldExists('external_firstname', 'event_charges')) {
|
||||
$this->forge->addColumn('event_charges', $fields);
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
foreach (['external_firstname', 'external_lastname', 'external_note'] as $field) {
|
||||
if ($this->db->fieldExists($field, 'event_charges')) {
|
||||
$this->forge->dropColumn('event_charges', $field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddExternalParentInfoToEventCharges extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$fields = [
|
||||
'external_parent_firstname' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 150,
|
||||
'null' => true,
|
||||
'after' => 'external_note',
|
||||
],
|
||||
'external_parent_lastname' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 150,
|
||||
'null' => true,
|
||||
],
|
||||
'external_parent_phone' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 50,
|
||||
'null' => true,
|
||||
],
|
||||
];
|
||||
|
||||
if (! $this->db->fieldExists('external_parent_firstname', 'event_charges')) {
|
||||
$this->forge->addColumn('event_charges', $fields);
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
foreach (['external_parent_firstname', 'external_parent_lastname', 'external_parent_phone'] as $field) {
|
||||
if ($this->db->fieldExists($field, 'event_charges')) {
|
||||
$this->forge->dropColumn('event_charges', $field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddExternalParentEmailToEventCharges extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$fields = [
|
||||
'external_parent_email' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 254,
|
||||
'null' => true,
|
||||
'after' => 'external_parent_phone',
|
||||
],
|
||||
];
|
||||
|
||||
if (! $this->db->fieldExists('external_parent_email', 'event_charges')) {
|
||||
$this->forge->addColumn('event_charges', $fields);
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if ($this->db->fieldExists('external_parent_email', 'event_charges')) {
|
||||
$this->forge->dropColumn('event_charges', 'external_parent_email');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddWaiverSignedToEventCharges extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$fields = [
|
||||
'waiver_signed' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'default' => 0,
|
||||
'after' => 'charged',
|
||||
],
|
||||
];
|
||||
|
||||
if (! $this->db->fieldExists('waiver_signed', 'event_charges')) {
|
||||
$this->forge->addColumn('event_charges', $fields);
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if ($this->db->fieldExists('waiver_signed', 'event_charges')) {
|
||||
$this->forge->dropColumn('event_charges', 'waiver_signed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateCertificateRecords extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if ($this->db->tableExists('certificate_records')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'auto_increment' => true,
|
||||
],
|
||||
'certificate_number' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 30,
|
||||
],
|
||||
'student_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
],
|
||||
'student_name' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 200,
|
||||
],
|
||||
'grade' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 100,
|
||||
'null' => true,
|
||||
],
|
||||
'cert_date' => [
|
||||
'type' => 'DATE',
|
||||
'null' => true,
|
||||
],
|
||||
'school_year' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 20,
|
||||
'null' => true,
|
||||
],
|
||||
'class_section_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'null' => true,
|
||||
],
|
||||
'issued_by' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'null' => true,
|
||||
],
|
||||
'issued_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'updated_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addUniqueKey('certificate_number');
|
||||
$this->forge->addKey(['school_year', 'student_id']);
|
||||
$this->forge->createTable('certificate_records');
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
$this->forge->dropTable('certificate_records', true);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ class ApiAuthFilter implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
helper('security');
|
||||
|
||||
$authorization = $request->getHeaderLine('Authorization');
|
||||
if (!$authorization || stripos($authorization, 'Bearer ') !== 0) {
|
||||
return $this->unauthorized('Missing or invalid Authorization header');
|
||||
@@ -21,7 +23,12 @@ class ApiAuthFilter implements FilterInterface
|
||||
return $this->unauthorized('Bearer token is required');
|
||||
}
|
||||
|
||||
$secret = env('JWT_SECRET', 'change-me-in-env');
|
||||
try {
|
||||
$secret = require_env('JWT_SECRET');
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->unauthorized('JWT configuration is missing');
|
||||
}
|
||||
|
||||
$payload = jwt_decode($token, $secret);
|
||||
|
||||
if (!$payload || empty($payload['sub'])) {
|
||||
|
||||
@@ -13,13 +13,15 @@ class ApiDocsAuthFilter implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
helper('security');
|
||||
|
||||
$authHeader = $request->getHeaderLine('Authorization');
|
||||
|
||||
if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
|
||||
$token = trim(substr($authHeader, 7));
|
||||
|
||||
try {
|
||||
$key = getenv('JWT_SECRET') ?: 'your_default_secret';
|
||||
$key = require_env('JWT_SECRET');
|
||||
$decoded = JWT::decode($token, new Key($key, 'HS256'));
|
||||
|
||||
if (!isset($decoded->roles) || empty($decoded->roles->admin)) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Services;
|
||||
|
||||
class ApiRateLimitFilter implements FilterInterface
|
||||
{
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
$throttler = service('throttler');
|
||||
$response = Services::response();
|
||||
|
||||
$maxRequests = isset($arguments[0]) ? max(1, (int) $arguments[0]) : 60;
|
||||
$windowSeconds = isset($arguments[1]) ? max(1, (int) $arguments[1]) : MINUTE;
|
||||
|
||||
$userId = session()->get('user_id');
|
||||
$identifier = $userId ? 'user:' . $userId : 'ip:' . $request->getIPAddress();
|
||||
$route = trim($request->getUri()->getPath(), '/');
|
||||
$key = 'api-rate-' . sha1($request->getMethod() . '|' . $route . '|' . $identifier);
|
||||
|
||||
if (! $throttler->check($key, $maxRequests, $windowSeconds)) {
|
||||
return $response
|
||||
->setStatusCode(429)
|
||||
->setHeader('Retry-After', (string) $windowSeconds)
|
||||
->setJSON([
|
||||
'status' => false,
|
||||
'message' => 'Too many requests. Please try again later.',
|
||||
]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
{
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,13 @@ class AuthFilter implements FilterInterface
|
||||
|
||||
// Must be logged in
|
||||
if (empty($userRoles) || empty($userId)) {
|
||||
return redirect()->to('/login');
|
||||
$target = ltrim($request->getUri()->getPath(), '/');
|
||||
$query = $request->getUri()->getQuery();
|
||||
if ($query !== '') {
|
||||
$target .= '?' . $query;
|
||||
}
|
||||
|
||||
return redirect()->to('/login?redirect_to=' . rawurlencode('/' . ltrim($target, '/')));
|
||||
}
|
||||
|
||||
// Enforce 12-hour session lifetime
|
||||
@@ -110,7 +116,14 @@ class AuthFilter implements FilterInterface
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->to('/login')->with('error', 'Your session has expired. Please log in again.');
|
||||
$target = ltrim($request->getUri()->getPath(), '/');
|
||||
$query = $request->getUri()->getQuery();
|
||||
if ($query !== '') {
|
||||
$target .= '?' . $query;
|
||||
}
|
||||
|
||||
return redirect()->to('/login?redirect_to=' . rawurlencode('/' . ltrim($target, '/')))
|
||||
->with('error', 'Your session has expired. Please log in again.');
|
||||
}
|
||||
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||
|
||||
@@ -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,3 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/AuthHelper.php';
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
if (! function_exists('sanitize_request_value')) {
|
||||
/**
|
||||
* Recursively normalize request values without applying output encoding.
|
||||
*/
|
||||
function sanitize_request_value($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $key => $item) {
|
||||
$value[$key] = sanitize_request_value($item);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? $value;
|
||||
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('require_env')) {
|
||||
/**
|
||||
* Fetch an environment variable and fail closed when it is missing.
|
||||
*/
|
||||
function require_env(string $key): string
|
||||
{
|
||||
$value = env($key);
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
throw new RuntimeException(sprintf('Missing required environment variable: %s', $key));
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,9 @@ class StaffTimeOffLinkService
|
||||
public function __construct(?string $secret = null, ?int $ttlSeconds = null)
|
||||
{
|
||||
helper('jwt');
|
||||
helper('security');
|
||||
|
||||
$this->secret = $secret ?: (string)env('JWT_SECRET', 'change-me-in-env');
|
||||
$this->secret = $secret ?: require_env('JWT_SECRET');
|
||||
$this->ttl = ($ttlSeconds !== null && $ttlSeconds > 0) ? $ttlSeconds : self::DEFAULT_TTL;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class CertificateRecordModel extends Model
|
||||
{
|
||||
protected $table = 'certificate_records';
|
||||
protected $primaryKey = 'id';
|
||||
protected $useTimestamps = true;
|
||||
|
||||
protected $allowedFields = [
|
||||
'certificate_number',
|
||||
'student_id',
|
||||
'student_name',
|
||||
'grade',
|
||||
'cert_date',
|
||||
'school_year',
|
||||
'class_section_id',
|
||||
'issued_by',
|
||||
'issued_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* Generates the next certificate number for a given school year.
|
||||
* Format: ARSS-{school_year}-{4-digit sequence} e.g. ARSS-2025-2026-0003
|
||||
* Uses a transaction + row count to guarantee uniqueness within a request.
|
||||
* Academic Records and Student Services (ARSS)
|
||||
*/
|
||||
public function nextNumber(string $schoolYear): string
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
$db->transStart();
|
||||
|
||||
$count = $db->table($this->table)
|
||||
->where('school_year', $schoolYear)
|
||||
->countAllResults();
|
||||
|
||||
$seq = str_pad($count + 1, 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$db->transComplete();
|
||||
|
||||
return 'ARSS-' . $schoolYear . '-' . $seq;
|
||||
}
|
||||
|
||||
/** Returns paginated records with the issuing admin's name joined. */
|
||||
public function getAuditLog(?string $schoolYear = null, int $perPage = 50): array
|
||||
{
|
||||
$builder = $this->db->table($this->table . ' cr')
|
||||
->select('cr.*, u.firstname AS admin_firstname, u.lastname AS admin_lastname')
|
||||
->join('users u', 'u.id = cr.issued_by', 'left')
|
||||
->orderBy('cr.issued_at', 'DESC');
|
||||
|
||||
if ($schoolYear) {
|
||||
$builder->where('cr.school_year', $schoolYear);
|
||||
}
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
/** Total certificates issued per school year, ordered by year DESC. */
|
||||
public function yearSummary(): array
|
||||
{
|
||||
return $this->db->table($this->table)
|
||||
->select('school_year, COUNT(*) AS total')
|
||||
->groupBy('school_year')
|
||||
->orderBy('school_year', 'DESC')
|
||||
->get()->getResultArray();
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,20 @@ class EventChargesModel extends Model
|
||||
'student_id',
|
||||
'participation',
|
||||
'charged',
|
||||
'waiver_signed',
|
||||
'event_paid',
|
||||
'event_payment_id',
|
||||
'class_section_id',
|
||||
'external_firstname',
|
||||
'external_lastname',
|
||||
'external_note',
|
||||
'external_parent_firstname',
|
||||
'external_parent_lastname',
|
||||
'external_parent_phone',
|
||||
'external_parent_email',
|
||||
'semester',
|
||||
'school_year',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
|
||||
@@ -8,6 +8,7 @@ class EmailService
|
||||
{
|
||||
protected array $senders;
|
||||
protected EmailController $mailer;
|
||||
protected int $lastCcBatchCount = 0;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -73,6 +74,45 @@ class EmailService
|
||||
return $allOk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one email with all recipients in CC.
|
||||
*/
|
||||
public function sendCc(array $ccList, string $subject, string $message, string $fromKey = 'general', array $attachments = [], array $headers = []): bool
|
||||
{
|
||||
$this->lastCcBatchCount = 0;
|
||||
|
||||
if (empty($ccList)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$sender = $this->getSenderDetails($fromKey);
|
||||
$to = trim((string) ($sender['email'] ?? ''));
|
||||
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
$fallback = trim((string) (getenv('SMTP_USER') ?: ''));
|
||||
$to = filter_var($fallback, FILTER_VALIDATE_EMAIL) ? $fallback : 'no-reply@alrahmaisgl.org';
|
||||
}
|
||||
|
||||
$attachmentsPayload = $this->normalizeAttachments($attachments);
|
||||
$chunks = array_chunk(array_values($ccList), 10);
|
||||
$this->lastCcBatchCount = count($chunks);
|
||||
$allOk = true;
|
||||
|
||||
foreach ($chunks as $index => $chunk) {
|
||||
$ok = $this->mailer->sendEmailWithCc($to, $chunk, $subject, $message, $fromKey, null, null, $attachmentsPayload);
|
||||
if (!$ok) {
|
||||
$allOk = false;
|
||||
log_message('error', 'EmailService::sendCc failed for batch ' . ($index + 1) . ' of ' . count($chunks));
|
||||
}
|
||||
}
|
||||
|
||||
return $allOk;
|
||||
}
|
||||
|
||||
public function getLastCcBatchCount(): int
|
||||
{
|
||||
return $this->lastCcBatchCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize various attachment inputs to the format expected by EmailController.
|
||||
*/
|
||||
|
||||
@@ -212,42 +212,42 @@
|
||||
<div class="col-4">
|
||||
<img
|
||||
class="img-fluid rounded bg-light p-1"
|
||||
src=""assets/images/classes-1.jpg"
|
||||
src="assets/images/classes-1.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img
|
||||
class="img-fluid rounded bg-light p-1"
|
||||
src=""assets/images/classes-2.jpg"
|
||||
src="assets/images/classes-2.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img
|
||||
class="img-fluid rounded bg-light p-1"
|
||||
src=""assets/images/classes-3.jpg"
|
||||
src="assets/images/classes-3.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img
|
||||
class="img-fluid rounded bg-light p-1"
|
||||
src=""assets/images/classes-4.jpg"
|
||||
src="assets/images/classes-4.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img
|
||||
class="img-fluid rounded bg-light p-1"
|
||||
src=""assets/images/classes-5.jpg"
|
||||
src="assets/images/classes-5.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img
|
||||
class="img-fluid rounded bg-light p-1"
|
||||
src=""assets/images/classes-6.jpg"
|
||||
src="assets/images/classes-6.jpg"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -170,22 +170,22 @@
|
||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||
<div class="row g-2 pt-2">
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-1.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-2.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-3.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-4.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-5.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-6.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||
<div>
|
||||
<h2 class="mb-0"><i class="bi bi-journal-check me-2"></i>Certificate Audit Log</h2>
|
||||
<div class="text-muted small">Every issued certificate is recorded here for tracking and auditing.</div>
|
||||
</div>
|
||||
<a href="<?= site_url('administrator/certificates') ?>" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-award me-1"></i>Generate Certificates
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Year summary cards -->
|
||||
<?php if (!empty($yearSummary)): ?>
|
||||
<div class="row g-3 mb-4">
|
||||
<?php foreach ($yearSummary as $yr): ?>
|
||||
<div class="col-auto">
|
||||
<div class="card shadow-sm text-center px-4 py-2" style="min-width:150px;">
|
||||
<div class="fw-bold fs-4"><?= (int) $yr['total'] ?></div>
|
||||
<div class="text-muted small"><?= esc($yr['school_year']) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Year filter -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" action="<?= site_url('administrator/certificates/log') ?>" class="row g-2 align-items-center">
|
||||
<div class="col-auto">
|
||||
<label class="col-form-label fw-semibold">School Year</label>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="school_year" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">— All years —</option>
|
||||
<?php foreach ($yearSummary as $yr): ?>
|
||||
<option value="<?= esc($yr['school_year']) ?>"
|
||||
<?= ($yr['school_year'] === $schoolYear) ? 'selected' : '' ?>>
|
||||
<?= esc($yr['school_year']) ?> (<?= (int) $yr['total'] ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Records table -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold">Issued Certificates
|
||||
<span class="badge bg-secondary ms-1"><?= count($records) ?></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-striped align-middle mb-0 no-mgmt-sticky">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Certificate #</th>
|
||||
<th>Student</th>
|
||||
<th>Grade</th>
|
||||
<th>Cert Date</th>
|
||||
<th>School Year</th>
|
||||
<th>Issued By</th>
|
||||
<th>Issued At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($records)): ?>
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">No certificates issued yet.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($records as $r): ?>
|
||||
<tr>
|
||||
<td><code><?= esc($r['certificate_number']) ?></code></td>
|
||||
<td><?= esc($r['student_name']) ?></td>
|
||||
<td><?= esc($r['grade'] ?? '—') ?></td>
|
||||
<td><?= $r['cert_date'] ? esc(date('m/d/Y', strtotime($r['cert_date']))) : '—' ?></td>
|
||||
<td><?= esc($r['school_year'] ?? '—') ?></td>
|
||||
<td><?= esc(trim(($r['admin_firstname'] ?? '') . ' ' . ($r['admin_lastname'] ?? '')) ?: '—') ?></td>
|
||||
<td><?= esc($r['issued_at'] ? date('m/d/Y g:i A', strtotime($r['issued_at'])) : '—') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,168 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||
<div>
|
||||
<h2 class="mb-0"><i class="bi bi-award me-2"></i>Generate Certificates</h2>
|
||||
<div class="text-muted small">Select a class, choose students, then generate and print their certificates.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (session()->getFlashdata('error')): ?>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<?= esc(session()->getFlashdata('error')) ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Filter form -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header fw-semibold">Filter Students</div>
|
||||
<div class="card-body">
|
||||
<form method="get" action="<?= site_url('administrator/certificates') ?>" id="filterForm">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-md-5">
|
||||
<label class="form-label fw-semibold mb-1">Class / Section</label>
|
||||
<select name="class_section_id" class="form-select" id="classSectionSelect">
|
||||
<option value="">— Select a class —</option>
|
||||
<?php foreach ($classSections as $cs): ?>
|
||||
<option value="<?= esc($cs['class_section_id']) ?>"
|
||||
<?= ((string)$selectedClassId === (string)$cs['class_section_id']) ? 'selected' : '' ?>>
|
||||
<?= esc($cs['class_section_name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label fw-semibold mb-1">School Year</label>
|
||||
<input type="text" name="school_year" class="form-control" value="<?= esc($schoolYear) ?>" placeholder="e.g. 2024-2025">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-funnel me-1"></i>Load Students
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($students)): ?>
|
||||
<!-- Certificate generation form -->
|
||||
<form method="post" action="<?= site_url('administrator/certificates/generate') ?>" id="certForm" target="_blank">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="class_section_id" value="<?= esc($selectedClassId) ?>">
|
||||
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<span class="fw-semibold">
|
||||
Students
|
||||
<span class="badge bg-secondary ms-1"><?= count($students) ?></span>
|
||||
</span>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="input-group input-group-sm" style="width:200px;">
|
||||
<span class="input-group-text"><i class="bi bi-calendar3"></i></span>
|
||||
<input type="date" class="form-control" id="certDatePicker"
|
||||
value="<?= date('Y-m-d') ?>" title="Certificate date">
|
||||
<input type="hidden" name="cert_date" id="certDateHidden" value="<?= esc($certDate) ?>">
|
||||
</div>
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="selectAll">
|
||||
<label class="form-check-label" for="selectAll">Select all</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-striped align-middle mb-0 no-mgmt-sticky" id="studentsTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:40px;"></th>
|
||||
<th>Last Name</th>
|
||||
<th>First Name</th>
|
||||
<th>Grade / Class</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($students as $s): ?>
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<input class="form-check-input student-check" type="checkbox"
|
||||
name="student_ids[]" value="<?= (int) $s['id'] ?>">
|
||||
</td>
|
||||
<td><?= esc($s['lastname']) ?></td>
|
||||
<td><?= esc($s['firstname']) ?></td>
|
||||
<td><?= esc($s['grade'] ?? '') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<span class="text-muted small" id="selectedCount">0 students selected</span>
|
||||
<button type="submit" class="btn btn-success" id="generateBtn" disabled>
|
||||
<i class="bi bi-printer me-1"></i>Generate & Print Certificates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php elseif ($selectedClassId !== null && $selectedClassId !== ''): ?>
|
||||
<div class="alert alert-warning">No active students found for the selected class and school year.</div>
|
||||
<?php else: ?>
|
||||
<div class="alert alert-info">Select a class above to load its student roster.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// Sync date picker → hidden field (MM/DD/YYYY format for the certificate)
|
||||
const datePicker = document.getElementById('certDatePicker');
|
||||
const dateHidden = document.getElementById('certDateHidden');
|
||||
if (datePicker && dateHidden) {
|
||||
datePicker.addEventListener('change', function () {
|
||||
const d = new Date(this.value + 'T00:00:00');
|
||||
if (!isNaN(d)) {
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const yy = d.getFullYear();
|
||||
dateHidden.value = mm + '/' + dd + '/' + yy;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Select-all toggle
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const checks = document.querySelectorAll('.student-check');
|
||||
const generateBtn = document.getElementById('generateBtn');
|
||||
const selectedCount = document.getElementById('selectedCount');
|
||||
|
||||
function updateState() {
|
||||
const chosen = document.querySelectorAll('.student-check:checked').length;
|
||||
selectedCount.textContent = chosen + ' student' + (chosen !== 1 ? 's' : '') + ' selected';
|
||||
generateBtn.disabled = chosen === 0;
|
||||
if (selectAll) {
|
||||
selectAll.checked = chosen === checks.length && checks.length > 0;
|
||||
selectAll.indeterminate = chosen > 0 && chosen < checks.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener('change', function () {
|
||||
checks.forEach(c => { c.checked = this.checked; });
|
||||
updateState();
|
||||
});
|
||||
}
|
||||
|
||||
checks.forEach(c => c.addEventListener('change', updateState));
|
||||
updateState();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-control"></textarea>
|
||||
<textarea id="event_description" name="description" class="form-control"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -56,9 +56,89 @@
|
||||
<input type="text" name="school_year" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">School Calendar</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" value="1">
|
||||
<label class="form-check-label" for="add_to_calendar">
|
||||
Add this event to the school calendar
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="calendar_recipients" class="ms-3" style="display:none;">
|
||||
<div class="mb-2 fw-semibold">Visible On Calendars</div>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<label class="form-check-label">
|
||||
<input class="form-check-input me-1" type="checkbox" name="notify_parent" value="1" checked>
|
||||
Parents
|
||||
</label>
|
||||
<label class="form-check-label">
|
||||
<input class="form-check-input me-1" type="checkbox" name="notify_teacher" value="1" checked>
|
||||
Teachers
|
||||
</label>
|
||||
<label class="form-check-label">
|
||||
<input class="form-check-input me-1" type="checkbox" name="notify_admin" value="1" checked>
|
||||
Admins
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">If none are selected, the event is visible to everyone.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Parent Email Broadcast</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="send_email_parent" name="send_email_parent" value="1">
|
||||
<label class="form-check-label" for="send_email_parent">
|
||||
Send one event email to all parents and add all parent emails in CC
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">The email is sent once. The school sender address stays in To, and all parent emails are placed in CC.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success">Create Event</button>
|
||||
<a href="<?= site_url('administrator/events') ?>" class="btn btn-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<script src="<?= base_url('assets/tinymce/tinymce.min.js') ?>"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const toggle = document.getElementById('add_to_calendar');
|
||||
const recipients = document.getElementById('calendar_recipients');
|
||||
const description = document.getElementById('event_description');
|
||||
|
||||
if (toggle && recipients) {
|
||||
function sync() {
|
||||
recipients.style.display = toggle.checked ? '' : 'none';
|
||||
}
|
||||
toggle.addEventListener('change', sync);
|
||||
sync();
|
||||
}
|
||||
|
||||
if (description && window.tinymce) {
|
||||
tinymce.init({
|
||||
selector: '#event_description',
|
||||
base_url: '<?= base_url('assets/tinymce') ?>',
|
||||
suffix: '.min',
|
||||
license_key: 'gpl',
|
||||
height: 320,
|
||||
menubar: true,
|
||||
branding: false,
|
||||
promotion: false,
|
||||
plugins: 'advlist autolink lists link charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime table help wordcount',
|
||||
toolbar: 'undo redo | blocks | bold italic underline strikethrough forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link table | removeformat | preview code',
|
||||
convert_urls: false,
|
||||
content_style: 'body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; font-size: 14px; }'
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
<div class="container mt-4">
|
||||
<h2>Edit Event</h2>
|
||||
|
||||
<?php
|
||||
$defaultCategories = ['Fun Event-1', 'Fun Event-2', 'Fun Event-3'];
|
||||
$existingCategories = array_map('strval', $categories ?? []);
|
||||
$allCategories = array_unique(array_merge($defaultCategories, $existingCategories));
|
||||
?>
|
||||
|
||||
<?php if (session()->getFlashdata('error')): ?>
|
||||
<div class="alert alert-danger"><?= session()->getFlashdata('error') ?></div>
|
||||
<?php endif; ?>
|
||||
@@ -23,7 +29,7 @@
|
||||
<label class="form-label">Category</label>
|
||||
<select name="event_category" class="form-control" required>
|
||||
<option value="" disabled <?= empty($event['event_category']) ? 'selected' : '' ?>>Select category</option>
|
||||
<?php foreach (($categories ?? []) as $category): ?>
|
||||
<?php foreach ($allCategories as $category): ?>
|
||||
<option value="<?= esc($category) ?>" <?= (string)($event['event_category'] ?? '') === (string)$category ? 'selected' : '' ?>>
|
||||
<?= esc(ucwords($category)) ?>
|
||||
</option>
|
||||
@@ -33,7 +39,7 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-control"><?= esc($event['description']) ?></textarea>
|
||||
<textarea id="event_description" name="description" class="form-control"><?= esc($event['description']) ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
@@ -44,7 +50,7 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Current Flyer</label><br>
|
||||
<?php if ($event['flyer']): ?>
|
||||
<img src="<?= base_url('writable/uploads/' . $event['flyer']) ?>" width="150" class="mb-2">
|
||||
<img src="<?= base_url('uploads/' . ltrim((string) $event['flyer'], '/')) ?>" width="150" class="mb-2">
|
||||
<?php else: ?>
|
||||
<div class="text-muted">No flyer uploaded.</div>
|
||||
<?php endif; ?>
|
||||
@@ -70,9 +76,89 @@
|
||||
<input type="text" name="school_year" class="form-control" value="<?= esc($event['school_year']) ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">School Calendar</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" value="1">
|
||||
<label class="form-check-label" for="add_to_calendar">
|
||||
Add this event to the school calendar
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="calendar_recipients" class="ms-3" style="display:none;">
|
||||
<div class="mb-2 fw-semibold">Visible On Calendars</div>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<label class="form-check-label">
|
||||
<input class="form-check-input me-1" type="checkbox" name="notify_parent" value="1" checked>
|
||||
Parents
|
||||
</label>
|
||||
<label class="form-check-label">
|
||||
<input class="form-check-input me-1" type="checkbox" name="notify_teacher" value="1" checked>
|
||||
Teachers
|
||||
</label>
|
||||
<label class="form-check-label">
|
||||
<input class="form-check-input me-1" type="checkbox" name="notify_admin" value="1" checked>
|
||||
Admins
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">If none are selected, the event is visible to everyone.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Parent Email Broadcast</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="send_email_parent" name="send_email_parent" value="1">
|
||||
<label class="form-check-label" for="send_email_parent">
|
||||
Send one event email to all parents and add all parent emails in CC
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">Use this when you want to rebroadcast the updated event to all parents in a single email.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Update Event</button>
|
||||
<a href="<?= site_url('administrator/events') ?>" class="btn btn-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<script src="<?= base_url('assets/tinymce/tinymce.min.js') ?>"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const toggle = document.getElementById('add_to_calendar');
|
||||
const recipients = document.getElementById('calendar_recipients');
|
||||
const description = document.getElementById('event_description');
|
||||
|
||||
if (toggle && recipients) {
|
||||
function sync() {
|
||||
recipients.style.display = toggle.checked ? '' : 'none';
|
||||
}
|
||||
toggle.addEventListener('change', sync);
|
||||
sync();
|
||||
}
|
||||
|
||||
if (description && window.tinymce) {
|
||||
tinymce.init({
|
||||
selector: '#event_description',
|
||||
base_url: '<?= base_url('assets/tinymce') ?>',
|
||||
suffix: '.min',
|
||||
license_key: 'gpl',
|
||||
height: 320,
|
||||
menubar: true,
|
||||
branding: false,
|
||||
promotion: false,
|
||||
plugins: 'advlist autolink lists link charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime table help wordcount',
|
||||
toolbar: 'undo redo | blocks | bold italic underline strikethrough forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link table | removeformat | preview code',
|
||||
convert_urls: false,
|
||||
content_style: 'body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; font-size: 14px; }'
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -3,6 +3,54 @@
|
||||
|
||||
<div class="container mt-5">
|
||||
<h3>Event Charges</h3>
|
||||
<style>
|
||||
.event-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.event-card-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sortable-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sortable-header:hover,
|
||||
.sortable-header:focus {
|
||||
color: var(--bs-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.sortable-header::after {
|
||||
content: '↕';
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.sortable-header.sorted-asc::after {
|
||||
content: '↑';
|
||||
opacity: 1;
|
||||
}
|
||||
.sortable-header.sorted-desc::after {
|
||||
content: '↓';
|
||||
opacity: 1;
|
||||
}
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
<div class="alert alert-success"><?= session()->getFlashdata('success') ?></div>
|
||||
@@ -12,6 +60,14 @@
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$classSectionNames = $classSectionNames ?? [];
|
||||
$semesterOptions = $semesterOptions ?? [];
|
||||
$schoolYearOptions = $schoolYearOptions ?? [];
|
||||
?>
|
||||
|
||||
<?php
|
||||
$filterParentId = $filterParentId ?? 0;
|
||||
$parentBalances = $parentBalances ?? [];
|
||||
$selectedEvent = null;
|
||||
if (!empty($filterEventId)) {
|
||||
foreach ($events as $event) {
|
||||
@@ -22,18 +78,58 @@
|
||||
}
|
||||
}
|
||||
?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" action="<?= site_url('administrator/event-charges') ?>" class="row g-3 align-items-end">
|
||||
<?php if ($filterEventId > 0): ?>
|
||||
<input type="hidden" name="event_id" value="<?= esc($filterEventId) ?>">
|
||||
<?php endif; ?>
|
||||
<?php if ($filterParentId > 0): ?>
|
||||
<input type="hidden" name="parent_id" value="<?= esc($filterParentId) ?>">
|
||||
<?php endif; ?>
|
||||
<div class="col-md-4">
|
||||
<label for="semester_filter" class="form-label">Semester</label>
|
||||
<select id="semester_filter" name="semester" class="form-select">
|
||||
<option value="">-- All semesters --</option>
|
||||
<?php foreach ($semesterOptions as $option): ?>
|
||||
<option value="<?= esc($option) ?>" <?= $semester === $option ? 'selected' : '' ?>>
|
||||
<?= esc($option) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="school_year_filter" class="form-label">School Year</label>
|
||||
<select id="school_year_filter" name="school_year" class="form-select">
|
||||
<option value="">-- All school years --</option>
|
||||
<?php foreach ($schoolYearOptions as $option): ?>
|
||||
<option value="<?= esc($option) ?>" <?= $school_year === $option ? 'selected' : '' ?>>
|
||||
<?= esc($option) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-outline-primary">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add New Charge Form -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form action="<?= site_url('payment/event_charges') ?>" method="post">
|
||||
<form id="event-participant-form" action="<?= site_url('payment/event_charges') ?>" method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="school_year" value="<?= esc($school_year) ?>">
|
||||
<input type="hidden" name="semester" value="<?= esc($semester) ?>">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="event_id" class="form-label">Select Event</label>
|
||||
<select name="event_id" id="event_id" class="form-select" required>
|
||||
<option value="">-- All events --</option>
|
||||
<?php foreach ($events as $event): ?>
|
||||
<option value="<?= esc($event['id']) ?>" <?= isset($filterEventId) && $filterEventId == $event['id'] ? 'selected' : '' ?>>
|
||||
<option value="<?= esc($event['id']) ?>" data-amount="<?= esc(number_format($event['amount'] ?? 0, 2, '.', '')) ?>" <?= isset($filterEventId) && $filterEventId == $event['id'] ? 'selected' : '' ?>>
|
||||
<?= esc($event['event_name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
@@ -49,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')) ?>
|
||||
@@ -61,15 +172,12 @@
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-md-4">
|
||||
<label for="parent_id" class="form-label">Parents List</label>
|
||||
<select id="parent_id" name="parent_id" class="form-select" required>
|
||||
<select id="parent_id" name="parent_id" class="form-select">
|
||||
<option value="">-- Select Parent --</option>
|
||||
<?php foreach ($parents as $parent): ?>
|
||||
<option value="<?= $parent['id'] ?>">
|
||||
<option value="<?= esc($parent['id']) ?>" <?= $filterParentId && $filterParentId == $parent['id'] ? 'selected' : '' ?>>
|
||||
<?= esc($parent['firstname'] . ' ' . $parent['lastname']) ?> (<?= esc($parent['school_id']) ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
@@ -82,6 +190,15 @@
|
||||
<div id="studentList" class="row g-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<?php if (!in_array(strtolower((string)(session()->get('role') ?? '')), ['parent', 'teacher', 'teacher_assistant'], true)): ?>
|
||||
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#externalParticipantModal">
|
||||
Add non-school participant
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<div class="form-text">Enrolled students are saved when you click Submit. Non-school participants are saved immediately and will then appear in all browsers.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
<a href="<?= site_url('administrator/events') ?>" class="btn btn-success">Event List</a>
|
||||
@@ -89,77 +206,326 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charge Tables grouped by event -->
|
||||
<?php
|
||||
$grouped = [];
|
||||
foreach ($charges as $charge) {
|
||||
$label = $charge['event_name'] ?? 'N/A';
|
||||
$grouped[$label][] = $charge;
|
||||
$eventId = (int)($charge['event_id'] ?? 0);
|
||||
if (!isset($grouped[$eventId])) {
|
||||
$grouped[$eventId] = [
|
||||
'label' => $charge['event_name'] ?? 'N/A',
|
||||
'rows' => [],
|
||||
];
|
||||
}
|
||||
$grouped[$eventId]['rows'][] = $charge;
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if (empty($grouped)): ?>
|
||||
<div class="alert alert-info">No charges found.</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($grouped as $eventLabel => $rows): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<?= esc($eventLabel) ?>
|
||||
<?php
|
||||
$printBaseParams = array_filter([
|
||||
'school_year' => $school_year,
|
||||
'semester' => $semester,
|
||||
'parent_id' => $filterParentId > 0 ? $filterParentId : null,
|
||||
'event_id' => $filterEventId > 0 ? $filterEventId : null,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
$printAllUrl = site_url('administrator/event-charges/pdf');
|
||||
if (!empty($printBaseParams)) {
|
||||
$printAllUrl .= '?' . http_build_query($printBaseParams);
|
||||
}
|
||||
?>
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3 no-print">
|
||||
<p class="text-muted mb-0">Click a column header to sort the participants list.</p>
|
||||
<a href="<?= esc($printAllUrl) ?>"
|
||||
class="btn btn-outline-secondary pdf-print-link"
|
||||
data-base-href="<?= esc($printAllUrl) ?>"
|
||||
data-print-scope="all">
|
||||
Print All Participant Lists
|
||||
</a>
|
||||
</div>
|
||||
<div id="eventChargeCards">
|
||||
<?php foreach ($grouped as $eventId => $data): ?>
|
||||
<?php $eventLabel = $data['label']; ?>
|
||||
<?php $rows = $data['rows']; ?>
|
||||
<?php
|
||||
$singlePrintParams = array_filter([
|
||||
'school_year' => $school_year,
|
||||
'semester' => $semester,
|
||||
'parent_id' => $filterParentId > 0 ? $filterParentId : null,
|
||||
'event_id' => $eventId,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
$singlePrintUrl = site_url('administrator/event-charges/pdf') . '?' . http_build_query($singlePrintParams);
|
||||
?>
|
||||
<div class="card mb-4 event-card" data-event-id="<?= esc($eventId) ?>">
|
||||
<div class="card-header bg-light fw-semibold event-card-header">
|
||||
<span><?= esc($eventLabel) ?></span>
|
||||
<div class="event-card-header-actions no-print">
|
||||
<a href="<?= esc($singlePrintUrl) ?>"
|
||||
class="btn btn-outline-secondary btn-sm pdf-print-link"
|
||||
data-base-href="<?= esc($singlePrintUrl) ?>"
|
||||
data-print-scope="single"
|
||||
data-event-id="<?= esc($eventId) ?>">
|
||||
Print This List
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<?php
|
||||
$totalParticipants = 0;
|
||||
$totalCharged = 0.0;
|
||||
?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-striped mb-0 no-mgmt-sticky">
|
||||
<table id="eventTable_<?= esc($eventId) ?>" class="table table-bordered table-striped mb-0 no-mgmt-sticky event-charges-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Parent Name</th>
|
||||
<th>Student Name</th>
|
||||
<th>Charged Amount</th>
|
||||
<th>Is Participating</th>
|
||||
<th>Semester</th>
|
||||
<th>Year</th>
|
||||
<th>Created</th>
|
||||
<th>Description</th>
|
||||
<th>Event Fees</th>
|
||||
<th><button type="button" class="sortable-header" data-sort-index="0" data-sort-type="number">ID</button></th>
|
||||
<th><button type="button" class="sortable-header" data-sort-index="1" data-sort-type="text">Parent Name</button></th>
|
||||
<th><button type="button" class="sortable-header" data-sort-index="2" data-sort-type="text">Student Name</button></th>
|
||||
<th><button type="button" class="sortable-header" data-sort-index="3" data-sort-type="text">External Info</button></th>
|
||||
<th><button type="button" class="sortable-header" data-sort-index="4" data-sort-type="text">Class Section</button></th>
|
||||
<th><button type="button" class="sortable-header" data-sort-index="5" data-sort-type="number">Charged Amount</button></th>
|
||||
<th><button type="button" class="sortable-header" data-sort-index="6" data-sort-type="date">Created</button></th>
|
||||
<th><button type="button" class="sortable-header" data-sort-index="7" data-sort-type="text">Waiver</button></th>
|
||||
<th><button type="button" class="sortable-header" data-sort-index="8" data-sort-type="text">Fees Paid</button></th>
|
||||
<th><button type="button" class="sortable-header" data-sort-index="9" data-sort-type="text">Payment</button></th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $charge): ?>
|
||||
<tr>
|
||||
<td><?= esc($charge['id']) ?></td>
|
||||
<td><?= esc($charge['parent_firstname'] . ' ' . $charge['parent_lastname']) ?></td>
|
||||
<td>
|
||||
<?= $charge['student_firstname']
|
||||
? esc($charge['student_firstname'] . ' ' . $charge['student_lastname'])
|
||||
: '-' ?>
|
||||
<?php
|
||||
$isParticipating = ($charge['participation'] === 'yes');
|
||||
$isEventPaid = !empty($charge['event_paid']);
|
||||
$returnParams = [];
|
||||
if (!empty($semester)) {
|
||||
$returnParams['semester'] = $semester;
|
||||
}
|
||||
if (!empty($school_year)) {
|
||||
$returnParams['school_year'] = $school_year;
|
||||
}
|
||||
if (!empty($filterEventId)) {
|
||||
$returnParams['event_id'] = $filterEventId;
|
||||
}
|
||||
if (!empty($filterParentId)) {
|
||||
$returnParams['parent_id'] = $filterParentId;
|
||||
}
|
||||
$returnTo = site_url('administrator/event-charges')
|
||||
. (!empty($returnParams) ? '?' . http_build_query($returnParams) : '')
|
||||
. '#eventTable_' . (int) ($charge['event_id'] ?? 0);
|
||||
?>
|
||||
<tr data-charge-id="<?= esc((string) ($charge['id'] ?? '')) ?>">
|
||||
<td data-sort-value="<?= esc((string) ($charge['id'] ?? '')) ?>"><?= esc($charge['id']) ?></td>
|
||||
<?php
|
||||
$studentName = $charge['student_firstname']
|
||||
? trim($charge['student_firstname'] . ' ' . $charge['student_lastname'])
|
||||
: '';
|
||||
$externalName = trim(
|
||||
($charge['external_firstname'] ?? '') . ' ' .
|
||||
($charge['external_lastname'] ?? '')
|
||||
);
|
||||
$displayName = $studentName ?: ($externalName ?: '—');
|
||||
$externalNote = $charge['external_note'] ?? '';
|
||||
$externalParentLabel = trim(
|
||||
($charge['external_parent_firstname'] ?? '') . ' ' .
|
||||
($charge['external_parent_lastname'] ?? '')
|
||||
);
|
||||
$externalParentPhone = $charge['external_parent_phone'] ?? '';
|
||||
$externalParentEmail = $charge['external_parent_email'] ?? '';
|
||||
$parentPhone = $charge['parent_cellphone'] ?? '';
|
||||
$standardParentName = trim($charge['parent_firstname'] . ' ' . $charge['parent_lastname']);
|
||||
$parentColumnName = $externalParentLabel ?: ($standardParentName ?: '—');
|
||||
$isExternalParticipant = $externalName !== '';
|
||||
$infoPhone = $isExternalParticipant ? $externalParentPhone : $parentPhone;
|
||||
$infoEmail = $isExternalParticipant ? $externalParentEmail : '';
|
||||
$externalInfoValue = trim(implode(' ', array_filter([
|
||||
$infoPhone,
|
||||
$infoEmail,
|
||||
$externalNote,
|
||||
])));
|
||||
?>
|
||||
<td data-sort-value="<?= esc(strtolower($parentColumnName)) ?>"><?= esc($parentColumnName) ?></td>
|
||||
<td data-sort-value="<?= esc(strtolower($displayName)) ?>">
|
||||
<?= esc($displayName) ?>
|
||||
<?php if ($externalNote): ?>
|
||||
<small class="text-muted d-block"><?= esc($externalNote) ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>$<?= esc(number_format($charge['charged'] ?? 0, 2)) ?></td>
|
||||
<td>
|
||||
<?= $charge['participation']
|
||||
? '<span class="badge bg-success">Yes</span>'
|
||||
: '<span class="badge bg-warning">No</span>' ?>
|
||||
<td data-sort-value="<?= esc(strtolower($externalInfoValue)) ?>">
|
||||
<?php if ($infoPhone): ?>
|
||||
<div class="text-muted small"><?= esc($infoPhone) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($infoEmail): ?>
|
||||
<div class="text-muted small"><?= esc($infoEmail) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!$infoPhone && !$infoEmail): ?>
|
||||
<span class="text-muted small">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td data-sort-value="<?= esc(strtolower((string) ($classSectionNames[$charge['class_section_id'] ?? ''] ?? ''))) ?>">
|
||||
<?= esc($classSectionNames[$charge['class_section_id'] ?? ''] ?? '—') ?>
|
||||
</td>
|
||||
<td data-sort-value="<?= esc(number_format((float) ($charge['event_amount'] ?? 0), 2, '.', '')) ?>">$<?= esc(number_format($charge['event_amount'] ?? 0, 2)) ?></td>
|
||||
<td data-sort-value="<?= esc((string) ($charge['created_at'] ?? '')) ?>"><?= esc(!empty($charge['created_at']) ? local_datetime($charge['created_at'], 'm-d-Y H:i') : '') ?></td>
|
||||
<?php
|
||||
$feeAmount = (float) ($charge['event_amount'] ?? 0);
|
||||
$hasBalanceRecord = array_key_exists((int)$charge['parent_id'], $parentBalances);
|
||||
$parentBalance = $hasBalanceRecord ? (float)$parentBalances[$charge['parent_id']] : null;
|
||||
$waiverSigned = !empty($charge['waiver_signed']);
|
||||
$feeIsPaid = $isParticipating && ($isEventPaid || ($hasBalanceRecord && $parentBalance <= 0));
|
||||
?>
|
||||
<td class="text-center" data-sort-value="<?= $waiverSigned ? 'signed' : 'unsigned' ?>">
|
||||
<form method="post" action="<?= site_url('administrator/event-charges/waiver/' . esc($charge['id'])) ?>" class="d-inline-flex align-items-center">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="return_to" value="<?= esc($returnTo) ?>">
|
||||
<input type="hidden" name="waiver_signed" value="<?= $waiverSigned ? '1' : '0' ?>">
|
||||
<input type="checkbox" class="form-check-input" id="eventWaiver_<?= esc($charge['id']) ?>"
|
||||
<?= $waiverSigned ? 'checked' : '' ?>
|
||||
onchange="this.form.waiver_signed.value = this.checked ? 1 : 0; this.form.submit();">
|
||||
<label class="form-check-label ms-2" for="eventWaiver_<?= esc($charge['id']) ?>" aria-hidden="true">
|
||||
<?= $waiverSigned ? 'Signed' : 'Unsigned' ?>
|
||||
</label>
|
||||
</form>
|
||||
</td>
|
||||
<td class="text-center" data-sort-value="<?= $feeIsPaid ? 'paid' : 'unpaid' ?>">
|
||||
<?php if ($feeIsPaid): ?>
|
||||
<span class="badge bg-success">Paid</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-danger">Unpaid</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-center" data-sort-value="<?= $isEventPaid ? 'paid' : 'unpaid' ?>">
|
||||
<form method="post" action="<?= site_url('administrator/event-charges/payment/' . esc($charge['id'])) ?>" class="d-inline-flex align-items-center">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="return_to" value="<?= esc($returnTo) ?>">
|
||||
<input type="hidden" name="paid" value="<?= $feeIsPaid ? '1' : '0' ?>">
|
||||
<input type="checkbox" class="form-check-input" id="eventPayment_<?= esc($charge['id']) ?>"
|
||||
<?= $isEventPaid ? 'checked' : '' ?>
|
||||
onchange="this.form.paid.value = this.checked ? 1 : 0; this.form.submit();">
|
||||
<label class="form-check-label ms-2" for="eventPayment_<?= esc($charge['id']) ?>" aria-hidden="true">Paid</label>
|
||||
</form>
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<a href="<?= site_url('administrator/event-charges') ?>?event_id=<?= esc($charge['event_id']) ?>&parent_id=<?= esc($charge['parent_id']) ?>#event-participant-form" class="btn btn-outline-primary btn-sm">
|
||||
Edit
|
||||
</a>
|
||||
<form action="<?= site_url('administrator/event-charges/remove/' . esc($charge['id'])) ?>" method="post" onsubmit="return confirm('Remove this participation and update the charge?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="return_to" value="<?= esc($returnTo) ?>">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
<td><?= esc($charge['semester'] ?? '-') ?></td>
|
||||
<td><?= esc($charge['school_year'] ?? '-') ?></td>
|
||||
<td><?= esc(!empty($charge['created_at']) ? local_datetime($charge['created_at'], 'm-d-Y H:i') : '') ?></td>
|
||||
<td><?= esc($charge['event_description'] ?? '—') ?></td>
|
||||
<td>$<?= esc(number_format($charge['event_amount'] ?? 0, 2)) ?></td>
|
||||
</tr>
|
||||
<?php
|
||||
if ($isParticipating) {
|
||||
$totalParticipants++;
|
||||
$totalCharged += (float) ($charge['event_amount'] ?? 0);
|
||||
}
|
||||
?>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot class="table-light">
|
||||
<tr>
|
||||
<th colspan="4">Totals</th>
|
||||
<th>$<?= esc(number_format($totalCharged, 2)) ?></th>
|
||||
<th colspan="6">
|
||||
<span class="badge bg-secondary">
|
||||
<?= esc($totalParticipants) ?> participating
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!in_array(strtolower((string)(session()->get('role') ?? '')), ['parent', 'teacher', 'teacher_assistant'], true)): ?>
|
||||
<div class="modal fade" id="externalParticipantModal" tabindex="-1" aria-labelledby="externalParticipantModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="externalParticipantForm" class="needs-validation" novalidate>
|
||||
<?= csrf_field() ?>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="externalParticipantModalLabel">Add Non-School Participant</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="small text-muted">Provide the student’s name and the accompanying guardian/contact details.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Student / Kid Info</label>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="modalExternalFirstName" placeholder="First name" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="modalExternalLastName" placeholder="Last name" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Parent / Contact Info</label>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="modalParentFirstName" placeholder="Parent first name" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="modalParentLastName" placeholder="Parent last name" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="text" class="form-control" id="modalParentPhone" placeholder="Parent phone or contact">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="email" class="form-control" id="modalParentEmail" placeholder="Parent email" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="modalExternalNote">Note <span class="text-muted small">(optional)</span></label>
|
||||
<textarea rows="2" class="form-control" id="modalExternalNote" placeholder="Additional info (e.g., guest, sibling, etc.)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="externalParticipantSave">
|
||||
Add to list
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.event-description-toggle').forEach(function (button) {
|
||||
const targetSelector = button.getAttribute('data-bs-target');
|
||||
const target = targetSelector ? document.querySelector(targetSelector) : null;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.addEventListener('show.bs.collapse', function () {
|
||||
button.textContent = button.dataset.labelExpanded || 'Hide description';
|
||||
});
|
||||
|
||||
target.addEventListener('hide.bs.collapse', function () {
|
||||
button.textContent = button.dataset.labelCollapsed || 'Show description';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function loadStudentsWithCharges() {
|
||||
let parentId = $('#parent_id').val();
|
||||
let eventId = $('#event_id').val();
|
||||
@@ -201,6 +567,19 @@ function loadStudentsWithCharges() {
|
||||
}
|
||||
}
|
||||
|
||||
const capitalizeName = (value) => {
|
||||
if (!value) return '';
|
||||
return value
|
||||
.split(' ')
|
||||
.map(part => {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1).toLowerCase();
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
$(function() {
|
||||
$('#parent_id').on('change', loadStudentsWithCharges);
|
||||
|
||||
@@ -214,6 +593,212 @@ $(function() {
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
|
||||
if ($('#event_id').val() && $('#parent_id').val()) {
|
||||
loadStudentsWithCharges();
|
||||
}
|
||||
|
||||
const $externalSaveButton = $('#externalParticipantSave');
|
||||
|
||||
const modalElement = document.getElementById('externalParticipantModal');
|
||||
const modalInstance = modalElement ? new bootstrap.Modal(modalElement) : null;
|
||||
|
||||
function clearModalInputs() {
|
||||
$('#modalExternalFirstName').val('');
|
||||
$('#modalExternalLastName').val('');
|
||||
$('#modalExternalNote').val('');
|
||||
$('#modalParentFirstName').val('');
|
||||
$('#modalParentLastName').val('');
|
||||
$('#modalParentPhone').val('');
|
||||
$('#modalParentEmail').val('');
|
||||
}
|
||||
|
||||
function appendHiddenField(form, name, value) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
form.appendChild(input);
|
||||
}
|
||||
|
||||
function submitExternalParticipant(payload) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.action = '<?= site_url('payment/event_charges') ?>';
|
||||
|
||||
appendHiddenField(form, '<?= csrf_token() ?>', '<?= csrf_hash() ?>');
|
||||
appendHiddenField(form, 'school_year', '<?= esc($school_year) ?>');
|
||||
appendHiddenField(form, 'semester', '<?= esc($semester) ?>');
|
||||
appendHiddenField(form, 'event_id', payload.eventId);
|
||||
appendHiddenField(form, 'external_participants[new][firstname]', payload.firstname);
|
||||
appendHiddenField(form, 'external_participants[new][lastname]', payload.lastname);
|
||||
appendHiddenField(form, 'external_participants[new][note]', payload.note);
|
||||
appendHiddenField(form, 'external_participants[new][parent_firstname]', payload.parentFirstname);
|
||||
appendHiddenField(form, 'external_participants[new][parent_lastname]', payload.parentLastname);
|
||||
appendHiddenField(form, 'external_participants[new][parent_phone]', payload.parentPhone);
|
||||
appendHiddenField(form, 'external_participants[new][parent_email]', payload.parentEmail);
|
||||
appendHiddenField(form, 'external_participants[new][waiver_signed]', '0');
|
||||
appendHiddenField(form, 'external_participants[new][paid]', '0');
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function getSortValue(row, columnIndex, sortType) {
|
||||
const cell = row.children[columnIndex];
|
||||
if (!cell) {
|
||||
return sortType === 'number' ? 0 : '';
|
||||
}
|
||||
const rawValue = cell.getAttribute('data-sort-value');
|
||||
const fallbackValue = (cell.textContent || '').trim();
|
||||
const value = rawValue !== null ? rawValue : fallbackValue;
|
||||
if (sortType === 'number') {
|
||||
const parsed = parseFloat(String(value).replace(/[^0-9.-]/g, ''));
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
if (sortType === 'date') {
|
||||
const time = Date.parse(value);
|
||||
return Number.isNaN(time) ? 0 : time;
|
||||
}
|
||||
return String(value).toLowerCase();
|
||||
}
|
||||
|
||||
function updateSortIndicators(table, columnIndex, direction) {
|
||||
$(table).find('.sortable-header').each(function() {
|
||||
const isCurrent = Number($(this).data('sortIndex')) === Number(columnIndex);
|
||||
$(this)
|
||||
.toggleClass('sorted-asc', isCurrent && direction === 'asc')
|
||||
.toggleClass('sorted-desc', isCurrent && direction === 'desc')
|
||||
.attr('aria-sort', isCurrent ? direction : 'none');
|
||||
});
|
||||
}
|
||||
|
||||
function sortEventTable(table, columnIndex, sortType, direction) {
|
||||
if (!table || !table.tBodies.length) {
|
||||
return;
|
||||
}
|
||||
const tbody = table.tBodies[0];
|
||||
const rows = Array.from(tbody.rows);
|
||||
rows.sort((a, b) => {
|
||||
const first = getSortValue(a, columnIndex, sortType);
|
||||
const second = getSortValue(b, columnIndex, sortType);
|
||||
if (first < second) {
|
||||
return direction === 'asc' ? -1 : 1;
|
||||
}
|
||||
if (first > second) {
|
||||
return direction === 'asc' ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
rows.forEach((row) => tbody.appendChild(row));
|
||||
table.dataset.sortColumn = String(columnIndex);
|
||||
table.dataset.sortType = sortType;
|
||||
table.dataset.sortDirection = direction;
|
||||
updateSortIndicators(table, columnIndex, direction);
|
||||
}
|
||||
|
||||
function applyActiveSort(table) {
|
||||
if (!table || !table.dataset || !table.dataset.sortColumn) {
|
||||
return;
|
||||
}
|
||||
sortEventTable(
|
||||
table,
|
||||
Number(table.dataset.sortColumn),
|
||||
table.dataset.sortType || 'text',
|
||||
table.dataset.sortDirection || 'asc'
|
||||
);
|
||||
}
|
||||
|
||||
function getTableRowOrder(table) {
|
||||
if (!table || !table.tBodies.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(table.tBodies[0].rows)
|
||||
.map((row) => String(row.dataset.chargeId || '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildPdfUrl(link) {
|
||||
const baseHref = String(link.dataset.baseHref || link.getAttribute('href') || '');
|
||||
const url = new URL(baseHref, window.location.origin);
|
||||
const scope = String(link.dataset.printScope || 'single');
|
||||
|
||||
if (scope === 'all') {
|
||||
document.querySelectorAll('.event-card').forEach((card) => {
|
||||
const eventId = String(card.dataset.eventId || '').trim();
|
||||
const table = card.querySelector('.event-charges-table');
|
||||
const rowOrder = getTableRowOrder(table);
|
||||
if (eventId && rowOrder.length) {
|
||||
url.searchParams.set(`row_order[${eventId}]`, rowOrder.join(','));
|
||||
}
|
||||
});
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const eventId = String(link.dataset.eventId || '').trim();
|
||||
const table = eventId ? document.getElementById(`eventTable_${eventId}`) : null;
|
||||
const rowOrder = getTableRowOrder(table);
|
||||
if (eventId && rowOrder.length) {
|
||||
url.searchParams.set(`row_order[${eventId}]`, rowOrder.join(','));
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
$('.sortable-header').on('click', function() {
|
||||
const $button = $(this);
|
||||
const table = $button.closest('table').get(0);
|
||||
const columnIndex = Number($button.data('sortIndex'));
|
||||
const sortType = String($button.data('sortType') || 'text');
|
||||
const currentColumn = Number(table.dataset.sortColumn);
|
||||
const currentDirection = table.dataset.sortDirection || 'asc';
|
||||
const direction = currentColumn === columnIndex && currentDirection === 'asc' ? 'desc' : 'asc';
|
||||
sortEventTable(table, columnIndex, sortType, direction);
|
||||
});
|
||||
|
||||
$('.pdf-print-link').on('click', function() {
|
||||
this.href = buildPdfUrl(this);
|
||||
});
|
||||
|
||||
$('#externalParticipantSave').on('click', function() {
|
||||
const firstName = capitalizeName($('#modalExternalFirstName').val().trim());
|
||||
const lastName = capitalizeName($('#modalExternalLastName').val().trim());
|
||||
const note = $('#modalExternalNote').val().trim();
|
||||
if (!firstName && !lastName) {
|
||||
alert('Please provide at least a first or last name for the non-school participant.');
|
||||
return;
|
||||
}
|
||||
const parentFirst = capitalizeName($('#modalParentFirstName').val().trim());
|
||||
const parentLast = capitalizeName($('#modalParentLastName').val().trim());
|
||||
const parentPhone = $('#modalParentPhone').val().trim();
|
||||
const parentEmail = $('#modalParentEmail').val().trim();
|
||||
const eventId = $('#event_id').val();
|
||||
if (!eventId) {
|
||||
alert('Select an event before adding a non-school participant.');
|
||||
return;
|
||||
}
|
||||
const explicitParentName = `${parentFirst} ${parentLast}`.trim();
|
||||
if (!explicitParentName) {
|
||||
alert('Please enter the external kid parent name.');
|
||||
return;
|
||||
}
|
||||
if (!parentEmail) {
|
||||
alert('Please enter the external kid parent email.');
|
||||
return;
|
||||
}
|
||||
submitExternalParticipant({
|
||||
firstname: firstName,
|
||||
lastname: lastName,
|
||||
note: note,
|
||||
parentFirstname: parentFirst,
|
||||
parentLastname: parentLast,
|
||||
parentPhone: parentPhone,
|
||||
parentEmail: parentEmail,
|
||||
eventId: eventId
|
||||
});
|
||||
clearModalInputs();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Event Participant Lists</title>
|
||||
<style>
|
||||
@page {
|
||||
margin: 24px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: DejaVu Sans, sans-serif;
|
||||
font-size: 11px;
|
||||
color: #1f2933;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0 0 18px;
|
||||
color: #52606d;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
margin-bottom: 22px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
margin: 0 0 8px;
|
||||
padding: 8px 10px;
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #d9e2ec;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #d9e2ec;
|
||||
padding: 6px 7px;
|
||||
vertical-align: top;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f5f7fa;
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
tfoot th {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.small {
|
||||
color: #52606d;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.status-paid {
|
||||
color: #166534;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-unpaid {
|
||||
color: #b42318;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-neutral {
|
||||
color: #52606d;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<?php
|
||||
$title = !empty($filterEventId) ? 'Event Participant List' : 'All Event Participant Lists';
|
||||
if (!empty($filterParentId)) {
|
||||
$title .= ' for Selected Parent';
|
||||
}
|
||||
?>
|
||||
<h1><?= esc($title) ?></h1>
|
||||
<p class="meta">
|
||||
Generated on <?= esc($generatedAt) ?>
|
||||
| School Year: <?= esc($school_year ?: 'N/A') ?>
|
||||
| Semester: <?= esc($semester ?: 'N/A') ?>
|
||||
</p>
|
||||
|
||||
<?php if (empty($groupedCharges)): ?>
|
||||
<p>No charges found for the selected filters.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($groupedCharges as $eventId => $data): ?>
|
||||
<?php
|
||||
$rows = $data['rows'] ?? [];
|
||||
$totalParticipants = 0;
|
||||
$totalCharged = 0.0;
|
||||
?>
|
||||
<section class="event-card">
|
||||
<h2 class="event-title"><?= esc($data['label'] ?? 'N/A') ?></h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 4%;">ID</th>
|
||||
<th style="width: 15%;">Parent Name</th>
|
||||
<th style="width: 14%;">Student Name</th>
|
||||
<th style="width: 15%;">External Info</th>
|
||||
<th style="width: 11%;">Class Section</th>
|
||||
<th style="width: 8%;">Charged Amount</th>
|
||||
<th style="width: 10%;">Created</th>
|
||||
<th style="width: 7%;">Waiver</th>
|
||||
<th style="width: 7%;">Fees Paid</th>
|
||||
<th style="width: 9%;">Payment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $charge): ?>
|
||||
<?php
|
||||
$isParticipating = ($charge['participation'] ?? '') === 'yes';
|
||||
$isEventPaid = !empty($charge['event_paid']);
|
||||
$feeAmount = (float) ($charge['event_amount'] ?? 0);
|
||||
$hasBalanceRecord = array_key_exists((int) ($charge['parent_id'] ?? 0), $parentBalances);
|
||||
$parentBalance = $hasBalanceRecord ? (float) $parentBalances[(int) $charge['parent_id']] : null;
|
||||
$waiverSigned = !empty($charge['waiver_signed']);
|
||||
$feeIsPaid = $isParticipating && ($isEventPaid || ($hasBalanceRecord && $parentBalance <= 0));
|
||||
|
||||
$studentName = !empty($charge['student_firstname'])
|
||||
? trim(($charge['student_firstname'] ?? '') . ' ' . ($charge['student_lastname'] ?? ''))
|
||||
: '';
|
||||
$externalName = trim(($charge['external_firstname'] ?? '') . ' ' . ($charge['external_lastname'] ?? ''));
|
||||
$displayName = $studentName ?: ($externalName ?: '—');
|
||||
$externalNote = trim((string) ($charge['external_note'] ?? ''));
|
||||
$externalParentLabel = trim(($charge['external_parent_firstname'] ?? '') . ' ' . ($charge['external_parent_lastname'] ?? ''));
|
||||
$externalParentPhone = trim((string) ($charge['external_parent_phone'] ?? ''));
|
||||
$externalParentEmail = trim((string) ($charge['external_parent_email'] ?? ''));
|
||||
$parentPhone = trim((string) ($charge['parent_cellphone'] ?? ''));
|
||||
$standardParentName = trim(($charge['parent_firstname'] ?? '') . ' ' . ($charge['parent_lastname'] ?? ''));
|
||||
$parentColumnName = $externalParentLabel ?: ($standardParentName ?: '—');
|
||||
$isExternalParticipant = $externalName !== '';
|
||||
$infoPhone = $isExternalParticipant ? $externalParentPhone : $parentPhone;
|
||||
$infoEmail = $isExternalParticipant ? $externalParentEmail : '';
|
||||
$classSectionName = $classSectionNames[$charge['class_section_id'] ?? ''] ?? '—';
|
||||
|
||||
if ($isParticipating) {
|
||||
$totalParticipants++;
|
||||
$totalCharged += $feeAmount;
|
||||
}
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($charge['id'] ?? '') ?></td>
|
||||
<td><?= esc($parentColumnName) ?></td>
|
||||
<td>
|
||||
<?= esc($displayName) ?>
|
||||
<?php if ($externalNote !== ''): ?>
|
||||
<div class="small"><?= esc($externalNote) ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($infoPhone !== ''): ?>
|
||||
<div class="small"><?= esc($infoPhone) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($infoEmail !== ''): ?>
|
||||
<div class="small"><?= esc($infoEmail) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($infoPhone === '' && $infoEmail === ''): ?>
|
||||
<span class="small">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= esc($classSectionName) ?></td>
|
||||
<td>$<?= esc(number_format($feeAmount, 2)) ?></td>
|
||||
<td><?= esc(!empty($charge['created_at']) ? local_datetime($charge['created_at'], 'm-d-Y H:i') : '') ?></td>
|
||||
<td class="<?= $waiverSigned ? 'status-paid' : 'status-neutral' ?>">
|
||||
<?= $waiverSigned ? 'Signed' : 'Unsigned' ?>
|
||||
</td>
|
||||
<td class="<?= $feeIsPaid ? 'status-paid' : 'status-unpaid' ?>">
|
||||
<?= $feeIsPaid ? 'Paid' : 'Unpaid' ?>
|
||||
</td>
|
||||
<td class="<?= $isEventPaid ? 'status-paid' : 'status-unpaid' ?>">
|
||||
<?= $isEventPaid ? 'Paid' : 'Unpaid' ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th colspan="5">Totals</th>
|
||||
<th>$<?= esc(number_format($totalCharged, 2)) ?></th>
|
||||
<th colspan="4"><?= esc($totalParticipants) ?> participating</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</section>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</body>
|
||||
</html>
|
||||
@@ -12,6 +12,14 @@
|
||||
<div class="alert alert-danger"><?= session()->getFlashdata('error') ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (session()->getFlashdata('email_status')): ?>
|
||||
<div class="alert alert-info"><?= session()->getFlashdata('email_status') ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (session()->getFlashdata('email_error')): ?>
|
||||
<div class="alert alert-warning"><?= session()->getFlashdata('email_error') ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($events)): ?>
|
||||
<div class="alert alert-info mb-3 d-inline-block">No events found.</div>
|
||||
<?php else: ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,17 +13,19 @@
|
||||
</a>
|
||||
</div>
|
||||
<!-- Search Form -->
|
||||
<!--form method="get" action="<--?= base_url('admin/paypal-transactions') ?>" class="mb-3">
|
||||
<--?= csrf_field(); ?>
|
||||
<!--
|
||||
<form method="get" action="<?= base_url('admin/paypal-transactions') ?>" class="mb-3">
|
||||
<?= csrf_field(); ?>
|
||||
<div class="input-group">
|
||||
<input type="text" name="q" value="<--?= esc($keyword) ?>" class="form-control"
|
||||
<input type="text" name="q" value="<?= esc($keyword) ?>" class="form-control"
|
||||
placeholder="Search by transaction ID, email, order ID, or event type">
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
<--?php if ($keyword): ?>
|
||||
<a href="<--?= base_url('admin/paypal-transactions') ?>" class="btn btn-secondary">Clear</a>
|
||||
<--?php endif; ?>
|
||||
<?php if ($keyword): ?>
|
||||
<a href="<?= base_url('admin/paypal-transactions') ?>" class="btn btn-secondary">Clear</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form-->
|
||||
</form>
|
||||
-->
|
||||
|
||||
<!-- Transactions Table -->
|
||||
<table id="myTable" class="display table table-striped">
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<?php
|
||||
$classResults = $classResults ?? [];
|
||||
$selectedYear = $selectedYear ?? '';
|
||||
$selectedPercentile = $selectedPercentile ?? 75;
|
||||
$years = $years ?? [];
|
||||
|
||||
$totalStudents = array_sum(array_column($classResults, 'student_count'));
|
||||
$totalScored = array_sum(array_column($classResults, 'scored_count'));
|
||||
$totalTrophies = array_sum(array_column($classResults, 'trophy_count'));
|
||||
$totalBoys = array_sum(array_column($classResults, 'boys'));
|
||||
$totalGirls = array_sum(array_column($classResults, 'girls'));
|
||||
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
|
||||
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
|
||||
|
||||
$pctBoys = $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0;
|
||||
$pctGirls = $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0;
|
||||
$pctTrophyBoys = $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0;
|
||||
$pctTrophyGirls = $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0;
|
||||
$pctTrophyAll = $totalStudents > 0 ? round($totalTrophies / $totalStudents * 100) : 0;
|
||||
?>
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2">
|
||||
<div>
|
||||
<h2 class="mb-1"><i class="bi bi-trophy-fill text-warning me-2"></i>Trophy Projections</h2>
|
||||
<p class="text-muted mb-0">
|
||||
Fall scores are used to calculate a per-class threshold at the
|
||||
<strong><?= (int) $selectedPercentile ?>th percentile</strong> and project which students are on track
|
||||
for a year-end trophy. Minimum 3 trophies per class. Names are hidden by default.
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?= site_url('administrator/trophy/winners?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
|
||||
class="btn btn-warning btn-sm">
|
||||
<i class="bi bi-eye-fill me-1"></i>Reveal Winners
|
||||
</a>
|
||||
<a href="<?= site_url('administrator/trophy/final?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
|
||||
class="btn btn-success btn-sm">
|
||||
<i class="bi bi-bar-chart-steps me-1"></i>Final vs Predicted
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Year filter -->
|
||||
<form method="get" action="<?= site_url('administrator/trophy') ?>" class="row g-2 align-items-end mb-4">
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small fw-semibold">School Year</label>
|
||||
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
|
||||
<?php foreach ($years as $yr): ?>
|
||||
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>>
|
||||
<?= esc($yr) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
<?php if (empty($years)): ?>
|
||||
<option value="<?= esc($selectedYear) ?>" selected><?= esc($selectedYear) ?></option>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small fw-semibold">Percentile threshold</label>
|
||||
<div class="input-group input-group-sm" style="width:130px;">
|
||||
<input type="number" name="percentile" class="form-control"
|
||||
min="1" max="99" step="1"
|
||||
value="<?= (int) $selectedPercentile ?>"
|
||||
title="Students scoring above this percentile receive a trophy (min 3 per class)">
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (empty($classResults)): ?>
|
||||
<div class="alert alert-info">No class or fall-score data found for the selected school year.</div>
|
||||
<?php else: ?>
|
||||
|
||||
<!-- ── Global stats ── -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card text-center shadow-sm border-warning h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2 text-warning"><i class="bi bi-trophy-fill"></i></div>
|
||||
<div class="fs-3 fw-bold"><?= $totalTrophies ?></div>
|
||||
<div class="text-muted small">Trophies</div>
|
||||
<span class="badge bg-warning text-dark mt-1"><?= $pctTrophyAll ?>% of students</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card text-center shadow-sm h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2 text-primary"><i class="bi bi-people-fill"></i></div>
|
||||
<div class="fs-3 fw-bold"><?= $totalStudents ?></div>
|
||||
<div class="text-muted small">Students</div>
|
||||
<span class="badge bg-secondary mt-1"><?= count($classResults) ?> classes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card text-center shadow-sm h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2 text-success"><i class="bi bi-bar-chart-fill"></i></div>
|
||||
<div class="fs-3 fw-bold"><?= $totalScored ?></div>
|
||||
<div class="text-muted small">With Fall Scores</div>
|
||||
<span class="badge bg-success mt-1"><?= $totalStudents > 0 ? round($totalScored/$totalStudents*100) : 0 ?>%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card text-center shadow-sm h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2" style="color:#4A90E2"><i class="bi bi-gender-male"></i></div>
|
||||
<div class="fs-3 fw-bold"><?= $totalBoys ?></div>
|
||||
<div class="text-muted small">Boys</div>
|
||||
<span class="badge text-white mt-1" style="background:#4A90E2"><?= $pctBoys ?>% of students</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card text-center shadow-sm h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2" style="color:#E47AB0"><i class="bi bi-gender-female"></i></div>
|
||||
<div class="fs-3 fw-bold"><?= $totalGirls ?></div>
|
||||
<div class="text-muted small">Girls</div>
|
||||
<span class="badge text-white mt-1" style="background:#E47AB0"><?= $pctGirls ?>% of students</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card text-center shadow-sm h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2 text-warning"><i class="bi bi-award-fill"></i></div>
|
||||
<div class="fs-3 fw-bold">
|
||||
<span style="color:#4A90E2"><?= $totalTrophyBoys ?></span>
|
||||
<span class="text-muted fs-5">/</span>
|
||||
<span style="color:#E47AB0"><?= $totalTrophyGirls ?></span>
|
||||
</div>
|
||||
<div class="text-muted small">Trophy M / F</div>
|
||||
<span class="small">
|
||||
<span style="color:#4A90E2"><?= $pctTrophyBoys ?>%</span>
|
||||
·
|
||||
<span style="color:#E47AB0"><?= $pctTrophyGirls ?>%</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Per-class summary ── -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white fw-semibold py-2">Class Breakdown</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm align-middle mb-0 no-mgmt-sticky" id="trophy-breakdown-table" data-no-mgmt-sticky>
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Class</th>
|
||||
<th class="text-center">Students</th>
|
||||
<th class="text-center">Scored</th>
|
||||
<th class="text-center">
|
||||
<i class="bi bi-trophy-fill text-warning"></i> Trophies
|
||||
</th>
|
||||
<th class="text-center" style="color:#4A90E2">
|
||||
<i class="bi bi-gender-male"></i> Boys
|
||||
</th>
|
||||
<th class="text-center" style="color:#E47AB0">
|
||||
<i class="bi bi-gender-female"></i> Girls
|
||||
</th>
|
||||
<th class="text-center" style="color:#4A90E2">Trophy Boys</th>
|
||||
<th class="text-center" style="color:#E47AB0">Trophy Girls</th>
|
||||
<th class="text-center">Rate</th>
|
||||
<th class="text-end">Threshold</th>
|
||||
<th class="text-center">Custom Threshold</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($classResults as $cls):
|
||||
$scores = array_filter(
|
||||
array_column($cls['students'], 'fall_score'),
|
||||
fn($s) => $s !== null
|
||||
);
|
||||
$scoresJson = json_encode(array_values($scores));
|
||||
$clsId = 'cls' . $cls['section_id'];
|
||||
?>
|
||||
<tr id="row-<?= $clsId ?>">
|
||||
<td class="ps-3 fw-semibold"><?= esc($cls['section_name']) ?></td>
|
||||
<td class="text-center"><?= $cls['student_count'] ?></td>
|
||||
<td class="text-center">
|
||||
<?php if ($cls['scored_count'] < 3): ?>
|
||||
<span class="badge bg-danger" title="Fewer than 3 fall scores — minimum of 3 trophies cannot be enforced">
|
||||
<?= $cls['scored_count'] ?> <i class="bi bi-exclamation-triangle-fill"></i>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<?= $cls['scored_count'] ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-center" id="trophy-cell-<?= $clsId ?>">
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-trophy-fill"></i>
|
||||
<span id="trophy-count-<?= $clsId ?>"><?= $cls['trophy_count'] ?></span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?= $cls['boys'] ?>
|
||||
<span class="text-muted small">(<?= $cls['pct_boys'] ?>%)</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?= $cls['girls'] ?>
|
||||
<span class="text-muted small">(<?= $cls['pct_girls'] ?>%)</span>
|
||||
</td>
|
||||
<td class="text-center" id="trophy-boys-<?= $clsId ?>"><?= $cls['trophy_boys'] ?><?php if ($cls['boys'] > 0): ?> <span class="text-muted small">(<?= $cls['pct_trophy_boys'] ?>%)</span><?php endif; ?></td>
|
||||
<td class="text-center" id="trophy-girls-<?= $clsId ?>"><?= $cls['trophy_girls'] ?><?php if ($cls['girls'] > 0): ?> <span class="text-muted small">(<?= $cls['pct_trophy_girls'] ?>%)</span><?php endif; ?></td>
|
||||
<td class="text-center">
|
||||
<div class="progress" style="height:8px;min-width:60px;">
|
||||
<div class="progress-bar bg-warning" id="rate-bar-<?= $clsId ?>" style="width:<?= $cls['pct_trophy_total'] ?>%;"></div>
|
||||
</div>
|
||||
<span class="small text-muted" id="rate-txt-<?= $clsId ?>"><?= $cls['pct_trophy_total'] ?>%</span>
|
||||
</td>
|
||||
<td class="text-end text-muted small" id="auto-threshold-<?= $clsId ?>">
|
||||
<?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '—' ?>
|
||||
</td>
|
||||
<td>
|
||||
<div class="input-group input-group-sm" style="min-width:150px;">
|
||||
<input type="number" id="custom-threshold-<?= $clsId ?>"
|
||||
class="form-control form-control-sm"
|
||||
step="0.1" min="0"
|
||||
placeholder="e.g. <?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '80.0' ?>"
|
||||
data-scores='<?= $scoresJson ?>'
|
||||
data-clsid="<?= $clsId ?>"
|
||||
data-boys='<?= json_encode(array_values(array_filter(array_map(fn($s) => $s['gender'] === 'Male' ? $s['fall_score'] : null, $cls['students']), fn($s) => $s !== null))) ?>'
|
||||
data-girls='<?= json_encode(array_values(array_filter(array_map(fn($s) => $s['gender'] === 'Female' ? $s['fall_score'] : null, $cls['students']), fn($s) => $s !== null))) ?>'
|
||||
data-total="<?= $cls['student_count'] ?>"
|
||||
data-nboys="<?= $cls['boys'] ?>"
|
||||
data-ngirls="<?= $cls['girls'] ?>">
|
||||
<button class="btn btn-outline-primary btn-sm" type="button"
|
||||
onclick="applyCustomThreshold('<?= $clsId ?>')">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
<div id="custom-note-<?= $clsId ?>" class="text-muted" style="font-size:.7rem;"></div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot class="table-light fw-semibold">
|
||||
<tr>
|
||||
<td class="ps-3">Total</td>
|
||||
<td class="text-center"><?= $totalStudents ?></td>
|
||||
<td class="text-center"><?= $totalScored ?></td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-trophy-fill"></i> <?= $totalTrophies ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?= $totalBoys ?>
|
||||
<span class="text-muted small fw-normal">(<?= $pctBoys ?>%)</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?= $totalGirls ?>
|
||||
<span class="text-muted small fw-normal">(<?= $pctGirls ?>%)</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?= $totalTrophyBoys ?>
|
||||
<span class="text-muted small fw-normal">(<?= $pctTrophyBoys ?>%)</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?= $totalTrophyGirls ?>
|
||||
<span class="text-muted small fw-normal">(<?= $pctTrophyGirls ?>%)</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="progress" style="height:8px;min-width:60px;">
|
||||
<div class="progress-bar bg-warning" style="width:<?= $pctTrophyAll ?>%;"></div>
|
||||
</div>
|
||||
<span class="small text-muted"><?= $pctTrophyAll ?>%</span>
|
||||
</td>
|
||||
<td></td>
|
||||
<td class="pe-3"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
var TROPHY_NS = 'trophy_custom_<?= esc($selectedYear) ?>_<?= (int)$selectedPercentile ?>';
|
||||
|
||||
function countAtOrAbove(arr, t) {
|
||||
return arr.filter(function(s) { return s >= t; }).length;
|
||||
}
|
||||
|
||||
function applyThreshold(clsId, val) {
|
||||
var input = document.getElementById('custom-threshold-' + clsId);
|
||||
var note = document.getElementById('custom-note-' + clsId);
|
||||
|
||||
var scores = JSON.parse(input.dataset.scores);
|
||||
var boys = JSON.parse(input.dataset.boys);
|
||||
var girls = JSON.parse(input.dataset.girls);
|
||||
var nTotal = parseInt(input.dataset.total);
|
||||
var nBoys = parseInt(input.dataset.nboys);
|
||||
var nGirls = parseInt(input.dataset.ngirls);
|
||||
var MIN = 3;
|
||||
|
||||
var winners = countAtOrAbove(scores, val);
|
||||
var usedThreshold = val;
|
||||
var forced = false;
|
||||
|
||||
if (winners < MIN && scores.length >= MIN) {
|
||||
var desc = scores.slice().sort(function(a, b) { return b - a; });
|
||||
usedThreshold = desc[MIN - 1];
|
||||
winners = countAtOrAbove(scores, usedThreshold);
|
||||
forced = true;
|
||||
} else if (winners < MIN) {
|
||||
winners = scores.length;
|
||||
usedThreshold = scores.length > 0 ? Math.min.apply(null, scores) : val;
|
||||
forced = true;
|
||||
}
|
||||
|
||||
var trophyBoys = countAtOrAbove(boys, usedThreshold);
|
||||
var trophyGirls = countAtOrAbove(girls, usedThreshold);
|
||||
var rate = nTotal > 0 ? Math.round(winners / nTotal * 100) : 0;
|
||||
var pctTB = nBoys > 0 ? Math.round(trophyBoys / nBoys * 100) : 0;
|
||||
var pctTG = nGirls > 0 ? Math.round(trophyGirls / nGirls * 100) : 0;
|
||||
|
||||
document.getElementById('trophy-count-' + clsId).textContent = winners;
|
||||
document.getElementById('trophy-boys-' + clsId).textContent = trophyBoys + (nBoys > 0 ? ' (' + pctTB + '%)' : '');
|
||||
document.getElementById('trophy-girls-' + clsId).textContent = trophyGirls + (nGirls > 0 ? ' (' + pctTG + '%)' : '');
|
||||
document.getElementById('rate-bar-' + clsId).style.width = rate + '%';
|
||||
document.getElementById('rate-txt-' + clsId).textContent = rate + '%';
|
||||
document.getElementById('auto-threshold-' + clsId).textContent = usedThreshold.toFixed(1);
|
||||
|
||||
if (forced) {
|
||||
note.style.color = '#dc3545';
|
||||
note.textContent = 'Min 3 enforced → threshold lowered to ' + usedThreshold.toFixed(1);
|
||||
} else {
|
||||
note.style.color = '#6c757d';
|
||||
note.textContent = 'Custom threshold: ' + usedThreshold.toFixed(1);
|
||||
}
|
||||
|
||||
var badge = document.getElementById('trophy-cell-' + clsId).querySelector('.badge');
|
||||
badge.classList.remove('bg-warning');
|
||||
badge.classList.add('bg-primary');
|
||||
}
|
||||
|
||||
function applyCustomThreshold(clsId) {
|
||||
var input = document.getElementById('custom-threshold-' + clsId);
|
||||
var val = parseFloat(input.value);
|
||||
if (isNaN(val)) {
|
||||
document.getElementById('custom-note-' + clsId).textContent = 'Enter a valid number.';
|
||||
return;
|
||||
}
|
||||
applyThreshold(clsId, val);
|
||||
// Persist so the value survives filter changes within the same percentile/year
|
||||
try {
|
||||
var saved = JSON.parse(localStorage.getItem(TROPHY_NS) || '{}');
|
||||
saved[clsId] = val;
|
||||
localStorage.setItem(TROPHY_NS, JSON.stringify(saved));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Restore saved custom thresholds on page load
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
try {
|
||||
var saved = JSON.parse(localStorage.getItem(TROPHY_NS) || '{}');
|
||||
Object.keys(saved).forEach(function (clsId) {
|
||||
var input = document.getElementById('custom-threshold-' + clsId);
|
||||
if (!input) return;
|
||||
input.value = saved[clsId];
|
||||
applyThreshold(clsId, saved[clsId]);
|
||||
});
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
// Clear all custom thresholds when the percentile/year form is submitted
|
||||
document.querySelector('form[action*="trophy"]').addEventListener('submit', function () {
|
||||
try { localStorage.removeItem(TROPHY_NS); } catch (e) {}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,598 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<?php
|
||||
$classResults = $classResults ?? [];
|
||||
$selectedYear = $selectedYear ?? '';
|
||||
$selectedPercentile = $selectedPercentile ?? 75;
|
||||
$years = $years ?? [];
|
||||
|
||||
$totalStudents = array_sum(array_column($classResults, 'student_count'));
|
||||
$totalPredicted = array_sum(array_column($classResults, 'predicted_count'));
|
||||
$totalActual = array_sum(array_column($classResults, 'actual_count'));
|
||||
$totalConfirmed = array_sum(array_column($classResults, 'confirmed'));
|
||||
$totalSurprise = array_sum(array_column($classResults, 'surprises'));
|
||||
$totalMissed = array_sum(array_column($classResults, 'missed'));
|
||||
$overallAccuracy = $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : ($totalActual === 0 ? 100 : 0);
|
||||
?>
|
||||
|
||||
<style>
|
||||
.status-confirmed { color:#198754; font-weight:600; }
|
||||
.status-surprise { color:#0d6efd; font-weight:600; }
|
||||
.status-missed { color:#fd7e14; font-weight:600; }
|
||||
.status-none { color:#adb5bd; }
|
||||
|
||||
.print-only { display: none !important; }
|
||||
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.print-only { display: block !important; }
|
||||
.screen-only { display: none !important; }
|
||||
body { font-size: 11px; }
|
||||
.container-fluid { padding: 0 !important; }
|
||||
h2, h3 { font-size: 13px; margin-bottom: .3rem; }
|
||||
.print-page-break { break-before: page; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 10px; }
|
||||
table th, table td { border: 1px solid #bbb; padding: 3px 5px; }
|
||||
table thead { background: #333 !important; color: #fff !important;
|
||||
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
table thead th { position: static !important; top: auto !important; box-shadow: none !important; }
|
||||
.s-confirmed { color: #198754; font-weight: bold; }
|
||||
.s-surprise { color: #0d6efd; font-weight: bold; }
|
||||
.s-missed { color: #fd7e14; font-weight: bold; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2 no-print">
|
||||
<div>
|
||||
<h2 class="mb-1">
|
||||
<i class="bi bi-trophy-fill text-warning me-1"></i>
|
||||
Final vs Predicted — <?= esc($selectedYear) ?>
|
||||
</h2>
|
||||
<p class="text-muted mb-0">
|
||||
Compares the <strong>Fall-score prediction</strong> (<?= (int)$selectedPercentile ?>th percentile)
|
||||
with the <strong>year-end result</strong> based on the average of Fall & Spring scores.
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button onclick="printWithCharts()" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-printer-fill me-1"></i>Print
|
||||
</button>
|
||||
<a href="<?= site_url('administrator/trophy?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<form method="get" action="<?= site_url('administrator/trophy/final') ?>"
|
||||
class="row g-2 align-items-end mb-4 no-print">
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small fw-semibold">School Year</label>
|
||||
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
|
||||
<?php foreach ($years as $yr): ?>
|
||||
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>><?= esc($yr) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small fw-semibold">Percentile</label>
|
||||
<div class="input-group input-group-sm" style="width:110px;">
|
||||
<input type="number" name="percentile" class="form-control"
|
||||
min="1" max="99" step="1" value="<?= (int)$selectedPercentile ?>">
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (empty($classResults)): ?>
|
||||
<div class="alert alert-info no-print">No data found for the selected school year.</div>
|
||||
<?php else: ?>
|
||||
|
||||
<!-- ══ SCREEN VIEW ═══════════════════════════════════════════════════════ -->
|
||||
<div class="screen-only">
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="d-flex flex-wrap gap-3 mb-3 small">
|
||||
<span><span class="badge bg-success me-1">✓ Confirmed</span> Predicted AND got a year-end trophy</span>
|
||||
<span><span class="badge bg-primary me-1">↑ Surprise</span> NOT predicted, but earned a trophy</span>
|
||||
<span><span class="badge bg-warning text-dark me-1">↓ Missed</span> Predicted, but fell short year-end</span>
|
||||
<span><span class="badge bg-light text-muted border me-1">— None</span> No trophy either way</span>
|
||||
</div>
|
||||
|
||||
<!-- Global stat tiles -->
|
||||
<div class="row g-3 mb-4">
|
||||
<?php foreach ([
|
||||
[$totalPredicted, 'Predicted (Fall)', 'primary', null, $totalStudents > 0 ? round($totalPredicted / $totalStudents * 100) . '% of students' : '—'],
|
||||
[$totalActual, 'Actual (Year)', 'warning', 'dark', $totalStudents > 0 ? round($totalActual / $totalStudents * 100) . '% of students' : '—'],
|
||||
[$totalConfirmed, 'Confirmed', 'success', null, $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) . '% of predicted' : '—'],
|
||||
[$totalSurprise, 'Surprises', 'info', 'dark', 'Not in prediction'],
|
||||
[$totalMissed, 'Missed', 'orange', null, 'Were predicted'],
|
||||
[$overallAccuracy,'Accuracy', 'dark', null, 'prediction rate'],
|
||||
] as [$val, $label, $color, $textClass, $sub]):
|
||||
$isOrange = $color === 'orange';
|
||||
$bgClass = $isOrange ? '' : 'bg-' . $color;
|
||||
$txClass = $textClass ? 'text-' . $textClass : 'text-white';
|
||||
?>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="card text-center shadow-sm h-100 <?= $isOrange ? 'border-warning' : '' ?>">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-3 fw-bold <?= $isOrange ? 'text-warning' : 'text-' . $color ?>">
|
||||
<?= $val ?><?= $label === 'Accuracy' ? '%' : '' ?>
|
||||
</div>
|
||||
<div class="text-muted small"><?= $label ?></div>
|
||||
<span class="badge mt-1 <?= $bgClass . ' ' . $txClass ?>"
|
||||
<?= $isOrange ? 'style="background:#fd7e14"' : '' ?>>
|
||||
<?= $sub ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Per-class breakdown -->
|
||||
<?php foreach ($classResults as $cls): ?>
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2 py-2"
|
||||
style="background:#f8f9fa;">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="fw-bold fs-6"><?= esc($cls['section_name']) ?></span>
|
||||
<span class="badge bg-primary"><?= $cls['predicted_count'] ?> predicted</span>
|
||||
<span class="badge bg-warning text-dark"><?= $cls['actual_count'] ?> actual</span>
|
||||
<?php if ($cls['confirmed'] > 0): ?>
|
||||
<span class="badge bg-success"><?= $cls['confirmed'] ?> confirmed</span>
|
||||
<?php endif; ?>
|
||||
<?php if ($cls['surprises'] > 0): ?>
|
||||
<span class="badge bg-info text-dark"><?= $cls['surprises'] ?> surprise<?= $cls['surprises'] > 1 ? 's' : '' ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ($cls['missed'] > 0): ?>
|
||||
<span class="badge" style="background:#fd7e14"><?= $cls['missed'] ?> missed</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="d-flex gap-3 small align-items-center">
|
||||
<span class="text-muted">Fall ≥ <strong><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></strong></span>
|
||||
<span class="text-muted">Year ≥ <strong><?= $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?></strong></span>
|
||||
<span class="fw-semibold">
|
||||
Accuracy:
|
||||
<span class="<?= $cls['accuracy'] >= 80 ? 'text-success' : ($cls['accuracy'] >= 50 ? 'text-warning' : 'text-danger') ?>">
|
||||
<?= $cls['accuracy'] ?>%
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0 align-middle" data-no-mgmt-sticky>
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3" style="width:2rem;">#</th>
|
||||
<th>Name</th>
|
||||
<th class="text-center" style="width:3rem;">Gender</th>
|
||||
<th class="text-end">Fall</th>
|
||||
<th class="text-end">Spring</th>
|
||||
<th class="text-end">Year Avg</th>
|
||||
<th class="text-center">Predicted</th>
|
||||
<th class="text-center">Actual</th>
|
||||
<th class="text-center">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php $rank = 0; foreach ($cls['students'] as $s):
|
||||
if ($s['status'] === 'none') continue;
|
||||
$rank++;
|
||||
$isMale = strtolower($s['gender'] ?? '') === 'male';
|
||||
$rowBg = match ($s['status']) {
|
||||
'confirmed' => 'table-success',
|
||||
'surprise' => 'table-primary',
|
||||
'missed' => 'table-warning',
|
||||
default => '',
|
||||
};
|
||||
?>
|
||||
<tr class="<?= $rowBg ?>">
|
||||
<td class="ps-3 text-muted small"><?= $rank ?></td>
|
||||
<td class="fw-semibold small"><?= esc($s['name']) ?></td>
|
||||
<td class="text-center">
|
||||
<span class="badge" style="background:<?= $isMale ? '#4A90E2' : '#E47AB0' ?>;font-size:.65rem;">
|
||||
<?= $isMale ? 'M' : 'F' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end small"><?= $s['fall_score'] !== null ? number_format($s['fall_score'], 1) : '<span class="text-muted">—</span>' ?></td>
|
||||
<td class="text-end small"><?= $s['spring_score'] !== null ? number_format($s['spring_score'], 1) : '<span class="text-muted">—</span>' ?></td>
|
||||
<td class="text-end small fw-semibold"><?= $s['year_score'] !== null ? number_format($s['year_score'], 1) : '<span class="text-muted">—</span>' ?></td>
|
||||
<td class="text-center small">
|
||||
<?= $s['predicted']
|
||||
? '<span class="badge bg-primary">Yes</span>'
|
||||
: '<span class="badge bg-light text-muted border">No</span>' ?>
|
||||
</td>
|
||||
<td class="text-center small">
|
||||
<?= $s['actual']
|
||||
? '<span class="badge bg-warning text-dark">Yes</span>'
|
||||
: '<span class="badge bg-light text-muted border">No</span>' ?>
|
||||
</td>
|
||||
<td class="text-center small">
|
||||
<?php match ($s['status']) {
|
||||
'confirmed' => print '<span class="badge bg-success">✓ Confirmed</span>',
|
||||
'surprise' => print '<span class="badge bg-info text-dark">↑ Surprise</span>',
|
||||
'missed' => print '<span class="badge" style="background:#fd7e14;color:#fff;">↓ Missed</span>',
|
||||
default => print '<span class="text-muted small">—</span>',
|
||||
}; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Overall accuracy summary table -->
|
||||
<div class="card shadow-sm mt-2 mb-4">
|
||||
<div class="card-header bg-dark text-white fw-semibold py-2">
|
||||
<i class="bi bi-bar-chart-fill me-2"></i>Prediction Accuracy Summary — <?= esc($selectedYear) ?>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-bordered mb-0 align-middle" data-no-mgmt-sticky>
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th class="ps-2">Class</th>
|
||||
<th class="text-center">Students</th>
|
||||
<th class="text-center text-primary-emphasis">Predicted</th>
|
||||
<th class="text-center text-warning-emphasis">Actual</th>
|
||||
<th class="text-center text-success-emphasis">✓ Confirmed</th>
|
||||
<th class="text-center" style="color:#6ea8fe">↑ Surprises</th>
|
||||
<th class="text-center" style="color:#ffda6a">↓ Missed</th>
|
||||
<th class="text-center">Accuracy</th>
|
||||
<th class="text-end pe-2">Fall ≥</th>
|
||||
<th class="text-end pe-2">Year ≥</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($classResults as $cls): ?>
|
||||
<tr>
|
||||
<td class="ps-2 fw-semibold small"><?= esc($cls['section_name']) ?></td>
|
||||
<td class="text-center small"><?= $cls['student_count'] ?></td>
|
||||
<td class="text-center small"><span class="badge bg-primary"><?= $cls['predicted_count'] ?></span></td>
|
||||
<td class="text-center small"><span class="badge bg-warning text-dark"><?= $cls['actual_count'] ?></span></td>
|
||||
<td class="text-center small"><span class="badge bg-success"><?= $cls['confirmed'] ?></span></td>
|
||||
<td class="text-center small"><span class="badge bg-info text-dark"><?= $cls['surprises'] ?></span></td>
|
||||
<td class="text-center small"><span class="badge" style="background:#fd7e14"><?= $cls['missed'] ?></span></td>
|
||||
<td class="text-center small fw-semibold <?= $cls['accuracy'] >= 80 ? 'text-success' : ($cls['accuracy'] >= 50 ? 'text-warning' : 'text-danger') ?>">
|
||||
<?= $cls['accuracy'] ?>%
|
||||
</td>
|
||||
<td class="text-end pe-2 small text-muted"><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></td>
|
||||
<td class="text-end pe-2 small text-muted"><?= $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot class="table-secondary fw-semibold">
|
||||
<tr>
|
||||
<td class="ps-2">Total</td>
|
||||
<td class="text-center"><?= $totalStudents ?></td>
|
||||
<td class="text-center"><span class="badge bg-primary"><?= $totalPredicted ?></span></td>
|
||||
<td class="text-center"><span class="badge bg-warning text-dark"><?= $totalActual ?></span></td>
|
||||
<td class="text-center"><span class="badge bg-success"><?= $totalConfirmed ?></span></td>
|
||||
<td class="text-center"><span class="badge bg-info text-dark"><?= $totalSurprise ?></span></td>
|
||||
<td class="text-center"><span class="badge" style="background:#fd7e14"><?= $totalMissed ?></span></td>
|
||||
<td class="text-center fw-semibold <?= $overallAccuracy >= 80 ? 'text-success' : ($overallAccuracy >= 50 ? 'text-warning' : 'text-danger') ?>">
|
||||
<?= $overallAccuracy ?>%
|
||||
</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="row g-4 mt-1 mb-4">
|
||||
<!-- Grouped bar: predicted / actual / confirmed / surprises / missed per class -->
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="small fw-semibold text-muted mb-2">
|
||||
<i class="bi bi-bar-chart-fill me-1"></i>Trophy Counts per Class
|
||||
</div>
|
||||
<canvas id="chart-counts" style="max-height:280px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bar: accuracy % per class + doughnut overall breakdown -->
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="row g-3 h-100">
|
||||
<div class="col-12">
|
||||
<div class="border rounded p-3">
|
||||
<div class="small fw-semibold text-muted mb-2">
|
||||
<i class="bi bi-bullseye me-1"></i>Prediction Accuracy per Class
|
||||
</div>
|
||||
<canvas id="chart-accuracy" style="max-height:130px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="border rounded p-3">
|
||||
<div class="small fw-semibold text-muted mb-2">
|
||||
<i class="bi bi-pie-chart-fill me-1"></i>Overall Outcome Breakdown
|
||||
</div>
|
||||
<canvas id="chart-breakdown" style="max-height:130px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /screen-only -->
|
||||
|
||||
<!-- ══ PRINT VIEW ════════════════════════════════════════════════════════ -->
|
||||
<div class="print-only">
|
||||
|
||||
<div style="text-align:center;margin-bottom:10px;border-bottom:2px solid #333;padding-bottom:6px;">
|
||||
<h2 style="margin:0;">Final vs Predicted — <?= esc($selectedYear) ?></h2>
|
||||
<p style="margin:3px 0 0;font-size:10px;color:#555;">
|
||||
<?= (int)$selectedPercentile ?>th percentile —
|
||||
Predicted: <?= $totalPredicted ?> —
|
||||
Actual: <?= $totalActual ?> —
|
||||
Confirmed: <?= $totalConfirmed ?> —
|
||||
Accuracy: <?= $overallAccuracy ?>%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- All students flat table -->
|
||||
<h3 style="margin-bottom:4px;">Student Detail</h3>
|
||||
<table data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Class</th>
|
||||
<th>Name</th>
|
||||
<th style="text-align:center;">G</th>
|
||||
<th style="text-align:right;">Fall</th>
|
||||
<th style="text-align:right;">Spring</th>
|
||||
<th style="text-align:right;">Year Avg</th>
|
||||
<th style="text-align:center;">Predicted</th>
|
||||
<th style="text-align:center;">Actual</th>
|
||||
<th style="text-align:center;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$rank = 0;
|
||||
foreach ($classResults as $cls):
|
||||
foreach ($cls['students'] as $s):
|
||||
if ($s['status'] === 'none') continue;
|
||||
$rank++;
|
||||
$isMale = strtolower($s['gender'] ?? '') === 'male';
|
||||
$statusLabel = match ($s['status']) {
|
||||
'confirmed' => '✓ Confirmed',
|
||||
'surprise' => '↑ Surprise',
|
||||
'missed' => '↓ Missed',
|
||||
default => '—',
|
||||
};
|
||||
$statusClass = 's-' . $s['status'];
|
||||
?>
|
||||
<tr>
|
||||
<td style="text-align:center;"><?= $rank ?></td>
|
||||
<td><?= esc($cls['section_name']) ?></td>
|
||||
<td><strong><?= esc($s['name']) ?></strong></td>
|
||||
<td style="text-align:center;"><?= $isMale ? 'M' : 'F' ?></td>
|
||||
<td style="text-align:right;"><?= $s['fall_score'] !== null ? number_format($s['fall_score'], 1) : '—' ?></td>
|
||||
<td style="text-align:right;"><?= $s['spring_score'] !== null ? number_format($s['spring_score'], 1) : '—' ?></td>
|
||||
<td style="text-align:right;font-weight:bold;"><?= $s['year_score'] !== null ? number_format($s['year_score'], 1) : '—' ?></td>
|
||||
<td style="text-align:center;"><?= $s['predicted'] ? 'Yes' : 'No' ?></td>
|
||||
<td style="text-align:center;"><?= $s['actual'] ? 'Yes' : 'No' ?></td>
|
||||
<td style="text-align:center;" class="<?= $statusClass ?>"><?= $statusLabel ?></td>
|
||||
</tr>
|
||||
<?php endforeach; endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Accuracy summary (new page) -->
|
||||
<div class="print-page-break"></div>
|
||||
<h3 style="margin-bottom:4px;">Prediction Accuracy Summary</h3>
|
||||
<table data-no-mgmt-sticky style="margin-bottom:14px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Class</th>
|
||||
<th style="text-align:center;">Students</th>
|
||||
<th style="text-align:center;">Predicted</th>
|
||||
<th style="text-align:center;">Actual</th>
|
||||
<th style="text-align:center;">✓ Confirmed</th>
|
||||
<th style="text-align:center;">↑ Surprises</th>
|
||||
<th style="text-align:center;">↓ Missed</th>
|
||||
<th style="text-align:center;">Accuracy</th>
|
||||
<th style="text-align:right;">Fall ≥</th>
|
||||
<th style="text-align:right;">Year ≥</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($classResults as $cls): ?>
|
||||
<tr>
|
||||
<td><?= esc($cls['section_name']) ?></td>
|
||||
<td style="text-align:center;"><?= $cls['student_count'] ?></td>
|
||||
<td style="text-align:center;"><?= $cls['predicted_count'] ?></td>
|
||||
<td style="text-align:center;"><?= $cls['actual_count'] ?></td>
|
||||
<td style="text-align:center;" class="s-confirmed"><?= $cls['confirmed'] ?></td>
|
||||
<td style="text-align:center;" class="s-surprise"><?= $cls['surprises'] ?></td>
|
||||
<td style="text-align:center;" class="s-missed"><?= $cls['missed'] ?></td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $cls['accuracy'] ?>%</td>
|
||||
<td style="text-align:right;"><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></td>
|
||||
<td style="text-align:right;"><?= $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td style="font-weight:bold;">Total</td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $totalStudents ?></td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $totalPredicted ?></td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $totalActual ?></td>
|
||||
<td style="text-align:center;font-weight:bold;" class="s-confirmed"><?= $totalConfirmed ?></td>
|
||||
<td style="text-align:center;font-weight:bold;" class="s-surprise"><?= $totalSurprise ?></td>
|
||||
<td style="text-align:center;font-weight:bold;" class="s-missed"><?= $totalMissed ?></td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $overallAccuracy ?>%</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<!-- Charts as images (populated by JS before printing) -->
|
||||
<div class="print-page-break"></div>
|
||||
<h3 style="margin-bottom:6px;">Charts</h3>
|
||||
<div style="margin-bottom:14px;">
|
||||
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Trophy Counts per Class</p>
|
||||
<img id="print-chart-counts" style="width:100%;max-height:220px;object-fit:contain;" src="" alt="">
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;margin-bottom:14px;">
|
||||
<div style="flex:1;">
|
||||
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Prediction Accuracy per Class</p>
|
||||
<img id="print-chart-accuracy" style="width:100%;max-height:160px;object-fit:contain;" src="" alt="">
|
||||
</div>
|
||||
<div style="flex:1;">
|
||||
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Overall Outcome Breakdown</p>
|
||||
<img id="print-chart-breakdown" style="width:100%;max-height:160px;object-fit:contain;" src="" alt="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /print-only -->
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
<?php
|
||||
$cLabels = [];
|
||||
$cPredicted = [];
|
||||
$cActual = [];
|
||||
$cConfirmed = [];
|
||||
$cSurprise = [];
|
||||
$cMissed = [];
|
||||
$cAccuracy = [];
|
||||
foreach ($classResults as $cls) {
|
||||
$cLabels[] = $cls['section_name'];
|
||||
$cPredicted[] = $cls['predicted_count'];
|
||||
$cActual[] = $cls['actual_count'];
|
||||
$cConfirmed[] = $cls['confirmed'];
|
||||
$cSurprise[] = $cls['surprises'];
|
||||
$cMissed[] = $cls['missed'];
|
||||
$cAccuracy[] = $cls['accuracy'];
|
||||
}
|
||||
?>
|
||||
var labels = <?= json_encode($cLabels) ?>;
|
||||
var predicted = <?= json_encode($cPredicted) ?>;
|
||||
var actual = <?= json_encode($cActual) ?>;
|
||||
var confirmed = <?= json_encode($cConfirmed) ?>;
|
||||
var surprises = <?= json_encode($cSurprise) ?>;
|
||||
var missed = <?= json_encode($cMissed) ?>;
|
||||
var accuracy = <?= json_encode($cAccuracy) ?>;
|
||||
|
||||
/* ── Chart 1: grouped bar – counts per class ── */
|
||||
new Chart(document.getElementById('chart-counts'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{ label: 'Predicted', data: predicted, backgroundColor: '#4A90E2', borderRadius: 3 },
|
||||
{ label: 'Actual', data: actual, backgroundColor: '#f0a500', borderRadius: 3 },
|
||||
{ label: 'Confirmed', data: confirmed, backgroundColor: '#28a745', borderRadius: 3 },
|
||||
{ label: 'Surprises', data: surprises, backgroundColor: '#17a2b8', borderRadius: 3 },
|
||||
{ label: 'Missed', data: missed, backgroundColor: '#fd7e14', borderRadius: 3 },
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { grid: { color: '#f0f0f0' } },
|
||||
y: { beginAtZero: true, ticks: { stepSize: 1 }, grid: { color: '#f0f0f0' } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Chart 2: accuracy % per class ── */
|
||||
new Chart(document.getElementById('chart-accuracy'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Accuracy %',
|
||||
data: accuracy,
|
||||
backgroundColor: accuracy.map(function(a) {
|
||||
return a >= 80 ? '#28a745' : a >= 50 ? '#f0a500' : '#dc3545';
|
||||
}),
|
||||
borderRadius: 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true, max: 100,
|
||||
ticks: { callback: function(v) { return v + '%'; } },
|
||||
grid: { color: '#f0f0f0' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Chart 3: doughnut overall outcome breakdown ── */
|
||||
new Chart(document.getElementById('chart-breakdown'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Confirmed', 'Surprises', 'Missed'],
|
||||
datasets: [{
|
||||
data: [<?= $totalConfirmed ?>, <?= $totalSurprise ?>, <?= $totalMissed ?>],
|
||||
backgroundColor: ['#28a745', '#17a2b8', '#fd7e14'],
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
var total = ctx.dataset.data.reduce(function(a, b) { return a + b; }, 0);
|
||||
var pct = total > 0 ? Math.round(ctx.parsed / total * 100) : 0;
|
||||
return ctx.label + ': ' + ctx.parsed + ' (' + pct + '%)';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
function captureCharts() {
|
||||
var map = {
|
||||
'chart-counts': 'print-chart-counts',
|
||||
'chart-accuracy': 'print-chart-accuracy',
|
||||
'chart-breakdown': 'print-chart-breakdown',
|
||||
};
|
||||
Object.keys(map).forEach(function(canvasId) {
|
||||
var canvas = document.getElementById(canvasId);
|
||||
var img = document.getElementById(map[canvasId]);
|
||||
if (canvas && img) img.src = canvas.toDataURL('image/png');
|
||||
});
|
||||
}
|
||||
|
||||
function printWithCharts() {
|
||||
captureCharts();
|
||||
window.print();
|
||||
}
|
||||
|
||||
window.addEventListener('beforeprint', captureCharts);
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,480 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<?php
|
||||
$classResults = $classResults ?? [];
|
||||
$selectedYear = $selectedYear ?? '';
|
||||
$selectedPercentile = $selectedPercentile ?? 75;
|
||||
$years = $years ?? [];
|
||||
|
||||
$totalWinners = array_sum(array_map(fn($c) => count($c['winners']), $classResults));
|
||||
$totalStudents = array_sum(array_column($classResults, 'student_count'));
|
||||
$totalBoys = array_sum(array_column($classResults, 'boys'));
|
||||
$totalGirls = array_sum(array_column($classResults, 'girls'));
|
||||
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
|
||||
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
|
||||
|
||||
$pctWinners = $totalStudents > 0 ? round($totalWinners / $totalStudents * 100) : 0;
|
||||
$pctTrophyBoys = $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0;
|
||||
$pctTrophyGirls = $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0;
|
||||
$pctBoys = $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0;
|
||||
$pctGirls = $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0;
|
||||
?>
|
||||
|
||||
<style>
|
||||
/* ── Screen: hide print-only blocks ── */
|
||||
.print-only { display: none !important; }
|
||||
|
||||
/* ── Print ── */
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.print-only { display: block !important; }
|
||||
.screen-only { display: none !important; }
|
||||
|
||||
body { font-size: 11px; }
|
||||
.container-fluid { padding: 0 !important; }
|
||||
h2, h3 { font-size: 14px; margin-bottom: .4rem; }
|
||||
.print-page-break { break-before: page; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 10px; }
|
||||
table th,
|
||||
table td { border: 1px solid #bbb; padding: 3px 6px; }
|
||||
table thead { background: #333 !important; color: #fff !important;
|
||||
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
table thead th { position: static !important; top: auto !important; box-shadow: none !important; }
|
||||
table tfoot { background: #eee !important;
|
||||
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.badge-m { color: #4A90E2; font-weight: bold; }
|
||||
.badge-f { color: #E47AB0; font-weight: bold; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- ══ SCREEN HEADER ══════════════════════════════════════════════════════ -->
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2 no-print">
|
||||
<div>
|
||||
<h2 class="mb-1"><i class="bi bi-trophy-fill text-warning me-2"></i>Trophy Winners</h2>
|
||||
<p class="text-muted mb-0">
|
||||
<?= esc($selectedYear) ?> — <?= (int)$selectedPercentile ?>th percentile —
|
||||
<strong><?= $totalWinners ?></strong> winners across <strong><?= count($classResults) ?></strong> classes
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button onclick="window.print()" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-printer-fill me-1"></i>Print
|
||||
</button>
|
||||
<a href="<?= site_url('administrator/trophy?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter (screen only) -->
|
||||
<form method="get" action="<?= site_url('administrator/trophy/winners') ?>"
|
||||
class="row g-2 align-items-end mb-4 no-print">
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small fw-semibold">School Year</label>
|
||||
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
|
||||
<?php foreach ($years as $yr): ?>
|
||||
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>><?= esc($yr) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label mb-1 small fw-semibold">Percentile</label>
|
||||
<div class="input-group input-group-sm" style="width:110px;">
|
||||
<input type="number" name="percentile" class="form-control"
|
||||
min="1" max="99" step="1" value="<?= (int)$selectedPercentile ?>">
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (empty($classResults)): ?>
|
||||
<div class="alert alert-info no-print">No projected trophy winners found.</div>
|
||||
<?php else: ?>
|
||||
|
||||
<!-- ══ PRINT VIEW ════════════════════════════════════════════════════════ -->
|
||||
<div class="print-only">
|
||||
|
||||
<!-- Print title -->
|
||||
<div style="text-align:center;margin-bottom:12px;border-bottom:2px solid #333;padding-bottom:8px;">
|
||||
<h2 style="margin:0;">Trophy Winners — <?= esc($selectedYear) ?></h2>
|
||||
<p style="margin:4px 0 0;font-size:10px;color:#555;">
|
||||
<?= (int)$selectedPercentile ?>th percentile —
|
||||
<?= $totalWinners ?> winners / <?= $totalStudents ?> students —
|
||||
<?= count($classResults) ?> classes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- TABLE 1: All winners -->
|
||||
<h3 style="margin-bottom:4px;">Winners</h3>
|
||||
<table data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Class</th>
|
||||
<th>Name</th>
|
||||
<th style="text-align:center;">Gender</th>
|
||||
<th style="text-align:right;">Fall Score</th>
|
||||
<th style="text-align:right;">Spring Score</th>
|
||||
<th style="text-align:right;">Year Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$globalRank = 0;
|
||||
foreach ($classResults as $cls):
|
||||
foreach ($cls['winners'] as $student):
|
||||
$globalRank++;
|
||||
$isMale = strtolower($student['gender'] ?? '') === 'male';
|
||||
?>
|
||||
<tr>
|
||||
<td style="text-align:center;"><?= $globalRank ?></td>
|
||||
<td><?= esc($cls['section_name']) ?></td>
|
||||
<td><strong><?= esc($student['name']) ?></strong></td>
|
||||
<td style="text-align:center;">
|
||||
<span class="<?= $isMale ? 'badge-m' : 'badge-f' ?>">
|
||||
<?= $isMale ? 'M' : 'F' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td style="text-align:right;font-weight:bold;">
|
||||
<?= $student['fall_score'] !== null ? number_format($student['fall_score'], 1) : '—' ?>
|
||||
</td>
|
||||
<td style="text-align:right;">
|
||||
<?= $student['spring_score'] !== null ? number_format($student['spring_score'], 1) : '—' ?>
|
||||
</td>
|
||||
<td style="text-align:right;font-weight:bold;color:#155724;">
|
||||
<?= $student['year_score'] !== null ? number_format($student['year_score'], 1) : '—' ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2" style="font-weight:bold;">Total</td>
|
||||
<td colspan="2" style="font-weight:bold;"><?= $totalWinners ?> winners</td>
|
||||
<td colspan="3" style="text-align:right;font-weight:bold;"><?= $pctWinners ?>% of students</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<!-- TABLE 2: Year summary (new page) -->
|
||||
<div class="print-page-break"></div>
|
||||
|
||||
<h3 style="margin-bottom:4px;">Year Summary — <?= esc($selectedYear) ?></h3>
|
||||
<table style="margin-bottom:16px;" data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Class</th>
|
||||
<th style="text-align:center;">Students</th>
|
||||
<th style="text-align:center;">Boys</th>
|
||||
<th style="text-align:center;">Girls</th>
|
||||
<th style="text-align:center;">Winners</th>
|
||||
<th style="text-align:center;">Trophy Boys</th>
|
||||
<th style="text-align:center;">Trophy Girls</th>
|
||||
<th style="text-align:center;">Rate</th>
|
||||
<th style="text-align:right;">Fall Threshold</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($classResults as $cls): ?>
|
||||
<tr>
|
||||
<td><?= esc($cls['section_name']) ?></td>
|
||||
<td style="text-align:center;"><?= $cls['student_count'] ?></td>
|
||||
<td style="text-align:center;"><?= $cls['boys'] ?> (<?= $cls['pct_boys'] ?>%)</td>
|
||||
<td style="text-align:center;"><?= $cls['girls'] ?> (<?= $cls['pct_girls'] ?>%)</td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= count($cls['winners']) ?></td>
|
||||
<td style="text-align:center;">
|
||||
<span class="badge-m"><?= $cls['trophy_boys'] ?></span>
|
||||
<?= $cls['boys'] > 0 ? '(' . $cls['pct_trophy_boys'] . '%)' : '' ?>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
<span class="badge-f"><?= $cls['trophy_girls'] ?></span>
|
||||
<?= $cls['girls'] > 0 ? '(' . $cls['pct_trophy_girls'] . '%)' : '' ?>
|
||||
</td>
|
||||
<td style="text-align:center;"><?= $cls['pct_trophy_total'] ?>%</td>
|
||||
<td style="text-align:right;"><?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '—' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td style="font-weight:bold;">Total</td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $totalStudents ?></td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $totalBoys ?> (<?= $pctBoys ?>%)</td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $totalGirls ?> (<?= $pctGirls ?>%)</td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $totalWinners ?></td>
|
||||
<td style="text-align:center;font-weight:bold;">
|
||||
<span class="badge-m"><?= $totalTrophyBoys ?></span> (<?= $pctTrophyBoys ?>%)
|
||||
</td>
|
||||
<td style="text-align:center;font-weight:bold;">
|
||||
<span class="badge-f"><?= $totalTrophyGirls ?></span> (<?= $pctTrophyGirls ?>%)
|
||||
</td>
|
||||
<td style="text-align:center;font-weight:bold;"><?= $pctWinners ?>%</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
</div><!-- /print-only -->
|
||||
|
||||
<!-- ══ SCREEN VIEW ═══════════════════════════════════════════════════════ -->
|
||||
<div class="screen-only">
|
||||
|
||||
<!-- Per-class cards -->
|
||||
<?php foreach ($classResults as $cls):
|
||||
$nW = count($cls['winners']);
|
||||
?>
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2 py-2"
|
||||
style="background:#fff8e1;">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="fw-bold fs-6"><?= esc($cls['section_name']) ?></span>
|
||||
<span class="badge bg-warning text-dark">
|
||||
<i class="bi bi-trophy-fill"></i> <?= $nW ?> winner<?= $nW !== 1 ? 's' : '' ?>
|
||||
</span>
|
||||
<?php if ($cls['threshold'] !== null): ?>
|
||||
<span class="text-muted small">Fall ≥ <?= number_format((float)$cls['threshold'], 1) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="d-flex gap-3 small">
|
||||
<span>
|
||||
<i class="bi bi-gender-male" style="color:#4A90E2"></i>
|
||||
<strong style="color:#4A90E2"><?= $cls['trophy_boys'] ?></strong>/<?= $cls['boys'] ?> boys
|
||||
<?php if ($cls['boys'] > 0): ?><span class="text-muted">(<?= $cls['pct_trophy_boys'] ?>%)</span><?php endif; ?>
|
||||
</span>
|
||||
<span>
|
||||
<i class="bi bi-gender-female" style="color:#E47AB0"></i>
|
||||
<strong style="color:#E47AB0"><?= $cls['trophy_girls'] ?></strong>/<?= $cls['girls'] ?> girls
|
||||
<?php if ($cls['girls'] > 0): ?><span class="text-muted">(<?= $cls['pct_trophy_girls'] ?>%)</span><?php endif; ?>
|
||||
</span>
|
||||
<span class="text-muted"><?= $nW ?>/<?= $cls['student_count'] ?> (<?= $cls['pct_trophy_total'] ?>%)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0 align-middle" data-no-mgmt-sticky>
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3" style="width:2.2rem;">#</th>
|
||||
<th>Name</th>
|
||||
<th class="text-center" style="width:3rem;">Gender</th>
|
||||
<th class="text-end">Fall Score</th>
|
||||
<th class="text-end">Spring Score</th>
|
||||
<th class="text-end pe-3">Year Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($cls['winners'] as $i => $student):
|
||||
$isMale = strtolower($student['gender'] ?? '') === 'male';
|
||||
?>
|
||||
<tr>
|
||||
<td class="ps-3 text-muted small"><?= $i + 1 ?></td>
|
||||
<td class="fw-semibold small"><?= esc($student['name']) ?></td>
|
||||
<td class="text-center">
|
||||
<span class="badge" style="background:<?= $isMale ? '#4A90E2' : '#E47AB0' ?>;font-size:.65rem;">
|
||||
<?= $isMale ? 'M' : 'F' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end small fw-semibold">
|
||||
<?= $student['fall_score'] !== null ? number_format($student['fall_score'], 1) : '<span class="text-muted">—</span>' ?>
|
||||
</td>
|
||||
<td class="text-end small">
|
||||
<?= $student['spring_score'] !== null
|
||||
? '<span class="fw-semibold">' . number_format($student['spring_score'], 1) . '</span>'
|
||||
: '<span class="text-muted">—</span>' ?>
|
||||
</td>
|
||||
<td class="text-end pe-3 small fw-semibold" style="color:#155724;">
|
||||
<?= $student['year_score'] !== null ? number_format($student['year_score'], 1) : '<span class="text-muted">—</span>' ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- Overall year stats -->
|
||||
<div class="card shadow-sm mt-2">
|
||||
<div class="card-header bg-dark text-white fw-semibold py-2">
|
||||
<i class="bi bi-bar-chart-fill me-2"></i>Overall Year Summary — <?= esc($selectedYear) ?>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<?php foreach ([
|
||||
[$totalWinners, 'Total Winners', 'warning', 'text-dark', $pctWinners . '% of students', null],
|
||||
[$totalStudents, 'Total Students', 'secondary', 'text-white', count($classResults) . ' classes', null],
|
||||
[$totalBoys, 'Total Boys', null, null, $pctBoys . '%', '#4A90E2'],
|
||||
[$totalGirls, 'Total Girls', null, null, $pctGirls . '%', '#E47AB0'],
|
||||
[$totalTrophyBoys, 'Trophy Boys', null, null, $pctTrophyBoys . '% of boys', '#4A90E2'],
|
||||
[$totalTrophyGirls, 'Trophy Girls', null, null, $pctTrophyGirls . '% of girls', '#E47AB0'],
|
||||
] as [$val, $label, $bg, $tc, $sub, $color]): ?>
|
||||
<div class="col-6 col-sm-4 col-lg-2">
|
||||
<div class="border rounded text-center p-3 h-100">
|
||||
<div class="fs-3 fw-bold" <?= isset($color) ? 'style="color:' . $color . '"' : ($bg ? 'class="text-' . $bg . '"' : '') ?>>
|
||||
<?= $val ?>
|
||||
</div>
|
||||
<div class="small text-muted"><?= $label ?></div>
|
||||
<div class="badge mt-1 <?= $bg ? 'bg-' . $bg . ' ' . $tc : 'text-white' ?>"
|
||||
<?= isset($color) && !$bg ? 'style="background:' . $color . '"' : '' ?>>
|
||||
<?= $sub ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mt-1">
|
||||
<!-- Stacked bar: trophies per class (boys + girls) -->
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<div class="small fw-semibold text-muted mb-2">
|
||||
<i class="bi bi-trophy-fill text-warning me-1"></i>Trophy Winners per Class
|
||||
</div>
|
||||
<canvas id="chart-per-class" style="max-height:260px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Doughnut: trophy gender split + rate per class -->
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="row g-3 h-100">
|
||||
<div class="col-12">
|
||||
<div class="border rounded p-3">
|
||||
<div class="small fw-semibold text-muted mb-2">
|
||||
<i class="bi bi-gender-ambiguous me-1"></i>Winners by Gender
|
||||
</div>
|
||||
<canvas id="chart-gender" style="max-height:160px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="border rounded p-3">
|
||||
<div class="small fw-semibold text-muted mb-2">
|
||||
<i class="bi bi-bar-chart-fill me-1"></i>Trophy Rate per Class (%)
|
||||
</div>
|
||||
<canvas id="chart-rate" style="max-height:130px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /screen-only -->
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
<?php
|
||||
$chartLabels = [];
|
||||
$chartBoys = [];
|
||||
$chartGirls = [];
|
||||
$chartRates = [];
|
||||
foreach ($classResults as $cls) {
|
||||
$chartLabels[] = $cls['section_name'];
|
||||
$chartBoys[] = $cls['trophy_boys'];
|
||||
$chartGirls[] = $cls['trophy_girls'];
|
||||
$chartRates[] = $cls['pct_trophy_total'];
|
||||
}
|
||||
?>
|
||||
var labels = <?= json_encode($chartLabels) ?>;
|
||||
var boys = <?= json_encode($chartBoys) ?>;
|
||||
var girls = <?= json_encode($chartGirls) ?>;
|
||||
var rates = <?= json_encode($chartRates) ?>;
|
||||
|
||||
var BOY_COLOR = '#4A90E2';
|
||||
var GIRL_COLOR = '#E47AB0';
|
||||
var RATE_COLOR = '#f0a500';
|
||||
|
||||
/* ── Chart 1: stacked horizontal bar per class ── */
|
||||
new Chart(document.getElementById('chart-per-class'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{ label: 'Boys', data: boys, backgroundColor: BOY_COLOR, stack: 'winners' },
|
||||
{ label: 'Girls', data: girls, backgroundColor: GIRL_COLOR, stack: 'winners' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: {
|
||||
x: { stacked: true, ticks: { stepSize: 1 }, grid: { color: '#f0f0f0' } },
|
||||
y: { stacked: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Chart 2: doughnut gender split ── */
|
||||
new Chart(document.getElementById('chart-gender'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Trophy Boys', 'Trophy Girls'],
|
||||
datasets: [{
|
||||
data: [<?= $totalTrophyBoys ?>, <?= $totalTrophyGirls ?>],
|
||||
backgroundColor: [BOY_COLOR, GIRL_COLOR],
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
var total = ctx.dataset.data.reduce(function(a,b){ return a+b; }, 0);
|
||||
var pct = total > 0 ? Math.round(ctx.parsed / total * 100) : 0;
|
||||
return ctx.label + ': ' + ctx.parsed + ' (' + pct + '%)';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Chart 3: bar chart rate per class ── */
|
||||
new Chart(document.getElementById('chart-rate'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Trophy Rate %',
|
||||
data: rates,
|
||||
backgroundColor: rates.map(function(r) {
|
||||
return r > 30 ? '#dc3545' : r > 20 ? RATE_COLOR : '#28a745';
|
||||
}),
|
||||
borderRadius: 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true, max: 100,
|
||||
ticks: { callback: function(v) { return v + '%'; } },
|
||||
grid: { color: '#f0f0f0' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -195,22 +195,22 @@
|
||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||
<div class="row g-2 pt-2">
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-1.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-2.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-3.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-4.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-5.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-6.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
<br>
|
||||
<input type="hidden" name="selected_role" id="selected_role">
|
||||
<input type="hidden" name="redirect_to" value="<?= esc((string) ($redirect_to ?? '')) ?>">
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-center gap-3 mt-3">
|
||||
<?php foreach ($roles as $role): ?>
|
||||
|
||||
@@ -155,22 +155,22 @@
|
||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||
<div class="row g-2 pt-2">
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-1.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-2.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-3.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-4.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-5.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-6.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Certificate Verification — Al Rahma Sunday School</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.7.2/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
body { background: #f4f6f9; }
|
||||
.verify-card { max-width: 520px; margin: 60px auto; }
|
||||
.badge-valid { background: #198754; }
|
||||
.badge-invalid { background: #dc3545; }
|
||||
.cert-logo { max-height: 70px; }
|
||||
.field-label { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: #6c757d; }
|
||||
.field-value { font-size: 1.05rem; font-weight: 500; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="verify-card px-3">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-4 mt-5">
|
||||
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="Al Rahma Sunday School" class="cert-logo mb-3">
|
||||
<h4 class="fw-bold mb-0">Certificate Verification</h4>
|
||||
<p class="text-muted small">Al Rahma Sunday School</p>
|
||||
</div>
|
||||
|
||||
<?php if ($record): ?>
|
||||
|
||||
<!-- Valid -->
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-4">
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-4">
|
||||
<span class="badge badge-valid text-white px-3 py-2 fs-6">
|
||||
<i class="bi bi-patch-check-fill me-1"></i>Verified
|
||||
</span>
|
||||
<code class="text-muted"><?= esc($record['certificate_number']) ?></code>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="field-label">Student Name</div>
|
||||
<div class="field-value"><?= esc($record['student_name']) ?></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="field-label">Grade / Class</div>
|
||||
<div class="field-value"><?= esc($record['grade'] ?? '—') ?></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="field-label">School Year</div>
|
||||
<div class="field-value"><?= esc($record['school_year'] ?? '—') ?></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="field-label">Certificate Date</div>
|
||||
<div class="field-value">
|
||||
<?= $record['cert_date'] ? esc(date('F j, Y', strtotime($record['cert_date']))) : '—' ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="field-label">Issued By</div>
|
||||
<div class="field-value">
|
||||
<?= esc(trim(($record['admin_firstname'] ?? '') . ' ' . ($record['admin_lastname'] ?? '')) ?: '—') ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="field-label">Issued On</div>
|
||||
<div class="field-value">
|
||||
<?= $record['issued_at'] ? esc(date('F j, Y \a\t g:i A', strtotime($record['issued_at']))) : '—' ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="card-footer text-muted small text-center py-2">
|
||||
This certificate was officially issued by Al Rahma Sunday School.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
|
||||
<!-- Not found -->
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-4 text-center">
|
||||
<span class="badge badge-invalid text-white px-3 py-2 fs-6 mb-3 d-inline-block">
|
||||
<i class="bi bi-x-circle-fill me-1"></i>Not Found
|
||||
</span>
|
||||
<p class="mb-0">No certificate matching this code was found in our records.<br>
|
||||
Please check the code and try again.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="text-center text-muted small mt-4">
|
||||
© <?= date('Y') ?> Al Rahma Sunday School
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -93,7 +93,7 @@
|
||||
<div class="col-lg-4 col-md-6 wow fadeInUp" data-wow-delay="0.1s">
|
||||
<div class="classes-item">
|
||||
<div class="bg-light rounded-circle w-75 mx-auto p-3">
|
||||
<img class="img-fluid rounded-circle" src=""assets/images/classes-1.jpg" alt="">
|
||||
<img class="img-fluid rounded-circle" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="bg-light rounded p-4 pt-5 mt-n5">
|
||||
<a class="d-block text-center h3 mt-3 mb-4" href="">Quran Learning</a>
|
||||
@@ -104,7 +104,7 @@
|
||||
<div class="col-lg-4 col-md-6 wow fadeInUp" data-wow-delay="0.3s">
|
||||
<div class="classes-item">
|
||||
<div class="bg-light rounded-circle w-75 mx-auto p-3">
|
||||
<img class="img-fluid rounded-circle" src=""assets/images/classes-2.jpg" alt="">
|
||||
<img class="img-fluid rounded-circle" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="bg-light rounded p-4 pt-5 mt-n5">
|
||||
<a class="d-block text-center h3 mt-3 mb-4" href="">Islamic Studies</a>
|
||||
@@ -114,7 +114,7 @@
|
||||
<div class="col-lg-4 col-md-6 wow fadeInUp" data-wow-delay="0.5s">
|
||||
<div class="classes-item">
|
||||
<div class="bg-light rounded-circle w-75 mx-auto p-3">
|
||||
<img class="img-fluid rounded-circle" src=""assets/images/classes-3.jpg" alt="">
|
||||
<img class="img-fluid rounded-circle" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="bg-light rounded p-4 pt-5 mt-n5">
|
||||
<a class="d-block text-center h3 mt-3 mb-4" href="">Arabic Learning</a>
|
||||
@@ -160,22 +160,22 @@
|
||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||
<div class="row g-2 pt-2">
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-1.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-2.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-3.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-4.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-5.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-6.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,22 +195,22 @@
|
||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||
<div class="row g-2 pt-2">
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-1.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-2.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-3.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-4.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-5.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-6.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
$eventName = trim((string) ($event['event_name'] ?? 'Upcoming Event'));
|
||||
$eventCategory = trim((string) ($event['event_category'] ?? ''));
|
||||
$eventDateRaw = trim((string) ($event['expiration_date'] ?? ''));
|
||||
$eventDate = $eventDateRaw !== '' ? date('F j, Y', strtotime($eventDateRaw)) : 'TBD';
|
||||
$amountRaw = $event['amount'] ?? null;
|
||||
$amount = ($amountRaw !== null && $amountRaw !== '') ? number_format((float) $amountRaw, 2) : null;
|
||||
?>
|
||||
<div style="font-family: sans-serif; line-height: 1.6;">
|
||||
<h1 style="font-size: 1.5rem; margin-bottom: 0.5rem;"><?= esc($eventName) ?></h1>
|
||||
<p style="margin:0.25rem 0;"><strong>Date:</strong> <?= esc($eventDate) ?></p>
|
||||
<?php if ($eventCategory !== ''): ?>
|
||||
<p style="margin:0.25rem 0;"><strong>Category:</strong> <?= esc($eventCategory) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($amount !== null): ?>
|
||||
<p style="margin:0.25rem 0;"><strong>Amount:</strong> $<?= esc($amount) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($event['description'])): ?>
|
||||
<div style="margin:0.75rem 0;"><?= $event['description'] ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($flyerUrl)): ?>
|
||||
<div style="margin:1rem 0;">
|
||||
<img
|
||||
src="<?= esc((string) $flyerUrl) ?>"
|
||||
alt="Event flyer for <?= esc($eventName) ?>"
|
||||
style="display:block; max-width:100%; height:auto; border:1px solid #ddd; border-radius:8px;"
|
||||
>
|
||||
</div>
|
||||
<p style="margin:0.75rem 0;">
|
||||
Flyer:
|
||||
<a href="<?= esc((string) $flyerUrl) ?>"><?= esc((string) $flyerUrl) ?></a>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<p style="margin:0.75rem 0;">For more details, please contact the school office or review the parent event page.</p>
|
||||
<p style="margin:0.25rem 0;">
|
||||
<a href="<?= esc((string) $eventUrl) ?>"><?= esc((string) $eventUrl) ?></a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -80,10 +80,10 @@
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= esc($e['approver_firstname'] . ' ' . $e['approver_lastname'] ?? '-') ?></td>
|
||||
<!--td><--?= esc($e['status_reason'] ?? '-') ?></td-->
|
||||
<!-- <td><?= esc($e['status_reason'] ?? '-') ?></td> -->
|
||||
<td>
|
||||
<?php if ($e['receipt_path']): ?>
|
||||
<!--a href="<--?= base_url('writable/' . $e['receipt_path']) ?>" target="_blank">View</a-->
|
||||
<!-- <a href="<?= base_url('writable/' . $e['receipt_path']) ?>" target="_blank">View</a> -->
|
||||
<a href="<?= site_url('receipts/' . $e['receipt_path']) ?>" target="_blank">View receipt</a>
|
||||
<?php else: ?>
|
||||
N/A
|
||||
|
||||
@@ -26,22 +26,22 @@
|
||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||
<div class="row g-2 pt-2">
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-1.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-2.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-3.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-4.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-5.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-6.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
<strong>Enrollment deadline:</strong>
|
||||
<?= $lastDayOfRegistration ? local_date($lastDayOfRegistration, 'm-d-Y') : 'N/A' ?>
|
||||
</div>
|
||||
<!--div><strong>Withdrawal:</strong> <--?= esc($withdrawalDeadline ?? 'N/A') ?></div-->
|
||||
<!-- <div><strong>Withdrawal:</strong> <?= esc($withdrawalDeadline ?? 'N/A') ?></div> -->
|
||||
<hr class="my-2">
|
||||
<?php if (!empty($enrollments)): ?>
|
||||
<ul class="mb-0 ps-3">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<!--meta name="csrf-token" content="<--?= csrf_hash() ?>"-->
|
||||
<!-- <meta name="csrf-token" content="<?= csrf_hash() ?>"> -->
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Al Rahma Sunday School</title>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Meta Information -->
|
||||
<!--meta name="csrf-token" content="<--?= csrf_hash() ?>"-->
|
||||
<!-- <meta name="csrf-token" content="<?= csrf_hash() ?>"> -->
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!-- Prevent back/forward cache issues on management pages -->
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($activeEvents as $event): ?>
|
||||
<?php $externalParticipants = $externalParticipantsByEvent[$event['id']] ?? []; ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
|
||||
@@ -42,6 +43,53 @@
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<h6 class="mb-1">Description</h6>
|
||||
<?php if (!empty($event['description'])): ?>
|
||||
<div class="mb-0"><?= $event['description'] ?></div>
|
||||
<?php else: ?>
|
||||
<p class="mb-0">No description</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($externalParticipants)): ?>
|
||||
<div class="mb-3">
|
||||
<h6 class="mb-2">External Participants</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Student Name</th>
|
||||
<th>Status</th>
|
||||
<th>Fee</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($externalParticipants as $participant): ?>
|
||||
<?php
|
||||
$isPaid = !empty($participant['event_paid']) || ((float) ($participant['charged'] ?? 0) <= 0);
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<?= esc($participant['name']) ?> <span class="text-muted">(external)</span>
|
||||
<?php if (!empty($participant['note'])): ?>
|
||||
<small class="text-muted d-block"><?= esc($participant['note']) ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-<?= $isPaid ? 'success' : 'danger' ?>">
|
||||
<?= $isPaid ? 'Paid' : 'Unpaid' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-nowrap">$<?= esc(number_format((float) ($participant['charged'] ?? 0), 2)) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Participation Table -->
|
||||
<form method="post" action="<?= site_url('parent/updateParticipation') ?>">
|
||||
<?= csrf_field() ?>
|
||||
@@ -51,11 +99,8 @@
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Student First Name</th>
|
||||
<th>Student Last Name</th>
|
||||
<th>Student Name</th>
|
||||
<th class="text-center">Participate</th>
|
||||
<th>Description</th>
|
||||
<th>Event Fees</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -65,8 +110,7 @@
|
||||
$current = $charges[$key]['participation'] ?? '';
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($student['firstname']) ?></td>
|
||||
<td><?= esc($student['lastname']) ?></td>
|
||||
<td><?= esc(trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''))) ?></td>
|
||||
<td class="text-center align-middle">
|
||||
<div class="d-inline-flex align-items-center gap-3">
|
||||
<div class="form-check">
|
||||
@@ -83,8 +127,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><?= esc($event['description'] ?: 'No description') ?></td>
|
||||
<td class="text-nowrap">$<?= esc(number_format((float) ($event['amount'] ?? 0), 2)) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
||||
@@ -143,12 +143,67 @@ $deadline = parseDbDateTime($dueDate, 'UTC', $displayTz); // format either DATE
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php if (!empty($invoiceEventCharges)): ?>
|
||||
<?php foreach ($invoices as $invoice): ?>
|
||||
<?php $charges = $invoiceEventCharges[(int)($invoice['id'] ?? 0)] ?? []; ?>
|
||||
<?php if (empty($charges)): ?>
|
||||
<?php continue; ?>
|
||||
<?php endif; ?>
|
||||
<div class="card mb-3 shadow-sm">
|
||||
<div class="card-header">
|
||||
<strong>Event charges for Invoice #<?= esc($invoice['invoice_number'] ?? '#'.$invoice['id']) ?></strong>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive mb-0">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Event</th>
|
||||
<th>Student</th>
|
||||
<th>Fee</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($charges as $charge): ?>
|
||||
<?php
|
||||
$studentName = trim(($charge['student_firstname'] ?? '') . ' ' . ($charge['student_lastname'] ?? ''));
|
||||
if ($studentName === '') {
|
||||
$externalName = trim(($charge['external_firstname'] ?? '') . ' ' . ($charge['external_lastname'] ?? ''));
|
||||
if ($externalName !== '') {
|
||||
$studentName = $externalName . ' (external)';
|
||||
}
|
||||
}
|
||||
$eventName = $charge['event_name'] ?? '—';
|
||||
$paid = !empty($charge['event_paid']) || ((float)($charge['charged'] ?? 0) <= 0);
|
||||
$badgeClass = $paid ? 'success' : 'danger';
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($eventName) ?></td>
|
||||
<td><?= esc($studentName ?: '—') ?></td>
|
||||
<td>$<?= number_format((float)($charge['event_amount'] ?? 0), 2) ?></td>
|
||||
<td>
|
||||
<span class="badge bg-<?= $badgeClass ?>">
|
||||
<?= $paid ? 'Paid' : 'Unpaid' ?>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<div class="alert alert-info mb-3 d-inline-block">
|
||||
No invoice found for the selected school year.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php $invoiceEventCharges = $invoiceEventCharges ?? []; ?>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
|
||||
@@ -11,7 +11,7 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
|
||||
<div class="alert alert-info mt-2">You've reached the maximum number of students (<?= $maxChilds ?>).</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($disableEmergencyBtn): ?>
|
||||
<!--div class="alert alert-info mt-2">You've reached the maximum number of emergency contacts (<--?= $maxEmergency ?>).</div-->
|
||||
<!-- <div class="alert alert-info mt-2">You've reached the maximum number of emergency contacts (<?= esc((string) $maxEmergency) ?>).</div> -->
|
||||
<?php endif; ?>
|
||||
<?php if (session()->getFlashdata('error')): ?>
|
||||
<div class="alert alert-danger">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -100,8 +100,16 @@
|
||||
$balanceCalc = (float)$totalAmount - (float)$discounted - (float)$paid - (float)$refunded;
|
||||
if ($balanceCalc < 0) $balanceCalc = 0.0;
|
||||
$balance = $balanceCalc;
|
||||
$status = ($balanceCalc === 0.0) ? 'Paid' : 'Unpaid';
|
||||
$statusClass = ($status === 'Paid') ? 'bg-success' : 'bg-danger';
|
||||
if ($balanceCalc <= 0.00001) {
|
||||
$status = 'Paid';
|
||||
$statusClass = 'bg-success';
|
||||
} elseif ($paid > 0.00001) {
|
||||
$status = 'Partially Paid';
|
||||
$statusClass = 'bg-warning text-dark';
|
||||
} else {
|
||||
$status = 'Unpaid';
|
||||
$statusClass = 'bg-danger';
|
||||
}
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($inv['invoice_number']) ?></td>
|
||||
@@ -376,8 +384,15 @@
|
||||
const total = Number(inv.total_amount || 0);
|
||||
let balance = total - paid - discounted - refunded;
|
||||
if (!Number.isFinite(balance) || balance < 0) balance = 0;
|
||||
const status = balance === 0 ? 'Paid' : 'Unpaid';
|
||||
const statusClass = balance === 0 ? 'bg-success' : 'bg-danger';
|
||||
let status = 'Unpaid';
|
||||
let statusClass = 'bg-danger';
|
||||
if (balance <= 0.00001) {
|
||||
status = 'Paid';
|
||||
statusClass = 'bg-success';
|
||||
} else if (paid > 0.00001) {
|
||||
status = 'Partially Paid';
|
||||
statusClass = 'bg-warning text-dark';
|
||||
}
|
||||
const tr = document.createElement('tr');
|
||||
const parentHtml = (inv.parent_id && Number(inv.parent_id) > 0) ?
|
||||
`<a href="<?= site_url('family') ?>?guardian_id=${encodeURIComponent(inv.parent_id)}" class="text-decoration-none" data-family-guardian-id="${inv.parent_id}">${inv.parent_name||''}</a>` :
|
||||
|
||||
@@ -22,16 +22,18 @@
|
||||
<input type="tel" name="phone" class="form-control" value="<?= esc($user['phone'] ?? '') ?>" placeholder="Enter phone number">
|
||||
</div>
|
||||
|
||||
<!--div class="mb-3">
|
||||
<!--
|
||||
<div class="mb-3">
|
||||
<label>Role</label>
|
||||
<select name="role_id" class="form-control" required>
|
||||
<--?php foreach ($roles as $role): ?>
|
||||
<option value="<--?= $role['id'] ?>" <--?= $roleId == $role['id'] ? 'selected' : '' ?>>
|
||||
<--?= esc($role['name']) ?>
|
||||
<?php foreach ($roles as $role): ?>
|
||||
<option value="<?= $role['id'] ?>" <?= $roleId == $role['id'] ? 'selected' : '' ?>>
|
||||
<?= esc($role['name']) ?>
|
||||
</option>
|
||||
<--?php endforeach ?>
|
||||
<?php endforeach ?>
|
||||
</select>
|
||||
</div-->
|
||||
</div>
|
||||
-->
|
||||
<button type="submit" class="btn btn-primary">Update</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!--a href="<--?= site_url('staff/create') ?>" class="btn btn-primary mb-3">Add Staff</a-->
|
||||
<!-- <a href="<?= site_url('staff/create') ?>" class="btn btn-primary mb-3">Add Staff</a> -->
|
||||
|
||||
<div class="table-responsive">
|
||||
<table id="myTable" class="display table table-striped table-bordered table-hover w-100" style="width:100%">
|
||||
|
||||
@@ -89,12 +89,14 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
|
||||
};
|
||||
?>
|
||||
|
||||
<h2 class="h5 mb-3">Your submissions</h2>
|
||||
<h2 class="h5 mb-1">Visible submissions</h2>
|
||||
<p class="text-muted small">Includes your uploads and submitted exam drafts from teachers assigned to the same class-section.</p>
|
||||
<?php if (!empty($drafts)): ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped align-middle teacher-drafts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Teacher</th>
|
||||
<th>Class</th>
|
||||
<th>Title / type</th>
|
||||
<th>Ver.</th>
|
||||
@@ -111,6 +113,7 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
|
||||
$badgeHtml = $renderBadge($st, $statusBadges);
|
||||
?>
|
||||
<tr data-draft-id="<?= (int) ($d['id'] ?? 0) ?>">
|
||||
<td><?= esc($d['teacher_display_name'] ?? 'Teacher') ?></td>
|
||||
<td><?= esc($d['class_section_name'] ?? '') ?></td>
|
||||
<td class="title-cell"><?= esc($d['draft_title'] ?? $d['exam_type'] ?? '') ?></td>
|
||||
<td><?= esc((string) ($d['version'] ?? '')) ?></td>
|
||||
@@ -168,6 +171,7 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
|
||||
<table class="table table-sm table-striped align-middle teacher-drafts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Teacher</th>
|
||||
<th>Class</th>
|
||||
<th>Title</th>
|
||||
<th>Ver.</th>
|
||||
@@ -183,6 +187,7 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
|
||||
<?php foreach ($legacyExams as $d): ?>
|
||||
<?php $st = strtolower((string) ($d['status'] ?? '')); ?>
|
||||
<tr>
|
||||
<td><?= esc($d['teacher_display_name'] ?? 'Teacher') ?></td>
|
||||
<td><?= esc($d['class_section_name'] ?? '') ?></td>
|
||||
<td class="title-cell"><?= esc($d['draft_title'] ?? $d['exam_type'] ?? '') ?></td>
|
||||
<td><?= esc((string) ($d['version'] ?? '')) ?></td>
|
||||
|
||||
@@ -194,22 +194,22 @@
|
||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||
<div class="row g-2 pt-2">
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-1.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-2.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-3.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-4.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-5.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-6.jpg') ?>" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,22 +128,22 @@
|
||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||
<div class="row g-2 pt-2">
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-1.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-2.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-3.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-4.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-5.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-6.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -180,22 +180,22 @@
|
||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||
<div class="row g-2 pt-2">
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-1.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-2.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-3.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-4.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-5.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-6.jpg" alt="">
|
||||
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
<?php
|
||||
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
|
||||
if (!function_exists('base_url')) {
|
||||
throw new PageNotFoundException('base_url function not found');
|
||||
}
|
||||
|
||||
$session = session();
|
||||
|
||||
if ($session->get('isLoggedIn')) {
|
||||
header('Location: ' . base_url('/dashboard'));
|
||||
exit();
|
||||
}
|
||||
?>
|
||||
|
||||
<?= $this->extend('layout/register_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
<div class="registration-form container mt-5 mb-5">
|
||||
|
||||
<form id="loginForm" method="post" action="<?= base_url('/user/login') ?>">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="redirect_to" value="<?= esc(old('redirect_to', $redirect_to ?? '')) ?>">
|
||||
<div class="text-center mb-4">
|
||||
<a href="<?= base_url('/') ?>">
|
||||
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt=""
|
||||
@@ -33,7 +18,8 @@ if ($session->get('isLoggedIn')) {
|
||||
<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>
|
||||
|
||||
@@ -45,7 +31,8 @@ if ($session->get('isLoggedIn')) {
|
||||
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)"
|
||||
@@ -60,9 +47,9 @@ if ($session->get('isLoggedIn')) {
|
||||
<button type="submit" class="btn btn-success item">Login</button>
|
||||
</div>
|
||||
<!-- Flash Error Message -->
|
||||
<?php if ($session->getFlashdata('error')): ?>
|
||||
<?php if (session()->getFlashdata('error')): ?>
|
||||
<div class="alert alert-danger mt-3 text-center" role="alert">
|
||||
<?= esc($session->getFlashdata('error')) ?>
|
||||
<?= esc(session()->getFlashdata('error')) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<!-- Forgot Password -->
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
<div id="collapse<?= $index ?>" class="collapse <?= $index === 0 ? 'show' : '' ?>" aria-labelledby="heading<?= $index ?>" data-bs-parent="#classAccordion">
|
||||
<div class="card-body">
|
||||
|
||||
<!--h5>Semester: <--?= esc($classSection['semester'] ?? '') ?></h5-->
|
||||
<!--h5>School Year: <--?= esc($classSection['school_year'] ?? '') ?></h5-->
|
||||
<!--p><--?= esc($classSection['description'] ?? '') ?></p-->
|
||||
<!-- <h5>Semester: <?= esc($classSection['semester'] ?? '') ?></h5> -->
|
||||
<!-- <h5>School Year: <?= esc($classSection['school_year'] ?? '') ?></h5> -->
|
||||
<!-- <p><?= esc($classSection['description'] ?? '') ?></p> -->
|
||||
|
||||
<?php if (!empty($classSection['parents'])): ?>
|
||||
<div class="table-responsive">
|
||||
@@ -92,13 +92,15 @@
|
||||
<option value="0" <?= ((string)$pm === '0') ? 'selected' : '' ?>>No</option>
|
||||
</select>
|
||||
</td>
|
||||
<!--td>
|
||||
<--?php if (!empty($p['primary_email'])): ?>
|
||||
<a href="mailto:<--?= esc($p['primary_email']) ?>"><--?= esc($p['primary_email']) ?></a>
|
||||
<--?php else: ?>
|
||||
<!--
|
||||
<td>
|
||||
<?php if (!empty($p['primary_email'])): ?>
|
||||
<a href="mailto:<?= esc($p['primary_email']) ?>"><?= esc($p['primary_email']) ?></a>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">—</span>
|
||||
<--?php endif; ?>
|
||||
</td-->
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
-->
|
||||
<td><?= esc($p['second_name']) ?></td>
|
||||
<td>
|
||||
<?php if (!empty($p['second_phone'])): ?>
|
||||
@@ -109,13 +111,15 @@
|
||||
<span class="text-muted">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<!--td>
|
||||
<--?php if (!empty($p['second_email'])): ?>
|
||||
<--a href="mailto:<--?= esc($p['second_email']) ?>"><--?= esc($p['second_email']) ?></a>
|
||||
<--?php else: ?>
|
||||
<--span class="text-muted">—</span>
|
||||
<--?php endif; ?>
|
||||
</td-->
|
||||
<!--
|
||||
<td>
|
||||
<?php if (!empty($p['second_email'])): ?>
|
||||
<a href="mailto:<?= esc($p['second_email']) ?>"><?= esc($p['second_email']) ?></a>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
-->
|
||||
|
||||
<td class="text-center">
|
||||
<?php if (!empty($p['second_id'])): ?>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"version":2,"defects":{"Tests\\App\\Controllers\\AuthControllerTest::testLoginReturnsValidResponse":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testDashboardSuccess":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testAbsenceInfoWithValidUser":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testAbsenceInfoUnauthorized":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testSubmitAbsenceRejectsMissingReason":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testSubmitAbsenceValidDateAndReason":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testSearchRequiresQueryParam":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testSearchReturnsStructuredResults":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testEnrollmentWithdrawalPagination":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testEnrollmentWithdrawalDataBuildsStudentList":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testUpdateEnrollmentStatusesRejectsEmptyPayload":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testUpdateEnrollmentStatusesWithValidStatus":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testStudentProfilesReturnsList":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testParentProfilesWithParentRole":8,"Tests\\App\\Controllers\\Api\\EmergencyContactControllerTest::testIndexFiltersByParent":8,"Tests\\App\\Controllers\\Api\\EmergencyContactControllerTest::testCreateValidContact":8,"Tests\\App\\Controllers\\Api\\EmergencyContactControllerTest::testCreateInvalidPayload":8,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testGetGuardiansReturnsDataWhenFamilyExists":8,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testGetByStudentWrapsStudentsAndGuardians":8,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testIndexUsesPaginatedResult":8,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testShowReturnsFamily":8,"Tests\\App\\Controllers\\Api\\ParentControllerTest::testShowReturnsParentById":8,"Tests\\App\\Controllers\\Api\\ParentControllerTest::testShowMissingParentReturnsError":8,"Tests\\App\\Controllers\\Api\\ParentControllerTest::testGetStudentsReturnsStudentList":8,"Tests\\App\\Controllers\\Api\\ParentControllerTest::testGetStudentsMissingParent":8,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testRolePermissionsReturnsDataForRole":8,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testUsersListReturnsSummaries":8,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testAssignRoleSuccess":8,"Tests\\App\\Controllers\\Api\\SupplierControllerTest::testShowReturnsNotFound":8,"Tests\\App\\Controllers\\Api\\SupplierControllerTest::testCreateValidSupplier":8,"Tests\\App\\Controllers\\Api\\SupplierControllerTest::testUpdateSupplierPersistsFields":8,"Tests\\App\\Controllers\\Api\\SupplierControllerTest::testDeleteSupplier":8},"times":{"Tests\\App\\Controllers\\AuthControllerTest::testLoginReturnsValidResponse":0.032,"Tests\\App\\Controllers\\Api\\EmergencyContactControllerTest::testIndexFiltersByParent":0.002,"Tests\\App\\Controllers\\Api\\EmergencyContactControllerTest::testShowReturnsNotFound":0.004,"Tests\\App\\Controllers\\Api\\EmergencyContactControllerTest::testCreateValidContact":0.002,"Tests\\App\\Controllers\\Api\\EmergencyContactControllerTest::testCreateInvalidPayload":0.001,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testGetGuardiansReturnsDataWhenFamilyExists":0.002,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testGetGuardiansReturnsErrorWhenFamilyMissing":0.001,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testGetByStudentWrapsStudentsAndGuardians":0.002,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testIndexUsesPaginatedResult":0.002,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testShowReturnsFamily":0.002,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testRolesListReturnsData":0.002,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testPermissionsListReturnsData":0.001,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testRolePermissionsReturnsDataForRole":0.002,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testUsersListReturnsSummaries":0.002,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testAssignRoleSuccess":0.003,"Tests\\App\\Controllers\\Api\\BroadcastEmailControllerTest::testSendValidationFails":0.005,"Tests\\App\\Controllers\\Api\\BroadcastEmailControllerTest::testSendStoresSentAndFailedRecipients":0.003,"Tests\\App\\Controllers\\Api\\BroadcastEmailControllerTest::testParentsReturnsParentList":0.001}}
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites>
|
||||
<testsuite name="Tests\App\Controllers\Api\BroadcastEmailControllerTest" file="/opt/lampp/htdocs/alrahma_school_sunday/tests/app/Controllers/Api/BroadcastEmailControllerTest.php" tests="3" assertions="15" errors="0" failures="0" skipped="0" time="0.023977">
|
||||
<testcase name="testSendValidationFails" file="/opt/lampp/htdocs/alrahma_school_sunday/tests/app/Controllers/Api/BroadcastEmailControllerTest.php" line="132" class="Tests\App\Controllers\Api\BroadcastEmailControllerTest" classname="Tests.App.Controllers.Api.BroadcastEmailControllerTest" assertions="3" time="0.016935"/>
|
||||
<testcase name="testSendStoresSentAndFailedRecipients" file="/opt/lampp/htdocs/alrahma_school_sunday/tests/app/Controllers/Api/BroadcastEmailControllerTest.php" line="144" class="Tests\App\Controllers\Api\BroadcastEmailControllerTest" classname="Tests.App.Controllers.Api.BroadcastEmailControllerTest" assertions="9" time="0.004336"/>
|
||||
<testcase name="testParentsReturnsParentList" file="/opt/lampp/htdocs/alrahma_school_sunday/tests/app/Controllers/Api/BroadcastEmailControllerTest.php" line="167" class="Tests\App\Controllers\Api\BroadcastEmailControllerTest" classname="Tests.App.Controllers.Api.BroadcastEmailControllerTest" assertions="3" time="0.002707"/>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
@@ -1,61 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Test Documentation</title>
|
||||
<style>
|
||||
body {
|
||||
text-rendering: optimizeLegibility;
|
||||
font-family: Source SansSerif Pro, Arial, sans-serif;
|
||||
font-variant-ligatures: common-ligatures;
|
||||
font-kerning: normal;
|
||||
margin-left: 2rem;
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
body > ul > li {
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: larger;
|
||||
text-decoration-line: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0 0 2rem;
|
||||
padding: 0 0 0 1rem;
|
||||
text-indent: -1rem;
|
||||
}
|
||||
|
||||
.success:before {
|
||||
color: #4e9a06;
|
||||
content: '✓';
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.defect {
|
||||
color: #a40000;
|
||||
}
|
||||
|
||||
.defect:before {
|
||||
color: #a40000;
|
||||
content: '✗';
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Broadcast Email Controller (Tests\App\Controllers\Api\BroadcastEmailController)</h2>
|
||||
<ul>
|
||||
<li class="success">Send validation fails</li>
|
||||
<li class="success">Send stores sent and failed recipients</li>
|
||||
<li class="success">Parents returns parent list</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +0,0 @@
|
||||
Broadcast Email Controller (Tests\App\Controllers\Api\BroadcastEmailController)
|
||||
[x] Send validation fails
|
||||
[x] Send stores sent and failed recipients
|
||||
[x] Parents returns parent list
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
define('LATEST_RELEASE', '^4.0');
|
||||
define('GITHUB_URL', 'https://github.com/codeigniter4/codeigniter4');
|
||||
|
||||
/*
|
||||
* --------------------------------------------------------------------
|
||||
* Stability Toggle
|
||||
* --------------------------------------------------------------------
|
||||
* Use this script to toggle the CodeIgniter dependency between the
|
||||
* latest stable release and the most recent development update.
|
||||
*
|
||||
* Usage: php builds [release|development]
|
||||
*/
|
||||
|
||||
// Determine the requested stability
|
||||
if (empty($argv[1]) || ! in_array($argv[1], ['release', 'development'], true)) {
|
||||
echo 'Usage: php builds [release|development]' . PHP_EOL;
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
$dev = $argv[1] === 'development';
|
||||
|
||||
$modified = [];
|
||||
|
||||
// Locate each file and update it for the requested stability
|
||||
|
||||
$file = __DIR__ . DIRECTORY_SEPARATOR . 'composer.json';
|
||||
|
||||
if (is_file($file)) {
|
||||
$contents = file_get_contents($file);
|
||||
|
||||
if ((string) $contents !== '') {
|
||||
$array = json_decode($contents, true);
|
||||
|
||||
if (is_array($array)) {
|
||||
if ($dev) {
|
||||
$array['minimum-stability'] = 'dev';
|
||||
$array['prefer-stable'] = true;
|
||||
$array['repositories'] ??= [];
|
||||
|
||||
$found = false;
|
||||
|
||||
foreach ($array['repositories'] as $repository) {
|
||||
if ($repository['url'] === GITHUB_URL) {
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $found) {
|
||||
$array['repositories'][] = [
|
||||
'type' => 'vcs',
|
||||
'url' => GITHUB_URL,
|
||||
];
|
||||
}
|
||||
|
||||
$array['require']['codeigniter4/codeigniter4'] = 'dev-develop';
|
||||
unset($array['require']['codeigniter4/framework']);
|
||||
} else {
|
||||
unset($array['minimum-stability']);
|
||||
|
||||
if (isset($array['repositories'])) {
|
||||
foreach ($array['repositories'] as $i => $repository) {
|
||||
if ($repository['url'] === GITHUB_URL) {
|
||||
unset($array['repositories'][$i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($array['repositories'])) {
|
||||
unset($array['repositories']);
|
||||
}
|
||||
}
|
||||
|
||||
$array['require']['codeigniter4/framework'] = LATEST_RELEASE;
|
||||
unset($array['require']['codeigniter4/codeigniter4']);
|
||||
}
|
||||
|
||||
file_put_contents($file, json_encode($array, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
|
||||
$modified[] = $file;
|
||||
} else {
|
||||
echo 'Warning: Unable to decode composer.json! Skipping...' . PHP_EOL;
|
||||
}
|
||||
} else {
|
||||
echo 'Warning: Unable to read composer.json! Skipping...' . PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
$files = [
|
||||
__DIR__ . DIRECTORY_SEPARATOR . 'app/Config/Paths.php',
|
||||
__DIR__ . DIRECTORY_SEPARATOR . 'phpunit.xml.dist',
|
||||
__DIR__ . DIRECTORY_SEPARATOR . 'phpunit.xml',
|
||||
];
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file)) {
|
||||
$contents = file_get_contents($file);
|
||||
|
||||
if ($dev) {
|
||||
$contents = str_replace('vendor/codeigniter4/framework', 'vendor/codeigniter4/codeigniter4', $contents);
|
||||
} else {
|
||||
$contents = str_replace('vendor/codeigniter4/codeigniter4', 'vendor/codeigniter4/framework', $contents);
|
||||
}
|
||||
|
||||
file_put_contents($file, $contents);
|
||||
|
||||
$modified[] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
if ($modified === []) {
|
||||
echo 'No files modified.' . PHP_EOL;
|
||||
} else {
|
||||
echo 'The following files were modified:' . PHP_EOL;
|
||||
|
||||
foreach ($modified as $file) {
|
||||
echo " * {$file}" . PHP_EOL;
|
||||
}
|
||||
|
||||
echo 'Run `composer update` to sync changes with your vendor folder.' . PHP_EOL;
|
||||
}
|
||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 74 KiB |