Skip to content

Commit 04a8a31

Browse files
committed
feat: Phase 46 — Performance Reviews with ratings and goals
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 3d9073c commit 04a8a31

16 files changed

Lines changed: 1217 additions & 4 deletions
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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\PerformanceReview;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class PerformanceReviewController extends Controller
14+
{
15+
public function index(): Response
16+
{
17+
$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'));
22+
}
23+
24+
public function create(Request $request): Response
25+
{
26+
$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'));
30+
}
31+
32+
public function store(Request $request): RedirectResponse
33+
{
34+
$this->authorize('create', PerformanceReview::class);
35+
$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',
47+
]);
48+
49+
$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,
57+
]);
58+
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.');
75+
}
76+
77+
public function show(PerformanceReview $performanceReview): Response
78+
{
79+
$this->authorize('view', $performanceReview);
80+
$performanceReview->load(['employee', 'reviewer', 'goals', 'competencies']);
81+
return Inertia::render('HR/PerformanceReviews/Show', compact('performanceReview'));
82+
}
83+
84+
public function startReview(PerformanceReview $performanceReview): RedirectResponse
85+
{
86+
$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.');
90+
}
91+
92+
public function complete(PerformanceReview $performanceReview, Request $request): RedirectResponse
93+
{
94+
$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.');
103+
}
104+
105+
public function updateGoal(PerformanceReview $performanceReview, Request $request, int $goalId): RedirectResponse
106+
{
107+
$this->authorize('update', $performanceReview);
108+
$goal = $performanceReview->goals()->findOrFail($goalId);
109+
$request->validate([
110+
'achieved' => 'boolean',
111+
'achievement_notes' => 'nullable|string',
112+
]);
113+
$goal->update($request->only('achieved', 'achievement_notes'));
114+
return back()->with('success', 'Goal updated.');
115+
}
116+
117+
public function destroy(PerformanceReview $performanceReview): RedirectResponse
118+
{
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.');
123+
}
124+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
use App\Models\User;
11+
12+
class PerformanceReview extends Model
13+
{
14+
use BelongsToTenant, SoftDeletes;
15+
16+
protected $fillable = [
17+
'tenant_id', 'employee_id', 'reviewer_id', 'period_start', 'period_end',
18+
'status', 'overall_rating', 'comments', 'completed_at',
19+
];
20+
21+
protected $casts = [
22+
'period_start' => 'date',
23+
'period_end' => 'date',
24+
'completed_at' => 'datetime',
25+
];
26+
27+
public function employee(): BelongsTo
28+
{
29+
return $this->belongsTo(Employee::class);
30+
}
31+
32+
public function reviewer(): BelongsTo
33+
{
34+
return $this->belongsTo(User::class, 'reviewer_id');
35+
}
36+
37+
public function goals(): HasMany
38+
{
39+
return $this->hasMany(PerformanceReviewGoal::class);
40+
}
41+
42+
public function competencies(): HasMany
43+
{
44+
return $this->hasMany(PerformanceReviewCompetency::class);
45+
}
46+
47+
public function getAverageCompetencyRatingAttribute(): ?float
48+
{
49+
$ratings = $this->competencies->whereNotNull('rating')->pluck('rating');
50+
if ($ratings->isEmpty()) {
51+
return null;
52+
}
53+
return round($ratings->avg(), 1);
54+
}
55+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
8+
class PerformanceReviewCompetency extends Model
9+
{
10+
protected $fillable = ['performance_review_id', 'name', 'rating', 'notes'];
11+
12+
public function review(): BelongsTo
13+
{
14+
return $this->belongsTo(PerformanceReview::class, 'performance_review_id');
15+
}
16+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
8+
class PerformanceReviewGoal extends Model
9+
{
10+
protected $fillable = ['performance_review_id', 'title', 'description', 'achieved', 'achievement_notes'];
11+
12+
protected $casts = ['achieved' => 'boolean'];
13+
14+
public function review(): BelongsTo
15+
{
16+
return $this->belongsTo(PerformanceReview::class, 'performance_review_id');
17+
}
18+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\HR\Models\PerformanceReview;
7+
8+
class PerformanceReviewPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('hr.view');
13+
}
14+
15+
public function view(User $user, PerformanceReview $performanceReview): bool
16+
{
17+
return $user->can('hr.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->can('hr.create');
23+
}
24+
25+
public function update(User $user, PerformanceReview $performanceReview): bool
26+
{
27+
return $user->can('hr.update');
28+
}
29+
30+
public function delete(User $user, PerformanceReview $performanceReview): bool
31+
{
32+
return $user->can('hr.delete');
33+
}
34+
}

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
use App\Modules\HR\Models\LeaveRequest;
1010
use App\Modules\HR\Models\OnboardingTemplate;
1111
use App\Modules\HR\Models\PayrollRun;
12+
use App\Modules\HR\Models\PerformanceReview;
1213
use App\Modules\HR\Policies\DepartmentPolicy;
1314
use App\Modules\HR\Policies\EmployeeOnboardingPolicy;
1415
use App\Modules\HR\Policies\EmployeePolicy;
1516
use App\Modules\HR\Policies\ExpenseClaimPolicy;
1617
use App\Modules\HR\Policies\LeaveRequestPolicy;
1718
use App\Modules\HR\Policies\OnboardingTemplatePolicy;
1819
use App\Modules\HR\Policies\PayrollRunPolicy;
20+
use App\Modules\HR\Policies\PerformanceReviewPolicy;
1921
use Illuminate\Support\Facades\Gate;
2022
use Illuminate\Support\ServiceProvider;
2123

@@ -32,7 +34,8 @@ public function boot(): void
3234
Gate::policy(EmployeeOnboarding::class, EmployeeOnboardingPolicy::class);
3335
Gate::policy(ExpenseClaim::class, ExpenseClaimPolicy::class);
3436
Gate::policy(LeaveRequest::class, LeaveRequestPolicy::class);
35-
Gate::policy(OnboardingTemplate::class, OnboardingTemplatePolicy::class);
36-
Gate::policy(PayrollRun::class, PayrollRunPolicy::class);
37+
Gate::policy(OnboardingTemplate::class, OnboardingTemplatePolicy::class);
38+
Gate::policy(PayrollRun::class, PayrollRunPolicy::class);
39+
Gate::policy(PerformanceReview::class, PerformanceReviewPolicy::class);
3740
}
3841
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use App\Modules\HR\Http\Controllers\OnboardingTemplateController;
99
use App\Modules\HR\Http\Controllers\PayrollController;
1010
use App\Modules\HR\Http\Controllers\PayrollRunController;
11+
use App\Modules\HR\Http\Controllers\PerformanceReviewController;
1112
use Illuminate\Support\Facades\Route;
1213

1314
Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () {
@@ -66,6 +67,12 @@
6667
Route::post('onboardings/{onboarding}/tasks/{task}/uncomplete', [EmployeeOnboardingController::class, 'uncompleteTask'])->name('onboardings.tasks.uncomplete');
6768
});
6869

