Skip to content

Commit cd005e2

Browse files
committed
feat(hr): Phase 68 — Expense Management with approval workflow
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 3669530 commit cd005e2

13 files changed

Lines changed: 609 additions & 490 deletions

File tree

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

Lines changed: 80 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -3,164 +3,162 @@
33
namespace App\Modules\HR\Http\Controllers;
44

55
use App\Http\Controllers\Controller;
6-
use App\Modules\HR\Http\Requests\StoreExpenseClaimRequest;
7-
use App\Modules\HR\Http\Resources\ExpenseClaimResource;
86
use App\Modules\HR\Models\Employee;
97
use App\Modules\HR\Models\ExpenseClaim;
8+
use App\Modules\HR\Models\ExpenseClaimItem;
109
use Illuminate\Http\RedirectResponse;
1110
use Illuminate\Http\Request;
12-
use Illuminate\Support\Facades\Auth;
1311
use Inertia\Inertia;
1412
use Inertia\Response;
1513

1614
class ExpenseClaimController extends Controller
1715
{
18-
const CATEGORIES = ['travel', 'meals', 'supplies', 'accommodation', 'other'];
19-
2016
public function index(Request $request): Response
2117
{
2218
$this->authorize('viewAny', ExpenseClaim::class);
2319

24-
$claims = ExpenseClaim::with(['employee', 'reviewer'])
20+
$claims = ExpenseClaim::with(['employee'])
2521
->when($request->status, fn ($q) => $q->where('status', $request->status))
2622
->orderBy('created_at', 'desc')
27-
->paginate(25)
23+
->paginate(15)
2824
->withQueryString();
2925

3026
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-
],
27+
'claims' => $claims,
28+
'filters' => $request->only(['status']),
3829
]);
3930
}
4031

4132
public function create(): Response
4233
{
4334
$this->authorize('create', ExpenseClaim::class);
4435

36+
$employees = Employee::where('status', 'active')
37+
->orderBy('last_name')
38+
->get(['id', 'first_name', 'last_name']);
39+
4540
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-
],
41+
'employees' => $employees,
5542
]);
5643
}
5744

58-
public function store(StoreExpenseClaimRequest $request): RedirectResponse
45+
public function store(Request $request): RedirectResponse
5946
{
6047
$this->authorize('create', ExpenseClaim::class);
6148

62-
$data = $request->validated();
49+
$validated = $request->validate([
50+
'title' => 'required|string',
51+
'employee_id' => 'required|exists:employees,id',
52+
'description' => 'nullable|string',
53+
]);
6354

6455
$claim = ExpenseClaim::create([
65-
...$data,
66-
'tenant_id' => auth()->user()->tenant_id,
67-
'status' => 'draft',
68-
'created_by' => Auth::id(),
56+
'tenant_id' => auth()->user()->tenant_id,
57+
'employee_id' => $validated['employee_id'],
58+
'title' => $validated['title'],
59+
'description' => $validated['description'] ?? null,
60+
'status' => 'draft',
61+
'total_amount' => 0,
6962
]);
7063

71-
return redirect()->route('hr.expense-claims.show', $claim)
72-
->with('success', 'Expense claim created.');
64+
return redirect()->route('hr.expense-claims.show', $claim);
7365
}
7466

7567
public function show(ExpenseClaim $expenseClaim): Response
7668
{
7769
$this->authorize('view', $expenseClaim);
7870

79-
$expenseClaim->load(['employee', 'submitter', 'reviewer']);
71+
$expenseClaim->load(['items', 'employee', 'approvedBy']);
8072

8173
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-
],
74+
'expenseClaim' => $expenseClaim,
9475
]);
9576
}
9677

78+
public function destroy(ExpenseClaim $expenseClaim): RedirectResponse
79+
{
80+
$this->authorize('delete', $expenseClaim);
81+
82+
$expenseClaim->delete();
83+
84+
return redirect()->route('hr.expense-claims.index');
85+
}
86+
9787
public function submit(ExpenseClaim $expenseClaim): RedirectResponse
9888
{
9989
$this->authorize('update', $expenseClaim);
10090

101-
try {
102-
$expenseClaim->submit();
103-
} catch (\DomainException $e) {
104-
return back()->withErrors(['status' => $e->getMessage()]);
105-
}
91+
$expenseClaim->submit();
10692

10793
return back()->with('success', 'Expense claim submitted.');
10894
}
10995

