fix enrollment logic, add financial aid, fix class distribution
Tests / PHPUnit (push) Failing after 1m6s
Tests / PHPUnit (push) Failing after 1m6s
This commit is contained in:
+96
-22
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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' => '',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'] ?? []);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+15
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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]);
|
||||
}
|
||||
}
|
||||
+50
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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">← 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';
|
||||
|
||||
@@ -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
@@ -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 & 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 & 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) ?> · 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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 year’s 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 year’s 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 year’s tax return to the school office.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 1–9</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>'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user