Skip to content

Commit af398e2

Browse files
committed
feat(hr): Phase 144 — HR Interview Scheduling
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent eef2a00 commit af398e2

11 files changed

Lines changed: 691 additions & 0 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\InterviewSchedule;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class InterviewScheduleController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', InterviewSchedule::class);
17+
18+
$interviews = InterviewSchedule::with('interviewer')
19+
->latest()
20+
->paginate(15);
21+
22+
return Inertia::render('HR/InterviewSchedules/Index', [
23+
'interviews' => $interviews,
24+
]);
25+
}
26+
27+
public function create(): Response
28+
{
29+
$this->authorize('create', InterviewSchedule::class);
30+
31+
return Inertia::render('HR/InterviewSchedules/Create');
32+
}
33+
34+
public function store(Request $request): RedirectResponse
35+
{
36+
$this->authorize('create', InterviewSchedule::class);
37+
38+
$data = $request->validate([
39+
'candidate_name' => ['required', 'string', 'max:255'],
40+
'position_title' => ['required', 'string', 'max:255'],
41+
'scheduled_at' => ['required', 'date'],
42+
'candidate_email' => ['nullable', 'email', 'max:255'],
43+
'interview_type' => ['nullable', 'in:in-person,video,phone,panel'],
44+
'duration_minutes' => ['nullable', 'integer', 'min:15'],
45+
'location' => ['nullable', 'string', 'max:255'],
46+
'meeting_link' => ['nullable', 'string', 'max:255'],
47+
'notes' => ['nullable', 'string'],
48+
'interviewer_id' => ['nullable', 'exists:users,id'],
49+
'job_application_id' => ['nullable', 'exists:job_applications,id'],
50+
]);
51+
52+
$data['tenant_id'] = app('tenant')->id;
53+
$data['created_by'] = auth()->id();
54+
55+
InterviewSchedule::create($data);
56+
57+
return redirect()->route('hr.interview-schedules.index')
58+
->with('success', 'Interview schedule created.');
59+
}
60+
61+
public function show(InterviewSchedule $interviewSchedule): Response
62+
{
63+
$this->authorize('view', $interviewSchedule);
64+
65+
$interviewSchedule->load('interviewer', 'jobApplication', 'createdBy');
66+
67+
return Inertia::render('HR/InterviewSchedules/Show', [
68+
'interview' => $interviewSchedule,
69+
]);
70+
}
71+
72+
public function edit(InterviewSchedule $interviewSchedule): Response
73+
{
74+
$this->authorize('update', $interviewSchedule);
75+
76+
return Inertia::render('HR/InterviewSchedules/Edit', [
77+
'interview' => $interviewSchedule,
78+
]);
79+
}
80+
81+
public function update(Request $request, InterviewSchedule $interviewSchedule): RedirectResponse
82+
{
83+
$this->authorize('update', $interviewSchedule);
84+
85+
$data = $request->validate([
86+
'candidate_name' => ['required', 'string', 'max:255'],
87+
'position_title' => ['required', 'string', 'max:255'],
88+
'scheduled_at' => ['required', 'date'],
89+
'candidate_email' => ['nullable', 'email', 'max:255'],
90+
'interview_type' => ['nullable', 'in:in-person,video,phone,panel'],
91+
'duration_minutes' => ['nullable', 'integer', 'min:15'],
92+
'location' => ['nullable', 'string', 'max:255'],
93+
'meeting_link' => ['nullable', 'string', 'max:255'],
94+
'notes' => ['nullable', 'string'],
95+
'interviewer_id' => ['nullable', 'exists:users,id'],
96+
]);
97+
98+
$interviewSchedule->update($data);
99+
100+
return redirect()->route('hr.interview-schedules.index')
101+
->with('success', 'Interview schedule updated.');
102+
}
103+
104+
public function destroy(InterviewSchedule $interviewSchedule): RedirectResponse
105+
{
106+
$this->authorize('delete', $interviewSchedule);
107+
108+
$interviewSchedule->delete();
109+
110+
return redirect()->route('hr.interview-schedules.index')
111+
->with('success', 'Interview schedule deleted.');
112+
}
113+
114+
public function confirm(InterviewSchedule $interviewSchedule): RedirectResponse
115+
{
116+
$this->authorize('confirm', $interviewSchedule);
117+
118+
$interviewSchedule->confirm();
119+
120+
return redirect()->route('hr.interview-schedules.index')
121+
->with('success', 'Interview confirmed.');
122+
}
123+
124+
public function complete(Request $request, InterviewSchedule $interviewSchedule): RedirectResponse
125+
{
126+
$this->authorize('complete', $interviewSchedule);
127+
128+
$request->validate([
129+
'outcome' => ['nullable', 'string', 'in:pass,fail,hold'],
130+
'feedback' => ['nullable', 'string'],
131+
]);
132+
133+
$interviewSchedule->complete($request->outcome, $request->feedback);
134+
135+
return redirect()->route('hr.interview-schedules.index')
136+
->with('success', 'Interview completed.');
137+
}
138+
139+
public function cancel(InterviewSchedule $interviewSchedule): RedirectResponse
140+
{
141+
$this->authorize('cancel', $interviewSchedule);
142+
143+
$interviewSchedule->cancel();
144+
145+
return redirect()->route('hr.interview-schedules.index')
146+
->with('success', 'Interview cancelled.');
147+
}
148+
149+
public function noShow(InterviewSchedule $interviewSchedule): RedirectResponse
150+
{
151+
$this->authorize('markNoShow', $interviewSchedule);
152+
153+
$interviewSchedule->markNoShow();
154+
155+
return redirect()->route('hr.interview-schedules.index')
156+
->with('success', 'Interview marked as no-show.');
157+
}
158+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class InterviewSchedule extends Model
12+
{
13+
use BelongsToTenant, SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'interview_number',
18+
'candidate_name',
19+
'candidate_email',
20+
'position_title',
21+
'interview_type',
22+
'status',
23+
'scheduled_at',
24+
'duration_minutes',
25+
'location',
26+
'meeting_link',
27+
'notes',
28+
'feedback',
29+
'outcome',
30+
'interviewer_id',
31+
'job_application_id',
32+
'created_by',
33+
];
34+
35+
protected $casts = [
36+
'scheduled_at' => 'datetime',
37+
'duration_minutes' => 'integer',
38+
];
39+
40+
protected $attributes = [
41+
'status' => 'scheduled',
42+
'interview_type' => 'in-person',
43+
'duration_minutes' => 60,
44+
];
45+
46+
// Relations
47+
48+
public function interviewer(): BelongsTo
49+
{
50+
return $this->belongsTo(User::class, 'interviewer_id');
51+
}
52+
53+
public function jobApplication(): BelongsTo
54+
{
55+
return $this->belongsTo(JobApplication::class);
56+
}
57+
58+
public function createdBy(): BelongsTo
59+
{
60+
return $this->belongsTo(User::class, 'created_by');
61+
}
62+
63+
// State transition methods
64+
65+
public function confirm(): void
66+
{
67+
$this->status = 'confirmed';
68+
$this->save();
69+
}
70+
71+
public function complete(string $outcome = null, string $feedback = null): void
72+
{
73+
$this->status = 'completed';
74+
if ($outcome !== null) {
75+
$this->outcome = $outcome;
76+
}
77+
if ($feedback !== null) {
78+
$this->feedback = $feedback;
79+
}
80+
$this->save();
81+
}
82+
83+
public function cancel(): void
84+
{
85+
$this->status = 'cancelled';
86+
$this->save();
87+
}
88+
89+
public function markNoShow(): void
90+
{
91+
$this->status = 'no-show';
92+
$this->save();
93+
}
94+
95+
public function generateInterviewNumber(): string
96+
{
97+
return 'INT-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
98+
}
99+
100+
// Accessors
101+
102+
public function getIsScheduledAttribute(): bool
103+
{
104+
return $this->status === 'scheduled';
105+
}
106+
107+
public function getIsConfirmedAttribute(): bool
108+
{
109+
return $this->status === 'confirmed';
110+
}
111+
112+
public function getIsCompletedAttribute(): bool
113+
{
114+
return $this->status === 'completed';
115+
}
116+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\HR\Models\InterviewSchedule;
7+
8+
class InterviewSchedulePolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('hr.view');
13+
}
14+
15+
public function view(User $user, InterviewSchedule $interviewSchedule): bool
16+
{
17+
return $user->can('hr.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->can('hr.create');
23+
}
24+
25+
public function update(User $user, InterviewSchedule $interviewSchedule): bool
26+
{
27+
return $user->can('hr.create');
28+
}
29+
30+
public function confirm(User $user, InterviewSchedule $interviewSchedule): bool
31+
{
32+
return $user->can('hr.create');
33+
}
34+
35+
public function complete(User $user, InterviewSchedule $interviewSchedule): bool
36+
{
37+
return $user->can('hr.create');
38+
}
39+
40+
public function cancel(User $user, InterviewSchedule $interviewSchedule): bool
41+
{
42+
return $user->can('hr.create');
43+
}
44+
45+
public function delete(User $user, InterviewSchedule $interviewSchedule): bool
46+
{
47+
return $user->can('hr.delete');
48+
}
49+
50+
public function markNoShow(User $user, InterviewSchedule $interviewSchedule): bool
51+
{
52+
return $user->can('hr.delete');
53+
}
54+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@
9292
use App\Modules\HR\Policies\SuccessionPlanPolicy;
9393
use App\Modules\HR\Models\MentorshipProgram;
9494
use App\Modules\HR\Policies\MentorshipProgramPolicy;
95+
use App\Modules\HR\Models\InterviewSchedule;
96+
use App\Modules\HR\Policies\InterviewSchedulePolicy;
9597
use Illuminate\Support\Facades\Gate;
9698
use Illuminate\Support\ServiceProvider;
9799

@@ -158,5 +160,6 @@ public function boot(): void
158160
Gate::policy(SuccessionPlan::class, SuccessionPlanPolicy::class);
159161
Gate::policy(SuccessionCandidate::class, SuccessionPlanPolicy::class);
160162
Gate::policy(MentorshipProgram::class, MentorshipProgramPolicy::class);
163+
Gate::policy(InterviewSchedule::class, InterviewSchedulePolicy::class);
161164
}
162165
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,3 +331,13 @@
331331
Route::post('mentorship-programs/{mentorship_program}/log-session',[MentorshipProgramController::class, 'logSession'])->name('mentorship-programs.log-session');
332332
Route::resource('mentorship-programs', MentorshipProgramController::class);
333333
});
334+
335+
// Interview Schedules
336+
use App\Modules\HR\Http\Controllers\InterviewScheduleController;
337+
Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () {
338+
Route::post('interview-schedules/{interview_schedule}/confirm', [InterviewScheduleController::class, 'confirm'])->name('interview-schedules.confirm');
339+
Route::post('interview-schedules/{interview_schedule}/complete', [InterviewScheduleController::class, 'complete'])->name('interview-schedules.complete');
340+
Route::post('interview-schedules/{interview_schedule}/cancel', [InterviewScheduleController::class, 'cancel'])->name('interview-schedules.cancel');
341+
Route::post('interview-schedules/{interview_schedule}/no-show', [InterviewScheduleController::class, 'noShow'])->name('interview-schedules.no-show');
342+
Route::resource('interview-schedules', InterviewScheduleController::class);
343+
});

0 commit comments

Comments
 (0)