Skip to content

Commit d8dbf6d

Browse files
committed
feat(hr): Phase 71 — Performance Reviews with KPIs and achievement tracking
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 7d867b3 commit d8dbf6d

13 files changed

Lines changed: 647 additions & 590 deletions

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

Lines changed: 115 additions & 70 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\Employee;
7+
use App\Modules\HR\Models\PerformanceKpi;
78
use App\Modules\HR\Models\PerformanceReview;
89
use Illuminate\Http\RedirectResponse;
910
use Illuminate\Http\Request;
@@ -12,113 +13,157 @@
1213

1314
class PerformanceReviewController extends Controller
1415
{
15-
public function index(): Response
16+
public function index(Request $request): Response
1617
{
1718
$this->authorize('viewAny', PerformanceReview::class);
18-
$reviews = PerformanceReview::with(['employee', 'reviewer'])
19-
->orderByDesc('period_end')
20-
->paginate(25);
21-
return Inertia::render('HR/PerformanceReviews/Index', compact('reviews'));
19+
20+
$query = PerformanceReview::with(['employee', 'reviewer'])
21+
->orderByDesc('review_date');
22+
23+
if ($request->filled('employee_id')) {
24+
$query->where('employee_id', $request->employee_id);
25+
}
26+
27+
if ($request->filled('status')) {
28+
$query->where('status', $request->status);
29+
}
30+
31+
$reviews = $query->paginate(15);
32+
$filters = $request->only(['employee_id', 'status']);
33+
34+
return Inertia::render('HR/PerformanceReviews/Index', compact('reviews', 'filters'));
2235
}
2336

24-
public function create(Request $request): Response
37+
public function create(): Response
2538
{
2639
$this->authorize('create', PerformanceReview::class);
27-
$employees = Employee::where('status', 'active')->orderBy('first_name')->get(['id', 'first_name', 'last_name']);
28-
$employeeId = $request->get('employee_id');
29-
return Inertia::render('HR/PerformanceReviews/Create', compact('employees', 'employeeId'));
40+
41+
$employees = Employee::where('status', 'active')
42+
->orderBy('first_name')
43+
->get(['id', 'first_name', 'last_name']);
44+
45+
return Inertia::render('HR/PerformanceReviews/Create', compact('employees'));
3046
}
3147

3248
public function store(Request $request): RedirectResponse
3349
{
3450
$this->authorize('create', PerformanceReview::class);
51+
3552
$data = $request->validate([
36-
'employee_id' => 'required|exists:employees,id',
37-
'period_start' => 'required|date',
38-
'period_end' => 'required|date|after_or_equal:period_start',
39-
'comments' => 'nullable|string',
40-
'goals' => 'nullable|array',
41-
'goals.*.title' => 'required_with:goals|string',
42-
'goals.*.description' => 'nullable|string',
43-
'competencies' => 'nullable|array',
44-
'competencies.*.name' => 'required_with:competencies|string',
45-
'competencies.*.rating' => 'nullable|integer|min:1|max:5',
46-
'competencies.*.notes' => 'nullable|string',
53+
'employee_id' => 'required|exists:employees,id',
54+
'reviewer_id' => 'nullable|exists:users,id',
55+
'review_period' => 'required|string|max:50',
56+
'review_date' => 'required|date',
57+
'overall_rating' => 'nullable|numeric|min:1|max:5',
58+
'strengths' => 'nullable|string',
59+
'improvements' => 'nullable|string',
60+
'goals' => 'nullable|string',
61+
'reviewer_notes' => 'nullable|string',
4762
]);
4863

4964
$review = PerformanceReview::create([
50-
'tenant_id' => auth()->user()->tenant_id,
51-
'employee_id' => $data['employee_id'],
52-
'reviewer_id' => auth()->id(),
53-
'period_start' => $data['period_start'],
54-
'period_end' => $data['period_end'],
55-
'status' => 'draft',
56-
'comments' => $data['comments'] ?? null,
65+
'tenant_id' => auth()->user()->tenant_id,
66+
'employee_id' => $data['employee_id'],
67+
'reviewer_id' => $data['reviewer_id'] ?? null,
68+
'review_period' => $data['review_period'],
69+
'review_date' => $data['review_date'],
70+
'status' => 'draft',
71+
'overall_rating' => $data['overall_rating'] ?? null,
72+
'strengths' => $data['strengths'] ?? null,
73+
'improvements' => $data['improvements'] ?? null,
74+
'goals' => $data['goals'] ?? null,
75+
'reviewer_notes' => $data['reviewer_notes'] ?? null,
5776
]);
5877

59-
foreach ($data['goals'] ?? [] as $goal) {
60-
$review->goals()->create([
61-
'title' => $goal['title'],
62-
'description' => $goal['description'] ?? null,
63-
]);
64-
}
65-
66-
foreach ($data['competencies'] ?? [] as $comp) {
67-
$review->competencies()->create([
68-
'name' => $comp['name'],
69-
'rating' => $comp['rating'] ?? null,
70-
'notes' => $comp['notes'] ?? null,
71-
]);
72-
}
73-
74-
return redirect()->route('hr.performance-reviews.show', $review)->with('success', 'Review created.');
78+
return redirect()->route('hr.performance-reviews.show', $review);
7579
}
7680

7781
public function show(PerformanceReview $performanceReview): Response
7882
{
7983
$this->authorize('view', $performanceReview);
80-
$performanceReview->load(['employee', 'reviewer', 'goals', 'competencies']);
81-
return Inertia::render('HR/PerformanceReviews/Show', compact('performanceReview'));
84+
85+
$performanceReview->load(['kpis', 'employee', 'reviewer']);
86+
87+
$reviewData = $performanceReview->toArray();
88+
$reviewData['average_kpi_score'] = $performanceReview->average_kpi_score;
89+
90+
return Inertia::render('HR/PerformanceReviews/Show', [
91+
'review' => $reviewData,
92+
]);
93+
}
94+
95+
public function destroy(PerformanceReview $performanceReview): RedirectResponse
96+
{
97+
$this->authorize('delete', $performanceReview);
98+
99+
$performanceReview->delete();
100+
101+
return redirect()->route('hr.performance-reviews.index');
82102
}
83103

84-
public function startReview(PerformanceReview $performanceReview): RedirectResponse
104+
public function submit(PerformanceReview $performanceReview): RedirectResponse
85105
{
86106
$this->authorize('update', $performanceReview);
87-
abort_unless($performanceReview->status === 'draft', 422, 'Only draft reviews can be started.');
88-
$performanceReview->update(['status' => 'in_review']);
89-
return back()->with('success', 'Review started.');
107+
108+
$performanceReview->submit();
109+
110+
return back();
90111
}
91112

92-
public function complete(PerformanceReview $performanceReview, Request $request): RedirectResponse
113+
public function acknowledge(PerformanceReview $performanceReview): RedirectResponse
93114
{
94115
$this->authorize('update', $performanceReview);
95-
abort_unless($performanceReview->status === 'in_review', 422, 'Only in-review reviews can be completed.');
96-
$request->validate(['overall_rating' => 'required|integer|min:1|max:5']);
97-
$performanceReview->update([
98-
'status' => 'completed',
99-
'overall_rating' => $request->overall_rating,
100-
'completed_at' => now(),
101-
]);
102-
return back()->with('success', 'Review completed.');
116+
117+
$performanceReview->acknowledge();
118+
119+
return back();
103120
}
104121

105-
public function updateGoal(PerformanceReview $performanceReview, Request $request, int $goalId): RedirectResponse
122+
public function addKpi(Request $request, PerformanceReview $performanceReview): RedirectResponse
106123
{
107124
$this->authorize('update', $performanceReview);
108-
$goal = $performanceReview->goals()->findOrFail($goalId);
109-
$request->validate([
110-
'achieved' => 'boolean',
111-
'achievement_notes' => 'nullable|string',
125+
126+
$data = $request->validate([
127+
'name' => 'required|string',
128+
'target_score' => 'required|numeric|min:0.01',
129+
'actual_score' => 'required|numeric|min:0',
130+
'weight' => 'nullable|numeric|min:0',
131+
'notes' => 'nullable|string',
112132
]);
113-
$goal->update($request->only('achieved', 'achievement_notes'));
114-
return back()->with('success', 'Goal updated.');
133+
134+
$performanceReview->kpis()->create([
135+
'tenant_id' => $performanceReview->tenant_id,
136+
'performance_review_id' => $performanceReview->id,
137+
'name' => $data['name'],
138+
'target_score' => $data['target_score'],
139+
'actual_score' => $data['actual_score'],
140+
'weight' => $data['weight'] ?? 1,
141+
'notes' => $data['notes'] ?? null,
142+
]);
143+
144+
return back()->with('success', 'KPI added.');
115145
}
116146

117-
public function destroy(PerformanceReview $performanceReview): RedirectResponse
147+
public function removeKpi(PerformanceReview $performanceReview, PerformanceKpi $kpi): RedirectResponse
118148
{
119-
$this->authorize('delete', $performanceReview);
120-
abort_unless($performanceReview->status === 'draft', 422, 'Only draft reviews can be deleted.');
121-
$performanceReview->delete();
122-
return redirect()->route('hr.performance-reviews.index')->with('success', 'Review deleted.');
149+
$this->authorize('update', $performanceReview);
150+
151+
$kpi->delete();
152+
153+
return back();
154+
}
155+
156+
public function updateKpi(Request $request, PerformanceReview $performanceReview, PerformanceKpi $kpi): RedirectResponse
157+
{
158+
$this->authorize('update', $performanceReview);
159+
160+
$data = $request->validate([
161+
'actual_score' => 'required|numeric|min:0',
162+
'notes' => 'nullable|string',
163+
]);
164+
165+
$kpi->update($data);
166+
167+
return back();
123168
}
124169
}
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 PerformanceKpi extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $table = 'performance_kpis';
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'performance_review_id',
18+
'name',
19+
'description',
20+
'target_score',
21+
'actual_score',
22+
'weight',
23+
'notes',
24+
];
25+
26+
protected $casts = [
27+
'target_score' => 'decimal:2',
28+
'actual_score' => 'decimal:2',
29+
'weight' => 'decimal:2',
30+
];
31+
32+
public function review(): BelongsTo
33+
{
34+
return $this->belongsTo(PerformanceReview::class, 'performance_review_id');
35+
}
36+
37+
public function getAchievementPercentAttribute(): ?float
38+
{
39+
if ($this->target_score <= 0) {
40+
return null;
41+
}
42+
43+
return round($this->actual_score / $this->target_score * 100, 1);
44+
}
45+
}

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

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,26 @@ class PerformanceReview extends Model
1313
{
1414
use BelongsToTenant, SoftDeletes;
1515

16+
protected $table = 'performance_reviews';
17+
1618
protected $fillable = [
17-
'tenant_id', 'employee_id', 'reviewer_id', 'period_start', 'period_end',
18-
'status', 'overall_rating', 'comments', 'completed_at',
19+
'tenant_id',
20+
'employee_id',
21+
'reviewer_id',
22+
'review_period',
23+
'review_date',
24+
'status',
25+
'overall_rating',
26+
'strengths',
27+
'improvements',
28+
'goals',
29+
'reviewer_notes',
1930
];
2031

2132
protected $casts = [
22-
'period_start' => 'date',
23-
'period_end' => 'date',
24-
'completed_at' => 'datetime',
33+
'review_date' => 'date',
34+
'overall_rating' => 'decimal:1',
35+
'status' => 'string',
2536
];
2637

2738
public function employee(): BelongsTo
@@ -34,22 +45,33 @@ public function reviewer(): BelongsTo
3445
return $this->belongsTo(User::class, 'reviewer_id');
3546
}
3647

