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
1764 lines
75 KiB
PHP
1764 lines
75 KiB
PHP
<?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();
|
||
}
|
||
}
|