feat: implement employee advance payment functionality with history tracking
This commit is contained in:
parent
81b5db77e2
commit
54ad2edaf7
@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
|
||||
54
app/Models/EmployeeAdvancePayment.php
Normal file
54
app/Models/EmployeeAdvancePayment.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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" />,
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user