Skip to content

Commit eb81f73

Browse files
committed
feat(hr): Phase 127 — HR Job Offer Letters
Add full job offer letter lifecycle: draft → sent → accepted/declined, with expiry detection, soft-delete, and policy-gated CRUD + action routes. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 33f5ba6 commit eb81f73

9 files changed

Lines changed: 348 additions & 0 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\JobOfferLetter;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class JobOfferController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', JobOfferLetter::class);
17+
18+
$offers = JobOfferLetter::orderByDesc('created_at')->paginate(20);
19+
20+
return Inertia::render('HR/JobOffers/Index', compact('offers'));
21+
}
22+
23+
public function store(Request $request): RedirectResponse
24+
{
25+
$this->authorize('create', JobOfferLetter::class);
26+
27+
$data = $request->validate([
28+
'candidate_name' => ['required', 'string', 'max:255'],
29+
'candidate_email' => ['required', 'email'],
30+
'position_title' => ['required', 'string', 'max:255'],
31+
'offered_salary' => ['nullable', 'numeric', 'min:0'],
32+
'proposed_start_date'=> ['nullable', 'date'],
33+
'offer_expiry_date' => ['nullable', 'date'],
34+
'offer_terms' => ['nullable', 'string'],
35+
]);
36+
37+
JobOfferLetter::create([
38+
'tenant_id' => auth()->user()->tenant_id,
39+
'created_by' => auth()->id(),
40+
...$data,
41+
]);
42+
43+
return redirect()->route('hr.job-offers.index')->with('success', 'Job offer letter created.');
44+
}
45+
46+
public function show(JobOfferLetter $jobOffer): Response
47+
{
48+
$this->authorize('view', $jobOffer);
49+
50+
return Inertia::render('HR/JobOffers/Show', compact('jobOffer'));
51+
}
52+
53+
public function send(JobOfferLetter $jobOffer): RedirectResponse
54+
{
55+
$this->authorize('update', $jobOffer);
56+
57+
$jobOffer->send();
58+
59+
return back()->with('success', 'Offer letter sent.');
60+
}
61+
62+
public function accept(JobOfferLetter $jobOffer): RedirectResponse
63+
{
64+
$this->authorize('update', $jobOffer);
65+
66+
$jobOffer->accept();
67+
68+
return back()->with('success', 'Offer letter accepted.');
69+
}
70+
71+
public function decline(JobOfferLetter $jobOffer): RedirectResponse
72+
{
73+
$this->authorize('update', $jobOffer);
74+
75+
$jobOffer->decline();
76+
77+
return back()->with('success', 'Offer letter declined.');
78+
}
79+
80+
public function destroy(JobOfferLetter $jobOffer): RedirectResponse
81+
{
82+
$this->authorize('delete', $jobOffer);
83+
84+
$jobOffer->delete();
85+
86+
return back()->with('success', 'Offer letter deleted.');
87+
}
88+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
9+
class JobOfferLetter extends Model
10+
{
11+
use BelongsToTenant;
12+
use SoftDeletes;
13+
14+
protected $fillable = [
15+
'tenant_id',
16+
'job_application_id',
17+
'candidate_name',
18+
'candidate_email',
19+
'position_title',
20+
'offered_salary',
21+
'proposed_start_date',
22+
'offer_expiry_date',
23+
'offer_terms',
24+
'status',
25+
'sent_at',
26+
'responded_at',
27+
'created_by',
28+
];
29+
30+
protected $casts = [
31+
'offered_salary' => 'float',
32+
'proposed_start_date' => 'date',
33+
'offer_expiry_date' => 'date',
34+
'sent_at' => 'datetime',
35+
'responded_at' => 'datetime',
36+
];
37+
38+
protected $attributes = ['status' => 'draft'];
39+
40+
// ── Actions ───────────────────────────────────────────────────────────────
41+
42+
public function send(): void
43+
{
44+
$this->update([
45+
'status' => 'sent',
46+
'sent_at' => now(),
47+
]);
48+
}
49+
50+
public function accept(): void
51+
{
52+
$this->update([
53+
'status' => 'accepted',
54+
'responded_at' => now(),
55+
]);
56+
}
57+
58+
public function decline(): void
59+
{
60+
$this->update([
61+
'status' => 'declined',
62+
'responded_at' => now(),
63+
]);
64+
}
65+
66+
// ── Accessors ─────────────────────────────────────────────────────────────
67+
68+
public function getIsExpiredAttribute(): bool
69+
{
70+
return $this->offer_expiry_date !== null
71+
&& $this->offer_expiry_date->lt(today())
72+
&& $this->status !== 'accepted';
73+
}
74+
75+
public function getIsPendingAttribute(): bool
76+
{
77+
return $this->status === 'sent';
78+
}
79+
}
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 JobOfferPolicy
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
@@ -78,6 +78,8 @@
7878
use App\Modules\HR\Models\FlexibleWorkArrangement;
7979
use App\Modules\HR\Policies\FlexibleWorkPolicy;
8080
use App\Modules\HR\Policies\EmployeeSurveyPolicy;
81+
use App\Modules\HR\Models\JobOfferLetter;
82+
use App\Modules\HR\Policies\JobOfferPolicy;
8183
use Illuminate\Support\Facades\Gate;
8284
use Illuminate\Support\ServiceProvider;
8385

