Skip to content

Commit 3fb9455

Browse files
committed
feat(hr): Phase 99 — Job Positions & Recruitment Management
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent f4dc210 commit 3fb9455

14 files changed

Lines changed: 718 additions & 287 deletions

File tree

erp/app/Modules/HR/Http/Controllers/JobApplicationController.php

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ public function index(Request $request): Response
2222
if ($request->filled('job_position_id')) {
2323
$query->where('job_position_id', $request->job_position_id);
2424
}
25+
if ($request->filled('status')) {
26+
$query->where('status', $request->status);
27+
}
2528

2629
$applications = $query->paginate(15);
2730

@@ -32,7 +35,8 @@ public function create(): Response
3235
{
3336
$this->authorize('create', JobApplication::class);
3437

35-
$positions = JobPosition::where('status', 'open')->orderBy('title')->get(['id', 'title']);
38+
$positions = JobPosition::where('is_active', true)->orWhere('status', 'open')
39+
->orderBy('title')->get(['id', 'title']);
3640

3741
return Inertia::render('HR/JobApplications/Create', compact('positions'));
3842
}
@@ -47,24 +51,24 @@ public function store(Request $request): RedirectResponse
4751
'applicant_email' => ['required', 'email', 'max:255'],
4852
'applicant_phone' => ['nullable', 'string', 'max:50'],
4953
'cover_letter' => ['nullable', 'string'],
54+
'resume_url' => ['nullable', 'string'],
5055
'source' => ['nullable', 'string', 'max:100'],
5156
'rating' => ['nullable', 'integer', 'min:1', 'max:5'],
5257
]);
5358

54-
$application = JobApplication::create([
55-
'tenant_id' => auth()->user()->tenant_id,
56-
...$data,
57-
]);
59+
$data['tenant_id'] = auth()->user()->tenant_id;
60+
$data['status'] = 'new';
5861

59-
return redirect()->route('hr.job-applications.show', $application)
60-
->with('success', 'Job application created.');
62+
$application = JobApplication::create($data);
63+
64+
return redirect()->back()->with('success', 'Job application created.');
6165
}
6266

