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 ?? ''));
| = esc($student['school_id']) ?> |
-
+
= esc($student['firstname']) ?>
= student_enrollment_status_button($student, $selectedYear) ?>
|
-
+
= esc($student['lastname']) ?>
|
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: = (int)$gCount ?>
Students: = (int)$sCount ?>
- Invoices: = (int)($sum['invoices_count'] ?? 0) ?>
-
- Balance: $= number_format((float)($sum['balance'] ?? 0), 2) ?>
-
+
+ Invoices: = (int)($sum['invoices_count'] ?? 0) ?>
+
+ Balance: $= number_format((float)($sum['balance'] ?? 0), 2) ?>
+
+
@@ -105,15 +150,20 @@ if ($returnUrl === '') {
+
+
+
-
+
+
+
+
+
@@ -126,26 +176,202 @@ if ($returnUrl === '') {
No students linked.
-
+
-
-
-
-
- = esc(($s['firstname'] ?? '').' '.($s['lastname'] ?? '')) ?>
-
+ 0 && $selectedStudentId === $studentId;
+ $allergies = array_values(array_filter(array_map('trim', (array)($s['allergies'] ?? []))));
+ $conditions = array_values(array_filter(array_map('trim', (array)($s['medical_conditions'] ?? []))));
+ ?>
+
+
+
+
+
+
Student Details
+
+ $displayValue($s['school_id'] ?? null),
+ 'Date of Birth' => $formatDate($s['dob'] ?? null),
+ 'Age' => $displayValue($s['age'] ?? null),
+ 'Gender' => $displayValue($s['gender'] ?? null),
+ 'Assigned Class / Section' => $displayValue($s['grade'] ?? null),
+ 'Enrollment Status' => $displayValue($s['enrollment_status'] ?? null),
+ 'Registration Grade' => $displayValue($s['registration_grade'] ?? null),
+ 'Status' => ((int)($s['is_active'] ?? 0) === 1) ? 'Active' : 'Inactive',
+ 'Photo Consent' => ((int)($s['photo_consent'] ?? 0) === 1) ? 'Yes' : 'No',
+ 'Registration Date' => $formatDate($s['registration_date'] ?? null, true),
+ 'Year of Registration' => $displayValue($s['year_of_registration'] ?? null),
+ 'RFID Tag' => $displayValue($s['rfid_tag'] ?? null),
+ 'Tuition Paid' => ((int)($s['tuition_paid'] ?? 0) === 1) ? 'Yes' : 'No',
+ ];
+ ?>
+ $value): ?>
+
+
= esc($label) ?>
+
= esc($value) ?>
+
+
+
+
+
+
+
+
+
Medical Conditions
+
+
None on file
+
+
+
+ - = esc($condition) ?>
+
+
+
+
+
+
+
+
+
+
Allergies
+
+
None on file
+
+
+
+ - = esc($allergy) ?>
+
+
+
+
+
+
+
+
-
-
= esc($s['grade']) ?>
-
-
+
-
+
+
+
+
+
+
No students linked.
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
No score history found for this student.
+
+
+
+
+
+ | School Year |
+ Midterm S1 |
+
+ PTAP S1 |
+
+ Attendance S1 |
+
+ Final S2 |
+
+ PTAP S2 |
+
+ Attendance S2 |
+
+ Year Score |
+
+
+
+
+
+ | = esc($history['school_year'] ?? '—') ?> |
+ = esc($formatScore($history['midterm_s1'] ?? null)) ?> |
+
+ = esc($formatScore($history['ptap_s1'] ?? null)) ?> |
+
+ = esc($formatScore($history['attendance_s1'] ?? null)) ?> |
+
+ = esc($formatScore($history['final_s2'] ?? null)) ?> |
+
+ = esc($formatScore($history['ptap_s2'] ?? null)) ?> |
+
+ = esc($formatScore($history['attendance_s2'] ?? null)) ?> |
+
+ = esc($formatScore($history['year_score'] ?? null)) ?> |
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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 === '') {
- | # | Status | Total | Paid | Balance | Date |
+ Parent | # | Status | Total | Paid | Balance | Issued | Due |
+ | = esc($iv['parent_name'] ?? ('Parent #' . (int)($iv['parent_id'] ?? 0))) ?> |
= esc($iv['invoice_number']) ?> |
= esc($iv['status']) ?> |
$= number_format((float)($iv['total_amount'] ?? 0), 2) ?> |
$= number_format((float)($iv['paid_amount'] ?? 0), 2) ?> |
$= number_format((float)($iv['balance'] ?? 0), 2) ?> |
= esc(!empty($iv['issue_date']) ? local_date($iv['issue_date'], 'm-d-Y') : '') ?> |
+ = esc(!empty($iv['due_date']) ? local_date($iv['due_date'], 'm-d-Y') : '—') ?> |
@@ -350,13 +579,14 @@ if ($returnUrl === '') {
- | Invoice | Amount | Invoice Balance | Method | Date | Payment Status | Invoice Status |
+ Parent | Invoice | Amount | Invoice Balance | Method | Date | Payment Status | Invoice Status |
+ | = esc($p['parent_name'] ?? ('Parent #' . (int)($p['parent_id'] ?? 0))) ?> |
|
$= number_format((float)($p['paid_amount'] ?? 0), 2) ?> |
$= number_format((float)($p['invoice_current_balance'] ?? 0), 2) ?> |
@@ -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);
+ }
+}