Skip to content

Commit a2c9e75

Browse files
committed
feat(hr): Phase 48 — Job Positions & Recruitment Pipeline
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent c3bfbaa commit a2c9e75

18 files changed

Lines changed: 1617 additions & 0 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\JobApplication;
7+
use App\Modules\HR\Models\JobPosition;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Validation\Rule;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class JobApplicationController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', JobApplication::class);
19+
20+
$query = JobApplication::with('jobPosition')->latest();
21+
22+
if ($request->filled('job_position_id')) {
23+
$query->where('job_position_id', $request->job_position_id);
24+
}
25+
26+
$applications = $query->paginate(15);
27+
28+
return Inertia::render('HR/JobApplications/Index', compact('applications'));
29+
}
30+
31+
public function create(): Response
32+
{
33+
$this->authorize('create', JobApplication::class);
34+
35+
$positions = JobPosition::where('status', 'open')->orderBy('title')->get(['id', 'title']);
36+
37+
return Inertia::render('HR/JobApplications/Create', compact('positions'));
38+
}
39+
40+
public function store(Request $request): RedirectResponse
41+
{
42+
$this->authorize('create', JobApplication::class);
43+
44+
$data = $request->validate([
45+
'job_position_id' => ['required', Rule::exists('job_positions', 'id')],
46+
'applicant_name' => ['required', 'string', 'max:255'],
47+
'applicant_email' => ['required', 'email', 'max:255'],
48+
'applicant_phone' => ['nullable', 'string', 'max:50'],
49+
'cover_letter' => ['nullable', 'string'],
50+
'source' => ['nullable', 'string', 'max:100'],
51+
'rating' => ['nullable', 'integer', 'min:1', 'max:5'],
52+
]);
53+
54+
$application = JobApplication::create([
55+
'tenant_id' => auth()->user()->tenant_id,
56+
...$data,
57+
]);
58+
59+
return redirect()->route('hr.job-applications.show', $application)
60+
->with('success', 'Job application created.');
61+
}
62+
63+
public function show(JobApplication $jobApplication): Response
64+
{
65+
$this->authorize('view', $jobApplication);
66+
67+
$jobApplication->load('jobPosition');
68+
69+
return Inertia::render('HR/JobApplications/Show', [
70+
'application' => $jobApplication,
71+
]);
72+
}
73+
74+
public function destroy(JobApplication $jobApplication): RedirectResponse
75+
{
76+
$this->authorize('delete', $jobApplication);
77+
78+
$jobApplication->delete();
79+
80+
return redirect()->route('hr.job-applications.index')
81+
->with('success', 'Job application deleted.');
82+
}
83+
84+
public function advance(Request $request, JobApplication $jobApplication): RedirectResponse
85+
{
86+
$this->authorize('update', $jobApplication);
87+
88+
$data = $request->validate([
89+
'stage' => ['required', Rule::in(['applied', 'screening', 'interview', 'offer', 'hired', 'rejected'])],
90+
]);
91+
92+
$jobApplication->advance($data['stage']);
93+
94+
return redirect()->back()->with('success', 'Application stage updated.');
95+
}
96+
97+
public function reject(Request $request, JobApplication $jobApplication): RedirectResponse
98+
{
99+
$this->authorize('update', $jobApplication);
100+
101+
$request->validate([
102+
'reason' => ['nullable', 'string'],
103+
]);
104+
105+
$jobApplication->reject($request->reason);
106+
107+
return redirect()->back()->with('success', 'Application rejected.');
108+
}
109+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\Department;
7+
use App\Modules\HR\Models\JobPosition;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Validation\Rule;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class JobPositionController extends Controller
15+
{
16+
public function index(): Response
17+
{
18+
$this->authorize('viewAny', JobPosition::class);
19+
20+
$positions = JobPosition::with('department')
21+
->withCount('applications')
22+
->latest()
23+
->paginate(15);
24+
25+
return Inertia::render('HR/JobPositions/Index', compact('positions'));
26+
}
27+
28+
public function create(): Response
29+
{
30+
$this->authorize('create', JobPosition::class);
31+
32+
$departments = Department::orderBy('name')->get(['id', 'name']);
33+
34+
return Inertia::render('HR/JobPositions/Create', compact('departments'));
35+
}
36+
37+
public function store(Request $request): RedirectResponse
38+
{
39+
$this->authorize('create', JobPosition::class);
40+
41+
$data = $request->validate([
42+
'title' => ['required', 'string', 'max:255'],
43+
'department_id' => ['nullable', Rule::exists('departments', 'id')],
44+
'location' => ['nullable', 'string', 'max:255'],
45+
'employment_type' => ['required', Rule::in(['full_time', 'part_time', 'contract', 'internship'])],
46+
'description' => ['nullable', 'string'],
47+
'requirements' => ['nullable', 'string'],
48+
'openings' => ['required', 'integer', 'min:1'],
49+
]);
50+
51+
$position = JobPosition::create([
52+
'tenant_id' => auth()->user()->tenant_id,
53+
...$data,
54+
]);
55+
56+
return redirect()->route('hr.job-positions.show', $position)
57+
->with('success', 'Job position created.');
58+
}
59+
60+
public function show(JobPosition $jobPosition): Response
61+
{
62+
$this->authorize('view', $jobPosition);
63+
64+
$jobPosition->load([
65+
'department',
66+
'applications' => fn ($q) => $q->latest(),
67+
]);
68+
69+
return Inertia::render('HR/JobPositions/Show', [
70+
'position' => $jobPosition,
71+
]);
72+
}
73+
74+
public function destroy(JobPosition $jobPosition): RedirectResponse
75+
{
76+
$this->authorize('delete', $jobPosition);
77+
78+
$jobPosition->delete();
79+
80+
return redirect()->route('hr.job-positions.index')
81+
->with('success', 'Job position deleted.');
82+
}
83+
84+
public function publish(JobPosition $jobPosition): RedirectResponse
85+
{
86+
$this->authorize('update', $jobPosition);
87+
88+
$jobPosition->publish();
89+
90+
return redirect()->back()->with('success', 'Job position published.');
91+
}
92+
93+
public function close(JobPosition $jobPosition): RedirectResponse
94+
{
95+
$this->authorize('update', $jobPosition);
96+
97+
$jobPosition->close();
98+
99+
return redirect()->back()->with('success', 'Job position closed.');
100+
}
101+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
10+
class JobApplication extends Model
11+
{
12+
use BelongsToTenant, SoftDeletes;
13+
14+
protected $fillable = [
15+
'tenant_id',
16+
'job_position_id',
17+
'applicant_name',
18+
'applicant_email',
19+
'applicant_phone',
20+
'resume_path',
21+
'cover_letter',
22+
'source',
23+
'stage',
24+
'notes',
25+
'rating',
26+
'rejected_at',
27+
'hired_at',
28+
];
29+
30+
protected $casts = [
31+
'rejected_at' => 'datetime',
32+
'hired_at' => 'datetime',
33+
];
34+
35+
public function jobPosition(): BelongsTo
36+
{
37+
return $this->belongsTo(JobPosition::class);
38+
}
39+
40+
public function advance(string $stage): void
41+
{
42+
$this->stage = $stage;
43+
if ($stage === 'hired') {
44+
$this->hired_at = now();
45+
}
46+
if ($stage === 'rejected') {
47+
$this->rejected_at = now();
48+
}
49+
$this->save();
50+
}
51+
52+
public function reject(?string $reason = null): void
53+
{
54+
$this->advance('rejected');
55+
if ($reason !== null) {
56+
$this->notes = trim(($this->notes ? $this->notes . "\n" : '') . $reason);
57+
$this->save();
58+
}
59+
}
60+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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+
11+
class JobPosition extends Model
12+
{
13+
use BelongsToTenant, SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'title',
18+
'department_id',
19+
'location',
20+
'employment_type',
21+
'description',
22+
'requirements',
23+
'openings',
24+
'status',
25+
'posted_at',
26+
'closed_at',
27+
];
28+
29+
protected $casts = [
30+
'posted_at' => 'datetime',
31+
'closed_at' => 'datetime',
32+
];
33+
34+
public function department(): BelongsTo
35+
{
36+
return $this->belongsTo(Department::class);
37+
}
38+
39+
public function applications(): HasMany
40+
{
41+
return $this->hasMany(JobApplication::class);
42+
}
43+
44+
public function getOpenApplicationsCountAttribute(): int
45+
{
46+
return $this->applications()->whereNotIn('stage', ['hired', 'rejected'])->count();
47+
}
48+
49+
public function publish(): void
50+
{
51+
$this->status = 'open';
52+
$this->posted_at = now();
53+
$this->save();
54+
}
55+
56+
public function close(): void
57+
{
58+
$this->status = 'closed';
59+
$this->closed_at = now();
60+
$this->save();
61+
}
62+
}
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 RecruitmentPolicy
8+
{
9+
public function viewAny(User $user): bool
10+
{
11+
return $user->can('hr.view');
12+
}
13+
14+
public function view(User $user): bool
15+
{
16+
return $user->can('hr.view');
17+
}
18+
19+
public function create(User $user): bool
20+
{
21+
return $user->can('hr.create');
22+
}
23+
24+
public function update(User $user): bool
25+
{
26+
return $user->can('hr.create');
27+
}
28+
29+
public function delete(User $user): bool
30+
{
31+
return $user->can('hr.delete');
32+
}
33+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
use App\Modules\HR\Models\EmployeeOnboarding;
88
use App\Modules\HR\Models\EmployeeTrainingRecord;
99
use App\Modules\HR\Models\ExpenseClaim;
10+
use App\Modules\HR\Models\JobApplication;
11+
use App\Modules\HR\Models\JobPosition;
1012
use App\Modules\HR\Models\LeaveRequest;
1113
use App\Modules\HR\Models\OnboardingTemplate;
1214
use App\Modules\HR\Models\PayrollRun;
@@ -20,6 +22,7 @@
2022
use App\Modules\HR\Policies\OnboardingTemplatePolicy;
2123
use App\Modules\HR\Policies\PayrollRunPolicy;
2224
use App\Modules\HR\Policies\PerformanceReviewPolicy;
25+
use App\Modules\HR\Policies\RecruitmentPolicy;
2326
use App\Modules\HR\Policies\TrainingPolicy;
2427
use Illuminate\Support\Facades\Gate;
2528
use Illuminate\Support\ServiceProvider;
@@ -36,6 +39,8 @@ public function boot(): void
3639
Gate::policy(Employee::class, EmployeePolicy::class);
3740
Gate::policy(EmployeeOnboarding::class, EmployeeOnboardingPolicy::class);
3841
Gate::policy(ExpenseClaim::class, ExpenseClaimPolicy::class);
42+
Gate::policy(JobApplication::class, RecruitmentPolicy::class);
43+
Gate::policy(JobPosition::class, RecruitmentPolicy::class);
3944
Gate::policy(LeaveRequest::class, LeaveRequestPolicy::class);
4045
Gate::policy(OnboardingTemplate::class, OnboardingTemplatePolicy::class);
4146
Gate::policy(PayrollRun::class, PayrollRunPolicy::class);

0 commit comments

Comments
 (0)