@@ -136,5 +138,6 @@ public function boot(): void
136138
Gate::policy(OvertimeRequest::class, OvertimeRequestPolicy::class);
137139
Gate::policy(EmployeeSurvey::class, EmployeeSurveyPolicy::class);
138140
Gate::policy(FlexibleWorkArrangement::class, FlexibleWorkPolicy::class);
141+
Gate::policy(JobOfferLetter::class, JobOfferPolicy::class);
139142
}
140143
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,12 @@
276276
Route::post('flexible-work/{flexibleWork}/reject', [FlexibleWorkController::class, 'reject'])->name('flexible-work.reject');
277277
Route::resource('flexible-work', FlexibleWorkController::class)->only(['index', 'store', 'show', 'destroy']);
278278
});
279+
280+
// Job Offer Letters
281+
use App\Modules\HR\Http\Controllers\JobOfferController;
282+
Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () {
283+
Route::post('job-offers/{jobOffer}/send', [JobOfferController::class, 'send'])->name('job-offers.send');
284+
Route::post('job-offers/{jobOffer}/accept', [JobOfferController::class, 'accept'])->name('job-offers.accept');
285+
Route::post('job-offers/{jobOffer}/decline', [JobOfferController::class, 'decline'])->name('job-offers.decline');
286+
Route::resource('job-offers', JobOfferController::class)->only(['index', 'store', 'show', 'destroy']);
287+
});
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('job_offer_letters', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->unsignedBigInteger('job_application_id')->nullable();
15+
$table->string('candidate_name');
16+
$table->string('candidate_email');
17+
$table->string('position_title');
18+
$table->decimal('offered_salary', 15, 2)->nullable();
19+
$table->date('proposed_start_date')->nullable();
20+
$table->date('offer_expiry_date')->nullable();
21+
$table->text('offer_terms')->nullable();
22+
$table->string('status')->default('draft'); // draft/sent/accepted/declined/expired
23+
$table->timestamp('sent_at')->nullable();
24+
$table->timestamp('responded_at')->nullable();
25+
$table->unsignedBigInteger('created_by')->nullable();
26+
$table->timestamps();
27+
$table->softDeletes();
28+
});
29+
}
30+
31+
public function down(): void
32+
{
33+
Schema::dropIfExists('job_offer_letters');
34+
}
35+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default function Index() { return <div>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>Show</div>; }
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\HR\Models\JobOfferLetter;
6+
use Database\Seeders\RolePermissionSeeder;
7+
8+
beforeEach(function () {
9+
$this->seed(RolePermissionSeeder::class);
10+
$this->tenant = Tenant::create(['name' => 'OfferCorp', 'slug' => 'offer-corp-' . uniqid()]);
11+
$this->admin = User::factory()->create(['tenant_id' => $this->tenant->id]);
12+
$this->admin->assignRole('super-admin');
13+
$this->actingAs($this->admin);
14+
app()->instance('tenant', $this->tenant);
15+
});
16+
17+
function makeOfferLetter(array $attrs = []): JobOfferLetter
18+
{
19+
return JobOfferLetter::create([
20+
'tenant_id' => test()->tenant->id,
21+
'candidate_name' => 'Jane Doe',
22+
'candidate_email' => 'jane.' . uniqid() . '@example.com',
23+
'position_title' => 'Software Engineer',
24+
'created_by' => test()->admin->id,
25+
...$attrs,
26+
]);
27+
}
28+
29+
it('index requires authentication', function () {
30+
$this->post('/logout');
31+
$this->get('/hr/job-offers')->assertRedirect('/login');
32+
});
33+
34+
it('admin can list job offers', function () {
35+
makeOfferLetter();
36+
$this->get('/hr/job-offers')->assertOk();
37+
});
38+
39+
it('store creates a job offer letter', function () {
40+
$this->post('/hr/job-offers', [
41+
'candidate_name' => 'John Smith',
42+
'candidate_email' => 'john@example.com',
43+
'position_title' => 'Product Manager',
44+
'offered_salary' => 80000,
45+
])->assertRedirect();
46+
47+
expect(JobOfferLetter::where('candidate_email', 'john@example.com')->exists())->toBeTrue();
48+
});
49+
50+
it('store validates required fields', function () {
51+
$this->postJson('/hr/job-offers', [])->assertStatus(422)->assertJsonValidationErrors(['candidate_name', 'candidate_email', 'position_title']);
52+
});
53+
54+
it('show displays a job offer', function () {
55+
$offer = makeOfferLetter();
56+
$this->get("/hr/job-offers/{$offer->id}")->assertOk();
57+
});
58+
59+
it('send transitions status to sent', function () {
60+
$offer = makeOfferLetter();
61+
expect($offer->status)->toBe('draft');
62+
63+
$this->post("/hr/job-offers/{$offer->id}/send")->assertRedirect();
64+
65+
$offer->refresh();
66+
expect($offer->status)->toBe('sent');
67+
expect($offer->sent_at)->not->toBeNull();
68+
expect($offer->is_pending)->toBeTrue();
69+
});
70+
71+
it('accept transitions status to accepted', function () {
72+
$offer = makeOfferLetter(['status' => 'sent']);
73+
$this->post("/hr/job-offers/{$offer->id}/accept")->assertRedirect();
74+
$offer->refresh();
75+
expect($offer->status)->toBe('accepted');
76+
expect($offer->responded_at)->not->toBeNull();
77+
});
78+
79+
it('decline transitions status to declined', function () {
80+
$offer = makeOfferLetter(['status' => 'sent']);
81+
$this->post("/hr/job-offers/{$offer->id}/decline")->assertRedirect();
82+
$offer->refresh();
83+
expect($offer->status)->toBe('declined');
84+
});
85+
86+
it('is_expired accessor returns true for past expiry non-accepted offer', function () {
87+
$offer = makeOfferLetter([
88+
'status' => 'sent',
89+
'offer_expiry_date' => now()->subDay()->toDateString(),
90+
]);
91+
expect($offer->is_expired)->toBeTrue();
92+
});
93+
94+
it('destroy soft-deletes the offer', function () {
95+
$offer = makeOfferLetter();
96+
$this->delete("/hr/job-offers/{$offer->id}")->assertRedirect();
97+
expect(JobOfferLetter::find($offer->id))->toBeNull();
98+
expect(JobOfferLetter::withTrashed()->find($offer->id))->not->toBeNull();
99+
});

0 commit comments

Comments
 (0)