Skip to content

Commit a309069

Browse files
committed
feat(hr): Phase 89 — Disciplinary Cases & Grievance Management
Implements HR Disciplinary & Grievance Management module with migrations, models (DisciplinaryCase, Grievance), controllers, DisciplinaryPolicy, routes, TypeScript types, React pages (Index/Create/Show), Sidebar links, and 10 Pest tests (931 → 941 total passing). https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent e1fa812 commit a309069

18 files changed

Lines changed: 1611 additions & 0 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\DisciplinaryCase;
7+
use App\Modules\HR\Models\Employee;
8+
use Carbon\Carbon;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class DisciplinaryCaseController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', DisciplinaryCase::class);
19+
20+
$cases = DisciplinaryCase::with(['employee', 'handledBy'])
21+
->when($request->status, fn ($q) => $q->where('status', $request->status))
22+
->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id))
23+
->orderBy('created_at', 'desc')
24+
->paginate(20)
25+
->withQueryString();
26+
27+
return Inertia::render('HR/DisciplinaryCases/Index', [
28+
'cases' => $cases,
29+
'filters' => $request->only(['status', 'employee_id']),
30+
]);
31+
}
32+
33+
public function create(): Response
34+
{
35+
$this->authorize('create', DisciplinaryCase::class);
36+
37+
$employees = Employee::where('status', 'active')
38+
->orderBy('last_name')
39+
->get(['id', 'first_name', 'last_name']);
40+
41+
return Inertia::render('HR/DisciplinaryCases/Create', [
42+
'employees' => $employees,
43+
]);
44+
}
45+
46+
public function store(Request $request): RedirectResponse
47+
{
48+
$this->authorize('create', DisciplinaryCase::class);
49+
50+
$validated = $request->validate([
51+
'employee_id' => 'required|exists:employees,id',
52+
'incident_type' => 'required|in:misconduct,poor_performance,attendance,policy_violation,other',
53+
'incident_date' => 'required|date',
54+
'description' => 'required|string',
55+
'severity' => 'required|in:minor,moderate,major,gross',
56+
]);
57+
58+
$case = DisciplinaryCase::create([
59+
'tenant_id' => auth()->user()->tenant_id,
60+
'employee_id' => $validated['employee_id'],
61+
'incident_type' => $validated['incident_type'],
62+
'incident_date' => $validated['incident_date'],
63+
'description' => $validated['description'],
64+
'severity' => $validated['severity'],
65+
'status' => 'open',
66+
'handled_by' => auth()->id(),
67+
]);
68+
69+
$case->update([
70+
'reference' => 'DISC-' . now()->year . '-' . str_pad($case->id, 4, '0', STR_PAD_LEFT),
71+
]);
72+
73+
return redirect()->route('hr.disciplinary-cases.show', $case);
74+
}
75+
76+
public function show(DisciplinaryCase $disciplinaryCase): Response
77+
{
78+
$this->authorize('view', $disciplinaryCase);
79+
80+
$disciplinaryCase->load(['employee', 'handledBy']);
81+
82+
return Inertia::render('HR/DisciplinaryCases/Show', [
83+
'disciplinaryCase' => $disciplinaryCase,
84+
]);
85+
}
86+
87+
public function destroy(DisciplinaryCase $disciplinaryCase): RedirectResponse
88+
{
89+
$this->authorize('delete', $disciplinaryCase);
90+
91+
$disciplinaryCase->delete();
92+
93+
return redirect()->route('hr.disciplinary-cases.index');
94+
}
95+
96+
public function scheduleHearing(Request $request, DisciplinaryCase $disciplinaryCase): RedirectResponse
97+
{
98+
$this->authorize('update', $disciplinaryCase);
99+
100+
$validated = $request->validate([
101+
'hearing_date' => 'required|date|after:today',
102+
]);
103+
104+
$disciplinaryCase->scheduleHearing(Carbon::parse($validated['hearing_date']));
105+
106+
return redirect()->back();
107+
}
108+
109+
public function resolve(Request $request, DisciplinaryCase $disciplinaryCase): RedirectResponse
110+
{
111+
$this->authorize('update', $disciplinaryCase);
112+
113+
$validated = $request->validate([
114+
'outcome' => 'required|in:warning,final_warning,suspension,dismissal,no_action',
115+
'outcome_notes' => 'nullable|string',
116+
]);
117+
118+
$disciplinaryCase->resolve($validated['outcome'], $validated['outcome_notes'] ?? null);
119+
120+
return redirect()->back();
121+
}
122+
123+
public function close(Request $request, DisciplinaryCase $disciplinaryCase): RedirectResponse
124+
{
125+
$this->authorize('update', $disciplinaryCase);
126+
127+
$disciplinaryCase->close();
128+
129+
return redirect()->back();
130+
}
131+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\Employee;
7+
use App\Modules\HR\Models\Grievance;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class GrievanceController extends Controller
14+
{
15+
public function index(Request $request): Response
16+
{
17+
$this->authorize('viewAny', Grievance::class);
18+
19+
$grievances = Grievance::with(['employee', 'assignedTo'])
20+
->when($request->status, fn ($q) => $q->where('status', $request->status))
21+
->orderBy('created_at', 'desc')
22+
->paginate(20)
23+
->withQueryString();
24+
25+
return Inertia::render('HR/Grievances/Index', [
26+
'grievances' => $grievances,
27+
'filters' => $request->only(['status']),
28+
]);
29+
}
30+
31+
public function create(): Response
32+
{
33+
$this->authorize('create', Grievance::class);
34+
35+
$employees = Employee::where('status', 'active')
36+
->orderBy('last_name')
37+
->get(['id', 'first_name', 'last_name']);
38+
39+
return Inertia::render('HR/Grievances/Create', [
40+
'employees' => $employees,
41+
]);
42+
}
43+
44+
public function store(Request $request): RedirectResponse
45+
{
46+
$this->authorize('create', Grievance::class);
47+
48+
$validated = $request->validate([
49+
'employee_id' => 'required|exists:employees,id',
50+
'category' => 'required|string',
51+
'description' => 'required|string',
52+
'submitted_date' => 'required|date',
53+
'is_anonymous' => 'nullable|boolean',
54+
]);
55+
56+
$grievance = Grievance::create([
57+
'tenant_id' => auth()->user()->tenant_id,
58+
'employee_id' => $validated['employee_id'],
59+
'category' => $validated['category'],
60+
'description' => $validated['description'],
61+
'submitted_date' => $validated['submitted_date'],
62+
'is_anonymous' => $validated['is_anonymous'] ?? false,
63+
'status' => 'submitted',
64+
]);
65+
66+
$grievance->update([
67+
'reference' => 'GRV-' . now()->year . '-' . str_pad($grievance->id, 4, '0', STR_PAD_LEFT),
68+
]);
69+
70+
return redirect()->route('hr.grievances.show', $grievance);
71+
}
72+
73+
public function show(Grievance $grievance): Response
74+
{
75+
$this->authorize('view', $grievance);
76+
77+
$grievance->load(['employee', 'assignedTo']);
78+
79+
return Inertia::render('HR/Grievances/Show', [
80+
'grievance' => $grievance,
81+
]);
82+
}
83+
84+
public function destroy(Grievance $grievance): RedirectResponse
85+
{
86+
$this->authorize('delete', $grievance);
87+
88+
$grievance->delete();
89+
90+
return redirect()->route('hr.grievances.index');
91+
}
92+
93+
public function assign(Request $request, Grievance $grievance): RedirectResponse
94+
{
95+
$this->authorize('update', $grievance);
96+
97+
$validated = $request->validate([
98+
'assigned_to' => 'required|integer|exists:users,id',
99+
]);
100+
101+
$grievance->assign($validated['assigned_to']);
102+
103+
return redirect()->back();
104+
}
105+
106+
public function resolve(Request $request, Grievance $grievance): RedirectResponse
107+
{
108+
$this->authorize('update', $grievance);
109+
110+
$validated = $request->validate([
111+
'resolution' => 'required|string',
112+
]);
113+
114+
$grievance->resolve($validated['resolution']);
115+
116+
return redirect()->back();
117+
}
118+
119+
public function close(Request $request, Grievance $grievance): RedirectResponse
120+
{
121+
$this->authorize('update', $grievance);
122+
123+
$grievance->close();
124+
125+
return redirect()->back();
126+
}
127+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Carbon\Carbon;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class DisciplinaryCase extends Model
13+
{
14+
use BelongsToTenant;
15+
use SoftDeletes;
16+
17+
protected $fillable = [
18+
'tenant_id', 'employee_id', 'reference', 'incident_type',
19+
'incident_date', 'description', 'severity', 'status',
20+
'outcome', 'outcome_notes', 'handled_by', 'hearing_date', 'resolved_date',
21+
];
22+
23+
protected $casts = [
24+
'incident_date' => 'date',
25+
'hearing_date' => 'date',
26+
'resolved_date' => 'date',
27+
];
28+
29+
public function employee(): BelongsTo
30+
{
31+
return $this->belongsTo(Employee::class);
32+
}
33+
34+
public function handledBy(): BelongsTo
35+
{
36+
return $this->belongsTo(User::class, 'handled_by');
37+
}
38+
39+
public function scheduleHearing(Carbon $date): void
40+
{
41+
$this->hearing_date = $date;
42+
$this->status = 'hearing_scheduled';
43+
$this->save();
44+
}
45+
46+
public function resolve(string $outcome, ?string $notes = null): void
47+
{
48+
$this->outcome = $outcome;
49+
$this->outcome_notes = $notes;
50+
$this->status = 'resolved';
51+
$this->resolved_date = now()->toDateString();
52+
$this->save();
53+
}
54+
55+
public function close(): void
56+
{
57+
$this->status = 'closed';
58+
$this->save();
59+
}
60+
61+
public function getIsOpenAttribute(): bool
62+
{
63+
return !in_array($this->status, ['resolved', 'closed']);
64+
}
65+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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 Grievance extends Model
12+
{
13+
use BelongsToTenant;
14+
use SoftDeletes;
15+
16+
protected $fillable = [
17+
'tenant_id', 'employee_id', 'reference', 'category',
18+
'description', 'status', 'resolution', 'is_anonymous',
19+
'assigned_to', 'submitted_date', 'resolved_date',
20+
];
21+
22+
protected $casts = [
23+
'submitted_date' => 'date',
24+
'resolved_date' => 'date',
25+
'is_anonymous' => 'boolean',
26+
];
27+
28+
public function employee(): BelongsTo
29+
{
30+
return $this->belongsTo(Employee::class);
31+
}
32+
33+
public function assignedTo(): BelongsTo
34+
{
35+
return $this->belongsTo(User::class, 'assigned_to');
36+
}
37+
38+
public function resolve(string $resolution): void
39+
{
40+
$this->resolution = $resolution;
41+
$this->status = 'resolved';
42+
$this->resolved_date = now()->toDateString();
43+
$this->save();
44+
}
45+
46+
public function close(): void
47+
{
48+
$this->status = 'closed';
49+
$this->save();
50+
}
51+
52+
public function assign(int $userId): void
53+
{
54+
$this->assigned_to = $userId;
55+
$this->status = 'under_review';
56+
$this->save();
57+
}
58+
}

0 commit comments

Comments
 (0)