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]
|
||||
protected function expense(Builder $query): void
|
||||
protected function expenseType(Builder $query): void
|
||||
{
|
||||
$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,
|
||||
SupplierSeeder::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 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 },
|
||||
|
||||
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 {
|
||||
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
|
||||
|
||||
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';
|
||||
|
||||
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