Add cash management features including CashAccount and CashTransaction models, CashController for handling transactions, and corresponding requests for validation. Implement permissions in the Permission and Role enums, and update routes for cash operations. Enhance UI components for cash transaction management, including a data table and form modal. Update sidebar for cash navigation.

This commit is contained in:
Yoga Pangestu 2026-06-10 23:48:49 +07:00
parent 01997dccc4
commit 0e8166a7ff
21 changed files with 1091 additions and 1 deletions

View File

@ -44,6 +44,11 @@ enum Permission: string
case RAW_MATERIALS_DELETE = 'raw-materials.delete';
case RAW_MATERIALS_TOGGLE_STATUS = 'raw-materials.toggle-status';
case CASH_VIEW = 'cash.view';
case CASH_DEPOSIT = 'cash.deposit';
case CASH_UPDATE = 'cash.update';
case CASH_DELETE = 'cash.delete';
public function label(): string
{
return match ($this) {
@ -82,6 +87,11 @@ public function label(): string
self::RAW_MATERIALS_UPDATE => 'Ubah Bahan Baku',
self::RAW_MATERIALS_DELETE => 'Hapus Bahan Baku',
self::RAW_MATERIALS_TOGGLE_STATUS => 'Ubah Status Bahan Baku',
self::CASH_VIEW => 'Lihat Kas',
self::CASH_DEPOSIT => 'Setor Kas',
self::CASH_UPDATE => 'Ubah Setor Kas',
self::CASH_DELETE => 'Hapus Setor Kas',
};
}
@ -101,6 +111,7 @@ public function group(): string
self::PRODUCTS_DELETE, self::PRODUCTS_TOGGLE_STATUS => 'Produk',
self::RAW_MATERIALS_VIEW, self::RAW_MATERIALS_CREATE, self::RAW_MATERIALS_UPDATE,
self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku',
self::CASH_VIEW, self::CASH_DEPOSIT, self::CASH_UPDATE, self::CASH_DELETE => 'Kas',
};
}

View File

@ -66,6 +66,10 @@ public function permissions(): array
Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_DELETE,
Permission::RAW_MATERIALS_TOGGLE_STATUS,
Permission::CASH_VIEW,
Permission::CASH_DEPOSIT,
Permission::CASH_UPDATE,
Permission::CASH_DELETE,
],
self::ADMIN_TOKO => [
Permission::DASHBOARD_VIEW,
@ -91,6 +95,10 @@ public function permissions(): array
Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
Permission::CASH_VIEW,
Permission::CASH_DEPOSIT,
Permission::CASH_UPDATE,
Permission::CASH_DELETE,
],
self::ADMIN_BAHAN_BAKU => [
Permission::DASHBOARD_VIEW,

View File

@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Admin\Finance;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\DepositCashRequest;
use App\Http\Requests\Admin\Finance\UpdateCashTransactionRequest;
use App\Models\CashTransaction;
use App\Services\Finance\CashService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class CashController extends Controller
{
use ParsesDataTableQuery;
public function __construct(
private readonly CashService $cashService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$cashAccount = $this->cashService->getDefaultAccount();
return Inertia::render('admin/finance/cash/Index', [
'cashAccount' => $cashAccount,
'transactions' => $this->cashService->paginateForIndex($cashAccount, $tableQuery),
'filters' => $this->dataTableFilters($tableQuery),
]);
}
public function deposit(DepositCashRequest $request): RedirectResponse
{
$cashAccount = $this->cashService->getDefaultAccount();
$this->cashService->deposit($cashAccount, $request->validated(), $request->user());
Inertia::flash('success', 'Setor kas berhasil dicatat.');
return redirect()->route('admin.finance.cash.index');
}
public function update(UpdateCashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse
{
$this->cashService->updateDeposit($cashTransaction, $request->validated());
Inertia::flash('success', 'Setor kas berhasil diperbarui.');
return redirect()->route('admin.finance.cash.index');
}
public function destroy(CashTransaction $cashTransaction): RedirectResponse
{
$this->cashService->deleteTransaction($cashTransaction);
Inertia::flash('success', 'Setor kas berhasil dihapus.');
return redirect()->route('admin.finance.cash.index');
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Admin\Finance;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class DepositCashRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::CASH_DEPOSIT->value) ?? false;
}
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:1'],
'description' => ['required', 'string', 'max:200'],
];
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Admin\Finance;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class UpdateCashTransactionRequest extends FormRequest
{
public function authorize(): bool
{
$transaction = $this->route('cashTransaction');
return $this->user()?->can(Permission::CASH_UPDATE->value)
&& $transaction?->reference_type === null;
}
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:1'],
'description' => ['required', 'string', 'max:200'],
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Guarded(['id'])]
#[Appends(['balance_formatted'])]
class CashAccount extends Model
{
protected function casts(): array
{
return [
'balance' => 'integer',
];
}
public function balanceFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->balance, 0, ',', '.'),
);
}
public function transactions(): HasMany
{
return $this->hasMany(CashTransaction::class);
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends([
'amount_formatted',
'balance_after_formatted',
'reference_label',
'is_incoming',
'created_at_formatted',
'created_by_name',
])]
class CashTransaction extends Model
{
use SoftDeletes;
protected function casts(): array
{
return [
'amount' => 'integer',
'balance_after' => 'integer',
];
}
public function amountFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'),
);
}
public function balanceAfterFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->balance_after, 0, ',', '.'),
);
}
public function referenceLabel(): Attribute
{
return Attribute::make(
get: fn () => self::labelForReferenceType($this->reference_type),
);
}
public function isIncoming(): Attribute
{
return Attribute::make(
get: fn () => self::isIncomingReference($this->reference_type),
);
}
public function createdAtFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'),
);
}
public function createdByName(): Attribute
{
return Attribute::make(
get: fn () => $this->createdBy?->profile?->full_name ?? $this->createdBy?->username,
);
}
public static function labelForReferenceType(?string $referenceType): string
{
if ($referenceType === null) {
return 'Setor Kas';
}
return match ($referenceType) {
default => class_basename($referenceType),
};
}
public static function isIncomingReference(?string $referenceType): bool
{
if ($referenceType === null) {
return true;
}
return match ($referenceType) {
default => false,
};
}
public function cashAccount(): BelongsTo
{
return $this->belongsTo(CashAccount::class);
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_id');
}
public function reference(): MorphTo
{
return $this->morphTo();
}
}

