diff --git a/app/Config/Routes.php b/app/Config/Routes.php index ddddb58..eeeb08f 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1281,7 +1281,8 @@ $routes->post('submit_contact_form', 'View\PageController::submitContactForm'); // app/Config/Routes.php $routes->get('/logout', 'AuthController::logout'); -$routes->get('/stats', 'Stats::index'); +$routes->get('/stats', 'View\StatsController::index', ['filter' => 'auth:administrator|admin|principal|vice_principal|administrative staff|head of department (education)|view_financial_reports,read']); +$routes->get('/administrator/stats', 'View\StatsController::index', ['filter' => 'auth:administrator|admin|principal|vice_principal|administrative staff|head of department (education)|view_financial_reports,read']); // Define the route for user registration $routes->get('/register', 'View\RegisterController::index'); diff --git a/app/Controllers/View/ExpenseController.php b/app/Controllers/View/ExpenseController.php index f16fa4d..747b1f6 100644 --- a/app/Controllers/View/ExpenseController.php +++ b/app/Controllers/View/ExpenseController.php @@ -196,7 +196,7 @@ class ExpenseController extends BaseController } $status = $isDonation ? 'approved' : 'pending'; - $statusReason = $isDonation ? 'Marked as Donation (non-reimbursable).' : null; + $statusReason = $isDonation ? 'Recorded as donation income to the school (non-reimbursable).' : null; $db = \Config\Database::connect(); $db->transBegin(); @@ -399,7 +399,7 @@ class ExpenseController extends BaseController if ($isDonation) { $updateData['status'] = 'approved'; - $updateData['status_reason'] = 'Marked as Donation (non-reimbursable).'; + $updateData['status_reason'] = 'Recorded as donation income to the school (non-reimbursable).'; $updateData['approved_by'] = $userId ?: null; $updateData['reimbursement_id'] = null; } elseif (($expense['category'] ?? '') === 'Donation') { diff --git a/app/Controllers/View/FinancialController.php b/app/Controllers/View/FinancialController.php index 0c6e7e6..4ceee8f 100644 --- a/app/Controllers/View/FinancialController.php +++ b/app/Controllers/View/FinancialController.php @@ -11,6 +11,7 @@ use App\Models\ReimbursementModel; use App\Models\UserModel; use App\Libraries\FinancialStatus; use App\Libraries\InvoiceLedgerService; +use App\Services\FinancialCategorySummaryService; require_once APPPATH . 'ThirdParty/fpdf/fpdf.php'; use FPDF; @@ -340,10 +341,16 @@ public function financialReport() unset($invoiceRow); // === Expenses === - $expenses = $expenseModel + $expenseRows = $expenseModel ->select('category, SUM(amount) AS total_amount') ->groupBy('category') ->findAll(); + $categorySummary = FinancialCategorySummaryService::summarize($expenseRows); + $expenses = []; + foreach ($categorySummary['expenseCategories'] as $category => $amount) { + $expenses[] = ['category' => $category, 'total_amount' => $amount]; + } + $donationToSchool = $categorySummary['donationIncome']; // === Reimbursements === $reimbursements = $reimbursementModel @@ -370,6 +377,7 @@ public function financialReport() 'paymentTotals' => $paymentTotals, 'refunds' => $refunds, 'expenses' => $expenses, + 'donationToSchool' => $donationToSchool, 'reimbursements' => $reimbursements, 'discounts' => $discounts, 'eventFeesTotal' => $eventFeesTotal, @@ -385,6 +393,7 @@ public function financialReport() 'paymentTotals' => $paymentTotals, // summary totals + grand total 'refunds' => $refunds, 'expenses' => $expenses, + 'donationToSchool' => $donationToSchool, 'reimbursements' => $reimbursements, 'discounts' => $discounts, 'eventFeesTotal' => $eventFeesTotal, @@ -457,7 +466,7 @@ public function financialReport() ['Total Refunds', (float)($data['totalRefunds'] ?? 0)], ['Total Expenses', (float)($data['totalExpenses'] ?? 0)], ['Total Reimbursements', (float)($data['totalReimbursements'] ?? 0)], - ['Donation to School (included in expenses)', (float)($data['donationToSchool'] ?? 0)], + ['Donation Income to School', (float)($data['donationToSchool'] ?? 0)], ['Net Charges After Discounts/Refunds', (float)($data['netAmount'] ?? 0)], ['Amount Collected (Paid)', (float)($data['amountCollected'] ?? 0)], ['Overpayment Credits', (float)($data['totalOverpaid'] ?? 0)], @@ -534,7 +543,7 @@ public function financialReport() ['Total Refunds' => (float)($summary['totalRefunds'] ?? 0)], ['Total Expenses' => (float)($summary['totalExpenses'] ?? 0)], ['Total Reimbursements' => (float)($summary['totalReimbursements'] ?? 0)], - ['Donation to School (included in expenses)' => (float)($summary['donationToSchool'] ?? 0)], + ['Donation Income to School' => (float)($summary['donationToSchool'] ?? 0)], ['Net Charges After Discounts/Refunds' => (float)($summary['netAmount'] ?? 0)], ['Amount Collected (Paid)' => (float)($summary['amountCollected'] ?? 0)], ['Overpayment Credits' => (float)($summary['totalOverpaid'] ?? 0)], @@ -572,8 +581,8 @@ public function financialReport() 'Parents With Outstanding Balance Details' => $sections['unpaidParents'] ?? [], 'Expense Details' => $sections['expenses'] ?? [], 'Reimbursement Details' => $sections['reimbursements'] ?? [], - 'Donation Expense Details' => $sections['donationExpenses'] ?? [], - 'Donation Reimbursement Details' => $sections['donationReimbursements'] ?? [], + 'Donation Income Details' => $sections['donationExpenses'] ?? [], + 'Special Recipient Reimbursement Details' => $sections['donationReimbursements'] ?? [], 'Reimbursement Batch Fallback Details' => $sections['reimbursementBatchFallbacks'] ?? [], 'Overpayment Credit Details' => $summary['overpaymentDetails'] ?? [], ]; @@ -883,6 +892,9 @@ public function financialReport() $donationExpenses = array_values(array_filter($expenses, static function ($row) { return strtolower(trim((string)($row['Category'] ?? ''))) === 'donation'; })); + $expenses = array_values(array_filter($expenses, static function ($row) { + return strtolower(trim((string)($row['Category'] ?? ''))) !== 'donation'; + })); $donationReimbursements = []; if (!empty($specialRecipientIds)) { @@ -1319,7 +1331,13 @@ public function financialReport() if (!empty($dateTo)) { $expBuilder->where('DATE(created_at) <=', $dateTo); } - $expenses = $expBuilder->groupBy('category')->findAll(); + $expenseRows = $expBuilder->groupBy('category')->findAll(); + $categorySummary = FinancialCategorySummaryService::summarize($expenseRows); + $expenses = []; + foreach ($categorySummary['expenseCategories'] as $category => $amount) { + $expenses[] = ['category' => $category, 'total_amount' => $amount]; + } + $donationIncome = $categorySummary['donationIncome']; // Reimbursements summary with filters $reimbBuilder = $reimbursementModel @@ -1412,6 +1430,10 @@ public function financialReport() '', ]); + // Donation income is incoming money, not an expense. + fputcsv($out, []); + fputcsv($out, ['Donation Income to School', $donationIncome]); + // Expenses section fputcsv($out, []); // blank line fputcsv($out, ['Expenses Summary']); @@ -1469,7 +1491,7 @@ public function financialReport() 'Total Refunds' => $data['totalRefunds'], 'Total Expenses' => $data['totalExpenses'], 'Total Reimbursements' => $data['totalReimbursements'], - 'Donation to School (Masjid/Donation reimbursements)' => $data['donationToSchool'] ?? 0, + 'Donation Income to School' => $data['donationToSchool'] ?? 0, 'Net Amount (Earned Income)' => $data['netAmount'], 'Amount Collected (Paid)' => $data['amountCollected'], 'Amount Unpaid (Outstanding)' => $data['totalUnpaid'], @@ -1673,16 +1695,25 @@ public function financialReport() $totalCharges += $extraChargesUnapplied; - // === Expenses === - $expenseBuilder = $expenseModel->where('school_year', $schoolYear); + // Donation rows live in the legacy expenses table but represent incoming + // funds. Classify them separately so they never inflate school expenses. + $expenseCategoryBuilder = $db->table('expenses') + ->select('category, COALESCE(SUM(amount), 0) AS amount', false) + ->where('school_year', $schoolYear) + ->groupBy('category') + ->orderBy('amount', 'DESC'); if (!empty($invoiceDateFrom)) { - $expenseBuilder->where('DATE(created_at) >=', $invoiceDateFrom); + $expenseCategoryBuilder->where('DATE(created_at) >=', $invoiceDateFrom); } if (!empty($invoiceDateTo)) { - $expenseBuilder->where('DATE(created_at) <=', $invoiceDateTo); + $expenseCategoryBuilder->where('DATE(created_at) <=', $invoiceDateTo); } - $expenseResult = $expenseBuilder->selectSum('amount')->get()->getRowArray(); - $totalExpenses = isset($expenseResult['amount']) ? (float) $expenseResult['amount'] : 0.00; + $categorySummary = FinancialCategorySummaryService::summarize( + $expenseCategoryBuilder->get()->getResultArray() + ); + $totalExpenses = $categorySummary['totalExpenses']; + $donationToSchool = $categorySummary['donationIncome']; + $expenseCategories = $categorySummary['expenseCategories']; // === Reimbursements === // Reimbursements: include rows missing school_year by falling back to expense.school_year @@ -1754,62 +1785,6 @@ public function financialReport() $fallbackAmount = isset($batchFallbackRow['amount']) ? (float) $batchFallbackRow['amount'] : 0.00; $totalReimbursements += $fallbackAmount; - // Donations line: donations captured as expenses (category=Donation) plus legacy Masjid/Donation reimbursements. - $donationExpense = 0.0; - $donationExpenseBuilder = (new ExpenseModel()) - ->where('school_year', $schoolYear) - ->where('category', 'Donation'); - if (!empty($invoiceDateFrom)) { - $donationExpenseBuilder->where('DATE(created_at) >=', $invoiceDateFrom); - } - if (!empty($invoiceDateTo)) { - $donationExpenseBuilder->where('DATE(created_at) <=', $invoiceDateTo); - } - $donationExpenseRow = $donationExpenseBuilder->selectSum('amount')->get()->getRowArray(); - $donationExpense = isset($donationExpenseRow['amount']) ? (float) $donationExpenseRow['amount'] : 0.00; - - $donationReimb = 0.0; - $specialRecipientIds = array_map('intval', array_keys(ReimbursementController::SPECIAL_RECIPIENTS)); - if (!empty($specialRecipientIds)) { - $donationBuilder = $db->table('reimbursements r') - ->select('SUM(r.amount) AS amount') - ->join('expenses e', 'e.id = r.expense_id', 'left') - ->whereIn('r.reimbursed_to', $specialRecipientIds); - if (!empty($schoolYear)) { - $donationBuilder - ->groupStart() - ->groupStart() - ->where('r.school_year', $schoolYear) - ->orWhere( - "REPLACE(REPLACE(REPLACE(REPLACE(r.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '')", - $normalizedYear - ) - ->groupEnd() - ->orGroupStart() - ->where('r.school_year IS NULL', null, false) - ->groupStart() - ->where('e.school_year', $schoolYear) - ->orWhere( - "REPLACE(REPLACE(REPLACE(REPLACE(e.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '')", - $normalizedYear - ) - ->groupEnd() - ->groupEnd() - ->groupEnd(); - } - if (!empty($invoiceDateFrom)) { - $donationBuilder->where('DATE(COALESCE(r.created_at, e.created_at)) >=', $invoiceDateFrom); - } - if (!empty($invoiceDateTo)) { - $donationBuilder->where('DATE(COALESCE(r.created_at, e.created_at)) <=', $invoiceDateTo); - } - $donationRow = $donationBuilder->get()->getRowArray(); - $donationReimb = isset($donationRow['amount']) ? (float) $donationRow['amount'] : 0.00; - } - - $donationToSchool = $donationExpense + $donationReimb; - $totalReimbursements = max(0.0, $totalReimbursements - $donationReimb); - // === Net, Outstanding & Overpayments === $overpaymentDetails = []; $totalUnpaid = 0.0; @@ -1884,6 +1859,7 @@ public function financialReport() 'totalDiscounts' => $totalDiscounts, 'totalRefunds' => $totalRefunds, 'totalExpenses' => $totalExpenses, + 'expenseCategories' => $expenseCategories, 'totalReimbursements' => $totalReimbursements, 'donationToSchool' => $donationToSchool, 'totalPaid' => $totalPaid, diff --git a/app/Controllers/View/StatsController.php b/app/Controllers/View/StatsController.php index 068baf9..5282827 100644 --- a/app/Controllers/View/StatsController.php +++ b/app/Controllers/View/StatsController.php @@ -2,17 +2,44 @@ namespace App\Controllers\View; -use App\Models\StatsModel; use App\Controllers\BaseController; +use App\Services\AcademicStatisticsService; class StatsController extends BaseController { public function index() { - $model = new StatsModel(); - $data['stats'] = $model->getStats(); + $schoolYear = $this->currentSchoolYearName(); + $statistics = (new AcademicStatisticsService())->forSchoolYear($schoolYear); - return view('stats_view', $data); + return view('administrator/stats', [ + 'schoolYear' => $schoolYear, + 'statistics' => $statistics, + 'canViewFinancialStats' => $this->canViewFinancialStats(), + ]); + } + + private function canViewFinancialStats(): bool + { + $roles = array_map( + static fn ($role): string => strtolower(trim((string) $role)), + array_filter(array_merge((array) session()->get('roles'), [session()->get('role')])) + ); + + if ((bool) array_intersect(array_unique($roles), [ + 'admin', + 'administrator', + 'administrative staff', + 'principal', + ])) { + return true; + } + + helper('auth'); + $userId = (int) (session()->get('user_id') ?? 0); + + return $userId > 0 && function_exists('has_permission') + && has_permission($userId, 'view_financial_reports'); } public function calendar() diff --git a/app/Database/Migrations/2026-09-13-000100_AddAcademicStatisticsNavItem.php b/app/Database/Migrations/2026-09-13-000100_AddAcademicStatisticsNavItem.php new file mode 100644 index 0000000..728539e --- /dev/null +++ b/app/Database/Migrations/2026-09-13-000100_AddAcademicStatisticsNavItem.php @@ -0,0 +1,92 @@ +db->tableExists('nav_items')) { + return; + } + + $existing = $this->db->table('nav_items')->where('url', $this->url)->get()->getRowArray(); + if ($existing !== null) { + return; + } + + $parentColumn = $this->parentColumn(); + $parentBuilder = $this->db->table('nav_items')->where('label', 'Student-Affairs'); + if ($parentColumn !== null) { + $parentBuilder->where($parentColumn, null); + } + $parent = $parentBuilder->get()->getRowArray(); + + $insert = [ + 'label' => 'School Statistics', + 'url' => $this->url, + 'sort_order' => 14, + 'is_enabled' => 1, + 'created_at' => date('Y-m-d H:i:s'), + ]; + if ($parentColumn !== null) { + $insert[$parentColumn] = $parent['id'] ?? null; + } + + $this->db->table('nav_items')->insert($insert); + $navItemId = (int) $this->db->insertID(); + $this->grantToRoles($navItemId, ['administrator', 'admin', 'principal', 'vice_principal', 'administrative staff', 'head of department (education)']); + } + + public function down(): void + { + if (! $this->db->tableExists('nav_items')) { + return; + } + + $row = $this->db->table('nav_items')->where('url', $this->url)->get()->getRowArray(); + if ($row === null) { + return; + } + + if ($this->db->tableExists('role_nav_items')) { + $this->db->table('role_nav_items')->where('nav_item_id', (int) $row['id'])->delete(); + } + $this->db->table('nav_items')->where('id', (int) $row['id'])->delete(); + } + + private function grantToRoles(int $navItemId, array $roles): void + { + if ($navItemId <= 0 || ! $this->db->tableExists('role_nav_items')) { + return; + } + + foreach ($roles as $role) { + $insert = ['nav_item_id' => $navItemId, 'created_at' => date('Y-m-d H:i:s')]; + if ($this->db->fieldExists('role_id', 'role_nav_items') && $this->db->tableExists('roles')) { + $roleRow = $this->db->table('roles')->select('id')->where('LOWER(name)', strtolower($role))->get()->getRowArray(); + if ($roleRow === null) { + continue; + } + $insert['role_id'] = (int) $roleRow['id']; + } elseif ($this->db->fieldExists('role', 'role_nav_items')) { + $insert['role'] = $role; + } else { + continue; + } + $this->db->table('role_nav_items')->insert($insert); + } + } + + private function parentColumn(): ?string + { + if ($this->db->fieldExists('parent_id', 'nav_items')) { + return 'parent_id'; + } + return $this->db->fieldExists('menu_parent_id', 'nav_items') ? 'menu_parent_id' : null; + } +} diff --git a/app/Database/Migrations/2026-09-13-000200_RenameAndGrantSchoolStatisticsNavItem.php b/app/Database/Migrations/2026-09-13-000200_RenameAndGrantSchoolStatisticsNavItem.php new file mode 100644 index 0000000..483c94c --- /dev/null +++ b/app/Database/Migrations/2026-09-13-000200_RenameAndGrantSchoolStatisticsNavItem.php @@ -0,0 +1,64 @@ +rename('School Statistics'); + if ($navItemId <= 0 || ! $this->db->tableExists('role_nav_items')) { + return; + } + + foreach (['administrator', 'admin', 'principal', 'vice_principal', 'administrative staff', 'head of department (education)'] as $role) { + $roleRow = $this->db->table('roles') + ->select('id') + ->where('LOWER(name)', strtolower($role)) + ->get() + ->getRowArray(); + if ($roleRow === null) { + continue; + } + + $roleId = (int) $roleRow['id']; + $exists = $this->db->table('role_nav_items') + ->where('role_id', $roleId) + ->where('nav_item_id', $navItemId) + ->countAllResults() > 0; + if (! $exists) { + $this->db->table('role_nav_items')->insert([ + 'role_id' => $roleId, + 'nav_item_id' => $navItemId, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + } + } + + public function down(): void + { + $this->rename('Academic Statistics'); + } + + private function rename(string $label): int + { + if (! $this->db->tableExists('nav_items')) { + return 0; + } + + $row = $this->db->table('nav_items')->select('id')->where('url', $this->url)->get()->getRowArray(); + if ($row === null) { + return 0; + } + + $navItemId = (int) $row['id']; + $this->db->table('nav_items')->where('id', $navItemId)->update(['label' => $label]); + + return $navItemId; + } +} diff --git a/app/Services/AcademicStatisticsService.php b/app/Services/AcademicStatisticsService.php new file mode 100644 index 0000000..1170c8d --- /dev/null +++ b/app/Services/AcademicStatisticsService.php @@ -0,0 +1,315 @@ +db = $db ?? \Config\Database::connect(); + } + + public function forSchoolYear(string $schoolYear): array + { + $schoolYear = trim($schoolYear); + + if ($schoolYear === '') { + return self::summarize([], $schoolYear); + } + + $builder = $this->db->table('student_class sc') + ->select([ + 'sc.student_id', + 'sc.class_section_id', + 's.gender', + 'cs.class_section_name', + ]) + ->select('MAX(CASE WHEN LOWER(ss.semester) = "fall" THEN ss.semester_score END) AS fall_score', false) + ->select('MAX(CASE WHEN LOWER(ss.semester) = "spring" THEN ss.semester_score END) AS spring_score', false) + ->join('students s', 's.id = sc.student_id', 'left') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->join( + 'semester_scores ss', + 'ss.student_id = sc.student_id' + . ' AND ss.class_section_id = sc.class_section_id' + . ' AND ss.school_year = ' . $this->db->escape($schoolYear), + 'left' + ) + ->where('sc.school_year', $schoolYear) + ->groupStart() + ->where('sc.is_event_only', 0) + ->orWhere('sc.is_event_only', null) + ->groupEnd() + ->groupBy('sc.student_id, sc.class_section_id, s.gender, cs.class_section_name') + ->orderBy('cs.class_section_name', 'ASC'); + + return self::summarize($builder->get()->getResultArray(), $schoolYear); + } + + public static function summarize(array $rows, string $schoolYear = ''): array + { + $students = []; + $classSizes = []; + $trophyCandidates = []; + + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId <= 0) { + continue; + } + + $sectionId = (int) ($row['class_section_id'] ?? 0); + $sectionName = trim((string) ($row['class_section_name'] ?? '')); + if ($sectionName === '') { + $sectionName = $sectionId > 0 ? 'Section ' . $sectionId : 'Unassigned'; + } + + $classKey = $sectionId > 0 ? 'section-' . $sectionId : 'name-' . $sectionName; + $classSizes[$classKey] ??= ['label' => $sectionName, 'students' => []]; + $classSizes[$classKey]['students'][$studentId] = self::genderKey($row['gender'] ?? null); + + $fall = is_numeric($row['fall_score'] ?? null) ? (float) $row['fall_score'] : null; + $spring = is_numeric($row['spring_score'] ?? null) ? (float) $row['spring_score'] : null; + $score = $fall !== null && $spring !== null + ? ($fall + $spring) / 2 + : ($fall ?? $spring); + + if (! isset($students[$studentId])) { + $students[$studentId] = [ + 'gender' => self::genderKey($row['gender'] ?? null), + 'scores' => [], + 'isKg' => false, + ]; + } + + if (preg_match('/(^|[^a-z])kg([^a-z]|$)/i', $sectionName) === 1) { + $students[$studentId]['isKg'] = true; + } + + if ($score !== null) { + $students[$studentId]['scores'][] = $score; + } + + $trophyCandidates[$classKey][$studentId] = [ + 'studentId' => $studentId, + 'gender' => self::genderKey($row['gender'] ?? null), + 'score' => $score, + ]; + } + + $trophyWinnerIds = []; + foreach ($trophyCandidates as $classCandidates) { + $threshold = self::trophyThreshold( + array_column($classCandidates, 'score'), + self::TROPHY_PERCENTILE + ); + if ($threshold === null) { + continue; + } + + foreach ($classCandidates as $candidate) { + if ($candidate['score'] !== null && $candidate['score'] >= $threshold) { + $trophyWinnerIds[$candidate['studentId']] = true; + } + } + } + + $gender = ['Male' => 0, 'Female' => 0]; + $results = ['Pass' => 0, 'Fail' => 0, 'Awaiting results' => 0]; + $topGender = ['Male' => 0, 'Female' => 0]; + $trophyGender = ['Male' => 0, 'Female' => 0]; + $scoreBands = ['Below 60' => 0, '60–69' => 0, '70–79' => 0, '80–89' => 0, '90–100' => 0, 'KG – Passed' => 0]; + $genderByStat = [ + 'Passed' => ['Male' => 0, 'Female' => 0], + 'Failed' => ['Male' => 0, 'Female' => 0], + 'Top performers' => ['Male' => 0, 'Female' => 0], + 'Trophy winners' => ['Male' => 0, 'Female' => 0], + ]; + $scoreTotal = 0.0; + $scoredStudents = 0; + + foreach ($students as $studentId => $student) { + $studentGender = $student['gender']; + if ($studentGender !== null) { + $gender[$studentGender]++; + if (isset($trophyWinnerIds[$studentId])) { + $trophyGender[$studentGender]++; + $genderByStat['Trophy winners'][$studentGender]++; + } + } + if ($student['scores'] === []) { + if ($student['isKg']) { + $results['Pass']++; + $scoreBands['KG – Passed']++; + if ($studentGender !== null) { + $genderByStat['Passed'][$studentGender]++; + } + } else { + $results['Awaiting results']++; + } + continue; + } + + $score = array_sum($student['scores']) / count($student['scores']); + $scoreTotal += $score; + $scoredStudents++; + $resultKey = $student['isKg'] || $score >= self::PASS_SCORE ? 'Pass' : 'Fail'; + $results[$resultKey]++; + if ($studentGender !== null) { + $genderByStat[$resultKey === 'Pass' ? 'Passed' : 'Failed'][$studentGender]++; + } + + if ($score >= self::TOP_PERFORMER_SCORE) { + if ($studentGender !== null) { + $topGender[$studentGender]++; + $genderByStat['Top performers'][$studentGender]++; + } + } + + if ($student['isKg']) { + $scoreBands['KG – Passed']++; + } elseif ($score >= self::TOP_PERFORMER_SCORE) { + $scoreBands['90–100']++; + } elseif ($score >= 80) { + $scoreBands['80–89']++; + } elseif ($score >= 70) { + $scoreBands['70–79']++; + } elseif ($score >= 60) { + $scoreBands['60–69']++; + } else { + $scoreBands['Below 60']++; + } + } + + uasort($classSizes, static fn (array $a, array $b): int => strnatcasecmp($a['label'], $b['label'])); + $classLabels = []; + $classValues = []; + $classMaleValues = []; + $classFemaleValues = []; + foreach ($classSizes as $class) { + $classLabels[] = $class['label']; + $classValues[] = count($class['students']); + $classMaleValues[] = count(array_filter( + $class['students'], + static fn (?string $gender): bool => $gender === 'Male' + )); + $classFemaleValues[] = count(array_filter( + $class['students'], + static fn (?string $gender): bool => $gender === 'Female' + )); + } + + $topPerformers = array_sum($topGender); + $trophyWinners = array_sum($trophyGender); + $passCount = $results['Pass']; + + return [ + 'schoolYear' => $schoolYear, + 'totalStudents' => count($students), + 'totalClasses' => count($classSizes), + 'scoredStudents' => $scoredStudents, + 'averageScore' => $scoredStudents > 0 ? round($scoreTotal / $scoredStudents, 1) : null, + 'passRate' => ($results['Pass'] + $results['Fail']) > 0 + ? round($passCount / ($results['Pass'] + $results['Fail']) * 100, 1) + : null, + 'topPerformers' => $topPerformers, + 'trophyWinners' => $trophyWinners, + 'gender' => $gender, + 'results' => $results, + 'topGender' => $topGender, + 'trophyGender' => $trophyGender, + 'genderByStat' => $genderByStat, + 'classSizes' => [ + 'labels' => $classLabels, + 'values' => $classValues, + 'male' => $classMaleValues, + 'female' => $classFemaleValues, + ], + 'scoreBands' => $scoreBands, + ]; + } + + private static function genderKey(mixed $gender): ?string + { + return match (strtolower(trim((string) $gender))) { + 'male', 'm', 'boy', 'boys' => 'Male', + 'female', 'f', 'girl', 'girls' => 'Female', + default => null, + }; + } + + /** + * Match the final trophy report: 75th percentile per class, at least three + * scored winners when available, with tied scores included. + */ + private static function trophyThreshold(array $scores, float $percentile): ?float + { + $scores = array_values(array_filter($scores, static fn ($score): bool => is_numeric($score))); + $scores = array_map('floatval', $scores); + sort($scores); + $count = count($scores); + + if ($count === 0) { + return null; + } + + $minimumWinners = 3; + $maximumWinners = max($minimumWinners, (int) floor($count * (1 - $percentile / 100))); + $index = ($percentile / 100) * ($count - 1); + $lower = (int) floor($index); + $upper = (int) ceil($index); + $threshold = $lower === $upper + ? $scores[$lower] + : $scores[$lower] + ($index - $lower) * ($scores[$upper] - $scores[$lower]); + $winnerCount = self::countAtOrAbove($scores, $threshold); + + if ($winnerCount < $minimumWinners) { + $descending = array_reverse($scores); + return $descending[min($minimumWinners, $count) - 1]; + } + + if ($winnerCount <= $maximumWinners) { + return $threshold; + } + + $descending = array_reverse($scores); + $threshold = $descending[$maximumWinners - 1]; + $winnerCount = self::countAtOrAbove($scores, $threshold); + if ($winnerCount <= $maximumWinners) { + return $threshold; + } + + $higherScores = array_values(array_unique(array_filter( + $scores, + static fn (float $score): bool => $score > $threshold + ))); + sort($higherScores); + foreach ($higherScores as $candidate) { + if (self::countAtOrAbove($scores, $candidate) <= $maximumWinners) { + $threshold = $candidate; + $winnerCount = self::countAtOrAbove($scores, $candidate); + break; + } + } + + if ($winnerCount < $minimumWinners) { + $descending = array_reverse($scores); + return $descending[min($minimumWinners, $count) - 1]; + } + + return $threshold; + } + + private static function countAtOrAbove(array $scores, float $threshold): int + { + return count(array_filter($scores, static fn (float $score): bool => $score >= $threshold)); + } +} diff --git a/app/Services/FinancialCategorySummaryService.php b/app/Services/FinancialCategorySummaryService.php new file mode 100644 index 0000000..5834cdd --- /dev/null +++ b/app/Services/FinancialCategorySummaryService.php @@ -0,0 +1,41 @@ + round($totalExpenses, 2), + 'donationIncome' => round($donationIncome, 2), + 'expenseCategories' => $expenseCategories, + ]; + } +} diff --git a/app/Views/administrator/stats.php b/app/Views/administrator/stats.php new file mode 100644 index 0000000..d21bdbb --- /dev/null +++ b/app/Views/administrator/stats.php @@ -0,0 +1,678 @@ +extend('layout/management_layout') ?> + +section('styles') ?> + +endSection() ?> + +section('content') ?> + $value === null ? '—' : number_format((float) $value, is_float($value) ? 1 : 0); +$chartData = [ + 'totalStudents' => (int) ($statistics['totalStudents'] ?? 0), + 'gender' => $statistics['gender'] ?? [], + 'results' => $statistics['results'] ?? [], + 'topGender' => $statistics['topGender'] ?? [], + 'trophyGender' => $statistics['trophyGender'] ?? [], + 'genderByStat' => $statistics['genderByStat'] ?? [], + 'classSizes' => $statistics['classSizes'] ?? ['labels' => [], 'values' => [], 'male' => [], 'female' => []], + 'scoreBands' => $statistics['scoreBands'] ?? [], +]; +$canViewFinancialStats = (bool) ($canViewFinancialStats ?? false); +?> +
+
+
+
Academic overview
+

