feat: implement employee advance payment management with new model, migration, and frontend updates

This commit is contained in:
Yoga Pangestu 2026-06-28 09:33:40 +07:00
parent fe1549e0a5
commit 414ce51904
10 changed files with 186 additions and 9 deletions

View File

@ -13,6 +13,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Guarded(['id'])]
#[Appends([
@ -71,6 +72,11 @@ public function verifiedBy(): BelongsTo
return $this->belongsTo(User::class, 'verified_by_id');
}
public function payments(): HasMany
{
return $this->hasMany(EmployeeAdvancePayment::class)->latest('paid_at');
}
public function amountFormatted(): Attribute
{
return Attribute::make(

View File

@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Guarded(['id'])]
#[Appends([
'amount_formatted',
'paid_at_formatted',
])]
class EmployeeAdvancePayment extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'amount' => 'integer',
'paid_at' => 'datetime',
];
}
public function employeeAdvance(): BelongsTo
{
return $this->belongsTo(EmployeeAdvance::class);
}
public function paidBy(): BelongsTo
{
return $this->belongsTo(User::class, 'paid_by_id');
}
public function cashTransaction(): BelongsTo
{
return $this->belongsTo(CashTransaction::class);
}
public function amountFormatted(): \Illuminate\Database\Eloquent\Casts\Attribute
{
return \Illuminate\Database\Eloquent\Casts\Attribute::make(
get: fn () => 'Rp '.number_format($this->amount, 0, ',', '.'),
);
}
public function paidAtFormatted(): \Illuminate\Database\Eloquent\Casts\Attribute
{
return \Illuminate\Database\Eloquent\Casts\Attribute::make(
get: fn () => $this->paid_at?->translatedFormat('l, d F Y H:i'),
);
}
}

View File

@ -5,6 +5,7 @@
use App\Enums\EmployeeAdvanceStatus;
use App\Enums\Role;
use App\Models\EmployeeAdvance;
use App\Models\EmployeeAdvancePayment;
use App\Models\User;
use App\Services\Concerns\ResolvesAuthenticatedEmployee;
use App\Services\System\PushNotificationService;
@ -49,7 +50,7 @@ public function outstandingSummary(?User $user = null): array
public function paginateForIndex(array $tableQuery, User $user, string $status = ''): LengthAwarePaginator
{
$query = EmployeeAdvance::query()
->with(['employee.user.profile', 'rejection'])
->with(['employee.user.profile', 'rejection', 'payments'])
->when(! $user->hasAnyRole([Role::OWNER->value, Role::DEVELOPER->value, Role::DIREKTUR->value]), function (Builder $query) use ($user): void {
$employeeId = $user->employee?->id ?? -1;
$query->where('employee_id', $employeeId);
@ -275,6 +276,15 @@ public function pay(EmployeeAdvance $employeeAdvance, User $user, ?int $payAmoun
'paid_by_id' => $isFullPayment ? $user->id : $employeeAdvance->paid_by_id,
'status' => $isFullPayment ? EmployeeAdvanceStatus::PAID : EmployeeAdvanceStatus::PARTIALLY_PAID,
]);
EmployeeAdvancePayment::create([
'employee_advance_id' => $employeeAdvance->id,
'paid_by_id' => $user->id,
'cash_transaction_id' => $cashTransaction->id,
'amount' => $amountToPay,
'description' => $description,
'paid_at' => now(),
]);
});
} catch (ValidationException $e) {
throw $e;

View File

@ -0,0 +1,17 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement("ALTER TABLE employee_advances MODIFY COLUMN status ENUM('pending','approved','rejected','partially_paid','paid') DEFAULT 'pending'");
}
public function down(): void
{
DB::statement("ALTER TABLE employee_advances MODIFY COLUMN status ENUM('pending','approved','rejected','paid') DEFAULT 'pending'");
}
};

View File

@ -0,0 +1,31 @@
<?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('employee_advance_payments', function (Blueprint $table) {
$table->id();
$table->foreignId('employee_advance_id')->constrained()->cascadeOnDelete();
$table->foreignId('paid_by_id')->nullable()->constrained('users')->restrictOnDelete();
$table->foreignId('cash_transaction_id')->nullable()->unique()->constrained('cash_transactions')->restrictOnDelete();
$table->unsignedBigInteger('amount');
$table->string('description', 100)->nullable();
$table->timestamp('paid_at')->useCurrent();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
});
}
public function down(): void
{
Schema::dropIfExists('employee_advance_payments');
}
};

