fix enrollment logic, add financial aid, fix class distribution
Tests / PHPUnit (push) Failing after 1m6s

This commit is contained in:
root
2026-08-15 15:07:16 -04:00
parent c12bb59372
commit 4603d9ced2
95 changed files with 6892 additions and 1295 deletions
@@ -0,0 +1,217 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use CodeIgniter\Database\BaseConnection;
use Throwable;
class RefreshEnrollmentAdminTables extends BaseCommand
{
protected $group = 'Registration';
protected $name = 'registration:refresh-admin-tables';
protected $description = 'Refresh enrollment admin dashboard data from the transition service.';
protected $usage = 'php spark registration:refresh-admin-tables [--school-year=2026-2027] [--limit=50] [--dry-run]';
protected $options = [
'--school-year' => 'Target school year name. Defaults to the active/current configured year.',
'--limit' => 'Maximum number of source-year students to process.',
'--dry-run' => 'Preview target/source years and source student count without writing.',
];
private BaseConnection $db;
public function run(array $params)
{
$this->db = \Config\Database::connect();
$options = $this->parseOptions($params);
$schoolYear = trim((string) ($options['school-year'] ?? ''));
if ($schoolYear === '') {
$schoolYear = $this->currentSchoolYear();
}
$sourceYear = $this->previousSchoolYearName($schoolYear);
if ($schoolYear === '' || $sourceYear === null) {
CLI::error('Unable to determine target/source school year.');
return;
}
$limit = (int) ($options['limit'] ?? 0);
$dryRun = ! empty($options['dry-run']);
$students = $this->sourceStudents($sourceYear, $limit);
CLI::write('Enrollment admin refresh', 'cyan');
CLI::write('Target year: ' . $schoolYear);
CLI::write('Source year: ' . $sourceYear);
CLI::write('Source students: ' . count($students));
$before = $this->dashboardCounts($schoolYear);
$this->printCounts('Before', $before);
if ($dryRun) {
CLI::write('Dry-run complete. Re-run without --dry-run to write updates.', 'yellow');
return;
}
$processed = 0;
$eligible = 0;
$blocked = 0;
$errors = 0;
$transitionService = service('enrollmentTransition');
foreach ($students as $student) {
$studentId = (int) ($student['student_id'] ?? 0);
if ($studentId <= 0) {
continue;
}
try {
$evaluation = $transitionService->applyInitialTransition(
$studentId,
$sourceYear,
$schoolYear,
is_numeric($student['parent_id'] ?? null) ? (int) $student['parent_id'] : null,
null,
'admin'
);
$processed++;
if (! empty($evaluation['academic_eligible']) && ($evaluation['blockers'] ?? []) === []) {
$eligible++;
} else {
$blocked++;
}
} catch (Throwable $e) {
$errors++;
CLI::error('Student #' . $studentId . ' failed: ' . $e->getMessage());
}
}
$after = $this->dashboardCounts($schoolYear);
$this->printCounts('After', $after);
CLI::write('Processed: ' . $processed, 'white');
CLI::write('Eligible/applied: ' . $eligible, 'green');
CLI::write('Blocked/flagged: ' . $blocked, $blocked > 0 ? 'yellow' : 'white');
CLI::write('Errors: ' . $errors, $errors > 0 ? 'red' : 'white');
}
private function parseOptions(array $params): array
{
$options = [
'school-year' => '',
'limit' => 0,
'dry-run' => false,
];
$rawParams = array_merge($params, array_slice($_SERVER['argv'] ?? [], 2));
foreach ($rawParams as $param) {
$value = trim((string) $param);
if ($value === '--dry-run') {
$options['dry-run'] = true;
continue;
}
if (! str_starts_with($value, '--') || ! str_contains($value, '=')) {
continue;
}
[$key, $raw] = explode('=', substr($value, 2), 2);
if (array_key_exists($key, $options)) {
$options[$key] = $raw;
}
}
return $options;
}
private function sourceStudents(string $sourceYear, int $limit): array
{
if (! $this->db->tableExists('student_class')) {
return [];
}
$builder = $this->db->table('student_class sc')
->select('sc.student_id, s.parent_id')
->join('students s', 's.id = sc.student_id', 'left')
->where('sc.school_year', $sourceYear)
->groupBy('sc.student_id, s.parent_id')
->orderBy('sc.student_id', 'ASC');
if ($limit > 0) {
$builder->limit($limit);
}
return $builder->get()->getResultArray();
}
private function dashboardCounts(string $schoolYear): array
{
return [
'enrollments' => $this->countRows('enrollments', ['school_year' => $schoolYear]),
'open_flags' => $this->countRows('enrollment_flags', ['school_year' => $schoolYear, 'status' => 'open']),
'audits' => $this->countRows('enrollment_transition_audits', ['school_year' => $schoolYear]),
'exceptions' => $this->countRows('enrollment_exceptions', ['school_year' => $schoolYear]),
'email_records' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear]),
];
}
private function countRows(string $table, array $where): int
{
if (! $this->db->tableExists($table)) {
return 0;
}
$builder = $this->db->table($table);
foreach ($where as $field => $value) {
$builder->where($field, $value);
}
return $builder->countAllResults();
}
private function printCounts(string $label, array $counts): void
{
CLI::write($label . ' counts:', 'white');
foreach ($counts as $key => $value) {
CLI::write(' ' . $key . ': ' . $value);
}
}
private function currentSchoolYear(): string
{
if ($this->db->tableExists('school_years')) {
$row = $this->db->table('school_years')
->select('name')
->where('status', 'active')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
if (! empty($row['name'])) {
return (string) $row['name'];
}
}
if ($this->db->tableExists('configuration')) {
$row = $this->db->table('configuration')
->select('config_value')
->where('config_key', 'school_year')
->limit(1)
->get()
->getRowArray();
if (! empty($row['config_value'])) {
return (string) $row['config_value'];
}
}
return '';
}
private function previousSchoolYearName(string $schoolYear): ?string
{
return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)
? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1)
: null;
}
}
+12
View File
@@ -215,4 +215,16 @@ class App extends BaseConfig
// Sessions (also in App.php)
public string $sessionCookieName = '__Host-ci_session';
public bool $sessionRegenerateDestroy = true;
public function __construct()
{
parent::__construct();
$isProduction = ENVIRONMENT === 'production';
$this->forceGlobalSecureRequests = $isProduction;
$this->cookieSecure = $isProduction;
if (! $isProduction) {
$this->cookiePrefix = '';
$this->sessionCookieName = 'ci_session';
}
}
}
+9
View File
@@ -56,6 +56,15 @@ class Cookie extends BaseConfig
*/
public bool $secure = false;
public function __construct()
{
parent::__construct();
$this->secure = ENVIRONMENT === 'production';
if ($this->secure) {
$this->prefix = '__Host-';
}
}
/**
* --------------------------------------------------------------------------
* Cookie HTTPOnly
+2 -2
View File
@@ -34,7 +34,7 @@ class Cors extends BaseConfig
* - ['http://localhost:8080']
* - ['https://www.example.com']
*/
'allowedOrigins' => ['*'], // Allow all origins for mobile apps
'allowedOrigins' => ['*'],
/**
* Origin regex patterns for the `Access-Control-Allow-Origin` header.
@@ -57,7 +57,7 @@ class Cors extends BaseConfig
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
*/
'supportsCredentials' => true, // Enable for mobile apps using cookies/auth
'supportsCredentials' => false,
/**
* Set headers to allow.
+1 -1
View File
@@ -29,7 +29,7 @@ class Database extends Config
'DBDriver' => env('database.default.DBDriver', 'MySQLi'),
'DBPrefix' => '',
'pConnect' => false,
'DBDebug' => (ENVIRONMENT !== 'development'),
'DBDebug' => (ENVIRONMENT === 'development'),
'charset' => 'utf8',
'DBCollat' => 'utf8_general_ci',
'swapPre' => '',
+9
View File
@@ -23,6 +23,15 @@ class Encryption extends BaseConfig
*/
public string $key = '';
public function __construct()
{
parent::__construct();
$fromEnv = (string) env('encryption.key', env('ENCRYPTION_KEY', ''));
if ($fromEnv !== '') {
$this->key = $fromEnv;
}
}
/**
* --------------------------------------------------------------------------
* Encryption Driver to Use
-4
View File
@@ -50,10 +50,6 @@ class Filters extends BaseConfig
'invalidchars',
'schoolYearWritable',
'csrf' => ['except' => [
// WhatsApp membership management (legacy allowances retained)
'whatsapp/update-membership',
'index.php/whatsapp/update-membership',
// Attendance management AJAX saves
'attendance/update',
'index.php/attendance/update',
+99 -93
View File
@@ -115,6 +115,12 @@ $routes->post('administrator/enrollment-admin/flags/(:num)/resolve', 'View\Enrol
$routes->post('administrator/enrollment-admin/flags/(:num)/assign-class', 'View\EnrollmentAdminController::assignClass/$1', ['filter' => 'auth:admin']);
$routes->post('administrator/enrollment-admin/flags/(:num)/makeup-promotion', 'View\EnrollmentAdminController::confirmMakeupPromotion/$1', ['filter' => 'auth:admin']);
$routes->post('administrator/enrollment-admin/flags/(:num)/approve-exception', 'View\EnrollmentAdminController::approveException/$1', ['filter' => 'auth:admin']);
$routes->post('administrator/enrollment-admin/exceptions/create', 'View\EnrollmentAdminController::createException', ['filter' => 'auth:admin']);
$routes->post('administrator/enrollment-admin/exceptions/(:num)/revoke', 'View\EnrollmentAdminController::revokeException/$1', ['filter' => 'auth:admin']);
$routes->get('administrator/financial-aid', 'Administrator\FinancialAidController::index', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
$routes->get('administrator/financial-aid/(:num)', 'Administrator\FinancialAidController::show/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
$routes->post('administrator/financial-aid/(:num)/approve', 'Administrator\FinancialAidController::approve/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
$routes->post('administrator/financial-aid/(:num)/deny', 'Administrator\FinancialAidController::deny/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']);
// API for report card meta (students, class sections, school years)
$routes->get('api/printables/report-card/meta', 'View\ReportCardsController::reportCardMeta', ['filter' => 'auth']);
$routes->get('api/printables/report-card/completeness', 'View\ReportCardsController::reportCardCompleteness', ['filter' => 'auth']);
@@ -212,6 +218,7 @@ $routes->get('/classes', 'View\UserController::classes'); // Classes page route
$routes->get('/contact', 'View\UserController::contact'); // Contact Us page route
$routes->post('/user/login', 'AuthController::login');
$routes->post('api/login', 'AuthController::apiLogin');
$routes->get('admin/api/v1/admin/auth/me', 'AuthController::adminAuthMe');
$routes->get('/welcome_back', 'View\UserController::welcomeBack');
$routes->get('user/forgot_password', 'View\UserController::forgotPassword'); //display forgot password form
@@ -571,32 +578,29 @@ $routes->post('grading/release-scores', 'View\GradingController::toggleParentSco
$routes->post('grading/refresh-semester-scores', 'View\GradingController::refreshSemesterScores', ['filter' => 'auth:read']);
// Event admin routes
$routes->get('administrator/events', 'View\EventController::index');
$routes->get('administrator/events/create', 'View\EventController::create'); // show create form
$routes->post('administrator/events/create', 'View\EventController::create'); // handle event create submission
// Route to show form and handle POST in same controller method
$routes->match(['get', 'post'], 'administrator/events/edit/(:num)', 'View\EventController::edit/$1');
$routes->post('administrator/events/delete/(:num)', 'View\EventController::delete/$1'); // handle delete
$routes->get('administrator/events', 'View\EventController::index', ['filter' => 'auth:admin']);
$routes->get('administrator/events/create', 'View\EventController::create', ['filter' => 'auth:admin']);
$routes->post('administrator/events/create', 'View\EventController::create', ['filter' => 'auth:admin']);
$routes->match(['get', 'post'], 'administrator/events/edit/(:num)', 'View\EventController::edit/$1', ['filter' => 'auth:admin']);
$routes->post('administrator/events/delete/(:num)', 'View\EventController::delete/$1', ['filter' => 'auth:admin']);
$routes->post('payment/event_charges', 'View\EventController::eventUpdate', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
// Parent event participation
$routes->get('administrator/event-charges', 'View\EventController::eventShow');
$routes->get('administrator/event-charges/pdf', 'View\EventController::eventChargesPdf');
$routes->get('administrator/event-charges', 'View\EventController::eventShow', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('administrator/event-charges/pdf', 'View\EventController::eventChargesPdf', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('administrator/get-students-with-charges', 'View\EventController::getStudentsWithCharges');
$routes->get('administrator/get-students-with-charges', 'View\EventController::getStudentsWithCharges', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$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
$routes->post('parent/updateParticipation', 'View\ParentController::updateParticipation', ['filter' => 'auth:parent']);
// Web View Routes for Invoices
$routes->get('invoices/getByParent/(:num)', 'View\InvoiceController::getByParent/$1');
$routes->get('invoices/create', 'View\InvoiceController::create');
$routes->get('invoices/getByParent/(:num)', 'View\InvoiceController::getByParent/$1', ['filter' => 'auth:parent|view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('invoices/create', 'View\InvoiceController::create', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
$routes->post('invoices/updateStatus/(:num)', 'View\InvoiceController::updateStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
// app/Config/Routes.php
@@ -639,8 +643,8 @@ $routes->group('admin', ['filter' => 'auth'], static function ($routes) {
$routes->group('admin', ['filter' => 'auth'], static function ($routes) {
$routes->get('broadcast-email', 'View\BroadcastEmailController::index');
$routes->post('broadcast-email/send', 'View\BroadcastEmailController::send');
$routes->post('broadcast-email/upload-image', 'View\BroadcastEmailController::uploadImage');
});
$routes->post('admin/broadcast-email/upload-image', 'View\BroadcastEmailController::uploadImage');
$routes->get('payment/financial_report', 'View\FinancialController::financialReport', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
@@ -651,7 +655,7 @@ $routes->get('payment/download_csv', 'View\FinancialController::downloadCsv', ['
$routes->get('administrator/tuition-forecast', 'View\TuitionForecastController::index', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->post('administrator/tuition-forecast/calculate', 'View\TuitionForecastController::calculate', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('administrator/tuition-forecast/export', 'View\TuitionForecastController::exportCsv', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('reports/downloadFinancialReport', 'View\FinancialController::downloadFinancialReport');
$routes->get('reports/downloadFinancialReport', 'View\FinancialController::downloadFinancialReport', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
// Financial APIs (JSON)
$routes->get('api/financial/report', 'View\FinancialController::financialReportData', ['filter' => 'auth']);
$routes->get('api/financial/summary', 'View\FinancialController::financialReportSummaryData', ['filter' => 'auth']);
@@ -685,20 +689,20 @@ $routes->get('reimbursements/export', 'View\ReimbursementController::export', ['
$routes->get('reimbursements', 'View\ReimbursementController::index', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
// Health check (upload dirs + DB timezone columns)
$routes->get('admin/health', 'View\HealthController::index');
$routes->get('admin/health', 'View\HealthController::index', ['filter' => 'auth:admin']);
//Notifications
$routes->get('notifications/active', 'View\NotificationsController::listActive');
$routes->get('api/notifications/active', 'View\NotificationsController::activeNotificationsData');
$routes->get('notifications/deleted', 'View\NotificationsController::listDeleted');
$routes->get('api/notifications/deleted', 'View\NotificationsController::deletedNotificationsData');
$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1');
$routes->get('notifications/active', 'View\NotificationsController::listActive', ['filter' => 'auth:admin']);
$routes->get('api/notifications/active', 'View\NotificationsController::activeNotificationsData', ['filter' => 'auth:admin']);
$routes->get('notifications/deleted', 'View\NotificationsController::listDeleted', ['filter' => 'auth:admin']);
$routes->get('api/notifications/deleted', 'View\NotificationsController::deletedNotificationsData', ['filter' => 'auth:admin']);
$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1', ['filter' => 'auth:admin']);
//$routes->post('notifications/restore/(:num)', 'View\NotificationsController::restore/$1');
//$routes->get('notifications/mark-read/(:num)', 'View\NotificationsController::markAsRead/$1');
$routes->get('/administrator/notifications_alerts', 'View\AdministratorController::notificationsAlerts');
$routes->post('/administrator/notifications_alerts/save', 'View\AdministratorController::saveNotificationSubjects');
$routes->get('/administrator/print-notifications', 'View\AdministratorController::printNotificationRecipients');
$routes->post('/administrator/print-notifications/save', 'View\AdministratorController::savePrintNotificationRecipients');
$routes->get('/administrator/notifications_alerts', 'View\AdministratorController::notificationsAlerts', ['filter' => 'auth:admin']);
$routes->post('/administrator/notifications_alerts/save', 'View\AdministratorController::saveNotificationSubjects', ['filter' => 'auth:admin']);
$routes->get('/administrator/print-notifications', 'View\AdministratorController::printNotificationRecipients', ['filter' => 'auth:admin']);
$routes->post('/administrator/print-notifications/save', 'View\AdministratorController::savePrintNotificationRecipients', ['filter' => 'auth:admin']);
$routes->get(
@@ -732,6 +736,8 @@ $routes->post(
['filter' => 'auth:create_invoice']
);
$routes->get('/parent/invoice_payment', 'View\InvoiceController::invoicePayment', ['filter' => 'auth:parent']); // Route for invoice payment page
$routes->get('/parent/financial-aid', 'View\ParentFinancialAidController::index', ['filter' => 'auth:parent']);
$routes->post('/parent/financial-aid', 'View\ParentFinancialAidController::submit', ['filter' => 'auth:parent']);
$routes->get('invoice/pay/(:num)', 'View\InvoiceController::payInvoice/$1', ['filter' => 'auth:view_invoice|view_payment|administrator|administrative staff|principal|parent']); // Route for paying the invoice
$routes->get('invoices', 'View\InvoiceController::index', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); // Default route for invoices
$routes->get('invoices/(:any)', 'View\InvoiceController::index/$1', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); // Route with school year filter
@@ -783,20 +789,20 @@ $routes->post('/principal/exam-drafts/upload-legacy', 'View\ExamDraftController:
* routes for View\MessagesController
* --------------------------------------------------------------------
*/
$routes->get('messages', 'View\MessagesController::index');
$routes->post('messages/send', 'View\MessagesController::send');
$routes->get('/messages/inbox', 'View\MessagesController::inbox');
$routes->get('/messages/sent', 'View\MessagesController::sent');
$routes->get('/messages/drafts', 'View\MessagesController::drafts');
$routes->get('/messages/trash', 'View\MessagesController::trash');
$routes->get('messages', 'View\MessagesController::index', ['filter' => 'auth']);
$routes->post('messages/send', 'View\MessagesController::send', ['filter' => 'auth']);
$routes->get('/messages/inbox', 'View\MessagesController::inbox', ['filter' => 'auth']);
$routes->get('/messages/sent', 'View\MessagesController::sent', ['filter' => 'auth']);
$routes->get('/messages/drafts', 'View\MessagesController::drafts', ['filter' => 'auth']);
$routes->get('/messages/trash', 'View\MessagesController::trash', ['filter' => 'auth']);
// Additional routes for handling actions like viewing, sending, deleting, etc.
$routes->get('/messages/view/(:num)', 'View\MessagesController::view/$1');
$routes->post('/messages/send', 'View\MessagesController::send');
$routes->get('/messages/delete/(:num)', 'View\MessagesController::delete/$1');
$routes->get('/messages/deletePermanent/(:num)', 'View\MessagesController::deletePermanent/$1');
$routes->get('/messages/restore/(:num)', 'View\MessagesController::restore/$1');
$routes->get('/messages/getRecipients/(:any)', 'View\MessagesController::getRecipients/$1');
$routes->get('/messages/view/(:num)', 'View\MessagesController::view/$1', ['filter' => 'auth']);
$routes->post('/messages/send', 'View\MessagesController::send', ['filter' => 'auth']);
$routes->get('/messages/delete/(:num)', 'View\MessagesController::delete/$1', ['filter' => 'auth']);
$routes->get('/messages/deletePermanent/(:num)', 'View\MessagesController::deletePermanent/$1', ['filter' => 'auth']);
$routes->get('/messages/restore/(:num)', 'View\MessagesController::restore/$1', ['filter' => 'auth']);
$routes->get('/messages/getRecipients/(:any)', 'View\MessagesController::getRecipients/$1', ['filter' => 'auth']);
/*
* --------------------------------------------------------------------
@@ -841,7 +847,7 @@ $routes->group('api/time', ['namespace' => 'App\Controllers\Api'], static functi
* routes for View\SchoolCalendarController
* --------------------------------------------------------------------
*/
$routes->group('administrator', ['namespace' => 'App\Controllers'], function ($routes) {
$routes->group('administrator', ['namespace' => 'App\Controllers', 'filter' => 'auth:admin'], function ($routes) {
$routes->get('calendar_view', 'View\SchoolCalendarController::index');
$routes->get('calendar_edit/(:num)', 'View\SchoolCalendarController::edit/$1');
$routes->get('calendar_delete/(:num)', 'View\SchoolCalendarController::delete/$1');
@@ -858,52 +864,52 @@ $routes->group('administrator', ['namespace' => 'App\Controllers'], function ($r
* routes for Parent Pages
* --------------------------------------------------------------------
*/
$routes->get('/parent/add_second_parent', 'View\ParentController::addSecondParent');
$routes->get('/parent/viewSecondParent', 'View\ParentController::viewSecondParent');
$routes->post('/parent/saveSecondParent', 'View\ParentController::saveSecondParent');
$routes->get('/parent/add_second_parent', 'View\ParentController::addSecondParent', ['filter' => 'auth:parent']);
$routes->get('/parent/viewSecondParent', 'View\ParentController::viewSecondParent', ['filter' => 'auth:parent']);
$routes->post('/parent/saveSecondParent', 'View\ParentController::saveSecondParent', ['filter' => 'auth:parent']);
$routes->get('/parent/enroll_classes', 'View\ParentController::enrollClasses');
$routes->post('/parent/enroll_classes_handler', 'View\ParentController::enrollClassesHandler');
$routes->get('/parent/enroll_success', 'View\ParentController::enrollSuccess');
$routes->get('/parent/enroll_failure', 'View\ParentController::enrollFailure');
$routes->get('/parent/payment', 'View\ParentController::viewPayments');
$routes->get('/parent/calendar', 'View\SchoolCalendarController::calendarParentView');
$routes->get('/parent/attendance', 'View\ParentController::attendance');
$routes->get('/parent/enroll_classes', 'View\ParentController::enrollClasses', ['filter' => 'auth:parent']);
$routes->post('/parent/enroll_classes_handler', 'View\ParentController::enrollClassesHandler', ['filter' => 'auth:parent']);
$routes->get('/parent/enroll_success', 'View\ParentController::enrollSuccess', ['filter' => 'auth:parent']);
$routes->get('/parent/enroll_failure', 'View\ParentController::enrollFailure', ['filter' => 'auth:parent']);
$routes->get('/parent/payment', 'View\ParentController::viewPayments', ['filter' => 'auth:parent']);
$routes->get('/parent/calendar', 'View\SchoolCalendarController::calendarParentView', ['filter' => 'auth:parent']);
$routes->get('/parent/attendance', 'View\ParentController::attendance', ['filter' => 'auth:parent']);
// Parent reporting: absence/late/early dismissal
$routes->get('parent/report-attendance', 'View\ParentAttendanceReportController::form');
$routes->post('parent/report-attendance', 'View\ParentAttendanceReportController::submit');
$routes->post('parent/report-attendance/update', 'View\ParentAttendanceReportController::update');
$routes->get('attendance/parent-reports', 'View\ParentAttendanceReportController::list');
$routes->get('parent/report-attendance', 'View\ParentAttendanceReportController::form', ['filter' => 'auth:parent']);
$routes->post('parent/report-attendance', 'View\ParentAttendanceReportController::submit', ['filter' => 'auth:parent']);
$routes->post('parent/report-attendance/update', 'View\ParentAttendanceReportController::update', ['filter' => 'auth:parent']);
$routes->get('attendance/parent-reports', 'View\ParentAttendanceReportController::list', ['filter' => 'auth:admin']);
// Admin/Teacher: early dismissal page by month
$routes->get('attendance/early-dismissals', 'View\ParentAttendanceReportController::earlyDismissals');
$routes->get('attendance/early-dismissals', 'View\ParentAttendanceReportController::earlyDismissals', ['filter' => 'auth:admin']);
// Admin/Teacher: add early dismissal
$routes->get('attendance/early-dismissals/new', 'View\ParentAttendanceReportController::addEarlyDismissalForm');
$routes->post('attendance/early-dismissals', 'View\ParentAttendanceReportController::saveEarlyDismissal');
$routes->post('attendance/early-dismissals/signature', 'View\ParentAttendanceReportController::uploadEarlyDismissalSignature');
$routes->get('attendance/early-dismissals/new', 'View\ParentAttendanceReportController::addEarlyDismissalForm', ['filter' => 'auth:admin']);
$routes->post('attendance/early-dismissals', 'View\ParentAttendanceReportController::saveEarlyDismissal', ['filter' => 'auth:admin']);
$routes->post('attendance/early-dismissals/signature', 'View\ParentAttendanceReportController::uploadEarlyDismissalSignature', ['filter' => 'auth:admin']);
// Parent report: client-side check API
$routes->post('api/parent/report-attendance/check', 'View\ParentAttendanceReportController::checkExisting');
$routes->post('api/parent/report-attendance/check', 'View\ParentAttendanceReportController::checkExisting', ['filter' => 'auth:parent']);
$routes->get('/support', 'View\StatsController::support');
$routes->get('no-kids', 'View\ParentController::noKids');
$routes->match(['get', 'post'], 'parent/edit-student/(:num)', 'View\ParentController::editStudent/$1');
$routes->get('parent/edit-all-students', 'View\ParentController::showEditAllStudentsModal');
$routes->post('parent/edit-all-students', 'View\ParentController::updateAllStudents');
$routes->get('no-kids', 'View\ParentController::noKids', ['filter' => 'auth:parent']);
$routes->match(['get', 'post'], 'parent/edit-student/(:num)', 'View\ParentController::editStudent/$1', ['filter' => 'auth:parent']);
$routes->get('parent/edit-all-students', 'View\ParentController::showEditAllStudentsModal', ['filter' => 'auth:parent']);
$routes->post('parent/edit-all-students', 'View\ParentController::updateAllStudents', ['filter' => 'auth:parent']);
// Show registration form with dynamic student and emergency contact blocks
$routes->get('/parent/register_student', 'View\ParentController::registerStudentForm');
$routes->get('/parent/child_register', 'View\ParentController::registerKidCheck');
$routes->get('/parent/register_student', 'View\ParentController::registerStudentForm', ['filter' => 'auth:parent']);
$routes->get('/parent/child_register', 'View\ParentController::registerKidCheck', ['filter' => 'auth:parent']);
// Handle form submission (students + emergency contacts)
$routes->post('/parent/register_student/save', 'View\ParentController::saveStudentRegistration');
$routes->post('/parent/update_student/(:num)', 'View\ParentController::updateStudent/$1');
$routes->get('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1');
$routes->post('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1');
$routes->post('/parent/delete_student/(:num)', 'View\ParentController::deleteStudent/$1');
$routes->post('/parent/register_student/save', 'View\ParentController::saveStudentRegistration', ['filter' => 'auth:parent']);
$routes->post('/parent/update_student/(:num)', 'View\ParentController::updateStudent/$1', ['filter' => 'auth:parent']);
$routes->get('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1', ['filter' => 'auth:parent']);
$routes->post('/parent/edit_student/(:num)', 'View\ParentController::editStudent/$1', ['filter' => 'auth:parent']);
$routes->post('/parent/delete_student/(:num)', 'View\ParentController::deleteStudent/$1', ['filter' => 'auth:parent']);
$routes->get('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1');
$routes->post('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1');
$routes->get('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1', ['filter' => 'auth:parent']);
$routes->post('/parent/edit_emergency_contact/(:num)', 'View\ParentController::editEmergencyContact/$1', ['filter' => 'auth:parent']);
/*management navigation bar*/
@@ -915,7 +921,7 @@ $routes->post('nav-builder/reorder', 'View\NavBuilderController::reorder',
// Inventory routes
$routes->group('inventory', ['filter' => 'csrf'], static function ($routes) {
$routes->group('inventory', ['filter' => 'auth:view_inventory|administrator|administrative staff|principal'], static function ($routes) {
/** ---------------------------
* Movements (put FIRST)
@@ -1013,13 +1019,13 @@ $routes->group('communications', static function ($routes) {
////////////////////////////////////////////////
// app/Config/Routes.php
$routes->group('api', static function ($routes) {
$routes->group('api', ['filter' => 'auth:admin'], static function ($routes) {
// Families & Guardians API
$routes->get('students/(:num)/families', 'View\FamilyController::familiesByStudent/$1');
$routes->get('families/(:num)/guardians', 'View\FamilyController::guardiansByFamily/$1');
});
$routes->group('families', static function ($routes) {
$routes->group('families', ['filter' => 'auth:admin'], static function ($routes) {
// Allow GET for convenience in browser; keep POST for API/automation
$routes->match(['get', 'post'], 'bootstrap', 'View\FamilyController::bootstrap'); // protect with auth in production
$routes->post('attach-second-by-user', 'View\FamilyController::attachSecondByUser');
@@ -1031,10 +1037,10 @@ $routes->group('families', static function ($routes) {
$routes->post('unlink-student', 'View\FamilyController::unlinkStudent');
});
// Allow GET for manual triggering from browser
$routes->match(['get', 'post'], 'families/import-legacy', 'View\FamilyController::importSecondParentsFromLegacy');
$routes->match(['get', 'post'], 'families/import-legacy', 'View\FamilyController::importSecondParentsFromLegacy', ['filter' => 'auth:admin']);
// Admin page (protect with your auth/permission)
$routes->group('family', static function ($routes) {
$routes->group('family', ['filter' => 'auth:admin'], static function ($routes) {
$routes->get('', 'View\FamilyAdminController::index');
$routes->get('index', 'View\FamilyAdminController::index');
$routes->get('search', 'View\FamilyAdminController::search');
@@ -1043,13 +1049,13 @@ $routes->group('family', static function ($routes) {
$routes->post('compose-email/send', 'View\FamilyAdminController::sendComposeEmail');
});
// Convenience alias
$routes->get('family', 'View\FamilyAdminController::index');
$routes->get('family', 'View\FamilyAdminController::index', ['filter' => 'auth:admin']);
//////////////////////////////////////////////////////////
//upload files
$routes->get('receipts/(:any)', 'View\FilesController::receipt/$1', ['filter' => 'auth']);
$routes->get('reimbreceipts/(:any)', 'View\FilesController::reimb/$1', ['filter' => 'auth']); // serves from writable/uploads/reimbursements
$routes->get('early-dismissal-signatures/(:any)', 'View\FilesController::earlyDismissalSignature/$1');
$routes->get('early-dismissal-signatures/(:any)', 'View\FilesController::earlyDismissalSignature/$1', ['filter' => 'auth']);
// Expenses
$routes->get('expenses/edit/(:num)', 'View\ExpenseController::edit/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('expenses/update/(:num)', 'View\ExpenseController::update/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
@@ -1061,13 +1067,13 @@ $routes->post('reimbursements/update/(:num)', 'View\ReimbursementController::upd
// app/Config/Routes.php
$routes->get('whatsapp/', 'View\WhatsappController::index');
$routes->post('whatsapp/sendInvites', 'View\WhatsappController::sendInvites');
$routes->post('whatsapp/saveLink', 'View\WhatsappController::saveLink');
$routes->get('whatsapp/parent-contacts', 'View\WhatsappController::parentContacts');
$routes->get('whatsapp/parent-contacts-by-class', 'View\WhatsappController::parentContactsByClass');
$routes->get('whatsapp/', 'View\WhatsappController::index', ['filter' => 'auth:admin']);
$routes->post('whatsapp/sendInvites', 'View\WhatsappController::sendInvites', ['filter' => 'auth:admin']);
$routes->post('whatsapp/saveLink', 'View\WhatsappController::saveLink', ['filter' => 'auth:admin']);
$routes->get('whatsapp/parent-contacts', 'View\WhatsappController::parentContacts', ['filter' => 'auth:admin']);
$routes->get('whatsapp/parent-contacts-by-class', 'View\WhatsappController::parentContactsByClass', ['filter' => 'auth:admin']);
// Track WhatsApp group membership per class/parent
$routes->match(['get', 'post'], 'whatsapp/update-membership', 'View\WhatsappController::updateMembership');
$routes->match(['get', 'post'], 'whatsapp/update-membership', 'View\WhatsappController::updateMembership', ['filter' => 'auth:admin']);
@@ -1107,15 +1113,15 @@ $routes->post('admin/teacher-attendance/save-cell', 'View\AttendanceController::
$routes->post('admin/admins-attendance/save-cell', 'View\AttendanceController::saveCell');
$routes->get('parent/edit-emergency-contact', 'View\ParentController::editEmergencyContact');
$routes->match(['get', 'post'], 'parent/edit-emergency-contact/(:num)', 'View\ParentController::editEmergencyContact/$1');
$routes->get('parent/edit-emergency-contact', 'View\ParentController::editEmergencyContact', ['filter' => 'auth:parent']);
$routes->match(['get', 'post'], 'parent/edit-emergency-contact/(:num)', 'View\ParentController::editEmergencyContact/$1', ['filter' => 'auth:parent']);
$routes->get('rfid_coming_soon', 'View\RFIDController::rfidComingSoon');
$routes->post('/parent/save-emergency-contact', 'View\ParentController::saveEmergencyContact');
$routes->get('/parent/add-emergency-form', 'View\ParentController::addEmergencyForm');
$routes->post('/parent/save-emergency-contact', 'View\ParentController::saveEmergencyContact', ['filter' => 'auth:parent']);
$routes->get('/parent/add-emergency-form', 'View\ParentController::addEmergencyForm', ['filter' => 'auth:parent']);
$routes->get('report/combined', 'View\ScorePredictor::combinedReport');
@@ -1123,11 +1129,11 @@ $routes->get('report/combined', 'View\ScorePredictor::combinedReport');
//$routes->get('/parent/withdraw_success', 'View\ParentController::withdraw_success');
$routes->get('/profile/(:num)', 'View\ParentController::profile/$1');
$routes->post('/parent/updateProfile/(:num)', 'View\ParentController::updateProfile/$1');
$routes->get('/profile/(:num)', 'View\ParentController::profile/$1', ['filter' => 'auth']);
$routes->post('/parent/updateProfile/(:num)', 'View\ParentController::updateProfile/$1', ['filter' => 'auth']);
$routes->get('/preferences/(:num)', 'View\PreferencesController::index/$1');
$routes->post('/preferences/update/(:num)', 'View\PreferencesController::updatePreferences/$1');
$routes->get('/preferences/(:num)', 'View\PreferencesController::index/$1', ['filter' => 'auth']);
$routes->post('/preferences/update/(:num)', 'View\PreferencesController::updatePreferences/$1', ['filter' => 'auth']);
// Route to view_add_edit the configuration page
$routes->get('/configuration/configuration_view', 'View\ConfigurationController::index');
@@ -1248,7 +1254,7 @@ $routes->post('/register', 'View\RegisterController::register');
$routes->get('/register/success', 'View\RegisterController::success');
$routes->get('/parent/add-student-form', 'View\ParentController::addStudentForm');
$routes->get('/parent/add-student-form', 'View\ParentController::addStudentForm', ['filter' => 'auth:parent']);
$routes->get('/landing_page/admin_dashboard', 'View\LandingPageController::admin', ['filter' => 'auth:admin_dashboard,read']);
+32 -13
View File
@@ -70,21 +70,26 @@ class Services extends BaseService
$mail = new PHPMailer(true);
// Set up your PHPMailer configuration
$host = (string) env('MAIL_DEFAULT_HOST', env('SMTP_HOST', 'smtp.gmail.com'));
$user = (string) env('MAIL_DEFAULT_USER', env('SMTP_USER', ''));
$pass = (string) env('MAIL_DEFAULT_PASS', env('SMTP_PASS', ''));
$port = (int) env('MAIL_DEFAULT_PORT', env('SMTP_PORT', 465));
$encryption = strtolower((string) env('MAIL_DEFAULT_ENCRYPTION', env('SMTP_ENCRYPTION', 'ssl')));
$mail->isSMTP();
$mail->Host = 'smtp.gmail.com'; // Your SMTP host
$mail->Host = $host;
$mail->SMTPAuth = true;
$mail->Username = 'alrahma.sunday.school@gmail.com'; // Your email
$mail->Password = 'psnp emdq dykw ypul'; // Your email password
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
$mail->Port = 465;
$mail->Username = $user;
$mail->Password = $pass;
$mail->SMTPSecure = in_array($encryption, ['tls', 'starttls'], true)
? PHPMailer::ENCRYPTION_STARTTLS
: PHPMailer::ENCRYPTION_SMTPS;
$mail->Port = $port > 0 ? $port : 465;
$mail->Timeout = 10; // ⏱ 10-second timeout max
$mail->SMTPKeepAlive = false; // Prevent hanging connections
$mail->SMTPDebug = 0; // Set to 2 temporarily for debugging
$mail->SMTPDebug = 2; // You can set it to 1, 2, or 3 for increasing verbosity
$mail->Debugoutput = 'html'; // You can output it to 'html' or 'error_log'
$mail->Timeout = 10;
$mail->SMTPKeepAlive = false;
$mail->SMTPDebug = 0;
$mail->Debugoutput = 'error_log';
return $mail;
}
@@ -274,7 +279,6 @@ class Services extends BaseService
model(\App\Models\SchoolYearModel::class),
model(\App\Models\SchoolYearClosingBatchModel::class),
model(\App\Models\SchoolYearClosingItemModel::class),
model(\App\Models\ConfigurationModel::class),
static::schoolYearManagement(),
\Config\Database::connect()
);
@@ -301,4 +305,19 @@ class Services extends BaseService
static::emailService()
);
}
public static function financialAid(bool $getShared = true): \App\Services\FinancialAidService
{
if ($getShared) {
return static::getSharedInstance('financialAid');
}
return new \App\Services\FinancialAidService(
model(\App\Models\FinancialAidRequestModel::class),
model(\App\Models\InvoiceModel::class),
model(\App\Models\DiscountVoucherModel::class),
model(\App\Models\DiscountUsageModel::class),
new \App\Libraries\InvoiceLedgerService()
);
}
}
+9 -1
View File
@@ -32,7 +32,7 @@ class Session extends BaseConfig
*/
public string $cookieName = 'ci_session';
public string $cookieDomain = ''; // Leave blank for localhost
public bool $cookieSecure = false; // Set to false if not using HTTPS
public bool $cookieSecure = false; // Forced on in production via constructor
/**
* --------------------------------------------------------------------------
@@ -101,4 +101,12 @@ class Session extends BaseConfig
* DB Group for the database session.
*/
public ?string $DBGroup = null;
public function __construct()
{
parent::__construct();
$isProduction = ENVIRONMENT === 'production';
$this->cookieSecure = $isProduction;
$this->cookieName = $isProduction ? '__Host-ci_session' : 'ci_session';
}
}
@@ -0,0 +1,106 @@
<?php
namespace App\Controllers\Administrator;
use App\Controllers\BaseController;
use App\Models\FinancialAidRequestModel;
use App\Models\StudentModel;
use App\Models\UserModel;
use Throwable;
class FinancialAidController extends BaseController
{
public function index()
{
$schoolYear = $this->request->getGet('school_year');
$model = new FinancialAidRequestModel();
$builder = $model->orderBy('created_at', 'DESC');
if (is_string($schoolYear) && trim($schoolYear) !== '') {
$builder->where('school_year', trim($schoolYear));
}
$requests = $builder->findAll();
$parentIds = array_values(array_unique(array_filter(array_map('intval', array_column($requests, 'parent_id')))));
$parents = [];
if ($parentIds !== []) {
foreach ((new UserModel())->select('id, firstname, lastname, email')->whereIn('id', $parentIds)->findAll() as $parent) {
$parents[(int) $parent['id']] = $parent;
}
}
return view('administrator/financial_aid_queue', [
'requests' => $requests,
'parents' => $parents,
'schoolYear' => is_string($schoolYear) ? trim($schoolYear) : '',
]);
}
public function show(int $id)
{
$request = (new FinancialAidRequestModel())->find($id);
if ($request === null) {
return redirect()->to('/administrator/financial-aid')->with('error', 'Financial aid request was not found.');
}
$parent = (new UserModel())->find((int) $request['parent_id']);
$studentIds = json_decode((string) ($request['student_ids_json'] ?? '[]'), true);
$studentIds = is_array($studentIds) ? array_map('intval', $studentIds) : [];
$students = $studentIds !== []
? (new StudentModel())->whereIn('id', $studentIds)->findAll()
: [];
return view('administrator/financial_aid_review', [
'requestRow' => $request,
'parent' => $parent,
'students' => $students,
]);
}
public function approve(int $id)
{
try {
$model = new FinancialAidRequestModel();
$request = $model->find($id);
if ($request === null) {
return redirect()->to('/administrator/financial-aid')->with('error', 'Financial aid request was not found.');
}
if (! in_array((string) ($request['status'] ?? ''), ['submitted', 'under_review'], true)) {
return redirect()->back()->with('error', 'Only open requests can be approved.');
}
$amount = (float) $this->request->getPost('admin_amount');
$note = trim((string) $this->request->getPost('admin_note'));
service('financialAid')->applyApprovedAmount($request, $amount, (int) session()->get('user_id'), $note);
return redirect()->to('/administrator/financial-aid')->with('success', 'Financial aid was approved and applied to the invoice.');
} catch (Throwable $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
}
public function deny(int $id)
{
$model = new FinancialAidRequestModel();
$request = $model->find($id);
if ($request === null) {
return redirect()->to('/administrator/financial-aid')->with('error', 'Financial aid request was not found.');
}
if (! in_array((string) ($request['status'] ?? ''), ['submitted', 'under_review'], true)) {
return redirect()->back()->with('error', 'Only open requests can be denied.');
}
$note = trim((string) $this->request->getPost('admin_note'));
if ($note === '') {
return redirect()->back()->with('error', 'A denial note is required.');
}
$model->update($id, [
'status' => 'denied',
'admin_note' => $note,
'reviewed_by' => (int) session()->get('user_id'),
'reviewed_at' => date('Y-m-d H:i:s'),
]);
return redirect()->to('/administrator/financial-aid')->with('success', 'Financial aid request was denied.');
}
}
@@ -4,6 +4,7 @@ namespace App\Controllers\Administrator;
use App\Controllers\BaseController;
use App\Models\SchoolYearModel;
use App\Support\SchoolYear\SchoolYearStatus;
use Throwable;
class SchoolYearClosingController extends BaseController
@@ -12,6 +13,11 @@ class SchoolYearClosingController extends BaseController
{
try {
$targetId = $this->normalizeInt($this->request->getGet('target_school_year_id'));
$source = (new SchoolYearModel())->find($id);
if ($targetId === null && (string) ($source['status'] ?? '') === SchoolYearStatus::ACTIVE) {
$target = service('schoolYearManagement')->ensureNextDraftForClosing($id, $this->userId());
$targetId = (int) ($target['id'] ?? 0) ?: null;
}
$preview = service('schoolYearClosing')->preview($id, $targetId);
$promotionTable = $this->promotionTablePayload($preview['promotion']['rows'] ?? []);
$carryForwardTable = $this->carryForwardTablePayload($preview['carry_forward'] ?? []);
+27 -2
View File
@@ -252,6 +252,32 @@ class AuthController extends BaseController
]);
}
public function adminAuthMe()
{
if (! session()->get('is_logged_in')) {
return $this->response->setStatusCode(401)->setJSON([
'status' => false,
'message' => 'Unauthenticated.',
]);
}
$userId = (int) session()->get('user_id');
$roles = array_values(array_filter((array) (session()->get('roles') ?? [])));
$activeRole = session()->get('role');
return $this->response->setJSON([
'status' => true,
'user' => [
'id' => $userId,
'email' => session()->get('user_email'),
'name' => session()->get('user_name'),
'type' => session()->get('user_type'),
'roles' => $roles,
'role' => $activeRole,
],
]);
}
/**
* API Registration endpoint
* POST /api/v1/register
@@ -552,6 +578,7 @@ class AuthController extends BaseController
$roleNames = array_column($roles, 'name');
session()->regenerate(true);
session()->set([
'user_id' => $user['id'],
'user_email' => $user['email'],
@@ -565,8 +592,6 @@ class AuthController extends BaseController
'school_year' => $this->schoolYear,
]);
$this->applyStylePreferences((int) $user['id']);
log_message('debug', 'Session after login: ' . print_r(session()->get(), true));
//dd('Login successful. Roles:', $roleNames, session()->get());
if (count($roleNames) === 1) {
// One role → set and redirect directly
@@ -15,7 +15,7 @@ class AuthorizedUsersController extends ResourceController
protected $userModel;
protected $authorizedUserModel;
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
public function __construct()
{
$this->userModel = new UserModel();
$this->authorizedUserModel = new AuthorizedUserModel();
+1 -1
View File
@@ -18,7 +18,7 @@ class ClassController extends BaseController
protected $db;
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
public function __construct()
{
$this->db = \Config\Database::connect();
$this->classsectionModel = new ClassSectionModel();
+1 -1
View File
@@ -6,7 +6,7 @@ use App\Controllers\BaseController;
class ContactController extends BaseController
{
public function __construct(private \CodeIgniter\HTTP\IncomingRequest $request)
public function __construct()
{
helper('form'); // Load the form helper
}
@@ -26,8 +26,14 @@ class EnrollmentAdminController extends BaseController
$status = trim((string) ($this->request->getGet('status') ?? 'open'));
$flagType = trim((string) ($this->request->getGet('flag_type') ?? ''));
$assignedTo = trim((string) ($this->request->getGet('assigned_to') ?? ''));
$exceptionParentId = (int) ($this->request->getGet('exception_parent_id') ?? 0);
$canManageExceptions = $this->canManageEnrollmentExceptions();
service('enrollmentTransition')->syncDashboardBlockageFlags($schoolYear);
$flags = $this->enrollmentFlags($schoolYear, $status, $flagType, $assignedTo);
$openFlags = ($status === 'open' && $flagType === '' && $assignedTo === '')
? $flags
: $this->enrollmentFlags($schoolYear, 'open', '', '');
return view('administrator/enrollment_admin_dashboard', [
'flags' => $flags,
@@ -39,11 +45,19 @@ class EnrollmentAdminController extends BaseController
'schoolYears' => $this->schoolYears(),
'classSections' => $this->classSections($schoolYear),
'admins' => $this->adminUsers(),
'enrollmentFollowups' => $this->enrollmentFollowups($schoolYear),
'enrollmentFollowups' => $this->enrollmentFollowups($schoolYear, $openFlags),
'auditRows' => $this->auditRows($schoolYear),
'activeExceptions' => $canManageExceptions ? $this->enrollmentExceptions($schoolYear) : [],
'exceptionNeeded' => $this->exceptionNeededFromFlags($openFlags),
'canManageEnrollmentExceptions' => $canManageExceptions,
'exceptionFamilies' => $this->exceptionFamilies(),
'exceptionReasonCodes' => $this->exceptionReasonCodes(),
'exceptionSelectedParentId' => $exceptionParentId,
'exceptionPreview' => $this->exceptionPreview($exceptionParentId, $schoolYear),
'launchState' => $this->launchState($schoolYear),
'previewParentId' => $this->firstParentWithStudents(),
'emailExamples' => service('enrollmentRegistrationEmail')->previewExamplesForSchoolYear($schoolYear),
'openFlagCount' => $this->openFlagCount($schoolYear),
]);
}
@@ -202,28 +216,257 @@ class EnrollmentAdminController extends BaseController
public function approveException(int $id)
{
try {
if (! $this->canManageEnrollmentExceptions()) {
return redirect()->back()->with('error', 'You do not have permission to manage enrollment exceptions.');
}
$flag = $this->requireFlag($id);
$reason = trim((string) ($this->request->getPost('reason') ?? ''));
if ($reason === '') {
return redirect()->back()->with('error', 'Approval reason is required.');
}
$parentId = $this->parentIdForStudent((int) $flag['student_id'], (string) $flag['school_year']);
if ($parentId <= 0) {
return redirect()->back()->with('error', 'Unable to create exception because no linked parent was found.');
}
$ruleCodes = $this->ruleCodesForFlag((string) $flag['flag_type']);
$now = date('Y-m-d H:i:s');
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
$this->db->transStart();
if ($this->db->tableExists('enrollment_exceptions')) {
$existing = $this->db->table('enrollment_exceptions')
->where('parent_id', $parentId)
->where('student_id', (int) $flag['student_id'])
->where('school_year', (string) $flag['school_year'])
->where('status', 'active')
->limit(1)
->get()
->getRowArray();
$payload = [
'parent_id' => $parentId,
'student_id' => (int) $flag['student_id'],
'school_year' => (string) $flag['school_year'],
'source_school_year' => $flag['source_school_year'] ?? null,
'status' => 'active',
'reason_code' => (string) $flag['flag_type'],
'reason_note' => $reason,
'bypassed_rule_codes_json' => json_encode($ruleCodes, JSON_UNESCAPED_SLASHES),
'family_student_ids_json' => json_encode([(int) $flag['student_id']], JSON_UNESCAPED_SLASHES),
'created_by' => $this->userId(),
'approved_by' => $this->userId(),
'starts_at' => $now,
'expires_at' => $expiresAt,
'updated_at' => $now,
];
if ($existing !== null) {
$this->db->table('enrollment_exceptions')->where('id', (int) $existing['id'])->update($payload);
} else {
$payload['created_at'] = $now;
$this->db->table('enrollment_exceptions')->insert($payload);
}
}
$this->db->table('enrollments')
->where('student_id', (int) $flag['student_id'])
->where('school_year', (string) $flag['school_year'])
->update([
'exception_required' => 0,
'exception_reason' => $reason,
'updated_at' => date('Y-m-d H:i:s'),
'updated_at' => $now,
]);
$this->resolveFlagRow($flag, $reason, 'enrollment_exception_approved');
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return redirect()->back()->with('error', 'Unable to approve enrollment exception.');
}
return redirect()->back()->with('success', 'Enrollment exception approved.');
} catch (Throwable $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function createException()
{
try {
if (! $this->canManageEnrollmentExceptions()) {
return redirect()->back()->with('error', 'You do not have permission to manage enrollment exceptions.');
}
if (! $this->db->tableExists('enrollment_exceptions')) {
return redirect()->back()->with('error', 'Enrollment exception storage is not available. Run migrations first.');
}
$parentId = (int) ($this->request->getPost('parent_id') ?? 0);
$studentIds = $this->request->getPost('student_ids') ?? [];
if (! is_array($studentIds)) {
$studentIds = [$this->request->getPost('student_id') ?? 0];
}
$studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds), static fn (int $id): bool => $id > 0)));
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? ''));
$reasonCode = trim((string) ($this->request->getPost('reason_code') ?? ''));
$reasonNote = trim((string) ($this->request->getPost('reason_note') ?? ''));
$expiresAt = trim((string) ($this->request->getPost('expires_at') ?? ''));
$postedCodesByStudent = $this->request->getPost('bypassed_rule_codes_by_student') ?? [];
$postedCodesByStudent = is_array($postedCodesByStudent) ? $postedCodesByStudent : [];
if ($parentId <= 0 || $studentIds === [] || $schoolYear === '' || $reasonCode === '' || $reasonNote === '') {
return redirect()->back()->withInput()->with('error', 'Parent, at least one student, school year, reason code, and note are required.');
}
$sourceSchoolYear = $this->previousSchoolYearName($schoolYear);
if ($sourceSchoolYear === null) {
return redirect()->back()->withInput()->with('error', 'Unable to determine the source school year.');
}
if ($expiresAt === '') {
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
} else {
try {
$expiresAt = (new \DateTimeImmutable($expiresAt))->format('Y-m-d H:i:s');
} catch (\Throwable) {
return redirect()->back()->withInput()->with('error', 'Expiration date is invalid.');
}
}
$now = date('Y-m-d H:i:s');
$transitionService = service('enrollmentTransition');
$saved = 0;
$this->db->transStart();
foreach ($studentIds as $studentId) {
if (! $this->studentLinkedToParent($studentId, $parentId)) {
$this->db->transRollback();
return redirect()->back()->withInput()->with('error', 'Selected student is not linked to the selected parent.');
}
$evaluation = $transitionService->evaluateForParent($parentId, $studentId, $sourceSchoolYear, $schoolYear, 'admin');
$failedCodes = array_values(array_unique(array_filter(array_map('strval', array_merge(
$evaluation['blocking_rule_codes'] ?? [],
$evaluation['review_rule_codes'] ?? []
)))));
$postedCodes = $postedCodesByStudent[(string) $studentId] ?? $postedCodesByStudent[$studentId] ?? [];
$postedCodes = is_array($postedCodes) ? array_values(array_filter(array_map('strval', $postedCodes))) : [];
$postedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $postedCodes)));
$failedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $failedCodes)));
$ruleCodes = $postedCodes !== [] ? array_values(array_intersect($postedCodes, $failedCodes)) : $failedCodes;
$ruleCodes = array_values(array_filter($ruleCodes, static fn (string $code): bool => $code !== ''));
$nonOverridable = array_values(array_intersect($ruleCodes, ['STUDENT_NOT_LINKED', 'SOURCE_YEAR_NOT_FOUND', 'TARGET_YEAR_NOT_FOUND', 'ALREADY_ENROLLED']));
if ($nonOverridable !== []) {
$this->db->transRollback();
return redirect()->back()->withInput()->with('error', 'These rule(s) cannot be bypassed: ' . implode(', ', $nonOverridable));
}
if ($ruleCodes === []) {
$this->db->transRollback();
return redirect()->back()->withInput()->with('error', 'No failed eligibility rule codes were selected for one or more selected students.');
}
$existing = $this->db->table('enrollment_exceptions')
->where('parent_id', $parentId)
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('status', 'active')
->limit(1)
->get()
->getRowArray();
$payload = [
'parent_id' => $parentId,
'student_id' => $studentId,
'school_year' => $schoolYear,
'source_school_year' => $sourceSchoolYear,
'status' => 'active',
'reason_code' => $reasonCode,
'reason_note' => $reasonNote,
'bypassed_rule_codes_json' => json_encode($ruleCodes, JSON_UNESCAPED_SLASHES),
'family_student_ids_json' => json_encode([$studentId], JSON_UNESCAPED_SLASHES),
'created_by' => $this->userId(),
'approved_by' => $this->userId(),
'starts_at' => $now,
'expires_at' => $expiresAt,
'updated_at' => $now,
];
if ($existing !== null) {
$this->db->table('enrollment_exceptions')->where('id', (int) $existing['id'])->update($payload);
$exceptionId = (int) $existing['id'];
} else {
$payload['created_at'] = $now;
$this->db->table('enrollment_exceptions')->insert($payload);
$exceptionId = (int) $this->db->insertID();
}
$this->audit($studentId, $schoolYear, $sourceSchoolYear, 'enrollment_exception_created', $existing, array_merge($payload, [
'id' => $exceptionId,
'evaluation_decision' => $evaluation['decision'] ?? null,
]), $reasonNote);
$this->resolveCoveredFlags($studentId, $schoolYear, $ruleCodes, $reasonNote);
$saved++;
}
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return redirect()->back()->withInput()->with('error', 'Unable to save enrollment exception.');
}
return redirect()->to(site_url('administrator/enrollment-admin?school_year=' . rawurlencode($schoolYear) . '&exception_parent_id=' . $parentId))
->with('success', $saved . ' enrollment exception' . ($saved === 1 ? '' : 's') . ' saved.');
} catch (Throwable $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
}
public function revokeException(int $id)
{
if (! $this->canManageEnrollmentExceptions()) {
return redirect()->back()->with('error', 'You do not have permission to manage enrollment exceptions.');
}
if ($id <= 0 || ! $this->db->tableExists('enrollment_exceptions')) {
return redirect()->back()->with('error', 'Enrollment exception was not found.');
}
$reason = trim((string) ($this->request->getPost('revocation_reason') ?? ''));
if ($reason === '') {
return redirect()->back()->with('error', 'Revocation reason is required.');
}
$exception = $this->db->table('enrollment_exceptions')->where('id', $id)->limit(1)->get()->getRowArray();
if ($exception === null || (string) ($exception['status'] ?? '') !== 'active') {
return redirect()->back()->with('error', 'Only active exceptions can be revoked.');
}
$this->db->table('enrollment_exceptions')->where('id', $id)->update([
'status' => 'revoked',
'revoked_at' => date('Y-m-d H:i:s'),
'revoked_by' => $this->userId(),
'revocation_reason' => $reason,
'updated_at' => date('Y-m-d H:i:s'),
]);
$this->audit(
(int) $exception['student_id'],
(string) $exception['school_year'],
(string) ($exception['source_school_year'] ?? ''),
'enrollment_exception_revoked',
$exception,
['status' => 'revoked', 'revocation_reason' => $reason],
$reason
);
return redirect()->back()->with('success', 'Enrollment exception revoked.');
}
private function enrollmentFlags(string $schoolYear, string $status, string $flagType, string $assignedTo): array
{
if (! $this->db->tableExists('enrollment_flags')) {
@@ -232,10 +475,12 @@ class EnrollmentAdminController extends BaseController
$builder = $this->db->table('enrollment_flags ef')
->select('ef.*')
->select('s.firstname, s.lastname, s.school_id')
->select('s.firstname, s.lastname, s.school_id, s.parent_id')
->select('u.firstname AS assignee_firstname, u.lastname AS assignee_lastname')
->select('p.firstname AS parent_firstname, p.lastname AS parent_lastname')
->join('students s', 's.id = ef.student_id', 'left')
->join('users u', 'u.id = ef.assigned_to', 'left')
->join('users p', 'p.id = s.parent_id', 'left')
->orderBy('ef.created_at', 'DESC')
->orderBy('ef.id', 'DESC');
@@ -256,6 +501,7 @@ class EnrollmentAdminController extends BaseController
foreach ($rows as &$row) {
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
$row['assignee_name'] = trim((string) ($row['assignee_firstname'] ?? '') . ' ' . (string) ($row['assignee_lastname'] ?? ''));
$row['parent_name'] = trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')) ?: ((int) ($row['parent_id'] ?? 0) > 0 ? 'Parent #' . (int) $row['parent_id'] : '');
$row['details'] = json_decode((string) ($row['details_json'] ?? ''), true) ?: [];
}
unset($row);
@@ -263,10 +509,10 @@ class EnrollmentAdminController extends BaseController
return $rows;
}
private function enrollmentFollowups(string $schoolYear): array
private function enrollmentFollowups(string $schoolYear, array $flags = []): array
{
if (! $this->db->tableExists('enrollments')) {
return [];
return $this->followupsFromFlags($flags, []);
}
$fields = $this->db->getFieldNames('enrollments');
@@ -338,10 +584,16 @@ class EnrollmentAdminController extends BaseController
if (in_array('deliberation_decision', $fields, true)) {
$builder->orWhereIn('e.deliberation_decision', [
'make_up_exam',
'MAKE_UP_EXAM',
'repeat_class',
'REPEAT_CLASS',
'deferred',
'DEFERRED',
'DEFERRED_DECISION',
'expelled',
'EXPELLED',
'withdrawn',
'WITHDRAWN',
]);
}
@@ -351,9 +603,76 @@ class EnrollmentAdminController extends BaseController
}
unset($row);
return $this->followupsFromFlags($flags, $rows);
}
private function followupsFromFlags(array $flags, array $rows): array
{
$seen = [];
foreach ($rows as $row) {
$seen[(int) ($row['student_id'] ?? 0)] = true;
}
$followupTypes = [
'PENDING_MAKE_UP_EXAM_PROMOTION' => 'temporary_same_grade',
'CLASS_REASSIGNMENT_REQUIRED' => 'manual_class_required',
'CLASS_CAPACITY_EXCEPTION_REQUIRED' => 'manual_class_required',
'COMPLETION_OR_EXIT_PROCESS_REQUIRED' => 'exit_required',
];
foreach ($flags as $flag) {
$type = (string) ($flag['flag_type'] ?? '');
$studentId = (int) ($flag['student_id'] ?? 0);
if ($studentId <= 0 || isset($seen[$studentId]) || ! isset($followupTypes[$type])) {
continue;
}
$seen[$studentId] = true;
$rows[] = [
'id' => 0,
'student_id' => $studentId,
'student_name' => (string) ($flag['student_name'] ?? ''),
'school_id' => (string) ($flag['school_id'] ?? ''),
'enrollment_status' => 'not enrolled',
'deliberation_decision' => $type === 'PENDING_MAKE_UP_EXAM_PROMOTION' ? 'MAKE_UP_EXAM' : '',
'placement_status' => $followupTypes[$type],
'class_section_name' => '',
'exception_required' => 0,
'exception_reason' => $type,
'updated_at' => $flag['created_at'] ?? null,
];
}
return $rows;
}
private function exceptionNeededFromFlags(array $flags): array
{
$exceptionTypes = [
'AGE_EXCEPTION_REQUIRED',
'LATE_REGISTRATION_EXCEPTION',
'FINANCIAL_REVIEW_REQUIRED',
'CLASS_CAPACITY_EXCEPTION_REQUIRED',
'SIBLING_LAST_NAME_MISMATCH',
'DEFERRED_DELIBERATION',
'WITHDRAWAL_REVIEW_REQUIRED',
'RESTRICTED_ADMINISTRATIVE_REVIEW',
];
$needed = [];
foreach ($flags as $flag) {
if (($flag['status'] ?? 'open') !== 'open') {
continue;
}
if (! in_array((string) ($flag['flag_type'] ?? ''), $exceptionTypes, true)) {
continue;
}
$needed[] = $flag;
}
return $needed;
}
private function requireFlag(int $id): array
{
if ($id <= 0 || ! $this->db->tableExists('enrollment_flags')) {
@@ -392,6 +711,29 @@ class EnrollmentAdminController extends BaseController
}
}
private function resolveCoveredFlags(int $studentId, string $schoolYear, array $ruleCodes, string $notes): void
{
if ($studentId <= 0 || $schoolYear === '' || $ruleCodes === [] || ! $this->db->tableExists('enrollment_flags')) {
return;
}
$ruleCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $ruleCodes)));
$flags = $this->db->table('enrollment_flags')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('status', 'open')
->get()
->getResultArray();
foreach ($flags as $flag) {
$flagType = strtoupper(trim((string) ($flag['flag_type'] ?? '')));
$mapped = array_map('strtoupper', $this->ruleCodesForFlag($flagType));
if (in_array($flagType, $ruleCodes, true) || array_intersect($mapped, $ruleCodes) !== []) {
$this->resolveFlagRow($flag, $notes, 'enrollment_exception_created');
}
}
}
private function applyClassSection(int $studentId, string $schoolYear, int $sectionId, string $auditAction, string $placementStatus = 'manual_class_assigned'): void
{
$section = $this->db->table('classSection')
@@ -514,6 +856,270 @@ class EnrollmentAdminController extends BaseController
return $rows;
}
private function enrollmentExceptions(string $schoolYear): array
{
if (! $this->db->tableExists('enrollment_exceptions')) {
return [];
}
$builder = $this->db->table('enrollment_exceptions ee')
->select('ee.*')
->select('s.firstname, s.lastname, s.school_id')
->select('u.firstname AS parent_firstname, u.lastname AS parent_lastname')
->select('admin.firstname AS admin_firstname, admin.lastname AS admin_lastname')
->join('students s', 's.id = ee.student_id', 'left')
->join('users u', 'u.id = ee.parent_id', 'left')
->join('users admin', 'admin.id = ee.created_by', 'left')
->orderBy('ee.created_at', 'DESC')
->orderBy('ee.id', 'DESC')
->limit(100);
if ($schoolYear !== '') {
$builder->where('ee.school_year', $schoolYear);
}
$rows = $builder->get()->getResultArray();
foreach ($rows as &$row) {
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
$row['parent_name'] = trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')) ?: 'Parent #' . (int) ($row['parent_id'] ?? 0);
$row['created_by_name'] = trim((string) ($row['admin_firstname'] ?? '') . ' ' . (string) ($row['admin_lastname'] ?? '')) ?: ((int) ($row['created_by'] ?? 0) > 0 ? 'User #' . (int) $row['created_by'] : '');
$decoded = json_decode((string) ($row['bypassed_rule_codes_json'] ?? ''), true);
$row['bypassed_rule_codes'] = is_array($decoded) ? array_values(array_map('strval', $decoded)) : [];
}
unset($row);
return $rows;
}
private function exceptionFamilies(): array
{
if (! $this->db->tableExists('students') || ! $this->db->tableExists('users')) {
return [];
}
$rows = $this->db->table('students s')
->select('s.id AS student_id, s.firstname AS student_firstname, s.lastname AS student_lastname, s.school_id, s.parent_id')
->select('u.firstname AS parent_firstname, u.lastname AS parent_lastname, u.email AS parent_email')
->join('users u', 'u.id = s.parent_id', 'left')
->where('s.parent_id >', 0)
->orderBy('u.lastname', 'ASC')
->orderBy('u.firstname', 'ASC')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->limit(1000)
->get()
->getResultArray();
$families = [];
foreach ($rows as $row) {
$parentId = (int) ($row['parent_id'] ?? 0);
if ($parentId <= 0) {
continue;
}
if (! isset($families[$parentId])) {
$parentName = trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? ''));
$families[$parentId] = [
'parent_id' => $parentId,
'parent_name' => $parentName !== '' ? $parentName : 'Parent #' . $parentId,
'parent_email' => (string) ($row['parent_email'] ?? ''),
'students' => [],
];
}
$studentId = (int) ($row['student_id'] ?? 0);
$studentName = trim((string) ($row['student_firstname'] ?? '') . ' ' . (string) ($row['student_lastname'] ?? ''));
$families[$parentId]['students'][] = [
'student_id' => $studentId,
'student_name' => $studentName !== '' ? $studentName : 'Student #' . $studentId,
'school_id' => (string) ($row['school_id'] ?? ''),
];
}
$families = array_values($families);
usort($families, static function (array $left, array $right): int {
return strnatcasecmp(
(string) ($left['parent_name'] ?? ''),
(string) ($right['parent_name'] ?? '')
);
});
return $families;
}
private function exceptionReasonCodes(): array
{
return [
'ADMIN_REVIEW_APPROVED' => 'Administrative review approved',
'FINANCE_APPROVAL_REQUIRED' => 'Finance approval',
'SIBLING_LAST_NAME_REVIEWED' => 'Sibling last name reviewed',
'AGE_EXCEPTION_APPROVED' => 'Age exception approved',
'LATE_REGISTRATION_APPROVED' => 'Late registration approved',
'ACADEMIC_STATUS_EXCEPTION' => 'Academic status exception',
'MANUAL_PLACEMENT_EXCEPTION' => 'Manual placement exception',
];
}
private function exceptionPreview(int $parentId, string $schoolYear): ?array
{
if ($parentId <= 0 || $schoolYear === '') {
return null;
}
$sourceSchoolYear = $this->previousSchoolYearName($schoolYear);
if ($sourceSchoolYear === null) {
return [
'error' => 'Unable to determine the previous school year for ' . $schoolYear . '.',
];
}
$parent = $this->db->table('users u')
->select('u.firstname, u.lastname, u.email')
->where('u.id', $parentId)
->limit(1)
->get()
->getRowArray() ?: [];
$studentRows = $this->db->table('students s')
->select('s.id, s.firstname, s.lastname, s.school_id')
->where('s.parent_id', $parentId)
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->getResultArray();
$students = [];
foreach ($studentRows as $student) {
$linkedStudentId = (int) ($student['id'] ?? 0);
if ($linkedStudentId <= 0) {
continue;
}
$students[] = [
'student_id' => $linkedStudentId,
'student_name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . $linkedStudentId,
'school_id' => (string) ($student['school_id'] ?? ''),
'evaluation' => service('enrollmentTransition')->evaluateForParent($parentId, $linkedStudentId, $sourceSchoolYear, $schoolYear, 'admin'),
];
}
return [
'parent_id' => $parentId,
'school_year' => $schoolYear,
'source_school_year' => $sourceSchoolYear,
'parent_name' => trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')) ?: 'Parent #' . $parentId,
'parent_email' => (string) ($parent['email'] ?? ''),
'students' => $students,
];
}
private function studentLinkedToParent(int $studentId, int $parentId): bool
{
if ($studentId <= 0 || $parentId <= 0 || ! $this->db->tableExists('students')) {
return false;
}
return $this->db->table('students')
->where('id', $studentId)
->where('parent_id', $parentId)
->countAllResults() > 0;
}
private function previousSchoolYearName(string $schoolYear): ?string
{
$schoolYear = trim($schoolYear);
if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches)) {
return null;
}
return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1);
}
private function parentIdForStudent(int $studentId, string $schoolYear): int
{
if ($studentId <= 0) {
return 0;
}
if ($schoolYear !== '' && $this->db->tableExists('enrollments')) {
$row = $this->db->table('enrollments')
->select('parent_id')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderBy('updated_at', 'DESC')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
if ((int) ($row['parent_id'] ?? 0) > 0) {
return (int) $row['parent_id'];
}
}
$student = $this->db->table('students')
->select('parent_id')
->where('id', $studentId)
->limit(1)
->get()
->getRowArray();
return (int) ($student['parent_id'] ?? 0);
}
private function ruleCodesForFlag(string $flagType): array
{
return match ($flagType) {
'AGE_EXCEPTION_REQUIRED' => ['AGE_RULE_BLOCKED'],
'LATE_REGISTRATION_EXCEPTION' => ['REGISTRATION_CLOSED'],
'FINANCIAL_REVIEW_REQUIRED' => ['OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED'],
'CLASS_CAPACITY_EXCEPTION_REQUIRED' => ['CLASS_CAPACITY_EXCEPTION_REQUIRED'],
'RESTRICTED_ADMINISTRATIVE_REVIEW' => ['EXPELLED'],
'WITHDRAWAL_REVIEW_REQUIRED' => ['WITHDRAWN'],
'DEFERRED_DELIBERATION' => ['NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION', 'DEFERRED_DECISION'],
'SIBLING_LAST_NAME_MISMATCH' => ['SIBLING_LAST_NAME_MISMATCH'],
default => [$flagType],
};
}
private function canManageEnrollmentExceptions(): bool
{
$userId = $this->userId();
if ($userId <= 0) {
return false;
}
if (! $this->db->tableExists('permissions') || ! $this->db->tableExists('role_permissions') || ! $this->db->tableExists('user_roles')) {
return $this->isAdministratorSessionRole();
}
$rows = $this->db->table('user_roles ur')
->select('rp.*')
->join('role_permissions rp', 'rp.role_id = ur.role_id')
->join('permissions p', 'p.id = rp.permission_id')
->where('ur.user_id', $userId)
->where('LOWER(p.name)', 'enrollment.exception.manage')
->get()
->getResultArray();
foreach ($rows as $row) {
if (! empty($row['can_create']) || ! empty($row['can_update']) || ! empty($row['can_delete'])) {
return true;
}
}
return false;
}
private function isAdministratorSessionRole(): bool
{
$roles = array_map(static fn ($role): string => strtolower(trim((string) $role)), (array) session()->get('roles'));
$activeRole = strtolower(trim((string) session()->get('role')));
if ($activeRole !== '') {
$roles[] = $activeRole;
}
return (bool) array_intersect(array_unique($roles), ['administrator', 'principal']);
}
private function launchState(string $schoolYear): array
{
if ($schoolYear === '' || ! $this->db->tableExists('school_years')) {
@@ -579,6 +1185,20 @@ class EnrollmentAdminController extends BaseController
return is_numeric($row['parent_id'] ?? null) ? (int) $row['parent_id'] : null;
}
private function openFlagCount(string $schoolYear): int
{
if (! $this->db->tableExists('enrollment_flags')) {
return 0;
}
$builder = $this->db->table('enrollment_flags')->where('status', 'open');
if ($schoolYear !== '') {
$builder->where('school_year', $schoolYear);
}
return $builder->countAllResults();
}
private function flagTypes(): array
{
if (! $this->db->tableExists('enrollment_flags')) {
+34 -1
View File
@@ -152,6 +152,10 @@ class FilesController extends Controller
throw PageNotFoundException::forPageNotFound();
}
if (! $this->canViewEarlyDismissalSignature($name)) {
return $this->response->setStatusCode(403, 'You are not allowed to access this file.');
}
// 3) Build path under writable (EARLY DISMISSAL SIGNATURES)
$path = WRITEPATH . 'uploads/early_dismissal_signatures/' . $name;
if (!is_file($path)) {
@@ -200,7 +204,7 @@ class FilesController extends Controller
->setHeader('Content-Length', (string) $size)
->setHeader('ETag', $etag)
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
->setHeader('Cache-Control', 'public, max-age=86400')
->setHeader('Cache-Control', 'private, no-store')
->setBody(file_get_contents($path));
}
@@ -433,6 +437,35 @@ class FilesController extends Controller
return $draftSemester === '' || $currentSemester === '' || $draftSemester === $currentSemester;
}
private function canViewEarlyDismissalSignature(string $name): bool
{
$userId = (int) (session()->get('user_id') ?? 0);
if ($userId <= 0) {
return false;
}
$roles = array_map('strtolower', (array) (session()->get('roles') ?? []));
$activeRole = strtolower((string) (session()->get('role') ?? ''));
if ($activeRole !== '' && ! in_array($activeRole, $roles, true)) {
$roles[] = $activeRole;
}
foreach (['administrator', 'administrative staff', 'principal', 'admin', 'teacher', 'teacher_assistant'] as $role) {
if (in_array($role, $roles, true)) {
return true;
}
}
$row = \Config\Database::connect()
->table('early_dismissal_signatures')
->select('uploaded_by')
->where('filename', $name)
->get()
->getRowArray();
return $row !== null && (int) ($row['uploaded_by'] ?? 0) === $userId;
}
private function expenseRecordForFile(string $name): ?array
{
return \Config\Database::connect()
@@ -10,7 +10,6 @@ class HealthController extends Controller
{
return [
'label' => $label,
'path' => $path,
'exists' => is_dir($path),
'writable' => is_writable($path),
];
@@ -65,7 +64,6 @@ class HealthController extends Controller
'ok' => $ok,
'paths' => $pathsStatus,
'database' => $dbChecks,
'write_path' => WRITEPATH,
'timestamp' => date('c'),
];
+34 -52
View File
@@ -22,6 +22,7 @@ use App\Libraries\FinancialStatus;
use App\Libraries\IssueInvoiceCommand;
use App\Libraries\InvoiceIssuanceService;
use App\Libraries\InvoiceLedgerService;
use App\Libraries\Tuition\GradeLevelParser;
use DateTime;
use DateTimeZone;
@@ -44,7 +45,6 @@ class InvoiceController extends ResourceController
protected $studentClassModel;
protected $firstStudentFee;
protected $secondStudentFee;
protected $youthFee;
protected $refundDeadline;
protected $invoiceEventModel;
protected $paymentModel;
@@ -83,9 +83,8 @@ class InvoiceController extends ResourceController
$this->semester = $this->configModel->getConfig('semester');
$this->dueDate = $this->configModel->getConfig('first_day_of_school')
?: $this->configModel->getConfig('due_date');
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350);
$this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200);
$this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 200);
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 380);
$this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 280);
$this->refundDeadline = date('Y-m-d', strtotime($this->configModel->getConfig('refund_deadline')));
}
@@ -676,36 +675,16 @@ class InvoiceController extends ResourceController
}
unset($student); // break reference
// 2) Partition into regular (<= grade 9) vs youth (> grade 9)
$regularCount = 0;
$youthCount = 0;
// 2) First student pays the base fee; every additional student pays base minus $100.
usort($students, fn (array $left, array $right): int => GradeLevelParser::parse($left['grade'] ?? null) <=> GradeLevelParser::parse($right['grade'] ?? null));
foreach ($students as $student) {
$levelInfo = $this->getGradeLevel($student['grade']);
$level = (int) ($levelInfo['level'] ?? 999);
// Youth if level > $this->gradeFee (e.g., gradeFee = 9)
if ($level > $this->gradeFee) {
$youthCount++;
} else {
$regularCount++;
}
}
// 3) Calculate totals per your rules
$studentCount = 0;
$total = 0.0;
// Youth: flat youth fee per student
$total += $youthCount * $this->youthFee;
// Regulars: first student full price, others discounted — but only if 2+ regulars
if ($regularCount >= 2) {
$total += $this->firstStudentFee; // one full
$total += ($regularCount - 1) * $this->secondStudentFee; // rest discounted
} elseif ($regularCount === 1) {
$total += $this->firstStudentFee; // single regular: no discount even if there are youths
foreach ($students as $student) {
$total += ($studentCount === 0) ? $this->firstStudentFee : $this->secondStudentFee;
$studentCount++;
}
// if 0 regulars, nothing to add here
return $total;
}
@@ -860,41 +839,27 @@ class InvoiceController extends ResourceController
$refundAllowed = $currentDate <= $deadline;
$studentCharges = [];
$regularCount = 0;
$studentCount = 0;
/**
* Computes the fee for a student given numeric level and original grade name.
* - KG/K/Kindergarten are always treated as "regular".
* - Otherwise, "regular" means gradeLevel <= $this->gradeFee threshold.
* First student pays the base fee; every additional student pays base minus $100.
*/
$computeCharge = function (int $gradeLevel, string $gradeName) use (&$regularCount) {
$threshold = (int) $this->gradeFee;
// Force Kindergarten to be regular regardless of numeric mapping
$isRegular = $this->isKindergarten($gradeName) || ($gradeLevel > 0 && $gradeLevel <= $threshold);
if ($isRegular) {
$fee = ($regularCount === 0) ? $this->firstStudentFee : $this->secondStudentFee;
$regularCount++;
return $fee;
}
return $this->youthFee;
$computeCharge = function (array $student) use (&$studentCount) {
$fee = ($studentCount === 0) ? $this->firstStudentFee : $this->secondStudentFee;
$studentCount++;
return $fee;
};
// Registered kids -> pay unit fee
foreach ($registeredKids as $student) {
$gradeName = (string)$student['grade'];
$gradeLevel = $this->gradeLevelInt($gradeName);
$unitFee = $computeCharge($gradeLevel, $gradeName);
$unitFee = $computeCharge($student);
$studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0];
}
// Refund NOT allowed -> withdrawn students still owe unit fee
if (!$refundAllowed) {
foreach ($withdrawnKids as $student) {
$gradeName = (string)$student['grade'];
$gradeLevel = $this->gradeLevelInt($gradeName);
$unitFee = $computeCharge($gradeLevel, $gradeName);
$unitFee = $computeCharge($student);
$studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0];
}
}
@@ -1625,6 +1590,23 @@ private function getGradeLevel($grade): array
// View: Get invoices by parent ID (for web views)
public function getByParent($parentId)
{
$parentId = (int) $parentId;
$userId = (int) (session()->get('user_id') ?? 0);
$roles = array_map(
static fn ($role): string => strtolower(trim((string) $role)),
array_filter(array_merge((array) session()->get('roles'), [session()->get('role')]))
);
$isStaff = (bool) array_intersect($roles, [
'administrator',
'administrative staff',
'principal',
'admin',
]);
if ($userId <= 0 || (! $isStaff && $userId !== $parentId)) {
return redirect()->to('/access_denied');
}
$invoices = $this->invoiceModel->getInvoicesByUserId($parentId, $this->schoolYear);
return view('invoice_list', ['invoices' => $invoices]);
}
@@ -92,6 +92,10 @@ class MessagesController extends BaseController
$userRoleModel = new UserRoleModel();
//$role = session()->get('role');
$userId = session()->get('user_id');
if (empty($userId)) {
return redirect()->to('/login');
}
// Fetch the user role from the user_roles and roles tables
$role = $userRoleModel->select('roles.name')
->join('roles', 'roles.id = user_roles.role_id')
@@ -103,6 +107,11 @@ class MessagesController extends BaseController
$attachmentPath = null;
$file = $this->request->getFile('attachment');
if ($file && $file->isValid() && !$file->hasMoved()) {
$allowedExt = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'doc', 'docx'];
$ext = strtolower((string) $file->getExtension());
if (! in_array($ext, $allowedExt, true) || $file->getSize() > 5 * 1024 * 1024) {
return redirect()->back()->with('error', 'Attachment must be a document or image under 5MB.');
}
$attachmentPath = $file->store();
}
+283 -83
View File
@@ -92,15 +92,6 @@ class ParentController extends BaseController
$this->maxEmergency = (int) $this->configModel->getConfig('max_emergency') ?? 0;
helper(['url', 'form']);
if (!session()->get('is_logged_in')) {
return redirect()->to('/login');
}
// Add more role-specific checks if needed
if (session()->get('role') !== 'parent') {
return redirect()->to('/access_denied');
}
}
public function index()
@@ -252,9 +243,6 @@ class ParentController extends BaseController
public function enrollClasses()
{
try {
// Log session data for debugging
log_message('info', 'Session Data: ' . print_r(session()->get(), true));
// Get deadlines and school year from config
if (!$this->schoolYear) {
log_message('error', 'Current school year not found in configuration.');
@@ -373,7 +361,7 @@ class ParentController extends BaseController
if ($isEditable) {
$student['transition_evaluation'] = $previousSchoolYear !== null
? $this->transitionEvaluationForStudent((int) $studentId, $previousSchoolYear, $selectedYear)
? $this->transitionEvaluationForStudent((int) $parentId, (int) $studentId, $previousSchoolYear, $selectedYear)
: null;
$student['enrollment_eligibility_message'] = $this->eligibilityMessageFromTransition(
$student,
@@ -382,11 +370,13 @@ class ParentController extends BaseController
);
$student['expected_placement_label'] = $this->expectedPlacementLabel($student['transition_evaluation']);
$student['required_action_label'] = $this->requiredActionLabel($student['transition_evaluation']);
$student['parent_enrollment_state'] = $this->parentEnrollmentState($student);
} else {
$student['transition_evaluation'] = $this->readonlyEnrollmentEvaluation($student['previous_year_decision']);
$student['enrollment_eligibility_message'] = ['message' => '', 'blocking' => false, 'level' => 'info'];
$student['expected_placement_label'] = (string) ($student['class_section'] ?? 'Class not Assigned');
$student['required_action_label'] = 'Read-only closed school year.';
$student['parent_enrollment_state'] = $this->parentEnrollmentState($student);
}
}
@@ -416,8 +406,6 @@ class ParentController extends BaseController
public function enrollClassesHandler()
{
// Call enrollClasses() function at the start of this method
$this->enrollClasses();
$refundService = new FeeCalculationService();
// Retrieve enrollment and withdrawal data from the POST request
@@ -460,25 +448,70 @@ class ParentController extends BaseController
if (!empty($enroll)) {
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
$blockingDecisionMessages = $this->blockedEnrollmentDecisionMessages(array_map('intval', (array) $enroll), $selectedYear);
if ($blockingDecisionMessages !== []) {
return redirect()->back()->withInput()->with('error', implode(' ', $blockingDecisionMessages));
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
if ($previousSchoolYear === null) {
return redirect()->back()->withInput()->with('error', 'Enrollment cannot be submitted because the closing school year could not be determined.');
}
$financialBlockers = $this->financialSubmissionBlockers((int) $parentId, $selectedYear);
if ($financialBlockers !== []) {
return redirect()->back()->withInput()->with('error', implode(' ', $financialBlockers));
$parent = $this->userModel->find((int) $parentId);
if (! is_array($parent) || ($parent['user_type'] ?? '') !== 'primary') {
return redirect()->back()->withInput()->with('error', 'Only primary parents can enroll students.');
}
$transitionService = service('enrollmentTransition');
$submittedStudentIds = array_values(array_unique(array_filter(array_map('intval', (array) $enroll), static fn (int $id): bool => $id > 0)));
$evaluations = [];
$errors = [];
foreach ($submittedStudentIds as $studentId) {
try {
$evaluation = $transitionService->evaluateForParent((int) $parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent');
} catch (Throwable $e) {
log_message('error', 'Parent enrollment eligibility evaluation failed for student {studentId}: {message}', [
'studentId' => $studentId,
'message' => $e->getMessage(),
]);
$errors[] = 'Student ID ' . $studentId . ': enrollment eligibility could not be evaluated. Please contact administration.';
continue;
}
$studentInfo = $this->studentModel->find($studentId);
if (! is_array($studentInfo)) {
$errors[] = 'Student ID ' . $studentId . ': student record was not found.';
continue;
}
$studentName = trim((string) ($studentInfo['firstname'] ?? '') . ' ' . (string) ($studentInfo['lastname'] ?? '')) ?: 'Student ID ' . $studentId;
if (empty($evaluation['can_enroll'])) {
$messages = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? []))));
$codes = array_values(array_filter(array_map('strval', array_merge($evaluation['blocking_rule_codes'] ?? [], $evaluation['review_rule_codes'] ?? []))));
$errors[] = $studentName . ': ' . ($messages !== [] ? implode(' ', $messages) : 'Enrollment is not currently allowed' . ($codes !== [] ? ' (' . implode(', ', $codes) . ')' : '') . '.');
continue;
}
if (! $this->studentModel->getStudentSchoolIdByStudentId($studentId)) {
$errors[] = $studentName . ': Student school ID not found.';
continue;
}
$evaluations[$studentId] = $evaluation;
}
if ($errors !== []) {
return redirect()->back()->withInput()->with('error', implode(' ', $errors));
}
$this->db->transStart();
foreach ($enroll as $studentId) {
$studentId = (int) $studentId;
if (! isset($evaluations[$studentId])) {
continue;
}
$evaluation = $evaluations[$studentId];
// Get student full name (supports both string return or array with firstname/lastname)
$studentInfo = $this->studentModel->getFullNameById($studentId);
if (empty($studentName)) {
$studentName = "Student ID $studentId";
log_message('warning', "Name for student ID $studentId not found in students table.");
}
// Save student info into $studentData
$studentData[$studentId] = $studentInfo; // raw return from getFullName()
@@ -490,49 +523,57 @@ class ParentController extends BaseController
->get()
->getRowArray();
$studentSchoolId = $this->studentModel->getStudentSchoolIdByStudentId($studentId);
if (!$studentSchoolId) {
return redirect()->back()->with('error', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']}: Student school ID not found.");
}
$studentName = trim((string) ($studentData[$studentId]['firstname'] ?? '') . ' ' . (string) ($studentData[$studentId]['lastname'] ?? '')) ?: 'Student ID ' . $studentId;
if ($existingEnrollment) {
$isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear);
$targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review';
$targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending';
$update = $this->enrollmentPayloadFromEvaluation($evaluation, [
'is_withdrawn' => 0,
'withdrawal_date' => null,
'enrollment_status' => $targetEnrollmentStatus,
'admission_status' => $targetAdmissionStatus,
'updated_at' => utc_now(),
]);
if ($existingEnrollment['is_withdrawn'] == 1) {
// Reactivate the enrollment if the student was previously withdrawn
$this->enrollmentModel->where('id', $existingEnrollment['id'])->update([
'is_withdrawn' => 0,
'withdrawal_date' => null,
'enrollment_status' => $targetEnrollmentStatus,
'admission_status' => $targetAdmissionStatus,
'updated_at' => utc_now()
]);
$this->enrollmentModel->where('id', $existingEnrollment['id'])->update($update);
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been re-enrolled in enrollment ID {$existingEnrollment['id']}.");
// Apply promotion-based class placement for the upcoming year
$this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment);
} else {
$currentStatus = (string) ($existingEnrollment['enrollment_status'] ?? '');
$update = [
'updated_at' => utc_now(),
];
if ($currentStatus === 'enrolled') {
$update['admission_status'] = 'accepted';
} else {
$update['enrollment_status'] = $targetEnrollmentStatus;
$update['admission_status'] = $targetAdmissionStatus;
}
$this->enrollmentModel->where('id', $existingEnrollment['id'])->update($update);
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled.");
$this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment);
}
$enrollmentId = (int) $existingEnrollment['id'];
if (! empty($evaluation['admin_exception']['id'])) {
$transitionService->markExceptionUsed((int) $evaluation['admin_exception']['id'], $enrollmentId);
}
$transitionService->auditEnrollmentDecision(
$studentId,
$selectedYear,
$previousSchoolYear,
! empty($evaluation['admin_exception']) ? 'parent_enrollment_submitted_with_exception' : 'parent_enrollment_submitted',
(int) $parentId,
$existingEnrollment,
$this->enrollmentAuditPayload($update, $evaluation),
implode(' ', array_map('strval', $evaluation['rule_codes'] ?? []))
);
} else {
$isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear);
$targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review';
$targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending';
// If no enrollment record exists, insert a new enrollment record
$result = $this->enrollmentModel->insert([
$payload = $this->enrollmentPayloadFromEvaluation($evaluation, [
'student_id' => $studentId,
'parent_id' => $parentId,
'school_year' => $selectedYear,
@@ -543,16 +584,38 @@ class ParentController extends BaseController
'admission_status' => $targetAdmissionStatus,
'created_at' => utc_now()
]);
$result = $this->enrollmentModel->insert($payload, true);
if (!$result) {
dd($this->enrollmentModel->errors());
$this->db->transRollback();
return redirect()->back()->withInput()->with('error', $studentName . ': Unable to save enrollment.');
} else {
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been newly enrolled.");
// Apply promotion-based class placement for the upcoming year
$this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment);
}
$enrollmentId = (int) $result;
if (! empty($evaluation['admin_exception']['id'])) {
$transitionService->markExceptionUsed((int) $evaluation['admin_exception']['id'], $enrollmentId);
}
$transitionService->auditEnrollmentDecision(
$studentId,
$selectedYear,
$previousSchoolYear,
! empty($evaluation['admin_exception']) ? 'parent_enrollment_submitted_with_exception' : 'parent_enrollment_submitted',
(int) $parentId,
null,
$this->enrollmentAuditPayload($payload, $evaluation),
implode(' ', array_map('strval', $evaluation['rule_codes'] ?? []))
);
}
}
$this->db->transComplete();
if (! $this->db->transStatus()) {
return redirect()->back()->withInput()->with('error', 'A database error occurred while submitting enrollment.');
}
}
// $studentData now holds info for all students processed
@@ -652,6 +715,74 @@ class ParentController extends BaseController
}
}
private function enrollmentPayloadFromEvaluation(array $evaluation, array $base): array
{
$payload = array_merge($base, [
'source_school_year' => $evaluation['source_school_year'] ?? null,
'deliberation_decision' => $evaluation['deliberation_decision'] ?? null,
'source_grade_id' => $evaluation['source_grade_id'] ?? null,
'assigned_grade_id' => $evaluation['assigned_grade_id'] ?? null,
'source_class_section_id' => $evaluation['source_class_section_id'] ?? null,
'assigned_class_section_id' => $evaluation['assigned_class_section_id'] ?? null,
'class_section_id' => $evaluation['assigned_class_section_id'] ?? ($base['class_section_id'] ?? null),
'placement_status' => $evaluation['placement_status'] ?? null,
'age_reference_date' => $evaluation['age_reference_date'] ?? null,
'age_on_reference_date' => $evaluation['age_on_reference_date'] ?? null,
'adult_student' => ! empty($evaluation['adult_student']) ? 1 : 0,
'parent_enrollment_allowed' => ! empty($evaluation['parent_enrollment_allowed']) ? 1 : 0,
'student_self_enrollment_allowed' => ! empty($evaluation['student_self_enrollment_allowed']) ? 1 : 0,
'exception_required' => ! empty($evaluation['admin_exception']) || ! empty($evaluation['flags']) ? 1 : 0,
'exception_reason' => $this->exceptionReasonFromEvaluation($evaluation),
'registration_submitted_at' => utc_now(),
]);
return $this->filterEnrollmentPayloadByColumns($payload);
}
private function exceptionReasonFromEvaluation(array $evaluation): ?string
{
if (! empty($evaluation['admin_exception'])) {
return 'Admin exception: ' . (string) ($evaluation['admin_exception']['reason_code'] ?? 'approved');
}
$codes = array_values(array_filter(array_map('strval', array_merge(
$evaluation['blocking_rule_codes'] ?? [],
$evaluation['review_rule_codes'] ?? [],
$evaluation['warning_rule_codes'] ?? []
))));
return $codes !== [] ? implode(', ', array_unique($codes)) : null;
}
private function enrollmentAuditPayload(array $payload, array $evaluation): array
{
return [
'enrollment' => $payload,
'eligibility' => [
'decision' => $evaluation['decision'] ?? null,
'can_enroll' => ! empty($evaluation['can_enroll']),
'rule_codes' => $evaluation['rule_codes'] ?? [],
'blocking_rule_codes' => $evaluation['blocking_rule_codes'] ?? [],
'review_rule_codes' => $evaluation['review_rule_codes'] ?? [],
'warning_rule_codes' => $evaluation['warning_rule_codes'] ?? [],
'admin_exception' => $evaluation['admin_exception'] ?? null,
'financial_summary' => $evaluation['financial_summary'] ?? null,
'last_name_exception_carry_forward' => $evaluation['last_name_exception_carry_forward'] ?? null,
],
];
}
private function filterEnrollmentPayloadByColumns(array $payload): array
{
foreach (array_keys($payload) as $column) {
if (! $this->db->fieldExists($column, 'enrollments')) {
unset($payload[$column]);
}
}
return $payload;
}
private function hasAcceptedPolicyForYear(int $parentId, string $schoolYear): bool
{
if ($parentId <= 0 || $schoolYear === '') {
@@ -1243,7 +1374,8 @@ class ParentController extends BaseController
try {
$studentName = $this->studentNameForEnrollmentMessage($studentId);
$evaluation = service('enrollmentTransition')->evaluate($studentId, $previousSchoolYear, $targetSchoolYear, 'parent');
$parentId = (int) session()->get('user_id');
$evaluation = service('enrollmentTransition')->evaluateForParent($parentId, $studentId, $previousSchoolYear, $targetSchoolYear, 'parent');
foreach ($evaluation['blockers'] ?? [] as $blocker) {
$blocker = trim((string) $blocker);
if ($blocker !== '') {
@@ -1268,10 +1400,10 @@ class ParentController extends BaseController
return EnrollmentEligibility::parentDecisionMessage($student, $decisionRow, $targetSchoolYear, $fallMakeupExamOn);
}
private function transitionEvaluationForStudent(int $studentId, string $previousSchoolYear, string $selectedYear): ?array
private function transitionEvaluationForStudent(int $parentId, int $studentId, string $previousSchoolYear, string $selectedYear): ?array
{
try {
return service('enrollmentTransition')->evaluate($studentId, $previousSchoolYear, $selectedYear, 'parent');
return service('enrollmentTransition')->evaluateForParent($parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent');
} catch (\Throwable $e) {
log_message('error', 'Enrollment transition evaluation failed for student ' . $studentId . ': ' . $e->getMessage());
@@ -1317,6 +1449,14 @@ class ParentController extends BaseController
);
}
if (! empty($evaluation['can_enroll']) && ! empty($evaluation['admin_exception'])) {
return [
'message' => 'Enrollment has been authorized by administration.',
'blocking' => false,
'level' => 'info',
];
}
$blockers = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? []))));
if ($blockers !== []) {
$name = $this->studentNameFromRow($student);
@@ -1409,31 +1549,80 @@ class ParentController extends BaseController
private function requiredActionLabel(?array $evaluation): string
{
if ($evaluation === null) {
return 'Complete re-enrollment before the registration deadline.';
return 'Contact administration';
}
if (($evaluation['blockers'] ?? []) !== []) {
if (($evaluation['adult_student'] ?? false) && ! ($evaluation['parent_enrollment_allowed'] ?? false)) {
return 'Student must complete the authorized adult-student process or contact administration.';
$state = $this->parentEnrollmentStateFromEvaluation($evaluation, null);
return match ($state) {
'Enroll' => 'Complete re-enrollment before the registration deadline.',
'Eligible with follow-up' => 'Complete re-enrollment and follow the listed next step.',
'Already submitted' => 'Already submitted',
'Action needed' => 'Action needed: pay the previous-year balance or contact administration.',
'Under review' => 'Under review. Contact the school administration.',
default => 'Contact administration',
};
}
private function parentEnrollmentState(array $student): string
{
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
if (in_array($status, [
'admission under review',
'review & decision',
'payment pending',
'enrolled',
'waitlist',
'withdraw under review',
], true)) {
return 'Already submitted';
}
return $this->parentEnrollmentStateFromEvaluation(
is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : null,
$status
);
}
private function parentEnrollmentStateFromEvaluation(?array $evaluation, ?string $enrollmentStatus): string
{
if ($evaluation === null) {
return 'Contact administration';
}
$decision = (string) ($evaluation['decision'] ?? '');
$codes = array_map('strval', $evaluation['blocking_rule_codes'] ?? []);
if ($decision === 'ALREADY_ENROLLED' || $enrollmentStatus === 'already enrolled') {
return 'Already submitted';
}
if (! empty($evaluation['can_enroll']) || $decision === 'EXCEPTION_ELIGIBLE' || $decision === 'ELIGIBLE') {
if ($decision === 'ELIGIBLE_WITH_WARNING' || ($evaluation['warning_rule_codes'] ?? []) !== []) {
return 'Eligible with follow-up';
}
$decision = (string) ($evaluation['deliberation_decision'] ?? '');
if (in_array($decision, [
DeliberationDecision::EXPELLED,
DeliberationDecision::WITHDRAWN,
DeliberationDecision::DEFERRED_DECISION,
], true)) {
return 'Contact the school administration.';
}
return 'Review the eligibility message above.';
return 'Enroll';
}
if (($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM) {
return 'Complete re-enrollment and follow make-up exam instructions.';
if ($decision === 'ELIGIBLE_WITH_WARNING') {
return 'Eligible with follow-up';
}
return 'Complete re-enrollment before the registration deadline.';
if (in_array('OUTSTANDING_BALANCE_BLOCKED', $codes, true)
|| in_array('FINANCE_APPROVAL_REQUIRED', $codes, true)
|| in_array('SIBLING_LAST_NAME_MISMATCH', $codes, true)
) {
return 'Action needed';
}
if ($decision === 'REVIEW_REQUIRED' || in_array((string) ($evaluation['deliberation_decision'] ?? ''), [
DeliberationDecision::EXPELLED,
DeliberationDecision::WITHDRAWN,
DeliberationDecision::DEFERRED_DECISION,
], true)) {
return 'Under review';
}
return 'Contact administration';
}
private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear): array
@@ -1444,7 +1633,7 @@ class ParentController extends BaseController
$registrationFee = round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2);
$tuitionDue = round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2);
$mandatoryFees = round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2);
$behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'information_only');
$behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment');
$amountDue = max(0.0, $carryOver) + $registrationFee + $tuitionDue + $mandatoryFees;
return [
@@ -1461,21 +1650,6 @@ class ParentController extends BaseController
];
}
private function financialSubmissionBlockers(int $parentId, string $selectedYear): array
{
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
$summary = $this->familyFinancialSummary($parentId, $previousSchoolYear, $selectedYear);
if (($summary['carry_over_balance'] ?? 0.0) <= 0.0) {
return [];
}
return match ((string) ($summary['balance_behavior'] ?? 'information_only')) {
'submission_blocked_until_payment' => ['Registration cannot be submitted until the previous-year balance is paid.'],
'admin_approval_required' => ['Registration requires administrative financial approval because there is a previous-year balance.'],
default => [],
};
}
private function financialPolicyMessage(string $behavior, string $configured): string
{
$configured = trim($configured);
@@ -1488,7 +1662,7 @@ class ParentController extends BaseController
'submission_allowed_confirmation_blocked' => 'Registration may be submitted, but it will not be confirmed until the balance is settled.',
'submission_blocked_until_payment' => 'The balance must be paid before registration can be submitted.',
'admin_approval_required' => 'Please contact the finance office to arrange an approved exception.',
default => 'The balance is shown for information and does not currently block registration.',
default => 'The previous-year balance must be paid before registration can be submitted.',
};
}
@@ -1784,6 +1958,10 @@ class ParentController extends BaseController
public function profile($id)
{
if (! $this->canAccessUserRecord((int) $id)) {
return redirect()->to('/access_denied');
}
// Fetch the user's data based on the given ID
$user = $this->userModel->find($id);
@@ -1798,6 +1976,10 @@ class ParentController extends BaseController
public function updateProfile($id)
{
if (! $this->canAccessUserRecord((int) $id)) {
return redirect()->to('/access_denied');
}
$user = $this->userModel->find($id);
// Step 1: Check if user exists
@@ -2937,4 +3119,22 @@ $existing = $this->studentModel
return redirect()->back()->with('success', 'Participation updated');
}
private function canAccessUserRecord(int $id): bool
{
$userId = (int) (session()->get('user_id') ?? 0);
if ($userId <= 0 || $id <= 0) {
return false;
}
if ($userId === $id) {
return true;
}
$roles = array_map(
static fn ($role): string => strtolower(trim((string) $role)),
array_filter(array_merge((array) session()->get('roles'), [session()->get('role')]))
);
return (bool) array_intersect($roles, ['administrator', 'administrative staff', 'principal', 'admin']);
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ConfigurationModel;
use App\Models\FinancialAidRequestModel;
use App\Models\StudentModel;
class ParentFinancialAidController extends BaseController
{
public function index()
{
$parentId = (int) session()->get('user_id');
if ($parentId <= 0) {
return redirect()->to('/login');
}
$schoolYear = (string) ((new ConfigurationModel())->getConfig('school_year') ?? '');
$model = new FinancialAidRequestModel();
$requests = $model
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderBy('id', 'DESC')
->findAll();
$students = (new StudentModel())
->where('parent_id', $parentId)
->orderBy('lastname', 'ASC')
->orderBy('firstname', 'ASC')
->findAll();
return view('parent/financial_aid', [
'schoolYear' => $schoolYear,
'students' => $students,
'requests' => $requests,
'openRequest' => $model->openRequestForParent($parentId, $schoolYear),
]);
}
public function submit()
{
$parentId = (int) session()->get('user_id');
if ($parentId <= 0) {
return redirect()->to('/login');
}
$schoolYear = (string) ((new ConfigurationModel())->getConfig('school_year') ?? '');
$model = new FinancialAidRequestModel();
if ($model->openRequestForParent($parentId, $schoolYear) !== null) {
return redirect()->back()->with('error', 'You already have an open financial aid request for this school year.');
}
$studentIds = array_values(array_unique(array_filter(array_map('intval', (array) $this->request->getPost('student_ids')))));
$linkedIds = array_map('intval', array_column(
(new StudentModel())->select('id')->where('parent_id', $parentId)->findAll(),
'id'
));
$studentIds = array_values(array_intersect($studentIds, $linkedIds));
if ($studentIds === []) {
return redirect()->back()->withInput()->with('error', 'Select at least one of your students.');
}
$needStatement = trim((string) $this->request->getPost('need_statement'));
if ($needStatement === '') {
return redirect()->back()->withInput()->with('error', 'Please describe why you are requesting financial aid.');
}
$householdSize = (int) $this->request->getPost('household_size');
$requestedAmount = trim((string) $this->request->getPost('requested_amount'));
$model->insert([
'parent_id' => $parentId,
'school_year' => $schoolYear,
'student_ids_json' => json_encode($studentIds),
'household_size' => $householdSize > 0 ? $householdSize : null,
'need_statement' => $needStatement,
'requested_amount' => $requestedAmount !== '' ? (float) $requestedAmount : null,
'status' => 'submitted',
]);
return redirect()->to('/parent/financial-aid')->with('success', 'Your financial aid request was submitted.');
}
}
+31 -8
View File
@@ -20,13 +20,16 @@ class PreferencesController extends BaseController
*/
public function index($userId = null)
{
// Get user ID from parameter or session
$userId = $userId ?? (int) session()->get('user_id');
if (!$userId) {
$sessionUserId = (int) (session()->get('user_id') ?? 0);
if ($sessionUserId <= 0) {
return redirect()->to('/login')->with('error', 'Please log in to view preferences');
}
$userId = (int) ($userId ?? $sessionUserId);
if (! $this->canAccessUserPreferences($userId, $sessionUserId)) {
return redirect()->to('/access_denied');
}
// Fetch preferences for the current user
$preferences = $this->preferencesModel->where('user_id', $userId)->first();
@@ -64,13 +67,16 @@ class PreferencesController extends BaseController
*/
public function updatePreferences($userId = null)
{
// Get user ID from parameter or session
$userId = $userId ?? (int) session()->get('user_id');
if (!$userId) {
$sessionUserId = (int) (session()->get('user_id') ?? 0);
if ($sessionUserId <= 0) {
return redirect()->to('/login')->with('error', 'Please log in to update preferences');
}
$userId = (int) ($userId ?? $sessionUserId);
if (! $this->canAccessUserPreferences($userId, $sessionUserId)) {
return redirect()->to('/access_denied');
}
// Validation rules
$validation = \Config\Services::validation();
@@ -141,4 +147,21 @@ class PreferencesController extends BaseController
// Redirect back to preferences page with success message
return redirect()->to('/preferences/' . $userId)->with('success', 'Preferences updated successfully');
}
private function canAccessUserPreferences(int $requestedUserId, int $sessionUserId): bool
{
if ($requestedUserId <= 0 || $sessionUserId <= 0) {
return false;
}
if ($requestedUserId === $sessionUserId) {
return true;
}
$roles = array_map(
static fn ($role): string => strtolower(trim((string) $role)),
array_filter(array_merge((array) session()->get('roles'), [session()->get('role')]))
);
return (bool) array_intersect($roles, ['administrator', 'administrative staff', 'principal', 'admin']);
}
}
+214 -49
View File
@@ -914,28 +914,37 @@ class StudentController extends BaseController
}
$total = count($cands);
if ($sectionCount * $minPerSection > $total) {
$msg = 'Insufficient students: ' . $sectionCount . ' sections require at least ' . ($sectionCount * $minPerSection) . ' students, but only ' . $total . ' are available.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
if ($maxPerSection !== null && $total > $sectionCount * $maxPerSection) {
$msg = 'Capacity exceeded: ' . $sectionCount . ' sections can hold at most ' . ($sectionCount * $maxPerSection) . ' students, but ' . $total . ' must be assigned.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
// Fetch lettered sections for this class
$baseSection = $this->sectionForDistribution($classSectionId, $year);
// Fetch lettered sections for this class. The requested section count is a max:
// if the class cannot split into 2+ sections, keep the assignment on the base grade.
$letters = $this->letterSectionsForDistribution($classId, $year);
if (empty($letters)) {
$msg = 'No lettered sections found for the selected class.';
$availableSectionCount = count($letters);
if (!$baseSection || (int)($baseSection['class_id'] ?? 0) !== $classId) {
$msg = 'No base grade found for the selected class.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
if (count($letters) < $sectionCount) {
$msg = 'Not enough sections available. Needed: ' . $sectionCount . ', available: ' . count($letters);
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
$actualSectionCount = min($sectionCount, $availableSectionCount);
if ($minPerSection > 0) {
$actualSectionCount = min($actualSectionCount, max(1, intdiv($total, $minPerSection)));
}
if ($maxPerSection !== null) {
$minimumNeededForCapacity = (int)ceil($total / $maxPerSection);
$capacitySectionCount = max(1, $availableSectionCount);
if ($minimumNeededForCapacity > $capacitySectionCount || $minimumNeededForCapacity > $sectionCount) {
$msg = 'Capacity exceeded: available sections can hold at most ' . ($capacitySectionCount * $maxPerSection) . ' students, but ' . $total . ' must be assigned.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 400) : redirect()->back()->with('error', $msg);
}
$actualSectionCount = max($actualSectionCount, $minimumNeededForCapacity);
}
$letters = array_slice($letters, 0, $sectionCount);
if ($actualSectionCount < 2) {
$letters = [$baseSection];
} else {
$letters = array_slice($letters, 0, $actualSectionCount);
}
$buckets = $this->buildBalancedDistribution($cands, $letters, $minPerSection, $maxPerSection);
$draftModel = new StudentSectionDistributionDraftModel();
@@ -946,21 +955,11 @@ class StudentController extends BaseController
$draftIdByStudentId = [];
$this->db->transStart();
$studentIdsToReplace = array_values(array_unique(array_map(
static fn(array $student): int => (int)($student['student_id'] ?? 0),
$cands
)));
if (!empty($studentIdsToReplace)) {
$draftModel->where('school_year', $year)
->whereIn('student_id', $studentIdsToReplace)
->where('status', 'pending')
->delete();
}
foreach ($buckets as $b) {
$secId = (int)$b['class_section_id'];
foreach ($b['assigned'] as $student) {
$sid = (int)$student['student_id'];
$draftId = (int)$draftModel->insert([
$draftId = $this->upsertDistributionDraft($draftModel, [
'student_id' => $sid,
'class_id' => $classId,
'class_section_id' => $secId,
@@ -971,7 +970,6 @@ class StudentController extends BaseController
'status' => 'pending',
'batch_key' => $batchKey,
'created_by' => $updatedBy,
'created_at' => $now,
'updated_at' => $now,
]);
if ($draftId > 0) {
@@ -1026,6 +1024,7 @@ class StudentController extends BaseController
'age_at_reference' => $student['age_at_reference'] ?? null,
'gender' => (string)($student['gender'] ?? ''),
'last_year_class_section' => (string)($student['last_year_class_section'] ?? ''),
'previous_final_score' => $student['previous_final_score'] ?? null,
'class_id' => $classId,
'class_section_id' => $secId,
'class_section_name' => $nameById[$secId] ?? (string)$secId,
@@ -1182,12 +1181,7 @@ class StudentController extends BaseController
$now = utc_now();
$this->db->transStart();
$draftModel->where('student_id', $studentId)
->where('school_year', $year)
->where('status', 'pending')
->delete();
$draftId = (int)$draftModel->insert([
$draftId = $this->upsertDistributionDraft($draftModel, [
'student_id' => $studentId,
'class_id' => $targetClassId,
'class_section_id' => $targetSectionId,
@@ -1198,7 +1192,6 @@ class StudentController extends BaseController
'status' => 'pending',
'batch_key' => sha1($year . ':' . $studentId . ':' . microtime(true)),
'created_by' => $updatedBy,
'created_at' => $now,
'updated_at' => $now,
]);
@@ -1286,7 +1279,8 @@ class StudentController extends BaseController
return $this->mergeDistributionCandidates(
$out,
$this->decisionDistributionCandidates($classId, $year),
$this->currentYearDistributionCandidates($classId, $year)
$this->currentYearDistributionCandidates($classId, $year),
$this->registeredKgDistributionCandidates($classId, $year)
);
}
@@ -1302,7 +1296,7 @@ class StudentController extends BaseController
$builder = $this->db->table('student_class sc')
->select('0 AS promotion_queue_id, sc.student_id, sc.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false)
->join('students', 'students.id = sc.student_id', 'inner')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id AND cs.school_year = sc.school_year', 'left')
->where('sc.school_year', $year)
->where('sc.class_section_id IS NOT NULL', null, false);
@@ -1325,7 +1319,7 @@ class StudentController extends BaseController
$builder = $this->db->table('enrollments e')
->select('0 AS promotion_queue_id, e.student_id, e.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false)
->join('students', 'students.id = e.student_id', 'inner')
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left')
->where('e.school_year', $year)
->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
->groupStart()
@@ -1413,6 +1407,40 @@ class StudentController extends BaseController
return $out;
}
private function upsertDistributionDraft(StudentSectionDistributionDraftModel $draftModel, array $data): int
{
$studentId = (int)($data['student_id'] ?? 0);
$year = (string)($data['school_year'] ?? '');
if ($studentId <= 0 || $year === '') {
return 0;
}
$existing = $draftModel
->where('student_id', $studentId)
->where('school_year', $year)
->first();
if ($existing) {
$draftId = (int)($existing['id'] ?? 0);
if ($draftId <= 0) {
return 0;
}
unset($data['created_at']);
$data['status'] = 'pending';
$data['applied_at'] = null;
$draftModel->update($draftId, $data);
return $draftId;
}
if (empty($data['created_at'])) {
$data['created_at'] = $data['updated_at'] ?? utc_now();
}
return (int)$draftModel->insert($data);
}
private function kgDistributionCandidates(int $classId, string $year): array
{
if ($classId <= 0 || $year === '' || ! $this->db->tableExists('enrollments') || ! $this->db->tableExists('students')) {
@@ -1422,7 +1450,7 @@ class StudentController extends BaseController
$builder = $this->db->table('enrollments e')
->select('0 AS promotion_queue_id, e.student_id, e.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false)
->join('students', 'students.id = e.student_id', 'inner')
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left')
->where('e.school_year', $year)
->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
->groupStart()
@@ -1515,7 +1543,12 @@ class StudentController extends BaseController
if ($this->db->fieldExists('school_year', 'students')) {
$builder->where('school_year', $year);
} elseif ($this->db->fieldExists('year_of_registration', 'students') && preg_match('/^(\d{4})/', $year, $matches)) {
$builder->where('year_of_registration', (int)$matches[1]);
$registrationYears = [(int)$matches[1]];
$previousYear = $this->previousSchoolYearName($year);
if ($previousYear !== null && preg_match('/^(\d{4})/', $previousYear, $previousMatches)) {
$registrationYears[] = (int)$previousMatches[1];
}
$builder->whereIn('year_of_registration', array_values(array_unique($registrationYears)));
}
if ($this->db->fieldExists('is_active', 'students')) {
@@ -1537,6 +1570,10 @@ class StudentController extends BaseController
continue;
}
if (!$this->studentHasOnlyKgPriorPlacement($studentId, $year)) {
continue;
}
$ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year);
$targetClassId = $this->distributionTargetClassIdForStudent(
$classId,
@@ -1565,6 +1602,72 @@ class StudentController extends BaseController
return $out;
}
private function studentHasOnlyKgPriorPlacement(int $studentId, string $targetSchoolYear): bool
{
$previousYear = $this->previousSchoolYearName($targetSchoolYear);
if ($studentId <= 0 || $previousYear === null) {
return true;
}
$baseNames = [];
if ($this->db->tableExists('student_class')) {
$builder = $this->db->table('student_class sc')
->select('cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id AND cs.school_year = sc.school_year', 'left')
->where('sc.student_id', $studentId)
->where('sc.school_year', $previousYear)
->where('sc.class_section_id IS NOT NULL', null, false);
if ($this->db->fieldExists('is_event_only', 'student_class')) {
$builder->groupStart()
->where('sc.is_event_only', 0)
->orWhere('sc.is_event_only', null)
->groupEnd();
}
foreach ($builder->get()->getResultArray() as $row) {
$baseName = $this->baseClassNameForDistribution((string)($row['class_section_name'] ?? ''));
if ($baseName !== '') {
$baseNames[$baseName] = true;
}
}
}
if ($this->db->tableExists('enrollments')) {
$rows = $this->db->table('enrollments e')
->select('cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left')
->where('e.student_id', $studentId)
->where('e.school_year', $previousYear)
->where('e.class_section_id IS NOT NULL', null, false)
->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled'])
->groupStart()
->where('e.is_withdrawn', 0)
->orWhere('e.is_withdrawn', null)
->groupEnd()
->get()
->getResultArray();
foreach ($rows as $row) {
$baseName = $this->baseClassNameForDistribution((string)($row['class_section_name'] ?? ''));
if ($baseName !== '') {
$baseNames[$baseName] = true;
}
}
}
if (empty($baseNames)) {
return true;
}
return count($baseNames) === 1 && isset($baseNames['KG']);
}
private function baseClassNameForDistribution(string $classSectionName): string
{
return strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? ''));
}
private function isDistributionKgClass(int $classId, string $year): bool
{
if ($classId <= 0) {
@@ -1722,7 +1825,8 @@ class StudentController extends BaseController
$targetClassId = $this->targetClassIdFromDecision(
(string)($row['class_section_name'] ?? ''),
(string)($row['decision'] ?? '')
(string)($row['decision'] ?? ''),
$targetSchoolYear
);
$ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $targetSchoolYear);
$targetClassId = $this->distributionTargetClassIdForStudent(
@@ -1754,7 +1858,7 @@ class StudentController extends BaseController
return $out;
}
private function targetClassIdFromDecision(string $classSectionName, string $decision): ?int
private function targetClassIdFromDecision(string $classSectionName, string $decision, string $targetSchoolYear = ''): ?int
{
$baseName = strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? ''));
if ($baseName === '') {
@@ -1767,17 +1871,30 @@ class StudentController extends BaseController
$targetBaseName = '1';
} elseif (ctype_digit($baseName)) {
$level = (int)$baseName;
$targetBaseName = $level >= 9 ? 'YOUTH' : (string)($level + 1);
$targetBaseName = $level >= 10 ? 'YOUTH' : (string)($level + 1);
} elseif ($baseName === 'YOUTH') {
$targetBaseName = 'YOUTH';
}
}
$row = $this->classSectionModel
$query = $this->classSectionModel
->select('class_id')
->where('UPPER(class_section_name)', $targetBaseName)
->where("class_section_name NOT LIKE '%-%'", null, false)
->first();
->orderBy('id', 'DESC');
if ($targetSchoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) {
$query->where('school_year', $targetSchoolYear);
}
$row = $query->first();
if (!$row && $targetSchoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) {
$row = $this->classSectionModel
->select('class_id')
->where('UPPER(class_section_name)', $targetBaseName)
->where("class_section_name NOT LIKE '%-%'", null, false)
->first();
}
return $row ? (int)$row['class_id'] : null;
}
@@ -1884,7 +2001,7 @@ class StudentController extends BaseController
if ($this->db->tableExists('student_class')) {
$builder = $this->db->table('student_class sc')
->select('cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id AND cs.school_year = sc.school_year', 'left')
->where('sc.student_id', $studentId)
->where('sc.school_year', $previousYear)
->where('sc.class_section_id IS NOT NULL', null, false);
@@ -1911,7 +2028,7 @@ class StudentController extends BaseController
if (empty($names) && $this->db->tableExists('enrollments')) {
$rows = $this->db->table('enrollments e')
->select('cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
->join('classSection cs', 'cs.class_section_id = e.class_section_id AND cs.school_year = e.school_year', 'left')
->where('e.student_id', $studentId)
->where('e.school_year', $previousYear)
->where('e.class_section_id IS NOT NULL', null, false)
@@ -2236,6 +2353,7 @@ class StudentController extends BaseController
'age_at_reference' => $student['age_at_reference'] ?? null,
'gender' => (string)($student['gender'] ?? ''),
'last_year_class_section' => (string)($student['last_year_class_section'] ?? ''),
'previous_final_score' => $student['previous_final_score'] ?? null,
'class_id' => $classId,
'class_section_id' => 0,
'class_section_name' => $className,
@@ -2277,7 +2395,26 @@ class StudentController extends BaseController
}
$timezone = new \DateTimeZone((string)(config('School')->attendance['timezone'] ?? user_timezone()));
$reference = new \DateTimeImmutable($matches[1] . '-09-01', $timezone);
$reference = null;
$configuredReference = '';
try {
$configuredReference = trim((string)($this->configModel ? $this->configModel->getConfig('date_age_reference') : ''));
} catch (\Throwable $e) {
$configuredReference = '';
}
if ($configuredReference !== '') {
$candidate = \DateTimeImmutable::createFromFormat('!Y-m-d', $configuredReference, $timezone);
$errors = \DateTimeImmutable::getLastErrors();
$hasErrors = is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0);
if ($candidate !== false && !$hasErrors && $candidate->format('Y') === $matches[1]) {
$reference = $candidate;
}
}
if ($reference === null) {
$reference = new \DateTimeImmutable($matches[1] . '-09-01', $timezone);
}
return $reference->setTime(0, 0, 0);
}
@@ -2312,6 +2449,10 @@ class StudentController extends BaseController
return $this->distributionBaseClassIdByName('KG', $schoolYear);
}
if ($defaultClassId === null && $ageAtReference === 6) {
return $this->distributionBaseClassIdByName('1', $schoolYear);
}
if ($this->isDistributionKgSource($defaultClassId, $sourceClassName, $schoolYear)) {
if ($ageAtReference !== null && $ageAtReference < 6) {
return $this->distributionBaseClassIdByName('KG', $schoolYear) ?? $defaultClassId;
@@ -2474,8 +2615,8 @@ class StudentController extends BaseController
}
$builder = $this->db->table('student_section_distribution_drafts d')
->select('d.id AS draft_id, d.class_id, d.class_section_id, d.previous_school_year, cs.class_section_name, students.firstname, students.lastname, students.gender, students.dob, d.student_id')
->join('classSection cs', 'cs.class_section_id = d.class_section_id', 'left')
->select('d.id AS draft_id, d.class_id, d.class_section_id, d.previous_school_year, d.previous_final_score, cs.class_section_name, students.firstname, students.lastname, students.gender, students.dob, d.student_id')
->join('classSection cs', 'cs.class_section_id = d.class_section_id AND cs.school_year = d.school_year', 'left')
->join('students', 'students.id = d.student_id', 'left')
->where('d.class_id', $classId)
->where('d.school_year', $year)
@@ -2511,6 +2652,10 @@ class StudentController extends BaseController
'class_section_id' => $sectionId,
'class_section_name' => (string)($row['class_section_name'] ?? $sectionId),
'total' => 0,
'male' => 0,
'female' => 0,
'score_total' => 0.0,
'score_count' => 0,
'student_names' => [],
'student_assignments' => [],
];
@@ -2520,6 +2665,16 @@ class StudentController extends BaseController
if ($name === '') {
$name = 'Student #' . $studentId;
}
$gender = strtolower((string)($row['gender'] ?? ''));
if ($gender === 'female') {
$sections[$sectionId]['female']++;
} else {
$sections[$sectionId]['male']++;
}
if (is_numeric($row['previous_final_score'] ?? null)) {
$sections[$sectionId]['score_total'] += (float)$row['previous_final_score'];
$sections[$sectionId]['score_count']++;
}
$sections[$sectionId]['student_names'][] = $name;
$sections[$sectionId]['student_assignments'][] = [
'draft_id' => (int)($row['draft_id'] ?? 0),
@@ -2527,6 +2682,7 @@ class StudentController extends BaseController
'student_name' => $name,
'age_at_reference' => $this->distributionAgeAtReference($row['dob'] ?? null, $year),
'gender' => (string)($row['gender'] ?? ''),
'previous_final_score' => is_numeric($row['previous_final_score'] ?? null) ? (float)$row['previous_final_score'] : null,
'last_year_class_section' => $this->distributionPreviousClassSectionName(
$studentId,
$year,
@@ -2538,6 +2694,15 @@ class StudentController extends BaseController
$sections[$sectionId]['total']++;
}
foreach ($sections as &$section) {
$scoreCount = (int)($section['score_count'] ?? 0);
$section['average_score'] = $scoreCount > 0
? round((float)$section['score_total'] / $scoreCount, 2)
: null;
unset($section['score_total'], $section['score_count']);
}
unset($section);
return array_values($sections);
}
@@ -73,7 +73,7 @@ class CreateWhatsappGroupMemberships extends Migration
$this->forge->addKey(['class_section_id', 'school_year', 'semester']);
$this->forge->addUniqueKey(['class_section_id', 'school_year', 'semester', 'subject_type', 'subject_id'], 'uniq_whatsapp_membership');
$this->forge->createTable('whatsapp_group_memberships');
$this->forge->createTable('whatsapp_group_memberships', true);
}
public function down()
@@ -55,7 +55,7 @@ class CreateReimbursementBatchAdminFiles extends Migration
$this->forge->addKey('batch_id');
$this->forge->addKey('admin_id');
$this->forge->addUniqueKey(['batch_id', 'admin_id']);
$this->forge->createTable('reimbursement_batch_admin_files');
$this->forge->createTable('reimbursement_batch_admin_files', true);
}
public function down()
@@ -68,7 +68,7 @@ class CreatePrintRequests extends Migration
if ($this->db->tableExists('classes')) {
$this->forge->addForeignKey('class_id', 'classes', 'id', 'CASCADE', 'CASCADE');
}
$this->forge->createTable('print_requests');
$this->forge->createTable('print_requests', true);
}
public function down()
@@ -30,6 +30,16 @@ class RevertPrintRequestsForeignKey extends Migration
return;
}
$orphanCount = (int) $db->table('print_requests pr')
->join('classes c', 'c.id = pr.class_id', 'left')
->where('pr.class_id IS NOT NULL', null, false)
->where('c.id IS NULL', null, false)
->countAllResults();
if ($orphanCount > 0) {
return;
}
// Add the new foreign key
$this->forge->addForeignKey('class_id', 'classes', 'id', 'CASCADE', 'CASCADE');
@@ -29,6 +29,16 @@ class FixPrintRequestsForeignKeyOnceAndForAll extends Migration
return;
}
$orphanCount = (int) $db->table('print_requests pr')
->join('classSection cs', 'cs.id = pr.class_id', 'left')
->where('pr.class_id IS NOT NULL', null, false)
->where('cs.id IS NULL', null, false)
->countAllResults();
if ($orphanCount > 0) {
return;
}
$this->forge->addForeignKey('class_id', 'classSection', 'id', 'CASCADE', 'CASCADE');
$this->forge->processIndexes('print_requests');
}
@@ -20,6 +20,16 @@ class FixPrintRequestsForeignKeyAgain extends Migration
}
}
$orphanCount = (int) $db->table('print_requests pr')
->join('classSection cs', 'cs.class_id = pr.class_id', 'left')
->where('pr.class_id IS NOT NULL', null, false)
->where('cs.class_id IS NULL', null, false)
->countAllResults();
if ($orphanCount > 0) {
return;
}
$this->forge->addForeignKey('class_id', 'classSection', 'class_id', 'CASCADE', 'CASCADE');
$this->forge->processIndexes('print_requests');
}
@@ -36,7 +36,7 @@ class CreateClassProgressReports extends Migration
$this->forge->addKey('id', true);
$this->forge->addKey(['class_section_id', 'week_start', 'week_end']);
$this->forge->createTable('class_progress_reports');
$this->forge->createTable('class_progress_reports', true);
}
public function down()
@@ -54,7 +54,7 @@ class CreateSubjectCurriculumItems extends Migration
$this->forge->addKey('id', true);
$this->forge->addKey(['class_id', 'subject']);
$this->forge->createTable('subject_curriculum_items');
$this->forge->createTable('subject_curriculum_items', true);
}
public function down()
@@ -63,7 +63,7 @@ class CreateTeacherSubmissionNotificationHistory extends Migration
$this->forge->addKey('id', true);
$this->forge->addKey(['teacher_id', 'class_section_id']);
$this->forge->createTable('teacher_submission_notification_history');
$this->forge->createTable('teacher_submission_notification_history', true);
}
public function down()
@@ -100,7 +100,7 @@ class CreateExamDraftSubmissions extends Migration
$this->forge->addKey('id', true);
$this->forge->addKey(['teacher_id', 'class_section_id']);
$this->forge->createTable('exam_drafts');
$this->forge->createTable('exam_drafts', true);
}
public function down()
@@ -8,19 +8,27 @@ class AddVersionToExamDrafts extends Migration
{
public function up()
{
$this->forge->addColumn('exam_drafts', [
'version' => [
$fields = [];
if (! $this->db->fieldExists('version', 'exam_drafts')) {
$fields['version'] = [
'type' => 'INT',
'unsigned' => true,
'default' => 1,
],
'previous_draft_id' => [
];
}
if (! $this->db->fieldExists('previous_draft_id', 'exam_drafts')) {
$fields['previous_draft_id'] = [
'type' => 'INT',
'unsigned' => true,
'null' => true,
],
]);
$this->forge->addKey('version');
];
}
if ($fields !== []) {
$this->forge->addColumn('exam_drafts', $fields);
}
}
public function down()
@@ -8,6 +8,10 @@ class AddFinalPdfToExamDrafts extends Migration
{
public function up()
{
if ($this->db->fieldExists('final_pdf_file', 'exam_drafts')) {
return;
}
$this->forge->addColumn('exam_drafts', [
'final_pdf_file' => [
'type' => 'VARCHAR',
@@ -20,7 +24,8 @@ class AddFinalPdfToExamDrafts extends Migration
public function down()
{
$this->forge->dropColumn('exam_drafts', 'final_pdf_file');
if ($this->db->fieldExists('final_pdf_file', 'exam_drafts')) {
$this->forge->dropColumn('exam_drafts', 'final_pdf_file');
}
}
}
@@ -8,6 +8,10 @@ class AddIsLegacyToExamDrafts extends Migration
{
public function up()
{
if ($this->db->fieldExists('is_legacy', 'exam_drafts')) {
return;
}
$this->forge->addColumn('exam_drafts', [
'is_legacy' => [
'type' => 'TINYINT',
@@ -20,7 +24,8 @@ class AddIsLegacyToExamDrafts extends Migration
public function down()
{
$this->forge->dropColumn('exam_drafts', 'is_legacy');
if ($this->db->fieldExists('is_legacy', 'exam_drafts')) {
$this->forge->dropColumn('exam_drafts', 'is_legacy');
}
}
}
@@ -24,7 +24,7 @@ class CreateClassProgressAttachments extends Migration
$this->forge->addKey('id', true);
$this->forge->addKey('report_id');
$this->forge->createTable('class_progress_attachments');
$this->forge->createTable('class_progress_attachments', true);
}
public function down()
@@ -26,7 +26,7 @@ class CreatePlacementLevels extends Migration
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['student_id', 'school_year'], 'unique_student_school_year');
$this->forge->addKey('school_year');
$this->forge->createTable('placement_levels');
$this->forge->createTable('placement_levels', true);
}
public function down()
@@ -24,7 +24,7 @@ class CreatePlacementBatches extends Migration
$this->forge->addKey('id', true);
$this->forge->addKey(['placement_test', 'school_year']);
$this->forge->createTable('placement_batches');
$this->forge->createTable('placement_batches', true);
}
public function down()
@@ -26,7 +26,7 @@ class CreatePlacementScores extends Migration
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['batch_id', 'student_id'], 'unique_batch_student');
$this->forge->addKey('batch_id');
$this->forge->createTable('placement_scores');
$this->forge->createTable('placement_scores', true);
}
public function down()
@@ -67,7 +67,7 @@ class CreateReportCardAcknowledgements extends Migration
$this->forge->addKey('id', true);
$this->forge->addKey(['parent_id', 'student_id', 'school_year', 'semester'], false, true);
$this->forge->createTable('report_card_acknowledgements');
$this->forge->createTable('report_card_acknowledgements', true);
}
public function down()
@@ -75,7 +75,7 @@ class CreateCertificateRecords extends Migration
$this->forge->addKey('id', true);
$this->forge->addUniqueKey('certificate_number');
$this->forge->addKey(['school_year', 'student_id']);
$this->forge->createTable('certificate_records');
$this->forge->createTable('certificate_records', true);
}
public function down()
@@ -60,7 +60,7 @@ class CreateBelowSixtyDecisions extends Migration
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['student_id', 'semester', 'school_year']);
$this->forge->addKey(['school_year', 'semester']);
$this->forge->createTable('below_sixty_decisions');
$this->forge->createTable('below_sixty_decisions', true);
}
public function down()
@@ -75,7 +75,7 @@ class CreateStudentDecisions extends Migration
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['student_id', 'semester', 'school_year']);
$this->forge->addKey(['school_year', 'semester']);
$this->forge->createTable('student_decisions');
$this->forge->createTable('student_decisions', true);
}
public function down()
@@ -40,8 +40,10 @@ class AddVerificationTokenToCertificateRecords extends Migration
->update(['verification_token' => $this->generateToken()]);
}
$this->forge->addUniqueKey('verification_token', self::INDEX_NAME);
$this->forge->processIndexes('certificate_records');
if (! $this->indexExists('certificate_records', self::INDEX_NAME)) {
$this->forge->addUniqueKey('verification_token', self::INDEX_NAME);
$this->forge->processIndexes('certificate_records');
}
}
public function down()
@@ -70,4 +72,15 @@ class AddVerificationTokenToCertificateRecords extends Migration
return $token;
}
private function indexExists(string $table, string $indexName): bool
{
foreach ($this->db->getIndexData($table) as $index) {
if (($index->name ?? '') === $indexName) {
return true;
}
}
return false;
}
}
@@ -149,11 +149,11 @@ class FinancialSystemLedgerCleanup extends Migration
$defaults = [
'tuition_calculator_version' => 'old',
'youth_fee' => '200.00',
'new_tuition_full_amount' => '370.00',
'new_tuition_youth_amount' => '200.00',
'new_tuition_second_student_discount' => '50.00',
'new_tuition_third_student_discount' => '50.00',
'youth_fee' => '380.00',
'new_tuition_full_amount' => '380.00',
'new_tuition_youth_amount' => '380.00',
'new_tuition_second_student_discount' => '100.00',
'new_tuition_third_student_discount' => '100.00',
'new_tuition_fourth_plus_discount' => '100.00',
];
@@ -104,7 +104,7 @@ class CreateSchoolYears extends Migration
$this->forge->addKey('id', true);
$this->forge->addKey('name', false, true);
$this->forge->addKey('status');
$this->forge->createTable('school_years');
$this->forge->createTable('school_years', true);
} else {
$this->ensureSchoolYearColumns();
}
@@ -193,7 +193,7 @@ class CreateSchoolYears extends Migration
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['source_school_year_id', 'status']);
$this->forge->createTable('school_year_closing_batches');
$this->forge->createTable('school_year_closing_batches', true);
}
if (! $this->db->tableExists('school_year_closing_items')) {
@@ -214,7 +214,7 @@ class CreateSchoolYears extends Migration
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['closing_batch_id', 'family_id'], false, true);
$this->forge->createTable('school_year_closing_items');
$this->forge->createTable('school_year_closing_items', true);
}
if (! $this->db->tableExists('school_year_transition_logs')) {
@@ -230,7 +230,7 @@ class CreateSchoolYears extends Migration
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['school_year_id', 'created_at']);
$this->forge->createTable('school_year_transition_logs');
$this->forge->createTable('school_year_transition_logs', true);
}
}
@@ -33,7 +33,7 @@ class CreateStudentSectionDistributionDrafts extends Migration
$this->forge->addUniqueKey(['student_id', 'school_year'], 'unique_distribution_draft_student_year');
$this->forge->addKey(['class_id', 'school_year', 'status'], false, false, 'distribution_draft_class_year_status');
$this->forge->addKey(['class_section_id', 'school_year'], false, false, 'distribution_draft_section_year');
$this->forge->createTable('student_section_distribution_drafts');
$this->forge->createTable('student_section_distribution_drafts', true);
}
public function down()
@@ -63,7 +63,7 @@ class CreateParentPolicyAcceptances extends Migration
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['parent_id', 'school_year'], 'uq_parent_policy_year');
$this->forge->addKey('school_year');
$this->forge->createTable('parent_policy_acceptances');
$this->forge->createTable('parent_policy_acceptances', true);
$this->backfillExistingAcceptances();
}
@@ -39,7 +39,7 @@ class CreateEnrollmentPhaseTwoTables extends Migration
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['school_year', 'grade_class_id']);
$this->forge->createTable('enrollment_age_rules');
$this->forge->createTable('enrollment_age_rules', true);
}
if (! $this->db->tableExists('enrollment_flags')) {
@@ -59,7 +59,7 @@ class CreateEnrollmentPhaseTwoTables extends Migration
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['student_id', 'school_year', 'flag_type']);
$this->forge->createTable('enrollment_flags');
$this->forge->createTable('enrollment_flags', true);
}
if (! $this->db->tableExists('enrollment_transition_audits')) {
@@ -77,7 +77,7 @@ class CreateEnrollmentPhaseTwoTables extends Migration
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['student_id', 'school_year', 'created_at']);
$this->forge->createTable('enrollment_transition_audits');
$this->forge->createTable('enrollment_transition_audits', true);
}
}
@@ -43,7 +43,7 @@ class CreateEnrollmentEmailRecords extends Migration
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['school_year', 'parent_user_id']);
$this->forge->createTable('enrollment_email_records');
$this->forge->createTable('enrollment_email_records', true);
}
public function down()
@@ -0,0 +1,48 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateEnrollmentExceptions extends Migration
{
public function up()
{
if ($this->db->tableExists('enrollment_exceptions')) {
return;
}
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'parent_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'school_year' => ['type' => 'VARCHAR', 'constraint' => 20],
'source_school_year' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true],
'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'active'],
'reason_code' => ['type' => 'VARCHAR', 'constraint' => 80],
'reason_note' => ['type' => 'TEXT'],
'bypassed_rule_codes_json' => ['type' => 'TEXT', 'null' => true],
'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'approved_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'starts_at' => ['type' => 'DATETIME', 'null' => true],
'expires_at' => ['type' => 'DATETIME', 'null' => true],
'used_at' => ['type' => 'DATETIME', 'null' => true],
'enrollment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'revoked_at' => ['type' => 'DATETIME', 'null' => true],
'revoked_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'revocation_reason' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['parent_id', 'student_id', 'school_year', 'status'], false, false, 'idx_enrollment_exceptions_scope_status');
$this->forge->addKey(['student_id', 'school_year'], false, false, 'idx_enrollment_exceptions_student_year');
$this->forge->addKey(['status', 'expires_at'], false, false, 'idx_enrollment_exceptions_status_expiry');
$this->forge->createTable('enrollment_exceptions', true);
}
public function down()
{
$this->forge->dropTable('enrollment_exceptions', true);
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class GrantEnrollmentExceptionPermission extends Migration
{
private string $permissionName = 'enrollment.exception.manage';
public function up()
{
if (! $this->db->tableExists('permissions')) {
return;
}
$permission = $this->db->table('permissions')
->where('name', $this->permissionName)
->limit(1)
->get()
->getRowArray();
if ($permission === null) {
$insert = [
'name' => $this->permissionName,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
];
if ($this->db->fieldExists('description', 'permissions')) {
$insert['description'] = 'Manage scoped enrollment eligibility exceptions.';
}
$this->db->table('permissions')->insert($insert);
$permissionId = (int) $this->db->insertID();
} else {
$permissionId = (int) $permission['id'];
}
// The plan requires a narrow permission but not an automatic broad role grant.
// Assign enrollment.exception.manage through the existing role-permission UI.
}
public function down()
{
if (! $this->db->tableExists('permissions') || ! $this->db->tableExists('role_permissions')) {
return;
}
$permission = $this->db->table('permissions')
->where('name', $this->permissionName)
->limit(1)
->get()
->getRowArray();
if ($permission === null) {
return;
}
$this->db->table('role_permissions')
->where('permission_id', (int) $permission['id'])
->delete();
$this->db->table('permissions')
->where('id', (int) $permission['id'])
->delete();
}
}
@@ -0,0 +1,107 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class UpdateTuitionFeeDefaults extends Migration
{
public function up()
{
if (!$this->db->tableExists('configuration')) {
return;
}
$this->forceConfig('first_student_fee', '380.00');
$this->forceConfig('second_student_fee', '280.00');
$this->forceConfig('new_tuition_full_amount', '380.00');
$this->forceConfig('new_tuition_second_student_discount', '100.00');
$this->aliasAndForceConfig('Youth_fee', 'youth_fee', '380.00');
}
public function down()
{
if (!$this->db->tableExists('configuration')) {
return;
}
$this->rollbackConfig('first_student_fee', '370.00');
$this->rollbackConfig('second_student_fee', '200.00');
$this->rollbackConfig('youth_fee', '200.00');
$this->rollbackConfig('new_tuition_full_amount', '370.00');
$this->rollbackConfig('new_tuition_second_student_discount', '50.00');
}
protected function aliasAndForceConfig(string $legacyKey, string $canonicalKey, string $value): void
{
$legacy = $this->db->table('configuration')
->select('id, config_value')
->where('config_key', $legacyKey)
->orderBy('id', 'ASC')
->get()
->getRowArray();
if ($legacy && strcasecmp($legacyKey, $canonicalKey) !== 0) {
$this->db->table('configuration')
->where('id', (int) $legacy['id'])
->update(['config_key' => $canonicalKey, 'config_value' => $value]);
}
$this->forceConfig($canonicalKey, $value);
}
protected function forceConfig(string $key, string $value): void
{
$row = $this->db->table('configuration')
->select('id')
->where('config_key', $key)
->orderBy('id', 'ASC')
->get()
->getRowArray();
if (!$row) {
$this->db->table('configuration')->insert([
'config_key' => $key,
'config_value' => $value,
]);
return;
}
$this->db->table('configuration')
->where('config_key', $key)
->update(['config_value' => $value]);
}
protected function upsertConfig(string $key, string $value, array $legacyValues): void
{
$row = $this->db->table('configuration')
->select('id, config_value')
->where('config_key', $key)
->orderBy('id', 'ASC')
->get()
->getRowArray();
if (!$row) {
$this->db->table('configuration')->insert([
'config_key' => $key,
'config_value' => $value,
]);
return;
}
$current = trim((string) ($row['config_value'] ?? ''));
if (in_array($current, $legacyValues, true)) {
$this->db->table('configuration')
->where('config_key', $key)
->update(['config_value' => $value]);
}
}
protected function rollbackConfig(string $key, string $value): void
{
$this->db->table('configuration')
->where('config_key', $key)
->where('config_value', '380.00')
->update(['config_value' => $value]);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class EnrollmentCarryOverAndExceptionUniqueness extends Migration
{
public function up()
{
if ($this->db->tableExists('school_years') && $this->db->fieldExists('carry_over_balance_behavior', 'school_years')) {
$this->db->table('school_years')
->groupStart()
->where('carry_over_balance_behavior', 'information_only')
->orWhere('carry_over_balance_behavior', null)
->orWhere('carry_over_balance_behavior', '')
->groupEnd()
->update(['carry_over_balance_behavior' => 'submission_blocked_until_payment']);
}
if (! $this->db->tableExists('enrollment_exceptions')) {
return;
}
$indexes = $this->db->query('SHOW INDEX FROM enrollment_exceptions')->getResultArray();
$names = array_map(static fn (array $row): string => (string) ($row['Key_name'] ?? ''), $indexes);
if (in_array('uniq_enrollment_exceptions_scope_status', $names, true)) {
return;
}
try {
$this->db->query(
'ALTER TABLE enrollment_exceptions ADD UNIQUE INDEX uniq_enrollment_exceptions_scope_status (parent_id, student_id, school_year, status)'
);
} catch (\Throwable $e) {
log_message('error', 'Unable to add unique enrollment exception index: ' . $e->getMessage());
}
}
public function down()
{
if ($this->db->tableExists('enrollment_exceptions')) {
try {
$this->db->query('ALTER TABLE enrollment_exceptions DROP INDEX uniq_enrollment_exceptions_scope_status');
} catch (\Throwable $e) {
// Index may not exist.
}
}
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateFinancialAidRequests extends Migration
{
public function up()
{
if ($this->db->tableExists('financial_aid_requests')) {
return;
}
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'parent_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true],
'school_year' => ['type' => 'VARCHAR', 'constraint' => 20],
'student_ids_json' => ['type' => 'TEXT', 'null' => true],
'household_size' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'need_statement' => ['type' => 'TEXT'],
'requested_amount' => ['type' => 'DECIMAL', 'constraint' => '10,2', 'null' => true],
'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'submitted'],
'admin_amount' => ['type' => 'DECIMAL', 'constraint' => '10,2', 'null' => true],
'admin_note' => ['type' => 'TEXT', 'null' => true],
'reviewed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'reviewed_at' => ['type' => 'DATETIME', 'null' => true],
'invoice_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'discount_usage_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'voucher_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => true],
'updated_at' => ['type' => 'DATETIME', 'null' => true],
]);
$this->forge->addKey('id', true);
$this->forge->addKey(['parent_id', 'school_year', 'status'], false, false, 'idx_financial_aid_parent_year_status');
$this->forge->addKey(['school_year', 'status'], false, false, 'idx_financial_aid_year_status');
$this->forge->createTable('financial_aid_requests', true);
}
public function down()
{
$this->forge->dropTable('financial_aid_requests', true);
}
}
@@ -0,0 +1,108 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddFinancialAidNavItem extends Migration
{
private string $url = 'administrator/financial-aid';
public function up(): void
{
if (! $this->db->tableExists('nav_items')) {
return;
}
$parentColumn = $this->parentColumn();
$existingQuery = $this->db->table('nav_items')
->where('url', $this->url)
->get();
$existing = $existingQuery !== false ? $existingQuery->getRowArray() : null;
if ($existing !== null) {
return;
}
$parentBuilder = $this->db->table('nav_items')
->where('label', 'Financial');
if ($parentColumn !== null) {
$parentBuilder->where($parentColumn, null);
}
$parentQuery = $parentBuilder->get();
$parent = $parentQuery !== false ? $parentQuery->getRowArray() : null;
$insert = [
'label' => 'Financial Aid',
'url' => $this->url,
'sort_order' => 9,
'is_enabled' => 1,
'created_at' => date('Y-m-d H:i:s'),
];
if ($parentColumn !== null) {
$insert[$parentColumn] = $parent['id'] ?? null;
}
$this->db->table('nav_items')->insert($insert);
$navItemId = (int) $this->db->insertID();
if (
$navItemId <= 0
|| ! $this->db->tableExists('role_nav_items')
|| ! $this->db->fieldExists('role', 'role_nav_items')
|| ! $this->db->fieldExists('nav_item_id', 'role_nav_items')
) {
return;
}
foreach (['administrator', 'principal', 'vice_principal'] as $role) {
$this->db->table('role_nav_items')->insert([
'role' => $role,
'nav_item_id' => $navItemId,
'created_at' => date('Y-m-d H:i:s'),
]);
}
}
public function down(): void
{
if (! $this->db->tableExists('nav_items')) {
return;
}
$query = $this->db->table('nav_items')
->where('url', $this->url)
->get();
$row = $query !== false ? $query->getRowArray() : null;
if ($row === null) {
return;
}
if ($this->db->tableExists('role_nav_items')) {
$this->db->table('role_nav_items')
->where('nav_item_id', (int) $row['id'])
->delete();
}
$this->db->table('nav_items')
->where('id', (int) $row['id'])
->delete();
}
private function parentColumn(): ?string
{
if ($this->db->fieldExists('parent_id', 'nav_items')) {
return 'parent_id';
}
if ($this->db->fieldExists('menu_parent_id', 'nav_items')) {
return 'menu_parent_id';
}
return null;
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddEnrollmentExceptionFamilyStudentIds extends Migration
{
public function up(): void
{
if (! $this->db->tableExists('enrollment_exceptions')) {
return;
}
if ($this->db->fieldExists('family_student_ids_json', 'enrollment_exceptions')) {
return;
}
$this->forge->addColumn('enrollment_exceptions', [
'family_student_ids_json' => [
'type' => 'TEXT',
'null' => true,
'after' => 'bypassed_rule_codes_json',
],
]);
}
public function down(): void
{
if (! $this->db->tableExists('enrollment_exceptions')) {
return;
}
if ($this->db->fieldExists('family_student_ids_json', 'enrollment_exceptions')) {
$this->forge->dropColumn('enrollment_exceptions', 'family_student_ids_json');
}
}
}
@@ -0,0 +1,140 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class GrantEnrollmentExceptionPermissionToAdminRoles extends Migration
{
private string $permissionName = 'enrollment.exception.manage';
/**
* @var list<string>
*/
private array $roleNames = [
'administrator',
'admin',
'principal',
'vice principal',
'vice_principal',
];
public function up(): void
{
if (
! $this->db->tableExists('roles')
|| ! $this->db->tableExists('permissions')
|| ! $this->db->tableExists('role_permissions')
) {
return;
}
$now = date('Y-m-d H:i:s');
$permission = $this->db->table('permissions')
->select('id')
->where('name', $this->permissionName)
->get()
->getRowArray();
if ($permission === null) {
$insert = [
'name' => $this->permissionName,
'created_at' => $now,
'updated_at' => $now,
];
if ($this->db->fieldExists('description', 'permissions')) {
$insert['description'] = 'Manage scoped enrollment eligibility exceptions.';
}
$this->db->table('permissions')->insert($insert);
$permissionId = (int) $this->db->insertID();
} else {
$permissionId = (int) $permission['id'];
}
if ($permissionId <= 0) {
return;
}
$roles = $this->db->table('roles')
->select('id')
->whereIn('name', $this->roleNames)
->get()
->getResultArray();
foreach ($roles as $role) {
$roleId = (int) ($role['id'] ?? 0);
if ($roleId <= 0) {
continue;
}
$existing = $this->db->table('role_permissions')
->where('role_id', $roleId)
->where('permission_id', $permissionId)
->get()
->getRowArray();
$grant = [
'can_create' => 1,
'can_read' => 1,
'can_update' => 1,
'can_delete' => 1,
'updated_at' => $now,
];
if ($this->db->fieldExists('can_manage', 'role_permissions')) {
$grant['can_manage'] = 1;
}
if ($existing === null) {
$grant['role_id'] = $roleId;
$grant['permission_id'] = $permissionId;
$grant['created_at'] = $now;
$this->db->table('role_permissions')->insert($grant);
continue;
}
$this->db->table('role_permissions')
->where('id', (int) $existing['id'])
->update($grant);
}
}
public function down(): void
{
if (
! $this->db->tableExists('roles')
|| ! $this->db->tableExists('permissions')
|| ! $this->db->tableExists('role_permissions')
) {
return;
}
$permission = $this->db->table('permissions')
->select('id')
->where('name', $this->permissionName)
->get()
->getRowArray();
if ($permission === null) {
return;
}
$roles = $this->db->table('roles')
->select('id')
->whereIn('name', $this->roleNames)
->get()
->getResultArray();
$roleIds = array_values(array_filter(array_map(static fn (array $role): int => (int) ($role['id'] ?? 0), $roles)));
if ($roleIds === []) {
return;
}
$this->db->table('role_permissions')
->where('permission_id', (int) $permission['id'])
->whereIn('role_id', $roleIds)
->delete();
}
}
+1
View File
@@ -93,6 +93,7 @@ class NavSeeder extends Seeder
['parent'=>'Financial','label'=>'Manual Payment','url'=>'payment/manual_pay','sort_order'=>6],
['parent'=>'Financial','label'=>'Refund Management','url'=>'refunds/list','sort_order'=>7],
['parent'=>'Financial','label'=>'Reimbursement Management','url'=>'reimbursements/index','sort_order'=>8],
['parent'=>'Financial','label'=>'Financial Aid','url'=>'administrator/financial-aid','sort_order'=>9],
// Printables
['parent'=>'Printables','label'=>'Badges','url'=>'printables_reports/badge_form','sort_order'=>1],
+1 -4
View File
@@ -646,12 +646,9 @@ class InvoiceLedgerService
'grade_fee' => $this->configurationModel->getConfig('grade_fee'),
'first_student_fee' => $this->configurationModel->getConfig('first_student_fee'),
'second_student_fee' => $this->configurationModel->getConfig('second_student_fee'),
'youth_fee' => $this->configurationModel->getConfig('youth_fee'),
'youth_fee' => $this->configurationModel->getConfig('first_student_fee'),
'new_tuition_full_amount' => $this->configurationModel->getConfig('new_tuition_full_amount'),
'new_tuition_youth_amount' => $this->configurationModel->getConfig('new_tuition_youth_amount'),
'new_tuition_second_student_discount' => $this->configurationModel->getConfig('new_tuition_second_student_discount'),
'new_tuition_third_student_discount' => $this->configurationModel->getConfig('new_tuition_third_student_discount'),
'new_tuition_fourth_plus_discount' => $this->configurationModel->getConfig('new_tuition_fourth_plus_discount'),
];
}
@@ -37,4 +37,17 @@ final class GradeLevelParser
return 999;
}
public static function isYouth($grade): bool
{
if (! is_string($grade) && ! is_numeric($grade)) {
return false;
}
$value = strtoupper(trim((string) $grade));
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
$value = str_replace(['.', '_', '-'], ['', '', ' '], $value);
return (bool) preg_match('/^Y(?:OUTH)?\s*\d*$/', $value);
}
}
@@ -9,11 +9,8 @@ final class NewTuitionCalculatorService implements TuitionCalculatorInterface
public function calculateFamilyTuition(array $students, array $config): array
{
$gradeFee = (int) ($config['grade_fee'] ?? 9);
$fullAmountCents = $this->toCents($config['new_tuition_full_amount'] ?? 370);
$youthAmountCents = $this->toCents($config['new_tuition_youth_amount'] ?? $config['youth_fee'] ?? 200);
$secondDiscountCents = $this->toCents($config['new_tuition_second_student_discount'] ?? 50);
$thirdDiscountCents = $this->toCents($config['new_tuition_third_student_discount'] ?? 50);
$fourthPlusDiscountCents = $this->toCents($config['new_tuition_fourth_plus_discount'] ?? 100);
$fullAmountCents = $this->toCents($config['new_tuition_full_amount'] ?? $config['first_student_fee'] ?? 380);
$additionalDiscountCents = $this->additionalDiscountCents($config, $fullAmountCents);
usort($students, function (array $left, array $right) use ($gradeFee): int {
$leftLevel = GradeLevelParser::parse($left['grade_level'] ?? null, $gradeFee);
@@ -23,45 +20,20 @@ final class NewTuitionCalculatorService implements TuitionCalculatorInterface
});
$details = [];
$regularPosition = 0;
$familyPosition = 0;
foreach (array_values($students) as $student) {
$level = GradeLevelParser::parse($student['grade_level'] ?? null, $gradeFee);
if ($level > $gradeFee) {
$position = null;
$discountCents = 0;
$rule = 'new_youth_unit_price';
$baseAmountCents = $youthAmountCents;
} else {
$regularPosition++;
$position = $regularPosition;
if ($position === 1) {
$discountCents = 0;
$rule = 'new_first_student_full_amount';
} elseif ($position === 2) {
$discountCents = $secondDiscountCents;
$rule = 'new_second_student_discount';
} elseif ($position === 3) {
$discountCents = $thirdDiscountCents;
$rule = 'new_third_student_discount';
} else {
$discountCents = $fourthPlusDiscountCents;
$rule = 'new_fourth_plus_student_discount';
}
$baseAmountCents = $fullAmountCents;
}
$amountCents = max(0, $baseAmountCents - $discountCents);
$familyPosition++;
$discountCents = $familyPosition === 1 ? 0 : $additionalDiscountCents;
$rule = $familyPosition === 1 ? 'new_first_student_full_amount' : 'new_additional_student_discount';
$amountCents = max(0, $fullAmountCents - $discountCents);
$details[] = [
'student_id' => (int) ($student['student_id'] ?? 0),
'student_name' => (string) ($student['student_name'] ?? ''),
'grade_level' => $student['grade_level'] ?? null,
'family_position' => $position,
'full_amount' => $this->fromCents($baseAmountCents),
'family_position' => $familyPosition,
'full_amount' => $this->fromCents($fullAmountCents),
'discount' => $this->fromCents($discountCents),
'rule' => $rule,
'amount' => $this->fromCents($amountCents),
@@ -77,6 +49,19 @@ final class NewTuitionCalculatorService implements TuitionCalculatorInterface
];
}
private function additionalDiscountCents(array $config, int $fullAmountCents): int
{
if (array_key_exists('new_tuition_second_student_discount', $config) && $config['new_tuition_second_student_discount'] !== null && $config['new_tuition_second_student_discount'] !== '') {
return $this->toCents($config['new_tuition_second_student_discount']);
}
if (array_key_exists('second_student_fee', $config) && $config['second_student_fee'] !== null && $config['second_student_fee'] !== '') {
return max(0, $fullAmountCents - $this->toCents($config['second_student_fee']));
}
return $this->toCents(100);
}
private function toCents($amount): int
{
return (int) round(((float) $amount) * 100);
@@ -9,9 +9,10 @@ final class OldTuitionCalculatorService implements TuitionCalculatorInterface
public function calculateFamilyTuition(array $students, array $config): array
{
$gradeFee = (int) ($config['grade_fee'] ?? 9);
$firstStudentFee = $this->toCents($config['first_student_fee'] ?? 370);
$secondStudentFee = $this->toCents($config['second_student_fee'] ?? 200);
$youthFee = $this->toCents($config['youth_fee'] ?? 200);
$firstStudentFee = $this->toCents($config['first_student_fee'] ?? $config['new_tuition_full_amount'] ?? 380);
$secondStudentFee = isset($config['second_student_fee']) && $config['second_student_fee'] !== '' && $config['second_student_fee'] !== null
? $this->toCents($config['second_student_fee'])
: max(0, $firstStudentFee - 10000);
usort($students, function (array $left, array $right) use ($gradeFee): int {
$leftLevel = GradeLevelParser::parse($left['grade_level'] ?? null, $gradeFee);
@@ -20,20 +21,13 @@ final class OldTuitionCalculatorService implements TuitionCalculatorInterface
return [$leftLevel, (int) ($left['student_id'] ?? 0)] <=> [$rightLevel, (int) ($right['student_id'] ?? 0)];
});
$regularCount = 0;
$familyPosition = 0;
$details = [];
foreach ($students as $student) {
$level = GradeLevelParser::parse($student['grade_level'] ?? null, $gradeFee);
if ($level > $gradeFee) {
$amountCents = $youthFee;
$rule = 'old_youth_fee';
} else {
$regularCount++;
$amountCents = $regularCount === 1 ? $firstStudentFee : $secondStudentFee;
$rule = $regularCount === 1 ? 'old_first_student_fee' : 'old_second_student_fee';
}
$familyPosition++;
$amountCents = $familyPosition === 1 ? $firstStudentFee : $secondStudentFee;
$rule = $familyPosition === 1 ? 'old_first_student_fee' : 'old_additional_student_fee';
$details[] = [
'student_id' => (int) ($student['student_id'] ?? 0),
@@ -43,7 +43,6 @@ class TuitionForecastService
$mode = $this->normalizeMode($mode);
$options = $this->normalizeOptions($options);
$this->unitPriceOverride = $options['unit_price'];
$this->youthUnitPriceOverride = $options['youth_unit_price'];
$tuitionConfig = $this->getTuitionConfig();
$familyRows = [];
$summary = [
@@ -119,7 +118,7 @@ class TuitionForecastService
$summary['projected_tuition'] = $mode === 'old' ? $summary['old_projected_tuition'] : $summary['new_projected_tuition'];
$summary['projected_income'] = $mode === 'old' ? $summary['old_projected_income'] : $summary['new_projected_income'];
$summary['unit_price'] = number_format((float) ($tuitionConfig['new_tuition_full_amount'] ?? 0), 2, '.', '');
$summary['youth_unit_price'] = number_format((float) ($tuitionConfig['new_tuition_youth_amount'] ?? 0), 2, '.', '');
$summary['youth_unit_price'] = $summary['unit_price'];
return [
'school_year' => $schoolYear,
@@ -545,32 +544,18 @@ class TuitionForecastService
'grade_fee' => $this->configurationModel->getConfig('grade_fee'),
'first_student_fee' => $this->configurationModel->getConfig('first_student_fee'),
'second_student_fee' => $this->configurationModel->getConfig('second_student_fee'),
'youth_fee' => $this->configurationModel->getConfig('youth_fee') ?? '200.00',
'youth_fee' => $this->configurationModel->getConfig('first_student_fee') ?? '380.00',
'new_tuition_full_amount' => $this->normalizedUnitPriceOverride(),
'new_tuition_youth_amount' => $this->normalizedYouthUnitPriceOverride(),
'new_tuition_second_student_discount' => $this->configurationModel->getConfig('new_tuition_second_student_discount'),
'new_tuition_third_student_discount' => $this->configurationModel->getConfig('new_tuition_third_student_discount'),
'new_tuition_fourth_plus_discount' => $this->configurationModel->getConfig('new_tuition_fourth_plus_discount'),
'new_tuition_second_student_discount' => $this->configurationModel->getConfig('new_tuition_second_student_discount') ?? '100.00',
];
}
protected ?string $unitPriceOverride = null;
protected ?string $youthUnitPriceOverride = null;
protected function normalizedUnitPriceOverride(): string
{
return $this->unitPriceOverride
?? (string) ($this->configurationModel->getConfig('new_tuition_full_amount') ?? '0.00');
}
protected function normalizedYouthUnitPriceOverride(): string
{
return $this->youthUnitPriceOverride
?? (string) (
$this->configurationModel->getConfig('new_tuition_youth_amount')
?? $this->configurationModel->getConfig('youth_fee')
?? '200.00'
);
?? (string) ($this->configurationModel->getConfig('new_tuition_full_amount') ?? '380.00');
}
protected function normalizeMoney($value): ?string
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class EnrollmentExceptionModel extends Model
{
protected $table = 'enrollment_exceptions';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = [
'parent_id',
'student_id',
'school_year',
'source_school_year',
'status',
'reason_code',
'reason_note',
'bypassed_rule_codes_json',
'created_by',
'approved_by',
'starts_at',
'expires_at',
'used_at',
'enrollment_id',
'revoked_at',
'revoked_by',
'revocation_reason',
'family_student_ids_json',
'created_at',
'updated_at',
];
protected $validationRules = [
'parent_id' => 'required|integer',
'student_id' => 'required|integer',
'school_year' => 'required|string|max_length[20]',
'source_school_year' => 'permit_empty|string|max_length[20]',
'status' => 'required|in_list[active,used,revoked,expired]',
'reason_code' => 'required|string|max_length[80]',
'reason_note' => 'required|string',
'created_by' => 'permit_empty|integer',
'approved_by' => 'permit_empty|integer',
'enrollment_id' => 'permit_empty|integer',
'revoked_by' => 'permit_empty|integer',
];
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class FinancialAidRequestModel extends Model
{
protected $table = 'financial_aid_requests';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = [
'parent_id',
'school_year',
'student_ids_json',
'household_size',
'need_statement',
'requested_amount',
'status',
'admin_amount',
'admin_note',
'reviewed_by',
'reviewed_at',
'invoice_id',
'discount_usage_id',
'voucher_id',
'created_at',
'updated_at',
];
public function openRequestForParent(int $parentId, string $schoolYear): ?array
{
return $this->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->whereIn('status', ['submitted', 'under_review'])
->orderBy('id', 'DESC')
->first();
}
}
File diff suppressed because it is too large Load Diff
+12 -25
View File
@@ -59,21 +59,15 @@ class FeeCalculationService
});
// Retrieve fee configs
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350);
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200);
$youthFee = (float) ($configModel->getConfig('youth_fee') ?? 200);
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 380);
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 280);
// Assign tuition_fee to all students (before filtering refunds)
$regularCount = 0;
$studentCount = 0;
foreach ($allStudents as &$student) {
$gradeLevel = $this->getGradeLevel($student['grade']);
if ($gradeLevel > 9) {
$studentFee = $youthFee;
} else {
$studentFee = ($regularCount === 0) ? $firstStudentFee : $secondStudentFee;
$regularCount++;
}
$studentFee = ($studentCount === 0) ? $firstStudentFee : $secondStudentFee;
$studentCount++;
$student['tuition_fee'] = $studentFee;
}
unset($student);
@@ -97,7 +91,7 @@ class FeeCalculationService
$daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days;
$weeksRemaining = min($weekOfStudy, max(0, ceil($daysRemaining / 7)));
//$studentFee = $student['tuition_fee'];
$studentFee = (float) ($student['tuition_fee'] ?? 0);
$proportionalRefund = ($studentFee / $weekOfStudy) * $weeksRemaining;
$refundAmount += $proportionalRefund;
@@ -146,9 +140,8 @@ class FeeCalculationService
$configModel = new ConfigurationModel();
$classSectionModel = new \App\Models\ClassSectionModel();
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350);
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200);
$youthFee = (float) ($configModel->getConfig('youth_fee') ?? 200);
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 380);
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 280);
// ✅ Pre-fetch and assign grade/class section names before sorting
foreach ($students as &$student) {
@@ -162,19 +155,13 @@ class FeeCalculationService
return $this->compareGrades($a['grade'], $b['grade']);
});
$regularCount = 0;
$studentCount = 0;
$totalFee = 0;
// ✅ Calculate fee
foreach ($students as $student) {
$gradeLevel = $this->getGradeLevel($student['grade']);
if ($gradeLevel > 9) {
$totalFee += $youthFee;
} else {
$totalFee += ($regularCount === 0) ? $firstStudentFee : $secondStudentFee;
$regularCount++;
}
$totalFee += ($studentCount === 0) ? $firstStudentFee : $secondStudentFee;
$studentCount++;
}
return $totalFee;
+300
View File
@@ -0,0 +1,300 @@
<?php
namespace App\Services;
use App\Libraries\IssueInvoiceCommand;
use App\Libraries\InvoiceIssuanceService;
use App\Libraries\InvoiceLedgerService;
use App\Libraries\Tuition\GradeLevelParser;
use App\Models\ClassSectionModel;
use App\Models\ConfigurationModel;
use App\Models\DiscountUsageModel;
use App\Models\DiscountVoucherModel;
use App\Models\EnrollmentModel;
use App\Models\EventChargesModel;
use App\Models\FinancialAidRequestModel;
use App\Models\InvoiceModel;
use App\Models\StudentClassModel;
use App\Models\UserModel;
use DateTime;
use DateTimeZone;
use RuntimeException;
final class FinancialAidService
{
public function __construct(
private readonly FinancialAidRequestModel $requestModel,
private readonly InvoiceModel $invoiceModel,
private readonly DiscountVoucherModel $voucherModel,
private readonly DiscountUsageModel $usageModel,
private readonly InvoiceLedgerService $invoiceLedgerService,
private readonly ?InvoiceIssuanceService $invoiceIssuanceService = null,
private readonly ?EnrollmentModel $enrollmentModel = null,
private readonly ?StudentClassModel $studentClassModel = null,
private readonly ?ClassSectionModel $classSectionModel = null,
private readonly ?EventChargesModel $eventChargesModel = null,
private readonly ?ConfigurationModel $configurationModel = null,
private readonly ?UserModel $userModel = null,
) {
}
public function applyApprovedAmount(array $request, float $amount, int $reviewedBy, string $adminNote = ''): array
{
if ($amount <= 0) {
throw new RuntimeException('Enter a financial aid amount greater than zero.');
}
$parentId = (int) ($request['parent_id'] ?? 0);
$schoolYear = (string) ($request['school_year'] ?? '');
$invoice = $this->latestInvoice($parentId, $schoolYear);
if ($invoice === null) {
$invoice = $this->createInvoiceForParentYear($parentId, $schoolYear);
}
$db = $this->requestModel->db;
$db->transStart();
$code = 'FA-' . (int) ($request['id'] ?? 0) . '-' . date('YmdHis');
$voucherId = $this->voucherModel->insert([
'code' => $code,
'discount_type' => 'fixed',
'discount_value' => $amount,
'max_uses' => 1,
'times_used' => 0,
'valid_from' => date('Y-m-d'),
'valid_until' => date('Y-m-d', strtotime('+1 year')),
'school_year' => $schoolYear,
'is_active' => 1,
'description' => 'Financial aid request #' . (int) ($request['id'] ?? 0),
], true);
if ($voucherId === false) {
$db->transRollback();
throw new RuntimeException('Unable to create the financial aid voucher.');
}
$now = function_exists('utc_now') ? utc_now() : date('Y-m-d H:i:s');
$amountCents = (int) round($amount * 100);
$usagePayload = [
'voucher_id' => (int) $voucherId,
'invoice_id' => (int) $invoice['id'],
'parent_id' => $parentId,
'discount_amount' => $amount,
'description' => 'Financial aid',
'school_year' => $schoolYear,
'updated_by' => $reviewedBy,
'used_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
if ($db->fieldExists('requested_discount_cents', 'discount_usages')) {
$usagePayload['requested_discount_cents'] = $amountCents;
$usagePayload['eligible_base_cents'] = $amountCents;
$usagePayload['eligible_base_before_cents'] = $amountCents;
$usagePayload['applied_discount_cents'] = $amountCents;
$usagePayload['application_order'] = 1;
}
$usageId = $this->usageModel->insert($usagePayload, true);
if ($usageId === false) {
$db->transRollback();
throw new RuntimeException('Unable to record the financial aid discount.');
}
$this->voucherModel->update((int) $voucherId, ['times_used' => 1, 'is_active' => 0]);
$this->invoiceLedgerService->recalculateInvoice((int) $invoice['id']);
$this->requestModel->update((int) $request['id'], [
'status' => 'approved',
'admin_amount' => $amount,
'admin_note' => $adminNote,
'reviewed_by' => $reviewedBy,
'reviewed_at' => $now,
'invoice_id' => (int) $invoice['id'],
'discount_usage_id' => (int) $usageId,
'voucher_id' => (int) $voucherId,
]);
$db->transComplete();
if ($db->transStatus() === false) {
throw new RuntimeException('Unable to apply the financial aid discount.');
}
return $this->requestModel->find((int) $request['id']) ?? $request;
}
private function latestInvoice(int $parentId, string $schoolYear): ?array
{
if ($parentId <= 0 || $schoolYear === '') {
return null;
}
return $this->invoiceModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderBy('id', 'DESC')
->first();
}
private function createInvoiceForParentYear(int $parentId, string $schoolYear): array
{
if ($parentId <= 0 || $schoolYear === '') {
throw new RuntimeException('Cannot create an invoice because the parent or school year is missing.');
}
$enrollmentModel = $this->enrollmentModel ?? new EnrollmentModel();
$studentClassModel = $this->studentClassModel ?? new StudentClassModel();
$classSectionModel = $this->classSectionModel ?? new ClassSectionModel();
$eventChargesModel = $this->eventChargesModel ?? new EventChargesModel();
$configurationModel = $this->configurationModel ?? new ConfigurationModel();
$userModel = $this->userModel ?? new UserModel();
$invoiceIssuanceService = $this->invoiceIssuanceService ?? new InvoiceIssuanceService(
$this->requestModel->db,
$this->invoiceModel,
null,
$this->invoiceLedgerService
);
$semester = (string) ($configurationModel->getConfig('semester') ?: '');
$enrollments = $enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->findAll();
if ($enrollments === []) {
throw new RuntimeException('No enrollment records were found, so an invoice could not be created for this parent.');
}
$registeredKids = [];
$withdrawnKids = [];
foreach ($enrollments as $enrollment) {
$studentData = [
'student_id' => (int) ($enrollment['student_id'] ?? 0),
'parent_id' => (int) ($enrollment['parent_id'] ?? 0),
'class_section_id' => (int) ($enrollment['class_section_id'] ?? 0),
'enrollment_status' => (string) ($enrollment['enrollment_status'] ?? ''),
'school_year' => (string) ($enrollment['school_year'] ?? ''),
'semester' => (string) ($enrollment['semester'] ?? ''),
'admission_status' => (string) ($enrollment['admission_status'] ?? ''),
'is_withdrawn' => (int) ($enrollment['is_withdrawn'] ?? 0),
];
if (in_array($studentData['enrollment_status'], ['enrolled', 'payment pending'], true)) {
$registeredKids[] = $studentData;
} elseif (in_array($studentData['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
$withdrawnKids[] = $studentData;
}
}
$registeredKids = $this->onlyStudentsWithClassAssignment($registeredKids, $studentClassModel, $schoolYear);
$withdrawnKids = $this->onlyStudentsWithClassAssignment($withdrawnKids, $studentClassModel, $schoolYear);
$tuitionAmount = $this->calculateTuitionAmount($registeredKids, $withdrawnKids, $classSectionModel, $configurationModel);
$eventAmount = array_sum(array_map(
static fn(array $row): float => (float) ($row['charged'] ?? 0),
$eventChargesModel->getChargesWithEventInfo($parentId, $schoolYear)
));
$totalAmount = $tuitionAmount + $eventAmount;
if ($totalAmount <= 0) {
throw new RuntimeException('Invoice could not be created because this parent has no billable tuition or event charges.');
}
$schoolId = $userModel->getSchoolIdByUserId($parentId);
$invoiceNumber = !empty($schoolId)
? 'INV-' . $schoolId . '-' . uniqid()
: uniqid('INV-');
$issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
$dueUtc = $this->invoiceDueUtc($configurationModel);
$result = $invoiceIssuanceService->issueInvoice(new IssueInvoiceCommand([
'parent_id' => $parentId,
'invoice_number' => $invoiceNumber,
'total_amount' => $totalAmount,
'paid_amount' => 0,
'balance' => $totalAmount,
'school_year' => $schoolYear,
'semester' => $semester,
'issue_date' => $issueUtc,
'due_date' => $dueUtc,
'created_at' => function_exists('utc_now') ? utc_now() : $issueUtc,
'updated_at' => function_exists('utc_now') ? utc_now() : $issueUtc,
], $tuitionAmount, $eventAmount, [
'parent_id' => $parentId,
'school_year' => $schoolYear,
'semester' => $semester,
'registered_student_count' => count($registeredKids),
'withdrawn_student_count' => count($withdrawnKids),
]));
$invoice = $this->invoiceModel->find($result->invoiceId);
if (!is_array($invoice)) {
throw new RuntimeException('Invoice was created but could not be reloaded.');
}
return $invoice;
}
private function onlyStudentsWithClassAssignment(array $students, StudentClassModel $studentClassModel, string $schoolYear): array
{
return array_values(array_filter($students, static function (array $student) use ($studentClassModel, $schoolYear): bool {
$studentId = (int) ($student['student_id'] ?? 0);
return $studentId > 0 && $studentClassModel->hasNonEventAssignment($studentId, $schoolYear);
}));
}
private function calculateTuitionAmount(
array $registeredKids,
array $withdrawnKids,
ClassSectionModel $classSectionModel,
ConfigurationModel $configurationModel
): float {
$tuitionStudents = $this->isBeforeRefundDeadline($configurationModel)
? $registeredKids
: array_merge($registeredKids, $withdrawnKids);
foreach ($tuitionStudents as &$student) {
$gradeName = $classSectionModel->getClassSectionNameBySectionId((int) ($student['class_section_id'] ?? 0));
$student['grade'] = strtoupper(trim((string) $gradeName));
}
unset($student);
usort($tuitionStudents, static fn(array $left, array $right): int => GradeLevelParser::parse($left['grade'] ?? null) <=> GradeLevelParser::parse($right['grade'] ?? null));
$firstStudentFee = (float) ($configurationModel->getConfig('first_student_fee') ?? 380);
$secondStudentFee = (float) ($configurationModel->getConfig('second_student_fee') ?? 280);
$total = 0.0;
foreach (array_values($tuitionStudents) as $index => $student) {
$total += $index === 0 ? $firstStudentFee : $secondStudentFee;
}
return $total;
}
private function isBeforeRefundDeadline(ConfigurationModel $configurationModel): bool
{
try {
$refundDeadline = (string) ($configurationModel->getConfig('refund_deadline') ?? '');
if ($refundDeadline === '') {
return true;
}
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$tz = new DateTimeZone($tzName);
return new \DateTimeImmutable('today', $tz) <= new \DateTimeImmutable($refundDeadline, $tz);
} catch (\Throwable) {
return true;
}
}
private function invoiceDueUtc(ConfigurationModel $configurationModel): ?string
{
$dueDate = (string) ($configurationModel->getConfig('first_day_of_school') ?: $configurationModel->getConfig('due_date') ?: '');
if ($dueDate === '') {
return null;
}
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$dueLocal = new DateTime($dueDate . ' 19:59:59', new DateTimeZone($tzName));
$dueLocal->setTimezone(new DateTimeZone('UTC'));
return $dueLocal->format('Y-m-d H:i:s');
}
}
+1 -3
View File
@@ -5,7 +5,6 @@ namespace App\Services;
use App\Models\SchoolYearClosingBatchModel;
use App\Models\SchoolYearClosingItemModel;
use App\Models\SchoolYearModel;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Support\Enrollment\DeliberationDecision;
use App\Support\SchoolYear\SchoolYearStatus;
@@ -19,7 +18,6 @@ final class SchoolYearClosingService
private readonly SchoolYearModel $schoolYearModel,
private readonly SchoolYearClosingBatchModel $batchModel,
private readonly SchoolYearClosingItemModel $itemModel,
private readonly ConfigurationModel $configurationModel,
private readonly SchoolYearManagementService $managementService,
private readonly BaseConnection $db,
) {
@@ -261,7 +259,7 @@ final class SchoolYearClosingService
'next_school_year_id' => $targetYearId,
]);
$targetName = (string) ($target['name'] ?? '');
$this->configurationModel->setConfigValueByKey('school_year', $targetName);
$this->managementService->syncConfigurationForYear($targetYearId);
$this->syncActiveYearSession($targetName);
$this->managementService->log($sourceYearId, SchoolYearStatus::CLOSING, SchoolYearStatus::CLOSED, 'closing_complete', $userId, [
'closing_batch_id' => (int) $batch['id'],
+189 -9
View File
@@ -8,6 +8,7 @@ use App\Models\SchoolYearModel;
use App\Models\SchoolYearTransitionLogModel;
use App\Support\SchoolYear\SchoolYearStatus;
use CodeIgniter\Database\BaseConnection;
use DateTimeImmutable;
use InvalidArgumentException;
use RuntimeException;
@@ -25,15 +26,29 @@ final class SchoolYearManagementService
public function createDraft(array $payload, ?int $userId = null): int
{
$existingYears = $this->db->table($this->schoolYearModel->getTable())->countAllResults();
$nextDraft = $this->nextDraftDefaults();
if ($nextDraft['name'] === null) {
throw new InvalidArgumentException('Create an initial school year before using automatic next-year draft creation.');
$requestedName = trim((string) ($payload['name'] ?? ''));
if ($existingYears === 0) {
if (! preg_match('/^\d{4}-\d{4}$/', $requestedName)) {
throw new InvalidArgumentException('Enter the first school year as YYYY-YYYY, for example 2025-2026.');
}
$payload['name'] = $requestedName;
} else {
if ($nextDraft['name'] === null) {
throw new InvalidArgumentException('Create an initial school year before using automatic next-year draft creation.');
}
$payload['name'] = $nextDraft['name'];
}
$payload['name'] = $nextDraft['name'];
$payload = $this->withCalendarDefaults($payload, (string) $payload['name']);
$payload = $this->metadataPayload($payload);
$payload['previous_school_year_id'] = (int) ($nextDraft['previous_year']['id'] ?? 0) ?: $this->previousYearIdForDraft((string) $payload['name']);
$payload['previous_school_year_id'] = $existingYears === 0
? null
: ((int) ($nextDraft['previous_year']['id'] ?? 0) ?: $this->previousYearIdForDraft((string) $payload['name']));
$payload['status'] = SchoolYearStatus::DRAFT;
$payload['carry_over_balance_behavior'] = 'submission_blocked_until_payment';
$payload['created_by'] = $userId;
$payload['updated_by'] = $userId;
@@ -42,7 +57,9 @@ final class SchoolYearManagementService
$this->db->transStart();
$id = $this->schoolYearModel->insert($payload, true);
if ($id !== false) {
$this->syncConfigurationFromSchoolYear($payload);
if ($existingYears === 0 || $this->schoolYearModel->active() === null) {
$this->syncConfigurationFromSchoolYear($payload);
}
$this->log((int) $id, null, SchoolYearStatus::DRAFT, 'create', $userId);
}
$this->db->transComplete();
@@ -54,6 +71,49 @@ final class SchoolYearManagementService
return (int) $id;
}
public function ensureNextDraftForClosing(int $sourceYearId, ?int $userId = null): array
{
$source = $this->requireYear($sourceYearId);
$sourceName = (string) ($source['name'] ?? '');
$nextName = $this->nextSchoolYearName($sourceName);
if ($nextName === null) {
throw new InvalidArgumentException('Unable to determine the next school year name.');
}
$existing = $this->schoolYearModel
->where('name', $nextName)
->first();
if ($existing !== null) {
return $existing;
}
$payload = $this->metadataPayload($this->withCalendarDefaults([
'name' => $nextName,
'previous_school_year_id' => $sourceYearId,
], $nextName));
$payload['status'] = SchoolYearStatus::DRAFT;
$payload['carry_over_balance_behavior'] = 'submission_blocked_until_payment';
$payload['created_by'] = $userId;
$payload['updated_by'] = $userId;
$this->validationService->validateMetadata($payload);
$this->db->transStart();
$id = $this->schoolYearModel->insert($payload, true);
if ($id !== false) {
$this->log((int) $id, null, SchoolYearStatus::DRAFT, 'create_for_closing', $userId, [
'source_school_year_id' => $sourceYearId,
]);
}
$this->db->transComplete();
if ($id === false || $this->db->transStatus() === false) {
throw new RuntimeException($this->firstModelError('Unable to create next school year.'));
}
return $this->requireYear((int) $id);
}
public function nextDraftDefaults(): array
{
$previousYear = $this->sourceYearForNextDraft();
@@ -83,7 +143,9 @@ final class SchoolYearManagementService
$this->db->transStart();
$updated = $this->schoolYearModel->update($id, $payload);
if ($updated !== false) {
$this->syncConfigurationFromSchoolYear($payload);
if ($status === SchoolYearStatus::ACTIVE) {
$this->syncConfigurationFromSchoolYear(array_merge($year, $payload));
}
$this->log($id, $status, $status, 'metadata_update', $userId);
}
$this->db->transComplete();
@@ -121,10 +183,13 @@ final class SchoolYearManagementService
]);
}
$year['carry_over_balance_behavior'] = trim((string) ($year['carry_over_balance_behavior'] ?? ''))
?: 'submission_blocked_until_payment';
$this->schoolYearModel->update($id, [
'status' => SchoolYearStatus::ACTIVE,
'activated_at' => $now,
'updated_by' => $userId,
'carry_over_balance_behavior' => $year['carry_over_balance_behavior'],
]);
$this->syncConfigurationFromSchoolYear($year);
$this->syncActiveYearSession((string) $year['name']);
@@ -137,6 +202,11 @@ final class SchoolYearManagementService
}
}
public function syncConfigurationForYear(int $id): void
{
$this->syncConfigurationFromSchoolYear($this->requireYear($id));
}
public function deleteDraft(int $id, ?int $userId = null): void
{
$year = $this->requireYear($id);
@@ -289,14 +359,54 @@ final class SchoolYearManagementService
throw new RuntimeException('Unable to update school-year configuration: school year name is missing.');
}
$calendar = $this->calendarPayloadForSchoolYear($name);
$schoolYear = $this->withCalendarDefaults($schoolYear, $name);
$ageReferenceDate = $this->ageReferenceDateForSchoolYear($name);
$yearStart = (string) ($schoolYear['starts_on'] ?? $calendar['starts_on']);
$yearEnd = (string) ($schoolYear['ends_on'] ?? $calendar['ends_on']);
$registrationDay = (string) ($schoolYear['registration_starts_on'] ?? $calendar['registration_starts_on']);
$enrollmentDeadline = (string) ($schoolYear['registration_ends_on'] ?? $calendar['registration_ends_on']);
$firstDay = $calendar['first_day_of_school'];
$lastDay = $calendar['last_day_of_school'];
$finalExam = $calendar['final_exam_day'];
$makeupExam = (string) ($schoolYear['fall_makeup_exam_on'] ?? $calendar['fall_makeup_exam_on']);
$orientation = $calendar['orientation_day'];
$midterm = $calendar['midterm_exam_day'];
$installment = $calendar['installment_date'];
$springStart = $calendar['spring_semester_start'];
$configValues = [
'school_year' => $name,
'date_age_reference' => $ageReferenceDate,
'refund_deadline' => $ageReferenceDate,
'enrollment_deadline' => (string) ($schoolYear['registration_ends_on'] ?? ''),
'fall_semester_start' => (string) ($schoolYear['starts_on'] ?? ''),
'last_day_of_school' => (string) ($schoolYear['ends_on'] ?? ''),
'year_start_date' => $yearStart,
'year_end_date' => $yearEnd,
'school_year_start_date' => $yearStart,
'school_year_end_date' => $yearEnd,
'registration_day' => $registrationDay,
'registration_starts_on' => $registrationDay,
'end_of_registration' => $enrollmentDeadline,
'enrollment_deadline' => $enrollmentDeadline,
'1st_day_of_school' => $firstDay,
'first_day_of_school' => $firstDay,
'Installment_date' => $installment,
'installment_date' => $installment,
'fall_semester_start' => $firstDay,
'school_start_date' => $firstDay,
'Due_date' => $firstDay,
'due_date' => $firstDay,
'last_day_of_school' => $lastDay,
'last_school_day' => $lastDay,
'Final_Exam_day' => $finalExam,
'final_exam_day' => $finalExam,
'Make_up_exam' => $makeupExam,
'make_up_exam' => $makeupExam,
'makeup_exam_day' => $makeupExam,
'Orientation_day' => $orientation,
'orientation_day' => $orientation,
'Midterm_exam_day' => $midterm,
'midterm_exam_day' => $midterm,
'spring_semester_start' => $springStart,
];
foreach ($configValues as $key => $value) {
@@ -306,6 +416,76 @@ final class SchoolYearManagementService
}
}
private function withCalendarDefaults(array $payload, string $schoolYearName): array
{
$calendar = $this->calendarPayloadForSchoolYear($schoolYearName);
foreach ($calendar as $key => $value) {
if (! array_key_exists($key, $payload) || trim((string) $payload[$key]) === '') {
$payload[$key] = $value;
}
}
return $payload;
}
private function calendarPayloadForSchoolYear(string $schoolYearName): array
{
if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYearName, $matches)) {
throw new RuntimeException('Unable to calculate school-year dates: invalid school year format.');
}
$startYear = (int) $matches[1];
$endYear = (int) $matches[2];
$firstDay = $this->nextToLastSundayOfMonth($startYear, 9);
$finalExam = $this->lastMondayOfMonth($endYear, 5)->modify('-8 days');
$midterm = $this->nthSundayOfMonth($endYear, 1, 3);
return [
'starts_on' => sprintf('%04d-08-01', $startYear),
'ends_on' => sprintf('%04d-07-31', $endYear),
'registration_starts_on' => sprintf('%04d-08-01', $startYear),
'registration_ends_on' => $firstDay->modify('+15 days')->format('Y-m-d'),
'fall_makeup_exam_on' => $firstDay->modify('-1 week')->format('Y-m-d'),
'orientation_day' => $firstDay->modify('-2 weeks')->format('Y-m-d'),
'first_day_of_school' => $firstDay->format('Y-m-d'),
'1st_day_of_school' => $firstDay->format('Y-m-d'),
'installment_date' => sprintf('%04d-03-01', $endYear),
'last_day_of_school' => $finalExam->modify('+2 weeks')->format('Y-m-d'),
'last_school_day' => $finalExam->modify('+2 weeks')->format('Y-m-d'),
'final_exam_day' => $finalExam->format('Y-m-d'),
'midterm_exam_day' => $midterm->format('Y-m-d'),
'spring_semester_start' => $midterm->modify('+1 week')->format('Y-m-d'),
];
}
private function nextToLastSundayOfMonth(int $year, int $month): DateTimeImmutable
{
$lastDay = (new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month)))
->modify('last day of this month');
$lastSunday = $lastDay->modify('-' . ((int) $lastDay->format('w')) . ' days');
return $lastSunday->modify('-1 week');
}
private function nthSundayOfMonth(int $year, int $month, int $nth): DateTimeImmutable
{
$firstDay = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month));
$daysUntilSunday = (7 - (int) $firstDay->format('w')) % 7;
return $firstDay
->modify('+' . $daysUntilSunday . ' days')
->modify('+' . max(0, $nth - 1) . ' weeks');
}
private function lastMondayOfMonth(int $year, int $month): DateTimeImmutable
{
$lastDay = (new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month)))
->modify('last day of this month');
$daysSinceMonday = ((int) $lastDay->format('N') + 6) % 7;
return $lastDay->modify('-' . $daysSinceMonday . ' days');
}
private function ageReferenceDateForSchoolYear(string $schoolYearName): string
{
if (! preg_match('/^(\d{4})-\d{4}$/', $schoolYearName, $matches)) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid py-3">
<h2 class="mb-3">Financial Aid Requests</h2>
<?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; ?>
<form method="get" class="row g-2 mb-3">
<div class="col-md-3">
<input class="form-control" name="school_year" placeholder="School year" value="<?= esc($schoolYear ?? '') ?>">
</div>
<div class="col-md-2">
<button class="btn btn-primary" type="submit">Filter</button>
</div>
</form>
<div class="table-responsive">
<table class="table table-striped no-mgmt-sticky" data-no-mgmt-sticky>
<thead>
<tr>
<th>ID</th>
<th>Parent</th>
<th>Year</th>
<th>Status</th>
<th>Requested</th>
<th>Submitted</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach (($requests ?? []) as $row): ?>
<?php $parent = $parents[(int) ($row['parent_id'] ?? 0)] ?? []; ?>
<tr>
<td><?= (int) ($row['id'] ?? 0) ?></td>
<td><?= esc(trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')) ?: ('#' . (int) ($row['parent_id'] ?? 0))) ?></td>
<td><?= esc($row['school_year'] ?? '') ?></td>
<td><?= esc($row['status'] ?? '') ?></td>
<td><?= $row['requested_amount'] !== null && $row['requested_amount'] !== '' ? '$' . number_format((float) $row['requested_amount'], 2) : '—' ?></td>
<td><?= esc($row['created_at'] ?? '') ?></td>
<td><a class="btn btn-sm btn-outline-primary" href="<?= site_url('administrator/financial-aid/' . (int) $row['id']) ?>">Review</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($requests)): ?>
<tr><td colspan="7" class="text-muted text-center">No financial aid requests found.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?= $this->endSection() ?>
@@ -0,0 +1,52 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container py-3">
<a href="<?= site_url('administrator/financial-aid') ?>" class="small">&larr; Back to queue</a>
<h2 class="mt-2">Review Financial Aid Request #<?= (int) ($requestRow['id'] ?? 0) ?></h2>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<div class="border rounded p-3 mb-3">
<div><strong>Parent:</strong> <?= esc(trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? ''))) ?> <?= esc($parent['email'] ?? '') ?></div>
<div><strong>School year:</strong> <?= esc($requestRow['school_year'] ?? '') ?></div>
<div><strong>Status:</strong> <?= esc($requestRow['status'] ?? '') ?></div>
<div><strong>Household size:</strong> <?= esc((string) ($requestRow['household_size'] ?? 'Not provided')) ?></div>
<div><strong>Requested amount:</strong> <?= $requestRow['requested_amount'] !== null && $requestRow['requested_amount'] !== '' ? '$' . number_format((float) $requestRow['requested_amount'], 2) : 'Not specified' ?></div>
<div class="mt-2"><strong>Need statement</strong></div>
<p><?= nl2br(esc($requestRow['need_statement'] ?? '')) ?></p>
<div><strong>Students</strong></div>
<ul>
<?php foreach (($students ?? []) as $student): ?>
<li><?= esc(trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''))) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php if (in_array((string) ($requestRow['status'] ?? ''), ['submitted', 'under_review'], true)): ?>
<div class="row g-3">
<div class="col-md-6">
<form method="post" action="<?= site_url('administrator/financial-aid/' . (int) $requestRow['id'] . '/approve') ?>" class="border rounded p-3">
<?= csrf_field() ?>
<h5>Approve</h5>
<label class="form-label" for="admin_amount">Amount to apply</label>
<input class="form-control mb-2" type="number" min="0.01" step="0.01" name="admin_amount" id="admin_amount" required value="<?= esc(old('admin_amount', $requestRow['requested_amount'] ?? '')) ?>">
<label class="form-label" for="admin_note">Note</label>
<textarea class="form-control mb-2" name="admin_note" id="admin_note" rows="3"><?= esc(old('admin_note')) ?></textarea>
<button class="btn btn-success" type="submit">Approve and apply to invoice</button>
</form>
</div>
<div class="col-md-6">
<form method="post" action="<?= site_url('administrator/financial-aid/' . (int) $requestRow['id'] . '/deny') ?>" class="border rounded p-3">
<?= csrf_field() ?>
<h5>Deny</h5>
<label class="form-label" for="deny_note">Reason</label>
<textarea class="form-control mb-2" name="admin_note" id="deny_note" rows="3" required><?= esc(old('admin_note')) ?></textarea>
<button class="btn btn-danger" type="submit">Deny request</button>
</form>
</div>
</div>
<?php endif; ?>
</div>
<?= $this->endSection() ?>
@@ -152,17 +152,17 @@
<div class="card-body">
<div class="row g-3 align-items-end mb-2">
<div class="col-md-2">
<label for="sectionCount" class="form-label">Number of Sections</label>
<label for="sectionCount" class="form-label">Max Number of Sections</label>
<input type="number" min="1" id="sectionCount" class="form-control" placeholder="e.g. 2" />
</div>
<div class="col-md-2">
<label for="minStudents" class="form-label">Minimum Students</label>
<label for="minStudents" class="form-label">Min Students Per Section</label>
<input type="number" min="1" id="minStudents" class="form-control" placeholder="e.g. 20" />
</div>
<div class="col-md-2">
<!--div class="col-md-2">
<label for="maxStudents" class="form-label">Maximum Students</label>
<input type="number" min="1" id="maxStudents" class="form-control" placeholder="Optional" />
</div>
</div-->
<div class="col-md-3 d-flex gap-2">
<button type="button" id="refreshTotalsBtn" class="btn btn-outline-secondary">Refresh Totals</button>
<button type="button" id="generateAllBtn" class="btn btn-primary">Generate All</button>
@@ -217,6 +217,13 @@
</tr>
</thead>
<tbody id="tblBody"></tbody>
<tfoot class="table-light">
<tr>
<th>Total</th>
<th class="text-end" id="allStudentsTotal">0</th>
<th></th>
</tr>
</tfoot>
</table>
</div>
</div>
@@ -247,6 +254,7 @@
}, $classes ?? [])), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
const tblBody = document.getElementById('tblBody');
const allStudentsTotal = document.getElementById('allStudentsTotal');
const sectionCountInput = document.getElementById('sectionCount');
const minInput = document.getElementById('minStudents');
const maxInput = document.getElementById('maxStudents');
@@ -352,6 +360,20 @@
});
});
if (!resolvedClassId && selectedClassId > 0) {
const firstSection = (sectionsByClassId[String(selectedClassId)] || [])[0] || null;
if (firstSection) {
select.value = String(firstSection.id);
resolvedClassId = selectedClassId;
} else {
const baseClass = baseClasses.find(c => c.class_id === selectedClassId) || null;
if (baseClass) {
select.value = String(baseClass.class_section_id);
resolvedClassId = selectedClassId;
}
}
}
const selectedOption = select.options[select.selectedIndex] || null;
if (!resolvedClassId && selectedOption) {
resolvedClassId = parseInt(selectedOption.dataset.classId || '0', 10);
@@ -380,8 +402,10 @@
function buildInitialTable(rows) {
tblBody.innerHTML = '';
rowIndexByClassId = {};
let allTotal = 0;
rows.forEach(function(r){
allTotal += parseInt(r.total || '0', 10) || 0;
const tr = document.createElement('tr');
tr.dataset.classId = r.class_id;
tr.dataset.classSectionId = r.class_section_id;
@@ -417,13 +441,16 @@
renderSectionsForRow(r.class_section_id, r.sections, r.students || []);
}
});
if (allStudentsTotal) {
allStudentsTotal.textContent = String(allTotal);
}
applyStudentSearch();
}
function runDistribution(baseSectionId, baseName) {
const sectionCount = parseInt(sectionCountInput.value || '0', 10);
const minStudents = parseInt(minInput.value || '0', 10);
const maxStudents = parseInt(maxInput.value || '0', 10);
const maxStudents = parseInt((maxInput && maxInput.value) || '0', 10);
if (!sectionCount || sectionCount <= 0 || !minStudents || minStudents <= 0) {
msgEl.textContent = 'Enter number of sections and minimum students first.';
return;
@@ -611,6 +638,7 @@
student_name: assignment.student_name || 'Student',
age_at_reference: assignment.age_at_reference ?? null,
gender: assignment.gender || '',
previous_final_score: assignment.previous_final_score ?? null,
last_year_class_section: assignment.last_year_class_section || '',
class_id: parseInt(assignment.class_id || section.class_id || '0', 10),
class_section_id: parseInt(assignment.class_section_id || section.class_section_id || '0', 10),
@@ -623,6 +651,27 @@
return out;
}
function assignmentStats(assignments) {
const stats = { male: 0, female: 0, scoreTotal: 0, scoreCount: 0 };
(Array.isArray(assignments) ? assignments : []).forEach(function(assignment){
const gender = String(assignment.gender || '').trim().toLowerCase();
if (gender === 'female' || gender === 'f') {
stats.female++;
} else if (gender === 'male' || gender === 'm') {
stats.male++;
}
const score = Number(assignment.previous_final_score);
if (Number.isFinite(score)) {
stats.scoreTotal += score;
stats.scoreCount++;
}
});
stats.averageScore = stats.scoreCount ? stats.scoreTotal / stats.scoreCount : null;
return stats;
}
function renderClassRoster(tr, assignments) {
const wrap = document.createElement('div');
wrap.className = 'distribution-class-roster';
@@ -641,6 +690,23 @@
title.appendChild(count);
wrap.appendChild(title);
const stats = assignmentStats(assignments);
const meta = document.createElement('div');
meta.className = 'distribution-meta';
if (stats.averageScore !== null) {
const avg = document.createElement('span');
avg.className = 'badge text-bg-light';
avg.textContent = 'Avg ' + stats.averageScore.toFixed(2);
meta.appendChild(avg);
}
if (stats.male || stats.female) {
const gender = document.createElement('span');
gender.className = 'badge text-bg-light';
gender.textContent = 'M ' + stats.male + ' / F ' + stats.female;
meta.appendChild(gender);
}
if (meta.children.length) wrap.appendChild(meta);
if (!assignments.length) {
const empty = document.createElement('div');
empty.className = 'distribution-empty';
+3 -24
View File
@@ -30,7 +30,6 @@ $warningText = static function (array $warnings): string {
'include_event_only' => !empty($filters['include_event_only']) ? '1' : '0',
'include_paid_invoices' => !empty($filters['include_paid_invoices']) ? '1' : '0',
'unit_price' => $filters['unit_price'] ?? '',
'youth_unit_price' => $filters['youth_unit_price'] ?? '',
]);
?>
<a href="<?= site_url('administrator/tuition-forecast/export?' . $exportQuery) ?>" class="btn btn-success">
@@ -71,7 +70,7 @@ $warningText = static function (array $warnings): string {
</select>
</div>
<div class="col-md-3">
<label for="unit_price" class="form-label">Grades Unit Price</label>
<label for="unit_price" class="form-label">Base Tuition</label>
<input
type="number"
min="0"
@@ -80,19 +79,7 @@ $warningText = static function (array $warnings): string {
name="unit_price"
class="form-control"
value="<?= esc((string) ($filters['unit_price'] ?? ($summary['unit_price'] ?? ''))) ?>"
placeholder="New tuition unit price for grades">
</div>
<div class="col-md-3">
<label for="youth_unit_price" class="form-label">Youth Unit Price</label>
<input
type="number"
min="0"
step="0.01"
id="youth_unit_price"
name="youth_unit_price"
class="form-control"
value="<?= esc((string) ($filters['youth_unit_price'] ?? ($summary['youth_unit_price'] ?? ''))) ?>"
placeholder="New tuition unit price for youth">
placeholder="First student tuition">
</div>
<div class="col-md-2">
<div class="form-check">
@@ -149,19 +136,11 @@ $warningText = static function (array $warnings): string {
<div class="col-xl col-lg-3 col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="text-muted small">Grades Unit Price</div>
<div class="text-muted small">Base Tuition</div>
<div class="fs-5 fw-semibold"><?= esc($fmtMoney($summary['unit_price'] ?? 0)) ?></div>
</div>
</div>
</div>
<div class="col-xl col-lg-3 col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="text-muted small">Youth Unit Price</div>
<div class="fs-5 fw-semibold"><?= esc($fmtMoney($summary['youth_unit_price'] ?? 0)) ?></div>
</div>
</div>
</div>
<div class="col-xl col-lg-3 col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
+508 -318
View File
@@ -1,256 +1,418 @@
<?= $this->extend('layout/main_layout') ?>
<?= $this->section('content') ?>
<div class="container my-5">
<h3 class="text-center text-success" style="font-family: Arial, sans-serif;">Enroll in Classes</h3>
<?php if (!empty($selectedYear)): ?>
<div class="text-center text-muted small">School Year: <?= esc($selectedYear) ?></div>
<?php endif; ?>
</div>
<?= $this->section('styles') ?>
<style>
.enrollment-start-panel {
border: 1px solid #d8e7dc;
border-radius: 8px;
background: #f8fbf8;
padding: 1rem;
}
.enrollment-stepper {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: .5rem;
}
.enrollment-step {
border-bottom: 3px solid #dee2e6;
color: #6c757d;
font-size: .85rem;
padding: .35rem 0;
text-align: center;
}
.enrollment-step.is-active {
border-color: #198754;
color: #198754;
font-weight: 700;
}
.student-select-card {
border: 1px solid #dee2e6;
border-radius: 8px;
cursor: pointer;
padding: .85rem;
transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;
}
.student-select-card.is-selected {
background: #eef8f0;
border-color: #198754;
box-shadow: 0 0 0 .15rem rgba(25, 135, 84, .14);
}
.student-select-card.is-disabled {
background: #f8f9fa;
color: #6c757d;
cursor: not-allowed;
}
.student-select-icon {
align-items: center;
border: 1px solid #adb5bd;
border-radius: 50%;
display: inline-flex;
height: 1.65rem;
justify-content: center;
width: 1.65rem;
}
.student-select-card.is-selected .student-select-icon {
background: #198754;
border-color: #198754;
color: #fff;
}
.enrollment-policy-frame {
border: 1px solid #dee2e6;
border-radius: 8px;
height: min(54vh, 520px);
min-height: 320px;
width: 100%;
}
.enrollment-modal-footer {
gap: .5rem;
}
@media (max-width: 575.98px) {
.enrollment-stepper {
gap: .25rem;
}
.enrollment-step {
font-size: .72rem;
}
.enrollment-modal-footer {
align-items: stretch;
flex-direction: column;
}
.enrollment-modal-footer .btn {
width: 100%;
}
.student-select-card {
padding: .75rem;
}
.enrollment-status-table,
.enrollment-status-table thead,
.enrollment-status-table tbody,
.enrollment-status-table tr,
.enrollment-status-table th,
.enrollment-status-table td {
display: block;
width: 100%;
}
.enrollment-status-table thead {
display: none;
}
.enrollment-status-table {
border: 0;
}
.enrollment-status-table tr {
border: 1px solid #dee2e6;
border-radius: 8px;
margin-bottom: .85rem;
overflow: hidden;
}
.enrollment-status-table td {
align-items: flex-start;
border-bottom: 1px solid #eef1f3;
display: flex;
gap: .75rem;
justify-content: space-between;
padding: .75rem;
text-align: right;
}
.enrollment-status-table td:last-child {
border-bottom: 0;
}
.enrollment-status-table td::before {
color: #6c757d;
content: attr(data-label);
flex: 0 0 42%;
font-size: .8rem;
font-weight: 700;
text-align: left;
}
.enrollment-status-table td > * {
max-width: 58%;
}
}
</style>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?php
// Put this near the very top of the file (before output), or right after the extend/section lines.
$tz = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$deadlineObj = (new DateTime($lastDayOfRegistration, new DateTimeZone($tz)))->setTime(23, 59, 59);
$nowObj = new DateTime('now', new DateTimeZone($tz));
$nowObj = new DateTime('now', new DateTimeZone($tz));
$deadlinePassed = $nowObj > $deadlineObj;
$deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
$hasAcceptedSchoolPolicy = (bool) ($hasAcceptedSchoolPolicy ?? false);
$familyFinancialSummary = is_array($familyFinancialSummary ?? null) ? $familyFinancialSummary : [];
$money = static function ($amount) use ($familyFinancialSummary): string {
$currency = (string) ($familyFinancialSummary['currency'] ?? '$');
return $currency . number_format((float) $amount, 2);
};
$statusBadge = static function (?string $status): string {
$status = strtolower(trim((string) $status));
return match ($status) {
'admission under review' => '<span class="badge bg-primary">admission under review</span>',
'review & decision' => '<span class="badge bg-secondary">review &amp; decision</span>',
'payment pending' => '<span class="badge bg-warning text-dark">payment pending</span>',
'enrolled' => '<span class="badge bg-success">enrolled</span>',
'withdraw under review' => '<span class="badge bg-warning text-dark">withdraw under review</span>',
'refund pending' => '<span class="badge bg-info text-dark">refund pending</span>',
'withdrawn' => '<span class="badge bg-danger">withdrawn</span>',
'waitlist' => '<span class="badge bg-secondary">waitlist</span>',
'denied' => '<span class="badge bg-danger">denied</span>',
'not enrolled' => '<span class="badge bg-light text-dark">not enrolled</span>',
default => '<span class="badge bg-light text-dark">unknown</span>',
};
};
$enrollableCount = 0;
$withdrawableCount = 0;
foreach (($students ?? []) as $student) {
$eligibilityMessage = is_array($student['enrollment_eligibility_message'] ?? null) ? $student['enrollment_eligibility_message'] : ['blocking' => false];
if (($student['enrollment_status'] ?? '') === 'not enrolled' && !$deadlinePassed && $isEditable && empty($eligibilityMessage['blocking'])) {
$enrollableCount++;
}
if (($student['enrollment_status'] ?? '') === 'enrolled' && $isEditable) {
$withdrawableCount++;
}
}
?>
<!-- Registration Info -->
<div class="alert alert-info mb-3">
<p>Enrollment is the process of officially signing up your child for the upcoming school year.</p>
<ul>
<li>Last Day for Enrollment is <strong><?= esc(local_date($lastDayOfRegistration, 'm-d-Y')) ?></strong>.</li>
<li>Once you click "Save", the enrollment status will change to <strong>admission under review</strong>.</li>
<li>Payments are processed on the first day of school: <strong><?= esc(local_date($schoolStartDate, 'm-d-Y')) ?></strong>.</li>
</ul>
</div>
<?php if ($familyFinancialSummary !== []): ?>
<div class="border rounded p-3 mb-3 bg-light">
<div class="fw-semibold mb-2">Family Account Information</div>
<div class="row g-2">
<div class="col-md-4">
<span class="text-muted">Previous-year carry-over balance:</span>
<strong><?= esc($money($familyFinancialSummary['carry_over_balance'] ?? 0)) ?></strong>
</div>
<div class="col-md-4">
<span class="text-muted">Registration fee:</span>
<strong><?= esc($money($familyFinancialSummary['registration_fee'] ?? 0)) ?></strong>
</div>
<div class="col-md-4">
<span class="text-muted">Tuition due now:</span>
<strong><?= esc($money($familyFinancialSummary['tuition_due_at_registration'] ?? 0)) ?></strong>
</div>
<div class="col-md-4">
<span class="text-muted">Mandatory fees:</span>
<strong><?= esc($money($familyFinancialSummary['mandatory_fees'] ?? 0)) ?></strong>
</div>
<div class="col-md-4">
<span class="text-muted">Current-year account balance:</span>
<strong><?= esc($money($familyFinancialSummary['current_balance'] ?? 0)) ?></strong>
</div>
<div class="col-md-4">
<span class="text-muted">Total currently due:</span>
<strong><?= esc($money($familyFinancialSummary['amount_due'] ?? 0)) ?></strong>
</div>
</div>
<?php if (!empty($familyFinancialSummary['policy_message'])): ?>
<div class="small text-muted mt-2"><?= esc($familyFinancialSummary['policy_message']) ?></div>
<div class="container my-4">
<div class="text-center mb-4">
<h3 class="text-success" style="font-family: Arial, sans-serif;">Enroll in Classes</h3>
<?php if (!empty($selectedYear)): ?>
<div class="text-muted small">School Year: <?= esc($selectedYear) ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php $hasAcceptedSchoolPolicy = (bool) ($hasAcceptedSchoolPolicy ?? false); ?>
<?php if (!empty($students)): ?>
<form action="<?= base_url('/parent/enroll_classes_handler') ?>" method="post">
<?= csrf_field() ?>
<input type="hidden" id="accept_school_policy_input" name="accept_school_policy" value="<?= $hasAcceptedSchoolPolicy ? '1' : '0' ?>">
<div class="table-responsive">
<table class="table table-striped table-bordered align-middle">
<thead>
<tr>
<th>#</th>
<th>School ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Age</th>
<th>Gender</th>
<th>Grade</th>
<th>Decision</th>
<th>Required Action</th>
<th>Enroll</th>
<th>Withdraw</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<?php foreach ($students as $index => $student): ?>
<?php $eligibilityMessage = is_array($student['enrollment_eligibility_message'] ?? null) ? $student['enrollment_eligibility_message'] : ['message' => '', 'blocking' => false, 'level' => 'info']; ?>
<div class="alert alert-info mb-3">
<p>Enrollment is the process of officially signing up your child for the upcoming school year.</p>
<ul class="mb-0">
<li>Last Day for Enrollment is <strong><?= esc(local_date($lastDayOfRegistration, 'm-d-Y')) ?></strong>.</li>
<li>After submit, enrollment status changes to <strong>admission under review</strong>.</li>
<li>Payments are processed on the first day of school: <strong><?= esc(local_date($schoolStartDate, 'm-d-Y')) ?></strong>.</li>
</ul>
</div>
<?php if ($familyFinancialSummary !== []): ?>
<div class="border rounded p-3 mb-3 bg-light">
<div class="fw-semibold mb-2">Family Account Information</div>
<div class="row g-2">
<div class="col-md-4"><span class="text-muted">Previous-year carry-over balance:</span> <strong><?= esc($money($familyFinancialSummary['carry_over_balance'] ?? 0)) ?></strong></div>
<div class="col-md-4"><span class="text-muted">Registration fee:</span> <strong><?= esc($money($familyFinancialSummary['registration_fee'] ?? 0)) ?></strong></div>
<div class="col-md-4"><span class="text-muted">Tuition due now:</span> <strong><?= esc($money($familyFinancialSummary['tuition_due_at_registration'] ?? 0)) ?></strong></div>
<div class="col-md-4"><span class="text-muted">Mandatory fees:</span> <strong><?= esc($money($familyFinancialSummary['mandatory_fees'] ?? 0)) ?></strong></div>
<div class="col-md-4"><span class="text-muted">Current-year account balance:</span> <strong><?= esc($money($familyFinancialSummary['current_balance'] ?? 0)) ?></strong></div>
<div class="col-md-4"><span class="text-muted">Total currently due:</span> <strong><?= esc($money($familyFinancialSummary['amount_due'] ?? 0)) ?></strong></div>
</div>
<?php if (!empty($familyFinancialSummary['policy_message'])): ?>
<div class="small text-muted mt-2"><?= esc($familyFinancialSummary['policy_message']) ?></div>
<?php endif; ?>
<div class="small mt-2"><a href="<?= site_url('parent/financial-aid') ?>">Request financial aid</a></div>
</div>
<?php endif; ?>
<?php if (!empty($students)): ?>
<form action="<?= base_url('/parent/enroll_classes_handler') ?>" method="post" id="enrollmentFlowForm">
<?= csrf_field() ?>
<input type="hidden" id="accept_school_policy_input" name="accept_school_policy" value="<?= $hasAcceptedSchoolPolicy ? '1' : '0' ?>">
<div class="enrollment-start-panel mb-4">
<div class="row g-3 align-items-center">
<div class="col-lg">
<div class="fw-semibold">Enrollment process</div>
<div class="text-muted small">
Select students, review policy information, then submit enrollment.
</div>
</div>
<div class="col-lg-auto">
<button type="button"
class="btn btn-success btn-lg w-100"
id="startEnrollmentButton"
<?= (!$isEditable || $enrollableCount === 0 || $deadlinePassed) ? 'disabled' : '' ?>>
Start Enrollment
</button>
</div>
</div>
<?php if ($deadlinePassed): ?>
<div class="small text-danger mt-2">Enrollment closed on <?= esc($deadlineObj->format('m-d-Y')) ?>.</div>
<?php elseif ($enrollableCount === 0): ?>
<div class="small text-muted mt-2">No students are currently available for new enrollment.</div>
<?php endif; ?>
</div>
<div class="table-responsive mb-4">
<table class="table table-striped table-bordered align-middle enrollment-status-table">
<thead>
<tr>
<td><?= $index + 1 ?></td>
<td><?= esc($student['school_id'] ?? 'N/A') ?></td>
<td><?= esc($student['firstname'] ?? 'N/A') ?></td>
<td><?= esc($student['lastname'] ?? 'N/A') ?></td>
<td><?= esc($student['age'] ?? 'N/A') ?></td>
<td><?= esc($student['gender'] ?? 'N/A') ?></td>
<td>
<?php
$section = $student['class_section'] ?? null;
echo $section
? esc(preg_replace_callback('/\byouth\b/i', fn($m) => ucfirst(strtolower($m[0])), $section))
: 'Not Assigned';
?>
</td>
<td><?= esc($student['transition_evaluation']['decision_label'] ?? 'Pending') ?></td>
<td><?= esc($student['required_action_label'] ?? 'Contact the school administration.') ?></td>
<!-- Enroll Checkbox -->
<td>
<?php if ($student['enrollment_status'] === 'not enrolled'): ?>
<?php $disableEnrollUI = $deadlinePassed || !$isEditable || (bool) ($eligibilityMessage['blocking'] ?? false); ?>
<input type="checkbox"
name="enroll[]"
value="<?= esc($student['id']) ?>"
data-decision-message="<?= esc($eligibilityMessage['message'] ?? '') ?>"
data-decision-blocking="<?= !empty($eligibilityMessage['blocking']) ? '1' : '0' ?>"
data-decision-message-target="enrollment-decision-message-<?= esc($student['id']) ?>"
<?= $disableEnrollUI ? 'disabled' : '' ?>>
<?php elseif (in_array($student['enrollment_status'], ['enrolled', 'admission under review', 'review & decision', 'payment pending', 'withdraw under review'])): ?>
<input type="checkbox" checked disabled>
<?php else: ?>
<input type="checkbox" disabled>
<?php endif; ?>
</td>
<!-- Withdraw Checkbox -->
<td>
<?php if ($student['enrollment_status'] === 'enrolled'): ?>
<input type="checkbox" name="withdraw[]" value="<?= esc($student['id']) ?>" <?= !$isEditable ? 'disabled' : '' ?>>
<?php elseif ($student['enrollment_status'] === 'withdrawn'): ?>
<input type="checkbox" checked disabled>
<?php else: ?>
<input type="checkbox" disabled>
<?php endif; ?>
</td>
<!-- Status -->
<td>
<?php
$status = strtolower(trim($student['enrollment_status'] ?? ''));
switch ($status) {
case 'admission under review':
echo '<span class="badge bg-primary">admission under review</span>';
break;
case 'review & decision':
echo '<span class="badge bg-secondary">review &amp; decision</span>';
break;
case 'payment pending':
echo '<span class="badge bg-warning text-dark">payment pending</span>';
break;
case 'enrolled':
echo '<span class="badge bg-success">enrolled</span>';
break;
case 'withdraw under review':
echo '<span class="badge bg-warning text-dark">withdraw under review</span>';
break;
case 'refund pending':
echo '<span class="badge bg-info text-dark">refund pending</span>';
break;
case 'withdrawn':
echo '<span class="badge bg-danger">withdrawn</span>';
break;
case 'waitlist':
echo '<span class="badge bg-secondary">waitlist</span>'; // lighter and neutral
break;
case 'denied':
echo '<span class="badge bg-danger">denied</span>'; // red stands out clearly
break;
case 'not enrolled':
echo '<span class="badge bg-light text-dark">not enrolled</span>'; // very neutral
break;
default:
echo '<span class="badge bg-light text-dark">unknown</span>';
}
?>
</td>
<th>Student</th>
<th>Grade</th>
<th>Decision</th>
<th>Required Action</th>
<th>Status</th>
<th>Withdraw</th>
</tr>
<?php if (($eligibilityMessage['message'] ?? '') !== ''): ?>
<tr id="enrollment-decision-message-<?= esc($student['id']) ?>" class="enrollment-decision-message-row">
<td colspan="12">
<div class="alert alert-<?= esc($eligibilityMessage['level'] ?? 'info') ?> mb-0">
<?= esc($eligibilityMessage['message']) ?>
</div>
</thead>
<tbody>
<?php foreach ($students as $student): ?>
<?php
$studentName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''));
$studentName = $studentName !== '' ? $studentName : 'Student';
$section = $student['class_section'] ?? null;
$gradeLabel = $section
? preg_replace_callback('/\byouth\b/i', fn($m) => ucfirst(strtolower($m[0])), (string) $section)
: 'Not Assigned';
?>
<tr>
<td data-label="Student">
<div class="fw-semibold"><?= esc($studentName) ?></div>
<div class="text-muted small">School ID: <?= esc($student['school_id'] ?? 'N/A') ?></div>
</td>
<td data-label="Grade"><?= esc($gradeLabel) ?></td>
<td data-label="Decision"><?= esc($student['transition_evaluation']['decision_label'] ?? 'Pending') ?></td>
<td data-label="Required Action"><?= esc($student['required_action_label'] ?? 'Contact administration') ?></td>
<td data-label="Status"><?= $statusBadge($student['enrollment_status'] ?? '') ?></td>
<td data-label="Withdraw">
<?php if (($student['enrollment_status'] ?? '') === 'enrolled'): ?>
<div class="form-check form-switch m-0">
<input class="form-check-input" type="checkbox" name="withdraw[]" value="<?= esc($student['id']) ?>" id="withdraw-<?= esc($student['id']) ?>" <?= !$isEditable ? 'disabled' : '' ?>>
<label class="form-check-label small" for="withdraw-<?= esc($student['id']) ?>">Request</label>
</div>
<?php elseif (($student['enrollment_status'] ?? '') === 'withdrawn'): ?>
<span class="text-muted small">Withdrawn</span>
<?php else: ?>
<span class="text-muted small">Unavailable</span>
<?php endif; ?>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Save Button -->
<?php
$today = local_date(utc_now(), 'Y-m-d');
$disableDueToDate = ($today >= $lastDayOfRegistration);
// Allow submit for withdrawals even after deadline; keep editability guard only
$disableSave = !$isEditable;
?>
<div class="d-flex justify-content-center">
<button type="submit"
class="btn btn-lg btn-success <?= $disableSave ? 'disabled' : '' ?>"
<?= $disableSave ? 'disabled' : '' ?>
title="<?php
if (!$isEditable) {
echo 'Editing is not allowed for this record.';
}
?>">
Submit
</button>
</div>
<br>
</form>
<?php else: ?>
<p>No students found for the selected school year. Please register your kids first.</p>
<?php endif; ?>
<!-- School Policy Modal -->
<div class="modal fade" id="schoolPolicyModal" tabindex="-1" aria-labelledby="schoolPolicyModalLabel" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="schoolPolicyModalLabel">School Policies</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="modal-body">
<iframe src="<?= base_url('policy/school_policy') ?>" width="100%" height="520" frameborder="0" title="School Policies"></iframe>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" value="1" id="schoolPolicyAcceptedCheckbox" <?= $hasAcceptedSchoolPolicy ? 'checked disabled' : '' ?>>
<label class="form-check-label" for="schoolPolicyAcceptedCheckbox">
I have read and accept all school policies.
</label>
<?php if ($withdrawableCount > 0): ?>
<div class="d-flex justify-content-center mb-4">
<button type="submit" class="btn btn-outline-danger" id="withdrawSubmitButton">Submit Withdrawal Request</button>
</div>
<?php endif; ?>
<div class="modal fade" id="enrollmentFlowModal" tabindex="-1" aria-labelledby="enrollmentFlowModalLabel" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-xl modal-dialog-scrollable modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="enrollmentFlowModalLabel">Enroll your childs</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="enrollment-stepper mb-3" aria-label="Enrollment steps">
<div class="enrollment-step is-active" data-step-indicator="0">Students</div>
<div class="enrollment-step" data-step-indicator="1">Policy</div>
<div class="enrollment-step" data-step-indicator="2">Submit</div>
</div>
<div data-step-panel="0">
<h6 class="fw-semibold">Select student names</h6>
<div class="text-muted small mb-3">Tap each student you want to enroll for <?= esc($selectedYear ?? 'the selected school year') ?>.</div>
<div class="d-grid gap-2">
<?php foreach ($students as $student): ?>
<?php
$eligibilityMessage = is_array($student['enrollment_eligibility_message'] ?? null) ? $student['enrollment_eligibility_message'] : ['message' => '', 'blocking' => false, 'level' => 'info'];
$studentName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''));
$studentName = $studentName !== '' ? $studentName : 'Student';
$canEnroll = ($student['enrollment_status'] ?? '') === 'not enrolled'
&& !$deadlinePassed
&& $isEditable
&& empty($eligibilityMessage['blocking']);
$section = $student['class_section'] ?? null;
$gradeLabel = $section
? preg_replace_callback('/\byouth\b/i', fn($m) => ucfirst(strtolower($m[0])), (string) $section)
: 'Not Assigned';
?>
<div class="student-select-card <?= $canEnroll ? '' : 'is-disabled' ?>"
data-student-card
data-student-id="<?= esc($student['id']) ?>"
data-selectable="<?= $canEnroll ? '1' : '0' ?>">
<div class="d-flex gap-3 align-items-start">
<span class="student-select-icon" aria-hidden="true"><i class="bi bi-check2"></i></span>
<div class="flex-grow-1">
<div class="d-flex flex-wrap gap-2 align-items-center">
<div class="fw-semibold"><?= esc($studentName) ?></div>
<?= $statusBadge($student['enrollment_status'] ?? '') ?>
</div>
<div class="small text-muted">Grade: <?= esc($gradeLabel) ?> &middot; School ID: <?= esc($student['school_id'] ?? 'N/A') ?></div>
<div class="small">Required action: <?= esc($student['required_action_label'] ?? 'Contact administration') ?></div>
<?php if (($eligibilityMessage['message'] ?? '') !== ''): ?>
<div class="alert alert-<?= esc($eligibilityMessage['level'] ?? 'info') ?> py-2 px-3 mt-2 mb-0 small">
<?= esc($eligibilityMessage['message']) ?>
</div>
<?php endif; ?>
</div>
</div>
<?php if ($canEnroll): ?>
<input class="d-none" type="checkbox" name="enroll[]" value="<?= esc($student['id']) ?>" data-enroll-input>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="d-none" data-step-panel="1">
<h6 class="fw-semibold">Policy info update</h6>
<div class="text-muted small mb-3">Review the current school policies before submitting enrollment.</div>
<iframe src="<?= base_url('policy/school_policy') ?>" class="enrollment-policy-frame" frameborder="0" title="School Policies"></iframe>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" value="1" id="schoolPolicyAcceptedCheckbox" <?= $hasAcceptedSchoolPolicy ? 'checked disabled' : '' ?>>
<label class="form-check-label" for="schoolPolicyAcceptedCheckbox">
I have read and accept all school policies.
</label>
</div>
</div>
<div class="d-none" data-step-panel="2">
<h6 class="fw-semibold">Enroll submit</h6>
<div class="text-muted small mb-3">Review the selected students, then submit enrollment.</div>
<div id="enrollmentReviewList" class="d-grid gap-2"></div>
<div class="alert alert-warning mt-3 mb-0 small">
Submitting sends the selected enrollment(s) to admission review.
</div>
</div>
</div>
<div class="modal-footer enrollment-modal-footer">
<button type="button" class="btn btn-outline-secondary" id="enrollmentBackButton">Back</button>
<button type="button" class="btn btn-success" id="enrollmentNextButton">Next</button>
<button type="submit" class="btn btn-success d-none" id="enrollmentSubmitButton">Submit Enrollment</button>
</div>
</div>
</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-success" id="acceptSchoolPolicyButton" <?= $hasAcceptedSchoolPolicy ? '' : 'disabled' ?>>
Accept Policy
</button>
</div>
</div>
</div>
</form>
<?php else: ?>
<p>No students found for the selected school year. Please register your kids first.</p>
<?php endif; ?>
</div>
<!-- Enrollment Deadline Modal -->
<div class="modal fade" id="deadlineModal" tabindex="-1" aria-labelledby="deadlineModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
@@ -259,11 +421,8 @@ $money = static function ($amount) use ($familyFinancialSummary): string {
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Enrollment for the selected school year closed on
<strong><?= esc($deadlineObj->format('m-d-Y')) ?></strong>.
You can still request a withdrawal (if applicable), but new enrollments are no longer accepted.
<br><br>
If you believe this is an error, please contact the school office.
Enrollment for the selected school year closed on <strong><?= esc($deadlineObj->format('m-d-Y')) ?></strong>.
You can still request a withdrawal if applicable.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">OK</button>
@@ -271,133 +430,164 @@ $money = static function ($amount) use ($familyFinancialSummary): string {
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
document.addEventListener("DOMContentLoaded", function() {
// Values from PHP
const deadlinePassed = <?= $deadlinePassed ? 'true' : 'false' ?>;
const enrollmentDeadline = new Date("<?= esc($deadlineISO) ?>");
let hasAcceptedSchoolPolicy = <?= $hasAcceptedSchoolPolicy ? 'true' : 'false' ?>;
// Bootstrap Modal
const modalEl = document.getElementById('deadlineModal');
const deadlineModal = modalEl ? new bootstrap.Modal(modalEl) : null;
const policyModalEl = document.getElementById('schoolPolicyModal');
const policyModal = policyModalEl ? new bootstrap.Modal(policyModalEl) : null;
const form = document.getElementById('enrollmentFlowForm');
const startButton = document.getElementById('startEnrollmentButton');
const flowModalEl = document.getElementById('enrollmentFlowModal');
const deadlineModalEl = document.getElementById('deadlineModal');
const flowModal = flowModalEl ? new bootstrap.Modal(flowModalEl) : null;
const deadlineModal = deadlineModalEl ? new bootstrap.Modal(deadlineModalEl) : null;
const policyAcceptedInput = document.getElementById('accept_school_policy_input');
const policyAcceptedCheckbox = document.getElementById('schoolPolicyAcceptedCheckbox');
const acceptPolicyButton = document.getElementById('acceptSchoolPolicyButton');
const showDeadlineModal = () => {
if (deadlineModal) deadlineModal.show();
};
const showPolicyModal = () => {
if (policyModal) policyModal.show();
};
const setDecisionMessageVisible = (checkbox, visible) => {
const targetId = checkbox.dataset.decisionMessageTarget || '';
const target = targetId ? document.getElementById(targetId) : null;
if (target) {
target.classList.toggle('d-none', !visible);
const backButton = document.getElementById('enrollmentBackButton');
const nextButton = document.getElementById('enrollmentNextButton');
const submitButton = document.getElementById('enrollmentSubmitButton');
const reviewList = document.getElementById('enrollmentReviewList');
let currentStep = 0;
let hasAcceptedSchoolPolicy = <?= $hasAcceptedSchoolPolicy ? 'true' : 'false' ?>;
const selectedEnrollInputs = () => form ? Array.from(form.querySelectorAll("input[name='enroll[]']:checked")) : [];
const selectedWithdrawInputs = () => form ? Array.from(form.querySelectorAll("input[name='withdraw[]']:checked")) : [];
function setStep(step) {
currentStep = Math.max(0, Math.min(2, step));
document.querySelectorAll('[data-step-panel]').forEach(panel => {
panel.classList.toggle('d-none', Number(panel.dataset.stepPanel) !== currentStep);
});
document.querySelectorAll('[data-step-indicator]').forEach(indicator => {
indicator.classList.toggle('is-active', Number(indicator.dataset.stepIndicator) === currentStep);
});
backButton.classList.toggle('d-none', currentStep === 0);
nextButton.classList.toggle('d-none', currentStep === 2);
submitButton.classList.toggle('d-none', currentStep !== 2);
if (currentStep === 2) {
renderReview();
}
};
const hideAllDecisionMessages = () => {
document.querySelectorAll('.enrollment-decision-message-row').forEach(row => {
row.classList.add('d-none');
});
};
}
if (policyAcceptedCheckbox && acceptPolicyButton) {
policyAcceptedCheckbox.addEventListener('change', function() {
acceptPolicyButton.disabled = !this.checked;
function renderReview() {
const cards = selectedEnrollInputs().map(input => {
const card = input.closest('[data-student-card]');
const name = card ? card.querySelector('.fw-semibold')?.textContent?.trim() : 'Student';
const meta = card ? card.querySelector('.small.text-muted')?.textContent?.trim() : '';
return '<div class="border rounded p-3"><div class="fw-semibold">' + escapeHtml(name || 'Student') + '</div><div class="small text-muted">' + escapeHtml(meta || '') + '</div></div>';
});
reviewList.innerHTML = cards.length ? cards.join('') : '<div class="alert alert-warning mb-0">No students selected.</div>';
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function(char) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' })[char];
});
}
if (acceptPolicyButton) {
acceptPolicyButton.addEventListener('click', function() {
if (policyAcceptedCheckbox && !policyAcceptedCheckbox.checked) {
return;
}
hasAcceptedSchoolPolicy = true;
if (policyAcceptedInput) {
policyAcceptedInput.value = '1';
}
if (policyModal) {
policyModal.hide();
}
});
function syncPolicyAccepted() {
hasAcceptedSchoolPolicy = !!(policyAcceptedCheckbox && policyAcceptedCheckbox.checked);
if (policyAcceptedInput) {
policyAcceptedInput.value = hasAcceptedSchoolPolicy ? '1' : '0';
}
}
// The enrollment form
const form = document.querySelector("form[action*='enroll_classes_handler']");
// 1) Block clicking "enroll" checkboxes after deadline
document.querySelectorAll("input[name='enroll[]']").forEach(cb => {
cb.addEventListener("click", function(e) {
if (startButton && flowModal) {
startButton.addEventListener('click', function() {
if (deadlinePassed) {
e.preventDefault();
e.stopImmediatePropagation();
this.checked = false;
showDeadlineModal();
if (deadlineModal) deadlineModal.show();
return;
}
setStep(0);
flowModal.show();
});
}
if (!hasAcceptedSchoolPolicy) {
e.preventDefault();
e.stopImmediatePropagation();
this.checked = false;
showPolicyModal();
document.querySelectorAll('[data-student-card]').forEach(card => {
card.addEventListener('click', function() {
if (this.dataset.selectable !== '1') {
return;
}
if (this.checked && this.dataset.decisionMessage) {
setDecisionMessageVisible(this, true);
if (this.dataset.decisionBlocking === '1') {
e.preventDefault();
e.stopImmediatePropagation();
this.checked = false;
return;
}
} else {
setDecisionMessageVisible(this, false);
const input = this.querySelector('[data-enroll-input]');
if (!input) {
return;
}
input.checked = !input.checked;
this.classList.toggle('is-selected', input.checked);
});
});
// 2) Prevent submission if any enroll[] is checked after deadline
if (policyAcceptedCheckbox) {
policyAcceptedCheckbox.addEventListener('change', syncPolicyAccepted);
}
if (backButton) {
backButton.addEventListener('click', function() {
setStep(currentStep - 1);
});
}
if (nextButton) {
nextButton.addEventListener('click', function() {
if (currentStep === 0 && selectedEnrollInputs().length === 0) {
alert('Please select at least one student to enroll.');
return;
}
if (currentStep === 1) {
syncPolicyAccepted();
if (!hasAcceptedSchoolPolicy) {
alert('Please read and accept the school policies before continuing.');
return;
}
}
setStep(currentStep + 1);
});
}
if (form) {
form.addEventListener("submit", function(e) {
const anyBoxesChecked = form.querySelectorAll("input[name='enroll[]']:checked").length > 0;
const anyWithdrawChecked = form.querySelectorAll("input[name='withdraw[]']:checked").length > 0;
form.addEventListener('submit', function(e) {
if (e.submitter && e.submitter.id === 'withdrawSubmitButton') {
selectedEnrollInputs().forEach(input => {
input.checked = false;
input.closest('[data-student-card]')?.classList.remove('is-selected');
});
}
if (!anyBoxesChecked && !anyWithdrawChecked) {
if (e.submitter && e.submitter.id === 'enrollmentSubmitButton') {
selectedWithdrawInputs().forEach(input => {
input.checked = false;
});
}
const anyEnroll = selectedEnrollInputs().length > 0;
const anyWithdraw = selectedWithdrawInputs().length > 0;
syncPolicyAccepted();
if (!anyEnroll && !anyWithdraw) {
e.preventDefault();
alert("Please select at least one student to enroll or withdraw before saving.");
alert('Please select at least one student to enroll or withdraw before submitting.');
return;
}
if (!hasAcceptedSchoolPolicy && anyBoxesChecked) {
if (deadlinePassed && anyEnroll) {
e.preventDefault();
showPolicyModal();
if (deadlineModal) deadlineModal.show();
return;
}
if (deadlinePassed && anyBoxesChecked) {
if (anyEnroll && !hasAcceptedSchoolPolicy) {
e.preventDefault();
showDeadlineModal();
setStep(1);
if (flowModal) flowModal.show();
return;
}
// 3) If only withdrawals are selected, ask for a quick confirmation
if (!anyBoxesChecked && anyWithdrawChecked) {
if (!anyEnroll && anyWithdraw) {
const ok = confirm('Confirm withdrawal request for the selected student(s)?');
if (!ok) {
e.preventDefault();
return;
}
}
});
+93
View File
@@ -0,0 +1,93 @@
<?= $this->extend('layout/main_layout') ?>
<?= $this->section('content') ?>
<div class="container my-5">
<h3 class="text-center text-success">Financial Aid</h3>
<?php if (!empty($schoolYear)): ?>
<div class="text-center text-muted small mb-3">School Year: <?= esc($schoolYear) ?></div>
<?php endif; ?>
<?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="alert alert-warning" role="alert">
<h4 class="alert-heading">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Bring these documents to the school office
</h4>
<p class="mb-2">
You must bring <strong>2 recent pay stubs</strong> <strong>and</strong> <strong>last years tax return</strong>
to the school office so administration can review your financial aid request.
</p>
<ul class="mb-2">
<li><strong>Recent pay stubs</strong></li>
<li><strong>Last years tax return</strong></li>
</ul>
<p class="mb-0">This is required whether you are submitting a new request or already have one open.</p>
</div>
<p>Use this form to request a tuition reduction. Administration will review the request and apply any approved amount to your invoice.</p>
<?php if (!empty($requests)): ?>
<div class="table-responsive mb-4">
<table class="table table-bordered">
<thead>
<tr>
<th>Submitted</th>
<th>Status</th>
<th>Requested</th>
<th>Approved amount</th>
<th>Note</th>
</tr>
</thead>
<tbody>
<?php foreach ($requests as $row): ?>
<tr>
<td><?= esc($row['created_at'] ?? '') ?></td>
<td><?= esc($row['status'] ?? '') ?></td>
<td><?= $row['requested_amount'] !== null && $row['requested_amount'] !== '' ? '$' . number_format((float) $row['requested_amount'], 2) : 'Not specified' ?></td>
<td><?= $row['admin_amount'] !== null && $row['admin_amount'] !== '' ? '$' . number_format((float) $row['admin_amount'], 2) : '—' ?></td>
<td><?= esc($row['admin_note'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if (empty($openRequest)): ?>
<form method="post" action="<?= site_url('parent/financial-aid') ?>" class="border rounded p-3 bg-light">
<?= csrf_field() ?>
<div class="mb-3">
<label class="form-label">Students</label>
<?php foreach (($students ?? []) as $student): ?>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="student_ids[]" value="<?= (int) $student['id'] ?>" id="fa_student_<?= (int) $student['id'] ?>">
<label class="form-check-label" for="fa_student_<?= (int) $student['id'] ?>">
<?= esc(trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''))) ?>
</label>
</div>
<?php endforeach; ?>
</div>
<div class="mb-3">
<label class="form-label" for="household_size">Household size</label>
<input class="form-control" type="number" min="1" name="household_size" id="household_size" value="<?= esc(old('household_size')) ?>">
</div>
<div class="mb-3">
<label class="form-label" for="requested_amount">Requested amount (optional)</label>
<input class="form-control" type="number" min="0" step="0.01" name="requested_amount" id="requested_amount" value="<?= esc(old('requested_amount')) ?>">
</div>
<div class="mb-3">
<label class="form-label" for="need_statement">Why are you requesting financial aid?</label>
<textarea class="form-control" name="need_statement" id="need_statement" rows="5" required><?= esc(old('need_statement')) ?></textarea>
</div>
<button class="btn btn-success" type="submit">Submit request</button>
</form>
<?php else: ?>
<div class="alert alert-info">You already have an open request. Administration will update it after review. Remember to bring recent pay stubs and last years tax return to the school office.</div>
<?php endif; ?>
</div>
<?= $this->endSection() ?>
+3
View File
@@ -109,6 +109,9 @@ $deadlineDisplay = formatCalendarDateOnly($dueDate);
Cash, checks and debit/credit cards are all accepted forms of payment. However, if you elect to pay in
installments, only cash and checks will be accepted.
</li>
<li>
Need help with tuition? <a href="<?= site_url('parent/financial-aid') ?>">Request financial aid</a>.
</li>
</ul>
</div>
+6
View File
@@ -325,6 +325,12 @@ switch ($role) {
'label' => 'Invoice',
'title' => 'View and pay your child(ren)\'s invoice securely (tuition & events).',
],
[
'href' => base_url('/parent/financial-aid'),
'icon' => 'bi-heart',
'label' => 'Financial Aid',
'title' => 'Request a tuition reduction for review by school administration.',
],
[
'href' => base_url('/parent/attendance'),
'icon' => 'bi-calendar-check',
+5 -12
View File
@@ -48,8 +48,7 @@ After the registration period ends, parents will be invited to join WhatsApp gra
'title' => 'Tuition',
'body' => 'All parents will be asked to <u>pay tuition for the year</u> or come to an agreement with the administration about an interest-free installment plan where <u>payments are made at the beginning of each month starting October 1st</u>. Accepted methods of payments are cash, check and debit/credit card. Tuition covers school expenses related to operations, purchase of books/supplies/materials, organizing events for students, etc... The school is committed to offer high quality services while maintaining low cost of operations so that tuition is affordable by the majority of parents. <u>Annual</u> tuition for this school year is as follows:
<u>Kindergarten & Grades 1 9</u>: <strong>$370</strong> per child + <strong>$220</strong> per additional child
<u>Youth</u>: $200
<u>All students</u>: <strong>$380</strong> for the first child + <strong>$280</strong> for each additional child
Examples:
<table border="1" cellspacing="0" cellpadding="8" style="border-collapse: collapse; text-align: center; width: 100%;">
@@ -67,22 +66,16 @@ Examples:
</thead>
<tbody>
<tr>
<td># kids in K & Grades 19</td>
<td># of children</td>
<td>1</td>
<td>2</td>
<td>3</td>
</tr>
<tr>
<td># kids in Youth</td>
<td>0</td>
<td>0</td>
<td>1</td>
</tr>
<tr>
<td>Total Tuition</td>
<td><strong>$370</strong></td>
<td><strong>$370 + $220 = $590</strong></td>
<td><strong>$370 + 2×$220 + $200 = $1010</strong></td>
<td><strong>$380</strong></td>
<td><strong>$380 + $280 = $660</strong></td>
<td><strong>$380 + 2×$280 = $940</strong></td>
</tr>
</tbody>
</table>'
+18 -51
View File
@@ -13,11 +13,8 @@
$money = static fn ($value): string => '$' . number_format((float) $value, 2);
$activeId = (int) ($activeYear['id'] ?? 0);
$nextDraftId = (int) ($nextDraftYear['id'] ?? 0);
$newDraftName = (string) ($nextDraftDefaults['name'] ?? '');
$defaultNewPreviousYear = $nextDraftDefaults['previous_year'] ?? null;
$defaultNewPreviousYearName = (string) ($defaultNewPreviousYear['name'] ?? 'None');
$closingPreviewUrl = $activeId > 0
? site_url('administrator/school-years/' . $activeId . '/closing/preview' . ($nextDraftId > 0 ? '?' . http_build_query(['target_school_year_id' => $nextDraftId]) : ''))
? site_url('administrator/school-years/' . $activeId . '/closing/preview')
: '';
?>
@@ -51,14 +48,16 @@
<?php elseif ($activeYear && $nextDraftYear): ?>
<div class="text-muted">Review promotions and balances for <?= esc($activeYear['name']) ?>. When all blockers are resolved, end it and start <?= esc($nextDraftYear['name']) ?>.</div>
<?php elseif ($activeYear): ?>
<div class="text-muted">Create the next school year first. It will stay as a draft until the current year is closed.</div>
<div class="text-muted">Review promotions and balances for <?= esc($activeYear['name']) ?>. The next school year will be created automatically when you close this year.</div>
<?php elseif ($nextDraftYear): ?>
<div class="text-muted">No year is active. Start <?= esc($nextDraftYear['name']) ?> when you are ready.</div>
<?php elseif (empty($schoolYears)): ?>
<div class="text-muted">Create the first school year to begin.</div>
<?php else: ?>
<div class="text-muted">Create a school year draft to begin.</div>
<div class="text-muted">The next school year is created automatically when you close the current year.</div>
<?php endif; ?>
<ol class="small text-muted ps-3 mt-2 mb-0">
<li>Create the next year as a draft.</li>
<li>Open the end-year checklist. The next draft year is created automatically.</li>
<li>Review the end-year checklist for the current year.</li>
<li>End the current year, then start the next year.</li>
</ol>
@@ -66,74 +65,42 @@
<div class="d-flex align-items-start">
<?php if ($closingYear): ?>
<a class="btn btn-primary" href="<?= site_url('administrator/school-years/' . (int) $closingYear['id'] . '/closing/preview') ?>">Finish End-Year Checklist</a>
<?php elseif ($activeYear && $nextDraftYear): ?>
<a class="btn btn-primary" href="<?= esc($closingPreviewUrl, 'attr') ?>">Close Year</a>
<?php elseif ($activeYear): ?>
<button class="btn btn-success" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">Create Next Year</button>
<a class="btn btn-primary" href="<?= esc($closingPreviewUrl, 'attr') ?>">Close Year</a>
<?php elseif ($nextDraftYear): ?>
<button class="btn btn-success" type="button" data-bs-toggle="modal" data-bs-target="#activateSchoolYearModal<?= $nextDraftId ?>">Start <?= esc($nextDraftYear['name']) ?></button>
<?php else: ?>
<?php elseif (empty($schoolYears)): ?>
<button class="btn btn-success" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">Create School Year</button>
<?php endif; ?>
</div>
</div>
</div>
<?php if (empty($schoolYears)): ?>
<div class="collapse mb-4" id="schoolYearCreateForm">
<div class="border rounded bg-white p-3">
<form action="<?= site_url('administrator/school-years/store') ?>" method="post" class="row g-3 align-items-end">
<?= csrf_field() ?>
<div class="col-md-2">
<div class="col-md-3">
<label class="form-label" for="new_school_year_name">School Year</label>
<input
class="form-control"
id="new_school_year_name"
name="name"
type="text"
value="<?= esc($newDraftName !== '' ? $newDraftName : 'Not available', 'attr') ?>"
readonly
placeholder="2025-2026"
pattern="\d{4}-\d{4}"
required
>
</div>
<div class="col-md-2">
<label class="form-label" for="new_previous_school_year_display">Previous Year</label>
<input
class="form-control"
id="new_previous_school_year_display"
type="text"
value="<?= esc($defaultNewPreviousYearName, 'attr') ?>"
readonly
>
</div>
<div class="col-md-2">
<label class="form-label" for="new_school_year_starts_on">Starts On</label>
<input class="form-control" id="new_school_year_starts_on" name="starts_on" type="date">
</div>
<div class="col-md-2">
<label class="form-label" for="new_school_year_ends_on">Ends On</label>
<input class="form-control" id="new_school_year_ends_on" name="ends_on" type="date">
</div>
<div class="col-md-2">
<label class="form-label" for="new_registration_starts_on">Registration Starts</label>
<input class="form-control" id="new_registration_starts_on" name="registration_starts_on" type="date">
</div>
<div class="col-md-2">
<label class="form-label" for="new_registration_ends_on">Registration Ends</label>
<input class="form-control" id="new_registration_ends_on" name="registration_ends_on" type="date">
</div>
<div class="col-md-2">
<label class="form-label" for="new_fall_makeup_exam_on">Fall Makeup Exam</label>
<input class="form-control" id="new_fall_makeup_exam_on" name="fall_makeup_exam_on" type="date">
</div>
<div class="col-md-2 d-flex gap-2">
<button class="btn btn-primary" type="submit" <?= $newDraftName === '' ? 'disabled' : '' ?>>Save Draft</button>
<div class="col-md-3 d-flex gap-2">
<button class="btn btn-primary" type="submit">Save Draft</button>
<button class="btn btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">Cancel</button>
</div>
<div class="col-12">
<label class="form-label" for="new_school_year_description">Description</label>
<textarea class="form-control" id="new_school_year_description" name="description" rows="2"></textarea>
</div>
</form>
</div>
</div>
<?php endif; ?>
<div class="table-responsive">
<table id="schoolYearsTable" class="table table-striped table-hover align-middle">
@@ -216,7 +183,7 @@
</li>
<?php elseif ($status === 'active'): ?>
<li>
<a class="dropdown-item" href="<?= site_url('administrator/school-years/' . $id . '/closing/preview' . ($nextDraftId > 0 ? '?' . http_build_query(['target_school_year_id' => $nextDraftId]) : '')) ?>">Close year</a>
<a class="dropdown-item" href="<?= site_url('administrator/school-years/' . $id . '/closing/preview') ?>">Close year</a>
</li>
<?php elseif ($status === 'closing'): ?>
<li>