feat: implement ensureEditable method in LeaveRequest model for validation; update LeaveRequestController and LeaveRequestService to handle editability checks and transaction management for update and delete operations; enhance LeaveRequestService with new methods for pending requests and index page data

This commit is contained in:
Yoga Pangestu 2026-07-04 00:29:57 +07:00
parent a8ab96b10b
commit e153497114
5 changed files with 87 additions and 95 deletions

View File

@ -12,6 +12,7 @@
use App\Services\Hr\LeaveRequestService; use App\Services\Hr\LeaveRequestService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -45,6 +46,12 @@ public function store(SubmitLeaveRequest $request): RedirectResponse
public function update(SubmitLeaveRequest $request, LeaveRequest $leaveRequest): RedirectResponse public function update(SubmitLeaveRequest $request, LeaveRequest $leaveRequest): RedirectResponse
{ {
try {
$leaveRequest->ensureEditable();
} catch (ValidationException) {
abort(403);
}
$this->leaveRequestService->update($leaveRequest, $request->validated(), $request->user()); $this->leaveRequestService->update($leaveRequest, $request->validated(), $request->user());
$this->flashUpdated('Pengajuan cuti'); $this->flashUpdated('Pengajuan cuti');
@ -54,6 +61,12 @@ public function update(SubmitLeaveRequest $request, LeaveRequest $leaveRequest):
public function destroy(LeaveRequest $leaveRequest): RedirectResponse public function destroy(LeaveRequest $leaveRequest): RedirectResponse
{ {
try {
$leaveRequest->ensureEditable();
} catch (ValidationException) {
abort(403);
}
$this->leaveRequestService->delete($leaveRequest); $this->leaveRequestService->delete($leaveRequest);
$this->flashDeleted('Pengajuan cuti'); $this->flashDeleted('Pengajuan cuti');

View File

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\LeaveRequestStatus; use App\Enums\LeaveRequestStatus;
use App\Enums\Permission;
use App\Models\Concerns\HasRejection; use App\Models\Concerns\HasRejection;
use App\Models\Concerns\InteractsWithActivityLog; use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Appends;
@ -13,6 +14,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Validation\ValidationException;
#[Guarded(['id'])] #[Guarded(['id'])]
#[Appends([ #[Appends([
@ -136,7 +138,24 @@ public function statusLabel(): Attribute
); );
} }
// 5. Relation // 5. Other Methods
public function ensureEditable(): void
{
if ($this->status !== LeaveRequestStatus::PENDING) {
throw ValidationException::withMessages([
'status' => 'Pengajuan cuti tidak dapat diubah.',
]);
}
}
public static function canSubmit(User $user): bool
{
return $user->can(Permission::LEAVE_REQUESTS_CREATE->value)
&& ! $user->can(Permission::LEAVE_REQUESTS_VERIFY->value)
&& $user->employee !== null;
}
// 6. Relation
public function employee(): BelongsTo public function employee(): BelongsTo
{ {
return $this->belongsTo(Employee::class); return $this->belongsTo(Employee::class);

View File

@ -3,21 +3,19 @@
namespace App\Services\Hr; namespace App\Services\Hr;
use App\Enums\LeaveRequestStatus; use App\Enums\LeaveRequestStatus;
use App\Enums\Permission;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\ResolvesAuthenticatedEmployee; use App\Services\Concerns\ResolvesAuthenticatedEmployee;
use App\Services\Concerns\RunsInTransaction;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class LeaveRequestService class LeaveRequestService
{ {
use ResolvesAuthenticatedEmployee; use ResolvesAuthenticatedEmployee, RunsInTransaction;
public function __construct( public function __construct(
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
@ -109,7 +107,12 @@ public function delete(LeaveRequest $leaveRequest): void
$totalDays = $leaveRequest->total_days; $totalDays = $leaveRequest->total_days;
$employeeName = $leaveRequest->employee?->user?->profile?->full_name; $employeeName = $leaveRequest->employee?->user?->profile?->full_name;
$leaveRequest->delete(); $this->runInTransaction(
function () use ($leaveRequest): void {
$leaveRequest->delete();
},
'Gagal menghapus pengajuan cuti',
);
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'🗑️ Pengajuan Cuti Dihapus', '🗑️ Pengajuan Cuti Dihapus',
@ -121,25 +124,16 @@ public function delete(LeaveRequest $leaveRequest): void
public function approve(LeaveRequest $leaveRequest, User $user): void public function approve(LeaveRequest $leaveRequest, User $user): void
{ {
try { $this->runInTransaction(
DB::transaction(function () use ($leaveRequest, $user): void { function () use ($leaveRequest, $user): void {
$leaveRequest->update([ $leaveRequest->update([
'status' => LeaveRequestStatus::APPROVED, 'status' => LeaveRequestStatus::APPROVED,
'verified_at' => Carbon::now(), 'verified_at' => Carbon::now(),
'verified_by_id' => $user->id, 'verified_by_id' => $user->id,
]); ]);
}); },
} catch (ValidationException $e) { 'Gagal menyetujui pengajuan cuti',
throw $e; );
} catch (\Throwable $e) {
Log::error('Gagal menyetujui pengajuan cuti: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$leaveRequest->loadMissing('employee.user'); $leaveRequest->loadMissing('employee.user');
if ($leaveRequest->employee?->user_id) { if ($leaveRequest->employee?->user_id) {
@ -154,8 +148,8 @@ public function approve(LeaveRequest $leaveRequest, User $user): void
public function reject(LeaveRequest $leaveRequest, string $reason, User $user): void public function reject(LeaveRequest $leaveRequest, string $reason, User $user): void
{ {
try { $this->runInTransaction(
DB::transaction(function () use ($leaveRequest, $user, $reason): void { function () use ($leaveRequest, $user, $reason): void {
$leaveRequest->update([ $leaveRequest->update([
'status' => LeaveRequestStatus::REJECTED, 'status' => LeaveRequestStatus::REJECTED,
'verified_at' => Carbon::now(), 'verified_at' => Carbon::now(),
@ -166,18 +160,9 @@ public function reject(LeaveRequest $leaveRequest, string $reason, User $user):
'reason' => $reason, 'reason' => $reason,
'rejected_by_id' => $user->id, 'rejected_by_id' => $user->id,
]); ]);
}); },
} catch (ValidationException $e) { 'Gagal menolak pengajuan cuti',
throw $e; );
} catch (\Throwable $e) {
Log::error('Gagal menolak pengajuan cuti: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$leaveRequest->loadMissing('employee.user'); $leaveRequest->loadMissing('employee.user');
if ($leaveRequest->employee?->user_id) { if ($leaveRequest->employee?->user_id) {
@ -190,6 +175,28 @@ public function reject(LeaveRequest $leaveRequest, string $reason, User $user):
} }
} }
public function hasPendingForEmployee(User $user): bool
{
if ($user->employee === null) {
return false;
}
return LeaveRequest::query()
->pending()
->where('employee_id', $user->employee->id)
->exists();
}
public function indexPageData(array $tableQuery, User $user): array
{
return [
'leaveRequests' => $this->paginateForIndex($tableQuery, $user),
'authEmployeeId' => $user->employee?->id,
'canSubmit' => LeaveRequest::canSubmit($user),
'hasPending' => $this->hasPendingForEmployee($user),
];
}
private function calculateTotalDays(Carbon $startDate, Carbon $endDate): int private function calculateTotalDays(Carbon $startDate, Carbon $endDate): int
{ {
return (int) $startDate->diffInDays($endDate) + 1; return (int) $startDate->diffInDays($endDate) + 1;
@ -216,33 +223,4 @@ private function applySorting(Builder $query, string $sort, string $direction):
$query->latest(); $query->latest();
} }
public function hasPendingForEmployee(User $user): bool
{
if ($user->employee === null) {
return false;
}
return LeaveRequest::query()
->pending()
->where('employee_id', $user->employee->id)
->exists();
}
public function canSubmit(User $user): bool
{
return $user->can(Permission::LEAVE_REQUESTS_CREATE->value)
&& ! $user->can(Permission::LEAVE_REQUESTS_VERIFY->value)
&& $user->employee !== null;
}
public function indexPageData(array $tableQuery, User $user): array
{
return [
'leaveRequests' => $this->paginateForIndex($tableQuery, $user),
'authEmployeeId' => $user->employee?->id,
'canSubmit' => $this->canSubmit($user),
'hasPending' => $this->hasPendingForEmployee($user),
];
}
} }

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -8,17 +10,16 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/hr/leave_requests';
import type { DataTableSort } from '@/types/data-table'; import type { DataTableSort } from '@/types/data-table';
import type { import type {
LeaveRequestListItem, LeaveRequestListItem,
LeaveRequestPageProps, LeaveRequestPageProps,
} from '@/types/leave-request'; } from '@/types/leave-request';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import LeaveRequestFormModal from './form/LeaveRequestFormModal.vue'; import LeaveRequestFormModal from './form/LeaveRequestFormModal.vue';
import RejectLeaveRequestModal from './form/RejectLeaveRequestModal.vue'; import RejectLeaveRequestModal from './form/RejectLeaveRequestModal.vue';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';
import { index } from '@/routes/admin/hr/leave_requests';
const props = defineProps<LeaveRequestPageProps>(); const props = defineProps<LeaveRequestPageProps>();
@ -87,48 +88,29 @@ watch(
</script> </script>
<template> <template>
<Head title="Cuti" /> <Head title="Cuti" />
<AdminLayout> <AdminLayout>
<div <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="space-y-1"> <div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">Cuti</h2> <h2 class="text-2xl font-bold tracking-tight">Cuti</h2>
</div> </div>
<CreateButton <CreateButton v-if="canSubmit && !hasPending" @click="openCreateModal" label="Ajukan" />
v-if="canSubmit && !hasPending"
@click="openCreateModal"
label="Ajukan"
/>
</div> </div>
<Card class="min-w-0"> <Card class="min-w-0">
<CardContent class="min-w-0 pt-6"> <CardContent class="min-w-0 pt-6">
<DataTable <DataTable v-model:search="search" :columns="columns" :data="leaveRequests.data"
v-model:search="search" :pagination="pagination" :pagination-links="leaveRequests.links" :sort="currentSort"
:columns="columns" @sort-change="setSort" @filters-reset="resetFilters" />
:data="leaveRequests.data"
:pagination="pagination"
:pagination-links="leaveRequests.links"
:sort="currentSort"
@sort-change="setSort"
@filters-reset="resetFilters"
/>
</CardContent> </CardContent>
</Card> </Card>
<LeaveRequestFormModal <LeaveRequestFormModal v-if="canSubmit" v-model:open="formModalOpen" :leave-request="editingLeaveRequest" />
v-if="canSubmit"
v-model:open="formModalOpen"
:leave-request="editingLeaveRequest"
/>
<RejectLeaveRequestModal <RejectLeaveRequestModal v-if="can('leave_requests.verify')" v-model:open="rejectModalOpen"
v-if="can('leave_requests.verify')" :leave-request="rejectingLeaveRequest" />
v-model:open="rejectModalOpen"
:leave-request="rejectingLeaveRequest"
/>
</AdminLayout> </AdminLayout>
</template> </template>

View File

@ -44,7 +44,7 @@ function createLeaveEmployeeUser(): User
'full_name' => fake()->name(), 'full_name' => fake()->name(),
]); ]);
Employee::factory()->create(['user_id' => $user->id]); Employee::factory()->create(['user_id' => $user->id]);
$user->assignRole('marketing'); $user->assignRole('marketing-offline');
$user->forgetCachedPermissions(); $user->forgetCachedPermissions();
return $user; return $user;