Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c294d7bed7 | |||
| 5ed65a867c | |||
| 22d6f960ea | |||
| cc0b83c1b9 | |||
| 31976752da | |||
| 0bf8d13777 | |||
| 4ae62e37a3 | |||
| d8fdbeb4c3 | |||
| 239d35f8ff | |||
| 3333e71c94 | |||
| af75475214 | |||
| a8ef665239 | |||
| 7c97a60795 | |||
| acca87c77b | |||
| 9aacbe8972 | |||
| 76866ffe1e | |||
| b01a4496e6 | |||
| 1f51be91e4 | |||
| 92016f90d0 | |||
| 45d7895182 | |||
| 596d368b1d | |||
| 35988e9ce1 | |||
| 5fc4d8be30 | |||
| dcfd1f9542 | |||
| b99f816261 | |||
| 931e30b6f1 | |||
| 189bb1a628 | |||
| f04934039e | |||
| c13dcb07d1 | |||
| dd1d492c30 | |||
| a448288878 | |||
| 821e9cdc81 | |||
| 4e2d12e524 | |||
| 7bc999643a | |||
| 89e95b7851 | |||
| 2543df3d33 | |||
| 112d073235 | |||
| 2611730ec6 | |||
| 05dad52e10 |
@@ -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
|
|
||||||
+16
-1
@@ -23,17 +23,32 @@
|
|||||||
|
|
||||||
/public/build/
|
/public/build/
|
||||||
/public/hot/
|
/public/hot/
|
||||||
|
/public/uploads/**
|
||||||
|
!/public/uploads/**/index.html
|
||||||
|
!/public/uploads/.gitkeep
|
||||||
|
|
||||||
|
/uploads/**
|
||||||
|
!/uploads/**/index.html
|
||||||
|
!/uploads/.gitkeep
|
||||||
|
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
docker-compose.local.yml
|
docker-compose.local.yml
|
||||||
|
|
||||||
*.log
|
*.log
|
||||||
|
.swp
|
||||||
|
.swo
|
||||||
|
*~
|
||||||
|
.tmp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.codex/
|
||||||
|
|
||||||
/coverage/
|
/coverage/
|
||||||
/build/
|
/build/
|
||||||
|
/builds
|
||||||
/phpunit.xml.cache
|
/phpunit.xml.cache
|
||||||
/.phpunit.result.cache
|
/.phpunit.result.cache
|
||||||
|
/writable/reports/*
|
||||||
|
/writable/test.txt
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use App\Controllers\View\EmailController;
|
||||||
|
use App\Models\ClassSectionModel;
|
||||||
|
use App\Models\ConfigurationModel;
|
||||||
|
use App\Models\TeacherSubmissionNotificationHistoryModel;
|
||||||
|
use App\Models\UserModel;
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
use Config\Database;
|
||||||
|
|
||||||
|
class SendExamDraftDeadlineReminders extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Exam Drafts';
|
||||||
|
protected $name = 'exam-drafts:deadline-reminders';
|
||||||
|
protected $description = 'Sends exam draft deadline reminders to teachers who have not submitted drafts.';
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
helper('date');
|
||||||
|
|
||||||
|
$configModel = new ConfigurationModel();
|
||||||
|
$schoolYear = (string) ($configModel->getConfig('school_year') ?? '');
|
||||||
|
$semester = (string) ($configModel->getConfig('semester') ?? '');
|
||||||
|
$deadlineValue = trim((string) ($configModel->getConfig('exam_draft_deadline') ?? ''));
|
||||||
|
if ($deadlineValue === '') {
|
||||||
|
CLI::write('exam_draft_deadline is not configured.', 'yellow');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tz = new \DateTimeZone(config('App')->appTimezone ?? 'UTC');
|
||||||
|
try {
|
||||||
|
$deadline = new \DateTimeImmutable($deadlineValue, $tz);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
CLI::write('Invalid exam_draft_deadline value.', 'red');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$deadline = $deadline->setTime(0, 0, 0);
|
||||||
|
$today = new \DateTimeImmutable('now', $tz);
|
||||||
|
$today = $today->setTime(0, 0, 0);
|
||||||
|
|
||||||
|
if ($today > $deadline) {
|
||||||
|
CLI::write('Deadline has passed. No reminders sent.', 'yellow');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$daysToDeadline = (int) $today->diff($deadline)->format('%r%a');
|
||||||
|
if (!$this->shouldSendOnDay($daysToDeadline)) {
|
||||||
|
CLI::write('No reminder scheduled for today.', 'yellow');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::connect();
|
||||||
|
$teacherClassRows = $db->table('teacher_class')
|
||||||
|
->select('teacher_id, class_section_id, school_year, semester')
|
||||||
|
->when($schoolYear !== '', static function ($builder) use ($schoolYear) {
|
||||||
|
return $builder->where('school_year', $schoolYear);
|
||||||
|
})
|
||||||
|
->when($semester !== '', static function ($builder) use ($semester) {
|
||||||
|
return $builder->where('semester', $semester);
|
||||||
|
})
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
if (empty($teacherClassRows)) {
|
||||||
|
CLI::write('No teacher assignments found.', 'yellow');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$draftTable = 'exam_drafts';
|
||||||
|
$fields = $db->getFieldNames($draftTable);
|
||||||
|
$teacherColumn = in_array('teacher_id', $fields, true) ? 'teacher_id' : 'author_id';
|
||||||
|
$hasStatusColumn = in_array('status', $fields, true);
|
||||||
|
$hasIsLegacyColumn = in_array('is_legacy', $fields, true);
|
||||||
|
|
||||||
|
$draftsQuery = $db->table($draftTable)
|
||||||
|
->select("{$teacherColumn} AS teacher_id, class_section_id")
|
||||||
|
->when($schoolYear !== '', static function ($builder) use ($schoolYear) {
|
||||||
|
return $builder->where('school_year', $schoolYear);
|
||||||
|
})
|
||||||
|
->when($semester !== '', static function ($builder) use ($semester) {
|
||||||
|
return $builder->where('semester', $semester);
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($hasStatusColumn) {
|
||||||
|
$draftsQuery = $draftsQuery->where('status !=', 'legacy');
|
||||||
|
}
|
||||||
|
if ($hasIsLegacyColumn) {
|
||||||
|
$draftsQuery = $draftsQuery->where('is_legacy', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$draftRows = $draftsQuery->get()->getResultArray();
|
||||||
|
$submittedMap = [];
|
||||||
|
foreach ($draftRows as $row) {
|
||||||
|
$key = (int) ($row['teacher_id'] ?? 0) . '|' . (int) ($row['class_section_id'] ?? 0);
|
||||||
|
$submittedMap[$key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$missingByTeacher = [];
|
||||||
|
foreach ($teacherClassRows as $row) {
|
||||||
|
$teacherId = (int) ($row['teacher_id'] ?? 0);
|
||||||
|
$classSectionId = (int) ($row['class_section_id'] ?? 0);
|
||||||
|
if ($teacherId <= 0 || $classSectionId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = $teacherId . '|' . $classSectionId;
|
||||||
|
if (!isset($submittedMap[$key])) {
|
||||||
|
$missingByTeacher[$teacherId][] = $classSectionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($missingByTeacher)) {
|
||||||
|
CLI::write('All teachers have submitted drafts.', 'green');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userModel = new UserModel();
|
||||||
|
$classSectionModel = new ClassSectionModel();
|
||||||
|
$historyModel = new TeacherSubmissionNotificationHistoryModel();
|
||||||
|
$mailer = new EmailController();
|
||||||
|
|
||||||
|
$classSections = $classSectionModel
|
||||||
|
->select('class_section_id, class_section_name')
|
||||||
|
->findAll();
|
||||||
|
$classLookup = [];
|
||||||
|
foreach ($classSections as $section) {
|
||||||
|
$classLookup[(int) ($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$todayStamp = $today->format('Y-m-d');
|
||||||
|
foreach ($missingByTeacher as $teacherId => $sectionIds) {
|
||||||
|
$teacher = $userModel->find($teacherId);
|
||||||
|
$email = $teacher['email'] ?? '';
|
||||||
|
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$alreadySent = $historyModel
|
||||||
|
->where('teacher_id', $teacherId)
|
||||||
|
->where('notification_category', 'exam_draft_deadline')
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->where('semester', $semester)
|
||||||
|
->like('sent_at', $todayStamp)
|
||||||
|
->countAllResults() > 0;
|
||||||
|
if ($alreadySent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$classNames = array_map(static function ($id) use ($classLookup) {
|
||||||
|
return $classLookup[(int) $id] ?? "Class {$id}";
|
||||||
|
}, $sectionIds);
|
||||||
|
$classList = implode(', ', $classNames);
|
||||||
|
$teacherName = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? ''));
|
||||||
|
if ($teacherName === '') {
|
||||||
|
$teacherName = 'Teacher';
|
||||||
|
}
|
||||||
|
|
||||||
|
$subject = 'Reminder: Exam draft submission deadline';
|
||||||
|
$body = '<p>Dear ' . esc($teacherName) . ',</p>'
|
||||||
|
. '<p>This is a reminder to submit your exam draft(s) for: ' . esc($classList) . '.</p>'
|
||||||
|
. '<p>Deadline: <strong>' . esc($deadline->format('Y-m-d')) . '</strong></p>'
|
||||||
|
. '<p>Please submit your draft at <a href="' . esc(base_url('teacher/exam-drafts')) . '">Teacher Exam Drafts</a>.</p>'
|
||||||
|
. '<p>Thank you.</p>';
|
||||||
|
|
||||||
|
$status = 'failed';
|
||||||
|
if ($mailer->sendEmail($email, $subject, $body, 'notifications')) {
|
||||||
|
$status = 'sent';
|
||||||
|
CLI::write("Sent reminder to {$email}", 'green');
|
||||||
|
}
|
||||||
|
|
||||||
|
$historyModel->insert([
|
||||||
|
'teacher_id' => $teacherId,
|
||||||
|
'class_section_id' => 0,
|
||||||
|
'admin_id' => null,
|
||||||
|
'notification_category' => 'exam_draft_deadline',
|
||||||
|
'message' => strip_tags($body),
|
||||||
|
'status' => $status,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'semester' => $semester,
|
||||||
|
'sent_at' => utc_now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldSendOnDay(int $daysToDeadline): bool
|
||||||
|
{
|
||||||
|
if ($daysToDeadline < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($daysToDeadline <= 4) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return in_array($daysToDeadline, [28, 21, 14, 7], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,13 +36,13 @@ class SyncPaypalPayments extends BaseCommand
|
|||||||
$this->invoiceModel = new InvoiceModel();
|
$this->invoiceModel = new InvoiceModel();
|
||||||
$this->studentModel = new StudentModel();
|
$this->studentModel = new StudentModel();
|
||||||
$this->enrollmentModel = new EnrollmentModel();
|
$this->enrollmentModel = new EnrollmentModel();
|
||||||
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function run(array $params)
|
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');
|
$dryRun = CLI::getOption('dry-run');
|
||||||
$reportOnly = CLI::getOption('report-only');
|
$reportOnly = CLI::getOption('report-only');
|
||||||
$mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE');
|
$mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE');
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ class App extends BaseConfig
|
|||||||
| DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!!
|
| DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!!
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
public string $permittedURIChars = 'a-z 0-9~%.:_\-';
|
public string $permittedURIChars = 'a-z 0-9~%.:_\-,';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* --------------------------------------------------------------------------
|
* --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Listeners\SchoolEventListener;
|
|||||||
use App\Listeners\AttendanceConsequenceListener;
|
use App\Listeners\AttendanceConsequenceListener;
|
||||||
use App\Listeners\WhatsappInviteListener;
|
use App\Listeners\WhatsappInviteListener;
|
||||||
use App\Listeners\BelowSixtyEmailListener;
|
use App\Listeners\BelowSixtyEmailListener;
|
||||||
|
use App\Listeners\DecisionEmailListener;
|
||||||
|
|
||||||
// Create an instance so we can use $this->emailService like your other handlers
|
// Create an instance so we can use $this->emailService like your other handlers
|
||||||
$waListener = new WhatsappInviteListener(service('emailService'));
|
$waListener = new WhatsappInviteListener(service('emailService'));
|
||||||
@@ -118,6 +119,7 @@ Events::on('attendance.follow_up', [AttendanceConsequenceListener::class, 'fol
|
|||||||
Events::on('attendance.final_warning', [AttendanceConsequenceListener::class, 'finalWarning']);
|
Events::on('attendance.final_warning', [AttendanceConsequenceListener::class, 'finalWarning']);
|
||||||
Events::on('attendance.dismissal', [AttendanceConsequenceListener::class, 'dismissal']);
|
Events::on('attendance.dismissal', [AttendanceConsequenceListener::class, 'dismissal']);
|
||||||
Events::on('below60.email', [BelowSixtyEmailListener::class, 'handle']);
|
Events::on('below60.email', [BelowSixtyEmailListener::class, 'handle']);
|
||||||
|
Events::on('below60.decision_email', [DecisionEmailListener::class, 'handle']);
|
||||||
|
|
||||||
//Whatsapp Event listener
|
//Whatsapp Event listener
|
||||||
Events::on('whatsapp_invites.send', [$waListener, 'handle']);
|
Events::on('whatsapp_invites.send', [$waListener, 'handle']);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class Filters extends BaseConfig
|
|||||||
'honeypot' => Honeypot::class,
|
'honeypot' => Honeypot::class,
|
||||||
'invalidchars' => InvalidChars::class,
|
'invalidchars' => InvalidChars::class,
|
||||||
'secureheaders' => SecureHeaders::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
|
'auth' => \App\Filters\AuthFilter::class, // Define the alias for your auth filter
|
||||||
'apiAuth' => \App\Filters\ApiAuthFilter::class, // JWT-based API authentication
|
'apiAuth' => \App\Filters\ApiAuthFilter::class, // JWT-based API authentication
|
||||||
'cleanupScheduler' => \App\Filters\CleanupScheduler::class,
|
'cleanupScheduler' => \App\Filters\CleanupScheduler::class,
|
||||||
@@ -42,6 +44,8 @@ class Filters extends BaseConfig
|
|||||||
public array $globals = [
|
public array $globals = [
|
||||||
'before' => [
|
'before' => [
|
||||||
'timezone',
|
'timezone',
|
||||||
|
'sanitizeinput',
|
||||||
|
'invalidchars',
|
||||||
'csrf' => ['except' => [
|
'csrf' => ['except' => [
|
||||||
// Webhooks / integrations
|
// Webhooks / integrations
|
||||||
'api/paypal-webhook',
|
'api/paypal-webhook',
|
||||||
@@ -80,6 +84,7 @@ class Filters extends BaseConfig
|
|||||||
]],
|
]],
|
||||||
],
|
],
|
||||||
'after' => [
|
'after' => [
|
||||||
|
'secureheaders',
|
||||||
'toolbar',
|
'toolbar',
|
||||||
'cleanupScheduler' => ['except' => ['cleanup/*']],
|
'cleanupScheduler' => ['except' => ['cleanup/*']],
|
||||||
],
|
],
|
||||||
@@ -112,7 +117,7 @@ class Filters extends BaseConfig
|
|||||||
* @var array<string, array<string, list<string>>>
|
* @var array<string, array<string, list<string>>>
|
||||||
*/
|
*/
|
||||||
public array $filters = [
|
public array $filters = [
|
||||||
|
'apiratelimit' => ['before' => ['api/*', 'index.php/api/*']],
|
||||||
];
|
];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-3
@@ -174,6 +174,18 @@ $routes->group('administrator/subject-curriculum', ['filter' => 'auth:update_cur
|
|||||||
$routes->post('delete/(:num)', 'View\SubjectCurriculumController::delete/$1');
|
$routes->post('delete/(:num)', 'View\SubjectCurriculumController::delete/$1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$routes->get('administrator/trophy', 'View\TrophyController::index', ['filter' => 'auth:admin']);
|
||||||
|
$routes->get('administrator/trophy/winners', 'View\TrophyController::winners', ['filter' => 'auth:admin']);
|
||||||
|
$routes->get('administrator/trophy/final', 'View\TrophyController::final', ['filter' => 'auth:admin']);
|
||||||
|
|
||||||
|
// Certificates
|
||||||
|
$routes->get('administrator/certificates', 'View\CertificateController::index', ['filter' => 'auth:admin']);
|
||||||
|
$routes->get('administrator/certificates/csrf-token', 'View\CertificateController::csrfToken', ['filter' => 'auth:admin']);
|
||||||
|
$routes->post('administrator/certificates/generate', 'View\CertificateController::generate', ['filter' => 'auth:admin']);
|
||||||
|
$routes->get('administrator/certificates/log', 'View\CertificateController::auditLog', ['filter' => 'auth:admin']);
|
||||||
|
$routes->get('administrator/certificates/reprint/(:any)', 'View\CertificateController::reprint/$1', ['filter' => 'auth:read']);
|
||||||
|
$routes->get('verify/(:segment)', 'View\CertificateController::verify/$1');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -363,6 +375,7 @@ $routes->get('/teacher/teacher_contactus', 'View\TeacherController::contactusTea
|
|||||||
|
|
||||||
$routes->get('/teacher/exam-drafts', 'View\ExamDraftController::teacherIndex', ['filter' => 'auth:teacher,teacher_assistant,teacher_dashboard,read']);
|
$routes->get('/teacher/exam-drafts', 'View\ExamDraftController::teacherIndex', ['filter' => 'auth:teacher,teacher_assistant,teacher_dashboard,read']);
|
||||||
$routes->post('/teacher/exam-drafts', 'View\ExamDraftController::teacherStore', ['filter' => 'auth:teacher,teacher_assistant,teacher_dashboard,read']);
|
$routes->post('/teacher/exam-drafts', 'View\ExamDraftController::teacherStore', ['filter' => 'auth:teacher,teacher_assistant,teacher_dashboard,read']);
|
||||||
|
$routes->get('/teacher/exam-drafts/status', 'View\ExamDraftController::teacherStatusFeed', ['filter' => 'auth:teacher,teacher_assistant,teacher_dashboard,read']);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -392,8 +405,8 @@ $routes->get('print-requests/file/(:segment)', 'PrintRequests::serveFile/$1', ['
|
|||||||
$routes->get('print-requests/file/(:segment)/(:alpha)', 'PrintRequests::serveFile/$1/$2', ['filter' => 'auth:teacher,teacher_assistant,admin']);
|
$routes->get('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('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/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']);
|
$routes->get('exam-drafts/files/final/(:segment)', 'View\FilesController::examDraftFinal/$1', ['filter' => 'auth:teacher,teacher_assistant,admin,administrator,principal']);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -510,6 +523,14 @@ $routes->post('grading/below-60/email', 'View\GradingController::sendBelowSixtyE
|
|||||||
$routes->post('grading/below-60/status', 'View\GradingController::updateBelowSixtyStatus', ['filter' => 'auth:read']);
|
$routes->post('grading/below-60/status', 'View\GradingController::updateBelowSixtyStatus', ['filter' => 'auth:read']);
|
||||||
$routes->get('grading/below-60/schedule', 'View\GradingController::scheduleBelowSixty', ['filter' => 'auth:read']);
|
$routes->get('grading/below-60/schedule', 'View\GradingController::scheduleBelowSixty', ['filter' => 'auth:read']);
|
||||||
$routes->post('grading/below-60/schedule', 'View\GradingController::saveBelowSixtyMeeting', ['filter' => 'auth:read']);
|
$routes->post('grading/below-60/schedule', 'View\GradingController::saveBelowSixtyMeeting', ['filter' => 'auth:read']);
|
||||||
|
$routes->get('grading/decisions', 'View\GradingController::allDecisions', ['filter' => 'auth:read']);
|
||||||
|
$routes->post('grading/decisions/generate', 'View\GradingController::generateAllDecisions', ['filter' => 'auth:read']);
|
||||||
|
$routes->get('grading/below-60/decisions', 'View\GradingController::belowSixtyDecisions', ['filter' => 'auth:read']);
|
||||||
|
$routes->post('grading/below-60/decisions/save', 'View\GradingController::saveBelowSixtyDecision', ['filter' => 'auth:read']);
|
||||||
|
$routes->get('grading/below-60/decisions/student-details', 'View\GradingController::studentDecisionDetails', ['filter' => 'auth:read']);
|
||||||
|
$routes->get('grading/below-60/decisions/email/preview', 'View\GradingController::previewDecisionEmail', ['filter' => 'auth:read']);
|
||||||
|
$routes->get('grading/below-60/decisions/email/edit', 'View\GradingController::editDecisionEmail', ['filter' => 'auth:read']);
|
||||||
|
$routes->post('grading/below-60/decisions/email', 'View\GradingController::sendDecisionEmail', ['filter' => 'auth:read']);
|
||||||
|
|
||||||
|
|
||||||
// Final part
|
// Final part
|
||||||
@@ -534,9 +555,13 @@ $routes->post('payment/event_charges', 'View\EventController::eventUpdate');
|
|||||||
|
|
||||||
// Parent event participation
|
// Parent event participation
|
||||||
$routes->get('administrator/event-charges', 'View\EventController::eventShow');
|
$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('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
|
// parent event participation page
|
||||||
$routes->post('parent/updateParticipation', 'View\ParentController::updateParticipation'); // handle parent participation updates
|
$routes->post('parent/updateParticipation', 'View\ParentController::updateParticipation'); // handle parent participation updates
|
||||||
|
|
||||||
@@ -741,6 +766,9 @@ $routes->post('/administrator/teacher-submissions/notify', 'View\AdministratorCo
|
|||||||
$routes->get('/administrator/exam-drafts', 'View\ExamDraftController::adminIndex', ['filter' => 'auth:admin']);
|
$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/review', 'View\ExamDraftController::adminReview', ['filter' => 'auth:admin']);
|
||||||
$routes->post('/administrator/exam-drafts/upload-legacy', 'View\ExamDraftController::adminUploadLegacy', ['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.
|
* 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) {
|
if ($getShared) {
|
||||||
return static::getSharedInstance('email', $config);
|
return static::getSharedInstance('email', $config);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace App\Controllers;
|
|||||||
use App\Models\LoginActivityModel;
|
use App\Models\LoginActivityModel;
|
||||||
use App\Models\UserModel;
|
use App\Models\UserModel;
|
||||||
use App\Models\UserRoleModel;
|
use App\Models\UserRoleModel;
|
||||||
use CodeIgniter\Controller;
|
|
||||||
use CodeIgniter\Events\Events;
|
use CodeIgniter\Events\Events;
|
||||||
use App\Models\IpAttemptModel;
|
use App\Models\IpAttemptModel;
|
||||||
use App\Models\PasswordResetModel;
|
use App\Models\PasswordResetModel;
|
||||||
@@ -19,7 +18,7 @@ require_once APPPATH . 'Helpers/pbkdf2_helper.php';
|
|||||||
require_once APPPATH . 'Helpers/jwt_helper.php';
|
require_once APPPATH . 'Helpers/jwt_helper.php';
|
||||||
|
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends BaseController
|
||||||
{
|
{
|
||||||
protected $configModel;
|
protected $configModel;
|
||||||
protected $userModel;
|
protected $userModel;
|
||||||
@@ -56,17 +55,50 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
public function loginMask()
|
public function loginMask()
|
||||||
{
|
{
|
||||||
// Serve the login view directly here
|
$redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getGet('redirect_to') ?? ''));
|
||||||
return view('user/login'); // Adjust the view path as needed
|
|
||||||
|
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()
|
public function login()
|
||||||
{
|
{
|
||||||
log_message('info', 'Processing login form submission.');
|
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
|
// Step 1: Get email, password, and IP from the request
|
||||||
$email = $this->request->getPost('email');
|
$email = strtolower((string) $requestData['email']);
|
||||||
$password = $this->request->getPost('password');
|
$password = (string) $requestData['password'];
|
||||||
$ip = $this->request->getIPAddress();
|
$ip = $this->request->getIPAddress();
|
||||||
|
|
||||||
log_message('info', 'Login attempt from IP: ' . $ip . ' for email: ' . $email);
|
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());
|
$this->logLoginAttempt($user['id'], $user['email'], $ip, $this->request->getUserAgent());
|
||||||
|
|
||||||
// ✅ Step 7: Call loginUser() to set session and redirect
|
// ✅ Step 7: Call loginUser() to set session and redirect
|
||||||
return $this->loginUser($user);
|
return $this->loginUser($user, $redirectTo);
|
||||||
} else {
|
} else {
|
||||||
// Step 8: Password mismatch — log failed attempt
|
// Step 8: Password mismatch — log failed attempt
|
||||||
$this->handleFailedLogin($user['id'], $user['email'], $ip);
|
$this->handleFailedLogin($user['id'], $user['email'], $ip);
|
||||||
@@ -120,26 +152,29 @@ class AuthController extends Controller
|
|||||||
// JSON API: POST /api/login
|
// JSON API: POST /api/login
|
||||||
public function apiLogin()
|
public function apiLogin()
|
||||||
{
|
{
|
||||||
$requestData = $this->request->getJSON(true);
|
$requestData = sanitize_request_value($this->request->getJSON(true) ?: [
|
||||||
if (!$requestData) {
|
'email' => $this->request->getPost('email'),
|
||||||
// fallback to form vars
|
'password' => $this->request->getPost('password'),
|
||||||
$requestData = [
|
]);
|
||||||
'email' => $this->request->getPost('email'),
|
|
||||||
'password' => $this->request->getPost('password'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$email = $requestData['email'] ?? '';
|
$validation = \Config\Services::validation();
|
||||||
$password = $requestData['password'] ?? '';
|
$validation->setRules([
|
||||||
$ip = $this->request->getIPAddress();
|
'email' => 'required|valid_email|max_length[254]',
|
||||||
|
'password' => 'required|max_length[255]',
|
||||||
|
]);
|
||||||
|
|
||||||
if (!$email || !$password) {
|
if (! $validation->run($requestData)) {
|
||||||
return $this->response->setStatusCode(400)->setJSON([
|
return $this->response->setStatusCode(422)->setJSON([
|
||||||
'status' => false,
|
'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)) {
|
if ($this->isIpBlocked($ip)) {
|
||||||
return $this->response->setStatusCode(429)->setJSON([
|
return $this->response->setStatusCode(429)->setJSON([
|
||||||
'status' => false,
|
'status' => false,
|
||||||
@@ -201,7 +236,15 @@ class AuthController extends Controller
|
|||||||
'exp' => $exp,
|
'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');
|
$token = jwt_encode($payload, $secret, 'HS256');
|
||||||
|
|
||||||
return $this->response->setJSON([
|
return $this->response->setJSON([
|
||||||
@@ -221,18 +264,14 @@ class AuthController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function apiRegister()
|
public function apiRegister()
|
||||||
{
|
{
|
||||||
$requestData = $this->request->getJSON(true);
|
$requestData = sanitize_request_value($this->request->getJSON(true) ?: $this->request->getPost());
|
||||||
if (!$requestData) {
|
|
||||||
// fallback to form vars
|
|
||||||
$requestData = $this->request->getPost();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
$rules = [
|
$rules = [
|
||||||
'firstname' => 'required|min_length[2]|max_length[30]',
|
'firstname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]",
|
||||||
'lastname' => 'required|min_length[2]|max_length[30]',
|
'lastname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]",
|
||||||
'email' => 'required|valid_email|is_unique[users.email]',
|
'email' => 'required|valid_email|max_length[254]|is_unique[users.email]',
|
||||||
'password' => 'required|min_length[8]',
|
'password' => 'required|min_length[8]|max_length[255]',
|
||||||
'cellphone' => 'required|min_length[10]|max_length[20]',
|
'cellphone' => 'required|min_length[10]|max_length[20]',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -313,7 +352,7 @@ class AuthController extends Controller
|
|||||||
'exp' => $exp,
|
'exp' => $exp,
|
||||||
];
|
];
|
||||||
|
|
||||||
$secret = env('JWT_SECRET', 'change-me-in-env');
|
$secret = require_env('JWT_SECRET');
|
||||||
$token = jwt_encode($payload, $secret, 'HS256');
|
$token = jwt_encode($payload, $secret, 'HS256');
|
||||||
|
|
||||||
return $this->response->setStatusCode(201)->setJSON([
|
return $this->response->setStatusCode(201)->setJSON([
|
||||||
@@ -491,7 +530,7 @@ class AuthController extends Controller
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function loginUser($user)
|
private function loginUser($user, ?string $redirectTo = null)
|
||||||
{
|
{
|
||||||
$userRoleModel = new UserRoleModel();
|
$userRoleModel = new UserRoleModel();
|
||||||
$roles = $userRoleModel->select('roles.name')
|
$roles = $userRoleModel->select('roles.name')
|
||||||
@@ -525,9 +564,17 @@ class AuthController extends Controller
|
|||||||
if (count($roleNames) === 1) {
|
if (count($roleNames) === 1) {
|
||||||
// One role → set and redirect directly
|
// One role → set and redirect directly
|
||||||
session()->set('role', $roleNames[0]);
|
session()->set('role', $roleNames[0]);
|
||||||
|
if ($redirectTo !== null) {
|
||||||
|
return redirect()->to($redirectTo);
|
||||||
|
}
|
||||||
return $this->redirectToDashboard([$roleNames[0]]);
|
return $this->redirectToDashboard([$roleNames[0]]);
|
||||||
} else {
|
} else {
|
||||||
// Multiple roles → redirect to role selection view
|
// 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');
|
return redirect()->to('/select-role');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,12 +639,17 @@ private function redirectToDashboard(array $roles)
|
|||||||
{
|
{
|
||||||
$selectedRole = $this->request->getPost('selected_role');
|
$selectedRole = $this->request->getPost('selected_role');
|
||||||
$availableRoles = session()->get('roles');
|
$availableRoles = session()->get('roles');
|
||||||
|
$redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? session()->get('post_login_redirect') ?? ''));
|
||||||
|
|
||||||
if (!$selectedRole || !in_array($selectedRole, $availableRoles)) {
|
if (!$selectedRole || !in_array($selectedRole, $availableRoles)) {
|
||||||
return redirect()->to('/select-role')->with('error', 'Invalid role selected.');
|
return redirect()->to('/select-role')->with('error', 'Invalid role selected.');
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->set('role', $selectedRole);
|
session()->set('role', $selectedRole);
|
||||||
|
session()->remove('post_login_redirect');
|
||||||
|
if ($redirectTo !== null) {
|
||||||
|
return redirect()->to($redirectTo);
|
||||||
|
}
|
||||||
return $this->redirectToDashboard([$selectedRole]);
|
return $this->redirectToDashboard([$selectedRole]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,7 +661,44 @@ private function redirectToDashboard(array $roles)
|
|||||||
return redirect()->to('/login')->with('error', 'No roles available.');
|
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>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
protected $helpers = [];
|
protected $helpers = ['security'];
|
||||||
|
|
||||||
/** @var ApiClient */
|
/** @var ApiClient */
|
||||||
protected ApiClient $api;
|
protected ApiClient $api;
|
||||||
@@ -73,6 +73,21 @@ abstract class BaseController extends Controller
|
|||||||
// Assuming the user role is stored in the session
|
// Assuming the user role is stored in the session
|
||||||
return session()->get('role');
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ class ClassProgressController extends BaseController
|
|||||||
'db_subject' => 'Quran/Arabic',
|
'db_subject' => 'Quran/Arabic',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Unit column for teacher custom Islamic / Quran rows; must match teacher form JS. Segment: "Custom / {text}". */
|
||||||
|
public const CUSTOM_UNIT_ROW_LABEL = 'Custom';
|
||||||
|
|
||||||
protected ClassProgressReportModel $reportModel;
|
protected ClassProgressReportModel $reportModel;
|
||||||
protected ClassProgressAttachmentModel $attachmentModel;
|
protected ClassProgressAttachmentModel $attachmentModel;
|
||||||
protected TeacherClassModel $teacherClassModel;
|
protected TeacherClassModel $teacherClassModel;
|
||||||
@@ -383,13 +387,18 @@ class ClassProgressController extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$sundayOptions = $this->buildSundayOptions();
|
||||||
|
if (!in_array($row['week_start'], $sundayOptions, true)) {
|
||||||
|
array_unshift($sundayOptions, $row['week_start']);
|
||||||
|
}
|
||||||
|
|
||||||
return view('teacher/class_progress_submit', [
|
return view('teacher/class_progress_submit', [
|
||||||
'subjectSections' => self::SUBJECT_SECTIONS,
|
'subjectSections' => self::SUBJECT_SECTIONS,
|
||||||
'subjectCurriculum' => $subjectCurriculum,
|
'subjectCurriculum' => $subjectCurriculum,
|
||||||
'classSectionId' => $row['class_section_id'],
|
'classSectionId' => $row['class_section_id'],
|
||||||
'classSectionName' => $classSectionName,
|
'classSectionName' => $classSectionName,
|
||||||
'classId' => $classId,
|
'classId' => $classId,
|
||||||
'sundayOptions' => [$row['week_start']],
|
'sundayOptions' => $sundayOptions,
|
||||||
'defaultWeekStart' => $row['week_start'],
|
'defaultWeekStart' => $row['week_start'],
|
||||||
'existingWeekEnd' => $row['week_end'],
|
'existingWeekEnd' => $row['week_end'],
|
||||||
'existingReports' => $subjectReports,
|
'existingReports' => $subjectReports,
|
||||||
@@ -458,6 +467,32 @@ class ClassProgressController extends BaseController
|
|||||||
return redirect()->back()->withInput()->with('error', 'No class assignment found for this report.');
|
return redirect()->back()->withInput()->with('error', 'No class assignment found for this report.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$confirmOverwrite = (bool) $this->request->getPost('confirm_overwrite');
|
||||||
|
if ($weekStart && $weekStart !== (string) ($row['week_start'] ?? '')) {
|
||||||
|
$conflicts = $this->reportModel
|
||||||
|
->select('id')
|
||||||
|
->where('class_section_id', $classSectionId)
|
||||||
|
->where('week_start', $weekStart)
|
||||||
|
->where('teacher_id', $teacherId)
|
||||||
|
->findAll();
|
||||||
|
if (! $confirmOverwrite && ! empty($conflicts)) {
|
||||||
|
return redirect()->back()
|
||||||
|
->withInput()
|
||||||
|
->with('warning', 'A progress report already exists for this week, are you sure you want to override it?')
|
||||||
|
->with('confirm_overwrite', true);
|
||||||
|
}
|
||||||
|
if ($confirmOverwrite && ! empty($conflicts)) {
|
||||||
|
$conflictIds = array_values(array_filter(array_map(
|
||||||
|
static fn (array $row): int => (int) ($row['id'] ?? 0),
|
||||||
|
$conflicts
|
||||||
|
)));
|
||||||
|
if (! empty($conflictIds)) {
|
||||||
|
$this->attachmentModel->whereIn('report_id', $conflictIds)->delete();
|
||||||
|
$this->reportModel->whereIn('id', $conflictIds)->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$weeklyReports = $this->reportModel
|
$weeklyReports = $this->reportModel
|
||||||
->select('class_progress_reports.*')
|
->select('class_progress_reports.*')
|
||||||
->whereIn('teacher_id', $allowedTeacherIds)
|
->whereIn('teacher_id', $allowedTeacherIds)
|
||||||
@@ -674,6 +709,37 @@ class ClassProgressController extends BaseController
|
|||||||
return $flags ? json_encode($flags) : null;
|
return $flags ? json_encode($flags) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split stored unit_title into curriculum lines vs custom teacher-typed subjects ("Custom / …").
|
||||||
|
* Keep in sync with teacher form, {@see buildUnitChapterSummary()}, and admin views.
|
||||||
|
*
|
||||||
|
* @return array{curriculum: list<string>, custom: list<string>}
|
||||||
|
*/
|
||||||
|
public static function splitUnitTitleForDisplay(string $unitTitle): array
|
||||||
|
{
|
||||||
|
$unitTitle = trim($unitTitle);
|
||||||
|
if ($unitTitle === '') {
|
||||||
|
return ['curriculum' => [], 'custom' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments = preg_split('/\s*;\s*/', $unitTitle, -1, PREG_SPLIT_NO_EMPTY);
|
||||||
|
$curriculum = [];
|
||||||
|
$custom = [];
|
||||||
|
foreach ($segments as $seg) {
|
||||||
|
$seg = trim((string) $seg);
|
||||||
|
if ($seg === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^Custom\s*\/\s*(.+)$/iu', $seg, $m)) {
|
||||||
|
$custom[] = trim($m[1]);
|
||||||
|
} else {
|
||||||
|
$curriculum[] = $seg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['curriculum' => $curriculum, 'custom' => $custom];
|
||||||
|
}
|
||||||
|
|
||||||
protected function buildUnitChapterSummary(string $slug): ?string
|
protected function buildUnitChapterSummary(string $slug): ?string
|
||||||
{
|
{
|
||||||
$unitValues = array_map('trim', (array) $this->request->getPost("unit_$slug"));
|
$unitValues = array_map('trim', (array) $this->request->getPost("unit_$slug"));
|
||||||
@@ -686,9 +752,12 @@ class ClassProgressController extends BaseController
|
|||||||
if ($unit === '' && $chapter === '') {
|
if ($unit === '' && $chapter === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (strcasecmp($unit, self::CUSTOM_UNIT_ROW_LABEL) === 0 && $chapter !== '') {
|
||||||
|
$unit = self::CUSTOM_UNIT_ROW_LABEL;
|
||||||
|
}
|
||||||
$segment = $unit;
|
$segment = $unit;
|
||||||
if ($chapter !== '') {
|
if ($chapter !== '') {
|
||||||
$segment = $segment !== '' ? $segment . ' / ' . $chapter : $chapter;
|
$segment = $segment !== '' ? $unit . ' / ' . $chapter : $chapter;
|
||||||
}
|
}
|
||||||
if ($segment === '') {
|
if ($segment === '') {
|
||||||
continue;
|
continue;
|
||||||
@@ -699,17 +768,23 @@ class ClassProgressController extends BaseController
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$summary = implode(' ; ', $parts);
|
$summary = implode(' ; ', $parts);
|
||||||
|
|
||||||
return mb_strlen($summary) > 120 ? mb_substr($summary, 0, 120) : $summary;
|
return mb_strlen($summary) > 120 ? mb_substr($summary, 0, 120) : $summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function hasIslamicUnitSelection(): bool
|
protected function hasIslamicUnitSelection(): bool
|
||||||
{
|
{
|
||||||
$unitValues = array_map('trim', (array) $this->request->getPost('unit_islamic'));
|
$unitValues = array_map('trim', (array) $this->request->getPost('unit_islamic'));
|
||||||
foreach ($unitValues as $value) {
|
$chapterValues = array_map('trim', (array) $this->request->getPost('chapter_islamic'));
|
||||||
if ($value !== '') {
|
$count = max(count($unitValues), count($chapterValues));
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$unit = $unitValues[$i] ?? '';
|
||||||
|
$chapter = $chapterValues[$i] ?? '';
|
||||||
|
if ($unit !== '' || $chapter !== '') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,9 +803,19 @@ class ClassProgressController extends BaseController
|
|||||||
if ($segment === '') {
|
if ($segment === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (preg_match('/^Custom\s*\/\s*(.+)$/iu', $segment, $m)) {
|
||||||
|
$units[] = self::CUSTOM_UNIT_ROW_LABEL;
|
||||||
|
$chapters[] = trim($m[1]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$parts = preg_split('/\s*\/\s*/', $segment, 2);
|
$parts = preg_split('/\s*\/\s*/', $segment, 2);
|
||||||
if (count($parts) === 2) {
|
if (count($parts) === 2) {
|
||||||
$units[] = trim($parts[0]);
|
$u = trim($parts[0]);
|
||||||
|
if (strcasecmp($u, self::CUSTOM_UNIT_ROW_LABEL) === 0) {
|
||||||
|
$u = self::CUSTOM_UNIT_ROW_LABEL;
|
||||||
|
}
|
||||||
|
$units[] = $u;
|
||||||
$chapters[] = trim($parts[1]);
|
$chapters[] = trim($parts[1]);
|
||||||
} else {
|
} else {
|
||||||
$units[] = $segment;
|
$units[] = $segment;
|
||||||
|
|||||||
@@ -20,15 +20,22 @@ class ProofreadController extends ResourceController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Accept form-urlencoded payload to play nicely with CSRF protection
|
// Accept form-urlencoded payload to play nicely with CSRF protection
|
||||||
$text = (string) ($this->request->getPost('text') ?? '');
|
$validation = service('validation');
|
||||||
if ($text === '' || mb_strlen($text) > 20000) {
|
$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([
|
return $this->respond([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'error' => 'Invalid text (empty or too long).',
|
'error' => 'Invalid text payload.',
|
||||||
'csrfHash' => csrf_hash(),
|
'csrfHash' => csrf_hash(),
|
||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$text = (string) $payload['text'];
|
||||||
|
|
||||||
$client = \Config\Services::curlrequest(['timeout' => 10]);
|
$client = \Config\Services::curlrequest(['timeout' => 10]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -763,7 +763,13 @@ class AdministratorController extends BaseController
|
|||||||
|
|
||||||
[$progressExpectedWeeks, $progressSubmittedBySection] = $this->buildClassProgressStats($sectionIds);
|
[$progressExpectedWeeks, $progressSubmittedBySection] = $this->buildClassProgressStats($sectionIds);
|
||||||
$examDraftCounts = [];
|
$examDraftCounts = [];
|
||||||
$examDraftDeadline = $this->resolveExamDraftDeadline($semester, $schoolYear);
|
$examDraftDeadline = $this->resolveTeacherDashboardExamDraftDeadline($semester, $schoolYear);
|
||||||
|
$examDraftDeadlineConfig = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
|
||||||
|
$examDraftDeadlineFormatted = '';
|
||||||
|
if ($examDraftDeadlineConfig !== '') {
|
||||||
|
$parsedUi = $this->parseExamDraftDeadlineConfigValue();
|
||||||
|
$examDraftDeadlineFormatted = $parsedUi !== null ? $parsedUi->format('M j, Y') : '';
|
||||||
|
}
|
||||||
$homeworkCounts = [];
|
$homeworkCounts = [];
|
||||||
if (! empty($sectionIds)) {
|
if (! empty($sectionIds)) {
|
||||||
$draftBuilder = $examDrafts
|
$draftBuilder = $examDrafts
|
||||||
@@ -1066,6 +1072,8 @@ class AdministratorController extends BaseController
|
|||||||
'notificationHistory' => $historyMap,
|
'notificationHistory' => $historyMap,
|
||||||
'summary' => $summary,
|
'summary' => $summary,
|
||||||
'lowProgressSectionIds' => $lowProgressSectionIds,
|
'lowProgressSectionIds' => $lowProgressSectionIds,
|
||||||
|
'examDraftDeadlineConfig' => $examDraftDeadlineConfig,
|
||||||
|
'examDraftDeadlineFormatted' => $examDraftDeadlineFormatted,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1299,6 +1307,7 @@ class AdministratorController extends BaseController
|
|||||||
$progressUrl = site_url('teacher/progress/history');
|
$progressUrl = site_url('teacher/progress/history');
|
||||||
$examDraftUrl = site_url('teacher/exam-drafts');
|
$examDraftUrl = site_url('teacher/exam-drafts');
|
||||||
$homeworkUrl = site_url('teacher/addHomework');
|
$homeworkUrl = site_url('teacher/addHomework');
|
||||||
|
$examDraftDeadlineEmailHtml = $this->buildExamDraftDeadlineEmailHtml();
|
||||||
$sentCount = 0;
|
$sentCount = 0;
|
||||||
$failCount = 0;
|
$failCount = 0;
|
||||||
|
|
||||||
@@ -1347,7 +1356,8 @@ class AdministratorController extends BaseController
|
|||||||
} else {
|
} else {
|
||||||
$draftLabel = 'exam draft';
|
$draftLabel = 'exam draft';
|
||||||
}
|
}
|
||||||
$examDraftNote = "<p>" . ucfirst($draftLabel) . " submissions can be updated at <a href=\"{$examDraftUrl}\">Teacher Exam Drafts</a>.</p>";
|
$examDraftNote = "<p>" . ucfirst($draftLabel) . " submissions can be updated at <a href=\"{$examDraftUrl}\">Teacher Exam Drafts</a>.</p>"
|
||||||
|
. $examDraftDeadlineEmailHtml;
|
||||||
}
|
}
|
||||||
$homeworkNote = '';
|
$homeworkNote = '';
|
||||||
if (in_array('homework', $missingItems, true)) {
|
if (in_array('homework', $missingItems, true)) {
|
||||||
@@ -1485,6 +1495,60 @@ class AdministratorController extends BaseController
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exam draft due date for the teacher submissions dashboard: prefers the configuration key
|
||||||
|
* `exam_draft_deadline` (same as automated reminders); otherwise fall/spring exam deadlines.
|
||||||
|
*/
|
||||||
|
private function resolveTeacherDashboardExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
$fromExamDraftKey = $this->parseExamDraftDeadlineConfigValue();
|
||||||
|
if ($fromExamDraftKey !== null) {
|
||||||
|
return $fromExamDraftKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolveExamDraftDeadline($semester, $schoolYear);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the `exam_draft_deadline` configuration value using the application timezone (midnight that calendar day).
|
||||||
|
*/
|
||||||
|
private function parseExamDraftDeadlineConfigValue(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
$raw = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
|
||||||
|
if ($raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$tz = new \DateTimeZone(config('App')->appTimezone ?? 'UTC');
|
||||||
|
try {
|
||||||
|
$deadline = new \DateTimeImmutable($raw, $tz);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $deadline->setTime(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML snippet for reminder emails when exam draft is included (deadline from exam_draft_deadline config).
|
||||||
|
*/
|
||||||
|
private function buildExamDraftDeadlineEmailHtml(): string
|
||||||
|
{
|
||||||
|
$raw = trim((string) ($this->configModel->getConfig('exam_draft_deadline') ?? ''));
|
||||||
|
if ($raw === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$parsed = $this->parseExamDraftDeadlineConfigValue();
|
||||||
|
$display = $parsed !== null
|
||||||
|
? htmlspecialchars($parsed->format('l, F j, Y'), ENT_QUOTES, 'UTF-8')
|
||||||
|
: htmlspecialchars($raw, ENT_QUOTES, 'UTF-8');
|
||||||
|
$rawEsc = htmlspecialchars($raw, ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
|
return '<p><strong>Exam draft submission deadline</strong> (<code>exam_draft_deadline</code>): '
|
||||||
|
. "<strong>{$display}</strong>"
|
||||||
|
. ($parsed !== null && $rawEsc !== $display ? " <span style=\"color:#555;\">(configured value: {$rawEsc})</span>" : '')
|
||||||
|
. '.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable
|
private function resolveExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable
|
||||||
{
|
{
|
||||||
$semesterKey = strtolower(trim($semester));
|
$semesterKey = strtolower(trim($semester));
|
||||||
|
|||||||
@@ -2003,6 +2003,58 @@ class AttendanceTrackingController extends BaseController
|
|||||||
return [$subject, $body];
|
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()
|
public function compose()
|
||||||
{
|
{
|
||||||
@@ -2033,8 +2085,8 @@ class AttendanceTrackingController extends BaseController
|
|||||||
$rendered = $this->renderTemplate($code, $variant, $ctx);
|
$rendered = $this->renderTemplate($code, $variant, $ctx);
|
||||||
|
|
||||||
if (!$rendered) {
|
if (!$rendered) {
|
||||||
return redirect()->to(site_url('attendance/violations'))
|
log_message('warning', 'Attendance email template missing; using fallback compose body. code=' . $code . ' variant=' . $variant);
|
||||||
->with('error', 'Template not found for ' . $code . ' (' . $variant . ').');
|
$rendered = $this->buildFallbackTemplate($code, $variant, $ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
[$subject, $body] = $rendered;
|
[$subject, $body] = $rendered;
|
||||||
|
|||||||
@@ -0,0 +1,557 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\View;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Models\ClassSectionModel;
|
||||||
|
use App\Models\ConfigurationModel;
|
||||||
|
use App\Models\CertificateRecordModel;
|
||||||
|
use App\Models\StudentDecisionModel;
|
||||||
|
|
||||||
|
class CertificateController extends BaseController
|
||||||
|
{
|
||||||
|
protected $classSectionModel;
|
||||||
|
protected $configModel;
|
||||||
|
protected $certRecordModel;
|
||||||
|
protected $schoolYear;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->classSectionModel = new ClassSectionModel();
|
||||||
|
$this->configModel = new ConfigurationModel();
|
||||||
|
$this->certRecordModel = new CertificateRecordModel();
|
||||||
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Admin UI ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$selectedCsid = $this->request->getGet('class_section_id');
|
||||||
|
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||||
|
|
||||||
|
// ── All enrolled students across every class section ───────────────────
|
||||||
|
$allEnrolled = $db->table('student_class sc')
|
||||||
|
->select('s.id AS student_id, s.firstname, s.lastname, sc.class_section_id, cs.class_section_name')
|
||||||
|
->join('students s', 's.id = sc.student_id')
|
||||||
|
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
||||||
|
->where('s.is_active', 1)
|
||||||
|
->where('sc.school_year', $schoolYear)
|
||||||
|
->orderBy('s.firstname', 'ASC')
|
||||||
|
->orderBy('s.lastname', 'ASC')
|
||||||
|
->get()->getResultArray();
|
||||||
|
|
||||||
|
$allIds = array_unique(array_column($allEnrolled, 'student_id'));
|
||||||
|
|
||||||
|
// ── Semester scores ────────────────────────────────────────────────────
|
||||||
|
$allScoreMap = [];
|
||||||
|
if (!empty($allIds)) {
|
||||||
|
foreach ($db->table('semester_scores')
|
||||||
|
->select('student_id, semester, semester_score')
|
||||||
|
->whereIn('student_id', $allIds)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->where('semester_score IS NOT NULL', null, false)
|
||||||
|
->get()->getResultArray() as $sr) {
|
||||||
|
$allScoreMap[(int)$sr['student_id']][ucfirst(strtolower($sr['semester']))] =
|
||||||
|
is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Below-60 manual decisions ──────────────────────────────────────────
|
||||||
|
$allBelowMap = [];
|
||||||
|
if (!empty($allIds)) {
|
||||||
|
foreach ($db->table('below_sixty_decisions')
|
||||||
|
->whereIn('student_id', $allIds)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->get()->getResultArray() as $b) {
|
||||||
|
$allBelowMap[(int)$b['student_id']][ucfirst(strtolower($b['semester']))] = (string)$b['decision'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Certificate records (most recent per student) ──────────────────────
|
||||||
|
$certsByStudent = [];
|
||||||
|
if (!empty($allIds)) {
|
||||||
|
foreach ($db->table('certificate_records')
|
||||||
|
->select('student_id, certificate_number, issued_at')
|
||||||
|
->whereIn('student_id', $allIds)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->orderBy('issued_at', 'DESC')
|
||||||
|
->get()->getResultArray() as $c) {
|
||||||
|
$sid = (int)$c['student_id'];
|
||||||
|
if (!isset($certsByStudent[$sid])) {
|
||||||
|
$certsByStudent[$sid] = $c['certificate_number'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build per-student decisions + per-class buckets ────────────────────
|
||||||
|
$decisionsByStudent = [];
|
||||||
|
$studentsByClass = []; // [csid => [student rows...]]
|
||||||
|
$statsPerClass = []; // [csid => {name, total, pass, cert}]
|
||||||
|
|
||||||
|
foreach ($allEnrolled as $row) {
|
||||||
|
$sid = (int)$row['student_id'];
|
||||||
|
$csid = (int)$row['class_section_id'];
|
||||||
|
|
||||||
|
// Decisions per semester
|
||||||
|
foreach ($allScoreMap[$sid] ?? [] as $sem => $score) {
|
||||||
|
if ($score === null) continue;
|
||||||
|
if ($score >= 60) {
|
||||||
|
$dec = 'Pass'; $src = 'auto';
|
||||||
|
} elseif (!empty($allBelowMap[$sid][$sem])) {
|
||||||
|
$dec = $allBelowMap[$sid][$sem]; $src = 'manual';
|
||||||
|
} else {
|
||||||
|
$dec = ''; $src = 'pending';
|
||||||
|
}
|
||||||
|
$decisionsByStudent[$sid][$sem] = ['decision' => $dec, 'source' => $src];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-class stats
|
||||||
|
if (!isset($statsPerClass[$csid])) {
|
||||||
|
$statsPerClass[$csid] = ['name' => $row['class_section_name'], 'total' => 0, 'pass' => 0, 'cert' => 0];
|
||||||
|
}
|
||||||
|
$statsPerClass[$csid]['total']++;
|
||||||
|
|
||||||
|
$sems = $allScoreMap[$sid] ?? [];
|
||||||
|
$isPass = !empty($sems);
|
||||||
|
foreach ($sems as $sem => $score) {
|
||||||
|
if ($score === null) { $isPass = false; break; }
|
||||||
|
if ($score >= 60) continue;
|
||||||
|
$md = $allBelowMap[$sid][$sem] ?? '';
|
||||||
|
if ($md === '' || $md !== 'Pass') { $isPass = false; break; }
|
||||||
|
}
|
||||||
|
if ($isPass) $statsPerClass[$csid]['pass']++;
|
||||||
|
if (isset($certsByStudent[$sid])) $statsPerClass[$csid]['cert']++;
|
||||||
|
|
||||||
|
// Group students by class
|
||||||
|
$studentsByClass[$csid][] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Determine default active tab ───────────────────────────────────────
|
||||||
|
$firstCsid = !empty($allEnrolled) ? (int)$allEnrolled[0]['class_section_id'] : null;
|
||||||
|
if ($selectedCsid === null && $firstCsid !== null) {
|
||||||
|
$selectedCsid = (string)$firstCsid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('admin/certificates/index', [
|
||||||
|
'studentsByClass' => $studentsByClass,
|
||||||
|
'selectedClassId' => $selectedCsid,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
'certDate' => date('m/d/Y'),
|
||||||
|
'decisionsByStudent' => $decisionsByStudent,
|
||||||
|
'certsByStudent' => $certsByStudent,
|
||||||
|
'statsPerClass' => $statsPerClass,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function auditLog()
|
||||||
|
{
|
||||||
|
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
||||||
|
$records = $this->certRecordModel->getAuditLog($schoolYear);
|
||||||
|
$yearSummary = $this->certRecordModel->yearSummary();
|
||||||
|
|
||||||
|
return view('admin/certificates/audit_log', [
|
||||||
|
'records' => $records,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
'yearSummary' => $yearSummary,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function csrfToken()
|
||||||
|
{
|
||||||
|
return $this->response
|
||||||
|
->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
|
||||||
|
->setHeader('Pragma', 'no-cache')
|
||||||
|
->setJSON([
|
||||||
|
'csrf_token' => csrf_token(),
|
||||||
|
'csrf_hash' => csrf_hash(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public verification page ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function verify(string $certNumber)
|
||||||
|
{
|
||||||
|
$record = \Config\Database::connect()
|
||||||
|
->table('certificate_records cr')
|
||||||
|
->select('cr.*, u.firstname AS admin_firstname, u.lastname AS admin_lastname')
|
||||||
|
->join('users u', 'u.id = cr.issued_by', 'left')
|
||||||
|
->where('cr.certificate_number', strtoupper($certNumber))
|
||||||
|
->get()->getRowArray();
|
||||||
|
|
||||||
|
return view('certificates/verify', ['record' => $record ?: null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reprint an existing certificate ──────────────────────────────────────
|
||||||
|
|
||||||
|
public function reprint(string $certNumber)
|
||||||
|
{
|
||||||
|
$record = \Config\Database::connect()
|
||||||
|
->table('certificate_records')
|
||||||
|
->where('certificate_number', strtoupper($certNumber))
|
||||||
|
->get()->getRowArray();
|
||||||
|
|
||||||
|
if (!$record) {
|
||||||
|
return redirect()->to('administrator/certificates/log')
|
||||||
|
->with('error', 'Certificate not found: ' . $certNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
$certDateFormatted = '';
|
||||||
|
if (!empty($record['cert_date'])) {
|
||||||
|
$ts = strtotime((string)$record['cert_date']);
|
||||||
|
if ($ts) {
|
||||||
|
$certDateFormatted = date('m/d/Y', $ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$student = [
|
||||||
|
'firstname' => (string)($record['student_name'] ?? ''),
|
||||||
|
'lastname' => '',
|
||||||
|
'grade' => (string)($record['grade'] ?? ''),
|
||||||
|
'cert_number' => (string)($record['certificate_number'] ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
// student_name is stored as "Firstname Lastname" — split for the PDF builder
|
||||||
|
$parts = explode(' ', trim($student['firstname']), 2);
|
||||||
|
if (count($parts) === 2) {
|
||||||
|
$student['firstname'] = $parts[0];
|
||||||
|
$student['lastname'] = $parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdfData = $this->buildPdf([$student], $certDateFormatted ?: date('m/d/Y'));
|
||||||
|
$filename = 'Certificate_' . strtoupper($certNumber) . '.pdf';
|
||||||
|
|
||||||
|
return $this->response
|
||||||
|
->setHeader('Content-Type', 'application/pdf')
|
||||||
|
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
|
||||||
|
->setBody($pdfData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PDF generation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function generate()
|
||||||
|
{
|
||||||
|
$studentIds = $this->request->getPost('student_ids') ?? [];
|
||||||
|
$certDate = trim($this->request->getPost('cert_date') ?? date('m/d/Y'));
|
||||||
|
$classSectionId = $this->request->getPost('class_section_id');
|
||||||
|
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
||||||
|
|
||||||
|
if (empty($studentIds)) {
|
||||||
|
if ($this->request->isAJAX()) {
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode(422)
|
||||||
|
->setJSON([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Please select at least one student.',
|
||||||
|
'csrf_token' => csrf_token(),
|
||||||
|
'csrf_hash' => csrf_hash(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return redirect()->to('administrator/certificates')->with('error', 'Please select at least one student.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$studentIds = array_filter(array_map('intval', $studentIds));
|
||||||
|
$certDate = preg_replace('/[^0-9\/\-]/', '', $certDate);
|
||||||
|
|
||||||
|
if (empty($studentIds)) {
|
||||||
|
if ($this->request->isAJAX()) {
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode(422)
|
||||||
|
->setJSON([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Invalid student selection.',
|
||||||
|
'csrf_token' => csrf_token(),
|
||||||
|
'csrf_hash' => csrf_hash(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return redirect()->to('administrator/certificates')->with('error', 'Invalid student selection.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$students = [];
|
||||||
|
|
||||||
|
foreach ($studentIds as $id) {
|
||||||
|
$row = null;
|
||||||
|
if ($classSectionId) {
|
||||||
|
$row = $db->table('student_class sc')
|
||||||
|
->select('s.id, s.firstname, s.lastname, cs.class_section_name AS grade')
|
||||||
|
->join('students s', 's.id = sc.student_id')
|
||||||
|
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
|
||||||
|
->where('sc.class_section_id', (int) $classSectionId)
|
||||||
|
->where('s.id', $id)
|
||||||
|
->get()->getRowArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
$s = $db->table('students')
|
||||||
|
->select('id, firstname, lastname, registration_grade AS grade')
|
||||||
|
->where('id', $id)
|
||||||
|
->get()->getRowArray();
|
||||||
|
$row = $s ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($row) {
|
||||||
|
$students[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($students)) {
|
||||||
|
if ($this->request->isAJAX()) {
|
||||||
|
return $this->response
|
||||||
|
->setStatusCode(422)
|
||||||
|
->setJSON([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'No valid students found.',
|
||||||
|
'csrf_token' => csrf_token(),
|
||||||
|
'csrf_hash' => csrf_hash(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return redirect()->to('administrator/certificates')->with('error', 'No valid students found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$issuedBy = session()->get('user_id');
|
||||||
|
$issuedAt = date('Y-m-d H:i:s');
|
||||||
|
$certDateDb = $this->parseCertDate($certDate);
|
||||||
|
|
||||||
|
// Load any existing certificates for these students this school year
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$existingCerts = $db->table('certificate_records')
|
||||||
|
->select('student_id, certificate_number')
|
||||||
|
->whereIn('student_id', array_column($students, 'id'))
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->get()->getResultArray();
|
||||||
|
$existingCertMap = [];
|
||||||
|
foreach ($existingCerts as $ec) {
|
||||||
|
$existingCertMap[(int)$ec['student_id']] = $ec['certificate_number'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($students as &$student) {
|
||||||
|
$sid = (int)$student['id'];
|
||||||
|
if (isset($existingCertMap[$sid])) {
|
||||||
|
// Reuse existing certificate number — do not create a new record
|
||||||
|
$student['cert_number'] = $existingCertMap[$sid];
|
||||||
|
} else {
|
||||||
|
$certNumber = $this->certRecordModel->nextNumber($schoolYear);
|
||||||
|
$this->certRecordModel->insert([
|
||||||
|
'certificate_number' => $certNumber,
|
||||||
|
'student_id' => $sid,
|
||||||
|
'student_name' => $student['firstname'] . ' ' . $student['lastname'],
|
||||||
|
'grade' => $this->formatGrade($student['grade'] ?? ''),
|
||||||
|
'cert_date' => $certDateDb,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'class_section_id' => $classSectionId ?: null,
|
||||||
|
'issued_by' => $issuedBy,
|
||||||
|
'issued_at' => $issuedAt,
|
||||||
|
]);
|
||||||
|
$student['cert_number'] = $certNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($student);
|
||||||
|
|
||||||
|
$pdfData = $this->buildPdf($students, $certDate);
|
||||||
|
$filename = 'Certificates_' . date('Ymd_His') . '.pdf';
|
||||||
|
|
||||||
|
return $this->response
|
||||||
|
->setHeader('Content-Type', 'application/pdf')
|
||||||
|
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
|
||||||
|
->setHeader('X-CSRF-TOKEN-NAME', csrf_token())
|
||||||
|
->setHeader('X-CSRF-TOKEN', csrf_hash())
|
||||||
|
->setBody($pdfData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function parseCertDate(string $certDate): ?string
|
||||||
|
{
|
||||||
|
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $certDate, $m)) {
|
||||||
|
return $m[3] . '-' . $m[1] . '-' . $m[2];
|
||||||
|
}
|
||||||
|
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $certDate)) {
|
||||||
|
return $certDate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatGrade(string $raw): string
|
||||||
|
{
|
||||||
|
$clean = trim($raw);
|
||||||
|
$lower = strtolower($clean);
|
||||||
|
|
||||||
|
// Strip section suffix: "Grade 1-A" → "Grade 1", "Grade 2-B" → "Grade 2"
|
||||||
|
if (preg_match('/^grade\s*(\d+)/i', $clean, $m)) {
|
||||||
|
return 'Grade ' . (int)$m[1];
|
||||||
|
}
|
||||||
|
if ($lower === 'youth') {
|
||||||
|
return 'Youth';
|
||||||
|
}
|
||||||
|
if ($lower === 'kg' || $lower === 'kindergarten') {
|
||||||
|
return 'Kindergarten';
|
||||||
|
}
|
||||||
|
// Raw number or number-section (e.g. "1", "1-A", "2-B") → keep number only
|
||||||
|
if (preg_match('/^(\d+)([- ][A-Za-z0-9]+)?$/', $clean, $m)) {
|
||||||
|
return 'Grade ' . (int)$m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PDF rendering ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private function buildPdf(array $students, string $certDate): string
|
||||||
|
{
|
||||||
|
$fontDir = FCPATH . 'assets' . DIRECTORY_SEPARATOR . 'certificates' . DIRECTORY_SEPARATOR . 'fonts' . DIRECTORY_SEPARATOR;
|
||||||
|
$imgDir = FCPATH . 'assets' . DIRECTORY_SEPARATOR . 'certificates' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR;
|
||||||
|
|
||||||
|
$edwardianFont = \TCPDF_FONTS::addTTFfont($fontDir . 'Edwardian Script ITC Regular.ttf', 'TrueTypeUnicode', '', 32);
|
||||||
|
$garamondBold = \TCPDF_FONTS::addTTFfont($fontDir . 'Garamond Bold.ttf', 'TrueTypeUnicode', '', 32);
|
||||||
|
$ebGaramond = \TCPDF_FONTS::addTTFfont($fontDir . 'EBGaramond-Regular.ttf', 'TrueTypeUnicode', '', 32);
|
||||||
|
|
||||||
|
$pdf = new \TCPDF('L', 'pt', 'A4', true, 'UTF-8', false);
|
||||||
|
$pdf->SetCreator('Al Rahma Sunday School');
|
||||||
|
$pdf->SetTitle('Student Certificates');
|
||||||
|
$pdf->SetMargins(0, 0, 0, true);
|
||||||
|
$pdf->SetAutoPageBreak(false, 0);
|
||||||
|
$pdf->setPrintHeader(false);
|
||||||
|
$pdf->setPrintFooter(false);
|
||||||
|
$pdf->SetHeaderMargin(0);
|
||||||
|
$pdf->SetFooterMargin(0);
|
||||||
|
|
||||||
|
$W = $pdf->getPageWidth();
|
||||||
|
$H = $pdf->getPageHeight();
|
||||||
|
|
||||||
|
foreach ($students as $student) {
|
||||||
|
$pdf->AddPage();
|
||||||
|
|
||||||
|
$name = $student['firstname'] . ' ' . $student['lastname'];
|
||||||
|
$grade = $this->formatGrade($student['grade'] ?? '');
|
||||||
|
$certNumber = $student['cert_number'] ?? '';
|
||||||
|
|
||||||
|
// Build verification URL for an inline TCPDF QR code.
|
||||||
|
$verifyUrl = site_url('verify/' . rawurlencode($certNumber));
|
||||||
|
|
||||||
|
$this->drawCertificate(
|
||||||
|
$pdf, $W, $H,
|
||||||
|
$name, $grade, $certDate, $certNumber,
|
||||||
|
$verifyUrl,
|
||||||
|
$imgDir, $edwardianFont, $garamondBold, $ebGaramond
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pdf->Output('', 'S');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinate mapping: iTextSharp origin bottom-left → TCPDF origin top-left.
|
||||||
|
* y_tcpdf ≈ H − y_iTextSharp
|
||||||
|
*/
|
||||||
|
private function drawCertificate(
|
||||||
|
\TCPDF $pdf,
|
||||||
|
float $W,
|
||||||
|
float $H,
|
||||||
|
string $name,
|
||||||
|
string $grade,
|
||||||
|
string $certDate,
|
||||||
|
string $certNumber,
|
||||||
|
?string $verifyUrl,
|
||||||
|
string $imgDir,
|
||||||
|
string $edwardianFont,
|
||||||
|
string $garamondBold,
|
||||||
|
string $ebGaramond
|
||||||
|
): void {
|
||||||
|
// ── Images
|
||||||
|
$pdf->Image($imgDir . 'title.png', 126, 0, 600);
|
||||||
|
$pdf->Image($imgDir . 'background.png', 280, 176, 291, 340);
|
||||||
|
$pdf->Image($imgDir . 'signature.png', 140, 410, 90, 80);
|
||||||
|
|
||||||
|
// ── "Presented to:"
|
||||||
|
$pdf->SetFont('times', 'B', 24);
|
||||||
|
$pdf->SetTextColor(0, 0, 0);
|
||||||
|
$pdf->SetXY(0, 151);
|
||||||
|
$pdf->Cell($W, 24, 'Presented to:', 0, 0, 'C');
|
||||||
|
|
||||||
|
// ── QR code — left-aligned with "Presented to:", vertically centred on that line
|
||||||
|
$qrSize = 42; // pt
|
||||||
|
if (!empty($verifyUrl)) {
|
||||||
|
$qrX = 120; // ~1 cm from left edge
|
||||||
|
$qrY = 171 + (24 - $qrSize) / 2; // vertically centred on "Presented to:" row
|
||||||
|
$style = [
|
||||||
|
'border' => false,
|
||||||
|
'padding' => 0,
|
||||||
|
'fgcolor' => [0, 0, 0],
|
||||||
|
'bgcolor' => false,
|
||||||
|
];
|
||||||
|
$pdf->write2DBarcode($verifyUrl, 'QRCODE,L', $qrX, $qrY, $qrSize, $qrSize, $style, 'N');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Student name — center based on actual string width
|
||||||
|
$pdf->SetFont($edwardianFont, '', 38);
|
||||||
|
$nameX = ($W - $pdf->GetStringWidth($name)) / 2;
|
||||||
|
$this->drawGradientText($pdf, $edwardianFont, 38, $name, $nameX, 229.5);
|
||||||
|
|
||||||
|
// ── Line under name
|
||||||
|
$pdf->SetFont('times', '', 20);
|
||||||
|
$pdf->SetXY(0, 243);
|
||||||
|
$pdf->Cell(600, 20, '___________________________________', 0, 0, 'R');
|
||||||
|
|
||||||
|
// ── Description
|
||||||
|
$pdf->SetFont($ebGaramond, '', 20);
|
||||||
|
$pdf->SetXY(0, 273);
|
||||||
|
$pdf->Cell($W, 20, 'for successfully completing the requirements of', 0, 0, 'C');
|
||||||
|
|
||||||
|
// ── Grade
|
||||||
|
$pdf->SetFont($garamondBold, '', 20);
|
||||||
|
$pdf->SetXY(0, 310);
|
||||||
|
$pdf->Cell($W, 20, $grade, 0, 0, 'C');
|
||||||
|
|
||||||
|
// ── "at"
|
||||||
|
$pdf->SetFont('times', '', 20);
|
||||||
|
$pdf->SetXY(0, 343);
|
||||||
|
$pdf->Cell($W, 20, 'at', 0, 0, 'C');
|
||||||
|
|
||||||
|
// ── School name
|
||||||
|
$pdf->SetFont($garamondBold, '', 20);
|
||||||
|
$pdf->SetXY(0, 375);
|
||||||
|
$pdf->Cell($W, 20, 'Al Rahma Sunday School', 0, 0, 'C');
|
||||||
|
|
||||||
|
// ── Date (gradient script)
|
||||||
|
$this->drawGradientText($pdf, $edwardianFont, 26, $certDate, 586, 456);
|
||||||
|
|
||||||
|
// ── Date underline + label
|
||||||
|
$pdf->SetFont('times', '', 20);
|
||||||
|
$pdf->SetXY(0, 463);
|
||||||
|
$pdf->Cell(742, 20, '_________________', 0, 0, 'R');
|
||||||
|
$pdf->SetXY(650, 492);
|
||||||
|
$pdf->Cell(80, 20, 'Date', 0, 0, 'C');
|
||||||
|
|
||||||
|
// ── Signature underline + label
|
||||||
|
$pdf->SetXY(106, 463);
|
||||||
|
$pdf->Cell(200, 20, '_________________', 0, 0, 'L');
|
||||||
|
$pdf->SetXY(106, 492);
|
||||||
|
$pdf->Cell(168, 20, 'Signature', 0, 0, 'C');
|
||||||
|
|
||||||
|
// ── Certificate number — 1.4 cm from bottom, 2.5 cm from left
|
||||||
|
if ($certNumber !== '') {
|
||||||
|
$pdf->SetFont('helvetica', '', 8);
|
||||||
|
$pdf->SetTextColor(150, 150, 150);
|
||||||
|
$pdf->SetXY(70.86, $H - 39.68);
|
||||||
|
$pdf->Cell(200, 12, 'Certificate No. ' . $certNumber, 0, 0, 'L');
|
||||||
|
$pdf->SetTextColor(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function drawGradientText(\TCPDF $pdf, string $fontName, float $fontSize, string $text, float $x, float $y): void
|
||||||
|
{
|
||||||
|
$pdf->SetFont($fontName, '', $fontSize);
|
||||||
|
|
||||||
|
for ($i = 0; $i < 6; $i++) {
|
||||||
|
$pdf->setAlpha(($i + 1) * 25 / 255);
|
||||||
|
$pdf->SetTextColor(0, 0, 0);
|
||||||
|
$pdf->Text($x + $i / 4, $y + $i / 4, $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdf->setAlpha(1);
|
||||||
|
$pdf->SetTextColor(0, 0, 0);
|
||||||
|
$pdf->Text($x, $y, $text);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,10 +24,10 @@ class ContactController extends BaseController
|
|||||||
|
|
||||||
// Define validation rules
|
// Define validation rules
|
||||||
$validation->setRules([
|
$validation->setRules([
|
||||||
'name' => 'required|min_length[3]',
|
'name' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[3]|max_length[100]",
|
||||||
'email' => 'required|valid_email',
|
'email' => 'required|valid_email|max_length[254]',
|
||||||
'subject' => 'required|min_length[3]',
|
'subject' => 'required|min_length[3]|max_length[150]',
|
||||||
'message' => 'required|min_length[10]'
|
'message' => 'required|min_length[10]|max_length[5000]'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$validation->withRequest($this->request)->run()) {
|
if (!$validation->withRequest($this->request)->run()) {
|
||||||
@@ -37,10 +37,10 @@ class ContactController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process form data
|
// Process form data
|
||||||
$name = $this->request->getPost('name');
|
$name = esc((string) $this->request->getPost('name'));
|
||||||
$email = strtolower($this->request->getPost('email'));
|
$email = esc(strtolower((string) $this->request->getPost('email')));
|
||||||
$subject = $this->request->getPost('subject');
|
$subject = esc((string) $this->request->getPost('subject'));
|
||||||
$message = $this->request->getPost('message');
|
$message = nl2br(esc((string) $this->request->getPost('message')));
|
||||||
|
|
||||||
// Initialize the EmailController
|
// Initialize the EmailController
|
||||||
$emailController = new \App\Controllers\View\EmailController();
|
$emailController = new \App\Controllers\View\EmailController();
|
||||||
@@ -63,4 +63,4 @@ class ContactController extends BaseController
|
|||||||
|
|
||||||
return redirect()->to('/parent/contact');
|
return redirect()->to('/parent/contact');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -750,7 +750,9 @@ class DiscountController extends BaseController
|
|||||||
}
|
}
|
||||||
} catch (\Throwable $e) {}
|
} catch (\Throwable $e) {}
|
||||||
|
|
||||||
$newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
|
$discountableTotal = $tuitionSubtotal + $additionalSubtotal;
|
||||||
|
$nonDiscountableTotal = $eventSubtotal;
|
||||||
|
$newTotal = round($discountableTotal + $nonDiscountableTotal, 2);
|
||||||
|
|
||||||
// ---- Payments / Discounts / Refunds ----
|
// ---- Payments / Discounts / Refunds ----
|
||||||
$db = $this->db;
|
$db = $this->db;
|
||||||
@@ -794,7 +796,8 @@ class DiscountController extends BaseController
|
|||||||
->get()->getRowArray();
|
->get()->getRowArray();
|
||||||
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
|
$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');
|
$newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid');
|
||||||
|
|
||||||
$updateData = [
|
$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. */
|
/** Resolve null/alias profile names to a canonical env prefix. */
|
||||||
private function resolveProfile(?string $profile): string
|
private function resolveProfile(?string $profile): string
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -224,6 +224,10 @@ class FilesController extends Controller
|
|||||||
throw PageNotFoundException::forPageNotFound();
|
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';
|
$mime = 'application/octet-stream';
|
||||||
if (function_exists('finfo_open')) {
|
if (function_exists('finfo_open')) {
|
||||||
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
@@ -271,8 +275,8 @@ class FilesController extends Controller
|
|||||||
|
|
||||||
private function buildDraftDownloadName(string $filename, string $subdir): string
|
private function buildDraftDownloadName(string $filename, string $subdir): string
|
||||||
{
|
{
|
||||||
$column = $subdir === 'finals' ? 'final_file' : 'teacher_file';
|
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
|
$column = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
|
||||||
$row = $db->table('exam_drafts ed')
|
$row = $db->table('exam_drafts ed')
|
||||||
->select('ed.version, ed.exam_type, ed.class_section_id, cs.class_section_name')
|
->select('ed.version, ed.exam_type, ed.class_section_id, cs.class_section_name')
|
||||||
->join('classSection cs', 'cs.class_section_id = ed.class_section_id', 'left')
|
->join('classSection cs', 'cs.class_section_id = ed.class_section_id', 'left')
|
||||||
@@ -301,4 +305,124 @@ class FilesController extends Controller
|
|||||||
$value = trim($value, '_');
|
$value = trim($value, '_');
|
||||||
return $value === '' ? 'Exam' : mb_strtolower($value);
|
return $value === '' ? 'Exam' : mb_strtolower($value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveExamDraftFileColumn($db): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$fields = $db->getFieldNames('exam_drafts');
|
||||||
|
if (in_array('teacher_file', $fields, true)) {
|
||||||
|
return 'teacher_file';
|
||||||
|
}
|
||||||
|
if (in_array('author_file', $fields, true)) {
|
||||||
|
return 'author_file';
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'FilesController::resolveExamDraftFileColumn error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
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 ===
|
// === Invoices ===
|
||||||
$invoices = $invoiceModel
|
// Business rule: one active invoice per parent per school year.
|
||||||
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
|
// If legacy data has multiple invoices, show only the latest one to avoid reporting older invoices as "Unpaid"
|
||||||
->join('users', 'users.id = invoices.parent_id')
|
// after new charges/payments update the current invoice.
|
||||||
->findAll();
|
$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)
|
// Helper to build a fresh, filtered PaymentModel each time (so filters don't get lost between queries)
|
||||||
$buildPaymentModel = function () use ($schoolYear, $dateFrom, $dateTo) {
|
$buildPaymentModel = function () use ($schoolYear, $dateFrom, $dateTo) {
|
||||||
$pm = new PaymentModel();
|
$pm = new PaymentModel();
|
||||||
if ($schoolYear) {
|
|
||||||
$pm->where('school_year', $schoolYear);
|
|
||||||
}
|
|
||||||
if (!empty($dateFrom)) {
|
if (!empty($dateFrom)) {
|
||||||
$pm->where('DATE(payment_date) >=', $dateFrom);
|
$pm->where('DATE(payment_date) >=', $dateFrom);
|
||||||
}
|
}
|
||||||
@@ -114,7 +142,13 @@ public function financialReport()
|
|||||||
};
|
};
|
||||||
|
|
||||||
// === Payments aggregated by invoice_id (for the "Paid" column) ===
|
// === 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')
|
->select('invoice_id, SUM(paid_amount) AS paid_amount')
|
||||||
->where('invoice_id IS NOT NULL')
|
->where('invoice_id IS NOT NULL')
|
||||||
->groupBy('invoice_id')
|
->groupBy('invoice_id')
|
||||||
@@ -138,6 +172,7 @@ public function financialReport()
|
|||||||
SUM(paid_amount) AS amount
|
SUM(paid_amount) AS amount
|
||||||
")
|
")
|
||||||
->where('invoice_id IS NOT NULL')
|
->where('invoice_id IS NOT NULL')
|
||||||
|
->whereIn('invoice_id', $invoiceIds ?: [-1])
|
||||||
->groupBy('invoice_id, method')
|
->groupBy('invoice_id, method')
|
||||||
->findAll();
|
->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
|
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')
|
->where('invoice_id IS NOT NULL')
|
||||||
|
->whereIn('invoice_id', $invoiceIds ?: [-1])
|
||||||
->first() ?? [];
|
->first() ?? [];
|
||||||
|
|
||||||
$paymentTotals = [
|
$paymentTotals = [
|
||||||
@@ -216,25 +252,28 @@ public function financialReport()
|
|||||||
$schoolYears[] = (string)$schoolYear;
|
$schoolYears[] = (string)$schoolYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$eventFeesTotal = $this->getEventFeesTotal($schoolYear, $dateFrom, $dateTo);
|
||||||
|
|
||||||
// JSON API support
|
// JSON API support
|
||||||
if ($this->wantsJson() || strtolower((string)($this->request->getGet('format') ?? '')) === 'json') {
|
if ($this->wantsJson() || strtolower((string)($this->request->getGet('format') ?? '')) === 'json') {
|
||||||
return $this->response->setJSON([
|
return $this->response->setJSON([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'selectedYear' => $schoolYear,
|
'selectedYear' => $schoolYear,
|
||||||
'dateFrom' => $dateFrom,
|
'dateFrom' => $dateFrom,
|
||||||
'dateTo' => $dateTo,
|
'dateTo' => $dateTo,
|
||||||
'schoolYears' => $schoolYears,
|
'schoolYears' => $schoolYears,
|
||||||
'invoices' => $invoices,
|
'invoices' => $invoices,
|
||||||
'payments' => $payments,
|
'payments' => $payments,
|
||||||
'paymentBreakdown' => $paymentBreakdown,
|
'paymentBreakdown' => $paymentBreakdown,
|
||||||
'paymentTotals' => $paymentTotals,
|
'paymentTotals' => $paymentTotals,
|
||||||
'refunds' => $refunds,
|
'refunds' => $refunds,
|
||||||
'expenses' => $expenses,
|
'expenses' => $expenses,
|
||||||
'reimbursements' => $reimbursements,
|
'reimbursements' => $reimbursements,
|
||||||
'discounts' => $discounts,
|
'discounts' => $discounts,
|
||||||
'csrf_token' => csrf_token(),
|
'eventFeesTotal' => $eventFeesTotal,
|
||||||
'csrf_hash' => csrf_hash(),
|
'csrf_token' => csrf_token(),
|
||||||
]);
|
'csrf_hash' => csrf_hash(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('payment/financial_report', [
|
return view('payment/financial_report', [
|
||||||
@@ -246,6 +285,7 @@ public function financialReport()
|
|||||||
'expenses' => $expenses,
|
'expenses' => $expenses,
|
||||||
'reimbursements' => $reimbursements,
|
'reimbursements' => $reimbursements,
|
||||||
'discounts' => $discounts,
|
'discounts' => $discounts,
|
||||||
|
'eventFeesTotal' => $eventFeesTotal,
|
||||||
'selectedYear' => $schoolYear,
|
'selectedYear' => $schoolYear,
|
||||||
'schoolYears' => $schoolYears,
|
'schoolYears' => $schoolYears,
|
||||||
'dateFrom' => $dateFrom,
|
'dateFrom' => $dateFrom,
|
||||||
@@ -1058,6 +1098,7 @@ public function financialReport()
|
|||||||
$amountCollected = $totalPaid;
|
$amountCollected = $totalPaid;
|
||||||
$netAmount = ($totalCharges - $totalDiscounts - $totalRefunds);
|
$netAmount = ($totalCharges - $totalDiscounts - $totalRefunds);
|
||||||
|
|
||||||
|
$totalEventFees = $this->getEventFeesTotal($schoolYear, $invoiceDateFrom, $invoiceDateTo);
|
||||||
return [
|
return [
|
||||||
'schoolYear' => $schoolYear,
|
'schoolYear' => $schoolYear,
|
||||||
'dateFrom' => $dateFrom,
|
'dateFrom' => $dateFrom,
|
||||||
@@ -1073,9 +1114,28 @@ public function financialReport()
|
|||||||
'amountCollected' => $amountCollected,
|
'amountCollected' => $amountCollected,
|
||||||
'totalUnpaid' => $totalUnpaid,
|
'totalUnpaid' => $totalUnpaid,
|
||||||
'netAmount' => $netAmount,
|
'netAmount' => $netAmount,
|
||||||
|
'totalEventFees' => $totalEventFees,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getEventFeesTotal(?string $schoolYear, ?string $dateFrom, ?string $dateTo): float
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$builder = $db->table('event_charges ec')
|
||||||
|
->select('COALESCE(SUM(ec.charged),0) AS amount', false);
|
||||||
|
if (!empty($schoolYear)) {
|
||||||
|
$builder->where('ec.school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
if (!empty($dateFrom)) {
|
||||||
|
$builder->where('DATE(ec.created_at) >=', $dateFrom);
|
||||||
|
}
|
||||||
|
if (!empty($dateTo)) {
|
||||||
|
$builder->where('DATE(ec.created_at) <=', $dateTo);
|
||||||
|
}
|
||||||
|
$row = $builder->get()->getRowArray();
|
||||||
|
return $row ? (float)($row['amount'] ?? 0) : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Management page: list parents with outstanding balances (> 0) for a school year.
|
* Management page: list parents with outstanding balances (> 0) for a school year.
|
||||||
@@ -1226,6 +1286,23 @@ public function financialReport()
|
|||||||
$byParent[$pid]['total_balance'] += $extra;
|
$byParent[$pid]['total_balance'] += $extra;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$eventFeesPerParent = [];
|
||||||
|
try {
|
||||||
|
$eventFeesRows = $db->table('event_charges ec')
|
||||||
|
->select('ec.parent_id, COALESCE(SUM(ec.charged),0) AS event_fees', false)
|
||||||
|
->where('ec.school_year', $schoolYear)
|
||||||
|
->groupBy('ec.parent_id')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
foreach ($eventFeesRows as $row) {
|
||||||
|
$pid = (int)($row['parent_id'] ?? 0);
|
||||||
|
if ($pid <= 0) continue;
|
||||||
|
$eventFeesPerParent[$pid] = (float)($row['event_fees'] ?? 0);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore, fallback to no event fees data
|
||||||
|
}
|
||||||
|
|
||||||
// Reduce into rows list; only parents with positive balance
|
// Reduce into rows list; only parents with positive balance
|
||||||
// Also compute remaining installments and suggested monthly amount
|
// Also compute remaining installments and suggested monthly amount
|
||||||
$rows = [];
|
$rows = [];
|
||||||
@@ -1305,7 +1382,7 @@ public function financialReport()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$dataRows = array_map(function(array $r) use ($hasPayments, $paidTotals, $paymentCounts, $nextInstallmentYmd) {
|
$dataRows = array_map(function(array $r) use ($hasPayments, $paidTotals, $paymentCounts, $nextInstallmentYmd, $eventFeesPerParent) {
|
||||||
$pid = (int)($r['parent_id'] ?? 0);
|
$pid = (int)($r['parent_id'] ?? 0);
|
||||||
$name = trim((string)($r['firstname'] ?? '') . ' ' . (string)($r['lastname'] ?? ''));
|
$name = trim((string)($r['firstname'] ?? '') . ' ' . (string)($r['lastname'] ?? ''));
|
||||||
return [
|
return [
|
||||||
@@ -1322,6 +1399,7 @@ public function financialReport()
|
|||||||
'payment_count' => isset($paymentCounts[$pid]) ? (int)$paymentCounts[$pid] : 0,
|
'payment_count' => isset($paymentCounts[$pid]) ? (int)$paymentCounts[$pid] : 0,
|
||||||
'has_installment'=> isset($hasPayments[$pid]) ? 1 : 0,
|
'has_installment'=> isset($hasPayments[$pid]) ? 1 : 0,
|
||||||
'next_installment' => $nextInstallmentYmd,
|
'next_installment' => $nextInstallmentYmd,
|
||||||
|
'event_fees' => (float)($eventFeesPerParent[$pid] ?? 0),
|
||||||
];
|
];
|
||||||
}, $rows);
|
}, $rows);
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ use App\Models\PlacementLevelModel;
|
|||||||
use App\Models\PlacementBatchModel;
|
use App\Models\PlacementBatchModel;
|
||||||
use App\Models\PlacementScoreModel;
|
use App\Models\PlacementScoreModel;
|
||||||
use App\Models\GradingLockModel;
|
use App\Models\GradingLockModel;
|
||||||
|
use App\Models\BelowSixtyDecisionModel;
|
||||||
|
use App\Models\StudentDecisionModel;
|
||||||
use App\Services\NavbarService;
|
use App\Services\NavbarService;
|
||||||
|
|
||||||
//use App\Models\ScoreModel;
|
//use App\Models\ScoreModel;
|
||||||
@@ -1074,7 +1076,8 @@ class GradingController extends Controller
|
|||||||
$requestedSemester = trim((string)($this->request->getGet('semester') ?? ''));
|
$requestedSemester = trim((string)($this->request->getGet('semester') ?? ''));
|
||||||
$requestedYear = trim((string)($this->request->getGet('school_year') ?? ''));
|
$requestedYear = trim((string)($this->request->getGet('school_year') ?? ''));
|
||||||
|
|
||||||
$semester = $requestedSemester !== '' ? $requestedSemester : ($configuredSemester !== '' ? $configuredSemester : 'Fall');
|
$isYearMode = (strtolower($requestedSemester) === 'year');
|
||||||
|
$semester = $isYearMode ? 'year' : ($requestedSemester !== '' ? $requestedSemester : ($configuredSemester !== '' ? $configuredSemester : 'Fall'));
|
||||||
$schoolYear = $requestedYear !== '' ? $requestedYear : $configuredYear;
|
$schoolYear = $requestedYear !== '' ? $requestedYear : $configuredYear;
|
||||||
|
|
||||||
$schoolYears = $this->getSchoolYearsForScores($schoolYear);
|
$schoolYears = $this->getSchoolYearsForScores($schoolYear);
|
||||||
@@ -1088,6 +1091,8 @@ class GradingController extends Controller
|
|||||||
'schoolYear' => $schoolYear,
|
'schoolYear' => $schoolYear,
|
||||||
'schoolYears' => $schoolYears,
|
'schoolYears' => $schoolYears,
|
||||||
'canViewGrading' => $canViewGrading,
|
'canViewGrading' => $canViewGrading,
|
||||||
|
'isYearMode' => $isYearMode,
|
||||||
|
'showAllSemesterOption' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1760,14 +1765,16 @@ class GradingController extends Controller
|
|||||||
|
|
||||||
private function fetchBelowSixtyRows(string $schoolYear, string $semester): array
|
private function fetchBelowSixtyRows(string $schoolYear, string $semester): array
|
||||||
{
|
{
|
||||||
$semesterKey = strtolower(trim($semester));
|
$isYearMode = strtolower(trim($semester)) === 'year';
|
||||||
$rows = $this->db->table('semester_scores ss')
|
$semesterKey = $isYearMode ? '' : strtolower(trim($semester));
|
||||||
|
$builder = $this->db->table('semester_scores ss')
|
||||||
->select([
|
->select([
|
||||||
's.id AS student_id',
|
's.id AS student_id',
|
||||||
's.school_id',
|
's.school_id',
|
||||||
's.firstname',
|
's.firstname',
|
||||||
's.lastname',
|
's.lastname',
|
||||||
'cs.class_section_name',
|
'cs.class_section_name',
|
||||||
|
'ss.semester',
|
||||||
'ss.homework_avg',
|
'ss.homework_avg',
|
||||||
'ss.project_avg',
|
'ss.project_avg',
|
||||||
'ss.participation_score',
|
'ss.participation_score',
|
||||||
@@ -1782,11 +1789,17 @@ class GradingController extends Controller
|
|||||||
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
|
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
|
||||||
->where('s.is_active', 1)
|
->where('s.is_active', 1)
|
||||||
->where('ss.school_year', $schoolYear)
|
->where('ss.school_year', $schoolYear)
|
||||||
->where("LOWER(TRIM(ss.semester))", $semesterKey)
|
|
||||||
->where('ss.semester_score IS NOT NULL', null, false)
|
->where('ss.semester_score IS NOT NULL', null, false)
|
||||||
->where('ss.semester_score <', 60)
|
->where('ss.semester_score <', 60);
|
||||||
|
|
||||||
|
if (!$isYearMode) {
|
||||||
|
$builder->where("LOWER(TRIM(ss.semester))", $semesterKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $builder
|
||||||
->orderBy('s.lastname', 'ASC')
|
->orderBy('s.lastname', 'ASC')
|
||||||
->orderBy('s.firstname', 'ASC')
|
->orderBy('s.firstname', 'ASC')
|
||||||
|
->orderBy('ss.semester', 'ASC')
|
||||||
->get()
|
->get()
|
||||||
->getResultArray();
|
->getResultArray();
|
||||||
|
|
||||||
@@ -1802,19 +1815,22 @@ class GradingController extends Controller
|
|||||||
|
|
||||||
$commentMap = [];
|
$commentMap = [];
|
||||||
if (!empty($studentIds)) {
|
if (!empty($studentIds)) {
|
||||||
$commentRows = $this->db->table('score_comments')
|
$commentBuilder = $this->db->table('score_comments')
|
||||||
->select('student_id, comment, created_at')
|
->select('student_id, semester, comment, created_at')
|
||||||
->where('score_type', 'general')
|
->where('score_type', 'general')
|
||||||
->where('school_year', $schoolYear)
|
->where('school_year', $schoolYear)
|
||||||
->where("LOWER(TRIM(semester))", $semesterKey)
|
|
||||||
->whereIn('student_id', $studentIds)
|
->whereIn('student_id', $studentIds)
|
||||||
->orderBy('created_at', 'DESC')
|
->orderBy('created_at', 'DESC');
|
||||||
->get()
|
if (!$isYearMode) {
|
||||||
->getResultArray();
|
$commentBuilder->where("LOWER(TRIM(semester))", $semesterKey);
|
||||||
|
}
|
||||||
|
$commentRows = $commentBuilder->get()->getResultArray();
|
||||||
foreach ($commentRows as $row) {
|
foreach ($commentRows as $row) {
|
||||||
$sid = (int)($row['student_id'] ?? 0);
|
$sid = (int)($row['student_id'] ?? 0);
|
||||||
if ($sid > 0 && !isset($commentMap[$sid])) {
|
$sem = strtolower(trim((string)($row['semester'] ?? '')));
|
||||||
$commentMap[$sid] = (string)($row['comment'] ?? '');
|
$key = $sid . '_' . $sem;
|
||||||
|
if ($sid > 0 && !isset($commentMap[$key])) {
|
||||||
|
$commentMap[$key] = (string)($row['comment'] ?? '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1822,21 +1838,24 @@ class GradingController extends Controller
|
|||||||
$statusMap = [];
|
$statusMap = [];
|
||||||
$noteMap = [];
|
$noteMap = [];
|
||||||
if (!empty($studentIds)) {
|
if (!empty($studentIds)) {
|
||||||
$flagRows = $this->db->table('current_flag')
|
$flagBuilder = $this->db->table('current_flag')
|
||||||
->select('student_id, flag_state, open_description, close_description')
|
->select('student_id, semester, flag_state, open_description, close_description')
|
||||||
->where('flag', 'grade')
|
->where('flag', 'grade')
|
||||||
->where('school_year', $schoolYear)
|
->where('school_year', $schoolYear)
|
||||||
->where("LOWER(TRIM(semester))", $semesterKey)
|
->whereIn('student_id', $studentIds);
|
||||||
->whereIn('student_id', $studentIds)
|
if (!$isYearMode) {
|
||||||
->get()
|
$flagBuilder->where("LOWER(TRIM(semester))", $semesterKey);
|
||||||
->getResultArray();
|
}
|
||||||
|
$flagRows = $flagBuilder->get()->getResultArray();
|
||||||
foreach ($flagRows as $row) {
|
foreach ($flagRows as $row) {
|
||||||
$sid = (int)($row['student_id'] ?? 0);
|
$sid = (int)($row['student_id'] ?? 0);
|
||||||
if ($sid <= 0) continue;
|
if ($sid <= 0) continue;
|
||||||
$statusMap[$sid] = (string)($row['flag_state'] ?? '');
|
$sem = strtolower(trim((string)($row['semester'] ?? '')));
|
||||||
|
$key = $sid . '_' . $sem;
|
||||||
|
$statusMap[$key] = (string)($row['flag_state'] ?? '');
|
||||||
$openNote = trim((string)($row['open_description'] ?? ''));
|
$openNote = trim((string)($row['open_description'] ?? ''));
|
||||||
$closeNote = trim((string)($row['close_description'] ?? ''));
|
$closeNote = trim((string)($row['close_description'] ?? ''));
|
||||||
$noteMap[$sid] = [
|
$noteMap[$key] = [
|
||||||
'open' => $openNote,
|
'open' => $openNote,
|
||||||
'closed' => $closeNote,
|
'closed' => $closeNote,
|
||||||
];
|
];
|
||||||
@@ -1845,10 +1864,12 @@ class GradingController extends Controller
|
|||||||
|
|
||||||
foreach ($rows as &$row) {
|
foreach ($rows as &$row) {
|
||||||
$sid = (int)($row['student_id'] ?? 0);
|
$sid = (int)($row['student_id'] ?? 0);
|
||||||
$row['comment'] = $commentMap[$sid] ?? '';
|
$sem = strtolower(trim((string)($row['semester'] ?? '')));
|
||||||
$flagState = strtolower(trim((string)($statusMap[$sid] ?? '')));
|
$key = $sid . '_' . $sem;
|
||||||
|
$row['comment'] = $commentMap[$key] ?? '';
|
||||||
|
$flagState = strtolower(trim((string)($statusMap[$key] ?? '')));
|
||||||
$row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open';
|
$row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open';
|
||||||
$noteBag = $noteMap[$sid] ?? ['open' => '', 'closed' => ''];
|
$noteBag = $noteMap[$key] ?? ['open' => '', 'closed' => ''];
|
||||||
$rawNote = $row['status'] === 'Closed' ? (string)$noteBag['closed'] : (string)$noteBag['open'];
|
$rawNote = $row['status'] === 'Closed' ? (string)$noteBag['closed'] : (string)$noteBag['open'];
|
||||||
if ($rawNote !== '') {
|
if ($rawNote !== '') {
|
||||||
$lines = preg_split('/\R/', $rawNote);
|
$lines = preg_split('/\R/', $rawNote);
|
||||||
@@ -1954,6 +1975,73 @@ class GradingController extends Controller
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function fetchAllSemestersForStudent(int $studentId, string $schoolYear): array
|
||||||
|
{
|
||||||
|
$rows = $this->db->table('semester_scores ss')
|
||||||
|
->select([
|
||||||
|
'ss.semester',
|
||||||
|
'cs.class_section_name',
|
||||||
|
'ss.homework_avg',
|
||||||
|
'ss.project_avg',
|
||||||
|
'ss.participation_score',
|
||||||
|
'COALESCE(ss.test_avg, ss.quiz_avg) AS test_avg',
|
||||||
|
'ss.ptap_score',
|
||||||
|
'ss.attendance_score',
|
||||||
|
'ss.midterm_exam_score',
|
||||||
|
'ss.final_exam_score',
|
||||||
|
'ss.semester_score',
|
||||||
|
])
|
||||||
|
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
|
||||||
|
->where('ss.student_id', $studentId)
|
||||||
|
->where('ss.school_year', $schoolYear)
|
||||||
|
->orderBy('ss.semester', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
$semesters = [];
|
||||||
|
foreach ($rows as $sr) {
|
||||||
|
$sem = ucfirst(strtolower(trim((string)($sr['semester'] ?? ''))));
|
||||||
|
$semesters[$sem] = [
|
||||||
|
'semester' => $sem,
|
||||||
|
'class_section_name' => $sr['class_section_name'] ?? '',
|
||||||
|
'homework_avg' => $sr['homework_avg'] ?? null,
|
||||||
|
'project_avg' => $sr['project_avg'] ?? null,
|
||||||
|
'participation_score' => $sr['participation_score'] ?? null,
|
||||||
|
'test_avg' => $sr['test_avg'] ?? null,
|
||||||
|
'ptap_score' => $sr['ptap_score'] ?? null,
|
||||||
|
'attendance_score' => $sr['attendance_score'] ?? null,
|
||||||
|
'midterm_exam_score' => $sr['midterm_exam_score'] ?? null,
|
||||||
|
'final_exam_score' => $sr['final_exam_score'] ?? null,
|
||||||
|
'semester_score' => $sr['semester_score'] ?? null,
|
||||||
|
'comments' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($semesters)) {
|
||||||
|
$commentRows = $this->db->table('score_comments')
|
||||||
|
->select('semester, score_type, comment, created_at')
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->where('comment IS NOT NULL', null, false)
|
||||||
|
->where('comment !=', '')
|
||||||
|
->orderBy('semester', 'ASC')
|
||||||
|
->orderBy('score_type', 'ASC')
|
||||||
|
->orderBy('created_at', 'DESC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($commentRows as $c) {
|
||||||
|
$sem = ucfirst(strtolower(trim((string)($c['semester'] ?? ''))));
|
||||||
|
$type = strtolower(trim((string)($c['score_type'] ?? 'general')));
|
||||||
|
if (isset($semesters[$sem]) && !isset($semesters[$sem]['comments'][$type])) {
|
||||||
|
$semesters[$sem]['comments'][$type] = (string)($c['comment'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($semesters);
|
||||||
|
}
|
||||||
|
|
||||||
private function fetchBelowSixtyParentName(int $studentId): string
|
private function fetchBelowSixtyParentName(int $studentId): string
|
||||||
{
|
{
|
||||||
$parentName = 'Parent/Guardian';
|
$parentName = 'Parent/Guardian';
|
||||||
@@ -2124,6 +2212,622 @@ class GradingController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function belowSixtyDecisions()
|
||||||
|
{
|
||||||
|
$configuredSemester = (string) $this->semester;
|
||||||
|
$configuredYear = (string) $this->schoolYear;
|
||||||
|
|
||||||
|
$semester = trim((string)($this->request->getGet('semester') ?? ''));
|
||||||
|
$schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
|
||||||
|
if ($semester === '') $semester = $configuredSemester !== '' ? $configuredSemester : 'Fall';
|
||||||
|
if ($schoolYear === '') $schoolYear = $configuredYear;
|
||||||
|
|
||||||
|
$schoolYears = $this->getSchoolYearsForScores($schoolYear);
|
||||||
|
$rows = $this->fetchBelowSixtyRows($schoolYear, $semester);
|
||||||
|
|
||||||
|
$decisionModel = new BelowSixtyDecisionModel();
|
||||||
|
$studentIds = array_values(array_unique(array_filter(
|
||||||
|
array_map(static fn($r) => (int)($r['student_id'] ?? 0), $rows),
|
||||||
|
static fn($id) => $id > 0
|
||||||
|
)));
|
||||||
|
|
||||||
|
$decisionMap = [];
|
||||||
|
if (!empty($studentIds)) {
|
||||||
|
$dRows = $decisionModel
|
||||||
|
->whereIn('student_id', $studentIds)
|
||||||
|
->where('semester', $semester)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->findAll();
|
||||||
|
foreach ($dRows as $d) {
|
||||||
|
$decisionMap[(int)$d['student_id']] = $d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$sid = (int)($row['student_id'] ?? 0);
|
||||||
|
$row['decision'] = $decisionMap[$sid]['decision'] ?? '';
|
||||||
|
$row['decision_notes'] = $decisionMap[$sid]['notes'] ?? '';
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
// Load consolidated decisions from student_decisions for this term
|
||||||
|
$sdModel = new StudentDecisionModel();
|
||||||
|
$sdRows = $sdModel
|
||||||
|
->where('semester', $semester)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->findAll();
|
||||||
|
$sdMap = [];
|
||||||
|
foreach ($sdRows as $sd) {
|
||||||
|
$sdMap[(int)$sd['student_id']] = $sd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the most recent certificate per student for this school_year
|
||||||
|
$studentIds = array_values(array_unique(array_filter(
|
||||||
|
array_map(static fn($r) => (int)($r['student_id'] ?? 0), $rows),
|
||||||
|
static fn($id) => $id > 0
|
||||||
|
)));
|
||||||
|
$certMap = [];
|
||||||
|
if (!empty($studentIds)) {
|
||||||
|
$certRows = $this->db->table('certificate_records')
|
||||||
|
->select('student_id, certificate_number, issued_at')
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->whereIn('student_id', $studentIds)
|
||||||
|
->orderBy('issued_at', 'DESC')
|
||||||
|
->get()->getResultArray();
|
||||||
|
foreach ($certRows as $cr) {
|
||||||
|
$sid = (int)($cr['student_id'] ?? 0);
|
||||||
|
if ($sid > 0 && !isset($certMap[$sid])) {
|
||||||
|
$certMap[$sid] = (string)($cr['certificate_number'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rows as &$row) {
|
||||||
|
$sid = (int)($row['student_id'] ?? 0);
|
||||||
|
$row['consolidated_decision'] = $sdMap[$sid]['decision'] ?? null;
|
||||||
|
$row['certificate_number'] = $certMap[$sid] ?? '';
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
$canViewGrading = $this->userHasMenuUrl('grading');
|
||||||
|
|
||||||
|
return view('grading/below_sixty_decisions', [
|
||||||
|
'rows' => $rows,
|
||||||
|
'semester' => $semester,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
'schoolYears' => $schoolYears,
|
||||||
|
'canViewGrading' => $canViewGrading,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveBelowSixtyDecision()
|
||||||
|
{
|
||||||
|
$studentId = (int)$this->request->getPost('student_id');
|
||||||
|
$semester = trim((string)$this->request->getPost('semester'));
|
||||||
|
$schoolYear = trim((string)$this->request->getPost('school_year'));
|
||||||
|
$decision = trim((string)$this->request->getPost('decision'));
|
||||||
|
$notes = trim((string)$this->request->getPost('notes'));
|
||||||
|
|
||||||
|
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
|
||||||
|
return redirect()->back()->with('error', 'Missing required data.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = ['', 'Pass', 'Repeat Class', 'Make-up exam in fall', 'Deferred decision', 'Expel', 'Withdrawn'];
|
||||||
|
if (!in_array($decision, $allowed, true)) {
|
||||||
|
return redirect()->back()->with('error', 'Invalid decision value.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$decisionModel = new BelowSixtyDecisionModel();
|
||||||
|
$existing = $decisionModel
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->where('semester', $semester)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$userId = (int)(session()->get('user_id') ?? 0) ?: null;
|
||||||
|
$payload = [
|
||||||
|
'decision' => $decision !== '' ? $decision : null,
|
||||||
|
'notes' => $notes !== '' ? $notes : null,
|
||||||
|
'decided_by' => $userId,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$decisionModel->update((int)$existing['id'], $payload);
|
||||||
|
} else {
|
||||||
|
$payload['student_id'] = $studentId;
|
||||||
|
$payload['semester'] = $semester;
|
||||||
|
$payload['school_year'] = $schoolYear;
|
||||||
|
$decisionModel->insert($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = http_build_query(['semester' => $semester, 'school_year' => $schoolYear]);
|
||||||
|
return redirect()->to(base_url('grading/below-60/decisions') . ($query ? '?' . $query : ''))
|
||||||
|
->with('status', 'Decision saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function studentDecisionDetails()
|
||||||
|
{
|
||||||
|
$studentId = (int)$this->request->getGet('student_id');
|
||||||
|
$schoolYear = trim((string)$this->request->getGet('school_year'));
|
||||||
|
|
||||||
|
if ($studentId <= 0 || $schoolYear === '') {
|
||||||
|
return $this->response->setJSON(['error' => 'Missing student or school year.'])->setStatusCode(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'semesters' => $this->fetchAllSemestersForStudent($studentId, $schoolYear),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previewDecisionEmail()
|
||||||
|
{
|
||||||
|
$studentId = (int)$this->request->getGet('student_id');
|
||||||
|
$semester = trim((string)$this->request->getGet('semester'));
|
||||||
|
$schoolYear = trim((string)$this->request->getGet('school_year'));
|
||||||
|
|
||||||
|
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
|
||||||
|
return $this->response->setJSON(['error' => 'Missing student or term.'])->setStatusCode(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
|
||||||
|
if (empty($row)) {
|
||||||
|
return $this->response->setJSON(['error' => 'Student not found.'])->setStatusCode(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$decisionModel = new BelowSixtyDecisionModel();
|
||||||
|
$decisionRow = $decisionModel
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->where('semester', $semester)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$decision = (string)($decisionRow['decision'] ?? '');
|
||||||
|
$notes = (string)($decisionRow['notes'] ?? '');
|
||||||
|
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||||
|
$parentName = $this->fetchBelowSixtyParentName($studentId);
|
||||||
|
|
||||||
|
$subject = 'Academic Decision';
|
||||||
|
if ($studentName !== '') $subject .= ' — ' . $studentName;
|
||||||
|
if ($semester !== '' || $schoolYear !== '') {
|
||||||
|
$subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
$scores = [
|
||||||
|
'homework_avg' => $row['homework_avg'] ?? null,
|
||||||
|
'project_avg' => $row['project_avg'] ?? null,
|
||||||
|
'participation_score' => $row['participation_score'] ?? null,
|
||||||
|
'test_avg' => $row['test_avg'] ?? null,
|
||||||
|
'ptap_score' => $row['ptap_score'] ?? null,
|
||||||
|
'attendance_score' => $row['attendance_score'] ?? null,
|
||||||
|
'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
|
||||||
|
'semester_score' => $row['semester_score'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fetch all semesters' scores + comments for the email
|
||||||
|
$allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear);
|
||||||
|
|
||||||
|
$html = view('emails/below_sixty_decision', [
|
||||||
|
'title' => $subject,
|
||||||
|
'parent_name' => $parentName,
|
||||||
|
'student_name' => $studentName !== '' ? $studentName : 'your student',
|
||||||
|
'class_section_name' => $row['class_section_name'] ?? '',
|
||||||
|
'semester' => $semester,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'decision' => $decision,
|
||||||
|
'notes' => $notes,
|
||||||
|
'scores' => $scores,
|
||||||
|
'all_semesters' => array_values($allSemesters),
|
||||||
|
], ['saveData' => true]);
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'subject' => $subject,
|
||||||
|
'html' => $html,
|
||||||
|
'student_id' => $studentId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function editDecisionEmail()
|
||||||
|
{
|
||||||
|
$studentId = (int)$this->request->getGet('student_id');
|
||||||
|
$semester = trim((string)$this->request->getGet('semester'));
|
||||||
|
$schoolYear = trim((string)$this->request->getGet('school_year'));
|
||||||
|
|
||||||
|
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
|
||||||
|
return redirect()->back()->with('error', 'Missing student or term.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
|
||||||
|
if (empty($row)) {
|
||||||
|
return redirect()->back()->with('error', 'Student record not found for the selected term.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$decisionModel = new BelowSixtyDecisionModel();
|
||||||
|
$decisionRow = $decisionModel
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->where('semester', $semester)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$decision = (string)($decisionRow['decision'] ?? '');
|
||||||
|
$notes = (string)($decisionRow['notes'] ?? '');
|
||||||
|
|
||||||
|
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||||
|
$parentName = $this->fetchBelowSixtyParentName($studentId);
|
||||||
|
|
||||||
|
$subject = 'Academic Decision';
|
||||||
|
if ($studentName !== '') $subject .= ' — ' . $studentName;
|
||||||
|
if ($semester !== '' || $schoolYear !== '') {
|
||||||
|
$subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
$scores = [
|
||||||
|
'homework_avg' => $row['homework_avg'] ?? null,
|
||||||
|
'project_avg' => $row['project_avg'] ?? null,
|
||||||
|
'participation_score' => $row['participation_score'] ?? null,
|
||||||
|
'test_avg' => $row['test_avg'] ?? null,
|
||||||
|
'ptap_score' => $row['ptap_score'] ?? null,
|
||||||
|
'attendance_score' => $row['attendance_score'] ?? null,
|
||||||
|
'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
|
||||||
|
'semester_score' => $row['semester_score'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$html = view('emails/below_sixty_decision', [
|
||||||
|
'title' => $subject,
|
||||||
|
'parent_name' => $parentName,
|
||||||
|
'student_name' => $studentName !== '' ? $studentName : 'your student',
|
||||||
|
'class_section_name' => $row['class_section_name'] ?? '',
|
||||||
|
'semester' => $semester,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'decision' => $decision,
|
||||||
|
'notes' => $notes,
|
||||||
|
'scores' => $scores,
|
||||||
|
], ['saveData' => true]);
|
||||||
|
|
||||||
|
return view('grading/below_sixty_decision_email_editor', [
|
||||||
|
'studentId' => $studentId,
|
||||||
|
'studentName' => $studentName,
|
||||||
|
'semester' => $semester,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
'subject' => $subject,
|
||||||
|
'html' => $html,
|
||||||
|
'decision' => $decision,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendDecisionEmail()
|
||||||
|
{
|
||||||
|
$studentId = (int)$this->request->getPost('student_id');
|
||||||
|
$semester = trim((string)$this->request->getPost('semester'));
|
||||||
|
$schoolYear = trim((string)$this->request->getPost('school_year'));
|
||||||
|
$subjectInput= trim((string)$this->request->getPost('subject'));
|
||||||
|
$htmlInput = (string)($this->request->getPost('html') ?? '');
|
||||||
|
|
||||||
|
if ($studentId <= 0 || $semester === '' || $schoolYear === '') {
|
||||||
|
return redirect()->back()->with('error', 'Missing student or term.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
|
||||||
|
if (empty($row)) {
|
||||||
|
return redirect()->back()->with('error', 'Student record not found for the selected term.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$decisionModel = new BelowSixtyDecisionModel();
|
||||||
|
$decisionRow = $decisionModel
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->where('semester', $semester)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||||
|
$subject = $subjectInput !== '' ? $subjectInput : ('Academic Decision — ' . $studentName . ' (' . trim($semester . ' ' . $schoolYear) . ')');
|
||||||
|
|
||||||
|
$allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'student_id' => $studentId,
|
||||||
|
'student_name' => $studentName,
|
||||||
|
'class_section_name' => $row['class_section_name'] ?? '',
|
||||||
|
'semester' => $semester,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'decision' => (string)($decisionRow['decision'] ?? ''),
|
||||||
|
'notes' => (string)($decisionRow['notes'] ?? ''),
|
||||||
|
'subject' => $subject,
|
||||||
|
'all_semesters' => $allSemesters,
|
||||||
|
'scores' => [
|
||||||
|
'homework_avg' => $row['homework_avg'] ?? null,
|
||||||
|
'project_avg' => $row['project_avg'] ?? null,
|
||||||
|
'participation_score' => $row['participation_score'] ?? null,
|
||||||
|
'test_avg' => $row['test_avg'] ?? null,
|
||||||
|
'ptap_score' => $row['ptap_score'] ?? null,
|
||||||
|
'attendance_score' => $row['attendance_score'] ?? null,
|
||||||
|
'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
|
||||||
|
'semester_score' => $row['semester_score'] ?? null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (trim($htmlInput) !== '') {
|
||||||
|
$payload['html'] = $htmlInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
\CodeIgniter\Events\Events::trigger('below60.decision_email', $payload);
|
||||||
|
|
||||||
|
$query = http_build_query(['semester' => $semester, 'school_year' => $schoolYear]);
|
||||||
|
return redirect()->to(base_url('grading/below-60/decisions') . ($query ? '?' . $query : ''))
|
||||||
|
->with('status', 'Decision email sent to parent(s).');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allDecisions()
|
||||||
|
{
|
||||||
|
$configuredYear = (string)$this->schoolYear;
|
||||||
|
|
||||||
|
$schoolYear = trim((string)($this->request->getGet('school_year') ?? ''));
|
||||||
|
if ($schoolYear === '') $schoolYear = $configuredYear;
|
||||||
|
|
||||||
|
$schoolYears = $this->getSchoolYearsForScores($schoolYear);
|
||||||
|
|
||||||
|
// Load saved year decisions (semester='year') for this school year
|
||||||
|
$decModel = new StudentDecisionModel();
|
||||||
|
$saved = $decModel
|
||||||
|
->where('semester', 'year')
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->findAll();
|
||||||
|
$savedMap = [];
|
||||||
|
foreach ($saved as $s) {
|
||||||
|
$savedMap[(int)$s['student_id']] = $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Fall and Spring semester_scores per student for this school year
|
||||||
|
$allScoreRows = $this->db->table('semester_scores ss')
|
||||||
|
->select([
|
||||||
|
's.id AS student_id',
|
||||||
|
's.school_id',
|
||||||
|
's.firstname',
|
||||||
|
's.lastname',
|
||||||
|
'cs.class_section_name',
|
||||||
|
'LOWER(TRIM(ss.semester)) AS sem_key',
|
||||||
|
'ss.semester_score',
|
||||||
|
])
|
||||||
|
->join('students s', 's.id = ss.student_id', 'inner')
|
||||||
|
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
|
||||||
|
->where('s.is_active', 1)
|
||||||
|
->where('ss.school_year', $schoolYear)
|
||||||
|
->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
|
||||||
|
->where('ss.semester_score IS NOT NULL', null, false)
|
||||||
|
->orderBy('cs.class_section_name', 'ASC')
|
||||||
|
->orderBy('s.lastname', 'ASC')
|
||||||
|
->orderBy('s.firstname', 'ASC')
|
||||||
|
->get()->getResultArray();
|
||||||
|
|
||||||
|
// Group by student: keep one base info row + fall/spring scores
|
||||||
|
$studentMap = [];
|
||||||
|
foreach ($allScoreRows as $sr) {
|
||||||
|
$sid = (int)$sr['student_id'];
|
||||||
|
if (!isset($studentMap[$sid])) {
|
||||||
|
$studentMap[$sid] = [
|
||||||
|
'student_id' => $sid,
|
||||||
|
'school_id' => $sr['school_id'] ?? '',
|
||||||
|
'firstname' => $sr['firstname'] ?? '',
|
||||||
|
'lastname' => $sr['lastname'] ?? '',
|
||||||
|
'class_section_name' => $sr['class_section_name'] ?? '',
|
||||||
|
'fall_score' => null,
|
||||||
|
'spring_score' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$semKey = strtolower(trim((string)($sr['sem_key'] ?? '')));
|
||||||
|
$val = is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null;
|
||||||
|
if ($semKey === 'fall') {
|
||||||
|
$studentMap[$sid]['fall_score'] = $val;
|
||||||
|
} elseif ($semKey === 'spring') {
|
||||||
|
$studentMap[$sid]['spring_score'] = $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull below-60 decisions for either semester (use worst available)
|
||||||
|
$belowDecModel = new BelowSixtyDecisionModel();
|
||||||
|
$belowRows = $belowDecModel
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->findAll();
|
||||||
|
$belowMap = [];
|
||||||
|
foreach ($belowRows as $b) {
|
||||||
|
$sid = (int)$b['student_id'];
|
||||||
|
// prefer a non-empty decision over empty
|
||||||
|
if (!isset($belowMap[$sid]) || (string)($belowMap[$sid]['decision'] ?? '') === '') {
|
||||||
|
$belowMap[$sid] = $b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build final rows with year_score = (fall + spring) / 2
|
||||||
|
$rows = [];
|
||||||
|
foreach ($studentMap as $sid => $info) {
|
||||||
|
$fall = $info['fall_score'];
|
||||||
|
$spring = $info['spring_score'];
|
||||||
|
|
||||||
|
if ($fall !== null && $spring !== null) {
|
||||||
|
$yearScore = round(($fall + $spring) / 2, 2);
|
||||||
|
} elseif ($fall !== null) {
|
||||||
|
$yearScore = $fall;
|
||||||
|
} elseif ($spring !== null) {
|
||||||
|
$yearScore = $spring;
|
||||||
|
} else {
|
||||||
|
$yearScore = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($savedMap[$sid])) {
|
||||||
|
$decision = (string)($savedMap[$sid]['decision'] ?? '');
|
||||||
|
$source = (string)($savedMap[$sid]['source'] ?? 'auto');
|
||||||
|
$notes = (string)($savedMap[$sid]['notes'] ?? '');
|
||||||
|
} elseif ($yearScore !== null && $yearScore >= 60) {
|
||||||
|
$decision = 'Pass';
|
||||||
|
$source = 'auto';
|
||||||
|
$notes = '';
|
||||||
|
} elseif ($yearScore !== null && isset($belowMap[$sid])) {
|
||||||
|
$decision = (string)($belowMap[$sid]['decision'] ?? '');
|
||||||
|
$source = $decision !== '' ? 'manual' : 'pending';
|
||||||
|
$notes = (string)($belowMap[$sid]['notes'] ?? '');
|
||||||
|
} else {
|
||||||
|
$decision = '';
|
||||||
|
$source = 'pending';
|
||||||
|
$notes = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'student_id' => $sid,
|
||||||
|
'school_id' => $info['school_id'],
|
||||||
|
'firstname' => $info['firstname'],
|
||||||
|
'lastname' => $info['lastname'],
|
||||||
|
'class_section_name' => $info['class_section_name'],
|
||||||
|
'fall_score' => $fall,
|
||||||
|
'spring_score' => $spring,
|
||||||
|
'year_score' => $yearScore,
|
||||||
|
'decision' => $decision,
|
||||||
|
'source' => $source,
|
||||||
|
'notes' => $notes,
|
||||||
|
'saved' => isset($savedMap[$sid]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$generated = !empty($saved);
|
||||||
|
|
||||||
|
return view('grading/all_decisions', [
|
||||||
|
'rows' => $rows,
|
||||||
|
'schoolYear' => $schoolYear,
|
||||||
|
'schoolYears' => $schoolYears,
|
||||||
|
'generated' => $generated,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateAllDecisions()
|
||||||
|
{
|
||||||
|
$schoolYear = trim((string)$this->request->getPost('school_year'));
|
||||||
|
|
||||||
|
if ($schoolYear === '') {
|
||||||
|
return redirect()->back()->with('error', 'Missing school year.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Fall and Spring scores per student
|
||||||
|
$allScoreRows = $this->db->table('semester_scores ss')
|
||||||
|
->select([
|
||||||
|
's.id AS student_id',
|
||||||
|
's.firstname',
|
||||||
|
's.lastname',
|
||||||
|
'cs.class_section_name',
|
||||||
|
'LOWER(TRIM(ss.semester)) AS sem_key',
|
||||||
|
'ss.semester_score',
|
||||||
|
])
|
||||||
|
->join('students s', 's.id = ss.student_id', 'inner')
|
||||||
|
->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left')
|
||||||
|
->where('s.is_active', 1)
|
||||||
|
->where('ss.school_year', $schoolYear)
|
||||||
|
->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring'])
|
||||||
|
->where('ss.semester_score IS NOT NULL', null, false)
|
||||||
|
->get()->getResultArray();
|
||||||
|
|
||||||
|
if (empty($allScoreRows)) {
|
||||||
|
return redirect()->back()->with('error', 'No semester scores found for this school year.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by student
|
||||||
|
$studentMap = [];
|
||||||
|
foreach ($allScoreRows as $sr) {
|
||||||
|
$sid = (int)$sr['student_id'];
|
||||||
|
if (!isset($studentMap[$sid])) {
|
||||||
|
$studentMap[$sid] = [
|
||||||
|
'firstname' => $sr['firstname'] ?? '',
|
||||||
|
'lastname' => $sr['lastname'] ?? '',
|
||||||
|
'class_section_name' => $sr['class_section_name'] ?? '',
|
||||||
|
'fall_score' => null,
|
||||||
|
'spring_score' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$semKey = strtolower(trim((string)($sr['sem_key'] ?? '')));
|
||||||
|
$val = is_numeric($sr['semester_score']) ? (float)$sr['semester_score'] : null;
|
||||||
|
if ($semKey === 'fall') {
|
||||||
|
$studentMap[$sid]['fall_score'] = $val;
|
||||||
|
} elseif ($semKey === 'spring') {
|
||||||
|
$studentMap[$sid]['spring_score'] = $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull below-60 decisions for any semester of this year
|
||||||
|
$belowDecModel = new BelowSixtyDecisionModel();
|
||||||
|
$belowRows = $belowDecModel
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->findAll();
|
||||||
|
$belowMap = [];
|
||||||
|
foreach ($belowRows as $b) {
|
||||||
|
$sid = (int)$b['student_id'];
|
||||||
|
if (!isset($belowMap[$sid]) || (string)($belowMap[$sid]['decision'] ?? '') === '') {
|
||||||
|
$belowMap[$sid] = $b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing year decisions to upsert
|
||||||
|
$decModel = new StudentDecisionModel();
|
||||||
|
$existing = $decModel
|
||||||
|
->where('semester', 'year')
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->findAll();
|
||||||
|
$existingMap = [];
|
||||||
|
foreach ($existing as $e) {
|
||||||
|
$existingMap[(int)$e['student_id']] = $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int)(session()->get('user_id') ?? 0) ?: null;
|
||||||
|
$savedCount = 0;
|
||||||
|
|
||||||
|
foreach ($studentMap as $sid => $info) {
|
||||||
|
$fall = $info['fall_score'];
|
||||||
|
$spring = $info['spring_score'];
|
||||||
|
|
||||||
|
if ($fall !== null && $spring !== null) {
|
||||||
|
$yearScore = round(($fall + $spring) / 2, 2);
|
||||||
|
} elseif ($fall !== null) {
|
||||||
|
$yearScore = $fall;
|
||||||
|
} elseif ($spring !== null) {
|
||||||
|
$yearScore = $spring;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($yearScore >= 60) {
|
||||||
|
$decision = 'Pass';
|
||||||
|
$source = 'auto';
|
||||||
|
$notes = null;
|
||||||
|
} elseif (isset($belowMap[$sid]) && (string)($belowMap[$sid]['decision'] ?? '') !== '') {
|
||||||
|
$decision = (string)$belowMap[$sid]['decision'];
|
||||||
|
$source = 'manual';
|
||||||
|
$notes = ($belowMap[$sid]['notes'] ?? '') !== '' ? (string)$belowMap[$sid]['notes'] : null;
|
||||||
|
} else {
|
||||||
|
$decision = null;
|
||||||
|
$source = 'pending';
|
||||||
|
$notes = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'student_id' => $sid,
|
||||||
|
'semester' => 'year',
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'class_section_name' => $info['class_section_name'] ?? null,
|
||||||
|
'semester_score' => $yearScore,
|
||||||
|
'decision' => $decision,
|
||||||
|
'source' => $source,
|
||||||
|
'notes' => $notes,
|
||||||
|
'generated_by' => $userId,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isset($existingMap[$sid])) {
|
||||||
|
$decModel->update($existingMap[$sid]['id'], $payload);
|
||||||
|
} else {
|
||||||
|
$decModel->insert($payload);
|
||||||
|
}
|
||||||
|
$savedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = http_build_query(['school_year' => $schoolYear]);
|
||||||
|
return redirect()->to(base_url('grading/decisions') . '?' . $query)
|
||||||
|
->with('status', "Decisions generated for {$savedCount} students.");
|
||||||
|
}
|
||||||
|
|
||||||
public function getScoreComment()
|
public function getScoreComment()
|
||||||
{
|
{
|
||||||
// Get all students for the current semester and school year
|
// Get all students for the current semester and school year
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class HomeworkTrackingController extends BaseController
|
|||||||
|
|
||||||
$hasHomework = [];
|
$hasHomework = [];
|
||||||
$hwEnteredAt = [];
|
$hwEnteredAt = [];
|
||||||
|
$homeworkSubmissionCounts = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$csid = (int)($r['class_section_id'] ?? 0);
|
$csid = (int)($r['class_section_id'] ?? 0);
|
||||||
$hi = (int)($r['homework_index'] ?? 0);
|
$hi = (int)($r['homework_index'] ?? 0);
|
||||||
@@ -94,6 +95,7 @@ class HomeworkTrackingController extends BaseController
|
|||||||
$hasHomework[$csid][$hi] = true;
|
$hasHomework[$csid][$hi] = true;
|
||||||
$dateStr = substr((string)($r['first_created'] ?? ''), 0, 10);
|
$dateStr = substr((string)($r['first_created'] ?? ''), 0, 10);
|
||||||
$hwEnteredAt[$csid][$hi] = $dateStr ?: null;
|
$hwEnteredAt[$csid][$hi] = $dateStr ?: null;
|
||||||
|
$homeworkSubmissionCounts[$csid] = ($homeworkSubmissionCounts[$csid] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +219,7 @@ class HomeworkTrackingController extends BaseController
|
|||||||
'teachers' => $teachersPage,
|
'teachers' => $teachersPage,
|
||||||
'hasHomework' => $hasHomework,
|
'hasHomework' => $hasHomework,
|
||||||
'hwEnteredAt' => $hwEnteredAt,
|
'hwEnteredAt' => $hwEnteredAt,
|
||||||
|
'homeworkSubmissionCounts' => $homeworkSubmissionCounts,
|
||||||
'hasHomeworkByDate' => $hasHomeworkByDate,
|
'hasHomeworkByDate' => $hasHomeworkByDate,
|
||||||
'hwEnteredAtByDate' => $hwEnteredAtByDate,
|
'hwEnteredAtByDate' => $hwEnteredAtByDate,
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
|
|||||||
@@ -381,18 +381,24 @@ class InvoiceController extends ResourceController
|
|||||||
return false;
|
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');
|
$isAjax = $this->request->isAJAX() || str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json');
|
||||||
if ($parentId == null) {
|
if ($parentId == null) {
|
||||||
$parentId = (int)$this->request->getPost('parent_id');
|
$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
|
// Fetch enrolled + withdrawn students
|
||||||
$enrollments = $this->enrollmentModel
|
$enrollments = $this->enrollmentModel
|
||||||
->where('parent_id', $parentId)
|
->where('parent_id', $parentId)
|
||||||
->where('school_year', $this->schoolYear)
|
->where('school_year', $schoolYear)
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
if (empty($enrollments)) {
|
if (empty($enrollments)) {
|
||||||
@@ -444,21 +450,23 @@ class InvoiceController extends ResourceController
|
|||||||
$tuitionFee = $fees['tuition_fee'];
|
$tuitionFee = $fees['tuition_fee'];
|
||||||
|
|
||||||
// ✅ Fetch event charges
|
// ✅ Fetch event charges
|
||||||
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $this->schoolYear);
|
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
|
||||||
$eventchargeTotal = array_sum(array_column($eventsList, 'charged'));
|
$eventchargeTotal = array_sum(array_column($eventsList, 'charged'));
|
||||||
|
|
||||||
$totalDiscount = $this->recalculateAndUpdateDiscount(
|
$totalDiscount = 0.0;
|
||||||
$parentId,
|
if ($recalculateDiscounts) {
|
||||||
$this->schoolYear,
|
$totalDiscount = $this->recalculateAndUpdateDiscount(
|
||||||
$tuitionFee,
|
$parentId,
|
||||||
$enrollments
|
$schoolYear,
|
||||||
);
|
$tuitionFee,
|
||||||
|
$enrollments
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$semester = $this->semester;
|
|
||||||
// ✅ Refunds PAID to the parent for this year (Partial/Paid)
|
// ✅ 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);
|
$discountedTuition = max(0, $tuitionFee);
|
||||||
$totalAmount = $discountedTuition + $eventchargeTotal;
|
$totalAmount = $discountedTuition + $eventchargeTotal;
|
||||||
@@ -469,17 +477,22 @@ class InvoiceController extends ResourceController
|
|||||||
- $refundPaid // approved refunds paid to parent
|
- $refundPaid // approved refunds paid to parent
|
||||||
- $totalPaid; // payments received
|
- $totalPaid; // payments received
|
||||||
|
|
||||||
// Your invoice fetch
|
// Business rule: single invoice per parent per school year.
|
||||||
$existingInvoices = $this->invoiceModel->getInvoicesByParentId($parentId, $this->schoolYear);
|
// 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;
|
$updated = false;
|
||||||
|
|
||||||
$updatedIds = [];
|
$updatedIds = [];
|
||||||
if (!empty($existingInvoices)) {
|
if (!empty($invoice) && isset($invoice['id'])) {
|
||||||
foreach ($existingInvoices as $invoice) {
|
$paymentExclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||||
if (!isset($invoice['id'])) {
|
$paymentsHasStatus = false;
|
||||||
continue;
|
$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
|
// Preserve applied additional charges and recalc this invoice only
|
||||||
$extrasSum = 0.0;
|
$extrasSum = 0.0;
|
||||||
@@ -487,7 +500,7 @@ class InvoiceController extends ResourceController
|
|||||||
$rows = $this->db->table('additional_charges')
|
$rows = $this->db->table('additional_charges')
|
||||||
->select('charge_type, amount')
|
->select('charge_type, amount')
|
||||||
->where('invoice_id', (int)$invoice['id'])
|
->where('invoice_id', (int)$invoice['id'])
|
||||||
->where('school_year', $this->schoolYear)
|
->where('school_year', $schoolYear)
|
||||||
->where('status', 'applied')
|
->where('status', 'applied')
|
||||||
->get()->getResultArray();
|
->get()->getResultArray();
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
@@ -520,7 +533,7 @@ class InvoiceController extends ResourceController
|
|||||||
$r = $this->db->table('refunds')
|
$r = $this->db->table('refunds')
|
||||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||||
->where('invoice_id', (int)$invoice['id'])
|
->where('invoice_id', (int)$invoice['id'])
|
||||||
->where('school_year', $this->schoolYear)
|
->where('school_year', $schoolYear)
|
||||||
->whereIn('status', ['Partial','Paid'])
|
->whereIn('status', ['Partial','Paid'])
|
||||||
->get()->getRowArray();
|
->get()->getRowArray();
|
||||||
$invRefunds = (float)($r['tot'] ?? 0.0);
|
$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());
|
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 = (float)($invoice['paid_amount'] ?? 0.0);
|
$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;
|
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
|
||||||
$newStatus = ($newBalance <= 0.00001)
|
$newStatus = ($newBalance <= 0.00001)
|
||||||
@@ -547,7 +585,6 @@ class InvoiceController extends ResourceController
|
|||||||
$updatedIds[] = (int)$invoice['id'];
|
$updatedIds[] = (int)$invoice['id'];
|
||||||
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
|
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
|
||||||
$updated = true;
|
$updated = true;
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Generate invoice number
|
// Generate invoice number
|
||||||
$schoolId = $this->userModel->getSchoolIdByUserId($parentId);
|
$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
|
// Initial balance equals the created total; discounts/refunds/payments will adjust later
|
||||||
'balance' => $totalAmount,
|
'balance' => $totalAmount,
|
||||||
'status' => 'Unpaid',
|
'status' => 'Unpaid',
|
||||||
'school_year' => $this->schoolYear,
|
'school_year' => $schoolYear,
|
||||||
'semester' => $semester,
|
'semester' => $semester,
|
||||||
'issue_date' => $issueUtc,
|
'issue_date' => $issueUtc,
|
||||||
'due_date' => $dueUtc,
|
'due_date' => $dueUtc,
|
||||||
@@ -615,6 +652,47 @@ class InvoiceController extends ResourceController
|
|||||||
->with('success', $updated ? 'Invoice updated.' : 'Invoice created.');
|
->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(
|
private function recalculateAndUpdateDiscount(
|
||||||
int $parentId,
|
int $parentId,
|
||||||
string $schoolYear,
|
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());
|
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
|
// Recalculate discount
|
||||||
if ($discountUsage['discount_type'] === 'percent') {
|
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);
|
$dt = $toLocal($event['created_at'] ?? null, false);
|
||||||
$amount = (float)($event['charged'] ?? 0.0);
|
$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;
|
$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', [
|
return view('/parent/invoice_payment', [
|
||||||
'invoices' => $invoices,
|
'invoices' => $invoices,
|
||||||
'schoolYears' => $schoolYears,
|
'schoolYears' => $schoolYears,
|
||||||
'selectedYear' => $selectedYear,
|
'selectedYear' => $selectedYear,
|
||||||
'currentSchoolYear' => $currentSchoolYear,
|
'currentSchoolYear' => $currentSchoolYear,
|
||||||
'dueDate' => $this->dueDate
|
'dueDate' => $this->dueDate,
|
||||||
|
'invoiceEventCharges' => $invoiceEventCharges,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ class LandingPageController extends BaseController
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Teacher class sections only apply to the teacher role; other roles have no teacher_class rows.
|
||||||
|
if (strtolower(trim((string) $this->getUserRole())) !== 'teacher') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Get all class assignments for this teacher in the current term
|
// Get all class assignments for this teacher in the current term
|
||||||
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
|
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
|
||||||
(int)$user_id,
|
(int)$user_id,
|
||||||
@@ -106,7 +111,7 @@ class LandingPageController extends BaseController
|
|||||||
$ids = array_values(array_filter(array_unique($ids)));
|
$ids = array_values(array_filter(array_unique($ids)));
|
||||||
|
|
||||||
if (empty($ids)) {
|
if (empty($ids)) {
|
||||||
log_message('error', "No class section found for user ID: $user_id");
|
log_message('warning', "No class section found for user ID: $user_id");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,8 +873,12 @@ class LandingPageController extends BaseController
|
|||||||
|
|
||||||
protected function getUserRole()
|
protected function getUserRole()
|
||||||
{
|
{
|
||||||
// Assuming you have a session variable storing the user role
|
$active = session()->get('active_role');
|
||||||
return session()->get('user_role', 'guest'); // Default to guest if not set
|
if ($active !== null && $active !== '') {
|
||||||
|
return (string) $active;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) (session()->get('role') ?? 'guest');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getUserRoleFromDatabase($user_id)
|
protected function getUserRoleFromDatabase($user_id)
|
||||||
|
|||||||
@@ -1797,16 +1797,34 @@ $existing = $this->studentModel
|
|||||||
$activeEventCount = is_array($activeEvents) ? count($activeEvents) : 0;
|
$activeEventCount = is_array($activeEvents) ? count($activeEvents) : 0;
|
||||||
|
|
||||||
// Get charges (participation info)
|
// Get charges (participation info)
|
||||||
$chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
|
$chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $semester);
|
||||||
|
|
||||||
// Build a map: "studentId:eventId" => [ 'participation' => ..., 'date' => ... ]
|
// Build a map: "studentId:eventId" => [ 'participation' => ..., 'date' => ... ]
|
||||||
$charges = [];
|
$charges = [];
|
||||||
|
$externalParticipantsByEvent = [];
|
||||||
foreach ($chargesList as $charge) {
|
foreach ($chargesList as $charge) {
|
||||||
$key = $charge['student_id'] . ':' . $charge['event_id'];
|
$studentId = $charge['student_id'] ?? null;
|
||||||
$charges[$key] = [
|
$eventId = (int) ($charge['event_id'] ?? 0);
|
||||||
'participation' => $charge['participation'],
|
|
||||||
'date' => $charge['updated_at'] ?? $charge['created_at'] // Use updated_at if available
|
if (!empty($studentId)) {
|
||||||
];
|
$key = $studentId . ':' . $eventId;
|
||||||
|
$charges[$key] = [
|
||||||
|
'participation' => $charge['participation'],
|
||||||
|
'date' => $charge['updated_at'] ?? $charge['created_at'], // Use updated_at if available
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$externalName = trim((string) ($charge['external_firstname'] ?? '') . ' ' . (string) ($charge['external_lastname'] ?? ''));
|
||||||
|
if ($eventId > 0 && $externalName !== '') {
|
||||||
|
$externalParticipantsByEvent[$eventId][] = [
|
||||||
|
'name' => $externalName,
|
||||||
|
'note' => (string) ($charge['external_note'] ?? ''),
|
||||||
|
'participation' => (string) ($charge['participation'] ?? ''),
|
||||||
|
'event_paid' => !empty($charge['event_paid']),
|
||||||
|
'charged' => (float) ($charge['charged'] ?? ($charge['event_amount'] ?? 0)),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get enrolled students
|
// Get enrolled students
|
||||||
@@ -1815,6 +1833,7 @@ $existing = $this->studentModel
|
|||||||
return view('parent/event_participation', [
|
return view('parent/event_participation', [
|
||||||
'activeEvents' => $activeEvents,
|
'activeEvents' => $activeEvents,
|
||||||
'charges' => $charges,
|
'charges' => $charges,
|
||||||
|
'externalParticipantsByEvent' => $externalParticipantsByEvent,
|
||||||
'yourStudents' => $students,
|
'yourStudents' => $students,
|
||||||
'activeEventCount' => $activeEventCount,
|
'activeEventCount' => $activeEventCount,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -73,19 +73,6 @@ class ReportCardsController extends PrintablesBaseController
|
|||||||
$semesters = array_values(array_filter(array_map(static fn($r) => (string)($r['semester'] ?? ''), $rs)));
|
$semesters = array_values(array_filter(array_map(static fn($r) => (string)($r['semester'] ?? ''), $rs)));
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
$rs2 = $this->db->table('student_class')
|
|
||||||
->select('DISTINCT semester', false)
|
|
||||||
->where('school_year', $year)
|
|
||||||
->where('semester IS NOT NULL', null, false)
|
|
||||||
->orderBy('semester', 'ASC')
|
|
||||||
->get()->getResultArray();
|
|
||||||
foreach ($rs2 as $r) {
|
|
||||||
$val = (string)($r['semester'] ?? '');
|
|
||||||
if ($val !== '' && !in_array($val, $semesters, true)) $semesters[] = $val;
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
}
|
|
||||||
// Ensure standard options are present even if data is missing (so Spring can be selected)
|
// Ensure standard options are present even if data is missing (so Spring can be selected)
|
||||||
$defaults = array_values(array_filter([(string)($this->semester ?? ''), 'Fall', 'Spring'], static fn($v) => $v !== ''));
|
$defaults = array_values(array_filter([(string)($this->semester ?? ''), 'Fall', 'Spring'], static fn($v) => $v !== ''));
|
||||||
foreach ($defaults as $d) {
|
foreach ($defaults as $d) {
|
||||||
@@ -1566,34 +1553,30 @@ class ReportCardsController extends PrintablesBaseController
|
|||||||
$semRange = $this->semesterRangeService->getSemesterRange($refYear, ucfirst($normSemester));
|
$semRange = $this->semesterRangeService->getSemesterRange($refYear, ucfirst($normSemester));
|
||||||
if ($semRange) {
|
if ($semRange) {
|
||||||
try {
|
try {
|
||||||
$events = $this->calendarModel->getEventsBySchoolYearAndSemester($refYear, $normSemester);
|
// Fetch all events for the year (no semester filter) so that
|
||||||
|
// no-school events stored with a different semester label or
|
||||||
|
// NULL semester are still picked up. Date-range filtering below.
|
||||||
|
$events = $this->calendarModel->getEventsBySchoolYearAndSemester($refYear, null);
|
||||||
$noSchoolDays = [];
|
$noSchoolDays = [];
|
||||||
foreach ($events as $event) {
|
foreach ($events as $event) {
|
||||||
if (empty($event['no_school'])) {
|
if (empty($event['no_school'])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$dateStr = substr((string)($event['date'] ?? ''), 0, 10);
|
$dateStr = substr((string)($event['date'] ?? ''), 0, 10);
|
||||||
if ($dateStr === '') {
|
if ($dateStr === '' || $dateStr < $semRange[0] || $dateStr > $semRange[1]) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
$eventDate = new \DateTime($dateStr);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($eventDate->format('N') !== '7') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($dateStr < $semRange[0] || $dateStr > $semRange[1]) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$noSchoolDays[$dateStr] = true;
|
$noSchoolDays[$dateStr] = true;
|
||||||
}
|
}
|
||||||
$sundays = $this->listSundays($semRange[0], $semRange[1]);
|
$sundays = $this->listSundays($semRange[0], $semRange[1]);
|
||||||
$anchor = $this->resolveAnchorSunday();
|
|
||||||
$limit = $semRange[1];
|
$limit = $semRange[1];
|
||||||
if ($anchor !== '' && $anchor >= $semRange[0]) {
|
// Only cap at "today" while the semester is still in progress.
|
||||||
$limit = min($anchor, $semRange[1]);
|
// Once the semester end date has passed, count all its Sundays.
|
||||||
|
if ($semRange[1] > date('Y-m-d')) {
|
||||||
|
$anchor = $this->resolveAnchorSunday();
|
||||||
|
if ($anchor !== '' && $anchor >= $semRange[0]) {
|
||||||
|
$limit = min($anchor, $semRange[1]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$totalSemesterDays = 0;
|
$totalSemesterDays = 0;
|
||||||
foreach ($sundays as $date) {
|
foreach ($sundays as $date) {
|
||||||
|
|||||||
@@ -1287,14 +1287,14 @@ class ScoreController extends Controller
|
|||||||
public function viewStudentScore()
|
public function viewStudentScore()
|
||||||
{
|
{
|
||||||
$parentId = session()->get('user_id');
|
$parentId = session()->get('user_id');
|
||||||
$userType = $_SESSION['user_type'];
|
$userType = session()->get('user_type') ?? '';
|
||||||
$firstParentId = null;
|
$firstParentId = null;
|
||||||
$releaseFall = $this->getParentScoresReleasedForSemester('Fall');
|
$releaseFall = $this->getParentScoresReleasedForSemester('Fall');
|
||||||
$releaseSpring = $this->getParentScoresReleasedForSemester('Spring');
|
$releaseSpring = $this->getParentScoresReleasedForSemester('Spring');
|
||||||
$releaseAny = $releaseFall || $releaseSpring;
|
$releaseAny = $releaseFall || $releaseSpring;
|
||||||
|
|
||||||
// Identify the firstparent based on user type
|
// Identify the firstparent based on user type
|
||||||
if ($userType === 'primary') {
|
if ($userType === 'primary' || $userType === '') {
|
||||||
$firstParentId = $parentId;
|
$firstParentId = $parentId;
|
||||||
} elseif ($userType === 'secondary') {
|
} elseif ($userType === 'secondary') {
|
||||||
$parentData = $this->db->table('parents')
|
$parentData = $this->db->table('parents')
|
||||||
|
|||||||
@@ -297,12 +297,16 @@ class TeacherController extends BaseController
|
|||||||
$schoolYear = $forYear ?: ((string)($this->schoolYear ?? 'Not Set'));
|
$schoolYear = $forYear ?: ((string)($this->schoolYear ?? 'Not Set'));
|
||||||
|
|
||||||
$teachers = $this->teacherModel->getTeachersAndTAs();
|
$teachers = $this->teacherModel->getTeachersAndTAs();
|
||||||
// Prefer class sections for the selected year, fallback to all if none
|
// Prefer class sections for the selected year if the column exists, otherwise fallback to all.
|
||||||
$classSections = $classSectionModel
|
if ($this->db->fieldExists('school_year', 'classSection')) {
|
||||||
->where('school_year', $schoolYear)
|
$classSections = $classSectionModel
|
||||||
->orderBy('class_section_name', 'ASC')
|
->where('school_year', $schoolYear)
|
||||||
->findAll();
|
->orderBy('class_section_name', 'ASC')
|
||||||
if (empty($classSections)) {
|
->findAll();
|
||||||
|
if (empty($classSections)) {
|
||||||
|
$classSections = $classSectionModel->orderBy('class_section_name', 'ASC')->findAll();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
$classSections = $classSectionModel->orderBy('class_section_name', 'ASC')->findAll();
|
$classSections = $classSectionModel->orderBy('class_section_name', 'ASC')->findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddReviewRevisionToExamDrafts extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
if (! $this->db->fieldExists('review_revision', 'exam_drafts')) {
|
||||||
|
$this->forge->addColumn('exam_drafts', [
|
||||||
|
'review_revision' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'null' => false,
|
||||||
|
'default' => 0,
|
||||||
|
'after' => 'version',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
if ($this->db->fieldExists('review_revision', 'exam_drafts')) {
|
||||||
|
$this->forge->dropColumn('exam_drafts', 'review_revision');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateBelowSixtyDecisions extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
if ($this->db->tableExists('below_sixty_decisions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'unsigned' => true,
|
||||||
|
'auto_increment' => true,
|
||||||
|
],
|
||||||
|
'student_id' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'unsigned' => true,
|
||||||
|
],
|
||||||
|
'semester' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 20,
|
||||||
|
],
|
||||||
|
'school_year' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 20,
|
||||||
|
],
|
||||||
|
'decision' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 100,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'notes' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'decided_by' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'unsigned' => true,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'updated_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addUniqueKey(['student_id', 'semester', 'school_year']);
|
||||||
|
$this->forge->addKey(['school_year', 'semester']);
|
||||||
|
$this->forge->createTable('below_sixty_decisions');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('below_sixty_decisions', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateStudentDecisions extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
if ($this->db->tableExists('student_decisions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'unsigned' => true,
|
||||||
|
'auto_increment' => true,
|
||||||
|
],
|
||||||
|
'student_id' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'unsigned' => true,
|
||||||
|
],
|
||||||
|
'semester' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 20,
|
||||||
|
],
|
||||||
|
'school_year' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 20,
|
||||||
|
],
|
||||||
|
'class_section_name' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 100,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'semester_score' => [
|
||||||
|
'type' => 'DECIMAL',
|
||||||
|
'constraint' => '8,2',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'decision' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 100,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'source' => [
|
||||||
|
'type' => 'ENUM',
|
||||||
|
'constraint' => ['auto', 'manual', 'pending'],
|
||||||
|
'default' => 'auto',
|
||||||
|
],
|
||||||
|
'notes' => [
|
||||||
|
'type' => 'TEXT',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'generated_by' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'unsigned' => true,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'updated_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addUniqueKey(['student_id', 'semester', 'school_year']);
|
||||||
|
$this->forge->addKey(['school_year', 'semester']);
|
||||||
|
$this->forge->createTable('student_decisions');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('student_decisions', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ class ApiAuthFilter implements FilterInterface
|
|||||||
{
|
{
|
||||||
public function before(RequestInterface $request, $arguments = null)
|
public function before(RequestInterface $request, $arguments = null)
|
||||||
{
|
{
|
||||||
|
helper('security');
|
||||||
|
|
||||||
$authorization = $request->getHeaderLine('Authorization');
|
$authorization = $request->getHeaderLine('Authorization');
|
||||||
if (!$authorization || stripos($authorization, 'Bearer ') !== 0) {
|
if (!$authorization || stripos($authorization, 'Bearer ') !== 0) {
|
||||||
return $this->unauthorized('Missing or invalid Authorization header');
|
return $this->unauthorized('Missing or invalid Authorization header');
|
||||||
@@ -21,7 +23,12 @@ class ApiAuthFilter implements FilterInterface
|
|||||||
return $this->unauthorized('Bearer token is required');
|
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);
|
$payload = jwt_decode($token, $secret);
|
||||||
|
|
||||||
if (!$payload || empty($payload['sub'])) {
|
if (!$payload || empty($payload['sub'])) {
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ class ApiDocsAuthFilter implements FilterInterface
|
|||||||
{
|
{
|
||||||
public function before(RequestInterface $request, $arguments = null)
|
public function before(RequestInterface $request, $arguments = null)
|
||||||
{
|
{
|
||||||
|
helper('security');
|
||||||
|
|
||||||
$authHeader = $request->getHeaderLine('Authorization');
|
$authHeader = $request->getHeaderLine('Authorization');
|
||||||
|
|
||||||
if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
|
if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
|
||||||
$token = trim(substr($authHeader, 7));
|
$token = trim(substr($authHeader, 7));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$key = getenv('JWT_SECRET') ?: 'your_default_secret';
|
$key = require_env('JWT_SECRET');
|
||||||
$decoded = JWT::decode($token, new Key($key, 'HS256'));
|
$decoded = JWT::decode($token, new Key($key, 'HS256'));
|
||||||
|
|
||||||
if (!isset($decoded->roles) || empty($decoded->roles->admin)) {
|
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
|
// Must be logged in
|
||||||
if (empty($userRoles) || empty($userId)) {
|
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
|
// 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)
|
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)
|
public function __construct(?string $secret = null, ?int $ttlSeconds = null)
|
||||||
{
|
{
|
||||||
helper('jwt');
|
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;
|
$this->ttl = ($ttlSeconds !== null && $ttlSeconds > 0) ? $ttlSeconds : self::DEFAULT_TTL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use Config\Services;
|
||||||
|
|
||||||
|
class DecisionEmailListener
|
||||||
|
{
|
||||||
|
public static function handle(array $payload): void
|
||||||
|
{
|
||||||
|
$studentId = (int)($payload['student_id'] ?? 0);
|
||||||
|
$studentName = (string)($payload['student_name'] ?? '');
|
||||||
|
$classSection= (string)($payload['class_section_name'] ?? '');
|
||||||
|
$semester = (string)($payload['semester'] ?? '');
|
||||||
|
$schoolYear = (string)($payload['school_year'] ?? '');
|
||||||
|
$decision = (string)($payload['decision'] ?? '');
|
||||||
|
$notes = (string)($payload['notes'] ?? '');
|
||||||
|
$scores = is_array($payload['scores'] ?? null) ? $payload['scores'] : [];
|
||||||
|
$allSemesters = is_array($payload['all_semesters'] ?? null) ? $payload['all_semesters'] : [];
|
||||||
|
|
||||||
|
if ($studentId <= 0) {
|
||||||
|
log_message('warning', 'DecisionEmail: missing student_id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
$rows = $db->query(
|
||||||
|
"SELECT u.firstname, u.lastname, u.email, fg.is_primary
|
||||||
|
FROM family_students fs
|
||||||
|
JOIN family_guardians fg ON fg.family_id = fs.family_id
|
||||||
|
JOIN users u ON u.id = fg.user_id
|
||||||
|
WHERE fs.student_id = ?
|
||||||
|
AND fg.receive_emails = 1
|
||||||
|
AND u.email IS NOT NULL AND u.email != ''
|
||||||
|
ORDER BY fg.is_primary DESC, u.lastname, u.firstname",
|
||||||
|
[$studentId]
|
||||||
|
)->getResultArray();
|
||||||
|
|
||||||
|
if (empty($rows)) {
|
||||||
|
log_message('warning', 'DecisionEmail: no guardian emails for student_id=' . $studentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$emails = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$em = trim((string)($row['email'] ?? ''));
|
||||||
|
if ($em !== '') $emails[$em] = true;
|
||||||
|
}
|
||||||
|
$emails = array_keys($emails);
|
||||||
|
|
||||||
|
$primary = $rows[0] ?? [];
|
||||||
|
$parentName = trim((string)($primary['firstname'] ?? '') . ' ' . (string)($primary['lastname'] ?? ''));
|
||||||
|
|
||||||
|
$subject = (string)($payload['subject'] ?? '');
|
||||||
|
if ($subject === '') {
|
||||||
|
$subject = 'Academic Decision';
|
||||||
|
if ($studentName !== '') $subject .= ' — ' . $studentName;
|
||||||
|
if ($semester !== '' || $schoolYear !== '') {
|
||||||
|
$subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$emailData = [
|
||||||
|
'title' => $subject,
|
||||||
|
'parent_name' => $parentName !== '' ? $parentName : 'Parent/Guardian',
|
||||||
|
'student_name' => $studentName !== '' ? $studentName : 'your student',
|
||||||
|
'class_section_name' => $classSection,
|
||||||
|
'semester' => $semester,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'decision' => $decision,
|
||||||
|
'notes' => $notes,
|
||||||
|
'scores' => $scores,
|
||||||
|
'all_semesters' => $allSemesters,
|
||||||
|
];
|
||||||
|
|
||||||
|
$html = trim((string)($payload['html'] ?? ''));
|
||||||
|
if ($html === '') {
|
||||||
|
$html = view('emails/below_sixty_decision', $emailData, ['saveData' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$okAny = false;
|
||||||
|
foreach ($emails as $to) {
|
||||||
|
$ok = self::sendViaEmailService($to, $subject, $html);
|
||||||
|
if (!$ok) {
|
||||||
|
$ok = self::sendViaCiEmail($to, $subject, $html);
|
||||||
|
}
|
||||||
|
$okAny = $okAny || $ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($okAny) {
|
||||||
|
log_message('info', 'DecisionEmail: sent for student_id=' . $studentId);
|
||||||
|
} else {
|
||||||
|
log_message('error', 'DecisionEmail: failed for student_id=' . $studentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function sendViaEmailService(string $to, string $subject, string $html): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$svc = function_exists('service') ? service('emailService') : null;
|
||||||
|
if (!$svc && method_exists(Services::class, 'emailService')) {
|
||||||
|
$svc = Services::emailService();
|
||||||
|
}
|
||||||
|
if (!$svc) return false;
|
||||||
|
return (bool)$svc->send($to, $subject, $html, 'general');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('debug', 'DecisionEmail sendViaEmailService failed: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function sendViaCiEmail(string $to, string $subject, string $html): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$email = Services::email();
|
||||||
|
$cfg = config('Email');
|
||||||
|
$fromEmail = $cfg->fromEmail ?? $cfg->SMTPUser ?? 'no-reply@example.com';
|
||||||
|
$fromName = $cfg->fromName ?? 'Al Rahma Sunday School';
|
||||||
|
|
||||||
|
$email->setTo($to);
|
||||||
|
$email->setFrom($fromEmail, $fromName);
|
||||||
|
$email->setSubject($subject);
|
||||||
|
$email->setMessage($html);
|
||||||
|
$email->setMailType('html');
|
||||||
|
$ok = $email->send();
|
||||||
|
if (!$ok) {
|
||||||
|
$dbg = method_exists($email, 'printDebugger') ? $email->printDebugger(['headers', 'subject']) : 'no debugger';
|
||||||
|
log_message('debug', 'DecisionEmail CI send failed: ' . print_r($dbg, true));
|
||||||
|
}
|
||||||
|
return $ok;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('debug', 'DecisionEmail sendViaCiEmail exception: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class BelowSixtyDecisionModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'below_sixty_decisions';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
|
||||||
|
protected $allowedFields = [
|
||||||
|
'student_id',
|
||||||
|
'semester',
|
||||||
|
'school_year',
|
||||||
|
'decision',
|
||||||
|
'notes',
|
||||||
|
'decided_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
}
|
||||||
@@ -0,0 +1,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,15 @@ namespace App\Models;
|
|||||||
|
|
||||||
use CodeIgniter\Model;
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weekly class progress reports (one row per subject per week).
|
||||||
|
*
|
||||||
|
* unit_title stores a compact summary of unit/chapter rows: segments joined with " ; ".
|
||||||
|
* Teacher-entered custom Islamic or Quran topics use the prefix "Custom / {text}"
|
||||||
|
* (see {@see \App\Controllers\ClassProgressController::CUSTOM_UNIT_ROW_LABEL} and
|
||||||
|
* {@see \App\Controllers\ClassProgressController::splitUnitTitleForDisplay()}).
|
||||||
|
* DB column is VARCHAR(120); controller truncates to match.
|
||||||
|
*/
|
||||||
class ClassProgressReportModel extends Model
|
class ClassProgressReportModel extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'class_progress_reports';
|
protected $table = 'class_progress_reports';
|
||||||
@@ -27,7 +36,42 @@ class ClassProgressReportModel extends Model
|
|||||||
'flags_json',
|
'flags_json',
|
||||||
'attachment_path',
|
'attachment_path',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $useTimestamps = true;
|
protected $useTimestamps = true;
|
||||||
protected $createdField = 'created_at';
|
protected $createdField = 'created_at';
|
||||||
protected $updatedField = 'updated_at';
|
protected $updatedField = 'updated_at';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation is performed in {@see \App\Controllers\ClassProgressController} on HTTP input.
|
||||||
|
* Rules below document schema limits and can be enabled if you set {@see $skipValidation} to false.
|
||||||
|
*/
|
||||||
|
protected $skipValidation = true;
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'class_section_id' => 'required|integer',
|
||||||
|
'teacher_id' => 'required|integer',
|
||||||
|
'week_start' => 'required|valid_date[Y-m-d]',
|
||||||
|
'week_end' => 'required|valid_date[Y-m-d]',
|
||||||
|
'subject' => 'required|string|max_length[160]',
|
||||||
|
'unit_title' => 'permit_empty|string|max_length[120]',
|
||||||
|
'covered' => 'permit_empty|string',
|
||||||
|
'homework' => 'permit_empty|string',
|
||||||
|
'assessment' => 'permit_empty|string',
|
||||||
|
'status' => 'permit_empty|in_list[on_track,slightly_behind,behind]',
|
||||||
|
'status_notes' => 'permit_empty|string|max_length[200]',
|
||||||
|
'class_notes' => 'permit_empty|string',
|
||||||
|
'next_week_plan' => 'permit_empty|string',
|
||||||
|
'support_needed' => 'permit_empty|string',
|
||||||
|
'flags_json' => 'permit_empty|string',
|
||||||
|
'attachment_path' => 'permit_empty|string|max_length[255]',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $validationMessages = [
|
||||||
|
'unit_title' => [
|
||||||
|
'max_length' => 'Unit and chapter summary cannot exceed 120 characters.',
|
||||||
|
],
|
||||||
|
'subject' => [
|
||||||
|
'max_length' => 'Subject cannot exceed 160 characters.',
|
||||||
|
],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,20 @@ class EventChargesModel extends Model
|
|||||||
'student_id',
|
'student_id',
|
||||||
'participation',
|
'participation',
|
||||||
'charged',
|
'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',
|
'semester',
|
||||||
'school_year',
|
'school_year',
|
||||||
|
'created_by',
|
||||||
'updated_by',
|
'updated_by',
|
||||||
'created_at',
|
'created_at',
|
||||||
'updated_at'
|
'updated_at'
|
||||||
@@ -30,7 +42,7 @@ class EventChargesModel extends Model
|
|||||||
|
|
||||||
public function getChargesWithEventInfo($parentId = null, $schoolYear = null, $semester = null)
|
public function getChargesWithEventInfo($parentId = null, $schoolYear = null, $semester = null)
|
||||||
{
|
{
|
||||||
$builder = $this->select('event_charges.*, events.event_name, events.amount')
|
$builder = $this->select('event_charges.*, events.event_name, events.amount AS event_amount, events.description AS event_description')
|
||||||
->join('events', 'events.id = event_charges.event_id', 'left');
|
->join('events', 'events.id = event_charges.event_id', 'left');
|
||||||
|
|
||||||
if ($parentId) {
|
if ($parentId) {
|
||||||
|
|||||||
@@ -8,19 +8,51 @@ class ExamDraftModel extends Model
|
|||||||
{
|
{
|
||||||
protected $table = 'exam_drafts';
|
protected $table = 'exam_drafts';
|
||||||
protected $primaryKey = 'id';
|
protected $primaryKey = 'id';
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
protected bool $updateOnlyChanged = false;
|
||||||
|
|
||||||
|
/** @var list<string> Stored in `status` column */
|
||||||
|
public const STATUSES = [
|
||||||
|
'submitted',
|
||||||
|
'accepted',
|
||||||
|
'review needed',
|
||||||
|
'rejected',
|
||||||
|
'canceled',
|
||||||
|
'under review',
|
||||||
|
'legacy',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var list<string> Stored in `acceptance_type` when status is finalized */
|
||||||
|
public const ACCEPTANCE_TYPES = [
|
||||||
|
'as_is',
|
||||||
|
'minor_edits',
|
||||||
|
];
|
||||||
|
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'teacher_id',
|
'teacher_id',
|
||||||
|
'author_id',
|
||||||
'class_section_id',
|
'class_section_id',
|
||||||
'semester',
|
'semester',
|
||||||
'school_year',
|
'school_year',
|
||||||
'exam_type',
|
'exam_type',
|
||||||
'draft_title',
|
'draft_title',
|
||||||
|
'author_comment',
|
||||||
'description',
|
'description',
|
||||||
'teacher_file',
|
'teacher_file',
|
||||||
'teacher_filename',
|
'teacher_filename',
|
||||||
|
'author_file',
|
||||||
|
'author_filename',
|
||||||
'status',
|
'status',
|
||||||
|
'acceptance_type',
|
||||||
|
'review_revision',
|
||||||
|
'reviewer_id',
|
||||||
'admin_id',
|
'admin_id',
|
||||||
'is_legacy',
|
'is_legacy',
|
||||||
|
'reviewer_comment',
|
||||||
|
'reviewer_comments',
|
||||||
'admin_comments',
|
'admin_comments',
|
||||||
'reviewed_at',
|
'reviewed_at',
|
||||||
'final_file',
|
'final_file',
|
||||||
@@ -29,9 +61,40 @@ class ExamDraftModel extends Model
|
|||||||
'version',
|
'version',
|
||||||
'previous_draft_id',
|
'previous_draft_id',
|
||||||
];
|
];
|
||||||
protected $returnType = 'array';
|
|
||||||
protected $useTimestamps = true;
|
/**
|
||||||
protected $createdField = 'created_at';
|
* Applied on insert/update when validation runs (e.g. $model->insert($data, true)).
|
||||||
protected $updatedField = 'updated_at';
|
* Uses if_exist so partial updates still validate only keys present in $data.
|
||||||
protected bool $updateOnlyChanged = false; // force updates even if CI thinks nothing changed
|
*/
|
||||||
|
protected $validationRules = [
|
||||||
|
'status' => 'if_exist|in_list[submitted,accepted,review needed,rejected,canceled,under review,legacy]',
|
||||||
|
'acceptance_type' => 'if_exist|permit_empty|in_list[as_is,minor_edits]',
|
||||||
|
'author_id' => 'if_exist|is_natural_no_zero',
|
||||||
|
'class_section_id' => 'if_exist|is_natural_no_zero',
|
||||||
|
'version' => 'if_exist|is_natural_no_zero',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $validationMessages = [
|
||||||
|
'status' => [
|
||||||
|
'in_list' => 'Invalid exam draft status.',
|
||||||
|
],
|
||||||
|
'acceptance_type' => [
|
||||||
|
'in_list' => 'Acceptance must be as_is or minor_edits.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected array $casts = [
|
||||||
|
'teacher_id' => 'int',
|
||||||
|
'author_id' => 'int',
|
||||||
|
'class_section_id' => 'int',
|
||||||
|
'reviewer_id' => '?int',
|
||||||
|
'admin_id' => '?int',
|
||||||
|
'review_revision' => 'int',
|
||||||
|
'version' => 'int',
|
||||||
|
'previous_draft_id' => '?int',
|
||||||
|
'is_legacy' => 'boolean',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class StudentDecisionModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'student_decisions';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
|
||||||
|
protected $allowedFields = [
|
||||||
|
'student_id',
|
||||||
|
'semester',
|
||||||
|
'school_year',
|
||||||
|
'class_section_name',
|
||||||
|
'semester_score',
|
||||||
|
'decision',
|
||||||
|
'source',
|
||||||
|
'notes',
|
||||||
|
'generated_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ class TeacherClassModel extends Model
|
|||||||
$builder = $this->db->table('teacher_class tc')
|
$builder = $this->db->table('teacher_class tc')
|
||||||
->select([
|
->select([
|
||||||
'cs.class_section_name',
|
'cs.class_section_name',
|
||||||
'cs.id AS class_section_pk',
|
'cs.class_section_id AS class_section_pk',
|
||||||
'cs.class_section_id',
|
'cs.class_section_id',
|
||||||
'c.id AS class_id',
|
'c.id AS class_id',
|
||||||
'c.class_name',
|
'c.class_name',
|
||||||
@@ -181,10 +181,10 @@ class TeacherClassModel extends Model
|
|||||||
{
|
{
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
// Adjust table/column names if yours differ (e.g., cs.id vs cs.class_section_id)
|
// Adjust table/column names if yours differ (e.g., cs.class_section_id vs cs.id)
|
||||||
$row = $db->table('classSection cs')
|
$row = $db->table('classSection cs')
|
||||||
->select('u.id AS teacher_id, u.firstname, u.lastname')
|
->select('u.id AS teacher_id, u.firstname, u.lastname')
|
||||||
->join('teacher_class tc', 'tc.class_section_id = cs.id', 'inner')
|
->join('teacher_class tc', 'tc.class_section_id = cs.class_section_id', 'inner')
|
||||||
->join('users u', 'u.id = tc.teacher_id', 'inner')
|
->join('users u', 'u.id = tc.teacher_id', 'inner')
|
||||||
->where('cs.class_section_name', $classSectionName)
|
->where('cs.class_section_name', $classSectionName)
|
||||||
->where('tc.school_year', $schoolYear)
|
->where('tc.school_year', $schoolYear)
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ class TeacherModel extends Model
|
|||||||
public function getTeachersAndTAs(): array
|
public function getTeachersAndTAs(): array
|
||||||
{
|
{
|
||||||
return $this->db->table('users u')
|
return $this->db->table('users u')
|
||||||
->select('u.id, u.firstname, u.lastname, u.email, u.cellphone, r.name as role')
|
->select('u.id, u.firstname, u.lastname, u.email, u.cellphone, MIN(r.name) as role')
|
||||||
->join('user_roles ur', 'ur.user_id = u.id')
|
->join('user_roles ur', 'ur.user_id = u.id')
|
||||||
->join('roles r', 'r.id = ur.role_id')
|
->join('roles r', 'r.id = ur.role_id')
|
||||||
->whereIn('r.name', ['teacher', 'teacher_assistant']) // ✅ Filter only relevant roles
|
->whereIn('r.name', ['teacher', 'teacher_assistant']) // ✅ Filter only relevant roles
|
||||||
->groupBy('u.id')
|
->groupBy('u.id, u.firstname, u.lastname, u.email, u.cellphone')
|
||||||
->orderBy('u.lastname', 'ASC')
|
->orderBy('u.lastname', 'ASC')
|
||||||
->get()
|
->get()
|
||||||
->getResultArray();
|
->getResultArray();
|
||||||
@@ -72,4 +72,4 @@ public function getTeachersAndTAs(): array
|
|||||||
->get()
|
->get()
|
||||||
->getResultArray();
|
->getResultArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class EmailService
|
|||||||
{
|
{
|
||||||
protected array $senders;
|
protected array $senders;
|
||||||
protected EmailController $mailer;
|
protected EmailController $mailer;
|
||||||
|
protected int $lastCcBatchCount = 0;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -73,6 +74,45 @@ class EmailService
|
|||||||
return $allOk;
|
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.
|
* Normalize various attachment inputs to the format expected by EmailController.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -72,11 +72,26 @@ class SemesterRangeService
|
|||||||
$y1 = (int)$m[1];
|
$y1 = (int)$m[1];
|
||||||
$y2 = (int)$m[2];
|
$y2 = (int)$m[2];
|
||||||
$normalized = ucfirst(strtolower(trim($semester)));
|
$normalized = ucfirst(strtolower(trim($semester)));
|
||||||
|
|
||||||
|
$fallStartCfg = (string)($this->configModel->getConfig('fall_semester_start') ?? '');
|
||||||
|
$fallEndCfg = (string)($this->configModel->getConfig('fall_end_date') ?? '');
|
||||||
|
$springStartCfg = (string)($this->configModel->getConfig('spring_semester_start') ?? '');
|
||||||
|
$springEndCfg = (string)($this->configModel->getConfig('last_school_day') ?? '');
|
||||||
|
|
||||||
|
$md = static fn(string $cfg, int $year, string $fallback): string =>
|
||||||
|
$cfg !== '' ? sprintf('%04d-%s', $year, date('m-d', strtotime($cfg))) : $fallback;
|
||||||
|
|
||||||
if ($normalized === 'Fall') {
|
if ($normalized === 'Fall') {
|
||||||
return [sprintf('%04d-09-21', $y1), sprintf('%04d-01-18', $y2)];
|
return [
|
||||||
|
$md($fallStartCfg, $y1, sprintf('%04d-09-21', $y1)),
|
||||||
|
$md($fallEndCfg, $y2, sprintf('%04d-01-18', $y2)),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
if ($normalized === 'Spring') {
|
if ($normalized === 'Spring') {
|
||||||
return [sprintf('%04d-01-25', $y2), sprintf('%04d-05-31', $y2)];
|
return [
|
||||||
|
$md($springStartCfg, $y2, sprintf('%04d-01-25', $y2)),
|
||||||
|
$md($springEndCfg, $y2, sprintf('%04d-05-31', $y2)),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -212,42 +212,42 @@
|
|||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<img
|
<img
|
||||||
class="img-fluid rounded bg-light p-1"
|
class="img-fluid rounded bg-light p-1"
|
||||||
src=""assets/images/classes-1.jpg"
|
src="assets/images/classes-1.jpg"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<img
|
<img
|
||||||
class="img-fluid rounded bg-light p-1"
|
class="img-fluid rounded bg-light p-1"
|
||||||
src=""assets/images/classes-2.jpg"
|
src="assets/images/classes-2.jpg"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<img
|
<img
|
||||||
class="img-fluid rounded bg-light p-1"
|
class="img-fluid rounded bg-light p-1"
|
||||||
src=""assets/images/classes-3.jpg"
|
src="assets/images/classes-3.jpg"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<img
|
<img
|
||||||
class="img-fluid rounded bg-light p-1"
|
class="img-fluid rounded bg-light p-1"
|
||||||
src=""assets/images/classes-4.jpg"
|
src="assets/images/classes-4.jpg"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<img
|
<img
|
||||||
class="img-fluid rounded bg-light p-1"
|
class="img-fluid rounded bg-light p-1"
|
||||||
src=""assets/images/classes-5.jpg"
|
src="assets/images/classes-5.jpg"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<img
|
<img
|
||||||
class="img-fluid rounded bg-light p-1"
|
class="img-fluid rounded bg-light p-1"
|
||||||
src=""assets/images/classes-6.jpg"
|
src="assets/images/classes-6.jpg"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+8
-8
@@ -44,7 +44,7 @@
|
|||||||
<!-- Navbar Start -->
|
<!-- Navbar Start -->
|
||||||
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
||||||
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
||||||
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
|
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
|
||||||
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
||||||
@@ -170,22 +170,22 @@
|
|||||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||||
<div class="row g-2 pt-2">
|
<div class="row g-2 pt-2">
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,4 +236,4 @@
|
|||||||
<script src="<?= base_url('js/main.js') ?>"></script>
|
<script src="<?= base_url('js/main.js') ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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,424 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="wrapper">
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// ── Group sections by grade (mirrors daily_attendance logic) ─────────────────
|
||||||
|
$gradeGroups = []; // sortKey => ['label'=>, 'slug'=>, 'csids'=>[]]
|
||||||
|
|
||||||
|
foreach ($statsPerClass as $csid => $cs) {
|
||||||
|
$name = $cs['name'];
|
||||||
|
$lower = strtolower(trim($name));
|
||||||
|
|
||||||
|
if (preg_match('/grade\s*(\d+)/i', $name, $m)) {
|
||||||
|
$n = (int)$m[1];
|
||||||
|
$label = 'Grade ' . $n;
|
||||||
|
$sortKey = '1_' . str_pad($n, 3, '0', STR_PAD_LEFT);
|
||||||
|
$slug = 'grade' . $n;
|
||||||
|
} elseif (strpos($lower, 'kg') !== false || strpos($lower, 'kindergarten') !== false) {
|
||||||
|
$label = 'KG';
|
||||||
|
$sortKey = '0_kg';
|
||||||
|
$slug = 'kg';
|
||||||
|
} elseif (strpos($lower, 'arabic') !== false) {
|
||||||
|
$label = 'Arabic';
|
||||||
|
$sortKey = '2_arabic';
|
||||||
|
$slug = 'arabic';
|
||||||
|
} elseif (strpos($lower, 'youth') !== false) {
|
||||||
|
$label = 'Youth';
|
||||||
|
$sortKey = '5_youth';
|
||||||
|
$slug = 'youth';
|
||||||
|
} else {
|
||||||
|
$label = $name;
|
||||||
|
$sortKey = '3_' . $lower;
|
||||||
|
$slug = preg_replace('/[^a-z0-9]+/', '-', $lower);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($gradeGroups[$sortKey])) {
|
||||||
|
$gradeGroups[$sortKey] = ['label' => $label, 'slug' => $slug, 'csids' => []];
|
||||||
|
}
|
||||||
|
$gradeGroups[$sortKey]['csids'][] = $csid;
|
||||||
|
}
|
||||||
|
ksort($gradeGroups);
|
||||||
|
$gradeKeys = array_keys($gradeGroups);
|
||||||
|
$defaultKey = $gradeKeys[0] ?? null;
|
||||||
|
|
||||||
|
$decisionBadge = [
|
||||||
|
'Pass' => 'success',
|
||||||
|
'Repeat Class' => 'danger',
|
||||||
|
'Make-up exam in fall' => 'info',
|
||||||
|
'Deferred decision' => 'info',
|
||||||
|
'Expel' => 'danger',
|
||||||
|
'Withdrawn' => 'secondary',
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
|
||||||
|
<h2 class="text-center mt-4 mb-3"><i class="bi bi-award me-2"></i>Generate Certificates</h2>
|
||||||
|
|
||||||
|
<!-- School year filter -->
|
||||||
|
<div class="d-flex justify-content-end mb-3">
|
||||||
|
<form method="get" action="<?= site_url('administrator/certificates') ?>" class="d-flex gap-2 align-items-center">
|
||||||
|
<label class="form-label mb-0 me-1 text-muted small">School Year</label>
|
||||||
|
<input type="text" name="school_year" class="form-control form-control-sm" style="width:130px;"
|
||||||
|
value="<?= esc($schoolYear) ?>" placeholder="e.g. 2024-2025">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Reload
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (session()->getFlashdata('error')): ?>
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<?= esc(session()->getFlashdata('error')) ?>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (empty($gradeGroups)): ?>
|
||||||
|
<div class="alert alert-info">No classes found for <?= esc($schoolYear) ?>.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<!-- Grade tabs -->
|
||||||
|
<ul class="nav nav-tabs justify-content-center" id="certTabs" role="tablist" style="flex-wrap:wrap;row-gap:.25rem;">
|
||||||
|
<?php foreach ($gradeGroups as $key => $group): ?>
|
||||||
|
<?php
|
||||||
|
$slug = $group['slug'];
|
||||||
|
$label = $group['label'];
|
||||||
|
$total = array_sum(array_map(fn($id) => $statsPerClass[$id]['total'] ?? 0, $group['csids']));
|
||||||
|
$grpPass = array_sum(array_map(fn($id) => $statsPerClass[$id]['pass'] ?? 0, $group['csids']));
|
||||||
|
$grpCert = array_sum(array_map(fn($id) => $statsPerClass[$id]['cert'] ?? 0, $group['csids']));
|
||||||
|
$fullyDone = $grpPass > 0 && $grpCert >= $grpPass;
|
||||||
|
$hasPass = $grpPass > 0;
|
||||||
|
$isActive = ($key === $defaultKey);
|
||||||
|
$statusTitle = $hasPass
|
||||||
|
? ($fullyDone ? 'Fully generated (' . $grpCert . '/' . $grpPass . ')' : 'Not fully generated (' . $grpCert . '/' . $grpPass . ')')
|
||||||
|
: 'No eligible students';
|
||||||
|
?>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<a class="nav-link <?= $isActive ? 'active' : '' ?>"
|
||||||
|
id="cert-<?= esc($slug) ?>-tab"
|
||||||
|
data-bs-toggle="tab"
|
||||||
|
href="#cert-<?= esc($slug) ?>"
|
||||||
|
role="tab"
|
||||||
|
aria-controls="cert-<?= esc($slug) ?>"
|
||||||
|
aria-selected="<?= $isActive ? 'true' : 'false' ?>">
|
||||||
|
<span class="cert-status-dot <?= !$hasPass ? 'no-eligible' : ($fullyDone ? 'done' : 'pending') ?>"
|
||||||
|
title="<?= esc($statusTitle) ?>"></span>
|
||||||
|
<?= esc($label) ?>
|
||||||
|
<span class="badge bg-secondary ms-1"><?= $total ?></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Tab content -->
|
||||||
|
<div class="tab-content mt-3" id="certTabContent">
|
||||||
|
<?php foreach ($gradeGroups as $key => $group): ?>
|
||||||
|
<?php $isActive = ($key === $defaultKey); ?>
|
||||||
|
<div class="tab-pane fade <?= $isActive ? 'show active' : '' ?>"
|
||||||
|
id="cert-<?= esc($group['slug']) ?>"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="cert-<?= esc($group['slug']) ?>-tab">
|
||||||
|
|
||||||
|
<?php foreach ($group['csids'] as $csid): ?>
|
||||||
|
<?php
|
||||||
|
$cs = $statsPerClass[$csid];
|
||||||
|
$students = $studentsByClass[$csid] ?? [];
|
||||||
|
$csPass = $cs['pass'];
|
||||||
|
$csCert = $cs['cert'];
|
||||||
|
$csRemain = max(0, $csPass - $csCert);
|
||||||
|
$formId = 'certForm-' . $csid;
|
||||||
|
$tableId = 'studentsTable-' . $csid;
|
||||||
|
?>
|
||||||
|
|
||||||
|
<h4 class="mt-4 mb-2 text-center"><?= esc($cs['name']) ?></h4>
|
||||||
|
|
||||||
|
<?php if (empty($students)): ?>
|
||||||
|
<p class="text-muted text-center">No active students.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<form method="post" action="<?= site_url('administrator/certificates/generate') ?>"
|
||||||
|
id="<?= esc($formId) ?>" class="cert-form mb-5">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="class_section_id" value="<?= (int)$csid ?>">
|
||||||
|
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
|
||||||
|
|
||||||
|
<!-- Stats + date picker -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
|
||||||
|
<div class="d-flex align-items-center gap-4 flex-wrap">
|
||||||
|
<span class="fw-semibold">
|
||||||
|
Students <span class="badge bg-secondary ms-1"><?= count($students) ?></span>
|
||||||
|
</span>
|
||||||
|
<span class="text-muted small">
|
||||||
|
<strong class="text-success"><?= $csPass ?></strong> Pass
|
||||||
|
</span>
|
||||||
|
<span class="text-muted small">
|
||||||
|
<strong class="text-primary"><?= $csCert ?></strong> Generated
|
||||||
|
</span>
|
||||||
|
<span class="text-muted small">
|
||||||
|
<strong class="<?= $csRemain > 0 ? 'text-warning' : 'text-muted' ?>"><?= $csRemain ?></strong> Remaining
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="input-group input-group-sm" style="width:200px;">
|
||||||
|
<span class="input-group-text"><i class="bi bi-calendar3"></i></span>
|
||||||
|
<input type="date" class="form-control cert-date-picker"
|
||||||
|
value="<?= date('Y-m-d') ?>" title="Certificate date">
|
||||||
|
<input type="hidden" name="cert_date" class="cert-date-hidden" value="<?= esc($certDate) ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Student table -->
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-striped align-middle mb-0 cert-students-table"
|
||||||
|
id="<?= esc($tableId) ?>">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:40px;" class="text-center">
|
||||||
|
<input class="form-check-input cert-select-all" type="checkbox">
|
||||||
|
</th>
|
||||||
|
<th>First Name</th>
|
||||||
|
<th>Last Name</th>
|
||||||
|
<th>Decision</th>
|
||||||
|
<th>Certificate No.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($students as $s): ?>
|
||||||
|
<?php
|
||||||
|
$sid = (int)$s['student_id'];
|
||||||
|
$stuDec = $decisionsByStudent[$sid] ?? [];
|
||||||
|
$certNo = $certsByStudent[$sid] ?? null;
|
||||||
|
$allDecs = array_map(fn($d) => $d['decision'], $stuDec);
|
||||||
|
$hasPending = in_array('', $allDecs, true);
|
||||||
|
$unique = array_values(array_unique(array_filter($allDecs, fn($d) => $d !== '')));
|
||||||
|
$isPass = !empty($stuDec) && !$hasPending && $unique === ['Pass'];
|
||||||
|
$displayDecs = $isPass ? ['Pass'] : array_values(array_filter($unique, fn($d) => $d !== 'Pass'));
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td class="text-center">
|
||||||
|
<input class="form-check-input cert-student-check" type="checkbox"
|
||||||
|
name="student_ids[]" value="<?= $sid ?>"
|
||||||
|
<?= $isPass ? '' : 'disabled' ?>>
|
||||||
|
</td>
|
||||||
|
<td><?= esc($s['firstname']) ?></td>
|
||||||
|
<td><?= esc($s['lastname']) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if (empty($stuDec)): ?>
|
||||||
|
<span class="text-muted small">—</span>
|
||||||
|
<?php elseif ($hasPending || empty($unique) || empty($displayDecs)): ?>
|
||||||
|
<span class="badge bg-warning text-dark">Pending</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($displayDecs as $dec): $color = $decisionBadge[$dec] ?? 'secondary'; ?>
|
||||||
|
<span class="badge bg-<?= esc($color) ?> me-1"><?= esc($dec) ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php if ($certNo): ?>
|
||||||
|
<a href="<?= site_url('administrator/certificates/reprint/' . rawurlencode($certNo)) ?>"
|
||||||
|
target="_blank" class="font-monospace small">
|
||||||
|
<?= esc($certNo) ?>
|
||||||
|
</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-2">
|
||||||
|
<span class="text-muted small cert-selected-count">0 students selected</span>
|
||||||
|
<button type="submit" class="btn btn-success cert-generate-btn" disabled>
|
||||||
|
<i class="bi bi-printer me-1"></i>Generate & Print Certificates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div><!-- .wrapper -->
|
||||||
|
</div><!-- .container-fluid -->
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const csrfRefreshUrl = <?= json_encode(site_url('administrator/certificates/csrf-token'), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||||
|
let currentCsrfTokenName = <?= json_encode(csrf_token(), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||||
|
|
||||||
|
function cssEscape(v) {
|
||||||
|
return window.CSS?.escape ? window.CSS.escape(v) : String(v).replace(/(["\\.#:[\],= ])/g, '\\$1');
|
||||||
|
}
|
||||||
|
function updateCsrfInForm(form, name, hash) {
|
||||||
|
if (!form || !name || !hash) return;
|
||||||
|
form.querySelectorAll('input[type="hidden"]').forEach(inp => {
|
||||||
|
if (inp.name === name || inp.name === currentCsrfTokenName) { inp.name = name; inp.value = hash; }
|
||||||
|
});
|
||||||
|
let inp = form.querySelector(`input[name="${cssEscape(name)}"]`);
|
||||||
|
if (!inp) { inp = document.createElement('input'); inp.type = 'hidden'; inp.name = name; form.appendChild(inp); }
|
||||||
|
inp.value = hash;
|
||||||
|
currentCsrfTokenName = name;
|
||||||
|
}
|
||||||
|
async function refreshCsrf(form) {
|
||||||
|
const r = await fetch(csrfRefreshUrl, { method: 'GET', credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Cache-Control': 'no-store' } });
|
||||||
|
if (!r.ok) throw new Error('CSRF refresh failed');
|
||||||
|
const d = await r.json();
|
||||||
|
if (d?.csrf_token && d?.csrf_hash) updateCsrfInForm(form, d.csrf_token, d.csrf_hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pdfWindow = null, activePdfBlobUrl = null;
|
||||||
|
function openPdfWindow() {
|
||||||
|
if (!pdfWindow || pdfWindow.closed) pdfWindow = window.open('', 'certificatePdfWindow');
|
||||||
|
if (!pdfWindow) return null;
|
||||||
|
pdfWindow.document.open();
|
||||||
|
pdfWindow.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Certificate PDF</title>
|
||||||
|
<style>html,body{margin:0;height:100%;background:#f3f4f6;font-family:Arial,sans-serif}.viewer-shell{display:flex;flex-direction:column;height:100%}.viewer-status{padding:12px 16px;background:#111827;color:#fff;font-size:14px}.viewer-frame{flex:1;width:100%;border:0;background:#cbd5e1}</style></head>
|
||||||
|
<body><div class="viewer-shell"><div class="viewer-status" id="viewerStatus">Preparing certificate PDF...</div><iframe class="viewer-frame" id="pdfFrame"></iframe></div>
|
||||||
|
<script>window.showCertificatePdf=function(url,fn){const f=document.getElementById('pdfFrame'),s=document.getElementById('viewerStatus');if(s)s.textContent=fn?'Showing '+fn:'Certificate PDF ready';if(f)f.src=url;document.title=fn||'Certificate PDF'};<\/script></body></html>`);
|
||||||
|
pdfWindow.document.close();
|
||||||
|
return pdfWindow;
|
||||||
|
}
|
||||||
|
function showError(pane, msg) {
|
||||||
|
let el = pane.querySelector('.cert-inline-error');
|
||||||
|
if (!el) { el = document.createElement('div'); el.className = 'alert alert-danger alert-dismissible fade show cert-inline-error'; pane.prepend(el); }
|
||||||
|
el.innerHTML = `${msg}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.cert-form').forEach(function (form) {
|
||||||
|
const pane = form.closest('.tab-pane') || form.parentElement;
|
||||||
|
const selectAll = form.querySelector('.cert-select-all');
|
||||||
|
const checks = form.querySelectorAll('.cert-student-check');
|
||||||
|
const eligibleChecks = form.querySelectorAll('.cert-student-check:not([disabled])');
|
||||||
|
const generateBtn = form.querySelector('.cert-generate-btn');
|
||||||
|
const selectedCount = form.querySelector('.cert-selected-count');
|
||||||
|
const datePicker = form.querySelector('.cert-date-picker');
|
||||||
|
const dateHidden = form.querySelector('.cert-date-hidden');
|
||||||
|
|
||||||
|
if (datePicker && dateHidden) {
|
||||||
|
datePicker.addEventListener('change', function () {
|
||||||
|
const d = new Date(this.value + 'T00:00:00');
|
||||||
|
if (!isNaN(d)) dateHidden.value = String(d.getMonth()+1).padStart(2,'0')+'/'+String(d.getDate()).padStart(2,'0')+'/'+d.getFullYear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateState() {
|
||||||
|
const chosen = form.querySelectorAll('.cert-student-check:checked').length;
|
||||||
|
if (selectedCount) selectedCount.textContent = chosen + ' student' + (chosen !== 1 ? 's' : '') + ' selected';
|
||||||
|
if (generateBtn) generateBtn.disabled = chosen === 0;
|
||||||
|
if (selectAll) {
|
||||||
|
const ec = eligibleChecks.length;
|
||||||
|
selectAll.checked = ec > 0 && chosen === ec;
|
||||||
|
selectAll.indeterminate = chosen > 0 && chosen < ec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.addEventListener('change', function () {
|
||||||
|
eligibleChecks.forEach(c => { c.checked = this.checked; });
|
||||||
|
updateState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
checks.forEach(c => c.addEventListener('change', updateState));
|
||||||
|
updateState();
|
||||||
|
|
||||||
|
form.addEventListener('submit', async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const chosen = form.querySelectorAll('.cert-student-check:checked').length;
|
||||||
|
if (chosen === 0) { showError(pane, 'Please select at least one student.'); return; }
|
||||||
|
const win = openPdfWindow();
|
||||||
|
if (!win) { showError(pane, 'Unable to open PDF tab — please allow pop-ups.'); return; }
|
||||||
|
const origHtml = generateBtn.innerHTML;
|
||||||
|
generateBtn.disabled = true;
|
||||||
|
generateBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Generating...';
|
||||||
|
try {
|
||||||
|
await refreshCsrf(form);
|
||||||
|
const csrfVal = form.querySelector(`input[name="${cssEscape(currentCsrfTokenName)}"]`)?.value || '';
|
||||||
|
const fd = new FormData(form);
|
||||||
|
if (csrfVal) fd.set(currentCsrfTokenName, csrfVal);
|
||||||
|
const resp = await fetch(form.action, { method: 'POST', body: fd, credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||||
|
const nn = resp.headers.get('X-CSRF-TOKEN-NAME'), nh = resp.headers.get('X-CSRF-TOKEN');
|
||||||
|
if (nn && nh) updateCsrfInForm(form, nn, nh);
|
||||||
|
const ct = resp.headers.get('Content-Type') || '';
|
||||||
|
if (!resp.ok || !ct.toLowerCase().includes('application/pdf')) {
|
||||||
|
let msg = 'Certificate generation failed.';
|
||||||
|
try { const d = await resp.json(); if (d?.error) msg = d.error; } catch (_) { try { msg = await resp.text() || msg; } catch (_2) {} }
|
||||||
|
win.close(); showError(pane, msg);
|
||||||
|
await refreshCsrf(form).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const disp = resp.headers.get('Content-Disposition') || '';
|
||||||
|
const fn = (disp.match(/filename="?([^"]+)"?/i) || [])[1] || 'Certificates.pdf';
|
||||||
|
const url = URL.createObjectURL(await resp.blob());
|
||||||
|
if (activePdfBlobUrl) URL.revokeObjectURL(activePdfBlobUrl);
|
||||||
|
activePdfBlobUrl = url;
|
||||||
|
win.showCertificatePdf(url, fn);
|
||||||
|
const activePane = form.closest('.tab-pane');
|
||||||
|
if (activePane?.id) window.location.hash = activePane.id;
|
||||||
|
window.location.reload();
|
||||||
|
} catch (_) {
|
||||||
|
showError(pane, 'Certificate generation failed. Please try again.');
|
||||||
|
await refreshCsrf(form).catch(() => {});
|
||||||
|
} finally {
|
||||||
|
generateBtn.innerHTML = origHtml;
|
||||||
|
updateState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?= $this->section('scripts') ?>
|
||||||
|
<script>
|
||||||
|
// Restore tab from hash — runs after Bootstrap is loaded
|
||||||
|
(function () {
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (!hash) return;
|
||||||
|
const tabLink = document.querySelector('a[href="' + hash + '"][data-bs-toggle="tab"]');
|
||||||
|
if (tabLink && window.bootstrap?.Tab) {
|
||||||
|
new bootstrap.Tab(tabLink).show();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.cert-status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cert-status-dot.done { background-color: #198754; }
|
||||||
|
.cert-status-dot.pending { background-color: #dc3545; }
|
||||||
|
.cert-status-dot.no-eligible { background-color: #adb5bd; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
if (!window.$ || !$.fn?.DataTable) return;
|
||||||
|
$(function () {
|
||||||
|
document.querySelectorAll('.cert-students-table').forEach(function (tbl) {
|
||||||
|
if ($.fn.DataTable.isDataTable(tbl)) return;
|
||||||
|
try {
|
||||||
|
$(tbl).DataTable({
|
||||||
|
order: [[1, 'asc'], [2, 'asc']],
|
||||||
|
pageLength: 100,
|
||||||
|
lengthMenu: [25, 50, 100, 200],
|
||||||
|
columnDefs: [{ orderable: false, targets: [0, 3, 4] }]
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -214,7 +214,17 @@
|
|||||||
<strong class="small mb-0"><?= esc($section['label'] ?? $subjectName) ?></strong>
|
<strong class="small mb-0"><?= esc($section['label'] ?? $subjectName) ?></strong>
|
||||||
<span class="badge <?= $badgeClass ?>"><?= esc($statusTag) ?></span>
|
<span class="badge <?= $badgeClass ?>"><?= esc($statusTag) ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-muted"><?= $report ? esc($report['unit_title'] ?: '-') : 'No submission' ?></div>
|
<div class="small">
|
||||||
|
<?php if ($report): ?>
|
||||||
|
<?= view('admin/partials/class_progress_unit_display', [
|
||||||
|
'unitTitle' => (string) ($report['unit_title'] ?? ''),
|
||||||
|
'isQuran' => ($subjectName === 'Quran/Arabic'),
|
||||||
|
'compact' => true,
|
||||||
|
]) ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="text-muted">No submission</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
<?php
|
<?php
|
||||||
$subjectName = $section['db_subject'] ?? $section['label'] ?? $slug;
|
$subjectName = $section['db_subject'] ?? $section['label'] ?? $slug;
|
||||||
$isQuran = $subjectName === 'Quran/Arabic';
|
$isQuran = $subjectName === 'Quran/Arabic';
|
||||||
$unitLabel = $isQuran ? 'Surah / Custom Arabic' : 'Unit / Chapter';
|
|
||||||
$homeworkLabel = $isQuran ? 'Arabic Practice / Homework' : 'Assigned Homework';
|
$homeworkLabel = $isQuran ? 'Arabic Practice / Homework' : 'Assigned Homework';
|
||||||
$report = $reportsBySubject[$subjectName] ?? null;
|
$report = $reportsBySubject[$subjectName] ?? null;
|
||||||
?>
|
?>
|
||||||
@@ -51,7 +50,13 @@
|
|||||||
<?php if (! $report): ?>
|
<?php if (! $report): ?>
|
||||||
<div class="text-muted">No entry submitted for this subject this week.</div>
|
<div class="text-muted">No entry submitted for this subject this week.</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="mb-2"><strong><?= $unitLabel ?>:</strong> <?= esc($report['unit_title'] ?: '-') ?></div>
|
<div class="mb-2">
|
||||||
|
<?= view('admin/partials/class_progress_unit_display', [
|
||||||
|
'unitTitle' => (string) ($report['unit_title'] ?? ''),
|
||||||
|
'isQuran' => $isQuran,
|
||||||
|
'compact' => false,
|
||||||
|
]) ?>
|
||||||
|
</div>
|
||||||
<?php if (!empty($report['materials'])): ?>
|
<?php if (!empty($report['materials'])): ?>
|
||||||
<div class="mb-2"><strong>Materials:</strong> <?= esc($report['materials']) ?></div>
|
<div class="mb-2"><strong>Materials:</strong> <?= esc($report['materials']) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Renders class progress unit_title with explicit curriculum vs custom subject lines.
|
||||||
|
*
|
||||||
|
* @var string $unitTitle
|
||||||
|
* @var bool $isQuran
|
||||||
|
* @var bool $compact If true, tighter layout for the list accordion.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use App\Controllers\ClassProgressController;
|
||||||
|
|
||||||
|
$unitTitle = trim((string) ($unitTitle ?? ''));
|
||||||
|
$compact = ! empty($compact);
|
||||||
|
$isQuran = ! empty($isQuran);
|
||||||
|
|
||||||
|
$split = ClassProgressController::splitUnitTitleForDisplay($unitTitle);
|
||||||
|
$curriculumLines = $split['curriculum'];
|
||||||
|
$customTopics = $split['custom'];
|
||||||
|
|
||||||
|
$labelCurriculum = $isQuran ? 'Surah / curriculum' : 'Unit / chapter';
|
||||||
|
$labelCustom = $isQuran ? 'Custom Surah / Arabic' : 'Custom subject(s)';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php if ($unitTitle === ''): ?>
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
<?php elseif ($compact): ?>
|
||||||
|
<?php if (! empty($customTopics)): ?>
|
||||||
|
<div class="small">
|
||||||
|
<span class="badge text-bg-info me-1"><?= $isQuran ? 'Custom' : 'Custom subject' ?></span><?= esc(implode(', ', $customTopics)) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (! empty($curriculumLines)): ?>
|
||||||
|
<div class="small <?= ! empty($customTopics) ? 'text-muted mt-1' : 'text-muted' ?>"><?= esc(implode(' · ', $curriculumLines)) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (empty($customTopics) && empty($curriculumLines)): ?>
|
||||||
|
<div class="small text-muted"><?= esc($unitTitle) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php if (! empty($curriculumLines)): ?>
|
||||||
|
<div class="mb-2"><strong><?= esc($labelCurriculum) ?>:</strong> <?= esc(implode(' ; ', $curriculumLines)) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (! empty($customTopics)): ?>
|
||||||
|
<div class="mb-2"><strong><?= esc($labelCustom) ?>:</strong> <?= esc(implode(' ; ', $customTopics)) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (empty($curriculumLines) && empty($customTopics)): ?>
|
||||||
|
<div class="mb-2"><strong><?= esc($labelCurriculum) ?>:</strong> <?= esc($unitTitle) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
@@ -4,6 +4,11 @@
|
|||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<h2>Create Event</h2>
|
<h2>Create 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));
|
||||||
|
?>
|
||||||
<form method="post" action="<?= site_url('administrator/events/create') ?>" enctype="multipart/form-data">
|
<form method="post" action="<?= site_url('administrator/events/create') ?>" enctype="multipart/form-data">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -15,7 +20,7 @@
|
|||||||
<label class="form-label">Category</label>
|
<label class="form-label">Category</label>
|
||||||
<select name="event_category" class="form-control" required>
|
<select name="event_category" class="form-control" required>
|
||||||
<option value="" selected disabled>Select category</option>
|
<option value="" selected disabled>Select category</option>
|
||||||
<?php foreach (($categories ?? []) as $category): ?>
|
<?php foreach ($allCategories as $category): ?>
|
||||||
<option value="<?= esc($category) ?>"><?= esc(ucwords($category)) ?></option>
|
<option value="<?= esc($category) ?>"><?= esc(ucwords($category)) ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
@@ -23,7 +28,7 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Description</label>
|
<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>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -51,9 +56,89 @@
|
|||||||
<input type="text" name="school_year" class="form-control" required>
|
<input type="text" name="school_year" class="form-control" required>
|
||||||
</div>
|
</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>
|
<button type="submit" class="btn btn-success">Create Event</button>
|
||||||
<a href="<?= site_url('administrator/events') ?>" class="btn btn-secondary">Cancel</a>
|
<a href="<?= site_url('administrator/events') ?>" class="btn btn-secondary">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?= $this->endSection() ?>
|
<?= $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">
|
<div class="container mt-4">
|
||||||
<h2>Edit Event</h2>
|
<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')): ?>
|
<?php if (session()->getFlashdata('error')): ?>
|
||||||
<div class="alert alert-danger"><?= session()->getFlashdata('error') ?></div>
|
<div class="alert alert-danger"><?= session()->getFlashdata('error') ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -23,7 +29,7 @@
|
|||||||
<label class="form-label">Category</label>
|
<label class="form-label">Category</label>
|
||||||
<select name="event_category" class="form-control" required>
|
<select name="event_category" class="form-control" required>
|
||||||
<option value="" disabled <?= empty($event['event_category']) ? 'selected' : '' ?>>Select category</option>
|
<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' : '' ?>>
|
<option value="<?= esc($category) ?>" <?= (string)($event['event_category'] ?? '') === (string)$category ? 'selected' : '' ?>>
|
||||||
<?= esc(ucwords($category)) ?>
|
<?= esc(ucwords($category)) ?>
|
||||||
</option>
|
</option>
|
||||||
@@ -33,7 +39,7 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Description</label>
|
<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>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -44,7 +50,7 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Current Flyer</label><br>
|
<label class="form-label">Current Flyer</label><br>
|
||||||
<?php if ($event['flyer']): ?>
|
<?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: ?>
|
<?php else: ?>
|
||||||
<div class="text-muted">No flyer uploaded.</div>
|
<div class="text-muted">No flyer uploaded.</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -70,9 +76,89 @@
|
|||||||
<input type="text" name="school_year" class="form-control" value="<?= esc($event['school_year']) ?>" required>
|
<input type="text" name="school_year" class="form-control" value="<?= esc($event['school_year']) ?>" required>
|
||||||
</div>
|
</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>
|
<button type="submit" class="btn btn-primary">Update Event</button>
|
||||||
<a href="<?= site_url('administrator/events') ?>" class="btn btn-secondary">Cancel</a>
|
<a href="<?= site_url('administrator/events') ?>" class="btn btn-secondary">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?= $this->endSection() ?>
|
<?= $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">
|
<div class="container mt-5">
|
||||||
<h3>Event Charges</h3>
|
<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')): ?>
|
<?php if (session()->getFlashdata('success')): ?>
|
||||||
<div class="alert alert-success"><?= session()->getFlashdata('success') ?></div>
|
<div class="alert alert-success"><?= session()->getFlashdata('success') ?></div>
|
||||||
@@ -11,110 +59,473 @@
|
|||||||
<div class="alert alert-danger"><?= session()->getFlashdata('error') ?></div>
|
<div class="alert alert-danger"><?= session()->getFlashdata('error') ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Add New Charge Form -->
|
<?php
|
||||||
<form action="<?= site_url('payment/event_charges') ?>" method="post">
|
$classSectionNames = $classSectionNames ?? [];
|
||||||
<?= csrf_field() ?>
|
$semesterOptions = $semesterOptions ?? [];
|
||||||
<div class="row">
|
$schoolYearOptions = $schoolYearOptions ?? [];
|
||||||
<div class="col-md-3 mt-3">
|
?>
|
||||||
<label for="event_id" class="form-label">Select Event</label>
|
|
||||||
<select name="event_id" id="event_id" class="form-select" required>
|
|
||||||
<option value="">-- Select Event --</option>
|
|
||||||
<?php foreach ($events as $event): ?>
|
|
||||||
<option value="<?= esc($event['id']) ?>">
|
|
||||||
<?= esc($event['event_name']) ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-3 mt-3">
|
<?php
|
||||||
<label for="parent_id" class="form-label">Parent</label>
|
$filterParentId = $filterParentId ?? 0;
|
||||||
<select id="parent_id" name="parent_id" class="form-select" required>
|
$parentBalances = $parentBalances ?? [];
|
||||||
<option value="">-- Select Parent --</option>
|
$selectedEvent = null;
|
||||||
<?php foreach ($parents as $parent): ?>
|
if (!empty($filterEventId)) {
|
||||||
<option value="<?= $parent['id'] ?>">
|
foreach ($events as $event) {
|
||||||
<?= esc($parent['firstname'] . ' ' . $parent['lastname']) ?> (<?= esc($parent['school_id']) ?>)
|
if ((int)$event['id'] === (int)$filterEventId) {
|
||||||
</option>
|
$selectedEvent = $event;
|
||||||
<?php endforeach; ?>
|
break;
|
||||||
</select>
|
}
|
||||||
</div>
|
}
|
||||||
|
}
|
||||||
<div class="col-12 mt-3" id="studentCheckboxContainer" style="display: none;">
|
?>
|
||||||
<label>Select Students:</label>
|
<div class="card mb-4">
|
||||||
<div id="studentList" class="row"></div>
|
<div class="card-body">
|
||||||
</div>
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary mt-3">Submit</button>
|
<!-- Add New Charge Form -->
|
||||||
<a href="<?= site_url('administrator/events') ?>" class="btn btn-success mt-3">Event List</a>
|
<div class="card mb-4">
|
||||||
</form>
|
<div class="card-body">
|
||||||
<br>
|
<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']) ?>" data-amount="<?= esc(number_format($event['amount'] ?? 0, 2, '.', '')) ?>" <?= isset($filterEventId) && $filterEventId == $event['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= esc($event['event_name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($selectedEvent): ?>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="border rounded bg-light p-3 h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h6 class="m-0"><?= esc($selectedEvent['event_name']) ?></h6>
|
||||||
|
<span class="badge bg-info text-dark">
|
||||||
|
$<?= esc(number_format($selectedEvent['amount'] ?? 0, 2)) ?> fee
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<?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')) ?>
|
||||||
|
</small>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<option value="">-- Select Parent --</option>
|
||||||
|
<?php foreach ($parents as $parent): ?>
|
||||||
|
<option value="<?= esc($parent['id']) ?>" <?= $filterParentId && $filterParentId == $parent['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= esc($parent['firstname'] . ' ' . $parent['lastname']) ?> (<?= esc($parent['school_id']) ?>)
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4" id="studentCheckboxContainer" style="display: none;">
|
||||||
|
<label class="form-label">Select Students:</label>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Charge Tables grouped by event -->
|
<!-- Charge Tables grouped by event -->
|
||||||
<?php
|
<?php
|
||||||
$grouped = [];
|
$grouped = [];
|
||||||
foreach ($charges as $charge) {
|
foreach ($charges as $charge) {
|
||||||
$label = $charge['event_name'] ?? 'N/A';
|
$eventId = (int)($charge['event_id'] ?? 0);
|
||||||
$grouped[$label][] = $charge;
|
if (!isset($grouped[$eventId])) {
|
||||||
|
$grouped[$eventId] = [
|
||||||
|
'label' => $charge['event_name'] ?? 'N/A',
|
||||||
|
'rows' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$grouped[$eventId]['rows'][] = $charge;
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php if (empty($grouped)): ?>
|
<?php if (empty($grouped)): ?>
|
||||||
<div class="alert alert-info">No charges found.</div>
|
<div class="alert alert-info">No charges found.</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($grouped as $eventLabel => $rows): ?>
|
<?php
|
||||||
<div class="card mb-4">
|
$printBaseParams = array_filter([
|
||||||
<div class="card-header bg-light fw-semibold">
|
'school_year' => $school_year,
|
||||||
<?= esc($eventLabel) ?>
|
'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>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
|
<?php
|
||||||
|
$totalParticipants = 0;
|
||||||
|
$totalCharged = 0.0;
|
||||||
|
?>
|
||||||
<div class="table-responsive">
|
<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">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th><button type="button" class="sortable-header" data-sort-index="0" data-sort-type="number">ID</button></th>
|
||||||
<th>Parent Name</th>
|
<th><button type="button" class="sortable-header" data-sort-index="1" data-sort-type="text">Parent Name</button></th>
|
||||||
<th>Student Name</th>
|
<th><button type="button" class="sortable-header" data-sort-index="2" data-sort-type="text">Student Name</button></th>
|
||||||
<th>Charged Amount</th>
|
<th><button type="button" class="sortable-header" data-sort-index="3" data-sort-type="text">External Info</button></th>
|
||||||
<th>Is Participating</th>
|
<th><button type="button" class="sortable-header" data-sort-index="4" data-sort-type="text">Class Section</button></th>
|
||||||
<th>Semester</th>
|
<th><button type="button" class="sortable-header" data-sort-index="5" data-sort-type="number">Charged Amount</button></th>
|
||||||
<th>Year</th>
|
<th><button type="button" class="sortable-header" data-sort-index="6" data-sort-type="date">Created</button></th>
|
||||||
<th>Created</th>
|
<th><button type="button" class="sortable-header" data-sort-index="7" data-sort-type="text">Waiver</button></th>
|
||||||
</tr>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($rows as $charge): ?>
|
<?php foreach ($rows as $charge): ?>
|
||||||
<tr>
|
<?php
|
||||||
<td><?= esc($charge['id']) ?></td>
|
$isParticipating = ($charge['participation'] === 'yes');
|
||||||
<td><?= esc($charge['parent_firstname'] . ' ' . $charge['parent_lastname']) ?></td>
|
$isEventPaid = !empty($charge['event_paid']);
|
||||||
<td>
|
$returnParams = [];
|
||||||
<?= $charge['student_firstname']
|
if (!empty($semester)) {
|
||||||
? esc($charge['student_firstname'] . ' ' . $charge['student_lastname'])
|
$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>
|
||||||
<td>$<?= esc(number_format($charge['charged'] ?? 0, 2)) ?></td>
|
<td data-sort-value="<?= esc(strtolower($externalInfoValue)) ?>">
|
||||||
<td>
|
<?php if ($infoPhone): ?>
|
||||||
<?= $charge['participation']
|
<div class="text-muted small"><?= esc($infoPhone) ?></div>
|
||||||
? '<span class="badge bg-success">Yes</span>'
|
<?php endif; ?>
|
||||||
: '<span class="badge bg-warning">No</span>' ?>
|
<?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>
|
||||||
<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>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
<?php
|
||||||
|
if ($isParticipating) {
|
||||||
|
$totalParticipants++;
|
||||||
|
$totalCharged += (float) ($charge['event_amount'] ?? 0);
|
||||||
|
}
|
||||||
|
?>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endif; ?>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</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->endSection() ?>
|
||||||
|
|
||||||
<?= $this->section('scripts') ?>
|
<?= $this->section('scripts') ?>
|
||||||
<script>
|
<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() {
|
function loadStudentsWithCharges() {
|
||||||
let parentId = $('#parent_id').val();
|
let parentId = $('#parent_id').val();
|
||||||
let eventId = $('#event_id').val();
|
let eventId = $('#event_id').val();
|
||||||
@@ -156,7 +567,238 @@ function loadStudentsWithCharges() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#parent_id').on('change', loadStudentsWithCharges);
|
const capitalizeName = (value) => {
|
||||||
$('#event_id').on('change', loadStudentsWithCharges);
|
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);
|
||||||
|
|
||||||
|
$('#event_id').on('change', function() {
|
||||||
|
let eventId = $(this).val();
|
||||||
|
let url = new URL(window.location.href);
|
||||||
|
if (eventId) {
|
||||||
|
url.searchParams.set('event_id', eventId);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('event_id');
|
||||||
|
}
|
||||||
|
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>
|
</script>
|
||||||
<?= $this->endSection() ?>
|
<?= $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>
|
<div class="alert alert-danger"><?= session()->getFlashdata('error') ?></div>
|
||||||
<?php endif; ?>
|
<?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)): ?>
|
<?php if (empty($events)): ?>
|
||||||
<div class="alert alert-info mb-3 d-inline-block">No events found.</div>
|
<div class="alert alert-info mb-3 d-inline-block">No events found.</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
@@ -21,7 +29,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Event Name</th>
|
<th>Event Name</th>
|
||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
<th>Amount</th>
|
<th>Description</th>
|
||||||
|
<th>Event Fees</th>
|
||||||
<th>Expiration Date</th>
|
<th>Expiration Date</th>
|
||||||
<th>Semester</th>
|
<th>Semester</th>
|
||||||
<th>School Year</th>
|
<th>School Year</th>
|
||||||
@@ -33,7 +42,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><?= esc($event['event_name']) ?></td>
|
<td><?= esc($event['event_name']) ?></td>
|
||||||
<td><?= esc($event['event_category'] ?? '—') ?></td>
|
<td><?= esc($event['event_category'] ?? '—') ?></td>
|
||||||
<td><?= esc($event['amount']) ?></td>
|
<td><?= esc(!empty($event['description']) ? $event['description'] : '—') ?></td>
|
||||||
|
<td>$<?= esc(number_format($event['amount'], 2)) ?></td>
|
||||||
<td><?= esc(!empty($event['expiration_date']) ? local_date($event['expiration_date'], 'm-d-Y') : '') ?></td>
|
<td><?= esc(!empty($event['expiration_date']) ? local_date($event['expiration_date'], 'm-d-Y') : '') ?></td>
|
||||||
<td><?= esc($event['semester']) ?></td>
|
<td><?= esc($event['semester']) ?></td>
|
||||||
<td><?= esc($event['school_year']) ?></td>
|
<td><?= esc($event['school_year']) ?></td>
|
||||||
|
|||||||
@@ -1,11 +1,36 @@
|
|||||||
<?= $this->extend('layout/management_layout') ?>
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
<?= $this->section('content') ?>
|
<?= $this->section('content') ?>
|
||||||
|
<?php
|
||||||
|
$statusBadges = $statusBadges ?? [];
|
||||||
|
$drafts = $drafts ?? [];
|
||||||
|
$legacyByClass = $legacyByClass ?? [];
|
||||||
|
$classSections = $classSections ?? [];
|
||||||
|
$examTypes = $examTypes ?? [];
|
||||||
|
$visibleClasses = $visibleClasses ?? [];
|
||||||
|
$classDraftGroups = $classDraftGroups ?? [];
|
||||||
|
$newSubmissionClasses = $newSubmissionClasses ?? [];
|
||||||
|
$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'];
|
||||||
|
$style = !empty($b['style']) ? ' style="' . esc($b['style']) . '"' : '';
|
||||||
|
return '<span class="badge ' . esc($b['class']) . '"' . $style . '>' . esc($b['label']) . '</span>';
|
||||||
|
};
|
||||||
|
|
||||||
|
$fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensions));
|
||||||
|
?>
|
||||||
<div class="container-fluid px-4 py-4">
|
<div class="container-fluid px-4 py-4">
|
||||||
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
|
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-1">Exam Draft Submissions</h1>
|
<h1 class="h3 mb-1">Exam draft submissions</h1>
|
||||||
<p class="text-muted mb-0 small">
|
<p class="text-muted mb-0 small">
|
||||||
<?= esc($semester ?: 'Semester') ?> <?= esc($schoolYear ?: '') ?>
|
<?= esc($reviewPortalLabel) ?> review portal · <?= esc($semester ?: 'Semester') ?> <?= esc($schoolYear ?: '') ?>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted small d-flex flex-column align-items-lg-end">
|
<div class="text-muted small d-flex flex-column align-items-lg-end">
|
||||||
@@ -15,13 +40,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (session()->getFlashdata('success')): ?>
|
<?php if (session()->getFlashdata('success')): ?>
|
||||||
<div class="alert alert-success">
|
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
|
||||||
<?= esc(session()->getFlashdata('success')) ?>
|
|
||||||
</div>
|
|
||||||
<?php elseif (session()->getFlashdata('error')): ?>
|
<?php elseif (session()->getFlashdata('error')): ?>
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
|
||||||
<?= esc(session()->getFlashdata('error')) ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<ul class="nav nav-pills mb-3" id="examDraftTabs" role="tablist">
|
<ul class="nav nav-pills mb-3" id="examDraftTabs" role="tablist">
|
||||||
@@ -32,177 +53,253 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link" id="legacy-tab" data-bs-toggle="pill" data-bs-target="#legacy" type="button" role="tab" aria-controls="legacy" aria-selected="false">
|
<button class="nav-link" id="legacy-tab" data-bs-toggle="pill" data-bs-target="#legacy" type="button" role="tab" aria-controls="legacy" aria-selected="false">
|
||||||
Legacy Exams
|
Legacy exams
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="tab-content" id="examDraftTabsContent">
|
<div class="tab-content" id="examDraftTabsContent">
|
||||||
<div class="tab-pane fade show active" id="submissions" role="tabpanel" aria-labelledby="submissions-tab">
|
<div class="tab-pane fade show active" id="submissions" role="tabpanel" aria-labelledby="submissions-tab">
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<?php if (empty($drafts)): ?>
|
<?php if (empty($visibleClasses)): ?>
|
||||||
<p class="text-muted mb-0">No exam drafts have been submitted yet.</p>
|
<p class="text-muted mb-0">No classes with enrolled students are available for this term.</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="table-responsive">
|
<div class="accordion exam-drafts-accordion" id="examDraftsAccordion">
|
||||||
<table class="table table-striped table-bordered align-middle mb-0 exam-drafts-table no-mgmt-sticky">
|
<?php foreach ($visibleClasses as $classSectionId => $classInfo): ?>
|
||||||
<thead class="table-light">
|
<?php $classDraftsForSection = $classDraftGroups[$classSectionId] ?? []; ?>
|
||||||
<tr>
|
<?php $collapseId = 'classDraftGroup_' . $classSectionId; ?>
|
||||||
<th>Teacher</th>
|
<div class="accordion-item mb-3">
|
||||||
<th>Class</th>
|
<h2 class="accordion-header" id="heading_<?= esc($collapseId) ?>">
|
||||||
<th>Title / Type</th>
|
<button class="accordion-button collapsed px-3" type="button" data-bs-toggle="collapse" data-bs-target="#<?= esc($collapseId) ?>" aria-expanded="false" aria-controls="<?= esc($collapseId) ?>">
|
||||||
<th>Version</th>
|
<div class="d-flex flex-column flex-lg-row w-100 justify-content-between gap-2">
|
||||||
<th>Submitted</th>
|
<div>
|
||||||
<th>Status</th>
|
<strong><?= esc($classInfo['class_section_name'] ?? ('Class ' . $classSectionId)) ?></strong>
|
||||||
<th>Files</th>
|
<div class="small text-muted">
|
||||||
<th>PDF</th>
|
<?= esc($classInfo['student_count'] ?? 0) ?> students · <?= esc(count($classDraftsForSection)) ?> submissions
|
||||||
<th>Review</th>
|
<?php if (!empty($newSubmissionClasses[$classSectionId])): ?>
|
||||||
</tr>
|
<span class="badge bg-info text-dark ms-2">New submission</span>
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($drafts as $draft): ?>
|
|
||||||
<?php
|
|
||||||
$teacherName = trim(($draft['teacher_first'] ?? '') . ' ' . ($draft['teacher_last'] ?? ''));
|
|
||||||
if ($teacherName === '') {
|
|
||||||
$teacherName = ($draft['admin_id'] ?? null) === ($draft['teacher_id'] ?? null)
|
|
||||||
? 'Admin Upload'
|
|
||||||
: 'User #' . ($draft['teacher_id'] ?? 'N/A');
|
|
||||||
}
|
|
||||||
$statusInfo = $statusBadges[$draft['status'] ?? 'pending'] ?? ['label' => 'Unknown', 'class' => 'bg-secondary text-white'];
|
|
||||||
$adminName = trim(($draft['admin_first'] ?? '') . ' ' . ($draft['admin_last'] ?? ''));
|
|
||||||
?>
|
|
||||||
<tr>
|
|
||||||
<td><?= esc($teacherName) ?></td>
|
|
||||||
<td><?= esc($draft['class_section_name'] ?? 'Unknown') ?></td>
|
|
||||||
<td>
|
|
||||||
<strong><?= esc($draft['draft_title'] ?? 'Untitled') ?></strong>
|
|
||||||
<div class="small text-muted"><?= esc($draft['exam_type'] ?? 'N/A') ?></div>
|
|
||||||
<?php if (!empty($draft['description'])): ?>
|
|
||||||
<div class="small text-muted"><?= esc($draft['description']) ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td class="text-nowrap">v<?= esc($draft['version'] ?? 1) ?></td>
|
|
||||||
<td class="text-nowrap"><?= esc((!empty($draft['created_at']) ? local_datetime($draft['created_at'], 'M j, Y g:i A') : '—')) ?></td>
|
|
||||||
<td>
|
|
||||||
<span class="badge <?= esc($statusInfo['class']) ?>">
|
|
||||||
<?= esc($statusInfo['label']) ?>
|
|
||||||
</span>
|
|
||||||
<?php if (!empty($draft['reviewed_at'])): ?>
|
|
||||||
<div class="small text-muted mt-1">
|
|
||||||
Reviewed <?= esc(local_datetime($draft['reviewed_at'], 'M j, Y g:i A')) ?>
|
|
||||||
<?php if ($adminName !== ''): ?>
|
|
||||||
by <?= esc($adminName) ?>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
</div>
|
||||||
</td>
|
<span class="badge bg-secondary align-self-start">
|
||||||
<td>
|
<?= esc(count($classDraftsForSection)) ?>
|
||||||
<?php if (!empty($draft['teacher_file'])): ?>
|
<?= count($classDraftsForSection) === 1 ? 'submission' : 'submissions' ?>
|
||||||
<div>
|
</span>
|
||||||
<a href="<?= base_url('exam-drafts/files/teacher/' . $draft['teacher_file']) ?>" target="_blank">
|
</div>
|
||||||
<?= esc($draft['teacher_filename'] ?? 'Submitted draft') ?>
|
</button>
|
||||||
</a>
|
</h2>
|
||||||
</div>
|
<div id="<?= esc($collapseId) ?>" class="accordion-collapse collapse" aria-labelledby="heading_<?= esc($collapseId) ?>">
|
||||||
<?php else: ?>
|
<div class="accordion-body pt-0 px-3">
|
||||||
<span class="text-muted small">No teacher file</span>
|
<?php if (empty($classDraftsForSection)): ?>
|
||||||
<?php endif; ?>
|
<p class="text-muted mb-0">No submissions yet for this class.</p>
|
||||||
<?php if (!empty($draft['final_file'])): ?>
|
<?php else: ?>
|
||||||
<div class="mt-2">
|
<div class="table-responsive">
|
||||||
<a href="<?= base_url('exam-drafts/files/final/' . $draft['final_file']) ?>" target="_blank" class="link-success small">
|
<table class="table table-striped table-bordered align-middle mb-0 exam-drafts-table no-mgmt-sticky">
|
||||||
<?= esc($draft['final_filename'] ?? 'Final draft') ?>
|
<thead class="table-light">
|
||||||
</a>
|
<tr>
|
||||||
</div>
|
<th>Teacher</th>
|
||||||
<?php endif; ?>
|
<th>Class</th>
|
||||||
</td>
|
<th>Title & AuthorNote</th>
|
||||||
<td class="text-nowrap">
|
<th>Version</th>
|
||||||
<?php
|
<th>Date-Time</th>
|
||||||
$pdfFile = $draft['final_pdf_file'] ?? null;
|
<th>Status</th>
|
||||||
// Fallback: if final_file itself is a pdf, use it
|
<th>Files</th>
|
||||||
if (empty($pdfFile) && !empty($draft['final_file']) && strtolower(pathinfo($draft['final_file'], PATHINFO_EXTENSION)) === 'pdf') {
|
<th>Reviewer Action</th>
|
||||||
$pdfFile = $draft['final_file'];
|
<th>Final version</th>
|
||||||
}
|
</tr>
|
||||||
?>
|
</thead>
|
||||||
<?php if (!empty($pdfFile)): ?>
|
<tbody>
|
||||||
<a href="<?= base_url('exam-drafts/files/final/' . $pdfFile) ?>" target="_blank" class="link-success small">
|
<?php foreach ($classDraftsForSection as $draft): ?>
|
||||||
PDF
|
<?php
|
||||||
</a>
|
$teacherName = trim(($draft['teacher_first'] ?? '') . ' ' . ($draft['teacher_last'] ?? ''));
|
||||||
<?php else: ?>
|
if ($teacherName === '') {
|
||||||
<span class="text-muted small">—</span>
|
$teacherName = ($draft['admin_id'] ?? null) === ($draft['teacher_id'] ?? null)
|
||||||
<?php endif; ?>
|
? 'Admin upload'
|
||||||
</td>
|
: 'User #' . ($draft['teacher_id'] ?? 'N/A');
|
||||||
<td>
|
}
|
||||||
<form method="post" action="<?= base_url('/administrator/exam-drafts/review') ?>" enctype="multipart/form-data">
|
$st = strtolower((string) ($draft['status'] ?? ''));
|
||||||
<?= csrf_field() ?>
|
$adminName = trim(($draft['admin_first'] ?? '') . ' ' . ($draft['admin_last'] ?? ''));
|
||||||
<input type="hidden" name="draft_id" value="<?= esc($draft['id']) ?>">
|
$authorNote = $draft['author_comment'] ?? $draft['description'] ?? '';
|
||||||
<div class="mb-2">
|
$reviewerNote = $draft['reviewer_comment'] ?? $draft['admin_comments'] ?? '';
|
||||||
<textarea
|
?>
|
||||||
name="admin_comments"
|
<tr>
|
||||||
rows="2"
|
<td><?= esc($teacherName) ?></td>
|
||||||
class="form-control form-control-sm"
|
<td><?= esc($draft['class_section_name'] ?? 'Unknown') ?></td>
|
||||||
placeholder="Optional note for the teacher"
|
<td>
|
||||||
><?= esc($draft['admin_comments'] ?? '') ?></textarea>
|
<strong><?= esc($draft['draft_title'] ?? 'Untitled') ?></strong>
|
||||||
</div>
|
<div class="small text-muted"><?= esc($draft['exam_type'] ?? 'N/A') ?></div>
|
||||||
<div class="mb-2">
|
<?php if ($authorNote !== ''): ?>
|
||||||
<label class="form-label small mb-1">Status</label>
|
<div class="small mt-1">
|
||||||
<select name="review_status" class="form-select form-select-sm">
|
<span class="text-muted fw-semibold">Author:</span>
|
||||||
<?php foreach ($statusOptions as $option): ?>
|
<?= esc($authorNote) ?>
|
||||||
<option value="<?= esc($option) ?>" <?= ($option === ($draft['status'] ?? '')) ? 'selected' : '' ?>>
|
</div>
|
||||||
<?= esc(ucfirst($option)) ?>
|
<?php endif; ?>
|
||||||
</option>
|
<?php if ($reviewerNote !== ''): ?>
|
||||||
|
<div class="small mt-1 border-top pt-1">
|
||||||
|
<span class="text-muted fw-semibold">Reviewer:</span>
|
||||||
|
<?= esc($reviewerNote) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap">v<?= esc((string) ($draft['version'] ?? 1)) ?></td>
|
||||||
|
<td class="text-nowrap"><?= esc(!empty($draft['created_at']) ? local_datetime($draft['created_at'], 'M j, Y g:i A') : '—') ?></td>
|
||||||
|
<td>
|
||||||
|
<?php
|
||||||
|
$badgeData = $statusBadges[$st] ?? ['label' => $st, 'class' => 'bg-secondary text-white'];
|
||||||
|
$badgeStyle = !empty($badgeData['style']) ? ' style="' . esc($badgeData['style']) . '"' : '';
|
||||||
|
?>
|
||||||
|
<span class="badge <?= esc($badgeData['class']) ?> js-status-badge" data-status="<?= esc($st) ?>"<?= $badgeStyle ?>>
|
||||||
|
<?= esc($badgeData['label']) ?>
|
||||||
|
</span>
|
||||||
|
<div class="small text-muted mt-1 js-acceptance-note">
|
||||||
|
<?php if ($st === 'accepted' && !empty($draft['acceptance_type'])): ?>
|
||||||
|
<?= $draft['acceptance_type'] === 'minor_edits' ? 'Accepted w/ minor edits' : 'Accepted as is' ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php if (!empty($draft['reviewed_at'])): ?>
|
||||||
|
<div class="small text-muted mt-1">
|
||||||
|
Reviewed <?= esc(local_datetime($draft['reviewed_at'], 'M j, Y g:i A')) ?>
|
||||||
|
<?php if ($adminName !== ''): ?>
|
||||||
|
by <?= esc($adminName) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php $formId = 'review_form_' . (int) ($draft['id'] ?? 0); ?>
|
||||||
|
<?php $revNumber = max(1, (int) ($draft['version'] ?? 1)); ?>
|
||||||
|
<?php if (!empty($draft['teacher_file'])): ?>
|
||||||
|
<div>
|
||||||
|
<a href="<?= base_url('exam-drafts/files/teacher/' . $draft['teacher_file']) ?>" target="_blank" rel="noopener">
|
||||||
|
<?= esc('Ver' . $revNumber . ' ' . ($draft['teacher_filename'] ?? 'Submitted draft')) ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="text-muted small">No teacher file</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($draft['final_file']) && strtolower((string) ($draft['status'] ?? '')) === 'accepted'): ?>
|
||||||
|
<div class="mt-2">
|
||||||
|
<a href="<?= base_url('exam-drafts/files/final/' . $draft['final_file']) ?>" target="_blank" rel="noopener" class="link-success small">
|
||||||
|
<?= esc('Final ' . ($draft['final_filename'] ?? 'Final draft')) ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($draft['review_files'])): ?>
|
||||||
|
<div class="mt-2">
|
||||||
|
<?php
|
||||||
|
$reviewLinks = [];
|
||||||
|
foreach ($draft['review_files'] as $rf) {
|
||||||
|
$rev = max(1, (int) ($rf['review_revision'] ?? 1));
|
||||||
|
$name = $rf['final_filename'] ?? 'Review file';
|
||||||
|
$file = $rf['final_file'] ?? '';
|
||||||
|
if ($file !== '') {
|
||||||
|
$reviewLinks[] = '<a href="' . esc(base_url('exam-drafts/files/final/' . $file)) . '" target="_blank" rel="noopener" class="link-success small">' . esc('Ver' . $revNumber . '_' . $rev . ' ' . $name) . '</a>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<?= !empty($reviewLinks) ? implode(' | ', $reviewLinks) : '' ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="mt-2">
|
||||||
|
<?php $fileInputId = 'review_file_' . (int) ($draft['id'] ?? 0); ?>
|
||||||
|
<input type="file" name="final_file" id="<?= esc($fileInputId) ?>" class="form-control form-control-sm" accept="<?= esc($fileAccept) ?>" form="<?= esc($formId) ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary mt-2" form="<?= esc($formId) ?>">Upload review file</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="min-width: 280px;">
|
||||||
|
<?= 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 = ''; ?>
|
||||||
|
<select name="review_status" class="form-select form-select-sm" required>
|
||||||
|
<option value="" <?= $selectedStatus === '' ? 'selected' : '' ?> disabled>— Select status —</option>
|
||||||
|
<option value="accepted" <?= $selectedStatus === 'accepted' ? 'selected' : '' ?>>Accepted</option>
|
||||||
|
<option value="legacy" <?= $selectedStatus === 'legacy' ? 'selected' : '' ?>>Legacy</option>
|
||||||
|
<option value="canceled" <?= $selectedStatus === 'canceled' ? 'selected' : '' ?>>Canceled</option>
|
||||||
|
<option value="rejected" <?= $selectedStatus === 'rejected' ? 'selected' : '' ?>>Rejected</option>
|
||||||
|
<option value="review needed" <?= $selectedStatus === 'review needed' ? 'selected' : '' ?>>Review needed</option>
|
||||||
|
<option value="under review" <?= $selectedStatus === 'under review' ? 'selected' : '' ?>>Under review</option>
|
||||||
|
</select>
|
||||||
|
<div class="small js-acceptance-group d-none">
|
||||||
|
<span class="d-block mb-1">As is / Minor edits</span>
|
||||||
|
<?php $acc = (string) ($draft['acceptance_type'] ?? ''); ?>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="radio" name="acceptance_type" value="as_is" id="as_is_<?= (int) ($draft['id'] ?? 0) ?>" <?= $acc !== 'minor_edits' ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="as_is_<?= (int) ($draft['id'] ?? 0) ?>">As is</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="radio" name="acceptance_type" value="minor_edits" id="minor_<?= (int) ($draft['id'] ?? 0) ?>" <?= $acc === 'minor_edits' ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="minor_<?= (int) ($draft['id'] ?? 0) ?>">Minor edits</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea name="reviewer_comment" class="form-control form-control-sm js-review-comment" rows="3" placeholder="Feedback for the teacher"><?= esc($draft['reviewer_comment'] ?? $draft['reviewer_comments'] ?? $draft['admin_comments'] ?? '') ?></textarea>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<span class="text-muted small js-review-status"></span>
|
||||||
|
</div>
|
||||||
|
<?= form_close() ?>
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap" style="min-width: 220px;">
|
||||||
|
<?php
|
||||||
|
$pdfFile = $draft['final_pdf_file'] ?? null;
|
||||||
|
if (empty($pdfFile) && !empty($draft['final_file']) && strtolower(pathinfo($draft['final_file'], PATHINFO_EXTENSION)) === 'pdf') {
|
||||||
|
$pdfFile = $draft['final_file'];
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<?php if (!empty($pdfFile)): ?>
|
||||||
|
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="<?= base_url('exam-drafts/files/final/' . $pdfFile) ?>" target="_blank" rel="noopener">View</a>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('exam-drafts/files/final/' . $pdfFile) ?>" target="_blank" rel="noopener" download>Download</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</tbody>
|
||||||
</div>
|
</table>
|
||||||
<div class="mb-2">
|
</div>
|
||||||
<label class="form-label small mb-1">Upload final draft</label>
|
<?php endif; ?>
|
||||||
<input type="file" name="final_file" class="form-control form-control-sm">
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-sm btn-primary w-100">
|
</div>
|
||||||
Save review
|
<?php endforeach; ?>
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-pane fade" id="legacy" role="tabpanel" aria-labelledby="legacy-tab">
|
<div class="tab-pane fade" id="legacy" role="tabpanel" aria-labelledby="legacy-tab">
|
||||||
<div class="card border-primary-subtle mb-3">
|
<div class="card border-primary-subtle mb-3">
|
||||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<strong>Upload Old / Legacy Exam</strong>
|
<strong>Upload old / legacy exam</strong>
|
||||||
<div class="small text-muted">Store historic exams as finalized records.</div>
|
<div class="small text-muted">Store historic exams as accepted records.</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge bg-primary-subtle text-primary">Admin only</span>
|
<span class="badge bg-primary-subtle text-primary">Admin only</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" action="<?= base_url('/administrator/exam-drafts/upload-legacy') ?>" enctype="multipart/form-data" class="row g-3">
|
<?= form_open_multipart($legacyUploadUrl, ['class' => 'row g-3']) ?>
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label small">Class Sections</label>
|
<label class="form-label small">Class sections</label>
|
||||||
<select name="class_section_ids[]" class="form-select" multiple required>
|
<select name="class_section_ids[]" class="form-select" multiple required size="6">
|
||||||
<?php foreach ($classSections as $cs): ?>
|
<?php foreach ($classSections as $cs): ?>
|
||||||
<option value="<?= esc($cs['class_section_id']) ?>"><?= esc($cs['class_section_name']) ?></option>
|
<option value="<?= esc($cs['class_section_id'] ?? '') ?>"><?= esc($cs['class_section_name'] ?? '') ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text">Hold Ctrl (Windows) or Command (Mac) to select multiple sections.</div>
|
<div class="form-text">Hold Ctrl (Windows) or Command (Mac) to select multiple sections.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label small">School Year</label>
|
<label class="form-label small">School year</label>
|
||||||
<input type="text" name="school_year" class="form-control" value="<?= esc($schoolYear) ?>" placeholder="2025-2026" required>
|
<input type="text" name="school_year" class="form-control" value="<?= esc($schoolYear) ?>" placeholder="2025-2026" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label small">Semester</label>
|
<label class="form-label small">Semester</label>
|
||||||
<select name="semester" class="form-select" required>
|
<input type="text" name="semester" class="form-control" value="<?= esc($semester) ?>" placeholder="Fall / Spring" required>
|
||||||
<option value="Fall" <?= ($semester === 'Fall') ? 'selected' : '' ?>>Fall</option>
|
|
||||||
<option value="Spring" <?= ($semester === 'Spring') ? 'selected' : '' ?>>Spring</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label small">Exam Type</label>
|
<label class="form-label small">Exam type</label>
|
||||||
<select name="exam_type" class="form-select">
|
<select name="exam_type" class="form-select">
|
||||||
<option value="">— Select type —</option>
|
<option value="">— Select type —</option>
|
||||||
<?php foreach ($examTypes as $type): ?>
|
<?php foreach ($examTypes as $type): ?>
|
||||||
@@ -211,14 +308,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label small">Upload File</label>
|
<label class="form-label small">Upload file</label>
|
||||||
<input type="file" name="old_exam_file" class="form-control" accept=".doc,.docx,.pdf" required>
|
<input type="file" name="old_exam_file" class="form-control" accept="<?= esc($fileAccept) ?>" required>
|
||||||
<div class="form-text">Allowed: <?= esc(implode(', ', $allowedExtensions)) ?> • Max <?= number_format($maxUploadBytes / 1024 / 1024, 0) ?> MB</div>
|
<div class="form-text">Allowed: <?= esc(implode(', ', $allowedExtensions)) ?> • Max <?= number_format($maxUploadBytes / 1024 / 1024, 0) ?> MB</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<button type="submit" class="btn btn-primary">Upload Legacy Exam</button>
|
<button type="submit" class="btn btn-primary">Upload legacy exam</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<?= form_close() ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -231,20 +328,21 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h6 class="mb-2"><?= esc($group['class_section_name'] ?? 'Class') ?></h6>
|
<h6 class="mb-2"><?= esc($group['class_section_name'] ?? 'Class') ?></h6>
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
<?php foreach ($group['items'] as $item): ?>
|
<?php foreach ($group['items'] ?? [] as $item): ?>
|
||||||
<div class="list-group-item d-flex justify-content-between align-items-start">
|
<?php $lst = strtolower((string) ($item['status'] ?? '')); ?>
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||||
<div class="me-3">
|
<div class="me-3">
|
||||||
<div class="fw-semibold"><?= esc($item['draft_title'] ?? 'Legacy Exam') ?></div>
|
<div class="fw-semibold"><?= esc($item['draft_title'] ?? 'Legacy exam') ?></div>
|
||||||
<div class="small text-muted">
|
<div class="small text-muted">
|
||||||
<?= esc($item['exam_type'] ?? 'N/A') ?> •
|
<?= esc($item['exam_type'] ?? 'N/A') ?> •
|
||||||
<?= esc($item['semester'] ?? '') ?> <?= esc($item['school_year'] ?? '') ?>
|
<?= esc($item['semester'] ?? '') ?> <?= esc($item['school_year'] ?? '') ?>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="small mt-1"><?= $renderBadge($lst, $statusBadges) ?></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end d-flex flex-wrap gap-2 justify-content-end">
|
||||||
<?php if (!empty($item['final_file'])): ?>
|
<?php if (!empty($item['final_file'])): ?>
|
||||||
<a href="<?= base_url('exam-drafts/files/final/' . $item['final_file']) ?>" target="_blank" class="btn btn-sm btn-outline-primary">
|
<a class="btn btn-sm btn-outline-primary" href="<?= base_url('exam-drafts/files/final/' . $item['final_file']) ?>" target="_blank" rel="noopener">View</a>
|
||||||
Download
|
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('exam-drafts/files/final/' . $item['final_file']) ?>" target="_blank" rel="noopener" download>Download</a>
|
||||||
</a>
|
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="text-muted small">File missing</span>
|
<span class="text-muted small">File missing</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -260,6 +358,208 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const submissionsTab = document.getElementById('submissions-tab');
|
||||||
|
if (submissionsTab) {
|
||||||
|
submissionsTab.addEventListener('click', () => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const statusBadges = <?= json_encode($statusBadges, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||||
|
const csrfTokenName = <?= json_encode(csrf_token(), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||||
|
const csrfCookieNames = <?= json_encode(array_values(array_filter([
|
||||||
|
config('Security')->csrfCookieName ?? null,
|
||||||
|
config('Security')->cookieName ?? null,
|
||||||
|
'csrf_cookie_name',
|
||||||
|
])), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||||
|
|
||||||
|
const readCookie = (name) => {
|
||||||
|
const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[$()*+.?[\\\]^{|}-]/g, '\\$&') + '=([^;]*)'));
|
||||||
|
return match ? decodeURIComponent(match[1]) : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncCsrfToken = (form) => {
|
||||||
|
let tokenValue = '';
|
||||||
|
csrfCookieNames.some((name) => {
|
||||||
|
tokenValue = readCookie(name);
|
||||||
|
return tokenValue !== '';
|
||||||
|
});
|
||||||
|
if (!tokenValue && form) {
|
||||||
|
const tokenInput = form.querySelector(`input[name="${csrfTokenName}"]`);
|
||||||
|
if (tokenInput && tokenInput.value) {
|
||||||
|
tokenValue = tokenInput.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!tokenValue || !form) {
|
||||||
|
return tokenValue;
|
||||||
|
}
|
||||||
|
const tokenInput = form.querySelector(`input[name="${csrfTokenName}"]`);
|
||||||
|
if (tokenInput) {
|
||||||
|
tokenInput.value = tokenValue;
|
||||||
|
}
|
||||||
|
return tokenValue;
|
||||||
|
};
|
||||||
|
const updateRow = (row) => {
|
||||||
|
const statusSelect = row.querySelector('select[name="review_status"]');
|
||||||
|
const acceptanceGroup = row.querySelector('.js-acceptance-group');
|
||||||
|
if (!statusSelect || !acceptanceGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
acceptanceGroup.classList.toggle('d-none', statusSelect.value !== 'accepted');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStatusUI = (row) => {
|
||||||
|
const statusSelect = row.querySelector('select[name="review_status"]');
|
||||||
|
const badge = row.querySelector('.js-status-badge');
|
||||||
|
if (!statusSelect || !badge || statusSelect.value === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const status = statusSelect.value;
|
||||||
|
const badgeData = statusBadges[status] || { label: status, class: 'bg-secondary text-white' };
|
||||||
|
badge.textContent = badgeData.label || status;
|
||||||
|
badge.className = `badge ${badgeData.class} js-status-badge`;
|
||||||
|
if (badgeData.style) {
|
||||||
|
badge.setAttribute('style', badgeData.style);
|
||||||
|
} else {
|
||||||
|
badge.removeAttribute('style');
|
||||||
|
}
|
||||||
|
badge.dataset.status = status;
|
||||||
|
|
||||||
|
const acceptanceNote = row.querySelector('.js-acceptance-note');
|
||||||
|
if (acceptanceNote) {
|
||||||
|
if (status === 'accepted') {
|
||||||
|
const acc = row.querySelector('input[name="acceptance_type"]:checked');
|
||||||
|
acceptanceNote.textContent = acc && acc.value === 'minor_edits'
|
||||||
|
? 'Accepted w/ minor edits'
|
||||||
|
: 'Accepted as is';
|
||||||
|
} else {
|
||||||
|
acceptanceNote.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveRow = (row, options = {}) => {
|
||||||
|
const form = row.querySelector('form');
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statusSelect = form.querySelector('select[name="review_status"]');
|
||||||
|
if (!statusSelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statusLabel = form.querySelector('.js-review-status');
|
||||||
|
const data = new FormData(form);
|
||||||
|
data.delete('final_file');
|
||||||
|
data.delete('old_exam_file');
|
||||||
|
if (options.commitComment) {
|
||||||
|
const commentInput = form.querySelector('textarea[name="reviewer_comment"]');
|
||||||
|
if (commentInput) {
|
||||||
|
const raw = commentInput.value || '';
|
||||||
|
const committed = raw.endsWith('\n') ? raw : raw + '\n';
|
||||||
|
data.set('reviewer_comment', committed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const csrfToken = syncCsrfToken(form);
|
||||||
|
if (csrfToken) {
|
||||||
|
data.set(csrfTokenName, csrfToken);
|
||||||
|
}
|
||||||
|
if (statusLabel) {
|
||||||
|
statusLabel.textContent = 'Saving...';
|
||||||
|
}
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: data,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-CSRF-TOKEN': csrfToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((resp) => {
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error('Save failed');
|
||||||
|
}
|
||||||
|
syncCsrfToken(form);
|
||||||
|
updateStatusUI(row);
|
||||||
|
if (statusLabel) {
|
||||||
|
statusLabel.textContent = 'Saved';
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (statusLabel && statusLabel.textContent === 'Saved') {
|
||||||
|
statusLabel.textContent = '';
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (statusLabel) {
|
||||||
|
statusLabel.textContent = 'Error saving';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const debounce = (fn, wait) => {
|
||||||
|
let timer;
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => fn(...args), wait);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedSave = debounce(saveRow, 500);
|
||||||
|
|
||||||
|
document.querySelectorAll('.exam-drafts-table tbody tr').forEach(updateRow);
|
||||||
|
|
||||||
|
document.addEventListener('change', (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = target.closest('tr');
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target instanceof HTMLSelectElement && target.name === 'review_status') {
|
||||||
|
updateRow(row);
|
||||||
|
debouncedSave(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target instanceof HTMLInputElement && target.name === 'acceptance_type') {
|
||||||
|
debouncedSave(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('input', (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLTextAreaElement) || target.name !== 'reviewer_comment') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = target.closest('tr');
|
||||||
|
if (row && target.value.endsWith('\n')) {
|
||||||
|
const lastCommitted = target.dataset.lastCommitted || '';
|
||||||
|
if (target.value !== lastCommitted) {
|
||||||
|
target.dataset.lastCommitted = target.value;
|
||||||
|
debouncedSave(row, { commitComment: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('blur', (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLTextAreaElement) || target.name !== 'reviewer_comment') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = target.closest('tr');
|
||||||
|
if (row) {
|
||||||
|
const lastCommitted = target.dataset.lastCommitted || '';
|
||||||
|
if (target.value !== lastCommitted) {
|
||||||
|
target.dataset.lastCommitted = target.value;
|
||||||
|
debouncedSave(row, { commitComment: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
<?= $this->section('styles') ?>
|
<?= $this->section('styles') ?>
|
||||||
@@ -270,13 +570,15 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.exam-drafts-table th {
|
.exam-drafts-table th {
|
||||||
min-width: 120px;
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.exam-drafts-table td {
|
.exam-drafts-table td {
|
||||||
max-width: 220px;
|
max-width: none;
|
||||||
}
|
}
|
||||||
.exam-drafts-table {
|
.exam-drafts-table {
|
||||||
table-layout: auto;
|
table-layout: auto;
|
||||||
|
width: max-content;
|
||||||
}
|
}
|
||||||
.exam-drafts-table thead th {
|
.exam-drafts-table thead th {
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
|
|||||||
@@ -13,17 +13,19 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<!-- Search Form -->
|
<!-- 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">
|
<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">
|
placeholder="Search by transaction ID, email, order ID, or event type">
|
||||||
<button type="submit" class="btn btn-primary">Search</button>
|
<button type="submit" class="btn btn-primary">Search</button>
|
||||||
<--?php if ($keyword): ?>
|
<?php if ($keyword): ?>
|
||||||
<a href="<--?= base_url('admin/paypal-transactions') ?>" class="btn btn-secondary">Clear</a>
|
<a href="<?= base_url('admin/paypal-transactions') ?>" class="btn btn-secondary">Clear</a>
|
||||||
<--?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</form-->
|
</form>
|
||||||
|
-->
|
||||||
|
|
||||||
<!-- Transactions Table -->
|
<!-- Transactions Table -->
|
||||||
<table id="myTable" class="display table table-striped">
|
<table id="myTable" class="display table table-striped">
|
||||||
|
|||||||
@@ -144,58 +144,86 @@
|
|||||||
<span class="text-muted small"><?= count($entries) ?> record<?= count($entries) === 1 ? '' : 's' ?></span>
|
<span class="text-muted small"><?= count($entries) ?> record<?= count($entries) === 1 ? '' : 's' ?></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-responsive">
|
<?php if (empty($entries)): ?>
|
||||||
<table class="table table-bordered table-hover align-middle mb-0" data-no-mgmt-sticky>
|
<div class="text-center text-muted">No curriculum records yet.</div>
|
||||||
<thead class="table-light">
|
<?php else: ?>
|
||||||
<tr>
|
<?php
|
||||||
<th>Class</th>
|
$entriesByClass = [];
|
||||||
<th>Subject</th>
|
foreach ($entries as $entry) {
|
||||||
<th>Unit</th>
|
$className = ($entry['class_name'] ?? '') ?: '—';
|
||||||
<th>Unit title</th>
|
if (!isset($entriesByClass[$className])) {
|
||||||
<th>Chapter / Surah</th>
|
$entriesByClass[$className] = [];
|
||||||
<th>Updated</th>
|
}
|
||||||
<th style="min-width: 170px;">Actions</th>
|
$entriesByClass[$className][] = $entry;
|
||||||
</tr>
|
}
|
||||||
</thead>
|
?>
|
||||||
<tbody>
|
<div class="accordion" id="curriculumAccordion">
|
||||||
<?php if (empty($entries)): ?>
|
<?php $accIndex = 0; ?>
|
||||||
<tr>
|
<?php foreach ($entriesByClass as $className => $classEntries): ?>
|
||||||
<td colspan="7" class="text-center text-muted">No curriculum records yet.</td>
|
<?php
|
||||||
</tr>
|
$accIndex++;
|
||||||
<?php else: ?>
|
$collapseId = 'curriculum-class-' . $accIndex;
|
||||||
<?php foreach ($entries as $entry): ?>
|
$headingId = 'curriculum-heading-' . $accIndex;
|
||||||
<?php
|
?>
|
||||||
$subjectLabel = $subjectLabels[$entry['subject']] ?? ucfirst($entry['subject'] ?? '');
|
<div class="accordion-item mb-2">
|
||||||
$updatedAt = $entry['updated_at'] ?? $entry['created_at'] ?? '';
|
<h2 class="accordion-header" id="<?= esc($headingId) ?>">
|
||||||
$updatedDisplay = '';
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#<?= esc($collapseId) ?>" aria-expanded="false" aria-controls="<?= esc($collapseId) ?>">
|
||||||
if ($updatedAt) {
|
<?= esc($className) ?>
|
||||||
try {
|
<span class="badge bg-secondary ms-2"><?= count($classEntries) ?> entries</span>
|
||||||
$updatedDisplay = (new \DateTime($updatedAt))->format('M d, Y H:i');
|
</button>
|
||||||
} catch (\Exception $e) {
|
</h2>
|
||||||
$updatedDisplay = $updatedAt;
|
<div id="<?= esc($collapseId) ?>" class="accordion-collapse collapse" aria-labelledby="<?= esc($headingId) ?>" data-bs-parent="#curriculumAccordion">
|
||||||
}
|
<div class="accordion-body">
|
||||||
}
|
<div class="table-responsive">
|
||||||
?>
|
<table class="table table-bordered table-hover align-middle mb-0" data-no-mgmt-sticky>
|
||||||
<tr>
|
<thead class="table-light">
|
||||||
<td><?= esc(($entry['class_name'] ?? '') ?: '—') ?></td>
|
<tr>
|
||||||
<td><?= esc($subjectLabel) ?></td>
|
<th>Subject</th>
|
||||||
<td><?= $entry['unit_number'] ? esc((string)$entry['unit_number']) : '—' ?></td>
|
<th>Unit</th>
|
||||||
<td><?= esc(($entry['unit_title'] ?? '') ?: '—') ?></td>
|
<th>Unit title</th>
|
||||||
<td><?= esc(($entry['chapter_name'] ?? '') ?: '—') ?></td>
|
<th>Chapter / Surah</th>
|
||||||
<td><?= esc($updatedDisplay ?: '—') ?></td>
|
<th>Updated</th>
|
||||||
<td class="d-flex gap-2 flex-wrap">
|
<th style="min-width: 170px;">Actions</th>
|
||||||
<a href="<?= site_url('administrator/subject-curriculum/edit/' . $entry['id']) ?>" class="btn btn-sm btn-outline-primary">Edit</a>
|
</tr>
|
||||||
<form action="<?= site_url('administrator/subject-curriculum/delete/' . $entry['id']) ?>" method="post" onsubmit="return confirm('Remove this curriculum entry?');">
|
</thead>
|
||||||
<?= csrf_field() ?>
|
<tbody>
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
<?php foreach ($classEntries as $entry): ?>
|
||||||
</form>
|
<?php
|
||||||
</td>
|
$subjectLabel = $subjectLabels[$entry['subject']] ?? ucfirst($entry['subject'] ?? '');
|
||||||
</tr>
|
$updatedAt = $entry['updated_at'] ?? $entry['created_at'] ?? '';
|
||||||
<?php endforeach; ?>
|
$updatedDisplay = '';
|
||||||
<?php endif; ?>
|
if ($updatedAt) {
|
||||||
</tbody>
|
try {
|
||||||
</table>
|
$updatedDisplay = (new \DateTime($updatedAt))->format('M d, Y H:i');
|
||||||
</div>
|
} catch (\Exception $e) {
|
||||||
|
$updatedDisplay = $updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td><?= esc($subjectLabel) ?></td>
|
||||||
|
<td><?= $entry['unit_number'] ? esc((string)$entry['unit_number']) : '—' ?></td>
|
||||||
|
<td><?= esc(($entry['unit_title'] ?? '') ?: '—') ?></td>
|
||||||
|
<td><?= esc(($entry['chapter_name'] ?? '') ?: '—') ?></td>
|
||||||
|
<td><?= esc($updatedDisplay ?: '—') ?></td>
|
||||||
|
<td class="d-flex gap-2 flex-wrap">
|
||||||
|
<a href="<?= site_url('administrator/subject-curriculum/edit/' . $entry['id']) ?>" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||||
|
<form action="<?= site_url('administrator/subject-curriculum/delete/' . $entry['id']) ?>" method="post" onsubmit="return confirm('Remove this curriculum entry?');">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,7 +63,15 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9" class="text-center text-muted">Loading...</td>
|
<td class="text-center text-muted">Loading...</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -198,10 +206,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
if (!teachers || teachers.length === 0) {
|
if (!teachers || teachers.length === 0) {
|
||||||
var emptyRow = document.createElement('tr');
|
var emptyRow = document.createElement('tr');
|
||||||
var emptyCell = document.createElement('td');
|
var emptyCell = document.createElement('td');
|
||||||
emptyCell.colSpan = 9;
|
|
||||||
emptyCell.className = 'text-center text-muted';
|
emptyCell.className = 'text-center text-muted';
|
||||||
emptyCell.textContent = 'No teachers found.';
|
emptyCell.textContent = 'No teachers found.';
|
||||||
emptyRow.appendChild(emptyCell);
|
emptyRow.appendChild(emptyCell);
|
||||||
|
for (var i = 0; i < 8; i++) {
|
||||||
|
emptyRow.appendChild(document.createElement('td'));
|
||||||
|
}
|
||||||
tbody.appendChild(emptyRow);
|
tbody.appendChild(emptyRow);
|
||||||
} else {
|
} else {
|
||||||
teachers.forEach(function (teacher, index) {
|
teachers.forEach(function (teacher, index) {
|
||||||
@@ -312,7 +322,10 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-muted">Loading...</td></tr>';
|
tbody.innerHTML = '<tr>'
|
||||||
|
+ '<td class="text-center text-muted">Loading...</td>'
|
||||||
|
+ '<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>'
|
||||||
|
+ '</tr>';
|
||||||
|
|
||||||
var url = apiList + (selectedYear ? ('?schoolYear=' + encodeURIComponent(selectedYear)) : '');
|
var url = apiList + (selectedYear ? ('?schoolYear=' + encodeURIComponent(selectedYear)) : '');
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
|
|||||||
@@ -23,6 +23,41 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<?php
|
<?php
|
||||||
$termExamLabel = (isset($semester) && strtolower((string) $semester) === 'spring') ? 'Final' : 'Midterm';
|
$termExamLabel = (isset($semester) && strtolower((string) $semester) === 'spring') ? 'Final' : 'Midterm';
|
||||||
|
$lowProgressSectionIds = $lowProgressSectionIds ?? [];
|
||||||
|
$flaggedClasses = count($lowProgressSectionIds);
|
||||||
|
$pageNotifications = [];
|
||||||
|
if ($missingItemsCount > 0) {
|
||||||
|
$pageNotifications[] = [
|
||||||
|
'level' => 'danger',
|
||||||
|
'message' => "{$missingItemsCount} missing item" . ($missingItemsCount === 1 ? '' : 's') . " awaiting teacher uploads.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($completionPercent < 70) {
|
||||||
|
$pageNotifications[] = [
|
||||||
|
'level' => 'warning',
|
||||||
|
'message' => "Submission completion is below 70% — follow up with remaining teachers.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if ($flaggedClasses > 0) {
|
||||||
|
$pageNotifications[] = [
|
||||||
|
'level' => 'warning',
|
||||||
|
'message' => "Progress is under 50% for {$flaggedClasses} class section" . ($flaggedClasses === 1 ? '' : 's') . ".",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (empty($rows)) {
|
||||||
|
$pageNotifications[] = [
|
||||||
|
'level' => 'info',
|
||||||
|
'message' => 'No class-section assignments submitted yet; encourage teachers to upload drafts.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (empty($pageNotifications)) {
|
||||||
|
$pageNotifications[] = [
|
||||||
|
'level' => 'info',
|
||||||
|
'message' => 'All tracked classes are current. Use the controls below to send reminders.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$examDraftDeadlineConfig = $examDraftDeadlineConfig ?? '';
|
||||||
|
$examDraftDeadlineFormatted = $examDraftDeadlineFormatted ?? '';
|
||||||
?>
|
?>
|
||||||
<?php if (session()->getFlashdata('success')): ?>
|
<?php if (session()->getFlashdata('success')): ?>
|
||||||
<div class="alert alert-success">
|
<div class="alert alert-success">
|
||||||
@@ -37,203 +72,278 @@
|
|||||||
<?= esc(session()->getFlashdata('info')) ?>
|
<?= esc(session()->getFlashdata('info')) ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php $lowProgressSectionIds = $lowProgressSectionIds ?? []; ?>
|
<?php
|
||||||
<?php if (!empty($lowProgressSectionIds)): ?>
|
$groupedRows = [];
|
||||||
<div class="alert alert-warning">
|
foreach ($rows as $idx => $row) {
|
||||||
Showing teachers for class sections with progress submissions below 50%.
|
$classKey = (string) ($row['class_section_id'] ?? $row['class_section'] ?? 'class_' . $idx);
|
||||||
</div>
|
$groupedRows[$classKey][] = $row;
|
||||||
<?php endif; ?>
|
}
|
||||||
<div class="border rounded-3 p-3 mb-4 bg-light">
|
?>
|
||||||
<div class="d-flex flex-wrap gap-4 align-items-center">
|
<div class="row g-3 mb-4 align-items-stretch">
|
||||||
<div>
|
<div class="col-lg-8">
|
||||||
<div class="text-uppercase small text-muted">Submission completion</div>
|
<div class="row g-3 summary-row">
|
||||||
<div class="h4 fw-semibold mb-1"><?= esc($completionPercent) ?>%</div>
|
<div class="col-12 col-sm-6 col-xl-3">
|
||||||
<div class="progress" style="height:6px;">
|
<div class="summary-card h-100 shadow-sm border-0">
|
||||||
<div
|
<div class="summary-card-body">
|
||||||
class="progress-bar bg-primary"
|
<div class="summary-label text-uppercase">Completion</div>
|
||||||
role="progressbar"
|
<div class="summary-value"><?= esc($completionPercent) ?>%</div>
|
||||||
style="width: <?= esc($completionPercent) ?>%;"
|
<div class="progress summary-progress">
|
||||||
aria-valuenow="<?= esc($completionPercent) ?>"
|
<div
|
||||||
aria-valuemin="0"
|
class="progress-bar"
|
||||||
aria-valuemax="100"
|
role="progressbar"
|
||||||
></div>
|
style="width: <?= esc($completionPercent) ?>%;"
|
||||||
|
aria-valuenow="<?= esc($completionPercent) ?>"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 col-xl-3">
|
||||||
|
<div class="summary-card h-100 shadow-sm border-0">
|
||||||
|
<div class="summary-card-body">
|
||||||
|
<div class="summary-label text-uppercase">Missing items</div>
|
||||||
|
<div class="summary-value <?= $missingItemsCount > 0 ? 'text-danger' : '' ?>"><?= esc($missingItemsCount) ?></div>
|
||||||
|
<div class="summary-note"><?= esc($submittedItems) ?> submitted / <?= esc($totalItems) ?> total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 col-xl-3">
|
||||||
|
<div class="summary-card h-100 shadow-sm border-0">
|
||||||
|
<div class="summary-card-body">
|
||||||
|
<div class="summary-label text-uppercase">Submissions</div>
|
||||||
|
<div class="summary-value"><?= esc($submittedItems) ?></div>
|
||||||
|
<div class="summary-note"><?= esc($totalItems) ?> teachers tracked</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 col-xl-3">
|
||||||
|
<div class="summary-card h-100 shadow-sm border-0">
|
||||||
|
<div class="summary-card-body">
|
||||||
|
<div class="summary-label text-uppercase">Flagged</div>
|
||||||
|
<div class="summary-value"><?= esc($flaggedClasses) ?></div>
|
||||||
|
<div class="summary-note">Sections under 50% complete</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<div class="text-uppercase small text-muted">Missing items</div>
|
<div class="col-lg-4">
|
||||||
<div class="h4 fw-semibold text-danger mb-0"><?= esc($missingItemsCount) ?></div>
|
<div class="card page-notifications-card h-100 shadow-sm">
|
||||||
</div>
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<div class="text-muted small">
|
<span>Page notifications</span>
|
||||||
<?= esc($submittedItems) ?> submitted / <?= esc($totalItems) ?> total items
|
<span class="badge bg-light text-dark"><?= count($pageNotifications) ?> alerts</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="page-notifications-list mb-0">
|
||||||
|
<?php foreach ($pageNotifications as $notification): ?>
|
||||||
|
<li class="page-notification-item page-notification-<?= esc($notification['level']) ?>">
|
||||||
|
<span class="notification-indicator" aria-hidden="true"></span>
|
||||||
|
<span><?= esc($notification['message']) ?></span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="<?= site_url('administrator/teacher-submissions/notify') ?>">
|
<form method="post" action="<?= site_url('administrator/teacher-submissions/notify') ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<div class="table-responsive">
|
<?php if (empty($rows)): ?>
|
||||||
<table
|
<p class="text-center text-muted mt-4">No teacher-class assignments found for this term.</p>
|
||||||
class="table table-striped table-bordered m-0 align-middle teacher-submissions-table"
|
<?php else: ?>
|
||||||
data-no-mgmt-sticky
|
<div class="table-control-bar mb-3 d-flex flex-wrap align-items-center gap-3">
|
||||||
data-no-dt-fixedheader
|
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||||
>
|
<label class="form-check form-check-inline mb-0">
|
||||||
<thead class="table-light">
|
<input class="form-check-input" type="checkbox" name="notify_midterm_score" value="1">
|
||||||
<tr>
|
<span class="form-check-label small">Include <?= esc($termExamLabel) ?> score</span>
|
||||||
<th>Class-Section</th>
|
</label>
|
||||||
<th>Teachers Name</th>
|
<label class="form-check form-check-inline mb-0">
|
||||||
<th class="text-center">
|
<input class="form-check-input" type="checkbox" name="notify_midterm_comment" value="1">
|
||||||
<?= esc($termExamLabel) ?> Score
|
<span class="form-check-label small">Include <?= esc($termExamLabel) ?> comment</span>
|
||||||
<div class="form-check d-inline-flex align-items-center gap-1 ms-2">
|
</label>
|
||||||
<input class="form-check-input" type="checkbox" name="notify_midterm_score" id="notifyMidtermScore" value="1">
|
<label class="form-check form-check-inline mb-0">
|
||||||
<label class="form-check-label small" for="notifyMidtermScore">Include</label>
|
<input class="form-check-input" type="checkbox" name="notify_participation" value="1">
|
||||||
</div>
|
<span class="form-check-label small">Include participation</span>
|
||||||
</th>
|
</label>
|
||||||
<th class="text-center">
|
<label class="form-check form-check-inline mb-0">
|
||||||
<?= esc($termExamLabel) ?> Comment
|
<input class="form-check-input" type="checkbox" name="notify_ptap_comment" value="1">
|
||||||
<div class="form-check d-inline-flex align-items-center gap-1 ms-2">
|
<span class="form-check-label small">Include PTAP comment</span>
|
||||||
<input class="form-check-input" type="checkbox" name="notify_midterm_comment" id="notifyMidtermComment" value="1">
|
</label>
|
||||||
<label class="form-check-label small" for="notifyMidtermComment">Include</label>
|
<label class="form-check form-check-inline mb-0">
|
||||||
</div>
|
<input class="form-check-input" type="checkbox" name="notify_class_progress" value="1">
|
||||||
</th>
|
<span class="form-check-label small">Include class progress</span>
|
||||||
<th class="text-center">
|
</label>
|
||||||
Participation
|
<label class="form-check form-check-inline mb-0">
|
||||||
<div class="form-check d-inline-flex align-items-center gap-1 ms-2">
|
<input class="form-check-input" type="checkbox" name="notify_exam_draft" value="1">
|
||||||
<input class="form-check-input" type="checkbox" name="notify_participation" id="notifyParticipation" value="1">
|
<span class="form-check-label small">Include exam draft</span>
|
||||||
<label class="form-check-label small" for="notifyParticipation">Include</label>
|
</label>
|
||||||
</div>
|
<label class="form-check form-check-inline mb-0">
|
||||||
</th>
|
<input class="form-check-input" type="checkbox" name="homework_notify_all" value="1">
|
||||||
<th class="text-center">
|
<span class="form-check-label small">Include homework</span>
|
||||||
PTAP Comment
|
</label>
|
||||||
<div class="form-check d-inline-flex align-items-center gap-1 ms-2">
|
</div>
|
||||||
<input class="form-check-input" type="checkbox" name="notify_ptap_comment" id="notifyPtapComment" value="1">
|
<div class="small text-muted">
|
||||||
<label class="form-check-label small" for="notifyPtapComment">Include</label>
|
<span class="text-uppercase" style="font-size:0.65rem;">Exam draft deadline</span><br>
|
||||||
</div>
|
<?php if ($examDraftDeadlineConfig !== ''): ?>
|
||||||
</th>
|
<?= esc($examDraftDeadlineConfig) ?>
|
||||||
<th class="text-center">
|
<?php if ($examDraftDeadlineFormatted !== ''): ?>
|
||||||
Class Progress
|
<span class="text-muted"> → <?= esc($examDraftDeadlineFormatted) ?></span>
|
||||||
<div class="form-check d-inline-flex align-items-center gap-1 ms-2">
|
<?php endif; ?>
|
||||||
<input class="form-check-input" type="checkbox" name="notify_class_progress" id="notifyClassProgress" value="1">
|
|
||||||
<label class="form-check-label small" for="notifyClassProgress">Include</label>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th class="text-center">
|
|
||||||
Exam Draft
|
|
||||||
<div class="form-check d-inline-flex align-items-center gap-1 ms-2">
|
|
||||||
<input class="form-check-input" type="checkbox" name="notify_exam_draft" id="notifyExamDraft" value="1">
|
|
||||||
<label class="form-check-label small" for="notifyExamDraft">Include</label>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th class="text-center">
|
|
||||||
Homework
|
|
||||||
<div class="form-check d-inline-flex align-items-center gap-1 ms-2">
|
|
||||||
<input class="form-check-input" type="checkbox" name="homework_notify_all" id="homeworkNotifyAll" value="1">
|
|
||||||
<label class="form-check-label small" for="homeworkNotifyAll">Include</label>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th class="text-center">Notifications</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php if (!empty($rows)): ?>
|
|
||||||
<?php $homeworkToggleRendered = false; ?>
|
|
||||||
<?php foreach ($rows as $row): ?>
|
|
||||||
<tr>
|
|
||||||
<td><?= esc($row['class_section']) ?></td>
|
|
||||||
<td>
|
|
||||||
<?php if (!empty($row['teachers'])): ?>
|
|
||||||
<?php foreach ($row['teachers'] as $teacher): ?>
|
|
||||||
<div><?= esc($teacher['label'] ?? 'Teacher') ?></div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php else: ?>
|
|
||||||
<span class="text-muted small">Unassigned</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<?php foreach ([
|
|
||||||
'midterm_score_status',
|
|
||||||
'midterm_comment_status',
|
|
||||||
'participation_status',
|
|
||||||
'ptap_comment_status',
|
|
||||||
'class_progress_status',
|
|
||||||
'exam_draft_status',
|
|
||||||
] as $statusKey): ?>
|
|
||||||
<?php $status = $row[$statusKey] ?? ['label' => 'N/A', 'badge' => 'bg-secondary']; ?>
|
|
||||||
<td class="text-center">
|
|
||||||
<span class="badge <?= esc($status['badge'] ?? 'bg-secondary') ?>">
|
|
||||||
<?= esc($status['label'] ?? 'N/A') ?>
|
|
||||||
</span>
|
|
||||||
<?php if (!empty($status['detail'])): ?>
|
|
||||||
<div class="small text-muted"><?= esc($status['detail']) ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php $homeworkStatus = $row['homework_status'] ?? ['label' => 'N/A', 'badge' => 'bg-secondary']; ?>
|
|
||||||
<td class="text-center">
|
|
||||||
<span class="badge <?= esc($homeworkStatus['badge'] ?? 'bg-secondary') ?>">
|
|
||||||
<?= esc($homeworkStatus['label'] ?? 'N/A') ?>
|
|
||||||
</span>
|
|
||||||
<?php if (!empty($homeworkStatus['detail'])): ?>
|
|
||||||
<div class="small text-muted"><?= esc($homeworkStatus['detail']) ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<?php if (!empty($row['teachers'])): ?>
|
|
||||||
<?php $missingPayload = base64_encode(json_encode($row['missing_items'] ?? [])); ?>
|
|
||||||
<?php foreach ($row['teachers'] as $teacher): ?>
|
|
||||||
<?php $history = $notificationHistory[$row['class_section_id']][$teacher['id']] ?? []; ?>
|
|
||||||
<?php $lastEntry = $history[0] ?? null; ?>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="d-flex align-items-center gap-2 mb-1">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="form-check-input"
|
|
||||||
name="notify[<?= esc($row['class_section_id']) ?>][<?= esc($teacher['id']) ?>]"
|
|
||||||
value="1"
|
|
||||||
/>
|
|
||||||
<span class="fw-semibold"><?= esc($teacher['label'] ?? 'Teacher') ?></span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="missing_items[<?= esc($row['class_section_id']) ?>][<?= esc($teacher['id']) ?>]"
|
|
||||||
value="<?= esc($missingPayload) ?>"
|
|
||||||
/>
|
|
||||||
<div class="small text-muted">
|
|
||||||
<?php if ($lastEntry !== null): ?>
|
|
||||||
<span>
|
|
||||||
Last <?= esc($lastEntry['status'] === 'sent' ? 'sent' : 'attempted') ?> on <?= esc($lastEntry['sent_at_text'] ?: 'N/A') ?>
|
|
||||||
by <?= esc($lastEntry['admin_name'] ?? '') ?>
|
|
||||||
</span>
|
|
||||||
<?php if (count($history) > 1): ?>
|
|
||||||
<div>History: <?= count($history) ?> entries</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php else: ?>
|
|
||||||
<span>No notifications sent yet</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php else: ?>
|
|
||||||
<span class="text-muted small">No teacher assigned</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if (!empty($row['missing_items'])): ?>
|
|
||||||
<div class="small text-danger mt-1">
|
|
||||||
Outstanding: <?= esc(implode(', ', $row['missing_items'])) ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<tr>
|
<span class="text-warning">Not set</span>
|
||||||
<td colspan="7" class="text-center">No teacher-class assignments found for this term.</td>
|
|
||||||
</tr>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
<div class="accordion" id="teacherSubmissionAccordion">
|
||||||
<div class="mt-3 text-end">
|
<?php foreach ($groupedRows as $sectionKey => $sectionRows): ?>
|
||||||
<button type="submit" class="btn btn-primary" <?= empty($rows) ? 'disabled' : '' ?>>
|
<?php
|
||||||
Send notifications to selected teachers
|
$firstRow = $sectionRows[0] ?? [];
|
||||||
</button>
|
$sectionLabel = esc($firstRow['class_section'] ?? 'Class section');
|
||||||
</div>
|
$collapseId = 'teacherSectionCollapse_' . md5($sectionKey);
|
||||||
|
$badgeStatus = count($sectionRows) === 1 ? 'submission' : 'submissions';
|
||||||
|
?>
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header" id="heading_<?= esc($collapseId) ?>">
|
||||||
|
<button
|
||||||
|
class="accordion-button collapsed d-flex justify-content-between align-items-center"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#<?= esc($collapseId) ?>"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="<?= esc($collapseId) ?>"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong><?= $sectionLabel ?></strong>
|
||||||
|
<div class="small text-muted"><?= esc(count($sectionRows)) ?> <?= $badgeStatus ?></div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-primary-subtle text-primary"><?= count($sectionRows) ?> rows</span>
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="<?= esc($collapseId) ?>" class="accordion-collapse collapse" aria-labelledby="heading_<?= esc($collapseId) ?>">
|
||||||
|
<div class="accordion-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table
|
||||||
|
class="table table-striped table-bordered m-0 align-middle teacher-submissions-table mb-0"
|
||||||
|
data-no-mgmt-sticky
|
||||||
|
data-no-dt-fixedheader
|
||||||
|
>
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Class-Section</th>
|
||||||
|
<th>Teachers Name</th>
|
||||||
|
<th class="text-center"><?= esc($termExamLabel) ?> Score</th>
|
||||||
|
<th class="text-center"><?= esc($termExamLabel) ?> Comment</th>
|
||||||
|
<th class="text-center">Participation</th>
|
||||||
|
<th class="text-center">PTAP Comment</th>
|
||||||
|
<th class="text-center">Class Progress</th>
|
||||||
|
<th class="text-center">Exam Draft</th>
|
||||||
|
<th class="text-center">Homework</th>
|
||||||
|
<th class="text-center">Notifications</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($sectionRows as $row): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= esc($row['class_section']) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if (!empty($row['teachers'])): ?>
|
||||||
|
<?php foreach ($row['teachers'] as $teacher): ?>
|
||||||
|
<div><?= esc($teacher['label'] ?? 'Teacher') ?></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="text-muted small">Unassigned</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<?php foreach ([
|
||||||
|
'midterm_score_status',
|
||||||
|
'midterm_comment_status',
|
||||||
|
'participation_status',
|
||||||
|
'ptap_comment_status',
|
||||||
|
'class_progress_status',
|
||||||
|
'exam_draft_status',
|
||||||
|
] as $statusKey): ?>
|
||||||
|
<?php $status = $row[$statusKey] ?? ['label' => 'N/A', 'badge' => 'bg-secondary']; ?>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge <?= esc($status['badge'] ?? 'bg-secondary') ?>">
|
||||||
|
<?= esc($status['label'] ?? 'N/A') ?>
|
||||||
|
</span>
|
||||||
|
<?php if (!empty($status['detail'])): ?>
|
||||||
|
<div class="small text-muted"><?= esc($status['detail']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php $homeworkStatus = $row['homework_status'] ?? ['label' => 'N/A', 'badge' => 'bg-secondary']; ?>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge <?= esc($homeworkStatus['badge'] ?? 'bg-secondary') ?>">
|
||||||
|
<?= esc($homeworkStatus['label'] ?? 'N/A') ?>
|
||||||
|
</span>
|
||||||
|
<?php if (!empty($homeworkStatus['detail'])): ?>
|
||||||
|
<div class="small text-muted"><?= esc($homeworkStatus['detail']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php if (!empty($row['teachers'])): ?>
|
||||||
|
<?php $missingPayload = base64_encode(json_encode($row['missing_items'] ?? [])); ?>
|
||||||
|
<?php foreach ($row['teachers'] as $teacher): ?>
|
||||||
|
<?php $history = $notificationHistory[$row['class_section_id']][$teacher['id']] ?? []; ?>
|
||||||
|
<?php $lastEntry = $history[0] ?? null; ?>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="d-flex align-items-center gap-2 mb-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-check-input"
|
||||||
|
name="notify[<?= esc($row['class_section_id']) ?>][<?= esc($teacher['id']) ?>]"
|
||||||
|
value="1"
|
||||||
|
/>
|
||||||
|
<span class="fw-semibold"><?= esc($teacher['label'] ?? 'Teacher') ?></span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="missing_items[<?= esc($row['class_section_id']) ?>][<?= esc($teacher['id']) ?>]"
|
||||||
|
value="<?= esc($missingPayload) ?>"
|
||||||
|
/>
|
||||||
|
<div class="small text-muted">
|
||||||
|
<?php if ($lastEntry !== null): ?>
|
||||||
|
<span>
|
||||||
|
Last <?= esc($lastEntry['status'] === 'sent' ? 'sent' : 'attempted') ?> on <?= esc($lastEntry['sent_at_text'] ?: 'N/A') ?>
|
||||||
|
by <?= esc($lastEntry['admin_name'] ?? '') ?>
|
||||||
|
</span>
|
||||||
|
<?php if (count($history) > 1): ?>
|
||||||
|
<div>History: <?= count($history) ?> entries</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<span>No notifications sent yet</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="text-muted small">No teacher assigned</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($row['missing_items'])): ?>
|
||||||
|
<div class="small text-danger mt-1">
|
||||||
|
Outstanding: <?= esc(implode(', ', $row['missing_items'])) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-end">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
Send notifications to selected teachers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,5 +375,77 @@
|
|||||||
.card-body .table-responsive .teacher-submissions-table td {
|
.card-body .table-responsive .teacher-submissions-table td {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
background: var(--bs-white);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
.summary-card-body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.summary-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
.summary-value {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
.summary-note {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
.summary-progress {
|
||||||
|
height: 6px;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
.summary-progress .progress-bar {
|
||||||
|
background: var(--bs-primary);
|
||||||
|
}
|
||||||
|
.page-notifications-card {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
.page-notifications-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.page-notification-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.page-notification-info .notification-indicator {
|
||||||
|
background: #0dcaf0;
|
||||||
|
}
|
||||||
|
.page-notification-warning .notification-indicator {
|
||||||
|
background: #ffc107;
|
||||||
|
}
|
||||||
|
.page-notification-danger .notification-indicator {
|
||||||
|
background: #dc3545;
|
||||||
|
}
|
||||||
|
.notification-indicator {
|
||||||
|
width: 0.75rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.table-control-bar {
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
.table-control-bar .form-check-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 0.15rem;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|||||||
@@ -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 (!in_array($s['status'], ['confirmed', 'surprise'], true)) continue;
|
||||||
|
$rank++;
|
||||||
|
$isMale = strtolower($s['gender'] ?? '') === 'male';
|
||||||
|
$statusLabel = match ($s['status']) {
|
||||||
|
'confirmed' => '✓ Confirmed',
|
||||||
|
'surprise' => '↑ Surprise',
|
||||||
|
'missed' => '↓ Missed',
|
||||||
|
default => '—',
|
||||||
|
};
|
||||||
|
$statusClass = 's-' . $s['status'];
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center;"><?= $rank ?></td>
|
||||||
|
<td><?= esc($cls['section_name']) ?></td>
|
||||||
|
<td><strong><?= esc($s['name']) ?></strong></td>
|
||||||
|
<td style="text-align:center;"><?= $isMale ? 'M' : 'F' ?></td>
|
||||||
|
<td style="text-align:right;"><?= $s['fall_score'] !== null ? number_format($s['fall_score'], 1) : '—' ?></td>
|
||||||
|
<td style="text-align:right;"><?= $s['spring_score'] !== null ? number_format($s['spring_score'], 1) : '—' ?></td>
|
||||||
|
<td style="text-align:right;font-weight:bold;"><?= $s['year_score'] !== null ? number_format($s['year_score'], 1) : '—' ?></td>
|
||||||
|
<td style="text-align:center;"><?= $s['predicted'] ? 'Yes' : 'No' ?></td>
|
||||||
|
<td style="text-align:center;"><?= $s['actual'] ? 'Yes' : 'No' ?></td>
|
||||||
|
<td style="text-align:center;" class="<?= $statusClass ?>"><?= $statusLabel ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Accuracy summary (new page) -->
|
||||||
|
<div class="print-page-break"></div>
|
||||||
|
<h3 style="margin-bottom:4px;">Prediction Accuracy Summary</h3>
|
||||||
|
<table data-no-mgmt-sticky style="margin-bottom:14px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Class</th>
|
||||||
|
<th style="text-align:center;">Students</th>
|
||||||
|
<th style="text-align:center;">Predicted</th>
|
||||||
|
<th style="text-align:center;">Actual</th>
|
||||||
|
<th style="text-align:center;">✓ Confirmed</th>
|
||||||
|
<th style="text-align:center;">↑ Surprises</th>
|
||||||
|
<th style="text-align:center;">↓ Missed</th>
|
||||||
|
<th style="text-align:center;">Accuracy</th>
|
||||||
|
<th style="text-align:right;">Fall ≥</th>
|
||||||
|
<th style="text-align:right;">Year ≥</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($classResults as $cls): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= esc($cls['section_name']) ?></td>
|
||||||
|
<td style="text-align:center;"><?= $cls['student_count'] ?></td>
|
||||||
|
<td style="text-align:center;"><?= $cls['predicted_count'] ?></td>
|
||||||
|
<td style="text-align:center;"><?= $cls['actual_count'] ?></td>
|
||||||
|
<td style="text-align:center;" class="s-confirmed"><?= $cls['confirmed'] ?></td>
|
||||||
|
<td style="text-align:center;" class="s-surprise"><?= $cls['surprises'] ?></td>
|
||||||
|
<td style="text-align:center;" class="s-missed"><?= $cls['missed'] ?></td>
|
||||||
|
<td style="text-align:center;font-weight:bold;"><?= $cls['accuracy'] ?>%</td>
|
||||||
|
<td style="text-align:right;"><?= $cls['fall_threshold'] !== null ? number_format((float)$cls['fall_threshold'], 1) : '—' ?></td>
|
||||||
|
<td style="text-align:right;"><?= $cls['year_threshold'] !== null ? number_format((float)$cls['year_threshold'], 1) : '—' ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:bold;">Total</td>
|
||||||
|
<td style="text-align:center;font-weight:bold;"><?= $totalStudents ?></td>
|
||||||
|
<td style="text-align:center;font-weight:bold;"><?= $totalPredicted ?></td>
|
||||||
|
<td style="text-align:center;font-weight:bold;"><?= $totalActual ?></td>
|
||||||
|
<td style="text-align:center;font-weight:bold;" class="s-confirmed"><?= $totalConfirmed ?></td>
|
||||||
|
<td style="text-align:center;font-weight:bold;" class="s-surprise"><?= $totalSurprise ?></td>
|
||||||
|
<td style="text-align:center;font-weight:bold;" class="s-missed"><?= $totalMissed ?></td>
|
||||||
|
<td style="text-align:center;font-weight:bold;"><?= $overallAccuracy ?>%</td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Charts as images (populated by JS before printing) -->
|
||||||
|
<div class="print-page-break"></div>
|
||||||
|
<h3 style="margin-bottom:6px;">Charts</h3>
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Trophy Counts per Class</p>
|
||||||
|
<img id="print-chart-counts" style="width:100%;max-height:220px;object-fit:contain;" src="" alt="">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:16px;margin-bottom:14px;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Prediction Accuracy per Class</p>
|
||||||
|
<img id="print-chart-accuracy" style="width:100%;max-height:160px;object-fit:contain;" src="" alt="">
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<p style="font-size:10px;font-weight:bold;margin:0 0 4px;">Overall Outcome Breakdown</p>
|
||||||
|
<img id="print-chart-breakdown" style="width:100%;max-height:160px;object-fit:contain;" src="" alt="">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /print-only -->
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->section('scripts') ?>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
<?php
|
||||||
|
$cLabels = [];
|
||||||
|
$cPredicted = [];
|
||||||
|
$cActual = [];
|
||||||
|
$cConfirmed = [];
|
||||||
|
$cSurprise = [];
|
||||||
|
$cMissed = [];
|
||||||
|
$cAccuracy = [];
|
||||||
|
foreach ($classResults as $cls) {
|
||||||
|
$cLabels[] = $cls['section_name'];
|
||||||
|
$cPredicted[] = $cls['predicted_count'];
|
||||||
|
$cActual[] = $cls['actual_count'];
|
||||||
|
$cConfirmed[] = $cls['confirmed'];
|
||||||
|
$cSurprise[] = $cls['surprises'];
|
||||||
|
$cMissed[] = $cls['missed'];
|
||||||
|
$cAccuracy[] = $cls['accuracy'];
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
var labels = <?= json_encode($cLabels) ?>;
|
||||||
|
var predicted = <?= json_encode($cPredicted) ?>;
|
||||||
|
var actual = <?= json_encode($cActual) ?>;
|
||||||
|
var confirmed = <?= json_encode($cConfirmed) ?>;
|
||||||
|
var surprises = <?= json_encode($cSurprise) ?>;
|
||||||
|
var missed = <?= json_encode($cMissed) ?>;
|
||||||
|
var accuracy = <?= json_encode($cAccuracy) ?>;
|
||||||
|
|
||||||
|
/* ── Chart 1: grouped bar – counts per class ── */
|
||||||
|
new Chart(document.getElementById('chart-counts'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'Predicted', data: predicted, backgroundColor: '#4A90E2', borderRadius: 3 },
|
||||||
|
{ label: 'Actual', data: actual, backgroundColor: '#f0a500', borderRadius: 3 },
|
||||||
|
{ label: 'Confirmed', data: confirmed, backgroundColor: '#28a745', borderRadius: 3 },
|
||||||
|
{ label: 'Surprises', data: surprises, backgroundColor: '#17a2b8', borderRadius: 3 },
|
||||||
|
{ label: 'Missed', data: missed, backgroundColor: '#fd7e14', borderRadius: 3 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
scales: {
|
||||||
|
x: { grid: { color: '#f0f0f0' } },
|
||||||
|
y: { beginAtZero: true, ticks: { stepSize: 1 }, grid: { color: '#f0f0f0' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Chart 2: accuracy % per class ── */
|
||||||
|
new Chart(document.getElementById('chart-accuracy'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Accuracy %',
|
||||||
|
data: accuracy,
|
||||||
|
backgroundColor: accuracy.map(function(a) {
|
||||||
|
return a >= 80 ? '#28a745' : a >= 50 ? '#f0a500' : '#dc3545';
|
||||||
|
}),
|
||||||
|
borderRadius: 3
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true, max: 100,
|
||||||
|
ticks: { callback: function(v) { return v + '%'; } },
|
||||||
|
grid: { color: '#f0f0f0' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Chart 3: doughnut overall outcome breakdown ── */
|
||||||
|
new Chart(document.getElementById('chart-breakdown'), {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: ['Confirmed', 'Surprises', 'Missed'],
|
||||||
|
datasets: [{
|
||||||
|
data: [<?= $totalConfirmed ?>, <?= $totalSurprise ?>, <?= $totalMissed ?>],
|
||||||
|
backgroundColor: ['#28a745', '#17a2b8', '#fd7e14'],
|
||||||
|
borderWidth: 2
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom' },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(ctx) {
|
||||||
|
var total = ctx.dataset.data.reduce(function(a, b) { return a + b; }, 0);
|
||||||
|
var pct = total > 0 ? Math.round(ctx.parsed / total * 100) : 0;
|
||||||
|
return ctx.label + ': ' + ctx.parsed + ' (' + pct + '%)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
function captureCharts() {
|
||||||
|
var map = {
|
||||||
|
'chart-counts': 'print-chart-counts',
|
||||||
|
'chart-accuracy': 'print-chart-accuracy',
|
||||||
|
'chart-breakdown': 'print-chart-breakdown',
|
||||||
|
};
|
||||||
|
Object.keys(map).forEach(function(canvasId) {
|
||||||
|
var canvas = document.getElementById(canvasId);
|
||||||
|
var img = document.getElementById(map[canvasId]);
|
||||||
|
if (canvas && img) img.src = canvas.toDataURL('image/png');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function printWithCharts() {
|
||||||
|
captureCharts();
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeprint', captureCharts);
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$classResults = $classResults ?? [];
|
||||||
|
$selectedYear = $selectedYear ?? '';
|
||||||
|
$selectedPercentile = $selectedPercentile ?? 75;
|
||||||
|
$years = $years ?? [];
|
||||||
|
|
||||||
|
$totalWinners = array_sum(array_map(fn($c) => count($c['winners']), $classResults));
|
||||||
|
$totalStudents = array_sum(array_column($classResults, 'student_count'));
|
||||||
|
$totalBoys = array_sum(array_column($classResults, 'boys'));
|
||||||
|
$totalGirls = array_sum(array_column($classResults, 'girls'));
|
||||||
|
$totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys'));
|
||||||
|
$totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls'));
|
||||||
|
|
||||||
|
$pctWinners = $totalStudents > 0 ? round($totalWinners / $totalStudents * 100) : 0;
|
||||||
|
$pctTrophyBoys = $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0;
|
||||||
|
$pctTrophyGirls = $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0;
|
||||||
|
$pctBoys = $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0;
|
||||||
|
$pctGirls = $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0;
|
||||||
|
?>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── Screen: hide print-only blocks ── */
|
||||||
|
.print-only { display: none !important; }
|
||||||
|
|
||||||
|
/* ── Print ── */
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
.print-only { display: block !important; }
|
||||||
|
.screen-only { display: none !important; }
|
||||||
|
|
||||||
|
body { font-size: 11px; }
|
||||||
|
.container-fluid { padding: 0 !important; }
|
||||||
|
h2, h3 { font-size: 14px; margin-bottom: .4rem; }
|
||||||
|
.print-page-break { break-before: page; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 10px; }
|
||||||
|
table th,
|
||||||
|
table td { border: 1px solid #bbb; padding: 3px 6px; }
|
||||||
|
table thead { background: #333 !important; color: #fff !important;
|
||||||
|
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
table thead th { position: static !important; top: auto !important; box-shadow: none !important; }
|
||||||
|
table tfoot { background: #eee !important;
|
||||||
|
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
.badge-m { color: #4A90E2; font-weight: bold; }
|
||||||
|
.badge-f { color: #E47AB0; font-weight: bold; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
|
||||||
|
<!-- ══ SCREEN HEADER ══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2 no-print">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-1"><i class="bi bi-trophy-fill text-warning me-2"></i>Trophy Winners</h2>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
<?= esc($selectedYear) ?> — <?= (int)$selectedPercentile ?>th percentile —
|
||||||
|
<strong><?= $totalWinners ?></strong> winners across <strong><?= count($classResults) ?></strong> classes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button onclick="window.print()" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-printer-fill me-1"></i>Print
|
||||||
|
</button>
|
||||||
|
<a href="<?= site_url('administrator/trophy?' . http_build_query(['school_year' => $selectedYear, 'percentile' => $selectedPercentile])) ?>"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter (screen only) -->
|
||||||
|
<form method="get" action="<?= site_url('administrator/trophy/winners') ?>"
|
||||||
|
class="row g-2 align-items-end mb-4 no-print">
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label mb-1 small fw-semibold">School Year</label>
|
||||||
|
<select name="school_year" class="form-select form-select-sm" style="min-width:130px;">
|
||||||
|
<?php foreach ($years as $yr): ?>
|
||||||
|
<option value="<?= esc($yr) ?>" <?= $yr === $selectedYear ? 'selected' : '' ?>><?= esc($yr) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label mb-1 small fw-semibold">Percentile</label>
|
||||||
|
<div class="input-group input-group-sm" style="width:110px;">
|
||||||
|
<input type="number" name="percentile" class="form-control"
|
||||||
|
min="1" max="99" step="1" value="<?= (int)$selectedPercentile ?>">
|
||||||
|
<span class="input-group-text">%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Apply</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if (empty($classResults)): ?>
|
||||||
|
<div class="alert alert-info no-print">No projected trophy winners found.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<!-- ══ PRINT VIEW ════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="print-only">
|
||||||
|
|
||||||
|
<!-- Print title -->
|
||||||
|
<div style="text-align:center;margin-bottom:12px;border-bottom:2px solid #333;padding-bottom:8px;">
|
||||||
|
<h2 style="margin:0;">Trophy Winners — <?= esc($selectedYear) ?></h2>
|
||||||
|
<p style="margin:4px 0 0;font-size:10px;color:#555;">
|
||||||
|
<?= (int)$selectedPercentile ?>th percentile —
|
||||||
|
<?= $totalWinners ?> winners / <?= $totalStudents ?> students —
|
||||||
|
<?= count($classResults) ?> classes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TABLE 1: All winners -->
|
||||||
|
<h3 style="margin-bottom:4px;">Winners</h3>
|
||||||
|
<table data-no-mgmt-sticky>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Class</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th style="text-align:center;">Gender</th>
|
||||||
|
<th style="text-align:right;">Fall Score</th>
|
||||||
|
<th style="text-align:right;">Spring Score</th>
|
||||||
|
<th style="text-align:right;">Year Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php
|
||||||
|
$globalRank = 0;
|
||||||
|
foreach ($classResults as $cls):
|
||||||
|
foreach ($cls['winners'] as $student):
|
||||||
|
$globalRank++;
|
||||||
|
$isMale = strtolower($student['gender'] ?? '') === 'male';
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center;"><?= $globalRank ?></td>
|
||||||
|
<td><?= esc($cls['section_name']) ?></td>
|
||||||
|
<td><strong><?= esc($student['name']) ?></strong></td>
|
||||||
|
<td style="text-align:center;">
|
||||||
|
<span class="<?= $isMale ? 'badge-m' : 'badge-f' ?>">
|
||||||
|
<?= $isMale ? 'M' : 'F' ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;font-weight:bold;">
|
||||||
|
<?= $student['fall_score'] !== null ? number_format($student['fall_score'], 1) : '—' ?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<?= $student['spring_score'] !== null ? number_format($student['spring_score'], 1) : '—' ?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;font-weight:bold;color:#155724;">
|
||||||
|
<?= $student['year_score'] !== null ? number_format($student['year_score'], 1) : '—' ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" style="font-weight:bold;">Total</td>
|
||||||
|
<td colspan="2" style="font-weight:bold;"><?= $totalWinners ?> winners</td>
|
||||||
|
<td colspan="3" style="text-align:right;font-weight:bold;"><?= $pctWinners ?>% of students</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- TABLE 2: Year summary (new page) -->
|
||||||
|
<div class="print-page-break"></div>
|
||||||
|
|
||||||
|
<h3 style="margin-bottom:4px;">Year Summary — <?= esc($selectedYear) ?></h3>
|
||||||
|
<table style="margin-bottom:16px;" data-no-mgmt-sticky>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Class</th>
|
||||||
|
<th style="text-align:center;">Students</th>
|
||||||
|
<th style="text-align:center;">Boys</th>
|
||||||
|
<th style="text-align:center;">Girls</th>
|
||||||
|
<th style="text-align:center;">Winners</th>
|
||||||
|
<th style="text-align:center;">Trophy Boys</th>
|
||||||
|
<th style="text-align:center;">Trophy Girls</th>
|
||||||
|
<th style="text-align:center;">Rate</th>
|
||||||
|
<th style="text-align:right;">Fall Threshold</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($classResults as $cls): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= esc($cls['section_name']) ?></td>
|
||||||
|
<td style="text-align:center;"><?= $cls['student_count'] ?></td>
|
||||||
|
<td style="text-align:center;"><?= $cls['boys'] ?> (<?= $cls['pct_boys'] ?>%)</td>
|
||||||
|
<td style="text-align:center;"><?= $cls['girls'] ?> (<?= $cls['pct_girls'] ?>%)</td>
|
||||||
|
<td style="text-align:center;font-weight:bold;"><?= count($cls['winners']) ?></td>
|
||||||
|
<td style="text-align:center;">
|
||||||
|
<span class="badge-m"><?= $cls['trophy_boys'] ?></span>
|
||||||
|
<?= $cls['boys'] > 0 ? '(' . $cls['pct_trophy_boys'] . '%)' : '' ?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center;">
|
||||||
|
<span class="badge-f"><?= $cls['trophy_girls'] ?></span>
|
||||||
|
<?= $cls['girls'] > 0 ? '(' . $cls['pct_trophy_girls'] . '%)' : '' ?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center;"><?= $cls['pct_trophy_total'] ?>%</td>
|
||||||
|
<td style="text-align:right;"><?= $cls['threshold'] !== null ? number_format((float)$cls['threshold'], 1) : '—' ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:bold;">Total</td>
|
||||||
|
<td style="text-align:center;font-weight:bold;"><?= $totalStudents ?></td>
|
||||||
|
<td style="text-align:center;font-weight:bold;"><?= $totalBoys ?> (<?= $pctBoys ?>%)</td>
|
||||||
|
<td style="text-align:center;font-weight:bold;"><?= $totalGirls ?> (<?= $pctGirls ?>%)</td>
|
||||||
|
<td style="text-align:center;font-weight:bold;"><?= $totalWinners ?></td>
|
||||||
|
<td style="text-align:center;font-weight:bold;">
|
||||||
|
<span class="badge-m"><?= $totalTrophyBoys ?></span> (<?= $pctTrophyBoys ?>%)
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center;font-weight:bold;">
|
||||||
|
<span class="badge-f"><?= $totalTrophyGirls ?></span> (<?= $pctTrophyGirls ?>%)
|
||||||
|
</td>
|
||||||
|
<td style="text-align:center;font-weight:bold;"><?= $pctWinners ?>%</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div><!-- /print-only -->
|
||||||
|
|
||||||
|
<!-- ══ SCREEN VIEW ═══════════════════════════════════════════════════════ -->
|
||||||
|
<div class="screen-only">
|
||||||
|
|
||||||
|
<!-- Per-class cards -->
|
||||||
|
<?php foreach ($classResults as $cls):
|
||||||
|
$nW = count($cls['winners']);
|
||||||
|
?>
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2 py-2"
|
||||||
|
style="background:#fff8e1;">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="fw-bold fs-6"><?= esc($cls['section_name']) ?></span>
|
||||||
|
<span class="badge bg-warning text-dark">
|
||||||
|
<i class="bi bi-trophy-fill"></i> <?= $nW ?> winner<?= $nW !== 1 ? 's' : '' ?>
|
||||||
|
</span>
|
||||||
|
<?php if ($cls['threshold'] !== null): ?>
|
||||||
|
<span class="text-muted small">Fall ≥ <?= number_format((float)$cls['threshold'], 1) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-3 small">
|
||||||
|
<span>
|
||||||
|
<i class="bi bi-gender-male" style="color:#4A90E2"></i>
|
||||||
|
<strong style="color:#4A90E2"><?= $cls['trophy_boys'] ?></strong>/<?= $cls['boys'] ?> boys
|
||||||
|
<?php if ($cls['boys'] > 0): ?><span class="text-muted">(<?= $cls['pct_trophy_boys'] ?>%)</span><?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<i class="bi bi-gender-female" style="color:#E47AB0"></i>
|
||||||
|
<strong style="color:#E47AB0"><?= $cls['trophy_girls'] ?></strong>/<?= $cls['girls'] ?> girls
|
||||||
|
<?php if ($cls['girls'] > 0): ?><span class="text-muted">(<?= $cls['pct_trophy_girls'] ?>%)</span><?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<span class="text-muted"><?= $nW ?>/<?= $cls['student_count'] ?> (<?= $cls['pct_trophy_total'] ?>%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm table-hover mb-0 align-middle" data-no-mgmt-sticky>
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3" style="width:2.2rem;">#</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th class="text-center" style="width:3rem;">Gender</th>
|
||||||
|
<th class="text-end">Fall Score</th>
|
||||||
|
<th class="text-end">Spring Score</th>
|
||||||
|
<th class="text-end pe-3">Year Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($cls['winners'] as $i => $student):
|
||||||
|
$isMale = strtolower($student['gender'] ?? '') === 'male';
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3 text-muted small"><?= $i + 1 ?></td>
|
||||||
|
<td class="fw-semibold small"><?= esc($student['name']) ?></td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge" style="background:<?= $isMale ? '#4A90E2' : '#E47AB0' ?>;font-size:.65rem;">
|
||||||
|
<?= $isMale ? 'M' : 'F' ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end small fw-semibold">
|
||||||
|
<?= $student['fall_score'] !== null ? number_format($student['fall_score'], 1) : '<span class="text-muted">—</span>' ?>
|
||||||
|
</td>
|
||||||
|
<td class="text-end small">
|
||||||
|
<?= $student['spring_score'] !== null
|
||||||
|
? '<span class="fw-semibold">' . number_format($student['spring_score'], 1) . '</span>'
|
||||||
|
: '<span class="text-muted">—</span>' ?>
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-3 small fw-semibold" style="color:#155724;">
|
||||||
|
<?= $student['year_score'] !== null ? number_format($student['year_score'], 1) : '<span class="text-muted">—</span>' ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<!-- Overall year stats -->
|
||||||
|
<div class="card shadow-sm mt-2">
|
||||||
|
<div class="card-header bg-dark text-white fw-semibold py-2">
|
||||||
|
<i class="bi bi-bar-chart-fill me-2"></i>Overall Year Summary — <?= esc($selectedYear) ?>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<?php foreach ([
|
||||||
|
[$totalWinners, 'Total Winners', 'warning', 'text-dark', $pctWinners . '% of students', null],
|
||||||
|
[$totalStudents, 'Total Students', 'secondary', 'text-white', count($classResults) . ' classes', null],
|
||||||
|
[$totalBoys, 'Total Boys', null, null, $pctBoys . '%', '#4A90E2'],
|
||||||
|
[$totalGirls, 'Total Girls', null, null, $pctGirls . '%', '#E47AB0'],
|
||||||
|
[$totalTrophyBoys, 'Trophy Boys', null, null, $pctTrophyBoys . '% of boys', '#4A90E2'],
|
||||||
|
[$totalTrophyGirls, 'Trophy Girls', null, null, $pctTrophyGirls . '% of girls', '#E47AB0'],
|
||||||
|
] as [$val, $label, $bg, $tc, $sub, $color]): ?>
|
||||||
|
<div class="col-6 col-sm-4 col-lg-2">
|
||||||
|
<div class="border rounded text-center p-3 h-100">
|
||||||
|
<div class="fs-3 fw-bold" <?= isset($color) ? 'style="color:' . $color . '"' : ($bg ? 'class="text-' . $bg . '"' : '') ?>>
|
||||||
|
<?= $val ?>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted"><?= $label ?></div>
|
||||||
|
<div class="badge mt-1 <?= $bg ? 'bg-' . $bg . ' ' . $tc : 'text-white' ?>"
|
||||||
|
<?= isset($color) && !$bg ? 'style="background:' . $color . '"' : '' ?>>
|
||||||
|
<?= $sub ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 mt-1">
|
||||||
|
<!-- Stacked bar: trophies per class (boys + girls) -->
|
||||||
|
<div class="col-12 col-lg-7">
|
||||||
|
<div class="border rounded p-3 h-100">
|
||||||
|
<div class="small fw-semibold text-muted mb-2">
|
||||||
|
<i class="bi bi-trophy-fill text-warning me-1"></i>Trophy Winners per Class
|
||||||
|
</div>
|
||||||
|
<canvas id="chart-per-class" style="max-height:260px;"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Doughnut: trophy gender split + rate per class -->
|
||||||
|
<div class="col-12 col-lg-5">
|
||||||
|
<div class="row g-3 h-100">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="border rounded p-3">
|
||||||
|
<div class="small fw-semibold text-muted mb-2">
|
||||||
|
<i class="bi bi-gender-ambiguous me-1"></i>Winners by Gender
|
||||||
|
</div>
|
||||||
|
<canvas id="chart-gender" style="max-height:160px;"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="border rounded p-3">
|
||||||
|
<div class="small fw-semibold text-muted mb-2">
|
||||||
|
<i class="bi bi-bar-chart-fill me-1"></i>Trophy Rate per Class (%)
|
||||||
|
</div>
|
||||||
|
<canvas id="chart-rate" style="max-height:130px;"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /screen-only -->
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->section('scripts') ?>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
<?php
|
||||||
|
$chartLabels = [];
|
||||||
|
$chartBoys = [];
|
||||||
|
$chartGirls = [];
|
||||||
|
$chartRates = [];
|
||||||
|
foreach ($classResults as $cls) {
|
||||||
|
$chartLabels[] = $cls['section_name'];
|
||||||
|
$chartBoys[] = $cls['trophy_boys'];
|
||||||
|
$chartGirls[] = $cls['trophy_girls'];
|
||||||
|
$chartRates[] = $cls['pct_trophy_total'];
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
var labels = <?= json_encode($chartLabels) ?>;
|
||||||
|
var boys = <?= json_encode($chartBoys) ?>;
|
||||||
|
var girls = <?= json_encode($chartGirls) ?>;
|
||||||
|
var rates = <?= json_encode($chartRates) ?>;
|
||||||
|
|
||||||
|
var BOY_COLOR = '#4A90E2';
|
||||||
|
var GIRL_COLOR = '#E47AB0';
|
||||||
|
var RATE_COLOR = '#f0a500';
|
||||||
|
|
||||||
|
/* ── Chart 1: stacked horizontal bar per class ── */
|
||||||
|
new Chart(document.getElementById('chart-per-class'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'Boys', data: boys, backgroundColor: BOY_COLOR, stack: 'winners' },
|
||||||
|
{ label: 'Girls', data: girls, backgroundColor: GIRL_COLOR, stack: 'winners' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: { legend: { position: 'bottom' } },
|
||||||
|
scales: {
|
||||||
|
x: { stacked: true, ticks: { stepSize: 1 }, grid: { color: '#f0f0f0' } },
|
||||||
|
y: { stacked: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Chart 2: doughnut gender split ── */
|
||||||
|
new Chart(document.getElementById('chart-gender'), {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: ['Trophy Boys', 'Trophy Girls'],
|
||||||
|
datasets: [{
|
||||||
|
data: [<?= $totalTrophyBoys ?>, <?= $totalTrophyGirls ?>],
|
||||||
|
backgroundColor: [BOY_COLOR, GIRL_COLOR],
|
||||||
|
borderWidth: 2
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'bottom' },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: function(ctx) {
|
||||||
|
var total = ctx.dataset.data.reduce(function(a,b){ return a+b; }, 0);
|
||||||
|
var pct = total > 0 ? Math.round(ctx.parsed / total * 100) : 0;
|
||||||
|
return ctx.label + ': ' + ctx.parsed + ' (' + pct + '%)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Chart 3: bar chart rate per class ── */
|
||||||
|
new Chart(document.getElementById('chart-rate'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Trophy Rate %',
|
||||||
|
data: rates,
|
||||||
|
backgroundColor: rates.map(function(r) {
|
||||||
|
return r > 30 ? '#dc3545' : r > 20 ? RATE_COLOR : '#28a745';
|
||||||
|
}),
|
||||||
|
borderRadius: 3
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true, max: 100,
|
||||||
|
ticks: { callback: function(v) { return v + '%'; } },
|
||||||
|
grid: { color: '#f0f0f0' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
<!-- Navbar Start -->
|
<!-- Navbar Start -->
|
||||||
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
||||||
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
||||||
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
|
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
|
||||||
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
||||||
@@ -195,22 +195,22 @@
|
|||||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||||
<div class="row g-2 pt-2">
|
<div class="row g-2 pt-2">
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,4 +267,4 @@
|
|||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<a href="<?= base_url('/') ?>">
|
<a href="<?= base_url('/') ?>">
|
||||||
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 180px; height: 120px;">
|
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
<br>
|
<br>
|
||||||
<input type="hidden" name="selected_role" id="selected_role">
|
<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">
|
<div class="d-flex flex-wrap justify-content-center gap-3 mt-3">
|
||||||
<?php foreach ($roles as $role): ?>
|
<?php foreach ($roles as $role): ?>
|
||||||
@@ -68,4 +69,4 @@
|
|||||||
tooltipTriggerList.forEach(el => new bootstrap.Tooltip(el));
|
tooltipTriggerList.forEach(el => new bootstrap.Tooltip(el));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
<!-- Navbar Start -->
|
<!-- Navbar Start -->
|
||||||
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
||||||
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
||||||
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
|
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
|
||||||
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
||||||
@@ -155,22 +155,22 @@
|
|||||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||||
<div class="row g-2 pt-2">
|
<div class="row g-2 pt-2">
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,4 +227,4 @@
|
|||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Certificate Verification — Al Rahma Sunday School</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.7.2/font/bootstrap-icons.min.css">
|
||||||
|
<style>
|
||||||
|
body { background: #f4f6f9; }
|
||||||
|
.verify-card { max-width: 520px; margin: 60px auto; }
|
||||||
|
.badge-valid { background: #198754; }
|
||||||
|
.badge-invalid { background: #dc3545; }
|
||||||
|
.cert-logo { width: 70px; height: 70px; border-radius: 50%; object-fit: cover; }
|
||||||
|
.field-label { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: #6c757d; }
|
||||||
|
.field-value { font-size: 1.05rem; font-weight: 500; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="verify-card px-3">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="text-center mb-4 mt-5">
|
||||||
|
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="Al Rahma Sunday School" class="cert-logo mb-3">
|
||||||
|
<h4 class="fw-bold mb-0">Certificate Verification</h4>
|
||||||
|
<p class="text-muted small">Al Rahma Sunday School</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($record): ?>
|
||||||
|
|
||||||
|
<!-- Valid -->
|
||||||
|
<div class="card shadow-sm border-0">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-4">
|
||||||
|
<span class="badge badge-valid text-white px-3 py-2 fs-6">
|
||||||
|
<i class="bi bi-patch-check-fill me-1"></i>Verified
|
||||||
|
</span>
|
||||||
|
<code class="text-muted"><?= esc($record['certificate_number']) ?></code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="field-label">Student Name</div>
|
||||||
|
<div class="field-value"><?= esc($record['student_name']) ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="field-label">Grade / Class</div>
|
||||||
|
<div class="field-value"><?= esc($record['grade'] ?? '—') ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="field-label">School Year</div>
|
||||||
|
<div class="field-value"><?= esc($record['school_year'] ?? '—') ?></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="field-label">Certificate Date</div>
|
||||||
|
<div class="field-value">
|
||||||
|
<?= $record['cert_date'] ? esc(date('F j, Y', strtotime($record['cert_date']))) : '—' ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="field-label">Issued By</div>
|
||||||
|
<div class="field-value">
|
||||||
|
<?= esc(trim(($record['admin_firstname'] ?? '') . ' ' . ($record['admin_lastname'] ?? '')) ?: '—') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="field-label">Issued On</div>
|
||||||
|
<div class="field-value">
|
||||||
|
<?= $record['issued_at'] ? esc(date('F j, Y \a\t g:i A', strtotime($record['issued_at']))) : '—' ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-muted small text-center py-2">
|
||||||
|
This certificate was officially issued by Al Rahma Sunday School.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<!-- Not found -->
|
||||||
|
<div class="card shadow-sm border-0">
|
||||||
|
<div class="card-body p-4 text-center">
|
||||||
|
<span class="badge badge-invalid text-white px-3 py-2 fs-6 mb-3 d-inline-block">
|
||||||
|
<i class="bi bi-x-circle-fill me-1"></i>Not Found
|
||||||
|
</span>
|
||||||
|
<p class="mb-0">No certificate matching this code was found in our records.<br>
|
||||||
|
Please check the code and try again.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<p class="text-center text-muted small mt-4">
|
||||||
|
© <?= date('Y') ?> Al Rahma Sunday School
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+11
-11
@@ -45,7 +45,7 @@
|
|||||||
<!-- Navbar Start -->
|
<!-- Navbar Start -->
|
||||||
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
||||||
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
||||||
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
|
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
|
||||||
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
<div class="col-lg-4 col-md-6 wow fadeInUp" data-wow-delay="0.1s">
|
<div class="col-lg-4 col-md-6 wow fadeInUp" data-wow-delay="0.1s">
|
||||||
<div class="classes-item">
|
<div class="classes-item">
|
||||||
<div class="bg-light rounded-circle w-75 mx-auto p-3">
|
<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>
|
||||||
<div class="bg-light rounded p-4 pt-5 mt-n5">
|
<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>
|
<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="col-lg-4 col-md-6 wow fadeInUp" data-wow-delay="0.3s">
|
||||||
<div class="classes-item">
|
<div class="classes-item">
|
||||||
<div class="bg-light rounded-circle w-75 mx-auto p-3">
|
<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>
|
||||||
<div class="bg-light rounded p-4 pt-5 mt-n5">
|
<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>
|
<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="col-lg-4 col-md-6 wow fadeInUp" data-wow-delay="0.5s">
|
||||||
<div class="classes-item">
|
<div class="classes-item">
|
||||||
<div class="bg-light rounded-circle w-75 mx-auto p-3">
|
<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>
|
||||||
<div class="bg-light rounded p-4 pt-5 mt-n5">
|
<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>
|
<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>
|
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||||
<div class="row g-2 pt-2">
|
<div class="row g-2 pt-2">
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,4 +228,4 @@
|
|||||||
<script src="<?= base_url('js/main.js') ?>"></script>
|
<script src="<?= base_url('js/main.js') ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<!-- Navbar Start -->
|
<!-- Navbar Start -->
|
||||||
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
||||||
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
||||||
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
|
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
|
||||||
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
||||||
@@ -195,22 +195,22 @@
|
|||||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||||
<div class="row g-2 pt-2">
|
<div class="row g-2 pt-2">
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,4 +280,4 @@
|
|||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?= $this->extend('layout/email_layout') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<div style="font-size:16px; font-family:Arial, Helvetica, sans-serif; color:#333;">
|
||||||
|
<p style="margin:0 0 12px 0;line-height:1.5;">
|
||||||
|
Dear <?= esc($parent_name ?? 'Parent/Guardian') ?>,
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 12px 0;line-height:1.5;">
|
||||||
|
We are writing to share the school's decision regarding
|
||||||
|
<strong><?= esc($student_name ?? 'your student') ?></strong>
|
||||||
|
<?php if (!empty($class_section_name)): ?>(<?= esc($class_section_name) ?>)<?php endif; ?>
|
||||||
|
for the <?= esc(trim(($school_year ?? ''))) ?> school year.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php if (!empty($decision)): ?>
|
||||||
|
<table style="width:100%; border-collapse:collapse; margin:0 0 16px; font-size:15px;">
|
||||||
|
<tr>
|
||||||
|
<td style="border:1px solid #ddd; padding:10px; font-weight:bold; background:#f8f9fa; width:35%;">Decision</td>
|
||||||
|
<td style="border:1px solid #ddd; padding:10px; font-weight:bold; color:#2c5282;"><?= esc($decision) ?></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($notes)): ?>
|
||||||
|
<p style="margin:0 0 6px 0;line-height:1.5;"><strong>Comments:</strong></p>
|
||||||
|
<p style="margin:0 0 16px 0;line-height:1.6;background:#f8f9fa;padding:10px 14px;border-left:4px solid #4a90d9;">
|
||||||
|
<?= nl2br(esc($notes)) ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$scoreLabels = [
|
||||||
|
'homework_avg' => 'Homework Avg',
|
||||||
|
'project_avg' => 'Project Avg',
|
||||||
|
'participation_score' => 'Participation',
|
||||||
|
'test_avg' => 'Test Avg',
|
||||||
|
'ptap_score' => 'PTAP Score',
|
||||||
|
'attendance_score' => 'Attendance',
|
||||||
|
'midterm_exam_score' => 'Midterm Score',
|
||||||
|
'final_exam_score' => 'Final Exam',
|
||||||
|
'semester_score' => 'Semester Score',
|
||||||
|
];
|
||||||
|
|
||||||
|
$allSemesters = is_array($all_semesters ?? null) ? $all_semesters : [];
|
||||||
|
$hasSemesters = !empty($allSemesters);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php if ($hasSemesters): ?>
|
||||||
|
<p style="margin:0 0 8px 0;line-height:1.5;"><strong>Score Summary — <?= esc($school_year ?? '') ?></strong></p>
|
||||||
|
|
||||||
|
<?php foreach ($allSemesters as $sem): ?>
|
||||||
|
<?php
|
||||||
|
$semLabel = (string)($sem['semester'] ?? '');
|
||||||
|
$hasAnyScore = false;
|
||||||
|
foreach ($scoreLabels as $key => $_) {
|
||||||
|
$v = $sem[$key] ?? null;
|
||||||
|
if ($v !== null && $v !== '') { $hasAnyScore = true; break; }
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<p style="margin:0 0 4px 0;font-weight:bold;font-size:14px;"><?= esc($semLabel) ?> Semester</p>
|
||||||
|
<table style="width:100%; border-collapse:collapse; margin:0 0 12px; font-size:14px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f8f9fa;">
|
||||||
|
<th style="border:1px solid #ddd; padding:6px; text-align:left;">Item</th>
|
||||||
|
<th style="border:1px solid #ddd; padding:6px; text-align:left;">Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($scoreLabels as $key => $label): ?>
|
||||||
|
<?php $val = $sem[$key] ?? null; if ($val === null || $val === '') continue; ?>
|
||||||
|
<tr>
|
||||||
|
<td style="border:1px solid #ddd; padding:6px;"><?= esc($label) ?></td>
|
||||||
|
<td style="border:1px solid #ddd; padding:6px;<?= $key === 'semester_score' ? ' font-weight:bold;' : '' ?>">
|
||||||
|
<?= esc(number_format((float)$val, 2, '.', '')) ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (!$hasAnyScore): ?>
|
||||||
|
<tr><td colspan="2" style="border:1px solid #ddd; padding:6px; color:#999;">No scores recorded.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php
|
||||||
|
$semComments = is_array($sem['comments'] ?? null) ? $sem['comments'] : [];
|
||||||
|
$commentTypeLabels = [
|
||||||
|
'general' => 'General',
|
||||||
|
'attendance' => 'Attendance',
|
||||||
|
'attendance_comment' => 'Attendance',
|
||||||
|
'midterm' => 'Midterm',
|
||||||
|
'final' => 'Final Exam',
|
||||||
|
'ptap' => 'PTAP',
|
||||||
|
];
|
||||||
|
// Deduplicate by label+text
|
||||||
|
$seenCmt = [];
|
||||||
|
$dedupedCmt = [];
|
||||||
|
foreach ($semComments as $ctype => $ctext) {
|
||||||
|
$ctext = trim((string)$ctext);
|
||||||
|
if ($ctext === '') continue;
|
||||||
|
$clabel = $commentTypeLabels[$ctype] ?? ucfirst($ctype);
|
||||||
|
$key = $clabel . '|' . $ctext;
|
||||||
|
if (!isset($seenCmt[$key])) { $seenCmt[$key] = true; $dedupedCmt[] = [$clabel, $ctext]; }
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<?php if (!empty($dedupedCmt)): ?>
|
||||||
|
<p style="margin:0 0 4px 0;font-size:13px;font-weight:bold;">Comments</p>
|
||||||
|
<?php foreach ($dedupedCmt as [$clabel, $ctext]): ?>
|
||||||
|
<p style="margin:0 0 8px 0;font-size:13px;color:#444;line-height:1.5;background:#f8f9fa;padding:6px 10px;border-left:3px solid #aaa;">
|
||||||
|
<strong><?= esc($clabel) ?>:</strong> <?= nl2br(esc($ctext)) ?>
|
||||||
|
</p>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php else: ?>
|
||||||
|
<?php
|
||||||
|
// Fallback: single semester scores block
|
||||||
|
$scores = is_array($scores ?? null) ? $scores : [];
|
||||||
|
$hasScores = (bool) array_filter($scores, static fn($v) => $v !== null && $v !== '');
|
||||||
|
?>
|
||||||
|
<?php if ($hasScores): ?>
|
||||||
|
<p style="margin:0 0 6px 0;line-height:1.5;"><strong>Score Summary:</strong></p>
|
||||||
|
<table style="width:100%; border-collapse:collapse; margin:0 0 16px; font-size:14px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f8f9fa;">
|
||||||
|
<th style="border:1px solid #ddd; padding:6px; text-align:left;">Item</th>
|
||||||
|
<th style="border:1px solid #ddd; padding:6px; text-align:left;">Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($scoreLabels as $key => $label): ?>
|
||||||
|
<?php $val = $scores[$key] ?? null; if ($val === null || $val === '') continue; ?>
|
||||||
|
<tr>
|
||||||
|
<td style="border:1px solid #ddd; padding:6px;"><?= esc($label) ?></td>
|
||||||
|
<td style="border:1px solid #ddd; padding:6px;"><?= esc(number_format((float)$val, 2, '.', '')) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<p style="margin:0 0 12px 0;line-height:1.5;">
|
||||||
|
Please do not hesitate to contact the school if you have any questions or concerns.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin:0;line-height:1.5;">
|
||||||
|
Thank you,<br>
|
||||||
|
Al Rahma Sunday School
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -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>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="modal-content rounded-4 shadow border-0" style="max-width: 600px; margin: auto;">
|
<div class="modal-content rounded-4 shadow border-0" style="max-width: 600px; margin: auto;">
|
||||||
<div class="modal-body text-center p-5">
|
<div class="modal-body text-center p-5">
|
||||||
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="Logo"
|
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="Logo"
|
||||||
style="width: 180px; height: 120px;" class="mb-4">
|
style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;" class="mb-4">
|
||||||
<h5 class="modal-title text-danger mb-3" id="blockedLabel">Access Blocked</h5>
|
<h5 class="modal-title text-danger mb-3" id="blockedLabel">Access Blocked</h5>
|
||||||
<p class="lead mb-3">Too many password reset attempts have been made.</p>
|
<p class="lead mb-3">Too many password reset attempts have been made.</p>
|
||||||
<p>Please try again after 24 hours or contact support if you need urgent assistance.</p>
|
<p>Please try again after 24 hours or contact support if you need urgent assistance.</p>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="bg-white p-5 rounded-5 shadow registration-form container" style="max-width: 600px; width: 100%;">
|
<div class="bg-white p-5 rounded-5 shadow registration-form container" style="max-width: 600px; width: 100%;">
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<a href="<?= base_url('/') ?>">
|
<a href="<?= base_url('/') ?>">
|
||||||
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 180px; height: 120px;">
|
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="" style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -80,10 +80,10 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td><?= esc($e['approver_firstname'] . ' ' . $e['approver_lastname'] ?? '-') ?></td>
|
<td><?= esc($e['approver_firstname'] . ' ' . $e['approver_lastname'] ?? '-') ?></td>
|
||||||
<!--td><--?= esc($e['status_reason'] ?? '-') ?></td-->
|
<!-- <td><?= esc($e['status_reason'] ?? '-') ?></td> -->
|
||||||
<td>
|
<td>
|
||||||
<?php if ($e['receipt_path']): ?>
|
<?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>
|
<a href="<?= site_url('receipts/' . $e['receipt_path']) ?>" target="_blank">View receipt</a>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
N/A
|
N/A
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<!-- Navbar Start -->
|
<!-- Navbar Start -->
|
||||||
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
<nav class="navbar navbar-expand-lg bg-white navbar-light sticky-top px-4 px-lg-5 py-lg-0">
|
||||||
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
<a href="<?= base_url('/') ?>" class="navbar-brand d-flex align-items-center">
|
||||||
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px;">
|
<img src="<?= base_url('images/logo.png') ?>" alt="Al Rahma Sunday School" style="height: 40px; width: 40px; border-radius: 50%; object-fit: cover;">
|
||||||
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
<h1 class="m-0 ms-2 green-title">Al Rahma Sunday School</h1>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
<button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse">
|
||||||
|
|||||||
@@ -26,22 +26,22 @@
|
|||||||
<h3 class="text-white mb-4">Photo Gallery</h3>
|
<h3 class="text-white mb-4">Photo Gallery</h3>
|
||||||
<div class="row g-2 pt-2">
|
<div class="row g-2 pt-2">
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
||||||
<div class="col-4">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="wrapper">
|
||||||
|
<h2 class="text-center mt-4 mb-4">Student Decisions — All Students</h2>
|
||||||
|
|
||||||
|
<!-- School Year filter only -->
|
||||||
|
<form method="get" class="row g-2 align-items-center justify-content-center mb-3">
|
||||||
|
<div class="col-auto"><label class="form-label mb-0">School year</label></div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<select name="school_year" class="form-select form-select-sm" style="min-width: 180px;">
|
||||||
|
<?php $years = isset($schoolYears) && is_array($schoolYears) ? $schoolYears : []; ?>
|
||||||
|
<?php foreach ($years as $y):
|
||||||
|
$val = is_array($y) && isset($y['school_year']) ? (string)$y['school_year'] : (string)$y; ?>
|
||||||
|
<option value="<?= esc($val) ?>" <?= ($schoolYear === $val ? 'selected' : '') ?>><?= esc($val) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($years) && $schoolYear !== ''): ?>
|
||||||
|
<option value="<?= esc($schoolYear) ?>" selected><?= esc($schoolYear) ?></option>
|
||||||
|
<?php endif; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-secondary btn-sm">Apply</button>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="?">Reset</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
|
<div class="text-muted">
|
||||||
|
<?= esc($schoolYear ?? '') ?>
|
||||||
|
<?php if ($generated): ?>
|
||||||
|
<span class="badge bg-success ms-2">Saved</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge bg-warning text-dark ms-2">Not yet generated</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="<?= base_url('grading') ?>">
|
||||||
|
Grading
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty(session()->getFlashdata('status'))): ?>
|
||||||
|
<div class="alert alert-success alert-dismissible fade show">
|
||||||
|
<?= esc(session()->getFlashdata('status')) ?>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty(session()->getFlashdata('error'))): ?>
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show">
|
||||||
|
<?= esc(session()->getFlashdata('error')) ?>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Generate / Regenerate button -->
|
||||||
|
<form method="post" action="<?= site_url('grading/decisions/generate') ?>" class="mb-3">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="school_year" value="<?= esc($schoolYear ?? '') ?>">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<?= $generated ? 'Regenerate Decisions' : 'Generate Decisions' ?>
|
||||||
|
</button>
|
||||||
|
<span class="text-muted small ms-2">
|
||||||
|
Year score ≥ 60 → <strong>Pass</strong> (auto) |
|
||||||
|
Year score < 60 → pulled from below-60 decisions
|
||||||
|
</span>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if (empty($rows)): ?>
|
||||||
|
<div class="alert alert-info">No semester scores found for this school year.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$decisionBadge = [
|
||||||
|
'Pass' => 'success',
|
||||||
|
'Repeat Class' => 'danger',
|
||||||
|
'Make-up exam in fall' => 'info',
|
||||||
|
'Deferred decision' => 'info',
|
||||||
|
'Expel' => 'danger',
|
||||||
|
'Withdrawn' => 'secondary',
|
||||||
|
];
|
||||||
|
$sourceBadge = [
|
||||||
|
'auto' => ['bg-success-subtle text-success-emphasis', 'Auto'],
|
||||||
|
'manual' => ['bg-primary-subtle text-primary-emphasis', 'Manual'],
|
||||||
|
'pending' => ['bg-warning-subtle text-warning-emphasis', 'Pending'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$stats = ['Pass' => 0, 'Other' => 0, 'Pending' => 0];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if ($r['decision'] === 'Pass') $stats['Pass']++;
|
||||||
|
elseif ($r['decision'] === '' || $r['source'] === 'pending') $stats['Pending']++;
|
||||||
|
else $stats['Other']++;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div class="d-flex gap-3 mb-3 flex-wrap">
|
||||||
|
<div class="card text-center px-4 py-2 border-success">
|
||||||
|
<div class="fs-4 fw-bold text-success"><?= $stats['Pass'] ?></div>
|
||||||
|
<div class="text-muted small">Pass</div>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center px-4 py-2 border-primary">
|
||||||
|
<div class="fs-4 fw-bold text-primary"><?= $stats['Other'] ?></div>
|
||||||
|
<div class="text-muted small">Other decision</div>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center px-4 py-2 border-warning">
|
||||||
|
<div class="fs-4 fw-bold text-warning"><?= $stats['Pending'] ?></div>
|
||||||
|
<div class="text-muted small">Pending</div>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center px-4 py-2">
|
||||||
|
<div class="fs-4 fw-bold"><?= count($rows) ?></div>
|
||||||
|
<div class="text-muted small">Total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-bordered table-striped align-middle w-100 all-decisions-dt"
|
||||||
|
data-no-mgmt-sticky data-no-dt-fixedheader>
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Student Name</th>
|
||||||
|
<th>Section</th>
|
||||||
|
<th class="text-center">Fall Score</th>
|
||||||
|
<th class="text-center">Spring Score</th>
|
||||||
|
<th class="text-center">Year Score</th>
|
||||||
|
<th class="text-center">Decision</th>
|
||||||
|
<th class="text-center">Source</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($rows as $row): ?>
|
||||||
|
<?php
|
||||||
|
$yearScore = $row['year_score'];
|
||||||
|
$decision = (string)($row['decision'] ?? '');
|
||||||
|
$source = (string)($row['source'] ?? 'pending');
|
||||||
|
$yearVal = is_numeric($yearScore) ? (float)$yearScore : null;
|
||||||
|
$rowClass = '';
|
||||||
|
if ($yearVal !== null && $yearVal < 60) {
|
||||||
|
$rowClass = $yearVal < 50 ? 'grade-red' : 'grade-orange';
|
||||||
|
}
|
||||||
|
$badge = $decisionBadge[$decision] ?? null;
|
||||||
|
[$srcCls, $srcLabel] = $sourceBadge[$source] ?? ['bg-light text-muted', $source];
|
||||||
|
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||||
|
$fmt = fn($v) => is_numeric($v) ? number_format((float)$v, 2) : '—';
|
||||||
|
?>
|
||||||
|
<tr class="<?= esc($rowClass) ?>">
|
||||||
|
<td><?= esc($studentName ?: 'N/A') ?></td>
|
||||||
|
<td><?= esc($row['class_section_name'] ?? '—') ?></td>
|
||||||
|
<td class="text-center"><?= esc($fmt($row['fall_score'] ?? null)) ?></td>
|
||||||
|
<td class="text-center"><?= esc($fmt($row['spring_score'] ?? null)) ?></td>
|
||||||
|
<td class="text-center fw-semibold">
|
||||||
|
<?= esc($fmt($yearVal)) ?>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<?php if ($decision !== '' && $badge): ?>
|
||||||
|
<span class="badge bg-<?= esc($badge) ?>"><?= esc($decision) ?></span>
|
||||||
|
<?php elseif ($decision !== ''): ?>
|
||||||
|
<span class="badge bg-secondary"><?= esc($decision) ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="text-muted small">—</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge <?= esc($srcCls) ?>"><?= esc($srcLabel) ?></span>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small"><?= nl2br(esc((string)($row['notes'] ?? ''))) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="mt-2 mb-4 d-flex gap-2 flex-wrap align-items-center">
|
||||||
|
<?php foreach ($decisionBadge as $label => $color): ?>
|
||||||
|
<span class="badge bg-<?= esc($color) ?> px-2 py-1"><?= esc($label) ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<span class="text-muted small ms-2">— decision colour key</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('scripts') ?>
|
||||||
|
<style>
|
||||||
|
.grade-orange td { background: #fff3cd !important; color: #8a5d00; }
|
||||||
|
.grade-red td { background: #f8d7da !important; color: #842029; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
if (!window.$ || !$.fn || !$.fn.DataTable) return;
|
||||||
|
$(function () {
|
||||||
|
const tbl = $('.all-decisions-dt');
|
||||||
|
if (!tbl.length) return;
|
||||||
|
try {
|
||||||
|
tbl.DataTable({
|
||||||
|
order: [[1, 'asc'], [0, 'asc']],
|
||||||
|
pageLength: 100,
|
||||||
|
lengthMenu: [25, 50, 100, 200],
|
||||||
|
columnDefs: [{ orderable: false, targets: [7] }]
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -9,15 +9,21 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
<div class="text-muted">
|
<div class="text-muted">
|
||||||
<?= esc(ucfirst($semester ?? '')) ?> • <?= esc($schoolYear ?? '') ?>
|
<?= !empty($isYearMode) ? 'Whole Year' : esc(ucfirst($semester ?? '')) ?> • <?= esc($schoolYear ?? '') ?>
|
||||||
</div>
|
</div>
|
||||||
<?php if (!empty($canViewGrading)): ?>
|
<div class="d-flex gap-2">
|
||||||
<a class="btn btn-outline-secondary btn-sm" href="<?= base_url('grading') ?>">
|
<?php if (empty($isYearMode)): ?>
|
||||||
Back to Grading
|
<a class="btn btn-outline-primary btn-sm"
|
||||||
|
href="<?= site_url('grading/below-60/decisions?' . http_build_query(['semester' => $semester ?? '', 'school_year' => $schoolYear ?? ''])) ?>">
|
||||||
|
Decisions
|
||||||
</a>
|
</a>
|
||||||
<?php else: ?>
|
<?php endif; ?>
|
||||||
<span class="text-muted small">You do not have access to the Grading page.</span>
|
<?php if (!empty($canViewGrading)): ?>
|
||||||
<?php endif; ?>
|
<a class="btn btn-outline-secondary btn-sm" href="<?= base_url('grading') ?>">
|
||||||
|
Back to Grading
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
@@ -43,6 +49,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Student Name</th>
|
<th>Student Name</th>
|
||||||
<th>Section</th>
|
<th>Section</th>
|
||||||
|
<?php if (!empty($isYearMode)): ?><th>Semester</th><?php endif; ?>
|
||||||
<th>Hwk Avg</th>
|
<th>Hwk Avg</th>
|
||||||
<th>Project Avg</th>
|
<th>Project Avg</th>
|
||||||
<th>Participation</th>
|
<th>Participation</th>
|
||||||
@@ -50,10 +57,12 @@
|
|||||||
<th>PTAP Score</th>
|
<th>PTAP Score</th>
|
||||||
<th>Attendance</th>
|
<th>Attendance</th>
|
||||||
<th>Midterm Score</th>
|
<th>Midterm Score</th>
|
||||||
<th><?= strcasecmp($semester ?? '', 'fall') === 0 ? '1st Semester Score' : 'Semester Score' ?></th>
|
<th><?= !empty($isYearMode) ? 'Semester Score' : (strcasecmp($semester ?? '', 'fall') === 0 ? '1st Semester Score' : 'Semester Score') ?></th>
|
||||||
|
<?php if (empty($isYearMode)): ?>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Email Parent</th>
|
<th>Email Parent</th>
|
||||||
<th>Schedule Meeting</th>
|
<th>Schedule Meeting</th>
|
||||||
|
<?php endif; ?>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -72,9 +81,13 @@
|
|||||||
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||||
?>
|
?>
|
||||||
<?php $isClosed = ($row['status'] ?? 'Open') === 'Closed'; ?>
|
<?php $isClosed = ($row['status'] ?? 'Open') === 'Closed'; ?>
|
||||||
|
<?php $rowSemester = ucfirst(strtolower(trim((string)($row['semester'] ?? ($semester ?? ''))))); ?>
|
||||||
<tr class="<?= esc($scoreClass) ?>">
|
<tr class="<?= esc($scoreClass) ?>">
|
||||||
<td><?= esc($studentName !== '' ? $studentName : 'N/A') ?></td>
|
<td><?= esc($studentName !== '' ? $studentName : 'N/A') ?></td>
|
||||||
<td><?= esc($row['class_section_name'] ?? '—') ?></td>
|
<td><?= esc($row['class_section_name'] ?? '—') ?></td>
|
||||||
|
<?php if (!empty($isYearMode)): ?>
|
||||||
|
<td class="text-center"><?= esc($rowSemester) ?></td>
|
||||||
|
<?php endif; ?>
|
||||||
<td class="text-center"><?= $displayScore($row['homework_avg'] ?? null) ?></td>
|
<td class="text-center"><?= $displayScore($row['homework_avg'] ?? null) ?></td>
|
||||||
<td class="text-center"><?= $displayScore($row['project_avg'] ?? null) ?></td>
|
<td class="text-center"><?= $displayScore($row['project_avg'] ?? null) ?></td>
|
||||||
<td class="text-center"><?= $displayScore($row['participation_score'] ?? null) ?></td>
|
<td class="text-center"><?= $displayScore($row['participation_score'] ?? null) ?></td>
|
||||||
@@ -83,6 +96,7 @@
|
|||||||
<td class="text-center"><?= $displayScore($row['attendance_score'] ?? null) ?></td>
|
<td class="text-center"><?= $displayScore($row['attendance_score'] ?? null) ?></td>
|
||||||
<td class="text-center"><?= $displayScore($row['midterm_exam_score'] ?? null) ?></td>
|
<td class="text-center"><?= $displayScore($row['midterm_exam_score'] ?? null) ?></td>
|
||||||
<td class="text-center"><?= $displayScore($row['semester_score'] ?? null) ?></td>
|
<td class="text-center"><?= $displayScore($row['semester_score'] ?? null) ?></td>
|
||||||
|
<?php if (empty($isYearMode)): ?>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<form method="post" action="<?= site_url('grading/below-60/status') ?>" class="d-flex align-items-center gap-2 justify-content-center">
|
<form method="post" action="<?= site_url('grading/below-60/status') ?>" class="d-flex align-items-center gap-2 justify-content-center">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
@@ -117,6 +131,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
|
<?php endif; ?>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -143,8 +158,9 @@
|
|||||||
const table = $('.below-sixty-dt');
|
const table = $('.below-sixty-dt');
|
||||||
if (!table.length) return;
|
if (!table.length) return;
|
||||||
try {
|
try {
|
||||||
|
const semesterOffset = <?= !empty($isYearMode) ? '1' : '0' ?>;
|
||||||
table.DataTable({
|
table.DataTable({
|
||||||
order: [[8, 'asc']],
|
order: [[8 + semesterOffset, 'asc']],
|
||||||
pageLength: 100,
|
pageLength: 100,
|
||||||
lengthMenu: [10, 25, 50, 100, 200]
|
lengthMenu: [10, 25, 50, 100, 200]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="wrapper">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 class="h4 mb-1">Send Decision Email</h2>
|
||||||
|
<div class="text-muted">
|
||||||
|
<?= esc($studentName !== '' ? $studentName : 'Student') ?> • <?= esc($semester ?? '') ?> • <?= esc($schoolYear ?? '') ?>
|
||||||
|
<?php if (!empty($decision)): ?>
|
||||||
|
— <span class="fw-semibold"><?= esc($decision) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm"
|
||||||
|
href="<?= site_url('grading/below-60/decisions?' . http_build_query(['semester' => $semester ?? '', 'school_year' => $schoolYear ?? ''])) ?>">
|
||||||
|
← Back to Decisions
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="<?= site_url('grading/below-60/decisions/email') ?>" class="card shadow-sm">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="student_id" value="<?= esc((string)($studentId ?? '')) ?>">
|
||||||
|
<input type="hidden" name="semester" value="<?= esc((string)($semester ?? '')) ?>">
|
||||||
|
<input type="hidden" name="school_year" value="<?= esc((string)($schoolYear ?? '')) ?>">
|
||||||
|
<input type="hidden" name="html" id="decision_html"
|
||||||
|
value="<?= htmlspecialchars((string)($html ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="decisionSubject">Subject</label>
|
||||||
|
<input type="text" class="form-control" id="decisionSubject" name="subject"
|
||||||
|
value="<?= esc((string)($subject ?? '')) ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="decisionEditor">Body (Rich Text)</label>
|
||||||
|
<textarea class="form-control" id="decisionEditor" rows="16"><?= (string)($html ?? '') ?></textarea>
|
||||||
|
<div class="form-text">Review and edit the message before sending.</div>
|
||||||
|
<noscript>
|
||||||
|
<div class="alert alert-warning mt-2">
|
||||||
|
JavaScript is disabled. The message will be sent from the hidden <code>html</code> field.
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end gap-2">
|
||||||
|
<a class="btn btn-outline-secondary"
|
||||||
|
href="<?= site_url('grading/below-60/decisions?' . http_build_query(['semester' => $semester ?? '', 'school_year' => $schoolYear ?? ''])) ?>">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary">Send Email</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('scripts') ?>
|
||||||
|
<script src="<?= base_url('assets/tinymce/tinymce.min.js') ?>"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const form = document.querySelector('form[action$="grading/below-60/decisions/email"]');
|
||||||
|
const hiddenHtml = document.getElementById('decision_html');
|
||||||
|
|
||||||
|
if (!window.tinymce) return;
|
||||||
|
|
||||||
|
tinymce.init({
|
||||||
|
selector: '#decisionEditor',
|
||||||
|
base_url: '<?= base_url('assets/tinymce') ?>',
|
||||||
|
suffix: '.min',
|
||||||
|
license_key: 'gpl',
|
||||||
|
height: 460,
|
||||||
|
menubar: true,
|
||||||
|
branding: false,
|
||||||
|
promotion: false,
|
||||||
|
plugins: 'advlist autolink lists link image charmap preview anchor ' +
|
||||||
|
'searchreplace visualblocks code fullscreen insertdatetime media table ' +
|
||||||
|
'help wordcount emoticons codesample',
|
||||||
|
toolbar: 'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough forecolor backcolor | ' +
|
||||||
|
'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | ' +
|
||||||
|
'link image media table | emoticons codesample | removeformat | preview code',
|
||||||
|
convert_urls: false,
|
||||||
|
paste_data_images: true,
|
||||||
|
content_style: 'body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; font-size: 14px; }',
|
||||||
|
setup(editor) {
|
||||||
|
editor.on('keyup change undo redo SetContent', function () {
|
||||||
|
if (hiddenHtml) hiddenHtml.value = editor.getContent({ format: 'html' });
|
||||||
|
});
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function () {
|
||||||
|
if (hiddenHtml) hiddenHtml.value = editor.getContent({ format: 'html' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
<?= $this->extend('layout/management_layout') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="wrapper below-sixty-decisions-wrapper">
|
||||||
|
<h2 class="text-center mt-4 mb-4">Below 60 — Decisions</h2>
|
||||||
|
|
||||||
|
<?= $this->include('partials/academic_filter') ?>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
|
<div class="text-muted">
|
||||||
|
<?= esc(ucfirst($semester ?? '')) ?> • <?= esc($schoolYear ?? '') ?>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm"
|
||||||
|
href="<?= site_url('grading/below-60?' . http_build_query(['semester' => $semester, 'school_year' => $schoolYear])) ?>">
|
||||||
|
← Back to Below 60
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-outline-primary btn-sm"
|
||||||
|
href="<?= site_url('grading/decisions?' . http_build_query(['semester' => $semester, 'school_year' => $schoolYear])) ?>">
|
||||||
|
All Decisions
|
||||||
|
</a>
|
||||||
|
<?php if (!empty($canViewGrading)): ?>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="<?= base_url('grading') ?>">
|
||||||
|
Grading
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty(session()->getFlashdata('status'))): ?>
|
||||||
|
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||||
|
<?= esc(session()->getFlashdata('status')) ?>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty(session()->getFlashdata('error'))): ?>
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
|
<?= esc(session()->getFlashdata('error')) ?>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$decisionOptions = [
|
||||||
|
'' => '— No decision yet —',
|
||||||
|
'Pass' => 'Pass',
|
||||||
|
'Repeat Class' => 'Repeat Class',
|
||||||
|
'Make-up exam in fall' => 'Make-up exam in fall',
|
||||||
|
'Deferred decision' => 'Deferred decision',
|
||||||
|
'Expel' => 'Expel',
|
||||||
|
'Withdrawn' => 'Withdrawn',
|
||||||
|
];
|
||||||
|
|
||||||
|
$decisionBadge = [
|
||||||
|
'Pass' => 'success',
|
||||||
|
'Repeat Class' => 'danger',
|
||||||
|
'Make-up exam in fall' => 'info',
|
||||||
|
'Deferred decision' => 'info',
|
||||||
|
'Expel' => 'danger',
|
||||||
|
'Withdrawn' => 'secondary',
|
||||||
|
];
|
||||||
|
|
||||||
|
$displayScore = function ($value) {
|
||||||
|
if ($value === null || $value === '') return '—';
|
||||||
|
if (is_numeric($value)) return esc(number_format((float)$value, 2, '.', ''));
|
||||||
|
return esc($value);
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php if (empty($rows)): ?>
|
||||||
|
<div class="alert alert-success text-center d-inline-block">
|
||||||
|
No students below 60 for this selection.
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-bordered table-striped align-middle w-100 decisions-dt" data-no-mgmt-sticky data-no-dt-fixedheader>
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="min-width:160px">Student Name</th>
|
||||||
|
<th>Section</th>
|
||||||
|
<th class="text-center">Score</th>
|
||||||
|
<th style="min-width:300px">Comments / Rationale & Decision</th>
|
||||||
|
<th style="min-width:150px" class="text-center">Below-60 Decision</th>
|
||||||
|
<th style="min-width:130px" class="text-center">Final Decision</th>
|
||||||
|
<th style="min-width:130px" class="text-center">Certificate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($rows as $row): ?>
|
||||||
|
<?php
|
||||||
|
$scoreRaw = $row['semester_score'] ?? null;
|
||||||
|
$scoreVal = is_numeric($scoreRaw) ? (float)$scoreRaw : null;
|
||||||
|
$rowClass = '';
|
||||||
|
if ($scoreVal !== null) {
|
||||||
|
$rowClass = $scoreVal < 50 ? 'grade-red' : 'grade-orange';
|
||||||
|
}
|
||||||
|
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
|
||||||
|
$currentDecision = (string)($row['decision'] ?? '');
|
||||||
|
$currentNotes = (string)($row['decision_notes'] ?? '');
|
||||||
|
$badge = $decisionBadge[$currentDecision] ?? null;
|
||||||
|
?>
|
||||||
|
<tr class="<?= esc($rowClass) ?>">
|
||||||
|
<td><?= esc($studentName !== '' ? $studentName : 'N/A') ?></td>
|
||||||
|
<td><?= esc($row['class_section_name'] ?? '—') ?></td>
|
||||||
|
<td class="text-center">
|
||||||
|
<div class="fw-semibold"><?= $displayScore($scoreRaw) ?></div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-secondary btn-xs mt-1 btn-show-details"
|
||||||
|
style="font-size:0.72rem;padding:1px 7px;"
|
||||||
|
data-student-id="<?= (int)($row['student_id'] ?? 0) ?>"
|
||||||
|
data-student-name="<?= esc($studentName !== '' ? $studentName : 'N/A') ?>"
|
||||||
|
data-school-year="<?= esc((string)($schoolYear ?? '')) ?>">
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="<?= site_url('grading/below-60/decisions/save') ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="student_id" value="<?= esc((string)($row['student_id'] ?? '')) ?>">
|
||||||
|
<input type="hidden" name="semester" value="<?= esc((string)($semester ?? '')) ?>">
|
||||||
|
<input type="hidden" name="school_year" value="<?= esc((string)($schoolYear ?? '')) ?>">
|
||||||
|
<textarea name="notes"
|
||||||
|
class="form-control form-control-sm decision-notes"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Add comments or rationale…"><?= esc($currentNotes) ?></textarea>
|
||||||
|
<div class="d-flex gap-2 mt-2 align-items-center">
|
||||||
|
<select name="decision" class="form-select form-select-sm decision-select flex-grow-1">
|
||||||
|
<?php foreach ($decisionOptions as $val => $label): ?>
|
||||||
|
<option value="<?= esc($val) ?>" <?= $currentDecision === $val ? 'selected' : '' ?>>
|
||||||
|
<?= esc($label) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="text-center align-middle">
|
||||||
|
<?php if ($currentDecision !== '' && $badge): ?>
|
||||||
|
<span class="badge bg-<?= esc($badge) ?> fs-6 px-3 py-2 d-block mb-2"><?= esc($currentDecision) ?></span>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-outline-primary btn-send-email"
|
||||||
|
data-student-id="<?= (int)($row['student_id'] ?? 0) ?>"
|
||||||
|
data-semester="<?= esc((string)($semester ?? '')) ?>"
|
||||||
|
data-school-year="<?= esc((string)($schoolYear ?? '')) ?>">
|
||||||
|
Send Email
|
||||||
|
</button>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="text-muted small">Pending</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// Final (consolidated) decision from student_decisions table
|
||||||
|
$finalDecision = $row['consolidated_decision'] ?? null;
|
||||||
|
$finalBadge = $finalDecision !== null ? ($decisionBadge[$finalDecision] ?? 'secondary') : null;
|
||||||
|
?>
|
||||||
|
<td class="text-center align-middle">
|
||||||
|
<?php if ($finalDecision !== null && $finalDecision !== ''): ?>
|
||||||
|
<span class="badge bg-<?= esc($finalBadge) ?> px-2 py-1"><?= esc($finalDecision) ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="<?= site_url('grading/decisions?' . http_build_query(['semester' => $semester ?? '', 'school_year' => $schoolYear ?? ''])) ?>"
|
||||||
|
class="text-muted small">Generate</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<?php $certNumber = (string)($row['certificate_number'] ?? ''); ?>
|
||||||
|
<td class="text-center align-middle">
|
||||||
|
<?php if ($certNumber !== ''): ?>
|
||||||
|
<a href="<?= site_url('administrator/certificates/reprint/' . rawurlencode($certNumber)) ?>"
|
||||||
|
target="_blank"
|
||||||
|
class="font-monospace text-decoration-none fw-semibold"
|
||||||
|
title="Click to reprint certificate">
|
||||||
|
<?= esc($certNumber) ?>
|
||||||
|
</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="text-muted small">—</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 mb-4 d-flex gap-2 flex-wrap">
|
||||||
|
<?php foreach ($decisionBadge as $label => $color): ?>
|
||||||
|
<span class="badge bg-<?= esc($color) ?> px-3 py-2"><?= esc($label) ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<span class="text-muted small align-self-center ms-1">— decision colour key</span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Score details modal -->
|
||||||
|
<div class="modal fade" id="detailsModal" tabindex="-1" aria-labelledby="detailsModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="detailsModalLabel">Score Details</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="detailsModalBody">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email editor modal -->
|
||||||
|
<div class="modal fade" id="decisionEmailModal" tabindex="-1" aria-labelledby="decisionEmailModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="decisionEmailModalLabel">Send Decision Email</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="emailModalLoading" class="modal-body text-center py-5" style="display:none;">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
<div class="mt-2 text-muted">Loading email template…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="emailModalError" class="modal-body" style="display:none;">
|
||||||
|
<div class="alert alert-danger mb-0" id="emailModalErrorMsg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="decisionEmailForm" method="post"
|
||||||
|
action="<?= site_url('grading/below-60/decisions/email') ?>"
|
||||||
|
style="display:none;">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="student_id" id="emailStudentId">
|
||||||
|
<input type="hidden" name="semester" id="emailSemester">
|
||||||
|
<input type="hidden" name="school_year" id="emailSchoolYear">
|
||||||
|
<input type="hidden" name="html" id="emailHtmlHidden">
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold" for="emailSubjectInput">Subject</label>
|
||||||
|
<input type="text" class="form-control" id="emailSubjectInput" name="subject" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-1">
|
||||||
|
<label class="form-label fw-semibold">Body</label>
|
||||||
|
</div>
|
||||||
|
<textarea id="decisionEmailEditor" rows="18"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="emailSendBtn">Send Email</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('scripts') ?>
|
||||||
|
<style>
|
||||||
|
.grade-orange td { background: #fff3cd !important; color: #8a5d00; }
|
||||||
|
.grade-red td { background: #f8d7da !important; color: #842029; }
|
||||||
|
.decision-notes { font-size: 0.85rem; resize: vertical; min-height: 60px; }
|
||||||
|
.decisions-dt td { vertical-align: top; }
|
||||||
|
</style>
|
||||||
|
<script src="<?= base_url('assets/tinymce/tinymce.min.js') ?>"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// DataTable
|
||||||
|
if (window.$ && $.fn && $.fn.DataTable) {
|
||||||
|
$(function () {
|
||||||
|
const tbl = $('.decisions-dt');
|
||||||
|
if (!tbl.length) return;
|
||||||
|
try {
|
||||||
|
tbl.DataTable({
|
||||||
|
order: [[2, 'asc']],
|
||||||
|
pageLength: 100,
|
||||||
|
lengthMenu: [10, 25, 50, 100, 200],
|
||||||
|
columnDefs: [{ orderable: false, targets: [3] }]
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Details modal ────────────────────────────────────────────
|
||||||
|
const detailsModal = document.getElementById('detailsModal');
|
||||||
|
const detailsModalBody = document.getElementById('detailsModalBody');
|
||||||
|
const detailsModalTitle= document.getElementById('detailsModalLabel');
|
||||||
|
|
||||||
|
const SCORE_LABELS = {
|
||||||
|
homework_avg: 'Homework Avg',
|
||||||
|
project_avg: 'Project Avg',
|
||||||
|
participation_score: 'Participation',
|
||||||
|
test_avg: 'Test Avg',
|
||||||
|
ptap_score: 'PTAP Score',
|
||||||
|
attendance_score: 'Attendance',
|
||||||
|
midterm_exam_score: 'Midterm Score',
|
||||||
|
final_exam_score: 'Final Exam',
|
||||||
|
semester_score: 'Semester Score',
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMMENT_TYPE_LABELS = {
|
||||||
|
general: 'General',
|
||||||
|
attendance: 'Attendance',
|
||||||
|
attendance_comment: 'Attendance',
|
||||||
|
midterm: 'Midterm',
|
||||||
|
final: 'Final Exam',
|
||||||
|
ptap: 'PTAP',
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtScore(v) {
|
||||||
|
if (v === null || v === '' || v === undefined) return '—';
|
||||||
|
const n = parseFloat(v);
|
||||||
|
return isNaN(n) ? v : n.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDetailsHtml(semesters) {
|
||||||
|
if (!semesters || semesters.length === 0) {
|
||||||
|
return '<div class="alert alert-warning mb-0">No score data found.</div>';
|
||||||
|
}
|
||||||
|
let html = '';
|
||||||
|
semesters.forEach(function (sem) {
|
||||||
|
html += '<h6 class="fw-bold mt-3 mb-2">' + esc(sem.semester || '') + ' Semester';
|
||||||
|
if (sem.class_section_name) html += ' <span class="text-muted fw-normal fs-6">— ' + esc(sem.class_section_name) + '</span>';
|
||||||
|
html += '</h6>';
|
||||||
|
|
||||||
|
// Scores table
|
||||||
|
html += '<table class="table table-sm table-bordered mb-2">';
|
||||||
|
html += '<thead class="table-light"><tr><th>Item</th><th class="text-center">Score</th></tr></thead><tbody>';
|
||||||
|
let hasRow = false;
|
||||||
|
Object.entries(SCORE_LABELS).forEach(function ([key, label]) {
|
||||||
|
const v = sem[key];
|
||||||
|
if (v === null || v === '' || v === undefined) return;
|
||||||
|
const bold = key === 'semester_score' ? ' fw-bold' : '';
|
||||||
|
html += '<tr><td>' + label + '</td><td class="text-center' + bold + '">' + fmtScore(v) + '</td></tr>';
|
||||||
|
hasRow = true;
|
||||||
|
});
|
||||||
|
if (!hasRow) html += '<tr><td colspan="2" class="text-muted">No scores recorded.</td></tr>';
|
||||||
|
html += '</tbody></table>';
|
||||||
|
|
||||||
|
// Comments section
|
||||||
|
const comments = sem.comments || {};
|
||||||
|
const commentEntries = Object.entries(comments).filter(function ([, v]) { return v && v.trim(); });
|
||||||
|
|
||||||
|
// Deduplicate attendance + attendance_comment (show once)
|
||||||
|
const seen = {};
|
||||||
|
const deduped = [];
|
||||||
|
commentEntries.forEach(function ([type, text]) {
|
||||||
|
const label = COMMENT_TYPE_LABELS[type] || type;
|
||||||
|
const key = label + '|' + text.trim();
|
||||||
|
if (!seen[key]) { seen[key] = true; deduped.push([label, text]); }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deduped.length > 0) {
|
||||||
|
html += '<div class="mb-3">';
|
||||||
|
html += '<p class="fw-semibold mb-1" style="font-size:0.9rem;">Comments</p>';
|
||||||
|
deduped.forEach(function ([label, text]) {
|
||||||
|
html += '<div class="mb-2 p-2 bg-light rounded border-start border-3 border-secondary">';
|
||||||
|
html += '<span class="badge bg-secondary me-1" style="font-size:0.7rem;">' + esc(label) + '</span>';
|
||||||
|
html += '<span class="text-dark" style="font-size:0.9rem;">' + esc(text).replace(/\n/g, '<br>') + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailsModal) {
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
const btn = e.target.closest('.btn-show-details');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const studentId = btn.dataset.studentId;
|
||||||
|
const studentName= btn.dataset.studentName;
|
||||||
|
const schoolYear = btn.dataset.schoolYear;
|
||||||
|
|
||||||
|
detailsModalTitle.textContent = studentName + ' — Score Details';
|
||||||
|
detailsModalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"></div></div>';
|
||||||
|
bootstrap.Modal.getOrCreateInstance(detailsModal).show();
|
||||||
|
|
||||||
|
const url = '<?= site_url('grading/below-60/decisions/student-details') ?>'
|
||||||
|
+ '?student_id=' + encodeURIComponent(studentId)
|
||||||
|
+ '&school_year=' + encodeURIComponent(schoolYear);
|
||||||
|
|
||||||
|
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.error) {
|
||||||
|
detailsModalBody.innerHTML = '<div class="alert alert-danger">' + data.error + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
detailsModalBody.innerHTML = buildDetailsHtml(data.semesters);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
detailsModalBody.innerHTML = '<div class="alert alert-danger">Failed to load details.</div>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Email modal ───────────────────────────────────────────────
|
||||||
|
const modal = document.getElementById('decisionEmailModal');
|
||||||
|
const loadingPane = document.getElementById('emailModalLoading');
|
||||||
|
const errorPane = document.getElementById('emailModalError');
|
||||||
|
const errorMsg = document.getElementById('emailModalErrorMsg');
|
||||||
|
const form = document.getElementById('decisionEmailForm');
|
||||||
|
const subjectInput = document.getElementById('emailSubjectInput');
|
||||||
|
const htmlHidden = document.getElementById('emailHtmlHidden');
|
||||||
|
const studentIdIn = document.getElementById('emailStudentId');
|
||||||
|
const semesterIn = document.getElementById('emailSemester');
|
||||||
|
const schoolYearIn = document.getElementById('emailSchoolYear');
|
||||||
|
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
function showPane(which) {
|
||||||
|
loadingPane.style.display = which === 'loading' ? '' : 'none';
|
||||||
|
errorPane.style.display = which === 'error' ? '' : 'none';
|
||||||
|
form.style.display = which === 'form' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyEditor() {
|
||||||
|
if (window.tinymce) {
|
||||||
|
tinymce.remove('#decisionEmailEditor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initEditor(html) {
|
||||||
|
if (!window.tinymce) return;
|
||||||
|
tinymce.init({
|
||||||
|
selector: '#decisionEmailEditor',
|
||||||
|
base_url: '<?= base_url('assets/tinymce') ?>',
|
||||||
|
suffix: '.min',
|
||||||
|
license_key: 'gpl',
|
||||||
|
height: 460,
|
||||||
|
menubar: true,
|
||||||
|
branding: false,
|
||||||
|
promotion: false,
|
||||||
|
plugins: 'advlist autolink lists link image charmap preview anchor ' +
|
||||||
|
'searchreplace visualblocks code fullscreen insertdatetime media table ' +
|
||||||
|
'help wordcount emoticons codesample',
|
||||||
|
toolbar: 'undo redo | blocks fontfamily fontsize | bold italic underline strikethrough forecolor backcolor | ' +
|
||||||
|
'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | ' +
|
||||||
|
'link image media table | emoticons codesample | removeformat | preview code',
|
||||||
|
convert_urls: false,
|
||||||
|
paste_data_images: true,
|
||||||
|
content_style: 'body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; font-size: 14px; }',
|
||||||
|
setup(editor) {
|
||||||
|
editor.on('init', function () {
|
||||||
|
editor.setContent(html);
|
||||||
|
if (htmlHidden) htmlHidden.value = html;
|
||||||
|
});
|
||||||
|
editor.on('keyup change undo redo SetContent', function () {
|
||||||
|
if (htmlHidden) htmlHidden.value = editor.getContent({ format: 'html' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync hidden field before form submit
|
||||||
|
form.addEventListener('submit', function () {
|
||||||
|
if (window.tinymce) {
|
||||||
|
const ed = tinymce.get('decisionEmailEditor');
|
||||||
|
if (ed && htmlHidden) htmlHidden.value = ed.getContent({ format: 'html' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Destroy editor when modal closes
|
||||||
|
modal.addEventListener('hidden.bs.modal', function () {
|
||||||
|
destroyEditor();
|
||||||
|
showPane('loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open modal when "Send Email" is clicked
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
const btn = e.target.closest('.btn-send-email');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const studentId = btn.dataset.studentId;
|
||||||
|
const semester = btn.dataset.semester;
|
||||||
|
const schoolYear = btn.dataset.schoolYear;
|
||||||
|
|
||||||
|
studentIdIn.value = studentId;
|
||||||
|
semesterIn.value = semester;
|
||||||
|
schoolYearIn.value = schoolYear;
|
||||||
|
|
||||||
|
showPane('loading');
|
||||||
|
|
||||||
|
// Open the modal immediately (shows spinner)
|
||||||
|
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
|
||||||
|
bsModal.show();
|
||||||
|
|
||||||
|
// Fetch the pre-rendered email
|
||||||
|
const url = '<?= site_url('grading/below-60/decisions/email/preview') ?>'
|
||||||
|
+ '?student_id=' + encodeURIComponent(studentId)
|
||||||
|
+ '&semester=' + encodeURIComponent(semester)
|
||||||
|
+ '&school_year='+ encodeURIComponent(schoolYear);
|
||||||
|
|
||||||
|
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||||
|
.then(function (res) { return res.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.error) {
|
||||||
|
errorMsg.textContent = data.error;
|
||||||
|
showPane('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
subjectInput.value = data.subject || '';
|
||||||
|
// Reset textarea content before init
|
||||||
|
document.getElementById('decisionEmailEditor').value = data.html || '';
|
||||||
|
showPane('form');
|
||||||
|
initEditor(data.html || '');
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
errorMsg.textContent = 'Failed to load email template. Please try again.';
|
||||||
|
showPane('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user