Files
alrahma_sunday_school_api/app/Http/Controllers/old/ParentAttendanceReportController.php
T
2026-03-08 16:33:24 -04:00

1834 lines
76 KiB
PHP

<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ConfigurationModel;
use App\Models\EarlyDismissalSignatureModel;
use App\Models\ParentAttendanceReportModel;
use App\Models\StudentModel;
use App\Models\StudentClassModel;
use App\Models\ClassSectionModel;
use App\Models\AttendanceDataModel;
use App\Models\AttendanceRecordModel;
use App\Services\SemesterRangeService;
class ParentAttendanceReportController extends BaseController
{
protected $db;
protected $configModel;
protected $reportModel;
protected $studentModel;
protected $studentClassModel;
protected $classSectionModel;
protected $attendanceDataModel;
protected $earlyDismissalSignatureModel;
protected $attendanceRecordModel;
public function __construct()
{
$this->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 = '✅ <strong>Submission received successfully for ' . count($successNames) . ' item' . (count($successNames) > 1 ? 's' : '') . '.</strong><br>';
$msg .= '<ul style="margin-top:5px;">';
foreach ($successNames as $s) {
$dateLabel = $s['date_label'] ?? ($s['date'] ?? '');
$msg .= '<li><strong>' . esc($s['name']) . '</strong> — ' . esc($s['type'])
. ($dateLabel ? ' on <strong>' . esc($dateLabel) . '</strong>' : '')
. (($s['time'] ?? '') && $s['time'] !== 'N/A' ? ' at <strong>' . esc($s['time']) . '</strong>' : '')
. '</li>';
}
$msg .= '</ul>';
$dateSummary = $this->formatDateList($selectedDateList);
if ($dateSummary !== '') {
$msg .= '<strong>Date(s):</strong> ' . esc($dateSummary);
}
if (!empty($blocked)) {
$msg .= '<br><em>Note:</em> 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<int, array{value:string,label:string}>, 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 '';
}
}