Merge pull request 'feat: add sales rep system with territories, commissions, and dashboard' (#3) from feat/sales-rep-system into develop
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
274
app/Http/Controllers/Seller/Sales/AccountsController.php
Normal file
274
app/Http/Controllers/Seller/Sales/AccountsController.php
Normal file
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AccountNote;
|
||||
use App\Models\Business;
|
||||
use App\Models\Order;
|
||||
use App\Models\SalesRepAssignment;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountsController extends Controller
|
||||
{
|
||||
/**
|
||||
* My Accounts - list of accounts assigned to this sales rep
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Get account assignments with eager loading
|
||||
$assignments = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->accounts()
|
||||
->with(['assignable', 'assignable.primaryLocation', 'assignable.contacts'])
|
||||
->get();
|
||||
|
||||
// Get account metrics in batch
|
||||
$accountIds = $assignments->pluck('assignable_id');
|
||||
$metrics = $this->getAccountMetrics($accountIds);
|
||||
|
||||
// Build account list with metrics
|
||||
$accounts = $assignments->map(function ($assignment) use ($metrics) {
|
||||
$accountId = $assignment->assignable_id;
|
||||
$accountMetrics = $metrics[$accountId] ?? [];
|
||||
|
||||
return [
|
||||
'assignment' => $assignment,
|
||||
'account' => $assignment->assignable,
|
||||
'metrics' => $accountMetrics,
|
||||
'health' => $this->calculateHealth($accountMetrics),
|
||||
];
|
||||
});
|
||||
|
||||
// Apply filters
|
||||
$statusFilter = $request->get('status');
|
||||
if ($statusFilter) {
|
||||
$accounts = $accounts->filter(fn ($a) => $a['health']['status'] === $statusFilter);
|
||||
}
|
||||
|
||||
// Sort by health priority (at_risk first)
|
||||
$accounts = $accounts->sortBy(fn ($a) => $a['health']['priority'])->values();
|
||||
|
||||
return view('seller.sales.accounts.index', compact('business', 'accounts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show account detail with full history
|
||||
*/
|
||||
public function show(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Verify user is assigned to this account
|
||||
$assignment = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->where('assignable_type', Business::class)
|
||||
->where('assignable_id', $account->id)
|
||||
->first();
|
||||
|
||||
if (! $assignment) {
|
||||
abort(403, 'You are not assigned to this account.');
|
||||
}
|
||||
|
||||
// Get account details with relationships
|
||||
$account->load(['primaryLocation', 'locations', 'contacts']);
|
||||
|
||||
// Get order history
|
||||
$orders = Order::where('business_id', $account->id)
|
||||
->with(['items.product.brand'])
|
||||
->orderByDesc('created_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Get account notes
|
||||
$notes = AccountNote::forBusiness($business->id)
|
||||
->forAccount($account->id)
|
||||
->with('author')
|
||||
->orderByDesc('is_pinned')
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
// Get account metrics
|
||||
$metrics = $this->getAccountDetailMetrics($account);
|
||||
|
||||
return view('seller.sales.accounts.show', compact(
|
||||
'business',
|
||||
'account',
|
||||
'assignment',
|
||||
'orders',
|
||||
'notes',
|
||||
'metrics'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new account note
|
||||
*/
|
||||
public function storeNote(Request $request, Business $business, Business $account)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'note_type' => 'required|in:general,competitor,pain_point,opportunity,objection',
|
||||
'content' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
$note = AccountNote::create([
|
||||
'business_id' => $business->id,
|
||||
'account_id' => $account->id,
|
||||
'user_id' => $request->user()->id,
|
||||
'note_type' => $validated['note_type'],
|
||||
'content' => $validated['content'],
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Note added successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle note pinned status
|
||||
*/
|
||||
public function toggleNotePin(Request $request, Business $business, AccountNote $note)
|
||||
{
|
||||
// Verify note belongs to this business
|
||||
if ($note->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$note->is_pinned = ! $note->is_pinned;
|
||||
$note->save();
|
||||
|
||||
return back()->with('success', $note->is_pinned ? 'Note pinned.' : 'Note unpinned.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an account note
|
||||
*/
|
||||
public function destroyNote(Request $request, Business $business, AccountNote $note)
|
||||
{
|
||||
// Verify note belongs to this business
|
||||
if ($note->business_id !== $business->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
// Only allow deletion by author or admin
|
||||
if ($note->user_id !== $request->user()->id && ! $request->user()->isAdmin()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$note->delete();
|
||||
|
||||
return back()->with('success', 'Note deleted.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch metrics for multiple accounts
|
||||
*/
|
||||
protected function getAccountMetrics($accountIds): array
|
||||
{
|
||||
if ($accountIds->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$metrics = Order::whereIn('business_id', $accountIds)
|
||||
->where('status', 'completed')
|
||||
->groupBy('business_id')
|
||||
->selectRaw('
|
||||
business_id,
|
||||
COUNT(*) as order_count,
|
||||
SUM(total) as total_revenue,
|
||||
MAX(created_at) as last_order_date
|
||||
')
|
||||
->get()
|
||||
->keyBy('business_id');
|
||||
|
||||
// Get 4-week rolling revenue
|
||||
$recentRevenue = Order::whereIn('business_id', $accountIds)
|
||||
->where('status', 'completed')
|
||||
->where('created_at', '>=', now()->subWeeks(4))
|
||||
->groupBy('business_id')
|
||||
->selectRaw('business_id, SUM(total) as four_week_revenue')
|
||||
->pluck('four_week_revenue', 'business_id');
|
||||
|
||||
return $accountIds->mapWithKeys(function ($id) use ($metrics, $recentRevenue) {
|
||||
$m = $metrics[$id] ?? null;
|
||||
|
||||
return [$id => [
|
||||
'order_count' => $m?->order_count ?? 0,
|
||||
'total_revenue' => ($m?->total_revenue ?? 0) / 100,
|
||||
'four_week_revenue' => ($recentRevenue[$id] ?? 0) / 100,
|
||||
'last_order_date' => $m?->last_order_date,
|
||||
'days_since_order' => $m?->last_order_date
|
||||
? now()->diffInDays($m->last_order_date)
|
||||
: null,
|
||||
]];
|
||||
})->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed metrics for a single account
|
||||
*/
|
||||
protected function getAccountDetailMetrics(Business $account): array
|
||||
{
|
||||
$orders = Order::where('business_id', $account->id)
|
||||
->where('status', 'completed')
|
||||
->get();
|
||||
|
||||
if ($orders->isEmpty()) {
|
||||
return [
|
||||
'lifetime_revenue' => 0,
|
||||
'lifetime_orders' => 0,
|
||||
'avg_order_value' => 0,
|
||||
'four_week_revenue' => 0,
|
||||
'last_order_date' => null,
|
||||
'days_since_order' => null,
|
||||
'avg_order_interval' => null,
|
||||
];
|
||||
}
|
||||
|
||||
$lifetime = $orders->sum('total') / 100;
|
||||
$recentOrders = $orders->where('created_at', '>=', now()->subWeeks(4));
|
||||
$fourWeekRevenue = $recentOrders->sum('total') / 100;
|
||||
|
||||
// Calculate average order interval
|
||||
$sortedDates = $orders->pluck('created_at')->sort()->values();
|
||||
$intervals = [];
|
||||
for ($i = 1; $i < $sortedDates->count(); $i++) {
|
||||
$intervals[] = $sortedDates[$i]->diffInDays($sortedDates[$i - 1]);
|
||||
}
|
||||
$avgInterval = count($intervals) > 0 ? array_sum($intervals) / count($intervals) : null;
|
||||
|
||||
return [
|
||||
'lifetime_revenue' => $lifetime,
|
||||
'lifetime_orders' => $orders->count(),
|
||||
'avg_order_value' => $orders->count() > 0 ? $lifetime / $orders->count() : 0,
|
||||
'four_week_revenue' => $fourWeekRevenue,
|
||||
'last_order_date' => $orders->max('created_at'),
|
||||
'days_since_order' => $orders->max('created_at')
|
||||
? now()->diffInDays($orders->max('created_at'))
|
||||
: null,
|
||||
'avg_order_interval' => $avgInterval ? round($avgInterval) : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate health status
|
||||
*/
|
||||
protected function calculateHealth(array $metrics): array
|
||||
{
|
||||
$days = $metrics['days_since_order'] ?? null;
|
||||
|
||||
if ($days === null) {
|
||||
return ['status' => 'new', 'label' => 'New', 'color' => 'info', 'priority' => 2];
|
||||
}
|
||||
|
||||
if ($days >= 60) {
|
||||
return ['status' => 'at_risk', 'label' => 'At Risk', 'color' => 'error', 'priority' => 0];
|
||||
}
|
||||
|
||||
if ($days >= 30) {
|
||||
return ['status' => 'needs_attention', 'label' => 'Needs Attention', 'color' => 'warning', 'priority' => 1];
|
||||
}
|
||||
|
||||
return ['status' => 'healthy', 'label' => 'Healthy', 'color' => 'success', 'priority' => 3];
|
||||
}
|
||||
}
|
||||
285
app/Http/Controllers/Seller/Sales/DashboardController.php
Normal file
285
app/Http/Controllers/Seller/Sales/DashboardController.php
Normal file
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Order;
|
||||
use App\Models\SalesRepAssignment;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
/**
|
||||
* Sales Rep Dashboard - My Accounts with health status
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Cache dashboard data for 5 minutes
|
||||
$cacheKey = "sales_dashboard_{$business->id}_{$user->id}";
|
||||
|
||||
$data = Cache::remember($cacheKey, 300, function () use ($business, $user) {
|
||||
return $this->getDashboardData($business, $user);
|
||||
});
|
||||
|
||||
$data['business'] = $business;
|
||||
|
||||
return view('seller.sales.dashboard.index', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* My Accounts view - accounts assigned to this sales rep
|
||||
*/
|
||||
public function myAccounts(Request $request, Business $business)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Get account assignments for this user
|
||||
$assignments = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->accounts()
|
||||
->with(['assignable', 'assignable.primaryLocation'])
|
||||
->get();
|
||||
|
||||
// Get account IDs
|
||||
$accountIds = $assignments->pluck('assignable_id');
|
||||
|
||||
// Calculate account health metrics in a single efficient query
|
||||
$accountMetrics = $this->getAccountMetrics($accountIds);
|
||||
|
||||
// Combine assignments with metrics
|
||||
$accounts = $assignments->map(function ($assignment) use ($accountMetrics) {
|
||||
$accountId = $assignment->assignable_id;
|
||||
$metrics = $accountMetrics[$accountId] ?? [];
|
||||
|
||||
return [
|
||||
'assignment' => $assignment,
|
||||
'account' => $assignment->assignable,
|
||||
'metrics' => $metrics,
|
||||
'health_status' => $this->calculateHealthStatus($metrics),
|
||||
];
|
||||
})->sortBy(fn ($a) => $a['health_status']['priority']);
|
||||
|
||||
return view('seller.sales.accounts.index', compact('business', 'accounts'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard data
|
||||
*/
|
||||
protected function getDashboardData(Business $business, $user): array
|
||||
{
|
||||
// Get assigned accounts count
|
||||
$accountAssignments = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->accounts()
|
||||
->count();
|
||||
|
||||
// Get assigned locations count
|
||||
$locationAssignments = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->locations()
|
||||
->count();
|
||||
|
||||
// Get accounts needing attention (no order in 30+ days)
|
||||
$needsAttention = $this->getAccountsNeedingAttention($business, $user);
|
||||
|
||||
// Get accounts at risk (no order in 60+ days)
|
||||
$atRisk = $this->getAccountsAtRisk($business, $user);
|
||||
|
||||
// Get recent orders for assigned accounts
|
||||
$recentOrders = $this->getRecentOrders($business, $user, 10);
|
||||
|
||||
// Get commission summary
|
||||
$commissionSummary = $this->getCommissionSummary($business, $user);
|
||||
|
||||
return [
|
||||
'stats' => [
|
||||
'assigned_accounts' => $accountAssignments,
|
||||
'assigned_locations' => $locationAssignments,
|
||||
'needs_attention' => $needsAttention->count(),
|
||||
'at_risk' => $atRisk->count(),
|
||||
],
|
||||
'needs_attention' => $needsAttention,
|
||||
'at_risk' => $atRisk,
|
||||
'recent_orders' => $recentOrders,
|
||||
'commission_summary' => $commissionSummary,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account metrics for multiple accounts efficiently
|
||||
*/
|
||||
protected function getAccountMetrics($accountIds): array
|
||||
{
|
||||
if ($accountIds->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get order metrics per account
|
||||
$orderMetrics = Order::whereIn('business_id', $accountIds)
|
||||
->where('status', 'completed')
|
||||
->groupBy('business_id')
|
||||
->selectRaw('business_id,
|
||||
COUNT(*) as order_count,
|
||||
SUM(total) as total_revenue,
|
||||
MAX(created_at) as last_order_date,
|
||||
AVG(EXTRACT(EPOCH FROM (created_at - LAG(created_at) OVER (PARTITION BY business_id ORDER BY created_at)))) as avg_order_interval_seconds
|
||||
')
|
||||
->get()
|
||||
->keyBy('business_id');
|
||||
|
||||
return $orderMetrics->mapWithKeys(function ($metrics, $accountId) {
|
||||
return [$accountId => [
|
||||
'order_count' => $metrics->order_count,
|
||||
'total_revenue' => $metrics->total_revenue ?? 0,
|
||||
'last_order_date' => $metrics->last_order_date,
|
||||
'days_since_last_order' => $metrics->last_order_date
|
||||
? now()->diffInDays($metrics->last_order_date)
|
||||
: null,
|
||||
'avg_order_interval_days' => $metrics->avg_order_interval_seconds
|
||||
? round($metrics->avg_order_interval_seconds / 86400)
|
||||
: null,
|
||||
]];
|
||||
})->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate health status based on metrics
|
||||
*/
|
||||
protected function calculateHealthStatus(array $metrics): array
|
||||
{
|
||||
$daysSinceOrder = $metrics['days_since_last_order'] ?? null;
|
||||
$avgInterval = $metrics['avg_order_interval_days'] ?? 30;
|
||||
|
||||
if ($daysSinceOrder === null) {
|
||||
return [
|
||||
'status' => 'new',
|
||||
'label' => 'New Account',
|
||||
'color' => 'info',
|
||||
'priority' => 2,
|
||||
];
|
||||
}
|
||||
|
||||
// At risk: More than 2x their average order interval, or 60+ days
|
||||
if ($daysSinceOrder >= max($avgInterval * 2, 60)) {
|
||||
return [
|
||||
'status' => 'at_risk',
|
||||
'label' => 'At Risk',
|
||||
'color' => 'error',
|
||||
'priority' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Needs attention: More than 1.5x their average order interval, or 30+ days
|
||||
if ($daysSinceOrder >= max($avgInterval * 1.5, 30)) {
|
||||
return [
|
||||
'status' => 'needs_attention',
|
||||
'label' => 'Needs Attention',
|
||||
'color' => 'warning',
|
||||
'priority' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => 'healthy',
|
||||
'label' => 'Healthy',
|
||||
'color' => 'success',
|
||||
'priority' => 3,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounts needing attention (no order in 30-59 days)
|
||||
*/
|
||||
protected function getAccountsNeedingAttention(Business $business, $user)
|
||||
{
|
||||
$assignedAccountIds = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->accounts()
|
||||
->pluck('assignable_id');
|
||||
|
||||
return Business::whereIn('id', $assignedAccountIds)
|
||||
->whereHas('orders', function ($q) {
|
||||
$q->where('status', 'completed')
|
||||
->where('created_at', '<', now()->subDays(30))
|
||||
->where('created_at', '>=', now()->subDays(60));
|
||||
})
|
||||
->orWhereDoesntHave('orders')
|
||||
->with('primaryLocation')
|
||||
->limit(10)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounts at risk (no order in 60+ days)
|
||||
*/
|
||||
protected function getAccountsAtRisk(Business $business, $user)
|
||||
{
|
||||
$assignedAccountIds = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->accounts()
|
||||
->pluck('assignable_id');
|
||||
|
||||
return Business::whereIn('id', $assignedAccountIds)
|
||||
->whereHas('orders', function ($q) {
|
||||
$q->where('status', 'completed')
|
||||
->where('created_at', '<', now()->subDays(60));
|
||||
})
|
||||
->with('primaryLocation')
|
||||
->limit(10)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent orders for assigned accounts
|
||||
*/
|
||||
protected function getRecentOrders(Business $business, $user, int $limit)
|
||||
{
|
||||
$assignedAccountIds = SalesRepAssignment::forBusiness($business->id)
|
||||
->forUser($user->id)
|
||||
->accounts()
|
||||
->pluck('assignable_id');
|
||||
|
||||
return Order::whereIn('business_id', $assignedAccountIds)
|
||||
->with(['business', 'items.product'])
|
||||
->orderByDesc('created_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commission summary for the current user
|
||||
*/
|
||||
protected function getCommissionSummary(Business $business, $user): array
|
||||
{
|
||||
$pendingCommission = DB::table('sales_commissions')
|
||||
->where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'pending')
|
||||
->sum('commission_amount');
|
||||
|
||||
$approvedCommission = DB::table('sales_commissions')
|
||||
->where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'approved')
|
||||
->sum('commission_amount');
|
||||
|
||||
$paidThisMonth = DB::table('sales_commissions')
|
||||
->where('business_id', $business->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'paid')
|
||||
->whereMonth('paid_at', now()->month)
|
||||
->whereYear('paid_at', now()->year)
|
||||
->sum('commission_amount');
|
||||
|
||||
return [
|
||||
'pending' => $pendingCommission / 100,
|
||||
'approved' => $approvedCommission / 100,
|
||||
'paid_this_month' => $paidThisMonth / 100,
|
||||
];
|
||||
}
|
||||
}
|
||||
127
app/Models/AccountNote.php
Normal file
127
app/Models/AccountNote.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Account Note - Sales rep notes on buyer accounts
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property int $account_id
|
||||
* @property int $user_id
|
||||
* @property string $note_type
|
||||
* @property string $content
|
||||
* @property bool $is_pinned
|
||||
*/
|
||||
class AccountNote extends Model
|
||||
{
|
||||
public const TYPE_GENERAL = 'general';
|
||||
|
||||
public const TYPE_COMPETITOR = 'competitor';
|
||||
|
||||
public const TYPE_PAIN_POINT = 'pain_point';
|
||||
|
||||
public const TYPE_OPPORTUNITY = 'opportunity';
|
||||
|
||||
public const TYPE_OBJECTION = 'objection';
|
||||
|
||||
public const TYPES = [
|
||||
self::TYPE_GENERAL => 'General',
|
||||
self::TYPE_COMPETITOR => 'Competitor Intel',
|
||||
self::TYPE_PAIN_POINT => 'Pain Point',
|
||||
self::TYPE_OPPORTUNITY => 'Opportunity',
|
||||
self::TYPE_OBJECTION => 'Objection',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'account_id',
|
||||
'user_id',
|
||||
'note_type',
|
||||
'content',
|
||||
'is_pinned',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_pinned' => 'boolean',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'account_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForAccount($query, int $accountId)
|
||||
{
|
||||
return $query->where('account_id', $accountId);
|
||||
}
|
||||
|
||||
public function scopePinned($query)
|
||||
{
|
||||
return $query->where('is_pinned', true);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('note_type', $type);
|
||||
}
|
||||
|
||||
public function scopeCompetitor($query)
|
||||
{
|
||||
return $query->where('note_type', self::TYPE_COMPETITOR);
|
||||
}
|
||||
|
||||
public function scopePainPoints($query)
|
||||
{
|
||||
return $query->where('note_type', self::TYPE_PAIN_POINT);
|
||||
}
|
||||
|
||||
public function scopeOpportunities($query)
|
||||
{
|
||||
return $query->where('note_type', self::TYPE_OPPORTUNITY);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
public function getTypeLabel(): string
|
||||
{
|
||||
return self::TYPES[$this->note_type] ?? ucfirst($this->note_type);
|
||||
}
|
||||
|
||||
public function pin(): void
|
||||
{
|
||||
$this->update(['is_pinned' => true]);
|
||||
}
|
||||
|
||||
public function unpin(): void
|
||||
{
|
||||
$this->update(['is_pinned' => false]);
|
||||
}
|
||||
}
|
||||
97
app/Models/CompetitorReplacement.php
Normal file
97
app/Models/CompetitorReplacement.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Competitor Replacement - Maps CannaiQ competitor products to our products
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property string $cannaiq_product_id
|
||||
* @property string $competitor_name
|
||||
* @property string|null $competitor_product_name
|
||||
* @property int $product_id
|
||||
* @property string|null $advantage_notes
|
||||
* @property int $created_by
|
||||
*/
|
||||
class CompetitorReplacement extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'cannaiq_product_id',
|
||||
'competitor_name',
|
||||
'competitor_product_name',
|
||||
'product_id',
|
||||
'advantage_notes',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForCompetitor($query, string $competitorName)
|
||||
{
|
||||
return $query->where('competitor_name', $competitorName);
|
||||
}
|
||||
|
||||
public function scopeForProduct($query, int $productId)
|
||||
{
|
||||
return $query->where('product_id', $productId);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
/**
|
||||
* Get display label showing competitor → our product
|
||||
*/
|
||||
public function getDisplayLabel(): string
|
||||
{
|
||||
$competitor = $this->competitor_product_name
|
||||
? "{$this->competitor_name} - {$this->competitor_product_name}"
|
||||
: $this->competitor_name;
|
||||
|
||||
return "{$competitor} → {$this->product->name}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short pitch summary
|
||||
*/
|
||||
public function getPitchSummary(): string
|
||||
{
|
||||
if (! $this->advantage_notes) {
|
||||
return "Replace with {$this->product->name}";
|
||||
}
|
||||
|
||||
// Return first sentence or 100 chars
|
||||
$notes = $this->advantage_notes;
|
||||
$firstSentence = strtok($notes, '.');
|
||||
|
||||
return strlen($firstSentence) > 100
|
||||
? substr($notes, 0, 97).'...'
|
||||
: $firstSentence.'.';
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ class Contact extends Model
|
||||
'work_hours', // JSON: schedule
|
||||
'availability_notes',
|
||||
'emergency_contact',
|
||||
'best_time_to_contact',
|
||||
|
||||
// Status & Settings
|
||||
'is_primary', // Primary contact for business/location
|
||||
@@ -98,6 +99,7 @@ class Contact extends Model
|
||||
'last_contact_date',
|
||||
'next_followup_date',
|
||||
'relationship_notes',
|
||||
'working_notes', // Sales notes on how they prefer to work
|
||||
|
||||
// Account Management
|
||||
'archived_at',
|
||||
|
||||
237
app/Models/ProspectImport.php
Normal file
237
app/Models/ProspectImport.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Prospect Import - Track CSV/bulk import jobs
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property int $user_id
|
||||
* @property string $filename
|
||||
* @property string $status
|
||||
* @property int $total_rows
|
||||
* @property int $processed_rows
|
||||
* @property int $created_count
|
||||
* @property int $updated_count
|
||||
* @property int $skipped_count
|
||||
* @property int $error_count
|
||||
* @property array|null $errors
|
||||
* @property array|null $column_mapping
|
||||
* @property \Carbon\Carbon|null $completed_at
|
||||
*/
|
||||
class ProspectImport extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'user_id',
|
||||
'filename',
|
||||
'status',
|
||||
'total_rows',
|
||||
'processed_rows',
|
||||
'created_count',
|
||||
'updated_count',
|
||||
'skipped_count',
|
||||
'error_count',
|
||||
'errors',
|
||||
'column_mapping',
|
||||
'completed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'total_rows' => 'integer',
|
||||
'processed_rows' => 'integer',
|
||||
'created_count' => 'integer',
|
||||
'updated_count' => 'integer',
|
||||
'skipped_count' => 'integer',
|
||||
'error_count' => 'integer',
|
||||
'errors' => 'array',
|
||||
'column_mapping' => 'array',
|
||||
'completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function importer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
public function scopeProcessing($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PROCESSING);
|
||||
}
|
||||
|
||||
public function scopeCompleted($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_COMPLETED);
|
||||
}
|
||||
|
||||
public function scopeFailed($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_FAILED);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isProcessing(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PROCESSING;
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_FAILED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress percentage
|
||||
*/
|
||||
public function getProgressPercent(): int
|
||||
{
|
||||
if ($this->total_rows === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) round(($this->processed_rows / $this->total_rows) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get success rate percentage
|
||||
*/
|
||||
public function getSuccessRate(): int
|
||||
{
|
||||
$total = $this->created_count + $this->updated_count + $this->skipped_count + $this->error_count;
|
||||
|
||||
if ($total === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) round((($this->created_count + $this->updated_count) / $total) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark import as processing
|
||||
*/
|
||||
public function markProcessing(): void
|
||||
{
|
||||
$this->update(['status' => self::STATUS_PROCESSING]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark import as completed
|
||||
*/
|
||||
public function markCompleted(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_COMPLETED,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark import as failed
|
||||
*/
|
||||
public function markFailed(?string $reason = null): void
|
||||
{
|
||||
$errors = $this->errors ?? [];
|
||||
if ($reason) {
|
||||
$errors[] = ['row' => 0, 'error' => $reason];
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'status' => self::STATUS_FAILED,
|
||||
'errors' => $errors,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add error for a specific row
|
||||
*/
|
||||
public function addError(int $row, string $error): void
|
||||
{
|
||||
$errors = $this->errors ?? [];
|
||||
$errors[] = ['row' => $row, 'error' => $error];
|
||||
|
||||
$this->update([
|
||||
'errors' => $errors,
|
||||
'error_count' => $this->error_count + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment processed count
|
||||
*/
|
||||
public function incrementProcessed(): void
|
||||
{
|
||||
$this->increment('processed_rows');
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment created count
|
||||
*/
|
||||
public function incrementCreated(): void
|
||||
{
|
||||
$this->increment('created_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment updated count
|
||||
*/
|
||||
public function incrementUpdated(): void
|
||||
{
|
||||
$this->increment('updated_count');
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment skipped count
|
||||
*/
|
||||
public function incrementSkipped(): void
|
||||
{
|
||||
$this->increment('skipped_count');
|
||||
}
|
||||
}
|
||||
196
app/Models/ProspectInsight.php
Normal file
196
app/Models/ProspectInsight.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Crm\CrmLead;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Prospect Insight - Gap analysis and opportunity tracking
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property int|null $lead_id
|
||||
* @property int|null $account_id
|
||||
* @property string $insight_type
|
||||
* @property string|null $category
|
||||
* @property string $description
|
||||
* @property array|null $supporting_data
|
||||
* @property int $created_by
|
||||
*/
|
||||
class ProspectInsight extends Model
|
||||
{
|
||||
// Insight types
|
||||
public const TYPE_GAP = 'gap';
|
||||
|
||||
public const TYPE_PAIN_POINT = 'pain_point';
|
||||
|
||||
public const TYPE_OPPORTUNITY = 'opportunity';
|
||||
|
||||
public const TYPE_OBJECTION = 'objection';
|
||||
|
||||
public const TYPE_COMPETITOR_WEAKNESS = 'competitor_weakness';
|
||||
|
||||
public const TYPES = [
|
||||
self::TYPE_GAP => 'Gap',
|
||||
self::TYPE_PAIN_POINT => 'Pain Point',
|
||||
self::TYPE_OPPORTUNITY => 'Opportunity',
|
||||
self::TYPE_OBJECTION => 'Objection',
|
||||
self::TYPE_COMPETITOR_WEAKNESS => 'Competitor Weakness',
|
||||
];
|
||||
|
||||
// Categories
|
||||
public const CATEGORY_PRICE_POINT = 'price_point';
|
||||
|
||||
public const CATEGORY_QUALITY = 'quality';
|
||||
|
||||
public const CATEGORY_CONSISTENCY = 'consistency';
|
||||
|
||||
public const CATEGORY_SERVICE = 'service';
|
||||
|
||||
public const CATEGORY_MARGIN = 'margin';
|
||||
|
||||
public const CATEGORY_RELIABILITY = 'reliability';
|
||||
|
||||
public const CATEGORY_SELECTION = 'selection';
|
||||
|
||||
public const CATEGORIES = [
|
||||
self::CATEGORY_PRICE_POINT => 'Price Point',
|
||||
self::CATEGORY_QUALITY => 'Quality',
|
||||
self::CATEGORY_CONSISTENCY => 'Consistency',
|
||||
self::CATEGORY_SERVICE => 'Service',
|
||||
self::CATEGORY_MARGIN => 'Margin',
|
||||
self::CATEGORY_RELIABILITY => 'Reliability',
|
||||
self::CATEGORY_SELECTION => 'Selection',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'lead_id',
|
||||
'account_id',
|
||||
'insight_type',
|
||||
'category',
|
||||
'description',
|
||||
'supporting_data',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'supporting_data' => 'array',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function lead(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CrmLead::class, 'lead_id');
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class, 'account_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForLead($query, int $leadId)
|
||||
{
|
||||
return $query->where('lead_id', $leadId);
|
||||
}
|
||||
|
||||
public function scopeForAccount($query, int $accountId)
|
||||
{
|
||||
return $query->where('account_id', $accountId);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('insight_type', $type);
|
||||
}
|
||||
|
||||
public function scopeOfCategory($query, string $category)
|
||||
{
|
||||
return $query->where('category', $category);
|
||||
}
|
||||
|
||||
public function scopeGaps($query)
|
||||
{
|
||||
return $query->where('insight_type', self::TYPE_GAP);
|
||||
}
|
||||
|
||||
public function scopePainPoints($query)
|
||||
{
|
||||
return $query->where('insight_type', self::TYPE_PAIN_POINT);
|
||||
}
|
||||
|
||||
public function scopeOpportunities($query)
|
||||
{
|
||||
return $query->where('insight_type', self::TYPE_OPPORTUNITY);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
public function getTypeLabel(): string
|
||||
{
|
||||
return self::TYPES[$this->insight_type] ?? ucfirst($this->insight_type);
|
||||
}
|
||||
|
||||
public function getCategoryLabel(): string
|
||||
{
|
||||
if (! $this->category) {
|
||||
return 'General';
|
||||
}
|
||||
|
||||
return self::CATEGORIES[$this->category] ?? ucfirst($this->category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this insight is for a lead (prospect) vs existing account
|
||||
*/
|
||||
public function isForProspect(): bool
|
||||
{
|
||||
return ! is_null($this->lead_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target entity (lead or account)
|
||||
*/
|
||||
public function getTarget(): CrmLead|Business|null
|
||||
{
|
||||
if ($this->lead_id) {
|
||||
return $this->lead;
|
||||
}
|
||||
|
||||
if ($this->account_id) {
|
||||
return $this->account;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add supporting data reference
|
||||
*/
|
||||
public function addSupportingData(string $key, mixed $value): void
|
||||
{
|
||||
$data = $this->supporting_data ?? [];
|
||||
$data[$key] = $value;
|
||||
$this->update(['supporting_data' => $data]);
|
||||
}
|
||||
}
|
||||
185
app/Models/SalesCommission.php
Normal file
185
app/Models/SalesCommission.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Sales Commission - Actual commission earned by sales rep on an order
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property int $user_id
|
||||
* @property int $order_id
|
||||
* @property int|null $order_item_id
|
||||
* @property int|null $commission_rate_id
|
||||
* @property int $order_total
|
||||
* @property float $commission_percent
|
||||
* @property int $commission_amount
|
||||
* @property string $status
|
||||
* @property \Carbon\Carbon|null $approved_at
|
||||
* @property int|null $approved_by
|
||||
* @property \Carbon\Carbon|null $paid_at
|
||||
* @property string|null $payment_reference
|
||||
* @property string|null $notes
|
||||
*/
|
||||
class SalesCommission extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
|
||||
public const STATUS_PAID = 'paid';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'user_id',
|
||||
'order_id',
|
||||
'order_item_id',
|
||||
'commission_rate_id',
|
||||
'order_total',
|
||||
'commission_percent',
|
||||
'commission_amount',
|
||||
'status',
|
||||
'approved_at',
|
||||
'approved_by',
|
||||
'paid_at',
|
||||
'payment_reference',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'order_total' => 'integer',
|
||||
'commission_percent' => 'decimal:2',
|
||||
'commission_amount' => 'integer',
|
||||
'approved_at' => 'datetime',
|
||||
'paid_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function salesRep(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function orderItem(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OrderItem::class);
|
||||
}
|
||||
|
||||
public function commissionRate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesCommissionRate::class, 'commission_rate_id');
|
||||
}
|
||||
|
||||
public function approver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
public function scopeApproved($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_APPROVED);
|
||||
}
|
||||
|
||||
public function scopePaid($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PAID);
|
||||
}
|
||||
|
||||
public function scopeUnpaid($query)
|
||||
{
|
||||
return $query->whereIn('status', [self::STATUS_PENDING, self::STATUS_APPROVED]);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_APPROVED;
|
||||
}
|
||||
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PAID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commission amount in dollars
|
||||
*/
|
||||
public function getCommissionDollars(): float
|
||||
{
|
||||
return $this->commission_amount / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get order total in dollars
|
||||
*/
|
||||
public function getOrderDollars(): float
|
||||
{
|
||||
return $this->order_total / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve this commission
|
||||
*/
|
||||
public function approve(User $approver): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_APPROVED,
|
||||
'approved_at' => now(),
|
||||
'approved_by' => $approver->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark as paid
|
||||
*/
|
||||
public function markPaid(?string $reference = null): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_PAID,
|
||||
'paid_at' => now(),
|
||||
'payment_reference' => $reference,
|
||||
]);
|
||||
}
|
||||
}
|
||||
152
app/Models/SalesCommissionRate.php
Normal file
152
app/Models/SalesCommissionRate.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* Sales Commission Rate - Defines commission rates for sales reps
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property int|null $user_id
|
||||
* @property string $rate_type
|
||||
* @property string|null $rateable_type
|
||||
* @property int|null $rateable_id
|
||||
* @property float $commission_percent
|
||||
* @property \Carbon\Carbon $effective_from
|
||||
* @property \Carbon\Carbon|null $effective_to
|
||||
* @property bool $is_active
|
||||
*/
|
||||
class SalesCommissionRate extends Model
|
||||
{
|
||||
public const TYPE_DEFAULT = 'default';
|
||||
|
||||
public const TYPE_ACCOUNT = 'account';
|
||||
|
||||
public const TYPE_PRODUCT = 'product';
|
||||
|
||||
public const TYPE_BRAND = 'brand';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'user_id',
|
||||
'rate_type',
|
||||
'rateable_type',
|
||||
'rateable_id',
|
||||
'commission_percent',
|
||||
'effective_from',
|
||||
'effective_to',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'commission_percent' => 'decimal:2',
|
||||
'effective_from' => 'date',
|
||||
'effective_to' => 'date',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function rateable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function commissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesCommission::class, 'commission_rate_id');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, ?int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeEffective($query, ?\Carbon\Carbon $date = null)
|
||||
{
|
||||
$date = $date ?? now();
|
||||
|
||||
return $query
|
||||
->where('effective_from', '<=', $date)
|
||||
->where(function ($q) use ($date) {
|
||||
$q->whereNull('effective_to')
|
||||
->orWhere('effective_to', '>=', $date);
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeDefault($query)
|
||||
{
|
||||
return $query->where('rate_type', self::TYPE_DEFAULT);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
/**
|
||||
* Check if this rate is currently effective
|
||||
*/
|
||||
public function isEffective(?\Carbon\Carbon $date = null): bool
|
||||
{
|
||||
$date = $date ?? now();
|
||||
|
||||
if (! $this->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->effective_from > $date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->effective_to && $this->effective_to < $date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display label for this rate
|
||||
*/
|
||||
public function getDisplayLabel(): string
|
||||
{
|
||||
$label = "{$this->commission_percent}%";
|
||||
|
||||
if ($this->user) {
|
||||
$label .= " for {$this->user->name}";
|
||||
}
|
||||
|
||||
return match ($this->rate_type) {
|
||||
self::TYPE_DEFAULT => "Default: {$label}",
|
||||
self::TYPE_ACCOUNT => "Account: {$label}",
|
||||
self::TYPE_PRODUCT => "Product: {$label}",
|
||||
self::TYPE_BRAND => "Brand: {$label}",
|
||||
default => $label,
|
||||
};
|
||||
}
|
||||
}
|
||||
139
app/Models/SalesRepAssignment.php
Normal file
139
app/Models/SalesRepAssignment.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* Sales Rep Assignment - Links sales reps to accounts or stores
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property int $user_id
|
||||
* @property string $assignable_type
|
||||
* @property int $assignable_id
|
||||
* @property string $assignment_type
|
||||
* @property float|null $commission_rate
|
||||
* @property \Carbon\Carbon $assigned_at
|
||||
* @property int|null $assigned_by
|
||||
* @property string|null $notes
|
||||
*/
|
||||
class SalesRepAssignment extends Model
|
||||
{
|
||||
public const TYPE_PRIMARY = 'primary';
|
||||
|
||||
public const TYPE_SECONDARY = 'secondary';
|
||||
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'user_id',
|
||||
'assignable_type',
|
||||
'assignable_id',
|
||||
'assignment_type',
|
||||
'commission_rate',
|
||||
'assigned_at',
|
||||
'assigned_by',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'commission_rate' => 'decimal:2',
|
||||
'assigned_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
/**
|
||||
* The seller business that owns this assignment
|
||||
*/
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sales rep user
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for user - the sales rep
|
||||
*/
|
||||
public function salesRep(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The assigned entity (Business account or Location store)
|
||||
*/
|
||||
public function assignable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Who made this assignment
|
||||
*/
|
||||
public function assigner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assigned_by');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function scopePrimary($query)
|
||||
{
|
||||
return $query->where('assignment_type', self::TYPE_PRIMARY);
|
||||
}
|
||||
|
||||
public function scopeSecondary($query)
|
||||
{
|
||||
return $query->where('assignment_type', self::TYPE_SECONDARY);
|
||||
}
|
||||
|
||||
public function scopeAccounts($query)
|
||||
{
|
||||
return $query->where('assignable_type', Business::class);
|
||||
}
|
||||
|
||||
public function scopeLocations($query)
|
||||
{
|
||||
return $query->where('assignable_type', Location::class);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
public function isPrimary(): bool
|
||||
{
|
||||
return $this->assignment_type === self::TYPE_PRIMARY;
|
||||
}
|
||||
|
||||
public function isSecondary(): bool
|
||||
{
|
||||
return $this->assignment_type === self::TYPE_SECONDARY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective commission rate (override or default)
|
||||
*/
|
||||
public function getEffectiveCommissionRate(?float $defaultRate = null): ?float
|
||||
{
|
||||
return $this->commission_rate ?? $defaultRate;
|
||||
}
|
||||
}
|
||||
106
app/Models/SalesTerritory.php
Normal file
106
app/Models/SalesTerritory.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* Sales Territory - Geographic region for sales rep assignment
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $business_id
|
||||
* @property string $name
|
||||
* @property string|null $description
|
||||
* @property string $color
|
||||
* @property bool $is_active
|
||||
*/
|
||||
class SalesTerritory extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'business_id',
|
||||
'name',
|
||||
'description',
|
||||
'color',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
public function areas(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesTerritoryArea::class, 'territory_id');
|
||||
}
|
||||
|
||||
public function assignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalesTerritoryAssignment::class, 'territory_id');
|
||||
}
|
||||
|
||||
public function salesReps(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'sales_territory_assignments', 'territory_id', 'user_id')
|
||||
->withPivot(['assignment_type', 'assigned_at', 'assigned_by'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
/**
|
||||
* Get the primary sales rep for this territory
|
||||
*/
|
||||
public function getPrimaryRep(): ?User
|
||||
{
|
||||
return $this->salesReps()
|
||||
->wherePivot('assignment_type', 'primary')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a location falls within this territory
|
||||
*/
|
||||
public function containsLocation(Location $location): bool
|
||||
{
|
||||
foreach ($this->areas as $area) {
|
||||
if ($area->matchesLocation($location)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all zip codes in this territory
|
||||
*/
|
||||
public function getZipCodes(): array
|
||||
{
|
||||
return $this->areas()
|
||||
->where('area_type', 'zip')
|
||||
->pluck('area_value')
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
72
app/Models/SalesTerritoryArea.php
Normal file
72
app/Models/SalesTerritoryArea.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Sales Territory Area - Geographic area definition (zip, city, state, county)
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $territory_id
|
||||
* @property string $area_type
|
||||
* @property string $area_value
|
||||
*/
|
||||
class SalesTerritoryArea extends Model
|
||||
{
|
||||
public const TYPE_ZIP = 'zip';
|
||||
|
||||
public const TYPE_CITY = 'city';
|
||||
|
||||
public const TYPE_STATE = 'state';
|
||||
|
||||
public const TYPE_COUNTY = 'county';
|
||||
|
||||
protected $fillable = [
|
||||
'territory_id',
|
||||
'area_type',
|
||||
'area_value',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function territory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesTerritory::class, 'territory_id');
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
/**
|
||||
* Check if a location matches this area definition
|
||||
*/
|
||||
public function matchesLocation(Location $location): bool
|
||||
{
|
||||
$value = strtolower(trim($this->area_value));
|
||||
|
||||
return match ($this->area_type) {
|
||||
self::TYPE_ZIP => strtolower(trim($location->zipcode ?? '')) === $value,
|
||||
self::TYPE_CITY => strtolower(trim($location->city ?? '')) === $value,
|
||||
self::TYPE_STATE => strtolower(trim($location->state ?? '')) === $value,
|
||||
self::TYPE_COUNTY => strtolower(trim($location->county ?? '')) === $value,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display label for this area
|
||||
*/
|
||||
public function getDisplayLabel(): string
|
||||
{
|
||||
$typeLabel = match ($this->area_type) {
|
||||
self::TYPE_ZIP => 'ZIP',
|
||||
self::TYPE_CITY => 'City',
|
||||
self::TYPE_STATE => 'State',
|
||||
self::TYPE_COUNTY => 'County',
|
||||
default => ucfirst($this->area_type),
|
||||
};
|
||||
|
||||
return "{$typeLabel}: {$this->area_value}";
|
||||
}
|
||||
}
|
||||
81
app/Models/SalesTerritoryAssignment.php
Normal file
81
app/Models/SalesTerritoryAssignment.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Sales Territory Assignment - Links sales reps to territories
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $territory_id
|
||||
* @property int $user_id
|
||||
* @property string $assignment_type
|
||||
* @property \Carbon\Carbon $assigned_at
|
||||
* @property int|null $assigned_by
|
||||
*/
|
||||
class SalesTerritoryAssignment extends Model
|
||||
{
|
||||
public const TYPE_PRIMARY = 'primary';
|
||||
|
||||
public const TYPE_SECONDARY = 'secondary';
|
||||
|
||||
protected $fillable = [
|
||||
'territory_id',
|
||||
'user_id',
|
||||
'assignment_type',
|
||||
'assigned_at',
|
||||
'assigned_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'assigned_at' => 'datetime',
|
||||
];
|
||||
|
||||
// ==================== Relationships ====================
|
||||
|
||||
public function territory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalesTerritory::class, 'territory_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function salesRep(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function assigner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assigned_by');
|
||||
}
|
||||
|
||||
// ==================== Scopes ====================
|
||||
|
||||
public function scopePrimary($query)
|
||||
{
|
||||
return $query->where('assignment_type', self::TYPE_PRIMARY);
|
||||
}
|
||||
|
||||
public function scopeSecondary($query)
|
||||
{
|
||||
return $query->where('assignment_type', self::TYPE_SECONDARY);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
public function isPrimary(): bool
|
||||
{
|
||||
return $this->assignment_type === self::TYPE_PRIMARY;
|
||||
}
|
||||
}
|
||||
210
app/Services/Sales/ReorderPredictionService.php
Normal file
210
app/Services/Sales/ReorderPredictionService.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Sales;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\Order;
|
||||
use App\Models\SalesRepAssignment;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ReorderPredictionService
|
||||
{
|
||||
/**
|
||||
* Get accounts approaching their typical reorder window
|
||||
*
|
||||
* @param int $businessId Seller business ID
|
||||
* @param int|null $userId Optional sales rep ID to filter by
|
||||
* @return Collection Accounts with reorder predictions
|
||||
*/
|
||||
public function getAccountsApproachingReorder(int $businessId, ?int $userId = null): Collection
|
||||
{
|
||||
// Get assigned accounts for this rep (or all if no rep specified)
|
||||
$query = SalesRepAssignment::forBusiness($businessId)->accounts();
|
||||
|
||||
if ($userId) {
|
||||
$query->forUser($userId);
|
||||
}
|
||||
|
||||
$accountIds = $query->pluck('assignable_id');
|
||||
|
||||
if ($accountIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Get order history and calculate patterns
|
||||
$accountPatterns = $this->calculateOrderPatterns($accountIds);
|
||||
|
||||
// Filter to accounts approaching reorder window
|
||||
return $accountPatterns
|
||||
->filter(fn ($account) => $this->isApproachingReorder($account))
|
||||
->sortBy('days_until_predicted_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate order patterns for given accounts
|
||||
*/
|
||||
public function calculateOrderPatterns(Collection $accountIds): Collection
|
||||
{
|
||||
// Get completed orders grouped by account
|
||||
$orders = Order::whereIn('business_id', $accountIds)
|
||||
->where('status', 'completed')
|
||||
->orderBy('business_id')
|
||||
->orderBy('created_at')
|
||||
->get(['id', 'business_id', 'total', 'created_at'])
|
||||
->groupBy('business_id');
|
||||
|
||||
// Get account info
|
||||
$accounts = Business::whereIn('id', $accountIds)
|
||||
->with('primaryLocation')
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return $accountIds->map(function ($accountId) use ($orders, $accounts) {
|
||||
$accountOrders = $orders[$accountId] ?? collect();
|
||||
$account = $accounts[$accountId] ?? null;
|
||||
|
||||
if (! $account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pattern = $this->analyzeOrderPattern($accountOrders);
|
||||
|
||||
return [
|
||||
'account' => $account,
|
||||
'order_count' => $accountOrders->count(),
|
||||
'last_order_date' => $accountOrders->last()?->created_at,
|
||||
'days_since_last_order' => $accountOrders->last()
|
||||
? now()->diffInDays($accountOrders->last()->created_at)
|
||||
: null,
|
||||
'avg_order_interval' => $pattern['avg_interval'],
|
||||
'predicted_next_order' => $pattern['predicted_next_order'],
|
||||
'days_until_predicted_order' => $pattern['days_until_predicted'],
|
||||
'confidence' => $pattern['confidence'],
|
||||
'avg_order_value' => $pattern['avg_order_value'],
|
||||
'suggested_products' => $this->getSuggestedProducts($accountOrders),
|
||||
];
|
||||
})->filter()->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze order pattern for a single account
|
||||
*/
|
||||
protected function analyzeOrderPattern(Collection $orders): array
|
||||
{
|
||||
if ($orders->count() < 2) {
|
||||
return [
|
||||
'avg_interval' => null,
|
||||
'predicted_next_order' => null,
|
||||
'days_until_predicted' => null,
|
||||
'confidence' => 'low',
|
||||
'avg_order_value' => $orders->avg('total') / 100 ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate intervals between orders
|
||||
$intervals = [];
|
||||
$sortedOrders = $orders->sortBy('created_at')->values();
|
||||
|
||||
for ($i = 1; $i < $sortedOrders->count(); $i++) {
|
||||
$intervals[] = $sortedOrders[$i]->created_at->diffInDays($sortedOrders[$i - 1]->created_at);
|
||||
}
|
||||
|
||||
$avgInterval = count($intervals) > 0 ? array_sum($intervals) / count($intervals) : 30;
|
||||
$lastOrderDate = $sortedOrders->last()->created_at;
|
||||
$predictedNextOrder = $lastOrderDate->copy()->addDays((int) round($avgInterval));
|
||||
|
||||
// Calculate confidence based on consistency
|
||||
$stdDev = $this->standardDeviation($intervals);
|
||||
$coefficientOfVariation = $avgInterval > 0 ? $stdDev / $avgInterval : 1;
|
||||
|
||||
$confidence = 'low';
|
||||
if ($orders->count() >= 5 && $coefficientOfVariation < 0.3) {
|
||||
$confidence = 'high';
|
||||
} elseif ($orders->count() >= 3 && $coefficientOfVariation < 0.5) {
|
||||
$confidence = 'medium';
|
||||
}
|
||||
|
||||
return [
|
||||
'avg_interval' => round($avgInterval),
|
||||
'predicted_next_order' => $predictedNextOrder,
|
||||
'days_until_predicted' => max(0, now()->diffInDays($predictedNextOrder, false)),
|
||||
'confidence' => $confidence,
|
||||
'avg_order_value' => $orders->avg('total') / 100,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account is approaching their reorder window
|
||||
*/
|
||||
protected function isApproachingReorder(array $account): bool
|
||||
{
|
||||
$daysUntil = $account['days_until_predicted_order'] ?? null;
|
||||
|
||||
// No prediction available or already overdue
|
||||
if ($daysUntil === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Within 7 days of predicted order OR overdue (negative)
|
||||
return $daysUntil <= 7;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested products based on order history
|
||||
*/
|
||||
protected function getSuggestedProducts(Collection $orders, int $limit = 5): array
|
||||
{
|
||||
if ($orders->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$orderIds = $orders->pluck('id');
|
||||
|
||||
// Get most frequently ordered products
|
||||
return DB::table('order_items')
|
||||
->whereIn('order_id', $orderIds)
|
||||
->join('products', 'order_items.product_id', '=', 'products.id')
|
||||
->selectRaw('products.id, products.name, products.sku, COUNT(*) as order_count, SUM(order_items.quantity) as total_quantity')
|
||||
->groupBy('products.id', 'products.name', 'products.sku')
|
||||
->orderByDesc('order_count')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'sku' => $p->sku,
|
||||
'order_count' => $p->order_count,
|
||||
'avg_quantity' => round($p->total_quantity / $p->order_count),
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reorder alerts for a sales rep (cached)
|
||||
*/
|
||||
public function getReorderAlerts(int $businessId, int $userId): Collection
|
||||
{
|
||||
$cacheKey = "reorder_alerts_{$businessId}_{$userId}";
|
||||
|
||||
return Cache::remember($cacheKey, 300, function () use ($businessId, $userId) {
|
||||
return $this->getAccountsApproachingReorder($businessId, $userId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate standard deviation
|
||||
*/
|
||||
protected function standardDeviation(array $values): float
|
||||
{
|
||||
if (count($values) < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$mean = array_sum($values) / count($values);
|
||||
$squaredDiffs = array_map(fn ($v) => pow($v - $mean, 2), $values);
|
||||
|
||||
return sqrt(array_sum($squaredDiffs) / count($values));
|
||||
}
|
||||
}
|
||||
@@ -328,6 +328,25 @@ class SuiteMenuResolver
|
||||
'order' => 24,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SALES REP SECTION (My Accounts, Territory, Commission)
|
||||
// For field sales reps to manage their assigned accounts
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
'sales_rep_dashboard' => [
|
||||
'label' => 'My Sales',
|
||||
'icon' => 'heroicon-o-user-circle',
|
||||
'route' => 'seller.business.sales.dashboard',
|
||||
'section' => 'Commerce',
|
||||
'order' => 56,
|
||||
],
|
||||
'sales_rep_accounts' => [
|
||||
'label' => 'My Accounts',
|
||||
'icon' => 'heroicon-o-building-office-2',
|
||||
'route' => 'seller.business.sales.accounts',
|
||||
'section' => 'Commerce',
|
||||
'order' => 57,
|
||||
],
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// LEGACY INBOX SECTION (now merged into Connect)
|
||||
// Kept for backwards compatibility with existing suite configs
|
||||
|
||||
@@ -54,6 +54,8 @@ return [
|
||||
'orders',
|
||||
'invoices',
|
||||
// 'backorders' removed - will be shown on account page
|
||||
'sales_rep_dashboard', // My Sales dashboard for field reps
|
||||
'sales_rep_accounts', // My Accounts - accounts assigned to this rep
|
||||
|
||||
// Brands section
|
||||
'brands',
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Sales Rep Assignments - Assign sales reps to accounts (businesses) or locations (stores)
|
||||
*
|
||||
* Supports primary/secondary assignments with optional commission rate override.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sales_rep_assignments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained()->cascadeOnDelete(); // Seller business
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); // Sales rep
|
||||
|
||||
// Polymorphic: can assign to Business (account) or Location (store)
|
||||
$table->string('assignable_type'); // App\Models\Business or App\Models\Location
|
||||
$table->unsignedBigInteger('assignable_id');
|
||||
|
||||
$table->string('assignment_type')->default('primary'); // primary, secondary
|
||||
$table->decimal('commission_rate', 5, 2)->nullable(); // Override rate for this assignment
|
||||
$table->timestamp('assigned_at')->useCurrent();
|
||||
$table->foreignId('assigned_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
// Prevent duplicate assignments
|
||||
$table->unique(['business_id', 'user_id', 'assignable_type', 'assignable_id'], 'sales_rep_unique_assignment');
|
||||
|
||||
$table->index(['assignable_type', 'assignable_id']);
|
||||
$table->index(['user_id', 'assignment_type']);
|
||||
});
|
||||
|
||||
// Add primary_sales_rep_id to businesses for quick lookup
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->foreignId('primary_sales_rep_id')->nullable()->after('owner_id')->constrained('users')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('primary_sales_rep_id');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('sales_rep_assignments');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Sales Territories - Geographic territories for sales rep assignment
|
||||
*
|
||||
* Territories can be defined by zip codes, cities, states, or counties.
|
||||
* Sales reps can be assigned as primary or secondary to territories.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Main territories table
|
||||
Schema::create('sales_territories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('color', 7)->default('#3B82F6'); // Hex color for map/UI
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['business_id', 'is_active']);
|
||||
});
|
||||
|
||||
// Geographic areas that make up a territory
|
||||
Schema::create('sales_territory_areas', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('territory_id')->constrained('sales_territories')->cascadeOnDelete();
|
||||
$table->string('area_type'); // zip, city, state, county
|
||||
$table->string('area_value'); // e.g., "85001", "Phoenix", "AZ", "Maricopa"
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['territory_id', 'area_type']);
|
||||
$table->index(['area_type', 'area_value']);
|
||||
});
|
||||
|
||||
// Sales rep assignments to territories
|
||||
Schema::create('sales_territory_assignments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('territory_id')->constrained('sales_territories')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('assignment_type')->default('primary'); // primary, secondary
|
||||
$table->timestamp('assigned_at')->useCurrent();
|
||||
$table->foreignId('assigned_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['territory_id', 'user_id'], 'territory_user_unique');
|
||||
$table->index(['user_id', 'assignment_type']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sales_territory_assignments');
|
||||
Schema::dropIfExists('sales_territory_areas');
|
||||
Schema::dropIfExists('sales_territories');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Sales Commissions - Commission rates and tracking for sales reps
|
||||
*
|
||||
* Supports default rates, per-account rates, per-product rates, and per-brand rates.
|
||||
* Tracks actual commissions earned from orders.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Commission rate rules
|
||||
Schema::create('sales_commission_rates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); // null = default for all reps
|
||||
|
||||
// Rate type determines what this rate applies to
|
||||
$table->string('rate_type'); // default, account, product, brand
|
||||
|
||||
// Polymorphic reference for account/product/brand specific rates
|
||||
$table->string('rateable_type')->nullable(); // App\Models\Business, App\Models\Product, App\Models\Brand
|
||||
$table->unsignedBigInteger('rateable_id')->nullable();
|
||||
|
||||
$table->decimal('commission_percent', 5, 2); // e.g., 5.00 = 5%
|
||||
$table->date('effective_from');
|
||||
$table->date('effective_to')->nullable(); // null = no end date
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['business_id', 'rate_type', 'is_active']);
|
||||
$table->index(['rateable_type', 'rateable_id']);
|
||||
$table->index(['user_id', 'is_active']);
|
||||
});
|
||||
|
||||
// Actual commission records from orders
|
||||
Schema::create('sales_commissions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); // Sales rep
|
||||
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('order_item_id')->nullable()->constrained()->cascadeOnDelete(); // For line-item level
|
||||
$table->foreignId('commission_rate_id')->nullable()->constrained('sales_commission_rates')->nullOnDelete();
|
||||
|
||||
$table->unsignedBigInteger('order_total'); // In cents
|
||||
$table->decimal('commission_percent', 5, 2); // Rate applied (snapshot)
|
||||
$table->unsignedBigInteger('commission_amount'); // In cents
|
||||
|
||||
$table->string('status')->default('pending'); // pending, approved, paid
|
||||
$table->timestamp('approved_at')->nullable();
|
||||
$table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('paid_at')->nullable();
|
||||
$table->string('payment_reference')->nullable(); // External payment reference
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['business_id', 'status']);
|
||||
$table->index(['user_id', 'status']);
|
||||
$table->index(['order_id']);
|
||||
$table->unique(['order_id', 'user_id', 'order_item_id'], 'unique_commission_per_item');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sales_commissions');
|
||||
Schema::dropIfExists('sales_commission_rates');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Add sales-specific fields to contacts table
|
||||
*
|
||||
* Supports sales rep workflows for managing buyer contacts
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('contacts', function (Blueprint $table) {
|
||||
// Sales-specific fields for working with contacts
|
||||
$table->string('best_time_to_contact')->nullable()->after('timezone');
|
||||
$table->text('working_notes')->nullable()->after('relationship_notes');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('contacts', function (Blueprint $table) {
|
||||
$table->dropColumn(['best_time_to_contact', 'working_notes']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Account Notes - Sales rep notes on buyer accounts
|
||||
*
|
||||
* Allows sales reps to track competitive intelligence, pain points,
|
||||
* opportunities, and general notes about accounts they manage.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('account_notes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained()->cascadeOnDelete(); // Seller
|
||||
$table->foreignId('account_id')->constrained('businesses')->cascadeOnDelete(); // Buyer
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); // Who wrote it
|
||||
|
||||
// Note classification
|
||||
$table->string('note_type'); // general, competitor, pain_point, opportunity, objection
|
||||
|
||||
// Content
|
||||
$table->text('content');
|
||||
$table->boolean('is_pinned')->default(false);
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['business_id', 'account_id']);
|
||||
$table->index(['account_id', 'note_type']);
|
||||
$table->index(['account_id', 'is_pinned']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('account_notes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Competitor Replacements - Maps CannaiQ competitor products to our products
|
||||
*
|
||||
* Allows sales reps to document "when you see competitor X, pitch our product Y"
|
||||
* with notes on why our product is a better fit.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('competitor_replacements', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained()->cascadeOnDelete(); // Seller
|
||||
|
||||
// CannaiQ competitor product reference
|
||||
$table->string('cannaiq_product_id'); // External ID from CannaiQ
|
||||
$table->string('competitor_name'); // Denormalized for display
|
||||
$table->string('competitor_product_name')->nullable(); // Specific product name
|
||||
|
||||
// Our replacement product
|
||||
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
// Sales pitch notes
|
||||
$table->text('advantage_notes')->nullable(); // Why ours is better
|
||||
|
||||
// Audit
|
||||
$table->foreignId('created_by')->constrained('users')->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['business_id', 'cannaiq_product_id'], 'unique_competitor_per_business');
|
||||
$table->index(['business_id', 'competitor_name']);
|
||||
$table->index(['product_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('competitor_replacements');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Prospect Insights - Gap analysis and opportunity tracking for prospects
|
||||
*
|
||||
* Tracks gaps (price point, category, quality), pain points, and opportunities
|
||||
* for prospect accounts. Can be used for both CrmLeads and existing accounts.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('prospect_insights', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained()->cascadeOnDelete(); // Seller
|
||||
|
||||
// Can link to either a lead or existing account
|
||||
$table->foreignId('lead_id')->nullable()->constrained('crm_leads')->cascadeOnDelete();
|
||||
$table->foreignId('account_id')->nullable()->constrained('businesses')->cascadeOnDelete();
|
||||
|
||||
// Insight classification
|
||||
$table->string('insight_type'); // gap, pain_point, opportunity, objection, competitor_weakness
|
||||
$table->string('category')->nullable(); // price_point, quality, consistency, service, margin, reliability, selection
|
||||
|
||||
// Content
|
||||
$table->text('description');
|
||||
$table->json('supporting_data')->nullable(); // Success story refs, metrics, etc.
|
||||
|
||||
// Audit
|
||||
$table->foreignId('created_by')->constrained('users')->cascadeOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['business_id', 'lead_id']);
|
||||
$table->index(['business_id', 'account_id']);
|
||||
$table->index(['insight_type', 'category']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('prospect_insights');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Prospect Imports - Track CSV/bulk import jobs for prospects
|
||||
*
|
||||
* Records import history with success/error counts and allows
|
||||
* reviewing failed rows.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('prospect_imports', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('business_id')->constrained()->cascadeOnDelete(); // Seller
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); // Who imported
|
||||
|
||||
$table->string('filename');
|
||||
$table->string('status')->default('pending'); // pending, processing, completed, failed
|
||||
|
||||
// Progress tracking
|
||||
$table->unsignedInteger('total_rows')->default(0);
|
||||
$table->unsignedInteger('processed_rows')->default(0);
|
||||
$table->unsignedInteger('created_count')->default(0);
|
||||
$table->unsignedInteger('updated_count')->default(0);
|
||||
$table->unsignedInteger('skipped_count')->default(0);
|
||||
$table->unsignedInteger('error_count')->default(0);
|
||||
|
||||
// Error details
|
||||
$table->json('errors')->nullable(); // Array of row errors
|
||||
$table->json('column_mapping')->nullable(); // How columns were mapped
|
||||
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['business_id', 'status']);
|
||||
$table->index(['user_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('prospect_imports');
|
||||
}
|
||||
};
|
||||
169
resources/views/seller/sales/accounts/index.blade.php
Normal file
169
resources/views/seller/sales/accounts/index.blade.php
Normal file
@@ -0,0 +1,169 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<div class="nx-shell">
|
||||
<div class="nx-page">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">My Accounts</h1>
|
||||
<p class="text-base-content/70">Accounts assigned to you for management</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-outline btn-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
Filter
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li><a href="{{ route('seller.business.sales.accounts', $business) }}" class="{{ !request('status') ? 'active' : '' }}">All</a></li>
|
||||
<li><a href="{{ route('seller.business.sales.accounts', [$business, 'status' => 'at_risk']) }}" class="{{ request('status') === 'at_risk' ? 'active' : '' }}">At Risk</a></li>
|
||||
<li><a href="{{ route('seller.business.sales.accounts', [$business, 'status' => 'needs_attention']) }}" class="{{ request('status') === 'needs_attention' ? 'active' : '' }}">Needs Attention</a></li>
|
||||
<li><a href="{{ route('seller.business.sales.accounts', [$business, 'status' => 'healthy']) }}" class="{{ request('status') === 'healthy' ? 'active' : '' }}">Healthy</a></li>
|
||||
<li><a href="{{ route('seller.business.sales.accounts', [$business, 'status' => 'new']) }}" class="{{ request('status') === 'new' ? 'active' : '' }}">New</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<a href="{{ route('seller.business.sales.dashboard', $business) }}" class="btn btn-ghost btn-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 17l-5-5m0 0l5-5m-5 5h12" />
|
||||
</svg>
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Health Status Summary --}}
|
||||
<div class="flex gap-2 mb-6 flex-wrap">
|
||||
@php
|
||||
$statusCounts = $accounts->groupBy('health.status')->map->count();
|
||||
@endphp
|
||||
<div class="badge badge-error gap-1">
|
||||
<span class="font-bold">{{ $statusCounts['at_risk'] ?? 0 }}</span> At Risk
|
||||
</div>
|
||||
<div class="badge badge-warning gap-1">
|
||||
<span class="font-bold">{{ $statusCounts['needs_attention'] ?? 0 }}</span> Needs Attention
|
||||
</div>
|
||||
<div class="badge badge-success gap-1">
|
||||
<span class="font-bold">{{ $statusCounts['healthy'] ?? 0 }}</span> Healthy
|
||||
</div>
|
||||
<div class="badge badge-info gap-1">
|
||||
<span class="font-bold">{{ $statusCounts['new'] ?? 0 }}</span> New
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Accounts List --}}
|
||||
<div class="space-y-3">
|
||||
@forelse($accounts as $item)
|
||||
@php
|
||||
$account = $item['account'];
|
||||
$metrics = $item['metrics'];
|
||||
$health = $item['health'];
|
||||
$assignment = $item['assignment'];
|
||||
@endphp
|
||||
<div class="card bg-base-200 hover:bg-base-300 transition-colors">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
{{-- Avatar --}}
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-{{ $health['color'] }} text-{{ $health['color'] }}-content rounded-lg w-12">
|
||||
<span class="text-xl">{{ substr($account->name ?? '?', 0, 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Account Info --}}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<a href="{{ route('seller.business.sales.accounts.show', [$business, $account]) }}"
|
||||
class="font-bold text-lg hover:underline">
|
||||
{{ $account->name }}
|
||||
</a>
|
||||
<span class="badge badge-{{ $health['color'] }} badge-sm">{{ $health['label'] }}</span>
|
||||
@if($assignment->assignment_type === 'secondary')
|
||||
<span class="badge badge-ghost badge-xs">Secondary</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-sm text-base-content/70 flex flex-wrap gap-x-4 gap-y-1">
|
||||
@if($account->primaryLocation)
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{{ $account->primaryLocation->city }}, {{ $account->primaryLocation->state }}
|
||||
</span>
|
||||
@endif
|
||||
@if($account->contacts->count() > 0)
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
{{ $account->contacts->count() }} contact{{ $account->contacts->count() !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Metrics --}}
|
||||
<div class="flex gap-6 text-sm">
|
||||
<div class="text-center">
|
||||
<div class="font-bold text-lg">${{ number_format($metrics['four_week_revenue'] ?? 0) }}</div>
|
||||
<div class="text-base-content/50 text-xs">4-week revenue</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="font-bold text-lg">{{ $metrics['order_count'] ?? 0 }}</div>
|
||||
<div class="text-base-content/50 text-xs">orders</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
@if($metrics['days_since_order'])
|
||||
<div class="font-bold text-lg {{ $metrics['days_since_order'] >= 60 ? 'text-error' : ($metrics['days_since_order'] >= 30 ? 'text-warning' : '') }}">
|
||||
{{ $metrics['days_since_order'] }}
|
||||
</div>
|
||||
<div class="text-base-content/50 text-xs">days ago</div>
|
||||
@else
|
||||
<div class="font-bold text-lg text-base-content/30">--</div>
|
||||
<div class="text-base-content/50 text-xs">no orders</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Actions --}}
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('seller.business.sales.accounts.show', [$business, $account]) }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
View
|
||||
</a>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li><a href="{{ route('seller.business.crm.accounts.show', [$business, $account]) }}">Full CRM Profile</a></li>
|
||||
<li><a href="{{ route('seller.business.crm.deals.create', [$business, 'account_id' => $account->id]) }}">Create Deal</a></li>
|
||||
<li><a href="{{ route('seller.business.crm.tasks.create', [$business, 'account_id' => $account->id]) }}">Add Task</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body text-center py-12">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-base-content/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-bold mb-2">No accounts assigned</h3>
|
||||
<p class="text-base-content/50">You don't have any accounts assigned to you yet.</p>
|
||||
<p class="text-base-content/50 text-sm mt-2">Contact your manager to get account assignments.</p>
|
||||
</div>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
310
resources/views/seller/sales/accounts/show.blade.php
Normal file
310
resources/views/seller/sales/accounts/show.blade.php
Normal file
@@ -0,0 +1,310 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<div class="nx-shell">
|
||||
<div class="nx-page">
|
||||
<div class="p-6">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="{{ route('seller.business.sales.accounts', $business) }}" class="btn btn-ghost btn-sm btn-square">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ $account->name }}</h1>
|
||||
<p class="text-base-content/70">
|
||||
@if($account->primaryLocation)
|
||||
{{ $account->primaryLocation->city }}, {{ $account->primaryLocation->state }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('seller.business.crm.accounts.show', [$business, $account]) }}" class="btn btn-outline btn-sm">
|
||||
Full CRM Profile
|
||||
</a>
|
||||
<a href="{{ route('seller.business.crm.deals.create', [$business, 'account_id' => $account->id]) }}" class="btn btn-primary btn-sm">
|
||||
Create Deal
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Metrics Cards --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
|
||||
<div class="stat bg-base-200 rounded-lg p-4">
|
||||
<div class="stat-title text-xs">Lifetime Revenue</div>
|
||||
<div class="stat-value text-lg">${{ number_format($metrics['lifetime_revenue']) }}</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-lg p-4">
|
||||
<div class="stat-title text-xs">4-Week Revenue</div>
|
||||
<div class="stat-value text-lg">${{ number_format($metrics['four_week_revenue']) }}</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-lg p-4">
|
||||
<div class="stat-title text-xs">Total Orders</div>
|
||||
<div class="stat-value text-lg">{{ $metrics['lifetime_orders'] }}</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-lg p-4">
|
||||
<div class="stat-title text-xs">Avg Order</div>
|
||||
<div class="stat-value text-lg">${{ number_format($metrics['avg_order_value']) }}</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-lg p-4">
|
||||
<div class="stat-title text-xs">Days Since Order</div>
|
||||
<div class="stat-value text-lg {{ ($metrics['days_since_order'] ?? 999) >= 60 ? 'text-error' : (($metrics['days_since_order'] ?? 0) >= 30 ? 'text-warning' : '') }}">
|
||||
{{ $metrics['days_since_order'] ?? '--' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-lg p-4">
|
||||
<div class="stat-title text-xs">Order Interval</div>
|
||||
<div class="stat-value text-lg">{{ $metrics['avg_order_interval'] ? $metrics['avg_order_interval'] . ' days' : '--' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{{-- Main Column --}}
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
{{-- Order History --}}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Order History</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Date</th>
|
||||
<th>Items</th>
|
||||
<th>Total</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($orders as $order)
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<a href="{{ route('seller.business.orders.show', [$business, $order]) }}" class="link link-primary">
|
||||
{{ $order->order_number }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ $order->created_at->format('M j, Y') }}</td>
|
||||
<td>
|
||||
<div class="text-sm">
|
||||
@foreach($order->items->take(3) as $item)
|
||||
<div class="truncate max-w-xs">{{ $item->product->name ?? 'Product' }} x{{ $item->quantity }}</div>
|
||||
@endforeach
|
||||
@if($order->items->count() > 3)
|
||||
<div class="text-base-content/50">+{{ $order->items->count() - 3 }} more</div>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td>${{ number_format($order->total / 100, 2) }}</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ $order->status === 'completed' ? 'success' : ($order->status === 'pending' ? 'warning' : 'ghost') }} badge-sm">
|
||||
{{ ucfirst($order->status) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-8 text-base-content/50">
|
||||
No orders yet
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Notes --}}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Account Notes</h2>
|
||||
|
||||
{{-- Add Note Form --}}
|
||||
<form action="{{ route('seller.business.sales.accounts.notes.store', [$business, $account]) }}" method="POST" class="mb-4">
|
||||
@csrf
|
||||
<div class="flex gap-2 mb-2">
|
||||
<select name="note_type" class="select select-bordered select-sm">
|
||||
<option value="general">General</option>
|
||||
<option value="competitor">Competitor Intel</option>
|
||||
<option value="pain_point">Pain Point</option>
|
||||
<option value="opportunity">Opportunity</option>
|
||||
<option value="objection">Objection</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<textarea name="content" class="textarea textarea-bordered flex-1" rows="2" placeholder="Add a note..."></textarea>
|
||||
<button type="submit" class="btn btn-primary">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Notes List --}}
|
||||
<div class="space-y-3">
|
||||
@forelse($notes as $note)
|
||||
<div class="bg-base-100 rounded-lg p-3 {{ $note->is_pinned ? 'border-l-4 border-primary' : '' }}">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-{{ $note->note_type === 'competitor' ? 'secondary' : ($note->note_type === 'pain_point' ? 'error' : ($note->note_type === 'opportunity' ? 'success' : 'ghost')) }} badge-sm">
|
||||
{{ $note->getTypeLabel() }}
|
||||
</span>
|
||||
@if($note->is_pinned)
|
||||
<svg class="w-4 h-4 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M5 5a2 2 0 012-2h6a2 2 0 012 2v2a2 2 0 01-2 2H7a2 2 0 01-2-2V5z" />
|
||||
<path d="M9 14v4a1 1 0 001 1h.01a1 1 0 00.99-1v-4h-2z" />
|
||||
</svg>
|
||||
@endif
|
||||
<span class="text-xs text-base-content/50">
|
||||
{{ $note->author->name ?? 'Unknown' }} - {{ $note->created_at->diffForHumans() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-xs btn-square">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01" />
|
||||
</svg>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-32">
|
||||
<li>
|
||||
<form action="{{ route('seller.business.sales.accounts.notes.pin', [$business, $note]) }}" method="POST">
|
||||
@csrf
|
||||
<button type="submit">{{ $note->is_pinned ? 'Unpin' : 'Pin' }}</button>
|
||||
</form>
|
||||
</li>
|
||||
@if($note->user_id === auth()->id())
|
||||
<li>
|
||||
<form action="{{ route('seller.business.sales.accounts.notes.destroy', [$business, $note]) }}" method="POST">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-error">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm whitespace-pre-wrap">{{ $note->content }}</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-8 text-base-content/50">
|
||||
No notes yet. Add your first note above.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Sidebar --}}
|
||||
<div class="space-y-6">
|
||||
{{-- Contacts --}}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">Contacts</h2>
|
||||
<div class="space-y-3">
|
||||
@forelse($account->contacts as $contact)
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-neutral text-neutral-content rounded-full w-8">
|
||||
<span class="text-xs">{{ substr($contact->first_name ?? '?', 0, 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm">{{ $contact->getFullName() }}</div>
|
||||
@if($contact->position)
|
||||
<div class="text-xs text-base-content/50">{{ $contact->position }}</div>
|
||||
@endif
|
||||
@if($contact->contact_type)
|
||||
<span class="badge badge-ghost badge-xs">{{ ucfirst($contact->contact_type) }}</span>
|
||||
@endif
|
||||
<div class="flex gap-2 mt-1">
|
||||
@if($contact->phone)
|
||||
<a href="tel:{{ $contact->phone }}" class="btn btn-ghost btn-xs btn-square" title="Call">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</a>
|
||||
@endif
|
||||
@if($contact->email)
|
||||
<a href="mailto:{{ $contact->email }}" class="btn btn-ghost btn-xs btn-square" title="Email">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-4 text-base-content/50 text-sm">
|
||||
No contacts found
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
<div class="card-actions mt-2">
|
||||
<a href="{{ route('seller.business.crm.accounts.contacts', [$business, $account]) }}" class="btn btn-ghost btn-xs btn-block">
|
||||
Manage Contacts
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Locations --}}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">Locations</h2>
|
||||
<div class="space-y-2">
|
||||
@forelse($account->locations as $location)
|
||||
<div class="bg-base-100 rounded p-2">
|
||||
<div class="font-medium text-sm">{{ $location->name ?? 'Main Location' }}</div>
|
||||
<div class="text-xs text-base-content/70">
|
||||
{{ $location->address_line_1 }}<br>
|
||||
{{ $location->city }}, {{ $location->state }} {{ $location->zipcode }}
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-4 text-base-content/50 text-sm">
|
||||
No locations
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Assignment Info --}}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">Assignment</h2>
|
||||
<div class="text-sm space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">Type</span>
|
||||
<span class="badge badge-{{ $assignment->assignment_type === 'primary' ? 'primary' : 'ghost' }} badge-sm">
|
||||
{{ ucfirst($assignment->assignment_type) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">Assigned</span>
|
||||
<span>{{ $assignment->assigned_at->format('M j, Y') }}</span>
|
||||
</div>
|
||||
@if($assignment->commission_rate)
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">Commission</span>
|
||||
<span>{{ $assignment->commission_rate }}%</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@if($assignment->notes)
|
||||
<div class="mt-3 p-2 bg-base-100 rounded text-sm">
|
||||
{{ $assignment->notes }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
170
resources/views/seller/sales/dashboard/index.blade.php
Normal file
170
resources/views/seller/sales/dashboard/index.blade.php
Normal file
@@ -0,0 +1,170 @@
|
||||
@extends('layouts.app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
<div class="nx-shell">
|
||||
<div class="nx-page">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">My Sales Dashboard</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('seller.business.sales.accounts', $business) }}" class="btn btn-primary btn-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
View All Accounts
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Stats Cards --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="stat bg-base-200 rounded-lg">
|
||||
<div class="stat-title">Assigned Accounts</div>
|
||||
<div class="stat-value text-primary">{{ $stats['assigned_accounts'] ?? 0 }}</div>
|
||||
<div class="stat-desc">{{ $stats['assigned_locations'] ?? 0 }} locations</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-lg">
|
||||
<div class="stat-title">Needs Attention</div>
|
||||
<div class="stat-value text-warning">{{ $stats['needs_attention'] ?? 0 }}</div>
|
||||
<div class="stat-desc">30-60 days since order</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-lg">
|
||||
<div class="stat-title">At Risk</div>
|
||||
<div class="stat-value text-error">{{ $stats['at_risk'] ?? 0 }}</div>
|
||||
<div class="stat-desc">60+ days since order</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-lg">
|
||||
<div class="stat-title">Pending Commission</div>
|
||||
<div class="stat-value text-success">${{ number_format($commission_summary['pending'] ?? 0) }}</div>
|
||||
<div class="stat-desc">${{ number_format($commission_summary['approved'] ?? 0) }} approved</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{{-- Needs Attention --}}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg class="w-5 h-5 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
Needs Attention
|
||||
<span class="badge badge-warning">{{ $needs_attention->count() }}</span>
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
@forelse($needs_attention as $account)
|
||||
<a href="{{ route('seller.business.sales.accounts.show', [$business, $account]) }}"
|
||||
class="flex items-center gap-3 p-3 bg-base-100 rounded-lg hover:bg-base-300 transition-colors">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-warning text-warning-content rounded-full w-10">
|
||||
<span>{{ substr($account->name ?? '?', 0, 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ $account->name }}</div>
|
||||
<div class="text-sm text-base-content/70 truncate">
|
||||
{{ $account->primaryLocation?->city ?? 'No location' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="badge badge-warning badge-outline">Reach out</div>
|
||||
</a>
|
||||
@empty
|
||||
<div class="text-center py-8 text-base-content/50">
|
||||
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
All accounts are healthy!
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- At Risk --}}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<svg class="w-5 h-5 text-error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
At Risk Accounts
|
||||
<span class="badge badge-error">{{ $at_risk->count() }}</span>
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
@forelse($at_risk as $account)
|
||||
<a href="{{ route('seller.business.sales.accounts.show', [$business, $account]) }}"
|
||||
class="flex items-center gap-3 p-3 bg-base-100 rounded-lg hover:bg-base-300 transition-colors border-l-4 border-error">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-error text-error-content rounded-full w-10">
|
||||
<span>{{ substr($account->name ?? '?', 0, 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium truncate">{{ $account->name }}</div>
|
||||
<div class="text-sm text-base-content/70 truncate">
|
||||
{{ $account->primaryLocation?->city ?? 'No location' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="badge badge-error badge-outline">Urgent</div>
|
||||
</a>
|
||||
@empty
|
||||
<div class="text-center py-8 text-base-content/50">
|
||||
<svg class="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
No at-risk accounts
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Recent Orders --}}
|
||||
<div class="card bg-base-200 mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Recent Orders from My Accounts</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Account</th>
|
||||
<th>Date</th>
|
||||
<th>Total</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($recent_orders as $order)
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<a href="{{ route('seller.business.orders.show', [$business, $order]) }}" class="link link-primary">
|
||||
{{ $order->order_number }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ $order->business->name ?? 'Unknown' }}</td>
|
||||
<td>{{ $order->created_at->format('M j, Y') }}</td>
|
||||
<td>${{ number_format($order->total / 100, 2) }}</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ $order->status === 'completed' ? 'success' : ($order->status === 'pending' ? 'warning' : 'ghost') }} badge-sm">
|
||||
{{ ucfirst($order->status) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-8 text-base-content/50">
|
||||
No recent orders from your accounts
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1038,6 +1038,23 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
|
||||
});
|
||||
});
|
||||
|
||||
// Sales Rep Module (Part of Sales Suite)
|
||||
// Features: My Accounts, Reorder Alerts, Territory Management, Commission Tracking
|
||||
// URL: /s/{business}/sales/*
|
||||
Route::prefix('sales')->name('sales.')->middleware('suite:sales')->group(function () {
|
||||
// Sales Rep Dashboard
|
||||
Route::get('/', [\App\Http\Controllers\Seller\Sales\DashboardController::class, 'index'])->name('dashboard');
|
||||
|
||||
// My Accounts - accounts assigned to this sales rep
|
||||
Route::get('/accounts', [\App\Http\Controllers\Seller\Sales\AccountsController::class, 'index'])->name('accounts');
|
||||
Route::get('/accounts/{account:slug}', [\App\Http\Controllers\Seller\Sales\AccountsController::class, 'show'])->name('accounts.show')->scopeBindings(false);
|
||||
|
||||
// Account Notes
|
||||
Route::post('/accounts/{account:slug}/notes', [\App\Http\Controllers\Seller\Sales\AccountsController::class, 'storeNote'])->name('accounts.notes.store')->scopeBindings(false);
|
||||
Route::post('/notes/{note}/pin', [\App\Http\Controllers\Seller\Sales\AccountsController::class, 'toggleNotePin'])->name('accounts.notes.pin');
|
||||
Route::delete('/notes/{note}', [\App\Http\Controllers\Seller\Sales\AccountsController::class, 'destroyNote'])->name('accounts.notes.destroy');
|
||||
});
|
||||
|
||||
// Marketing Module (Optional)
|
||||
// Flag: has_marketing
|
||||
// Features: Social media management, campaigns, email marketing
|
||||
|
||||
Reference in New Issue
Block a user