View File

@ -10,6 +10,7 @@
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
@ -64,4 +65,9 @@ public function employee(): HasOne
{
return $this->hasOne(Employee::class);
}
public function cashTransactions(): HasMany
{
return $this->hasMany(CashTransaction::class, 'created_by_id');
}
}

View File

@ -0,0 +1,212 @@
<?php
namespace App\Services\Finance;
use App\Models\CashAccount;
use App\Models\CashTransaction;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CashService
{
public function getDefaultAccount(): CashAccount
{
return CashAccount::query()->firstOrFail();
}
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(CashAccount $cashAccount, array $tableQuery): LengthAwarePaginator
{
$query = CashTransaction::query()
->with(['createdBy.profile', 'reference'])
->where('cash_account_id', $cashAccount->id)
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
$query->where('description', 'like', "%{$search}%");
});
});
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(10)
->withQueryString();
}
/**
* @param array{amount: int, description: string} $validated
*/
public function deposit(CashAccount $cashAccount, array $validated, User $user): CashTransaction
{
return DB::transaction(function () use ($cashAccount, $validated, $user): CashTransaction {
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);
$amount = (int) $validated['amount'];
$newBalance = $account->balance + $amount;
$account->balance = $newBalance;
$account->save();
return CashTransaction::create([
'cash_account_id' => $account->id,
'amount' => $amount,
'balance_after' => $newBalance,
'description' => $validated['description'],
'created_by_id' => $user->id,
]);
});
}
public function recordOutgoing(
Model $reference,
int $amount,
string $description,
User $user,
?CashAccount $cashAccount = null,
): CashTransaction {
return DB::transaction(function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction {
$account = CashAccount::query()->lockForUpdate()->findOrFail(
($cashAccount ?? $this->getDefaultAccount())->id,
);
if ($account->balance < $amount) {
throw ValidationException::withMessages([
'amount' => 'Saldo kas tidak mencukupi.',
]);
}
$newBalance = $account->balance - $amount;
$account->balance = $newBalance;
$account->save();
$transaction = new CashTransaction([
'cash_account_id' => $account->id,
'amount' => $amount,
'balance_after' => $newBalance,
'description' => $description,
'created_by_id' => $user->id,
]);
$transaction->reference()->associate($reference);
$transaction->save();
return $transaction;
});
}
public function recordIncoming(
Model $reference,
int $amount,
string $description,
User $user,
?CashAccount $cashAccount = null,
): CashTransaction {
return DB::transaction(function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction {
$account = CashAccount::query()->lockForUpdate()->findOrFail(
($cashAccount ?? $this->getDefaultAccount())->id,
);
$newBalance = $account->balance + $amount;
$account->balance = $newBalance;
$account->save();
$transaction = new CashTransaction([
'cash_account_id' => $account->id,
'amount' => $amount,
'balance_after' => $newBalance,
'description' => $description,
'created_by_id' => $user->id,
]);
$transaction->reference()->associate($reference);
$transaction->save();
return $transaction;
});
}
/**
* @param array{amount: int, description: string} $validated
*/
public function updateDeposit(CashTransaction $transaction, array $validated): void
{
$this->ensureEditable($transaction);
DB::transaction(function () use ($transaction, $validated): void {
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
$transaction->amount = (int) $validated['amount'];
$transaction->description = $validated['description'];
$transaction->save();
$this->recalculateBalances($transaction->cashAccount);
});
}
public function deleteTransaction(CashTransaction $transaction): void
{
$this->ensureEditable($transaction);
DB::transaction(function () use ($transaction): void {
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
$transaction->delete();
$this->recalculateBalances($transaction->cashAccount);
});
}
private function ensureEditable(CashTransaction $transaction): void
{
if ($transaction->reference_type !== null) {
throw ValidationException::withMessages([
'transaction' => 'Transaksi ini tidak dapat diubah dari halaman kas.',
]);
}
}
private function recalculateBalances(CashAccount $cashAccount): void
{
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);
$runningBalance = 0;
$transactions = CashTransaction::query()
->where('cash_account_id', $account->id)
->orderBy('created_at')
->orderBy('id')
->get();
foreach ($transactions as $transaction) {
if (CashTransaction::isIncomingReference($transaction->reference_type)) {
$runningBalance += $transaction->amount;
} else {
$runningBalance -= $transaction->amount;
}
$transaction->balance_after = $runningBalance;
$transaction->saveQuietly();
}
$account->balance = $runningBalance;
$account->save();
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['created_at', 'amount', 'reference_type'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
}

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('cash_accounts', function (Blueprint $table) {
$table->id();
$table->foreignId('created_by_id')->constrained('users')->restrictOnDelete();
$table->string('name', 200);
$table->unsignedBigInteger('balance')->default(0);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('cash_accounts');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('cash_transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('cash_account_id')->constrained()->cascadeOnDelete();
$table->foreignId('created_by_id')->constrained('users')->restrictOnDelete();
$table->nullableMorphs('reference');
$table->unsignedBigInteger('amount');
$table->unsignedBigInteger('balance_after');
$table->string('description', 200);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('cash_transactions');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('cash_accounts', function (Blueprint $table) {
$table->unsignedBigInteger('balance')->default(0)->change();
});
Schema::table('cash_transactions', function (Blueprint $table) {
$table->unsignedBigInteger('amount')->change();
$table->unsignedBigInteger('balance_after')->change();
});
}
public function down(): void
{
Schema::table('cash_accounts', function (Blueprint $table) {
$table->decimal('balance', 18, 2)->default(0)->change();
});
Schema::table('cash_transactions', function (Blueprint $table) {
$table->decimal('amount', 18, 2)->change();
$table->decimal('balance_after', 18, 2)->change();
});
}
};

View File

@ -0,0 +1,19 @@
<?php
namespace Database\Seeders;
use App\Models\CashAccount;
use App\Models\User;
use Illuminate\Database\Seeder;
class CashAccountSeeder extends Seeder
{
public function run(): void
{
CashAccount::firstOrCreate(
['created_by_id' => User::first()->id],
['name' => 'Kas Toko'],
['balance' => 0],
);
}
}

View File

@ -22,6 +22,7 @@ public function run(): void
RawMaterialSeeder::class,
SupplierSeeder::class,
CustomerSeeder::class,
CashAccountSeeder::class,
]);
}
}

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { FolderTree, Layers, LayoutDashboard, Package, User, UserCheck, Users } from '@lucide/vue';
import { FolderTree, Layers, LayoutDashboard, Package, User, UserCheck, Users, Wallet } from '@lucide/vue';
import { computed } from 'vue';
import {
Sidebar,
@ -26,6 +26,7 @@ const isProductsActive = computed(() => page.url.startsWith('/admin/master/produ
const isRawMaterialsActive = computed(() => page.url.startsWith('/admin/master/raw-materials'));
const isSuppliersActive = computed(() => page.url.startsWith('/admin/master/suppliers'));
const isCustomersActive = computed(() => page.url.startsWith('/admin/master/customers'));
const isCashActive = computed(() => page.url.startsWith('/admin/finance/cash'));
const showMasterMenu = computed(() => (
can('categories.view') || can('products.view') || can('raw-materials.view')
|| can('suppliers.view') || can('customers.view')
@ -110,6 +111,21 @@ const showMasterMenu = computed(() => (
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Keuangan</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem v-if="can('cash.view')">
<SidebarMenuButton as-child tooltip="Kas Toko" :is-active="isCashActive">
<Link href="/admin/finance/cash">
<Wallet />
<span>Kas Toko</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup v-if="can('employees.view')">
<SidebarGroupLabel>HR</SidebarGroupLabel>
<SidebarGroupContent>

View File

@ -0,0 +1,132 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { computed, watch } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { RupiahInput } from '@/components/ui/rupiah-input';
import { Textarea } from '@/components/ui/textarea';
import { parseRupiah } from '@/lib/rupiah';
import type { CashTransactionFormData, CashTransactionListItem } from '@/types/cash';
const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{
transaction?: CashTransactionListItem | null;
}>();
const isEditing = computed(() => props.transaction != null);
const form = useForm<CashTransactionFormData>({
amount: '',
description: '',
});
function resetForm() {
form.reset();
form.clearErrors();
}
function populateForm(transaction: CashTransactionListItem | null | undefined) {
resetForm();
if (!transaction) {
return;
}
form.amount = String(transaction.amount);
form.description = transaction.description;
}
watch(
() => props.transaction,
(transaction) => {
populateForm(transaction);
},
);
watch(open, (isOpen) => {
if (isOpen) {
populateForm(props.transaction);
} else {
resetForm();
}
});
function submit() {
const options = {
preserveScroll: true,
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error(isEditing.value
? 'Gagal memperbarui setor kas. Periksa kembali formulir.'
: 'Gagal mencatat setor kas. Periksa kembali formulir.');
},
};
form.transform((data) => ({
...data,
amount: parseRupiah(data.amount),
}));
if (isEditing.value && props.transaction) {
form.put(`/admin/finance/cash/transactions/${props.transaction.id}`, options);
} else {
form.post('/admin/finance/cash/deposit', options);
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ isEditing ? 'Ubah Setor Kas' : 'Setor Kas' }}</DialogTitle>
</DialogHeader>
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="cash-amount" required>Jumlah</FieldLabel>
<RupiahInput id="cash-amount" v-model="form.amount" autofocus />
<FieldError :errors="form.errors.amount ? [form.errors.amount] : []" />
</Field>
<Field>
<FieldLabel for="cash-description" required>Keterangan</FieldLabel>
<Textarea id="cash-description" v-model="form.description"
placeholder="Contoh: Setoran kas harian" rows="3" />
<FieldError :errors="form.errors.description ? [form.errors.description] : []" />
</Field>
</FieldSet>
</FieldGroup>
<DialogFooter class="mt-6">
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
Batal
</Button>
<Button type="submit" :disabled="form.processing">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,75 @@
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 { Badge } from '@/components/ui/badge';
import type { CashTransactionListItem } from '@/types/cash';
export function createColumns(onEdit: (transaction: CashTransactionListItem) => void): ColumnDef<CashTransactionListItem>[] {
return [
{
accessorKey: 'created_at_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Tanggal', column: 'created_at' }),
},
{
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,
);
},
},
{
accessorKey: 'amount_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Jumlah', column: 'amount' }),
cell: ({ row }) => {
const isIncoming = row.original.is_incoming;
const prefix = isIncoming ? '+' : '-';
return h(
'span',
{ class: isIncoming ? 'font-medium text-green-600' : 'font-medium text-red-600' },
`${prefix} ${row.original.amount_formatted}`,
);
},
},
{
accessorKey: 'balance_after_formatted',
enableSorting: false,
header: () => 'Saldo Setelah',
},
{
accessorKey: 'description',
enableSorting: false,
header: () => 'Keterangan',
},
{
accessorKey: 'created_by_name',
enableSorting: false,
header: () => 'Oleh',
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
cell: ({ row }) => {
if (row.original.reference_type !== null) {
return null;
}
return h(DataTableActions, {
transaction: row.original,
onEdit: () => onEdit(row.original),
});
},
},
];
}

