Skip to content

Commit a71f322

Browse files
committed
feat(hr): Phase 124 — HR Flexible Working Arrangements
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 555a749 commit a71f322

9 files changed

Lines changed: 353 additions & 0 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\FlexibleWorkArrangement;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class FlexibleWorkController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', FlexibleWorkArrangement::class);
17+
18+
$arrangements = FlexibleWorkArrangement::with('employee')
19+
->orderByDesc('created_at')
20+
->paginate(20);
21+
22+
return Inertia::render('HR/FlexibleWork/Index', compact('arrangements'));
23+
}
24+
25+
public function store(Request $request): RedirectResponse
26+
{
27+
$this->authorize('create', FlexibleWorkArrangement::class);
28+
29+
$data = $request->validate([
30+
'employee_id' => ['required', 'exists:employees,id'],
31+
'arrangement_type' => ['required', 'string', 'max:50'],
32+
'start_date' => ['required', 'date'],
33+
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
34+
'hours_per_week' => ['nullable', 'integer', 'min:1', 'max:168'],
35+
'description' => ['nullable', 'string'],
36+
]);
37+
38+
FlexibleWorkArrangement::create([
39+
'tenant_id' => auth()->user()->tenant_id,
40+
...$data,
41+
]);
42+
43+
return redirect()->route('hr.flexible-work.index')->with('success', 'Flexible work arrangement created.');
44+
}
45+
46+
public function show(FlexibleWorkArrangement $flexibleWork): Response
47+
{
48+
$this->authorize('view', $flexibleWork);
49+
50+
$flexibleWork->load('employee');
51+
52+
return Inertia::render('HR/FlexibleWork/Show', compact('flexibleWork'));
53+
}
54+
55+
public function approve(FlexibleWorkArrangement $flexibleWork): RedirectResponse
56+
{
57+
$this->authorize('update', $flexibleWork);
58+
59+
$flexibleWork->approve(auth()->id());
60+
61+
return back()->with('success', 'Arrangement approved.');
62+
}
63+
64+
public function reject(Request $request, FlexibleWorkArrangement $flexibleWork): RedirectResponse
65+
{
66+
$this->authorize('update', $flexibleWork);
67+
68+
$request->validate([
69+
'reason' => ['required', 'string'],
70+
]);
71+
72+
$flexibleWork->reject($request->reason);
73+
74+
return back()->with('success', 'Arrangement rejected.');
75+
}
76+
77+
public function destroy(FlexibleWorkArrangement $flexibleWork): RedirectResponse
78+
{
79+
$this->authorize('delete', $flexibleWork);
80+
81+
$flexibleWork->delete();
82+
83+
return back()->with('success', 'Arrangement deleted.');
84+
}
85+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
use Illuminate\Database\Eloquent\SoftDeletes;
9+
10+
class FlexibleWorkArrangement extends Model
11+
{
12+
use BelongsToTenant;
13+
use SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'employee_id',
18+
'arrangement_type',
19+
'start_date',
20+
'end_date',
21+
'hours_per_week',
22+
'description',
23+
'status',
24+
'approved_by',
25+
'approved_at',
26+
'rejection_reason',
27+
];
28+
29+
protected $casts = [
30+
'start_date' => 'date',
31+
'end_date' => 'date',
32+
'approved_at' => 'datetime',
33+
];
34+
35+
protected $attributes = ['status' => 'pending'];
36+
37+
// ── Relationships ─────────────────────────────────────────────────────────
38+
39+
public function employee(): BelongsTo
40+
{
41+
return $this->belongsTo(Employee::class);
42+
}
43+
44+
// ── Actions ───────────────────────────────────────────────────────────────
45+
46+
public function approve(int $userId): void
47+
{
48+
$this->update([
49+
'status' => 'approved',
50+
'approved_by' => $userId,
51+
'approved_at' => now(),
52+
]);
53+
}
54+
55+
public function reject(string $reason): void
56+
{
57+
$this->update([
58+
'status' => 'rejected',
59+
'rejection_reason' => $reason,
60+
]);
61+
}
62+
63+
public function expire(): void
64+
{
65+
$this->update(['status' => 'expired']);
66+
}
67+
68+
// ── Accessors ─────────────────────────────────────────────────────────────
69+
70+
public function getIsPendingAttribute(): bool
71+
{
72+
return $this->status === 'pending';
73+
}
74+
75+
public function getIsActiveAttribute(): bool
76+
{
77+
return $this->status === 'approved'
78+
&& ($this->end_date === null || $this->end_date->gte(now()->startOfDay()));
79+
}
80+
}
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 FlexibleWorkPolicy
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
@@ -75,6 +75,8 @@
7575
use App\Modules\HR\Models\SalaryGrade;
7676
use App\Modules\HR\Policies\SalaryGradePolicy;
7777
use App\Modules\HR\Models\EmployeeSurvey;
78+
use App\Modules\HR\Models\FlexibleWorkArrangement;
79+
use App\Modules\HR\Policies\FlexibleWorkPolicy;
7880
use App\Modules\HR\Policies\EmployeeSurveyPolicy;
7981
use Illuminate\Support\Facades\Gate;
8082
use Illuminate\Support\ServiceProvider;
@@ -133,5 +135,6 @@ public function boot(): void
133135
Gate::policy(SalaryGrade::class, SalaryGradePolicy::class);
134136
Gate::policy(OvertimeRequest::class, OvertimeRequestPolicy::class);
135137
Gate::policy(EmployeeSurvey::class, EmployeeSurveyPolicy::class);
138+
Gate::policy(FlexibleWorkArrangement::class, FlexibleWorkPolicy::class);
136139
}
137140
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,3 +267,12 @@
267267
Route::post('surveys/{survey}/respond', [EmployeeSurveyController::class, 'respond'])->name('surveys.respond');
268268
Route::resource('surveys', EmployeeSurveyController::class)->only(['index', 'store', 'show', 'destroy']);
269269
});
270+
271+
272+
// Flexible Work Arrangements
273+
use App\Modules\HR\Http\Controllers\FlexibleWorkController;
274+
Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () {
275+
Route::post('flexible-work/{flexibleWork}/approve', [FlexibleWorkController::class, 'approve'])->name('flexible-work.approve');
276+
Route::post('flexible-work/{flexibleWork}/reject', [FlexibleWorkController::class, 'reject'])->name('flexible-work.reject');
277+
Route::resource('flexible-work', FlexibleWorkController::class)->only(['index', 'store', 'show', 'destroy']);
278+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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('flexible_work_arrangements', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->unsignedBigInteger('employee_id');
15+
$table->string('arrangement_type'); // remote/hybrid/compressed_hours/part_time/flexible_hours
16+
$table->date('start_date');
17+
$table->date('end_date')->nullable();
18+
$table->integer('hours_per_week')->nullable();
19+
$table->text('description')->nullable();
20+
$table->string('status')->default('pending'); // pending/approved/rejected/expired
21+
$table->unsignedBigInteger('approved_by')->nullable();
22+
$table->timestamp('approved_at')->nullable();
23+
$table->text('rejection_reason')->nullable();
24+
$table->timestamps();
25+
$table->softDeletes();
26+
});
27+
}
28+
29+
public function down(): void
30+
{
31+
Schema::dropIfExists('flexible_work_arrangements');
32+
}
33+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default function Index() { return <div>FlexibleWork Index</div>; }
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default function Show() { return <div>FlexibleWork Show</div>; }
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\HR\Models\Employee;
6+
use App\Modules\HR\Models\FlexibleWorkArrangement;
7+
use Database\Seeders\RolePermissionSeeder;
8+
9+
beforeEach(function () {
10+
$this->seed(RolePermissionSeeder::class);
11+
$this->tenant = Tenant::create(['name' => 'FWAcorp', 'slug' => 'fwa-corp-' . uniqid()]);
12+
$this->admin = User::factory()->create(['tenant_id' => $this->tenant->id]);
13+
$this->admin->assignRole('super-admin');
14+
$this->actingAs($this->admin);
15+
app()->instance('tenant', $this->tenant);
16+
});
17+
18+
function makeFWAEmployee(): Employee
19+
{
20+
return Employee::create([
21+
'tenant_id' => test()->tenant->id,
22+
'user_id' => test()->admin->id,
23+
'first_name' => 'Flex',
24+
'last_name' => 'Worker-' . uniqid(),
25+
'email' => 'fwa.' . uniqid() . '@test.com',
26+
'status' => 'active',
27+
'start_date' => now()->toDateString(),
28+
]);
29+
}
30+
31+
function makeFlexWork(array $attrs = []): FlexibleWorkArrangement
32+
{
33+
$emp = makeFWAEmployee();
34+
return FlexibleWorkArrangement::create([
35+
'tenant_id' => test()->tenant->id,
36+
'employee_id' => $emp->id,
37+
'arrangement_type' => 'remote',
38+
'start_date' => now()->toDateString(),
39+
...$attrs,
40+
]);
41+
}
42+
43+
it('index requires authentication', function () {
44+
$this->post('/logout');
45+
$this->get('/hr/flexible-work')->assertRedirect('/login');
46+
});
47+
48+
it('admin can list flexible work arrangements', function () {
49+
makeFlexWork();
50+
$this->get('/hr/flexible-work')->assertOk();
51+
});
52+
53+
it('store creates a flexible work arrangement', function () {
54+
$emp = makeFWAEmployee();
55+
$this->post('/hr/flexible-work', [
56+
'employee_id' => $emp->id,
57+
'arrangement_type' => 'hybrid',
58+
'start_date' => now()->toDateString(),
59+
'end_date' => now()->addMonths(6)->toDateString(),
60+
])->assertRedirect();
61+
62+
expect(FlexibleWorkArrangement::where('employee_id', $emp->id)->where('arrangement_type', 'hybrid')->exists())->toBeTrue();
63+
});
64+
65+
it('store validates required fields', function () {
66+
$this->postJson('/hr/flexible-work', [])->assertStatus(422)->assertJsonValidationErrors(['employee_id', 'arrangement_type', 'start_date']);
67+
});
68+
69+
it('show displays the arrangement', function () {
70+
$fwa = makeFlexWork();
71+
$this->get("/hr/flexible-work/{$fwa->id}")->assertOk();
72+
});
73+
74+
it('approve transitions to approved', function () {
75+
$fwa = makeFlexWork();
76+
expect($fwa->is_pending)->toBeTrue();
77+
78+
$this->post("/hr/flexible-work/{$fwa->id}/approve")->assertRedirect();
79+
80+
$fwa->refresh();
81+
expect($fwa->status)->toBe('approved');
82+
expect($fwa->approved_by)->toBe(test()->admin->id);
83+
});
84+
85+
it('reject transitions to rejected with reason', function () {
86+
$fwa = makeFlexWork();
87+
$this->post("/hr/flexible-work/{$fwa->id}/reject", ['reason' => 'Business needs'])->assertRedirect();
88+
$fwa->refresh();
89+
expect($fwa->status)->toBe('rejected');
90+
expect($fwa->rejection_reason)->toBe('Business needs');
91+
});
92+
93+
it('is_active returns true for approved arrangement without end date', function () {
94+
$fwa = makeFlexWork(['status' => 'approved']);
95+
expect($fwa->is_active)->toBeTrue();
96+
});
97+
98+
it('is_active returns false for rejected arrangement', function () {
99+
$fwa = makeFlexWork(['status' => 'rejected']);
100+
expect($fwa->is_active)->toBeFalse();
101+
});
102+
103+
it('destroy soft-deletes the arrangement', function () {
104+
$fwa = makeFlexWork();
105+
$this->delete("/hr/flexible-work/{$fwa->id}")->assertRedirect();
106+
expect(FlexibleWorkArrangement::find($fwa->id))->toBeNull();
107+
expect(FlexibleWorkArrangement::withTrashed()->find($fwa->id))->not->toBeNull();
108+
});

0 commit comments

Comments
 (0)