Skip to content

Commit a925307

Browse files
committed
Phases 48-49: Employee Self-Service & Contract Management APIs
- Phase 48: /me endpoints let an authenticated user view their employee profile, update phone, see leave requests, apply for leave with date validation, view payslips, expense claims, and a dashboard summary - Phase 49: Full contract CRUD with activate/terminate/renew lifecycle actions, expiring-soon dashboard endpoint, soft delete, and tenant isolation Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 36c9566 commit a925307

5 files changed

Lines changed: 642 additions & 0 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Modules\Finance\Models\Contract;
6+
use Illuminate\Http\JsonResponse;
7+
use Illuminate\Http\Request;
8+
9+
class ContractApiController extends ApiController
10+
{
11+
public function index(Request $request): JsonResponse
12+
{
13+
$tenantId = $this->tenantId($request);
14+
15+
$contracts = Contract::where('tenant_id', $tenantId)
16+
->when($request->status, fn ($q) => $q->where('status', $request->status))
17+
->when($request->type, fn ($q) => $q->where('type', $request->type))
18+
->when($request->contact_id, fn ($q) => $q->where('contact_id', $request->contact_id))
19+
->when($request->boolean('expiring_soon'), fn ($q) => $q->expiringSoon())
20+
->with('contact:id,name')
21+
->orderByDesc('created_at')
22+
->paginate(20);
23+
24+
return $this->paginated($contracts);
25+
}
26+
27+
public function store(Request $request): JsonResponse
28+
{
29+
$tenantId = $this->tenantId($request);
30+
31+
$data = $request->validate([
32+
'contact_id' => ['nullable', 'integer', 'exists:contacts,id'],
33+
'title' => ['required', 'string', 'max:255'],
34+
'type' => ['required', 'string', 'in:client,vendor,employment,nda,other'],
35+
'value' => ['nullable', 'numeric', 'min:0'],
36+
'currency_code' => ['nullable', 'string', 'max:3'],
37+
'start_date' => ['nullable', 'date'],
38+
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
39+
'auto_renew' => ['boolean'],
40+
'renewal_notice_days' => ['nullable', 'integer', 'min:1'],
41+
'description' => ['nullable', 'string'],
42+
'terms' => ['nullable', 'string'],
43+
'notes' => ['nullable', 'string'],
44+
'party_name' => ['nullable', 'string', 'max:255'],
45+
'party_email' => ['nullable', 'email'],
46+
]);
47+
48+
$contract = Contract::create([
49+
...$data,
50+
'tenant_id' => $tenantId,
51+
'contract_number' => Contract::generateContractNumber(),
52+
'status' => 'draft',
53+
'created_by' => $request->user()->id,
54+
]);
55+
56+
return $this->success($contract, 201);
57+
}
58+
59+
public function show(Contract $contract): JsonResponse
60+
{
61+
return $this->success($contract->load(['contact:id,name', 'renewals', 'createdBy:id,name']));
62+
}
63+
64+
public function update(Request $request, Contract $contract): JsonResponse
65+
{
66+
$data = $request->validate([
67+
'title' => ['sometimes', 'string', 'max:255'],
68+
'type' => ['sometimes', 'string', 'in:client,vendor,employment,nda,other'],
69+
'value' => ['nullable', 'numeric', 'min:0'],
70+
'start_date' => ['nullable', 'date'],
71+
'end_date' => ['nullable', 'date'],
72+
'auto_renew' => ['boolean'],
73+
'renewal_notice_days' => ['nullable', 'integer', 'min:1'],
74+
'description' => ['nullable', 'string'],
75+
'terms' => ['nullable', 'string'],
76+
'notes' => ['nullable', 'string'],
77+
]);
78+
79+
$contract->update($data);
80+
81+
return $this->success($contract->fresh());
82+
}
83+
84+
public function activate(Contract $contract): JsonResponse
85+
{
86+
$contract->activate();
87+
return $this->success($contract->fresh());
88+
}
89+
90+
public function terminate(Request $request, Contract $contract): JsonResponse
91+
{
92+
$data = $request->validate([
93+
'notes' => ['nullable', 'string', 'max:500'],
94+
]);
95+
96+
$contract->terminate($data['notes'] ?? '');
97+
98+
return $this->success($contract->fresh());
99+
}
100+
101+
public function renew(Request $request, Contract $contract): JsonResponse
102+
{
103+
$data = $request->validate([
104+
'new_end_date' => ['required', 'date', 'after:today'],
105+
'new_value' => ['nullable', 'numeric', 'min:0'],
106+
'notes' => ['nullable', 'string', 'max:500'],
107+
]);
108+
109+
$contract->renew(
110+
$data['new_end_date'],
111+
$data['new_value'] ?? null,
112+
$data['notes'] ?? '',
113+
$request->user()->id,
114+
);
115+
116+
return $this->success($contract->fresh()->load('renewals'));
117+
}
118+
119+
public function expiringSoon(Request $request): JsonResponse
120+
{
121+
$tenantId = $this->tenantId($request);
122+
$days = (int) $request->get('days', 30);
123+
124+
$contracts = Contract::where('tenant_id', $tenantId)
125+
->where('status', 'active')
126+
->whereBetween('end_date', [now(), now()->addDays($days)])
127+
->with('contact:id,name')
128+
->orderBy('end_date')
129+
->get()
130+
->map(fn ($c) => [
131+
'id' => $c->id,
132+
'contract_number' => $c->contract_number,
133+
'title' => $c->title,
134+
'contact_name' => $c->contact?->name,
135+
'end_date' => $c->end_date?->toDateString(),
136+
'days_remaining' => $c->days_remaining,
137+
'value' => $c->value,
138+
]);
139+
140+
return $this->success(['days' => $days, 'contracts' => $contracts]);
141+
}
142+
143+
public function destroy(Contract $contract): JsonResponse
144+
{
145+
$contract->delete();
146+
return $this->success(['message' => 'Contract deleted.']);
147+
}
148+
149+
private function tenantId(Request $request): int
150+
{
151+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
152+
}
153+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Modules\HR\Models\Employee;
6+
use App\Modules\HR\Models\ExpenseClaim;
7+
use App\Modules\HR\Models\LeaveRequest;
8+
use App\Modules\HR\Models\Payslip;
9+
use Illuminate\Http\JsonResponse;
10+
use Illuminate\Http\Request;
11+
12+
class SelfServiceController extends ApiController
13+
{
14+
public function profile(Request $request): JsonResponse
15+
{
16+
$employee = $this->resolveEmployee($request);
17+
18+
if (! $employee) {
19+
return $this->error('No employee profile linked to your account.', 404);
20+
}
21+
22+
return $this->success($employee->load(['department:id,name', 'leaveBalances.leaveType:id,name']));
23+
}
24+
25+
public function updateProfile(Request $request): JsonResponse
26+
{
27+
$employee = $this->resolveEmployee($request);
28+
29+
if (! $employee) {
30+
return $this->error('No employee profile linked to your account.', 404);
31+
}
32+
33+
$data = $request->validate([
34+
'phone' => ['nullable', 'string', 'max:50'],
35+
]);
36+
37+
$employee->update($data);
38+
39+
return $this->success($employee->fresh());
40+
}
41+
42+
public function payslips(Request $request): JsonResponse
43+
{
44+
$employee = $this->resolveEmployee($request);
45+
46+
if (! $employee) {
47+
return $this->error('No employee profile linked to your account.', 404);
48+
}
49+
50+
$payslips = Payslip::where('employee_id', $employee->id)
51+
->with('payrollRun:id,period_start,period_end,status')
52+
->orderByDesc('created_at')
53+
->paginate(12);
54+
55+
return $this->paginated($payslips);
56+
}
57+
58+
public function leaveRequests(Request $request): JsonResponse
59+
{
60+
$employee = $this->resolveEmployee($request);
61+
62+
if (! $employee) {
63+
return $this->error('No employee profile linked to your account.', 404);
64+
}
65+
66+
$requests = LeaveRequest::where('employee_id', $employee->id)
67+
->when($request->status, fn ($q) => $q->where('status', $request->status))
68+
->with('leaveType:id,name')
69+
->orderByDesc('created_at')
70+
->paginate(20);
71+
72+
return $this->paginated($requests);
73+
}
74+
75+
public function applyLeave(Request $request): JsonResponse
76+
{
77+
$employee = $this->resolveEmployee($request);
78+
79+
if (! $employee) {
80+
return $this->error('No employee profile linked to your account.', 404);
81+
}
82+
83+
$data = $request->validate([
84+
'leave_type_id' => ['required', 'integer', 'exists:leave_types,id'],
85+
'start_date' => ['required', 'date', 'after_or_equal:today'],
86+
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
87+
'reason' => ['nullable', 'string', 'max:500'],
88+
]);
89+
90+
$days = (int) now()->parse($data['start_date'])->diffInWeekdays(now()->parse($data['end_date'])) + 1;
91+
92+
$leaveRequest = LeaveRequest::create([
93+
'tenant_id' => $employee->tenant_id,
94+
'employee_id' => $employee->id,
95+
'leave_type_id' => $data['leave_type_id'],
96+
'start_date' => $data['start_date'],
97+
'end_date' => $data['end_date'],
98+
'days' => $days,
99+
'days_requested' => $days,
100+
'reason' => $data['reason'] ?? null,
101+
'notes' => $data['reason'] ?? null,
102+
'status' => 'pending',
103+
]);
104+
105+
return $this->success($leaveRequest->load('leaveType:id,name'), 201);
106+
}
107+
108+
public function expenseClaims(Request $request): JsonResponse
109+
{
110+
$employee = $this->resolveEmployee($request);
111+
112+
if (! $employee) {
113+
return $this->error('No employee profile linked to your account.', 404);
114+
}
115+
116+
$claims = ExpenseClaim::where('employee_id', $employee->id)
117+
->when($request->status, fn ($q) => $q->where('status', $request->status))
118+
->with('items')
119+
->orderByDesc('created_at')
120+
->paginate(20);
121+
122+
return $this->paginated($claims);
123+
}
124+
125+
public function summary(Request $request): JsonResponse
126+
{
127+
$employee = $this->resolveEmployee($request);
128+
129+
if (! $employee) {
130+
return $this->error('No employee profile linked to your account.', 404);
131+
}
132+
133+
$pendingLeave = LeaveRequest::where('employee_id', $employee->id)->where('status', 'pending')->count();
134+
$pendingExpense = ExpenseClaim::where('employee_id', $employee->id)->where('status', 'submitted')->count();
135+
$totalPayslips = Payslip::where('employee_id', $employee->id)->count();
136+
137+
return $this->success([
138+
'employee_id' => $employee->id,
139+
'name' => $employee->full_name,
140+
'position' => $employee->position,
141+
'department' => $employee->department?->name,
142+
'pending_leave_requests' => $pendingLeave,
143+
'pending_expense_claims' => $pendingExpense,
144+
'total_payslips' => $totalPayslips,
145+
]);
146+
}
147+
148+
private function resolveEmployee(Request $request): ?Employee
149+
{
150+
return Employee::where('user_id', $request->user()->id)->first();
151+
}
152+
}

erp/routes/api.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,30 @@
456456
Route::post('/{contact}/evaluate', [\App\Http\Controllers\Api\V1\VendorPerformanceController::class, 'evaluate']);
457457
});
458458

