feat: implement employee advance payment functionality with history tracking

This commit is contained in:
Yoga Pangestu 2026-08-06 12:09:53 +07:00
parent 81b5db77e2
commit 54ad2edaf7
7 changed files with 314 additions and 41 deletions

View File

@ -4,6 +4,7 @@
use App\Enums\EmployeeAdvanceStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\EmployeeAdvancePaymentRequest;
use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\EmployeeAdvance;
@ -68,11 +69,11 @@ public function approve(EmployeeAdvance $employeeAdvance): RedirectResponse
);
}
public function pay(EmployeeAdvance $employeeAdvance): RedirectResponse
public function pay(EmployeeAdvancePaymentRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->pay($employeeAdvance),
'Kasbon berhasil dibayar.',
fn () => $this->service->pay($employeeAdvance, $request->validated('amount')),
'Pembayaran kasbon berhasil.',
'admin.finance.employee-advances.index'
);
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Admin\Finance;
use App\Concerns\CurrencyStripping;
use Illuminate\Foundation\Http\FormRequest;
use Override;
class EmployeeAdvancePaymentRequest extends FormRequest
{
use CurrencyStripping;
public function authorize(): bool
{
return true;
}
#[Override]
public function prepareForValidation(): void
{
$this->merge($this->stripCurrencyDot($this->all(), 'amount'));
}
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:1'],
];
}
public function attributes(): array
{
return [
'amount' => 'jumlah bayar',
];
}
}

View File

@ -11,9 +11,10 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Appends(['formatted_amount', 'formatted_created_at', 'formatted_due_date', 'formatted_paid_amount', 'status_label'])]
#[Appends(['formatted_amount', 'formatted_created_at', 'formatted_due_date', 'formatted_paid_amount', 'formatted_remaining_amount', 'status_label'])]
#[Guarded(['id'])]
class EmployeeAdvance extends Model
{
@ -59,6 +60,13 @@ protected function formattedPaidAmount(): Attribute
);
}
protected function formattedRemainingAmount(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->amount - $this->paid_amount, 0, ',', '.'),
);
}
protected function statusLabel(): Attribute
{
return Attribute::make(
@ -106,6 +114,11 @@ public function employee(): BelongsTo
return $this->belongsTo(Employee::class);
}
public function payments(): HasMany
{
return $this->hasMany(EmployeeAdvancePayment::class);
}
public function paidBy(): BelongsTo
{
return $this->belongsTo(User::class, 'paid_by_id');

View File

@ -0,0 +1,54 @@
<?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\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Appends(['formatted_amount', 'formatted_paid_at'])]
#[Guarded(['id'])]
class EmployeeAdvancePayment extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'amount' => 'integer',
'paid_at' => 'datetime',
];
}
protected function formattedAmount(): Attribute
{
return Attribute::make(
get: fn() => 'Rp ' . number_format($this->amount, 0, ',', '.'),
);
}
protected function formattedPaidAt(): Attribute
{
return Attribute::make(
get: fn() => $this->paid_at?->translatedFormat('l, d F Y'),
);
}
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);
}
}

View File

