feat: implement transaction management in AttendanceService for check-in and check-out processes; refactor attendance date formatting in Attendance model; enhance AttendanceDetailDialog and Index components for improved UI and code clarity

This commit is contained in:
Yoga Pangestu 2026-07-04 12:00:52 +07:00
parent e153497114
commit 786d0fdb39
5 changed files with 88 additions and 90 deletions

View File

@ -49,7 +49,7 @@ protected function casts(): array
public function attendanceDateFormatted(): Attribute
{
return Attribute::make(
get: fn () => Carbon::parse($this->attendance_date)->translatedFormat('l, d F Y'),
get: fn () => $this->attendance_date?->translatedFormat('l, d F Y'),
);
}

View File

@ -9,6 +9,7 @@
use App\Models\LeaveRequest;
use App\Models\User;
use App\Services\Concerns\ResolvesAuthenticatedEmployee;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService;
use Carbon\CarbonInterface;
@ -18,7 +19,7 @@
class AttendanceService
{
use ResolvesAuthenticatedEmployee;
use ResolvesAuthenticatedEmployee, RunsInTransaction;
public function __construct(
private readonly MediaService $mediaService,
@ -71,30 +72,35 @@ public function checkIn(array $validated, User $user): void
{
$employee = $this->resolveAuthenticatedEmployee($user);
$existing = Attendance::query()
->where('employee_id', $employee->id)
->whereDate('attendance_date', today())
->exists();
$this->runInTransaction(
function () use ($employee, $validated): void {
$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.',
]);
}
if ($existing) {
throw ValidationException::withMessages([
'attendance' => 'Anda sudah melakukan presensi masuk hari ini.',
]);
}
$attendance = Attendance::create([
'employee_id' => $employee->id,
'attendance_date' => today(),
'check_in_at' => now(),
'check_in_latitude' => $validated['latitude'],
'check_in_longitude' => $validated['longitude'],
]);
$attendance = Attendance::create([
'employee_id' => $employee->id,
'attendance_date' => today(),
'check_in_at' => now(),
'check_in_latitude' => $validated['latitude'],
'check_in_longitude' => $validated['longitude'],
]);
$this->mediaService->addBase64Image(
$attendance,
$validated['photo'],
'checkin',
'checkin',
$this->mediaService->addBase64Image(
$attendance,
$validated['photo'],
'checkin',
'checkin',
);
},
'Gagal mencatat presensi masuk',
);
$this->pushNotificationService->sendToRoles(
@ -109,38 +115,45 @@ public function checkOut(array $validated, User $user): void
{
$employee = $this->resolveAuthenticatedEmployee($user);
$attendance = Attendance::query()
->where('employee_id', $employee->id)
->whereDate('attendance_date', today())
->first();
$workDurationMinutes = $this->runInTransaction(
function () use ($employee, $validated): int {
$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 === 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.',
]);
}
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);
$checkOutAt = now();
$workDurationMinutes = (int) $attendance->check_in_at->diffInMinutes($checkOutAt);
$attendance->update([
'check_out_at' => $checkOutAt,
'check_out_latitude' => $validated['latitude'],
'check_out_longitude' => $validated['longitude'],
'work_duration_minutes' => $workDurationMinutes,
]);
$attendance->update([
'check_out_at' => $checkOutAt,
'check_out_latitude' => $validated['latitude'],
'check_out_longitude' => $validated['longitude'],
'work_duration_minutes' => $workDurationMinutes,
]);
$this->mediaService->addBase64Image(
$attendance,
$validated['photo'],
'checkout',
'checkout',
$this->mediaService->addBase64Image(
$attendance,
$validated['photo'],
'checkout',
'checkout',
);
return $workDurationMinutes;
},
'Gagal mencatat presensi pulang',
);
$this->pushNotificationService->sendToRoles(
@ -157,13 +170,18 @@ public function delete(Attendance $attendance): void
$employeeName = $attendance->employee?->user?->profile?->full_name;
$date = $attendance->attendance_date;
$attendance->clearMediaCollection('checkin');
$attendance->clearMediaCollection('checkout');
$attendance->delete();
$this->runInTransaction(
function () use ($attendance): void {
$attendance->clearMediaCollection('checkin');
$attendance->clearMediaCollection('checkout');
$attendance->delete();
},
'Gagal menghapus presensi',
);
$this->pushNotificationService->sendToRoles(
'🗑️ Presensi Dihapus',
"Data presensi {$employeeName} tanggal {$date} telah dihapus.",
"Data presensi {$employeeName} tanggal {$date?->toDateString()} telah dihapus.",
['owner', 'developer', 'direktur'],
route('admin.hr.attendances.index'),
);

View File

@ -8,7 +8,7 @@ import type { AttendanceListItem, CalendarRange, TodayAttendance } from '@/types
import AttendanceWebcamModal from './form/AttendanceWebcamModal.vue';
import AttendanceCalendar from './table/AttendanceCalendar.vue';
const props = defineProps<{
defineProps<{
attendances: AttendanceListItem[];
todayAttendance: TodayAttendance;
isOnLeave: boolean;
@ -44,13 +44,8 @@ function openCheckOutModal() {
</div>
</div>
<AttendanceCard
:today-attendance="todayAttendance"
:is-on-leave="isOnLeave"
:can-check-in="canCheckIn"
@check-in="openCheckInModal"
@check-out="openCheckOutModal"
/>
<AttendanceCard :today-attendance="todayAttendance" :is-on-leave="isOnLeave" :can-check-in="canCheckIn"
@check-in="openCheckInModal" @check-out="openCheckOutModal" />
<Card class="min-w-0 overflow-hidden">
<CardContent class="min-w-0">

View File

@ -1,7 +1,5 @@
<script setup lang="ts">
import { Trash2 } from '@lucide/vue';
import AttendanceMap from './AttendanceMap.vue';
import AttendancePhotoCell from './attendance-photo-cell.vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import {
@ -13,8 +11,10 @@ import {
} from '@/components/ui/dialog';
import { useCan } from '@/composables/useCan';
import { useDestroy } from '@/composables/useDestroy';
import type { AttendanceListItem } from '@/types/attendance';
import { destroy } from '@/routes/admin/hr/attendances';
import type { AttendanceListItem } from '@/types/attendance';
import AttendancePhotoCell from './attendance-photo-cell.vue';
import AttendanceMap from './AttendanceMap.vue';
const open = defineModel<boolean>('open', { required: true });
@ -76,29 +76,20 @@ const { open: deleteConfirmOpen, processing: deleteProcessing, destroy: destroyA
</div>
</dl>
<AttendanceMap
:check-in-lat="attendance.check_in_latitude"
:check-in-lng="attendance.check_in_longitude"
:check-out-lat="attendance.check_out_latitude"
:check-out-lng="attendance.check_out_longitude"
/>
<AttendanceMap :check-in-lat="attendance.check_in_latitude"
:check-in-lng="attendance.check_in_longitude" :check-out-lat="attendance.check_out_latitude"
:check-out-lng="attendance.check_out_longitude" />
<div>
<p class="text-muted-foreground mb-2 text-sm">
Foto
</p>
<AttendancePhotoCell
:check-in-photo-url="attendance.check_in_photo_url"
:check-out-photo-url="attendance.check_out_photo_url"
/>
<AttendancePhotoCell :check-in-photo-url="attendance.check_in_photo_url"
:check-out-photo-url="attendance.check_out_photo_url" />
</div>
<div v-if="can('attendances.delete')" class="flex justify-end border-t pt-4">
<Button
variant="destructive"
size="sm"
@click="deleteConfirmOpen = true"
>
<Button variant="destructive" size="sm" @click="deleteConfirmOpen = true">
<Trash2 class="size-4" />
Hapus
</Button>
@ -107,15 +98,9 @@ const { open: deleteConfirmOpen, processing: deleteProcessing, destroy: destroyA
</DialogContent>
</Dialog>
<ConfirmDialog
v-if="can('attendances.delete') && attendance"
v-model:open="deleteConfirmOpen"
<ConfirmDialog v-if="can('attendances.delete') && attendance" v-model:open="deleteConfirmOpen"
title="Hapus data presensi?"
:description="`Presensi tanggal ${attendance.attendance_date_formatted} akan dihapus secara permanen.`"
confirm-label="Hapus"
cancel-label="Batal"
destructive
:loading="deleteProcessing"
@confirm="destroyAttendance"
/>
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing"
@confirm="destroyAttendance" />
</template>

View File

@ -46,7 +46,7 @@ function createAttendanceEmployeeUser(PermissionEnum ...$permissions): User
'full_name' => fake()->name(),
]);
Employee::factory()->create(['user_id' => $user->id]);
$user->assignRole('marketing');
$user->assignRole('marketing-offline');
$user->givePermissionTo(
array_merge(