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.
This commit is contained in:
Yoga Pangestu 2026-07-29 02:08:39 +07:00
parent 46644428fc
commit c3d8ea2f66
17 changed files with 1128 additions and 2 deletions

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Admin\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\CashTransactionRequest;
use App\Services\Admin\Finance\CashAccountService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class CashAccountController extends Controller
{
public function __construct(
private CashAccountService $service
) {}
public function index(): Response
{
$cashAccount = $this->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');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Admin\Finance;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CashAccountRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$cashAccount = $this->route('cash_account');
return [
'name' => ['required', 'string', 'max:200', Rule::unique('cash_accounts', 'name')->ignore($cashAccount)],
];
}
public function attributes(): array
{
return [
'name' => 'nama',
];
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests\Admin\Finance;
use Illuminate\Foundation\Http\FormRequest;
use Override;
class CashTransactionRequest 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',
];
}
}

View File

@ -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);
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Services\Admin\Finance;
use App\Enums\CashTransactionType;
use App\Models\CashAccount;
use App\Models\CashTransaction;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
class CashAccountService
{
public function get(): ?CashAccount
{
return CashAccount::first();
}
public function getAllTransactions(): Collection
{
$cashAccount = $this->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'],
]);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Database\Seeders;
use App\Models\CashAccount;
use Illuminate\Database\Seeder;
class CashAccountSeeder extends Seeder
{
public function run(): void
{
CashAccount::factory()->count(1)->create();
}
}

View File

@ -19,6 +19,7 @@ public function run(): void
CategorySeeder::class,
SupplierSeeder::class,
CustomerSeeder::class,
CashAccountSeeder::class,
]);
}
}

View File

@ -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 },

View File

@ -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<HTMLInputElement>) => {
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 (
<InputGroup className={className}>
<InputGroupAddon>
<InputGroupText>Rp</InputGroupText>
</InputGroupAddon>
<InputGroupInput
name={name}
type="text"
inputMode="numeric"
value={displayValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
/>
</InputGroup>
);
}

View File

@ -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 (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex w-full items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30",
"h-9 min-w-0 has-[>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<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
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<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -10,3 +10,11 @@ export function cn(...inputs: ClassValue[]) {
export function toUrl(url: NonNullable<InertiaLinkProps['href']>): string {
return typeof url === 'string' ? url : url.url;
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(amount);
}

View File

@ -0,0 +1,138 @@
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 CashAccount = {
id: number;
name: string;
balance: number;
};
type CreateColumnsParams = {
handleEdit: (cashAccount: CashAccount) => void;
handleDeleteClick: (cashAccount: CashAccount) => void;
};
export function createCashAccountColumns(
params: CreateColumnsParams,
): ColumnDef<CashAccount>[] {
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: 'name',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Nama</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-medium">
{row.getValue('name') as string}
</span>
),
},
{
accessorKey: 'balance',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Saldo</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-medium">
{formatCurrency(row.getValue('balance') as number)}
</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 cashAccount = row.original;
return (
<TooltipProvider>
<div className="flex items-center justify-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleEdit(cashAccount)
}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Edit
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
handleDeleteClick(cashAccount)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Hapus
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
},
];
}

View File

