Skip to content

Commit 47e2b86

Browse files
committed
feat(finance): Phase 78 — CRM Leads Pipeline with activities and win/loss tracking
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent f309372 commit 47e2b86

14 files changed

Lines changed: 1106 additions & 0 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\User;
7+
use App\Modules\Finance\Models\Lead;
8+
use App\Modules\Finance\Models\LeadActivity;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class LeadController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', Lead::class);
19+
20+
$query = Lead::with('assignedTo');
21+
22+
if ($request->filled('stage')) {
23+
$query->where('stage', $request->stage);
24+
}
25+
26+
$leads = $query->latest()->paginate(20)->withQueryString();
27+
28+
return Inertia::render('Finance/Leads/Index', [
29+
'leads' => $leads,
30+
'filters' => $request->only('stage'),
31+
]);
32+
}
33+
34+
public function create(): Response
35+
{
36+
$this->authorize('create', Lead::class);
37+
38+
$users = User::orderBy('name')->get(['id', 'name']);
39+
40+
return Inertia::render('Finance/Leads/Create', [
41+
'users' => $users,
42+
]);
43+
}
44+
45+
public function store(Request $request): RedirectResponse
46+
{
47+
$this->authorize('create', Lead::class);
48+
49+
$data = $request->validate([
50+
'name' => ['required', 'string'],
51+
'email' => ['nullable', 'email'],
52+
'phone' => ['nullable', 'string', 'max:30'],
53+
'company' => ['nullable', 'string'],
54+
'source' => ['required', 'in:website,referral,cold_call,trade_show,social_media,other'],
55+
'stage' => ['nullable', 'in:new,contacted,qualified,proposal,negotiation,won,lost'],
56+
'estimated_value' => ['nullable', 'numeric'],
57+
'probability' => ['nullable', 'integer', 'min:0', 'max:100'],
58+
'notes' => ['nullable', 'string'],
59+
'assigned_to' => ['nullable', 'exists:users,id'],
60+
'expected_close_date' => ['nullable', 'date'],
61+
]);
62+
63+
$data['tenant_id'] = app('tenant')->id;
64+
$data['stage'] = $data['stage'] ?? 'new';
65+
66+
$lead = Lead::create($data);
67+
68+
return redirect()->route('finance.leads.show', $lead);
69+
}
70+
71+
public function show(Lead $lead): Response
72+
{
73+
$this->authorize('view', $lead);
74+
75+
$lead->load(['activities' => function ($q) {
76+
$q->with('user')->orderBy('activity_date', 'desc');
77+
}]);
78+
79+
$lead->append('weighted_value');
80+
81+
return Inertia::render('Finance/Leads/Show', [
82+
'lead' => $lead,
83+
]);
84+
}
85+
86+
public function update(Request $request, Lead $lead): RedirectResponse
87+
{
88+
$this->authorize('update', $lead);
89+
90+
$data = $request->validate([
91+
'stage' => ['nullable', 'in:new,contacted,qualified,proposal,negotiation,won,lost'],
92+
'probability' => ['nullable', 'integer', 'min:0', 'max:100'],
93+
'estimated_value' => ['nullable', 'numeric'],
94+
'notes' => ['nullable', 'string'],
95+
'expected_close_date' => ['nullable', 'date'],
96+
'assigned_to' => ['nullable', 'exists:users,id'],
97+
]);
98+
99+
$lead->update($data);
100+
101+
return back()->with('success', 'Lead updated successfully.');
102+
}
103+
104+
public function destroy(Lead $lead): RedirectResponse
105+
{
106+
$this->authorize('delete', $lead);
107+
108+
$lead->delete();
109+
110+
return redirect()->route('finance.leads.index');
111+
}
112+
113+
public function markWon(Lead $lead): RedirectResponse
114+
{
115+
$this->authorize('update', $lead);
116+
117+
$lead->markWon();
118+
119+
return back()->with('success', 'Lead marked as won.');
120+
}
121+
122+
public function markLost(Request $request, Lead $lead): RedirectResponse
123+
{
124+
$this->authorize('update', $lead);
125+
126+
$data = $request->validate([
127+
'reason' => ['required', 'string'],
128+
]);
129+
130+
$lead->markLost($data['reason']);
131+
132+
return back()->with('success', 'Lead marked as lost.');
133+
}
134+
135+
public function addActivity(Request $request, Lead $lead): RedirectResponse
136+
{
137+
$this->authorize('create', Lead::class);
138+
139+
$data = $request->validate([
140+
'type' => ['required', 'in:call,email,meeting,note,task'],
141+
'description' => ['required', 'string'],
142+
'activity_date' => ['required', 'date'],
143+
'outcome' => ['nullable', 'string'],
144+
'duration_minutes' => ['nullable', 'integer', 'min:1'],
145+
]);
146+
147+
$data['tenant_id'] = app('tenant')->id;
148+
$data['lead_id'] = $lead->id;
149+
$data['user_id'] = auth()->id();
150+
151+
LeadActivity::create($data);
152+
153+
return back()->with('success', 'Activity added successfully.');
154+
}
155+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
use Illuminate\Support\Carbon;
12+
13+
class Lead extends Model
14+
{
15+
use BelongsToTenant;
16+
use SoftDeletes;
17+
18+
protected $table = 'leads';
19+
20+
protected $fillable = [
21+
'tenant_id',
22+
'name',
23+
'email',
24+
'phone',
25+
'company',
26+
'source',
27+
'stage',
28+
'assigned_to',
29+
'estimated_value',
30+
'probability',
31+
'notes',
32+
'lost_reason',
33+
'won_at',
34+
'lost_at',
35+
'expected_close_date',
36+
];
37+
38+
protected $casts = [
39+
'estimated_value' => 'decimal:2',
40+
'probability' => 'integer',
41+
'won_at' => 'date',
42+
'lost_at' => 'date',
43+
'expected_close_date' => 'date',
44+
];
45+
46+
public function assignedTo(): BelongsTo
47+
{
48+
return $this->belongsTo(User::class, 'assigned_to');
49+
}
50+
51+
public function activities(): HasMany
52+
{
53+
return $this->hasMany(LeadActivity::class);
54+
}
55+
56+
public function markWon(): void
57+
{
58+
$this->stage = 'won';
59+
$this->won_at = Carbon::today();
60+
$this->probability = 100;
61+
$this->save();
62+
}
63+
64+
public function markLost(string $reason): void
65+
{
66+
$this->stage = 'lost';
67+
$this->lost_at = Carbon::today();
68+
$this->lost_reason = $reason;
69+
$this->probability = 0;
70+
$this->save();
71+
}
72+
73+
public function moveStage(string $stage): void
74+
{
75+
$this->stage = $stage;
76+
$this->save();
77+
}
78+
79+
public function getWeightedValueAttribute(): float
80+
{
81+
if ($this->estimated_value === null) {
82+
return 0.0;
83+
}
84+
85+
return (float) $this->estimated_value * $this->probability / 100;
86+
}
87+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
10+
class LeadActivity extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $table = 'lead_activities';
15+
16+
protected $fillable = [
17+
'tenant_id',
18+
'lead_id',
19+
'user_id',
20+
'type',
21+
'description',
22+
'activity_date',
23+
'outcome',
24+
'duration_minutes',
25+
];
26+
27+
protected $casts = [
28+
'activity_date' => 'date',
29+
'duration_minutes' => 'integer',
30+
];
31+
32+
public function lead(): BelongsTo
33+
{
34+
return $this->belongsTo(Lead::class);
35+
}
36+
37+
public function user(): BelongsTo
38+
{
39+
return $this->belongsTo(User::class);
40+
}
41+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\Lead;
7+
8+
class LeadPolicy
9+
{
10+
public function viewAny(User $user): bool { return $user->can('finance.view'); }
11+
public function view(User $user, Lead $lead): bool { return $user->can('finance.view'); }
12+
public function create(User $user): bool { return $user->can('finance.create'); }
13+
public function update(User $user, Lead $lead): bool { return $user->can('finance.create'); }
14+
public function delete(User $user, Lead $lead): bool { return $user->can('finance.delete'); }
15+
}

erp/app/Modules/Finance/Providers/FinanceServiceProvider.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@
6868
use App\Modules\Finance\Models\MaintenanceLog;
6969
use App\Modules\Finance\Policies\ServiceAgreementPolicy;
7070
use App\Modules\Finance\Models\LoyaltyProgram;
71+
use App\Modules\Finance\Models\Lead;
72+
use App\Modules\Finance\Models\LeadActivity;
73+
use App\Modules\Finance\Policies\LeadPolicy;
7174
use App\Modules\Finance\Models\LoyaltyEnrollment;
7275
use App\Modules\Finance\Models\LoyaltyTransaction;
7376
use App\Modules\Finance\Policies\LoyaltyPolicy;
@@ -125,6 +128,9 @@ public function boot(): void
125128
Gate::policy(MaintenanceLog::class, ServiceAgreementPolicy::class);
126129

127130
Gate::policy(LoyaltyProgram::class, LoyaltyPolicy::class);
131+
132+
Gate::policy(Lead::class, LeadPolicy::class);
133+
Gate::policy(LeadActivity::class, LeadPolicy::class);
128134
Gate::policy(LoyaltyEnrollment::class, LoyaltyPolicy::class);
129135
Gate::policy(LoyaltyTransaction::class, LoyaltyPolicy::class);
130136

erp/app/Modules/Finance/routes/finance.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
use App\Modules\Finance\Http\Controllers\TaxGroupController;
3838
use App\Modules\Finance\Http\Controllers\ServiceAgreementController;
3939
use App\Modules\Finance\Http\Controllers\LoyaltyProgramController;
40+
use App\Modules\Finance\Http\Controllers\LeadController;
4041

4142
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
4243

@@ -270,4 +271,10 @@
270271
Route::post("loyalty-programs/{loyaltyProgram}/redeem-points", [LoyaltyProgramController::class, "redeemPoints"])->name("loyalty-programs.redeem-points");
271272
Route::resource("loyalty-programs", LoyaltyProgramController::class)->except(["edit", "update"]);
272273

274+
// Leads
275+
Route::post('leads/{lead}/mark-won', [LeadController::class, 'markWon'])->name('leads.mark-won');
276+
Route::post('leads/{lead}/mark-lost', [LeadController::class, 'markLost'])->name('leads.mark-lost');
277+
Route::post('leads/{lead}/activities', [LeadController::class, 'addActivity'])->name('leads.activities.add');
278+
Route::resource('leads', LeadController::class)->except(['edit']);
279+
273280
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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::create('leads', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('name');
15+
$table->string('email')->nullable();
16+
$table->string('phone', 30)->nullable();
17+
$table->string('company')->nullable();
18+
$table->string('source', 30)->default('other');
19+
$table->string('stage', 20)->default('new');
20+
$table->unsignedBigInteger('assigned_to')->nullable();
21+
$table->decimal('estimated_value', 14, 2)->nullable();
22+
$table->unsignedTinyInteger('probability')->default(0);
23+
$table->text('notes')->nullable();
24+
$table->text('lost_reason')->nullable();
25+
$table->date('won_at')->nullable();
26+
$table->date('lost_at')->nullable();
27+
$table->date('expected_close_date')->nullable();
28+
$table->softDeletes();
29+
$table->timestamps();
30+
$table->index(['tenant_id', 'stage']);
31+
});
32+
}
33+
34+
public function down(): void
35+
{
36+
Schema::dropIfExists('leads');
37+
}
38+
};

0 commit comments

Comments
 (0)