Skip to content

Commit fc7e10f

Browse files
committed
feat(hr): Phase 63 — Loan & Advance Management
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 3d00f1d commit fc7e10f

14 files changed

Lines changed: 1070 additions & 0 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\Employee;
7+
use App\Modules\HR\Models\EmployeeLoan;
8+
use App\Modules\HR\Models\LoanRepayment;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Illuminate\Validation\Rule;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
15+
class EmployeeLoanController extends Controller
16+
{
17+
public function index(Request $request): Response
18+
{
19+
$this->authorize('viewAny', EmployeeLoan::class);
20+
21+
$loans = EmployeeLoan::with('employee')
22+
->when($request->status, fn ($q) => $q->where('status', $request->status))
23+
->orderBy('created_at', 'desc')
24+
->paginate(15)
25+
->withQueryString();
26+
27+
return Inertia::render('HR/EmployeeLoans/Index', [
28+
'loans' => $loans,
29+
'filters' => $request->only(['status']),
30+
]);
31+
}
32+
33+
public function create(): Response
34+
{
35+
$this->authorize('create', EmployeeLoan::class);
36+
37+
return Inertia::render('HR/EmployeeLoans/Create', [
38+
'employees' => Employee::active()->orderBy('last_name')->get()->map(fn ($e) => [
39+
'id' => $e->id,
40+
'full_name' => $e->full_name,
41+
]),
42+
]);
43+
}
44+
45+
public function store(Request $request): RedirectResponse
46+
{
47+
$this->authorize('create', EmployeeLoan::class);
48+
49+
$validated = $request->validate([
50+
'employee_id' => ['required', Rule::exists('employees', 'id')],
51+
'type' => ['required', Rule::in(['loan', 'advance'])],
52+
'amount' => ['required', 'numeric', 'min:0.01'],
53+
'interest_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
54+
'purpose' => ['nullable', 'string', 'max:255'],
55+
'notes' => ['nullable', 'string'],
56+
'repayment_start_date' => ['nullable', 'date'],
57+
]);
58+
59+
$loan = EmployeeLoan::create([
60+
...$validated,
61+
'tenant_id' => auth()->user()->tenant_id,
62+
'outstanding_balance' => $validated['amount'],
63+
'status' => 'pending',
64+
]);
65+
66+
return redirect()->route('hr.employee-loans.show', $loan)
67+
->with('success', 'Loan created successfully.');
68+
}
69+
70+
public function show(EmployeeLoan $employeeLoan): Response
71+
{
72+
$this->authorize('view', $employeeLoan);
73+
74+
$employeeLoan->load(['employee', 'repayments']);
75+
76+
return Inertia::render('HR/EmployeeLoans/Show', [
77+
'loan' => array_merge($employeeLoan->toArray(), [
78+
'total_repaid' => $employeeLoan->total_repaid,
79+
'is_fully_repaid' => $employeeLoan->is_fully_repaid,
80+
]),
81+
'can' => [
82+
'create' => auth()->user()->can('create', EmployeeLoan::class),
83+
'delete' => auth()->user()->can('delete', $employeeLoan),
84+
],
85+
]);
86+
}
87+
88+
public function destroy(EmployeeLoan $employeeLoan): RedirectResponse
89+
{
90+
$this->authorize('delete', $employeeLoan);
91+
92+
if ($employeeLoan->status !== 'pending') {
93+
return back()->withErrors(['status' => 'Only pending loans can be deleted.']);
94+
}
95+
96+
$employeeLoan->delete();
97+
98+
return redirect()->route('hr.employee-loans.index')
99+
->with('success', 'Loan deleted.');
100+
}
101+
102+
public function approve(EmployeeLoan $employeeLoan): RedirectResponse
103+
{
104+
$this->authorize('create', EmployeeLoan::class);
105+
106+
$employeeLoan->approve(auth()->user());
107+
108+
return back()->with('success', 'Loan approved.');
109+
}
110+
111+
public function cancel(EmployeeLoan $employeeLoan): RedirectResponse
112+
{
113+
$this->authorize('create', EmployeeLoan::class);
114+
115+
$employeeLoan->cancel();
116+
117+
return back()->with('success', 'Loan cancelled.');
118+
}
119+
120+
public function addRepayment(Request $request, EmployeeLoan $employeeLoan): RedirectResponse
121+
{
122+
$this->authorize('create', EmployeeLoan::class);
123+
124+
$validated = $request->validate([
125+
'amount' => ['required', 'numeric', 'min:0.01'],
126+
'payment_date' => ['required', 'date'],
127+
'notes' => ['nullable', 'string'],
128+
]);
129+
130+
$repayment = LoanRepayment::create([
131+
...$validated,
132+
'tenant_id' => auth()->user()->tenant_id,
133+
'employee_loan_id' => $employeeLoan->id,
134+
]);
135+
136+
$employeeLoan->decrement('outstanding_balance', $repayment->amount);
137+
138+
if ($employeeLoan->fresh()->outstanding_balance <= 0) {
139+
$employeeLoan->update(['status' => 'completed', 'outstanding_balance' => 0]);
140+
}
141+
142+
return back()->with('success', 'Repayment recorded.');
143+
}
144+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class EmployeeLoan extends Model
13+
{
14+
use BelongsToTenant;
15+
use SoftDeletes;
16+
17+
protected $fillable = [
18+
'tenant_id', 'employee_id', 'type', 'amount', 'outstanding_balance',
19+
'interest_rate', 'status', 'approved_by', 'approved_at', 'disbursed_at',
20+
'purpose', 'notes', 'repayment_start_date',
21+
];
22+
23+
protected $casts = [
24+
'amount' => 'decimal:2',
25+
'outstanding_balance' => 'decimal:2',
26+
'interest_rate' => 'decimal:2',
27+
'approved_at' => 'datetime',
28+
'disbursed_at' => 'datetime',
29+
'repayment_start_date' => 'date',
30+
];
31+
32+
public function employee(): BelongsTo
33+
{
34+
return $this->belongsTo(Employee::class);
35+
}
36+
37+
public function approver(): BelongsTo
38+
{
39+
return $this->belongsTo(User::class, 'approved_by');
40+
}
41+
42+
public function repayments(): HasMany
43+
{
44+
return $this->hasMany(LoanRepayment::class);
45+
}
46+
47+
public function approve(User $user): void
48+
{
49+
$this->status = 'active';
50+
$this->approved_by = $user->id;
51+
$this->approved_at = now();
52+
$this->disbursed_at = now();
53+
$this->save();
54+
}
55+
56+
public function cancel(): void
57+
{
58+
$this->status = 'cancelled';
59+
$this->save();
60+
}
61+
62+
public function getTotalRepaidAttribute(): float
63+
{
64+
return (float) $this->repayments->sum('amount');
65+
}
66+
67+
public function getIsFullyRepaidAttribute(): bool
68+
{
69+
return (float) $this->outstanding_balance <= 0;
70+
}
71+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class LoanRepayment extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id', 'employee_loan_id', 'amount', 'payment_date', 'notes',
15+
];
16+
17+
protected $casts = [
18+
'amount' => 'decimal:2',
19+
'payment_date' => 'date',
20+
];
21+
22+
public function loan(): BelongsTo
23+
{
24+
return $this->belongsTo(EmployeeLoan::class, 'employee_loan_id');
25+
}
26+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\HR\Models\EmployeeLoan;
7+
8+
class LoanPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('hr.view');
13+
}
14+
15+
public function view(User $user, EmployeeLoan $loan): bool
16+
{
17+
return $user->can('hr.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->can('hr.create');
23+
}
24+
25+
public function update(User $user, EmployeeLoan $loan): bool
26+
{
27+
return $user->can('hr.create');
28+
}
29+
30+
public function delete(User $user, EmployeeLoan $loan): bool
31+
{
32+
return $user->can('hr.delete');
33+
}
34+
}

