Skip to content

Commit 3d9073c

Browse files
committed
feat: Phase 45 — Employee Onboarding Checklists
Adds onboarding template management (admin-defined reusable checklists) and per-employee onboarding instances with task completion tracking, progress calculation, and due-date computation from employee start date. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent d6550e7 commit 3d9073c

23 files changed

Lines changed: 1485 additions & 5 deletions
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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\EmployeeOnboardingTask;
9+
use App\Modules\HR\Models\OnboardingTemplate;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
15+
class EmployeeOnboardingController extends Controller
16+
{
17+
public function index(Employee $employee): Response
18+
{
19+
$this->authorize('viewAny', EmployeeOnboarding::class);
20+
21+
$onboardings = $employee->onboardings()
22+
->withCount('tasks')
23+
->orderByDesc('created_at')
24+
->get()
25+
->map(function ($onboarding) {
26+
$onboarding->progress = $onboarding->progress;
27+
return $onboarding;
28+
});
29+
30+
return Inertia::render('HR/Employees/Onboardings/Index', [
31+
'employee' => $employee,
32+
'onboardings' => $onboardings,
33+
'breadcrumbs' => [
34+
['label' => 'HR'],
35+
['label' => 'Employees', 'href' => route('hr.employees.index')],
36+
['label' => $employee->full_name, 'href' => route('hr.employees.show', $employee)],
37+
['label' => 'Onboardings'],
38+
],
39+
]);
40+
}
41+
42+
public function create(Employee $employee): Response
43+
{
44+
$this->authorize('create', EmployeeOnboarding::class);
45+
46+
$templates = OnboardingTemplate::where('is_active', true)
47+
->orderBy('name')
48+
->get(['id', 'name', 'description']);
49+
50+
return Inertia::render('HR/Employees/Onboardings/Create', [
51+
'employee' => $employee,
52+
'templates' => $templates,
53+
'breadcrumbs' => [
54+
['label' => 'HR'],
55+
['label' => 'Employees', 'href' => route('hr.employees.index')],
56+
['label' => $employee->full_name, 'href' => route('hr.employees.show', $employee)],
57+
['label' => 'Onboardings', 'href' => route('hr.employees.onboardings.index', $employee)],
58+
['label' => 'New Onboarding'],
59+
],
60+
]);
61+
}
62+
63+
public function store(Request $request, Employee $employee): RedirectResponse
64+
{
65+
$this->authorize('create', EmployeeOnboarding::class);
66+
67+
$validated = $request->validate([
68+
'template_id' => 'nullable|exists:onboarding_templates,id',
69+
'title' => 'nullable|string|max:255',
70+
'started_at' => 'required|date',
71+
]);
72+
73+
if ($validated['template_id']) {
74+
$template = OnboardingTemplate::findOrFail($validated['template_id']);
75+
$onboarding = EmployeeOnboarding::fromTemplate($employee, $template);
76+
// Override started_at if explicitly provided
77+
if ($validated['started_at']) {
78+
$onboarding->update(['started_at' => $validated['started_at']]);
79+
}
80+
} else {
81+
$onboarding = EmployeeOnboarding::create([
82+
'tenant_id' => $employee->tenant_id,
83+
'employee_id' => $employee->id,
84+
'template_id' => null,
85+
'title' => $validated['title'] ?? 'Onboarding',
86+
'status' => 'in_progress',
87+
'started_at' => $validated['started_at'],
88+
]);
89+
}
90+
91+
return redirect()->route('hr.employees.onboardings.show', [$employee, $onboarding])
92+
->with('success', 'Onboarding created.');
93+
}
94+
95+
public function show(Employee $employee, EmployeeOnboarding $onboarding): Response
96+
{
97+
$this->authorize('view', $onboarding);
98+
99+
$onboarding->load('tasks');
100+
101+
return Inertia::render('HR/Employees/Onboardings/Show', [
102+
'employee' => $employee,
103+
'onboarding' => array_merge($onboarding->toArray(), ['progress' => $onboarding->progress]),
104+
'breadcrumbs' => [
105+
['label' => 'HR'],
106+
['label' => 'Employees', 'href' => route('hr.employees.index')],
107+
['label' => $employee->full_name, 'href' => route('hr.employees.show', $employee)],
108+
['label' => 'Onboardings', 'href' => route('hr.employees.onboardings.index', $employee)],
109+
['label' => $onboarding->title],
110+
],
111+
]);
112+
}
113+
114+
public function completeTask(Employee $employee, EmployeeOnboarding $onboarding, EmployeeOnboardingTask $task): RedirectResponse
115+
{
116+
$this->authorize('update', $onboarding);
117+
118+
$task->update([
119+
'completed_at' => now(),
120+
'completed_by' => auth()->id(),
121+
]);
122+
123+
return back()->with('success', 'Task marked as complete.');
124+
}
125+
126+
public function uncompleteTask(Employee $employee, EmployeeOnboarding $onboarding, EmployeeOnboardingTask $task): RedirectResponse
127+
{
128+
$this->authorize('update', $onboarding);
129+
130+
$task->update([
131+
'completed_at' => null,
132+
'completed_by' => null,
133+
]);
134+
135+
return back()->with('success', 'Task marked as incomplete.');
136+
}
137+
138+
public function complete(Employee $employee, EmployeeOnboarding $onboarding): RedirectResponse
139+
{
140+
$this->authorize('update', $onboarding);
141+
142+
$onboarding->update([
143+
'status' => 'completed',
144+
'completed_at' => now()->toDateString(),
145+
]);
146+
147+
return back()->with('success', 'Onboarding marked as complete.');
148+
}
149+
150+
public function destroy(Employee $employee, EmployeeOnboarding $onboarding): RedirectResponse
151+
{
152+
$this->authorize('delete', $onboarding);
153+
154+
$onboarding->delete();
155+
156+
return redirect()->route('hr.employees.onboardings.index', $employee)
157+
->with('success', 'Onboarding deleted.');
158+
}
159+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\OnboardingTemplate;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class OnboardingTemplateController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', OnboardingTemplate::class);
17+
18+
$templates = OnboardingTemplate::withCount('tasks')
19+
->orderBy('name')
20+
->get();
21+
22+
return Inertia::render('HR/OnboardingTemplates/Index', [
23+
'templates' => $templates,
24+
'breadcrumbs' => [
25+
['label' => 'HR'],
26+
['label' => 'Onboarding Templates', 'href' => route('hr.onboarding-templates.index')],
27+
],
28+
]);
29+
}
30+
31+
public function create(): Response
32+
{
33+
$this->authorize('create', OnboardingTemplate::class);
34+
35+
return Inertia::render('HR/OnboardingTemplates/Create', [
36+
'breadcrumbs' => [
37+
['label' => 'HR'],
38+
['label' => 'Onboarding Templates', 'href' => route('hr.onboarding-templates.index')],
39+
['label' => 'New Template'],
40+
],
41+
]);
42+
}
43+
44+
public function store(Request $request): RedirectResponse
45+
{
46+
$this->authorize('create', OnboardingTemplate::class);
47+
48+
$validated = $request->validate([
49+
'name' => 'required|string|max:255',
50+
'description' => 'nullable|string',
51+
'is_active' => 'boolean',
52+
'tasks' => 'array',
53+
'tasks.*.title' => 'required|string|max:255',
54+
'tasks.*.description' => 'nullable|string',
55+
'tasks.*.due_days' => 'integer|min:0|max:255',
56+
'tasks.*.sort_order' => 'integer|min:0|max:255',
57+
]);
58+
59+
$template = OnboardingTemplate::create([
60+
'tenant_id' => auth()->user()->tenant_id,
61+
'name' => $validated['name'],
62+
'description' => $validated['description'] ?? null,
63+
'is_active' => $validated['is_active'] ?? true,
64+
]);
65+
66+
foreach ($validated['tasks'] ?? [] as $taskData) {
67+
$template->tasks()->create([
68+
'title' => $taskData['title'],
69+
'description' => $taskData['description'] ?? null,
70+
'due_days' => $taskData['due_days'] ?? 0,
71+
'sort_order' => $taskData['sort_order'] ?? 0,
72+
]);
73+
}
74+
75+
return redirect()->route('hr.onboarding-templates.show', $template)
76+
->with('success', 'Onboarding template created.');
77+
}
78+
79+
public function show(OnboardingTemplate $onboardingTemplate): Response
80+
{
81+
$this->authorize('view', $onboardingTemplate);
82+
83+
$onboardingTemplate->load('tasks');
84+
85+
return Inertia::render('HR/OnboardingTemplates/Show', [
86+
'template' => $onboardingTemplate,
87+
'breadcrumbs' => [
88+
['label' => 'HR'],
89+
['label' => 'Onboarding Templates', 'href' => route('hr.onboarding-templates.index')],
90+
['label' => $onboardingTemplate->name],
91+
],
92+
]);
93+
}
94+
95+
public function destroy(OnboardingTemplate $onboardingTemplate): RedirectResponse
96+
{
97+
$this->authorize('delete', $onboardingTemplate);
98+
99+
$hasActiveOnboardings = $onboardingTemplate->onboardings()
100+
->where('status', 'in_progress')
101+
->exists();
102+
103+
if ($hasActiveOnboardings) {
104+
return back()->withErrors(['template' => 'Cannot delete a template with in-progress onboardings.']);
105+
}
106+
107+
$onboardingTemplate->delete();
108+
109+
return redirect()->route('hr.onboarding-templates.index')
110+
->with('success', 'Onboarding template deleted.');
111+
}
112+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ public function payrollItems(): HasMany
4747
return $this->hasMany(PayrollItem::class);
4848
}
4949

