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

View File

@ -7,6 +7,7 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\DepositCashRequest; use App\Http\Requests\Admin\Finance\DepositCashRequest;
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;
@ -46,6 +47,17 @@ 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
{
$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 public function update(UpdateCashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse
{ {
$this->cashService->updateDeposit($cashTransaction, $request->validated()); $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; namespace App\Models;
use App\Enums\CashTransactionType;
use App\Models\Concerns\HasModuleMedia; use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\InteractsWithActivityLog; use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Appends;
@ -33,6 +34,7 @@ class CashTransaction extends Model implements HasMedia
protected function casts(): array protected function casts(): array
{ {
return [ return [
'type' => CashTransactionType::class,
'amount' => 'integer', 'amount' => 'integer',
'balance_after' => 'integer', 'balance_after' => 'integer',
]; ];
@ -89,7 +91,7 @@ public function isIncoming(): Attribute
return $this->reference->repayment_cash_transaction_id === $this->id; 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( return Attribute::make(
get: function () { 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) { if ($this->reference_type === EmployeeAdvance::class && $this->reference instanceof EmployeeAdvance) {
return $this->reference->repayment_cash_transaction_id === $this->id return $this->reference->repayment_cash_transaction_id === $this->id
? 'Pelunasan Kasbon' ? 'Pelunasan Kasbon'

View File

@ -2,6 +2,7 @@
namespace App\Services\Finance; namespace App\Services\Finance;
use App\Enums\CashTransactionType;
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\CashTransaction; use App\Models\CashTransaction;
use App\Models\User; use App\Models\User;
@ -73,6 +74,7 @@ public function deposit(CashAccount $cashAccount, array $validated, User $user):
$transaction = CashTransaction::create([ $transaction = CashTransaction::create([
'cash_account_id' => $account->id, 'cash_account_id' => $account->id,
'type' => CashTransactionType::DEPOSIT,
'amount' => $amount, 'amount' => $amount,
'balance_after' => $newBalance, 'balance_after' => $newBalance,
'description' => $validated['description'], 'description' => $validated['description'],
@ -94,6 +96,50 @@ public function deposit(CashAccount $cashAccount, array $validated, User $user):
return $transaction; 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( public function recordOutgoing(
Model $reference, Model $reference,
int $amount, int $amount,
@ -308,7 +354,7 @@ private function recalculateBalances(CashAccount $cashAccount): void
->get(); ->get();
foreach ($transactions as $transaction) { foreach ($transactions as $transaction) {
if (CashTransaction::isIncomingTransaction($transaction)) { if ($transaction->type === CashTransactionType::DEPOSIT) {
$runningBalance += $transaction->amount; $runningBalance += $transaction->amount;
} else { } else {
$runningBalance -= $transaction->amount; $runningBalance -= $transaction->amount;

View File

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

View File

@ -32,9 +32,18 @@ const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{ const props = defineProps<{
transaction?: CashTransactionListItem | null; transaction?: CashTransactionListItem | null;
mode?: 'deposit' | 'withdrawal';
}>(); }>();
const isEditing = computed(() => props.transaction != null); 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); const existingPhotoId = ref<number | null>(null);
@ -113,8 +122,8 @@ function submit() {
}, },
onError: () => { onError: () => {
toast.error(isEditing.value toast.error(isEditing.value
? 'Gagal memperbarui setor kas. Periksa kembali formulir.' ? 'Gagal memperbarui transaksi kas. Periksa kembali formulir.'
: 'Gagal mencatat setor kas. Periksa kembali formulir.'); : 'Gagal mencatat transaksi kas. Periksa kembali formulir.');
}, },
}; };
@ -124,15 +133,29 @@ function submit() {
return; 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> </script>
<template> <template>
<Dialog v-model:open="open"> <Dialog v-model:open="open">
<DialogContent class="sm:max-w-md"> <DialogContent class="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{{ isEditing ? 'Ubah Setor Kas' : 'Setor Kas' }}</DialogTitle> <DialogTitle>{{ dialogTitle }}</DialogTitle>
</DialogHeader> </DialogHeader>
<form @submit.prevent="submit"> <form @submit.prevent="submit">
@ -146,7 +169,7 @@ function submit() {
<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="Contoh: Setoran kas harian" rows="3" :placeholder="placeholder" rows="3"
:maxlength="FIELD_LIMITS.description" /> :maxlength="FIELD_LIMITS.description" />
<FieldError :errors="formErrors(form, 'description')" /> <FieldError :errors="formErrors(form, 'description')" />
</Field> </Field>

View File

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

View File

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

View File

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