Skip to content

Commit b95c8ee

Browse files
committed
feat(hr): Phase 83 — Employee Leave Management with approval workflow
Adds leave types, leave requests with approve/reject/cancel workflow, and leave balances with pending/used/remaining day tracking. Includes ALTER TABLE migrations for existing tables and a new leave_balances table. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent d26f662 commit b95c8ee

20 files changed

Lines changed: 1064 additions & 76 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\LeaveBalance;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class LeaveBalanceController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', LeaveBalance::class);
17+
18+
$balances = LeaveBalance::with(['employee', 'leaveType'])
19+
->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id))
20+
->when($request->year, fn ($q) => $q->where('year', $request->year))
21+
->latest()
22+
->paginate(25)
23+
->withQueryString();
24+
25+
return Inertia::render('HR/LeaveBalances/Index', [
26+
'balances' => $balances,
27+
'filters' => $request->only(['employee_id', 'year']),
28+
'breadcrumbs' => [
29+
['label' => 'HR'],
30+
['label' => 'Leave Balances', 'href' => route('hr.leave-balances.index')],
31+
],
32+
]);
33+
}
34+
35+
public function update(Request $request, LeaveBalance $leaveBalance): RedirectResponse
36+
{
37+
$this->authorize('update', $leaveBalance);
38+
39+
$data = $request->validate([
40+
'allocated_days' => 'required|numeric|min:0',
41+
]);
42+
43+
$leaveBalance->update($data);
44+
45+
return back()->with('success', 'Leave balance updated.');
46+
}
47+
}

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

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use App\Modules\HR\Http\Requests\StoreLeaveRequestRequest;
77
use App\Modules\HR\Http\Resources\LeaveRequestResource;
88
use App\Modules\HR\Models\Employee;
9+
use App\Modules\HR\Models\LeaveBalance;
910
use App\Modules\HR\Models\LeaveRequest;
1011
use App\Modules\HR\Models\LeaveType;
1112
use Illuminate\Http\RedirectResponse;
@@ -32,6 +33,7 @@ public function index(Request $request): Response
3233
'employees' => Employee::active()->orderBy('last_name')->get()->map(fn ($e) => [
3334
'id' => $e->id, 'full_name' => $e->full_name,
3435
]),
36+
'leaveTypes' => LeaveType::where('is_active', true)->orderBy('name')->get(['id', 'name']),
3537
'filters' => $request->only(['status', 'employee_id']),
3638
'breadcrumbs' => [
3739
['label' => 'HR'],
@@ -99,7 +101,7 @@ public function create(): Response
99101
'employees' => Employee::active()->orderBy('last_name')->get()->map(fn ($e) => [
100102
'id' => $e->id, 'full_name' => $e->full_name,
101103
]),
102-
'leaveTypes' => LeaveType::where('is_active', true)->orderBy('name')->get(['id', 'name']),
104+
'leaveTypes' => LeaveType::where('is_active', true)->orderBy('name')->get(['id', 'name', 'default_days']),
103105
'breadcrumbs' => [
104106
['label' => 'HR'],
105107
['label' => 'Leave Requests', 'href' => route('hr.leave-requests.index')],
@@ -108,17 +110,61 @@ public function create(): Response
108110
]);
109111
}
110112