View File

@ -0,0 +1,75 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { Pencil, Trash2 } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useCan } from '@/composables/useCan';
import type { CashTransactionListItem } from '@/types/cash';
const props = defineProps<{
transaction: CashTransactionListItem;
}>();
const emit = defineEmits<{
edit: [transaction: CashTransactionListItem];
}>();
const { can } = useCan();
const showActions = computed(() => props.transaction.reference_type === null);
const deleteConfirmOpen = ref(false);
const deleteProcessing = ref(false);
function destroyTransaction() {
deleteProcessing.value = true;
router.delete(`/admin/finance/cash/transactions/${props.transaction.id}`, {
preserveScroll: true,
onSuccess: () => {
deleteConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menghapus setor kas.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
</script>
<template>
<template v-if="showActions">
<div class="flex items-center justify-end gap-1">
<Tooltip v-if="can('cash.update')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" @click="emit('edit', transaction)">
<Pencil class="size-4" />
<span class="sr-only">Ubah</span>
</Button>
</TooltipTrigger>
<TooltipContent>Ubah</TooltipContent>
</Tooltip>
<Tooltip v-if="can('cash.delete')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
@click="deleteConfirmOpen = true">
<Trash2 class="size-4" />
<span class="sr-only">Hapus</span>
</Button>
</TooltipTrigger>
<TooltipContent>Hapus</TooltipContent>
</Tooltip>
</div>
<ConfirmDialog v-if="can('cash.delete')" v-model:open="deleteConfirmOpen" title="Hapus setor kas?"
:description="`Setor kas sebesar ${transaction.amount_formatted} akan dihapus. Saldo kas akan disesuaikan.`"
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing"
@confirm="destroyTransaction" />
</template>
</template>

View File

@ -0,0 +1,127 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { ArrowDownCircle, 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';
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';
const props = defineProps<{
cashAccount: CashAccount;
transactions: PaginatedCashTransactions;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
};
}>();
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const formModalOpen = ref(false);
const editingTransaction = ref<CashTransactionListItem | null>(null);
const { query, setSearch, setSort, resetFilters, syncFromServer } = useDataTableQuery({
url: '/admin/finance/cash',
initial: { ...props.filters },
});
useDataTableQuerySync(() => props.filters, syncFromServer);
const columns = computed(() => createColumns(openEditModal));
const currentSort = computed<DataTableSort | null>(() => {
if (!query.value.sort || !query.value.direction) {
return null;
}
return {
column: query.value.sort,
direction: query.value.direction,
};
});
const pagination = computed(() => ({
currentPage: props.transactions.current_page,
perPage: props.transactions.per_page,
lastPage: props.transactions.last_page,
total: props.transactions.total,
}));
function openCreateModal() {
editingTransaction.value = null;
formModalOpen.value = true;
}
function openEditModal(transaction: CashTransactionListItem) {
editingTransaction.value = transaction;
formModalOpen.value = true;
}
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Kas Toko" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
{{ cashAccount.name }}
</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>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">
Saldo Saat Ini
</CardTitle>
<Wallet class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-3xl font-bold tracking-tight">
{{ cashAccount.balance_formatted }}
</div>
</CardContent>
</Card>
<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" />
</CardContent>
</Card>
<CashTransactionFormModal
v-if="can('cash.deposit') || can('cash.update')"
v-model:open="formModalOpen"
:transaction="editingTransaction"
/>
</AdminLayout>
</template>

View File

@ -0,0 +1,38 @@
export type CashAccount = {
id: number;
name: string;
balance: number;
balance_formatted: string;
};
export type CashTransactionListItem = {
id: number;
reference_type: string | null;
reference_label: string;
is_incoming: boolean;
amount: number;
amount_formatted: string;
balance_after: number;
balance_after_formatted: string;
description: string;
created_at_formatted: string;
created_by_name: string;
};
export type CashTransactionFormData = {
amount: string;
description: string;
};
export type PaginatedCashTransactions = {
data: CashTransactionListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};

View File

@ -2,6 +2,7 @@
use App\Enums\Permission;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\Finance\CashController;
use App\Http\Controllers\Admin\Hr\EmployeeController;
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController;
@ -157,6 +158,26 @@
Route::put('appearance', [AppearanceController::class, 'update'])->name('appearance.update');
});
Route::prefix('finance')->name('finance.')->group(function () {
Route::prefix('cash')->name('cash.')
->middleware('permission:'.Permission::CASH_VIEW->value)
->group(function () {
Route::get('/', [CashController::class, 'index'])->name('index');
Route::post('deposit', [CashController::class, 'deposit'])
->middleware('permission:'.Permission::CASH_DEPOSIT->value)
->name('deposit');
Route::put('transactions/{cashTransaction}', [CashController::class, 'update'])
->middleware('permission:'.Permission::CASH_UPDATE->value)
->name('transactions.update');
Route::delete('transactions/{cashTransaction}', [CashController::class, 'destroy'])
->middleware('permission:'.Permission::CASH_DELETE->value)
->name('transactions.destroy');
});
});
Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () {
Route::prefix('employees')->name('employees.')
->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)