feat: add cash withdrawal functionality, including permission, controller method, and UI updates
This commit is contained in:
parent
cb61384a4e
commit
91f7858cfe
21
app/Enums/CashTransactionType.php
Normal file
21
app/Enums/CashTransactionType.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -133,6 +133,7 @@ public function permissions(): array
|
||||
|
||||
Permission::CASH_VIEW,
|
||||
Permission::CASH_DEPOSIT,
|
||||
Permission::CASH_WITHDRAW,
|
||||
Permission::CASH_UPDATE,
|
||||
Permission::CASH_DELETE,
|
||||
|
||||
|
||||
@ -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());
|
||||
|
||||
41
app/Http/Requests/Admin/Finance/WithdrawCashRequest.php
Normal file
41
app/Http/Requests/Admin/Finance/WithdrawCashRequest.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user