- 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)
1424 lines
58 KiB
PHP
1424 lines
58 KiB
PHP
<?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',
|
||
],
|
||
];
|
||
});
|
||
}
|
||
}
|