Skip to content

Commit 91fc1eb

Browse files
committed
Phases 221-225: Approval Workflows Module — 20 tests passing
4 migrations (approval_workflows, approval_steps, approval_requests, approval_actions), 4 models (ApprovalWorkflow with findFor()/stepCount(), ApprovalRequest with approve()/reject()/cancel()/canApprove()/createFor(), ApprovalStep, ApprovalAction), ApprovalsPolicy, 3 controllers (Dashboard/ Workflow/Request with myPending), 8 React pages (Dashboard, Workflows CRUD+Show, Requests Index+Show+MyPending), Sidebar Approvals section. Multi-step approval flow with per-step user/role assignment. 20/20 tests passing. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 904180c commit 91fc1eb

25 files changed

Lines changed: 2583 additions & 0 deletions
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<?php
2+
3+
namespace App\Modules\Approvals\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Approvals\Models\ApprovalRequest;
7+
use App\Modules\Approvals\Models\ApprovalStep;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class ApprovalRequestController extends Controller
14+
{
15+
public function index(Request $request): Response
16+
{
17+
$query = ApprovalRequest::withoutGlobalScopes()
18+
->where('approval_requests.tenant_id', auth()->user()->tenant_id)
19+
->with(['workflow', 'requestedBy'])
20+
->orderByDesc('created_at');
21+
22+
if ($request->filled('status')) {
23+
$query->where('status', $request->status);
24+
}
25+
26+
if ($request->filled('entity_type')) {
27+
$query->where('entity_type', $request->entity_type);
28+
}
29+
30+
$requests = $query->paginate(25)->withQueryString();
31+
32+
return Inertia::render('Approvals/Requests/Index', [
33+
'requests' => $requests,
34+
'filters' => $request->only(['status', 'entity_type']),
35+
]);
36+
}
37+
38+
public function show(ApprovalRequest $approvalRequest): Response
39+
{
40+
$approvalRequest->load([
41+
'workflow.steps.approver',
42+
'requestedBy',
43+
'actions.actor',
44+
]);
45+
46+
$canApprove = $approvalRequest->canApprove(auth()->user());
47+
48+
return Inertia::render('Approvals/Requests/Show', [
49+
'approvalRequest' => $approvalRequest,
50+
'canApprove' => $canApprove,
51+
]);
52+
}
53+
54+
public function approve(Request $request, ApprovalRequest $approvalRequest): RedirectResponse
55+
{
56+
$data = $request->validate([
57+
'comments' => 'nullable|string|max:1000',
58+
]);
59+
60+
if (! $approvalRequest->canApprove(auth()->user())) {
61+
return back()->with('error', 'You are not authorized to approve this request.');
62+
}
63+
64+
$approvalRequest->approve(auth()->user(), $data['comments'] ?? '');
65+
66+
return back()->with('success', 'Request approved successfully.');
67+
}
68+
69+
public function reject(Request $request, ApprovalRequest $approvalRequest): RedirectResponse
70+
{
71+
$data = $request->validate([
72+
'reason' => 'required|string|max:1000',
73+
]);
74+
75+
if (! $approvalRequest->canApprove(auth()->user())) {
76+
return back()->with('error', 'You are not authorized to reject this request.');
77+
}
78+
79+
$approvalRequest->reject(auth()->user(), $data['reason']);
80+
81+
return back()->with('success', 'Request rejected.');
82+
}
83+
84+
public function cancel(ApprovalRequest $approvalRequest): RedirectResponse
85+
{
86+
if (! $approvalRequest->isPending()) {
87+
return back()->with('error', 'Only pending requests can be cancelled.');
88+
}
89+
90+
$approvalRequest->cancel();
91+
92+
return back()->with('success', 'Request cancelled.');
93+
}
94+
95+
public function myPending(): Response
96+
{
97+
$user = auth()->user();
98+
99+
// Find approval steps where this user is the approver by ID or by role
100+
$userRoles = $user->getRoleNames()->toArray();
101+
102+
$requests = ApprovalRequest::withoutGlobalScopes()
103+
->where('approval_requests.tenant_id', $user->tenant_id)
104+
->where('approval_requests.status', 'pending')
105+
->with(['workflow', 'requestedBy'])
106+
->whereHas('workflow.steps', function ($query) use ($user, $userRoles) {
107+
$query->whereColumn('approval_steps.step_number', 'approval_requests.current_step')
108+
->where('approval_steps.workflow_id', \DB::raw('approval_requests.workflow_id'))
109+
->where(function ($q) use ($user, $userRoles) {
110+
$q->where('approval_steps.approver_id', $user->id);
111+
if (! empty($userRoles)) {
112+
$q->orWhereIn('approval_steps.approver_role', $userRoles);
113+
}
114+
});
115+
})
116+
->orderByDesc('created_at')
117+
->get();
118+
119+
return Inertia::render('Approvals/Requests/MyPending', [
120+
'requests' => $requests,
121+
]);
122+
}
123+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
<?php
2+
3+
namespace App\Modules\Approvals\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\User;
7+
use App\Modules\Approvals\Models\ApprovalStep;
8+
use App\Modules\Approvals\Models\ApprovalWorkflow;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
use Spatie\Permission\Models\Role;
14+
15+
class ApprovalWorkflowController extends Controller
16+
{
17+
public function index(): Response
18+
{
19+
$workflows = ApprovalWorkflow::withoutGlobalScopes()
20+
->where('tenant_id', auth()->user()->tenant_id)
21+
->withCount('steps')
22+
->orderBy('name')
23+
->get();
24+
25+
return Inertia::render('Approvals/Workflows/Index', [
26+
'workflows' => $workflows,
27+
]);
28+
}
29+
30+
public function create(): Response
31+
{
32+
$users = User::where('tenant_id', auth()->user()->tenant_id)
33+
->where('is_active', true)
34+
->orderBy('name')
35+
->get(['id', 'name', 'email']);
36+
37+
$roles = Role::orderBy('name')->pluck('name');
38+
39+
return Inertia::render('Approvals/Workflows/Create', [
40+
'users' => $users,
41+
'roles' => $roles,
42+
]);
43+
}
44+
45+
public function store(Request $request): RedirectResponse
46+
{
47+
$data = $request->validate([
48+
'name' => 'required|string|max:255',
49+
'entity_type' => 'required|string|in:purchase_order,expense,leave_request,bill,manufacturing_order',
50+
'min_amount' => 'nullable|numeric|min:0',
51+
'max_amount' => 'nullable|numeric|min:0',
52+
'is_active' => 'boolean',
53+
'steps' => 'array|min:1',
54+
'steps.*.name' => 'required|string|max:255',
55+
'steps.*.approver_id' => 'nullable|exists:users,id',
56+
'steps.*.approver_role' => 'nullable|string|max:100',
57+
'steps.*.is_required' => 'boolean',
58+
]);
59+
60+
$workflow = ApprovalWorkflow::create([
61+
'tenant_id' => auth()->user()->tenant_id,
62+
'name' => $data['name'],
63+
'entity_type' => $data['entity_type'],
64+
'min_amount' => $data['min_amount'] ?? null,
65+
'max_amount' => $data['max_amount'] ?? null,
66+
'is_active' => $data['is_active'] ?? true,
67+
]);
68+
69+
foreach ($data['steps'] ?? [] as $index => $stepData) {
70+
ApprovalStep::create([
71+
'workflow_id' => $workflow->id,
72+
'step_number' => $index + 1,
73+
'name' => $stepData['name'],
74+
'approver_id' => $stepData['approver_id'] ?? null,
75+
'approver_role' => $stepData['approver_role'] ?? null,
76+
'is_required' => $stepData['is_required'] ?? true,
77+
]);
78+
}
79+
80+
return redirect()->route('approvals.workflows.index')
81+
->with('success', 'Workflow created successfully.');
82+
}
83+
84+
public function show(ApprovalWorkflow $workflow): Response
85+
{
86+
$workflow->load('steps.approver');
87+
88+
return Inertia::render('Approvals/Workflows/Show', [
89+
'workflow' => $workflow,
90+
]);
91+
}
92+
93+
public function edit(ApprovalWorkflow $workflow): Response
94+
{
95+
$workflow->load('steps');
96+
97+
$users = User::where('tenant_id', auth()->user()->tenant_id)
98+
->where('is_active', true)
99+
->orderBy('name')
100+
->get(['id', 'name', 'email']);
101+
102+
$roles = Role::orderBy('name')->pluck('name');
103+
104+
return Inertia::render('Approvals/Workflows/Edit', [
105+
'workflow' => $workflow,
106+
'users' => $users,
107+
'roles' => $roles,
108+
]);
109+
}
110+
111+
public function update(Request $request, ApprovalWorkflow $workflow): RedirectResponse
112+
{
113+
$data = $request->validate([
114+
'name' => 'required|string|max:255',
115+
'entity_type' => 'required|string|in:purchase_order,expense,leave_request,bill,manufacturing_order',
116+
'min_amount' => 'nullable|numeric|min:0',
117+
'max_amount' => 'nullable|numeric|min:0',
118+
'is_active' => 'boolean',
119+
'steps' => 'array|min:1',
120+
'steps.*.name' => 'required|string|max:255',
121+
'steps.*.approver_id' => 'nullable|exists:users,id',
122+
'steps.*.approver_role' => 'nullable|string|max:100',
123+
'steps.*.is_required' => 'boolean',
124+
]);
125+
126+
$workflow->update([
127+
'name' => $data['name'],
128+
'entity_type' => $data['entity_type'],
129+
'min_amount' => $data['min_amount'] ?? null,
130+
'max_amount' => $data['max_amount'] ?? null,
131+
'is_active' => $data['is_active'] ?? true,
132+
]);
133+
134+
// Sync steps: delete existing, recreate
135+
$workflow->steps()->delete();
136+
137+
foreach ($data['steps'] ?? [] as $index => $stepData) {
138+
ApprovalStep::create([
139+
'workflow_id' => $workflow->id,
140+
'step_number' => $index + 1,
141+
'name' => $stepData['name'],
142+
'approver_id' => $stepData['approver_id'] ?? null,
143+
'approver_role' => $stepData['approver_role'] ?? null,
144+
'is_required' => $stepData['is_required'] ?? true,
145+
]);
146+
}
147+
148+
return redirect()->route('approvals.workflows.show', $workflow)
149+
->with('success', 'Workflow updated successfully.');
150+
}
151+
152+
public function destroy(ApprovalWorkflow $workflow): RedirectResponse
153+
{
154+
$workflow->delete();
155+
156+
return redirect()->route('approvals.workflows.index')
157+
->with('success', 'Workflow deleted successfully.');
158+
}
159+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
namespace App\Modules\Approvals\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Approvals\Models\ApprovalRequest;
7+
use Illuminate\Support\Facades\DB;
8+
use Inertia\Inertia;
9+
use Inertia\Response;
10+
11+
class ApprovalsDashboardController extends Controller
12+
{
13+
public function index(): Response
14+
{
15+
$user = auth()->user();
16+
$tenantId = $user->tenant_id;
17+
18+
$pendingRequests = ApprovalRequest::withoutGlobalScopes()
19+
->where('tenant_id', $tenantId)
20+
->where('status', 'pending')
21+
->count();
22+
23+
$approvedToday = ApprovalRequest::withoutGlobalScopes()
24+
->where('tenant_id', $tenantId)
25+
->where('status', 'approved')
26+
->whereDate('approved_at', today())
27+
->count();
28+
29+
$rejectedToday = ApprovalRequest::withoutGlobalScopes()
30+
->where('tenant_id', $tenantId)
31+
->where('status', 'rejected')
32+
->whereDate('rejected_at', today())
33+
->count();
34+
35+
$userRoles = $user->getRoleNames()->toArray();
36+
37+
$myPendingCount = ApprovalRequest::withoutGlobalScopes()
38+
->where('approval_requests.tenant_id', $tenantId)
39+
->where('approval_requests.status', 'pending')
40+
->whereHas('workflow.steps', function ($query) use ($user, $userRoles) {
41+
$query->whereColumn('approval_steps.step_number', 'approval_requests.current_step')
42+
->where('approval_steps.workflow_id', DB::raw('approval_requests.workflow_id'))
43+
->where(function ($q) use ($user, $userRoles) {
44+
$q->where('approval_steps.approver_id', $user->id);
45+
if (! empty($userRoles)) {
46+
$q->orWhereIn('approval_steps.approver_role', $userRoles);
47+
}
48+
});
49+
})
50+
->count();
51+
52+
$recentRequests = ApprovalRequest::withoutGlobalScopes()
53+
->where('tenant_id', $tenantId)
54+
->with(['workflow', 'requestedBy'])
55+
->orderByDesc('created_at')
56+
->limit(10)
57+
->get();
58+
59+
return Inertia::render('Approvals/Dashboard', [
60+
'stats' => [
61+
'pending_requests' => $pendingRequests,
62+
'approved_today' => $approvedToday,
63+
'rejected_today' => $rejectedToday,
64+
'my_pending' => $myPendingCount,
65+
],
66+
'recentRequests' => $recentRequests,
67+
]);
68+
}
69+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace App\Modules\Approvals\Models;
4+
5+
use App\Models\User;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class ApprovalAction extends Model
10+
{
11+
protected $table = 'approval_actions';
12+
13+
protected $fillable = [
14+
'request_id',
15+
'step_number',
16+
'action',
17+
'actor_id',
18+
'comments',
19+
'acted_at',
20+
];
21+
22+
protected $casts = [
23+
'acted_at' => 'datetime',
24+
];
25+
26+
public function request(): BelongsTo
27+
{
28+
return $this->belongsTo(ApprovalRequest::class, 'request_id');
29+
}
30+
31+
public function actor(): BelongsTo
32+
{
33+
return $this->belongsTo(User::class, 'actor_id');
34+
}
35+
}

0 commit comments

Comments
 (0)