erp/app/Modules/HR/Providers/HRServiceProvider.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
use App\Modules\HR\Models\AttendanceRecord;
66
use App\Modules\HR\Models\Department;
77
use App\Modules\HR\Models\Employee;
8+
use App\Modules\HR\Models\EmployeeLoan;
89
use App\Modules\HR\Models\EmployeeOnboarding;
910
use App\Modules\HR\Models\EmployeeTrainingRecord;
1011
use App\Modules\HR\Models\ExpenseClaim;
1112
use App\Modules\HR\Models\JobApplication;
1213
use App\Modules\HR\Models\JobPosition;
1314
use App\Modules\HR\Models\LeaveRequest;
15+
use App\Modules\HR\Models\LoanRepayment;
1416
use App\Modules\HR\Models\OnboardingTemplate;
1517
use App\Modules\HR\Models\PayrollRun;
1618
use App\Modules\HR\Models\PerformanceReview;
@@ -22,6 +24,7 @@
2224
use App\Modules\HR\Policies\EmployeePolicy;
2325
use App\Modules\HR\Policies\ExpenseClaimPolicy;
2426
use App\Modules\HR\Policies\LeaveRequestPolicy;
27+
use App\Modules\HR\Policies\LoanPolicy;
2528
use App\Modules\HR\Policies\OnboardingTemplatePolicy;
2629
use App\Modules\HR\Policies\PayrollRunPolicy;
2730
use App\Modules\HR\Policies\PerformanceReviewPolicy;
@@ -42,11 +45,13 @@ public function boot(): void
4245
Gate::policy(WorkSchedule::class, AttendancePolicy::class);
4346
Gate::policy(Department::class, DepartmentPolicy::class);
4447
Gate::policy(Employee::class, EmployeePolicy::class);
48+
Gate::policy(EmployeeLoan::class, LoanPolicy::class);
4549
Gate::policy(EmployeeOnboarding::class, EmployeeOnboardingPolicy::class);
4650
Gate::policy(ExpenseClaim::class, ExpenseClaimPolicy::class);
4751
Gate::policy(JobApplication::class, RecruitmentPolicy::class);
4852
Gate::policy(JobPosition::class, RecruitmentPolicy::class);
4953
Gate::policy(LeaveRequest::class, LeaveRequestPolicy::class);
54+
Gate::policy(LoanRepayment::class, LoanPolicy::class);
5055
Gate::policy(OnboardingTemplate::class, OnboardingTemplatePolicy::class);
5156
Gate::policy(PayrollRun::class, PayrollRunPolicy::class);
5257
Gate::policy(PerformanceReview::class, PerformanceReviewPolicy::class);

