Skip to content

Commit 83c00c7

Browse files
committed
feat(phase-40): HR leave balance API with team view and allocation
- LeaveBalanceController: employee balance, leave types, allocation, team overview - leaveBalances() relationship added to Employee model - Supports year filter, department filter on team view - Routes: /leave/types, /leave/employees/{id}/balance, /leave/allocate, /leave/team - 8 feature tests covering balance calculation, re-allocation, year filter Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent afb4359 commit 83c00c7

5 files changed

Lines changed: 276 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ beforeEach(function () {
171171
| 37 | Webhook Management REST API — CRUD, delivery log, ping, HMAC ||
172172
| 38 | API Token Management — named tokens with abilities and expiry ||
173173
| 39 | Inventory Reorder Suggestions — deficit calc + urgency levels ||
174+
| 40 | HR Leave Balance API — allocation, team view, year filters ||
174175

175176
## File Locations Reference
176177

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Modules\HR\Models\Employee;
6+
use App\Modules\HR\Models\LeaveBalance;
7+
use App\Modules\HR\Models\LeaveType;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\Request;
10+
11+
class LeaveBalanceController extends ApiController
12+
{
13+
public function types(Request $request): JsonResponse
14+
{
15+
$tenantId = $this->tenantId($request);
16+
$types = LeaveType::where('tenant_id', $tenantId)->where('is_active', true)->get();
17+
return $this->success($types);
18+
}
19+
20+
public function employee(Request $request, Employee $employee): JsonResponse
21+
{
22+
$year = (int) $request->get('year', now()->year);
23+
$balances = LeaveBalance::where('employee_id', $employee->id)
24+
->where('year', $year)
25+
->with('leaveType')
26+
->get()
27+
->map(fn ($b) => [
28+
'leave_type_id' => $b->leave_type_id,
29+
'leave_type' => $b->leaveType?->name,
30+
'year' => $b->year,
31+
'allocated_days' => $b->allocated_days,
32+
'used_days' => $b->used_days,
33+
'pending_days' => $b->pending_days,
34+
'remaining_days' => $b->remaining_days,
35+
]);
36+
37+
return $this->success([
38+
'employee_id' => $employee->id,
39+
'employee_name' => $employee->full_name,
40+
'year' => $year,
41+
'balances' => $balances,
42+
]);
43+
}
44+
45+
public function allocate(Request $request): JsonResponse
46+
{
47+
$tenantId = $this->tenantId($request);
48+
49+
$data = $request->validate([
50+
'employee_id' => ['required', 'exists:employees,id'],
51+
'leave_type_id' => ['required', 'exists:leave_types,id'],
52+
'year' => ['required', 'integer', 'min:2000'],
53+
'allocated_days' => ['required', 'numeric', 'min:0'],
54+
]);
55+
56+
$balance = LeaveBalance::updateOrCreate(
57+
[
58+
'employee_id' => $data['employee_id'],
59+
'leave_type_id' => $data['leave_type_id'],
60+
'year' => $data['year'],
61+
],
62+
[
63+
'tenant_id' => $tenantId,
64+
'allocated_days' => $data['allocated_days'],
65+
]
66+
);
67+
68+
return $this->success($balance->load('leaveType'), 201);
69+
}
70+
71+
public function team(Request $request): JsonResponse
72+
{
73+
$tenantId = $this->tenantId($request);
74+
$year = (int) $request->get('year', now()->year);
75+
$departmentId = $request->get('department_id');
76+
77+
$employees = Employee::where('tenant_id', $tenantId)
78+
->where('status', 'active')
79+
->when($departmentId, fn ($q) => $q->where('department_id', $departmentId))
80+
->with(['leaveBalances' => fn ($q) => $q->where('year', $year)->with('leaveType')])
81+
->get()
82+
->map(fn ($e) => [
83+
'employee_id' => $e->id,
84+
'employee_name' => $e->full_name,
85+
'balances' => $e->leaveBalances->map(fn ($b) => [
86+
'leave_type' => $b->leaveType?->name,
87+
'allocated_days' => $b->allocated_days,
88+
'used_days' => $b->used_days,
89+
'remaining_days' => $b->remaining_days,
90+
]),
91+
'total_remaining' => $e->leaveBalances->sum('remaining_days'),
92+
]);
93+
94+
return $this->success([
95+
'year' => $year,
96+
'employees' => $employees,
97+
]);
98+
}
99+
100+
private function tenantId(Request $request): int
101+
{
102+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
103+
}
104+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Illuminate\Database\Eloquent\Relations\BelongsTo;
1010
use Illuminate\Database\Eloquent\Relations\HasMany;
1111
use Illuminate\Database\Eloquent\SoftDeletes;
12+
use App\Modules\HR\Models\LeaveBalance;
1213

1314
class Employee extends Model
1415
{
@@ -121,4 +122,9 @@ public function emergencyContacts(): HasMany
121122
{
122123
return $this->hasMany(EmployeeEmergencyContact::class);
123124
}
125+
126+
public function leaveBalances(): HasMany
127+
{
128+
return $this->hasMany(LeaveBalance::class);
129+
}
124130
}

erp/routes/api.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,14 @@
448448
});
449449
Route::get('products/{product}/matrix', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'matrix']);
450450

