Add employee advance management features including EmployeeAdvance model, EmployeeAdvanceController for CRUD operations, and corresponding permissions in the Permission and Role enums. Implement request validation for employee advances and enhance UI components for employee advance listing and management, including data table and form modal. Update sidebar for employee advances navigation.

This commit is contained in:
Yoga Pangestu 2026-06-11 00:40:53 +07:00
parent e2f0feed9d
commit 39a74964fc
23 changed files with 1418 additions and 6 deletions

View File

@ -0,0 +1,25 @@
<?php
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum EmployeeAdvanceStatus: string
{
use ProvidesEnumOptions;
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
case PAID = 'paid';
public function label(): string
{
return match ($this) {
self::PENDING => 'Menunggu',
self::APPROVED => 'Disetujui',
self::REJECTED => 'Ditolak',
self::PAID => 'Lunas',
};
}
}

View File

@ -54,6 +54,13 @@ enum Permission: string
case EXPENSES_UPDATE = 'expenses.update';
case EXPENSES_DELETE = 'expenses.delete';
case EMPLOYEE_ADVANCES_VIEW = 'employee-advances.view';
case EMPLOYEE_ADVANCES_CREATE = 'employee-advances.create';
case EMPLOYEE_ADVANCES_UPDATE = 'employee-advances.update';
case EMPLOYEE_ADVANCES_DELETE = 'employee-advances.delete';
case EMPLOYEE_ADVANCES_VERIFY = 'employee-advances.verify';
case EMPLOYEE_ADVANCES_PAY = 'employee-advances.pay';
public function label(): string
{
return match ($this) {
@ -102,6 +109,13 @@ public function label(): string
self::EXPENSES_CREATE => 'Tambah Pengeluaran',
self::EXPENSES_UPDATE => 'Ubah Pengeluaran',
self::EXPENSES_DELETE => 'Hapus Pengeluaran',
self::EMPLOYEE_ADVANCES_VIEW => 'Lihat Kasbon',
self::EMPLOYEE_ADVANCES_CREATE => 'Ajukan Kasbon',
self::EMPLOYEE_ADVANCES_UPDATE => 'Ubah Kasbon',
self::EMPLOYEE_ADVANCES_DELETE => 'Hapus Kasbon',
self::EMPLOYEE_ADVANCES_VERIFY => 'Setujui/Tolak Kasbon',
self::EMPLOYEE_ADVANCES_PAY => 'Pelunasi Kasbon',
};
}
@ -124,6 +138,9 @@ public function group(): string
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',
self::EMPLOYEE_ADVANCES_VIEW, self::EMPLOYEE_ADVANCES_CREATE, self::EMPLOYEE_ADVANCES_UPDATE,
self::EMPLOYEE_ADVANCES_DELETE, self::EMPLOYEE_ADVANCES_VERIFY,
self::EMPLOYEE_ADVANCES_PAY => 'Kasbon',
};
}

View File

