diff --git a/app/Controllers/Administrator/SchoolYearClosingController.php b/app/Controllers/Administrator/SchoolYearClosingController.php index 20baf38..f5660af 100644 --- a/app/Controllers/Administrator/SchoolYearClosingController.php +++ b/app/Controllers/Administrator/SchoolYearClosingController.php @@ -14,11 +14,13 @@ class SchoolYearClosingController extends BaseController $targetId = $this->normalizeInt($this->request->getGet('target_school_year_id')); $preview = service('schoolYearClosing')->preview($id, $targetId); $promotionTable = $this->promotionTablePayload($preview['promotion']['rows'] ?? []); + $carryForwardTable = $this->carryForwardTablePayload($preview['carry_forward'] ?? []); $latestBatch = service('schoolYearClosing')->latestBatch($id); return view('school_years/closing_preview', [ 'preview' => $preview, 'promotionTable' => $promotionTable, + 'carryForwardTable' => $carryForwardTable, 'latestBatch' => $latestBatch, 'missingCarryForwardInvoices' => $this->missingCarryForwardInvoiceCount($latestBatch), 'schoolYears' => (new SchoolYearModel())->orderBy('name', 'DESC')->findAll(), @@ -118,13 +120,10 @@ class SchoolYearClosingController extends BaseController private function promotionTablePayload(array $rows): array { $allowedSorts = ['student', 'school_id', 'class', 'year_score', 'decision', 'source', 'queue', 'target', 'status']; - $sort = (string) ($this->request->getGet('sort') ?? 'class'); - $sort = in_array($sort, $allowedSorts, true) ? $sort : 'class'; - $order = strtolower((string) ($this->request->getGet('order') ?? 'asc')) === 'desc' ? 'desc' : 'asc'; - $page = max(1, (int) ($this->request->getGet('page') ?? 1)); - $perPage = (int) ($this->request->getGet('per_page') ?? 25); + $sort = 'class'; + $order = 'asc'; + $perPage = 25; $allowedPerPage = [10, 25, 50, 100]; - $perPage = in_array($perPage, $allowedPerPage, true) ? $perPage : 25; usort($rows, function (array $a, array $b) use ($sort, $order): int { $comparison = $this->comparePromotionRows($a, $b, $sort); @@ -142,21 +141,18 @@ class SchoolYearClosingController extends BaseController }); $total = count($rows); - $pageCount = max(1, (int) ceil($total / $perPage)); - $page = min($page, $pageCount); - $offset = ($page - 1) * $perPage; return [ - 'rows' => array_slice($rows, $offset, $perPage), + 'rows' => $rows, 'sort' => $sort, 'order' => $order, - 'page' => $page, + 'page' => 1, 'perPage' => $perPage, 'total' => $total, - 'pageCount' => $pageCount, + 'pageCount' => 1, 'allowedPerPage' => $allowedPerPage, - 'from' => $total === 0 ? 0 : $offset + 1, - 'to' => min($offset + $perPage, $total), + 'from' => $total === 0 ? 0 : 1, + 'to' => $total, ]; } @@ -198,6 +194,58 @@ class SchoolYearClosingController extends BaseController }; } + private function carryForwardTablePayload(array $rows): array + { + $allowedSorts = ['family', 'parent', 'source_balance', 'credits', 'adjustments', 'net']; + $sort = (string) ($this->request->getGet('cf_sort') ?? 'family'); + $sort = in_array($sort, $allowedSorts, true) ? $sort : 'family'; + $order = strtolower((string) ($this->request->getGet('cf_order') ?? 'asc')) === 'desc' ? 'desc' : 'asc'; + + usort($rows, function (array $a, array $b) use ($sort, $order): int { + $comparison = $this->compareCarryForwardRows($a, $b, $sort); + if ($comparison === 0 && $sort !== 'family') { + $comparison = $this->compareCarryForwardRows($a, $b, 'family'); + } + if ($comparison === 0) { + $comparison = ((int) ($a['family_id'] ?? 0)) <=> ((int) ($b['family_id'] ?? 0)); + } + + return $order === 'desc' ? -$comparison : $comparison; + }); + + return [ + 'rows' => $rows, + 'sort' => $sort, + 'order' => $order, + ]; + } + + private function compareCarryForwardRows(array $a, array $b, string $sort): int + { + $numericSorts = ['source_balance', 'credits', 'adjustments', 'net']; + $aValue = $this->carryForwardSortValue($a, $sort); + $bValue = $this->carryForwardSortValue($b, $sort); + + if (in_array($sort, $numericSorts, true)) { + return (float) $aValue <=> (float) $bValue; + } + + return strnatcasecmp((string) $aValue, (string) $bValue); + } + + private function carryForwardSortValue(array $row, string $sort): mixed + { + return match ($sort) { + 'family' => (string) ($row['family'] ?? ''), + 'parent' => trim((string) ($row['parent'] ?? '') . ' ' . (string) ($row['parent_email'] ?? '')), + 'source_balance' => (float) ($row['source_balance'] ?? 0), + 'credits' => (float) ($row['credit_amount'] ?? 0), + 'adjustments' => (float) ($row['adjustment_amount'] ?? 0), + 'net' => (float) ($row['carry_forward_amount'] ?? 0), + default => '', + }; + } + private function userId(): ?int { $id = session('user_id') ?? session('id'); diff --git a/app/Controllers/View/GradingController.php b/app/Controllers/View/GradingController.php index fc0bd35..ffa8860 100644 --- a/app/Controllers/View/GradingController.php +++ b/app/Controllers/View/GradingController.php @@ -31,6 +31,7 @@ use App\Models\GradingLockModel; use App\Models\BelowSixtyDecisionModel; use App\Models\StudentDecisionModel; use App\Services\NavbarService; +use App\Support\Enrollment\DeliberationDecision; //use App\Models\ScoreModel; @@ -3399,13 +3400,16 @@ public function allDecisions() 's.firstname', 's.lastname', 's.gender', + 's.dob', 'ss.class_section_id', 'cs.class_section_name', + 'c.class_name', 'LOWER(TRIM(ss.semester)) AS sem_key', 'ss.semester_score', ]) ->join('students s', 's.id = ss.student_id', 'inner') ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left') + ->join('classes c', 'c.id = cs.class_id', 'left') ->where('s.is_active', 1) ->where('ss.school_year', $schoolYear) ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring']) @@ -3432,7 +3436,9 @@ public function allDecisions() 'firstname' => $sr['firstname'] ?? '', 'lastname' => $sr['lastname'] ?? '', 'gender' => $sr['gender'] ?? '', + 'dob' => $sr['dob'] ?? '', 'class_section_id' => (int)($sr['class_section_id'] ?? 0), + 'class_name' => $sr['class_name'] ?? '', 'class_section_name' => $sr['class_section_name'] ?? '', 'fall_score' => null, 'spring_score' => null, @@ -3513,18 +3519,30 @@ public function allDecisions() $notes = ''; } + $currentClassSectionName = trim((string)($info['class_section_name'] ?? '')); + if ($currentClassSectionName === '' && isset($savedMap[$sid])) { + $currentClassSectionName = trim((string)($savedMap[$sid]['class_section_name'] ?? '')); + } + $currentClassName = trim((string)($info['class_name'] ?? '')); + if ($currentClassName === '') { + $currentClassName = $this->classOnlyLabel($currentClassSectionName); + } + $rows[] = [ 'student_id' => $sid, 'school_id' => $info['school_id'], 'firstname' => $info['firstname'], 'lastname' => $info['lastname'], 'gender' => $info['gender'] ?? '', + 'dob' => $info['dob'] ?? '', 'class_section_id' => (int)($info['class_section_id'] ?? 0), - 'class_section_name' => $info['class_section_name'], + 'class_name' => $currentClassName, + 'class_section_name' => $currentClassSectionName, 'fall_score' => $fall, 'spring_score' => $spring, 'year_score' => $yearScore, 'decision' => $decision, + 'next_year_placement' => $this->nextYearPlacementLabel($decision, $currentClassName, $currentClassSectionName, (string)($info['dob'] ?? ''), $schoolYear), 'source' => $source, 'notes' => $notes, 'saved' => isset($savedMap[$sid]), @@ -3741,6 +3759,76 @@ public function generateAllDecisions() ->with('status', "Decisions generated for {$savedCount} students."); } +private function nextYearPlacementLabel( + ?string $decision, + string $currentClassName, + string $currentClassSectionName, + string $dob, + string $schoolYear +): string +{ + $normalizedDecision = DeliberationDecision::normalize($decision); + $classLabel = $this->classOnlyLabel($currentClassName !== '' ? $currentClassName : $currentClassSectionName); + + if ($this->isKgClass($classLabel)) { + $kgPlacement = $this->kgPlacementByComingSeptember($dob, $schoolYear); + if ($kgPlacement !== '') { + return $kgPlacement; + } + } + + if ($normalizedDecision === DeliberationDecision::REPEAT_CLASS) { + return $classLabel; + } + + return ''; +} + +private function classOnlyLabel(string $className): string +{ + return trim((string) preg_replace('/-.+$/', '', $className)); +} + +private function isKgClass(string $className): bool +{ + $value = strtoupper(trim($className)); + + return preg_match('/(^|[^A-Z0-9])KG([^A-Z0-9]|$)/', $value) === 1 || str_contains($value, 'KINDERGARTEN'); +} + +private function kgPlacementByComingSeptember(string $dob, string $schoolYear): string +{ + $dob = trim($dob); + if ($dob === '') { + return ''; + } + + try { + $birthDate = new \DateTimeImmutable($dob); + $cutoff = $this->comingSeptemberFirstCutoff($schoolYear); + } catch (\Throwable) { + return ''; + } + + if ($birthDate > $cutoff) { + return ''; + } + + return $birthDate->diff($cutoff)->y >= 6 ? '1' : 'KG'; +} + +private function comingSeptemberFirstCutoff(string $schoolYear): \DateTimeImmutable +{ + if (preg_match('/^(\d{4})-\d{4}$/', $schoolYear, $matches) === 1) { + return new \DateTimeImmutable(((int) $matches[1] + 1) . '-09-01'); + } + + $today = new \DateTimeImmutable('today'); + $cutoff = new \DateTimeImmutable($today->format('Y') . '-09-01'); + + return $today <= $cutoff ? $cutoff : $cutoff->modify('+1 year'); +} + private function calculateTrophyThreshold(array $scores, float $percentile = 75.0): array { $scores = array_values(array_filter( diff --git a/app/Services/SchoolYearClosingService.php b/app/Services/SchoolYearClosingService.php index 0b4db28..86ead1e 100644 --- a/app/Services/SchoolYearClosingService.php +++ b/app/Services/SchoolYearClosingService.php @@ -595,9 +595,10 @@ final class SchoolYearClosingService $builder = $this->db->table('student_class sc') ->select('sc.student_id, sc.class_section_id, sc.created_at, sc.updated_at') ->select('s.school_id, s.firstname, s.lastname') - ->select('cs.class_section_name') + ->select('cs.class_section_name, cs.class_id, c.class_name') ->join('students s', 's.id = sc.student_id', 'inner') ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->join('classes c', 'c.id = cs.class_id', 'left') ->where('sc.school_year', $schoolYear) ->where('sc.class_section_id IS NOT NULL', null, false); @@ -639,6 +640,8 @@ final class SchoolYearClosingService 'school_id' => (string) ($row['school_id'] ?? ''), 'student_name' => trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')), 'class_section_id' => (int) ($row['class_section_id'] ?? 0), + 'class_id' => (int) ($row['class_id'] ?? 0), + 'class_name' => (string) ($row['class_name'] ?? ''), 'class_section_name' => (string) ($row['class_section_name'] ?? ''), 'dob' => (string) ($row['dob'] ?? ''), 'registration_grade' => (string) ($row['registration_grade'] ?? ''), @@ -703,6 +706,14 @@ final class SchoolYearClosingService $student['target_section'] = $queue['target_section']; } + if ($this->isKgStudent($student)) { + $kgPlacement = $this->kgPlacementLabelByTargetYearStartCutoff((string) ($student['dob'] ?? ''), $targetSchoolYear); + if ($kgPlacement !== '') { + $student['target_section'] = ''; + $student['target_class'] = $kgPlacement; + } + } + $summary['total_students']++; if ($student['status'] === 'missing') { $summary['missing_decision']++; @@ -712,10 +723,22 @@ final class SchoolYearClosingService $summary['with_decision']++; if (($student['normalized_decision'] ?? null) === DeliberationDecision::PASSED) { $summary['pass']++; + if ($student['target_class'] === '') { + $student['target_class'] = $this->nextClassLabelForPromotionPreview( + (string) ($student['class_name'] ?: $student['class_section_name']), + $targetSchoolYear + ); + } if ($queue === null && $student['auto_kg_pass'] !== true) { $summary['missing_queue']++; } } else { + if (($student['normalized_decision'] ?? null) === DeliberationDecision::REPEAT_CLASS) { + $student['target_section'] = ''; + $student['target_class'] = $student['target_class'] !== '' + ? $student['target_class'] + : $this->classOnlyLabel((string) ($student['class_name'] ?: $student['class_section_name'])); + } $summary['other_decision']++; } } @@ -740,9 +763,9 @@ final class SchoolYearClosingService private function isKgStudent(array $student): bool { - foreach (['class_section_name', 'registration_grade'] as $field) { + foreach (['class_name', 'class_section_name'] as $field) { $value = strtoupper(trim((string) ($student[$field] ?? ''))); - if ($value === 'KG' || str_starts_with($value, 'KG-') || str_contains($value, 'KINDERGARTEN')) { + if (preg_match('/(^|[^A-Z0-9])KG([^A-Z0-9]|$)/', $value) === 1 || str_contains($value, 'KINDERGARTEN')) { return true; } } @@ -753,13 +776,13 @@ final class SchoolYearClosingService private function kgAgeStatusByTargetYearStartCutoff(string $dob, ?string $targetSchoolYear): string { $dob = trim($dob); - if ($dob === '' || $targetSchoolYear === null || ! preg_match('/^(\d{4})-\d{4}$/', $targetSchoolYear, $matches)) { + if ($dob === '') { return ''; } try { $birthDate = new \DateTimeImmutable($dob); - $cutoff = new \DateTimeImmutable($matches[1] . '-09-01'); + $cutoff = $this->septemberFirstCutoff($targetSchoolYear); } catch (\Throwable) { return ''; } @@ -776,6 +799,27 @@ final class SchoolYearClosingService return 'keep_kg'; } + private function kgPlacementLabelByTargetYearStartCutoff(string $dob, ?string $targetSchoolYear): string + { + return match ($this->kgAgeStatusByTargetYearStartCutoff($dob, $targetSchoolYear)) { + 'pass' => '1', + 'keep_kg' => 'KG', + default => '', + }; + } + + private function septemberFirstCutoff(?string $targetSchoolYear): \DateTimeImmutable + { + if ($targetSchoolYear !== null && preg_match('/^(\d{4})-\d{4}$/', $targetSchoolYear, $matches) === 1) { + return new \DateTimeImmutable($matches[1] . '-09-01'); + } + + $today = new \DateTimeImmutable('today'); + $cutoff = new \DateTimeImmutable($today->format('Y') . '-09-01'); + + return $today <= $cutoff ? $cutoff : $cutoff->modify('+1 year'); + } + private function promotionDecisionRows(array $studentIds, string $schoolYear): array { if (! $this->db->tableExists('student_decisions')) { @@ -880,6 +924,83 @@ final class SchoolYearClosingService return $queueRows; } + private function nextClassLabelForPromotionPreview(string $sourceClassName, ?string $targetSchoolYear): string + { + $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 ''; + } + + if ($targetSchoolYear !== null && $targetSchoolYear !== '' && $this->db->tableExists('classes')) { + $targetClass = $this->classByNameForPromotionPreview($target, $targetSchoolYear); + if ($targetClass !== null) { + return (string) ($targetClass['class_name'] ?? $target); + } + + if (ctype_digit($base) && (int) $base >= 9) { + $youthClass = $this->classByNameForPromotionPreview('YOUTH', $targetSchoolYear); + if ($youthClass !== null) { + return (string) ($youthClass['class_name'] ?? 'YOUTH'); + } + } + } + + return $target; + } + + private function classOnlyLabel(string $className): string + { + return trim((string) preg_replace('/-.+$/', '', $className)); + } + + 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('/(^|[^A-Z0-9])KG([^A-Z0-9]|$)/', $base) === 1) { + return 'KG'; + } + + if (preg_match('/\d+/', $base, $matches) === 1) { + return (string) (int) $matches[0]; + } + + return $base; + } + + private function classByNameForPromotionPreview(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 countBySchoolYear(string $table, string $schoolYear, string $distinctField): int { if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) { diff --git a/app/Views/grading/all_decisions.php b/app/Views/grading/all_decisions.php index 7520ccc..a9051e8 100644 --- a/app/Views/grading/all_decisions.php +++ b/app/Views/grading/all_decisions.php @@ -214,6 +214,7 @@ Spring Score Year Score Decision + Next year placement Source Notes @@ -251,6 +252,7 @@ + @@ -312,7 +314,7 @@ order: [[1, 'asc'], [0, 'asc']], pageLength: 100, lengthMenu: [25, 50, 100, 200], - columnDefs: [{ orderable: false, targets: [7] }] + columnDefs: [{ orderable: false, targets: [8] }] }); } catch (_) {} }); diff --git a/app/Views/school_years/closing_preview.php b/app/Views/school_years/closing_preview.php index c77b7ec..b2b4346 100644 --- a/app/Views/school_years/closing_preview.php +++ b/app/Views/school_years/closing_preview.php @@ -8,17 +8,8 @@ $finance = $preview['finance'] ?? []; $promotion = $preview['promotion'] ?? ['summary' => [], 'rows' => []]; $promotionSummary = $promotion['summary'] ?? []; - $promotionTable = $promotionTable ?? []; - $promotionRows = $promotionTable['rows'] ?? ($promotion['rows'] ?? []); - $promotionSort = (string) ($promotionTable['sort'] ?? 'class'); - $promotionOrder = (string) ($promotionTable['order'] ?? 'asc'); - $promotionPage = (int) ($promotionTable['page'] ?? 1); - $promotionPerPage = (int) ($promotionTable['perPage'] ?? 25); - $promotionTotal = (int) ($promotionTable['total'] ?? count($promotionRows)); - $promotionPageCount = (int) ($promotionTable['pageCount'] ?? 1); - $promotionFrom = (int) ($promotionTable['from'] ?? ($promotionTotal === 0 ? 0 : 1)); - $promotionTo = (int) ($promotionTable['to'] ?? count($promotionRows)); - $promotionPerPageOptions = $promotionTable['allowedPerPage'] ?? [10, 25, 50, 100]; + $promotionRows = $promotion['rows'] ?? []; + $promotionTotal = count($promotionRows); $blockers = $preview['blockers'] ?? []; $warnings = $preview['warnings'] ?? []; $carryForward = $preview['carry_forward'] ?? []; @@ -27,27 +18,61 @@ $missingCarryForwardInvoices = (int) ($missingCarryForwardInvoices ?? 0); $money = static fn ($value): string => '$' . number_format((float) $value, 2); $score = static fn ($value): string => is_numeric($value) ? number_format((float) $value, 2) : '-'; - $promotionUrl = static function (array $overrides = []) use ($source, $target, $promotionSort, $promotionOrder, $promotionPage, $promotionPerPage): string { - $query = array_merge([ - 'target_school_year_id' => $target['id'] ?? null, - 'sort' => $promotionSort, - 'order' => $promotionOrder, - 'page' => $promotionPage, - 'per_page' => $promotionPerPage, - ], $overrides); - $query = array_filter($query, static fn ($value): bool => $value !== null && $value !== ''); + $ageBySchoolYearSecondSeptember = static function (array $row) use ($source): string { + $dob = trim((string) ($row['dob'] ?? '')); + $schoolYear = (string) ($source['name'] ?? ''); + if ($dob === '' || preg_match('/^\d{4}-(\d{4})$/', $schoolYear, $matches) !== 1) { + return ''; + } - return site_url('administrator/school-years/' . (int) ($source['id'] ?? 0) . '/closing/preview?' . http_build_query($query)); + try { + $birthDate = new DateTimeImmutable($dob); + $cutoff = new DateTimeImmutable($matches[1] . '-09-01'); + } catch (Throwable) { + return ''; + } + + if ($birthDate > $cutoff) { + return ''; + } + + return (string) $birthDate->diff($cutoff)->y; }; - $sortLink = static function (string $key, string $label, string $class = '') use ($promotionSort, $promotionOrder, $promotionUrl): string { - $nextOrder = $promotionSort === $key && $promotionOrder === 'asc' ? 'desc' : 'asc'; - $indicator = $promotionSort === $key ? ($promotionOrder === 'asc' ? ' (asc)' : ' (desc)') : ''; + $kgPlacementFallback = static function (array $row) use ($target): string { + $classText = strtoupper(trim( + (string) ($row['class_name'] ?? '') . ' ' + . (string) ($row['class_section_name'] ?? '') + )); + if (preg_match('/(^|[^A-Z0-9])KG([^A-Z0-9]|$)/', $classText) !== 1 && ! str_contains($classText, 'KINDERGARTEN')) { + return ''; + } - return '' . esc($label . $indicator) . ''; + $dob = trim((string) ($row['dob'] ?? '')); + if ($dob === '') { + return ''; + } + + try { + $targetName = (string) ($target['name'] ?? ''); + if (preg_match('/^(\d{4})-\d{4}$/', $targetName, $matches) === 1) { + $cutoff = new DateTimeImmutable($matches[1] . '-09-01'); + } else { + $today = new DateTimeImmutable('today'); + $cutoff = new DateTimeImmutable($today->format('Y') . '-09-01'); + if ($today > $cutoff) { + $cutoff = $cutoff->modify('+1 year'); + } + } + $birthDate = new DateTimeImmutable($dob); + } catch (Throwable) { + return ''; + } + + if ($birthDate > $cutoff) { + return ''; + } + + return $birthDate->diff($cutoff)->y >= 6 ? '1' : 'KG'; }; ?> @@ -188,7 +213,7 @@ -
+
Student Promotion Preview
@@ -232,52 +257,26 @@
-
-
- Showing - of students -
-
- - - - - - - - -
-
-
- +
- - - - - - - - - + + + + + + + + + - - - - + + - - - + +
StudentSchool IDClassAge Sep 1Year ScoreDecisionSourceNext Year PlacementStatus
No active class assignments found for this school year.
- - 1): ?> - - -
-
+
Carry-Forward Families
- +
@@ -345,9 +322,6 @@ - - - @@ -357,10 +331,10 @@
- - - - + + + + @@ -421,3 +395,60 @@ endSection() ?> + +section('scripts') ?> + +endSection() ?>
Family
No carry-forward balances found.