Skip to content

Commit 9b5812f

Browse files
committed
feat(hr): Phase 149 — HR Employee Emergency Contacts
Adds EmployeeEmergencyContact model with markAsPrimary() that enforces a single primary contact per employee. Uses shallow nested routing under employees so show/edit/update/destroy work without the parent parameter. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 7ad792d commit 9b5812f

12 files changed

Lines changed: 573 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Modules\HR\Models\Employee;
6+
use App\Modules\HR\Models\EmployeeEmergencyContact;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class EmployeeEmergencyContactController
13+
{
14+
public function index(Employee $employee): Response
15+
{
16+
$contacts = $employee->emergencyContacts()->orderByDesc('is_primary')->get();
17+
return Inertia::render('HR/EmergencyContacts/Index', compact('employee', 'contacts'));
18+
}
19+
20+
public function create(Employee $employee): Response
21+
{
22+
return Inertia::render('HR/EmergencyContacts/Create', compact('employee'));
23+
}
24+
25+
public function store(Request $request, Employee $employee): RedirectResponse
26+
{
27+
$data = $request->validate([
28+
'name' => 'required|string|max:255',
29+
'relationship' => 'required|string|max:100',
30+
'phone_primary' => 'required|string|max:50',
31+
'phone_secondary' => 'nullable|string|max:50',
32+
'email' => 'nullable|email|max:255',
33+
'address' => 'nullable|string',
34+
'is_primary' => 'boolean',
35+
'notes' => 'nullable|string',
36+
]);
37+
38+
$data['employee_id'] = $employee->id;
39+
40+
$contact = EmployeeEmergencyContact::create($data);
41+
42+
if (!empty($data['is_primary'])) {
43+
$contact->markAsPrimary();
44+
}
45+
46+
return redirect()->route('hr.employees.emergency-contacts.index', $employee);
47+
}
48+
49+
public function show(EmployeeEmergencyContact $emergencyContact): Response
50+
{
51+
$emergencyContact->load('employee');
52+
return Inertia::render('HR/EmergencyContacts/Show', [
53+
'employee' => $emergencyContact->employee,
54+
'contact' => $emergencyContact,
55+
]);
56+
}
57+
58+
public function edit(EmployeeEmergencyContact $emergencyContact): Response
59+
{
60+
return Inertia::render('HR/EmergencyContacts/Edit', [
61+
'contact' => $emergencyContact,
62+
]);
63+
}
64+
65+
public function update(Request $request, EmployeeEmergencyContact $emergencyContact): RedirectResponse
66+
{
67+
$data = $request->validate([
68+
'name' => 'required|string|max:255',
69+
'relationship' => 'required|string|max:100',
70+
'phone_primary' => 'required|string|max:50',
71+
'phone_secondary' => 'nullable|string|max:50',
72+
'email' => 'nullable|email|max:255',
73+
'address' => 'nullable|string',
74+
'is_primary' => 'boolean',
75+
'notes' => 'nullable|string',
76+
]);
77+
78+
$emergencyContact->update($data);
79+
80+
if (!empty($data['is_primary'])) {
81+
$emergencyContact->markAsPrimary();
82+
}
83+
84+
return redirect()->route('hr.employees.emergency-contacts.index', $emergencyContact->employee_id);
85+
}
86+
87+
public function destroy(EmployeeEmergencyContact $emergencyContact): RedirectResponse
88+
{
89+
$employeeId = $emergencyContact->employee_id;
90+
$emergencyContact->delete();
91+
return redirect()->route('hr.employees.emergency-contacts.index', $employeeId);
92+
}
93+
94+
public function markPrimary(Employee $employee, EmployeeEmergencyContact $emergencyContact): RedirectResponse
95+
{
96+
$emergencyContact->markAsPrimary();
97+
return redirect()->route('hr.employees.emergency-contacts.index', $employee);
98+
}
99+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,9 @@ public function scopeSearch($query, string $term)
109109
->orWhere('employee_number', 'like', "%{$term}%");
110110
});
111111
}
112+
113+
public function emergencyContacts(): HasMany
114+
{
115+
return $this->hasMany(EmployeeEmergencyContact::class);
116+
}
112117
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Models;
4+
5+
use Illuminate\Database\Eloquent\Casts\Attribute;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class EmployeeEmergencyContact extends Model
10+
{
11+
protected $fillable = [
12+
'employee_id',
13+
'name',
14+
'relationship',
15+
'phone_primary',
16+
'phone_secondary',
17+
'email',
18+
'address',
19+
'is_primary',
20+
'notes',
21+
];
22+
23+
protected $casts = [
24+
'is_primary' => 'boolean',
25+
];
26+
27+
protected $attributes = [
28+
'is_primary' => false,
29+
];
30+
31+
public function employee(): BelongsTo
32+
{
33+
return $this->belongsTo(Employee::class);
34+
}
35+
36+
public function markAsPrimary(): void
37+
{
38+
// Clear any existing primary on this employee
39+
static::where('employee_id', $this->employee_id)
40+
->where('id', '!=', $this->id)
41+
->update(['is_primary' => false]);
42+
43+
$this->is_primary = true;
44+
$this->save();
45+
}
46+
47+
protected function displayName(): Attribute
48+
{
49+
return Attribute::make(
50+
get: fn () => $this->name . ' (' . $this->relationship . ')',
51+
);
52+
}
53+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\HR\Models\EmployeeEmergencyContact;
7+
8+
class EmployeeEmergencyContactPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('hr.view');
13+
}
14+
15+
public function view(User $user, EmployeeEmergencyContact $contact): 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, EmployeeEmergencyContact $contact): bool
26+
{
27+
return $user->can('hr.create');
28+
}
29+
30+
public function markPrimary(User $user, EmployeeEmergencyContact $contact): bool
31+
{
32+
return $user->can('hr.create');
33+
}
34+
35+
public function delete(User $user, EmployeeEmergencyContact $contact): bool
36+
{
37+
return $user->can('hr.delete');
38+
}
39+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@
9494
use App\Modules\HR\Policies\MentorshipProgramPolicy;
9595
use App\Modules\HR\Models\InterviewSchedule;
9696
use App\Modules\HR\Policies\InterviewSchedulePolicy;
97+
use App\Modules\HR\Models\EmployeeEmergencyContact;
98+
use App\Modules\HR\Policies\EmployeeEmergencyContactPolicy;
9799
use Illuminate\Support\Facades\Gate;
98100
use Illuminate\Support\ServiceProvider;
99101

