1834 lines
76 KiB
PHP
Executable File
1834 lines
76 KiB
PHP
Executable File
<?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 '';
|
|
}
|
|
}
|