Add expenses management features including Expense model, ExpenseController for CRUD operations, and corresponding permissions in the Permission and Role enums. Update routes for expenses functionality and enhance UI components for expenses navigation in the sidebar.
This commit is contained in:
parent
0e8166a7ff
commit
e2f0feed9d
@ -49,6 +49,11 @@ enum Permission: string
|
||||
case CASH_UPDATE = 'cash.update';
|
||||
case CASH_DELETE = 'cash.delete';
|
||||
|
||||
case EXPENSES_VIEW = 'expenses.view';
|
||||
case EXPENSES_CREATE = 'expenses.create';
|
||||
case EXPENSES_UPDATE = 'expenses.update';
|
||||
case EXPENSES_DELETE = 'expenses.delete';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
@ -92,6 +97,11 @@ public function label(): string
|
||||
self::CASH_DEPOSIT => 'Setor Kas',
|
||||
self::CASH_UPDATE => 'Ubah Setor Kas',
|
||||
self::CASH_DELETE => 'Hapus Setor Kas',
|
||||
|
||||
self::EXPENSES_VIEW => 'Lihat Pengeluaran',
|
||||
self::EXPENSES_CREATE => 'Tambah Pengeluaran',
|
||||
self::EXPENSES_UPDATE => 'Ubah Pengeluaran',
|
||||
self::EXPENSES_DELETE => 'Hapus Pengeluaran',
|
||||
};
|
||||
}
|
||||
|
||||
@ -112,6 +122,8 @@ public function group(): string
|
||||
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',
|
||||
self::EXPENSES_VIEW, self::EXPENSES_CREATE, self::EXPENSES_UPDATE,
|
||||
self::EXPENSES_DELETE => 'Pengeluaran',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -70,6 +70,10 @@ public function permissions(): array
|
||||
Permission::CASH_DEPOSIT,
|
||||
Permission::CASH_UPDATE,
|
||||
Permission::CASH_DELETE,
|
||||
Permission::EXPENSES_VIEW,
|
||||
Permission::EXPENSES_CREATE,
|
||||
Permission::EXPENSES_UPDATE,
|
||||
Permission::EXPENSES_DELETE,
|
||||
],
|
||||
self::ADMIN_TOKO => [
|
||||
Permission::DASHBOARD_VIEW,
|
||||
@ -99,6 +103,10 @@ public function permissions(): array
|
||||
Permission::CASH_DEPOSIT,
|
||||
Permission::CASH_UPDATE,
|
||||
Permission::CASH_DELETE,
|
||||
Permission::EXPENSES_VIEW,
|
||||
Permission::EXPENSES_CREATE,
|
||||
Permission::EXPENSES_UPDATE,
|
||||
Permission::EXPENSES_DELETE,
|
||||
],
|
||||
self::ADMIN_BAHAN_BAKU => [
|
||||
Permission::DASHBOARD_VIEW,
|
||||
|
||||
59
app/Http/Controllers/Admin/Finance/ExpenseController.php
Normal file
59
app/Http/Controllers/Admin/Finance/ExpenseController.php
Normal file
@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Finance;
|
||||
|
||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Finance\ExpenseRequest;
|
||||
use App\Models\Expense;
|
||||
use App\Services\Finance\ExpenseService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ExpenseController extends Controller
|
||||
{
|
||||
use ParsesDataTableQuery;
|
||||
|
||||
public function __construct(
|
||||
private readonly ExpenseService $expenseService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$tableQuery = $this->parseDataTableQuery($request);
|
||||
|
||||
return Inertia::render('admin/finance/expenses/Index', [
|
||||
'expenses' => $this->expenseService->paginateForIndex($tableQuery),
|
||||
'filters' => $this->dataTableFilters($tableQuery),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(ExpenseRequest $request): RedirectResponse
|
||||
{
|
||||
$this->expenseService->create($request->validated(), $request->user());
|
||||
|
||||
Inertia::flash('success', 'Pengeluaran berhasil ditambahkan.');
|
||||
|
||||
return redirect()->route('admin.finance.expenses.index');
|
||||
}
|
||||
|
||||
public function update(ExpenseRequest $request, Expense $expense): RedirectResponse
|
||||
{
|
||||
$this->expenseService->update($expense, $request->validated());
|
||||
|
||||
Inertia::flash('success', 'Pengeluaran berhasil diperbarui.');
|
||||
|
||||
return redirect()->route('admin.finance.expenses.index');
|
||||
}
|
||||
|
||||
public function destroy(Expense $expense): RedirectResponse
|
||||
{
|
||||
$this->expenseService->delete($expense);
|
||||
|
||||
Inertia::flash('success', 'Pengeluaran berhasil dihapus.');
|
||||
|
||||
return redirect()->route('admin.finance.expenses.index');
|
||||
}
|
||||
}
|
||||
29
app/Http/Requests/Admin/Finance/ExpenseRequest.php
Normal file
29
app/Http/Requests/Admin/Finance/ExpenseRequest.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Finance;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ExpenseRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$permission = $this->isMethod('POST')
|
||||
? Permission::EXPENSES_CREATE
|
||||
: Permission::EXPENSES_UPDATE;
|
||||
|
||||
return $this->user()?->can($permission->value) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'integer', 'min:1'],
|
||||
'description' => ['required', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -80,6 +80,7 @@ public static function labelForReferenceType(?string $referenceType): string
|
||||
}
|
||||
|
||||
return match ($referenceType) {
|
||||
Expense::class => 'Pengeluaran',
|
||||
default => class_basename($referenceType),
|
||||
};
|
||||
}
|
||||
|
||||
55
app/Models/Expense.php
Normal file
55
app/Models/Expense.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?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\SoftDeletes;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['amount_formatted', 'created_at_formatted', 'created_by_name'])]
|
||||
class Expense extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function amountFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
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 function cashTransaction(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CashTransaction::class);
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_id');
|
||||
}
|
||||
}
|
||||
@ -70,4 +70,9 @@ public function cashTransactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CashTransaction::class, 'created_by_id');
|
||||
}
|
||||
|
||||
public function expenses(): HasMany
|
||||
{
|
||||
return $this->hasMany(Expense::class, 'created_by_id');
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,6 +164,42 @@ public function deleteTransaction(CashTransaction $transaction): void
|
||||
});
|
||||
}
|
||||
|
||||
public function updateReferencedTransaction(
|
||||
CashTransaction $transaction,
|
||||
int $amount,
|
||||
string $description,
|
||||
): void {
|
||||
DB::transaction(function () use ($transaction, $amount, $description): void {
|
||||
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
|
||||
|
||||
$transaction->amount = $amount;
|
||||
$transaction->description = $description;
|
||||
$transaction->save();
|
||||
|
||||
$this->recalculateBalances($transaction->cashAccount);
|
||||
|
||||
$account = $transaction->cashAccount->fresh();
|
||||
|
||||
if ($account->balance < 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'amount' => 'Saldo kas tidak mencukupi.',
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function deleteReferencedTransaction(CashTransaction $transaction): void
|
||||
{
|
||||
DB::transaction(function () use ($transaction): void {
|
||||
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
|
||||
$account = $transaction->cashAccount;
|
||||
|
||||
$transaction->delete();
|
||||
|
||||
$this->recalculateBalances($account);
|
||||
});
|
||||
}
|
||||
|
||||
private function ensureEditable(CashTransaction $transaction): void
|
||||
{
|
||||
if ($transaction->reference_type !== null) {
|
||||
|
||||
109
app/Services/Finance/ExpenseService.php
Normal file
109
app/Services/Finance/ExpenseService.php
Normal file
@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Models\Expense;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ExpenseService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CashService $cashService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
|
||||
*/
|
||||
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
|
||||
{
|
||||
$query = Expense::query()
|
||||
->with(['createdBy.profile'])
|
||||
->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 create(array $validated, User $user): void
|
||||
{
|
||||
DB::transaction(function () use ($validated, $user): void {
|
||||
$amount = (int) $validated['amount'];
|
||||
$description = $validated['description'];
|
||||
|
||||
$expense = Expense::create([
|
||||
'amount' => $amount,
|
||||
'description' => $description,
|
||||
'created_by_id' => $user->id,
|
||||
]);
|
||||
|
||||
$cashTransaction = $this->cashService->recordOutgoing(
|
||||
$expense,
|
||||
$amount,
|
||||
$description,
|
||||
$user,
|
||||
);
|
||||
|
||||
$expense->cash_transaction_id = $cashTransaction->id;
|
||||
$expense->save();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{amount: int, description: string} $validated
|
||||
*/
|
||||
public function update(Expense $expense, array $validated): void
|
||||
{
|
||||
DB::transaction(function () use ($expense, $validated): void {
|
||||
$amount = (int) $validated['amount'];
|
||||
$description = $validated['description'];
|
||||
|
||||
$expense->amount = $amount;
|
||||
$expense->description = $description;
|
||||
$expense->save();
|
||||
|
||||
if ($expense->cashTransaction) {
|
||||
$this->cashService->updateReferencedTransaction(
|
||||
$expense->cashTransaction,
|
||||
$amount,
|
||||
$description,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(Expense $expense): void
|
||||
{
|
||||
DB::transaction(function () use ($expense): void {
|
||||
if ($expense->cashTransaction) {
|
||||
$this->cashService->deleteReferencedTransaction($expense->cashTransaction);
|
||||
}
|
||||
|
||||
$expense->delete();
|
||||
});
|
||||
}
|
||||
|
||||
private function applySorting(Builder $query, string $sort, string $direction): void
|
||||
{
|
||||
if (in_array($sort, ['created_at', 'amount', 'description'], true)) {
|
||||
$query->orderBy($sort, $direction);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->latest();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
<?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('expenses', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('cash_transaction_id')->nullable()->unique()->constrained()->restrictOnDelete();
|
||||
$table->foreignId('created_by_id')->constrained('users')->restrictOnDelete();
|
||||
|
||||
$table->unsignedBigInteger('amount');
|
||||
$table->string('description', 200);
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('expenses');
|
||||
}
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Link, usePage } from '@inertiajs/vue3';
|
||||
import { FolderTree, Layers, LayoutDashboard, Package, User, UserCheck, Users, Wallet } from '@lucide/vue';
|
||||
import { FolderTree, Layers, LayoutDashboard, Package, Receipt, User, UserCheck, Users, Wallet } from '@lucide/vue';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
Sidebar,
|
||||
@ -27,6 +27,8 @@ const isRawMaterialsActive = computed(() => page.url.startsWith('/admin/master/r
|
||||
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 isExpensesActive = computed(() => page.url.startsWith('/admin/finance/expenses'));
|
||||
const showFinanceMenu = computed(() => can('cash.view') || can('expenses.view'));
|
||||
const showMasterMenu = computed(() => (
|
||||
can('categories.view') || can('products.view') || can('raw-materials.view')
|
||||
|| can('suppliers.view') || can('customers.view')
|
||||
@ -111,7 +113,7 @@ const showMasterMenu = computed(() => (
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroup v-if="showFinanceMenu">
|
||||
<SidebarGroupLabel>Keuangan</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
@ -123,6 +125,14 @@ const showMasterMenu = computed(() => (
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem v-if="can('expenses.view')">
|
||||
<SidebarMenuButton as-child tooltip="Pengeluaran" :is-active="isExpensesActive">
|
||||
<Link href="/admin/finance/expenses">
|
||||
<Receipt />
|
||||
<span>Pengeluaran</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
@ -0,0 +1,130 @@
|
||||
<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 { ExpenseFormData, ExpenseListItem } from '@/types/expense';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
expense?: ExpenseListItem | null;
|
||||
}>();
|
||||
|
||||
const isEditing = computed(() => props.expense != null);
|
||||
|
||||
const form = useForm<ExpenseFormData>({
|
||||
amount: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
}
|
||||
|
||||
function populateForm(expense: ExpenseListItem | null | undefined) {
|
||||
resetForm();
|
||||
|
||||
if (!expense) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.amount = String(expense.amount);
|
||||
form.description = expense.description;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.expense,
|
||||
(expense) => {
|
||||
populateForm(expense);
|
||||
},
|
||||
);
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
populateForm(props.expense);
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
});
|
||||
|
||||
function submit() {
|
||||
const options = {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
|
||||
},
|
||||
};
|
||||
|
||||
form.transform((data) => ({
|
||||
...data,
|
||||
amount: parseRupiah(data.amount),
|
||||
}));
|
||||
|
||||
if (isEditing.value && props.expense) {
|
||||
form.put(`/admin/finance/expenses/${props.expense.id}`, options);
|
||||
} else {
|
||||
form.post('/admin/finance/expenses', options);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ isEditing ? 'Ubah Pengeluaran' : 'Tambah Pengeluaran' }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<FieldGroup>
|
||||
<FieldSet class="grid gap-4">
|
||||
<Field>
|
||||
<FieldLabel for="expense-amount" required>Jumlah</FieldLabel>
|
||||
<RupiahInput id="expense-amount" v-model="form.amount" autofocus />
|
||||
<FieldError :errors="form.errors.amount ? [form.errors.amount] : []" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="expense-description" required>Keterangan</FieldLabel>
|
||||
<Textarea id="expense-description" v-model="form.description"
|
||||
placeholder="Contoh: Pembelian perlengkapan toko" 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>
|
||||
39
resources/js/components/admin/finance/expenses/columns.ts
Normal file
39
resources/js/components/admin/finance/expenses/columns.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { h } from 'vue';
|
||||
import DataTableActions from '@/components/admin/finance/expenses/data-table-actions.vue';
|
||||
import { DataTableColumnHeader } from '@/components/data-table';
|
||||
import type { ExpenseListItem } from '@/types/expense';
|
||||
|
||||
export function createColumns(onEdit: (expense: ExpenseListItem) => void): ColumnDef<ExpenseListItem>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'created_at_formatted',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Tanggal', column: 'created_at' }),
|
||||
},
|
||||
{
|
||||
accessorKey: 'amount_formatted',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Jumlah', column: 'amount' }),
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Keterangan', column: 'description' }),
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_by_name',
|
||||
enableSorting: false,
|
||||
header: () => 'Oleh',
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => h(DataTableActions, {
|
||||
expense: row.original,
|
||||
onEdit: () => onEdit(row.original),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { Pencil, Trash2 } from '@lucide/vue';
|
||||
import { 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 { ExpenseListItem } from '@/types/expense';
|
||||
|
||||
const props = defineProps<{
|
||||
expense: ExpenseListItem;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [expense: ExpenseListItem];
|
||||
}>();
|
||||
|
||||
const { can } = useCan();
|
||||
|
||||
const deleteConfirmOpen = ref(false);
|
||||
const deleteProcessing = ref(false);
|
||||
|
||||
function destroyExpense() {
|
||||
deleteProcessing.value = true;
|
||||
|
||||
router.delete(`/admin/finance/expenses/${props.expense.id}`, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
deleteConfirmOpen.value = false;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Gagal menghapus pengeluaran.');
|
||||
},
|
||||
onFinish: () => {
|
||||
deleteProcessing.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Tooltip v-if="can('expenses.update')">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-8" @click="emit('edit', expense)">
|
||||
<Pencil class="size-4" />
|
||||
<span class="sr-only">Ubah</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Ubah</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="can('expenses.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('expenses.delete')" v-model:open="deleteConfirmOpen" title="Hapus pengeluaran?"
|
||||
:description="`Pengeluaran ${expense.amount_formatted} akan dihapus. Saldo kas akan disesuaikan.`"
|
||||
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing" @confirm="destroyExpense" />
|
||||
</template>
|
||||
108
resources/js/pages/admin/finance/expenses/Index.vue
Normal file
108
resources/js/pages/admin/finance/expenses/Index.vue
Normal file
@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { Plus } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import ExpenseFormModal from '@/components/admin/finance/expenses/ExpenseFormModal.vue';
|
||||
import { createColumns } from '@/components/admin/finance/expenses/columns';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type { ExpenseListItem, PaginatedExpenses } from '@/types/expense';
|
||||
import type { DataTableSort } from '@/types/data-table';
|
||||
|
||||
const props = defineProps<{
|
||||
expenses: PaginatedExpenses;
|
||||
filters: {
|
||||
search: string;
|
||||
sort?: string;
|
||||
direction?: 'asc' | 'desc';
|
||||
};
|
||||
}>();
|
||||
|
||||
const { can } = useCan();
|
||||
const search = ref(props.filters.search ?? '');
|
||||
const formModalOpen = ref(false);
|
||||
const editingExpense = ref<ExpenseListItem | null>(null);
|
||||
|
||||
const { query, setSearch, setSort, resetFilters, syncFromServer } = useDataTableQuery({
|
||||
url: '/admin/finance/expenses',
|
||||
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.expenses.current_page,
|
||||
perPage: props.expenses.per_page,
|
||||
lastPage: props.expenses.last_page,
|
||||
total: props.expenses.total,
|
||||
}));
|
||||
|
||||
function openCreateModal() {
|
||||
editingExpense.value = null;
|
||||
formModalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditModal(expense: ExpenseListItem) {
|
||||
editingExpense.value = expense;
|
||||
formModalOpen.value = true;
|
||||
}
|
||||
|
||||
watch(search, (value) => {
|
||||
setSearch(value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.filters.search,
|
||||
(value) => {
|
||||
search.value = value ?? '';
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head title="Pengeluaran" />
|
||||
|
||||
<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">
|
||||
Pengeluaran
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Button v-if="can('expenses.create')" class="shrink-0 self-start sm:self-center" @click="openCreateModal">
|
||||
<Plus class="size-4" />
|
||||
Tambah
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card class="min-w-0">
|
||||
<CardContent class="min-w-0">
|
||||
<DataTable v-model:search="search" :columns="columns" :data="expenses.data" :pagination="pagination"
|
||||
:pagination-links="expenses.links" :sort="currentSort" @sort-change="setSort"
|
||||
@filters-reset="resetFilters" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ExpenseFormModal v-if="can('expenses.create') || can('expenses.update')" v-model:open="formModalOpen"
|
||||
:expense="editingExpense" />
|
||||
</AdminLayout>
|
||||
</template>
|
||||
26
resources/js/types/expense.ts
Normal file
26
resources/js/types/expense.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export type ExpenseListItem = {
|
||||
id: number;
|
||||
amount: number;
|
||||
amount_formatted: string;
|
||||
description: string;
|
||||
created_at_formatted: string;
|
||||
created_by_name: string;
|
||||
};
|
||||
|
||||
export type ExpenseFormData = {
|
||||
amount: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type PaginatedExpenses = {
|
||||
data: ExpenseListItem[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
links: Array<{
|
||||
url: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}>;
|
||||
};
|
||||
@ -3,6 +3,7 @@
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Controllers\Admin\DashboardController;
|
||||
use App\Http\Controllers\Admin\Finance\CashController;
|
||||
use App\Http\Controllers\Admin\Finance\ExpenseController;
|
||||
use App\Http\Controllers\Admin\Hr\EmployeeController;
|
||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||
@ -176,6 +177,24 @@
|
||||
->middleware('permission:'.Permission::CASH_DELETE->value)
|
||||
->name('transactions.destroy');
|
||||
});
|
||||
|
||||
Route::prefix('expenses')->name('expenses.')
|
||||
->middleware('permission:'.Permission::EXPENSES_VIEW->value)
|
||||
->group(function () {
|
||||
Route::get('/', [ExpenseController::class, 'index'])->name('index');
|
||||
|
||||
Route::post('/', [ExpenseController::class, 'store'])
|
||||
->middleware('permission:'.Permission::EXPENSES_CREATE->value)
|
||||
->name('store');
|
||||
|
||||
Route::put('{expense}', [ExpenseController::class, 'update'])
|
||||
->middleware('permission:'.Permission::EXPENSES_UPDATE->value)
|
||||
->name('update');
|
||||
|
||||
Route::delete('{expense}', [ExpenseController::class, 'destroy'])
|
||||
->middleware('permission:'.Permission::EXPENSES_DELETE->value)
|
||||
->name('destroy');
|
||||
});
|
||||
});
|
||||
|
||||
Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user