Skip to content

Commit 1c8c85d

Browse files
committed
feat(hr): Phase 84 — Employee Training & Certifications
Adds training enrollments and employee certifications to the HR module, extending the existing training courses with new fields (category, cost, is_mandatory), new enrollment lifecycle (enroll/complete/fail), and standalone certification tracking with expiry awareness. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent b95c8ee commit 1c8c85d

21 files changed

Lines changed: 1205 additions & 126 deletions
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\EmployeeCertification;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class EmployeeCertificationController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', EmployeeCertification::class);
17+
18+
$query = EmployeeCertification::with(['employee']);
19+
20+
if ($request->filled('employee_id')) {
21+
$query->where('employee_id', $request->employee_id);
22+
}
23+
24+
$certifications = $query->latest()->paginate(20);
25+
26+
return Inertia::render('HR/EmployeeCertifications/Index', compact('certifications'));
27+
}
28+
29+
public function store(Request $request): RedirectResponse
30+
{
31+
$this->authorize('create', EmployeeCertification::class);
32+
33+
$data = $request->validate([
34+
'employee_id' => 'required|exists:employees,id',
35+
'name' => 'required|string|max:255',
36+
'issuing_body' => 'nullable|string|max:255',
37+
'certificate_number' => 'nullable|string|max:255',
38+
'issued_date' => 'required|date',
39+
'expiry_date' => 'nullable|date|after:issued_date',
40+
]);
41+
42+
EmployeeCertification::create([
43+
'tenant_id' => auth()->user()->tenant_id,
44+
...$data,
45+
]);
46+
47+
return redirect()->back()->with('success', 'Certification added.');
48+
}
49+
50+
public function destroy(EmployeeCertification $employeeCertification): RedirectResponse
51+
{
52+
$this->authorize('delete', $employeeCertification);
53+
54+
$employeeCertification->delete();
55+
56+
return redirect()->back()->with('success', 'Certification deleted.');
57+
}
58+
}

erp/app/Modules/HR/Http/Controllers/TrainingCourseController.php

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use App\Http\Controllers\Controller;
66
use App\Modules\HR\Models\TrainingCourse;
7+
use App\Modules\HR\Models\TrainingEnrollment;
78
use Illuminate\Http\RedirectResponse;
89
use Illuminate\Http\Request;
910
use Inertia\Inertia;
@@ -15,9 +16,9 @@ public function index(): Response
1516
{
1617
$this->authorize('viewAny', TrainingCourse::class);
1718

18-
$courses = TrainingCourse::withCount('trainingRecords')
19+
$courses = TrainingCourse::withCount('enrollments')
1920
->orderBy('title')
20-
->paginate(25);
21+
->paginate(20);
2122

2223
return Inertia::render('HR/TrainingCourses/Index', compact('courses'));
2324
}
@@ -35,10 +36,13 @@ public function store(Request $request): RedirectResponse
3536

3637
$data = $request->validate([
3738
'title' => 'required|string|max:255',
39+
'category' => 'nullable|string|max:255',
3840
'provider' => 'nullable|string|max:255',
39-
'type' => 'required|in:internal,external,online,certification',
40-
'duration_hours' => 'nullable|numeric|min:0',
41+
'type' => 'nullable|in:internal,external,online,certification',
42+
'duration_hours' => 'nullable|integer|min:0',
43+
'cost' => 'nullable|numeric|min:0',
4144
'description' => 'nullable|string',
45+
'is_mandatory' => 'boolean',
4246
'is_active' => 'boolean',
4347
]);
4448

