db = \Config\Database::connect(); $this->configModel = new ConfigurationModel(); $this->reportModel = new ParentAttendanceReportModel(); $this->studentModel = new StudentModel(); $this->studentClassModel = new StudentClassModel(); $this->classSectionModel = new ClassSectionModel(); $this->attendanceDataModel = new AttendanceDataModel(); $this->attendanceRecordModel = new AttendanceRecordModel(); $this->earlyDismissalSignatureModel = new EarlyDismissalSignatureModel(); helper(['form', 'url']); } // GET: parent form to report absence/late/early dismissal public function form() { $parentId = $this->resolvePrimaryParentIdFromSession(); if (!$parentId) { return redirect()->to('/login'); } $students = $this->db->table('students') ->where('parent_id', $parentId) ->orderBy('firstname', 'ASC') ->orderBy('lastname', 'ASC') ->get()->getResultArray(); // Build a list of Sundays (last few + upcoming weeks) [$sundays, $defaultDate] = $this->computeSundays(); // Load upcoming reports for preview/edit (today and forward within this school year) $schoolYear = (string) $this->configModel->getConfig('school_year'); $todayYmd = local_date(utc_now(), 'Y-m-d'); $previewRows = $this->reportModel->builder() ->select('parent_attendance_reports.*, s.firstname, s.lastname') ->join('students s', 's.id = parent_attendance_reports.student_id', 'left') ->where('parent_attendance_reports.parent_id', (int) $parentId) ->where('parent_attendance_reports.school_year', $schoolYear) ->where('parent_attendance_reports.report_date >=', $todayYmd) ->orderBy('parent_attendance_reports.report_date', 'ASC') ->orderBy('s.firstname', 'ASC') ->orderBy('s.lastname', 'ASC') ->get()->getResultArray(); $cutoffThreshold = $this->normalizeCutoffTime((string) $this->configModel->getConfig('parent_report_cutoff')); return view('parent/report_attendance', [ 'students' => $students, 'today' => local_date(utc_now(), 'Y-m-d'), 'sundays' => $sundays, // array of ['value'=>Y-m-d, 'label'=>...] items 'defaultDate' => $defaultDate, // preselected date (upcoming Sunday) 'myReports' => $previewRows, 'cutoffThreshold' => $cutoffThreshold, ]); } // POST: save parent report public function submit() { $parentId = $this->resolvePrimaryParentIdFromSession(); if (!$parentId) { return redirect()->to('/login'); } $post = $this->request->getPost(); $rules = [ 'student_ids' => [ 'rules' => 'required', 'errors' => [ 'required' => 'At least select one student', ], ], 'date' => 'permit_empty|valid_date[Y-m-d]', 'type' => 'required|in_list[absent,late,early_dismissal]', 'arrival_time' => 'permit_empty|regex_match[/^\d{2}:\d{2}(:\d{2})?$/]', 'dismiss_time' => 'permit_empty|regex_match[/^\d{2}:\d{2}(:\d{2})?$/]', 'reason' => 'permit_empty|string', ]; if (! $this->validate($rules)) { return redirect()->back()->withInput()->with('error', implode(' ', $this->validator->getErrors())); } $studentIdsRaw = (array) ($post['student_ids'] ?? []); $studentIds = []; foreach ($studentIdsRaw as $sidRaw) { $sid = (int) $sidRaw; if ($sid > 0) { $studentIds[] = $sid; } } $datesInput = $post['dates'] ?? ($post['date'] ?? null); $rawDates = []; if (is_array($datesInput)) { $rawDates = $datesInput; } elseif ($datesInput !== null) { $rawDates = [$datesInput]; } $rawDates = array_values(array_filter(array_map(function ($val) { return substr((string) $val, 0, 10); }, $rawDates))); $rawDates = array_values(array_unique(array_filter($rawDates))); if (empty($rawDates)) { return redirect()->back()->withInput()->with('error', 'Please select at least one Sunday date.'); } $type = (string) $post['type']; $arrivalTime = $post['arrival_time'] ?? null; $dismissTime = $post['dismiss_time'] ?? null; $reasonRaw = $post['reason'] ?? null; $reason = is_string($reasonRaw) ? trim($reasonRaw) : null; // Enforce Sunday-only and future-or-today selection $todayCheck = new \DateTime('today'); $cutoffThreshold = $this->normalizeCutoffTime((string) $this->configModel->getConfig('parent_report_cutoff')); $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); $tz = null; $nowTz = null; try { $tz = new \DateTimeZone($tzName ?: 'UTC'); $nowTz = new \DateTime('now', $tz); } catch (\Throwable $e) { $tz = null; $nowTz = null; } $validDates = []; $cap = $this->firstSundayOfJune((string) $this->configModel->getConfig('school_year')); $lateCutoff = null; $lateNow = null; $lateTodayStr = $todayCheck->format('Y-m-d'); if ($type === 'late') { try { if (!$tz) { $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); $tz = new \DateTimeZone($tzName ?: 'UTC'); } $lateNow = $nowTz ?: new \DateTime('now', $tz); $cm = $this->configModel; $cutoffStr = (string) ($cm->getConfig('late_report_cutoff') ?: $cm->getConfig('late_cutoff_time') ?: '10:10'); if (preg_match('/^(\d{1,2}):(\d{2})/', $cutoffStr, $m)) { $hh = str_pad((string) ((int) $m[1]), 2, '0', STR_PAD_LEFT); $mm = str_pad($m[2], 2, '0', STR_PAD_LEFT); $cutoffStr = $hh . ':' . $mm; } else { $cutoffStr = '10:10'; } $lateCutoff = new \DateTime($lateTodayStr . ' ' . $cutoffStr . ':00', $tz); } catch (\Throwable $e) { log_message('warning', 'Late cutoff prep failed: ' . $e->getMessage()); $lateCutoff = null; $lateNow = null; } } foreach ($rawDates as $entry) { $dt = \DateTime::createFromFormat('Y-m-d', $entry); if (!$dt) { return redirect()->back()->withInput()->with('error', 'Invalid date selected: ' . $entry); } if ((int)$dt->format('w') !== 0) { return redirect()->back()->withInput()->with('error', 'Please select Sunday dates only.'); } if ($dt < $todayCheck) { return redirect()->back()->withInput()->with('error', 'Past dates are not allowed. Please select today or a future Sunday.'); } if ($nowTz && $tz) { try { $cutoff = new \DateTime($entry . ' ' . $cutoffThreshold . ':00', $tz); if ($nowTz >= $cutoff) { $abbr = $cutoff->format('T'); return redirect()->back()->withInput()->with('error', 'Reporting closes at ' . $cutoffThreshold . ' ' . $abbr . ' on the report date.'); } } catch (\Throwable $e) { // ignore and continue } } if ($dt > $cap) { return redirect()->back()->withInput()->with('error', 'The last available date is the first Sunday of June.'); } if ($type === 'late' && $lateCutoff && $lateNow && $entry === $lateTodayStr && $lateNow > $lateCutoff) { return redirect()->back()->withInput()->with('error', 'Late reporting is closed for today. Please contact the office if needed.'); } $validDates[$entry] = $dt; } // Enforce required time fields for specific types if ($type === 'late' && (!is_string($arrivalTime) || $arrivalTime === '')) { return redirect()->back()->withInput()->with('error', 'Please provide an expected arrival time for late reporting.'); } if ($type === 'early_dismissal' && (!is_string($dismissTime) || $dismissTime === '')) { return redirect()->back()->withInput()->with('error', 'Please provide a dismissal time for early dismissal.'); } if ($type !== 'early_dismissal') { if ($reason === null || $reason === '') { return redirect()->back()->withInput()->with('error', 'Please provide a reason for absence or late arrival.'); } } $reason = ($reason !== null && $reason !== '') ? $reason : null; // Upper bound already enforced by $validDates building $semester = (string) $this->configModel->getConfig('semester'); $schoolYear = (string) $this->configModel->getConfig('school_year'); $semesterResolver = new SemesterRangeService($this->configModel); $inserted = 0; $blocked = []; $successNames = []; $successDetails = []; $submitterUserId = (int) (session()->get('user_id') ?? 0); $selectedDateList = array_keys($validDates); foreach ($studentIds as $sid) { if ($sid <= 0) continue; $sectionRow = $this->db->table('student_class') ->select('class_section_id') ->where('student_id', $sid) ->where('school_year', $schoolYear) ->orderBy('updated_at', 'DESC') ->get()->getRowArray(); $classSectionId = $sectionRow['class_section_id'] ?? null; $classLabel = $this->resolveClassSectionLabel($classSectionId); $stu = $this->studentModel->select('firstname, lastname')->find($sid); $studentName = $stu ? trim(($stu['firstname'] ?? '') . ' ' . ($stu['lastname'] ?? '')) : ('Student #' . $sid); foreach ($validDates as $reportDate => $_dt) { $semesterForDate = $semesterResolver->getSemesterForDate($reportDate); if ($semesterForDate === '') { $semesterForDate = $semester; } $saved = false; try { $qb = $this->reportModel->builder(); $qb->where('student_id', $sid) ->where('report_date', $reportDate); if ($type === 'early_dismissal') { $existingALAny = (clone $qb) ->groupStart() ->where('type', 'absent') ->orWhere('type', 'late') ->groupEnd() ->get()->getRowArray(); if ($existingALAny) { $blocked[] = $studentName . ' (' . $reportDate . ')'; } else { $existingED = (clone $qb)->where('type', 'early_dismissal')->get()->getFirstRow('array'); if ($existingED) { $this->reportModel->update((int)$existingED['id'], [ 'dismiss_time' => $dismissTime ?: $existingED['dismiss_time'], 'status' => 'new', ]); $inserted++; $saved = true; $dismissTimeUsed = $dismissTime ?: ($existingED['dismiss_time'] ?? null); $dismissDisplay = $this->formatTimeDisplay($dismissTimeUsed); $successNames[] = [ 'name' => $studentName, 'type' => 'Early Dismissal', 'time' => $dismissDisplay ?: 'N/A', 'date' => $reportDate, 'date_label' => $this->formatReportDate($reportDate) ?? $reportDate, ]; $successDetails[] = [ 'student_id' => $sid, 'name' => $studentName, 'class_label' => $classLabel, 'type' => $type, 'type_label' => $this->formatParentReportType($type), 'arrival_time' => null, 'dismiss_time' => $dismissDisplay, 'date' => $reportDate, 'date_label' => $this->formatReportDate($reportDate) ?? $reportDate, ]; } else { $ok = $this->reportModel->insert([ 'parent_id' => $parentId, 'student_id' => $sid, 'class_section_id' => $classSectionId, 'report_date' => $reportDate, 'type' => 'early_dismissal', 'dismiss_time' => $dismissTime ?: null, 'reason' => $reason ?: null, 'semester' => $semesterForDate, 'school_year' => $schoolYear, 'status' => 'new', ], false); if ($ok) { $inserted++; $saved = true; $dismissTimeFmt = $this->formatTimeDisplay($dismissTime); $successNames[] = [ 'name' => $studentName, 'type' => 'Early Dismissal', 'time' => $dismissTimeFmt ?: 'N/A', 'date' => $reportDate, 'date_label' => $this->formatReportDate($reportDate) ?? $reportDate, ]; $successDetails[] = [ 'student_id' => $sid, 'name' => $studentName, 'class_label' => $classLabel, 'type' => $type, 'type_label' => $this->formatParentReportType($type), 'arrival_time' => null, 'dismiss_time' => $dismissTimeFmt, 'date' => $reportDate, 'date_label' => $this->formatReportDate($reportDate) ?? $reportDate, ]; } } } } else { $existingAL = (clone $qb) ->groupStart() ->where('type', 'absent') ->orWhere('type', 'late') ->groupEnd() ->get()->getRowArray(); $existingED = (clone $qb)->where('type', 'early_dismissal')->get()->getFirstRow('array'); if ($existingAL || $existingED) { $blocked[] = $studentName . ' (' . $reportDate . ')'; } else { $ok = $this->reportModel->insert([ 'parent_id' => $parentId, 'student_id' => $sid, 'class_section_id' => $classSectionId, 'report_date' => $reportDate, 'type' => $type, 'arrival_time' => ($type === 'late') ? ($arrivalTime ?: null) : null, 'reason' => $reason ?: null, 'semester' => $semesterForDate, 'school_year' => $schoolYear, 'status' => 'new', ], false); if ($ok) { $inserted++; $saved = true; $arrivalFmt = $this->formatTimeDisplay($arrivalTime); $successNames[] = [ 'name' => $studentName, 'type' => ucfirst($type), 'time' => ($type === 'late') ? ($arrivalFmt ?: 'N/A') : 'N/A', 'date' => $reportDate, 'date_label' => $this->formatReportDate($reportDate) ?? $reportDate, ]; $successDetails[] = [ 'student_id' => $sid, 'name' => $studentName, 'class_label' => $classLabel, 'type' => $type, 'type_label' => $this->formatParentReportType($type), 'arrival_time' => ($type === 'late') ? $arrivalFmt : null, 'dismiss_time' => null, 'date' => $reportDate, 'date_label' => $this->formatReportDate($reportDate) ?? $reportDate, ]; } } } } catch (\Throwable $e) { log_message('error', 'Parent report save failed: ' . $e->getMessage()); } if ($saved && in_array($type, ['absent', 'late'], true)) { try { $this->reflectToAttendanceData( studentId: $sid, reportDate: $reportDate, type: $type, semester: $semesterForDate, schoolYear: $schoolYear, classSectionId: $classSectionId, reason: $reason, arrivalTime: $arrivalTime ); } catch (\Throwable $e) { log_message('error', 'Unable to reflect parent report to attendance_data: ' . $e->getMessage()); } } } } // --- Final message output --- if ($inserted <= 0) { $err = !empty($blocked) ? ('Already submitted and cannot be changed for: ' . implode(', ', array_slice($blocked, 0, 5)) . (count($blocked) > 5 ? '…' : '')) : 'Could not save your report. Please try again.'; return redirect()->back()->withInput()->with('error', $err); } // ✅ Build formatted success message $msg = '✅ Submission received successfully for ' . count($successNames) . ' item' . (count($successNames) > 1 ? 's' : '') . '.
'; $msg .= ''; $dateSummary = $this->formatDateList($selectedDateList); if ($dateSummary !== '') { $msg .= 'Date(s): ' . esc($dateSummary); } if (!empty($blocked)) { $msg .= '
Note: Already submitted and unchanged for ' . count($blocked) . ' student' . (count($blocked) > 1 ? 's' : '') . '.'; } if (!empty($successDetails)) { try { $this->sendParentReportNotifications([ 'parent_id' => $parentId, 'submitter_id' => $submitterUserId > 0 ? $submitterUserId : $parentId, 'students' => $successDetails, 'type' => $type, 'dates' => $selectedDateList, 'report_date' => $selectedDateList[0] ?? null, 'reason' => $reason, 'semester' => $semester, 'school_year' => $schoolYear, ]); } catch (\Throwable $e) { log_message('error', 'Parent report confirmation email failed: ' . $e->getMessage()); } } return redirect()->to(site_url('parent/report-attendance'))->with('message', $msg); } /** * Returns an array of Sunday dates and a recommended default selection. * @return array{0: array, 1: string} */ private function computeSundays(int $weeksBefore = 4, int $weeksAfter = 26): array { $today = new \DateTime('today'); $dow = (int)$today->format('w'); // 0=Sun // This Sunday (today if Sunday, else last Sunday) $thisSunday = clone $today; if ($dow !== 0) { $thisSunday->modify('last sunday'); } // Default selection: upcoming Sunday (today if Sunday) $default = clone $today; if ($dow !== 0) { $default->modify('next sunday'); } // Upper bound: first Sunday of June (school year end) $schoolYear = (string) $this->configModel->getConfig('school_year'); $cap = $this->firstSundayOfJune($schoolYear); $dates = []; // Backward Sundays (limited) for ($i = $weeksBefore; $i >= 1; $i--) { $d = (clone $thisSunday)->modify('-' . $i . ' week'); $dates[] = $d; } // Forward Sundays from this Sunday up to the cap (inclusive) $cursor = clone $thisSunday; // this Sunday while ($cursor <= $cap) { $dates[] = clone $cursor; $cursor->modify('+1 week'); } // Determine date range for calendar query $rangeStart = reset($dates); $rangeEnd = end($dates); $rangeStartStr = $rangeStart instanceof \DateTime ? $rangeStart->format('Y-m-d') : null; $rangeEndStr = $rangeEnd instanceof \DateTime ? $rangeEnd->format('Y-m-d') : null; // Fetch No School dates from calendar_events within range $noSchool = []; if ($rangeStartStr && $rangeEndStr) { try { $builder = $this->db->table('calendar_events') ->select('date') ->where('no_school', 1) ->groupStart() ->where('school_year', $schoolYear) ->orWhere('school_year IS NULL', null, false) ->groupEnd() ->where('date >=', $rangeStartStr) ->where('date <=', $rangeEndStr); $rows = $builder->get()->getResultArray(); foreach ($rows as $r) { $d = substr((string)($r['date'] ?? ''), 0, 10); if ($d !== '') $noSchool[$d] = true; } } catch (\Throwable $e) { // ignore, fallback below } } // Fallback using CalendarModel heuristics if none found if (empty($noSchool) && $rangeStartStr && $rangeEndStr) { 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); $ns = (int)($ev['no_school'] ?? 0); if ($ns === 1 && $d >= $rangeStartStr && $d <= $rangeEndStr) { $noSchool[$d] = true; } } } catch (\Throwable $e) { // ignore } } // Filter out no-school Sundays $filtered = []; foreach ($dates as $d) { $ymd = $d->format('Y-m-d'); if (!isset($noSchool[$ymd])) { $filtered[] = $d; } } // Keep only today or future Sundays (no past dates displayed) $filteredFuture = []; foreach ($filtered as $d) { if ($d >= $today) { $filteredFuture[] = $d; } } $filteredCapped = []; foreach ($filteredFuture as $d) { if ($d <= $cap) { $filteredCapped[] = $d; } } // Choose a sensible default (nearest allowed Sunday on/after today) $defaultYmd = $default->format('Y-m-d'); $needFind = isset($noSchool[$defaultYmd]) || (new \DateTime($defaultYmd) < $today) || (new \DateTime($defaultYmd) > $cap); if ($needFind) { $found = null; foreach ($filteredCapped as $d) { $found = $d; break; } if ($found instanceof \DateTime) { $defaultYmd = $found->format('Y-m-d'); } } // Build value/label pairs $out = []; foreach ($filteredCapped as $d) { $out[] = [ 'value' => $d->format('Y-m-d'), 'label' => $d->format('m-d-Y'), ]; } return [$out, $defaultYmd]; } /** * First Sunday of June for the given school year. * If school_year is like "2024-2025", we use 2025-06 (end year). * Fallback: if parsing fails, use upcoming June based on today. */ private function firstSundayOfJune(string $schoolYear): \DateTime { $today = new \DateTime('today'); $endYear = null; if (preg_match('/^(\d{4})\D(\d{4})$/', $schoolYear, $m)) { $endYear = (int)$m[2]; } if ($endYear === null) { $y = (int)$today->format('Y'); // If we're past June, cap to next year's June; otherwise this year's June $endYear = ((int)$today->format('n') > 6) ? ($y + 1) : $y; } $juneFirst = new \DateTime(sprintf('%04d-06-01', $endYear)); if ((int)$juneFirst->format('w') !== 0) { $juneFirst->modify('next sunday'); } return $juneFirst; // DateTime (midnight) } /** * Insert or update attendance_data for admin daily view based on parent report. * - For 'absent': upsert an ABSENT row and mark is_reported='yes'. * - For 'late': if no row exists, insert LATE and mark is_reported='yes'. * Teachers are allowed to override parent-created late entries (see AttendanceController guard). */ private function reflectToAttendanceData( int $studentId, string $reportDate, string $type, string $semester, string $schoolYear, ?int $classSectionId, ?string $reason = null, ?string $arrivalTime = null ): void { if ($classSectionId === null || $classSectionId <= 0) { // Try resolve from student_class for current year, then fallback to last known assignment $row = $this->db->table('student_class') ->select('class_section_id') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->orderBy('updated_at', 'DESC') ->get()->getRowArray(); $classSectionId = (int) ($row['class_section_id'] ?? 0); if ($classSectionId <= 0) { $row = $this->db->table('student_class') ->select('class_section_id') ->where('student_id', $studentId) ->orderBy('updated_at', 'DESC') ->get()->getRowArray(); $classSectionId = (int) ($row['class_section_id'] ?? 0); } } // Existing attendance for that date/term (strict, then relaxed) $existing = $this->attendanceDataModel ->where('student_id', $studentId) ->where('class_section_id', $classSectionId) ->where('date', $reportDate) ->where('semester', $semester) ->where('school_year', $schoolYear) ->first(); if (!$existing) { $existing = $this->attendanceDataModel ->where('student_id', $studentId) ->where('date', $reportDate) ->where('semester', $semester) ->where('school_year', $schoolYear) ->first(); } if (!$existing) { $existing = $this->attendanceDataModel->builder() ->where('student_id', $studentId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->where("DATE(`date`)", $reportDate, false) ->get()->getFirstRow('array'); } if (($classSectionId === null || $classSectionId <= 0) && !empty($existing['class_section_id'])) { $classSectionId = (int) $existing['class_section_id']; } $hasIsReported = $this->db->fieldExists('is_reported', 'attendance_data'); $hasLegacyReported = $this->db->fieldExists('reported', 'attendance_data'); $hasIsNotified = $this->db->fieldExists('is_notified', 'attendance_data'); $status = ($type === 'late') ? 'late' : (($type === 'absent') ? 'absent' : null); if ($status === null) return; // Build parent-reported reason $reasonParts = ['Parent-reported']; if ($type === 'late' && $arrivalTime) { $reasonParts[] = 'ETA ' . substr($arrivalTime, 0, 5); } if (!empty($reason)) { $reasonParts[] = trim($reason); } $reasonText = implode(' — ', $reasonParts); if (!$existing) { if ($classSectionId === null || $classSectionId <= 0) { // Try last known attendance_data entry for class/section $fallback = $this->attendanceDataModel->builder() ->select('class_section_id, class_id, school_id') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->orderBy('date', 'DESC') ->limit(1) ->get()->getFirstRow('array'); if (!$fallback) { $fallback = $this->attendanceDataModel->builder() ->select('class_section_id, class_id, school_id') ->where('student_id', $studentId) ->orderBy('date', 'DESC') ->limit(1) ->get()->getFirstRow('array'); } if (!empty($fallback['class_section_id'])) { $classSectionId = (int) $fallback['class_section_id']; } } if ($classSectionId === null || $classSectionId <= 0) { return; // cannot insert without a class section } // class_id required by attendance_data validation $classId = $this->classSectionModel->getClassId($classSectionId); if (!$classId || $classId <= 0) { $fallback = $this->attendanceDataModel->builder() ->select('class_id') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->orderBy('date', 'DESC') ->limit(1) ->get()->getFirstRow('array'); if (!$fallback) { $fallback = $this->attendanceDataModel->builder() ->select('class_id') ->where('student_id', $studentId) ->orderBy('date', 'DESC') ->limit(1) ->get()->getFirstRow('array'); } $classId = (int) ($fallback['class_id'] ?? 0); } if (!$classId || $classId <= 0) return; // Student school_id $stu = $this->studentModel->select('school_id')->find($studentId); $schoolId = (string) ($stu['school_id'] ?? ''); if ($schoolId === '') { $fallback = $this->attendanceDataModel->builder() ->select('school_id') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->orderBy('date', 'DESC') ->limit(1) ->get()->getFirstRow('array'); if (!$fallback) { $fallback = $this->attendanceDataModel->builder() ->select('school_id') ->where('student_id', $studentId) ->orderBy('date', 'DESC') ->limit(1) ->get()->getFirstRow('array'); } $schoolId = (string) ($fallback['school_id'] ?? ''); } if ($schoolId === '') return; // Insert a new row $row = [ 'class_id' => $classId, 'class_section_id' => $classSectionId, 'student_id' => $studentId, 'school_id' => $schoolId, 'date' => $reportDate, 'status' => $status, 'reason' => $reasonText, 'semester' => $semester, 'school_year' => $schoolYear, 'modified_by' => (int) (session()->get('user_id') ?? 0), 'created_at' => utc_now(), 'updated_at' => utc_now(), ]; if ($hasIsReported) { $row['is_reported'] = 'yes'; } if ($hasLegacyReported) { $row['reported'] = 1; } if ($hasIsNotified) { $row['is_notified'] = 'no'; } $this->attendanceDataModel->insert($row, false); $this->bumpAttendanceRecordFromParent($studentId, $classSectionId, $schoolId, $status, $semester, $schoolYear); return; } // If already exists: only mark reported for matching status; do not override teacher-marked present $existingStatus = strtolower((string) ($existing['status'] ?? '')); if ($existingStatus === $status) { $update = [ 'reason' => $existing['reason'] ?: $reasonText, 'updated_at' => utc_now(), ]; if ($hasIsReported) { $update['is_reported'] = 'yes'; } if ($hasLegacyReported) { $update['reported'] = 1; } $this->attendanceDataModel->update((int) $existing['id'], $update); return; } if ($existingStatus === '' || $existingStatus === 'present') { $update = [ 'status' => $status, 'reason' => $existing['reason'] ?: $reasonText, 'updated_at' => utc_now(), ]; if ($hasIsReported) { $update['is_reported'] = 'yes'; } if ($hasLegacyReported) { $update['reported'] = 1; } $this->attendanceDataModel->update((int) $existing['id'], $update); $classSectionId = (int) ($existing['class_section_id'] ?? $classSectionId ?? 0); $schoolId = (string) ($existing['school_id'] ?? ''); if ($classSectionId > 0 && $schoolId !== '') { $this->adjustAttendanceRecordForStatusChange( studentId: $studentId, classSectionId: $classSectionId, schoolId: $schoolId, semester: $semester, schoolYear: $schoolYear, oldStatus: $existingStatus, newStatus: $status ); } } } private function bumpAttendanceRecordFromParent( int $studentId, int $classSectionId, string $schoolId, string $status, string $semester, string $schoolYear ): void { $status = strtolower($status); $record = $this->attendanceRecordModel->where([ 'student_id' => $studentId, 'semester' => $semester, 'school_year' => $schoolYear, ])->first(); $now = utc_now(); if ($record) { $updates = [ 'total_presence' => (int)($record['total_presence'] ?? 0), 'total_absence' => (int)($record['total_absence'] ?? 0), 'total_late' => (int)($record['total_late'] ?? 0), 'total_attendance' => (int)($record['total_attendance'] ?? 0) + 1, 'updated_at' => $now, 'modified_by' => (int) (session()->get('user_id') ?? 0), ]; if ($status === 'present') { $updates['total_presence']++; } elseif ($status === 'absent') { $updates['total_absence']++; } elseif ($status === 'late') { $updates['total_late']++; } $this->attendanceRecordModel->update((int) $record['id'], $updates); return; } $insert = [ 'class_section_id' => $classSectionId, 'student_id' => $studentId, 'school_id' => $schoolId, 'semester' => $semester, 'school_year' => $schoolYear, 'created_at' => $now, 'updated_at' => $now, 'modified_by' => (int) (session()->get('user_id') ?? 0), 'total_presence' => ($status === 'present') ? 1 : 0, 'total_absence' => ($status === 'absent') ? 1 : 0, 'total_late' => ($status === 'late') ? 1 : 0, 'total_attendance' => 1, ]; $this->attendanceRecordModel->insert($insert); } private function adjustAttendanceRecordForStatusChange( int $studentId, int $classSectionId, string $schoolId, string $semester, string $schoolYear, string $oldStatus, string $newStatus ): void { $oldStatus = strtolower($oldStatus); $newStatus = strtolower($newStatus); if ($oldStatus === $newStatus) { return; } $record = $this->attendanceRecordModel->where([ 'student_id' => $studentId, 'semester' => $semester, 'school_year' => $schoolYear, ])->first(); if (!$record) { $this->bumpAttendanceRecordFromParent($studentId, $classSectionId, $schoolId, $newStatus, $semester, $schoolYear); return; } $updates = [ 'total_presence' => (int) ($record['total_presence'] ?? 0), 'total_absence' => (int) ($record['total_absence'] ?? 0), 'total_late' => (int) ($record['total_late'] ?? 0), 'total_attendance' => (int) ($record['total_attendance'] ?? 0), 'updated_at' => utc_now(), 'modified_by' => (int) (session()->get('user_id') ?? 0), ]; if ($oldStatus === 'present') { $updates['total_presence'] = max(0, $updates['total_presence'] - 1); } elseif ($oldStatus === 'absent') { $updates['total_absence'] = max(0, $updates['total_absence'] - 1); } elseif ($oldStatus === 'late') { $updates['total_late'] = max(0, $updates['total_late'] - 1); } if ($newStatus === 'present') { $updates['total_presence']++; } elseif ($newStatus === 'absent') { $updates['total_absence']++; } elseif ($newStatus === 'late') { $updates['total_late']++; } $this->attendanceRecordModel->update((int) $record['id'], $updates); } // GET: staff view to list reports (today & upcoming by default) public function list() { $role = strtolower((string) (session()->get('role') ?? '')); if (!in_array($role, ['teacher', 'teacher_assistant', 'administrator', 'admin'], true)) { return redirect()->to('/access_denied'); } $start = (string)($this->request->getGet('start') ?: local_date(utc_now(), 'Y-m-d')); $end = (string)($this->request->getGet('end') ?: ''); // open-ended $schoolYearParam = $this->request->getGet('school_year'); $semesterParam = $this->request->getGet('semester'); $schoolYear = (is_string($schoolYearParam) && $schoolYearParam !== '') ? (string)$schoolYearParam : (string)$this->configModel->getConfig('school_year'); $semester = (is_string($semesterParam) && $semesterParam !== '') ? (string)$semesterParam : (string)$this->configModel->getConfig('semester'); $rows = $this->reportModel->listForDateRange($start, $end ?: null, $schoolYear, $semester); return view('attendance/parent_reports', [ 'rows' => $rows, 'start' => $start, 'end' => $end ?: null, 'schoolYear' => $schoolYear, 'semester' => $semester, ]); } private function canAccessEarlyDismissalsNav(): bool { $sessionRole = session()->get('role'); $roleNames = is_array($sessionRole) ? $sessionRole : [$sessionRole]; $roleNames = array_values(array_filter(array_map('strval', $roleNames))); if (empty($roleNames)) { return false; } $roleIdRows = $this->db->table('roles') ->select('id') ->whereIn('name', $roleNames) ->get() ->getResultArray(); $roleIds = array_map('intval', array_column($roleIdRows, 'id')); if (empty($roleIds)) { return false; } $allowed = $this->db->table('role_nav_items AS rni') ->select('1') ->join('nav_items AS ni', 'ni.id = rni.nav_item_id') ->where('ni.url', 'attendance/early-dismissals') ->whereIn('rni.role_id', $roleIds) ->get(1) ->getFirstRow(); return (bool) $allowed; } private function canManageEarlyDismissals(): bool { $allowedRoles = ['teacher', 'teacher_assistant', 'administrator', 'admin']; $sessionRole = session()->get('role'); $roles = is_array($sessionRole) ? $sessionRole : [$sessionRole]; foreach ($roles as $role) { $role = strtolower((string) $role); if ($role !== '' && in_array($role, $allowedRoles, true)) { return true; } } return false; } // GET: staff page listing early dismissals by month public function earlyDismissals() { // Authorize via role->nav mapping instead of hardcoded roles if (! $this->canAccessEarlyDismissalsNav()) { return redirect()->to('/access_denied'); } // Get filters $schoolYearParam = $this->request->getGet('school_year'); $semesterParam = $this->request->getGet('semester'); // Default to config values $schoolYear = (is_string($schoolYearParam) && $schoolYearParam !== '') ? trim($schoolYearParam) : (string)$this->configModel->getConfig('school_year'); $semester = (is_string($semesterParam) && $semesterParam !== '') ? trim($semesterParam) : ''; // Query early dismissal reports $b = $this->reportModel->builder(); $b->select('parent_attendance_reports.*, s.firstname, s.lastname, cs.class_section_name') ->join('students s', 's.id = parent_attendance_reports.student_id', 'left') ->join('classSection cs', 'cs.class_section_id = parent_attendance_reports.class_section_id', 'left') ->where('parent_attendance_reports.type', 'early_dismissal'); // Always filter by school year if ($schoolYear !== '') { $b->where('parent_attendance_reports.school_year', $schoolYear); } // Filter by semester only if chosen if ($semester !== '') { $b->where('parent_attendance_reports.semester', $semester); } $rows = $b->orderBy('parent_attendance_reports.report_date', 'DESC') ->orderBy('parent_attendance_reports.dismiss_time', 'ASC') ->get() ->getResultArray(); // Group results by date $groups = []; foreach ($rows as $r) { $d = substr((string)($r['report_date'] ?? ''), 0, 10); if ($d === '') { $d = 'Unknown'; } $groups[$d][] = $r; } uksort($groups, static function ($a, $b) { if ($a === 'Unknown' && $b === 'Unknown') { return 0; } if ($a === 'Unknown') { return 1; } if ($b === 'Unknown') { return -1; } return strcmp($b, $a); // newest date first (Y-m-d sorts lexicographically) }); $signatureByDate = []; if (!empty($groups)) { $dates = array_values(array_filter(array_keys($groups), static function ($d) { return preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $d); })); if (!empty($dates)) { $sigQ = $this->earlyDismissalSignatureModel ->whereIn('report_date', $dates); if ($schoolYear !== '') { $sigQ->where('school_year', $schoolYear); } if ($semester !== '') { $sigQ->where('semester', $semester); } $sigRows = $sigQ->orderBy('report_date', 'ASC')->findAll(); foreach ($sigRows as $sig) { $d = substr((string) ($sig['report_date'] ?? ''), 0, 10); if ($d !== '') { $signatureByDate[$d] = $sig; } } } } return view('attendance/early_dismissals', [ 'groups' => $groups, 'schoolYear' => $schoolYear, 'semester' => $semester, 'signatureByDate' => $signatureByDate, ]); } private function ensureUploadSubdir(string $subdir): string { $base = rtrim(WRITEPATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'uploads'; $dir = $base . DIRECTORY_SEPARATOR . trim($subdir, '/\\'); if (!is_dir($dir)) { if (!@mkdir($dir, 0755, true) && !is_dir($dir)) { log_message('error', 'Failed to create upload directory: ' . $dir); throw new \RuntimeException('Upload directory is not available.'); } } if (!is_writable($dir)) { log_message('error', 'Upload directory not writable: ' . $dir); throw new \RuntimeException('Upload directory is not writable.'); } return $dir; } // POST: upload parent signature document for a specific early dismissal date public function uploadEarlyDismissalSignature() { if (! $this->canAccessEarlyDismissalsNav() || ! $this->canManageEarlyDismissals()) { return redirect()->to('/access_denied'); } if (! $this->request->is('post')) { return redirect()->to(site_url('attendance/early-dismissals')); } $rules = [ 'report_date' => 'required|valid_date[Y-m-d]', 'signature_file' => 'uploaded[signature_file]' . '|max_size[signature_file,4096]' . '|ext_in[signature_file,jpg,jpeg,png,webp,gif,pdf]' . '|mime_in[signature_file,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]', 'school_year' => 'permit_empty|string', 'semester' => 'permit_empty|string', 'return_url' => 'permit_empty|string', ]; $messages = [ 'signature_file' => [ 'uploaded' => 'Signature file is required.', 'max_size' => 'Maximum file size is 4MB.', 'ext_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.', 'mime_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.', ], ]; if (! $this->validate($rules, $messages)) { return redirect()->back()->withInput()->with('error', $this->validator->listErrors()); } $reportDate = substr((string) $this->request->getPost('report_date'), 0, 10); $returnUrl = (string) ($this->request->getPost('return_url') ?? site_url('attendance/early-dismissals')); $schoolYear = (string) ($this->request->getPost('school_year') ?? $this->configModel->getConfig('school_year')); $semesterRaw = (string) ($this->request->getPost('semester') ?? ''); $hasSemesterFilter = ($semesterRaw !== ''); $semester = $hasSemesterFilter ? $semesterRaw : null; $dt = \DateTime::createFromFormat('Y-m-d', $reportDate); if (!$dt || (int) $dt->format('w') !== 0) { return redirect()->to($returnUrl)->with('error', 'Please select a Sunday date.'); } $existsQ = $this->reportModel->builder() ->select('id') ->where('type', 'early_dismissal') ->where('report_date', $reportDate); if ($schoolYear !== '') { $existsQ->where('school_year', $schoolYear); } if ($hasSemesterFilter) { $existsQ->where('semester', $semester); } $exists = $existsQ->get(1)->getRowArray(); if (!$exists) { return redirect()->to($returnUrl)->with('error', 'No early dismissal records found for that date.'); } $file = $this->request->getFile('signature_file'); if (! $file || ! $file->isValid() || $file->hasMoved()) { return redirect()->to($returnUrl)->with('error', 'Invalid signature file.'); } try { $this->ensureUploadSubdir('early_dismissal_signatures'); $stored = $file->store('early_dismissal_signatures'); } catch (\Throwable $e) { log_message('error', 'Signature upload failed: ' . $e->getMessage()); return redirect()->to($returnUrl)->with('error', 'Unable to save the signature file.'); } if (!$stored) { return redirect()->to($returnUrl)->with('error', 'Unable to save the signature file.'); } $filename = basename($stored); $payload = [ 'report_date' => $reportDate, 'school_year' => $schoolYear !== '' ? $schoolYear : null, 'semester' => $semester, 'filename' => $filename, 'original_name' => $file->getClientName(), 'mime_type' => $file->getClientMimeType(), 'file_size' => (int) ($file->getSize() ?? 0), 'uploaded_by' => (int) (session()->get('user_id') ?? 0), ]; $existingQ = $this->earlyDismissalSignatureModel ->where('report_date', $reportDate); if ($schoolYear !== '') { $existingQ->where('school_year', $schoolYear); } if ($hasSemesterFilter) { $existingQ->where('semester', $semester); } $existing = $existingQ->first(); try { if ($existing && !empty($existing['id'])) { $oldFile = (string) ($existing['filename'] ?? ''); $this->earlyDismissalSignatureModel->update((int) $existing['id'], $payload); if ($oldFile !== '' && $oldFile !== $filename) { $oldPath = WRITEPATH . 'uploads/early_dismissal_signatures/' . $oldFile; if (is_file($oldPath)) { @unlink($oldPath); } } } else { $this->earlyDismissalSignatureModel->insert($payload); } } catch (\Throwable $e) { log_message('error', 'Signature DB save failed: ' . $e->getMessage()); return redirect()->to($returnUrl)->with('error', 'Unable to record the signature file.'); } return redirect()->to($returnUrl)->with('message', 'Signature uploaded for ' . esc($reportDate) . '.'); } // GET: form for staff to add an early dismissal entry public function addEarlyDismissalForm() { $role = strtolower((string) (session()->get('role') ?? '')); if (!in_array($role, ['teacher', 'teacher_assistant', 'administrator', 'admin'], true)) { return redirect()->to('/access_denied'); } $schoolYear = (string)$this->configModel->getConfig('school_year'); $semester = (string)$this->configModel->getConfig('semester'); // Fetch students with their current-year class section (if any) try { $students = $this->db->table('students s') ->select('s.id, s.firstname, s.lastname, s.parent_id, cs.class_section_name, sc.class_section_id') ->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $this->db->escape($schoolYear), 'left') ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') ->orderBy('s.lastname', 'ASC') ->orderBy('s.firstname', 'ASC') ->get()->getResultArray(); } catch (\Throwable $e) { $students = []; } // Suggest upcoming Sunday (or today if Sunday) [$_, $defaultDate] = $this->computeSundays(0, 26); return view('attendance/early_dismissals_add', [ 'students' => $students, 'defaultDate' => $defaultDate, 'schoolYear' => $schoolYear, 'semester' => $semester, ]); } // POST: save staff-created early dismissal public function saveEarlyDismissal() { $role = strtolower((string) (session()->get('role') ?? '')); if (!in_array($role, ['teacher', 'teacher_assistant', 'administrator', 'admin'], true)) { return redirect()->to('/access_denied'); } $rules = [ 'student_id' => 'required|is_natural_no_zero', 'date' => 'required|valid_date[Y-m-d]', 'dismiss_time' => 'required|regex_match[/^\d{2}:\d{2}(:\d{2})?$/]', 'reason' => 'permit_empty|string', ]; if (! $this->validate($rules)) { return redirect()->back()->withInput()->with('error', implode(' ', $this->validator->getErrors())); } $studentId = (int) $this->request->getPost('student_id'); $reportDate = substr((string) $this->request->getPost('date'), 0, 10); $dismissTime = (string) $this->request->getPost('dismiss_time'); $reason = (string) ($this->request->getPost('reason') ?? ''); // Sunday-only enforcement (staff can enter past Sundays if needed) $dt = \DateTime::createFromFormat('Y-m-d', $reportDate); if (!$dt || (int)$dt->format('w') !== 0) { return redirect()->back()->withInput()->with('error', 'Please select a Sunday date.'); } $schoolYear = (string)$this->configModel->getConfig('school_year'); $semester = (string)$this->configModel->getConfig('semester'); // Resolve parent_id of the student $stu = $this->studentModel->select('id, parent_id, firstname, lastname')->find($studentId); if (!$stu || empty($stu['parent_id'])) { return redirect()->back()->withInput()->with('error', 'Selected student not found or has no primary parent assigned.'); } $parentId = (int) $stu['parent_id']; // Resolve class_section_id for current school year $sectionRow = $this->db->table('student_class') ->select('class_section_id') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->orderBy('updated_at', 'DESC') ->limit(1) ->get()->getRowArray(); $classSectionId = isset($sectionRow['class_section_id']) ? (int)$sectionRow['class_section_id'] : null; try { // Block if Absent/Late exists for this date $qb = $this->reportModel->builder(); $qb->where('student_id', $studentId) ->where('report_date', $reportDate) ->where('school_year', $schoolYear); $existingAL = (clone $qb) ->groupStart() ->where('type', 'absent') ->orWhere('type', 'late') ->groupEnd() ->get()->getRowArray(); if ($existingAL) { return redirect()->back()->withInput()->with('error', 'Absent/Late already submitted for this student on the selected date.'); } // If early dismissal already exists, update time; else insert new $existingED = (clone $qb)->where('type', 'early_dismissal')->get()->getFirstRow('array'); if ($existingED) { $this->reportModel->update((int)$existingED['id'], [ 'dismiss_time' => $dismissTime ?: $existingED['dismiss_time'], 'reason' => $reason ?: ($existingED['reason'] ?? null), 'status' => 'new', ]); } else { $this->reportModel->insert([ 'parent_id' => $parentId, 'student_id' => $studentId, 'class_section_id' => $classSectionId, 'report_date' => $reportDate, 'type' => 'early_dismissal', 'dismiss_time' => $dismissTime, 'reason' => ($reason !== '') ? $reason : null, 'semester' => $semester, 'school_year' => $schoolYear, 'status' => 'new', ], false); } } catch (\Throwable $e) { log_message('error', 'Admin add early dismissal failed: ' . $e->getMessage()); return redirect()->back()->withInput()->with('error', 'Could not save early dismissal. Please try again.'); } $name = trim(($stu['firstname'] ?? '') . ' ' . ($stu['lastname'] ?? '')); return redirect()->to(site_url('attendance/early-dismissals')) ->with('message', 'Early dismissal saved for ' . esc($name) . ' on ' . esc($reportDate) . ' at ' . esc(substr($dismissTime, 0, 5)) . '.'); } // POST (AJAX): check existing submissions for given date and student IDs public function checkExisting() { if (! $this->request->isAJAX()) { return $this->response->setStatusCode(405)->setJSON(['ok' => false, 'error' => 'Method not allowed']); } $datesPost = $this->request->getPost('dates'); $dateSingle = $this->request->getPost('date'); $dateValues = []; if (is_array($datesPost)) { foreach ($datesPost as $d) { $dateValues[] = substr((string) $d, 0, 10); } } elseif ($dateSingle !== null) { $dateValues[] = substr((string) $dateSingle, 0, 10); } $dateValues = array_values(array_unique(array_filter($dateValues, static function ($d) { return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d); }))); $type = (string) $this->request->getPost('type'); $ids = (array) $this->request->getPost('student_ids'); // Basic validation if (empty($dateValues)) { return $this->response->setStatusCode(400)->setJSON(['ok' => false, 'error' => 'Invalid date']); } if (!in_array($type, ['absent', 'late', 'early_dismissal'], true)) { return $this->response->setStatusCode(400)->setJSON(['ok' => false, 'error' => 'Invalid type']); } $studentIds = array_values(array_unique(array_map('intval', $ids))); $studentIds = array_filter($studentIds, static fn($v) => $v > 0); if (empty($studentIds)) { return $this->response->setJSON(['ok' => true, 'students' => []]); } // Fetch existing submissions for these students on this date $rows = $this->reportModel->builder() ->select('parent_attendance_reports.student_id, parent_attendance_reports.type, parent_attendance_reports.report_date, s.firstname, s.lastname') ->join('students s', 's.id = parent_attendance_reports.student_id', 'left') ->whereIn('parent_attendance_reports.report_date', $dateValues) ->whereIn('parent_attendance_reports.student_id', $studentIds) ->get()->getResultArray(); $map = []; foreach ($rows as $r) { $sid = (int) ($r['student_id'] ?? 0); if ($sid <= 0) continue; if (!isset($map[$sid])) { $map[$sid] = [ 'student_id' => $sid, 'firstname' => (string) ($r['firstname'] ?? ''), 'lastname' => (string) ($r['lastname'] ?? ''), 'dates' => [], ]; } $dVal = substr((string) ($r['report_date'] ?? ''), 0, 10); if ($dVal === '') { continue; } if (!isset($map[$sid]['dates'][$dVal])) { $map[$sid]['dates'][$dVal] = []; } $t = (string) ($r['type'] ?? ''); if ($t !== '' && !in_array($t, $map[$sid]['dates'][$dVal], true)) { $map[$sid]['dates'][$dVal][] = $t; } } return $this->response->setJSON([ 'ok' => true, 'students' => array_values($map), ]); } // POST: update an existing parent report (limited fields) before 9:00 AM on report date public function update() { $parentId = $this->resolvePrimaryParentIdFromSession(); if (!$parentId) { return redirect()->to('/login'); } $id = (int) ($this->request->getPost('id') ?? 0); if ($id <= 0) { return redirect()->back()->with('error', 'Invalid report.'); } $row = $this->reportModel->find($id); if (!$row || (int)($row['parent_id'] ?? 0) !== (int)$parentId) { return redirect()->back()->with('error', 'Report not found.'); } // Only allow editing for 'new' status entries $status = (string) ($row['status'] ?? ''); if ($status !== 'new') { return redirect()->back()->with('error', 'This report can no longer be edited.'); } // Enforce 9:00 AM cutoff on the report_date (school timezone) try { $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); $tz = new \DateTimeZone($tzName ?: 'UTC'); $cutoff = new \DateTime(($row['report_date'] ?? '') . ' 09:00:00', $tz); $now = new \DateTime('now', $tz); if ($now >= $cutoff) { return redirect()->back()->with('error', 'Editing window closed at 9:00 AM on the report date.'); } } catch (\Throwable $e) { log_message('warning', 'Edit cutoff check failed: ' . $e->getMessage()); // Fail-safe: do not allow edit if cutoff cannot be computed reliably return redirect()->back()->with('error', 'Editing is unavailable at this time.'); } // Collect permissible fields $type = (string) ($row['type'] ?? ''); $reason = trim((string) ($this->request->getPost('reason') ?? '')); $arrival = (string) ($this->request->getPost('arrival_time') ?? ''); $dismiss = (string) ($this->request->getPost('dismiss_time') ?? ''); $update = []; if ($reason !== '') { $update['reason'] = $reason; } else { $update['reason'] = null; // allow clearing } if ($type === 'late') { if ($arrival !== '') { if (!preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $arrival)) { return redirect()->back()->with('error', 'Invalid arrival time format.'); } $update['arrival_time'] = $arrival; } } elseif ($type === 'early_dismissal') { if ($dismiss !== '') { if (!preg_match('/^\d{2}:\d{2}(:\d{2})?$/', $dismiss)) { return redirect()->back()->with('error', 'Invalid dismissal time format.'); } $update['dismiss_time'] = $dismiss; } } if (empty($update)) { return redirect()->back()->with('error', 'Nothing to update.'); } try { $this->reportModel->update($id, $update); } catch (\Throwable $e) { log_message('error', 'Parent report update failed: ' . $e->getMessage()); return redirect()->back()->with('error', 'Could not save changes.'); } return redirect()->to(site_url('parent/report-attendance'))->with('message', 'Changes saved.'); } private function resolvePrimaryParentIdFromSession(): ?int { $userId = (int) (session()->get('user_id') ?? 0); $userType = (string) (session()->get('user_type') ?? ''); if ($userId <= 0) return null; if ($userType === 'primary' || $userType === '') { return $userId; } if ($userType === 'secondary') { $parentData = $this->db->table('parents') ->select('parent_id') ->where('secondparent_user_id', $userId) ->orderBy('updated_at', 'DESC') ->limit(1) ->get()->getRowArray(); if (!empty($parentData['parent_id'])) { return (int) $parentData['parent_id']; } } if ($userType === 'tertiary') { $authUserData = $this->db->table('authorized_users') ->select('user_id as parent_id') ->where('authorized_user_id', $userId) ->orderBy('updated_at', 'DESC') ->limit(1) ->get()->getRowArray(); if (!empty($authUserData['parent_id'])) { return (int) $authUserData['parent_id']; } } return $userId; // fallback } private function normalizeCutoffTime(string $raw, string $default = '09:00'): string { $raw = trim($raw); if ($raw === '') { return $default; } if (preg_match('/^(\d{1,2}):(\d{2})$/', $raw, $m)) { $hh = str_pad((string) ((int) $m[1]), 2, '0', STR_PAD_LEFT); $mm = $m[2]; return $hh . ':' . $mm; } return $default; } /** * Send confirmation/notification emails for parent attendance submissions. */ private function sendParentReportNotifications(array $context): void { $parentId = (int) ($context['parent_id'] ?? 0); $submitterId = (int) ($context['submitter_id'] ?? 0); $students = $context['students'] ?? []; if ($parentId <= 0 || empty($students) || !is_array($students)) { return; } $primaryParent = $this->getUserContactRow($parentId); $submitter = $submitterId > 0 ? $this->getUserContactRow($submitterId) : null; if (!$primaryParent && !$submitter) { return; } if (!$submitter) { $submitter = $primaryParent; } $type = (string) ($context['type'] ?? 'absent'); $typeLabel = $this->formatParentReportType($type); $reportDate = (string) ($context['report_date'] ?? ''); $dateCodesRaw = (array) ($context['dates'] ?? []); $dateCodes = []; foreach ($dateCodesRaw as $code) { $ymd = substr((string) $code, 0, 10); if ($ymd === '' || isset($dateCodes[$ymd])) { continue; } $dateCodes[$ymd] = $ymd; } if (empty($dateCodes) && $reportDate !== '') { $dateCodes[$reportDate] = $reportDate; } $dateCodes = array_values($dateCodes); $effectiveDate = $dateCodes[0] ?? $reportDate; $reportDateFmt = $this->formatReportDate($effectiveDate); $dateLabels = array_map(function ($d) { return $this->formatReportDate($d) ?? $d; }, $dateCodes); $reason = trim((string) ($context['reason'] ?? '')); $semester = (string) ($context['semester'] ?? ''); $schoolYear = (string) ($context['school_year'] ?? ''); $payload = [ 'recipient_parent' => $submitter, 'primary_parent' => $primaryParent, 'students' => $students, 'student_count' => count($students), 'type' => $type, 'type_label' => $typeLabel, 'report_date' => $effectiveDate, 'report_date_fmt' => $reportDateFmt, 'dates' => $dateCodes, 'date_labels' => $dateLabels, 'date_summary' => $this->formatDateList($dateCodes), 'reason' => $reason, 'semester' => $semester, 'school_year' => $schoolYear, 'submitted_at' => local_date(utc_now(), 'm-d-Y g:i A'), ]; $mailer = new EmailController(); $recipientEmail = trim((string) ($submitter['email'] ?? '')); if ($recipientEmail === '' && $primaryParent && $primaryParent !== $submitter) { $recipientEmail = trim((string) ($primaryParent['email'] ?? '')); } $subjectType = $typeLabel ?: 'Attendance'; $subjectTypeClean = ucfirst(strtolower($subjectType)); $subjectDateSuffix = $reportDateFmt ?: ($effectiveDate ?: ''); if ($recipientEmail !== '') { $subjectParent = $subjectTypeClean . ' Request Received'; $parentBody = view('emails/parent_attendance_parent', $payload + ['subject' => $subjectParent]); if (! $mailer->sendEmail($recipientEmail, $subjectParent, $parentBody)) { log_message('error', 'Failed to send parent attendance confirmation to ' . $recipientEmail); } } $subjectAdmin = $subjectTypeClean . ' Request'; $adminBody = view('emails/parent_attendance_admin', $payload + ['subject' => $subjectAdmin]); $adminRecipient = $this->adminParentReportEmail(); if ($adminRecipient === '' || ! $mailer->sendEmail($adminRecipient, $subjectAdmin, $adminBody)) { log_message('error', 'Failed to send parent attendance alert to admin inbox.'); } } private function getUserContactRow(?int $userId): ?array { $uid = (int) ($userId ?? 0); if ($uid <= 0) { return null; } $row = $this->db->table('users') ->select('id, firstname, lastname, email, cellphone') ->where('id', $uid) ->limit(1) ->get() ->getRowArray(); return $row ?: null; } private function resolveClassSectionLabel(?int $classSectionId): string { $cid = (int) ($classSectionId ?? 0); if ($cid <= 0) { return 'Not Assigned'; } try { $name = $this->classSectionModel->getClassSectionNameBySectionId($cid); if (is_string($name) && $name !== '') { return $name; } } catch (\Throwable $e) { log_message('debug', 'Failed to resolve class section label: ' . $e->getMessage()); } return 'Not Assigned'; } private function formatParentReportType(string $type): string { return match ($type) { 'late' => 'Late Arrival', 'early_dismissal' => 'Early Dismissal', default => 'Absence', }; } private function formatReportDate(?string $ymd): ?string { if (!$ymd) { return null; } try { $dt = new \DateTime($ymd); return $dt->format('m-d-Y'); } catch (\Throwable $e) { return $ymd; } } private function formatTimeDisplay(?string $time): ?string { $t = trim((string) ($time ?? '')); if ($t === '') { return null; } $dt = \DateTime::createFromFormat('H:i:s', $t) ?: \DateTime::createFromFormat('H:i', $t); if ($dt instanceof \DateTime) { return $dt->format('g:i A'); } return $t; } private function formatDateList(array $dates): string { $seen = []; $labels = []; foreach ($dates as $d) { $ymd = substr((string) $d, 0, 10); if ($ymd === '' || isset($seen[$ymd])) { continue; } $seen[$ymd] = true; $labels[] = $this->formatReportDate($ymd) ?? $ymd; } return implode(', ', array_filter($labels, static function ($v) { return $v !== null && $v !== ''; })); } private function adminParentReportEmail(): string { $candidates = [ (string) env('MAIL_PARENT_REPORT_TO', ''), (string) env('MAIL_DEFAULT_REPLY_TO', ''), (string) env('MAIL_FROM_ADDRESS', ''), ]; foreach ($candidates as $value) { $trimmed = trim($value); if ($trimmed !== '') { return $trimmed; } } $emailConfig = config('Email'); if ($emailConfig && !empty($emailConfig->fromEmail)) { return $emailConfig->fromEmail; } return ''; } }