add new ststs feature
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 51s
Tests / PHPUnit (push) Successful in 1m25s

This commit is contained in:
root
2026-09-13 02:20:14 -04:00
parent 1787144f27
commit c3a30989b2
16 changed files with 1395 additions and 84 deletions
+2 -1
View File
@@ -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');
+2 -2
View File
@@ -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') {
+45 -69
View File
@@ -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,
+31 -4
View File
@@ -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()
@@ -0,0 +1,92 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddAcademicStatisticsNavItem extends Migration
{
private string $url = 'administrator/stats';
public function up(): void
{
if (! $this->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;
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class RenameAndGrantSchoolStatisticsNavItem extends Migration
{
private string $url = 'administrator/stats';
public function up(): void
{
$navItemId = $this->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;
}
}
+315
View File
@@ -0,0 +1,315 @@
<?php
namespace App\Services;
use CodeIgniter\Database\BaseConnection;
class AcademicStatisticsService
{
public const PASS_SCORE = 60.0;
public const TOP_PERFORMER_SCORE = 90.0;
public const TROPHY_PERCENTILE = 75.0;
private BaseConnection $db;
public function __construct(?BaseConnection $db = null)
{
$this->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, '6069' => 0, '7079' => 0, '8089' => 0, '90100' => 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['90100']++;
} elseif ($score >= 80) {
$scoreBands['8089']++;
} elseif ($score >= 70) {
$scoreBands['7079']++;
} elseif ($score >= 60) {
$scoreBands['6069']++;
} 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));
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Services;
final class FinancialCategorySummaryService
{
/**
* Classify grouped rows from the legacy expenses table by accounting meaning.
* Donation rows are incoming funds, not school expenses.
*/
public static function summarize(array $rows): array
{
$expenseCategories = [];
$totalExpenses = 0.0;
$donationIncome = 0.0;
foreach ($rows as $row) {
$category = trim((string) ($row['category'] ?? '')) ?: 'Uncategorized';
$amount = round((float) ($row['amount'] ?? $row['total_amount'] ?? 0), 2);
if ($amount <= 0) {
continue;
}
if (strcasecmp($category, 'Donation') === 0) {
$donationIncome += $amount;
continue;
}
$expenseCategories[$category] = round(($expenseCategories[$category] ?? 0) + $amount, 2);
$totalExpenses += $amount;
}
arsort($expenseCategories, SORT_NUMERIC);
return [
'totalExpenses' => round($totalExpenses, 2),
'donationIncome' => round($donationIncome, 2),
'expenseCategories' => $expenseCategories,
];
}
}
+678
View File
@@ -0,0 +1,678 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('styles') ?>
<style>
.academic-stats { --stats-blue:#2563eb; --stats-orange:#f97316; --stats-green:#16a34a; --stats-red:#dc2626; }
.academic-stats .hero { background:linear-gradient(125deg,#075985,#0ea5e9); border-radius:1.25rem; color:#fff; overflow:hidden; position:relative; }
.academic-stats .hero::after { content:""; position:absolute; width:260px; height:260px; right:-70px; top:-120px; border:45px solid rgba(255,255,255,.12); border-radius:50%; }
.academic-stats .eyebrow { font-size:.76rem; font-weight:700; letter-spacing:.13em; text-transform:uppercase; opacity:.82; }
.academic-stats .metric-card,.academic-stats .chart-card { background:#fff; border:1px solid #e2e8f0; border-radius:1rem; box-shadow:0 10px 28px rgba(15,23,42,.06); }
.academic-stats .metric-card { height:100%; padding:1.15rem; }
.academic-stats .metric-icon { width:42px; height:42px; display:grid; place-items:center; color:#0369a1; background:#e0f2fe; border-radius:.8rem; }
.academic-stats .metric-value { color:#0f172a; font-size:1.7rem; font-weight:750; line-height:1.1; }
.academic-stats .metric-label,.academic-stats .chart-note { color:#64748b; font-size:.86rem; }
.academic-stats .academic-metric { border-top:4px solid var(--metric-accent); background:linear-gradient(145deg,var(--metric-soft),#fff 58%); }
.academic-stats .academic-metric .metric-icon { background:var(--metric-icon-bg); color:var(--metric-accent); }
.academic-stats .academic-metric .metric-value { color:var(--metric-value); }
.academic-stats .metric-sky { --metric-accent:#0284c7; --metric-icon-bg:#e0f2fe; --metric-soft:#f0f9ff; --metric-value:#075985; }
.academic-stats .metric-violet { --metric-accent:#7c3aed; --metric-icon-bg:#ede9fe; --metric-soft:#f5f3ff; --metric-value:#5b21b6; }
.academic-stats .metric-cyan { --metric-accent:#0891b2; --metric-icon-bg:#cffafe; --metric-soft:#ecfeff; --metric-value:#155e75; }
.academic-stats .metric-green { --metric-accent:#16a34a; --metric-icon-bg:#dcfce7; --metric-soft:#f0fdf4; --metric-value:#166534; }
.academic-stats .metric-amber { --metric-accent:#d97706; --metric-icon-bg:#fef3c7; --metric-soft:#fffbeb; --metric-value:#92400e; }
.academic-stats .metric-rose { --metric-accent:#e11d48; --metric-icon-bg:#ffe4e6; --metric-soft:#fff1f2; --metric-value:#9f1239; }
.academic-stats .chart-card { height:100%; padding:1.25rem; }
.academic-stats .chart-title { color:#0f172a; font-size:1.04rem; font-weight:700; margin:0; }
.academic-stats .chart-wrap { height:280px; margin-top:1rem; position:relative; }
.academic-stats .chart-wrap.chart-wide { height:330px; }
.academic-stats .chart-wrap canvas { cursor:zoom-in; }
.academic-stats .chart-wrap canvas:focus-visible { border-radius:.5rem; outline:3px solid #0ea5e9; outline-offset:4px; }
.academic-stats .definition { background:#f8fafc; border-left:4px solid #0ea5e9; color:#475569; }
.academic-stats .section-heading { align-items:end; display:flex; gap:1rem; justify-content:space-between; }
.academic-stats .finance-kicker { color:#047857; font-size:.76rem; font-weight:750; letter-spacing:.13em; text-transform:uppercase; }
.academic-stats .finance-card .metric-icon { background:#dcfce7; color:#047857; }
.academic-stats .finance-status { min-height:52px; }
@media (max-width:575.98px) { .academic-stats .chart-wrap,.academic-stats .chart-wrap.chart-wide { height:250px; } }
@media print { .academic-stats .hero { background:#fff !important; color:#0f172a !important; border:1px solid #cbd5e1; } }
</style>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?php
$statistics = $statistics ?? [];
$schoolYear = (string) ($schoolYear ?? $statistics['schoolYear'] ?? '');
$number = static fn ($value): string => $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);
?>
<main class="container-fluid px-3 px-lg-4 py-4 academic-stats">
<section class="hero p-4 p-lg-5 mb-4">
<div class="position-relative" style="z-index:1">
<div class="eyebrow mb-2">Academic overview</div>
<h1 class="display-6 fw-bold mb-2">Al Rahma <?= esc($schoolYear !== '' ? $schoolYear : 'School') ?> Statistics</h1>
<p class="mb-0 opacity-75">Enrollment, results, high achievement, and class distribution in one view.</p>
</div>
</section>
<section class="row g-3 mb-4" aria-label="Key statistics">
<?php foreach ([
['people-fill','totalStudents','Students','Enrolled in academic classes','sky'],
['grid-3x3-gap-fill','totalClasses','Classes','Sections with students','violet'],
['graph-up-arrow','averageScore','Average score','Among students with results','cyan'],
['check-circle-fill','passRate','Pass rate','Pass mark: 60%; KG automatically passes','green'],
['trophy-fill','topPerformers','Top performers','Year average of 90% or higher','amber'],
['award-fill','trophyWinners','Trophy winners','Final winners under the class trophy rule','rose'],
] as [$icon,$key,$label,$hint,$tone]): ?>
<div class="col-6 col-lg">
<div class="metric-card academic-metric metric-<?= esc($tone) ?>">
<div class="metric-icon mb-3"><i class="bi bi-<?= esc($icon) ?>"></i></div>
<div class="metric-value"><?= esc($number($statistics[$key] ?? null)) ?><?= $key === 'passRate' && ($statistics[$key] ?? null) !== null ? '%' : '' ?></div>
<div class="fw-semibold mt-1"><?= esc($label) ?></div>
<div class="metric-label mt-1"><?= esc($hint) ?></div>
</div>
</div>
<?php endforeach; ?>
</section>
<?php if (($statistics['totalStudents'] ?? 0) === 0): ?>
<div class="alert alert-info border-0 shadow-sm mb-4"><i class="bi bi-info-circle me-2"></i>No academic class assignments were found for this school year.</div>
<?php endif; ?>
<section class="row g-4 mb-4">
<div class="col-12 col-lg-4"><article class="chart-card">
<h2 class="chart-title">Student enrollment</h2><p class="chart-note mb-0">Gender distribution of enrolled students</p>
<div class="chart-wrap"><canvas id="studentGenderChart" role="img" aria-label="Student enrollment by gender"></canvas></div>
</article></div>
<div class="col-12 col-lg-4"><article class="chart-card">
<h2 class="chart-title">Academic results</h2><p class="chart-note mb-0">Pass, fail, and results not yet available</p>
<div class="chart-wrap"><canvas id="academicResultsChart" role="img" aria-label="Academic result distribution"></canvas></div>
</article></div>
<div class="col-12 col-lg-4"><article class="chart-card">
<h2 class="chart-title">Top performers</h2><p class="chart-note mb-0">Students averaging 90%+, measured against total enrollment</p>
<div class="chart-wrap"><canvas id="topPerformersChart" role="img" aria-label="Top performers by gender"></canvas></div>
</article></div>
</section>
<section class="row g-4 mb-4">
<div class="col-12 col-xl-7"><article class="chart-card">
<h2 class="chart-title">Enrollment by class</h2><p class="chart-note mb-0">Number of male and female students in each class</p>
<div class="chart-wrap chart-wide"><canvas id="classSizeChart" role="img" aria-label="Male and female enrollment by class"></canvas></div>
</article></div>
<div class="col-12 col-xl-5"><article class="chart-card">
<h2 class="chart-title">Score distribution</h2><p class="chart-note mb-0">Score bands and awaiting results against total enrollment</p>
<div class="chart-wrap chart-wide"><canvas id="scoreBandsChart" role="img" aria-label="Students by score band"></canvas></div>
</article></div>
</section>
<section class="mb-4" aria-labelledby="genderByStatTitle">
<div class="mb-3">
<h2 class="h3 fw-bold mb-1" id="genderByStatTitle">Gender distribution by academic status</h2>
<p class="text-muted mb-0">Counts by gender; percentages use total enrolled students</p>
</div>
<div class="row g-4">
<?php foreach (array_keys($statistics['genderByStat'] ?? []) as $index => $status): ?>
<div class="col-12 col-md-6 col-xl-4">
<article class="chart-card">
<h3 class="chart-title text-center"><?= esc($status) ?></h3>
<div class="chart-wrap"><canvas id="genderStatusPie<?= (int) $index ?>" role="img" aria-label="<?= esc($status) ?> students by gender"></canvas></div>
</article>
</div>
<?php endforeach; ?>
</div>
</section>
<aside class="definition rounded-3 p-3 small">
<strong>How results are calculated:</strong> every academic percentage uses total enrolled students as its denominator. KG students are counted as passed regardless of whether a semester score has been entered. For other classes, the year average uses both Fall and Spring semester scores when available, or the available semester when only one has been recorded. A score of 60% passes; 90% or higher is counted as a top performer. Trophy winners use the same per-class 75th-percentile rule as the final trophy report, with at least three scored winners per class when available and ties included.
</aside>
<?php if ($canViewFinancialStats): ?>
<section class="pt-5" id="financialStatistics" aria-labelledby="financialStatisticsTitle">
<div class="section-heading mb-4">
<div>
<div class="finance-kicker mb-2">Financial overview</div>
<h2 class="h2 fw-bold mb-1" id="financialStatisticsTitle">Al Rahma <?= esc($schoolYear !== '' ? $schoolYear : 'School') ?> Financials</h2>
<p class="text-muted mb-0">Collections, outstanding balances, spending, and donations.</p>
</div>
<a class="btn btn-outline-success d-none d-md-inline-flex align-items-center gap-2" href="<?= site_url('financial-report/financialReportSummary') ?>">
<i class="bi bi-box-arrow-up-right"></i> Detailed report
</a>
</div>
<div id="financialStatsStatus" class="finance-status alert alert-light border shadow-sm mb-3" role="status">
<span class="spinner-border spinner-border-sm text-success me-2" aria-hidden="true"></span>Loading financial statistics…
</div>
<div class="row g-3 mb-4">
<?php foreach ([
['receipt','financeGross','Gross charges'],
['cash-coin','financeCollected','Collected'],
['hourglass-split','financeOutstanding','Net outstanding'],
['cart-check','financeExpenses','Expenses'],
['heart-fill','financeDonations','Donations'],
] as [$icon,$id,$label]): ?>
<div class="col-6 col-lg"><div class="metric-card finance-card">
<div class="metric-icon mb-3"><i class="bi bi-<?= esc($icon) ?>"></i></div>
<div class="metric-value" id="<?= esc($id) ?>">—</div>
<div class="fw-semibold mt-1"><?= esc($label) ?></div>
</div></div>
<?php endforeach; ?>
</div>
<div class="row g-4 mb-4">
<div class="col-12 col-lg-6"><article class="chart-card">
<h3 class="chart-title">Gross charge allocation</h3><p class="chart-note mb-0">Collected, outstanding, discounts, and refunds; denominator is gross charges</p>
<div class="chart-wrap"><canvas id="tuitionCoverageChart" role="img" aria-label="Allocation of gross charges"></canvas></div>
</article></div>
<div class="col-12 col-lg-6"><article class="chart-card">
<h3 class="chart-title">Income distribution</h3><p class="chart-note mb-0">Payments and donation income as a percentage of gross charges</p>
<div class="chart-wrap"><canvas id="financialDistributionChart" role="img" aria-label="Tuition and fee payments plus donation income"></canvas></div>
</article></div>
<div class="col-12"><article class="chart-card">
<h3 class="chart-title">Expense details</h3><p class="chart-note mb-0">Expense categories only; every percentage uses gross charges as its denominator</p>
<div class="chart-wrap chart-wide"><canvas id="expenseDetailsChart" role="img" aria-label="Expense categories as percentages of gross charges"></canvas></div>
</article></div>
</div>
<aside class="definition rounded-3 p-3 small mb-2">
<strong>Financial reconciliation:</strong> figures use the same invoice-ledger rules as the detailed financial report. Donations are incoming school funds and are excluded from expenses and reimbursements.
</aside>
</section>
<?php endif; ?>
</main>
<div class="modal fade" id="chartZoomModal" tabindex="-1" aria-labelledby="chartZoomTitle" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header">
<div>
<div class="small text-muted">Expanded chart</div>
<h2 class="modal-title h4 mb-0" id="chartZoomTitle">Statistics</h2>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close expanded chart"></button>
</div>
<div class="modal-body p-3 p-md-4">
<div style="height:min(72vh,760px); min-height:420px; position:relative">
<canvas id="expandedStatsChart" role="img" aria-label="Expanded statistics chart"></canvas>
</div>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0"></script>
<script>
(() => {
const dominantCenterLabel = {
id:'dominantCenterLabel',
afterDatasetsDraw(chart, args, options) {
if (!['doughnut','pie'].includes(chart.config.type)) return;
const dataset = chart.data.datasets[0];
if (!dataset || !dataset.data?.length) return;
const values = dataset.data.map(value => Number(value || 0));
const base = Number(dataset.percentageBase || 0) || values.reduce((sum, value) => sum + value, 0);
if (base <= 0) return;
const largest = Math.max(...values);
const percent = largest / base * 100;
if (percent <= 85) return;
const { ctx, chartArea } = chart;
ctx.save();
ctx.fillStyle = options.color || '#0f172a';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const centerX = (chartArea.left + chartArea.right) / 2;
const centerY = (chartArea.top + chartArea.bottom) / 2;
if (dataset.showMoneyLabels) {
ctx.font = `800 ${Number(options.fontSize || 22)}px system-ui, sans-serif`;
ctx.fillText(new Intl.NumberFormat('en-US', { style:'currency', currency:'USD', maximumFractionDigits:0 }).format(largest), centerX, centerY - 10);
ctx.font = `700 ${Math.max(13, Number(options.fontSize || 22) - 7)}px system-ui, sans-serif`;
ctx.fillText(`${percent.toFixed(1)}%`, centerX, centerY + 17);
} else {
ctx.font = `800 ${Number(options.fontSize || 22)}px system-ui, sans-serif`;
ctx.fillText(`${percent.toFixed(1)}%`, centerX, centerY);
}
ctx.restore();
}
};
const externalSliceLabels = {
id:'externalSliceLabels',
afterDatasetsDraw(chart, args, options) {
if (!['doughnut','pie'].includes(chart.config.type)) return;
const dataset = chart.data.datasets[0];
const meta = chart.getDatasetMeta(0);
if (!dataset || !meta?.data?.length) return;
const values = dataset.data.map(value => Number(value || 0));
const base = Number(dataset.percentageBase || 0) || values.reduce((sum, value) => sum + value, 0);
if (base <= 0) return;
const threshold = Number(options.maximumPercent || 6);
const items = [];
meta.data.forEach((arc, index) => {
const value = values[index] || 0;
const percent = value / base * 100;
if (value <= 0 || percent >= threshold) return;
const props = arc.getProps(['x','y','startAngle','endAngle','outerRadius'], true);
const angle = (props.startAngle + props.endAngle) / 2;
const cosine = Math.cos(angle);
const sine = Math.sin(angle);
items.push({
angle,
anchorX:props.x + cosine * (props.outerRadius + 2),
anchorY:props.y + sine * (props.outerRadius + 2),
centerX:props.x,
centerY:props.y,
radius:props.outerRadius,
side:cosine >= 0 ? 1 : -1,
targetY:props.y + sine * (props.outerRadius + 24),
text:dataset.showMoneyLabels
? `${new Intl.NumberFormat('en-US', { style:'currency', currency:'USD', maximumFractionDigits:0 }).format(value)} · ${percent.toFixed(1)}%`
: `${percent.toFixed(1)}%`,
color:Array.isArray(dataset.backgroundColor) ? dataset.backgroundColor[index] : dataset.backgroundColor,
});
});
if (!items.length) return;
const gap = Number(options.gap || 21);
const minY = chart.chartArea.top + 8;
const maxY = chart.chartArea.bottom - 8;
[-1, 1].forEach(side => {
const sideItems = items.filter(item => item.side === side).sort((a, b) => a.targetY - b.targetY);
const itemGap = sideItems.length > 1
? Math.min(gap, (maxY - minY) / (sideItems.length - 1))
: gap;
sideItems.forEach((item, index) => {
item.labelY = Math.max(item.targetY, minY + index * itemGap);
});
if (sideItems.length && sideItems[sideItems.length - 1].labelY > maxY) {
sideItems[sideItems.length - 1].labelY = maxY;
for (let index = sideItems.length - 2; index >= 0; index--) {
sideItems[index].labelY = Math.min(sideItems[index].labelY, sideItems[index + 1].labelY - itemGap);
}
}
});
const { ctx } = chart;
ctx.save();
ctx.font = `800 ${Number(options.fontSize || 12)}px system-ui, sans-serif`;
ctx.textBaseline = 'middle';
ctx.lineWidth = 1.5;
items.forEach(item => {
const radialX = item.centerX + Math.cos(item.angle) * (item.radius + 14);
const endX = item.centerX + item.side * (item.radius + Number(options.lineLength || 43));
ctx.strokeStyle = item.color || '#64748b';
ctx.fillStyle = item.color || '#64748b';
ctx.beginPath();
ctx.moveTo(endX, item.labelY);
ctx.lineTo(radialX, item.labelY);
ctx.lineTo(item.anchorX, item.anchorY);
ctx.stroke();
const direction = Math.atan2(item.anchorY - item.labelY, item.anchorX - radialX);
const arrowSize = 5;
ctx.beginPath();
ctx.moveTo(item.anchorX, item.anchorY);
ctx.lineTo(item.anchorX - Math.cos(direction - .55) * arrowSize, item.anchorY - Math.sin(direction - .55) * arrowSize);
ctx.lineTo(item.anchorX - Math.cos(direction + .55) * arrowSize, item.anchorY - Math.sin(direction + .55) * arrowSize);
ctx.closePath();
ctx.fill();
ctx.fillStyle = options.color || '#334155';
ctx.textAlign = item.side > 0 ? 'left' : 'right';
ctx.fillText(item.text, endX + item.side * 5, item.labelY);
});
ctx.restore();
}
};
Chart.register(ChartDataLabels, dominantCenterLabel, externalSliceLabels);
const data = <?= json_encode($chartData, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
const palette = { blue:'#2563eb', orange:'#f97316', green:'#16a34a', red:'#dc2626', amber:'#eab308', slate:'#94a3b8', cyan:'#06b6d4' };
const percentage = (value, values) => {
const total = values.reduce((sum, item) => sum + Number(item || 0), 0);
return total > 0 ? `${(Number(value || 0) / total * 100).toFixed(1)}%` : '0.0%';
};
const percentageFromBase = (value, base) => Number(base) > 0
? `${(Number(value || 0) / Number(base) * 100).toFixed(1)}%`
: '0.0%';
const chartPercentageBase = context => Number(context.dataset.percentageBase || 0)
|| (context.dataset.data || []).reduce((sum, item) => sum + Number(item || 0), 0);
const dataLabel = (color = '#fff') => ({
color,
display(context) { return Number(context.dataset.data[context.dataIndex] || 0) > 0; },
font:{ weight:'700', size:12 },
formatter(value, context) { return percentageFromBase(value, chartPercentageBase(context)); },
textStrokeColor:'rgba(15,23,42,.45)',
textStrokeWidth:color === '#fff' ? 2 : 0,
});
const pieDataLabel = (minimumPercent = 6) => ({
...dataLabel(),
display(context) {
const value = Number(context.dataset.data[context.dataIndex] || 0);
const base = chartPercentageBase(context);
const percent = base > 0 ? value / base * 100 : 0;
return value > 0 && percent >= minimumPercent && percent <= 85;
},
});
const percentageLegend = {
position:'bottom',
labels:{
usePointStyle:true,
padding:18,
generateLabels(chart) {
const dataset = chart.data.datasets[0] || { data:[] };
const base = Number(dataset.percentageBase || 0)
|| (dataset.data || []).reduce((sum, item) => sum + Number(item || 0), 0);
const labels = chart.data.labels || [];
const backgroundColors = Array.isArray(dataset.backgroundColor) ? dataset.backgroundColor : [];
const borderColors = Array.isArray(dataset.borderColor) ? dataset.borderColor : [];
return labels.map((label, index) => ({
text:`${String(label || `Slice ${index + 1}`)}${dataset.showCountsInLegend ? `: ${Number(dataset.data[index] || 0)}` : ''} — ${percentageFromBase(dataset.data[index], base)}`,
fillStyle:backgroundColors[index] || dataset.backgroundColor || '#94a3b8',
strokeStyle:borderColors[index] || dataset.borderColor || '#fff',
lineWidth:Number(dataset.borderWidth || 0),
pointStyle:'circle',
hidden:!chart.getDataVisibility(index),
index,
}));
}
}
};
const common = { responsive:true, maintainAspectRatio:false, plugins:{ legend:{ position:'bottom', labels:{ usePointStyle:true, padding:18 } }, dominantCenterLabel:{ fontSize:22 }, externalSliceLabels:{ fontSize:12, maximumPercent:6 }, datalabels:dataLabel(), tooltip:{ callbacks:{ label(ctx){ const values = ctx.dataset.data || []; const value = Number(ctx.raw || 0); const base = Number(ctx.dataset.percentageBase || 0) || values.reduce((sum, item) => sum + Number(item || 0), 0); return `${ctx.label}: ${value} (${percentageFromBase(value, base)})`; } } } } };
const pie = (id, values, colors, percentageBase, remainderLabel = '', showCountsInLegend = false) => {
const labels = Object.keys(values);
const chartValues = Object.values(values).map(value => Number(value || 0));
const chartColors = [...colors];
const remainder = Math.max(0, Number(percentageBase || 0) - chartValues.reduce((sum, value) => sum + value, 0));
if (remainderLabel && remainder > 0) {
labels.push(remainderLabel);
chartValues.push(remainder);
chartColors.push('#cbd5e1');
}
return new Chart(document.getElementById(id), { type:'doughnut', data:{ labels, datasets:[{ data:chartValues, percentageBase, showCountsInLegend, backgroundColor:chartColors, borderColor:'#fff', borderWidth:3, hoverOffset:6 }] }, options:{ ...common, cutout:'54%', layout:{ padding:{ left:64, right:64, top:10, bottom:10 } }, plugins:{ ...common.plugins, legend:percentageLegend, datalabels:pieDataLabel() } } });
};
pie('studentGenderChart', data.gender, [palette.blue,palette.orange], data.totalStudents);
pie('academicResultsChart', data.results, [palette.green,palette.red,palette.slate], data.totalStudents);
pie('topPerformersChart', data.topGender, [palette.blue,palette.orange], data.totalStudents, 'Other students');
new Chart(document.getElementById('classSizeChart'), {
type:'bar',
data:{
labels:data.classSizes.labels,
datasets:[
{ label:'Male', data:data.classSizes.male || [], percentageBase:data.totalStudents, backgroundColor:palette.blue, borderRadius:5, maxBarThickness:52 },
{ label:'Female', data:data.classSizes.female || [], percentageBase:data.totalStudents, backgroundColor:palette.orange, borderRadius:5, maxBarThickness:52 },
]
},
options:{
...common,
scales:{
y:{ beginAtZero:true, stacked:true, ticks:{ precision:0 }, grid:{ color:'#e2e8f0' } },
x:{ stacked:true, grid:{ display:false } }
},
plugins:{
...common.plugins,
legend:{ position:'bottom', labels:{ usePointStyle:true, padding:18 } },
tooltip:{
callbacks:{
label(context) {
const count = Number(context.raw || 0);
return `${context.dataset.label}: ${count} ${count === 1 ? 'student' : 'students'}`;
}
}
},
datalabels:{
...dataLabel(),
formatter(value, context) {
return Number(value || 0) > 0 ? String(Number(value)) : '';
}
}
}
}
});
const scoreBandLabels = [...Object.keys(data.scoreBands), 'Awaiting results'];
const scoreBandValues = [...Object.values(data.scoreBands), Number(data.results['Awaiting results'] || 0)];
new Chart(document.getElementById('scoreBandsChart'), { type:'bar', data:{ labels:scoreBandLabels, datasets:[{ label:'Students', data:scoreBandValues, percentageBase:data.totalStudents, backgroundColor:[palette.red,palette.amber,palette.cyan,palette.blue,palette.green,'#8b5cf6',palette.slate], borderRadius:7 }] }, options:{ ...common, indexAxis:'y', layout:{ padding:{ right:48 } }, scales:{ x:{ beginAtZero:true, ticks:{ precision:0 }, grid:{ color:'#e2e8f0' } }, y:{ grid:{ display:false } } }, plugins:{ ...common.plugins, legend:{ display:false }, datalabels:{ ...dataLabel('#334155'), anchor:'end', align:'right', clamp:true } } } });
Object.entries(data.genderByStat).forEach(([status, counts], index) => {
pie(
`genderStatusPie${index}`,
{ Male:Number(counts.Male || 0), Female:Number(counts.Female || 0) },
[palette.blue,palette.orange],
data.totalStudents,
'Not in this status',
true
);
});
<?php if ($canViewFinancialStats): ?>
const financialUrl = <?= json_encode(site_url('api/financial/summary') . '?school_year=' . rawurlencode($schoolYear), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
const money = value => new Intl.NumberFormat('en-US', { style:'currency', currency:'USD', maximumFractionDigits:0 }).format(Number(value || 0));
const financialCharts = [];
const moneyPercentageLegend = {
...percentageLegend,
labels:{
...percentageLegend.labels,
generateLabels(chart) {
const dataset = chart.data.datasets[0] || { data:[] };
const base = Number(dataset.percentageBase || 0);
const colors = Array.isArray(dataset.backgroundColor) ? dataset.backgroundColor : [];
return (chart.data.labels || []).map((label, index) => ({
text:`${String(label)}: ${money(dataset.data[index])} — ${percentageFromBase(dataset.data[index], base)}`,
fillStyle:colors[index] || '#94a3b8',
strokeStyle:'#fff',
lineWidth:2,
pointStyle:'circle',
hidden:!chart.getDataVisibility(index),
index,
}));
}
}
};
const moneyPie = (id, labels, values, colors, grossCharges, remainderLabel) => {
const chartLabels = [...labels];
const chartValues = values.map(value => Number(value || 0));
const chartColors = [...colors];
const represented = chartValues.reduce((sum, value) => sum + Math.max(0, value), 0);
const percentageBase = Number(grossCharges || 0);
const remainder = Math.max(0, percentageBase - represented);
if (remainder > 0) {
chartLabels.push(remainderLabel);
chartValues.push(remainder);
chartColors.push('#cbd5e1');
}
const chart = new Chart(document.getElementById(id), {
type:'doughnut',
data:{ labels:chartLabels, datasets:[{ data:chartValues, percentageBase, showMoneyLabels:true, backgroundColor:chartColors, borderColor:'#fff', borderWidth:3, hoverOffset:6 }] },
options:{ ...common, cutout:'54%', layout:{ padding:{ left:64, right:64, top:10, bottom:10 } }, plugins:{ ...common.plugins,
legend:moneyPercentageLegend,
datalabels:{
...pieDataLabel(),
formatter(value) { return `${money(value)}\n${percentageFromBase(value, percentageBase)}`; }
},
tooltip:{ callbacks:{ label(ctx){ const value = Number(ctx.raw || 0); return `${ctx.label}: ${money(value)} (${percentageFromBase(value, percentageBase)} of gross charges)`; } } }
} }
});
financialCharts.push(chart);
};
const setMoney = (id, value) => { const el = document.getElementById(id); if (el) el.textContent = money(value); };
fetch(financialUrl, { headers:{ Accept:'application/json' }, credentials:'same-origin' })
.then(response => { if (!response.ok) throw new Error('Financial data could not be loaded.'); return response.json(); })
.then(finance => {
if (!finance || finance.ok !== true) throw new Error('Financial data could not be loaded.');
const gross = Number(finance.grossCharges || finance.totalCharges || 0);
const collected = Number(finance.amountCollected || finance.totalPaid || 0);
const outstanding = Number(finance.netReceivable || 0);
const expenses = Number(finance.totalExpenses || 0);
const donations = Number(finance.donationToSchool || 0);
const discounts = Number(finance.totalDiscounts || 0);
const refunds = Number(finance.totalRefunds || 0);
const expenseEntries = Object.entries(finance.expenseCategories || {});
if (!expenseEntries.length && expenses > 0) expenseEntries.push(['Expenses', expenses]);
setMoney('financeGross', gross);
setMoney('financeCollected', collected);
setMoney('financeOutstanding', outstanding);
setMoney('financeExpenses', expenses);
setMoney('financeDonations', donations);
moneyPie(
'tuitionCoverageChart',
['Collected','Net outstanding','Discounts','Refunds'],
[collected,outstanding,discounts,refunds],
[palette.blue,palette.orange,palette.amber,palette.red],
gross,
'Other gross-charge adjustments'
);
moneyPie('financialDistributionChart', ['Tuition & fee payments','Donation income'], [collected,donations], [palette.blue,palette.green], gross, 'Uncollected gross charges');
moneyPie(
'expenseDetailsChart',
expenseEntries.map(([category]) => category),
expenseEntries.map(([, amount]) => Number(amount || 0)),
expenseEntries.map((entry, index) => ['#8b5cf6','#ec4899','#64748b','#0d9488','#a16207','#2563eb','#f97316'][index % 7]),
gross,
'Gross charges remaining after expenses'
);
const status = document.getElementById('financialStatsStatus');
if (status) {
const hasData = gross > 0 || collected > 0 || expenses > 0 || donations > 0 || discounts > 0 || refunds > 0;
status.className = `finance-status alert ${hasData ? 'alert-success' : 'alert-info'} border-0 shadow-sm mb-3`;
status.innerHTML = hasData
? '<i class="bi bi-check-circle me-2"></i>Financial statistics are reconciled with the detailed report.'
: '<i class="bi bi-info-circle me-2"></i>No financial activity was found for this school year.';
}
})
.catch(error => {
const status = document.getElementById('financialStatsStatus');
if (status) {
status.className = 'finance-status alert alert-danger border-0 shadow-sm mb-3';
status.innerHTML = '<i class="bi bi-exclamation-triangle me-2"></i>' + String(error.message || 'Financial statistics could not be loaded.');
}
});
<?php endif; ?>
const zoomModalElement = document.getElementById('chartZoomModal');
const zoomCanvas = document.getElementById('expandedStatsChart');
const zoomTitle = document.getElementById('chartZoomTitle');
const zoomModal = zoomModalElement ? new bootstrap.Modal(zoomModalElement) : null;
let expandedChart = null;
const cloneChartData = source => ({
labels:[...(source.data.labels || [])],
datasets:(source.data.datasets || []).map(dataset => ({
...dataset,
data:[...(dataset.data || [])],
backgroundColor:Array.isArray(dataset.backgroundColor) ? [...dataset.backgroundColor] : dataset.backgroundColor,
borderColor:Array.isArray(dataset.borderColor) ? [...dataset.borderColor] : dataset.borderColor,
})),
});
const openExpandedChart = canvas => {
const source = Chart.getChart(canvas);
if (!source || !zoomModal || !zoomCanvas) return;
if (expandedChart) expandedChart.destroy();
const title = canvas.closest('.chart-card')?.querySelector('.chart-title')?.textContent?.trim() || 'Statistics';
const isMoneyChart = ['tuitionCoverageChart','financialDistributionChart','expenseDetailsChart'].includes(canvas.id);
const isEnrollmentByClass = canvas.id === 'classSizeChart';
const isHorizontal = source.options.indexAxis === 'y';
const isCircular = source.config.type === 'doughnut' || source.config.type === 'pie';
const isStacked = source.options.scales?.x?.stacked === true || source.options.scales?.y?.stacked === true;
const labelOptions = isEnrollmentByClass
? {
...dataLabel(),
font:{ weight:'800', size:18 },
anchor:'center',
align:'center',
formatter(value) { return Number(value || 0) > 0 ? String(Number(value)) : ''; },
}
: {
...(isCircular ? pieDataLabel(5) : dataLabel('#1e293b')),
font:{ weight:'800', size:18 },
...(isCircular ? {} : { anchor:'end', align:isHorizontal ? 'right' : 'top', clamp:true }),
...(isCircular && isMoneyChart ? {
formatter(value, context) {
const base = Number(context.dataset.percentageBase || 0);
return `${money(value)}\n${percentageFromBase(value, base)}`;
}
} : {}),
};
const tooltip = {
callbacks:{
label(context) {
const values = context.dataset.data || [];
const value = Number(context.raw || 0);
if (isEnrollmentByClass) {
return `${context.dataset.label}: ${value} ${value === 1 ? 'student' : 'students'}`;
}
const raw = isMoneyChart ? money(value) : value;
const base = Number(context.dataset.percentageBase || 0)
|| values.reduce((sum, item) => sum + Number(item || 0), 0);
const percent = percentageFromBase(value, base);
return `${context.label}: ${raw} (${percent}${isMoneyChart ? ' of gross charges' : ''})`;
}
}
};
const options = {
responsive:true,
maintainAspectRatio:false,
indexAxis:isHorizontal ? 'y' : 'x',
cutout:isCircular ? '48%' : undefined,
layout:{ padding:isCircular ? { left:130, right:130, top:18, bottom:18 } : { top:32, right:isHorizontal ? 70 : 18 } },
plugins:{
legend:isCircular
? { ...(isMoneyChart ? moneyPercentageLegend : percentageLegend), labels:{ ...(isMoneyChart ? moneyPercentageLegend.labels : percentageLegend.labels), padding:24, font:{ size:16 } } }
: { display:source.options.plugins?.legend?.display !== false, position:'bottom', labels:{ usePointStyle:true, padding:24, font:{ size:16 } } },
dominantCenterLabel:{ fontSize:30 },
externalSliceLabels:{ fontSize:16, maximumPercent:6, gap:27, lineLength:66 },
datalabels:labelOptions,
tooltip,
},
};
if (!isCircular) {
options.scales = isHorizontal
? { x:{ stacked:isStacked, beginAtZero:true, ticks:{ precision:0, font:{ size:14 } } }, y:{ stacked:isStacked, grid:{ display:false }, ticks:{ font:{ size:14 } } } }
: { y:{ stacked:isStacked, beginAtZero:true, ticks:{ precision:0, font:{ size:14 } } }, x:{ stacked:isStacked, grid:{ display:false }, ticks:{ font:{ size:14 } } } };
}
if (zoomTitle) zoomTitle.textContent = title;
zoomCanvas.setAttribute('aria-label', `Expanded ${title} chart`);
expandedChart = new Chart(zoomCanvas, { type:source.config.type, data:cloneChartData(source), options });
zoomModal.show();
};
document.querySelectorAll('.academic-stats .chart-wrap canvas').forEach(canvas => {
canvas.setAttribute('tabindex', '0');
canvas.setAttribute('role', 'button');
canvas.setAttribute('title', 'Click to enlarge this chart');
canvas.setAttribute('aria-label', `${canvas.getAttribute('aria-label') || 'Statistics chart'}. Click to enlarge.`);
canvas.addEventListener('click', () => openExpandedChart(canvas));
canvas.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openExpandedChart(canvas);
}
});
});
zoomModalElement?.addEventListener('hidden.bs.modal', () => {
if (expandedChart) {
expandedChart.destroy();
expandedChart = null;
}
});
})();
</script>
<?= $this->endSection() ?>
+2 -2
View File
@@ -17,9 +17,9 @@
<option value="Expense">Expense</option>
<option value="Purchase">Purchase</option>
<option value="Reimbursement">Reimbursement</option>
<option value="Donation">Donation (non-reimbursable)</option>
<option value="Donation">Donation income</option>
</select>
<div class="form-text text-muted">Use Donation when someone gifts items to the school so it is counted as an expense but skipped from reimbursement queues.</div>
<div class="form-text text-muted">Use Donation for money donated to the school. It is recorded as income and excluded from expenses and reimbursement queues.</div>
</div>
<div class="mb-3">
+2 -2
View File
@@ -15,11 +15,11 @@
<select name="category" class="form-control" required>
<?php foreach (['Expense', 'Purchase', 'Reimbursement', 'Donation'] as $opt): ?>
<option value="<?= $opt ?>" <?= $expense['category'] === $opt ? 'selected' : '' ?>>
<?= $opt === 'Donation' ? 'Donation (non-reimbursable)' : $opt ?>
<?= $opt === 'Donation' ? 'Donation income' : $opt ?>
</option>
<?php endforeach; ?>
</select>
<div class="form-text text-muted">Donation entries are tracked as expenses but will not move through reimbursement batches.</div>
<div class="form-text text-muted">Donation entries represent money received by the school. They are excluded from expenses and reimbursement queues.</div>
</div>
<div class="mb-3">
+8 -1
View File
@@ -154,7 +154,12 @@
</table>
</div>
<h3 class="mt-5">Expenses Summary</h3>
<div class="alert alert-success mt-5 mb-3">
<strong>Donation Income to School:</strong>
<span id="donationIncomeTotal">$<?= number_format((float) ($donationToSchool ?? 0), 2) ?></span>
</div>
<h3>Expenses Summary</h3>
<div class="table-responsive">
<table id="expensesTable" class="table table-bordered align-middle w-100">
<thead>
@@ -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 => {
@@ -42,7 +42,7 @@
<tr><td>Total Refunds</td><td class="text-right" id="sumRefunds">$0.00</td></tr>
<tr><td>Total Expenses</td><td class="text-right" id="sumExpenses">$0.00</td></tr>
<tr><td>Total Reimbursements</td><td class="text-right" id="sumReimb">$0.00</td></tr>
<tr><td>Donation to School (included in expenses)</td><td class="text-right" id="sumDonationToSchool">$0.00</td></tr>
<tr><td>Donation Income to School</td><td class="text-right" id="sumDonationToSchool">$0.00</td></tr>
<tr class="table-success"><td>Net Charges After Discounts/Refunds</td><td class="text-right font-weight-bold" id="sumNet">$0.00</td></tr>
<tr class="table-info"><td>Amount Collected (Paid)</td><td class="text-right font-weight-bold" id="sumCollected">$0.00</td></tr>
<tr><td>Overpayment Credits</td><td class="text-right" id="sumOverpaid">$0.00</td></tr>
@@ -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 }}}
});
@@ -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';
@@ -0,0 +1,81 @@
<?php
namespace Tests\App\Services;
use App\Services\AcademicStatisticsService;
use CodeIgniter\Test\CIUnitTestCase;
class AcademicStatisticsServiceTest extends CIUnitTestCase
{
public function testSummarizesUniqueStudentsScoresAndClasses(): void
{
$stats = AcademicStatisticsService::summarize([
['student_id' => 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']);
}
}
@@ -0,0 +1,24 @@
<?php
namespace Tests\App\Services;
use App\Services\FinancialCategorySummaryService;
use CodeIgniter\Test\CIUnitTestCase;
class FinancialCategorySummaryServiceTest extends CIUnitTestCase
{
public function testDonationsAreIncomeAndExcludedFromExpenses(): void
{
$summary = FinancialCategorySummaryService::summarize([
['category' => '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']);
}
}