This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
class EnrollmentCloseoutReport extends BaseCommand
|
||||
{
|
||||
protected $group = 'Registration';
|
||||
protected $name = 'registration:closeout-report';
|
||||
protected $description = 'Generate end-of-registration reconciliation and closeout exception lists.';
|
||||
protected $usage = 'php spark registration:closeout-report [--school-year=2026-2027] [--json] [--export=/tmp/registration-closeout.csv]';
|
||||
protected $options = [
|
||||
'--school-year' => '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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
class EnrollmentPostLaunchMonitor extends BaseCommand
|
||||
{
|
||||
protected $group = 'Registration';
|
||||
protected $name = 'registration:monitor';
|
||||
protected $description = 'Monitor post-launch enrollment progress, blocks, flags, and email delivery.';
|
||||
protected $usage = 'php spark registration:monitor [--school-year=2026-2027] [--days=7] [--json]';
|
||||
protected $options = [
|
||||
'--school-year' => '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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
class EnrollmentReleaseAudit extends BaseCommand
|
||||
{
|
||||
protected $group = 'Registration';
|
||||
protected $name = 'registration:release-audit';
|
||||
protected $description = 'Audit school-year transition and registration readiness before launch.';
|
||||
protected $usage = 'php spark registration:release-audit [--school-year=2026-2027] [--json]';
|
||||
protected $options = [
|
||||
'--school-year' => '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');
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+10
-1
@@ -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');
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
class EnrollmentAdminController extends BaseController
|
||||
{
|
||||
protected BaseConnection $db;
|
||||
|
||||
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
|
||||
{
|
||||
parent::initController($request, $response, $logger);
|
||||
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.');
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+188
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use App\Support\Enrollment\DeliberationDecision;
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddSchoolYearTransitionEnrollmentFields extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if ($this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateEnrollmentPhaseTwoTables extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if ($this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddRegistrationExperienceFinancialFields extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (! $this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateEnrollmentEmailRecords extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if ($this->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddEnrollmentAdministrationNavItem extends Migration
|
||||
{
|
||||
private string $label = 'Enrollment Administration';
|
||||
private string $url = 'administrator/enrollment-admin';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (! $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class SyncEnrollmentAdministrationNavRoles extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! $this->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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddReviewDecisionEnrollmentStatus extends Migration
|
||||
{
|
||||
private const STATUSES_WITH_REVIEW_DECISION = [
|
||||
'admission under review',
|
||||
'review & decision',
|
||||
'payment pending',
|
||||
'enrolled',
|
||||
'withdraw under review',
|
||||
'refund pending',
|
||||
'withdrawn',
|
||||
'waitlist',
|
||||
'denied',
|
||||
];
|
||||
|
||||
private const STATUSES_WITHOUT_REVIEW_DECISION = [
|
||||
'admission under review',
|
||||
'payment pending',
|
||||
'enrolled',
|
||||
'withdraw under review',
|
||||
'refund pending',
|
||||
'withdrawn',
|
||||
'waitlist',
|
||||
'denied',
|
||||
];
|
||||
|
||||
public function up()
|
||||
{
|
||||
$this->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'"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class EnrollmentAgeRuleModel extends Model
|
||||
{
|
||||
protected $table = 'enrollment_age_rules';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
|
||||
protected $allowedFields = [
|
||||
'school_year',
|
||||
'campus',
|
||||
'grade_class_id',
|
||||
'education_level',
|
||||
'minimum_age',
|
||||
'maximum_age',
|
||||
'age_reference_date',
|
||||
'behavior',
|
||||
'exceptions_allowed',
|
||||
'exception_roles',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class EnrollmentEmailRecordModel extends Model
|
||||
{
|
||||
protected $table = 'enrollment_email_records';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
|
||||
protected $allowedFields = [
|
||||
'school_year',
|
||||
'school_year_id',
|
||||
'family_account_id',
|
||||
'parent_user_id',
|
||||
'recipient_addresses_json',
|
||||
'template_version',
|
||||
'generated_subject',
|
||||
'generated_body',
|
||||
'student_ids_included_json',
|
||||
'generated_at',
|
||||
'sent_at',
|
||||
'delivery_status',
|
||||
'failure_reason',
|
||||
'retry_count',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class EnrollmentFlagModel extends Model
|
||||
{
|
||||
protected $table = 'enrollment_flags';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = false;
|
||||
|
||||
protected $allowedFields = [
|
||||
'flag_type',
|
||||
'student_id',
|
||||
'school_year',
|
||||
'source_school_year',
|
||||
'status',
|
||||
'priority',
|
||||
'assigned_to',
|
||||
'details_json',
|
||||
'created_at',
|
||||
'resolved_at',
|
||||
'resolution_notes',
|
||||
];
|
||||
}
|
||||
@@ -17,6 +17,22 @@ class EnrollmentModel extends Model
|
||||
'class_section_id',
|
||||
'parent_id',
|
||||
'school_year',
|
||||
'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',
|
||||
'enrollment_date',
|
||||
'enrollment_status',
|
||||
'withdrawal_date',
|
||||
@@ -36,10 +52,23 @@ class EnrollmentModel extends Model
|
||||
'class_section_id' => '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',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class EnrollmentTransitionAuditModel extends Model
|
||||
{
|
||||
protected $table = 'enrollment_transition_audits';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = false;
|
||||
|
||||
protected $allowedFields = [
|
||||
'student_id',
|
||||
'school_year',
|
||||
'source_school_year',
|
||||
'action',
|
||||
'performed_by',
|
||||
'original_values_json',
|
||||
'new_values_json',
|
||||
'reason',
|
||||
'created_at',
|
||||
];
|
||||
}
|
||||
@@ -19,6 +19,21 @@ class SchoolYearModel extends Model
|
||||
'description',
|
||||
'registration_starts_on',
|
||||
'registration_ends_on',
|
||||
'registration_opens_at',
|
||||
'registration_deadline_at',
|
||||
'late_registration_blocked',
|
||||
'administrative_exceptions_permitted',
|
||||
'registration_exception_roles',
|
||||
'adult_student_registration_enabled',
|
||||
'registration_fee',
|
||||
'tuition_due_at_registration',
|
||||
'mandatory_fees',
|
||||
'carry_over_balance_behavior',
|
||||
'financial_policy_message',
|
||||
'payment_plan_available',
|
||||
'registration_launch_approved_at',
|
||||
'registration_launch_approved_by',
|
||||
'registration_email_template_version',
|
||||
'fall_makeup_exam_on',
|
||||
'previous_school_year_id',
|
||||
'next_school_year_id',
|
||||
@@ -37,6 +52,18 @@ class SchoolYearModel extends Model
|
||||
'ends_on' => '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]',
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\Enrollment\DeliberationDecision;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use DateTimeInterface;
|
||||
|
||||
final class EnrollmentRegistrationEmailService
|
||||
{
|
||||
public const TEMPLATE_VERSION = 'phase5_consolidated_v1';
|
||||
|
||||
public function __construct(
|
||||
private readonly BaseConnection $db,
|
||||
private readonly EnrollmentTransitionService $transitionService,
|
||||
private readonly EmailService $emailService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function sendForDate(DateTimeInterface $date, bool $force = false, ?string $testEmail = null, bool $dryRun = false): array
|
||||
{
|
||||
$summary = [
|
||||
'school_years' => 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 = '<p>Dear ' . esc($family['name']) . ',</p>'
|
||||
. '<p>We are pleased to welcome your family to the registration process for the ' . esc($schoolYearName) . ' school year.</p>'
|
||||
. '<p>Registration opens on ' . esc($opens) . ' and closes on ' . esc($deadline) . '.</p>'
|
||||
. '<p>Please review the information below for each of your children, including the deliberation decision, registration eligibility, expected academic placement, and any required action.</p>'
|
||||
. implode('', $students)
|
||||
. $financial
|
||||
. '<p>To complete registration for each eligible student:</p>'
|
||||
. '<ol><li>Sign in to the parent portal.</li><li>Review and update student information.</li><li>Upload required documents.</li><li>Review and acknowledge school policies.</li><li>Review tuition, fees, and any carry-over balance.</li><li>Submit registration before ' . esc($deadline) . '.</li></ol>'
|
||||
. '<p>Registration portal: <a href="' . esc(site_url('parent/enroll_classes')) . '">' . esc(site_url('parent/enroll_classes')) . '</a></p>'
|
||||
. '<p>For assistance, please contact the school administration.</p>'
|
||||
. '<p>Sincerely,<br>Al Rahma Sunday School<br>School Administration</p>';
|
||||
|
||||
$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 '<h3>' . esc($name) . '</h3>'
|
||||
. '<p><strong>Deliberation decision:</strong> ' . esc($decision ?: 'Pending') . '<br>'
|
||||
. '<strong>Registration status:</strong> ' . esc($status) . '<br>'
|
||||
. '<strong>Expected placement:</strong> ' . esc($placement) . '</p>'
|
||||
. '<p>' . esc($message) . '</p>'
|
||||
. '<p><strong>Required action:</strong> ' . esc($requiredAction) . '</p>';
|
||||
}
|
||||
|
||||
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 '<h3>Family Account Information</h3>'
|
||||
. '<p><strong>Carry-over balance:</strong> $' . number_format($carry, 2) . '<br>'
|
||||
. '<strong>Registration fee:</strong> $' . number_format($registrationFee, 2) . '<br>'
|
||||
. '<strong>New-year tuition due now:</strong> $' . number_format($tuition, 2) . '<br>'
|
||||
. '<strong>Mandatory fees:</strong> $' . number_format($mandatory, 2) . '<br>'
|
||||
. '<strong>Total currently due:</strong> $' . number_format($total, 2) . '</p>'
|
||||
. '<p>' . esc($message) . '</p>';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\Enrollment\DeliberationDecision;
|
||||
use App\Support\Enrollment\EnrollmentEligibility;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use RuntimeException;
|
||||
|
||||
final class EnrollmentTransitionService
|
||||
{
|
||||
public function __construct(private readonly BaseConnection $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function evaluate(
|
||||
int $studentId,
|
||||
string $sourceSchoolYear,
|
||||
string $targetSchoolYear,
|
||||
string $actorRole = 'parent',
|
||||
?DateTimeInterface $now = null
|
||||
): array {
|
||||
$now = $now !== null ? DateTimeImmutable::createFromInterface($now) : new DateTimeImmutable('now');
|
||||
$targetYear = $this->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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Enrollment;
|
||||
|
||||
final class DeliberationDecision
|
||||
{
|
||||
public const PASSED = 'PASSED';
|
||||
public const REPEAT_CLASS = 'REPEAT_CLASS';
|
||||
public const MAKE_UP_EXAM = 'MAKE_UP_EXAM';
|
||||
public const EXPELLED = 'EXPELLED';
|
||||
public const WITHDRAWN = 'WITHDRAWN';
|
||||
public const DEFERRED_DECISION = 'DEFERRED_DECISION';
|
||||
|
||||
public const ALL = [
|
||||
self::PASSED,
|
||||
self::REPEAT_CLASS,
|
||||
self::MAKE_UP_EXAM,
|
||||
self::EXPELLED,
|
||||
self::WITHDRAWN,
|
||||
self::DEFERRED_DECISION,
|
||||
];
|
||||
|
||||
public static function normalize(?string $decision): ?string
|
||||
{
|
||||
$value = trim((string) $decision);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = strtoupper((string) preg_replace('/[^A-Z0-9]+/', '_', $value));
|
||||
$key = trim((string) preg_replace('/_+/', '_', $key), '_');
|
||||
|
||||
if (in_array($key, self::ALL, true)) {
|
||||
return $key;
|
||||
}
|
||||
|
||||
$compact = strtolower((string) preg_replace('/[^a-z0-9]+/', '', strtolower($value)));
|
||||
|
||||
return match (true) {
|
||||
in_array($compact, ['pass', 'passed', 'promote', 'promoted'], true) => 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Enrollment;
|
||||
|
||||
final class EnrollmentEligibility
|
||||
{
|
||||
public const EXPELLED_MESSAGE = 'Re-enrollment is not available because the final deliberation decision is expelled. Please contact the school administration for further information.';
|
||||
public const WITHDRAWN_MESSAGE = 'Re-enrollment is not available because the final deliberation decision is withdrawn. Please contact the school administration if this status needs to be reviewed.';
|
||||
public const DEFERRED_MESSAGE = 'Re-enrollment cannot currently be completed because the final deliberation decision is deferred. Please contact the school administration for the next required step.';
|
||||
public const MISSING_DECISION_MESSAGE = 'Re-enrollment cannot currently be completed because no final deliberation decision is recorded for the student. Registration will become available after the school records a final decision.';
|
||||
public const ADULT_STUDENT_MESSAGE = 'The student will be 18 years old or older on September 1. A parent or guardian cannot complete registration on the student’s behalf. The student must complete the authorized adult-student registration process or contact the school administration.';
|
||||
|
||||
public static function ageOnSeptemberFirst(?string $dob, string $targetSchoolYear): ?int
|
||||
{
|
||||
$dob = trim((string) $dob);
|
||||
if ($dob === '' || ! preg_match('/^(\d{4})/', trim($targetSchoolYear), $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob);
|
||||
$errors = \DateTimeImmutable::getLastErrors();
|
||||
if (
|
||||
$birthDate === false
|
||||
|| (is_array($errors) && (($errors['warning_count'] ?? 0) > 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Enrollment Administration</h2>
|
||||
<div class="text-muted">Review school-year transition flags, exceptions, and placement follow-up.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (session()->getFlashdata('error')): ?>
|
||||
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="border rounded p-3 mb-3 bg-light">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<div>
|
||||
<div class="fw-semibold">Registration Email Launch</div>
|
||||
<?php if (!empty($launchState['approved'])): ?>
|
||||
<div class="text-success small">Approved at <?= esc(local_datetime($launchState['approved_at'], 'm-d-Y H:i')) ?></div>
|
||||
<?php elseif (!empty($launchState['missing'])): ?>
|
||||
<div class="text-danger small">Missing: <?= esc(implode(', ', $launchState['missing'])) ?></div>
|
||||
<?php else: ?>
|
||||
<div class="text-muted small">Configuration is ready for launch approval.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-end">
|
||||
<?php if (!empty($previewParentId)): ?>
|
||||
<a class="btn btn-outline-secondary" target="_blank" href="<?= site_url('administrator/enrollment-admin/email-preview?school_year=' . rawurlencode((string) $schoolYear) . '&parent_id=' . (int) $previewParentId) ?>">Preview Email</a>
|
||||
<?php endif; ?>
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/approve-launch') ?>">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="school_year" value="<?= esc($schoolYear ?? '') ?>">
|
||||
<button type="submit" class="btn btn-success" <?= !empty($launchState['missing']) ? 'disabled' : '' ?>>Approve Launch</button>
|
||||
</form>
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/send-registration-emails') ?>" onsubmit="return confirm('Send real registration emails to parents for <?= esc((string) ($schoolYear ?? ''), 'js') ?>?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="school_year" value="<?= esc($schoolYear ?? '') ?>">
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="force_resend" name="force_resend" value="1">
|
||||
<label class="form-check-label small" for="force_resend">Resend sent</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger" <?= empty($launchState['approved']) || !empty($launchState['missing']) || empty($emailExamples) ? 'disabled' : '' ?>>Send Real Emails</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mb-3">Decision Email Examples</h4>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-sm table-bordered align-middle" data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Parent</th>
|
||||
<th>Recipient</th>
|
||||
<th>Students</th>
|
||||
<th>Subject</th>
|
||||
<th>Delivery</th>
|
||||
<th>Example</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!empty($emailExamples)): ?>
|
||||
<?php foreach ($emailExamples as $example): ?>
|
||||
<?php
|
||||
$deliveryStatus = (string) ($example['delivery_status'] ?? 'not sent');
|
||||
$badgeClass = match ($deliveryStatus) {
|
||||
'sent' => 'bg-success',
|
||||
'failed' => 'bg-danger',
|
||||
'generated' => 'bg-info text-dark',
|
||||
default => 'bg-secondary',
|
||||
};
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($example['parent_name'] ?? '') ?></td>
|
||||
<td><?= esc(implode(', ', $example['recipients'] ?? [])) ?></td>
|
||||
<td><?= esc(implode(', ', $example['student_names'] ?? [])) ?></td>
|
||||
<td><?= esc($example['subject'] ?? '') ?></td>
|
||||
<td>
|
||||
<span class="badge <?= esc($badgeClass) ?>"><?= esc($deliveryStatus) ?></span>
|
||||
<?php if (!empty($example['sent_at'])): ?>
|
||||
<div class="small text-muted"><?= esc(local_datetime($example['sent_at'], 'm-d-Y H:i')) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($example['failure_reason'])): ?>
|
||||
<div class="small text-danger"><?= esc($example['failure_reason']) ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-outline-secondary" target="_blank" href="<?= site_url('administrator/enrollment-admin/email-preview?school_year=' . rawurlencode((string) $schoolYear) . '&parent_id=' . (int) ($example['parent_user_id'] ?? 0)) ?>">View Example</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted">No parent decision email examples found for this school year.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="get" action="<?= site_url('administrator/enrollment-admin') ?>" class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="school_year">School Year</label>
|
||||
<select class="form-select" id="school_year" name="school_year">
|
||||
<?php foreach (($schoolYears ?? []) as $year): ?>
|
||||
<?php $name = (string) ($year['name'] ?? ''); ?>
|
||||
<option value="<?= esc($name) ?>" <?= $name === ($schoolYear ?? '') ? 'selected' : '' ?>><?= esc($name) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="status">Status</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<?php foreach (['open' => 'Open', 'resolved' => 'Resolved'] as $value => $label): ?>
|
||||
<option value="<?= esc($value) ?>" <?= $value === ($status ?? '') ? 'selected' : '' ?>><?= esc($label) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="flag_type">Flag Type</label>
|
||||
<select class="form-select" id="flag_type" name="flag_type">
|
||||
<option value="">All flag types</option>
|
||||
<?php foreach (($flagTypes ?? []) as $type): ?>
|
||||
<option value="<?= esc($type) ?>" <?= $type === ($flagType ?? '') ? 'selected' : '' ?>><?= esc($type) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="assigned_to">Assigned To</label>
|
||||
<select class="form-select" id="assigned_to" name="assigned_to">
|
||||
<option value="">Anyone</option>
|
||||
<?php foreach (($admins ?? []) as $admin): ?>
|
||||
<?php
|
||||
$adminId = (int) ($admin['id'] ?? 0);
|
||||
$adminName = trim((string) ($admin['firstname'] ?? '') . ' ' . (string) ($admin['lastname'] ?? '')) ?: 'User #' . $adminId;
|
||||
?>
|
||||
<option value="<?= $adminId ?>" <?= (string) $adminId === (string) ($assignedTo ?? '') ? 'selected' : '' ?>><?= esc($adminName) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary w-100">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h4 class="mb-3">Enrollment Follow-up</h4>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-bordered table-striped align-middle" data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>School ID</th>
|
||||
<th>Enrollment Status</th>
|
||||
<th>Decision</th>
|
||||
<th>Placement</th>
|
||||
<th>Class</th>
|
||||
<th>Exception / Reason</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!empty($enrollmentFollowups)): ?>
|
||||
<?php foreach ($enrollmentFollowups as $row): ?>
|
||||
<?php
|
||||
$statusValue = (string) ($row['enrollment_status'] ?? '');
|
||||
$placementValue = (string) ($row['placement_status'] ?? '');
|
||||
$decisionValue = (string) ($row['deliberation_decision'] ?? '');
|
||||
$statusClass = match (strtolower($statusValue)) {
|
||||
'review & decision' => 'bg-warning text-dark',
|
||||
'waitlist' => 'bg-info text-dark',
|
||||
'denied' => 'bg-danger',
|
||||
default => 'bg-secondary',
|
||||
};
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($row['student_name'] ?? '') ?></td>
|
||||
<td><?= esc($row['school_id'] ?? '') ?></td>
|
||||
<td><span class="badge <?= esc($statusClass) ?>"><?= esc($statusValue) ?></span></td>
|
||||
<td><?= esc($decisionValue !== '' ? $decisionValue : 'Pending') ?></td>
|
||||
<td><?= esc($placementValue !== '' ? $placementValue : 'Pending') ?></td>
|
||||
<td><?= esc($row['class_section_name'] ?? '') ?></td>
|
||||
<td>
|
||||
<?php if (!empty($row['exception_required'])): ?>
|
||||
<span class="badge bg-warning text-dark">Exception</span>
|
||||
<?php endif; ?>
|
||||
<div class="small"><?= esc($row['exception_reason'] ?? '') ?></div>
|
||||
</td>
|
||||
<td><?= esc(!empty($row['updated_at']) ? local_datetime($row['updated_at'], 'm-d-Y H:i') : '') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted">No enrollment records need follow-up for this school year.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 class="mb-3">Enrollment Flags</h4>
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-bordered table-striped align-middle" data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>School ID</th>
|
||||
<th>Flag Type</th>
|
||||
<th>Priority</th>
|
||||
<th>Details</th>
|
||||
<th>Assigned</th>
|
||||
<th>Created</th>
|
||||
<th style="min-width: 280px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!empty($flags)): ?>
|
||||
<?php foreach ($flags as $flag): ?>
|
||||
<?php
|
||||
$flagId = (int) ($flag['id'] ?? 0);
|
||||
$flagTypeValue = (string) ($flag['flag_type'] ?? '');
|
||||
$details = is_array($flag['details'] ?? null) ? $flag['details'] : [];
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($flag['student_name'] ?? '') ?></td>
|
||||
<td><?= esc($flag['school_id'] ?? '') ?></td>
|
||||
<td><span class="badge bg-secondary"><?= esc($flagTypeValue) ?></span></td>
|
||||
<td><?= esc($flag['priority'] ?? 'normal') ?></td>
|
||||
<td>
|
||||
<?php if ($details !== []): ?>
|
||||
<?php foreach ($details as $key => $value): ?>
|
||||
<div><span class="text-muted"><?= esc((string) $key) ?>:</span> <?= esc(is_scalar($value) ? (string) $value : json_encode($value)) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">No details</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= esc($flag['assignee_name'] ?: '') ?></td>
|
||||
<td><?= esc(!empty($flag['created_at']) ? local_datetime($flag['created_at'], 'm-d-Y H:i') : '') ?></td>
|
||||
<td>
|
||||
<?php if (($flag['status'] ?? '') === 'open'): ?>
|
||||
<?php if ($flagTypeValue === 'CLASS_REASSIGNMENT_REQUIRED'): ?>
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/flags/' . $flagId . '/assign-class') ?>" class="mb-2">
|
||||
<?= csrf_field() ?>
|
||||
<select class="form-select form-select-sm mb-2" name="class_section_id" required>
|
||||
<option value="">Select class section</option>
|
||||
<?php foreach (($classSections ?? []) as $section): ?>
|
||||
<option value="<?= (int) ($section['class_section_id'] ?? 0) ?>"><?= esc($section['class_section_name'] ?? '') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<input class="form-control form-control-sm mb-2" name="resolution_notes" placeholder="Reason / notes" required>
|
||||
<button class="btn btn-sm btn-primary" type="submit">Assign Class</button>
|
||||
</form>
|
||||
<?php elseif ($flagTypeValue === 'PENDING_MAKE_UP_EXAM_PROMOTION'): ?>
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/flags/' . $flagId . '/makeup-promotion') ?>" class="mb-2">
|
||||
<?= csrf_field() ?>
|
||||
<select class="form-select form-select-sm mb-2" name="exam_result" required>
|
||||
<option value="">Exam result</option>
|
||||
<option value="passed">Passed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm mb-2" name="class_section_id">
|
||||
<option value="">Promoted class section if passed</option>
|
||||
<?php foreach (($classSections ?? []) as $section): ?>
|
||||
<option value="<?= (int) ($section['class_section_id'] ?? 0) ?>"><?= esc($section['class_section_name'] ?? '') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<input class="form-control form-control-sm mb-2" name="resolution_notes" placeholder="Resolution notes" required>
|
||||
<button class="btn btn-sm btn-primary" type="submit">Resolve Exam</button>
|
||||
</form>
|
||||
<?php elseif (in_array($flagTypeValue, ['AGE_EXCEPTION_REQUIRED', 'LATE_REGISTRATION_EXCEPTION', 'FINANCIAL_REVIEW_REQUIRED', 'CLASS_CAPACITY_EXCEPTION_REQUIRED'], true)): ?>
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/flags/' . $flagId . '/approve-exception') ?>" class="mb-2">
|
||||
<?= csrf_field() ?>
|
||||
<input class="form-control form-control-sm mb-2" name="reason" placeholder="Approval reason" required>
|
||||
<button class="btn btn-sm btn-warning" type="submit">Approve Exception</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="<?= site_url('administrator/enrollment-admin/flags/' . $flagId . '/resolve') ?>">
|
||||
<?= csrf_field() ?>
|
||||
<input class="form-control form-control-sm mb-2" name="resolution_notes" placeholder="Resolution notes" required>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="submit">Resolve Manually</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<div class="text-muted small"><?= esc($flag['resolution_notes'] ?? '') ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted">No formal enrollment flags found. Check Enrollment Follow-up above for status-based items.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 class="mb-3">Recent Enrollment Audit</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered align-middle" data-no-mgmt-sticky>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Student</th>
|
||||
<th>Action</th>
|
||||
<th>Administrator</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!empty($auditRows)): ?>
|
||||
<?php foreach ($auditRows as $row): ?>
|
||||
<tr>
|
||||
<td><?= esc(!empty($row['created_at']) ? local_datetime($row['created_at'], 'm-d-Y H:i') : '') ?></td>
|
||||
<td><?= esc($row['student_name'] ?? '') ?></td>
|
||||
<td><?= esc($row['action'] ?? '') ?></td>
|
||||
<td><?= esc($row['performed_by_name'] ?? '') ?></td>
|
||||
<td><?= esc($row['reason'] ?? '') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">No enrollment audit records found. Audit rows appear after actions are completed from this dashboard or through the transition service.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<style>
|
||||
.enrollment-email-preview-frame .email-container {
|
||||
max-width: 1280px;
|
||||
width: min(1280px, 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Registration Email Preview</h2>
|
||||
<div class="text-muted"><?= esc($schoolYear ?? '') ?> · Parent #<?= esc((string) ($parentId ?? '')) ?></div>
|
||||
</div>
|
||||
<a class="btn btn-secondary" href="<?= site_url('administrator/enrollment-admin?school_year=' . rawurlencode((string) ($schoolYear ?? ''))) ?>">Back</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Subject</label>
|
||||
<input class="form-control" value="<?= esc($subject ?? '') ?>" readonly>
|
||||
</div>
|
||||
|
||||
<div class="border rounded bg-white p-3 enrollment-email-preview-frame">
|
||||
<?= $body ?? '' ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -3,30 +3,44 @@
|
||||
<?= $this->section('styles') ?>
|
||||
<style>
|
||||
#classesTable {
|
||||
table-layout: fixed;
|
||||
table-layout: auto;
|
||||
}
|
||||
#classesTable th:nth-child(1),
|
||||
#classesTable td:nth-child(1) {
|
||||
width: 10rem;
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#classesTable th:nth-child(2),
|
||||
#classesTable td:nth-child(2),
|
||||
#classesTable td:nth-child(2) {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#classesTable th:nth-child(3),
|
||||
#classesTable td:nth-child(3) {
|
||||
width: 6rem;
|
||||
}
|
||||
#classesTable th:nth-child(4),
|
||||
#classesTable td:nth-child(4) {
|
||||
width: 10rem;
|
||||
min-width: 34rem;
|
||||
}
|
||||
.distribution-cell {
|
||||
min-width: 28rem;
|
||||
min-width: 34rem;
|
||||
}
|
||||
.distribution-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: .5rem;
|
||||
}
|
||||
.distribution-class-roster {
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: .375rem;
|
||||
background: #f8f9fa;
|
||||
padding: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
.distribution-class-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: .5rem;
|
||||
margin-bottom: .35rem;
|
||||
}
|
||||
.distribution-section {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: .375rem;
|
||||
@@ -53,14 +67,78 @@
|
||||
max-height: 8.5rem;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
counter-reset: distribution-student;
|
||||
font-size: .8125rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.distribution-students li {
|
||||
counter-increment: distribution-student;
|
||||
}
|
||||
.distribution-class-roster .distribution-students {
|
||||
max-height: 14rem;
|
||||
}
|
||||
.distribution-students-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
.distribution-students-header {
|
||||
display: grid;
|
||||
grid-template-columns: 2.25rem minmax(12rem, 1fr) 3.5rem 5rem minmax(8rem, 10rem) minmax(12rem, 16rem);
|
||||
gap: .5rem;
|
||||
padding: 0 .25rem .2rem 0;
|
||||
font-size: .72rem;
|
||||
font-weight: 700;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.distribution-students-header span:nth-child(3) {
|
||||
text-align: end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.distribution-student-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2.25rem minmax(12rem, 1fr) 3.5rem 5rem minmax(8rem, 10rem) minmax(12rem, 16rem);
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
.student-row-number {
|
||||
color: #6c757d;
|
||||
text-align: end;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.student-row-number::before {
|
||||
content: counter(distribution-student) ". ";
|
||||
}
|
||||
.student-age-cell {
|
||||
text-align: end;
|
||||
color: #495057;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.student-gender-cell {
|
||||
color: #495057;
|
||||
}
|
||||
.student-last-year-cell {
|
||||
color: #495057;
|
||||
min-width: 0;
|
||||
}
|
||||
.student-assignment-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
.student-assignment-cell .form-select {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
min-width: 5rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
.distribution-empty {
|
||||
color: #6c757d;
|
||||
font-size: .875rem;
|
||||
}
|
||||
.distribution-search-summary {
|
||||
min-height: 1.25rem;
|
||||
}
|
||||
</style>
|
||||
<?= $this->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;
|
||||
?>
|
||||
<option value="<?= (int)($c['class_id'] ?? 0) ?>"><?= esc($name) ?></option>
|
||||
@@ -119,6 +197,15 @@
|
||||
<div id="includedClasses" class="d-flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 align-items-end mb-3">
|
||||
<div class="col-md-4">
|
||||
<label for="studentSearch" class="form-label">Search Students</label>
|
||||
<input type="search" id="studentSearch" class="form-control" placeholder="Search by student name..." autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div id="studentSearchSummary" class="small text-muted distribution-search-summary"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle no-mgmt-sticky" id="classesTable">
|
||||
@@ -126,8 +213,6 @@
|
||||
<tr id="tblHeader">
|
||||
<th>Class</th>
|
||||
<th>Total</th>
|
||||
<th>Sections</th>
|
||||
<th>Actions</th>
|
||||
<th>Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -151,6 +236,15 @@
|
||||
const selectedYear = '<?= esc($selectedYear ?? '') ?>';
|
||||
const totalsBaseUrl = '<?= site_url('administrator/sections/promotion-totals') ?>';
|
||||
const distUrl = '<?= site_url('administrator/sections/auto-distribute') ?>';
|
||||
const updateDraftUrl = '<?= site_url('administrator/sections/distribution-draft/update') ?>';
|
||||
const updateCandidateUrl = '<?= site_url('administrator/sections/distribution-candidate/update') ?>';
|
||||
const classSections = <?= json_encode(array_values(array_map(static function ($c) {
|
||||
return [
|
||||
'class_id' => (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();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -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 ?? ''));
|
||||
?>
|
||||
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
@@ -279,8 +280,8 @@ $gradeOptions = $gradeOptions ?? ['KG', '1', '2', '3', '4', '5', '6', '7', '8',
|
||||
<!-- age (readonly) -->
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Age</label>
|
||||
<input type="number" name="age" class="form-control js-age" value="<?= (int)($student['age'] ?? 0) ?>" min="0" step="1" readonly>
|
||||
<div class="form-text">Auto-calculated from DOB.</div>
|
||||
<input type="number" name="age" class="form-control js-age" value="<?= esc((string)($student['age'] ?? '')) ?>" min="0" step="1" readonly>
|
||||
<div class="form-text">Auto-calculated from DOB as of Sep 1 of the school year.</div>
|
||||
</div>
|
||||
|
||||
<!-- registration_grade -->
|
||||
@@ -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 = <?= json_encode($selectedYear, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
|
||||
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);
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<th>Registration Date</th>
|
||||
<th>Parent/Guardian</th>
|
||||
<th>Student Name</th>
|
||||
<th>Age</th>
|
||||
<th>New Student</th>
|
||||
<th>Removed (Prior Years)</th>
|
||||
<th>Current Class</th>
|
||||
@@ -78,6 +79,11 @@
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
|
||||
<!-- Age -->
|
||||
<td class="text-center">
|
||||
<?= isset($student['age']) && $student['age'] !== '' && $student['age'] !== null ? esc((string)(int)$student['age']) : '-' ?>
|
||||
</td>
|
||||
|
||||
<!-- New Student -->
|
||||
<td>
|
||||
<?php if (($student['new_student'] ?? 'Unknown') === 'Yes'): ?>
|
||||
@@ -109,6 +115,9 @@
|
||||
case 'admission under review':
|
||||
echo '<span class="badge bg-primary">admission under review</span>';
|
||||
break;
|
||||
case 'review & decision':
|
||||
echo '<span class="badge bg-secondary">review & decision</span>';
|
||||
break;
|
||||
case 'payment pending':
|
||||
echo '<span class="badge bg-warning text-dark">payment pending</span>';
|
||||
break;
|
||||
@@ -145,6 +154,7 @@
|
||||
data-parent-id="<?= $pid ?>"
|
||||
data-prev="<?= esc($student['enrollment_status'] ?? '') ?>">
|
||||
<option value="admission under review" <?= ($student['enrollment_status'] ?? '') === 'admission under review' ? 'selected' : '' ?>>admission under review</option>
|
||||
<option value="review & decision" <?= ($student['enrollment_status'] ?? '') === 'review & decision' ? 'selected' : '' ?>>review & decision</option>
|
||||
<option value="payment pending" <?= ($student['enrollment_status'] ?? '') === 'payment pending' ? 'selected' : '' ?>>payment pending</option>
|
||||
<option value="enrolled" <?= ($student['enrollment_status'] ?? '') === 'enrolled' ? 'selected' : '' ?>>enrolled</option>
|
||||
<option value="withdraw under review" <?= ($student['enrollment_status'] ?? '') === 'withdraw under review' ? 'selected' : '' ?>>withdraw under review</option>
|
||||
@@ -155,9 +165,9 @@
|
||||
</select>
|
||||
|
||||
</td>
|
||||
<!-- Assign Class (only for 'admission under review') -->
|
||||
<!-- Assign Class (only for review statuses) -->
|
||||
<td>
|
||||
<?php $isUnderReview = (strtolower($student['enrollment_status'] ?? '') === 'admission under review'); ?>
|
||||
<?php $isUnderReview = in_array(strtolower($student['enrollment_status'] ?? ''), ['admission under review', 'review & decision'], true); ?>
|
||||
<?php if ($isUnderReview): ?>
|
||||
<select class="form-select form-select-sm assign-class-select" <?= empty($isCurrentYear) ? 'disabled' : '' ?>
|
||||
data-student-id="<?= $sid ?>"
|
||||
@@ -181,7 +191,7 @@
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="9">No students available.</td>
|
||||
<td colspan="10">No students available.</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
@@ -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 '<span class="badge bg-primary">admission under review</span>';
|
||||
case 'review & decision': return '<span class="badge bg-secondary">review & decision</span>';
|
||||
case 'payment pending': return '<span class="badge bg-warning text-dark">payment pending</span>';
|
||||
case 'enrolled': return '<span class="badge bg-success">enrolled</span>';
|
||||
case 'withdraw under review': return '<span class="badge bg-warning text-dark">withdraw under review</span>';
|
||||
@@ -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') || '', <?= json_encode($classes ?? [], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>);
|
||||
} else {
|
||||
rowData[ASSIGN_COL_INDEX] = '<span class="text-muted">—</span>';
|
||||
}
|
||||
@@ -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 = '<span class="text-muted">—</span>';
|
||||
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') || '', <?= json_encode($classes ?? [], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>)
|
||||
: '<span class="text-muted">—</span>';
|
||||
}
|
||||
}
|
||||
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 = `<select name="enrollment_status[${studentId}]" class="form-control enrollment-status" ${IS_CURRENT_YEAR ? '' : 'disabled'} data-student-id="${studentId}" data-parent-id="${parentId}" data-prev="${esc(current)}">`;
|
||||
for (const o of opts) {
|
||||
@@ -793,7 +806,7 @@
|
||||
rowData[CLASS_COL_INDEX] = esc(st.class_section || 'Class not Assigned');
|
||||
rowData[STATUS_COL_INDEX] = badgeForStatusJs(st.enrollment_status || '');
|
||||
rowData[STATUS_SELECT_COL_INDEX] = buildStatusSelectHtml(id, tr.getAttribute('data-parent-id') || '', st.enrollment_status || '');
|
||||
if (norm(st.enrollment_status || '') === 'admission under review') {
|
||||
if (['admission under review', 'review & decision'].includes(norm(st.enrollment_status || ''))) {
|
||||
rowData[ASSIGN_COL_INDEX] = buildAssignSelectHtml(id, tr.getAttribute('data-parent-id') || '', data.classes || []);
|
||||
} else {
|
||||
rowData[ASSIGN_COL_INDEX] = '<span class="text-muted">—</span>';
|
||||
|
||||
@@ -72,6 +72,9 @@
|
||||
case 'admission under review':
|
||||
echo '<span class="badge bg-primary">admission under review</span>';
|
||||
break;
|
||||
case 'review & decision':
|
||||
echo '<span class="badge bg-secondary">review & decision</span>';
|
||||
break;
|
||||
case 'payment pending':
|
||||
echo '<span class="badge bg-warning text-dark">payment pending</span>';
|
||||
break;
|
||||
|
||||
@@ -8,6 +8,7 @@ $semester = 'year';
|
||||
|
||||
$schoolYear = $schoolYear ?? '';
|
||||
$schoolYears = $schoolYears ?? [];
|
||||
$isEditable = (bool)($isEditable ?? true);
|
||||
|
||||
if (empty($schoolYears) && $schoolYear !== '') {
|
||||
$schoolYears = [$schoolYear];
|
||||
@@ -102,6 +103,12 @@ if (empty($schoolYears) && $schoolYear !== '') {
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$isEditable): ?>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
This school year is closed. Decisions are read-only.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$decisionOptions = [
|
||||
'' => '— No decision yet —',
|
||||
@@ -214,11 +221,13 @@ if (empty($schoolYears) && $schoolYear !== '') {
|
||||
<textarea name="notes"
|
||||
class="form-control form-control-sm decision-notes"
|
||||
rows="3"
|
||||
placeholder="Add comments or rationale…"><?= esc($currentNotes) ?></textarea>
|
||||
placeholder="Add comments or rationale…"
|
||||
<?= $isEditable ? '' : 'readonly' ?>><?= esc($currentNotes) ?></textarea>
|
||||
|
||||
<div class="d-flex gap-2 mt-2 align-items-center">
|
||||
<select name="decision"
|
||||
class="form-select form-select-sm decision-select flex-grow-1">
|
||||
class="form-select form-select-sm decision-select flex-grow-1"
|
||||
<?= $isEditable ? '' : 'disabled' ?>>
|
||||
<?php foreach ($decisionOptions as $val => $label): ?>
|
||||
<option value="<?= esc($val) ?>" <?= $currentDecision === $val ? 'selected' : '' ?>>
|
||||
<?= esc($label) ?>
|
||||
@@ -226,7 +235,9 @@ if (empty($schoolYears) && $schoolYear !== '') {
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<button type="submit" class="btn btn-sm btn-primary">
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-primary"
|
||||
<?= $isEditable ? '' : 'disabled' ?>>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
@@ -243,7 +254,8 @@ if (empty($schoolYears) && $schoolYear !== '') {
|
||||
class="btn btn-sm btn-outline-primary btn-send-email"
|
||||
data-student-id="<?= (int)($row['student_id'] ?? 0) ?>"
|
||||
data-semester="year"
|
||||
data-school-year="<?= esc((string)$schoolYear) ?>">
|
||||
data-school-year="<?= esc((string)$schoolYear) ?>"
|
||||
<?= $isEditable ? '' : 'disabled' ?>>
|
||||
Send Email
|
||||
</button>
|
||||
<?php else: ?>
|
||||
|
||||
+1
-1
@@ -663,7 +663,7 @@
|
||||
<h1 class="mb-4">Our Curriculum and Grade Structure</h1>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<a class="btn btn-success py-3 px-5 mt-3" href="/register">Get Started Now<i class="fa fa-arrow-right ms-2"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
?>
|
||||
<!-- Registration Info -->
|
||||
<div class="alert alert-info mb-3">
|
||||
@@ -25,84 +30,46 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<?php if ($familyFinancialSummary !== []): ?>
|
||||
<div class="border rounded p-3 mb-3 bg-light">
|
||||
<div class="fw-semibold mb-2">Family Account Information</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<span class="text-muted">Previous-year carry-over balance:</span>
|
||||
<strong><?= esc($money($familyFinancialSummary['carry_over_balance'] ?? 0)) ?></strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<span class="text-muted">Registration fee:</span>
|
||||
<strong><?= esc($money($familyFinancialSummary['registration_fee'] ?? 0)) ?></strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<span class="text-muted">Tuition due now:</span>
|
||||
<strong><?= esc($money($familyFinancialSummary['tuition_due_at_registration'] ?? 0)) ?></strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<span class="text-muted">Mandatory fees:</span>
|
||||
<strong><?= esc($money($familyFinancialSummary['mandatory_fees'] ?? 0)) ?></strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<span class="text-muted">Current-year account balance:</span>
|
||||
<strong><?= esc($money($familyFinancialSummary['current_balance'] ?? 0)) ?></strong>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<span class="text-muted">Total currently due:</span>
|
||||
<strong><?= esc($money($familyFinancialSummary['amount_due'] ?? 0)) ?></strong>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!empty($familyFinancialSummary['policy_message'])): ?>
|
||||
<div class="small text-muted mt-2"><?= esc($familyFinancialSummary['policy_message']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (session()->getFlashdata('error')): ?>
|
||||
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php $hasAcceptedSchoolPolicy = (bool) ($hasAcceptedSchoolPolicy ?? false); ?>
|
||||
<?php
|
||||
$fallMakeupExamLabel = !empty($fallMakeupExamOn) ? local_date($fallMakeupExamOn, 'm-d-Y') : null;
|
||||
$decisionMessageForStudent = static function (array $student) use ($fallMakeupExamLabel): array {
|
||||
$decisionRow = is_array($student['previous_year_decision'] ?? null) ? $student['previous_year_decision'] : [];
|
||||
$decision = trim((string) ($decisionRow['decision'] ?? ''));
|
||||
$source = strtolower(trim((string) ($decisionRow['source'] ?? '')));
|
||||
$decisionKey = strtolower($decision);
|
||||
$name = trim((string) ($student['firstname'] ?? 'Your child') . ' ' . (string) ($student['lastname'] ?? ''));
|
||||
$name = $name !== '' ? $name : 'Your child';
|
||||
$previousClass = trim((string) ($decisionRow['class_section_name'] ?? ''));
|
||||
$previousClass = $previousClass !== '' ? $previousClass : 'the same class';
|
||||
|
||||
if ($decisionKey === 'pass' || ($decisionKey === '' && $source !== 'pending')) {
|
||||
return ['message' => '', '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',
|
||||
];
|
||||
};
|
||||
?>
|
||||
|
||||
<?php if (!empty($students)): ?>
|
||||
<form action="<?= base_url('/parent/enroll_classes_handler') ?>" method="post">
|
||||
<?= csrf_field() ?>
|
||||
@@ -118,6 +85,8 @@ $decisionMessageForStudent = static function (array $student) use ($fallMakeupEx
|
||||
<th>Age</th>
|
||||
<th>Gender</th>
|
||||
<th>Grade</th>
|
||||
<th>Decision</th>
|
||||
<th>Required Action</th>
|
||||
<th>Enroll</th>
|
||||
<th>Withdraw</th>
|
||||
<th>Status</th>
|
||||
@@ -125,7 +94,7 @@ $decisionMessageForStudent = static function (array $student) use ($fallMakeupEx
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($students as $index => $student): ?>
|
||||
<?php $decisionMessage = $decisionMessageForStudent($student); ?>
|
||||
<?php $eligibilityMessage = is_array($student['enrollment_eligibility_message'] ?? null) ? $student['enrollment_eligibility_message'] : ['message' => '', 'blocking' => false, 'level' => 'info']; ?>
|
||||
<tr>
|
||||
<td><?= $index + 1 ?></td>
|
||||
<td><?= esc($student['school_id'] ?? 'N/A') ?></td>
|
||||
@@ -141,19 +110,21 @@ $decisionMessageForStudent = static function (array $student) use ($fallMakeupEx
|
||||
: 'Not Assigned';
|
||||
?>
|
||||
</td>
|
||||
<td><?= esc($student['transition_evaluation']['decision_label'] ?? 'Pending') ?></td>
|
||||
<td><?= esc($student['required_action_label'] ?? 'Contact the school administration.') ?></td>
|
||||
|
||||
<!-- Enroll Checkbox -->
|
||||
<td>
|
||||
<?php if ($student['enrollment_status'] === 'not enrolled'): ?>
|
||||
<?php $disableEnrollUI = $deadlinePassed || !$isEditable; ?>
|
||||
<?php $disableEnrollUI = $deadlinePassed || !$isEditable || (bool) ($eligibilityMessage['blocking'] ?? false); ?>
|
||||
<input type="checkbox"
|
||||
name="enroll[]"
|
||||
value="<?= esc($student['id']) ?>"
|
||||
data-decision-message="<?= esc($decisionMessage['message']) ?>"
|
||||
data-decision-blocking="<?= $decisionMessage['blocking'] ? '1' : '0' ?>"
|
||||
data-decision-message="<?= esc($eligibilityMessage['message'] ?? '') ?>"
|
||||
data-decision-blocking="<?= !empty($eligibilityMessage['blocking']) ? '1' : '0' ?>"
|
||||
data-decision-message-target="enrollment-decision-message-<?= esc($student['id']) ?>"
|
||||
<?= $disableEnrollUI ? 'disabled' : '' ?>>
|
||||
<?php elseif (in_array($student['enrollment_status'], ['enrolled', 'admission under review', 'payment pending', 'withdraw under review'])): ?>
|
||||
<?php elseif (in_array($student['enrollment_status'], ['enrolled', 'admission under review', 'review & decision', 'payment pending', 'withdraw under review'])): ?>
|
||||
<input type="checkbox" checked disabled>
|
||||
<?php else: ?>
|
||||
<input type="checkbox" disabled>
|
||||
@@ -180,6 +151,9 @@ $decisionMessageForStudent = static function (array $student) use ($fallMakeupEx
|
||||
case 'admission under review':
|
||||
echo '<span class="badge bg-primary">admission under review</span>';
|
||||
break;
|
||||
case 'review & decision':
|
||||
echo '<span class="badge bg-secondary">review & decision</span>';
|
||||
break;
|
||||
case 'payment pending':
|
||||
echo '<span class="badge bg-warning text-dark">payment pending</span>';
|
||||
break;
|
||||
@@ -210,11 +184,11 @@ $decisionMessageForStudent = static function (array $student) use ($fallMakeupEx
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php if ($decisionMessage['message'] !== ''): ?>
|
||||
<tr id="enrollment-decision-message-<?= esc($student['id']) ?>" class="enrollment-decision-message-row d-none">
|
||||
<td colspan="10">
|
||||
<div class="alert alert-<?= esc($decisionMessage['level']) ?> mb-0">
|
||||
<?= esc($decisionMessage['message']) ?>
|
||||
<?php if (($eligibilityMessage['message'] ?? '') !== ''): ?>
|
||||
<tr id="enrollment-decision-message-<?= esc($student['id']) ?>" class="enrollment-decision-message-row">
|
||||
<td colspan="12">
|
||||
<div class="alert alert-<?= esc($eligibilityMessage['level'] ?? 'info') ?> mb-0">
|
||||
<?= esc($eligibilityMessage['message']) ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="container my-5">
|
||||
@@ -71,14 +96,14 @@ if (!function_exists('parseDbDateTime')) {
|
||||
<!-- Display Payment Notice / Deadline -->
|
||||
<?php
|
||||
$displayTz = user_timezone();
|
||||
$deadline = parseDbDateTime($dueDate, 'UTC', $displayTz); // format either DATE or DATETIME safely
|
||||
$deadlineDisplay = formatCalendarDateOnly($dueDate);
|
||||
?>
|
||||
<div class="alert alert-info mb-3 d-inline-block" style="display: inline-block; width: auto; padding: 10px;">
|
||||
<ul class="mb-0">
|
||||
<li>
|
||||
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
|
||||
(<strong><?= esc($deadline ? $deadline->format('m-d-Y') : 'TBD') ?></strong>).
|
||||
(<strong><?= esc($deadlineDisplay ?: 'TBD') ?></strong>).
|
||||
</li>
|
||||
<li>
|
||||
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
|
||||
<?php foreach ($invoices as $index => $invoice): ?>
|
||||
<?php
|
||||
$issueDt = parseDbDateTime($invoice['issue_date'] ?? null, 'UTC', $displayTz); // assume stored UTC
|
||||
$dueDt = parseDbDateTime($invoice['due_date'] ?? null, 'UTC', $displayTz, '12:00:00'); // if date-only, set noon
|
||||
$dueDateDisplay = formatCalendarDateOnly($invoice['due_date'] ?? null);
|
||||
$lastPay = parseDbDateTime($invoice['last_payment_date'] ?? null, 'UTC', $displayTz);
|
||||
?>
|
||||
|
||||
@@ -117,7 +142,7 @@ $deadline = parseDbDateTime($dueDate, 'UTC', $displayTz); // format either DATE
|
||||
<td><?= (int)$index + 1 ?></td>
|
||||
<td><?= esc($invoice['invoice_number']) ?></td>
|
||||
<td><?= $issueDt ? esc($issueDt->format('m-d-Y h:i A')) : '—' ?></td>
|
||||
<td><?= $dueDt ? esc($dueDt->format('m-d-Y')) : '—' ?></td>
|
||||
<td><?= $dueDateDisplay ? esc($dueDateDisplay) : '—' ?></td>
|
||||
<td><?= esc($invoice['description'] ?? '') ?: '—' ?></td>
|
||||
<td>$<?= number_format((float)($invoice['balance'] ?? 0), 2) ?></td>
|
||||
<td>$<?= number_format((float)($invoice['last_paid_amount'] ?? 0), 2) ?></td>
|
||||
|
||||
@@ -352,7 +352,8 @@ $closeAtFmt12 = $closeAt->format('m-d-Y g:i A T'); // e.g., 10-01-2025 12:00 AM
|
||||
|
||||
<script>
|
||||
window.appConfig = {
|
||||
registrationAgeDeadline: "<?= esc($registrationAgeDeadline) ?>"
|
||||
registrationAgeDeadline: "<?= esc($registrationAgeDeadline) ?>",
|
||||
schoolYearAgeDeadline: "<?= esc($schoolYearAgeDeadline ?? $registrationAgeDeadline) ?>"
|
||||
};
|
||||
</script>
|
||||
<!-- Only keep the script imports -->
|
||||
|
||||
@@ -84,6 +84,7 @@ $role = strtolower(session()->get('role') ?? 'guest');
|
||||
<a class="dropdown-item" href="/rfid_coming_soon">Attendance Scans</a>
|
||||
<a class="dropdown-item" href="/administrator/class_assignment">Classes List</a>
|
||||
<a class="dropdown-item" href="/administrator/emergency_contact">Emergency Contact</a>
|
||||
<a class="dropdown-item" href="/administrator/enrollment-admin">Enrollment Administration</a>
|
||||
<a class="dropdown-item" href="/enroll_withdraw/enrollment_withdrawal">Enrollment-Withdrawal</a>
|
||||
<!--a class="dropdown-item" href="/administrator/exam_management">Exams Management</a-->
|
||||
<a class="dropdown-item" href="/flags/flags_management">Flags Management</a>
|
||||
@@ -213,6 +214,7 @@ $role = strtolower(session()->get('role') ?? 'guest');
|
||||
<!--a class="dropdown-item" href="/rfid_coming_soon">Attendance Scans</a-->
|
||||
<a class="dropdown-item" href="/administrator/class_assignment">Classes List</a>
|
||||
<a class="dropdown-item" href="/administrator/emergency_contact">Emergency Contact</a>
|
||||
<a class="dropdown-item" href="/administrator/enrollment-admin">Enrollment Administration</a>
|
||||
<a class="dropdown-item" href="/enroll_withdraw/enrollment_withdrawal">Enrollment-Withdrawal</a>
|
||||
<!--a class="dropdown-item" href="/administrator/exam_management">Exams Management</a-->
|
||||
<a class="dropdown-item" href="/flags/flags_management">Flags Management</a>
|
||||
@@ -301,6 +303,7 @@ $role = strtolower(session()->get('role') ?? 'guest');
|
||||
<!--a class="dropdown-item" href="/rfid_coming_soon">Attendance Scans</a-->
|
||||
<a class="dropdown-item" href="/administrator/class_assignment">Classes List</a>
|
||||
<a class="dropdown-item" href="/administrator/emergency_contact">Emergency Contact</a>
|
||||
<a class="dropdown-item" href="/administrator/enrollment-admin">Enrollment Administration</a>
|
||||
<a class="dropdown-item" href="/enroll_withdraw/enrollment_withdrawal">Enrollment-Withdrawal</a>
|
||||
<!--a class="dropdown-item" href="/administrator/exam_management">Exams Management</a-->
|
||||
<a class="dropdown-item" href="/flags/flags_management">Flags Management</a>
|
||||
|
||||
@@ -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 <u>with a minimum age rule of at least 15</u>. <strong><u>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.</u></strong>
|
||||
'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 <u>with a minimum age rule of at least 15</u>. <strong><u>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.</u></strong>
|
||||
|
||||
Registration this year will open <strong><u>from 09-01-2025 to 10-01-2025</u></strong>. We highly encourage parents to enroll their children early so they will not miss out on early classes during the year. <strong><u>By 10-01-2025 at midnight</u></strong>, 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.
|
||||
|
||||
<strong><u>We only accept students with ages between 5 and 18</u></strong>. For younger students, they must be <strong><u>at least 6 years old by 12-31-2025</u></strong> to be admitted, there will be no exceptions. <strong><u>Students that are 5 years old by 12-31-2025 can enroll in our newly created Kindergarten class</u></strong>. 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.
|
||||
<strong><u>We only accept students with ages between 5 and 18</u></strong>. For younger students, they must be <strong><u>at least 6 years old by 09-01-2025</u></strong> to be admitted above Kindergarten, there will be no exceptions. <strong><u>Students that are 5 years old by 12-31-2025 can enroll in our newly created Kindergarten class</u></strong>. 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.'
|
||||
],
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()})`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Services;
|
||||
|
||||
use App\Services\EmailService;
|
||||
use App\Services\EnrollmentRegistrationEmailService;
|
||||
use App\Services\EnrollmentTransitionService;
|
||||
use App\Support\Enrollment\DeliberationDecision;
|
||||
use CodeIgniter\Database\BaseBuilder;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EnrollmentRegistrationEmailServiceTest extends TestCase
|
||||
{
|
||||
public function testPassedStudentUsesReEnrollmentOpeningLanguage(): void
|
||||
{
|
||||
$service = $this->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:</strong> $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<mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Services;
|
||||
|
||||
use App\Services\EnrollmentTransitionService;
|
||||
use CodeIgniter\Database\BaseBuilder;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class EnrollmentTransitionServiceTest extends TestCase
|
||||
{
|
||||
public function testPassedClassNameWithPrefixFindsNextNumericGrade(): void
|
||||
{
|
||||
$service = $this->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<mixed> $args
|
||||
*/
|
||||
private function invoke(EnrollmentTransitionService $service, string $method, array $args): mixed
|
||||
{
|
||||
$reflection = new \ReflectionMethod($service, $method);
|
||||
$reflection->setAccessible(true);
|
||||
|
||||
return $reflection->invokeArgs($service, $args);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Support\Enrollment;
|
||||
|
||||
use App\Support\Enrollment\DeliberationDecision;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
final class DeliberationDecisionTest extends CIUnitTestCase
|
||||
{
|
||||
public function testHistoricalDecisionValuesMapToCanonicalValues(): void
|
||||
{
|
||||
$cases = [
|
||||
'Pass' => 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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Support\Enrollment;
|
||||
|
||||
use App\Support\Enrollment\EnrollmentEligibility;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
final class EnrollmentEligibilityTest extends CIUnitTestCase
|
||||
{
|
||||
public function testParentEnrollmentIsBlockedWhenStudentIsEighteenOnSeptemberFirst(): void
|
||||
{
|
||||
$message = EnrollmentEligibility::parentDecisionMessage(
|
||||
['firstname' => '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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user