erp/app/Modules/HR/routes/hr.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use App\Modules\HR\Http\Controllers\AttendanceController;
44
use App\Modules\HR\Http\Controllers\DepartmentController;
55
use App\Modules\HR\Http\Controllers\EmployeeController;
6+
use App\Modules\HR\Http\Controllers\EmployeeLoanController;
67
use App\Modules\HR\Http\Controllers\EmployeeOnboardingController;
78
use App\Modules\HR\Http\Controllers\EmployeeTrainingRecordController;
89
use App\Modules\HR\Http\Controllers\ExpenseClaimController;
@@ -105,4 +106,10 @@
105106

106107
// Work Schedules
107108
Route::resource('work-schedules', WorkScheduleController::class)->except(['edit', 'update']);
109+
110+
// Employee Loans
111+
Route::post('employee-loans/{employeeLoan}/approve', [EmployeeLoanController::class, 'approve'])->name('employee-loans.approve');
112+
Route::post('employee-loans/{employeeLoan}/cancel', [EmployeeLoanController::class, 'cancel'])->name('employee-loans.cancel');
113+
Route::post('employee-loans/{employeeLoan}/repayments', [EmployeeLoanController::class, 'addRepayment'])->name('employee-loans.repayments.add');
114+
Route::resource('employee-loans', EmployeeLoanController::class)->except(['edit', 'update']);
108115
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::create('employee_loans', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->foreignId('employee_id')->constrained('employees')->cascadeOnDelete();
15+
$table->enum('type', ['loan', 'advance'])->default('loan');
16+
$table->decimal('amount', 10, 2);
17+
$table->decimal('outstanding_balance', 10, 2);
18+
$table->decimal('interest_rate', 5, 2)->default(0);
19+
$table->enum('status', ['pending', 'active', 'completed', 'cancelled'])->default('pending');
20+
$table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
21+
$table->timestamp('approved_at')->nullable();
22+
$table->timestamp('disbursed_at')->nullable();
23+
$table->string('purpose')->nullable();
24+
$table->text('notes')->nullable();
25+
$table->date('repayment_start_date')->nullable();
26+
$table->softDeletes();
27+
$table->timestamps();
28+
});
29+
}
30+
31+
public function down(): void
32+
{
33+
Schema::dropIfExists('employee_loans');
34+
}
35+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::create('loan_repayments', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->foreignId('employee_loan_id')->constrained('employee_loans')->cascadeOnDelete();
15+
$table->decimal('amount', 10, 2);
16+
$table->date('payment_date');
17+
$table->text('notes')->nullable();
18+
$table->timestamps();
19+
});
20+
}
21+
22+
public function down(): void
23+
{
24+
Schema::dropIfExists('loan_repayments');
25+
}
26+
};

0 commit comments

Comments
 (0)