Skip to content

Commit 887dba1

Browse files
committed
feat(hr): Phase 81 — Employee Onboarding Checklists with task tracking
Implements HR onboarding checklists (OnboardingChecklist, OnboardingTask), employee onboarding assignment (EmployeeOnboarding v2), and per-task progress tracking (OnboardingProgress) with auto-completion when all required tasks are done. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 79ef28b commit 887dba1

22 files changed

Lines changed: 1633 additions & 2 deletions
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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\EmployeeOnboarding;
8+
use App\Modules\HR\Models\OnboardingChecklist;
9+
use App\Modules\HR\Models\OnboardingProgress;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
15+
class EmployeeOnboardingTrackingController extends Controller
16+
{
17+
public function index(Request $request): Response
18+
{
19+
$this->authorize('viewAny', EmployeeOnboarding::class);
20+
21+
$query = EmployeeOnboarding::with(['employee', 'checklist'])
22+
->whereNotNull('onboarding_checklist_id');
23+
24+
if ($request->filled('employee_id')) {
25+
$query->where('employee_id', $request->employee_id);
26+
}
27+
28+
if ($request->filled('status')) {
29+
$query->where('status', $request->status);
30+
}
31+
32+
$onboardings = $query->orderByDesc('created_at')->paginate(20);
33+
34+
$employees = Employee::orderBy('first_name')->get(['id', 'first_name', 'last_name']);
35+
$checklists = OnboardingChecklist::where('is_active', true)->orderBy('name')->get(['id', 'name']);
36+
37+
return Inertia::render('HR/EmployeeOnboardings/Index', [
38+
'onboardings' => $onboardings,
39+
'employees' => $employees,
40+
'checklists' => $checklists,
41+
'filters' => $request->only(['employee_id', 'status']),
42+
]);
43+
}
44+
45+
public function create(): Response
46+
{
47+
$this->authorize('create', EmployeeOnboarding::class);
48+
49+
$employees = Employee::orderBy('first_name')->get(['id', 'first_name', 'last_name']);
50+
$checklists = OnboardingChecklist::where('is_active', true)->orderBy('name')->get(['id', 'name']);
51+
52+
return Inertia::render('HR/EmployeeOnboardings/Create', [
53+
'employees' => $employees,
54+
'checklists' => $checklists,
55+
]);
56+
}
57+
58+
public function store(Request $request): RedirectResponse
59+
{
60+
$this->authorize('create', EmployeeOnboarding::class);
61+
62+
$validated = $request->validate([
63+
'employee_id' => 'required|exists:employees,id',
64+
'onboarding_checklist_id' => 'required|exists:onboarding_checklists,id',
65+
'start_date' => 'required|date',
66+
]);
67+
68+
$checklist = OnboardingChecklist::with('tasks')->findOrFail($validated['onboarding_checklist_id']);
69+
70+
$onboarding = EmployeeOnboarding::create([
71+
'tenant_id' => auth()->user()->tenant_id,
72+
'employee_id' => $validated['employee_id'],
73+
'onboarding_checklist_id' => $validated['onboarding_checklist_id'],
74+
'start_date' => $validated['start_date'],
75+
'started_at' => $validated['start_date'],
76+
'title' => $checklist->name,
77+
'status' => 'in_progress',
78+
'assigned_by' => auth()->id(),
79+
]);
80+
81+
foreach ($checklist->tasks as $task) {
82+
OnboardingProgress::create([
83+
'tenant_id' => auth()->user()->tenant_id,
84+
'employee_onboarding_id' => $onboarding->id,
85+
'onboarding_task_id' => $task->id,
86+
'status' => 'pending',
87+
]);
88+
}
89+
90+
return redirect()->route('hr.employee-onboardings.show', $onboarding)
91+
->with('success', 'Employee onboarding created.');
92+
}
93+
94+
public function show(EmployeeOnboarding $employeeOnboarding): Response
95+
{
96+
$this->authorize('view', $employeeOnboarding);
97+
98+
$employeeOnboarding->load(['employee', 'checklist.tasks', 'progress.task']);
99+
100+
return Inertia::render('HR/EmployeeOnboardings/Show', [
101+
'onboarding' => $employeeOnboarding,
102+
]);
103+
}
104+
105+
public function completeTask(Request $request, EmployeeOnboarding $employeeOnboarding, OnboardingProgress $progress): RedirectResponse
106+
{
107+
$this->authorize('update', $employeeOnboarding);
108+
109+
$validated = $request->validate([
110+
'notes' => 'nullable|string',
111+
]);
112+
113+
$progress->complete(auth()->user(), $validated['notes'] ?? null);
114+
115+
return back()->with('success', 'Task completed.');
116+
}
117+
118+
public function skipTask(Request $request, EmployeeOnboarding $employeeOnboarding, OnboardingProgress $progress): RedirectResponse
119+
{
120+
$this->authorize('update', $employeeOnboarding);
121+
122+
$validated = $request->validate([
123+
'notes' => 'nullable|string',
124+
]);
125+
126+
$progress->skip($validated['notes'] ?? null);
127+
128+
return back()->with('success', 'Task skipped.');
129+
}
130+
131+
public function destroy(EmployeeOnboarding $employeeOnboarding): RedirectResponse
132+
{
133+
$this->authorize('delete', $employeeOnboarding);
134+
135+
$employeeOnboarding->delete();
136+
137+
return redirect()->route('hr.employee-onboardings.index')
138+
->with('success', 'Onboarding deleted.');
139+
}
140+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\OnboardingChecklist;
7+
use App\Modules\HR\Models\OnboardingTask;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class OnboardingChecklistController extends Controller
14+
{
15+
public function index(): Response
16+
{
17+
$this->authorize('viewAny', OnboardingChecklist::class);
18+
19+
$checklists = OnboardingChecklist::withCount('tasks')
20+
->orderBy('name')
21+
->paginate(20);
22+
23+
return Inertia::render('HR/OnboardingChecklists/Index', [
24+
'checklists' => $checklists,
25+
]);
26+
}
27+
28+
public function create(): Response
29+
{
30+
$this->authorize('create', OnboardingChecklist::class);
31+
32+
return Inertia::render('HR/OnboardingChecklists/Create');
33+
}
34+
35+
public function store(Request $request): RedirectResponse
36+
{
37+
$this->authorize('create', OnboardingChecklist::class);
38+
39+
$validated = $request->validate([
40+
'name' => 'required|string|max:255',
41+
'department' => 'nullable|string|max:255',
42+
'description' => 'nullable|string',
43+
'is_active' => 'boolean',
44+
'tasks' => 'array',
45+
'tasks.*.title' => 'required|string|max:255',
46+
'tasks.*.description' => 'nullable|string',
47+
'tasks.*.category' => 'nullable|string|max:255',
48+
'tasks.*.due_day_offset' => 'integer|min:0',
49+
'tasks.*.is_required' => 'boolean',
50+
'tasks.*.sort_order' => 'integer|min:0',
51+
]);
52+
53+
$checklist = OnboardingChecklist::create([
54+
'tenant_id' => auth()->user()->tenant_id,
55+
'name' => $validated['name'],
56+
'department' => $validated['department'] ?? null,
57+
'description' => $validated['description'] ?? null,
58+
'is_active' => $validated['is_active'] ?? true,
59+
]);
60+
61+
foreach ($validated['tasks'] ?? [] as $taskData) {
62+
$checklist->tasks()->create([
63+
'tenant_id' => auth()->user()->tenant_id,
64+
'title' => $taskData['title'],
65+
'description' => $taskData['description'] ?? null,
66+
'category' => $taskData['category'] ?? null,
67+
'due_day_offset' => $taskData['due_day_offset'] ?? 0,
68+
'is_required' => $taskData['is_required'] ?? true,
69+
'sort_order' => $taskData['sort_order'] ?? 0,
70+
]);
71+
}
72+
73+
return redirect()->route('hr.onboarding-checklists.show', $checklist)
74+
->with('success', 'Onboarding checklist created.');
75+
}
76+
77+
public function show(OnboardingChecklist $onboardingChecklist): Response
78+
{
79+
$this->authorize('view', $onboardingChecklist);
80+
81+
$onboardingChecklist->load('tasks');
82+
83+
return Inertia::render('HR/OnboardingChecklists/Show', [
84+
'checklist' => $onboardingChecklist,
85+
]);
86+
}
87+
88+
public function destroy(OnboardingChecklist $onboardingChecklist): RedirectResponse
89+
{
90+
$this->authorize('delete', $onboardingChecklist);
91+
92+
$onboardingChecklist->delete();
93+
94+
return redirect()->route('hr.onboarding-checklists.index')
95+
->with('success', 'Onboarding checklist deleted.');
96+
}
97+
}

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

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Modules\HR\Models;
44

5+
use App\Models\User;
56
use App\Modules\Core\Traits\BelongsToTenant;
67
use Illuminate\Database\Eloquent\Model;
78
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -12,9 +13,16 @@ class EmployeeOnboarding extends Model
1213
{
1314
use BelongsToTenant, SoftDeletes;
1415

15-
protected $fillable = ['tenant_id', 'employee_id', 'template_id', 'title', 'status', 'started_at', 'completed_at'];
16+
protected $fillable = [
17+
'tenant_id', 'employee_id', 'template_id', 'onboarding_checklist_id',
18+
'title', 'status', 'started_at', 'start_date', 'completed_at', 'assigned_by',
19+
];
1620

17-
protected $casts = ['started_at' => 'date', 'completed_at' => 'date'];
21+
protected $casts = [
22+
'started_at' => 'date',
23+
'start_date' => 'date',
24+
'completed_at' => 'datetime',
25+
];
1826

1927
public function employee(): BelongsTo
2028
{
@@ -26,18 +34,71 @@ public function template(): BelongsTo
2634
return $this->belongsTo(OnboardingTemplate::class, 'template_id');
2735
}
2836

37+
public function checklist(): BelongsTo
38+
{
39+
return $this->belongsTo(OnboardingChecklist::class, 'onboarding_checklist_id');
40+
}
41+
42+
/** Legacy tasks (EmployeeOnboardingTask) */
2943
public function tasks(): HasMany
3044
{
3145
return $this->hasMany(EmployeeOnboardingTask::class)->orderBy('sort_order');
3246
}
3347

48+
/** New progress items (OnboardingProgress) */
49+
public function progress(): HasMany
50+
{
51+
return $this->hasMany(OnboardingProgress::class, 'employee_onboarding_id');
52+
}
53+
54+
public function assignedBy(): BelongsTo
55+
{
56+
return $this->belongsTo(User::class, 'assigned_by');
57+
}
58+
59+
/** Legacy progress percentage (0-100 integer) */
3460
public function getProgressAttribute(): int
3561
{
3662
$total = $this->tasks()->count();
3763
if ($total === 0) return 0;
3864
return (int) round($this->tasks()->whereNotNull('completed_at')->count() / $total * 100);
3965
}
4066

67+
/** New completion percentage (0.0-100.0 float) */
68+
public function getCompletionPercentAttribute(): float
69+
{
70+
$total = $this->progress()->count();
71+
if ($total === 0) return 0.0;
72+
$done = $this->progress()->whereIn('status', ['completed', 'skipped'])->count();
73+
return round($done / $total * 100, 1);
74+
}
75+
76+
/**
77+
* Auto-complete this onboarding if all required tasks are completed or skipped.
78+
*/
79+
public function checkComplete(): void
80+
{
81+
$requiredTotal = $this->progress()
82+
->whereHas('task', fn($q) => $q->where('is_required', true))
83+
->count();
84+
85+
if ($requiredTotal === 0) {
86+
return;
87+
}
88+
89+
$requiredDone = $this->progress()
90+
->whereHas('task', fn($q) => $q->where('is_required', true))
91+
->whereIn('status', ['completed', 'skipped'])
92+
->count();
93+
94+
if ($requiredDone >= $requiredTotal) {
95+
$this->update([
96+
'status' => 'completed',
97+
'completed_at' => now(),
98+
]);
99+
}
100+
}
101+
41102
/**
42103
* Instantiate from a template, creating task copies for the employee.
43104
*/
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
use Illuminate\Database\Eloquent\SoftDeletes;
9+
10+
class OnboardingChecklist extends Model
11+
{
12+
use BelongsToTenant, SoftDeletes;
13+
14+
protected $fillable = [
15+
'tenant_id',
16+
'name',
17+
'department',
18+
'description',
19+
'is_active',
20+
];
21+
22+
protected $casts = [
23+
'is_active' => 'boolean',
24+
];
25+
26+
public function tasks(): HasMany
27+
{
28+
return $this->hasMany(OnboardingTask::class)->orderBy('sort_order');
29+
}
30+
31+
public function employeeOnboardings(): HasMany
32+
{
33+
return $this->hasMany(EmployeeOnboarding::class, 'onboarding_checklist_id');
34+
}
35+
}

0 commit comments

Comments
 (0)