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') ->select('id, parent_id, invoice_id, paid_amount, balance, payment_method, payment_date, status') ->whereIn('parent_id', $parentIds) ->orderBy('payment_date', '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(); $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']; } 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(); $studentClassModel = new \App\Models\StudentClassModel(); $configModel = new \App\Models\ConfigurationModel(); $schoolYear = (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; // Students $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", [$familyId] )->getResultArray(); if (!empty($studentsRows)) { foreach ($studentsRows as &$sr) { $sid = (int) ($sr['id'] ?? 0); $sr['grade'] = $sid ? (string) ($studentClassModel->getClassSectionsByStudentId($sid, $schoolYear) ?? '') : ''; } unset($sr); } $family['students'] = $studentsRows; // 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, school_year, semester, 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 (!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(); $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 $payRows = $db->table('payments') ->select('id, parent_id, invoice_id, paid_amount, balance, payment_method, payment_date, status') ->whereIn('parent_id', $parentIds) ->orderBy('payment_date', 'DESC') ->limit(10) ->get()->getResultArray(); $family['payments'] = $payRows; } return service('response')->setBody(view('family/card', ['f' => $family])); } 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; } }