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.

This commit is contained in:
Yoga Pangestu 2026-06-11 15:51:06 +07:00
parent 34c59c24f5
commit f6891dbe24
18 changed files with 1183 additions and 2 deletions

View File

@ -0,0 +1,23 @@
<?php
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum LeaveRequestStatus: string
{
use ProvidesEnumOptions;
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
public function label(): string
{
return match ($this) {
self::PENDING => 'Menunggu',
self::APPROVED => 'Disetujui',
self::REJECTED => 'Ditolak',
};
}
}

View File

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

View File

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

View File

@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Admin\Hr;
use App\Enums\Permission;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Hr\RejectLeaveRequestRequest;
use App\Http\Requests\Admin\Hr\SubmitLeaveRequest;
use App\Models\LeaveRequest;
use App\Services\Hr\LeaveRequestService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class LeaveRequestController extends Controller
{
use ParsesDataTableQuery;
public function __construct(
private readonly LeaveRequestService $leaveRequestService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->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');
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Admin\Hr;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class RejectLeaveRequestRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::LEAVE_REQUESTS_VERIFY->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'reason' => ['required', 'string', 'max:500'],
];
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Admin\Hr;
use App\Enums\LeaveRequestStatus;
use App\Enums\Permission;
use App\Models\LeaveRequest;
use Illuminate\Foundation\Http\FormRequest;
class SubmitLeaveRequest extends FormRequest
{
public function authorize(): bool
{
$user = $this->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<string, mixed>
*/
public function rules(): array
{
return [
'start_date' => ['required', 'date'],
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
];
}
}

View File

@ -107,4 +107,9 @@ public function attendances(): HasMany
{
return $this->hasMany(Attendance::class);
}
public function leaveRequests(): HasMany
{
return $this->hasMany(LeaveRequest::class);
}
}

121
app/Models/LeaveRequest.php Normal file
View File

@ -0,0 +1,121 @@
<?php
namespace App\Models;
use App\Enums\LeaveRequestStatus;
use App\Models\Concerns\HasRejection;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Guarded(['id'])]
#[Appends([
'start_date_formatted',
'end_date_formatted',
'start_date_input',
'end_date_input',
'status_label',
'employee_name',
'rejection_reason',
'created_at_formatted',
'is_editable',
'can_verify',
])]
class LeaveRequest extends Model
{
use HasRejection;
protected function casts(): array
{
return [
'start_date' => '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');
}
}

View File

@ -0,0 +1,180 @@
<?php
namespace App\Services\Hr;
use App\Enums\LeaveRequestStatus;
use App\Models\LeaveRequest;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class LeaveRequestService
{
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
{
$query = LeaveRequest::query()
->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();
}
}

View File

