Skip to content

Commit ce7308f

Browse files
committed
feat(hr): Phase 115 — Salary Grades & Pay Bands
Implements structured compensation framework with grade levels and salary ranges: migration, SalaryGrade model (isSalaryInRange, salary_range, midpoint accessors), salary_grade_id on employees, SalaryGradePolicy, SalaryGradeController (index/store/show/update/destroy), routes, frontend Index/Show stubs, TypeScript type, Sidebar link, and 10 Pest feature tests. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 14463e0 commit ce7308f

13 files changed

Lines changed: 436 additions & 1 deletion

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\SalaryGrade;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class SalaryGradeController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', SalaryGrade::class);
17+
18+
$grades = SalaryGrade::orderBy('name')
19+
->paginate(20);
20+
21+
return Inertia::render('HR/SalaryGrades/Index', [
22+
'grades' => $grades,
23+
]);
24+
}
25+
26+
public function store(Request $request): RedirectResponse
27+
{
28+
$this->authorize('create', SalaryGrade::class);
29+
30+
$validated = $request->validate([
31+
'name' => 'required|string|max:100',
32+
'code' => 'nullable|string|max:20',
33+
'min_salary' => 'required|numeric|min:0',
34+
'mid_salary' => 'nullable|numeric|min:0',
35+
'max_salary' => 'required|numeric|min:0|gte:min_salary',
36+
'currency' => 'nullable|string|max:3',
37+
'description' => 'nullable|string',
38+
'is_active' => 'boolean',
39+
]);
40+
41+
SalaryGrade::create([
42+
...$validated,
43+
'tenant_id' => auth()->user()->tenant_id,
44+
]);
45+
46+
return back()->with('success', 'Salary grade created.');
47+
}
48+
49+
public function show(SalaryGrade $salaryGrade): Response
50+
{
51+
$this->authorize('view', $salaryGrade);
52+
53+
return Inertia::render('HR/SalaryGrades/Show', [
54+
'grade' => $salaryGrade,
55+
]);
56+
}
57+
58+
public function update(Request $request, SalaryGrade $salaryGrade): RedirectResponse
59+
{
60+
$this->authorize('update', $salaryGrade);
61+
62+
$validated = $request->validate([
63+
'name' => 'required|string|max:100',
64+
'code' => 'nullable|string|max:20',
65+
'min_salary' => 'required|numeric|min:0',
66+
'mid_salary' => 'nullable|numeric|min:0',
67+
'max_salary' => 'required|numeric|min:0|gte:min_salary',
68+
'currency' => 'nullable|string|max:3',
69+
'description' => 'nullable|string',
70+
'is_active' => 'boolean',
71+
]);
72+
73+
$salaryGrade->update($validated);
74+
75+
return back()->with('success', 'Salary grade updated.');
76+
}
77+
78+
public function destroy(SalaryGrade $salaryGrade): RedirectResponse
79+
{
80+
$this->authorize('delete', $salaryGrade);
81+
82+
$salaryGrade->delete();
83+
84+
return redirect()->route('hr.salary-grades.index')
85+
->with('success', 'Salary grade deleted.');
86+
}
87+
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class Employee extends Model
1818
'tenant_id', 'user_id', 'department_id', 'employee_number',
1919
'first_name', 'last_name', 'email', 'phone', 'position',
2020
'employment_type', 'status', 'start_date', 'hire_date', 'end_date',
21-
'salary_type', 'salary_amount',
21+
'salary_type', 'salary_amount', 'salary_grade_id',
2222
];
2323

2424
protected $casts = [
@@ -37,6 +37,11 @@ public function user(): BelongsTo
3737
return $this->belongsTo(User::class);
3838
}
3939