@ -74,6 +74,11 @@ public function permissions(): array
Permission::EXPENSES_CREATE,
Permission::EXPENSES_UPDATE,
Permission::EXPENSES_DELETE,
Permission::EMPLOYEE_ADVANCES_VIEW,
Permission::EMPLOYEE_ADVANCES_CREATE,
Permission::EMPLOYEE_ADVANCES_UPDATE,
Permission::EMPLOYEE_ADVANCES_DELETE,
Permission::EMPLOYEE_ADVANCES_PAY,
],
self::ADMIN_TOKO => [
Permission::DASHBOARD_VIEW,
@ -107,6 +112,11 @@ public function permissions(): array
Permission::EXPENSES_CREATE,
Permission::EXPENSES_UPDATE,
Permission::EXPENSES_DELETE,
Permission::EMPLOYEE_ADVANCES_VIEW,
Permission::EMPLOYEE_ADVANCES_CREATE,
Permission::EMPLOYEE_ADVANCES_UPDATE,
Permission::EMPLOYEE_ADVANCES_DELETE,
Permission::EMPLOYEE_ADVANCES_PAY,
],
self::ADMIN_BAHAN_BAKU => [
Permission::DASHBOARD_VIEW,

View File

@ -0,0 +1,99 @@
<?php
namespace App\Http\Controllers\Admin\Finance;
use App\Enums\Permission;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest;
use App\Http\Requests\Admin\Finance\RejectEmployeeAdvanceRequest;
use App\Models\EmployeeAdvance;
use App\Services\Finance\EmployeeAdvanceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class EmployeeAdvanceController extends Controller
{
use ParsesDataTableQuery;
public function __construct(
private readonly EmployeeAdvanceService $employeeAdvanceService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$user = auth()->user();
return Inertia::render('admin/finance/employee-advances/Index', [
'employeeAdvances' => $this->employeeAdvanceService->paginateForIndex($tableQuery),
'summary' => $this->employeeAdvanceService->outstandingSummary(),
'authEmployeeId' => $user?->employee?->id,
'canSubmit' => $user?->can(Permission::EMPLOYEE_ADVANCES_CREATE->value)
&& ! $user->can(Permission::EMPLOYEE_ADVANCES_VERIFY->value)
&& $user->employee !== null,
'filters' => $this->dataTableFilters($tableQuery),
]);
}
public function store(EmployeeAdvanceRequest $request): RedirectResponse
{
$this->employeeAdvanceService->create($request->validated());
Inertia::flash('success', 'Kasbon berhasil diajukan.');
return redirect()->route('admin.finance.employee-advances.index');
}
public function update(EmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse
{
$this->employeeAdvanceService->update($employeeAdvance, $request->validated());
Inertia::flash('success', 'Kasbon berhasil diperbarui.');
return redirect()->route('admin.finance.employee-advances.index');
}
public function destroy(EmployeeAdvance $employeeAdvance): RedirectResponse
{
abort_unless(! auth()->user()?->can(Permission::EMPLOYEE_ADVANCES_VERIFY->value), 403);
$this->employeeAdvanceService->delete($employeeAdvance);
Inertia::flash('success', 'Kasbon berhasil dihapus.');
return redirect()->route('admin.finance.employee-advances.index');
}
public function approve(EmployeeAdvance $employeeAdvance): RedirectResponse
{
$this->employeeAdvanceService->approve($employeeAdvance);
Inertia::flash('success', 'Kasbon disetujui dan dicairkan dari kas.');
return redirect()->route('admin.finance.employee-advances.index');
}
public function reject(RejectEmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse
{
$this->employeeAdvanceService->reject(
$employeeAdvance,
$request->validated('reason'),
);
Inertia::flash('success', 'Kasbon berhasil ditolak.');
return redirect()->route('admin.finance.employee-advances.index');
}
public function pay(EmployeeAdvance $employeeAdvance): RedirectResponse
{
$this->employeeAdvanceService->pay($employeeAdvance);
Inertia::flash('success', 'Kasbon berhasil dilunasi.');
return redirect()->route('admin.finance.employee-advances.index');
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests\Admin\Finance;
use App\Enums\EmployeeAdvanceStatus;
use App\Enums\Permission;
use App\Models\EmployeeAdvance;
use Illuminate\Foundation\Http\FormRequest;
class EmployeeAdvanceRequest extends FormRequest
{
public function authorize(): bool
{
$user = $this->user();
if ($user === null || $user->can(Permission::EMPLOYEE_ADVANCES_VERIFY->value)) {
return false;
}
if ($this->isMethod('POST')) {
return $user->can(Permission::EMPLOYEE_ADVANCES_CREATE->value)
&& $user->employee !== null;
}
if (! $user->can(Permission::EMPLOYEE_ADVANCES_UPDATE->value)) {
return false;
}
/** @var EmployeeAdvance|null $employeeAdvance */
$employeeAdvance = $this->route('employeeAdvance');
return $employeeAdvance?->status === EmployeeAdvanceStatus::PENDING
&& $user->employee?->id === $employeeAdvance->employee_id;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:1'],
'description' => ['required', 'string', 'max:500'],
'due_date' => ['required', 'date'],
];
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Admin\Finance;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
class RejectEmployeeAdvanceRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::EMPLOYEE_ADVANCES_VERIFY->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'reason' => ['required', 'string', 'max:500'],
];
}
}

View File

@ -48,14 +48,28 @@ public function balanceAfterFormatted(): Attribute
public function referenceLabel(): Attribute
{
return Attribute::make(
get: fn () => self::labelForReferenceType($this->reference_type),
get: function () {
if ($this->reference_type === EmployeeAdvance::class && $this->reference instanceof EmployeeAdvance) {
return $this->reference->repayment_cash_transaction_id === $this->id
? 'Pelunasan Kasbon'
: 'Pencairan Kasbon';
}
return self::labelForReferenceType($this->reference_type);
},
);
}
public function isIncoming(): Attribute
{
return Attribute::make(
get: fn () => self::isIncomingReference($this->reference_type),
get: function () {
if ($this->reference_type === EmployeeAdvance::class && $this->reference instanceof EmployeeAdvance) {
return $this->reference->repayment_cash_transaction_id === $this->id;
}
return self::isIncomingTransaction($this);
},
);
}
@ -81,6 +95,7 @@ public static function labelForReferenceType(?string $referenceType): string
return match ($referenceType) {
Expense::class => 'Pengeluaran',
EmployeeAdvance::class => 'Kasbon Pegawai',
default => class_basename($referenceType),
};
}
@ -96,6 +111,15 @@ public static function isIncomingReference(?string $referenceType): bool
};
}
public static function isIncomingTransaction(self $transaction): bool
{
if ($transaction->reference_type === EmployeeAdvance::class && $transaction->reference instanceof EmployeeAdvance) {
return $transaction->reference->repayment_cash_transaction_id === $transaction->id;
}
return self::isIncomingReference($transaction->reference_type);
}
public function cashAccount(): BelongsTo
{
return $this->belongsTo(CashAccount::class);

View File

@ -0,0 +1,14 @@
<?php
namespace App\Models\Concerns;
use App\Models\Rejection;
use Illuminate\Database\Eloquent\Relations\MorphOne;
trait HasRejection
{
public function rejection(): MorphOne
{
return $this->morphOne(Rejection::class, 'rejectable');
}
}

View File

@ -11,6 +11,7 @@
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
@ -91,4 +92,9 @@ public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function advances(): HasMany
{
return $this->hasMany(EmployeeAdvance::class);
}
}