@ -0,0 +1,33 @@
<?php
use App\Enums\LeaveRequestStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('leave_requests', function (Blueprint $table) {
$table->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');
}
};

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Banknote, Clock, FolderTree, Layers, LayoutDashboard, Package, Receipt, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
import { Banknote, CalendarDays, Clock, FolderTree, Layers, LayoutDashboard, Package, Receipt, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
import { computed } from 'vue';
import {
Sidebar,
@ -22,7 +22,8 @@ const { can } = useCan();
const isDashboardActive = computed(() => page.url.startsWith('/admin/dashboard'));
const isEmployeesActive = computed(() => page.url.startsWith('/admin/hr/employees'));
const isAttendancesActive = computed(() => page.url.startsWith('/admin/hr/attendances'));
const showHrMenu = computed(() => can('employees.view') || can('attendances.view'));
const isLeaveRequestsActive = computed(() => page.url.startsWith('/admin/hr/leave-requests'));
const showHrMenu = computed(() => can('employees.view') || can('attendances.view') || can('leave-requests.view'));
const isCategoriesActive = computed(() => page.url.startsWith('/admin/master/categories'));
const isProductsActive = computed(() => page.url.startsWith('/admin/master/products'));
const isRawMaterialsActive = computed(() => page.url.startsWith('/admin/master/raw-materials'));
@ -178,6 +179,14 @@ const showMasterMenu = computed(() => (
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="can('leave-requests.view')">
<SidebarMenuButton as-child tooltip="Cuti" :is-active="isLeaveRequestsActive">
<Link href="/admin/hr/leave-requests">
<CalendarDays />
<span>Cuti</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View File

@ -0,0 +1,125 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { computed, watch } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import type { LeaveRequestFormData, LeaveRequestListItem } from '@/types/leave-request';
const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{
leaveRequest?: LeaveRequestListItem | null;
}>();
const isEditing = computed(() => props.leaveRequest != null);
const form = useForm<LeaveRequestFormData>({
start_date: '',
end_date: '',
});
function resetForm() {
form.reset();
form.clearErrors();
}
function populateForm(leaveRequest: LeaveRequestListItem | null | undefined) {
resetForm();
if (!leaveRequest) {
return;
}
form.start_date = leaveRequest.start_date_input;
form.end_date = leaveRequest.end_date_input;
}
watch(
() => props.leaveRequest,
(leaveRequest) => {
populateForm(leaveRequest);
},
);
watch(open, (isOpen) => {
if (isOpen) {
populateForm(props.leaveRequest);
} else {
resetForm();
}
});
function submit() {
const options = {
preserveScroll: true,
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
},
};
if (isEditing.value && props.leaveRequest) {
form.put(`/admin/hr/leave-requests/${props.leaveRequest.id}`, options);
} else {
form.post('/admin/hr/leave-requests', options);
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ isEditing ? 'Ubah Pengajuan Cuti' : 'Ajukan Cuti' }}</DialogTitle>
</DialogHeader>
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<p class="text-sm text-muted-foreground">
Pengajuan cuti harus diajukan minimal H-1 (tanggal mulai paling cepat besok).
</p>
<Field>
<FieldLabel for="leave-start-date" required>Tanggal Mulai</FieldLabel>
<DatePicker id="leave-start-date" v-model="form.start_date" autofocus />
<FieldError :errors="form.errors.start_date ? [form.errors.start_date] : []" />
</Field>
<Field>
<FieldLabel for="leave-end-date" required>Tanggal Selesai</FieldLabel>
<DatePicker id="leave-end-date" v-model="form.end_date" />
<FieldError :errors="form.errors.end_date ? [form.errors.end_date] : []" />
</Field>
</FieldSet>
</FieldGroup>
<DialogFooter class="mt-6">
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
Batal
</Button>
<Button type="submit" :disabled="form.processing">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : 'Ajukan' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { watch } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Textarea } from '@/components/ui/textarea';
import type { LeaveRequestListItem } from '@/types/leave-request';
const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{
leaveRequest?: LeaveRequestListItem | null;
}>();
const form = useForm({
reason: '',
});
function resetForm() {
form.reset();
form.clearErrors();
}
watch(open, (isOpen) => {
if (!isOpen) {
resetForm();
}
});
function submit() {
if (!props.leaveRequest) {
return;
}
form.post(`/admin/hr/leave-requests/${props.leaveRequest.id}/reject`, {
preserveScroll: true,
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menolak pengajuan cuti. Periksa kembali formulir.');
},
});
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Tolak Pengajuan Cuti</DialogTitle>
</DialogHeader>
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="reject-reason" required>Alasan Penolakan</FieldLabel>
<Textarea id="reject-reason" v-model="form.reason" placeholder="Tuliskan alasan penolakan"
rows="3" autofocus />
<FieldError :errors="form.errors.reason ? [form.errors.reason] : []" />
</Field>
</FieldSet>
</FieldGroup>
<DialogFooter class="mt-6">
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
Batal
</Button>
<Button type="submit" variant="destructive" :disabled="form.processing">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : 'Tolak' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,79 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import DataTableActions from '@/components/admin/hr/leave-requests/data-table-actions.vue';
import { DataTableColumnHeader } from '@/components/data-table';
import { Badge } from '@/components/ui/badge';
import type { LeaveRequestListItem } from '@/types/leave-request';
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'approved':
return 'default';
case 'rejected':
return 'destructive';
default:
return 'outline';
}
}
export function createColumns(
authEmployeeId: number | null,
onEdit: (leaveRequest: LeaveRequestListItem) => void,
onReject: (leaveRequest: LeaveRequestListItem) => void,
): ColumnDef<LeaveRequestListItem>[] {
return [
{
accessorKey: 'created_at_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Tanggal Pengajuan', column: 'created_at' }),
},
{
accessorKey: 'employee_name',
enableSorting: false,
header: () => 'Pegawai',
},
{
accessorKey: 'start_date_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Mulai', column: 'start_date' }),
},
{
accessorKey: 'end_date_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Selesai', column: 'end_date' }),
},
{
accessorKey: 'total_days',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Hari', column: 'total_days' }),
cell: ({ row }) => `${row.original.total_days} hari`,
},
{
accessorKey: 'status_label',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
cell: ({ row }) => h(
Badge,
{ variant: statusVariant(row.original.status) },
() => row.original.status_label,
),
},
{
accessorKey: 'rejection_reason',
enableSorting: false,
header: () => 'Alasan Penolakan',
cell: ({ row }) => row.original.rejection_reason ?? '-',
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
cell: ({ row }) => h(DataTableActions, {
leaveRequest: row.original,
authEmployeeId,
onEdit: () => onEdit(row.original),
onReject: () => onReject(row.original),
}),
},
];
}