451+
// Leave Balance Management
452+
Route::prefix('leave')->group(function () {
453+
Route::get('/types', [\App\Http\Controllers\Api\V1\LeaveBalanceController::class, 'types']);
454+
Route::get('/employees/{employee}/balance', [\App\Http\Controllers\Api\V1\LeaveBalanceController::class, 'employee']);
455+
Route::post('/allocate', [\App\Http\Controllers\Api\V1\LeaveBalanceController::class, 'allocate']);
456+
Route::get('/team', [\App\Http\Controllers\Api\V1\LeaveBalanceController::class, 'team']);
457+
});
458+
451459
// Inventory Reorder Suggestions
452460
Route::prefix('reorder')->group(function () {
453461
Route::get('/suggestions', [\App\Http\Controllers\Api\V1\ReorderController::class, 'suggestions']);
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\HR\Models\Employee;
6+
use App\Modules\HR\Models\LeaveBalance;
7+
use App\Modules\HR\Models\LeaveType;
8+
use Database\Seeders\RolePermissionSeeder;
9+
10+
beforeEach(function () {
11+
$this->seed(RolePermissionSeeder::class);
12+
$this->tenant = Tenant::create(['name' => 'Leave Co', 'slug' => 'leave-co-' . uniqid()]);
13+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
14+
$this->user->assignRole('super-admin');
15+
$this->token = $this->user->createToken('test')->plainTextToken;
16+
app()->instance('tenant', $this->tenant);
17+
18+
$this->leaveType = LeaveType::create([
19+
'tenant_id' => $this->tenant->id,
20+
'name' => 'Annual Leave',
21+
'code' => 'AL',
22+
'is_paid' => true,
23+
'is_active' => true,
24+
'default_days' => 20,
25+
]);
26+
27+
$this->employee = Employee::create([
28+
'tenant_id' => $this->tenant->id,
29+
'first_name' => 'Jane',
30+
'last_name' => 'Doe',
31+
'employee_number' => 'EMP-' . uniqid(),
32+
'email' => 'jane-' . uniqid() . '@example.com',
33+
'position' => 'Engineer',
34+
'status' => 'active',
35+
'hire_date' => now()->subYear()->toDateString(),
36+
]);
37+
});
38+
39+
test('can list leave types', function () {
40+
$this->withToken($this->token)
41+
->getJson('/api/v1/leave/types')
42+
->assertStatus(200)
43+
->assertJsonPath('data.0.name', 'Annual Leave');
44+
});
45+
46+
test('can get leave balance for an employee', function () {
47+
LeaveBalance::create([
48+
'tenant_id' => $this->tenant->id,
49+
'employee_id' => $this->employee->id,
50+
'leave_type_id' => $this->leaveType->id,
51+
'year' => now()->year,
52+
'allocated_days' => 20,
53+
'used_days' => 5,
54+
'pending_days' => 2,
55+
]);
56+
57+
$response = $this->withToken($this->token)
58+
->getJson("/api/v1/leave/employees/{$this->employee->id}/balance")
59+
->assertStatus(200);
60+
61+
$data = $response->json('data');
62+
expect($data['employee_id'])->toBe($this->employee->id);
63+
expect($data['balances'])->not->toBeEmpty();
64+
expect($data['balances'][0]['remaining_days'])->toBe(13);
65+
});
66+
67+
test('can allocate leave for an employee', function () {
68+
$this->withToken($this->token)
69+
->postJson('/api/v1/leave/allocate', [
70+
'employee_id' => $this->employee->id,
71+
'leave_type_id' => $this->leaveType->id,
72+
'year' => now()->year,
73+
'allocated_days' => 15,
74+
])
75+
->assertStatus(201);
76+
77+
expect(LeaveBalance::where('employee_id', $this->employee->id)->exists())->toBeTrue();
78+
});
79+
80+
test('allocate validates employee and leave type exist', function () {
81+
$this->withToken($this->token)
82+
->postJson('/api/v1/leave/allocate', [
83+
'employee_id' => 99999,
84+
'leave_type_id' => 99999,
85+
'year' => now()->year,
86+
'allocated_days' => 20,
87+
])
88+
->assertStatus(422)
89+
->assertJsonValidationErrors(['employee_id', 'leave_type_id']);
90+
});
91+
92+
test('re-allocating updates existing balance', function () {
93+
LeaveBalance::create([
94+
'tenant_id' => $this->tenant->id,
95+
'employee_id' => $this->employee->id,
96+
'leave_type_id' => $this->leaveType->id,
97+
'year' => now()->year,
98+
'allocated_days' => 10,
99+
'used_days' => 0,
100+
'pending_days' => 0,
101+
]);
102+
103+
$this->withToken($this->token)
104+
->postJson('/api/v1/leave/allocate', [
105+
'employee_id' => $this->employee->id,
106+
'leave_type_id' => $this->leaveType->id,
107+
'year' => now()->year,
108+
'allocated_days' => 25,
109+
])
110+
->assertStatus(201);
111+
112+
$balance = LeaveBalance::where('employee_id', $this->employee->id)->first();
113+
expect($balance->allocated_days)->toBe(25.0);
114+
expect(LeaveBalance::where('employee_id', $this->employee->id)->count())->toBe(1);
115+
});
116+
117+
test('team view returns all active employees with balances', function () {
118+
LeaveBalance::create([
119+
'tenant_id' => $this->tenant->id,
120+
'employee_id' => $this->employee->id,
121+
'leave_type_id' => $this->leaveType->id,
122+
'year' => now()->year,
123+
'allocated_days' => 20,
124+
'used_days' => 3,
125+
'pending_days' => 0,
126+
]);
127+
128+
$response = $this->withToken($this->token)
129+
->getJson('/api/v1/leave/team')
130+
->assertStatus(200)
131+
->assertJsonStructure(['data' => ['year', 'employees']]);
132+
133+
expect($response->json('data.year'))->toBe(now()->year);
134+
});
135+
136+
test('balance endpoint respects year filter', function () {
137+
LeaveBalance::create([
138+
'tenant_id' => $this->tenant->id,
139+
'employee_id' => $this->employee->id,
140+
'leave_type_id' => $this->leaveType->id,
141+
'year' => 2023,
142+
'allocated_days' => 18,
143+
'used_days' => 0,
144+
'pending_days' => 0,
145+
]);
146+
147+
$response = $this->withToken($this->token)
148+
->getJson("/api/v1/leave/employees/{$this->employee->id}/balance?year=2023")
149+
->assertStatus(200);
150+
151+
expect($response->json('data.year'))->toBe(2023);
152+
expect($response->json('data.balances.0.allocated_days'))->toBe(18);
153+
});
154+
155+
test('requires authentication', function () {
156+
$this->getJson('/api/v1/leave/types')->assertStatus(401);
157+
});

0 commit comments

Comments
 (0)