View File

@ -0,0 +1,136 @@
<?php
namespace App\Models;
use App\Enums\EmployeeAdvanceStatus;
use App\Models\Concerns\HasRejection;
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;
#[Guarded(['id'])]
#[Appends([
'amount_formatted',
'due_date_formatted',
'due_date_input',
'status_label',
'employee_name',
'rejection_reason',
'created_at_formatted',
'is_editable',
'can_verify',
'can_pay',
])]
class EmployeeAdvance extends Model
{
use HasRejection;
protected function casts(): array
{
return [
'amount' => 'integer',
'due_date' => 'date',
'status' => EmployeeAdvanceStatus::class,
'verified_at' => 'datetime',
'paid_at' => 'datetime',
];
}
public function amountFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'),
);
}
public function dueDateFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->due_date?->translatedFormat('l, d F Y'),
);
}
public function dueDateInput(): Attribute
{
return Attribute::make(
get: fn () => $this->due_date?->format('Y-m-d'),
);
}
public function statusLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->status?->label(),
);
}
public function employeeName(): Attribute
{
return Attribute::make(
get: fn () => $this->employee?->user?->profile?->full_name
?? $this->employee?->user?->username,
);
}
public function rejectionReason(): Attribute
{
return Attribute::make(
get: fn () => $this->rejection?->reason,
);
}
public function createdAtFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'),
);
}
public function isEditable(): Attribute
{
return Attribute::make(
get: fn () => $this->status === EmployeeAdvanceStatus::PENDING,
);
}
public function canVerify(): Attribute
{
return Attribute::make(
get: fn () => $this->status === EmployeeAdvanceStatus::PENDING,
);
}
public function canPay(): Attribute
{
return Attribute::make(
get: fn () => $this->status === EmployeeAdvanceStatus::APPROVED,
);
}
public function employee(): BelongsTo
{
return $this->belongsTo(Employee::class);
}
public function cashTransaction(): BelongsTo
{
return $this->belongsTo(CashTransaction::class);
}
public function repaymentCashTransaction(): BelongsTo
{
return $this->belongsTo(CashTransaction::class, 'repayment_cash_transaction_id');
}
public function verifiedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'verified_by_id');
}
public function paidBy(): BelongsTo
{
return $this->belongsTo(User::class, 'paid_by_id');
}
}

22
app/Models/Rejection.php Normal file
View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Guarded(['id'])]
class Rejection extends Model
{
public function rejectable(): MorphTo
{
return $this->morphTo();
}
public function rejectedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'rejected_by_id');
}
}

View File

