Files
2026-03-24 01:02:36 -04:00

573 lines
21 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?= csrf_meta() ?>
<?php
// Exclude unwanted grade buckets globally (e.g., legacy Grade 10/11) unless the bucket is the Arabic program.
$hiddenGrades = [10, 11];
$hasArabic = static function (array $sections): bool {
foreach ($sections as $section) {
$name = strtolower((string)($section['class_section_name'] ?? ''));
$code = strtolower((string)($section['class_section_id'] ?? ''));
if (strpos($name, 'arabic') !== false || strpos($code, 'arabic') !== false) {
return true;
}
}
return false;
};
foreach ($hiddenGrades as $hid) {
if (isset($grades[$hid]) && $hasArabic($grades[$hid])) {
continue; // keep Arabic sections even if the class_id matches a hidden grade bucket
}
unset($grades[$hid]);
}
// Prune out sections with no students (extra safety so empty Arabic-2/3 never render)
$filteredGrades = [];
foreach ($grades as $cid => $sections) {
$kept = [];
foreach ($sections as $section) {
$secKey = (string)($section['class_section_id'] ?? ($section['id'] ?? ''));
if ($secKey !== '' && !empty($studentsBySection[$secKey])) {
$kept[] = $section;
}
}
if (!empty($kept)) {
$filteredGrades[$cid] = $kept;
}
}
$grades = $filteredGrades;
?>
<div class="container-fluid">
<div class="wrapper">
<div class="content mb-3"></div>
<h2 class="text-center mt-4 mb-3">Attendance Analysis</h2>
<?= $this->include('partials/academic_filter') ?>
<?= $this->section('styles') ?>
<style>
.attn-analysis-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 1rem;
}
.attn-analysis-card {
background: #fff;
border: 1px solid rgba(0,0,0,0.08);
border-radius: .5rem;
padding: 1rem;
}
.attn-analysis-card h6 { margin-bottom: .75rem; }
.attn-analysis-wide { grid-column: span 12; }
.attn-analysis-half { grid-column: span 6; }
.attn-analysis-table {
width: 100%;
font-size: .9rem;
}
.attn-analysis-table th,
.attn-analysis-table td { padding: .35rem .5rem; }
.attn-analysis-scroll {
max-height: none;
overflow: visible;
}
.attn-pie-wrap {
width: 320px;
height: 320px;
margin: 0 auto;
}
@media (max-width: 1200px) {
.attn-analysis-half { grid-column: span 12; }
}
@media (max-width: 576px) {
.attn-pie-wrap {
width: 180px;
height: 180px;
}
}
</style>
<?= $this->endSection() ?>
<?php
$req = request();
$startDateRaw = trim((string)($req->getGet('start_date') ?? ''));
$endDateRaw = trim((string)($req->getGet('end_date') ?? ''));
$isValidDate = static function (string $d): bool {
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) return false;
[$y, $m, $day] = array_map('intval', explode('-', $d));
return checkdate($m, $day, $y);
};
$filterStart = ($startDateRaw !== '' && $isValidDate($startDateRaw)) ? $startDateRaw : '';
$filterEnd = ($endDateRaw !== '' && $isValidDate($endDateRaw)) ? $endDateRaw : '';
// ---- Build labels and a stable order: KG -> Grade 1..12 -> Youth ----
$labelsByClassId = [];
$gradeNumberById = [];
$kgIds = [];
$arabicIds = [];
$youthIds = [];
foreach ($grades as $classId => $sections) {
$cid = (int)$classId;
$sampleName = '';
$isArabicProgram = false;
foreach ($sections as $s) {
$sampleName = (string)($s['class_section_name'] ?? '');
$lowName = strtolower($sampleName);
$lowCode = strtolower((string)($s['class_section_id'] ?? ''));
if (strpos($lowName, 'arabic') !== false || strpos($lowCode, 'arabic') !== false) {
$isArabicProgram = true;
}
if ($sampleName !== '') break;
}
if ($isArabicProgram || $cid === 14) {
$labelsByClassId[$cid] = 'Arabic';
$arabicIds[] = $cid;
} elseif (preg_match('/\bKG\b/i', $sampleName)) {
$labelsByClassId[$cid] = 'KG';
$kgIds[] = $cid;
} elseif (preg_match('/\bYouth\b/i', $sampleName)) {
$labelsByClassId[$cid] = 'Youth';
$youthIds[] = $cid;
} elseif (preg_match('/Grade\s*(\d+)/i', $sampleName, $m)) {
$labelsByClassId[$cid] = 'Grade ' . (int)$m[1];
$gradeNumberById[$cid] = (int)$m[1];
} else {
if ($cid === 0) {
$labelsByClassId[$cid] = 'KG';
$kgIds[] = $cid;
} elseif ($cid === 13) {
$labelsByClassId[$cid] = 'Youth';
$youthIds[] = $cid;
} else {
$labelsByClassId[$cid] = 'Grade ' . $cid;
$gradeNumberById[$cid] = $cid;
}
}
}
$sortedClassIds = [];
if ($kgIds) {
sort($kgIds, SORT_NUMERIC);
$sortedClassIds = array_merge($sortedClassIds, $kgIds);
}
if ($gradeNumberById) {
asort($gradeNumberById, SORT_NUMERIC);
$sortedClassIds = array_merge($sortedClassIds, array_keys($gradeNumberById));
}
if ($youthIds) {
sort($youthIds, SORT_NUMERIC);
$sortedClassIds = array_merge($sortedClassIds, $youthIds);
}
if ($arabicIds) {
sort($arabicIds, SORT_NUMERIC);
$sortedClassIds = array_merge($sortedClassIds, $arabicIds);
}
$presentIds = array_map('intval', array_keys($grades));
$missingIds = array_values(array_diff($presentIds, $sortedClassIds));
if (!empty($missingIds)) {
$sortedClassIds = array_merge($sortedClassIds, $missingIds);
}
$labelFor = function (int $id) use ($labelsByClassId): string {
return $labelsByClassId[$id] ?? ('Class ' . $id);
};
// ---- Attendance analysis ----
$analysisOverall = ['present' => 0, 'late' => 0, 'absent' => 0];
$analysisByGrade = [];
$analysisBySection = [];
$analysisTotal = 0;
foreach ($grades as $classId => $sections) {
$gradeLabel = $labelFor((int)$classId);
if (!isset($analysisByGrade[$gradeLabel])) {
$analysisByGrade[$gradeLabel] = ['present' => 0, 'late' => 0, 'absent' => 0];
}
foreach ($sections as $section) {
$sectionKey = (string)($section['class_section_id'] ?? ($section['id'] ?? ''));
if ($sectionKey === '' || empty($studentsBySection[$sectionKey])) continue;
$secNameRaw = trim((string)($section['class_section_name'] ?? ''));
$secLabel = $secNameRaw !== '' ? $secNameRaw : ('Section ' . $sectionKey);
if (!isset($analysisBySection[$secLabel])) {
$analysisBySection[$secLabel] = ['present' => 0, 'late' => 0, 'absent' => 0, 'total_students' => 0];
}
$sectionTotalStudents = count($studentsBySection[$sectionKey]);
if ($sectionTotalStudents > ($analysisBySection[$secLabel]['total_students'] ?? 0)) {
$analysisBySection[$secLabel]['total_students'] = $sectionTotalStudents;
}
foreach ($studentsBySection[$sectionKey] as $stu) {
$sid = (int)($stu['id'] ?? 0);
if ($sid <= 0) continue;
$entries = $attendanceData[$sectionKey][$sid] ?? [];
if (!is_array($entries)) continue;
foreach ($entries as $e) {
$d = substr((string)($e['date'] ?? ''), 0, 10);
if ($d === '') continue;
if ($filterStart !== '' && $d < $filterStart) continue;
if ($filterEnd !== '' && $d > $filterEnd) continue;
$st = strtolower(trim((string)($e['status'] ?? '')));
if (!in_array($st, ['present', 'late', 'absent'], true)) continue;
$analysisOverall[$st]++;
$analysisByGrade[$gradeLabel][$st]++;
$analysisBySection[$secLabel][$st]++;
$analysisTotal++;
}
}
}
}
$overallPercent = [];
foreach (['present', 'late', 'absent'] as $st) {
$overallPercent[$st] = $analysisTotal > 0 ? round(($analysisOverall[$st] * 100) / $analysisTotal, 1) : 0;
}
$analysisGradeLabels = [];
$analysisGradeAbsent = [];
$analysisGradeLate = [];
$analysisGradeTotals = [];
foreach ($sortedClassIds as $cid) {
$lbl = $labelFor((int)$cid);
$g = $analysisByGrade[$lbl] ?? ['present' => 0, 'late' => 0, 'absent' => 0];
$analysisGradeLabels[] = $lbl;
$analysisGradeAbsent[] = (int)($g['absent'] ?? 0);
$analysisGradeLate[] = (int)($g['late'] ?? 0);
$analysisGradeTotals[] = (int)($g['present'] ?? 0) + (int)($g['late'] ?? 0) + (int)($g['absent'] ?? 0);
}
$analysisSectionLabels = array_keys($analysisBySection);
$analysisSectionAbsent = [];
$analysisSectionLate = [];
$analysisSectionTotals = [];
foreach ($analysisSectionLabels as $lbl) {
$s = $analysisBySection[$lbl] ?? ['present' => 0, 'late' => 0, 'absent' => 0];
$analysisSectionAbsent[] = (int)($s['absent'] ?? 0);
$analysisSectionLate[] = (int)($s['late'] ?? 0);
$analysisSectionTotals[] = (int)($s['total_students'] ?? 0);
}
// ---- Student absences/late list ----
$sectionLabelByKey = [];
foreach ($grades as $classId => $sections) {
foreach ($sections as $section) {
$sectionKey = (string)($section['class_section_id'] ?? ($section['id'] ?? ''));
if ($sectionKey === '') continue;
$secNameRaw = trim((string)($section['class_section_name'] ?? ''));
$sectionLabelByKey[$sectionKey] = $secNameRaw !== '' ? $secNameRaw : ('Section ' . $sectionKey);
}
}
$studentIssueRows = [];
foreach ($studentsBySection as $sectionKey => $students) {
foreach ($students as $stu) {
$sid = (int)($stu['id'] ?? 0);
if ($sid <= 0) continue;
$entries = $attendanceData[$sectionKey][$sid] ?? [];
if (!is_array($entries)) continue;
$abs = 0;
$late = 0;
foreach ($entries as $e) {
$d = substr((string)($e['date'] ?? ''), 0, 10);
if ($d === '') continue;
if ($filterStart !== '' && $d < $filterStart) continue;
if ($filterEnd !== '' && $d > $filterEnd) continue;
$st = strtolower(trim((string)($e['status'] ?? '')));
if ($st === 'absent') {
$abs++;
} elseif ($st === 'late') {
$late++;
}
}
if (($abs + $late) <= 0) continue;
$studentIssueRows[] = [
'name' => trim((string)($stu['firstname'] ?? '') . ' ' . (string)($stu['lastname'] ?? '')),
'section' => $sectionLabelByKey[(string)$sectionKey] ?? ('Section ' . $sectionKey),
'absent' => $abs,
'late' => $late,
];
}
}
usort($studentIssueRows, static function ($a, $b) {
$sec = strcmp($a['section'], $b['section']);
if ($sec !== 0) return $sec;
return strcmp($a['name'], $b['name']);
});
$totalDaysForPercent = 0;
if ($filterStart === '' && $filterEnd === '' && !empty($totalPassedDays)) {
$totalDaysForPercent = (int)$totalPassedDays;
} elseif (!empty($dateList) && is_array($dateList)) {
foreach ($dateList as $d) {
$d = (string)$d;
if ($d === '') continue;
if ($filterStart !== '' && $d < $filterStart) continue;
if ($filterEnd !== '' && $d > $filterEnd) continue;
if (!empty($noSchoolDays[$d])) continue;
$totalDaysForPercent++;
}
}
$backUrl = site_url('administrator/daily_attendance');
$queryParts = [];
if (!empty($semester)) $queryParts[] = 'semester=' . urlencode((string)$semester);
if (!empty($schoolYear)) $queryParts[] = 'school_year=' . urlencode((string)$schoolYear);
if ($queryParts) $backUrl .= '?' . implode('&', $queryParts);
?>
<div class="row justify-content-center mb-3">
<div class="col-auto">
<a class="btn btn-outline-secondary" href="<?= esc($backUrl) ?>">
Back to Daily Attendance
</a>
</div>
</div>
<form class="row g-2 justify-content-center mb-4" method="get" action="<?= esc(current_url()) ?>">
<?php if (!empty($semester)): ?>
<input type="hidden" name="semester" value="<?= esc((string)$semester) ?>">
<?php endif; ?>
<?php if (!empty($schoolYear)): ?>
<input type="hidden" name="school_year" value="<?= esc((string)$schoolYear) ?>">
<?php endif; ?>
<div class="col-12 col-md-3">
<label class="form-label">Start date</label>
<input type="date" class="form-control" name="start_date" value="<?= esc($filterStart) ?>">
</div>
<div class="col-12 col-md-3">
<label class="form-label">End date</label>
<input type="date" class="form-control" name="end_date" value="<?= esc($filterEnd) ?>">
</div>
<div class="col-12 col-md-auto d-flex align-items-end gap-2">
<button type="submit" class="btn btn-primary">Apply</button>
<a class="btn btn-outline-secondary" href="<?= esc(current_url()) ?>">Clear</a>
</div>
</form>
<div class="attn-analysis-grid">
<div class="attn-analysis-card attn-analysis-half">
<h6>Overall Distribution</h6>
<div class="attn-pie-wrap">
<canvas id="attnPieChart" aria-label="Overall attendance pie chart"></canvas>
</div>
<table class="attn-analysis-table mt-3 no-mgmt-sticky" data-no-mgmt-sticky>
<thead>
<tr>
<th title="Attendance status for the selected date range">Status</th>
<th class="text-end" title="Number of attendance entries with this status">Count</th>
<th class="text-end" title="Percent of total attendance entries">Percent</th>
</tr>
</thead>
<tbody>
<tr>
<td>Present</td>
<td class="text-end"><?= (int)$analysisOverall['present'] ?></td>
<td class="text-end"><?= number_format($overallPercent['present'], 1) ?>%</td>
</tr>
<tr>
<td>Late</td>
<td class="text-end"><?= (int)$analysisOverall['late'] ?></td>
<td class="text-end"><?= number_format($overallPercent['late'], 1) ?>%</td>
</tr>
<tr>
<td>Absent</td>
<td class="text-end"><?= (int)$analysisOverall['absent'] ?></td>
<td class="text-end"><?= number_format($overallPercent['absent'], 1) ?>%</td>
</tr>
</tbody>
</table>
</div>
<div class="attn-analysis-card attn-analysis-half">
<h6>By Section (Absent / Late)</h6>
<canvas id="attnSectionChart" height="200" aria-label="Attendance by section bar chart"></canvas>
<div class="attn-analysis-scroll mt-3">
<table id="sectionAnalysisTable" class="attn-analysis-table no-mgmt-sticky" data-no-mgmt-sticky>
<thead>
<tr>
<th title="Class section name">Section</th>
<th class="text-end" title="Total semester days in range (excluding no-school days)">Semester Days</th>
<th class="text-end" title="Total students in the class section">Total Students</th>
<th class="text-end" title="Number of absent entries in range">Absent</th>
<th class="text-end" title="Absent entries as a percent of total possible attendance entries (total students × total days in range)">Absent%</th>
<th class="text-end" title="Number of late entries in range">Late</th>
<th class="text-end" title="Late entries as a percent of total possible attendance entries (total students × total days in range)">Late%</th>
</tr>
</thead>
<tbody>
<?php foreach ($analysisSectionLabels as $i => $lbl): ?>
<?php
$tot = (int)($analysisSectionTotals[$i] ?? 0);
$abs = (int)($analysisSectionAbsent[$i] ?? 0);
$late = (int)($analysisSectionLate[$i] ?? 0);
$denom = $totalDaysForPercent > 0 ? ($totalDaysForPercent * $tot) : 0;
$absPct = $denom > 0 ? round(($abs * 100) / $denom, 1) : 0;
$latePct = $denom > 0 ? round(($late * 100) / $denom, 1) : 0;
?>
<tr>
<td><?= esc($lbl) ?></td>
<td class="text-end"><?= (int)$totalDaysForPercent ?></td>
<td class="text-end"><?= $tot ?></td>
<td class="text-end"><?= $abs ?></td>
<td class="text-end"><?= number_format($absPct, 1) ?>%</td>
<td class="text-end"><?= $late ?></td>
<td class="text-end"><?= number_format($latePct, 1) ?>%</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<div class="attn-analysis-card attn-analysis-wide">
<h6>Students With Absences / Late</h6>
<div class="attn-analysis-scroll">
<table id="studentIssueTable" class="attn-analysis-table no-mgmt-sticky" data-no-mgmt-sticky>
<thead>
<tr>
<th>Student Name</th>
<th>Class Section</th>
<th class="text-end">Nbr of ABS</th>
<th class="text-end">Nbr of LATE</th>
</tr>
</thead>
<tbody>
<?php if (empty($studentIssueRows)): ?>
<tr>
<td colspan="4" class="text-center text-muted">No absences or late records in the selected range.</td>
</tr>
<?php else: ?>
<?php foreach ($studentIssueRows as $row): ?>
<tr>
<td><?= esc($row['name']) ?></td>
<td><?= esc($row['section']) ?></td>
<td class="text-end"><?= (int)$row['absent'] ?></td>
<td class="text-end"><?= (int)$row['late'] ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const ATTN_ANALYSIS = <?= json_encode([
'overall' => [
'labels' => ['Present', 'Late', 'Absent'],
'counts' => [
(int)($analysisOverall['present'] ?? 0),
(int)($analysisOverall['late'] ?? 0),
(int)($analysisOverall['absent'] ?? 0),
],
],
'byGrade' => [
'labels' => $analysisGradeLabels,
'absent' => $analysisGradeAbsent,
'late' => $analysisGradeLate,
],
'bySection' => [
'labels' => $analysisSectionLabels,
'absent' => $analysisSectionAbsent,
'late' => $analysisSectionLate,
],
]) ?>;
function percentOfTotal(value, total) {
if (!total) return '0%';
return (Math.round((value * 1000) / total) / 10).toFixed(1) + '%';
}
function buildAttendanceCharts() {
if (!window.Chart) return;
const pieEl = document.getElementById('attnPieChart');
const sectionEl = document.getElementById('attnSectionChart');
if (!pieEl || !sectionEl) return;
const overall = ATTN_ANALYSIS.overall || { labels: [], counts: [] };
const total = (overall.counts || []).reduce((a, b) => a + (parseInt(b, 10) || 0), 0);
new Chart(pieEl.getContext('2d'), {
type: 'pie',
data: {
labels: overall.labels,
datasets: [{
data: overall.counts,
backgroundColor: ['#198754', '#f0ad4e', '#dc3545'],
borderWidth: 1,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: function(ctx) {
const val = parseInt(ctx.parsed, 10) || 0;
return ctx.label + ': ' + val + ' (' + percentOfTotal(val, total) + ')';
}
}
},
legend: { position: 'bottom' }
}
}
});
new Chart(sectionEl.getContext('2d'), {
type: 'bar',
data: {
labels: ATTN_ANALYSIS.bySection?.labels || [],
datasets: [
{
label: 'Absent',
data: ATTN_ANALYSIS.bySection?.absent || [],
backgroundColor: '#dc3545',
},
{
label: 'Late',
data: ATTN_ANALYSIS.bySection?.late || [],
backgroundColor: '#f0ad4e',
}
]
},
options: {
plugins: { legend: { position: 'bottom' } },
scales: {
y: { beginAtZero: true, ticks: { precision: 0 } }
}
}
});
}
document.addEventListener('DOMContentLoaded', buildAttendanceCharts);
document.addEventListener('DOMContentLoaded', function () {
if (window.jQuery && $.fn.DataTable) {
$('#sectionAnalysisTable').DataTable({
paging: false,
searching: false,
info: false,
order: [[0, 'asc']]
});
$('#studentIssueTable').DataTable({
paging: true,
searching: true,
info: true,
order: [[1, 'asc'], [0, 'asc']],
pageLength: 25
});
}
});
</script>
<?= $this->endSection() ?>