feat: implement cash transaction management with deposit and withdrawal requests, enhance UI components for better user experience

This commit is contained in:
Yoga Pangestu 2026-06-21 00:21:27 +07:00
parent 256b13ad73
commit 802c4e36c0
10 changed files with 76 additions and 37 deletions

View File

@ -5,9 +5,9 @@
use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\DepositCashRequest;
use App\Http\Requests\Admin\Finance\Cash\DepositRequest;
use App\Http\Requests\Admin\Finance\Cash\WithdrawRequest;
use App\Http\Requests\Admin\Finance\UpdateCashTransactionRequest;
use App\Http\Requests\Admin\Finance\WithdrawCashRequest;
use App\Models\CashTransaction;
use App\Services\Finance\CashService;
use Illuminate\Http\RedirectResponse;
@ -26,16 +26,19 @@ public function __construct(
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$referenceType = $request->string('reference_type')->toString();
$cashAccount = $this->cashService->getDefaultAccount();
return Inertia::render('admin/finance/cash/Index', [
'cashAccount' => $cashAccount,
'transactions' => $this->cashService->paginateForIndex($cashAccount, $tableQuery),
'filters' => $this->dataTableFilters($tableQuery),
'transactions' => $this->cashService->paginateForIndex($cashAccount, $tableQuery, $referenceType),
'filters' => $this->dataTableFilters($tableQuery, [
'reference_type' => $referenceType,
]),
]);
}
public function deposit(DepositCashRequest $request): RedirectResponse
public function deposit(DepositRequest $request): RedirectResponse
{
$cashAccount = $this->cashService->getDefaultAccount();
@ -46,7 +49,7 @@ public function deposit(DepositCashRequest $request): RedirectResponse
return redirect()->route('admin.finance.cash.index');
}
public function withdraw(WithdrawCashRequest $request): RedirectResponse
public function withdraw(WithdrawRequest $request): RedirectResponse
{
$cashAccount = $this->cashService->getDefaultAccount();

View File

@ -1,12 +1,12 @@
<?php
namespace App\Http\Requests\Admin\Finance;
namespace App\Http\Requests\Admin\Finance\Cash;
use App\Enums\Permission;
use App\Http\Requests\Concerns\ValidatesMediaUploads;
use Illuminate\Foundation\Http\FormRequest;
class DepositCashRequest extends FormRequest
class DepositRequest extends FormRequest
{
use ValidatesMediaUploads;

View File

@ -1,12 +1,12 @@
<?php
namespace App\Http\Requests\Admin\Finance;
namespace App\Http\Requests\Admin\Finance\Cash;
use App\Enums\Permission;
use App\Http\Requests\Concerns\ValidatesMediaUploads;
use Illuminate\Foundation\Http\FormRequest;
class WithdrawCashRequest extends FormRequest
class WithdrawRequest extends FormRequest
{
use ValidatesMediaUploads;

View File

@ -21,6 +21,7 @@
'balance_after_formatted',
'reference_label',
'is_incoming',
'source_badge_class',
'created_at_formatted',
'created_by_name',
])]
@ -93,6 +94,20 @@ public function isIncoming(): Attribute
);
}
public function sourceBadgeClass(): Attribute
{
return Attribute::make(
get: fn () => match ($this->reference_type) {
null => 'bg-blue-100 text-blue-700 hover:bg-blue-100',
Expense::class => 'bg-red-100 text-red-700 hover:bg-red-100',
EmployeeAdvance::class => 'bg-amber-100 text-amber-700 hover:bg-amber-100',
Payroll::class => 'bg-green-100 text-green-700 hover:bg-green-100',
Order::class => 'bg-purple-100 text-purple-700 hover:bg-purple-100',
default => 'bg-gray-100 text-gray-700 hover:bg-gray-100',
},
);
}
public function referenceLabel(): Attribute
{
return Attribute::make(

View File

@ -32,7 +32,7 @@ public function getDefaultAccount(): CashAccount
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(CashAccount $cashAccount, array $tableQuery): LengthAwarePaginator
public function paginateForIndex(CashAccount $cashAccount, array $tableQuery, string $referenceType = ''): LengthAwarePaginator
{
$query = CashTransaction::query()
->with(['createdBy.profile', 'reference', 'media'])
@ -42,6 +42,13 @@ public function paginateForIndex(CashAccount $cashAccount, array $tableQuery): L
$query->where(function (Builder $query) use ($search): void {
$query->where('description', 'like', "%{$search}%");
});
})
->when($referenceType !== '', function (Builder $query) use ($referenceType): void {
if ($referenceType === 'manual') {
$query->whereNull('reference_type');
} else {
$query->where('reference_type', $referenceType);
}
});
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);

View File

