Skip to content

Commit 4cfae70

Browse files
committed
feat(hr): Phase 106 — Employee Skills & Competency Tracking
Implements full skill tracking with SkillDefinition and EmployeeSkill models, controllers, policies, migrations, Inertia pages, and 10 Pest tests (1090 → 1100 passing). https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent f1bc91d commit 4cfae70

15 files changed

Lines changed: 918 additions & 1 deletion

File tree

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\EmployeeSkill;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class EmployeeSkillController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', EmployeeSkill::class);
17+
18+
$query = EmployeeSkill::with(['employee', 'definition']);
19+
20+
if ($request->filled('employee_id')) {
21+
$query->where('employee_id', $request->employee_id);
22+
}
23+
24+
$skills = $query->latest()->paginate(20);
25+
26+
return Inertia::render('HR/EmployeeSkills/Index', compact('skills'));
27+
}
28+
29+
public function store(Request $request): RedirectResponse
30+
{
31+
$this->authorize('create', EmployeeSkill::class);
32+
33+
$data = $request->validate([
34+
'employee_id' => 'required|exists:employees,id',
35+
'skill_name' => 'required|string|max:255',
36+
'skill_definition_id' => 'nullable|exists:skill_definitions,id',
37+
'proficiency_level' => 'required|integer|min:1|max:5',
38+
'acquired_date' => 'nullable|date',
39+
'notes' => 'nullable|string',
40+
]);
41+
42+
EmployeeSkill::create([
43+
'tenant_id' => auth()->user()->tenant_id,
44+
...$data,
45+
]);
46+
47+
return redirect()->back()->with('success', 'Skill added.');
48+
}
49+
50+
public function show(EmployeeSkill $employeeSkill): Response
51+
{
52+
$this->authorize('view', $employeeSkill);
53+
54+
$employeeSkill->load(['employee', 'definition']);
55+
56+
return Inertia::render('HR/EmployeeSkills/Show', compact('employeeSkill'));
57+
}
58+
59+
public function verify(EmployeeSkill $employeeSkill): RedirectResponse
60+
{
61+
$this->authorize('update', $employeeSkill);
62+
63+
$employeeSkill->verify(auth()->id());
64+
65+
return redirect()->back()->with('success', 'Skill verified.');
66+
}
67+
68+
public function destroy(EmployeeSkill $employeeSkill): RedirectResponse
69+
{
70+
$this->authorize('delete', $employeeSkill);
71+
72+
$employeeSkill->delete();
73+
74+
return redirect()->back()->with('success', 'Skill deleted.');
75+
}
76+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\SkillDefinition;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class SkillDefinitionController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', SkillDefinition::class);
17+
18+
$definitions = SkillDefinition::where('is_active', true)
19+
->where('tenant_id', auth()->user()->tenant_id)
20+
->latest()
21+
->get();
22+
23+
return Inertia::render('HR/SkillDefinitions/Index', compact('definitions'));
24+
}
25+
26+
public function store(Request $request): RedirectResponse
27+
{
28+
$this->authorize('create', SkillDefinition::class);
29+
30+
$data = $request->validate([
31+
'name' => 'required|string|max:255',
32+
'category' => 'nullable|string|max:100',
33+
'description' => 'nullable|string',
34+
]);
35+
36+
SkillDefinition::create([
37+
'tenant_id' => auth()->user()->tenant_id,
38+
...$data,
39+
]);
40+
41+
return redirect()->back()->with('success', 'Skill definition created.');
42+
}
43+
44+
public function destroy(SkillDefinition $skillDefinition): RedirectResponse
45+
{
46+
$this->authorize('delete', $skillDefinition);
47+
48+
$skillDefinition->delete();
49+
50+
return redirect()->back()->with('success', 'Skill definition deleted.');
51+
}
52+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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 EmployeeSkill extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $fillable = [
15+
'tenant_id', 'employee_id', 'skill_definition_id', 'skill_name',
16+
'proficiency_level', 'is_verified', 'verified_by', 'verified_at',
17+
'acquired_date', 'notes',
18+
];
19+
20+
protected $attributes = [
21+
'is_verified' => false,
22+
'proficiency_level' => 1,
23+
];
24+
25+
protected $casts = [
26+
'proficiency_level' => 'integer',
27+
'is_verified' => 'boolean',
28+
'verified_at' => 'datetime',
29+
'acquired_date' => 'date',
30+
];
31+
32+
public function employee(): BelongsTo
33+
{
34+
return $this->belongsTo(Employee::class);
35+
}
36+
37+
public function definition(): BelongsTo
38+
{
39+
return $this->belongsTo(SkillDefinition::class, 'skill_definition_id');
40+
}
41+
42+
public function verifiedBy(): BelongsTo
43+
{
44+
return $this->belongsTo(User::class, 'verified_by');
45+
}
46+
47+
public function verify(int $userId): void
48+
{
49+
$this->is_verified = true;
50+
$this->verified_by = $userId;
51+
$this->verified_at = now();
52+
$this->save();
53+
}
54+
55+
public function getProficiencyLabelAttribute(): string
56+
{
57+
return match ($this->proficiency_level) {
58+
1 => 'Beginner',
59+
2 => 'Basic',
60+
3 => 'Intermediate',
61+
4 => 'Advanced',
62+
5 => 'Expert',
63+
default => 'Unknown',
64+
};
65+
}
66+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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\HasMany;
8+
9+
class SkillDefinition extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = ['tenant_id', 'name', 'category', 'description', 'is_active'];
14+
15+
protected $casts = ['is_active' => 'boolean'];
16+
17+
public function employeeSkills(): HasMany
18+
{
19+
return $this->hasMany(EmployeeSkill::class);
20+
}
21+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Policies;
4+
5+
use App\Models\User;
6+
7+
class EmployeeSkillPolicy
8+
{
9+
public function viewAny(User $user): bool
10+
{
11+
return $user->hasPermissionTo('hr.view');
12+
}
13+
14+
public function view(User $user, $model): bool
15+
{
16+
return $user->hasPermissionTo('hr.view');
17+
}
18+
19+
public function create(User $user): bool
20+
{
21+
return $user->hasPermissionTo('hr.create');
22+
}
23+
24+
public function update(User $user, $model): bool
25+
{
26+
return $user->hasPermissionTo('hr.create');
27+
}
28+
29+
public function delete(User $user, $model): bool
30+
{
31+
return $user->hasPermissionTo('hr.delete');
32+
}
33+
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@
6161
use App\Modules\HR\Policies\WorkSchedulePolicy;
6262
use App\Modules\HR\Models\EmployeeDocument;
6363
use App\Modules\HR\Policies\EmployeeDocumentPolicy;
64+
use App\Modules\HR\Models\SkillDefinition;
65+
use App\Modules\HR\Models\EmployeeSkill;
66+
use App\Modules\HR\Policies\EmployeeSkillPolicy;
6467
use Illuminate\Support\Facades\Gate;
6568
use Illuminate\Support\ServiceProvider;
6669

@@ -110,5 +113,7 @@ public function boot(): void
110113
Gate::policy(WorkScheduleShift::class, WorkSchedulePolicy::class);
111114
Gate::policy(EmployeeSchedule::class, WorkSchedulePolicy::class);
112115
Gate::policy(EmployeeDocument::class, EmployeeDocumentPolicy::class);
116+
Gate::policy(SkillDefinition::class, EmployeeSkillPolicy::class);
117+
Gate::policy(EmployeeSkill::class, EmployeeSkillPolicy::class);
113118
}
114-
}
119+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,12 @@
211211
Route::post('employee-documents/{employeeDocument}/verify', [EmployeeDocumentController::class, 'verify'])->name('employee-documents.verify');
212212
Route::resource('employee-documents', EmployeeDocumentController::class)->only(['index', 'store', 'show', 'destroy']);
213213
});
214+
215+
// Employee Skills
216+
use App\Modules\HR\Http\Controllers\EmployeeSkillController;
217+
use App\Modules\HR\Http\Controllers\SkillDefinitionController;
218+
Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () {
219+
Route::post('employee-skills/{employeeSkill}/verify', [EmployeeSkillController::class, 'verify'])->name('employee-skills.verify');
220+
Route::resource('employee-skills', EmployeeSkillController::class)->only(['index', 'store', 'show', 'destroy']);
221+
Route::resource('skill-definitions', SkillDefinitionController::class)->only(['index', 'store', 'destroy']);
222+
});
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('skill_definitions', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('name');
15+
$table->string('category')->nullable(); // e.g. technical, soft, language, management
16+
$table->text('description')->nullable();
17+
$table->boolean('is_active')->default(true);
18+
$table->timestamps();
19+
});
20+
}
21+
22+
public function down(): void
23+
{
24+
Schema::dropIfExists('skill_definitions');
25+
}
26+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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_skills', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->unsignedBigInteger('employee_id');
15+
$table->unsignedBigInteger('skill_definition_id')->nullable();
16+
$table->string('skill_name'); // denormalized for easy querying even without definition
17+
$table->unsignedTinyInteger('proficiency_level')->default(1); // 1=beginner, 2=basic, 3=intermediate, 4=advanced, 5=expert
18+
$table->boolean('is_verified')->default(false);
19+
$table->unsignedBigInteger('verified_by')->nullable();
20+
$table->timestamp('verified_at')->nullable();
21+
$table->date('acquired_date')->nullable();
22+
$table->text('notes')->nullable();
23+
$table->timestamps();
24+
});
25+
}
26+
27+
public function down(): void
28+
{
29+
Schema::dropIfExists('employee_skills');
30+
}
31+
};

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ const navItems: NavItem[] = [
185185
{ label: 'Timesheets', href: '/hr/timesheets', icon: <span /> },
186186
{ label: 'Benefit Plans', href: '/hr/benefit-plans', icon: <span /> },
187187
{ label: 'Employee Benefits', href: '/hr/employee-benefits', icon: <span /> },
188+
{ label: 'Skills', href: '/hr/employee-skills', icon: <span /> },
189+
{ label: 'Skill Definitions', href: '/hr/skill-definitions', icon: <span /> },
188190
],
189191
},
190192
{

0 commit comments

Comments
 (0)