70+
// Performance Reviews
71+
Route::resource('performance-reviews', PerformanceReviewController::class)->except(['edit', 'update']);
72+
Route::post('performance-reviews/{performanceReview}/start', [PerformanceReviewController::class, 'startReview'])->name('performance-reviews.start');
73+
Route::post('performance-reviews/{performanceReview}/complete', [PerformanceReviewController::class, 'complete'])->name('performance-reviews.complete');
74+
Route::patch('performance-reviews/{performanceReview}/goals/{goal}', [PerformanceReviewController::class, 'updateGoal'])->name('performance-reviews.goals.update');
75+
6976
// Expense Claims
7077
Route::post('expense-claims/{expenseClaim}/submit', [ExpenseClaimController::class, 'submit'])->name('expense-claims.submit');
7178
Route::post('expense-claims/{expenseClaim}/approve', [ExpenseClaimController::class, 'approve'])->name('expense-claims.approve');
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('performance_reviews', 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('reviewer_id')->nullable()->constrained('users')->nullOnDelete();
16+
$table->date('period_start');
17+
$table->date('period_end');
18+
$table->enum('status', ['draft', 'in_review', 'completed'])->default('draft');
19+
$table->tinyInteger('overall_rating')->unsigned()->nullable();
20+
$table->text('comments')->nullable();
21+
$table->timestamp('completed_at')->nullable();
22+
$table->timestamps();
23+
$table->softDeletes();
24+
});
25+
}
26+
27+
public function down(): void
28+
{
29+
Schema::dropIfExists('performance_reviews');
30+
}
31+
};
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('performance_review_goals', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('performance_review_id')->constrained('performance_reviews')->cascadeOnDelete();
14+
$table->string('title');
15+
$table->text('description')->nullable();
16+
$table->boolean('achieved')->default(false);
17+
$table->text('achievement_notes')->nullable();
18+
$table->timestamps();
19+
});
20+
}
21+
22+
public function down(): void
23+
{
24+
Schema::dropIfExists('performance_review_goals');
25+
}
26+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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('performance_review_competencies', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('performance_review_id')->constrained('performance_reviews')->cascadeOnDelete();
14+
$table->string('name');
15+
$table->tinyInteger('rating')->unsigned()->nullable();
16+
$table->text('notes')->nullable();
17+
$table->timestamps();
18+
});
19+
}
20+
21+
public function down(): void
22+
{
23+
Schema::dropIfExists('performance_review_competencies');
24+
}
25+
};

0 commit comments

Comments
 (0)