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