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
+96 -22
View File
@@ -1,4 +1,4 @@
name: Deploy to Hosting Server
name: Deploy to Shared Hosting
on:
workflow_dispatch:
@@ -10,15 +10,19 @@ on:
jobs:
deploy:
name: Manual deploy
name: Shared hosting deploy
runs-on: ubuntu-latest
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_APP_PATH: ${{ secrets.DEPLOY_APP_PATH }}
DEPLOY_PUBLIC_PATH: ${{ secrets.DEPLOY_PUBLIC_PATH }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_PHP_BIN: ${{ secrets.DEPLOY_PHP_BIN }}
DEPLOY_INDEX_REQUIRE: ${{ secrets.DEPLOY_INDEX_REQUIRE }}
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
GITEA_PACKAGE_OWNER: ${{ secrets.GITEA_PACKAGE_OWNER }}
GITEA_PACKAGE_USER: ${{ secrets.GITEAPACKAGEUSER }}
@@ -45,10 +49,8 @@ jobs:
run: bash scripts/build-deploy-artifact.sh
- name: Publish deployment package
if: ${{ env.GITEA_PACKAGE_USER != '' && env.GITEA_PACKAGE_TOKEN != '' }}
run: |
test -n "${GITEA_PACKAGE_USER}"
test -n "${GITEA_PACKAGE_TOKEN}"
BASE_URL="${GITEA_BASE_URL:-https://192.168.3.80}"
PACKAGE_OWNER="${GITEA_PACKAGE_OWNER:-melabidi}"
PACKAGE_NAME="alrahma_sunday_school"
@@ -59,9 +61,7 @@ jobs:
PACKAGE_URL="${BASE_URL}/api/packages/${PACKAGE_OWNER}/generic/${PACKAGE_NAME}/${VERSION}/${FILE_NAME}"
tar -czf "build/${FILE_NAME}" -C build/deploy .
echo "Publishing package ${PACKAGE_NAME} ${VERSION} to ${BASE_URL}/-/packages"
echo "Package owner: ${PACKAGE_OWNER}"
echo "Package file: ${FILE_NAME}"
echo "Publishing package ${PACKAGE_NAME} ${VERSION}"
curl --fail-with-body --insecure \
--user "${GITEA_PACKAGE_USER}:${GITEA_PACKAGE_TOKEN}" \
@@ -70,11 +70,33 @@ jobs:
echo "Package uploaded: ${PACKAGE_URL}"
- name: Resolve shared-hosting paths
run: |
APP_PATH="${DEPLOY_APP_PATH:-${DEPLOY_PATH}}"
PUBLIC_PATH="${DEPLOY_PUBLIC_PATH}"
if [ -z "${APP_PATH}" ]; then
echo "Set DEPLOY_APP_PATH (application directory next to public_html)."
echo "Example: /home/u280815660/domains/home.alrahmaisgl.org/alrahma"
exit 1
fi
if [ -z "${PUBLIC_PATH}" ]; then
echo "Set DEPLOY_PUBLIC_PATH (shared-hosting document root)."
echo "Example: /home/u280815660/domains/home.alrahmaisgl.org/public_html"
exit 1
fi
echo "APP_PATH=${APP_PATH}" >> "$GITHUB_ENV"
echo "PUBLIC_PATH=${PUBLIC_PATH}" >> "$GITHUB_ENV"
echo "SSH_PORT=${DEPLOY_PORT:-65002}" >> "$GITHUB_ENV"
echo "PHP_BIN=${DEPLOY_PHP_BIN:-/opt/alt/php85/usr/bin/php}" >> "$GITHUB_ENV"
echo "INDEX_REQUIRE=${DEPLOY_INDEX_REQUIRE:-../alrahma/app/Config/Paths.php}" >> "$GITHUB_ENV"
- name: Configure SSH
run: |
test -n "${DEPLOY_HOST}"
test -n "${DEPLOY_USER}"
test -n "${DEPLOY_PATH}"
test -n "${DEPLOY_SSH_KEY}"
mkdir -p ~/.ssh
@@ -82,29 +104,81 @@ jobs:
printf '%s\n' "${DEPLOY_SSH_KEY}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
SSH_PORT="${DEPLOY_PORT:-22}"
ssh-keyscan -p "${SSH_PORT}" "${DEPLOY_HOST}" >> ~/.ssh/known_hosts
{
echo "Host shared-hosting"
echo " HostName ${DEPLOY_HOST}"
echo " User ${DEPLOY_USER}"
echo " Port ${SSH_PORT}"
echo " IdentityFile ~/.ssh/deploy_key"
echo " IdentitiesOnly yes"
echo " StrictHostKeyChecking accept-new"
echo " ServerAliveInterval 30"
} > ~/.ssh/config
chmod 600 ~/.ssh/config
- name: Deploy files
ssh-keyscan -p "${SSH_PORT}" "${DEPLOY_HOST}" >> ~/.ssh/known_hosts || true
- name: Prepare remote directories
run: |
SSH_PORT="${DEPLOY_PORT:-22}"
ssh -i ~/.ssh/deploy_key -p "${SSH_PORT}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
"mkdir -p '${DEPLOY_PATH}' '${DEPLOY_PATH}/writable/cache' '${DEPLOY_PATH}/writable/logs' '${DEPLOY_PATH}/writable/session' '${DEPLOY_PATH}/writable/uploads' '${DEPLOY_PATH}/writable/debugbar'"
ssh shared-hosting "mkdir -p \
'${APP_PATH}' \
'${APP_PATH}/writable/cache' \
'${APP_PATH}/writable/logs' \
'${APP_PATH}/writable/session' \
'${APP_PATH}/writable/uploads' \
'${APP_PATH}/writable/debugbar' \
'${APP_PATH}/writable/reports' \
'${PUBLIC_PATH}'"
- name: Deploy application files
run: |
rsync -az --delete \
-e "ssh -i ~/.ssh/deploy_key -p ${SSH_PORT}" \
-e ssh \
--exclude '.env' \
--exclude 'writable/cache/***' \
--exclude 'writable/logs/***' \
--exclude 'writable/session/***' \
--exclude 'writable/uploads/***' \
--exclude 'writable/debugbar/***' \
build/deploy/ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/"
--exclude 'writable/reports/***' \
build/deploy/ "shared-hosting:${APP_PATH}/"
- name: Finalize deployment
- name: Deploy public_html document root
run: |
SSH_PORT="${DEPLOY_PORT:-22}"
rsync -az --delete \
-e ssh \
--exclude 'uploads/***' \
--exclude 'cgi-bin/***' \
--exclude '.well-known/***' \
--exclude 'error_log' \
--exclude 'default.php' \
build/deploy/public/ "shared-hosting:${PUBLIC_PATH}/"
ssh -i ~/.ssh/deploy_key -p "${SSH_PORT}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
"cd '${DEPLOY_PATH}' && php spark cache:clear || true"
- name: Finalize shared-hosting release
run: |
ssh shared-hosting "APP_PATH=$(printf '%q' "${APP_PATH}") PUBLIC_PATH=$(printf '%q' "${PUBLIC_PATH}") INDEX_REQUIRE=$(printf '%q' "${INDEX_REQUIRE}") PHP_BIN=$(printf '%q' "${PHP_BIN}") bash -s" <<'REMOTE'
set -euo pipefail
test -f "${PUBLIC_PATH}/index.php"
test -d "${APP_PATH}/app"
test -d "${APP_PATH}/vendor"
grep -q "require FCPATH . '" "${PUBLIC_PATH}/index.php"
sed -i "s|require FCPATH . '../[^']*app/Config/Paths.php';|require FCPATH . '${INDEX_REQUIRE}';|" "${PUBLIC_PATH}/index.php"
grep -F "require FCPATH . '${INDEX_REQUIRE}';" "${PUBLIC_PATH}/index.php"
chmod 644 "${PUBLIC_PATH}/index.php" "${PUBLIC_PATH}/.htaccess" || true
find "${PUBLIC_PATH}" -type d -exec chmod 755 {} +
find "${APP_PATH}/writable" -type d -exec chmod 775 {} + || true
find "${APP_PATH}/writable" -type f -exec chmod 664 {} + || true
if [ -x "${PHP_BIN}" ]; then
(cd "${APP_PATH}" && "${PHP_BIN}" spark cache:clear) || true
else
(cd "${APP_PATH}" && php spark cache:clear) || true
fi
echo "Deployed app to ${APP_PATH}"
echo "Deployed document root to ${PUBLIC_PATH}"
echo "index.php requires ${INDEX_REQUIRE}"
REMOTE
+6
View File
@@ -1,6 +1,12 @@
.env
.env.*
!.env.example
/env
/env.production
/env.backup
/env_for_test*
/environment.txt
/env\ (copy\ 1)
/vendor/
/node_modules/
+41 -8
View File
@@ -31,24 +31,57 @@ Excluded:
- local scripts that are not needed at runtime
- runtime cache, logs, sessions, uploads, and generated reports
The server keeps its own `.env` and runtime `writable/` contents. The deploy job syncs from `build/deploy/` and excludes mutable writable subdirectories so production data is not overwritten.
The server keeps its own `.env` and runtime `writable/` contents. The deploy job never overwrites those.
## Shared hosting layout
`.gitea/workflows/deploy.yml` deploys to Hostinger-style shared hosting, where the document root is `public_html` and the CodeIgniter application lives in a sibling directory:
```text
/home/USER/domains/home.alrahmaisgl.org/alrahma application (not web-accessible)
/home/USER/domains/home.alrahmaisgl.org/public_html document root (contents of public/)
```
`public_html/index.php` is patched to load:
```php
require FCPATH . '../alrahma/app/Config/Paths.php';
```
Existing `public_html/uploads`, `cgi-bin`, and `.well-known` are left in place.
## Gitea repository secrets
Required for SSH deploy:
- `DEPLOY_HOST` - SSH hostname from the Hostinger panel
- `DEPLOY_USER` - SSH username (for example `u280815660`)
- `DEPLOY_SSH_KEY` - private key whose public half is in Hostinger SSH Access
- `DEPLOY_APP_PATH` - application directory, for example `/home/u280815660/domains/home.alrahmaisgl.org/alrahma`
- `DEPLOY_PUBLIC_PATH` - document root, for example `/home/u280815660/domains/home.alrahmaisgl.org/public_html`
Optional:
- `DEPLOY_PORT` - defaults to `65002` (Hostinger SSH). Use `22` only if the host uses the standard port.
- `DEPLOY_PHP_BIN` - defaults to `/opt/alt/php85/usr/bin/php`
- `DEPLOY_INDEX_REQUIRE` - defaults to `../alrahma/app/Config/Paths.php`
- `DEPLOY_PATH` - fallback for `DEPLOY_APP_PATH` if the newer secret is not set
Create `.env` once on the server under `DEPLOY_APP_PATH`. Do not put database or mail passwords in the workflow.
## Gitea Package
The deploy workflow also archives `build/deploy/` and publishes it to Gitea Packages as a generic package:
The deploy workflow also archives `build/deploy/` and publishes it to Gitea Packages when package credentials are present:
- Package name: `alrahma_sunday_school`
- Version format: `{branch}-{run_number}-{short_sha}`
- File format: `alrahma_sunday_school-{version}.tar.gz`
Required repository secrets for package publishing:
Optional package secrets:
- `GITEAPACKAGEUSER`
- `GITEAPACKAGETOKEN`
Optional repository secrets:
- `GITEA_BASE_URL` - defaults to `https://192.168.3.80`.
- `GITEA_PACKAGE_OWNER` - defaults to `melabidi`.
- `GITEA_BASE_URL` - defaults to `https://192.168.3.80`
- `GITEA_PACKAGE_OWNER` - defaults to `melabidi`
The token must have permission to publish packages for the package owner.
@@ -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>
+387
View File
@@ -0,0 +1,387 @@
# Enrollment Eligibility and Admin Exception Plan
## 1. Goal
Tighten re-enrollment eligibility for existing Al-Rahma Sunday School students without changing the separate new-student registration flow. Parents should only be able to submit re-enrollment for students already linked to their account and already transitioned or eligible for transition into the selected school year.
This plan matches the current project enrollment architecture:
- CodeIgniter 4 controllers and views under `app/Controllers/View` and `app/Views`.
- School years are referenced by `school_years.name` strings such as `2026-2027`, not by target-year IDs.
- Parent re-enrollment currently uses `/parent/enroll_classes_handler` and `app/Views/parent/enroll_classes.php`.
- New-student registration remains `/parent/register_student` and is out of scope except that re-enrollment must not create new students.
- Eligibility and placement already live mainly in `App\Services\EnrollmentTransitionService`, `App\Support\Enrollment\DeliberationDecision`, and `App\Support\Enrollment\EnrollmentEligibility`.
- Existing operational tables include `enrollments`, `student_class`, `student_decisions`, `school_years`, `enrollment_flags`, `enrollment_age_rules`, `enrollment_transition_audits`, and `invoices`.
## 2. Existing Rules To Preserve
Do not replace the current transition model with a parallel eligibility service. Extend the existing service and return shape.
Current enrollment decisions are based on `student_decisions.decision` or `student_decisions.deliberation_decision_standard`, normalized by `DeliberationDecision`:
| Decision | Current behavior |
|---|---|
| `PASSED` | Parent re-enrollment is allowed when the student can be placed in the next grade. If no next grade exists, the student is blocked with an exit/completion flag. |
| `REPEAT_CLASS` | Parent re-enrollment is allowed into the same class when available; otherwise administration must assign the class. |
| `MAKE_UP_EXAM` | Parent re-enrollment is allowed, but placement is temporary in the same grade until the make-up exam result is resolved. |
| `EXPELLED` | Parent re-enrollment is blocked and administrative review is flagged. |
| `WITHDRAWN` | Parent re-enrollment is blocked and withdrawal review is flagged. Historical misspellings such as `widthraw`, `widthdraw`, and `widthrwan` are already normalized to `WITHDRAWN`. |
| `DEFERRED_DECISION` or pending/missing decision | Parent re-enrollment is blocked until administration records or reviews the decision. |
Also preserve these existing controls:
- Registration window checks from `school_years.registration_opens_at` / `registration_starts_on` and `registration_deadline_at` / `registration_ends_on`.
- Late-registration behavior from `school_years.late_registration_blocked`.
- Adult-student blocking for parent-submitted registration when the student is 18 or older on September 1 of the target school year.
- Age-rule checks from `enrollment_age_rules`.
- Placement statuses such as `automatic_distribution_pending`, `same_class_assigned`, `temporary_same_grade`, `manual_class_required`, `temporary_manual_class_required`, and `exit_required`.
- Follow-up flags in `enrollment_flags`.
- Audit history in `enrollment_transition_audits`.
## 3. New Eligibility Requirements
Add these policy checks to the existing transition evaluation and parent submission path.
### 3.1 Non-overridable checks
These must pass for every parent-submitted enrollment, including an administrative exception:
1. Parent session is authenticated and belongs to an active primary parent account.
2. Student row exists in `students`.
3. Student is linked to the parent through the current project link model. Today the direct link is `students.parent_id`; if `family_students` / `family_guardians` are adopted for this flow, centralize that lookup and use it everywhere.
4. Target school year name exists in `school_years.name`.
5. Source school year can be resolved by the existing previous-year helpers.
6. Student belongs to the source year through `student_class`, `enrollments`, or `student_decisions`.
7. Student does not already have an active target-year enrollment that should block duplicate submission.
8. Parent request values are reloaded from the database. Browser-provided decision, eligibility, class, or exception values are ignored.
9. Parent re-enrollment never creates, attaches, or relinks a student. Keep that work in the existing parent/admin student-management flow.
### 3.2 Standard eligibility checks
After the non-overridable checks pass, standard parent eligibility is:
```text
academic_ok =
deliberation_decision in (PASSED, REPEAT_CLASS, MAKE_UP_EXAM)
AND placement_status is not exit_required
status_ok =
deliberation_decision not in (EXPELLED, WITHDRAWN, DEFERRED_DECISION)
AND latest relevant enrollment status is not denied or withdrawn
window_ok =
registration window is open for parent submissions
age_ok =
student is not an adult student for parent-submitted enrollment
AND configured blocking age rules pass
family_name_ok =
all active linked siblings for the parent have the same normalized last name
financial_ok =
previous-year balance policy does not block submission
standard_parent_enrollment_allowed =
academic_ok AND status_ok AND window_ok AND age_ok AND family_name_ok AND financial_ok
```
Missing or unrecognized academic data should remain blocking/review-required as the current service does today. Make-up exam is not a blocker; it is eligible with a warning and a `PENDING_MAKE_UP_EXAM_PROMOTION` follow-up.
## 4. Sibling Last-Name Rule
Implement sibling last-name matching as a family-level rule in the existing enrollment evaluation.
Required behavior:
- Evaluate all active students linked to the parent for the target re-enrollment flow.
- A parent with one linked student passes this rule.
- If every linked student has the same normalized last name, set the rule result to pass for every student.
- If any linked student has a different, missing, or unusable last name, block every linked student for that parent with the same machine-readable reason.
- Normalize by trimming, collapsing spaces, comparing case-insensitively, and ignoring common punctuation variants. Preserve original names for display and audit.
- Do not hide this rule in SQL-only filtering. Return it as a visible reason code such as `SIBLING_LAST_NAME_MISMATCH`.
This rule should be overrideable by administration, but only after the linked-student relationship is already valid.
## 5. Financial Rule
Use the existing invoice source, not a new finance ledger abstraction unless finance is refactored separately.
Current project behavior:
- `ParentController::familyFinancialSummary()` computes carry-over balance with `invoices.balance` for the previous school year.
- Blocking behavior is configured by `school_years.carry_over_balance_behavior`.
- Existing supported policy values include `information_only`, `payment_plan_required`, `submission_allowed_confirmation_blocked`, `submission_blocked_until_payment`, and `admin_approval_required`.
Required behavior:
- Recalculate the parent balance from `invoices` when the enrollment page is shown and again inside the submit handler.
- Treat a previous-year balance as blocking only when `carry_over_balance_behavior` is `submission_blocked_until_payment` or `admin_approval_required`.
- Keep `payment_plan_required` and `submission_allowed_confirmation_blocked` non-blocking for submission unless school policy changes those meanings.
- Record the balance, source school year, target school year, and policy behavior in the eligibility/audit snapshot.
## 6. Decision Output
Extend the current `EnrollmentTransitionService::evaluate()` response instead of introducing a separate response contract.
Add stable fields like:
```php
[
'decision' => 'ELIGIBLE',
'parent_enrollment_allowed' => true,
'rule_codes' => [],
'blocking_rule_codes' => [],
'warning_rule_codes' => [],
'review_rule_codes' => [],
'admin_exception' => null,
'financial_summary' => [],
]
```
Recommended decision values:
| Decision | Meaning |
|---|---|
| `ELIGIBLE` | Parent may submit re-enrollment. |
| `ELIGIBLE_WITH_WARNING` | Parent may submit, but the student needs a follow-up such as make-up exam promotion or manual placement. |
| `INELIGIBLE` | Confirmed blocker exists. |
| `REVIEW_REQUIRED` | Staff must correct or verify data. |
| `EXCEPTION_ELIGIBLE` | Standard rules fail, but a valid admin exception permits submission. |
| `ALREADY_ENROLLED` | Existing target-year enrollment blocks duplicate submission. |
Suggested rule codes:
- `PASSED`, `REPEAT_CLASS`, `MAKE_UP_EXAM`
- `NO_FINAL_DECISION`, `UNRECOGNIZED_DECISION`
- `EXPELLED`, `WITHDRAWN`, `DEFERRED_DECISION`
- `EXIT_REQUIRED`
- `REGISTRATION_NOT_OPEN`, `REGISTRATION_CLOSED`
- `ADULT_STUDENT_PARENT_BLOCKED`
- `AGE_RULE_BLOCKED`
- `SIBLING_LAST_NAME_MISMATCH`
- `OUTSTANDING_BALANCE_BLOCKED`, `FINANCE_APPROVAL_REQUIRED`
- `STUDENT_NOT_LINKED`, `SOURCE_YEAR_NOT_FOUND`, `TARGET_YEAR_NOT_FOUND`
- `ALREADY_ENROLLED`
Keep human-readable parent messages in `EnrollmentEligibility` or a nearby presenter helper so controllers and views do not hard-code policy text.
## 7. Admin Exception Design
Use the project's existing terminology: an exception is an administrative allowance for a specific enrollment case. Avoid a broad `parent.can_enroll = true` flag.
### 7.1 Table
Add an `enrollment_exceptions` table or explicitly rename the concept if the team prefers `enrollment_overrides`. The table should fit current school-year string usage:
| Field | Purpose |
|---|---|
| `id` | Primary key |
| `parent_id` | Parent receiving the exception |
| `student_id` | Existing linked student covered by the exception |
| `school_year` | Target year name |
| `source_school_year` | Source year name |
| `status` | `active`, `used`, `revoked`, or `expired` |
| `reason_code` | Structured exception category |
| `reason_note` | Required admin explanation |
| `bypassed_rule_codes_json` | Snapshot of standard rules bypassed |
| `created_by` | Admin user ID |
| `approved_by` | Optional second approver |
| `starts_at` / `expires_at` | Bounded validity period |
| `used_at` / `enrollment_id` | Consumption tracking |
| `revoked_at` / `revoked_by` / `revocation_reason` | Revocation tracking |
| `created_at` / `updated_at` | Record history |
Add an index or unique constraint that prevents more than one active exception for the same `parent_id`, `student_id`, and `school_year`.
### 7.2 What an exception may bypass
An exception may bypass only standard eligibility rules:
- `NO_FINAL_DECISION` or `UNRECOGNIZED_DECISION`
- `EXPELLED`, `WITHDRAWN`, or `DEFERRED_DECISION`, if school policy permits the selected reason code
- `AGE_RULE_BLOCKED`, when the configured age rule permits exceptions
- `SIBLING_LAST_NAME_MISMATCH`
- `OUTSTANDING_BALANCE_BLOCKED` or `FINANCE_APPROVAL_REQUIRED`
- Late registration closure, only if `school_years.administrative_exceptions_permitted` allows it
- Manual placement or capacity exception follow-ups
An exception must not bypass identity, parent-student linkage, target-year existence, source-year existence, duplicate enrollment protection, CSRF/server validation, or the rule that parent re-enrollment cannot create new students.
### 7.3 Permissions and UI
Use a narrow permission, for example `enrollment.exception.manage`, and add it through the existing roles/permissions system. Do not give it to every admin role by default.
Admin UI should be added to the existing Enrollment Administration dashboard or parent/family card flow:
1. Choose school year.
2. Choose parent.
3. View linked students and live evaluation results.
4. Select one or more students.
5. Review failed rule codes and messages.
6. Enter reason code, note, and expiry.
7. Require second approval for high-risk cases if policy requires it.
8. Save, revoke, and view history.
Do not add exception controls to the parent UI. Parents should only see that enrollment is allowed, blocked, under review, or requires contacting administration.
## 8. Backend Integration Points
Implement later work in these places:
- `App\Services\EnrollmentTransitionService`
- Add family-level linked-student evaluation.
- Add sibling last-name rule.
- Add financial blocking rule or accept a finance collaborator if the team wants to keep the service narrower.
- Add exception lookup/application.
- Return stable rule codes.
- `App\Support\Enrollment\EnrollmentEligibility`
- Keep parent-safe message generation here or split into a small presenter class.
- `App\Controllers\View\ParentController`
- Keep `/parent/register_student` as new-student registration.
- In the re-enrollment page and `/parent/enroll_classes_handler`, re-evaluate from database state before accepting submitted student IDs.
- Ignore disabled checkbox assumptions from the browser.
- `App\Controllers\View\EnrollmentAdminController`
- Add exception preview/create/revoke/history beside existing flags, follow-ups, audits, launch approval, and email preview.
- `App\Controllers\View\AdministratorController`
- Keep Enrollment/Withdrawal status management, but do not let status changes serve as an unaudited substitute for exceptions.
- Migrations/models
- Add an exception table/model.
- Add any missing unique/index protection for target-year duplicate enrollments where feasible with the current schema.
- Keep audit writes compatible with `enrollment_transition_audits`.
## 9. Submission Safety
Inside the parent re-enrollment submit handler:
1. Start a database transaction.
2. Resolve parent ID from session.
3. Resolve selected target school year and source school year.
4. Load submitted student IDs, then reload each student from the database.
5. Verify parent-student linkage.
6. Re-run `EnrollmentTransitionService::evaluate()` for each submitted student.
7. Recalculate financial blockers for the parent.
8. Validate any active exception by `parent_id`, `student_id`, and `school_year`.
9. Reject students whose final decision is not eligible or exception-eligible.
10. Create or update the target-year `enrollments` row with the current eligibility snapshot.
11. Mark one-use exceptions as used.
12. Write `enrollment_transition_audits` entries for accepted, rejected, and exception-applied outcomes.
13. Commit.
Add database protection against duplicate target-year enrollments. If historical duplicates prevent a strict unique index immediately, first add a cleanup/reporting step and then add the constraint.
## 10. Parent Experience
In `app/Views/parent/enroll_classes.php`, show existing linked students only.
Recommended states:
| State | Meaning |
|---|---|
| Enroll | Eligible or exception-eligible. |
| Eligible with follow-up | Eligible, but requires make-up exam, manual placement, capacity review, or similar flag. |
| Already submitted | Existing target-year enrollment is in review, payment pending, enrolled, waitlist, or another non-duplicate state. |
| Action needed | Financial or data correction is required. |
| Under review | Staff must resolve academic/status/placement data. |
| Contact administration | Parent cannot resolve the blocker in self-service. |
Do not expose internal exception notes or sensitive finance details. Do not show internal labels such as expelled unless school policy explicitly approves the wording; use parent-safe messages already present in `EnrollmentEligibility`.
## 11. Audit and Reporting
Continue using `enrollment_transition_audits` for enrollment decisions. Extend the payloads rather than creating a disconnected audit stream.
Audit records should include:
- actor user ID
- parent ID when available
- student ID
- source and target school-year names
- decision and rule-code snapshot
- financial snapshot used for the decision
- exception ID when applied
- original and new enrollment values
- request timestamp
Reports needed:
- active exceptions by school year
- expiring unused exceptions
- enrollments submitted through exception
- blockers by rule code
- repeated parent submission failures
- financial blockers pending finance/admin approval
## 12. Test Matrix
Add focused tests around the current classes and controller submission behavior:
| Case | Expected result |
|---|---|
| `PASSED` with clean status and placement | Eligible |
| `REPEAT_CLASS` with same target section | Eligible, same class assigned |
| `REPEAT_CLASS` without target section | Eligible with manual-class follow-up |
| `MAKE_UP_EXAM` | Eligible with warning and make-up follow-up |
| `EXPELLED` | Blocked/review-required |
| `WITHDRAWN`, including historical misspellings | Blocked/review-required |
| `DEFERRED_DECISION` | Blocked/review-required |
| Pending or missing final decision | Blocked/review-required |
| Passed highest grade with no next class | Blocked with exit/completion follow-up |
| Registration not open | Blocked for parent |
| Registration closed and late blocking enabled | Blocked for parent |
| Adult student on September 1 | Parent submission blocked |
| Blocking age rule | Blocked unless valid exception applies |
| One linked sibling last name differs | Every linked sibling blocked |
| Linked sibling has missing last name | Every linked sibling blocked |
| One linked student only | Last-name rule passes |
| Previous-year balance with information-only policy | Not blocked |
| Previous-year balance with submission-blocked policy | Blocked |
| Previous-year balance with admin-approval policy | Blocked unless valid exception applies |
| Submitted student does not belong to parent | Denied even with exception |
| Nonexistent student ID | Denied |
| Expired/revoked/wrong-year/wrong-student exception | Denied |
| Valid scoped exception | Exception eligible and audited |
| Browser tampers with disabled checkbox or decision fields | Server ignores browser state |
| Concurrent duplicate submissions | Only one target-year enrollment is accepted |
Keep existing unit tests for `DeliberationDecision` and `EnrollmentEligibility`, and add service-level tests for `EnrollmentTransitionService` with mocked builders where practical. Add integration tests for the submit path if the test database can reliably seed `students`, `student_class`, `student_decisions`, `school_years`, `enrollments`, `invoices`, and exception rows.
## 13. Rollout Plan
1. Confirm policy decisions: sibling rule, financial behavior, exception authority, parent-facing messages, and high-risk approvals.
2. Inventory data quality: duplicate target-year enrollments, missing decisions, misspelled withdrawal values, missing last names, missing parent links, and open invoice balances.
3. Add rule-code output to `EnrollmentTransitionService` without enforcing new blockers yet.
4. Add read-only reporting for who would be blocked by sibling/finance rules.
5. Add exception schema, model, permissions, audit payloads, and admin UI.
6. Enforce server-side parent submission checks behind a feature flag or school-year setting.
7. Monitor blockers, exceptions, failed submissions, duplicate attempts, and finance approval volume during the first enrollment window.
8. Remove any old duplicated eligibility checks only after the central evaluation is stable.
## 14. Decisions Required Before Coding
These are policy decisions and should not be guessed in code:
1. Does the sibling last-name rule apply to every `students.parent_id` match, or only active/current linked students after `family_students` is fully adopted?
2. What exact statuses count as active linked students for the sibling rule?
3. Which parent-facing message is approved for sibling mismatch?
4. Which financial behavior values should block submission versus only block confirmation?
5. What balance tolerance, if any, is allowed?
6. Can finance/admin approve a balance exception, and does it need second approval above a threshold?
7. Can expulsion, withdrawal, deferred decision, or adult-student rules be overridden, and by whom?
8. How long can an exception remain active, and is it single-use?
9. Should duplicate enrollment protection block every existing target-year row, or only statuses such as `admission under review`, `review & decision`, `payment pending`, `enrolled`, and `waitlist`?
## 15. Definition of Done
- Parent re-enrollment uses one authoritative server-side evaluation path.
- Parent submission can enroll only existing linked students.
- New-student registration remains separate and unaffected.
- Academic, placement, registration-window, adult-student, age-rule, sibling-name, financial, and exception checks are all represented as stable rule codes.
- Make-up exam remains eligible with a temporary placement follow-up.
- Sibling last-name mismatch blocks every linked student unless a valid scoped exception applies.
- Financial blocking follows `school_years.carry_over_balance_behavior` and current invoice balances.
- Administrative exceptions are scoped by parent, student, and school-year name.
- Exceptions cannot bypass identity, linkage, school-year validity, source-year validity, or duplicate protection.
- Every accepted, blocked, and exception-applied submission is auditable.
- Duplicate target-year submissions are prevented.
- The test matrix above passes.
-4
View File
@@ -53,8 +53,4 @@ $paths = new Config\Paths();
// LOAD THE FRAMEWORK BOOTSTRAP FILE
require $paths->systemDirectory . '/Boot.php';
if (! defined('ENVIRONMENT')) {
define('ENVIRONMENT', isset($_SERVER['CI_ENV']) ? $_SERVER['CI_ENV'] : 'production');
}
exit(CodeIgniter\Boot::bootWeb($paths));
@@ -3,9 +3,9 @@
namespace Tests\App\Libraries;
use App\Libraries\Tuition\NewTuitionCalculatorService;
use CodeIgniter\Test\CIUnitTestCase;
use PHPUnit\Framework\TestCase;
class NewTuitionCalculatorServiceTest extends CIUnitTestCase
class NewTuitionCalculatorServiceTest extends TestCase
{
public function testFamilyDiscountsApplyByStudentPosition(): void
{
@@ -17,17 +17,17 @@ class NewTuitionCalculatorServiceTest extends CIUnitTestCase
['student_id' => 2, 'student_name' => 'Second', 'grade_level' => '2'],
], [
'grade_fee' => 9,
'new_tuition_full_amount' => '350.00',
'new_tuition_second_student_discount' => '50.00',
'new_tuition_third_student_discount' => '50.00',
'new_tuition_fourth_plus_discount' => '100.00',
'new_tuition_full_amount' => '380.00',
'new_tuition_second_student_discount' => '100.00',
]);
$this->assertSame('1200.00', $result['total']);
$this->assertSame('350.00', $result['details'][0]['amount']);
$this->assertSame('300.00', $result['details'][1]['amount']);
$this->assertSame('300.00', $result['details'][2]['amount']);
$this->assertSame('250.00', $result['details'][3]['amount']);
$this->assertSame('1220.00', $result['total']);
$this->assertSame('380.00', $result['details'][0]['amount']);
$this->assertSame('280.00', $result['details'][1]['amount']);
$this->assertSame('280.00', $result['details'][2]['amount']);
$this->assertSame('280.00', $result['details'][3]['amount']);
$this->assertSame('new_first_student_full_amount', $result['details'][0]['rule']);
$this->assertSame('new_additional_student_discount', $result['details'][1]['rule']);
}
public function testDiscountCannotDriveAmountBelowZero(): void
@@ -37,22 +37,19 @@ class NewTuitionCalculatorServiceTest extends CIUnitTestCase
['student_id' => 1, 'student_name' => 'One', 'grade_level' => '1'],
['student_id' => 2, 'student_name' => 'Two', 'grade_level' => '2'],
['student_id' => 3, 'student_name' => 'Three', 'grade_level' => '3'],
['student_id' => 4, 'student_name' => 'Four', 'grade_level' => '4'],
['student_id' => 5, 'student_name' => 'Five', 'grade_level' => '5'],
], [
'grade_fee' => 9,
'new_tuition_full_amount' => '75.00',
'new_tuition_second_student_discount' => '50.00',
'new_tuition_third_student_discount' => '50.00',
'new_tuition_fourth_plus_discount' => '100.00',
'new_tuition_second_student_discount' => '100.00',
]);
$this->assertSame('125.00', $result['total']);
$this->assertSame('0.00', $result['details'][3]['amount']);
$this->assertSame('0.00', $result['details'][4]['amount']);
$this->assertSame('75.00', $result['total']);
$this->assertSame('75.00', $result['details'][0]['amount']);
$this->assertSame('0.00', $result['details'][1]['amount']);
$this->assertSame('0.00', $result['details'][2]['amount']);
}
public function testYouthStudentsUseSeparateUnitPriceWithoutAffectingRegularDiscountOrder(): void
public function testYouthStudentsUseTheSameFamilyTuitionRule(): void
{
$service = new NewTuitionCalculatorService();
$result = $service->calculateFamilyTuition([
@@ -61,19 +58,31 @@ class NewTuitionCalculatorServiceTest extends CIUnitTestCase
['student_id' => 2, 'student_name' => 'Regular Two', 'grade_level' => '5'],
], [
'grade_fee' => 9,
'new_tuition_full_amount' => '370.00',
'new_tuition_youth_amount' => '200.00',
'new_tuition_second_student_discount' => '150.00',
'new_tuition_third_student_discount' => '150.00',
'new_tuition_fourth_plus_discount' => '150.00',
'new_tuition_full_amount' => '380.00',
'new_tuition_second_student_discount' => '100.00',
]);
$this->assertSame('790.00', $result['total']);
$this->assertSame('new_first_student_full_amount', $result['details'][0]['rule']);
$this->assertSame('370.00', $result['details'][0]['amount']);
$this->assertSame('new_second_student_discount', $result['details'][1]['rule']);
$this->assertSame('220.00', $result['details'][1]['amount']);
$this->assertSame('new_youth_unit_price', $result['details'][2]['rule']);
$this->assertSame('200.00', $result['details'][2]['amount']);
$this->assertSame('940.00', $result['total']);
$this->assertSame('380.00', $result['details'][0]['amount']);
$this->assertSame('280.00', $result['details'][1]['amount']);
$this->assertSame('280.00', $result['details'][2]['amount']);
$this->assertSame('new_additional_student_discount', $result['details'][2]['rule']);
}
public function testGradeStudentAndYouthStudentAreTwoFamilyStudents(): void
{
$service = new NewTuitionCalculatorService();
$result = $service->calculateFamilyTuition([
['student_id' => 1, 'student_name' => 'Grade Student', 'grade_level' => '4'],
['student_id' => 2, 'student_name' => 'Youth Student', 'grade_level' => 'Youth 1'],
], [
'grade_fee' => 9,
'new_tuition_full_amount' => '380.00',
'new_tuition_second_student_discount' => '100.00',
]);
$this->assertSame('660.00', $result['total']);
$this->assertSame('380.00', $result['details'][0]['amount']);
$this->assertSame('280.00', $result['details'][1]['amount']);
}
}
@@ -3,11 +3,11 @@
namespace Tests\App\Libraries;
use App\Libraries\Tuition\OldTuitionCalculatorService;
use CodeIgniter\Test\CIUnitTestCase;
use PHPUnit\Framework\TestCase;
class OldTuitionCalculatorServiceTest extends CIUnitTestCase
class OldTuitionCalculatorServiceTest extends TestCase
{
public function testRegularAndYouthStudentsUseLegacyRules(): void
public function testAllStudentsUseTheSameFirstAndAdditionalFeeRule(): void
{
$service = new OldTuitionCalculatorService();
$result = $service->calculateFamilyTuition([
@@ -16,14 +16,16 @@ class OldTuitionCalculatorServiceTest extends CIUnitTestCase
['student_id' => 2, 'student_name' => 'Second Regular', 'grade_level' => '5'],
], [
'grade_fee' => 9,
'first_student_fee' => '350.00',
'second_student_fee' => '200.00',
'youth_fee' => '180.00',
'first_student_fee' => '380.00',
'second_student_fee' => '280.00',
]);
$this->assertSame('730.00', $result['total']);
$this->assertSame('940.00', $result['total']);
$this->assertSame('old_first_student_fee', $result['details'][0]['rule']);
$this->assertSame('old_second_student_fee', $result['details'][1]['rule']);
$this->assertSame('old_youth_fee', $result['details'][2]['rule']);
$this->assertSame('380.00', $result['details'][0]['amount']);
$this->assertSame('old_additional_student_fee', $result['details'][1]['rule']);
$this->assertSame('280.00', $result['details'][1]['amount']);
$this->assertSame('old_additional_student_fee', $result['details'][2]['rule']);
$this->assertSame('280.00', $result['details'][2]['amount']);
}
}
@@ -56,12 +56,9 @@ class TuitionForecastServiceTest extends CIUnitTestCase
'grade_fee' => 9,
'first_student_fee' => '350.00',
'second_student_fee' => '200.00',
'youth_fee' => '200.00',
'youth_fee' => '350.00',
'new_tuition_full_amount' => $this->unitPriceOverride ?? '350.00',
'new_tuition_youth_amount' => $this->youthUnitPriceOverride ?? '200.00',
'new_tuition_second_student_discount' => '50.00',
'new_tuition_third_student_discount' => '50.00',
'new_tuition_fourth_plus_discount' => '100.00',
'new_tuition_second_student_discount' => '100.00',
];
}
};
@@ -70,18 +67,17 @@ class TuitionForecastServiceTest extends CIUnitTestCase
'include_payment_pending' => true,
'include_withdrawn_mode' => 'refund_deadline',
'unit_price' => '400.00',
'youth_unit_price' => '200.00',
]);
$this->assertSame(1, $result['summary']['family_count']);
$this->assertSame(4, $result['summary']['student_count']);
$this->assertSame(3, $result['summary']['billable_student_count']);
$this->assertSame('750.00', $result['summary']['old_projected_tuition']);
$this->assertSame('950.00', $result['summary']['new_projected_tuition']);
$this->assertSame('1000.00', $result['summary']['new_projected_tuition']);
$this->assertSame('750.00', $result['summary']['old_projected_income']);
$this->assertSame('950.00', $result['summary']['new_projected_income']);
$this->assertSame('1000.00', $result['summary']['new_projected_income']);
$this->assertSame('400.00', $result['summary']['unit_price']);
$this->assertSame('200.00', $result['summary']['youth_unit_price']);
$this->assertSame('400.00', $result['summary']['youth_unit_price']);
$this->assertSame('event_only', $result['families'][0]['student_details'][3]['excluded_reason']);
}
}
@@ -75,6 +75,7 @@ final class EnrollmentTransitionServiceTest extends TestCase
private function builderReturning(?array $row): BaseBuilder
{
$builder = $this->createMock(BaseBuilder::class);
$builder->method('select')->willReturnSelf();
$builder->method('orderBy')->willReturnSelf();
$builder->method('limit')->willReturnSelf();
$builder->method('get')->willReturn(new class($row) {
@@ -91,6 +92,306 @@ final class EnrollmentTransitionServiceTest extends TestCase
return $builder;
}
public function testNormalizeLastNameIgnoresPunctuationAndCase(): void
{
$service = new EnrollmentTransitionService($this->createMock(BaseConnection::class));
$this->assertSame('omalley', $this->invoke($service, 'normalizeLastName', ["O'Malley"]));
$this->assertSame('omalley', $this->invoke($service, 'normalizeLastName', ['O-Malley']));
}
public function testSingleLinkedStudentPassesLastNameRule(): void
{
$service = $this->serviceWithStudentRows([
['id' => 1, 'firstname' => 'Amina', 'lastname' => 'Hassan', 'parent_id' => 9],
]);
$evaluation = [];
$this->invoke($service, 'applyHouseholdLastNameRule', [&$evaluation, 9]);
$this->assertTrue($evaluation['family_name_check_ok']);
$this->assertArrayNotHasKey('blocking_rule_codes', $evaluation);
}
public function testMismatchedSiblingLastNamesBlockEveryStudent(): void
{
$service = $this->serviceWithStudentRows([
['id' => 1, 'firstname' => 'Amina', 'lastname' => 'Hassan', 'parent_id' => 9],
['id' => 2, 'firstname' => 'Omar', 'lastname' => 'Ali', 'parent_id' => 9],
]);
$evaluation = [];
$this->invoke($service, 'applyHouseholdLastNameRule', [&$evaluation, 9]);
$this->assertFalse($evaluation['family_name_check_ok']);
$this->assertContains('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes']);
}
public function testMissingSiblingLastNameBlocksFamily(): void
{
$service = $this->serviceWithStudentRows([
['id' => 1, 'firstname' => 'Amina', 'lastname' => 'Hassan', 'parent_id' => 9],
['id' => 2, 'firstname' => 'Omar', 'lastname' => ' ', 'parent_id' => 9],
]);
$evaluation = [];
$this->invoke($service, 'applyHouseholdLastNameRule', [&$evaluation, 9]);
$this->assertContains('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes']);
}
public function testPreviousYearBalanceBlocksEnrollment(): void
{
$service = $this->serviceWithFinance(125.50, 'submission_blocked_until_payment');
$evaluation = [];
$this->invoke($service, 'applyFinancialRule', [&$evaluation, 9, '2025-2026', '2026-2027']);
$this->assertContains('OUTSTANDING_BALANCE_BLOCKED', $evaluation['blocking_rule_codes']);
$this->assertSame(125.50, $evaluation['financial_summary']['carry_over_balance']);
}
public function testPreviousYearBalanceWithAdminApprovalPolicyUsesFinanceReviewCode(): void
{
$service = $this->serviceWithFinance(40.00, 'admin_approval_required');
$evaluation = [];
$this->invoke($service, 'applyFinancialRule', [&$evaluation, 9, '2025-2026', '2026-2027']);
$this->assertContains('FINANCE_APPROVAL_REQUIRED', $evaluation['blocking_rule_codes']);
}
public function testZeroCarryOverBalanceDoesNotBlock(): void
{
$service = $this->serviceWithFinance(0.00, 'submission_blocked_until_payment');
$evaluation = [];
$this->invoke($service, 'applyFinancialRule', [&$evaluation, 9, '2025-2026', '2026-2027']);
$this->assertArrayNotHasKey('blocking_rule_codes', $evaluation);
}
public function testActiveEnrollmentStatusesBlockDuplicates(): void
{
$service = new EnrollmentTransitionService($this->createMock(BaseConnection::class));
$this->assertTrue($this->invoke($service, 'activeEnrollmentBlocksDuplicate', [[
'is_withdrawn' => 0,
'enrollment_status' => 'payment pending',
'admission_status' => 'pending',
]]));
$this->assertFalse($this->invoke($service, 'activeEnrollmentBlocksDuplicate', [[
'is_withdrawn' => 1,
'enrollment_status' => 'withdrawn',
'admission_status' => 'accepted',
]]));
}
/**
* @param list<array<string, mixed>> $rows
*/
private function serviceWithStudentRows(array $rows): EnrollmentTransitionService
{
$builder = $this->createMock(BaseBuilder::class);
$builder->method('select')->willReturnSelf();
$builder->method('where')->willReturnSelf();
$builder->method('orderBy')->willReturnSelf();
$builder->method('get')->willReturn(new class($rows) {
public function __construct(private readonly array $rows)
{
}
public function getResultArray(): array
{
return $this->rows;
}
});
$db = $this->createMock(BaseConnection::class);
$db->method('tableExists')->with('students')->willReturn(true);
$db->method('table')->with('students')->willReturn($builder);
return new EnrollmentTransitionService($db);
}
private function serviceWithFinance(float $balance, string $behavior): EnrollmentTransitionService
{
$yearBuilder = $this->builderReturning([
'name' => '2026-2027',
'carry_over_balance_behavior' => $behavior,
'registration_fee' => 0,
'tuition_due_at_registration' => 0,
'mandatory_fees' => 0,
]);
$yearBuilder->method('where')->willReturnSelf();
$invoiceBuilder = $this->builderReturning(['balance' => $balance]);
$invoiceBuilder->method('where')->willReturnSelf();
$db = $this->createMock(BaseConnection::class);
$db->method('tableExists')->willReturn(true);
$db->method('table')->willReturnCallback(static function (string $table) use ($yearBuilder, $invoiceBuilder) {
return $table === 'school_years' ? $yearBuilder : $invoiceBuilder;
});
return new EnrollmentTransitionService($db);
}
public function testPreviousLastNameExceptionCarriesForwardWhenHouseholdUnchangedAndBalancePaid(): void
{
$service = $this->serviceWithLastNameCarryForward(
[
['id' => 1, 'firstname' => 'Amina', 'lastname' => 'Hassan', 'parent_id' => 9],
['id' => 2, 'firstname' => 'Omar', 'lastname' => 'Ali', 'parent_id' => 9],
],
[[
'id' => 44,
'parent_id' => 9,
'student_id' => 1,
'school_year' => '2025-2026',
'status' => 'used',
'reason_code' => 'SIBLING_LAST_NAME_REVIEWED',
'bypassed_rule_codes_json' => json_encode(['SIBLING_LAST_NAME_MISMATCH']),
'family_student_ids_json' => json_encode([1, 2]),
]]
);
$evaluation = [
'blocking_rule_codes' => ['SIBLING_LAST_NAME_MISMATCH'],
'blockers' => ['Linked siblings have different last names. Please contact administration to review the family record.'],
'warnings' => [],
];
$this->invoke($service, 'applyCarriedForwardLastNameException', [&$evaluation, 9, '2025-2026', '2026-2027']);
$this->assertNotContains('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes']);
$this->assertContains('LAST_NAME_EXCEPTION_CARRIED_FORWARD', $evaluation['warning_rule_codes']);
$this->assertTrue($evaluation['last_name_exception_carry_forward']['eligible']);
}
public function testEmptyBypassCodesDoNotGrantException(): void
{
$builder = $this->createMock(BaseBuilder::class);
$builder->method('where')->willReturnSelf();
$builder->method('groupStart')->willReturnSelf();
$builder->method('orWhere')->willReturnSelf();
$builder->method('groupEnd')->willReturnSelf();
$builder->method('orderBy')->willReturnSelf();
$builder->method('limit')->willReturnSelf();
$builder->method('get')->willReturn(new class {
public function getRowArray(): array
{
return [
'id' => 7,
'reason_code' => 'FINANCIAL_REVIEW_REQUIRED',
'bypassed_rule_codes_json' => '[]',
];
}
});
$db = $this->createMock(BaseConnection::class);
$db->method('tableExists')->with('enrollment_exceptions')->willReturn(true);
$db->method('table')->with('enrollment_exceptions')->willReturn($builder);
$service = new EnrollmentTransitionService($db);
$evaluation = [
'blocking_rule_codes' => ['OUTSTANDING_BALANCE_BLOCKED'],
'review_rule_codes' => [],
];
$this->invoke($service, 'applyScopedException', [&$evaluation, 9, 1, '2025-2026', '2026-2027']);
$this->assertArrayNotHasKey('admin_exception', $evaluation);
$this->assertArrayNotHasKey('parent_enrollment_allowed', $evaluation);
}
public function testPreviousLastNameExceptionDoesNotCarryForwardWhenNewStudentIsAdded(): void
{
$service = $this->serviceWithLastNameCarryForward(
[
['id' => 1, 'firstname' => 'Amina', 'lastname' => 'Hassan', 'parent_id' => 9],
['id' => 2, 'firstname' => 'Omar', 'lastname' => 'Ali', 'parent_id' => 9],
['id' => 3, 'firstname' => 'Layla', 'lastname' => 'Hassan', 'parent_id' => 9],
],
[[
'id' => 44,
'parent_id' => 9,
'student_id' => 1,
'school_year' => '2025-2026',
'status' => 'used',
'reason_code' => 'SIBLING_LAST_NAME_REVIEWED',
'bypassed_rule_codes_json' => json_encode(['SIBLING_LAST_NAME_MISMATCH']),
'family_student_ids_json' => json_encode([1, 2]),
]]
);
$evaluation = [
'blocking_rule_codes' => ['SIBLING_LAST_NAME_MISMATCH'],
'blockers' => ['Linked siblings have different last names. Please contact administration to review the family record.'],
];
$this->invoke($service, 'applyCarriedForwardLastNameException', [&$evaluation, 9, '2025-2026', '2026-2027']);
$this->assertContains('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes']);
$this->assertFalse($evaluation['last_name_exception_carry_forward']['eligible']);
$this->assertSame('NEW_STUDENT_ADDED', $evaluation['last_name_exception_carry_forward']['reason']);
}
public function testPreviousLastNameExceptionDoesNotCarryForwardWhenBalanceIsUnpaid(): void
{
$service = new EnrollmentTransitionService($this->createMock(BaseConnection::class));
$evaluation = [
'blocking_rule_codes' => ['SIBLING_LAST_NAME_MISMATCH', 'OUTSTANDING_BALANCE_BLOCKED'],
];
$this->invoke($service, 'applyCarriedForwardLastNameException', [&$evaluation, 9, '2025-2026', '2026-2027']);
$this->assertContains('SIBLING_LAST_NAME_MISMATCH', $evaluation['blocking_rule_codes']);
$this->assertArrayNotHasKey('last_name_exception_carry_forward', $evaluation);
}
/**
* @param list<array<string, mixed>> $students
* @param list<array<string, mixed>> $exceptions
*/
private function serviceWithLastNameCarryForward(array $students, array $exceptions): EnrollmentTransitionService
{
$studentBuilder = $this->createMock(BaseBuilder::class);
$studentBuilder->method('select')->willReturnSelf();
$studentBuilder->method('where')->willReturnSelf();
$studentBuilder->method('orderBy')->willReturnSelf();
$studentBuilder->method('get')->willReturn(new class($students) {
public function __construct(private readonly array $rows)
{
}
public function getResultArray(): array
{
return $this->rows;
}
});
$exceptionBuilder = $this->createMock(BaseBuilder::class);
$exceptionBuilder->method('where')->willReturnSelf();
$exceptionBuilder->method('whereIn')->willReturnSelf();
$exceptionBuilder->method('orderBy')->willReturnSelf();
$exceptionBuilder->method('get')->willReturn(new class($exceptions) {
public function __construct(private readonly array $rows)
{
}
public function getResultArray(): array
{
return $this->rows;
}
});
$db = $this->createMock(BaseConnection::class);
$db->method('tableExists')->willReturn(true);
$db->method('table')->willReturnCallback(static function (string $table) use ($studentBuilder, $exceptionBuilder) {
return $table === 'students' ? $studentBuilder : $exceptionBuilder;
});
return new EnrollmentTransitionService($db);
}
/**
* @param list<mixed> $args
*/
@@ -0,0 +1,46 @@
<?php
namespace Tests\App\Services;
use App\Services\SchoolYearManagementService;
use CodeIgniter\Test\CIUnitTestCase;
use ReflectionClass;
final class SchoolYearManagementServiceCalendarTest extends CIUnitTestCase
{
public function testCalendarDefaultsForNextSchoolYear(): void
{
$calendar = $this->calendarPayload('2026-2027');
$this->assertSame('2026-08-01', $calendar['starts_on']);
$this->assertSame('2027-07-31', $calendar['ends_on']);
$this->assertSame('2026-08-01', $calendar['registration_starts_on']);
$this->assertSame('2026-10-05', $calendar['registration_ends_on']);
$this->assertSame('2026-09-20', $calendar['first_day_of_school']);
$this->assertSame('2026-09-20', $calendar['1st_day_of_school']);
$this->assertSame('2026-09-13', $calendar['fall_makeup_exam_on']);
$this->assertSame('2026-09-06', $calendar['orientation_day']);
$this->assertSame('2027-05-23', $calendar['final_exam_day']);
$this->assertSame('2027-06-06', $calendar['last_day_of_school']);
$this->assertSame('2027-06-06', $calendar['last_school_day']);
$this->assertSame('2027-01-17', $calendar['midterm_exam_day']);
$this->assertSame('2027-01-24', $calendar['spring_semester_start']);
$this->assertSame('2027-03-01', $calendar['installment_date']);
}
public function testNextToLastSeptemberSundayWhenMonthEndsOnSunday(): void
{
$calendar = $this->calendarPayload('2029-2030');
$this->assertSame('2029-09-23', $calendar['first_day_of_school']);
}
private function calendarPayload(string $schoolYear): array
{
$service = (new ReflectionClass(SchoolYearManagementService::class))->newInstanceWithoutConstructor();
$method = (new ReflectionClass(SchoolYearManagementService::class))->getMethod('calendarPayloadForSchoolYear');
$method->setAccessible(true);
return $method->invoke($service, $schoolYear);
}
}