@@ -161,5 +163,6 @@ public function boot(): void
161163
Gate::policy(SuccessionCandidate::class, SuccessionPlanPolicy::class);
162164
Gate::policy(MentorshipProgram::class, MentorshipProgramPolicy::class);
163165
Gate::policy(InterviewSchedule::class, InterviewSchedulePolicy::class);
166+
Gate::policy(EmployeeEmergencyContact::class, EmployeeEmergencyContactPolicy::class);
164167
}
165168
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,3 +341,13 @@
341341
Route::post('interview-schedules/{interview_schedule}/no-show', [InterviewScheduleController::class, 'noShow'])->name('interview-schedules.no-show');
342342
Route::resource('interview-schedules', InterviewScheduleController::class);
343343
});
344+
345+
// Employee Emergency Contacts
346+
use App\Modules\HR\Http\Controllers\EmployeeEmergencyContactController;
347+
Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () {
348+
Route::post('employees/{employee}/emergency-contacts/{emergency_contact}/mark-primary',
349+
[EmployeeEmergencyContactController::class, 'markPrimary']
350+
)->name('employees.emergency-contacts.mark-primary');
351+
Route::resource('employees.emergency-contacts', EmployeeEmergencyContactController::class)
352+
->shallow();
353+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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('employee_emergency_contacts', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('employee_id')->constrained()->cascadeOnDelete();
14+
$table->string('name');
15+
$table->string('relationship');
16+
$table->string('phone_primary');
17+
$table->string('phone_secondary')->nullable();
18+
$table->string('email')->nullable();
19+
$table->text('address')->nullable();
20+
$table->boolean('is_primary')->default(false);
21+
$table->text('notes')->nullable();
22+
$table->timestamps();
23+
});
24+
}
25+
26+
public function down(): void
27+
{
28+
Schema::dropIfExists('employee_emergency_contacts');
29+
}
30+
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React from 'react';
2+
import { Head, useForm } from '@inertiajs/react';
3+
4+
interface Employee { id: number; first_name: string; last_name: string; }
5+
6+
export default function Create({ employee }: { employee: Employee }) {
7+
const { data, setData, post, errors } = useForm({
8+
name: '',
9+
relationship: '',
10+
phone_primary: '',
11+
phone_secondary: '',
12+
email: '',
13+
address: '',
14+
is_primary: false,
15+
notes: '',
16+
});
17+
18+
return (
19+
<>
20+
<Head title="Add Emergency Contact" />
21+
<div className="p-6 max-w-xl">
22+
<h1 className="text-2xl font-bold mb-1">Add Emergency Contact</h1>
23+
<p className="text-gray-500 mb-6">{employee.first_name} {employee.last_name}</p>
24+
<form onSubmit={e => { e.preventDefault(); post(`/hr/employees/${employee.id}/emergency-contacts`); }}>
25+
<div className="grid grid-cols-2 gap-4 mb-4">
26+
<div>
27+
<label className="block text-sm font-medium mb-1">Name *</label>
28+
<input value={data.name} onChange={e => setData('name', e.target.value)} className="w-full border rounded px-3 py-2" />
29+
{errors.name && <p className="text-red-600 text-sm">{errors.name}</p>}
30+
</div>
31+
<div>
32+
<label className="block text-sm font-medium mb-1">Relationship *</label>
33+
<input value={data.relationship} onChange={e => setData('relationship', e.target.value)} className="w-full border rounded px-3 py-2" placeholder="Spouse, Parent, Sibling..." />
34+
{errors.relationship && <p className="text-red-600 text-sm">{errors.relationship}</p>}
35+
</div>
36+
<div>
37+
<label className="block text-sm font-medium mb-1">Primary Phone *</label>
38+
<input value={data.phone_primary} onChange={e => setData('phone_primary', e.target.value)} className="w-full border rounded px-3 py-2" />
39+
{errors.phone_primary && <p className="text-red-600 text-sm">{errors.phone_primary}</p>}
40+
</div>
41+
<div>
42+
<label className="block text-sm font-medium mb-1">Secondary Phone</label>
43+
<input value={data.phone_secondary} onChange={e => setData('phone_secondary', e.target.value)} className="w-full border rounded px-3 py-2" />
44+
</div>
45+
<div className="col-span-2">
46+
<label className="block text-sm font-medium mb-1">Email</label>
47+
<input type="email" value={data.email} onChange={e => setData('email', e.target.value)} className="w-full border rounded px-3 py-2" />
48+
</div>
49+
</div>
50+
<div className="mb-4">
51+
<label className="flex items-center gap-2">
52+
<input type="checkbox" checked={data.is_primary} onChange={e => setData('is_primary', e.target.checked)} />
53+
<span className="text-sm">Set as primary contact</span>
54+
</label>
55+
</div>
56+
<button type="submit" className="bg-blue-600 text-white px-6 py-2 rounded">Add Contact</button>
57+
</form>
58+
</div>
59+
</>
60+
);
61+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from 'react';
2+
import { Head, useForm } from '@inertiajs/react';
3+
4+
interface Contact { id: number; name: string; relationship: string; phone_primary: string; phone_secondary: string | null; email: string | null; address: string | null; is_primary: boolean; notes: string | null; }
5+
6+
export default function Edit({ contact }: { contact: Contact }) {
7+
const { data, setData, put } = useForm({
8+
name: contact.name,
9+
relationship: contact.relationship,
10+
phone_primary: contact.phone_primary,
11+
phone_secondary: contact.phone_secondary ?? '',
12+
email: contact.email ?? '',
13+
address: contact.address ?? '',
14+
is_primary: contact.is_primary,
15+
notes: contact.notes ?? '',
16+
});
17+
18+
return (
19+
<>
20+
<Head title="Edit Emergency Contact" />
21+
<div className="p-6 max-w-xl">
22+
<h1 className="text-2xl font-bold mb-6">Edit Emergency Contact</h1>
23+
<form onSubmit={e => { e.preventDefault(); put(`/hr/emergency-contacts/${contact.id}`); }}>
24+
<div className="grid grid-cols-2 gap-4 mb-4">
25+
<div>
26+
<label className="block text-sm font-medium mb-1">Name *</label>
27+
<input value={data.name} onChange={e => setData('name', e.target.value)} className="w-full border rounded px-3 py-2" />
28+
</div>
29+
<div>
30+
<label className="block text-sm font-medium mb-1">Relationship *</label>
31+
<input value={data.relationship} onChange={e => setData('relationship', e.target.value)} className="w-full border rounded px-3 py-2" />
32+
</div>
33+
<div>
34+
<label className="block text-sm font-medium mb-1">Primary Phone *</label>
35+
<input value={data.phone_primary} onChange={e => setData('phone_primary', e.target.value)} className="w-full border rounded px-3 py-2" />
36+
</div>
37+
<div>
38+
<label className="block text-sm font-medium mb-1">Secondary Phone</label>
39+
<input value={data.phone_secondary} onChange={e => setData('phone_secondary', e.target.value)} className="w-full border rounded px-3 py-2" />
40+
</div>
41+
</div>
42+
<div className="mb-4">
43+
<label className="flex items-center gap-2">
44+
<input type="checkbox" checked={data.is_primary} onChange={e => setData('is_primary', e.target.checked)} />
45+
<span className="text-sm">Primary contact</span>
46+
</label>
47+
</div>
48+
<button type="submit" className="bg-blue-600 text-white px-6 py-2 rounded">Save</button>
49+
</form>
50+
</div>
51+
</>
52+
);
53+
}

0 commit comments

Comments
 (0)