Files
alrahma_sunday_school/app/Views/administrator/stats.php
T
root c3a30989b2
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 51s
Tests / PHPUnit (push) Successful in 1m25s
add new ststs feature
2026-09-13 02:20:14 -04:00

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, '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() ?>