View File

@ -44,6 +44,7 @@ const emit = defineEmits<{
{{ description }}
</AlertDialogDescription>
</AlertDialogHeader>
<slot name="content" />
<AlertDialogFooter>
<AlertDialogCancel :disabled="loading">
{{ cancelLabel }}

View File

@ -7,12 +7,17 @@ defineProps<{
tooltip?: string;
disabled?: boolean;
}>();
defineEmits<{
click: [];
}>();
</script>
<template>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" :disabled="disabled">
<Button variant="ghost" size="icon" class="size-8" :disabled="disabled"
@click="$emit('click')">
<Banknote class="size-4" />
<span class="sr-only">{{ tooltip || 'Bayar' }}</span>
</Button>

View File

@ -42,6 +42,25 @@ export function createColumns(
enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Jumlah', column: 'amount' }),
},
{
id: 'payment_status',
enableSorting: false,
header: () => 'Dibayar / Sisa',
cell: ({ row }) => {
const { paid_amount_formatted, remaining_amount, remaining_amount_formatted, status } = row.original;
if (status === 'pending' || status === 'rejected') {
return h('span', { class: 'text-muted-foreground text-sm' }, '-');
}
return h('div', { class: 'space-y-0.5' }, [
h('div', { class: 'text-sm font-medium' }, paid_amount_formatted),
remaining_amount > 0
? h('div', { class: 'text-xs text-muted-foreground' }, `Sisa: ${remaining_amount_formatted}`)
: h('div', { class: 'text-xs text-green-600 font-medium' }, 'Lunas'),
]);
},
},
{
accessorKey: 'description',
enableSorting: true,

View File

@ -64,9 +64,11 @@ function approveEmployeeAdvance() {
onSuccess: () => {
approveConfirmOpen.value = false;
},
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
onError: (errors: Record<string, string>) => {
const message = errors.system ?? Object.values(errors)[0];
if (message) {
toast.error(message);
}
},
onFinish: () => {
@ -80,11 +82,13 @@ function payEmployeeAdvance() {
if (!amount || amount <= 0) {
payError.value = 'Jumlah pembayaran harus lebih dari 0.';
return;
}
if (amount > props.employeeAdvance.remaining_amount) {
payError.value = 'Jumlah pembayaran melebihi sisa kasbon.';
return;
}
@ -96,9 +100,11 @@ function payEmployeeAdvance() {
onSuccess: () => {
payConfirmOpen.value = false;
},
onError: (errors: any) => {
if (errors.system) {
toast.error(errors.system);
onError: (errors: Record<string, string>) => {
const message = errors.system ?? Object.values(errors)[0];
if (message) {
toast.error(message);
}
},
onFinish: () => {
@ -146,8 +152,21 @@ function payEmployeeAdvance() {
<Field>
<FieldLabel for="pay-amount" required>Jumlah Pembayaran</FieldLabel>
<RupiahInput id="pay-amount" v-model="payAmount" placeholder="0" />
<FieldError v-if="payError" :errors="{ amount: [payError] }" />
<FieldError v-if="payError" :errors="[payError]" />
</Field>
<div v-if="employeeAdvance.payments && employeeAdvance.payments.length > 0" class="space-y-2">
<p class="text-sm font-medium">Riwayat Pembayaran</p>
<div class="max-h-40 space-y-2 overflow-y-auto rounded-md border p-2">
<div v-for="payment in employeeAdvance.payments" :key="payment.id"
class="flex items-center justify-between text-sm">
<div>
<p class="font-medium">{{ payment.amount_formatted }}</p>
<p class="text-xs text-muted-foreground">{{ payment.paid_at_formatted }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
</ConfirmDialog>

View File

@ -1,3 +1,15 @@
export type EmployeeAdvancePayment = {
id: number;
employee_advance_id: number;
paid_by_id: number | null;
cash_transaction_id: number | null;
amount: number;
amount_formatted: string;
description: string | null;
paid_at: string;
paid_at_formatted: string;
};
export type EmployeeAdvanceListItem = {
id: number;
employee_id: number;
@ -18,6 +30,7 @@ export type EmployeeAdvanceListItem = {
is_editable: boolean;
can_verify: boolean;
can_pay: boolean;
payments?: EmployeeAdvancePayment[];
};
export type EmployeeAdvanceFormData = {