50+
public function onboardings(): HasMany
51+
{
52+
return $this->hasMany(EmployeeOnboarding::class);
53+
}
54+
5055
public function getFullNameAttribute(): string
5156
{
5257
return "{$this->first_name} {$this->last_name}";
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
11+
class EmployeeOnboarding extends Model
12+
{
13+
use BelongsToTenant, SoftDeletes;
14+
15+
protected $fillable = ['tenant_id', 'employee_id', 'template_id', 'title', 'status', 'started_at', 'completed_at'];
16+
17+
protected $casts = ['started_at' => 'date', 'completed_at' => 'date'];
18+
19+
public function employee(): BelongsTo
20+
{
21+
return $this->belongsTo(Employee::class);
22+
}
23+
24+
public function template(): BelongsTo
25+
{
26+
return $this->belongsTo(OnboardingTemplate::class, 'template_id');
27+
}
28+
29+
public function tasks(): HasMany
30+
{
31+
return $this->hasMany(EmployeeOnboardingTask::class)->orderBy('sort_order');
32+
}
33+
34+
public function getProgressAttribute(): int
35+
{
36+
$total = $this->tasks()->count();
37+
if ($total === 0) return 0;
38+
return (int) round($this->tasks()->whereNotNull('completed_at')->count() / $total * 100);
39+
}
40+
41+
/**
42+
* Instantiate from a template, creating task copies for the employee.
43+
*/
44+
public static function fromTemplate(Employee $employee, OnboardingTemplate $template): self
45+
{
46+
$startDate = $employee->start_date ?? now()->toDateString();
47+
48+
$onboarding = self::create([
49+
'tenant_id' => $employee->tenant_id,
50+
'employee_id' => $employee->id,
51+
'template_id' => $template->id,
52+
'title' => $template->name,
53+
'status' => 'in_progress',
54+
'started_at' => $startDate,
55+
]);
56+
57+
foreach ($template->tasks as $task) {
58+
$dueDate = $task->due_days > 0
59+
? \Carbon\Carbon::parse($onboarding->started_at)->addDays($task->due_days)->toDateString()
60+
: null;
61+
62+
$onboarding->tasks()->create([
63+
'title' => $task->title,
64+
'description' => $task->description,
65+
'due_date' => $dueDate,
66+
'sort_order' => $task->sort_order,
67+
]);
68+
}
69+
70+
return $onboarding;
71+
}
72+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Models;
4+
5+
use App\Models\User;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class EmployeeOnboardingTask extends Model
10+
{
11+
protected $fillable = ['employee_onboarding_id', 'title', 'description', 'due_date', 'completed_at', 'completed_by', 'sort_order'];
12+
13+
protected $casts = ['completed_at' => 'datetime', 'due_date' => 'date'];
14+
15+
public function onboarding(): BelongsTo
16+
{
17+
return $this->belongsTo(EmployeeOnboarding::class, 'employee_onboarding_id');
18+
}
19+
20+
public function completer(): BelongsTo
21+
{
22+
return $this->belongsTo(User::class, 'completed_by');
23+
}
24+
25+
public function getIsCompletedAttribute(): bool
26+
{
27+
return $this->completed_at !== null;
28+
}
29+
}

0 commit comments

Comments
 (0)