From ac19226bf023c5b84e946ee4525a251c8d97a424 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 9 Sep 2026 23:02:16 -0400 Subject: [PATCH] add features to family card, scores, invoices and students details --- app/Config/Routes.php | 6 +- .../View/FamilyAdminController.php | 144 ++++++++- app/Services/StudentScoreHistoryService.php | 235 +++++++++++++++ app/Views/administrator/student_profiles.php | 4 +- app/Views/family/card.php | 275 ++++++++++++++++-- app/Views/layout/main_layout.php | 3 + app/Views/layout/management_layout.php | 3 + .../Config/FamilyCardRouteIntegrityTest.php | 30 ++ .../View/FamilyAdminControllerAccessTest.php | 38 +++ .../View/InventorySchemaNormalizationTest.php | 2 +- tests/app/Views/FamilyCardViewTest.php | 120 ++++++++ 11 files changed, 822 insertions(+), 38 deletions(-) create mode 100644 app/Services/StudentScoreHistoryService.php create mode 100644 tests/app/Config/FamilyCardRouteIntegrityTest.php create mode 100644 tests/app/Controllers/View/FamilyAdminControllerAccessTest.php create mode 100644 tests/app/Views/FamilyCardViewTest.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index a1373ff..eba839e 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1074,10 +1074,14 @@ $routes->group('family', ['filter' => 'auth:admin|principal'], static function ( $routes->get('', 'View\FamilyAdminController::index'); $routes->get('index', 'View\FamilyAdminController::index'); $routes->get('search', 'View\FamilyAdminController::search'); - $routes->get('card', 'View\FamilyAdminController::card'); $routes->get('compose-email', 'View\FamilyAdminController::composeEmail'); $routes->post('compose-email/send', 'View\FamilyAdminController::sendComposeEmail'); }); +// Teachers and TAs may inspect family/student details from their student lists. +// Financial data remains protected inside FamilyAdminController::card(). +$routes->get('family/card', 'View\FamilyAdminController::card', [ + 'filter' => 'auth:admin|administrator|administrative staff|principal|teacher|teacher_assistant', +]); // Convenience alias $routes->get('family', 'View\FamilyAdminController::index', ['filter' => 'auth:admin|principal']); ////////////////////////////////////////////////////////// diff --git a/app/Controllers/View/FamilyAdminController.php b/app/Controllers/View/FamilyAdminController.php index 283b482..2eb9a8f 100644 --- a/app/Controllers/View/FamilyAdminController.php +++ b/app/Controllers/View/FamilyAdminController.php @@ -239,6 +239,7 @@ class FamilyAdminController extends BaseController public function card(): ResponseInterface { $db = \Config\Database::connect(); + $canViewInvoices = $this->canViewFamilyInvoices(); $studentId = (int) ($this->request->getGet('student_id') ?? 0); $guardianId = (int) ($this->request->getGet('guardian_id') ?? 0); $familyId = (int) ($this->request->getGet('family_id') ?? 0); @@ -338,9 +339,8 @@ class FamilyAdminController extends BaseController // Hydrate with guardians, students (+grades), invoices, payments $invoiceModel = new \App\Models\InvoiceModel(); $paymentModel = new \App\Models\PaymentModel(); - $studentClassModel = new \App\Models\StudentClassModel(); $configModel = new \App\Models\ConfigurationModel(); - $schoolYear = (string) ($configModel->getConfig('school_year') ?? ''); + $schoolYear = $this->currentSchoolYearName((string) ($configModel->getConfig('school_year') ?? '')); // Guardians $guardians = $db->query( @@ -354,10 +354,11 @@ class FamilyAdminController extends BaseController [$familyId] )->getResultArray(); $family['guardians'] = $guardians; + $family['can_view_invoices'] = $canViewInvoices; // Students $studentsRows = $db->query( - "SELECT s.id, s.firstname, s.lastname + "SELECT s.* FROM family_students fs JOIN students s ON s.id = fs.student_id WHERE fs.family_id = ? @@ -369,7 +370,7 @@ class FamilyAdminController extends BaseController $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 + "SELECT * FROM students WHERE id = ? LIMIT 1", @@ -383,13 +384,101 @@ class FamilyAdminController extends BaseController } if (!empty($studentsRows)) { + $studentIds = array_values(array_filter(array_map( + static fn(array $row): int => (int) ($row['id'] ?? 0), + $studentsRows + ))); + $allergiesByStudent = []; + $conditionsByStudent = []; + $classAssignmentsByStudent = []; + $enrollmentByStudent = []; + $scoreHistoryByStudent = []; + + if (!empty($studentIds)) { + if ($schoolYear !== '') { + $classSectionJoin = 'cs.class_section_id = sc.class_section_id'; + if ($db->fieldExists('school_year', 'classSection')) { + $classSectionJoin .= ' AND (cs.school_year = sc.school_year OR cs.school_year IS NULL)'; + } + + $classAssignmentRows = $db->table('student_class sc') + ->select('sc.student_id, cs.class_section_name, c.class_name') + ->join('classSection cs', $classSectionJoin, 'left') + ->join('classes c', 'c.id = cs.class_id', 'left') + ->whereIn('sc.student_id', $studentIds) + ->where('sc.school_year', $schoolYear) + ->where('sc.class_section_id IS NOT NULL', null, false) + ->orderBy('cs.class_section_name', 'ASC') + ->get() + ->getResultArray(); + foreach ($classAssignmentRows as $classAssignmentRow) { + $assignmentStudentId = (int) ($classAssignmentRow['student_id'] ?? 0); + $className = trim((string) ($classAssignmentRow['class_name'] ?? '')); + $sectionName = trim((string) ($classAssignmentRow['class_section_name'] ?? '')); + $label = $className !== '' && $sectionName !== '' && strcasecmp($className, $sectionName) !== 0 + ? $className . ' / ' . $sectionName + : ($sectionName !== '' ? $sectionName : $className); + + if ($assignmentStudentId > 0 && $label !== '') { + $classAssignmentsByStudent[$assignmentStudentId][$label] = true; + } + } + + $enrollmentRows = $db->table('enrollments e') + ->select('e.student_id, e.enrollment_status') + ->whereIn('e.student_id', $studentIds) + ->where('e.school_year', $schoolYear) + ->orderBy('e.updated_at', 'DESC') + ->orderBy('e.enrollment_date', 'DESC') + ->orderBy('e.id', 'DESC') + ->get() + ->getResultArray(); + + foreach ($enrollmentRows as $enrollmentRow) { + $enrollmentStudentId = (int) ($enrollmentRow['student_id'] ?? 0); + if ($enrollmentStudentId > 0 && !isset($enrollmentByStudent[$enrollmentStudentId])) { + $enrollmentByStudent[$enrollmentStudentId] = $enrollmentRow; + } + } + } + + $allergyRows = $db->table('student_allergies') + ->select('student_id, allergy') + ->whereIn('student_id', $studentIds) + ->orderBy('allergy', 'ASC') + ->get() + ->getResultArray(); + foreach ($allergyRows as $allergyRow) { + $allergiesByStudent[(int) ($allergyRow['student_id'] ?? 0)][] = (string) ($allergyRow['allergy'] ?? ''); + } + + $conditionRows = $db->table('student_medical_conditions') + ->select('student_id, condition_name') + ->whereIn('student_id', $studentIds) + ->orderBy('condition_name', 'ASC') + ->get() + ->getResultArray(); + foreach ($conditionRows as $conditionRow) { + $conditionsByStudent[(int) ($conditionRow['student_id'] ?? 0)][] = (string) ($conditionRow['condition_name'] ?? ''); + } + + $scoreHistoryByStudent = (new \App\Services\StudentScoreHistoryService($db)) + ->forStudents($studentIds); + } + foreach ($studentsRows as &$sr) { $sid = (int) ($sr['id'] ?? 0); - $sr['grade'] = $sid ? (string) ($studentClassModel->getClassSectionsByStudentId($sid, $schoolYear) ?? '') : ''; + $enrollment = $enrollmentByStudent[$sid] ?? []; + $sr['grade'] = implode(', ', array_keys($classAssignmentsByStudent[$sid] ?? [])); + $sr['enrollment_status'] = (string) ($enrollment['enrollment_status'] ?? ''); + $sr['allergies'] = $allergiesByStudent[$sid] ?? []; + $sr['medical_conditions'] = $conditionsByStudent[$sid] ?? []; + $sr['score_history'] = $scoreHistoryByStudent[$sid] ?? []; } unset($sr); } $family['students'] = $studentsRows; + $family['selected_student_id'] = $studentId; // Financials $parentIds = array_map(static fn($g) => (int)($g['user_id'] ?? 0), $guardians); @@ -427,13 +516,21 @@ class FamilyAdminController extends BaseController } } - if (!empty($parentIds)) { + if ($canViewInvoices && !empty($parentIds)) { // Invoices - $invRows = $db->table('invoices') + $invoiceBuilder = $db->table('invoices') ->select('id, parent_id, invoice_number, status, total_amount, paid_amount, balance, issue_date, due_date') ->whereIn('parent_id', $parentIds) - ->orderBy('issue_date', 'DESC') - ->get()->getResultArray(); + ->orderBy('issue_date', 'DESC'); + if ($schoolYear !== '') { + $invoiceBuilder->where('school_year', $schoolYear); + } + $invRows = $invoiceBuilder->get()->getResultArray(); + foreach ($invRows as &$invoiceRow) { + $invoiceParentId = (int) ($invoiceRow['parent_id'] ?? 0); + $invoiceRow['parent_name'] = $gmap[$invoiceParentId] ?? ('Parent #' . $invoiceParentId); + } + unset($invoiceRow); $family['invoices'] = $invRows; $invoiceMap = []; foreach ($invRows as $ir) { @@ -449,20 +546,43 @@ class FamilyAdminController extends BaseController } // Payments - $payRows = $db->table('payments p') + $paymentBuilder = $db->table('payments p') ->select('p.id, p.parent_id, p.invoice_id, p.paid_amount, p.payment_method, p.payment_date, p.status AS payment_status, p.installment_seq, p.number_of_installments, i.invoice_number, i.balance AS invoice_current_balance, i.status AS invoice_status, i.school_year') ->join('invoices i', 'i.id = p.invoice_id', 'inner') ->whereIn('p.parent_id', $parentIds) ->orderBy('p.payment_date', 'DESC') ->orderBy('p.id', 'DESC') - ->limit(10) - ->get()->getResultArray(); + ->limit(10); + if ($schoolYear !== '') { + $paymentBuilder->where('i.school_year', $schoolYear); + } + $payRows = $paymentBuilder->get()->getResultArray(); + foreach ($payRows as &$paymentRow) { + $paymentParentId = (int) ($paymentRow['parent_id'] ?? 0); + $paymentRow['parent_name'] = $gmap[$paymentParentId] ?? ('Parent #' . $paymentParentId); + } + unset($paymentRow); $family['payments'] = $payRows; } return service('response')->setBody(view('family/card', ['f' => $family])); } + private function canViewFamilyInvoices(): bool + { + $roles = array_map( + static fn($role): string => strtolower(trim((string) $role)), + array_filter(array_merge((array) session()->get('roles'), [session()->get('role')])) + ); + + return (bool) array_intersect(array_unique($roles), [ + 'admin', + 'administrator', + 'administrative staff', + 'principal', + ]); + } + public function composeEmail() { $to = trim((string)$this->request->getGet('to')); diff --git a/app/Services/StudentScoreHistoryService.php b/app/Services/StudentScoreHistoryService.php new file mode 100644 index 0000000..b17c3fd --- /dev/null +++ b/app/Services/StudentScoreHistoryService.php @@ -0,0 +1,235 @@ +>> + */ + public function forStudents(array $studentIds): array + { + $studentIds = array_values(array_unique(array_filter( + array_map('intval', $studentIds), + static fn(int $id): bool => $id > 0 + ))); + if ($studentIds === []) { + return []; + } + + $history = []; + $semesterRows = $this->latestSemesterRows($studentIds); + foreach ($semesterRows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + $schoolYear = trim((string) ($row['school_year'] ?? '')); + $semester = $this->normalizeSemester($row['semester'] ?? ''); + if ($studentId <= 0 || $schoolYear === '' || !in_array($semester, ['fall', 'spring'], true)) { + continue; + } + + $history[$studentId][$schoolYear] ??= $this->emptyYear($schoolYear); + if ($semester === 'fall') { + $history[$studentId][$schoolYear]['midterm_s1'] = $this->score($row['midterm_exam_score'] ?? null); + $history[$studentId][$schoolYear]['ptap_s1'] = $this->score($row['ptap_score'] ?? null); + $history[$studentId][$schoolYear]['attendance_s1'] = $this->score($row['attendance_score'] ?? null); + $history[$studentId][$schoolYear]['_semester_score_s1'] = $this->score($row['semester_score'] ?? null); + } else { + $finalScore = $this->score($row['final_exam_score'] ?? null); + if ($finalScore === null) { + // Some older Spring rows stored the final in this legacy column. + $finalScore = $this->score($row['midterm_exam_score'] ?? null); + } + $history[$studentId][$schoolYear]['final_s2'] = $finalScore; + $history[$studentId][$schoolYear]['ptap_s2'] = $this->score($row['ptap_score'] ?? null); + $history[$studentId][$schoolYear]['attendance_s2'] = $this->score($row['attendance_score'] ?? null); + $history[$studentId][$schoolYear]['_semester_score_s2'] = $this->score($row['semester_score'] ?? null); + } + } + + foreach ($this->commentRows($studentIds) as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + $schoolYear = trim((string) ($row['school_year'] ?? '')); + $semester = $this->normalizeSemester($row['semester'] ?? ''); + $type = $this->normalizeCommentType($row['score_type'] ?? ''); + if ($studentId <= 0 || $schoolYear === '') { + continue; + } + + $history[$studentId][$schoolYear] ??= $this->emptyYear($schoolYear); + + $field = match ($semester . ':' . $type) { + 'fall:midterm' => 'midterm_comment_s1', + 'fall:ptap' => 'ptap_comment_s1', + 'fall:attendance' => 'attendance_comment_s1', + 'spring:final' => 'final_comment_s2', + 'spring:ptap' => 'ptap_comment_s2', + 'spring:attendance' => 'attendance_comment_s2', + default => null, + }; + if ($field === null || $history[$studentId][$schoolYear][$field] !== '') { + continue; + } + + $comment = trim((string) ($row['comment'] ?? '')); + $review = trim((string) ($row['comment_review'] ?? '')); + $history[$studentId][$schoolYear][$field] = $type === 'attendance' + ? ($comment !== '' ? $comment : $review) + : ($review !== '' ? $review : $comment); + } + + foreach ($this->decisionRows($studentIds) as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + $schoolYear = trim((string) ($row['school_year'] ?? '')); + if ($studentId <= 0 || $schoolYear === '') { + continue; + } + + $history[$studentId][$schoolYear] ??= $this->emptyYear($schoolYear); + if ($history[$studentId][$schoolYear]['year_score'] === null) { + $history[$studentId][$schoolYear]['year_score'] = $this->score($row['year_score'] ?? null); + } + } + + foreach ($history as &$studentHistory) { + foreach ($studentHistory as &$year) { + if ($year['year_score'] === null) { + $semesterScores = array_values(array_filter( + [$year['_semester_score_s1'], $year['_semester_score_s2']], + static fn($score): bool => $score !== null + )); + if ($semesterScores !== []) { + $year['year_score'] = round(array_sum($semesterScores) / count($semesterScores), 1); + } + } + unset($year['_semester_score_s1'], $year['_semester_score_s2']); + } + unset($year); + krsort($studentHistory, SORT_STRING); + $studentHistory = array_values($studentHistory); + } + unset($studentHistory); + + return $history; + } + + private function latestSemesterRows(array $studentIds): array + { + if (!$this->db->tableExists('semester_scores')) { + return []; + } + + $rows = $this->db->table('semester_scores') + ->select('id, student_id, school_year, semester, midterm_exam_score, final_exam_score, ptap_score, attendance_score, semester_score, updated_at') + ->whereIn('student_id', $studentIds) + ->orderBy('school_year', 'DESC') + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + + $latest = []; + foreach ($rows as $row) { + $key = (int) ($row['student_id'] ?? 0) + . '|' . trim((string) ($row['school_year'] ?? '')) + . '|' . $this->normalizeSemester($row['semester'] ?? ''); + $latest[$key] ??= $row; + } + + return array_values($latest); + } + + private function commentRows(array $studentIds): array + { + if (!$this->db->tableExists('score_comments')) { + return []; + } + + $builder = $this->db->table('score_comments') + ->select('id, student_id, school_year, semester, score_type, comment, comment_review') + ->whereIn('student_id', $studentIds); + if ($this->db->fieldExists('updated_at', 'score_comments')) { + $builder->orderBy('updated_at', 'DESC'); + } elseif ($this->db->fieldExists('created_at', 'score_comments')) { + $builder->orderBy('created_at', 'DESC'); + } + + return $builder->orderBy('id', 'DESC')->get()->getResultArray(); + } + + private function decisionRows(array $studentIds): array + { + if (!$this->db->tableExists('student_decisions')) { + return []; + } + + return $this->db->table('student_decisions') + ->select('id, student_id, school_year, year_score') + ->whereIn('student_id', $studentIds) + ->orderBy('school_year', 'DESC') + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + } + + private function emptyYear(string $schoolYear): array + { + return [ + 'school_year' => $schoolYear, + 'midterm_s1' => null, + 'midterm_comment_s1' => '', + 'ptap_s1' => null, + 'ptap_comment_s1' => '', + 'attendance_s1' => null, + 'attendance_comment_s1' => '', + 'final_s2' => null, + 'final_comment_s2' => '', + 'ptap_s2' => null, + 'ptap_comment_s2' => '', + 'attendance_s2' => null, + 'attendance_comment_s2' => '', + 'year_score' => null, + '_semester_score_s1' => null, + '_semester_score_s2' => null, + ]; + } + + private function normalizeSemester($value): string + { + $semester = strtolower(trim((string) $value)); + return match ($semester) { + 'fall', 'first', 'first semester', 'semester 1', '1' => 'fall', + 'spring', 'second', 'second semester', 'semester 2', '2' => 'spring', + default => $semester, + }; + } + + private function normalizeCommentType($value): string + { + $type = strtolower(trim((string) $value)); + $type = trim((string) preg_replace('/[^a-z0-9]+/', '_', $type), '_'); + + return match ($type) { + 'midterm_comment', 'midterm_comments' => 'midterm', + 'final_comment', 'final_comments' => 'final', + 'ptap_comment', 'ptap_comments' => 'ptap', + 'attendance_comment', 'attendance_comments', 'attendence', 'attendence_comment' => 'attendance', + default => $type, + }; + } + + private function score($value): ?float + { + return $value !== null && $value !== '' && is_numeric($value) + ? round((float) $value, 2) + : null; + } +} diff --git a/app/Views/administrator/student_profiles.php b/app/Views/administrator/student_profiles.php index f5d43e6..40fe336 100644 --- a/app/Views/administrator/student_profiles.php +++ b/app/Views/administrator/student_profiles.php @@ -160,13 +160,13 @@ $selectedYear = trim((string)($selectedYear ?? '')); - + - + diff --git a/app/Views/family/card.php b/app/Views/family/card.php index 1022a88..f5fde55 100644 --- a/app/Views/family/card.php +++ b/app/Views/family/card.php @@ -4,6 +4,41 @@ $gCount = count($f['guardians'] ?? []); $sCount = count($f['students'] ?? []); $sum = $f['finance_summary'] ?? ['invoices_count'=>0,'total_amount'=>0,'paid_amount'=>0,'balance'=>0]; +$canViewInvoices = !empty($f['can_view_invoices']); +$selectedStudentId = (int)($f['selected_student_id'] ?? 0); +$scoreDefaultStudentId = $selectedStudentId > 0 + ? $selectedStudentId + : (int)($f['students'][0]['id'] ?? 0); + +$displayValue = static function ($value, string $fallback = 'Not provided'): string { + if ($value === null || trim((string)$value) === '') { + return $fallback; + } + + return (string)$value; +}; + +$formatDate = static function ($value, bool $includeTime = false) use ($displayValue): string { + if ($value === null || trim((string)$value) === '') { + return 'Not provided'; + } + + try { + return $includeTime + ? local_datetime((string)$value, 'm-d-Y g:i A') + : (new \DateTime((string)$value))->format('m-d-Y'); + } catch (\Throwable $e) { + return $displayValue($value); + } +}; + +$formatScore = static function ($value): string { + if ($value === null || $value === '' || !is_numeric($value)) { + return '—'; + } + + return rtrim(rtrim(number_format((float)$value, 2, '.', ''), '0'), '.'); +}; // Title: prefer provided household_name unless it's generic like "Family of User 53" or empty. $titleRaw = trim((string)($f['household_name'] ?? '')); @@ -41,6 +76,14 @@ if ($returnUrl === '') { .family-card-root .fc-title { font-size: 1.3rem; letter-spacing: .2px; } .family-card-root .fc-name { font-size: 1.08rem; font-weight: 600; color: #0b5ed7; } .family-card-root .fc-name:hover { color: #084298; text-decoration: underline; } + .family-card-root .fc-student-details { background: #f8fafc; border-top: 1px solid #e5e7eb; } + .family-card-root .fc-detail-label { color: #64748b; font-size: .76rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; } + .family-card-root .fc-detail-value { color: #12344d; overflow-wrap: anywhere; } + .family-card-root .fc-health-card { border-left: 4px solid #2d89ec !important; } + .family-card-root .fc-score-table th { white-space: normal; min-width: 105px; vertical-align: middle; } + .family-card-root .fc-score-table td { white-space: normal; vertical-align: top; } + .family-card-root .fc-score-table .fc-score-comment { min-width: 220px; max-width: 320px; } + .family-card-root .fc-score-student-nav { flex-wrap: nowrap; overflow-x: auto; } .family-card-root .fc-badges .badge { background: rgba(255,255,255,.18); color: #fff; font-weight: 500; } .family-card-root .nav-tabs { padding-left: .5rem; padding-right: .5rem; } .family-card-root .nav-tabs .nav-link { color: #2161a7; font-weight: 600; } @@ -92,10 +135,12 @@ if ($returnUrl === '') {
Guardians: Students: - Invoices: - - Balance: $ - + + Invoices: + + Balance: $ + +
@@ -105,15 +150,20 @@ if ($returnUrl === '') { + - + + +
@@ -126,26 +176,202 @@ if ($returnUrl === '') {
No students linked.
- +
+ +
+
+ +
No students linked.
+ + + +
+ + +
+ +
No score history found for this student.
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
School YearMidterm S1Midterm Comment S1PTAP S1PTAP Comment S1Attendance S1Attendance Comment S1Final S2Final Comment S2PTAP S2PTAP Comment S2Attendance S2Attendance Comment S2Year Score
+
+ +
+ +
+ +
+
+
@@ -281,7 +507,8 @@ if ($returnUrl === '') {
- + +
@@ -313,7 +540,7 @@ if ($returnUrl === '') {
-
Invoices
+
Parent Invoices
No invoices on record.
@@ -321,18 +548,20 @@ if ($returnUrl === '') { - + + + @@ -350,13 +579,14 @@ if ($returnUrl === '') {
#StatusTotalPaidBalanceDateParent#StatusTotalPaidBalanceIssuedDue
$ $ $
- + + @@ -374,5 +604,6 @@ if ($returnUrl === '') { + diff --git a/app/Views/layout/main_layout.php b/app/Views/layout/main_layout.php index 5dd458e..a3e0885 100644 --- a/app/Views/layout/main_layout.php +++ b/app/Views/layout/main_layout.php @@ -369,6 +369,7 @@ html, body { overflow-x: hidden; } let sid = link.getAttribute('data-family-student-id'); let gid = link.getAttribute('data-family-guardian-id'); let fid = link.getAttribute('data-family-id'); + let schoolYear = link.getAttribute('data-family-school-year'); if (!sid && !gid && !fid && link.matches('a[href]')) { try { const url = new URL(link.getAttribute('href'), window.location.origin); @@ -378,6 +379,7 @@ html, body { overflow-x: hidden; } sid = url.searchParams.get('student_id'); gid = url.searchParams.get('guardian_id'); fid = url.searchParams.get('family_id'); + schoolYear = url.searchParams.get('school_year'); } catch (_) { return null; } @@ -386,6 +388,7 @@ html, body { overflow-x: hidden; } if (fid) params.family_id = fid; else if (sid) params.student_id = sid; else if (gid) params.guardian_id = gid; + if (schoolYear) params.school_year = schoolYear; return Object.keys(params).length ? params : null; } diff --git a/app/Views/layout/management_layout.php b/app/Views/layout/management_layout.php index fc47cf8..c36da0d 100644 --- a/app/Views/layout/management_layout.php +++ b/app/Views/layout/management_layout.php @@ -632,6 +632,7 @@ let sid = link.getAttribute('data-family-student-id'); let gid = link.getAttribute('data-family-guardian-id'); let fid = link.getAttribute('data-family-id'); + let schoolYear = link.getAttribute('data-family-school-year'); if (!sid && !gid && !fid && link.matches('a[href]')) { try { const url = new URL(link.getAttribute('href'), window.location.origin); @@ -641,6 +642,7 @@ sid = url.searchParams.get('student_id'); gid = url.searchParams.get('guardian_id'); fid = url.searchParams.get('family_id'); + schoolYear = url.searchParams.get('school_year'); } catch (_) { return null; } @@ -649,6 +651,7 @@ if (fid) params.family_id = fid; else if (sid) params.student_id = sid; else if (gid) params.guardian_id = gid; + if (schoolYear) params.school_year = schoolYear; return Object.keys(params).length ? params : null; } diff --git a/tests/app/Config/FamilyCardRouteIntegrityTest.php b/tests/app/Config/FamilyCardRouteIntegrityTest.php new file mode 100644 index 0000000..467cb8b --- /dev/null +++ b/tests/app/Config/FamilyCardRouteIntegrityTest.php @@ -0,0 +1,30 @@ +assertStringContainsString( + "\$routes->group('family', ['filter' => 'auth:admin|principal']", + $routes + ); + $this->assertStringContainsString( + "\$routes->get('family/card', 'View\\FamilyAdminController::card'", + $routes + ); + $this->assertStringContainsString( + "auth:admin|administrator|administrative staff|principal|teacher|teacher_assistant", + $routes + ); + $this->assertStringNotContainsString( + "\$routes->get('card', 'View\\FamilyAdminController::card');", + $routes + ); + } +} diff --git a/tests/app/Controllers/View/FamilyAdminControllerAccessTest.php b/tests/app/Controllers/View/FamilyAdminControllerAccessTest.php new file mode 100644 index 0000000..9df886e --- /dev/null +++ b/tests/app/Controllers/View/FamilyAdminControllerAccessTest.php @@ -0,0 +1,38 @@ +remove(['role', 'roles']); + parent::tearDown(); + } + + public function testTeachersAndAssistantsCannotViewFamilyInvoices(): void + { + $controller = new FamilyAdminController(); + $method = new \ReflectionMethod($controller, 'canViewFamilyInvoices'); + + session()->set('role', 'teacher'); + $this->assertFalse($method->invoke($controller)); + + session()->set('role', 'teacher_assistant'); + $this->assertFalse($method->invoke($controller)); + } + + public function testAdministrativeRolesCanViewFamilyInvoices(): void + { + $controller = new FamilyAdminController(); + $method = new \ReflectionMethod($controller, 'canViewFamilyInvoices'); + + foreach (['admin', 'administrator', 'administrative staff', 'principal'] as $role) { + session()->set('role', $role); + $this->assertTrue($method->invoke($controller), $role); + } + } +} diff --git a/tests/app/Controllers/View/InventorySchemaNormalizationTest.php b/tests/app/Controllers/View/InventorySchemaNormalizationTest.php index d3cbae7..6834f46 100644 --- a/tests/app/Controllers/View/InventorySchemaNormalizationTest.php +++ b/tests/app/Controllers/View/InventorySchemaNormalizationTest.php @@ -40,7 +40,7 @@ class InventorySchemaNormalizationTest extends CIUnitTestCase { $routes = file_get_contents(ROOTPATH . 'app/Config/Routes.php'); - $this->assertStringContainsString("\$routes->get('card', 'View\\FamilyAdminController::card')", $routes); + $this->assertStringContainsString("\$routes->get('family/card', 'View\\FamilyAdminController::card'", $routes); $this->assertStringContainsString("\$routes->get('summary-all', 'View\\InventoryController::summaryAll')", $routes); $this->assertStringContainsString("\$routes->get('/', 'View\\InventoryController::index')", $routes); $this->assertStringContainsString("\$routes->get('(classroom|book|office|kitchen)', 'View\\InventoryController::index/\$1')", $routes); diff --git a/tests/app/Views/FamilyCardViewTest.php b/tests/app/Views/FamilyCardViewTest.php new file mode 100644 index 0000000..d90df5b --- /dev/null +++ b/tests/app/Views/FamilyCardViewTest.php @@ -0,0 +1,120 @@ + [ + 'id' => 9, + 'household_name' => 'Family of Tester', + 'guardians' => [], + 'students' => [[ + 'id' => 12, + 'school_id' => 'STU-12', + 'firstname' => 'Test', + 'lastname' => 'Student', + 'dob' => '2015-01-02', + 'age' => 11, + 'gender' => 'Female', + 'grade' => '5 / 5-A', + 'enrollment_status' => 'enrolled', + 'registration_grade' => '5', + 'is_active' => 1, + 'photo_consent' => 1, + 'registration_date' => '2025-09-02 14:30:00', + 'year_of_registration' => '2025', + 'rfid_tag' => 'RF-12', + 'tuition_paid' => 1, + 'allergies' => ['Peanut'], + 'medical_conditions' => ['Asthma'], + 'score_history' => [[ + 'school_year' => '2025-2026', + 'midterm_s1' => 88.5, + 'midterm_comment_s1' => 'Strong first semester.', + 'ptap_s1' => 91, + 'ptap_comment_s1' => 'Participates consistently.', + 'attendance_s1' => 95, + 'attendance_comment_s1' => 'Excellent attendance.', + 'final_s2' => 92, + 'final_comment_s2' => 'Excellent finish.', + 'ptap_s2' => 94, + 'ptap_comment_s2' => 'Continued strong participation.', + 'attendance_s2' => 100, + 'attendance_comment_s2' => 'Perfect attendance.', + 'year_score' => 93.25, + ]], + ]], + 'selected_student_id' => 12, + 'emergency_contacts' => [], + 'finance_summary' => [], + ], + ]); + + $this->assertStringContainsString('Test Student', $html); + $this->assertStringContainsString('data-bs-target="#fc-student-details-9-12"', $html); + $this->assertStringContainsString('accordion-collapse collapse show', $html); + $this->assertStringContainsString('Student Details', $html); + $this->assertStringContainsString('STU-12', $html); + $this->assertStringContainsString('5 / 5-A', $html); + $this->assertStringContainsString('Enrollment Status', $html); + $this->assertStringNotContainsString('Enrollment School Year', $html); + $this->assertStringNotContainsString('Enrollment Semester', $html); + $this->assertStringContainsString('Peanut', $html); + $this->assertStringContainsString('Asthma', $html); + $this->assertStringContainsString('Scores History', $html); + $this->assertStringContainsString('Midterm Comment S1', $html); + $this->assertStringContainsString('Attendance Comment S2', $html); + $this->assertStringContainsString('Strong first semester.', $html); + $this->assertStringContainsString('93.25', $html); + $this->assertStringNotContainsString('data-family-student-id="12"', $html); + $this->assertStringNotContainsString('id="fc-tab-fin"', $html); + $this->assertStringNotContainsString('Invoices:', $html); + } + + public function testAdministrativeFamilyCardDisplaysParentInvoices(): void + { + helper('time'); + + $html = view('family/card', [ + 'f' => [ + 'id' => 10, + 'household_name' => 'Family of Admin Test', + 'guardians' => [], + 'students' => [], + 'emergency_contacts' => [], + 'can_view_invoices' => true, + 'finance_summary' => [ + 'invoices_count' => 1, + 'total_amount' => 500, + 'paid_amount' => 200, + 'balance' => 300, + ], + 'invoices' => [[ + 'parent_id' => 88, + 'parent_name' => 'Parent Person', + 'invoice_number' => 'INV-2026-001', + 'status' => 'Partially Paid', + 'total_amount' => 500, + 'paid_amount' => 200, + 'balance' => 300, + 'issue_date' => '2026-09-01 12:00:00', + 'due_date' => '2026-09-30 12:00:00', + ]], + 'payments' => [], + ], + ]); + + $this->assertStringContainsString('id="fc-tab-fin"', $html); + $this->assertStringContainsString('Parent Invoices', $html); + $this->assertStringContainsString('Parent Person', $html); + $this->assertStringContainsString('INV-2026-001', $html); + $this->assertStringContainsString('Invoices: 1', $html); + } +}
InvoiceAmountInvoice BalanceMethodDatePayment StatusInvoice StatusParentInvoiceAmountInvoice BalanceMethodDatePayment StatusInvoice Status
$ $