Files
hub/app/Http/Controllers/DashboardController.php
kelly c7d6ee5e21 feat: multiple UI/UX improvements and case-insensitive search
- Refactor New Quote page to enterprise data-entry layout (2-column, dense)
- Add Payment Terms dropdown (COD, NET 15, NET 30, NET 60)
- Fix sidebar menu active states and route names
- Fix brand filter badge visibility on brands page
- Remove company_name references (use business instead)
- Polish Promotions page layout
- Fix double-click issue on sidebar menu collapse
- Make all searches case-insensitive (like -> ilike for PostgreSQL)
2025-12-14 15:36:00 -07:00

1424 lines
58 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Business;
use App\Models\Crm\CrmDeal;
use App\Models\Crm\CrmMeetingBooking;
use App\Models\Crm\CrmTask;
use App\Models\Crm\CrmThread;
use App\Services\Crm\CrmSlaService;
use App\Services\Dashboard\CommandCenterService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class DashboardController extends Controller
{
/**
* Cache TTL for dashboard metrics (5 minutes)
*/
private const DASHBOARD_CACHE_TTL = 300;
public function __construct(
protected CommandCenterService $commandCenterService,
) {}
/**
* Main dashboard redirect - automatically routes to business context
* Redirects to /s/{business}/dashboard based on user's primary business
*/
public function index(Request $request)
{
$user = $request->user();
// Get user's primary business
$business = $user->primaryBusiness();
// If no business exists, redirect to setup
if (! $business) {
return redirect()->route('seller.setup');
}
// Redirect to business-scoped dashboard
return redirect()->route('seller.business.dashboard', $business->slug);
}
/**
* Dashboard Overview - Revenue Command Center
*
* Single source of truth for all seller dashboard metrics.
* Uses CommandCenterService which provides:
* - DB/service as source of truth
* - Redis as cache layer
* - Explicit scoping (business|brand|user) per metric
*/
public function overview(Request $request, Business $business)
{
$user = $request->user();
// Get all Command Center data via the single service
$commandCenterData = $this->commandCenterService->getData($business, $user);
return view('seller.dashboard.overview', [
'business' => $business,
'commandCenter' => $commandCenterData,
]);
}
/**
* Dashboard Analytics - Deep analytical view with buyer intelligence
* Requires analytics module permissions (handled by middleware)
*
* NOTE: All metrics are pre-calculated by CalculateDashboardMetrics job
* and stored in Redis. This method only reads from Redis for instant response.
*/
public function analytics(Business $business)
{
// Read pre-calculated metrics from Redis
$redisKey = "dashboard:{$business->id}:analytics";
$cachedMetrics = \Illuminate\Support\Facades\Redis::get($redisKey);
if ($cachedMetrics) {
$data = json_decode($cachedMetrics, true);
// Map cached data to view variables
$buyerEngagementScore = $data['kpis']['buyer_engagement_score'] ?? 0;
$menuToOrderConversion = 0; // Still a stub
$emailEngagementRate = $data['kpis']['email_engagement_rate'] ?? 0;
$highIntentBuyersCount = $data['kpis']['high_intent_buyers_count'] ?? 0;
$categoryPerformanceIndex = null; // Still a stub
$sellThroughRate30d = $data['kpis']['sell_through_rate_30d'] ?? 0;
$funnelData = $data['funnel'] ?? $this->getEmptyFunnelData();
// Convert arrays back to collections of objects for view compatibility
$topProductsByRevenue = collect($data['top_products_by_revenue'] ?? [])->map(fn ($item) => (object) $item);
$topProductsByUnits = collect($data['top_products_by_units'] ?? [])->map(fn ($item) => (object) $item);
$topProductsByMargin = collect([]); // Still a stub
$topProductsByIntent = collect([]); // Still a stub
$brandPerformance = collect($data['brand_performance'] ?? [])->map(fn ($item) => (object) $item);
$buyerBehavior = collect($data['buyer_behavior'] ?? [])->map(fn ($item) => (object) $item);
$inventoryHealth = collect($data['inventory_health'] ?? [])->map(fn ($item) => (object) $item);
} else {
// No cached data - dispatch job and return empty state
\App\Jobs\CalculateDashboardMetrics::dispatch($business->id);
$buyerEngagementScore = 0;
$menuToOrderConversion = 0;
$emailEngagementRate = 0;
$highIntentBuyersCount = 0;
$categoryPerformanceIndex = null;
$sellThroughRate30d = 0;
$funnelData = $this->getEmptyFunnelData();
$topProductsByRevenue = collect([]);
$topProductsByUnits = collect([]);
$topProductsByMargin = collect([]);
$topProductsByIntent = collect([]);
$brandPerformance = collect([]);
$buyerBehavior = collect([]);
$inventoryHealth = collect([]);
}
return view('seller.dashboard.analytics', compact(
'business',
'buyerEngagementScore',
'menuToOrderConversion',
'emailEngagementRate',
'highIntentBuyersCount',
'categoryPerformanceIndex',
'sellThroughRate30d',
'funnelData',
'topProductsByRevenue',
'topProductsByUnits',
'topProductsByMargin',
'topProductsByIntent',
'brandPerformance',
'buyerBehavior',
'inventoryHealth'
));
}
/**
* Get empty funnel data structure for when no cached data exists
*/
private function getEmptyFunnelData(): array
{
return [
'menuViews' => 0,
'emailOpens' => 0,
'clicks' => 0,
'sessions' => 0,
'orders' => 0,
'revenue' => 0,
];
}
/**
* Dashboard Sales - Rep-specific sales dashboard
* Shows personal KPIs, tasks, accounts, and activity for sales reps
*
* NOTE: All metrics are pre-calculated by CalculateDashboardMetrics job
* and stored in Redis. This method only reads from Redis for instant response.
*/
public function sales(Business $business)
{
$user = auth()->user();
// ===== Access Control =====
// Sales Dashboard is a CORE seller feature (not premium/analytics-gated).
// Any authenticated seller who belongs to this business can access it.
// Includes 'both' user type for vertically integrated businesses.
if (! $user || ! in_array($user->user_type, ['seller', 'both', 'admin', 'superadmin'])) {
abort(403, 'Access denied. Sales dashboard is for seller users only.');
}
// Check user belongs to this business (or is admin/superadmin)
if (! in_array($user->user_type, ['admin', 'superadmin']) && ! $user->businesses->contains($business->id)) {
abort(403, 'You do not belong to this business.');
}
// Read pre-calculated metrics from Redis
$redisKey = "dashboard:{$business->id}:sales";
$cachedMetrics = \Illuminate\Support\Facades\Redis::get($redisKey);
// Task stubs (not cached - still TODO)
$myOpenTasks = 0;
$myCompletedTasksLast30 = 0;
$myCallsLoggedLast30 = 0;
$myEmailsSentLast30 = 0;
$myMenusSentLast30 = 0;
if ($cachedMetrics) {
$data = json_decode($cachedMetrics, true);
// Map cached data to view variables
$myRevenueLast30 = $data['kpis']['revenue_last_30'] ?? 0;
$myOrdersLast30 = $data['kpis']['orders_last_30'] ?? 0;
$myActiveAccountsCount = $data['kpis']['active_accounts_count'] ?? 0;
$hotAccounts = collect($data['hot_accounts'] ?? []);
$myAccounts = collect($data['my_accounts'] ?? []);
// Transform brand data to include 'brand' object for view compatibility
$myBrandPerformance = collect($data['my_brand_performance'] ?? [])->map(function ($item) {
$item['brand'] = (object) [
'hashid' => $item['brand_hashid'] ?? null,
'logo_path' => $item['brand_logo_path'] ?? null,
];
return $item;
});
$brandOverview = collect($data['brand_overview'] ?? [])->map(function ($item) {
$item['brand'] = (object) [
'hashid' => $item['brand_hashid'] ?? null,
'logo_path' => $item['brand_logo_path'] ?? null,
];
return $item;
});
$myActivityFeed = collect($data['activity_feed'] ?? []);
// Generate playbook suggestions from cached data
$playbookSuggestions = $this->generatePlaybookSuggestions($hotAccounts, $myAccounts, $myBrandPerformance);
} else {
// No cached data - dispatch job and return empty state
\App\Jobs\CalculateDashboardMetrics::dispatch($business->id);
$myRevenueLast30 = 0;
$myOrdersLast30 = 0;
$myActiveAccountsCount = 0;
$hotAccounts = collect([]);
$myAccounts = collect([]);
$myBrandPerformance = collect([]);
$brandOverview = collect([]);
$myActivityFeed = collect([]);
$playbookSuggestions = ['Data is being calculated. Please refresh in a moment.'];
}
return view('seller.dashboard.sales', compact(
'business',
'user',
'myRevenueLast30',
'myOrdersLast30',
'myOpenTasks',
'myCompletedTasksLast30',
'myCallsLoggedLast30',
'myEmailsSentLast30',
'myMenusSentLast30',
'myActiveAccountsCount',
'hotAccounts',
'myAccounts',
'myBrandPerformance',
'brandOverview',
'myActivityFeed',
'playbookSuggestions'
));
}
/**
* Generate playbook suggestions from cached sales data
*/
private function generatePlaybookSuggestions($hotAccounts, $myAccounts, $myBrandPerformance): array
{
$playbookSuggestions = [];
$start30 = now()->subDays(30);
// Suggestion 1: High-intent accounts with no recent orders
$highIntentNoOrders = $hotAccounts->filter(function ($account) use ($start30) {
$lastOrderAt = $account['last_order_at'] ?? null;
if (! $lastOrderAt) {
return true;
}
// Handle both string and Carbon instances
$lastOrderDate = is_string($lastOrderAt) ? \Carbon\Carbon::parse($lastOrderAt) : $lastOrderAt;
return $lastOrderDate->lt($start30);
});
if ($highIntentNoOrders->isNotEmpty()) {
$account = $highIntentNoOrders->first();
$playbookSuggestions[] = 'Follow up with '.$account['buyerBusinessName'].' high engagement (score: '.$account['total_score'].') but no orders in 30 days.';
}
// Suggestion 2: At-risk accounts
$atRiskAccounts = $myAccounts->where('healthLabel', 'At Risk')->take(1);
if ($atRiskAccounts->isNotEmpty()) {
$account = $atRiskAccounts->first();
$playbookSuggestions[] = 'Re-engage '.$account['buyerBusinessName'].' had orders in last 90 days but none recently.';
}
// Suggestion 3: Strong performing brands
$topBrand = $myBrandPerformance->first();
if ($topBrand && ($topBrand['revenueLast30'] ?? 0) > 0) {
$playbookSuggestions[] = 'Promote '.$topBrand['brandName'].' to more accounts strong performance ($'.number_format($topBrand['revenueLast30'], 2).' in 30 days).';
}
// Suggestion 4: Idle accounts
$idleAccounts = $myAccounts->where('healthLabel', 'Idle')->count();
if ($idleAccounts > 0) {
$playbookSuggestions[] = 'Reactivation opportunity: '.$idleAccounts.' idle accounts with no orders in 90 days.';
}
// Ensure at least one suggestion
if (empty($playbookSuggestions)) {
$playbookSuggestions[] = 'Keep up the great work! Monitor your hot accounts for follow-up opportunities.';
}
return $playbookSuggestions;
}
/**
* Business-scoped dashboard
* Reads pre-calculated metrics from Redis (populated by CalculateDashboardMetrics job)
* Falls back to on-demand calculation if Redis data is missing
*/
public function businessDashboard(Request $request, Business $business)
{
$user = $request->user();
// Check onboarding status (cheap - no DB queries)
$needsOnboarding = ! $user->business_onboarding_completed
|| in_array($business->status, ['not_started', 'in_progress', 'rejected']);
$isPending = $business->status === 'submitted';
$isRejected = $business->status === 'rejected';
// Get user's departments to determine which metrics to show (already loaded on user)
$userDepartments = $user->departments ?? collect();
$departmentCodes = $userDepartments->pluck('code');
// Determine dashboard type based on departments (no queries)
$hasSolventless = $departmentCodes->intersect(['LAZ-SOLV', 'CRG-SOLV'])->isNotEmpty();
$hasSales = $departmentCodes->intersect(['CBD-SALES', 'CBD-MKTG'])->isNotEmpty();
$hasDelivery = $departmentCodes->contains('CRG-DELV');
$isOwner = $business->owner_user_id === $user->id;
$showSalesMetrics = $hasSales;
$showProcessingMetrics = $hasSolventless;
$showFleetMetrics = $hasDelivery;
// Read pre-calculated metrics from Redis
$redisKey = "dashboard:{$business->id}:metrics";
$cachedMetrics = \Illuminate\Support\Facades\Redis::get($redisKey);
if ($cachedMetrics) {
// Fast path: Read from Redis (no DB queries for metrics)
$metrics = json_decode($cachedMetrics, true);
$dashboardData = $metrics['core'] ?? $this->getEmptyDashboardData();
$stats = $metrics['invoice'] ?? $this->getEmptyInvoiceStats();
$chartData = $metrics['chart'] ?? ['7_days' => [], '30_days' => [], '12_months' => []];
$processingData = $showProcessingMetrics ? ($metrics['processing'] ?? null) : null;
$fleetData = $showFleetMetrics ? ($metrics['fleet'] ?? null) : null;
// Convert low stock alerts back to collection format
$lowStockData = $metrics['low_stock'] ?? ['count' => 0, 'alerts' => []];
$lowStockCount = $lowStockData['count'];
$lowStockAlerts = $showSalesMetrics ? collect($lowStockData['alerts'])->map(function ($alert) {
return (object) $alert;
}) : collect([]);
// Convert top products back to collection format
$topProducts = $showSalesMetrics ? collect($metrics['top_products'] ?? [])->map(function ($product) {
return (object) $product;
}) : collect([]);
} else {
// Fallback: Dispatch job to calculate and use empty data for now
// This ensures first request doesn't block
\App\Jobs\CalculateDashboardMetrics::dispatch($business->id);
$dashboardData = $this->getEmptyDashboardData();
$stats = $this->getEmptyInvoiceStats();
$chartData = ['7_days' => ['labels' => [], 'values' => []], '30_days' => ['labels' => [], 'values' => []], '12_months' => ['labels' => [], 'values' => []]];
$processingData = null;
$fleetData = null;
$lowStockAlerts = collect([]);
$lowStockCount = 0;
$topProducts = collect([]);
}
// === REAL-TIME DATA (lightweight queries - always fresh) ===
// Recent invoices - small result set with eager loading
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
$recentInvoices = \App\Models\Invoice::with(['order:id,business_id', 'business:id,name'])
->whereHas('order.items.product.brand', function ($query) use ($brandIds) {
$query->whereIn('id', $brandIds);
})
->latest()
->take(5)
->get();
// Notifications - user-specific, indexed query
$recentNotifications = \App\Models\Notification::forBusiness($business->id)
->where(function ($q) use ($user) {
$q->whereNull('user_id')
->orWhere('user_id', $user->id);
})
->latest()
->take(5)
->get();
$unreadNotificationCount = \App\Models\Notification::forBusiness($business->id)
->where(function ($q) use ($user) {
$q->whereNull('user_id')
->orWhere('user_id', $user->id);
})
->unread()
->count();
// Static data
$progressData = [];
$progressSummary = ['completion_percentage' => 100];
return view('seller.dashboard', [
'user' => $user,
'business' => $business,
'needsOnboarding' => $needsOnboarding,
'isPending' => $isPending,
'isRejected' => $isRejected,
'isOwner' => $isOwner,
'dashboardData' => $dashboardData,
'progressData' => $progressData,
'progressSummary' => $progressSummary,
'chartData' => $chartData,
'invoiceStats' => $stats,
'recentInvoices' => $recentInvoices,
'showSalesMetrics' => $showSalesMetrics,
'showProcessingMetrics' => $showProcessingMetrics,
'showFleetMetrics' => $showFleetMetrics,
'processingData' => $processingData,
'fleetData' => $fleetData,
'userDepartments' => $userDepartments,
'lowStockAlerts' => $lowStockAlerts,
'lowStockCount' => $lowStockCount,
'topProducts' => $topProducts,
'recentNotifications' => $recentNotifications,
'unreadNotificationCount' => $unreadNotificationCount,
]);
}
/**
* Get empty dashboard data structure for fallback
*/
private function getEmptyDashboardData(): array
{
return [
'revenue' => ['current' => '0.00', 'previous' => '0.00', 'change' => 0],
'orders' => ['current' => 0, 'previous' => 0, 'change' => 0],
'products' => ['current' => 0, 'previous' => 0, 'change' => 0],
'avg_order_value' => ['current' => '0.00', 'previous' => '0.00', 'change' => 0],
];
}
/**
* Get empty invoice stats structure for fallback
*/
private function getEmptyInvoiceStats(): array
{
return [
'total_invoices' => 0,
'pending_invoices' => 0,
'paid_invoices' => 0,
'overdue_invoices' => 0,
'total_outstanding' => 0,
];
}
/**
* Generate revenue chart data for different time periods
* Supports 7 days, 30 days, and 12 months views
* Optimized to use single query for all periods
*/
private function getRevenueChartData(array $brandIds): array
{
$brandNames = \App\Models\Brand::whereIn('id', $brandIds)->pluck('name')->toArray();
// Single query for the full 12-month period (covers all time ranges)
$start = now()->subMonths(12)->startOfDay();
$allOrders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $start)
->selectRaw('DATE(orders.created_at) as date, SUM(orders.total) as revenue')
->groupBy('date')
->orderBy('date', 'asc')
->get();
// Filter for different time periods from the same result set
$sevenDaysStart = now()->subDays(7)->startOfDay();
$thirtyDaysStart = now()->subDays(30)->startOfDay();
$sevenDaysOrders = $allOrders->filter(fn ($o) => \Carbon\Carbon::parse($o->date) >= $sevenDaysStart);
$thirtyDaysOrders = $allOrders->filter(fn ($o) => \Carbon\Carbon::parse($o->date) >= $thirtyDaysStart);
return [
'7_days' => $this->generateDailyData($sevenDaysOrders, 7),
'30_days' => $this->generateDailyData($thirtyDaysOrders, 30),
'12_months' => $this->generateMonthlyData($allOrders, 12),
];
}
/**
* Get revenue grouped by date or month for a specific period
*/
private function getRevenueByPeriod(array $brandNames, int $count, string $unit): array
{
$start = now()->sub($count, $unit)->startOfDay();
$end = now()->endOfDay();
// Optimized query using join instead of subquery
$orders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$start, $end])
->selectRaw('DATE(orders.created_at) as date, SUM(orders.total) as revenue')
->groupBy('date')
->orderBy('date', 'asc')
->get();
// Generate labels and values based on period type
if ($unit === 'months') {
return $this->generateMonthlyData($orders, $count);
} else {
return $this->generateDailyData($orders, $count);
}
}
/**
* Generate daily revenue data with all dates filled in
*/
private function generateDailyData($orders, int $days): array
{
$labels = [];
$values = [];
// Create a map of existing data
$revenueByDate = $orders->pluck('revenue', 'date')->toArray();
// Generate all dates in range
for ($i = $days - 1; $i >= 0; $i--) {
$date = now()->subDays($i);
$dateKey = $date->format('Y-m-d');
$labels[] = $date->format('M j'); // "Jan 15"
// Convert cents to dollars
$revenue = isset($revenueByDate[$dateKey]) ? $revenueByDate[$dateKey] / 100 : 0;
$values[] = round($revenue, 2);
}
return [
'labels' => $labels,
'values' => $values,
];
}
/**
* Generate monthly revenue data with all months filled in
*/
private function generateMonthlyData($orders, int $months): array
{
$labels = [];
$values = [];
// Group by month
$revenueByMonth = [];
foreach ($orders as $order) {
$monthKey = \Carbon\Carbon::parse($order->date)->format('Y-m');
if (! isset($revenueByMonth[$monthKey])) {
$revenueByMonth[$monthKey] = 0;
}
$revenueByMonth[$monthKey] += $order->revenue;
}
// Generate all months in range
for ($i = $months - 1; $i >= 0; $i--) {
$date = now()->subMonths($i);
$monthKey = $date->format('Y-m');
$labels[] = $date->format('M Y'); // "Jan 2025"
// Convert cents to dollars
$revenue = isset($revenueByMonth[$monthKey]) ? $revenueByMonth[$monthKey] / 100 : 0;
$values[] = round($revenue, 2);
}
return [
'labels' => $labels,
'values' => $values,
];
}
/**
* Get processing/manufacturing metrics for solventless departments
* Optimized with caching and reduced queries
*/
private function getProcessingMetrics(Business $business, $userDepartments): array
{
$solventlessDepts = $userDepartments->whereIn('code', ['LAZ-SOLV', 'CRG-SOLV']);
$departmentIds = $solventlessDepts->pluck('id');
// Current period (last 30 days)
$currentStart = now()->subDays(30);
$previousStart = now()->subDays(60);
$previousEnd = now()->subDays(30);
// Cache processing metrics for 5 minutes
$cacheKey = "dashboard.processing.{$business->id}";
$cachedMetrics = Cache::remember($cacheKey, self::DASHBOARD_CACHE_TTL, function () use ($business, $currentStart, $previousStart, $previousEnd) {
// Fetch all completed washes in one query for both periods (60 days)
$allWashes = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->where('created_at', '>=', $previousStart)
->select('id', 'metadata', 'created_at')
->get();
// Split into current and previous periods
$currentWashesData = $allWashes->filter(fn ($w) => $w->created_at >= $currentStart);
$previousWashesData = $allWashes->filter(fn ($w) => $w->created_at < $currentStart);
$currentWashes = $currentWashesData->count();
$previousWashes = $previousWashesData->count();
$washesChange = $previousWashes > 0 ? (($currentWashes - $previousWashes) / $previousWashes) * 100 : 0;
// Calculate yields from already-loaded data (avoid re-fetching)
$calculateYield = function ($collection) {
if ($collection->isEmpty()) {
return 0;
}
return $collection->avg(function ($conversion) {
$stage1 = $conversion->getStage1Data();
$stage2 = $conversion->getStage2Data();
if (! $stage1 || ! $stage2) {
return 0;
}
$startingWeight = $stage1['starting_weight'] ?? 0;
$totalYield = $stage2['total_yield'] ?? 0;
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
});
};
$currentYield = $calculateYield($currentWashesData);
$previousYield = $calculateYield($previousWashesData);
$yieldChange = $previousYield > 0 ? (($currentYield - $previousYield) / $previousYield) * 100 : 0;
return compact('currentWashes', 'previousWashes', 'washesChange', 'currentYield', 'previousYield', 'yieldChange');
});
extract($cachedMetrics);
// Active Work Orders
$activeWorkOrders = \App\Models\WorkOrder::where('business_id', $business->id)
->whereIn('department_id', $departmentIds)
->where('status', 'in_progress')
->count();
// Completed Work Orders (30 days)
$currentCompletedOrders = \App\Models\WorkOrder::where('business_id', $business->id)
->whereIn('department_id', $departmentIds)
->where('status', 'completed')
->where('updated_at', '>=', $currentStart)
->count();
$previousCompletedOrders = \App\Models\WorkOrder::where('business_id', $business->id)
->whereIn('department_id', $departmentIds)
->where('status', 'completed')
->whereBetween('updated_at', [$previousStart, $previousEnd])
->count();
$completedChange = $previousCompletedOrders > 0 ? (($currentCompletedOrders - $previousCompletedOrders) / $previousCompletedOrders) * 100 : 0;
// Get strain performance data
$strainPerformance = $this->getStrainPerformanceData($business, $currentStart);
// Get Idle Fresh Frozen data
$idleFreshFrozen = $this->getIdleFreshFrozen($business);
// Get current user's subdivision prefixes (first 3 chars of department codes)
$userSubdivisions = auth()->user()->departments()
->pluck('code')
->map(fn ($code) => substr($code, 0, 3))
->unique()
->values();
// Get all user IDs in the same subdivisions
$allowedOperatorIds = \App\Models\User::whereHas('departments', function ($q) use ($userSubdivisions) {
$q->whereIn(\DB::raw('SUBSTRING(code, 1, 3)'), $userSubdivisions->toArray());
})->pluck('id');
// Get Active Washes data
$activeWashes = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'in_progress')
->whereIn('operator_user_id', $allowedOperatorIds)
->with(['operator.departments'])
->orderBy('started_at', 'desc')
->take(5)
->get();
return [
'washes' => [
'current' => $currentWashes,
'previous' => $previousWashes,
'change' => round($washesChange, 1),
],
'yield' => [
'current' => number_format($currentYield, 1),
'previous' => number_format($previousYield, 1),
'change' => round($yieldChange, 1),
],
'active_orders' => [
'current' => $activeWorkOrders,
'previous' => $activeWorkOrders, // No historical tracking
'change' => 0,
],
'completed_orders' => [
'current' => $currentCompletedOrders,
'previous' => $previousCompletedOrders,
'change' => round($completedChange, 1),
],
'strain_performance' => $strainPerformance,
'idle_fresh_frozen' => $idleFreshFrozen,
'active_washes' => $activeWashes,
];
}
/**
* Get idle Fresh Frozen components ready for processing
*/
private function getIdleFreshFrozen(Business $business): array
{
// Find Fresh Frozen category
$ffCategory = \App\Models\ComponentCategory::where('business_id', $business->id)
->where('slug', 'fresh-frozen')
->first();
if (! $ffCategory) {
return [
'components' => collect([]),
'total_count' => 0,
'total_weight' => 0,
];
}
// Get all Fresh Frozen components with inventory
$components = \App\Models\Component::where('business_id', $business->id)
->where('component_category_id', $ffCategory->id)
->where('quantity_on_hand', '>', 0)
->where('is_active', true)
->orderBy('created_at', 'desc')
->limit(5) // Show top 5 on dashboard
->get();
// Extract strain names from components
$strainNames = $components->map(fn ($c) => str_replace(' - Fresh Frozen', '', $c->name))->toArray();
// Fetch ALL past washes for ALL strains in ONE query (eliminates N+1)
$allPastWashes = Cache::remember("dashboard.strain_washes.{$business->id}", self::DASHBOARD_CACHE_TTL, function () use ($business) {
return \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->select('id', 'metadata', 'completed_at')
->orderBy('completed_at', 'desc')
->limit(100) // Get recent washes
->get()
->groupBy(function ($wash) {
$stage1 = $wash->getStage1Data();
return $stage1['strain'] ?? 'unknown';
});
});
// Add past performance data for each component (no additional queries)
$componentsWithPerformance = $components->map(function ($component) use ($allPastWashes) {
$strainName = str_replace(' - Fresh Frozen', '', $component->name);
$pastWashes = ($allPastWashes[$strainName] ?? collect())->take(10);
if ($pastWashes->isEmpty()) {
$component->past_performance = [
'has_data' => false,
'wash_count' => 0,
'avg_yield' => null,
'avg_hash_quality' => null,
];
} else {
// Calculate average yield
$avgYield = $pastWashes->avg(function ($wash) {
$stage1 = $wash->getStage1Data();
$stage2 = $wash->getStage2Data();
if (! $stage1 || ! $stage2) {
return 0;
}
$startingWeight = $stage1['starting_weight'] ?? 0;
$totalYield = $stage2['total_yield'] ?? 0;
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
});
// Calculate average hash quality (Stage 2) - defensive extraction
$qualityGrades = [];
foreach ($pastWashes as $wash) {
$stage2 = $wash->getStage2Data();
if (! $stage2 || ! isset($stage2['yields'])) {
continue;
}
// Check all yield types for quality data (handles both hash and rosin structures)
foreach ($stage2['yields'] as $yieldType => $yieldData) {
if (isset($yieldData['quality']) && $yieldData['quality']) {
$qualityGrades[] = $yieldData['quality'];
}
}
}
// Only include quality if we have the data
if (empty($qualityGrades)) {
$component->past_performance = [
'has_data' => true, // Has wash data
'wash_count' => $pastWashes->count(),
'avg_yield' => round($avgYield, 1),
'avg_hash_quality' => null, // No quality data tracked
];
} else {
$avgQuality = $this->calculateAverageQuality($qualityGrades);
$component->past_performance = [
'has_data' => true,
'wash_count' => $pastWashes->count(),
'avg_yield' => round($avgYield, 1),
'avg_hash_quality' => $avgQuality['letter'],
];
}
}
return $component;
});
return [
'components' => $componentsWithPerformance,
'total_count' => $componentsWithPerformance->count(),
'total_weight' => $componentsWithPerformance->sum('quantity_on_hand'),
];
}
/**
* Get strain-specific performance metrics for processing department
*/
private function getStrainPerformanceData(Business $business, $startDate): array
{
// Get all completed washes for the period
$washes = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->where('created_at', '>=', $startDate)
->get();
// Group by strain and calculate metrics
$strainData = [];
foreach ($washes as $wash) {
$stage1 = $wash->getStage1Data();
$stage2 = $wash->getStage2Data();
if (! $stage1 || ! $stage2) {
continue;
}
$strain = $stage1['strain'] ?? 'Unknown';
$startingWeight = $stage1['starting_weight'] ?? 0;
$totalYield = $stage2['total_yield'] ?? 0;
if (! isset($strainData[$strain])) {
$strainData[$strain] = [
'strain' => $strain,
'wash_count' => 0,
'total_input' => 0,
'total_output' => 0,
'yields' => [],
'hash_stage1_quality_grades' => [],
'hash_stage2_quality_grades' => [],
];
}
$strainData[$strain]['wash_count']++;
$strainData[$strain]['total_input'] += $startingWeight;
$strainData[$strain]['total_output'] += $totalYield;
// Calculate yield percentage as number
$yieldPercentage = $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
$strainData[$strain]['yields'][] = $yieldPercentage;
// Collect quality grades from Stage 1 (hash - initial assessment)
if (isset($stage1['quality_grades'])) {
foreach ($stage1['quality_grades'] as $micron => $grade) {
if ($grade) {
$strainData[$strain]['hash_stage1_quality_grades'][] = $grade;
}
}
}
// Collect quality grades from Stage 2 (hash - final assessment after drying)
if (isset($stage2['yields'])) {
foreach ($stage2['yields'] as $type => $data) {
if (isset($data['quality']) && $data['quality']) {
$strainData[$strain]['hash_stage2_quality_grades'][] = $data['quality'];
}
}
}
}
// Calculate averages and format data
$results = [];
foreach ($strainData as $strain => $data) {
$avgYield = count($data['yields']) > 0 ? array_sum($data['yields']) / count($data['yields']) : 0;
// Calculate average quality grades
// Stage 1: Initial hash assessment during washing
// Stage 2: Final hash assessment after drying
$hashStage1Quality = $this->calculateAverageQuality($data['hash_stage1_quality_grades']);
$hashStage2Quality = $this->calculateAverageQuality($data['hash_stage2_quality_grades']);
$results[] = [
'strain' => $strain,
'wash_count' => $data['wash_count'],
'total_input_g' => round($data['total_input'], 2),
'total_output_g' => round($data['total_output'], 2),
'avg_yield_percentage' => round($avgYield, 2),
'avg_input_per_wash' => $data['wash_count'] > 0 ? round($data['total_input'] / $data['wash_count'], 2) : 0,
'avg_output_per_wash' => $data['wash_count'] > 0 ? round($data['total_output'] / $data['wash_count'], 2) : 0,
'avg_hash_quality' => $hashStage1Quality['letter'], // Stage 1 assessment
'avg_rosin_quality' => $hashStage2Quality['letter'], // Stage 2 assessment (still called rosin for backward compat with views)
'hash_quality_score' => $hashStage1Quality['score'],
'rosin_quality_score' => $hashStage2Quality['score'], // Actually hash Stage 2 score
];
}
// Sort by wash count (most processed strains first)
usort($results, function ($a, $b) {
return $b['wash_count'] - $a['wash_count'];
});
return $results;
}
/**
* Calculate average quality grade from array of letter grades
*
* @param array $grades Array of letter grades (A, B, C, D, F)
* @return array ['letter' => 'A', 'score' => 4.0]
*/
private function calculateAverageQuality(array $grades): array
{
if (empty($grades)) {
return ['letter' => null, 'score' => null];
}
// Convert letters to numeric scores
$gradeMap = ['A' => 4, 'B' => 3, 'C' => 2, 'D' => 1, 'F' => 0];
$scores = array_map(fn ($grade) => $gradeMap[$grade] ?? 0, $grades);
$avgScore = array_sum($scores) / count($scores);
// Convert back to letter grade
if ($avgScore >= 3.5) {
$letter = 'A';
} elseif ($avgScore >= 2.5) {
$letter = 'B';
} elseif ($avgScore >= 1.5) {
$letter = 'C';
} elseif ($avgScore >= 0.5) {
$letter = 'D';
} else {
$letter = 'F';
}
return ['letter' => $letter, 'score' => round($avgScore, 2)];
}
/**
* Get order statistics for a given period
* Returns order count, revenue, and total units
*/
private function getOrderStats(array $brandNames, $startDate, $endDate): object
{
return \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$startDate, $endDate])
->selectRaw('COUNT(DISTINCT orders.id) as order_count')
->selectRaw('SUM(orders.total) as revenue')
->selectRaw('SUM(order_items.quantity) as total_units')
->first() ?? (object) ['order_count' => 0, 'revenue' => 0, 'total_units' => 0];
}
/**
* Build recent activity feed from various sources
* Combines orders, invoices, inventory alerts, and broadcasts
*/
private function buildRecentActivity(Business $business, array $brandIds, array $brandNames): \Illuminate\Support\Collection
{
$activities = collect();
// Recent Orders (last 5)
$recentOrders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->select('orders.*')
->distinct()
->with('business')
->latest('orders.created_at')
->limit(5)
->get();
foreach ($recentOrders as $order) {
$activities->push([
'type' => 'order',
'icon' => 'heroicons--shopping-bag',
'message' => 'New order #'.$order->order_number.' from '.$order->business->name,
'timestamp' => $order->created_at,
'url' => route('seller.business.orders.show', [$business->slug, $order->order_number]),
]);
}
// Recent Invoices (last 5)
$recentInvoices = \App\Models\Invoice::whereHas('order.items.product.brand', function ($query) use ($brandIds) {
$query->whereIn('id', $brandIds);
})
->with(['order', 'business'])
->latest('created_at')
->limit(5)
->get();
foreach ($recentInvoices as $invoice) {
$activities->push([
'type' => 'invoice',
'icon' => 'heroicons--document-text',
'message' => 'Invoice '.$invoice->invoice_number.' generated for '.$invoice->business->name,
'timestamp' => $invoice->created_at,
'url' => route('seller.business.invoices.show', [$business->slug, $invoice->invoice_number]),
]);
}
// Recent Inventory Alerts (last 5)
$recentAlerts = \App\Models\InventoryAlert::where('business_id', $business->id)
->with('product')
->latest('triggered_at')
->limit(5)
->get();
foreach ($recentAlerts as $alert) {
$activities->push([
'type' => 'alert',
'icon' => 'heroicons--exclamation-triangle',
'message' => $alert->title.($alert->product ? ' - '.$alert->product->name : ''),
'timestamp' => $alert->triggered_at,
'url' => null, // No detail page for alerts yet
]);
}
// Recent Broadcasts (last 3)
$recentBroadcasts = \App\Models\Broadcast::where('business_id', $business->id)
->whereNotNull('started_sending_at')
->latest('started_sending_at')
->limit(3)
->get();
foreach ($recentBroadcasts as $broadcast) {
$activities->push([
'type' => 'broadcast',
'icon' => 'heroicons--megaphone',
'message' => 'Email campaign "'.$broadcast->name.'" sent to '.$broadcast->total_sent.' recipients',
'timestamp' => $broadcast->started_sending_at,
'url' => null, // Can add broadcast detail URL if available
]);
}
// Sort all activities by timestamp (most recent first) and take top 10
return $activities->sortByDesc('timestamp')->take(10)->values();
}
/**
* Build recent activity feed specifically for overview dashboard
* Filters to last 7 days and allows custom limit
*/
private function buildRecentActivityForOverview(Business $business, array $brandIds, array $brandNames, $startDate, int $limit = 20): \Illuminate\Support\Collection
{
$activities = collect();
// Recent Orders (last 7 days)
$recentOrders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $startDate)
->select('orders.*')
->distinct()
->with('business')
->latest('orders.created_at')
->get();
foreach ($recentOrders as $order) {
$activities->push([
'type' => 'order',
'message' => 'New order #'.$order->order_number.' from '.$order->business->name,
'timestamp' => $order->created_at,
]);
}
// Recent Invoices (last 7 days)
$recentInvoices = \App\Models\Invoice::whereHas('order.items.product.brand', function ($query) use ($brandIds) {
$query->whereIn('id', $brandIds);
})
->where('created_at', '>=', $startDate)
->with(['order', 'business'])
->latest('created_at')
->get();
foreach ($recentInvoices as $invoice) {
$activities->push([
'type' => 'invoice',
'message' => 'Invoice '.$invoice->invoice_number.' generated for '.$invoice->business->name,
'timestamp' => $invoice->created_at,
]);
}
// Recent Inventory Alerts (last 7 days)
$recentAlerts = \App\Models\InventoryAlert::where('business_id', $business->id)
->where('triggered_at', '>=', $startDate)
->with('product')
->latest('triggered_at')
->get();
foreach ($recentAlerts as $alert) {
$activities->push([
'type' => 'alert',
'message' => $alert->title.($alert->product ? ' - '.$alert->product->name : ''),
'timestamp' => $alert->triggered_at,
]);
}
// Sort all activities by timestamp (most recent first) and apply limit
return $activities->sortByDesc('timestamp')->take($limit)->values();
}
/**
* Get Sales Inbox data - unified view of items needing sales rep attention
* Includes overdue invoices, deals needing follow-up, tasks, and messages
*/
protected function getSalesInboxData(Business $business, $user): array
{
$overdue = [];
$upcoming = [];
$messages = [];
// Get brand IDs for this business
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
// Overdue invoices
$overdueInvoices = \App\Models\Invoice::whereHas('order.items.product.brand', function ($query) use ($brandIds) {
$query->whereIn('id', $brandIds);
})
->where('payment_status', 'pending')
->where('due_date', '<', now())
->with(['business:id,name', 'order:id,order_number'])
->orderBy('due_date', 'asc')
->limit(10)
->get();
foreach ($overdueInvoices as $invoice) {
$daysOverdue = now()->diffInDays($invoice->due_date, false);
$overdue[] = [
'type' => 'invoice',
'label' => "Invoice {$invoice->invoice_number} for {$invoice->business->name}",
'age' => $daysOverdue,
'link' => route('seller.business.invoices.show', [$business->slug, $invoice->invoice_number]),
];
}
// Overdue tasks
$overdueTasks = CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->where('due_at', '<', now())
->with(['contact:id,first_name,last_name'])
->orderBy('due_at', 'asc')
->limit(10)
->get();
foreach ($overdueTasks as $task) {
$daysOverdue = now()->diffInDays($task->due_at, false);
$contactName = $task->contact
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: 'Contact'
: 'Unknown';
$overdue[] = [
'type' => 'task',
'label' => $task->title.($contactName ? " ({$contactName})" : ''),
'age' => $daysOverdue,
'link' => route('seller.business.crm.tasks.index', $business->slug),
];
}
// Deals needing follow-up (no activity in 7+ days)
$staleDeals = CrmDeal::forBusiness($business->id)
->where('status', 'open')
->where(function ($q) {
$q->whereNull('last_activity_at')
->orWhere('last_activity_at', '<', now()->subDays(7));
})
->with(['account:id,name'])
->orderBy('last_activity_at', 'asc')
->limit(5)
->get();
foreach ($staleDeals as $deal) {
$daysSinceActivity = $deal->last_activity_at
? now()->diffInDays($deal->last_activity_at, false)
: -30;
$overdue[] = [
'type' => 'deal',
'label' => $deal->name.($deal->account ? " ({$deal->account->name})" : ''),
'age' => $daysSinceActivity,
'link' => route('seller.business.crm.deals.show', [$business->slug, $deal->hashid]),
];
}
// Upcoming tasks (due in next 7 days)
$upcomingTasks = CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->where('due_at', '>=', now())
->where('due_at', '<=', now()->addDays(7))
->with(['contact:id,first_name,last_name'])
->orderBy('due_at', 'asc')
->limit(10)
->get();
foreach ($upcomingTasks as $task) {
$daysUntilDue = now()->diffInDays($task->due_at, false);
$contactName = $task->contact
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: 'Contact'
: 'Unknown';
$upcoming[] = [
'type' => 'task',
'label' => $task->title.($contactName ? " ({$contactName})" : ''),
'days_until' => abs($daysUntilDue),
'link' => route('seller.business.crm.tasks.index', $business->slug),
];
}
// Upcoming meetings (next 7 days)
$upcomingMeetings = CrmMeetingBooking::where('business_id', $business->id)
->where('status', 'scheduled')
->where('start_at', '>=', now())
->where('start_at', '<=', now()->addDays(7))
->with(['contact:id,first_name,last_name'])
->orderBy('start_at', 'asc')
->limit(5)
->get();
foreach ($upcomingMeetings as $meeting) {
$daysUntil = now()->diffInDays($meeting->start_at, false);
$contactName = $meeting->contact
? trim($meeting->contact->first_name.' '.$meeting->contact->last_name) ?: 'Contact'
: 'Unknown';
$upcoming[] = [
'type' => 'meeting',
'label' => ($meeting->title ?? 'Meeting')." with {$contactName}",
'days_until' => abs($daysUntil),
'link' => route('seller.business.crm.calendar.index', $business->slug),
];
}
// CRM Messages (unread threads)
$unreadThreads = CrmThread::forBusiness($business->id)
->where('status', 'open')
->where('is_read', false)
->with(['contact:id,name,email'])
->orderBy('last_message_at', 'desc')
->limit(5)
->get();
foreach ($unreadThreads as $thread) {
$messages[] = [
'type' => 'message',
'label' => $thread->contact->name ?? $thread->contact->email ?? 'Unknown contact',
'preview' => $thread->last_message_preview ?? 'New message',
'time' => $thread->last_message_at?->diffForHumans() ?? 'Recently',
'link' => route('seller.business.crm.threads.show', [$business->slug, $thread->hashid]),
];
}
// Sort overdue by age (most overdue first)
usort($overdue, fn ($a, $b) => $a['age'] <=> $b['age']);
// Sort upcoming by days until due
usort($upcoming, fn ($a, $b) => $a['days_until'] <=> $b['days_until']);
return [
'overdue' => $overdue,
'upcoming' => $upcoming,
'messages' => $messages,
];
}
/**
* Get summary data for dashboard hub tiles
* Each tile shows key metrics with a link to the detail page
*/
protected function getHubTilesData(Business $business, $user): array
{
$cacheKey = "dashboard_hub_tiles:{$business->id}:{$user->id}";
return Cache::remember($cacheKey, 60, function () use ($business) {
// Inbox (CRM Threads)
$inboxStats = CrmThread::forBusiness($business->id)
->selectRaw("
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_count,
SUM(CASE WHEN status = 'open' AND is_read = false THEN 1 ELSE 0 END) as unread_count,
SUM(CASE WHEN status = 'open' AND priority = 'urgent' THEN 1 ELSE 0 END) as urgent_count
")
->first();
// Deals
$dealStats = CrmDeal::forBusiness($business->id)
->selectRaw("
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_count,
SUM(CASE WHEN status = 'open' THEN value ELSE 0 END) as pipeline_value,
SUM(CASE WHEN status = 'won' AND EXTRACT(MONTH FROM actual_close_date) = ? AND EXTRACT(YEAR FROM actual_close_date) = ? THEN value ELSE 0 END) as won_this_month
", [now()->month, now()->year])
->first();
// Tasks
$taskStats = CrmTask::where('seller_business_id', $business->id)
->selectRaw('
SUM(CASE WHEN completed_at IS NULL THEN 1 ELSE 0 END) as pending_count,
SUM(CASE WHEN completed_at IS NULL AND DATE(due_at) = CURRENT_DATE THEN 1 ELSE 0 END) as due_today,
SUM(CASE WHEN completed_at IS NULL AND due_at < NOW() THEN 1 ELSE 0 END) as overdue_count
')
->first();
// Calendar/Meetings (upcoming in next 7 days)
$upcomingMeetings = CrmMeetingBooking::where('business_id', $business->id)
->where('status', 'scheduled')
->where('start_at', '>=', now())
->where('start_at', '<=', now()->addDays(7))
->count();
$todayMeetings = CrmMeetingBooking::where('business_id', $business->id)
->where('status', 'scheduled')
->whereDate('start_at', today())
->count();
// Buyer Intelligence
$buyerStats = [
'total' => 0,
'at_risk' => 0,
'high_value' => 0,
];
if (class_exists(BuyerEngagementScore::class)) {
$buyerStats = [
'total' => BuyerEngagementScore::forBusiness($business->id)->count(),
'at_risk' => BuyerEngagementScore::forBusiness($business->id)->where('engagement_level', 'cold')->count(),
'high_value' => BuyerEngagementScore::forBusiness($business->id)->highValue()->count(),
];
}
// Team Performance (SLA)
$slaMetrics = ['compliance_rate' => 100, 'avg_response_time' => 0];
try {
$slaService = app(CrmSlaService::class);
$slaMetrics = $slaService->getMetrics($business->id, 30);
} catch (\Exception $e) {
// SLA service not available
}
// Orchestrator tasks count
$orchestratorTasks = 0;
try {
$orchestratorData = (new \App\Http\Controllers\Seller\OrchestratorController)->getWidgetData($business);
$orchestratorTasks = $orchestratorData['total_count'] ?? 0;
} catch (\Exception $e) {
// Orchestrator not available
}
return [
'inbox' => [
'open' => $inboxStats->open_count ?? 0,
'unread' => $inboxStats->unread_count ?? 0,
'urgent' => $inboxStats->urgent_count ?? 0,
'route' => 'seller.business.crm.threads.index',
],
'deals' => [
'open' => $dealStats->open_count ?? 0,
'pipeline_value' => $dealStats->pipeline_value ?? 0,
'won_this_month' => $dealStats->won_this_month ?? 0,
'route' => 'seller.business.crm.deals.index',
],
'tasks' => [
'pending' => $taskStats->pending_count ?? 0,
'due_today' => $taskStats->due_today ?? 0,
'overdue' => $taskStats->overdue_count ?? 0,
'route' => 'seller.business.crm.tasks.index',
],
'calendar' => [
'upcoming' => $upcomingMeetings,
'today' => $todayMeetings,
'route' => 'seller.business.crm.calendar.index',
],
'buyers' => [
'total' => $buyerStats['total'],
'at_risk' => $buyerStats['at_risk'],
'high_value' => $buyerStats['high_value'],
'route' => 'seller.business.buyer-intelligence.index',
],
'team' => [
'sla_compliance' => $slaMetrics['compliance_rate'] ?? 100,
'avg_response_time' => $slaMetrics['avg_response_time'] ?? 0,
'route' => 'seller.business.crm.dashboard.team',
],
'orchestrator' => [
'tasks' => $orchestratorTasks,
'route' => 'seller.business.orchestrator.index',
],
'analytics' => [
'route' => 'seller.business.dashboard.analytics',
],
];
});
}
}