111-
public function store(StoreLeaveRequestRequest $request): RedirectResponse
113+
public function store(Request $request): RedirectResponse
112114
{
113115
$this->authorize('create', LeaveRequest::class);
114116

115-
$data = $request->validated();
117+
$data = $request->validate([
118+
'employee_id' => 'required|integer|exists:employees,id',
119+
'leave_type_id' => 'required|integer|exists:leave_types,id',
120+
'start_date' => 'required|date',
121+
'end_date' => 'required|date|after_or_equal:start_date',
122+
'days_requested' => 'nullable|numeric|min:0.5',
123+
'reason' => 'nullable|string',
124+
]);
125+
126+
// Compute days_requested if not provided
127+
if (empty($data['days_requested'])) {
128+
$start = \Carbon\Carbon::parse($data['start_date']);
129+
$end = \Carbon\Carbon::parse($data['end_date']);
130+
$data['days_requested'] = max(0.5, $start->diffInDays($end) + 1);
131+
}
132+
133+
$daysRequested = (float) $data['days_requested'];
116134

117135
$leaveRequest = LeaveRequest::create([
118-
...$data,
119-
'tenant_id' => auth()->user()->tenant_id,
136+
'tenant_id' => auth()->user()->tenant_id,
137+
'employee_id' => $data['employee_id'],
138+
'leave_type_id' => $data['leave_type_id'],
139+
'start_date' => $data['start_date'],
140+
'end_date' => $data['end_date'],
141+
'days_requested' => $daysRequested,
142+
'days' => (int) $daysRequested,
143+
'reason' => $data['reason'] ?? null,
144+
'notes' => $data['reason'] ?? null,
145+
'status' => 'pending',
120146
]);
121147

148+
// Create or update LeaveBalance — increment pending_days
149+
$leaveType = LeaveType::find($data['leave_type_id']);
150+
$year = \Carbon\Carbon::parse($data['start_date'])->year;
151+
152+
$balance = LeaveBalance::firstOrCreate(
153+
[
154+
'employee_id' => $data['employee_id'],
155+
'leave_type_id' => $data['leave_type_id'],
156+
'year' => $year,
157+
],
158+
[
159+
'tenant_id' => auth()->user()->tenant_id,
160+
'allocated_days' => $leaveType?->default_days ?: ($leaveType?->days_per_year ?: 0),
161+
'used_days' => 0,
162+
'pending_days' => 0,
163+
]
164+
);
165+
166+
$balance->increment('pending_days', $daysRequested);
167+
122168
return redirect()->route('hr.leave-requests.show', $leaveRequest)
123169
->with('success', 'Leave request submitted.');
124170
}
@@ -127,7 +173,7 @@ public function show(LeaveRequest $leaveRequest): Response
127173
{
128174
$this->authorize('view', $leaveRequest);
129175

130-
$leaveRequest->load(['employee', 'leaveType', 'reviewer']);
176+
$leaveRequest->load(['employee', 'leaveType', 'approver', 'reviewer']);
131177

132178
return Inertia::render('HR/LeaveRequests/Show', [
133179
'leaveRequest' => new LeaveRequestResource($leaveRequest),
@@ -152,37 +198,42 @@ public function destroy(LeaveRequest $leaveRequest): RedirectResponse
152198
->with('success', 'Leave request deleted.');
153199
}
154200

155-
public function approve(LeaveRequest $leaveRequest): RedirectResponse
201+
public function approve(Request $request, LeaveRequest $leaveRequest): RedirectResponse
156202
{
157203
$this->authorize('update', $leaveRequest);
158204

159205
if ($leaveRequest->status !== 'pending') {
160206
return back()->withErrors(['status' => 'Only pending leave requests can be approved.']);
161207
}
162208

163-
$leaveRequest->update([
164-
'status' => 'approved',
165-
'reviewed_by' => Auth::id(),
166-
'reviewed_at' => now(),
167-
]);
209+
$leaveRequest->approve(auth()->user());
168210

169211
return back()->with('success', 'Leave request approved.');
170212
}
171213

172-
public function reject(LeaveRequest $leaveRequest): RedirectResponse
214+
public function reject(Request $request, LeaveRequest $leaveRequest): RedirectResponse
173215
{
174216
$this->authorize('update', $leaveRequest);
175217

176218
if ($leaveRequest->status !== 'pending') {
177219
return back()->withErrors(['status' => 'Only pending leave requests can be rejected.']);
178220
}
179221

180-
$leaveRequest->update([
181-
'status' => 'rejected',
182-
'reviewed_by' => Auth::id(),
183-
'reviewed_at' => now(),
222+
$data = $request->validate([
223+
'rejection_reason' => 'nullable|string',
184224
]);
185225

226+
$leaveRequest->reject(auth()->user(), $data['rejection_reason'] ?? '');
227+
186228
return back()->with('success', 'Leave request rejected.');
187229
}
230+
231+
public function cancel(Request $request, LeaveRequest $leaveRequest): RedirectResponse
232+
{
233+
$this->authorize('update', $leaveRequest);
234+
235+
$leaveRequest->cancel();
236+
237+
return back()->with('success', 'Leave request cancelled.');
238+
}
188239
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\LeaveType;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class LeaveTypeController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', LeaveType::class);
17+
18+
$leaveTypes = LeaveType::latest()->paginate(25)->withQueryString();
19+
20+
return Inertia::render('HR/LeaveTypes/Index', [
21+
'leaveTypes' => $leaveTypes,
22+
'breadcrumbs' => [
23+
['label' => 'HR'],
24+
['label' => 'Leave Types', 'href' => route('hr.leave-types.index')],
25+
],
26+
]);
27+
}
28+
29+
public function store(Request $request): RedirectResponse
30+
{
31+
$this->authorize('create', LeaveType::class);
32+
33+
$data = $request->validate([
34+
'name' => 'required|string|max:255',
35+
'code' => 'nullable|string|max:10',
36+
'default_days' => 'nullable|integer|min:0',
37+
'is_paid' => 'nullable|boolean',
38+
'requires_approval' => 'nullable|boolean',
39+
'description' => 'nullable|string',
40+
]);
41+
42+
LeaveType::create([
43+
'tenant_id' => auth()->user()->tenant_id,
44+
'name' => $data['name'],
45+
'code' => $data['code'] ?? null,
46+
'default_days' => $data['default_days'] ?? 0,
47+
'days_per_year' => $data['default_days'] ?? 0,
48+
'is_paid' => $data['is_paid'] ?? true,
49+
'requires_approval' => $data['requires_approval'] ?? true,
50+
'description' => $data['description'] ?? null,
51+
]);
52+
53+
return back()->with('success', 'Leave type created.');
54+
}
55+
56+
public function update(Request $request, LeaveType $leaveType): RedirectResponse
57+
{
58+
$this->authorize('update', $leaveType);
59+
60+
$data = $request->validate([
61+
'name' => 'required|string|max:255',
62+
'code' => 'nullable|string|max:10',
63+
'default_days' => 'nullable|integer|min:0',
64+
'is_paid' => 'nullable|boolean',
65+
'requires_approval' => 'nullable|boolean',
66+
'description' => 'nullable|string',
67+
]);
68+
69+
$leaveType->update([
70+
'name' => $data['name'],
71+
'code' => $data['code'] ?? $leaveType->code,
72+
'default_days' => $data['default_days'] ?? $leaveType->default_days,
73+
'days_per_year' => $data['default_days'] ?? $leaveType->days_per_year,
74+
'is_paid' => $data['is_paid'] ?? $leaveType->is_paid,
75+
'requires_approval' => $data['requires_approval'] ?? $leaveType->requires_approval,
76+
'description' => $data['description'] ?? $leaveType->description,
77+
]);
78+
79+
return back()->with('success', 'Leave type updated.');
80+
}
81+
82+
public function destroy(LeaveType $leaveType): RedirectResponse
83+
{
84+
$this->authorize('delete', $leaveType);
85+
86+
$leaveType->delete();
87+
88+
return back()->with('success', 'Leave type deleted.');
89+
}
90+
}

erp/app/Modules/HR/Http/Resources/LeaveRequestResource.php

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,34 @@ class LeaveRequestResource extends JsonResource
1010
public function toArray(Request $request): array
1111
{
1212
return [
13-
'id' => $this->id,
14-
'employee_id' => $this->employee_id,
15-
'employee' => $this->whenLoaded('employee', fn () => $this->employee
13+
'id' => $this->id,
14+
'employee_id' => $this->employee_id,
15+
'employee' => $this->whenLoaded('employee', fn () => $this->employee
1616
? ['id' => $this->employee->id, 'full_name' => $this->employee->full_name]
1717
: null),
18-
'leave_type_id' => $this->leave_type_id,
19-
'leave_type' => $this->whenLoaded('leaveType', fn () => $this->leaveType?->name),
20-
'type' => $this->leaveType?->name ?? 'other',
21-
'start_date' => $this->start_date?->toDateString(),
22-
'end_date' => $this->end_date?->toDateString(),
23-
'days' => $this->days,
24-
'reason' => $this->notes,
25-
'notes' => $this->notes,
26-
'status' => $this->status,
27-
'approved_by' => $this->reviewed_by,
28-
'approved_at' => $this->reviewed_at?->toDateTimeString(),
29-
'reviewed_by' => $this->reviewed_by,
30-
'reviewed_at' => $this->reviewed_at?->toDateTimeString(),
31-
'approver' => $this->whenLoaded('reviewer', fn () => $this->reviewer?->name),
32-
'created_at' => $this->created_at?->toDateString(),
18+
'leave_type_id' => $this->leave_type_id,
19+
'leave_type' => $this->whenLoaded('leaveType', fn () => $this->leaveType
20+
? ['id' => $this->leaveType->id, 'name' => $this->leaveType->name]
21+
: null),
22+
'type' => $this->leaveType?->name ?? 'other',
23+
'start_date' => $this->start_date?->toDateString(),
24+
'end_date' => $this->end_date?->toDateString(),
25+
'days' => $this->days,
26+
'days_requested' => (float) ($this->attributes['days_requested'] ?? $this->days),
27+
'reason' => $this->reason ?? $this->notes,
28+
'notes' => $this->notes,
29+
'rejection_reason' => $this->rejection_reason,
30+
'status' => $this->status,
31+
'approved_by' => $this->approved_by ?? $this->reviewed_by,
32+
'approved_at' => $this->approved_at?->toDateTimeString() ?? $this->reviewed_at?->toDateTimeString(),
33+
'reviewed_by' => $this->reviewed_by,
34+
'reviewed_at' => $this->reviewed_at?->toDateTimeString(),
35+
'approver' => $this->whenLoaded('approver', fn () => $this->approver
36+
? ['id' => $this->approver->id, 'name' => $this->approver->name]
37+
: null) ?? $this->whenLoaded('reviewer', fn () => $this->reviewer
38+
? ['id' => $this->reviewer->id, 'name' => $this->reviewer->name]
39+
: null),
40+
'created_at' => $this->created_at?->toDateString(),
3341
];
3442
}
3543
}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class Employee extends Model
1717
protected $fillable = [
1818
'tenant_id', 'user_id', 'department_id', 'employee_number',
1919
'first_name', 'last_name', 'email', 'phone', 'position',
20-
'employment_type', 'status', 'start_date', 'end_date',
20+
'employment_type', 'status', 'start_date', 'hire_date', 'end_date',
2121
'salary_type', 'salary_amount',
2222
];
2323

@@ -66,12 +66,18 @@ public function getCodeAttribute(): string
6666
return 'EMP-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
6767
}
6868

69-
/** hire_date alias for start_date */
69+
/** hire_date alias for start_date (getter) */
7070
public function getHireDateAttribute()
7171
{
7272
return $this->start_date;
7373
}
7474

75+
/** hire_date alias for start_date (setter) */
76+
public function setHireDateAttribute($value): void
77+
{
78+
$this->attributes['start_date'] = $value;
79+
}
80+
7581
/** salary alias for salary_amount */
7682
public function getSalaryAttribute()
7783
{

0 commit comments

Comments
 (0)