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\Enums\EmployeeAdvanceStatus;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\EmployeeAdvancePaymentRequest;
use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest; use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest;
use App\Http\Requests\PaginatedRequest; use App\Http\Requests\PaginatedRequest;
use App\Models\EmployeeAdvance; 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( return $this->handleAction(
fn () => $this->service->pay($employeeAdvance), fn () => $this->service->pay($employeeAdvance, $request->validated('amount')),
'Kasbon berhasil dibayar.', 'Pembayaran kasbon berhasil.',
'admin.finance.employee-advances.index' '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\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; 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'])] #[Guarded(['id'])]
class EmployeeAdvance extends Model 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 protected function statusLabel(): Attribute
{ {
return Attribute::make( return Attribute::make(
@ -106,6 +114,11 @@ public function employee(): BelongsTo
return $this->belongsTo(Employee::class); return $this->belongsTo(Employee::class);
} }
public function payments(): HasMany
{
return $this->hasMany(EmployeeAdvancePayment::class);
}
public function paidBy(): BelongsTo public function paidBy(): BelongsTo
{ {
return $this->belongsTo(User::class, 'paid_by_id'); 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\Enums\EmployeeAdvanceStatus;
use App\Models\EmployeeAdvance; use App\Models\EmployeeAdvance;
use App\Models\EmployeeAdvancePayment;
use App\Services\Concerns\HandlesCashTransactions; use App\Services\Concerns\HandlesCashTransactions;
use App\Services\NotificationService; use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class EmployeeAdvanceService class EmployeeAdvanceService
{ {
@ -22,7 +24,7 @@ private function canViewAll(): bool
public function getAll(array $filters = []): Collection public function getAll(array $filters = []): Collection
{ {
return EmployeeAdvance::select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at']) 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()))) ->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->latest() ->latest()
->get(); ->get();
@ -32,7 +34,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
{ {
return EmployeeAdvance::query() return EmployeeAdvance::query()
->select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at']) ->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(! $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($filters['status'] ?? null, fn ($q) => $q->where('status', $filters['status']))
->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%")) ->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%"))
@ -117,29 +119,51 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
return $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( $cashTransaction = $this->creditCash(
amount: $employeeAdvance->amount, amount: $amount,
description: 'Pembayaran kasbon: '.$employeeAdvance->description, description: 'Pembayaran kasbon: '.$employeeAdvance->description,
); );
$employeeAdvance->update([ EmployeeAdvancePayment::create([
'status' => EmployeeAdvanceStatus::PAID, 'employee_advance_id' => $employeeAdvance->id,
'paid_by_id' => auth()->id(), 'paid_by_id' => auth()->id(),
'paid_amount' => $employeeAdvance->amount, 'cash_transaction_id' => $cashTransaction->id,
'amount' => $amount,
'paid_at' => now(), '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; 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( NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
title: 'Kasbon Dibayar', title: $employeeAdvance->status === EmployeeAdvanceStatus::PAID ? 'Kasbon Dibayar' : 'Pembayaran Kasbon',
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah dibayar lunas'.' oleh '.auth()->user()->full_name.'.', body: $notificationBody,
url: route('admin.finance.employee-advances.index'), url: route('admin.finance.employee-advances.index'),
additionalUser: $employeeAdvance->employee->user ?? null, 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 { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge'; 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 = { export type EmployeeAdvance = {
id: number; id: number;
@ -9,6 +23,8 @@ export type EmployeeAdvance = {
formatted_amount: string; formatted_amount: string;
paid_amount: number; paid_amount: number;
formatted_paid_amount: string; formatted_paid_amount: string;
remaining_amount: number;
formatted_remaining_amount: string;
description: string; description: string;
due_date: string; due_date: string;
formatted_due_date: string; formatted_due_date: string;
@ -23,6 +39,7 @@ export type EmployeeAdvance = {
}; };
}; };
}; };
payments: EmployeeAdvancePayment[];
}; };
function getStatusBadge(status: string) { function getStatusBadge(status: string) {
@ -63,6 +80,7 @@ type CreateColumnsParams = {
handleDeleteClick: (employeeAdvance: EmployeeAdvance) => void; handleDeleteClick: (employeeAdvance: EmployeeAdvance) => void;
handleApprove: (employeeAdvance: EmployeeAdvance) => void; handleApprove: (employeeAdvance: EmployeeAdvance) => void;
handlePay: (employeeAdvance: EmployeeAdvance) => void; handlePay: (employeeAdvance: EmployeeAdvance) => void;
handleShowPayments: (employeeAdvance: EmployeeAdvance) => void;
can: (permission: string) => boolean; can: (permission: string) => boolean;
authUserId: number; authUserId: number;
}; };
@ -70,7 +88,7 @@ type CreateColumnsParams = {
export function createEmployeeAdvanceColumns( export function createEmployeeAdvanceColumns(
params: CreateColumnsParams, params: CreateColumnsParams,
): ColumnDef<EmployeeAdvance>[] { ): ColumnDef<EmployeeAdvance>[] {
const { handleEdit, handleDeleteClick, handleApprove, handlePay, can, authUserId } = const { handleEdit, handleDeleteClick, handleApprove, handlePay, handleShowPayments, can, authUserId } =
params; params;
return [ return [
@ -98,11 +116,27 @@ export function createEmployeeAdvanceColumns(
accessorKey: 'formatted_amount', accessorKey: 'formatted_amount',
header: () => <span>Jumlah</span>, header: () => <span>Jumlah</span>,
cell: ({ row }) => ( cell: ({ row }) => (
<span className="font-medium text-red-600"> <span className="font-medium">
- {row.getValue('formatted_amount') as string} {row.getValue('formatted_amount') as string}
</span> </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', accessorKey: 'description',
header: () => <span>Keterangan</span>, header: () => <span>Keterangan</span>,
@ -161,6 +195,12 @@ export function createEmployeeAdvanceColumns(
employeeAdvance.status === 'approved', employeeAdvance.status === 'approved',
onClick: () => handlePay(employeeAdvance), onClick: () => handlePay(employeeAdvance),
}, },
{
label: 'Riwayat',
icon: <History className="h-4 w-4" />,
show: employeeAdvance.payments.length > 0,
onClick: () => handleShowPayments(employeeAdvance),
},
{ {
label: 'Edit', label: 'Edit',
icon: <Pencil className="h-4 w-4" />, 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 { PageHeader } from '@/components/page-header';
import { RupiahInput } from '@/components/rupiah-input'; import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { import {
Select, Select,
SelectContent, SelectContent,
@ -66,6 +74,7 @@ export default function EmployeeAdvanceIndex({
const [deleting, setDeleting] = useState<EmployeeAdvance | null>(null); const [deleting, setDeleting] = useState<EmployeeAdvance | null>(null);
const [approving, setApproving] = useState<EmployeeAdvance | null>(null); const [approving, setApproving] = useState<EmployeeAdvance | null>(null);
const [paying, setPaying] = 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 [dueDate, setDueDate] = useState<Date | undefined>(undefined);
const [editingDueDate, setEditingDueDate] = useState<Date | undefined>( const [editingDueDate, setEditingDueDate] = useState<Date | undefined>(
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({ const columns = createEmployeeAdvanceColumns({
handleEdit: (employeeAdvance) => setEditing(employeeAdvance), handleEdit: (employeeAdvance) => setEditing(employeeAdvance),
handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance), handleDeleteClick: (employeeAdvance) => setDeleting(employeeAdvance),
handleApprove: (employeeAdvance) => setApproving(employeeAdvance), handleApprove: (employeeAdvance) => setApproving(employeeAdvance),
handlePay: (employeeAdvance) => setPaying(employeeAdvance), handlePay: (employeeAdvance) => setPaying(employeeAdvance),
handleShowPayments: (employeeAdvance) => setViewingPayments(employeeAdvance),
can, can,
authUserId: auth.user.id, authUserId: auth.user.id,
}); });
@ -383,20 +379,128 @@ export default function EmployeeAdvanceIndex({
onConfirm={handleApprove} onConfirm={handleApprove}
/> />
<DeleteConfirmDialog <FormDialog
target={paying} open={paying !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setPaying(null); setPaying(null);
} }
}} }}
title="Bayar Kasbon" title="Bayar Kasbon"
description={(advance) => action={paying ? pay(paying.id) : ''}
`Apakah Anda yakin ingin membayar kasbon "${advance.description}" sebesar ${advance.formatted_amount}? Saldo kas akan dikembalikan.` 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" </FormDialog>
onConfirm={handlePay}
/> <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> </div>
</> </>
); );