add features to family card, scores, invoices and students details
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m51s

This commit is contained in:
root
2026-09-09 23:02:16 -04:00
parent 36e8ffe56d
commit ac19226bf0
11 changed files with 822 additions and 38 deletions
+5 -1
View File
@@ -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']);
//////////////////////////////////////////////////////////
+132 -12
View File
@@ -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'));
+235
View File
@@ -0,0 +1,235 @@
<?php
namespace App\Services;
use CodeIgniter\Database\BaseConnection;
final class StudentScoreHistoryService
{
public function __construct(private readonly BaseConnection $db)
{
}
/**
* Build one score-history row per student and school year.
*
* @return array<int, array<int, array<string, mixed>>>
*/
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;
}
}
+2 -2
View File
@@ -160,13 +160,13 @@ $selectedYear = trim((string)($selectedYear ?? ''));
<tr>
<td><?= esc($student['school_id']) ?></td>
<td>
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)($student['id'] ?? 0) ?>">
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)($student['id'] ?? 0) ?>" data-family-school-year="<?= esc($selectedYear) ?>">
<?= esc($student['firstname']) ?>
</a>
<?= student_enrollment_status_button($student, $selectedYear) ?>
</td>
<td>
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)($student['id'] ?? 0) ?>">
<a href="#" class="text-decoration-none" data-family-student-id="<?= (int)($student['id'] ?? 0) ?>" data-family-school-year="<?= esc($selectedYear) ?>">
<?= esc($student['lastname']) ?>
</a>
</td>
+253 -22
View File
@@ -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 === '') {
<div class="d-flex flex-wrap gap-2 fc-badges">
<span class="badge">Guardians: <?= (int)$gCount ?></span>
<span class="badge">Students: <?= (int)$sCount ?></span>
<span class="badge">Invoices: <?= (int)($sum['invoices_count'] ?? 0) ?></span>
<span class="badge">
Balance: $<?= number_format((float)($sum['balance'] ?? 0), 2) ?>
</span>
<?php if ($canViewInvoices): ?>
<span class="badge">Invoices: <?= (int)($sum['invoices_count'] ?? 0) ?></span>
<span class="badge">
Balance: $<?= number_format((float)($sum['balance'] ?? 0), 2) ?>
</span>
<?php endif; ?>
</div>
</div>
</div>
@@ -105,15 +150,20 @@ if ($returnUrl === '') {
<li class="nav-item" role="presentation">
<button class="nav-link active" id="fc-tab-overview" data-bs-toggle="tab" data-bs-target="#fc-overview" type="button" role="tab">Students</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="fc-tab-scores" data-bs-toggle="tab" data-bs-target="#fc-scores" type="button" role="tab">Scores History</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="fc-tab-guardians" data-bs-toggle="tab" data-bs-target="#fc-guardians" type="button" role="tab">Parents/Guardians</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="fc-tab-ec" data-bs-toggle="tab" data-bs-target="#fc-ec" type="button" role="tab">Emergency</button>
</li>
<!--li class="nav-item" role="presentation">
<button class="nav-link" id="fc-tab-fin" data-bs-toggle="tab" data-bs-target="#fc-fin" type="button" role="tab">Financials</button>
</li-->
<?php if ($canViewInvoices): ?>
<li class="nav-item" role="presentation">
<button class="nav-link" id="fc-tab-fin" data-bs-toggle="tab" data-bs-target="#fc-fin" type="button" role="tab">Invoices</button>
</li>
<?php endif; ?>
</ul>
<div class="tab-content">
@@ -126,26 +176,202 @@ if ($returnUrl === '') {
<?php if (empty($f['students'])): ?>
<div class="text-muted small">No students linked.</div>
<?php else: ?>
<ul class="list-group">
<div class="accordion" id="fc-students-<?= (int)($f['id'] ?? 0) ?>">
<?php foreach ($f['students'] as $s): ?>
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<a href="#" class="text-decoration-none fc-name" data-family-student-id="<?= (int)($s['id'] ?? 0) ?>">
<?= esc(($s['firstname'] ?? '').' '.($s['lastname'] ?? '')) ?>
</a>
<?php
$studentId = (int)($s['id'] ?? 0);
$detailId = 'fc-student-details-' . (int)($f['id'] ?? 0) . '-' . $studentId;
$isSelected = $selectedStudentId > 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'] ?? []))));
?>
<div class="accordion-item">
<h2 class="accordion-header">
<button
type="button"
class="accordion-button <?= $isSelected ? '' : 'collapsed' ?>"
data-bs-toggle="collapse"
data-bs-target="#<?= esc($detailId) ?>"
aria-expanded="<?= $isSelected ? 'true' : 'false' ?>"
aria-controls="<?= esc($detailId) ?>"
>
<span class="fc-name">
<?= esc(trim(($s['firstname'] ?? '').' '.($s['lastname'] ?? ''))) ?>
</span>
<?php if (!empty($s['grade'])): ?>
<span class="badge text-bg-secondary ms-2"><?= esc($s['grade']) ?></span>
<?php endif; ?>
</button>
</h2>
<div
id="<?= esc($detailId) ?>"
class="accordion-collapse collapse <?= $isSelected ? 'show' : '' ?>"
data-bs-parent="#fc-students-<?= (int)($f['id'] ?? 0) ?>"
>
<div class="accordion-body fc-student-details">
<h6 class="mb-3">Student Details</h6>
<div class="row g-3">
<?php
$details = [
'School ID' => $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',
];
?>
<?php foreach ($details as $label => $value): ?>
<div class="col-12 col-sm-6 col-lg-4">
<div class="fc-detail-label"><?= esc($label) ?></div>
<div class="fc-detail-value"><?= esc($value) ?></div>
</div>
<?php endforeach; ?>
</div>
<div class="row g-3 mt-1">
<div class="col-12 col-lg-6">
<div class="card h-100 fc-health-card">
<div class="card-body">
<div class="fc-detail-label mb-2">Medical Conditions</div>
<?php if (empty($conditions)): ?>
<div class="text-muted">None on file</div>
<?php else: ?>
<ul class="mb-0 ps-3">
<?php foreach ($conditions as $condition): ?>
<li><?= esc($condition) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100 fc-health-card">
<div class="card-body">
<div class="fc-detail-label mb-2">Allergies</div>
<?php if (empty($allergies)): ?>
<div class="text-muted">None on file</div>
<?php else: ?>
<ul class="mb-0 ps-3">
<?php foreach ($allergies as $allergy): ?>
<li><?= esc($allergy) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
</div>
<?php if (!empty($s['grade'])): ?>
<span class="badge text-bg-secondary"><?= esc($s['grade']) ?></span>
<?php endif; ?>
</li>
</div>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<!-- Scores History -->
<div class="tab-pane fade" id="fc-scores" role="tabpanel" aria-labelledby="fc-tab-scores">
<div class="p-3">
<?php if (empty($f['students'])): ?>
<div class="alert alert-light border mb-0">No students linked.</div>
<?php else: ?>
<ul class="nav nav-pills fc-score-student-nav gap-2 mb-3" role="tablist">
<?php foreach ($f['students'] as $s): ?>
<?php
$scoreStudentId = (int)($s['id'] ?? 0);
$scoreStudentActive = $scoreStudentId === $scoreDefaultStudentId;
$scorePaneId = 'fc-score-student-' . (int)($f['id'] ?? 0) . '-' . $scoreStudentId;
?>
<li class="nav-item" role="presentation">
<button
class="nav-link text-nowrap <?= $scoreStudentActive ? 'active' : '' ?>"
data-bs-toggle="pill"
data-bs-target="#<?= esc($scorePaneId) ?>"
type="button"
role="tab"
aria-selected="<?= $scoreStudentActive ? 'true' : 'false' ?>"
>
<?= esc(trim(($s['firstname'] ?? '') . ' ' . ($s['lastname'] ?? ''))) ?>
</button>
</li>
<?php endforeach; ?>
</ul>
<div class="tab-content">
<?php foreach ($f['students'] as $s): ?>
<?php
$scoreStudentId = (int)($s['id'] ?? 0);
$scoreStudentActive = $scoreStudentId === $scoreDefaultStudentId;
$scorePaneId = 'fc-score-student-' . (int)($f['id'] ?? 0) . '-' . $scoreStudentId;
$scoreHistory = (array)($s['score_history'] ?? []);
?>
<div class="tab-pane fade <?= $scoreStudentActive ? 'show active' : '' ?>" id="<?= esc($scorePaneId) ?>" role="tabpanel">
<?php if (empty($scoreHistory)): ?>
<div class="alert alert-light border mb-0">No score history found for this student.</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm table-bordered table-striped align-middle fc-score-table mb-0">
<thead class="table-light">
<tr>
<th>School Year</th>
<th>Midterm S1</th>
<th class="fc-score-comment">Midterm Comment S1</th>
<th>PTAP S1</th>
<th class="fc-score-comment">PTAP Comment S1</th>
<th>Attendance S1</th>
<th class="fc-score-comment">Attendance Comment S1</th>
<th>Final S2</th>
<th class="fc-score-comment">Final Comment S2</th>
<th>PTAP S2</th>
<th class="fc-score-comment">PTAP Comment S2</th>
<th>Attendance S2</th>
<th class="fc-score-comment">Attendance Comment S2</th>
<th>Year Score</th>
</tr>
</thead>
<tbody>
<?php foreach ($scoreHistory as $history): ?>
<tr>
<td data-label="School Year" class="text-nowrap fw-semibold"><?= esc($history['school_year'] ?? '—') ?></td>
<td data-label="Midterm S1"><?= esc($formatScore($history['midterm_s1'] ?? null)) ?></td>
<td data-label="Midterm Comment S1" class="fc-score-comment"><?= esc($displayValue($history['midterm_comment_s1'] ?? null, '—')) ?></td>
<td data-label="PTAP S1"><?= esc($formatScore($history['ptap_s1'] ?? null)) ?></td>
<td data-label="PTAP Comment S1" class="fc-score-comment"><?= esc($displayValue($history['ptap_comment_s1'] ?? null, '—')) ?></td>
<td data-label="Attendance S1"><?= esc($formatScore($history['attendance_s1'] ?? null)) ?></td>
<td data-label="Attendance Comment S1" class="fc-score-comment"><?= esc($displayValue($history['attendance_comment_s1'] ?? null, '—')) ?></td>
<td data-label="Final S2"><?= esc($formatScore($history['final_s2'] ?? null)) ?></td>
<td data-label="Final Comment S2" class="fc-score-comment"><?= esc($displayValue($history['final_comment_s2'] ?? null, '—')) ?></td>
<td data-label="PTAP S2"><?= esc($formatScore($history['ptap_s2'] ?? null)) ?></td>
<td data-label="PTAP Comment S2" class="fc-score-comment"><?= esc($displayValue($history['ptap_comment_s2'] ?? null, '—')) ?></td>
<td data-label="Attendance S2"><?= esc($formatScore($history['attendance_s2'] ?? null)) ?></td>
<td data-label="Attendance Comment S2" class="fc-score-comment"><?= esc($displayValue($history['attendance_comment_s2'] ?? null, '—')) ?></td>
<td data-label="Year Score" class="fw-semibold"><?= esc($formatScore($history['year_score'] ?? null)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<!-- Guardians -->
<div class="tab-pane fade" id="fc-guardians" role="tabpanel" aria-labelledby="fc-tab-guardians">
<div class="p-3">
@@ -281,7 +507,8 @@ if ($returnUrl === '') {
</div>
</div>
<!-- Financials -->
<?php if ($canViewInvoices): ?>
<!-- Invoices (administrative roles only) -->
<div class="tab-pane fade" id="fc-fin" role="tabpanel" aria-labelledby="fc-tab-fin">
<div class="p-3">
<div class="row g-3 mb-2">
@@ -313,7 +540,7 @@ if ($returnUrl === '') {
<div class="row g-3">
<div class="col-md-6">
<h6 class="mb-2">Invoices</h6>
<h6 class="mb-2">Parent Invoices</h6>
<?php if (empty($f['invoices'])): ?>
<div class="alert alert-light border">No invoices on record.</div>
<?php else: ?>
@@ -321,18 +548,20 @@ if ($returnUrl === '') {
<table class="table table-sm table-striped table-hover align-middle fc-table-stack">
<thead class="table-light">
<tr>
<th>#</th><th>Status</th><th>Total</th><th>Paid</th><th>Balance</th><th>Date</th>
<th>Parent</th><th>#</th><th>Status</th><th>Total</th><th>Paid</th><th>Balance</th><th>Issued</th><th>Due</th>
</tr>
</thead>
<tbody>
<?php foreach ($f['invoices'] as $iv): ?>
<tr>
<td data-label="Parent"><?= esc($iv['parent_name'] ?? ('Parent #' . (int)($iv['parent_id'] ?? 0))) ?></td>
<td data-label="Invoice"><?= esc($iv['invoice_number']) ?></td>
<td data-label="Status"><?= esc($iv['status']) ?></td>
<td data-label="Total">$<?= number_format((float)($iv['total_amount'] ?? 0), 2) ?></td>
<td data-label="Paid">$<?= number_format((float)($iv['paid_amount'] ?? 0), 2) ?></td>
<td data-label="Balance">$<?= number_format((float)($iv['balance'] ?? 0), 2) ?></td>
<td data-label="Date"><?= esc(!empty($iv['issue_date']) ? local_date($iv['issue_date'], 'm-d-Y') : '') ?></td>
<td data-label="Due"><?= esc(!empty($iv['due_date']) ? local_date($iv['due_date'], 'm-d-Y') : '—') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
@@ -350,13 +579,14 @@ if ($returnUrl === '') {
<table class="table table-sm table-striped table-hover align-middle fc-table-stack">
<thead class="table-light">
<tr>
<th>Invoice</th><th>Amount</th><th>Invoice Balance</th><th>Method</th><th>Date</th><th>Payment Status</th><th>Invoice Status</th>
<th>Parent</th><th>Invoice</th><th>Amount</th><th>Invoice Balance</th><th>Method</th><th>Date</th><th>Payment Status</th><th>Invoice Status</th>
</tr>
</thead>
<tbody>
<?php $imap = $f['invoice_map'] ?? []; ?>
<?php foreach ($f['payments'] as $p): ?>
<tr>
<td data-label="Parent"><?= esc($p['parent_name'] ?? ('Parent #' . (int)($p['parent_id'] ?? 0))) ?></td>
<td data-label="Invoice"><?php $iid = (int)($p['invoice_id'] ?? 0); echo esc($p['invoice_number'] ?? $imap[$iid] ?? ('#'.$iid)); ?></td>
<td data-label="Amount">$<?= number_format((float)($p['paid_amount'] ?? 0), 2) ?></td>
<td data-label="Invoice Balance">$<?= number_format((float)($p['invoice_current_balance'] ?? 0), 2) ?></td>
@@ -374,5 +604,6 @@ if ($returnUrl === '') {
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
+3
View File
@@ -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;
}
+3
View File
@@ -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;
}
@@ -0,0 +1,30 @@
<?php
namespace Tests\App\Config;
use CodeIgniter\Test\CIUnitTestCase;
final class FamilyCardRouteIntegrityTest extends CIUnitTestCase
{
public function testFamilyCardAllowsTeachersWhileFamilyManagementRemainsAdministrative(): void
{
$routes = file_get_contents(ROOTPATH . 'app/Config/Routes.php') ?: '';
$this->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
);
}
}
@@ -0,0 +1,38 @@
<?php
namespace Tests\App\Controllers\View;
use App\Controllers\View\FamilyAdminController;
use CodeIgniter\Test\CIUnitTestCase;
final class FamilyAdminControllerAccessTest extends CIUnitTestCase
{
protected function tearDown(): void
{
session()->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);
}
}
}
@@ -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);
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace Tests\App\Views;
use CodeIgniter\Test\CIUnitTestCase;
final class FamilyCardViewTest extends CIUnitTestCase
{
public function testStudentNameExpandsCompleteStudentDetails(): void
{
helper('time');
$html = view('family/card', [
'f' => [
'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);
}
}