diff --git a/app/Commands/EnrollmentCloseoutReport.php b/app/Commands/EnrollmentCloseoutReport.php new file mode 100644 index 0000000..e4445d2 --- /dev/null +++ b/app/Commands/EnrollmentCloseoutReport.php @@ -0,0 +1,323 @@ + 'Target school year name. Defaults to the active/current configured year.', + '--json' => 'Print machine-readable JSON.', + '--export' => 'Write closeout exception rows to a CSV file.', + ]; + + private BaseConnection $db; + + public function run(array $params) + { + $this->db = \Config\Database::connect(); + $schoolYear = trim((string) (CLI::getOption('school-year') ?? '')); + if ($schoolYear === '') { + $schoolYear = $this->currentSchoolYear(); + } + + $report = $this->buildReport($schoolYear); + $exportPath = $this->optionValue('export'); + if ($exportPath !== '') { + $this->writeCsv($exportPath, $report['exceptions']); + $report['export_path'] = $exportPath; + } + + if (CLI::getOption('json') !== null) { + CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + return; + } + + $this->printReport($report); + } + + private function buildReport(string $schoolYear): array + { + $previousYear = $this->previousSchoolYearName($schoolYear); + $expected = $this->expectedReturningStudents($previousYear); + $enrolled = $this->enrolledStudents($schoolYear); + $unsubmitted = $this->unsubmittedReturningStudents($schoolYear, $previousYear); + $pendingEnrollments = $this->pendingEnrollments($schoolYear); + $unresolvedFlags = $this->unresolvedFlags($schoolYear); + $failedEmails = $this->failedEmails($schoolYear); + + $exceptions = array_merge( + $this->exceptionRows('unsubmitted_returning_student', $unsubmitted), + $this->exceptionRows('pending_enrollment', $pendingEnrollments), + $this->exceptionRows('unresolved_flag', $unresolvedFlags), + $this->exceptionRows('failed_email', $failedEmails) + ); + + $summary = [ + 'expected_returning_students' => count($expected), + 'students_with_target_year_enrollment' => count($enrolled), + 'unsubmitted_returning_students' => count($unsubmitted), + 'pending_enrollments' => count($pendingEnrollments), + 'unresolved_flags' => count($unresolvedFlags), + 'failed_emails' => count($failedEmails), + 'closeout_exception_total' => count($exceptions), + ]; + + return [ + 'school_year' => $schoolYear, + 'source_school_year' => $previousYear, + 'generated_at' => date('Y-m-d H:i:s'), + 'ready_to_close' => $summary['closeout_exception_total'] === 0, + 'summary' => $summary, + 'exceptions' => $exceptions, + ]; + } + + private function expectedReturningStudents(?string $previousYear): array + { + if ($previousYear === null || ! $this->db->tableExists('student_class')) { + return []; + } + + return $this->db->table('student_class sc') + ->select('DISTINCT sc.student_id', false) + ->where('sc.school_year', $previousYear) + ->get() + ->getResultArray(); + } + + private function enrolledStudents(string $schoolYear): array + { + if (! $this->db->tableExists('enrollments')) { + return []; + } + + return $this->db->table('enrollments') + ->select('DISTINCT student_id', false) + ->where('school_year', $schoolYear) + ->get() + ->getResultArray(); + } + + private function unsubmittedReturningStudents(string $schoolYear, ?string $previousYear): array + { + if ($previousYear === null || ! $this->db->tableExists('student_class') || ! $this->db->tableExists('enrollments')) { + return []; + } + + $join = 'e.student_id = sc.student_id AND e.school_year = ' . $this->db->escape($schoolYear); + + return $this->db->table('student_class sc') + ->select('sc.student_id, s.firstname, s.lastname, s.school_id') + ->select('e.id AS enrollment_id, e.enrollment_status, e.admission_status, e.registration_submitted_at') + ->join('students s', 's.id = sc.student_id', 'left') + ->join('enrollments e', $join, 'left', false) + ->where('sc.school_year', $previousYear) + ->groupStart() + ->where('e.id IS NULL') + ->orWhere('e.registration_submitted_at IS NULL', null, false) + ->groupEnd() + ->groupBy('sc.student_id, s.firstname, s.lastname, s.school_id, e.id, e.enrollment_status, e.admission_status, e.registration_submitted_at') + ->orderBy('s.lastname', 'ASC') + ->orderBy('s.firstname', 'ASC') + ->get() + ->getResultArray(); + } + + private function pendingEnrollments(string $schoolYear): array + { + if (! $this->db->tableExists('enrollments')) { + return []; + } + + return $this->db->table('enrollments e') + ->select('e.student_id, e.id AS enrollment_id, e.enrollment_status, e.admission_status, e.registration_submitted_at, e.registration_confirmed_at') + ->select('s.firstname, s.lastname, s.school_id') + ->join('students s', 's.id = e.student_id', 'left') + ->where('e.school_year', $schoolYear) + ->groupStart() + ->where('e.enrollment_status', 'admission under review') + ->orWhere('e.admission_status', 'pending') + ->orWhere('e.registration_confirmed_at IS NULL', null, false) + ->groupEnd() + ->orderBy('s.lastname', 'ASC') + ->orderBy('s.firstname', 'ASC') + ->get() + ->getResultArray(); + } + + private function unresolvedFlags(string $schoolYear): array + { + if (! $this->db->tableExists('enrollment_flags')) { + return []; + } + + return $this->db->table('enrollment_flags ef') + ->select('ef.id AS flag_id, ef.student_id, ef.flag_type, ef.priority, ef.created_at') + ->select('s.firstname, s.lastname, s.school_id') + ->join('students s', 's.id = ef.student_id', 'left') + ->where('ef.school_year', $schoolYear) + ->where('ef.status', 'open') + ->orderBy('ef.priority', 'DESC') + ->orderBy('ef.created_at', 'ASC') + ->get() + ->getResultArray(); + } + + private function failedEmails(string $schoolYear): array + { + if (! $this->db->tableExists('enrollment_email_records')) { + return []; + } + + return $this->db->table('enrollment_email_records') + ->select('id AS email_record_id, parent_user_id, recipient_addresses_json, delivery_status, failure_reason, retry_count, updated_at') + ->where('school_year', $schoolYear) + ->where('delivery_status', 'failed') + ->orderBy('updated_at', 'DESC') + ->get() + ->getResultArray(); + } + + private function exceptionRows(string $type, array $rows): array + { + $exceptions = []; + foreach ($rows as $row) { + $studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')); + $exceptions[] = [ + 'type' => $type, + 'student_id' => $row['student_id'] ?? '', + 'student_name' => $studentName, + 'school_id' => $row['school_id'] ?? '', + 'reference_id' => $row['enrollment_id'] ?? $row['flag_id'] ?? $row['email_record_id'] ?? '', + 'status' => $row['enrollment_status'] ?? $row['flag_type'] ?? $row['delivery_status'] ?? '', + 'detail' => $this->exceptionDetail($type, $row), + ]; + } + + return $exceptions; + } + + private function exceptionDetail(string $type, array $row): string + { + return match ($type) { + 'unsubmitted_returning_student' => empty($row['enrollment_id']) + ? 'No target-year enrollment exists.' + : 'Target-year enrollment exists but registration has not been submitted.', + 'pending_enrollment' => 'Enrollment status: ' . (string) ($row['enrollment_status'] ?? '') . '; admission status: ' . (string) ($row['admission_status'] ?? ''), + 'unresolved_flag' => 'Open flag priority: ' . (string) ($row['priority'] ?? ''), + 'failed_email' => 'Retry count: ' . (int) ($row['retry_count'] ?? 0) . '; reason: ' . (string) ($row['failure_reason'] ?? ''), + default => '', + }; + } + + private function writeCsv(string $path, array $rows): void + { + $directory = dirname($path); + if ($directory !== '' && $directory !== '.' && ! is_dir($directory)) { + throw new \RuntimeException('Export directory does not exist: ' . $directory); + } + + $handle = fopen($path, 'wb'); + if ($handle === false) { + throw new \RuntimeException('Unable to write export file: ' . $path); + } + + fputcsv($handle, ['type', 'student_id', 'student_name', 'school_id', 'reference_id', 'status', 'detail']); + foreach ($rows as $row) { + fputcsv($handle, [ + $row['type'] ?? '', + $row['student_id'] ?? '', + $row['student_name'] ?? '', + $row['school_id'] ?? '', + $row['reference_id'] ?? '', + $row['status'] ?? '', + $row['detail'] ?? '', + ]); + } + + fclose($handle); + } + + private function optionValue(string $name): string + { + $value = CLI::getOption($name); + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + + $argv = $_SERVER['argv'] ?? []; + $long = '--' . $name; + foreach ($argv as $index => $arg) { + if (str_starts_with((string) $arg, $long . '=')) { + return trim(substr((string) $arg, strlen($long) + 1)); + } + if ($arg === $long && isset($argv[$index + 1]) && ! str_starts_with((string) $argv[$index + 1], '--')) { + return trim((string) $argv[$index + 1]); + } + } + + return ''; + } + + private function currentSchoolYear(): string + { + if ($this->db->tableExists('school_years')) { + $row = $this->db->table('school_years') + ->select('name') + ->where('status', 'active') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + if (! empty($row['name'])) { + return (string) $row['name']; + } + } + + if ($this->db->tableExists('configuration')) { + $row = $this->db->table('configuration') + ->select('config_value') + ->where('config_key', 'school_year') + ->limit(1) + ->get() + ->getRowArray(); + if (! empty($row['config_value'])) { + return (string) $row['config_value']; + } + } + + return ''; + } + + private function previousSchoolYearName(string $schoolYear): ?string + { + return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) + ? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1) + : null; + } + + private function printReport(array $report): void + { + CLI::write('Registration Closeout Report: ' . (string) ($report['school_year'] ?? ''), ($report['ready_to_close'] ?? false) ? 'green' : 'yellow'); + CLI::write('Generated at: ' . (string) ($report['generated_at'] ?? '')); + CLI::write('Ready to close: ' . (($report['ready_to_close'] ?? false) ? 'yes' : 'no')); + CLI::newLine(); + + foreach (($report['summary'] ?? []) as $key => $value) { + CLI::write($key . ': ' . $value); + } + + if (! empty($report['export_path'])) { + CLI::newLine(); + CLI::write('Export written: ' . (string) $report['export_path'], 'green'); + } + } +} diff --git a/app/Commands/EnrollmentPostLaunchMonitor.php b/app/Commands/EnrollmentPostLaunchMonitor.php new file mode 100644 index 0000000..d07bbcb --- /dev/null +++ b/app/Commands/EnrollmentPostLaunchMonitor.php @@ -0,0 +1,295 @@ + 'Target school year name. Defaults to the active/current configured year.', + '--days' => 'Recent activity window in days. Defaults to 7.', + '--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') ?? '')); + if ($schoolYear === '') { + $schoolYear = $this->currentSchoolYear(); + } + + $days = max(1, (int) (CLI::getOption('days') ?? 7)); + $report = $this->buildReport($schoolYear, $days); + + if (CLI::getOption('json') !== null) { + CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + return; + } + + $this->printReport($report); + } + + private function buildReport(string $schoolYear, int $days): array + { + $previousYear = $this->previousSchoolYearName($schoolYear); + $expected = $this->expectedReturningStudentCount($previousYear); + $enrollments = $this->enrollmentSummary($schoolYear); + $flags = $this->flagSummary($schoolYear, $days); + $emails = $this->emailSummary($schoolYear); + $audits = $this->auditSummary($schoolYear, $days); + + $submitted = (int) ($enrollments['submitted'] ?? 0); + $progressPercent = $expected > 0 ? round(($submitted / $expected) * 100, 1) : null; + $needsAttention = (int) ($flags['open_total'] ?? 0) + + (int) ($flags['stale_total'] ?? 0) + + (int) ($emails['failed'] ?? 0) + + (int) ($enrollments['blocked_total'] ?? 0); + + return [ + 'school_year' => $schoolYear, + 'source_school_year' => $previousYear, + 'generated_at' => date('Y-m-d H:i:s'), + 'activity_window_days' => $days, + 'expected_returning_students' => $expected, + 'submitted_registrations' => $submitted, + 'progress_percent' => $progressPercent, + 'needs_attention_count' => $needsAttention, + 'enrollments' => $enrollments, + 'flags' => $flags, + 'emails' => $emails, + 'audits' => $audits, + ]; + } + + private function expectedReturningStudentCount(?string $previousYear): int + { + if ($previousYear === null || ! $this->db->tableExists('student_class')) { + return 0; + } + + return (int) ($this->db->table('student_class') + ->select('COUNT(DISTINCT student_id) AS total', false) + ->where('school_year', $previousYear) + ->get() + ->getRowArray()['total'] ?? 0); + } + + private function enrollmentSummary(string $schoolYear): array + { + if (! $this->db->tableExists('enrollments')) { + return [ + 'total' => 0, + 'submitted' => 0, + 'confirmed' => 0, + 'blocked_total' => 0, + 'by_status' => [], + 'by_placement_status' => [], + ]; + } + + $total = $this->countRows('enrollments', ['school_year' => $schoolYear]); + $submitted = $this->db->fieldExists('registration_submitted_at', 'enrollments') + ? $this->countRows('enrollments', ['school_year' => $schoolYear], 'registration_submitted_at IS NOT NULL') + : $total; + $confirmed = $this->db->fieldExists('registration_confirmed_at', 'enrollments') + ? $this->countRows('enrollments', ['school_year' => $schoolYear], 'registration_confirmed_at IS NOT NULL') + : $this->countRows('enrollments', ['school_year' => $schoolYear, 'enrollment_status' => 'enrolled']); + + $blocked = 0; + if ($this->db->fieldExists('parent_enrollment_allowed', 'enrollments')) { + $blocked += $this->countRows('enrollments', ['school_year' => $schoolYear, 'parent_enrollment_allowed' => 0]); + } + if ($this->db->fieldExists('exception_required', 'enrollments')) { + $blocked += $this->countRows('enrollments', ['school_year' => $schoolYear, 'exception_required' => 1]); + } + + return [ + 'total' => $total, + 'submitted' => $submitted, + 'confirmed' => $confirmed, + 'blocked_total' => $blocked, + 'by_status' => $this->groupCount('enrollments', 'enrollment_status', ['school_year' => $schoolYear]), + 'by_placement_status' => $this->db->fieldExists('placement_status', 'enrollments') + ? $this->groupCount('enrollments', 'placement_status', ['school_year' => $schoolYear]) + : [], + ]; + } + + private function flagSummary(string $schoolYear, int $days): array + { + if (! $this->db->tableExists('enrollment_flags')) { + return [ + 'open_total' => 0, + 'stale_total' => 0, + 'by_type' => [], + 'by_priority' => [], + ]; + } + + $staleBefore = date('Y-m-d H:i:s', strtotime('-' . $days . ' days')); + + return [ + 'open_total' => $this->countRows('enrollment_flags', ['school_year' => $schoolYear, 'status' => 'open']), + 'stale_total' => $this->countRows('enrollment_flags', ['school_year' => $schoolYear, 'status' => 'open'], 'created_at < ' . $this->db->escape($staleBefore)), + 'by_type' => $this->groupCount('enrollment_flags', 'flag_type', ['school_year' => $schoolYear, 'status' => 'open']), + 'by_priority' => $this->groupCount('enrollment_flags', 'priority', ['school_year' => $schoolYear, 'status' => 'open']), + ]; + } + + private function emailSummary(string $schoolYear): array + { + if (! $this->db->tableExists('enrollment_email_records')) { + return [ + 'total' => 0, + 'sent' => 0, + 'failed' => 0, + 'pending' => 0, + 'by_status' => [], + ]; + } + + return [ + 'total' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear]), + 'sent' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear, 'delivery_status' => 'sent']), + 'failed' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear, 'delivery_status' => 'failed']), + 'pending' => $this->countRows('enrollment_email_records', ['school_year' => $schoolYear, 'delivery_status' => 'pending']), + 'by_status' => $this->groupCount('enrollment_email_records', 'delivery_status', ['school_year' => $schoolYear]), + ]; + } + + private function auditSummary(string $schoolYear, int $days): array + { + if (! $this->db->tableExists('enrollment_transition_audits')) { + return [ + 'recent_total' => 0, + 'by_action' => [], + ]; + } + + $since = date('Y-m-d H:i:s', strtotime('-' . $days . ' days')); + + return [ + 'recent_total' => $this->countRows('enrollment_transition_audits', ['school_year' => $schoolYear], 'created_at >= ' . $this->db->escape($since)), + 'by_action' => $this->groupCount('enrollment_transition_audits', 'action', ['school_year' => $schoolYear], 'created_at >= ' . $this->db->escape($since)), + ]; + } + + private function countRows(string $table, array $where, ?string $rawWhere = null): int + { + $builder = $this->db->table($table); + foreach ($where as $field => $value) { + $builder->where($field, $value); + } + if ($rawWhere !== null) { + $builder->where($rawWhere, null, false); + } + + return $builder->countAllResults(); + } + + private function groupCount(string $table, string $field, array $where, ?string $rawWhere = null): array + { + if (! $this->db->fieldExists($field, $table)) { + return []; + } + + $builder = $this->db->table($table) + ->select($field . ' AS value, COUNT(*) AS total', false) + ->groupBy($field) + ->orderBy('total', 'DESC', false); + + foreach ($where as $whereField => $value) { + $builder->where($whereField, $value); + } + if ($rawWhere !== null) { + $builder->where($rawWhere, null, false); + } + + $result = []; + foreach ($builder->get()->getResultArray() as $row) { + $key = trim((string) ($row['value'] ?? '')) ?: 'blank'; + $result[$key] = (int) ($row['total'] ?? 0); + } + + return $result; + } + + private function currentSchoolYear(): string + { + if ($this->db->tableExists('school_years')) { + $row = $this->db->table('school_years') + ->select('name') + ->where('status', 'active') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + if (! empty($row['name'])) { + return (string) $row['name']; + } + } + + if ($this->db->tableExists('configuration')) { + $row = $this->db->table('configuration') + ->select('config_value') + ->where('config_key', 'school_year') + ->limit(1) + ->get() + ->getRowArray(); + if (! empty($row['config_value'])) { + return (string) $row['config_value']; + } + } + + return ''; + } + + private function previousSchoolYearName(string $schoolYear): ?string + { + return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) + ? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1) + : null; + } + + private function printReport(array $report): void + { + CLI::write('Registration Post-Launch Monitor: ' . (string) ($report['school_year'] ?? ''), 'cyan'); + CLI::write('Generated at: ' . (string) ($report['generated_at'] ?? '')); + CLI::write('Expected returning students: ' . (int) ($report['expected_returning_students'] ?? 0)); + CLI::write('Submitted registrations: ' . (int) ($report['submitted_registrations'] ?? 0)); + CLI::write('Progress: ' . (($report['progress_percent'] ?? null) === null ? 'n/a' : (string) $report['progress_percent'] . '%')); + CLI::write('Needs attention: ' . (int) ($report['needs_attention_count'] ?? 0), ((int) ($report['needs_attention_count'] ?? 0)) > 0 ? 'yellow' : 'green'); + CLI::newLine(); + + $this->printSection('Enrollments', $report['enrollments'] ?? []); + $this->printSection('Flags', $report['flags'] ?? []); + $this->printSection('Emails', $report['emails'] ?? []); + $this->printSection('Recent Audits', $report['audits'] ?? []); + } + + private function printSection(string $title, array $data): void + { + CLI::write($title, 'white'); + foreach ($data as $key => $value) { + if (is_array($value)) { + CLI::write(' ' . $key . ':'); + foreach ($value as $nestedKey => $nestedValue) { + CLI::write(' ' . $nestedKey . ': ' . $nestedValue); + } + } else { + CLI::write(' ' . $key . ': ' . $value); + } + } + CLI::newLine(); + } +} diff --git a/app/Commands/EnrollmentReleaseAudit.php b/app/Commands/EnrollmentReleaseAudit.php new file mode 100644 index 0000000..839ef0d --- /dev/null +++ b/app/Commands/EnrollmentReleaseAudit.php @@ -0,0 +1,319 @@ + 'Target school year name. Defaults to the active/current configured year.', + '--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') ?? '')); + if ($schoolYear === '') { + $schoolYear = $this->currentSchoolYear(); + } + + $report = $this->buildReport($schoolYear); + + if (CLI::getOption('json') !== null) { + CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + return; + } + + $this->printReport($report); + } + + private function buildReport(string $schoolYear): array + { + $previousYear = $this->previousSchoolYearName($schoolYear); + $checks = [ + 'school_year_configuration' => $this->schoolYearConfigurationCheck($schoolYear), + 'launch_approval' => $this->launchApprovalCheck($schoolYear), + 'deliberation_decisions' => $this->deliberationDecisionCheck($schoolYear, $previousYear), + 'open_enrollment_flags' => $this->openEnrollmentFlagsCheck($schoolYear), + 'failed_registration_emails' => $this->failedEmailCheck($schoolYear), + 'duplicate_or_invalid_contacts' => $this->contactQualityCheck(), + 'email_preview_sample' => $this->emailPreviewCheck($schoolYear), + ]; + + $blocking = 0; + $warnings = 0; + foreach ($checks as $check) { + if (($check['severity'] ?? '') === 'blocking') { + $blocking++; + } elseif (($check['severity'] ?? '') === 'warning') { + $warnings++; + } + } + + return [ + 'school_year' => $schoolYear, + 'source_school_year' => $previousYear, + 'generated_at' => date('Y-m-d H:i:s'), + 'ready' => $blocking === 0, + 'blocking_count' => $blocking, + 'warning_count' => $warnings, + 'checks' => $checks, + ]; + } + + private function schoolYearConfigurationCheck(string $schoolYear): array + { + if (! $this->db->tableExists('school_years')) { + return $this->check('blocking', 'school_years table is missing.'); + } + + $row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray(); + if ($row === null) { + return $this->check('blocking', 'Target school year record was not found.'); + } + + $missing = []; + foreach ([ + 'registration_starts_on' => 'registration opening date', + 'registration_ends_on' => 'registration deadline', + ] as $field => $label) { + if (empty($row[$field])) { + $missing[] = $label; + } + } + + if (! $this->db->tableExists('email_templates')) { + $missing[] = 'email templates table'; + } elseif ($this->activeTemplateCount('registration_opening') <= 0) { + $missing[] = 'active registration email template'; + } + + return $missing === [] + ? $this->check('pass', 'Required school-year launch configuration is present.') + : $this->check('blocking', 'Missing: ' . implode(', ', $missing)); + } + + private function launchApprovalCheck(string $schoolYear): array + { + if (! $this->db->tableExists('school_years') || ! $this->db->fieldExists('registration_launch_approved_at', 'school_years')) { + return $this->check('warning', 'Launch approval fields have not been migrated yet.'); + } + + $row = $this->db->table('school_years') + ->select('registration_launch_approved_at') + ->where('name', $schoolYear) + ->limit(1) + ->get() + ->getRowArray(); + + return ! empty($row['registration_launch_approved_at'] ?? null) + ? $this->check('pass', 'Registration launch has been approved.') + : $this->check('warning', 'Registration launch has not been approved yet.'); + } + + private function deliberationDecisionCheck(string $schoolYear, ?string $previousYear): array + { + if ($previousYear === null) { + return $this->check('warning', 'Previous school year could not be inferred.'); + } + if (! $this->db->tableExists('student_class') || ! $this->db->tableExists('student_decisions')) { + return $this->check('warning', 'Student placement or decision tables are missing.'); + } + + $rows = $this->db->table('student_class sc') + ->select('COUNT(DISTINCT sc.student_id) AS total', false) + ->join('student_decisions sd', 'sd.student_id = sc.student_id AND sd.school_year = ' . $this->db->escape($previousYear), 'left', false) + ->where('sc.school_year', $previousYear) + ->groupStart() + ->where('sd.id IS NULL') + ->orWhere('sd.decision IS NULL') + ->orWhere('TRIM(sd.decision) =', '') + ->groupEnd() + ->get() + ->getRowArray(); + + $missing = (int) ($rows['total'] ?? 0); + return $missing === 0 + ? $this->check('pass', 'All placed source-year students have a recorded deliberation decision.') + : $this->check('blocking', $missing . ' source-year student(s) are missing deliberation decisions.'); + } + + private function openEnrollmentFlagsCheck(string $schoolYear): array + { + if (! $this->db->tableExists('enrollment_flags')) { + return $this->check('warning', 'Enrollment flags table has not been migrated yet.'); + } + + $count = $this->db->table('enrollment_flags') + ->where('school_year', $schoolYear) + ->where('status', 'open') + ->countAllResults(); + + return $count === 0 + ? $this->check('pass', 'No open enrollment follow-up flags remain.') + : $this->check('warning', $count . ' open enrollment follow-up flag(s) remain.'); + } + + private function failedEmailCheck(string $schoolYear): array + { + if (! $this->db->tableExists('enrollment_email_records')) { + return $this->check('warning', 'Enrollment email record table has not been migrated yet.'); + } + + $failed = $this->db->table('enrollment_email_records') + ->where('school_year', $schoolYear) + ->where('delivery_status', 'failed') + ->countAllResults(); + + return $failed === 0 + ? $this->check('pass', 'No failed registration email records found.') + : $this->check('warning', $failed . ' failed registration email record(s) need retry or review.'); + } + + private function contactQualityCheck(): array + { + if (! $this->db->tableExists('users')) { + return $this->check('warning', 'Users table is missing.'); + } + if (! $this->db->fieldExists('email', 'users')) { + return $this->check('warning', 'Users table does not include an email field.'); + } + + $rows = $this->db->query( + "SELECT LOWER(TRIM(email)) AS normalized_email, COUNT(*) AS total + FROM users + WHERE email IS NOT NULL AND email <> '' + GROUP BY LOWER(TRIM(email)) + HAVING COUNT(*) > 1" + )->getResultArray(); + + $invalid = 0; + foreach ($this->db->table('users')->select('email')->where('email IS NOT NULL')->where('email !=', '')->get()->getResultArray() as $row) { + if (! filter_var((string) ($row['email'] ?? ''), FILTER_VALIDATE_EMAIL)) { + $invalid++; + } + } + + $duplicates = count($rows); + if ($duplicates === 0 && $invalid === 0) { + return $this->check('pass', 'No duplicate or invalid parent/user email addresses were detected.'); + } + + return $this->check('warning', $duplicates . ' duplicate email value(s) and ' . $invalid . ' invalid email address(es) were detected.'); + } + + private function emailPreviewCheck(string $schoolYear): array + { + if (! $this->db->tableExists('students')) { + return $this->check('warning', 'Students table is missing.'); + } + + $row = $this->db->table('students') + ->select('parent_id') + ->where('parent_id IS NOT NULL', null, false) + ->orderBy('parent_id', 'ASC') + ->limit(1) + ->get() + ->getRowArray(); + + $parentId = is_numeric($row['parent_id'] ?? null) ? (int) $row['parent_id'] : 0; + if ($parentId <= 0) { + return $this->check('warning', 'No parent with students was found for email preview.'); + } + + try { + $message = service('enrollmentRegistrationEmail')->previewForParent($schoolYear, $parentId); + } catch (\Throwable $e) { + return $this->check('blocking', 'Email preview generation failed: ' . $e->getMessage()); + } + + return $message !== null && trim((string) ($message['body'] ?? '')) !== '' + ? $this->check('pass', 'A consolidated registration email preview can be generated.') + : $this->check('blocking', 'No consolidated registration email preview could be generated.'); + } + + private function currentSchoolYear(): string + { + if ($this->db->tableExists('school_years')) { + $row = $this->db->table('school_years') + ->select('name') + ->where('status', 'active') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + if (! empty($row['name'])) { + return (string) $row['name']; + } + } + + if ($this->db->tableExists('configuration')) { + $row = $this->db->table('configuration') + ->select('config_value') + ->where('config_key', 'school_year') + ->limit(1) + ->get() + ->getRowArray(); + if (! empty($row['config_value'])) { + return (string) $row['config_value']; + } + } + + return ''; + } + + private function activeTemplateCount(string $key): int + { + $fields = $this->db->getFieldNames('email_templates'); + $keyField = in_array('code', $fields, true) ? 'code' : 'template_key'; + + return $this->db->table('email_templates') + ->where($keyField, $key) + ->where('is_active', 1) + ->countAllResults(); + } + + private function previousSchoolYearName(string $schoolYear): ?string + { + return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) + ? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1) + : null; + } + + private function check(string $severity, string $message): array + { + return ['severity' => $severity, 'message' => $message]; + } + + private function printReport(array $report): void + { + CLI::write('Registration Release Audit: ' . (string) ($report['school_year'] ?? ''), ($report['ready'] ?? false) ? 'green' : 'red'); + CLI::write('Generated at: ' . (string) ($report['generated_at'] ?? '')); + CLI::write('Ready: ' . (($report['ready'] ?? false) ? 'yes' : 'no')); + CLI::newLine(); + + foreach (($report['checks'] ?? []) as $name => $check) { + $severity = (string) ($check['severity'] ?? 'warning'); + $color = match ($severity) { + 'pass' => 'green', + 'blocking' => 'red', + default => 'yellow', + }; + CLI::write(sprintf('[%s] %s: %s', strtoupper($severity), $name, (string) ($check['message'] ?? '')), $color); + } + + CLI::newLine(); + CLI::write('Blocking: ' . (int) ($report['blocking_count'] ?? 0), ((int) ($report['blocking_count'] ?? 0)) > 0 ? 'red' : 'green'); + CLI::write('Warnings: ' . (int) ($report['warning_count'] ?? 0), ((int) ($report['warning_count'] ?? 0)) > 0 ? 'yellow' : 'green'); + } +} diff --git a/app/Commands/SendRegistrationOpeningEmail.php b/app/Commands/SendRegistrationOpeningEmail.php index 11f91a1..c4e81ec 100644 --- a/app/Commands/SendRegistrationOpeningEmail.php +++ b/app/Commands/SendRegistrationOpeningEmail.php @@ -2,7 +2,6 @@ namespace App\Commands; -use App\Services\RegistrationOpeningEmailService; use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; @@ -32,7 +31,7 @@ class SendRegistrationOpeningEmail extends BaseCommand return; } - $service = new RegistrationOpeningEmailService(); + $service = service('enrollmentRegistrationEmail'); $summary = $service->sendForDate($date, $force, $email, $dryRun); foreach ($summary['messages'] as $message) { diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 4b87c04..d845525 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -107,6 +107,14 @@ $routes->post('student/score-card', 'View\StudentController::scoreCard'); $routes->get('student/score-card', 'View\StudentController::scoreCardIndex'); $routes->get('student/score-card/list', 'View\StudentController::scoreCardList'); $routes->get('administrator/student-score-card', 'View\StudentController::scoreCardAdmin'); +$routes->get('administrator/enrollment-admin', 'View\EnrollmentAdminController::dashboard', ['filter' => 'auth:admin']); +$routes->get('administrator/enrollment-admin/email-preview', 'View\EnrollmentAdminController::previewEmail', ['filter' => 'auth:admin']); +$routes->post('administrator/enrollment-admin/approve-launch', 'View\EnrollmentAdminController::approveLaunch', ['filter' => 'auth:admin']); +$routes->post('administrator/enrollment-admin/send-registration-emails', 'View\EnrollmentAdminController::sendRegistrationEmails', ['filter' => 'auth:admin']); +$routes->post('administrator/enrollment-admin/flags/(:num)/resolve', 'View\EnrollmentAdminController::resolveFlag/$1', ['filter' => 'auth:admin']); +$routes->post('administrator/enrollment-admin/flags/(:num)/assign-class', 'View\EnrollmentAdminController::assignClass/$1', ['filter' => 'auth:admin']); +$routes->post('administrator/enrollment-admin/flags/(:num)/makeup-promotion', 'View\EnrollmentAdminController::confirmMakeupPromotion/$1', ['filter' => 'auth:admin']); +$routes->post('administrator/enrollment-admin/flags/(:num)/approve-exception', 'View\EnrollmentAdminController::approveException/$1', ['filter' => 'auth:admin']); // API for report card meta (students, class sections, school years) $routes->get('api/printables/report-card/meta', 'View\ReportCardsController::reportCardMeta', ['filter' => 'auth']); $routes->get('api/printables/report-card/completeness', 'View\ReportCardsController::reportCardCompleteness', ['filter' => 'auth']); @@ -255,6 +263,8 @@ $routes->post('administrator/remove_class_student', 'View\StudentController::rem // Sections auto-distribution (admin) $routes->get('administrator/sections/auto-distribute', 'View\StudentController::autoDistributePage'); $routes->post('administrator/sections/auto-distribute', 'View\StudentController::autoDistributeSections'); +$routes->post('administrator/sections/distribution-draft/update', 'View\StudentController::updateDistributionDraft'); +$routes->post('administrator/sections/distribution-candidate/update', 'View\StudentController::updateDistributionCandidate'); // Totals API for dashboard $routes->get('administrator/sections/promotion-totals', 'View\StudentController::promotionTotalsApi'); @@ -1279,7 +1289,6 @@ $routes->get('flags/flags_management', 'View\FlagController::index'); $routes->get('flags/processed_flags', 'View\FlagController::processedFlags'); $routes->get('flags/incident_analysis', 'View\FlagController::incidentAnalysis'); $routes->post('flags/add', 'View\FlagController::addFlag'); -$routes->get('/flags/update_state/(:num)', 'View\FlagController::updateState/$1'); $routes->post('/flags/update_state/(:num)', 'View\FlagController::updateState/$1'); $routes->get('flags/getStudentsByGrade/(:num)', 'View\FlagController::getStudentsByGrade/$1'); $routes->get('flags/history', 'View\FlagController::history'); diff --git a/app/Config/Services.php b/app/Config/Services.php index eb8acf4..77d9c2b 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -279,4 +279,26 @@ class Services extends BaseService \Config\Database::connect() ); } + + public static function enrollmentTransition(bool $getShared = true): \App\Services\EnrollmentTransitionService + { + if ($getShared) { + return static::getSharedInstance('enrollmentTransition'); + } + + return new \App\Services\EnrollmentTransitionService(\Config\Database::connect()); + } + + public static function enrollmentRegistrationEmail(bool $getShared = true): \App\Services\EnrollmentRegistrationEmailService + { + if ($getShared) { + return static::getSharedInstance('enrollmentRegistrationEmail'); + } + + return new \App\Services\EnrollmentRegistrationEmailService( + \Config\Database::connect(), + static::enrollmentTransition(), + static::emailService() + ); + } } diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index 6f59147..1ada60c 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -132,6 +132,16 @@ abstract class BaseController extends Controller service('schoolYearWriteGuard')->assertWritable($context, $allowDraftForAdmin, $isAdmin); } + protected function assertSchoolYearNameWritable( + string $schoolYear, + bool $allowDraftForAdmin = false, + bool $isAdmin = false + ): void { + $context = service('schoolYearContext')->forYearName($schoolYear); + + $this->assertSchoolYearWritable($context, $allowDraftForAdmin, $isAdmin); + } + private function syncSchoolYearPropertyFromContext(): void { if (! property_exists($this, 'schoolYear')) { diff --git a/app/Controllers/View/AdministratorController.php b/app/Controllers/View/AdministratorController.php index 42e797a..2f95675 100644 --- a/app/Controllers/View/AdministratorController.php +++ b/app/Controllers/View/AdministratorController.php @@ -33,6 +33,7 @@ use App\Models\TeacherSubmissionNotificationHistoryModel; use App\Models\ExamDraftModel; use App\Models\HomeworkModel; use App\Services\SemesterRangeService; +use App\Support\Enrollment\DeliberationDecision; use CodeIgniter\Events\Events; @@ -572,14 +573,14 @@ class AdministratorController extends BaseController // USERS (phone col: cellphone) $uCols = ['firstname', 'lastname', 'email', 'cellphone', 'school_id', 'city', 'state']; $uQB = $db->table('users') - ->select('id, firstname, lastname, email, cellphone, school_id, city, state, semester'); + ->select('id, firstname, lastname, email, cellphone, school_id, city, state'); $applyMultiTokenLike($uQB, $uCols, $tokens, ['cellphone']); $users = $uQB->limit(150)->get()->getResultArray(); // STUDENTS (no phone column to search) $sCols = ['firstname', 'lastname', 'school_id', 'rfid_tag', 'dob', 'gender']; $sQB = $db->table('students') - ->select('id, parent_id, school_id, firstname, lastname, dob, gender, semester, rfid_tag'); + ->select('id, parent_id, school_id, firstname, lastname, dob, gender, rfid_tag'); $applyMultiTokenLike($sQB, $sCols, $tokens, []); $students = $sQB->limit(150)->get()->getResultArray(); @@ -2061,6 +2062,7 @@ class AdministratorController extends BaseController { $db = db_connect(); $isPg = ($db->getPlatform() === 'Postgre'); // 'MySQLi', 'Postgre', 'SQLSRV', ... + $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); // In MySQL, avoid truncation of long lists if (!$isPg) { @@ -2133,20 +2135,23 @@ class AdministratorController extends BaseController ->getResultArray(); } - // === Inject class_section_name from student_class via your method and replace grade === + // === Inject current-year class_section_name from student_class and replace grade === foreach ($students as $i => $row) { $sid = (int) ($row['id'] ?? 0); if ($sid > 0) { - // Fetch class_section_name for this student - // Assumes your method signature: getClassSectionNameByStudentId(int $studentId): ?string - $classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid) ?? ''); + $classSectionName = (string) ($this->studentClassModel->getClassSectionNameByStudentId($sid, $selectedYear) ?? ''); - // Expose it explicitly and replace original grade if present $students[$i]['class_section_name'] = $classSectionName; } else { // Keep keys consistent even if id missing $students[$i]['class_section_name'] = ''; } + + $studentYear = trim((string) ($row['school_year'] ?? '')); + $students[$i]['age'] = $this->calculateAgeAsOfSchoolYearStartYear( + $row['dob'] ?? null, + $studentYear !== '' ? $studentYear : $selectedYear + ); } // === end injection === @@ -2154,9 +2159,45 @@ class AdministratorController extends BaseController 'students' => $students, 'gradeOptions' => ['K', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Youth'], 'genderOptions' => ['Male', 'Female', 'Other'], + 'selectedYear' => $selectedYear, ]); } + private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int + { + $dob = trim((string) $dob); + $schoolYear = trim($schoolYear); + + if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) { + return null; + } + + try { + $timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone())); + $birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone); + $errors = \DateTimeImmutable::getLastErrors(); + $hasParseErrors = is_array($errors) + && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); + + if ($birthDate === false || $hasParseErrors) { + return null; + } + + $schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-09-01', $timezone); + if ($birthDate > $schoolYearStartYearCutoff) { + return null; + } + + return $birthDate->diff($schoolYearStartYearCutoff)->y; + } catch (\Throwable $e) { + log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [ + 'message' => $e->getMessage(), + ]); + + return null; + } + } + public function parentProfiles() @@ -2330,28 +2371,19 @@ class AdministratorController extends BaseController $schoolYearContext = $this->resolveSchoolYearContext(); $selectedYear = $schoolYearContext->yearName(); + $this->syncReviewDecisionEnrollments($selectedYear); + $students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear); - $selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear); - $removedPriorIds = []; - if ($selectedStartYear !== null) { - $removedRows = $this->db->table('enrollments') - ->select('student_id, school_year') - ->where('is_withdrawn', 1) - ->get()->getResultArray(); - foreach ($removedRows as $row) { - $rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? '')); - if ($rowYear !== null && $rowYear < $selectedStartYear) { - $removedPriorIds[(int)($row['student_id'] ?? 0)] = true; - } - } - } + $removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear); $returningStudentIds = $this->priorYearStudentIds($selectedYear); foreach ($students as &$s) { // ===== Ensure IDs needed by the modal ===== $s['student_id'] = (int)($s['id'] ?? 0); - $s['removed_previous_year'] = isset($removedPriorIds[$s['student_id']]) ? 'Yes' : 'No'; + $priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null; + $s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No'; + $s['prior_removed_status'] = $priorRemovedStatus; // Prefer parent_id; fallback to secondparent_user_id if present if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) { @@ -2389,7 +2421,9 @@ class AdministratorController extends BaseController // ===== Admission override ===== // Enrollment status for selected year $statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear); - if (!empty($statusForYear)) { + if (!empty($priorRemovedStatus)) { + $s['enrollment_status'] = $priorRemovedStatus; + } elseif (!empty($statusForYear)) { $s['enrollment_status'] = $statusForYear; } elseif (($s['admission_status'] ?? null) === 'denied') { $s['enrollment_status'] = 'denied'; @@ -2401,6 +2435,8 @@ class AdministratorController extends BaseController // ===== Class section name for the selected year ===== $name = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear); $s['class_section'] = $name ?: 'Class not Assigned'; + $calculatedAge = $this->calculateAgeAsOfSchoolYearStartYear($s['dob'] ?? null, $selectedYear); + $s['age'] = $calculatedAge ?? ($s['age'] ?? null); // ===== Sortable registration date (for data-order in view) ===== $s['registration_date_order'] = !empty($s['registration_date']) @@ -2445,27 +2481,18 @@ class AdministratorController extends BaseController try { $selectedYear = $this->currentSchoolYearName((string)($this->schoolYear ?? '')); + $this->syncReviewDecisionEnrollments($selectedYear); + $students = $this->studentModel->getStudentsWithClassAndEnrollment($selectedYear); - $selectedStartYear = $this->getSchoolYearStartYear((string)$selectedYear); - $removedPriorIds = []; - if ($selectedStartYear !== null) { - $removedRows = $this->db->table('enrollments') - ->select('student_id, school_year') - ->where('is_withdrawn', 1) - ->get()->getResultArray(); - foreach ($removedRows as $row) { - $rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? '')); - if ($rowYear !== null && $rowYear < $selectedStartYear) { - $removedPriorIds[(int)($row['student_id'] ?? 0)] = true; - } - } - } + $removedPriorStatuses = $this->removedPriorYearStudentStatuses($selectedYear); $returningStudentIds = $this->priorYearStudentIds($selectedYear); foreach ($students as &$s) { $s['student_id'] = (int)($s['id'] ?? 0); - $s['removed_previous_year'] = isset($removedPriorIds[$s['student_id']]) ? 'Yes' : 'No'; + $priorRemovedStatus = $removedPriorStatuses[$s['student_id']] ?? null; + $s['removed_previous_year'] = $priorRemovedStatus !== null ? 'Yes' : 'No'; + $s['prior_removed_status'] = $priorRemovedStatus; if (empty($s['parent_id']) && !empty($s['secondparent_user_id'])) { $s['parent_id'] = (int)$s['secondparent_user_id']; @@ -2490,7 +2517,9 @@ class AdministratorController extends BaseController $s['new_student'] = $s['is_new'] === 1 ? 'Yes' : 'No'; $statusForYear = $this->enrollmentModel->getEnrollmentStatus((int)$s['student_id'], $selectedYear); - if (!empty($statusForYear)) { + if (!empty($priorRemovedStatus)) { + $s['enrollment_status'] = $priorRemovedStatus; + } elseif (!empty($statusForYear)) { $s['enrollment_status'] = $statusForYear; } elseif (($s['admission_status'] ?? null) === 'denied') { $s['enrollment_status'] = 'denied'; @@ -2501,6 +2530,8 @@ class AdministratorController extends BaseController $className = $this->studentClassModel->getClassSectionsByStudentId((int)$s['student_id'], $selectedYear); $s['class_section'] = $className ?: 'Class not Assigned'; + $calculatedAge = $this->calculateAgeAsOfSchoolYearStartYear($s['dob'] ?? null, $selectedYear); + $s['age'] = $calculatedAge ?? ($s['age'] ?? null); $s['registration_date_order'] = !empty($s['registration_date']) ? date('Y-m-d', strtotime($s['registration_date'])) @@ -2536,6 +2567,172 @@ class AdministratorController extends BaseController } } + private function syncReviewDecisionEnrollments(string $selectedYear): void + { + $selectedYear = trim($selectedYear); + $sourceYear = $this->getPreviousSchoolYear($selectedYear); + if ($selectedYear === '' || $sourceYear === '' || ! $this->db->tableExists('enrollments')) { + return; + } + + $studentIds = $this->sourceYearStudentIds($sourceYear); + if ($studentIds === []) { + return; + } + + $transitionService = service('enrollmentTransition'); + $now = utc_now(); + + foreach ($studentIds as $studentId) { + try { + $evaluation = $transitionService->evaluate((int) $studentId, $sourceYear, $selectedYear, 'parent'); + } catch (\Throwable $e) { + log_message('error', 'Review & Decision enrollment sync evaluation failed for student {studentId}: {message}', [ + 'studentId' => $studentId, + 'message' => $e->getMessage(), + ]); + continue; + } + + if (! $this->needsReviewDecisionEnrollment($evaluation)) { + continue; + } + + $student = $this->studentModel->find((int) $studentId); + if (! is_array($student)) { + continue; + } + + $parentId = (int) ($student['parent_id'] ?? ($student['secondparent_user_id'] ?? 0)); + if ($parentId <= 0) { + log_message('warning', 'Review & Decision enrollment sync skipped student {studentId}: no parent ID.', [ + 'studentId' => $studentId, + ]); + continue; + } + + $existing = $this->db->table('enrollments') + ->select('id, enrollment_status') + ->where('student_id', (int) $studentId) + ->where('school_year', $selectedYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + if ($existing !== null) { + $existingStatus = (string) ($existing['enrollment_status'] ?? ''); + if ($existingStatus === 'review & decision' || ! in_array($existingStatus, ['', 'admission under review'], true)) { + continue; + } + } + + $payload = [ + 'student_id' => (int) $studentId, + 'parent_id' => $parentId, + 'school_year' => $selectedYear, + 'semester' => (string) $this->semester, + 'source_school_year' => $sourceYear, + 'deliberation_decision' => $evaluation['deliberation_decision'] ?? null, + 'source_grade_id' => $evaluation['source_grade_id'] ?? null, + 'assigned_grade_id' => $evaluation['assigned_grade_id'] ?? null, + 'source_class_section_id' => $evaluation['source_class_section_id'] ?? null, + 'assigned_class_section_id' => $evaluation['assigned_class_section_id'] ?? null, + 'placement_status' => $evaluation['placement_status'] ?? 'not_created', + 'age_reference_date' => $evaluation['age_reference_date'] ?? null, + 'age_on_reference_date' => $evaluation['age_on_reference_date'] ?? null, + 'adult_student' => ! empty($evaluation['adult_student']) ? 1 : 0, + 'parent_enrollment_allowed' => ! empty($evaluation['parent_enrollment_allowed']) ? 1 : 0, + 'student_self_enrollment_allowed' => ! empty($evaluation['student_self_enrollment_allowed']) ? 1 : 0, + 'exception_required' => 1, + 'exception_reason' => implode(', ', array_filter(array_column($evaluation['flags'] ?? [], 'flag_type'))) ?: implode(' ', array_map('strval', $evaluation['blockers'] ?? [])), + 'enrollment_date' => local_date(utc_now(), 'Y-m-d'), + 'enrollment_status' => 'review & decision', + 'admission_status' => 'pending', + 'is_withdrawn' => 0, + 'updated_at' => $now, + ]; + $payload = $this->filterEnrollmentPayloadByColumns($payload); + + if ($existing !== null) { + $this->db->table('enrollments') + ->where('id', (int) $existing['id']) + ->update($payload); + } else { + $payload['created_at'] = $now; + $this->db->table('enrollments')->insert($this->filterEnrollmentPayloadByColumns($payload)); + } + } + } + + private function needsReviewDecisionEnrollment(array $evaluation): bool + { + $decision = (string) ($evaluation['deliberation_decision'] ?? ''); + + if (in_array($decision, [ + DeliberationDecision::EXPELLED, + DeliberationDecision::WITHDRAWN, + DeliberationDecision::DEFERRED_DECISION, + ], true)) { + return true; + } + + $hasSourceAssignment = (int) ($evaluation['source_class_section_id'] ?? 0) > 0 + || (int) ($evaluation['source_grade_id'] ?? 0) > 0; + + return $decision === '' + && $hasSourceAssignment + && array_filter($evaluation['blockers'] ?? []) !== []; + } + + private function sourceYearStudentIds(string $sourceYear): array + { + $studentIds = []; + + foreach (['student_class', 'enrollments', 'student_decisions'] as $table) { + if (! $this->db->tableExists($table) || ! $this->db->fieldExists('student_id', $table)) { + continue; + } + + $yearColumn = match ($table) { + 'student_class', 'student_decisions' => 'school_year', + default => 'school_year', + }; + + if (! $this->db->fieldExists($yearColumn, $table)) { + continue; + } + + $rows = $this->db->table($table) + ->select('student_id') + ->where($yearColumn, $sourceYear) + ->where('student_id IS NOT NULL', null, false) + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId > 0) { + $studentIds[$studentId] = true; + } + } + } + + return array_keys($studentIds); + } + + private function filterEnrollmentPayloadByColumns(array $payload): array + { + foreach (array_keys($payload) as $column) { + if (! $this->db->fieldExists($column, 'enrollments')) { + unset($payload[$column]); + } + } + + return $payload; + } + private function enrollmentClassOptions(string $selectedYear): array { $select = ['id', 'class_section_id', 'class_section_name']; @@ -2572,6 +2769,115 @@ class AdministratorController extends BaseController ->findAll(); } + private function removedPriorYearStudentStatuses(string $selectedYear): array + { + $selectedStartYear = $this->getSchoolYearStartYear($selectedYear); + if ($selectedStartYear === null || ! $this->db->tableExists('enrollments')) { + return []; + } + + $select = ['student_id', 'school_year']; + $hasIsWithdrawn = $this->db->fieldExists('is_withdrawn', 'enrollments'); + $hasEnrollmentStatus = $this->db->fieldExists('enrollment_status', 'enrollments'); + $hasAdmissionStatus = $this->db->fieldExists('admission_status', 'enrollments'); + + if ($hasIsWithdrawn) { + $select[] = 'is_withdrawn'; + } + if ($hasEnrollmentStatus) { + $select[] = 'enrollment_status'; + } + if ($hasAdmissionStatus) { + $select[] = 'admission_status'; + } + + if (! $hasIsWithdrawn && ! $hasEnrollmentStatus && ! $hasAdmissionStatus) { + return []; + } + + $builder = $this->db->table('enrollments') + ->select(implode(', ', $select)) + ->where('student_id IS NOT NULL', null, false) + ->where('school_year IS NOT NULL', null, false) + ->groupStart(); + + $hasRemovalCondition = false; + if ($hasIsWithdrawn) { + $builder->where('is_withdrawn', 1); + $hasRemovalCondition = true; + } + + if ($hasEnrollmentStatus) { + if ($hasRemovalCondition) { + $builder->orWhereIn('enrollment_status', ['withdrawn', 'widthran', 'denied']); + } else { + $builder->whereIn('enrollment_status', ['withdrawn', 'widthran', 'denied']); + } + $hasRemovalCondition = true; + } + + if ($hasAdmissionStatus) { + if ($hasRemovalCondition) { + $builder->orWhere('admission_status', 'denied'); + } else { + $builder->where('admission_status', 'denied'); + } + $hasRemovalCondition = true; + } + + $builder->groupEnd(); + if (! $hasRemovalCondition) { + return []; + } + + $removedPriorStatuses = []; + foreach ($builder->get()->getResultArray() as $row) { + $rowYear = $this->getSchoolYearStartYear((string)($row['school_year'] ?? '')); + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || $rowYear === null || $rowYear >= $selectedStartYear) { + continue; + } + + $status = $this->priorRemovedEnrollmentStatus($row); + if ($status === null) { + continue; + } + + if ( + !isset($removedPriorStatuses[$studentId]) + || $rowYear > (int)$removedPriorStatuses[$studentId]['year'] + ) { + $removedPriorStatuses[$studentId] = [ + 'year' => $rowYear, + 'status' => $status, + ]; + } + } + + $statusByStudentId = []; + foreach ($removedPriorStatuses as $studentId => $row) { + $statusByStudentId[(int)$studentId] = (string)$row['status']; + } + + return $statusByStudentId; + } + + private function priorRemovedEnrollmentStatus(array $row): ?string + { + $enrollmentStatus = strtolower(trim((string)($row['enrollment_status'] ?? ''))); + $admissionStatus = strtolower(trim((string)($row['admission_status'] ?? ''))); + + if ($enrollmentStatus === 'denied' || $admissionStatus === 'denied') { + return 'denied'; + } + + if (in_array($enrollmentStatus, ['withdrawn', 'widthran'], true) || (int)($row['is_withdrawn'] ?? 0) === 1) { + return 'withdrawn'; + } + + return null; + } + private function priorYearStudentIds(string $selectedYear): array { $selectedStartYear = $this->getSchoolYearStartYear($selectedYear); @@ -2674,6 +2980,7 @@ class AdministratorController extends BaseController $validStatuses = [ 'admission under review', + 'review & decision', 'payment pending', 'enrolled', 'withdraw under review', @@ -2921,6 +3228,7 @@ class AdministratorController extends BaseController // === AFTER COMMIT: fire specific events, batched per parent/status === $eventMap = [ 'admission under review' => 'admissionUnderReview', + 'review & decision' => 'admissionUnderReview', 'payment pending' => 'paymentPending', 'enrolled' => 'studentEnrolled', 'withdraw under review' => 'withdrawUnderReview', diff --git a/app/Controllers/View/AssignmentController.php b/app/Controllers/View/AssignmentController.php index d0ba0cd..757f450 100644 --- a/app/Controllers/View/AssignmentController.php +++ b/app/Controllers/View/AssignmentController.php @@ -46,9 +46,35 @@ class AssignmentController extends BaseController // Apply school year filter (default to current config) but avoid semester filtering so the full year is visible $selectedSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? ''); - $year = (string)($this->schoolYear ?? ''); + $year = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? '')); + if ($year === '') { + $year = $this->currentSchoolYearName((string)($this->schoolYear ?? '')); + } $this->applyPendingDistributionDraftsForEnrolledStudents($year); + $this->repairMissingEnrollmentClassAssignments($year); + $distributedSectionIds = array_values(array_unique(array_merge( + $this->distributedClassSectionIds($year), + $this->enrollmentAssignedClassSectionIds($year) + ))); + + $classSectionsAll = []; + if (!empty($distributedSectionIds)) { + $classSectionsAll = $this->classSectionModel + ->select('id, class_section_id, class_section_name, school_year') + ->where('school_year', $year) + ->whereIn('class_section_id', $distributedSectionIds) + ->orderBy('class_section_name', 'ASC') + ->findAll(); + } + + $classSectionById = []; + foreach ($classSectionsAll as $section) { + $sectionId = (int)($section['class_section_id'] ?? 0); + if ($sectionId > 0) { + $classSectionById[$sectionId] = $section; + } + } $tcQ = $this->teacherClassModel; if ($year !== '') { @@ -73,20 +99,26 @@ class AssignmentController extends BaseController $studentsBySection[$sc['class_section_id']][] = $sc; } - $allSectionIds = array_unique(array_merge(array_keys($teacherBySection), array_keys($studentsBySection))); + $allSectionIds = array_unique(array_merge(array_keys($classSectionById), array_keys($teacherBySection), array_keys($studentsBySection))); + $distributedSectionSet = array_fill_keys($distributedSectionIds, true); foreach ($allSectionIds as $classSectionId) { + if (!isset($distributedSectionSet[(int)$classSectionId])) { + continue; + } + $teacherClasses = $teacherBySection[$classSectionId] ?? []; $studentClasses = $studentsBySection[$classSectionId] ?? []; $hasTeacher = !empty($teacherClasses); $hasStudents = !empty($studentClasses); + $hasClassSection = isset($classSectionById[(int)$classSectionId]); - if (!$hasTeacher && !$hasStudents) { + if (!$hasClassSection && !$hasTeacher && !$hasStudents) { continue; } - $classSectionName = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? ''); + $classSectionName = (string) ($classSectionById[(int)$classSectionId]['class_section_name'] ?? $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? ''); $mainTeachers = []; $teacherAssistants = []; @@ -176,12 +208,19 @@ class AssignmentController extends BaseController $schoolYearsList = []; try { $db = Database::connect(); - $yearsQuery = $db->table('teacher_class') + $yearsQuery = $db->table('classSection') ->select('DISTINCT school_year', false) ->where('school_year IS NOT NULL', null, false) ->orderBy('school_year', 'DESC') ->get() ->getResultArray(); + $studentYearsQuery = $db->table('student_class') + ->select('DISTINCT school_year', false) + ->where('school_year IS NOT NULL', null, false) + ->orderBy('school_year', 'DESC') + ->get() + ->getResultArray(); + $yearsQuery = array_merge($yearsQuery, $studentYearsQuery); foreach ($yearsQuery as $row) { $val = (string)($row['school_year'] ?? ''); if ($val !== '' && !in_array($val, $schoolYearsList, true)) { @@ -196,7 +235,7 @@ class AssignmentController extends BaseController } // Sort sections - usort($data['classSections'], fn($a, $b) => strcmp((string) $a['class_section_name'], (string) $b['class_section_name'])); + usort($data['classSections'], fn($a, $b) => strnatcasecmp((string) $a['class_section_name'], (string) $b['class_section_name'])); $data['schoolYears'] = $schoolYearsList; $data['schoolYear'] = $year; @@ -206,6 +245,205 @@ class AssignmentController extends BaseController return view('administrator/class_assignment', $data); } + private function distributedClassSectionIds(string $year): array + { + $year = trim($year); + if ($year === '') { + return []; + } + + try { + $db = Database::connect(); + if (! $db->tableExists('student_section_distribution_drafts')) { + return []; + } + + $baseRows = $db->table('classSection') + ->select('class_id, class_section_id, class_section_name') + ->where('school_year', $year) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->orderBy('class_id', 'ASC') + ->get() + ->getResultArray(); + + $draftRows = $db->table('student_section_distribution_drafts d') + ->select('d.class_id, d.class_section_id, COALESCE(cs.class_section_name, d.class_section_id) AS class_section_name', false) + ->join( + 'classSection cs', + 'cs.class_section_id = d.class_section_id AND cs.school_year = d.school_year', + 'left', + false + ) + ->where('d.school_year', $year) + ->where('d.class_section_id >', 0) + ->whereIn('status', ['pending', 'applied']) + ->get() + ->getResultArray(); + + $draftsByClassId = []; + foreach ($draftRows as $row) { + $classId = (int)($row['class_id'] ?? 0); + $sectionId = (int)($row['class_section_id'] ?? 0); + if ($classId <= 0 || $sectionId <= 0) { + continue; + } + + $draftsByClassId[$classId][$sectionId] = [ + 'class_section_id' => $sectionId, + 'class_section_name' => (string)($row['class_section_name'] ?? ''), + ]; + } + + $allowed = []; + $seenClassIds = []; + foreach ($baseRows as $row) { + $classId = (int)($row['class_id'] ?? 0); + $baseSectionId = (int)($row['class_section_id'] ?? 0); + $baseName = trim((string)($row['class_section_name'] ?? '')); + if ($classId <= 0 || $baseSectionId <= 0 || $baseName === '') { + continue; + } + + $normalized = strtolower($baseName); + $isAutoDistributeBase = $normalized === 'youth' + || (ctype_digit($normalized) && (int)$normalized >= 1 && (int)$normalized <= 10); + if (!$isAutoDistributeBase) { + continue; + } + + $seenClassIds[$classId] = true; + $classDrafts = $draftsByClassId[$classId] ?? []; + $hasBaseDraft = false; + $letteredDraftIds = []; + foreach ($classDrafts as $draft) { + $draftSectionId = (int)($draft['class_section_id'] ?? 0); + $draftName = trim((string)($draft['class_section_name'] ?? '')); + if ($draftSectionId === $baseSectionId || $draftName === '' || strpos($draftName, '-') === false) { + $hasBaseDraft = true; + continue; + } + + $letteredDraftIds[] = $draftSectionId; + } + + if ($hasBaseDraft || empty($letteredDraftIds)) { + $allowed[] = $baseSectionId; + continue; + } + + foreach ($letteredDraftIds as $draftSectionId) { + $allowed[] = $draftSectionId; + } + } + + foreach ($draftsByClassId as $classId => $classDrafts) { + if (isset($seenClassIds[$classId])) { + continue; + } + + foreach ($classDrafts as $draft) { + $sectionId = (int)($draft['class_section_id'] ?? 0); + if ($sectionId > 0) { + $allowed[] = $sectionId; + } + } + } + + return array_values(array_unique(array_filter( + $allowed, + static fn(int $sectionId): bool => $sectionId > 0 + ))); + } catch (\Throwable $e) { + log_message('error', 'distributedClassSectionIds failed: ' . $e->getMessage()); + return []; + } + } + + private function enrollmentAssignedClassSectionIds(string $year): array + { + $year = trim($year); + if ($year === '') { + return []; + } + + try { + $db = Database::connect(); + $allowedStatuses = ['admission under review', 'review & decision', 'payment pending', 'enrolled']; + $ids = []; + + if ($db->tableExists('enrollments')) { + $builder = $db->table('enrollments e') + ->select('e.class_section_id') + ->join('students s', 's.id = e.student_id', 'inner') + ->where('e.school_year', $year) + ->whereIn('e.enrollment_status', $allowedStatuses) + ->where('e.class_section_id IS NOT NULL', null, false) + ->where('e.class_section_id >', 0); + + if ($db->fieldExists('is_active', 'students')) { + $builder->where('s.is_active', 1); + } + if ($db->fieldExists('is_withdrawn', 'enrollments')) { + $builder->groupStart() + ->where('e.is_withdrawn', 0) + ->orWhere('e.is_withdrawn', null) + ->groupEnd(); + } + + foreach ($builder->groupBy('e.class_section_id')->get()->getResultArray() as $row) { + $sectionId = (int)($row['class_section_id'] ?? 0); + if ($sectionId > 0) { + $ids[] = $sectionId; + } + } + } + + if ($db->tableExists('student_class') && $db->tableExists('enrollments')) { + $builder = $db->table('student_class sc') + ->select('sc.class_section_id') + ->join( + 'enrollments e', + 'e.student_id = sc.student_id AND e.school_year = sc.school_year', + 'inner', + false + ) + ->join('students s', 's.id = sc.student_id', 'inner') + ->where('sc.school_year', $year) + ->whereIn('e.enrollment_status', $allowedStatuses) + ->where('sc.class_section_id IS NOT NULL', null, false) + ->where('sc.class_section_id >', 0); + + if ($db->fieldExists('is_active', 'students')) { + $builder->where('s.is_active', 1); + } + if ($db->fieldExists('is_event_only', 'student_class')) { + $builder->groupStart() + ->where('sc.is_event_only', 0) + ->orWhere('sc.is_event_only', null) + ->groupEnd(); + } + if ($db->fieldExists('is_withdrawn', 'enrollments')) { + $builder->groupStart() + ->where('e.is_withdrawn', 0) + ->orWhere('e.is_withdrawn', null) + ->groupEnd(); + } + + foreach ($builder->groupBy('sc.class_section_id')->get()->getResultArray() as $row) { + $sectionId = (int)($row['class_section_id'] ?? 0); + if ($sectionId > 0) { + $ids[] = $sectionId; + } + } + } + + return array_values(array_unique($ids)); + } catch (\Throwable $e) { + log_message('error', 'enrollmentAssignedClassSectionIds failed: ' . $e->getMessage()); + return []; + } + } + private function applyPendingDistributionDraftsForEnrolledStudents(string $year): void { if ($year === '') { @@ -227,7 +465,7 @@ class AssignmentController extends BaseController ) ->where('d.school_year', $year) ->where('d.status', 'pending') - ->whereIn('e.enrollment_status', ['payment pending', 'enrolled']) + ->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled']) ->groupBy('d.id, d.student_id, d.class_section_id') ->get() ->getResultArray(); @@ -274,10 +512,12 @@ class AssignmentController extends BaseController $db->table('enrollments') ->where('student_id', $studentId) ->where('school_year', $year) - ->whereIn('enrollment_status', ['payment pending', 'enrolled']) + ->whereIn('enrollment_status', ['admission under review', 'payment pending', 'enrolled']) ->update([ - 'class_section_id' => $sectionId, - 'updated_at' => $now, + 'class_section_id' => $sectionId, + 'assigned_class_section_id' => $sectionId, + 'placement_status' => 'automatic_distribution_applied', + 'updated_at' => $now, ]); $db->table('student_section_distribution_drafts') @@ -294,6 +534,188 @@ class AssignmentController extends BaseController } } + private function repairMissingEnrollmentClassAssignments(string $year): void + { + if ($year === '') { + return; + } + + try { + $db = Database::connect(); + $this->ensureClassSectionsForYear($db, $year); + if (! $db->tableExists('enrollments') || ! $db->tableExists('student_class')) { + return; + } + + $previousYear = $this->previousSchoolYearName($year); + if ($previousYear === null) { + return; + } + + $rows = $db->table('enrollments e') + ->select('e.student_id, e.school_year, e.enrollment_status, e.class_section_id, sc.id AS student_class_id') + ->join('student_class sc', 'sc.student_id = e.student_id AND sc.school_year = e.school_year', 'left') + ->where('e.school_year', $year) + ->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled']) + ->groupStart() + ->where('e.class_section_id', null) + ->orWhere('e.class_section_id', 0) + ->orWhere('sc.id', null) + ->groupEnd() + ->groupBy('e.student_id, e.school_year, e.enrollment_status, e.class_section_id, sc.id') + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0) { + continue; + } + + $evaluation = service('enrollmentTransition')->evaluate($studentId, $previousYear, $year, 'admin'); + if (!($evaluation['academic_eligible'] ?? false) || ($evaluation['blockers'] ?? []) !== []) { + continue; + } + + $targetSectionId = (int)($evaluation['assigned_class_section_id'] ?? 0); + if ($targetSectionId <= 0 && (int)($evaluation['assigned_grade_id'] ?? 0) > 0) { + $base = $this->baseSectionForClassYear((int)$evaluation['assigned_grade_id'], $year); + $targetSectionId = (int)($base['class_section_id'] ?? 0); + } + + if ($targetSectionId <= 0) { + continue; + } + + $placementStatus = match ((string)($evaluation['placement_status'] ?? '')) { + 'automatic_distribution_pending' => 'base_section_pending_distribution', + 'same_class_assigned', 'temporary_same_grade' => (string)$evaluation['placement_status'], + default => 'manual_class_assigned', + }; + + $existing = $db->table('student_class') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year', $year) + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + $studentClassPayload = [ + 'student_id' => $studentId, + 'class_section_id' => $targetSectionId, + 'school_year' => $year, + 'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null, + 'updated_at' => utc_now(), + ]; + + if ($existing !== null) { + $db->table('student_class')->where('id', (int)$existing['id'])->update($studentClassPayload); + } else { + $studentClassPayload['created_at'] = utc_now(); + $db->table('student_class')->insert($studentClassPayload); + } + + $db->table('enrollments') + ->where('student_id', $studentId) + ->where('school_year', $year) + ->whereIn('enrollment_status', ['admission under review', 'payment pending', 'enrolled']) + ->update([ + 'class_section_id' => $targetSectionId, + 'assigned_class_section_id' => $targetSectionId, + 'placement_status' => $placementStatus, + 'updated_at' => utc_now(), + ]); + } + } catch (\Throwable $e) { + log_message('error', 'repairMissingEnrollmentClassAssignments failed: ' . $e->getMessage()); + } + } + + private function baseSectionForClassYear(int $classId, string $schoolYear): ?array + { + $db = Database::connect(); + $this->ensureClassSectionsForYear($db, $schoolYear); + if ($classId <= 0 || $schoolYear === '' || ! $db->tableExists('classSection')) { + return null; + } + + $builder = $db->table('classSection') + ->select('class_section_id, class_section_name, class_id') + ->where('class_id', $classId) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->orderBy('id', 'ASC') + ->limit(1); + + if ($db->fieldExists('school_year', 'classSection')) { + $builder->where('school_year', $schoolYear); + } + + $row = $builder->get()->getRowArray(); + + return $row !== null && (int)($row['class_section_id'] ?? 0) > 0 ? $row : null; + } + + private function ensureClassSectionsForYear($db, string $targetSchoolYear): void + { + $targetSchoolYear = trim($targetSchoolYear); + if ($targetSchoolYear === '' || ! $db->tableExists('classSection') || ! $db->fieldExists('school_year', 'classSection')) { + return; + } + + if ($db->table('classSection')->where('school_year', $targetSchoolYear)->countAllResults() > 0) { + return; + } + + $sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + if ($sourceSchoolYear === null) { + return; + } + + $sourceRows = $db->table('classSection') + ->select('class_id, class_section_id, class_section_name') + ->where('school_year', $sourceSchoolYear) + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + + $now = utc_now(); + foreach ($sourceRows as $row) { + $classSectionId = (int)($row['class_section_id'] ?? 0); + if ($classSectionId <= 0) { + continue; + } + + $exists = $db->table('classSection') + ->where('school_year', $targetSchoolYear) + ->where('class_section_id', $classSectionId) + ->countAllResults(); + if ($exists > 0) { + continue; + } + + $db->table('classSection')->insert([ + 'class_id' => (int)($row['class_id'] ?? 0), + 'class_section_id' => $classSectionId, + 'class_section_name' => (string)($row['class_section_name'] ?? ''), + 'school_year' => $targetSchoolYear, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + private function previousSchoolYearName(string $schoolYear): ?string + { + $schoolYear = trim($schoolYear); + if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches)) { + return null; + } + + return ((int)$matches[1] - 1) . '-' . ((int)$matches[2] - 1); + } + public function save() @@ -316,8 +738,46 @@ class AssignmentController extends BaseController // API: JSON payload for Classes List page public function classAssignmentData() { - $teacherClassesAll = $this->teacherClassModel->findAll(); - $studentClassesAll = $this->studentClassModel->findAll(); + $year = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('school_year') ?? '')); + if ($year === '') { + $year = $this->currentSchoolYearName((string)($this->schoolYear ?? '')); + } + $this->applyPendingDistributionDraftsForEnrolledStudents($year); + $this->repairMissingEnrollmentClassAssignments($year); + $distributedSectionIds = array_values(array_unique(array_merge( + $this->distributedClassSectionIds($year), + $this->enrollmentAssignedClassSectionIds($year) + ))); + + $classSectionsAll = []; + if (!empty($distributedSectionIds)) { + $classSectionsAll = $this->classSectionModel + ->select('id, class_section_id, class_section_name, school_year') + ->where('school_year', $year) + ->whereIn('class_section_id', $distributedSectionIds) + ->orderBy('class_section_name', 'ASC') + ->findAll(); + } + + $classSectionById = []; + foreach ($classSectionsAll as $section) { + $sectionId = (int)($section['class_section_id'] ?? 0); + if ($sectionId > 0) { + $classSectionById[$sectionId] = $section; + } + } + + $tcQ = $this->teacherClassModel; + if ($year !== '') { + $tcQ = $tcQ->where('school_year', $year); + } + $teacherClassesAll = $tcQ->findAll(); + + $scQ = $this->studentClassModel->active(); + if ($year !== '') { + $scQ = $scQ->where('student_class.school_year', $year); + } + $studentClassesAll = $scQ->findAll(); // Group by section $teacherBySection = []; @@ -331,16 +791,22 @@ class AssignmentController extends BaseController if ($secId) $studentsBySection[$secId][] = $sc; } - $allSectionIds = array_values(array_unique(array_merge(array_keys($teacherBySection), array_keys($studentsBySection)))); + $allSectionIds = array_values(array_unique(array_merge(array_keys($classSectionById), array_keys($teacherBySection), array_keys($studentsBySection)))); + $distributedSectionSet = array_fill_keys($distributedSectionIds, true); $classSections = []; foreach ($allSectionIds as $classSectionId) { + if (!isset($distributedSectionSet[(int)$classSectionId])) { + continue; + } + $hasTeacher = !empty($teacherBySection[$classSectionId]); $hasStudents = !empty($studentsBySection[$classSectionId]); - if (!$hasTeacher && !$hasStudents) continue; + $hasClassSection = isset($classSectionById[(int)$classSectionId]); + if (!$hasClassSection && !$hasTeacher && !$hasStudents) continue; - $classSectionName = (string) ($this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? ''); + $classSectionName = (string) ($classSectionById[(int)$classSectionId]['class_section_name'] ?? $this->classSectionModel->getClassSectionNameBySectionId($classSectionId) ?? ''); $mainTeachers = []; $teacherAssistants = []; @@ -374,12 +840,18 @@ class AssignmentController extends BaseController // Load students for the section $students = []; - foreach ($this->studentClassModel->active()->where('student_class.class_section_id', $classSectionId)->findAll() as $studentClass) { + $seenStudentIds = []; + foreach ($studentsBySection[$classSectionId] ?? [] as $studentClass) { + $sid = (int)($studentClass['student_id'] ?? 0); + if ($sid <= 0 || isset($seenStudentIds[$sid])) { + continue; + } $stu = $this->studentModel - ->where('id', (int)$studentClass['student_id']) + ->where('id', $sid) ->where('is_active', 1) ->first(); if (!$stu) continue; + $students[] = [ 'id' => (int)$stu['id'], 'firstname' => (string)($stu['firstname'] ?? ''), @@ -391,6 +863,7 @@ class AssignmentController extends BaseController 'tuition_paid' => (bool)($stu['tuition_paid'] ?? false), 'school_id' => (string)($stu['school_id'] ?? ''), ]; + $seenStudentIds[$sid] = true; } $classSections[] = [ @@ -406,7 +879,7 @@ class AssignmentController extends BaseController } // Sort by class_section_name - usort($classSections, fn($a, $b) => strcmp((string)($a['class_section_name'] ?? ''), (string)($b['class_section_name'] ?? ''))); + usort($classSections, fn($a, $b) => strnatcasecmp((string)($a['class_section_name'] ?? ''), (string)($b['class_section_name'] ?? ''))); return $this->response->setJSON([ 'classSections' => $classSections, diff --git a/app/Controllers/View/EnrollmentAdminController.php b/app/Controllers/View/EnrollmentAdminController.php new file mode 100644 index 0000000..4bb58e0 --- /dev/null +++ b/app/Controllers/View/EnrollmentAdminController.php @@ -0,0 +1,633 @@ +db = \Config\Database::connect(); + } + + public function dashboard() + { + $schoolYear = trim((string) ($this->request->getGet('school_year') ?? $this->currentSchoolYearName((string) ($this->schoolYear ?? '')))); + $status = trim((string) ($this->request->getGet('status') ?? 'open')); + $flagType = trim((string) ($this->request->getGet('flag_type') ?? '')); + $assignedTo = trim((string) ($this->request->getGet('assigned_to') ?? '')); + + $flags = $this->enrollmentFlags($schoolYear, $status, $flagType, $assignedTo); + + return view('administrator/enrollment_admin_dashboard', [ + 'flags' => $flags, + 'schoolYear' => $schoolYear, + 'status' => $status, + 'flagType' => $flagType, + 'assignedTo' => $assignedTo, + 'flagTypes' => $this->flagTypes(), + 'schoolYears' => $this->schoolYears(), + 'classSections' => $this->classSections($schoolYear), + 'admins' => $this->adminUsers(), + 'enrollmentFollowups' => $this->enrollmentFollowups($schoolYear), + 'auditRows' => $this->auditRows($schoolYear), + 'launchState' => $this->launchState($schoolYear), + 'previewParentId' => $this->firstParentWithStudents(), + 'emailExamples' => service('enrollmentRegistrationEmail')->previewExamplesForSchoolYear($schoolYear), + ]); + } + + public function approveLaunch() + { + $schoolYear = trim((string) ($this->request->getPost('school_year') ?? '')); + if ($schoolYear === '') { + return redirect()->back()->with('error', 'School year is required.'); + } + + if (! $this->launchConfigurationComplete($schoolYear, $missing)) { + return redirect()->back()->with('error', 'Registration launch is not ready: ' . implode(', ', $missing)); + } + + $this->db->table('school_years') + ->where('name', $schoolYear) + ->update([ + 'registration_launch_approved_at' => date('Y-m-d H:i:s'), + 'registration_launch_approved_by' => $this->userId(), + 'registration_email_template_version' => \App\Services\EnrollmentRegistrationEmailService::TEMPLATE_VERSION, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return redirect()->back()->with('success', 'Registration launch approved for ' . $schoolYear . '.'); + } + + public function sendRegistrationEmails() + { + $schoolYear = trim((string) ($this->request->getPost('school_year') ?? '')); + if ($schoolYear === '') { + return redirect()->back()->with('error', 'School year is required.'); + } + + if (! $this->launchConfigurationComplete($schoolYear, $missing)) { + return redirect()->back()->with('error', 'Registration launch is not ready: ' . implode(', ', $missing)); + } + + $force = (bool) $this->request->getPost('force_resend'); + $result = service('enrollmentRegistrationEmail')->sendForSchoolYearName($schoolYear, $force); + $message = sprintf( + 'Registration emails processed for %s: %d sent, %d failed, %d skipped.', + $schoolYear, + (int) ($result['sent'] ?? 0), + (int) ($result['failed'] ?? 0), + (int) ($result['skipped'] ?? 0) + ); + + $details = array_filter(array_map('strval', $result['messages'] ?? [])); + if ($details !== []) { + $message .= ' ' . implode(' ', $details); + } + + return redirect()->back()->with(((int) ($result['failed'] ?? 0) > 0) ? 'error' : 'success', $message); + } + + public function previewEmail() + { + $schoolYear = trim((string) ($this->request->getGet('school_year') ?? '')); + $parentId = (int) ($this->request->getGet('parent_id') ?? 0); + if ($schoolYear === '' || $parentId <= 0) { + return redirect()->back()->with('error', 'School year and parent are required for preview.'); + } + + $message = service('enrollmentRegistrationEmail')->previewForParent($schoolYear, $parentId); + if ($message === null) { + return redirect()->back()->with('error', 'No preview email could be generated for that parent.'); + } + + return view('administrator/enrollment_email_preview', [ + 'schoolYear' => $schoolYear, + 'parentId' => $parentId, + 'subject' => $message['subject'], + 'body' => $message['body'], + ]); + } + + public function resolveFlag(int $id) + { + try { + $flag = $this->requireFlag($id); + $notes = trim((string) ($this->request->getPost('resolution_notes') ?? '')); + if ($notes === '') { + return redirect()->back()->with('error', 'Resolution notes are required.'); + } + + $this->resolveFlagRow($flag, $notes, 'flag_resolved'); + return redirect()->back()->with('success', 'Enrollment flag resolved.'); + } catch (Throwable $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } + + public function assignClass(int $id) + { + try { + $flag = $this->requireFlag($id); + $sectionId = (int) ($this->request->getPost('class_section_id') ?? 0); + $notes = trim((string) ($this->request->getPost('resolution_notes') ?? '')); + if ($sectionId <= 0) { + return redirect()->back()->with('error', 'Select a target class section.'); + } + if ($notes === '') { + return redirect()->back()->with('error', 'Resolution notes are required.'); + } + + $this->applyClassSection((int) $flag['student_id'], (string) $flag['school_year'], $sectionId, 'manual_class_assignment'); + $this->resolveFlagRow($flag, $notes, 'manual_class_assignment', ['class_section_id' => $sectionId]); + + return redirect()->back()->with('success', 'Class assignment applied.'); + } catch (Throwable $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } + + public function confirmMakeupPromotion(int $id) + { + try { + $flag = $this->requireFlag($id); + if ((string) ($flag['flag_type'] ?? '') !== 'PENDING_MAKE_UP_EXAM_PROMOTION') { + return redirect()->back()->with('error', 'This flag is not a make-up exam promotion flag.'); + } + + $sectionId = (int) ($this->request->getPost('class_section_id') ?? 0); + $examResult = trim((string) ($this->request->getPost('exam_result') ?? '')); + $notes = trim((string) ($this->request->getPost('resolution_notes') ?? '')); + if (! in_array($examResult, ['passed', 'failed'], true)) { + return redirect()->back()->with('error', 'Select whether the make-up exam was passed or failed.'); + } + if ($notes === '') { + return redirect()->back()->with('error', 'Resolution notes are required.'); + } + + if ($examResult === 'passed') { + if ($sectionId <= 0) { + return redirect()->back()->with('error', 'Select the promoted class section.'); + } + + $this->applyClassSection((int) $flag['student_id'], (string) $flag['school_year'], $sectionId, 'make_up_exam_promotion_completed', 'promotion_completed'); + $this->resolveFlagRow($flag, $notes, 'make_up_exam_promotion_completed', [ + 'exam_result' => $examResult, + 'class_section_id' => $sectionId, + ]); + } else { + $this->updateEnrollmentPlacementStatus((int) $flag['student_id'], (string) $flag['school_year'], 'no_promotion_required'); + $this->resolveFlagRow($flag, $notes, 'make_up_exam_no_promotion_required', [ + 'exam_result' => $examResult, + ]); + } + + return redirect()->back()->with('success', 'Make-up exam follow-up resolved.'); + } catch (Throwable $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } + + public function approveException(int $id) + { + try { + $flag = $this->requireFlag($id); + $reason = trim((string) ($this->request->getPost('reason') ?? '')); + if ($reason === '') { + return redirect()->back()->with('error', 'Approval reason is required.'); + } + + $this->db->table('enrollments') + ->where('student_id', (int) $flag['student_id']) + ->where('school_year', (string) $flag['school_year']) + ->update([ + 'exception_required' => 0, + 'exception_reason' => $reason, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + $this->resolveFlagRow($flag, $reason, 'enrollment_exception_approved'); + return redirect()->back()->with('success', 'Enrollment exception approved.'); + } catch (Throwable $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } + + private function enrollmentFlags(string $schoolYear, string $status, string $flagType, string $assignedTo): array + { + if (! $this->db->tableExists('enrollment_flags')) { + return []; + } + + $builder = $this->db->table('enrollment_flags ef') + ->select('ef.*') + ->select('s.firstname, s.lastname, s.school_id') + ->select('u.firstname AS assignee_firstname, u.lastname AS assignee_lastname') + ->join('students s', 's.id = ef.student_id', 'left') + ->join('users u', 'u.id = ef.assigned_to', 'left') + ->orderBy('ef.created_at', 'DESC') + ->orderBy('ef.id', 'DESC'); + + if ($schoolYear !== '') { + $builder->where('ef.school_year', $schoolYear); + } + if ($status !== '') { + $builder->where('ef.status', $status); + } + if ($flagType !== '') { + $builder->where('ef.flag_type', $flagType); + } + if (is_numeric($assignedTo) && (int) $assignedTo > 0) { + $builder->where('ef.assigned_to', (int) $assignedTo); + } + + $rows = $builder->get()->getResultArray(); + foreach ($rows as &$row) { + $row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0); + $row['assignee_name'] = trim((string) ($row['assignee_firstname'] ?? '') . ' ' . (string) ($row['assignee_lastname'] ?? '')); + $row['details'] = json_decode((string) ($row['details_json'] ?? ''), true) ?: []; + } + unset($row); + + return $rows; + } + + private function enrollmentFollowups(string $schoolYear): array + { + if (! $this->db->tableExists('enrollments')) { + return []; + } + + $fields = $this->db->getFieldNames('enrollments'); + $select = [ + 'e.id', + 'e.student_id', + 'e.school_year', + 'e.enrollment_status', + 'e.class_section_id', + 'e.updated_at', + 's.firstname', + 's.lastname', + 's.school_id', + 'cs.class_section_name', + ]; + + foreach ([ + 'deliberation_decision', + 'placement_status', + 'exception_required', + 'exception_reason', + 'source_school_year', + 'assigned_class_section_id', + 'age_on_reference_date', + ] as $field) { + if (in_array($field, $fields, true)) { + $select[] = 'e.' . $field; + } + } + + $builder = $this->db->table('enrollments e') + ->select(implode(', ', $select)) + ->join('students s', 's.id = e.student_id', 'left') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') + ->orderBy('e.updated_at', 'DESC') + ->orderBy('e.id', 'DESC') + ->limit(200); + + if ($schoolYear !== '') { + $builder->where('e.school_year', $schoolYear); + } + + $builder->groupStart() + ->whereIn('e.enrollment_status', [ + 'review & decision', + 'admission under review', + 'waitlist', + 'denied', + 'Review & Decision', + 'Admission Under Review', + 'Waitlist', + 'Denied', + ]); + + if (in_array('placement_status', $fields, true)) { + $builder->orWhereIn('e.placement_status', [ + 'temporary_same_grade', + 'temporary_manual_class_required', + 'manual_class_required', + 'exit_required', + 'automatic_distribution_pending', + ]); + } + + if (in_array('exception_required', $fields, true)) { + $builder->orWhere('e.exception_required', 1); + } + + if (in_array('deliberation_decision', $fields, true)) { + $builder->orWhereIn('e.deliberation_decision', [ + 'make_up_exam', + 'repeat_class', + 'deferred', + 'expelled', + 'withdrawn', + ]); + } + + $rows = $builder->groupEnd()->get()->getResultArray(); + foreach ($rows as &$row) { + $row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0); + } + unset($row); + + return $rows; + } + + private function requireFlag(int $id): array + { + if ($id <= 0 || ! $this->db->tableExists('enrollment_flags')) { + throw new \RuntimeException('Enrollment flag was not found.'); + } + + $flag = $this->db->table('enrollment_flags')->where('id', $id)->limit(1)->get()->getRowArray(); + if ($flag === null) { + throw new \RuntimeException('Enrollment flag was not found.'); + } + + return $flag; + } + + private function resolveFlagRow(array $flag, string $notes, string $auditAction, array $metadata = []): void + { + $this->db->transStart(); + $original = $flag; + $this->db->table('enrollment_flags') + ->where('id', (int) $flag['id']) + ->update([ + 'status' => 'resolved', + 'resolved_at' => date('Y-m-d H:i:s'), + 'resolution_notes' => $notes, + ]); + + $this->audit((int) $flag['student_id'], (string) $flag['school_year'], (string) ($flag['source_school_year'] ?? ''), $auditAction, $original, array_merge($metadata, [ + 'flag_id' => (int) $flag['id'], + 'flag_type' => (string) $flag['flag_type'], + 'resolution_notes' => $notes, + ]), $notes); + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + throw new \RuntimeException('Unable to resolve enrollment flag.'); + } + } + + private function applyClassSection(int $studentId, string $schoolYear, int $sectionId, string $auditAction, string $placementStatus = 'manual_class_assigned'): void + { + $section = $this->db->table('classSection') + ->select('class_section_id, class_id, class_section_name') + ->where('class_section_id', $sectionId) + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + if ($section === null) { + throw new \RuntimeException('Selected class section was not found.'); + } + + $originalEnrollment = $this->latestEnrollment($studentId, $schoolYear); + $payload = [ + 'class_section_id' => $sectionId, + 'assigned_class_section_id' => $sectionId, + 'assigned_grade_id' => (int) ($section['class_id'] ?? 0) ?: null, + 'placement_status' => $placementStatus, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + $this->db->table('enrollments') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->update($payload); + + $studentClass = $this->db->table('student_class') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + $studentClassPayload = [ + 'student_id' => $studentId, + 'class_section_id' => $sectionId, + 'school_year' => $schoolYear, + 'updated_by' => $this->userId(), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + if ($studentClass !== null) { + $this->db->table('student_class')->where('id', (int) $studentClass['id'])->update($studentClassPayload); + } else { + $studentClassPayload['created_at'] = date('Y-m-d H:i:s'); + $this->db->table('student_class')->insert($studentClassPayload); + } + + $this->audit($studentId, $schoolYear, (string) ($originalEnrollment['source_school_year'] ?? ''), $auditAction, $originalEnrollment, $payload, 'Class section assigned by administrator.'); + } + + private function updateEnrollmentPlacementStatus(int $studentId, string $schoolYear, string $placementStatus): void + { + $this->db->table('enrollments') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->update([ + 'placement_status' => $placementStatus, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + + 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; + } + + private function audit(int $studentId, string $schoolYear, string $sourceSchoolYear, string $action, ?array $original, array $new, string $reason): void + { + if (! $this->db->tableExists('enrollment_transition_audits')) { + return; + } + + $this->db->table('enrollment_transition_audits')->insert([ + 'student_id' => $studentId, + 'school_year' => $schoolYear, + 'source_school_year' => $sourceSchoolYear !== '' ? $sourceSchoolYear : null, + 'action' => $action, + 'performed_by' => $this->userId(), + 'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null, + 'new_values_json' => json_encode($new, JSON_UNESCAPED_SLASHES), + 'reason' => $reason, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + + private function auditRows(string $schoolYear): array + { + if (! $this->db->tableExists('enrollment_transition_audits')) { + return []; + } + + $builder = $this->db->table('enrollment_transition_audits eta') + ->select('eta.*') + ->select('s.firstname, s.lastname') + ->select('u.firstname AS user_firstname, u.lastname AS user_lastname') + ->join('students s', 's.id = eta.student_id', 'left') + ->join('users u', 'u.id = eta.performed_by', 'left') + ->orderBy('eta.created_at', 'DESC') + ->limit(50); + + if ($schoolYear !== '') { + $builder->where('eta.school_year', $schoolYear); + } + + $rows = $builder->get()->getResultArray(); + foreach ($rows as &$row) { + $row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0); + $row['performed_by_name'] = trim((string) ($row['user_firstname'] ?? '') . ' ' . (string) ($row['user_lastname'] ?? '')) ?: ((int) ($row['performed_by'] ?? 0) > 0 ? 'User #' . (int) $row['performed_by'] : ''); + } + unset($row); + + return $rows; + } + + private function launchState(string $schoolYear): array + { + if ($schoolYear === '' || ! $this->db->tableExists('school_years')) { + return ['approved' => false, 'approved_at' => null, 'missing' => ['school year']]; + } + + $row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray(); + $this->launchConfigurationComplete($schoolYear, $missing); + + return [ + 'approved' => ! empty($row['registration_launch_approved_at'] ?? null), + 'approved_at' => $row['registration_launch_approved_at'] ?? null, + 'missing' => $missing, + ]; + } + + private function launchConfigurationComplete(string $schoolYear, ?array &$missing = null): bool + { + $missing = []; + $row = $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray(); + if ($row === null) { + $missing[] = 'school year record'; + return false; + } + + foreach (['registration_starts_on' => 'registration opening date', 'registration_ends_on' => 'registration deadline'] as $field => $label) { + if (empty($row[$field])) { + $missing[] = $label; + } + } + + if (! $this->db->tableExists('email_templates')) { + $missing[] = 'email templates table'; + } else { + $fields = $this->db->getFieldNames('email_templates'); + $keyField = in_array('code', $fields, true) ? 'code' : 'template_key'; + $template = $this->db->table('email_templates') + ->where($keyField, 'registration_opening') + ->where('is_active', 1) + ->countAllResults(); + if ($template <= 0) { + $missing[] = 'approved registration email template'; + } + } + + return $missing === []; + } + + private function firstParentWithStudents(): ?int + { + if (! $this->db->tableExists('students')) { + return null; + } + + $row = $this->db->table('students') + ->select('parent_id') + ->where('parent_id IS NOT NULL', null, false) + ->orderBy('parent_id', 'ASC') + ->limit(1) + ->get() + ->getRowArray(); + + return is_numeric($row['parent_id'] ?? null) ? (int) $row['parent_id'] : null; + } + + private function flagTypes(): array + { + if (! $this->db->tableExists('enrollment_flags')) { + return []; + } + + return array_column($this->db->table('enrollment_flags')->select('flag_type')->distinct()->orderBy('flag_type')->get()->getResultArray(), 'flag_type'); + } + + private function schoolYears(): array + { + if (! $this->db->tableExists('school_years')) { + return []; + } + + return $this->db->table('school_years')->select('name')->orderBy('name', 'DESC')->get()->getResultArray(); + } + + private function classSections(string $schoolYear): array + { + if (! $this->db->tableExists('classSection')) { + return []; + } + + $builder = $this->db->table('classSection')->select('class_section_id, class_section_name')->orderBy('class_section_name', 'ASC'); + if ($schoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $builder->where('school_year', $schoolYear); + } + + return $builder->get()->getResultArray(); + } + + private function adminUsers(): array + { + if (! $this->db->tableExists('users')) { + return []; + } + + return $this->db->table('users') + ->select('id, firstname, lastname, user_type') + ->whereIn('user_type', ['administrator', 'admin', 'principal', 'administrative staff']) + ->orderBy('lastname', 'ASC') + ->get() + ->getResultArray(); + } + + private function userId(): ?int + { + $id = session('user_id') ?? session('id'); + return is_numeric($id) ? (int) $id : null; + } +} diff --git a/app/Controllers/View/EventController.php b/app/Controllers/View/EventController.php index 2d4e3be..23feb68 100644 --- a/app/Controllers/View/EventController.php +++ b/app/Controllers/View/EventController.php @@ -92,6 +92,13 @@ class EventController extends ResourceController return $this->eventChargesHasCreatedBy; } + private function assertSchoolYearNameWritable(string $schoolYear): void + { + service('schoolYearWriteGuard')->assertWritable( + service('schoolYearContext')->forYearName($schoolYear) + ); + } + private function currentSchoolYearName(): string { try { @@ -939,6 +946,7 @@ class EventController extends ResourceController if (!$charge) { return redirect()->to($returnTo)->with('error', 'Charge not found.'); } + $this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? '')); $paymentId = (int)($charge['event_payment_id'] ?? 0); if ($paymentId > 0) { @@ -989,6 +997,7 @@ class EventController extends ResourceController if (!$charge) { return redirect()->to($returnTo)->with('error', 'Charge not found.'); } + $this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? '')); $signed = $this->request->getPost('waiver_signed') === '1'; @@ -1070,6 +1079,7 @@ class EventController extends ResourceController if (!$charge) { return null; } + $this->assertSchoolYearNameWritable((string)($charge['school_year'] ?? '')); $event = $this->eventModel->find($charge['event_id']); $eventAmount = max(0.0, (float)($event['amount'] ?? 0)); diff --git a/app/Controllers/View/ExpenseController.php b/app/Controllers/View/ExpenseController.php index 5a55c2c..d94b862 100644 --- a/app/Controllers/View/ExpenseController.php +++ b/app/Controllers/View/ExpenseController.php @@ -261,6 +261,7 @@ class ExpenseController extends BaseController if (!$expense) { throw new \RuntimeException('Expense not found'); } + $this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? '')); $db->query( "SELECT id FROM reimbursements WHERE expense_id = ? AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided') FOR UPDATE", [$id] @@ -338,6 +339,7 @@ class ExpenseController extends BaseController if (!$expense) { throw PageNotFoundException::forPageNotFound("Expense #$id not found"); } + $this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? '')); // Base rules $rules = [ diff --git a/app/Controllers/View/FamilyAdminController.php b/app/Controllers/View/FamilyAdminController.php index c230948..283b482 100644 --- a/app/Controllers/View/FamilyAdminController.php +++ b/app/Controllers/View/FamilyAdminController.php @@ -254,7 +254,41 @@ class FamilyAdminController extends BaseController LIMIT 1", [$studentId] )->getRowArray(); - if (!empty($row['id'])) $familyId = (int) $row['id']; + if (!empty($row['id'])) { + $familyId = (int) $row['id']; + } else { + // Legacy/newly-created students can have students.parent_id set + // before the normalized family_students row is created. + $row = $db->query( + "SELECT f.id + FROM students s + JOIN family_guardians fg ON fg.user_id = s.parent_id + JOIN families f ON f.id = fg.family_id + WHERE s.id = ? + ORDER BY fg.is_primary DESC, f.household_name + LIMIT 1", + [$studentId] + )->getRowArray(); + + if (!empty($row['id'])) { + $familyId = (int) $row['id']; + } else { + $row = $db->query( + "SELECT f.id + FROM students s + JOIN families f ON f.family_code = CONCAT('FAM-', s.parent_id) + WHERE s.id = ? + AND s.parent_id IS NOT NULL + ORDER BY f.household_name + LIMIT 1", + [$studentId] + )->getRowArray(); + + if (!empty($row['id'])) { + $familyId = (int) $row['id']; + } + } + } } elseif ($guardianId) { // 1) Try via guardians link $row = $db->query( @@ -330,6 +364,24 @@ class FamilyAdminController extends BaseController ORDER BY s.lastname, s.firstname", [$familyId] )->getResultArray(); + + if ($studentId > 0) { + $studentIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $studentsRows); + if (!in_array($studentId, $studentIds, true)) { + $selectedStudent = $db->query( + "SELECT id, firstname, lastname + FROM students + WHERE id = ? + LIMIT 1", + [$studentId] + )->getRowArray(); + + if ($selectedStudent) { + $studentsRows[] = $selectedStudent; + } + } + } + if (!empty($studentsRows)) { foreach ($studentsRows as &$sr) { $sid = (int) ($sr['id'] ?? 0); diff --git a/app/Controllers/View/FlagController.php b/app/Controllers/View/FlagController.php index d141d23..d1ef50d 100644 --- a/app/Controllers/View/FlagController.php +++ b/app/Controllers/View/FlagController.php @@ -24,6 +24,13 @@ class FlagController extends Controller helper(['url', 'form']); } + private function assertSchoolYearNameWritable(string $schoolYear): void + { + service('schoolYearWriteGuard')->assertWritable( + service('schoolYearContext')->forYearName($schoolYear) + ); + } + public function index() { $currentFlagModel = new CurrentFlagModel(); @@ -436,6 +443,13 @@ class FlagController extends Controller return $this->index(); } + $flagData = $currentFlagModel->find($id); + if (!$flagData) { + session()->setFlashdata('error', 'Incident not found.'); + return $this->index(); + } + $this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? '')); + $update = ['flag_state' => $newState]; if ($newState === 'Closed') { $update['updated_by_closed'] = $userId; @@ -490,6 +504,7 @@ class FlagController extends Controller session()->setFlashdata('error', 'Incident not found.'); return redirect()->back(); } + $this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? '')); // Proceed only if flag is not closed if ($flagData['flag_state'] !== 'Closed') { @@ -537,6 +552,7 @@ class FlagController extends Controller session()->setFlashdata('error', 'Incident not found.'); return redirect()->back(); } + $this->assertSchoolYearNameWritable((string)($flagData['school_year'] ?? '')); // Check if the flag is not already canceled if ($flagData['flag_state'] !== 'Canceled') { diff --git a/app/Controllers/View/GradingController.php b/app/Controllers/View/GradingController.php index 967020c..fc0bd35 100644 --- a/app/Controllers/View/GradingController.php +++ b/app/Controllers/View/GradingController.php @@ -2,10 +2,10 @@ namespace App\Controllers\View; +use App\Controllers\BaseController; use App\Models\StudentModel; use App\Models\StudentClassModel; use App\Models\ConfigurationModel; -use CodeIgniter\Controller; use CodeIgniter\Events\Events; use App\Models\HomeworkModel; use App\Models\QuizModel; @@ -35,7 +35,7 @@ use App\Services\NavbarService; //use App\Models\ScoreModel; -class GradingController extends Controller +class GradingController extends BaseController { protected $semesterScoreService; protected $db; @@ -1119,15 +1119,6 @@ public function belowSixty() ]); } - private function currentSchoolYearName(?string $fallback = null): string - { - try { - return service('schoolYearContext')->resolve($this->request)->yearName(); - } catch (\Throwable) { - return trim((string) ($fallback ?? '')); - } - } - public function editBelowSixtyEmail() { $studentId = (int)$this->request->getGet('student_id'); @@ -2329,7 +2320,8 @@ public function belowSixty() public function belowSixtyDecisions() { - $configuredYear = (string) $this->schoolYear; + $schoolYearContext = $this->resolveSchoolYearContext(); + $configuredYear = $schoolYearContext->yearName(); $schoolYear = trim((string)($this->request->getGet('school_year') ?? '')); @@ -2541,11 +2533,15 @@ public function belowSixty() 'schoolYear' => $schoolYear, 'schoolYears' => $schoolYears, 'canViewGrading' => $canViewGrading, + 'isEditable' => ! $schoolYearContext->isReadonly(), ]); } public function saveBelowSixtyDecision() { + $schoolYearContext = $this->resolveSchoolYearContext(); + $this->assertSchoolYearWritable($schoolYearContext); + $studentId = (int)($this->request->getPost('student_id') ?? 0); $semester = strtolower(trim((string)($this->request->getPost('semester') ?? 'year'))); $schoolYear = trim((string)($this->request->getPost('school_year') ?? '')); @@ -2556,6 +2552,10 @@ public function saveBelowSixtyDecision() return redirect()->back()->with('error', 'Missing student or school year.'); } + if ($schoolYear !== $schoolYearContext->yearName()) { + return redirect()->back()->with('error', 'Selected school year does not match the submitted decision.'); + } + // This decision page should feed certificate decisions as whole-year decisions. // Force year mode here so certificate logic receives final year decision. $semester = 'year'; diff --git a/app/Controllers/View/InvoiceController.php b/app/Controllers/View/InvoiceController.php index 4ce7802..cba2277 100644 --- a/app/Controllers/View/InvoiceController.php +++ b/app/Controllers/View/InvoiceController.php @@ -81,7 +81,8 @@ class InvoiceController extends ResourceController $this->gradeFee = $this->configModel->getConfig('grade_fee'); $this->schoolYear = $this->currentSchoolYearName(); $this->semester = $this->configModel->getConfig('semester'); - $this->dueDate = $this->configModel->getConfig('due_date'); + $this->dueDate = $this->configModel->getConfig('first_day_of_school') + ?: $this->configModel->getConfig('due_date'); $this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350); $this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200); $this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 200); @@ -230,6 +231,13 @@ class InvoiceController extends ResourceController } } + private function assertSchoolYearNameWritable(string $schoolYear): void + { + service('schoolYearWriteGuard')->assertWritable( + service('schoolYearContext')->forYearName($schoolYear) + ); + } + /** * API: Invoice management composite data (used by invoice_management view) * Returns the same structure previously rendered server-side in index(). @@ -891,7 +899,7 @@ class InvoiceController extends ResourceController } } - $eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear); + $eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $invoice['semester'] ?? null); // Attach SCHOOL IDs $allKids = array_merge($registeredKids, $withdrawnKids); @@ -1073,18 +1081,40 @@ class InvoiceController extends ResourceController $pdf->SetFont('Arial', 'B', 12); $pdf->Cell(40, 6, 'Due Date:', 0, 0, 'L'); $pdf->SetFont('Arial', '', 12); - $dueLocal = null; + $formatCalendarDate = static function ($raw): ?string { + if ($raw instanceof \DateTimeInterface) { + return $raw->format('m-d-Y'); + } + + $value = trim((string)($raw ?? '')); + if ($value === '' || preg_match('/^0{4}-0{2}-0{2}/', $value)) { + return null; + } + + if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $value, $matches)) { + return $matches[2] . '-' . $matches[3] . '-' . $matches[1]; + } + + if (preg_match('/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})/', $value, $matches)) { + return sprintf('%02d-%02d-%04d', (int)$matches[1], (int)$matches[2], (int)$matches[3]); + } + + $timestamp = strtotime($value); + return $timestamp === false ? null : date('m-d-Y', $timestamp); + }; + + $dueDisplay = $formatCalendarDate($invoice['due_date'] ?? null); try { $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); - if (!empty($invoice['due_date'])) { - $dueLocal = (new \DateTimeImmutable($invoice['due_date'], new \DateTimeZone('UTC'))) - ->setTimezone(new \DateTimeZone($tzName)); - } elseif (!empty($invoice['created_at'])) { + if ($dueDisplay === null && !empty($invoice['created_at'])) { $dueLocal = new \DateTimeImmutable($invoice['created_at'], new \DateTimeZone($tzName)); + $dueDisplay = $dueLocal->format('m-d-Y'); } } catch (\Throwable $e) {} - if (!$dueLocal) { $dueLocal = new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')); } - $pdf->Cell(0, 6, $dueLocal->format('m-d-Y'), 0, 1, 'L'); + if ($dueDisplay === null) { + $dueDisplay = (new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')))->format('m-d-Y'); + } + $pdf->Cell(0, 6, $dueDisplay, 0, 1, 'L'); $pdf->Ln(5); $pdf->SetFont('Arial', '', 9); @@ -1145,16 +1175,111 @@ class InvoiceController extends ResourceController ]; }; - // --- Frozen invoice charge lines. Do not rebuild issued charges from current enrollment/events. + $studentById = []; + foreach (($data['students'] ?? []) as $student) { + $sid = (int)($student['student_id'] ?? 0); + if ($sid <= 0) { + continue; + } + $studentById[$sid] = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? '')); + } + + $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; + } + + $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 = []; + foreach (($events ?? []) as $event) { + $amount = (float)($event['charged'] ?? 0.0); + if (abs($amount) < 0.00001) { + continue; + } + + $sid = (int)($event['student_id'] ?? 0); + $studentName = $sid > 0 ? ($studentById[$sid] ?? '') : ''; + if ($studentName === '') { + $studentName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? '')); + } + + $desc = trim((string)($event['event_name'] ?? 'Event charge')); + if ($studentName !== '') { + $desc .= ' - ' . $studentName; + } + + $eventRows[] = [ + 'description' => $desc, + 'amount' => $amount, + ]; + } + + // --- Frozen invoice charge lines remain authoritative for totals. + // Aggregate tuition/event lines are expanded for display when invoice details are available. foreach (($data['invoiceLines'] ?? []) as $line) { $dt = $toLocal($line['created_at'] ?? ($invoice['created_at'] ?? null), true); $amount = ((int)($line['line_amount_cents'] ?? 0)) / 100; $type = (string)($line['line_type'] ?? 'other'); $category = str_contains($type, 'event') ? 'event' : (str_contains($type, 'additional') ? 'additional' : 'registration'); + + if (!str_contains($type, 'additional') && !str_contains($type, 'event') && !empty($studentTuitionRows)) { + $expandedTotal = 0.0; + foreach ($studentTuitionRows as $row) { + $expandedTotal += (float)$row['amount']; + $push($dt, $row['description'], (float)$row['amount'], 'registration'); + } + + $delta = round($amount - $expandedTotal, 2); + if (abs($delta) >= 0.01) { + $push($dt, 'Tuition charges adjustment', $delta, 'registration'); + } + continue; + } + + if (str_contains($type, 'event') && !empty($eventRows)) { + $expandedTotal = 0.0; + foreach ($eventRows as $row) { + $expandedTotal += (float)$row['amount']; + $push($dt, $row['description'], (float)$row['amount'], 'event'); + } + + $delta = round($amount - $expandedTotal, 2); + if (abs($delta) >= 0.01) { + $push($dt, 'Event charges adjustment', $delta, 'event'); + } + continue; + } + $push($dt, (string)($line['description'] ?? 'Invoice line'), $amount, $category); } + 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'); + } + } + // --- Payments (negative) — stored in local time foreach ($payments as $payment) { $dt = $toLocal($payment['payment_date'] ?? null, false /* local */); @@ -1513,6 +1638,12 @@ private function getGradeLevel($grade): array // API: Update invoice status public function updateStatusAPI($invoiceId) { + $invoice = $this->invoiceModel->find($invoiceId); + if (!$invoice) { + return $this->failNotFound('Invoice not found.'); + } + $this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? '')); + $status = $this->request->getPost('status'); if ($this->invoiceModel->updateInvoiceStatus($invoiceId, $status)) { return $this->respond(['status' => 'success']); @@ -1524,6 +1655,12 @@ private function getGradeLevel($grade): array // View: Update invoice status public function updateStatus($invoiceId) { + $invoice = $this->invoiceModel->find($invoiceId); + if (!$invoice) { + return redirect()->back()->with('error', 'Invoice not found.'); + } + $this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? '')); + $status = $this->request->getPost('status'); if ($this->invoiceModel->updateInvoiceStatus($invoiceId, $status)) { return redirect()->to('/invoices'); diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index 5c51dbc..4217445 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -21,6 +21,8 @@ use CodeIgniter\Events\Events; use App\Services\SchoolIdService; use App\Services\FeeCalculationService; use App\Services\PhoneFormatterService; +use App\Support\Enrollment\DeliberationDecision; +use App\Support\Enrollment\EnrollmentEligibility; use InvalidArgumentException; use DateTimeImmutable; use DateTimeZone; @@ -299,6 +301,7 @@ class ParentController extends BaseController // Map enrollment statuses $statusMap = [ 'admission under review' => 'admission under review', + 'review & decision' => 'review & decision', 'payment pending' => 'payment pending', 'enrolled' => 'enrolled', 'withdraw under review' => 'withdraw under review', @@ -360,12 +363,31 @@ class ParentController extends BaseController // ✅ Updated disable logic to include denied status $student['disable_enroll'] = in_array( $student['enrollment_status'], - ['admission under review', 'payment pending', 'enrolled', 'withdraw under review', 'denied'] + ['admission under review', 'review & decision', 'payment pending', 'enrolled', 'withdraw under review', 'denied'] ); - $student['previous_year_decision'] = $previousSchoolYear !== null - ? $this->studentDecisionForYear((int) $studentId, $previousSchoolYear) + $decisionYear = $isEditable ? $previousSchoolYear : $selectedYear; + $student['previous_year_decision'] = $decisionYear !== null + ? $this->studentDecisionForYear((int) $studentId, $decisionYear) : null; + + if ($isEditable) { + $student['transition_evaluation'] = $previousSchoolYear !== null + ? $this->transitionEvaluationForStudent((int) $studentId, $previousSchoolYear, $selectedYear) + : null; + $student['enrollment_eligibility_message'] = $this->eligibilityMessageFromTransition( + $student, + $student['transition_evaluation'], + $fallMakeupExamOn + ); + $student['expected_placement_label'] = $this->expectedPlacementLabel($student['transition_evaluation']); + $student['required_action_label'] = $this->requiredActionLabel($student['transition_evaluation']); + } else { + $student['transition_evaluation'] = $this->readonlyEnrollmentEvaluation($student['previous_year_decision']); + $student['enrollment_eligibility_message'] = ['message' => '', 'blocking' => false, 'level' => 'info']; + $student['expected_placement_label'] = (string) ($student['class_section'] ?? 'Class not Assigned'); + $student['required_action_label'] = 'Read-only closed school year.'; + } } // Render view @@ -379,6 +401,7 @@ class ParentController extends BaseController 'lastDayOfRegistration' => $this->lastDayOfRegistration, 'schoolStartDate' => $this->schoolStartDate, 'hasAcceptedSchoolPolicy' => $this->hasAcceptedPolicyForYear((int) $parentId, $selectedYear), + 'familyFinancialSummary' => $this->familyFinancialSummary((int) $parentId, $previousSchoolYear, $selectedYear), ]); } catch (Exception $e) { log_message('error', 'An error occurred in enrollClasses: ' . $e->getMessage()); @@ -442,6 +465,11 @@ class ParentController extends BaseController return redirect()->back()->withInput()->with('error', implode(' ', $blockingDecisionMessages)); } + $financialBlockers = $this->financialSubmissionBlockers((int) $parentId, $selectedYear); + if ($financialBlockers !== []) { + return redirect()->back()->withInput()->with('error', implode(' ', $financialBlockers)); + } + foreach ($enroll as $studentId) { // Get student full name (supports both string return or array with firstname/lastname) $studentInfo = $this->studentModel->getFullNameById($studentId); @@ -457,7 +485,7 @@ class ParentController extends BaseController // Check if the student already has an enrollment record for the current school year and semester $existingEnrollment = $this->enrollmentModel ->where('student_id', $studentId) - ->where('school_year', $this->schoolYear) + ->where('school_year', $selectedYear) ->where('semester', $this->semester) ->get() ->getRowArray(); @@ -467,37 +495,52 @@ class ParentController extends BaseController return redirect()->back()->with('error', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']}: Student school ID not found."); } if ($existingEnrollment) { - if ($existingEnrollment['is_withdrawn'] == 1) { - $passedPreviousYear = $this->studentPassedPreviousYear((int)$studentId, (string)$this->schoolYear); + $isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear); + $targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review'; + $targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending'; + if ($existingEnrollment['is_withdrawn'] == 1) { // Reactivate the enrollment if the student was previously withdrawn $this->enrollmentModel->where('id', $existingEnrollment['id'])->update([ 'is_withdrawn' => 0, 'withdrawal_date' => null, - 'enrollment_status' => $passedPreviousYear ? 'payment pending' : 'admission under review', - 'admission_status' => $passedPreviousYear ? 'accepted' : 'pending', + 'enrollment_status' => $targetEnrollmentStatus, + 'admission_status' => $targetAdmissionStatus, 'updated_at' => utc_now() ]); log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been re-enrolled in enrollment ID {$existingEnrollment['id']}."); // Apply promotion-based class placement for the upcoming year - $this->applyPromotionAssignment((int)$studentId, (string)$this->schoolYear); + $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); } else { + $currentStatus = (string) ($existingEnrollment['enrollment_status'] ?? ''); + $update = [ + 'updated_at' => utc_now(), + ]; + if ($currentStatus === 'enrolled') { + $update['admission_status'] = 'accepted'; + } else { + $update['enrollment_status'] = $targetEnrollmentStatus; + $update['admission_status'] = $targetAdmissionStatus; + } + $this->enrollmentModel->where('id', $existingEnrollment['id'])->update($update); log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled."); - $this->applyPromotionAssignment((int)$studentId, (string)$this->schoolYear); + $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); } } else { - $passedPreviousYear = $this->studentPassedPreviousYear((int)$studentId, (string)$this->schoolYear); + $isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear); + $targetEnrollmentStatus = $isReturningReEnrollment ? 'payment pending' : 'admission under review'; + $targetAdmissionStatus = $isReturningReEnrollment ? 'accepted' : 'pending'; // If no enrollment record exists, insert a new enrollment record $result = $this->enrollmentModel->insert([ 'student_id' => $studentId, 'parent_id' => $parentId, - 'school_year' => $this->schoolYear, + 'school_year' => $selectedYear, 'semester' => $this->semester, 'enrollment_date' => local_date(utc_now(), 'Y-m-d'), 'is_withdrawn' => 0, - 'enrollment_status' => $passedPreviousYear ? 'payment pending' : 'admission under review', - 'admission_status' => $passedPreviousYear ? 'accepted' : 'pending', + 'enrollment_status' => $targetEnrollmentStatus, + 'admission_status' => $targetAdmissionStatus, 'created_at' => utc_now() ]); @@ -506,7 +549,7 @@ class ParentController extends BaseController } else { log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been newly enrolled."); // Apply promotion-based class placement for the upcoming year - $this->applyPromotionAssignment((int)$studentId, (string)$this->schoolYear); + $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); } } } @@ -648,13 +691,23 @@ class ParentController extends BaseController * create/update student_class row accordingly (base section until distribution), * and mark the queue row as applied. */ - private function applyPromotionAssignment(int $studentId, string $year): void + private function applyPromotionAssignment(int $studentId, string $year, bool $updateEnrollmentPlacement = true): void { try { + $this->ensureClassSectionsForYear($year); $promo = new \App\Models\PromotionQueueModel(); $classSectionModel = new \App\Models\ClassSectionModel(); $draftModel = new StudentSectionDistributionDraftModel(); $studentClass = new \App\Models\StudentClassModel(); + $previousYear = $this->previousSchoolYearName($year); + $previousDecision = $previousYear !== null + ? $this->studentDecisionForYear($studentId, $previousYear) + : null; + + if ($this->isFallMakeupExamDecision($previousDecision)) { + $this->keepMakeupExamStudentInPreviousGrade($studentId, $year, $previousYear); + return; + } $draft = $draftModel->where('student_id', $studentId) ->where('school_year', $year) @@ -665,15 +718,41 @@ class ParentController extends BaseController ->where('school_year_to', $year) ->first(); - if (!$row && !$draft) return; // nothing to do - $targetSectionId = (int)($draft['class_section_id'] ?? 0); if ($targetSectionId <= 0) { $targetSectionId = (int)($row['to_class_section_id'] ?? 0); } + + $placementStatus = $draft ? 'automatic_distribution_applied' : 'manual_class_assigned'; + if (!$row && !$draft && $previousYear !== null) { + $evaluation = service('enrollmentTransition')->evaluate($studentId, $previousYear, $year, 'admin'); + if (!($evaluation['academic_eligible'] ?? false) || ($evaluation['blockers'] ?? []) !== []) { + log_message('warning', 'applyPromotionAssignment: transition fallback blocked for student_id=' . $studentId . ', year=' . $year . ', blockers=' . implode('; ', $evaluation['blockers'] ?? [])); + return; + } + + $targetSectionId = (int)($evaluation['assigned_class_section_id'] ?? 0); + if ($targetSectionId <= 0 && (int)($evaluation['assigned_grade_id'] ?? 0) > 0) { + $base = $this->baseSectionForClassYear((int)$evaluation['assigned_grade_id'], $year); + $targetSectionId = (int)($base['class_section_id'] ?? 0); + } + + $placementStatus = match ((string)($evaluation['placement_status'] ?? '')) { + 'automatic_distribution_pending' => 'base_section_pending_distribution', + 'same_class_assigned', 'temporary_same_grade' => (string)$evaluation['placement_status'], + default => 'manual_class_assigned', + }; + } + + if (!$row && !$draft && $targetSectionId <= 0) { + log_message('warning', 'applyPromotionAssignment: no placement source found for student_id=' . $studentId . ', year=' . $year); + return; + } + if ($targetSectionId <= 0) { // Resolve base section for target class (e.g., '3') - $base = $classSectionModel->getBaseSectionByClassId((int)($row['to_class_id'] ?? 0)); + $base = $this->baseSectionForClassYear((int)($row['to_class_id'] ?? 0), $year) + ?? $classSectionModel->getBaseSectionByClassId((int)($row['to_class_id'] ?? 0)); if (!$base) { log_message('warning', 'applyPromotionAssignment: no draft or base section found for student_id=' . $studentId . ', year=' . $year); return; @@ -703,14 +782,18 @@ class ParentController extends BaseController $studentClass->insert($payload); } - $this->db->table('enrollments') - ->where('student_id', $studentId) - ->where('school_year', $year) - ->whereIn('enrollment_status', ['payment pending', 'enrolled']) - ->update([ - 'class_section_id' => $targetSectionId, - 'updated_at' => utc_now(), - ]); + if ($updateEnrollmentPlacement) { + $this->db->table('enrollments') + ->where('student_id', $studentId) + ->where('school_year', $year) + ->whereIn('enrollment_status', ['admission under review', 'review & decision', 'payment pending', 'enrolled']) + ->update([ + 'class_section_id' => $targetSectionId, + 'assigned_class_section_id' => $targetSectionId, + 'placement_status' => $placementStatus, + 'updated_at' => utc_now(), + ]); + } // Mark promotion as applied when promotion_queue is the source. if ($row) { @@ -759,16 +842,17 @@ class ParentController extends BaseController } if ($this->db->tableExists('student_decisions')) { - $passedDecision = $this->db->table('student_decisions') - ->select('id') + $decisionRow = $this->db->table('student_decisions') + ->select('decision') ->where('student_id', $studentId) ->where('school_year', $previousSchoolYear) - ->where('LOWER(TRIM(decision)) = ' . $this->db->escape('pass'), null, false) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray(); - return $passedDecision !== null; + return DeliberationDecision::normalize($decisionRow['decision'] ?? null) === DeliberationDecision::PASSED; } } catch (\Throwable $e) { log_message('error', 'studentPassedPreviousYear failed: ' . $e->getMessage()); @@ -777,6 +861,326 @@ class ParentController extends BaseController return false; } + private function isReturningReEnrollmentStudent(int $studentId, string $targetSchoolYear): bool + { + $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + if ($studentId <= 0 || $previousSchoolYear === null) { + return false; + } + + try { + if ($this->db->tableExists('student_decisions')) { + $decision = $this->db->table('student_decisions') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year', $previousSchoolYear) + ->limit(1) + ->get() + ->getRowArray(); + + if ($decision !== null) { + return true; + } + } + + if ($this->db->tableExists('student_class')) { + $assignment = $this->db->table('student_class') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year', $previousSchoolYear) + ->limit(1) + ->get() + ->getRowArray(); + + if ($assignment !== null) { + return true; + } + } + + if ($this->db->tableExists('enrollments')) { + $enrollment = $this->db->table('enrollments') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year', $previousSchoolYear) + ->limit(1) + ->get() + ->getRowArray(); + + if ($enrollment !== null) { + return true; + } + } + + if ($this->db->tableExists('promotion_queue')) { + $promotion = $this->db->table('promotion_queue') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year_from', $previousSchoolYear) + ->where('school_year_to', $targetSchoolYear) + ->limit(1) + ->get() + ->getRowArray(); + + if ($promotion !== null) { + return true; + } + } + } catch (\Throwable $e) { + log_message('error', 'isReturningReEnrollmentStudent failed: ' . $e->getMessage()); + } + + return false; + } + + private function isFallMakeupExamDecision(?array $decisionRow): bool + { + return DeliberationDecision::normalize($decisionRow['decision'] ?? null) === DeliberationDecision::MAKE_UP_EXAM; + } + + private function keepMakeupExamStudentInPreviousGrade(int $studentId, string $targetSchoolYear, ?string $previousSchoolYear): void + { + if ($studentId <= 0 || $targetSchoolYear === '' || $previousSchoolYear === null) { + return; + } + + $previousAssignment = $this->db->table('student_class sc') + ->select('sc.class_section_id, cs.class_section_name') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->where('sc.student_id', $studentId) + ->where('sc.school_year', $previousSchoolYear) + ->where('sc.class_section_id IS NOT NULL', null, false) + ->orderBy('sc.updated_at', 'DESC') + ->orderBy('sc.id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + if ($previousAssignment === null) { + $this->openMakeupExamPlacementFlag($studentId, $targetSchoolYear, null, null); + log_message('warning', 'No previous class assignment found for fall make-up exam student_id=' . $studentId . ', year=' . $previousSchoolYear); + return; + } + + $previousSectionId = (int) ($previousAssignment['class_section_id'] ?? 0); + $previousSectionName = trim((string) ($previousAssignment['class_section_name'] ?? '')); + $targetSectionId = $this->equivalentClassSectionIdForYear($previousSectionId, $previousSectionName, $targetSchoolYear); + + if ($targetSectionId <= 0) { + $targetSectionId = $previousSectionId; + } + + if ($targetSectionId <= 0) { + $this->openMakeupExamPlacementFlag($studentId, $targetSchoolYear, null, $previousSectionName !== '' ? $previousSectionName : null); + return; + } + + $now = utc_now(); + $updatedBy = (int) (session()->get('user_id') ?? 0) ?: null; + $studentClass = new \App\Models\StudentClassModel(); + $existing = $studentClass->where('student_id', $studentId) + ->where('school_year', $targetSchoolYear) + ->first(); + + $payload = [ + 'student_id' => $studentId, + 'class_section_id' => $targetSectionId, + 'school_year' => $targetSchoolYear, + 'updated_by' => $updatedBy, + 'updated_at' => $now, + 'description' => 'Held at previous grade pending fall make-up exam result.', + ]; + + if ($existing) { + $studentClass->update((int) $existing['id'], $payload); + } else { + $payload['created_at'] = $now; + $studentClass->insert($payload); + } + + $this->db->table('enrollments') + ->where('student_id', $studentId) + ->where('school_year', $targetSchoolYear) + ->whereIn('enrollment_status', ['admission under review', 'review & decision', 'payment pending', 'enrolled']) + ->update([ + 'class_section_id' => $targetSectionId, + 'updated_at' => $now, + ]); + + $targetSectionName = $this->classSectionNameById($targetSectionId) ?? $previousSectionName; + $this->openMakeupExamPlacementFlag( + $studentId, + $targetSchoolYear, + $targetSectionId, + $targetSectionName !== '' ? $targetSectionName : null + ); + } + + private function equivalentClassSectionIdForYear(int $sourceSectionId, string $sourceSectionName, string $targetSchoolYear): int + { + if ($sourceSectionName !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $row = $this->db->table('classSection') + ->select('class_section_id') + ->where('class_section_name', $sourceSectionName) + ->where('school_year', $targetSchoolYear) + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + if ($row !== null && (int) ($row['class_section_id'] ?? 0) > 0) { + return (int) $row['class_section_id']; + } + } + + return $sourceSectionId; + } + + private function classSectionNameById(int $classSectionId): ?string + { + if ($classSectionId <= 0) { + return null; + } + + $row = $this->db->table('classSection') + ->select('class_section_name') + ->where('class_section_id', $classSectionId) + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + $name = trim((string) ($row['class_section_name'] ?? '')); + + return $name !== '' ? $name : null; + } + + private function baseSectionForClassYear(int $classId, string $schoolYear): ?array + { + $this->ensureClassSectionsForYear($schoolYear); + if ($classId <= 0 || $schoolYear === '' || ! $this->db->tableExists('classSection')) { + return null; + } + + $builder = $this->db->table('classSection') + ->select('class_section_id, class_section_name, class_id') + ->where('class_id', $classId) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->orderBy('id', 'ASC') + ->limit(1); + + if ($this->db->fieldExists('school_year', 'classSection')) { + $builder->where('school_year', $schoolYear); + } + + $row = $builder->get()->getRowArray(); + + return $row !== null && (int) ($row['class_section_id'] ?? 0) > 0 ? $row : null; + } + + private function ensureClassSectionsForYear(string $targetSchoolYear): void + { + $targetSchoolYear = trim($targetSchoolYear); + if ($targetSchoolYear === '' || ! $this->db->tableExists('classSection') || ! $this->db->fieldExists('school_year', 'classSection')) { + return; + } + + if ($this->db->table('classSection')->where('school_year', $targetSchoolYear)->countAllResults() > 0) { + return; + } + + $sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + if ($sourceSchoolYear === null) { + return; + } + + $sourceRows = $this->db->table('classSection') + ->select('class_id, class_section_id, class_section_name') + ->where('school_year', $sourceSchoolYear) + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + + $now = utc_now(); + foreach ($sourceRows as $row) { + $classSectionId = (int)($row['class_section_id'] ?? 0); + if ($classSectionId <= 0) { + continue; + } + + $exists = $this->db->table('classSection') + ->where('school_year', $targetSchoolYear) + ->where('class_section_id', $classSectionId) + ->countAllResults(); + if ($exists > 0) { + continue; + } + + $this->db->table('classSection')->insert([ + 'class_id' => (int)($row['class_id'] ?? 0), + 'class_section_id' => $classSectionId, + 'class_section_name' => (string)($row['class_section_name'] ?? ''), + 'school_year' => $targetSchoolYear, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + private function openMakeupExamPlacementFlag( + int $studentId, + string $targetSchoolYear, + ?int $classSectionId, + ?string $classSectionName + ): void { + if ($studentId <= 0 || $targetSchoolYear === '' || ! $this->db->tableExists('current_flag')) { + return; + } + + $student = $this->db->table('students') + ->select('firstname, lastname') + ->where('id', $studentId) + ->limit(1) + ->get() + ->getRowArray(); + $studentName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); + $studentName = $studentName !== '' ? $studentName : 'Student ID ' . $studentId; + $heldGradeText = $classSectionName !== null && trim($classSectionName) !== '' + ? ' Student is currently held in ' . trim($classSectionName) . '.' + : ''; + $description = 'Fall make-up exam enrollment: keep student at the closed-year grade until the exam is passed. After passing, place the student in the new grade.' . $heldGradeText; + + $existing = $this->db->table('current_flag') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year', $targetSchoolYear) + ->where('flag', 'grade') + ->where('flag_state', 'Open') + ->where('open_description', $description) + ->limit(1) + ->get() + ->getRowArray(); + + if ($existing !== null) { + return; + } + + $now = utc_now(); + $this->db->table('current_flag')->insert([ + 'student_id' => $studentId, + 'student_name' => $studentName, + 'grade' => $classSectionId !== null && $classSectionId > 0 ? (string) $classSectionId : '', + 'flag' => 'grade', + 'flag_datetime' => $now, + 'flag_state' => 'Open', + 'updated_by_open' => (int) (session()->get('user_id') ?? 0) ?: null, + 'open_description' => $description, + 'semester' => (string) ($this->semester ?? ''), + 'school_year' => $targetSchoolYear, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + private function studentDecisionForYear(int $studentId, string $schoolYear): ?array { if ($studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('student_decisions')) { @@ -824,37 +1228,325 @@ class ParentController extends BaseController private function blockedEnrollmentDecisionMessages(array $studentIds, string $targetSchoolYear): array { $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); - if ($previousSchoolYear === null) { - return []; - } $messages = []; foreach (array_unique($studentIds) as $studentId) { $studentId = (int) $studentId; - $decisionRow = $this->studentDecisionForYear($studentId, $previousSchoolYear); - $decision = strtolower(trim((string) ($decisionRow['decision'] ?? ''))); - $source = strtolower(trim((string) ($decisionRow['source'] ?? ''))); - - if ($decision === 'pass' || $decision === 'repeat class') { + if ($studentId <= 0) { continue; } - if ($decision === '' && $source !== 'pending') { + if ($previousSchoolYear === null) { + $messages[] = 'Student ID ' . $studentId . ': enrollment cannot be submitted because the closing school year could not be determined.'; continue; } - $studentInfo = $this->studentModel->getFullNameById($studentId); - $studentName = is_array($studentInfo) - ? trim((string) ($studentInfo['firstname'] ?? '') . ' ' . (string) ($studentInfo['lastname'] ?? '')) - : trim((string) $studentInfo); - $studentName = $studentName !== '' ? $studentName : 'Student ID ' . $studentId; - - $messages[] = $studentName . ': enrollment cannot be submitted until the prior-year decision is resolved with administration.'; + try { + $studentName = $this->studentNameForEnrollmentMessage($studentId); + $evaluation = service('enrollmentTransition')->evaluate($studentId, $previousSchoolYear, $targetSchoolYear, 'parent'); + foreach ($evaluation['blockers'] ?? [] as $blocker) { + $blocker = trim((string) $blocker); + if ($blocker !== '') { + $messages[] = $this->messageWithStudentName($studentName, $blocker); + } + } + } catch (\Throwable $e) { + log_message('error', 'Enrollment transition evaluation failed: ' . $e->getMessage()); + $messages[] = 'Student ID ' . $studentId . ': enrollment eligibility could not be evaluated. Please contact the administration.'; + } } return $messages; } + private function enrollmentEligibilityMessageForStudent( + array $student, + ?array $decisionRow, + string $targetSchoolYear, + ?string $fallMakeupExamOn = null + ): array { + return EnrollmentEligibility::parentDecisionMessage($student, $decisionRow, $targetSchoolYear, $fallMakeupExamOn); + } + + private function transitionEvaluationForStudent(int $studentId, string $previousSchoolYear, string $selectedYear): ?array + { + try { + return service('enrollmentTransition')->evaluate($studentId, $previousSchoolYear, $selectedYear, 'parent'); + } catch (\Throwable $e) { + log_message('error', 'Enrollment transition evaluation failed for student ' . $studentId . ': ' . $e->getMessage()); + + return [ + 'blockers' => ['Enrollment eligibility could not be evaluated. Please contact the administration.'], + 'warnings' => [], + 'placement_status' => 'unknown', + 'deliberation_decision' => null, + 'parent_enrollment_allowed' => false, + 'student_self_enrollment_allowed' => false, + 'assigned_grade_id' => null, + 'assigned_class_section_id' => null, + ]; + } + } + + private function readonlyEnrollmentEvaluation(?array $decisionRow): array + { + $decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null) + ?? DeliberationDecision::normalize($decisionRow['decision'] ?? null); + + return [ + 'blockers' => [], + 'warnings' => [], + 'placement_status' => 'readonly', + 'deliberation_decision' => $decision, + 'decision_label' => DeliberationDecision::display($decisionRow['decision'] ?? '') ?: 'Pending', + 'parent_enrollment_allowed' => false, + 'student_self_enrollment_allowed' => false, + 'assigned_grade_id' => null, + 'assigned_class_section_id' => null, + ]; + } + + private function eligibilityMessageFromTransition(array $student, ?array $evaluation, ?string $fallMakeupExamOn): array + { + if ($evaluation === null) { + return $this->enrollmentEligibilityMessageForStudent( + $student, + $student['previous_year_decision'] ?? null, + $this->currentSchoolYearName((string) ($this->schoolYear ?? '')), + $fallMakeupExamOn + ); + } + + $blockers = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? [])))); + if ($blockers !== []) { + $name = $this->studentNameFromRow($student); + $blockers = array_map(fn(string $message): string => $this->messageWithStudentName($name, $message), $blockers); + + return [ + 'message' => implode(' ', $blockers), + 'blocking' => true, + 'level' => 'danger', + ]; + } + + $warnings = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['warnings'] ?? [])))); + if ($warnings !== []) { + return [ + 'message' => implode(' ', $warnings), + 'blocking' => false, + 'level' => 'warning', + ]; + } + + $decision = (string) ($evaluation['deliberation_decision'] ?? ''); + if ($decision === DeliberationDecision::MAKE_UP_EXAM) { + $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); + $dateText = $fallMakeupExamOn !== null ? ' on ' . local_date($fallMakeupExamOn, 'm-d-Y') : ''; + return [ + 'message' => ($name !== '' ? $name : 'The student') . ' may complete enrollment now and will initially remain in the same grade until the make-up exam' . $dateText . ' is resolved by administration.', + 'blocking' => false, + 'level' => 'warning', + ]; + } + + return ['message' => '', 'blocking' => false, 'level' => 'info']; + } + + private function studentNameFromRow(array $student): string + { + $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); + + return $name !== '' ? $name : 'The student'; + } + + private function studentNameForEnrollmentMessage(int $studentId): string + { + try { + $row = $this->db->table('students') + ->select('firstname, lastname') + ->where('id', $studentId) + ->limit(1) + ->get() + ->getRowArray(); + + return is_array($row) ? $this->studentNameFromRow($row) : 'Student ID ' . $studentId; + } catch (\Throwable $e) { + log_message('error', 'Unable to load student name for enrollment blocker: ' . $e->getMessage()); + + return 'Student ID ' . $studentId; + } + } + + private function messageWithStudentName(string $studentName, string $message): string + { + $studentName = trim($studentName) !== '' ? trim($studentName) : 'The student'; + $message = trim($message); + + if ($message === '' || str_contains($message, $studentName)) { + return $message; + } + + return $studentName . ': ' . $message; + } + + private function expectedPlacementLabel(?array $evaluation): string + { + if ($evaluation === null || ($evaluation['blockers'] ?? []) !== [] && empty($evaluation['assigned_grade_id'])) { + return 'Pending'; + } + + $status = (string) ($evaluation['placement_status'] ?? ''); + return match ($status) { + 'automatic_distribution_pending' => $this->gradeLabel($this->classNameById((int) ($evaluation['assigned_grade_id'] ?? 0))), + 'same_class_assigned', 'temporary_same_grade' => $this->classSectionNameById((int) ($evaluation['assigned_class_section_id'] ?? 0)) ?: 'Same grade', + 'manual_class_required' => 'Same grade - administration must assign class', + 'temporary_manual_class_required' => 'Same grade initially - administration must assign temporary class', + 'exit_required' => 'Completion or exit process required', + default => 'Pending', + }; + } + + private function requiredActionLabel(?array $evaluation): string + { + if ($evaluation === null) { + return 'Complete re-enrollment before the registration deadline.'; + } + + if (($evaluation['blockers'] ?? []) !== []) { + if (($evaluation['adult_student'] ?? false) && ! ($evaluation['parent_enrollment_allowed'] ?? false)) { + return 'Student must complete the authorized adult-student process or contact administration.'; + } + + $decision = (string) ($evaluation['deliberation_decision'] ?? ''); + if (in_array($decision, [ + DeliberationDecision::EXPELLED, + DeliberationDecision::WITHDRAWN, + DeliberationDecision::DEFERRED_DECISION, + ], true)) { + return 'Contact the school administration.'; + } + + return 'Review the eligibility message above.'; + } + + if (($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM) { + return 'Complete re-enrollment and follow make-up exam instructions.'; + } + + return 'Complete re-enrollment before the registration deadline.'; + } + + private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear): 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 = round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2); + $mandatoryFees = round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2); + $behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'information_only'); + $amountDue = max(0.0, $carryOver) + $registrationFee + $tuitionDue + $mandatoryFees; + + 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'] ?? '')), + ]; + } + + private function financialSubmissionBlockers(int $parentId, string $selectedYear): array + { + $previousSchoolYear = $this->previousSchoolYearName($selectedYear); + $summary = $this->familyFinancialSummary($parentId, $previousSchoolYear, $selectedYear); + if (($summary['carry_over_balance'] ?? 0.0) <= 0.0) { + return []; + } + + return match ((string) ($summary['balance_behavior'] ?? 'information_only')) { + 'submission_blocked_until_payment' => ['Registration cannot be submitted until the previous-year balance is paid.'], + 'admin_approval_required' => ['Registration requires administrative financial approval because there is a previous-year balance.'], + default => [], + }; + } + + private function financialPolicyMessage(string $behavior, string $configured): string + { + $configured = trim($configured); + if ($configured !== '') { + return $configured; + } + + return match ($behavior) { + 'payment_plan_required' => 'Registration may continue under an approved payment plan.', + 'submission_allowed_confirmation_blocked' => 'Registration may be submitted, but it will not be confirmed until the balance is settled.', + 'submission_blocked_until_payment' => 'The balance must be paid before registration can be submitted.', + 'admin_approval_required' => 'Please contact the finance office to arrange an approved exception.', + default => 'The balance is shown for information and does not currently block registration.', + }; + } + + private function invoiceBalanceForParent(int $parentId, string $schoolYear): float + { + if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('invoices')) { + return 0.0; + } + + $row = $this->db->table('invoices') + ->select('COALESCE(SUM(balance), 0) AS balance', false) + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->get() + ->getRowArray(); + + return round((float) ($row['balance'] ?? 0), 2); + } + + private function schoolYearConfig(string $schoolYear): array + { + if ($schoolYear === '' || ! $this->db->tableExists('school_years')) { + return []; + } + + return $this->db->table('school_years') + ->where('name', $schoolYear) + ->limit(1) + ->get() + ->getRowArray() ?: []; + } + + private function classNameById(int $classId): string + { + if ($classId <= 0 || ! $this->db->tableExists('classes')) { + return 'Assigned grade'; + } + + $row = $this->db->table('classes') + ->select('class_name') + ->where('id', $classId) + ->limit(1) + ->get() + ->getRowArray(); + + return trim((string) ($row['class_name'] ?? '')) ?: 'Assigned grade'; + } + + private function gradeLabel(string $className): string + { + $className = trim($className); + if ($className === '') { + return 'Assigned grade'; + } + + return preg_match('/^grade\b/i', $className) === 1 ? $className : 'Grade ' . $className; + } + private function previousSchoolYearName(string $schoolYear): ?string { $schoolYear = trim($schoolYear); @@ -1408,8 +2100,10 @@ class ParentController extends BaseController log_message('error', "Failed to fetch registration data: " . $e->getMessage()); return redirect()->back()->with('error', 'Failed to retrieve registration info.'); } + $selectedSchoolYear = (string) ($data['selectedYear'] ?? ''); $data['lastDayOfRegistration'] = $this->lastDayOfRegistration; - $data['registrationAgeDeadline'] = $this->dateAgeReference; + $data['registrationAgeDeadline'] = $this->registrationMinimumAgeDeadline($selectedSchoolYear); + $data['schoolYearAgeDeadline'] = $this->schoolYearAgeDeadline($selectedSchoolYear); return view('parent/register_student', $data); } @@ -1500,15 +2194,21 @@ class ParentController extends BaseController $this->validateNames($lastName); $dobObj = new \DateTime($dob); - $objregistrationAgeDeadline = new \DateTime($this->ageDateRefernce); - $age = $dobObj->diff($objregistrationAgeDeadline)->y; + $schoolYearAgeDeadline = $this->schoolYearAgeDeadline($schoolYear); + $age = $this->calculateAgeAsOfSchoolYearStartYear($dob, $schoolYear); - $validation = $this->validateDobAge($dob, $this->ageDateRefernce); + $validation = $this->validateDobAge( + $dob, + $this->registrationMinimumAgeDeadline($schoolYear), + 5, + 18, + $schoolYearAgeDeadline + ); if (!$validation['isValid']) { - $displayDeadline = (new DateTime($this->ageDateRefernce))->format('m-d-Y'); + $displayDeadline = (new DateTime($schoolYearAgeDeadline))->format('m-d-Y'); session()->setFlashdata( 'error', - "Student '{$firstName} {$lastName}' {$validation['message']}. Age at deadline would be: $displayDeadline." + "Student '{$firstName} {$lastName}' {$validation['message']}. General age is calculated as of {$displayDeadline}." ); return false; } @@ -1613,7 +2313,13 @@ $existing = $this->studentModel * @param int $maxAge Maximum allowed age (default: 18) * @return array ['isValid' => bool, 'message' => string] */ - function validateDobAge(string $dob, string $registrationAgeDeadline, int $minAge = 5, int $maxAge = 18): array + function validateDobAge( + string $dob, + string $registrationAgeDeadline, + int $minAge = 5, + int $maxAge = 18, + ?string $schoolYearAgeDeadline = null + ): array { $response = ['isValid' => false, 'message' => '', 'age' => null]; @@ -1635,24 +2341,32 @@ $existing = $this->studentModel return $response; } - // 3) Parse deadline; if it's date-only, we set it to end-of-day to be inclusive + // 3) Parse deadlines; Dec 31 is only the special minimum-age registration grace date. try { - $deadline = new DateTimeImmutable($registrationAgeDeadline, $tz); + $minimumAgeDeadline = new DateTimeImmutable($registrationAgeDeadline, $tz); } catch (Throwable $e) { - $deadline = new DateTimeImmutable('now', $tz); + $minimumAgeDeadline = new DateTimeImmutable('now', $tz); } - // Inclusive of the deadline date - $deadline = $deadline->setTime(23, 59, 59); - // 4) Age at deadline (now that times are normalized) - $ageAtDeadline = $birthDate->diff($deadline)->y; + try { + $ageDeadline = new DateTimeImmutable($schoolYearAgeDeadline ?: $registrationAgeDeadline, $tz); + } catch (Throwable $e) { + $ageDeadline = $minimumAgeDeadline; + } + + $minimumAgeDeadline = $minimumAgeDeadline->setTime(23, 59, 59); + $ageDeadline = $ageDeadline->setTime(23, 59, 59); + + // 4) The persisted/general age is based on Sep 1 of the school year. + $ageAtDeadline = $birthDate->diff($ageDeadline)->y; + $ageAtMinimumAgeDeadline = $birthDate->diff($minimumAgeDeadline)->y; $response['age'] = $ageAtDeadline; // 5) Allowed birthdate window (inclusive) - // - Earliest birthdate = deadline - maxAge years (exactly maxAge on deadline is OK) - // - Latest birthdate = deadline - minAge years (exactly minAge on deadline is OK) - $minBirthDate = $deadline->modify("-{$maxAge} years")->setTime(0, 0, 0); // earliest allowed - $maxBirthDate = $deadline->modify("-{$minAge} years")->setTime(23, 59, 59); // latest allowed + // - Earliest birthdate = Sep 1 - maxAge years. + // - Latest birthdate = Dec 31 - minAge years, only for minimum-age registration eligibility. + $minBirthDate = $ageDeadline->modify('-' . ($maxAge + 1) . ' years')->modify('+1 day')->setTime(0, 0, 0); // earliest allowed + $maxBirthDate = $minimumAgeDeadline->modify("-{$minAge} years")->setTime(23, 59, 59); // youngest allowed // 6) Validate $isValid = ($birthDate >= $minBirthDate) && ($birthDate <= $maxBirthDate); @@ -1660,11 +2374,12 @@ $existing = $this->studentModel if (!$isValid) { $response['message'] = sprintf( - 'Must be %d-%d years old by %s. Current age would be: %d', + 'Must be at least %d years old by %s and no older than %d by %s. Current registration age would be: %d', $minAge, + $minimumAgeDeadline->format('m-d-Y'), $maxAge, - $deadline->format('m-d-Y'), - $ageAtDeadline + $ageDeadline->format('m-d-Y'), + $ageAtMinimumAgeDeadline ); } @@ -2031,9 +2746,9 @@ $existing = $this->studentModel { try { $parts = explode('-', $this->schoolYear); - $endYear = isset($parts[1]) ? trim($parts[1]) : date('Y'); + $startYear = isset($parts[0]) ? trim($parts[0]) : date('Y'); - $registrationAgeDeadline = "$endYear-12-31"; + $registrationAgeDeadline = "$startYear-09-01"; // Convert both dates into DateTime objects $deadlineObj = \DateTime::createFromFormat('Y-m-d', $registrationAgeDeadline); @@ -2062,41 +2777,6 @@ $existing = $this->studentModel } } - private function calculateAgeAsOfToday(?string $dob): ?int - { - $dob = trim((string) $dob); - - if ($dob === '') { - return null; - } - - try { - $timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone())); - $birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone); - $errors = \DateTimeImmutable::getLastErrors(); - $hasParseErrors = is_array($errors) - && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); - - if ($birthDate === false || $hasParseErrors) { - return null; - } - - $today = new \DateTimeImmutable('today', $timezone); - - if ($birthDate > $today) { - return null; - } - - return $birthDate->diff($today)->y; - } catch (\Throwable $e) { - log_message('warning', 'Unable to calculate current age from DOB: {message}', [ - 'message' => $e->getMessage(), - ]); - - return null; - } - } - private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int { $dob = trim((string) $dob); @@ -2117,7 +2797,7 @@ $existing = $this->studentModel return null; } - $schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-12-31', $timezone); + $schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-09-01', $timezone); if ($birthDate > $schoolYearStartYearCutoff) { return null; @@ -2133,6 +2813,33 @@ $existing = $this->studentModel } } + private function schoolYearAgeDeadline(string $schoolYear): string + { + if (preg_match('/^(\d{4})/', trim($schoolYear), $matches)) { + return $matches[1] . '-09-01'; + } + + if (!empty($this->schoolStartDate) && strtotime((string) $this->schoolStartDate)) { + return (new \DateTimeImmutable((string) $this->schoolStartDate))->format('Y-m-d'); + } + + return date('Y') . '-09-01'; + } + + private function registrationMinimumAgeDeadline(string $schoolYear): string + { + $configured = trim((string) ($this->ageDateRefernce ?? $this->dateAgeReference ?? '')); + if ($configured !== '' && strtotime($configured)) { + return (new \DateTimeImmutable($configured))->format('Y-m-d'); + } + + if (preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) { + return $matches[2] . '-12-31'; + } + + return date('Y') . '-12-31'; + } + public function parentEventPage() { $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); diff --git a/app/Controllers/View/PaymentController.php b/app/Controllers/View/PaymentController.php index 4c21f2c..41ac88d 100644 --- a/app/Controllers/View/PaymentController.php +++ b/app/Controllers/View/PaymentController.php @@ -151,6 +151,13 @@ class PaymentController extends ResourceController } } + private function assertSchoolYearNameWritable(string $schoolYear): void + { + service('schoolYearWriteGuard')->assertWritable( + service('schoolYearContext')->forYearName($schoolYear) + ); + } + // View: Create a new payment plan public function create() { @@ -160,6 +167,12 @@ class PaymentController extends ResourceController // API: Update balance after payment public function updateBalanceAPI($paymentId) { + $payment = $this->paymentModel->find($paymentId); + if (!$payment) { + return $this->failNotFound('Payment not found.'); + } + $this->assertSchoolYearNameWritable((string)($payment['school_year'] ?? '')); + $amountPaid = $this->request->getPost('paid_amount'); if ($this->paymentModel->updateBalance($paymentId, $amountPaid)) { return $this->respond(['status' => 'success']); @@ -171,6 +184,12 @@ class PaymentController extends ResourceController // View: Update balance after payment public function updateBalance($paymentId) { + $payment = $this->paymentModel->find($paymentId); + if (!$payment) { + return redirect()->back()->with('error', 'Payment not found.'); + } + $this->assertSchoolYearNameWritable((string)($payment['school_year'] ?? '')); + $amountPaid = $this->request->getPost('paid_amount'); if ($this->paymentModel->updateBalance($paymentId, $amountPaid)) { return redirect()->to('/payments'); @@ -879,6 +898,7 @@ class PaymentController extends ResourceController if (!$invoice) { return redirect()->back()->with('error', 'Invoice not found.'); } + $this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? '')); // Snapshot pre-payment balance $initialPreBalance = $this->getCurrentInvoiceBalance($invoiceId); @@ -1142,6 +1162,7 @@ class PaymentController extends ResourceController if (!$invoice) { return redirect()->back()->with('error', 'Linked invoice not found.'); } + $this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ($payment['school_year'] ?? ''))); //$schoolYear = (new ConfigurationModel())->getConfig('school_year'); $checkFile = $payment['check_file']; // default keep old file @@ -1280,6 +1301,7 @@ class PaymentController extends ResourceController } $schoolYear = (string)($invoice['school_year'] ?? $this->schoolYear); + $this->assertSchoolYearNameWritable($schoolYear); $this->recalculateInvoice((int)$invoice['id'], $schoolYear); return redirect()->back()->with('success', 'Invoice recalculated.'); diff --git a/app/Controllers/View/ReimbursementController.php b/app/Controllers/View/ReimbursementController.php index 0f64a68..686dbde 100644 --- a/app/Controllers/View/ReimbursementController.php +++ b/app/Controllers/View/ReimbursementController.php @@ -488,6 +488,7 @@ class ReimbursementController extends BaseController 'error' => 'Expense not found.', ]); } + $this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? '')); if (!empty($expense['reimbursement_id'])) { return $this->response->setStatusCode(409)->setJSON([ @@ -655,6 +656,12 @@ public function updateBatchAssignment() $this->db->transBegin(); try { + $expense = $this->expenseModel->find($expenseId); + if (!$expense) { + throw new \RuntimeException('Expense not found.'); + } + $this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? '')); + $activeItem = $this->batchItemModel ->where('expense_id', $expenseId) ->where('unassigned_at IS NULL', null, false) @@ -698,6 +705,7 @@ public function updateBatchAssignment() 'error' => 'Batch not found or already closed.', ]); } + $this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? '')); if (!$reimbursementId) { if ($activeItem && !empty($activeItem['reimbursement_id'])) { @@ -847,6 +855,7 @@ public function updateBatchAssignment() 'error' => 'Batch not found or already closed.', ]); } + $this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? '')); $adminRows = $this->batchItemModel ->select('DISTINCT COALESCE(admin_id, 0) AS admin_id') @@ -1038,6 +1047,7 @@ public function updateBatchAssignment() 'error' => 'Batch not found.', ]); } + $this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? '')); if (strtolower((string) ($batch['status'] ?? '')) !== 'open') { return $this->response->setStatusCode(409)->setJSON([ 'success' => false, @@ -1618,6 +1628,7 @@ public function updateBatchAssignment() 'error' => 'Requested batch was not found.', ]); } + $this->assertSchoolYearNameWritable((string)($batch['school_year'] ?? '')); $receiptRows = !empty($receiptIds) ? $this->fetchBatchReceiptRows($batchId, $receiptIds) : []; @@ -2108,6 +2119,7 @@ public function updateBatchAssignment() if (!$reimb) { throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found"); } + $this->assertSchoolYearNameWritable((string)($reimb['school_year'] ?? '')); if ($this->isPaidReimbursement($reimb)) { return redirect()->to('/reimbursements')->with('error', 'Paid reimbursements are immutable. Reverse and replace the transaction instead.'); } @@ -2207,6 +2219,7 @@ public function updateBatchAssignment() if (!$reimbursement) { throw new \RuntimeException('Reimbursement not found.'); } + $this->assertSchoolYearNameWritable((string)($reimbursement['school_year'] ?? '')); if (FinancialStatus::normalizeReimbursementStatus($reimbursement['status'] ?? null) !== FinancialStatus::REIMBURSEMENT_PAID) { throw new \RuntimeException('Only paid reimbursements can be reversed.'); } @@ -2277,6 +2290,7 @@ public function updateBatchAssignment() if (!$expense) { throw new \RuntimeException('Expense not found.'); } + $this->assertSchoolYearNameWritable((string)($expense['school_year'] ?? '')); if (FinancialStatus::normalize((string) ($expense['status'] ?? '')) !== 'approved') { throw new \RuntimeException('Expense must be approved before reimbursement.'); } diff --git a/app/Controllers/View/StudentController.php b/app/Controllers/View/StudentController.php index ea8b025..81b5e92 100644 --- a/app/Controllers/View/StudentController.php +++ b/app/Controllers/View/StudentController.php @@ -13,6 +13,7 @@ use App\Models\ConfigurationModel; use App\Models\StudentSectionDistributionDraftModel; use App\Models\StudentAllergyModel; use App\Models\StudentMedicalConditionModel; +use App\Support\Enrollment\DeliberationDecision; use CodeIgniter\Database\Exceptions\DataException; use Config\Services; use Throwable; @@ -33,6 +34,9 @@ class StudentController extends BaseController protected $classSectionModel; protected $emergencyContact; protected $enrollmentModel; + protected $distributionBaseClassIdCache = []; + protected $distributionPreviousClassSectionCache = []; + protected $distributionExcludedDecisionCache = []; public function __construct() { @@ -890,6 +894,7 @@ class StudentController extends BaseController $maxRaw = trim((string) ($this->request->getPost('max_students_per_section') ?? '')); $maxPerSection = $maxRaw === '' ? null : (int) $maxRaw; $year = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear)); + $this->ensureClassSectionsForYear($year); if ($classId <= 0 && $classSectionId > 0) { $cid = $this->classSectionModel->getClassId($classSectionId); @@ -938,6 +943,7 @@ class StudentController extends BaseController $updatedBy = (int)(session()->get('user_id') ?? 0) ?: null; $now = utc_now(); $batchKey = sha1($year . ':' . $classId . ':' . microtime(true)); + $draftIdByStudentId = []; $this->db->transStart(); $studentIdsToReplace = array_values(array_unique(array_map( @@ -954,7 +960,7 @@ class StudentController extends BaseController $secId = (int)$b['class_section_id']; foreach ($b['assigned'] as $student) { $sid = (int)$student['student_id']; - $draftModel->insert([ + $draftId = (int)$draftModel->insert([ 'student_id' => $sid, 'class_id' => $classId, 'class_section_id' => $secId, @@ -968,8 +974,12 @@ class StudentController extends BaseController 'created_at' => $now, 'updated_at' => $now, ]); + if ($draftId > 0) { + $draftIdByStudentId[$sid] = $draftId; + } if ((int)($student['promotion_queue_id'] ?? 0) > 0) { $promo->update((int)$student['promotion_queue_id'], [ + 'to_class_id' => $classId, 'to_class_section_id' => $secId, 'status' => 'assigned', 'updated_by' => $updatedBy, @@ -998,17 +1008,35 @@ class StudentController extends BaseController $male = 0; $female = 0; $studentNames = []; + $studentAssignments = []; foreach ($b['assigned'] as $student) { $groups[$student['score_group']] = ($groups[$student['score_group']] ?? 0) + 1; $gender = strtolower((string)($student['gender'] ?? '')); if ($gender === 'female') $female++; else $male++; + $studentId = (int)($student['student_id'] ?? 0); $studentName = trim((string)($student['student_name'] ?? '')); if ($studentName === '') { - $studentName = 'Student #' . (int)($student['student_id'] ?? 0); + $studentName = 'Student #' . $studentId; } $studentNames[] = $studentName; + $studentAssignments[] = [ + 'draft_id' => $draftIdByStudentId[$studentId] ?? 0, + 'student_id' => $studentId, + 'student_name' => $studentName, + 'age_at_reference' => $student['age_at_reference'] ?? null, + 'gender' => (string)($student['gender'] ?? ''), + 'last_year_class_section' => (string)($student['last_year_class_section'] ?? ''), + 'class_id' => $classId, + 'class_section_id' => $secId, + 'class_section_name' => $nameById[$secId] ?? (string)$secId, + ]; } + usort($studentNames, static fn($a, $b): int => strnatcasecmp((string)$a, (string)$b)); + usort($studentAssignments, static function (array $a, array $b): int { + return strnatcasecmp((string)($a['student_name'] ?? ''), (string)($b['student_name'] ?? '')); + }); $summary[] = [ + 'class_id' => $classId, 'class_section_id' => $secId, 'class_section_name' => $nameById[$secId] ?? (string)$secId, 'total' => count($b['assigned']), @@ -1017,6 +1045,7 @@ class StudentController extends BaseController 'score_groups' => $groups, 'average_score' => count($scores) > 0 ? round(array_sum($scores) / count($scores), 2) : null, 'student_names' => $studentNames, + 'student_assignments'=> $studentAssignments, ]; } @@ -1029,25 +1058,215 @@ class StudentController extends BaseController } } + public function updateDistributionDraft() + { + $json = function (array $p, int $code = 200) { + $p['csrfTokenName'] = csrf_token(); + $p['csrfHash'] = csrf_hash(); + $p[csrf_token()] = csrf_hash(); + return $this->response->setStatusCode($code)->setJSON($p); + }; + + try { + $draftId = (int)$this->request->getPost('draft_id'); + $targetSectionId = (int)$this->request->getPost('class_section_id'); + if ($draftId <= 0 || $targetSectionId <= 0) { + return $json(['ok' => false, 'message' => 'Select a valid student draft and target section.'], 400); + } + + $draftModel = new StudentSectionDistributionDraftModel(); + $draft = $draftModel->where('id', $draftId) + ->where('status', 'pending') + ->first(); + if (!$draft) { + return $json(['ok' => false, 'message' => 'This draft assignment is no longer editable.'], 404); + } + + $year = (string)($draft['school_year'] ?? ''); + $targetSection = $this->sectionForDistribution($targetSectionId, $year); + if (!$targetSection) { + return $json(['ok' => false, 'message' => 'Target class or section must be valid for the selected school year.'], 400); + } + $targetClassId = (int)($targetSection['class_id'] ?? 0); + + $updatedBy = (int)(session()->get('user_id') ?? 0) ?: null; + $now = utc_now(); + + $this->db->transStart(); + $draftModel->update($draftId, [ + 'class_id' => $targetClassId, + 'class_section_id' => $targetSectionId, + 'updated_at' => $now, + ]); + + if ($this->db->tableExists('promotion_queue')) { + $this->db->table('promotion_queue') + ->where('student_id', (int)($draft['student_id'] ?? 0)) + ->where('school_year_to', $year) + ->whereIn('status', ['queued', 'assigned']) + ->update([ + 'to_class_id' => $targetClassId, + 'to_class_section_id' => $targetSectionId, + 'status' => 'assigned', + 'updated_by' => $updatedBy, + 'updated_at' => $now, + ]); + } + $this->db->transComplete(); + + if (!$this->db->transStatus()) { + return $json(['ok' => false, 'message' => 'Draft assignment could not be updated.'], 500); + } + + return $json(['ok' => true, 'message' => 'Draft assignment updated.']); + } catch (\Throwable $e) { + return $json(['ok' => false, 'message' => 'Draft assignment update failed: ' . $e->getMessage()], 500); + } + } + + public function updateDistributionCandidate() + { + $json = function (array $p, int $code = 200) { + $p['csrfTokenName'] = csrf_token(); + $p['csrfHash'] = csrf_hash(); + $p[csrf_token()] = csrf_hash(); + return $this->response->setStatusCode($code)->setJSON($p); + }; + + try { + $studentId = (int)$this->request->getPost('student_id'); + $targetSectionId = (int)$this->request->getPost('class_section_id'); + $year = trim((string)($this->request->getPost('school_year') ?? $this->schoolYear)); + + if ($studentId <= 0 || $targetSectionId <= 0 || $year === '') { + return $json(['ok' => false, 'message' => 'Select a valid student, year, and target assignment.'], 400); + } + + $targetSection = $this->sectionForDistribution($targetSectionId, $year); + if (!$targetSection) { + return $json(['ok' => false, 'message' => 'Target class or section must be valid for the selected school year.'], 400); + } + $targetClassId = (int)($targetSection['class_id'] ?? 0); + if ($targetClassId <= 0) { + return $json(['ok' => false, 'message' => 'Target class could not be resolved.'], 400); + } + + $queueRow = null; + if ($this->db->tableExists('promotion_queue')) { + $queueRow = $this->db->table('promotion_queue') + ->where('student_id', $studentId) + ->where('school_year_to', $year) + ->whereIn('status', ['queued', 'assigned']) + ->orderBy('id', 'DESC') + ->get() + ->getRowArray(); + } + + $previousSchoolYear = (string)($queueRow['school_year_from'] ?? ($this->previousSchoolYearName($year) ?? '')); + $previousScore = $this->previousAverageScore($studentId, $previousSchoolYear); + if ($previousScore === null && $this->db->tableExists('student_decisions')) { + $decisionRow = $this->db->table('student_decisions') + ->select('year_score') + ->where('student_id', $studentId) + ->where('school_year', $previousSchoolYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->get() + ->getRowArray(); + $previousScore = is_numeric($decisionRow['year_score'] ?? null) ? (float)$decisionRow['year_score'] : null; + } + $previousScore = $previousScore === null ? 0.0 : max(0.0, min(100.0, (float)$previousScore)); + + $draftModel = new StudentSectionDistributionDraftModel(); + $updatedBy = (int)(session()->get('user_id') ?? 0) ?: null; + $now = utc_now(); + + $this->db->transStart(); + $draftModel->where('student_id', $studentId) + ->where('school_year', $year) + ->where('status', 'pending') + ->delete(); + + $draftId = (int)$draftModel->insert([ + 'student_id' => $studentId, + 'class_id' => $targetClassId, + 'class_section_id' => $targetSectionId, + 'school_year' => $year, + 'previous_school_year' => $previousSchoolYear, + 'previous_final_score' => $previousScore, + 'score_group' => $this->scoreGroup($previousScore), + 'status' => 'pending', + 'batch_key' => sha1($year . ':' . $studentId . ':' . microtime(true)), + 'created_by' => $updatedBy, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + if ($this->db->tableExists('promotion_queue')) { + $this->db->table('promotion_queue') + ->where('student_id', $studentId) + ->where('school_year_to', $year) + ->whereIn('status', ['queued', 'assigned']) + ->update([ + 'to_class_id' => $targetClassId, + 'to_class_section_id' => $targetSectionId, + 'status' => 'assigned', + 'updated_by' => $updatedBy, + 'updated_at' => $now, + ]); + } + $this->db->transComplete(); + + if (!$this->db->transStatus() || $draftId <= 0) { + return $json(['ok' => false, 'message' => 'Candidate assignment could not be saved.'], 500); + } + + return $json(['ok' => true, 'message' => 'Candidate assignment saved.']); + } catch (\Throwable $e) { + return $json(['ok' => false, 'message' => 'Candidate assignment update failed: ' . $e->getMessage()], 500); + } + } + private function distributionCandidates(int $classId, string $year): array { - $rows = $this->db->table('promotion_queue pq') - ->select('pq.id AS promotion_queue_id, pq.student_id, pq.school_year_from, pq.to_class_id, students.firstname, students.lastname, students.gender, sd.year_score AS decision_score') + if ($this->isDistributionKgClass($classId, $year)) { + return $this->mergeDistributionCandidates( + $this->kgDistributionCandidates($classId, $year), + $this->currentYearDistributionCandidates($classId, $year) + ); + } + + $builder = $this->db->table('promotion_queue pq') + ->select('pq.id AS promotion_queue_id, pq.student_id, pq.school_year_from, pq.to_class_id, students.firstname, students.lastname, students.gender, students.age, students.dob, sd.year_score AS decision_score') ->join('students', 'students.id = pq.student_id', 'left') ->join('student_decisions sd', 'sd.student_id = pq.student_id AND sd.school_year = pq.school_year_from', 'left') - ->where('pq.to_class_id', $classId) ->where('pq.school_year_to', $year) ->whereIn('pq.status', ['queued', 'assigned']) - ->groupBy('pq.id') + ->groupBy('pq.id'); + $this->applyDistributionAgeFilter($builder, $year); + $rows = $builder ->get() ->getResultArray(); - if (empty($rows)) { - return $this->decisionDistributionCandidates($classId, $year); - } - $out = []; + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($excludedByDecision[$studentId])) { + continue; + } + + $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year); + $targetClassId = $this->distributionTargetClassIdForStudent( + is_numeric($row['to_class_id'] ?? null) ? (int)$row['to_class_id'] : null, + $ageAtReference, + $year, + (string)($row['source_class_name'] ?? '') + ); + if ($targetClassId !== $classId) { + continue; + } + $score = is_numeric($row['decision_score'] ?? null) ? (float)$row['decision_score'] : $this->previousAverageScore((int)$row['student_id'], (string)($row['school_year_from'] ?? '')); @@ -1055,14 +1274,331 @@ class StudentController extends BaseController $row['previous_final_score'] = $score; $row['score_group'] = $this->scoreGroup($score); $row['student_name'] = $this->formatStudentName($row); + $row['age_at_reference'] = $ageAtReference; + $row['last_year_class_section'] = $this->distributionPreviousClassSectionName( + (int)($row['student_id'] ?? 0), + $year, + (string)($row['school_year_from'] ?? '') + ); $out[] = $row; } + return $this->mergeDistributionCandidates( + $out, + $this->decisionDistributionCandidates($classId, $year), + $this->currentYearDistributionCandidates($classId, $year) + ); + } + + private function currentYearDistributionCandidates(int $classId, string $year): array + { + if ($classId <= 0 || $year === '' || ! $this->db->tableExists('students')) { + return []; + } + + $rows = []; + + if ($this->db->tableExists('student_class')) { + $builder = $this->db->table('student_class sc') + ->select('0 AS promotion_queue_id, sc.student_id, sc.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false) + ->join('students', 'students.id = sc.student_id', 'inner') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->where('sc.school_year', $year) + ->where('sc.class_section_id IS NOT NULL', null, false); + + if ($this->db->fieldExists('is_event_only', 'student_class')) { + $builder->groupStart() + ->where('sc.is_event_only', 0) + ->orWhere('sc.is_event_only', null) + ->groupEnd(); + } + + if ($this->db->fieldExists('is_active', 'students')) { + $builder->where('students.is_active', 1); + } + + $this->applyDistributionAgeFilter($builder, $year); + $rows = array_merge($rows, $builder->get()->getResultArray()); + } + + if ($this->db->tableExists('enrollments')) { + $builder = $this->db->table('enrollments e') + ->select('0 AS promotion_queue_id, e.student_id, e.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false) + ->join('students', 'students.id = e.student_id', 'inner') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') + ->where('e.school_year', $year) + ->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled']) + ->groupStart() + ->where('e.is_withdrawn', 0) + ->orWhere('e.is_withdrawn', null) + ->groupEnd(); + + if ($this->db->fieldExists('is_active', 'students')) { + $builder->where('students.is_active', 1); + } + + $this->applyDistributionAgeFilter($builder, $year); + $rows = array_merge($rows, $builder->get()->getResultArray()); + } + + if ($this->db->fieldExists('school_year', 'students')) { + $builder = $this->db->table('students') + ->select('0 AS promotion_queue_id, id AS student_id, school_year AS school_year_from, NULL AS source_class_id, registration_grade AS source_class_name, firstname, lastname, gender, age, dob, registration_grade', false) + ->where('school_year', $year); + + if ($this->db->fieldExists('is_active', 'students')) { + $builder->where('is_active', 1); + } + + $this->applyDistributionAgeFilter($builder, $year); + $rows = array_merge($rows, $builder->get()->getResultArray()); + } + + $previousYear = $this->previousSchoolYearName($year) ?? ''; + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); + $out = []; + foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($excludedByDecision[$studentId])) { + continue; + } + + $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year); + $targetClassId = $this->distributionTargetClassIdForStudent( + is_numeric($row['source_class_id'] ?? null) ? (int)$row['source_class_id'] : null, + $ageAtReference, + $year, + (string)($row['source_class_name'] ?? $row['registration_grade'] ?? '') + ); + if ($targetClassId !== $classId) { + continue; + } + + $score = $this->previousAverageScore($studentId, $previousYear); + $score = $score === null ? 0.0 : max(0.0, min(100.0, $score)); + $out[] = [ + 'promotion_queue_id' => 0, + 'student_id' => $studentId, + 'school_year_from' => $previousYear, + 'to_class_id' => $classId, + 'student_name' => $this->formatStudentName($row), + 'age_at_reference' => $ageAtReference, + 'gender' => (string)($row['gender'] ?? ''), + 'last_year_class_section' => $this->distributionPreviousClassSectionName($studentId, $year, $previousYear), + 'previous_final_score' => $score, + 'score_group' => $this->scoreGroup($score), + ]; + } + return $out; } + private function mergeDistributionCandidates(array ...$candidateSets): array + { + $out = []; + $seen = []; + + foreach ($candidateSets as $candidates) { + foreach ($candidates as $candidate) { + $studentId = (int)($candidate['student_id'] ?? 0); + if ($studentId <= 0 || isset($seen[$studentId])) { + continue; + } + + $seen[$studentId] = true; + $out[] = $candidate; + } + } + + return $out; + } + + private function kgDistributionCandidates(int $classId, string $year): array + { + if ($classId <= 0 || $year === '' || ! $this->db->tableExists('enrollments') || ! $this->db->tableExists('students')) { + return []; + } + + $builder = $this->db->table('enrollments e') + ->select('0 AS promotion_queue_id, e.student_id, e.school_year AS school_year_from, cs.class_id AS source_class_id, cs.class_section_name AS source_class_name, students.firstname, students.lastname, students.gender, students.age, students.dob, students.registration_grade', false) + ->join('students', 'students.id = e.student_id', 'inner') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') + ->where('e.school_year', $year) + ->whereIn('e.enrollment_status', ['admission under review', 'payment pending', 'enrolled']) + ->groupStart() + ->where('e.is_withdrawn', 0) + ->orWhere('e.is_withdrawn', null) + ->groupEnd() + ->groupStart() + ->where('cs.class_id', $classId) + ->orWhereIn('UPPER(students.registration_grade)', ['KG', 'K', 'KINDERGARTEN']) + ->groupEnd(); + + if ($this->db->fieldExists('is_active', 'students')) { + $builder->where('students.is_active', 1); + } + + $this->applyDistributionAgeFilter($builder, $year); + $rows = $builder + ->orderBy('students.lastname', 'ASC') + ->orderBy('students.firstname', 'ASC') + ->get() + ->getResultArray(); + + $seen = []; + $out = []; + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); + foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($seen[$studentId]) || isset($excludedByDecision[$studentId])) { + continue; + } + $seen[$studentId] = true; + + $score = $this->previousAverageScore($studentId, $this->previousSchoolYearName($year) ?? ''); + $score = $score === null ? 0.0 : max(0.0, min(100.0, $score)); + $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year); + $targetClassId = $this->distributionTargetClassIdForStudent( + is_numeric($row['source_class_id'] ?? null) ? (int)$row['source_class_id'] : $classId, + $ageAtReference, + $year, + (string)($row['source_class_name'] ?? $row['registration_grade'] ?? '') + ); + if ($targetClassId !== $classId) { + continue; + } + + $out[] = [ + 'promotion_queue_id' => 0, + 'student_id' => $studentId, + 'school_year_from' => '', + 'to_class_id' => $classId, + 'student_name' => $this->formatStudentName($row), + 'age_at_reference' => $ageAtReference, + 'gender' => (string)($row['gender'] ?? ''), + 'last_year_class_section' => $this->distributionPreviousClassSectionName($studentId, $year, $this->previousSchoolYearName($year)), + 'previous_final_score' => $score, + 'score_group' => $this->scoreGroup($score), + ]; + } + + foreach ($this->registeredKgDistributionCandidates($classId, $year) as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($seen[$studentId])) { + continue; + } + $seen[$studentId] = true; + $out[] = $row; + } + + usort($out, static function (array $a, array $b): int { + return strnatcasecmp((string)($a['student_name'] ?? ''), (string)($b['student_name'] ?? '')); + }); + + return $out; + } + + private function registeredKgDistributionCandidates(int $classId, string $year): array + { + if ($classId <= 0 || $year === '' || ! $this->db->tableExists('students')) { + return []; + } + + $builder = $this->db->table('students') + ->select('id AS student_id, firstname, lastname, gender, age, dob, registration_grade') + ->groupStart() + ->where('UPPER(registration_grade)', 'KG') + ->orWhere('UPPER(registration_grade)', 'K') + ->orWhere('UPPER(registration_grade)', 'KINDERGARTEN') + ->groupEnd(); + + if ($this->db->fieldExists('school_year', 'students')) { + $builder->where('school_year', $year); + } elseif ($this->db->fieldExists('year_of_registration', 'students') && preg_match('/^(\d{4})/', $year, $matches)) { + $builder->where('year_of_registration', (int)$matches[1]); + } + + if ($this->db->fieldExists('is_active', 'students')) { + $builder->where('is_active', 1); + } + + $this->applyDistributionAgeFilter($builder, $year); + $rows = $builder + ->orderBy('lastname', 'ASC') + ->orderBy('firstname', 'ASC') + ->get() + ->getResultArray(); + + $out = []; + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); + foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($excludedByDecision[$studentId])) { + continue; + } + + $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $year); + $targetClassId = $this->distributionTargetClassIdForStudent( + $classId, + $ageAtReference, + $year, + (string)($row['registration_grade'] ?? 'KG') + ); + if ($targetClassId !== $classId) { + continue; + } + + $out[] = [ + 'promotion_queue_id' => 0, + 'student_id' => $studentId, + 'school_year_from' => '', + 'to_class_id' => $classId, + 'student_name' => $this->formatStudentName($row), + 'age_at_reference' => $ageAtReference, + 'gender' => (string)($row['gender'] ?? ''), + 'last_year_class_section' => $this->distributionPreviousClassSectionName($studentId, $year, $this->previousSchoolYearName($year)), + 'previous_final_score' => 0.0, + 'score_group' => $this->scoreGroup(0.0), + ]; + } + + return $out; + } + + private function isDistributionKgClass(int $classId, string $year): bool + { + if ($classId <= 0) { + return false; + } + + $query = $this->classSectionModel + ->select('class_section_name') + ->where('class_id', $classId) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->orderBy('id', 'DESC'); + if ($year !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $query->where('school_year', $year); + } + + $row = $query->first(); + if (!$row && $year !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $row = $this->classSectionModel + ->select('class_section_name') + ->where('class_id', $classId) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->orderBy('id', 'DESC') + ->first(); + } + + $name = strtoupper(trim((string)($row['class_section_name'] ?? ''))); + + return in_array($name, ['KG', 'K', 'KINDERGARTEN'], true); + } + private function letterSectionsForDistribution(int $classId, string $year): array { + $this->ensureClassSectionsForYear($year); + $query = $this->classSectionModel ->where('class_id', $classId) ->like('class_section_name', '-', 'both') @@ -1079,6 +1615,80 @@ class StudentController extends BaseController return $this->classSectionModel->getLetterSectionsByClassId($classId); } + private function sectionForDistribution(int $classSectionId, string $year): ?array + { + if ($classSectionId <= 0) { + return null; + } + $this->ensureClassSectionsForYear($year); + + $query = $this->classSectionModel + ->where('class_section_id', $classSectionId) + ->orderBy('id', 'DESC'); + if ($year !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $query->where('school_year', $year); + } + + $section = $query->first(); + if ($section || $year === '' || ! $this->db->fieldExists('school_year', 'classSection')) { + return $section ?: null; + } + + return $this->classSectionModel + ->where('class_section_id', $classSectionId) + ->orderBy('id', 'DESC') + ->first(); + } + + private function ensureClassSectionsForYear(string $targetSchoolYear): void + { + $targetSchoolYear = trim($targetSchoolYear); + if ($targetSchoolYear === '' || ! $this->db->tableExists('classSection') || ! $this->db->fieldExists('school_year', 'classSection')) { + return; + } + + if ($this->db->table('classSection')->where('school_year', $targetSchoolYear)->countAllResults() > 0) { + return; + } + + $sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + if ($sourceSchoolYear === null) { + return; + } + + $sourceRows = $this->db->table('classSection') + ->select('class_id, class_section_id, class_section_name') + ->where('school_year', $sourceSchoolYear) + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + + $now = utc_now(); + foreach ($sourceRows as $row) { + $classSectionId = (int)($row['class_section_id'] ?? 0); + if ($classSectionId <= 0) { + continue; + } + + $exists = $this->db->table('classSection') + ->where('school_year', $targetSchoolYear) + ->where('class_section_id', $classSectionId) + ->countAllResults(); + if ($exists > 0) { + continue; + } + + $this->db->table('classSection')->insert([ + 'class_id' => (int)($row['class_id'] ?? 0), + 'class_section_id' => $classSectionId, + 'class_section_name' => (string)($row['class_section_name'] ?? ''), + 'school_year' => $targetSchoolYear, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + private function decisionDistributionCandidates(int $classId, string $targetSchoolYear): array { $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); @@ -1086,13 +1696,15 @@ class StudentController extends BaseController return []; } - $rows = $this->db->table('student_decisions sd') - ->select('sd.id AS decision_id, sd.student_id, sd.class_section_name, sd.year_score, sd.decision, students.firstname, students.lastname, students.gender') + $builder = $this->db->table('student_decisions sd') + ->select('sd.id AS decision_id, sd.student_id, sd.class_section_name, sd.year_score, sd.decision, students.firstname, students.lastname, students.gender, students.age, students.dob') ->join('students', 'students.id = sd.student_id', 'left') ->where('sd.school_year', $previousSchoolYear) ->where('students.is_active', 1) ->orderBy('sd.updated_at', 'DESC') - ->orderBy('sd.id', 'DESC') + ->orderBy('sd.id', 'DESC'); + $this->applyDistributionAgeFilter($builder, $targetSchoolYear); + $rows = $builder ->get() ->getResultArray(); @@ -1104,11 +1716,21 @@ class StudentController extends BaseController continue; } $seen[$studentId] = true; + if (DeliberationDecision::normalize($row['decision'] ?? null) !== DeliberationDecision::PASSED) { + continue; + } $targetClassId = $this->targetClassIdFromDecision( (string)($row['class_section_name'] ?? ''), (string)($row['decision'] ?? '') ); + $ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $targetSchoolYear); + $targetClassId = $this->distributionTargetClassIdForStudent( + $targetClassId, + $ageAtReference, + $targetSchoolYear, + (string)($row['class_section_name'] ?? '') + ); if ($targetClassId !== $classId) { continue; } @@ -1121,7 +1743,9 @@ class StudentController extends BaseController 'school_year_from' => $previousSchoolYear, 'to_class_id' => $classId, 'student_name' => $this->formatStudentName($row), + 'age_at_reference' => $ageAtReference, 'gender' => (string)($row['gender'] ?? ''), + 'last_year_class_section' => $this->distributionPreviousClassSectionName($studentId, $targetSchoolYear, $previousSchoolYear), 'previous_final_score' => $score, 'score_group' => $this->scoreGroup($score), ]; @@ -1132,14 +1756,13 @@ class StudentController extends BaseController private function targetClassIdFromDecision(string $classSectionName, string $decision): ?int { - $decision = strtolower(trim($decision)); $baseName = strtoupper(trim(preg_replace('/-.+$/', '', $classSectionName) ?? '')); if ($baseName === '') { return null; } $targetBaseName = $baseName; - if ($decision === 'pass') { + if (DeliberationDecision::normalize($decision) === DeliberationDecision::PASSED) { if ($baseName === 'KG') { $targetBaseName = '1'; } elseif (ctype_digit($baseName)) { @@ -1195,6 +1818,125 @@ class StudentController extends BaseController return is_numeric($row['avg_score'] ?? null) ? (float)$row['avg_score'] : null; } + private function distributionExcludedDecisionStudentIds(string $targetSchoolYear): array + { + $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + if ($previousSchoolYear === null || ! $this->db->tableExists('student_decisions')) { + return []; + } + + if (array_key_exists($previousSchoolYear, $this->distributionExcludedDecisionCache)) { + return $this->distributionExcludedDecisionCache[$previousSchoolYear]; + } + + $rows = $this->db->table('student_decisions') + ->select('student_id, decision') + ->where('school_year', $previousSchoolYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + + $seen = []; + $excluded = []; + foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($seen[$studentId])) { + continue; + } + + $seen[$studentId] = true; + if ($this->isDistributionExcludedDecision((string)($row['decision'] ?? ''))) { + $excluded[$studentId] = true; + } + } + + $this->distributionExcludedDecisionCache[$previousSchoolYear] = $excluded; + + return $excluded; + } + + private function isDistributionExcludedDecision(string $decision): bool + { + return DeliberationDecision::normalize($decision) !== DeliberationDecision::PASSED; + } + + private function distributionPreviousClassSectionName(int $studentId, string $targetSchoolYear, ?string $sourceSchoolYear = null): string + { + if ($studentId <= 0) { + return ''; + } + + $previousYear = trim((string)($sourceSchoolYear ?? '')); + if ($previousYear === '' || $previousYear === $targetSchoolYear || preg_match('/^\d{4}-\d{4}$/', $previousYear) !== 1) { + $previousYear = $this->previousSchoolYearName($targetSchoolYear) ?? ''; + } + if ($previousYear === '') { + return ''; + } + + $cacheKey = $studentId . ':' . $previousYear; + if (array_key_exists($cacheKey, $this->distributionPreviousClassSectionCache)) { + return $this->distributionPreviousClassSectionCache[$cacheKey]; + } + + $names = []; + if ($this->db->tableExists('student_class')) { + $builder = $this->db->table('student_class sc') + ->select('cs.class_section_name') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->where('sc.student_id', $studentId) + ->where('sc.school_year', $previousYear) + ->where('sc.class_section_id IS NOT NULL', null, false); + + if ($this->db->fieldExists('is_event_only', 'student_class')) { + $builder->groupStart() + ->where('sc.is_event_only', 0) + ->orWhere('sc.is_event_only', null) + ->groupEnd(); + } + + $rows = $builder + ->orderBy('cs.class_section_name', 'ASC') + ->get() + ->getResultArray(); + foreach ($rows as $row) { + $name = trim((string)($row['class_section_name'] ?? '')); + if ($name !== '') { + $names[$name] = true; + } + } + } + + if (empty($names) && $this->db->tableExists('enrollments')) { + $rows = $this->db->table('enrollments e') + ->select('cs.class_section_name') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') + ->where('e.student_id', $studentId) + ->where('e.school_year', $previousYear) + ->where('e.class_section_id IS NOT NULL', null, false) + ->whereIn('e.enrollment_status', ['payment pending', 'enrolled']) + ->groupStart() + ->where('e.is_withdrawn', 0) + ->orWhere('e.is_withdrawn', null) + ->groupEnd() + ->orderBy('cs.class_section_name', 'ASC') + ->get() + ->getResultArray(); + foreach ($rows as $row) { + $name = trim((string)($row['class_section_name'] ?? '')); + if ($name !== '') { + $names[$name] = true; + } + } + } + + $value = implode(', ', array_keys($names)); + $this->distributionPreviousClassSectionCache[$cacheKey] = $value; + + return $value; + } + private function scoreGroup(float $score): string { if ($score >= 90) return '90_100'; @@ -1220,60 +1962,90 @@ class StudentController extends BaseController $buckets[$idx] = [ 'class_section_id' => (int)$section['class_section_id'], 'assigned' => [], + 'gender_counts' => ['male' => 0, 'female' => 0, 'other' => 0], ]; } - $groups = ['90_100' => [], '80_89' => [], '70_79' => [], '69_below' => []]; + $genderTotals = ['male' => 0, 'female' => 0, 'other' => 0]; foreach ($students as $student) { - $groups[$student['score_group']][] = $student; + $genderTotals[$this->distributionGenderKey($student)]++; + } + + $targetGenderCounts = []; + foreach ($genderTotals as $gender => $genderTotal) { + $baseGenderSize = intdiv($genderTotal, $sectionCount); + $genderRemainder = $genderTotal % $sectionCount; + $targetGenderCounts[$gender] = array_fill(0, $sectionCount, $baseGenderSize); + $order = range(0, $sectionCount - 1); + if ($gender === 'female') { + $order = array_reverse($order); + } + foreach ($order as $sectionIdx) { + if ($genderRemainder <= 0) { + break; + } + $targetGenderCounts[$gender][$sectionIdx]++; + $genderRemainder--; + } + } + + $groups = [ + '90_100' => ['male' => [], 'female' => [], 'other' => []], + '80_89' => ['male' => [], 'female' => [], 'other' => []], + '70_79' => ['male' => [], 'female' => [], 'other' => []], + '69_below' => ['male' => [], 'female' => [], 'other' => []], + ]; + foreach ($students as $student) { + $scoreGroup = (string)($student['score_group'] ?? '69_below'); + if (!isset($groups[$scoreGroup])) { + $scoreGroup = '69_below'; + } + $groups[$scoreGroup][$this->distributionGenderKey($student)][] = $student; } $currentCounts = array_fill(0, $sectionCount, 0); - $allocations = []; - $groupIndex = 0; - foreach ($groups as $groupName => $groupStudents) { - usort($groupStudents, static fn($a, $b): int => ((float)$b['previous_final_score']) <=> ((float)$a['previous_final_score'])); - $groupTotal = count($groupStudents); - $base = intdiv($groupTotal, $sectionCount); - $extra = $groupTotal % $sectionCount; - $allocations[$groupName] = array_fill(0, $sectionCount, $base); - foreach ($currentCounts as $idx => $cnt) { - $currentCounts[$idx] += $base; - } + $chooseSection = static function (string $gender) use (&$buckets, &$currentCounts, $targetSizes, $targetGenderCounts, $sectionCount): ?int { + $best = null; + $bestScore = null; - $order = range(0, $sectionCount - 1); - $offset = $groupIndex % $sectionCount; - $order = array_merge(array_slice($order, $offset), array_slice($order, 0, $offset)); - usort($order, static function ($a, $b) use ($targetSizes, $currentCounts) { - $remainingA = $targetSizes[$a] - $currentCounts[$a]; - $remainingB = $targetSizes[$b] - $currentCounts[$b]; - return $remainingB <=> $remainingA; - }); - - foreach ($order as $sectionIdx) { - if ($extra <= 0) break; - if ($currentCounts[$sectionIdx] >= $targetSizes[$sectionIdx]) continue; - $allocations[$groupName][$sectionIdx]++; - $currentCounts[$sectionIdx]++; - $extra--; - } - $groups[$groupName] = $groupStudents; - $groupIndex++; - } - - foreach ($groups as $groupName => $groupStudents) { - $quotas = $allocations[$groupName]; - foreach ($groupStudents as $idx => $student) { - $round = intdiv($idx, max(1, $sectionCount)); - $order = range(0, $sectionCount - 1); - if ($round % 2 === 1) { - $order = array_reverse($order); + for ($sectionIdx = 0; $sectionIdx < $sectionCount; $sectionIdx++) { + if ($currentCounts[$sectionIdx] >= $targetSizes[$sectionIdx]) { + continue; } - foreach ($order as $sectionIdx) { - if (($quotas[$sectionIdx] ?? 0) <= 0) continue; + + $genderRemaining = ($targetGenderCounts[$gender][$sectionIdx] ?? 0) - ($buckets[$sectionIdx]['gender_counts'][$gender] ?? 0); + $totalRemaining = $targetSizes[$sectionIdx] - $currentCounts[$sectionIdx]; + $score = [ + $genderRemaining > 0 ? 1 : 0, + $genderRemaining, + $totalRemaining, + -$currentCounts[$sectionIdx], + -$sectionIdx, + ]; + + if ($bestScore === null || $score > $bestScore) { + $best = $sectionIdx; + $bestScore = $score; + } + } + + return $best; + }; + + foreach ($groups as $genderGroups) { + foreach (['male', 'female', 'other'] as $gender) { + $groupStudents = $genderGroups[$gender] ?? []; + usort($groupStudents, static fn($a, $b): int => ((float)$b['previous_final_score']) <=> ((float)$a['previous_final_score'])); + + foreach ($groupStudents as $student) { + $sectionIdx = $chooseSection($gender); + if ($sectionIdx === null) { + continue; + } + $buckets[$sectionIdx]['assigned'][] = $student; - $quotas[$sectionIdx]--; - break; + $buckets[$sectionIdx]['gender_counts'][$gender]++; + $currentCounts[$sectionIdx]++; } } } @@ -1281,6 +2053,19 @@ class StudentController extends BaseController return $this->balanceDistributionAverages($buckets, $minPerSection, $maxPerSection); } + private function distributionGenderKey(array $student): string + { + $gender = strtolower(trim((string)($student['gender'] ?? ''))); + if ($gender === 'female' || $gender === 'f') { + return 'female'; + } + if ($gender === 'male' || $gender === 'm') { + return 'male'; + } + + return 'other'; + } + private function balanceDistributionAverages(array $buckets, int $minPerSection, ?int $maxPerSection): array { for ($i = 0; $i < 50; $i++) { @@ -1299,6 +2084,7 @@ class StudentController extends BaseController foreach ($buckets[$highIdx]['assigned'] as $hiPos => $hiStudent) { foreach ($buckets[$lowIdx]['assigned'] as $loPos => $loStudent) { if ($hiStudent['score_group'] !== $loStudent['score_group']) continue; + if ($this->distributionGenderKey($hiStudent) !== $this->distributionGenderKey($loStudent)) continue; $trial = $buckets; $trial[$highIdx]['assigned'][$hiPos] = $loStudent; $trial[$lowIdx]['assigned'][$loPos] = $hiStudent; @@ -1334,8 +2120,14 @@ class StudentController extends BaseController try { $year = trim((string)($this->request->getGet('school_year') ?? $this->schoolYear)); $includeClassIds = $this->parseIncludedClassIds($this->request->getGet('include_class_ids')); + $includeClassIds = array_values(array_unique(array_merge( + $includeClassIds, + $this->pendingDistributionDraftClassIds($year) + ))); + $draftTotalsByClassId = $this->pendingDistributionDraftTotalsByClassId($year); + $draftStudentIds = $this->pendingDistributionDraftStudentIds($year); - // Fetch base sections (no dash) and filter to KG, 1..9, youth + // Fetch base sections (no dash) and filter to KG, 1..10, Youth $baseQuery = $this->classSectionModel ->where("class_section_name NOT LIKE '%-%'", null, false) ->orderBy('class_id', 'ASC'); @@ -1355,7 +2147,7 @@ class StudentController extends BaseController $nameRaw = (string)($r['class_section_name'] ?? ''); $name = strtolower($nameRaw); - // Only KG, 1..9, Youth per request + // Only KG, 1..10, Youth per request if ($name === 'kg' || $name === 'youth') { $wanted[] = $r; continue; @@ -1363,7 +2155,7 @@ class StudentController extends BaseController if (ctype_digit($name)) { $num = (int)$name; - if ($num >= 1 && $num <= 9) { + if ($num >= 1 && $num <= 10) { $wanted[] = $r; continue; } @@ -1389,23 +2181,18 @@ class StudentController extends BaseController $out = []; foreach ($wanted as $r) { $classId = (int)$r['class_id']; - $cands = $this->db->table('promotion_queue pq') - ->select('pq.student_id') - ->where('pq.to_class_id', $classId) - ->where('pq.school_year_to', $year) - ->whereIn('pq.status', ['queued','assigned']) - ->groupBy('pq.student_id') - ->get()->getResultArray(); - $total = count($cands); - if ($total === 0) { - $total = count($this->decisionDistributionCandidates($classId, $year)); - } + $candidateStudents = array_values(array_filter( + $this->distributionCandidates($classId, $year), + static fn(array $student): bool => !isset($draftStudentIds[(int)($student['student_id'] ?? 0)]) + )); + $total = count($candidateStudents) + ($draftTotalsByClassId[$classId] ?? 0); $out[] = [ 'class_id' => $classId, 'class_section_id' => (int)($r['class_section_id'] ?? 0), 'class_section_name'=> (string)($r['class_section_name'] ?? ''), 'total' => $total, + 'students' => $this->distributionCandidateSummaries($candidateStudents, $classId, (string)($r['class_section_name'] ?? '')), 'sections' => $this->savedDistributionSections($classId, $year), ]; } @@ -1432,14 +2219,262 @@ class StudentController extends BaseController ))); } + private function distributionCandidateSummaries(array $students, int $classId, string $className): array + { + $out = []; + foreach ($students as $student) { + $studentId = (int)($student['student_id'] ?? 0); + $studentName = trim((string)($student['student_name'] ?? '')); + if ($studentName === '') { + $studentName = 'Student #' . $studentId; + } + + $out[] = [ + 'draft_id' => 0, + 'student_id' => $studentId, + 'student_name' => $studentName, + 'age_at_reference' => $student['age_at_reference'] ?? null, + 'gender' => (string)($student['gender'] ?? ''), + 'last_year_class_section' => (string)($student['last_year_class_section'] ?? ''), + 'class_id' => $classId, + 'class_section_id' => 0, + 'class_section_name' => $className, + ]; + } + + usort($out, static function (array $a, array $b): int { + return strnatcasecmp((string)($a['student_name'] ?? ''), (string)($b['student_name'] ?? '')); + }); + + return $out; + } + + private function applyDistributionAgeFilter($builder, string $schoolYear): void + { + [$earliestDob, $latestDob] = $this->distributionAgeBirthDateWindow($schoolYear); + + $builder + ->where('students.dob IS NOT NULL', null, false) + ->where('students.dob >=', $earliestDob) + ->where('students.dob <=', $latestDob); + } + + private function distributionAgeBirthDateWindow(string $schoolYear): array + { + $reference = $this->distributionAgeReferenceDate($schoolYear); + + return [ + $reference->modify('-18 years +1 day')->format('Y-m-d'), + $reference->modify('-5 years')->format('Y-m-d'), + ]; + } + + private function distributionAgeReferenceDate(string $schoolYear): \DateTimeImmutable + { + $schoolYear = trim($schoolYear); + if (!preg_match('/^(\d{4})/', $schoolYear, $matches)) { + throw new \RuntimeException('School year is required for auto-distribution age filtering.'); + } + + $timezone = new \DateTimeZone((string)(config('School')->attendance['timezone'] ?? user_timezone())); + $reference = new \DateTimeImmutable($matches[1] . '-09-01', $timezone); + + return $reference->setTime(0, 0, 0); + } + + private function distributionAgeAtReference($dob, string $schoolYear): ?int + { + $dob = trim((string)$dob); + if ($dob === '') { + return null; + } + + $timezone = new \DateTimeZone((string)(config('School')->attendance['timezone'] ?? user_timezone())); + $birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone); + $errors = \DateTimeImmutable::getLastErrors(); + $hasErrors = is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); + + if ($birthDate === false || $hasErrors) { + return null; + } + + $reference = $this->distributionAgeReferenceDate($schoolYear); + if ($birthDate > $reference) { + return null; + } + + return $birthDate->diff($reference)->y; + } + + private function distributionTargetClassIdForStudent(?int $defaultClassId, ?int $ageAtReference, string $schoolYear, string $sourceClassName = ''): ?int + { + if ($defaultClassId === null && $ageAtReference !== null && $ageAtReference < 6) { + return $this->distributionBaseClassIdByName('KG', $schoolYear); + } + + if ($this->isDistributionKgSource($defaultClassId, $sourceClassName, $schoolYear)) { + if ($ageAtReference !== null && $ageAtReference < 6) { + return $this->distributionBaseClassIdByName('KG', $schoolYear) ?? $defaultClassId; + } + + if ($ageAtReference !== null && $ageAtReference >= 6) { + return $this->distributionBaseClassIdByName('1', $schoolYear) ?? $defaultClassId; + } + } + + if ($defaultClassId !== null) { + return $defaultClassId; + } + + if ($ageAtReference === 14) { + return $this->distributionBaseClassIdByName('9', $schoolYear); + } + + if ($ageAtReference === 15) { + return $this->distributionBaseClassIdByName('10', $schoolYear); + } + + if ($ageAtReference === 16 || $ageAtReference === 17) { + return $this->distributionBaseClassIdByName('YOUTH', $schoolYear); + } + + return null; + } + + private function isDistributionKgSource(?int $classId, string $sourceClassName, string $schoolYear): bool + { + $sourceClassName = strtoupper(trim(preg_replace('/-.+$/', '', $sourceClassName) ?? '')); + if (in_array($sourceClassName, ['KG', 'K', 'KINDERGARTEN'], true)) { + return true; + } + + return $classId !== null && $classId > 0 && $this->isDistributionKgClass($classId, $schoolYear); + } + + private function distributionBaseClassIdByName(string $baseName, string $schoolYear): ?int + { + $normalized = strtoupper(trim($baseName)); + $cacheKey = $schoolYear . ':' . $normalized; + if (array_key_exists($cacheKey, $this->distributionBaseClassIdCache)) { + return $this->distributionBaseClassIdCache[$cacheKey]; + } + + $query = $this->classSectionModel + ->select('class_id') + ->where('UPPER(class_section_name)', $normalized) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->orderBy('id', 'DESC'); + if ($schoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $query->where('school_year', $schoolYear); + } + + $row = $query->first(); + if (!$row && $schoolYear !== '' && $this->db->fieldExists('school_year', 'classSection')) { + $row = $this->classSectionModel + ->select('class_id') + ->where('UPPER(class_section_name)', $normalized) + ->where("class_section_name NOT LIKE '%-%'", null, false) + ->orderBy('id', 'DESC') + ->first(); + } + + $this->distributionBaseClassIdCache[$cacheKey] = $row ? (int)$row['class_id'] : null; + + return $this->distributionBaseClassIdCache[$cacheKey]; + } + + private function pendingDistributionDraftClassIds(string $year): array + { + if ($year === '' || ! $this->db->tableExists('student_section_distribution_drafts')) { + return []; + } + + $builder = $this->db->table('student_section_distribution_drafts') + ->join('students', 'students.id = student_section_distribution_drafts.student_id', 'left') + ->select('student_section_distribution_drafts.class_id') + ->where('student_section_distribution_drafts.school_year', $year) + ->where('student_section_distribution_drafts.status', 'pending') + ->groupBy('student_section_distribution_drafts.class_id'); + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); + if (!empty($excludedByDecision)) { + $builder->whereNotIn('student_section_distribution_drafts.student_id', array_keys($excludedByDecision)); + } + $this->applyDistributionAgeFilter($builder, $year); + $rows = $builder + ->get() + ->getResultArray(); + + return array_values(array_unique(array_filter( + array_map(static fn(array $row): int => (int)($row['class_id'] ?? 0), $rows), + static fn(int $id): bool => $id > 0 + ))); + } + + private function pendingDistributionDraftTotalsByClassId(string $year): array + { + if ($year === '' || ! $this->db->tableExists('student_section_distribution_drafts')) { + return []; + } + + $builder = $this->db->table('student_section_distribution_drafts') + ->join('students', 'students.id = student_section_distribution_drafts.student_id', 'left') + ->select('student_section_distribution_drafts.class_id, COUNT(*) AS total', false) + ->where('student_section_distribution_drafts.school_year', $year) + ->where('student_section_distribution_drafts.status', 'pending') + ->groupBy('student_section_distribution_drafts.class_id'); + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); + if (!empty($excludedByDecision)) { + $builder->whereNotIn('student_section_distribution_drafts.student_id', array_keys($excludedByDecision)); + } + $this->applyDistributionAgeFilter($builder, $year); + $rows = $builder + ->get() + ->getResultArray(); + + $totals = []; + foreach ($rows as $row) { + $classId = (int)($row['class_id'] ?? 0); + if ($classId > 0) { + $totals[$classId] = (int)($row['total'] ?? 0); + } + } + + return $totals; + } + + private function pendingDistributionDraftStudentIds(string $year): array + { + if ($year === '' || ! $this->db->tableExists('student_section_distribution_drafts')) { + return []; + } + + $rows = $this->db->table('student_section_distribution_drafts') + ->select('student_id') + ->where('school_year', $year) + ->where('status', 'pending') + ->get() + ->getResultArray(); + + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); + $ids = []; + foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId > 0 && !isset($excludedByDecision[$studentId])) { + $ids[$studentId] = true; + } + } + + return $ids; + } + private function savedDistributionSections(int $classId, string $year): array { if (! $this->db->tableExists('student_section_distribution_drafts')) { return []; } - $rows = $this->db->table('student_section_distribution_drafts d') - ->select('d.class_section_id, cs.class_section_name, students.firstname, students.lastname, d.student_id') + $builder = $this->db->table('student_section_distribution_drafts d') + ->select('d.id AS draft_id, d.class_id, d.class_section_id, d.previous_school_year, cs.class_section_name, students.firstname, students.lastname, students.gender, students.dob, d.student_id') ->join('classSection cs', 'cs.class_section_id = d.class_section_id', 'left') ->join('students', 'students.id = d.student_id', 'left') ->where('d.class_id', $classId) @@ -1447,30 +2482,59 @@ class StudentController extends BaseController ->where('d.status', 'pending') ->orderBy('cs.class_section_name', 'ASC') ->orderBy('students.lastname', 'ASC') - ->orderBy('students.firstname', 'ASC') + ->orderBy('students.firstname', 'ASC'); + $this->applyDistributionAgeFilter($builder, $year); + $rows = $builder ->get() ->getResultArray(); + if (empty($rows)) { + return []; + } + $sections = []; + $excludedByDecision = $this->distributionExcludedDecisionStudentIds($year); + foreach ($rows as $row) { + $studentId = (int)($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($excludedByDecision[$studentId])) { + continue; + } + $sectionId = (int)($row['class_section_id'] ?? 0); if ($sectionId <= 0) { continue; } if (!isset($sections[$sectionId])) { $sections[$sectionId] = [ + 'class_id' => (int)($row['class_id'] ?? $classId), 'class_section_id' => $sectionId, 'class_section_name' => (string)($row['class_section_name'] ?? $sectionId), 'total' => 0, 'student_names' => [], + 'student_assignments' => [], ]; } $name = trim(trim((string)($row['firstname'] ?? '')) . ' ' . trim((string)($row['lastname'] ?? ''))); if ($name === '') { - $name = 'Student #' . (int)($row['student_id'] ?? 0); + $name = 'Student #' . $studentId; } $sections[$sectionId]['student_names'][] = $name; + $sections[$sectionId]['student_assignments'][] = [ + 'draft_id' => (int)($row['draft_id'] ?? 0), + 'student_id' => $studentId, + 'student_name' => $name, + 'age_at_reference' => $this->distributionAgeAtReference($row['dob'] ?? null, $year), + 'gender' => (string)($row['gender'] ?? ''), + 'last_year_class_section' => $this->distributionPreviousClassSectionName( + $studentId, + $year, + (string)($row['previous_school_year'] ?? '') + ), + 'class_id' => (int)($row['class_id'] ?? $classId), + 'class_section_id' => $sectionId, + ]; $sections[$sectionId]['total']++; } @@ -1570,10 +2634,11 @@ class StudentController extends BaseController } $dobStr = $dob->format('Y-m-d'); - $today = new \DateTimeImmutable('today', $tzLocal); - $age = (int)$today->format('Y') - (int)$dob->format('Y'); - if ((int)$today->format('md') < (int)$dob->format('md')) $age--; - if ($age < 0) throw new \RuntimeException('DOB results in negative age.'); + $ageSchoolYear = $in['school_year'] ?: $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); + $age = $this->distributionAgeAtReference($dobStr, $ageSchoolYear); + if ($age === null) { + throw new \RuntimeException('DOB results in invalid age for the selected school year.'); + } // registration_date: accept date or datetime-local; store as UTC DATETIME $regDateStr = null; diff --git a/app/Database/Migrations/2026-08-06-000100_AddSchoolYearTransitionEnrollmentFields.php b/app/Database/Migrations/2026-08-06-000100_AddSchoolYearTransitionEnrollmentFields.php new file mode 100644 index 0000000..0d4e051 --- /dev/null +++ b/app/Database/Migrations/2026-08-06-000100_AddSchoolYearTransitionEnrollmentFields.php @@ -0,0 +1,188 @@ +db->tableExists('student_decisions') && ! $this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { + $this->forge->addColumn('student_decisions', [ + 'deliberation_decision_standard' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + 'after' => 'decision', + ], + ]); + } + + if ($this->db->tableExists('student_decisions') && $this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { + $rows = $this->db->table('student_decisions') + ->select('id, decision') + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $standard = DeliberationDecision::normalize($row['decision'] ?? null); + if ($standard === null) { + continue; + } + + $this->db->table('student_decisions') + ->where('id', (int) $row['id']) + ->update(['deliberation_decision_standard' => $standard]); + } + } + + if (! $this->db->tableExists('enrollments')) { + return; + } + + $fields = []; + $this->addFieldIfMissing($fields, 'source_school_year', [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'null' => true, + 'after' => 'school_year', + ]); + $this->addFieldIfMissing($fields, 'deliberation_decision', [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + 'after' => 'source_school_year', + ]); + $this->addFieldIfMissing($fields, 'source_grade_id', [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'after' => 'deliberation_decision', + ]); + $this->addFieldIfMissing($fields, 'assigned_grade_id', [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'after' => 'source_grade_id', + ]); + $this->addFieldIfMissing($fields, 'source_class_section_id', [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'after' => 'assigned_grade_id', + ]); + $this->addFieldIfMissing($fields, 'assigned_class_section_id', [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'after' => 'source_class_section_id', + ]); + $this->addFieldIfMissing($fields, 'placement_status', [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => true, + 'after' => 'assigned_class_section_id', + ]); + $this->addFieldIfMissing($fields, 'age_reference_date', [ + 'type' => 'DATE', + 'null' => true, + 'after' => 'placement_status', + ]); + $this->addFieldIfMissing($fields, 'age_on_reference_date', [ + 'type' => 'INT', + 'constraint' => 3, + 'null' => true, + 'after' => 'age_reference_date', + ]); + $this->addFieldIfMissing($fields, 'adult_student', [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + 'after' => 'age_on_reference_date', + ]); + $this->addFieldIfMissing($fields, 'parent_enrollment_allowed', [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 1, + 'after' => 'adult_student', + ]); + $this->addFieldIfMissing($fields, 'student_self_enrollment_allowed', [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + 'after' => 'parent_enrollment_allowed', + ]); + $this->addFieldIfMissing($fields, 'exception_required', [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + 'after' => 'student_self_enrollment_allowed', + ]); + $this->addFieldIfMissing($fields, 'exception_reason', [ + 'type' => 'TEXT', + 'null' => true, + 'after' => 'exception_required', + ]); + $this->addFieldIfMissing($fields, 'registration_submitted_at', [ + 'type' => 'DATETIME', + 'null' => true, + 'after' => 'exception_reason', + ]); + $this->addFieldIfMissing($fields, 'registration_confirmed_at', [ + 'type' => 'DATETIME', + 'null' => true, + 'after' => 'registration_submitted_at', + ]); + + if ($fields !== []) { + $this->forge->addColumn('enrollments', $fields); + } + } + + public function down() + { + if ($this->db->tableExists('student_decisions') && $this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { + $this->forge->dropColumn('student_decisions', 'deliberation_decision_standard'); + } + + if (! $this->db->tableExists('enrollments')) { + return; + } + + foreach ([ + 'source_school_year', + 'deliberation_decision', + 'source_grade_id', + 'assigned_grade_id', + 'source_class_section_id', + 'assigned_class_section_id', + 'placement_status', + 'age_reference_date', + 'age_on_reference_date', + 'adult_student', + 'parent_enrollment_allowed', + 'student_self_enrollment_allowed', + 'exception_required', + 'exception_reason', + 'registration_submitted_at', + 'registration_confirmed_at', + ] as $field) { + if ($this->db->fieldExists($field, 'enrollments')) { + $this->forge->dropColumn('enrollments', $field); + } + } + } + + private function addFieldIfMissing(array &$fields, string $name, array $definition): void + { + if (! $this->db->fieldExists($name, 'enrollments')) { + $fields[$name] = $definition; + } + } +} diff --git a/app/Database/Migrations/2026-08-06-000200_CreateEnrollmentPhaseTwoTables.php b/app/Database/Migrations/2026-08-06-000200_CreateEnrollmentPhaseTwoTables.php new file mode 100644 index 0000000..fe54066 --- /dev/null +++ b/app/Database/Migrations/2026-08-06-000200_CreateEnrollmentPhaseTwoTables.php @@ -0,0 +1,114 @@ +db->tableExists('school_years')) { + $schoolYearFields = []; + $this->addColumnIfMissing($schoolYearFields, 'school_years', 'registration_opens_at', ['type' => 'DATETIME', 'null' => true, 'after' => 'registration_starts_on']); + $this->addColumnIfMissing($schoolYearFields, 'school_years', 'registration_deadline_at', ['type' => 'DATETIME', 'null' => true, 'after' => 'registration_ends_on']); + $this->addColumnIfMissing($schoolYearFields, 'school_years', 'late_registration_blocked', ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1, 'after' => 'registration_deadline_at']); + $this->addColumnIfMissing($schoolYearFields, 'school_years', 'administrative_exceptions_permitted', ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1, 'after' => 'late_registration_blocked']); + $this->addColumnIfMissing($schoolYearFields, 'school_years', 'registration_exception_roles', ['type' => 'TEXT', 'null' => true, 'after' => 'administrative_exceptions_permitted']); + $this->addColumnIfMissing($schoolYearFields, 'school_years', 'adult_student_registration_enabled', ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0, 'after' => 'registration_exception_roles']); + if ($schoolYearFields !== []) { + $this->forge->addColumn('school_years', $schoolYearFields); + } + } + + if (! $this->db->tableExists('enrollment_age_rules')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 20], + 'campus' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'grade_class_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'education_level' => ['type' => 'VARCHAR', 'constraint' => 80, 'null' => true], + 'minimum_age' => ['type' => 'INT', 'constraint' => 3, 'null' => true], + 'maximum_age' => ['type' => 'INT', 'constraint' => 3, 'null' => true], + 'age_reference_date' => ['type' => 'DATE', 'null' => true], + 'behavior' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'blocking'], + 'exceptions_allowed' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0], + 'exception_roles' => ['type' => 'TEXT', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['school_year', 'grade_class_id']); + $this->forge->createTable('enrollment_age_rules'); + } + + if (! $this->db->tableExists('enrollment_flags')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'flag_type' => ['type' => 'VARCHAR', 'constraint' => 80], + 'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 20], + 'source_school_year' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true], + 'status' => ['type' => 'VARCHAR', 'constraint' => 40, 'default' => 'open'], + 'priority' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'normal'], + 'assigned_to' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'details_json' => ['type' => 'TEXT', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'resolved_at' => ['type' => 'DATETIME', 'null' => true], + 'resolution_notes' => ['type' => 'TEXT', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['student_id', 'school_year', 'flag_type']); + $this->forge->createTable('enrollment_flags'); + } + + if (! $this->db->tableExists('enrollment_transition_audits')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 20], + 'source_school_year' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true], + 'action' => ['type' => 'VARCHAR', 'constraint' => 80], + 'performed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'original_values_json' => ['type' => 'TEXT', 'null' => true], + 'new_values_json' => ['type' => 'TEXT', 'null' => true], + 'reason' => ['type' => 'TEXT', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['student_id', 'school_year', 'created_at']); + $this->forge->createTable('enrollment_transition_audits'); + } + } + + public function down() + { + $this->forge->dropTable('enrollment_transition_audits', true); + $this->forge->dropTable('enrollment_flags', true); + $this->forge->dropTable('enrollment_age_rules', true); + + if (! $this->db->tableExists('school_years')) { + return; + } + + foreach ([ + 'registration_opens_at', + 'registration_deadline_at', + 'late_registration_blocked', + 'administrative_exceptions_permitted', + 'registration_exception_roles', + 'adult_student_registration_enabled', + ] as $field) { + if ($this->db->fieldExists($field, 'school_years')) { + $this->forge->dropColumn('school_years', $field); + } + } + } + + private function addColumnIfMissing(array &$fields, string $table, string $name, array $definition): void + { + if (! $this->db->fieldExists($name, $table)) { + $fields[$name] = $definition; + } + } +} diff --git a/app/Database/Migrations/2026-08-06-000300_AddRegistrationExperienceFinancialFields.php b/app/Database/Migrations/2026-08-06-000300_AddRegistrationExperienceFinancialFields.php new file mode 100644 index 0000000..f93de30 --- /dev/null +++ b/app/Database/Migrations/2026-08-06-000300_AddRegistrationExperienceFinancialFields.php @@ -0,0 +1,54 @@ +db->tableExists('school_years')) { + return; + } + + $fields = []; + $this->addColumnIfMissing($fields, 'registration_fee', ['type' => 'DECIMAL', 'constraint' => '10,2', 'default' => 0, 'after' => 'adult_student_registration_enabled']); + $this->addColumnIfMissing($fields, 'tuition_due_at_registration', ['type' => 'DECIMAL', 'constraint' => '10,2', 'default' => 0, 'after' => 'registration_fee']); + $this->addColumnIfMissing($fields, 'mandatory_fees', ['type' => 'DECIMAL', 'constraint' => '10,2', 'default' => 0, 'after' => 'tuition_due_at_registration']); + $this->addColumnIfMissing($fields, 'carry_over_balance_behavior', ['type' => 'VARCHAR', 'constraint' => 60, 'default' => 'information_only', 'after' => 'mandatory_fees']); + $this->addColumnIfMissing($fields, 'financial_policy_message', ['type' => 'TEXT', 'null' => true, 'after' => 'carry_over_balance_behavior']); + $this->addColumnIfMissing($fields, 'payment_plan_available', ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0, 'after' => 'financial_policy_message']); + + if ($fields !== []) { + $this->forge->addColumn('school_years', $fields); + } + } + + public function down() + { + if (! $this->db->tableExists('school_years')) { + return; + } + + foreach ([ + 'registration_fee', + 'tuition_due_at_registration', + 'mandatory_fees', + 'carry_over_balance_behavior', + 'financial_policy_message', + 'payment_plan_available', + ] as $field) { + if ($this->db->fieldExists($field, 'school_years')) { + $this->forge->dropColumn('school_years', $field); + } + } + } + + private function addColumnIfMissing(array &$fields, string $name, array $definition): void + { + if (! $this->db->fieldExists($name, 'school_years')) { + $fields[$name] = $definition; + } + } +} diff --git a/app/Database/Migrations/2026-08-06-000400_CreateEnrollmentEmailRecords.php b/app/Database/Migrations/2026-08-06-000400_CreateEnrollmentEmailRecords.php new file mode 100644 index 0000000..1a40eb8 --- /dev/null +++ b/app/Database/Migrations/2026-08-06-000400_CreateEnrollmentEmailRecords.php @@ -0,0 +1,70 @@ +db->tableExists('school_years')) { + $fields = []; + $this->addSchoolYearColumnIfMissing($fields, 'registration_launch_approved_at', ['type' => 'DATETIME', 'null' => true, 'after' => 'payment_plan_available']); + $this->addSchoolYearColumnIfMissing($fields, 'registration_launch_approved_by', ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'registration_launch_approved_at']); + $this->addSchoolYearColumnIfMissing($fields, 'registration_email_template_version', ['type' => 'VARCHAR', 'constraint' => 80, 'null' => true, 'after' => 'registration_launch_approved_by']); + if ($fields !== []) { + $this->forge->addColumn('school_years', $fields); + } + } + + if ($this->db->tableExists('enrollment_email_records')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 20], + 'school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'family_account_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'parent_user_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'recipient_addresses_json' => ['type' => 'TEXT'], + 'template_version' => ['type' => 'VARCHAR', 'constraint' => 80, 'null' => true], + 'generated_subject' => ['type' => 'VARCHAR', 'constraint' => 255], + 'generated_body' => ['type' => 'LONGTEXT'], + 'student_ids_included_json' => ['type' => 'TEXT', 'null' => true], + 'generated_at' => ['type' => 'DATETIME', 'null' => true], + 'sent_at' => ['type' => 'DATETIME', 'null' => true], + 'delivery_status' => ['type' => 'VARCHAR', 'constraint' => 40, 'default' => 'generated'], + 'failure_reason' => ['type' => 'TEXT', 'null' => true], + 'retry_count' => ['type' => 'INT', 'constraint' => 11, 'default' => 0], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['school_year', 'parent_user_id']); + $this->forge->createTable('enrollment_email_records'); + } + + public function down() + { + $this->forge->dropTable('enrollment_email_records', true); + + if (! $this->db->tableExists('school_years')) { + return; + } + + foreach (['registration_launch_approved_at', 'registration_launch_approved_by', 'registration_email_template_version'] as $field) { + if ($this->db->fieldExists($field, 'school_years')) { + $this->forge->dropColumn('school_years', $field); + } + } + } + + private function addSchoolYearColumnIfMissing(array &$fields, string $name, array $definition): void + { + if (! $this->db->fieldExists($name, 'school_years')) { + $fields[$name] = $definition; + } + } +} diff --git a/app/Database/Migrations/2026-08-06-000500_AddEnrollmentAdministrationNavItem.php b/app/Database/Migrations/2026-08-06-000500_AddEnrollmentAdministrationNavItem.php new file mode 100644 index 0000000..dcf2ff3 --- /dev/null +++ b/app/Database/Migrations/2026-08-06-000500_AddEnrollmentAdministrationNavItem.php @@ -0,0 +1,190 @@ +db->tableExists('nav_items')) { + return; + } + + $parentColumn = $this->parentColumn(); + $parent = $this->studentAffairsParent($parentColumn); + $navItemId = $this->navItemId($parentColumn, $parent); + + if ($navItemId <= 0) { + return; + } + + $this->grantRoles($navItemId, [ + 'administrator', + 'principal', + 'vice_principal', + 'head of department (education)', + ]); + + cache()->clean(); + } + + public function down(): void + { + if (! $this->db->tableExists('nav_items')) { + return; + } + + $row = $this->db->table('nav_items') + ->where('url', $this->url) + ->get() + ->getRowArray(); + + if ($row === null) { + return; + } + + if ($this->db->tableExists('role_nav_items')) { + $this->db->table('role_nav_items') + ->where('nav_item_id', (int) $row['id']) + ->delete(); + } + + $this->db->table('nav_items') + ->where('id', (int) $row['id']) + ->delete(); + + cache()->clean(); + } + + private function navItemId(?string $parentColumn, ?array $parent): int + { + $existing = $this->db->table('nav_items') + ->where('url', $this->url) + ->get() + ->getRowArray(); + + if ($existing !== null) { + $updates = [ + 'label' => $this->label, + 'is_enabled' => 1, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + if ($parentColumn !== null && ! empty($parent['id'])) { + $updates[$parentColumn] = (int) $parent['id']; + } + + $this->db->table('nav_items') + ->where('id', (int) $existing['id']) + ->update($updates); + + return (int) $existing['id']; + } + + $insert = [ + 'label' => $this->label, + 'url' => $this->url, + 'sort_order' => 6, + 'is_enabled' => 1, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + if ($this->db->fieldExists('icon_class', 'nav_items')) { + $insert['icon_class'] = 'bi bi-diagram-3'; + } + if ($parentColumn !== null && ! empty($parent['id'])) { + $insert[$parentColumn] = (int) $parent['id']; + } + + $this->db->table('nav_items')->insert($insert); + + return (int) $this->db->insertID(); + } + + private function studentAffairsParent(?string $parentColumn): ?array + { + $builder = $this->db->table('nav_items')->where('label', 'Student-Affairs'); + if ($parentColumn !== null) { + $builder->where($parentColumn, null); + } + + return $builder->get()->getRowArray(); + } + + private function grantRoles(int $navItemId, array $roleNames): void + { + if (! $this->db->tableExists('role_nav_items')) { + return; + } + + if ($this->db->fieldExists('role_id', 'role_nav_items') && $this->db->tableExists('roles')) { + foreach ($roleNames as $roleName) { + $roleBuilder = $this->db->table('roles')->select('id'); + $roleBuilder->groupStart() + ->where('LOWER(name)', strtolower($roleName)); + if ($this->db->fieldExists('slug', 'roles')) { + $roleBuilder->orWhere('LOWER(slug)', strtolower($roleName)); + } + $role = $roleBuilder->groupEnd() + ->limit(1) + ->get() + ->getRowArray(); + if ($role === null) { + continue; + } + + $exists = $this->db->table('role_nav_items') + ->where('role_id', (int) $role['id']) + ->where('nav_item_id', $navItemId) + ->countAllResults() > 0; + if (! $exists) { + $this->db->table('role_nav_items')->insert([ + 'role_id' => (int) $role['id'], + 'nav_item_id' => $navItemId, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + } + + return; + } + + if (! $this->db->fieldExists('role', 'role_nav_items')) { + return; + } + + foreach ($roleNames as $roleName) { + $roleName = strtolower($roleName); + $exists = $this->db->table('role_nav_items') + ->where('role', $roleName) + ->where('nav_item_id', $navItemId) + ->countAllResults() > 0; + if (! $exists) { + $this->db->table('role_nav_items')->insert([ + 'role' => $roleName, + 'nav_item_id' => $navItemId, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + } + } + + private function parentColumn(): ?string + { + if ($this->db->fieldExists('menu_parent_id', 'nav_items')) { + return 'menu_parent_id'; + } + + if ($this->db->fieldExists('parent_id', 'nav_items')) { + return 'parent_id'; + } + + return null; + } +} diff --git a/app/Database/Migrations/2026-08-06-000501_SyncEnrollmentAdministrationNavRoles.php b/app/Database/Migrations/2026-08-06-000501_SyncEnrollmentAdministrationNavRoles.php new file mode 100644 index 0000000..d1d2f43 --- /dev/null +++ b/app/Database/Migrations/2026-08-06-000501_SyncEnrollmentAdministrationNavRoles.php @@ -0,0 +1,77 @@ +db->tableExists('nav_items') || ! $this->db->tableExists('role_nav_items')) { + return; + } + + $nav = $this->db->table('nav_items') + ->select('id') + ->where('url', 'administrator/enrollment-admin') + ->limit(1) + ->get() + ->getRowArray(); + + if ($nav === null) { + return; + } + + $this->grantRoles((int) $nav['id'], [ + 'administrator', + 'principal', + 'vice_principal', + 'head of department (education)', + ]); + + cache()->clean(); + } + + public function down(): void + { + } + + private function grantRoles(int $navItemId, array $roleNames): void + { + if (! $this->db->fieldExists('role_id', 'role_nav_items') || ! $this->db->tableExists('roles')) { + return; + } + + foreach ($roleNames as $roleName) { + $roleBuilder = $this->db->table('roles')->select('id'); + $roleBuilder->groupStart() + ->where('LOWER(name)', strtolower($roleName)); + if ($this->db->fieldExists('slug', 'roles')) { + $roleBuilder->orWhere('LOWER(slug)', strtolower($roleName)); + } + $role = $roleBuilder->groupEnd() + ->limit(1) + ->get() + ->getRowArray(); + + if ($role === null) { + continue; + } + + $exists = $this->db->table('role_nav_items') + ->where('role_id', (int) $role['id']) + ->where('nav_item_id', $navItemId) + ->countAllResults() > 0; + + if (! $exists) { + $this->db->table('role_nav_items')->insert([ + 'role_id' => (int) $role['id'], + 'nav_item_id' => $navItemId, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + } + } +} diff --git a/app/Database/Migrations/2026-08-07-000100_AddReviewDecisionEnrollmentStatus.php b/app/Database/Migrations/2026-08-07-000100_AddReviewDecisionEnrollmentStatus.php new file mode 100644 index 0000000..4cb08fe --- /dev/null +++ b/app/Database/Migrations/2026-08-07-000100_AddReviewDecisionEnrollmentStatus.php @@ -0,0 +1,63 @@ +modifyEnrollmentStatusEnum(self::STATUSES_WITH_REVIEW_DECISION); + } + + public function down() + { + if ($this->db->tableExists('enrollments')) { + $this->db->table('enrollments') + ->where('enrollment_status', 'review & decision') + ->update(['enrollment_status' => 'admission under review']); + } + + $this->modifyEnrollmentStatusEnum(self::STATUSES_WITHOUT_REVIEW_DECISION); + } + + private function modifyEnrollmentStatusEnum(array $statuses): void + { + if (! $this->db->tableExists('enrollments') || ! $this->db->fieldExists('enrollment_status', 'enrollments')) { + return; + } + + if (! in_array($this->db->DBDriver, ['MySQLi', 'MySQL'], true)) { + return; + } + + $enumValues = implode(',', array_map(static fn (string $status): string => "'" . str_replace("'", "''", $status) . "'", $statuses)); + $this->db->query( + "ALTER TABLE `enrollments` MODIFY `enrollment_status` ENUM({$enumValues}) NOT NULL DEFAULT 'admission under review'" + ); + } +} diff --git a/app/Database/Seeds/NavSeeder.php b/app/Database/Seeds/NavSeeder.php index f5f438d..d9fd30b 100644 --- a/app/Database/Seeds/NavSeeder.php +++ b/app/Database/Seeds/NavSeeder.php @@ -67,13 +67,14 @@ class NavSeeder extends Seeder ['parent'=>'Student-Affairs','label'=>'Attendance Scans','url'=>'rfid_coming_soon','sort_order'=>3], ['parent'=>'Student-Affairs','label'=>'Classes List','url'=>'administrator/class_assignment','sort_order'=>4], ['parent'=>'Student-Affairs','label'=>'Emergency Contact','url'=>'administrator/emergency_contact','sort_order'=>5], - ['parent'=>'Student-Affairs','label'=>'Enrollment-Withdrawal','url'=>'enroll_withdraw/enrollment_withdrawal','sort_order'=>6], - ['parent'=>'Student-Affairs','label'=>'Flags Management','url'=>'flags/flags_management','sort_order'=>7], - ['parent'=>'Student-Affairs','label'=>'School Calendar','url'=>'administrator/calendar_view','sort_order'=>8], - ['parent'=>'Student-Affairs','label'=>'Score Analysis','url'=>'report/combined','sort_order'=>9], - ['parent'=>'Student-Affairs','label'=>'Score Management','url'=>'grading','sort_order'=>10], - ['parent'=>'Student-Affairs','label'=>'Student Class Assignment','url'=>'administrator/student_class_assignment','sort_order'=>11], - ['parent'=>'Student-Affairs','label'=>'Student Profile','url'=>'administrator/student_profiles','sort_order'=>12], + ['parent'=>'Student-Affairs','label'=>'Enrollment Administration','url'=>'administrator/enrollment-admin','sort_order'=>6], + ['parent'=>'Student-Affairs','label'=>'Enrollment-Withdrawal','url'=>'enroll_withdraw/enrollment_withdrawal','sort_order'=>7], + ['parent'=>'Student-Affairs','label'=>'Flags Management','url'=>'flags/flags_management','sort_order'=>8], + ['parent'=>'Student-Affairs','label'=>'School Calendar','url'=>'administrator/calendar_view','sort_order'=>9], + ['parent'=>'Student-Affairs','label'=>'Score Analysis','url'=>'report/combined','sort_order'=>10], + ['parent'=>'Student-Affairs','label'=>'Score Management','url'=>'grading','sort_order'=>11], + ['parent'=>'Student-Affairs','label'=>'Student Class Assignment','url'=>'administrator/student_class_assignment','sort_order'=>12], + ['parent'=>'Student-Affairs','label'=>'Student Profile','url'=>'administrator/student_profiles','sort_order'=>13], // Classes ['parent'=>'Classes','label'=>'Classes List','url'=>'administrator/class_assignment','sort_order'=>1], @@ -155,7 +156,7 @@ class NavSeeder extends Seeder } // Example: HOD Education (Student-Affairs group) - $hodEduLabels = ['Student-Affairs','Attendance Management','Classes List','Emergency Contact','Enrollment-Withdrawal','Flags Management','School Calendar','Score Analysis','Score Management','Student Class Assignment','Student Profile']; + $hodEduLabels = ['Student-Affairs','Attendance Management','Classes List','Emergency Contact','Enrollment Administration','Enrollment-Withdrawal','Flags Management','School Calendar','Score Analysis','Score Management','Student Class Assignment','Student Profile']; foreach ($navRows as $row) { if (in_array($row['label'], $hodEduLabels, true)) { $roleMap->insert([ diff --git a/app/Models/EnrollmentAgeRuleModel.php b/app/Models/EnrollmentAgeRuleModel.php new file mode 100644 index 0000000..3c1c52f --- /dev/null +++ b/app/Models/EnrollmentAgeRuleModel.php @@ -0,0 +1,28 @@ + 'permit_empty|integer', 'parent_id' => 'required|integer', 'school_year' => 'required|string|max_length[9]', + 'source_school_year' => 'permit_empty|string|max_length[20]', + 'deliberation_decision' => 'permit_empty|in_list[PASSED,REPEAT_CLASS,MAKE_UP_EXAM,EXPELLED,WITHDRAWN,DEFERRED_DECISION]', + 'source_grade_id' => 'permit_empty|integer', + 'assigned_grade_id' => 'permit_empty|integer', + 'source_class_section_id' => 'permit_empty|integer', + 'assigned_class_section_id' => 'permit_empty|integer', + 'placement_status' => 'permit_empty|string|max_length[40]', + 'age_reference_date' => 'permit_empty|valid_date', + 'age_on_reference_date' => 'permit_empty|integer', + 'adult_student' => 'permit_empty|in_list[0,1]', + 'parent_enrollment_allowed' => 'permit_empty|in_list[0,1]', + 'student_self_enrollment_allowed' => 'permit_empty|in_list[0,1]', + 'exception_required' => 'permit_empty|in_list[0,1]', 'enrollment_date' => 'required|valid_date', 'withdrawal_date' => 'permit_empty|valid_date', 'is_withdrawn' => 'permit_empty|in_list[0,1]', - 'enrollment_status' => 'required|in_list[admission under review,payment pending,enrolled,withdraw under review,refund pending,withdrawn]', + 'enrollment_status' => 'required|in_list[admission under review,review & decision,payment pending,enrolled,withdraw under review,refund pending,withdrawn,waitlist,denied]', 'admission_status' => 'required|in_list[pending,accepted,denied]', 'semester' => 'permit_empty|string|max_length[25]', ]; @@ -73,7 +102,7 @@ class EnrollmentModel extends Model ], 'enrollment_status' => [ 'required' => 'Enrollment status is required', - 'in_list' => 'Enrollment status must be one of: admission under review, payment pending, enrolled, withdraw under review, refund pending, withdrawn', + 'in_list' => 'Enrollment status must be one of: admission under review, review & decision, payment pending, enrolled, withdraw under review, refund pending, withdrawn, waitlist, denied', ], 'admission_status' => [ 'required' => 'Admission status is required', diff --git a/app/Models/EnrollmentTransitionAuditModel.php b/app/Models/EnrollmentTransitionAuditModel.php new file mode 100644 index 0000000..fd6a968 --- /dev/null +++ b/app/Models/EnrollmentTransitionAuditModel.php @@ -0,0 +1,25 @@ + 'permit_empty|valid_date[Y-m-d]', 'registration_starts_on' => 'permit_empty|valid_date[Y-m-d]', 'registration_ends_on' => 'permit_empty|valid_date[Y-m-d]', + 'registration_opens_at' => 'permit_empty|valid_date[Y-m-d H:i:s]', + 'registration_deadline_at' => 'permit_empty|valid_date[Y-m-d H:i:s]', + '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]', + 'registration_launch_approved_by' => 'permit_empty|integer', 'fall_makeup_exam_on' => 'permit_empty|valid_date[Y-m-d]', ]; diff --git a/app/Models/StudentDecisionModel.php b/app/Models/StudentDecisionModel.php index 461b3fa..64b2e6f 100644 --- a/app/Models/StudentDecisionModel.php +++ b/app/Models/StudentDecisionModel.php @@ -4,6 +4,7 @@ namespace App\Models; use CodeIgniter\Model; use App\Models\Concerns\SchoolYearAutoFillTrait; +use App\Support\Enrollment\DeliberationDecision; class StudentDecisionModel extends Model { @@ -18,6 +19,7 @@ class StudentDecisionModel extends Model 'class_section_name', 'year_score', 'decision', + 'deliberation_decision_standard', 'source', 'notes', 'generated_by', @@ -30,4 +32,18 @@ class StudentDecisionModel extends Model protected $useTimestamps = true; protected $createdField = 'created_at'; protected $updatedField = 'updated_at'; + protected $beforeInsert = ['standardizeDeliberationDecision']; + protected $beforeUpdate = ['standardizeDeliberationDecision']; + + protected function standardizeDeliberationDecision(array $data): array + { + if ( + array_key_exists('decision', $data['data'] ?? []) + && $this->db->fieldExists('deliberation_decision_standard', $this->table) + ) { + $data['data']['deliberation_decision_standard'] = DeliberationDecision::normalize($data['data']['decision'] ?? null); + } + + return $data; + } } diff --git a/app/Services/EnrollmentRegistrationEmailService.php b/app/Services/EnrollmentRegistrationEmailService.php new file mode 100644 index 0000000..0d73fde --- /dev/null +++ b/app/Services/EnrollmentRegistrationEmailService.php @@ -0,0 +1,486 @@ + 0, + 'recipients' => 0, + 'sent' => 0, + 'failed' => 0, + 'skipped' => 0, + 'dry_run' => $dryRun, + 'messages' => [], + ]; + + foreach ($this->registrationYearsForDate($date, $force) as $schoolYear) { + $summary['school_years']++; + if (! $dryRun && empty($schoolYear['registration_launch_approved_at'])) { + $summary['messages'][] = 'Registration launch is not approved for ' . (string) ($schoolYear['name'] ?? '') . '.'; + $summary['skipped']++; + continue; + } + + $result = $this->sendForSchoolYear($schoolYear, $testEmail, $dryRun, $force); + foreach (['recipients', 'sent', 'failed', 'skipped'] as $key) { + $summary[$key] += $result[$key] ?? 0; + } + array_push($summary['messages'], ...($result['messages'] ?? [])); + } + + return $summary; + } + + public function sendForSchoolYear(array $schoolYear, ?string $testEmail = null, bool $dryRun = false, bool $force = false): array + { + $summary = ['recipients' => 0, 'sent' => 0, 'failed' => 0, 'skipped' => 0, 'messages' => []]; + if (! $dryRun && empty($schoolYear['registration_launch_approved_at'])) { + $summary['messages'][] = 'Registration launch is not approved for ' . (string) ($schoolYear['name'] ?? '') . '.'; + $summary['skipped']++; + return $summary; + } + + $families = $this->recipientFamilies($schoolYear, $testEmail); + if ($families === []) { + $summary['messages'][] = 'No recipient families found for ' . (string) ($schoolYear['name'] ?? '') . '.'; + return $summary; + } + + foreach ($families as $family) { + if ($testEmail === null && ! $force && $this->alreadySent((string) $schoolYear['name'], (int) $family['parent_user_id'])) { + $summary['skipped']++; + continue; + } + + $message = $this->buildMessage($schoolYear, $family); + if ($testEmail !== null) { + $message['subject'] = '[TEST] ' . $message['subject']; + $message['recipients'] = [$testEmail]; + } + + $summary['recipients']++; + $recordId = $dryRun || ! $this->db->tableExists('enrollment_email_records') ? null : $this->recordGenerated($schoolYear, $family, $message); + $sent = $dryRun || $this->emailService->send($message['recipients'][0], $message['subject'], $message['body'], 'general'); + $sent ? $summary['sent']++ : $summary['failed']++; + + if (! $dryRun && $recordId !== null) { + $this->recordDelivery($recordId, $sent, $sent ? null : 'Email send failed'); + } + } + + return $summary; + } + + public function previewForParent(string $schoolYearName, int $parentId): ?array + { + $schoolYear = $this->schoolYearByName($schoolYearName); + if ($schoolYear === null) { + return null; + } + + foreach ($this->recipientFamilies($schoolYear, null) as $family) { + if ((int) $family['parent_user_id'] === $parentId) { + return $this->buildMessage($schoolYear, $family); + } + } + + return null; + } + + public function previewExamplesForSchoolYear(string $schoolYearName): array + { + $schoolYear = $this->schoolYearByName($schoolYearName); + if ($schoolYear === null) { + return []; + } + + $examples = []; + foreach ($this->recipientFamilies($schoolYear, null) as $family) { + $message = $this->buildMessage($schoolYear, $family); + $latest = $this->latestEmailRecord($schoolYearName, (int) $family['parent_user_id']); + + $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'] + ), + 'subject' => (string) $message['subject'], + 'delivery_status' => (string) ($latest['delivery_status'] ?? 'not sent'), + 'sent_at' => $latest['sent_at'] ?? null, + 'failure_reason' => $latest['failure_reason'] ?? null, + ]; + } + + return $examples; + } + + public function sendForSchoolYearName(string $schoolYearName, bool $force = false): array + { + $schoolYear = $this->schoolYearByName($schoolYearName); + if ($schoolYear === null) { + return ['recipients' => 0, 'sent' => 0, 'failed' => 0, 'skipped' => 1, 'messages' => ['School year was not found.']]; + } + + return $this->sendForSchoolYear($schoolYear, null, false, $force); + } + + private function buildMessage(array $schoolYear, array $family): array + { + $schoolYearName = (string) ($schoolYear['name'] ?? ''); + $previousYear = $this->previousSchoolYearName($schoolYearName); + $deadline = $this->dateText($schoolYear['registration_deadline_at'] ?? $schoolYear['registration_ends_on'] ?? null); + $opens = $this->dateText($schoolYear['registration_opens_at'] ?? $schoolYear['registration_starts_on'] ?? null); + $students = []; + $studentIds = []; + + foreach ($family['students'] as $student) { + $studentId = (int) ($student['id'] ?? 0); + if ($studentId <= 0 || $previousYear === null) { + continue; + } + + $evaluation = $this->transitionService->evaluate($studentId, $previousYear, $schoolYearName, 'parent'); + $students[] = $this->studentSection($student, $evaluation, $opens, $deadline); + $studentIds[] = $studentId; + } + + $financial = $this->financialSection((int) $family['parent_user_id'], $schoolYear, $previousYear); + $subject = 'Registration for ' . $schoolYearName . ' Is Now Open'; + $bodyHtml = '

Dear ' . esc($family['name']) . ',

' + . '

We are pleased to welcome your family to the registration process for the ' . esc($schoolYearName) . ' school year.

' + . '

Registration opens on ' . esc($opens) . ' and closes on ' . esc($deadline) . '.

' + . '

Please review the information below for each of your children, including the deliberation decision, registration eligibility, expected academic placement, and any required action.

' + . implode('', $students) + . $financial + . '

To complete registration for each eligible student:

' + . '
  1. Sign in to the parent portal.
  2. Review and update student information.
  3. Upload required documents.
  4. Review and acknowledge school policies.
  5. Review tuition, fees, and any carry-over balance.
  6. Submit registration before ' . esc($deadline) . '.
' + . '

Registration portal: ' . esc(site_url('parent/enroll_classes')) . '

' + . '

For assistance, please contact the school administration.

' + . '

Sincerely,
Al Rahma Sunday School
School Administration

'; + + $body = view('emails/_wrap_layout', [ + 'title' => $subject, + 'body_html' => $bodyHtml, + ], ['saveData' => true]); + + return [ + 'subject' => $subject, + 'body' => $body, + 'recipients' => $family['recipients'], + 'student_ids' => array_values(array_unique($studentIds)), + ]; + } + + private function studentSection(array $student, array $evaluation, string $opens, string $deadline): string + { + $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student'; + $decision = DeliberationDecision::display((string) ($evaluation['deliberation_decision'] ?? '')); + $status = $this->registrationStatus($evaluation); + $placement = $this->placementText($evaluation); + $requiredAction = $this->requiredAction($evaluation, $deadline); + $message = $this->decisionMessage($name, $evaluation, $opens, $deadline); + + return '

' . esc($name) . '

' + . '

Deliberation decision: ' . esc($decision ?: 'Pending') . '
' + . 'Registration status: ' . esc($status) . '
' + . 'Expected placement: ' . esc($placement) . '

' + . '

' . esc($message) . '

' + . '

Required action: ' . esc($requiredAction) . '

'; + } + + private function decisionMessage(string $name, array $evaluation, string $opens, string $deadline): string + { + if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) { + $grade = $this->currentGradeText($evaluation); + + return 'We are pleased to inform you that ' . $name . ' has successfully passed ' . $grade . '. ' + . 'Re-enrollment for the new school year will open on ' . $opens . '. Please make sure to re-enroll your child before ' . $deadline . ' to secure their enrollment for the upcoming school year. ' + . 'To complete the process, sign in to the parent portal, review the student’s information, submit the required documents, acknowledge the school policies, and complete any applicable payment steps.'; + } + + if (($evaluation['blockers'] ?? []) !== []) { + return implode(' ', array_map('strval', $evaluation['blockers'])); + } + + return match ((string) ($evaluation['deliberation_decision'] ?? '')) { + DeliberationDecision::REPEAT_CLASS => 'The deliberation decision for ' . $name . ' is to repeat the current grade. After registration is completed, the student will remain in the same grade and class when available.', + DeliberationDecision::MAKE_UP_EXAM => 'The deliberation decision for ' . $name . ' is pending the result of a make-up exam. Registration may be completed now. The student will initially remain in the same grade.', + default => 'Please review the registration portal for the current enrollment status.', + }; + } + + 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; + if ($total <= 0.0) { + return ''; + } + + $message = trim((string) ($schoolYear['financial_policy_message'] ?? '')); + if ($message === '') { + $message = 'The balance is shown for information and does not currently block registration.'; + } + + return '

Family Account Information

' + . '

Carry-over balance: $' . number_format($carry, 2) . '
' + . 'Registration fee: $' . number_format($registrationFee, 2) . '
' + . 'New-year tuition due now: $' . number_format($tuition, 2) . '
' + . 'Mandatory fees: $' . number_format($mandatory, 2) . '
' + . 'Total currently due: $' . number_format($total, 2) . '

' + . '

' . esc($message) . '

'; + } + + private function recipientFamilies(array $schoolYear, ?string $testEmail): array + { + $builder = $this->db->table('users u') + ->select('u.id AS parent_user_id, u.firstname, u.lastname, u.email') + ->where('u.email IS NOT NULL') + ->where('u.email !=', ''); + + if ($this->db->fieldExists('user_type', 'users')) { + $builder->where('u.user_type', 'primary'); + } + + if ($testEmail !== null) { + $builder->limit(1); + } + + $families = []; + foreach ($builder->get()->getResultArray() as $row) { + $parentId = (int) ($row['parent_user_id'] ?? 0); + $students = $this->studentsForParent($parentId); + if ($students === []) { + continue; + } + + $email = $testEmail ?? strtolower(trim((string) ($row['email'] ?? ''))); + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + continue; + } + + $name = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Parent or Guardian'; + $families[$parentId] = [ + 'parent_user_id' => $parentId, + 'family_account_id' => $parentId, + 'name' => $name, + 'recipients' => [$email], + 'students' => $students, + ]; + } + + return array_values($families); + } + + private function studentsForParent(int $parentId): array + { + return $this->db->table('students') + ->select('id, firstname, lastname, dob') + ->where('parent_id', $parentId) + ->orderBy('lastname', 'ASC') + ->orderBy('firstname', 'ASC') + ->get() + ->getResultArray(); + } + + private function recordGenerated(array $schoolYear, array $family, array $message): int + { + $now = date('Y-m-d H:i:s'); + $this->db->table('enrollment_email_records')->insert([ + 'school_year' => (string) ($schoolYear['name'] ?? ''), + 'school_year_id' => (int) ($schoolYear['id'] ?? 0) ?: null, + 'family_account_id' => (int) ($family['family_account_id'] ?? 0) ?: null, + 'parent_user_id' => (int) ($family['parent_user_id'] ?? 0) ?: null, + 'recipient_addresses_json' => json_encode($message['recipients']), + 'template_version' => self::TEMPLATE_VERSION, + 'generated_subject' => $message['subject'], + 'generated_body' => $message['body'], + 'student_ids_included_json' => json_encode($message['student_ids']), + 'generated_at' => $now, + 'delivery_status' => 'generated', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return (int) $this->db->insertID(); + } + + private function recordDelivery(int $recordId, bool $sent, ?string $failure): void + { + $this->db->table('enrollment_email_records') + ->where('id', $recordId) + ->update([ + 'delivery_status' => $sent ? 'sent' : 'failed', + 'sent_at' => $sent ? date('Y-m-d H:i:s') : null, + 'failure_reason' => $failure, + 'retry_count' => $sent ? 0 : 1, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + + private function alreadySent(string $schoolYear, int $parentId): bool + { + if (! $this->db->tableExists('enrollment_email_records')) { + return false; + } + + return $this->db->table('enrollment_email_records') + ->where('school_year', $schoolYear) + ->where('parent_user_id', $parentId) + ->where('delivery_status', 'sent') + ->countAllResults() > 0; + } + + private function latestEmailRecord(string $schoolYear, int $parentId): ?array + { + if (! $this->db->tableExists('enrollment_email_records')) { + return null; + } + + return $this->db->table('enrollment_email_records') + ->select('delivery_status, sent_at, failure_reason') + ->where('school_year', $schoolYear) + ->where('parent_user_id', $parentId) + ->orderBy('created_at', 'DESC') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray() ?: null; + } + + private function registrationYearsForDate(DateTimeInterface $date, bool $force): array + { + $builder = $this->db->table('school_years')->where('registration_starts_on IS NOT NULL'); + if (! $force) { + $builder->where('registration_starts_on', $date->format('Y-m-d')); + } + + return $builder->orderBy('registration_starts_on', 'ASC')->get()->getResultArray(); + } + + private function schoolYearByName(string $schoolYear): ?array + { + return $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray() ?: null; + } + + private function invoiceBalance(int $parentId, string $schoolYear): float + { + if (! $this->db->tableExists('invoices')) { + return 0.0; + } + + $row = $this->db->table('invoices') + ->select('COALESCE(SUM(balance), 0) AS balance', false) + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->get() + ->getRowArray(); + + return round((float) ($row['balance'] ?? 0), 2); + } + + private function registrationStatus(array $evaluation): string + { + if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) { + return 'Eligible'; + } + + if (($evaluation['blockers'] ?? []) !== []) { + return ($evaluation['adult_student'] ?? false) ? 'Student Action Required' : 'Not Eligible'; + } + return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM ? 'Eligible with pending placement' : 'Eligible'; + } + + private function placementText(array $evaluation): string + { + return 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 - administrative class assignment required', + 'exit_required' => 'Completion or exit process required', + default => 'Pending', + }; + } + + private function requiredAction(array $evaluation, string $deadline): string + { + if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::PASSED) { + return '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.'; + } + return ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM + ? 'Complete re-enrollment and follow the school instructions regarding the make-up exam.' + : 'Complete re-enrollment before ' . $deadline . '.'; + } + + private function previousSchoolYearName(string $schoolYear): ?string + { + return preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $m) ? ((int) $m[1] - 1) . '-' . ((int) $m[2] - 1) : null; + } + + private function dateText(mixed $value): string + { + $value = trim((string) $value); + if ($value === '') { + return 'the posted date'; + } + try { + return (new \DateTimeImmutable($value))->format('F j, Y'); + } catch (\Throwable) { + return $value; + } + } + + private function currentGradeText(array $evaluation): string + { + return $this->gradeText( + $evaluation['source_grade_name'] ?? $evaluation['source_class_section_name'] ?? null, + 'the current grade' + ); + } + + private function assignedGradeText(array $evaluation): string + { + return $this->gradeText( + $evaluation['assigned_grade_name'] ?? null, + 'Grade to be assigned' + ); + } + + private function gradeText(mixed $value, string $fallback): string + { + $grade = trim((string) $value); + + if ($grade === '') { + return $fallback; + } + + return preg_match('/^grade\b/i', $grade) === 1 ? $grade : 'Grade ' . $grade; + } +} diff --git a/app/Services/EnrollmentTransitionService.php b/app/Services/EnrollmentTransitionService.php new file mode 100644 index 0000000..05d615b --- /dev/null +++ b/app/Services/EnrollmentTransitionService.php @@ -0,0 +1,666 @@ +schoolYearByName($targetSchoolYear); + $sourceAssignment = $this->sourceAssignment($studentId, $sourceSchoolYear); + $student = $this->student($studentId); + $decisionRow = $this->decisionRow($studentId, $sourceSchoolYear); + $decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null) + ?? DeliberationDecision::normalize($decisionRow['decision'] ?? null); + + $result = [ + 'student_id' => $studentId, + 'source_school_year' => $sourceSchoolYear, + 'target_school_year' => $targetSchoolYear, + 'deliberation_decision' => $decision, + 'decision_label' => DeliberationDecision::display($decisionRow['decision'] ?? ''), + 'source_grade_id' => $sourceAssignment['class_id'] ?? null, + 'source_grade_name' => $sourceAssignment['class_name'] ?? null, + 'source_class_section_id' => $sourceAssignment['class_section_id'] ?? null, + 'source_class_section_name' => $sourceAssignment['class_section_name'] ?? null, + 'assigned_grade_id' => null, + 'assigned_class_section_id' => null, + 'placement_status' => 'not_created', + 'academic_eligible' => false, + 'parent_enrollment_allowed' => false, + 'student_self_enrollment_allowed' => false, + 'administrative_enrollment_allowed' => true, + 'age_reference_date' => $this->ageReferenceDate($targetSchoolYear), + 'age_on_reference_date' => null, + 'adult_student' => false, + 'registration_window_status' => 'unknown', + 'blockers' => [], + 'warnings' => [], + 'flags' => [], + ]; + + if ($student === null) { + $result['blockers'][] = 'Student record was not found.'; + return $result; + } + + if ($sourceAssignment === null) { + $result['blockers'][] = 'Student does not belong to the closing school year.'; + return $result; + } + + if ($decisionRow === null || $decision === null) { + $result['blockers'][] = EnrollmentEligibility::MISSING_DECISION_MESSAGE; + $result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high', [ + 'reason' => 'Missing or unrecognized final deliberation decision.', + ]); + return $result; + } + + if ($decision === DeliberationDecision::EXPELLED) { + $result['blockers'][] = EnrollmentEligibility::EXPELLED_MESSAGE; + $result['flags'][] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high'); + return $result; + } + + if ($decision === DeliberationDecision::WITHDRAWN) { + $result['blockers'][] = EnrollmentEligibility::WITHDRAWN_MESSAGE; + $result['flags'][] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal'); + return $result; + } + + if ($decision === DeliberationDecision::DEFERRED_DECISION) { + $result['blockers'][] = EnrollmentEligibility::DEFERRED_MESSAGE; + $result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high'); + return $result; + } + + $placement = $this->placement($decision, $sourceAssignment, $targetSchoolYear); + $result = array_replace($result, $placement); + $result['academic_eligible'] = $placement['placement_status'] !== 'exit_required'; + + if ($placement['placement_status'] === 'exit_required') { + $result['blockers'][] = 'The student has passed the highest available grade and must follow the school completion or exit process.'; + $result['flags'][] = $this->flag('COMPLETION_OR_EXIT_PROCESS_REQUIRED', 'normal', [ + 'source_class' => $sourceAssignment['class_section_name'] ?? null, + ]); + } + + $age = EnrollmentEligibility::ageOnSeptemberFirst($student['dob'] ?? null, $targetSchoolYear); + $result['age_on_reference_date'] = $age; + $result['adult_student'] = $age !== null && $age >= 18; + $result['parent_enrollment_allowed'] = $result['academic_eligible']; + $result['student_self_enrollment_allowed'] = $result['academic_eligible'] && (bool) ($targetYear['adult_student_registration_enabled'] ?? false); + + if ($result['adult_student']) { + $result['parent_enrollment_allowed'] = false; + $result['flags'][] = $this->flag('ADULT_STUDENT_ACTION_REQUIRED', 'normal', [ + 'age_on_reference_date' => $age, + ]); + if ($actorRole === 'parent') { + $result['blockers'][] = str_replace('The student', $this->studentName($student), EnrollmentEligibility::ADULT_STUDENT_MESSAGE); + } + } + + $this->applyRegistrationWindow($result, $targetYear, $now, $actorRole); + $this->applyAgeRules($result, $targetSchoolYear, $age); + + if ($result['blockers'] === [] && $result['academic_eligible']) { + if ($actorRole === 'parent') { + $result['parent_enrollment_allowed'] = $result['parent_enrollment_allowed'] && ! $result['adult_student']; + } + } elseif ($actorRole === 'parent') { + $result['parent_enrollment_allowed'] = false; + } elseif ($actorRole === 'student') { + $result['student_self_enrollment_allowed'] = false; + } + + return $result; + } + + public function applyInitialTransition( + int $studentId, + string $sourceSchoolYear, + string $targetSchoolYear, + ?int $parentId, + ?int $performedBy = null, + string $actorRole = 'admin' + ): array { + $evaluation = $this->evaluate($studentId, $sourceSchoolYear, $targetSchoolYear, $actorRole); + if (! $evaluation['academic_eligible'] || $evaluation['blockers'] !== []) { + $this->writeFlags($evaluation, $performedBy); + $this->audit($evaluation, 'transition_evaluation_blocked', $performedBy, null, $evaluation); + return $evaluation; + } + + $this->db->transStart(); + $original = $this->latestEnrollment($studentId, $targetSchoolYear); + $student = $this->student($studentId); + $parentId = $parentId ?? (is_numeric($student['parent_id'] ?? null) ? (int) $student['parent_id'] : null); + + $payload = [ + 'student_id' => $studentId, + 'parent_id' => $parentId, + 'school_year' => $targetSchoolYear, + 'source_school_year' => $sourceSchoolYear, + 'deliberation_decision' => $evaluation['deliberation_decision'], + 'source_grade_id' => $evaluation['source_grade_id'], + 'assigned_grade_id' => $evaluation['assigned_grade_id'], + 'source_class_section_id' => $evaluation['source_class_section_id'], + 'assigned_class_section_id' => $evaluation['assigned_class_section_id'], + 'class_section_id' => $evaluation['assigned_class_section_id'], + 'placement_status' => $evaluation['placement_status'], + 'age_reference_date' => $evaluation['age_reference_date'], + 'age_on_reference_date' => $evaluation['age_on_reference_date'], + 'adult_student' => $evaluation['adult_student'] ? 1 : 0, + 'parent_enrollment_allowed' => $evaluation['parent_enrollment_allowed'] ? 1 : 0, + 'student_self_enrollment_allowed' => $evaluation['student_self_enrollment_allowed'] ? 1 : 0, + 'exception_required' => $evaluation['flags'] !== [] ? 1 : 0, + 'exception_reason' => $evaluation['flags'] !== [] ? implode(', ', array_column($evaluation['flags'], 'flag_type')) : null, + 'enrollment_date' => date('Y-m-d'), + 'enrollment_status' => 'admission under review', + 'admission_status' => 'pending', + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + if ($original !== null) { + $this->db->table('enrollments')->where('id', (int) $original['id'])->update($payload); + } else { + $payload['created_at'] = date('Y-m-d H:i:s'); + $this->db->table('enrollments')->insert($payload); + } + + if ((int) ($evaluation['assigned_class_section_id'] ?? 0) > 0) { + $this->upsertStudentClass($studentId, (int) $evaluation['assigned_class_section_id'], $targetSchoolYear, $performedBy); + } + + $this->writeFlags($evaluation, $performedBy); + $this->audit($evaluation, 'initial_transition_applied', $performedBy, $original, $payload); + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + throw new RuntimeException('Unable to apply enrollment transition.'); + } + + return $evaluation; + } + + private function placement(string $decision, array $sourceAssignment, string $targetSchoolYear): array + { + $sourceClassId = (int) ($sourceAssignment['class_id'] ?? 0); + $sourceClassName = trim((string) ($sourceAssignment['class_name'] ?? $sourceAssignment['class_section_name'] ?? '')); + $sourceSectionName = trim((string) ($sourceAssignment['class_section_name'] ?? '')); + + if ($decision === DeliberationDecision::PASSED) { + $targetClass = $this->nextClass($sourceClassName, $targetSchoolYear); + return [ + 'assigned_grade_id' => $targetClass['id'] ?? null, + 'assigned_grade_name' => $targetClass['class_name'] ?? null, + 'assigned_class_section_id' => null, + 'placement_status' => $targetClass === null ? 'exit_required' : 'automatic_distribution_pending', + 'flags' => [], + ]; + } + + if ($decision === DeliberationDecision::REPEAT_CLASS) { + $targetSection = $this->matchingSection($sourceSectionName, $sourceClassId, $targetSchoolYear); + $flags = []; + if ($targetSection === null) { + $flags[] = $this->flag('CLASS_REASSIGNMENT_REQUIRED', 'normal', [ + 'previous_class_section_name' => $sourceSectionName, + ]); + } elseif ($this->sectionAtCapacity((int) $targetSection['class_section_id'])) { + $flags[] = $this->flag('CLASS_CAPACITY_EXCEPTION_REQUIRED', 'normal', [ + 'class_section_id' => (int) $targetSection['class_section_id'], + ]); + } + + return [ + 'assigned_grade_id' => $targetSection['class_id'] ?? $this->sameClassInTargetYear($sourceClassName, $targetSchoolYear)['id'] ?? ($sourceClassId ?: null), + 'assigned_grade_name' => $this->classNameForId((int) ($targetSection['class_id'] ?? 0)) ?? $sourceClassName, + 'assigned_class_section_id' => $targetSection['class_section_id'] ?? null, + 'placement_status' => $targetSection === null ? 'manual_class_required' : 'same_class_assigned', + 'flags' => $flags, + ]; + } + + if ($decision === DeliberationDecision::MAKE_UP_EXAM) { + $targetSection = $this->matchingSection($sourceSectionName, $sourceClassId, $targetSchoolYear); + return [ + 'assigned_grade_id' => $targetSection['class_id'] ?? $this->sameClassInTargetYear($sourceClassName, $targetSchoolYear)['id'] ?? ($sourceClassId ?: null), + 'assigned_grade_name' => $this->classNameForId((int) ($targetSection['class_id'] ?? 0)) ?? $sourceClassName, + 'assigned_class_section_id' => $targetSection['class_section_id'] ?? null, + 'placement_status' => $targetSection === null ? 'temporary_manual_class_required' : 'temporary_same_grade', + 'flags' => [ + $this->flag('PENDING_MAKE_UP_EXAM_PROMOTION', 'high', [ + 'current_grade_id' => $sourceClassId ?: null, + 'expected_promoted_grade_id' => $this->nextClass($sourceClassName, $targetSchoolYear)['id'] ?? null, + 'current_class_section_id' => $targetSection['class_section_id'] ?? null, + ]), + ], + ]; + } + + return ['assigned_grade_id' => null, 'assigned_class_section_id' => null, 'placement_status' => 'not_created', 'flags' => []]; + } + + private function applyRegistrationWindow(array &$result, ?array $targetYear, DateTimeImmutable $now, string $actorRole): void + { + if ($targetYear === null) { + $result['registration_window_status'] = 'missing_school_year'; + $result['blockers'][] = 'Target school year configuration was not found.'; + return; + } + + $opensAt = $this->dateTimeFromYear($targetYear, 'registration_opens_at', 'registration_starts_on', false); + $deadlineAt = $this->dateTimeFromYear($targetYear, 'registration_deadline_at', 'registration_ends_on', true); + + if ($opensAt !== null && $now < $opensAt) { + $result['registration_window_status'] = 'not_open'; + $result['blockers'][] = 'Registration for the new school year has not opened yet. Registration will be available starting on ' . $opensAt->format('F j, Y g:i A') . '.'; + return; + } + + if ($deadlineAt !== null && $now > $deadlineAt && (int) ($targetYear['late_registration_blocked'] ?? 1) === 1 && $actorRole !== 'admin') { + $result['registration_window_status'] = 'closed'; + $result['blockers'][] = 'The registration deadline was ' . $deadlineAt->format('F j, Y g:i A') . '. Online registration is no longer available. Please contact the school administration if you believe an exception applies.'; + $result['flags'][] = $this->flag('LATE_REGISTRATION_EXCEPTION', 'normal'); + return; + } + + $result['registration_window_status'] = 'open'; + } + + private function applyAgeRules(array &$result, string $targetSchoolYear, ?int $age): void + { + if ($age === null || ! $this->db->tableExists('enrollment_age_rules')) { + return; + } + + $builder = $this->db->table('enrollment_age_rules') + ->where('school_year', $targetSchoolYear) + ->groupStart() + ->where('grade_class_id', null) + ->orWhere('grade_class_id', $result['assigned_grade_id']) + ->groupEnd(); + + foreach ($builder->get()->getResultArray() as $rule) { + $min = is_numeric($rule['minimum_age'] ?? null) ? (int) $rule['minimum_age'] : null; + $max = is_numeric($rule['maximum_age'] ?? null) ? (int) $rule['maximum_age'] : null; + $violated = ($min !== null && $age < $min) || ($max !== null && $age > $max); + if (! $violated) { + continue; + } + + $message = 'Student age does not satisfy a configured age rule for the target placement.'; + if (($rule['behavior'] ?? 'blocking') === 'warning') { + $result['warnings'][] = $message; + } else { + $result['blockers'][] = $message; + $result['flags'][] = $this->flag('AGE_EXCEPTION_REQUIRED', 'normal', [ + 'age_rule_id' => (int) $rule['id'], + 'age_on_reference_date' => $age, + ]); + } + } + } + + private function sourceAssignment(int $studentId, string $sourceSchoolYear): ?array + { + if (! $this->db->tableExists('student_class')) { + return $this->sourceEnrollmentAssignment($studentId, $sourceSchoolYear); + } + + $assignment = $this->db->table('student_class sc') + ->select('sc.class_section_id, cs.class_section_name, cs.class_id, c.class_name') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->join('classes c', 'c.id = cs.class_id', 'left') + ->where('sc.student_id', $studentId) + ->where('sc.school_year', $sourceSchoolYear) + ->where('sc.class_section_id IS NOT NULL', null, false) + ->orderBy('sc.updated_at', 'DESC') + ->orderBy('sc.id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + return $assignment ?: $this->sourceEnrollmentAssignment($studentId, $sourceSchoolYear); + } + + private function sourceEnrollmentAssignment(int $studentId, string $sourceSchoolYear): ?array + { + if (! $this->db->tableExists('enrollments')) { + return null; + } + + return $this->db->table('enrollments e') + ->select('e.class_section_id, cs.class_section_name, cs.class_id, c.class_name') + ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left') + ->join('classes c', 'c.id = cs.class_id', 'left') + ->where('e.student_id', $studentId) + ->where('e.school_year', $sourceSchoolYear) + ->where('e.class_section_id IS NOT NULL', null, false) + ->orderBy('e.updated_at', 'DESC') + ->orderBy('e.id', 'DESC') + ->limit(1) + ->get() + ->getRowArray() ?: null; + } + + private function decisionRow(int $studentId, string $sourceSchoolYear): ?array + { + if (! $this->db->tableExists('student_decisions')) { + return null; + } + + $select = ['decision', 'source', 'notes', 'class_section_name']; + if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { + $select[] = 'deliberation_decision_standard'; + } + + return $this->db->table('student_decisions') + ->select($select) + ->where('student_id', $studentId) + ->where('school_year', $sourceSchoolYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray() ?: null; + } + + private function student(int $studentId): ?array + { + return $this->db->table('students') + ->select('id, firstname, lastname, dob, parent_id') + ->where('id', $studentId) + ->limit(1) + ->get() + ->getRowArray() ?: null; + } + + private function schoolYearByName(string $schoolYear): ?array + { + if (! $this->db->tableExists('school_years')) { + return null; + } + + return $this->db->table('school_years')->where('name', $schoolYear)->limit(1)->get()->getRowArray() ?: null; + } + + private function nextClass(string $sourceClassName, string $targetSchoolYear): ?array + { + $base = $this->classBaseName($sourceClassName); + $target = match (true) { + $base === 'KG' || str_contains($base, 'KINDERGARTEN') => '1', + ctype_digit($base) => (string) ((int) $base + 1), + $base === 'YOUTH' => 'YOUTH', + default => '', + }; + + if ($target === '') { + return null; + } + + $targetClass = $this->classByName($target, $targetSchoolYear); + if ($targetClass !== null) { + return $targetClass; + } + + return ctype_digit($base) && (int) $base >= 9 + ? $this->classByName('YOUTH', $targetSchoolYear) + : null; + } + + private function sameClassInTargetYear(string $sourceClassName, string $targetSchoolYear): ?array + { + $base = $this->classBaseName($sourceClassName); + if ($base === '') { + return null; + } + + if (str_contains($base, 'KINDERGARTEN')) { + $base = 'KG'; + } + + return $this->classByName($base, $targetSchoolYear); + } + + private function classBaseName(string $className): string + { + $base = strtoupper(trim((string) preg_replace('/-.+$/', '', $className))); + $base = preg_replace('/\b(CLASS|GRADE)\b/i', '', $base) ?? $base; + $base = trim(preg_replace('/\s+/', ' ', $base) ?? $base); + + if (str_contains($base, 'KINDERGARTEN')) { + return 'KG'; + } + + if (preg_match('/\d+/', $base, $matches) === 1) { + return (string) (int) $matches[0]; + } + + return $base; + } + + private function classByName(string $className, string $schoolYear): ?array + { + $builder = $this->db->table('classes')->where('UPPER(class_name)', strtoupper($className)); + if ($this->db->fieldExists('school_year', 'classes')) { + $builder->where('school_year', $schoolYear); + } + + $row = $builder->orderBy('id', 'DESC')->limit(1)->get()->getRowArray(); + if ($row !== null || ! $this->db->fieldExists('school_year', 'classes')) { + return $row ?: null; + } + + return $this->db->table('classes') + ->where('UPPER(class_name)', strtoupper($className)) + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray() ?: null; + } + + private function classNameForId(int $classId): ?string + { + if ($classId <= 0 || ! $this->db->tableExists('classes')) { + return null; + } + + $row = $this->db->table('classes') + ->select('class_name') + ->where('id', $classId) + ->limit(1) + ->get() + ->getRowArray(); + + $name = trim((string) ($row['class_name'] ?? '')); + + return $name !== '' ? $name : null; + } + + private function matchingSection(string $sourceSectionName, int $sourceClassId, string $targetSchoolYear): ?array + { + $builder = $this->db->table('classSection')->select('class_section_id, class_id, class_section_name')->orderBy('id', 'DESC'); + if ($sourceSectionName !== '') { + $builder->where('class_section_name', $sourceSectionName); + } else { + $builder->where('class_id', $sourceClassId)->where("class_section_name NOT LIKE '%-%'", null, false); + } + + if ($this->db->fieldExists('school_year', 'classSection')) { + $builder->where('school_year', $targetSchoolYear); + } + + return $builder->limit(1)->get()->getRowArray() ?: null; + } + + private function sectionAtCapacity(int $classSectionId): bool + { + $section = $this->db->table('classSection cs') + ->select('cs.class_section_id, c.capacity') + ->join('classes c', 'c.id = cs.class_id', 'left') + ->where('cs.class_section_id', $classSectionId) + ->orderBy('cs.id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + $capacity = is_numeric($section['capacity'] ?? null) ? (int) $section['capacity'] : 0; + if ($capacity <= 0) { + return false; + } + + $count = $this->db->table('student_class')->where('class_section_id', $classSectionId)->countAllResults(); + return $count >= $capacity; + } + + private function latestEnrollment(int $studentId, string $schoolYear): ?array + { + if (! $this->db->tableExists('enrollments')) { + return null; + } + + 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; + } + + private function upsertStudentClass(int $studentId, int $classSectionId, string $schoolYear, ?int $performedBy): void + { + $existing = $this->db->table('student_class') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + $payload = [ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'school_year' => $schoolYear, + 'updated_by' => $performedBy, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + if ($existing !== null) { + $this->db->table('student_class')->where('id', (int) $existing['id'])->update($payload); + } else { + $payload['created_at'] = date('Y-m-d H:i:s'); + $this->db->table('student_class')->insert($payload); + } + } + + private function writeFlags(array $evaluation, ?int $performedBy): void + { + if (! $this->db->tableExists('enrollment_flags')) { + return; + } + + foreach ($evaluation['flags'] as $flag) { + $existing = $this->db->table('enrollment_flags') + ->where('student_id', (int) $evaluation['student_id']) + ->where('school_year', (string) $evaluation['target_school_year']) + ->where('flag_type', (string) $flag['flag_type']) + ->where('status', 'open') + ->limit(1) + ->get() + ->getRowArray(); + + if ($existing !== null) { + continue; + } + + $this->db->table('enrollment_flags')->insert([ + 'flag_type' => $flag['flag_type'], + 'student_id' => (int) $evaluation['student_id'], + 'school_year' => (string) $evaluation['target_school_year'], + 'source_school_year' => (string) $evaluation['source_school_year'], + 'status' => 'open', + 'priority' => $flag['priority'] ?? 'normal', + 'assigned_to' => $performedBy, + 'details_json' => json_encode($flag['details'] ?? [], JSON_UNESCAPED_SLASHES), + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + } + + private function audit(array $evaluation, string $action, ?int $performedBy, ?array $original, array $new): void + { + if (! $this->db->tableExists('enrollment_transition_audits')) { + return; + } + + $this->db->table('enrollment_transition_audits')->insert([ + 'student_id' => (int) $evaluation['student_id'], + 'school_year' => (string) $evaluation['target_school_year'], + 'source_school_year' => (string) $evaluation['source_school_year'], + 'action' => $action, + 'performed_by' => $performedBy, + 'original_values_json' => $original !== null ? json_encode($original, JSON_UNESCAPED_SLASHES) : null, + 'new_values_json' => json_encode($new, JSON_UNESCAPED_SLASHES), + 'reason' => implode(' ', $evaluation['blockers'] ?? []), + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + + private function flag(string $type, string $priority, array $details = []): array + { + return ['flag_type' => $type, 'priority' => $priority, 'details' => $details]; + } + + private function ageReferenceDate(string $schoolYear): string + { + return preg_match('/^(\d{4})/', $schoolYear, $matches) ? $matches[1] . '-09-01' : date('Y') . '-09-01'; + } + + private function dateTimeFromYear(array $year, string $dateTimeField, string $dateField, bool $endOfDay): ?DateTimeImmutable + { + $value = trim((string) ($year[$dateTimeField] ?? '')); + if ($value === '') { + $date = trim((string) ($year[$dateField] ?? '')); + $value = $date !== '' ? $date . ($endOfDay ? ' 23:59:59' : ' 00:00:00') : ''; + } + + if ($value === '') { + return null; + } + + try { + return new DateTimeImmutable($value); + } catch (\Throwable) { + return null; + } + } + + private function studentName(array $student): string + { + $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); + return $name !== '' ? $name : 'The student'; + } +} diff --git a/app/Services/SchoolYearClosingService.php b/app/Services/SchoolYearClosingService.php index 7d654e7..0b4db28 100644 --- a/app/Services/SchoolYearClosingService.php +++ b/app/Services/SchoolYearClosingService.php @@ -7,6 +7,7 @@ use App\Models\SchoolYearClosingItemModel; use App\Models\SchoolYearModel; use App\Models\ConfigurationModel; use App\Models\InvoiceModel; +use App\Support\Enrollment\DeliberationDecision; use App\Support\SchoolYear\SchoolYearStatus; use CodeIgniter\Database\BaseConnection; use InvalidArgumentException; @@ -644,6 +645,7 @@ final class SchoolYearClosingService 'auto_kg_pass' => false, 'year_score' => null, 'decision' => '', + 'normalized_decision' => null, 'source' => 'missing', 'notes' => '', 'status' => 'missing', @@ -667,6 +669,7 @@ final class SchoolYearClosingService if ($decision !== null) { $student['year_score'] = $decision['year_score']; $student['decision'] = $decision['decision']; + $student['normalized_decision'] = $decision['normalized_decision']; $student['source'] = $decision['source']; $student['notes'] = $decision['notes']; $student['status'] = $decision['status']; @@ -676,17 +679,19 @@ final class SchoolYearClosingService } if ($decision === null && $this->isKgStudent($student)) { - $kgAgeStatus = $this->kgAgeStatusByTargetYearCutoff((string) $student['dob'], $targetSchoolYear); + $kgAgeStatus = $this->kgAgeStatusByTargetYearStartCutoff((string) $student['dob'], $targetSchoolYear); if ($kgAgeStatus === 'pass') { $student['decision'] = 'Pass'; + $student['normalized_decision'] = DeliberationDecision::PASSED; $student['source'] = 'automatic_kg_age'; - $student['notes'] = 'Auto-pass KG student: age 6 or older by Dec 31 of the next school year start year.'; + $student['notes'] = 'Auto-pass KG student: age 6 or older by Sep 1 of the next school year.'; $student['status'] = 'decided'; $student['auto_kg_pass'] = true; } elseif ($kgAgeStatus === 'keep_kg') { $student['decision'] = 'Keep KG'; + $student['normalized_decision'] = DeliberationDecision::REPEAT_CLASS; $student['source'] = 'automatic_kg_age'; - $student['notes'] = 'Auto-keep KG student: younger than 6 by Dec 31 of the next school year start year.'; + $student['notes'] = 'Auto-keep KG student: younger than 6 by Sep 1 of the next school year.'; $student['status'] = 'decided'; } } @@ -705,7 +710,7 @@ final class SchoolYearClosingService $summary['pending_decision']++; } else { $summary['with_decision']++; - if (strcasecmp((string) $student['decision'], 'Pass') === 0) { + if (($student['normalized_decision'] ?? null) === DeliberationDecision::PASSED) { $summary['pass']++; if ($queue === null && $student['auto_kg_pass'] !== true) { $summary['missing_queue']++; @@ -745,7 +750,7 @@ final class SchoolYearClosingService return false; } - private function kgAgeStatusByTargetYearCutoff(string $dob, ?string $targetSchoolYear): string + private function kgAgeStatusByTargetYearStartCutoff(string $dob, ?string $targetSchoolYear): string { $dob = trim($dob); if ($dob === '' || $targetSchoolYear === null || ! preg_match('/^(\d{4})-\d{4}$/', $targetSchoolYear, $matches)) { @@ -754,7 +759,7 @@ final class SchoolYearClosingService try { $birthDate = new \DateTimeImmutable($dob); - $cutoff = new \DateTimeImmutable($matches[1] . '-12-31'); + $cutoff = new \DateTimeImmutable($matches[1] . '-09-01'); } catch (\Throwable) { return ''; } @@ -808,11 +813,13 @@ final class SchoolYearClosingService $decision = trim((string) ($row['decision'] ?? '')); $source = trim((string) ($row['source'] ?? '')); $status = $decision === '' || $source === 'pending' ? 'pending' : 'decided'; + $normalizedDecision = DeliberationDecision::normalize($decision); $decisions[$studentId] = [ 'class_section_name' => (string) ($row['class_section_name'] ?? ''), 'year_score' => is_numeric($row['year_score'] ?? null) ? round((float) $row['year_score'], 2) : null, 'decision' => $decision, + 'normalized_decision' => $normalizedDecision, 'source' => $source !== '' ? $source : ($status === 'pending' ? 'pending' : 'manual'), 'notes' => (string) ($row['notes'] ?? ''), 'status' => $status, diff --git a/app/Services/SchoolYearContextService.php b/app/Services/SchoolYearContextService.php index 632b5e7..96ba6e4 100644 --- a/app/Services/SchoolYearContextService.php +++ b/app/Services/SchoolYearContextService.php @@ -22,7 +22,7 @@ final class SchoolYearContextService IncomingRequest $request, ?int $routeSchoolYearId = null ): SchoolYearContext { - $requestedId = $routeSchoolYearId ?? $this->normalizeInt($request->getGet('school_year_id')); + $requestedId = $routeSchoolYearId ?? $this->requestedSchoolYearId($request); $requestedName = $this->legacyYearName($request); $userId = (int) (session()->get('user_id') ?? 0); @@ -118,6 +118,27 @@ final class SchoolYearContextService return $context; } + public function forYearName(string $yearName): SchoolYearContext + { + $yearName = trim($yearName); + + if ($yearName === '') { + throw new SchoolYearNotFoundException('Selected school year was not found.'); + } + + $row = $this->schoolYearModel + ->where('name', $yearName) + ->first(); + + if ($row === null) { + throw new SchoolYearNotFoundException('Selected school year was not found.'); + } + + $userId = (int) (session()->get('user_id') ?? 0); + + return $this->authorizedContext($row, $userId, true); + } + public function clearSelection(): void { session()->remove('selected_school_year_id'); @@ -155,6 +176,31 @@ final class SchoolYearContextService return (int) $value; } + private function requestedSchoolYearId(IncomingRequest $request): ?int + { + $ids = []; + + foreach (['school_year_id', 'schoolYearId', 'year_id'] as $key) { + $value = $this->normalizeInt($request->getGet($key)); + if ($value !== null) { + $ids[$key] = $value; + } + + if ($this->isWriteRequest($request)) { + $value = $this->normalizeInt($request->getPost($key)); + if ($value !== null) { + $ids['post:' . $key] = $value; + } + } + } + + if (count(array_unique($ids)) > 1) { + throw new InvalidSchoolYearSelectionException('Conflicting school-year parameters were provided.'); + } + + return $ids === [] ? null : (int) reset($ids); + } + private function legacyYearName(IncomingRequest $request): string { $names = []; @@ -164,6 +210,13 @@ final class SchoolYearContextService if ($value !== '') { $names[$key] = $value; } + + if ($this->isWriteRequest($request)) { + $value = trim((string) ($request->getPost($key) ?? '')); + if ($value !== '') { + $names['post:' . $key] = $value; + } + } } if (count(array_unique($names)) > 1) { @@ -179,6 +232,11 @@ final class SchoolYearContextService return $names === [] ? '' : (string) reset($names); } + private function isWriteRequest(IncomingRequest $request): bool + { + return in_array(strtoupper($request->getMethod()), ['POST', 'PUT', 'PATCH', 'DELETE'], true); + } + private function authorizedContext(array $row, int $userId, bool $explicit): SchoolYearContext { if (! $this->isSelectable($row, $userId)) { diff --git a/app/Support/Enrollment/DeliberationDecision.php b/app/Support/Enrollment/DeliberationDecision.php new file mode 100644 index 0000000..0cc554f --- /dev/null +++ b/app/Support/Enrollment/DeliberationDecision.php @@ -0,0 +1,71 @@ + self::PASSED, + str_contains($compact, 'repeat') || str_contains($compact, 'keepkg') => self::REPEAT_CLASS, + str_contains($compact, 'makeupexam') || str_contains($compact, 'makeupexamfall') || str_contains($compact, 'makeup') => self::MAKE_UP_EXAM, + str_contains($compact, 'deferred') || str_contains($compact, 'pendingdecision') => self::DEFERRED_DECISION, + str_contains($compact, 'withdraw') || str_contains($compact, 'widthraw') || str_contains($compact, 'widthdraw') || str_contains($compact, 'widthrwan') => self::WITHDRAWN, + str_contains($compact, 'expel') => self::EXPELLED, + default => null, + }; + } + + public static function display(?string $decision): string + { + return match (self::normalize($decision) ?? $decision) { + self::PASSED => 'Passed', + self::REPEAT_CLASS => 'Repeat Class', + self::MAKE_UP_EXAM => 'Make-up Exam', + self::EXPELLED => 'Expelled', + self::WITHDRAWN => 'Withdrawn', + self::DEFERRED_DECISION => 'Deferred Decision', + default => trim((string) $decision), + }; + } + + public static function blocksEnrollment(?string $decision): bool + { + return in_array(self::normalize($decision), [ + self::EXPELLED, + self::WITHDRAWN, + self::DEFERRED_DECISION, + ], true); + } +} diff --git a/app/Support/Enrollment/EnrollmentEligibility.php b/app/Support/Enrollment/EnrollmentEligibility.php new file mode 100644 index 0000000..ebd5135 --- /dev/null +++ b/app/Support/Enrollment/EnrollmentEligibility.php @@ -0,0 +1,105 @@ + 0 || ($errors['error_count'] ?? 0) > 0)) + ) { + return null; + } + + $referenceDate = new \DateTimeImmutable($matches[1] . '-09-01'); + } catch (\Throwable) { + return null; + } + + return $birthDate > $referenceDate ? null : $birthDate->diff($referenceDate)->y; + } + + public static function parentDecisionMessage( + array $student, + ?array $decisionRow, + string $targetSchoolYear, + ?string $fallMakeupExamOn = null + ): array { + $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); + $name = $name !== '' ? $name : 'The student'; + $decision = DeliberationDecision::normalize($decisionRow['decision'] ?? null); + $source = strtolower(trim((string) ($decisionRow['source'] ?? ''))); + + if ($decision === DeliberationDecision::EXPELLED) { + return self::message($name . ': ' . self::EXPELLED_MESSAGE, true, 'danger'); + } + + if ($decision === DeliberationDecision::WITHDRAWN || ($student['enrollment_status'] ?? null) === 'withdrawn') { + return self::message($name . ': ' . self::WITHDRAWN_MESSAGE, true, 'warning'); + } + + if ($decision === DeliberationDecision::DEFERRED_DECISION) { + return self::message($name . ': ' . self::DEFERRED_MESSAGE, true, 'warning'); + } + + if ($decision === null && $source === 'pending') { + 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( + $name . ' has a make-up exam decision. Enrollment is allowed, but the student will initially remain in the same grade as the closed school year until the fall make-up exam' . $dateText . ' is passed and administration confirms promotion.', + false, + 'warning' + ); + } + + if ($decision === DeliberationDecision::REPEAT_CLASS) { + $previousClass = trim((string) ($decisionRow['class_section_name'] ?? '')); + $previousClass = $previousClass !== '' ? $previousClass : 'the same class'; + + return self::message($name . ' has a repeat class decision and is accepted only in ' . $previousClass . '.', false, 'info'); + } + + if ($decision === DeliberationDecision::PASSED || ($decision === null && $source !== 'pending' && trim((string) ($decisionRow['decision'] ?? '')) === '')) { + return self::message('', false, 'info'); + } + + return self::message( + $name . ' does not have a final academic decision that permits online re-enrollment. Please contact the administration.', + true, + 'warning' + ); + } + + private static function message(string $message, bool $blocking, string $level): array + { + return [ + 'message' => $message, + 'blocking' => $blocking, + 'level' => $level, + ]; + } +} diff --git a/app/Views/administrator/enrollment_admin_dashboard.php b/app/Views/administrator/enrollment_admin_dashboard.php new file mode 100644 index 0000000..d8888fd --- /dev/null +++ b/app/Views/administrator/enrollment_admin_dashboard.php @@ -0,0 +1,336 @@ +extend('layout/management_layout') ?> +section('content') ?> + +
+
+
+

Enrollment Administration

+
Review school-year transition flags, exceptions, and placement follow-up.
+
+
+ + getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + +
+
+
+
Registration Email Launch
+ +
Approved at
+ +
Missing:
+ +
Configuration is ready for launch approval.
+ +
+
+ + Preview Email + +
+ + + +
+
+ + +
+ + +
+ +
+
+
+
+ +

Decision Email Examples

+
+ + + + + + + + + + + + + + + 'bg-success', + 'failed' => 'bg-danger', + 'generated' => 'bg-info text-dark', + default => 'bg-secondary', + }; + ?> + + + + + + + + + + + + + + + +
ParentRecipientStudentsSubjectDeliveryExample
+ + +
+ + +
+ +
+ View Example +
No parent decision email examples found for this school year.
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +

Enrollment Follow-up

+
+ + + + + + + + + + + + + + + + + 'bg-warning text-dark', + 'waitlist' => 'bg-info text-dark', + 'denied' => 'bg-danger', + default => 'bg-secondary', + }; + ?> + + + + + + + + + + + + + + + + + +
StudentSchool IDEnrollment StatusDecisionPlacementClassException / ReasonUpdated
+ + Exception + +
+
No enrollment records need follow-up for this school year.
+
+ +

Enrollment Flags

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StudentSchool IDFlag TypePriorityDetailsAssignedCreatedAction
+ + $value): ?> +
:
+ + + No details + +
+ + +
+ + + + +
+ +
+ + + + + +
+ +
+ + + +
+ + +
+ + + +
+ +
+ +
No formal enrollment flags found. Check Enrollment Follow-up above for status-based items.
+
+ +

Recent Enrollment Audit

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
WhenStudentActionAdministratorReason
No enrollment audit records found. Audit rows appear after actions are completed from this dashboard or through the transition service.
+
+
+ +endSection() ?> diff --git a/app/Views/administrator/enrollment_email_preview.php b/app/Views/administrator/enrollment_email_preview.php new file mode 100644 index 0000000..bcbd8be --- /dev/null +++ b/app/Views/administrator/enrollment_email_preview.php @@ -0,0 +1,30 @@ +extend('layout/management_layout') ?> +section('content') ?> + + + +
+
+
+

Registration Email Preview

+
· Parent #
+
+ Back +
+ +
+ + +
+ +
+ +
+
+ +endSection() ?> diff --git a/app/Views/administrator/sections_auto_distribute.php b/app/Views/administrator/sections_auto_distribute.php index 924f7da..df05c04 100644 --- a/app/Views/administrator/sections_auto_distribute.php +++ b/app/Views/administrator/sections_auto_distribute.php @@ -3,30 +3,44 @@ section('styles') ?> endSection() ?> @@ -104,7 +182,7 @@ $name = trim((string)($c['class_section_name'] ?? '')); $isBase = ($name !== '' && strpos($name, '-') === false); $lowerName = strtolower($name); - $isStandard = ($lowerName === 'kg' || $lowerName === 'youth' || (ctype_digit($lowerName) && (int)$lowerName >= 1 && (int)$lowerName <= 9)); + $isStandard = ($lowerName === 'kg' || $lowerName === 'youth' || (ctype_digit($lowerName) && (int)$lowerName >= 1 && (int)$lowerName <= 10)); if (!$isBase || $isStandard) continue; ?> @@ -119,6 +197,15 @@
+
+
+ + +
+
+
+
+
@@ -126,8 +213,6 @@ - - @@ -151,6 +236,15 @@ const selectedYear = ''; const totalsBaseUrl = ''; const distUrl = ''; + const updateDraftUrl = ''; + const updateCandidateUrl = ''; + const classSections = (int)($c['class_id'] ?? 0), + 'class_section_id' => (int)($c['class_section_id'] ?? 0), + 'class_section_name' => (string)($c['class_section_name'] ?? ''), + ]; + }, $classes ?? [])), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>; const tblBody = document.getElementById('tblBody'); const sectionCountInput = document.getElementById('sectionCount'); @@ -161,9 +255,13 @@ const additionalClassSelect = document.getElementById('additionalClassSelect'); const addClassBtn = document.getElementById('addClassBtn'); const includedClassesEl = document.getElementById('includedClasses'); + const studentSearchInput = document.getElementById('studentSearch'); + const studentSearchSummary = document.getElementById('studentSearchSummary'); let rowIndexByClassId = {}; // mapping to locate rows let includedClassIds = []; + const baseClasses = buildBaseClassOptions(); + const sectionsByClassId = buildSectionsByClassId(); function totalsUrl() { const params = new URLSearchParams(); @@ -172,9 +270,111 @@ return totalsBaseUrl + '?' + params.toString(); } - function selectedSectionCount() { - const count = parseInt(sectionCountInput.value || '0', 10); - return count > 0 ? count : ''; + function buildBaseClassOptions() { + const seen = {}; + return classSections + .filter(row => row.class_id > 0 && row.class_section_name && row.class_section_name.indexOf('-') < 0) + .filter(function(row){ + if (seen[row.class_id]) return false; + seen[row.class_id] = true; + return true; + }) + .sort((a, b) => String(a.class_section_name).localeCompare(String(b.class_section_name), undefined, { numeric: true })); + } + + function buildSectionsByClassId() { + const out = {}; + classSections.forEach(function(row){ + if (!row.class_id || !row.class_section_id || !row.class_section_name || row.class_section_name.indexOf('-') < 0) return; + if (!out[row.class_id]) out[row.class_id] = []; + out[row.class_id].push({ + id: row.class_section_id, + name: row.class_section_name + }); + }); + Object.keys(out).forEach(function(classId){ + out[classId].sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { numeric: true })); + }); + return out; + } + + function classNameById(classId) { + const row = baseClasses.find(c => c.class_id === parseInt(classId || '0', 10)); + return row ? row.class_section_name : ('Class #' + classId); + } + + function isStandardBaseClassName(name) { + const normalized = String(name || '').trim().toLowerCase(); + if (normalized === 'kg' || normalized === 'youth') return true; + if (!/^\d+$/.test(normalized)) return false; + const n = parseInt(normalized, 10); + return n >= 1 && n <= 10; + } + + function ensureIncludedClassVisible(classId) { + classId = parseInt(classId || '0', 10); + if (!classId) return; + const className = classNameById(classId); + if (isStandardBaseClassName(className)) return; + if (includedClassIds.indexOf(classId) < 0) { + includedClassIds.push(classId); + renderIncludedClasses(); + } + } + + function populateDestinationSelect(select, selectedSectionId, selectedClassId) { + select.innerHTML = ''; + selectedClassId = parseInt(selectedClassId || '0', 10); + let resolvedClassId = 0; + + baseClasses.forEach(function(baseClass){ + const classOption = document.createElement('option'); + classOption.value = String(baseClass.class_section_id); + classOption.dataset.classId = String(baseClass.class_id); + classOption.textContent = baseClass.class_section_name; + if (baseClass.class_section_id === parseInt(selectedSectionId || '0', 10) || (!parseInt(selectedSectionId || '0', 10) && selectedClassId === baseClass.class_id)) { + classOption.selected = true; + resolvedClassId = baseClass.class_id; + } + select.appendChild(classOption); + + const sections = sectionsByClassId[String(baseClass.class_id)] || []; + sections.forEach(function(section){ + const opt = document.createElement('option'); + opt.value = String(section.id); + opt.dataset.classId = String(baseClass.class_id); + opt.textContent = section.name; + if (section.id === parseInt(selectedSectionId || '0', 10)) { + opt.selected = true; + resolvedClassId = baseClass.class_id; + } + select.appendChild(opt); + }); + }); + + const selectedOption = select.options[select.selectedIndex] || null; + if (!resolvedClassId && selectedOption) { + resolvedClassId = parseInt(selectedOption.dataset.classId || '0', 10); + } + + return { + classId: resolvedClassId, + sectionId: selectedOption ? parseInt(selectedOption.value || '0', 10) : 0 + }; + } + + function selectedDestination(select) { + const selectedOption = select && select.options ? select.options[select.selectedIndex] : null; + return { + classId: selectedOption ? parseInt(selectedOption.dataset.classId || '0', 10) : 0, + sectionId: selectedOption ? parseInt(selectedOption.value || '0', 10) : 0 + }; + } + + function assignmentLabel(assignment) { + if (assignment && assignment.class_section_name) return assignment.class_section_name; + const classId = parseInt((assignment && assignment.class_id) || '0', 10); + return classId > 0 ? classNameById(classId) : '-'; } function buildInitialTable(rows) { @@ -196,25 +396,16 @@ tdTotal.textContent = r.total; tr.appendChild(tdTotal); - const tdNeed = document.createElement('td'); - tdNeed.className = 'text-end need-cell'; - tdNeed.textContent = selectedSectionCount(); - tr.appendChild(tdNeed); - - const tdAct = document.createElement('td'); - const btn = document.createElement('button'); - btn.className = 'btn btn-sm btn-primary'; - btn.textContent = 'Generate Sections'; - btn.addEventListener('click', function(){ runDistribution(r.class_section_id, r.class_section_name); }); - tdAct.appendChild(btn); - tr.appendChild(tdAct); - const tdResults = document.createElement('td'); tdResults.className = 'distribution-cell'; - const empty = document.createElement('span'); - empty.className = 'distribution-empty'; - empty.textContent = 'Not generated'; - tdResults.appendChild(empty); + if (Array.isArray(r.students) && r.students.length) { + tdResults.appendChild(renderClassRoster(tr, r.students)); + } else { + const empty = document.createElement('span'); + empty.className = 'distribution-empty'; + empty.textContent = 'No students'; + tdResults.appendChild(empty); + } tr.appendChild(tdResults); tblBody.appendChild(tr); @@ -223,16 +414,10 @@ rows.forEach(function(r){ if (Array.isArray(r.sections) && r.sections.length) { - renderSectionsForRow(r.class_section_id, r.sections); + renderSectionsForRow(r.class_section_id, r.sections, r.students || []); } }); - } - - function updateNeeds() { - document.querySelectorAll('#tblBody tr').forEach(function(tr){ - const needCell = tr.querySelector('.need-cell'); - if (needCell) needCell.textContent = selectedSectionCount(); - }); + applyStudentSearch(); } function runDistribution(baseSectionId, baseName) { @@ -273,12 +458,12 @@ msgEl.textContent = res && res.message ? res.message : 'Completed.'; - renderSectionsForRow(baseSectionId, res.sections); + renderSectionsForRow(baseSectionId, res.sections, []); }) .catch(() => { msgEl.textContent = 'Failed to distribute. Please try again.'; }); } - function renderSectionsForRow(baseSectionId, sections) { + function renderSectionsForRow(baseSectionId, sections, unassignedAssignments) { const tr = Array.from(document.querySelectorAll('#tblBody tr')).find(function(_tr){ return String(_tr.dataset.classSectionId || '') === String(baseSectionId || ''); }); @@ -288,11 +473,54 @@ if (!td) return; td.innerHTML = ''; + const unassigned = Array.isArray(unassignedAssignments) ? unassignedAssignments : (tr._unassignedAssignments || []); + tr._unassignedAssignments = unassigned; + const allSections = Array.isArray(sections) ? sections.slice() : []; + const baseSections = allSections.filter(function(section){ + const name = String(section.class_section_name || ''); + return name === String(tr.dataset.className || '') || name.indexOf('-') < 0; + }); + const visibleSections = allSections.filter(function(section){ + return baseSections.indexOf(section) < 0; + }); + const baseAssignments = assignmentsFromSections(baseSections); + const rosterAssignments = unassigned.concat(baseAssignments); + if (rosterAssignments.length) { + td.appendChild(renderClassRoster(tr, rosterAssignments)); + } + const grid = document.createElement('div'); grid.className = 'distribution-grid'; + const allAssignments = assignmentsFromSections(visibleSections); - sections.forEach(function(s){ - const studentNames = Array.isArray(s.student_names) ? s.student_names : []; + if (!visibleSections.length && !rosterAssignments.length) { + td.appendChild(renderClassRoster(tr, allAssignments)); + } + + visibleSections.forEach(function(s){ + const studentAssignments = Array.isArray(s.student_assignments) && s.student_assignments.length + ? s.student_assignments.map(function(assignment){ + return { + ...assignment, + class_id: assignment.class_id || s.class_id, + class_section_id: assignment.class_section_id || s.class_section_id, + class_section_name: assignment.class_section_name || s.class_section_name || '' + }; + }) + : (Array.isArray(s.student_names) ? s.student_names : []).map(function(studentName){ + return { + draft_id: 0, + student_id: 0, + student_name: studentName, + age_at_reference: null, + gender: '', + last_year_class_section: '', + class_id: s.class_id, + class_section_id: s.class_section_id, + class_section_name: s.class_section_name || '' + }; + }); + const studentNames = studentAssignments.map(a => a.student_name).filter(Boolean); const block = document.createElement('div'); block.className = 'distribution-section'; @@ -327,14 +555,7 @@ if (meta.children.length) block.appendChild(meta); if (studentNames.length) { - const list = document.createElement('ol'); - list.className = 'distribution-students'; - studentNames.forEach(function(studentName){ - const item = document.createElement('li'); - item.textContent = studentName; - list.appendChild(item); - }); - block.appendChild(list); + block.appendChild(renderAssignmentList(studentAssignments, true)); } else { const empty = document.createElement('div'); empty.className = 'distribution-empty'; @@ -346,6 +567,344 @@ }); td.appendChild(grid); + renderAddSectionButton(td, tr, baseSectionId, visibleSections); + applyStudentSearch(); + } + + function assignmentsFromSections(sections) { + const out = []; + const seenDrafts = {}; + const seenFallback = {}; + + sections.forEach(function(section){ + const assignments = Array.isArray(section.student_assignments) && section.student_assignments.length + ? section.student_assignments + : (Array.isArray(section.student_names) ? section.student_names : []).map(function(studentName, idx){ + return { + draft_id: 0, + student_id: 0, + student_name: studentName, + age_at_reference: null, + gender: '', + last_year_class_section: '', + class_id: section.class_id, + class_section_id: section.class_section_id, + class_section_name: section.class_section_name, + fallback_key: String(section.class_section_id || '') + ':' + idx + ':' + String(studentName || '') + }; + }); + + assignments.forEach(function(assignment){ + const draftId = parseInt(assignment.draft_id || '0', 10); + if (draftId > 0) { + if (seenDrafts[draftId]) return; + seenDrafts[draftId] = true; + } else { + const key = assignment.fallback_key || String(assignment.class_section_id || section.class_section_id || '') + ':' + String(assignment.student_name || ''); + if (seenFallback[key]) return; + seenFallback[key] = true; + } + + out.push({ + draft_id: draftId, + student_id: parseInt(assignment.student_id || '0', 10), + student_name: assignment.student_name || 'Student', + age_at_reference: assignment.age_at_reference ?? null, + gender: assignment.gender || '', + last_year_class_section: assignment.last_year_class_section || '', + class_id: parseInt(assignment.class_id || section.class_id || '0', 10), + class_section_id: parseInt(assignment.class_section_id || section.class_section_id || '0', 10), + class_section_name: assignment.class_section_name || section.class_section_name || '' + }); + }); + }); + + out.sort((a, b) => String(a.student_name).localeCompare(String(b.student_name), undefined, { numeric: true })); + return out; + } + + function renderClassRoster(tr, assignments) { + const wrap = document.createElement('div'); + wrap.className = 'distribution-class-roster'; + + const title = document.createElement('div'); + title.className = 'distribution-class-title'; + + const name = document.createElement('div'); + name.className = 'fw-semibold'; + name.textContent = 'Students in ' + (tr.dataset.className || 'class'); + title.appendChild(name); + + const count = document.createElement('div'); + count.className = 'badge bg-secondary'; + count.textContent = assignments.length + ' students'; + title.appendChild(count); + wrap.appendChild(title); + + if (!assignments.length) { + const empty = document.createElement('div'); + empty.className = 'distribution-empty'; + empty.textContent = 'No students assigned'; + wrap.appendChild(empty); + return wrap; + } + + wrap.appendChild(renderAssignmentList(assignments, true)); + return wrap; + } + + function renderAssignmentList(assignments, editable) { + const wrap = document.createElement('div'); + wrap.className = 'distribution-students-wrap'; + + const header = document.createElement('div'); + header.className = 'distribution-students-header'; + ['', 'Student', 'Age', 'Gender', 'Last Year', 'Assignment'].forEach(function(label){ + const cell = document.createElement('span'); + cell.textContent = label; + header.appendChild(cell); + }); + wrap.appendChild(header); + + const list = document.createElement('ol'); + list.className = 'distribution-students'; + + const sortedAssignments = Array.isArray(assignments) + ? assignments.slice().sort(function(a, b){ + return String(a.student_name || '').localeCompare(String(b.student_name || ''), undefined, { numeric: true }); + }) + : []; + + sortedAssignments.forEach(function(assignment){ + const item = document.createElement('li'); + item.dataset.searchText = [ + assignment.student_name || '', + assignment.age_at_reference ?? '', + assignment.gender || '', + assignment.last_year_class_section || '', + assignmentLabel(assignment) + ].join(' ').toLowerCase(); + const row = document.createElement('div'); + row.className = 'distribution-student-row'; + + const rowNumber = document.createElement('span'); + rowNumber.className = 'student-row-number'; + row.appendChild(rowNumber); + + const studentLabel = document.createElement('span'); + studentLabel.textContent = assignment.student_name || 'Student'; + row.appendChild(studentLabel); + + const ageCell = document.createElement('span'); + ageCell.className = 'student-age-cell'; + ageCell.textContent = assignment.age_at_reference === null || assignment.age_at_reference === undefined || assignment.age_at_reference === '' + ? '-' + : assignment.age_at_reference; + row.appendChild(ageCell); + + const genderCell = document.createElement('span'); + genderCell.className = 'student-gender-cell'; + genderCell.textContent = assignment.gender || '-'; + row.appendChild(genderCell); + + const lastYearCell = document.createElement('span'); + lastYearCell.className = 'student-last-year-cell'; + lastYearCell.textContent = assignment.last_year_class_section || '-'; + row.appendChild(lastYearCell); + + const assignmentCell = document.createElement('span'); + assignmentCell.className = 'student-assignment-cell'; + + const draftId = parseInt(assignment.draft_id || '0', 10); + const studentId = parseInt(assignment.student_id || '0', 10); + if (editable && (draftId > 0 || studentId > 0) && baseClasses.length) { + const destinationSelect = document.createElement('select'); + destinationSelect.className = 'form-select form-select-sm'; + destinationSelect.setAttribute('aria-label', 'Change class and section for ' + (assignment.student_name || 'student')); + populateDestinationSelect( + destinationSelect, + parseInt(assignment.class_section_id || '0', 10), + parseInt(assignment.class_id || '0', 10) + ); + destinationSelect.dataset.previousValue = destinationSelect.value; + + destinationSelect.addEventListener('change', function(){ + const target = selectedDestination(destinationSelect); + if (draftId > 0) { + updateDraftAssignment(draftId, target.classId, target.sectionId, destinationSelect); + } else { + updateCandidateAssignment(studentId, target.classId, target.sectionId, destinationSelect); + } + }); + + assignmentCell.appendChild(destinationSelect); + } else { + assignmentCell.textContent = assignmentLabel(assignment); + } + row.appendChild(assignmentCell); + + item.appendChild(row); + list.appendChild(item); + }); + + wrap.appendChild(list); + return wrap; + } + + function applyStudentSearch() { + const query = String((studentSearchInput && studentSearchInput.value) || '').trim().toLowerCase(); + let totalStudents = 0; + let visibleStudents = 0; + + document.querySelectorAll('.distribution-students li').forEach(function(item){ + totalStudents++; + const matches = !query || String(item.dataset.searchText || '').indexOf(query) >= 0; + item.hidden = !matches; + if (matches) visibleStudents++; + }); + + document.querySelectorAll('.distribution-section, .distribution-class-roster').forEach(function(block){ + const items = Array.from(block.querySelectorAll('.distribution-students li')); + block.hidden = !!query && items.length > 0 && !items.some(item => !item.hidden); + }); + + document.querySelectorAll('#tblBody tr').forEach(function(tr){ + const items = Array.from(tr.querySelectorAll('.distribution-students li')); + tr.hidden = !!query && items.length > 0 && !items.some(item => !item.hidden); + }); + + if (studentSearchSummary) { + studentSearchSummary.textContent = query + ? visibleStudents + ' of ' + totalStudents + ' students shown' + : ''; + } + } + + function renderAddSectionButton(container, tr, baseSectionId, visibleSections) { + const baseClassId = parseInt(tr.dataset.classId || '0', 10); + const allSections = sectionsByClassId[String(baseClassId)] || []; + const visibleSectionIds = visibleSections.map(section => parseInt(section.class_section_id || '0', 10)); + const nextSection = allSections.find(section => visibleSectionIds.indexOf(section.id) < 0); + if (!nextSection) return; + + const addWrap = document.createElement('div'); + addWrap.className = 'mt-2'; + + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'btn btn-sm btn-outline-secondary'; + addBtn.textContent = 'Add Section'; + addBtn.addEventListener('click', function(){ + const nextVisibleSections = visibleSections.concat([{ + class_id: baseClassId, + class_section_id: nextSection.id, + class_section_name: nextSection.name, + total: 0, + student_names: [], + student_assignments: [] + }]); + renderSectionsForRow(baseSectionId, nextVisibleSections, tr._unassignedAssignments || []); + }); + + addWrap.appendChild(addBtn); + container.appendChild(addWrap); + } + + function updateDraftAssignment(draftId, classId, classSectionId, selectEl) { + if (!draftId || !classSectionId) { + msgEl.textContent = 'Select a valid target section.'; + return; + } + + const previousValue = selectEl ? selectEl.dataset.previousValue || selectEl.defaultValue || '' : ''; + if (selectEl) selectEl.disabled = true; + + const fd = new FormData(); + fd.append('draft_id', String(draftId)); + fd.append('class_section_id', String(classSectionId)); + + const csrfNameEl = document.getElementById('csrfName'); + const csrfValueEl= document.getElementById('csrfValue'); + if (csrfNameEl && csrfValueEl) { + fd.append(csrfNameEl.value, csrfValueEl.value); + } + + msgEl.textContent = 'Updating draft assignment...'; + fetch(updateDraftUrl, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd }) + .then(r => r.json()) + .then(res => { + if (res && res.csrfTokenName && res.csrfHash) { + if (csrfNameEl) csrfNameEl.value = res.csrfTokenName; + if (csrfValueEl) csrfValueEl.value = res.csrfHash; + } + + if (!res || !res.ok) { + if (selectEl && previousValue) selectEl.value = previousValue; + msgEl.textContent = res && res.message ? res.message : 'Draft assignment could not be updated.'; + return; + } + + msgEl.textContent = res.message || 'Draft assignment updated.'; + if (selectEl) selectEl.dataset.previousValue = String(classSectionId); + ensureIncludedClassVisible(classId); + loadTotals(); + }) + .catch(() => { + if (selectEl && previousValue) selectEl.value = previousValue; + msgEl.textContent = 'Draft assignment could not be updated.'; + }) + .finally(() => { + if (selectEl) selectEl.disabled = false; + }); + } + + function updateCandidateAssignment(studentId, classId, classSectionId, selectEl) { + if (!studentId || !classSectionId) { + msgEl.textContent = 'Select a valid student and target assignment.'; + return; + } + + const previousValue = selectEl ? selectEl.dataset.previousValue || selectEl.defaultValue || '' : ''; + if (selectEl) selectEl.disabled = true; + + const fd = new FormData(); + fd.append('student_id', String(studentId)); + fd.append('class_section_id', String(classSectionId)); + fd.append('school_year', selectedYear); + + const csrfNameEl = document.getElementById('csrfName'); + const csrfValueEl= document.getElementById('csrfValue'); + if (csrfNameEl && csrfValueEl) { + fd.append(csrfNameEl.value, csrfValueEl.value); + } + + msgEl.textContent = 'Saving candidate assignment...'; + fetch(updateCandidateUrl, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body: fd }) + .then(r => r.json()) + .then(res => { + if (res && res.csrfTokenName && res.csrfHash) { + if (csrfNameEl) csrfNameEl.value = res.csrfTokenName; + if (csrfValueEl) csrfValueEl.value = res.csrfHash; + } + + if (!res || !res.ok) { + if (selectEl && previousValue) selectEl.value = previousValue; + msgEl.textContent = res && res.message ? res.message : 'Candidate assignment could not be saved.'; + return; + } + + msgEl.textContent = res.message || 'Candidate assignment saved.'; + if (selectEl) selectEl.dataset.previousValue = String(classSectionId); + ensureIncludedClassVisible(classId); + loadTotals(); + }) + .catch(() => { + if (selectEl && previousValue) selectEl.value = previousValue; + msgEl.textContent = 'Candidate assignment could not be saved.'; + }) + .finally(() => { + if (selectEl) selectEl.disabled = false; + }); } function loadTotals() { @@ -355,7 +914,6 @@ .then(res => { if (!res || !res.ok) { msgEl.textContent = res && res.message ? res.message : 'Failed to load totals.'; return; } buildInitialTable(res.rows || []); - updateNeeds(); msgEl.textContent = ''; }) .catch(() => { msgEl.textContent = 'Failed to load totals.'; }); @@ -402,6 +960,9 @@ }); refreshBtn.addEventListener('click', function(){ loadTotals(); }); + if (studentSearchInput) { + studentSearchInput.addEventListener('input', applyStudentSearch); + } document.getElementById('generateAllBtn').addEventListener('click', async function(){ const sectionCount = parseInt(sectionCountInput.value || '0', 10); const minStudents = parseInt(minInput.value || '0', 10); @@ -421,8 +982,6 @@ } msgEl.textContent = 'All distributions completed.'; }); - sectionCountInput.addEventListener('input', function(){ updateNeeds(); }); - loadTotals(); })(); diff --git a/app/Views/administrator/student_profiles.php b/app/Views/administrator/student_profiles.php index fe94206..9b308e1 100644 --- a/app/Views/administrator/student_profiles.php +++ b/app/Views/administrator/student_profiles.php @@ -64,6 +64,7 @@ $allergyOptions = $allergyOptions ?? [ ]; $gradeOptions = $gradeOptions ?? ['KG', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', 'Youth']; +$selectedYear = trim((string)($selectedYear ?? '')); ?> extend('layout/management_layout') ?> @@ -279,8 +280,8 @@ $gradeOptions = $gradeOptions ?? ['KG', '1', '2', '3', '4', '5', '6', '7', '8',
- -
Auto-calculated from DOB.
+ +
Auto-calculated from DOB as of Sep 1 of the school year.
@@ -589,32 +590,43 @@ $gradeOptions = $gradeOptions ?? ['KG', '1', '2', '3', '4', '5', '6', '7', '8', // Bind all multi-selects present on the page (all modals rendered server-side) document.querySelectorAll('select.js-multi').forEach(bindSelect); + const fallbackSchoolYear = ; + + function ageAsOfSchoolYearStart(dobValue, schoolYearValue) { + if (!dobValue) return ''; + const yearMatch = String(schoolYearValue || fallbackSchoolYear || '').match(/^(\d{4})/); + if (!yearMatch) return ''; + + const birthParts = dobValue.split('-').map(Number); + if (birthParts.length !== 3 || birthParts.some(Number.isNaN)) return ''; + + const dob = new Date(birthParts[0], birthParts[1] - 1, birthParts[2]); + const cutoff = new Date(Number(yearMatch[1]), 8, 1); + if (Number.isNaN(dob.getTime()) || dob > cutoff) return ''; + + let age = cutoff.getFullYear() - dob.getFullYear(); + const monthDelta = cutoff.getMonth() - dob.getMonth(); + if (monthDelta < 0 || (monthDelta === 0 && cutoff.getDate() < dob.getDate())) age--; + return age >= 0 ? String(age) : ''; + } + // Modal lifecycle: wire age + (re)bind selects inside shown modal document.querySelectorAll('.modal').forEach((modal) => { modal.addEventListener('shown.bs.modal', () => { - // Age from DOB const dob = modal.querySelector('.js-dob'); const age = modal.querySelector('.js-age'); + const schoolYear = modal.querySelector('input[name="school_year"]'); if (dob && age) { const update = () => { - if (!dob.value) { - age.value = ''; - return; - } - const d = new Date(dob.value + 'T00:00:00'); - if (isNaN(d.getTime())) { - age.value = ''; - return; - } - const t = new Date(); - let a = t.getFullYear() - d.getFullYear(); - const m = t.getMonth() - d.getMonth(); - if (m < 0 || (m === 0 && t.getDate() < d.getDate())) a--; - age.value = a >= 0 ? a : ''; + age.value = ageAsOfSchoolYearStart(dob.value, schoolYear ? schoolYear.value : ''); }; update(); dob.addEventListener('change', update); dob.addEventListener('input', update); + if (schoolYear) { + schoolYear.addEventListener('change', update); + schoolYear.addEventListener('input', update); + } } modal.querySelectorAll('select.js-multi').forEach(bindSelect); diff --git a/app/Views/enroll_withdraw/enrollment_withdrawal.php b/app/Views/enroll_withdraw/enrollment_withdrawal.php index 562a53e..52c4874 100644 --- a/app/Views/enroll_withdraw/enrollment_withdrawal.php +++ b/app/Views/enroll_withdraw/enrollment_withdrawal.php @@ -38,6 +38,7 @@ + @@ -78,6 +79,11 @@ + + + - + - + @@ -379,8 +389,8 @@ // Disable sort/search on interactive columns (status select, assign select) columnDefs: [ - { targets: [7, 8], orderable: false, searchable: false }, - { targets: [0, 1, 2, 3, 4, 5, 6], render: function(data, type) { + { targets: [8, 9], orderable: false, searchable: false }, + { targets: [0, 1, 2, 3, 4, 5, 6, 7], render: function(data, type) { if (type === 'filter' || type === 'sort' || type === 'type') { return stripHtml(data); } @@ -460,6 +470,7 @@ const s = norm(status); switch (s) { case 'admission under review': return 'admission under review'; + case 'review & decision': return 'review & decision'; case 'payment pending': return 'payment pending'; case 'enrolled': return 'enrolled'; case 'withdraw under review': return 'withdraw under review'; @@ -559,7 +570,7 @@ btn.disabled = true; if (prog) prog.classList.remove('d-none'); - // Collect tasks: rows where status is 'admission under review' and a class is selected + // Collect tasks: rows where status is a review state and a class is selected const assigns = Array.from(document.querySelectorAll('.assign-class-select')); const tasks = []; const parentSet = new Set(); @@ -570,7 +581,7 @@ const desired = norm(statusSelect?.getAttribute('data-desired') || statusSelect?.value || ''); const classId = sel.value; // Only include rows explicitly set to move to payment pending with a class selected - if (prev === 'admission under review' && desired === 'payment pending' && classId) { + if (['admission under review', 'review & decision'].includes(prev) && desired === 'payment pending' && classId) { const studentId = sel.getAttribute('data-student-id'); const parentId = sel.getAttribute('data-parent-id'); const className = sel.options[sel.selectedIndex]?.text || ''; @@ -579,7 +590,7 @@ } }); - // Collect standalone status updates (not AUR->payment pending) + // Collect standalone status updates (not review-state -> payment pending) const statusTasks = []; const shouldInvoice = new Set(['payment pending','enrolled','withdrawn','refund pending','withdraw under review']); document.querySelectorAll('select.enrollment-status').forEach(se => { @@ -587,7 +598,7 @@ const prev = norm(se.getAttribute('data-prev') || ''); const desired = norm(se.getAttribute('data-desired') || ''); if (!desired || desired === prev) return; - if (prev === 'admission under review' && desired === 'payment pending') return; // handled by tasks + if (['admission under review', 'review & decision'].includes(prev) && desired === 'payment pending') return; // handled by tasks const parentId = se.getAttribute('data-parent-id') || tr?.getAttribute('data-parent-id') || ''; statusTasks.push({ tr, studentId: se.getAttribute('data-student-id'), desired, parentId }); if (parentId && shouldInvoice.has(desired)) parentSet.add(parentId); @@ -672,8 +683,8 @@ const rowData = r.data(); rowData[STATUS_COL_INDEX] = badgeForStatusJs(s.desired); rowData[STATUS_SELECT_COL_INDEX] = buildStatusSelectHtml(s.studentId, s.tr.getAttribute('data-parent-id') || '', s.desired); - if (norm(s.desired) === 'admission under review') { - // leave current assign select as-is + if (['admission under review', 'review & decision'].includes(norm(s.desired))) { + rowData[ASSIGN_COL_INDEX] = buildAssignSelectHtml(s.studentId, s.tr.getAttribute('data-parent-id') || '', ); } else { rowData[ASSIGN_COL_INDEX] = ''; } @@ -688,10 +699,12 @@ statusSelect.removeAttribute('data-desired'); statusSelect.classList.remove('pending-status-select'); } - if (norm(s.desired) !== 'admission under review') { - const cells = s.tr.querySelectorAll('td'); - const assignCell = cells[ASSIGN_COL_INDEX]; - if (assignCell) assignCell.innerHTML = ''; + const cells = s.tr.querySelectorAll('td'); + const assignCell = cells[ASSIGN_COL_INDEX]; + if (assignCell) { + assignCell.innerHTML = ['admission under review', 'review & decision'].includes(norm(s.desired)) + ? buildAssignSelectHtml(s.studentId, s.tr.getAttribute('data-parent-id') || '', ) + : ''; } } showRowToast(s.tr, 'Status updated.'); @@ -749,7 +762,7 @@ function buildStatusSelectHtml(studentId, parentId, current) { const opts = [ - 'admission under review','payment pending','enrolled','withdraw under review','refund pending','withdrawn','waitlist','denied' + 'admission under review','review & decision','payment pending','enrolled','withdraw under review','refund pending','withdrawn','waitlist','denied' ]; let html = ` + placeholder="Add comments or rationale…" + >
-
@@ -243,7 +254,8 @@ if (empty($schoolYears) && $schoolYear !== '') { class="btn btn-sm btn-outline-primary btn-send-email" data-student-id="" data-semester="year" - data-school-year=""> + data-school-year="" + > Send Email diff --git a/app/Views/index.php b/app/Views/index.php index 71bbbfc..ecfab77 100644 --- a/app/Views/index.php +++ b/app/Views/index.php @@ -663,7 +663,7 @@

Our Curriculum and Grade Structure

Our program spans nine structured grade levels, each building upon the previous year's knowledge to ensure a solid foundation in faith, character, and Islamic learning.

Upon completing the 9th grade, students transition into our three-year Youth Program, which emphasizes deeper community engagement, personal development and practical application of Islamic principles. Participation in the Youth Program requires students to be at least 15 years old, ensuring they are mature enough to benefit from its advanced content.

-

Starting this academic year, children must be at least 6 years old by 12-31-2025 to enroll in Grade 1. For younger learners, we are pleased to offer our newly established Kindergarten class, welcoming children who are at least 5 years old by 12-31-2025. This early education program is designed to introduce young minds to the basics of Islamic teachings in a warm, age-appropriate environment.

+

Starting this academic year, children must be at least 6 years old by 09-01-2025 to enroll in Grade 1. For younger learners, we are pleased to offer our newly established Kindergarten class, welcoming children who are at least 5 years old by 12-31-2025. This early education program is designed to introduce young minds to the basics of Islamic teachings in a warm, age-appropriate environment.

Get Started Now diff --git a/app/Views/parent/enroll_classes.php b/app/Views/parent/enroll_classes.php index ef0bdc1..7285ba1 100644 --- a/app/Views/parent/enroll_classes.php +++ b/app/Views/parent/enroll_classes.php @@ -14,6 +14,11 @@ $deadlineObj = (new DateTime($lastDayOfRegistration, new DateTimeZone($tz)))->se $nowObj = new DateTime('now', new DateTimeZone($tz)); $deadlinePassed = $nowObj > $deadlineObj; $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS +$familyFinancialSummary = is_array($familyFinancialSummary ?? null) ? $familyFinancialSummary : []; +$money = static function ($amount) use ($familyFinancialSummary): string { + $currency = (string) ($familyFinancialSummary['currency'] ?? '$'); + return $currency . number_format((float) $amount, 2); +}; ?>
@@ -25,84 +30,46 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
+ +
+
Family Account Information
+
+
+ Previous-year carry-over balance: + +
+
+ Registration fee: + +
+
+ Tuition due now: + +
+
+ Mandatory fees: + +
+
+ Current-year account balance: + +
+
+ Total currently due: + +
+
+ +
+ +
+ + getFlashdata('error')): ?>
getFlashdata('error')) ?>
- '', 'blocking' => false, 'level' => 'info']; - } - - if ($decisionKey === 'make-up exam in fall') { - $dateText = $fallMakeupExamLabel !== null ? ' on ' . $fallMakeupExamLabel : ''; - return [ - 'message' => $name . ' has a make-up exam decision. Enrollment can be completed only after the fall make-up exam' . $dateText . ' and after the result is finalized.', - 'blocking' => true, - 'level' => 'warning', - ]; - } - - if ($decisionKey === 'deferred decision') { - return [ - 'message' => $name . ' has a deferred decision. Enrollment can be completed only after you visit the administration to discuss your child\'s situation.', - 'blocking' => true, - 'level' => 'warning', - ]; - } - - if ($decisionKey === 'repeat class') { - return [ - 'message' => $name . ' has a repeat class decision and is accepted only in ' . $previousClass . '.', - 'blocking' => false, - 'level' => 'info', - ]; - } - - if ($decisionKey === 'expel') { - return [ - 'message' => $name . ' has an expel decision. Enrollment cannot be completed online; please contact the administration.', - 'blocking' => true, - 'level' => 'danger', - ]; - } - - if ($decisionKey === 'withdrawn') { - return [ - 'message' => $name . ' was marked withdrawn in the final decision. Please contact the administration before requesting enrollment.', - 'blocking' => true, - 'level' => 'warning', - ]; - } - - if ($decisionKey === '' || $source === 'pending') { - return [ - 'message' => $name . ' does not have a final promotion decision yet. Enrollment can be completed only after the administration finalizes the decision.', - 'blocking' => true, - 'level' => 'warning', - ]; - } - - return [ - 'message' => $name . ' has a "' . $decision . '" decision. Please contact the administration before requesting enrollment.', - 'blocking' => true, - 'level' => 'warning', - ]; -}; -?> - @@ -118,6 +85,8 @@ $decisionMessageForStudent = static function (array $student) use ($fallMakeupEx + + @@ -125,7 +94,7 @@ $decisionMessageForStudent = static function (array $student) use ($fallMakeupEx $student): ?> - + '', 'blocking' => false, 'level' => 'info']; ?> @@ -141,19 +110,21 @@ $decisionMessageForStudent = static function (array $student) use ($fallMakeupEx : 'Not Assigned'; ?> + + - - - + @@ -363,8 +337,6 @@ $decisionMessageForStudent = static function (array $student) use ($fallMakeupEx // 1) Block clicking "enroll" checkboxes after deadline document.querySelectorAll("input[name='enroll[]']").forEach(cb => { cb.addEventListener("click", function(e) { - hideAllDecisionMessages(); - if (deadlinePassed) { e.preventDefault(); e.stopImmediatePropagation(); diff --git a/app/Views/parent/invoice_payment.php b/app/Views/parent/invoice_payment.php index 10e2fc6..1e7f7ee 100644 --- a/app/Views/parent/invoice_payment.php +++ b/app/Views/parent/invoice_payment.php @@ -59,6 +59,31 @@ if (!function_exists('parseDbDateTime')) { } } } + +if (!function_exists('formatCalendarDateOnly')) { + function formatCalendarDateOnly($raw): ?string + { + if (!$raw) return null; + + if ($raw instanceof DateTimeInterface) { + return $raw->format('m-d-Y'); + } + + $s = trim((string)$raw); + if ($s === '' || preg_match('/^0{4}-0{2}-0{2}/', $s)) return null; + + if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $s, $m)) { + return $m[2] . '-' . $m[3] . '-' . $m[1]; + } + + if (preg_match('/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})/', $s, $m)) { + return sprintf('%02d-%02d-%04d', (int)$m[1], (int)$m[2], (int)$m[3]); + } + + $ts = strtotime($s); + return $ts === false ? null : date('m-d-Y', $ts); + } +} ?>
@@ -71,14 +96,14 @@ if (!function_exists('parseDbDateTime')) {
  • This website release version does not have an option to pay your invoice online. All payments this school year will be made in person on first day of school - (format('m-d-Y') : 'TBD') ?>). + ().
  • Cash, checks and debit/credit cards are all accepted forms of payment. However, if you elect to pay in @@ -109,7 +134,7 @@ $deadline = parseDbDateTime($dueDate, 'UTC', $displayTz); // format either DATE $invoice): ?> @@ -117,7 +142,7 @@ $deadline = parseDbDateTime($dueDate, 'UTC', $displayTz); // format either DATE
- + diff --git a/app/Views/parent/register_student.php b/app/Views/parent/register_student.php index 5b26823..3c0d35f 100644 --- a/app/Views/parent/register_student.php +++ b/app/Views/parent/register_student.php @@ -352,7 +352,8 @@ $closeAtFmt12 = $closeAt->format('m-d-Y g:i A T'); // e.g., 10-01-2025 12:00 AM diff --git a/app/Views/partials/navbar_back.php b/app/Views/partials/navbar_back.php index a55c10d..2c91987 100644 --- a/app/Views/partials/navbar_back.php +++ b/app/Views/partials/navbar_back.php @@ -84,6 +84,7 @@ $role = strtolower(session()->get('role') ?? 'guest'); Attendance ScansClasses ListEmergency Contact + Enrollment AdministrationEnrollment-WithdrawalFlags Management @@ -213,6 +214,7 @@ $role = strtolower(session()->get('role') ?? 'guest'); Classes ListEmergency Contact + Enrollment AdministrationEnrollment-WithdrawalFlags Management @@ -301,6 +303,7 @@ $role = strtolower(session()->get('role') ?? 'guest'); Classes ListEmergency Contact + Enrollment AdministrationEnrollment-WithdrawalFlags Management diff --git a/app/Views/policy/school_policy_partial.php b/app/Views/policy/school_policy_partial.php index de1f495..d315ad8 100644 --- a/app/Views/policy/school_policy_partial.php +++ b/app/Views/policy/school_policy_partial.php @@ -32,7 +32,7 @@ We therefore expect your support and cooperation to achieve the goals and object 'subsections' => [ [ 'title' => 'Registration', - 'body' => 'The school offers a solid curriculum of Islamic Studies and Quran/Arabic through 9 progressive grades, after which the students will join a 3-year rotation youth program with a minimum age rule of at least 15. Starting this year, to be eligible to get into 1st grade, your child needs to be at least 6 years old by 12-31-2025. Younger students will have the possibility to be enrolled in our newly created Kindergarten class if they are at least 5 years old by 12-31-2025. + 'body' => 'The school offers a solid curriculum of Islamic Studies and Quran/Arabic through 9 progressive grades, after which the students will join a 3-year rotation youth program with a minimum age rule of at least 15. Starting this year, to be eligible to get into 1st grade, your child needs to be at least 6 years old by 09-01-2025. Younger students will have the possibility to be enrolled in our newly created Kindergarten class if they are at least 5 years old by 12-31-2025. Registration this year will open from 09-01-2025 to 10-01-2025. We highly encourage parents to enroll their children early so they will not miss out on early classes during the year. By 10-01-2025 at midnight, registration will have closed and parents will not be allowed to register their kids except if the said parents just recently moved to the area from a faraway town/city/state. @@ -40,7 +40,7 @@ The school will make every effort to broadcast the opening of the registration c Please make sure your contact information is correct and up to date especially the phone numbers and emails section because that would be our only way to get in contact with you regarding school matters. Also, please make sure to fill out the known allergies section accurately so that we ensure your kids are never presented with food they could be allergic to. -We only accept students with ages between 5 and 18. For younger students, they must be at least 6 years old by 12-31-2025 to be admitted, there will be no exceptions. Students that are 5 years old by 12-31-2025 can enroll in our newly created Kindergarten class. Students outside of the range described above are considered too young or too old for the activities offered by the school and will not be eligible to enroll with us. Please do not register your kids if they do not meet the criteria described above. +We only accept students with ages between 5 and 18. For younger students, they must be at least 6 years old by 09-01-2025 to be admitted above Kindergarten, there will be no exceptions. Students that are 5 years old by 12-31-2025 can enroll in our newly created Kindergarten class. Students outside of the range described above are considered too young or too old for the activities offered by the school and will not be eligible to enroll with us. Please do not register your kids if they do not meet the criteria described above. After the registration period ends, parents will be invited to join WhatsApp grade groups where their kids are enrolled. These groups allow teachers, parents and administration to stay close and communicate efficiently about all school matters and events.' ], diff --git a/docs/enrollment-phase-6-release-checklist.md b/docs/enrollment-phase-6-release-checklist.md new file mode 100644 index 0000000..2f2efd6 --- /dev/null +++ b/docs/enrollment-phase-6-release-checklist.md @@ -0,0 +1,52 @@ +# Enrollment Phase 6 Release Checklist + +Use this checklist before opening re-enrollment for a target school year. + +## Automated Checks + +Run the release audit: + +```bash +php spark registration:release-audit --school-year=2026-2027 +``` + +For CI or exported review output: + +```bash +php spark registration:release-audit --school-year=2026-2027 --json +``` + +The release audit must show `ready: true` before launch approval. Blocking items must be resolved before registration is opened or registration-opening emails are sent. + +## Required Test Coverage + +- Deliberation decisions: expelled, withdrawn, deferred, passed, repeat class, make-up exam, and historical spelling variants. +- Age rules: parent block at 18 on September 1, allowed under-18 registrations, and adult-student exception paths. +- Placement rules: one-grade promotion, repeat same class, repeat class unavailable flag, make-up provisional placement, and final-grade handling. +- Registration dates: before opening, valid window, after deadline, and logged exceptions. +- Policy and financial presentation: required acknowledgement, policy version retention, family balance once, and child-level balance per child. +- Email communication: consolidated family email, child sections, adult-student instructions, registration dates, delivery failures, and retained sent content. +- Audit records: automatic placements, manual changes, administrative overrides, before-and-after values, and reason fields. + +## Non-Production Validation + +- Configure a non-production school year with registration dates, tuition, policies, class sections, and email template. +- Run historical decision normalization against copied data. +- Run the transition evaluation for the non-production year. +- Preview emails for at least these family cases: passed child, repeat child, make-up exam child, blocked child, adult student, family balance, and multiple children. +- Confirm the administrator dashboard shows open flags, blocked students, failed emails, and launch approval status. + +## Launch Approval + +- A school administrator reviews the release audit output. +- All blocking checks are cleared. +- Any remaining warnings are accepted intentionally. +- Registration launch approval is recorded in the administrator enrollment dashboard. +- Registration-opening emails are sent only after approval. + +## Post-Launch Monitoring + +- Monitor enrollment blocks daily during the first registration week. +- Resolve placement flags and make-up exam flags from the administrator dashboard. +- Review failed registration emails and retry after correcting contact data. +- Compare submitted registrations against expected transition counts. diff --git a/docs/enrollment-phase-7-post-launch-monitoring.md b/docs/enrollment-phase-7-post-launch-monitoring.md new file mode 100644 index 0000000..dbdbb36 --- /dev/null +++ b/docs/enrollment-phase-7-post-launch-monitoring.md @@ -0,0 +1,41 @@ +# Enrollment Phase 7 Post-Launch Monitoring + +Phase 7 begins after registration launch approval and the first registration-opening email send. + +## Daily Monitor + +Run: + +```bash +php spark registration:monitor --school-year=2026-2027 +``` + +Use JSON for dashboards or saved daily snapshots: + +```bash +php spark registration:monitor --school-year=2026-2027 --days=7 --json +``` + +## Review Targets + +- Submitted registrations compared with expected returning students. +- Students blocked by eligibility, adult-student, financial, date, or exception rules. +- Open enrollment flags by type and priority. +- Stale flags older than the selected `--days` window. +- Failed or pending registration-opening emails. +- Recent transition audit actions. + +## Daily Actions + +- Resolve high-priority open flags first. +- Retry failed emails after correcting contact records. +- Follow up with families that received email but have not submitted. +- Confirm make-up exam and manual placement flags do not stay stale. +- Review adult-student and exception-required cases with authorized administrators. + +## Weekly Actions + +- Compare submitted registration counts against the expected transition count. +- Confirm unresolved warnings are still intentional. +- Export or save the JSON monitor output for launch-week audit history. +- Re-run `php spark registration:release-audit --school-year=2026-2027 --json` after major configuration or school-year changes. diff --git a/docs/enrollment-phase-8-closeout-reconciliation.md b/docs/enrollment-phase-8-closeout-reconciliation.md new file mode 100644 index 0000000..39e095a --- /dev/null +++ b/docs/enrollment-phase-8-closeout-reconciliation.md @@ -0,0 +1,53 @@ +# Enrollment Phase 8 Closeout and Reconciliation + +Phase 8 starts when the registration window is ending or after online registration closes. + +## Closeout Report + +Run: + +```bash +php spark registration:closeout-report --school-year=2026-2027 +``` + +Use JSON for saved archive output: + +```bash +php spark registration:closeout-report --school-year=2026-2027 --json +``` + +Export exception rows for spreadsheet review: + +```bash +php spark registration:closeout-report --school-year=2026-2027 --export=/tmp/registration-closeout-2026-2027.csv +``` + +## Closeout Gates + +- Every expected returning student is either submitted, intentionally not returning, withdrawn, expelled, or administratively resolved. +- No admission-under-review registrations remain without an owner. +- No open enrollment placement, make-up exam, age, date, or financial exception flags remain. +- No failed registration-opening email records remain without retry notes or corrected contact data. +- Final class placement counts match administrator-approved class rosters. +- Registration audit history is retained before any school-year status is archived. + +## Exception Review + +The closeout report groups exceptions into: + +- `unsubmitted_returning_student` +- `pending_enrollment` +- `unresolved_flag` +- `failed_email` + +Each row should be assigned to an administrator, resolved, and rechecked before the school year transition is considered complete. + +## Archive Package + +Save these artifacts for the school-year transition record: + +- Phase 6 release audit JSON. +- Phase 7 daily or weekly monitor snapshots. +- Phase 8 closeout report JSON. +- Phase 8 closeout CSV export, if exceptions existed. +- Final administrator approval note for closing registration. diff --git a/public/assets/js/age_validation.js b/public/assets/js/age_validation.js index b05d615..3da8850 100644 --- a/public/assets/js/age_validation.js +++ b/public/assets/js/age_validation.js @@ -2,21 +2,19 @@ export function validateAgeLive(inputEl, minAge = 5, maxAge = 18) { const dob = inputEl.value.trim(); if (!dob) return false; - const birthDate = normalize(new Date(dob)); + const birthDate = parseDateOnly(dob); if (isNaN(birthDate.getTime())) { showFeedback(inputEl, false, "Invalid date format (Use YYYY-MM-DD)"); return false; } const registrationAgeDeadline = window.appConfig?.registrationAgeDeadline; - const deadline = normalize(new Date(registrationAgeDeadline || new Date())); + const schoolYearAgeDeadline = window.appConfig?.schoolYearAgeDeadline || registrationAgeDeadline; + const minimumAgeDeadline = parseDateOnlyOrToday(registrationAgeDeadline); + const ageDeadline = parseDateOnlyOrToday(schoolYearAgeDeadline); - // Calculate boundaries - add one day to effectively make the age calculation more lenient - const adjustedDeadline = new Date(deadline); - adjustedDeadline.setDate(adjustedDeadline.getDate() + 1); - - const minBirthDate = new Date(Date.UTC(adjustedDeadline.getFullYear() - maxAge, adjustedDeadline.getMonth(), adjustedDeadline.getDate())); // oldest - const maxBirthDate = new Date(Date.UTC(adjustedDeadline.getFullYear() - minAge, adjustedDeadline.getMonth(), adjustedDeadline.getDate())); // youngest + const minBirthDate = new Date(Date.UTC(ageDeadline.getUTCFullYear() - maxAge - 1, ageDeadline.getUTCMonth(), ageDeadline.getUTCDate() + 1)); // oldest + const maxBirthDate = new Date(Date.UTC(minimumAgeDeadline.getUTCFullYear() - minAge, minimumAgeDeadline.getUTCMonth(), minimumAgeDeadline.getUTCDate())); // youngest const isValidAge = birthDate >= minBirthDate && birthDate <= maxBirthDate; @@ -34,7 +32,7 @@ export function validateAgeLive(inputEl, minAge = 5, maxAge = 18) { const errorMessage = isValidAge ? "" - : `Must be ${minAge}-${maxAge} years old by ${formatDateMMDDYYYY(registrationAgeDeadline)}.`; + : `Must be at least ${minAge} years old by ${formatDateMMDDYYYY(registrationAgeDeadline)} and no older than ${maxAge} by ${formatDateMMDDYYYY(schoolYearAgeDeadline)}.`; showFeedback(inputEl, isValidAge, errorMessage); return isValidAge; @@ -44,6 +42,31 @@ function normalize(date) { return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); } +function parseDateOnly(value) { + const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) return new Date(NaN); + + const year = Number(match[1]); + const monthIndex = Number(match[2]) - 1; + const day = Number(match[3]); + const date = new Date(Date.UTC(year, monthIndex, day)); + + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== monthIndex || + date.getUTCDate() !== day + ) { + return new Date(NaN); + } + + return date; +} + +function parseDateOnlyOrToday(value) { + const date = parseDateOnly(value); + return isNaN(date.getTime()) ? normalize(new Date()) : date; +} + // Helper function (unchanged) function showFeedback(inputEl, isValid, message) { @@ -57,17 +80,17 @@ function showFeedback(inputEl, isValid, message) { // Set max birthdate (2025 - minAge) window.addEventListener("DOMContentLoaded", () => { const minAge = 5; // Must match validateAgeLive's default - const deadline = window.appConfig?.registrationAgeDeadline - ? new Date(window.appConfig.registrationAgeDeadline) - : new Date(); - const maxBirthYear = deadline.getFullYear() - minAge; - const maxDateStr = `${maxBirthYear}-12-31`; // Latest allowed birthdate + const deadline = parseDateOnlyOrToday(window.appConfig?.schoolYearAgeDeadline); + const registrationAgeDeadline = parseDateOnly(window.appConfig?.registrationAgeDeadline); + const maxDeadline = isNaN(registrationAgeDeadline.getTime()) ? deadline : registrationAgeDeadline; + const maxBirthYear = maxDeadline.getUTCFullYear() - minAge; + const maxDateStr = `${maxBirthYear}-12-31`; // Latest allowed birthdate for age-5 registration grace document.querySelectorAll(".dob-input").forEach((input) => { input.setAttribute("max", maxDateStr); input.setAttribute( "aria-label", - `Born before ${maxBirthYear + 1} (${minAge}+ years by ${deadline.getFullYear()})` + `Born before ${maxBirthYear + 1} (${minAge}+ years by ${maxDeadline.getUTCFullYear()})` ); }); -}); \ No newline at end of file +}); diff --git a/public/assets/js/modal_validation.js b/public/assets/js/modal_validation.js index 8911e0e..fe57831 100644 --- a/public/assets/js/modal_validation.js +++ b/public/assets/js/modal_validation.js @@ -8,12 +8,28 @@ document.addEventListener('DOMContentLoaded', () => { const gradeRE = /^[A-Za-z0-9\s-]{1,20}$/; const MIN_AGE = 5, MAX_AGE = 18; + const parseDateOnly = (value) => { + const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) return new Date(NaN); + return new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))); + }; + + const ageReferenceDate = () => { + const configured = window.appConfig?.schoolYearAgeDeadline; + const configuredDate = parseDateOnly(configured); + if (!isNaN(configuredDate.getTime())) return configuredDate; + + const now = new Date(); + const year = now.getMonth() >= 8 ? now.getFullYear() : now.getFullYear() - 1; + return new Date(Date.UTC(year, 8, 1)); + }; + const calcAge = (d) => { - const dob = new Date(d); - const today = new Date(); - let a = today.getFullYear() - dob.getFullYear(); - const m = today.getMonth() - dob.getMonth(); - if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) a--; + const dob = parseDateOnly(d); + const reference = ageReferenceDate(); + let a = reference.getUTCFullYear() - dob.getUTCFullYear(); + const m = reference.getUTCMonth() - dob.getUTCMonth(); + if (m < 0 || (m === 0 && reference.getUTCDate() < dob.getUTCDate())) a--; return a; }; diff --git a/tests/app/Controllers/View/ParentControllerAgeTest.php b/tests/app/Controllers/View/ParentControllerAgeTest.php index 721b4b2..43df358 100644 --- a/tests/app/Controllers/View/ParentControllerAgeTest.php +++ b/tests/app/Controllers/View/ParentControllerAgeTest.php @@ -3,12 +3,13 @@ namespace Tests\App\Controllers\View; use App\Controllers\View\ParentController; +use App\Support\Enrollment\DeliberationDecision; use CodeIgniter\Test\CIUnitTestCase; use ReflectionMethod; final class ParentControllerAgeTest extends CIUnitTestCase { - public function testEnrollmentAgeUsesSchoolYearStartYearCutoff(): void + public function testEnrollmentAgeUsesSeptemberFirstSchoolYearCutoff(): void { $controller = new class extends ParentController { public function __construct() @@ -19,8 +20,9 @@ final class ParentControllerAgeTest extends CIUnitTestCase $method = new ReflectionMethod(ParentController::class, 'calculateAgeAsOfSchoolYearStartYear'); $method->setAccessible(true); - $this->assertSame(6, $method->invoke($controller, '2019-09-15', '2025-2026')); + $this->assertSame(5, $method->invoke($controller, '2019-09-15', '2025-2026')); $this->assertSame(5, $method->invoke($controller, '2020-01-01', '2025-2026')); + $this->assertSame(4, $method->invoke($controller, '2020-09-02', '2025-2026')); } public function testEnrollmentAgeRejectsInvalidInputs(): void @@ -39,4 +41,127 @@ final class ParentControllerAgeTest extends CIUnitTestCase $this->assertNull($method->invoke($controller, '2026-01-01', '2025-2026')); $this->assertNull($method->invoke($controller, '2019-09-15', '')); } + + public function testRegistrationValidationUsesDecemberThirtyFirstOnlyForMinimumAgeGrace(): void + { + $controller = new class extends ParentController { + public function __construct() + { + } + }; + + $this->assertTrue($controller->validateDobAge('2020-12-31', '2025-12-31', 5, 18, '2025-09-01')['isValid']); + $this->assertFalse($controller->validateDobAge('2021-01-01', '2025-12-31', 5, 18, '2025-09-01')['isValid']); + $this->assertTrue($controller->validateDobAge('2006-09-02', '2025-12-31', 5, 18, '2025-09-01')['isValid']); + $this->assertFalse($controller->validateDobAge('2006-08-31', '2025-12-31', 5, 18, '2025-09-01')['isValid']); + } + + public function testEnrollmentEligibilityBlocksFinalDecisionStatuses(): void + { + $controller = new class extends ParentController { + public function __construct() + { + } + }; + + $method = new ReflectionMethod(ParentController::class, 'enrollmentEligibilityMessageForStudent'); + $method->setAccessible(true); + + foreach (['expel', 'withdrawn', 'deferred decision'] as $decision) { + $message = $method->invoke( + $controller, + ['firstname' => 'Ali', 'lastname' => 'Ahmed', 'dob' => '2010-01-01'], + ['decision' => $decision, 'source' => 'manual', 'class_section_name' => 'Level 5'], + '2025-2026', + null + ); + + $this->assertTrue($message['blocking'], $decision . ' should block enrollment.'); + $this->assertNotSame('', $message['message']); + } + } + + public function testEnrollmentEligibilityBlocksAdultStudentsOnSeptemberFirst(): void + { + $controller = new class extends ParentController { + public function __construct() + { + } + }; + + $method = new ReflectionMethod(ParentController::class, 'enrollmentEligibilityMessageForStudent'); + $method->setAccessible(true); + + $message = $method->invoke( + $controller, + ['firstname' => 'Ali', 'lastname' => 'Ahmed', 'dob' => '2006-08-31'], + ['decision' => 'pass', 'source' => 'manual', 'class_section_name' => 'Level 9'], + '2025-2026', + null + ); + + $this->assertTrue($message['blocking']); + $this->assertStringContainsString('18 years old or older on September 1', $message['message']); + $this->assertStringContainsString('A parent or guardian cannot complete registration', $message['message']); + } + + public function testEnrollmentEligibilityAllowsFallMakeupExamWithWarning(): void + { + $controller = new class extends ParentController { + public function __construct() + { + } + }; + + $method = new ReflectionMethod(ParentController::class, 'enrollmentEligibilityMessageForStudent'); + $method->setAccessible(true); + + $message = $method->invoke( + $controller, + ['firstname' => 'Ali', 'lastname' => 'Ahmed', 'dob' => '2010-01-01'], + ['decision' => 'Make Up Exam in Fall', 'source' => 'manual', 'class_section_name' => 'Level 5'], + '2025-2026', + '2025-09-10' + ); + + $this->assertFalse($message['blocking']); + $this->assertSame('warning', $message['level']); + $this->assertStringContainsString('will initially remain in the same grade', $message['message']); + } + + public function testRequiredActionDoesNotDefaultToContactAdministration(): void + { + $controller = new class extends ParentController { + public function __construct() + { + } + }; + + $method = new ReflectionMethod(ParentController::class, 'requiredActionLabel'); + $method->setAccessible(true); + + $this->assertSame( + 'Complete re-enrollment before the registration deadline.', + $method->invoke($controller, null) + ); + + $this->assertSame( + 'Complete re-enrollment before the registration deadline.', + $method->invoke($controller, [ + 'blockers' => [], + 'deliberation_decision' => DeliberationDecision::PASSED, + 'parent_enrollment_allowed' => true, + ]) + ); + + $this->assertSame( + 'Contact the school administration.', + $method->invoke($controller, [ + 'blockers' => ['Final decision is pending.'], + 'deliberation_decision' => DeliberationDecision::DEFERRED_DECISION, + 'parent_enrollment_allowed' => false, + ]) + ); + } + } diff --git a/tests/app/Filters/SchoolYearWritableFilterTest.php b/tests/app/Filters/SchoolYearWritableFilterTest.php index 3c112bf..96cf5e4 100644 --- a/tests/app/Filters/SchoolYearWritableFilterTest.php +++ b/tests/app/Filters/SchoolYearWritableFilterTest.php @@ -15,14 +15,43 @@ use Config\Services; final class SchoolYearWritableFilterFakeModel extends SchoolYearModel { + private array $filters = []; + public function __construct(private readonly array $row) { } + public function find($id = null) + { + return ((int) $id === (int) ($this->row['id'] ?? 0)) ? $this->row : null; + } + public function active(): ?array { return $this->row; } + + public function where($key, $value = null, ?bool $escape = null) + { + $this->filters[] = [$key, $value]; + + return $this; + } + + public function first() + { + foreach ($this->filters as [$key, $value]) { + if (($this->row[$key] ?? null) !== $value) { + $this->filters = []; + + return null; + } + } + + $this->filters = []; + + return $this->row; + } } final class SchoolYearWritableFilterTest extends CIUnitTestCase @@ -59,6 +88,21 @@ final class SchoolYearWritableFilterTest extends CIUnitTestCase $this->assertNull((new SchoolYearWritableFilter())->before($request)); } + public function testPostBodySchoolYearAgainstClosedYearIsBlocked(): void + { + $this->useSchoolYearContext(['id' => 1, 'name' => '2025-2026', 'status' => 'closed']); + + $request = $this->request('POST', 'https://example.test/grading/below-60/decisions/save'); + $request->setHeader('Accept', 'application/json'); + $request->setGlobal('post', ['school_year' => '2025-2026']); + + $result = (new SchoolYearWritableFilter())->before($request); + + $this->assertInstanceOf(Response::class, $result); + $this->assertSame(409, $result->getStatusCode()); + $this->assertStringContainsString('Read-only school year', $result->getBody()); + } + public function testSchoolYearSelectionPostIsExempt(): void { $this->useSchoolYearContext(['id' => 1, 'name' => '2025-2026', 'status' => 'closed']); diff --git a/tests/app/Services/EnrollmentRegistrationEmailServiceTest.php b/tests/app/Services/EnrollmentRegistrationEmailServiceTest.php new file mode 100644 index 0000000..8f23206 --- /dev/null +++ b/tests/app/Services/EnrollmentRegistrationEmailServiceTest.php @@ -0,0 +1,142 @@ +service(); + + $evaluation = [ + 'deliberation_decision' => DeliberationDecision::PASSED, + 'placement_status' => 'exit_required', + 'source_grade_name' => '8', + 'adult_student' => false, + 'blockers' => [ + 'The student has passed the highest available grade and must follow the school completion or exit process.', + 'Registration for the new school year has not opened yet.', + ], + ]; + + $status = $this->invoke($service, 'registrationStatus', [$evaluation]); + $message = $this->invoke($service, 'decisionMessage', ['Student Name', $evaluation, 'August 1, 2026', 'August 31, 2026']); + $action = $this->invoke($service, 'requiredAction', [$evaluation, 'August 31, 2026']); + + $this->assertSame('Eligible', $status); + $this->assertStringContainsString('We are pleased to inform you that Student Name has successfully passed Grade 8.', $message); + $this->assertStringContainsString('Re-enrollment for the new school year will open on August 1, 2026.', $message); + $this->assertStringContainsString('Please make sure to re-enroll your child before August 31, 2026', $message); + $this->assertStringContainsString('sign in to the parent portal', $message); + $this->assertStringContainsString('Complete re-enrollment before August 31, 2026.', $action); + $this->assertStringNotContainsString('Not Eligible', $status); + $this->assertStringNotContainsString('Registration for the new school year has not opened yet', $message); + $this->assertStringNotContainsString('Contact the school administration.', $action); + } + + public function testFinancialSectionIsHiddenWhenNothingIsDue(): void + { + $service = $this->service(); + + $html = $this->invoke($service, 'financialSection', [10, [ + 'registration_fee' => 0, + 'tuition_due_at_registration' => 0, + 'mandatory_fees' => 0, + ], null]); + + $this->assertSame('', $html); + } + + public function testFinancialSectionShowsWhenAnyAmountIsDue(): void + { + $service = $this->service(); + + $html = $this->invoke($service, 'financialSection', [10, [ + 'registration_fee' => 25, + 'tuition_due_at_registration' => 0, + 'mandatory_fees' => 0, + ], null]); + + $this->assertStringContainsString('Family Account Information', $html); + $this->assertStringContainsString('Total currently due: $25.00', $html); + } + + public function testAutomaticDistributionPlacementShowsOnlyGrade(): void + { + $service = $this->service(); + + $placement = $this->invoke($service, 'placementText', [[ + 'placement_status' => 'automatic_distribution_pending', + 'assigned_grade_name' => '9', + ]]); + + $this->assertSame('Grade 9', $placement); + } + + public function testForceCannotSendWithoutAdminLaunchApproval(): void + { + $emailService = $this->createMock(EmailService::class); + $emailService->expects($this->never())->method('send'); + $service = $this->service($this->dbWithNoRecipientFamilies(), $emailService); + + $summary = $service->sendForSchoolYear([ + 'name' => '2026-2027', + 'registration_launch_approved_at' => null, + ], null, false, true); + + $this->assertSame(0, $summary['sent']); + $this->assertSame(1, $summary['skipped']); + $this->assertStringContainsString('not approved', implode(' ', $summary['messages'])); + } + + /** + * @param list $args + */ + private function invoke(EnrollmentRegistrationEmailService $service, string $method, array $args): mixed + { + $reflection = new \ReflectionMethod($service, $method); + $reflection->setAccessible(true); + + return $reflection->invokeArgs($service, $args); + } + + private function service(?BaseConnection $db = null, ?EmailService $emailService = null): EnrollmentRegistrationEmailService + { + $db ??= $this->createMock(BaseConnection::class); + + return new EnrollmentRegistrationEmailService( + $db, + new EnrollmentTransitionService($db), + $emailService ?? $this->createMock(EmailService::class), + ); + } + + private function dbWithNoRecipientFamilies(): BaseConnection + { + $query = new class { + public function getResultArray(): array + { + return []; + } + }; + + $builder = $this->createMock(BaseBuilder::class); + $builder->method('select')->willReturnSelf(); + $builder->method('where')->willReturnSelf(); + $builder->method('get')->willReturn($query); + + $db = $this->createMock(BaseConnection::class); + $db->method('table')->willReturn($builder); + $db->method('fieldExists')->with('user_type', 'users')->willReturn(false); + + return $db; + } +} diff --git a/tests/app/Services/EnrollmentTransitionServiceTest.php b/tests/app/Services/EnrollmentTransitionServiceTest.php new file mode 100644 index 0000000..7eb4cf9 --- /dev/null +++ b/tests/app/Services/EnrollmentTransitionServiceTest.php @@ -0,0 +1,104 @@ +serviceExpectingClassLookup('9', ['id' => 9, 'class_name' => '9']); + + $result = $this->invoke($service, 'nextClass', ['Class 8', '2026-2027']); + + $this->assertSame(9, (int) $result['id']); + } + + public function testPassedClassNinePromotesToTenWhenGradeTenExists(): void + { + $service = $this->serviceExpectingClassLookup('10', ['id' => 10, 'class_name' => '10']); + + $result = $this->invoke($service, 'nextClass', ['Class 9', '2026-2027']); + + $this->assertSame(10, (int) $result['id']); + } + + public function testClassLookupFallsBackToGlobalRowsWhenSchoolYearSpecificRowIsMissing(): void + { + $firstBuilder = $this->builderReturning(null); + $firstBuilder->expects($this->exactly(2)) + ->method('where') + ->willReturnSelf(); + + $fallbackBuilder = $this->builderReturning(['id' => 10, 'class_name' => '10']); + $fallbackBuilder->expects($this->once()) + ->method('where') + ->with('UPPER(class_name)', '10') + ->willReturnSelf(); + + $db = $this->createMock(BaseConnection::class); + $db->expects($this->exactly(2)) + ->method('table') + ->with('classes') + ->willReturnOnConsecutiveCalls($firstBuilder, $fallbackBuilder); + $db->method('fieldExists')->with('school_year', 'classes')->willReturn(true); + + $service = new EnrollmentTransitionService($db); + + $result = $this->invoke($service, 'nextClass', ['Class 9', '2026-2027']); + + $this->assertSame(10, (int) $result['id']); + } + + private function serviceExpectingClassLookup(string $expectedClassName, array $row): EnrollmentTransitionService + { + $builder = $this->builderReturning($row); + $builder->expects($this->once()) + ->method('where') + ->with('UPPER(class_name)', $expectedClassName) + ->willReturnSelf(); + + $db = $this->createMock(BaseConnection::class); + $db->expects($this->once()) + ->method('table') + ->with('classes') + ->willReturn($builder); + $db->method('fieldExists')->with('school_year', 'classes')->willReturn(false); + + return new EnrollmentTransitionService($db); + } + + private function builderReturning(?array $row): BaseBuilder + { + $builder = $this->createMock(BaseBuilder::class); + $builder->method('orderBy')->willReturnSelf(); + $builder->method('limit')->willReturnSelf(); + $builder->method('get')->willReturn(new class($row) { + public function __construct(private readonly ?array $row) + { + } + + public function getRowArray(): ?array + { + return $this->row; + } + }); + + return $builder; + } + + /** + * @param list $args + */ + private function invoke(EnrollmentTransitionService $service, string $method, array $args): mixed + { + $reflection = new \ReflectionMethod($service, $method); + $reflection->setAccessible(true); + + return $reflection->invokeArgs($service, $args); + } +} diff --git a/tests/app/Services/SchoolYearContextServiceTest.php b/tests/app/Services/SchoolYearContextServiceTest.php index e9bd8c5..9087d47 100644 --- a/tests/app/Services/SchoolYearContextServiceTest.php +++ b/tests/app/Services/SchoolYearContextServiceTest.php @@ -12,9 +12,14 @@ use Config\App; final class SchoolYearContextRequest extends MockIncomingRequest { - public function __construct(private readonly array $gets = []) + public function __construct( + private readonly array $gets = [], + private readonly array $posts = [], + string $method = 'GET' + ) { parent::__construct(config(App::class), new URI('https://test.alrahmaisgl.org'), 'php://input', new UserAgent()); + $this->setMethod($method); } public function getGet($key = null, $filter = null, $default = null) @@ -25,6 +30,15 @@ final class SchoolYearContextRequest extends MockIncomingRequest return $this->gets[$key] ?? $default; } + + public function getPost($index = null, $filter = null, $flags = null) + { + if ($index === null) { + return $this->posts; + } + + return $this->posts[$index] ?? null; + } } final class SchoolYearContextFakeModel extends SchoolYearModel @@ -79,6 +93,11 @@ final class SchoolYearContextFakeModel extends SchoolYearModel return $limit !== null ? array_slice($rows, $offset, $limit) : array_slice($rows, $offset); } + + public function first() + { + return $this->findAll(1)[0] ?? null; + } } final class SchoolYearContextServiceTest extends CIUnitTestCase @@ -110,6 +129,56 @@ final class SchoolYearContextServiceTest extends CIUnitTestCase $this->assertSame(1, session()->get('selected_school_year_id')); } + public function testPostSchoolYearNameTakesPrecedenceOverActiveYearForWrites(): void + { + $service = new SchoolYearContextService(new SchoolYearContextFakeModel([ + 1 => ['id' => 1, 'name' => '2025-2026', 'status' => 'closed'], + 2 => ['id' => 2, 'name' => '2026-2027', 'status' => 'active'], + ])); + + $context = $service->resolve(new SchoolYearContextRequest( + posts: ['school_year' => '2025-2026'], + method: 'POST' + )); + + $this->assertSame(1, $context->id()); + $this->assertSame('2025-2026', $context->yearName()); + $this->assertTrue($context->isReadonly()); + $this->assertTrue($context->isExplicitSelection()); + } + + public function testPostSchoolYearIdTakesPrecedenceOverActiveYearForWrites(): void + { + $service = new SchoolYearContextService(new SchoolYearContextFakeModel([ + 1 => ['id' => 1, 'name' => '2025-2026', 'status' => 'closed'], + 2 => ['id' => 2, 'name' => '2026-2027', 'status' => 'active'], + ])); + + $context = $service->resolve(new SchoolYearContextRequest( + posts: ['school_year_id' => '1'], + method: 'POST' + )); + + $this->assertSame(1, $context->id()); + $this->assertSame('2025-2026', $context->yearName()); + $this->assertTrue($context->isReadonly()); + } + + public function testCanResolveContextFromStoredSchoolYearName(): void + { + $service = new SchoolYearContextService(new SchoolYearContextFakeModel([ + 1 => ['id' => 1, 'name' => '2025-2026', 'status' => 'closed'], + 2 => ['id' => 2, 'name' => '2026-2027', 'status' => 'active'], + ])); + + $context = $service->forYearName('2025-2026'); + + $this->assertSame(1, $context->id()); + $this->assertSame('2025-2026', $context->yearName()); + $this->assertTrue($context->isReadonly()); + $this->assertTrue($context->isExplicitSelection()); + } + public function testInvalidSessionSelectionFallsBackToActiveYearAndClearsSession(): void { session()->set('selected_school_year_id', 99); diff --git a/tests/app/Support/Enrollment/DeliberationDecisionTest.php b/tests/app/Support/Enrollment/DeliberationDecisionTest.php new file mode 100644 index 0000000..2c10288 --- /dev/null +++ b/tests/app/Support/Enrollment/DeliberationDecisionTest.php @@ -0,0 +1,36 @@ + DeliberationDecision::PASSED, + 'Passed' => DeliberationDecision::PASSED, + 'Repeat Class' => DeliberationDecision::REPEAT_CLASS, + 'Make-up exam in fall' => DeliberationDecision::MAKE_UP_EXAM, + 'Expel' => DeliberationDecision::EXPELLED, + 'Expelled' => DeliberationDecision::EXPELLED, + 'Withdrawn' => DeliberationDecision::WITHDRAWN, + 'Withdraw' => DeliberationDecision::WITHDRAWN, + 'Widthrwan' => DeliberationDecision::WITHDRAWN, + 'Deferred' => DeliberationDecision::DEFERRED_DECISION, + 'Deferred decision' => DeliberationDecision::DEFERRED_DECISION, + ]; + + foreach ($cases as $input => $expected) { + $this->assertSame($expected, DeliberationDecision::normalize($input), $input); + } + } + + public function testBlankAndUnknownDecisionsRemainUnmapped(): void + { + $this->assertNull(DeliberationDecision::normalize('')); + $this->assertNull(DeliberationDecision::normalize('Teacher review needed')); + } +} diff --git a/tests/app/Support/Enrollment/EnrollmentEligibilityTest.php b/tests/app/Support/Enrollment/EnrollmentEligibilityTest.php new file mode 100644 index 0000000..dec492f --- /dev/null +++ b/tests/app/Support/Enrollment/EnrollmentEligibilityTest.php @@ -0,0 +1,78 @@ + 'Adult', 'lastname' => 'Student', 'dob' => '2008-09-01'], + ['decision' => 'Pass', 'source' => 'manual'], + '2026-2027' + ); + + $this->assertTrue($message['blocking']); + $this->assertStringContainsString('18 years old or older on September 1', $message['message']); + } + + public function testParentEnrollmentIsAllowedWhenStudentTurnsEighteenAfterSeptemberFirst(): void + { + $message = EnrollmentEligibility::parentDecisionMessage( + ['firstname' => 'Minor', 'lastname' => 'Student', 'dob' => '2008-09-02'], + ['decision' => 'Pass', 'source' => 'manual'], + '2026-2027' + ); + + $this->assertFalse($message['blocking']); + } + + public function testBlockedDecisionMessagesUseRequiredAdministrativeText(): void + { + $expelled = EnrollmentEligibility::parentDecisionMessage( + ['firstname' => 'A', 'lastname' => 'B', 'dob' => '2010-01-01'], + ['decision' => 'Expel', 'source' => 'manual'], + '2026-2027' + ); + $withdrawn = EnrollmentEligibility::parentDecisionMessage( + ['firstname' => 'A', 'lastname' => 'B', 'dob' => '2010-01-01'], + ['decision' => 'Widthrwan', 'source' => 'manual'], + '2026-2027' + ); + $deferred = EnrollmentEligibility::parentDecisionMessage( + ['firstname' => 'A', 'lastname' => 'B', 'dob' => '2010-01-01'], + ['decision' => 'Deferred', 'source' => 'manual'], + '2026-2027' + ); + + $this->assertTrue($expelled['blocking']); + $this->assertTrue($withdrawn['blocking']); + $this->assertTrue($deferred['blocking']); + $this->assertStringContainsString('A B', $expelled['message']); + $this->assertStringContainsString('A B', $withdrawn['message']); + $this->assertStringContainsString('A B', $deferred['message']); + $this->assertStringContainsString(EnrollmentEligibility::EXPELLED_MESSAGE, $expelled['message']); + $this->assertStringContainsString(EnrollmentEligibility::WITHDRAWN_MESSAGE, $withdrawn['message']); + $this->assertStringContainsString(EnrollmentEligibility::DEFERRED_MESSAGE, $deferred['message']); + $this->assertStringContainsString('decision is expelled', $expelled['message']); + $this->assertStringContainsString('decision is withdrawn', $withdrawn['message']); + $this->assertStringContainsString('decision is deferred', $deferred['message']); + } + + public function testPendingSourceUsesMissingDecisionMessageNotDeferredDecisionMessage(): void + { + $message = EnrollmentEligibility::parentDecisionMessage( + ['firstname' => 'A', 'lastname' => 'B', 'dob' => '2010-01-01'], + ['decision' => '', 'source' => 'pending'], + '2026-2027' + ); + + $this->assertTrue($message['blocking']); + $this->assertStringContainsString('A B', $message['message']); + $this->assertStringContainsString(EnrollmentEligibility::MISSING_DECISION_MESSAGE, $message['message']); + $this->assertStringNotContainsString(EnrollmentEligibility::DEFERRED_MESSAGE, $message['message']); + } +}
Class TotalSectionsActions Distribution
Registration Date Parent/Guardian Student NameAge New Student Removed (Prior Years) Current Class + + @@ -109,6 +115,9 @@ case 'admission under review': echo 'admission under review'; break; + case 'review & decision': + echo 'review & decision'; + break; case 'payment pending': echo 'payment pending'; break; @@ -145,6 +154,7 @@ data-parent-id="" data-prev=""> + @@ -155,9 +165,9 @@ - +
No students available.No students available.
Age Gender GradeDecisionRequired Action Enroll Withdraw Status
- + > - + @@ -180,6 +151,9 @@ $decisionMessageForStudent = static function (array $student) use ($fallMakeupEx case 'admission under review': echo 'admission under review'; break; + case 'review & decision': + echo 'review & decision'; + break; case 'payment pending': echo 'payment pending'; break; @@ -210,11 +184,11 @@ $decisionMessageForStudent = static function (array $student) use ($fallMakeupEx ?>
-
- + +
+
+
format('m-d-Y h:i A')) : '—' ?>format('m-d-Y')) : '—' ?> $ $