@ -215,13 +215,14 @@ private function recalculateBalances(CashAccount $cashAccount): void
$runningBalance = 0;
$transactions = CashTransaction::query()
->with('reference')
->where('cash_account_id', $account->id)
->orderBy('created_at')
->orderBy('id')
->get();
foreach ($transactions as $transaction) {
if (CashTransaction::isIncomingReference($transaction->reference_type)) {
if (CashTransaction::isIncomingTransaction($transaction)) {
$runningBalance += $transaction->amount;
} else {
$runningBalance -= $transaction->amount;

View File

@ -0,0 +1,215 @@
<?php
namespace App\Services\Finance;
use App\Enums\EmployeeAdvanceStatus;
use App\Models\EmployeeAdvance;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class EmployeeAdvanceService
{
public function __construct(
private readonly CashService $cashService,
) {}
/**
* @return array{outstanding_amount: int, outstanding_amount_formatted: string, outstanding_count: int}
*/
public function outstandingSummary(): array
{
$outstandingAmount = (int) EmployeeAdvance::query()
->where('status', EmployeeAdvanceStatus::APPROVED)
->sum('amount');
$outstandingCount = EmployeeAdvance::query()
->where('status', EmployeeAdvanceStatus::APPROVED)
->count();
return [
'outstanding_amount' => $outstandingAmount,
'outstanding_amount_formatted' => 'Rp '.number_format($outstandingAmount, 0, ',', '.'),
'outstanding_count' => $outstandingCount,
];
}
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery): LengthAwarePaginator
{
$query = EmployeeAdvance::query()
->with(['employee.user.profile', 'rejection'])
->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}%")
->orWhereHas('employee.user.profile', fn (Builder $query) => $query->where('full_name', 'like', "%{$search}%"))
->orWhereHas('employee.user', fn (Builder $query) => $query->where('username', 'like', "%{$search}%"))
->orWhereHas('rejection', fn (Builder $query) => $query->where('reason', 'like', "%{$search}%"));
});
});
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(10)
->withQueryString();
}
/**
* @param array{amount: int, description: string, due_date: string} $validated
*/
public function create(array $validated): void
{
$employee = auth()->user()?->employee;
if ($employee === null) {
throw ValidationException::withMessages([
'employee' => 'Akun Anda tidak terhubung ke data pegawai.',
]);
}
EmployeeAdvance::create([
'employee_id' => $employee->id,
'amount' => (int) $validated['amount'],
'description' => $validated['description'],
'due_date' => $validated['due_date'],
'status' => EmployeeAdvanceStatus::PENDING,
]);
}
/**
* @param array{amount: int, description: string, due_date: string} $validated
*/
public function update(EmployeeAdvance $employeeAdvance, array $validated): void
{
$this->ensureOwnedBySubmitter($employeeAdvance);
$this->ensurePending($employeeAdvance, 'Kasbon hanya dapat diubah saat status menunggu.');
$employeeAdvance->amount = (int) $validated['amount'];
$employeeAdvance->description = $validated['description'];
$employeeAdvance->due_date = $validated['due_date'];
$employeeAdvance->save();
}
public function delete(EmployeeAdvance $employeeAdvance): void
{
$this->ensureOwnedBySubmitter($employeeAdvance);
$this->ensurePending($employeeAdvance, 'Kasbon hanya dapat dihapus saat status menunggu.');
$employeeAdvance->delete();
}
public function approve(EmployeeAdvance $employeeAdvance): void
{
$this->ensurePending($employeeAdvance, 'Kasbon ini sudah diverifikasi.');
$user = auth()->user();
DB::transaction(function () use ($employeeAdvance, $user): void {
$employeeAdvance->loadMissing('employee.user.profile');
$description = sprintf(
'Pencairan kasbon: %s',
$employeeAdvance->employeeName,
);
$cashTransaction = $this->cashService->recordOutgoing(
$employeeAdvance,
$employeeAdvance->amount,
$description,
$user,
);
$employeeAdvance->cash_transaction_id = $cashTransaction->id;
$employeeAdvance->status = EmployeeAdvanceStatus::APPROVED;
$employeeAdvance->verified_at = now();
$employeeAdvance->verified_by_id = $user->id;
$employeeAdvance->save();
});
}
public function reject(EmployeeAdvance $employeeAdvance, string $reason): void
{
$this->ensurePending($employeeAdvance, 'Kasbon ini sudah diverifikasi.');
$user = auth()->user();
DB::transaction(function () use ($employeeAdvance, $user, $reason): void {
$employeeAdvance->status = EmployeeAdvanceStatus::REJECTED;
$employeeAdvance->verified_at = now();
$employeeAdvance->verified_by_id = $user->id;
$employeeAdvance->save();
$employeeAdvance->rejection()->create([
'reason' => $reason,
'rejected_by_id' => $user->id,
]);
});
}
public function pay(EmployeeAdvance $employeeAdvance): void
{
if ($employeeAdvance->status !== EmployeeAdvanceStatus::APPROVED) {
throw ValidationException::withMessages([
'employee_advance' => 'Kasbon hanya dapat dilunasi setelah disetujui.',
]);
}
$user = auth()->user();
DB::transaction(function () use ($employeeAdvance, $user): void {
$employeeAdvance->loadMissing('employee.user.profile');
$description = sprintf(
'Pelunasan kasbon: %s',
$employeeAdvance->employeeName,
);
$cashTransaction = $this->cashService->recordIncoming(
$employeeAdvance,
$employeeAdvance->amount,
$description,
$user,
);
$employeeAdvance->repayment_cash_transaction_id = $cashTransaction->id;
$employeeAdvance->paid_at = now();
$employeeAdvance->paid_by_id = $user->id;
$employeeAdvance->status = EmployeeAdvanceStatus::PAID;
$employeeAdvance->save();
});
}
private function ensureOwnedBySubmitter(EmployeeAdvance $employeeAdvance): void
{
if (auth()->user()?->employee?->id !== $employeeAdvance->employee_id) {
throw ValidationException::withMessages([
'employee_advance' => 'Anda tidak memiliki akses untuk mengubah kasbon ini.',
]);
}
}
private function ensurePending(EmployeeAdvance $employeeAdvance, string $message): void
{
if ($employeeAdvance->status !== EmployeeAdvanceStatus::PENDING) {
throw ValidationException::withMessages([
'employee_advance' => $message,
]);
}
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['created_at', 'amount', 'due_date', 'status', 'description'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
}

View File

@ -0,0 +1,37 @@
<?php
use App\Enums\EmployeeAdvanceStatus;
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('employee_advances', function (Blueprint $table) {
$table->id();
$table->foreignId('employee_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('amount');
$table->string('description', 500);
$table->date('due_date');
$table->enum('status', array_column(EmployeeAdvanceStatus::cases(), 'value'))
->default(EmployeeAdvanceStatus::PENDING->value);
$table->foreignId('cash_transaction_id')->nullable()->unique()->constrained()->restrictOnDelete();
$table->foreignId('repayment_cash_transaction_id')->nullable()->unique()->constrained('cash_transactions')->restrictOnDelete();
$table->timestamp('verified_at')->nullable();
$table->foreignId('verified_by_id')->nullable()->constrained('users')->restrictOnDelete();
$table->timestamp('paid_at')->nullable();
$table->foreignId('paid_by_id')->nullable()->constrained('users')->restrictOnDelete();
$table->timestamps();
$table->index(['employee_id', 'status']);
$table->index(['status', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('employee_advances');
}
};

View File

@ -0,0 +1,26 @@
<?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('rejections', function (Blueprint $table) {
$table->id();
$table->morphs('rejectable');
$table->string('reason', 500);
$table->foreignId('rejected_by_id')->constrained('users')->restrictOnDelete();
$table->timestamps();
$table->unique(['rejectable_type', 'rejectable_id']);
});
}
public function down(): void
{
Schema::dropIfExists('rejections');
}
};

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { FolderTree, Layers, LayoutDashboard, Package, Receipt, User, UserCheck, Users, Wallet } from '@lucide/vue';
import { Banknote, FolderTree, Layers, LayoutDashboard, Package, Receipt, User, UserCheck, Users, Wallet } from '@lucide/vue';
import { computed } from 'vue';
import {
Sidebar,
@ -28,7 +28,10 @@ const isSuppliersActive = computed(() => page.url.startsWith('/admin/master/supp
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 isEmployeeAdvancesActive = computed(() => page.url.startsWith('/admin/finance/employee-advances'));
const showFinanceMenu = computed(() => (
can('cash.view') || can('expenses.view') || can('employee-advances.view')
));
const showMasterMenu = computed(() => (
can('categories.view') || can('products.view') || can('raw-materials.view')
|| can('suppliers.view') || can('customers.view')
@ -133,6 +136,14 @@ const showMasterMenu = computed(() => (
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="can('employee-advances.view')">
<SidebarMenuButton as-child tooltip="Kasbon" :is-active="isEmployeeAdvancesActive">
<Link href="/admin/finance/employee-advances">
<Banknote />
<span>Kasbon</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View File

@ -0,0 +1,138 @@
<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 { DatePicker } from '@/components/ui/date-picker';
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 { EmployeeAdvanceFormData, EmployeeAdvanceListItem } from '@/types/employee-advance';
const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{
employeeAdvance?: EmployeeAdvanceListItem | null;
}>();
const isEditing = computed(() => props.employeeAdvance != null);
const form = useForm<EmployeeAdvanceFormData>({
amount: '',
description: '',
due_date: '',
});
function resetForm() {
form.reset();
form.clearErrors();
}
function populateForm(employeeAdvance: EmployeeAdvanceListItem | null | undefined) {
resetForm();
if (!employeeAdvance) {
return;
}
form.amount = String(employeeAdvance.amount);
form.description = employeeAdvance.description;
form.due_date = employeeAdvance.due_date_input;
}
watch(
() => props.employeeAdvance,
(employeeAdvance) => {
populateForm(employeeAdvance);
},
);
watch(open, (isOpen) => {
if (isOpen) {
populateForm(props.employeeAdvance);
} 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.employeeAdvance) {
form.put(`/admin/finance/employee-advances/${props.employeeAdvance.id}`, options);
} else {
form.post('/admin/finance/employee-advances', options);
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ isEditing ? 'Ubah Pengajuan Kasbon' : 'Ajukan Kasbon' }}</DialogTitle>
</DialogHeader>
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="employee-advance-amount" required>Jumlah</FieldLabel>
<RupiahInput id="employee-advance-amount" v-model="form.amount" autofocus />
<FieldError :errors="form.errors.amount ? [form.errors.amount] : []" />
</Field>
<Field>
<FieldLabel for="employee-advance-description" required>Keterangan</FieldLabel>
<Textarea id="employee-advance-description" v-model="form.description"
placeholder="Contoh: Kebutuhan mendesak" rows="3" />
<FieldError :errors="form.errors.description ? [form.errors.description] : []" />
</Field>
<Field>
<FieldLabel for="employee-advance-due-date" required>Jatuh Tempo</FieldLabel>
<DatePicker id="employee-advance-due-date" v-model="form.due_date" />
<FieldError :errors="form.errors.due_date ? [form.errors.due_date] : []" />
</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...' : 'Ajukan' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { 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 { Textarea } from '@/components/ui/textarea';
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{
employeeAdvance?: EmployeeAdvanceListItem | null;
}>();
const form = useForm({
reason: '',
});
function resetForm() {
form.reset();
form.clearErrors();
}
watch(open, (isOpen) => {
if (!isOpen) {
resetForm();
}
});
function submit() {
if (!props.employeeAdvance) {
return;
}
form.post(`/admin/finance/employee-advances/${props.employeeAdvance.id}/reject`, {
preserveScroll: true,
onSuccess: () => {
open.value = false;
},
onError: () => {
toast.error('Gagal menolak kasbon. Periksa kembali formulir.');
},
});
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Tolak Kasbon</DialogTitle>
</DialogHeader>
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="reject-reason" required>Alasan Penolakan</FieldLabel>
<Textarea id="reject-reason" v-model="form.reason" placeholder="Tuliskan alasan penolakan"
rows="3" autofocus />
<FieldError :errors="form.errors.reason ? [form.errors.reason] : []" />
</Field>
</FieldSet>
</FieldGroup>
<DialogFooter class="mt-6">
<Button type="button" variant="outline" :disabled="form.processing" @click="open = false">
Batal
</Button>
<Button type="submit" variant="destructive" :disabled="form.processing">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : 'Tolak' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,80 @@
import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue';
import DataTableActions from '@/components/admin/finance/employee-advances/data-table-actions.vue';
import { DataTableColumnHeader } from '@/components/data-table';
import { Badge } from '@/components/ui/badge';
import type { EmployeeAdvanceListItem } from '@/types/employee-advance';
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'approved':
return 'default';
case 'paid':
return 'secondary';
case 'rejected':
return 'destructive';
default:
return 'outline';
}
}
export function createColumns(
authEmployeeId: number | null,
onEdit: (employeeAdvance: EmployeeAdvanceListItem) => void,
onReject: (employeeAdvance: EmployeeAdvanceListItem) => void,
): ColumnDef<EmployeeAdvanceListItem>[] {
return [
{
accessorKey: 'created_at_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Tanggal', column: 'created_at' }),
},
{
accessorKey: 'employee_name',
enableSorting: false,
header: () => 'Pegawai',
},
{
accessorKey: 'amount_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Jumlah', column: 'amount' }),
},
{
accessorKey: 'description',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Keterangan', column: 'description' }),
},
{
accessorKey: 'due_date_formatted',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Jatuh Tempo', column: 'due_date' }),
},
{
accessorKey: 'status_label',
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Status', column: 'status' }),
cell: ({ row }) => h(
Badge,
{ variant: statusVariant(row.original.status) },
() => row.original.status_label,
),
},
{
accessorKey: 'rejection_reason',
enableSorting: false,
header: () => 'Alasan Penolakan',
cell: ({ row }) => row.original.rejection_reason ?? '-',
},
{
id: 'actions',
enableSorting: false,
enableHiding: false,
cell: ({ row }) => h(DataTableActions, {
employeeAdvance: row.original,
authEmployeeId,
onEdit: () => onEdit(row.original),
onReject: () => onReject(row.original),
}),
},
];
}

View File

@ -0,0 +1,169 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { Banknote, Check, Pencil, Trash2, X } 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 { EmployeeAdvanceListItem } from '@/types/employee-advance';
const props = defineProps<{
employeeAdvance: EmployeeAdvanceListItem;
authEmployeeId: number | null;
}>();
const emit = defineEmits<{
edit: [employeeAdvance: EmployeeAdvanceListItem];
reject: [employeeAdvance: EmployeeAdvanceListItem];
}>();
const { can } = useCan();
const isOwner = computed(() => (
props.authEmployeeId !== null
&& props.employeeAdvance.employee_id === props.authEmployeeId
));
const canEdit = computed(() => (
props.employeeAdvance.is_editable
&& !can('employee-advances.verify')
&& isOwner.value
&& can('employee-advances.update')
));
const canDelete = computed(() => (
props.employeeAdvance.is_editable
&& !can('employee-advances.verify')
&& isOwner.value
&& can('employee-advances.delete')
));
const deleteConfirmOpen = ref(false);
const approveConfirmOpen = ref(false);
const payConfirmOpen = ref(false);
const deleteProcessing = ref(false);
const approveProcessing = ref(false);
const payProcessing = ref(false);
function destroyEmployeeAdvance() {
deleteProcessing.value = true;
router.delete(`/admin/finance/employee-advances/${props.employeeAdvance.id}`, {
preserveScroll: true,
onSuccess: () => {
deleteConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menghapus kasbon.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
function approveEmployeeAdvance() {
approveProcessing.value = true;
router.post(`/admin/finance/employee-advances/${props.employeeAdvance.id}/approve`, {}, {
preserveScroll: true,
onSuccess: () => {
approveConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menyetujui kasbon.');
},
onFinish: () => {
approveProcessing.value = false;
},
});
}
function payEmployeeAdvance() {
payProcessing.value = true;
router.post(`/admin/finance/employee-advances/${props.employeeAdvance.id}/pay`, {}, {
preserveScroll: true,
onSuccess: () => {
payConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal melunasi kasbon.');
},
onFinish: () => {
payProcessing.value = false;
},
});
}
</script>
<template>
<div class="flex items-center justify-end gap-1">
<Tooltip v-if="canEdit">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" @click="emit('edit', employeeAdvance)">
<Pencil class="size-4" />
<span class="sr-only">Ubah</span>
</Button>
</TooltipTrigger>
<TooltipContent>Ubah</TooltipContent>
</Tooltip>
<Tooltip v-if="employeeAdvance.can_verify && can('employee-advances.verify')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8 text-green-600 hover:text-green-600"
@click="approveConfirmOpen = true">
<Check class="size-4" />
<span class="sr-only">Setujui</span>
</Button>
</TooltipTrigger>
<TooltipContent>Setujui</TooltipContent>
</Tooltip>
<Tooltip v-if="employeeAdvance.can_verify && can('employee-advances.verify')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
@click="emit('reject', employeeAdvance)">
<X class="size-4" />
<span class="sr-only">Tolak</span>
</Button>
</TooltipTrigger>
<TooltipContent>Tolak</TooltipContent>
</Tooltip>
<Tooltip v-if="employeeAdvance.can_pay && can('employee-advances.pay')">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" @click="payConfirmOpen = true">
<Banknote class="size-4" />
<span class="sr-only">Lunasi</span>
</Button>
</TooltipTrigger>
<TooltipContent>Lunasi Kasbon</TooltipContent>
</Tooltip>
<Tooltip v-if="canDelete">
<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('employee-advances.verify')" v-model:open="approveConfirmOpen" title="Setujui kasbon?"
:description="`Kasbon ${employeeAdvance.amount_formatted} untuk ${employeeAdvance.employee_name} akan dicairkan. Saldo kas akan berkurang.`"
confirm-label="Setujui" cancel-label="Batal" :loading="approveProcessing" @confirm="approveEmployeeAdvance" />
<ConfirmDialog v-if="can('employee-advances.pay')" v-model:open="payConfirmOpen" title="Lunasi kasbon?"
:description="`Pelunasan kasbon ${employeeAdvance.amount_formatted} dari ${employeeAdvance.employee_name}. Saldo kas akan bertambah.`"
confirm-label="Lunasi" cancel-label="Batal" :loading="payProcessing" @confirm="payEmployeeAdvance" />
<ConfirmDialog v-if="canDelete" v-model:open="deleteConfirmOpen" title="Hapus kasbon?"
:description="`Pengajuan kasbon ${employeeAdvance.amount_formatted} akan dihapus.`" confirm-label="Hapus"
cancel-label="Batal" destructive :loading="deleteProcessing" @confirm="destroyEmployeeAdvance" />
</template>

View File

@ -0,0 +1,133 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { HandCoins, Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { createColumns } from '@/components/admin/finance/employee-advances/columns';
import EmployeeAdvanceFormModal from '@/components/admin/finance/employee-advances/EmployeeAdvanceFormModal.vue';
import RejectEmployeeAdvanceModal from '@/components/admin/finance/employee-advances/RejectEmployeeAdvanceModal.vue';
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 { DataTableSort } from '@/types/data-table';
import type { EmployeeAdvanceListItem, EmployeeAdvancePageProps } from '@/types/employee-advance';
const props = defineProps<EmployeeAdvancePageProps>();
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const formModalOpen = ref(false);
const rejectModalOpen = ref(false);
const editingEmployeeAdvance = ref<EmployeeAdvanceListItem | null>(null);
const rejectingEmployeeAdvance = ref<EmployeeAdvanceListItem | null>(null);
const { query, setSearch, setSort, resetFilters, syncFromServer } = useDataTableQuery({
url: '/admin/finance/employee-advances',
initial: { ...props.filters },
});
useDataTableQuerySync(() => props.filters, syncFromServer);
const columns = computed(() => createColumns(
props.authEmployeeId,
openEditModal,
openRejectModal,
));
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.employeeAdvances.current_page,
perPage: props.employeeAdvances.per_page,
lastPage: props.employeeAdvances.last_page,
total: props.employeeAdvances.total,
}));
function openCreateModal() {
editingEmployeeAdvance.value = null;
formModalOpen.value = true;
}
function openEditModal(employeeAdvance: EmployeeAdvanceListItem) {
editingEmployeeAdvance.value = employeeAdvance;
formModalOpen.value = true;
}
function openRejectModal(employeeAdvance: EmployeeAdvanceListItem) {
rejectingEmployeeAdvance.value = employeeAdvance;
rejectModalOpen.value = true;
}
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Kasbon" />
<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">
Kasbon
</h2>
</div>
<Button v-if="canSubmit" class="shrink-0 self-start sm:self-center" @click="openCreateModal">
<Plus class="size-4" />
Ajukan
</Button>
</div>
<Card v-if="can('employee-advances.verify')">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium text-muted-foreground">
Total Piutang Kasbon
</CardTitle>
<HandCoins class="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-3xl font-bold tracking-tight">
{{ summary.outstanding_amount_formatted }}
</div>
<p class="mt-1 text-sm text-muted-foreground">
{{ summary.outstanding_count }} kasbon belum lunas
</p>
</CardContent>
</Card>
<Card class="min-w-0">
<CardContent class="min-w-0 pt-6">
<DataTable v-model:search="search" :columns="columns" :data="employeeAdvances.data"
:pagination="pagination" :pagination-links="employeeAdvances.links" :sort="currentSort"
@sort-change="setSort" @filters-reset="resetFilters" />
</CardContent>
</Card>
<EmployeeAdvanceFormModal v-if="canSubmit" v-model:open="formModalOpen"
:employee-advance="editingEmployeeAdvance" />
<RejectEmployeeAdvanceModal v-if="can('employee-advances.verify')" v-model:open="rejectModalOpen"
:employee-advance="rejectingEmployeeAdvance" />
</AdminLayout>
</template>

View File

@ -0,0 +1,54 @@
export type EmployeeAdvanceListItem = {
id: number;
employee_id: number;
amount: number;
amount_formatted: string;
description: string;
due_date_input: string;
due_date_formatted: string;
status: string;
status_label: string;
rejection_reason: string | null;
employee_name: string;
created_at_formatted: string;
is_editable: boolean;
can_verify: boolean;
can_pay: boolean;
};
export type EmployeeAdvanceFormData = {
amount: string;
description: string;
due_date: string;
};
export type PaginatedEmployeeAdvances = {
data: EmployeeAdvanceListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};
export type EmployeeAdvanceSummary = {
outstanding_amount: number;
outstanding_amount_formatted: string;
outstanding_count: number;
};
export type EmployeeAdvancePageProps = {
employeeAdvances: PaginatedEmployeeAdvances;
summary: EmployeeAdvanceSummary;
authEmployeeId: number | null;
canSubmit: boolean;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
};
};

View File

@ -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\EmployeeAdvanceController;
use App\Http\Controllers\Admin\Finance\ExpenseController;
use App\Http\Controllers\Admin\Hr\EmployeeController;
use App\Http\Controllers\Admin\Master\CategoryController;
@ -195,6 +196,36 @@
->middleware('permission:'.Permission::EXPENSES_DELETE->value)
->name('destroy');
});
Route::prefix('employee-advances')->name('employee-advances.')
->middleware('permission:'.Permission::EMPLOYEE_ADVANCES_VIEW->value)
->group(function () {
Route::get('/', [EmployeeAdvanceController::class, 'index'])->name('index');
Route::post('/', [EmployeeAdvanceController::class, 'store'])
->middleware('permission:'.Permission::EMPLOYEE_ADVANCES_CREATE->value)
->name('store');
Route::put('{employeeAdvance}', [EmployeeAdvanceController::class, 'update'])
->middleware('permission:'.Permission::EMPLOYEE_ADVANCES_UPDATE->value)
->name('update');
Route::delete('{employeeAdvance}', [EmployeeAdvanceController::class, 'destroy'])
->middleware('permission:'.Permission::EMPLOYEE_ADVANCES_DELETE->value)
->name('destroy');
Route::post('{employeeAdvance}/approve', [EmployeeAdvanceController::class, 'approve'])
->middleware('permission:'.Permission::EMPLOYEE_ADVANCES_VERIFY->value)
->name('approve');
Route::post('{employeeAdvance}/reject', [EmployeeAdvanceController::class, 'reject'])
->middleware('permission:'.Permission::EMPLOYEE_ADVANCES_VERIFY->value)
->name('reject');
Route::post('{employeeAdvance}/pay', [EmployeeAdvanceController::class, 'pay'])
->middleware('permission:'.Permission::EMPLOYEE_ADVANCES_PAY->value)
->name('pay');
});
});
Route::prefix('hr')->name('hr.')->middleware('permission:'.Permission::EMPLOYEES_VIEW->value)->group(function () {
@ -213,7 +244,7 @@
->middleware('permission:'.Permission::EMPLOYEES_CREATE->value)
->name('create');
Route::post('employees', [EmployeeController::class, 'store'])
Route::post('/', [EmployeeController::class, 'store'])
->middleware('permission:'.Permission::EMPLOYEES_CREATE->value)
->name('store');