fix enrollment, invoice, payment and financila aid
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Support\Enrollment\DeliberationDecision;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
class EnrollmentDataAuditCommand extends BaseCommand
|
||||
{
|
||||
protected $group = 'Registration';
|
||||
protected $name = 'registration:enrollment-data-audit';
|
||||
protected $description = 'Audit withdrawn terminology normalization and duplicate enrollment rows per student/year.';
|
||||
protected $usage = 'php spark registration:enrollment-data-audit [--school-year=2026-2027] [--json]';
|
||||
protected $options = [
|
||||
'--school-year' => 'Optional school year filter for duplicate enrollment detection.',
|
||||
'--json' => 'Print machine-readable JSON.',
|
||||
];
|
||||
|
||||
private BaseConnection $db;
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
$schoolYear = trim((string) (CLI::getOption('school-year') ?? ''));
|
||||
|
||||
$report = [
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'school_year_filter' => $schoolYear !== '' ? $schoolYear : null,
|
||||
'withdrawn_normalization' => $this->auditWithdrawnValues(),
|
||||
'duplicate_enrollments' => $this->auditDuplicateEnrollments($schoolYear),
|
||||
];
|
||||
|
||||
if (CLI::getOption('json') !== null) {
|
||||
CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
CLI::write('Enrollment Data Audit', 'green');
|
||||
CLI::newLine();
|
||||
CLI::write('Withdrawn / misspelling findings: ' . count($report['withdrawn_normalization']), 'yellow');
|
||||
foreach ($report['withdrawn_normalization'] as $row) {
|
||||
CLI::write(sprintf(
|
||||
' [%s] %s.%s = %s',
|
||||
$row['table'],
|
||||
$row['column'],
|
||||
$row['id'] ?? $row['student_id'] ?? '?',
|
||||
$row['value']
|
||||
));
|
||||
}
|
||||
CLI::newLine();
|
||||
CLI::write('Duplicate enrollment groups: ' . count($report['duplicate_enrollments']), 'yellow');
|
||||
foreach ($report['duplicate_enrollments'] as $group) {
|
||||
CLI::write(sprintf(
|
||||
' student=%d year=%s rows=%d ids=[%s]',
|
||||
$group['student_id'],
|
||||
$group['school_year'],
|
||||
$group['row_count'],
|
||||
implode(',', $group['enrollment_ids'])
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function auditWithdrawnValues(): array
|
||||
{
|
||||
$findings = [];
|
||||
$patterns = ['widthrawan', 'widthrwan', 'withdraw'];
|
||||
|
||||
if ($this->db->tableExists('enrollments')) {
|
||||
$rows = $this->db->table('enrollments')
|
||||
->select('id, student_id, school_year, enrollment_status')
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($rows as $row) {
|
||||
$status = strtolower(trim((string) ($row['enrollment_status'] ?? '')));
|
||||
foreach ($patterns as $pattern) {
|
||||
if ($status === $pattern || str_contains($status, $pattern)) {
|
||||
$findings[] = [
|
||||
'table' => 'enrollments',
|
||||
'column' => 'enrollment_status',
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'student_id' => (int) ($row['student_id'] ?? 0),
|
||||
'school_year' => (string) ($row['school_year'] ?? ''),
|
||||
'value' => (string) ($row['enrollment_status'] ?? ''),
|
||||
'recommended' => 'withdrawn',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('student_decisions')) {
|
||||
$rows = $this->db->table('student_decisions')
|
||||
->select('id, student_id, school_year, decision, deliberation_decision_standard')
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($rows as $row) {
|
||||
foreach (['decision', 'deliberation_decision_standard'] as $column) {
|
||||
$raw = (string) ($row[$column] ?? '');
|
||||
$normalized = DeliberationDecision::normalize($raw);
|
||||
if ($raw !== '' && $normalized === DeliberationDecision::WITHDRAWN && strtoupper($raw) !== DeliberationDecision::WITHDRAWN) {
|
||||
$findings[] = [
|
||||
'table' => 'student_decisions',
|
||||
'column' => $column,
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'student_id' => (int) ($row['student_id'] ?? 0),
|
||||
'school_year' => (string) ($row['school_year'] ?? ''),
|
||||
'value' => $raw,
|
||||
'recommended' => DeliberationDecision::WITHDRAWN,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function auditDuplicateEnrollments(string $schoolYear): array
|
||||
{
|
||||
if (! $this->db->tableExists('enrollments')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$builder = $this->db->table('enrollments')
|
||||
->select('student_id, school_year, COUNT(*) AS row_count, GROUP_CONCAT(id ORDER BY updated_at DESC, id DESC) AS enrollment_ids', false)
|
||||
->groupBy('student_id, school_year')
|
||||
->having('row_count >', 1);
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$groups = [];
|
||||
foreach ($builder->get()->getResultArray() as $row) {
|
||||
$ids = array_values(array_filter(array_map('intval', explode(',', (string) ($row['enrollment_ids'] ?? '')))));
|
||||
$groups[] = [
|
||||
'student_id' => (int) ($row['student_id'] ?? 0),
|
||||
'school_year' => (string) ($row['school_year'] ?? ''),
|
||||
'row_count' => (int) ($row['row_count'] ?? 0),
|
||||
'enrollment_ids' => $ids,
|
||||
];
|
||||
}
|
||||
|
||||
return $groups;
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@ $routes->post('administrator/enrollment-admin/exceptions/create', 'View\Enrollme
|
||||
$routes->post('administrator/enrollment-admin/exceptions/(:num)/revoke', 'View\EnrollmentAdminController::revokeException/$1', ['filter' => $enrollmentAdminFilter]);
|
||||
$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)/estimate', 'Administrator\FinancialAidController::estimate/$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']);
|
||||
$withdrawalFinancialFilter = 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal';
|
||||
@@ -876,6 +877,7 @@ $routes->post('/parent/saveSecondParent', 'View\ParentController::saveSecondPare
|
||||
|
||||
$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/enrollment_eligibility_refresh', 'View\ParentController::enrollmentEligibilityRefresh', ['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']);
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
namespace App\Controllers\Administrator;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\FinancialAidEstimateModel;
|
||||
use App\Models\FinancialAidRequestModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\StudentModel;
|
||||
use App\Models\UserModel;
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
class FinancialAidController extends BaseController
|
||||
@@ -49,13 +52,68 @@ class FinancialAidController extends BaseController
|
||||
? (new StudentModel())->whereIn('id', $studentIds)->findAll()
|
||||
: [];
|
||||
|
||||
$schoolYear = (string) ($request['school_year'] ?? '');
|
||||
$invoiceBalance = $this->invoiceBalanceForParent((int) ($request['parent_id'] ?? 0), $schoolYear);
|
||||
$householdSize = max(1, (int) ($request['household_size'] ?? 1));
|
||||
$householdIncome = (float) ($request['household_income'] ?? 0);
|
||||
$requestedAmount = (float) ($request['requested_amount'] ?? 0);
|
||||
$estimateModel = new FinancialAidEstimateModel();
|
||||
$estimatedAid = $estimateModel->estimatedAid($invoiceBalance, $householdSize, $householdIncome);
|
||||
$finalRebate = $estimateModel->finalRebate($invoiceBalance, $householdSize, $householdIncome, $requestedAmount);
|
||||
|
||||
return view('administrator/financial_aid_review', [
|
||||
'requestRow' => $request,
|
||||
'parent' => $parent,
|
||||
'students' => $students,
|
||||
'schoolYear' => $schoolYear,
|
||||
'invoiceBalance' => $invoiceBalance,
|
||||
'estimatedAid' => $estimatedAid,
|
||||
'finalRebate' => $finalRebate,
|
||||
]);
|
||||
}
|
||||
|
||||
public function estimate(int $id)
|
||||
{
|
||||
$request = (new FinancialAidRequestModel())->find($id);
|
||||
if ($request === null) {
|
||||
return $this->response->setStatusCode(404)->setJSON(['error' => 'Financial aid request was not found.']);
|
||||
}
|
||||
|
||||
$householdIncomeRaw = trim((string) $this->request->getPost('household_income'));
|
||||
$householdSizeRaw = (int) $this->request->getPost('household_size');
|
||||
$schoolYear = (string) ($request['school_year'] ?? '');
|
||||
$invoiceBalance = $this->invoiceBalanceForParent((int) ($request['parent_id'] ?? 0), $schoolYear);
|
||||
|
||||
if ($householdIncomeRaw === '' || ! is_numeric($householdIncomeRaw) || (float) $householdIncomeRaw < 0) {
|
||||
return $this->response->setStatusCode(422)->setJSON(['error' => 'Enter a valid household income.']);
|
||||
}
|
||||
if ($householdSizeRaw < 1) {
|
||||
return $this->response->setStatusCode(422)->setJSON(['error' => 'Household size must be at least 1.']);
|
||||
}
|
||||
|
||||
try {
|
||||
$estimateModel = new FinancialAidEstimateModel();
|
||||
$householdIncome = round((float) $householdIncomeRaw, 2);
|
||||
$estimatedAid = $estimateModel->estimatedAid($invoiceBalance, $householdSizeRaw, $householdIncome);
|
||||
$finalRebate = $estimateModel->finalRebate(
|
||||
$invoiceBalance,
|
||||
$householdSizeRaw,
|
||||
$householdIncome,
|
||||
(float) ($request['requested_amount'] ?? 0)
|
||||
);
|
||||
|
||||
return $this->response->setJSON([
|
||||
'invoice_balance' => $invoiceBalance,
|
||||
'estimated_aid' => $estimatedAid,
|
||||
'final_rebate' => $finalRebate,
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return $this->response->setStatusCode(422)->setJSON(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function approve(int $id)
|
||||
{
|
||||
try {
|
||||
@@ -113,4 +171,23 @@ class FinancialAidController extends BaseController
|
||||
|
||||
return (float) ($request['requested_amount'] ?? 0);
|
||||
}
|
||||
|
||||
private function invoiceBalanceForParent(int $parentId, string $schoolYear): float
|
||||
{
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$invoice = (new InvoiceModel())
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
|
||||
if ($invoice === null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return round((float) ($invoice['balance'] ?? 0), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,6 @@ class EnrollmentAdminController extends BaseController
|
||||
|
||||
$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();
|
||||
|
||||
@@ -260,7 +259,7 @@ class EnrollmentAdminController extends BaseController
|
||||
'created_by' => $this->userId(),
|
||||
'approved_by' => $this->userId(),
|
||||
'starts_at' => $now,
|
||||
'expires_at' => $expiresAt,
|
||||
'expires_at' => null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
@@ -314,7 +313,6 @@ class EnrollmentAdminController extends BaseController
|
||||
$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 : [];
|
||||
|
||||
@@ -327,16 +325,6 @@ class EnrollmentAdminController extends BaseController
|
||||
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;
|
||||
@@ -360,7 +348,7 @@ class EnrollmentAdminController extends BaseController
|
||||
$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']));
|
||||
$nonOverridable = array_values(array_intersect($ruleCodes, ['ADULT_STUDENT_PARENT_BLOCKED']));
|
||||
if ($nonOverridable !== []) {
|
||||
$this->db->transRollback();
|
||||
return redirect()->back()->withInput()->with('error', 'These rule(s) cannot be bypassed: ' . implode(', ', $nonOverridable));
|
||||
@@ -393,7 +381,7 @@ class EnrollmentAdminController extends BaseController
|
||||
'created_by' => $this->userId(),
|
||||
'approved_by' => $this->userId(),
|
||||
'starts_at' => $now,
|
||||
'expires_at' => $expiresAt,
|
||||
'expires_at' => null,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
@@ -496,8 +484,13 @@ class EnrollmentAdminController extends BaseController
|
||||
if (is_numeric($assignedTo) && (int) $assignedTo > 0) {
|
||||
$builder->where('ef.assigned_to', (int) $assignedTo);
|
||||
}
|
||||
$builder->where('ef.flag_type !=', 'CLASS_CAPACITY_EXCEPTION_REQUIRED');
|
||||
|
||||
$rows = $builder->get()->getResultArray();
|
||||
$rows = array_values(array_filter(
|
||||
$rows,
|
||||
static fn (array $row): bool => (string) ($row['flag_type'] ?? '') !== 'CLASS_CAPACITY_EXCEPTION_REQUIRED'
|
||||
));
|
||||
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'] ?? ''));
|
||||
@@ -671,7 +664,6 @@ class EnrollmentAdminController extends BaseController
|
||||
$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',
|
||||
];
|
||||
|
||||
@@ -707,7 +699,6 @@ class EnrollmentAdminController extends BaseController
|
||||
'AGE_EXCEPTION_REQUIRED',
|
||||
'LATE_REGISTRATION_EXCEPTION',
|
||||
'FINANCIAL_REVIEW_REQUIRED',
|
||||
'CLASS_CAPACITY_EXCEPTION_REQUIRED',
|
||||
'SIBLING_LAST_NAME_MISMATCH',
|
||||
'DEFERRED_DELIBERATION',
|
||||
'WITHDRAWAL_REVIEW_REQUIRED',
|
||||
@@ -802,7 +793,7 @@ class EnrollmentAdminController extends BaseController
|
||||
throw new \RuntimeException('Selected class section was not found.');
|
||||
}
|
||||
|
||||
$originalEnrollment = $this->latestEnrollment($studentId, $schoolYear);
|
||||
$originalEnrollment = service('enrollmentStatus')->controllingEnrollment($studentId, $schoolYear);
|
||||
$payload = [
|
||||
'class_section_id' => $sectionId,
|
||||
'assigned_class_section_id' => $sectionId,
|
||||
@@ -838,6 +829,26 @@ class EnrollmentAdminController extends BaseController
|
||||
}
|
||||
|
||||
$this->audit($studentId, $schoolYear, (string) ($originalEnrollment['source_school_year'] ?? ''), $auditAction, $originalEnrollment, $payload, 'Class section assigned by administrator.');
|
||||
|
||||
$parentId = (int) ($originalEnrollment['parent_id'] ?? 0);
|
||||
if ($parentId <= 0) {
|
||||
$student = $this->db->table('students')->select('parent_id')->where('id', $studentId)->limit(1)->get()->getRowArray();
|
||||
$parentId = (int) ($student['parent_id'] ?? 0);
|
||||
}
|
||||
if ($parentId > 0) {
|
||||
try {
|
||||
(new InvoiceController())->generateInvoice(
|
||||
(string) $parentId,
|
||||
$schoolYear,
|
||||
(string) ($originalEnrollment['semester'] ?? getSemester())
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Invoice refresh after enrollment admin class assignment failed for parent {parent_id}: {error}', [
|
||||
'parent_id' => $parentId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function updateEnrollmentPlacementStatus(int $studentId, string $schoolYear, string $placementStatus): void
|
||||
@@ -853,14 +864,7 @@ class EnrollmentAdminController extends BaseController
|
||||
|
||||
private function latestEnrollment(int $studentId, string $schoolYear): ?array
|
||||
{
|
||||
return $this->db->table('enrollments')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray() ?: null;
|
||||
return service('enrollmentStatus')->controllingEnrollment($studentId, $schoolYear);
|
||||
}
|
||||
|
||||
private function audit(int $studentId, string $schoolYear, string $sourceSchoolYear, string $action, ?array $original, array $new, string $reason): void
|
||||
@@ -1126,7 +1130,6 @@ class EnrollmentAdminController extends BaseController
|
||||
'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'],
|
||||
@@ -1246,7 +1249,9 @@ class EnrollmentAdminController extends BaseController
|
||||
return 0;
|
||||
}
|
||||
|
||||
$builder = $this->db->table('enrollment_flags')->where('status', 'open');
|
||||
$builder = $this->db->table('enrollment_flags')
|
||||
->where('status', 'open')
|
||||
->where('flag_type !=', 'CLASS_CAPACITY_EXCEPTION_REQUIRED');
|
||||
if ($schoolYear !== '') {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
}
|
||||
@@ -1260,7 +1265,18 @@ class EnrollmentAdminController extends BaseController
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_column($this->db->table('enrollment_flags')->select('flag_type')->distinct()->orderBy('flag_type')->get()->getResultArray(), 'flag_type');
|
||||
$types = array_column(
|
||||
$this->db->table('enrollment_flags')
|
||||
->select('flag_type')
|
||||
->where('flag_type !=', 'CLASS_CAPACITY_EXCEPTION_REQUIRED')
|
||||
->distinct()
|
||||
->orderBy('flag_type')
|
||||
->get()
|
||||
->getResultArray(),
|
||||
'flag_type'
|
||||
);
|
||||
|
||||
return array_values(array_filter($types, static fn ($type): bool => (string) $type !== 'CLASS_CAPACITY_EXCEPTION_REQUIRED'));
|
||||
}
|
||||
|
||||
private function schoolYears(): array
|
||||
|
||||
@@ -145,6 +145,10 @@ class InventoryController extends BaseController
|
||||
}
|
||||
if (($item['type'] ?? '') === 'book') {
|
||||
$this->saveBookClassAssignments($id);
|
||||
$gradeError = $this->syncBookCategoryGradeRange($data['category_id'] ?? null);
|
||||
if ($gradeError !== null) {
|
||||
return redirect()->back()->withInput()->with('error', $gradeError);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('inventory/' . $item['type']))->with('success', 'Item updated.');
|
||||
@@ -161,6 +165,10 @@ class InventoryController extends BaseController
|
||||
$initialQty = (int) ($itemData['quantity'] ?? 0);
|
||||
if (($itemData['type'] ?? '') === 'book') {
|
||||
$this->saveBookClassAssignments((int) $itemId);
|
||||
$gradeError = $this->syncBookCategoryGradeRange($itemData['category_id'] ?? null);
|
||||
if ($gradeError !== null) {
|
||||
return redirect()->back()->withInput()->with('error', $gradeError);
|
||||
}
|
||||
if ($initialQty !== 0) {
|
||||
$this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock');
|
||||
} else {
|
||||
@@ -229,20 +237,12 @@ class InventoryController extends BaseController
|
||||
|
||||
// Only books have grade range
|
||||
if ($data['type'] === 'book') {
|
||||
$gmin = $this->request->getPost('grade_min');
|
||||
$gmax = $this->request->getPost('grade_max');
|
||||
|
||||
$gmin = ($gmin === '' || $gmin === null) ? null : max(0, (int)$gmin);
|
||||
$gmax = ($gmax === '' || $gmax === null) ? null : max(0, (int)$gmax);
|
||||
|
||||
if ($gmin !== null && $gmax !== null && $gmin > $gmax) {
|
||||
return redirect()->back()->withInput()->with('error', 'Grade Min cannot be greater than Grade Max.');
|
||||
$normalized = $this->normalizeGradeRangePost();
|
||||
if (is_string($normalized)) {
|
||||
return redirect()->back()->withInput()->with('error', $normalized);
|
||||
}
|
||||
if ($gmin !== null && $gmin > 13) $gmin = 13;
|
||||
if ($gmax !== null && $gmax > 13) $gmax = 13;
|
||||
|
||||
$data['grade_min'] = $gmin;
|
||||
$data['grade_max'] = $gmax;
|
||||
$data['grade_min'] = $normalized['grade_min'];
|
||||
$data['grade_max'] = $normalized['grade_max'];
|
||||
} else {
|
||||
$data['grade_min'] = null;
|
||||
$data['grade_max'] = null;
|
||||
@@ -264,7 +264,14 @@ class InventoryController extends BaseController
|
||||
'A category with this Type & Name already exists. Please choose a different name or edit the existing one.'
|
||||
);
|
||||
}
|
||||
$ok = $this->catModel->update((int)$id, $data);
|
||||
$current = $this->db->table('inventory_categories')->where('id', (int) $id)->get()->getRowArray();
|
||||
if (!$current) {
|
||||
return redirect()->back()->withInput()->with('error', 'Category not found.');
|
||||
}
|
||||
if (!empty($current['school_year'])) {
|
||||
$data['school_year'] = $current['school_year'];
|
||||
}
|
||||
$ok = $this->writeCategoryRow((int) $id, $data);
|
||||
} else {
|
||||
// Creating: block if already exists
|
||||
if ($existing) {
|
||||
@@ -295,7 +302,9 @@ class InventoryController extends BaseController
|
||||
}
|
||||
|
||||
if (!$ok) {
|
||||
return redirect()->back()->withInput()->with('error', 'Failed to save category.');
|
||||
$errors = $this->catModel->errors();
|
||||
$message = $errors !== [] ? implode(', ', $errors) : 'Failed to save category.';
|
||||
return redirect()->back()->withInput()->with('error', $message);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Category saved.');
|
||||
@@ -531,6 +540,85 @@ class InventoryController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist grade_min/grade_max onto the selected book category.
|
||||
* Returns an error message string on failure, or null on success/no-op.
|
||||
*/
|
||||
private function syncBookCategoryGradeRange($categoryId): ?string
|
||||
{
|
||||
$categoryId = (int) ($categoryId ?? 0);
|
||||
if ($categoryId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Disabled inputs are omitted from POST; do not wipe an existing category range.
|
||||
$post = $this->request->getPost();
|
||||
if (! is_array($post)
|
||||
|| (! array_key_exists('grade_min', $post) && ! array_key_exists('grade_max', $post))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = $this->normalizeGradeRangePost();
|
||||
if (is_string($normalized)) {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
$category = $this->db->table('inventory_categories')->where('id', $categoryId)->get()->getRowArray();
|
||||
if (!$category || ($category['type'] ?? '') !== 'book') {
|
||||
return 'Selected category was not found or is not a book category.';
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'grade_min' => $normalized['grade_min'],
|
||||
'grade_max' => $normalized['grade_max'],
|
||||
];
|
||||
if (!empty($category['school_year'])) {
|
||||
$payload['school_year'] = $category['school_year'];
|
||||
}
|
||||
|
||||
if (!$this->writeCategoryRow($categoryId, $payload)) {
|
||||
return 'Failed to update category grade range.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function writeCategoryRow(int $id, array $data): bool
|
||||
{
|
||||
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||
|
||||
return (bool) $this->db->table('inventory_categories')->where('id', $id)->update($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{grade_min: ?int, grade_max: ?int}|string
|
||||
*/
|
||||
private function normalizeGradeRangePost()
|
||||
{
|
||||
$gmin = $this->request->getPost('grade_min');
|
||||
$gmax = $this->request->getPost('grade_max');
|
||||
|
||||
$gmin = ($gmin === '' || $gmin === null) ? null : max(0, (int) $gmin);
|
||||
$gmax = ($gmax === '' || $gmax === null) ? null : max(0, (int) $gmax);
|
||||
|
||||
if ($gmin !== null && $gmin > 13) {
|
||||
$gmin = 13;
|
||||
}
|
||||
if ($gmax !== null && $gmax > 13) {
|
||||
$gmax = 13;
|
||||
}
|
||||
|
||||
if ($gmin !== null && $gmax !== null && $gmin > $gmax) {
|
||||
return 'Grade Min cannot be greater than Grade Max.';
|
||||
}
|
||||
|
||||
return [
|
||||
'grade_min' => $gmin,
|
||||
'grade_max' => $gmax,
|
||||
];
|
||||
}
|
||||
|
||||
private function moneyValueToCents($value): int
|
||||
{
|
||||
$raw = trim((string) $value);
|
||||
|
||||
@@ -105,104 +105,11 @@ class InvoiceController extends ResourceController
|
||||
log_message('info', "Selected school year for invoice retrieval: $schoolYear");
|
||||
|
||||
$invoiceData = [];
|
||||
$parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $schoolYear);
|
||||
$parents = $this->invoiceManagementParents($schoolYear);
|
||||
|
||||
foreach ($parents as $parent) {
|
||||
$students = $this->studentModel->where('parent_id', $parent['id'])->findAll();
|
||||
|
||||
$parentData = [
|
||||
'parent_name' => $parent['firstname'] . ' ' . $parent['lastname'],
|
||||
'parent_id' => $parent['id'],
|
||||
'enrolledKids' => [],
|
||||
'withdrawnKids' => [],
|
||||
'invoice_amount' => 0,
|
||||
'refund_amount' => 0, // default
|
||||
'last_updated' => null,
|
||||
'invoice_date' => null
|
||||
];
|
||||
|
||||
// Fetch most recent invoice
|
||||
$invoices = $this->invoiceModel->getInvoicesByParentId($parent['id'], $schoolYear);
|
||||
foreach ($invoices as $invoice) {
|
||||
if ($invoice) {
|
||||
$parentData['invoice_amount'] = $invoice['total_amount'];
|
||||
$parentData['last_updated'] = $invoice['updated_at'];
|
||||
// Prefer issue_date (UTC) and render in configured/user local time for display
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$parentData['invoice_date'] = !empty($invoice['issue_date'])
|
||||
? (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC')))
|
||||
->setTimezone(new \DateTimeZone($tzName))
|
||||
->format('Y-m-d H:i:s')
|
||||
: ($invoice['updated_at'] ?? null);
|
||||
$parentData['invoice_id'] = $invoice['id'];
|
||||
|
||||
$refundSummary = $this->paidRefundSummaryForParentYear((int) $parent['id'], (string) $schoolYear);
|
||||
$parentData['refund_amount'] = $refundSummary['amount'];
|
||||
$parentData['refund_details'] = $refundSummary['details'];
|
||||
|
||||
log_message('info', "Latest invoice for parent {$parent['firstname']} {$parent['lastname']} in school year $schoolYear: Amount = {$invoice['total_amount']}, Updated at = {$invoice['updated_at']}");
|
||||
} else {
|
||||
log_message('error', "No invoice found for parent {$parent['firstname']} {$parent['lastname']} in school year $schoolYear.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($students as $student) {
|
||||
$studentClass = $this->db->table('student_class')
|
||||
->where('student_id', $student['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->get()->getRowArray();
|
||||
|
||||
$grade = 'N/A';
|
||||
if ($studentClass && isset($studentClass['class_section_id'])) {
|
||||
$classSection = $this->db->table('classSection')
|
||||
->where('class_section_id', $studentClass['class_section_id'])
|
||||
->get()->getRowArray();
|
||||
|
||||
if ($classSection && isset($classSection['class_section_name'])) {
|
||||
$grade = $classSection['class_section_name'];
|
||||
}
|
||||
}
|
||||
|
||||
$enrollments = $this->enrollmentModel
|
||||
->where('student_id', $student['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
foreach ($enrollments as $enrollment) {
|
||||
$kidData = [
|
||||
'name' => $student['firstname'] . ' ' . $student['lastname'],
|
||||
'grade' => $grade,
|
||||
'tuition_fee' => $enrollment['tuition_fee'] ?? 0
|
||||
];
|
||||
|
||||
switch ($enrollment['enrollment_status']) {
|
||||
case 'payment pending':
|
||||
case 'enrolled':
|
||||
$parentData['enrolledKids'][] = $kidData;
|
||||
break;
|
||||
case 'withdraw under review':
|
||||
case 'withdrawn':
|
||||
case 'refund pending':
|
||||
$parentData['withdrawnKids'][] = $kidData;
|
||||
break;
|
||||
case 'admission under review':
|
||||
log_message('info', "Student ID {$student['id']} is under admission review and not included in the invoice.");
|
||||
break;
|
||||
case 'waitlist':
|
||||
log_message('info', "Student ID {$student['id']} is in waitlist and not included in the invoice.");
|
||||
break;
|
||||
case 'denied':
|
||||
log_message('info', "Student ID {$student['id']} is denied and not included in the invoice.");
|
||||
break;
|
||||
default:
|
||||
log_message('error', "Unexpected enrollment status '{$enrollment['enrollment_status']}' for student ID {$student['id']}.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($parentData['enrolledKids']) || !empty($parentData['withdrawnKids'])) {
|
||||
$invoiceData[] = $parentData;
|
||||
foreach ($this->invoiceManagementRowsForParent($parent, $schoolYear) as $row) {
|
||||
$invoiceData[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,94 +277,11 @@ class InvoiceController extends ResourceController
|
||||
$schoolYears = [$this->schoolYear];
|
||||
}
|
||||
|
||||
$parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $schoolYear);
|
||||
$parents = $this->invoiceManagementParents($schoolYear);
|
||||
|
||||
foreach ($parents as $parent) {
|
||||
$students = $this->studentModel->where('parent_id', $parent['id'])->findAll();
|
||||
|
||||
$parentData = [
|
||||
'parent_name' => trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')),
|
||||
'parent_id' => (int)$parent['id'],
|
||||
'enrolledKids' => [],
|
||||
'withdrawnKids' => [],
|
||||
'invoice_amount'=> 0,
|
||||
'refund_amount' => 0,
|
||||
'last_updated' => null,
|
||||
'invoice_date' => null,
|
||||
'invoice_id' => null,
|
||||
];
|
||||
|
||||
// Latest invoice
|
||||
$invoices = $this->invoiceModel->getInvoicesByParentId($parent['id'], $schoolYear);
|
||||
foreach ($invoices as $invoice) {
|
||||
if ($invoice) {
|
||||
$parentData['invoice_amount'] = (float)($invoice['total_amount'] ?? 0);
|
||||
$parentData['last_updated'] = $invoice['updated_at'] ?? null;
|
||||
// Prefer issue_date (UTC) -> local; fall back to updated_at/created_at
|
||||
if (!empty($invoice['issue_date'])) {
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$parentData['invoice_date'] = (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC')))
|
||||
->setTimezone(new \DateTimeZone($tzName))
|
||||
->format('Y-m-d H:i:s');
|
||||
} else {
|
||||
$parentData['invoice_date'] = date('Y-m-d H:i:s', strtotime($invoice['updated_at'] ?? $invoice['created_at'] ?? 'now'));
|
||||
}
|
||||
$parentData['invoice_id'] = $invoice['id'] ?? null;
|
||||
|
||||
$refundSummary = $this->paidRefundSummaryForParentYear((int) $parent['id'], (string) $schoolYear);
|
||||
$parentData['refund_amount'] = $refundSummary['amount'];
|
||||
$parentData['refund_details'] = $refundSummary['details'];
|
||||
break; // only most recent as before
|
||||
}
|
||||
}
|
||||
|
||||
// Build kids lists based on enrollment statuses
|
||||
foreach ($students as $student) {
|
||||
$studentClass = $this->db->table('student_class')
|
||||
->where('student_id', $student['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->get()->getRowArray();
|
||||
|
||||
$grade = 'N/A';
|
||||
if ($studentClass && isset($studentClass['class_section_id'])) {
|
||||
$classSection = $this->db->table('classSection')
|
||||
->where('class_section_id', $studentClass['class_section_id'])
|
||||
->get()->getRowArray();
|
||||
if ($classSection && isset($classSection['class_section_name'])) {
|
||||
$grade = $classSection['class_section_name'];
|
||||
}
|
||||
}
|
||||
|
||||
$enrollments = $this->enrollmentModel
|
||||
->where('student_id', $student['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
foreach ($enrollments as $enrollment) {
|
||||
$kid = [
|
||||
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
|
||||
'grade' => $grade,
|
||||
'tuition_fee' => (float)($enrollment['tuition_fee'] ?? 0),
|
||||
];
|
||||
switch ($enrollment['enrollment_status']) {
|
||||
case 'payment pending':
|
||||
case 'enrolled':
|
||||
$parentData['enrolledKids'][] = $kid;
|
||||
break;
|
||||
case 'withdraw under review':
|
||||
case 'withdrawn':
|
||||
case 'refund pending':
|
||||
$parentData['withdrawnKids'][] = $kid;
|
||||
break;
|
||||
default:
|
||||
// ignore others for invoice summary
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($parentData['enrolledKids']) || !empty($parentData['withdrawnKids'])) {
|
||||
$invoiceData[] = $parentData;
|
||||
foreach ($this->invoiceManagementRowsForParent($parent, $schoolYear) as $row) {
|
||||
$invoiceData[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,7 +434,9 @@ class InvoiceController extends ResourceController
|
||||
$updated = false;
|
||||
$updatedIds = [];
|
||||
if (!empty($invoice) && isset($invoice['id'])) {
|
||||
$ledger = $this->invoiceLedgerService->recalculate((int) $invoice['id']);
|
||||
$invoiceId = (int) $invoice['id'];
|
||||
$this->invoiceLedgerService->syncTuitionLines($invoiceId);
|
||||
$ledger = $this->invoiceLedgerService->recalculate($invoiceId);
|
||||
$updatedIds[] = (int) $ledger['invoice_id'];
|
||||
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
|
||||
$updated = true;
|
||||
@@ -767,39 +593,273 @@ class InvoiceController extends ResourceController
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer invoices flagged as having discounts.
|
||||
$invoice = $this->invoiceModel
|
||||
$invoices = $this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('has_discount', 1)
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
if (!empty($invoice)) {
|
||||
return $invoice;
|
||||
->findAll();
|
||||
|
||||
$tuitionInvoices = [];
|
||||
foreach ($invoices as $invoice) {
|
||||
if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) {
|
||||
continue;
|
||||
}
|
||||
$tuitionInvoices[] = $invoice;
|
||||
}
|
||||
|
||||
if ($tuitionInvoices === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($tuitionInvoices as $invoice) {
|
||||
if ((int) ($invoice['has_discount'] ?? 0) === 1) {
|
||||
return $invoice;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: prefer invoices with discount_usages rows.
|
||||
try {
|
||||
$row = $this->db->table('invoices i')
|
||||
->select('i.*')
|
||||
->join('discount_usages du', 'du.invoice_id = i.id', 'inner')
|
||||
->where('i.parent_id', $parentId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->orderBy('i.id', 'DESC')
|
||||
->get()
|
||||
->getRowArray();
|
||||
if (!empty($row)) {
|
||||
return $row;
|
||||
$tuitionInvoiceIds = array_values(array_filter(array_map(
|
||||
static fn (array $row): int => (int) ($row['id'] ?? 0),
|
||||
$tuitionInvoices
|
||||
)));
|
||||
if ($tuitionInvoiceIds !== []) {
|
||||
$row = $this->db->table('invoices i')
|
||||
->select('i.*')
|
||||
->join('discount_usages du', 'du.invoice_id = i.id', 'inner')
|
||||
->whereIn('i.id', $tuitionInvoiceIds)
|
||||
->orderBy('i.id', 'DESC')
|
||||
->get()
|
||||
->getRowArray();
|
||||
if (! empty($row)) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
// Final fallback: latest invoice for parent/year.
|
||||
return $this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
return $tuitionInvoices[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function invoiceManagementParents(string $schoolYear): array
|
||||
{
|
||||
$byId = [];
|
||||
foreach ($this->userModel->getUsersByRoleAndSchoolYear('parent', $schoolYear) as $parent) {
|
||||
$byId[(int) ($parent['id'] ?? 0)] = $parent;
|
||||
}
|
||||
|
||||
$invoiceParentRows = $this->invoiceModel
|
||||
->select('parent_id')
|
||||
->distinct()
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
->findAll();
|
||||
|
||||
foreach ($invoiceParentRows as $row) {
|
||||
$parentId = (int) ($row['parent_id'] ?? 0);
|
||||
if ($parentId <= 0 || isset($byId[$parentId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parent = $this->userModel->find($parentId);
|
||||
if ($parent !== null) {
|
||||
$byId[$parentId] = $parent;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($byId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function invoiceManagementRowsForParent(array $parent, string $schoolYear): array
|
||||
{
|
||||
$parentId = (int) ($parent['id'] ?? 0);
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$kids = $this->invoiceManagementKidsForParent($parentId, $schoolYear);
|
||||
$enrolledKids = $kids['enrolledKids'];
|
||||
$withdrawnKids = $kids['withdrawnKids'];
|
||||
$rows = [];
|
||||
|
||||
foreach ($this->carryForwardInvoicesForParentYear($parentId, $schoolYear) as $carryForwardInvoice) {
|
||||
$rows[] = $this->buildInvoiceManagementRow(
|
||||
$parent,
|
||||
$carryForwardInvoice,
|
||||
true,
|
||||
[],
|
||||
[],
|
||||
$this->paidRefundSummaryForInvoice((int) ($carryForwardInvoice['id'] ?? 0))
|
||||
);
|
||||
}
|
||||
|
||||
$tuitionInvoice = $this->selectActiveInvoiceForParentYear($parentId, $schoolYear);
|
||||
if ($enrolledKids !== [] || $withdrawnKids !== [] || $tuitionInvoice !== null) {
|
||||
$rows[] = $this->buildInvoiceManagementRow(
|
||||
$parent,
|
||||
$tuitionInvoice,
|
||||
false,
|
||||
$enrolledKids,
|
||||
$withdrawnKids,
|
||||
$this->paidRefundSummaryForParentYear($parentId, $schoolYear)
|
||||
);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{enrolledKids: list<array<string, mixed>>, withdrawnKids: list<array<string, mixed>>}
|
||||
*/
|
||||
private function invoiceManagementKidsForParent(int $parentId, string $schoolYear): array
|
||||
{
|
||||
$enrolledKids = [];
|
||||
$withdrawnKids = [];
|
||||
$students = $this->studentModel->where('parent_id', $parentId)->findAll();
|
||||
|
||||
foreach ($students as $student) {
|
||||
$studentClass = $this->db->table('student_class')
|
||||
->where('student_id', $student['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$grade = 'N/A';
|
||||
if ($studentClass && isset($studentClass['class_section_id'])) {
|
||||
$classSection = $this->db->table('classSection')
|
||||
->where('class_section_id', $studentClass['class_section_id'])
|
||||
->get()
|
||||
->getRowArray();
|
||||
if ($classSection && isset($classSection['class_section_name'])) {
|
||||
$grade = $classSection['class_section_name'];
|
||||
}
|
||||
}
|
||||
|
||||
$enrollments = $this->enrollmentModel
|
||||
->where('student_id', $student['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
foreach ($enrollments as $enrollment) {
|
||||
$kid = [
|
||||
'name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')),
|
||||
'grade' => $grade,
|
||||
'tuition_fee' => (float) ($enrollment['tuition_fee'] ?? 0),
|
||||
];
|
||||
|
||||
switch ($enrollment['enrollment_status']) {
|
||||
case 'payment pending':
|
||||
case 'enrolled':
|
||||
$enrolledKids[] = $kid;
|
||||
break;
|
||||
case 'withdraw under review':
|
||||
case 'withdrawn':
|
||||
case 'refund pending':
|
||||
$withdrawnKids[] = $kid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'enrolledKids' => $enrolledKids,
|
||||
'withdrawnKids' => $withdrawnKids,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function carryForwardInvoicesForParentYear(int $parentId, string $schoolYear): array
|
||||
{
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('id', 'ASC')
|
||||
->findAll(),
|
||||
fn (array $invoice): bool => $this->invoiceLedgerService->invoiceIsCarryForward($invoice)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $enrolledKids
|
||||
* @param list<array<string, mixed>> $withdrawnKids
|
||||
* @param array{amount: float, details: list<array<string, mixed>>} $refundSummary
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildInvoiceManagementRow(
|
||||
array $parent,
|
||||
?array $invoice,
|
||||
bool $isCarryForward,
|
||||
array $enrolledKids,
|
||||
array $withdrawnKids,
|
||||
array $refundSummary
|
||||
): array {
|
||||
$parentId = (int) ($parent['id'] ?? 0);
|
||||
$invoiceId = $invoice !== null ? (int) ($invoice['id'] ?? 0) : null;
|
||||
$invoiceDate = null;
|
||||
|
||||
if ($invoice !== null) {
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$invoiceDate = ! empty($invoice['issue_date'])
|
||||
? (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC')))
|
||||
->setTimezone(new \DateTimeZone($tzName))
|
||||
->format('Y-m-d H:i:s')
|
||||
: ($invoice['updated_at'] ?? null);
|
||||
}
|
||||
|
||||
$description = '';
|
||||
if ($isCarryForward && $invoice !== null) {
|
||||
$description = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice);
|
||||
}
|
||||
|
||||
return [
|
||||
'parent_name' => trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')),
|
||||
'parent_id' => $parentId,
|
||||
'enrolledKids' => $enrolledKids,
|
||||
'withdrawnKids' => $withdrawnKids,
|
||||
'invoice_amount' => $invoice !== null ? (float) ($invoice['total_amount'] ?? 0) : 0.0,
|
||||
'invoice_balance' => $invoice !== null ? (float) ($invoice['balance'] ?? 0) : 0.0,
|
||||
'refund_amount' => (float) ($refundSummary['amount'] ?? 0.0),
|
||||
'refund_details' => $refundSummary['details'] ?? [],
|
||||
'last_updated' => $invoice['updated_at'] ?? null,
|
||||
'invoice_date' => $invoiceDate,
|
||||
'invoice_id' => $invoiceId,
|
||||
'invoice_number' => $invoice !== null ? (string) ($invoice['invoice_number'] ?? '') : '',
|
||||
'invoice_description' => $description,
|
||||
'invoice_status' => $invoice !== null ? (string) ($invoice['status'] ?? '') : '',
|
||||
'is_carry_forward' => $isCarryForward,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{amount: float, details: list<array<string, mixed>>}
|
||||
*/
|
||||
private function paidRefundSummaryForInvoice(int $invoiceId): array
|
||||
{
|
||||
if ($invoiceId <= 0) {
|
||||
return ['amount' => 0.0, 'details' => []];
|
||||
}
|
||||
|
||||
$details = $this->paidRefundDetailsForInvoice($invoiceId);
|
||||
$amount = 0.0;
|
||||
foreach ($details as $detail) {
|
||||
$amount += (float) ($detail['amount'] ?? 0.0);
|
||||
}
|
||||
|
||||
return [
|
||||
'amount' => round($amount, 2),
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
|
||||
private function recalculateAndUpdateDiscount(
|
||||
@@ -854,11 +914,15 @@ class InvoiceController extends ResourceController
|
||||
|
||||
private function calculateTotalTuitionFee(array $students): float
|
||||
{
|
||||
$schoolYear = (string) ($students[0]['school_year'] ?? $this->schoolYear);
|
||||
|
||||
// 1) Normalize each student's grade name (once)
|
||||
foreach ($students as &$student) {
|
||||
$gradeName = $this->classSectionModel
|
||||
->getClassSectionNameBySectionId($student['class_section_id']);
|
||||
$student['grade'] = strtoupper(trim($gradeName));
|
||||
$student['grade'] = $this->resolveStudentGradeName(
|
||||
(int) ($student['student_id'] ?? 0),
|
||||
$schoolYear,
|
||||
$student['class_section_id'] ?? null
|
||||
);
|
||||
}
|
||||
unset($student); // break reference
|
||||
|
||||
@@ -876,6 +940,29 @@ class InvoiceController extends ResourceController
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function resolveStudentGradeName(int $studentId, string $schoolYear, $classSectionId = null): string
|
||||
{
|
||||
$sectionId = $classSectionId;
|
||||
if (empty($sectionId) && $studentId > 0 && $schoolYear !== '') {
|
||||
$row = $this->studentClassModel
|
||||
->select('class_section_id')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('is_event_only', 0)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->first();
|
||||
$sectionId = $row['class_section_id'] ?? null;
|
||||
}
|
||||
|
||||
if (empty($sectionId)) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
$gradeName = $this->classSectionModel->getClassSectionNameBySectionId($sectionId);
|
||||
|
||||
return strtoupper(trim((string) $gradeName));
|
||||
}
|
||||
|
||||
|
||||
// Method to check and generate an invoice when enrollment status changes
|
||||
public function checkAndGenerateInvoice($parentId, $status)
|
||||
@@ -922,6 +1009,10 @@ class InvoiceController extends ResourceController
|
||||
return ['error' => "No invoice was generated. Please contact the school administration."];
|
||||
}
|
||||
|
||||
if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) {
|
||||
return $this->prepareCarryForwardInvoiceData($invoice);
|
||||
}
|
||||
|
||||
$parentId = $invoice['parent_id'];
|
||||
$schoolYear = $invoice['school_year'];
|
||||
|
||||
@@ -1141,6 +1232,8 @@ class InvoiceController extends ResourceController
|
||||
return [
|
||||
'invoice' => $invoice,
|
||||
'parent' => $parent,
|
||||
'isCarryForwardInvoice' => $this->invoiceLedgerService->invoiceIsCarryForward($invoice),
|
||||
'carryForwardDescription'=> $this->invoiceLedgerService->carryForwardDisplayDescription($invoice),
|
||||
'registeredKids' => $registeredKids,
|
||||
'withdrawnKids' => $withdrawnKids,
|
||||
'studentCharges' => $studentCharges,
|
||||
@@ -1157,6 +1250,85 @@ class InvoiceController extends ResourceController
|
||||
];
|
||||
}
|
||||
|
||||
private function prepareCarryForwardInvoiceData(array $invoice): array
|
||||
{
|
||||
$invoiceId = (int) ($invoice['id'] ?? 0);
|
||||
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
||||
$schoolYear = (string) ($invoice['school_year'] ?? '');
|
||||
|
||||
$parent = $this->userModel->find($parentId);
|
||||
if (! $parent) {
|
||||
return ['error' => 'Parent associated with the invoice was not found.'];
|
||||
}
|
||||
|
||||
$ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId);
|
||||
$carryForwardDescription = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice);
|
||||
$invoice['description'] = $carryForwardDescription;
|
||||
|
||||
$carryForwardAmount = (float) ($ledger['total_amount'] ?? $invoice['total_amount'] ?? 0);
|
||||
if (abs($carryForwardAmount) >= 0.01) {
|
||||
try {
|
||||
$this->invoiceLedgerService->issueCarryForwardInvoiceLine(
|
||||
$invoiceId,
|
||||
$carryForwardAmount,
|
||||
$carryForwardDescription
|
||||
);
|
||||
$ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('warning', 'Unable to normalize carry-forward invoice line for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$db = $this->db;
|
||||
$table = $this->paymentModel->table;
|
||||
$hasStatus = $db->fieldExists('status', $table);
|
||||
$hasVoid = $db->fieldExists('is_void', $table);
|
||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
|
||||
$paymentQuery = $this->paymentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('invoice_id', $invoiceId);
|
||||
|
||||
if ($hasStatus) {
|
||||
$paymentQuery->groupStart()
|
||||
->whereNotIn('status', $exclude)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
if ($hasVoid) {
|
||||
$paymentQuery->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$payments = $paymentQuery->findAll();
|
||||
$refundsPaidTotal = (float) ($ledger['refund_paid_total'] ?? 0.0);
|
||||
$refundDetails = $this->paidRefundDetailsForInvoice($invoiceId);
|
||||
|
||||
return [
|
||||
'invoice' => $invoice,
|
||||
'parent' => $parent,
|
||||
'isCarryForwardInvoice' => true,
|
||||
'carryForwardDescription' => $carryForwardDescription,
|
||||
'registeredKids' => [],
|
||||
'withdrawnKids' => [],
|
||||
'studentCharges' => [],
|
||||
'events' => [],
|
||||
'students' => [],
|
||||
'payments' => $payments,
|
||||
'discounts' => [],
|
||||
'additionalChargesTotal' => 0.0,
|
||||
'additionalChargeLines' => [],
|
||||
'invoiceLines' => [],
|
||||
'refundsPaidTotal' => $refundsPaidTotal,
|
||||
'refundDetails' => $refundDetails,
|
||||
'ledger' => $ledger,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Treat common KG spellings as Kindergarten.
|
||||
@@ -1339,25 +1511,30 @@ class InvoiceController extends ResourceController
|
||||
}
|
||||
|
||||
$studentTuitionRows = [];
|
||||
foreach (array_merge($registeredKids ?? [], $withdrawnKids ?? []) as $student) {
|
||||
$sid = (int)($student['student_id'] ?? 0);
|
||||
$charge = $studentCharges[$sid] ?? null;
|
||||
$amount = (float)($charge['unit_fee'] ?? 0.0);
|
||||
if ($sid <= 0 || abs($amount) < 0.00001) {
|
||||
continue;
|
||||
}
|
||||
$isCarryForwardInvoice = (bool) ($data['isCarryForwardInvoice'] ?? $this->invoiceLedgerService->invoiceIsCarryForward($invoice));
|
||||
$carryForwardDescription = (string) ($data['carryForwardDescription'] ?? $this->invoiceLedgerService->carryForwardDisplayDescription($invoice));
|
||||
|
||||
$name = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? ''));
|
||||
$grade = trim((string)($student['grade'] ?? ''));
|
||||
$desc = 'Tuition - ' . ($name !== '' ? $name : 'Student #' . $sid);
|
||||
if ($grade !== '' && strtoupper($grade) !== 'N/A') {
|
||||
$desc .= ' (' . $grade . ')';
|
||||
}
|
||||
if (! $isCarryForwardInvoice) {
|
||||
foreach (array_merge($registeredKids ?? [], $withdrawnKids ?? []) as $student) {
|
||||
$sid = (int)($student['student_id'] ?? 0);
|
||||
$charge = $studentCharges[$sid] ?? null;
|
||||
$amount = (float)($charge['unit_fee'] ?? 0.0);
|
||||
if ($sid <= 0 || abs($amount) < 0.00001) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$studentTuitionRows[] = [
|
||||
'description' => $desc,
|
||||
'amount' => $amount,
|
||||
];
|
||||
$name = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? ''));
|
||||
$grade = trim((string)($student['grade'] ?? ''));
|
||||
$desc = 'Tuition - ' . ($name !== '' ? $name : 'Student #' . $sid);
|
||||
if ($grade !== '' && strtoupper($grade) !== 'N/A') {
|
||||
$desc .= ' (' . $grade . ')';
|
||||
}
|
||||
|
||||
$studentTuitionRows[] = [
|
||||
'description' => $desc,
|
||||
'amount' => $amount,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$eventRows = [];
|
||||
@@ -1390,9 +1567,24 @@ class InvoiceController extends ResourceController
|
||||
$dt = $toLocal($line['created_at'] ?? ($invoice['created_at'] ?? null), true);
|
||||
$amount = ((int)($line['line_amount_cents'] ?? 0)) / 100;
|
||||
$type = (string)($line['line_type'] ?? 'other');
|
||||
$sourceType = (string)($line['source_type'] ?? '');
|
||||
$category = str_contains($type, 'event') ? 'event'
|
||||
: (str_contains($type, 'additional') ? 'additional' : 'registration');
|
||||
|
||||
if ($sourceType === 'carry_forward_invoice' || $sourceType === 'carry_forward_opening_balance') {
|
||||
$lineDescription = trim((string)($line['description'] ?? ''));
|
||||
if ($lineDescription === '') {
|
||||
$lineDescription = $carryForwardDescription;
|
||||
}
|
||||
$push($dt, $lineDescription, $amount, 'additional');
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isCarryForwardInvoice) {
|
||||
$push($dt, $carryForwardDescription, $amount, 'additional');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!str_contains($type, 'additional') && !str_contains($type, 'event') && !empty($studentTuitionRows)) {
|
||||
$expandedTotal = 0.0;
|
||||
foreach ($studentTuitionRows as $row) {
|
||||
@@ -1426,11 +1618,18 @@ class InvoiceController extends ResourceController
|
||||
|
||||
if (empty($data['invoiceLines'] ?? [])) {
|
||||
$fallbackDt = $toLocal($invoice['created_at'] ?? ($invoice['issue_date'] ?? null), true);
|
||||
foreach ($studentTuitionRows as $row) {
|
||||
$push($fallbackDt, $row['description'], (float)$row['amount'], 'registration');
|
||||
}
|
||||
foreach ($eventRows as $row) {
|
||||
$push($fallbackDt, $row['description'], (float)$row['amount'], 'event');
|
||||
if ($isCarryForwardInvoice) {
|
||||
$carryForwardAmount = (float)($ledger['total_amount'] ?? $invoice['total_amount'] ?? 0);
|
||||
if (abs($carryForwardAmount) >= 0.01) {
|
||||
$push($fallbackDt, $carryForwardDescription, $carryForwardAmount, 'additional');
|
||||
}
|
||||
} else {
|
||||
foreach ($studentTuitionRows as $row) {
|
||||
$push($fallbackDt, $row['description'], (float)$row['amount'], 'registration');
|
||||
}
|
||||
foreach ($eventRows as $row) {
|
||||
$push($fallbackDt, $row['description'], (float)$row['amount'], 'event');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1737,13 +1936,22 @@ private function getGradeLevel($grade): array
|
||||
|
||||
// Attach refund amount and last payment data to each invoice
|
||||
foreach ($invoices as &$invoice) {
|
||||
if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) {
|
||||
$invoice['description'] = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice);
|
||||
}
|
||||
|
||||
$invoice['refund_amount'] = $refunds[$invoice['id']] ?? 0.00;
|
||||
$invoice['last_paid_amount'] = $lastPayments[$invoice['id']]['last_paid_amount'] ?? 0.00;
|
||||
$invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null;
|
||||
}
|
||||
unset($invoice);
|
||||
|
||||
$invoiceEventCharges = [];
|
||||
foreach ($invoices as $invoice) {
|
||||
if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$year = $invoice['school_year'] ?? $this->schoolYear;
|
||||
$sem = $invoice['semester'] ?? $this->semester;
|
||||
$invoiceEventCharges[(int)$invoice['id']] = $this->chargesModel->getChargesWithEventInfo(
|
||||
|
||||
@@ -317,13 +317,8 @@ class ParentController extends BaseController
|
||||
$student['class_section'] = !empty($classSections)
|
||||
? implode(', ', $classSections)
|
||||
: 'Class not Assigned';
|
||||
$isArabicClass = !empty($classSections) && array_reduce(
|
||||
$classSections,
|
||||
static fn($carry, $name) => $carry || (is_string($name) && stripos($name, 'arabic') === 0),
|
||||
false
|
||||
);
|
||||
|
||||
// ✅ Get enrollment status AND admission status
|
||||
// Get enrollment status AND admission status
|
||||
$enrollment = $this->db->table('enrollments')
|
||||
->select('enrollment_status, admission_status')
|
||||
->where('student_id', $studentId)
|
||||
@@ -350,10 +345,7 @@ class ParentController extends BaseController
|
||||
$student['enrollment_status'] = 'not enrolled';
|
||||
}
|
||||
|
||||
// If assigned to Arabic class without an enrollment record, display as enrolled.
|
||||
if ($student['enrollment_status'] === 'not enrolled' && $isArabicClass) {
|
||||
$student['enrollment_status'] = 'enrolled';
|
||||
}
|
||||
// No enrollment record fabrication from class assignment.
|
||||
|
||||
// ✅ Updated disable logic to include denied status
|
||||
$student['disable_enroll'] = in_array(
|
||||
@@ -411,6 +403,15 @@ class ParentController extends BaseController
|
||||
)));
|
||||
}
|
||||
|
||||
if ($previousSchoolYear !== null) {
|
||||
service('enrollmentTransition')->syncParentFinancialReviewFlags(
|
||||
(int) $parentId,
|
||||
$previousSchoolYear,
|
||||
$selectedYear,
|
||||
array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students))
|
||||
);
|
||||
}
|
||||
|
||||
// Render view
|
||||
return view('/parent/enroll_classes', [
|
||||
'students' => $students,
|
||||
@@ -528,6 +529,7 @@ class ParentController extends BaseController
|
||||
|
||||
$studentName = trim((string) ($studentInfo['firstname'] ?? '') . ' ' . (string) ($studentInfo['lastname'] ?? '')) ?: 'Student ID ' . $studentId;
|
||||
if (empty($evaluation['can_enroll'])) {
|
||||
$transitionService->logEnrollmentBlock($evaluation, 'parent_enroll_submit', (int) $parentId, (int) $parentId);
|
||||
$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) . ')' : '') . '.');
|
||||
@@ -891,7 +893,12 @@ class ParentController extends BaseController
|
||||
private function updateEnrollmentParentContact(int $parentId): array
|
||||
{
|
||||
$fields = $this->request->getPost('parent_contact');
|
||||
$normalized = $this->normalizeEnrollmentParentContact(is_array($fields) ? $fields : []);
|
||||
$currentState = '';
|
||||
$user = $this->userModel->find($parentId);
|
||||
if (is_array($user)) {
|
||||
$currentState = strtoupper(trim((string) ($user['state'] ?? '')));
|
||||
}
|
||||
$normalized = $this->normalizeEnrollmentParentContact(is_array($fields) ? $fields : [], $currentState);
|
||||
if ($normalized['errors'] !== []) {
|
||||
return $normalized['errors'];
|
||||
}
|
||||
@@ -907,33 +914,38 @@ class ParentController extends BaseController
|
||||
* @param array<string, mixed> $fields
|
||||
* @return array{errors: list<string>, data: array<string, string>}
|
||||
*/
|
||||
private function normalizeEnrollmentParentContact(array $fields): array
|
||||
private function normalizeEnrollmentParentContact(array $fields, string $existingState = ''): array
|
||||
{
|
||||
$phoneDigits = preg_replace('/\D/', '', (string) ($fields['cellphone'] ?? '')) ?? '';
|
||||
$street = trim((string) ($fields['address_street'] ?? ''));
|
||||
$apt = trim((string) ($fields['apt'] ?? ''));
|
||||
$city = trim((string) ($fields['city'] ?? ''));
|
||||
$street = $this->collapseContactWhitespace((string) ($fields['address_street'] ?? ''));
|
||||
$apt = $this->collapseContactWhitespace((string) ($fields['apt'] ?? ''));
|
||||
$city = $this->collapseContactWhitespace((string) ($fields['city'] ?? ''));
|
||||
$state = strtoupper(trim((string) ($fields['state'] ?? '')));
|
||||
$zip = trim((string) ($fields['zip'] ?? ''));
|
||||
$zip = preg_replace('/\D/', '', (string) ($fields['zip'] ?? '')) ?? '';
|
||||
$errors = [];
|
||||
$allowedStates = ['CT', 'ME', 'MA', 'NH', 'NY', 'RI', 'VT'];
|
||||
$existingState = strtoupper(trim($existingState));
|
||||
if (preg_match('/^[A-Z]{2}$/', $existingState) === 1) {
|
||||
$allowedStates[] = $existingState;
|
||||
}
|
||||
|
||||
if (strlen($phoneDigits) !== 10) {
|
||||
$errors[] = 'A valid 10-digit home/cell phone number is required.';
|
||||
}
|
||||
|
||||
if (strlen($street) < 5 || strlen($street) > 255) {
|
||||
$errors[] = 'Home street address is required.';
|
||||
if ($street === '' || strlen($street) < 2 || strlen($street) > 50 || ! preg_match('/^[A-Za-z0-9.\s\-]+$/', $street)) {
|
||||
$errors[] = 'Home street address must be 2–50 characters and may contain only letters, numbers, spaces, periods, and hyphens.';
|
||||
}
|
||||
|
||||
if ($apt !== '' && strlen($apt) > 15) {
|
||||
$errors[] = 'Apartment or unit must be 15 characters or fewer.';
|
||||
if ($apt !== '' && (strlen($apt) > 15 || ! preg_match('/^[A-Za-z0-9.\s\-]+$/', $apt))) {
|
||||
$errors[] = 'Apartment or unit must be 15 characters or fewer and may contain only letters, numbers, spaces, periods, and hyphens.';
|
||||
}
|
||||
|
||||
if (strlen($city) < 2 || strlen($city) > 100) {
|
||||
$errors[] = 'City is required.';
|
||||
if (strlen($city) < 2 || strlen($city) > 30 || ! preg_match('/^[A-Za-z\s.\'\-]+$/', $city)) {
|
||||
$errors[] = 'City must be 2–30 characters and may contain only letters, spaces, periods, apostrophes, and hyphens.';
|
||||
}
|
||||
|
||||
if (! preg_match('/^[A-Z]{2}$/', $state)) {
|
||||
if (! preg_match('/^[A-Z]{2}$/', $state) || ! in_array($state, $allowedStates, true)) {
|
||||
$errors[] = 'State is required.';
|
||||
}
|
||||
|
||||
@@ -951,9 +963,9 @@ class ParentController extends BaseController
|
||||
'errors' => [],
|
||||
'data' => [
|
||||
'cellphone' => $formattedPhone ?: $phoneDigits,
|
||||
'address_street' => $street,
|
||||
'apt' => $apt,
|
||||
'city' => ucfirst(strtolower($city)),
|
||||
'address_street' => ucwords(strtolower($street)),
|
||||
'apt' => strtoupper($apt),
|
||||
'city' => ucwords(strtolower($city), " -'"),
|
||||
'state' => $state,
|
||||
'zip' => $zip,
|
||||
'updated_at' => utc_now(),
|
||||
@@ -961,6 +973,11 @@ class ParentController extends BaseController
|
||||
];
|
||||
}
|
||||
|
||||
private function collapseContactWhitespace(string $value): string
|
||||
{
|
||||
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $selected
|
||||
* @return list<string>
|
||||
@@ -1211,51 +1228,6 @@ class ParentController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
private function studentPassedPreviousYear(int $studentId, string $targetSchoolYear): bool
|
||||
{
|
||||
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||||
|
||||
if ($studentId <= 0 || $previousSchoolYear === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($this->db->tableExists('promotion_queue')) {
|
||||
$queuedPromotion = $this->db->table('promotion_queue')
|
||||
->select('id')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year_from', $previousSchoolYear)
|
||||
->where('school_year_to', $targetSchoolYear)
|
||||
->whereIn('status', ['queued', 'assigned', 'applied'])
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if ($queuedPromotion !== null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('student_decisions')) {
|
||||
$decisionRow = $this->db->table('student_decisions')
|
||||
->select('decision')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $previousSchoolYear)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(1)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return DeliberationDecision::normalize($decisionRow['decision'] ?? null) === DeliberationDecision::PASSED;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'studentPassedPreviousYear failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isReturningReEnrollmentStudent(int $studentId, string $targetSchoolYear): bool
|
||||
{
|
||||
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||||
@@ -1704,6 +1676,18 @@ class ParentController extends BaseController
|
||||
|
||||
private function eligibilityMessageFromTransition(array $student, ?array $evaluation, ?string $fallMakeupExamOn): array
|
||||
{
|
||||
if ($this->hasSettledParentEnrollmentStatus(
|
||||
(string) ($student['enrollment_status'] ?? ''),
|
||||
(string) ($student['admission_status'] ?? '')
|
||||
)) {
|
||||
return [
|
||||
'message' => EnrollmentEligibility::alreadyEnrolledMessage((string) ($student['enrollment_status'] ?? '')),
|
||||
'blocking' => true,
|
||||
'level' => 'info',
|
||||
'primary_block_reason' => 'ALREADY_ENROLLED',
|
||||
];
|
||||
}
|
||||
|
||||
if ($evaluation === null) {
|
||||
return $this->enrollmentEligibilityMessageForStudent(
|
||||
$student,
|
||||
@@ -1721,6 +1705,15 @@ class ParentController extends BaseController
|
||||
];
|
||||
}
|
||||
|
||||
if (($evaluation['can_enroll'] ?? false) === false && ! empty($evaluation['primary_parent_message'])) {
|
||||
return [
|
||||
'message' => (string) $evaluation['primary_parent_message'],
|
||||
'blocking' => true,
|
||||
'level' => 'danger',
|
||||
'primary_block_reason' => $evaluation['primary_block_reason'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$blockers = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? []))));
|
||||
if ($blockers !== []) {
|
||||
$name = $this->studentNameFromRow($student);
|
||||
@@ -1830,14 +1823,7 @@ class ParentController extends BaseController
|
||||
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)) {
|
||||
if ($this->hasSettledParentEnrollmentStatus($status, (string) ($student['admission_status'] ?? ''))) {
|
||||
return 'Already submitted';
|
||||
}
|
||||
|
||||
@@ -1889,29 +1875,131 @@ class ParentController extends BaseController
|
||||
return 'Contact administration';
|
||||
}
|
||||
|
||||
private function hasSettledParentEnrollmentStatus(string $status, string $admissionStatus = ''): bool
|
||||
{
|
||||
if (strtolower(trim($admissionStatus)) === 'accepted') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim($status)), [
|
||||
'admission under review',
|
||||
'review & decision',
|
||||
'payment pending',
|
||||
'enrolled',
|
||||
'waitlist',
|
||||
'withdraw under review',
|
||||
'refund pending',
|
||||
], true);
|
||||
}
|
||||
|
||||
private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear, array $students = []): array
|
||||
{
|
||||
$schoolYearConfig = $this->schoolYearConfig($selectedYear);
|
||||
$carryOver = $previousSchoolYear !== null ? $this->invoiceBalanceForParent($parentId, $previousSchoolYear) : 0.0;
|
||||
$currentBalance = $this->invoiceBalanceForParent($parentId, $selectedYear);
|
||||
$registrationFee = round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2);
|
||||
$tuitionDue = $this->enrollmentTuitionDue($students);
|
||||
$mandatoryFees = round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2);
|
||||
$behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment');
|
||||
$amountDue = max(0.0, $carryOver) + max(0.0, $currentBalance) + $registrationFee + $tuitionDue + $mandatoryFees;
|
||||
$summary = service('enrollmentTransition')->getEnrollmentFinancialSummary(
|
||||
$parentId,
|
||||
$previousSchoolYear ?? '',
|
||||
$selectedYear,
|
||||
$tuitionDue
|
||||
);
|
||||
$behavior = (string) ($summary['balance_behavior'] ?? 'submission_blocked_until_payment');
|
||||
$schoolYearConfig = $this->schoolYearConfig($selectedYear);
|
||||
$summary['policy_message'] = $this->financialPolicyMessage(
|
||||
$behavior,
|
||||
(string) ($schoolYearConfig['financial_policy_message'] ?? '')
|
||||
);
|
||||
|
||||
return [
|
||||
'currency' => '$',
|
||||
'carry_over_balance' => round($carryOver, 2),
|
||||
'current_balance' => round($currentBalance, 2),
|
||||
'registration_fee' => $registrationFee,
|
||||
'tuition_due_at_registration' => $tuitionDue,
|
||||
'mandatory_fees' => $mandatoryFees,
|
||||
'amount_due' => round($amountDue, 2),
|
||||
'balance_behavior' => $behavior,
|
||||
'payment_plan_available' => (bool) ($schoolYearConfig['payment_plan_available'] ?? false),
|
||||
'policy_message' => $this->financialPolicyMessage($behavior, (string) ($schoolYearConfig['financial_policy_message'] ?? '')),
|
||||
];
|
||||
return $summary;
|
||||
}
|
||||
|
||||
public function enrollmentEligibilityRefresh()
|
||||
{
|
||||
$parentId = (int) session()->get('user_id');
|
||||
if ($parentId <= 0) {
|
||||
return $this->response->setStatusCode(401)->setJSON(['error' => 'Unauthorized']);
|
||||
}
|
||||
|
||||
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
|
||||
if ($previousSchoolYear === null) {
|
||||
return $this->response->setJSON(['students' => []]);
|
||||
}
|
||||
|
||||
$students = $this->db->table('students')
|
||||
->select('id, firstname, lastname, dob')
|
||||
->where('parent_id', $parentId)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$transitionService = service('enrollmentTransition');
|
||||
$payload = [];
|
||||
foreach ($students as $student) {
|
||||
$studentId = (int) ($student['id'] ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingEnrollment = $this->db->table('enrollments')
|
||||
->select('enrollment_status, admission_status')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $selectedYear)
|
||||
->orderBy('id', 'DESC')
|
||||
->get()
|
||||
->getRowArray();
|
||||
$existingStatus = is_array($existingEnrollment) ? (string) ($existingEnrollment['enrollment_status'] ?? '') : '';
|
||||
$existingAdmissionStatus = is_array($existingEnrollment) ? (string) ($existingEnrollment['admission_status'] ?? '') : '';
|
||||
|
||||
if ($this->hasSettledParentEnrollmentStatus($existingStatus, $existingAdmissionStatus)) {
|
||||
$payload[] = [
|
||||
'student_id' => $studentId,
|
||||
'can_enroll' => false,
|
||||
'primary_block_reason' => 'ALREADY_ENROLLED',
|
||||
'primary_parent_message' => EnrollmentEligibility::alreadyEnrolledMessage($existingStatus),
|
||||
'block_title' => EnrollmentEligibility::alreadyEnrolledTitle($existingStatus),
|
||||
'decision' => 'ALREADY_ENROLLED',
|
||||
'blocking_rule_codes' => ['ALREADY_ENROLLED'],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$evaluation = $transitionService->evaluateForParent(
|
||||
$parentId,
|
||||
$studentId,
|
||||
$previousSchoolYear,
|
||||
$selectedYear
|
||||
);
|
||||
$payload[] = [
|
||||
'student_id' => $studentId,
|
||||
'can_enroll' => (bool) ($evaluation['can_enroll'] ?? false),
|
||||
'primary_block_reason' => $evaluation['primary_block_reason'] ?? null,
|
||||
'primary_parent_message' => $evaluation['primary_parent_message'] ?? null,
|
||||
'blocking_rule_codes' => array_values(array_map('strval', $evaluation['blocking_rule_codes'] ?? [])),
|
||||
'decision' => $evaluation['decision'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$financialSummary = $transitionService->getEnrollmentFinancialSummary(
|
||||
$parentId,
|
||||
(string) $previousSchoolYear,
|
||||
$selectedYear
|
||||
);
|
||||
|
||||
if ($previousSchoolYear !== null) {
|
||||
$transitionService->syncParentFinancialReviewFlags(
|
||||
$parentId,
|
||||
$previousSchoolYear,
|
||||
$selectedYear,
|
||||
array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students))
|
||||
);
|
||||
}
|
||||
|
||||
return $this->response->setJSON([
|
||||
'students' => $payload,
|
||||
'financial_summary' => [
|
||||
'carry_forward_balance' => (float) ($financialSummary['carry_forward_balance'] ?? 0),
|
||||
'current_year_balance' => (float) ($financialSummary['current_year_balance'] ?? 0),
|
||||
'total_enrollment_due' => (float) ($financialSummary['total_enrollment_due'] ?? 0),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function enrollmentTuitionDue(array $students): float
|
||||
@@ -1919,16 +2007,11 @@ class ParentController extends BaseController
|
||||
$tuitionStudents = [];
|
||||
|
||||
foreach ($students as $student) {
|
||||
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
|
||||
$eligibilityMessage = is_array($student['enrollment_eligibility_message'] ?? null)
|
||||
? $student['enrollment_eligibility_message']
|
||||
: ['blocking' => false];
|
||||
|
||||
if ($status !== 'not enrolled' || ! empty($eligibilityMessage['blocking'])) {
|
||||
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
|
||||
if (($evaluation['can_enroll'] ?? false) !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
|
||||
$tuitionStudents[] = [
|
||||
'student_id' => (int) ($student['id'] ?? $student['student_id'] ?? 0),
|
||||
'class_section_id' => (int) (
|
||||
@@ -1954,9 +2037,7 @@ class ParentController extends BaseController
|
||||
'currency' => '$',
|
||||
'first_student_fee' => round((float) ($this->configModel->getConfig('first_student_fee') ?? 380), 2),
|
||||
'second_student_fee' => round((float) ($this->configModel->getConfig('second_student_fee') ?? 280), 2),
|
||||
'registration_fee' => round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2),
|
||||
'tuition_due_at_registration' => round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2),
|
||||
'mandatory_fees' => round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1976,20 +2057,79 @@ class ParentController extends BaseController
|
||||
};
|
||||
}
|
||||
|
||||
private function invoiceBalanceForParent(int $parentId, string $schoolYear): float
|
||||
private function auditParentStudentFieldChanges(array $original, array $updated, int $parentId, string $source): void
|
||||
{
|
||||
if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('invoices')) {
|
||||
return 0.0;
|
||||
if (! $this->db->tableExists('enrollment_transition_audits')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $this->db->table('invoices')
|
||||
->select('COALESCE(SUM(balance), 0) AS balance', false)
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()
|
||||
->getRowArray();
|
||||
$studentId = (int) ($original['id'] ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return round((float) ($row['balance'] ?? 0), 2);
|
||||
$fields = ['firstname', 'lastname', 'dob'];
|
||||
$changes = [];
|
||||
foreach ($fields as $field) {
|
||||
$oldValue = trim((string) ($original[$field] ?? ''));
|
||||
$newValue = trim((string) ($updated[$field] ?? ''));
|
||||
if ($oldValue !== $newValue) {
|
||||
$changes[$field] = [
|
||||
'old_value' => $oldValue,
|
||||
'new_value' => $newValue,
|
||||
'changed' => true,
|
||||
'changed_by' => $parentId,
|
||||
'changed_at' => date('Y-m-d H:i:s'),
|
||||
'source' => $source,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($changes === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->table('enrollment_transition_audits')->insert([
|
||||
'student_id' => $studentId,
|
||||
'school_year' => (string) ($updated['school_year'] ?? $original['school_year'] ?? date('Y')),
|
||||
'source_school_year' => null,
|
||||
'action' => 'parent_student_field_edit',
|
||||
'performed_by' => $parentId,
|
||||
'original_values_json' => json_encode($original, JSON_UNESCAPED_SLASHES),
|
||||
'new_values_json' => json_encode(['changes' => $changes, 'updated' => $updated], JSON_UNESCAPED_SLASHES),
|
||||
'reason' => $source,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function parentEditAffectsEligibility(array $original, array $updated): bool
|
||||
{
|
||||
return trim((string) ($original['dob'] ?? '')) !== trim((string) ($updated['dob'] ?? ''))
|
||||
|| trim((string) ($original['lastname'] ?? '')) !== trim((string) ($updated['lastname'] ?? ''));
|
||||
}
|
||||
|
||||
private function recheckEligibilityAfterParentEdit(int $studentId, int $parentId, string $targetSchoolYear): void
|
||||
{
|
||||
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||||
if ($previousSchoolYear === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$evaluation = service('enrollmentTransition')->evaluateForParent(
|
||||
$parentId,
|
||||
$studentId,
|
||||
$previousSchoolYear,
|
||||
$targetSchoolYear,
|
||||
'parent'
|
||||
);
|
||||
|
||||
if (($evaluation['can_enroll'] ?? false) === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = (string) ($evaluation['primary_parent_message'] ?? 'Updated student information affects enrollment eligibility.');
|
||||
service('enrollmentTransition')->logEnrollmentBlock($evaluation, 'parent_student_edit', $parentId, $parentId);
|
||||
session()->setFlashdata('warning', $message);
|
||||
}
|
||||
|
||||
private function schoolYearConfig(string $schoolYear): array
|
||||
@@ -2548,7 +2688,7 @@ class ParentController extends BaseController
|
||||
}
|
||||
|
||||
// ✅ 8. Pass $isNew to the student save function
|
||||
$result = $this->validateAndSaveOrUpdateStudent($idx, $parentId, $this->semester, $this->schoolYear, $schoolIdService, $isNew, null, null);
|
||||
$result = $this->validateAndSaveOrUpdateStudent($idx, $parentId, $this->semester, $this->schoolYear, $schoolIdService, $isNew, null);
|
||||
|
||||
if ($result) {
|
||||
$studentAdded = true;
|
||||
@@ -2775,7 +2915,19 @@ $existing = $this->studentModel
|
||||
|
||||
// ---------- UPDATE OR INSERT ----------
|
||||
if ($studentId) {
|
||||
$existing = $this->studentModel->find((int) $studentId);
|
||||
if (! is_array($existing) || (int) ($existing['parent_id'] ?? 0) !== (int) $parentId) {
|
||||
session()->setFlashdata('error', 'Student record was not found for this parent account.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->auditParentStudentFieldChanges($existing, $studentData, (int) $parentId, 'parent_student_edit');
|
||||
$this->studentModel->update($studentId, $studentData);
|
||||
|
||||
if ($this->parentEditAffectsEligibility($existing, $studentData)) {
|
||||
$this->recheckEligibilityAfterParentEdit((int) $studentId, (int) $parentId, (string) $schoolYear);
|
||||
}
|
||||
} else {
|
||||
$studentData['registration_date'] = utc_now();
|
||||
$studentData['tuition_paid'] = 0;
|
||||
@@ -3148,7 +3300,7 @@ $existing = $this->studentModel
|
||||
$this->request->setGlobal('post', $formData);
|
||||
|
||||
// Save/update
|
||||
$this->validateAndSaveOrUpdateStudent(0, $parentId, $this->semester, $this->schoolYear, $schoolIdService, $id, null, null);
|
||||
$this->validateAndSaveOrUpdateStudent(0, $parentId, $this->semester, $this->schoolYear, $schoolIdService, null, (int) $id);
|
||||
|
||||
return redirect()->to('/parent/child_register')->with('success', 'Student updated!');
|
||||
}
|
||||
|
||||
@@ -24,15 +24,8 @@ class ParentFinancialAidController extends BaseController
|
||||
->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),
|
||||
'existingRequest' => $model->requestForParentYear($parentId, $schoolYear),
|
||||
@@ -52,14 +45,27 @@ class ParentFinancialAidController extends BaseController
|
||||
return redirect()->back()->with('error', 'You can submit only one financial aid application per school year.');
|
||||
}
|
||||
|
||||
$studentIds = array_values(array_unique(array_filter(array_map('intval', (array) $this->request->getPost('student_ids')))));
|
||||
$linkedIds = array_map('intval', array_column(
|
||||
$studentIds = 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.');
|
||||
return redirect()->back()->withInput()->with('error', 'No students are linked to your account.');
|
||||
}
|
||||
|
||||
$householdIncomeRaw = trim((string) $this->request->getPost('household_income'));
|
||||
if ($householdIncomeRaw === '' || ! is_numeric($householdIncomeRaw) || (float) $householdIncomeRaw < 0) {
|
||||
return redirect()->back()->withInput()->with('error', 'Enter your household income.');
|
||||
}
|
||||
|
||||
$householdSize = (int) $this->request->getPost('household_size');
|
||||
if ($householdSize < 1) {
|
||||
return redirect()->back()->withInput()->with('error', 'Enter your household size.');
|
||||
}
|
||||
|
||||
$requestedAmountRaw = trim((string) $this->request->getPost('requested_amount'));
|
||||
if ($requestedAmountRaw === '' || ! is_numeric($requestedAmountRaw) || (float) $requestedAmountRaw <= 0) {
|
||||
return redirect()->back()->withInput()->with('error', 'Enter the amount you are requesting.');
|
||||
}
|
||||
|
||||
$needStatement = trim((string) $this->request->getPost('need_statement'));
|
||||
@@ -67,16 +73,14 @@ class ParentFinancialAidController extends BaseController
|
||||
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,
|
||||
'student_ids_json' => json_encode(array_values($studentIds)),
|
||||
'household_size' => $householdSize,
|
||||
'household_income' => round((float) $householdIncomeRaw, 2),
|
||||
'need_statement' => $needStatement,
|
||||
'requested_amount' => $requestedAmount !== '' ? (float) $requestedAmount : null,
|
||||
'requested_amount' => round((float) $requestedAmountRaw, 2),
|
||||
'status' => 'submitted',
|
||||
]);
|
||||
|
||||
|
||||
@@ -525,16 +525,15 @@ class PaymentController extends ResourceController
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2) Payment check: recompute current balance from payments (authoritative)
|
||||
$total = (float) ($invoice['total_amount'] ?? 0);
|
||||
$currentBal = $this->getCurrentInvoiceBalance($invoiceId); // <-- uses the safe helper
|
||||
if (!($total > 0 && $currentBal < $total)) {
|
||||
log_message('info', 'No payment yet (or still full balance). Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
|
||||
// 2) Payment check: enrollment transitions only after the invoice is fully paid
|
||||
$currentBal = $this->getCurrentInvoiceBalance($invoiceId);
|
||||
if ($currentBal > 0.00001) {
|
||||
log_message('info', 'Invoice not fully paid. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
||||
$schoolYear = (string) $this->schoolYear;
|
||||
$schoolYear = (string) ($invoice['school_year'] ?? $this->schoolYear);
|
||||
$semester = isset($this->semester) && $this->semester !== '' ? (string)$this->semester : null;
|
||||
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
|
||||
@@ -214,22 +214,41 @@ class StudentController extends BaseController
|
||||
|
||||
$attnStats = [];
|
||||
$scoreStats = [];
|
||||
$invoiceParentId = 0;
|
||||
if (!$isEventOnly) {
|
||||
// Update enrollment for current term (if exists)
|
||||
$enroll = $this->enrollmentModel
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', (string)$this->schoolYear)
|
||||
->where('semester', (string)$this->semester)
|
||||
->first();
|
||||
$enroll = \Config\Services::enrollmentStatus(false)
|
||||
->controllingEnrollment($studentId, (string) $this->schoolYear);
|
||||
|
||||
if ($enroll) {
|
||||
$invoiceParentId = (int) ($enroll['parent_id'] ?? $student['parent_id'] ?? 0);
|
||||
$parentId = $invoiceParentId;
|
||||
if ($parentId > 0) {
|
||||
$advance = service('enrollmentTransition')->evaluateEnrollmentAdvance(
|
||||
$parentId,
|
||||
$studentId,
|
||||
(string) $this->schoolYear,
|
||||
'admin'
|
||||
);
|
||||
if (! service('enrollmentTransition')->adminMayAdvanceEnrollment($advance)) {
|
||||
service('enrollmentTransition')->logEnrollmentBlock(
|
||||
$advance,
|
||||
'assign_class_student',
|
||||
$parentId,
|
||||
$userId > 0 ? $userId : null
|
||||
);
|
||||
$msg = (string) ($advance['primary_parent_message'] ?? 'Enrollment eligibility check failed.');
|
||||
throw new \RuntimeException($msg);
|
||||
}
|
||||
}
|
||||
|
||||
$enPk = $this->enrollmentModel->primaryKey ?? 'id';
|
||||
$result = \Config\Services::enrollmentStatus(false)->upsertStatus([
|
||||
'id' => (int) $enroll[$enPk],
|
||||
'student_id' => $studentId,
|
||||
'parent_id' => (int) ($enroll['parent_id'] ?? 0),
|
||||
'school_year' => (string) $this->schoolYear,
|
||||
'semester' => (string) $this->semester,
|
||||
'semester' => (string) ($enroll['semester'] ?? $this->semester),
|
||||
'class_section_id' => $primarySectionId,
|
||||
'enrollment_status' => 'payment pending',
|
||||
// Ensure admission is marked accepted once moved out of review
|
||||
@@ -267,6 +286,21 @@ class StudentController extends BaseController
|
||||
|
||||
$this->db->transCommit();
|
||||
|
||||
if (! $isEventOnly && $invoiceParentId > 0) {
|
||||
try {
|
||||
(new InvoiceController())->generateInvoice(
|
||||
(string) $invoiceParentId,
|
||||
(string) $this->schoolYear,
|
||||
(string) $this->semester
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Invoice refresh after class assignment failed for parent {parent_id}: {error}', [
|
||||
'parent_id' => $invoiceParentId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$resp = [
|
||||
'ok' => true,
|
||||
'student_id' => $studentId,
|
||||
@@ -680,7 +714,7 @@ class StudentController extends BaseController
|
||||
$cands = $this->distributionCandidates($classId, $year);
|
||||
|
||||
if (empty($cands)) {
|
||||
$msg = 'No promoted students found to distribute for selected class/year.';
|
||||
$msg = 'No students found to distribute for the selected class/year.';
|
||||
return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg);
|
||||
}
|
||||
|
||||
@@ -1592,6 +1626,9 @@ class StudentController extends BaseController
|
||||
|
||||
$scoreField = $this->studentDecisionScoreField();
|
||||
$select = 'sd.id AS decision_id, sd.student_id, sd.class_section_name, sd.decision, students.firstname, students.lastname, students.gender, students.age, students.dob';
|
||||
if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) {
|
||||
$select .= ', sd.deliberation_decision_standard';
|
||||
}
|
||||
if ($scoreField !== null) {
|
||||
$select .= ', sd.' . $scoreField . ' AS year_score';
|
||||
}
|
||||
@@ -1616,13 +1653,15 @@ class StudentController extends BaseController
|
||||
continue;
|
||||
}
|
||||
$seen[$studentId] = true;
|
||||
if (DeliberationDecision::normalize($row['decision'] ?? null) !== DeliberationDecision::PASSED) {
|
||||
$normalizedDecision = $this->normalizedDecisionFromRow($row);
|
||||
if ($normalizedDecision !== DeliberationDecision::PASSED
|
||||
&& $normalizedDecision !== DeliberationDecision::REPEAT_CLASS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetClassId = $this->targetClassIdFromDecision(
|
||||
(string)($row['class_section_name'] ?? ''),
|
||||
(string)($row['decision'] ?? ''),
|
||||
$normalizedDecision ?? (string)($row['decision'] ?? ''),
|
||||
$targetSchoolYear
|
||||
);
|
||||
$ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $targetSchoolYear);
|
||||
@@ -1760,8 +1799,13 @@ class StudentController extends BaseController
|
||||
return $this->distributionExcludedDecisionCache[$previousSchoolYear];
|
||||
}
|
||||
|
||||
$select = 'student_id, decision';
|
||||
if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) {
|
||||
$select .= ', deliberation_decision_standard';
|
||||
}
|
||||
|
||||
$rows = $this->db->table('student_decisions')
|
||||
->select('student_id, decision')
|
||||
->select($select)
|
||||
->where('school_year', $previousSchoolYear)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->orderBy('id', 'DESC')
|
||||
@@ -1777,7 +1821,8 @@ class StudentController extends BaseController
|
||||
}
|
||||
|
||||
$seen[$studentId] = true;
|
||||
if ($this->isDistributionExcludedDecision((string)($row['decision'] ?? ''))) {
|
||||
$normalizedDecision = $this->normalizedDecisionFromRow($row);
|
||||
if ($normalizedDecision !== null && $this->isDistributionExcludedDecision($normalizedDecision)) {
|
||||
$excluded[$studentId] = true;
|
||||
}
|
||||
}
|
||||
@@ -1787,9 +1832,25 @@ class StudentController extends BaseController
|
||||
return $excluded;
|
||||
}
|
||||
|
||||
private function normalizedDecisionFromRow(array $row): ?string
|
||||
{
|
||||
return DeliberationDecision::normalize($row['deliberation_decision_standard'] ?? null)
|
||||
?? DeliberationDecision::normalize($row['decision'] ?? null);
|
||||
}
|
||||
|
||||
private function isDistributionExcludedDecision(string $decision): bool
|
||||
{
|
||||
return DeliberationDecision::normalize($decision) !== DeliberationDecision::PASSED;
|
||||
$normalized = DeliberationDecision::normalize($decision);
|
||||
if ($normalized === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($normalized, [
|
||||
DeliberationDecision::EXPELLED,
|
||||
DeliberationDecision::WITHDRAWN,
|
||||
DeliberationDecision::DEFERRED_DECISION,
|
||||
DeliberationDecision::MAKE_UP_EXAM,
|
||||
], true);
|
||||
}
|
||||
|
||||
private function distributionPreviousClassSectionName(int $studentId, string $targetSchoolYear, ?string $sourceSchoolYear = null): string
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddHouseholdIncomeToFinancialAidRequests extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (! $this->db->tableExists('financial_aid_requests')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array('household_income', $this->db->getFieldNames('financial_aid_requests'), true)) {
|
||||
$this->forge->addColumn('financial_aid_requests', [
|
||||
'household_income' => [
|
||||
'type' => 'DECIMAL',
|
||||
'constraint' => '12,2',
|
||||
'null' => true,
|
||||
'after' => 'household_size',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if ($this->db->tableExists('financial_aid_requests')
|
||||
&& in_array('household_income', $this->db->getFieldNames('financial_aid_requests'), true)
|
||||
) {
|
||||
$this->forge->dropColumn('financial_aid_requests', 'household_income');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class RemoveRegistrationAndMandatoryFees extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (! $this->db->tableExists('school_years')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (['registration_fee', 'mandatory_fees'] as $column) {
|
||||
if ($this->db->fieldExists($column, 'school_years')) {
|
||||
$this->forge->dropColumn('school_years', $column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if (! $this->db->tableExists('school_years')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->db->fieldExists('registration_fee', 'school_years')) {
|
||||
$this->forge->addColumn('school_years', [
|
||||
'registration_fee' => [
|
||||
'type' => 'DECIMAL',
|
||||
'constraint' => '10,2',
|
||||
'default' => 0,
|
||||
'after' => 'adult_student_registration_enabled',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $this->db->fieldExists('mandatory_fees', 'school_years')) {
|
||||
$this->forge->addColumn('school_years', [
|
||||
'mandatory_fees' => [
|
||||
'type' => 'DECIMAL',
|
||||
'constraint' => '10,2',
|
||||
'default' => 0,
|
||||
'after' => 'tuition_due_at_registration',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,76 @@ class InvoiceLedgerService
|
||||
return $this->recalculateInvoice($invoiceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh frozen tuition invoice lines to match the current enrollment/class assignment state.
|
||||
* Event and additional charge lines are left unchanged.
|
||||
*/
|
||||
public function syncTuitionLines(int $invoiceId): bool
|
||||
{
|
||||
$invoice = $this->loadInvoice($invoiceId);
|
||||
if ($invoice === null || $this->isCarryForwardInvoice($invoice) || ! $this->invoiceLinesAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->invoiceHasLines($invoiceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentTuitionCents = $this->toCents($this->calculateTuitionTotal($invoice));
|
||||
$frozen = $this->calculateFrozenLineTotals($invoiceId);
|
||||
$frozenTuitionCents = (int) ($frozen['tuition_cents'] ?? 0);
|
||||
|
||||
if ($currentTuitionCents === $frozenTuitionCents) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = utc_now();
|
||||
$this->invoiceLineModel()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->groupStart()
|
||||
->where('line_type', 'tuition')
|
||||
->orWhere('source_type', 'tuition_calculation')
|
||||
->groupEnd()
|
||||
->set([
|
||||
'voided_at' => $now,
|
||||
'updated_at' => $now,
|
||||
])
|
||||
->update();
|
||||
|
||||
if ($currentTuitionCents === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$metadata = [
|
||||
'parent_id' => (int) ($invoice['parent_id'] ?? 0),
|
||||
'school_year' => (string) ($invoice['school_year'] ?? ''),
|
||||
'semester' => (string) ($invoice['semester'] ?? ''),
|
||||
'synced_at' => $now,
|
||||
];
|
||||
|
||||
$lineId = $this->invoiceLineModel()->insert(
|
||||
$this->buildInvoiceLineRow(
|
||||
$invoiceId,
|
||||
'tuition',
|
||||
'tuition_calculation',
|
||||
null,
|
||||
'Tuition charges',
|
||||
$currentTuitionCents,
|
||||
1,
|
||||
$this->getCalculationVersion(),
|
||||
$metadata,
|
||||
$now
|
||||
)
|
||||
);
|
||||
|
||||
if (! $lineId) {
|
||||
throw new FinancialPersistenceException('INVOICE_TUITION_SYNC_FAILED', $this->invoiceLineModel()->errors());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getValidPaymentTotalCents(int $invoiceId): int
|
||||
{
|
||||
return $this->toCents($this->calculateValidPayments($invoiceId));
|
||||
@@ -428,9 +498,129 @@ class InvoiceLedgerService
|
||||
|
||||
return str_contains($description, 'carried over')
|
||||
|| str_contains($description, 'carry-forward')
|
||||
|| str_contains($description, 'carry over')
|
||||
|| str_contains($description, 'previous school year');
|
||||
}
|
||||
|
||||
public function invoiceIsCarryForward(array $invoice): bool
|
||||
{
|
||||
return $this->isCarryForwardInvoice($invoice);
|
||||
}
|
||||
|
||||
public function carryForwardDisplayDescription(array $invoice): string
|
||||
{
|
||||
$sourceYear = $this->carryForwardSourceSchoolYear($invoice);
|
||||
if ($sourceYear !== '') {
|
||||
return 'Carry over balance from last year ' . $sourceYear;
|
||||
}
|
||||
|
||||
return 'Carry over balance from last year';
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a single carry-over line on an opening-balance invoice. No student names are used.
|
||||
*/
|
||||
public function issueCarryForwardInvoiceLine(int $invoiceId, float $amount, string $description): int
|
||||
{
|
||||
if ($invoiceId <= 0 || ! $this->invoiceLinesAvailable()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$invoice = $this->loadInvoice($invoiceId);
|
||||
if ($invoice === null || ! $this->isCarryForwardInvoice($invoice)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$amountCents = $this->toCents($amount);
|
||||
if ($amountCents === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$description = trim($description);
|
||||
if ($description === '') {
|
||||
$description = $this->carryForwardDisplayDescription($invoice);
|
||||
}
|
||||
|
||||
$existingLine = $this->invoiceLineModel()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('source_type', 'carry_forward_opening_balance')
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->first();
|
||||
|
||||
$now = utc_now();
|
||||
|
||||
$this->invoiceLineModel()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->groupStart()
|
||||
->where('line_type', 'tuition')
|
||||
->orWhere('source_type', 'tuition_calculation')
|
||||
->groupEnd()
|
||||
->set([
|
||||
'voided_at' => $now,
|
||||
'updated_at' => $now,
|
||||
])
|
||||
->update();
|
||||
|
||||
if ($existingLine !== null) {
|
||||
$this->invoiceLineModel()->update((int) $existingLine['id'], [
|
||||
'description' => $description,
|
||||
'unit_amount_cents' => $amountCents,
|
||||
'line_amount_cents' => $amountCents,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return (int) $existingLine['id'];
|
||||
}
|
||||
|
||||
$lineId = $this->invoiceLineModel()->insert(
|
||||
$this->buildInvoiceLineRow(
|
||||
$invoiceId,
|
||||
'additional_charge',
|
||||
'carry_forward_opening_balance',
|
||||
$invoiceId,
|
||||
$description,
|
||||
$amountCents,
|
||||
0,
|
||||
$this->getCalculationVersion(),
|
||||
['carry_forward' => true],
|
||||
$now
|
||||
)
|
||||
);
|
||||
|
||||
if (! $lineId) {
|
||||
throw new FinancialPersistenceException('INVOICE_CARRY_FORWARD_LINE_FAILED', $this->invoiceLineModel()->errors());
|
||||
}
|
||||
|
||||
return (int) $lineId;
|
||||
}
|
||||
|
||||
private function carryForwardSourceSchoolYear(array $invoice): string
|
||||
{
|
||||
$description = (string) ($invoice['description'] ?? '');
|
||||
if (preg_match('/last year\s+(\d{4}-\d{4})/i', $description, $matches) === 1) {
|
||||
return $matches[1];
|
||||
}
|
||||
if (preg_match('/previous school year\s+(\d{4}-\d{4})/i', $description, $matches) === 1) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
$invoiceNumber = (string) ($invoice['invoice_number'] ?? '');
|
||||
if (preg_match('/^CF-(\d{8})-/i', $invoiceNumber, $matches) === 1) {
|
||||
$compact = $matches[1];
|
||||
if (strlen($compact) === 8) {
|
||||
return substr($compact, 0, 4) . '-' . substr($compact, 4, 4);
|
||||
}
|
||||
}
|
||||
|
||||
$targetYear = trim((string) ($invoice['school_year'] ?? ''));
|
||||
if (preg_match('/^(\d{4})-(\d{4})$/', $targetYear, $matches) === 1) {
|
||||
return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
protected function calculateTuitionTotal(array $invoice): float
|
||||
{
|
||||
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* PHP port of app/Controllers/financial_aid_formula.py
|
||||
*/
|
||||
class FinancialAidEstimateModel
|
||||
{
|
||||
private const FEDERAL_POVERTY_BASE = 15960.0;
|
||||
private const FEDERAL_POVERTY_STEP = 5680.0;
|
||||
private const MASSACHUSETTS_COST_FACTOR = 1.05757;
|
||||
private const PROTECTED_INCOME_MULTIPLIER = 2.0;
|
||||
private const DISCRETIONARY_CONTRIBUTION_RATE = 0.10;
|
||||
|
||||
public function estimatedAid(float $totalBalance, int $householdSize, float $householdIncome): float
|
||||
{
|
||||
if ($householdSize < 1) {
|
||||
throw new InvalidArgumentException('household_size must be at least 1');
|
||||
}
|
||||
if ($totalBalance < 0 || $householdIncome < 0) {
|
||||
throw new InvalidArgumentException('balance and income cannot be negative');
|
||||
}
|
||||
|
||||
$federalPovertyLevel = self::FEDERAL_POVERTY_BASE + (self::FEDERAL_POVERTY_STEP * ($householdSize - 1));
|
||||
$adjustedNeedThreshold = $federalPovertyLevel * self::MASSACHUSETTS_COST_FACTOR;
|
||||
$protectedIncome = self::PROTECTED_INCOME_MULTIPLIER * $adjustedNeedThreshold;
|
||||
$discretionaryIncome = max(0.0, $householdIncome - $protectedIncome);
|
||||
$expectedContribution = self::DISCRETIONARY_CONTRIBUTION_RATE * $discretionaryIncome;
|
||||
$estimated = $totalBalance - $expectedContribution;
|
||||
|
||||
return round(max(0.0, min($totalBalance, $estimated)), 2);
|
||||
}
|
||||
|
||||
public function finalRebate(
|
||||
float $totalBalance,
|
||||
int $householdSize,
|
||||
float $householdIncome,
|
||||
float $amountRequested
|
||||
): float {
|
||||
if ($amountRequested < 0) {
|
||||
throw new InvalidArgumentException('amount_requested cannot be negative');
|
||||
}
|
||||
|
||||
$estimated = $this->estimatedAid($totalBalance, $householdSize, $householdIncome);
|
||||
|
||||
return min($estimated, $amountRequested, $totalBalance);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class FinancialAidRequestModel extends Model
|
||||
'school_year',
|
||||
'student_ids_json',
|
||||
'household_size',
|
||||
'household_income',
|
||||
'need_statement',
|
||||
'requested_amount',
|
||||
'status',
|
||||
|
||||
@@ -25,7 +25,7 @@ class InventoryCategoryModel extends Model
|
||||
'school_year',
|
||||
];
|
||||
protected $validationRules = [
|
||||
'school_year' => 'required|string|max_length[9]',
|
||||
'school_year' => 'permit_empty|string|max_length[9]',
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -25,9 +25,7 @@ class SchoolYearModel extends Model
|
||||
'administrative_exceptions_permitted',
|
||||
'registration_exception_roles',
|
||||
'adult_student_registration_enabled',
|
||||
'registration_fee',
|
||||
'tuition_due_at_registration',
|
||||
'mandatory_fees',
|
||||
'carry_over_balance_behavior',
|
||||
'financial_policy_message',
|
||||
'payment_plan_available',
|
||||
@@ -60,9 +58,7 @@ class SchoolYearModel extends Model
|
||||
'late_registration_blocked' => 'permit_empty|in_list[0,1]',
|
||||
'administrative_exceptions_permitted' => 'permit_empty|in_list[0,1]',
|
||||
'adult_student_registration_enabled' => 'permit_empty|in_list[0,1]',
|
||||
'registration_fee' => 'permit_empty|decimal',
|
||||
'tuition_due_at_registration' => 'permit_empty|decimal',
|
||||
'mandatory_fees' => 'permit_empty|decimal',
|
||||
'carry_over_balance_behavior' => 'permit_empty|in_list[information_only,payment_plan_required,submission_allowed_confirmation_blocked,submission_blocked_until_payment,admin_approval_required]',
|
||||
'payment_plan_available' => 'permit_empty|in_list[0,1]',
|
||||
'registration_launch_approved_at' => 'permit_empty|valid_date[Y-m-d H:i:s]',
|
||||
|
||||
@@ -116,15 +116,31 @@ final class EnrollmentRegistrationEmailService
|
||||
foreach ($this->recipientFamilies($schoolYear, null) as $family) {
|
||||
$message = $this->buildMessage($schoolYear, $family);
|
||||
$latest = $this->latestEmailRecord($schoolYearName, (int) $family['parent_user_id']);
|
||||
$previousYear = $this->previousSchoolYearName($schoolYearName);
|
||||
$studentSummaries = [];
|
||||
|
||||
foreach ($family['students'] as $student) {
|
||||
$studentId = (int) ($student['id'] ?? 0);
|
||||
$evaluation = $studentId > 0 && $previousYear !== null
|
||||
? $this->transitionService->evaluateForParent(
|
||||
(int) $family['parent_user_id'],
|
||||
$studentId,
|
||||
$previousYear,
|
||||
$schoolYearName
|
||||
)
|
||||
: [];
|
||||
$studentSummaries[] = [
|
||||
'name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . $studentId,
|
||||
'adult_student' => ! empty($evaluation['adult_student']),
|
||||
];
|
||||
}
|
||||
|
||||
$examples[] = [
|
||||
'parent_user_id' => (int) $family['parent_user_id'],
|
||||
'parent_name' => (string) $family['name'],
|
||||
'recipients' => $message['recipients'],
|
||||
'student_names' => array_map(
|
||||
static fn (array $student): string => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student #' . (int) ($student['id'] ?? 0),
|
||||
$family['students']
|
||||
),
|
||||
'students' => $studentSummaries,
|
||||
'student_names' => array_column($studentSummaries, 'name'),
|
||||
'subject' => (string) $message['subject'],
|
||||
'delivery_status' => (string) ($latest['delivery_status'] ?? 'not sent'),
|
||||
'sent_at' => $latest['sent_at'] ?? null,
|
||||
@@ -161,10 +177,15 @@ final class EnrollmentRegistrationEmailService
|
||||
continue;
|
||||
}
|
||||
|
||||
$evaluation = $this->transitionService->evaluate($studentId, $previousYear, $schoolYearName, 'parent');
|
||||
$evaluation = $this->transitionService->evaluateForParent(
|
||||
(int) $family['parent_user_id'],
|
||||
$studentId,
|
||||
$previousYear,
|
||||
$schoolYearName
|
||||
);
|
||||
$studentRows[] = $this->studentRow($student, $evaluation, $opens, $deadline, $schoolYear);
|
||||
$studentIds[] = $studentId;
|
||||
$hasReEnrollmentEligibleStudent = $hasReEnrollmentEligibleStudent || $this->canCompleteParentReEnrollment($evaluation);
|
||||
$hasReEnrollmentEligibleStudent = $hasReEnrollmentEligibleStudent || (bool) ($evaluation['can_enroll'] ?? false);
|
||||
}
|
||||
|
||||
$financial = $this->financialSection((int) $family['parent_user_id'], $schoolYear, $previousYear);
|
||||
@@ -192,7 +213,7 @@ final class EnrollmentRegistrationEmailService
|
||||
|
||||
private function canCompleteParentReEnrollment(array $evaluation): bool
|
||||
{
|
||||
return (bool) ($evaluation['parent_enrollment_allowed'] ?? false);
|
||||
return (bool) ($evaluation['can_enroll'] ?? false);
|
||||
}
|
||||
|
||||
private function registrationStepsSection(string $opens, string $deadline, bool $include): string
|
||||
@@ -227,7 +248,7 @@ final class EnrollmentRegistrationEmailService
|
||||
$requiredAction = $this->requiredAction($evaluation, $deadline, $name);
|
||||
$message = $this->decisionMessage($name, $evaluation, $opens, $deadline, $schoolYear);
|
||||
$nextStepHtml = $this->decisionMessageHtml($message, (string) ($evaluation['deliberation_decision'] ?? ''));
|
||||
if ($requiredAction !== '') {
|
||||
if ($this->shouldAppendRequiredAction($message, $requiredAction)) {
|
||||
$nextStepHtml .= '<br><strong>' . esc($requiredAction) . '</strong>';
|
||||
}
|
||||
|
||||
@@ -239,7 +260,7 @@ final class EnrollmentRegistrationEmailService
|
||||
. '</td>'
|
||||
. '</tr>'
|
||||
. $this->studentDetailRow('Decision', esc($decision ?: 'Pending'))
|
||||
. $this->studentDetailRow('Registration Status', '<strong>' . esc($status) . '</strong><br>' . esc($placement))
|
||||
. $this->studentDetailRow('Registration Status', '<strong>' . esc($status) . '</strong>' . ($placement !== '' ? '<br>' . esc($placement) : ''))
|
||||
. $this->studentDetailRow('Next Step', $nextStepHtml)
|
||||
. '</tbody></table>';
|
||||
}
|
||||
@@ -272,14 +293,15 @@ final class EnrollmentRegistrationEmailService
|
||||
|
||||
private function decisionMessage(string $name, array $evaluation, string $opens, string $deadline, array $schoolYear = []): string
|
||||
{
|
||||
if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) {
|
||||
$grade = $this->currentGradeText($evaluation);
|
||||
|
||||
return $name . ' has successfully passed ' . $grade . '.';
|
||||
if (($evaluation['can_enroll'] ?? false) === false && ! empty($evaluation['primary_parent_message'])) {
|
||||
return (string) $evaluation['primary_parent_message'];
|
||||
}
|
||||
|
||||
if (($evaluation['blockers'] ?? []) !== []) {
|
||||
return implode(' ', array_map('strval', $evaluation['blockers']));
|
||||
return implode(' ', array_values(array_unique(array_filter(array_map(
|
||||
static fn ($blocker): string => trim((string) $blocker),
|
||||
$evaluation['blockers']
|
||||
)))));
|
||||
}
|
||||
|
||||
return match ((string) ($evaluation['deliberation_decision'] ?? '')) {
|
||||
@@ -318,11 +340,13 @@ final class EnrollmentRegistrationEmailService
|
||||
|
||||
private function financialSection(int $parentId, array $schoolYear, ?string $previousYear): string
|
||||
{
|
||||
$carry = $previousYear !== null ? $this->invoiceBalance($parentId, $previousYear) : 0.0;
|
||||
$registrationFee = (float) ($schoolYear['registration_fee'] ?? 0);
|
||||
$tuition = (float) ($schoolYear['tuition_due_at_registration'] ?? 0);
|
||||
$mandatory = (float) ($schoolYear['mandatory_fees'] ?? 0);
|
||||
$total = max(0, $carry) + $registrationFee + $tuition + $mandatory;
|
||||
$schoolYearName = (string) ($schoolYear['name'] ?? '');
|
||||
$summary = $this->transitionService->getEnrollmentFinancialSummary(
|
||||
$parentId,
|
||||
$previousYear ?? '',
|
||||
$schoolYearName
|
||||
);
|
||||
$total = (float) ($summary['total_enrollment_due'] ?? $summary['amount_due'] ?? 0);
|
||||
if ($total <= 0.0) {
|
||||
return '';
|
||||
}
|
||||
@@ -333,7 +357,7 @@ final class EnrollmentRegistrationEmailService
|
||||
}
|
||||
|
||||
return '<h3>Family Account Information</h3>'
|
||||
. '<p><strong>Carry-over balance:</strong> $' . number_format($carry, 2) . '<br>'
|
||||
. '<p><strong>Carry-over balance:</strong> $' . number_format((float) ($summary['carry_forward_balance'] ?? 0), 2) . '<br>'
|
||||
. '<strong>Total currently due:</strong> $' . number_format($total, 2) . '</p>'
|
||||
. '<p>' . esc($message) . '</p>';
|
||||
}
|
||||
@@ -488,40 +512,73 @@ final class EnrollmentRegistrationEmailService
|
||||
|
||||
private function registrationStatus(array $evaluation): string
|
||||
{
|
||||
if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) {
|
||||
return 'Eligible';
|
||||
if (($evaluation['can_enroll'] ?? false) === true) {
|
||||
return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM
|
||||
? 'Eligible with pending placement'
|
||||
: 'Eligible';
|
||||
}
|
||||
|
||||
if (! empty($evaluation['adult_student'])) {
|
||||
return 'Not Eligible';
|
||||
}
|
||||
|
||||
if (($evaluation['blockers'] ?? []) !== []) {
|
||||
return ($evaluation['adult_student'] ?? false) ? 'Student Action Required' : 'Not Eligible';
|
||||
return 'Not Eligible';
|
||||
}
|
||||
return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM ? 'Eligible with pending placement' : 'Eligible';
|
||||
|
||||
return 'Not Eligible';
|
||||
}
|
||||
|
||||
private function placementText(array $evaluation): string
|
||||
{
|
||||
return match ((string) ($evaluation['placement_status'] ?? '')) {
|
||||
$placement = match ((string) ($evaluation['placement_status'] ?? '')) {
|
||||
'automatic_distribution_pending' => $this->assignedGradeText($evaluation),
|
||||
'same_class_assigned' => 'Same grade and class',
|
||||
'temporary_same_grade' => 'Same grade initially',
|
||||
'manual_class_required' => 'Same grade',
|
||||
'exit_required' => 'Completion or exit process required',
|
||||
default => 'Pending',
|
||||
default => '',
|
||||
};
|
||||
|
||||
if ($placement !== '') {
|
||||
return $placement;
|
||||
}
|
||||
|
||||
return ($evaluation['can_enroll'] ?? false) === true ? 'Pending' : '';
|
||||
}
|
||||
|
||||
private function requiredAction(array $evaluation, string $deadline, string $name = 'The student'): string
|
||||
{
|
||||
if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) {
|
||||
return 'Complete re-enrollment before ' . $deadline . '.';
|
||||
if (($evaluation['can_enroll'] ?? false) === true) {
|
||||
return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM
|
||||
? ''
|
||||
: 'Complete re-enrollment before ' . $deadline . '.';
|
||||
}
|
||||
|
||||
if (($evaluation['blockers'] ?? []) !== []) {
|
||||
return ($evaluation['adult_student'] ?? false) ? 'Student must complete the authorized adult-student process or contact administration.' : 'Contact the school administration.';
|
||||
if (! empty($evaluation['primary_parent_message'])) {
|
||||
return (string) $evaluation['primary_parent_message'];
|
||||
}
|
||||
return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM
|
||||
? ''
|
||||
: 'Complete re-enrollment before ' . $deadline . '.';
|
||||
|
||||
return 'Contact the school administration.';
|
||||
}
|
||||
|
||||
private function shouldAppendRequiredAction(string $message, string $requiredAction): bool
|
||||
{
|
||||
$message = trim($message);
|
||||
$requiredAction = trim($requiredAction);
|
||||
if ($requiredAction === '') {
|
||||
return false;
|
||||
}
|
||||
if ($message === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$normalizedMessage = preg_replace('/\s+/', ' ', strtolower($message)) ?? $message;
|
||||
$normalizedAction = preg_replace('/\s+/', ' ', strtolower($requiredAction)) ?? $requiredAction;
|
||||
|
||||
return $normalizedMessage !== $normalizedAction
|
||||
&& ! str_contains($normalizedMessage, $normalizedAction)
|
||||
&& ! str_contains($normalizedAction, $normalizedMessage);
|
||||
}
|
||||
|
||||
private function previousSchoolYearName(string $schoolYear): ?string
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -214,6 +214,8 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s
|
||||
$refundAmountByParent = []; // parent_id => preview amount for notification context
|
||||
|
||||
$validStatuses = EnrollmentStatusService::VALID_STATUSES;
|
||||
$transitionService = service('enrollmentTransition');
|
||||
$advanceStatuses = $transitionService->statusesRequiringEnrollmentEligibility();
|
||||
|
||||
foreach ($enrollmentStatuses as $studentId => $newEnrollmentStatus) {
|
||||
if (!in_array($newEnrollmentStatus, $validStatuses, true)) {
|
||||
@@ -246,6 +248,16 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($newEnrollmentStatus, $advanceStatuses, true)) {
|
||||
$advance = $transitionService->evaluateEnrollmentAdvance($parentId, (int) $studentId, $this->schoolYear, 'admin');
|
||||
if (! $transitionService->adminMayAdvanceEnrollment($advance)) {
|
||||
$transitionService->logEnrollmentBlock($advance, 'admin_updateStatuses_create', $parentId, $performedBy);
|
||||
$errors[] = "Student ID $studentId cannot be advanced to '$newEnrollmentStatus': "
|
||||
. (string) ($advance['primary_parent_message'] ?? 'Enrollment eligibility check failed.');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$isWithdrawn = in_array($newEnrollmentStatus, ['withdrawn', 'refund pending', 'withdraw under review'], true) ? 1 : 0;
|
||||
|
||||
$result = $enrollmentStatusService->upsertStatus([
|
||||
@@ -323,6 +335,16 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($oldStatus !== $newEnrollmentStatus && in_array($newEnrollmentStatus, $advanceStatuses, true)) {
|
||||
$advance = $transitionService->evaluateEnrollmentAdvance((int) $parentId, (int) $studentId, $this->schoolYear, 'admin');
|
||||
if (! $transitionService->adminMayAdvanceEnrollment($advance)) {
|
||||
$transitionService->logEnrollmentBlock($advance, 'admin_updateStatuses', (int) $parentId, $performedBy);
|
||||
$errors[] = "Student ID $studentId cannot be advanced to '$newEnrollmentStatus': "
|
||||
. (string) ($advance['primary_parent_message'] ?? 'Enrollment eligibility check failed.');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$result = $enrollmentStatusService->upsertStatus([
|
||||
'id' => (int) $enrollmentRow['id'],
|
||||
'student_id' => (int) $studentId,
|
||||
|
||||
@@ -4,6 +4,9 @@ namespace App\Services;
|
||||
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
/**
|
||||
* @deprecated Use EnrollmentRegistrationEmailService instead. This legacy sender is retained for reference only.
|
||||
*/
|
||||
class RegistrationOpeningEmailService
|
||||
{
|
||||
public const TEMPLATE_KEY = 'registration_opening';
|
||||
|
||||
@@ -753,8 +753,9 @@ final class SchoolYearClosingService
|
||||
];
|
||||
|
||||
if ($this->db->fieldExists('description', 'invoices')) {
|
||||
$label = $amount > 0 ? 'Balance carried over' : 'Credit carried over';
|
||||
$payload['description'] = "{$label} from previous school year {$sourceYear}.";
|
||||
$payload['description'] = $amount >= 0
|
||||
? "Carry over balance from last year {$sourceYear}"
|
||||
: "Credit carry over from last year {$sourceYear}";
|
||||
}
|
||||
|
||||
$invoiceModel = new InvoiceModel();
|
||||
@@ -763,7 +764,17 @@ final class SchoolYearClosingService
|
||||
throw new RuntimeException('Unable to create carry-forward invoice: ' . json_encode($invoiceModel->errors()));
|
||||
}
|
||||
|
||||
return (int) $invoiceId;
|
||||
$invoiceId = (int) $invoiceId;
|
||||
$description = (string) ($payload['description'] ?? "Carry over balance from last year {$sourceYear}");
|
||||
try {
|
||||
$ledgerService = new \App\Libraries\InvoiceLedgerService();
|
||||
$ledgerService->issueCarryForwardInvoiceLine($invoiceId, $amount, $description);
|
||||
$ledgerService->recalculateInvoice($invoiceId);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Carry-forward invoice line issuance failed for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $invoiceId;
|
||||
}
|
||||
|
||||
private function carryForwardInvoiceNumber(int $itemId, int $parentId, string $sourceYear, string $targetYear): string
|
||||
|
||||
@@ -8,7 +8,106 @@ final class EnrollmentEligibility
|
||||
public const WITHDRAWN_MESSAGE = 'Re-enrollment is not available because the final deliberation decision is withdrawn. Please contact the school administration if this status needs to be reviewed.';
|
||||
public const DEFERRED_MESSAGE = 'Re-enrollment cannot currently be completed because the final deliberation decision is deferred. Please contact the school administration for the next required step.';
|
||||
public const MISSING_DECISION_MESSAGE = 'Re-enrollment cannot currently be completed because no final deliberation decision is recorded for the student. Registration will become available after the school records a final decision.';
|
||||
public const ADULT_STUDENT_MESSAGE = 'The student will be 18 years old or older on September 1. A parent or guardian cannot complete registration on the student’s behalf. The student must complete the authorized adult-student registration process or contact the school administration.';
|
||||
public const ADULT_STUDENT_MESSAGE = 'This student is over the allowed age for enrollment or re-enrollment at the school. Please contact school administration.';
|
||||
public const ADULT_STUDENT_PARENT_PORTAL_MESSAGE = self::ADULT_STUDENT_MESSAGE;
|
||||
public const WITHDRAWN_PORTAL_MESSAGE = 'This student is currently marked as Withdrawn and cannot be enrolled at this time. Please contact school administration.';
|
||||
public const SIBLING_PORTAL_MESSAGE = 'Enrollment cannot continue because the family record requires administrative review. Please contact school administration.';
|
||||
public const BALANCE_PORTAL_MESSAGE = 'Enrollment cannot continue because there is an outstanding balance. Please contact school administration.';
|
||||
public const EXPELLED_PORTAL_MESSAGE = 'This student cannot be enrolled through the parent portal. Please contact school administration.';
|
||||
public const FINANCE_APPROVAL_PORTAL_MESSAGE = 'Registration requires administrative financial approval. Please contact school administration.';
|
||||
public const ALREADY_ENROLLED_MESSAGE = 'This student is already enrolled for the selected school year.';
|
||||
public const ALREADY_SUBMITTED_MESSAGE = 'This student already has an enrollment for the selected school year.';
|
||||
|
||||
public const ADULT_STUDENT_MIN_AGE = 18;
|
||||
|
||||
/** @var array<string, int> Lower numbers win when selecting the primary parent-facing blocker. */
|
||||
public const BLOCKER_PRIORITY = [
|
||||
'ALREADY_ENROLLED' => 0,
|
||||
'ADULT_STUDENT_PARENT_BLOCKED' => 1,
|
||||
'ADULT_STUDENT_ACTION_REQUIRED' => 1,
|
||||
'EXPELLED' => 2,
|
||||
'WITHDRAWN' => 3,
|
||||
'OUTSTANDING_BALANCE_BLOCKED' => 4,
|
||||
'SIBLING_LAST_NAME_MISMATCH' => 5,
|
||||
'FINANCE_APPROVAL_REQUIRED' => 6,
|
||||
'DEFERRED_DECISION' => 7,
|
||||
'NO_FINAL_DECISION' => 8,
|
||||
'UNRECOGNIZED_DECISION' => 9,
|
||||
];
|
||||
|
||||
public static function messageForRuleCode(string $ruleCode): string
|
||||
{
|
||||
return match ($ruleCode) {
|
||||
'ADULT_STUDENT_PARENT_BLOCKED', 'ADULT_STUDENT_ACTION_REQUIRED' => self::ADULT_STUDENT_PARENT_PORTAL_MESSAGE,
|
||||
'EXPELLED' => self::EXPELLED_PORTAL_MESSAGE,
|
||||
'WITHDRAWN' => self::WITHDRAWN_PORTAL_MESSAGE,
|
||||
'OUTSTANDING_BALANCE_BLOCKED' => self::BALANCE_PORTAL_MESSAGE,
|
||||
'SIBLING_LAST_NAME_MISMATCH' => self::SIBLING_PORTAL_MESSAGE,
|
||||
'FINANCE_APPROVAL_REQUIRED' => self::FINANCE_APPROVAL_PORTAL_MESSAGE,
|
||||
'DEFERRED_DECISION' => self::DEFERRED_MESSAGE,
|
||||
'NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION' => self::MISSING_DECISION_MESSAGE,
|
||||
'ALREADY_ENROLLED' => self::ALREADY_ENROLLED_MESSAGE,
|
||||
default => 'Enrollment cannot continue at this time. Please contact school administration.',
|
||||
};
|
||||
}
|
||||
|
||||
public static function alreadyEnrolledMessage(?string $enrollmentStatus = null): string
|
||||
{
|
||||
return match (strtolower(trim((string) $enrollmentStatus))) {
|
||||
'payment pending' => 'This student already has an enrollment for the selected school year. Payment is still pending.',
|
||||
'admission under review', 'review & decision' => 'This student already has an enrollment submitted for the selected school year and it is under review.',
|
||||
'waitlist' => 'This student is already on the waitlist for the selected school year.',
|
||||
'withdraw under review' => 'This student is already enrolled and has a withdrawal request under review.',
|
||||
'refund pending' => 'This student already has an enrollment for the selected school year. A refund is pending.',
|
||||
'enrolled' => self::ALREADY_ENROLLED_MESSAGE,
|
||||
default => self::ALREADY_SUBMITTED_MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
public static function alreadyEnrolledTitle(?string $enrollmentStatus = null): string
|
||||
{
|
||||
return match (strtolower(trim((string) $enrollmentStatus))) {
|
||||
'payment pending', 'admission under review', 'review & decision' => 'Enrollment Already Submitted',
|
||||
'waitlist' => 'Already on Waitlist',
|
||||
'withdraw under review' => 'Withdrawal Under Review',
|
||||
'refund pending' => 'Refund Pending',
|
||||
default => 'Already Enrolled',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $student
|
||||
*/
|
||||
public static function isMarkedWithdrawn(array $student): bool
|
||||
{
|
||||
if ((int) ($student['is_withdrawn'] ?? 0) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
|
||||
|
||||
return $status === 'withdrawn' || $status === 'withdrawn student';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $blockers
|
||||
*/
|
||||
public static function selectPrimaryBlocker(array $blockers): ?string
|
||||
{
|
||||
$blockers = array_values(array_unique(array_filter(array_map('strval', $blockers))));
|
||||
if ($blockers === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($blockers, static function (string $a, string $b): int {
|
||||
$priorityA = self::BLOCKER_PRIORITY[$a] ?? 999;
|
||||
$priorityB = self::BLOCKER_PRIORITY[$b] ?? 999;
|
||||
|
||||
return $priorityA <=> $priorityB ?: strcmp($a, $b);
|
||||
});
|
||||
|
||||
return $blockers[0];
|
||||
}
|
||||
|
||||
public static function ageOnSeptemberFirst(?string $dob, string $targetSchoolYear): ?int
|
||||
{
|
||||
@@ -50,7 +149,12 @@ final class EnrollmentEligibility
|
||||
return self::message($name . ': ' . self::EXPELLED_MESSAGE, true, 'danger');
|
||||
}
|
||||
|
||||
if ($decision === DeliberationDecision::WITHDRAWN || ($student['enrollment_status'] ?? null) === 'withdrawn') {
|
||||
$age = self::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear);
|
||||
if ($age !== null && $age >= self::ADULT_STUDENT_MIN_AGE) {
|
||||
return self::message(str_replace('The student', $name, self::ADULT_STUDENT_MESSAGE), true, 'danger');
|
||||
}
|
||||
|
||||
if ($decision === DeliberationDecision::WITHDRAWN || self::isMarkedWithdrawn($student)) {
|
||||
return self::message($name . ': ' . self::WITHDRAWN_MESSAGE, true, 'warning');
|
||||
}
|
||||
|
||||
@@ -62,11 +166,6 @@ final class EnrollmentEligibility
|
||||
return self::message($name . ': ' . self::MISSING_DECISION_MESSAGE, true, 'warning');
|
||||
}
|
||||
|
||||
$age = self::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear);
|
||||
if ($age !== null && $age >= 18) {
|
||||
return self::message(str_replace('The student', $name, self::ADULT_STUDENT_MESSAGE), true, 'danger');
|
||||
}
|
||||
|
||||
if ($decision === DeliberationDecision::MAKE_UP_EXAM) {
|
||||
$dateText = $fallMakeupExamOn !== null ? ' on ' . local_date($fallMakeupExamOn, 'm-d-Y') : '';
|
||||
return self::message(
|
||||
|
||||
@@ -48,6 +48,18 @@
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
}
|
||||
.enrollment-admin .issue-badge.adult-student,
|
||||
.enrollment-admin .adult-student-name {
|
||||
background-color: #b02a37;
|
||||
color: #fff;
|
||||
}
|
||||
.enrollment-admin .adult-student-name {
|
||||
display: inline-block;
|
||||
border-radius: .25rem;
|
||||
font-weight: 600;
|
||||
padding: .15rem .35rem;
|
||||
margin: .1rem .15rem .1rem 0;
|
||||
}
|
||||
</style>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -62,12 +74,11 @@ if (!function_exists('enrollment_admin_flag_label')) {
|
||||
'AGE_EXCEPTION_REQUIRED' => 'Age exception',
|
||||
'LATE_REGISTRATION_EXCEPTION' => 'Late registration',
|
||||
'FINANCIAL_REVIEW_REQUIRED' => 'Finance review',
|
||||
'CLASS_CAPACITY_EXCEPTION_REQUIRED' => 'Class capacity',
|
||||
'DEFERRED_DELIBERATION' => 'Decision review',
|
||||
'RESTRICTED_ADMINISTRATIVE_REVIEW' => 'Restricted review',
|
||||
'WITHDRAWAL_REVIEW_REQUIRED' => 'Withdrawal review',
|
||||
'COMPLETION_OR_EXIT_PROCESS_REQUIRED' => 'Exit / completion',
|
||||
'ADULT_STUDENT_ACTION_REQUIRED' => 'Adult student',
|
||||
'ADULT_STUDENT_ACTION_REQUIRED', 'ADULT_STUDENT_PARENT_BLOCKED' => 'Adult student',
|
||||
'SIBLING_LAST_NAME_MISMATCH' => 'Sibling last names',
|
||||
'NO_FINAL_DECISION' => 'No final decision',
|
||||
'UNRECOGNIZED_DECISION' => 'Unrecognized decision',
|
||||
@@ -110,8 +121,9 @@ if (!function_exists('enrollment_admin_issue_badge_class')) {
|
||||
return match (strtoupper($code)) {
|
||||
'NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION', 'EXPELLED', 'DENIED', 'RESTRICTED_ADMINISTRATIVE_REVIEW' => 'bg-danger',
|
||||
'DEFERRED_DECISION', 'DEFERRED_DELIBERATION', 'FINANCIAL_REVIEW_REQUIRED', 'OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED' => 'bg-warning text-dark',
|
||||
'PENDING_MAKE_UP_EXAM_PROMOTION', 'MAKE_UP_EXAM', 'AGE_EXCEPTION_REQUIRED', 'ADULT_STUDENT_ACTION_REQUIRED' => 'bg-info text-dark',
|
||||
'CLASS_REASSIGNMENT_REQUIRED', 'CLASS_CAPACITY_EXCEPTION_REQUIRED' => 'bg-primary',
|
||||
'ADULT_STUDENT_ACTION_REQUIRED', 'ADULT_STUDENT_PARENT_BLOCKED' => 'adult-student',
|
||||
'PENDING_MAKE_UP_EXAM_PROMOTION', 'MAKE_UP_EXAM', 'AGE_EXCEPTION_REQUIRED' => 'bg-info text-dark',
|
||||
'CLASS_REASSIGNMENT_REQUIRED' => 'bg-primary',
|
||||
'WITHDRAWN', 'WITHDRAWAL_REVIEW_REQUIRED', 'COMPLETION_OR_EXIT_PROCESS_REQUIRED' => 'bg-dark',
|
||||
'SIBLING_LAST_NAME_MISMATCH', 'LATE_REGISTRATION_EXCEPTION' => 'bg-secondary',
|
||||
default => 'bg-secondary',
|
||||
@@ -147,7 +159,7 @@ if (!function_exists('enrollment_admin_rule_label')) {
|
||||
'AGE_RULE_BLOCKED' => 'Age rule not met',
|
||||
'REGISTRATION_CLOSED' => 'Registration is closed',
|
||||
'REGISTRATION_NOT_OPEN' => 'Registration is not open yet',
|
||||
'ADULT_STUDENT_PARENT_BLOCKED' => 'Adult student cannot be enrolled by a parent',
|
||||
'ADULT_STUDENT_PARENT_BLOCKED' => 'Adult students cannot enroll or re-enroll at the school',
|
||||
'EXIT_REQUIRED' => 'Exit / completion required',
|
||||
'EXPELLED' => 'Expelled — administrative review',
|
||||
'WITHDRAWN' => 'Withdrawn — review required',
|
||||
@@ -405,7 +417,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
|
||||
<input class="form-control form-control-sm mb-2" name="resolution_notes" placeholder="Resolution notes" required>
|
||||
<button class="btn btn-sm btn-primary" type="submit">Save exam result</button>
|
||||
</form>
|
||||
<?php elseif (in_array($flagTypeValue, ['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'], true) && !empty($canManageEnrollmentExceptions)): ?>
|
||||
<?php elseif (in_array($flagTypeValue, ['AGE_EXCEPTION_REQUIRED', 'LATE_REGISTRATION_EXCEPTION', 'FINANCIAL_REVIEW_REQUIRED', 'SIBLING_LAST_NAME_MISMATCH', 'DEFERRED_DELIBERATION', 'WITHDRAWAL_REVIEW_REQUIRED', 'RESTRICTED_ADMINISTRATIVE_REVIEW'], true) && !empty($canManageEnrollmentExceptions)): ?>
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/flags/' . $flagId . '/approve-exception') ?>" class="mb-2">
|
||||
<?= csrf_field() ?>
|
||||
<div class="small fw-semibold mb-1">Approve an exception for this student</div>
|
||||
@@ -528,7 +540,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="bypassed_rule_codes_by_student[<?= $studentId ?>][]" id="bypass_<?= $studentId ?>_<?= esc($code) ?>" value="<?= esc($code) ?>" <?= $isBypassable ? 'checked' : 'disabled' ?>>
|
||||
<label class="form-check-label small" for="bypass_<?= $studentId ?>_<?= esc($code) ?>">
|
||||
<?= esc(enrollment_admin_rule_label($code)) ?><?= $isBypassable ? '' : ' (cannot bypass)' ?>
|
||||
<?= esc(enrollment_admin_rule_label($code)) ?><?= $isBypassable ? '' : ' ' ?>
|
||||
</label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
@@ -562,9 +574,8 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label class="form-label" for="expires_at">Expires</label>
|
||||
<input class="form-control form-control-sm" id="expires_at" name="expires_at" type="datetime-local">
|
||||
<div class="col-md-7 d-flex align-items-end">
|
||||
<div class="form-text mb-2">Applies for the selected school year (<?= esc($schoolYear ?? '') ?>).</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="reason_note">Approval note</label>
|
||||
@@ -601,7 +612,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
|
||||
<th>Reason</th>
|
||||
<th>Bypassed rules</th>
|
||||
<th>Created by</th>
|
||||
<th>Expires</th>
|
||||
<th>School year</th>
|
||||
<th class="no-sort">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -633,7 +644,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
|
||||
</td>
|
||||
<td><?= esc($bypassedLabels !== [] ? implode(', ', $bypassedLabels) : 'None') ?></td>
|
||||
<td><?= esc($exception['created_by_name'] ?? '') ?></td>
|
||||
<td><?= esc(!empty($exception['expires_at']) ? local_datetime($exception['expires_at'], 'm-d-Y H:i') : 'No expiry') ?></td>
|
||||
<td><?= esc($exception['school_year'] ?? ($schoolYear ?? '')) ?></td>
|
||||
<td>
|
||||
<?php if ($exceptionStatus === 'active' && !empty($canManageEnrollmentExceptions)): ?>
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/exceptions/' . $exceptionId . '/revoke') ?>">
|
||||
@@ -751,7 +762,20 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
|
||||
<tr>
|
||||
<td><?= esc($example['parent_name'] ?? '') ?></td>
|
||||
<td><?= esc(implode(', ', $example['recipients'] ?? [])) ?></td>
|
||||
<td><?= esc(implode(', ', $example['student_names'] ?? [])) ?></td>
|
||||
<td>
|
||||
<?php if (!empty($example['students']) && is_array($example['students'])): ?>
|
||||
<?php foreach ($example['students'] as $studentIndex => $student): ?>
|
||||
<?= $studentIndex > 0 ? ', ' : '' ?>
|
||||
<?php if (!empty($student['adult_student'])): ?>
|
||||
<span class="adult-student-name"><?= esc($student['name'] ?? '') ?></span>
|
||||
<?php else: ?>
|
||||
<span><?= esc($student['name'] ?? '') ?></span>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<?= esc(implode(', ', $example['student_names'] ?? [])) ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= esc($example['subject'] ?? '') ?></td>
|
||||
<td>
|
||||
<span class="badge <?= esc($badgeClass) ?>"><?= esc($deliveryLabel) ?></span>
|
||||
|
||||
@@ -9,22 +9,51 @@
|
||||
<?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'] ?? ''))) ?>
|
||||
<?= student_enrollment_status_button($student, $schoolYear ?? null) ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div><strong>Parent:</strong> <?= esc(trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? ''))) ?></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>Parent statement</strong></div>
|
||||
<p class="mb-2"><?= nl2br(esc($requestRow['need_statement'] ?? '')) ?></p>
|
||||
<div><strong>Students</strong></div>
|
||||
<ul class="mb-0">
|
||||
<?php foreach (($students ?? []) as $student): ?>
|
||||
<li>
|
||||
<?= esc(trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''))) ?>
|
||||
<?= student_enrollment_status_button($student, $schoolYear ?? null) ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h5 class="mb-3">Aid estimate calculator</h5>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="calc_household_income">Household income</label>
|
||||
<input class="form-control" type="number" min="0" step="0.01" id="calc_household_income"
|
||||
value="<?= esc((string) ($requestRow['household_income'] ?? '0')) ?>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="calc_household_size">Household size</label>
|
||||
<input class="form-control" type="number" min="1" id="calc_household_size"
|
||||
value="<?= esc((string) max(1, (int) ($requestRow['household_size'] ?? 1))) ?>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="calc_invoice_balance">Invoice balance</label>
|
||||
<input class="form-control" type="number" min="0" step="0.01" id="calc_invoice_balance"
|
||||
value="<?= esc(number_format((float) ($invoiceBalance ?? 0), 2, '.', '')) ?>" readonly>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary mb-3" id="calcFinancialAidBtn">Calculate / Recalculate</button>
|
||||
<div id="calcFinancialAidError" class="alert alert-danger d-none py-2"></div>
|
||||
<div id="calcFinancialAidSuccess" class="alert alert-success d-none py-2">Financial aid calculation completed.</div>
|
||||
<div class="border rounded p-3 bg-light">
|
||||
<div class="small text-muted">Estimated aid</div>
|
||||
<div class="fs-4 fw-semibold" id="calcEstimatedAid">$<?= number_format((float) ($estimatedAid ?? 0), 2) ?></div>
|
||||
<div class="small text-muted mt-2">Capped by request and balance</div>
|
||||
<div class="fw-semibold" id="calcFinalRebate">$<?= number_format((float) ($finalRebate ?? 0), 2) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (in_array((string) ($requestRow['status'] ?? ''), ['submitted', 'under_review'], true)): ?>
|
||||
@@ -53,3 +82,138 @@
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<script>
|
||||
(function () {
|
||||
const btn = document.getElementById('calcFinancialAidBtn');
|
||||
const errorBox = document.getElementById('calcFinancialAidError');
|
||||
const successBox = document.getElementById('calcFinancialAidSuccess');
|
||||
const estimatedEl = document.getElementById('calcEstimatedAid');
|
||||
const finalEl = document.getElementById('calcFinalRebate');
|
||||
const csrfName = <?= json_encode(csrf_token()) ?>;
|
||||
const csrfHeaderName = <?= json_encode(csrf_header()) ?>;
|
||||
let csrfHash = <?= json_encode(csrf_hash()) ?>;
|
||||
const csrfCookieNames = <?= json_encode(array_values(array_unique(array_filter([
|
||||
config('Security')->cookieName ?? null,
|
||||
config('Security')->csrfCookieName ?? null,
|
||||
'csrf_cookie_name',
|
||||
])))) ?>;
|
||||
|
||||
function fmtUSD(value) {
|
||||
return '$' + Number(value || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
return document.cookie.split(';')
|
||||
.map((value) => value.trim())
|
||||
.find((value) => value.startsWith(name + '='))?.split('=')[1] || '';
|
||||
}
|
||||
|
||||
function refreshCsrfFromCookie() {
|
||||
for (const cookieName of csrfCookieNames) {
|
||||
const value = decodeURIComponent(getCookie(cookieName) || '');
|
||||
if (value) {
|
||||
csrfHash = value;
|
||||
syncCsrfFields();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncCsrfFields() {
|
||||
document.querySelectorAll('input[name="' + csrfName + '"]').forEach((input) => {
|
||||
input.value = csrfHash;
|
||||
});
|
||||
}
|
||||
|
||||
function captureCsrf(resp, data) {
|
||||
const headerValue = resp.headers.get(csrfHeaderName) || resp.headers.get('X-CSRF-HASH') || '';
|
||||
if (headerValue) {
|
||||
csrfHash = headerValue;
|
||||
syncCsrfFields();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.csrf_hash) {
|
||||
csrfHash = data.csrf_hash;
|
||||
syncCsrfFields();
|
||||
return;
|
||||
}
|
||||
if (data?.csrfHash) {
|
||||
csrfHash = data.csrfHash;
|
||||
syncCsrfFields();
|
||||
return;
|
||||
}
|
||||
|
||||
refreshCsrfFromCookie();
|
||||
}
|
||||
|
||||
document.querySelectorAll(
|
||||
'form[action$="/approve"], form[action$="/deny"]'
|
||||
).forEach((form) => {
|
||||
form.addEventListener('submit', () => {
|
||||
refreshCsrfFromCookie();
|
||||
syncCsrfFields();
|
||||
});
|
||||
});
|
||||
|
||||
btn?.addEventListener('click', async function () {
|
||||
if (errorBox) {
|
||||
errorBox.classList.add('d-none');
|
||||
errorBox.textContent = '';
|
||||
}
|
||||
if (successBox) {
|
||||
successBox.classList.add('d-none');
|
||||
}
|
||||
|
||||
refreshCsrfFromCookie();
|
||||
|
||||
const body = new URLSearchParams();
|
||||
body.set('household_income', document.getElementById('calc_household_income')?.value ?? '');
|
||||
body.set('household_size', document.getElementById('calc_household_size')?.value ?? '');
|
||||
body.set(csrfName, csrfHash);
|
||||
|
||||
try {
|
||||
const resp = await fetch(<?= json_encode(site_url('administrator/financial-aid/' . (int) ($requestRow['id'] ?? 0) . '/estimate')) ?>, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
[csrfHeaderName]: csrfHash,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: body.toString(),
|
||||
});
|
||||
const text = await resp.text();
|
||||
let data = {};
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch (parseErr) {
|
||||
throw new Error('Unable to calculate estimated aid.');
|
||||
}
|
||||
captureCsrf(resp, data);
|
||||
if (!resp.ok) {
|
||||
throw new Error(data.error || data.message || 'Unable to calculate estimated aid.');
|
||||
}
|
||||
|
||||
if (estimatedEl) estimatedEl.textContent = fmtUSD(data.estimated_aid);
|
||||
if (finalEl) finalEl.textContent = fmtUSD(data.final_rebate);
|
||||
const balanceInput = document.getElementById('calc_invoice_balance');
|
||||
if (balanceInput && data.invoice_balance !== undefined) {
|
||||
balanceInput.value = Number(data.invoice_balance).toFixed(2);
|
||||
}
|
||||
if (successBox) {
|
||||
successBox.textContent = 'Financial aid calculation completed.';
|
||||
successBox.classList.remove('d-none');
|
||||
}
|
||||
} catch (err) {
|
||||
if (errorBox) {
|
||||
errorBox.textContent = err.message || 'Unable to calculate estimated aid.';
|
||||
errorBox.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -54,12 +54,26 @@ $selectedClasses = array_values(array_intersect(range(1,13), $selectedClasses));
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Category</label>
|
||||
<?php
|
||||
$selectedCategoryId = (int) old('category_id', $item['category_id'] ?? 0);
|
||||
$selectedCat = null;
|
||||
if ($selectedCategoryId > 0) {
|
||||
foreach ($categories as $c) {
|
||||
if ((int)($c['id'] ?? 0) === $selectedCategoryId) {
|
||||
$selectedCat = $c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$editGmin = old('grade_min', $selectedCat['grade_min'] ?? '');
|
||||
$editGmax = old('grade_max', $selectedCat['grade_max'] ?? '');
|
||||
?>
|
||||
<select class="form-select" name="category_id" id="bookCategorySelect">
|
||||
<option value="">— None —</option>
|
||||
<?php foreach ($categories as $c): ?>
|
||||
<?php
|
||||
$cid = (int)($c['id'] ?? 0);
|
||||
$sel = isset($item['category_id']) && (int)$item['category_id'] === $cid ? 'selected' : '';
|
||||
$sel = $selectedCategoryId === $cid ? 'selected' : '';
|
||||
$gmin = $c['grade_min'] ?? '';
|
||||
$gmax = $c['grade_max'] ?? '';
|
||||
?>
|
||||
@@ -71,9 +85,20 @@ $selectedClasses = array_values(array_intersect(range(1,13), $selectedClasses));
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="row g-2 mt-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label" for="bookGradeMin">Grade Min</label>
|
||||
<input type="number" class="form-control" name="grade_min" id="bookGradeMin"
|
||||
min="0" max="13" value="<?= esc((string)$editGmin) ?>">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label" for="bookGradeMax">Grade Max</label>
|
||||
<input type="number" class="form-control" name="grade_max" id="bookGradeMax"
|
||||
min="0" max="13" value="<?= esc((string)$editGmax) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Grade Range:
|
||||
<span id="catGradeRange">—</span>
|
||||
Grade range is saved on the selected category (shared by all books in that category).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,20 +166,35 @@ $selectedClasses = array_values(array_intersect(range(1,13), $selectedClasses));
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Category grade range helper
|
||||
const sel = document.getElementById('bookCategorySelect');
|
||||
const out = document.getElementById('catGradeRange');
|
||||
function label(opt) {
|
||||
if (!opt) { out.textContent = '—'; return; }
|
||||
const gmin = opt.getAttribute('data-gmin');
|
||||
const gmax = opt.getAttribute('data-gmax');
|
||||
if ((gmin && gmin !== '') && (gmax && gmax !== '')) out.textContent = `G${gmin}–G${gmax}`;
|
||||
else if (gmin && gmin !== '') out.textContent = `G${gmin}+`;
|
||||
else if (gmax && gmax !== '') out.textContent = `≤G${gmax}`;
|
||||
else out.textContent = '—';
|
||||
const gminInput = document.getElementById('bookGradeMin');
|
||||
const gmaxInput = document.getElementById('bookGradeMax');
|
||||
|
||||
function syncGradeInputsFromCategory() {
|
||||
if (!sel || !gminInput || !gmaxInput) return;
|
||||
const hasCategory = sel.value !== '';
|
||||
if (!hasCategory) {
|
||||
gminInput.value = '';
|
||||
gmaxInput.value = '';
|
||||
return;
|
||||
}
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
gminInput.value = opt?.getAttribute('data-gmin') ?? '';
|
||||
gmaxInput.value = opt?.getAttribute('data-gmax') ?? '';
|
||||
}
|
||||
sel?.addEventListener('change', () => label(sel.options[sel.selectedIndex]));
|
||||
if (sel) label(sel.options[sel.selectedIndex]); // init on load
|
||||
|
||||
// Keep option dataset in sync so re-selecting the same category shows latest typed values
|
||||
function mirrorInputsOntoSelectedOption() {
|
||||
if (!sel || !sel.value) return;
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
if (!opt) return;
|
||||
opt.setAttribute('data-gmin', gminInput?.value ?? '');
|
||||
opt.setAttribute('data-gmax', gmaxInput?.value ?? '');
|
||||
}
|
||||
|
||||
sel?.addEventListener('change', syncGradeInputsFromCategory);
|
||||
gminInput?.addEventListener('change', mirrorInputsOntoSelectedOption);
|
||||
gmaxInput?.addEventListener('change', mirrorInputsOntoSelectedOption);
|
||||
|
||||
// Classes select all / clear
|
||||
const selectAllBtn = document.getElementById('btnSelectAllClasses');
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<a class="btn btn-outline-primary" href="<?= site_url('inventory/book-prices') ?>">Book Prices</a>
|
||||
<a class="btn btn-primary" href="<?= site_url('inventory/create/book') ?>">Add Book</a>
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#addCategoryModal">Add Category</button>
|
||||
<button class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#editCategoryModal">Edit Category</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +66,19 @@
|
||||
<td><?= esc($i['isbn']) ?></td>
|
||||
<td><?= esc($i['edition']) ?></td>
|
||||
<td><?= esc($catName) ?></td>
|
||||
<td><?= esc($catGrades) ?></td> <!-- NEW -->
|
||||
<td>
|
||||
<?php if (!empty($cat['id'])): ?>
|
||||
<button type="button"
|
||||
class="btn btn-link p-0 text-decoration-none js-edit-category-grades"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#editCategoryModal"
|
||||
data-category-id="<?= (int) $cat['id'] ?>">
|
||||
<?= esc($catGrades) ?>
|
||||
</button>
|
||||
<?php else: ?>
|
||||
<?= esc($catGrades) ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= esc($i['quantity']) ?></td>
|
||||
<td><?= esc($i['unit']) ?></td>
|
||||
<td><?= esc($userNames[(int)($i['updated_by'] ?? 0)] ?? '—') ?></td>
|
||||
@@ -102,7 +115,6 @@
|
||||
<input type="text" name="name" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<!-- NEW: Grade range -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Grade Min</label>
|
||||
@@ -127,6 +139,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Category Modal -->
|
||||
<div class="modal fade" id="editCategoryModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" method="post" action="<?= site_url('inventory/category/save') ?>" id="editCategoryForm">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="type" value="book">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit Category (Books)</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Category</label>
|
||||
<select class="form-select" name="id" id="editCategorySelect" required>
|
||||
<option value="">— Select category —</option>
|
||||
<?php foreach ($categories as $c): ?>
|
||||
<option
|
||||
value="<?= (int)($c['id'] ?? 0) ?>"
|
||||
data-name="<?= esc($c['name'] ?? '', 'attr') ?>"
|
||||
data-description="<?= esc($c['description'] ?? '', 'attr') ?>"
|
||||
data-gmin="<?= esc((string)($c['grade_min'] ?? ''), 'attr') ?>"
|
||||
data-gmax="<?= esc((string)($c['grade_max'] ?? ''), 'attr') ?>"
|
||||
><?= esc(($c['name'] ?? '') . ($gradeLabel($c) !== '—' ? ' (' . $gradeLabel($c) . ')' : '')) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" name="name" id="editCategoryName" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Grade Min</label>
|
||||
<input type="number" name="grade_min" id="editCategoryGradeMin" class="form-control" min="0" max="13" placeholder="e.g., 1">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Grade Max</label>
|
||||
<input type="number" name="grade_max" id="editCategoryGradeMax" class="form-control" min="0" max="13" placeholder="e.g., 3">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<small class="text-muted">Optional. Changing grade range updates every book in this category.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-0">
|
||||
<label class="form-label">Description (optional)</label>
|
||||
<textarea name="description" id="editCategoryDescription" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"><button type="submit" class="btn btn-primary" id="editCategorySaveBtn">Update Category</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
<script>
|
||||
document.getElementById('categoryFilter')?.addEventListener('change', function() {
|
||||
const val = this.value;
|
||||
@@ -134,6 +205,32 @@ document.getElementById('categoryFilter')?.addEventListener('change', function()
|
||||
tr.style.display = (!val || tr.getAttribute('data-category') === val) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
(function() {
|
||||
const select = document.getElementById('editCategorySelect');
|
||||
const nameInput = document.getElementById('editCategoryName');
|
||||
const descInput = document.getElementById('editCategoryDescription');
|
||||
const gminInput = document.getElementById('editCategoryGradeMin');
|
||||
const gmaxInput = document.getElementById('editCategoryGradeMax');
|
||||
|
||||
function fillEditCategory() {
|
||||
const opt = select?.options[select.selectedIndex];
|
||||
const has = !!(select && select.value);
|
||||
if (nameInput) nameInput.value = has ? (opt.getAttribute('data-name') || '') : '';
|
||||
if (descInput) descInput.value = has ? (opt.getAttribute('data-description') || '') : '';
|
||||
if (gminInput) gminInput.value = has ? (opt.getAttribute('data-gmin') || '') : '';
|
||||
if (gmaxInput) gmaxInput.value = has ? (opt.getAttribute('data-gmax') || '') : '';
|
||||
}
|
||||
|
||||
select?.addEventListener('change', fillEditCategory);
|
||||
document.getElementById('editCategoryModal')?.addEventListener('show.bs.modal', function (e) {
|
||||
const trigger = e.relatedTarget;
|
||||
const preselect = trigger && trigger.getAttribute ? trigger.getAttribute('data-category-id') : null;
|
||||
if (preselect && select) {
|
||||
select.value = preselect;
|
||||
}
|
||||
fillEditCategory();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -126,13 +126,22 @@
|
||||
return name;
|
||||
}
|
||||
function renderInvoiceRow(r) {
|
||||
const isCarryForward = !!r.is_carry_forward;
|
||||
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
||||
const genBtn = `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
|
||||
const pdf = r.invoice_id ? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>` : '<span class=\"text-muted\">No invoice yet</span>';
|
||||
const genBtn = isCarryForward
|
||||
? '<span class="text-muted small">Audit only</span>'
|
||||
: `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
|
||||
const invoiceLabel = isCarryForward
|
||||
? `<div><span class="badge bg-secondary me-1">Carry-over</span>${esc(r.invoice_description || 'Carry over balance')}</div>`
|
||||
+ (r.invoice_number ? `<div class="text-muted small">${esc(r.invoice_number)}</div>` : '')
|
||||
: renderStudents(r.enrolledKids || []);
|
||||
const pdf = r.invoice_id
|
||||
? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>`
|
||||
: (isCarryForward ? '<span class=\"text-muted\">—</span>' : '<span class=\"text-muted\">No invoice yet</span>');
|
||||
|
||||
return [
|
||||
renderParentCell(r),
|
||||
renderStudents(r.enrolledKids || []),
|
||||
invoiceLabel,
|
||||
genBtn,
|
||||
fmtMoney(r.invoice_amount),
|
||||
renderRefundCell(r),
|
||||
|
||||
@@ -254,17 +254,35 @@ $statusBadge = static function (?string $status): string {
|
||||
default => '<span class="badge bg-light text-dark">unknown</span>',
|
||||
};
|
||||
};
|
||||
$alreadyEnrolledMessage = static fn (?string $status): string => \App\Support\Enrollment\EnrollmentEligibility::alreadyEnrolledMessage($status);
|
||||
$alreadyEnrolledTitle = static fn (?string $status): string => \App\Support\Enrollment\EnrollmentEligibility::alreadyEnrolledTitle($status);
|
||||
$hasSettledEnrollmentStatus = static function (?string $status, ?string $admissionStatus = ''): bool {
|
||||
if (strtolower(trim((string) $admissionStatus)) === 'accepted') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim((string) $status)), [
|
||||
'admission under review',
|
||||
'review & decision',
|
||||
'payment pending',
|
||||
'enrolled',
|
||||
'waitlist',
|
||||
'withdraw under review',
|
||||
'refund pending',
|
||||
], true);
|
||||
};
|
||||
$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'])) {
|
||||
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
|
||||
if (($evaluation['can_enroll'] ?? false) === true && !$deadlinePassed && $isEditable) {
|
||||
$enrollableCount++;
|
||||
}
|
||||
if (($student['enrollment_status'] ?? '') === 'enrolled' && $isEditable) {
|
||||
$withdrawableCount++;
|
||||
}
|
||||
}
|
||||
$studentCount = count($students ?? []);
|
||||
?>
|
||||
|
||||
<div class="container my-4">
|
||||
@@ -311,15 +329,17 @@ foreach (($students ?? []) as $student) {
|
||||
<button type="button"
|
||||
class="btn btn-success btn-lg w-100"
|
||||
id="startEnrollmentButton"
|
||||
<?= (!$isEditable || $enrollableCount === 0 || $deadlinePassed) ? 'disabled' : '' ?>>
|
||||
<?= (!$isEditable || $studentCount === 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 ($studentCount === 0): ?>
|
||||
<div class="small text-muted mt-2">No students are currently linked to this account.</div>
|
||||
<?php elseif ($enrollableCount === 0): ?>
|
||||
<div class="small text-muted mt-2">No students are currently available for new enrollment.</div>
|
||||
<div class="small text-muted mt-2">No students are currently eligible for enrollment. You may still click Start Enrollment to review the reason.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -332,6 +352,7 @@ foreach (($students ?? []) as $student) {
|
||||
<th>Decision</th>
|
||||
<th>Required Action</th>
|
||||
<th>Status</th>
|
||||
<th>Eligibility</th>
|
||||
<th>Withdraw</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -352,8 +373,24 @@ foreach (($students ?? []) as $student) {
|
||||
</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="Required Action"><?= esc($student['parent_enrollment_state'] ?? $student['required_action_label'] ?? 'Contact administration') ?></td>
|
||||
<td data-label="Status"><?= $statusBadge($student['enrollment_status'] ?? '') ?></td>
|
||||
<td data-label="Eligibility">
|
||||
<?php
|
||||
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
|
||||
if ($hasSettledEnrollmentStatus($student['enrollment_status'] ?? '', $student['admission_status'] ?? '')) {
|
||||
echo '<span class="text-muted small">' . esc($alreadyEnrolledMessage((string) ($student['enrollment_status'] ?? ''))) . '</span>';
|
||||
} elseif (($evaluation['can_enroll'] ?? false) === true) {
|
||||
echo '<span class="text-success small">Eligible</span>';
|
||||
} elseif (! empty($evaluation['primary_parent_message'])) {
|
||||
echo '<span class="text-danger small">' . esc($evaluation['primary_parent_message']) . '</span>';
|
||||
} elseif (! empty($student['enrollment_eligibility_message']['message'])) {
|
||||
echo '<span class="text-danger small">' . esc($student['enrollment_eligibility_message']['message']) . '</span>';
|
||||
} else {
|
||||
echo '<span class="text-muted small">Not eligible</span>';
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
<td data-label="Withdraw">
|
||||
<?php if (($student['enrollment_status'] ?? '') === 'enrolled'): ?>
|
||||
<div class="form-check form-switch m-0 enrollment-withdraw-control">
|
||||
@@ -464,12 +501,19 @@ foreach (($students ?? []) as $student) {
|
||||
<?php foreach ($students as $student): ?>
|
||||
<?php
|
||||
$eligibilityMessage = is_array($student['enrollment_eligibility_message'] ?? null) ? $student['enrollment_eligibility_message'] : ['message' => '', 'blocking' => false, 'level' => 'info'];
|
||||
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
|
||||
$studentName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? ''));
|
||||
$studentName = $studentName !== '' ? $studentName : 'Student';
|
||||
$canEnroll = ($student['enrollment_status'] ?? '') === 'not enrolled'
|
||||
$canEnroll = ($evaluation['can_enroll'] ?? false) === true
|
||||
&& !$deadlinePassed
|
||||
&& $isEditable
|
||||
&& empty($eligibilityMessage['blocking']);
|
||||
&& $isEditable;
|
||||
$hasSettledEnrollment = $hasSettledEnrollmentStatus($student['enrollment_status'] ?? '', $student['admission_status'] ?? '');
|
||||
$blockMessage = $hasSettledEnrollment
|
||||
? $alreadyEnrolledMessage((string) ($student['enrollment_status'] ?? ''))
|
||||
: (string) ($evaluation['primary_parent_message'] ?? $eligibilityMessage['message'] ?? '');
|
||||
$blockTitle = $hasSettledEnrollment
|
||||
? $alreadyEnrolledTitle((string) ($student['enrollment_status'] ?? ''))
|
||||
: 'Enrollment Not Available';
|
||||
$section = $student['class_section'] ?? null;
|
||||
$gradeLabel = $section
|
||||
? preg_replace_callback('/\byouth\b/i', fn($m) => ucfirst(strtolower($m[0])), (string) $section)
|
||||
@@ -502,7 +546,10 @@ foreach (($students ?? []) as $student) {
|
||||
data-required-action="<?= esc($student['required_action_label'] ?? 'Contact administration') ?>"
|
||||
data-expected-placement="<?= esc($student['expected_placement_label'] ?? $gradeLabel) ?>"
|
||||
data-is-new="<?= (string) ($student['is_new'] ?? '1') === '1' ? '1' : '0' ?>"
|
||||
data-selectable="<?= $canEnroll ? '1' : '0' ?>">
|
||||
data-selectable="<?= $canEnroll ? '1' : '0' ?>"
|
||||
data-already-enrolled="<?= $hasSettledEnrollment ? '1' : '0' ?>"
|
||||
data-block-title="<?= esc($blockTitle) ?>"
|
||||
data-block-message="<?= esc($blockMessage) ?>">
|
||||
<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">
|
||||
@@ -622,7 +669,11 @@ foreach (($students ?? []) as $student) {
|
||||
value="<?= esc($parentContact['cellphone'] ?? '') ?>"
|
||||
maxlength="12"
|
||||
inputmode="numeric"
|
||||
autocomplete="tel"
|
||||
title="Enter a valid 10-digit phone number (e.g., 123-456-7890)"
|
||||
required>
|
||||
<div class="form-text text-muted">10-digit US phone number (e.g., 123-456-7890).</div>
|
||||
<div id="parent-contact-cellphone-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label small fw-semibold" for="parent-contact-street">Home street address</label>
|
||||
@@ -631,8 +682,12 @@ foreach (($students ?? []) as $student) {
|
||||
id="parent-contact-street"
|
||||
name="parent_contact[address_street]"
|
||||
value="<?= esc($parentContact['address_street'] ?? '') ?>"
|
||||
maxlength="255"
|
||||
maxlength="50"
|
||||
autocomplete="address-line1"
|
||||
title="2–50 characters. Letters, numbers, spaces, periods, and hyphens only."
|
||||
required>
|
||||
<div class="form-text text-muted">2–50 characters. Letters, numbers, spaces, periods, and hyphens only.</div>
|
||||
<div id="parent-contact-street-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold" for="parent-contact-apt">Apt / Unit</label>
|
||||
@@ -641,7 +696,11 @@ foreach (($students ?? []) as $student) {
|
||||
id="parent-contact-apt"
|
||||
name="parent_contact[apt]"
|
||||
value="<?= esc($parentContact['apt'] ?? '') ?>"
|
||||
maxlength="15">
|
||||
maxlength="15"
|
||||
autocomplete="address-line2"
|
||||
title="Optional. Letters, numbers, spaces, periods, and hyphens only.">
|
||||
<div class="form-text text-muted">Optional. Max 15 characters.</div>
|
||||
<div id="parent-contact-apt-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold" for="parent-contact-city">City</label>
|
||||
@@ -650,8 +709,12 @@ foreach (($students ?? []) as $student) {
|
||||
id="parent-contact-city"
|
||||
name="parent_contact[city]"
|
||||
value="<?= esc($parentContact['city'] ?? '') ?>"
|
||||
maxlength="100"
|
||||
maxlength="30"
|
||||
autocomplete="address-level2"
|
||||
title="2–30 characters. Letters, spaces, periods, apostrophes, and hyphens only."
|
||||
required>
|
||||
<div class="form-text text-muted">2–30 characters. Letters and spaces allowed.</div>
|
||||
<div id="parent-contact-city-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold" for="parent-contact-zip">ZIP code</label>
|
||||
@@ -662,7 +725,12 @@ foreach (($students ?? []) as $student) {
|
||||
value="<?= esc($parentContact['zip'] ?? '') ?>"
|
||||
maxlength="5"
|
||||
inputmode="numeric"
|
||||
pattern="\d{5}"
|
||||
autocomplete="postal-code"
|
||||
title="Please enter a 5-digit ZIP code"
|
||||
required>
|
||||
<div class="form-text text-muted">5-digit US ZIP code only.</div>
|
||||
<div id="parent-contact-zip-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold" for="parent-contact-state">State</label>
|
||||
@@ -672,6 +740,7 @@ foreach (($students ?? []) as $student) {
|
||||
<option value="<?= esc($abbr) ?>" <?= $currentParentState === $abbr ? 'selected' : '' ?>><?= esc($stateName) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div id="parent-contact-state-error" class="invalid-feedback"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -777,6 +846,22 @@ foreach (($students ?? []) as $student) {
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="enrollmentBlockModal" tabindex="-1" aria-labelledby="enrollmentBlockModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="enrollmentBlockModalLabel">Enrollment Not Available</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="enrollmentBlockModalBody">
|
||||
Enrollment cannot continue at this time. Please contact school administration.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
@@ -804,8 +889,15 @@ foreach (($students ?? []) as $student) {
|
||||
const startButton = document.getElementById('startEnrollmentButton');
|
||||
const flowModalEl = document.getElementById('enrollmentFlowModal');
|
||||
const deadlineModalEl = document.getElementById('deadlineModal');
|
||||
const blockModalEl = document.getElementById('enrollmentBlockModal');
|
||||
const blockModalTitle = document.getElementById('enrollmentBlockModalLabel');
|
||||
const blockModalHeader = blockModalEl ? blockModalEl.querySelector('.modal-header') : null;
|
||||
const blockModalBody = document.getElementById('enrollmentBlockModalBody');
|
||||
const flowModal = flowModalEl ? new bootstrap.Modal(flowModalEl) : null;
|
||||
const deadlineModal = deadlineModalEl ? new bootstrap.Modal(deadlineModalEl) : null;
|
||||
const blockModal = blockModalEl ? new bootstrap.Modal(blockModalEl) : null;
|
||||
const eligibilityRefreshUrl = <?= json_encode(base_url('/parent/enrollment_eligibility_refresh'), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||
let latestEligibility = {};
|
||||
const policyAcceptedInput = document.getElementById('accept_school_policy_input');
|
||||
const policyAcceptedCheckbox = document.getElementById('schoolPolicyAcceptedCheckbox');
|
||||
const backButton = document.getElementById('enrollmentBackButton');
|
||||
@@ -825,17 +917,12 @@ foreach (($students ?? []) as $student) {
|
||||
'currency' => (string) ($enrollmentFeeSchedule['currency'] ?? '$'),
|
||||
'firstStudentFee' => (float) ($enrollmentFeeSchedule['first_student_fee'] ?? 0),
|
||||
'secondStudentFee' => (float) ($enrollmentFeeSchedule['second_student_fee'] ?? 0),
|
||||
'registrationFee' => (float) ($enrollmentFeeSchedule['registration_fee'] ?? 0),
|
||||
'tuitionDueAtRegistration' => (float) ($enrollmentFeeSchedule['tuition_due_at_registration'] ?? 0),
|
||||
'mandatoryFees' => (float) ($enrollmentFeeSchedule['mandatory_fees'] ?? 0),
|
||||
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||
const familyFinancial = <?= json_encode([
|
||||
'carryOverBalance' => (float) ($familyFinancialSummary['carry_over_balance'] ?? 0),
|
||||
'registrationFee' => (float) ($familyFinancialSummary['registration_fee'] ?? 0),
|
||||
'tuitionDueAtRegistration' => (float) ($familyFinancialSummary['tuition_due_at_registration'] ?? 0),
|
||||
'mandatoryFees' => (float) ($familyFinancialSummary['mandatory_fees'] ?? 0),
|
||||
'currentBalance' => (float) ($familyFinancialSummary['current_balance'] ?? 0),
|
||||
'amountDue' => (float) ($familyFinancialSummary['amount_due'] ?? 0),
|
||||
'carryOverBalance' => (float) ($familyFinancialSummary['carry_forward_balance'] ?? $familyFinancialSummary['carry_over_balance'] ?? 0),
|
||||
'currentBalance' => (float) ($familyFinancialSummary['current_year_balance'] ?? $familyFinancialSummary['current_balance'] ?? 0),
|
||||
'amountDue' => (float) ($familyFinancialSummary['total_enrollment_due'] ?? $familyFinancialSummary['amount_due'] ?? 0),
|
||||
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||
let currentStep = 0;
|
||||
let hasAcceptedSchoolPolicy = <?= $hasAcceptedSchoolPolicy ? 'true' : 'false' ?>;
|
||||
@@ -872,15 +959,11 @@ foreach (($students ?? []) as $student) {
|
||||
const selectedCount = selectedEnrollInputs().length;
|
||||
const tuitionDue = selectedCount > 0 ? calculateSelectedTuition() : 0;
|
||||
const carryOver = Number(familyFinancial.carryOverBalance || 0);
|
||||
const registrationFee = Number(familyFinancial.registrationFee || feeSchedule.registrationFee || 0);
|
||||
const mandatoryFees = Number(familyFinancial.mandatoryFees || feeSchedule.mandatoryFees || 0);
|
||||
const currentBalance = Number(familyFinancial.currentBalance || 0);
|
||||
const amountDue = Math.max(0, carryOver) + Math.max(0, currentBalance) + registrationFee + tuitionDue + mandatoryFees;
|
||||
const amountDue = Math.max(0, carryOver) + Math.max(0, currentBalance) + tuitionDue;
|
||||
const values = {
|
||||
carry_over_balance: carryOver,
|
||||
registration_fee: registrationFee,
|
||||
tuition_due_at_registration: tuitionDue,
|
||||
mandatory_fees: mandatoryFees,
|
||||
current_balance: currentBalance,
|
||||
amount_due: amountDue,
|
||||
};
|
||||
@@ -982,6 +1065,35 @@ foreach (($students ?? []) as $student) {
|
||||
return digits;
|
||||
}
|
||||
|
||||
function titleCaseContact(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/(^|[\s\-'])([a-z])/g, function(_, prefix, letter) {
|
||||
return prefix + letter.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
const parentContactRules = {
|
||||
phone: /^\(?\d{3}\)?[-\s]?\d{3}[-\s]?\d{4}$/,
|
||||
street: /^[A-Za-z0-9.\s-]{2,50}$/,
|
||||
apt: /^[A-Za-z0-9.\s-]{0,15}$/,
|
||||
city: /^[A-Za-z\s.'-]{2,30}$/,
|
||||
zip: /^\d{5}$/,
|
||||
};
|
||||
|
||||
function setParentContactError(input, message) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const error = document.getElementById(input.id + '-error');
|
||||
input.classList.toggle('is-invalid', !!message);
|
||||
if (error) {
|
||||
error.textContent = message || '';
|
||||
}
|
||||
}
|
||||
|
||||
function parentContactAddress() {
|
||||
const street = parentStreetInput?.value?.trim() || '';
|
||||
const apt = parentAptInput?.value?.trim() || '';
|
||||
@@ -1004,36 +1116,96 @@ foreach (($students ?? []) as $student) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateParentContactField(input) {
|
||||
if (!input) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const value = input.value.trim();
|
||||
let message = '';
|
||||
|
||||
switch (input.id) {
|
||||
case 'parent-contact-cellphone':
|
||||
if (!parentContactRules.phone.test(value) || digitsOnly(value).length !== 10) {
|
||||
message = 'Please enter a valid 10-digit phone number.';
|
||||
}
|
||||
break;
|
||||
case 'parent-contact-street':
|
||||
if (!parentContactRules.street.test(value)) {
|
||||
message = 'Street address must be 2–50 characters and may contain only letters, numbers, spaces, periods, and hyphens.';
|
||||
}
|
||||
break;
|
||||
case 'parent-contact-apt':
|
||||
if (value && !parentContactRules.apt.test(value)) {
|
||||
message = 'Apartment or unit may contain only letters, numbers, spaces, periods, and hyphens.';
|
||||
}
|
||||
break;
|
||||
case 'parent-contact-city':
|
||||
if (!parentContactRules.city.test(value)) {
|
||||
message = 'City must be 2–30 characters and may contain only letters, spaces, periods, apostrophes, and hyphens.';
|
||||
}
|
||||
break;
|
||||
case 'parent-contact-zip':
|
||||
if (!parentContactRules.zip.test(digitsOnly(value))) {
|
||||
message = 'Please enter a valid 5-digit ZIP code.';
|
||||
}
|
||||
break;
|
||||
case 'parent-contact-state':
|
||||
if (!value) {
|
||||
message = 'Please select your state.';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
setParentContactError(input, message);
|
||||
return message === '';
|
||||
}
|
||||
|
||||
function validateParentContact() {
|
||||
const phone = digitsOnly(parentPhoneInput?.value || '');
|
||||
if (phone.length !== 10) {
|
||||
alert('Please enter a valid 10-digit phone number.');
|
||||
parentPhoneInput?.focus();
|
||||
return false;
|
||||
}
|
||||
if (!parentStreetInput || parentStreetInput.value.trim().length < 5) {
|
||||
alert('Please enter your home street address.');
|
||||
parentStreetInput?.focus();
|
||||
return false;
|
||||
}
|
||||
if (!parentCityInput || parentCityInput.value.trim().length < 2) {
|
||||
alert('Please enter your city.');
|
||||
parentCityInput?.focus();
|
||||
return false;
|
||||
}
|
||||
if (!parentStateInput || parentStateInput.value.trim() === '') {
|
||||
alert('Please select your state.');
|
||||
parentStateInput?.focus();
|
||||
return false;
|
||||
}
|
||||
if (!parentZipInput || digitsOnly(parentZipInput.value).length !== 5) {
|
||||
alert('Please enter a valid 5-digit ZIP code.');
|
||||
parentZipInput?.focus();
|
||||
const fields = [
|
||||
parentPhoneInput,
|
||||
parentStreetInput,
|
||||
parentAptInput,
|
||||
parentCityInput,
|
||||
parentZipInput,
|
||||
parentStateInput,
|
||||
];
|
||||
fields.forEach(shapeParentContactField);
|
||||
renderParentContactReview();
|
||||
let firstInvalid = null;
|
||||
fields.forEach(field => {
|
||||
if (!validateParentContactField(field) && !firstInvalid) {
|
||||
firstInvalid = field;
|
||||
}
|
||||
});
|
||||
if (firstInvalid) {
|
||||
firstInvalid.focus();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function shapeParentContactField(input) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
if (input === parentPhoneInput) {
|
||||
input.value = formatPhoneDisplay(input.value);
|
||||
return;
|
||||
}
|
||||
if (input === parentZipInput) {
|
||||
input.value = digitsOnly(input.value).substring(0, 5);
|
||||
return;
|
||||
}
|
||||
if (input === parentStreetInput || input === parentCityInput) {
|
||||
input.value = titleCaseContact(input.value);
|
||||
return;
|
||||
}
|
||||
if (input === parentAptInput) {
|
||||
input.value = input.value.trim().replace(/\s+/g, ' ').toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
function selectStudentsForEnrollment(ids) {
|
||||
const wanted = Array.isArray(ids) ? ids.map(Number).filter(id => id > 0) : [];
|
||||
document.querySelectorAll('[data-student-card]').forEach(card => {
|
||||
@@ -1192,24 +1364,143 @@ foreach (($students ?? []) as $student) {
|
||||
});
|
||||
});
|
||||
|
||||
function showBlockModal(message, options = {}) {
|
||||
const alreadyEnrolled = options.alreadyEnrolled === true;
|
||||
if (blockModalTitle) {
|
||||
blockModalTitle.textContent = options.title || (alreadyEnrolled ? 'Already Enrolled' : 'Enrollment Not Available');
|
||||
}
|
||||
if (blockModalHeader) {
|
||||
blockModalHeader.classList.toggle('bg-danger', !alreadyEnrolled);
|
||||
blockModalHeader.classList.toggle('bg-success', alreadyEnrolled);
|
||||
}
|
||||
if (blockModalBody) {
|
||||
blockModalBody.textContent = message || (alreadyEnrolled
|
||||
? 'This student is already enrolled for the selected school year.'
|
||||
: 'Enrollment cannot continue at this time. Please contact school administration.');
|
||||
}
|
||||
if (blockModal) {
|
||||
blockModal.show();
|
||||
}
|
||||
}
|
||||
|
||||
function blockDetailsForCard(card) {
|
||||
const studentId = String(card?.dataset?.studentId || '');
|
||||
const row = latestEligibility[studentId] || {};
|
||||
const alreadyEnrolled = row.decision === 'ALREADY_ENROLLED' || card.dataset.alreadyEnrolled === '1';
|
||||
return {
|
||||
alreadyEnrolled,
|
||||
title: row.block_title || card.dataset.blockTitle || (alreadyEnrolled ? 'Already Enrolled' : 'Enrollment Not Available'),
|
||||
message: row.primary_parent_message || card.dataset.blockMessage || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshEligibility() {
|
||||
const response = await fetch(eligibilityRefreshUrl, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to refresh eligibility.');
|
||||
}
|
||||
const payload = await response.json();
|
||||
latestEligibility = {};
|
||||
(payload.students || []).forEach(row => {
|
||||
latestEligibility[String(row.student_id)] = row;
|
||||
});
|
||||
if (payload.financial_summary) {
|
||||
familyFinancial.carryOverBalance = Number(payload.financial_summary.carry_forward_balance || 0);
|
||||
familyFinancial.currentBalance = Number(payload.financial_summary.current_year_balance || 0);
|
||||
familyFinancial.amountDue = Number(payload.financial_summary.total_enrollment_due || 0);
|
||||
updateFinancialReview();
|
||||
}
|
||||
applyEligibilityToCards();
|
||||
return latestEligibility;
|
||||
}
|
||||
|
||||
function applyEligibilityToCards() {
|
||||
let shouldReload = false;
|
||||
|
||||
document.querySelectorAll('[data-student-card]').forEach(card => {
|
||||
const studentId = String(card.dataset.studentId || '');
|
||||
const row = latestEligibility[studentId];
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasBlocked = card.dataset.selectable !== '1';
|
||||
const canEnroll = row.can_enroll === true && !deadlinePassed;
|
||||
card.dataset.selectable = canEnroll ? '1' : '0';
|
||||
card.classList.toggle('is-disabled', !canEnroll);
|
||||
if (row.primary_parent_message) {
|
||||
card.dataset.blockMessage = row.primary_parent_message;
|
||||
}
|
||||
if (row.decision === 'ALREADY_ENROLLED') {
|
||||
card.dataset.alreadyEnrolled = '1';
|
||||
card.dataset.blockTitle = row.block_title || 'Already Enrolled';
|
||||
}
|
||||
|
||||
if (wasBlocked && canEnroll && !card.querySelector('[data-enroll-input]')) {
|
||||
shouldReload = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldReload) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
if (startButton && flowModal) {
|
||||
startButton.addEventListener('click', function() {
|
||||
startButton.addEventListener('click', async function() {
|
||||
if (deadlinePassed) {
|
||||
if (deadlineModal) deadlineModal.show();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await refreshEligibility();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Unable to refresh enrollment eligibility.');
|
||||
return;
|
||||
}
|
||||
|
||||
const eligibleCards = Array.from(document.querySelectorAll('[data-student-card]')).filter(card => {
|
||||
const studentId = String(card.dataset.studentId || '');
|
||||
return latestEligibility[studentId]?.can_enroll === true;
|
||||
});
|
||||
|
||||
if (eligibleCards.length === 0) {
|
||||
const firstBlocked = Object.values(latestEligibility).find(row => row.primary_parent_message || (row.blocking_rule_codes || []).length > 0);
|
||||
const alreadyEnrolled = firstBlocked?.decision === 'ALREADY_ENROLLED';
|
||||
const blockReason = firstBlocked?.primary_parent_message
|
||||
|| (firstBlocked?.blocking_rule_codes || []).join(', ')
|
||||
|| 'No students are currently eligible for enrollment.';
|
||||
showBlockModal(blockReason, {
|
||||
alreadyEnrolled,
|
||||
title: firstBlocked?.block_title || (alreadyEnrolled ? 'Already Enrolled' : 'Enrollment Not Available'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setStep(0);
|
||||
flowModal.show();
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-student-card]').forEach(card => {
|
||||
card.addEventListener('click', function(event) {
|
||||
card.addEventListener('click', async function(event) {
|
||||
if (event.target.closest('input, select, textarea, label')) {
|
||||
return;
|
||||
}
|
||||
if (this.dataset.selectable !== '1') {
|
||||
return;
|
||||
try {
|
||||
await refreshEligibility();
|
||||
} catch (error) {
|
||||
// Fall back to the last known server-rendered message.
|
||||
}
|
||||
if (this.dataset.selectable !== '1') {
|
||||
const details = blockDetailsForCard(this);
|
||||
showBlockModal(details.message, details);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const input = this.querySelector('[data-enroll-input]');
|
||||
if (!input) {
|
||||
@@ -1320,21 +1611,41 @@ foreach (($students ?? []) as $student) {
|
||||
});
|
||||
}
|
||||
|
||||
if (parentPhoneInput) {
|
||||
parentPhoneInput.addEventListener('input', function() {
|
||||
this.value = formatPhoneDisplay(this.value);
|
||||
[
|
||||
parentPhoneInput,
|
||||
parentStreetInput,
|
||||
parentAptInput,
|
||||
parentCityInput,
|
||||
parentZipInput,
|
||||
parentStateInput,
|
||||
].forEach(input => {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.addEventListener('input', function() {
|
||||
if (input === parentPhoneInput) {
|
||||
input.value = formatPhoneDisplay(input.value);
|
||||
}
|
||||
if (input === parentZipInput) {
|
||||
input.value = digitsOnly(input.value).substring(0, 5);
|
||||
}
|
||||
validateParentContactField(input);
|
||||
renderParentContactReview();
|
||||
});
|
||||
}
|
||||
|
||||
if (parentZipInput) {
|
||||
parentZipInput.addEventListener('input', function() {
|
||||
this.value = digitsOnly(this.value).substring(0, 5);
|
||||
input.addEventListener('blur', function() {
|
||||
shapeParentContactField(input);
|
||||
validateParentContactField(input);
|
||||
renderParentContactReview();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (enrollmentStartStep >= 2 && startButton && !startButton.disabled && !<?= $flashError ? 'true' : 'false' ?>) {
|
||||
openEnrollmentAtStep(enrollmentStartStep - 1);
|
||||
}
|
||||
|
||||
refreshEligibility().catch(() => {
|
||||
// Keep server-rendered eligibility if refresh fails.
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -62,26 +62,19 @@
|
||||
<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; ?>
|
||||
<label class="form-label" for="household_income">Household income <span class="text-danger">*</span></label>
|
||||
<input class="form-control" type="number" min="0" step="0.01" name="household_income" id="household_income" required value="<?= esc(old('household_income')) ?>">
|
||||
</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')) ?>">
|
||||
<label class="form-label" for="household_size">Household size <span class="text-danger">*</span></label>
|
||||
<input class="form-control" type="number" min="1" name="household_size" id="household_size" required 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')) ?>">
|
||||
<label class="form-label" for="requested_amount">Requested amount <span class="text-danger">*</span></label>
|
||||
<input class="form-control" type="number" min="0.01" step="0.01" name="requested_amount" id="requested_amount" required value="<?= esc(old('requested_amount')) ?>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="need_statement">Why are you requesting financial aid?</label>
|
||||
<label class="form-label" for="need_statement">Reason for financial aid request <span class="text-danger">*</span></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>
|
||||
|
||||
Reference in New Issue
Block a user