Al Rahma Statistics

+

Enrollment, results, high achievement, and class distribution in one view.

+
+
+ +
+ +
+
+
+
+
+
+
+
+ +
+ + +
No academic class assignments were found for this school year.
+ + +
+
+

Student enrollment

Gender distribution of enrolled students

+
+
+
+

Academic results

Pass, fail, and results not yet available

+
+
+
+

Top performers

Students averaging 90%+, measured against total enrollment

+
+
+
+ +
+
+

Enrollment by class

Number of male and female students in each class

+
+
+
+

Score distribution

Score bands and awaiting results against total enrollment

+
+
+
+ +
+
+

Gender distribution by academic status

+

Counts by gender; percentages use total enrolled students

+
+
+ $status): ?> +
+
+

+
+
+
+ +
+
+ + + + +
+
+
+
Financial overview
+

Al Rahma Financials

+

Collections, outstanding balances, spending, and donations.

+
+ + Detailed report + +
+ +
+ Loading financial statistics… +
+ +
+ +
+
+
+
+
+ +
+ +
+
+

Gross charge allocation

Collected, outstanding, discounts, and refunds; denominator is gross charges

+
+
+
+

Income distribution

Payments and donation income as a percentage of gross charges

+
+
+
+

Expense details

Expense categories only; every percentage uses gross charges as its denominator

