Student enrollment
Gender distribution of enrolled students
+ +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 @@
+= $this->extend('layout/management_layout') ?>
+
+= $this->section('styles') ?>
+
+= $this->endSection() ?>
+
+= $this->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);
+?>
+ Enrollment, results, high achievement, and class distribution in one view. Gender distribution of enrolled students Pass, fail, and results not yet available Students averaging 90%+, measured against total enrollment Number of male and female students in each class Score bands and awaiting results against total enrollment Counts by gender; percentages use total enrolled students Collections, outstanding balances, spending, and donations. Collected, outstanding, discounts, and refunds; denominator is gross charges Payments and donation income as a percentage of gross charges Expense categories only; every percentage uses gross charges as its denominatorAl Rahma = esc($schoolYear !== '' ? $schoolYear : 'School') ?> Statistics
+ Student enrollment
Academic results
Top performers
Enrollment by class
Score distribution
Gender distribution by academic status
+ = esc($status) ?>
+
+ Al Rahma = esc($schoolYear !== '' ? $schoolYear : 'School') ?> Financials
+ Gross charge allocation
Income distribution
Expense details
| 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 |