feat: add cash withdrawal functionality, including permission, controller method, and UI updates

This commit is contained in:
Yoga Pangestu 2026-06-18 00:16:49 +07:00
parent cb61384a4e
commit 91f7858cfe
12 changed files with 183 additions and 16 deletions

View File

@ -0,0 +1,21 @@
<?php
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum CashTransactionType: string
{
use ProvidesEnumOptions;
case DEPOSIT = 'deposit';
case WITHDRAWAL = 'withdrawal';
public function label(): string
{
return match ($this) {
self::DEPOSIT => 'Setor Kas',
self::WITHDRAWAL => 'Tarik Kas',
};
}
}

View File

@ -78,6 +78,7 @@ enum Permission: string
case CASH_VIEW = 'cash.view';
case CASH_DEPOSIT = 'cash.deposit';
case CASH_WITHDRAW = 'cash.withdraw';
case CASH_UPDATE = 'cash.update';
case CASH_DELETE = 'cash.delete';
@ -179,6 +180,7 @@ public function label(): string
self::CASH_VIEW => 'Lihat Kas',
self::CASH_DEPOSIT => 'Setor Kas',
self::CASH_WITHDRAW => 'Tarik Kas',
self::CASH_UPDATE => 'Ubah Setor Kas',
self::CASH_DELETE => 'Hapus Setor Kas',
@ -237,7 +239,7 @@ public function group(): string
self::CUTTINGS_VIEW, self::CUTTINGS_CREATE, self::CUTTINGS_UPDATE,
self::CUTTINGS_DELETE, self::CUTTINGS_COMPLETE, self::CUTTINGS_VERIFY,
self::CUTTINGS_REJECT => 'Cutting',
self::CASH_VIEW, self::CASH_DEPOSIT, self::CASH_UPDATE, self::CASH_DELETE => 'Kas',
self::CASH_VIEW, self::CASH_DEPOSIT, self::CASH_WITHDRAW, self::CASH_UPDATE, self::CASH_DELETE => 'Kas',
self::EXPENSES_VIEW, self::EXPENSES_CREATE, self::EXPENSES_UPDATE,
self::EXPENSES_DELETE => 'Pengeluaran',
self::EMPLOYEE_ADVANCES_VIEW, self::EMPLOYEE_ADVANCES_CREATE, self::EMPLOYEE_ADVANCES_UPDATE,

View File

@ -133,6 +133,7 @@ public function permissions(): array
Permission::CASH_VIEW,
Permission::CASH_DEPOSIT,
Permission::CASH_WITHDRAW,
Permission::CASH_UPDATE,
Permission::CASH_DELETE,

View File