37-
public function goals(): HasMany
48+
public function kpis(): HasMany
49+
{
50+
return $this->hasMany(PerformanceKpi::class);
51+
}
52+
53+
public function submit(): void
3854
{
39-
return $this->hasMany(PerformanceReviewGoal::class);
55+
$this->status = 'submitted';
56+
$this->save();
4057
}
4158

42-
public function competencies(): HasMany
59+
public function acknowledge(): void
4360
{
44-
return $this->hasMany(PerformanceReviewCompetency::class);
61+
$this->status = 'acknowledged';
62+
$this->save();
4563
}
4664

47-
public function getAverageCompetencyRatingAttribute(): ?float
65+
public function getAverageKpiScoreAttribute(): ?float
4866
{
49-
$ratings = $this->competencies->whereNotNull('rating')->pluck('rating');
50-
if ($ratings->isEmpty()) {
67+
$kpis = $this->kpis->filter(fn ($kpi) => $kpi->target_score > 0);
68+
69+
if ($kpis->isEmpty()) {
5170
return null;
5271
}
53-
return round($ratings->avg(), 1);
72+
73+
$total = $kpis->sum(fn ($kpi) => ($kpi->actual_score / $kpi->target_score) * 100);
74+
75+
return round($total / $kpis->count(), 1);
5476
}
5577
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use App\Modules\HR\Models\LoanRepayment;
1717
use App\Modules\HR\Models\OnboardingTemplate;
1818
use App\Modules\HR\Models\PayrollRun;
19+
use App\Modules\HR\Models\PerformanceKpi;
1920
use App\Modules\HR\Models\PerformanceReview;
2021
use App\Modules\HR\Models\ShiftAssignment;
2122
use App\Modules\HR\Models\ShiftTemplate;
@@ -59,6 +60,7 @@ public function boot(): void
5960
Gate::policy(LoanRepayment::class, LoanPolicy::class);
6061
Gate::policy(OnboardingTemplate::class, OnboardingTemplatePolicy::class);
6162
Gate::policy(PayrollRun::class, PayrollRunPolicy::class);
63+
Gate::policy(PerformanceKpi::class, PerformanceReviewPolicy::class);
6264
Gate::policy(PerformanceReview::class, PerformanceReviewPolicy::class);
6365
Gate::policy(TrainingCourse::class, TrainingPolicy::class);
6466
Gate::policy(EmployeeTrainingRecord::class, TrainingPolicy::class);

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,12 @@
7878
});
7979

