679 lines
37 KiB
PHP
679 lines
37 KiB
PHP
<?= $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, 'Not classified as top performers');
|
|
|
|
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() ?>
|