diff --git a/app/Http/Controllers/Admin/Finance/EmployeeAdvanceController.php b/app/Http/Controllers/Admin/Finance/EmployeeAdvanceController.php new file mode 100644 index 0000000..6812415 --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/EmployeeAdvanceController.php @@ -0,0 +1,97 @@ + $this->service->getAll(), + ]); + } + + public function store(EmployeeAdvanceRequest $request): RedirectResponse + { + try { + $this->service->create($request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Kasbon 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.finance.employee-advances.index'); + } + + public function update(EmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse + { + try { + $this->service->update($employeeAdvance, $request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Kasbon 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.finance.employee-advances.index'); + } + + public function destroy(EmployeeAdvance $employeeAdvance): RedirectResponse + { + try { + $this->service->delete($employeeAdvance); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Kasbon berhasil dihapus.']); + } catch (\Exception $e) { + Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]); + } + + return to_route('admin.finance.employee-advances.index'); + } + + public function approve(EmployeeAdvance $employeeAdvance): RedirectResponse + { + try { + $this->service->approve($employeeAdvance); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Kasbon berhasil disetujui.']); + } catch (\Exception $e) { + Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]); + } + + return to_route('admin.finance.employee-advances.index'); + } + + public function pay(EmployeeAdvance $employeeAdvance): RedirectResponse + { + try { + $this->service->pay($employeeAdvance); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Kasbon berhasil dibayar.']); + } catch (\Exception $e) { + Inertia::flash('toast', ['type' => 'error', 'message' => $e->getMessage()]); + } + + return to_route('admin.finance.employee-advances.index'); + } +} diff --git a/app/Http/Requests/Admin/Finance/EmployeeAdvanceRequest.php b/app/Http/Requests/Admin/Finance/EmployeeAdvanceRequest.php new file mode 100644 index 0000000..c939bf2 --- /dev/null +++ b/app/Http/Requests/Admin/Finance/EmployeeAdvanceRequest.php @@ -0,0 +1,42 @@ +has('amount')) { + $this->merge([ + 'amount' => str_replace('.', '', $this->amount), + ]); + } + } + + public function rules(): array + { + return [ + 'amount' => ['required', 'integer', 'min:1'], + 'description' => ['required', 'string', 'max:100'], + 'due_date' => ['required', 'date', 'after_or_equal:today'], + ]; + } + + public function attributes(): array + { + return [ + 'amount' => 'jumlah', + 'description' => 'keterangan', + 'due_date' => 'jatuh tempo', + ]; + } +} diff --git a/app/Services/Admin/Finance/EmployeeAdvanceService.php b/app/Services/Admin/Finance/EmployeeAdvanceService.php new file mode 100644 index 0000000..3c1e44b --- /dev/null +++ b/app/Services/Admin/Finance/EmployeeAdvanceService.php @@ -0,0 +1,150 @@ +latest() + ->get(); + } + + public function create(array $data): EmployeeAdvance + { + return DB::transaction(function () use ($data) { + $cashAccount = CashAccount::firstOrFail(); + $employee = auth()->user()->employee; + + if (!$employee) { + throw new \Exception('Anda tidak terdaftar sebagai karyawan.'); + } + + if ($cashAccount->balance < $data['amount']) { + throw ValidationException::withMessages([ + 'amount' => 'Saldo tidak mencukupi.', + ]); + } + + $newBalance = $cashAccount->balance - $data['amount']; + $cashAccount->update(['balance' => $newBalance]); + + $cashTransaction = CashTransaction::create([ + 'cash_account_id' => $cashAccount->id, + 'created_by_id' => auth()->id(), + 'amount' => $data['amount'], + 'balance_after' => $newBalance, + 'type' => CashTransactionType::EXPENSE, + 'description' => 'Kasbon: ' . $data['description'], + ]); + + return EmployeeAdvance::create([ + 'cash_transaction_id' => $cashTransaction->id, + 'employee_id' => $employee->id, + 'amount' => $data['amount'], + 'description' => $data['description'], + 'due_date' => $data['due_date'], + 'status' => EmployeeAdvanceStatus::PENDING, + ]); + }); + } + + public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeAdvance + { + return DB::transaction(function () use ($employeeAdvance, $data) { + $cashAccount = CashAccount::firstOrFail(); + $cashTransaction = $employeeAdvance->cashTransaction; + + $oldAmount = $employeeAdvance->amount; + $newAmount = $data['amount']; + $difference = $newAmount - $oldAmount; + + if ($difference > 0 && $cashAccount->balance < $difference) { + throw ValidationException::withMessages([ + 'amount' => 'Saldo tidak mencukupi.', + ]); + } + + $newBalance = $cashAccount->balance - $difference; + $cashAccount->update(['balance' => $newBalance]); + + $cashTransaction->update([ + 'amount' => $newAmount, + 'balance_after' => $newBalance, + 'description' => 'Kasbon: ' . $data['description'], + ]); + + $employeeAdvance->update([ + 'amount' => $newAmount, + 'description' => $data['description'], + 'due_date' => $data['due_date'], + ]); + + return $employeeAdvance; + }); + } + + public function delete(EmployeeAdvance $employeeAdvance): bool + { + return DB::transaction(function () use ($employeeAdvance) { + $cashAccount = CashAccount::firstOrFail(); + + if ($employeeAdvance->status === EmployeeAdvanceStatus::PAID) { + $newBalance = $cashAccount->balance + $employeeAdvance->amount; + $cashAccount->update(['balance' => $newBalance]); + } + + return $employeeAdvance->delete(); + }); + } + + public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance + { + $employeeAdvance->update([ + 'status' => EmployeeAdvanceStatus::APPROVED, + 'verified_by_id' => auth()->id(), + 'verified_at' => now(), + ]); + + return $employeeAdvance; + } + + public function pay(EmployeeAdvance $employeeAdvance): EmployeeAdvance + { + return DB::transaction(function () use ($employeeAdvance) { + $cashAccount = CashAccount::firstOrFail(); + + $newBalance = $cashAccount->balance + $employeeAdvance->amount; + $cashAccount->update(['balance' => $newBalance]); + + $cashTransaction = CashTransaction::create([ + 'cash_account_id' => $cashAccount->id, + 'created_by_id' => auth()->id(), + 'amount' => $employeeAdvance->amount, + 'balance_after' => $newBalance, + 'type' => CashTransactionType::DEPOSIT, + 'description' => 'Pembayaran kasbon: ' . $employeeAdvance->description, + ]); + + $employeeAdvance->update([ + 'status' => EmployeeAdvanceStatus::PAID, + 'paid_by_id' => auth()->id(), + 'paid_amount' => $employeeAdvance->amount, + 'paid_at' => now(), + 'repayment_cash_transaction_id' => $cashTransaction->id, + ]); + + return $employeeAdvance; + }); + } +} diff --git a/package.json b/package.json index 8aa09cf..8929253 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "concurrently": "^9.0.1", + "date-fns": "^4.4.0", "globals": "^15.14.0", "input-otp": "^1.4.2", "laravel-vite-plugin": "^3.0.0", @@ -60,6 +61,7 @@ "next-themes": "^0.4.6", "radix-ui": "^1.6.7", "react": "^19.2.0", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.0", "shadcn": "^4.16.0", "sonner": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e2e720..ee22c54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: concurrently: specifier: ^9.0.1 version: 9.2.4 + date-fns: + specifier: ^4.4.0 + version: 4.4.0 globals: specifier: ^15.14.0 version: 15.15.0 @@ -101,6 +104,9 @@ importers: react: specifier: ^19.2.0 version: 19.2.8 + react-day-picker: + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.2.17)(react@19.2.8) react-dom: specifier: ^19.2.0 version: 19.2.8(react@19.2.8) @@ -331,6 +337,9 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@dotenvx/dotenvx@1.75.1': resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} hasBin: true @@ -1950,6 +1959,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + debounce-fn@4.0.0: resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} engines: {node: '>=10'} @@ -3378,6 +3390,16 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: @@ -4161,6 +4183,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@date-fns/tz@1.5.0': {} + '@dotenvx/dotenvx@1.75.1': dependencies: '@dotenvx/primitives': 0.8.0 @@ -5832,6 +5856,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns@4.4.0: {} + debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 @@ -7267,6 +7293,14 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + react-day-picker@10.0.1(@types/react@19.2.17)(react@19.2.8): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 538083d..9e8b9e3 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -40,6 +40,7 @@ import { index as customersIndex } from '@/routes/admin/master/customers'; 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'; type NavMenuItem = { title: string; href: string; icon: LucideIcon }; @@ -73,7 +74,7 @@ const kelolaItems: NavMenuItem[] = [ const keuanganItems: NavMenuItem[] = [ { title: 'Kas Toko', href: cashAccountsIndex.url(), icon: Wallet }, { title: 'Pengeluaran', href: expensesIndex.url(), icon: ArrowUpFromLine }, - { title: 'Kasbon', href: '#', icon: HandCoins }, + { title: 'Kasbon', href: employeeAdvancesIndex.url(), icon: HandCoins }, { title: 'Gaji', href: '#', icon: DollarSign }, ]; diff --git a/resources/js/components/date-picker.tsx b/resources/js/components/date-picker.tsx new file mode 100644 index 0000000..176dd2e --- /dev/null +++ b/resources/js/components/date-picker.tsx @@ -0,0 +1,91 @@ +import * as React from "react" +import { format } from "date-fns" +import { id } from "date-fns/locale" +import { CalendarIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +interface DatePickerProps { + value?: Date | string | null + onChange?: (date: Date | undefined) => void + placeholder?: string + disabled?: boolean + className?: string + name?: string + id?: string + min?: Date + max?: Date +} + +function DatePicker({ + value, + onChange, + placeholder = "Pilih tanggal", + disabled = false, + className, + name, + id, + min, + max, +}: DatePickerProps) { + const [open, setOpen] = React.useState(false) + + const date = React.useMemo(() => { + if (!value) return undefined + if (value instanceof Date) return value + return new Date(value) + }, [value]) + + const formattedDate = React.useMemo(() => { + if (!date) return "" + return format(date, "dd MMM yyyy", { locale: id }) + }, [date]) + + return ( + + + + + {date ? formattedDate : placeholder} + + + + { + onChange?.(selectedDate) + setOpen(false) + }} + disabled={(date) => { + if (min && date < min) return true + if (max && date > max) return true + return false + }} + initialFocus + /> + + {name && ( + + )} + + ) +} + +export { DatePicker } diff --git a/resources/js/components/ui/calendar.tsx b/resources/js/components/ui/calendar.tsx new file mode 100644 index 0000000..be12f05 --- /dev/null +++ b/resources/js/components/ui/calendar.tsx @@ -0,0 +1,218 @@ +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { + DayPicker, + getDefaultClassNames, + type DayButton, +} from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", + defaultClassNames.caption_label + ), + month_grid: cn("w-full border-collapse", defaultClassNames.month_grid), + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-(--cell-size) select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" + : "[&:first-child[data-selected=true]_button]:rounded-l-md", + defaultClassNames.day + ), + range_start: cn( + "rounded-l-md bg-accent", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), + today: cn( + "rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( + + ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + + + {children} + + + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + span]:text-xs [&>span]:opacity-70", + defaultClassNames.day, + className + )} + {...props} + /> + ) +} + +export { Calendar, CalendarDayButton } diff --git a/resources/js/components/ui/popover.tsx b/resources/js/components/ui/popover.tsx new file mode 100644 index 0000000..4182075 --- /dev/null +++ b/resources/js/components/ui/popover.tsx @@ -0,0 +1,87 @@ +import * as React from "react" +import { Popover as PopoverPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { + return ( + + ) +} + +function PopoverDescription({ + className, + ...props +}: React.ComponentProps<"p">) { + return ( + + ) +} + +export { + Popover, + PopoverTrigger, + PopoverContent, + PopoverAnchor, + PopoverHeader, + PopoverTitle, + PopoverDescription, +} diff --git a/resources/js/pages/admin/finance/employee-advance/columns.tsx b/resources/js/pages/admin/finance/employee-advance/columns.tsx new file mode 100644 index 0000000..2357ad1 --- /dev/null +++ b/resources/js/pages/admin/finance/employee-advance/columns.tsx @@ -0,0 +1,287 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { ArrowUpDown, CheckCircle, CircleDollarSign, Pencil, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { formatCurrency } from '@/lib/utils'; + +export type EmployeeAdvance = { + id: number; + amount: number; + paid_amount: number; + description: string; + due_date: string; + status: 'pending' | 'approved' | 'paid' | 'rejected' | 'cancelled'; + created_at: string; + employee: { + user: { + user_profile: { + full_name: string; + }; + }; + }; +}; + +function formatDate(dateString: string): string { + const date = new Date(dateString); + + return date.toLocaleDateString('id-ID', { + day: '2-digit', + month: 'short', + year: 'numeric', + }) + ' ' + date.toLocaleTimeString('id-ID', { + hour: '2-digit', + minute: '2-digit', + }); +} + +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', + }, + paid: { + label: 'Dibayar', + className: 'bg-blue-100 text-blue-800 hover:bg-blue-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: (employeeAdvance: EmployeeAdvance) => void; + handleDeleteClick: (employeeAdvance: EmployeeAdvance) => void; + handleApprove: (employeeAdvance: EmployeeAdvance) => void; + handlePay: (employeeAdvance: EmployeeAdvance) => void; +}; + +export function createEmployeeAdvanceColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { handleEdit, handleDeleteClick, handleApprove, handlePay } = params; + + return [ + { + id: 'no', + header: () => No, + cell: ({ row }) => ( + + {row.index + 1} + + ), + meta: { + className: 'w-[50px] text-center', + headerClassName: 'w-[50px] text-center', + }, + }, + { + accessorKey: 'created_at', + header: ({ column }) => ( + + column.toggleSorting( + column.getIsSorted() === 'asc', + ) + } + > + Tanggal + + + ), + cell: ({ row }) => ( + {formatDate(row.getValue('created_at') as string)} + ), + }, + { + id: 'employee_name', + header: () => Oleh, + cell: ({ row }) => { + const employee = row.original.employee; + + return {employee?.user?.user_profile?.full_name ?? '-'}; + }, + }, + { + accessorKey: 'amount', + header: ({ column }) => ( + + column.toggleSorting( + column.getIsSorted() === 'asc', + ) + } + > + Jumlah + + + ), + cell: ({ row }) => ( + + - {formatCurrency(row.getValue('amount') as number)} + + ), + }, + { + accessorKey: 'description', + header: () => Keterangan, + cell: ({ row }) => ( + {row.getValue('description') as string} + ), + }, + { + accessorKey: 'due_date', + header: ({ column }) => ( + + column.toggleSorting( + column.getIsSorted() === 'asc', + ) + } + > + Jatuh Tempo + + + ), + cell: ({ row }) => ( + {formatShortDate(row.getValue('due_date') as string)} + ), + }, + { + 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 employeeAdvance = row.original; + + return ( + + + {employeeAdvance.status === 'pending' && ( + + + + handleApprove(employeeAdvance) + } + > + + + + + Setujui + + + )} + + {employeeAdvance.status === 'approved' && ( + + + + handlePay(employeeAdvance) + } + > + + + + + Bayar + + + )} + + + + + handleEdit(employeeAdvance) + } + > + + + + + Edit + + + + + + + handleDeleteClick(employeeAdvance) + } + > + + + + + Hapus + + + + + ); + }, + }, + ]; +} diff --git a/resources/js/pages/admin/finance/employee-advance/index.tsx b/resources/js/pages/admin/finance/employee-advance/index.tsx new file mode 100644 index 0000000..4b57af0 --- /dev/null +++ b/resources/js/pages/admin/finance/employee-advance/index.tsx @@ -0,0 +1,313 @@ +import { Form, Head, router } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import InputError from '@/components/input-error'; +import { RupiahInput } from '@/components/rupiah-input'; +import { DatePicker } from '@/components/date-picker'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { destroy, index as employeeAdvanceIndex, store, update, approve, pay } from '@/routes/admin/finance/employee-advances'; +import { createEmployeeAdvanceColumns } from './columns'; +import type { EmployeeAdvance } from './columns'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { DataTable } from '@/components/data-table'; + +type Props = { + employeeAdvances: EmployeeAdvance[]; +}; + +export default function EmployeeAdvanceIndex({ employeeAdvances }: Props) { + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + const [approving, setApproving] = useState(null); + const [paying, setPaying] = useState(null); + const [dueDate, setDueDate] = useState(undefined); + const [editingDueDate, setEditingDueDate] = useState(undefined); + + useEffect(() => { + if (editing) { + setEditingDueDate(new Date(editing.due_date)); + } else { + setEditingDueDate(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 handlePay() { + if (!paying) { + return; + } + + router.post(pay(paying.id), {}, { + onSuccess: () => setPaying(null), + }); + } + + const columns = createEmployeeAdvanceColumns({ + handleEdit: (employeeAdvance) => setEditing(employeeAdvance), + handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance), + handleApprove: (employeeAdvance) => setApproving(employeeAdvance), + handlePay: (employeeAdvance) => setPaying(employeeAdvance), + }); + + return ( + <> + + + + + + + Kasbon + + + { + setCreateOpen(open); + if (!open) { + setDueDate(undefined); + } + }}> + + setCreateOpen(true)} + > + + Tambah + + + + setCreateOpen(false)}> + {({ errors, processing }) => { + + return ( + <> + + Tambah Kasbon + + + + + Jumlah{' '} * + + + + + + + Keterangan{' '} * + + + + + + + Jatuh Tempo{' '} * + + + + + + + + setCreateOpen(false)} + > + Batal + + + {processing + ? 'Menyimpan...' + : 'Simpan'} + + + > + ); + }} + + + + + + + + { + if (!open) { + setEditing(null); + setEditingDueDate(undefined); + } + }} + > + + {editing && ( + { + setEditing(null); + setEditingDueDate(undefined); + }}> + {({ errors, processing }) => { + + return ( + <> + + Edit Kasbon + + + + Jumlah{' '} * + + + + + Keterangan{' '} * + + + + + Jatuh Tempo{' '} * + + + + + + + { + setEditing(null); + }} + > + Batal + + + {processing + ? 'Menyimpan...' + : 'Simpan'} + + + > + ); + }} + + )} + + + + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Kasbon" + description={`Apakah Anda yakin ingin menghapus kasbon "${deleting?.description}"? Tindakan ini tidak dapat dibatalkan.`} + confirmLabel="Hapus" + onConfirm={handleDelete} + /> + + { + if (!open) { + setApproving(null); + } + }} + title="Setujui Kasbon" + description={`Apakah Anda yakin ingin menyetujui kasbon "${approving?.description}"?`} + confirmLabel="Setujui" + onConfirm={handleApprove} + /> + + { + if (!open) { + setPaying(null); + } + }} + title="Bayar Kasbon" + description={`Apakah Anda yakin ingin membayar kasbon "${paying?.description}" sebesar ${paying?.amount}? Saldo kas akan dikembalikan.`} + confirmLabel="Bayar" + onConfirm={handlePay} + /> + + > + ); +} + +EmployeeAdvanceIndex.layout = { + breadcrumbs: [ + { + title: 'Keuangan', + href: employeeAdvanceIndex(), + }, + { + title: 'Kasbon', + href: employeeAdvanceIndex(), + }, + ], +}; diff --git a/routes/web.php b/routes/web.php index f0def91..60e5319 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('cash-accounts.transactions.destroy'); Route::resource('expenses', ExpenseController::class)->except(['show', 'create', 'edit']); + + Route::resource('employee-advances', EmployeeAdvanceController::class)->except(['show', 'create', 'edit']); + 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'); }); });