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

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2025-12-15 23:55:05 +00:00
28 changed files with 3253 additions and 0 deletions

View 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];
}
}

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

View 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.'.';
}
}

View File

@@ -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',

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

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

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

View 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,
};
}
}

View 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;
}
}

View 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();
}
}

View 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}";
}
}

View 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;
}
}

View 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));
}
}

View File

@@ -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

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View 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

View File

@@ -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