@ -7,6 +7,7 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\DepositCashRequest;
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;
@ -46,6 +47,17 @@ public function deposit(DepositCashRequest $request): RedirectResponse
return redirect()->route('admin.finance.cash.index');
}
public function withdraw(WithdrawCashRequest $request): RedirectResponse
{
$cashAccount = $this->cashService->getDefaultAccount();
$this->cashService->withdraw($cashAccount, $request->validated(), $request->user());
$this->flashSuccess('Tarik kas berhasil dicatat.');
return redirect()->route('admin.finance.cash.index');
}
public function update(UpdateCashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse
{
$this->cashService->updateDeposit($cashTransaction, $request->validated());

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\Admin\Finance;
use App\Enums\Permission;
use App\Http\Requests\Concerns\ValidatesMediaUploads;
use Illuminate\Foundation\Http\FormRequest;
class WithdrawCashRequest extends FormRequest
{
use ValidatesMediaUploads;
public function authorize(): bool
{
return $this->user()?->can(Permission::CASH_WITHDRAW->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:1'],
'description' => ['required', 'string', 'max:100'],
...$this->photoRules(),
];
}
/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'amount' => 'jumlah',
'description' => 'keterangan',
...$this->photoUploadAttributes('foto bukti'),
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\CashTransactionType;
use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
@ -33,6 +34,7 @@ class CashTransaction extends Model implements HasMedia
protected function casts(): array
{
return [
'type' => CashTransactionType::class,
'amount' => 'integer',
'balance_after' => 'integer',
];
@ -89,7 +91,7 @@ public function isIncoming(): Attribute
return $this->reference->repayment_cash_transaction_id === $this->id;
}
return self::isIncomingTransaction($this);
return $this->type === CashTransactionType::DEPOSIT;
},
);
}
@ -98,6 +100,10 @@ public function referenceLabel(): Attribute
{
return Attribute::make(
get: function () {
if ($this->reference_type === null) {
return $this->type === CashTransactionType::WITHDRAWAL ? 'Tarik Kas' : 'Setor Kas';
}
if ($this->reference_type === EmployeeAdvance::class && $this->reference instanceof EmployeeAdvance) {
return $this->reference->repayment_cash_transaction_id === $this->id
? 'Pelunasan Kasbon'

View File

@ -2,6 +2,7 @@
namespace App\Services\Finance;
use App\Enums\CashTransactionType;
use App\Models\CashAccount;
use App\Models\CashTransaction;
use App\Models\User;
@ -73,6 +74,7 @@ public function deposit(CashAccount $cashAccount, array $validated, User $user):
$transaction = CashTransaction::create([
'cash_account_id' => $account->id,
'type' => CashTransactionType::DEPOSIT,
'amount' => $amount,
'balance_after' => $newBalance,
'description' => $validated['description'],
@ -94,6 +96,50 @@ public function deposit(CashAccount $cashAccount, array $validated, User $user):
return $transaction;
}
/**
* @param array{amount: int, description: string} $validated
*/
public function withdraw(CashAccount $cashAccount, array $validated, User $user): CashTransaction
{
$transaction = DB::transaction(function () use ($cashAccount, $validated, $user): CashTransaction {
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);
$amount = (int) $validated['amount'];
if ($account->balance < $amount) {
throw ValidationException::withMessages([
'amount' => 'Saldo kas tidak mencukupi.',
]);
}
$newBalance = $account->balance - $amount;
$account->balance = $newBalance;
$account->save();
$transaction = CashTransaction::create([
'cash_account_id' => $account->id,
'type' => CashTransactionType::WITHDRAWAL,
'amount' => $amount,
'balance_after' => $newBalance,
'description' => $validated['description'],
'created_by_id' => $user->id,
]);
$this->syncPhotos($transaction, $validated);
return $transaction;
});
$this->pushNotificationService->sendToRoles(
'🏦 Tarik Kas',
"Tarik kas {$transaction->amount_formatted} dengan keterangan: {$transaction->description} oleh {$user->profile?->full_name}.",
['owner', 'developer', 'admin-toko'],
'/admin/finance/cash',
);
return $transaction;
}
public function recordOutgoing(
Model $reference,
int $amount,
@ -308,7 +354,7 @@ private function recalculateBalances(CashAccount $cashAccount): void
->get();
foreach ($transactions as $transaction) {
if (CashTransaction::isIncomingTransaction($transaction)) {
if ($transaction->type === CashTransactionType::DEPOSIT) {
$runningBalance += $transaction->amount;
} else {
$runningBalance -= $transaction->amount;

View File

@ -1,5 +1,6 @@
<?php
use App\Enums\CashTransactionType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@ -17,6 +18,7 @@ public function up(): void
$table->nullableMorphs('reference');
$table->unsignedBigInteger('amount');
$table->unsignedBigInteger('balance_after');
$table->enum('type', array_column(CashTransactionType::cases(), 'value'))->default(CashTransactionType::DEPOSIT->value);
$table->string('description', 100);
$table->timestamp('created_at')->useCurrent();

View File

@ -32,9 +32,18 @@ const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{
transaction?: CashTransactionListItem | null;
mode?: 'deposit' | 'withdrawal';
}>();
const isEditing = computed(() => props.transaction != null);
const currentMode = computed(() => {
if (isEditing.value && props.transaction) {
return props.transaction.type;
}
return props.mode ?? 'deposit';
});
const isWithdrawal = computed(() => currentMode.value === 'withdrawal');
const existingPhotoId = ref<number | null>(null);
@ -113,8 +122,8 @@ function submit() {
},
onError: () => {
toast.error(isEditing.value
? 'Gagal memperbarui setor kas. Periksa kembali formulir.'
: 'Gagal mencatat setor kas. Periksa kembali formulir.');
? 'Gagal memperbarui transaksi kas. Periksa kembali formulir.'
: 'Gagal mencatat transaksi kas. Periksa kembali formulir.');
},
};
@ -124,15 +133,29 @@ function submit() {
return;
}
form.transform(() => payload).post('/admin/finance/cash/deposit', options);
const endpoint = isWithdrawal.value ? '/admin/finance/cash/withdraw' : '/admin/finance/cash/deposit';
form.transform(() => payload).post(endpoint, options);
}
const dialogTitle = computed(() => {
if (isEditing.value) {
return isWithdrawal.value ? 'Ubah Tarik Kas' : 'Ubah Setor Kas';
}
return isWithdrawal.value ? 'Tarik Kas' : 'Setor Kas';
});
const placeholder = computed(() =>
isWithdrawal.value ? 'Contoh: Tarik kas ke owner' : 'Contoh: Setoran kas harian',
);
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ isEditing ? 'Ubah Setor Kas' : 'Setor Kas' }}</DialogTitle>
<DialogTitle>{{ dialogTitle }}</DialogTitle>
</DialogHeader>
<form @submit.prevent="submit">
@ -146,7 +169,7 @@ function submit() {
<Field>
<FieldLabel for="cash-description" required>Keterangan</FieldLabel>
<Textarea id="cash-description" v-model="form.description"
placeholder="Contoh: Setoran kas harian" rows="3"
:placeholder="placeholder" rows="3"
:maxlength="FIELD_LIMITS.description" />
<FieldError :errors="formErrors(form, 'description')" />
</Field>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { ArrowDownCircle, Wallet } from '@lucide/vue';
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';
@ -26,6 +26,7 @@ const props = defineProps<{
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const formModalOpen = ref(false);
const formModalMode = ref<'deposit' | 'withdrawal'>('deposit');
const editingTransaction = ref<CashTransactionListItem | null>(null);
const { query, setSearch, setSort, resetFilters, syncFromServer } = useDataTableQuery({
@ -55,8 +56,9 @@ const pagination = computed(() => ({
total: props.transactions.total,
}));
function openCreateModal() {
function openCreateModal(mode: 'deposit' | 'withdrawal') {
editingTransaction.value = null;
formModalMode.value = mode;
formModalOpen.value = true;
}
@ -89,11 +91,16 @@ watch(
</h2>
</div>
<Button v-if="can('cash.deposit')" class="shrink-0 self-start sm:self-center" variant="outline"
@click="openCreateModal">
<ArrowDownCircle class="size-4" />
Setor Kas
</Button>
<div class="flex shrink-0 items-center gap-2 self-start sm:self-center">
<Button v-if="can('cash.deposit')" variant="outline" @click="openCreateModal('deposit')">
<ArrowDownCircle class="size-4" />
Setor Kas
</Button>
<Button v-if="can('cash.withdraw')" variant="outline" @click="openCreateModal('withdrawal')">
<ArrowUpCircle class="size-4" />
Tarik Kas
</Button>
</div>
</div>
<Card>
@ -119,9 +126,10 @@ watch(
</Card>
<CashTransactionFormModal
v-if="can('cash.deposit') || can('cash.update')"
v-if="can('cash.deposit') || can('cash.withdraw') || can('cash.update')"
v-model:open="formModalOpen"
:transaction="editingTransaction"
:mode="formModalMode"
/>
</AdminLayout>
</template>

View File

@ -9,6 +9,7 @@ export type CashAccount = {
export type CashTransactionListItem = {
id: number;
type: 'deposit' | 'withdrawal';
reference_type: string | null;
reference_label: string;
is_incoming: boolean;

View File

@ -341,6 +341,10 @@
->middleware('permission:'.Permission::CASH_DEPOSIT->value)
->name('deposit');
Route::post('withdraw', [CashController::class, 'withdraw'])
->middleware('permission:'.Permission::CASH_WITHDRAW->value)
->name('withdraw');
Route::put('transactions/{cashTransaction}', [CashController::class, 'update'])
->middleware('permission:'.Permission::CASH_UPDATE->value)
->name('transactions.update');