6367
public function show(JobApplication $jobApplication): Response
6468
{
6569
$this->authorize('view', $jobApplication);
6670

67-
$jobApplication->load('jobPosition');
71+
$jobApplication->load('jobPosition', 'reviewer');
6872

6973
return Inertia::render('HR/JobApplications/Show', [
7074
'application' => $jobApplication,
@@ -77,32 +81,51 @@ public function destroy(JobApplication $jobApplication): RedirectResponse
7781

7882
$jobApplication->delete();
7983

80-
return redirect()->route('hr.job-applications.index')
81-
->with('success', 'Job application deleted.');
84+
return redirect()->back()->with('success', 'Job application deleted.');
8285
}
8386

8487
public function advance(Request $request, JobApplication $jobApplication): RedirectResponse
8588
{
8689
$this->authorize('update', $jobApplication);
8790

91+
$validStages = ['screening', 'interview', 'offer', 'applied', 'hired', 'rejected'];
92+
8893
$data = $request->validate([
89-
'stage' => ['required', Rule::in(['applied', 'screening', 'interview', 'offer', 'hired', 'rejected'])],
94+
'status' => ['nullable', Rule::in($validStages)],
95+
'stage' => ['nullable', Rule::in($validStages)],
9096
]);
9197

92-
$jobApplication->advance($data['stage']);
98+
$newStatus = $data['status'] ?? $data['stage'] ?? null;
99+
100+
if ($newStatus === null) {
101+
return back()->withErrors(['status' => 'A status is required.']);
102+
}
103+
104+
$jobApplication->advance($newStatus);
105+
106+
return redirect()->back()->with('success', 'Application status updated.');
107+
}
108+
109+
public function hire(JobApplication $jobApplication): RedirectResponse
110+
{
111+
$this->authorize('update', $jobApplication);
112+
113+
$jobApplication->hire();
93114

94-
return redirect()->back()->with('success', 'Application stage updated.');
115+
return redirect()->back()->with('success', 'Applicant hired.');
95116
}
96117

97118
public function reject(Request $request, JobApplication $jobApplication): RedirectResponse
98119
{
99120
$this->authorize('update', $jobApplication);
100121

101122
$request->validate([
123+
'notes' => ['nullable', 'string'],
102124
'reason' => ['nullable', 'string'],
103125
]);
104126

105-
$jobApplication->reject($request->reason);
127+
$notes = $request->notes ?? $request->reason ?? '';
128+
$jobApplication->reject($notes);
106129

107130
return redirect()->back()->with('success', 'Application rejected.');
108131
}

erp/app/Modules/HR/Http/Controllers/JobPositionController.php

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,22 @@
1313

1414
class JobPositionController extends Controller
1515
{
16-
public function index(): Response
16+
public function index(Request $request): Response
1717
{
1818
$this->authorize('viewAny', JobPosition::class);
1919

20-
$positions = JobPosition::with('department')
20+
$query = JobPosition::with('department')
2121
->withCount('applications')
22-
->latest()
23-
->paginate(15);
22+
->latest();
23+
24+
if ($request->filled('department')) {
25+
$query->where('department', $request->department);
26+
}
27+
if ($request->has('is_active') && $request->is_active !== null) {
28+
$query->where('is_active', filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN));
29+
}
30+
31+
$positions = $query->paginate(15);
2432

2533
return Inertia::render('HR/JobPositions/Index', compact('positions'));
2634
}
@@ -40,21 +48,27 @@ public function store(Request $request): RedirectResponse
4048

4149
$data = $request->validate([
4250
'title' => ['required', 'string', 'max:255'],
51+
'employment_type' => ['required', Rule::in(['full_time', 'part_time', 'contract', 'internship'])],
52+
'department' => ['nullable', 'string', 'max:255'],
4353
'department_id' => ['nullable', Rule::exists('departments', 'id')],
4454
'location' => ['nullable', 'string', 'max:255'],
45-
'employment_type' => ['required', Rule::in(['full_time', 'part_time', 'contract', 'internship'])],
4655
'description' => ['nullable', 'string'],
4756
'requirements' => ['nullable', 'string'],
48-
'openings' => ['required', 'integer', 'min:1'],
57+
'salary_min' => ['nullable', 'numeric', 'min:0'],
58+
'salary_max' => ['nullable', 'numeric', 'min:0'],
59+
'openings' => ['integer', 'min:1'],
60+
'is_active' => ['boolean'],
61+
'posted_at' => ['nullable', 'date'],
62+
'closes_at' => ['nullable', 'date'],
4963
]);
5064

51-
$position = JobPosition::create([
52-
'tenant_id' => auth()->user()->tenant_id,
53-
...$data,
54-
]);
65+
$data['tenant_id'] = auth()->user()->tenant_id;
66+
$data['openings'] = $data['openings'] ?? 1;
67+
$data['is_active'] = $data['is_active'] ?? true;
68+
69+
$position = JobPosition::create($data);
5570

56-
return redirect()->route('hr.job-positions.show', $position)
57-
->with('success', 'Job position created.');
71+
return redirect()->back()->with('success', 'Job position created.');
5872
}
5973

6074
public function show(JobPosition $jobPosition): Response
@@ -77,8 +91,7 @@ public function destroy(JobPosition $jobPosition): RedirectResponse
7791

7892
$jobPosition->delete();
7993

80-
return redirect()->route('hr.job-positions.index')
81-
->with('success', 'Job position deleted.');
94+
return redirect()->back()->with('success', 'Job position deleted.');
8295
}
8396

8497
public function publish(JobPosition $jobPosition): RedirectResponse

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

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace App\Modules\HR\Models;
44

55
use App\Modules\Core\Traits\BelongsToTenant;
6+
use App\Models\User;
67
use Illuminate\Database\Eloquent\Model;
78
use Illuminate\Database\Eloquent\SoftDeletes;
89
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -17,44 +18,78 @@ class JobApplication extends Model
1718
'applicant_name',
1819
'applicant_email',
1920
'applicant_phone',
20-
'resume_path',
21+
'status',
22+
'stage',
2123
'cover_letter',
24+
'resume_url',
25+
'resume_path',
2226
'source',
23-
'stage',
24-
'notes',
2527
'rating',
28+
'notes',
29+
'reviewed_by',
30+
'reviewed_at',
2631
'rejected_at',
2732
'hired_at',
2833
];
2934

3035
protected $casts = [
36+
'rating' => 'integer',
37+
'reviewed_at' => 'datetime',
3138
'rejected_at' => 'datetime',
3239
'hired_at' => 'datetime',
3340
];
3441

42+
public function position(): BelongsTo
43+
{
44+
return $this->belongsTo(JobPosition::class, 'job_position_id');
45+
}
46+
3547
public function jobPosition(): BelongsTo
3648
{
3749
return $this->belongsTo(JobPosition::class);
3850
}
3951