View File

@ -0,0 +1,136 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { Check, Pencil, Trash2, X } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useCan } from '@/composables/useCan';
import type { LeaveRequestListItem } from '@/types/leave-request';
const props = defineProps<{
leaveRequest: LeaveRequestListItem;
authEmployeeId: number | null;
}>();
const emit = defineEmits<{
edit: [leaveRequest: LeaveRequestListItem];
reject: [leaveRequest: LeaveRequestListItem];
}>();
const { can } = useCan();
const isOwner = computed(() => (
props.authEmployeeId !== null
&& props.leaveRequest.employee_id === props.authEmployeeId
));
const canEdit = computed(() => (
props.leaveRequest.is_editable
&& !can('leave-requests.verify')
&& isOwner.value
&& can('leave-requests.update')
));
const canDelete = computed(() => (
props.leaveRequest.is_editable
&& !can('leave-requests.verify')
&& isOwner.value
&& can('leave-requests.delete')
));
const deleteConfirmOpen = ref(false);
const approveConfirmOpen = ref(false);
const deleteProcessing = ref(false);
const approveProcessing = ref(false);
function destroyLeaveRequest() {
deleteProcessing.value = true;
router.delete(`/admin/hr/leave-requests/${props.leaveRequest.id}`, {
preserveScroll: true,
onSuccess: () => {
deleteConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menghapus pengajuan cuti.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
function approveLeaveRequest() {
approveProcessing.value = true;
router.post(`/admin/hr/leave-requests/${props.leaveRequest.id}/approve`, {}, {
preserveScroll: true,
onSuccess: () => {
approveConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menyetujui pengajuan cuti.');
},
onFinish: () => {
approveProcessing.value = false;
},
});
}
</script>
<template>
<div class="flex items-center justify-end gap-1">
<Tooltip v-if="canEdit">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" @click="emit('edit', leaveRequest)">
<Pencil class="size-4" />
<span class="sr-only">Ubah</span>
</Button>
</TooltipTrigger>
<TooltipContent>Ubah</TooltipContent>
</Tooltip>
<Tooltip v-if="leaveRequest.can_verify && can('leave-requests.verify')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8 text-green-600 hover:text-green-600"
@click="approveConfirmOpen = true">
<Check class="size-4" />
<span class="sr-only">Setujui</span>
</Button>
</TooltipTrigger>
<TooltipContent>Setujui</TooltipContent>
</Tooltip>
<Tooltip v-if="leaveRequest.can_verify && can('leave-requests.verify')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
@click="emit('reject', leaveRequest)">
<X class="size-4" />
<span class="sr-only">Tolak</span>
</Button>
</TooltipTrigger>
<TooltipContent>Tolak</TooltipContent>
</Tooltip>
<Tooltip v-if="canDelete">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
@click="deleteConfirmOpen = true">
<Trash2 class="size-4" />
<span class="sr-only">Hapus</span>
</Button>
</TooltipTrigger>
<TooltipContent>Hapus</TooltipContent>
</Tooltip>
</div>
<ConfirmDialog v-if="can('leave-requests.verify')" v-model:open="approveConfirmOpen" title="Setujui cuti?"
:description="`Cuti ${leaveRequest.employee_name} (${leaveRequest.start_date_formatted} - ${leaveRequest.end_date_formatted}, ${leaveRequest.total_days} hari) akan disetujui.`"
confirm-label="Setujui" cancel-label="Batal" :loading="approveProcessing" @confirm="approveLeaveRequest" />
<ConfirmDialog v-if="canDelete" v-model:open="deleteConfirmOpen" title="Hapus pengajuan cuti?"
:description="`Pengajuan cuti ${leaveRequest.employee_name} akan dihapus.`" confirm-label="Hapus"
cancel-label="Batal" destructive :loading="deleteProcessing" @confirm="destroyLeaveRequest" />
</template>

View File

@ -0,0 +1,116 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { createColumns } from '@/components/admin/hr/leave-requests/columns';
import LeaveRequestFormModal from '@/components/admin/hr/leave-requests/LeaveRequestFormModal.vue';
import RejectLeaveRequestModal from '@/components/admin/hr/leave-requests/RejectLeaveRequestModal.vue';
import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableSort } from '@/types/data-table';
import type { LeaveRequestListItem, LeaveRequestPageProps } from '@/types/leave-request';
const props = defineProps<LeaveRequestPageProps>();
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const formModalOpen = ref(false);
const rejectModalOpen = ref(false);
const editingLeaveRequest = ref<LeaveRequestListItem | null>(null);
const rejectingLeaveRequest = ref<LeaveRequestListItem | null>(null);
const { query, setSearch, setSort, resetFilters, syncFromServer } = useDataTableQuery({
url: '/admin/hr/leave-requests',
initial: { ...props.filters },
});
useDataTableQuerySync(() => props.filters, syncFromServer);
const columns = computed(() => createColumns(
props.authEmployeeId,
openEditModal,
openRejectModal,
));
const currentSort = computed<DataTableSort | null>(() => {
if (!query.value.sort || !query.value.direction) {
return null;
}
return {
column: query.value.sort,
direction: query.value.direction,
};
});
const pagination = computed(() => ({
currentPage: props.leaveRequests.current_page,
perPage: props.leaveRequests.per_page,
lastPage: props.leaveRequests.last_page,
total: props.leaveRequests.total,
}));
function openCreateModal() {
editingLeaveRequest.value = null;
formModalOpen.value = true;
}
function openEditModal(leaveRequest: LeaveRequestListItem) {
editingLeaveRequest.value = leaveRequest;
formModalOpen.value = true;
}
function openRejectModal(leaveRequest: LeaveRequestListItem) {
rejectingLeaveRequest.value = leaveRequest;
rejectModalOpen.value = true;
}
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Cuti" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Cuti
</h2>
</div>
<Button v-if="canSubmit" class="shrink-0 self-start sm:self-center" @click="openCreateModal">
<Plus class="size-4" />
Ajukan
</Button>
</div>
<Card class="min-w-0">
<CardContent class="min-w-0 pt-6">
<DataTable v-model:search="search" :columns="columns" :data="leaveRequests.data"
:pagination="pagination" :pagination-links="leaveRequests.links" :sort="currentSort"
@sort-change="setSort" @filters-reset="resetFilters" />
</CardContent>
</Card>
<LeaveRequestFormModal v-if="canSubmit" v-model:open="formModalOpen"
:leave-request="editingLeaveRequest" />
<RejectLeaveRequestModal v-if="can('leave-requests.verify')" v-model:open="rejectModalOpen"
:leave-request="rejectingLeaveRequest" />
</AdminLayout>
</template>

View File

@ -0,0 +1,45 @@
export type LeaveRequestListItem = {
id: number;
employee_id: number;
start_date_input: string;
end_date_input: string;
start_date_formatted: string;
end_date_formatted: string;
total_days: number;
status: string;
status_label: string;
rejection_reason: string | null;
employee_name: string;
created_at_formatted: string;
is_editable: boolean;
can_verify: boolean;
};
export type LeaveRequestFormData = {
start_date: string;
end_date: string;
};
export type PaginatedLeaveRequests = {
data: LeaveRequestListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};
export type LeaveRequestPageProps = {
leaveRequests: PaginatedLeaveRequests;
authEmployeeId: number | null;
canSubmit: boolean;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
};
};

View File

@ -9,6 +9,7 @@
use App\Http\Controllers\Admin\Finance\PayrollPeriodController;
use App\Http\Controllers\Admin\Hr\AttendanceController;
use App\Http\Controllers\Admin\Hr\EmployeeController;
use App\Http\Controllers\Admin\Hr\LeaveRequestController;
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController;
use App\Http\Controllers\Admin\Master\ProductController;
@ -301,6 +302,32 @@
Route::get('/', [EmployeeController::class, 'index'])->name('index');
});
Route::prefix('leave-requests')->name('leave-requests.')
->middleware('permission:'.Permission::LEAVE_REQUESTS_VIEW->value)
->group(function () {
Route::get('/', [LeaveRequestController::class, 'index'])->name('index');
Route::post('/', [LeaveRequestController::class, 'store'])
->middleware('permission:'.Permission::LEAVE_REQUESTS_CREATE->value)
->name('store');
Route::put('{leaveRequest}', [LeaveRequestController::class, 'update'])
->middleware('permission:'.Permission::LEAVE_REQUESTS_UPDATE->value)
->name('update');
Route::delete('{leaveRequest}', [LeaveRequestController::class, 'destroy'])
->middleware('permission:'.Permission::LEAVE_REQUESTS_DELETE->value)
->name('destroy');
Route::post('{leaveRequest}/approve', [LeaveRequestController::class, 'approve'])
->middleware('permission:'.Permission::LEAVE_REQUESTS_VERIFY->value)
->name('approve');
Route::post('{leaveRequest}/reject', [LeaveRequestController::class, 'reject'])
->middleware('permission:'.Permission::LEAVE_REQUESTS_VERIFY->value)
->name('reject');
});
});
});
});