Files
hub/app/Http/Controllers/Seller/Crm/AccountController.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

811 lines
30 KiB
PHP

<?php
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Activity;
use App\Models\Business;
use App\Models\Contact;
use App\Models\Crm\CrmEvent;
use App\Models\Crm\CrmQuote;
use App\Models\Crm\CrmTask;
use App\Models\Invoice;
use App\Models\Location;
use App\Models\SalesOpportunity;
use App\Models\SendMenuLog;
use App\Services\Cannaiq\CannaiqClient;
use Illuminate\Http\Request;
class AccountController extends Controller
{
/**
* Display accounts listing - only buyers who have ordered from this seller
*/
public function index(Request $request, Business $business)
{
$query = Business::where('type', 'buyer')
->whereHas('orders', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
})
->with(['contacts']);
// Search filter
if ($request->filled('q')) {
$search = $request->q;
$query->where(function ($q) use ($search) {
$q->where('name', 'ILIKE', "%{$search}%")
->orWhere('dba_name', 'ILIKE', "%{$search}%")
->orWhere('business_email', 'ILIKE', "%{$search}%");
});
}
// Status filter - default to approved, but allow viewing all
if ($request->filled('status') && $request->status !== 'all') {
$query->where('status', $request->status);
} else {
$query->where('status', 'approved');
}
$accounts = $query->orderBy('name')->paginate(25);
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $accounts->map(fn ($a) => [
'slug' => $a->slug,
'name' => $a->name,
'email' => $a->business_email,
'status' => $a->status,
])->values()->toArray(),
]);
}
return view('seller.crm.accounts.index', compact('business', 'accounts'));
}
/**
* Show create customer form
*/
public function create(Request $request, Business $business)
{
return view('seller.crm.accounts.create', compact('business'));
}
/**
* Store a new customer (buyer business)
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'license_number' => 'nullable|string|max:100',
'business_email' => 'nullable|email|max:255',
'business_phone' => 'nullable|string|max:50',
'physical_address' => 'nullable|string|max:255',
'physical_city' => 'nullable|string|max:100',
'physical_state' => 'nullable|string|max:50',
'physical_zipcode' => 'nullable|string|max:20',
'contact_name' => 'nullable|string|max:255',
'contact_email' => 'nullable|email|max:255',
'contact_phone' => 'nullable|string|max:50',
'contact_title' => 'nullable|string|max:100',
]);
// Create the buyer business
$account = Business::create([
'name' => $validated['name'],
'dba_name' => $validated['dba_name'] ?? null,
'license_number' => $validated['license_number'] ?? null,
'business_email' => $validated['business_email'] ?? null,
'business_phone' => $validated['business_phone'] ?? null,
'physical_address' => $validated['physical_address'] ?? null,
'physical_city' => $validated['physical_city'] ?? null,
'physical_state' => $validated['physical_state'] ?? null,
'physical_zipcode' => $validated['physical_zipcode'] ?? null,
'type' => 'buyer',
'status' => 'approved', // Auto-approve customers created by sellers
]);
// Create contact if provided
if (! empty($validated['contact_name'])) {
$account->contacts()->create([
'first_name' => explode(' ', $validated['contact_name'])[0],
'last_name' => implode(' ', array_slice(explode(' ', $validated['contact_name']), 1)) ?: null,
'email' => $validated['contact_email'] ?? null,
'phone' => $validated['contact_phone'] ?? null,
'title' => $validated['contact_title'] ?? null,
]);
}
// Log the creation event
CrmEvent::log(
sellerBusinessId: $business->id,
eventType: 'account_created',
summary: "Customer {$account->name} created",
buyerBusinessId: $account->id,
userId: auth()->id(),
channel: 'system'
);
// Return JSON for AJAX requests
if ($request->expectsJson()) {
return response()->json([
'id' => $account->id,
'name' => $account->name,
'slug' => $account->slug,
]);
}
return redirect()
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Customer created successfully.');
}
/**
* Show edit customer form
*/
public function edit(Request $request, Business $business, Business $account)
{
return view('seller.crm.accounts.edit', compact('business', 'account'));
}
/**
* Update a customer (buyer business)
*/
public function update(Request $request, Business $business, Business $account)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'license_number' => 'nullable|string|max:100',
'business_email' => 'nullable|email|max:255',
'business_phone' => 'nullable|string|max:50',
'physical_address' => 'nullable|string|max:255',
'physical_city' => 'nullable|string|max:100',
'physical_state' => 'nullable|string|max:50',
'physical_zipcode' => 'nullable|string|max:20',
]);
$account->update($validated);
return redirect()
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Customer updated successfully.');
}
/**
* Show account details
*/
public function show(Request $request, Business $business, Business $account)
{
$account->load(['contacts']);
// Location filtering
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
// Load all locations for this account with contacts pivot
$locations = $account->locations()
->with(['contacts' => function ($q) {
$q->wherePivot('role', 'buyer');
}])
->orderBy('name')
->get();
// Base order query for this seller
$baseOrderQuery = fn () => $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
});
// Get orders (filtered by location if selected)
$ordersQuery = $baseOrderQuery();
if ($selectedLocation) {
$ordersQuery->where('location_id', $selectedLocation->id);
}
$orders = $ordersQuery->with(['invoice', 'location'])->latest()->limit(10)->get();
// Get quotes for this account (filtered by location if selected)
$quotesQuery = CrmQuote::where('business_id', $business->id)
->where('account_id', $account->id);
if ($selectedLocation) {
$quotesQuery->where('location_id', $selectedLocation->id);
}
$quotes = $quotesQuery->with(['contact', 'items'])->latest()->limit(10)->get();
// Base invoice query
$baseInvoiceQuery = fn () => Invoice::whereHas('order', function ($q) use ($business, $account) {
$q->where('business_id', $account->id)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
});
// Get invoices (filtered by location if selected)
$invoicesQuery = $baseInvoiceQuery();
if ($selectedLocation) {
$invoicesQuery->whereHas('order', function ($q) use ($selectedLocation) {
$q->where('location_id', $selectedLocation->id);
});
}
$invoices = $invoicesQuery->with(['order', 'payments'])->latest()->limit(10)->get();
// Get opportunities for this account from this seller
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['stage', 'brand'])
->latest()
->get();
// Get tasks related to this account
$tasks = CrmTask::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->whereNull('completed_at')
->with('assignee')
->orderBy('due_at')
->limit(5)
->get();
// Get conversation events for this account
$conversationEvents = CrmEvent::where('seller_business_id', $business->id)
->where('buyer_business_id', $account->id)
->latest('occurred_at')
->limit(20)
->get();
// Get menu send history for this account
$sendHistory = SendMenuLog::where('business_id', $business->id)
->where('customer_id', $account->id)
->with(['menu', 'brand'])
->latest('sent_at')
->limit(10)
->get();
// Get activity log for this account
$activities = Activity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['causer'])
->latest()
->limit(20)
->get();
// Compute stats - if location selected, show location-specific stats
if ($selectedLocation) {
$orderStats = $baseOrderQuery()
->where('location_id', $selectedLocation->id)
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
->first();
} else {
$orderStats = $baseOrderQuery()
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
->first();
}
$opportunityStats = SalesOpportunity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->where('status', 'open')
->selectRaw('COUNT(*) as open_count, COALESCE(SUM(value), 0) as pipeline_value')
->first();
// Financial stats from invoices (location-filtered if applicable)
$financialStatsQuery = $baseInvoiceQuery();
if ($selectedLocation) {
$financialStatsQuery->whereHas('order', function ($q) use ($selectedLocation) {
$q->where('location_id', $selectedLocation->id);
});
}
$financialStats = $financialStatsQuery->selectRaw('
COALESCE(SUM(amount_due), 0) as outstanding_balance,
COALESCE(SUM(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN amount_due ELSE 0 END), 0) as past_due_amount,
COUNT(CASE WHEN amount_due > 0 THEN 1 END) as open_invoice_count,
MIN(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN due_date END) as oldest_past_due_date
')
->first();
// Get last payment info
$lastPayment = \App\Models\InvoicePayment::whereHas('invoice.order', function ($q) use ($business, $account) {
$q->where('business_id', $account->id)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
})
->latest('payment_date')
->first();
$stats = [
'total_orders' => $orderStats->total_orders ?? 0,
'total_revenue' => $orderStats->total_revenue ?? 0,
'open_opportunities' => $opportunityStats->open_count ?? 0,
'pipeline_value' => $opportunityStats->pipeline_value ?? 0,
];
$financials = [
'outstanding_balance' => $financialStats->outstanding_balance ?? 0,
'past_due_amount' => $financialStats->past_due_amount ?? 0,
'open_invoice_count' => $financialStats->open_invoice_count ?? 0,
'oldest_past_due_days' => $financialStats->oldest_past_due_date
? (int) ceil(abs(now()->diffInDays($financialStats->oldest_past_due_date)))
: null,
'last_payment_amount' => $lastPayment->amount ?? null,
'last_payment_date' => $lastPayment->payment_date ?? null,
];
// Calculate unattributed orders/invoices (those without location_id)
$unattributedOrdersCount = $baseOrderQuery()->whereNull('location_id')->count();
$unattributedInvoicesCount = $baseInvoiceQuery()
->whereHas('order', function ($q) {
$q->whereNull('location_id');
})
->count();
// Calculate per-location stats for location tiles
$locationStats = [];
if ($locations->count() > 0) {
$locationIds = $locations->pluck('id')->toArray();
// Order stats by location
$ordersByLocation = $baseOrderQuery()
->whereIn('location_id', $locationIds)
->selectRaw('location_id, COUNT(*) as orders_count, COALESCE(SUM(total), 0) as revenue')
->groupBy('location_id')
->get()
->keyBy('location_id');
// Invoice stats by location
$invoicesByLocation = Invoice::whereHas('order', function ($q) use ($business, $account, $locationIds) {
$q->where('business_id', $account->id)
->whereIn('location_id', $locationIds)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
})
->selectRaw('
(SELECT location_id FROM orders WHERE orders.id = invoices.order_id) as location_id,
COALESCE(SUM(amount_due), 0) as outstanding,
COALESCE(SUM(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN amount_due ELSE 0 END), 0) as past_due,
COUNT(CASE WHEN amount_due > 0 THEN 1 END) as open_invoices
')
->groupByRaw('(SELECT location_id FROM orders WHERE orders.id = invoices.order_id)')
->get()
->keyBy('location_id');
foreach ($locations as $location) {
$orderData = $ordersByLocation->get($location->id);
$invoiceData = $invoicesByLocation->get($location->id);
$ordersCount = $orderData->orders_count ?? 0;
$openInvoices = $invoiceData->open_invoices ?? 0;
$locationStats[$location->id] = [
'orders' => $ordersCount,
'revenue' => $orderData->revenue ?? 0,
'outstanding' => $invoiceData->outstanding ?? 0,
'past_due' => $invoiceData->past_due ?? 0,
'open_invoices' => $openInvoices,
'has_attributed_data' => ($ordersCount + $openInvoices) > 0,
];
}
}
return view('seller.crm.accounts.show', compact(
'business',
'account',
'stats',
'financials',
'orders',
'quotes',
'invoices',
'opportunities',
'tasks',
'conversationEvents',
'sendHistory',
'activities',
'locations',
'selectedLocation',
'locationStats',
'unattributedOrdersCount',
'unattributedInvoicesCount'
));
}
/**
* Show account contacts
*/
public function contacts(Request $request, Business $business, Business $account)
{
// Location filtering
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
// Base query for contacts
$contactsQuery = $account->contacts();
// If location selected, filter to contacts assigned to that location
if ($selectedLocation) {
$contactsQuery->whereHas('locations', function ($q) use ($selectedLocation) {
$q->where('locations.id', $selectedLocation->id);
});
}
$contacts = $contactsQuery->paginate(25);
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.contacts', compact('business', 'account', 'contacts', 'locations', 'selectedLocation'));
}
/**
* Show account opportunities
*/
public function opportunities(Request $request, Business $business, Business $account)
{
// Location filtering (note: opportunities don't have location_id yet, so we just pass the context)
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
// Load opportunities for this account
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['stage', 'brand', 'owner'])
->latest()
->paginate(25);
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.opportunities', compact('business', 'account', 'opportunities', 'locations', 'selectedLocation'));
}
/**
* Show account orders
*/
public function orders(Request $request, Business $business, Business $account)
{
// Location filtering
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
$ordersQuery = $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
});
// Filter by location if selected
if ($selectedLocation) {
$ordersQuery->where('location_id', $selectedLocation->id);
}
$orders = $ordersQuery->with(['items.product.brand', 'location'])
->latest()
->paginate(25);
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.orders', compact('business', 'account', 'orders', 'locations', 'selectedLocation'));
}
/**
* Show account activity
*/
public function activity(Request $request, Business $business, Business $account)
{
// Location filtering (note: activities don't have location_id yet, so we just pass the context)
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
$activities = Activity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['causer'])
->latest()
->paginate(50);
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.activity', compact('business', 'account', 'activities', 'locations', 'selectedLocation'));
}
/**
* Show account tasks
*/
public function tasks(Request $request, Business $business, Business $account)
{
// Location filtering (note: tasks don't have location_id yet, so we just pass the context)
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
// Load tasks for this account
$tasks = CrmTask::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['assignee', 'opportunity'])
->orderByRaw('completed_at IS NOT NULL')
->orderBy('due_at')
->paginate(25);
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.tasks', compact('business', 'account', 'tasks', 'locations', 'selectedLocation'));
}
/**
* Store a note for an account
*/
public function storeNote(Request $request, Business $business, Business $account)
{
$request->validate([
'note' => 'required|string|max:5000',
]);
CrmEvent::log(
sellerBusinessId: $business->id,
eventType: 'note_added',
summary: $request->input('note'),
buyerBusinessId: $account->id,
userId: auth()->id(),
channel: 'system'
);
return redirect()
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Note added successfully.');
}
/**
* Store a new contact for an account
*/
public function storeContact(Request $request, Business $business, Business $account)
{
$validated = $request->validate([
'first_name' => 'required|string|max:100',
'last_name' => 'nullable|string|max:100',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'title' => 'nullable|string|max:100',
]);
$contact = $account->contacts()->create($validated);
// Return JSON for AJAX requests
if ($request->expectsJson()) {
return response()->json([
'id' => $contact->id,
'first_name' => $contact->first_name,
'last_name' => $contact->last_name,
'email' => $contact->email,
'phone' => $contact->phone,
'title' => $contact->title,
]);
}
return redirect()
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
->with('success', 'Contact added successfully.');
}
/**
* Show edit contact form
*/
public function editContact(Request $request, Business $business, Business $account, Contact $contact)
{
// Verify contact belongs to this account
if ($contact->business_id !== $account->id) {
abort(404);
}
return view('seller.crm.accounts.contacts-edit', compact('business', 'account', 'contact'));
}
/**
* Update a contact
*/
public function updateContact(Request $request, Business $business, Business $account, Contact $contact)
{
// Verify contact belongs to this account
if ($contact->business_id !== $account->id) {
abort(404);
}
$validated = $request->validate([
'first_name' => 'required|string|max:100',
'last_name' => 'nullable|string|max:100',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:50',
'title' => 'nullable|string|max:100',
'is_active' => 'boolean',
]);
// Handle checkbox - if not sent, default to false
$validated['is_active'] = $request->boolean('is_active');
$contact->update($validated);
return redirect()
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
->with('success', 'Contact updated successfully.');
}
/**
* Delete a contact
*/
public function destroyContact(Request $request, Business $business, Business $account, Contact $contact)
{
// Verify contact belongs to this account
if ($contact->business_id !== $account->id) {
abort(404);
}
$contact->delete();
return redirect()
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
->with('success', 'Contact deleted successfully.');
}
/**
* Show location edit form
*/
public function editLocation(Request $request, Business $business, Business $account, Location $location)
{
// Verify location belongs to this account
if ($location->business_id !== $account->id) {
abort(404);
}
// Load contacts that can be assigned to this location
$contacts = $account->contacts()->orderBy('first_name')->get();
// Load currently assigned contacts with their roles
$locationContacts = $location->contacts()->get();
// Available roles for location contacts
$contactRoles = [
'buyer' => 'Buyer',
'ap' => 'Accounts Payable',
'marketing' => 'Marketing',
'gm' => 'General Manager',
'inventory' => 'Inventory Manager',
'other' => 'Other',
];
// CannaiQ platforms
$cannaiqPlatforms = [
'dutchie' => 'Dutchie',
'jane' => 'Jane',
'weedmaps' => 'Weedmaps',
'leafly' => 'Leafly',
'iheartjane' => 'iHeartJane',
'other' => 'Other',
];
return view('seller.crm.accounts.locations-edit', compact(
'business',
'account',
'location',
'contacts',
'locationContacts',
'contactRoles',
'cannaiqPlatforms'
));
}
/**
* Update location
*/
public function updateLocation(Request $request, Business $business, Business $account, Location $location)
{
// Verify location belongs to this account
if ($location->business_id !== $account->id) {
abort(404);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'address' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:50',
'zipcode' => 'nullable|string|max:20',
'phone' => 'nullable|string|max:50',
'email' => 'nullable|email|max:255',
'is_active' => 'boolean',
'cannaiq_platform' => 'nullable|string|max:50',
'cannaiq_store_slug' => 'nullable|string|max:255',
'cannaiq_store_id' => 'nullable|string|max:100',
'cannaiq_store_name' => 'nullable|string|max:255',
'contact_roles' => 'nullable|array',
'contact_roles.*.contact_id' => 'required|exists:contacts,id',
'contact_roles.*.role' => 'required|string|max:50',
'contact_roles.*.is_primary' => 'boolean',
]);
// Handle checkbox
$validated['is_active'] = $request->boolean('is_active');
// Clear CannaiQ fields if platform is cleared
if (empty($validated['cannaiq_platform'])) {
$validated['cannaiq_store_slug'] = null;
$validated['cannaiq_store_id'] = null;
$validated['cannaiq_store_name'] = null;
}
// Update location
$location->update([
'name' => $validated['name'],
'address' => $validated['address'] ?? null,
'city' => $validated['city'] ?? null,
'state' => $validated['state'] ?? null,
'zipcode' => $validated['zipcode'] ?? null,
'phone' => $validated['phone'] ?? null,
'email' => $validated['email'] ?? null,
'is_active' => $validated['is_active'],
'cannaiq_platform' => $validated['cannaiq_platform'] ?? null,
'cannaiq_store_slug' => $validated['cannaiq_store_slug'] ?? null,
'cannaiq_store_id' => $validated['cannaiq_store_id'] ?? null,
'cannaiq_store_name' => $validated['cannaiq_store_name'] ?? null,
]);
// Sync location contacts
if (isset($validated['contact_roles'])) {
$syncData = [];
foreach ($validated['contact_roles'] as $contactRole) {
// Verify contact belongs to this account
$contact = Contact::where('business_id', $account->id)
->where('id', $contactRole['contact_id'])
->first();
if ($contact) {
$syncData[$contact->id] = [
'role' => $contactRole['role'],
'is_primary' => $contactRole['is_primary'] ?? false,
];
}
}
$location->contacts()->sync($syncData);
} else {
$location->contacts()->detach();
}
return redirect()
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Location updated successfully.');
}
/**
* Search CannaiQ stores for linking
*/
public function searchCannaiqStores(Request $request, Business $business, Business $account, Location $location)
{
// Verify location belongs to this account
if ($location->business_id !== $account->id) {
abort(404);
}
$request->validate([
'platform' => 'required|string|max:50',
'query' => 'required|string|min:2|max:100',
]);
try {
$client = app(CannaiqClient::class);
$results = $client->searchStores(
platform: $request->input('platform'),
query: $request->input('query')
);
return response()->json([
'success' => true,
'stores' => $results,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Failed to search stores: '.$e->getMessage(),
'stores' => [],
], 500);
}
}
}