Skip to content

Commit 64c0463

Browse files
committed
feat(hr): Phase 112 — Employee Position Changes & Promotions
Implements audit trail of promotions, transfers, salary changes with full CRUD, approve action, policy, migration, 10 Pest tests (all pass, total 1160), TypeScript types, and sidebar link. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent dc5dcb2 commit 64c0463

11 files changed

Lines changed: 582 additions & 0 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\EmployeePositionChange;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class EmployeePositionChangeController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', EmployeePositionChange::class);
17+
18+
$changes = EmployeePositionChange::with(['employee', 'fromDepartment', 'toDepartment'])
19+
->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id))
20+
->orderBy('effective_date', 'desc')
21+
->paginate(20)
22+
->withQueryString();
23+
24+
return Inertia::render('HR/PositionChanges/Index', [
25+
'changes' => $changes,
26+
'filters' => $request->only(['employee_id']),
27+
]);
28+
}
29+
30+
public function store(Request $request): RedirectResponse
31+
{
32+
$this->authorize('create', EmployeePositionChange::class);
33+
34+
$validated = $request->validate([
35+
'employee_id' => 'required|exists:employees,id',
36+
'change_type' => 'required|string|in:promotion,demotion,transfer,salary_change,title_change,department_change',
37+
'effective_date' => 'required|date',
38+
'from_title' => 'nullable|string|max:255',
39+
'to_title' => 'nullable|string|max:255',
40+
'from_department_id' => 'nullable|exists:departments,id',
41+
'to_department_id' => 'nullable|exists:departments,id',
42+
'from_salary' => 'nullable|numeric|min:0',
43+
'to_salary' => 'nullable|numeric|min:0',
44+
'reason' => 'nullable|string',
45+
'notes' => 'nullable|string',
46+
]);
47+
48+
$change = EmployeePositionChange::create([
49+
'tenant_id' => auth()->user()->tenant_id,
50+
...$validated,
51+
]);
52+
53+
return redirect()->route('hr.position-changes.show', $change);
54+
}
55+
56+
public function show(EmployeePositionChange $positionChange): Response
57+
{
58+
$this->authorize('view', $positionChange);
59+
60+
$positionChange->load(['employee', 'fromDepartment', 'toDepartment', 'approvedBy']);
61+
62+
return Inertia::render('HR/PositionChanges/Show', [
63+
'change' => $positionChange,
64+
]);
65+
}
66+
67+
public function approve(EmployeePositionChange $positionChange): RedirectResponse
68+
{
69+
$this->authorize('update', $positionChange);
70+
71+
$positionChange->approve(auth()->id());
72+
73+
return redirect()->back()->with('success', 'Position change approved successfully.');
74+
}
75+
76+
public function destroy(EmployeePositionChange $positionChange): RedirectResponse
77+
{
78+
$this->authorize('delete', $positionChange);
79+
80+
$positionChange->delete();
81+
82+
return redirect()->route('hr.position-changes.index');
83+
}
84+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
10+
class EmployeePositionChange extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $fillable = [
15+
'tenant_id', 'employee_id', 'change_type',
16+
'from_title', 'to_title',
17+
'from_department_id', 'to_department_id',
18+
'from_salary', 'to_salary',
19+
'effective_date', 'reason', 'approved_by', 'approved_at', 'notes',
20+
];
21+
22+
protected $casts = [
23+
'effective_date' => 'date',
24+
'from_salary' => 'float',
25+
'to_salary' => 'float',
26+
'approved_at' => 'datetime',
27+
];
28+
29+
public function employee(): BelongsTo
30+
{
31+
return $this->belongsTo(Employee::class);
32+
}
33+
34+
public function fromDepartment(): BelongsTo
35+
{
36+
return $this->belongsTo(Department::class, 'from_department_id');
37+
}
38+
39+
public function toDepartment(): BelongsTo
40+
{
41+
return $this->belongsTo(Department::class, 'to_department_id');
42+
}
43+
44+
public function approvedBy(): BelongsTo
45+
{
46+
return $this->belongsTo(User::class, 'approved_by');
47+
}
48+
49+
public function approve(int $userId): void
50+
{
51+
$this->approved_by = $userId;
52+
$this->approved_at = now();
53+
$this->save();
54+
}
55+
56+
public function getSalaryChangeAttribute(): float
57+
{
58+
if ($this->from_salary === null || $this->to_salary === null) {
59+
return 0.0;
60+
}
61+
return $this->to_salary - $this->from_salary;
62+
}
63+
64+
public function getIsApprovedAttribute(): bool
65+
{
66+
return $this->approved_by !== null;
67+
}
68+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Policies;
4+
5+
use App\Models\User;
6+
7+
class PositionChangePolicy
8+
{
9+
public function viewAny(User $user): bool
10+
{
11+
return $user->hasPermissionTo('hr.view');
12+
}
13+
14+
public function view(User $user, $model): bool
15+
{
16+
return $user->hasPermissionTo('hr.view');
17+
}
18+
19+
public function create(User $user): bool
20+
{
21+
return $user->hasPermissionTo('hr.create');
22+
}
23+
24+
public function update(User $user, $model): bool
25+
{
26+
return $user->hasPermissionTo('hr.create');
27+
}
28+
29+
public function delete(User $user, $model): bool
30+
{
31+
return $user->hasPermissionTo('hr.delete');
32+
}
33+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@
6868
use App\Modules\HR\Policies\HrAnnouncementPolicy;
6969
use App\Modules\HR\Models\EmployeeExit;
7070
use App\Modules\HR\Policies\EmployeeExitPolicy;
71+
use App\Modules\HR\Models\EmployeePositionChange;
72+
use App\Modules\HR\Policies\PositionChangePolicy;
7173
use Illuminate\Support\Facades\Gate;
7274
use Illuminate\Support\ServiceProvider;
7375

@@ -121,5 +123,6 @@ public function boot(): void
121123
Gate::policy(EmployeeSkill::class, EmployeeSkillPolicy::class);
122124
Gate::policy(HrAnnouncement::class, HrAnnouncementPolicy::class);
123125
Gate::policy(EmployeeExit::class, EmployeeExitPolicy::class);
126+
Gate::policy(EmployeePositionChange::class, PositionChangePolicy::class);
124127
}
125128
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,10 @@
236236
Route::post('employee-exits/{employeeExit}/in-progress', [EmployeeExitController::class, 'markInProgress'])->name('employee-exits.in-progress');
237237
Route::resource('employee-exits', EmployeeExitController::class)->only(['index', 'store', 'show', 'destroy']);
238238
});
239+
240+
// Employee Position Changes
241+
use App\Modules\HR\Http\Controllers\EmployeePositionChangeController;
242+
Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () {
243+
Route::post('position-changes/{positionChange}/approve', [EmployeePositionChangeController::class, 'approve'])->name('position-changes.approve');
244+
Route::resource('position-changes', EmployeePositionChangeController::class)->only(['index', 'store', 'show', 'destroy']);
245+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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('employee_position_changes', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->unsignedBigInteger('employee_id');
15+
$table->string('change_type'); // promotion, demotion, transfer, salary_change, title_change, department_change
16+
$table->string('from_title')->nullable();
17+
$table->string('to_title')->nullable();
18+
$table->unsignedBigInteger('from_department_id')->nullable();
19+
$table->unsignedBigInteger('to_department_id')->nullable();
20+
$table->decimal('from_salary', 15, 2)->nullable();
21+
$table->decimal('to_salary', 15, 2)->nullable();
22+
$table->date('effective_date');
23+
$table->text('reason')->nullable();
24+
$table->unsignedBigInteger('approved_by')->nullable();
25+
$table->timestamp('approved_at')->nullable();
26+
$table->text('notes')->nullable();
27+
$table->timestamps();
28+
});
29+
}
30+
31+
public function down(): void
32+
{
33+
Schema::dropIfExists('employee_position_changes');
34+
}
35+
};

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ const navItems: NavItem[] = [
192192
{ label: 'Skill Definitions', href: '/hr/skill-definitions', icon: <span /> },
193193
{ label: 'Announcements', href: '/hr/announcements', icon: <span /> },
194194
{ label: 'Exit Management', href: '/hr/employee-exits', icon: <span /> },
195+
{ label: 'Position Changes', href: '/hr/position-changes', icon: <span /> },
195196
],
196197
},
197198
{
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Head, Link } from '@inertiajs/react';
2+
import AppLayout from '@/Layouts/AppLayout';
3+
import { Table } from '@/Components/Common/Table';
4+
import { Pagination } from '@/Components/Inventory/Pagination';
5+
import type { PageProps } from '@/types';
6+
import type { Paginator } from '@/types/inventory';
7+
import type { EmployeePositionChange } from '@/types/hr';
8+
9+
interface Props extends PageProps {
10+
changes: Paginator<EmployeePositionChange>;
11+
filters: { employee_id?: number };
12+
}
13+
14+
export default function PositionChangesIndex({ changes, filters }: Props) {
15+
return (
16+
<AppLayout>
17+
<Head title="Position Changes" />
18+
<div className="space-y-6">
19+
<div className="flex items-center justify-between">
20+
<div>
21+
<h1 className="text-2xl font-semibold text-slate-900">Position Changes</h1>
22+
<p className="text-sm text-slate-500 mt-1">{changes.total} records</p>
23+
</div>
24+
</div>
25+
26+
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
27+
<Table
28+
columns={[
29+
{
30+
key: 'employee',
31+
header: 'Employee',
32+
render: (r) => (
33+
<Link href={`/hr/position-changes/${r.id}`} className="font-medium text-slate-900 hover:text-indigo-600">
34+
{r.employee ? `${r.employee.first_name} ${r.employee.last_name}` : '—'}
35+
</Link>
36+
),
37+
},
38+
{
39+
key: 'change_type',
40+
header: 'Change Type',
41+
render: (r) => (
42+
<span className="capitalize text-sm text-slate-700">{r.change_type.replace('_', ' ')}</span>
43+
),
44+
},
45+
{
46+
key: 'effective_date',
47+
header: 'Effective Date',
48+
render: (r) => <span className="text-sm text-slate-700">{r.effective_date}</span>,
49+
},
50+
{
51+
key: 'is_approved',
52+
header: 'Approved',
53+
render: (r) => (
54+
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${r.is_approved ? 'bg-green-100 text-green-800' : 'bg-amber-100 text-amber-800'}`}>
55+
{r.is_approved ? 'Approved' : 'Pending'}
56+
</span>
57+
),
58+
},
59+
{
60+
key: 'actions',
61+
header: '',
62+
render: (r) => (
63+
<Link href={`/hr/position-changes/${r.id}`} className="text-sm text-indigo-600 hover:text-indigo-800">
64+
View
65+
</Link>
66+
),
67+
},
68+
]}
69+
data={changes.data}
70+
emptyMessage="No position changes found."
71+
/>
72+
<Pagination paginator={changes} />
73+
</div>
74+
</div>
75+
</AppLayout>
76+
);
77+
}

0 commit comments

Comments
 (0)