1602 lines
61 KiB
PHP
1602 lines
61 KiB
PHP
<?= $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 didn’t 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"> </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"> </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() ?>
|