+
+
+
+ + +
+ +
+ + +endSection() ?> + +section('scripts') ?> + + + +endSection() ?> diff --git a/app/Views/expenses/create.php b/app/Views/expenses/create.php index cc8bafe..c432230 100644 --- a/app/Views/expenses/create.php +++ b/app/Views/expenses/create.php @@ -17,9 +17,9 @@ - + -
Use Donation when someone gifts items to the school so it is counted as an expense but skipped from reimbursement queues.
+
Use Donation for money donated to the school. It is recorded as income and excluded from expenses and reimbursement queues.
diff --git a/app/Views/expenses/edit.php b/app/Views/expenses/edit.php index fe56c50..3acfce1 100644 --- a/app/Views/expenses/edit.php +++ b/app/Views/expenses/edit.php @@ -15,11 +15,11 @@ -
Donation entries are tracked as expenses but will not move through reimbursement batches.
+
Donation entries represent money received by the school. They are excluded from expenses and reimbursement queues.
diff --git a/app/Views/payment/financial_report.php b/app/Views/payment/financial_report.php index c8155a7..b198179 100644 --- a/app/Views/payment/financial_report.php +++ b/app/Views/payment/financial_report.php @@ -154,7 +154,12 @@
-

Expenses Summary

+
+ Donation Income to School: + $ +
+ +

