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 @@ + + +