Files
hub/app/Http/Controllers/Seller/Crm/DealController.php
kelly 496ca61489 feat: dashboard redesign and sidebar consolidation
- 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
2025-12-14 03:41:31 -07:00

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.');
}
}