configService->context(); $students = Student::query() ->where('parent_id', $parentId) ->orderBy('firstname') ->orderBy('lastname') ->get() ->toArray(); [$sundays, $defaultDate] = $this->calendarService->computeSundays(); $today = now()->toDateString(); $schoolYear = (string) ($context['school_year'] ?? ''); $previewRows = DB::table('parent_attendance_reports as par') ->select('par.*', 's.firstname', 's.lastname') ->leftJoin('students as s', 's.id', '=', 'par.student_id') ->where('par.parent_id', $parentId) ->where('par.school_year', $schoolYear) ->where('par.report_date', '>=', $today) ->orderBy('par.report_date') ->orderBy('s.firstname') ->orderBy('s.lastname') ->get() ->map(fn ($row) => (array) $row) ->all(); $cutoffThreshold = $this->calendarService->normalizeCutoffTime( (string) ($context['parent_report_cutoff'] ?? ''), '09:00' ); return [ 'students' => $students, 'today' => $today, 'sundays' => $sundays, 'defaultDate' => $defaultDate, 'myReports' => $previewRows, 'cutoffThreshold' => $cutoffThreshold, ]; } public function submit(int $parentId, array $payload): array { $context = $this->configService->context(); $schoolYear = (string) ($context['school_year'] ?? ''); $semester = (string) ($context['semester'] ?? ''); $studentIds = array_values(array_filter(array_map('intval', (array) ($payload['student_ids'] ?? [])))); if (empty($studentIds)) { throw new \RuntimeException('At least select one student.'); } $datesInput = $payload['dates'] ?? ($payload['date'] ?? null); $rawDates = []; if (is_array($datesInput)) { $rawDates = $datesInput; } elseif ($datesInput !== null) { $rawDates = [$datesInput]; } $rawDates = array_values(array_unique(array_filter(array_map(static function ($val) { return substr((string) $val, 0, 10); }, $rawDates)))); if (empty($rawDates)) { throw new \RuntimeException('Please select at least one Sunday date.'); } $type = (string) ($payload['type'] ?? ''); if (! in_array($type, ['absent', 'late', 'early_dismissal'], true)) { throw new \RuntimeException('Invalid report type.'); } $arrivalTime = $payload['arrival_time'] ?? null; $dismissTime = $payload['dismiss_time'] ?? null; $reason = isset($payload['reason']) ? trim((string) $payload['reason']) : null; $todayCheck = new \DateTime('today'); $cap = $this->calendarService->firstSundayOfJune($schoolYear); $validDates = []; foreach ($rawDates as $entry) { $dt = \DateTime::createFromFormat('Y-m-d', $entry); if (! $dt) { throw new \RuntimeException('Invalid date selected: '.$entry); } if ((int) $dt->format('w') !== 0) { throw new \RuntimeException('Please select Sunday dates only.'); } if ($dt < $todayCheck) { throw new \RuntimeException('Past dates are not allowed. Please select today or a future Sunday.'); } if ($dt > $cap) { throw new \RuntimeException('The last available date is the first Sunday of June.'); } $validDates[$entry] = $dt; } if ($type === 'late' && (! is_string($arrivalTime) || $arrivalTime === '')) { throw new \RuntimeException('Please provide an expected arrival time for late reporting.'); } if ($type === 'early_dismissal' && (! is_string($dismissTime) || $dismissTime === '')) { throw new \RuntimeException('Please provide a dismissal time for early dismissal.'); } if ($type !== 'early_dismissal') { if ($reason === null || $reason === '') { throw new \RuntimeException('Please provide a reason for absence or late arrival.'); } } $inserted = 0; $blocked = []; $successDetails = []; foreach ($studentIds as $sid) { if ($sid <= 0) { continue; } $sectionRow = DB::table('student_class') ->select('class_section_id') ->where('student_id', $sid) ->where('school_year', $schoolYear) ->orderByDesc('updated_at') ->first(); $classSectionId = $sectionRow->class_section_id ?? null; $student = Student::query()->select('firstname', 'lastname')->find($sid); $studentName = $student ? trim($student->firstname.' '.$student->lastname) : ('Student #'.$sid); $classLabel = $this->resolveClassSectionLabel($classSectionId); foreach ($validDates as $reportDate => $_dt) { $semesterForDate = $this->semesterRangeService->getSemesterForDate($reportDate) ?: $semester; $saved = false; $qb = ParentAttendanceReport::query() ->where('student_id', $sid) ->where('report_date', $reportDate); if ($type === 'early_dismissal') { $existingAL = (clone $qb) ->whereIn('type', ['absent', 'late']) ->first(); if ($existingAL) { $blocked[] = $studentName.' ('.$reportDate.')'; } else { $existingED = (clone $qb)->where('type', 'early_dismissal')->first(); if ($existingED) { $existingED->update([ 'dismiss_time' => $dismissTime ?: $existingED->dismiss_time, 'status' => 'new', ]); $saved = true; $inserted++; } else { ParentAttendanceReport::query()->create([ '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', ]); $saved = true; $inserted++; } } } else { $existingAny = (clone $qb) ->whereIn('type', ['absent', 'late', 'early_dismissal']) ->first(); if ($existingAny) { $blocked[] = $studentName.' ('.$reportDate.')'; } else { ParentAttendanceReport::query()->create([ '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', ]); $saved = true; $inserted++; } } if ($saved && in_array($type, ['absent', 'late'], true)) { $this->reflectToAttendanceData( studentId: $sid, reportDate: $reportDate, type: $type, semester: $semesterForDate, schoolYear: $schoolYear, classSectionId: $classSectionId, reason: $reason, arrivalTime: $arrivalTime ); } if ($saved) { $successDetails[] = [ 'student_id' => $sid, 'name' => $studentName, 'class_label' => $classLabel, 'type' => $type, 'arrival_time' => $arrivalTime, 'dismiss_time' => $dismissTime, 'date' => $reportDate, ]; } } } 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.'; throw new \RuntimeException($err); } return [ 'inserted' => $inserted, 'blocked' => $blocked, 'students' => $successDetails, 'dates' => array_keys($validDates), ]; } public function listReports(string $start, ?string $end, ?string $schoolYear, ?string $semester, int $parentId): array { return ParentAttendanceReport::listForDateRange($start, $end, $schoolYear, $semester, $parentId); } public function checkExisting(int $parentId, array $payload): array { $datesPost = $payload['dates'] ?? null; $dateSingle = $payload['date'] ?? null; $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) ($payload['type'] ?? ''); if (empty($dateValues) || ! in_array($type, ['absent', 'late', 'early_dismissal'], true)) { throw new \RuntimeException('Invalid date or type.'); } $studentIds = array_values(array_unique(array_map('intval', (array) ($payload['student_ids'] ?? [])))); $studentIds = array_filter($studentIds, static fn ($v) => $v > 0); if (empty($studentIds)) { return []; } $uniqueIds = array_values(array_unique($studentIds)); $ownedCount = Student::query() ->where('parent_id', $parentId) ->whereIn('id', $uniqueIds) ->count(); if ($ownedCount !== count($uniqueIds)) { throw new \RuntimeException('Invalid student selection.'); } $rows = DB::table('parent_attendance_reports as par') ->select('par.student_id', 'par.type', 'par.report_date', 's.firstname', 's.lastname') ->leftJoin('students as s', 's.id', '=', 'par.student_id') ->whereIn('par.report_date', $dateValues) ->whereIn('par.student_id', $studentIds) ->get() ->map(fn ($row) => (array) $row) ->all(); $map = []; foreach ($rows as $row) { $sid = (int) ($row['student_id'] ?? 0); if ($sid <= 0) { continue; } if (! isset($map[$sid])) { $map[$sid] = [ 'student_id' => $sid, 'firstname' => (string) ($row['firstname'] ?? ''), 'lastname' => (string) ($row['lastname'] ?? ''), 'dates' => [], ]; } $dVal = substr((string) ($row['report_date'] ?? ''), 0, 10); if ($dVal === '') { continue; } $map[$sid]['dates'][$dVal] = $map[$sid]['dates'][$dVal] ?? []; $t = (string) ($row['type'] ?? ''); if ($t !== '' && ! in_array($t, $map[$sid]['dates'][$dVal], true)) { $map[$sid]['dates'][$dVal][] = $t; } } return array_values($map); } public function updateReport(int $parentId, int $reportId, array $payload): void { $row = ParentAttendanceReport::query()->find($reportId); if (! $row || (int) $row->parent_id !== $parentId) { throw new \RuntimeException('Report not found.'); } if ((string) ($row->status ?? '') !== 'new') { throw new \RuntimeException('This report can no longer be edited.'); } $tzName = (string) (config('School')->attendance['timezone'] ?? 'UTC'); $tz = new \DateTimeZone($tzName ?: 'UTC'); $cutoff = new \DateTime($row->report_date.' 09:00:00', $tz); $now = new \DateTime('now', $tz); if ($now >= $cutoff) { throw new \RuntimeException('Editing window closed at 9:00 AM on the report date.'); } $type = (string) $row->type; $reason = trim((string) ($payload['reason'] ?? '')); $arrival = (string) ($payload['arrival_time'] ?? ''); $dismiss = (string) ($payload['dismiss_time'] ?? ''); $update = [ 'reason' => $reason !== '' ? $reason : null, ]; if ($type === 'late' && $arrival !== '') { if (! preg_match('/^\\d{2}:\\d{2}(:\\d{2})?$/', $arrival)) { throw new \RuntimeException('Invalid arrival time format.'); } $update['arrival_time'] = $arrival; } if ($type === 'early_dismissal' && $dismiss !== '') { if (! preg_match('/^\\d{2}:\\d{2}(:\\d{2})?$/', $dismiss)) { throw new \RuntimeException('Invalid dismissal time format.'); } $update['dismiss_time'] = $dismiss; } if (empty($update)) { throw new \RuntimeException('Nothing to update.'); } $row->update($update); } /** * Staff index: early dismissal rows grouped by date (+ signature metadata per date). * * Parity with legacy {@see ParentAttendanceReportController::earlyDismissals}. * * @return array{groups: array>>, school_year: string, semester: string, signature_by_date: array>} */ public function staffEarlyDismissalsIndex(?string $schoolYearParam, ?string $semesterParam): array { $context = $this->configService->context(); $schoolYear = filled($schoolYearParam) ? trim((string) $schoolYearParam) : (string) ($context['school_year'] ?? ''); $semester = filled($semesterParam) ? trim((string) $semesterParam) : ''; $b = DB::table('parent_attendance_reports as par') ->select('par.*', 's.firstname', 's.lastname', 'cs.class_section_name') ->leftJoin('students as s', 's.id', '=', 'par.student_id') ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'par.class_section_id') ->where('par.type', '=', 'early_dismissal'); if ($schoolYear !== '') { $b->where('par.school_year', $schoolYear); } if ($semester !== '') { $b->where('par.semester', $semester); } $rows = $b->orderByDesc('par.report_date') ->orderBy('par.dismiss_time') ->get() ->map(fn ($r) => (array) $r) ->all(); $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((string) $b, (string) $a); }); $signatureByDate = []; $dates = array_values(array_filter(array_keys($groups), static function ($d) { return preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $d) === 1; })); if ($dates !== []) { $sigQ = EarlyDismissalSignature::query()->whereIn('report_date', $dates); if ($schoolYear !== '') { $sigQ->where('school_year', $schoolYear); } if ($semester !== '') { $sigQ->where('semester', $semester); } foreach ($sigQ->orderBy('report_date')->get() as $sig) { $d = $sig->report_date instanceof \DateTimeInterface ? $sig->report_date->format('Y-m-d') : substr((string) $sig->report_date, 0, 10); if ($d !== '') { $signatureByDate[$d] = $sig->toArray(); } } } return [ 'groups' => $groups, 'school_year' => $schoolYear, 'semester' => $semester, 'signature_by_date' => $signatureByDate, ]; } /** * Parity with legacy {@see ParentAttendanceReportController::addEarlyDismissalForm}. * * @return array{students: array>, default_date: string, school_year: string, semester: string} */ public function staffAddEarlyDismissalFormData(): array { $context = $this->configService->context(); $schoolYear = (string) ($context['school_year'] ?? ''); $semester = (string) ($context['semester'] ?? ''); try { $students = DB::table('students as s') ->select('s.id', 's.firstname', 's.lastname', 's.parent_id', 'cs.class_section_name', 'sc.class_section_id') ->leftJoin('student_class as sc', function ($join) use ($schoolYear) { $join->on('sc.student_id', '=', 's.id') ->where('sc.school_year', '=', $schoolYear); }) ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') ->orderBy('s.lastname') ->orderBy('s.firstname') ->get() ->map(fn ($r) => (array) $r) ->all(); } catch (\Throwable) { $students = []; } [, $defaultDate] = $this->calendarService->computeSundays(0, 26); return [ 'students' => $students, 'default_date' => $defaultDate, 'school_year' => $schoolYear, 'semester' => $semester, ]; } /** * Parity with legacy {@see ParentAttendanceReportController::saveEarlyDismissal}. */ public function saveStaffEarlyDismissal(int $studentId, string $reportDate, string $dismissTime, string $reason): void { $context = $this->configService->context(); $schoolYear = (string) ($context['school_year'] ?? ''); $semester = (string) ($context['semester'] ?? ''); $dt = \DateTime::createFromFormat('Y-m-d', $reportDate); if (! $dt || (int) $dt->format('w') !== 0) { throw new \RuntimeException('Please select a Sunday date.'); } $stu = Student::query()->select('id', 'parent_id', 'firstname', 'lastname')->find($studentId); if (! $stu || empty($stu->parent_id)) { throw new \RuntimeException('Selected student not found or has no primary parent assigned.'); } $parentId = (int) $stu->parent_id; $sectionRow = DB::table('student_class') ->select('class_section_id') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->orderByDesc('updated_at') ->first(); $classSectionId = $sectionRow ? (int) $sectionRow->class_section_id : null; $qb = ParentAttendanceReport::query() ->where('student_id', $studentId) ->whereDate('report_date', $reportDate) ->where('school_year', $schoolYear); $existingAl = (clone $qb)->where(function ($q) { $q->where('type', 'absent')->orWhere('type', 'late'); })->first(); if ($existingAl) { throw new \RuntimeException('Absent/Late already submitted for this student on the selected date.'); } $existingEd = (clone $qb)->where('type', 'early_dismissal')->first(); if ($existingEd) { $existingEd->update([ 'dismiss_time' => $dismissTime !== '' ? $dismissTime : $existingEd->dismiss_time, 'reason' => $reason !== '' ? $reason : $existingEd->reason, 'status' => 'new', ]); return; } ParentAttendanceReport::query()->create([ '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', ]); } /** * Parity with legacy {@see ParentAttendanceReportController::uploadEarlyDismissalSignature}. * Files live under {@see storage_path('uploads/early_dismissal_signatures')} (same as {@see FilesController::earlyDismissalSignature}). */ public function storeEarlyDismissalSignature( UploadedFile $file, string $reportDate, ?string $schoolYearPost, ?string $semesterPost, int $uploadedByUserId, ): void { $dt = \DateTime::createFromFormat('Y-m-d', $reportDate); if (! $dt || (int) $dt->format('w') !== 0) { throw new \RuntimeException('Please select a Sunday date.'); } $context = $this->configService->context(); $schoolYear = $schoolYearPost !== null && $schoolYearPost !== '' ? (string) $schoolYearPost : (string) ($context['school_year'] ?? ''); $semesterRaw = (string) ($semesterPost ?? ''); $hasSemesterFilter = $semesterRaw !== ''; $semester = $hasSemesterFilter ? $semesterRaw : null; $existsQ = ParentAttendanceReport::query() ->where('type', 'early_dismissal') ->whereDate('report_date', $reportDate); if ($schoolYear !== '') { $existsQ->where('school_year', $schoolYear); } if ($hasSemesterFilter) { $existsQ->where('semester', $semester); } if (! $existsQ->exists()) { throw new \RuntimeException('No early dismissal records found for that date.'); } $targetDir = storage_path('uploads/early_dismissal_signatures'); if (! is_dir($targetDir) && ! @mkdir($targetDir, 0755, true) && ! is_dir($targetDir)) { throw new \RuntimeException('Upload directory is not available.'); } $originalName = $file->getClientOriginalName(); $mimeType = $file->getClientMimeType(); $fileSize = (int) $file->getSize(); $filename = $file->hashName(); $file->move($targetDir, $filename); $payload = [ 'report_date' => $reportDate, 'school_year' => $schoolYear !== '' ? $schoolYear : null, 'semester' => $semester, 'filename' => $filename, 'original_name' => $originalName, 'mime_type' => $mimeType, 'file_size' => $fileSize, 'uploaded_by' => $uploadedByUserId, ]; $existingQ = EarlyDismissalSignature::query()->whereDate('report_date', $reportDate); if ($schoolYear !== '') { $existingQ->where('school_year', $schoolYear); } if ($hasSemesterFilter) { $existingQ->where('semester', $semester); } $existing = $existingQ->first(); if ($existing) { $oldFile = (string) ($existing->filename ?? ''); $existing->update($payload); if ($oldFile !== '' && $oldFile !== $filename) { @unlink($targetDir.DIRECTORY_SEPARATOR.$oldFile); } } else { EarlyDismissalSignature::query()->create($payload); } } private function resolveClassSectionLabel(?int $classSectionId): string { $cid = (int) ($classSectionId ?? 0); if ($cid <= 0) { return 'Not Assigned'; } $name = ClassSection::query() ->where('class_section_id', $cid) ->value('class_section_name'); return $name ?: 'Not Assigned'; } private function reflectToAttendanceData( int $studentId, string $reportDate, string $type, string $semester, string $schoolYear, ?int $classSectionId, ?string $reason = null, ?string $arrivalTime = null ): void { $status = ($type === 'late') ? 'late' : (($type === 'absent') ? 'absent' : null); if ($status === null) { return; } if (! $classSectionId) { $row = DB::table('student_class') ->select('class_section_id') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->orderByDesc('updated_at') ->first(); $classSectionId = (int) ($row->class_section_id ?? 0); } if ($classSectionId <= 0) { return; } $existing = AttendanceData::query() ->where('student_id', $studentId) ->where('class_section_id', $classSectionId) ->where('date', $reportDate) ->where('semester', $semester) ->where('school_year', $schoolYear) ->first(); $reasonParts = ['Parent-reported']; if ($type === 'late' && $arrivalTime) { $reasonParts[] = 'ETA '.substr($arrivalTime, 0, 5); } if (! empty($reason)) { $reasonParts[] = trim($reason); } $reasonText = implode(' — ', $reasonParts); if (! $existing) { $classId = ClassSection::query() ->where('class_section_id', $classSectionId) ->value('class_id'); $schoolId = Student::query()->whereKey($studentId)->value('school_id'); if (! $classId || ! $schoolId) { return; } AttendanceData::query()->create([ 'class_id' => $classId, 'class_section_id' => $classSectionId, 'student_id' => $studentId, 'school_id' => $schoolId, 'date' => $reportDate, 'status' => $status, 'reason' => $reasonText, 'semester' => $semester, 'school_year' => $schoolYear, ]); $this->bumpAttendanceRecord($studentId, $classSectionId, $schoolId, $status, $semester, $schoolYear); return; } $existingStatus = strtolower((string) ($existing->status ?? '')); if ($existingStatus === 'present' || $existingStatus === '') { $existing->update([ 'status' => $status, 'reason' => $existing->reason ?: $reasonText, ]); } } private function bumpAttendanceRecord( int $studentId, int $classSectionId, string $schoolId, string $status, string $semester, string $schoolYear ): void { $record = AttendanceRecord::query() ->where('student_id', $studentId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->first(); 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, ]; if ($status === 'present') { $updates['total_presence']++; } elseif ($status === 'absent') { $updates['total_absence']++; } elseif ($status === 'late') { $updates['total_late']++; } $record->update($updates); return; } AttendanceRecord::query()->create([ 'class_section_id' => $classSectionId, 'student_id' => $studentId, 'school_id' => $schoolId, 'semester' => $semester, 'school_year' => $schoolYear, 'total_presence' => ($status === 'present') ? 1 : 0, 'total_absence' => ($status === 'absent') ? 1 : 0, 'total_late' => ($status === 'late') ? 1 : 0, 'total_attendance' => 1, ]); } }