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:
parent
46644428fc
commit
c3d8ea2f66
45
app/Http/Controllers/Admin/Finance/CashAccountController.php
Normal file
45
app/Http/Controllers/Admin/Finance/CashAccountController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Requests/Admin/Finance/CashAccountRequest.php
Normal file
30
app/Http/Requests/Admin/Finance/CashAccountRequest.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Http/Requests/Admin/Finance/CashTransactionRequest.php
Normal file
40
app/Http/Requests/Admin/Finance/CashTransactionRequest.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,7 +34,7 @@ protected function deposit(Builder $query): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[Scope]
|
#[Scope]
|
||||||
protected function expense(Builder $query): void
|
protected function expenseType(Builder $query): void
|
||||||
{
|
{
|
||||||
$query->where('type', CashTransactionType::EXPENSE);
|
$query->where('type', CashTransactionType::EXPENSE);
|
||||||
}
|
}
|
||||||
|
|||||||
71
app/Services/Admin/Finance/CashAccountService.php
Normal file
71
app/Services/Admin/Finance/CashAccountService.php
Normal 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'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
database/seeders/CashAccountSeeder.php
Normal file
14
database/seeders/CashAccountSeeder.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@ public function run(): void
|
|||||||
CategorySeeder::class,
|
CategorySeeder::class,
|
||||||
SupplierSeeder::class,
|
SupplierSeeder::class,
|
||||||
CustomerSeeder::class,
|
CustomerSeeder::class,
|
||||||
|
CashAccountSeeder::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import { dashboard } from '@/routes';
|
|||||||
import { index as categoriesIndex } from '@/routes/admin/master/categories';
|
import { index as categoriesIndex } from '@/routes/admin/master/categories';
|
||||||
import { index as customersIndex } from '@/routes/admin/master/customers';
|
import { index as customersIndex } from '@/routes/admin/master/customers';
|
||||||
import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
|
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 };
|
type NavMenuItem = { title: string; href: string; icon: LucideIcon };
|
||||||
|
|
||||||
@ -69,7 +70,7 @@ const kelolaItems: NavMenuItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const keuanganItems: NavMenuItem[] = [
|
const keuanganItems: NavMenuItem[] = [
|
||||||
{ title: 'Kas Toko', href: '#', icon: Wallet },
|
{ title: 'Kas Toko', href: cashAccountsIndex.url(), icon: Wallet },
|
||||||
{ title: 'Pengeluaran', href: '#', icon: ArrowUpFromLine },
|
{ title: 'Pengeluaran', href: '#', icon: ArrowUpFromLine },
|
||||||
{ title: 'Kasbon', href: '#', icon: HandCoins },
|
{ title: 'Kasbon', href: '#', icon: HandCoins },
|
||||||
{ title: 'Gaji', href: '#', icon: DollarSign },
|
{ title: 'Gaji', href: '#', icon: DollarSign },
|
||||||
|
|||||||
80
resources/js/components/rupiah-input.tsx
Normal file
80
resources/js/components/rupiah-input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
resources/js/components/ui/input-group.tsx
Normal file
168
resources/js/components/ui/input-group.tsx
Normal 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,
|
||||||
|
}
|
||||||
18
resources/js/components/ui/textarea.tsx
Normal file
18
resources/js/components/ui/textarea.tsx
Normal 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 }
|
||||||
@ -10,3 +10,11 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
export function toUrl(url: NonNullable<InertiaLinkProps['href']>): string {
|
export function toUrl(url: NonNullable<InertiaLinkProps['href']>): string {
|
||||||
return typeof url === 'string' ? url : url.url;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
138
resources/js/pages/admin/finance/cash-account/columns.tsx
Normal file
138
resources/js/pages/admin/finance/cash-account/columns.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
182
resources/js/pages/admin/finance/cash-account/index.tsx
Normal file
182
resources/js/pages/admin/finance/cash-account/index.tsx
Normal 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(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@ -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>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\Finance\CashAccountController;
|
||||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||||
use App\Http\Controllers\Admin\Master\SupplierController;
|
use App\Http\Controllers\Admin\Master\SupplierController;
|
||||||
@ -15,6 +16,12 @@
|
|||||||
Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']);
|
Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']);
|
||||||
Route::resource('customers', CustomerController::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';
|
require __DIR__.'/settings.php';
|
||||||
|
|||||||
153
tests/Feature/Admin/Finance/CashAccountTest.php
Normal file
153
tests/Feature/Admin/Finance/CashAccountTest.php
Normal 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');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user