Files
hub/app/Http/Controllers/DashboardController.php
kelly 7954804998 feat: add Brand Portal navigation, usage billing, and orchestrator improvements
Brand Portal:
- Add dedicated brand-portal-sidebar component with restricted navigation
- Add brand-portal-app layout for Brand Manager users
- Update all brand-portal views to use new restricted layout
- Add EnsureBrandManagerAccess middleware

Usage-Based Billing:
- Add usage_metrics, plan_usage_metrics, business_usage_counters tables
- Add UsageMetric, PlanUsageMetric, BusinessUsageCounter models
- Add UsageDashboard Filament page
- Add PlanUsageSeeder, UsageMetricsSeeder, SampleUsageDataSeeder

Orchestrator Enhancements:
- Add orchestrator_runs table for batch tracking
- Add OrchestratorRun model
- Add OrchestratorCrossBrandService for multi-brand campaigns
- Add orchestrator marketing config, message variants, playbook seeders

Promotions:
- Add promo tracking fields to orders
- Add key_selling_points to brands
- Add PromotionRecommendationEngine service
- Add InBrandPromoHelper, CrossBrandPromoHelper
- Add BuyerPromoIntelligence service
- Add promotion-templates config

Documentation:
- Add BRAND_MANAGER_SUITE.md
- Add USAGE_BASED_BILLING.md
- Add PLANS_AND_PRICING.md
- Add SALES_ORCHESTRATOR.md and related docs

Tests:
- Add BrandDashboardTest, BrandProfileAccessTest
- Add BrandSelectorTest, ProductPreviewTest
- Add OrchestratorBrandIntegrationTest
2025-12-01 14:24:47 -07:00