459+
// Contract Management
460+
Route::prefix('contracts')->group(function () {
461+
Route::get('/expiring-soon', [\App\Http\Controllers\Api\V1\ContractApiController::class, 'expiringSoon']);
462+
Route::get('/', [\App\Http\Controllers\Api\V1\ContractApiController::class, 'index']);
463+
Route::post('/', [\App\Http\Controllers\Api\V1\ContractApiController::class, 'store']);
464+
Route::get('/{contract}', [\App\Http\Controllers\Api\V1\ContractApiController::class, 'show']);
465+
Route::put('/{contract}', [\App\Http\Controllers\Api\V1\ContractApiController::class, 'update']);
466+
Route::delete('/{contract}', [\App\Http\Controllers\Api\V1\ContractApiController::class, 'destroy']);
467+
Route::post('/{contract}/activate', [\App\Http\Controllers\Api\V1\ContractApiController::class, 'activate']);
468+
Route::post('/{contract}/terminate', [\App\Http\Controllers\Api\V1\ContractApiController::class, 'terminate']);
469+
Route::post('/{contract}/renew', [\App\Http\Controllers\Api\V1\ContractApiController::class, 'renew']);
470+
});
471+
472+
// Employee Self-Service
473+
Route::prefix('me')->group(function () {
474+
Route::get('/', [\App\Http\Controllers\Api\V1\SelfServiceController::class, 'profile']);
475+
Route::put('/', [\App\Http\Controllers\Api\V1\SelfServiceController::class, 'updateProfile']);
476+
Route::get('/summary', [\App\Http\Controllers\Api\V1\SelfServiceController::class, 'summary']);
477+
Route::get('/payslips', [\App\Http\Controllers\Api\V1\SelfServiceController::class, 'payslips']);
478+
Route::get('/leave-requests', [\App\Http\Controllers\Api\V1\SelfServiceController::class, 'leaveRequests']);
479+
Route::post('/leave-requests', [\App\Http\Controllers\Api\V1\SelfServiceController::class, 'applyLeave']);
480+
Route::get('/expense-claims', [\App\Http\Controllers\Api\V1\SelfServiceController::class, 'expenseClaims']);
481+
});
482+
459483
// Tenant Settings API
460484
Route::prefix('settings')->group(function () {
461485
Route::get('/', [\App\Http\Controllers\Api\V1\TenantSettingsController::class, 'index']);

0 commit comments

Comments
 (0)