with(['employee.user.profile']) ->when(! $hasScopedAccess, function (Builder $query): void { $query->whereRaw('1 = 0'); }) ->when($hasScopedAccess && $scopedEmployeeId !== null, function (Builder $query) use ($scopedEmployeeId): void { $query->where('employee_id', $scopedEmployeeId); }) ->whereDate('attendance_date', '>=', $start->toDateString()) ->whereDate('attendance_date', '<', $end->toDateString()) ->orderBy('attendance_date') ->orderBy('check_in_at') ->get(); } /** * @return array|null */ public function todayAttendanceForEmployee(Employee $employee): ?array { $attendance = Attendance::query() ->where('employee_id', $employee->id) ->whereDate('attendance_date', today()) ->first(); return $attendance?->toArray(); } /** * @param array{photo: string, latitude: float, longitude: float, location_tag: string} $validated */ public function checkIn(array $validated): void { $employee = $this->resolveAuthEmployee(); $existing = Attendance::query() ->where('employee_id', $employee->id) ->whereDate('attendance_date', today()) ->exists(); if ($existing) { throw ValidationException::withMessages([ 'attendance' => 'Anda sudah melakukan presensi masuk hari ini.', ]); } $locationTag = $this->resolveLocationTag( $validated['location_tag'], (float) $validated['latitude'], (float) $validated['longitude'], ); Attendance::create([ 'employee_id' => $employee->id, 'attendance_date' => today(), 'check_in_at' => now(), 'check_in_photo_path' => $this->storePhoto($validated['photo'], $employee->id, 'check-in'), 'check_in_latitude' => $validated['latitude'], 'check_in_longitude' => $validated['longitude'], 'check_in_location_tag' => $locationTag, ]); } /** * @param array{photo: string, latitude: float, longitude: float, location_tag: string} $validated */ public function checkOut(array $validated): void { $employee = $this->resolveAuthEmployee(); $attendance = Attendance::query() ->where('employee_id', $employee->id) ->whereDate('attendance_date', today()) ->first(); if ($attendance === null) { throw ValidationException::withMessages([ 'attendance' => 'Anda belum melakukan presensi masuk hari ini.', ]); } if ($attendance->check_out_at !== null) { throw ValidationException::withMessages([ 'attendance' => 'Anda sudah melakukan presensi pulang hari ini.', ]); } $checkOutAt = now(); $workDurationMinutes = (int) $attendance->check_in_at->diffInMinutes($checkOutAt); $locationTag = $this->resolveLocationTag( $validated['location_tag'], (float) $validated['latitude'], (float) $validated['longitude'], ); $attendance->check_out_at = $checkOutAt; $attendance->check_out_photo_path = $this->storePhoto($validated['photo'], $employee->id, 'check-out'); $attendance->check_out_latitude = $validated['latitude']; $attendance->check_out_longitude = $validated['longitude']; $attendance->check_out_location_tag = $locationTag; $attendance->work_duration_minutes = $workDurationMinutes; $attendance->save(); } public function delete(Attendance $attendance): void { if ($attendance->check_in_photo_path) { Storage::disk('public')->delete($attendance->check_in_photo_path); } if ($attendance->check_out_photo_path) { Storage::disk('public')->delete($attendance->check_out_photo_path); } $attendance->delete(); } private function resolveAuthEmployee(): Employee { $employee = auth()->user()?->employee; if ($employee === null) { throw ValidationException::withMessages([ 'employee' => 'Akun Anda tidak terhubung ke data pegawai.', ]); } return $employee; } private function storePhoto(string $base64Photo, int $employeeId, string $type): string { $image = base64_decode( (string) preg_replace('#^data:image/\w+;base64,#i', '', $base64Photo), true, ); if ($image === false) { throw ValidationException::withMessages([ 'photo' => 'Foto presensi tidak valid.', ]); } $filename = sprintf('%d_%s_%s.jpg', $employeeId, now()->format('Y-m-d_His'), $type); $path = "attendances/{$filename}"; Storage::disk('public')->put($path, $image); return $path; } private function resolveLocationTag(string $clientTag, float $latitude, float $longitude): string { if ($clientTag !== '') { return mb_substr($clientTag, 0, 255); } $geocoded = $this->reverseGeocode($latitude, $longitude); if ($geocoded !== null) { return mb_substr($geocoded, 0, 255); } return mb_substr(sprintf('%s, %s', $latitude, $longitude), 0, 255); } private function reverseGeocode(float $latitude, float $longitude): ?string { try { $response = Http::timeout(5) ->withHeaders(['User-Agent' => config('app.name', 'DST Collection')]) ->get('https://nominatim.openstreetmap.org/reverse', [ 'lat' => $latitude, 'lon' => $longitude, 'format' => 'json', ]); if (! $response->successful()) { return null; } return $response->json('display_name'); } catch (\Throwable) { return null; } } }