110-
public function approve(ExpenseClaim $expenseClaim, Request $request): RedirectResponse
96+
public function approve(ExpenseClaim $expenseClaim): RedirectResponse
11197
{
112-
$this->authorize('approve', $expenseClaim);
113-
114-
$request->validate([
115-
'notes' => 'nullable|string',
116-
]);
98+
$this->authorize('update', $expenseClaim);
11799

118-
try {
119-
$expenseClaim->approve($request->input('notes', ''));
120-
} catch (\DomainException $e) {
121-
return back()->withErrors(['status' => $e->getMessage()]);
122-
}
100+
$expenseClaim->approve(auth()->user());
123101

124102
return back()->with('success', 'Expense claim approved.');
125103
}
126104

127-
public function reject(ExpenseClaim $expenseClaim, Request $request): RedirectResponse
105+
public function reject(Request $request, ExpenseClaim $expenseClaim): RedirectResponse
128106
{
129-
$this->authorize('approve', $expenseClaim);
107+
$this->authorize('update', $expenseClaim);
130108

131-
$request->validate([
132-
'notes' => 'required|string',
109+
$validated = $request->validate([
110+
'reason' => 'required|string',
133111
]);
134112

135-
try {
136-
$expenseClaim->reject($request->input('notes', ''));
137-
} catch (\DomainException $e) {
138-
return back()->withErrors(['status' => $e->getMessage()]);
139-
}
113+
$expenseClaim->reject($validated['reason']);
140114

141115
return back()->with('success', 'Expense claim rejected.');
142116
}
143117

144-
public function reimburse(ExpenseClaim $expenseClaim): RedirectResponse
118+
public function markPaid(ExpenseClaim $expenseClaim): RedirectResponse
145119
{
146-
$this->authorize('approve', $expenseClaim);
120+
$this->authorize('update', $expenseClaim);
147121

148-
try {
149-
$expenseClaim->reimburse();
150-
} catch (\DomainException $e) {
151-
return back()->withErrors(['status' => $e->getMessage()]);
152-
}
122+
$expenseClaim->markPaid();
153123

154-
return back()->with('success', 'Expense claim marked as reimbursed.');
124+
return back();
155125
}
156126

157-
public function destroy(ExpenseClaim $expenseClaim): RedirectResponse
127+
public function addItem(Request $request, ExpenseClaim $expenseClaim): RedirectResponse
158128
{
159-
$this->authorize('delete', $expenseClaim);
129+
$this->authorize('update', $expenseClaim);
160130

161-
$expenseClaim->delete();
131+
$validated = $request->validate([
132+
'category' => 'required|string|max:50',
133+
'description' => 'required|string',
134+
'amount' => 'required|numeric|min:0.01',
135+
'expense_date' => 'required|date',
136+
'receipt_reference' => 'nullable|string',
137+
]);
138+
139+
$expenseClaim->items()->create([
140+
'tenant_id' => auth()->user()->tenant_id,
141+
'expense_claim_id' => $expenseClaim->id,
142+
'category' => $validated['category'],
143+
'description' => $validated['description'],
144+
'amount' => $validated['amount'],
145+
'expense_date' => $validated['expense_date'],
146+
'receipt_reference' => $validated['receipt_reference'] ?? null,
147+
]);
148+
149+
$expenseClaim->recalculateTotal();
150+
151+
return back()->with('success', 'Item added.');
152+
}
153+
154+
public function removeItem(ExpenseClaim $expenseClaim, ExpenseClaimItem $item): RedirectResponse
155+
{
156+
$this->authorize('update', $expenseClaim);
157+
158+
$item->delete();
159+
160+
$expenseClaim->recalculateTotal();
162161

163-
return redirect()->route('hr.expense-claims.index')
164-
->with('success', 'Expense claim deleted.');
162+
return back();
165163
}
166164
}

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

Lines changed: 40 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -4,96 +4,84 @@
44

55
use App\Models\User;
66
use App\Modules\Core\Traits\BelongsToTenant;
7-
use App\Modules\Finance\Traits\HasAttachments;
8-
use DomainException;
97
use Illuminate\Database\Eloquent\Model;
108
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\HasMany;
1110
use Illuminate\Database\Eloquent\SoftDeletes;
12-
use Illuminate\Support\Facades\Auth;
1311

1412
class ExpenseClaim extends Model
1513
{
1614
use BelongsToTenant;
17-
use HasAttachments;
1815
use SoftDeletes;
1916

17+
protected $table = 'expense_claims';
18+
2019
protected $fillable = [
21-
'tenant_id', 'employee_id', 'submitted_by', 'title', 'description',
22-
'expense_date', 'amount', 'currency_code', 'category', 'receipt_path',
23-
'status', 'reviewed_by', 'reviewed_at', 'review_notes', 'created_by',
20+
'tenant_id', 'employee_id', 'title', 'description', 'status',
21+
'total_amount', 'submitted_at', 'approved_by', 'approved_at',
22+
'paid_at', 'rejection_reason', 'notes',
2423
];
2524

2625
protected $casts = [
27-
'expense_date' => 'date',
28-
'reviewed_at' => 'datetime',
29-
'amount' => 'float',
26+
'total_amount' => 'decimal:2',
27+
'submitted_at' => 'datetime',
28+
'approved_at' => 'datetime',
29+
'paid_at' => 'datetime',
30+
'status' => 'string',
3031
];
3132

32-
protected $attributes = ['status' => 'draft'];
33-
3433
public function employee(): BelongsTo
3534
{
3635
return $this->belongsTo(Employee::class);
3736
}
3837

39-
public function submitter(): BelongsTo
38+
public function approvedBy(): BelongsTo
4039
{
41-
return $this->belongsTo(User::class, 'submitted_by');
40+
return $this->belongsTo(User::class, 'approved_by');
4241
}
4342

44-
public function reviewer(): BelongsTo
43+
public function items(): HasMany
4544
{
46-
return $this->belongsTo(User::class, 'reviewed_by');
45+
return $this->hasMany(ExpenseClaimItem::class);
4746
}
4847

4948
public function submit(): void
5049
{
51-
if ($this->status !== 'draft') {
52-
throw new DomainException("Only draft claims can be submitted. Current status: {$this->status}.");
53-
}
54-
55-
$this->update([
56-
'status' => 'submitted',
57-
'submitted_by' => Auth::id(),
58-
]);
50+
$this->status = 'submitted';
51+
$this->submitted_at = now();
52+
$this->save();
5953
}
6054

61-
public function approve(string $notes = ''): void
55+
public function approve(User $user): void
6256
{
63-
if ($this->status !== 'submitted') {
64-
throw new DomainException("Only submitted claims can be approved. Current status: {$this->status}.");
65-
}
66-
67-
$this->update([
68-
'status' => 'approved',
69-
'reviewed_by' => Auth::id(),
70-
'reviewed_at' => now(),
71-
'review_notes' => $notes,
72-
]);
57+
$this->status = 'approved';
58+
$this->approved_by = $user->id;
59+
$this->approved_at = now();
60+
$this->save();
7361
}
7462

75-
public function reject(string $notes = ''): void
63+
public function reject(string $reason): void
7664
{
77-
if ($this->status !== 'submitted') {
78-
throw new DomainException("Only submitted claims can be rejected. Current status: {$this->status}.");
79-
}
65+
$this->status = 'rejected';
66+
$this->rejection_reason = $reason;
67+
$this->save();
68+
}
8069

81-
$this->update([
82-
'status' => 'rejected',
83-
'reviewed_by' => Auth::id(),
84-
'reviewed_at' => now(),
85-
'review_notes' => $notes,
86-
]);
70+
public function markPaid(): void
71+
{
72+
$this->status = 'paid';
73+
$this->paid_at = now();
74+
$this->save();
8775
}
8876

89-
public function reimburse(): void
77+
public function recalculateTotal(): void
9078
{
91-
if ($this->status !== 'approved') {
92-
throw new DomainException("Only approved claims can be reimbursed. Current status: {$this->status}.");
93-
}
79+
$this->total_amount = $this->items()->sum('amount');
80+
$this->save();
81+
}
9482

95-
$this->update([
96-
'status' => 'reimbursed',
97-
]);
83+
public function getTotalItemsAttribute(): int
84+
{
85+
return $this->items()->count();
9886
}
9987
}

0 commit comments

Comments
 (0)