add new ststs feature
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, '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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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() ?>
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }}}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user