From c6c765b906b912a43e7bfa855a673d59a514b769 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 29 Jul 2026 16:13:02 +0700 Subject: [PATCH] feat: implement expense management with CRUD functionality and integrate Inertia for UI --- .../Admin/Finance/ExpenseController.php | 52 +++++ .../Requests/Admin/Finance/ExpenseRequest.php | 40 ++++ .../Admin/Finance/CashAccountService.php | 2 +- app/Services/Admin/Finance/ExpenseService.php | 102 ++++++++ resources/js/components/app-sidebar.tsx | 3 +- .../cash-account/transaction-columns.tsx | 9 +- .../pages/admin/finance/expense/columns.tsx | 172 ++++++++++++++ .../js/pages/admin/finance/expense/index.tsx | 219 ++++++++++++++++++ routes/web.php | 3 + tests/Feature/Admin/Finance/ExpenseTest.php | 183 +++++++++++++++ 10 files changed, 779 insertions(+), 6 deletions(-) create mode 100644 app/Http/Controllers/Admin/Finance/ExpenseController.php create mode 100644 app/Http/Requests/Admin/Finance/ExpenseRequest.php create mode 100644 app/Services/Admin/Finance/ExpenseService.php create mode 100644 resources/js/pages/admin/finance/expense/columns.tsx create mode 100644 resources/js/pages/admin/finance/expense/index.tsx create mode 100644 tests/Feature/Admin/Finance/ExpenseTest.php diff --git a/app/Http/Controllers/Admin/Finance/ExpenseController.php b/app/Http/Controllers/Admin/Finance/ExpenseController.php new file mode 100644 index 0000000..60df661 --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/ExpenseController.php @@ -0,0 +1,52 @@ + $this->service->getAll(), + ]); + } + + public function store(ExpenseRequest $request): RedirectResponse + { + $this->service->create($request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengeluaran berhasil ditambahkan.']); + + return to_route('admin.finance.expenses.index'); + } + + public function update(ExpenseRequest $request, Expense $expense): RedirectResponse + { + $this->service->update($expense, $request->validated()); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengeluaran berhasil diperbarui.']); + + return to_route('admin.finance.expenses.index'); + } + + public function destroy(Expense $expense): RedirectResponse + { + $this->service->delete($expense); + + Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengeluaran berhasil dihapus.']); + + return to_route('admin.finance.expenses.index'); + } +} diff --git a/app/Http/Requests/Admin/Finance/ExpenseRequest.php b/app/Http/Requests/Admin/Finance/ExpenseRequest.php new file mode 100644 index 0000000..8154955 --- /dev/null +++ b/app/Http/Requests/Admin/Finance/ExpenseRequest.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/Services/Admin/Finance/CashAccountService.php b/app/Services/Admin/Finance/CashAccountService.php index fd5a60a..67447df 100644 --- a/app/Services/Admin/Finance/CashAccountService.php +++ b/app/Services/Admin/Finance/CashAccountService.php @@ -24,7 +24,7 @@ public function getAllTransactions(): Collection } return $cashAccount->cashTransactions() - ->with('createdBy') + ->with('createdBy.userProfile') ->latest() ->get(); } diff --git a/app/Services/Admin/Finance/ExpenseService.php b/app/Services/Admin/Finance/ExpenseService.php new file mode 100644 index 0000000..0d352ad --- /dev/null +++ b/app/Services/Admin/Finance/ExpenseService.php @@ -0,0 +1,102 @@ +latest() + ->get(); + } + + public function create(array $data): Expense + { + return DB::transaction(function () use ($data) { + $cashAccount = CashAccount::firstOrFail(); + + 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' => $data['description'], + ]); + + return Expense::create([ + 'cash_transaction_id' => $cashTransaction->id, + 'created_by_id' => auth()->id(), + 'amount' => $data['amount'], + 'description' => $data['description'], + ]); + }); + } + + public function update(Expense $expense, array $data): Expense + { + return DB::transaction(function () use ($expense, $data) { + $cashAccount = CashAccount::firstOrFail(); + $cashTransaction = $expense->cashTransaction; + + $oldAmount = $expense->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' => $data['description'], + ]); + + $expense->update([ + 'amount' => $newAmount, + 'description' => $data['description'], + ]); + + return $expense; + }); + } + + public function delete(Expense $expense): bool + { + return DB::transaction(function () use ($expense) { + $cashAccount = CashAccount::firstOrFail(); + $cashTransaction = $expense->cashTransaction; + + $newBalance = $cashAccount->balance + $expense->amount; + $cashAccount->update(['balance' => $newBalance]); + + $cashTransaction->delete(); + + return $expense->delete(); + }); + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 3eb0d1c..538083d 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -39,6 +39,7 @@ 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'; +import { index as expensesIndex } from '@/routes/admin/finance/expenses'; type NavMenuItem = { title: string; href: string; icon: LucideIcon }; @@ -71,7 +72,7 @@ const kelolaItems: NavMenuItem[] = [ const keuanganItems: NavMenuItem[] = [ { title: 'Kas Toko', href: cashAccountsIndex.url(), icon: Wallet }, - { title: 'Pengeluaran', href: '#', icon: ArrowUpFromLine }, + { title: 'Pengeluaran', href: expensesIndex.url(), icon: ArrowUpFromLine }, { title: 'Kasbon', href: '#', icon: HandCoins }, { title: 'Gaji', href: '#', icon: DollarSign }, ]; diff --git a/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx b/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx index b951f26..573daeb 100644 --- a/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx +++ b/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx @@ -1,7 +1,7 @@ -import type { ColumnDef } from '@tanstack/react-table'; -import { ArrowUpDown } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { formatCurrency } from '@/lib/utils'; +import type { ColumnDef } from '@tanstack/react-table'; +import { ArrowUpDown } from 'lucide-react'; export type CashTransaction = { id: number; @@ -11,7 +11,9 @@ export type CashTransaction = { description: string; created_at: string; created_by: { - name: string; + user_profile: { + full_name: string; + }; }; reference: { type: string; @@ -161,7 +163,6 @@ export function createTransactionColumns(): ColumnDef[] { header: () => Oleh, cell: ({ row }) => { const createdBy = row.original.created_by; - console.log(createdBy) return {createdBy?.user_profile?.full_name ?? '-'}; }, diff --git a/resources/js/pages/admin/finance/expense/columns.tsx b/resources/js/pages/admin/finance/expense/columns.tsx new file mode 100644 index 0000000..50cdb8b --- /dev/null +++ b/resources/js/pages/admin/finance/expense/columns.tsx @@ -0,0 +1,172 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { ArrowUpDown, Pencil, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { formatCurrency } from '@/lib/utils'; + +export type Expense = { + id: number; + amount: number; + description: string; + created_at: string; + created_by: { + 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', + }); +} + +type CreateColumnsParams = { + handleEdit: (expense: Expense) => void; + handleDeleteClick: (expense: Expense) => void; +}; + +export function createExpenseColumns( + params: CreateColumnsParams, +): ColumnDef[] { + const { handleEdit, handleDeleteClick } = 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 }) => ( + + ), + cell: ({ row }) => ( + {formatDate(row.getValue('created_at') as string)} + ), + }, + { + accessorKey: 'description', + header: () => Keterangan, + cell: ({ row }) => ( + {row.getValue('description') as string} + ), + }, + { + accessorKey: 'amount', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + - {formatCurrency(row.getValue('amount') as number)} + + ), + }, + { + id: 'created_by', + header: () => Oleh, + cell: ({ row }) => { + const createdBy = row.original.created_by; + + return {createdBy?.user_profile?.full_name ?? '-'}; + + }, + }, + { + id: 'actions', + header: () => Aksi, + meta: { + className: 'w-[100px] text-center', + headerClassName: 'w-[100px] text-center', + }, + cell: ({ row }) => { + const expense = row.original; + + return ( + +
+ + + + + + Edit + + + + + + + + + Hapus + + +
+
+ ); + }, + }, + ]; +} diff --git a/resources/js/pages/admin/finance/expense/index.tsx b/resources/js/pages/admin/finance/expense/index.tsx new file mode 100644 index 0000000..be02026 --- /dev/null +++ b/resources/js/pages/admin/finance/expense/index.tsx @@ -0,0 +1,219 @@ +import { Form, Head, router } from '@inertiajs/react'; +import { Plus } from 'lucide-react'; +import { useState } from 'react'; +import InputError from '@/components/input-error'; +import { RupiahInput } from '@/components/rupiah-input'; +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 expenseIndex, store, update } from '@/routes/admin/finance/expenses'; +import { createExpenseColumns } from './columns'; +import type { Expense } from './columns'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { DataTable } from '@/components/data-table'; + +type Props = { + expenses: Expense[]; +}; + +export default function ExpenseIndex({ expenses }: Props) { + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + + function handleDelete() { + if (!deleting) { + return; + } + + router.delete(destroy(deleting.id), { + onSuccess: () => setDeleting(null), + }); + } + + const columns = createExpenseColumns({ + handleEdit: (expense) => setEditing(expense), + handleDeleteClick: (expense) => setDeleting(expense), + }); + + return ( + <> + + +
+
+
+

+ Pengeluaran +

+
+ + + + +
setCreateOpen(false)}> + {({ errors, processing }) => { + + return ( + <> + + Tambah Pengeluaran + +
+
+ + + +
+
+ + + +
+
+ + + + + + ); + }} +
+
+
+
+ + + + { + if (!open) { + setEditing(null); + } + }} + > + + {editing && ( +
setEditing(null)}> + {({ errors, processing }) => { + + return ( + <> + + Edit Pengeluaran + +
+
+ + + +
+
+ + + +
+
+ + + + + + ); + }} +
+ )} +
+
+ + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Pengeluaran" + description={`Apakah Anda yakin ingin menghapus pengeluaran "${deleting?.description}"? Tindakan ini tidak dapat dibatalkan.`} + confirmLabel="Hapus" + onConfirm={handleDelete} + /> +
+ + ); +} + +ExpenseIndex.layout = { + breadcrumbs: [ + { + title: 'Keuangan', + href: expenseIndex(), + }, + { + title: 'Pengeluaran', + href: expenseIndex(), + }, + ], +}; diff --git a/routes/web.php b/routes/web.php index ed9c84d..d5c751c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('cash-accounts.index'); Route::post('cash-accounts/deposit', [CashAccountController::class, 'deposit'])->name('cash-accounts.deposit'); Route::post('cash-accounts/withdrawal', [CashAccountController::class, 'withdrawal'])->name('cash-accounts.withdrawal'); + + Route::resource('expenses', ExpenseController::class)->except(['show', 'create', 'edit']); }); }); diff --git a/tests/Feature/Admin/Finance/ExpenseTest.php b/tests/Feature/Admin/Finance/ExpenseTest.php new file mode 100644 index 0000000..586a4d8 --- /dev/null +++ b/tests/Feature/Admin/Finance/ExpenseTest.php @@ -0,0 +1,183 @@ +get(route('admin.finance.expenses.index')); + $response->assertRedirect(route('login')); +}); + +test('authenticated users can visit the expense index page', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->get(route('admin.finance.expenses.index')); + $response->assertOk(); +}); + +test('expense index page displays expenses', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $cashAccount = CashAccount::factory()->create(['balance' => 1000000]); + $cashTransaction = CashTransaction::factory()->create([ + 'cash_account_id' => $cashAccount->id, + 'created_by_id' => $user->id, + 'type' => 'expense', + 'amount' => 50000, + ]); + $expense = Expense::factory()->create([ + 'cash_transaction_id' => $cashTransaction->id, + 'created_by_id' => $user->id, + ]); + + $response = $this->get(route('admin.finance.expenses.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/finance/expense/index') + ->has('expenses') + ); +}); + +test('expense can be created', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + CashAccount::factory()->create(['balance' => 100000]); + + $response = $this->post(route('admin.finance.expenses.store'), [ + 'amount' => 50000, + 'description' => 'Pembelian ATK', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.finance.expenses.index')); + + $this->assertDatabaseHas('expenses', [ + 'amount' => 50000, + 'description' => 'Pembelian ATK', + ]); + + expect(CashAccount::first()->balance)->toBe(50000); +}); + +test('expense amount is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + CashAccount::factory()->create(['balance' => 100000]); + + $response = $this->post(route('admin.finance.expenses.store'), [ + 'amount' => '', + 'description' => 'Test', + ]); + + $response->assertSessionHasErrors('amount'); +}); + +test('expense amount must be at least 1', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + CashAccount::factory()->create(['balance' => 100000]); + + $response = $this->post(route('admin.finance.expenses.store'), [ + 'amount' => 0, + 'description' => 'Test', + ]); + + $response->assertSessionHasErrors('amount'); +}); + +test('expense description is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + CashAccount::factory()->create(['balance' => 100000]); + + $response = $this->post(route('admin.finance.expenses.store'), [ + 'amount' => 50000, + 'description' => '', + ]); + + $response->assertSessionHasErrors('description'); +}); + +test('expense fails when balance is insufficient', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + CashAccount::factory()->create(['balance' => 50000]); + + $response = $this->post(route('admin.finance.expenses.store'), [ + 'amount' => 100000, + 'description' => 'Test', + ]); + + $response->assertSessionHasErrors('amount'); +}); + +test('expense can be updated', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $cashAccount = CashAccount::factory()->create(['balance' => 100000]); + $cashTransaction = CashTransaction::factory()->create([ + 'cash_account_id' => $cashAccount->id, + 'created_by_id' => $user->id, + 'type' => 'expense', + 'amount' => 30000, + 'balance_after' => 70000, + ]); + $expense = Expense::factory()->create([ + 'cash_transaction_id' => $cashTransaction->id, + 'created_by_id' => $user->id, + 'amount' => 30000, + ]); + + $response = $this->put(route('admin.finance.expenses.update', $expense), [ + 'amount' => 40000, + 'description' => 'Pembelian ATK Updated', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.finance.expenses.index')); + + $expense->refresh(); + expect($expense->amount)->toBe(40000); + expect($expense->description)->toBe('Pembelian ATK Updated'); +}); + +test('expense can be deleted', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $cashAccount = CashAccount::factory()->create(['balance' => 100000]); + $cashTransaction = CashTransaction::factory()->create([ + 'cash_account_id' => $cashAccount->id, + 'created_by_id' => $user->id, + 'type' => 'expense', + 'amount' => 30000, + 'balance_after' => 70000, + ]); + $expense = Expense::factory()->create([ + 'cash_transaction_id' => $cashTransaction->id, + 'created_by_id' => $user->id, + 'amount' => 30000, + ]); + + $response = $this->delete(route('admin.finance.expenses.destroy', $expense)); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.finance.expenses.index')); + + $this->assertSoftDeleted('expenses', ['id' => $expense->id]); + expect(CashAccount::first()->balance)->toBe(130000); +});