@ -2,8 +2,6 @@
import { Head } from '@inertiajs/vue3';
import { ArrowDownCircle, ArrowUpCircle, Wallet } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import CashTransactionFormModal from '@/components/admin/finance/cash/CashTransactionFormModal.vue';
import { createColumns } from '@/components/admin/finance/cash/columns';
import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -11,7 +9,9 @@ import { useCan } from '@/composables/useCan';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CashAccount, CashTransactionListItem, PaginatedCashTransactions } from '@/types/cash';
import type { DataTableSort } from '@/types/data-table';
import type { DataTableFilterDef, DataTableSort } from '@/types/data-table';
import CashTransactionFormModal from './form/CashTransactionFormModal.vue';
import { createColumns } from './table/columns';
const props = defineProps<{
cashAccount: CashAccount;
@ -20,6 +20,7 @@ const props = defineProps<{
search: string;
sort?: string;
direction?: 'asc' | 'desc';
reference_type?: string;
};
}>();
@ -29,9 +30,10 @@ const formModalOpen = ref(false);
const formModalMode = ref<'deposit' | 'withdrawal'>('deposit');
const editingTransaction = ref<CashTransactionListItem | null>(null);
const { query, setSearch, setSort, resetFilters, syncFromServer } = useDataTableQuery({
const { query, setSearch, setSort, setFilter, resetFilters, syncFromServer } = useDataTableQuery({
url: '/admin/finance/cash',
initial: { ...props.filters },
filterKeys: ['reference_type'],
});
useDataTableQuerySync(() => props.filters, syncFromServer);
@ -49,6 +51,25 @@ const currentSort = computed<DataTableSort | null>(() => {
};
});
const filterDefs = computed<DataTableFilterDef[]>(() => [
{
key: 'reference_type',
label: 'Sumber',
type: 'select',
options: [
{ value: 'manual', label: 'Setor/Tarik Kas' },
{ value: 'App\\Models\\Expense', label: 'Pengeluaran' },
{ value: 'App\\Models\\EmployeeAdvance', label: 'Kasbon Pegawai' },
{ value: 'App\\Models\\Payroll', label: 'Gaji Pegawai' },
{ value: 'App\\Models\\Order', label: 'Pesanan' },
],
},
]);
const filterValues = computed(() => ({
reference_type: query.value.reference_type ?? '',
}));
const pagination = computed(() => ({
currentPage: props.transactions.current_page,
perPage: props.transactions.per_page,
@ -119,17 +140,14 @@ watch(
<Card class="min-w-0">
<CardContent class="min-w-0 pt-6">
<DataTable v-model:search="search" :columns="columns" :data="transactions.data"
:pagination="pagination" :pagination-links="transactions.links" :sort="currentSort"
@sort-change="setSort" @filters-reset="resetFilters" />
<DataTable v-model:search="search" :columns="columns" :data="transactions.data" :pagination="pagination"
:pagination-links="transactions.links" :sort="currentSort" :filter-defs="filterDefs"
:filter-values="filterValues" @sort-change="setSort" @filter-change="setFilter"
@filters-reset="resetFilters" />
</CardContent>
</Card>
<CashTransactionFormModal
v-if="can('cash.deposit') || can('cash.withdraw') || can('cash.update')"
v-model:open="formModalOpen"
:transaction="editingTransaction"
:mode="formModalMode"
/>
<CashTransactionFormModal v-if="can('cash.deposit') || can('cash.withdraw') || can('cash.update')"
v-model:open="formModalOpen" :transaction="editingTransaction" :mode="formModalMode" />
</AdminLayout>
</template>

View File

@ -168,9 +168,8 @@ const placeholder = computed(() =>
</Field>
<Field>
<FieldLabel for="cash-description" required>Keterangan</FieldLabel>
<Textarea id="cash-description" v-model="form.description"
:placeholder="placeholder" rows="3"
:maxlength="FIELD_LIMITS.description" />
<Textarea id="cash-description" v-model="form.description" :placeholder="placeholder"
rows="3" :maxlength="FIELD_LIMITS.description" />
<FieldError :errors="formErrors(form, 'description')" />
</Field>
<ImageUploadField id="cash-photos" v-model="photoFile" label="Foto Bukti" required

View File

@ -1,10 +1,10 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import DataTableActions from '@/components/admin/finance/cash/data-table-actions.vue';
import { DataTableColumnHeader } from '@/components/data-table';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Badge } from '@/components/ui/badge';
import type { CashTransactionListItem } from '@/types/cash';
import DataTableActions from './data-table-actions.vue';
export function createColumns(onEdit: (transaction: CashTransactionListItem) => void): ColumnDef<CashTransactionListItem>[] {
return [
@ -17,15 +17,11 @@ export function createColumns(onEdit: (transaction: CashTransactionListItem) =>
accessorKey: 'reference_label',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Sumber', column: 'reference_type' }),
cell: ({ row }) => {
const isIncoming = row.original.is_incoming;
return h(
Badge,
{ variant: isIncoming ? 'default' : 'secondary' },
() => row.original.reference_label,
);
},
cell: ({ row }) => h(
Badge,
{ class: row.original.source_badge_class },
() => row.original.reference_label,
),
},
{
accessorKey: 'amount_formatted',

View File

@ -13,6 +13,7 @@ export type CashTransactionListItem = {
reference_type: string | null;
reference_label: string;
is_incoming: boolean;
source_badge_class: string;
amount: number;
amount_formatted: string;
balance_after: number;