Skip to content

Commit b60b8f6

Browse files
committed
feat: Sign / E-Signature module — 10 tests passing
- SignRequest model: send() (generates signer tokens), cancel(), checkCompletion() (auto-completes when all signers have signed), pendingSigners(), allSigned() - SignRequestSigner model: sign() (sets signed_at, triggers checkCompletion), decline(), generateToken() (Str::random(40)) - SignController: CRUD + send/cancel/addSigner/removeSigner/sign/decline - Auto-completion: request status → 'completed' when last signer signs - 2 migrations: sign_requests, sign_request_signers - React pages: Index (request list, signers count badge, new request form with dynamic signer rows) + Show (signers table with Sign/Decline actions) https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 1b36957 commit b60b8f6

11 files changed

Lines changed: 1113 additions & 0 deletions

File tree

erp/app/Modules/Core/Providers/CoreServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use App\Modules\Events\Providers\EventsServiceProvider;
3030
use App\Modules\KnowledgeBase\Providers\KnowledgeBaseServiceProvider;
3131
use App\Modules\Planning\Providers\PlanningServiceProvider;
32+
use App\Modules\Sign\Providers\SignServiceProvider;
3233
use Illuminate\Support\Facades\Gate;
3334
use Illuminate\Support\ServiceProvider;
3435

@@ -59,6 +60,7 @@ public function register(): void
5960
$this->app->register(EventsServiceProvider::class);
6061
$this->app->register(KnowledgeBaseServiceProvider::class);
6162
$this->app->register(PlanningServiceProvider::class);
63+
$this->app->register(SignServiceProvider::class);
6264
}
6365

