fix attendance

This commit is contained in:
root
2026-06-04 20:49:14 -04:00
parent 6fa656bb6c
commit d28d11e2e5
15 changed files with 1101 additions and 44 deletions
@@ -0,0 +1,159 @@
<?php
namespace App\Http\Controllers\Api\AttendanceManagement;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Services\AttendanceManagement\AttendanceManagementService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Response;
class AttendanceManagementController extends BaseApiController
{
public function __construct(private AttendanceManagementService $service)
{
parent::__construct();
}
public function dashboard(Request $request): JsonResponse
{
try {
return $this->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);
}
}
}
@@ -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,
];
}
}
}
@@ -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];
}
}
/**
* 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;
}
}
@@ -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<int,string>,
* 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();
@@ -0,0 +1,525 @@
<?php
namespace App\Services\AttendanceManagement;
use App\Models\Configuration;
use App\Models\LateSlipLog;
use App\Models\Student;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class AttendanceManagementService
{
public const STATUS_PRESENT = 'present';
public const STATUS_ABSENT_PENDING = 'absent_pending_verification';
public const STATUS_ABSENT = 'absent';
public const STATUS_LATE = 'late';
public const STATUS_EARLY_DISMISSAL = 'early_dismissal';
public const STATUS_LATE_WITHOUT_SLIP = 'late_without_slip';
public function dashboard(array $filters = []): array
{
$this->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',
};
}
}
+9 -8
View File
@@ -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)));
+3 -1
View File
@@ -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']
+3 -1
View File
@@ -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,
],
@@ -0,0 +1,97 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('attendance_management_events')) {
Schema::create('attendance_management_events', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,101 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('nav_items') || !Schema::hasTable('role_nav_items') || !Schema::hasTable('roles')) {
return;
}
$now = now();
$label = 'Badge Scanning List';
$url = '/app/administrator/attendance/badge-scans';
$parentId = DB::table('nav_items')
->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();
}
}
};
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+11
View File
@@ -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 () {