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

View File

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

View File

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

View File

@ -21,6 +21,7 @@
'balance_after_formatted', 'balance_after_formatted',
'reference_label', 'reference_label',
'is_incoming', 'is_incoming',
'source_badge_class',
'created_at_formatted', 'created_at_formatted',
'created_by_name', '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 public function referenceLabel(): Attribute
{ {
return Attribute::make( return Attribute::make(

View File

@ -32,7 +32,7 @@ public function getDefaultAccount(): CashAccount
/** /**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery * @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() $query = CashTransaction::query()
->with(['createdBy.profile', 'reference', 'media']) ->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(function (Builder $query) use ($search): void {
$query->where('description', 'like', "%{$search}%"); $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']); $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);

View File

@ -2,8 +2,6 @@
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { ArrowDownCircle, ArrowUpCircle, Wallet } from '@lucide/vue'; import { ArrowDownCircle, ArrowUpCircle, Wallet } from '@lucide/vue';
import { computed, ref, watch } from '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 { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import type { CashAccount, CashTransactionListItem, PaginatedCashTransactions } from '@/types/cash'; 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<{ const props = defineProps<{
cashAccount: CashAccount; cashAccount: CashAccount;
@ -20,6 +20,7 @@ const props = defineProps<{
search: string; search: string;
sort?: string; sort?: string;
direction?: 'asc' | 'desc'; direction?: 'asc' | 'desc';
reference_type?: string;
}; };
}>(); }>();
@ -29,9 +30,10 @@ const formModalOpen = ref(false);
const formModalMode = ref<'deposit' | 'withdrawal'>('deposit'); const formModalMode = ref<'deposit' | 'withdrawal'>('deposit');
const editingTransaction = ref<CashTransactionListItem | null>(null); 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', url: '/admin/finance/cash',
initial: { ...props.filters }, initial: { ...props.filters },
filterKeys: ['reference_type'],
}); });
useDataTableQuerySync(() => props.filters, syncFromServer); 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(() => ({ const pagination = computed(() => ({
currentPage: props.transactions.current_page, currentPage: props.transactions.current_page,
perPage: props.transactions.per_page, perPage: props.transactions.per_page,
@ -119,17 +140,14 @@ watch(
<Card class="min-w-0"> <Card class="min-w-0">
<CardContent class="min-w-0 pt-6"> <CardContent class="min-w-0 pt-6">
<DataTable v-model:search="search" :columns="columns" :data="transactions.data" <DataTable v-model:search="search" :columns="columns" :data="transactions.data" :pagination="pagination"
:pagination="pagination" :pagination-links="transactions.links" :sort="currentSort" :pagination-links="transactions.links" :sort="currentSort" :filter-defs="filterDefs"
@sort-change="setSort" @filters-reset="resetFilters" /> :filter-values="filterValues" @sort-change="setSort" @filter-change="setFilter"
@filters-reset="resetFilters" />
</CardContent> </CardContent>
</Card> </Card>
<CashTransactionFormModal <CashTransactionFormModal v-if="can('cash.deposit') || can('cash.withdraw') || can('cash.update')"
v-if="can('cash.deposit') || can('cash.withdraw') || can('cash.update')" v-model:open="formModalOpen" :transaction="editingTransaction" :mode="formModalMode" />
v-model:open="formModalOpen"
:transaction="editingTransaction"
:mode="formModalMode"
/>
</AdminLayout> </AdminLayout>
</template> </template>

View File

@ -168,9 +168,8 @@ const placeholder = computed(() =>
</Field> </Field>
<Field> <Field>
<FieldLabel for="cash-description" required>Keterangan</FieldLabel> <FieldLabel for="cash-description" required>Keterangan</FieldLabel>
<Textarea id="cash-description" v-model="form.description" <Textarea id="cash-description" v-model="form.description" :placeholder="placeholder"
:placeholder="placeholder" rows="3" rows="3" :maxlength="FIELD_LIMITS.description" />
:maxlength="FIELD_LIMITS.description" />
<FieldError :errors="formErrors(form, 'description')" /> <FieldError :errors="formErrors(form, 'description')" />
</Field> </Field>
<ImageUploadField id="cash-photos" v-model="photoFile" label="Foto Bukti" required <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 type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue'; import { h } from 'vue';
import DataTableActions from '@/components/admin/finance/cash/data-table-actions.vue';
import { DataTableColumnHeader } from '@/components/data-table'; import { DataTableColumnHeader } from '@/components/data-table';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue'; import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import type { CashTransactionListItem } from '@/types/cash'; import type { CashTransactionListItem } from '@/types/cash';
import DataTableActions from './data-table-actions.vue';
export function createColumns(onEdit: (transaction: CashTransactionListItem) => void): ColumnDef<CashTransactionListItem>[] { export function createColumns(onEdit: (transaction: CashTransactionListItem) => void): ColumnDef<CashTransactionListItem>[] {
return [ return [
@ -17,15 +17,11 @@ export function createColumns(onEdit: (transaction: CashTransactionListItem) =>
accessorKey: 'reference_label', accessorKey: 'reference_label',
enableSorting: true, enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Sumber', column: 'reference_type' }), header: () => h(DataTableColumnHeader, { title: 'Sumber', column: 'reference_type' }),
cell: ({ row }) => { cell: ({ row }) => h(
const isIncoming = row.original.is_incoming; Badge,
{ class: row.original.source_badge_class },
return h( () => row.original.reference_label,
Badge, ),
{ variant: isIncoming ? 'default' : 'secondary' },
() => row.original.reference_label,
);
},
}, },
{ {
accessorKey: 'amount_formatted', accessorKey: 'amount_formatted',

View File

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