40+
public function salaryGrade(): BelongsTo
41+
{
42+
return $this->belongsTo(SalaryGrade::class);
43+
}
44+
4045
public function leaveRequests(): HasMany
4146
{
4247
return $this->hasMany(LeaveRequest::class);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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 SalaryGrade extends Model
11+
{
12+
use BelongsToTenant;
13+
use SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id', 'name', 'code', 'min_salary', 'mid_salary', 'max_salary',
17+
'currency', 'description', 'is_active',
18+
];
19+
20+
protected $casts = [
21+
'min_salary' => 'float',
22+
'mid_salary' => 'float',
23+
'max_salary' => 'float',
24+
'is_active' => 'boolean',
25+
];
26+
27+
public function employees(): HasMany
28+
{
29+
return $this->hasMany(Employee::class, 'salary_grade_id');
30+
}
31+
32+
public function isSalaryInRange(float $salary): bool
33+
{
34+
return $salary >= $this->min_salary && $salary <= $this->max_salary;
35+
}
36+
37+
public function getSalaryRangeAttribute(): string
38+
{
39+
return number_format($this->min_salary, 0) . ' - ' . number_format($this->max_salary, 0) . ' ' . $this->currency;
40+
}
41+
42+
public function getMidpointAttribute(): float
43+
{
44+
return $this->mid_salary ?? (($this->min_salary + $this->max_salary) / 2);
45+
}
46+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\HR\Models\SalaryGrade;
7+
8+
class SalaryGradePolicy
9+
{
10+
public function viewAny(User $user): bool { return $user->can('hr.view'); }
11+
public function view(User $user, SalaryGrade $salaryGrade): bool { return $user->can('hr.view'); }
12+
public function create(User $user): bool { return $user->can('hr.create'); }
13+
public function update(User $user, SalaryGrade $salaryGrade): bool { return $user->can('hr.create'); }
14+
public function delete(User $user, SalaryGrade $salaryGrade): bool { return $user->can('hr.delete'); }
15+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@
7070
use App\Modules\HR\Policies\EmployeeExitPolicy;
7171
use App\Modules\HR\Models\EmployeePositionChange;
7272
use App\Modules\HR\Policies\PositionChangePolicy;
73+
use App\Modules\HR\Models\SalaryGrade;
74+
use App\Modules\HR\Policies\SalaryGradePolicy;
7375
use Illuminate\Support\Facades\Gate;
7476
use Illuminate\Support\ServiceProvider;
7577

@@ -124,5 +126,6 @@ public function boot(): void
124126
Gate::policy(HrAnnouncement::class, HrAnnouncementPolicy::class);
125127
Gate::policy(EmployeeExit::class, EmployeeExitPolicy::class);
126128
Gate::policy(EmployeePositionChange::class, PositionChangePolicy::class);
129+
Gate::policy(SalaryGrade::class, SalaryGradePolicy::class);
127130
}
128131
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,9 @@
243243
Route::post('position-changes/{positionChange}/approve', [EmployeePositionChangeController::class, 'approve'])->name('position-changes.approve');
244244
Route::resource('position-changes', EmployeePositionChangeController::class)->only(['index', 'store', 'show', 'destroy']);
245245
});
246+
247+
// Salary Grades
248+
use App\Modules\HR\Http\Controllers\SalaryGradeController;
249+
Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () {
250+
Route::resource('salary-grades', SalaryGradeController::class)->except(['create', 'edit']);
251+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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('salary_grades', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('name');
15+
$table->string('code')->nullable();
16+
$table->decimal('min_salary', 15, 2);
17+
$table->decimal('mid_salary', 15, 2)->nullable();
18+
$table->decimal('max_salary', 15, 2);
19+
$table->string('currency', 3)->default('USD');
20+
$table->text('description')->nullable();
21+
$table->boolean('is_active')->default(true);
22+
$table->timestamps();
23+
$table->softDeletes();
24+
$table->unique(['tenant_id', 'name']);
25+
});
26+
}
27+
28+
public function down(): void
29+
{
30+
Schema::dropIfExists('salary_grades');
31+
}
32+
};
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::table('employees', function (Blueprint $table) {
12+
if (! Schema::hasColumn('employees', 'salary_grade_id')) {
13+
$table->unsignedBigInteger('salary_grade_id')->nullable()->after('salary_amount');
14+
}
15+
});
16+
}
17+
18+
public function down(): void
19+
{
20+
Schema::table('employees', function (Blueprint $table) {
21+
if (Schema::hasColumn('employees', 'salary_grade_id')) {
22+
$table->dropColumn('salary_grade_id');
23+
}
24+
});
25+
}
26+
};

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ const navItems: NavItem[] = [
195195
{ label: 'Announcements', href: '/hr/announcements', icon: <span /> },
196196
{ label: 'Exit Management', href: '/hr/employee-exits', icon: <span /> },
197197
{ label: 'Position Changes', href: '/hr/position-changes', icon: <span /> },
198+
{ label: 'Salary Grades', href: '/hr/salary-grades', icon: <span /> },
198199
],
199200
},
200201
{
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Head } from '@inertiajs/react';
2+
import { SalaryGrade } from '@/types/hr';
3+
4+
interface Props {
5+
grades: {
6+
data: SalaryGrade[];
7+
current_page: number;
8+
last_page: number;
9+
};
10+
}
11+
12+
export default function SalaryGradesIndex({ grades }: Props) {
13+
return (
14+
<>
15+
<Head title="Salary Grades" />
16+
<div className="p-6">
17+
<h1 className="text-2xl font-semibold text-slate-900">Salary Grades</h1>
18+
<div className="mt-4">
19+
<table className="w-full text-sm">
20+
<thead>
21+
<tr className="border-b">
22+
<th className="py-2 text-left">Name</th>
23+
<th className="py-2 text-left">Code</th>
24+
<th className="py-2 text-left">Salary Range</th>
25+
<th className="py-2 text-left">Currency</th>
26+
<th className="py-2 text-left">Active</th>
27+
</tr>
28+
</thead>
29+
<tbody>
30+
{grades.data.map((grade) => (
31+
<tr key={grade.id} className="border-b">
32+
<td className="py-2">{grade.name}</td>
33+
<td className="py-2">{grade.code ?? '-'}</td>
34+
<td className="py-2">{grade.salary_range}</td>
35+
<td className="py-2">{grade.currency}</td>
36+
<td className="py-2">{grade.is_active ? 'Yes' : 'No'}</td>
37+
</tr>
38+
))}
39+
</tbody>
40+
</table>
41+
</div>
42+
</div>
43+
</>
44+
);
45+
}

0 commit comments

Comments
 (0)