Skip to content

Commit 265728b

Browse files
committed
feat: Phase 25 — Employee Expense Claims (submit, approve, reject, reimburse)
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 75749b2 commit 265728b

14 files changed

Lines changed: 1097 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Http\Requests\StoreExpenseClaimRequest;
7+
use App\Modules\HR\Http\Resources\ExpenseClaimResource;
8+
use App\Modules\HR\Models\Employee;
9+
use App\Modules\HR\Models\ExpenseClaim;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Illuminate\Support\Facades\Auth;
13+
use Inertia\Inertia;
14+
use Inertia\Response;
15+
16+
class ExpenseClaimController extends Controller
17+
{
18+
const CATEGORIES = ['travel', 'meals', 'supplies', 'accommodation', 'other'];
19+
20+
public function index(Request $request): Response
21+
{
22+
$this->authorize('viewAny', ExpenseClaim::class);
23+
24+
$claims = ExpenseClaim::with(['employee', 'reviewer'])
25+
->when($request->status, fn ($q) => $q->where('status', $request->status))
26+
->orderBy('created_at', 'desc')
27+
->paginate(25)
28+
->withQueryString();
29+
30+
return Inertia::render('HR/ExpenseClaims/Index', [
31+
'claims' => ExpenseClaimResource::collection($claims),
32+
'filters' => $request->only(['status']),
33+
'categories' => self::CATEGORIES,
34+
'breadcrumbs' => [
35+
['label' => 'HR'],
36+
['label' => 'Expense Claims', 'href' => route('hr.expense-claims.index')],
37+
],
38+
]);
39+
}
40+
41+
public function create(): Response
42+
{
43+
$this->authorize('create', ExpenseClaim::class);
44+
45+
return Inertia::render('HR/ExpenseClaims/Create', [
46+
'employees' => Employee::active()->orderBy('last_name')->get()->map(fn ($e) => [
47+
'id' => $e->id, 'full_name' => $e->full_name,
48+
]),
49+
'categories' => self::CATEGORIES,
50+
'breadcrumbs' => [
51+
['label' => 'HR'],
52+
['label' => 'Expense Claims', 'href' => route('hr.expense-claims.index')],
53+
['label' => 'New Claim'],
54+
],
55+
]);
56+
}
57+
58+
public function store(StoreExpenseClaimRequest $request): RedirectResponse
59+
{
60+
$this->authorize('create', ExpenseClaim::class);
61+
62+
$data = $request->validated();
63+
64+
$claim = ExpenseClaim::create([
65+
...$data,
66+
'tenant_id' => auth()->user()->tenant_id,
67+
'status' => 'draft',
68+
'created_by' => Auth::id(),
69+
]);
70+
71+
return redirect()->route('hr.expense-claims.show', $claim)
72+
->with('success', 'Expense claim created.');
73+
}
74+
75+
public function show(ExpenseClaim $expenseClaim): Response
76+
{
77+
$this->authorize('view', $expenseClaim);
78+
79+
$expenseClaim->load(['employee', 'submitter', 'reviewer']);
80+
81+
return Inertia::render('HR/ExpenseClaims/Show', [
82+
'claim' => new ExpenseClaimResource($expenseClaim),
83+
'categories' => self::CATEGORIES,
84+
'can' => [
85+
'update' => auth()->user()->can('update', $expenseClaim),
86+
'delete' => auth()->user()->can('delete', $expenseClaim),
87+
'approve' => auth()->user()->can('approve', $expenseClaim),
88+
],
89+
'breadcrumbs' => [
90+
['label' => 'HR'],
91+
['label' => 'Expense Claims', 'href' => route('hr.expense-claims.index')],
92+
['label' => $expenseClaim->title],
93+
],
94+
]);
95+
}
96+
97+
public function submit(ExpenseClaim $expenseClaim): RedirectResponse
98+
{
99+
$this->authorize('update', $expenseClaim);
100+
101+
try {
102+
$expenseClaim->submit();
103+
} catch (\DomainException $e) {
104+
return back()->withErrors(['status' => $e->getMessage()]);
105+
}
106+
107+
return back()->with('success', 'Expense claim submitted.');
108+
}
109+
110+
public function approve(ExpenseClaim $expenseClaim, Request $request): RedirectResponse
111+
{
112+
$this->authorize('approve', $expenseClaim);
113+
114+
$request->validate([
115+
'notes' => 'nullable|string',
116+
]);
117+
118+
try {
119+
$expenseClaim->approve($request->input('notes', ''));
120+
} catch (\DomainException $e) {
121+
return back()->withErrors(['status' => $e->getMessage()]);
122+
}
123+
124+
return back()->with('success', 'Expense claim approved.');
125+
}
126+
127+
public function reject(ExpenseClaim $expenseClaim, Request $request): RedirectResponse
128+
{
129+
$this->authorize('approve', $expenseClaim);
130+
131+
$request->validate([
132+
'notes' => 'required|string',
133+
]);
134+
135+
try {
136+
$expenseClaim->reject($request->input('notes', ''));
137+
} catch (\DomainException $e) {
138+
return back()->withErrors(['status' => $e->getMessage()]);
139+
}
140+
141+
return back()->with('success', 'Expense claim rejected.');
142+
}
143+
144+
public function reimburse(ExpenseClaim $expenseClaim): RedirectResponse
145+
{
146+
$this->authorize('approve', $expenseClaim);
147+
148+
try {
149+
$expenseClaim->reimburse();
150+
} catch (\DomainException $e) {
151+
return back()->withErrors(['status' => $e->getMessage()]);
152+
}
153+
154+
return back()->with('success', 'Expense claim marked as reimbursed.');
155+
}
156+
157+
public function destroy(ExpenseClaim $expenseClaim): RedirectResponse
158+
{
159+
$this->authorize('delete', $expenseClaim);
160+
161+
$expenseClaim->delete();
162+
163+
return redirect()->route('hr.expense-claims.index')
164+
->with('success', 'Expense claim deleted.');
165+
}
166+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Requests;
4+
5+
use Illuminate\Foundation\Http\FormRequest;
6+
7+
class StoreExpenseClaimRequest extends FormRequest
8+
{
9+
public function authorize(): bool
10+
{
11+
return true;
12+
}
13+
14+
public function rules(): array
15+
{
16+
return [
17+
'employee_id' => 'required|exists:employees,id',
18+
'title' => 'required|string|max:191',
19+
'description' => 'nullable|string',
20+
'expense_date' => 'required|date',
21+
'amount' => 'required|numeric|min:0.01',
22+
'currency_code' => 'nullable|string|size:3',
23+
'category' => 'required|in:travel,meals,supplies,accommodation,other',
24+
];
25+
}
26+
}
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\Http\Resources;
4+
5+
use Illuminate\Http\Request;
6+
use Illuminate\Http\Resources\Json\JsonResource;
7+
8+
class ExpenseClaimResource extends JsonResource
9+
{
10+
public function toArray(Request $request): array
11+
{
12+
return [
13+
'id' => $this->id,
14+
'tenant_id' => $this->tenant_id,
15+
'employee_id' => $this->employee_id,
16+
'employee_name' => $this->whenLoaded('employee', fn () => $this->employee?->full_name),
17+
'employee' => $this->whenLoaded('employee', fn () => $this->employee
18+
? ['id' => $this->employee->id, 'full_name' => $this->employee->full_name]
19+
: null),
20+
'submitted_by' => $this->submitted_by,
21+
'submitted_by_name' => $this->whenLoaded('submitter', fn () => $this->submitter?->name),
22+
'title' => $this->title,
23+
'description' => $this->description,
24+
'expense_date' => $this->expense_date?->toDateString(),
25+
'amount' => $this->amount,
26+
'currency_code' => $this->currency_code,
27+
'category' => $this->category,
28+
'receipt_path' => $this->receipt_path,
29+
'status' => $this->status,
30+
'reviewed_by' => $this->reviewed_by,
31+
'reviewed_by_name' => $this->whenLoaded('reviewer', fn () => $this->reviewer?->name),
32+
'reviewed_at' => $this->reviewed_at?->toDateTimeString(),
33+
'review_notes' => $this->review_notes,
34+
'created_by' => $this->created_by,
35+
'created_at' => $this->created_at?->toDateTimeString(),
36+
'updated_at' => $this->updated_at?->toDateTimeString(),
37+
];
38+
}
39+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use DomainException;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
use Illuminate\Support\Facades\Auth;
12+
13+
class ExpenseClaim extends Model
14+
{
15+
use BelongsToTenant;
16+
use SoftDeletes;
17+
18+
protected $fillable = [
19+
'tenant_id', 'employee_id', 'submitted_by', 'title', 'description',
20+
'expense_date', 'amount', 'currency_code', 'category', 'receipt_path',
21+
'status', 'reviewed_by', 'reviewed_at', 'review_notes', 'created_by',
22+
];
23+
24+
protected $casts = [
25+
'expense_date' => 'date',
26+
'reviewed_at' => 'datetime',
27+
'amount' => 'float',
28+
];
29+
30+
protected $attributes = ['status' => 'draft'];
31+
32+
public function employee(): BelongsTo
33+
{
34+
return $this->belongsTo(Employee::class);
35+
}
36+
37+
public function submitter(): BelongsTo
38+
{
39+
return $this->belongsTo(User::class, 'submitted_by');
40+
}
41+
42+
public function reviewer(): BelongsTo
43+
{
44+
return $this->belongsTo(User::class, 'reviewed_by');
45+
}
46+
47+
public function submit(): void
48+
{
49+
if ($this->status !== 'draft') {
50+
throw new DomainException("Only draft claims can be submitted. Current status: {$this->status}.");
51+
}
52+
53+
$this->update([
54+
'status' => 'submitted',
55+
'submitted_by' => Auth::id(),
56+
]);
57+
}
58+
59+
public function approve(string $notes = ''): void
60+
{
61+
if ($this->status !== 'submitted') {
62+
throw new DomainException("Only submitted claims can be approved. Current status: {$this->status}.");
63+
}
64+
65+
$this->update([
66+
'status' => 'approved',
67+
'reviewed_by' => Auth::id(),
68+
'reviewed_at' => now(),
69+
'review_notes' => $notes,
70+
]);
71+
}
72+
73+
public function reject(string $notes = ''): void
74+
{
75+
if ($this->status !== 'submitted') {
76+
throw new DomainException("Only submitted claims can be rejected. Current status: {$this->status}.");
77+
}
78+
79+
$this->update([
80+
'status' => 'rejected',
81+
'reviewed_by' => Auth::id(),
82+
'reviewed_at' => now(),
83+
'review_notes' => $notes,
84+
]);
85+
}
86+
87+
public function reimburse(): void
88+
{
89+
if ($this->status !== 'approved') {
90+
throw new DomainException("Only approved claims can be reimbursed. Current status: {$this->status}.");
91+
}
92+
93+
$this->update([
94+
'status' => 'reimbursed',
95+
]);
96+
}
97+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\HR\Models\ExpenseClaim;
7+
8+
class ExpenseClaimPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('hr.view');
13+
}
14+
15+
public function view(User $user, ExpenseClaim $expenseClaim): 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, ExpenseClaim $expenseClaim): bool
26+
{
27+
return $user->can('hr.update');
28+
}
29+
30+
public function delete(User $user, ExpenseClaim $expenseClaim): bool
31+
{
32+
return $user->can('hr.delete') && $expenseClaim->status === 'draft';
33+
}
34+
35+
public function approve(User $user, ExpenseClaim $expenseClaim): bool
36+
{
37+
return $user->can('hr.update')
38+
&& $user->hasAnyRole(['admin', 'manager', 'super-admin']);
39+
}
40+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
use App\Modules\HR\Models\Department;
66
use App\Modules\HR\Models\Employee;
7+
use App\Modules\HR\Models\ExpenseClaim;
78
use App\Modules\HR\Models\LeaveRequest;
89
use App\Modules\HR\Models\PayrollRun;
910
use App\Modules\HR\Policies\DepartmentPolicy;
1011
use App\Modules\HR\Policies\EmployeePolicy;
12+
use App\Modules\HR\Policies\ExpenseClaimPolicy;
1113
use App\Modules\HR\Policies\LeaveRequestPolicy;
1214
use App\Modules\HR\Policies\PayrollRunPolicy;
1315
use Illuminate\Support\Facades\Gate;
@@ -23,6 +25,7 @@ public function boot(): void
2325

2426
Gate::policy(Department::class, DepartmentPolicy::class);
2527
Gate::policy(Employee::class, EmployeePolicy::class);
28+
Gate::policy(ExpenseClaim::class, ExpenseClaimPolicy::class);
2629
Gate::policy(LeaveRequest::class, LeaveRequestPolicy::class);
2730
Gate::policy(PayrollRun::class, PayrollRunPolicy::class);
2831
}

0 commit comments

Comments
 (0)