1764 lines
75 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers;
use App\Models\Business;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
/**
* 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 - Focused KPI view with business-wide metrics
* Accessible to all seller users
*/
public function overview(Business $business)
{
// Get filtered brand IDs for multi-tenancy
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
$brandNames = \App\Models\Brand::whereIn('id', $brandIds)->pluck('name')->toArray();
// Time periods
$currentStart = now()->subDays(30);
$currentEnd = now();
$previousStart = now()->subDays(60);
$previousEnd = now()->subDays(30);
$start7 = now()->subDays(7);
// Get core metrics for current and previous periods
$currentStats = $this->getOrderStats($brandNames, $currentStart, $currentEnd);
$previousStats = $this->getOrderStats($brandNames, $previousStart, $previousEnd);
// Calculate KPIs
$revenueLast30 = ($currentStats->revenue ?? 0) / 100;
$ordersLast30 = $currentStats->order_count ?? 0;
$unitsSoldLast30 = $currentStats->total_units ?? 0;
$averageOrderValueLast30 = $ordersLast30 > 0 ? $revenueLast30 / $ordersLast30 : 0;
// Previous period metrics for growth calculation
$previousRevenue = ($previousStats->revenue ?? 0) / 100;
$previousOrders = $previousStats->order_count ?? 0;
$previousUnits = $previousStats->total_units ?? 0;
$previousAOV = $previousOrders > 0 ? $previousRevenue / $previousOrders : 0;
// Growth percentages
$revenueGrowth = $previousRevenue > 0 ? (($revenueLast30 - $previousRevenue) / $previousRevenue) * 100 : 0;
$ordersGrowth = $previousOrders > 0 ? (($ordersLast30 - $previousOrders) / $previousOrders) * 100 : 0;
$unitsGrowth = $previousUnits > 0 ? (($unitsSoldLast30 - $previousUnits) / $previousUnits) * 100 : 0;
$aovGrowth = $previousAOV > 0 ? (($averageOrderValueLast30 - $previousAOV) / $previousAOV) * 100 : 0;
// Count active brands
$activeBrandCount = \App\Models\Brand::where('business_id', $business->id)
->where('is_active', true)
->count();
// Count active buyers (distinct buyer businesses that ordered in last 30 days)
$activeBuyerCount = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->distinct('orders.business_id')
->count('orders.business_id');
// Get inventory alerts count
$activeInventoryAlertsCount = \App\Models\InventoryAlert::where('business_id', $business->id)
->whereIn('alert_type', ['low_stock', 'out_of_stock'])
->active()
->count();
// Get active promotions count
$activePromotionCount = \App\Models\Broadcast::where('business_id', $business->id)
->whereIn('status', ['scheduled', 'sending'])
->count();
// Top Products (last 30 days by revenue)
$topProducts = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->select('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
->selectRaw('SUM(order_items.quantity) as total_units')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->groupBy('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
->orderByDesc('total_revenue')
->limit(10)
->get()
->map(function ($item) {
$item->total_revenue_dollars = $item->total_revenue / 100;
return $item;
});
// Top Brands (last 30 days)
$topBrands = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->select('order_items.brand_name')
->selectRaw('SUM(order_items.quantity) as total_units')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->selectRaw('COUNT(DISTINCT orders.id) as order_count')
->groupBy('order_items.brand_name')
->orderByDesc('total_revenue')
->get()
->map(function ($item) use ($previousStart, $previousEnd) {
$item->total_revenue_dollars = $item->total_revenue / 100;
// Calculate growth vs previous period
$prevBrandStats = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->where('order_items.brand_name', $item->brand_name)
->whereBetween('orders.created_at', [$previousStart, $previousEnd])
->selectRaw('SUM(order_items.line_total) as revenue')
->first();
$prevRevenue = ($prevBrandStats->revenue ?? 0) / 100;
$item->growth_pct = $prevRevenue > 0 ? (($item->total_revenue_dollars - $prevRevenue) / $prevRevenue) * 100 : null;
return $item;
});
// Needs Attention - Combined collection of items requiring immediate action
$needsAttention = collect();
// 1. Low Stock SKUs (inventory items at or below reorder point)
$lowStockItems = \App\Models\InventoryItem::where('business_id', $business->id)
->whereNotNull('reorder_point')
->whereColumn('quantity_on_hand', '<=', 'reorder_point')
->where('quantity_on_hand', '>', 0)
->with('product')
->latest('updated_at')
->take(5)
->get();
foreach ($lowStockItems as $item) {
$needsAttention->push([
'type' => 'low_stock',
'priority' => 'medium',
'message' => ($item->product ? $item->product->name : 'Unknown Product').' is low on stock ('.$item->quantity_on_hand.' units, reorder at '.$item->reorder_point.')',
'timestamp' => $item->updated_at,
'data' => [
'inventory_item_id' => $item->id,
'product_id' => $item->product_id,
'quantity' => $item->quantity_on_hand,
'reorder_point' => $item->reorder_point,
],
]);
}
// 2. Out of Stock SKUs
$outOfStockItems = \App\Models\InventoryItem::where('business_id', $business->id)
->where('quantity_on_hand', '<=', 0)
->with('product')
->latest('updated_at')
->take(5)
->get();
foreach ($outOfStockItems as $item) {
$needsAttention->push([
'type' => 'out_of_stock',
'priority' => 'high',
'message' => ($item->product ? $item->product->name : 'Unknown Product').' is out of stock',
'timestamp' => $item->updated_at,
'data' => [
'inventory_item_id' => $item->id,
'product_id' => $item->product_id,
],
]);
}
// 3. Engaged Buyers with No Recent Orders
// Get buyers with high engagement but no orders in last 30 days
$engagedNoOrders = \App\Models\Analytics\BuyerEngagementScore::where('seller_business_id', $business->id)
->where('engagement_level', 'hot')
->with('buyerBusiness')
->get()
->filter(function ($score) use ($brandNames, $currentStart) {
// Check if they have NO orders in last 30 days
$hasRecentOrder = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->where('orders.business_id', $score->buyer_business_id)
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->exists();
return ! $hasRecentOrder;
})
->take(3);
foreach ($engagedNoOrders as $score) {
$needsAttention->push([
'type' => 'engaged_no_order',
'priority' => 'medium',
'message' => ($score->buyerBusiness ? $score->buyerBusiness->name : 'Unknown Buyer').' is highly engaged (score: '.$score->total_score.') but hasn\'t ordered in 30 days',
'timestamp' => $score->last_activity_at,
'data' => [
'buyer_business_id' => $score->buyer_business_id,
'engagement_score' => $score->total_score,
'engagement_level' => $score->engagement_level,
],
]);
}
// 4. Expiring Promotions (stub - TODO: implement when promotion expiry dates are tracked)
// $expiringPromotions = \App\Models\Broadcast::where('business_id', $business->id)
// ->whereNotNull('expires_at')
// ->where('expires_at', '<=', now()->addDays(7))
// ->where('expires_at', '>=', now())
// ->get();
// foreach ($expiringPromotions as $promo) {
// $needsAttention->push([...]);
// }
// Sort by priority (high > medium > low) and then by timestamp
$priorityOrder = ['high' => 1, 'medium' => 2, 'low' => 3];
$needsAttention = $needsAttention->sortBy(function ($item) use ($priorityOrder) {
return [$priorityOrder[$item['priority']] ?? 4, $item['timestamp']->timestamp * -1];
})->values();
// Recent Activity - last 7 days, limit 15-20 entries
$recentActivity = $this->buildRecentActivityForOverview($business, $brandIds, $brandNames, $start7, 20);
// Orchestrator Widget Data (if enabled)
$orchestratorWidget = (new \App\Http\Controllers\Seller\OrchestratorController)->getWidgetData($business);
return view('seller.dashboard.overview', compact(
'business',
'revenueLast30',
'ordersLast30',
'unitsSoldLast30',
'averageOrderValueLast30',
'revenueGrowth',
'ordersGrowth',
'unitsGrowth',
'aovGrowth',
'activeBrandCount',
'activeBuyerCount',
'activeInventoryAlertsCount',
'activePromotionCount',
'topProducts',
'topBrands',
'needsAttention',
'recentActivity',
'orchestratorWidget'
));
}
/**
* Dashboard Analytics - Deep analytical view with buyer intelligence
* Requires analytics module permissions (handled by middleware)
*/
public function analytics(Business $business)
{
// Get filtered brand IDs for multi-tenancy
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
$brandNames = \App\Models\Brand::whereIn('id', $brandIds)->pluck('name')->toArray();
// Time period (last 30 days)
$currentStart = now()->subDays(30);
$previousStart = now()->subDays(60);
$previousEnd = now()->subDays(30);
// ===== A) KPIs =====
// Buyer Engagement Score - Average score for all buyers engaging with this seller
$buyerEngagementScores = \App\Models\Analytics\BuyerEngagementScore::where('seller_business_id', $business->id)
->get();
$buyerEngagementScore = $buyerEngagementScores->avg('total_score') ?? 0;
// Menu to Order Conversion - Stub (requires analytics event tracking)
$menuToOrderConversion = 0; // TODO: Implement when analytics events are tracked
// Email Engagement Rate - From broadcasts
$broadcasts = \App\Models\Broadcast::where('business_id', $business->id)
->where('total_sent', '>', 0)
->get();
$totalSent = $broadcasts->sum('total_sent');
$totalOpened = $broadcasts->sum('total_opened');
$totalClicked = $broadcasts->sum('total_clicked');
$emailEngagementRate = $totalSent > 0 ? (($totalOpened + $totalClicked) / ($totalSent * 2)) * 100 : 0;
// High-Intent Buyers Count
$highIntentBuyersCount = \App\Models\Analytics\BuyerEngagementScore::where('seller_business_id', $business->id)
->where('engagement_level', 'hot')
->count();
// Category Performance Index - Stub (would need category tracking)
$categoryPerformanceIndex = null; // TODO: Implement when product categories are tracked
// Sell-Through Rate (last 30 days)
$unitsSoldLast30 = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->sum('order_items.quantity');
$totalInventory = \App\Models\InventoryItem::where('business_id', $business->id)
->sum('quantity_on_hand');
$sellThroughRate30d = ($totalInventory + $unitsSoldLast30) > 0
? ($unitsSoldLast30 / ($totalInventory + $unitsSoldLast30)) * 100
: 0;
// ===== B) Funnel Data (last 30 days) =====
$funnelData = [
'menuViews' => 0, // TODO: Requires analytics event tracking
'emailOpens' => \App\Models\Broadcast::where('business_id', $business->id)
->where('created_at', '>=', $currentStart)
->sum('total_opened'),
'clicks' => \App\Models\Broadcast::where('business_id', $business->id)
->where('created_at', '>=', $currentStart)
->sum('total_clicked'),
'sessions' => \App\Models\Analytics\BuyerEngagementScore::where('seller_business_id', $business->id)
->sum('total_sessions'),
'orders' => \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->distinct('orders.id')
->count('orders.id'),
'revenue' => \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->sum('orders.total') / 100, // Convert cents to dollars
];
// ===== C) Product Performance Tabs =====
// Revenue Tab
$topProductsByRevenue = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->select('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
->selectRaw('SUM(order_items.quantity) as total_units')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->groupBy('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
->orderByDesc('total_revenue')
->limit(10)
->get()
->map(function ($item) {
$item->total_revenue_dollars = $item->total_revenue / 100;
return $item;
});
// Units Tab
$topProductsByUnits = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->select('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
->selectRaw('SUM(order_items.quantity) as total_units')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->groupBy('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
->orderByDesc('total_units')
->limit(10)
->get()
->map(function ($item) {
$item->total_revenue_dollars = $item->total_revenue / 100;
return $item;
});
// Margin Tab - Stub (requires cost data on products)
$topProductsByMargin = collect([]); // TODO: Implement when product cost data is available
// Intent Tab - Stub (requires analytics event tracking)
$topProductsByIntent = collect([]); // TODO: Implement when product view/add-to-cart events are tracked
// ===== D) Brand Performance Grid =====
$brandPerformance = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->select('order_items.brand_name')
->selectRaw('SUM(order_items.quantity) as total_units')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->selectRaw('COUNT(DISTINCT orders.id) as order_count')
->selectRaw('COUNT(DISTINCT orders.business_id) as buyer_count')
->groupBy('order_items.brand_name')
->orderByDesc('total_revenue')
->get()
->map(function ($item) use ($business, $previousStart, $previousEnd) {
$item->total_revenue_dollars = $item->total_revenue / 100;
// Calculate growth vs previous period
$prevBrandStats = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->where('order_items.brand_name', $item->brand_name)
->whereBetween('orders.created_at', [$previousStart, $previousEnd])
->selectRaw('SUM(order_items.line_total) as revenue')
->first();
$prevRevenue = ($prevBrandStats->revenue ?? 0) / 100;
$item->growth_pct = $prevRevenue > 0 ? (($item->total_revenue_dollars - $prevRevenue) / $prevRevenue) * 100 : null;
// Get average engagement score for buyers of this brand
// This is complex - would need to link orders to buyer businesses to engagement scores
$item->avg_engagement_score = null; // TODO: Implement buyer-brand engagement correlation
// Get brand object for additional data (logo, etc.)
$item->brand = \App\Models\Brand::where('business_id', $business->id)
->where('name', $item->brand_name)
->first();
return $item;
});
// ===== E) Buyer Behavior Table =====
$buyerBehavior = \App\Models\Analytics\BuyerEngagementScore::where('seller_business_id', $business->id)
->with('buyerBusiness')
->orderByDesc('total_score')
->limit(20)
->get()
->map(function ($score) use ($brandNames) {
// Get last order date for this buyer
$lastOrder = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->where('orders.business_id', $score->buyer_business_id)
->whereIn('order_items.brand_name', $brandNames)
->latest('orders.created_at')
->first();
return [
'buyer_business_id' => $score->buyer_business_id,
'buyer_name' => $score->buyerBusiness->name ?? 'Unknown',
'total_score' => $score->total_score,
'engagement_level' => $score->engagement_level,
'last_activity_at' => $score->last_activity_at,
'last_order_at' => $lastOrder ? $lastOrder->created_at : null,
'total_sessions' => $score->total_sessions,
'total_product_views' => $score->total_product_views,
'total_email_opens' => $score->total_email_opens,
'total_email_clicks' => $score->total_email_clicks,
];
});
// ===== F) Inventory Health =====
$inventoryHealth = \App\Models\InventoryItem::where('business_id', $business->id)
->with(['product', 'location'])
->get()
->map(function ($item) use ($currentStart, $brandNames) {
// Calculate days of supply based on 30-day average sales
$unitsSold30d = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->where('order_items.product_id', $item->product_id)
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->sum('order_items.quantity');
$avgDailySales = $unitsSold30d / 30;
$daysOfSupply = $avgDailySales > 0 ? round($item->quantity_on_hand / $avgDailySales, 1) : null;
// Determine health status
$healthStatus = 'healthy';
if ($item->quantity_on_hand <= 0) {
$healthStatus = 'out_of_stock';
} elseif ($item->reorder_point && $item->quantity_on_hand <= $item->reorder_point) {
$healthStatus = 'low_stock';
} elseif ($daysOfSupply && $daysOfSupply > 90) {
$healthStatus = 'overstock';
}
return [
'product_id' => $item->product_id,
'product_name' => $item->product->name ?? 'Unknown',
'location_name' => $item->location->name ?? 'Unknown',
'quantity_on_hand' => $item->quantity_on_hand,
'reorder_point' => $item->reorder_point,
'units_sold_30d' => $unitsSold30d,
'days_of_supply' => $daysOfSupply,
'health_status' => $healthStatus,
];
})
->sortBy(function ($item) {
// Sort by health priority: out_of_stock first, then low_stock, then overstock
$priority = ['out_of_stock' => 1, 'low_stock' => 2, 'overstock' => 3, 'healthy' => 4];
return $priority[$item['health_status']] ?? 5;
})
->values();
return view('seller.dashboard.analytics', compact(
'business',
'buyerEngagementScore',
'menuToOrderConversion',
'emailEngagementRate',
'highIntentBuyersCount',
'categoryPerformanceIndex',
'sellThroughRate30d',
'funnelData',
'topProductsByRevenue',
'topProductsByUnits',
'topProductsByMargin',
'topProductsByIntent',
'brandPerformance',
'buyerBehavior',
'inventoryHealth'
));
}
/**
* Dashboard Sales - Rep-specific sales dashboard
* Shows personal KPIs, tasks, accounts, and activity for sales reps
*/
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.');
}
// ===== Time Periods =====
$now = now();
$start30 = $now->copy()->subDays(30);
$start90 = $now->copy()->subDays(90);
$start7 = $now->copy()->subDays(7);
// Get brand filtering
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
$brandNames = \App\Models\Brand::whereIn('id', $brandIds)->pluck('name')->toArray();
// ===== A) Rep KPIs (Last 30 Days) =====
// TODO: Once rep assignment is implemented, filter these by $user's assigned accounts
// Revenue and Orders (currently shows all for this business)
$revenueAndOrders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $start30)
->selectRaw('COUNT(DISTINCT orders.id) as order_count')
->selectRaw('SUM(orders.total) as revenue')
->first();
$myRevenueLast30 = ($revenueAndOrders->revenue ?? 0) / 100;
$myOrdersLast30 = $revenueAndOrders->order_count ?? 0;
// Tasks - Stub (Task model not yet implemented)
$myOpenTasks = 0; // TODO: Implement when Task model is created
$myCompletedTasksLast30 = 0; // TODO: Implement when Task model is created
// Activity tracking - Stub (detailed activity logging not yet implemented)
$myCallsLoggedLast30 = 0; // TODO: Implement when activity logging is added
$myEmailsSentLast30 = 0; // TODO: Implement when activity logging is added
$myMenusSentLast30 = 0; // TODO: Implement when menu sends are tracked
// Active accounts count
$myActiveAccountsCount = \App\Models\Analytics\BuyerEngagementScore::where('seller_business_id', $business->id)
->distinct('buyer_business_id')
->count('buyer_business_id');
// ===== B) Hot Accounts =====
$hotAccounts = \App\Models\Analytics\BuyerEngagementScore::where('seller_business_id', $business->id)
->where('engagement_level', 'hot')
->with('buyerBusiness')
->orderByDesc('total_score')
->limit(10)
->get()
->map(function ($score) use ($brandNames) {
// Get last order for this buyer
$lastOrder = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->where('orders.business_id', $score->buyer_business_id)
->whereIn('order_items.brand_name', $brandNames)
->latest('orders.created_at')
->first();
return [
'buyer_business_id' => $score->buyer_business_id,
'buyerBusinessName' => $score->buyerBusiness->name ?? 'Unknown',
'total_score' => $score->total_score,
'engagement_level' => $score->engagement_level,
'last_activity_at' => $score->last_activity_at,
'last_order_at' => $lastOrder ? $lastOrder->created_at : null,
];
});
// ===== C) My Accounts Overview =====
$myAccounts = \App\Models\Analytics\BuyerEngagementScore::where('seller_business_id', $business->id)
->with('buyerBusiness')
->orderByDesc('total_score')
->get()
->map(function ($score) use ($brandNames, $start30, $start90) {
// Get orders in last 90 days
$orders90d = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->where('orders.business_id', $score->buyer_business_id)
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $start90)
->selectRaw('COUNT(DISTINCT orders.id) as order_count')
->selectRaw('SUM(orders.total) as revenue')
->first();
$ordersLast90d = $orders90d->order_count ?? 0;
$totalRevenueLast90d = ($orders90d->revenue ?? 0) / 100;
// Check recent orders for health status
$recentOrders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->where('orders.business_id', $score->buyer_business_id)
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $start30)
->exists();
// Determine health label
if ($recentOrders) {
$healthLabel = 'Healthy';
} elseif ($ordersLast90d > 0) {
$healthLabel = 'At Risk';
} else {
$healthLabel = 'Idle';
}
return [
'buyer_business_id' => $score->buyer_business_id,
'buyerBusinessName' => $score->buyerBusiness->name ?? 'Unknown',
'engagementScore' => $score->total_score,
'lastActivityAt' => $score->last_activity_at,
'ordersLast90d' => $ordersLast90d,
'totalRevenueLast90d' => $totalRevenueLast90d,
'healthLabel' => $healthLabel,
];
});
// ===== D) My Brand Performance =====
$myBrandPerformance = \App\Models\Brand::where('business_id', $business->id)
->where('is_active', true)
->get()
->map(function ($brand) use ($start30) {
// Get current period stats
$currentStats = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->where('order_items.brand_name', $brand->name)
->where('orders.created_at', '>=', $start30)
->selectRaw('SUM(order_items.quantity) as units')
->selectRaw('SUM(order_items.line_total) as revenue')
->first();
$unitsSoldLast30 = $currentStats->units ?? 0;
$revenueLast30 = ($currentStats->revenue ?? 0) / 100;
// Get previous period for growth calculation
$previousStart = now()->subDays(60);
$previousEnd = now()->subDays(30);
$previousStats = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->where('order_items.brand_name', $brand->name)
->whereBetween('orders.created_at', [$previousStart, $previousEnd])
->selectRaw('SUM(order_items.line_total) as revenue')
->first();
$previousRevenue = ($previousStats->revenue ?? 0) / 100;
$growthPctLast30 = $previousRevenue > 0 ? (($revenueLast30 - $previousRevenue) / $previousRevenue) * 100 : null;
return [
'brandName' => $brand->name,
'brand' => $brand,
'unitsSoldLast30' => $unitsSoldLast30,
'revenueLast30' => $revenueLast30,
'growthPctLast30' => $growthPctLast30,
];
})
->sortByDesc('revenueLast30')
->values();
// ===== D2) Brand Overview - Detailed per-brand metrics =====
$brandOverview = \App\Models\Brand::where('business_id', $business->id)
->where('is_active', true)
->get()
->map(function ($brand) use ($now) {
// Total buyers carrying this brand (distinct businesses that ordered)
$buyerCount = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->where('order_items.brand_name', $brand->name)
->distinct('orders.business_id')
->count('orders.business_id');
// Active products count
$activeProducts = \App\Models\Product::where('brand_id', $brand->id)
->where('is_active', true)
->count();
// Out-of-stock SKUs count
$outOfStockCount = \App\Models\InventoryItem::whereHas('product', function ($q) use ($brand) {
$q->where('brand_id', $brand->id);
})
->where('quantity_on_hand', '<=', 0)
->count();
// Promotions running this week (broadcasts with this brand)
$weekStart = $now->copy()->startOfWeek();
$weekEnd = $now->copy()->endOfWeek();
$activePromotions = \App\Models\Broadcast::where('business_id', $brand->business_id)
->whereIn('status', ['scheduled', 'sending', 'sent'])
->where(function ($q) use ($weekStart, $weekEnd) {
$q->whereBetween('scheduled_at', [$weekStart, $weekEnd])
->orWhereBetween('started_sending_at', [$weekStart, $weekEnd]);
})
->count();
// 30-day sales trend (daily data for sparkline)
$salesTrend = [];
for ($i = 29; $i >= 0; $i--) {
$date = $now->copy()->subDays($i)->format('Y-m-d');
$dayRevenue = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->where('order_items.brand_name', $brand->name)
->whereDate('orders.created_at', $date)
->sum('order_items.line_total');
$salesTrend[] = round($dayRevenue / 100, 2);
}
return [
'brand' => $brand,
'brandName' => $brand->name,
'buyerCount' => $buyerCount,
'activeProducts' => $activeProducts,
'outOfStockCount' => $outOfStockCount,
'activePromotions' => $activePromotions,
'salesTrend' => $salesTrend,
'totalRevenue30d' => array_sum($salesTrend),
];
})
->sortByDesc('totalRevenue30d')
->values();
// ===== E) Activity Feed =====
$myActivityFeed = collect();
// Recent orders
$recentOrders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $start7)
->select('orders.*')
->distinct()
->with('business')
->latest('orders.created_at')
->limit(5)
->get();
foreach ($recentOrders as $order) {
$myActivityFeed->push([
'type' => 'order',
'message' => 'New order #'.$order->order_number.' from '.$order->business->name,
'timestamp' => $order->created_at,
]);
}
// Recent invoices
$recentInvoices = \App\Models\Invoice::whereHas('order.items.product.brand', function ($query) use ($brandIds) {
$query->whereIn('id', $brandIds);
})
->where('created_at', '>=', $start7)
->with(['order', 'business'])
->latest('created_at')
->limit(5)
->get();
foreach ($recentInvoices as $invoice) {
$myActivityFeed->push([
'type' => 'invoice',
'message' => 'Invoice '.$invoice->invoice_number.' generated for '.$invoice->business->name,
'timestamp' => $invoice->created_at,
]);
}
// Sort by timestamp
$myActivityFeed = $myActivityFeed->sortByDesc('timestamp')->take(10)->values();
// ===== F) Playbook Suggestions =====
$playbookSuggestions = [];
// Suggestion 1: High-intent accounts with no recent orders
$highIntentNoOrders = $hotAccounts->filter(function ($account) use ($start30) {
return ! $account['last_order_at'] || $account['last_order_at']->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) {
$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 view('seller.dashboard.sales', compact(
'business',
'user',
'myRevenueLast30',
'myOrdersLast30',
'myOpenTasks',
'myCompletedTasksLast30',
'myCallsLoggedLast30',
'myEmailsSentLast30',
'myMenusSentLast30',
'myActiveAccountsCount',
'hotAccounts',
'myAccounts',
'myBrandPerformance',
'brandOverview',
'myActivityFeed',
'playbookSuggestions'
));
}
/**
* Business-scoped dashboard
*/
public function businessDashboard(Request $request, Business $business)
{
$user = $request->user();
// Check onboarding status
$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
$userDepartments = $user->departments ?? collect();
$departmentCodes = $userDepartments->pluck('code');
// Determine dashboard type based on departments
$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;
$isSuperAdmin = $user->hasRole('super-admin');
// Dashboard blocks determined ONLY by department groups (not by ownership or admin role)
// Users see data for their assigned departments - add user to department for access
$showSalesMetrics = $hasSales;
$showProcessingMetrics = $hasSolventless;
$showFleetMetrics = $hasDelivery;
// Get filtered brand IDs for multi-tenancy
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
// Get orders filtered by brand through order_items.brand_name
$brandNames = \App\Models\Brand::whereIn('id', $brandIds)->pluck('name')->toArray();
// Current period (last 30 days)
$currentStart = now()->subDays(30);
$currentEnd = now();
// Previous period (30-60 days ago)
$previousStart = now()->subDays(60);
$previousEnd = now()->subDays(30);
// Get order IDs and revenue in single optimized queries using joins
$currentStats = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$currentStart, $currentEnd])
->selectRaw('COUNT(DISTINCT orders.id) as order_count, SUM(orders.total) as revenue')
->first();
$previousStats = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$previousStart, $previousEnd])
->selectRaw('COUNT(DISTINCT orders.id) as order_count, SUM(orders.total) as revenue')
->first();
// Revenue
$currentRevenue = ($currentStats->revenue ?? 0) / 100;
$previousRevenue = ($previousStats->revenue ?? 0) / 100;
$revenueChange = $previousRevenue > 0 ? (($currentRevenue - $previousRevenue) / $previousRevenue) * 100 : 0;
// Orders count
$currentOrders = $currentStats->order_count ?? 0;
$previousOrders = $previousStats->order_count ?? 0;
$ordersChange = $previousOrders > 0 ? (($currentOrders - $previousOrders) / $previousOrders) * 100 : 0;
// Products count (active products for selected brand(s))
$currentProducts = \App\Models\Product::whereIn('brand_id', $brandIds)->count();
$previousProducts = \App\Models\Product::whereIn('brand_id', $brandIds)->count(); // No historical tracking yet
$productsChange = 0;
// Average order value
$currentAvgOrderValue = $currentOrders > 0 ? $currentRevenue / $currentOrders : 0;
$previousAvgOrderValue = $previousOrders > 0 ? $previousRevenue / $previousOrders : 0;
$avgOrderValueChange = $previousAvgOrderValue > 0 ? (($currentAvgOrderValue - $previousAvgOrderValue) / $previousAvgOrderValue) * 100 : 0;
$dashboardData = [
'revenue' => [
'current' => number_format($currentRevenue, 2, '.', ''),
'previous' => number_format($previousRevenue, 2, '.', ''),
'change' => round($revenueChange, 1),
],
'orders' => [
'current' => $currentOrders,
'previous' => $previousOrders,
'change' => round($ordersChange, 1),
],
'products' => [
'current' => $currentProducts,
'previous' => $previousProducts,
'change' => round($productsChange, 1),
],
'avg_order_value' => [
'current' => number_format($currentAvgOrderValue, 2, '.', ''),
'previous' => number_format($previousAvgOrderValue, 2, '.', ''),
'change' => round($avgOrderValueChange, 1),
],
];
// Invoice Statistics
$invoices = \App\Models\Invoice::with(['order.items.product.brand'])
->whereHas('order.items.product.brand', function ($query) use ($brandIds) {
$query->whereIn('id', $brandIds);
})
->get();
$stats = [
'total_invoices' => $invoices->count(),
'pending_invoices' => $invoices->where('payment_status', 'unpaid')->count(),
'paid_invoices' => $invoices->where('payment_status', 'paid')->count(),
'overdue_invoices' => $invoices->filter(fn ($inv) => $inv->isOverdue())->count(),
'total_outstanding' => $invoices->where('payment_status', 'unpaid')->sum('amount_due'),
];
// Recent Invoices (last 5)
$recentInvoices = \App\Models\Invoice::with(['order.items.product.brand', 'business'])
->whereHas('order.items.product.brand', function ($query) use ($brandIds) {
$query->whereIn('id', $brandIds);
})
->latest()
->take(5)
->get();
// Mock progress data for now (TODO: implement proper business setup tracking)
$progressData = [];
$progressSummary = ['completion_percentage' => 100];
// Get chart data for revenue visualization
$chartData = $this->getRevenueChartData($brandIds);
// Get processing metrics if user is in solventless departments
$processingData = null;
if ($showProcessingMetrics) {
$processingData = $this->getProcessingMetrics($business, $userDepartments);
}
// Get fleet metrics if user is in delivery department
$fleetData = null;
if ($showFleetMetrics) {
$fleetData = $this->getFleetMetrics($business);
}
// Get low-stock alerts for sales metrics
$lowStockAlerts = collect([]);
$lowStockCount = 0;
if ($showSalesMetrics) {
// Get active low-stock alerts for this business's brands
$lowStockAlerts = \App\Models\InventoryAlert::where('business_id', $business->id)
->whereIn('alert_type', ['low_stock', 'out_of_stock'])
->active()
->with(['product', 'inventoryItem'])
->latest('triggered_at')
->take(5)
->get();
// Count total low-stock items
$lowStockCount = \App\Models\InventoryAlert::where('business_id', $business->id)
->whereIn('alert_type', ['low_stock', 'out_of_stock'])
->active()
->count();
}
// Get recent notifications for the dashboard widget
$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();
// Get top products by revenue (last 30 days) for sales metrics
$topProducts = collect([]);
if ($showSalesMetrics) {
$topProducts = \App\Models\OrderItem::join('orders', 'order_items.order_id', '=', 'orders.id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $currentStart)
->select('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
->selectRaw('SUM(order_items.quantity) as total_units')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->groupBy('order_items.product_id', 'order_items.product_name', 'order_items.brand_name')
->orderByDesc('total_revenue')
->limit(5)
->get()
->map(function ($item) {
$item->total_revenue_dollars = $item->total_revenue / 100;
return $item;
});
}
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,
]);
}
/**
* Generate revenue chart data for different time periods
* Supports 7 days, 30 days, and 12 months views
*/
private function getRevenueChartData(array $brandIds): array
{
$brandNames = \App\Models\Brand::whereIn('id', $brandIds)->pluck('name')->toArray();
// 7 Days Data
$sevenDaysData = $this->getRevenueByPeriod($brandNames, 7, 'days');
// 30 Days Data
$thirtyDaysData = $this->getRevenueByPeriod($brandNames, 30, 'days');
// 12 Months Data
$twelveMonthsData = $this->getRevenueByPeriod($brandNames, 12, 'months');
return [
'7_days' => $sevenDaysData,
'30_days' => $thirtyDaysData,
'12_months' => $twelveMonthsData,
];
}
/**
* 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
*/
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);
// Get wash reports (hash washes) - using Conversion model
$currentWashes = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->where('created_at', '>=', $currentStart)
->count();
$previousWashes = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereBetween('created_at', [$previousStart, $previousEnd])
->count();
$washesChange = $previousWashes > 0 ? (($currentWashes - $previousWashes) / $previousWashes) * 100 : 0;
// Average Yield (calculate from metadata)
$currentWashesWithYield = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->where('created_at', '>=', $currentStart)
->get();
$currentYield = $currentWashesWithYield->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;
}) ?? 0;
$previousWashesWithYield = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereBetween('created_at', [$previousStart, $previousEnd])
->get();
$previousYield = $previousWashesWithYield->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;
}) ?? 0;
$yieldChange = $previousYield > 0 ? (($currentYield - $previousYield) / $previousYield) * 100 : 0;
// 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();
// Add past performance data for each component
$componentsWithPerformance = $components->map(function ($component) use ($business) {
$strainName = str_replace(' - Fresh Frozen', '', $component->name);
// Get past washes for this strain
$pastWashes = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereJsonContains('metadata->stage_1->strain', $strainName)
->orderBy('completed_at', 'desc')
->take(10)
->get();
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 fleet/delivery metrics
*/
private function getFleetMetrics(Business $business): array
{
// Current metrics
$totalDrivers = \App\Models\Driver::where('business_id', $business->id)->count();
$activeVehicles = \App\Models\Vehicle::where('business_id', $business->id)
->where('status', 'active')
->count();
$totalVehicles = \App\Models\Vehicle::where('business_id', $business->id)->count();
// Deliveries today (would need Delivery model - placeholder)
$deliveriesToday = 0;
return [
'drivers' => [
'current' => $totalDrivers,
'previous' => $totalDrivers,
'change' => 0,
],
'active_vehicles' => [
'current' => $activeVehicles,
'previous' => $activeVehicles,
'change' => 0,
],
'total_vehicles' => [
'current' => $totalVehicles,
'previous' => $totalVehicles,
'change' => 0,
],
'deliveries_today' => [
'current' => $deliveriesToday,
'previous' => 0,
'change' => 0,
],
];
}
/**
* 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();
}
}