Compare commits

..

18 Commits

Author SHA1 Message Date
root 35988e9ce1 fix logs issues 2026-04-18 11:44:02 -04:00
root 5fc4d8be30 fix double cote issue 2026-04-18 11:33:48 -04:00
root dcfd1f9542 fix users page 2026-04-18 11:29:18 -04:00
root b99f816261 update gitignore 2026-04-18 11:14:08 -04:00
root 931e30b6f1 update gitignore 2026-04-18 11:13:55 -04:00
root 189bb1a628 fix redirect page 2026-04-18 11:06:31 -04:00
root f04934039e add email sent for event 2026-04-18 10:51:22 -04:00
root c13dcb07d1 add event to calendar 2026-04-18 10:18:06 -04:00
root dd1d492c30 fix event charge and invoice update 2026-04-18 09:54:29 -04:00
root a448288878 update event charge as cash payment 2026-04-13 01:42:33 -04:00
root 821e9cdc81 update event charge at the invoice 2026-04-13 01:38:40 -04:00
root 4e2d12e524 fix events logic 2026-04-11 20:08:47 -04:00
root 7bc999643a exam draft fix 2026-04-11 15:33:12 -04:00
root 89e95b7851 add command to send teacher exam draft notification 2026-04-10 16:31:32 -04:00
root 2543df3d33 exam draft notification and submission 2026-04-09 01:50:24 -04:00
root 112d073235 fic exam draft submission 2026-04-08 19:45:47 -04:00
root 2611730ec6 update homework tracking 2026-04-07 18:42:39 -04:00
root 05dad52e10 fix score submission, and class progress 2026-04-04 17:44:40 -04:00
2522 changed files with 5263 additions and 4843 deletions
-184
View File
@@ -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
View File
@@ -23,17 +23,32 @@
/public/build/
/public/hot/
/public/uploads/**
!/public/uploads/**/index.html
!/public/uploads/.gitkeep
/uploads/**
!/uploads/**/index.html
!/uploads/.gitkeep
docker-compose.override.yml
docker-compose.local.yml
*.log
.swp
.swo
*~
.tmp
.DS_Store
Thumbs.db
.idea/
.vscode/
.codex/
/coverage/
/build/
/builds
/phpunit.xml.cache
/.phpunit.result.cache
/.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);
}
}
+1 -1
View File
@@ -82,7 +82,7 @@ class App extends BaseConfig
| DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!!
|
*/
public string $permittedURIChars = 'a-z 0-9~%.:_\-';
public string $permittedURIChars = 'a-z 0-9~%.:_\-,';
/**
* --------------------------------------------------------------------------
+4 -1
View File
@@ -363,6 +363,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->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']);
@@ -534,9 +535,11 @@ $routes->post('payment/event_charges', 'View\EventController::eventUpdate');
// Parent event participation
$routes->get('administrator/event-charges', 'View\EventController::eventShow');
$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1');
$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1');
$routes->get('administrator/get-students-with-charges', 'View\EventController::getStudentsWithCharges');
$routes->get('parent/events', 'View\ParentController::parentEventPage');
$routes->get('parent/events', 'View\ParentController::parentEventPage', ['filter' => 'auth:parent']);
// parent event participation page
$routes->post('parent/updateParticipation', 'View\ParentController::updateParticipation'); // handle parent participation updates
+74 -5
View File
@@ -56,13 +56,32 @@ class AuthController extends Controller
public function loginMask()
{
// Serve the login view directly here
return view('user/login'); // Adjust the view path as needed
$redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getGet('redirect_to') ?? ''));
if (session()->get('is_logged_in')) {
if ($redirectTo !== null) {
return redirect()->to($redirectTo);
}
$roles = session()->get('roles') ?? [];
if (empty($roles) && session()->get('role')) {
$roles = [session()->get('role')];
}
if (!empty($roles)) {
return $this->redirectToDashboard($roles);
}
}
return view('user/login', [
'redirect_to' => $redirectTo ?? '',
]);
}
public function login()
{
log_message('info', 'Processing login form submission.');
$redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? $this->request->getGet('redirect_to') ?? ''));
// Step 1: Get email, password, and IP from the request
$email = $this->request->getPost('email');
@@ -98,7 +117,7 @@ class AuthController extends Controller
$this->logLoginAttempt($user['id'], $user['email'], $ip, $this->request->getUserAgent());
// ✅ Step 7: Call loginUser() to set session and redirect
return $this->loginUser($user);
return $this->loginUser($user, $redirectTo);
} else {
// Step 8: Password mismatch — log failed attempt
$this->handleFailedLogin($user['id'], $user['email'], $ip);
@@ -491,7 +510,7 @@ class AuthController extends Controller
return true;
}
private function loginUser($user)
private function loginUser($user, ?string $redirectTo = null)
{
$userRoleModel = new UserRoleModel();
$roles = $userRoleModel->select('roles.name')
@@ -525,9 +544,17 @@ class AuthController extends Controller
if (count($roleNames) === 1) {
// One role → set and redirect directly
session()->set('role', $roleNames[0]);
if ($redirectTo !== null) {
return redirect()->to($redirectTo);
}
return $this->redirectToDashboard([$roleNames[0]]);
} else {
// Multiple roles → redirect to role selection view
if ($redirectTo !== null) {
session()->set('post_login_redirect', $redirectTo);
} else {
session()->remove('post_login_redirect');
}
return redirect()->to('/select-role');
}
@@ -592,12 +619,17 @@ private function redirectToDashboard(array $roles)
{
$selectedRole = $this->request->getPost('selected_role');
$availableRoles = session()->get('roles');
$redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? session()->get('post_login_redirect') ?? ''));
if (!$selectedRole || !in_array($selectedRole, $availableRoles)) {
return redirect()->to('/select-role')->with('error', 'Invalid role selected.');
}
session()->set('role', $selectedRole);
session()->remove('post_login_redirect');
if ($redirectTo !== null) {
return redirect()->to($redirectTo);
}
return $this->redirectToDashboard([$selectedRole]);
}
@@ -609,7 +641,44 @@ private function redirectToDashboard(array $roles)
return redirect()->to('/login')->with('error', 'No roles available.');
}
return view('auth/select_role', ['roles' => $roles]);
return view('auth/select_role', [
'roles' => $roles,
'redirect_to' => (string) (session()->get('post_login_redirect') ?? ''),
]);
}
private function sanitizeRedirectTarget(string $redirectTo): ?string
{
$redirectTo = trim($redirectTo);
if ($redirectTo === '') {
return null;
}
if (preg_match('#^https?://#i', $redirectTo)) {
$appHost = (string) parse_url(base_url('/'), PHP_URL_HOST);
$targetHost = (string) parse_url($redirectTo, PHP_URL_HOST);
$targetPath = (string) parse_url($redirectTo, PHP_URL_PATH);
$targetQuery = (string) parse_url($redirectTo, PHP_URL_QUERY);
if ($appHost === '' || $targetHost === '' || strcasecmp($appHost, $targetHost) !== 0) {
return null;
}
$redirectTo = $targetPath !== '' ? $targetPath : '/';
if ($targetQuery !== '') {
$redirectTo .= '?' . $targetQuery;
}
}
if (!str_starts_with($redirectTo, '/')) {
$redirectTo = '/' . ltrim($redirectTo, '/');
}
if (str_starts_with($redirectTo, '//')) {
return null;
}
return $redirectTo;
}
}
+90 -5
View File
@@ -28,6 +28,10 @@ class ClassProgressController extends BaseController
'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 ClassProgressAttachmentModel $attachmentModel;
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', [
'subjectSections' => self::SUBJECT_SECTIONS,
'subjectCurriculum' => $subjectCurriculum,
'classSectionId' => $row['class_section_id'],
'classSectionName' => $classSectionName,
'classId' => $classId,
'sundayOptions' => [$row['week_start']],
'sundayOptions' => $sundayOptions,
'defaultWeekStart' => $row['week_start'],
'existingWeekEnd' => $row['week_end'],
'existingReports' => $subjectReports,
@@ -458,6 +467,32 @@ class ClassProgressController extends BaseController
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
->select('class_progress_reports.*')
->whereIn('teacher_id', $allowedTeacherIds)
@@ -674,6 +709,37 @@ class ClassProgressController extends BaseController
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
{
$unitValues = array_map('trim', (array) $this->request->getPost("unit_$slug"));
@@ -686,9 +752,12 @@ class ClassProgressController extends BaseController
if ($unit === '' && $chapter === '') {
continue;
}
if (strcasecmp($unit, self::CUSTOM_UNIT_ROW_LABEL) === 0 && $chapter !== '') {
$unit = self::CUSTOM_UNIT_ROW_LABEL;
}
$segment = $unit;
if ($chapter !== '') {
$segment = $segment !== '' ? $segment . ' / ' . $chapter : $chapter;
$segment = $segment !== '' ? $unit . ' / ' . $chapter : $chapter;
}
if ($segment === '') {
continue;
@@ -699,17 +768,23 @@ class ClassProgressController extends BaseController
return null;
}
$summary = implode(' ; ', $parts);
return mb_strlen($summary) > 120 ? mb_substr($summary, 0, 120) : $summary;
}
protected function hasIslamicUnitSelection(): bool
{
$unitValues = array_map('trim', (array) $this->request->getPost('unit_islamic'));
foreach ($unitValues as $value) {
if ($value !== '') {
$chapterValues = array_map('trim', (array) $this->request->getPost('chapter_islamic'));
$count = max(count($unitValues), count($chapterValues));
for ($i = 0; $i < $count; $i++) {
$unit = $unitValues[$i] ?? '';
$chapter = $chapterValues[$i] ?? '';
if ($unit !== '' || $chapter !== '') {
return true;
}
}
return false;
}
@@ -728,9 +803,19 @@ class ClassProgressController extends BaseController
if ($segment === '') {
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);
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]);
} else {
$units[] = $segment;
@@ -763,7 +763,13 @@ class AdministratorController extends BaseController
[$progressExpectedWeeks, $progressSubmittedBySection] = $this->buildClassProgressStats($sectionIds);
$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 = [];
if (! empty($sectionIds)) {
$draftBuilder = $examDrafts
@@ -1066,6 +1072,8 @@ class AdministratorController extends BaseController
'notificationHistory' => $historyMap,
'summary' => $summary,
'lowProgressSectionIds' => $lowProgressSectionIds,
'examDraftDeadlineConfig' => $examDraftDeadlineConfig,
'examDraftDeadlineFormatted' => $examDraftDeadlineFormatted,
]);
}
@@ -1299,6 +1307,7 @@ class AdministratorController extends BaseController
$progressUrl = site_url('teacher/progress/history');
$examDraftUrl = site_url('teacher/exam-drafts');
$homeworkUrl = site_url('teacher/addHomework');
$examDraftDeadlineEmailHtml = $this->buildExamDraftDeadlineEmailHtml();
$sentCount = 0;
$failCount = 0;
@@ -1347,7 +1356,8 @@ class AdministratorController extends BaseController
} else {
$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 = '';
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
{
$semesterKey = strtolower(trim($semester));
+5 -2
View File
@@ -750,7 +750,9 @@ class DiscountController extends BaseController
}
} catch (\Throwable $e) {}
$newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
$discountableTotal = $tuitionSubtotal + $additionalSubtotal;
$nonDiscountableTotal = $eventSubtotal;
$newTotal = round($discountableTotal + $nonDiscountableTotal, 2);
// ---- Payments / Discounts / Refunds ----
$db = $this->db;
@@ -794,7 +796,8 @@ class DiscountController extends BaseController
->get()->getRowArray();
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
$newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid);
$appliedDiscount = min($totalDisc, $discountableTotal);
$newBalance = max(0.0, $newTotal - $appliedDiscount - $totalPaid - $totalRefundPaid);
$newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid');
$updateData = [
+157
View File
@@ -170,6 +170,163 @@ class EmailController extends Controller
}
}
/**
* Send one email with a single To recipient and many CC recipients.
*
* @param string $recipient
* @param string[] $ccRecipients
* @param string $subject
* @param string $htmlMessage
* @param string|null $profile
* @param string|null $replyToEmail
* @param string|null $replyToName
* @param array $attachments
*/
public function sendEmailWithCc(
string $recipient,
array $ccRecipients,
string $subject,
string $htmlMessage,
?string $profile = null,
?string $replyToEmail = null,
?string $replyToName = null,
array $attachments = []
): bool {
$ccRecipients = array_values(array_unique(array_filter(array_map(static function ($email) {
$email = trim((string) $email);
return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
}, $ccRecipients))));
if ($ccRecipients === []) {
return $this->sendEmail($recipient, $subject, $htmlMessage, $profile, $replyToEmail, $replyToName, $attachments);
}
$autoload = APPPATH . '../vendor/autoload.php';
if (is_file($autoload)) {
require_once $autoload;
}
$profile = $this->resolveProfile($profile);
$cfg = $this->getProfileConfig($profile);
if (empty($cfg['host']) || empty($cfg['user']) || $cfg['pass'] === '') {
log_message('error', "[mail:$profile] Missing SMTP config (host/user/pass). Check .env MAIL_{$this->envKeyFromProfile($profile)}_* or MAIL_DEFAULT_*.");
return false;
}
$debugEnabled = (bool) env('MAIL_DEBUG', false);
$timeout = (int) env('MAIL_TIMEOUT', 15);
$keepAlive = (bool) env('MAIL_KEEPALIVE', false);
$verifyPeer = filter_var(env('MAIL_VERIFY_PEER', 'true'), FILTER_VALIDATE_BOOLEAN);
$targetHost = $cfg['host'];
$targetPort = (int) $cfg['port'];
$resolved = @gethostbyname($targetHost);
if (!$resolved || $resolved === $targetHost) {
log_message('debug', "[mail:$profile] DNS resolve note: host=$targetHost, resolved=$resolved");
}
$sockOk = @fsockopen($targetHost, $targetPort, $errno, $errstr, 5);
if (!$sockOk) {
log_message('error', "[mail:$profile] Socket preflight failed to {$targetHost}:{$targetPort} (errno=$errno, err=$errstr). Likely firewall/port/encryption mismatch or wrong host.");
} else {
fclose($sockOk);
}
$mail = new PHPMailer(true);
try {
if ($debugEnabled) {
ob_start();
}
$mail->isSMTP();
$mail->Host = $cfg['host'];
$mail->Port = $cfg['port'];
$mail->SMTPAuth = true;
$mail->Username = $cfg['user'];
$mail->Password = $cfg['pass'];
$mail->CharSet = 'UTF-8';
$mail->Timeout = $timeout;
$mail->SMTPKeepAlive = $keepAlive;
$mail->SMTPAutoTLS = true;
if ($cfg['encryption'] === 'ssl') {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
} else {
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
}
$mail->SMTPOptions = [
'ssl' => [
'verify_peer' => $verifyPeer,
'verify_peer_name' => $verifyPeer,
'allow_self_signed' => !$verifyPeer,
],
];
$mail->SMTPDebug = $debugEnabled ? 3 : 0;
$mail->Debugoutput = 'error_log';
$mail->setFrom($cfg['fromEmail'], $cfg['fromName']);
if (!empty($cfg['returnPath'])) {
$mail->Sender = $cfg['returnPath'];
}
$mail->clearReplyTos();
$rtEmail = env('MAIL_DEFAULT_REPLY_TO');
$rtName = env('MAIL_DEFAULT_REPLY_TO_NAME');
if (!$rtEmail || !filter_var($rtEmail, FILTER_VALIDATE_EMAIL)) {
$rtEmail = $replyToEmail ?: ($cfg['replyTo'] ?: $cfg['fromEmail']);
}
if (!$rtName) {
$rtName = $replyToName ?: ($cfg['replyToName'] ?: $cfg['fromName']);
}
$rtName = $this->sanitizeReplyToName($rtName, $cfg['fromName']);
if ($rtEmail) {
$mail->addReplyTo($rtEmail, $rtName);
}
if (!empty($cfg['dkim']['domain']) && !empty($cfg['dkim']['private']) && !empty($cfg['dkim']['selector'])) {
$mail->DKIM_domain = $cfg['dkim']['domain'];
$mail->DKIM_private = $cfg['dkim']['private'];
$mail->DKIM_selector = $cfg['dkim']['selector'];
$mail->DKIM_identity = $cfg['fromEmail'];
}
$mail->addAddress($recipient);
foreach ($ccRecipients as $ccRecipient) {
$mail->addCC($ccRecipient);
}
foreach ($attachments as $att) {
if (!empty($att['path']) && is_file($att['path'])) {
$mail->addAttachment($att['path'], $att['name'] ?? '');
}
}
$mail->isHTML(true);
$mail->Subject = $subject;
$mail->Body = $htmlMessage;
$ok = $mail->send();
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
if ($ok) {
log_message('info', "[mail:$profile] Sent to {$recipient} with " . count($ccRecipients) . " CC recipient(s), subj='{$subject}' via {$cfg['host']}:{$cfg['port']}/{$cfg['encryption']}");
return true;
}
log_message('error', "[mail:$profile] Failed: {$mail->ErrorInfo}. Debug: {$dbg}");
return false;
} catch (Exception $e) {
$dbg = $debugEnabled ? (ob_get_clean() ?: '') : '';
log_message('error', "[mail:$profile] Exception: {$e->getMessage()} | PHPMailer: {$mail->ErrorInfo} | Debug: {$dbg}");
return false;
}
}
/** Resolve null/alias profile names to a canonical env prefix. */
private function resolveProfile(?string $profile): string
{
+761 -63
View File
@@ -6,8 +6,18 @@ use App\Models\EventModel;
use App\Models\EventChargesModel;
use App\Models\UserModel;
use App\Models\StudentModel;
use App\Models\StudentClassModel;
use App\Models\ConfigurationModel;
use App\Models\EnrollmentModel;
use App\Models\InvoiceModel;
use App\Models\InvoiceEventModel;
use App\Models\ClassSectionModel;
use App\Models\ParentModel;
use App\Models\PaymentModel;
use App\Models\CalendarModel;
use App\Services\EmailService;
use Config\Database;
#use ???
use App\Controllers\View\InvoiceController;
use CodeIgniter\RESTful\ResourceController;
@@ -19,10 +29,18 @@ class EventController extends ResourceController
protected $configModel;
protected $eventModel;
protected $invoiceController;
protected $invoiceModel;
protected $invoiceEventModel;
protected $studentClassModel;
protected $classSectionModel;
protected $paymentModel;
protected $parentModel;
protected $emailService;
protected $schoolYear;
protected $semester;
protected $categories;
protected $enrollmentModel;
private ?bool $eventChargesHasCreatedBy = null;
public function __construct()
{
@@ -32,7 +50,14 @@ class EventController extends ResourceController
$this->userModel = new UserModel(); // Add this
$this->eventModel = new EventModel();
$this->invoiceController = new InvoiceController();
$this->invoiceModel = new InvoiceModel();
$this->invoiceEventModel = new InvoiceEventModel();
$this->studentClassModel = new StudentClassModel();
$this->classSectionModel = new ClassSectionModel();
$this->paymentModel = new PaymentModel();
$this->enrollmentModel = new EnrollmentModel();
$this->parentModel = new ParentModel();
$this->emailService = new EmailService();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
@@ -44,6 +69,22 @@ class EventController extends ResourceController
];
}
private function eventChargesSupportsCreatedBy(): bool
{
if ($this->eventChargesHasCreatedBy !== null) {
return $this->eventChargesHasCreatedBy;
}
try {
$db = Database::connect();
$this->eventChargesHasCreatedBy = $db->fieldExists('created_by', 'event_charges');
} catch (\Throwable $e) {
$this->eventChargesHasCreatedBy = false;
}
return $this->eventChargesHasCreatedBy;
}
public function index()
{
$eventModel = new EventModel();
@@ -93,59 +134,52 @@ class EventController extends ResourceController
'created_by' => session()->get('user_id'),
]);
if ($eventId) {
$amount = (float) $this->request->getPost('amount');
$semester = (string) $this->request->getPost('semester');
$schoolYear = (string) $this->request->getPost('school_year');
$userId = (int) (session()->get('user_id') ?? 0);
if ($eventId && $this->request->getPost('add_to_calendar')) {
$calendarModel = new CalendarModel();
$enrollments = $this->enrollmentModel
->select('enrollments.student_id, students.parent_id')
->join('students', 'students.id = enrollments.student_id', 'left')
->where('enrollments.school_year', $schoolYear)
->whereIn('enrollments.enrollment_status', ['enrolled', 'payment pending'])
->findAll();
$title = trim((string)$this->request->getPost('event_name'));
$date = (string)$this->request->getPost('expiration_date');
$schoolYear = (string)$this->request->getPost('school_year');
$semester = (string)$this->request->getPost('semester');
$parentIds = [];
foreach ($enrollments as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
$parentId = (int) ($row['parent_id'] ?? 0);
if ($studentId <= 0 || $parentId <= 0) {
continue;
if ($title !== '' && $date !== '' && $schoolYear !== '') {
$data = [
'title' => $title,
'description' => (string)$this->request->getPost('description'),
'event_type' => 'Event',
'date' => $date,
'notify_parent' => $this->request->getPost('notify_parent') ? 1 : 0,
'notify_teacher' => $this->request->getPost('notify_teacher') ? 1 : 0,
'notify_admin' => $this->request->getPost('notify_admin') ? 1 : 0,
'no_school' => 0,
'school_year' => $schoolYear,
'semester' => $semester ?: $this->semester,
];
if (!$calendarModel->supportsEventType()) {
unset($data['event_type']);
}
$exists = $this->eventChargesModel
->where('event_id', $eventId)
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->first();
if ($exists) {
continue;
$dupQuery = $calendarModel
->where('school_year', $data['school_year'])
->where('date', $data['date'])
->where('title', $data['title']);
if ($calendarModel->supportsEventType() && isset($data['event_type'])) {
$dupQuery = $dupQuery->where('event_type', $data['event_type']);
}
$existing = $dupQuery->first();
if (!$existing) {
$calendarModel->save($data);
}
$this->eventChargesModel->insert([
'event_id' => $eventId,
'parent_id' => $parentId,
'student_id' => $studentId,
'participation' => 'yes',
'charged' => $amount,
'school_year' => $schoolYear,
'semester' => $semester,
'updated_by' => $userId ?: null,
]);
$parentIds[] = $parentId;
}
$parentIds = array_unique($parentIds);
foreach ($parentIds as $pid) {
$this->invoiceController->generateInvoice((string) $pid);
}
}
return redirect()->to('/administrator/events')->with('success', 'Event created successfully');
$redirect = redirect()->to('/administrator/events')->with('success', 'Event created successfully');
if ($eventId && $this->request->getPost('send_email_parent')) {
$emailStatus = $this->broadcastEventToParents((int) $eventId);
$redirect = $this->applyEventEmailFlash($redirect, $emailStatus);
}
return $redirect;
}
return view('administrator/events/create_event', [
@@ -186,7 +220,12 @@ class EventController extends ResourceController
]);
if ($updated) {
return redirect()->to('/administrator/events')->with('success', 'Event updated successfully');
$redirect = redirect()->to('/administrator/events')->with('success', 'Event updated successfully');
if ($this->request->getPost('send_email_parent')) {
$emailStatus = $this->broadcastEventToParents((int) $id);
$redirect = $this->applyEventEmailFlash($redirect, $emailStatus);
}
return $redirect;
} else {
log_message('debug', 'GET detected');
return redirect()->back()->with('error', 'Failed to update event');
@@ -199,6 +238,106 @@ class EventController extends ResourceController
]);
}
private function broadcastEventToParents(int $eventId): array
{
$event = $this->eventModel->find($eventId);
if (!$event) {
return ['ok' => false, 'count' => 0, 'message' => 'Event email was skipped because the event could not be loaded.'];
}
$emails = $this->collectParentBroadcastEmails((string) ($event['school_year'] ?? ''));
if ($emails === []) {
return ['ok' => false, 'count' => 0, 'message' => 'Event email was skipped because no parent email addresses were found.'];
}
$subject = 'School Event: ' . trim((string) ($event['event_name'] ?? 'Upcoming Event'));
$innerBody = view('emails/event_broadcast', [
'event' => $event,
'eventUrl' => base_url('parent/events'),
'flyerUrl' => !empty($event['flyer']) ? base_url('uploads/' . ltrim((string) $event['flyer'], '/')) : null,
]);
$body = view('emails/_wrap_layout', [
'title' => $subject,
'subject' => $subject,
'body_html' => $innerBody,
]);
$ok = $this->emailService->sendCc($emails, $subject, $body, 'general');
$batchCount = $this->emailService->getLastCcBatchCount();
return [
'ok' => $ok,
'count' => count($emails),
'batches' => $batchCount,
'message' => $ok
? 'Event email was sent in ' . $batchCount . ' CC batch(es) for ' . count($emails) . ' recipient(s).'
: 'The event was saved, but sending the parent CC email failed.',
];
}
private function collectParentBroadcastEmails(string $schoolYear = ''): array
{
$emails = [];
$db = Database::connect();
$parentRole = $db->table('roles')
->select('id')
->where('LOWER(name)', 'parent')
->get()
->getRow();
if ($parentRole) {
$firstParentQuery = $db->table('users')
->select('users.email')
->join('user_roles', 'user_roles.user_id = users.id')
->where('user_roles.role_id', (int) $parentRole->id)
->where('users.email IS NOT NULL', null, false)
->where('users.email !=', '');
if ($schoolYear !== '' && $db->fieldExists('school_year', 'users')) {
$firstParentQuery->where('users.school_year', $schoolYear);
}
foreach ($firstParentQuery->get()->getResultArray() as $row) {
$email = trim((string) ($row['email'] ?? ''));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$emails[strtolower($email)] = $email;
}
}
}
$secondParentQuery = $db->table('parents')
->select('secondparent_email')
->where('secondparent_email IS NOT NULL', null, false)
->where('secondparent_email !=', '');
if ($schoolYear !== '' && $db->fieldExists('school_year', 'parents')) {
$secondParentQuery->where('school_year', $schoolYear);
}
foreach ($secondParentQuery->get()->getResultArray() as $row) {
$email = trim((string) ($row['secondparent_email'] ?? ''));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$emails[strtolower($email)] = $email;
}
}
return array_values($emails);
}
private function applyEventEmailFlash($redirect, array $emailStatus)
{
if (!empty($emailStatus['message'])) {
return $redirect->with(
($emailStatus['ok'] ?? false) ? 'email_status' : 'email_error',
$emailStatus['message']
);
}
return $redirect;
}
public function delete($id = null)
{
@@ -218,6 +357,19 @@ class EventController extends ResourceController
$parentIds[] = $charge['parent_id'];
}
// Also remove any invoice_event rows tied to this event
$eventName = $event['event_name'] ?? null;
if ($eventName) {
$builder = $this->invoiceEventModel->where('event_name', $eventName);
if (!empty($event['school_year'])) {
$builder->where('school_year', $event['school_year']);
}
if (!empty($event['semester'])) {
$builder->where('semester', $event['semester']);
}
$builder->delete();
}
// Delete event
$this->eventModel->delete($id);
@@ -239,27 +391,102 @@ class EventController extends ResourceController
$semester = $this->request->getGet('semester') ?? $this->semester;
$parents = $this->userModel->getParents();
$events = $this->eventModel->getActiveEvents($this->schoolYear);
$events = $this->eventModel->getActiveEvents($schoolYear);
$filterEventId = (int) ($this->request->getGet('event_id') ?? 0);
$filterParentId = (int) ($this->request->getGet('parent_id') ?? 0);
$charges = $this->eventChargesModel
$chargesBuilder = $this->eventChargesModel
->select('event_charges.*,
users.firstname AS parent_firstname, users.lastname AS parent_lastname,
students.firstname AS student_firstname, students.lastname AS student_lastname,
events.event_name')
events.event_name, events.description AS event_description, events.amount AS event_amount')
->join('users', 'users.id = event_charges.parent_id', 'left')
->join('students', 'students.id = event_charges.student_id', 'left')
->join('events', 'events.id = event_charges.event_id', 'left')
->where('event_charges.school_year', $schoolYear)
->where('event_charges.semester', $semester)
->where('event_charges.semester', $semester);
if ($filterEventId > 0) {
$chargesBuilder->where('event_charges.event_id', $filterEventId);
}
$charges = $chargesBuilder
->orderBy('event_charges.created_at', 'DESC')
->findAll();
foreach ($charges as &$charge) {
if (empty($charge['class_section_id']) && !empty($charge['student_id'])) {
$sections = $this->studentClassModel->getClassSectionIdsByStudentId(
(int)$charge['student_id'],
$schoolYear
);
if (!empty($sections)) {
$charge['class_section_id'] = $sections[0];
}
}
}
unset($charge);
$parentBalances = [];
try {
$balanceRows = $this->invoiceModel
->select('parent_id, COALESCE(SUM(balance),0) AS total_balance')
->where('school_year', $schoolYear)
->where('semester', $semester)
->groupBy('parent_id')
->findAll();
foreach ($balanceRows as $row) {
$parentBalances[(int)($row['parent_id'] ?? 0)] = (float)($row['total_balance'] ?? 0.0);
}
} catch (\Throwable $e) {
log_message('error', 'Failed to load parent balances: ' . $e->getMessage());
}
$sectionIds = array_unique(array_filter(array_column($charges, 'class_section_id')));
$classSectionNames = [];
if (!empty($sectionIds)) {
$sections = $this->classSectionModel
->select('class_section_id, class_section_name')
->whereIn('class_section_id', $sectionIds)
->findAll();
foreach ($sections as $section) {
$classSectionNames[(int)($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? '';
}
}
$semesterOptions = (new EventChargesModel())
->select('semester')
->distinct()
->orderBy('semester', 'ASC')
->findColumn('semester');
$semesterOptions = array_values(array_filter(array_unique($semesterOptions ?? [])));
if (empty($semesterOptions)) {
$semesterOptions = [$this->semester];
}
$schoolYearOptions = (new EventChargesModel())
->select('school_year')
->distinct()
->orderBy('school_year', 'ASC')
->findColumn('school_year');
$schoolYearOptions = array_values(array_filter(array_unique($schoolYearOptions ?? [])));
if (empty($schoolYearOptions)) {
$schoolYearOptions = [$this->schoolYear];
}
return view('administrator/events/event_charges', [
'charges' => $charges,
'parents' => $parents,
'events' => $events,
'school_year' => $schoolYear,
'semester' => $semester,
'filterEventId' => $filterEventId,
'filterParentId' => $filterParentId,
'parentBalances' => $parentBalances,
'classSectionNames' => $classSectionNames,
'semesterOptions' => $semesterOptions,
'schoolYearOptions' => $schoolYearOptions,
]);
}
@@ -272,13 +499,24 @@ class EventController extends ResourceController
$parentId = $this->request->getPost('parent_id');
$eventId = $this->request->getPost('event_id');
$participations = $this->request->getPost('participation') ?? [];
$externalParticipants = $this->request->getPost('external_participants') ?? [];
if (!$parentId || !$eventId || empty($participations)) {
// Allow "external only" submissions (no enrolled students selected).
if (!$eventId || (empty($participations) && empty($externalParticipants))) {
return redirect()->back()->with('error', 'Missing required information.');
}
if (!empty($participations) && !$parentId) {
return redirect()->back()->with('error', 'Select a parent when adding enrolled students.');
}
$userId = session()->get('user_id');
$event = $this->eventModel->getEvent($eventId, $schoolYear);
if (!$event) {
return redirect()->back()->with('error', 'Selected event not found for the chosen school year.');
}
$parentsForInvoice = [];
$supportsCreatedBy = $this->eventChargesSupportsCreatedBy();
foreach ($participations as $studentId => $value) {
$existing = $this->eventChargesModel->where([
@@ -296,33 +534,392 @@ class EventController extends ResourceController
continue;
}
// value is 'yes'
$sectionIds = $this->studentClassModel->getClassSectionIdsByStudentId($studentId, $schoolYear);
$classSectionId = !empty($sectionIds) ? $sectionIds[0] : null;
if ($existing) {
$this->eventChargesModel->update($existing['id'], [
'participation' => 'yes',
'charged' => $event['amount'],
'updated_by' => $userId
]);
$updateData = [
'participation' => 'yes',
'updated_by' => $userId,
'class_section_id'=> $classSectionId,
];
if (isset($event['amount']) && ((float)$event['amount'] !== (float)($existing['charged'] ?? 0))) {
$updateData['charged'] = $event['amount'];
}
$this->eventChargesModel->update($existing['id'], $updateData);
continue;
} else {
$this->eventChargesModel->insert([
$insertData = [
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId,
'participation' => 'yes',
'charged' => $event['amount'],
'event_paid' => 0,
'class_section_id' => $classSectionId,
'school_year' => $schoolYear,
'semester' => $semester,
'created_by' => $userId,
'updated_by' => $userId
]);
];
if ($supportsCreatedBy) {
$insertData['created_by'] = $userId;
}
$this->eventChargesModel->insert($insertData);
}
}
$this->invoiceController->generateInvoice($parentId);
foreach ($externalParticipants as $pieces) {
$firstname = $this->normalizeExternalName($pieces['firstname'] ?? '');
$lastname = $this->normalizeExternalName($pieces['lastname'] ?? '');
$note = trim($pieces['note'] ?? '');
$parentFirst = $this->normalizeExternalName($pieces['parent_firstname'] ?? '');
$parentLast = $this->normalizeExternalName($pieces['parent_lastname'] ?? '');
$parentPhone = trim($pieces['parent_phone'] ?? '');
$parentEmail = trim((string)($pieces['parent_email'] ?? ''));
$markPaid = (string)($pieces['paid'] ?? '0') === '1';
if (!$firstname && !$lastname) {
continue;
}
$matchedParentId = $this->findExistingParentIdByExternalContact(
$parentFirst,
$parentLast,
$parentEmail,
$parentPhone
);
// Match external participants by kid name + event context.
// Parent contact info can change (or be fixed later) and should be updated, not used for matching.
$matchConditions = [
'event_id' => $eventId,
'external_firstname' => $firstname,
'external_lastname' => $lastname,
'school_year' => $schoolYear,
'semester' => $semester,
];
if ($matchedParentId) {
$matchConditions['parent_id'] = $matchedParentId;
} else {
// External-only parent: match by external parent contact to avoid duplicates.
$matchConditions['parent_id'] = null;
$matchConditions['external_parent_firstname'] = $parentFirst ?: null;
$matchConditions['external_parent_lastname'] = $parentLast ?: null;
$matchConditions['external_parent_phone'] = $parentPhone ?: null;
$matchConditions['external_parent_email'] = $parentEmail ?: null;
}
$existingExternal = $this->eventChargesModel->where($matchConditions)->first();
if ($existingExternal) {
$this->eventChargesModel->update($existingExternal['id'], [
'participation' => 'yes',
'charged' => $event['amount'],
'updated_by' => $userId,
// Preserve existing payment status; only update contact fields when provided.
'external_note' => $note !== '' ? $note : ($existingExternal['external_note'] ?? null),
'external_parent_firstname' => $parentFirst !== '' ? $parentFirst : ($existingExternal['external_parent_firstname'] ?? null),
'external_parent_lastname' => $parentLast !== '' ? $parentLast : ($existingExternal['external_parent_lastname'] ?? null),
'external_parent_phone' => $parentPhone !== '' ? $parentPhone : ($existingExternal['external_parent_phone'] ?? null),
'external_parent_email' => $parentEmail !== '' ? $parentEmail : ($existingExternal['external_parent_email'] ?? null),
]);
continue;
}
$insertData = [
'parent_id' => $matchedParentId ?: null,
'student_id' => null,
'event_id' => $eventId,
'participation' => 'yes',
'charged' => $event['amount'],
'event_paid' => $markPaid ? 1 : 0,
'external_firstname' => $firstname,
'external_lastname' => $lastname,
'external_note' => $note,
'external_parent_firstname' => $parentFirst,
'external_parent_lastname' => $parentLast,
'external_parent_phone' => $parentPhone,
'external_parent_email' => $parentEmail,
'school_year' => $schoolYear,
'semester' => $semester,
'updated_by' => $userId,
];
if ($supportsCreatedBy) {
$insertData['created_by'] = $userId;
}
$this->eventChargesModel->insert($insertData);
$newChargeId = (int)$this->eventChargesModel->getInsertID();
if ($markPaid && $newChargeId > 0) {
$this->applyEventPaymentStatus($newChargeId, true);
}
if ($matchedParentId && $this->parentHasEnrollment($matchedParentId, (string)$schoolYear)) {
$parentsForInvoice[$matchedParentId] = true;
}
}
if ($parentId && $this->parentHasEnrollment((int)$parentId, (string)$schoolYear)) {
$parentsForInvoice[(int)$parentId] = true;
}
foreach (array_keys($parentsForInvoice) as $pid) {
$this->invoiceController->generateInvoice((string)$pid, (string)$schoolYear, (string)$semester, false);
$this->fixInvoiceStatusAfterCharge((int)$pid, (string)$schoolYear);
}
return redirect()->back()->with('success', 'Event charges updated successfully.');
}
public function removeCharge($chargeId = null)
{
if (!$chargeId) {
return redirect()->back()->with('error', 'Invalid charge.');
}
$charge = $this->eventChargesModel->find($chargeId);
if (!$charge) {
return redirect()->back()->with('error', 'Charge not found.');
}
$paymentId = (int)($charge['event_payment_id'] ?? 0);
if ($paymentId > 0) {
$this->paymentModel->delete($paymentId);
}
$this->eventChargesModel->delete($chargeId);
if (!empty($charge['parent_id']) && $this->parentHasEnrollment((int)$charge['parent_id'], (string)($charge['school_year'] ?? ''))) {
$this->invoiceController->generateInvoice((string)$charge['parent_id'], (string)($charge['school_year'] ?? $this->schoolYear), (string)($charge['semester'] ?? $this->semester), false);
}
return redirect()->back()->with('success', 'Participation removed, invoices updated.');
}
public function toggleEventPayment($chargeId = null)
{
if (!$chargeId) {
return redirect()->back()->with('error', 'Invalid charge.');
}
$isPaid = $this->request->getPost('paid') === '1';
$meta = $this->applyEventPaymentStatus((int)$chargeId, $isPaid);
if (!$meta) {
return redirect()->back()->with('error', 'Charge not found.');
}
if (!empty($meta['invoice_id'])) {
$this->invoiceController->generateInvoice((string)$meta['parent_id'], (string)$meta['school_year'], (string)($meta['semester'] ?? $this->semester), false);
$this->fixInvoiceStatusAfterCharge((int)$meta['parent_id'], (string)$meta['school_year']);
}
return redirect()->back()->with('success', 'Event payment status updated.');
}
private function parentHasEnrollment(int $parentId, string $schoolYear): bool
{
if ($parentId <= 0 || $schoolYear === '') {
return false;
}
try {
return $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->countAllResults() > 0;
} catch (\Throwable $e) {
return false;
}
}
private function findExistingParentIdByExternalContact(string $first, string $last, string $email, string $phone): ?int
{
$first = trim($first);
$last = trim($last);
$email = trim($email);
$phoneDigits = preg_replace('/\D+/', '', (string)$phone);
if ($first === '' || $last === '' || $email === '' || $phoneDigits === '') {
return null;
}
try {
$db = Database::connect();
$expr = "REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') =";
$row = $this->userModel
->select('id')
->where('LOWER(firstname)', strtolower($first))
->where('LOWER(lastname)', strtolower($last))
->where('LOWER(email)', strtolower($email))
->where($expr, $db->escapeString($phoneDigits), false)
->first();
$id = (int)($row['id'] ?? 0);
return $id > 0 ? $id : null;
} catch (\Throwable $e) {
log_message('error', 'Failed to match external parent to users: ' . $e->getMessage());
return null;
}
}
private function syncInvoicePaymentSummary(int $invoiceId): void
{
if ($invoiceId <= 0) {
return;
}
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) {
return;
}
$db = \Config\Database::connect();
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$paidSum = 0.0;
try {
$qb = $db->table('payments')
->select('COALESCE(SUM(paid_amount),0) AS tot')
->where('invoice_id', $invoiceId)
->where('paid_amount >', 0);
if ($db->fieldExists('status', 'payments')) {
$qb->groupStart()
->whereNotIn('status', $exclude)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($db->fieldExists('is_void', 'payments')) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$row = $qb->get()->getRowArray();
$paidSum = (float)($row['tot'] ?? 0.0);
} catch (\Throwable $e) {
log_message('error', 'Failed to sum payments for invoice ' . $invoiceId . ': ' . $e->getMessage());
}
$discountSum = 0.0;
try {
$row = $db->table('discount_usages')
->select('COALESCE(SUM(discount_amount),0) AS tot')
->where('invoice_id', $invoiceId)
->get()
->getRowArray();
$discountSum = (float)($row['tot'] ?? 0.0);
} catch (\Throwable $e) {
log_message('error', 'Failed to sum discounts for invoice ' . $invoiceId . ': ' . $e->getMessage());
}
$refundSum = 0.0;
try {
$row = $db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial', 'Paid'])
->get()
->getRowArray();
$refundSum = (float)($row['tot'] ?? 0.0);
} catch (\Throwable $e) {
log_message('error', 'Failed to sum refunds for invoice ' . $invoiceId . ': ' . $e->getMessage());
}
$total = (float)($invoice['total_amount'] ?? 0.0);
$newBalance = round($total - $discountSum - $refundSum - $paidSum, 2);
$newStatus = ($newBalance <= 0.00001)
? 'Paid'
: (($paidSum > 0) ? 'Partially Paid' : 'Unpaid');
$this->invoiceModel->update($invoiceId, [
'paid_amount' => $paidSum,
'balance' => $newBalance,
'status' => $newStatus,
'updated_at' => utc_now(),
]);
}
private function applyEventPaymentStatus(int $chargeId, bool $isPaid): ?array
{
if ($chargeId <= 0) {
return null;
}
$charge = $this->eventChargesModel->find($chargeId);
if (!$charge) {
return null;
}
$event = $this->eventModel->find($charge['event_id']);
$eventAmount = max(0.0, (float)($event['amount'] ?? 0));
$paymentId = (int)($charge['event_payment_id'] ?? 0);
$parentId = (int)($charge['parent_id'] ?? 0);
$schoolYear = (string)($charge['school_year'] ?? $this->schoolYear);
$semester = (string)($charge['semester'] ?? $this->semester);
if ($parentId > 0 && $eventAmount > 0) {
$hasEnrollment = $this->parentHasEnrollment($parentId, $schoolYear);
$invoiceQuery = $this->invoiceModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderBy('id', 'DESC');
$invoice = $invoiceQuery->first();
if (!$invoice && $hasEnrollment) {
$this->invoiceController->generateInvoice((string)$parentId, $schoolYear, $semester, false);
$invoice = $invoiceQuery->first();
}
if ($invoice) {
$invoiceId = (int)($invoice['id'] ?? 0);
if ($invoiceId > 0) {
$this->syncInvoicePaymentSummary($invoiceId);
$invoice = $this->invoiceModel->find($invoiceId) ?? $invoice;
}
if ($isPaid && !$paymentId) {
$paymentSchoolYear = (string)($invoice['school_year'] ?? $schoolYear);
$paymentSemester = (string)($invoice['semester'] ?? $semester);
$paymentId = (int)($this->createEventPayment(
$parentId,
(int)$invoice['id'],
$eventAmount,
$paymentSchoolYear,
$paymentSemester,
(float)($invoice['total_amount'] ?? 0.0),
(float)($invoice['paid_amount'] ?? 0.0),
(float)($invoice['balance'] ?? 0.0)
) ?? 0);
} elseif (!$isPaid && $paymentId > 0) {
$this->paymentModel->delete($paymentId);
$paymentId = 0;
}
if (!empty($invoiceId)) {
$this->syncInvoicePaymentSummary($invoiceId);
}
} elseif (!$hasEnrollment) {
// External-only parent: do not create an invoice/payment row; just persist the paid flag on the charge.
$paymentId = 0;
}
}
$this->eventChargesModel->update($chargeId, [
'event_paid' => $isPaid ? 1 : 0,
'charged' => $eventAmount,
'event_payment_id' => $paymentId > 0 ? $paymentId : null,
]);
return [
'parent_id' => $parentId,
'school_year' => $schoolYear,
'semester' => $semester,
'invoice_id' => isset($invoiceId) && $invoiceId > 0 ? $invoiceId : null,
];
}
@@ -336,10 +933,18 @@ class EventController extends ResourceController
$students = $this->studentModel->where('parent_id', $parentId)->findAll();
// Get student_ids that already have charges
$chargedStudentIds = $this->eventChargesModel
$eventId = $this->request->getGet('event_id');
$chargesBuilder = $this->eventChargesModel
->where('parent_id', $parentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('school_year', $schoolYear);
if (!empty($eventId)) {
$chargesBuilder->where('event_id', $eventId);
}
$chargedStudentIds = $chargesBuilder
->groupBy('student_id')
->select('student_id')
->findColumn('student_id');
@@ -356,5 +961,98 @@ class EventController extends ResourceController
return $this->response->setJSON($data);
}
private function createEventPayment(
int $parentId,
int $invoiceId,
float $amount,
string $schoolYear,
?string $semester,
float $invoiceTotal = 0.0,
float $invoicePaid = 0.0,
float $invoiceBalance = 0.0
): ?int
{
if ($amount <= 0 || $invoiceId <= 0 || $parentId <= 0) {
return null;
}
$monthSemester = $semester ?: $this->semester;
$newPaid = (float)$invoicePaid + $amount;
$newBalance = (float)$invoiceBalance - $amount;
$paymentStatus = ($newBalance <= 0.00001)
? 'Paid'
: (($newPaid > 0) ? 'Partially Paid' : 'Unpaid');
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$priorCount = $this->paymentModel
->where('invoice_id', $invoiceId)
->where('paid_amount >', 0)
->whereNotIn('status', $exclude)
->countAllResults();
$installmentSeq = $priorCount + 1;
$data = [
'parent_id' => $parentId,
'invoice_id' => $invoiceId,
'total_amount' => $invoiceTotal > 0 ? $invoiceTotal : $amount,
'paid_amount' => $amount,
'balance' => $newBalance,
'number_of_installments' => $installmentSeq,
'payment_method' => 'cash',
'payment_date' => utc_now(),
'school_year' => $schoolYear,
'semester' => $monthSemester,
'status' => $paymentStatus,
'transaction_id' => $this->paymentModel->generateNewTransactionId(),
'updated_by' => session()->get('user_id'),
];
if (!$this->paymentModel->insert($data)) {
log_message('error', 'Failed to insert event payment: ' . json_encode($this->paymentModel->errors()));
return null;
}
return (int)$this->paymentModel->getInsertID();
}
private function normalizeExternalName(?string $value): string
{
$raw = trim((string)$value);
if ($raw === '') {
return '';
}
$parts = preg_split('/\s+/', $raw);
$formatted = array_map(function ($part) {
$part = trim($part);
if ($part === '') {
return '';
}
return mb_strtoupper(mb_substr($part, 0, 1)) . mb_strtolower(mb_substr($part, 1));
}, $parts);
return implode(' ', array_filter($formatted, fn($p) => $p !== ''));
}
private function fixInvoiceStatusAfterCharge($parentId, $schoolYear)
{
if (!$parentId || !$schoolYear) {
return;
}
$invoices = $this->invoiceModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->findAll();
foreach ($invoices as $invoice) {
$status = strtolower(trim($invoice['status'] ?? ''));
$balance = (float)($invoice['balance'] ?? 0.0);
if ($balance <= 0.00001 && $status !== 'paid') {
$this->invoiceModel->update($invoice['id'], ['status' => 'Paid']);
} elseif ($status === 'paid' && $balance > 0) {
$this->invoiceModel->update($invoice['id'], ['status' => 'Unpaid']);
}
}
}
}
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -271,8 +271,8 @@ class FilesController extends Controller
private function buildDraftDownloadName(string $filename, string $subdir): string
{
$column = $subdir === 'finals' ? 'final_file' : 'teacher_file';
$db = Database::connect();
$column = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
$row = $db->table('exam_drafts ed')
->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')
@@ -301,4 +301,20 @@ class FilesController extends Controller
$value = trim($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';
}
}
+99 -21
View File
@@ -68,17 +68,45 @@ public function financialReport()
}
// === Invoices ===
$invoices = $invoiceModel
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
->join('users', 'users.id = invoices.parent_id')
->findAll();
// Business rule: one active invoice per parent per school year.
// If legacy data has multiple invoices, show only the latest one to avoid reporting older invoices as "Unpaid"
// after new charges/payments update the current invoice.
$db = \Config\Database::connect();
if (!empty($schoolYear)) {
$latestSub = $db->table('invoices')
->select('parent_id, MAX(id) AS max_id')
->where('school_year', $schoolYear)
->groupBy('parent_id')
->getCompiledSelect();
$invBuilder = $db->table('invoices')
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name", false)
->join("($latestSub) latest", 'latest.max_id = invoices.id', 'inner', false)
->join('users', 'users.id = invoices.parent_id', 'left');
if ($hasFrom) {
$invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom);
}
if ($hasTo) {
$invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo);
}
$invoices = $invBuilder->get()->getResultArray();
} else {
$invoices = $invoiceModel
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
->join('users', 'users.id = invoices.parent_id')
->findAll();
}
$invoiceIds = array_values(array_unique(array_filter(array_map(static function ($inv) {
$id = (int)($inv['id'] ?? 0);
return $id > 0 ? $id : null;
}, $invoices))));
// Helper to build a fresh, filtered PaymentModel each time (so filters don't get lost between queries)
$buildPaymentModel = function () use ($schoolYear, $dateFrom, $dateTo) {
$pm = new PaymentModel();
if ($schoolYear) {
$pm->where('school_year', $schoolYear);
}
if (!empty($dateFrom)) {
$pm->where('DATE(payment_date) >=', $dateFrom);
}
@@ -114,7 +142,13 @@ public function financialReport()
};
// === Payments aggregated by invoice_id (for the "Paid" column) ===
$payments = $applyPaymentFilters($buildPaymentModel())
$paymentsQuery = $applyPaymentFilters($buildPaymentModel());
if (!empty($invoiceIds)) {
$paymentsQuery->whereIn('invoice_id', $invoiceIds);
} else {
$paymentsQuery->where('invoice_id', -1);
}
$payments = $paymentsQuery
->select('invoice_id, SUM(paid_amount) AS paid_amount')
->where('invoice_id IS NOT NULL')
->groupBy('invoice_id')
@@ -138,6 +172,7 @@ public function financialReport()
SUM(paid_amount) AS amount
")
->where('invoice_id IS NOT NULL')
->whereIn('invoice_id', $invoiceIds ?: [-1])
->groupBy('invoice_id, method')
->findAll();
@@ -161,6 +196,7 @@ public function financialReport()
SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN paid_amount ELSE 0 END) AS total_credit
")
->where('invoice_id IS NOT NULL')
->whereIn('invoice_id', $invoiceIds ?: [-1])
->first() ?? [];
$paymentTotals = [
@@ -216,25 +252,28 @@ public function financialReport()
$schoolYears[] = (string)$schoolYear;
}
$eventFeesTotal = $this->getEventFeesTotal($schoolYear, $dateFrom, $dateTo);
// JSON API support
if ($this->wantsJson() || strtolower((string)($this->request->getGet('format') ?? '')) === 'json') {
return $this->response->setJSON([
'ok' => true,
'selectedYear' => $schoolYear,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
return $this->response->setJSON([
'ok' => true,
'selectedYear' => $schoolYear,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'schoolYears' => $schoolYears,
'invoices' => $invoices,
'payments' => $payments,
'paymentBreakdown' => $paymentBreakdown,
'paymentTotals' => $paymentTotals,
'refunds' => $refunds,
'expenses' => $expenses,
'reimbursements' => $reimbursements,
'discounts' => $discounts,
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
'refunds' => $refunds,
'expenses' => $expenses,
'reimbursements' => $reimbursements,
'discounts' => $discounts,
'eventFeesTotal' => $eventFeesTotal,
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
return view('payment/financial_report', [
@@ -246,6 +285,7 @@ public function financialReport()
'expenses' => $expenses,
'reimbursements' => $reimbursements,
'discounts' => $discounts,
'eventFeesTotal' => $eventFeesTotal,
'selectedYear' => $schoolYear,
'schoolYears' => $schoolYears,
'dateFrom' => $dateFrom,
@@ -1058,6 +1098,7 @@ public function financialReport()
$amountCollected = $totalPaid;
$netAmount = ($totalCharges - $totalDiscounts - $totalRefunds);
$totalEventFees = $this->getEventFeesTotal($schoolYear, $invoiceDateFrom, $invoiceDateTo);
return [
'schoolYear' => $schoolYear,
'dateFrom' => $dateFrom,
@@ -1073,9 +1114,28 @@ public function financialReport()
'amountCollected' => $amountCollected,
'totalUnpaid' => $totalUnpaid,
'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.
@@ -1226,6 +1286,23 @@ public function financialReport()
$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
// Also compute remaining installments and suggested monthly amount
$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);
$name = trim((string)($r['firstname'] ?? '') . ' ' . (string)($r['lastname'] ?? ''));
return [
@@ -1322,6 +1399,7 @@ public function financialReport()
'payment_count' => isset($paymentCounts[$pid]) ? (int)$paymentCounts[$pid] : 0,
'has_installment'=> isset($hasPayments[$pid]) ? 1 : 0,
'next_installment' => $nextInstallmentYmd,
'event_fees' => (float)($eventFeesPerParent[$pid] ?? 0),
];
}, $rows);
@@ -86,6 +86,7 @@ class HomeworkTrackingController extends BaseController
$hasHomework = [];
$hwEnteredAt = [];
$homeworkSubmissionCounts = [];
foreach ($rows as $r) {
$csid = (int)($r['class_section_id'] ?? 0);
$hi = (int)($r['homework_index'] ?? 0);
@@ -94,6 +95,7 @@ class HomeworkTrackingController extends BaseController
$hasHomework[$csid][$hi] = true;
$dateStr = substr((string)($r['first_created'] ?? ''), 0, 10);
$hwEnteredAt[$csid][$hi] = $dateStr ?: null;
$homeworkSubmissionCounts[$csid] = ($homeworkSubmissionCounts[$csid] ?? 0) + 1;
}
}
@@ -217,6 +219,7 @@ class HomeworkTrackingController extends BaseController
'teachers' => $teachersPage,
'hasHomework' => $hasHomework,
'hwEnteredAt' => $hwEnteredAt,
'homeworkSubmissionCounts' => $homeworkSubmissionCounts,
'hasHomeworkByDate' => $hasHomeworkByDate,
'hwEnteredAtByDate' => $hwEnteredAtByDate,
'page' => $page,
+125 -29
View File
@@ -381,18 +381,24 @@ class InvoiceController extends ResourceController
return false;
}
public function generateInvoice(string $parentId = null)
public function generateInvoice(
string $parentId = null,
?string $schoolYearOverride = null,
?string $semesterOverride = null,
bool $recalculateDiscounts = true
)
{
$isAjax = $this->request->isAJAX() || str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json');
if ($parentId == null) {
$parentId = (int)$this->request->getPost('parent_id');
}
$schoolYear = (string) $this->schoolYear;
$schoolYear = (string) ($schoolYearOverride ?: $this->schoolYear);
$semester = (string) ($semesterOverride ?: $this->semester);
// Fetch enrolled + withdrawn students
$enrollments = $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $this->schoolYear)
->where('school_year', $schoolYear)
->findAll();
if (empty($enrollments)) {
@@ -444,21 +450,23 @@ class InvoiceController extends ResourceController
$tuitionFee = $fees['tuition_fee'];
// ✅ Fetch event charges
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $this->schoolYear);
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
$eventchargeTotal = array_sum(array_column($eventsList, 'charged'));
$totalDiscount = $this->recalculateAndUpdateDiscount(
$parentId,
$this->schoolYear,
$tuitionFee,
$enrollments
);
$totalDiscount = 0.0;
if ($recalculateDiscounts) {
$totalDiscount = $this->recalculateAndUpdateDiscount(
$parentId,
$schoolYear,
$tuitionFee,
$enrollments
);
}
$semester = $this->semester;
// ✅ Refunds PAID to the parent for this year (Partial/Paid)
$refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $this->schoolYear);
$refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $schoolYear);
$totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $this->schoolYear);
$totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $schoolYear);
$discountedTuition = max(0, $tuitionFee);
$totalAmount = $discountedTuition + $eventchargeTotal;
@@ -469,17 +477,22 @@ class InvoiceController extends ResourceController
- $refundPaid // approved refunds paid to parent
- $totalPaid; // payments received
// Your invoice fetch
$existingInvoices = $this->invoiceModel->getInvoicesByParentId($parentId, $this->schoolYear);
// Business rule: single invoice per parent per school year.
// If legacy duplicates exist, prefer the invoice that already has a discount applied,
// otherwise use the latest invoice for the parent/year.
$invoice = $this->selectActiveInvoiceForParentYear((int)$parentId, $schoolYear);
$updated = false;
$updatedIds = [];
if (!empty($existingInvoices)) {
foreach ($existingInvoices as $invoice) {
if (!isset($invoice['id'])) {
continue;
}
if (!empty($invoice) && isset($invoice['id'])) {
$paymentExclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$paymentsHasStatus = false;
$paymentsHasVoid = false;
try {
$paymentsHasStatus = $this->db->fieldExists('status', 'payments');
$paymentsHasVoid = $this->db->fieldExists('is_void', 'payments');
} catch (\Throwable $e) {
}
// Preserve applied additional charges and recalc this invoice only
$extrasSum = 0.0;
@@ -487,7 +500,7 @@ class InvoiceController extends ResourceController
$rows = $this->db->table('additional_charges')
->select('charge_type, amount')
->where('invoice_id', (int)$invoice['id'])
->where('school_year', $this->schoolYear)
->where('school_year', $schoolYear)
->where('status', 'applied')
->get()->getResultArray();
foreach ($rows as $r) {
@@ -520,7 +533,7 @@ class InvoiceController extends ResourceController
$r = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
->where('invoice_id', (int)$invoice['id'])
->where('school_year', $this->schoolYear)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$invRefunds = (float)($r['tot'] ?? 0.0);
@@ -528,8 +541,33 @@ class InvoiceController extends ResourceController
log_message('error', 'refund sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
}
// Payments recorded on this invoice (denormalized field kept in sync by PaymentController)
$paidOnInv = (float)($invoice['paid_amount'] ?? 0.0);
// Payments recorded on this invoice (sum payments to avoid stale invoice.paid_amount)
$paidOnInv = 0.0;
try {
$qb = $this->db->table('payments')
->select('COALESCE(SUM(paid_amount),0) AS tot')
->where('invoice_id', (int)$invoice['id'])
->where('paid_amount >', 0);
if ($paymentsHasStatus) {
$qb->groupStart()
->whereNotIn('status', $paymentExclude)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($paymentsHasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$row = $qb->get()->getRowArray();
$paidOnInv = (float)($row['tot'] ?? 0.0);
} catch (\Throwable $e) {
log_message('error', 'payment sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
$paidOnInv = (float)($invoice['paid_amount'] ?? 0.0);
}
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
$newStatus = ($newBalance <= 0.00001)
@@ -547,7 +585,6 @@ class InvoiceController extends ResourceController
$updatedIds[] = (int)$invoice['id'];
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
$updated = true;
}
} else {
// Generate invoice number
$schoolId = $this->userModel->getSchoolIdByUserId($parentId);
@@ -578,7 +615,7 @@ class InvoiceController extends ResourceController
// Initial balance equals the created total; discounts/refunds/payments will adjust later
'balance' => $totalAmount,
'status' => 'Unpaid',
'school_year' => $this->schoolYear,
'school_year' => $schoolYear,
'semester' => $semester,
'issue_date' => $issueUtc,
'due_date' => $dueUtc,
@@ -615,6 +652,47 @@ class InvoiceController extends ResourceController
->with('success', $updated ? 'Invoice updated.' : 'Invoice created.');
}
private function selectActiveInvoiceForParentYear(int $parentId, string $schoolYear): ?array
{
if ($parentId <= 0 || $schoolYear === '') {
return null;
}
// Prefer invoices flagged as having discounts.
$invoice = $this->invoiceModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('has_discount', 1)
->orderBy('id', 'DESC')
->first();
if (!empty($invoice)) {
return $invoice;
}
// Fallback: prefer invoices with discount_usages rows.
try {
$row = $this->db->table('invoices i')
->select('i.*')
->join('discount_usages du', 'du.invoice_id = i.id', 'inner')
->where('i.parent_id', $parentId)
->where('i.school_year', $schoolYear)
->orderBy('i.id', 'DESC')
->get()
->getRowArray();
if (!empty($row)) {
return $row;
}
} catch (\Throwable $e) {
}
// Final fallback: latest invoice for parent/year.
return $this->invoiceModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderBy('id', 'DESC')
->first();
}
private function recalculateAndUpdateDiscount(
int $parentId,
string $schoolYear,
@@ -678,7 +756,7 @@ class InvoiceController extends ResourceController
log_message('error', 'additional_charges sum failed for discount recalculation invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
}
$baseTotal = round($tuitionFee + $eventchargeTotal + $extrasSum, 2);
$baseTotal = round($tuitionFee + $extrasSum, 2);
// Recalculate discount
if ($discountUsage['discount_type'] === 'percent') {
@@ -1266,6 +1344,12 @@ class InvoiceController extends ResourceController
}
}
}
if ($studentName === 'N/A') {
$externalName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? ''));
if ($externalName !== '') {
$studentName = $externalName . ' (external)';
}
}
$dt = $toLocal($event['created_at'] ?? null, false);
$amount = (float)($event['charged'] ?? 0.0);
@@ -1612,12 +1696,24 @@ private function getGradeLevel($grade): array
$invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null;
}
$invoiceEventCharges = [];
foreach ($invoices as $invoice) {
$year = $invoice['school_year'] ?? $this->schoolYear;
$sem = $invoice['semester'] ?? $this->semester;
$invoiceEventCharges[(int)$invoice['id']] = $this->chargesModel->getChargesWithEventInfo(
$invoice['parent_id'],
$year,
$sem
);
}
return view('/parent/invoice_payment', [
'invoices' => $invoices,
'schoolYears' => $schoolYears,
'selectedYear' => $selectedYear,
'currentSchoolYear' => $currentSchoolYear,
'dueDate' => $this->dueDate
'dueDate' => $this->dueDate,
'invoiceEventCharges' => $invoiceEventCharges,
]);
}
+12 -3
View File
@@ -95,6 +95,11 @@ class LandingPageController extends BaseController
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
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
(int)$user_id,
@@ -106,7 +111,7 @@ class LandingPageController extends BaseController
$ids = array_values(array_filter(array_unique($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;
}
@@ -868,8 +873,12 @@ class LandingPageController extends BaseController
protected function getUserRole()
{
// Assuming you have a session variable storing the user role
return session()->get('user_role', 'guest'); // Default to guest if not set
$active = session()->get('active_role');
if ($active !== null && $active !== '') {
return (string) $active;
}
return (string) (session()->get('role') ?? 'guest');
}
protected function getUserRoleFromDatabase($user_id)
+2 -2
View File
@@ -1287,14 +1287,14 @@ class ScoreController extends Controller
public function viewStudentScore()
{
$parentId = session()->get('user_id');
$userType = $_SESSION['user_type'];
$userType = session()->get('user_type') ?? '';
$firstParentId = null;
$releaseFall = $this->getParentScoresReleasedForSemester('Fall');
$releaseSpring = $this->getParentScoresReleasedForSemester('Spring');
$releaseAny = $releaseFall || $releaseSpring;
// Identify the firstparent based on user type
if ($userType === 'primary') {
if ($userType === 'primary' || $userType === '') {
$firstParentId = $parentId;
} elseif ($userType === 'secondary') {
$parentData = $this->db->table('parents')
+10 -6
View File
@@ -297,12 +297,16 @@ class TeacherController extends BaseController
$schoolYear = $forYear ?: ((string)($this->schoolYear ?? 'Not Set'));
$teachers = $this->teacherModel->getTeachersAndTAs();
// Prefer class sections for the selected year, fallback to all if none
$classSections = $classSectionModel
->where('school_year', $schoolYear)
->orderBy('class_section_name', 'ASC')
->findAll();
if (empty($classSections)) {
// Prefer class sections for the selected year if the column exists, otherwise fallback to all.
if ($this->db->fieldExists('school_year', 'classSection')) {
$classSections = $classSectionModel
->where('school_year', $schoolYear)
->orderBy('class_section_name', 'ASC')
->findAll();
if (empty($classSections)) {
$classSections = $classSectionModel->orderBy('class_section_name', 'ASC')->findAll();
}
} else {
$classSections = $classSectionModel->orderBy('class_section_name', 'ASC')->findAll();
}
@@ -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');
}
}
}
@@ -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');
}
}
}
+15 -2
View File
@@ -41,7 +41,13 @@ class AuthFilter implements FilterInterface
// Must be logged in
if (empty($userRoles) || empty($userId)) {
return redirect()->to('/login');
$target = ltrim($request->getUri()->getPath(), '/');
$query = $request->getUri()->getQuery();
if ($query !== '') {
$target .= '?' . $query;
}
return redirect()->to('/login?redirect_to=' . rawurlencode('/' . ltrim($target, '/')));
}
// Enforce 12-hour session lifetime
@@ -110,7 +116,14 @@ class AuthFilter implements FilterInterface
]);
}
return redirect()->to('/login')->with('error', 'Your session has expired. Please log in again.');
$target = ltrim($request->getUri()->getPath(), '/');
$query = $request->getUri()->getQuery();
if ($query !== '') {
$target .= '?' . $query;
}
return redirect()->to('/login?redirect_to=' . rawurlencode('/' . ltrim($target, '/')))
->with('error', 'Your session has expired. Please log in again.');
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
+3
View File
@@ -0,0 +1,3 @@
<?php
require_once __DIR__ . '/AuthHelper.php';
+44
View File
@@ -4,6 +4,15 @@ namespace App\Models;
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
{
protected $table = 'class_progress_reports';
@@ -27,7 +36,42 @@ class ClassProgressReportModel extends Model
'flags_json',
'attachment_path',
];
protected $useTimestamps = true;
protected $createdField = 'created_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.',
],
];
}
+12 -1
View File
@@ -15,8 +15,19 @@ class EventChargesModel extends Model
'student_id',
'participation',
'charged',
'event_paid',
'event_payment_id',
'class_section_id',
'external_firstname',
'external_lastname',
'external_note',
'external_parent_firstname',
'external_parent_lastname',
'external_parent_phone',
'external_parent_email',
'semester',
'school_year',
'created_by',
'updated_by',
'created_at',
'updated_at'
@@ -30,7 +41,7 @@ class EventChargesModel extends Model
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');
if ($parentId) {
+68 -5
View File
@@ -8,19 +8,51 @@ class ExamDraftModel extends Model
{
protected $table = 'exam_drafts';
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 = [
'teacher_id',
'author_id',
'class_section_id',
'semester',
'school_year',
'exam_type',
'draft_title',
'author_comment',
'description',
'teacher_file',
'teacher_filename',
'author_file',
'author_filename',
'status',
'acceptance_type',
'review_revision',
'reviewer_id',
'admin_id',
'is_legacy',
'reviewer_comment',
'reviewer_comments',
'admin_comments',
'reviewed_at',
'final_file',
@@ -29,9 +61,40 @@ class ExamDraftModel extends Model
'version',
'previous_draft_id',
];
protected $returnType = 'array';
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected bool $updateOnlyChanged = false; // force updates even if CI thinks nothing changed
/**
* Applied on insert/update when validation runs (e.g. $model->insert($data, true)).
* Uses if_exist so partial updates still validate only keys present in $data.
*/
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',
];
}
+3 -3
View File
@@ -71,7 +71,7 @@ class TeacherClassModel extends Model
$builder = $this->db->table('teacher_class tc')
->select([
'cs.class_section_name',
'cs.id AS class_section_pk',
'cs.class_section_id AS class_section_pk',
'cs.class_section_id',
'c.id AS class_id',
'c.class_name',
@@ -181,10 +181,10 @@ class TeacherClassModel extends Model
{
$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')
->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')
->where('cs.class_section_name', $classSectionName)
->where('tc.school_year', $schoolYear)
+3 -3
View File
@@ -34,11 +34,11 @@ class TeacherModel extends Model
public function getTeachersAndTAs(): array
{
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('roles r', 'r.id = ur.role_id')
->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')
->get()
->getResultArray();
@@ -72,4 +72,4 @@ public function getTeachersAndTAs(): array
->get()
->getResultArray();
}
}
}
+40
View File
@@ -8,6 +8,7 @@ class EmailService
{
protected array $senders;
protected EmailController $mailer;
protected int $lastCcBatchCount = 0;
public function __construct()
{
@@ -73,6 +74,45 @@ class EmailService
return $allOk;
}
/**
* Send one email with all recipients in CC.
*/
public function sendCc(array $ccList, string $subject, string $message, string $fromKey = 'general', array $attachments = [], array $headers = []): bool
{
$this->lastCcBatchCount = 0;
if (empty($ccList)) {
return true;
}
$sender = $this->getSenderDetails($fromKey);
$to = trim((string) ($sender['email'] ?? ''));
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
$fallback = trim((string) (getenv('SMTP_USER') ?: ''));
$to = filter_var($fallback, FILTER_VALIDATE_EMAIL) ? $fallback : 'no-reply@alrahmaisgl.org';
}
$attachmentsPayload = $this->normalizeAttachments($attachments);
$chunks = array_chunk(array_values($ccList), 10);
$this->lastCcBatchCount = count($chunks);
$allOk = true;
foreach ($chunks as $index => $chunk) {
$ok = $this->mailer->sendEmailWithCc($to, $chunk, $subject, $message, $fromKey, null, null, $attachmentsPayload);
if (!$ok) {
$allOk = false;
log_message('error', 'EmailService::sendCc failed for batch ' . ($index + 1) . ' of ' . count($chunks));
}
}
return $allOk;
}
public function getLastCcBatchCount(): int
{
return $this->lastCcBatchCount;
}
/**
* Normalize various attachment inputs to the format expected by EmailController.
*/
+6 -6
View File
@@ -212,42 +212,42 @@
<div class="col-4">
<img
class="img-fluid rounded bg-light p-1"
src=""assets/images/classes-1.jpg"
src="assets/images/classes-1.jpg"
alt=""
/>
</div>
<div class="col-4">
<img
class="img-fluid rounded bg-light p-1"
src=""assets/images/classes-2.jpg"
src="assets/images/classes-2.jpg"
alt=""
/>
</div>
<div class="col-4">
<img
class="img-fluid rounded bg-light p-1"
src=""assets/images/classes-3.jpg"
src="assets/images/classes-3.jpg"
alt=""
/>
</div>
<div class="col-4">
<img
class="img-fluid rounded bg-light p-1"
src=""assets/images/classes-4.jpg"
src="assets/images/classes-4.jpg"
alt=""
/>
</div>
<div class="col-4">
<img
class="img-fluid rounded bg-light p-1"
src=""assets/images/classes-5.jpg"
src="assets/images/classes-5.jpg"
alt=""
/>
</div>
<div class="col-4">
<img
class="img-fluid rounded bg-light p-1"
src=""assets/images/classes-6.jpg"
src="assets/images/classes-6.jpg"
alt=""
/>
</div>
+7 -7
View File
@@ -170,22 +170,22 @@
<h3 class="text-white mb-4">Photo Gallery</h3>
<div class="row g-2 pt-2">
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-1.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-2.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-3.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-4.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-5.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-6.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
</div>
</div>
</div>
@@ -236,4 +236,4 @@
<script src="<?= base_url('js/main.js') ?>"></script>
</body>
</html>
</html>
+11 -1
View File
@@ -214,7 +214,17 @@
<strong class="small mb-0"><?= esc($section['label'] ?? $subjectName) ?></strong>
<span class="badge <?= $badgeClass ?>"><?= esc($statusTag) ?></span>
</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>
<?php endforeach; ?>
</div>
+7 -2
View File
@@ -39,7 +39,6 @@
<?php
$subjectName = $section['db_subject'] ?? $section['label'] ?? $slug;
$isQuran = $subjectName === 'Quran/Arabic';
$unitLabel = $isQuran ? 'Surah / Custom Arabic' : 'Unit / Chapter';
$homeworkLabel = $isQuran ? 'Arabic Practice / Homework' : 'Assigned Homework';
$report = $reportsBySubject[$subjectName] ?? null;
?>
@@ -51,7 +50,13 @@
<?php if (! $report): ?>
<div class="text-muted">No entry submitted for this subject this week.</div>
<?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'])): ?>
<div class="mb-2"><strong>Materials:</strong> <?= esc($report['materials']) ?></div>
<?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">
<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">
<?= csrf_field() ?>
<div class="mb-3">
@@ -15,7 +20,7 @@
<label class="form-label">Category</label>
<select name="event_category" class="form-control" required>
<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>
<?php endforeach; ?>
</select>
@@ -51,9 +56,69 @@
<input type="text" name="school_year" class="form-control" required>
</div>
<div class="card mb-3">
<div class="card-header">School Calendar</div>
<div class="card-body">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" value="1">
<label class="form-check-label" for="add_to_calendar">
Add this event to the school calendar
</label>
</div>
<div id="calendar_recipients" class="ms-3" style="display:none;">
<div class="mb-2 fw-semibold">Visible On Calendars</div>
<div class="d-flex flex-wrap gap-3">
<label class="form-check-label">
<input class="form-check-input me-1" type="checkbox" name="notify_parent" value="1" checked>
Parents
</label>
<label class="form-check-label">
<input class="form-check-input me-1" type="checkbox" name="notify_teacher" value="1" checked>
Teachers
</label>
<label class="form-check-label">
<input class="form-check-input me-1" type="checkbox" name="notify_admin" value="1" checked>
Admins
</label>
</div>
<div class="form-text">If none are selected, the event is visible to everyone.</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">Parent Email Broadcast</div>
<div class="card-body">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="send_email_parent" name="send_email_parent" value="1">
<label class="form-check-label" for="send_email_parent">
Send one event email to all parents and add all parent emails in CC
</label>
</div>
<div class="form-text">The email is sent once. The school sender address stays in To, and all parent emails are placed in CC.</div>
</div>
</div>
<button type="submit" class="btn btn-success">Create Event</button>
<a href="<?= site_url('administrator/events') ?>" class="btn btn-secondary">Cancel</a>
</form>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
(function () {
const toggle = document.getElementById('add_to_calendar');
const recipients = document.getElementById('calendar_recipients');
if (!toggle || !recipients) return;
function sync() {
recipients.style.display = toggle.checked ? '' : 'none';
}
toggle.addEventListener('change', sync);
sync();
})();
</script>
<?= $this->endSection() ?>
@@ -70,6 +70,19 @@
<input type="text" name="school_year" class="form-control" value="<?= esc($event['school_year']) ?>" required>
</div>
<div class="card mb-3">
<div class="card-header">Parent Email Broadcast</div>
<div class="card-body">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="send_email_parent" name="send_email_parent" value="1">
<label class="form-check-label" for="send_email_parent">
Send one event email to all parents and add all parent emails in CC
</label>
</div>
<div class="form-text">Use this when you want to rebroadcast the updated event to all parents in a single email.</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Update Event</button>
<a href="<?= site_url('administrator/events') ?>" class="btn btn-secondary">Cancel</a>
</form>
+603 -64
View File
@@ -11,104 +11,343 @@
<div class="alert alert-danger"><?= session()->getFlashdata('error') ?></div>
<?php endif; ?>
<!-- Add New Charge Form -->
<form action="<?= site_url('payment/event_charges') ?>" method="post">
<?= csrf_field() ?>
<div class="row">
<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>
<?php
$classSectionNames = $classSectionNames ?? [];
$semesterOptions = $semesterOptions ?? [];
$schoolYearOptions = $schoolYearOptions ?? [];
?>
<div class="col-md-3 mt-3">
<label for="parent_id" class="form-label">Parent</label>
<select id="parent_id" name="parent_id" class="form-select" required>
<option value="">-- Select Parent --</option>
<?php foreach ($parents as $parent): ?>
<option value="<?= $parent['id'] ?>">
<?= esc($parent['firstname'] . ' ' . $parent['lastname']) ?> (<?= esc($parent['school_id']) ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12 mt-3" id="studentCheckboxContainer" style="display: none;">
<label>Select Students:</label>
<div id="studentList" class="row"></div>
</div>
<?php
$filterParentId = $filterParentId ?? 0;
$parentBalances = $parentBalances ?? [];
$selectedEvent = null;
if (!empty($filterEventId)) {
foreach ($events as $event) {
if ((int)$event['id'] === (int)$filterEventId) {
$selectedEvent = $event;
break;
}
}
}
?>
<div class="card mb-4">
<div class="card-body">
<form method="get" action="<?= site_url('administrator/event-charges') ?>" class="row g-3 align-items-end">
<?php if ($filterEventId > 0): ?>
<input type="hidden" name="event_id" value="<?= esc($filterEventId) ?>">
<?php endif; ?>
<?php if ($filterParentId > 0): ?>
<input type="hidden" name="parent_id" value="<?= esc($filterParentId) ?>">
<?php endif; ?>
<div class="col-md-4">
<label for="semester_filter" class="form-label">Semester</label>
<select id="semester_filter" name="semester" class="form-select">
<option value="">-- All semesters --</option>
<?php foreach ($semesterOptions as $option): ?>
<option value="<?= esc($option) ?>" <?= $semester === $option ? 'selected' : '' ?>>
<?= esc($option) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label for="school_year_filter" class="form-label">School Year</label>
<select id="school_year_filter" name="school_year" class="form-select">
<option value="">-- All school years --</option>
<?php foreach ($schoolYearOptions as $option): ?>
<option value="<?= esc($option) ?>" <?= $school_year === $option ? 'selected' : '' ?>>
<?= esc($option) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-outline-primary">Filter</button>
</div>
</form>
</div>
</div>
<button type="submit" class="btn btn-primary mt-3">Submit</button>
<a href="<?= site_url('administrator/events') ?>" class="btn btn-success mt-3">Event List</a>
</form>
<br>
<!-- Add New Charge Form -->
<div class="card mb-4">
<div class="card-body">
<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>
<p class="mb-1 text-muted small">
<?= esc($selectedEvent['description'] ?: 'No description provided for this event.') ?>
</p>
<?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">
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#externalParticipantModal">
Add non-school participant
</button>
<div class="form-text">After adding participants, click Submit to save them to the database.</div>
<div id="externalParticipantsHidden"></div>
</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 -->
<?php
$grouped = [];
foreach ($charges as $charge) {
$label = $charge['event_name'] ?? 'N/A';
$grouped[$label][] = $charge;
$eventId = (int)($charge['event_id'] ?? 0);
if (!isset($grouped[$eventId])) {
$grouped[$eventId] = [
'label' => $charge['event_name'] ?? 'N/A',
'rows' => [],
];
}
$grouped[$eventId]['rows'][] = $charge;
}
?>
<?php if (empty($grouped)): ?>
<div class="alert alert-info">No charges found.</div>
<?php else: ?>
<?php foreach ($grouped as $eventLabel => $rows): ?>
<div class="card mb-4">
<?php foreach ($grouped as $eventId => $data): ?>
<?php $eventLabel = $data['label']; ?>
<?php $rows = $data['rows']; ?>
<div class="card mb-4 event-card" data-event-id="<?= esc($eventId) ?>">
<div class="card-header bg-light fw-semibold">
<?= esc($eventLabel) ?>
</div>
<div class="card-body p-0">
<?php
$totalParticipants = 0;
$totalCharged = 0.0;
?>
<div class="table-responsive">
<table class="table table-bordered table-striped mb-0 no-mgmt-sticky">
<table id="eventTable_<?= esc($eventId) ?>" class="table table-bordered table-striped mb-0 no-mgmt-sticky">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Parent Name</th>
<th>Student Name</th>
<th>Charged Amount</th>
<th>Is Participating</th>
<th>Semester</th>
<th>Year</th>
<th>Created</th>
</tr>
<tr>
<th>ID</th>
<th>Parent Name</th>
<th>Student Name</th>
<th>External Info</th>
<th>Class Section</th>
<th>Charged Amount</th>
<th>Created</th>
<th>Fees Paid</th>
<th>Payment</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $charge): ?>
<?php
$isParticipating = ($charge['participation'] === 'yes');
$isEventPaid = !empty($charge['event_paid']);
?>
<tr>
<td><?= esc($charge['id']) ?></td>
<td><?= esc($charge['parent_firstname'] . ' ' . $charge['parent_lastname']) ?></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'] ?? '';
$standardParentName = trim($charge['parent_firstname'] . ' ' . $charge['parent_lastname']);
$parentColumnName = $externalParentLabel ?: ($standardParentName ?: '—');
?>
<td><?= esc($parentColumnName) ?></td>
<td>
<?= $charge['student_firstname']
? esc($charge['student_firstname'] . ' ' . $charge['student_lastname'])
: '-' ?>
<?= esc($displayName) ?>
<?php if ($externalNote): ?>
<small class="text-muted d-block"><?= esc($externalNote) ?></small>
<?php endif; ?>
</td>
<td>$<?= esc(number_format($charge['charged'] ?? 0, 2)) ?></td>
<td>
<?= $charge['participation']
? '<span class="badge bg-success">Yes</span>'
: '<span class="badge bg-warning">No</span>' ?>
<?php if ($externalParentPhone): ?>
<div class="text-muted small"><?= esc($externalParentPhone) ?></div>
<?php else: ?>
<span class="text-muted small">—</span>
<?php endif; ?>
</td>
<td><?= esc($charge['semester'] ?? '-') ?></td>
<td><?= esc($charge['school_year'] ?? '-') ?></td>
<td>
<?= esc($classSectionNames[$charge['class_section_id'] ?? ''] ?? '') ?>
</td>
<td>$<?= esc(number_format($charge['event_amount'] ?? 0, 2)) ?></td>
<td><?= esc(!empty($charge['created_at']) ? local_datetime($charge['created_at'], 'm-d-Y H:i') : '') ?></td>
<?php
$feeAmount = (float) ($charge['event_amount'] ?? 0);
$hasBalanceRecord = array_key_exists((int)$charge['parent_id'], $parentBalances);
$parentBalance = $hasBalanceRecord ? (float)$parentBalances[$charge['parent_id']] : null;
$feeIsPaid = $isParticipating && ($isEventPaid || ($hasBalanceRecord && $parentBalance <= 0));
?>
<td class="text-center">
<?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">
<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="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() ?>
<button type="submit" class="btn btn-outline-danger btn-sm">Remove</button>
</form>
</div>
</td>
</tr>
<?php
if ($isParticipating) {
$totalParticipants++;
$totalCharged += (float) ($charge['event_amount'] ?? 0);
}
?>
<?php endforeach; ?>
</tbody>
<tfoot class="table-light">
<tr>
<th colspan="4">Totals</th>
<th>$<?= esc(number_format($totalCharged, 2)) ?></th>
<th colspan="5">
<span class="badge bg-secondary">
<?= esc($totalParticipants) ?> participating
</span>
</th>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach; ?>
<?php endif; ?>
</div>
<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 students 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>
<?= $this->endSection() ?>
@@ -156,7 +395,307 @@ function loadStudentsWithCharges() {
}
}
$('#parent_id').on('change', loadStudentsWithCharges);
$('#event_id').on('change', loadStudentsWithCharges);
const capitalizeName = (value) => {
if (!value) return '';
return value
.split(' ')
.map(part => {
const trimmed = part.trim();
if (!trimmed) return '';
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1).toLowerCase();
})
.filter(Boolean)
.join(' ');
};
$(function() {
$('#parent_id').on('change', loadStudentsWithCharges);
$('#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 $externalHidden = $('#externalParticipantsHidden');
const storageKey = 'eventChargesExternalParticipants';
let externalDrafts = [];
let editingKey = null;
const escapeHtml = (value) => {
const s = String(value ?? '');
return s.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
function buildExternalRow(entry) {
const key = entry.key;
const isPaid = !!entry.paid;
const firstEsc = escapeHtml(entry.firstname);
const lastEsc = escapeHtml(entry.lastname);
const noteEsc = escapeHtml(entry.note);
const parentFirstEsc = escapeHtml(entry.parentFirstname);
const parentLastEsc = escapeHtml(entry.parentLastname);
const parentPhoneEsc = escapeHtml(entry.parentPhone);
const parentEmailEsc = escapeHtml(entry.parentEmail);
const label = (firstEsc || lastEsc)
? `${firstEsc || ''} ${lastEsc || ''}`.trim()
: 'Unnamed participant';
const parentLabel = (parentFirstEsc || parentLastEsc)
? `<div class="text-muted small">Parent: ${parentFirstEsc} ${parentLastEsc}</div>`
: '';
const phoneLabel = parentPhoneEsc
? `<div class="text-muted small">${parentPhoneEsc}</div>`
: '';
const parentNameForRow = entry.parentDisplayName || '—';
const amountDisplay = ((entry.eventAmount ?? 0) || 0.0).toFixed(2);
const $wrapper = $(`
<div data-key="${key}">
<input type="hidden" name="external_participants[${key}][firstname]" value="${firstEsc}">
<input type="hidden" name="external_participants[${key}][lastname]" value="${lastEsc}">
<input type="hidden" name="external_participants[${key}][note]" value="${noteEsc}">
<input type="hidden" name="external_participants[${key}][parent_firstname]" value="${parentFirstEsc}">
<input type="hidden" name="external_participants[${key}][parent_lastname]" value="${parentLastEsc}">
<input type="hidden" name="external_participants[${key}][parent_phone]" value="${parentPhoneEsc}">
<input type="hidden" name="external_participants[${key}][parent_email]" value="${parentEmailEsc}">
<input type="hidden" name="external_participants[${key}][paid]" value="${isPaid ? '1' : '0'}">
</div>
`);
$externalHidden.append($wrapper);
const $tableBody = $(`#eventTable_${entry.eventId} tbody`);
if (!$tableBody.length) {
return;
}
const createdDisplay = new Date().toLocaleString();
const $previewRow = $(`
<tr class="external-preview table-warning" data-key="${key}" data-event-id="${entry.eventId}">
<td>—</td>
<td>${escapeHtml(parentNameForRow)}</td>
<td>
<strong>${label}</strong>
${noteEsc ? `<div class="text-muted small">${noteEsc}</div>` : ''}
</td>
<td>
${phoneLabel}
${phoneLabel ? '' : '<span class="text-muted small">—</span>'}
</td>
<td>External</td>
<td>$${amountDisplay}</td>
<td>${createdDisplay}</td>
<td class="text-center">
<span class="badge bg-warning text-dark">Pending</span>
${isPaid ? '<span class="badge bg-success ms-1 external-paid-badge">Paid on save</span>' : '<span class="badge bg-secondary ms-1 external-paid-badge d-none">Paid on save</span>'}
</td>
<td class="text-center">
<div class="form-check d-inline-flex align-items-center gap-2 justify-content-center m-0">
<input class="form-check-input external-paid-toggle" type="checkbox" data-key="${key}" ${isPaid ? 'checked' : ''}>
<span class="small text-muted">On save</span>
</div>
</td>
<td class="text-end d-flex gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm edit-external-participant" data-key="${key}">
Edit
</button>
<button type="button" class="btn btn-outline-danger btn-sm remove-external-participant" data-key="${key}">
Remove
</button>
</td>
</tr>
`);
$tableBody.append($previewRow);
}
function persistDrafts() {
localStorage.setItem(storageKey, JSON.stringify(externalDrafts));
}
function saveExternalDraft(entry) {
entry.key = entry.key ?? `${Date.now()}_${Math.random().toString(36).slice(2)}`;
entry.paid = !!entry.paid;
removeExternalEntryFromDom(entry.key);
externalDrafts = externalDrafts.filter((existing) => existing.key !== entry.key);
externalDrafts.push(entry);
persistDrafts();
buildExternalRow(entry);
}
function removeExternalEntryFromDom(key) {
$(`tr.external-preview[data-key="${key}"]`).remove();
$externalHidden.find(`div[data-key="${key}"]`).remove();
}
function removeDraft(key) {
externalDrafts = externalDrafts.filter((entry) => entry.key !== key);
persistDrafts();
}
function loadDrafts() {
const raw = localStorage.getItem(storageKey);
if (!raw) {
return;
}
try {
const saved = JSON.parse(raw);
if (!Array.isArray(saved)) {
return;
}
externalDrafts = saved;
const currentEvent = $('#event_id').val();
externalDrafts.forEach((entry) => {
if (String(entry.eventId) === String(currentEvent)) {
buildExternalRow(entry);
}
});
} catch (e) {
console.error('Failed to load external drafts', e);
}
}
function getDraftByKey(key) {
return externalDrafts.find((entry) => entry.key === key);
}
function openExternalModalForEntry(key) {
const entry = getDraftByKey(key);
if (!entry) {
return;
}
editingKey = key;
$('#modalExternalFirstName').val(entry.firstname);
$('#modalExternalLastName').val(entry.lastname);
$('#modalExternalNote').val(entry.note);
$('#modalParentFirstName').val(entry.parentFirstname);
$('#modalParentLastName').val(entry.parentLastname);
$('#modalParentPhone').val(entry.parentPhone);
$('#modalParentEmail').val(entry.parentEmail);
if (modalInstance) {
modalInstance.show();
}
}
const modalElement = document.getElementById('externalParticipantModal');
const modalInstance = modalElement ? new bootstrap.Modal(modalElement) : null;
if (modalElement) {
modalElement.addEventListener('hidden.bs.modal', () => {
editingKey = null;
});
}
function clearModalInputs() {
$('#modalExternalFirstName').val('');
$('#modalExternalLastName').val('');
$('#modalExternalNote').val('');
$('#modalParentFirstName').val('');
$('#modalParentLastName').val('');
$('#modalParentPhone').val('');
$('#modalParentEmail').val('');
}
$('#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 eventAmount = parseFloat($('#event_id option:selected').data('amount')) || 0;
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;
}
const parentName = explicitParentName;
const draftEntry = editingKey ? getDraftByKey(editingKey) : null;
const entry = {
firstname: firstName,
lastname: lastName,
note: note,
parentFirstname: parentFirst,
parentLastname: parentLast,
parentPhone: parentPhone,
parentEmail: parentEmail,
eventId: (draftEntry && draftEntry.eventId) ? draftEntry.eventId : eventId,
eventAmount: (draftEntry && draftEntry.eventAmount) ? draftEntry.eventAmount : eventAmount,
parentDisplayName: (draftEntry && draftEntry.parentDisplayName) ? draftEntry.parentDisplayName : parentName,
paid: (draftEntry && typeof draftEntry.paid !== 'undefined') ? !!draftEntry.paid : false,
};
if (editingKey) {
entry.key = editingKey;
}
saveExternalDraft(entry);
editingKey = null;
clearModalInputs();
if (modalInstance) {
modalInstance.hide();
}
});
$(document).on('click', '.remove-external-participant', function() {
const $entry = $(this).closest('[data-key]');
const key = $entry.data('key');
removeExternalEntryFromDom(key);
if (key) {
removeDraft(key);
}
});
$(document).on('click', '.edit-external-participant', function() {
const key = $(this).data('key');
if (key) {
openExternalModalForEntry(key);
}
});
$(document).on('change', '.external-paid-toggle', function() {
const key = $(this).data('key');
const paid = !!this.checked;
if (!key) {
return;
}
const entry = getDraftByKey(key);
if (entry) {
entry.paid = paid;
persistDrafts();
}
$externalHidden
.find(`div[data-key="${key}"] input[name="external_participants[${key}][paid]"]`)
.val(paid ? '1' : '0');
const $badge = $(`tr.external-preview[data-key="${key}"] .external-paid-badge`);
if (paid) {
$badge.removeClass('d-none').addClass('bg-success').text('Paid on save');
} else {
$badge.addClass('d-none');
}
});
$('#event-participant-form').on('submit', function() {
localStorage.removeItem(storageKey);
});
loadDrafts();
});
</script>
<?= $this->endSection() ?>
+12 -2
View File
@@ -12,6 +12,14 @@
<div class="alert alert-danger"><?= session()->getFlashdata('error') ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('email_status')): ?>
<div class="alert alert-info"><?= session()->getFlashdata('email_status') ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('email_error')): ?>
<div class="alert alert-warning"><?= session()->getFlashdata('email_error') ?></div>
<?php endif; ?>
<?php if (empty($events)): ?>
<div class="alert alert-info mb-3 d-inline-block">No events found.</div>
<?php else: ?>
@@ -21,7 +29,8 @@
<tr>
<th>Event Name</th>
<th>Category</th>
<th>Amount</th>
<th>Description</th>
<th>Event Fees</th>
<th>Expiration Date</th>
<th>Semester</th>
<th>School Year</th>
@@ -33,7 +42,8 @@
<tr>
<td><?= esc($event['event_name']) ?></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($event['semester']) ?></td>
<td><?= esc($event['school_year']) ?></td>
+452 -153
View File
@@ -1,9 +1,31 @@
<?= $this->extend('layout/management_layout') ?>
<?= $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'];
$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="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
<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">
<?= esc($semester ?: 'Semester') ?> <?= esc($schoolYear ?: '') ?>
</p>
@@ -15,13 +37,9 @@
</div>
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success">
<?= esc(session()->getFlashdata('success')) ?>
</div>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
<?php elseif (session()->getFlashdata('error')): ?>
<div class="alert alert-danger">
<?= esc(session()->getFlashdata('error')) ?>
</div>
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<ul class="nav nav-pills mb-3" id="examDraftTabs" role="tablist">
@@ -32,177 +50,253 @@
</li>
<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">
Legacy Exams
Legacy exams
</button>
</li>
</ul>
<div class="tab-content" id="examDraftTabsContent">
<div class="tab-pane fade show active" id="submissions" role="tabpanel" aria-labelledby="submissions-tab">
<div class="card mb-4">
<div class="card-body">
<?php if (empty($drafts)): ?>
<p class="text-muted mb-0">No exam drafts have been submitted yet.</p>
<?php if (empty($visibleClasses)): ?>
<p class="text-muted mb-0">No classes with enrolled students are available for this term.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-striped table-bordered align-middle mb-0 exam-drafts-table no-mgmt-sticky">
<thead class="table-light">
<tr>
<th>Teacher</th>
<th>Class</th>
<th>Title / Type</th>
<th>Version</th>
<th>Submitted</th>
<th>Status</th>
<th>Files</th>
<th>PDF</th>
<th>Review</th>
</tr>
</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) ?>
<div class="accordion exam-drafts-accordion" id="examDraftsAccordion">
<?php foreach ($visibleClasses as $classSectionId => $classInfo): ?>
<?php $classDraftsForSection = $classDraftGroups[$classSectionId] ?? []; ?>
<?php $collapseId = 'classDraftGroup_' . $classSectionId; ?>
<div class="accordion-item mb-3">
<h2 class="accordion-header" id="heading_<?= esc($collapseId) ?>">
<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) ?>">
<div class="d-flex flex-column flex-lg-row w-100 justify-content-between gap-2">
<div>
<strong><?= esc($classInfo['class_section_name'] ?? ('Class ' . $classSectionId)) ?></strong>
<div class="small text-muted">
<?= esc($classInfo['student_count'] ?? 0) ?> students · <?= esc(count($classDraftsForSection)) ?> submissions
<?php if (!empty($newSubmissionClasses[$classSectionId])): ?>
<span class="badge bg-info text-dark ms-2">New submission</span>
<?php endif; ?>
</div>
<?php endif; ?>
</td>
<td>
<?php if (!empty($draft['teacher_file'])): ?>
<div>
<a href="<?= base_url('exam-drafts/files/teacher/' . $draft['teacher_file']) ?>" target="_blank">
<?= esc($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'])): ?>
<div class="mt-2">
<a href="<?= base_url('exam-drafts/files/final/' . $draft['final_file']) ?>" target="_blank" class="link-success small">
<?= esc($draft['final_filename'] ?? 'Final draft') ?>
</a>
</div>
<?php endif; ?>
</td>
<td class="text-nowrap">
<?php
$pdfFile = $draft['final_pdf_file'] ?? null;
// Fallback: if final_file itself is a pdf, use it
if (empty($pdfFile) && !empty($draft['final_file']) && strtolower(pathinfo($draft['final_file'], PATHINFO_EXTENSION)) === 'pdf') {
$pdfFile = $draft['final_file'];
}
?>
<?php if (!empty($pdfFile)): ?>
<a href="<?= base_url('exam-drafts/files/final/' . $pdfFile) ?>" target="_blank" class="link-success small">
PDF
</a>
<?php else: ?>
<span class="text-muted small">—</span>
<?php endif; ?>
</td>
<td>
<form method="post" action="<?= base_url('/administrator/exam-drafts/review') ?>" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="hidden" name="draft_id" value="<?= esc($draft['id']) ?>">
<div class="mb-2">
<textarea
name="admin_comments"
rows="2"
class="form-control form-control-sm"
placeholder="Optional note for the teacher"
><?= esc($draft['admin_comments'] ?? '') ?></textarea>
</div>
<div class="mb-2">
<label class="form-label small mb-1">Status</label>
<select name="review_status" class="form-select form-select-sm">
<?php foreach ($statusOptions as $option): ?>
<option value="<?= esc($option) ?>" <?= ($option === ($draft['status'] ?? '')) ? 'selected' : '' ?>>
<?= esc(ucfirst($option)) ?>
</option>
</div>
<span class="badge bg-secondary align-self-start">
<?= esc(count($classDraftsForSection)) ?>
<?= count($classDraftsForSection) === 1 ? 'submission' : 'submissions' ?>
</span>
</div>
</button>
</h2>
<div id="<?= esc($collapseId) ?>" class="accordion-collapse collapse" aria-labelledby="heading_<?= esc($collapseId) ?>">
<div class="accordion-body pt-0 px-3">
<?php if (empty($classDraftsForSection)): ?>
<p class="text-muted mb-0">No submissions yet for this class.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-striped table-bordered align-middle mb-0 exam-drafts-table no-mgmt-sticky">
<thead class="table-light">
<tr>
<th>Teacher</th>
<th>Class</th>
<th>Title & AuthorNote</th>
<th>Version</th>
<th>Date-Time</th>
<th>Status</th>
<th>Files</th>
<th>Reviewer Action</th>
<th>Final version</th>
</tr>
</thead>
<tbody>
<?php foreach ($classDraftsForSection 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');
}
$st = strtolower((string) ($draft['status'] ?? ''));
$adminName = trim(($draft['admin_first'] ?? '') . ' ' . ($draft['admin_last'] ?? ''));
$authorNote = $draft['author_comment'] ?? $draft['description'] ?? '';
$reviewerNote = $draft['reviewer_comment'] ?? $draft['admin_comments'] ?? '';
?>
<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 ($authorNote !== ''): ?>
<div class="small mt-1">
<span class="text-muted fw-semibold">Author:</span>
<?= esc($authorNote) ?>
</div>
<?php endif; ?>
<?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(base_url('administrator/exam-drafts/review'), ['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; ?>
</select>
</div>
<div class="mb-2">
<label class="form-label small mb-1">Upload final draft</label>
<input type="file" name="final_file" class="form-control form-control-sm">
</div>
<button type="submit" class="btn btn-sm btn-primary w-100">
Save review
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<div class="tab-pane fade" id="legacy" role="tabpanel" aria-labelledby="legacy-tab">
<div class="card border-primary-subtle mb-3">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div>
<strong>Upload Old / Legacy Exam</strong>
<div class="small text-muted">Store historic exams as finalized records.</div>
<strong>Upload old / legacy exam</strong>
<div class="small text-muted">Store historic exams as accepted records.</div>
</div>
<span class="badge bg-primary-subtle text-primary">Admin only</span>
</div>
<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(base_url('administrator/exam-drafts/upload-legacy'), ['class' => 'row g-3']) ?>
<?= csrf_field() ?>
<div class="col-md-4">
<label class="form-label small">Class Sections</label>
<select name="class_section_ids[]" class="form-select" multiple required>
<label class="form-label small">Class sections</label>
<select name="class_section_ids[]" class="form-select" multiple required size="6">
<?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; ?>
</select>
<div class="form-text">Hold Ctrl (Windows) or Command (Mac) to select multiple sections.</div>
</div>
<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>
</div>
<div class="col-md-2">
<label class="form-label small">Semester</label>
<select name="semester" class="form-select" required>
<option value="Fall" <?= ($semester === 'Fall') ? 'selected' : '' ?>>Fall</option>
<option value="Spring" <?= ($semester === 'Spring') ? 'selected' : '' ?>>Spring</option>
</select>
<input type="text" name="semester" class="form-control" value="<?= esc($semester) ?>" placeholder="Fall / Spring" required>
</div>
<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">
<option value="">— Select type —</option>
<?php foreach ($examTypes as $type): ?>
@@ -211,14 +305,14 @@
</select>
</div>
<div class="col-12">
<label class="form-label small">Upload File</label>
<input type="file" name="old_exam_file" class="form-control" accept=".doc,.docx,.pdf" required>
<label class="form-label small">Upload file</label>
<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>
<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>
</form>
<?= form_close() ?>
</div>
</div>
@@ -231,20 +325,21 @@
<div class="mb-4">
<h6 class="mb-2"><?= esc($group['class_section_name'] ?? 'Class') ?></h6>
<div class="list-group">
<?php foreach ($group['items'] as $item): ?>
<div class="list-group-item d-flex justify-content-between align-items-start">
<?php foreach ($group['items'] ?? [] as $item): ?>
<?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="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">
<?= esc($item['exam_type'] ?? 'N/A') ?> •
<?= esc($item['semester'] ?? '') ?> <?= esc($item['school_year'] ?? '') ?>
</div>
<div class="small mt-1"><?= $renderBadge($lst, $statusBadges) ?></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'])): ?>
<a href="<?= base_url('exam-drafts/files/final/' . $item['final_file']) ?>" target="_blank" class="btn btn-sm btn-outline-primary">
Download
</a>
<a class="btn btn-sm btn-outline-primary" href="<?= base_url('exam-drafts/files/final/' . $item['final_file']) ?>" target="_blank" rel="noopener">View</a>
<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>
<?php else: ?>
<span class="text-muted small">File missing</span>
<?php endif; ?>
@@ -260,6 +355,208 @@
</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->section('styles') ?>
@@ -270,13 +567,15 @@
word-break: break-word;
}
.exam-drafts-table th {
min-width: 120px;
min-width: 0;
white-space: nowrap;
}
.exam-drafts-table td {
max-width: 220px;
max-width: none;
}
.exam-drafts-table {
table-layout: auto;
width: max-content;
}
.exam-drafts-table thead th {
background-color: #f8f9fa;
@@ -13,17 +13,19 @@
</a>
</div>
<!-- Search Form -->
<!--form method="get" action="<--?= base_url('admin/paypal-transactions') ?>" class="mb-3">
<--?= csrf_field(); ?>
<!--
<form method="get" action="<?= base_url('admin/paypal-transactions') ?>" class="mb-3">
<?= csrf_field(); ?>
<div class="input-group">
<input type="text" name="q" value="<--?= esc($keyword) ?>" class="form-control"
<input type="text" name="q" value="<?= esc($keyword) ?>" class="form-control"
placeholder="Search by transaction ID, email, order ID, or event type">
<button type="submit" class="btn btn-primary">Search</button>
<--?php if ($keyword): ?>
<a href="<--?= base_url('admin/paypal-transactions') ?>" class="btn btn-secondary">Clear</a>
<--?php endif; ?>
<?php if ($keyword): ?>
<a href="<?= base_url('admin/paypal-transactions') ?>" class="btn btn-secondary">Clear</a>
<?php endif; ?>
</div>
</form-->
</form>
-->
<!-- Transactions Table -->
<table id="myTable" class="display table table-striped">
+80 -52
View File
@@ -144,58 +144,86 @@
<span class="text-muted small"><?= count($entries) ?> record<?= count($entries) === 1 ? '' : 's' ?></span>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-hover align-middle mb-0" data-no-mgmt-sticky>
<thead class="table-light">
<tr>
<th>Class</th>
<th>Subject</th>
<th>Unit</th>
<th>Unit title</th>
<th>Chapter / Surah</th>
<th>Updated</th>
<th style="min-width: 170px;">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($entries)): ?>
<tr>
<td colspan="7" class="text-center text-muted">No curriculum records yet.</td>
</tr>
<?php else: ?>
<?php foreach ($entries as $entry): ?>
<?php
$subjectLabel = $subjectLabels[$entry['subject']] ?? ucfirst($entry['subject'] ?? '');
$updatedAt = $entry['updated_at'] ?? $entry['created_at'] ?? '';
$updatedDisplay = '';
if ($updatedAt) {
try {
$updatedDisplay = (new \DateTime($updatedAt))->format('M d, Y H:i');
} catch (\Exception $e) {
$updatedDisplay = $updatedAt;
}
}
?>
<tr>
<td><?= esc(($entry['class_name'] ?? '') ?: '—') ?></td>
<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; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if (empty($entries)): ?>
<div class="text-center text-muted">No curriculum records yet.</div>
<?php else: ?>
<?php
$entriesByClass = [];
foreach ($entries as $entry) {
$className = ($entry['class_name'] ?? '') ?: '—';
if (!isset($entriesByClass[$className])) {
$entriesByClass[$className] = [];
}
$entriesByClass[$className][] = $entry;
}
?>
<div class="accordion" id="curriculumAccordion">
<?php $accIndex = 0; ?>
<?php foreach ($entriesByClass as $className => $classEntries): ?>
<?php
$accIndex++;
$collapseId = 'curriculum-class-' . $accIndex;
$headingId = 'curriculum-heading-' . $accIndex;
?>
<div class="accordion-item mb-2">
<h2 class="accordion-header" id="<?= esc($headingId) ?>">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#<?= esc($collapseId) ?>" aria-expanded="false" aria-controls="<?= esc($collapseId) ?>">
<?= esc($className) ?>
<span class="badge bg-secondary ms-2"><?= count($classEntries) ?> entries</span>
</button>
</h2>
<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>
<thead class="table-light">
<tr>
<th>Subject</th>
<th>Unit</th>
<th>Unit title</th>
<th>Chapter / Surah</th>
<th>Updated</th>
<th style="min-width: 170px;">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($classEntries as $entry): ?>
<?php
$subjectLabel = $subjectLabels[$entry['subject']] ?? ucfirst($entry['subject'] ?? '');
$updatedAt = $entry['updated_at'] ?? $entry['created_at'] ?? '';
$updatedDisplay = '';
if ($updatedAt) {
try {
$updatedDisplay = (new \DateTime($updatedAt))->format('M d, Y H:i');
} 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>
@@ -63,7 +63,15 @@
</thead>
<tbody>
<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>
</tbody>
</table>
@@ -198,10 +206,12 @@ document.addEventListener('DOMContentLoaded', function () {
if (!teachers || teachers.length === 0) {
var emptyRow = document.createElement('tr');
var emptyCell = document.createElement('td');
emptyCell.colSpan = 9;
emptyCell.className = 'text-center text-muted';
emptyCell.textContent = 'No teachers found.';
emptyRow.appendChild(emptyCell);
for (var i = 0; i < 8; i++) {
emptyRow.appendChild(document.createElement('td'));
}
tbody.appendChild(emptyRow);
} else {
teachers.forEach(function (teacher, index) {
@@ -312,7 +322,10 @@ document.addEventListener('DOMContentLoaded', function () {
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)) : '');
fetch(url, {
+370 -188
View File
@@ -23,6 +23,41 @@
<div class="card-body">
<?php
$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')): ?>
<div class="alert alert-success">
@@ -37,203 +72,278 @@
<?= esc(session()->getFlashdata('info')) ?>
</div>
<?php endif; ?>
<?php $lowProgressSectionIds = $lowProgressSectionIds ?? []; ?>
<?php if (!empty($lowProgressSectionIds)): ?>
<div class="alert alert-warning">
Showing teachers for class sections with progress submissions below 50%.
</div>
<?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>
<div class="text-uppercase small text-muted">Submission completion</div>
<div class="h4 fw-semibold mb-1"><?= esc($completionPercent) ?>%</div>
<div class="progress" style="height:6px;">
<div
class="progress-bar bg-primary"
role="progressbar"
style="width: <?= esc($completionPercent) ?>%;"
aria-valuenow="<?= esc($completionPercent) ?>"
aria-valuemin="0"
aria-valuemax="100"
></div>
<?php
$groupedRows = [];
foreach ($rows as $idx => $row) {
$classKey = (string) ($row['class_section_id'] ?? $row['class_section'] ?? 'class_' . $idx);
$groupedRows[$classKey][] = $row;
}
?>
<div class="row g-3 mb-4 align-items-stretch">
<div class="col-lg-8">
<div class="row g-3 summary-row">
<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">Completion</div>
<div class="summary-value"><?= esc($completionPercent) ?>%</div>
<div class="progress summary-progress">
<div
class="progress-bar"
role="progressbar"
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 class="text-uppercase small text-muted">Missing items</div>
<div class="h4 fw-semibold text-danger mb-0"><?= esc($missingItemsCount) ?></div>
</div>
<div class="text-muted small">
<?= esc($submittedItems) ?> submitted / <?= esc($totalItems) ?> total items
</div>
<div class="col-lg-4">
<div class="card page-notifications-card h-100 shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Page notifications</span>
<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>
<form method="post" action="<?= site_url('administrator/teacher-submissions/notify') ?>">
<?= csrf_field() ?>
<div class="table-responsive">
<table
class="table table-striped table-bordered m-0 align-middle teacher-submissions-table"
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
<div class="form-check d-inline-flex align-items-center gap-1 ms-2">
<input class="form-check-input" type="checkbox" name="notify_midterm_score" id="notifyMidtermScore" value="1">
<label class="form-check-label small" for="notifyMidtermScore">Include</label>
</div>
</th>
<th class="text-center">
<?= esc($termExamLabel) ?> Comment
<div class="form-check d-inline-flex align-items-center gap-1 ms-2">
<input class="form-check-input" type="checkbox" name="notify_midterm_comment" id="notifyMidtermComment" value="1">
<label class="form-check-label small" for="notifyMidtermComment">Include</label>
</div>
</th>
<th class="text-center">
Participation
<div class="form-check d-inline-flex align-items-center gap-1 ms-2">
<input class="form-check-input" type="checkbox" name="notify_participation" id="notifyParticipation" value="1">
<label class="form-check-label small" for="notifyParticipation">Include</label>
</div>
</th>
<th class="text-center">
PTAP Comment
<div class="form-check d-inline-flex align-items-center gap-1 ms-2">
<input class="form-check-input" type="checkbox" name="notify_ptap_comment" id="notifyPtapComment" value="1">
<label class="form-check-label small" for="notifyPtapComment">Include</label>
</div>
</th>
<th class="text-center">
Class Progress
<div class="form-check d-inline-flex align-items-center gap-1 ms-2">
<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 if (empty($rows)): ?>
<p class="text-center text-muted mt-4">No teacher-class assignments found for this term.</p>
<?php else: ?>
<div class="table-control-bar mb-3 d-flex flex-wrap align-items-center gap-3">
<div class="d-flex flex-wrap align-items-center gap-3">
<label class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" name="notify_midterm_score" value="1">
<span class="form-check-label small">Include <?= esc($termExamLabel) ?> score</span>
</label>
<label class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" name="notify_midterm_comment" value="1">
<span class="form-check-label small">Include <?= esc($termExamLabel) ?> comment</span>
</label>
<label class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" name="notify_participation" value="1">
<span class="form-check-label small">Include participation</span>
</label>
<label class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" name="notify_ptap_comment" value="1">
<span class="form-check-label small">Include PTAP comment</span>
</label>
<label class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" name="notify_class_progress" value="1">
<span class="form-check-label small">Include class progress</span>
</label>
<label class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" name="notify_exam_draft" value="1">
<span class="form-check-label small">Include exam draft</span>
</label>
<label class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" name="homework_notify_all" value="1">
<span class="form-check-label small">Include homework</span>
</label>
</div>
<div class="small text-muted">
<span class="text-uppercase" style="font-size:0.65rem;">Exam draft deadline</span><br>
<?php if ($examDraftDeadlineConfig !== ''): ?>
<?= esc($examDraftDeadlineConfig) ?>
<?php if ($examDraftDeadlineFormatted !== ''): ?>
<span class="text-muted"> → <?= esc($examDraftDeadlineFormatted) ?></span>
<?php endif; ?>
<?php else: ?>
<tr>
<td colspan="7" class="text-center">No teacher-class assignments found for this term.</td>
</tr>
<span class="text-warning">Not set</span>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="mt-3 text-end">
<button type="submit" class="btn btn-primary" <?= empty($rows) ? 'disabled' : '' ?>>
Send notifications to selected teachers
</button>
</div>
</div>
</div>
<div class="accordion" id="teacherSubmissionAccordion">
<?php foreach ($groupedRows as $sectionKey => $sectionRows): ?>
<?php
$firstRow = $sectionRows[0] ?? [];
$sectionLabel = esc($firstRow['class_section'] ?? 'Class section');
$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>
</div>
</div>
@@ -265,5 +375,77 @@
.card-body .table-responsive .teacher-submissions-table td {
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>
<?= $this->endSection() ?>
+7 -7
View File
@@ -195,22 +195,22 @@
<h3 class="text-white mb-4">Photo Gallery</h3>
<div class="row g-2 pt-2">
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-1.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-2.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-3.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-4.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-5.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-6.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
</div>
</div>
</div>
@@ -267,4 +267,4 @@
<script src="js/main.js"></script>
</body>
</html>
</html>
+2 -1
View File
@@ -25,6 +25,7 @@
<br>
<input type="hidden" name="selected_role" id="selected_role">
<input type="hidden" name="redirect_to" value="<?= esc((string) ($redirect_to ?? '')) ?>">
<div class="d-flex flex-wrap justify-content-center gap-3 mt-3">
<?php foreach ($roles as $role): ?>
@@ -68,4 +69,4 @@
tooltipTriggerList.forEach(el => new bootstrap.Tooltip(el));
});
</script>
<?= $this->endSection() ?>
<?= $this->endSection() ?>
+7 -7
View File
@@ -155,22 +155,22 @@
<h3 class="text-white mb-4">Photo Gallery</h3>
<div class="row g-2 pt-2">
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-1.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-2.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-3.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-4.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-5.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-6.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
</div>
</div>
</div>
@@ -227,4 +227,4 @@
<script src="js/main.js"></script>
</body>
</html>
</html>
+10 -10
View File
@@ -93,7 +93,7 @@
<div class="col-lg-4 col-md-6 wow fadeInUp" data-wow-delay="0.1s">
<div class="classes-item">
<div class="bg-light rounded-circle w-75 mx-auto p-3">
<img class="img-fluid rounded-circle" src=""assets/images/classes-1.jpg" alt="">
<img class="img-fluid rounded-circle" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
</div>
<div class="bg-light rounded p-4 pt-5 mt-n5">
<a class="d-block text-center h3 mt-3 mb-4" href="">Quran Learning</a>
@@ -104,7 +104,7 @@
<div class="col-lg-4 col-md-6 wow fadeInUp" data-wow-delay="0.3s">
<div class="classes-item">
<div class="bg-light rounded-circle w-75 mx-auto p-3">
<img class="img-fluid rounded-circle" src=""assets/images/classes-2.jpg" alt="">
<img class="img-fluid rounded-circle" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
</div>
<div class="bg-light rounded p-4 pt-5 mt-n5">
<a class="d-block text-center h3 mt-3 mb-4" href="">Islamic Studies</a>
@@ -114,7 +114,7 @@
<div class="col-lg-4 col-md-6 wow fadeInUp" data-wow-delay="0.5s">
<div class="classes-item">
<div class="bg-light rounded-circle w-75 mx-auto p-3">
<img class="img-fluid rounded-circle" src=""assets/images/classes-3.jpg" alt="">
<img class="img-fluid rounded-circle" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
</div>
<div class="bg-light rounded p-4 pt-5 mt-n5">
<a class="d-block text-center h3 mt-3 mb-4" href="">Arabic Learning</a>
@@ -160,22 +160,22 @@
<h3 class="text-white mb-4">Photo Gallery</h3>
<div class="row g-2 pt-2">
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-1.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-2.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-3.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-4.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-5.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-6.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
</div>
</div>
</div>
@@ -228,4 +228,4 @@
<script src="<?= base_url('js/main.js') ?>"></script>
</body>
</html>
</html>
+7 -7
View File
@@ -195,22 +195,22 @@
<h3 class="text-white mb-4">Photo Gallery</h3>
<div class="row g-2 pt-2">
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-1.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-2.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-3.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-4.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-5.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-6.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
</div>
</div>
</div>
@@ -280,4 +280,4 @@
</body>
</html>
</html>
+38
View File
@@ -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'])): ?>
<p style="margin:0.75rem 0;"><?= nl2br(esc((string) $event['description'])) ?></p>
<?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>
+2 -2
View File
@@ -80,10 +80,10 @@
<?php endif; ?>
</td>
<td><?= esc($e['approver_firstname'] . ' ' . $e['approver_lastname'] ?? '-') ?></td>
<!--td><--?= esc($e['status_reason'] ?? '-') ?></td-->
<!-- <td><?= esc($e['status_reason'] ?? '-') ?></td> -->
<td>
<?php if ($e['receipt_path']): ?>
<!--a href="<--?= base_url('writable/' . $e['receipt_path']) ?>" target="_blank">View</a-->
<!-- <a href="<?= base_url('writable/' . $e['receipt_path']) ?>" target="_blank">View</a> -->
<a href="<?= site_url('receipts/' . $e['receipt_path']) ?>" target="_blank">View receipt</a>
<?php else: ?>
N/A
+6 -6
View File
@@ -26,22 +26,22 @@
<h3 class="text-white mb-4">Photo Gallery</h3>
<div class="row g-2 pt-2">
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-1.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-2.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-3.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-4.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-5.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-6.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
</div>
</div>
</div>
+5 -1
View File
@@ -70,7 +70,8 @@ $todayYmd = local_date(utc_now(), 'Y-m-d');
<tr>
<th class="sticky-col">Grade</th>
<th class="sticky-col-2" style="min-width:300px; width:300px;">Teacher &amp; TAs</th>
<?php foreach (($headerDates ?? []) as $i => $label): $ymd = $sundays[$i] ?? ''; ?>
<th class="text-center">HW Submitted</th>
<?php foreach (($headerDates ?? []) as $i => $label): $ymd = $sundays[$i] ?? ''; ?>
<?php $isCal = !empty($eventDays[$ymd]); $isFuture = ($ymd > $todayYmd); ?>
<th class="text-center <?= $isCal ? 'bg-warning' : ($isFuture ? 'bg-future' : '') ?>" title="<?= esc($ymd) ?>"><?= esc($label) ?></th>
<?php endforeach; ?>
@@ -100,6 +101,9 @@ $todayYmd = local_date(utc_now(), 'Y-m-d');
<?php endif; ?>
<div><strong>TA<?= count($taNames) !== 1 ? 's' : '' ?>:</strong> <?= !empty($taNames) ? esc(implode(', ', $taNames)) : '—' ?></div>
</td>
<td class="text-center">
<?= (int)($homeworkSubmissionCounts[$csid] ?? 0) ?>
</td>
<?php foreach (($sundays ?? []) as $ymd): ?>
<?php if (!empty($eventDays[$ymd])): ?>
<td class="bg-warning text-center">—</td>
+1 -1
View File
@@ -140,7 +140,7 @@
<strong>Enrollment deadline:</strong>
<?= $lastDayOfRegistration ? local_date($lastDayOfRegistration, 'm-d-Y') : 'N/A' ?>
</div>
<!--div><strong>Withdrawal:</strong> <--?= esc($withdrawalDeadline ?? 'N/A') ?></div-->
<!-- <div><strong>Withdrawal:</strong> <?= esc($withdrawalDeadline ?? 'N/A') ?></div> -->
<hr class="my-2">
<?php if (!empty($enrollments)): ?>
<ul class="mb-0 ps-3">
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<!--meta name="csrf-token" content="<--?= csrf_hash() ?>"-->
<!-- <meta name="csrf-token" content="<?= csrf_hash() ?>"> -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Al Rahma Sunday School</title>
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<!-- Meta Information -->
<!--meta name="csrf-token" content="<--?= csrf_hash() ?>"-->
<!-- <meta name="csrf-token" content="<?= csrf_hash() ?>"> -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Prevent back/forward cache issues on management pages -->
+9 -8
View File
@@ -42,9 +42,6 @@
<?php endif; ?>
</div>
<!-- Event Description -->
<p class="card-text"><?= esc($event['description']) ?></p>
<!-- Participation Table -->
<form method="post" action="<?= site_url('parent/updateParticipation') ?>">
<?= csrf_field() ?>
@@ -53,11 +50,13 @@
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Student First Name</th>
<th>Student Last Name</th>
<th class="text-center">Participate</th>
</tr>
<tr>
<th>Student First Name</th>
<th>Student Last Name</th>
<th class="text-center">Participate</th>
<th>Description</th>
<th>Event Fees</th>
</tr>
</thead>
<tbody>
<?php foreach ($yourStudents as $student): ?>
@@ -84,6 +83,8 @@
</div>
</div>
</td>
<td><?= esc($event['description'] ?: 'No description') ?></td>
<td class="text-nowrap">$<?= esc(number_format((float) ($event['amount'] ?? 0), 2)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
+55
View File
@@ -143,12 +143,67 @@ $deadline = parseDbDateTime($dueDate, 'UTC', $displayTz); // format either DATE
</tbody>
</table>
</div>
<?php if (!empty($invoiceEventCharges)): ?>
<?php foreach ($invoices as $invoice): ?>
<?php $charges = $invoiceEventCharges[(int)($invoice['id'] ?? 0)] ?? []; ?>
<?php if (empty($charges)): ?>
<?php continue; ?>
<?php endif; ?>
<div class="card mb-3 shadow-sm">
<div class="card-header">
<strong>Event charges for Invoice #<?= esc($invoice['invoice_number'] ?? '#'.$invoice['id']) ?></strong>
</div>
<div class="card-body p-0">
<div class="table-responsive mb-0">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>Event</th>
<th>Student</th>
<th>Fee</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<?php foreach ($charges as $charge): ?>
<?php
$studentName = trim(($charge['student_firstname'] ?? '') . ' ' . ($charge['student_lastname'] ?? ''));
if ($studentName === '') {
$externalName = trim(($charge['external_firstname'] ?? '') . ' ' . ($charge['external_lastname'] ?? ''));
if ($externalName !== '') {
$studentName = $externalName . ' (external)';
}
}
$eventName = $charge['event_name'] ?? '—';
$paid = !empty($charge['event_paid']) || ((float)($charge['charged'] ?? 0) <= 0);
$badgeClass = $paid ? 'success' : 'danger';
?>
<tr>
<td><?= esc($eventName) ?></td>
<td><?= esc($studentName ?: '—') ?></td>
<td>$<?= number_format((float)($charge['event_amount'] ?? 0), 2) ?></td>
<td>
<span class="badge bg-<?= $badgeClass ?>">
<?= $paid ? 'Paid' : 'Unpaid' ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php else: ?>
<div class="alert alert-info mb-3 d-inline-block">
No invoice found for the selected school year.
</div>
<?php endif; ?>
<?php $invoiceEventCharges = $invoiceEventCharges ?? []; ?>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
+1 -1
View File
@@ -11,7 +11,7 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
<div class="alert alert-info mt-2">You've reached the maximum number of students (<?= $maxChilds ?>).</div>
<?php endif; ?>
<?php if ($disableEmergencyBtn): ?>
<!--div class="alert alert-info mt-2">You've reached the maximum number of emergency contacts (<--?= $maxEmergency ?>).</div-->
<!-- <div class="alert alert-info mt-2">You've reached the maximum number of emergency contacts (<?= esc((string) $maxEmergency) ?>).</div> -->
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger">
+24 -4
View File
@@ -54,6 +54,11 @@
<button class="btn btn-primary" onclick="window.print()">Print Report</button>
<a id="summaryReportLink" href="<?= base_url('financial-report/financialReportSummary') ?>" class="btn btn-info">Display Summary Report</a>
</div>
<?php if (isset($eventFeesTotal)): ?>
<div class="alert alert-info mb-3">
<strong>Event fees total:</strong> $<?= number_format((float)$eventFeesTotal, 2) ?>
</div>
<?php endif; ?>
<div class="table-responsive">
<table id="invoicesTable" class="table table-bordered table-striped align-middle w-100">
<thead>
@@ -95,8 +100,16 @@
$balanceCalc = (float)$totalAmount - (float)$discounted - (float)$paid - (float)$refunded;
if ($balanceCalc < 0) $balanceCalc = 0.0;
$balance = $balanceCalc;
$status = ($balanceCalc === 0.0) ? 'Paid' : 'Unpaid';
$statusClass = ($status === 'Paid') ? 'bg-success' : 'bg-danger';
if ($balanceCalc <= 0.00001) {
$status = 'Paid';
$statusClass = 'bg-success';
} elseif ($paid > 0.00001) {
$status = 'Partially Paid';
$statusClass = 'bg-warning text-dark';
} else {
$status = 'Unpaid';
$statusClass = 'bg-danger';
}
?>
<tr>
<td><?= esc($inv['invoice_number']) ?></td>
@@ -371,8 +384,15 @@
const total = Number(inv.total_amount || 0);
let balance = total - paid - discounted - refunded;
if (!Number.isFinite(balance) || balance < 0) balance = 0;
const status = balance === 0 ? 'Paid' : 'Unpaid';
const statusClass = balance === 0 ? 'bg-success' : 'bg-danger';
let status = 'Unpaid';
let statusClass = 'bg-danger';
if (balance <= 0.00001) {
status = 'Paid';
statusClass = 'bg-success';
} else if (paid > 0.00001) {
status = 'Partially Paid';
statusClass = 'bg-warning text-dark';
}
const tr = document.createElement('tr');
const parentHtml = (inv.parent_id && Number(inv.parent_id) > 0) ?
`<a href="<?= site_url('family') ?>?guardian_id=${encodeURIComponent(inv.parent_id)}" class="text-decoration-none" data-family-guardian-id="${inv.parent_id}">${inv.parent_name||''}</a>` :
+10 -5
View File
@@ -32,6 +32,7 @@
</thead>
<tbody id="summaryBody">
<tr><td>Total Charges</td><td class="text-right" id="sumCharges">$0.00</td></tr>
<tr><td>Event Fees</td><td class="text-right" id="sumEventFees">$0.00</td></tr>
<tr><td>Total Extra Charges</td><td class="text-right" id="sumExtraCharges">$0.00</td></tr>
<tr><td>Total Discounts</td><td class="text-right" id="sumDiscounts">$0.00</td></tr>
<tr><td>Total Refunds</td><td class="text-right" id="sumRefunds">$0.00</td></tr>
@@ -98,6 +99,9 @@ function loadSummary(){
if (!d || d.ok !== true) return;
document.getElementById('summaryPeriod').textContent = 'Report for School Year: ' + (d.schoolYear||'');
document.getElementById('sumCharges').textContent = fmt(d.totalCharges);
if (document.getElementById('sumEventFees')) {
document.getElementById('sumEventFees').textContent = fmt(d.totalEventFees || 0);
}
if (document.getElementById('sumExtraCharges')) {
document.getElementById('sumExtraCharges').textContent = fmt(d.totalExtraCharges || 0);
}
@@ -127,10 +131,10 @@ function renderCharts(d){
const summaryCtx = document.getElementById('summaryChart').getContext('2d');
window._summaryChart = new Chart(summaryCtx, {
type: 'bar',
data: { labels: ['Charges','Paid','Unpaid','Discounts','Refunds','Expenses','Reimbursements','Net'],
data: { labels: ['Charges','Event Fees','Paid','Unpaid','Discounts','Refunds','Expenses','Reimbursements','Net'],
datasets: [{ label:'Amount (USD)', data:[
d.totalCharges||0, d.totalPaid||0, d.totalUnpaid||0, d.totalDiscounts||0, d.totalRefunds||0, d.totalExpenses||0, d.totalReimbursements||0, d.netAmount||0
], backgroundColor: ['#007bff','#28a745','#ffc107','#17a2b8','#ffc107','#dc3545','#6f42c1','#20c997']}] },
d.totalCharges||0, d.totalEventFees||0, d.totalPaid||0, d.totalUnpaid||0, d.totalDiscounts||0, d.totalRefunds||0, d.totalExpenses||0, d.totalReimbursements||0, d.netAmount||0
], backgroundColor: ['#007bff','#6610f2','#28a745','#ffc107','#17a2b8','#ffc107','#dc3545','#6f42c1','#20c997']}] },
options: { responsive:true, scales:{ y:{ beginAtZero:true }}}
});
@@ -177,11 +181,12 @@ document.addEventListener("DOMContentLoaded", function() {
window._summaryChart = new Chart(summaryCtx, {
type: 'bar',
data: {
labels: ['Charges', 'Paid', 'Unpaid', 'Discounts', 'Refunds', 'Expenses', 'Reimbursements', 'Net'],
labels: ['Charges', 'Event Fees', 'Paid', 'Unpaid', 'Discounts', 'Refunds', 'Expenses', 'Reimbursements', 'Net'],
datasets: [{
label: 'Amount (USD)',
data: [
<?= (float)$totalCharges ?>,
<?= (float)($totalEventFees ?? 0) ?>,
<?= (float)$totalPaid ?>,
<?= (float)$totalUnpaid ?>,
<?= (float)$totalDiscounts ?>,
@@ -191,7 +196,7 @@ document.addEventListener("DOMContentLoaded", function() {
<?= (float)$netAmount ?>
],
backgroundColor: [
'#007bff', '#28a745', '#ffc107', '#17a2b8', '#ffc107', '#dc3545', '#6f42c1', '#20c997'
'#007bff', '#6610f2', '#28a745', '#ffc107', '#17a2b8', '#ffc107', '#dc3545', '#6f42c1', '#20c997'
]
}]
},
+15 -10
View File
@@ -47,6 +47,7 @@
<button class="btn btn-primary btn-sm" type="submit">Send Reminders (All)</button>
</form>
</div>
<?php $sumEventFees = 0.0; ?>
<div class="table-responsive">
<table id="unpaidTable" class="table table-sm align-middle no-mgmt-sticky" data-no-mgmt-sticky>
<thead>
@@ -54,6 +55,7 @@
<th>Parent</th>
<th class="text-center">Nbr of Installements</th>
<th>Type</th>
<th class="text-end">Event Fees</th>
<th class="text-end">Invoice Amount</th>
<th class="text-end">Applied Discount</th>
<th class="text-end">Paid Amount</th>
@@ -67,7 +69,7 @@
</thead>
<tbody>
<?php if (empty($rows)): ?>
<tr><td colspan="12" class="text-center text-muted py-4">No parents with outstanding balance.</td></tr>
<tr><td colspan="13" class="text-center text-muted py-4">No parents with outstanding balance.</td></tr>
<?php else: ?>
<?php
$sumInvoice = 0.0;
@@ -86,18 +88,20 @@
</td>
<td class="text-center"><?= (int)($r['payment_count'] ?? 0) ?></td>
<td>
<?php if (($r['type'] ?? '') === 'no_payment'): ?>
<span class="badge bg-danger badge-type">no payment</span>
<?php else: ?>
<span class="badge bg-success badge-type">installment</span>
<?php endif; ?>
</td>
<?php $sumInvoice += (float)($r['total_invoice'] ?? 0); ?>
<?php $sumPaid += (float)($r['total_paid'] ?? 0); ?>
<?php if (($r['type'] ?? '') === 'no_payment'): ?>
<span class="badge bg-danger badge-type">no payment</span>
<?php else: ?>
<span class="badge bg-success badge-type">installment</span>
<?php endif; ?>
</td>
<?php $sumInvoice += (float)($r['total_invoice'] ?? 0); ?>
<?php $sumEventFees += (float)($r['event_fees'] ?? 0); ?>
<?php $sumPaid += (float)($r['total_paid'] ?? 0); ?>
<?php $sumDisc += (float)($r['total_discount'] ?? 0); ?>
<?php $sumInstAmt += (float)($r['installment_amount'] ?? 0); ?>
<?php $sumBal += (float)($r['total_balance'] ?? 0); ?>
<td class="text-end">$<?= number_format((float)($r['total_invoice'] ?? 0), 2) ?></td>
<td class="text-end">$<?= number_format((float)($r['event_fees'] ?? 0), 2) ?></td>
<td class="text-end">$<?= number_format((float)($r['total_invoice'] ?? 0), 2) ?></td>
<td class="text-end text-success">-$<?= number_format((float)($r['total_discount'] ?? 0), 2) ?></td>
<td class="text-end">$<?= number_format((float)($r['total_paid'] ?? 0), 2) ?></td>
<td class="text-center"><?= (int)($r['remaining_installments'] ?? 0) ?></td>
@@ -129,6 +133,7 @@
<tfoot>
<tr>
<th colspan="3" class="text-end">Totals:</th>
<th class="text-end">$<?= number_format($sumEventFees, 2) ?></th>
<th class="text-end">$<?= number_format($sumInvoice, 2) ?></th>
<th class="text-end text-success">-$<?= number_format($sumDisc, 2) ?></th>
<th class="text-end">$<?= number_format($sumPaid, 2) ?></th>
+8 -6
View File
@@ -22,16 +22,18 @@
<input type="tel" name="phone" class="form-control" value="<?= esc($user['phone'] ?? '') ?>" placeholder="Enter phone number">
</div>
<!--div class="mb-3">
<!--
<div class="mb-3">
<label>Role</label>
<select name="role_id" class="form-control" required>
<--?php foreach ($roles as $role): ?>
<option value="<--?= $role['id'] ?>" <--?= $roleId == $role['id'] ? 'selected' : '' ?>>
<--?= esc($role['name']) ?>
<?php foreach ($roles as $role): ?>
<option value="<?= $role['id'] ?>" <?= $roleId == $role['id'] ? 'selected' : '' ?>>
<?= esc($role['name']) ?>
</option>
<--?php endforeach ?>
<?php endforeach ?>
</select>
</div-->
</div>
-->
<button type="submit" class="btn btn-primary">Update</button>
</form>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@
</div>
<?php endif; ?>
<!--a href="<--?= site_url('staff/create') ?>" class="btn btn-primary mb-3">Add Staff</a-->
<!-- <a href="<?= site_url('staff/create') ?>" class="btn btn-primary mb-3">Add Staff</a> -->
<div class="table-responsive">
<table id="myTable" class="display table table-striped table-bordered table-hover w-100" style="width:100%">
+47 -34
View File
@@ -77,39 +77,25 @@
<div class="card shadow-sm mb-3">
<div class="card-header bg-white d-flex flex-wrap align-items-center justify-content-between gap-3">
<strong class="mb-0">Date Selection</strong>
<?php if ($isEdit): ?>
<div class="text-muted small">
<?php
try {
$displayStart = (new \DateTime($weekStartSelected))->format('M d, Y');
} catch (\Exception $e) {
$displayStart = $weekStartSelected;
}
?>
Week of <?= esc($displayStart ?: 'N/A') ?>
</div>
<input type="hidden" name="week_start" value="<?= esc($weekStartSelected) ?>">
<?php else: ?>
<div class="d-flex align-items-center gap-2">
<select id="weekStartSelect" name="week_start" class="form-select form-select-sm" required>
<option value="">Select week</option>
<?php foreach ($sundayOptions as $sunday): ?>
<?php
try {
$startDt = new \DateTime($sunday);
$displayStart = $startDt->format('M d, Y');
} catch (\Exception $e) {
$displayStart = $sunday;
}
?>
<option value="<?= esc($sunday) ?>" <?= $sunday === $weekStartSelected ? 'selected' : '' ?>>
<?= esc($displayStart) ?>
</option>
<?php endforeach; ?>
</select>
<div class="invalid-feedback">Week start is required.</div>
</div>
<?php endif; ?>
<div class="d-flex align-items-center gap-2">
<select id="weekStartSelect" name="week_start" class="form-select form-select-sm" required <?= $isEdit ? 'data-original-week="' . esc($weekStartSelected) . '"' : '' ?>>
<option value="">Select week</option>
<?php foreach ($sundayOptions as $sunday): ?>
<?php
try {
$startDt = new \DateTime($sunday);
$displayStart = $startDt->format('M d, Y');
} catch (\Exception $e) {
$displayStart = $sunday;
}
?>
<option value="<?= esc($sunday) ?>" <?= $sunday === $weekStartSelected ? 'selected' : '' ?>>
<?= esc($displayStart) ?>
</option>
<?php endforeach; ?>
</select>
<div class="invalid-feedback">Week start is required.</div>
</div>
</div>
<div class="card-body">
<input type="hidden" name="week_end" id="weekEndInput" value="<?= esc($weekEndValue) ?>" required>
@@ -197,6 +183,14 @@
</div>
<div class="form-text small text-muted">Add a custom Surah or Arabic target.</div>
</div>
<?php elseif ($slug === 'islamic'): ?>
<div class="px-2 py-2 border-top">
<div class="input-group input-group-sm">
<input type="text" class="form-control" placeholder="Type subject or topic" data-custom-input data-subject="<?= esc($slug) ?>">
<button type="button" class="btn btn-outline-secondary" data-custom-entry data-subject="<?= esc($slug) ?>">Add</button>
</div>
<div class="form-text small text-muted">Add a subject or unit not listed above (e.g. Seerah, Fiqh, Akhlaq).</div>
</div>
<?php endif; ?>
</div>
</div>
@@ -306,6 +300,19 @@
const forms = document.querySelectorAll('.needs-validation');
Array.from(forms).forEach(form => {
form.addEventListener('submit', event => {
const originalWeek = weekStartSelect?.dataset.originalWeek || '';
if (originalWeek && weekStartSelect && weekStartSelect.value && weekStartSelect.value !== originalWeek) {
const ok = confirm('A progress report already exists for the original week. Change the week and override any existing report for the new date?');
if (!ok) {
event.preventDefault();
event.stopPropagation();
return;
}
const confirmInput = document.getElementById('confirmOverwriteInput');
if (confirmInput) {
confirmInput.value = '1';
}
}
const islamicUnits = form.querySelectorAll('input[name="unit_islamic[]"]');
const hasIslamicUnit = Array.from(islamicUnits).some(input => input.value.trim() !== '');
if (!hasIslamicUnit) {
@@ -386,6 +393,9 @@
if (isQuran) {
return isCustom ? 'Custom' : 'Surah';
}
if (subject === 'islamic' && isCustom) {
return 'Custom';
}
return parts.join(' ');
};
@@ -446,7 +456,10 @@
if (!input) return;
const customValue = input.value.trim();
if (customValue === '') return;
const unitValue = buildUnitDisplay(subject, '', '', { isQuran: subject === 'quran', isCustom: true });
const unitValue = buildUnitDisplay(subject, '', '', {
isQuran: subject === 'quran',
isCustom: subject === 'quran' || subject === 'islamic',
});
appendUnitChapterRow(subject, unitValue, customValue);
input.value = '';
hideMenus();
+287 -14
View File
@@ -1,34 +1,307 @@
<?= $this->extend('layout/main_layout') ?>
<?= $this->section('content') ?>
<div class="container-xxl py-5">
<?php
$statusBadges = $statusBadges ?? [];
$drafts = $drafts ?? [];
$legacyExams = $legacyExams ?? [];
$assignments = $assignments ?? [];
$selectedClassSection = $selectedClassSection ?? 0;
$examTypes = $examTypes ?? [];
$schoolYear = $schoolYear ?? '';
$semester = $semester ?? '';
$maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
?>
<style>
.teacher-drafts-page,
.teacher-drafts-page * {
font-family: Arial, sans-serif !important;
}
.teacher-drafts-table {
table-layout: auto;
width: max-content;
}
.teacher-drafts-table th {
min-width: 0;
}
.teacher-drafts-table td {
max-width: none;
}
.teacher-drafts-table th {
white-space: nowrap;
}
.teacher-drafts-table td.title-cell {
white-space: nowrap;
}
</style>
<div class="container-xxl py-5 teacher-drafts-page">
<div class="container">
<h1>Drafts</h1>
<?php if (!empty($draftMessages)): ?>
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4">
<h1 class="mb-0">Exam drafts</h1>
<?php if (!empty($legacyExams)): ?>
<button type="button" class="btn btn-sm btn-outline-secondary" id="legacyToggle">Legacy Exams</button>
<?php endif; ?>
</div>
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<div class="card mb-4">
<div class="card-body">
<?= form_open_multipart(base_url('teacher/exam-drafts'), ['class' => 'row g-3', 'id' => 'examDraftForm']) ?>
<?= csrf_field() ?>
<div class="col-md-6">
<label class="form-label" for="exam_type">Exam type <span class="text-danger">*</span></label>
<select name="exam_type" id="exam_type" class="form-select" required>
<option value="">— Select —</option>
<?php foreach ($examTypes as $t): ?>
<option value="<?= esc($t) ?>" <?= old('exam_type') === $t ? 'selected' : '' ?>><?= esc($t) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-8">
<label class="form-label" for="author_comment">Author comment</label>
<textarea name="author_comment" id="author_comment" class="form-control" rows="2" placeholder="Optional note for reviewers"><?= esc(old('author_comment') ?? '') ?></textarea>
</div>
<div class="col-md-8">
<label class="form-label" for="draft_file">File (Word) <span class="text-danger">*</span></label>
<input type="file" name="draft_file" id="draft_file" class="form-control" accept=".doc,.docx" required>
<div class="form-text">Max <?= esc(number_format($maxUploadBytes / 1048576, 1)) ?> MB.</div>
</div>
<div class="col-12 d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-primary">Submit for review</button>
</div>
<?= form_close() ?>
<?php if ($schoolYear !== '' || $semester !== ''): ?>
<p class="text-muted small mb-0 mt-2">School year: <?= esc($schoolYear) ?> · Semester: <?= esc($semester) ?></p>
<?php endif; ?>
</div>
</div>
<?php
$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']) . ' js-status-badge" data-status="' . esc($status) . '"' . $style . '>' . esc($b['label']) . '</span>';
};
?>
<h2 class="h5 mb-3">Your submissions</h2>
<?php if (!empty($drafts)): ?>
<div class="table-responsive">
<table class="table table-striped">
<table class="table table-striped align-middle teacher-drafts-table">
<thead>
<tr>
<th>Subject</th>
<th>Date</th>
<th>Actions</th>
<th>Class</th>
<th>Title / type</th>
<th>Ver.</th>
<th>Status</th>
<th>File link</th>
<th>Reviewer comment</th>
<th>Last Update</th>
</tr>
</thead>
<tbody>
<?php foreach ($draftMessages as $message): ?>
<tr>
<td><?= esc($message['subject']) ?></td>
<td><?= esc(!empty($message['created_at']) ? local_datetime($message['created_at'], 'm-d-Y H:i') : '') ?></td>
<?php foreach ($drafts as $d): ?>
<?php
$st = strtolower((string) ($d['status'] ?? ''));
$badgeHtml = $renderBadge($st, $statusBadges);
?>
<tr data-draft-id="<?= (int) ($d['id'] ?? 0) ?>">
<td><?= esc($d['class_section_name'] ?? '') ?></td>
<td class="title-cell"><?= esc($d['draft_title'] ?? $d['exam_type'] ?? '') ?></td>
<td><?= esc((string) ($d['version'] ?? '')) ?></td>
<td><?= $badgeHtml ?></td>
<td>
<a href="<?= base_url('messages/edit/' . $message['id']) ?>">Edit</a> |
<a href="<?= base_url('messages/delete/' . $message['id']) ?>">Delete</a>
<?php $revNumber = max(1, (int) ($d['version'] ?? 1)); ?>
<?php if (!empty($d['teacher_file'])): ?>
<div>
<a href="<?= base_url('exam-drafts/files/teacher/' . $d['teacher_file']) ?>" target="_blank" rel="noopener">
<?= esc('Ver' . $revNumber . ' ' . ($d['teacher_filename'] ?? 'Submitted draft')) ?>
</a>
</div>
<?php endif; ?>
<?php if (!empty($d['final_file'])): ?>
<div class="mt-1">
<a href="<?= base_url('exam-drafts/files/final/' . $d['final_file']) ?>" target="_blank" rel="noopener" class="link-success small">
<?= esc('Final ' . ($d['final_filename'] ?? 'Final draft')) ?>
</a>
</div>
<?php endif; ?>
<?php if (!empty($d['review_files'])): ?>
<div class="mt-1">
<?php
$reviewLinks = [];
foreach ($d['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; ?>
<?php if (empty($d['teacher_file']) && empty($d['final_file'])): ?>
<?php endif; ?>
</td>
<td><?= nl2br(esc($d['reviewer_comment'] ?? $d['reviewer_comments'] ?? $d['admin_comments'] ?? '—')) ?></td>
<td><?= esc(!empty($d['updated_at']) ? local_datetime($d['updated_at'], 'm-d-Y H:i') : '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<p>No drafts found.</p>
<p class="text-muted">No submissions yet.</p>
<?php endif; ?>
<?php if (!empty($legacyExams)): ?>
<div id="legacySection" class="table-responsive d-none mt-4">
<table class="table table-sm table-striped align-middle teacher-drafts-table">
<thead>
<tr>
<th>Class</th>
<th>Title</th>
<th>Ver.</th>
<th>Status</th>
<th>School year</th>
<th>Semester</th>
<th>Exam type</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($legacyExams as $d): ?>
<?php $st = strtolower((string) ($d['status'] ?? '')); ?>
<tr>
<td><?= esc($d['class_section_name'] ?? '') ?></td>
<td class="title-cell"><?= esc($d['draft_title'] ?? $d['exam_type'] ?? '') ?></td>
<td><?= esc((string) ($d['version'] ?? '')) ?></td>
<td><?= $renderBadge($st, $statusBadges) ?></td>
<td><?= esc($d['school_year'] ?? '') ?></td>
<td><?= esc($d['semester'] ?? '') ?></td>
<td><?= esc($d['exam_type'] ?? '') ?></td>
<td><?= esc(!empty($d['updated_at']) ? local_datetime($d['updated_at'], 'm-d-Y H:i') : '') ?></td>
<td>
<?php if (!empty($d['final_file'])): ?>
<a class="btn btn-sm btn-outline-primary" href="<?= base_url('exam-drafts/files/final/' . $d['final_file']) ?>" target="_blank" rel="noopener">View</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
document.addEventListener('DOMContentLoaded', () => {
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) {
return;
}
const tokenInput = form.querySelector(`input[name="${csrfTokenName}"]`);
if (tokenInput) {
tokenInput.value = tokenValue;
}
};
const draftForm = document.getElementById('examDraftForm');
if (draftForm) {
draftForm.addEventListener('submit', () => syncCsrfToken(draftForm));
}
const statusBadges = <?= json_encode($statusBadges, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
const rows = new Map();
document.querySelectorAll('tr[data-draft-id]').forEach((row) => {
const id = row.getAttribute('data-draft-id');
if (id) {
rows.set(id, row);
}
});
const updateRow = (row, status, acceptanceType) => {
const badge = row.querySelector('.js-status-badge');
if (badge) {
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 note = row.querySelector('.js-acceptance-note');
if (note) {
if (status === 'accepted' && acceptanceType) {
note.textContent = acceptanceType === 'minor_edits' ? 'With minor edits' : 'As is';
} else {
note.textContent = '—';
}
}
};
const poll = () => {
fetch('<?= base_url('teacher/exam-drafts/status') ?>', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then((resp) => resp.ok ? resp.json() : null)
.then((data) => {
if (!data || !Array.isArray(data.drafts)) {
return;
}
data.drafts.forEach((draft) => {
const row = rows.get(String(draft.id));
if (row) {
updateRow(row, draft.status, draft.acceptance_type);
}
});
})
.catch(() => {});
};
if (rows.size > 0) {
poll();
setInterval(poll, 15000);
}
const legacyToggle = document.getElementById('legacyToggle');
const legacySection = document.getElementById('legacySection');
if (legacyToggle && legacySection) {
legacyToggle.addEventListener('click', () => {
legacySection.classList.toggle('d-none');
});
}
});
</script>
<?= $this->endSection() ?>
+9
View File
@@ -308,6 +308,7 @@
id="submitScoresLockBtn"
formaction="<?= base_url('/teacher/submit-scores-lock') ?>"
formmethod="post"
data-confirm-message="Once you submit, you cannot add any scores or comments. Do you want to continue?"
<?= $scoresLocked ? 'disabled' : '' ?>>
<?= $scoresLocked ? 'Scores Locked' : 'Submit Semester Scores' ?>
</button>
@@ -430,6 +431,14 @@
form.addEventListener('submit', function(event) {
syncCsrfToken();
const submitter = event.submitter;
if (submitter && submitter.id === 'submitScoresLockBtn') {
const message = submitter.dataset.confirmMessage || 'Submit semester scores?';
if (!confirm(message)) {
event.preventDefault();
return;
}
}
const errors = [];
form.querySelectorAll('textarea[data-first-name]').forEach(function(field) {
const value = field.value.trim();
+7 -7
View File
@@ -194,22 +194,22 @@
<h3 class="text-white mb-4">Photo Gallery</h3>
<div class="row g-2 pt-2">
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-1.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-2.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-3.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-4.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-5.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('"assets/images/classes-6.jpg') ?>" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
</div>
</div>
</div>
@@ -279,4 +279,4 @@
</body>
</html>
</html>
+7 -7
View File
@@ -128,22 +128,22 @@
<h3 class="text-white mb-4">Photo Gallery</h3>
<div class="row g-2 pt-2">
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-1.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-2.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-3.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-4.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-5.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-6.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
</div>
</div>
</div>
@@ -200,4 +200,4 @@
<script src="js/main.js"></script>
</body>
</html>
</html>
+7 -7
View File
@@ -180,22 +180,22 @@
<h3 class="text-white mb-4">Photo Gallery</h3>
<div class="row g-2 pt-2">
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-1.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-1.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-2.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-2.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-3.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-3.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-4.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-4.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-5.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-5.jpg') ?>" alt="">
</div>
<div class="col-4">
<img class="img-fluid rounded bg-light p-1" src=""assets/images/classes-6.jpg" alt="">
<img class="img-fluid rounded bg-light p-1" src="<?= base_url('assets/images/classes-6.jpg') ?>" alt="">
</div>
</div>
</div>
@@ -252,4 +252,4 @@
<script src="js/main.js"></script>
</body>
</html>
</html>
+4 -19
View File
@@ -1,25 +1,10 @@
<?php
use CodeIgniter\Exceptions\PageNotFoundException;
if (!function_exists('base_url')) {
throw new PageNotFoundException('base_url function not found');
}
$session = session();
if ($session->get('isLoggedIn')) {
header('Location: ' . base_url('/dashboard'));
exit();
}
?>
<?= $this->extend('layout/register_layout') ?>
<?= $this->section('content') ?>
<div class="registration-form container mt-5 mb-5">
<form id="loginForm" method="post" action="<?= base_url('/user/login') ?>">
<?= csrf_field() ?>
<input type="hidden" name="redirect_to" value="<?= esc(old('redirect_to', $redirect_to ?? '')) ?>">
<div class="text-center mb-4">
<a href="<?= base_url('/') ?>">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt=""
@@ -60,9 +45,9 @@ if ($session->get('isLoggedIn')) {
<button type="submit" class="btn btn-success item">Login</button>
</div>
<!-- Flash Error Message -->
<?php if ($session->getFlashdata('error')): ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger mt-3 text-center" role="alert">
<?= esc($session->getFlashdata('error')) ?>
<?= esc(session()->getFlashdata('error')) ?>
</div>
<?php endif; ?>
<!-- Forgot Password -->
@@ -125,4 +110,4 @@ if ($session->get('isLoggedIn')) {
}
});
</script>
<?= $this->endSection() ?>
<?= $this->endSection() ?>
+20 -16
View File
@@ -38,9 +38,9 @@
<div id="collapse<?= $index ?>" class="collapse <?= $index === 0 ? 'show' : '' ?>" aria-labelledby="heading<?= $index ?>" data-bs-parent="#classAccordion">
<div class="card-body">
<!--h5>Semester: <--?= esc($classSection['semester'] ?? '') ?></h5-->
<!--h5>School Year: <--?= esc($classSection['school_year'] ?? '') ?></h5-->
<!--p><--?= esc($classSection['description'] ?? '') ?></p-->
<!-- <h5>Semester: <?= esc($classSection['semester'] ?? '') ?></h5> -->
<!-- <h5>School Year: <?= esc($classSection['school_year'] ?? '') ?></h5> -->
<!-- <p><?= esc($classSection['description'] ?? '') ?></p> -->
<?php if (!empty($classSection['parents'])): ?>
<div class="table-responsive">
@@ -92,13 +92,15 @@
<option value="0" <?= ((string)$pm === '0') ? 'selected' : '' ?>>No</option>
</select>
</td>
<!--td>
<--?php if (!empty($p['primary_email'])): ?>
<a href="mailto:<--?= esc($p['primary_email']) ?>"><--?= esc($p['primary_email']) ?></a>
<--?php else: ?>
<!--
<td>
<?php if (!empty($p['primary_email'])): ?>
<a href="mailto:<?= esc($p['primary_email']) ?>"><?= esc($p['primary_email']) ?></a>
<?php else: ?>
<span class="text-muted">—</span>
<--?php endif; ?>
</td-->
<?php endif; ?>
</td>
-->
<td><?= esc($p['second_name']) ?></td>
<td>
<?php if (!empty($p['second_phone'])): ?>
@@ -109,13 +111,15 @@
<span class="text-muted">—</span>
<?php endif; ?>
</td>
<!--td>
<--?php if (!empty($p['second_email'])): ?>
<--a href="mailto:<--?= esc($p['second_email']) ?>"><--?= esc($p['second_email']) ?></a>
<--?php else: ?>
<--span class="text-muted">—</span>
<--?php endif; ?>
</td-->
<!--
<td>
<?php if (!empty($p['second_email'])): ?>
<a href="mailto:<?= esc($p['second_email']) ?>"><?= esc($p['second_email']) ?></a>
<?php else: ?>
<span class="text-muted">—</span>
<?php endif; ?>
</td>
-->
<td class="text-center">
<?php if (!empty($p['second_id'])): ?>
-1
View File
@@ -1 +0,0 @@
{"version":2,"defects":{"Tests\\App\\Controllers\\AuthControllerTest::testLoginReturnsValidResponse":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testDashboardSuccess":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testAbsenceInfoWithValidUser":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testAbsenceInfoUnauthorized":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testSubmitAbsenceRejectsMissingReason":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testSubmitAbsenceValidDateAndReason":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testSearchRequiresQueryParam":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testSearchReturnsStructuredResults":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testEnrollmentWithdrawalPagination":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testEnrollmentWithdrawalDataBuildsStudentList":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testUpdateEnrollmentStatusesRejectsEmptyPayload":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testUpdateEnrollmentStatusesWithValidStatus":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testStudentProfilesReturnsList":8,"Tests\\App\\Controllers\\Api\\AdministratorControllerTest::testParentProfilesWithParentRole":8,"Tests\\App\\Controllers\\Api\\EmergencyContactControllerTest::testIndexFiltersByParent":8,"Tests\\App\\Controllers\\Api\\EmergencyContactControllerTest::testCreateValidContact":8,"Tests\\App\\Controllers\\Api\\EmergencyContactControllerTest::testCreateInvalidPayload":8,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testGetGuardiansReturnsDataWhenFamilyExists":8,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testGetByStudentWrapsStudentsAndGuardians":8,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testIndexUsesPaginatedResult":8,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testShowReturnsFamily":8,"Tests\\App\\Controllers\\Api\\ParentControllerTest::testShowReturnsParentById":8,"Tests\\App\\Controllers\\Api\\ParentControllerTest::testShowMissingParentReturnsError":8,"Tests\\App\\Controllers\\Api\\ParentControllerTest::testGetStudentsReturnsStudentList":8,"Tests\\App\\Controllers\\Api\\ParentControllerTest::testGetStudentsMissingParent":8,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testRolePermissionsReturnsDataForRole":8,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testUsersListReturnsSummaries":8,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testAssignRoleSuccess":8,"Tests\\App\\Controllers\\Api\\SupplierControllerTest::testShowReturnsNotFound":8,"Tests\\App\\Controllers\\Api\\SupplierControllerTest::testCreateValidSupplier":8,"Tests\\App\\Controllers\\Api\\SupplierControllerTest::testUpdateSupplierPersistsFields":8,"Tests\\App\\Controllers\\Api\\SupplierControllerTest::testDeleteSupplier":8},"times":{"Tests\\App\\Controllers\\AuthControllerTest::testLoginReturnsValidResponse":0.032,"Tests\\App\\Controllers\\Api\\EmergencyContactControllerTest::testIndexFiltersByParent":0.002,"Tests\\App\\Controllers\\Api\\EmergencyContactControllerTest::testShowReturnsNotFound":0.004,"Tests\\App\\Controllers\\Api\\EmergencyContactControllerTest::testCreateValidContact":0.002,"Tests\\App\\Controllers\\Api\\EmergencyContactControllerTest::testCreateInvalidPayload":0.001,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testGetGuardiansReturnsDataWhenFamilyExists":0.002,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testGetGuardiansReturnsErrorWhenFamilyMissing":0.001,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testGetByStudentWrapsStudentsAndGuardians":0.002,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testIndexUsesPaginatedResult":0.002,"Tests\\App\\Controllers\\Api\\FamilyControllerTest::testShowReturnsFamily":0.002,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testRolesListReturnsData":0.002,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testPermissionsListReturnsData":0.001,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testRolePermissionsReturnsDataForRole":0.002,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testUsersListReturnsSummaries":0.002,"Tests\\App\\Controllers\\Api\\RolePermissionControllerTest::testAssignRoleSuccess":0.003,"Tests\\App\\Controllers\\Api\\BroadcastEmailControllerTest::testSendValidationFails":0.005,"Tests\\App\\Controllers\\Api\\BroadcastEmailControllerTest::testSendStoresSentAndFailedRecipients":0.003,"Tests\\App\\Controllers\\Api\\BroadcastEmailControllerTest::testParentsReturnsParentList":0.001}}
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="Tests\App\Controllers\Api\BroadcastEmailControllerTest" file="/opt/lampp/htdocs/alrahma_school_sunday/tests/app/Controllers/Api/BroadcastEmailControllerTest.php" tests="3" assertions="15" errors="0" failures="0" skipped="0" time="0.023977">
<testcase name="testSendValidationFails" file="/opt/lampp/htdocs/alrahma_school_sunday/tests/app/Controllers/Api/BroadcastEmailControllerTest.php" line="132" class="Tests\App\Controllers\Api\BroadcastEmailControllerTest" classname="Tests.App.Controllers.Api.BroadcastEmailControllerTest" assertions="3" time="0.016935"/>
<testcase name="testSendStoresSentAndFailedRecipients" file="/opt/lampp/htdocs/alrahma_school_sunday/tests/app/Controllers/Api/BroadcastEmailControllerTest.php" line="144" class="Tests\App\Controllers\Api\BroadcastEmailControllerTest" classname="Tests.App.Controllers.Api.BroadcastEmailControllerTest" assertions="9" time="0.004336"/>
<testcase name="testParentsReturnsParentList" file="/opt/lampp/htdocs/alrahma_school_sunday/tests/app/Controllers/Api/BroadcastEmailControllerTest.php" line="167" class="Tests\App\Controllers\Api\BroadcastEmailControllerTest" classname="Tests.App.Controllers.Api.BroadcastEmailControllerTest" assertions="3" time="0.002707"/>
</testsuite>
</testsuites>
-61
View File
@@ -1,61 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>Test Documentation</title>
<style>
body {
text-rendering: optimizeLegibility;
font-family: Source SansSerif Pro, Arial, sans-serif;
font-variant-ligatures: common-ligatures;
font-kerning: normal;
margin-left: 2rem;
background-color: #fff;
color: #000;
}
body > ul > li {
font-size: larger;
}
h2 {
font-size: larger;
text-decoration-line: underline;
text-decoration-thickness: 2px;
margin: 0;
padding: 0.5rem 0;
}
ul {
list-style: none;
margin: 0 0 2rem;
padding: 0 0 0 1rem;
text-indent: -1rem;
}
.success:before {
color: #4e9a06;
content: '✓';
padding-right: 0.5rem;
}
.defect {
color: #a40000;
}
.defect:before {
color: #a40000;
content: '✗';
padding-right: 0.5rem;
}
</style>
</head>
<body>
<h2>Broadcast Email Controller (Tests\App\Controllers\Api\BroadcastEmailController)</h2>
<ul>
<li class="success">Send validation fails</li>
<li class="success">Send stores sent and failed recipients</li>
<li class="success">Parents returns parent list</li>
</ul>
</body>
</html>
-5
View File
@@ -1,5 +0,0 @@
Broadcast Email Controller (Tests\App\Controllers\Api\BroadcastEmailController)
[x] Send validation fails
[x] Send stores sent and failed recipients
[x] Parents returns parent list
-125
View File
@@ -1,125 +0,0 @@
#!/usr/bin/env php
<?php
define('LATEST_RELEASE', '^4.0');
define('GITHUB_URL', 'https://github.com/codeigniter4/codeigniter4');
/*
* --------------------------------------------------------------------
* Stability Toggle
* --------------------------------------------------------------------
* Use this script to toggle the CodeIgniter dependency between the
* latest stable release and the most recent development update.
*
* Usage: php builds [release|development]
*/
// Determine the requested stability
if (empty($argv[1]) || ! in_array($argv[1], ['release', 'development'], true)) {
echo 'Usage: php builds [release|development]' . PHP_EOL;
exit;
}
$dev = $argv[1] === 'development';
$modified = [];
// Locate each file and update it for the requested stability
$file = __DIR__ . DIRECTORY_SEPARATOR . 'composer.json';
if (is_file($file)) {
$contents = file_get_contents($file);
if ((string) $contents !== '') {
$array = json_decode($contents, true);
if (is_array($array)) {
if ($dev) {
$array['minimum-stability'] = 'dev';
$array['prefer-stable'] = true;
$array['repositories'] ??= [];
$found = false;
foreach ($array['repositories'] as $repository) {
if ($repository['url'] === GITHUB_URL) {
$found = true;
break;
}
}
if (! $found) {
$array['repositories'][] = [
'type' => 'vcs',
'url' => GITHUB_URL,
];
}
$array['require']['codeigniter4/codeigniter4'] = 'dev-develop';
unset($array['require']['codeigniter4/framework']);
} else {
unset($array['minimum-stability']);
if (isset($array['repositories'])) {
foreach ($array['repositories'] as $i => $repository) {
if ($repository['url'] === GITHUB_URL) {
unset($array['repositories'][$i]);
break;
}
}
if (empty($array['repositories'])) {
unset($array['repositories']);
}
}
$array['require']['codeigniter4/framework'] = LATEST_RELEASE;
unset($array['require']['codeigniter4/codeigniter4']);
}
file_put_contents($file, json_encode($array, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
$modified[] = $file;
} else {
echo 'Warning: Unable to decode composer.json! Skipping...' . PHP_EOL;
}
} else {
echo 'Warning: Unable to read composer.json! Skipping...' . PHP_EOL;
}
}
$files = [
__DIR__ . DIRECTORY_SEPARATOR . 'app/Config/Paths.php',
__DIR__ . DIRECTORY_SEPARATOR . 'phpunit.xml.dist',
__DIR__ . DIRECTORY_SEPARATOR . 'phpunit.xml',
];
foreach ($files as $file) {
if (is_file($file)) {
$contents = file_get_contents($file);
if ($dev) {
$contents = str_replace('vendor/codeigniter4/framework', 'vendor/codeigniter4/codeigniter4', $contents);
} else {
$contents = str_replace('vendor/codeigniter4/codeigniter4', 'vendor/codeigniter4/framework', $contents);
}
file_put_contents($file, $contents);
$modified[] = $file;
}
}
if ($modified === []) {
echo 'No files modified.' . PHP_EOL;
} else {
echo 'The following files were modified:' . PHP_EOL;
foreach ($modified as $file) {
echo " * {$file}" . PHP_EOL;
}
echo 'Run `composer update` to sync changes with your vendor folder.' . PHP_EOL;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Some files were not shown because too many files have changed in this diff Show More