From d28d11e2e55dbaafc0926098c166855f54678f0d Mon Sep 17 00:00:00 2001 From: root Date: Thu, 4 Jun 2026 20:49:14 -0400 Subject: [PATCH] fix attendance --- .../AttendanceManagementController.php | 159 ++++++ .../Attendance/StaffMonthResource.php | 6 +- .../Attendance/SemesterRangeService.php | 88 +-- .../Attendance/StaffAttendanceService.php | 133 ++++- .../AttendanceManagementService.php | 525 ++++++++++++++++++ app/Services/Badges/BadgePdfService.php | 17 +- app/Services/System/HealthCheckService.php | 4 +- config/database.php | 4 +- ...00_create_attendance_management_tables.php | 97 ++++ ...00000_add_badge_scanning_list_nav_item.php | 101 ++++ public/.DS_Store | Bin 0 -> 10244 bytes public/assets/.DS_Store | Bin 0 -> 8196 bytes public/assets/certificates/.DS_Store | Bin 0 -> 10244 bytes resources/.DS_Store | Bin 0 -> 10244 bytes routes/api.php | 11 + 15 files changed, 1101 insertions(+), 44 deletions(-) create mode 100644 app/Http/Controllers/Api/AttendanceManagement/AttendanceManagementController.php create mode 100644 app/Services/AttendanceManagement/AttendanceManagementService.php create mode 100644 database/migrations/2026_06_04_180000_create_attendance_management_tables.php create mode 100644 database/migrations/2026_06_04_200000_add_badge_scanning_list_nav_item.php create mode 100644 public/.DS_Store create mode 100644 public/assets/.DS_Store create mode 100644 public/assets/certificates/.DS_Store create mode 100644 resources/.DS_Store diff --git a/app/Http/Controllers/Api/AttendanceManagement/AttendanceManagementController.php b/app/Http/Controllers/Api/AttendanceManagement/AttendanceManagementController.php new file mode 100644 index 00000000..906dfad8 --- /dev/null +++ b/app/Http/Controllers/Api/AttendanceManagement/AttendanceManagementController.php @@ -0,0 +1,159 @@ +success($this->service->dashboard($request->query())); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + public function manualEntry(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'person_type' => ['nullable', 'string', 'max:32'], + 'person_id' => ['nullable', 'integer'], + 'person_name' => ['nullable', 'string', 'max:255'], + 'role_grade' => ['nullable', 'string', 'max:128'], + 'badge_id' => ['nullable', 'string', 'max:128'], + 'entry_time' => ['nullable', 'date'], + 'manual_entry_time' => ['nullable', 'date'], + 'manual_reason' => ['nullable', 'string', 'max:255'], + 'reason_for_manual_entry' => ['nullable', 'string', 'max:255'], + 'report_status' => ['nullable', 'string', 'max:48'], + 'reason' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string'], + ]); + if ($validator->fails()) return $this->respondValidationError($validator->errors()->toArray()); + try { + return $this->success(['event' => $this->service->manualEntry($validator->validated(), $request->user())], 'Manual attendance entry recorded.', Response::HTTP_CREATED); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + public function scan(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'badge_scan' => ['nullable', 'string', 'max:255'], + 'badge_id' => ['nullable', 'string', 'max:255'], + 'scan_time' => ['nullable', 'date'], + 'scan_type' => ['nullable', 'string', 'max:32'], + 'location' => ['nullable', 'string', 'max:255'], + 'report_status' => ['nullable', 'string', 'max:48'], + 'authorized' => ['nullable'], + 'reason' => ['nullable', 'string', 'max:255'], + ]); + if ($validator->fails()) return $this->respondValidationError($validator->errors()->toArray()); + try { + return $this->success(['event' => $this->service->badgeScan($validator->validated(), $request->user())], 'Badge scan classified.'); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + public function exitEntry(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'person_type' => ['nullable', 'string', 'max:32'], + 'person_id' => ['nullable', 'integer'], + 'person_name' => ['nullable', 'string', 'max:255'], + 'role_grade' => ['nullable', 'string', 'max:128'], + 'badge_id' => ['nullable', 'string', 'max:128'], + 'exit_time' => ['nullable', 'date'], + 'manual_exit_time' => ['nullable', 'date'], + 'exit_method' => ['nullable', 'string', 'max:64'], + 'exit_location' => ['nullable', 'string', 'max:255'], + 'authorized' => ['nullable'], + 'authorized_by' => ['nullable', 'string', 'max:255'], + 'report_status' => ['nullable', 'string', 'max:48'], + 'reason' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string'], + ]); + if ($validator->fails()) return $this->respondValidationError($validator->errors()->toArray()); + try { + return $this->success(['event' => $this->service->exitEntry($validator->validated(), $request->user())], 'Exit attendance entry recorded.', Response::HTTP_CREATED); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + public function absent(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'person_type' => ['nullable', 'string', 'max:32'], + 'person_id' => ['nullable', 'integer'], + 'person_name' => ['nullable', 'string', 'max:255'], + 'role_grade' => ['nullable', 'string', 'max:128'], + 'badge_id' => ['nullable', 'string', 'max:128'], + 'date' => ['nullable', 'date'], + 'report_status' => ['nullable', 'string', 'max:48'], + 'reason' => ['nullable', 'string', 'max:255'], + ]); + if ($validator->fails()) return $this->respondValidationError($validator->errors()->toArray()); + try { + return $this->success(['event' => $this->service->markAbsent($validator->validated(), $request->user())], 'Absence recorded.', Response::HTTP_CREATED); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + public function completeFollowUp(int $id, Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'report_status' => ['nullable', 'string', 'max:48'], + 'final_decision' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string'], + ]); + if ($validator->fails()) return $this->respondValidationError($validator->errors()->toArray()); + try { + return $this->success(['event' => $this->service->completeFollowUp($id, $validator->validated(), $request->user())], 'Follow-up completed.'); + } catch (\RuntimeException $e) { + return $this->error($e->getMessage(), Response::HTTP_NOT_FOUND); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + public function reprintLateSlip(int $id, Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'reason' => ['nullable', 'string', 'max:255'], + 'slip_number' => ['nullable', 'string', 'max:64'], + 'late_slip_log_id' => ['nullable', 'integer'], + ]); + if ($validator->fails()) return $this->respondValidationError($validator->errors()->toArray()); + try { + return $this->success(['reprint' => $this->service->reprintLateSlip($id, $validator->validated(), $request->user())], 'Late slip reprint logged.', Response::HTTP_CREATED); + } catch (\RuntimeException $e) { + return $this->error($e->getMessage(), Response::HTTP_NOT_FOUND); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/app/Http/Resources/Attendance/StaffMonthResource.php b/app/Http/Resources/Attendance/StaffMonthResource.php index d3cdc477..c4382159 100644 --- a/app/Http/Resources/Attendance/StaffMonthResource.php +++ b/app/Http/Resources/Attendance/StaffMonthResource.php @@ -15,6 +15,10 @@ class StaffMonthResource extends JsonResource 'no_school_days' => $this['noSchoolDays'] ?? [], 'sections' => $this['sections'] ?? [], 'admins' => $this['admins'] ?? [], + 'schoolYears' => $this['schoolYears'] ?? [], + 'currentYear' => $this['currentYear'] ?? null, + 'isCurrentYear' => $this['isCurrentYear'] ?? true, + 'missingYear' => $this['missingYear'] ?? false, ]; } -} \ No newline at end of file +} diff --git a/app/Services/Attendance/SemesterRangeService.php b/app/Services/Attendance/SemesterRangeService.php index c4c065f5..79d80a27 100644 --- a/app/Services/Attendance/SemesterRangeService.php +++ b/app/Services/Attendance/SemesterRangeService.php @@ -20,31 +20,21 @@ class SemesterRangeService public function getSchoolYearRange(string $schoolYear): array { - $fallStart = Configuration::getConfig('fall_semester_start'); - $lastDay = Configuration::getConfig('last_school_day'); - - if ($fallStart && $lastDay) { - return [ - Carbon::parse($fallStart)->toDateString(), - Carbon::parse($lastDay)->toDateString(), - ]; - } - - [$startYear, $endYear] = $this->parseSchoolYear($schoolYear); + $config = $this->ensureAttendanceConfigs($schoolYear); return [ - Carbon::create($startYear, 9, 1)->toDateString(), - Carbon::create($endYear, 5, 31)->toDateString(), + Carbon::parse($config['fall_semester_start'])->toDateString(), + Carbon::parse($config['last_school_day'])->toDateString(), ]; } public function getSemesterRange(string $schoolYear, string $semester): ?array { $semester = $this->normalizeSemester($semester); - - $fallStart = Configuration::getConfig('fall_semester_start'); - $springStart = Configuration::getConfig('spring_semester_start'); - $lastDay = Configuration::getConfig('last_school_day'); + $config = $this->ensureAttendanceConfigs($schoolYear); + $fallStart = $config['fall_semester_start']; + $springStart = $config['spring_semester_start']; + $lastDay = $config['last_school_day']; if ($semester === 'Fall' && $fallStart && $springStart) { return [ @@ -60,20 +50,7 @@ class SemesterRangeService ]; } - // Fallback to hardcoded logic when config keys are missing - [$startYear, $endYear] = $this->parseSchoolYear($schoolYear); - - return match ($semester) { - 'Fall' => [ - Carbon::create($startYear, 9, 21)->toDateString(), - Carbon::create($endYear, 1, 18)->toDateString(), - ], - 'Spring' => [ - Carbon::create($endYear, 1, 25)->toDateString(), - Carbon::create($endYear, 5, 31)->toDateString(), - ], - default => null, - }; + return null; } public function buildSundayList(string $startDate, string $endDate): array @@ -102,7 +79,54 @@ class SemesterRangeService return [(int)$m[1], (int)$m[2]]; } + $configured = trim((string) Configuration::getConfig('school_year')); + if (preg_match('/^\s*(\d{4})\s*-\s*(\d{4})\s*$/', $configured, $m)) { + return [(int) $m[1], (int) $m[2]]; + } + $year = (int) date('Y'); + $derived = $year . '-' . ($year + 1); + Configuration::setConfigValueByKey('school_year', $derived); + return [$year, $year + 1]; } -} \ No newline at end of file + + /** + * Ensure the attendance range config keys exist in `configuration`. + * + * @return array{ + * school_year:string, + * fall_semester_start:string, + * spring_semester_start:string, + * last_school_day:string + * } + */ + protected function ensureAttendanceConfigs(string $schoolYear): array + { + [$startYear, $endYear] = $this->parseSchoolYear($schoolYear); + + $resolvedSchoolYear = trim($schoolYear); + if ($resolvedSchoolYear === '') { + $resolvedSchoolYear = sprintf('%04d-%04d', $startYear, $endYear); + } + + $defaults = [ + 'school_year' => $resolvedSchoolYear, + 'fall_semester_start' => Carbon::create($startYear, 9, 1)->toDateString(), + 'spring_semester_start' => Carbon::create($endYear, 1, 25)->toDateString(), + 'last_school_day' => Carbon::create($endYear, 5, 31)->toDateString(), + ]; + + $resolved = []; + foreach ($defaults as $key => $defaultValue) { + $value = trim((string) Configuration::getConfig($key)); + if ($value === '') { + Configuration::setConfigValueByKey($key, $defaultValue); + $value = $defaultValue; + } + $resolved[$key] = $value; + } + + return $resolved; + } +} diff --git a/app/Services/Attendance/StaffAttendanceService.php b/app/Services/Attendance/StaffAttendanceService.php index 419970d3..ea380676 100644 --- a/app/Services/Attendance/StaffAttendanceService.php +++ b/app/Services/Attendance/StaffAttendanceService.php @@ -6,6 +6,7 @@ use App\Models\AttendanceDay; use App\Models\Configuration; use App\Models\StaffAttendance; use App\Models\TeacherClass; +use Carbon\Carbon; use Illuminate\Contracts\Auth\Authenticatable; use App\Models\ClassSection; use Illuminate\Support\Facades\DB; @@ -181,7 +182,34 @@ class StaffAttendanceService public function monthData(?string $semester, ?string $schoolYear): array { $semester = $semester ?: (string)$this->configuration->getConfig('semester'); - $schoolYear = $schoolYear ?: (string)$this->configuration->getConfig('school_year'); + [ + 'school_year' => $schoolYear, + 'current_year' => $currentYear, + 'school_years' => $schoolYears, + 'missing_year' => $missingYear, + 'is_current_year' => $isCurrentYear, + ] = $this->resolveMonthSchoolYearContext($schoolYear); + + if ($schoolYear === '') { + return [ + 'filters' => [ + 'semester' => $semester, + 'school_year' => '', + 'range_start' => null, + 'range_end' => null, + 'range_label' => 'School Year', + 'month_label' => 'School Year', + ], + 'sundays' => [], + 'noSchoolDays' => [], + 'sections' => [], + 'admins' => [], + 'schoolYears' => $schoolYears, + 'currentYear' => $currentYear, + 'isCurrentYear' => false, + 'missingYear' => $missingYear, + ]; + } [$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYear); $semesterNorm = $this->semesterRangeService->normalizeSemester($semester); @@ -193,6 +221,8 @@ class StaffAttendanceService } } + $rangeLabel = $this->buildRangeLabel($schoolYear, $semesterNorm, $rangeStart, $rangeEnd); + $days = $this->semesterRangeService->buildSundayList($rangeStart, $rangeEnd); $assignedBySection = $semesterNorm !== '' @@ -411,14 +441,115 @@ class StaffAttendanceService 'school_year' => $schoolYear, 'range_start' => $rangeStart, 'range_end' => $rangeEnd, + 'range_label' => $rangeLabel, + 'month_label' => $rangeLabel, ], 'sundays' => $days, 'noSchoolDays' => $noSchoolDays, 'sections' => $sections, 'admins' => $admins, + 'schoolYears' => $schoolYears, + 'currentYear' => $currentYear, + 'isCurrentYear' => $isCurrentYear, + 'missingYear' => $missingYear, ]; } + /** + * Match the legacy month page behavior: + * - prefer the requested year + * - else use configured school year + * - else fall back to the latest year present in staff_attendance / teacher_class + * + * @return array{ + * school_year:string, + * current_year:string, + * school_years:array, + * missing_year:bool, + * is_current_year:bool + * } + */ + protected function resolveMonthSchoolYearContext(?string $requestedSchoolYear): array + { + $requestedSchoolYear = trim((string) $requestedSchoolYear); + $currentYear = trim((string) $this->configuration->getConfig('school_year')); + + $schoolYears = []; + + try { + $schoolYears = DB::table('staff_attendance') + ->select('school_year') + ->distinct() + ->whereNotNull('school_year') + ->orderByDesc('school_year') + ->pluck('school_year') + ->map(static fn ($value) => trim((string) $value)) + ->filter() + ->values() + ->all(); + } catch (\Throwable) { + $schoolYears = []; + } + + if ($schoolYears === []) { + try { + $schoolYears = DB::table('teacher_class') + ->select('school_year') + ->distinct() + ->whereNotNull('school_year') + ->orderByDesc('school_year') + ->pluck('school_year') + ->map(static fn ($value) => trim((string) $value)) + ->filter() + ->values() + ->all(); + } catch (\Throwable) { + $schoolYears = []; + } + } + + if ($currentYear !== '' && !in_array($currentYear, $schoolYears, true)) { + array_unshift($schoolYears, $currentYear); + } + + $schoolYears = array_values(array_unique(array_filter($schoolYears, static fn ($value) => $value !== ''))); + + if ($currentYear === '' && $schoolYears !== []) { + $currentYear = (string) $schoolYears[0]; + Configuration::setConfigValueByKey('school_year', $currentYear); + } + + $resolvedYear = $requestedSchoolYear !== '' + ? $requestedSchoolYear + : ($currentYear !== '' ? $currentYear : ($schoolYears[0] ?? '')); + + if ($resolvedYear !== '' && !in_array($resolvedYear, $schoolYears, true)) { + array_unshift($schoolYears, $resolvedYear); + } + + return [ + 'school_year' => $resolvedYear, + 'current_year' => $currentYear, + 'school_years' => array_values(array_unique($schoolYears)), + 'missing_year' => $currentYear === '', + 'is_current_year' => $currentYear !== '' && $resolvedYear === $currentYear, + ]; + } + + protected function buildRangeLabel(string $schoolYear, string $semesterNorm, string $rangeStart, string $rangeEnd): string + { + if ($semesterNorm === '') { + return 'School Year ' . $schoolYear; + } + + return sprintf( + '%s %s–%s', + $semesterNorm, + Carbon::parse($rangeStart)->format('d/m'), + Carbon::parse($rangeEnd)->format('d/m') + ); + } + public function adminsAttendanceData(?string $date, ?string $semester, ?string $schoolYear): array { $date = $date ?: now()->toDateString(); diff --git a/app/Services/AttendanceManagement/AttendanceManagementService.php b/app/Services/AttendanceManagement/AttendanceManagementService.php new file mode 100644 index 00000000..3695c18d --- /dev/null +++ b/app/Services/AttendanceManagement/AttendanceManagementService.php @@ -0,0 +1,525 @@ +ensureTables(); + $date = $this->dateOnly($filters['date'] ?? now()->toDateString()); + $query = DB::table('attendance_management_events')->whereDate('event_date', $date); + + foreach (['person_type', 'attendance_status', 'report_status', 'risk_level', 'entry_method', 'exit_method'] as $field) { + $value = trim((string) ($filters[$field] ?? '')); + if ($value !== '') { + $query->where($field, $value); + } + } + if (array_key_exists('follow_up_required', $filters) && $filters['follow_up_required'] !== '') { + $query->where('follow_up_required', filter_var($filters['follow_up_required'], FILTER_VALIDATE_BOOLEAN)); + } + if (array_key_exists('follow_up_completed', $filters) && $filters['follow_up_completed'] !== '') { + $query->where('follow_up_completed', filter_var($filters['follow_up_completed'], FILTER_VALIDATE_BOOLEAN)); + } + $search = trim((string) ($filters['q'] ?? '')); + if ($search !== '') { + $query->where(function ($q) use ($search) { + $q->where('person_name', 'like', "%{$search}%") + ->orWhere('badge_id', 'like', "%{$search}%") + ->orWhere('role_grade', 'like', "%{$search}%") + ->orWhere('combination_code', 'like', "%{$search}%"); + }); + } + + $rows = $query->orderByDesc('follow_up_required') + ->orderBy('follow_up_completed') + ->orderBy('person_name') + ->limit((int) ($filters['limit'] ?? 250)) + ->get() + ->map(fn ($r) => (array) $r) + ->all(); + + return [ + 'date' => $date, + 'summary' => $this->summaryForDate($date), + 'filters' => $this->availableFilters(), + 'rows' => $rows, + ]; + } + + public function manualEntry(array $data, ?User $actor = null): array + { + $this->ensureTables(); + $person = $this->resolvePerson($data); + $entryAt = $this->dateTime($data['entry_time'] ?? $data['manual_entry_time'] ?? now()); + $start = $this->schoolStartFor($entryAt); + $isLate = $entryAt->gt($start); + $reportStatus = $this->normalizeReportStatus($data['report_status'] ?? null); + $status = $isLate ? self::STATUS_LATE : self::STATUS_PRESENT; + $counts = $this->countsFor($person['type'], $person['id'], $entryAt->toDateString(), $status); + $badgeExceptionCount = $this->badgeExceptionCount($person['type'], $person['id'], $entryAt->toDateString()) + 1; + + $row = [ + 'person_type' => $person['type'], + 'person_id' => $person['id'], + 'person_name' => $person['name'], + 'role_grade' => $person['role_grade'], + 'badge_id' => $person['badge_id'], + 'event_date' => $entryAt->toDateString(), + 'attendance_status' => $status, + 'report_status' => $reportStatus, + 'entry_time' => $entryAt, + 'official_entry_time' => $entryAt, + 'entry_method' => 'manual_entry', + 'manual_reason' => trim((string) ($data['manual_reason'] ?? $data['reason_for_manual_entry'] ?? 'manual verification')), + 'reason' => $data['reason'] ?? null, + 'entered_by' => $actor?->id, + 'absence_count' => $counts['absence_count'], + 'late_count' => $counts['late_count'], + 'early_dismissal_count' => $counts['early_dismissal_count'], + 'badge_exception_count' => $badgeExceptionCount, + 'combination_code' => $this->combinationCode($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], $badgeExceptionCount), + 'risk_level' => $this->riskLevel($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], $badgeExceptionCount), + 'follow_up_required' => $reportStatus !== 'reported' || $badgeExceptionCount >= 3 || $isLate, + 'action_needed' => $this->actionNeeded($status, $reportStatus, $badgeExceptionCount), + 'notes' => $data['notes'] ?? null, + 'final_decision' => $isLate ? 'Late slip printed' : 'Present by verified manual entry', + 'audit' => json_encode(['source' => 'manual_entry', 'actor_id' => $actor?->id]), + 'created_at' => now(), + 'updated_at' => now(), + ]; + + $id = DB::table('attendance_management_events')->insertGetId($row); + $row['id'] = $id; + $this->recordBadgeException((int) $id, $person, $entryAt, $row['manual_reason'], $badgeExceptionCount, $actor, $data['notes'] ?? null); + + if ($person['type'] === 'student' && $isLate) { + $row['late_slip'] = $this->printLateSlip($row, $actor); + } + + return $row; + } + + public function badgeScan(array $data, ?User $actor = null): array + { + $this->ensureTables(); + $badge = trim((string) ($data['badge_scan'] ?? $data['badge_id'] ?? '')); + if ($badge === '') { + throw new \InvalidArgumentException('Badge scan value is required.'); + } + $person = $this->resolvePerson(['badge_id' => $badge]); + $entryAt = $this->dateTime($data['scan_time'] ?? now()); + $scanType = strtolower(trim((string) ($data['scan_type'] ?? 'entry'))); + $reportStatus = $this->normalizeReportStatus($data['report_status'] ?? null); + + if ($scanType === 'exit') { + return $this->exitEntry([ + 'person_type' => $person['type'], + 'person_id' => $person['id'], + 'exit_time' => $entryAt->toDateTimeString(), + 'exit_method' => 'badge_scan', + 'report_status' => $reportStatus, + 'exit_location' => $data['location'] ?? null, + 'authorized' => $data['authorized'] ?? false, + 'reason' => $data['reason'] ?? null, + ], $actor); + } + + $status = $entryAt->gt($this->schoolStartFor($entryAt)) ? self::STATUS_LATE : self::STATUS_PRESENT; + $counts = $this->countsFor($person['type'], $person['id'], $entryAt->toDateString(), $status); + $row = [ + 'person_type' => $person['type'], + 'person_id' => $person['id'], + 'person_name' => $person['name'], + 'role_grade' => $person['role_grade'], + 'badge_id' => $badge, + 'event_date' => $entryAt->toDateString(), + 'attendance_status' => $status, + 'report_status' => $reportStatus, + 'entry_time' => $entryAt, + 'official_entry_time' => $entryAt, + 'entry_method' => 'badge_scan', + 'scan_location' => $data['location'] ?? null, + 'reason' => $data['reason'] ?? null, + 'entered_by' => $actor?->id, + 'absence_count' => $counts['absence_count'], + 'late_count' => $counts['late_count'], + 'early_dismissal_count' => $counts['early_dismissal_count'], + 'badge_exception_count' => $this->badgeExceptionCount($person['type'], $person['id'], $entryAt->toDateString()), + 'combination_code' => $this->combinationCode($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], 0), + 'risk_level' => $this->riskLevel($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], 0), + 'follow_up_required' => $status === self::STATUS_LATE && $reportStatus !== 'reported', + 'action_needed' => $this->actionNeeded($status, $reportStatus, 0), + 'final_decision' => $status === self::STATUS_LATE ? 'Late slip printed' : 'Present by badge scan', + 'audit' => json_encode(['source' => 'badge_scan', 'actor_id' => $actor?->id]), + 'created_at' => now(), + 'updated_at' => now(), + ]; + $id = DB::table('attendance_management_events')->insertGetId($row); + $row['id'] = $id; + if ($person['type'] === 'student' && $status === self::STATUS_LATE) { + $row['late_slip'] = $this->printLateSlip($row, $actor); + } + return $row; + } + + public function exitEntry(array $data, ?User $actor = null): array + { + $this->ensureTables(); + $person = $this->resolvePerson($data); + $exitAt = $this->dateTime($data['exit_time'] ?? $data['manual_exit_time'] ?? now()); + $authorized = filter_var($data['authorized'] ?? false, FILTER_VALIDATE_BOOLEAN); + $reportStatus = $authorized ? 'reported' : $this->normalizeReportStatus($data['report_status'] ?? null); + $counts = $this->countsFor($person['type'], $person['id'], $exitAt->toDateString(), self::STATUS_EARLY_DISMISSAL); + $badgeExceptions = $this->badgeExceptionCount($person['type'], $person['id'], $exitAt->toDateString()) + ((($data['exit_method'] ?? '') === 'manual_exit') ? 1 : 0); + $row = [ + 'person_type' => $person['type'], + 'person_id' => $person['id'], + 'person_name' => $person['name'], + 'role_grade' => $person['role_grade'], + 'badge_id' => $person['badge_id'], + 'event_date' => $exitAt->toDateString(), + 'attendance_status' => self::STATUS_EARLY_DISMISSAL, + 'report_status' => $reportStatus, + 'exit_time' => $exitAt, + 'official_exit_time' => $exitAt, + 'exit_method' => $data['exit_method'] ?? 'manual_exit', + 'exit_location' => $data['exit_location'] ?? null, + 'authorized_by' => $data['authorized_by'] ?? null, + 'reason' => $data['reason'] ?? null, + 'entered_by' => $actor?->id, + 'absence_count' => $counts['absence_count'], + 'late_count' => $counts['late_count'], + 'early_dismissal_count' => $counts['early_dismissal_count'], + 'badge_exception_count' => $badgeExceptions, + 'combination_code' => $this->combinationCode($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], $badgeExceptions), + 'risk_level' => $this->riskLevel($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], $badgeExceptions), + 'follow_up_required' => ! $authorized || $reportStatus !== 'reported', + 'action_needed' => ! $authorized ? 'Review unauthorized early dismissal' : 'Record and monitor', + 'final_decision' => $authorized ? 'Reported early dismissal, excused' : 'Unauthorized early dismissal', + 'audit' => json_encode(['source' => $data['exit_method'] ?? 'manual_exit', 'actor_id' => $actor?->id]), + 'created_at' => now(), + 'updated_at' => now(), + ]; + $id = DB::table('attendance_management_events')->insertGetId($row); + $row['id'] = $id; + if (($data['exit_method'] ?? '') === 'manual_exit') { + $this->recordBadgeException((int) $id, $person, $exitAt, 'manual exit entry', $badgeExceptions, $actor, $data['notes'] ?? null); + } + return $row; + } + + public function markAbsent(array $data, ?User $actor = null): array + { + $this->ensureTables(); + $person = $this->resolvePerson($data); + $date = $this->dateOnly($data['date'] ?? now()->toDateString()); + $reportStatus = $this->normalizeReportStatus($data['report_status'] ?? null); + $counts = $this->countsFor($person['type'], $person['id'], $date, self::STATUS_ABSENT); + $row = [ + 'person_type' => $person['type'], + 'person_id' => $person['id'], + 'person_name' => $person['name'], + 'role_grade' => $person['role_grade'], + 'badge_id' => $person['badge_id'], + 'event_date' => $date, + 'attendance_status' => self::STATUS_ABSENT, + 'report_status' => $reportStatus, + 'reason' => $data['reason'] ?? null, + 'entered_by' => $actor?->id, + 'absence_count' => $counts['absence_count'], + 'late_count' => $counts['late_count'], + 'early_dismissal_count' => $counts['early_dismissal_count'], + 'badge_exception_count' => $this->badgeExceptionCount($person['type'], $person['id'], $date), + 'combination_code' => $this->combinationCode($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], 0), + 'risk_level' => $this->riskLevel($counts['absence_count'], $counts['late_count'], $counts['early_dismissal_count'], 0), + 'follow_up_required' => $reportStatus !== 'reported' || $counts['absence_count'] >= 3, + 'action_needed' => $this->actionNeeded(self::STATUS_ABSENT, $reportStatus, 0), + 'final_decision' => $reportStatus === 'reported' ? 'Reported absence, pending decision' : 'Not-reported absence, pending clarification', + 'audit' => json_encode(['source' => 'absence_mark', 'actor_id' => $actor?->id]), + 'created_at' => now(), + 'updated_at' => now(), + ]; + $id = DB::table('attendance_management_events')->insertGetId($row); + $row['id'] = $id; + return $row; + } + + public function completeFollowUp(int $id, array $data, ?User $actor = null): array + { + $this->ensureTables(); + $row = DB::table('attendance_management_events')->where('id', $id)->first(); + if (! $row) { + throw new \RuntimeException('Attendance management event not found.'); + } + $update = [ + 'follow_up_completed' => true, + 'final_decision' => trim((string) ($data['final_decision'] ?? 'Resolved after follow-up')), + 'notes' => trim((string) ($data['notes'] ?? ($row->notes ?? ''))), + 'updated_at' => now(), + ]; + if (isset($data['report_status'])) { + $update['report_status'] = $this->normalizeReportStatus($data['report_status']); + } + DB::table('attendance_management_events')->where('id', $id)->update($update); + return (array) DB::table('attendance_management_events')->where('id', $id)->first(); + } + + public function reprintLateSlip(int $eventId, array $data, ?User $actor = null): array + { + $this->ensureTables(); + $event = (array) DB::table('attendance_management_events')->where('id', $eventId)->first(); + if (! $event) { + throw new \RuntimeException('Attendance management event not found.'); + } + $count = (int) DB::table('late_slip_reprints')->where('attendance_management_event_id', $eventId)->count() + 1; + $row = [ + 'attendance_management_event_id' => $eventId, + 'late_slip_log_id' => $data['late_slip_log_id'] ?? null, + 'student_id' => $event['person_type'] === 'student' ? $event['person_id'] : null, + 'student_name' => $event['person_name'] ?? null, + 'slip_number' => $data['slip_number'] ?? ('AME-'.$eventId), + 'reprinted_at' => now(), + 'reprinted_by' => $actor?->id, + 'reason' => $data['reason'] ?? 'Reprint requested', + 'reprint_count' => $count, + 'created_at' => now(), + 'updated_at' => now(), + ]; + $row['id'] = DB::table('late_slip_reprints')->insertGetId($row); + DB::table('attendance_management_events')->where('id', $eventId)->update([ + 'final_decision' => 'Late slip reprinted', + 'updated_at' => now(), + ]); + return $row; + } + + public function ensureTables(): void + { + foreach (['attendance_management_events', 'badge_exceptions', 'late_slip_reprints'] as $table) { + if (! Schema::hasTable($table)) { + throw new \RuntimeException("Missing table {$table}. Run migrations."); + } + } + } + + private function resolvePerson(array $data): array + { + $type = strtolower(trim((string) ($data['person_type'] ?? ''))); + $id = (int) ($data['person_id'] ?? 0); + $badge = trim((string) ($data['badge_id'] ?? $data['badge_scan'] ?? '')); + + $student = null; + $user = null; + if ($type === 'student' && $id > 0) $student = Student::query()->find($id); + if (in_array($type, ['staff', 'user', 'teacher', 'administrator'], true) && $id > 0) $user = User::query()->find($id); + if (! $student && $badge !== '') $student = Student::query()->where('rfid_tag', $badge)->first(); + if (! $student && ! $user && $badge !== '') $user = User::query()->where('rfid_tag', $badge)->first(); + if (! $student && ! $user && $id > 0) $student = Student::query()->find($id); + if (! $student && ! $user && $id > 0) $user = User::query()->find($id); + + if ($student) { + return [ + 'type' => 'student', + 'id' => (int) $student->id, + 'name' => trim(($student->firstname ?? '').' '.($student->lastname ?? '')), + 'role_grade' => $student->registration_grade ?? null, + 'badge_id' => $student->rfid_tag ?? $badge, + ]; + } + if ($user) { + return [ + 'type' => 'staff', + 'id' => (int) $user->id, + 'name' => trim(($user->firstname ?? '').' '.($user->lastname ?? '')) ?: ($user->email ?? 'Staff #'.$user->id), + 'role_grade' => $user->user_type ?? 'staff', + 'badge_id' => $user->rfid_tag ?? $badge, + ]; + } + + $name = trim((string) ($data['person_name'] ?? '')); + if ($name === '') { + throw new \InvalidArgumentException('Person could not be resolved. Provide person_id, badge_id, or person_name.'); + } + return [ + 'type' => $type !== '' ? $type : 'visitor', + 'id' => $id > 0 ? $id : null, + 'name' => $name, + 'role_grade' => $data['role_grade'] ?? null, + 'badge_id' => $badge !== '' ? $badge : null, + ]; + } + + private function printLateSlip(array $event, ?User $actor): array + { + $payload = [ + 'school_year' => Configuration::getConfigValueByKey('school_year') ?? '', + 'semester' => Configuration::getConfigValueByKey('semester') ?? '', + 'student_name' => $event['person_name'] ?? '', + 'slip_date' => $event['event_date'] ?? now()->toDateString(), + 'time_in' => Carbon::parse($event['official_entry_time'] ?? now())->format('H:i:s'), + 'grade' => $event['role_grade'] ?? '', + 'reason' => $event['reason'] ?? ($event['manual_reason'] ?? ''), + 'admin_name' => $actor ? trim(($actor->firstname ?? '').' '.($actor->lastname ?? '')) : 'System', + ]; + $logged = LateSlipLog::logSlip($payload, $actor?->id); + return [ + 'printed' => $logged, + 'slip_number' => 'AME-'.($event['id'] ?? time()), + 'payload' => $payload, + ]; + } + + private function recordBadgeException(int $eventId, array $person, Carbon $date, string $reason, int $count, ?User $actor, ?string $notes): void + { + DB::table('badge_exceptions')->insert([ + 'attendance_management_event_id' => $eventId, + 'person_type' => $person['type'], + 'person_id' => $person['id'], + 'person_name' => $person['name'], + 'exception_date' => $date->toDateString(), + 'exception_type' => 'manual_entry', + 'badge_status' => $reason, + 'exception_count' => $count, + 'action' => $this->badgeExceptionAction($count), + 'decision' => $this->badgeExceptionDecision($count), + 'recorded_by' => $actor?->id, + 'notes' => $notes, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function countsFor(string $personType, $personId, string $date, string $newStatus): array + { + $q = DB::table('attendance_management_events') + ->where('person_type', $personType) + ->where('person_id', $personId) + ->whereDate('event_date', '<=', $date); + $absence = (clone $q)->where('attendance_status', self::STATUS_ABSENT)->count(); + $late = (clone $q)->whereIn('attendance_status', [self::STATUS_LATE, self::STATUS_LATE_WITHOUT_SLIP])->count(); + $ed = (clone $q)->where('attendance_status', self::STATUS_EARLY_DISMISSAL)->count(); + if ($newStatus === self::STATUS_ABSENT) $absence++; + if (in_array($newStatus, [self::STATUS_LATE, self::STATUS_LATE_WITHOUT_SLIP], true)) $late++; + if ($newStatus === self::STATUS_EARLY_DISMISSAL) $ed++; + return ['absence_count' => $absence, 'late_count' => $late, 'early_dismissal_count' => $ed]; + } + + private function badgeExceptionCount(string $personType, $personId, string $date): int + { + return (int) DB::table('badge_exceptions') + ->where('person_type', $personType) + ->where('person_id', $personId) + ->whereDate('exception_date', '<=', $date) + ->count(); + } + + private function summaryForDate(string $date): array + { + $base = DB::table('attendance_management_events')->whereDate('event_date', $date); + return [ + 'present' => (clone $base)->where('attendance_status', self::STATUS_PRESENT)->count(), + 'absent' => (clone $base)->where('attendance_status', self::STATUS_ABSENT)->count(), + 'late' => (clone $base)->where('attendance_status', self::STATUS_LATE)->count(), + 'early_dismissal' => (clone $base)->where('attendance_status', self::STATUS_EARLY_DISMISSAL)->count(), + 'not_reported' => (clone $base)->where('report_status', 'not_reported')->count(), + 'follow_up_required' => (clone $base)->where('follow_up_required', true)->where('follow_up_completed', false)->count(), + 'badge_exceptions' => DB::table('badge_exceptions')->whereDate('exception_date', $date)->count(), + 'late_slip_reprints' => DB::table('late_slip_reprints')->whereDate('reprinted_at', $date)->count(), + ]; + } + + private function availableFilters(): array + { + return [ + 'attendance_status' => [self::STATUS_PRESENT, self::STATUS_ABSENT, self::STATUS_ABSENT_PENDING, self::STATUS_LATE, self::STATUS_LATE_WITHOUT_SLIP, self::STATUS_EARLY_DISMISSAL], + 'report_status' => ['reported', 'not_reported', 'pending_clarification'], + 'risk_level' => ['low', 'medium', 'high', 'very_high', 'critical', 'severe'], + 'person_type' => ['student', 'staff', 'contractor', 'visitor'], + ]; + } + + private function schoolStartFor(Carbon $date): Carbon + { + $configured = Configuration::getConfigValueByKey('school_start_time') ?: config('attendance.school_start_time', '09:00:00'); + return Carbon::parse($date->toDateString().' '.$configured, $date->timezone); + } + + private function dateTime($value): Carbon { return $value instanceof Carbon ? $value : Carbon::parse((string) $value); } + private function dateOnly($value): string { return Carbon::parse((string) $value)->toDateString(); } + + private function normalizeReportStatus($value): string + { + $v = strtolower(str_replace(['-', ' '], '_', trim((string) $value))); + return in_array($v, ['reported', 'not_reported', 'pending_clarification'], true) ? $v : 'pending_clarification'; + } + + private function combinationCode(int $abs, int $late, int $ed, int $badge): string + { + $parts = []; + if ($abs > 0) $parts[] = min($abs, 5).'ABS'; + if ($late > 0) $parts[] = min($late, 5).'Late'; + if ($ed > 0) $parts[] = min($ed, 3).'ED'; + if ($badge > 0) $parts[] = min($badge, 5).'Badge Exceptions'; + return $parts ? implode(' + ', $parts) : 'Clear'; + } + + private function riskLevel(int $abs, int $late, int $ed, int $badge): string + { + if ($abs >= 5 && $late >= 5) return 'severe'; + if ($abs >= 5 || $late >= 5) return 'critical'; + if ($abs + $late + $ed + $badge >= 6 || $abs >= 4 || $late >= 4) return 'very_high'; + if ($abs + $late + $ed + $badge >= 4 || $abs >= 3 || $late >= 3 || $badge >= 4) return 'high'; + if ($abs + $late + $ed + $badge >= 2 || $badge >= 2) return 'medium'; + return 'low'; + } + + private function actionNeeded(string $status, string $reportStatus, int $badgeCount): string + { + if ($badgeCount >= 5) return 'Leadership review for badge compliance'; + if ($badgeCount >= 4) return 'Formal badge follow-up required'; + if ($status === self::STATUS_LATE && $reportStatus !== 'reported') return 'Print late slip and contact parent or staff'; + if ($status === self::STATUS_ABSENT && $reportStatus !== 'reported') return 'Same-day contact required'; + if ($status === self::STATUS_EARLY_DISMISSAL && $reportStatus !== 'reported') return 'Verify authorization and contact guardian/supervisor'; + return 'Record and monitor'; + } + + private function badgeExceptionAction(int $count): string + { + return match (true) { + $count >= 5 => 'Leadership review', + $count === 4 => 'Contact parent, supervisor, or HR', + $count === 3 => 'Admin warning', + $count === 2 => 'Issue reminder', + default => 'Record and allow manual entry', + }; + } + + private function badgeExceptionDecision(int $count): string + { + return match (true) { + $count >= 5 => 'Badge replacement, disciplinary action, or intervention review', + $count === 4 => 'Formal follow-up required', + $count === 3 => 'Badge compliance concern', + $count === 2 => 'Monitor', + default => 'No formal action', + }; + } +} diff --git a/app/Services/Badges/BadgePdfService.php b/app/Services/Badges/BadgePdfService.php index 4f0e07e8..d8022028 100644 --- a/app/Services/Badges/BadgePdfService.php +++ b/app/Services/Badges/BadgePdfService.php @@ -101,14 +101,8 @@ class BadgePdfService } try { - $binary = $this->renderWithDompdf( - $rows, - $schoolName, - $motto, - $footerBlock - ); - } catch (Throwable) { - // Keep the fixed-coordinate FPDF renderer as a fallback when Dompdf fails. + // Dompdf is substantially heavier here and can fatally exhaust memory on larger badge runs. + // Prefer the fixed-coordinate FPDF renderer first, and keep Dompdf only as a secondary fallback. $binary = $this->renderWithFpdf( $rows, $schoolName, @@ -117,6 +111,13 @@ class BadgePdfService $footerBlock, $logoPath ); + } catch (Throwable) { + $binary = $this->renderWithDompdf( + $rows, + $schoolName, + $motto, + $footerBlock + ); } $logIds = array_values(array_unique(array_merge($studentIds, $userIds))); diff --git a/app/Services/System/HealthCheckService.php b/app/Services/System/HealthCheckService.php index 1ecb2c31..de744997 100644 --- a/app/Services/System/HealthCheckService.php +++ b/app/Services/System/HealthCheckService.php @@ -8,6 +8,7 @@ class HealthCheckService { public function check(): array { + $migrationTable = (string) (config('database.migrations.table') ?? 'migrations'); $uploadsBase = storage_path('app/uploads'); $paths = [ 'uploads' => $uploadsBase, @@ -30,7 +31,8 @@ class HealthCheckService $dbChecks = [ 'user_preferences_exists' => Schema::hasTable('user_preferences'), 'settings_exists' => Schema::hasTable('settings'), - 'migrations_exists' => Schema::hasTable('migrations'), + 'migrations_exists' => Schema::hasTable($migrationTable), + 'migrations_table' => $migrationTable, ]; $dbChecks['user_preferences_has_timezone'] = $dbChecks['user_preferences_exists'] diff --git a/config/database.php b/config/database.php index 6db50c84..70d24990 100644 --- a/config/database.php +++ b/config/database.php @@ -134,7 +134,9 @@ return [ */ 'migrations' => [ - 'table' => 'migrations', + // The legacy database already has a `migrations` table with a + // different lifecycle/history. Keep Laravel's repository separate. + 'table' => env('DB_MIGRATIONS_TABLE', 'laravel_migrations'), 'update_date_on_publish' => true, ], diff --git a/database/migrations/2026_06_04_180000_create_attendance_management_tables.php b/database/migrations/2026_06_04_180000_create_attendance_management_tables.php new file mode 100644 index 00000000..4c4c94ad --- /dev/null +++ b/database/migrations/2026_06_04_180000_create_attendance_management_tables.php @@ -0,0 +1,97 @@ +id(); + $table->string('person_type', 32)->default('student'); + $table->unsignedBigInteger('person_id')->nullable(); + $table->string('person_name')->nullable(); + $table->string('role_grade', 128)->nullable(); + $table->string('badge_id', 128)->nullable()->index(); + $table->date('event_date')->index(); + $table->string('attendance_status', 48)->index(); + $table->string('report_status', 48)->default('pending_clarification')->index(); + $table->dateTime('entry_time')->nullable(); + $table->dateTime('exit_time')->nullable(); + $table->dateTime('official_entry_time')->nullable(); + $table->dateTime('official_exit_time')->nullable(); + $table->string('entry_method', 48)->nullable(); + $table->string('exit_method', 48)->nullable(); + $table->string('manual_reason')->nullable(); + $table->string('reason')->nullable(); + $table->string('scan_location')->nullable(); + $table->string('exit_location')->nullable(); + $table->string('authorized_by')->nullable(); + $table->unsignedBigInteger('entered_by')->nullable(); + $table->unsignedInteger('absence_count')->default(0); + $table->unsignedInteger('late_count')->default(0); + $table->unsignedInteger('early_dismissal_count')->default(0); + $table->unsignedInteger('badge_exception_count')->default(0); + $table->string('combination_code')->nullable(); + $table->string('risk_level', 32)->default('low'); + $table->boolean('follow_up_required')->default(false)->index(); + $table->boolean('follow_up_completed')->default(false)->index(); + $table->string('action_needed')->nullable(); + $table->text('notes')->nullable(); + $table->string('final_decision')->nullable(); + $table->json('audit')->nullable(); + $table->timestamps(); + + $table->index(['person_type', 'person_id', 'event_date'], 'ame_person_date_idx'); + $table->index(['attendance_status', 'report_status'], 'ame_status_report_idx'); + }); + } + + if (! Schema::hasTable('badge_exceptions')) { + Schema::create('badge_exceptions', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('attendance_management_event_id')->nullable(); + $table->string('person_type', 32)->default('student'); + $table->unsignedBigInteger('person_id')->nullable(); + $table->string('person_name')->nullable(); + $table->date('exception_date')->index(); + $table->string('exception_type', 64); + $table->string('badge_status', 64)->nullable(); + $table->unsignedInteger('exception_count')->default(1); + $table->string('action')->nullable(); + $table->string('decision')->nullable(); + $table->unsignedBigInteger('recorded_by')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['person_type', 'person_id', 'exception_date'], 'be_person_date_idx'); + }); + } + + if (! Schema::hasTable('late_slip_reprints')) { + Schema::create('late_slip_reprints', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('attendance_management_event_id')->nullable(); + $table->unsignedBigInteger('late_slip_log_id')->nullable(); + $table->unsignedBigInteger('student_id')->nullable(); + $table->string('student_name')->nullable(); + $table->string('slip_number')->nullable(); + $table->dateTime('reprinted_at'); + $table->unsignedBigInteger('reprinted_by')->nullable(); + $table->string('reason')->nullable(); + $table->unsignedInteger('reprint_count')->default(1); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('late_slip_reprints'); + Schema::dropIfExists('badge_exceptions'); + Schema::dropIfExists('attendance_management_events'); + } +}; diff --git a/database/migrations/2026_06_04_200000_add_badge_scanning_list_nav_item.php b/database/migrations/2026_06_04_200000_add_badge_scanning_list_nav_item.php new file mode 100644 index 00000000..458d827b --- /dev/null +++ b/database/migrations/2026_06_04_200000_add_badge_scanning_list_nav_item.php @@ -0,0 +1,101 @@ +whereNull('deleted_at') + ->whereRaw('LOWER(label) = ?', ['attendance']) + ->orderBy('id') + ->value('id'); + + $navId = DB::table('nav_items') + ->where('url', $url) + ->value('id'); + + if (!$navId) { + $navId = DB::table('nav_items')->insertGetId([ + 'menu_parent_id' => $parentId ?: null, + 'label' => $label, + 'url' => $url, + 'icon_class' => 'bi-upc-scan', + 'target' => null, + 'sort_order' => 50, + 'is_enabled' => 1, + 'created_at' => $now, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + } else { + DB::table('nav_items') + ->where('id', $navId) + ->update([ + 'menu_parent_id' => $parentId ?: null, + 'label' => $label, + 'icon_class' => 'bi-upc-scan', + 'sort_order' => 50, + 'is_enabled' => 1, + 'updated_at' => $now, + 'deleted_at' => null, + ]); + } + + $roleIds = DB::table('roles') + ->where('is_active', 1) + ->where(function ($query) { + $query->whereIn(DB::raw('LOWER(name)'), ['administrator', 'admin']) + ->orWhereIn(DB::raw('LOWER(COALESCE(slug, ""))'), ['administrator', 'admin']); + }) + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->filter(fn ($id) => $id > 0) + ->values(); + + foreach ($roleIds as $roleId) { + $exists = DB::table('role_nav_items') + ->where('role_id', $roleId) + ->where('nav_item_id', $navId) + ->exists(); + + if (!$exists) { + DB::table('role_nav_items')->insert([ + 'role_id' => $roleId, + 'nav_item_id' => $navId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + } + + public function down(): void + { + if (!Schema::hasTable('nav_items')) { + return; + } + + $url = '/app/administrator/attendance/badge-scans'; + $navId = DB::table('nav_items')->where('url', $url)->value('id'); + + if ($navId && Schema::hasTable('role_nav_items')) { + DB::table('role_nav_items')->where('nav_item_id', $navId)->delete(); + } + + if ($navId) { + DB::table('nav_items')->where('id', $navId)->delete(); + } + } +}; diff --git a/public/.DS_Store b/public/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..056cb669f57416d5cdd20d1fb4eeb1927fd1c560 GIT binary patch literal 10244 zcmeHMU2GIp6u#fIlo>kE0SXlCgoQ%*$pTw`((-4vf69M>ZRxiBEVDZ!9hlCPo!Pcf z8XFT6MZx%_@t-G+Mji~H@kJ9A(MLrSjPZe}QC~387nKLk%$;pu3yCI@P&2c+_uPB# zIrq*v^PM?!?=r^FT+r(oi!sJTDn3`Dd5P2Z-K;(hQ1NVamC};bZSjlNrM;?eg5P4wC1OEFVM#ZPekdAU1ygH}~wg5!S zi5* zlu>3653j4QkH-=t@!0VC)roklu^}-sB8ju=R&CsyIcD|Q&J)}Sgf9X%wZy0=OD(e8 zffjxuQI;xN^i9zsX-cI$RqaZ5_w1K5%8UWJ_4a&`t_tCZ<8W>(8`5Bbve z%tuTk-#KXNUT?SN<_udm?IT?SrfX&`qr=wvjOmC=($%V(x$_>VRn@zRrVCl~c)|2e zD0QM3tPxwF$_I41r=5=Ouc9j#MVg0JU%Wt7_xmF)d4p;_R9LoLl{;KAUeQ9qm8)dA z;~0TJas68$)irBnwbRnFhNb8dU#D@stac4>|0~-==H_ZyP3N4xJ~Gh_c}>l#ddOdP zhvs_i{f14qbR}H-AsXv(d$qjLK1d)8x!Z^vJW}HON^QF7Il&-#V;5P`?N8UF>wyjP zYUWm{DYa8nz7R8tV%h^H0iPZ#$G=!td^B~*<-A$8O4Z=oYm(VVx1x+|AIKmJ&Kq!XWLEbOc!U<~K_5z`B z3l9K=^LGxn+K0Dn-L`$=gxj@gJiunno)cTVeAU{<%}rxK{oV^ zkUkAFQG+E&5WXM87VIK~`}pp}A!MK-3mtv1(U0SV^-~zZqZq|wc#5!o7SG^Wynq++ z5?;aUc!Ti%7TzYbzl-y8-kNG{Ch_7Pzf{vdYmZSmOvS%^xlv97 z6{>>cc%s2^JaznY#idky+3*Y`XeUG7D5rrs$pq>|`Dy+=|1;p<9k)gM|7ibDa2@*q J`d{q-{|3+@fad@J literal 0 HcmV?d00001 diff --git a/public/assets/.DS_Store b/public/assets/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..053094c5a84060185871a43d57c19fe6fd58ba09 GIT binary patch literal 8196 zcmeHMU2GIp6u#fIzziMe00j!}goQ#7vcQ&~wEUUvpYmT|Te>Yj%k0iT2c|P+XLegC zjg5(ke_(vl_|KC@BM%19_@ar5=%bape9HGi25l& zP^eFNK%f&3X)2^6oCd12CeI!asv=A=z}1N#_r-~(LOQ}JS7+er4B^fQGZX~7lU_U! zXGn1xwUGxR4~+GI4F(JpXO-zCGNZlUGacLAV-#GXS*6+~ zb2sN5duPG$`CWO(D)!lKR+*O9tbEbZJfq37(&h<+=BnL%!Lh8Jj%)hMbZ0zc(DQj? zSeYKzDR?cxVi}Xx=Tb`1HFg`cs97>vX=%4+l(CS`PMtk(;e*RoHf%_>ZQj1?a~l)GF>xg86CFPXG}vvk}g-*&YAl_OjXAd z%@lLyv7+f6SL#GD*dsn)mG|j%rtOaI@1iT`MOue;Uo>A;_xhZcfZ;YU+G%My!%}pK@6)(eR=Y~v|H{^Ia$}9GX7Wy7ADQTe zb4|^vdcfayo924${f13Px)kpHF!6faUaerX4^Tjcvs-93_@p%NE4AsS=LAgh#tyQg z+h49p*8>~o*3PL`(`u)vd?{uX#k5N%fq*_M$AVZ_{xo%|rMz4BksS|ufurkENj@#n zv!}9^td(tJ2MK~h>=b)}onhzLr|cs8ntjiHVb|E70474hWK^LBbFmN!tU&`Bu@=o} z$1dzf8vD=-6Gz};5GOE%lXx6Y;TfF5X*`ct@ETsn8N7veaSrEk0UzQLKF1gM3Rm$1 ze#B3>hQIJPt_u@{*+Q)_M_3>vgk{2Vp+RUA)(Pu{9YVX15j5e5;0S}<0HF-M{d`<* zh2zv4_hKRFg%^u(_V&<=J+f)@maX>>^sY?hV4FE>c6`y&6{{OJHr)tH9>wLry%`Px z25v!4fPo-JM#b-Cj!Jo68L^|`*-79r21Osk$|NqmKwc;kc$5izxL95y5^R)-e3*zW z7YV3}5Dew|*eZ$Os8sM_BGw>@3ix21yg?#hDwTYw$|*@wSTKmS#M(pxmm=P+sK3Il zvY**+>^epLRLnpv79&Zq{tz}{2SvLd>zz1&EHvbxqYpOvag5^rB!=-QM(`M(rnoK_pD}Emp)!WPQ%qjl3Q&gAI z1>17$!z5Ei#jWc1*r{~kMabezZokq7Rk2T<9XZf&L&$Gf%sTsuzn zAXUHkawD7uD%1q&IME;-Cwk)_hSZOfsIQX>=?JGZL*+mJA>iNn(>r?qqxb*5djJ0c DPPmDL literal 0 HcmV?d00001 diff --git a/public/assets/certificates/.DS_Store b/public/assets/certificates/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e9a7e7cf2e43c365a4ec1fde6c05e6c180f7e293 GIT binary patch literal 10244 zcmeHMU2GIp6uxKr!wenh1qu}02@8erlLfZ?q~*_S|CI7qU|YH^Kg;aSKnG@L%FgVz zP#PN(6Gg%Jr1768jYb{}pz%c$712jU6O8eJs8L@q(HE5m&%Jk+&@CjINCIRgx%b?A z?mhS3bLKm9_MRn#Kx@HhBqT}*k+CpJRjh9FuyIxWx_8bQN4v_NQq&;mCtz`hSrEQ}@sIwmOpbYR7=07xs5 z%^z&zJ%GzZ1DXivn4nw~*A%x02t*;cVt^3G@hF>)G!f7-L4`O0Ax*K!PZsMRevhR0=-Vn9}8 zB~53IjI3>Jj78(4vFOOURqm}NR&LmxK5FM2_er4w1fKyirA(!##_MBy z#WsbqV=Zw`uD17I=zSo?;7+i*YWn4c@Jrt)_SO5XV!Ig=1n%< zn|19%&hgT8T2{BS1zY#c7TZo*CrsqkdfB{d+dEy)V$1ZUd~?`mV-to_{hpim+xTKB z3)dI3Xu&ggo4BY|G~01$udYlzP|i)AJ#XQX<*S-DCOSH|@48Y;r%j(xr>gyENyoPi zS!TXx$TIwaKHbZjj$t{6dyAH5Wo)zC(R1cBh$+gI+WI+jABt$&ou;M=8S7ZV@{iL7 zS>}60=WFUd1JAVGHP|i&y&&T{xcj2{nzomXwB=2#jX+@OGEMFFP_9F0*m5m~!F8BhKc`kp zYCSUjQchE9IYkR$kFQmrUv4P9nuf$O(QNq0iSiaVbVDL8*b0U=jjST=WE(k1@??aZ zCNGe)!vqc`=+_`1z{o`y`rwaF)Ics)w(Xy3mnm4uFgrUG~397d`07v~eU*V|d zs3=4172${KypoTWJ7f>Ki#aSauced3>;>vV8Qq0eiSA-`sf><7t3@{!Ss|lqQi*rf z#>i>~9gtRtZY7+hVC9X0BO)60|w;4fk8NiNIwaq@EDB2h{R}({&%ulE z61)tr!W-}=qWx`n2eJMhybmA1$M6YUg3kife}|a=6@D+n@%9pe1%*GxS7WmH=fXen{dn<~S?)l{_uv~i-%RwycVe`fW zitmD=xvp<_38X-cAI(F+sUF&f(%nMK=Gg{~5qja{nUP7T*8E`~L%a G|NjTaC4fr+ literal 0 HcmV?d00001 diff --git a/resources/.DS_Store b/resources/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..2176d14bfcb1459626b4b3dc13fc51ef5862baf4 GIT binary patch literal 10244 zcmeHMU2GIp6u#fIzziMe00jzm;zA+(WPvR|Y56nTKjpu`wsc#5mff9^4oqjt&hEBQ z8XFT6|G@a9@t-G+Mji~H@kJ9A(MLrSjPZe}QC~387nKLkojY4#3qGkqoSWQx?mhRM zd*___&YZn>8DnTE7!8a?7-KS3PBoR9TNFOe>$oBX96kxsXWl@@b~7|OpBWe@9Wp`> zgdPYz5PBf=K~rNEMY6CQgzk$yM_HM4vmD$fxrKuO*$^ z%SWBAYiCORbY_ob4yLr4R@cd=Eyv9H3YKA#$i8mNG2Foxx8N9_ubU7PvLY)fZPxJc znudmGBsLO_46j`oi$bzB}vM zg?`6NY16W4J6o{Rd9&HJ6V`E)c-3Au=i2s8*R%LCJ;}T|l;?Bfrjos$o6EQQizO{u zUr1>M&)jX&qE^vtqouu?GSi@(nL7L41&f!hY}^oU-@JX-rD|>3^cgj(+Dl$^^45cv znd=&~jC@~D+RK`bVL6AoiW{<6HOK}*3p8MKc>~o zvcE@Up04gQ=$W>=2H(Zd&dIb6?Y?lHuJ7eDtvQogBT!hfR98DaGG0pug3DK^YUdGp z{hG%gfmBzmR`o7BoiT0AP{ck>YgN6wDEzN&4VW8iR6Ut>`}@g6FW@z|==uS^?Y6X+ z?-(#0dZdfN-uKg7uh*B(nH_@!zks`ixc)08K38ftti0<_k~emc6+L{pX2bApm|Hui zT2JU*vi6yr(lpB{S_FA|tpfRSed*QI$Crw3{rgVD@A;0dkH-XCp_Hbxm8^|zV+UD| z4YQN%S$3M8WgoE%>k-oja&!+E@ii}(be;&WWVxA+d<<0^i~AGjt> zkY-D@(i~~N6qS}q%cVxCNm?hZmv%@UQc_Awhb31U5(Y@6K-(|IrB*sdz2Kukffii) zNoQ^yXmt;4+Pr1!e<#o`PZgmxbJpz0!lf%#H*IVlgP_1{38puK-$(pSapEK1M?@J~ zFNqjb?=8h=c`$ZSsF*`Ck63Mzke;tDkSSEO31YZNT_RJEXcNUSTDM%Lu+$`fs5aEC zQYiSe3NehLX41oA2wkJVVfhn3kQ%w8W|Yqhl2qeC6u4Q2p+;H9>x=d@>6&UPvbc}j~DO~ zUd3yK^f&M(;rwm9gLm-(KEwrl93cK{!uwD7r3}N{OXw98zh1|1F_Cj@*EvKSj`&Ju zFqfyqyCNZuSrewjFeQd5@t;hIH-=i66zLaXm=tgE!|zT{ieo4)ON%0JhV9+sfeK+t zgx~+S-~RvqyT{xU^k4cj;IA&)!t;N4{{J7G|NjIGRe9e4 literal 0 HcmV?d00001 diff --git a/routes/api.php b/routes/api.php index 0a1a1b42..d53be1ab 100644 --- a/routes/api.php +++ b/routes/api.php @@ -112,6 +112,7 @@ use App\Http\Controllers\Api\Attendance\AdminAttendanceApiController; use App\Http\Controllers\Api\Attendance\AttendanceCommentTemplateController; use App\Http\Controllers\Api\Attendance\TeacherAttendanceApiController; use App\Http\Controllers\Api\Attendance\StaffAttendanceApiController; +use App\Http\Controllers\Api\AttendanceManagement\AttendanceManagementController; use App\Http\Controllers\Api\Utilities\ProofreadController; use App\Http\Controllers\Api\PrintRequests\PrintRequestsController; use App\Http\Controllers\Api\BadgeScan\BadgeScanController; @@ -475,6 +476,16 @@ Route::prefix('v1')->group(function () { Route::post('/', [EarlyDismissalsController::class, 'store']); Route::post('/signature', [EarlyDismissalsController::class, 'uploadSignature']); }); + + Route::prefix('management')->group(function () { + Route::get('/dashboard', [AttendanceManagementController::class, 'dashboard']); + Route::post('/scan', [AttendanceManagementController::class, 'scan']); + Route::post('/manual-entry', [AttendanceManagementController::class, 'manualEntry']); + Route::post('/exit-entry', [AttendanceManagementController::class, 'exitEntry']); + Route::post('/absent', [AttendanceManagementController::class, 'absent']); + Route::post('/{id}/follow-up', [AttendanceManagementController::class, 'completeFollowUp']); + Route::post('/{id}/late-slip-reprint', [AttendanceManagementController::class, 'reprintLateSlip']); + }); }); Route::middleware('auth:api')->prefix('attendance-tracking')->group(function () {