feat: implement transaction type filtering in CashAccount management

This commit is contained in:
Yoga Pangestu 2026-07-30 12:40:26 +07:00
parent 0d90c5913e
commit 3f12a65b05
4 changed files with 340 additions and 8 deletions

View File

@ -7,6 +7,7 @@
use App\Models\CashTransaction;
use App\Services\Admin\Finance\CashAccountService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
@ -16,13 +17,16 @@ public function __construct(
private CashAccountService $service
) {}
public function index(): Response
public function index(Request $request): Response
{
$cashAccount = $this->service->get();
return Inertia::render('admin/finance/cash-account/index', [
'cashAccount' => $cashAccount,
'transactions' => $this->service->getAllTransactions(),
'transactions' => $this->service->getAllTransactions(
$request->only(['type'])
),
'filters' => $request->only(['type']),
]);
}

View File

@ -23,7 +23,7 @@ public function get(): ?CashAccount
return CashAccount::select('id', 'name', 'balance')->first();
}
public function getAllTransactions(): Collection
public function getAllTransactions(array $filters = []): Collection
{
$cashAccount = $this->get();
@ -34,6 +34,9 @@ public function getAllTransactions(): Collection
return $cashAccount->cashTransactions()
->select('id', 'created_by_id', 'reference_type', 'reference_id', 'amount', 'balance_after', 'type', 'description', 'created_at')
->with('createdBy.userProfile', 'media')
->when($filters['type'] ?? null, function ($query, $type) {
$query->where('type', $type);
})
->latest()
->get()
->map(fn (CashTransaction $transaction) => $this->formatTransaction($transaction));

View File

@ -1,6 +1,9 @@
import { Form, Head, router } from '@inertiajs/react';
import { ArrowDownToLine, ArrowUpFromLine, Wallet } from 'lucide-react';
import { ArrowDownToLine, ArrowUpFromLine, Filter, Wallet, X } from 'lucide-react';
import { useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { DataTable } from '@/components/data-table';
import { FileUpload } from '@/components/file-upload';
import InputError from '@/components/input-error';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
@ -14,14 +17,13 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { formatCurrency } from '@/lib/utils';
import { index as cashAccountIndex, deposit, withdrawal } from '@/routes/admin/finance/cash-accounts';
import { update as updateTransaction, destroy as destroyTransaction } from '@/routes/admin/finance/cash-accounts/transactions';
import { createTransactionColumns } from './transaction-columns';
import type { CashTransaction } from './transaction-columns';
import { DataTable } from '@/components/data-table';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload';
type CashAccount = {
id: number;
@ -32,13 +34,17 @@ type CashAccount = {
type Props = {
cashAccount: CashAccount | null;
transactions: CashTransaction[];
filters: {
type?: string;
};
};
export default function CashAccountIndex({ cashAccount, transactions }: Props) {
export default function CashAccountIndex({ cashAccount, transactions, filters }: Props) {
const [depositOpen, setDepositOpen] = useState(false);
const [withdrawalOpen, setWithdrawalOpen] = useState(false);
const [editing, setEditing] = useState<CashTransaction | null>(null);
const [deleting, setDeleting] = useState<CashTransaction | null>(null);
const [filterOpen, setFilterOpen] = useState(false);
const [depositReceiptKey, setDepositReceiptKey] = useState<string | null>(null);
const [depositUploading, setDepositUploading] = useState(false);
@ -52,6 +58,31 @@ export default function CashAccountIndex({ cashAccount, transactions }: Props) {
const [editUploading, setEditUploading] = useState(false);
const [editFileMeta, setEditFileMeta] = useState<{ size: number; type: string } | null>(null);
const hasActiveFilters = filters.type;
function applyFilter(key: string, value: string) {
const newFilters = { ...filters };
if (value === '' || value === 'all') {
delete newFilters[key as keyof typeof newFilters];
} else {
newFilters[key as keyof typeof newFilters] = value;
}
router.get(cashAccountIndex(), newFilters, {
preserveState: true,
replace: true,
});
}
function clearFilters() {
router.get(cashAccountIndex(), {}, {
preserveState: true,
replace: true,
});
setFilterOpen(false);
}
function handleDelete() {
if (!deleting) {
return;
@ -70,6 +101,61 @@ export default function CashAccountIndex({ cashAccount, transactions }: Props) {
handleDeleteClick: (transaction) => setDeleting(transaction),
});
const filterToolbar = (
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Filter className="h-4 w-4" />
Filter
{hasActiveFilters && (
<span className="ml-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{Object.values(filters).filter(Boolean).length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Filter</span>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={clearFilters}
>
<X className="mr-1 h-3 w-3" />
Hapus Semua
</Button>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Tipe Sumber
</label>
<Select
value={filters.type ?? 'all'}
onValueChange={(value) => applyFilter('type', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Tipe" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Tipe</SelectItem>
<SelectItem value="deposit">Deposit</SelectItem>
<SelectItem value="withdrawal">Withdrawal</SelectItem>
<SelectItem value="expense">Pengeluaran</SelectItem>
<SelectItem value="transfer">Transfer</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
);
return (
<>
<Head title="Kas Toko" />
@ -113,6 +199,7 @@ export default function CashAccountIndex({ cashAccount, transactions }: Props) {
searchKey="description"
searchPlaceholder="Cari transaksi..."
emptyText="Belum ada riwayat transaksi."
toolbar={filterToolbar}
/>
<Dialog open={depositOpen} onOpenChange={(open) => {

View File

@ -75,6 +75,244 @@
);
});
/*
|--------------------------------------------------------------------------
| FILTER - TRANSACTION TYPE
|--------------------------------------------------------------------------
*/
test('index can filter by type deposit', function () {
$user = User::factory()->create();
$this->actingAs($user);
$cashAccount = CashAccount::factory()->create(['balance' => 200000]);
CashTransaction::factory()->create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => $user->id,
'type' => CashTransactionType::DEPOSIT,
'amount' => 100000,
'balance_after' => 100000,
'description' => 'Deposit pertama',
]);
CashTransaction::factory()->create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => $user->id,
'type' => CashTransactionType::WITHDRAWAL,
'amount' => 50000,
'balance_after' => 200000,
'description' => 'Withdrawal pertama',
]);
CashTransaction::factory()->create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => $user->id,
'type' => CashTransactionType::DEPOSIT,
'amount' => 100000,
'balance_after' => 200000,
'description' => 'Deposit kedua',
]);
$response = $this->get(route('admin.finance.cash-accounts.index', ['type' => 'deposit']));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/finance/cash-account/index')
->has('transactions', 2)
);
});
test('index can filter by type withdrawal', function () {
$user = User::factory()->create();
$this->actingAs($user);
$cashAccount = CashAccount::factory()->create(['balance' => 100000]);
CashTransaction::factory()->create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => $user->id,
'type' => CashTransactionType::DEPOSIT,
'amount' => 100000,
'balance_after' => 100000,
'description' => 'Deposit',
]);
CashTransaction::factory()->create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => $user->id,
'type' => CashTransactionType::WITHDRAWAL,
'amount' => 30000,
'balance_after' => 100000,
'description' => 'Withdrawal pertama',
]);
CashTransaction::factory()->create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => $user->id,
'type' => CashTransactionType::WITHDRAWAL,
'amount' => 20000,
'balance_after' => 100000,
'description' => 'Withdrawal kedua',
]);
$response = $this->get(route('admin.finance.cash-accounts.index', ['type' => 'withdrawal']));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/finance/cash-account/index')
->has('transactions', 2)
);
});
test('index can filter by type expense', function () {
$user = User::factory()->create();
$this->actingAs($user);
$cashAccount = CashAccount::factory()->create(['balance' => 100000]);
CashTransaction::factory()->create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => $user->id,
'type' => CashTransactionType::DEPOSIT,
'amount' => 100000,
'balance_after' => 100000,
'description' => 'Deposit',
]);
CashTransaction::factory()->create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => $user->id,
'type' => CashTransactionType::EXPENSE,
'amount' => 25000,
'balance_after' => 100000,
'description' => 'Pengeluaran',
]);
$response = $this->get(route('admin.finance.cash-accounts.index', ['type' => 'expense']));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/finance/cash-account/index')
->has('transactions', 1)
);
});
test('index can filter by type transfer', function () {
$user = User::factory()->create();
$this->actingAs($user);
$cashAccount = CashAccount::factory()->create(['balance' => 100000]);
CashTransaction::factory()->create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => $user->id,
'type' => CashTransactionType::DEPOSIT,
'amount' => 100000,
'balance_after' => 100000,
'description' => 'Deposit',
]);
CashTransaction::factory()->create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => $user->id,
'type' => CashTransactionType::TRANSFER,
'amount' => 50000,
'balance_after' => 100000,
'description' => 'Transfer',
]);
$response = $this->get(route('admin.finance.cash-accounts.index', ['type' => 'transfer']));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/finance/cash-account/index')
->has('transactions', 1)
);
});
test('index without type filter shows all transactions', function () {
$user = User::factory()->create();
$this->actingAs($user);
$cashAccount = CashAccount::factory()->create(['balance' => 200000]);
CashTransaction::factory()->create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => $user->id,
'type' => CashTransactionType::DEPOSIT,
'amount' => 100000,
'balance_after' => 100000,
'description' => 'Deposit',
]);
CashTransaction::factory()->create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => $user->id,
'type' => CashTransactionType::WITHDRAWAL,
'amount' => 50000,
'balance_after' => 200000,
'description' => 'Withdrawal',
]);
CashTransaction::factory()->create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => $user->id,
'type' => CashTransactionType::EXPENSE,
'amount' => 30000,
'balance_after' => 200000,
'description' => 'Expense',
]);
$response = $this->get(route('admin.finance.cash-accounts.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/finance/cash-account/index')
->has('transactions', 3)
);
});
test('index passes type filter to view', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('admin.finance.cash-accounts.index', ['type' => 'deposit']));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('filters.type', 'deposit')
);
});
test('index passes empty filters when no type specified', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('admin.finance.cash-accounts.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('filters', [])
);
});
test('index with invalid type filter shows no transactions', function () {
$user = User::factory()->create();
$this->actingAs($user);
$cashAccount = CashAccount::factory()->create(['balance' => 100000]);
CashTransaction::factory()->create([
'cash_account_id' => $cashAccount->id,
'created_by_id' => $user->id,
'type' => CashTransactionType::DEPOSIT,
'amount' => 100000,
'balance_after' => 100000,
'description' => 'Deposit',
]);
$response = $this->get(route('admin.finance.cash-accounts.index', ['type' => 'invalid_type']));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/finance/cash-account/index')
->has('transactions', 0)
);
});
/*
|--------------------------------------------------------------------------
| DEPOSIT