@ -0,0 +1,182 @@
import { Form, Head } from '@inertiajs/react';
import { ArrowDownToLine, ArrowUpFromLine, Wallet } 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { formatCurrency } from '@/lib/utils';
import { index as cashAccountIndex, deposit, withdrawal } from '@/routes/admin/finance/cash-accounts';
import { createTransactionColumns } from './transaction-columns';
import type { CashTransaction } from './transaction-columns';
import { DataTable } from '@/components/data-table';
type CashAccount = {
id: number;
name: string;
balance: number;
};
type Props = {
cashAccount: CashAccount | null;
transactions: CashTransaction[];
};
export default function CashAccountIndex({ cashAccount, transactions }: Props) {
const [depositOpen, setDepositOpen] = useState(false);
const [withdrawalOpen, setWithdrawalOpen] = useState(false);
const columns = createTransactionColumns();
return (
<>
<Head title="Kas Toko" />
<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">
Kas Toko
</h2>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setDepositOpen(true)}>
<ArrowDownToLine className="h-4 w-4" />
Deposit
</Button>
<Button variant="outline" onClick={() => setWithdrawalOpen(true)}>
<ArrowUpFromLine className="h-4 w-4" />
Withdrawal
</Button>
</div>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Saldo saat ini
</CardTitle>
<Wallet className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatCurrency(cashAccount?.balance ?? 0)}
</div>
</CardContent>
</Card>
<DataTable
columns={columns}
data={transactions}
searchKey="description"
searchPlaceholder="Cari transaksi..."
emptyText="Belum ada riwayat transaksi."
/>
<Dialog open={depositOpen} onOpenChange={setDepositOpen}>
<DialogContent>
<Form action={deposit()} resetOnSuccess onSuccess={() => setDepositOpen(false)}>
{({ errors, processing }) => (
<>
<DialogHeader>
<DialogTitle>Deposit</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>
Keterangan <span className="text-destructive">*</span>
</Label>
<Input
name="description"
placeholder="Masukkan keterangan"
/>
<InputError message={errors.description} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDepositOpen(false)}>
Batal
</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</DialogFooter>
</>
)}
</Form>
</DialogContent>
</Dialog>
<Dialog open={withdrawalOpen} onOpenChange={setWithdrawalOpen}>
<DialogContent>
<Form action={withdrawal()} resetOnSuccess onSuccess={() => setWithdrawalOpen(false)}>
{({ errors, processing }) => (
<>
<DialogHeader>
<DialogTitle>Withdrawal</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>
Keterangan <span className="text-destructive">*</span>
</Label>
<Input
name="description"
placeholder="Masukkan keterangan"
/>
<InputError message={errors.description} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setWithdrawalOpen(false)}>
Batal
</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</DialogFooter>
</>
)}
</Form>
</DialogContent>
</Dialog>
</div>
</>
);
}
CashAccountIndex.layout = {
breadcrumbs: [
{
title: 'Keuangan',
href: cashAccountIndex(),
},
{
title: 'Kas Toko',
href: cashAccountIndex(),
},
],
};

View File

@ -0,0 +1,170 @@
import type { ColumnDef } from '@tanstack/react-table';
import { ArrowUpDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { formatCurrency } from '@/lib/utils';
export type CashTransaction = {
id: number;
amount: number;
balance_after: number;
type: 'deposit' | 'withdrawal' | 'expense' | 'transfer';
description: string;
created_at: string;
created_by: {
name: string;
};
reference: {
type: string;
} | null;
};
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 getTypeLabel(type: string): string {
const labels: Record<string, string> = {
deposit: 'Deposit',
withdrawal: 'Withdrawal',
expense: 'Pengeluaran',
transfer: 'Transfer',
};
return labels[type] ?? type;
}
function getReferenceLabel(type: string): string {
const labels: Record<string, string> = {
'App\\Models\\Expense': 'Pengeluaran',
'App\\Models\\Order': 'Penjualan Tunai',
'App\\Models\\Purchase': 'Pembelian',
'App\\Models\\CashAccount': 'Transfer Kas',
};
return labels[type] ?? '-';
}
export function createTransactionColumns(): ColumnDef<CashTransaction>[] {
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>
),
},
{
id: 'source',
header: () => <span>Sumber</span>,
cell: ({ row }) => {
const transaction = row.original;
return (
<div className="flex flex-col">
<span className="font-medium">{getTypeLabel(transaction.type)}</span>
<span className="text-xs text-muted-foreground">{getReferenceLabel(transaction.reference?.type ?? '')}</span>
</div>
);
},
},
{
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 }) => {
const transaction = row.original;
const isDeposit = transaction.type === 'deposit';
return (
<span className={isDeposit ? 'text-green-600 font-medium' : 'text-red-600 font-medium'}>
{isDeposit ? '+' : '-'} {formatCurrency(row.getValue('amount') as number)}
</span>
);
},
},
{
accessorKey: 'balance_after',
header: ({ column }) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === 'asc',
)
}
>
<span>Saldo Setelah</span>
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span className="font-medium">{formatCurrency(row.getValue('balance_after') as number)}</span>
),
},
{
accessorKey: 'description',
header: () => <span>Keterangan</span>,
cell: ({ row }) => (
<span className="max-w-[200px] truncate block">{row.getValue('description') as string}</span>
),
},
{
id: 'created_by',
header: () => <span>Oleh</span>,
cell: ({ row }) => {
const createdBy = row.original.created_by;
console.log(createdBy)
return <span>{createdBy?.user_profile?.full_name ?? '-'}</span>;
},
},
];
}

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Admin\Finance\CashAccountController;
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController;
use App\Http\Controllers\Admin\Master\SupplierController;
@ -15,6 +16,12 @@
Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']);
Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit']);
});
Route::prefix('admin/finance')->name('admin.finance.')->group(function () {
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');
});
});
require __DIR__.'/settings.php';

