Skip to content

Commit 0b89e86

Browse files
committed
Phases 166-170 (partial): CRM Module — backend complete
Migrations, models (CrmStage/CrmLead/CrmActivity), controllers (Dashboard/Stage/Lead/Activity/Report), routes, CRMServiceProvider, and CoreServiceProvider registration. React pages pending. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent a2990c3 commit 0b89e86

15 files changed

Lines changed: 829 additions & 0 deletions
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace App\Modules\CRM\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\CRM\Models\CrmActivity;
7+
use App\Modules\CRM\Models\CrmLead;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
11+
class CrmActivityController extends Controller
12+
{
13+
public function store(Request $request, CrmLead $lead): RedirectResponse
14+
{
15+
$validated = $request->validate([
16+
'type' => 'required|in:call,meeting,email,task,note',
17+
'subject' => 'required|string|max:255',
18+
'description' => 'nullable|string',
19+
'scheduled_at' => 'nullable|date',
20+
'assigned_to' => 'nullable|exists:users,id',
21+
]);
22+
23+
$lead->activities()->create([
24+
...$validated,
25+
'tenant_id' => auth()->user()->tenant_id,
26+
'created_by' => auth()->id(),
27+
]);
28+
29+
return back()->with('success', 'Activity added.');
30+
}
31+
32+
public function markDone(CrmActivity $activity): RedirectResponse
33+
{
34+
$activity->markDone();
35+
36+
return back()->with('success', 'Activity marked as done.');
37+
}
38+
39+
public function destroy(CrmActivity $activity): RedirectResponse
40+
{
41+
$activity->delete();
42+
43+
return back()->with('success', 'Activity deleted.');
44+
}
45+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
namespace App\Modules\CRM\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\CRM\Models\CrmLead;
7+
use App\Modules\CRM\Models\CrmStage;
8+
use Illuminate\Support\Carbon;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class CrmDashboardController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$now = Carbon::now();
17+
$startOfMonth = $now->copy()->startOfMonth();
18+
$thirtyDaysAgo = $now->copy()->subDays(30);
19+
20+
$totalLeads = CrmLead::where('type', 'lead')
21+
->where('status', 'open')
22+
->count();
23+
24+
$totalOpportunities = CrmLead::where('type', 'opportunity')
25+
->where('status', 'open')
26+
->count();
27+
28+
$totalExpectedRevenue = CrmLead::where('status', 'open')
29+
->sum('expected_revenue');
30+
31+
$wonThisMonth = CrmLead::where('status', 'won')
32+
->where('won_at', '>=', $startOfMonth)
33+
->count();
34+
35+
$wonRevenueThisMonth = CrmLead::where('status', 'won')
36+
->where('won_at', '>=', $startOfMonth)
37+
->sum('expected_revenue');
38+
39+
$wonLast30 = CrmLead::where('status', 'won')
40+
->where('won_at', '>=', $thirtyDaysAgo)
41+
->count();
42+
43+
$lostLast30 = CrmLead::where('status', 'lost')
44+
->where('lost_at', '>=', $thirtyDaysAgo)
45+
->count();
46+
47+
$total30 = $wonLast30 + $lostLast30;
48+
$conversionRate = $total30 > 0
49+
? round(($wonLast30 / $total30) * 100, 1)
50+
: 0;
51+
52+
$pipelineByStage = CrmStage::withCount([
53+
'leads as lead_count' => fn ($q) => $q->where('status', 'open'),
54+
])->withSum(
55+
['leads as expected_revenue_sum' => fn ($q) => $q->where('status', 'open')],
56+
'expected_revenue'
57+
)->orderBy('sequence')->get(['id', 'name', 'color', 'sequence']);
58+
59+
$recentLeads = CrmLead::with(['stage', 'assignee'])
60+
->orderByDesc('created_at')
61+
->limit(5)
62+
->get(['id', 'reference', 'title', 'stage_id', 'assigned_to', 'priority', 'expected_close_date', 'status']);
63+
64+
return Inertia::render('CRM/Dashboard', [
65+
'stats' => [
66+
'totalLeads' => $totalLeads,
67+
'totalOpportunities' => $totalOpportunities,
68+
'totalExpectedRevenue' => (float) $totalExpectedRevenue,
69+
'wonThisMonth' => $wonThisMonth,
70+
'wonRevenueThisMonth' => (float) $wonRevenueThisMonth,
71+
'conversionRate' => $conversionRate,
72+
],
73+
'pipelineByStage' => $pipelineByStage,
74+
'recentLeads' => $recentLeads,
75+
]);
76+
}
77+
}
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\CRM\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\User;
7+
use App\Modules\CRM\Models\CrmLead;
8+
use App\Modules\CRM\Models\CrmStage;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class CrmLeadController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$leads = CrmLead::with(['stage', 'assignee'])
19+
->when($request->type, fn ($q) => $q->where('type', $request->type))
20+
->when($request->status, fn ($q) => $q->where('status', $request->status))
21+
->when($request->stage_id, fn ($q) => $q->where('stage_id', $request->stage_id))
22+
->when($request->assigned_to, fn ($q) => $q->where('assigned_to', $request->assigned_to))
23+
->when($request->search, fn ($q) => $q->where(function ($q2) use ($request) {
24+
$q2->where('title', 'like', "%{$request->search}%")
25+
->orWhere('contact_name', 'like', "%{$request->search}%");
26+
}))
27+
->orderByDesc('created_at')
28+
->paginate(25)
29+
->withQueryString();
30+
31+
return Inertia::render('CRM/Leads/Index', [
32+
'leads' => $leads,
33+
'filters' => $request->only(['type', 'status', 'stage_id', 'assigned_to', 'search']),
34+
]);
35+
}
36+
37+
public function create(): Response
38+
{
39+
return Inertia::render('CRM/Leads/Create', [
40+
'stages' => CrmStage::where('is_active', true)->orderBy('sequence')->get(['id', 'name', 'type']),
41+
'users' => User::orderBy('name')->get(['id', 'name']),
42+
]);
43+
}
44+
45+
public function store(Request $request): RedirectResponse
46+
{
47+
$validated = $request->validate([
48+
'title' => 'required|string|max:255',
49+
'type' => 'required|in:lead,opportunity',
50+
'stage_id' => 'nullable|exists:crm_stages,id',
51+
'contact_name' => 'nullable|string|max:255',
52+
'company_name' => 'nullable|string|max:255',
53+
'email' => 'nullable|email|max:255',
54+
'phone' => 'nullable|string|max:50',
55+
'website' => 'nullable|string|max:255',
56+
'source' => 'nullable|string|max:50',
57+
'expected_revenue' => 'nullable|numeric|min:0',
58+
'probability' => 'nullable|numeric|min:0|max:100',
59+
'expected_close_date' => 'nullable|date',
60+
'priority' => 'required|in:low,normal,high,urgent',
61+
'description' => 'nullable|string',
62+
'assigned_to' => 'nullable|exists:users,id',
63+
]);
64+
65+
$lead = CrmLead::create([
66+
...$validated,
67+
'tenant_id' => auth()->user()->tenant_id,
68+
'created_by' => auth()->id(),
69+
]);
70+
71+
if (! $lead->reference) {
72+
$lead->reference = $lead->generateReference();
73+
$lead->saveQuietly();
74+
}
75+
76+
return redirect()->route('crm.leads.show', $lead)->with('success', 'Lead created.');
77+
}
78+
79+
public function show(CrmLead $lead): Response
80+
{
81+
$lead->load(['stage', 'assignee', 'activities.assignee']);
82+
83+
return Inertia::render('CRM/Leads/Show', [
84+
'lead' => $lead,
85+
'stages' => CrmStage::where('is_active', true)->orderBy('sequence')->get(['id', 'name']),
86+
'users' => User::orderBy('name')->get(['id', 'name']),
87+
]);
88+
}
89+
90+
public function edit(CrmLead $lead): Response
91+
{
92+
return Inertia::render('CRM/Leads/Edit', [
93+
'lead' => $lead,
94+
'stages' => CrmStage::where('is_active', true)->orderBy('sequence')->get(['id', 'name', 'type']),
95+
'users' => User::orderBy('name')->get(['id', 'name']),
96+
]);
97+
}
98+
99+
public function update(Request $request, CrmLead $lead): RedirectResponse
100+
{
101+
$validated = $request->validate([
102+
'title' => 'required|string|max:255',
103+
'type' => 'required|in:lead,opportunity',
104+
'stage_id' => 'nullable|exists:crm_stages,id',
105+
'contact_name' => 'nullable|string|max:255',
106+
'company_name' => 'nullable|string|max:255',
107+
'email' => 'nullable|email|max:255',
108+
'phone' => 'nullable|string|max:50',
109+
'website' => 'nullable|string|max:255',
110+
'source' => 'nullable|string|max:50',
111+
'expected_revenue' => 'nullable|numeric|min:0',
112+
'probability' => 'nullable|numeric|min:0|max:100',
113+
'expected_close_date' => 'nullable|date',
114+
'priority' => 'required|in:low,normal,high,urgent',
115+
'description' => 'nullable|string',
116+
'assigned_to' => 'nullable|exists:users,id',
117+
]);
118+
119+
$lead->update($validated);
120+
121+
return redirect()->route('crm.leads.show', $lead)->with('success', 'Lead updated.');
122+
}
123+
124+
public function destroy(CrmLead $lead): RedirectResponse
125+
{
126+
$lead->delete();
127+
128+
return redirect()->route('crm.leads.index')->with('success', 'Lead deleted.');
129+
}
130+
131+
public function markWon(CrmLead $lead): RedirectResponse
132+
{
133+
$lead->markWon();
134+
135+
return redirect()->route('crm.leads.show', $lead)->with('success', 'Lead marked as won.');
136+
}
137+
138+
public function markLost(Request $request, CrmLead $lead): RedirectResponse
139+
{
140+
$request->validate([
141+
'lost_reason' => 'nullable|string|max:1000',
142+
]);
143+
144+
$lead->markLost($request->lost_reason ?? '');
145+
146+
return redirect()->route('crm.leads.show', $lead)->with('success', 'Lead marked as lost.');
147+
}
148+
149+
public function convert(CrmLead $lead): RedirectResponse
150+
{
151+
$lead->convertToOpportunity();
152+
153+
return redirect()->route('crm.leads.show', $lead)->with('success', 'Converted to opportunity.');
154+
}
155+
}
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\CRM\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\CRM\Models\CrmLead;
7+
use App\Modules\CRM\Models\CrmStage;
8+
use Illuminate\Support\Carbon;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class CrmReportController extends Controller
13+
{
14+
public function pipeline(): Response
15+
{
16+
$stages = CrmStage::withCount([
17+
'leads as lead_count' => fn ($q) => $q->where('type', 'lead'),
18+
'leads as opportunity_count' => fn ($q) => $q->where('type', 'opportunity'),
19+
])->withSum(
20+
['leads as expected_revenue_sum' => fn ($q) => $q->whereIn('status', ['open', 'won'])],
21+
'expected_revenue'
22+
)->withAvg(
23+
['leads as avg_probability' => fn ($q) => $q->where('status', 'open')],
24+
'probability'
25+
)->orderBy('sequence')->get();
26+
27+
return Inertia::render('CRM/Reports/Pipeline', [
28+
'stages' => $stages,
29+
]);
30+
}
31+
32+
public function winLoss(): Response
33+
{
34+
$months = collect(range(5, 0))->map(function ($i) {
35+
$month = Carbon::now()->subMonths($i);
36+
37+
$won = CrmLead::where('status', 'won')
38+
->whereYear('won_at', $month->year)
39+
->whereMonth('won_at', $month->month)
40+
->count();
41+
42+
$wonRevenue = CrmLead::where('status', 'won')
43+
->whereYear('won_at', $month->year)
44+
->whereMonth('won_at', $month->month)
45+
->sum('expected_revenue');
46+
47+
$lost = CrmLead::where('status', 'lost')
48+
->whereYear('lost_at', $month->year)
49+
->whereMonth('lost_at', $month->month)
50+
->count();
51+
52+
$total = $won + $lost;
53+
54+
return [
55+
'month' => $month->format('M Y'),
56+
'won' => $won,
57+
'won_revenue' => (float) $wonRevenue,
58+
'lost' => $lost,
59+
'conversion_rate'=> $total > 0 ? round(($won / $total) * 100, 1) : 0,
60+
];
61+
});
62+
63+
return Inertia::render('CRM/Reports/WinLoss', [
64+
'months' => $months,
65+
'totals' => [
66+
'won' => $months->sum('won'),
67+
'lost' => $months->sum('lost'),
68+
'won_revenue' => $months->sum('won_revenue'),
69+
],
70+
]);
71+
}
72+
73+
public function source(): Response
74+
{
75+
$sources = CrmLead::selectRaw(
76+
"COALESCE(source, 'Unknown') as source_name,
77+
COUNT(*) as total_count,
78+
SUM(CASE WHEN type = 'lead' THEN 1 ELSE 0 END) as lead_count,
79+
SUM(CASE WHEN type = 'opportunity' THEN 1 ELSE 0 END) as opportunity_count,
80+
SUM(CASE WHEN status = 'won' THEN 1 ELSE 0 END) as won_count,
81+
SUM(CASE WHEN status = 'won' THEN expected_revenue ELSE 0 END) as total_revenue"
82+
)
83+
->groupBy('source_name')
84+
->orderByDesc('total_count')
85+
->get()
86+
->map(function ($row) {
87+
$row->win_rate = $row->total_count > 0
88+
? round(($row->won_count / $row->total_count) * 100, 1)
89+
: 0;
90+
return $row;
91+
});
92+
93+
return Inertia::render('CRM/Reports/Source', [
94+
'sources' => $sources,
95+
]);
96+
}
97+
}

0 commit comments

Comments
 (0)