- Redesign dashboard as daily briefing format with action-first layout - Consolidate sidebar menu structure (Dashboard as single link) - Fix CRM form styling to use consistent UI patterns - Add PWA icons and push notification groundwork - Update SuiteMenuResolver for cleaner navigation
381 lines
13 KiB
PHP
381 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Seller\Crm;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Brand;
|
|
use App\Models\Business;
|
|
use App\Models\Contact;
|
|
use App\Models\Crm\CrmDeal;
|
|
use App\Models\Crm\CrmPipeline;
|
|
use App\Models\User;
|
|
use App\Services\Crm\CrmAiService;
|
|
use Illuminate\Http\Request;
|
|
use Modules\Crm\Notifications\CrmDealWonNotification;
|
|
|
|
class DealController extends Controller
|
|
{
|
|
public function __construct(
|
|
protected CrmAiService $aiService
|
|
) {}
|
|
|
|
/**
|
|
* Display pipeline board view
|
|
*/
|
|
public function index(Request $request, Business $business)
|
|
{
|
|
// Get active pipeline
|
|
$pipeline = CrmPipeline::forBusiness($business->id)
|
|
->where('id', $request->input('pipeline_id'))
|
|
->first()
|
|
?? CrmPipeline::forBusiness($business->id)->default()->first()
|
|
?? CrmPipeline::createDefault($business->id);
|
|
|
|
// Build base query for deals
|
|
$dealsQuery = CrmDeal::forBusiness($business->id)
|
|
->where('pipeline_id', $pipeline->id)
|
|
->with(['contact:id,first_name,last_name,email', 'account:id,name', 'owner:id,first_name,last_name,email']);
|
|
|
|
// Filters
|
|
if ($request->filled('owner_id')) {
|
|
$dealsQuery->ownedBy($request->owner_id);
|
|
}
|
|
|
|
if ($request->filled('status')) {
|
|
match ($request->status) {
|
|
'open' => $dealsQuery->open(),
|
|
'won' => $dealsQuery->won(),
|
|
'lost' => $dealsQuery->lost(),
|
|
default => null,
|
|
};
|
|
} else {
|
|
$dealsQuery->open();
|
|
}
|
|
|
|
// Get deals grouped by stage using database grouping for efficiency
|
|
// Limit to reasonable number per stage for board view
|
|
$stages = $pipeline->stages ?? [];
|
|
$deals = collect();
|
|
foreach ($stages as $stage) {
|
|
$stageDeals = (clone $dealsQuery)
|
|
->where('stage', $stage['name'] ?? $stage)
|
|
->orderByDesc('value')
|
|
->limit(50)
|
|
->get();
|
|
$deals[$stage['name'] ?? $stage] = $stageDeals;
|
|
}
|
|
|
|
// Get pipelines for selector (limited fields)
|
|
$pipelines = CrmPipeline::forBusiness($business->id)
|
|
->active()
|
|
->select('id', 'name', 'stages', 'is_default')
|
|
->get();
|
|
|
|
// Get team members (limited fields)
|
|
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
|
->select('id', 'first_name', 'last_name', 'email')
|
|
->get();
|
|
|
|
// Calculate stats with single efficient query using selectRaw
|
|
$statsResult = CrmDeal::forBusiness($business->id)
|
|
->open()
|
|
->selectRaw('SUM(value) as total_value, SUM(weighted_value) as weighted_value, COUNT(*) as deals_count')
|
|
->first();
|
|
|
|
$wonThisMonth = CrmDeal::forBusiness($business->id)
|
|
->won()
|
|
->whereMonth('actual_close_date', now()->month)
|
|
->whereYear('actual_close_date', now()->year)
|
|
->sum('value');
|
|
|
|
$stats = [
|
|
'total_value' => $statsResult->total_value ?? 0,
|
|
'weighted_value' => $statsResult->weighted_value ?? 0,
|
|
'deals_count' => $statsResult->deals_count ?? 0,
|
|
'won_this_month' => $wonThisMonth,
|
|
];
|
|
|
|
return view('seller.crm.deals.index', compact('business', 'pipeline', 'deals', 'pipelines', 'teamMembers', 'stats'));
|
|
}
|
|
|
|
/**
|
|
* Show create deal form
|
|
*/
|
|
public function create(Request $request, Business $business)
|
|
{
|
|
$pipelines = CrmPipeline::forBusiness($business->id)
|
|
->active()
|
|
->select('id', 'name', 'stages', 'is_default')
|
|
->get();
|
|
|
|
// Limit contacts for dropdown - most recent 100
|
|
$contacts = Contact::where('business_id', $business->id)
|
|
->select('id', 'first_name', 'last_name', 'email')
|
|
->orderByDesc('updated_at')
|
|
->limit(100)
|
|
->get();
|
|
|
|
// Limit accounts for dropdown - buyers who have ordered from this seller
|
|
$accounts = Business::where('type', 'buyer')
|
|
->whereHas('orders', function ($q) use ($business) {
|
|
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
|
|
})
|
|
->select('id', 'name')
|
|
->orderBy('name')
|
|
->limit(100)
|
|
->get();
|
|
|
|
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
|
->select('id', 'first_name', 'last_name', 'email')
|
|
->get();
|
|
|
|
$brands = Brand::where('business_id', $business->id)
|
|
->select('id', 'name')
|
|
->get();
|
|
|
|
return view('seller.crm.deals.create', compact('pipelines', 'contacts', 'accounts', 'teamMembers', 'brands', 'business'));
|
|
}
|
|
|
|
/**
|
|
* Store new deal
|
|
*/
|
|
public function store(Request $request, Business $business)
|
|
{
|
|
$validated = $request->validate([
|
|
'name' => 'required|string|max:255',
|
|
'pipeline_id' => 'required|exists:crm_pipelines,id',
|
|
'stage' => 'required|string',
|
|
'value' => 'nullable|numeric|min:0',
|
|
'probability' => 'nullable|integer|min:0|max:100',
|
|
'expected_close_date' => 'nullable|date',
|
|
'contact_id' => 'nullable|exists:contacts,id',
|
|
'account_id' => 'nullable|exists:businesses,id',
|
|
'brand_id' => 'nullable|exists:brands,id',
|
|
'owner_id' => 'nullable|exists:users,id',
|
|
'description' => 'nullable|string|max:5000',
|
|
'priority' => 'nullable|in:low,medium,high',
|
|
'source' => 'nullable|string|max:255',
|
|
]);
|
|
|
|
// SECURITY: Verify pipeline belongs to business
|
|
$pipeline = CrmPipeline::where('id', $validated['pipeline_id'])
|
|
->where('business_id', $business->id)
|
|
->firstOrFail();
|
|
|
|
// SECURITY: Verify contact belongs to business
|
|
if (! empty($validated['contact_id'])) {
|
|
Contact::where('id', $validated['contact_id'])
|
|
->where('business_id', $business->id)
|
|
->firstOrFail();
|
|
}
|
|
|
|
// SECURITY: Verify owner belongs to business
|
|
if (! empty($validated['owner_id'])) {
|
|
User::where('id', $validated['owner_id'])
|
|
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
|
->firstOrFail();
|
|
}
|
|
|
|
// SECURITY: Verify brand belongs to business
|
|
if (! empty($validated['brand_id'])) {
|
|
Brand::where('id', $validated['brand_id'])
|
|
->where('business_id', $business->id)
|
|
->firstOrFail();
|
|
}
|
|
|
|
$deal = CrmDeal::create([
|
|
'business_id' => $business->id,
|
|
'pipeline_id' => $validated['pipeline_id'],
|
|
'name' => $validated['name'],
|
|
'stage' => $validated['stage'],
|
|
'value' => $validated['value'] ?? 0,
|
|
'probability' => $validated['probability'] ?? $pipeline->getStageProbability($validated['stage']),
|
|
'expected_close_date' => $validated['expected_close_date'],
|
|
'contact_id' => $validated['contact_id'],
|
|
'account_id' => $validated['account_id'],
|
|
'brand_id' => $validated['brand_id'],
|
|
'owner_id' => $validated['owner_id'] ?? $request->user()->id,
|
|
'description' => $validated['description'],
|
|
'priority' => $validated['priority'] ?? 'medium',
|
|
'source' => $validated['source'],
|
|
'status' => CrmDeal::STATUS_OPEN,
|
|
]);
|
|
|
|
return redirect()->route('seller.business.crm.deals.show', [$business, $deal])
|
|
->with('success', 'Deal created successfully.');
|
|
}
|
|
|
|
/**
|
|
* Show deal details
|
|
*/
|
|
public function show(Request $request, Business $business, CrmDeal $deal)
|
|
{
|
|
if ($deal->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$deal->load([
|
|
'contact',
|
|
'account',
|
|
'owner',
|
|
'brand',
|
|
'pipeline',
|
|
'thread.messages',
|
|
'quotes',
|
|
'stageHistory.user',
|
|
'internalNotes.user',
|
|
'tags.tag',
|
|
'files',
|
|
]);
|
|
|
|
// Get AI suggestions
|
|
$suggestions = $this->aiService->suggestNextSteps($deal);
|
|
|
|
// Get AI probability if not recent
|
|
if (! $deal->ai_probability || $deal->updated_at->diffInDays(now()) > 1) {
|
|
$this->aiService->predictWinProbability($deal);
|
|
$deal->refresh();
|
|
}
|
|
|
|
return view('seller.crm.deals.show', compact('deal', 'suggestions', 'business'));
|
|
}
|
|
|
|
/**
|
|
* Update deal stage (drag & drop)
|
|
*/
|
|
public function updateStage(Request $request, Business $business, CrmDeal $deal)
|
|
{
|
|
if ($deal->business_id !== $business->id) {
|
|
return response()->json(['error' => 'Unauthorized'], 403);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'stage' => 'required|string',
|
|
]);
|
|
|
|
$deal->changeStage($validated['stage'], $request->user());
|
|
|
|
// Update probability based on new stage
|
|
$probability = $deal->pipeline->getStageProbability($validated['stage']);
|
|
$deal->update(['probability' => $probability]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'deal' => $deal->fresh()->load(['contact', 'account', 'owner']),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Mark deal as won
|
|
*/
|
|
public function markWon(Request $request, Business $business, CrmDeal $deal)
|
|
{
|
|
if ($deal->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$deal->markAsWon($request->user(), $request->input('notes'));
|
|
|
|
// Notify team
|
|
if ($deal->owner) {
|
|
$deal->owner->notify(new CrmDealWonNotification($deal));
|
|
}
|
|
|
|
return back()->with('success', 'Congratulations! Deal marked as won.');
|
|
}
|
|
|
|
/**
|
|
* Mark deal as lost
|
|
*/
|
|
public function markLost(Request $request, Business $business, CrmDeal $deal)
|
|
{
|
|
if ($deal->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'lost_reason' => 'required|string',
|
|
'lost_reason_detail' => 'nullable|string|max:1000',
|
|
'competitor_name' => 'nullable|string|max:255',
|
|
]);
|
|
|
|
$deal->markAsLost(
|
|
$request->user(),
|
|
$validated['lost_reason'],
|
|
$validated['lost_reason_detail'],
|
|
$validated['competitor_name']
|
|
);
|
|
|
|
return back()->with('success', 'Deal marked as lost.');
|
|
}
|
|
|
|
/**
|
|
* Reopen a closed deal
|
|
*/
|
|
public function reopen(Request $request, Business $business, CrmDeal $deal)
|
|
{
|
|
if ($deal->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$deal->reopen($request->user());
|
|
|
|
return back()->with('success', 'Deal reopened.');
|
|
}
|
|
|
|
/**
|
|
* Update deal details
|
|
*/
|
|
public function update(Request $request, Business $business, CrmDeal $deal)
|
|
{
|
|
if ($deal->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'name' => 'sometimes|required|string|max:255',
|
|
'value' => 'sometimes|nullable|numeric|min:0',
|
|
'probability' => 'sometimes|nullable|integer|min:0|max:100',
|
|
'expected_close_date' => 'sometimes|nullable|date',
|
|
'contact_id' => 'sometimes|nullable|exists:contacts,id',
|
|
'owner_id' => 'sometimes|nullable|exists:users,id',
|
|
'description' => 'sometimes|nullable|string|max:5000',
|
|
'priority' => 'sometimes|nullable|in:low,medium,high',
|
|
'next_step' => 'sometimes|nullable|string|max:500',
|
|
'next_step_date' => 'sometimes|nullable|date',
|
|
]);
|
|
|
|
// SECURITY: Verify relationships
|
|
if (! empty($validated['contact_id'])) {
|
|
Contact::where('id', $validated['contact_id'])
|
|
->where('business_id', $business->id)
|
|
->firstOrFail();
|
|
}
|
|
|
|
if (! empty($validated['owner_id'])) {
|
|
User::where('id', $validated['owner_id'])
|
|
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
|
->firstOrFail();
|
|
}
|
|
|
|
$deal->update($validated);
|
|
|
|
return back()->with('success', 'Deal updated.');
|
|
}
|
|
|
|
/**
|
|
* Delete deal
|
|
*/
|
|
public function destroy(Request $request, Business $business, CrmDeal $deal)
|
|
{
|
|
if ($deal->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$deal->delete();
|
|
|
|
return redirect()->route('seller.business.crm.deals.index', $business)
|
|
->with('success', 'Deal deleted.');
|
|
}
|
|
}
|