40-
public function advance(string $stage): void
52+
public function reviewer(): BelongsTo
53+
{
54+
return $this->belongsTo(User::class, 'reviewed_by');
55+
}
56+
57+
public function advance(string $newStatus): void
4158
{
42-
$this->stage = $stage;
43-
if ($stage === 'hired') {
59+
$this->status = $newStatus;
60+
$this->stage = $newStatus;
61+
if ($newStatus === 'hired') {
4462
$this->hired_at = now();
4563
}
46-
if ($stage === 'rejected') {
64+
if ($newStatus === 'rejected') {
4765
$this->rejected_at = now();
4866
}
4967
$this->save();
5068
}
5169

52-
public function reject(?string $reason = null): void
70+
public function hire(): void
71+
{
72+
$this->status = 'hired';
73+
$this->stage = 'hired';
74+
$this->hired_at = now();
75+
$this->save();
76+
}
77+
78+
public function reject(string $notes = ''): void
5379
{
54-
$this->advance('rejected');
55-
if ($reason !== null) {
56-
$this->notes = trim(($this->notes ? $this->notes . "\n" : '') . $reason);
57-
$this->save();
80+
$this->status = 'rejected';
81+
$this->stage = 'rejected';
82+
$this->rejected_at = now();
83+
if ($notes !== '') {
84+
$this->notes = $notes;
5885
}
86+
$this->save();
87+
}
88+
89+
public function getIsActiveAttribute(): bool
90+
{
91+
$terminal = ['hired', 'rejected'];
92+
$s = $this->status ?? $this->stage ?? 'new';
93+
return !in_array($s, $terminal);
5994
}
6095
}

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Illuminate\Database\Eloquent\SoftDeletes;
88
use Illuminate\Database\Eloquent\Relations\BelongsTo;
99
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Support\Carbon;
1011

1112
class JobPosition extends Model
1213
{
@@ -15,20 +16,30 @@ class JobPosition extends Model
1516
protected $fillable = [
1617
'tenant_id',
1718
'title',
19+
'department',
1820
'department_id',
1921
'location',
2022
'employment_type',
2123
'description',
2224
'requirements',
25+
'salary_min',
26+
'salary_max',
2327
'openings',
28+
'is_active',
2429
'status',
2530
'posted_at',
31+
'closes_at',
2632
'closed_at',
2733
];
2834

2935
protected $casts = [
30-
'posted_at' => 'datetime',
31-
'closed_at' => 'datetime',
36+
'salary_min' => 'float',
37+
'salary_max' => 'float',
38+
'openings' => 'integer',
39+
'is_active' => 'boolean',
40+
'posted_at' => 'datetime',
41+
'closed_at' => 'datetime',
42+
'closes_at' => 'date',
3243
];
3344

3445
public function department(): BelongsTo
@@ -41,6 +52,22 @@ public function applications(): HasMany
4152
return $this->hasMany(JobApplication::class);
4253
}
4354

55+
public function getIsOpenAttribute(): bool
56+
{
57+
if (!$this->is_active) {
58+
return false;
59+
}
60+
if ($this->closes_at === null) {
61+
return true;
62+
}
63+
return Carbon::parse($this->closes_at)->gte(Carbon::today());
64+
}
65+
66+
public function getApplicationCountAttribute(): int
67+
{
68+
return $this->applications()->count();
69+
}
70+
4471
public function getOpenApplicationsCountAttribute(): int
4572
{
4673
return $this->applications()->whereNotIn('stage', ['hired', 'rejected'])->count();

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@
130130

131131
// Job Applications
132132
Route::patch('job-applications/{jobApplication}/advance', [JobApplicationController::class, 'advance'])->name('job-applications.advance');
133+
Route::post('job-applications/{jobApplication}/advance', [JobApplicationController::class, 'advance'])->name('job-applications.advance.post');
134+
Route::post('job-applications/{jobApplication}/hire', [JobApplicationController::class, 'hire'])->name('job-applications.hire');
133135
Route::post('job-applications/{jobApplication}/reject', [JobApplicationController::class, 'reject'])->name('job-applications.reject');
134136
Route::resource('job-applications', JobApplicationController::class)->except(['edit', 'update']);
135137

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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('job_positions', function (Blueprint $table) {
12+
if (!Schema::hasColumn('job_positions', 'department')) {
13+
$table->string('department')->nullable()->after('title');
14+
}
15+
if (!Schema::hasColumn('job_positions', 'salary_min')) {
16+
$table->decimal('salary_min', 12, 2)->nullable()->after('requirements');
17+
}
18+
if (!Schema::hasColumn('job_positions', 'salary_max')) {
19+
$table->decimal('salary_max', 12, 2)->nullable()->after('salary_min');
20+
}
21+
if (!Schema::hasColumn('job_positions', 'is_active')) {
22+
$table->boolean('is_active')->default(true)->after('salary_max');
23+
}
24+
if (!Schema::hasColumn('job_positions', 'closes_at')) {
25+
$table->date('closes_at')->nullable()->after('posted_at');
26+
}
27+
});
28+
}
29+
30+
public function down(): void
31+
{
32+
Schema::table('job_positions', function (Blueprint $table) {
33+
$columns = ['department', 'salary_min', 'salary_max', 'is_active', 'closes_at'];
34+
foreach ($columns as $col) {
35+
if (Schema::hasColumn('job_positions', $col)) {
36+
$table->dropColumn($col);
37+
}
38+
}
39+
});
40+
}
41+
};

0 commit comments

Comments
 (0)