request->getGet('student_id') ?? 0); $guardianId = (int) ($this->request->getGet('guardian_id') ?? 0); $data = ['student' => null, 'families' => [], 'guardians' => [], 'students' => []]; // Simple student list for select $data['students'] = $db->query("SELECT id, CONCAT(lastname, ', ', firstname) AS name FROM students ORDER BY lastname, firstname")->getResultArray(); // Preload search datasets (students + guardians/parents) $data['searchStudents'] = $db->query("SELECT id, firstname, lastname FROM students ORDER BY lastname, firstname")->getResultArray(); $data['searchGuardians'] = $db->query( "SELECT DISTINCT u.id, u.firstname, u.lastname, u.email, u.cellphone FROM family_guardians fg JOIN users u ON u.id = fg.user_id ORDER BY u.lastname, u.firstname" )->getResultArray(); // If a guardian is provided, resolve one of their students and redirect to use existing flow if (!$studentId && $guardianId) { $row = $db->query( "SELECT s.id AS student_id FROM family_guardians fg JOIN family_students fs ON fs.family_id = fg.family_id JOIN students s ON s.id = fs.student_id WHERE fg.user_id = ? ORDER BY s.lastname, s.firstname LIMIT 1", [$guardianId] )->getRowArray(); if (!$row) { $row = $db->query( "SELECT s.id AS student_id FROM students s WHERE s.parent_id = ? ORDER BY s.lastname, s.firstname LIMIT 1", [$guardianId] )->getRowArray(); } if ($row && !empty($row['student_id'])) { return redirect()->to(site_url('family?student_id='.(int)$row['student_id'])); } } if ($studentId) { $data['student'] = $db->query("SELECT id, firstname, lastname FROM students WHERE id = ?", [$studentId])->getRowArray(); // Fetch all families for this student with extended fields $families = $db->query( "SELECT f.id, f.family_code, f.household_name, f.address_line1, f.address_line2, f.city, f.state, f.postal_code, f.country, f.primary_phone, f.preferred_lang, f.preferred_contact_method, f.is_active, fs.is_primary_home FROM family_students fs JOIN families f ON f.id = fs.family_id WHERE fs.student_id = ? ORDER BY fs.is_primary_home DESC, f.household_name", [$studentId] )->getResultArray(); // Enrich each family with guardians, students, and financials $invoiceModel = new InvoiceModel(); $paymentModel = new PaymentModel(); $studentClassModel = new StudentClassModel(); $configModel = new ConfigurationModel(); $schoolYear = (string) ($configModel->getConfig('school_year') ?? ''); foreach ($families as &$fam) { $fid = (int) $fam['id']; // Guardians (with contact info) $guardians = $db->query( "SELECT u.id AS user_id, u.firstname, u.lastname, u.email, u.cellphone, u.address_street, u.city, u.state, u.zip, fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms FROM family_guardians fg JOIN users u ON u.id = fg.user_id WHERE fg.family_id = ? ORDER BY fg.is_primary DESC, u.lastname, u.firstname", [$fid] )->getResultArray(); $fam['guardians'] = $guardians; // Students in this family $studentsRows = $db->query( "SELECT s.id, s.firstname, s.lastname FROM family_students fs JOIN students s ON s.id = fs.student_id WHERE fs.family_id = ? ORDER BY s.lastname, s.firstname", [$fid] )->getResultArray(); // Enrich with grade label if available if (!empty($studentsRows)) { foreach ($studentsRows as &$sr) { $sid = (int) ($sr['id'] ?? 0); $sr['grade'] = $sid ? (string) ($studentClassModel->getClassSectionsByStudentId($sid, $schoolYear) ?? '') : ''; } unset($sr); } $fam['students'] = $studentsRows; // Financials: invoices and payments for all guardians (by user_id) $parentIds = array_map(static fn($g) => (int)($g['user_id'] ?? 0), $guardians); $parentIds = array_values(array_filter($parentIds)); $fam['invoices'] = []; $fam['payments'] = []; $fam['finance_summary'] = [ 'invoices_count' => 0, 'total_amount' => 0.0, 'paid_amount' => 0.0, 'balance' => 0.0, ]; if (!empty($parentIds)) { // Invoices $invRows = $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(); $fam['invoices'] = $invRows; // Map invoice id -> number for payments table $invoiceMap = []; foreach ($invRows as $ir) { $invoiceMap[(int)$ir['id']] = (string)($ir['invoice_number'] ?? ''); } $fam['invoice_map'] = $invoiceMap; // Aggregate summary foreach ($invRows as $ir) { $fam['finance_summary']['invoices_count']++; $fam['finance_summary']['total_amount'] += (float)($ir['total_amount'] ?? 0); $fam['finance_summary']['paid_amount'] += (float)($ir['paid_amount'] ?? 0); $fam['finance_summary']['balance'] += (float)($ir['balance'] ?? 0); } // Recent payments (limit 10) $payRows = $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(); $fam['payments'] = $payRows; } } unset($fam); $data['families'] = $families; // Back-compat: also expose guardians of first family for old UI pieces if (!empty($families)) { $familyId = (int)$families[0]['id']; $data['guardians'] = $db->query( "SELECT u.id, u.firstname, u.lastname, u.email, fg.relation, fg.is_primary, fg.receive_emails FROM family_guardians fg JOIN users u ON u.id = fg.user_id WHERE fg.family_id = ? ORDER BY fg.is_primary DESC, u.lastname, u.firstname", [$familyId] )->getResultArray(); } } return service('response')->setBody(view('family/index', $data)); } // GET /family/search?q=.. public function search(): ResponseInterface { $db = \Config\Database::connect(); $q = trim((string)($this->request->getGet('q') ?? '')); if ($q === '') { return $this->response->setJSON(['items' => []]); } $qs = '%' . $db->escapeLikeString($q) . '%'; // Students suggestions $students = $db->query( "SELECT id, firstname, lastname FROM students WHERE CONCAT_WS(' ', firstname, lastname) LIKE ? ORDER BY lastname, firstname LIMIT 8", [$qs] )->getResultArray(); // Parents/Guardians suggestions (users) $guardians = $db->query( "SELECT id, firstname, lastname, email, cellphone FROM users WHERE ( CONCAT_WS(' ', firstname, lastname) LIKE ? OR email LIKE ? OR cellphone LIKE ? ) ORDER BY lastname, firstname LIMIT 8", [$qs, $qs, $qs] )->getResultArray(); $items = []; foreach ($students as $s) { $items[] = [ 'type' => 'student', 'id' => (int)$s['id'], 'label'=> trim(($s['firstname'] ?? '').' '.($s['lastname'] ?? '')), 'sub' => 'Student', ]; } foreach ($guardians as $g) { $items[] = [ 'type' => 'guardian', 'id' => (int)$g['id'], 'label' => trim(($g['firstname'] ?? '').' '.($g['lastname'] ?? '')), 'sub' => trim(($g['email'] ?? '').' '.($g['cellphone'] ?? '')), ]; } return $this->response->setJSON(['items' => $items]); } // GET /family/card?student_id=.. | guardian_id=.. | family_id=.. 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); if (!$familyId) { if ($studentId) { $row = $db->query( "SELECT f.id FROM family_students fs JOIN families f ON f.id = fs.family_id WHERE fs.student_id = ? ORDER BY fs.is_primary_home DESC, f.household_name LIMIT 1", [$studentId] )->getRowArray(); if (!empty($row['id'])) { $familyId = (int) $row['id']; } else { // Legacy/newly-created students can have students.parent_id set // before the normalized family_students row is created. $row = $db->query( "SELECT f.id FROM students s JOIN family_guardians fg ON fg.user_id = s.parent_id JOIN families f ON f.id = fg.family_id WHERE s.id = ? ORDER BY fg.is_primary DESC, f.household_name LIMIT 1", [$studentId] )->getRowArray(); if (!empty($row['id'])) { $familyId = (int) $row['id']; } else { $row = $db->query( "SELECT f.id FROM students s JOIN families f ON f.family_code = CONCAT('FAM-', s.parent_id) WHERE s.id = ? AND s.parent_id IS NOT NULL ORDER BY f.household_name LIMIT 1", [$studentId] )->getRowArray(); if (!empty($row['id'])) { $familyId = (int) $row['id']; } } } } elseif ($guardianId) { // 1) Try via guardians link $row = $db->query( "SELECT f.id FROM family_guardians fg JOIN families f ON f.id = fg.family_id WHERE fg.user_id = ? ORDER BY f.household_name LIMIT 1", [$guardianId] )->getRowArray(); if (!empty($row['id'])) { $familyId = (int) $row['id']; } else { // 2) Fallback via students.parent_id → family_students $row = $db->query( "SELECT f.id FROM students s JOIN family_students fs ON fs.student_id = s.id JOIN families f ON f.id = fs.family_id WHERE s.parent_id = ? ORDER BY fs.is_primary_home DESC, f.household_name LIMIT 1", [$guardianId] )->getRowArray(); if (!empty($row['id'])) $familyId = (int) $row['id']; } } } if (!$familyId) { return $this->response->setStatusCode(404)->setBody('
Family not found.
'); } $family = $db->query( "SELECT f.id, f.family_code, f.household_name, f.address_line1, f.address_line2, f.city, f.state, f.postal_code, f.country, f.primary_phone, f.preferred_lang, f.preferred_contact_method, f.is_active FROM families f WHERE f.id = ?", [$familyId] )->getRowArray(); if (!$family) { return $this->response->setStatusCode(404)->setBody('
Family not found.
'); } // Hydrate with guardians, students (+grades), invoices, payments $invoiceModel = new \App\Models\InvoiceModel(); $paymentModel = new \App\Models\PaymentModel(); $configModel = new \App\Models\ConfigurationModel(); $schoolYear = $this->currentSchoolYearName((string) ($configModel->getConfig('school_year') ?? '')); // Guardians $guardians = $db->query( "SELECT u.id AS user_id, u.firstname, u.lastname, u.email, u.cellphone, u.address_street, u.city, u.state, u.zip, fg.relation, fg.is_primary, fg.receive_emails, fg.receive_sms FROM family_guardians fg JOIN users u ON u.id = fg.user_id WHERE fg.family_id = ? ORDER BY fg.is_primary DESC, u.lastname, u.firstname", [$familyId] )->getResultArray(); $family['guardians'] = $guardians; $family['can_view_invoices'] = $canViewInvoices; // Students $studentsRows = $db->query( "SELECT s.* FROM family_students fs JOIN students s ON s.id = fs.student_id WHERE fs.family_id = ? ORDER BY s.lastname, s.firstname", [$familyId] )->getResultArray(); if ($studentId > 0) { $studentIds = array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $studentsRows); if (!in_array($studentId, $studentIds, true)) { $selectedStudent = $db->query( "SELECT * FROM students WHERE id = ? LIMIT 1", [$studentId] )->getRowArray(); if ($selectedStudent) { $studentsRows[] = $selectedStudent; } } } 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); $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); $parentIds = array_values(array_filter($parentIds)); $family['invoices'] = []; $family['payments'] = []; $family['finance_summary'] = [ 'invoices_count' => 0, 'total_amount' => 0.0, 'paid_amount' => 0.0, 'balance' => 0.0, ]; // Emergency contacts (by guardian/parent) $family['emergency_contacts'] = []; if (!empty($parentIds)) { // Map guardian name by user_id $gmap = []; foreach ($guardians as $g) { $gid = (int)($g['user_id'] ?? 0); if ($gid > 0) $gmap[$gid] = trim(($g['firstname'] ?? '') . ' ' . ($g['lastname'] ?? '')); } $ecRows = $db->table('emergency_contacts') ->select('id, parent_id, emergency_contact_name, relation, cellphone, email, created_at, updated_at') ->whereIn('parent_id', $parentIds) ->orderBy('updated_at', 'DESC') ->get()->getResultArray(); if (!empty($ecRows)) { foreach ($ecRows as &$ec) { $pid = (int)($ec['parent_id'] ?? 0); $ec['parent_label'] = $gmap[$pid] ?? ('Parent #'.$pid); } unset($ec); $family['emergency_contacts'] = $ecRows; } } if ($canViewInvoices && !empty($parentIds)) { // 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'); 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) { $invoiceMap[(int)$ir['id']] = (string)($ir['invoice_number'] ?? ''); } $family['invoice_map'] = $invoiceMap; foreach ($invRows as $ir) { $family['finance_summary']['invoices_count']++; $family['finance_summary']['total_amount'] += (float)($ir['total_amount'] ?? 0); $family['finance_summary']['paid_amount'] += (float)($ir['paid_amount'] ?? 0); $family['finance_summary']['balance'] += (float)($ir['balance'] ?? 0); } // Payments $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); 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')); $name = trim((string)$this->request->getGet('name')); $returnUrl = trim((string)$this->request->getGet('return_url')); if ($returnUrl === '') { $returnUrl = trim((string)$this->request->getServer('HTTP_REFERER')); } if ($returnUrl === '') { $returnUrl = site_url('family'); } return view('family/compose_email', [ 'to' => $to, 'name' => $name, 'return_url' => $returnUrl, ]); } public function sendComposeEmail() { if (!$this->request->is('post')) { return redirect()->to(site_url('family')); } $to = trim((string)$this->request->getPost('to')); $subject = trim((string)$this->request->getPost('subject')); $html = (string)($this->request->getPost('html') ?? ''); $returnUrl = trim((string)$this->request->getPost('return_url')); if ($returnUrl === '' || !$this->isLocalReturnUrl($returnUrl)) { $returnUrl = site_url('family'); } if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { return redirect()->back()->withInput()->with('error', 'Please enter a valid email address.'); } if ($subject === '') { return redirect()->back()->withInput()->with('error', 'Subject is required.'); } if (trim($html) === '') { return redirect()->back()->withInput()->with('error', 'Email body is required.'); } $wrapped = view('emails/custom_html', [ 'subject' => $subject, 'body_html' => $html, ]); $mailer = new \App\Controllers\View\EmailController(); $ok = $mailer->sendEmail($to, $subject, $wrapped, 'communication'); if ($ok) { return redirect()->to($returnUrl)->with('status', 'Email sent.'); } return redirect()->to($returnUrl)->with('error', 'Unable to send email.'); } private function isLocalReturnUrl(string $url): bool { $url = trim($url); if ($url === '') return false; $parts = parse_url($url); if ($parts === false) return false; if (!empty($parts['scheme']) || !empty($parts['host'])) { $base = parse_url(site_url('/')); if (!$base || empty($base['host'])) return false; $hostMatch = strcasecmp((string)($parts['host'] ?? ''), (string)$base['host']) === 0; return $hostMatch; } // Relative URL (e.g., /family?x=1 or family?x=1) return true; } }