8080
// Performance Reviews
81+
Route::post('performance-reviews/{performanceReview}/submit', [PerformanceReviewController::class, 'submit'])->name('performance-reviews.submit');
82+
Route::post('performance-reviews/{performanceReview}/acknowledge', [PerformanceReviewController::class, 'acknowledge'])->name('performance-reviews.acknowledge');
83+
Route::post('performance-reviews/{performanceReview}/kpis', [PerformanceReviewController::class, 'addKpi'])->name('performance-reviews.kpis.add');
84+
Route::patch('performance-reviews/{performanceReview}/kpis/{kpi}', [PerformanceReviewController::class, 'updateKpi'])->name('performance-reviews.kpis.update');
85+
Route::delete('performance-reviews/{performanceReview}/kpis/{kpi}', [PerformanceReviewController::class, 'removeKpi'])->name('performance-reviews.kpis.remove');
8186
Route::resource('performance-reviews', PerformanceReviewController::class)->except(['edit', 'update']);
82-
Route::post('performance-reviews/{performanceReview}/start', [PerformanceReviewController::class, 'startReview'])->name('performance-reviews.start');
83-
Route::post('performance-reviews/{performanceReview}/complete', [PerformanceReviewController::class, 'complete'])->name('performance-reviews.complete');
84-
Route::patch('performance-reviews/{performanceReview}/goals/{goal}', [PerformanceReviewController::class, 'updateGoal'])->name('performance-reviews.goals.update');
8587

8688
// Expense Claims — custom actions BEFORE resource
8789
Route::post('expense-claims/{expenseClaim}/submit', [ExpenseClaimController::class, 'submit'])->name('expense-claims.submit');

0 commit comments

Comments
 (0)