@@ -55,9 +59,7 @@ public function show(TrainingCourse $trainingCourse): Response
5559
{
5660
$this->authorize('view', $trainingCourse);
5761

58-
$trainingCourse->load([
59-
'trainingRecords' => fn ($q) => $q->with('employee')->latest()->limit(20),
60-
]);
62+
$trainingCourse->load(['enrollments.employee']);
6163

6264
return Inertia::render('HR/TrainingCourses/Show', [
6365
'course' => $trainingCourse,
@@ -73,4 +75,26 @@ public function destroy(TrainingCourse $trainingCourse): RedirectResponse
7375
return redirect()->route('hr.training-courses.index')
7476
->with('success', 'Training course deleted.');
7577
}
78+
79+
public function enroll(Request $request, TrainingCourse $trainingCourse): RedirectResponse
80+
{
81+
$this->authorize('create', TrainingCourse::class);
82+
83+
$data = $request->validate([
84+
'employee_id' => 'required|exists:employees,id',
85+
'scheduled_date' => 'nullable|date',
86+
]);
87+
88+
TrainingEnrollment::create([
89+
'tenant_id' => auth()->user()->tenant_id,
90+
'training_course_id' => $trainingCourse->id,
91+
'employee_id' => $data['employee_id'],
92+
'enrolled_date' => now()->toDateString(),
93+
'scheduled_date' => $data['scheduled_date'] ?? null,
94+
'status' => 'enrolled',
95+
'enrolled_by' => auth()->id(),
96+
]);
97+
98+
return redirect()->back()->with('success', 'Employee enrolled.');
99+
}
76100
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\TrainingEnrollment;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class TrainingEnrollmentController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', TrainingEnrollment::class);
17+
18+
$query = TrainingEnrollment::with(['employee', 'course']);
19+
20+
if ($request->filled('status')) {
21+
$query->where('status', $request->status);
22+
}
23+
24+
if ($request->filled('employee_id')) {
25+
$query->where('employee_id', $request->employee_id);
26+
}
27+
28+
$enrollments = $query->latest()->paginate(20);
29+
30+
return Inertia::render('HR/TrainingEnrollments/Index', compact('enrollments'));
31+
}
32+
33+
public function show(TrainingEnrollment $trainingEnrollment): Response
34+
{
35+
$this->authorize('view', $trainingEnrollment);
36+
37+
$trainingEnrollment->load(['employee', 'course']);
38+
39+
return Inertia::render('HR/TrainingEnrollments/Show', [
40+
'enrollment' => $trainingEnrollment,
41+
]);
42+
}
43+
44+
public function complete(Request $request, TrainingEnrollment $trainingEnrollment): RedirectResponse
45+
{
46+
$this->authorize('update', $trainingEnrollment);
47+
48+
$data = $request->validate([
49+
'score' => 'nullable|numeric|min:0|max:100',
50+
'notes' => 'nullable|string',
51+
]);
52+
53+
$trainingEnrollment->complete(
54+
isset($data['score']) ? (float) $data['score'] : null,
55+
$data['notes'] ?? null
56+
);
57+
58+
return redirect()->back()->with('success', 'Enrollment marked as completed.');
59+
}
60+
61+
public function fail(Request $request, TrainingEnrollment $trainingEnrollment): RedirectResponse
62+
{
63+
$this->authorize('update', $trainingEnrollment);
64+
65+
$data = $request->validate([
66+
'notes' => 'nullable|string',
67+
]);
68+
69+
$trainingEnrollment->fail($data['notes'] ?? null);
70+
71+
return redirect()->back()->with('success', 'Enrollment marked as failed.');
72+
}
73+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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 EmployeeCertification extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id', 'employee_id', 'name', 'issuing_body', 'certificate_number',
15+
'issued_date', 'expiry_date', 'is_verified', 'training_enrollment_id',
16+
];
17+
18+
protected $casts = [
19+
'issued_date' => 'date',
20+
'expiry_date' => 'date',
21+
'is_verified' => 'boolean',
22+
];
23+
24+
public function employee(): BelongsTo
25+
{
26+
return $this->belongsTo(Employee::class);
27+
}
28+
29+
public function enrollment(): BelongsTo
30+
{
31+
return $this->belongsTo(TrainingEnrollment::class, 'training_enrollment_id');
32+
}
33+
34+
public function getIsExpiredAttribute(): bool
35+
{
36+
return $this->expiry_date !== null && $this->expiry_date->isPast();
37+
}
38+
39+
public function getIsExpiringAttribute(): bool
40+
{
41+
return $this->expiry_date !== null
42+
&& $this->expiry_date->isFuture()
43+
&& $this->expiry_date->diffInDays(now()) <= 30;
44+
}
45+
}

