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 isn’t 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; $seenStudents = []; foreach ($students as $sc) { $studentId = (int)$sc['student_id']; if ($studentId <= 0 || isset($seenStudents[$studentId])) { continue; } $student = $this->studentModel ->select('id, firstname, lastname, school_id') ->find($studentId); if (!$student) continue; $studentsBySection[$secCode][] = $student; $hasRoster = true; $seenStudents[$studentId] = 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; we’ll 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/09–18/01' : 'Spring 25/01–31/05'; } if (in_array($semesterNorm, ['Fall', 'Spring'], true)) { $semRange = $this->semesterRangeService->getSemesterRange($schoolYear, $semesterNorm); if ($semRange) { [$rangeStart, $rangeEnd] = $semRange; $rangeLabel = ($semesterNorm === 'Fall') ? 'Fall 21/09–18/01' : 'Spring 25/01–31/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 teacher’s 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(), ]); } }