6466
public function boot(): void
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<?php
2+
3+
namespace App\Modules\Sign\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Sign\Models\SignRequest;
7+
use App\Modules\Sign\Models\SignRequestSigner;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class SignController extends Controller
14+
{
15+
public function index(Request $request): Response
16+
{
17+
$signRequests = SignRequest::when($request->status, fn ($q) => $q->where('status', $request->status))
18+
->with(['signers', 'creator'])
19+
->orderByDesc('created_at')
20+
->paginate(20)
21+
->withQueryString();
22+
23+
return Inertia::render('Sign/Index', [
24+
'signRequests' => $signRequests,
25+
'filters' => $request->only(['status']),
26+
]);
27+
}
28+
29+
public function show(SignRequest $signRequest): Response
30+
{
31+
$signRequest->load(['signers', 'creator']);
32+
33+
return Inertia::render('Sign/Show', [
34+
'signRequest' => $signRequest,
35+
]);
36+
}
37+
38+
public function store(Request $request): RedirectResponse
39+
{
40+
$validated = $request->validate([
41+
'title' => 'required|string|max:255',
42+
'document_path' => 'required|string|max:255',
43+
'document_name' => 'required|string|max:255',
44+
'message' => 'nullable|string',
45+
'signers' => 'nullable|array',
46+
'signers.*.name' => 'required_with:signers|string|max:255',
47+
'signers.*.email' => 'required_with:signers|email|max:255',
48+
'signers.*.sequence' => 'nullable|integer',
49+
]);
50+
51+
$signRequest = SignRequest::create([
52+
'title' => $validated['title'],
53+
'document_path' => $validated['document_path'],
54+
'document_name' => $validated['document_name'],
55+
'message' => $validated['message'] ?? null,
56+
'tenant_id' => auth()->user()->tenant_id,
57+
'created_by' => auth()->id(),
58+
'status' => 'draft',
59+
]);
60+
61+
foreach ($validated['signers'] ?? [] as $signerData) {
62+
$signer = new SignRequestSigner([
63+
'sign_request_id' => $signRequest->id,
64+
'tenant_id' => $signRequest->tenant_id,
65+
'signer_name' => $signerData['name'],
66+
'signer_email' => $signerData['email'],
67+
'sequence' => $signerData['sequence'] ?? 0,
68+
'status' => 'pending',
69+
]);
70+
$signer->token = $signer->generateToken();
71+
$signer->save();
72+
}
73+
74+
return redirect()->route('sign.show', $signRequest)->with('success', 'Sign request created.');
75+
}
76+
77+
public function destroy(SignRequest $signRequest): RedirectResponse
78+
{
79+
abort_if(
80+
! in_array($signRequest->status, ['draft', 'cancelled']),
81+
403,
82+
'Only draft or cancelled requests can be deleted.'
83+
);
84+
85+
$signRequest->delete();
86+
87+
return redirect()->route('sign.index')->with('success', 'Sign request deleted.');
88+
}
89+
90+
public function send(SignRequest $signRequest): RedirectResponse
91+
{
92+
$signRequest->send();
93+
94+
return redirect()->back()->with('success', 'Sign request sent.');
95+
}
96+
97+
public function cancel(SignRequest $signRequest): RedirectResponse
98+
{
99+
$signRequest->cancel();
100+
101+
return redirect()->back()->with('success', 'Sign request cancelled.');
102+
}
103+
104+
public function addSigner(Request $request, SignRequest $signRequest): RedirectResponse
105+
{
106+
abort_if($signRequest->status !== 'draft', 403, 'Signers can only be added to draft requests.');
107+
108+
$validated = $request->validate([
109+
'signer_name' => 'required|string|max:255',
110+
'signer_email' => 'required|email|max:255',
111+
'sequence' => 'nullable|integer',
112+
]);
113+
114+
$signer = new SignRequestSigner([
115+
'sign_request_id' => $signRequest->id,
116+
'tenant_id' => $signRequest->tenant_id,
117+
'signer_name' => $validated['signer_name'],
118+
'signer_email' => $validated['signer_email'],
119+
'sequence' => $validated['sequence'] ?? 0,
120+
'status' => 'pending',
121+
]);
122+
$signer->token = $signer->generateToken();
123+
$signer->save();
124+
125+
return redirect()->back()->with('success', 'Signer added.');
126+
}
127+
128+
public function removeSigner(SignRequest $signRequest, SignRequestSigner $signer): RedirectResponse
129+
{
130+
abort_if($signRequest->status !== 'draft', 403, 'Signers can only be removed from draft requests.');
131+
132+
$signer->delete();
133+
134+
return redirect()->back()->with('success', 'Signer removed.');
135+
}
136+
137+
public function sign(SignRequest $signRequest, SignRequestSigner $signer): RedirectResponse
138+
{
139+
$signer->sign();
140+
141+
return redirect()->back()->with('success', 'Signed successfully.');
142+
}
143+
144+
public function decline(SignRequest $signRequest, SignRequestSigner $signer): RedirectResponse
145+
{
146+
$signer->decline();
147+
148+
return redirect()->back()->with('success', 'Declined.');
149+
}
150+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
namespace App\Modules\Sign\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Collection;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
10+
use Illuminate\Database\Eloquent\Relations\HasMany;
11+
use Illuminate\Database\Eloquent\SoftDeletes;
12+
13+
class SignRequest extends Model
14+
{
15+
use BelongsToTenant, SoftDeletes;
16+
17+
protected $fillable = [
18+
'tenant_id',
19+
'title',
20+
'document_path',
21+
'document_name',
22+
'status',
23+
'message',
24+
'created_by',
25+
'completed_at',
26+
];
27+
28+
protected $casts = [
29+
'completed_at' => 'datetime',
30+
];
31+
32+
public function signers(): HasMany
33+
{
34+
return $this->hasMany(SignRequestSigner::class)->orderBy('sequence');
35+
}
36+
37+
public function creator(): BelongsTo
38+
{
39+
return $this->belongsTo(User::class, 'created_by');
40+
}
41+
42+
public function send(): void
43+
{
44+
$this->status = 'sent';
45+
$this->save();
46+
47+
foreach ($this->signers as $signer) {
48+
if (empty($signer->token)) {
49+
$signer->token = $signer->generateToken();
50+
$signer->save();
51+
}
52+
}
53+
}
54+
55+
public function cancel(): void
56+
{
57+
$this->status = 'cancelled';
58+
$this->save();
59+
}
60+
61+
public function checkCompletion(): void
62+
{
63+
if ($this->allSigned()) {
64+
$this->status = 'completed';
65+
$this->completed_at = now();
66+
$this->save();
67+
}
68+
}
69+
70+
public function pendingSigners(): Collection
71+
{
72+
return $this->signers()->where('status', 'pending')->get();
73+
}
74+
75+
public function allSigned(): bool
76+
{
77+
return $this->signers()->exists()
78+
&& $this->signers()->where('status', '!=', 'signed')->doesntExist();
79+
}
80+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace App\Modules\Sign\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
use Illuminate\Support\Str;
9+
10+
class SignRequestSigner extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $fillable = [
15+
'sign_request_id',
16+
'tenant_id',
17+
'signer_name',
18+
'signer_email',
19+
'status',
20+
'signed_at',
21+
'declined_at',
22+
'token',
23+
'sequence',
24+
];
25+
26+
protected $casts = [
27+
'signed_at' => 'datetime',
28+
'declined_at' => 'datetime',
29+
];
30+
31+
public function request(): BelongsTo
32+
{
33+
return $this->belongsTo(SignRequest::class, 'sign_request_id');
34+
}
35+
36+
public function sign(): void
37+
{
38+
$this->status = 'signed';
39+
$this->signed_at = now();
40+
$this->save();
41+
42+
$this->request->checkCompletion();
43+
}
44+
45+
public function decline(): void
46+
{
47+
$this->status = 'declined';
48+
$this->declined_at = now();
49+
$this->save();
50+
}
51+
52+
public function generateToken(): string
53+
{
54+
return Str::random(40);
55+
}
56+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace App\Modules\Sign\Providers;
4+
5+
use Illuminate\Support\ServiceProvider;
6+
7+
class SignServiceProvider extends ServiceProvider
8+
{
9+
public function register(): void {}
10+
11+
public function boot(): void
12+
{
13+
$this->loadRoutesFrom(__DIR__ . '/../routes/sign.php');
14+
$this->loadMigrationsFrom(__DIR__ . '/../../../database/migrations');
15+
}
16+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
use App\Modules\Sign\Http\Controllers\SignController;
4+
use Illuminate\Support\Facades\Route;
5+
6+
Route::middleware(['web', 'auth', 'verified'])->prefix('sign')->name('sign.')->group(function () {
7+
Route::post('{signRequest}/send', [SignController::class, 'send'])->name('send');
8+
Route::post('{signRequest}/cancel', [SignController::class, 'cancel'])->name('cancel');
9+
Route::post('{signRequest}/signers', [SignController::class, 'addSigner'])->name('signers.store');
10+
Route::delete('{signRequest}/signers/{signer}', [SignController::class, 'removeSigner'])->name('signers.destroy');
11+
Route::post('{signRequest}/signers/{signer}/sign', [SignController::class, 'sign'])->name('signers.sign');
12+
Route::post('{signRequest}/signers/{signer}/decline', [SignController::class, 'decline'])->name('signers.decline');
13+
Route::get('', [SignController::class, 'index'])->name('index');
14+
Route::post('', [SignController::class, 'store'])->name('store');
15+
Route::get('{signRequest}', [SignController::class, 'show'])->name('show');
16+
Route::delete('{signRequest}', [SignController::class, 'destroy'])->name('destroy');
17+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::dropIfExists('sign_requests');
12+
Schema::create('sign_requests', function (Blueprint $table) {
13+
$table->id();
14+
$table->unsignedBigInteger('tenant_id')->index();
15+
$table->string('title');
16+
$table->string('document_path');
17+
$table->string('document_name');
18+
$table->enum('status', ['draft', 'sent', 'completed', 'cancelled'])->default('draft');
19+
$table->text('message')->nullable();
20+
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
21+
$table->timestamp('completed_at')->nullable();
22+
$table->timestamps();
23+
$table->softDeletes();
24+
});
25+
}
26+
27+
public function down(): void
28+
{
29+
Schema::dropIfExists('sign_requests');
30+
}
31+
};

0 commit comments

Comments
 (0)