View File

@ -0,0 +1,153 @@
<?php
use App\Models\CashAccount;
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.cash-accounts.index'));
$response->assertRedirect(route('login'));
});
test('authenticated users can visit the cash account index page', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('admin.finance.cash-accounts.index'));
$response->assertOk();
});
test('cash account index page displays cash account and transactions', function () {
$user = User::factory()->create();
$this->actingAs($user);
$cashAccount = CashAccount::factory()->create(['balance' => 500000]);
$response = $this->get(route('admin.finance.cash-accounts.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('admin/finance/cash-account/index')
->has('cashAccount')
->has('transactions')
);
});
test('deposit can be made', function () {
$user = User::factory()->create();
$this->actingAs($user);
CashAccount::factory()->create(['balance' => 100000]);
$response = $this->post(route('admin.finance.cash-accounts.deposit'), [
'amount' => 50000,
'description' => 'Deposit test',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.finance.cash-accounts.index'));
expect(CashAccount::first()->balance)->toBe(150000);
});
test('deposit amount is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
CashAccount::factory()->create();
$response = $this->post(route('admin.finance.cash-accounts.deposit'), [
'amount' => '',
'description' => 'Test',
]);
$response->assertSessionHasErrors('amount');
});
test('deposit amount must be at least 1', function () {
$user = User::factory()->create();
$this->actingAs($user);
CashAccount::factory()->create();
$response = $this->post(route('admin.finance.cash-accounts.deposit'), [
'amount' => 0,
'description' => 'Test',
]);
$response->assertSessionHasErrors('amount');
});
test('deposit description is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
CashAccount::factory()->create();
$response = $this->post(route('admin.finance.cash-accounts.deposit'), [
'amount' => 50000,
'description' => '',
]);
$response->assertSessionHasErrors('description');
});
test('withdrawal can be made', function () {
$user = User::factory()->create();
$this->actingAs($user);
CashAccount::factory()->create(['balance' => 100000]);
$response = $this->post(route('admin.finance.cash-accounts.withdrawal'), [
'amount' => 30000,
'description' => 'Withdrawal test',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('admin.finance.cash-accounts.index'));
expect(CashAccount::first()->balance)->toBe(70000);
});
test('withdrawal fails when balance is insufficient', function () {
$user = User::factory()->create();
$this->actingAs($user);
CashAccount::factory()->create(['balance' => 50000]);
$response = $this->post(route('admin.finance.cash-accounts.withdrawal'), [
'amount' => 100000,
'description' => 'Withdrawal test',
]);
$response->assertSessionHasErrors('amount');
});
test('withdrawal amount is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
CashAccount::factory()->create();
$response = $this->post(route('admin.finance.cash-accounts.withdrawal'), [
'amount' => '',
'description' => 'Test',
]);
$response->assertSessionHasErrors('amount');
});
test('withdrawal description is required', function () {
$user = User::factory()->create();
$this->actingAs($user);
CashAccount::factory()->create();
$response = $this->post(route('admin.finance.cash-accounts.withdrawal'), [
'amount' => 50000,
'description' => '',
]);
$response->assertSessionHasErrors('description');
});