Files
2026-03-30 23:50:20 -04:00

1602 lines
61 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() ?>
<div class="container-fluid">
<div class="wrapper">
<div class="content mb-3"></div>
<h2 class="text-center mt-4 mb-3">Attendance Management</h2>
<?= $this->include('partials/academic_filter') ?>
<?= $this->section('styles') ?>
<style>
/* Use standard thead; do not hide DataTables' internal headers */
/* Keep table header titles on a single line for alignment and ensure they layer above cells */
.attendance-table thead th { white-space: nowrap; z-index: 6 !important; }
.attendance-table thead th.school-id-col { z-index: 7 !important; }
.attendance-table thead th.student-name-col { z-index: 6 !important; }
/* Also apply nowrap to DataTables cloned headers */
div.dataTables_wrapper .dataTables_scrollHead table thead th,
table.fixedHeader-floating thead th { white-space: nowrap !important; }
/* Student Name column respects the longest content */
/* Student Name column */
.attendance-table th.student-name-col {
white-space: nowrap;
min-width: var(--name-min-ch, 12ch);
text-align: left;
}
.attendance-table td.student-name-col {
white-space: nowrap;
min-width: var(--name-min-ch, 12ch);
max-width: var(--name-min-ch, 12ch);
overflow: hidden;
text-overflow: ellipsis;
}
/* School ID column sized to fit content */
/* School ID column */
.attendance-table th.school-id-col {
white-space: nowrap;
min-width: var(--id-min-ch, 9ch);
text-align: left;
}
.attendance-table td.school-id-col {
white-space: nowrap;
min-width: var(--id-min-ch, 9ch);
max-width: var(--id-min-ch, 9ch);
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
/* Date columns: uniform width and nowrap */
.attendance-table th.date-col,
.attendance-table td.date-col {
white-space: nowrap;
min-width: 160px;
vertical-align: middle;
}
/* Make the first two columns sticky so names stay visible while scrolling */
.attendance-table th.school-id-col,
.attendance-table td.school-id-col {
position: sticky;
left: 0;
z-index: 4; /* above other cells; below modals */
background: #fff;
}
.attendance-table thead th.school-id-col { background: var(--mgmt-thead-bg, #f8f9fa) !important; }
/* Keep Student Name non-sticky to avoid header misalignment on some laptops */
.attendance-table th.student-name-col,
.attendance-table td.student-name-col {
position: static !important;
left: auto !important;
z-index: auto;
background: transparent;
box-shadow: none;
}
.attendance-table thead th.student-name-col { background: var(--mgmt-thead-bg, #f8f9fa) !important; }
/* visual separation for sticky boundary of the first column */
.attendance-table th.school-id-col, .attendance-table td.school-id-col { box-shadow: 1px 0 0 rgba(0,0,0,0.06) inset; }
/* Compact the selects and badges so more fits on laptop screens */
.attendance-table select.day-select.form-select-sm { font-size: 0.85rem; padding: .2rem .35rem; }
.attendance-table .attendance-badge { font-size: .72rem; vertical-align: middle; }
/* Reduce overall padding in content area on this page only */
.wrapper .content { padding-left: 12px; padding-right: 12px; }
/* Nav tabs wrap into multiple rows if needed */
#attendanceTabs.nav-tabs { flex-wrap: wrap; row-gap: .25rem; }
/* Responsiveness: tighten date column min-widths on narrower screens */
@media (max-width: 1600px) {
.attendance-table th.date-col,
.attendance-table td.date-col { min-width: 140px; }
}
@media (max-width: 1440px) {
.attendance-table th.date-col,
.attendance-table td.date-col { min-width: 120px; }
}
@media (max-width: 1280px) {
.attendance-table th.date-col,
.attendance-table td.date-col { min-width: 110px; }
.attendance-table select.day-select.form-select-sm { font-size: 0.8rem; padding: .18rem .3rem; }
}
@media (max-width: 1180px) {
.attendance-table th.date-col,
.attendance-table td.date-col { min-width: 100px; }
.attendance-table .attendance-badge { display: none; }
}
/* Ensure horizontal scrolling always available */
.table-responsive { overflow-x: auto; }
/* Date navigator: mimic dynamic navbar round arrow */
.date-nav-wrap { width: 100%; }
.mgmt-round-arrow {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 9999px;
background-color: var(--mgmt-primary);
color: #fff;
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
transition: background-color .2s ease, opacity .2s ease;
}
.mgmt-round-arrow:hover { background-color: var(--mgmt-primary-hover); }
.mgmt-round-arrow:disabled { opacity: .5; cursor: not-allowed; }
.date-range-label { min-width: 180px; text-align: center; color: var(--mgmt-muted); }
.attendance-status-light {
width: 12px;
height: 12px;
margin-right: .35rem;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, .25);
display: inline-block;
vertical-align: text-bottom;
}
.attendance-status-light.done {
background-color: #198754;
box-shadow: inset 0 0 0 1px rgba(25, 135, 84, .35);
}
.attendance-status-light.pending {
background-color: #dc3545;
box-shadow: inset 0 0 0 1px rgba(220, 53, 69, .35);
}
</style>
<?= $this->endSection() ?>
<!-- ===================== Global Student Search ===================== -->
<div class="row g-2 justify-content-center mb-3">
<div class="col-12 col-md-8 col-lg-6">
<div class="input-group">
<span class="input-group-text">Search Student</span>
<input id="globalStudentSearch"
class="form-control"
type="text"
placeholder="Type School ID or Student Name"
list="studentSuggestions"
autocomplete="off" />
<!--button id="globalStudentSearchBtn" class="btn btn-primary" type="button">Find</button-->
<button id="globalStudentSearchClear" class="btn btn-outline-secondary" type="button" title="Clear search">Clear</button>
</div>
<small class="text-muted">Searches all grades.</small>
</div>
</div>
<?php
$analysisUrl = site_url('administrator/daily_attendance/analysis');
$queryParts = [];
if (!empty($semester)) $queryParts[] = 'semester=' . urlencode((string)$semester);
if (!empty($schoolYear)) $queryParts[] = 'school_year=' . urlencode((string)$schoolYear);
if ($queryParts) $analysisUrl .= '?' . implode('&', $queryParts);
?>
<div class="row justify-content-center mb-3">
<div class="col-auto">
<a class="btn btn-outline-primary" href="<?= esc($analysisUrl) ?>">
Attendance Analysis
</a>
</div>
</div>
<?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;
?>
<datalist id="studentSuggestions">
<?php
// Output a single suggestion per student in the form: "SCHOOLID - First Last"
// Use a set to prevent duplicates across sections/loops.
$seen = [];
foreach ($grades as $classId => $sections) {
foreach ($sections as $section) {
$secId = $section['class_section_id'] ?? null;
if (!$secId || empty($studentsBySection[$secId])) continue;
foreach ($studentsBySection[$secId] as $stu) {
$sid = (int)($stu['id'] ?? 0);
$scid = trim((string)($stu['school_id'] ?? ''));
$fname = trim((string)($stu['firstname'] ?? ''));
$lname = trim((string)($stu['lastname'] ?? ''));
if (!$sid || $scid === '' || $fname === '' || $lname === '') continue;
// De-dup by either student DB id or school_id
if (isset($seen[$sid]) || isset($seen[$scid])) continue;
$seen[$sid] = true;
$seen[$scid] = true;
$className = (string)($section['class_section_name'] ?? '');
$suffix = $className !== '' ? (' (' . $className . ')') : '';
$label = $scid . ' - ' . $fname . ' ' . $lname . $suffix; // ASCII hyphen, then class section in parentheses
echo '<option value="' . esc($label) . '"></option>';
}
}
}
?>
</datalist>
<?php
// =====================================================================
// Helpers & Utilities
// =====================================================================
// ---- 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) {
// Arabic program: tab label is "Arabic" (sections show their own names e.g., Arabic-1)
$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);
}
// Keep Arabic after Youth (if present) and before any unknown leftovers
if ($arabicIds) {
sort($arabicIds, SORT_NUMERIC);
$sortedClassIds = array_merge($sortedClassIds, $arabicIds);
}
// Append any remaining class IDs (e.g., Arabic class) that didnt fit standard buckets
$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);
};
$slugFor = function (int $id) use ($labelsByClassId, $gradeNumberById, $arabicIds): string {
$lbl = $labelsByClassId[$id] ?? '';
if ($lbl === 'KG') return 'kg';
if ($lbl === 'Youth') return 'youth';
if (in_array($id, $arabicIds, true) || strcasecmp($lbl, 'Arabic') === 0) return 'arabic';
$n = $gradeNumberById[$id] ?? null;
return $n !== null ? 'g' . $n : (string)$id;
};
// Distinct student counts per classId for tab badges
$studentsCountByClassId = [];
foreach ($grades as $classId => $sections) {
$cid = (int)$classId;
$uniq = [];
foreach ($sections as $section) {
$secId = $section['class_section_id'] ?? null;
if ($secId && !empty($studentsBySection[$secId])) {
foreach ($studentsBySection[$secId] as $stu) {
if (!empty($stu['id'])) $uniq[(int)$stu['id']] = true;
}
}
}
$studentsCountByClassId[$cid] = count($uniq);
}
// Default active tab = first in sorted order (can still be overridden by ?class_id)
$defaultId = $sortedClassIds[0] ?? null;
// =====================================================================
// NEW UTILITIES (dates pivot logic) — includes "today if Sunday, else next Sunday" column
// =====================================================================
$__datesBySection = (isset($datesBySection) && is_array($datesBySection)) ? $datesBySection : [];
$__dateList = (isset($dateList) && is_array($dateList)) ? array_values(array_filter(array_map('strval', $dateList))) : [];
$__attendanceData = (isset($attendanceData) && is_array($attendanceData)) ? $attendanceData : [];
$__noSchoolDays = (isset($noSchoolDays) && is_array($noSchoolDays)) ? $noSchoolDays : [];
try {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$tzObj = new DateTimeZone($tzName ?: 'UTC');
} catch (\Throwable $e) {
try {
$tzObj = new DateTimeZone(user_timezone() ?: 'UTC');
} catch (\Throwable $e2) {
$tzObj = new DateTimeZone('UTC');
}
}
try {
$__nowDate = new DateTime('now', $tzObj);
} catch (\Throwable $e) {
$__nowDate = new DateTime('now');
}
$weekday = (int) $__nowDate->format('w');
if ($weekday === 0) {
$__anchorSunday = $__nowDate->format('Y-m-d');
} else {
$__anchorSunday = (clone $__nowDate)->modify('next sunday')->format('Y-m-d');
}
$buildDateCols = function ($sectionId) use ($__datesBySection, $__dateList, $__attendanceData, $__anchorSunday, $__nowDate, $__noSchoolDays) {
$filterDates = function (array $items) use ($__noSchoolDays) {
$map = [];
foreach ($items as $item) {
$d = (string)$item;
if ($d === '' || !empty($__noSchoolDays[$d])) {
continue;
}
$map[$d] = $d;
}
return array_values($map);
};
$candidates = [];
if (!empty($__datesBySection[$sectionId]) && is_array($__datesBySection[$sectionId])) {
$candidates = array_merge($candidates, $__datesBySection[$sectionId]);
}
if (!empty($__dateList) && is_array($__dateList)) {
$candidates = array_merge($candidates, $__dateList);
}
$cols = $filterDates($candidates);
if (!$cols && !empty($__attendanceData[$sectionId]) && is_array($__attendanceData[$sectionId])) {
$set = [];
foreach ($__attendanceData[$sectionId] as $sid => $entries) {
if (!is_array($entries)) continue;
foreach ($entries as $e) {
$d = (string)($e['date'] ?? '');
if ($d !== '') $set[$d] = $d;
}
}
$cols = $filterDates(array_keys($set));
}
if (!$cols) {
$nowRef = clone $__nowDate;
$fallback = [
(clone $nowRef)->modify('-7 days')->format('Y-m-d'),
$nowRef->format('Y-m-d'),
];
$cols = $filterDates($fallback);
}
if ($cols) {
sort($cols, SORT_STRING);
}
if (!empty($__anchorSunday) && empty($__noSchoolDays[$__anchorSunday]) && !in_array($__anchorSunday, $cols, true)) {
$cols[] = $__anchorSunday;
sort($cols, SORT_STRING);
}
return $cols;
};
$formatDateLabel = function ($ymd) {
$ymd = (string)$ymd;
if ($ymd === '') return '';
if (preg_match('/^(\\d{4})-(\\d{2})-(\\d{2})$/', $ymd, $m)) {
return $m[2] . '-' . $m[3] . '-' . $m[1];
}
try {
return local_date($ymd, 'm-d-Y');
} catch (\Throwable $e) {
return $ymd;
}
};
$todayDate = $__nowDate->format('Y-m-d');
$todayLabel = $todayDate ? $formatDateLabel($todayDate) : '';
// Build {date => {status, is_reported}}
$recAt = function (array $entries) {
$map = [];
foreach ($entries as $e) {
$d = (string)($e['date'] ?? '');
if ($d !== '') {
$map[$d] = [
'status' => (string)($e['status'] ?? ''),
'is_reported' => (string)($e['is_reported'] ?? 'no'),
];
}
}
return $map;
};
// Dropdown options + badge meta (first is placeholder)
$optionDefs = [
'none' => ['label' => 'Select attendance', 'status' => '', 'is_reported' => '', 'badge' => 'secondary', 'short' => '—'],
'present' => ['label' => 'Present', 'status' => 'present', 'is_reported' => 'no', 'badge' => 'success', 'short' => 'P'],
'late_yes' => ['label' => 'Late — Reported', 'status' => 'late', 'is_reported' => 'yes', 'badge' => 'warning text-dark', 'short' => 'LR'],
'late_no' => ['label' => 'Late — Not-reported', 'status' => 'late', 'is_reported' => 'no', 'badge' => 'warning text-dark', 'short' => 'LnR'],
'absent_yes' => ['label' => 'Absent — Reported', 'status' => 'absent', 'is_reported' => 'yes', 'badge' => 'danger', 'short' => 'AR'],
'absent_no' => ['label' => 'Absent — Not-reported', 'status' => 'absent', 'is_reported' => 'no', 'badge' => 'danger', 'short' => 'AnR'],
];
?>
<?php
$anchorSunday = $__anchorSunday ?? '';
$anchorSundayLabel = $anchorSunday ? $formatDateLabel($anchorSunday) : '';
$sectionHasCompleteAttendance = function (string $sectionKey, string $targetDate) use ($__attendanceData, $studentsBySection): bool {
if ($targetDate === '') {
return false;
}
$sectionStudents = $studentsBySection[$sectionKey] ?? [];
if (empty($sectionStudents)) {
return false;
}
$entriesByStudent = $__attendanceData[$sectionKey] ?? [];
foreach ($sectionStudents as $student) {
$studentId = (int)($student['id'] ?? 0);
if ($studentId <= 0) {
return false;
}
$found = false;
$entries = $entriesByStudent[$studentId] ?? [];
if (is_array($entries)) {
foreach ($entries as $entry) {
if ((string)($entry['date'] ?? '') === $targetDate) {
$found = true;
break;
}
}
}
if (!$found) {
return false;
}
}
return true;
};
$classHasCompleteAttendance = function (array $sections, string $targetDate) use ($sectionHasCompleteAttendance): bool {
if ($targetDate === '' || empty($sections)) {
return false;
}
foreach ($sections as $section) {
$secKey = (string)($section['class_section_id'] ?? ($section['id'] ?? ''));
if ($secKey === '') {
continue;
}
if (!$sectionHasCompleteAttendance($secKey, $targetDate)) {
return false;
}
}
return true;
};
$attendanceStatusByClass = [];
$attendanceStatusDateByClass = [];
foreach ($sortedClassIds as $cid) {
$sections = $grades[$cid] ?? [];
$statusDone = false;
$statusDate = '';
if ($todayDate !== '' && $classHasCompleteAttendance($sections, $todayDate)) {
$statusDone = true;
$statusDate = $todayDate;
} elseif ($anchorSunday !== '' && $classHasCompleteAttendance($sections, $anchorSunday)) {
$statusDone = true;
$statusDate = $anchorSunday;
}
$attendanceStatusByClass[$cid] = $statusDone;
$attendanceStatusDateByClass[$cid] = $statusDate;
}
?>
<!-- Tabs navigation -->
<ul class="nav nav-tabs justify-content-center" id="attendanceTabs" role="tablist">
<?php foreach ($sortedClassIds as $cid): ?>
<?php
$slug = $slugFor($cid);
$label = $labelFor($cid);
$cnt = (int)($studentsCountByClassId[$cid] ?? 0);
$statusDone = !empty($attendanceStatusByClass[$cid]);
$statusDate = $attendanceStatusDateByClass[$cid] ?? '';
if ($statusDate !== '') {
$statusLabel = $formatDateLabel($statusDate);
} elseif ($anchorSundayLabel !== '') {
$statusLabel = $anchorSundayLabel;
} elseif ($todayLabel !== '') {
$statusLabel = $todayLabel;
} else {
$statusLabel = '';
}
$statusTitle = $statusLabel
? ($statusDone ? 'Attendance recorded for ' : 'Attendance pending for ') . $statusLabel
: ($statusDone ? 'Attendance recorded' : 'Attendance pending');
?>
<li class="nav-item">
<a class="nav-link <?= ($cid === $defaultId) ? 'active' : '' ?>"
id="grade<?= esc($slug) ?>-tab"
data-bs-toggle="tab"
href="#grade<?= esc($slug) ?>"
role="tab"
aria-controls="grade<?= esc($slug) ?>"
aria-selected="<?= ($cid === $defaultId) ? 'true' : 'false' ?>"
data-class-id="<?= (int)$cid ?>">
<span class="attendance-status-light <?= $statusDone ? 'done' : 'pending' ?>"
title="<?= esc($statusTitle) ?>"
aria-hidden="true"></span>
<span class="visually-hidden"><?= esc($statusTitle) ?></span>
<?= esc($label) ?>
<span class="badge bg-secondary ms-1" title="Students in <?= esc($label) ?>"><?= $cnt ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
<!-- Tabs content -->
<div class="tab-content mt-3">
<?php foreach ($sortedClassIds as $cid): ?>
<?php
$slug = $slugFor($cid);
$label = $labelFor($cid);
$sections = $grades[$cid] ?? [];
?>
<div class="tab-pane fade <?= ($cid === $defaultId) ? 'show active' : '' ?>"
id="grade<?= esc($slug) ?>"
role="tabpanel"
aria-labelledby="grade<?= esc($slug) ?>-tab">
<?php /* Removed top-level grade header; show only per-section titles */ ?>
<?php foreach ($sections as $section): ?>
<?php
$sectionKeyRaw = $section['class_section_id'] ?? ($section['id'] ?? '');
$sectionKey = ($sectionKeyRaw !== '' && $sectionKeyRaw !== null) ? (string)$sectionKeyRaw : '';
if ($sectionKey === '' || empty($studentsBySection[$sectionKey])) continue;
$dateCols = $buildDateCols($sectionKey);
// Prefer oldest incomplete attendance date for initial view
$defaultDateYmd = '';
if (!empty($dateCols)) {
foreach ($dateCols as $d) {
$d = (string)$d;
if ($d !== '' && !$sectionHasCompleteAttendance($sectionKey, $d)) {
$defaultDateYmd = $d;
break;
}
}
if ($defaultDateYmd === '') {
$defaultDateYmd = (string)end($dateCols);
}
reset($dateCols);
}
$defaultDateLabel = $defaultDateYmd !== '' ? $formatDateLabel($defaultDateYmd) : '';
// Compute longest student name length (characters) for this section
$maxNameLen = 0;
foreach ($studentsBySection[$sectionKey] as $s_) {
$nm_ = trim(($s_['firstname'] ?? '') . ' ' . ($s_['lastname'] ?? ''));
$len_ = function_exists('mb_strlen') ? mb_strlen($nm_, 'UTF-8') : strlen($nm_);
if ($len_ > $maxNameLen) $maxNameLen = $len_;
}
$minNameCh = max(12, $maxNameLen + 2);
// Compute longest school ID length (characters) for this section (ensure header fits: 9ch)
$maxIdLen = 0;
foreach ($studentsBySection[$sectionKey] as $s_) {
$id_ = trim((string)($s_['school_id'] ?? ''));
$len_ = function_exists('mb_strlen') ? mb_strlen($id_, 'UTF-8') : strlen($id_);
if ($len_ > $maxIdLen) $maxIdLen = $len_;
}
$minIdCh = max(9, $maxIdLen);
// Derive a human-friendly section label above the table
$rawSecName = trim((string)($section['class_section_name'] ?? ''));
// Base label from section name (fallback to tab label)
$sectionLabel = ($rawSecName !== '') ? $rawSecName : $label;
// Add "Grade " prefix unless already present, but NEVER for KG, Youth, or Arabic program
if (preg_match('/^(KG(\b|-)|Youth(\b|-))/i', $sectionLabel)) {
$headerText = $sectionLabel;
} elseif (strcasecmp($label, 'Arabic') === 0 || stripos($sectionLabel, 'Arabic') === 0) {
$headerText = $sectionLabel;
} elseif (preg_match('/^\s*Grade\b/i', $sectionLabel)) {
$headerText = $sectionLabel;
} else {
$headerText = 'Grade ' . $sectionLabel;
}
?>
<h4 class="mt-4 mb-2 text-center"><?= esc($headerText) ?></h4>
<!-- Date navigation (shows 4 date columns at a time; arrows page left/right) -->
<div class="date-nav-wrap d-flex justify-content-center align-items-center gap-2 mb-2" data-date-nav data-section-id="<?= esc($sectionKey) ?>">
<button type="button" class="mgmt-round-arrow date-prev" title="Previous dates" aria-label="Previous dates">
<i class="bi bi-chevron-left"></i>
</button>
<span class="px-2 small date-range-label">&nbsp;</span>
<button type="button" class="mgmt-round-arrow date-next" title="Next dates" aria-label="Next dates">
<i class="bi bi-chevron-right"></i>
</button>
</div>
<div class="table-responsive">
<table class="table table-bordered attendance-table attendance-grid w-100"
data-section-id="<?= esc($sectionKey) ?>"
data-default-date-label="<?= esc($defaultDateLabel) ?>">
<thead>
<tr>
<th class="school-id-col" style="min-width: <?= (int)$minIdCh ?>ch;">School ID</th>
<th class="student-name-col" style="min-width: <?= (int)$minNameCh ?>ch;">Student Name</th>
<!-- (Class-Section hidden per request) -->
<?php foreach ($dateCols as $d): ?>
<?php $dLabel = $formatDateLabel($d); ?>
<th class="text-center date-col" style="min-width:160px;"><?= esc($dLabel) ?></th>
<?php endforeach; ?>
<th class="text-center">P</th>
<th class="text-center">L</th>
<th class="text-center">A</th>
<th class="text-center">T</th>
</tr>
</thead>
<tbody>
<?php
$uniqueStudents = [];
foreach ($studentsBySection[$sectionKey] as $student) {
$sid = (int)($student['id'] ?? 0);
if ($sid <= 0 || isset($uniqueStudents[$sid])) {
continue;
}
$uniqueStudents[$sid] = $student;
}
?>
<?php foreach ($uniqueStudents as $student): ?>
<?php
$sid = (int)$student['id'];
$entryMap = $recAt($__attendanceData[$sectionKey][$sid] ?? []);
$summary = ($attendanceRecord[$sectionKey][$sid] ?? null);
$tPresent = $summary ? (int)($summary['total_presence'] ?? 0) : 0;
$tLate = $summary ? (int)($summary['total_late'] ?? 0) : 0;
$tAbsent = $summary ? (int)($summary['total_absence'] ?? 0) : 0;
$schoolId = esc($student['school_id']);
$fullNameRaw = trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''));
$fullName = esc($fullNameRaw);
$fullNameAttr = esc(strtolower($fullNameRaw));
?>
<tr
data-student-id="<?= (int)$sid ?>"
data-school-id="<?= $schoolId ?>"
data-student-name="<?= $fullNameAttr ?>"
data-student-name-display="<?= esc($fullNameRaw) ?>"
data-class-id="<?= (int)$cid ?>"
data-grade-label="<?= esc($headerText) ?>">
<td class="school-id-col" style="min-width: <?= (int)$minIdCh ?>ch;"><?= $schoolId ?></td>
<td class="student-name-col" style="min-width: <?= (int)$minNameCh ?>ch;">
<a
href="<?= site_url('family') . '?student_id=' . (int)$sid ?>"
class="text-decoration-none"
data-family-student-id="<?= (int)$sid ?>"
title="Open family card">
<?= $fullName ?>
</a>
</td>
<?php foreach ($dateCols as $dcol): ?>
<?php
$rec = $entryMap[$dcol] ?? null;
$val = 'none';
if ($rec) {
if ($rec['status'] === 'present') $val = 'present';
elseif ($rec['status'] === 'late') $val = ($rec['is_reported'] === 'yes') ? 'late_yes' : 'late_no';
elseif ($rec['status'] === 'absent') $val = ($rec['is_reported'] === 'yes') ? 'absent_yes' : 'absent_no';
}
$isParentReport = $rec && (($rec['source'] ?? '') === 'parent_report');
$short = $optionDefs[$val]['short'] ?? '—';
$badgePart = $optionDefs[$val]['badge'] ?? 'secondary'; // e.g. "warning text-dark"
$badgeFull = 'bg-' . $badgePart; // -> "bg-warning text-dark" or "bg-secondary"
$dLabel = $formatDateLabel($dcol);
?>
<td class="text-center date-col" style="min-width:160px;">
<select
class="form-select form-select-sm day-select"
data-class-id="<?= (int)$cid ?>"
data-class-section-id="<?= esc($sectionKey) ?>"
data-student-id="<?= (int)$sid ?>"
data-school-id="<?= esc($student['school_id']) ?>"
data-date="<?= esc($dcol) ?>"
aria-label="Attendance on <?= esc($dLabel) ?>">
<?php foreach ($optionDefs as $key => $def): ?>
<option value="<?= $key ?>" <?= $key === $val ? 'selected' : '' ?>>
<?= esc($def['label']) ?>
</option>
<?php endforeach; ?>
</select>
<span class="attendance-badge badge rounded-pill ms-1 <?= esc($badgeFull) ?>">
<?= esc($short) ?>
</span>
<?php if ($isParentReport): ?>
<div class="text-muted small mt-1">Parent report</div>
<?php endif; ?>
</td>
<?php endforeach; ?>
<td class="text-center total-present"><?= (int)$tPresent ?></td>
<td class="text-center total-late"><?= (int)$tLate ?></td>
<td class="text-center total-absent"><?= (int)$tAbsent ?></td>
<td class="text-center total-expected"><?= (int)($totalPassedDays ?? 0) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- Late Slip Modal -->
<div class="modal fade" id="lateSlipModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Print Late Slip</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="lateSlipForm">
<div class="row g-2">
<div class="col-12">
<label class="form-label">Student Name</label>
<input type="text" class="form-control" name="student_name" readonly>
</div>
<div class="col-6">
<label class="form-label">Date</label>
<input type="text" class="form-control" name="date" readonly>
</div>
<div class="col-6">
<label class="form-label">Time In</label>
<input type="text" class="form-control" name="time_in" placeholder="hh:mm AM/PM">
</div>
<div class="col-12">
<label class="form-label">Grade</label>
<input type="text" class="form-control" name="grade" readonly>
</div>
<div class="col-12">
<label class="form-label">Reason</label>
<input type="text" class="form-control" name="reason" placeholder="Optional reason">
</div>
<div class="col-6">
<label class="form-label">School Year</label>
<input type="text" class="form-control" name="school_year" readonly>
</div>
<div class="col-6">
<label class="form-label">Admin Name</label>
<input type="text" class="form-control" name="admin_name" readonly>
</div>
</div>
</form>
<hr>
<label class="form-label">Preview</label>
<pre class="bg-light p-2" id="lateSlipPreview" style="white-space: pre-wrap; max-height: 240px; overflow:auto;">Loading...</pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="lateSlipPrintBtn">Print Slip</button>
</div>
</div>
</div>
</div>
<?php
// ================== SAVE ENDPOINTS (route you said works) ==================
$savePath = 'attendance/update';
$attendanceSaveUrl = '/' . $savePath; // same-origin relative URL
$attendanceSaveUrlIndex = '/index.php/' . $savePath; // index.php fallback (relative)
?>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
const ATTN_SAVE_URL = <?= json_encode($attendanceSaveUrl) ?>;
const ATTN_SAVE_URL_INDEX = <?= json_encode($attendanceSaveUrlIndex) ?>;
const SLIP_PREVIEW_URL = <?= json_encode('/slips/preview') ?>;
const SLIP_PRINT_URL = <?= json_encode('/slips/print') ?>;
const TERM_SCHOOL_YEAR = <?= json_encode($schoolYear ?? '') ?>;
const TERM_SEMESTER = <?= json_encode($semester ?? '') ?>;
<?php
$___adminName = isset($currentAdminName) && $currentAdminName !== ''
? (string)$currentAdminName
: trim((string)(session()->get('firstname') ?? '') . ' ' . (string)(session()->get('lastname') ?? ''));
?>
const CURRENT_USER_NAME = <?= json_encode($___adminName) ?>;
</script>
<script>
// ---- CSRF helpers (CI4) ----
const CSRF_NAME = <?= json_encode(csrf_token()) ?>;
const CSRF_HEADER_NAME = <?= json_encode(csrf_header()) ?>;
let CSRF_HASH = <?= json_encode(csrf_hash()) ?>;
function meta(name) {
return document.querySelector(`meta[name="${name}"]`);
}
function getCsrfHeaderName() { return CSRF_HEADER_NAME || 'X-CSRF-TOKEN'; }
function getCsrfFromMeta() { return ''; }
function getCookie(n) {
return document.cookie.split(';')
.map(v => v.trim())
.find(v => v.startsWith(n + '='))?.split('=')[1] || '';
}
// ✅ Use the right CI4 property for the CSRF cookie
const CSRF_COOKIE_NAME = <?= json_encode(config('Security')->csrfCookieName ?? (config('Security')->cookieName ?? 'csrf_cookie_name')) ?>;
function refreshCsrfFromPage() {
// On first load meta has a fresh token; after any POST CI rotates and sets cookie
const m = getCsrfFromMeta();
if (m) {
CSRF_HASH = m;
return;
}
const c = decodeURIComponent(getCookie(CSRF_COOKIE_NAME));
if (c) {
CSRF_HASH = c;
}
}
// --- URL-encoded posts (plays nicest with CI4) ---
function toUrlEncoded(obj) {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(obj)) p.append(k, v ?? '');
// Always include CSRF field in body to satisfy strict CSRF setups
if (CSRF_NAME && CSRF_HASH) {
p.append(CSRF_NAME, CSRF_HASH);
}
return p;
}
// If the server sends a fresh token back in a header, capture it
function captureCsrfFromResponse(res) {
const hdrName = getCsrfHeaderName();
const hdrVal = res.headers.get(hdrName);
if (hdrVal) {
CSRF_HASH = hdrVal;
return;
}
// Fallback to cookie (token rotates after POST)
const c = decodeURIComponent(getCookie(CSRF_COOKIE_NAME));
if (c) CSRF_HASH = c;
}
async function doPost(url, bodyParams) {
const res = await fetch(url, {
method: 'POST',
body: bodyParams,
credentials: 'same-origin',
headers: {
'Accept': 'application/json, text/html, */*',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
[getCsrfHeaderName()]: CSRF_HASH,
'X-Requested-With': 'XMLHttpRequest'
}
});
const text = await res.text();
let data = null;
try {
data = JSON.parse(text);
} catch {}
// ✅ Pick up new token (header first, then cookie, then JSON)
if (data?.csrfHash) {
CSRF_HASH = data.csrfHash;
} else {
captureCsrfFromResponse(res);
}
return {
res,
text,
data
};
}
// -------- Late slip helpers --------
function formatDateMDY(iso) {
if (!iso) return '';
const d = new Date(iso + 'T00:00:00');
if (isNaN(d.getTime())) return iso;
const mm = (d.getMonth() + 1).toString().padStart(2, '0');
const dd = d.getDate().toString().padStart(2, '0');
const yyyy = d.getFullYear();
return `${mm}-${dd}-${yyyy}`;
}
function currentTime12h(now = new Date()) {
let h = now.getHours();
const m = now.getMinutes();
const ampm = h >= 12 ? 'PM' : 'AM';
h = h % 12;
if (h === 0) h = 12;
return `${h.toString().padStart(1,'0')}:${m.toString().padStart(2,'0')} ${ampm}`;
}
function readSlipForm() {
const f = document.getElementById('lateSlipForm');
return {
student_name: f.student_name.value || '',
date: f.date.value || '',
time_in: f.time_in.value || '',
grade: f.grade.value || '',
reason: f.reason.value || '',
school_year: f.school_year.value || TERM_SCHOOL_YEAR || '',
admin_name: f.admin_name.value || CURRENT_USER_NAME || ''
};
}
async function refreshLateSlipPreview() {
const vals = readSlipForm();
const pre = document.getElementById('lateSlipPreview');
pre.textContent = 'Loading...';
try {
const { res, data, text } = await doPost(SLIP_PREVIEW_URL, toUrlEncoded(vals));
if (!res.ok || !data?.ok) {
pre.textContent = `Preview failed (HTTP ${res.status}).\n` + (data?.error || text || '');
return;
}
pre.textContent = data.text || '(Empty preview)';
} catch (e) {
pre.textContent = 'Preview error: ' + (e?.message || 'network');
}
}
// Auto-update preview when the user types reason (and time)
let __lateSlipPreviewDebounce;
function scheduleLateSlipPreview() {
if (__lateSlipPreviewDebounce) clearTimeout(__lateSlipPreviewDebounce);
__lateSlipPreviewDebounce = setTimeout(refreshLateSlipPreview, 250);
}
let __lateSlipAutoHooked = false;
function ensureLateSlipAutoPreview() {
if (__lateSlipAutoHooked) return;
const f = document.getElementById('lateSlipForm');
if (!f) return;
__lateSlipAutoHooked = true;
f.addEventListener('input', function(e){
const name = e.target && e.target.name;
if (name === 'reason' || name === 'time_in') scheduleLateSlipPreview();
});
f.addEventListener('change', function(e){
const name = e.target && e.target.name;
if (name === 'reason' || name === 'time_in') scheduleLateSlipPreview();
});
}
let __lateSlipModal;
function openLateSlipModal(initVals) {
const f = document.getElementById('lateSlipForm');
f.student_name.value = initVals.student_name || '';
f.date.value = initVals.date || '';
f.time_in.value = initVals.time_in || currentTime12h();
f.grade.value = initVals.grade || '';
f.reason.value = initVals.reason || '';
f.school_year.value = initVals.school_year || TERM_SCHOOL_YEAR || '';
f.admin_name.value = initVals.admin_name || CURRENT_USER_NAME || '';
if (!__lateSlipModal) {
__lateSlipModal = new bootstrap.Modal(document.getElementById('lateSlipModal'));
}
__lateSlipModal.show();
// Load preview after opening
setTimeout(refreshLateSlipPreview, 50);
// Hook auto-preview on input
ensureLateSlipAutoPreview();
}
async function printLateSlip() {
// Open a new tab that streams a PDF built from the form values
const vals = readSlipForm();
const p = new URLSearchParams(vals);
// Use credit-card width; height is dynamic based on content
p.set('paper', 'card');
const url = SLIP_PRINT_URL + '?' + p.toString();
window.open(url, '_blank', 'noopener');
__lateSlipModal?.hide();
}
// Dropdown value → {status, is_reported}
const OPTION_TO_PAYLOAD = {
'none': {
status: '',
is_reported: ''
},
'present': {
status: 'present',
is_reported: 'no'
},
'late_yes': {
status: 'late',
is_reported: 'yes'
},
'late_no': {
status: 'late',
is_reported: 'no'
},
'absent_yes': {
status: 'absent',
is_reported: 'yes'
},
'absent_no': {
status: 'absent',
is_reported: 'no'
},
};
async function postAttendanceChange(selEl) {
const v = selEl.value;
if (v === 'none') return {
ok: true,
skipped: true
};
const def = OPTION_TO_PAYLOAD[v] || OPTION_TO_PAYLOAD.present;
// ✅ make sure we ALWAYS have class_id; clear error if missing
const tr = selEl.closest('tr');
const tabActive = document.querySelector('.nav-link.active');
const classId =
selEl.dataset.classId ||
(tr && tr.dataset.classId) ||
(tabActive && tabActive.getAttribute('data-class-id')) ||
'';
if (!classId) {
return {
ok: false,
message: 'Missing class_id in dataset — cannot save attendance.'
};
}
// required dataset fields
const payload = {
[CSRF_NAME]: CSRF_HASH,
ajax: '1',
class_id: classId,
class_section_id: selEl.dataset.classSectionId || '',
student_id: selEl.dataset.studentId || '',
school_id: selEl.dataset.schoolId || '',
date: selEl.dataset.date || '',
semester: TERM_SEMESTER || '',
school_year: TERM_SCHOOL_YEAR || '',
status: def.status,
is_reported: def.is_reported,
reason: (def.status === 'late' || def.status === 'absent') ? '—' : ''
};
if (!payload.student_id || !payload.class_section_id || !payload.date) {
return {
ok: false,
message: 'Missing student_id / class_section_id / date.'
};
}
refreshCsrfFromPage();
const body = toUrlEncoded(payload);
try {
selEl.disabled = true;
// Try pretty URL then index.php fallback
let {
res,
text,
data
} = await doPost(ATTN_SAVE_URL, body);
if ((res.status === 404 || res.status === 405) && ATTN_SAVE_URL_INDEX !== ATTN_SAVE_URL) {
({
res,
text,
data
} = await doPost(ATTN_SAVE_URL_INDEX, body));
}
if (res.ok) return {
ok: true
};
const msg = (data && data.message) ? data.message : `HTTP ${res.status}\n` + text.slice(0, 1200);
return {
ok: false,
message: msg
};
} catch (e) {
return {
ok: false,
message: e?.message || 'Network error'
};
} finally {
selEl.disabled = false;
}
}
function recomputeRowTotals(tr) {
let p = 0,
l = 0,
a = 0;
tr.querySelectorAll('select.day-select').forEach(sel => {
const v = sel.value;
if (v === 'present') p++;
else if (v.startsWith('late_')) l++;
else if (v.startsWith('absent_')) a++;
});
tr.querySelector('.total-present')?.replaceChildren(document.createTextNode(p));
tr.querySelector('.total-late')?.replaceChildren(document.createTextNode(l));
tr.querySelector('.total-absent')?.replaceChildren(document.createTextNode(a));
}
const OPTION_META = {
'none': {
badge: 'bg-secondary',
short: '—'
},
'present': {
badge: 'bg-success',
short: 'P'
},
'late_yes': {
badge: 'bg-warning text-dark',
short: 'LR'
},
'late_no': {
badge: 'bg-warning text-dark',
short: 'LnR'
},
'absent_yes': {
badge: 'bg-danger',
short: 'AR'
},
'absent_no': {
badge: 'bg-danger',
short: 'AnR'
}
};
function updateCellBadge(selectEl) {
const meta = OPTION_META[selectEl.value] || OPTION_META.none;
const badge = selectEl.parentElement.querySelector('.attendance-badge');
if (!badge) return;
badge.className = 'attendance-badge badge rounded-pill ms-1 ' + meta.badge;
badge.textContent = meta.short;
}
document.addEventListener('change', async (e) => {
const sel = e.target.closest('select.day-select');
if (!sel) return;
const tr = sel.closest('tr');
const prev = sel.getAttribute('data-prev') || sel.value;
sel.setAttribute('data-prev', sel.value);
updateCellBadge(sel);
recomputeRowTotals(tr);
const result = await postAttendanceChange(sel);
if (!result.ok && !result.skipped) {
sel.value = prev;
updateCellBadge(sel);
recomputeRowTotals(tr);
alert('Failed to save attendance.\n\n' + (result.message || 'Check Network tab for details.'));
console.error('Save error details:', result.message);
return;
}
// After a successful save, if marked as LATE, prompt to print a slip
if (sel.value && sel.value.startsWith('late_')) {
try {
const isoDate = sel.getAttribute('data-date') || '';
const row = sel.closest('tr');
const nameDisp = (row?.getAttribute('data-student-name-display') || '').trim();
const linkName = row?.querySelector('td:nth-child(2) a')?.textContent?.trim() || '';
const studentName = nameDisp || linkName || '';
const gradeLabel = (row?.getAttribute('data-grade-label') || '').trim();
const initVals = {
student_name: studentName,
date: formatDateMDY(isoDate),
time_in: currentTime12h(),
grade: gradeLabel,
reason: '',
school_year: TERM_SCHOOL_YEAR,
admin_name: CURRENT_USER_NAME,
};
openLateSlipModal(initVals);
} catch (err) {
console.warn('Failed to open slip modal:', err);
}
}
});
// Modal buttons
document.getElementById('lateSlipPrintBtn')?.addEventListener('click', printLateSlip);
</script>
<script>
// ================== Global Search (sticky highlight) ==================
let __tableApis = [];
let __studentIndex = [];
let __currentStickyRow = null;
const __anchorSunday = <?= json_encode($__anchorSunday ?? '') ?>;
function normalizeQuery(q) {
return (q || '').toString().trim().toLowerCase().replace(/\s+/g, ' ');
}
function extractSchoolIdFromQuery(q) {
const m = (q || '').match(/^\s*(\S+)\s*(?:—|-)?/);
return m ? m[1] : '';
}
function activateTabById(tabId) {
const link = document.querySelector(`a.nav-link[href="#${tabId}"]`);
if (link) new bootstrap.Tab(link).show();
return !!link;
}
function revealRowInDataTable(tableApi, tr) {
try {
const $rows = tableApi.rows({ search: 'none' }).nodes().to$();
const idx = $rows.index(tr);
if (idx >= 0) {
const pageLen = tableApi.page.len();
const targetPage = Math.floor(idx / pageLen);
tableApi.page(targetPage).draw(false);
tr.classList.add('row-sticky-highlight', 'row-highlight');
void tr.offsetWidth;
setTimeout(() => tr.classList.remove('row-highlight'), 1200);
setTimeout(() => tr.scrollIntoView({ behavior: 'smooth', block: 'center' }), 80);
}
} catch (e) { console.warn(e); }
}
function clearStickyHighlight() {
if (__currentStickyRow) {
__currentStickyRow.classList.remove('row-sticky-highlight');
__currentStickyRow = null;
}
}
function findStudentAndReveal(queryRaw) {
const q = normalizeQuery(queryRaw);
if (!q) return;
const qId = extractSchoolIdFromQuery(queryRaw).toLowerCase();
let hit = __studentIndex.find(x => x.schoolId && x.schoolId.toLowerCase() === qId && qId !== '');
if (!hit && qId) hit = __studentIndex.find(x => (x.schoolId || '').toLowerCase().includes(qId));
if (!hit) hit = __studentIndex.find(x => (x.name || '').includes(q));
if (!hit) {
const parts = q.split(' ').filter(Boolean);
hit = __studentIndex.find(x => parts.every(p => (x.name || '').includes(p)));
}
if (!hit) {
alert('No matching student found.');
return;
}
clearStickyHighlight();
const ok = activateTabById(hit.tabId);
if (!ok) {
alert('Found the student but failed to open the tab.');
return;
}
__currentStickyRow = hit.tr;
setTimeout(() => revealRowInDataTable(hit.tableApi, hit.tr), 150);
}
document.addEventListener('DOMContentLoaded', function () {
// Compute offset if a fixed/sticky top navbar exists to prevent header overlap
function getFixedHeaderOffset() {
let total = 0;
const stack = [];
const header = document.querySelector('header.navbar.sticky-top, header.navbar.fixed-top');
if (header) stack.push(header);
const mgmt = document.getElementById('navbarManagement');
if (mgmt && (mgmt.classList.contains('sticky-top') || mgmt.classList.contains('fixed-top'))) stack.push(mgmt);
document.querySelectorAll('.navbar.sticky-top, .navbar.fixed-top').forEach(function (el) { if (!stack.includes(el)) stack.push(el); });
stack.forEach(function (el) { const h = el.offsetHeight || (el.getBoundingClientRect && el.getBoundingClientRect().height) || 0; total += Math.max(0, Math.round(h)); });
return total;
}
// DataTables init
document.querySelectorAll('.attendance-table').forEach(function (tbl) {
const $tbl = $(tbl);
const api = $.fn.dataTable.isDataTable(tbl) ? $tbl.DataTable() : $tbl.DataTable({
stateSave: false,
pageLength: 100,
lengthMenu: [10, 25, 50, 100],
order: [[1, 'asc']],
fixedHeader: {
header: true,
headerOffset: getFixedHeaderOffset()
}
});
__tableApis.push(api);
});
// Build index
__studentIndex = [];
document.querySelectorAll('.attendance-grid').forEach(tableEl => {
const tabPane = tableEl.closest('.tab-pane');
const tabId = tabPane ? tabPane.id : null;
const api = __tableApis.find(a => a.table().node() === tableEl);
tableEl.querySelectorAll('tbody tr[data-student-id]').forEach(tr => {
const schoolId = (tr.getAttribute('data-school-id') || '').trim();
const name = (tr.getAttribute('data-student-name') || '').trim();
__studentIndex.push({ schoolId, name, tr, tableApi: api, tabId });
});
});
// ---------- Search UI (auto-run when a datalist option is chosen) ----------
const input = document.getElementById('globalStudentSearch');
const btn = document.getElementById('globalStudentSearchBtn');
const clr = document.getElementById('globalStudentSearchClear');
const dlist = document.getElementById('studentSuggestions');
// Cache all suggestion values for quick exact match
const suggestionSet = new Set(
Array.from(dlist?.options || []).map(o => (o.value || '').trim())
);
let lastAutoQuery = ''; // avoid duplicate firing on same value
function go() { findStudentAndReveal(input.value); }
// Enter key still works
input?.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); go(); }
});
// Clickable Find (kept for flexibility)
btn?.addEventListener('click', go);
// Clear button
clr?.addEventListener('click', () => {
if (input) input.value = '';
clearStickyHighlight();
input?.focus();
});
// Auto-run when the value matches a datalist option exactly
function tryAutoRun() {
const val = (input?.value || '').trim();
if (!val) { clearStickyHighlight(); return; }
if (suggestionSet.has(val) && val !== lastAutoQuery) {
lastAutoQuery = val;
go();
}
}
// Some browsers fire 'change' after picking a datalist option; others only 'input'
input?.addEventListener('change', tryAutoRun);
input?.addEventListener('input', () => {
// If user erased value, remove sticky highlight
if (!input.value.trim()) clearStickyHighlight();
// Debounce micro—still feels instant
clearTimeout(input.__autoTimer);
input.__autoTimer = setTimeout(tryAutoRun, 50);
});
// Adjust columns on tab switch
$('a[data-bs-toggle="tab"]').on('shown.bs.tab', function () {
$.fn.dataTable.tables({ visible: true, api: true }).columns.adjust().draw(false);
});
// Initial adjust
$.fn.dataTable.tables({ visible: true, api: true }).columns.adjust().draw(false);
// ====== Date column navigator (default to anchor Sunday + 3 prior Sundays) ======
function getDateColsInfo(tableEl) {
const ths = Array.from(tableEl.querySelectorAll('thead th'));
const dateCols = [];
const labels = [];
ths.forEach((th, idx) => {
if (th.classList.contains('date-col')) {
dateCols.push(idx);
labels.push((th.textContent || '').trim());
}
});
return { dateCols, labels };
}
function initDateNavigatorForTable(tableEl) {
const api = __tableApis.find(a => a.table().node() === tableEl);
if (!api) return;
const info = getDateColsInfo(tableEl);
const dateCols = info.dateCols;
const labels = info.labels;
const windowSize = 4;
const anchorLabel = (__anchorSunday || '').trim();
const defaultLabel = (tableEl.dataset.defaultDateLabel || '').trim();
function resolveAnchorIndex() {
if (!labels.length) return 0;
const targetLabel = defaultLabel || anchorLabel;
if (!targetLabel) return labels.length - 1;
const exact = labels.indexOf(targetLabel);
if (exact >= 0) return exact;
for (let i = labels.length - 1; i >= 0; i--) {
if (labels[i] <= targetLabel) return i;
}
return 0;
}
// Store on element for later
tableEl.__dateCols = dateCols;
tableEl.__dateLabels = labels;
tableEl.dataset.windowSize = String(windowSize);
tableEl.dataset.dateColsCount = String(dateCols.length);
// Default window ends at oldest incomplete date (fallback to anchor Sunday)
let start = Math.max(0, resolveAnchorIndex() - (windowSize - 1));
tableEl.dataset.windowStart = String(start);
// Apply visibility (anchor Sunday + previous 3 shown by default)
dateCols.forEach((colIdx, i) => {
const visible = i >= start && i < start + windowSize;
api.column(colIdx).visible(visible, false);
});
api.columns.adjust().draw(false);
// Wire up nav UI
let nav = tableEl.closest('.tab-pane')?.querySelector(`[data-date-nav][data-section-id="${tableEl.getAttribute('data-section-id')}"]`);
if (!nav) {
// Fallback: inject above the table if placeholder not found
const container = document.createElement('div');
container.className = 'date-nav-wrap d-flex justify-content-center align-items-center gap-2 mb-2';
container.setAttribute('data-date-nav', '');
container.innerHTML = `
<button type="button" class="mgmt-round-arrow date-prev" title="Previous dates" aria-label="Previous dates">
<i class="bi bi-chevron-left"></i>
</button>
<span class="px-2 small date-range-label">&nbsp;</span>
<button type="button" class="mgmt-round-arrow date-next" title="Next dates" aria-label="Next dates">
<i class="bi bi-chevron-right"></i>
</button>`;
const resp = tableEl.closest('.table-responsive');
if (resp && resp.parentNode) resp.parentNode.insertBefore(container, resp);
nav = container;
}
function updateNav() {
const s = parseInt(tableEl.dataset.windowStart || '0', 10);
const n = parseInt(tableEl.dataset.dateColsCount || '0', 10);
const size = parseInt(tableEl.dataset.windowSize || '4', 10);
const prevBtn = nav.querySelector('.date-prev');
const nextBtn = nav.querySelector('.date-next');
const lbl = nav.querySelector('.date-range-label');
const startIdx = Math.max(0, s);
const endIdx = Math.min(n - 1, s + size - 1);
const startDate = labels[startIdx] || '';
const endDate = labels[endIdx] || '';
lbl.textContent = n ? `${startDate} ${endDate}` : 'No dates';
prevBtn.disabled = startIdx <= 0;
nextBtn.disabled = endIdx >= n - 1;
}
function setWindowStart(newStart) {
const n = dateCols.length;
const size = parseInt(tableEl.dataset.windowSize || '4', 10);
const startClamped = Math.max(0, Math.min(newStart, Math.max(0, n - size)));
if (String(startClamped) === tableEl.dataset.windowStart) return;
tableEl.dataset.windowStart = String(startClamped);
dateCols.forEach((colIdx, i) => {
const vis = i >= startClamped && i < startClamped + size;
api.column(colIdx).visible(vis, false);
});
api.columns.adjust().draw(false);
updateNav();
}
nav.querySelector('.date-prev')?.addEventListener('click', () => {
const s = parseInt(tableEl.dataset.windowStart || '0', 10);
setWindowStart(s - 1);
});
nav.querySelector('.date-next')?.addEventListener('click', () => {
const s = parseInt(tableEl.dataset.windowStart || '0', 10);
setWindowStart(s + 1);
});
updateNav();
}
document.querySelectorAll('.attendance-grid').forEach(tbl => {
initDateNavigatorForTable(tbl);
});
});
</script>
<style>
/* highlight classes */
@keyframes flashHighlight {
0% {
background-color: #fff3cd;
}
50% {
background-color: #ffe8a1;
}
100% {
background-color: #fff3cd;
}
}
tr.row-highlight {
animation: flashHighlight 1.2s ease-in-out 1;
}
tr.row-sticky-highlight {
background-color: #fff3cd !important;
}
</style>
<?= $this->endSection() ?>