feat: implement expense management with CRUD functionality and integrate Inertia for UI
This commit is contained in:
parent
c3d8ea2f66
commit
c6c765b906
52
app/Http/Controllers/Admin/Finance/ExpenseController.php
Normal file
52
app/Http/Controllers/Admin/Finance/ExpenseController.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Finance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Finance\ExpenseRequest;
|
||||
use App\Models\Expense;
|
||||
use App\Services\Admin\Finance\ExpenseService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ExpenseController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ExpenseService $service
|
||||
) {}
|
||||
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('admin/finance/expense/index', [
|
||||
'expenses' => $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');
|
||||
}
|
||||
}
|
||||
40
app/Http/Requests/Admin/Finance/ExpenseRequest.php
Normal file
40
app/Http/Requests/Admin/Finance/ExpenseRequest.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Finance;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Override;
|
||||
|
||||
class ExpenseRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function prepareForValidation()
|
||||
{
|
||||
if ($this->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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -24,7 +24,7 @@ public function getAllTransactions(): Collection
|
||||
}
|
||||
|
||||
return $cashAccount->cashTransactions()
|
||||
->with('createdBy')
|
||||
->with('createdBy.userProfile')
|
||||
->latest()
|
||||
->get();
|
||||
}
|
||||
|
||||
102
app/Services/Admin/Finance/ExpenseService.php
Normal file
102
app/Services/Admin/Finance/ExpenseService.php
Normal file
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Finance;
|
||||
|
||||
use App\Enums\CashTransactionType;
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Models\Expense;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ExpenseService
|
||||
{
|
||||
public function getAll(): Collection
|
||||
{
|
||||
return Expense::with('createdBy.userProfile')
|
||||
->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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
];
|
||||
|
||||
@ -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<CashTransaction>[] {
|
||||
header: () => <span>Oleh</span>,
|
||||
cell: ({ row }) => {
|
||||
const createdBy = row.original.created_by;
|
||||
console.log(createdBy)
|
||||
|
||||
return <span>{createdBy?.user_profile?.full_name ?? '-'}</span>;
|
||||
},
|
||||
|
||||
172
resources/js/pages/admin/finance/expense/columns.tsx
Normal file
172
resources/js/pages/admin/finance/expense/columns.tsx
Normal file
@ -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<Expense>[] {
|
||||
const { handleEdit, handleDeleteClick } = params;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
headerClassName: 'w-[50px] text-center',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>Tanggal</span>
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>{formatDate(row.getValue('created_at') as string)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: () => <span>Keterangan</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="max-w-[200px] truncate block">{row.getValue('description') as string}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>Jumlah</span>
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-red-600 font-medium">
|
||||
- {formatCurrency(row.getValue('amount') as number)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'created_by',
|
||||
header: () => <span>Oleh</span>,
|
||||
cell: ({ row }) => {
|
||||
const createdBy = row.original.created_by;
|
||||
|
||||
return <span>{createdBy?.user_profile?.full_name ?? '-'}</span>;
|
||||
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
meta: {
|
||||
className: 'w-[100px] text-center',
|
||||
headerClassName: 'w-[100px] text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const expense = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleEdit(expense)
|
||||
}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleDeleteClick(expense)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
219
resources/js/pages/admin/finance/expense/index.tsx
Normal file
219
resources/js/pages/admin/finance/expense/index.tsx
Normal file
@ -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<Expense | null>(null);
|
||||
const [deleting, setDeleting] = useState<Expense | null>(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 (
|
||||
<>
|
||||
<Head title="Pengeluaran" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Pengeluaran
|
||||
</h2>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Pengeluaran</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Jumlah{' '} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RupiahInput name="amount" min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
Keterangan{' '} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={expenses}
|
||||
searchKey="description"
|
||||
searchPlaceholder="Cari pengeluaran..."
|
||||
emptyText="Belum ada data pengeluaran."
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Pengeluaran</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Jumlah{' '} <span className="text-destructive">*</span></Label>
|
||||
<RupiahInput name="amount" defaultValue={editing.amount} min={1} />
|
||||
<InputError message={errors.amount} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-description">Keterangan{' '} <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="edit-description"
|
||||
name="description"
|
||||
placeholder="Masukkan keterangan"
|
||||
defaultValue={editing.description}
|
||||
/>
|
||||
<InputError message={errors.description} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ExpenseIndex.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Keuangan',
|
||||
href: expenseIndex(),
|
||||
},
|
||||
{
|
||||
title: 'Pengeluaran',
|
||||
href: expenseIndex(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\Finance\CashAccountController;
|
||||
use App\Http\Controllers\Admin\Finance\ExpenseController;
|
||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||
use App\Http\Controllers\Admin\Master\SupplierController;
|
||||
@ -21,6 +22,8 @@
|
||||
Route::get('cash-accounts', [CashAccountController::class, 'index'])->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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
183
tests/Feature/Admin/Finance/ExpenseTest.php
Normal file
183
tests/Feature/Admin/Finance/ExpenseTest.php
Normal file
@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Models\Expense;
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
test('guests are redirected to the login page', function () {
|
||||
$response = $this->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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user