diff --git a/app/Controllers/View/AdministratorController.php b/app/Controllers/View/AdministratorController.php index e18668d..6d4af84 100644 --- a/app/Controllers/View/AdministratorController.php +++ b/app/Controllers/View/AdministratorController.php @@ -521,6 +521,7 @@ class AdministratorController extends BaseController return view('administrator/administratordashboard', array_merge($searchData, [ 'dashboardEndpoint' => site_url('api/administrator/dashboard'), + 'schoolYear' => $this->schoolYear, ])); } diff --git a/app/Controllers/View/InvoiceController.php b/app/Controllers/View/InvoiceController.php index 0473f27..9b2ad10 100644 --- a/app/Controllers/View/InvoiceController.php +++ b/app/Controllers/View/InvoiceController.php @@ -1052,13 +1052,11 @@ class InvoiceController extends ResourceController $invoiceId = $invoice !== null ? (int) ($invoice['id'] ?? 0) : null; $invoiceDate = null; - if ($invoice !== null) { + if ($invoice !== null && ! empty($invoice['updated_at'])) { $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); - $invoiceDate = ! empty($invoice['issue_date']) - ? (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC'))) - ->setTimezone(new \DateTimeZone($tzName)) - ->format('Y-m-d H:i:s') - : ($invoice['updated_at'] ?? null); + $invoiceDate = (new \DateTimeImmutable($invoice['updated_at'], new \DateTimeZone('UTC'))) + ->setTimezone(new \DateTimeZone($tzName)) + ->format('Y-m-d H:i:s'); } $description = ''; diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index af1d4cb..794f929 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -481,6 +481,8 @@ class ParentController extends BaseController // Handle enrollments $studentData = []; $enrollmentResultMessages = []; + $invoiceResultMessage = null; + $invoiceErrorMessage = null; if (!empty($enroll)) { $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); @@ -713,17 +715,34 @@ class ParentController extends BaseController } } + if (!empty($enroll) && empty($withdraw)) { + $invoiceResult = $this->generateInvoiceForParentEnrollment((int) $parentId); + if (! empty($invoiceResult['ok'])) { + $invoiceResultMessage = (string) ($invoiceResult['message'] ?? 'Invoice generated.'); + } else { + $invoiceErrorMessage = (string) ($invoiceResult['message'] ?? 'Enrollment was submitted, but the invoice could not be generated.'); + } + } + $parentData = $this->userModel->getUserInfoById($parentId); $parentData['user_id'] = $parentId; // Redirect to the success page after processing enrollment and withdrawal if (!empty($withdraw)) { // Redirect to withdrawal success page if there are withdrawals //parent/enroll_classes $redirect = redirect()->to('/parent/enroll_classes'); + $successParts = []; + $errorParts = []; if ($withdrawalResultMessages !== []) { - $redirect = $redirect->with('success', 'Withdrawal request submitted. ' . implode(' ', $withdrawalResultMessages)); + $successParts[] = 'Withdrawal request submitted. ' . implode(' ', $withdrawalResultMessages); } if ($withdrawalErrors !== []) { - $redirect = $redirect->with('error', 'Some withdrawal requests failed. ' . implode(' ', $withdrawalErrors)); + $errorParts[] = 'Some withdrawal requests failed. ' . implode(' ', $withdrawalErrors); + } + if ($successParts !== []) { + $redirect = $redirect->with('success', implode(' ', $successParts)); + } + if ($errorParts !== []) { + $redirect = $redirect->with('error', implode(' ', $errorParts)); } return $redirect; @@ -742,11 +761,76 @@ class ParentController extends BaseController if ($enrollmentResultMessages !== []) { $successMessage .= ' ' . implode(' ', $enrollmentResultMessages); } + if ($invoiceResultMessage !== null) { + $successMessage .= ' ' . $invoiceResultMessage; + } - return redirect()->to('/parent/enroll_success')->with('success', $successMessage); + $redirect = redirect()->to('/parent/enroll_success')->with('success', $successMessage); + if ($invoiceErrorMessage !== null) { + $redirect = $redirect->with('error', $invoiceErrorMessage); + } + + return $redirect; } } + /** + * Generate or refresh the parent's school-year invoice after parent-submitted enrollment. + * + * The invoice engine is intentionally authoritative for billable statuses. For example, + * first-time students still under admission review may not produce billable lines yet. + * + * @return array{ok: bool, message: string} + */ + private function generateInvoiceForParentEnrollment(int $parentId): array + { + if ($parentId <= 0) { + return ['ok' => false, 'message' => 'Enrollment was submitted, but the parent invoice could not be generated.']; + } + + $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); + $semester = (string) ($this->semester ?? getSemester()); + + try { + $result = $this->eventController->generateInvoice((string) $parentId, $schoolYear, $semester); + } catch (Throwable $e) { + log_message('error', 'Invoice generation failed after parent enrollment: {message}', [ + 'message' => $e->getMessage(), + 'parentId' => $parentId, + 'schoolYear' => $schoolYear, + 'semester' => $semester, + ]); + + return ['ok' => false, 'message' => 'Enrollment was submitted, but the invoice could not be generated. Please contact the school administration.']; + } + + if (is_array($result) && ! empty($result['ok'])) { + return [ + 'ok' => true, + 'message' => ! empty($result['updated']) ? 'Invoice updated.' : 'Invoice generated.', + ]; + } + + $message = is_array($result) ? (string) ($result['message'] ?? '') : ''; + if ($message === 'Invoice requires at least one non-zero line.') { + log_message('info', 'No invoice generated after parent enrollment because no billable invoice lines exist yet for parent {parentId}, year {schoolYear}.', [ + 'parentId' => $parentId, + 'schoolYear' => $schoolYear, + ]); + + return ['ok' => true, 'message' => 'No invoice was generated yet because there are no billable enrollment charges.']; + } + + log_message('error', 'Invoice generation returned an unsuccessful result after parent enrollment: {result}', [ + 'result' => json_encode($result), + 'parentId' => $parentId, + 'schoolYear' => $schoolYear, + 'semester' => $semester, + ]); + + return ['ok' => false, 'message' => 'Enrollment was submitted, but the invoice could not be generated. Please contact the school administration.']; + } + private function enrollmentPayloadFromEvaluation(array $evaluation, array $base): array { $payload = array_merge($base, [ diff --git a/app/Services/AdministratorDashboardService.php b/app/Services/AdministratorDashboardService.php index 6bf2ab3..d161d9f 100644 --- a/app/Services/AdministratorDashboardService.php +++ b/app/Services/AdministratorDashboardService.php @@ -34,14 +34,13 @@ public function metrics(string $schoolYear, string $semester): array $totalAdmins = (int) ($this->userModel->countAdminsBySchoolYear($this->schoolYear) ?? 0); - $teachers = $this->userModel->getUsersByRoleAndSchoolYear('teacher', $this->schoolYear); + $teachers = $this->userModel->getUsersByRole('teacher'); $totalTeachers = $this->countUniqueEntities($teachers); - $teacherAssistants = $this->userModel->getUsersByRoleAndSchoolYear('teacher_assistant', $this->schoolYear); + $teacherAssistants = $this->userModel->getUsersByRole('teacher_assistant'); $totalTeacherAssistants = $this->countUniqueEntities($teacherAssistants); - $parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $this->schoolYear); - $totalParents = $this->countUniqueEntities($parents); + $totalParents = $this->countParentsWithEnrolledStudents($this->schoolYear); // Count only students that have a class assigned and exist in student_class for the current school year $totalStudents = (int) ( @@ -103,6 +102,36 @@ private function countUniqueEntities($rows): int return count(array_unique($ids)); } +private function countParentsWithEnrolledStudents(string $schoolYear): int +{ + $schoolYear = trim($schoolYear); + if ($schoolYear === '') { + return 0; + } + + return (int) ( + $this->db->table('students') + ->select('COUNT(DISTINCT students.parent_id) AS cnt') + ->join('student_class', 'student_class.student_id = students.id', 'inner') + ->join('users', 'users.id = students.parent_id', 'inner') + ->join('user_roles', 'user_roles.user_id = users.id', 'inner') + ->join('roles', 'roles.id = user_roles.role_id', 'inner') + ->where('student_class.school_year', $schoolYear) + ->where('student_class.class_section_id IS NOT NULL', null, false) + ->where('students.is_active', 1) + ->where('students.parent_id IS NOT NULL', null, false) + ->where('students.parent_id >', 0) + ->where('user_roles.deleted_at', null) + ->groupStart() + ->where('LOWER(roles.name)', 'parent') + ->orWhere('roles.slug', 'parent') + ->groupEnd() + ->get() + ->getRow('cnt') + ?? 0 + ); +} + public function search(string $query): array { $q = trim($query); @@ -122,6 +151,15 @@ public function search(string $query): array // 1) Tokenize input: split by whitespace and punctuation, keep meaningful pieces $rawTokens = preg_split('/[,\s]+/u', $q, -1, PREG_SPLIT_NO_EMPTY) ?: []; $tokens = array_values(array_filter(array_map('trim', $rawTokens))); + if ($tokens === []) { + return [ + 'query' => $q, + 'results' => [], + 'scope_used' => 'unscoped-merged', + 'scope_label' => 'all years/semesters (merged)', + 'total_found' => 0, + ]; + } // 2) Build phone variants for any token that looks numeric-ish $phoneMap = []; // token => variants[] @@ -229,22 +267,174 @@ public function search(string $query): array $applyMultiTokenLike($ecQB, $ecCols, $tokens, ['cellphone']); $emergency = $ecQB->limit(150)->get()->getResultArray(); - $raw = [ - 'users' => $users, - 'students' => $students, - 'parents' => $parents, - 'staff' => $staff, - 'emergency_contacts' => $emergency, - ]; - - $total = count($users) + count($students) + count($parents) + count($staff) + count($emergency); + $results = $this->mergeSearchResults($users, $students, $parents, $staff, $emergency); return [ 'query' => $q, - 'results' => $raw, - 'scope_used' => 'unscoped-raw', - 'scope_label' => 'all years/semesters (raw, tokenized)', - 'total_found' => $total, + 'results' => $results, + 'scope_used' => 'unscoped-merged', + 'scope_label' => 'all years/semesters (merged, tokenized)', + 'total_found' => count($results), ]; } + +private function mergeSearchResults(array $users, array $students, array $parents, array $staff, array $emergency): array +{ + $bundles = []; + $userIds = []; + + $ensureBundle = static function (int $userId) use (&$bundles, &$userIds): void { + if ($userId <= 0) { + return; + } + + if (!isset($bundles[$userId])) { + $bundles[$userId] = [ + 'user' => null, + 'students' => [], + 'parents' => [], + 'staff' => [], + 'emergency_contacts' => [], + ]; + } + + $userIds[$userId] = $userId; + }; + + foreach ($users as $user) { + $userId = (int) ($user['id'] ?? 0); + $ensureBundle($userId); + if ($userId > 0) { + $bundles[$userId]['user'] = $user; + } + } + + foreach ($students as $student) { + $parentId = (int) ($student['parent_id'] ?? 0); + $ensureBundle($parentId); + if ($parentId > 0) { + $bundles[$parentId]['students'][(int) ($student['id'] ?? 0)] = $student; + } + } + + foreach ($parents as $parent) { + $firstParentId = (int) ($parent['firstparent_id'] ?? 0); + $ensureBundle($firstParentId); + if ($firstParentId > 0) { + $bundles[$firstParentId]['parents'][(int) ($parent['id'] ?? 0)] = $parent; + } + } + + foreach ($staff as $staffRow) { + $userId = (int) ($staffRow['user_id'] ?? 0); + $ensureBundle($userId); + if ($userId > 0) { + $bundles[$userId]['staff'][(int) ($staffRow['id'] ?? 0)] = $staffRow; + } + } + + foreach ($emergency as $emergencyRow) { + $parentId = (int) ($emergencyRow['parent_id'] ?? 0); + $ensureBundle($parentId); + if ($parentId > 0) { + $bundles[$parentId]['emergency_contacts'][(int) ($emergencyRow['id'] ?? 0)] = $emergencyRow; + } + } + + if ($userIds === []) { + return []; + } + + $this->hydrateSearchBundles($bundles, array_values($userIds)); + + $results = array_values($bundles); + usort($results, static function (array $a, array $b): int { + $aUser = $a['user'] ?? []; + $bUser = $b['user'] ?? []; + $aName = trim((string) ($aUser['lastname'] ?? '') . ' ' . (string) ($aUser['firstname'] ?? '')); + $bName = trim((string) ($bUser['lastname'] ?? '') . ' ' . (string) ($bUser['firstname'] ?? '')); + + return strcasecmp($aName, $bName); + }); + + return $results; +} + +private function hydrateSearchBundles(array &$bundles, array $userIds): void +{ + $userRows = $this->db->table('users') + ->select('id, firstname, lastname, email, cellphone, school_id, city, state') + ->whereIn('id', $userIds) + ->get() + ->getResultArray(); + + foreach ($userRows as $user) { + $userId = (int) ($user['id'] ?? 0); + if ($userId > 0 && isset($bundles[$userId]) && empty($bundles[$userId]['user'])) { + $bundles[$userId]['user'] = $user; + } + } + + $studentRows = $this->db->table('students') + ->select('id, parent_id, school_id, firstname, lastname, dob, gender, rfid_tag, is_active') + ->whereIn('parent_id', $userIds) + ->orderBy('lastname', 'ASC') + ->orderBy('firstname', 'ASC') + ->get() + ->getResultArray(); + + foreach ($studentRows as $student) { + $parentId = (int) ($student['parent_id'] ?? 0); + if ($parentId > 0 && isset($bundles[$parentId])) { + $bundles[$parentId]['students'][(int) ($student['id'] ?? 0)] = $student; + } + } + + $parentRows = $this->db->table('parents') + ->select('id, firstparent_id, secondparent_firstname, secondparent_lastname, secondparent_email, secondparent_phone') + ->whereIn('firstparent_id', $userIds) + ->get() + ->getResultArray(); + + foreach ($parentRows as $parent) { + $firstParentId = (int) ($parent['firstparent_id'] ?? 0); + if ($firstParentId > 0 && isset($bundles[$firstParentId])) { + $bundles[$firstParentId]['parents'][(int) ($parent['id'] ?? 0)] = $parent; + } + } + + $staffRows = $this->db->table('staff') + ->select('id, user_id, firstname, lastname, email, phone, role_name, active_role') + ->whereIn('user_id', $userIds) + ->get() + ->getResultArray(); + + foreach ($staffRows as $staffRow) { + $userId = (int) ($staffRow['user_id'] ?? 0); + if ($userId > 0 && isset($bundles[$userId])) { + $bundles[$userId]['staff'][(int) ($staffRow['id'] ?? 0)] = $staffRow; + } + } + + $emergencyRows = $this->db->table('emergency_contacts') + ->select('id, parent_id, emergency_contact_name, relation, cellphone, email') + ->whereIn('parent_id', $userIds) + ->get() + ->getResultArray(); + + foreach ($emergencyRows as $emergencyRow) { + $parentId = (int) ($emergencyRow['parent_id'] ?? 0); + if ($parentId > 0 && isset($bundles[$parentId])) { + $bundles[$parentId]['emergency_contacts'][(int) ($emergencyRow['id'] ?? 0)] = $emergencyRow; + } + } + + foreach ($bundles as &$bundle) { + $bundle['students'] = array_values($bundle['students']); + $bundle['parents'] = array_values($bundle['parents']); + $bundle['staff'] = array_values($bundle['staff']); + $bundle['emergency_contacts'] = array_values($bundle['emergency_contacts']); + } + unset($bundle); +} } diff --git a/app/Support/Enrollment/EnrollmentEligibility.php b/app/Support/Enrollment/EnrollmentEligibility.php index 9c4f25f..e0c0699 100644 --- a/app/Support/Enrollment/EnrollmentEligibility.php +++ b/app/Support/Enrollment/EnrollmentEligibility.php @@ -9,7 +9,7 @@ final class EnrollmentEligibility public const DEFERRED_MESSAGE = 'Re-enrollment cannot currently be completed because the final deliberation decision is deferred. Please contact the school administration for the next required step.'; public const KG_MISSING_DECISION_ELIGIBLE_MESSAGE = 'KG students may complete registration now. Their new-year grade placement will be based on the school age-placement rule.'; public const MISSING_DECISION_MESSAGE = 'Re-enrollment cannot currently be completed because no final deliberation decision is recorded for the student. Registration will become available after the school records a final decision.'; - public const ADULT_STUDENT_MESSAGE = 'This student will be 18 years old or older on September 1 of the selected school year. The student can no longer enroll in the school.'; + public const ADULT_STUDENT_MESSAGE = 'This student will be 18 years old or older on September 1 of the selected school year. A parent or guardian cannot complete registration because the student can no longer enroll in the school.'; public const ADULT_STUDENT_PARENT_PORTAL_MESSAGE = self::ADULT_STUDENT_MESSAGE; public const WITHDRAWN_PORTAL_MESSAGE = 'This student is currently marked as Withdrawn and cannot be enrolled at this time. Please contact school administration.'; public const SIBLING_PORTAL_MESSAGE = 'Enrollment cannot continue because the family record requires administrative review. Please contact school administration.'; diff --git a/app/Views/grading/homework.php b/app/Views/grading/homework.php index 8ea2a2e..022b8a7 100644 --- a/app/Views/grading/homework.php +++ b/app/Views/grading/homework.php @@ -1,4 +1,81 @@ = $this->extend('layout/management_layout') ?> += $this->section('styles') ?> + += $this->endSection() ?> = $this->section('content') ?> "> -
| # | -School ID | -First Name | -Last Name | +# | +School ID | +First Name | +Last Name | = esc("Homework " . $index) ?> | @@ -59,15 +137,15 @@ $lockAttr = $scoresLocked ? 'disabled' : ''; $rowLockAttr = $scoresLocked ? $lockAttr : ($rowLocked ? 'readonly aria-disabled="true"' : ''); ?>
|---|---|---|---|---|---|---|---|---|
| = $row++ ?> | -= esc($student['school_id']) ?> | -+ | = $row++ ?> | += esc($student['school_id']) ?> | += esc($student['firstname']) ?> = student_enrollment_status_button($student, $schoolYear ?? null) ?> | -+ | = esc($student['lastname']) ?> @@ -89,6 +167,7 @@ $lockAttr = $scoresLocked ? 'disabled' : ''; |