erp/app/Modules/HR/Models/TrainingCourse.php

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,30 @@ class TrainingCourse extends Model
1111
{
1212
use BelongsToTenant, SoftDeletes;
1313

14-
protected $fillable = ['tenant_id', 'title', 'provider', 'type', 'duration_hours', 'description', 'is_active'];
14+
protected $fillable = [
15+
'tenant_id', 'title', 'category', 'provider', 'type',
16+
'duration_hours', 'cost', 'is_mandatory', 'description', 'is_active',
17+
];
1518

1619
protected $casts = [
1720
'is_active' => 'boolean',
18-
'duration_hours' => 'float',
21+
'is_mandatory' => 'boolean',
22+
'duration_hours' => 'integer',
23+
'cost' => 'float',
1924
];
2025

2126
public function trainingRecords(): HasMany
2227
{
2328
return $this->hasMany(EmployeeTrainingRecord::class);
2429
}
30+
31+
public function enrollments(): HasMany
32+
{
33+
return $this->hasMany(TrainingEnrollment::class);
34+
}
35+
36+
public function getEnrolledCountAttribute(): int
37+
{
38+
return $this->enrollments()->count();
39+
}
2540
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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+
10+
class TrainingEnrollment extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $fillable = [
15+
'tenant_id', 'employee_id', 'training_course_id',
16+
'enrolled_date', 'scheduled_date', 'completed_date',
17+
'status', 'score', 'notes', 'enrolled_by',
18+
];
19+
20+
protected $casts = [
21+
'enrolled_date' => 'date',
22+
'scheduled_date' => 'date',
23+
'completed_date' => 'date',
24+
'score' => 'float',
25+
];
26+
27+
public function employee(): BelongsTo
28+
{
29+
return $this->belongsTo(Employee::class);
30+
}
31+
32+
public function course(): BelongsTo
33+
{
34+
return $this->belongsTo(TrainingCourse::class, 'training_course_id');
35+
}
36+
37+
public function enrolledBy(): BelongsTo
38+
{
39+
return $this->belongsTo(User::class, 'enrolled_by');
40+
}
41+
42+
public function complete(float $score = null, string $notes = null): void
43+
{
44+
$this->status = 'completed';
45+
$this->completed_date = now()->toDateString();
46+
$this->score = $score;
47+
$this->notes = $notes;
48+
$this->save();
49+
}
50+
51+
public function fail(string $notes = null): void
52+
{
53+
$this->status = 'failed';
54+
$this->completed_date = now()->toDateString();
55+
$this->notes = $notes;
56+
$this->save();
57+
}
58+
59+
public function getIsCompletedAttribute(): bool
60+
{
61+
return $this->status === 'completed';
62+
}
63+
64+
public function getIsExpiredAttribute(): bool
65+
{
66+
return false;
67+
}
68+
}

erp/app/Modules/HR/Policies/TrainingPolicy.php

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,26 @@ class TrainingPolicy
88
{
99
public function viewAny(User $user): bool
1010
{
11-
return $user->can('hr.view');
11+
return $user->hasPermissionTo('hr.view');
1212
}
1313

14-
public function view(User $user): bool
14+
public function view(User $user, $model = null): bool
1515
{
16-
return $user->can('hr.view');
16+
return $user->hasPermissionTo('hr.view');
1717
}
1818

1919
public function create(User $user): bool
2020
{
21-
return $user->can('hr.create');
21+
return $user->hasPermissionTo('hr.create');
2222
}
2323

24-
public function delete(User $user): bool
24+
public function update(User $user, $model = null): bool
2525
{
26-
return $user->can('hr.delete');
26+
return $user->hasPermissionTo('hr.create');
27+
}
28+
29+
public function delete(User $user, $model = null): bool
30+
{
31+
return $user->hasPermissionTo('hr.delete');
2732
}
2833
}

0 commit comments

Comments
 (0)