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 00000000..056cb669 Binary files /dev/null and b/public/.DS_Store differ diff --git a/public/assets/.DS_Store b/public/assets/.DS_Store new file mode 100644 index 00000000..053094c5 Binary files /dev/null and b/public/assets/.DS_Store differ diff --git a/public/assets/certificates/.DS_Store b/public/assets/certificates/.DS_Store new file mode 100644 index 00000000..e9a7e7cf Binary files /dev/null and b/public/assets/certificates/.DS_Store differ diff --git a/resources/.DS_Store b/resources/.DS_Store new file mode 100644 index 00000000..2176d14b Binary files /dev/null and b/resources/.DS_Store differ 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 () {