Skip to content

Commit c3bfbaa

Browse files
committed
feat: Phase 47 — Training Records and certifications
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 04a8a31 commit c3bfbaa

18 files changed

Lines changed: 1367 additions & 0 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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\EmployeeTrainingRecord;
8+
use App\Modules\HR\Models\TrainingCourse;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class EmployeeTrainingRecordController extends Controller
15+
{
16+
public function index(): Response
17+
{
18+
$this->authorize('viewAny', EmployeeTrainingRecord::class);
19+
20+
$records = EmployeeTrainingRecord::with(['employee', 'trainingCourse'])
21+
->orderByDesc('completed_date')
22+
->paginate(25);
23+
24+
return Inertia::render('HR/TrainingRecords/Index', compact('records'));
25+
}
26+
27+
public function create(Request $request): Response
28+
{
29+
$this->authorize('create', EmployeeTrainingRecord::class);
30+
31+
$employees = Employee::where('status', 'active')
32+
->orderBy('first_name')
33+
->get(['id', 'first_name', 'last_name']);
34+
35+
$courses = TrainingCourse::where('is_active', true)
36+
->orderBy('title')
37+
->get(['id', 'title']);
38+
39+
$employeeId = $request->get('employee_id');
40+
$courseId = $request->get('training_course_id');
41+
42+
return Inertia::render('HR/TrainingRecords/Create', compact('employees', 'courses', 'employeeId', 'courseId'));
43+
}
44+
45+
public function store(Request $request): RedirectResponse
46+
{
47+
$this->authorize('create', EmployeeTrainingRecord::class);
48+
49+
$data = $request->validate([
50+
'employee_id' => 'required|exists:employees,id',
51+
'training_course_id' => 'nullable|exists:training_courses,id',
52+
'course_title' => 'required|string|max:255',
53+
'completed_date' => 'required|date',
54+
'expiry_date' => 'nullable|date|after_or_equal:completed_date',
55+
'score' => 'nullable|numeric|min:0',
56+
'passed' => 'boolean',
57+
'certificate_number' => 'nullable|string|max:255',
58+
'notes' => 'nullable|string',
59+
]);
60+
61+
// Snapshot course_title from selected course if not provided or if course is selected
62+
if (!empty($data['training_course_id'])) {
63+
$course = TrainingCourse::find($data['training_course_id']);
64+
if ($course) {
65+
$data['course_title'] = $course->title;
66+
}
67+
}
68+
69+
$record = EmployeeTrainingRecord::create([
70+
'tenant_id' => auth()->user()->tenant_id,
71+
...$data,
72+
]);
73+
74+
return redirect()->route('hr.training-records.show', $record)
75+
->with('success', 'Training record created.');
76+
}
77+
78+
public function show(EmployeeTrainingRecord $trainingRecord): Response
79+
{
80+
$this->authorize('view', $trainingRecord);
81+
82+
$trainingRecord->load(['employee', 'trainingCourse']);
83+
84+
return Inertia::render('HR/TrainingRecords/Show', compact('trainingRecord'));
85+
}
86+
87+
public function destroy(EmployeeTrainingRecord $trainingRecord): RedirectResponse
88+
{
89+
$this->authorize('delete', $trainingRecord);
90+
91+
$trainingRecord->delete();
92+
93+
return redirect()->route('hr.training-records.index')
94+
->with('success', 'Training record deleted.');
95+
}
96+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\TrainingCourse;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class TrainingCourseController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', TrainingCourse::class);
17+
18+
$courses = TrainingCourse::withCount('trainingRecords')
19+
->orderBy('title')
20+
->paginate(25);
21+
22+
return Inertia::render('HR/TrainingCourses/Index', compact('courses'));
23+
}
24+
25+
public function create(): Response
26+
{
27+
$this->authorize('create', TrainingCourse::class);
28+
29+
return Inertia::render('HR/TrainingCourses/Create');
30+
}
31+
32+
public function store(Request $request): RedirectResponse
33+
{
34+
$this->authorize('create', TrainingCourse::class);
35+
36+
$data = $request->validate([
37+
'title' => 'required|string|max:255',
38+
'provider' => 'nullable|string|max:255',
39+
'type' => 'required|in:internal,external,online,certification',
40+
'duration_hours' => 'nullable|numeric|min:0',
41+
'description' => 'nullable|string',
42+
'is_active' => 'boolean',
43+
]);
44+
45+
$course = TrainingCourse::create([
46+
'tenant_id' => auth()->user()->tenant_id,
47+
...$data,
48+
]);
49+
50+
return redirect()->route('hr.training-courses.show', $course)
51+
->with('success', 'Training course created.');
52+
}
53+
54+
public function show(TrainingCourse $trainingCourse): Response
55+
{
56+
$this->authorize('view', $trainingCourse);
57+
58+
$trainingCourse->load([
59+
'trainingRecords' => fn ($q) => $q->with('employee')->latest()->limit(20),
60+
]);
61+
62+
return Inertia::render('HR/TrainingCourses/Show', [
63+
'course' => $trainingCourse,
64+
]);
65+
}
66+
67+
public function destroy(TrainingCourse $trainingCourse): RedirectResponse
68+
{
69+
$this->authorize('delete', $trainingCourse);
70+
71+
$trainingCourse->delete();
72+
73+
return redirect()->route('hr.training-courses.index')
74+
->with('success', 'Training course deleted.');
75+
}
76+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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\SoftDeletes;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
10+
class EmployeeTrainingRecord extends Model
11+
{
12+
use BelongsToTenant, SoftDeletes;
13+
14+
protected $fillable = [
15+
'tenant_id', 'employee_id', 'training_course_id', 'course_title',
16+
'completed_date', 'expiry_date', 'score', 'passed', 'certificate_number', 'notes',
17+
];
18+
19+
protected $casts = [
20+
'completed_date' => 'date',
21+
'expiry_date' => 'date',
22+
'passed' => 'boolean',
23+
'score' => 'float',
24+
];
25+
26+
public function employee(): BelongsTo
27+
{
28+
return $this->belongsTo(Employee::class);
29+
}
30+
31+
public function trainingCourse(): BelongsTo
32+
{
33+
return $this->belongsTo(TrainingCourse::class);
34+
}
35+
36+
public function getIsExpiredAttribute(): bool
37+
{
38+
return $this->expiry_date && $this->expiry_date->isPast();
39+
}
40+
41+
public function getIsExpiringAttribute(): bool
42+
{
43+
return $this->expiry_date
44+
&& !$this->expiry_date->isPast()
45+
&& $this->expiry_date->diffInDays(now()) <= 30;
46+
}
47+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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\SoftDeletes;
8+
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
10+
class TrainingCourse extends Model
11+
{
12+
use BelongsToTenant, SoftDeletes;
13+
14+
protected $fillable = ['tenant_id', 'title', 'provider', 'type', 'duration_hours', 'description', 'is_active'];
15+
16+
protected $casts = [
17+
'is_active' => 'boolean',
18+
'duration_hours' => 'float',
19+
];
20+
21+
public function trainingRecords(): HasMany
22+
{
23+
return $this->hasMany(EmployeeTrainingRecord::class);
24+
}
25+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Policies;
4+
5+
use App\Models\User;
6+
7+
class TrainingPolicy
8+
{
9+
public function viewAny(User $user): bool
10+
{
11+
return $user->can('hr.view');
12+
}
13+
14+
public function view(User $user): bool
15+
{
16+
return $user->can('hr.view');
17+
}
18+
19+
public function create(User $user): bool
20+
{
21+
return $user->can('hr.create');
22+
}
23+
24+
public function delete(User $user): bool
25+
{
26+
return $user->can('hr.delete');
27+
}
28+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
use App\Modules\HR\Models\Department;
66
use App\Modules\HR\Models\Employee;
77
use App\Modules\HR\Models\EmployeeOnboarding;
8+
use App\Modules\HR\Models\EmployeeTrainingRecord;
89
use App\Modules\HR\Models\ExpenseClaim;
910
use App\Modules\HR\Models\LeaveRequest;
1011
use App\Modules\HR\Models\OnboardingTemplate;
1112
use App\Modules\HR\Models\PayrollRun;
1213
use App\Modules\HR\Models\PerformanceReview;
14+
use App\Modules\HR\Models\TrainingCourse;
1315
use App\Modules\HR\Policies\DepartmentPolicy;
1416
use App\Modules\HR\Policies\EmployeeOnboardingPolicy;
1517
use App\Modules\HR\Policies\EmployeePolicy;
@@ -18,6 +20,7 @@
1820
use App\Modules\HR\Policies\OnboardingTemplatePolicy;
1921
use App\Modules\HR\Policies\PayrollRunPolicy;
2022
use App\Modules\HR\Policies\PerformanceReviewPolicy;
23+
use App\Modules\HR\Policies\TrainingPolicy;
2124
use Illuminate\Support\Facades\Gate;
2225
use Illuminate\Support\ServiceProvider;
2326

@@ -37,5 +40,7 @@ public function boot(): void
3740
Gate::policy(OnboardingTemplate::class, OnboardingTemplatePolicy::class);
3841
Gate::policy(PayrollRun::class, PayrollRunPolicy::class);
3942
Gate::policy(PerformanceReview::class, PerformanceReviewPolicy::class);
43+
Gate::policy(TrainingCourse::class, TrainingPolicy::class);
44+
Gate::policy(EmployeeTrainingRecord::class, TrainingPolicy::class);
4045
}
4146
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
use App\Modules\HR\Http\Controllers\DepartmentController;
44
use App\Modules\HR\Http\Controllers\EmployeeController;
55
use App\Modules\HR\Http\Controllers\EmployeeOnboardingController;
6+
use App\Modules\HR\Http\Controllers\EmployeeTrainingRecordController;
67
use App\Modules\HR\Http\Controllers\ExpenseClaimController;
78
use App\Modules\HR\Http\Controllers\LeaveRequestController;
89
use App\Modules\HR\Http\Controllers\OnboardingTemplateController;
910
use App\Modules\HR\Http\Controllers\PayrollController;
1011
use App\Modules\HR\Http\Controllers\PayrollRunController;
1112
use App\Modules\HR\Http\Controllers\PerformanceReviewController;
13+
use App\Modules\HR\Http\Controllers\TrainingCourseController;
1214
use Illuminate\Support\Facades\Route;
1315

1416
Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () {
@@ -79,4 +81,8 @@
7981
Route::post('expense-claims/{expenseClaim}/reject', [ExpenseClaimController::class, 'reject'])->name('expense-claims.reject');
8082
Route::post('expense-claims/{expenseClaim}/reimburse', [ExpenseClaimController::class, 'reimburse'])->name('expense-claims.reimburse');
8183
Route::resource('expense-claims', ExpenseClaimController::class)->except(['edit', 'update']);
84+
85+
// Training
86+
Route::resource('training-courses', TrainingCourseController::class)->except(['edit', 'update']);
87+
Route::resource('training-records', EmployeeTrainingRecordController::class)->except(['edit', 'update']);
8288
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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('training_courses', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
14+
$table->string('title');
15+
$table->string('provider')->nullable();
16+
$table->enum('type', ['internal', 'external', 'online', 'certification'])->default('internal');
17+
$table->decimal('duration_hours', 5, 1)->nullable();
18+
$table->text('description')->nullable();
19+
$table->boolean('is_active')->default(true);
20+
$table->timestamps();
21+
$table->softDeletes();
22+
});
23+
}
24+
25+
public function down(): void
26+
{
27+
Schema::dropIfExists('training_courses');
28+
}
29+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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_training_records', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
14+
$table->foreignId('employee_id')->constrained('employees')->cascadeOnDelete();
15+
$table->foreignId('training_course_id')->nullable()->constrained('training_courses')->nullOnDelete();
16+
$table->string('course_title');
17+
$table->date('completed_date');
18+
$table->date('expiry_date')->nullable();
19+
$table->decimal('score', 5, 2)->nullable();
20+
$table->boolean('passed')->default(true);
21+
$table->string('certificate_number')->nullable();
22+
$table->text('notes')->nullable();
23+
$table->timestamps();
24+
$table->softDeletes();
25+
});
26+
}
27+
28+
public function down(): void
29+
{
30+
Schema::dropIfExists('employee_training_records');
31+
}
32+
};

0 commit comments

Comments
 (0)