Files
alrahma_sunday_school/app/Controllers/View/AttendanceController.php
T
2026-02-26 23:27:50 -05:00

3212 lines
134 KiB
PHP
Raw 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.
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\AttendanceDataModel;
use App\Models\StudentModel;
use App\Models\StudentClassModel;
use App\Models\ClassSectionModel;
use App\Models\EmergencyContactModel;
use App\Models\AttendanceRecordModel;
use CodeIgniter\Controller;
use App\Models\UserModel;
use App\Models\ConfigurationModel;
use App\Models\TeacherClassModel;
use App\Models\AttendanceDayModel;
use App\Models\TeacherAttendanceModel;
use App\Models\StaffAttendanceModel;
use App\Services\SemesterScoreService;
use App\Services\SemesterRangeService;
use App\Libraries\AttendanceAutoPublish;
use App\Models\UserRoleModel;
use RuntimeException;
use App\Models\CalendarModel;
class AttendanceController extends Controller
{
protected $db;
protected $configModel;
protected $semesterScoreService;
protected $semester;
protected $schoolYear;
protected $attendanceDataModel;
protected $studentModel;
protected $emergencyContactModel;
protected $userModel;
protected $teacherClassSection;
protected $attendanceRecordModel;
protected $classSectionModel;
protected $studentClassModel;
protected $enableAttendance;
protected $staffAttendanceModel;
protected $attendanceDayModel;
protected $calendarModel;
protected $userRoleModel;
protected $adminRoleCache = [];
protected $semesterRangeService;
public function __construct()
{
// Initialize the database connection in the constructor
$this->db = \Config\Database::connect();
$this->configModel = new ConfigurationModel();
$this->attendanceDataModel = new AttendanceDataModel();
$this->studentModel = new StudentModel();
$this->emergencyContactModel = new EmergencyContactModel();
$this->userModel = new UserModel();
$this->teacherClassSection = new TeacherClassModel();
$this->attendanceRecordModel = new AttendanceRecordModel();
$this->classSectionModel = new ClassSectionModel();
$this->studentClassModel = new StudentClassModel();
$this->attendanceDayModel = new AttendanceDayModel();
$this->staffAttendanceModel = new StaffAttendanceModel();
$this->calendarModel = model(CalendarModel::class);
$this->userRoleModel = new UserRoleModel();
$this->semesterScoreService = service('semesterScoreService');
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->enableAttendance = $this->configModel->getConfig('enable_attendance');
$this->semesterRangeService = new SemesterRangeService($this->configModel);
}
/**
* GET: filter + grid for a single date/section.
*/
public function index()
{
$semester = (string)($this->semester ?? ($this->request->getGet('semester') ?: 'Fall'));
$schoolYear = (string)($this->schoolYear ?? ($this->request->getGet('school_year') ?: date('Y') . '-' . (date('Y') + 1)));
$date = $this->request->getGet('date') ?: local_date(utc_now(), 'Y-m-d');
// IMPORTANT: this is the SECTION *CODE* (classSection.class_section_id)
$sectionCode = (int)($this->request->getGet('class_section_id') ?? 0);
// 1) Build the sections dropdown: code -> label (join by CODE, not PK)
// 1) which sections have assignments this term?
$bySection = $this->teacherClassSection->assignedBySectionForTerm($semester, $schoolYear);
$sectionCodes = array_keys($bySection);
// 2) fetch labels (names) in one query
$labels = [];
if (!empty($sectionCodes)) {
$labelRows = $this->classSectionModel
->select('class_section_id, class_section_name')
->whereIn('class_section_id', $sectionCodes)
->orderBy('class_section_id', 'ASC')
->findAll();
foreach ($labelRows as $r) {
$code = (int)$r['class_section_id'];
$name = trim((string)$r['class_section_name']);
$labels[$code] = ($name !== '') ? $name : ('Section #' . $code);
}
// ensure any missing codes still have a label
foreach ($sectionCodes as $code) {
if (!isset($labels[$code])) $labels[$code] = 'Section #' . (int)$code;
}
}
// 2) Grid (for the chosen section code & date)
$grid = [];
$locked = false;
if ($sectionCode > 0) {
// a) Assigned teachers/TAs for this section & term
$assigned = $this->teacherClassSection
->assignedForSectionTerm($sectionCode /* or $class_section_id */, $semester, $schoolYear);
// b) Pull statuses for those teachers from staff_attendance for THIS date
$teacherIds = array_map(static fn($r) => (int)$r['teacher_id'], $assigned);
$statusMap = []; // [user_id] => ['status'=>..., 'reason'=>...]
if (!empty($teacherIds)) {
$rows = $this->db->table('staff_attendance sa')
->select('sa.user_id, sa.status, sa.reason')
->where('sa.semester', $semester)
->where('sa.school_year', $schoolYear)
->where('sa.date', $date)
->whereIn('sa.user_id', $teacherIds)
->get()->getResultArray();
foreach ($rows as $r) {
$statusMap[(int)$r['user_id']] = [
'status' => $r['status'] ?? null,
'reason' => $r['reason'] ?? null,
];
}
}
// c) Build grid rows expected by the view (one row per assigned teacher/TA)
foreach ($assigned as $t) {
$tid = (int)$t['teacher_id'];
$posRaw = strtolower((string)($t['position'] ?? 'main'));
$pos = in_array($posRaw, ['main', 'ta'], true) ? $posRaw : 'main';
$name = trim(($t['firstname'] ?? '') . ' ' . ($t['lastname'] ?? '')) ?: ('User#' . $tid);
$cell = $statusMap[$tid] ?? ['status' => null, 'reason' => null];
$grid[] = [
'teacher_id' => $tid,
'name' => $name,
'position' => $pos, // 'main' | 'ta'
'status' => $cell['status'], // 'present' | 'absent' | 'late' | null
'reason' => $cell['reason'], // nullable
];
}
// d) Lock check keyed by section CODE
try {
$locked = $this->attendanceDayModel->isFinalized($sectionCode, $date, $semester, $schoolYear);
} catch (\Throwable $e) {
$locked = false;
}
}
return view('attendance/teacher_attendance_form', [
'semester' => $semester,
'schoolYear' => $schoolYear,
'date' => $date,
// Keep the GET param name as-is; it represents the SECTION CODE
'class_section_id' => $sectionCode,
'sections' => $labels, // assoc: [sectionCode => label]
'grid' => $grid,
'locked' => $locked,
]);
}
/**
* Show Update Attendance (students + staff) with No-School integration
*/
public function showUpdateAttendanceForm()
{
$user_id = (int)(session()->get('user_id') ?? 0);
if ($user_id <= 0) {
echo "User not logged in.";
return;
}
$teacher = $this->userModel->find($user_id);
$teacher_name = $teacher ? ($teacher['firstname'] . ' ' . $teacher['lastname']) : 'Unknown Teacher';
// Resolve class_section_id for this user (respect active selection)
$assignments = $this->teacherClassSection->getClassAssignmentsByUserId(
$user_id,
(string)$this->schoolYear,
(string)$this->semester
);
if (empty($assignments)) {
log_message('info', 'No classes found for teacher ID: ' . $user_id);
return redirect()->to('no-classes')
->with('message', 'You do not have an assigned class yet. Please contact the administration.');
}
// Resolve active section (supports both section code and PK for robustness)
$requestedIdRaw = $this->request->getGet('class_section_id') ?? '';
$requestedId = is_numeric($requestedIdRaw) ? (int)$requestedIdRaw : null;
$sessionId = (int)(session()->get('class_section_id') ?? 0);
$activeSection = null;
foreach ($assignments as $a) {
$cid = (int)($a['class_section_id'] ?? 0);
$pk = (int)($a['class_section_pk'] ?? 0);
if ($requestedId && ($requestedId === $cid || $requestedId === $pk)) {
$activeSection = $a;
break;
}
if ($sessionId && ($sessionId === $cid || $sessionId === $pk)) {
$activeSection = $a;
}
}
if ($activeSection === null) {
$activeSection = $assignments[0];
}
$classSectionCode = (int)($activeSection['class_section_id'] ?? 0); // section code
$classSectionPk = (int)($activeSection['class_section_pk'] ?? 0); // DB PK
// prefer the code for queries; fall back to PK only if missing
$class_section_id = $classSectionCode > 0 ? $classSectionCode : $classSectionPk;
if ($class_section_id <= 0) {
return redirect()->to('no-classes')
->with('message', 'Unable to determine your class section. Please contact administration.');
}
// Persist selection for navbar-driven navigation
session()->set('class_section_id', $class_section_id);
// Term context
$semester = (string)($this->semester ?? '');
$schoolYear = (string)($this->schoolYear ?? '');
$today = new \DateTimeImmutable(local_date(utc_now(), 'Y-m-d'));
// "Last Sunday" definition:
// - If today is Sunday => use today
// - Otherwise => use the next Sunday
$weekday = (int)$today->format('w'); // 0 (Sun) .. 6 (Sat)
$anchorSunday = ($weekday === 0) ? $today : $today->modify('next sunday');
// Build the last 3 Sundays in chronological order (oldest → newest)
$sundayDates = [
$anchorSunday->modify('-2 weeks')->format('Y-m-d'),
$anchorSunday->modify('-1 weeks')->format('Y-m-d'),
$anchorSunday->format('Y-m-d'),
];
$currentSunday = $sundayDates[2];
// Students roster for this section/year/semester (try section code, then PK fallback)
$students = $this->studentModel->getByClassAndYear($class_section_id, $schoolYear, $semester);
if (empty($students) && $classSectionPk > 0 && $classSectionPk !== $class_section_id) {
$students = $this->studentModel->getByClassAndYear($classSectionPk, $schoolYear, $semester);
}
// Build student attendance rows
$attendanceData = [];
$lockedByStudent = [];
foreach ($students as $student) {
$sid = (int)$student['id'];
// Pull attendance strictly for this section; fallback to PK only if none exists
$rowsQuery = $this->attendanceDataModel
->select('date, status, is_reported, reason, modified_by')
->where('student_id', $sid)
->where('school_year', $schoolYear)
->where('semester', $semester)
->groupStart()
->where('class_section_id', $class_section_id)
->orWhere('class_section_id', (int)$class_section_id); // tolerate PK/code mismatches
$rows = $rowsQuery
->groupEnd()
->whereIn('date', $sundayDates)
->findAll();
// Normalize status/is_reported so comparisons like === 'late' always work
$rows = $this->normalizeAttendanceEntries($rows);
if (empty($rows) && $classSectionPk > 0 && $classSectionPk !== $class_section_id) {
$rows = $this->attendanceDataModel
->select('date, status, is_reported, reason, modified_by')
->where('student_id', $sid)
->where('school_year', $schoolYear)
->where('semester', $semester)
->where('class_section_id', $classSectionPk)
->whereIn('date', $sundayDates)
->findAll();
$rows = $this->normalizeAttendanceEntries($rows);
}
// Grab the latest 3 attendance days for quick context beside today's selector
$recentRows = $this->attendanceDataModel->builder()
->select('date, status')
->where('student_id', $sid)
->where('school_year', $schoolYear)
->where('semester', $semester)
->groupStart()
->where('class_section_id', $class_section_id)
->orWhere('class_section_id', (int)$class_section_id) // tolerate PK/code mismatches
->groupEnd()
->orderBy('date', 'DESC')
->limit(3)
->get()
->getResultArray();
if (empty($recentRows) && $classSectionPk > 0 && $classSectionPk !== $class_section_id) {
$recentRows = $this->attendanceDataModel->builder()
->select('date, status')
->where('student_id', $sid)
->where('school_year', $schoolYear)
->where('semester', $semester)
->where('class_section_id', $classSectionPk)
->orderBy('date', 'DESC')
->limit(3)
->get()
->getResultArray();
}
$recentHistory = [];
foreach ($recentRows as $rr) {
$recentHistory[] = [
'date' => substr((string)($rr['date'] ?? ''), 0, 10),
'status' => strtolower((string)($rr['status'] ?? '')),
];
}
$snapshot = [];
$rowsByDate = [];
foreach ($rows as $entry) {
$d = (string)($entry['date'] ?? '');
$snapshot[$d] = $entry['status'] ?? null;
$rowsByDate[$d] = $entry;
}
$statusHistory = [];
foreach ($sundayDates as $date) {
$statusHistory[$date] = array_key_exists($date, $snapshot)
? $snapshot[$date]
: ($date === $currentSunday ? null : 'N/A');
}
$todayRow = $rowsByDate[$currentSunday] ?? null;
$lockInfo = $this->detectExternalSubmission($todayRow);
$todayReason = $todayRow['reason'] ?? null;
$attendanceData[] = [
'student' => $student,
'sunday_statuses' => $statusHistory,
'today' => $statusHistory[$currentSunday] ?? null,
'today_reason' => $todayReason,
'locked' => $lockInfo['locked'],
'locked_source' => $lockInfo['source'],
'recent_three' => $recentHistory,
];
$lockedByStudent[$sid] = [
'locked' => $lockInfo['locked'],
'source' => $lockInfo['source'],
'reason' => $todayReason,
'status' => $statusHistory[$currentSunday] ?? null,
];
}
// 🔹 Build TEACHER / TA rows from unified staff_attendance
$assigned = $this->teacherClassSection
->assignedForSectionTerm($class_section_id, $semester, $schoolYear);
$teacherRows = [];
if (!empty($assigned)) {
$wantedDates = array_values(array_filter($sundayDates));
$teacherIds = array_map(static fn($r) => (int)$r['teacher_id'], $assigned);
$statusMap = []; // [user_id][date] => ['status'=>..., 'reason'=>...]
if (!empty($teacherIds) && !empty($wantedDates)) {
$rows = $this->db->table('staff_attendance sa')
->select('sa.user_id, sa.date, sa.status, sa.reason')
->where('sa.semester', $semester)
->where('sa.school_year', $schoolYear)
->whereIn('sa.user_id', $teacherIds)
->whereIn('sa.date', $wantedDates)
->get()->getResultArray();
foreach ($rows as $r) {
$tid = (int)$r['user_id'];
$d = (string)$r['date'];
$statusMap[$tid][$d] = [
'status' => $r['status'] ?? null,
'reason' => $r['reason'] ?? null,
];
}
}
// Fetch last 3 attendance entries per teacher for quick context
$recentByTeacher = [];
if (!empty($teacherIds)) {
$recentRows = $this->db->table('staff_attendance sa')
->select('sa.user_id, sa.date, sa.status')
->where('sa.semester', $semester)
->where('sa.school_year', $schoolYear)
->whereIn('sa.user_id', $teacherIds)
->orderBy('sa.user_id', 'ASC')
->orderBy('sa.date', 'DESC')
->get()->getResultArray();
$counts = [];
foreach ($recentRows as $r) {
$uid = (int)($r['user_id'] ?? 0);
if ($uid <= 0) continue;
$counts[$uid] = ($counts[$uid] ?? 0) + 1;
if ($counts[$uid] > 3) continue;
$recentByTeacher[$uid][] = [
'date' => substr((string)($r['date'] ?? ''), 0, 10),
'status' => strtolower((string)($r['status'] ?? '')),
];
}
}
foreach ($assigned as $t) {
$tid = (int)$t['teacher_id'];
$pos = strtolower((string)($t['position'] ?? 'main'));
$pos = in_array($pos, ['main', 'ta'], true) ? $pos : 'main';
$full = trim(($t['firstname'] ?? '') . ' ' . ($t['lastname'] ?? ''));
$name = $full !== '' ? $full : ('User#' . $tid);
$history = [];
foreach ($sundayDates as $date) {
$history[$date] = isset($statusMap[$tid][$date]['status'])
? $statusMap[$tid][$date]['status']
: ($date === $currentSunday ? null : 'N/A');
}
$teacherRows[] = [
'teacher_id' => $tid,
'position' => $pos,
'name' => $name,
'sunday_statuses' => $history,
'today' => $history[$currentSunday] ?? null,
'recent_three' => $recentByTeacher[$tid] ?? [],
];
}
}
// 🔹 Fetch No-School events for the Sunday columns
$wantedDates = array_values(array_filter($sundayDates));
$noSchoolDays = [];
if (!empty($wantedDates)) {
$rows = $this->calendarModel
->where('no_school', 1)
->whereIn('date', $wantedDates)
// If your calendar isnt term-scoped, remove these 2 lines:
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
foreach ($rows as $r) {
$d = (string)$r['date'];
$noSchoolDays[$d] = [
'title' => $r['title'] ?? 'No School',
'description' => $r['description'] ?? '',
];
}
}
$data = [
'teacher_name' => $teacher_name,
'students' => $attendanceData,
'teachers' => $teacherRows,
'class_section_id' => $class_section_id,
'sunday_dates' => $sundayDates,
'current_sunday' => $currentSunday,
'enableAttendance' => $this->enableAttendance,
'canEdit' => ((int)$this->enableAttendance === 1),
'class_id' => ((int)$this->classSectionModel->getClassId($class_section_id)),
'no_school_days' => $noSchoolDays,
'today' => $currentSunday,
'locked_by_student' => $lockedByStudent,
];
return view('/teacher/showupdate_attendance', $data);
}
public function noClasses()
{
return view('/teacher/no_classes');
}
// This method renders the attendance page with the buttons and the student list
public function listAttendance()
{
$data = $this->buildDailyAttendanceData();
return view('administrator/daily_attendance', $data);
}
public function dailyAttendanceAnalysis()
{
$data = $this->buildDailyAttendanceData();
return view('administrator/daily_attendance_analysis', $data);
}
private function buildDailyAttendanceData(): array
{
// Current term (allow override via GET; default to configured/current)
$termSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? '');
$termYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear ?? '');
if ($termSemester === '' && $this->semester) {
$termSemester = (string)$this->semester;
}
if ($termYear === '' && $this->schoolYear) {
$termYear = (string)$this->schoolYear;
}
// Fetch only sections that actually have students in the selected term.
// If there are no assignments yet for the requested semester (e.g., Spring roster not populated),
// fall back to the school year only so the roster still renders and can be used for the new term.
$classSections = $this->classSectionModel
->select('classSection.id, classSection.class_id, classSection.class_section_id, classSection.class_section_name')
->join('student_class sc', 'sc.class_section_id = classSection.class_section_id', 'inner')
->where('sc.school_year', $termYear)
->groupBy(['classSection.id', 'classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name'])
->findAll();
$useRosterFallback = false;
if (empty($classSections) && $termYear !== '') {
$classSections = $this->classSectionModel
->select('classSection.id, classSection.class_id, classSection.class_section_id, classSection.class_section_name')
->join('student_class sc', 'sc.class_section_id = classSection.class_section_id', 'inner')
->where('sc.school_year', $termYear)
->groupBy(['classSection.id', 'classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name'])
->findAll();
$useRosterFallback = !empty($classSections);
}
$attendanceData = [];
$attendanceRecord = [];
$studentsBySection = [];
$grades = [];
$datesBySection = [];
$studentSectionMap = [];
$studentSchoolMap = [];
$sectionCounts = $this->studentClassModel->getStudentCountsBySection($termYear);
foreach ($classSections as $classSection) {
$secPk = (int)($classSection['id'] ?? 0);
$secCodeRaw = (string)($classSection['class_section_id'] ?? '');
$secCode = $secCodeRaw !== '' ? $secCodeRaw : (string)$secPk; // preserve non-numeric codes (e.g., "Arabic-1")
$classId = (int)$classSection['class_id'];
$studentsBySection[$secCode] = [];
$datesBySection[$secCode] = [];
$attendanceData[$secCode] = [];
$attendanceRecord[$secCode] = [];
// Keep grade grouping only if section has students
// Students may be stored against either section code or PK; try both
$countForCode = (int)($sectionCounts[$secCode] ?? 0);
$countForPk = (int)($sectionCounts[$secPk] ?? 0);
$sectionHasStudents = ($countForCode + $countForPk) > 0;
$students = $this->studentClassModel->getClassStudents($secCode, $termYear);
if (!$students && $secPk && (string)$secPk !== (string)$secCode) {
$students = $this->studentClassModel->getClassStudents($secPk, $termYear);
}
if (!$sectionHasStudents || !$students) {
continue;
}
$hasRoster = false;
foreach ($students as $sc) {
$studentId = (int)$sc['student_id'];
$student = $this->studentModel
->select('id, firstname, lastname, school_id')
->where('id', $studentId)
->where('is_active', 1)
->first();
if (!$student) continue;
$studentsBySection[$secCode][] = $student;
$hasRoster = true;
$studentSectionMap[$studentId] = $secCode;
$studentSchoolMap[$studentId] = (string)($student['school_id'] ?? '');
// ---------- ATTENDANCE DATA (history) ----------
$qb = $this->attendanceDataModel
->where('student_id', $studentId)
->groupStart()
->where('class_section_id', $secCode);
// Fallback: historical rows may have used the PK instead of the code
if ($secPk && (string)$secPk !== (string)$secCode) {
$qb->orWhere('class_section_id', $secPk);
}
$qb->groupEnd();
// Apply term filters only if present (otherwise show all history)
if ($termSemester !== '') $qb->where('semester', $termSemester);
if ($termYear !== '') $qb->where('school_year', $termYear);
$entries = $qb->orderBy('date', 'asc')->findAll();
$entries = $this->normalizeAttendanceEntries($entries);
$attendanceData[$secCode][$studentId] = $entries;
// ---------- ATTENDANCE SUMMARY ----------
$qbSum = $this->attendanceRecordModel
->where('student_id', $studentId)
->groupStart()
->where('class_section_id', $secCode);
if ($secPk && (string)$secPk !== (string)$secCode) {
$qbSum->orWhere('class_section_id', $secPk);
}
$qbSum->groupEnd();
if ($termSemester !== '') $qbSum->where('semester', $termSemester);
if ($termYear !== '') $qbSum->where('school_year', $termYear);
$summary = $qbSum->first();
$attendanceRecord[$secCode][$studentId] = $summary ?? [
'total_presence' => 0,
'total_late' => 0,
'total_absence' => 0,
'total_attendance' => 0,
];
}
if (!$hasRoster) {
unset($studentsBySection[$secCode], $datesBySection[$secCode], $attendanceData[$secCode], $attendanceRecord[$secCode]);
continue;
}
$grades[$classId][] = $classSection;
}
// ------ Merge any parent-submitted reports that are missing from attendance_data -------
if (!empty($studentSectionMap)) {
$builder = $this->db->table('parent_attendance_reports par')
->select([
'par.student_id',
'par.class_section_id',
'par.report_date',
'par.type',
'par.reason',
'par.arrival_time',
'par.dismiss_time',
])
->whereIn('par.student_id', array_keys($studentSectionMap))
->orderBy('par.report_date', 'ASC')
// Only pull absence/late; early dismissals are managed on the dedicated page.
->whereIn('par.type', ['absent', 'late']);
if ($termYear !== '') {
$builder->where('par.school_year', $termYear);
}
if ($termSemester !== '') {
$builder->where('par.semester', $termSemester);
}
$parentReports = $builder->get()->getResultArray();
foreach ($parentReports as $report) {
$studentId = (int) ($report['student_id'] ?? 0);
$reportDate = substr((string) ($report['report_date'] ?? ''), 0, 10);
if ($studentId <= 0 || $reportDate === '') {
continue;
}
$sectionIdRaw = $studentSectionMap[$studentId] ?? ($report['class_section_id'] ?? '');
$sectionKey = null;
if ($sectionIdRaw !== '' && isset($studentsBySection[$sectionIdRaw])) {
$sectionKey = $sectionIdRaw;
} elseif (is_numeric($sectionIdRaw)) {
$secInt = (int)$sectionIdRaw;
if (isset($studentsBySection[$secInt])) {
$sectionKey = $secInt;
}
}
if ($sectionKey === null) {
continue;
}
$existingEntries = $attendanceData[$sectionKey][$studentId] ?? [];
$alreadyHas = false;
foreach ($existingEntries as $entry) {
if (($entry['date'] ?? '') === $reportDate) {
$alreadyHas = true;
break;
}
}
if ($alreadyHas) {
continue;
}
$typeRaw = trim((string) ($report['type'] ?? ''));
$status = match ($typeRaw) {
'absent' => 'absent',
'late' => 'late',
// Early dismissals should not appear on the daily attendance grid.
'early_dismissal' => null,
default => null,
};
if ($status === null) {
continue;
}
$label = str_replace('_', ' ', ucfirst($typeRaw !== '' ? $typeRaw : 'report'));
$reasonParts = ['Parent reported ' . $label];
if ($typeRaw === 'late' && ($report['arrival_time'] ?? '') !== '') {
$reasonParts[] = 'ETA ' . substr((string) $report['arrival_time'], 0, 5);
}
if ($typeRaw === 'early_dismissal' && ($report['dismiss_time'] ?? '') !== '') {
$reasonParts[] = 'Dismiss ' . substr((string) $report['dismiss_time'], 0, 5);
}
$extraReason = trim((string) ($report['reason'] ?? ''));
if ($extraReason !== '') {
$reasonParts[] = $extraReason;
}
$reasonText = implode(' — ', $reasonParts);
$attendanceData[$sectionKey][$studentId][] = [
'date' => $reportDate,
'status' => $status,
'is_reported' => 'yes',
'reason' => $reasonText !== '' ? $reasonText : null,
'source' => 'parent_report',
];
$datesBySection[$sectionKey][$reportDate] = true;
}
}
foreach ($attendanceData as $secId => &$studentEntries) {
foreach ($studentEntries as &$entries) {
usort($entries, static fn($a, $b) => strcmp((string) ($a['date'] ?? ''), (string) ($b['date'] ?? '')));
}
}
unset($studentEntries, $entries);
foreach ($datesBySection as $sec => $set) {
$arr = array_keys($set);
sort($arr, SORT_STRING);
$datesBySection[$sec] = $arr;
}
// Prune grade sections that ended up with no roster (extra safety for UI)
foreach ($grades as $cid => $sections) {
$filtered = [];
foreach ($sections as $section) {
$secKey = (string)($section['class_section_id'] ?? ($section['id'] ?? ''));
if ($secKey !== '' && !empty($studentsBySection[$secKey])) {
$filtered[] = $section;
}
}
if (empty($filtered)) {
unset($grades[$cid]);
} else {
$grades[$cid] = $filtered;
}
}
// Sort grades (class_id) numerically
ksort($grades, SORT_NUMERIC);
$schoolYearForRange = $termYear !== '' ? $termYear : (string)($this->schoolYear ?? '');
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYearForRange);
$semesterNorm = $this->semesterRangeService->normalizeSemester($termSemester);
if ($semesterNorm !== '' && $schoolYearForRange !== '') {
$semRange = $this->semesterRangeService->getSemesterRange($schoolYearForRange, $semesterNorm);
if ($semRange) {
[$rangeStart, $rangeEnd] = $semRange;
}
}
$dateList = [];
try {
$start = new \DateTimeImmutable($rangeStart);
$end = new \DateTimeImmutable($rangeEnd);
$cursor = $start;
$w = (int)$cursor->format('w');
if ($w !== 0) {
$cursor = $cursor->modify('next sunday');
}
while ($cursor <= $end) {
$dateList[] = $cursor->format('Y-m-d');
$cursor = $cursor->modify('+7 days');
}
} catch (\Throwable $e) {
$dateList = [];
}
$noSchoolDays = [];
$events = [];
try {
$events = $this->calendarModel->getEvents();
} catch (\Throwable $e) {
$events = [];
}
foreach ($events as $event) {
$d = substr((string)($event['date'] ?? ''), 0, 10);
if ($d === '' || empty($event['no_school'])) {
continue;
}
if ($d < $rangeStart || $d > $rangeEnd) {
continue;
}
$eventYear = trim((string)($event['school_year'] ?? ''));
if ($schoolYearForRange !== '' && $eventYear !== '' && $eventYear !== $schoolYearForRange) {
continue;
}
$noSchoolDays[$d] = true;
}
// Total passed attendance days up to this Sunday (inclusive), excluding no-school days.
$anchorSundayYmd = '';
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');
$anchorSundayYmd = $weekday === 0
? $nowDate->format('Y-m-d')
: $nowDate->modify('next sunday')->format('Y-m-d');
$passedDatesSet = [];
if (!empty($dateList) && $anchorSundayYmd !== '') {
foreach ($dateList as $d) {
if ($d <= $anchorSundayYmd && empty($noSchoolDays[$d])) {
$passedDatesSet[$d] = true;
}
}
}
$totalPassedDays = count($passedDatesSet);
if ($totalPassedDays > 0 && $termYear !== '' && $termSemester !== '') {
foreach ($attendanceData as $secCode => $studentEntries) {
foreach ($studentEntries as $studentId => $entries) {
$statusByDate = [];
foreach ($entries as $entry) {
$d = substr((string)($entry['date'] ?? ''), 0, 10);
if ($d === '' || empty($passedDatesSet[$d])) {
continue;
}
$st = strtolower(trim((string)($entry['status'] ?? '')));
if (!in_array($st, ['present', 'absent', 'late'], true)) {
continue;
}
$statusByDate[$d] = $st;
}
$p = 0;
$l = 0;
$a = 0;
foreach ($statusByDate as $st) {
if ($st === 'present') {
$p++;
} elseif ($st === 'late') {
$l++;
} elseif ($st === 'absent') {
$a++;
}
}
$sum = $p + $l + $a;
$record = $attendanceRecord[$secCode][$studentId] ?? [];
$storedSum = (int)($record['total_presence'] ?? 0)
+ (int)($record['total_late'] ?? 0)
+ (int)($record['total_absence'] ?? 0);
if ($storedSum !== $totalPassedDays && $storedSum !== $sum) {
$schoolId = $studentSchoolMap[$studentId] ?? '';
$updated = $this->upsertAttendanceRecordTotals(
$studentId,
(string)$secCode,
$schoolId,
$termSemester,
$termYear,
$p,
$l,
$a,
$sum
);
if ($updated) {
$attendanceRecord[$secCode][$studentId]['total_presence'] = $p;
$attendanceRecord[$secCode][$studentId]['total_late'] = $l;
$attendanceRecord[$secCode][$studentId]['total_absence'] = $a;
$attendanceRecord[$secCode][$studentId]['total_attendance'] = $sum;
}
}
}
}
}
// Resolve current admin full name from users table via session user_id
$adminName = '';
try {
$uid = (int) (session()->get('user_id') ?? 0);
if ($uid > 0) {
$u = $this->userModel->select('firstname, lastname')->find($uid);
if ($u) {
$adminName = trim((string)($u['firstname'] ?? '') . ' ' . (string)($u['lastname'] ?? ''));
}
}
} catch (\Throwable $e) { /* ignore */ }
return [
'attendanceData' => $attendanceData,
'attendanceRecord' => $attendanceRecord,
'studentsBySection' => $studentsBySection,
'grades' => $grades,
'datesBySection' => $datesBySection,
'dateList' => $dateList,
'noSchoolDays' => $noSchoolDays,
'totalPassedDays' => $totalPassedDays ?? 0,
// Expose current term info for slip printing and UI
'semester' => (string)$termSemester,
'schoolYear' => (string)$termYear,
'currentAdminName' => $adminName,
];
}
// API: daily attendance data payload (used by admin management view)
public function dailyData()
{
$attendanceData = [];
$attendanceRecord = [];
$studentsBySection = [];
$grades = [];
$datesBySection = [];
$termSemester = (string)($this->request->getGet('semester') ?? $this->semester ?? '');
$termYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear ?? '');
if ($termSemester === '' && $this->semester) {
$termSemester = (string)$this->semester;
}
if ($termYear === '' && $this->schoolYear) {
$termYear = (string)$this->schoolYear;
}
// Fetch only sections that actually have students in the selected term.
// If no roster exists yet for the requested semester, fall back to school-year-only so the grid still shows students.
$classSections = $this->classSectionModel
->select('classSection.id, classSection.class_id, classSection.class_section_id, classSection.class_section_name')
->join('student_class sc', 'sc.class_section_id = classSection.class_section_id', 'inner')
->where('sc.school_year', $termYear)
->groupBy(['classSection.id', 'classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name'])
->findAll();
$useRosterFallback = false;
if (empty($classSections) && $termYear !== '') {
$classSections = $this->classSectionModel
->select('classSection.id, classSection.class_id, classSection.class_section_id, classSection.class_section_name')
->join('student_class sc', 'sc.class_section_id = classSection.class_section_id', 'inner')
->where('sc.school_year', $termYear)
->groupBy(['classSection.id', 'classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name'])
->findAll();
$useRosterFallback = !empty($classSections);
}
$sectionCounts = $this->studentClassModel->getStudentCountsBySection(
$termYear,
$useRosterFallback ? null : $termSemester
);
foreach ($classSections as $classSection) {
$secPk = (int)($classSection['id'] ?? 0);
$secCodeRaw = (string)($classSection['class_section_id'] ?? '');
$secCode = $secCodeRaw !== '' ? $secCodeRaw : (string)$secPk;
$classId = (int)$classSection['class_id'];
$studentsBySection[$secCode] = [];
$attendanceData[$secCode] = [];
$attendanceRecord[$secCode] = [];
$datesBySection[$secCode] = [];
$countForCode = (int)($sectionCounts[$secCode] ?? 0);
$countForPk = (int)($sectionCounts[$secPk] ?? 0);
$sectionHasStudents = ($countForCode + $countForPk) > 0;
$students = $this->studentClassModel->getClassStudents($secCode, $termYear);
if (!$students && $secPk && (string)$secPk !== (string)$secCode) {
$students = $this->studentClassModel->getClassStudents($secPk, $termYear);
}
if (!$sectionHasStudents || !$students) {
continue;
}
$hasRoster = false;
foreach ($students as $sc) {
$studentId = (int)$sc['student_id'];
$student = $this->studentModel
->select('id, firstname, lastname, school_id')
->find($studentId);
if (!$student) continue;
$studentsBySection[$secCode][] = $student;
$hasRoster = true;
// Attendance history
$qb = $this->attendanceDataModel
->where('student_id', $studentId)
->groupStart()
->where('class_section_id', $secCode);
if ($secPk && (string)$secPk !== (string)$secCode) {
$qb->orWhere('class_section_id', $secPk);
}
$qb->groupEnd();
if ($termSemester !== '') $qb->where('semester', $termSemester);
if ($termYear !== '') $qb->where('school_year', $termYear);
$entries = $qb->orderBy('date', 'asc')->findAll();
$entries = $this->normalizeAttendanceEntries($entries);
$attendanceData[$secCode][$studentId] = $entries;
foreach ($entries as $e) {
$d = (string)($e['date'] ?? '');
if ($d !== '') $datesBySection[$secCode][$d] = true;
}
// Attendance summary
$qbSum = $this->attendanceRecordModel
->where('student_id', $studentId)
->groupStart()
->where('class_section_id', $secCode);
if ($secPk && (string)$secPk !== (string)$secCode) {
$qbSum->orWhere('class_section_id', $secPk);
}
$qbSum->groupEnd();
if ($termSemester !== '') $qbSum->where('semester', $termSemester);
if ($termYear !== '') $qbSum->where('school_year', $termYear);
$summary = $qbSum->first();
$attendanceRecord[$secCode][$studentId] = $summary ?? [
'total_presence' => 0,
'total_late' => 0,
'total_absence' => 0,
'total_attendance' => 0,
];
}
if (!$hasRoster) {
unset($studentsBySection[$secCode], $attendanceData[$secCode], $attendanceRecord[$secCode], $datesBySection[$secCode]);
continue;
}
$grades[$classId][] = $classSection;
}
// Normalize dates arrays
foreach ($datesBySection as $sec => $set) {
$arr = array_keys($set);
sort($arr, SORT_STRING);
$datesBySection[$sec] = $arr;
}
ksort($grades, SORT_NUMERIC);
// Prune grade sections that ended up with no roster (extra safety for UI)
foreach ($grades as $cid => $sections) {
$filtered = [];
foreach ($sections as $section) {
$secKey = (string)($section['class_section_id'] ?? ($section['id'] ?? ''));
if ($secKey !== '' && !empty($studentsBySection[$secKey])) {
$filtered[] = $section;
}
}
if (empty($filtered)) {
unset($grades[$cid]);
} else {
$grades[$cid] = $filtered;
}
}
$payload = [
'attendanceData' => $attendanceData,
'attendanceRecord' => $attendanceRecord,
'studentsBySection' => $studentsBySection,
'grades' => $grades,
'datesBySection' => $datesBySection,
'semester' => $termSemester,
'school_year' => $termYear,
'csrfHash' => csrf_hash(),
];
return $this->response->setJSON($payload);
}
/**
* Normalize attendance rows so status/flags are consistent for comparisons.
*/
private function normalizeAttendanceEntries(array $entries): array
{
foreach ($entries as &$row) {
$row['status'] = $this->normalizeStatus($row['status'] ?? '');
$reportedRaw = $row['is_reported'] ?? ($row['reported'] ?? '');
$row['is_reported'] = $this->normalizeReportedFlag($reportedRaw);
}
return $entries;
}
private function normalizeStatus(?string $status): string
{
$s = strtolower(trim((string)$status));
return in_array($s, ['present', 'absent', 'late'], true) ? $s : $s;
}
private function normalizeReportedFlag($flag): string
{
$v = strtolower(trim((string)$flag));
return in_array($v, ['yes', '1', 'true', 'y', 'on'], true) ? 'yes' : 'no';
}
private function updateAttendanceRecord($classSectionId, $studentId, $schoolId, $newStatus, $semester, $schoolYear, $oldStatus)
{
$newStatus = strtolower($newStatus);
$oldStatus = strtolower($oldStatus ?? '');
$record = $this->attendanceRecordModel->where([
'student_id' => $studentId,
'semester' => $semester,
'school_year' => $schoolYear
])->first();
if (!$record) return;
// Initialize counters from record, defaulting to 0 if null
$total_presence = (int) ($record['total_presence'] ?? 0);
$total_absence = (int) ($record['total_absence'] ?? 0);
$total_late = (int) ($record['total_late'] ?? 0);
// Decrease old status count
if ($oldStatus === 'present') {
$total_presence--;
} elseif ($oldStatus === 'absent') {
$total_absence--;
} elseif ($oldStatus === 'late') {
$total_late--;
}
// Increase new status count
if ($newStatus === 'present') {
$total_presence++;
} elseif ($newStatus === 'absent') {
$total_absence++;
} elseif ($newStatus === 'late') {
$total_late++;
}
$updates = [];
$updates['total_presence'] = max(0, $total_presence);
$updates['total_absence'] = max(0, $total_absence);
$updates['total_late'] = max(0, $total_late);
$updates['updated_at'] = utc_now();
$updates['modified_by'] = session()->get('user_id');
$this->attendanceRecordModel->update($record['id'], $updates);
}
private function upsertAttendanceRecordTotals(
int $studentId,
string $classSectionId,
string $schoolId,
string $semester,
string $schoolYear,
int $totalPresence,
int $totalLate,
int $totalAbsence,
int $totalAttendance
): bool {
if ($semester === '' || $schoolYear === '') {
return false;
}
$record = $this->attendanceRecordModel->where([
'student_id' => $studentId,
'semester' => $semester,
'school_year' => $schoolYear,
])->first();
$now = utc_now();
$payload = [
'total_presence' => max(0, $totalPresence),
'total_late' => max(0, $totalLate),
'total_absence' => max(0, $totalAbsence),
'total_attendance' => max(0, $totalAttendance),
'updated_at' => $now,
'modified_by' => session()->get('user_id'),
];
if ($record) {
return (bool) $this->attendanceRecordModel->update((int) $record['id'], $payload);
}
$payload['class_section_id'] = (int) $classSectionId;
$payload['student_id'] = $studentId;
$payload['school_id'] = $schoolId;
$payload['semester'] = $semester;
$payload['school_year'] = $schoolYear;
$payload['created_at'] = $now;
return (bool) $this->attendanceRecordModel->insert($payload);
}
private function updateAttendanceData($attendanceId, $newStatus, $newReason = null, $reported = null)
{
$status = strtolower((string) $newStatus);
$data = [
'status' => $status,
'reason' => ($newReason !== null && $newReason !== '') ? $newReason : null,
'modified_by' => (int) (session()->get('user_id') ?? 0),
'updated_at' => utc_now(),
];
$reportedLog = 'unchanged';
if ($reported !== null) {
$reported = in_array(strtolower((string) $reported), ['yes', 'no'], true) ? strtolower($reported) : 'no';
$data['is_reported'] = $reported; // ← correct column name
$reportedLog = $reported;
}
try {
$ok = $this->attendanceDataModel->update($attendanceId, $data);
if (!$ok) {
log_message('error', 'Failed to UPDATE attendance_data ID {id}: {errs}', [
'id' => $attendanceId,
'errs' => json_encode($this->attendanceDataModel->errors(), JSON_UNESCAPED_SLASHES),
]);
return false;
}
log_message('info', "Updated attendance_data ID {$attendanceId} with status={$status}, reported={$reportedLog}.");
return true;
} catch (\Throwable $e) {
log_message('error', "Failed to update attendance_data ID {$attendanceId}: " . $e->getMessage());
return false;
}
}
protected function insertAttendanceData(
$classSectionId,
$studentId,
$studentSchoolId,
$status,
$reason,
$reported,
$semester,
$schoolYear,
$date,
$classId
) {
$status = strtolower((string) $status);
$reported = in_array(strtolower((string) $reported), ['yes', 'no'], true) ? strtolower($reported) : 'no';
$data = [
'class_id' => (int) $classId, // ← required by schema
'class_section_id' => (int) $classSectionId,
'student_id' => (int) $studentId,
'school_id' => (string) $studentSchoolId,
'status' => $status,
'reason' => ($reason !== null && $reason !== '') ? $reason : null,
'is_reported' => $reported, // ← correct column name
'is_notified' => 'no', // default: not notified
'semester' => (string) $semester,
'school_year' => (string) $schoolYear,
'date' => (string) $date,
'modified_by' => (int) (session()->get('user_id') ?? 0),
'created_at' => utc_now(),
'updated_at' => utc_now(),
];
try {
// pass false to get a boolean result (not insertId)
$ok = $this->attendanceDataModel->insert($data, false);
if (!$ok) {
log_message('error', 'Failed to INSERT attendance_data for student {sid} on {date}: {errs}', [
'sid' => $studentId,
'date' => $date,
'errs' => json_encode($this->attendanceDataModel->errors(), JSON_UNESCAPED_SLASHES),
]);
return false;
}
return true;
} catch (\Throwable $e) {
log_message('error', "Insert into attendance_data failed for student_id {$studentId}: " . $e->getMessage());
return false;
}
}
/**
* Determine if an attendance row was submitted by a parent or an admin-like user.
*/
private function detectExternalSubmission(?array $row): array
{
if (empty($row) || !is_array($row)) {
return ['locked' => false, 'source' => null];
}
$reportedRaw = strtolower((string)($row['is_reported'] ?? ''));
$reason = strtolower((string)($row['reason'] ?? ''));
$isParent = in_array($reportedRaw, ['yes', '1', 'true', 'y'], true)
|| (strpos($reason, 'parent-reported') !== false)
|| (strpos($reason, 'parent reported') !== false);
if ($isParent) {
return ['locked' => true, 'source' => 'parent'];
}
$modifierId = (int)($row['modified_by'] ?? 0);
if ($modifierId > 0 && $this->isAdminLikeUserId($modifierId)) {
return ['locked' => true, 'source' => 'admin'];
}
return ['locked' => false, 'source' => null];
}
/**
* True if the given user ID carries an admin-like role (anything other than teacher/parent/guest/assistant).
*/
private function isAdminLikeUserId(int $userId): bool
{
if ($userId <= 0) {
return false;
}
if (array_key_exists($userId, $this->adminRoleCache)) {
return (bool)$this->adminRoleCache[$userId];
}
try {
$roles = $this->userRoleModel->getRolesByUserId($userId);
} catch (\Throwable $e) {
return $this->adminRoleCache[$userId] = false;
}
if (empty($roles)) {
return $this->adminRoleCache[$userId] = false;
}
$normalize = static function ($v) {
$v = strtolower((string)$v);
return str_replace([' ', '-'], '_', $v);
};
$excluded = ['parent', 'guest', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta'];
foreach ($roles as $r) {
$name = $normalize($r['role_name'] ?? '');
if ($name === '') {
continue;
}
if (!in_array($name, $excluded, true)) {
return $this->adminRoleCache[$userId] = true;
}
}
return $this->adminRoleCache[$userId] = false;
}
//update attendance Mgnt
public function updateAttendanceManagement()
{
$isAjax = $this->request->isAJAX() || $this->request->getPost('ajax');
// --- collect input ---
$classSectionId = (int)$this->request->getPost('class_section_id');
$studentId = (int)$this->request->getPost('student_id');
$studentSchoolId = $this->request->getPost('school_id');
$status = strtolower((string)$this->request->getPost('status'));
$date = $this->request->getPost('date');
$reason = $this->request->getPost('reason');
$classId = $this->request->getPost('class_id'); // optional
// reported yes/no (present always no)
$reportedRaw = strtolower((string)($this->request->getPost('is_reported') ?? ''));
$is_reported = ($status === 'present')
? 'no'
: (in_array($reportedRaw, ['1', 'yes', 'y', 'true', 'on'], true) ? 'yes' : 'no');
// required fields (DO NOT require class_id)
if (empty($studentId) || empty($status) || empty($date) || empty($classSectionId)) {
$msg = 'Required data missing.';
return $isAjax
? $this->response->setStatusCode(400)->setJSON(['ok' => false, 'message' => $msg, 'csrfHash' => csrf_hash()])
: redirect()->back()->with('status', 'error')->with('message', $msg);
}
// ---------- NEW: Policy gate for admins/teachers on the management page ----------
$semesterIn = (string)($this->request->getPost('semester') ?? '');
$schoolYearIn = (string)($this->request->getPost('school_year') ?? '');
$semester = $semesterIn !== '' ? $semesterIn : (string)($this->semester ?? '');
$schoolYear = $schoolYearIn !== '' ? $schoolYearIn : (string)($this->schoolYear ?? '');
$userId = (int) (session()->get('user_id') ?? 0);
$userRoles = (array) (session()->get('roles') ?? []);
$permissions = (array) (session()->get('permissions') ?? []);
$currentUser = ['id' => $userId, 'roles' => $userRoles, 'permissions' => $permissions];
// Load day row (create a synthetic 'draft' if missing so admins can start correcting)
if (method_exists($this->attendanceDayModel, 'findBySectionDate')) {
$dayRow = $this->attendanceDayModel->findBySectionDate($classSectionId, $date, $semester, $schoolYear);
} else {
$dayRow = $this->db->table('attendance_day')
->where('class_section_id', $classSectionId)
->where('date', $date)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get()->getFirstRow('array');
}
$dayRowForPolicy = $dayRow ?: [
'status' => 'draft',
'class_section_id' => $classSectionId,
'date' => $date,
'semester' => $semester,
'school_year' => $schoolYear,
];
if (!\App\Libraries\AttendancePolicy::canEditDay($dayRowForPolicy, $currentUser)) {
$msg = 'Attendance is locked for your role.';
return $isAjax
? $this->response->setStatusCode(403)->setJSON(['ok' => false, 'message' => $msg, 'csrfHash' => csrf_hash()])
: redirect()->back()->with('status', 'error')->with('message', $msg);
}
// ---------------------------------------------------------------------------------
// ensure school_id
if (empty($studentSchoolId)) {
$studentRow = $this->studentModel->select('school_id')->find($studentId);
if (!$studentRow || empty($studentRow['school_id'])) {
$msg = 'Student school ID not found.';
return $isAjax
? $this->response->setStatusCode(400)->setJSON(['ok' => false, 'message' => $msg, 'csrfHash' => csrf_hash()])
: redirect()->back()->with('status', 'error')->with('message', $msg);
}
$studentSchoolId = $studentRow['school_id'];
}
// existing row?
$existing = $this->attendanceDataModel->where([
'student_id' => $studentId,
'date' => $date,
'semester' => $semester,
'school_year' => $schoolYear,
])->first();
if (!$existing && $date !== '') {
$existing = $this->attendanceDataModel->builder()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where("DATE(`date`)", $date, false)
->get()->getFirstRow('array');
}
if (!$existing && $studentSchoolId && $date !== '') {
$existing = $this->attendanceDataModel->builder()
->where('school_id', $studentSchoolId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where("DATE(`date`)", $date, false)
->get()->getFirstRow('array');
}
if (!$existing && $date !== '') {
$existing = $this->attendanceDataModel->builder()
->where('student_id', $studentId)
->where("DATE(`date`)", $date, false)
->get()->getFirstRow('array');
}
if (!$existing && $studentSchoolId && $date !== '') {
$existing = $this->attendanceDataModel->builder()
->where('school_id', $studentSchoolId)
->where("DATE(`date`)", $date, false)
->get()->getFirstRow('array');
}
$now = utc_now();
$row = [
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $studentSchoolId,
'status' => $status,
'reason' => $reason ?: null,
'semester' => $semester,
'school_year' => $schoolYear,
'date' => $date,
'modified_by' => $userId,
'updated_at' => $now,
'class_id' => $classId,
];
$hasIsReported = $this->db->fieldExists('is_reported', 'attendance_data');
$hasLegacyReported = $this->db->fieldExists('reported', 'attendance_data');
$hasIsNotified = $this->db->fieldExists('is_notified', 'attendance_data');
if ($hasIsReported) {
$row['is_reported'] = $is_reported;
}
if ($hasLegacyReported) {
$row['reported'] = ($is_reported === 'yes') ? 1 : 0;
}
if ($hasIsNotified) {
$row['is_notified'] = 'no';
}
try {
if ($existing) {
$row['created_at'] = $existing['created_at'];
if (!$this->attendanceDataModel->update($existing['id'], $row)) {
$errors = $this->attendanceDataModel->errors();
$msg = 'Failed to update attendance: ' . json_encode($errors);
log_message('error', $msg);
return $isAjax
? $this->response->setStatusCode(422)->setJSON(['ok' => false, 'message' => $msg, 'csrfHash' => csrf_hash()])
: redirect()->back()->with('status', 'error')->with('message', $msg);
}
if ($status !== $existing['status']) {
try {
$this->updateAttendanceRecord(
$classSectionId,
$studentId,
$studentSchoolId,
$status,
$semester,
$schoolYear,
$existing['status']
);
} catch (\Throwable $e) {
log_message('error', 'updateAttendanceRecord error: ' . $e->getMessage());
}
}
} else {
$row['created_at'] = $now;
if (!$this->attendanceDataModel->insert($row)) {
$errors = $this->attendanceDataModel->errors();
$msg = 'Failed to save attendance: ' . json_encode($errors);
log_message('error', $msg);
return $isAjax
? $this->response->setStatusCode(422)->setJSON(['ok' => false, 'message' => $msg, 'csrfHash' => csrf_hash()])
: redirect()->back()->with('status', 'error')->with('message', $msg);
}
try {
$this->addNewAttendanceRecord(
$classSectionId,
$studentId,
$studentSchoolId,
$status,
$semester,
$schoolYear
);
} catch (\Throwable $e) {
log_message('error', 'addNewAttendanceRecord error: ' . $e->getMessage());
}
}
// (Optional) ensure there is an anchor day row for this edit, so later publish works
if (!$dayRow) {
// create draft anchor if missing
$this->db->table('attendance_day')->insert([
'class_section_id' => $classSectionId,
'date' => $date,
'status' => 'draft',
'semester' => $semester,
'school_year' => $schoolYear,
'created_at' => $now,
'updated_at' => $now,
]);
}
// optional score updates (never bubble)
try {
$studentUserInfo = $this->studentModel->getStudentInfoByClassSectionId(
$classSectionId,
$semester,
$schoolYear
);
$this->semesterScoreService->updateScoresForStudents($studentUserInfo);
} catch (\Throwable $e) {
log_message('error', 'semesterScoreService->updateScoresForStudents error: ' . $e->getMessage());
}
if ($isAjax) {
return $this->response->setJSON(['ok' => true, 'csrfHash' => csrf_hash()]);
}
$params = ['class_section_id' => $classSectionId];
if (!empty($classId)) $params['class_id'] = $classId;
$query = http_build_query($params);
return redirect()->to("/administrator/daily_attendance?" . $query)
->with('status', 'success')
->with('message', 'Attendance updated successfully.');
} catch (\Throwable $e) {
log_message('error', 'updateAttendanceManagement fatal: ' . $e->getMessage());
$msg = 'Internal error while saving attendance.';
return $isAjax
? $this->response->setStatusCode(500)->setJSON(['ok' => false, 'message' => $msg, 'csrfHash' => csrf_hash()])
: redirect()->back()->with('status', 'error')->with('message', $msg);
}
}
//attendance update teacher
public function updateAttendance()
{
// 0) Read posted section as STRING (avoid losing leading zeros)
$rawSection = trim((string) $this->request->getPost('class_section_id'));
if ($rawSection === '') {
return redirect()->to('/teacher/showupdate_attendance')
->with('status', 'error')
->with('message', 'Missing or invalid class section.');
}
// Optional: the view also sends class_id; well only use it for logging
$postedClassId = (int) ($this->request->getPost('class_id') ?? 0);
// 0.1) Normalize to canonical CODE (classSection.class_section_id) with NO ambiguity:
// Try by CODE first; ONLY if not found, try by PK id.
$sectionTbl = $this->db->table('classSection')->select('id, class_id, class_section_id');
// Try literal code match
$sectionRow = $sectionTbl->where('class_section_id', $rawSection)->get()->getFirstRow('array');
// If not found and looks numeric, try numeric code (in case column is INT)
if (!$sectionRow && ctype_digit($rawSection)) {
$sectionRow = $this->db->table('classSection')
->select('id, class_id, class_section_id')
->where('class_section_id', (int) $rawSection)
->get()->getFirstRow('array');
}
// If still not found and looks numeric, fall back to PRIMARY KEY id
if (!$sectionRow && ctype_digit($rawSection)) {
$sectionRow = $this->db->table('classSection')
->select('id, class_id, class_section_id')
->where('id', (int) $rawSection)
->get()->getFirstRow('array');
}
if (!$sectionRow) {
return redirect()->to('/teacher/showupdate_attendance')
->with('status', 'error')
->with('message', 'Invalid class section. Please refresh and try again.');
}
// Canonical identifiers
$classSectionId = (int) ($sectionRow['class_section_id'] ?? 0); // SECTION CODE
$classId = (int) ($sectionRow['class_id'] ?? 0);
// If the view posted class_id and it disagrees with DB, log it (use DB value)
if ($postedClassId > 0 && $postedClassId !== $classId) {
log_message(
'warning',
sprintf(
'Posted class_id (%d) != DB class_id (%d) for class_section_id=%s; using DB value.',
$postedClassId,
$classId,
(string) $sectionRow['class_section_id']
)
);
}
$attendanceData = $this->request->getPost('attendance'); // students
$teachersData = $this->request->getPost('teachers'); // teachers
if (empty($attendanceData) || !is_array($attendanceData)) {
return redirect()->to('/teacher/showupdate_attendance?class_section_id=' . $classSectionId)
->with('status', 'error')
->with('message', 'No student attendance data provided.');
}
if (empty($teachersData) || !is_array($teachersData)) {
return redirect()->to('/teacher/showupdate_attendance?class_section_id=' . $classSectionId)
->with('status', 'error')
->with('message', 'No teacher attendance data provided.');
}
// Date (safe parse)
$firstRow = reset($attendanceData);
$rawDate = $this->request->getPost('date') ?: ($firstRow['date'] ?? null);
try {
$dt = new \DateTimeImmutable($rawDate ?: 'today');
} catch (\Throwable $e) {
$dt = new \DateTimeImmutable('today');
}
$attendDate = $dt->format('Y-m-d');
// Term context
$semester = (string)($this->semester ?? '');
$schoolYear = (string)($this->schoolYear ?? '');
// Existing attendance rows for today (used to lock parent/admin submissions)
$existingRows = $this->attendanceDataModel
->where('class_section_id', $classSectionId)
->where('date', $attendDate)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
$existingByStudent = [];
foreach ($existingRows as $r) {
$existingByStudent[(int)($r['student_id'] ?? 0)] = $r;
}
// Current user & roles
$userId = (int) (session()->get('user_id') ?? 0);
$userRoles = (array) (session()->get('roles') ?? []);
$permissions = (array) (session()->get('permissions') ?? []);
$isTeacher = in_array('teacher', $userRoles, true);
// Policy gate
$dayRow = method_exists($this->attendanceDayModel, 'findBySectionDate')
? $this->attendanceDayModel->findBySectionDate($classSectionId, $attendDate, $semester, $schoolYear)
: $this->db->table('attendance_day')
->where('class_section_id', $classSectionId)
->where('date', $attendDate)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get()->getFirstRow('array');
$dayRowForPolicy = $dayRow ?: [
'status' => 'draft',
'class_section_id' => $classSectionId,
'date' => $attendDate,
'semester' => $semester,
'school_year' => $schoolYear,
];
$currentUser = ['id' => $userId, 'roles' => $userRoles, 'permissions' => $permissions];
if (!\App\Libraries\AttendancePolicy::canEditDay($dayRowForPolicy, $currentUser)) {
return redirect()->to('/teacher/showupdate_attendance?class_section_id=' . $classSectionId)
->with('status', 'error')
->with('message', 'Attendance is locked for your role.');
}
if ($classId <= 0) {
return redirect()->to('/teacher/showupdate_attendance?class_section_id=' . $classSectionId)
->with('status', 'error')
->with('message', 'Cannot resolve class_id for this section. Please contact admin.');
}
$allowedStatuses = ['present', 'absent', 'late'];
// === TEACHERS (assignments from teacher_class; statuses saved to staff_attendance) ===
$assignedTeachers = $this->teacherClassSection
->assignedForSectionTerm($classSectionId, $semester, $schoolYear);
// ensure teacher statuses present in POST
$missingTeachers = [];
foreach ($assignedTeachers as $t) {
$tid = (int)$t['teacher_id'];
$sel = $teachersData[$tid]['status'] ?? null;
if (!$sel || !in_array(strtolower($sel), $allowedStatuses, true)) {
$missingTeachers[] = trim(($t['firstname'] ?? '') . ' ' . ($t['lastname'] ?? ''));
}
}
if (!empty($missingTeachers)) {
return redirect()->to('/teacher/showupdate_attendance?class_section_id=' . $classSectionId)
->with('status', 'error')
->with('message', 'Please fill teacher attendance for: ' . implode(', ', $missingTeachers));
}
// Ensure student rows
$missingStudents = [];
foreach ($attendanceData as $row) {
$sid = (int)($row['student_id'] ?? 0);
$status = isset($row['status']) ? strtolower(trim((string)$row['status'])) : null;
if ($sid <= 0 || !$status || !in_array($status, $allowedStatuses, true)) {
$missingStudents[] = (string)($row['school_id'] ?? ("SID#" . $sid));
}
}
if (!empty($missingStudents)) {
return redirect()->to('/teacher/showupdate_attendance?class_section_id=' . $classSectionId)
->with('status', 'error')
->with('message', 'Please fill student attendance for School IDs: ' . implode(', ', $missingStudents));
}
// Prevent teachers from changing parent/admin-submitted attendance
if ($isTeacher) {
$lockedConflicts = [];
foreach ($attendanceData as $key => $row) {
$sid = (int)($row['student_id'] ?? $key);
if ($sid <= 0) {
continue;
}
$existing = $existingByStudent[$sid] ?? null;
$lockInfo = $this->detectExternalSubmission($existing);
if (!$lockInfo['locked']) {
continue;
}
$postedStatus = strtolower(trim((string)($row['status'] ?? '')));
$existingStatus = strtolower((string)($existing['status'] ?? ''));
$postedReason = trim((string)($row['reason'] ?? ''));
$existingReason = trim((string)($existing['reason'] ?? ''));
$changedStatus = ($postedStatus !== '' && $existingStatus !== '' && $postedStatus !== $existingStatus);
$changedReason = ($existingReason !== '' && $postedReason !== '' && $postedReason !== $existingReason);
if ($changedStatus || $changedReason) {
$lockedConflicts[] = (string)($row['school_id'] ?? ("SID#" . $sid));
}
// Freeze values to avoid downstream updates even if equal
$attendanceData[$key]['status'] = $existingStatus !== '' ? $existingStatus : $postedStatus;
$attendanceData[$key]['reason'] = $existingReason !== '' ? $existingReason : $postedReason;
}
if (!empty($lockedConflicts)) {
return redirect()->to('/teacher/showupdate_attendance?class_section_id=' . $classSectionId)
->with('status', 'error')
->with('message', 'Attendance was submitted by parent/admin for: ' . implode(', ', array_unique($lockedConflicts)) . '. Please contact admin for changes.');
}
}
// Save
$this->db->transException(true);
$this->db->transStart();
try {
// Ensure draft anchor
$dayRow = method_exists($this->attendanceDayModel, 'getOrCreateDraft')
? $this->attendanceDayModel->getOrCreateDraft($classSectionId, $attendDate, $semester, $schoolYear, $userId)
: (function () use ($classSectionId, $attendDate, $semester, $schoolYear) {
$r = $this->db->table('attendance_day')
->where('class_section_id', $classSectionId)
->where('date', $attendDate)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get()->getFirstRow('array');
if ($r) return $r;
$now = utc_now();
$this->db->table('attendance_day')->insert([
'class_section_id' => $classSectionId,
'date' => $attendDate,
'status' => 'draft',
'semester' => $semester,
'school_year' => $schoolYear,
'created_at' => $now,
'updated_at' => $now,
]);
return $this->db->table('attendance_day')
->where('class_section_id', $classSectionId)
->where('date', $attendDate)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get()->getFirstRow('array');
})();
// 1) TEACHERS: write to staff_attendance
/** @var \App\Models\StaffAttendanceModel $staffModel */
$staffModel = $this->staffAttendanceModel;
foreach ($assignedTeachers as $t) {
$tid = (int)$t['teacher_id'];
$position = strtolower((string)($t['position'] ?? 'main'));
$position = ($position === 'ta') ? 'ta' : 'main';
$status = strtolower(trim((string)($teachersData[$tid]['status'] ?? '')));
$reason = trim((string)($teachersData[$tid]['reason'] ?? ''));
// unified upsert: one row per (user_id, date)
$ok = $staffModel->upsertCell(
userId: $tid,
date: $attendDate,
status: $status,
semester: $semester,
schoolYear: $schoolYear,
roleName: 'Teacher', // label; optional
classSectionId: $classSectionId, // scope to this section
position: $position, // main|ta
reason: ($reason !== '' ? $reason : null)
);
if (!$ok) {
throw new \RuntimeException("Failed to save teacher attendance for user #{$tid}");
}
}
// 2) STUDENTS (unchanged logic)
foreach ($attendanceData as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
$studentSchoolId = isset($row['school_id']) ? trim((string) $row['school_id']) : '';
$status = strtolower(trim((string) ($row['status'] ?? '')));
$reason = isset($row['reason']) ? trim((string) $row['reason']) : null;
if ($studentSchoolId === '') {
$stuRow = $this->studentModel->select('school_id')->find($studentId);
if ($stuRow && !empty($stuRow['school_id'])) $studentSchoolId = $stuRow['school_id'];
}
$existing = $existingByStudent[$studentId] ?? null;
// Teachers never update is_reported; new rows default to 'no'.
$reported = null;
if ($isTeacher) {
if (!$existing) {
$reported = 'no';
}
} else {
$reportedRaw = strtolower((string)($row['is_reported'] ?? $row['reported'] ?? ''));
$reported = ($status === 'absent' && in_array($reportedRaw, ['1', 'yes', 'y', 'true', 'on'], true)) ? 'yes' : 'no';
}
$lockInfo = $this->detectExternalSubmission($existing);
if ($isTeacher && $lockInfo['locked']) {
// Teacher cannot overwrite parent/admin submissions
continue;
}
if ($existing && ($existing['status'] ?? null) === 'late' && $isTeacher) {
// Allow override if the existing LATE was created from a Parent report
$exReason = strtolower((string)($existing['reason'] ?? ''));
$fromParent = (strpos($exReason, 'parent-reported') !== false);
if (!$fromParent) {
log_message('debug', "Teacher blocked from modifying LATE entry: student {$studentId} on {$attendDate}");
continue;
}
}
if ($existing) {
$this->updateAttendanceData((int)$existing['id'], $status, $reason, $reported);
$this->updateAttendanceRecord(
$classSectionId,
$studentId,
$studentSchoolId,
$status,
$semester,
$schoolYear,
$existing['status'] ?? null
);
} else {
$this->insertAttendanceData(
$classSectionId,
$studentId,
$studentSchoolId,
$status,
$reason,
$reported ?? 'no',
$semester,
$schoolYear,
$attendDate,
$classId
);
$this->addNewAttendanceRecord(
$classSectionId,
$studentId,
$studentSchoolId,
$status,
$semester,
$schoolYear
);
}
}
// Final completeness check for teachers (no DB call; we already validated input)
// If you prefer verifying against DB, uncomment the block below.
/*
$countAssigned = count($assignedTeachers);
$countSaved = $this->db->table('staff_attendance')
->where('class_section_id', $classSectionId)
->where('date', $attendDate)
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('user_id', array_map(fn($r)=> (int)$r['teacher_id'], $assignedTeachers))
->countAllResults();
if ($countSaved < $countAssigned) {
throw new \RuntimeException('Teacher attendance incomplete—cannot submit.');
}
*/
// Auto-publish at second Sunday after the day (end of day)
$autoPublishAt = AttendanceAutoPublish::secondSundayAfterEndOfDay($attendDate);
if (method_exists($this->attendanceDayModel, 'submit')) {
$this->attendanceDayModel->submit((int)$dayRow['id'], $userId, $autoPublishAt);
} else {
$this->db->table('attendance_day')
->where('id', (int)$dayRow['id'])
->update([
'status' => 'submitted',
'submitted_by' => $userId,
'submitted_at' => utc_now(),
'auto_publish_at' => $autoPublishAt,
'updated_at' => utc_now(),
]);
}
$this->db->transComplete();
if ($this->db->transStatus() === false) {
log_message('error', 'Attendance transaction failed (transStatus=false).');
return redirect()->to('/teacher/showupdate_attendance?class_section_id=' . $classSectionId)
->with('status', 'error')
->with('message', 'Could not save attendance due to a database error.');
}
try {
$studentUserInfo = $this->studentModel->getStudentInfoByClassSectionId($classSectionId, $this->semester, $this->schoolYear);
$this->semesterScoreService->updateScoresForStudents($studentUserInfo);
} catch (\Throwable $e) {
// ignore
}
return redirect()->to('/teacher/showupdate_attendance?class_section_id=' . $classSectionId)
->with('status', 'success')
->with('message', 'Attendance submitted for ' . date('m-d-Y', strtotime($attendDate)) . '. Admins can still review before publish.');
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'Attendance save failed: ' . $e->getMessage());
return redirect()->to('/teacher/showupdate_attendance?class_section_id=' . $classSectionId)
->with('status', 'error')
->with('message', 'Could not save attendance: ' . $e->getMessage());
}
}
private function addNewAttendanceRecord($classSectionId, $studentId, $schoolId, $status, $semester, $schoolYear)
{
$status = strtolower($status);
$existing = $this->attendanceRecordModel->where([
'student_id' => $studentId,
'semester' => $semester,
'school_year' => $schoolYear
])->first();
if ($existing) {
$updates = [
'total_presence' => (int)($existing['total_presence'] ?? 0),
'total_absence' => (int)($existing['total_absence'] ?? 0),
'total_late' => (int)($existing['total_late'] ?? 0),
'total_attendance' => (int)($existing['total_attendance'] ?? 0) + 1,
'updated_at' => utc_now(),
'modified_by' => session()->get('user_id'),
];
if ($status === 'present') {
$updates['total_presence']++;
} elseif ($status === 'absent') {
$updates['total_absence']++;
} elseif ($status === 'late') {
$updates['total_late']++;
}
$this->attendanceRecordModel->update($existing['id'], $updates);
} else {
$updates = [
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $schoolId,
'semester' => $semester,
'school_year' => $schoolYear,
'created_at' => utc_now(),
'updated_at' => utc_now(),
'modified_by' => session()->get('user_id'),
'total_presence' => ($status === 'present') ? 1 : 0,
'total_absence' => ($status === 'absent') ? 1 : 0,
'total_late' => ($status === 'late') ? 1 : 0,
'total_attendance' => 1,
];
$this->attendanceRecordModel->insert($updates);
}
}
public function adminAddEntry()
{
// POST data
$classSectionId = (int) $this->request->getPost('class_section_id');
$studentId = (int) $this->request->getPost('student_id');
$studentSchoolId = trim((string) $this->request->getPost('school_id'));
$status = strtolower((string) $this->request->getPost('status')); // present|late|absent
$date = $this->request->getPost('date') ?: local_date(utc_now(), 'Y-m-d');
$reason = $this->request->getPost('reason');
$classId = (int) $this->request->getPost('class_id'); // optional incoming
$now = utc_now();
$userId = (int) (session()->get('user_id') ?? 0);
// 🔘 NEW: normalize is_reported (default 'no' if not posted / invalid)
$isReportedIn = strtolower((string) $this->request->getPost('is_reported'));
$isReported = in_array($isReportedIn, ['yes', 'no'], true) ? $isReportedIn : 'no';
// Validate required
if ($classSectionId <= 0 || $studentId <= 0 || empty($status) || empty($date)) {
return redirect()->back()->with('status', 'error')->with('message', 'Required data missing.');
}
if (!in_array($status, ['present', 'late', 'absent'], true)) {
return redirect()->back()->with('status', 'error')->with('message', 'Invalid status.');
}
// If school_id not sent, fetch from DB
if ($studentSchoolId === '') {
$studentRow = $this->studentModel->select('school_id')->find($studentId);
if (!$studentRow || empty($studentRow['school_id'])) {
return redirect()->back()->with('status', 'error')->with('message', 'Student school ID not found.');
}
$studentSchoolId = $studentRow['school_id'];
}
// 🔎 Resolve class_id for this section
$resolvedClassId = 0;
$row = $this->classSectionModel
->select('class_id, id, class_section_id')
->groupStart()
->where('id', $classSectionId)
->orWhere('class_section_id', $classSectionId)
->groupEnd()
->first();
if ($row && isset($row['class_id'])) {
$resolvedClassId = (int) $row['class_id'];
}
if ($resolvedClassId <= 0) {
$fallback = $this->db->table('class_section') // change if your table is named differently
->select('class_id')
->groupStart()
->where('id', $classSectionId)
->orWhere('class_section_id', $classSectionId)
->groupEnd()
->get()->getRowArray();
if ($fallback && isset($fallback['class_id'])) {
$resolvedClassId = (int) $fallback['class_id'];
}
}
if ($resolvedClassId <= 0) {
return redirect()->back()->with('status', 'error')->with('message', 'Cannot resolve class_id for this section.');
}
if ($classId <= 0) {
$classId = $resolvedClassId; // for redirect URL
}
// 🔐 Day lock
if ($this->attendanceDayModel->isFinalized($classSectionId, $date)) {
return redirect()->back()->with('status', 'error')->with('message', 'Day is locked for this class and date.');
}
$this->db->transBegin();
try {
// Lock/draft row
$dayRow = $this->attendanceDayModel->getOrCreateDraft(
$classSectionId,
$date,
(string) $this->semester,
(string) $this->schoolYear,
$userId
);
// Existing row for THIS student/date/term
$existing = $this->attendanceDataModel->where([
'student_id' => $studentId,
'date' => $date,
'semester' => (string) $this->semester,
'school_year' => (string) $this->schoolYear,
])->first();
$attendanceData = [
'class_id' => $resolvedClassId,
'class_section_id' => $classSectionId,
'student_id' => $studentId,
'school_id' => $studentSchoolId,
'status' => $status,
'reason' => strlen((string)$reason) ? $reason : null,
'semester' => (string) $this->semester,
'school_year' => (string) $this->schoolYear,
'date' => $date,
'is_reported' => $isReported, // 🔘 NEW: always set it
'is_notified' => 'no', // default state for notifications
'modified_by' => $userId ?: null,
'updated_at' => $now,
];
if ($existing) {
$oldStatus = $existing['status'] ?? null;
$attendanceData['created_at'] = $existing['created_at'] ?? null;
if (!$this->attendanceDataModel->update((int)$existing['id'], $attendanceData)) {
$errs = $this->attendanceDataModel->errors();
throw new \RuntimeException('Failed to update attendance_data: ' . json_encode($errs, JSON_UNESCAPED_SLASHES));
}
if ($oldStatus !== null && $status !== $oldStatus) {
$this->updateAttendanceRecord(
$classSectionId,
$studentId,
$studentSchoolId,
$status, // new
(string)$this->semester,
(string)$this->schoolYear,
$oldStatus // old
);
}
} else {
$attendanceData['created_at'] = $now;
if (!$this->attendanceDataModel->insert($attendanceData)) {
$errs = $this->attendanceDataModel->errors();
throw new \RuntimeException('Failed to insert attendance_data: ' . json_encode($errs, JSON_UNESCAPED_SLASHES));
}
$this->addNewAttendanceRecord(
$classSectionId,
$studentId,
$studentSchoolId,
$status,
(string)$this->semester,
(string)$this->schoolYear
);
}
if ($this->db->transStatus() === false) {
throw new \RuntimeException('Transaction failed.');
}
$this->db->transCommit();
return redirect()->to("/administrator/daily_attendance?class_id={$classId}&class_section_id={$classSectionId}")
->with('status', 'success')
->with('message', 'Entry saved' . ($status === 'late' ? ' and locked for teachers.' : '.'));
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'adminAddEntry failed: ' . $e->getMessage());
return redirect()->back()->with('status', 'error')->with('message', 'Failed to add entry: ' . $e->getMessage());
}
}
// app/Controllers/AdminTeacherAttendanceController.php (append inside class)
// ---------------------------------------------------------------------
// Monthly view (teachers/TAs + admins) — uses staff_attendance only
// ---------------------------------------------------------------------
public function month()
{
// Switch to school-year range (remove month filter)
$semester = (string)($this->request->getGet('semester') ?? ($this->semester ?? 'Fall'));
$schoolYear = (string)($this->request->getGet('school_year') ?? ($this->schoolYear ?? (date('Y') . '-' . (date('Y') + 1))));
// Build school year options from staff_attendance (fallback to configured year)
try {
$yearsRows = $this->db->table('staff_attendance')
->distinct()
->select('school_year')
->orderBy('school_year', 'DESC')
->get()->getResultArray();
$schoolYears = array_values(array_filter(array_map(static function ($r) {
return isset($r['school_year']) ? (string)$r['school_year'] : null;
}, $yearsRows)));
if (empty($schoolYears)) {
$schoolYears = [(string)$this->schoolYear];
}
} catch (\Throwable $e) {
$schoolYears = [(string)$this->schoolYear];
}
// Compute configured school-year date range, then narrow by semester if requested
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYear);
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
$rangeLabel = 'School Year ' . (string)$schoolYear;
if (in_array($semesterNorm, ['Fall','Spring'], true)) {
$rangeLabel = ($semesterNorm === 'Fall') ? 'Fall 21/0918/01' : 'Spring 25/0131/05';
}
if (in_array($semesterNorm, ['Fall', 'Spring'], true)) {
$semRange = $this->semesterRangeService->getSemesterRange($schoolYear, $semesterNorm);
if ($semRange) {
[$rangeStart, $rangeEnd] = $semRange;
$rangeLabel = ($semesterNorm === 'Fall') ? 'Fall 21/0918/01' : 'Spring 25/0131/05';
}
}
$query = http_build_query([
'semester' => $semester,
'school_year' => $schoolYear,
]);
$isCurrentYear = ((string)$schoolYear === (string)$this->schoolYear);
$missingYear = empty($this->schoolYear);
return view('attendance/teacher_attendance_month', [
'semester' => $semester,
'schoolYear' => $schoolYear,
'schoolYears' => $schoolYears,
'currentYear' => (string)$this->schoolYear,
'isCurrentYear' => $isCurrentYear,
'missingYear' => $missingYear,
'rangeStart' => $rangeStart,
'rangeEnd' => $rangeEnd,
'rangeLabel' => $rangeLabel,
// Maintain endpoint without month
'endpoint' => site_url('api/admin/teacher-attendance/month' . ($query ? ('?' . $query) : '')),
'saveEndpoint' => site_url('admin/teacher-attendance/save-cell'),
]);
}
public function monthData()
{
// Year-range version with optional semester narrowing
$semester = (string)($this->request->getGet('semester') ?? ($this->semester ?? 'Fall'));
$schoolYear = (string)($this->request->getGet('school_year') ?? ($this->schoolYear ?? (date('Y') . '-' . (date('Y') + 1))));
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYear);
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
if (in_array($semesterNorm, ['Fall', 'Spring'], true)) {
$semRange = $this->semesterRangeService->getSemesterRange($schoolYear, $semesterNorm);
if ($semRange) {
[$rangeStart, $rangeEnd] = $semRange;
}
}
$payload = $this->buildYearPayload($semesterNorm ?: $semester, $schoolYear, $rangeStart, $rangeEnd);
return $this->response->setJSON($payload);
}
private function resolveMonthFilters(): array
{
$semesterParam = trim((string)($this->request->getGet('semester') ?? ''));
$schoolYearParam = trim((string)($this->request->getGet('school_year') ?? ''));
$monthParam = trim((string)($this->request->getGet('month') ?? ''));
$semester = $semesterParam !== '' ? $semesterParam : (string)($this->semester ?? 'Fall');
$schoolYear = $schoolYearParam !== '' ? $schoolYearParam : (string)($this->schoolYear ?? (date('Y') . '-' . (date('Y') + 1)));
$month = $monthParam !== '' ? $monthParam : date('Y-m');
try {
$dt = new \DateTimeImmutable($month . '-01');
$month = $dt->format('Y-m');
} catch (\Throwable $e) {
$month = date('Y-m');
}
return [$semester, $schoolYear, $month];
}
private function formatMonthLabel(string $month): string
{
try {
$dt = new \DateTimeImmutable($month . '-01');
return $dt->format('F Y');
} catch (\Throwable $e) {
return date('F Y');
}
}
private function buildYearPayload(string $semester, string $schoolYear, string $rangeStart, string $rangeEnd): array
{
try {
$start = new \DateTimeImmutable($rangeStart);
} catch (\Throwable $e) {
$start = new \DateTimeImmutable(date('Y-09-01'));
}
try {
$end = new \DateTimeImmutable($rangeEnd);
} catch (\Throwable $e) {
$end = (new \DateTimeImmutable($start->format('Y-m-d')))->modify('+9 months');
}
// Build all Sundays within the configured range
$sundays = [];
// Find first Sunday on/after start
$cursor = $start;
$w = (int)$cursor->format('w');
if ($w !== 0) {
$cursor = $cursor->modify('next sunday');
}
for (; $cursor <= $end; $cursor = $cursor->modify('+7 days')) {
$sundays[] = $cursor->format('Y-m-d');
}
// Pull No School days from calendar_events for the range
$noSchoolDays = [];
try {
$rows = $this->db->table('calendar_events')
->select('date')
->where('school_year', $schoolYear)
->where('no_school', 1)
->where('date >=', $start->format('Y-m-d'))
->where('date <=', $end->format('Y-m-d'))
->get()->getResultArray();
foreach ($rows as $r) {
$d = substr((string)($r['date'] ?? ''), 0, 10);
if ($d !== '') $noSchoolDays[$d] = true;
}
} catch (\Throwable $e) { /* ignore */ }
// Fallback if DB filters fail or column missing: use CalendarModel heuristics
if (empty($noSchoolDays)) {
try {
/** @var \App\Models\CalendarModel $cal */
$cal = model(\App\Models\CalendarModel::class);
$events = (array) ($cal->getEventsBySchoolYear($schoolYear) ?? []);
foreach ($events as $ev) {
$d = substr((string)($ev['date'] ?? ''), 0, 10);
$no = (int) ($ev['no_school'] ?? 0);
if ($d !== '' && $no === 1 && $d >= $start->format('Y-m-d') && $d <= $end->format('Y-m-d')) {
$noSchoolDays[$d] = true;
}
}
} catch (\Throwable $e) { /* ignore */ }
}
$rangeLabel = 'School Year ' . (string)$schoolYear;
// Prepare base payload
$payload = [
'filters' => [
'semester' => $semester,
'school_year' => $schoolYear,
'range_label' => $rangeLabel,
'range_start' => $start->format('Y-m-d'),
'range_end' => $end->format('Y-m-d'),
// Backward-compat for existing JS which may read month_label
'month_label' => $rangeLabel,
],
'sundays' => [],
'noSchoolDays' => [],
'sections' => [],
'admins' => [],
];
// =========================
// Teacher / TA sections
// =========================
$assignedBySection = [];
try {
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
if (in_array($semesterNorm, ['Fall','Spring'], true)) {
$assignedBySection = (array)$this->teacherClassSection->assignedBySectionForTerm($semesterNorm, $schoolYear);
} else {
// Whole-year: merge both terms
$fall = (array)$this->teacherClassSection->assignedBySectionForTerm('Fall', $schoolYear);
$spr = (array)$this->teacherClassSection->assignedBySectionForTerm('Spring', $schoolYear);
$codes = array_unique(array_merge(array_keys($fall), array_keys($spr)));
foreach ($codes as $code) {
$assignedBySection[$code] = array_merge($fall[$code] ?? [], $spr[$code] ?? []);
}
}
} catch (\Throwable $e) {
log_message('error', 'buildMonthPayload: failed to load assigned sections - ' . $e->getMessage());
}
$sectionCodes = array_keys($assignedBySection);
$sectionLabels = [];
if (!empty($sectionCodes)) {
$labelRows = $this->classSectionModel
->select('class_section_id, class_section_name')
->whereIn('class_section_id', $sectionCodes)
->get()->getResultArray();
foreach ($labelRows as $row) {
$code = (int)($row['class_section_id'] ?? 0);
if ($code <= 0) {
continue;
}
$name = trim((string)($row['class_section_name'] ?? ''));
$sectionLabels[$code] = $name !== '' ? $name : ('Section #' . $code);
}
}
$teacherIds = [];
foreach ($assignedBySection as $sectionRows) {
foreach ($sectionRows as $row) {
$tid = (int)($row['teacher_id'] ?? 0);
if ($tid > 0) {
$teacherIds[$tid] = true;
}
}
}
$teacherIds = array_keys($teacherIds);
// Use Sundays only for the specified school-year range
$days = $sundays;
// Build teacher statuses for the chosen day set
$statusByTeacher = [];
if (!empty($teacherIds) && !empty($days)) {
$qb = $this->db->table('staff_attendance sa')
->select('sa.user_id, sa.date, sa.status, sa.reason')
->where('sa.school_year', $schoolYear)
->whereIn('sa.user_id', $teacherIds)
->whereIn('sa.date', $days);
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
if (in_array($semesterNorm, ['Fall','Spring'], true)) {
$qb->where('sa.semester', $semesterNorm);
}
$statusRows = $qb->get()->getResultArray();
foreach ($statusRows as $row) {
$uid = (int)($row['user_id'] ?? 0);
$date = (string)($row['date'] ?? '');
if ($uid <= 0 || $date === '') {
continue;
}
$statusByTeacher[$uid][$date] = [
'status' => strtolower((string)($row['status'] ?? '')),
'reason' => $row['reason'] ?? null,
];
}
}
// Finalize days and no-school list in payload
$payload['sundays'] = $days;
$noSchoolDaysList = [];
foreach ($days as $d) {
if (isset($noSchoolDays[$d])) $noSchoolDaysList[] = $d;
}
$payload['noSchoolDays'] = $noSchoolDaysList;
foreach ($sectionCodes as $code) {
$teacherRows = $assignedBySection[$code] ?? [];
$teachersPayload = [];
foreach ($teacherRows as $row) {
$tid = (int)($row['teacher_id'] ?? 0);
if ($tid <= 0) {
continue;
}
$first = trim((string)($row['firstname'] ?? ''));
$last = trim((string)($row['lastname'] ?? ''));
$name = trim($first . ' ' . $last);
if ($name === '') {
$name = 'User#' . $tid;
}
$posRaw = strtolower((string)($row['position'] ?? 'main'));
$position = in_array($posRaw, ['main', 'ta'], true) ? $posRaw : 'main';
$cells = [];
$totals = ['p' => 0, 'a' => 0, 'l' => 0];
foreach ($days as $date) {
$cell = $statusByTeacher[$tid][$date] ?? null;
if (!$cell || empty($cell['status'])) {
continue;
}
$status = (string)$cell['status'];
if (!in_array($status, ['present', 'absent', 'late'], true)) {
continue;
}
$cells[$date] = [
'status' => $status,
'reason' => $cell['reason'] ?? null,
];
if ($status === 'present') {
$totals['p']++;
} elseif ($status === 'absent') {
$totals['a']++;
} elseif ($status === 'late') {
$totals['l']++;
}
}
$teachersPayload[] = [
'teacher_id' => $tid,
'user_id' => $tid,
'name' => $name,
'position' => $position,
'cells' => $cells,
'totals' => $totals,
];
}
$payload['sections'][] = [
'section_code' => (int)$code,
'label' => $sectionLabels[$code] ?? ('Section #' . (int)$code),
'teachers' => $teachersPayload,
];
}
// =========================
// Admin roster
// =========================
$excludedSlugs = ['parent', 'guest', 'teacher', 'teacher_assistant', 'ta', 'assistant_teacher'];
$adminCandidates = $this->db->table('users u')
->select('u.id AS user_id, u.firstname, u.lastname, r.id AS role_id, r.name AS role_name, r.slug, r.priority')
->join('user_roles ur', 'ur.user_id = u.id', 'left')
->join('roles r', 'r.id = ur.role_id', 'left')
->where('r.is_active', 1)
->get()->getResultArray();
$admins = [];
foreach ($adminCandidates as $row) {
$uid = (int)($row['user_id'] ?? 0);
$rid = (int)($row['role_id'] ?? 0);
$slug = strtolower((string)($row['slug'] ?? $row['role_name'] ?? ''));
if ($uid <= 0 || $rid <= 0) {
continue;
}
if (in_array($slug, $excludedSlugs, true)) {
continue;
}
$priority = (int)($row['priority'] ?? 100);
if (!isset($admins[$uid]) || $priority < (int)$admins[$uid]['priority']) {
$admins[$uid] = [
'user_id' => $uid,
'firstname' => $row['firstname'] ?? '',
'lastname' => $row['lastname'] ?? '',
'role_name' => $row['role_name'] ?? 'Admin',
'priority' => $priority,
];
}
}
$adminIds = array_keys($admins);
$statusByAdmin = [];
if (!empty($adminIds) && !empty($sundays)) {
$qb = $this->db->table('staff_attendance')
->select('user_id, date, status, reason')
->where('school_year', $schoolYear)
->whereIn('user_id', $adminIds)
->whereIn('date', $sundays);
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
if (in_array($semesterNorm, ['Fall','Spring'], true)) {
$qb->where('semester', $semesterNorm);
}
$statusRows = $qb->get()->getResultArray();
foreach ($statusRows as $row) {
$uid = (int)($row['user_id'] ?? 0);
$date = (string)($row['date'] ?? '');
if ($uid <= 0 || $date === '') {
continue;
}
$statusByAdmin[$uid][$date] = [
'status' => strtolower((string)($row['status'] ?? '')),
'reason' => $row['reason'] ?? null,
];
}
}
foreach ($admins as $uid => $admin) {
$name = trim(($admin['firstname'] ?? '') . ' ' . ($admin['lastname'] ?? ''));
if ($name === '') {
$name = 'User#' . $uid;
}
$cells = [];
$totals = ['p' => 0, 'a' => 0, 'l' => 0];
foreach ($sundays as $date) {
$cell = $statusByAdmin[$uid][$date] ?? null;
if (!$cell || empty($cell['status'])) {
continue;
}
$status = (string)$cell['status'];
if (!in_array($status, ['present', 'absent', 'late'], true)) {
continue;
}
$cells[$date] = [
'status' => $status,
'reason' => $cell['reason'] ?? null,
];
if ($status === 'present') {
$totals['p']++;
} elseif ($status === 'absent') {
$totals['a']++;
} elseif ($status === 'late') {
$totals['l']++;
}
}
$payload['admins'][] = [
'user_id' => $uid,
'name' => $name,
'role' => $admin['role_name'] ?? 'Admin',
'cells' => $cells,
'totals' => $totals,
];
}
return $payload;
}
public function monthCsv()
{
// Export CSV across the configured school-year Sundays
$semester = (string)($this->request->getGet('semester') ?: ($this->semester ?? 'Fall'));
$schoolYear = (string)($this->request->getGet('school_year') ?: ($this->schoolYear ?? (date('Y') . '-' . (date('Y') + 1))));
$scope = strtolower((string)$this->request->getGet('scope') ?? ''); // 'admins' to export admins CSV
[$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYear);
$semesterNorm = $this->semesterRangeService->normalizeSemester($semester);
if (in_array($semesterNorm, ['Fall','Spring'], true)) {
$semRange = $this->semesterRangeService->getSemesterRange($schoolYear, $semesterNorm);
if ($semRange) { [$rangeStart, $rangeEnd] = $semRange; }
}
try { $start = new \DateTimeImmutable($rangeStart); } catch (\Throwable $e) { $start = new \DateTimeImmutable(date('Y-09-01')); }
try { $end = new \DateTimeImmutable($rangeEnd); } catch (\Throwable $e) { $end = (new \DateTimeImmutable($start->format('Y-m-d')))->modify('+9 months'); }
// Sundays list
$daysList = [];
$cursor = $start;
$w = (int)$cursor->format('w');
if ($w !== 0) $cursor = $cursor->modify('next sunday');
for (; $cursor <= $end; $cursor = $cursor->modify('+7 days')) {
$daysList[] = $cursor->format('Y-m-d');
}
// =========================
// Admins CSV
// =========================
if ($scope === 'admins') {
$excludedSlugs = ['parent', 'guest', 'teacher', 'teacher_assistant', 'ta', 'assistant_teacher'];
// Build roster of admins with best role by priority
$cand = $this->db->table('users u')
->select('u.id AS user_id, u.firstname, u.lastname, r.id AS role_id, r.name AS role_name, r.slug, r.priority')
->join('user_roles ur', 'ur.user_id = u.id', 'left')
->join('roles r', 'r.id = ur.role_id', 'left')
->where('r.is_active', 1)
->get()->getResultArray();
$admins = [];
foreach ($cand as $row) {
$rid = (int)($row['role_id'] ?? 0);
$slug = strtolower((string)($row['slug'] ?? $row['role_name'] ?? ''));
if ($rid <= 0 || in_array($slug, $excludedSlugs, true)) continue;
$uid = (int)$row['user_id'];
$prio = (int)($row['priority'] ?? 100);
if (!isset($admins[$uid]) || $prio < (int)$admins[$uid]['priority']) {
$admins[$uid] = [
'user_id' => $uid,
'firstname' => $row['firstname'] ?? '',
'lastname' => $row['lastname'] ?? '',
'role_name' => $row['role_name'] ?? 'Admin',
'priority' => $prio,
];
}
}
$adminUserIds = array_keys($admins);
// Statuses (user/date only)
$adminStatusMap = [];
if (!empty($adminUserIds)) {
$qb = $this->db->table('staff_attendance')
->select('user_id, date, status')
->where('school_year', $schoolYear)
->where('date >=', $start->format('Y-m-d'))
->where('date <=', $end->format('Y-m-d'))
->whereIn('user_id', $adminUserIds);
if (in_array($semesterNorm, ['Fall','Spring'], true)) {
$qb->where('semester', $semesterNorm);
}
$rows = $qb->get()->getResultArray();
foreach ($rows as $r) {
$uid = (int)$r['user_id'];
$d = (string)$r['date'];
$adminStatusMap[$uid][$d] = strtolower((string)($r['status'] ?? ''));
}
}
// Stream CSV
$filename = 'admin_attendance_' . $semester . '_' . str_replace('/', '-', $schoolYear) . '_SY.csv';
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
$out = fopen('php://output', 'w');
$hdr = ['User ID', 'Name', 'Role'];
foreach ($daysList as $d) $hdr[] = $d;
$hdr[] = 'Total P';
$hdr[] = 'Total A';
$hdr[] = 'Total L';
fputcsv($out, $hdr);
foreach ($admins as $uid => $u) {
$name = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? '')) ?: ('User#' . $uid);
$role = $u['role_name'] ?? 'Admin';
$totP = $totA = $totL = 0;
$row = [$uid, $name, $role];
foreach ($daysList as $d) {
$s = $adminStatusMap[$uid][$d] ?? '';
$v = '';
if ($s === 'present') {
$v = 'P';
$totP++;
} elseif ($s === 'absent') {
$v = 'A';
$totA++;
} elseif ($s === 'late') {
$v = 'L';
$totL++;
}
$row[] = $v;
}
$row[] = $totP;
$row[] = $totA;
$row[] = $totL;
fputcsv($out, $row);
}
fclose($out);
exit;
}
// =========================
// Teachers/TAs CSV (replicate status to all assigned sections)
// =========================
// 1) Assignments for this term (keyed by SECTION CODE)
// Build roster for selected term or whole year
if (in_array($semesterNorm, ['Fall','Spring'], true)) {
$bySection = $this->teacherClassSection->assignedBySectionForTerm($semesterNorm, $schoolYear);
} else {
$fall = $this->teacherClassSection->assignedBySectionForTerm('Fall', $schoolYear);
$spr = $this->teacherClassSection->assignedBySectionForTerm('Spring', $schoolYear);
$bySection = [];
foreach (array_unique(array_merge(array_keys($fall), array_keys($spr))) as $code) {
$bySection[$code] = array_merge($fall[$code] ?? [], $spr[$code] ?? []);
}
}
// $bySection: [sectionCode] => [rows...]
// If you actually need a flat list like $tcRows:
$tcRows = [];
foreach ($bySection as $rows) {
foreach ($rows as $r) $tcRows[] = $r;
}
$assignedBySection = []; // [sectionCode][] = teacher rows
$teacherIdsGlobal = []; // [teacherId=>true]
foreach ($tcRows as $r) {
$sid = (int)$r['class_section_id']; // SECTION CODE
$tid = (int)$r['teacher_id'];
$pos = strtolower((string)($r['position'] ?? 'main'));
$pos = ($pos === 'ta') ? 'ta' : 'main';
$assignedBySection[$sid][] = [
'teacher_id' => $tid,
'firstname' => $r['firstname'] ?? '',
'lastname' => $r['lastname'] ?? '',
'position' => $pos,
];
$teacherIdsGlobal[$tid] = true;
}
$sectionCodes = array_keys($assignedBySection);
$teacherIds = array_keys($teacherIdsGlobal);
// 2) Statuses from staff_attendance for those teachers within month range & term (user/date only)
$statusByUser = []; // [teacherId][Y-m-d] => status
if (!empty($teacherIds)) {
$qb = $this->db->table('staff_attendance sa')
->select('sa.user_id, sa.date, sa.status')
->where('sa.school_year', $schoolYear)
->where('sa.date >=', $start->format('Y-m-d'))
->where('sa.date <=', $end->format('Y-m-d'))
->whereIn('sa.user_id', $teacherIds);
if (in_array($semesterNorm, ['Fall','Spring'], true)) {
$qb->where('sa.semester', $semesterNorm);
}
$rows = $qb->get()->getResultArray();
foreach ($rows as $r) {
$uid = (int)$r['user_id'];
$d = (string)$r['date'];
$statusByUser[$uid][$d] = strtolower((string)($r['status'] ?? ''));
}
}
// 3) Stream CSV
$filename = 'teacher_attendance_' . $semester . '_' . str_replace('/', '-', $schoolYear) . '_SY.csv';
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
$out = fopen('php://output', 'w');
$hdr = ['Class Section ID', 'Teacher ID', 'Teacher Name', 'Role'];
foreach ($daysList as $d) $hdr[] = $d;
$hdr[] = 'Total P';
$hdr[] = 'Total A';
$hdr[] = 'Total L';
fputcsv($out, $hdr);
foreach ($sectionCodes as $sid) {
foreach ($assignedBySection[$sid] as $t) {
$tid = (int)$t['teacher_id'];
$name = trim(($t['firstname'] ?? '') . ' ' . ($t['lastname'] ?? '')) ?: ('User#' . $tid);
$pos = (string)$t['position'];
$totP = $totA = $totL = 0;
$row = [$sid, $tid, $name, $pos];
foreach ($daysList as $d) {
$s = $statusByUser[$tid][$d] ?? ''; // replicate teachers status to all sections
$v = '';
if ($s === 'present') {
$v = 'P';
$totP++;
} elseif ($s === 'absent') {
$v = 'A';
$totA++;
} elseif ($s === 'late') {
$v = 'L';
$totL++;
}
$row[] = $v;
}
$row[] = $totP;
$row[] = $totA;
$row[] = $totL;
fputcsv($out, $row);
}
}
fclose($out);
exit;
}
// Semester helpers are now provided by SemesterRangeService.
//admins attendance
public function admins()
{
// Context
$semester = (string)($this->request->getGet('semester') ?: ($this->semester ?? 'Fall'));
$schoolYear = (string)($this->request->getGet('school_year') ?: ($this->schoolYear ?? (date('Y') . '-' . (date('Y') + 1))));
$date = (string)($this->request->getGet('date') ?: local_date(utc_now(), 'Y-m-d'));
// 1) Get all staff rows for the day/term
$allStaff = (array)$this->staffAttendanceModel->staffWithStatusForDay($date, $semester, $schoolYear, []);
if (empty($allStaff)) {
return view('attendance/admins_attendance_form', [
'semester' => $semester,
'schoolYear' => $schoolYear,
'date' => $date,
'adminsGrid' => [],
]);
}
// Collect unique user_ids
$userIds = [];
foreach ($allStaff as $row) {
$uid = (int)($row['user_id'] ?? 0);
if ($uid > 0) $userIds[$uid] = true;
}
$userIds = array_keys($userIds);
// 2) Fetch ALL roles for those users
$rolesRows = $this->db->table('user_roles ur')
->select('ur.user_id, r.id AS role_id, r.name AS role_name, r.slug AS role_slug, r.priority')
->join('roles r', 'r.id = ur.role_id', 'inner')
->where('r.is_active', 1)
->whereIn('ur.user_id', $userIds)
->get()->getResultArray();
// Group roles per user, sort by priority ASC
$rolesByUser = [];
foreach ($rolesRows as $r) {
$uid = (int)$r['user_id'];
if ($uid <= 0) continue;
$rolesByUser[$uid][] = $r;
}
foreach ($rolesByUser as &$list) {
usort($list, static function ($a, $b) {
return (int)($a['priority'] ?? 100) <=> (int)($b['priority'] ?? 100);
});
}
unset($list);
// 3) Exclude parent, guest, teacher, teacher_assistant
$excludeSlugs = ['parent', 'guest', 'teacher', 'teacher_assistant', 'ta', 'assistant_teacher'];
// Map attendance rows by user_id
$staffByUser = [];
foreach ($allStaff as $row) {
$uid = (int)($row['user_id'] ?? 0);
if ($uid <= 0) continue;
if (!isset($staffByUser[$uid])) {
$staffByUser[$uid] = $row;
}
}
// 4) Pick valid role for Admins list
$adminsGrid = [];
foreach ($userIds as $uid) {
$roles = $rolesByUser[$uid] ?? [];
$picked = null;
foreach ($roles as $r) {
$slug = strtolower((string)($r['role_slug'] ?? $r['role_name'] ?? ''));
if (!in_array($slug, $excludeSlugs, true)) {
$picked = $r;
break;
}
}
if ($picked === null) {
continue; // skip users with only excluded roles
}
$base = $staffByUser[$uid] ?? ['user_id' => $uid];
$base['role_name'] = $picked['role_name'] ?? 'Admin';
$base['role_slug'] = $picked['role_slug'] ?? null;
$adminsGrid[] = $base;
}
// 5) Sort nicely by name
usort($adminsGrid, static function ($a, $b) {
$an = trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? ''));
$bn = trim(($b['firstname'] ?? '') . ' ' . ($b['lastname'] ?? ''));
return strcasecmp($an, $bn);
});
return view('attendance/admins_attendance_form', [
'semester' => $semester,
'schoolYear' => $schoolYear,
'date' => $date,
'adminsGrid' => $adminsGrid,
]);
}
public function saveAdmins()
{
$date = (string)$this->request->getPost('date');
$semester = (string)$this->request->getPost('semester');
$schoolYear = (string)$this->request->getPost('school_year');
// Prefer 'admins' (your admins page); fallback to 'staff' if form used that name
$admins = (array)($this->request->getPost('admins') ?: $this->request->getPost('staff'));
if (empty($date) || empty($semester) || empty($schoolYear)) {
return redirect()->to('/admin/admins-attendance?date=' . $date . '&semester=' . urlencode($semester) . '&school_year=' . urlencode($schoolYear))
->with('error', 'Missing required fields (date/term).');
}
$allowedStatuses = ['present', 'absent', 'late'];
// roles we explicitly do NOT want to record in the admins page
$excludedRoleSlugs = ['parent', 'guest', 'teacher', 'teacher_assistant'];
$normalize = static function (?string $s): string {
$s = strtolower(trim((string)$s));
// turn "Teacher Assistant", "teacher-assistant", "teacher_assistant" → "teacher_assistant"
$s = str_replace(['-', ' '], '_', $s);
return $s;
};
$this->db->transException(true);
$this->db->transStart();
try {
$errors = [];
$saved = 0;
foreach ($admins as $uid => $row) {
$uid = (int)$uid;
$status = $normalize($row['status'] ?? '');
$reason = trim((string)($row['reason'] ?? ''));
// hidden inputs from the view; if you also have role_slug, prefer that
$roleName = (string)($row['role_name'] ?? '');
$roleSlug = $normalize($row['role_slug'] ?? $roleName);
// Skip excluded roles (parent/guest/teacher/teacher_assistant)
if (in_array($roleSlug, $excludedRoleSlugs, true)) {
continue;
}
// Empty status means clear any existing record for this admin for the day
if ($status === '') {
$existing = $this->staffAttendanceModel->where([
'user_id' => $uid,
'date' => $date,
'semester' => $semester,
'school_year' => $schoolYear,
])->first();
if (!empty($existing)) {
$this->staffAttendanceModel->delete((int)$existing['id']);
}
continue;
}
if (!in_array($status, $allowedStatuses, true)) {
$errors[] = "Invalid status for admin user #{$uid}";
continue;
}
// Upsert (role stored as a label; if blank, store a generic 'Admin')
$ok = $this->staffAttendanceModel->upsertOne(
$uid,
$roleName !== '' ? $roleName : 'Admin',
$date,
$semester,
$schoolYear,
$status,
$reason !== '' ? $reason : null
);
if ($ok === false) {
$errors[] = "DB error while saving user #{$uid}";
} else {
$saved++;
}
}
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return redirect()->to('/admin/admins-attendance?date=' . $date . '&semester=' . urlencode($semester) . '&school_year=' . urlencode($schoolYear))
->with('error', 'Database transaction failed.');
}
$msg = "Admins attendance saved ({$saved} row(s)).";
if (!empty($errors)) {
$msg .= ' Some rows skipped: ' . implode('; ', $errors);
}
return redirect()->to('/admin/admins-attendance?date=' . $date . '&semester=' . urlencode($semester) . '&school_year=' . urlencode($schoolYear))
->with('status', $msg);
} catch (\Throwable $e) {
$this->db->transRollback();
return redirect()->to('/admin/admins-attendance?date=' . $date . '&semester=' . urlencode($semester) . '&school_year=' . urlencode($schoolYear))
->with('error', 'Save failed: ' . $e->getMessage());
}
}
// app/Controllers/AttendanceController.php
public function saveCell()
{
if (!$this->request->is('post')) {
return $this->response->setStatusCode(405)
->setJSON(['ok' => false, 'error' => 'Method not allowed']);
}
$date = (string) $this->request->getPost('date'); // YYYY-MM-DD
$status = (string) $this->request->getPost('status'); // '', present, absent, late
$targetId = (int) $this->request->getPost('user_id'); // SUBJECT of the cell
$roleName = trim((string) $this->request->getPost('role_name')); // optional label
$semester = (string) $this->request->getPost('semester');
$schoolYear = (string) $this->request->getPost('school_year');
$reason = trim((string) $this->request->getPost('reason'));
// Validate
if ($date === '' || $targetId <= 0 || $semester === '' || $schoolYear === '') {
return $this->jsonErr('Missing required fields.');
}
if (!in_array($status, ['', 'present', 'absent', 'late'], true)) {
return $this->jsonErr('Invalid status.');
}
$model = $this->staffAttendanceModel;
// Find existing for upsert key (user_id + date + term)
$existing = $model->where([
'user_id' => $targetId,
'date' => $date,
'semester' => $semester,
'school_year' => $schoolYear,
])->first();
// If cleared -> delete row when present
if ($status === '') {
if ($existing) {
$model->delete((int)$existing['id']);
}
return $this->jsonOk();
}
// UPSERT using the model helper (this sets created_by/updated_by)
$ok = $model->upsertOne(
$targetId,
$roleName !== '' ? $roleName : ($existing['role_name'] ?? null),
$date,
$semester,
$schoolYear,
$status,
$reason !== '' ? $reason : null
);
if (!$ok) {
log_message('error', 'saveCell upsert failed for uid={uid}, date={date}, term={sem}/{yr}', [
'uid' => $targetId,
'date' => $date,
'sem' => $semester,
'yr' => $schoolYear,
]);
return $this->jsonErr('Database error.');
}
return $this->jsonOk();
}
private function jsonOk(array $extra = [])
{
return $this->response->setJSON(array_merge([
'ok' => true,
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
], $extra));
}
private function jsonErr(string $msg, int $code = 422)
{
return $this->response->setStatusCode($code)
->setJSON([
'ok' => false,
'error' => $msg,
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
}