From f6891dbe243d4a336829f0793116da3bf57bbe64 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 11 Jun 2026 15:51:06 +0700 Subject: [PATCH] Add leave request management features including LeaveRequest model, LeaveRequestController for CRUD operations, and corresponding permissions in the Permission and Role enums. Update routes for leave requests functionality and enhance UI components for leave requests navigation in the sidebar. --- app/Enums/LeaveRequestStatus.php | 23 +++ app/Enums/Permission.php | 14 ++ app/Enums/Role.php | 16 ++ .../Admin/Hr/LeaveRequestController.php | 89 +++++++++ .../Admin/Hr/RejectLeaveRequestRequest.php | 24 +++ .../Requests/Admin/Hr/SubmitLeaveRequest.php | 46 +++++ app/Models/Employee.php | 5 + app/Models/LeaveRequest.php | 121 ++++++++++++ app/Services/Hr/LeaveRequestService.php | 180 ++++++++++++++++++ ..._11_120000_create_leave_requests_table.php | 33 ++++ resources/js/components/AppSidebar.vue | 13 +- .../leave-requests/LeaveRequestFormModal.vue | 125 ++++++++++++ .../RejectLeaveRequestModal.vue | 93 +++++++++ .../admin/hr/leave-requests/columns.ts | 79 ++++++++ .../hr/leave-requests/data-table-actions.vue | 136 +++++++++++++ .../pages/admin/hr/leave-requests/Index.vue | 116 +++++++++++ resources/js/types/leave-request.ts | 45 +++++ routes/web.php | 27 +++ 18 files changed, 1183 insertions(+), 2 deletions(-) create mode 100644 app/Enums/LeaveRequestStatus.php create mode 100644 app/Http/Controllers/Admin/Hr/LeaveRequestController.php create mode 100644 app/Http/Requests/Admin/Hr/RejectLeaveRequestRequest.php create mode 100644 app/Http/Requests/Admin/Hr/SubmitLeaveRequest.php create mode 100644 app/Models/LeaveRequest.php create mode 100644 app/Services/Hr/LeaveRequestService.php create mode 100644 database/migrations/2026_06_11_120000_create_leave_requests_table.php create mode 100644 resources/js/components/admin/hr/leave-requests/LeaveRequestFormModal.vue create mode 100644 resources/js/components/admin/hr/leave-requests/RejectLeaveRequestModal.vue create mode 100644 resources/js/components/admin/hr/leave-requests/columns.ts create mode 100644 resources/js/components/admin/hr/leave-requests/data-table-actions.vue create mode 100644 resources/js/pages/admin/hr/leave-requests/Index.vue create mode 100644 resources/js/types/leave-request.ts diff --git a/app/Enums/LeaveRequestStatus.php b/app/Enums/LeaveRequestStatus.php new file mode 100644 index 0000000..a17f82e --- /dev/null +++ b/app/Enums/LeaveRequestStatus.php @@ -0,0 +1,23 @@ + 'Menunggu', + self::APPROVED => 'Disetujui', + self::REJECTED => 'Ditolak', + }; + } +} diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index acbfcb3..0205231 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -22,6 +22,12 @@ enum Permission: string case ATTENDANCES_DELETE = 'attendances.delete'; case ATTENDANCES_MANAGE = 'attendances.manage'; + case LEAVE_REQUESTS_VIEW = 'leave-requests.view'; + case LEAVE_REQUESTS_CREATE = 'leave-requests.create'; + case LEAVE_REQUESTS_UPDATE = 'leave-requests.update'; + case LEAVE_REQUESTS_DELETE = 'leave-requests.delete'; + case LEAVE_REQUESTS_VERIFY = 'leave-requests.verify'; + case CATEGORIES_VIEW = 'categories.view'; case CATEGORIES_CREATE = 'categories.create'; case CATEGORIES_UPDATE = 'categories.update'; @@ -88,6 +94,12 @@ public function label(): string self::ATTENDANCES_DELETE => 'Hapus Presensi', self::ATTENDANCES_MANAGE => 'Kelola Semua Presensi', + self::LEAVE_REQUESTS_VIEW => 'Lihat Cuti', + self::LEAVE_REQUESTS_CREATE => 'Ajukan Cuti', + self::LEAVE_REQUESTS_UPDATE => 'Ubah Pengajuan Cuti', + self::LEAVE_REQUESTS_DELETE => 'Hapus Pengajuan Cuti', + self::LEAVE_REQUESTS_VERIFY => 'Setujui/Tolak Cuti', + self::CATEGORIES_VIEW => 'Lihat Kategori', self::CATEGORIES_CREATE => 'Tambah Kategori', self::CATEGORIES_UPDATE => 'Ubah Kategori', @@ -147,6 +159,8 @@ public function group(): string self::EMPLOYEES_DELETE, self::EMPLOYEES_RESET_PASSWORD, self::EMPLOYEES_TOGGLE_STATUS => 'Pegawai', self::ATTENDANCES_VIEW, self::ATTENDANCES_CREATE, self::ATTENDANCES_DELETE, self::ATTENDANCES_MANAGE => 'Presensi', + self::LEAVE_REQUESTS_VIEW, self::LEAVE_REQUESTS_CREATE, self::LEAVE_REQUESTS_UPDATE, + self::LEAVE_REQUESTS_DELETE, self::LEAVE_REQUESTS_VERIFY => 'Cuti', self::CATEGORIES_VIEW, self::CATEGORIES_CREATE, self::CATEGORIES_UPDATE, self::CATEGORIES_DELETE => 'Kategori', self::SUPPLIERS_VIEW, self::SUPPLIERS_CREATE, self::SUPPLIERS_UPDATE, diff --git a/app/Enums/Role.php b/app/Enums/Role.php index b32899d..d99c0a2 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -48,6 +48,10 @@ public function permissions(): array Permission::ATTENDANCES_CREATE, Permission::ATTENDANCES_DELETE, Permission::ATTENDANCES_MANAGE, + Permission::LEAVE_REQUESTS_VIEW, + Permission::LEAVE_REQUESTS_CREATE, + Permission::LEAVE_REQUESTS_UPDATE, + Permission::LEAVE_REQUESTS_DELETE, Permission::CATEGORIES_VIEW, Permission::CATEGORIES_CREATE, Permission::CATEGORIES_UPDATE, @@ -99,6 +103,10 @@ public function permissions(): array Permission::ATTENDANCES_CREATE, Permission::ATTENDANCES_DELETE, Permission::ATTENDANCES_MANAGE, + Permission::LEAVE_REQUESTS_VIEW, + Permission::LEAVE_REQUESTS_CREATE, + Permission::LEAVE_REQUESTS_UPDATE, + Permission::LEAVE_REQUESTS_DELETE, Permission::CATEGORIES_VIEW, Permission::CATEGORIES_CREATE, Permission::CATEGORIES_UPDATE, @@ -139,6 +147,10 @@ public function permissions(): array Permission::EMPLOYEES_VIEW, Permission::ATTENDANCES_VIEW, Permission::ATTENDANCES_CREATE, + Permission::LEAVE_REQUESTS_VIEW, + Permission::LEAVE_REQUESTS_CREATE, + Permission::LEAVE_REQUESTS_UPDATE, + Permission::LEAVE_REQUESTS_DELETE, Permission::CATEGORIES_VIEW, Permission::CATEGORIES_CREATE, Permission::CATEGORIES_UPDATE, @@ -158,6 +170,10 @@ public function permissions(): array Permission::EMPLOYEES_VIEW, Permission::ATTENDANCES_VIEW, Permission::ATTENDANCES_CREATE, + Permission::LEAVE_REQUESTS_VIEW, + Permission::LEAVE_REQUESTS_CREATE, + Permission::LEAVE_REQUESTS_UPDATE, + Permission::LEAVE_REQUESTS_DELETE, Permission::CATEGORIES_VIEW, Permission::CATEGORIES_CREATE, Permission::CATEGORIES_UPDATE, diff --git a/app/Http/Controllers/Admin/Hr/LeaveRequestController.php b/app/Http/Controllers/Admin/Hr/LeaveRequestController.php new file mode 100644 index 0000000..004f2b7 --- /dev/null +++ b/app/Http/Controllers/Admin/Hr/LeaveRequestController.php @@ -0,0 +1,89 @@ +parseDataTableQuery($request); + $user = auth()->user(); + + return Inertia::render('admin/hr/leave-requests/Index', [ + 'leaveRequests' => $this->leaveRequestService->paginateForIndex($tableQuery), + 'authEmployeeId' => $user?->employee?->id, + 'canSubmit' => $user?->can(Permission::LEAVE_REQUESTS_CREATE->value) + && ! $user->can(Permission::LEAVE_REQUESTS_VERIFY->value) + && $user->employee !== null, + 'filters' => $this->dataTableFilters($tableQuery), + ]); + } + + public function store(SubmitLeaveRequest $request): RedirectResponse + { + $this->leaveRequestService->create($request->validated()); + + Inertia::flash('success', 'Pengajuan cuti berhasil diajukan.'); + + return redirect()->route('admin.hr.leave-requests.index'); + } + + public function update(SubmitLeaveRequest $request, LeaveRequest $leaveRequest): RedirectResponse + { + $this->leaveRequestService->update($leaveRequest, $request->validated()); + + Inertia::flash('success', 'Pengajuan cuti berhasil diperbarui.'); + + return redirect()->route('admin.hr.leave-requests.index'); + } + + public function destroy(LeaveRequest $leaveRequest): RedirectResponse + { + abort_unless(! auth()->user()?->can(Permission::LEAVE_REQUESTS_VERIFY->value), 403); + + $this->leaveRequestService->delete($leaveRequest); + + Inertia::flash('success', 'Pengajuan cuti berhasil dihapus.'); + + return redirect()->route('admin.hr.leave-requests.index'); + } + + public function approve(LeaveRequest $leaveRequest): RedirectResponse + { + $this->leaveRequestService->approve($leaveRequest); + + Inertia::flash('success', 'Pengajuan cuti disetujui.'); + + return redirect()->route('admin.hr.leave-requests.index'); + } + + public function reject(RejectLeaveRequestRequest $request, LeaveRequest $leaveRequest): RedirectResponse + { + $this->leaveRequestService->reject( + $leaveRequest, + $request->validated('reason'), + ); + + Inertia::flash('success', 'Pengajuan cuti berhasil ditolak.'); + + return redirect()->route('admin.hr.leave-requests.index'); + } +} diff --git a/app/Http/Requests/Admin/Hr/RejectLeaveRequestRequest.php b/app/Http/Requests/Admin/Hr/RejectLeaveRequestRequest.php new file mode 100644 index 0000000..d5aa78b --- /dev/null +++ b/app/Http/Requests/Admin/Hr/RejectLeaveRequestRequest.php @@ -0,0 +1,24 @@ +user()?->can(Permission::LEAVE_REQUESTS_VERIFY->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'reason' => ['required', 'string', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Hr/SubmitLeaveRequest.php b/app/Http/Requests/Admin/Hr/SubmitLeaveRequest.php new file mode 100644 index 0000000..ea62828 --- /dev/null +++ b/app/Http/Requests/Admin/Hr/SubmitLeaveRequest.php @@ -0,0 +1,46 @@ +user(); + + if ($user === null || $user->can(Permission::LEAVE_REQUESTS_VERIFY->value)) { + return false; + } + + if ($this->isMethod('POST')) { + return $user->can(Permission::LEAVE_REQUESTS_CREATE->value) + && $user->employee !== null; + } + + if (! $user->can(Permission::LEAVE_REQUESTS_UPDATE->value)) { + return false; + } + + /** @var LeaveRequest|null $leaveRequest */ + $leaveRequest = $this->route('leaveRequest'); + + return $leaveRequest?->status === LeaveRequestStatus::PENDING + && $user->employee?->id === $leaveRequest->employee_id; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'start_date' => ['required', 'date'], + 'end_date' => ['required', 'date', 'after_or_equal:start_date'], + ]; + } +} diff --git a/app/Models/Employee.php b/app/Models/Employee.php index a5438ec..49b36bf 100644 --- a/app/Models/Employee.php +++ b/app/Models/Employee.php @@ -107,4 +107,9 @@ public function attendances(): HasMany { return $this->hasMany(Attendance::class); } + + public function leaveRequests(): HasMany + { + return $this->hasMany(LeaveRequest::class); + } } diff --git a/app/Models/LeaveRequest.php b/app/Models/LeaveRequest.php new file mode 100644 index 0000000..9475e43 --- /dev/null +++ b/app/Models/LeaveRequest.php @@ -0,0 +1,121 @@ + 'date', + 'end_date' => 'date', + 'total_days' => 'integer', + 'status' => LeaveRequestStatus::class, + 'verified_at' => 'datetime', + ]; + } + + public function startDateFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->start_date?->translatedFormat('l, d F Y'), + ); + } + + public function endDateFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->end_date?->translatedFormat('l, d F Y'), + ); + } + + public function startDateInput(): Attribute + { + return Attribute::make( + get: fn () => $this->start_date?->format('Y-m-d'), + ); + } + + public function endDateInput(): Attribute + { + return Attribute::make( + get: fn () => $this->end_date?->format('Y-m-d'), + ); + } + + public function statusLabel(): Attribute + { + return Attribute::make( + get: fn () => $this->status?->label(), + ); + } + + public function employeeName(): Attribute + { + return Attribute::make( + get: fn () => $this->employee?->user?->profile?->full_name + ?? $this->employee?->user?->username, + ); + } + + public function rejectionReason(): Attribute + { + return Attribute::make( + get: fn () => $this->rejection?->reason, + ); + } + + public function createdAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function isEditable(): Attribute + { + return Attribute::make( + get: fn () => $this->status === LeaveRequestStatus::PENDING, + ); + } + + public function canVerify(): Attribute + { + return Attribute::make( + get: fn () => $this->status === LeaveRequestStatus::PENDING, + ); + } + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class); + } + + public function verifiedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'verified_by_id'); + } +} diff --git a/app/Services/Hr/LeaveRequestService.php b/app/Services/Hr/LeaveRequestService.php new file mode 100644 index 0000000..8213e1d --- /dev/null +++ b/app/Services/Hr/LeaveRequestService.php @@ -0,0 +1,180 @@ +with(['employee.user.profile', 'rejection']) + ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { + $search = $tableQuery['search']; + $query->where(function (Builder $query) use ($search): void { + $query->whereHas('employee.user.profile', fn (Builder $query) => $query->where('full_name', 'like', "%{$search}%")) + ->orWhereHas('employee.user', fn (Builder $query) => $query->where('username', 'like', "%{$search}%")) + ->orWhereHas('rejection', fn (Builder $query) => $query->where('reason', 'like', "%{$search}%")); + }); + }); + + $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']); + + return $query + ->paginate(10) + ->withQueryString(); + } + + /** + * @param array{start_date: string, end_date: string} $validated + */ + public function create(array $validated): void + { + $employee = auth()->user()?->employee; + + if ($employee === null) { + throw ValidationException::withMessages([ + 'employee' => 'Akun Anda tidak terhubung ke data pegawai.', + ]); + } + + $startDate = Carbon::parse($validated['start_date'])->startOfDay(); + $endDate = Carbon::parse($validated['end_date'])->startOfDay(); + + $this->ensureMinimumLeadTime($startDate); + $this->ensureValidDateRange($startDate, $endDate); + + LeaveRequest::create([ + 'employee_id' => $employee->id, + 'start_date' => $startDate, + 'end_date' => $endDate, + 'total_days' => $this->calculateTotalDays($startDate, $endDate), + 'status' => LeaveRequestStatus::PENDING, + ]); + } + + /** + * @param array{start_date: string, end_date: string} $validated + */ + public function update(LeaveRequest $leaveRequest, array $validated): void + { + $this->ensureOwnedBySubmitter($leaveRequest); + $this->ensurePending($leaveRequest, 'Pengajuan cuti hanya dapat diubah saat status menunggu.'); + + $startDate = Carbon::parse($validated['start_date'])->startOfDay(); + $endDate = Carbon::parse($validated['end_date'])->startOfDay(); + + $this->ensureMinimumLeadTime($startDate); + $this->ensureValidDateRange($startDate, $endDate); + + $leaveRequest->start_date = $startDate; + $leaveRequest->end_date = $endDate; + $leaveRequest->total_days = $this->calculateTotalDays($startDate, $endDate); + $leaveRequest->save(); + } + + public function delete(LeaveRequest $leaveRequest): void + { + $this->ensureOwnedBySubmitter($leaveRequest); + $this->ensurePending($leaveRequest, 'Pengajuan cuti hanya dapat dihapus saat status menunggu.'); + + $leaveRequest->delete(); + } + + public function approve(LeaveRequest $leaveRequest): void + { + $this->ensurePending($leaveRequest, 'Pengajuan cuti ini sudah diverifikasi.'); + + $user = auth()->user(); + + DB::transaction(function () use ($leaveRequest, $user): void { + $leaveRequest->status = LeaveRequestStatus::APPROVED; + $leaveRequest->verified_at = now(); + $leaveRequest->verified_by_id = $user->id; + $leaveRequest->save(); + }); + } + + public function reject(LeaveRequest $leaveRequest, string $reason): void + { + $this->ensurePending($leaveRequest, 'Pengajuan cuti ini sudah diverifikasi.'); + + $user = auth()->user(); + + DB::transaction(function () use ($leaveRequest, $user, $reason): void { + $leaveRequest->status = LeaveRequestStatus::REJECTED; + $leaveRequest->verified_at = now(); + $leaveRequest->verified_by_id = $user->id; + $leaveRequest->save(); + + $leaveRequest->rejection()->create([ + 'reason' => $reason, + 'rejected_by_id' => $user->id, + ]); + }); + } + + private function calculateTotalDays(Carbon $startDate, Carbon $endDate): int + { + return (int) $startDate->diffInDays($endDate) + 1; + } + + private function ensureMinimumLeadTime(Carbon $startDate): void + { + $minimumStartDate = now()->startOfDay()->addDay(); + + if ($startDate->lt($minimumStartDate)) { + throw ValidationException::withMessages([ + 'start_date' => 'Pengajuan cuti harus diajukan minimal H-1 (tanggal mulai paling cepat besok).', + ]); + } + } + + private function ensureValidDateRange(Carbon $startDate, Carbon $endDate): void + { + if ($endDate->lt($startDate)) { + throw ValidationException::withMessages([ + 'end_date' => 'Tanggal selesai tidak boleh sebelum tanggal mulai.', + ]); + } + } + + private function ensureOwnedBySubmitter(LeaveRequest $leaveRequest): void + { + if (auth()->user()?->employee?->id !== $leaveRequest->employee_id) { + throw ValidationException::withMessages([ + 'leave_request' => 'Anda tidak memiliki akses untuk mengubah pengajuan cuti ini.', + ]); + } + } + + private function ensurePending(LeaveRequest $leaveRequest, string $message): void + { + if ($leaveRequest->status !== LeaveRequestStatus::PENDING) { + throw ValidationException::withMessages([ + 'leave_request' => $message, + ]); + } + } + + private function applySorting(Builder $query, string $sort, string $direction): void + { + if (in_array($sort, ['created_at', 'start_date', 'end_date', 'total_days', 'status'], true)) { + $query->orderBy($sort, $direction); + + return; + } + + $query->latest(); + } +} diff --git a/database/migrations/2026_06_11_120000_create_leave_requests_table.php b/database/migrations/2026_06_11_120000_create_leave_requests_table.php new file mode 100644 index 0000000..6029638 --- /dev/null +++ b/database/migrations/2026_06_11_120000_create_leave_requests_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('employee_id')->constrained()->cascadeOnDelete(); + $table->date('start_date'); + $table->date('end_date'); + $table->unsignedInteger('total_days'); + $table->enum('status', array_column(LeaveRequestStatus::cases(), 'value')) + ->default(LeaveRequestStatus::PENDING->value); + $table->timestamp('verified_at')->nullable(); + $table->foreignId('verified_by_id')->nullable()->constrained('users')->restrictOnDelete(); + $table->timestamps(); + + $table->index(['employee_id', 'status']); + $table->index(['start_date', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('leave_requests'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 99da56e..fe6b569 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ + + diff --git a/resources/js/components/admin/hr/leave-requests/RejectLeaveRequestModal.vue b/resources/js/components/admin/hr/leave-requests/RejectLeaveRequestModal.vue new file mode 100644 index 0000000..99d690d --- /dev/null +++ b/resources/js/components/admin/hr/leave-requests/RejectLeaveRequestModal.vue @@ -0,0 +1,93 @@ + + +