1204 lines
46 KiB
PHP
1204 lines
46 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\api;
|
|
|
|
use App\Enums\NotificationType;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Jobs\WriteAttendanceJob;
|
|
use App\Models\AttendanceHistory;
|
|
use App\Models\AttendanceHistoryDaily;
|
|
use App\Models\Classes;
|
|
use App\Models\Notification;
|
|
use App\Models\Permission;
|
|
use App\Models\Report;
|
|
use App\Models\Schedule;
|
|
use App\Models\Student;
|
|
use App\Services\FirebaseMessagingService;
|
|
use App\Services\NotificationHelperService;
|
|
use App\Services\QrLinkService;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
class AttendanceController extends Controller
|
|
|
|
{
|
|
protected FirebaseMessagingService $fcm;
|
|
protected NotificationHelperService $notificationHelper;
|
|
public $distanceMaximumTolerance; // in meters (loaded from config)
|
|
|
|
// using lat and lon from config coordinate.php
|
|
public $latOfAttendance;
|
|
public $lonOfAttendance;
|
|
|
|
|
|
protected QrLinkService $qrLinkService;
|
|
|
|
public function __construct(FirebaseMessagingService $fcm, NotificationHelperService $notificationHelper, QrLinkService $qrLinkService)
|
|
{
|
|
$this->fcm = $fcm;
|
|
$this->notificationHelper = $notificationHelper;
|
|
$this->qrLinkService = $qrLinkService;
|
|
$coordinate = config('coordinate.coordinate', '-7.604032330848524, 112.10176449791652');
|
|
[$lat, $lon] = array_map('trim', explode(',', $coordinate));
|
|
$this->latOfAttendance = (float) $lat;
|
|
$this->lonOfAttendance = (float) $lon;
|
|
|
|
// load maximum distance tolerance from config (meters)
|
|
$this->distanceMaximumTolerance = (int) config('coordinate.max_distance_tolerance', 1000);
|
|
}
|
|
|
|
public function qrAttendance(Request $request) {
|
|
|
|
|
|
/// check academic period active or not.
|
|
|
|
try {
|
|
// request input validation
|
|
$request->validate([
|
|
'id_student' => 'required|integer',
|
|
'id_class' => 'required|integer',
|
|
'qrcode' => 'required|string',
|
|
'latitude' => 'required|numeric',
|
|
'longitude' => 'required|numeric',
|
|
]);
|
|
|
|
// request data variable
|
|
// $idStudent = $request->id_student;
|
|
// $idClass = $request->id_class;
|
|
// $rawCode = $request->qrcode;
|
|
|
|
// get request based on auth user
|
|
$user = $request->user();
|
|
if (!$user) {
|
|
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
|
|
}
|
|
|
|
// check if user has student profile
|
|
$student = $user->student ?? null;
|
|
if (!$student) {
|
|
return response()->json(['success' => false, 'message' => 'Student profile not found for user'], 403);
|
|
}
|
|
|
|
// // use student id from raw body for testing purpose.
|
|
// $idStudent = $request->id_student;
|
|
// $student = Student::find($idStudent);
|
|
// if (!$student) {
|
|
// return response()->json(['success' => false, 'message' => 'Student not found'], 404);
|
|
// }
|
|
|
|
$idStudent = $student->id;
|
|
$idClass = $request->id_class;
|
|
$rawCode = $request->qrcode;
|
|
|
|
// Coordinate Location from device client
|
|
$lat = $request->latitude;
|
|
$lon = $request->longitude;
|
|
|
|
// Center of School Location
|
|
$schoolLat = $this->latOfAttendance;
|
|
$schoolLon = $this->lonOfAttendance;
|
|
|
|
// count distance with helper
|
|
$distance = getDistanceInMeters($lat, $lon, $schoolLat, $schoolLon);
|
|
|
|
if ($distance > $this->distanceMaximumTolerance) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Lokasi di luar jangkauan',
|
|
], 403);
|
|
}
|
|
|
|
// log
|
|
error_log('distance: ');
|
|
error_log($distance);
|
|
|
|
// request time
|
|
// Jika client mengirimkan `date` (format ISO atau yang bisa di-parse oleh Carbon),
|
|
// gunakan itu sebagai waktu sekarang untuk keperluan testing/override.
|
|
if ($request->filled('date')) {
|
|
$now = Carbon::parse($request->input('date'), 'Asia/Jakarta');
|
|
} else {
|
|
$now = Carbon::now('Asia/Jakarta');
|
|
// testing using date: 1 April 2026 07:15 WIB (should be valid for schedule 7-8)
|
|
// $now = Carbon::parse('2026-05-18 07:15:00', 'Asia/Jakarta');
|
|
// $now = Carbon::parse('Asia/Jakarta');
|
|
}
|
|
|
|
$dayOfWeek = strtolower($now->locale('id')->dayName); // example: "senin"
|
|
|
|
// schedule search (compare with qrcode) => class of student and schedule search
|
|
$schedule = Schedule::query()
|
|
->where('id_class', $idClass)
|
|
->where('code', $rawCode)
|
|
->whereHas('academicPeriods', function ($q) {
|
|
$q->where('is_active', 1);
|
|
})
|
|
->first();
|
|
|
|
if (!$schedule) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Jadwal tidak ditemukan / QR tidak valid'], 404);
|
|
}
|
|
|
|
// day validation
|
|
if ($schedule->day_of_week !== $dayOfWeek) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Absensi tidak sesuai dengan jadwal'], 422);
|
|
}
|
|
|
|
// period validation using configuration that has been set
|
|
$periods = config('periods.periods'); // period mapping
|
|
$currentTime = $now->format('H:i');
|
|
$currentPeriod = null;
|
|
|
|
$validPeriod = false;
|
|
foreach (range($schedule->period_start, $schedule->period_end) as $p) {
|
|
if (
|
|
isset($periods[$p]) &&
|
|
$currentTime >= $periods[$p]['start'] &&
|
|
$currentTime < $periods[$p]['end']
|
|
) {
|
|
$validPeriod = true;
|
|
$currentPeriod = $p;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$validPeriod) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'jam pelajaran sudah lewat / belum dimulai'], 422);
|
|
}
|
|
|
|
$lockKey = "attendance:{$idStudent}:{$schedule->id}:" . $now->toDateString();
|
|
|
|
return Cache::lock($lockKey, 5)->block(3, function () use (
|
|
$idStudent,
|
|
$idClass,
|
|
$schedule,
|
|
$lat,
|
|
$lon,
|
|
$currentPeriod,
|
|
$now,
|
|
$lockKey
|
|
) {
|
|
Log::info('Attendance lock acquired', ['key' => $lockKey]);
|
|
error_log('Attendance lock acquired: ' . $lockKey);
|
|
|
|
$alreadyAttendance = AttendanceHistory::where('id_student', $idStudent)
|
|
->where('id_schedule', $schedule->id)
|
|
->where('attendance_date', $now->toDateString())
|
|
->first();
|
|
|
|
Log::info('Checking for existing attendance record:', [
|
|
'id_student' => $idStudent,
|
|
'id_schedule' => $schedule->id,
|
|
'date_query' => $now->toDateString(),
|
|
]);
|
|
|
|
Log::info('Query result:', [
|
|
'attendance_record' => $alreadyAttendance,
|
|
]);
|
|
|
|
if ($alreadyAttendance) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Anda sudah absen untuk mapel ini!'
|
|
], 422);
|
|
}
|
|
|
|
// Dispatch background job to write attendance (quick response to client)
|
|
$jobData = [
|
|
'id_student' => $idStudent,
|
|
'id_schedule' => $schedule->id,
|
|
'id_class' => $idClass,
|
|
'status' => 'hadir',
|
|
'coordinate' => "$lat, $lon",
|
|
'period_number' => $currentPeriod,
|
|
'attendance_date' => $now->toDateString(),
|
|
'created_at' => $now,
|
|
];
|
|
|
|
WriteAttendanceJob::dispatch($jobData);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Absensi diterima dan sedang diproses',
|
|
'data' => [
|
|
'schedule' => $schedule,
|
|
]
|
|
], 202);
|
|
});
|
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
|
// This will catch validation errors and return a 422 status
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Validasi gagal',
|
|
'errors' => $e->errors()
|
|
], 422);
|
|
} catch (LockTimeoutException $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Permintaan sedang diproses, silakan coba lagi sebentar',
|
|
], 429);
|
|
} catch (\Exception $e) {
|
|
// This will catch any other general exceptions
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Terjadi kesalahan pada server',
|
|
'error' => $e->getMessage()
|
|
], 500);
|
|
}
|
|
|
|
|
|
}
|
|
|
|
// get attendance report from eloquent AttendanceHistoryDaily from face recognition attendance
|
|
// based on student and date.
|
|
public function dailyAttendanceReport(Request $request)
|
|
{
|
|
try {
|
|
$request->validate([
|
|
'date' => 'required|date',
|
|
]);
|
|
|
|
$user = $request->user();
|
|
$student = $user->student ?? null;
|
|
if (!$student) {
|
|
return response()->json(['success' => false, 'message' => 'Student profile not found for user'], 403);
|
|
}
|
|
|
|
$date = Carbon::parse($request->date)->toDateString();
|
|
|
|
$attendanceRecords = AttendanceHistoryDaily::query()
|
|
->where('id_student', $student->id)
|
|
->whereDate('created_at', $date)
|
|
->get()
|
|
->first();
|
|
|
|
// if record is empty, return message no attendance record for that date
|
|
if (!$attendanceRecords) {
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'No attendance record for this date',
|
|
'data' => null,
|
|
], 200);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Daily attendance report fetched',
|
|
'data' => $attendanceRecords,
|
|
], 200);
|
|
|
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Validation failed',
|
|
'errors' => $e->errors(),
|
|
], 422);
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Server error',
|
|
'error' => $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
// get attendance report based on schedule, student, and date.
|
|
// expected result: List Schedule [schedule + attendance status (hadir, izin, sakit, alpa), if in schedule hasnt attendance record just return attendance status: null]
|
|
// get parameter: id_class, date, id_student (from token)
|
|
|
|
// this is for personal student attendance report, to show the attendance status for each schedule in that day.
|
|
public function report(Request $request)
|
|
{
|
|
try {
|
|
$request->validate([
|
|
'id_class' => 'required|integer',
|
|
'date' => 'required|date',
|
|
]);
|
|
|
|
$user = $request->user();
|
|
$student = $user->student ?? null;
|
|
if (!$student) {
|
|
return response()->json(['success' => false, 'message' => 'Student profile not found for user'], 403);
|
|
}
|
|
|
|
$idClass = $request->id_class;
|
|
$date = Carbon::parse($request->date)->toDateString();
|
|
$dayName = strtolower(Carbon::parse($date)->locale('id')->dayName);
|
|
$dayIndex = Carbon::parse($date)->dayOfWeekIso; // 1..7
|
|
|
|
// fetch schedules for class where academic period is active and day matches
|
|
$schedules = Schedule::query()
|
|
->where('id_class', $idClass)
|
|
->whereHas('academicPeriods', function ($q) {
|
|
$q->where('is_active', 1);
|
|
})
|
|
->where(function ($q) use ($dayName, $dayIndex) {
|
|
$q->where('day_of_week', $dayName)
|
|
->orWhere('day_of_week', (string) $dayIndex);
|
|
})
|
|
->with(['subject', 'teacher'])
|
|
->orderBy('period_start')
|
|
->get();
|
|
|
|
$result = [];
|
|
foreach ($schedules as $sch) {
|
|
$attendance = AttendanceHistory::where('id_student', $student->id)
|
|
->where('id_schedule', $sch->id)
|
|
->where('attendance_date', $date)
|
|
->first();
|
|
|
|
$result[] = [
|
|
'schedule' => $sch,
|
|
'attendance_status' => $attendance ? $attendance->status : null,
|
|
'attendance' => $attendance ?? null,
|
|
];
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Attendance report fetched',
|
|
'data' => $result,
|
|
], 200);
|
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Validation failed',
|
|
'errors' => $e->errors(),
|
|
], 422);
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Server error',
|
|
'error' => $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
// get attendance report history all student in once class filtered by class, and date now.
|
|
// expected result: List Schedule [schedule + all user in class + attendance status (hadir, izin, sakit, alpa), if in schedule hasnt attendance record, dont display the student in that schedule]
|
|
public function reportClass(Request $request)
|
|
{
|
|
try {
|
|
$request->validate([
|
|
'id_class' => 'required|integer',
|
|
'date' => 'required|date',
|
|
]);
|
|
|
|
$idClass = $request->id_class;
|
|
$date = Carbon::parse($request->date)->toDateString();
|
|
$dayName = strtolower(Carbon::parse($date)->locale('id')->dayName);
|
|
$dayIndex = Carbon::parse($date)->dayOfWeekIso; // 1..7
|
|
|
|
// schedules for class on that day and active academic period
|
|
$schedules = Schedule::query()
|
|
->where('id_class', $idClass)
|
|
->whereHas('academicPeriods', function ($q) {
|
|
$q->where('is_active', 1);
|
|
})
|
|
->where(function ($q) use ($dayName, $dayIndex) {
|
|
$q->where('day_of_week', $dayName)
|
|
->orWhere('day_of_week', (string) $dayIndex);
|
|
})
|
|
->with(['subject', 'teacher', 'class'])
|
|
->orderBy('period_start')
|
|
->get();
|
|
|
|
$data = [];
|
|
foreach ($schedules as $sch) {
|
|
// get attendance records for that schedule on the date
|
|
$attendances = AttendanceHistory::query()
|
|
->where('id_schedule', $sch->id)
|
|
->where('attendance_date', $date)
|
|
->with(['student.user'])
|
|
->get();
|
|
|
|
// if there are no attendance records, still include schedule with empty list (per request could be omitted by client)
|
|
$items = [];
|
|
foreach ($attendances as $att) {
|
|
$items[] = [
|
|
'attendance' => $att,
|
|
// 'student' => $att->student,
|
|
'status' => $att->status,
|
|
];
|
|
}
|
|
|
|
$data[] = [
|
|
'schedule' => $sch,
|
|
'attendances' => $items,
|
|
];
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Attendance history for class fetched',
|
|
'data' => $data,
|
|
], 200);
|
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Validation failed',
|
|
'errors' => $e->errors(),
|
|
], 422);
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Server error',
|
|
'error' => $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
// post permission from student using data
|
|
public function createPermission(Request $request)
|
|
{
|
|
try {
|
|
$request->validate([
|
|
'reason' => 'required|string',
|
|
'information' => 'nullable|string',
|
|
'evidence' => 'nullable|file|mimes:jpg,jpeg,png|max:2048',
|
|
'date_permission' => 'required|date',
|
|
// count days
|
|
'time_period' => 'required|integer|min:1|max:10',
|
|
]);
|
|
|
|
$user = $request->user();
|
|
$student = $user->student ?? null;
|
|
if (!$student) {
|
|
return response()->json(['success' => false, 'message' => 'Student profile not found for user'], 403);
|
|
}
|
|
|
|
$permission = new Permission();
|
|
$permission->id_student = $student->id;
|
|
$permission->reason = $request->reason;
|
|
$permission->information = $request->information;
|
|
$permission->date_permission = Carbon::parse($request->date_permission)->toDateString();
|
|
$permission->time_period = $request->time_period;
|
|
|
|
if ($request->hasFile('evidence')) {
|
|
$file = $request->file('evidence');
|
|
|
|
// filename format: {id_student}_{nama_lengkap}_timestamp.extension
|
|
$displayName = $student->name ?? ($student->user->name ?? 'student');
|
|
$safeName = str_replace(' ', '-', strtolower($displayName));
|
|
// Example: 12_budi-sudarsono_1771945730.jpg
|
|
$filename = $student->id . '_' . $safeName . '_' . time() . '.' . $file->getClientOriginalExtension();
|
|
|
|
|
|
Storage::disk('public')->putFileAs('permission/evidence', $file, $filename);
|
|
$permission->evidence = $filename;
|
|
}
|
|
|
|
$permission->save();
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Permission request created successfully',
|
|
'data' => $permission,
|
|
], 201);
|
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Validation failed',
|
|
'errors' => $e->errors(),
|
|
], 422);
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Server error',
|
|
'error' => $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
public function permissionReport(Request $request)
|
|
{
|
|
try {
|
|
$request->validate([
|
|
'start_date' => 'required|date',
|
|
'end_date' => 'required|date|after_or_equal:start_date',
|
|
]);
|
|
|
|
$user = $request->user();
|
|
$student = $user->student ?? null;
|
|
if (!$student) {
|
|
return response()->json(['success' => false, 'message' => 'Student profile not found for user'], 403);
|
|
}
|
|
|
|
$startDate = Carbon::parse($request->start_date)->toDateString();
|
|
$endDate = Carbon::parse($request->end_date)->toDateString();
|
|
|
|
$permissions = Permission::query()
|
|
->where('id_student', $student->id)
|
|
->whereDate('created_at', '>=', $startDate)
|
|
->whereDate('created_at', '<=', $endDate)
|
|
->get();
|
|
|
|
if ($permissions->isEmpty()) {
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'No permission records for this date',
|
|
'data' => null,
|
|
], 200);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Permission report fetched',
|
|
'data' => $permissions,
|
|
], 200);
|
|
|
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Validation failed',
|
|
'errors' => $e->errors(),
|
|
], 422);
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Server error',
|
|
'error' => $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
// get attendance in schedule per day by class id. (teacher only)
|
|
public function reportClassById(Request $request, $classId, $date)
|
|
{
|
|
try {
|
|
// check bearer token and get user
|
|
$user = $request->user();
|
|
if (!$user) {
|
|
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
|
|
}
|
|
|
|
$date = Carbon::parse($date)->toDateString();
|
|
$dayName = strtolower(Carbon::parse($date)->locale('id')->dayName);
|
|
$dayIndex = Carbon::parse($date)->dayOfWeekIso; // 1..7
|
|
|
|
$students = $this->getStudentsForClass((int) $classId);
|
|
$schedules = $this->getActiveSchedulesForClassByDate((int) $classId, $dayName, $dayIndex);
|
|
|
|
$data = [];
|
|
foreach ($schedules as $sch) {
|
|
$data[] = [
|
|
'schedule' => $sch,
|
|
'attendances' => $this->buildAttendanceItemsForSchedule($sch, $date, $students),
|
|
// fallback to oz ex: 2026-04-22T00:00:00Z,
|
|
'date_attendance' => Carbon::parse($date)->toDateString(),
|
|
];
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Attendance history for class fetched',
|
|
'data' => $data,
|
|
], 200);
|
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Validation failed',
|
|
'errors' => $e->errors(),
|
|
], 422);
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Server error',
|
|
'error' => $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
// daily report attendance for teacher side.
|
|
public function reportDailyAttendance(Request $request)
|
|
{
|
|
try{
|
|
// check bearer token and get user
|
|
$user = $request->user();
|
|
if (!$user) {
|
|
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
|
|
}
|
|
|
|
// get input request $classId, $date
|
|
$request->validate([
|
|
'id_class' => 'required|integer',
|
|
'date' => 'required|date',
|
|
]);
|
|
|
|
$classId = $request->input('id_class');
|
|
$date = Carbon::parse($request->input('date'))->toDateString();
|
|
|
|
// Get class info
|
|
$class = Classes::find($classId);
|
|
|
|
// Get all students in the class
|
|
$students = Student::query()
|
|
->where('id_class', $classId)
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
// Get all attendance records for this class and date
|
|
$historyReport = AttendanceHistoryDaily::query()
|
|
->where('id_class', $classId)
|
|
->whereHas('class.schedules.academicPeriods', function ($q) {
|
|
$q->where('is_active', 1);
|
|
})
|
|
->whereDate('created_at', $date)
|
|
->with(['class', 'student'])
|
|
->get();
|
|
|
|
// Create a map of attendance records by student id
|
|
$attendanceByStudent = $historyReport->keyBy('id_student');
|
|
|
|
// Add missing students with status 'none'
|
|
foreach ($students as $student) {
|
|
if (!$attendanceByStudent->has($student->id)) {
|
|
$newRecord = new AttendanceHistoryDaily();
|
|
$newRecord->id_student = $student->id;
|
|
$newRecord->id_class = $classId;
|
|
$newRecord->status = 'none';
|
|
$newRecord->picture = null;
|
|
$newRecord->created_at = $date;
|
|
$newRecord->setRelation('student', $student);
|
|
$newRecord->setRelation('class', $class);
|
|
|
|
$historyReport->push($newRecord);
|
|
}
|
|
}
|
|
|
|
if ($historyReport->isEmpty()) {
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'No attendance history for this class and date',
|
|
'data' => null,
|
|
], 200);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Daily attendance report for class fetched',
|
|
'data' => $historyReport,
|
|
], 200);
|
|
|
|
|
|
} catch (\Illuminate\Validation\ValidationException $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Validation failed',
|
|
'errors' => $e->errors(),
|
|
], 422);
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Server error',
|
|
'error' => $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return \Illuminate\Support\Collection<int, \App\Models\Student>
|
|
*/
|
|
private function getStudentsForClass(int $classId)
|
|
{
|
|
return Student::query()
|
|
->where('id_class', $classId)
|
|
->with('user')
|
|
->orderBy('name')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* @return \Illuminate\Support\Collection<int, \App\Models\Schedule>
|
|
*/
|
|
private function getActiveSchedulesForClassByDate(int $classId, string $dayName, int $dayIndex)
|
|
{
|
|
return Schedule::query()
|
|
->where('id_class', $classId)
|
|
->whereHas('academicPeriods', function ($q) {
|
|
$q->where('is_active', 1);
|
|
})
|
|
->where(function ($q) use ($dayName, $dayIndex) {
|
|
$q->where('day_of_week', $dayName)
|
|
->orWhere('day_of_week', (string) $dayIndex);
|
|
})
|
|
->with(['subject', 'teacher', 'class'])
|
|
->orderBy('period_start')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* @param \App\Models\Schedule $schedule
|
|
* @param string $date
|
|
* @param \Illuminate\Support\Collection<int, \App\Models\Student> $students
|
|
* @return array<int, array{attendance: \App\Models\AttendanceHistory, status: mixed}>
|
|
*/
|
|
private function buildAttendanceItemsForSchedule($schedule, string $date, $students): array
|
|
{
|
|
$attendances = AttendanceHistory::query()
|
|
->where('id_schedule', $schedule->id)
|
|
->where('attendance_date', $date)
|
|
->with(['student.user'])
|
|
->get();
|
|
|
|
$attendanceByStudent = $attendances->keyBy('id_student');
|
|
$items = [];
|
|
|
|
foreach ($students as $student) {
|
|
$attendance = $attendanceByStudent->get($student->id);
|
|
|
|
if (!$attendance) {
|
|
$attendance = new AttendanceHistory([
|
|
'period_number' => $schedule->period_start,
|
|
'status' => 'null',
|
|
'coordinate' => null,
|
|
'attendance_date' => $date,
|
|
'id_student' => $student->id,
|
|
'id_schedule' => $schedule->id,
|
|
'id_class' => $schedule->id_class,
|
|
]);
|
|
$attendance->setRelation('student', $student);
|
|
}
|
|
|
|
$items[] = [
|
|
'attendance' => $attendance,
|
|
'status' => $attendance->status,
|
|
];
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
// permission get by class id. (teacher only)
|
|
public function permissionReportByClass(Request $request, $classId)
|
|
{
|
|
try {
|
|
// check bearer token and get user
|
|
$user = $request->user();
|
|
if (!$user) {
|
|
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
|
|
}
|
|
|
|
$request->validate([
|
|
'start_date' => 'required|string',
|
|
'end_date' => 'required|string|after_or_equal:start_date',
|
|
]);
|
|
|
|
$startDate = $request->input('start_date');
|
|
$endDate = $request->input('end_date');
|
|
|
|
$permissions = Permission::query()
|
|
->whereHas('student', function ($q) use ($classId) {
|
|
$q->where('id_class', $classId);
|
|
})
|
|
->whereDate('created_at', '>=', $startDate)
|
|
->whereDate('created_at', '<=', $endDate)
|
|
->with(['student.user'])
|
|
->orderBy('created_at', 'desc')
|
|
->get();
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Permission report for class fetched',
|
|
'data' => $permissions,
|
|
], 200);
|
|
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Server error',
|
|
'error' => $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
// permission accept for teacher only
|
|
public function permissionAccept(Request $request)
|
|
{
|
|
try {
|
|
// check bearer token and get user
|
|
$user = $request->user();
|
|
if (!$user) {
|
|
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
|
|
}
|
|
|
|
$permissionId = $request->input('permission_id');
|
|
|
|
if (!$permissionId) {
|
|
return response()->json(['success' => false, 'message' => 'Permission ID is required'], 422);
|
|
}
|
|
|
|
// find permission by id
|
|
$permission = Permission::find($permissionId);
|
|
if (!$permission) {
|
|
return response()->json(['success' => false, 'message' => 'Permission not found'], 404);
|
|
}
|
|
|
|
// permission must be processed only, if already accepted or rejected, cannot be processed again.
|
|
if ($permission->status === 'diterima') {
|
|
return response()->json(['success' => false, 'message' => 'Permission already accepted'], 422);
|
|
}
|
|
if ($permission->status === 'ditolak') {
|
|
return response()->json(['success' => false, 'message' => 'Permission already rejected'], 422);
|
|
}
|
|
|
|
$student = Student::find($permission->id_student);
|
|
if (!$student) {
|
|
return response()->json(['success' => false, 'message' => 'Student not found'], 404);
|
|
}
|
|
|
|
DB::transaction(function () use ($permission, $user, $request, $student) {
|
|
// update status to accepted
|
|
$permission->status = 'diterima';
|
|
$permission->approved_by = $user->id; // set approved by teacher id
|
|
$permission->feedback = $request->input('feedback', null); // optional feedback message from teacher
|
|
$permission->save();
|
|
|
|
// * create attendance record history to related date (only fill with schedule that has active academic period, and match with date permission).
|
|
$permissionReason = $permission->reason; // contoh: 'sakit', 'izin', 'dispen', 'lainnya'
|
|
if ($permissionReason === 'lainnya') {
|
|
$permissionReason = 'izin';
|
|
}
|
|
|
|
$permissionDate = Carbon::parse($permission->date_permission)->startOfDay();
|
|
$permissionTimePeriod = (int) $permission->time_period;
|
|
$createdSchoolDays = 0;
|
|
$currentDate = $permissionDate->copy();
|
|
|
|
// Count time_period as school days; weekend days are skipped and replaced by the next weekday.
|
|
while ($createdSchoolDays < $permissionTimePeriod) {
|
|
if ($currentDate->isWeekend()) {
|
|
$currentDate->addDay();
|
|
continue;
|
|
}
|
|
|
|
$dayName = strtolower($currentDate->locale('id')->dayName);
|
|
$dayIndex = $currentDate->dayOfWeekIso; // 1..7
|
|
|
|
// search related schedule with day_of_week same and active academic period
|
|
$schedules = Schedule::query()
|
|
->where('id_class', $student->id_class)
|
|
->whereHas('academicPeriods', function ($q) {
|
|
$q->where('is_active', 1);
|
|
})
|
|
->where(function ($q) use ($dayName, $dayIndex) {
|
|
$q->where('day_of_week', $dayName)
|
|
->orWhere('day_of_week', (string) $dayIndex);
|
|
})
|
|
->get();
|
|
|
|
foreach ($schedules as $schedule) {
|
|
AttendanceHistory::updateOrCreate(
|
|
[
|
|
'id_schedule' => $schedule->id,
|
|
'id_student' => $student->id,
|
|
'attendance_date' => $currentDate->toDateString(),
|
|
],
|
|
[
|
|
'id_class' => $schedule->id_class,
|
|
'period_number' => $schedule->period_start,
|
|
'coordinate' => "$this->latOfAttendance, $this->lonOfAttendance",
|
|
'status' => $permissionReason,
|
|
]
|
|
);
|
|
}
|
|
|
|
// update attendancehistorydaily to 'izin'
|
|
AttendanceHistoryDaily::updateOrCreate(
|
|
[
|
|
'id_student' => $student->id,
|
|
'id_class' => $student->id_class,
|
|
'created_at' => $currentDate->toDateString(),
|
|
],
|
|
[
|
|
'status' => 'izin',
|
|
'picture' => '',
|
|
]
|
|
);
|
|
|
|
$createdSchoolDays++;
|
|
$currentDate->addDay();
|
|
}
|
|
});
|
|
|
|
// * notification zone: send notification to student related to the permission that has been accepted
|
|
$userStudent = $student->user;
|
|
if (!$userStudent) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'User student not found',
|
|
], 404);
|
|
}
|
|
|
|
$fcmToken = $userStudent->device_token;
|
|
|
|
$template = $this->notificationHelper->templatePermissionApproval(
|
|
$fcmToken
|
|
);
|
|
|
|
try {
|
|
$notificationResult = $this->notificationHelper->send($template);
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to send attendance violation notification', ['exception' => $e]);
|
|
$notificationResult = [
|
|
'success' => false,
|
|
'message' => 'Failed to send notification',
|
|
'error' => $e->getMessage(),
|
|
];
|
|
}
|
|
|
|
$createTemplate = $this->notificationHelper->createTemplateForStudent(
|
|
(int) $userStudent->id,
|
|
$template->title,
|
|
$template->body,
|
|
NotificationType::PermissionAccepted,
|
|
// user id of teacher, convert to int.
|
|
(int) $user->id
|
|
);
|
|
|
|
Notification::create($createTemplate->toArray());
|
|
|
|
// * end of notification zone
|
|
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Izin diterima untuk siswa ' . $student->name,
|
|
'data' => [
|
|
'permission' => $permission,
|
|
'notification_result' => $notificationResult,
|
|
],
|
|
], 200);
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Server error',
|
|
'error' => $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
|
|
public function permissionReject(Request $request)
|
|
{
|
|
try {
|
|
// check bearer token and get user
|
|
$user = $request->user();
|
|
if (!$user) {
|
|
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
|
|
}
|
|
|
|
$permissionId = $request->input('permission_id');
|
|
|
|
if (!$permissionId) {
|
|
return response()->json(['success' => false, 'message' => 'Permission ID is required'], 422);
|
|
}
|
|
|
|
// find permission by id
|
|
$permission = Permission::find($permissionId);
|
|
if (!$permission) {
|
|
return response()->json(['success' => false, 'message' => 'Permission not found'], 404);
|
|
}
|
|
|
|
// permission must be processed only, if already accepted or rejected, cannot be processed again.
|
|
if ($permission->status === 'diterima') {
|
|
return response()->json(['success' => false, 'message' => 'Permission already accepted'], 422);
|
|
}
|
|
if ($permission->status === 'ditolak') {
|
|
return response()->json(['success' => false, 'message' => 'Permission already rejected'], 422);
|
|
}
|
|
|
|
$student = Student::find($permission->id_student);
|
|
if (!$student) {
|
|
return response()->json(['success' => false, 'message' => 'Student not found'], 404);
|
|
}
|
|
|
|
DB::transaction(function () use ($permission, $user, $request) {
|
|
|
|
// update status to rejected
|
|
$permission->status = 'ditolak';
|
|
$permission->approved_by = $user->id; // set approved by teacher id
|
|
$permission->feedback = $request->input('feedback', null); // optional feedback message from teacher
|
|
$permission->save();
|
|
|
|
});
|
|
|
|
/// send notification while saving to database notification.
|
|
|
|
$fcmToken = $student->user->device_token;
|
|
|
|
$template = $this->notificationHelper->templatePermissionRejection(
|
|
$fcmToken,
|
|
$request->input('feedback', null) // optional custom message from teacher feedback
|
|
);
|
|
|
|
try {
|
|
$notificationResult = $this->notificationHelper->send($template);
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to send attendance violation notification', ['exception' => $e]);
|
|
$notificationResult = [
|
|
'success' => false,
|
|
'message' => 'Failed to send notification',
|
|
'error' => $e->getMessage(),
|
|
];
|
|
}
|
|
|
|
$createTemplate = $this->notificationHelper->createTemplateForStudent(
|
|
(int) $student->user->id,
|
|
$template->title,
|
|
$template->body,
|
|
NotificationType::PermissionRejected,
|
|
// user id of teacher, convert to int.
|
|
(int) $user->id
|
|
);
|
|
|
|
Notification::create($createTemplate->toArray());
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Izin ditolak untuk siswa ' . $student->name,
|
|
'data' => [
|
|
'permission' => $permission,
|
|
'notification_result' => $notificationResult,
|
|
],
|
|
], 200);
|
|
|
|
} catch (\Exception $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Server error',
|
|
'error' => $e->getMessage(),
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
public function reportDisrepancyStudentAttendanceByHistoryId(Request $request)
|
|
{
|
|
$user = $request->user();
|
|
|
|
if (!$user) {
|
|
return response()->json(['success' => false, 'message' => 'Unauthorized'], 401);
|
|
}
|
|
|
|
// input validate
|
|
$request->validate([
|
|
'attendance_history_id' => 'required|integer',
|
|
'disrepancy_type' => 'nullable|string|in:terlambat,hp_tidak_tersedia,izin,pulang_awal,alasan_lain',
|
|
'description' => 'nullable|string',
|
|
]);
|
|
|
|
$attendanceHistoryId = $request->input('attendance_history_id');
|
|
|
|
$attendanceHistory = AttendanceHistory::query()
|
|
->where('id', $attendanceHistoryId)
|
|
->with(['schedule'])
|
|
->first();
|
|
|
|
if (!$attendanceHistory) {
|
|
return response()->json(['success' => false, 'message' => 'Attendance history not found'], 404);
|
|
}
|
|
|
|
// cek status attendance history if already 'invalid', cannot report disrepancy again.
|
|
if ($attendanceHistory->status === 'invalid') {
|
|
return response()->json(['success' => false, 'message' => 'Attendance history already reported as invalid'], 422);
|
|
}
|
|
|
|
/// logic disrepancy here.
|
|
DB::transaction(function () use ($request, $attendanceHistory) {
|
|
$attendanceHistory->status = 'invalid';
|
|
$attendanceHistory->save();
|
|
|
|
// create report discrepancy
|
|
$report = new Report();
|
|
$report->id_attendance_history = $attendanceHistory->id;
|
|
$report->id_student = $attendanceHistory->id_student;
|
|
$report->id_class = $attendanceHistory->id_class;
|
|
$report->student_name = $attendanceHistory->student->name ?? 'Unknown Student';
|
|
$report->reported_by = $request->user()->id;
|
|
$report->disrepancy_type = $request->input('disrepancy_type', 'alasan_lain'); // default to 'alasan_lain' if not provided
|
|
$report->description = $request->input('description', null);
|
|
$report->marked_as_resolved = false;
|
|
$report->attendance_date = $attendanceHistory->attendance_date;
|
|
$report->save();
|
|
|
|
});
|
|
|
|
/// notification zone for report disrepancy, send notification to teacher related to the schedule of attendance history that has been reported.
|
|
$userStudent = $attendanceHistory->student->user;
|
|
$fcmToken = $userStudent->device_token;
|
|
|
|
$template = $this->notificationHelper->templateAttendanceViolation(
|
|
$fcmToken,
|
|
$request->input('description', null) // optional custom message from student about the disrepancy
|
|
);
|
|
|
|
// use try catch to handle notification sending error, but still return success response for reporting disrepancy, because the main process of reporting disrepancy has been done in database transaction.
|
|
try {
|
|
$notificationResult = $this->notificationHelper->send($template);
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to send attendance violation notification', ['exception' => $e]);
|
|
$notificationResult = [
|
|
'success' => false,
|
|
'message' => 'Failed to send notification',
|
|
'error' => $e->getMessage(),
|
|
];
|
|
}
|
|
|
|
$createTemplate = $this->notificationHelper->createTemplateForStudent(
|
|
(int) $userStudent->id,
|
|
$template->title,
|
|
$template->body,
|
|
NotificationType::AttendanceViolation,
|
|
// user id of system or admin, convert to int. (for example: 0)
|
|
(int) $user->id
|
|
);
|
|
|
|
Notification::create($createTemplate->toArray());
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => [
|
|
'report' => Report::query()->where('id_attendance_history', $attendanceHistory->id)->first(),
|
|
'notification_result' => $notificationResult,
|
|
]
|
|
], 200);
|
|
|
|
}
|
|
|
|
// return json response link randomize for public qr code, access from website
|
|
// use cache 5 minutes for the link, if more than 5 minutes, the link will be expired and generate new link.
|
|
public function generateLinkQrAttendance(Request $request, $idSchedule)
|
|
{
|
|
try {
|
|
if (!$request->user()) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Unauthorized'
|
|
], 401);
|
|
}
|
|
|
|
$result = $this->qrLinkService->generateLink($idSchedule);
|
|
return response()->json($result);
|
|
|
|
} catch (\Throwable $th) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'Server error',
|
|
'error' => $th->getMessage()
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
}
|