@ -4,11 +4,13 @@
use App\Enums\EmployeeAdvanceStatus;
use App\Models\EmployeeAdvance;
use App\Models\EmployeeAdvancePayment;
use App\Services\Concerns\HandlesCashTransactions;
use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class EmployeeAdvanceService
{
@ -22,7 +24,7 @@ private function canViewAll(): bool
public function getAll(array $filters = []): Collection
{
return EmployeeAdvance::select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
->with(['employee.user.userProfile'])
->with(['employee.user.userProfile', 'payments.paidBy.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->latest()
->get();
@ -32,7 +34,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
{
return EmployeeAdvance::query()
->select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
->with(['employee.user.userProfile'])
->with(['employee.user.userProfile', 'payments.paidBy.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when($filters['status'] ?? null, fn ($q) => $q->where('status', $filters['status']))
->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%"))
@ -117,29 +119,51 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
return $employeeAdvance;
}
public function pay(EmployeeAdvance $employeeAdvance): EmployeeAdvance
public function pay(EmployeeAdvance $employeeAdvance, int $amount): EmployeeAdvance
{
$employeeAdvance = DB::transaction(function () use ($employeeAdvance) {
$remaining = $employeeAdvance->amount - $employeeAdvance->paid_amount;
if ($amount > $remaining) {
throw ValidationException::withMessages([
'amount' => 'Jumlah bayar melebihi sisa kasbon.',
]);
}
$employeeAdvance = DB::transaction(function () use ($employeeAdvance, $amount) {
$cashTransaction = $this->creditCash(
amount: $employeeAdvance->amount,
amount: $amount,
description: 'Pembayaran kasbon: '.$employeeAdvance->description,
);
$employeeAdvance->update([
'status' => EmployeeAdvanceStatus::PAID,
EmployeeAdvancePayment::create([
'employee_advance_id' => $employeeAdvance->id,
'paid_by_id' => auth()->id(),
'paid_amount' => $employeeAdvance->amount,
'cash_transaction_id' => $cashTransaction->id,
'amount' => $amount,
'paid_at' => now(),
'repayment_cash_transaction_id' => $cashTransaction->id,
]);
$newPaidAmount = $employeeAdvance->paid_amount + $amount;
$isFullyPaid = $newPaidAmount >= $employeeAdvance->amount;
$employeeAdvance->update([
'paid_amount' => $newPaidAmount,
'status' => $isFullyPaid ? EmployeeAdvanceStatus::PAID : $employeeAdvance->status,
'paid_by_id' => $isFullyPaid ? auth()->id() : $employeeAdvance->paid_by_id,
'paid_at' => $isFullyPaid ? now() : $employeeAdvance->paid_at,
]);
return $employeeAdvance;
});
$notificationBody = $employeeAdvance->status === EmployeeAdvanceStatus::PAID
? 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah dibayar lunas'.' oleh '.auth()->user()->full_name.'.'
: 'Pembayaran kasbon sebesar Rp '.number_format($amount, 0, ',', '.').' oleh '.auth()->user()->full_name.'. Sisa: Rp '.number_format($employeeAdvance->amount - $employeeAdvance->paid_amount, 0, ',', '.').'.';
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Kasbon Dibayar',
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah dibayar lunas'.' oleh '.auth()->user()->full_name.'.',
title: $employeeAdvance->status === EmployeeAdvanceStatus::PAID ? 'Kasbon Dibayar' : 'Pembayaran Kasbon',
body: $notificationBody,
url: route('admin.finance.employee-advances.index'),
additionalUser: $employeeAdvance->employee->user ?? null,
);

View File

@ -1,7 +1,21 @@
import type { ColumnDef } from '@tanstack/react-table';
import { CheckCircle, CircleDollarSign, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import type { ColumnDef } from '@tanstack/react-table';
import { CheckCircle, CircleDollarSign, History, Pencil, Trash2 } from 'lucide-react';
export type EmployeeAdvancePayment = {
id: number;
amount: number;
formatted_amount: string;
description: string | null;
paid_at: string;
formatted_paid_at: string;
paid_by: {
user_profile: {
full_name: string;
};
} | null;
};
export type EmployeeAdvance = {
id: number;
@ -9,6 +23,8 @@ export type EmployeeAdvance = {
formatted_amount: string;
paid_amount: number;
formatted_paid_amount: string;
remaining_amount: number;
formatted_remaining_amount: string;
description: string;
due_date: string;
formatted_due_date: string;
@ -23,6 +39,7 @@ export type EmployeeAdvance = {
};
};
};
payments: EmployeeAdvancePayment[];
};
function getStatusBadge(status: string) {
@ -63,6 +80,7 @@ type CreateColumnsParams = {
handleDeleteClick: (employeeAdvance: EmployeeAdvance) => void;
handleApprove: (employeeAdvance: EmployeeAdvance) => void;
handlePay: (employeeAdvance: EmployeeAdvance) => void;
handleShowPayments: (employeeAdvance: EmployeeAdvance) => void;
can: (permission: string) => boolean;
authUserId: number;
};
@ -70,7 +88,7 @@ type CreateColumnsParams = {
export function createEmployeeAdvanceColumns(
params: CreateColumnsParams,
): ColumnDef<EmployeeAdvance>[] {
const { handleEdit, handleDeleteClick, handleApprove, handlePay, can, authUserId } =
const { handleEdit, handleDeleteClick, handleApprove, handlePay, handleShowPayments, can, authUserId } =
params;
return [
@ -98,11 +116,27 @@ export function createEmployeeAdvanceColumns(
accessorKey: 'formatted_amount',
header: () => <span>Jumlah</span>,
cell: ({ row }) => (
<span className="font-medium text-red-600">
- {row.getValue('formatted_amount') as string}
<span className="font-medium">
{row.getValue('formatted_amount') as string}
</span>
),
},
{
accessorKey: 'formatted_remaining_amount',
header: () => <span>Sisa</span>,
cell: ({ row }) => {
const employeeAdvance = row.original;
if (employeeAdvance.status === 'paid') {
return <span className="text-green-600">Lunas</span>;
}
return (
<span className="font-medium text-orange-600">
{row.getValue('formatted_remaining_amount') as string}
</span>
);
},
},
{
accessorKey: 'description',
header: () => <span>Keterangan</span>,
@ -161,6 +195,12 @@ export function createEmployeeAdvanceColumns(
employeeAdvance.status === 'approved',
onClick: () => handlePay(employeeAdvance),
},
{
label: 'Riwayat',
icon: <History className="h-4 w-4" />,
show: employeeAdvance.payments.length > 0,
onClick: () => handleShowPayments(employeeAdvance),
},
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,

View File

@ -8,8 +8,16 @@ import InputError from '@/components/input-error';
import { PageHeader } from '@/components/page-header';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
@ -66,6 +74,7 @@ export default function EmployeeAdvanceIndex({
const [deleting, setDeleting] = useState<EmployeeAdvance | null>(null);
const [approving, setApproving] = useState<EmployeeAdvance | null>(null);
const [paying, setPaying] = useState<EmployeeAdvance | null>(null);
const [viewingPayments, setViewingPayments] = useState<EmployeeAdvance | null>(null);
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
const [editingDueDate, setEditingDueDate] = useState<Date | undefined>(
undefined,
@ -125,25 +134,12 @@ export default function EmployeeAdvanceIndex({
);
}
function handlePay() {
if (!paying) {
return;
}
router.post(
pay(paying.id),
{},
{
onSuccess: () => setPaying(null),
},
);
}
const columns = createEmployeeAdvanceColumns({
handleEdit: (employeeAdvance) => setEditing(employeeAdvance),
handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance),
handleApprove: (employeeAdvance) => setApproving(employeeAdvance),
handlePay: (employeeAdvance) => setPaying(employeeAdvance),
handleShowPayments: (employeeAdvance) => setViewingPayments(employeeAdvance),
can,
authUserId: auth.user.id,
});
@ -383,20 +379,128 @@ export default function EmployeeAdvanceIndex({
onConfirm={handleApprove}
/>
<DeleteConfirmDialog
target={paying}
<FormDialog
open={paying !== null}
onOpenChange={(open) => {
if (!open) {
setPaying(null);
}
}}
title="Bayar Kasbon"
description={(advance) =>
`Apakah Anda yakin ingin membayar kasbon "${advance.description}" sebesar ${advance.formatted_amount}? Saldo kas akan dikembalikan.`
action={paying ? pay(paying.id) : ''}
resetOnSuccess
onSuccess={() => setPaying(null)}
>
{({ errors }) =>
paying && (
<>
<div className="mb-4 rounded-md bg-muted p-3 text-sm">
<p>
Sisa:{' '}
<span className="font-medium">
{paying.formatted_remaining_amount}
</span>
</p>
</div>
<div className="grid gap-2">
<Label>
Jumlah Bayar{' '}
<span className="text-destructive">
*
</span>
</Label>
<RupiahInput
name="amount"
defaultValue={paying.remaining_amount}
/>
<InputError message={errors.amount} />
</div>
</>
)
}
confirmLabel="Bayar"
onConfirm={handlePay}
/>
</FormDialog>
<Dialog
open={viewingPayments !== null}
onOpenChange={(open) => {
if (!open) {
setViewingPayments(null);
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Riwayat Pembayaran</DialogTitle>
<DialogDescription>
{viewingPayments?.description}
</DialogDescription>
</DialogHeader>
{viewingPayments && (
<div className="space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Total:
</span>
<span className="font-medium">
{viewingPayments.formatted_amount}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Terbayar:
</span>
<span className="font-medium text-green-600">
{viewingPayments.formatted_paid_amount}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Sisa:
</span>
<span className="font-medium text-orange-600">
{viewingPayments.formatted_remaining_amount}
</span>
</div>
<Separator />
{viewingPayments.payments.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Belum ada pembayaran.
</p>
) : (
<div className="space-y-3">
{viewingPayments.payments.map(
(payment) => (
<div
key={payment.id}
className="rounded-md border p-3"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-green-600">
{payment.formatted_amount}
</span>
<span className="text-xs text-muted-foreground">
{payment.formatted_paid_at}
</span>
</div>
{payment.paid_by && (
<p className="mt-1 text-xs text-muted-foreground">
oleh{' '}
{payment.paid_by
.user_profile
.full_name ?? '-'}
</p>
)}
</div>
),
)}
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
</>
);