Expenses Summary

@@ -410,6 +415,8 @@ } // Expenses + const donationIncome = qs('#donationIncomeTotal'); + if (donationIncome) donationIncome.textContent = fmt(data.donationToSchool || 0); const expTbody = qs('#expensesTable tbody'); expTbody.innerHTML = ''; (data.expenses || []).forEach(exp => { diff --git a/app/Views/payment/financial_report_summary.php b/app/Views/payment/financial_report_summary.php index 24a8d7e..acdf420 100644 --- a/app/Views/payment/financial_report_summary.php +++ b/app/Views/payment/financial_report_summary.php @@ -42,7 +42,7 @@ - + @@ -228,6 +228,7 @@ function renderCharts(d){ grossCharges: '#d62728', discounts: '#9467bd', expenses: '#20c997', + donations: '#16a34a', netCharges: '#e377c2', collected: '#28a745', overpaymentCredits: '#dc3545', @@ -242,6 +243,7 @@ function renderCharts(d){ chartColors.grossCharges, chartColors.discounts, chartColors.expenses, + chartColors.donations, chartColors.netCharges, chartColors.collected, chartColors.overpaymentCredits, @@ -250,9 +252,9 @@ function renderCharts(d){ ]; window._summaryChart = new Chart(summaryCtx, { type: 'bar', - data: { labels: ['Tuition','Event Fees','Prior-Year Carryover','Gross Charges','Discounts','Expenses','Net Charges','Collected','Overpayment Credits','Outstanding','Net Receivable'], + data: { labels: ['Tuition','Event Fees','Prior-Year Carryover','Gross Charges','Discounts','Expenses','Donation Income','Net Charges','Collected','Overpayment Credits','Outstanding','Net Receivable'], datasets: [{ label:'Amount (USD)', data:[ - d.tuitionCharges||0, d.totalEventFees||0, d.totalExtraCharges||0, d.grossCharges||d.totalCharges||0, d.totalDiscounts||0, d.totalExpenses||0, d.netAmount||0, d.amountCollected||d.totalPaid||0, d.totalOverpaid||0, d.totalUnpaid||0, d.netReceivable||0 + d.tuitionCharges||0, d.totalEventFees||0, d.totalExtraCharges||0, d.grossCharges||d.totalCharges||0, d.totalDiscounts||0, d.totalExpenses||0, d.donationToSchool||0, d.netAmount||0, d.amountCollected||d.totalPaid||0, d.totalOverpaid||0, d.totalUnpaid||0, d.netReceivable||0 ], backgroundColor: summaryBarColors}] }, options: { responsive:true, scales:{ y:{ beginAtZero:true }}} }); diff --git a/tests/app/Controllers/View/ExamDraftControllerGroupingTest.php b/tests/app/Controllers/View/ExamDraftControllerGroupingTest.php index 38dcc11..9ec6b69 100644 --- a/tests/app/Controllers/View/ExamDraftControllerGroupingTest.php +++ b/tests/app/Controllers/View/ExamDraftControllerGroupingTest.php @@ -135,6 +135,9 @@ final class ExamDraftControllerGroupingTest extends CIUnitTestCase public function testGeneratedPdfBesideDocxIsRejectedAsLossy(): void { $directory = WRITEPATH . 'uploads/exams/finals'; + if (!is_dir($directory)) { + $this->assertTrue(mkdir($directory, 0755, true) || is_dir($directory)); + } $base = 'conversion-safety-' . uniqid('', true); $pdf = $directory . '/' . $base . '.pdf'; $docx = $directory . '/' . $base . '.docx'; diff --git a/tests/app/Services/AcademicStatisticsServiceTest.php b/tests/app/Services/AcademicStatisticsServiceTest.php new file mode 100644 index 0000000..2c9d095 --- /dev/null +++ b/tests/app/Services/AcademicStatisticsServiceTest.php @@ -0,0 +1,81 @@ + 1, 'class_section_id' => 10, 'class_section_name' => '1-A', 'gender' => 'Male', 'fall_score' => 80, 'spring_score' => 100], + ['student_id' => 2, 'class_section_id' => 10, 'class_section_name' => '1-A', 'gender' => 'female', 'fall_score' => 55, 'spring_score' => null], + ['student_id' => 3, 'class_section_id' => 20, 'class_section_name' => '2-A', 'gender' => 'girl', 'fall_score' => null, 'spring_score' => null], + ], '2025-2026'); + + $this->assertSame(3, $stats['totalStudents']); + $this->assertSame(2, $stats['totalClasses']); + $this->assertSame(2, $stats['scoredStudents']); + $this->assertSame(72.5, $stats['averageScore']); + $this->assertSame(50.0, $stats['passRate']); + $this->assertSame(1, $stats['topPerformers']); + $this->assertSame(['Pass' => 1, 'Fail' => 1, 'Awaiting results' => 1], $stats['results']); + $this->assertSame(['Male' => 1, 'Female' => 2], $stats['gender']); + $this->assertSame([ + 'labels' => ['1-A', '2-A'], + 'values' => [2, 1], + 'male' => [1, 0], + 'female' => [1, 1], + ], $stats['classSizes']); + $this->assertSame([ + 'Passed' => ['Male' => 1, 'Female' => 0], + 'Failed' => ['Male' => 0, 'Female' => 1], + 'Top performers' => ['Male' => 1, 'Female' => 0], + 'Trophy winners' => ['Male' => 1, 'Female' => 1], + ], $stats['genderByStat']); + $this->assertSame(2, $stats['trophyWinners']); + $this->assertSame(['Male' => 1, 'Female' => 1], $stats['trophyGender']); + } + + public function testAveragesMultipleClassScoresOncePerStudent(): void + { + $stats = AcademicStatisticsService::summarize([ + ['student_id' => 9, 'class_section_id' => 1, 'class_section_name' => 'Quran', 'gender' => 'M', 'fall_score' => 100, 'spring_score' => 100], + ['student_id' => 9, 'class_section_id' => 2, 'class_section_name' => 'Arabic', 'gender' => 'M', 'fall_score' => 80, 'spring_score' => 80], + ]); + + $this->assertSame(1, $stats['totalStudents']); + $this->assertSame(2, $stats['totalClasses']); + $this->assertSame(90.0, $stats['averageScore']); + $this->assertSame(1, $stats['topPerformers']); + } + + public function testKgStudentsPassWithoutSemesterScores(): void + { + $stats = AcademicStatisticsService::summarize([ + ['student_id' => 21, 'class_section_id' => 3, 'class_section_name' => 'KG-A', 'gender' => 'Male', 'fall_score' => null, 'spring_score' => null], + ['student_id' => 22, 'class_section_id' => 4, 'class_section_name' => '1-A', 'gender' => 'Female', 'fall_score' => null, 'spring_score' => null], + ]); + + $this->assertSame(['Pass' => 1, 'Fail' => 0, 'Awaiting results' => 1], $stats['results']); + $this->assertSame(100.0, $stats['passRate']); + $this->assertSame(1, $stats['scoreBands']['KG – Passed']); + $this->assertSame(['Male' => 1, 'Female' => 0], $stats['genderByStat']['Passed']); + } + + public function testTrophyWinnersUseClassThresholdAndAreDistributedByGender(): void + { + $stats = AcademicStatisticsService::summarize([ + ['student_id' => 31, 'class_section_id' => 8, 'class_section_name' => '4-A', 'gender' => 'Male', 'fall_score' => 100, 'spring_score' => 100], + ['student_id' => 32, 'class_section_id' => 8, 'class_section_name' => '4-A', 'gender' => 'Female', 'fall_score' => 90, 'spring_score' => 90], + ['student_id' => 33, 'class_section_id' => 8, 'class_section_name' => '4-A', 'gender' => 'boy', 'fall_score' => 80, 'spring_score' => 80], + ['student_id' => 34, 'class_section_id' => 8, 'class_section_name' => '4-A', 'gender' => 'girl', 'fall_score' => 70, 'spring_score' => 70], + ]); + + $this->assertSame(3, $stats['trophyWinners']); + $this->assertSame(['Male' => 2, 'Female' => 1], $stats['trophyGender']); + $this->assertSame(['Male' => 2, 'Female' => 1], $stats['genderByStat']['Trophy winners']); + } +} diff --git a/tests/app/Services/FinancialCategorySummaryServiceTest.php b/tests/app/Services/FinancialCategorySummaryServiceTest.php new file mode 100644 index 0000000..b74f38b --- /dev/null +++ b/tests/app/Services/FinancialCategorySummaryServiceTest.php @@ -0,0 +1,24 @@ + 'Books', 'amount' => 125.50], + ['category' => 'Donation', 'amount' => 500], + ['category' => 'Office Supplies', 'amount' => 75], + ['category' => 'donation', 'amount' => 25], + ]); + + $this->assertSame(200.50, $summary['totalExpenses']); + $this->assertSame(525.0, $summary['donationIncome']); + $this->assertSame(['Books' => 125.50, 'Office Supplies' => 75.0], $summary['expenseCategories']); + $this->assertArrayNotHasKey('Donation', $summary['expenseCategories']); + } +}
Total Refunds$0.00
Total Expenses$0.00
Total Reimbursements$0.00
Donation to School (included in expenses)$0.00
Donation Income to School$0.00
Net Charges After Discounts/Refunds$0.00
Amount Collected (Paid)$0.00
Overpayment Credits$0.00