diff --git a/app/Http/Controllers/Admin/SDM/LeaveRequestController.php b/app/Http/Controllers/Admin/SDM/LeaveRequestController.php new file mode 100644 index 0000000..6a79839 --- /dev/null +++ b/app/Http/Controllers/Admin/SDM/LeaveRequestController.php @@ -0,0 +1,97 @@ + $this->service->getAll(), + ]); + } + + public function store(LeaveRequestRequest $request): RedirectResponse + { + try { + $this->service->create($request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Permohonan cuti berhasil ditambahkan.']); + } catch (ValidationException $e) { + $firstError = collect($e->errors())->flatten()->first(); + Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']); + } catch (\Exception $e) { + Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]); + } + + return to_route('admin.sdm.leave-requests.index'); + } + + public function update(LeaveRequestRequest $request, LeaveRequest $leaveRequest): RedirectResponse + { + try { + $this->service->update($leaveRequest, $request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Permohonan cuti berhasil diperbarui.']); + } catch (ValidationException $e) { + $firstError = collect($e->errors())->flatten()->first(); + Inertia::flash('toast', ['type' => 'error', 'message' => $firstError ?? 'Terjadi kesalahan.']); + } catch (\Exception $e) { + Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]); + } + + return to_route('admin.sdm.leave-requests.index'); + } + + public function destroy(LeaveRequest $leaveRequest): RedirectResponse + { + try { + $this->service->delete($leaveRequest); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Permohonan cuti berhasil dihapus.']); + } catch (\Exception $e) { + Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]); + } + + return to_route('admin.sdm.leave-requests.index'); + } + + public function approve(LeaveRequest $leaveRequest): RedirectResponse + { + try { + $this->service->approve($leaveRequest); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Permohonan cuti berhasil disetujui.']); + } catch (\Exception $e) { + Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]); + } + + return to_route('admin.sdm.leave-requests.index'); + } + + public function reject(LeaveRequest $leaveRequest): RedirectResponse + { + try { + $this->service->reject($leaveRequest); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Permohonan cuti berhasil ditolak.']); + } catch (\Exception $e) { + Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]); + } + + return to_route('admin.sdm.leave-requests.index'); + } +} diff --git a/app/Http/Requests/Admin/SDM/LeaveRequestRequest.php b/app/Http/Requests/Admin/SDM/LeaveRequestRequest.php new file mode 100644 index 0000000..556f7ce --- /dev/null +++ b/app/Http/Requests/Admin/SDM/LeaveRequestRequest.php @@ -0,0 +1,29 @@ + ['required', 'date', 'after_or_equal:today'], + 'end_date' => ['required', 'date', 'after_or_equal:start_date'], + ]; + } + + public function attributes(): array + { + return [ + 'start_date' => 'tanggal mulai', + 'end_date' => 'tanggal selesai', + ]; + } +} diff --git a/app/Services/Admin/Finance/EmployeeAdvanceService.php b/app/Services/Admin/Finance/EmployeeAdvanceService.php index 3c1e44b..e8d079e 100644 --- a/app/Services/Admin/Finance/EmployeeAdvanceService.php +++ b/app/Services/Admin/Finance/EmployeeAdvanceService.php @@ -26,7 +26,7 @@ public function create(array $data): EmployeeAdvance $cashAccount = CashAccount::firstOrFail(); $employee = auth()->user()->employee; - if (!$employee) { + if (! $employee) { throw new \Exception('Anda tidak terdaftar sebagai karyawan.'); } @@ -45,7 +45,7 @@ public function create(array $data): EmployeeAdvance 'amount' => $data['amount'], 'balance_after' => $newBalance, 'type' => CashTransactionType::EXPENSE, - 'description' => 'Kasbon: ' . $data['description'], + 'description' => 'Kasbon: '.$data['description'], ]); return EmployeeAdvance::create([ @@ -81,7 +81,7 @@ public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeA $cashTransaction->update([ 'amount' => $newAmount, 'balance_after' => $newBalance, - 'description' => 'Kasbon: ' . $data['description'], + 'description' => 'Kasbon: '.$data['description'], ]); $employeeAdvance->update([ @@ -133,7 +133,7 @@ public function pay(EmployeeAdvance $employeeAdvance): EmployeeAdvance 'amount' => $employeeAdvance->amount, 'balance_after' => $newBalance, 'type' => CashTransactionType::DEPOSIT, - 'description' => 'Pembayaran kasbon: ' . $employeeAdvance->description, + 'description' => 'Pembayaran kasbon: '.$employeeAdvance->description, ]); $employeeAdvance->update([ diff --git a/app/Services/Admin/SDM/LeaveRequestService.php b/app/Services/Admin/SDM/LeaveRequestService.php new file mode 100644 index 0000000..047f188 --- /dev/null +++ b/app/Services/Admin/SDM/LeaveRequestService.php @@ -0,0 +1,86 @@ +latest() + ->get(); + } + + public function create(array $data): LeaveRequest + { + return DB::transaction(function () use ($data) { + $employee = auth()->user()->employee; + + if (! $employee) { + throw new \Exception('Anda tidak terdaftar sebagai karyawan.'); + } + + $startDate = new Carbon($data['start_date']); + $endDate = new Carbon($data['end_date']); + $totalDays = $startDate->diffInDays($endDate) + 1; + + return LeaveRequest::create([ + 'employee_id' => $employee->id, + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'total_days' => $totalDays, + 'status' => LeaveRequestStatus::PENDING, + ]); + }); + } + + public function update(LeaveRequest $leaveRequest, array $data): LeaveRequest + { + return DB::transaction(function () use ($leaveRequest, $data) { + $startDate = new Carbon($data['start_date']); + $endDate = new Carbon($data['end_date']); + $totalDays = $startDate->diffInDays($endDate) + 1; + + $leaveRequest->update([ + 'start_date' => $data['start_date'], + 'end_date' => $data['end_date'], + 'total_days' => $totalDays, + ]); + + return $leaveRequest; + }); + } + + public function delete(LeaveRequest $leaveRequest): bool + { + return $leaveRequest->delete(); + } + + public function approve(LeaveRequest $leaveRequest): LeaveRequest + { + $leaveRequest->update([ + 'status' => LeaveRequestStatus::APPROVED, + 'verified_by_id' => auth()->id(), + 'verified_at' => now(), + ]); + + return $leaveRequest; + } + + public function reject(LeaveRequest $leaveRequest): LeaveRequest + { + $leaveRequest->update([ + 'status' => LeaveRequestStatus::REJECTED, + 'verified_by_id' => auth()->id(), + 'verified_at' => now(), + ]); + + return $leaveRequest; + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 9e8b9e3..e23a5b2 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -41,6 +41,7 @@ import { index as suppliersIndex } from '@/routes/admin/master/suppliers'; import { index as cashAccountsIndex } from '@/routes/admin/finance/cash-accounts'; import { index as expensesIndex } from '@/routes/admin/finance/expenses'; import { index as employeeAdvancesIndex } from '@/routes/admin/finance/employee-advances'; +import { index as leaveRequestsIndex } from '@/routes/admin/sdm/leave-requests'; type NavMenuItem = { title: string; href: string; icon: LucideIcon }; @@ -81,7 +82,7 @@ const keuanganItems: NavMenuItem[] = [ const sdmItems: NavMenuItem[] = [ { title: 'Pegawai', href: '#', icon: UserCircle }, { title: 'Presensi', href: '#', icon: CalendarCheck }, - { title: 'Cuti', href: '#', icon: CalendarDays }, + { title: 'Cuti', href: leaveRequestsIndex.url(), icon: CalendarDays }, ]; const sistemItems: NavMenuItem[] = [ diff --git a/resources/js/pages/admin/sdm/leave-request/columns.tsx b/resources/js/pages/admin/sdm/leave-request/columns.tsx new file mode 100644 index 0000000..2264ad6 --- /dev/null +++ b/resources/js/pages/admin/sdm/leave-request/columns.tsx @@ -0,0 +1,261 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import type { ColumnDef } from '@tanstack/react-table'; +import { ArrowUpDown, CheckCircle, Pencil, Trash2, XCircle } from 'lucide-react'; + +export type LeaveRequest = { + id: number; + start_date: string; + end_date: string; + total_days: number; + status: 'pending' | 'approved' | 'rejected' | 'cancelled'; + created_at: string; + employee: { + user: { + user_profile: { + full_name: string; + }; + }; + }; +}; + +function formatShortDate(dateString: string): string { + const date = new Date(dateString); + + return date.toLocaleDateString('id-ID', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); +} + +function getStatusBadge(status: string) { + const statusConfig: Record = { + pending: { + label: 'Menunggu', + className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100', + }, + approved: { + label: 'Disetujui', + className: 'bg-green-100 text-green-800 hover:bg-green-100', + }, + rejected: { + label: 'Ditolak', + className: 'bg-red-100 text-red-800 hover:bg-red-100', + }, + cancelled: { + label: 'Dibatalkan', + className: 'bg-gray-100 text-gray-800 hover:bg-gray-100', + }, + }; + + const config = statusConfig[status] ?? statusConfig.pending; + + return ( + + {config.label} + + ); +} + +type CreateColumnsParams = { + handleEdit: (leaveRequest: LeaveRequest) => void; + handleDeleteClick: (leaveRequest: LeaveRequest) => void; + handleApprove: (leaveRequest: LeaveRequest) => void; + handleReject: (leaveRequest: LeaveRequest) => void; +}; + +export function createLeaveRequestColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { handleEdit, handleDeleteClick, handleApprove, handleReject } = params; + + return [ + { + id: 'no', + header: () => No, + cell: ({ row }) => ( + + {row.index + 1} + + ), + meta: { + className: 'w-[50px] text-center', + headerClassName: 'w-[50px] text-center', + }, + }, + { + id: 'employee_name', + header: () => Oleh, + cell: ({ row }) => { + const employee = row.original.employee; + + return {employee?.user?.user_profile?.full_name ?? '-'}; + }, + }, + { + accessorKey: 'start_date', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {formatShortDate(row.getValue('start_date') as string)} + ), + }, + { + accessorKey: 'end_date', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {formatShortDate(row.getValue('end_date') as string)} + ), + }, + { + accessorKey: 'total_days', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.getValue('total_days') as number} hari + + ), + }, + { + accessorKey: 'status', + header: () => Status, + cell: ({ row }) => ( + {getStatusBadge(row.getValue('status') as string)} + ), + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[150px] text-center', + headerClassName: 'w-[150px] text-center', + }, + cell: ({ row }) => { + const leaveRequest = row.original; + + return ( + +
+ {leaveRequest.status === 'pending' && ( + <> + + + + + + Setujui + + + + + + + + + Tolak + + + + )} + + + + + + + Edit + + + + + + + + + Hapus + + +
+
+ ); + }, + }, + ]; +} diff --git a/resources/js/pages/admin/sdm/leave-request/index.tsx b/resources/js/pages/admin/sdm/leave-request/index.tsx new file mode 100644 index 0000000..14eb066 --- /dev/null +++ b/resources/js/pages/admin/sdm/leave-request/index.tsx @@ -0,0 +1,309 @@ +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { DataTable } from '@/components/data-table'; +import { DatePicker } from '@/components/date-picker'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { approve, destroy, index as leaveRequestIndex, reject, store, update } from '@/routes/admin/sdm/leave-requests'; +import { Form, Head, router } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import type { LeaveRequest } from './columns'; +import { createLeaveRequestColumns } from './columns'; + +type Props = { + leaveRequests: LeaveRequest[]; +}; + +export default function LeaveRequestIndex({ leaveRequests }: Props) { + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + const [approving, setApproving] = useState(null); + const [rejecting, setRejecting] = useState(null); + const [startDate, setStartDate] = useState(undefined); + const [endDate, setEndDate] = useState(undefined); + const [editingStartDate, setEditingStartDate] = useState(undefined); + const [editingEndDate, setEditingEndDate] = useState(undefined); + + useEffect(() => { + if (editing) { + setEditingStartDate(new Date(editing.start_date)); + setEditingEndDate(new Date(editing.end_date)); + } else { + setEditingStartDate(undefined); + setEditingEndDate(undefined); + } + }, [editing]); + + function handleDelete() { + if (!deleting) { + return; + } + + router.delete(destroy(deleting.id), { + onSuccess: () => setDeleting(null), + }); + } + + function handleApprove() { + if (!approving) { + return; + } + + router.post(approve(approving.id), {}, { + onSuccess: () => setApproving(null), + }); + } + + function handleReject() { + if (!rejecting) { + return; + } + + router.post(reject(rejecting.id), {}, { + onSuccess: () => setRejecting(null), + }); + } + + const columns = createLeaveRequestColumns({ + handleEdit: (leaveRequest) => setEditing(leaveRequest), + handleDeleteClick: (leaveRequest) => setDeleting(leaveRequest), + handleApprove: (leaveRequest) => setApproving(leaveRequest), + handleReject: (leaveRequest) => setRejecting(leaveRequest), + }); + + return ( + <> + + +
+
+
+

+ Cuti +

+
+ { + setCreateOpen(open); + if (!open) { + setStartDate(undefined); + setEndDate(undefined); + } + }}> + + + +
setCreateOpen(false)}> + {({ errors, processing }) => { + + return ( + <> + + Tambah Permohonan Cuti + +
+
+ + + + +
+
+ + + + +
+
+ + + + + + ); + }} +
+
+
+
+ + + + { + if (!open) { + setEditing(null); + setEditingStartDate(undefined); + setEditingEndDate(undefined); + } + }} + > + + {editing && ( +
{ + setEditing(null); + setEditingStartDate(undefined); + setEditingEndDate(undefined); + }}> + {({ errors, processing }) => { + + return ( + <> + + Edit Permohonan Cuti + +
+
+ + + + +
+
+ + + + +
+
+ + + + + + ); + }} +
+ )} +
+
+ + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Permohonan Cuti" + description={`Apakah Anda yakin ingin menghapus permohonan cuti ini? Tindakan ini tidak dapat dibatalkan.`} + confirmLabel="Hapus" + onConfirm={handleDelete} + /> + + { + if (!open) { + setApproving(null); + } + }} + title="Setujui Permohonan Cuti" + description={`Apakah Anda yakin ingin menyetujui permohonan cuti ini?`} + confirmLabel="Setujui" + onConfirm={handleApprove} + /> + + { + if (!open) { + setRejecting(null); + } + }} + title="Tolak Permohonan Cuti" + description={`Apakah Anda yakin ingin menolak permohonan cuti ini?`} + confirmLabel="Tolak" + onConfirm={handleReject} + /> +
+ + ); +} + +LeaveRequestIndex.layout = { + breadcrumbs: [ + { + title: 'SDM', + href: leaveRequestIndex(), + }, + { + title: 'Cuti', + href: leaveRequestIndex(), + }, + ], +}; diff --git a/routes/web.php b/routes/web.php index 60e5319..ba15dc1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Admin\Master\CategoryController; use App\Http\Controllers\Admin\Master\CustomerController; use App\Http\Controllers\Admin\Master\SupplierController; +use App\Http\Controllers\Admin\SDM\LeaveRequestController; use Illuminate\Support\Facades\Route; Route::inertia('/', 'welcome')->name('home'); @@ -32,6 +33,12 @@ Route::post('employee-advances/{employeeAdvance}/approve', [EmployeeAdvanceController::class, 'approve'])->name('employee-advances.approve'); Route::post('employee-advances/{employeeAdvance}/pay', [EmployeeAdvanceController::class, 'pay'])->name('employee-advances.pay'); }); + + Route::prefix('admin/sdm')->name('admin.sdm.')->group(function () { + Route::resource('leave-requests', LeaveRequestController::class)->except(['show', 'create', 'edit']); + Route::post('leave-requests/{leaveRequest}/approve', [LeaveRequestController::class, 'approve'])->name('leave-requests.approve'); + Route::post('leave-requests/{leaveRequest}/reject', [LeaveRequestController::class, 'reject'])->name('leave-requests.reject'); + }); }); require __DIR__.'/settings.php';