3217 lines
134 KiB
PHP
3217 lines
134 KiB
PHP
<?php
|
||
|
||
namespace App\Controllers\View;
|
||
use App\Controllers\BaseController;
|
||
use App\Models\AttendanceDataModel;
|
||
use App\Models\StudentModel;
|
||
use App\Models\StudentClassModel;
|
||
use App\Models\ClassSectionModel;
|
||
use App\Models\EmergencyContactModel;
|
||
use App\Models\AttendanceRecordModel;
|
||
use CodeIgniter\Controller;
|
||
use App\Models\UserModel;
|
||
use App\Models\ConfigurationModel;
|
||
use App\Models\TeacherClassModel;
|
||
use App\Models\AttendanceDayModel;
|
||
use App\Models\TeacherAttendanceModel;
|
||
use App\Models\StaffAttendanceModel;
|
||
use App\Services\SemesterScoreService;
|
||
use App\Services\SemesterRangeService;
|
||
use App\Libraries\AttendanceAutoPublish;
|
||
use App\Models\UserRoleModel;
|
||
use RuntimeException;
|
||
use App\Models\CalendarModel;
|
||
|
||
class AttendanceController extends Controller
|
||
{
|
||
protected $db;
|
||
protected $configModel;
|
||
protected $semesterScoreService;
|
||
protected $semester;
|
||
protected $schoolYear;
|
||
protected $attendanceDataModel;
|
||
protected $studentModel;
|
||
protected $emergencyContactModel;
|
||
protected $userModel;
|
||
protected $teacherClassSection;
|
||
protected $attendanceRecordModel;
|
||
protected $classSectionModel;
|
||
protected $studentClassModel;
|
||
protected $enableAttendance;
|
||
protected $staffAttendanceModel;
|
||
protected $attendanceDayModel;
|
||
protected $calendarModel;
|
||
protected $userRoleModel;
|
||
protected $adminRoleCache = [];
|
||
protected $semesterRangeService;
|
||
|
||
public function __construct()
|
||
{
|
||
// Initialize the database connection in the constructor
|
||
$this->db = \Config\Database::connect();
|
||
$this->configModel = new ConfigurationModel();
|
||
$this->attendanceDataModel = new AttendanceDataModel();
|
||
$this->studentModel = new StudentModel();
|
||
$this->emergencyContactModel = new EmergencyContactModel();
|
||
$this->userModel = new UserModel();
|
||
$this->teacherClassSection = new TeacherClassModel();
|
||
$this->attendanceRecordModel = new AttendanceRecordModel();
|
||
$this->classSectionModel = new ClassSectionModel();
|
||
$this->studentClassModel = new StudentClassModel();
|
||
$this->attendanceDayModel = new AttendanceDayModel();
|
||
$this->staffAttendanceModel = new StaffAttendanceModel();
|
||
$this->calendarModel = model(CalendarModel::class);
|
||
$this->userRoleModel = new UserRoleModel();
|
||
$this->semesterScoreService = service('semesterScoreService');
|
||
$this->semester = $this->configModel->getConfig('semester');
|
||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||
$this->enableAttendance = $this->configModel->getConfig('enable_attendance');
|
||
$this->semesterRangeService = new SemesterRangeService($this->configModel);
|
||
}
|
||
|
||
|
||
/**
|
||
* GET: filter + grid for a single date/section.
|
||
*/
|
||
public function index()
|
||
{
|
||
$semester = (string)($this->semester ?? ($this->request->getGet('semester') ?: 'Fall'));
|
||
$schoolYear = (string)($this->schoolYear ?? ($this->request->getGet('school_year') ?: date('Y') . '-' . (date('Y') + 1)));
|
||
$date = $this->request->getGet('date') ?: local_date(utc_now(), 'Y-m-d');
|
||
|
||
// IMPORTANT: this is the SECTION *CODE* (classSection.class_section_id)
|
||
$sectionCode = (int)($this->request->getGet('class_section_id') ?? 0);
|
||
|
||
// 1) Build the sections dropdown: code -> label (join by CODE, not PK)
|
||
// 1) which sections have assignments this term?
|
||
$bySection = $this->teacherClassSection->assignedBySectionForTerm($semester, $schoolYear);
|
||
$sectionCodes = array_keys($bySection);
|
||
|
||
// 2) fetch labels (names) in one query
|
||
$labels = [];
|
||
if (!empty($sectionCodes)) {
|
||
$labelRows = $this->classSectionModel
|
||
->select('class_section_id, class_section_name')
|
||
->whereIn('class_section_id', $sectionCodes)
|
||
->orderBy('class_section_id', 'ASC')
|
||
->findAll();
|
||
|
||
foreach ($labelRows as $r) {
|
||
$code = (int)$r['class_section_id'];
|
||
$name = trim((string)$r['class_section_name']);
|
||
$labels[$code] = ($name !== '') ? $name : ('Section #' . $code);
|
||
}
|
||
|
||
// ensure any missing codes still have a label
|
||
foreach ($sectionCodes as $code) {
|
||
if (!isset($labels[$code])) $labels[$code] = 'Section #' . (int)$code;
|
||
}
|
||
}
|
||
|
||
|
||
// 2) Grid (for the chosen section code & date)
|
||
$grid = [];
|
||
$locked = false;
|
||
|
||
if ($sectionCode > 0) {
|
||
// a) Assigned teachers/TAs for this section & term
|
||
$assigned = $this->teacherClassSection
|
||
->assignedForSectionTerm($sectionCode /* or $class_section_id */, $semester, $schoolYear);
|
||
|
||
|
||
// b) Pull statuses for those teachers from staff_attendance for THIS date
|
||
$teacherIds = array_map(static fn($r) => (int)$r['teacher_id'], $assigned);
|
||
$statusMap = []; // [user_id] => ['status'=>..., 'reason'=>...]
|
||
if (!empty($teacherIds)) {
|
||
$rows = $this->db->table('staff_attendance sa')
|
||
->select('sa.user_id, sa.status, sa.reason')
|
||
->where('sa.semester', $semester)
|
||
->where('sa.school_year', $schoolYear)
|
||
->where('sa.date', $date)
|
||
->whereIn('sa.user_id', $teacherIds)
|
||
->get()->getResultArray();
|
||
|
||
foreach ($rows as $r) {
|
||
$statusMap[(int)$r['user_id']] = [
|
||
'status' => $r['status'] ?? null,
|
||
'reason' => $r['reason'] ?? null,
|
||
];
|
||
}
|
||
}
|
||
|
||
// c) Build grid rows expected by the view (one row per assigned teacher/TA)
|
||
foreach ($assigned as $t) {
|
||
$tid = (int)$t['teacher_id'];
|
||
$posRaw = strtolower((string)($t['position'] ?? 'main'));
|
||
$pos = in_array($posRaw, ['main', 'ta'], true) ? $posRaw : 'main';
|
||
$name = trim(($t['firstname'] ?? '') . ' ' . ($t['lastname'] ?? '')) ?: ('User#' . $tid);
|
||
|
||
$cell = $statusMap[$tid] ?? ['status' => null, 'reason' => null];
|
||
|
||
$grid[] = [
|
||
'teacher_id' => $tid,
|
||
'name' => $name,
|
||
'position' => $pos, // 'main' | 'ta'
|
||
'status' => $cell['status'], // 'present' | 'absent' | 'late' | null
|
||
'reason' => $cell['reason'], // nullable
|
||
];
|
||
}
|
||
|
||
// d) Lock check keyed by section CODE
|
||
try {
|
||
$locked = $this->attendanceDayModel->isFinalized($sectionCode, $date, $semester, $schoolYear);
|
||
} catch (\Throwable $e) {
|
||
$locked = false;
|
||
}
|
||
}
|
||
|
||
return view('attendance/teacher_attendance_form', [
|
||
'semester' => $semester,
|
||
'schoolYear' => $schoolYear,
|
||
'date' => $date,
|
||
// Keep the GET param name as-is; it represents the SECTION CODE
|
||
'class_section_id' => $sectionCode,
|
||
'sections' => $labels, // assoc: [sectionCode => label]
|
||
'grid' => $grid,
|
||
'locked' => $locked,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Show Update Attendance (students + staff) with No-School integration
|
||
*/
|
||
public function showUpdateAttendanceForm()
|
||
{
|
||
$user_id = (int)(session()->get('user_id') ?? 0);
|
||
if ($user_id <= 0) {
|
||
echo "User not logged in.";
|
||
return;
|
||
}
|
||
|
||
$teacher = $this->userModel->find($user_id);
|
||
$teacher_name = $teacher ? ($teacher['firstname'] . ' ' . $teacher['lastname']) : 'Unknown Teacher';
|
||
|
||
// Resolve class_section_id for this user (respect active selection)
|
||
$assignments = $this->teacherClassSection->getClassAssignmentsByUserId(
|
||
$user_id,
|
||
(string)$this->schoolYear,
|
||
(string)$this->semester
|
||
);
|
||
|
||
if (empty($assignments)) {
|
||
log_message('info', 'No classes found for teacher ID: ' . $user_id);
|
||
return redirect()->to('no-classes')
|
||
->with('message', 'You do not have an assigned class yet. Please contact the administration.');
|
||
}
|
||
// Resolve active section (supports both section code and PK for robustness)
|
||
$requestedIdRaw = $this->request->getGet('class_section_id') ?? '';
|
||
$requestedId = is_numeric($requestedIdRaw) ? (int)$requestedIdRaw : null;
|
||
$sessionId = (int)(session()->get('class_section_id') ?? 0);
|
||
|
||
$activeSection = null;
|
||
foreach ($assignments as $a) {
|
||
$cid = (int)($a['class_section_id'] ?? 0);
|
||
$pk = (int)($a['class_section_pk'] ?? 0);
|
||
if ($requestedId && ($requestedId === $cid || $requestedId === $pk)) {
|
||
$activeSection = $a;
|
||
break;
|
||
}
|
||
if ($sessionId && ($sessionId === $cid || $sessionId === $pk)) {
|
||
$activeSection = $a;
|
||
}
|
||
}
|
||
if ($activeSection === null) {
|
||
$activeSection = $assignments[0];
|
||
}
|
||
|
||
$classSectionCode = (int)($activeSection['class_section_id'] ?? 0); // section code
|
||
$classSectionPk = (int)($activeSection['class_section_pk'] ?? 0); // DB PK
|
||
|
||
// prefer the code for queries; fall back to PK only if missing
|
||
$class_section_id = $classSectionCode > 0 ? $classSectionCode : $classSectionPk;
|
||
|
||
if ($class_section_id <= 0) {
|
||
return redirect()->to('no-classes')
|
||
->with('message', 'Unable to determine your class section. Please contact administration.');
|
||
}
|
||
|
||
// Persist selection for navbar-driven navigation
|
||
session()->set('class_section_id', $class_section_id);
|
||
|
||
// Term context
|
||
$semester = (string)($this->semester ?? '');
|
||
$schoolYear = (string)($this->schoolYear ?? '');
|
||
|
||
$today = new \DateTimeImmutable(local_date(utc_now(), 'Y-m-d'));
|
||
// "Last Sunday" definition:
|
||
// - If today is Sunday => use today
|
||
// - Otherwise => use the next Sunday
|
||
$weekday = (int)$today->format('w'); // 0 (Sun) .. 6 (Sat)
|
||
$anchorSunday = ($weekday === 0) ? $today : $today->modify('next sunday');
|
||
|
||
// Build the last 3 Sundays in chronological order (oldest → newest)
|
||
$sundayDates = [
|
||
$anchorSunday->modify('-2 weeks')->format('Y-m-d'),
|
||
$anchorSunday->modify('-1 weeks')->format('Y-m-d'),
|
||
$anchorSunday->format('Y-m-d'),
|
||
];
|
||
$currentSunday = $sundayDates[2];
|
||
|
||
// Students roster for this section/year/semester (try section code, then PK fallback)
|
||
$students = $this->studentModel->getByClassAndYear($class_section_id, $schoolYear, $semester);
|
||
if (empty($students) && $classSectionPk > 0 && $classSectionPk !== $class_section_id) {
|
||
$students = $this->studentModel->getByClassAndYear($classSectionPk, $schoolYear, $semester);
|
||
}
|
||
|
||
// Build student attendance rows
|
||
$attendanceData = [];
|
||
$lockedByStudent = [];
|
||
foreach ($students as $student) {
|
||
$sid = (int)$student['id'];
|
||
// Pull attendance strictly for this section; fallback to PK only if none exists
|
||
$rowsQuery = $this->attendanceDataModel
|
||
->select('date, status, is_reported, reason, modified_by')
|
||
->where('student_id', $sid)
|
||
->where('school_year', $schoolYear)
|
||
->where('semester', $semester)
|
||
->groupStart()
|
||
->where('class_section_id', $class_section_id)
|
||
->orWhere('class_section_id', (int)$class_section_id); // tolerate PK/code mismatches
|
||
|
||
$rows = $rowsQuery
|
||
->groupEnd()
|
||
->whereIn('date', $sundayDates)
|
||
->findAll();
|
||
|
||
// Normalize status/is_reported so comparisons like === 'late' always work
|
||
$rows = $this->normalizeAttendanceEntries($rows);
|
||
|
||
if (empty($rows) && $classSectionPk > 0 && $classSectionPk !== $class_section_id) {
|
||
$rows = $this->attendanceDataModel
|
||
->select('date, status, is_reported, reason, modified_by')
|
||
->where('student_id', $sid)
|
||
->where('school_year', $schoolYear)
|
||
->where('semester', $semester)
|
||
->where('class_section_id', $classSectionPk)
|
||
->whereIn('date', $sundayDates)
|
||
->findAll();
|
||
$rows = $this->normalizeAttendanceEntries($rows);
|
||
}
|
||
|
||
// Grab the latest 3 attendance days for quick context beside today's selector
|
||
$recentRows = $this->attendanceDataModel->builder()
|
||
->select('date, status')
|
||
->where('student_id', $sid)
|
||
->where('school_year', $schoolYear)
|
||
->where('semester', $semester)
|
||
->groupStart()
|
||
->where('class_section_id', $class_section_id)
|
||
->orWhere('class_section_id', (int)$class_section_id) // tolerate PK/code mismatches
|
||
->groupEnd()
|
||
->orderBy('date', 'DESC')
|
||
->limit(3)
|
||
->get()
|
||
->getResultArray();
|
||
|
||
if (empty($recentRows) && $classSectionPk > 0 && $classSectionPk !== $class_section_id) {
|
||
$recentRows = $this->attendanceDataModel->builder()
|
||
->select('date, status')
|
||
->where('student_id', $sid)
|
||
->where('school_year', $schoolYear)
|
||
->where('semester', $semester)
|
||
->where('class_section_id', $classSectionPk)
|
||
->orderBy('date', 'DESC')
|
||
->limit(3)
|
||
->get()
|
||
->getResultArray();
|
||
}
|
||
$recentHistory = [];
|
||
foreach ($recentRows as $rr) {
|
||
$recentHistory[] = [
|
||
'date' => substr((string)($rr['date'] ?? ''), 0, 10),
|
||
'status' => strtolower((string)($rr['status'] ?? '')),
|
||
];
|
||
}
|
||
|
||
$snapshot = [];
|
||
$rowsByDate = [];
|
||
foreach ($rows as $entry) {
|
||
$d = (string)($entry['date'] ?? '');
|
||
$snapshot[$d] = $entry['status'] ?? null;
|
||
$rowsByDate[$d] = $entry;
|
||
}
|
||
|
||
$statusHistory = [];
|
||
foreach ($sundayDates as $date) {
|
||
$statusHistory[$date] = array_key_exists($date, $snapshot)
|
||
? $snapshot[$date]
|
||
: ($date === $currentSunday ? null : 'N/A');
|
||
}
|
||
|
||
$todayRow = $rowsByDate[$currentSunday] ?? null;
|
||
$lockInfo = $this->detectExternalSubmission($todayRow);
|
||
$todayReason = $todayRow['reason'] ?? null;
|
||
|
||
$attendanceData[] = [
|
||
'student' => $student,
|
||
'sunday_statuses' => $statusHistory,
|
||
'today' => $statusHistory[$currentSunday] ?? null,
|
||
'today_reason' => $todayReason,
|
||
'locked' => $lockInfo['locked'],
|
||
'locked_source' => $lockInfo['source'],
|
||
'recent_three' => $recentHistory,
|
||
];
|
||
|
||
$lockedByStudent[$sid] = [
|
||
'locked' => $lockInfo['locked'],
|
||
'source' => $lockInfo['source'],
|
||
'reason' => $todayReason,
|
||
'status' => $statusHistory[$currentSunday] ?? null,
|
||
];
|
||
}
|
||
|
||
// 🔹 Build TEACHER / TA rows from unified staff_attendance
|
||
$assigned = $this->teacherClassSection
|
||
->assignedForSectionTerm($class_section_id, $semester, $schoolYear);
|
||
|
||
$teacherRows = [];
|
||
if (!empty($assigned)) {
|
||
$wantedDates = array_values(array_filter($sundayDates));
|
||
$teacherIds = array_map(static fn($r) => (int)$r['teacher_id'], $assigned);
|
||
|
||
$statusMap = []; // [user_id][date] => ['status'=>..., 'reason'=>...]
|
||
if (!empty($teacherIds) && !empty($wantedDates)) {
|
||
$rows = $this->db->table('staff_attendance sa')
|
||
->select('sa.user_id, sa.date, sa.status, sa.reason')
|
||
->where('sa.semester', $semester)
|
||
->where('sa.school_year', $schoolYear)
|
||
->whereIn('sa.user_id', $teacherIds)
|
||
->whereIn('sa.date', $wantedDates)
|
||
->get()->getResultArray();
|
||
|
||
foreach ($rows as $r) {
|
||
$tid = (int)$r['user_id'];
|
||
$d = (string)$r['date'];
|
||
$statusMap[$tid][$d] = [
|
||
'status' => $r['status'] ?? null,
|
||
'reason' => $r['reason'] ?? null,
|
||
];
|
||
}
|
||
}
|
||
|
||
// Fetch last 3 attendance entries per teacher for quick context
|
||
$recentByTeacher = [];
|
||
if (!empty($teacherIds)) {
|
||
$recentRows = $this->db->table('staff_attendance sa')
|
||
->select('sa.user_id, sa.date, sa.status')
|
||
->where('sa.semester', $semester)
|
||
->where('sa.school_year', $schoolYear)
|
||
->whereIn('sa.user_id', $teacherIds)
|
||
->orderBy('sa.user_id', 'ASC')
|
||
->orderBy('sa.date', 'DESC')
|
||
->get()->getResultArray();
|
||
|
||
$counts = [];
|
||
foreach ($recentRows as $r) {
|
||
$uid = (int)($r['user_id'] ?? 0);
|
||
if ($uid <= 0) continue;
|
||
$counts[$uid] = ($counts[$uid] ?? 0) + 1;
|
||
if ($counts[$uid] > 3) continue;
|
||
$recentByTeacher[$uid][] = [
|
||
'date' => substr((string)($r['date'] ?? ''), 0, 10),
|
||
'status' => strtolower((string)($r['status'] ?? '')),
|
||
];
|
||
}
|
||
}
|
||
|
||
foreach ($assigned as $t) {
|
||
$tid = (int)$t['teacher_id'];
|
||
$pos = strtolower((string)($t['position'] ?? 'main'));
|
||
$pos = in_array($pos, ['main', 'ta'], true) ? $pos : 'main';
|
||
$full = trim(($t['firstname'] ?? '') . ' ' . ($t['lastname'] ?? ''));
|
||
$name = $full !== '' ? $full : ('User#' . $tid);
|
||
|
||
$history = [];
|
||
foreach ($sundayDates as $date) {
|
||
$history[$date] = isset($statusMap[$tid][$date]['status'])
|
||
? $statusMap[$tid][$date]['status']
|
||
: ($date === $currentSunday ? null : 'N/A');
|
||
}
|
||
|
||
$teacherRows[] = [
|
||
'teacher_id' => $tid,
|
||
'position' => $pos,
|
||
'name' => $name,
|
||
'sunday_statuses' => $history,
|
||
'today' => $history[$currentSunday] ?? null,
|
||
'recent_three' => $recentByTeacher[$tid] ?? [],
|
||
];
|
||
}
|
||
}
|
||
|
||
// 🔹 Fetch No-School events for the Sunday columns
|
||
$wantedDates = array_values(array_filter($sundayDates));
|
||
|
||
$noSchoolDays = [];
|
||
if (!empty($wantedDates)) {
|
||
$rows = $this->calendarModel
|
||
->where('no_school', 1)
|
||
->whereIn('date', $wantedDates)
|
||
// If your calendar 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(),
|
||
]);
|
||
}
|
||
}
|