From c3d8ea2f66f53373599d99f9a9395db77720450c Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 29 Jul 2026 02:08:39 +0700 Subject: [PATCH] feat: implement cash account management with deposit and withdrawal functionality - Introduced CashAccountController for managing cash accounts. - Created CashTransactionRequest and CashAccountRequest for transaction validation. - Developed CashAccountService to handle business logic for cash transactions. - Added UI components for cash account management, including deposit and withdrawal dialogs. - Implemented data tables for displaying transactions and cash account details. - Updated routes to include cash account management endpoints. - Added tests for cash account functionality, including deposit and withdrawal operations. --- .../Admin/Finance/CashAccountController.php | 45 +++++ .../Admin/Finance/CashAccountRequest.php | 30 +++ .../Admin/Finance/CashTransactionRequest.php | 40 ++++ app/Models/CashTransaction.php | 2 +- .../Admin/Finance/CashAccountService.php | 71 +++++++ database/seeders/CashAccountSeeder.php | 14 ++ database/seeders/DatabaseSeeder.php | 1 + resources/js/components/app-sidebar.tsx | 3 +- resources/js/components/rupiah-input.tsx | 80 ++++++++ resources/js/components/ui/input-group.tsx | 168 ++++++++++++++++ resources/js/components/ui/textarea.tsx | 18 ++ resources/js/lib/utils.ts | 8 + .../admin/finance/cash-account/columns.tsx | 138 +++++++++++++ .../admin/finance/cash-account/index.tsx | 182 ++++++++++++++++++ .../cash-account/transaction-columns.tsx | 170 ++++++++++++++++ routes/web.php | 7 + .../Feature/Admin/Finance/CashAccountTest.php | 153 +++++++++++++++ 17 files changed, 1128 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/Admin/Finance/CashAccountController.php create mode 100644 app/Http/Requests/Admin/Finance/CashAccountRequest.php create mode 100644 app/Http/Requests/Admin/Finance/CashTransactionRequest.php create mode 100644 app/Services/Admin/Finance/CashAccountService.php create mode 100644 database/seeders/CashAccountSeeder.php create mode 100644 resources/js/components/rupiah-input.tsx create mode 100644 resources/js/components/ui/input-group.tsx create mode 100644 resources/js/components/ui/textarea.tsx create mode 100644 resources/js/pages/admin/finance/cash-account/columns.tsx create mode 100644 resources/js/pages/admin/finance/cash-account/index.tsx create mode 100644 resources/js/pages/admin/finance/cash-account/transaction-columns.tsx create mode 100644 tests/Feature/Admin/Finance/CashAccountTest.php diff --git a/app/Http/Controllers/Admin/Finance/CashAccountController.php b/app/Http/Controllers/Admin/Finance/CashAccountController.php new file mode 100644 index 0000000..7c59f3b --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/CashAccountController.php @@ -0,0 +1,45 @@ +service->get(); + + return Inertia::render('admin/finance/cash-account/index', [ + 'cashAccount' => $cashAccount, + 'transactions' => $this->service->getAllTransactions(), + ]); + } + + public function deposit(CashTransactionRequest $request): RedirectResponse + { + $this->service->deposit($request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Deposit berhasil ditambahkan.']); + + return to_route('admin.finance.cash-accounts.index'); + } + + public function withdrawal(CashTransactionRequest $request): RedirectResponse + { + $this->service->withdrawal($request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Withdrawal berhasil ditambahkan.']); + + return to_route('admin.finance.cash-accounts.index'); + } +} diff --git a/app/Http/Requests/Admin/Finance/CashAccountRequest.php b/app/Http/Requests/Admin/Finance/CashAccountRequest.php new file mode 100644 index 0000000..72c3745 --- /dev/null +++ b/app/Http/Requests/Admin/Finance/CashAccountRequest.php @@ -0,0 +1,30 @@ +route('cash_account'); + + return [ + 'name' => ['required', 'string', 'max:200', Rule::unique('cash_accounts', 'name')->ignore($cashAccount)], + ]; + } + + public function attributes(): array + { + return [ + 'name' => 'nama', + ]; + } +} diff --git a/app/Http/Requests/Admin/Finance/CashTransactionRequest.php b/app/Http/Requests/Admin/Finance/CashTransactionRequest.php new file mode 100644 index 0000000..2abdc5e --- /dev/null +++ b/app/Http/Requests/Admin/Finance/CashTransactionRequest.php @@ -0,0 +1,40 @@ +has('amount')) { + $this->merge([ + 'amount' => str_replace('.', '', $this->amount), + ]); + } + } + + public function rules(): array + { + return [ + 'amount' => ['required', 'integer', 'min:1'], + 'description' => ['required', 'string', 'max:100'], + ]; + } + + public function attributes(): array + { + return [ + 'amount' => 'jumlah', + 'description' => 'keterangan', + ]; + } +} diff --git a/app/Models/CashTransaction.php b/app/Models/CashTransaction.php index 3c67ef6..a568a70 100644 --- a/app/Models/CashTransaction.php +++ b/app/Models/CashTransaction.php @@ -34,7 +34,7 @@ protected function deposit(Builder $query): void } #[Scope] - protected function expense(Builder $query): void + protected function expenseType(Builder $query): void { $query->where('type', CashTransactionType::EXPENSE); } diff --git a/app/Services/Admin/Finance/CashAccountService.php b/app/Services/Admin/Finance/CashAccountService.php new file mode 100644 index 0000000..fd5a60a --- /dev/null +++ b/app/Services/Admin/Finance/CashAccountService.php @@ -0,0 +1,71 @@ +get(); + + if (! $cashAccount) { + return collect(); + } + + return $cashAccount->cashTransactions() + ->with('createdBy') + ->latest() + ->get(); + } + + public function deposit(array $data): CashTransaction + { + $cashAccount = CashAccount::firstOrFail(); + $newBalance = $cashAccount->balance + $data['amount']; + + $cashAccount->update(['balance' => $newBalance]); + + return CashTransaction::create([ + 'cash_account_id' => $cashAccount->id, + 'created_by_id' => auth()->id(), + 'amount' => $data['amount'], + 'balance_after' => $newBalance, + 'type' => CashTransactionType::DEPOSIT, + 'description' => $data['description'], + ]); + } + + public function withdrawal(array $data): CashTransaction + { + $cashAccount = CashAccount::firstOrFail(); + + if ($cashAccount->balance < $data['amount']) { + throw ValidationException::withMessages([ + 'amount' => 'Saldo tidak mencukupi.', + ]); + } + + $newBalance = $cashAccount->balance - $data['amount']; + $cashAccount->update(['balance' => $newBalance]); + + return CashTransaction::create([ + 'cash_account_id' => $cashAccount->id, + 'created_by_id' => auth()->id(), + 'amount' => $data['amount'], + 'balance_after' => $newBalance, + 'type' => CashTransactionType::WITHDRAWAL, + 'description' => $data['description'], + ]); + } +} diff --git a/database/seeders/CashAccountSeeder.php b/database/seeders/CashAccountSeeder.php new file mode 100644 index 0000000..c4c6643 --- /dev/null +++ b/database/seeders/CashAccountSeeder.php @@ -0,0 +1,14 @@ +count(1)->create(); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index ba7e7c8..f9900fa 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -19,6 +19,7 @@ public function run(): void CategorySeeder::class, SupplierSeeder::class, CustomerSeeder::class, + CashAccountSeeder::class, ]); } } diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 7a9873c..3eb0d1c 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -38,6 +38,7 @@ import { dashboard } from '@/routes'; import { index as categoriesIndex } from '@/routes/admin/master/categories'; 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'; type NavMenuItem = { title: string; href: string; icon: LucideIcon }; @@ -69,7 +70,7 @@ const kelolaItems: NavMenuItem[] = [ ]; const keuanganItems: NavMenuItem[] = [ - { title: 'Kas Toko', href: '#', icon: Wallet }, + { title: 'Kas Toko', href: cashAccountsIndex.url(), icon: Wallet }, { title: 'Pengeluaran', href: '#', icon: ArrowUpFromLine }, { title: 'Kasbon', href: '#', icon: HandCoins }, { title: 'Gaji', href: '#', icon: DollarSign }, diff --git a/resources/js/components/rupiah-input.tsx b/resources/js/components/rupiah-input.tsx new file mode 100644 index 0000000..13aa953 --- /dev/null +++ b/resources/js/components/rupiah-input.tsx @@ -0,0 +1,80 @@ +import { useCallback, useRef, useState } from 'react'; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from '@/components/ui/input-group'; + +type RupiahInputProps = { + name?: string; + defaultValue?: number; + placeholder?: string; + disabled?: boolean; + min?: number; + max?: number; + className?: string; +}; + +function formatRupiah(value: number): string { + return value.toLocaleString('id-ID'); +} + +function parseRupiah(value: string): number { + const cleaned = value.replace(/[^0-9]/g, ''); + return cleaned === '' ? 0 : parseInt(cleaned, 10); +} + +export function RupiahInput({ + name, + defaultValue = 0, + placeholder = '0', + disabled = false, + min, + max, + className, +}: RupiahInputProps) { + const [displayValue, setDisplayValue] = useState(formatRupiah(defaultValue)); + const lastValidRef = useRef(defaultValue); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const raw = parseRupiah(e.target.value); + let clamped = raw; + + if (min !== undefined && raw < min) { + clamped = min; + } + if (max !== undefined && raw > max) { + clamped = max; + } + + lastValidRef.current = clamped; + setDisplayValue(formatRupiah(clamped)); + }, + [min, max], + ); + + const handleBlur = useCallback(() => { + setDisplayValue(formatRupiah(lastValidRef.current)); + }, []); + + return ( + + + Rp + + + + ); +} diff --git a/resources/js/components/ui/input-group.tsx b/resources/js/components/ui/input-group.tsx new file mode 100644 index 0000000..7f78fec --- /dev/null +++ b/resources/js/components/ui/input-group.tsx @@ -0,0 +1,168 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
textarea]:h-auto", + + // Variants based on alignment. + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + + // Focus state. + "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50", + + // Error state. + "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", + "inline-end": + "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", + "block-start": + "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3", + "block-end": + "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", + sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +