feat: dashboard redesign and sidebar consolidation

- Redesign dashboard as daily briefing format with action-first layout
- Consolidate sidebar menu structure (Dashboard as single link)
- Fix CRM form styling to use consistent UI patterns
- Add PWA icons and push notification groundwork
- Update SuiteMenuResolver for cleaner navigation
This commit is contained in:
kelly
2025-12-14 03:21:55 -07:00
parent a812380b32
commit 496ca61489
35 changed files with 6112 additions and 1127 deletions

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use NotificationChannels\WebPush\PushSubscription;
class PushSubscriptionController extends Controller
{
/**
* Store a new push subscription
*/
public function store(Request $request)
{
$validated = $request->validate([
'endpoint' => 'required|url',
'keys.p256dh' => 'required|string',
'keys.auth' => 'required|string',
]);
$user = $request->user();
// Delete existing subscription for this endpoint
PushSubscription::where('endpoint', $validated['endpoint'])->delete();
// Create new subscription
$subscription = $user->updatePushSubscription(
$validated['endpoint'],
$validated['keys']['p256dh'],
$validated['keys']['auth']
);
return response()->json([
'success' => true,
'message' => 'Push subscription saved',
]);
}
/**
* Delete a push subscription
*/
public function destroy(Request $request)
{
$validated = $request->validate([
'endpoint' => 'required|url',
]);
PushSubscription::where('endpoint', $validated['endpoint'])
->where('subscribable_id', $request->user()->id)
->delete();
return response()->json([
'success' => true,
'message' => 'Push subscription removed',
]);
}
}

View File

@@ -19,11 +19,14 @@ use Illuminate\Http\Request;
class AccountController extends Controller
{
/**
* Display accounts listing
* Display accounts listing - only buyers who have ordered from this seller
*/
public function index(Request $request, Business $business)
{
$query = Business::where('type', 'buyer')
->whereHas('orders', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
})
->with(['contacts']);
// Search filter

View File

@@ -172,8 +172,9 @@ class CrmCalendarController extends Controller
]);
$allEvents = $allEvents->merge($bookings);
// 4. CRM Tasks with due dates (shown as all-day markers)
// 4. CRM Tasks with due dates (shown as all-day markers) - only show user's assigned tasks
$tasks = CrmTask::forSellerBusiness($business->id)
->where('assigned_to', $user->id)
->incomplete()
->whereNotNull('due_at')
->whereBetween('due_at', [$startDate, $endDate])

View File

@@ -115,12 +115,13 @@ class DealController extends Controller
->limit(100)
->get();
// Limit accounts for dropdown - most recent 100
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
})
// Limit accounts for dropdown - buyers who have ordered from this seller
$accounts = Business::where('type', 'buyer')
->whereHas('orders', function ($q) use ($business) {
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
})
->select('id', 'name')
->orderByDesc('updated_at')
->orderBy('name')
->limit(100)
->get();

View File

@@ -42,8 +42,8 @@ class InvoiceController extends Controller
// Stats - single efficient query with conditional aggregation
$invoiceStats = CrmInvoice::forBusiness($business->id)
->selectRaw("
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN amount_due ELSE 0 END) as outstanding,
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN amount_due ELSE 0 END) as overdue
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN balance_due ELSE 0 END) as outstanding,
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN balance_due ELSE 0 END) as overdue
")
->first();

View File

@@ -97,7 +97,19 @@ class TaskController extends Controller
*/
public function create(Request $request, Business $business)
{
return view('seller.crm.tasks.create', compact('business'));
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
// Prefill from query params (when creating task from contact/account/etc)
$prefill = [
'title' => $request->get('title'),
'business_id' => $request->get('business_id'),
'contact_id' => $request->get('contact_id'),
'opportunity_id' => $request->get('opportunity_id'),
'conversation_id' => $request->get('conversation_id'),
'order_id' => $request->get('order_id'),
];
return view('seller.crm.tasks.create', compact('business', 'teamMembers', 'prefill'));
}
/**

View File

@@ -16,17 +16,32 @@ class PromotionController extends Controller
protected PromoCalculator $promoCalculator
) {}
public function index(Business $business)
public function index(Request $request, Business $business)
{
// TODO: Future deprecation - This global promotions page will be replaced by brand-scoped promotions
// Once brand-scoped promotions are stable and rolled out, this route should redirect to:
// return redirect()->route('seller.business.brands.promotions.index', [$business, $defaultBrand]);
// Where $defaultBrand is determined by business context or user preference
$promotions = Promotion::where('business_id', $business->id)
->withCount('products')
->orderBy('created_at', 'desc')
->get();
// Get brands for filter dropdown
$brands = \App\Models\Brand::where('business_id', $business->id)
->orderBy('name')
->get(['id', 'name', 'hashid']);
$query = Promotion::where('business_id', $business->id)
->withCount('products');
// Filter by brand
if ($request->filled('brand')) {
$query->where('brand_id', $request->brand);
}
// Filter by status
if ($request->filled('status')) {
$query->where('status', $request->status);
}
$promotions = $query->orderBy('created_at', 'desc')->get();
// Load pending recommendations with product data
// Gracefully handle if promo_recommendations table doesn't exist yet
@@ -41,7 +56,7 @@ class PromotionController extends Controller
->get();
}
return view('seller.promotions.index', compact('business', 'promotions', 'recommendations'));
return view('seller.promotions.index', compact('business', 'promotions', 'recommendations', 'brands'));
}
public function create(Business $business)

View File

@@ -15,12 +15,13 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Lab404\Impersonate\Models\Impersonate;
use NotificationChannels\WebPush\HasPushSubscriptions;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements FilamentUser
{
/** @use HasFactory<UserFactory> */
use HasFactory, HasRoles, HasUuids, Impersonate, Notifiable;
use HasFactory, HasPushSubscriptions, HasRoles, HasUuids, Impersonate, Notifiable;
/**
* User type constants

View File

@@ -315,7 +315,7 @@ class CommandCenterService
// Engagement distribution
$distribution = BuyerEngagementScore::where('seller_business_id', $business->id)
->selectRaw("engagement_level, COUNT(*) as count")
->selectRaw('engagement_level, COUNT(*) as count')
->groupBy('engagement_level')
->pluck('count', 'engagement_level')
->toArray();

View File

@@ -29,23 +29,91 @@ class SuiteMenuResolver
*/
protected array $menuMap = [
// ═══════════════════════════════════════════════════════════════
// SALES SUITE ITEMS
// DASHBOARD SECTION (Single link)
// ═══════════════════════════════════════════════════════════════
'dashboard' => [
'label' => 'Dashboard',
'icon' => 'heroicon-o-home',
'route' => 'seller.business.dashboard',
'section' => 'Overview',
'section' => 'Dashboard',
'order' => 10,
'exact_match' => true, // Don't match seller.business.dashboard.* routes
],
'brands' => [
'label' => 'Brands',
'icon' => 'heroicon-o-building-storefront',
'route' => 'seller.business.brands.index',
'section' => 'Overview',
// ═══════════════════════════════════════════════════════════════
// CONNECT SECTION (Communications, Tasks, Calendar)
// ═══════════════════════════════════════════════════════════════
'connect_overview' => [
'label' => 'Overview',
'icon' => 'heroicon-o-inbox',
'route' => 'seller.business.messaging.index',
'section' => 'Connect',
'order' => 20,
],
'connect_conversations' => [
'label' => 'Conversations',
'icon' => 'heroicon-o-chat-bubble-left-right',
'route' => 'seller.business.crm.threads.index',
'section' => 'Connect',
'order' => 20,
],
'connect_contacts' => [
'label' => 'Contacts',
'icon' => 'heroicon-o-users',
'route' => 'seller.business.contacts.index',
'section' => 'Connect',
'order' => 21,
],
'connect_leads' => [
'label' => 'Leads',
'icon' => 'heroicon-o-user-plus',
'route' => 'seller.business.crm.leads.index',
'section' => 'Connect',
'order' => 22,
],
'connect_tasks' => [
'label' => 'Tasks',
'icon' => 'heroicon-o-clipboard-document-check',
'route' => 'seller.business.crm.tasks.index',
'section' => 'Connect',
'order' => 23,
],
'connect_calendar' => [
'label' => 'Calendar',
'icon' => 'heroicon-o-calendar-days',
'route' => 'seller.business.crm.calendar.index',
'section' => 'Connect',
'order' => 24,
],
// ═══════════════════════════════════════════════════════════════
// BRANDS SECTION
// ═══════════════════════════════════════════════════════════════
'brands' => [
'label' => 'All Brands',
'icon' => 'heroicon-o-building-storefront',
'route' => 'seller.business.brands.index',
'section' => 'Brands',
'order' => 40,
],
'promotions' => [
'label' => 'Promotions',
'icon' => 'heroicon-o-tag',
'route' => 'seller.business.promotions.index',
'section' => 'Brands',
'order' => 41,
],
'menus' => [
'label' => 'Menus',
'icon' => 'heroicon-o-document-text',
'route' => 'seller.business.brands.index', // Goes to brand picker (menus requires brand context)
'section' => 'Brands',
'order' => 42,
],
// ═══════════════════════════════════════════════════════════════
// INVENTORY SECTION
// ═══════════════════════════════════════════════════════════════
'inventory' => [
'label' => 'Products',
'icon' => 'heroicon-o-cube',
@@ -53,109 +121,96 @@ class SuiteMenuResolver
'section' => 'Inventory',
'order' => 100,
],
'menus' => [
'label' => 'Menus',
'icon' => 'heroicon-o-document-text',
'route' => 'seller.business.menus.index',
'section' => 'Sales',
'order' => 200,
],
'promotions' => [
'label' => 'Promotions',
'icon' => 'heroicon-o-tag',
'route' => 'seller.business.promotions.index',
'section' => 'Sales',
'order' => 210,
],
// Legacy items kept for backwards compatibility but reassigned
'buyers_accounts' => [
'label' => 'Customers',
'icon' => 'heroicon-o-user-group',
'route' => 'seller.business.customers.index',
'section' => 'CRM',
'order' => 300,
'section' => 'Commerce',
'order' => 51,
],
'conversations' => [
'label' => 'Inbox',
'icon' => 'heroicon-o-inbox',
'route' => 'seller.business.messaging.index',
'section' => 'Conversations',
'order' => 400,
'section' => 'Connect',
'order' => 20,
],
// CRM Omnichannel Inbox (threads from all channels: email, sms, chat)
'crm_inbox' => [
'label' => 'Inbox',
'label' => 'Conversations',
'icon' => 'heroicon-o-inbox-stack',
'route' => 'seller.business.crm.threads.index',
'section' => 'CRM',
'order' => 270,
'section' => 'Connect',
'order' => 22,
],
'crm_deals' => [
'label' => 'Deals',
'icon' => 'heroicon-o-currency-dollar',
'route' => 'seller.business.crm.deals.index',
'section' => 'CRM',
'order' => 280,
'section' => 'Commerce',
'order' => 55,
],
'messaging' => [
'label' => 'Contacts',
'icon' => 'heroicon-o-chat-bubble-left-right',
'route' => 'seller.business.contacts.index',
'section' => 'Conversations',
'order' => 410,
'section' => 'Connect',
'order' => 21,
],
'automations' => [
'label' => 'Orchestrator',
'icon' => 'heroicon-o-cpu-chip',
'route' => 'seller.business.orchestrator.index',
'section' => 'Overview',
'order' => 40,
'section' => 'Dashboard',
'order' => 11,
],
'copilot' => [
'label' => 'AI Copilot',
'icon' => 'heroicon-o-sparkles',
'route' => 'seller.business.copilot.index',
'section' => 'Automation',
'order' => 510,
'section' => 'Dashboard',
'order' => 12,
'requires_route' => true, // Only show if route exists
],
'analytics' => [
'label' => 'Analytics',
'icon' => 'heroicon-o-chart-bar',
'route' => 'seller.business.dashboard.analytics',
'section' => 'Overview',
'order' => 15,
'section' => 'Dashboard',
'order' => 13,
],
'buyer_intelligence' => [
'label' => 'Buyer Intelligence',
'icon' => 'heroicon-o-light-bulb',
'route' => 'seller.business.buyer-intelligence.index',
'section' => 'Overview',
'order' => 25,
'section' => 'Dashboard',
'order' => 14,
],
'market_intelligence' => [
'label' => 'Market Intelligence',
'icon' => 'heroicon-o-globe-alt',
'route' => 'seller.business.market-intelligence.index',
'section' => 'Overview',
'order' => 26,
'section' => 'Dashboard',
'order' => 15,
'requires_route' => true,
],
// ═══════════════════════════════════════════════════════════════
// COMMERCE SECTION
// ═══════════════════════════════════════════════════════════════
'commerce_overview' => [
'label' => 'Overview',
'icon' => 'heroicon-o-shopping-cart',
'route' => 'seller.business.commerce.index',
'all_customers' => [
'label' => 'Accounts',
'icon' => 'heroicon-o-building-office-2',
'route' => 'seller.business.crm.accounts.index',
'section' => 'Commerce',
'order' => 50,
'requires_route' => true,
],
'all_customers' => [
'label' => 'All Customers',
'icon' => 'heroicon-o-user-group',
'route' => 'seller.business.customers.index',
'quotes' => [
'label' => 'Quotes',
'icon' => 'heroicon-o-document-check',
'route' => 'seller.business.crm.quotes.index',
'section' => 'Commerce',
'order' => 51,
],
@@ -166,27 +221,14 @@ class SuiteMenuResolver
'section' => 'Commerce',
'order' => 52,
],
'quotes' => [
'label' => 'Quotes',
'icon' => 'heroicon-o-document-check',
'route' => 'seller.business.crm.quotes.index',
'section' => 'Commerce',
'order' => 53,
],
'invoices' => [
'label' => 'Invoices',
'icon' => 'heroicon-o-document-text',
'route' => 'seller.business.invoices.index',
'route' => 'seller.business.crm.invoices.index',
'section' => 'Commerce',
'order' => 54,
],
'backorders' => [
'label' => 'Backorders',
'icon' => 'heroicon-o-arrow-uturn-left',
'route' => 'seller.business.backorders.index',
'section' => 'Commerce',
'order' => 55,
'order' => 53,
],
// Backorders removed from nav - will be shown on account page
// ═══════════════════════════════════════════════════════════════
// INVENTORY SECTION (additional items)
@@ -200,107 +242,114 @@ class SuiteMenuResolver
],
// ═══════════════════════════════════════════════════════════════
// GROWTH SECTION (Marketing)
// MARKETING SECTION (formerly Growth)
// Channels & Templates removed - accessible from Campaign create pages
// ═══════════════════════════════════════════════════════════════
'campaigns' => [
'label' => 'Campaigns',
'icon' => 'heroicon-o-megaphone',
'route' => 'seller.business.marketing.campaigns.index',
'section' => 'Growth',
'section' => 'Marketing',
'order' => 220,
],
'channels' => [
'label' => 'Channels',
'icon' => 'heroicon-o-signal',
'route' => 'seller.business.marketing.channels.index',
'section' => 'Growth',
'order' => 230,
],
'templates' => [
'label' => 'Templates',
'icon' => 'heroicon-o-document-text',
'route' => 'seller.business.marketing.templates.index',
'section' => 'Growth',
'order' => 240,
],
'growth_automations' => [
'label' => 'Automations',
'icon' => 'heroicon-o-cog-6-tooth',
'route' => 'seller.business.crm.automations.index',
'section' => 'Growth',
'section' => 'Marketing',
'order' => 230,
],
// Channels removed from sidebar - accessible from Campaign create
'channels' => [
'label' => 'Channels',
'icon' => 'heroicon-o-signal',
'route' => 'seller.business.marketing.channels.index',
'section' => 'Marketing',
'order' => 240,
'requires_route' => true, // Keep but don't show in sidebar
],
// Templates removed from sidebar - accessible from Campaign create
'templates' => [
'label' => 'Templates',
'icon' => 'heroicon-o-document-text',
'route' => 'seller.business.marketing.templates.index',
'section' => 'Marketing',
'order' => 250,
'requires_route' => true, // Keep but don't show in sidebar
],
// ═══════════════════════════════════════════════════════════════
// SALES CRM SECTION
// LEGACY SALES CRM SECTION (now merged into Connect)
// Kept for backwards compatibility with existing suite configs
// ═══════════════════════════════════════════════════════════════
'sales_overview' => [
'label' => 'Overview',
'icon' => 'heroicon-o-presentation-chart-line',
'route' => 'seller.business.crm.dashboard',
'section' => 'Sales',
'order' => 300,
'section' => 'Connect',
'order' => 20,
],
'sales_pipeline' => [
'label' => 'Pipeline',
'icon' => 'heroicon-o-funnel',
'route' => 'seller.business.crm.pipeline.index',
'section' => 'Sales',
'order' => 301,
'section' => 'Commerce',
'order' => 54,
'requires_route' => true,
],
'sales_accounts' => [
'label' => 'Accounts',
'icon' => 'heroicon-o-building-office-2',
'route' => 'seller.business.crm.accounts.index',
'section' => 'Sales',
'order' => 302,
'section' => 'Commerce',
'order' => 50,
],
'sales_tasks' => [
'label' => 'Tasks',
'icon' => 'heroicon-o-clipboard-document-check',
'route' => 'seller.business.crm.tasks.index',
'section' => 'Sales',
'order' => 303,
'section' => 'Connect',
'order' => 23,
],
'sales_activity' => [
'label' => 'Activity',
'icon' => 'heroicon-o-clock',
'route' => 'seller.business.crm.activity.index',
'section' => 'Sales',
'order' => 304,
'section' => 'Connect',
'order' => 25,
],
'sales_calendar' => [
'label' => 'Calendar',
'icon' => 'heroicon-o-calendar-days',
'route' => 'seller.business.crm.calendar.index',
'section' => 'Sales',
'order' => 305,
'section' => 'Connect',
'order' => 24,
],
// ═══════════════════════════════════════════════════════════════
// INBOX SECTION
// LEGACY INBOX SECTION (now merged into Connect)
// Kept for backwards compatibility with existing suite configs
// ═══════════════════════════════════════════════════════════════
'inbox_overview' => [
'label' => 'Overview',
'icon' => 'heroicon-o-inbox',
'route' => 'seller.business.messaging.index',
'section' => 'Inbox',
'order' => 400,
'section' => 'Connect',
'order' => 20,
],
'inbox_contacts' => [
'label' => 'Contacts',
'icon' => 'heroicon-o-users',
'route' => 'seller.business.contacts.index',
'section' => 'Inbox',
'order' => 401,
'section' => 'Connect',
'order' => 21,
],
'inbox_conversations' => [
'label' => 'Conversations',
'icon' => 'heroicon-o-chat-bubble-left-right',
'route' => 'seller.business.conversations.index',
'section' => 'Inbox',
'order' => 402,
'section' => 'Connect',
'order' => 22,
'requires_route' => true,
],
// NOTE: 'settings' removed from sidebar - access via user dropdown only

View File

@@ -38,43 +38,37 @@ return [
*/
'menus' => [
'sales' => [
// Overview section
// Dashboard section (single link)
'dashboard',
'brands',
'market_intelligence',
// Connect section (communications only - tasks/calendar moved to topbar icons)
'connect_conversations',
'connect_contacts',
'connect_leads',
// 'connect_tasks' - moved to topbar icon
// 'connect_calendar' - moved to topbar icon
// Commerce section
'commerce_overview',
'all_customers',
'orders',
'all_customers', // Now shows as "Accounts" -> crm.accounts.index
'quotes',
'orders',
'invoices',
'backorders',
// 'backorders' removed - will be shown on account page
// Brands section
'brands',
'promotions',
// Brands section (uses existing 'brands' in Overview)
'menus',
// Inventory section
'inventory',
'stock',
// Growth section (Marketing)
// Marketing section (formerly Growth)
'campaigns',
'channels',
'templates',
'growth_automations',
// CRM section (Inbox & Deals)
'crm_inbox',
'crm_deals',
// Sales CRM section
'sales_overview',
'sales_pipeline',
'sales_accounts',
'sales_tasks',
'sales_activity',
'sales_calendar',
// Inbox section
'inbox_overview',
'inbox_contacts',
'inbox_conversations',
// Menus (optional)
'menus',
// 'channels' removed - accessible from Campaign create
// 'templates' removed - accessible from Campaign create
],
'processing' => [

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Skip if column already exists (manual fix applied)
if (Schema::hasColumn('crm_tasks', 'status')) {
return;
}
Schema::table('crm_tasks', function (Blueprint $table) {
$table->string('status', 20)->default('pending')->after('type');
});
// Backfill: set status based on completed_at
DB::table('crm_tasks')
->whereNotNull('completed_at')
->update(['status' => 'completed']);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('crm_tasks', function (Blueprint $table) {
$table->dropColumn('status');
});
}
};

4435
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,8 @@
"laravel-vite-plugin": "^1.2.0",
"postcss": "^8.4.31",
"tailwindcss": "^4.1.7",
"vite": "^6.2.4"
"vite": "^6.2.4",
"vite-plugin-pwa": "^1.2.0"
},
"dependencies": {
"@alpinejs/collapse": "^3.15.2",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,4 +1,5 @@
import './bootstrap';
import './push-notifications';
import Alpine from 'alpinejs';
import Precognition from 'laravel-precognition-alpine';

View File

@@ -0,0 +1,193 @@
/**
* Push Notification Manager
* Handles service worker registration, push subscription, and notification permissions
*/
export class PushNotificationManager {
constructor() {
this.vapidPublicKey = document.querySelector('meta[name="vapid-public-key"]')?.content;
this.pushEnabled = false;
}
/**
* Check if push notifications are supported
*/
isSupported() {
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
}
/**
* Initialize push notifications
*/
async init() {
if (!this.isSupported()) {
console.log('Push notifications not supported');
return false;
}
if (!this.vapidPublicKey) {
console.log('VAPID public key not found');
return false;
}
try {
// Wait for service worker registration from vite-plugin-pwa
const registration = await navigator.serviceWorker.ready;
console.log('Service Worker ready for push notifications');
return registration;
} catch (error) {
console.error('Service Worker registration failed:', error);
return false;
}
}
/**
* Request notification permission
*/
async requestPermission() {
const permission = await Notification.requestPermission();
return permission === 'granted';
}
/**
* Subscribe to push notifications
*/
async subscribe() {
try {
const registration = await navigator.serviceWorker.ready;
// Check if already subscribed
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// Create new subscription
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey)
});
}
// Send subscription to server
await this.sendSubscriptionToServer(subscription);
this.pushEnabled = true;
return subscription;
} catch (error) {
console.error('Failed to subscribe to push notifications:', error);
throw error;
}
}
/**
* Unsubscribe from push notifications
*/
async unsubscribe() {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
await this.removeSubscriptionFromServer(subscription);
}
this.pushEnabled = false;
return true;
} catch (error) {
console.error('Failed to unsubscribe from push notifications:', error);
throw error;
}
}
/**
* Send subscription to server
*/
async sendSubscriptionToServer(subscription) {
const response = await fetch('/api/push-subscriptions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content
},
body: JSON.stringify(subscription.toJSON())
});
if (!response.ok) {
throw new Error('Failed to send subscription to server');
}
return response.json();
}
/**
* Remove subscription from server
*/
async removeSubscriptionFromServer(subscription) {
const response = await fetch('/api/push-subscriptions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content
},
body: JSON.stringify({ endpoint: subscription.endpoint })
});
return response.ok;
}
/**
* Check current subscription status
*/
async getSubscriptionStatus() {
if (!this.isSupported()) {
return { supported: false, permission: 'default', subscribed: false };
}
const permission = Notification.permission;
let subscribed = false;
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
subscribed = !!subscription;
} catch (error) {
console.error('Error checking subscription status:', error);
}
return {
supported: true,
permission,
subscribed
};
}
/**
* Convert VAPID key from base64 to Uint8Array
*/
urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
}
// Create global instance
window.PushNotificationManager = PushNotificationManager;
// Auto-initialize when DOM is ready
document.addEventListener('DOMContentLoaded', async () => {
const pushManager = new PushNotificationManager();
window.pushManager = pushManager;
// Initialize service worker
await pushManager.init();
});

View File

@@ -17,20 +17,25 @@
// Define section order for consistent display
$sectionOrder = [
'Overview' => 1,
'Inbox' => 2,
'Dashboard' => 1,
'Connect' => 2,
'Commerce' => 3,
'Inventory' => 4,
'Sales' => 5,
'Growth' => 6,
'Brands' => 4,
'Inventory' => 5,
'Marketing' => 6,
'Processing' => 7,
'Manufacturing' => 8,
'Delivery' => 9,
'Management' => 10,
'Conversations' => 11,
'CRM' => 12,
'Automation' => 13,
'Finances' => 99,
'Finance' => 11,
'Accounting' => 12,
'Financials' => 13,
'Directory' => 14,
'Budgeting' => 15,
'Analytics' => 16,
'Administration' => 17,
'Accounts Receivable' => 18,
'Brand Portal' => 19,
];
// Sort sections by defined order
@@ -111,54 +116,69 @@
</div>
@else
@foreach($groupedMenu as $section => $items)
{{-- Section Header --}}
<div class="sidebar-section-header">{{ $section }}</div>
@if($section === 'Dashboard')
{{-- Dashboard is a single link, not a collapsible section --}}
@foreach($items as $item)
<a class="sidebar-item flex items-center gap-2 px-2.5 py-2 rounded-lg hover:bg-base-200 transition-colors {{ request()->routeIs($item['route']) ? 'active bg-base-200' : '' }}"
href="{{ $item['url'] }}">
<span class="icon-[lucide--bar-chart-3] size-4 text-base-content/60"></span>
<span>{{ $item['label'] }}</span>
</a>
@endforeach
@else
{{-- Section Header --}}
<div class="sidebar-section-header">{{ $section }}</div>
{{-- Section Items --}}
<div class="sidebar-group">
<button
@click="menu{{ Str::camel($section) }} = !menu{{ Str::camel($section) }}"
class="sidebar-group-toggle"
:class="{ 'expanded': menu{{ Str::camel($section) }} }">
@php
// Get first item's icon for section icon
$sectionIcon = match($section) {
'Overview' => 'icon-[lucide--bar-chart-3]',
'Inbox' => 'icon-[lucide--inbox]',
'Commerce' => 'icon-[lucide--shopping-cart]',
'Inventory' => 'icon-[lucide--package]',
'Sales' => 'icon-[lucide--dollar-sign]',
'Growth' => 'icon-[lucide--trending-up]',
'Conversations' => 'icon-[lucide--message-square]',
'CRM' => 'icon-[lucide--briefcase]',
'Automation' => 'icon-[lucide--cpu]',
'Processing' => 'icon-[lucide--beaker]',
'Manufacturing' => 'icon-[lucide--factory]',
'Delivery' => 'icon-[lucide--truck]',
'Management' => 'icon-[lucide--building-2]',
'Finances' => 'icon-[lucide--wallet]',
default => 'icon-[lucide--folder]',
};
@endphp
<span class="{{ $sectionIcon }} size-4 text-base-content/60"></span>
<span class="flex-1 text-left">{{ $section }}</span>
<span class="icon-[lucide--chevron-right] size-3.5 text-base-content/40 transition-transform duration-200"
:class="{ 'rotate-90': menu{{ Str::camel($section) }} }"></span>
</button>
<div x-show="menu{{ Str::camel($section) }}" x-collapse class="sidebar-group-content">
@foreach($items as $item)
<a class="sidebar-item {{ request()->routeIs($item['route']) || (!($item['exact_match'] ?? false) && request()->routeIs($item['route'] . '.*')) ? 'active' : '' }}"
href="{{ $item['url'] }}">
<span class="flex items-center justify-between w-full">
<span>{{ $item['label'] }}</span>
@if(!empty($item['shared_from_parent']))
<span class="ml-1 text-[9px] px-1 py-0.5 rounded bg-base-200 text-base-content/60 uppercase tracking-wide">Shared</span>
@endif
</span>
</a>
@endforeach
{{-- Section Items --}}
<div class="sidebar-group">
<button
@click="menu{{ Str::camel($section) }} = !menu{{ Str::camel($section) }}"
class="sidebar-group-toggle"
:class="{ 'expanded': menu{{ Str::camel($section) }} }">
@php
// Get first item's icon for section icon
$sectionIcon = match($section) {
'Connect' => 'icon-[lucide--link]',
'Commerce' => 'icon-[lucide--shopping-cart]',
'Brands' => 'icon-[lucide--building-2]',
'Inventory' => 'icon-[lucide--package]',
'Marketing' => 'icon-[lucide--megaphone]',
'Processing' => 'icon-[lucide--beaker]',
'Manufacturing' => 'icon-[lucide--factory]',
'Delivery' => 'icon-[lucide--truck]',
'Management' => 'icon-[lucide--building-2]',
'Finance' => 'icon-[lucide--wallet]',
'Accounting' => 'icon-[lucide--calculator]',
'Financials' => 'icon-[lucide--file-text]',
'Directory' => 'icon-[lucide--users]',
'Budgeting' => 'icon-[lucide--pie-chart]',
'Analytics' => 'icon-[lucide--line-chart]',
'Administration' => 'icon-[lucide--shield]',
'Accounts Receivable' => 'icon-[lucide--receipt]',
'Brand Portal' => 'icon-[lucide--store]',
default => 'icon-[lucide--folder]',
};
@endphp
<span class="{{ $sectionIcon }} size-4 text-base-content/60"></span>
<span class="flex-1 text-left">{{ $section }}</span>
<span class="icon-[lucide--chevron-right] size-3.5 text-base-content/40 transition-transform duration-200"
:class="{ 'rotate-90': menu{{ Str::camel($section) }} }"></span>
</button>
<div x-show="menu{{ Str::camel($section) }}" x-collapse class="sidebar-group-content">
@foreach($items as $item)
<a class="sidebar-item {{ request()->routeIs($item['route']) || (!($item['exact_match'] ?? false) && request()->routeIs($item['route'] . '.*')) ? 'active' : '' }}"
href="{{ $item['url'] }}">
<span class="flex items-center justify-between w-full">
<span>{{ $item['label'] }}</span>
@if(!empty($item['shared_from_parent']))
<span class="ml-1 text-[9px] px-1 py-0.5 rounded bg-base-200 text-base-content/60 uppercase tracking-wide">Shared</span>
@endif
</span>
</a>
@endforeach
</div>
</div>
</div>
@endif
@endforeach
@endif
</div>

View File

@@ -85,6 +85,12 @@
<span class="grow">Contacts</span>
</a>
{{-- Leads (prospects not yet converted to accounts) --}}
<a class="menu-item {{ request()->routeIs('seller.business.crm.leads.*') ? 'active' : '' }}"
href="{{ route('seller.business.crm.leads.index', $sidebarBusiness->slug) }}">
<span class="grow">Leads</span>
</a>
{{-- Premium: CRM Threads (if has Sales Suite) for sales-related messages --}}
@if($sidebarBusiness->hasSalesSuite())
<a class="menu-item {{ request()->routeIs('seller.business.crm.threads.*') ? 'active' : '' }}"

View File

@@ -7,12 +7,19 @@
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Favicon -->
<!-- Favicon & PWA -->
@if(\App\Models\SiteSetting::get('favicon_path'))
<link rel="icon" href="{{ \App\Models\SiteSetting::getFaviconUrl() }}" />
@else
<link rel="icon" href="/favicon.ico" />
@endif
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="theme-color" content="#5C0C36">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
@if(config('webpush.vapid.public_key'))
<meta name="vapid-public-key" content="{{ config('webpush.vapid.public_key') }}">
@endif
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
@@ -115,16 +122,49 @@
</div>
<div class="flex items-center gap-4">
<!-- Theme Switcher - Exact Nexus Lucide icons -->
<button
aria-label="Toggle Theme"
class="btn btn-sm btn-circle btn-ghost relative overflow-hidden"
onclick="toggleTheme()">
<span class="icon-[heroicons--sun] absolute size-4.5 -translate-y-4 opacity-0 transition-all duration-300 group-data-[theme=light]/html:translate-y-0 group-data-[theme=light]/html:opacity-100"></span>
<span class="icon-[heroicons--moon] absolute size-4.5 translate-y-4 opacity-0 transition-all duration-300 group-data-[theme=dark]/html:translate-y-0 group-data-[theme=dark]/html:opacity-100"></span>
<span class="icon-[heroicons--paint-brush] absolute size-4.5 opacity-100 group-data-[theme=dark]/html:opacity-0 group-data-[theme=light]/html:opacity-0"></span>
</button>
<!-- Quick Access Icons -->
@php
$topbarBusiness = request()->route('business') ?? auth()->user()?->primaryBusiness();
@endphp
@if($topbarBusiness)
{{-- Calendar --}}
<a href="{{ route('seller.business.crm.calendar.index', $topbarBusiness->slug) }}"
class="btn btn-sm btn-circle btn-ghost relative"
aria-label="Calendar">
<span class="icon-[heroicons--calendar-days] size-5"></span>
@php
$todayEventCount = \App\Models\CalendarEvent::where('seller_business_id', $topbarBusiness->id)
->where('assigned_to', auth()->id())
->where('status', 'scheduled')
->whereDate('start_at', today())
->count();
@endphp
@if($todayEventCount > 0)
<div class="absolute -top-1 -right-1 bg-info text-info-content text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1">
<span>{{ $todayEventCount > 99 ? '99+' : $todayEventCount }}</span>
</div>
@endif
</a>
{{-- Tasks --}}
<a href="{{ route('seller.business.crm.tasks.index', $topbarBusiness->slug) }}"
class="btn btn-sm btn-circle btn-ghost relative"
aria-label="Tasks">
<span class="icon-[heroicons--clipboard-document-check] size-5"></span>
@php
$pendingTaskCount = \App\Models\Crm\CrmTask::where('seller_business_id', $topbarBusiness->id)
->where('assigned_to', auth()->id())
->whereIn('status', ['pending', 'in_progress'])
->count();
@endphp
@if($pendingTaskCount > 0)
<div class="absolute -top-1 -right-1 bg-primary text-primary-content text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1">
<span>{{ $pendingTaskCount > 99 ? '99+' : $pendingTaskCount }}</span>
</div>
@endif
</a>
@endif
<!-- Notifications - Nexus Basic Style -->
<div class="relative" x-data="notificationDropdown()" x-cloak>
<button class="btn btn-sm btn-circle btn-ghost relative"
@@ -234,6 +274,16 @@
</div>
</div>
<!-- Theme Switcher -->
<button
aria-label="Toggle Theme"
class="btn btn-sm btn-circle btn-ghost relative overflow-hidden"
onclick="toggleTheme()">
<span class="icon-[heroicons--sun] absolute size-4.5 -translate-y-4 opacity-0 transition-all duration-300 group-data-[theme=light]/html:translate-y-0 group-data-[theme=light]/html:opacity-100"></span>
<span class="icon-[heroicons--moon] absolute size-4.5 translate-y-4 opacity-0 transition-all duration-300 group-data-[theme=dark]/html:translate-y-0 group-data-[theme=dark]/html:opacity-100"></span>
<span class="icon-[heroicons--paint-brush] absolute size-4.5 opacity-100 group-data-[theme=dark]/html:opacity-0 group-data-[theme=light]/html:opacity-0"></span>
</button>
<!-- User Account Dropdown (Top Right, next to notifications) -->
<x-seller-topbar-account />
</div>

View File

@@ -7,12 +7,19 @@
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Favicon -->
<!-- Favicon & PWA -->
@if(\App\Models\SiteSetting::get('favicon_path'))
<link rel="icon" href="{{ \App\Models\SiteSetting::getFaviconUrl() }}" />
@else
<link rel="icon" href="/favicon.ico" />
@endif
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="theme-color" content="#5C0C36">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
@if(config('webpush.vapid.public_key'))
<meta name="vapid-public-key" content="{{ config('webpush.vapid.public_key') }}">
@endif
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])

View File

@@ -8,6 +8,12 @@
<title>@yield('title', config('app.name', 'Laravel'))</title>
<!-- Favicon & PWA -->
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="theme-color" content="#5C0C36">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])

View File

@@ -7,6 +7,13 @@
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Favicon & PWA -->
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<meta name="theme-color" content="#5C0C36">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>

View File

@@ -1,213 +1,215 @@
@extends('layouts.app-with-sidebar')
@section('content')
<div class="px-4 py-6 max-w-3xl mx-auto">
<div class="max-w-3xl mx-auto px-4 py-4 space-y-4">
{{-- Header --}}
<div class="flex items-center justify-between mb-6">
<div>
<p class="text-lg font-medium flex items-center gap-2">
<span class="icon-[heroicons--building-storefront] size-5"></span>
Add Customer
</p>
<p class="text-base-content/60 text-sm mt-1">
Create a new customer account
</p>
<header class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="{{ route('seller.business.crm.accounts.index', $business->slug) }}" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--arrow-left] size-5"></span>
</a>
<div>
<h1 class="text-2xl font-semibold">Add Customer</h1>
<p class="text-sm text-base-content/60">Create a new customer account</p>
</div>
</div>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a href="{{ route('seller.business.crm.accounts.index', $business->slug) }}">Customers</a></li>
<li class="opacity-80">Add</li>
</ul>
</div>
</div>
</header>
<form method="POST" action="{{ route('seller.business.crm.accounts.store', $business->slug) }}">
<form method="POST" action="{{ route('seller.business.crm.accounts.store', $business->slug) }}" class="space-y-4">
@csrf
{{-- Business Info --}}
<div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body">
<h3 class="font-semibold mb-4">Business Information</h3>
<section class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-4">
<h3 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">Business Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Business Name <span class="text-error">*</span></span>
</label>
<input type="text"
name="name"
value="{{ old('name') }}"
class="input input-bordered @error('name') input-error @enderror"
placeholder="Dispensary name"
required>
@error('name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-sm font-medium" for="name">
Business Name <span class="text-error">*</span>
</label>
<input type="text"
name="name"
id="name"
value="{{ old('name') }}"
class="input input-bordered input-sm w-full @error('name') input-error @enderror"
placeholder="Dispensary name"
required>
@error('name')
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text">DBA Name</span>
</label>
<input type="text"
name="dba_name"
value="{{ old('dba_name') }}"
class="input input-bordered"
placeholder="Doing business as...">
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="dba_name">
DBA Name <span class="text-base-content/40 font-normal text-xs">(optional)</span>
</label>
<input type="text"
name="dba_name"
id="dba_name"
value="{{ old('dba_name') }}"
class="input input-bordered input-sm w-full"
placeholder="Doing business as...">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">License Number</span>
</label>
<input type="text"
name="license_number"
value="{{ old('license_number') }}"
class="input input-bordered"
placeholder="State cannabis license #">
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="license_number">
License Number
</label>
<input type="text"
name="license_number"
id="license_number"
value="{{ old('license_number') }}"
class="input input-bordered input-sm w-full"
placeholder="State cannabis license #">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Business Email</span>
</label>
<input type="email"
name="business_email"
value="{{ old('business_email') }}"
class="input input-bordered @error('business_email') input-error @enderror">
@error('business_email')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="business_email">
Business Email
</label>
<input type="email"
name="business_email"
id="business_email"
value="{{ old('business_email') }}"
class="input input-bordered input-sm w-full @error('business_email') input-error @enderror">
@error('business_email')
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Business Phone</span>
</label>
<input type="tel"
name="business_phone"
value="{{ old('business_phone') }}"
class="input input-bordered">
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="business_phone">
Business Phone
</label>
<input type="tel"
name="business_phone"
id="business_phone"
value="{{ old('business_phone') }}"
class="input input-bordered input-sm w-full">
</div>
</div>
</div>
</section>
{{-- Location --}}
<div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body">
<h3 class="font-semibold mb-4">Location</h3>
<section class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-4">
<h3 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">Location</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Street Address</span>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1.5 md:col-span-2">
<label class="text-sm font-medium" for="physical_address">
Street Address
</label>
<input type="text"
name="physical_address"
id="physical_address"
value="{{ old('physical_address') }}"
class="input input-bordered input-sm w-full">
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="physical_city">
City
</label>
<input type="text"
name="physical_city"
id="physical_city"
value="{{ old('physical_city') }}"
class="input input-bordered input-sm w-full">
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-sm font-medium" for="physical_state">
State
</label>
<input type="text"
name="physical_address"
value="{{ old('physical_address') }}"
class="input input-bordered">
name="physical_state"
id="physical_state"
value="{{ old('physical_state') }}"
class="input input-bordered input-sm w-full"
maxlength="2"
placeholder="AZ">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">City</span>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="physical_zipcode">
ZIP Code
</label>
<input type="text"
name="physical_city"
value="{{ old('physical_city') }}"
class="input input-bordered">
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">State</span>
</label>
<input type="text"
name="physical_state"
value="{{ old('physical_state') }}"
class="input input-bordered"
maxlength="2"
placeholder="AZ">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">ZIP Code</span>
</label>
<input type="text"
name="physical_zipcode"
value="{{ old('physical_zipcode') }}"
class="input input-bordered">
</div>
name="physical_zipcode"
id="physical_zipcode"
value="{{ old('physical_zipcode') }}"
class="input input-bordered input-sm w-full">
</div>
</div>
</div>
</div>
</section>
{{-- Contact --}}
<div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body">
<h3 class="font-semibold mb-4">Contact</h3>
<p class="text-sm text-base-content/60 mb-4">Optional - add the main person you'll be working with</p>
<section class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-4">
<div>
<h3 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">Contact</h3>
<p class="text-xs text-base-content/50 mt-1">Optional - add the main person you'll be working with</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Contact Name</span>
</label>
<input type="text"
name="contact_name"
value="{{ old('contact_name') }}"
class="input input-bordered"
placeholder="Full name">
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-sm font-medium" for="contact_name">
Contact Name
</label>
<input type="text"
name="contact_name"
id="contact_name"
value="{{ old('contact_name') }}"
class="input input-bordered input-sm w-full"
placeholder="Full name">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Title</span>
</label>
<input type="text"
name="contact_title"
value="{{ old('contact_title') }}"
class="input input-bordered"
placeholder="e.g., Buyer, Owner, Manager">
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="contact_title">
Title
</label>
<input type="text"
name="contact_title"
id="contact_title"
value="{{ old('contact_title') }}"
class="input input-bordered input-sm w-full"
placeholder="e.g., Buyer, Owner, Manager">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Email</span>
</label>
<input type="email"
name="contact_email"
value="{{ old('contact_email') }}"
class="input input-bordered @error('contact_email') input-error @enderror">
@error('contact_email')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="contact_email">
Email
</label>
<input type="email"
name="contact_email"
id="contact_email"
value="{{ old('contact_email') }}"
class="input input-bordered input-sm w-full @error('contact_email') input-error @enderror">
@error('contact_email')
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Phone</span>
</label>
<input type="tel"
name="contact_phone"
value="{{ old('contact_phone') }}"
class="input input-bordered">
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="contact_phone">
Phone
</label>
<input type="tel"
name="contact_phone"
id="contact_phone"
value="{{ old('contact_phone') }}"
class="input input-bordered input-sm w-full">
</div>
</div>
</div>
</section>
{{-- Actions --}}
<div class="flex justify-end gap-3">
<a href="{{ route('seller.business.crm.accounts.index', $business->slug) }}" class="btn btn-ghost">
{{-- Form Actions --}}
<div class="flex items-center justify-end gap-2">
<a href="{{ route('seller.business.crm.accounts.index', $business->slug) }}" class="btn btn-ghost btn-sm">
Cancel
</a>
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary btn-sm gap-1">
<span class="icon-[heroicons--check] size-4"></span>
Create Customer
</button>

View File

@@ -162,7 +162,7 @@
{{-- Type --}}
<div class="form-control">
<label class="label"><span class="label-text font-medium">Type <span class="text-error">*</span></span></label>
<select x-model="eventForm.type" class="select select-bordered w-full" required>
<select x-ref="typeSelect" x-model="eventForm.type" class="select select-bordered w-full" required>
@foreach($eventTypes as $type => $label)
<option value="{{ $type }}">{{ $label }}</option>
@endforeach
@@ -469,6 +469,7 @@ function calendarApp() {
selectedEvent: null,
saving: false,
errorMessage: '',
typeChoices: null,
stats: {
todayEvents: 0,
weekEvents: 0,
@@ -608,15 +609,17 @@ function calendarApp() {
};
this.showModal = true;
this.$nextTick(() => this.initTypeChoices('meeting'));
},
openEditModal(event) {
this.editingEvent = event;
this.errorMessage = '';
const eventType = event.extendedProps.type || 'meeting';
this.eventForm = {
title: event.title,
type: event.extendedProps.type || 'meeting',
type: eventType,
start_at: this.formatDateTimeLocal(event.start),
end_at: event.end ? this.formatDateTimeLocal(event.end) : '',
all_day: event.allDay,
@@ -628,12 +631,75 @@ function calendarApp() {
};
this.showModal = true;
this.$nextTick(() => this.initTypeChoices(eventType));
},
closeModal() {
this.showModal = false;
this.editingEvent = null;
this.errorMessage = '';
this.destroyTypeChoices();
},
initTypeChoices(selectedValue) {
// Destroy existing instance if present
this.destroyTypeChoices();
const selectEl = this.$refs.typeSelect;
if (!selectEl || !window.Choices) return;
this.typeChoices = new Choices(selectEl, {
searchEnabled: true,
searchPlaceholderValue: 'Search type...',
itemSelectText: '',
shouldSort: false,
position: 'bottom',
classNames: {
containerOuter: 'choices',
containerInner: 'choices__inner !min-h-[2.5rem] !rounded-lg !border-base-300 !bg-base-100',
input: 'choices__input !bg-transparent',
inputCloned: 'choices__input--cloned',
list: 'choices__list',
listItems: 'choices__list--multiple',
listSingle: 'choices__list--single !p-0',
listDropdown: 'choices__list--dropdown !border-base-300 !bg-base-100 !rounded-lg !mt-1',
item: 'choices__item',
itemSelectable: 'choices__item--selectable',
itemDisabled: 'choices__item--disabled',
itemChoice: 'choices__item--choice !text-base-content hover:!bg-primary/10',
placeholder: 'choices__placeholder',
group: 'choices__group',
groupHeading: 'choices__heading',
button: 'choices__button',
activeState: 'is-active',
focusState: 'is-focused',
openState: 'is-open',
disabledState: 'is-disabled',
highlightedState: 'is-highlighted !bg-primary !text-primary-content',
selectedState: 'is-selected',
flippedState: 'is-flipped',
loadingState: 'is-loading',
noResults: 'has-no-results',
noChoices: 'has-no-choices'
}
});
// Set initial value
if (selectedValue) {
this.typeChoices.setChoiceByValue(selectedValue);
}
// Sync changes back to Alpine
selectEl.addEventListener('change', (e) => {
this.eventForm.type = e.target.value;
});
},
destroyTypeChoices() {
if (this.typeChoices) {
this.typeChoices.destroy();
this.typeChoices = null;
}
},
async saveEvent() {

View File

@@ -1,215 +1,213 @@
@extends('layouts.app-with-sidebar')
@section('content')
<div class="px-4 py-6 max-w-3xl mx-auto">
<div class="max-w-3xl mx-auto px-4 py-4 space-y-4">
{{-- Header --}}
<div class="flex items-center justify-between mb-6">
<div>
<p class="text-lg font-medium flex items-center gap-2">
<span class="icon-[heroicons--user-plus] size-5"></span>
Add Lead
</p>
<p class="text-base-content/60 text-sm mt-1">
Add a new prospect to track
</p>
<header class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="{{ route('seller.business.crm.leads.index', $business->slug) }}" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--arrow-left] size-5"></span>
</a>
<div>
<h1 class="text-2xl font-semibold">Add Lead</h1>
<p class="text-sm text-base-content/60">Add a new prospect to track</p>
</div>
</div>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a href="{{ route('seller.business.crm.leads.index', $business->slug) }}">Leads</a></li>
<li class="opacity-80">Add</li>
</ul>
</div>
</div>
</header>
<form method="POST" action="{{ route('seller.business.crm.leads.store', $business->slug) }}">
<form method="POST" action="{{ route('seller.business.crm.leads.store', $business->slug) }}" class="space-y-4">
@csrf
{{-- Company Info --}}
<div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body">
<h3 class="font-semibold mb-4">Company Information</h3>
<section class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-4">
<h3 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">Company Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Company Name <span class="text-error">*</span></span>
</label>
<input type="text"
name="company_name"
value="{{ old('company_name') }}"
class="input input-bordered @error('company_name') input-error @enderror"
required>
@error('company_name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-sm font-medium" for="company_name">
Company Name <span class="text-error">*</span>
</label>
<input type="text"
name="company_name"
id="company_name"
value="{{ old('company_name') }}"
class="input input-bordered input-sm w-full @error('company_name') input-error @enderror"
required>
@error('company_name')
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text">DBA Name</span>
</label>
<input type="text"
name="dba_name"
value="{{ old('dba_name') }}"
class="input input-bordered">
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="dba_name">
DBA Name <span class="text-base-content/40 font-normal text-xs">(optional)</span>
</label>
<input type="text"
name="dba_name"
id="dba_name"
value="{{ old('dba_name') }}"
class="input input-bordered input-sm w-full">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">License Number</span>
</label>
<input type="text"
name="license_number"
value="{{ old('license_number') }}"
class="input input-bordered"
placeholder="State cannabis license #">
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="license_number">
License Number
</label>
<input type="text"
name="license_number"
id="license_number"
value="{{ old('license_number') }}"
class="input input-bordered input-sm w-full"
placeholder="State cannabis license #">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Source</span>
</label>
<select name="source" class="select select-bordered">
<option value="">Select source...</option>
@foreach(\App\Models\Crm\CrmLead::SOURCES as $value => $label)
<option value="{{ $value }}" {{ old('source') === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="source">
Source
</label>
<select name="source" id="source" class="select select-bordered select-sm w-full">
<option value="">Select source...</option>
@foreach(\App\Models\Crm\CrmLead::SOURCES as $value => $label)
<option value="{{ $value }}" {{ old('source') === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
</div>
</div>
</section>
{{-- Contact Info --}}
<div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body">
<h3 class="font-semibold mb-4">Primary Contact</h3>
<section class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-4">
<h3 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">Primary Contact</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Contact Name <span class="text-error">*</span></span>
</label>
<input type="text"
name="contact_name"
value="{{ old('contact_name') }}"
class="input input-bordered @error('contact_name') input-error @enderror"
required>
@error('contact_name')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-sm font-medium" for="contact_name">
Contact Name <span class="text-error">*</span>
</label>
<input type="text"
name="contact_name"
id="contact_name"
value="{{ old('contact_name') }}"
class="input input-bordered input-sm w-full @error('contact_name') input-error @enderror"
required>
@error('contact_name')
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Title</span>
</label>
<input type="text"
name="contact_title"
value="{{ old('contact_title') }}"
class="input input-bordered"
placeholder="e.g., Buyer, Owner">
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="contact_title">
Title
</label>
<input type="text"
name="contact_title"
id="contact_title"
value="{{ old('contact_title') }}"
class="input input-bordered input-sm w-full"
placeholder="e.g., Buyer, Owner">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Email</span>
</label>
<input type="email"
name="contact_email"
value="{{ old('contact_email') }}"
class="input input-bordered @error('contact_email') input-error @enderror">
@error('contact_email')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="contact_email">
Email
</label>
<input type="email"
name="contact_email"
id="contact_email"
value="{{ old('contact_email') }}"
class="input input-bordered input-sm w-full @error('contact_email') input-error @enderror">
@error('contact_email')
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Phone</span>
</label>
<input type="tel"
name="contact_phone"
value="{{ old('contact_phone') }}"
class="input input-bordered">
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="contact_phone">
Phone
</label>
<input type="tel"
name="contact_phone"
id="contact_phone"
value="{{ old('contact_phone') }}"
class="input input-bordered input-sm w-full">
</div>
</div>
</div>
</section>
{{-- Location --}}
<div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body">
<h3 class="font-semibold mb-4">Location</h3>
<section class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-4">
<h3 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">Location</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Address</span>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1.5 md:col-span-2">
<label class="text-sm font-medium" for="address">
Address
</label>
<input type="text"
name="address"
id="address"
value="{{ old('address') }}"
class="input input-bordered input-sm w-full">
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="city">
City
</label>
<input type="text"
name="city"
id="city"
value="{{ old('city') }}"
class="input input-bordered input-sm w-full">
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-sm font-medium" for="state">
State
</label>
<input type="text"
name="address"
value="{{ old('address') }}"
class="input input-bordered">
name="state"
id="state"
value="{{ old('state') }}"
class="input input-bordered input-sm w-full"
maxlength="2"
placeholder="CA">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">City</span>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="zip_code">
ZIP
</label>
<input type="text"
name="city"
value="{{ old('city') }}"
class="input input-bordered">
</div>
<div class="grid grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">State</span>
</label>
<input type="text"
name="state"
value="{{ old('state') }}"
class="input input-bordered"
maxlength="2"
placeholder="CA">
</div>
<div class="form-control">
<label class="label">
<span class="label-text">ZIP</span>
</label>
<input type="text"
name="zip_code"
value="{{ old('zip_code') }}"
class="input input-bordered">
</div>
name="zip_code"
id="zip_code"
value="{{ old('zip_code') }}"
class="input input-bordered input-sm w-full">
</div>
</div>
</div>
</div>
</section>
{{-- Notes --}}
<div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body">
<h3 class="font-semibold mb-4">Notes</h3>
<section class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-4">
<h3 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">Notes</h3>
<div class="form-control">
<textarea name="notes"
class="textarea textarea-bordered h-32"
placeholder="Add any notes about this lead...">{{ old('notes') }}</textarea>
</div>
<div class="space-y-1.5">
<textarea name="notes"
id="notes"
rows="4"
class="textarea textarea-bordered w-full text-sm"
placeholder="Add any notes about this lead...">{{ old('notes') }}</textarea>
</div>
</div>
</section>
{{-- Actions --}}
<div class="flex justify-end gap-3">
<a href="{{ route('seller.business.crm.leads.index', $business->slug) }}" class="btn btn-ghost">
{{-- Form Actions --}}
<div class="flex items-center justify-end gap-2">
<a href="{{ route('seller.business.crm.leads.index', $business->slug) }}" class="btn btn-ghost btn-sm">
Cancel
</a>
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary btn-sm gap-1">
<span class="icon-[heroicons--check] size-4"></span>
Save Lead
</button>

View File

@@ -1,128 +1,148 @@
@extends('layouts.app-with-sidebar')
@section('content')
<div class="max-w-4xl mx-auto px-4 py-4">
{{-- Page Header --}}
<header class="mb-6 flex items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-semibold mb-0.5">Create Meeting Link</h1>
<p class="text-sm text-base-content/70">Set up a booking page that contacts can use to schedule meetings with you.</p>
<div class="max-w-3xl mx-auto px-4 py-4 space-y-4">
{{-- Header --}}
<header class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="{{ route('seller.business.crm.meetings.links.index', $business) }}" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--arrow-left] size-5"></span>
</a>
<div>
<h1 class="text-2xl font-semibold">Create Meeting Link</h1>
<p class="text-sm text-base-content/60">Set up a booking page for scheduling meetings</p>
</div>
</div>
<a href="{{ route('seller.business.crm.meetings.links.index', $business) }}" class="btn btn-ghost btn-sm gap-1">
<span class="icon-[heroicons--arrow-left] size-4"></span>
Back
</a>
</header>
<form method="POST" action="{{ route('seller.business.crm.meetings.links.store', $business) }}" x-data="meetingLinkForm()" class="space-y-4">
@csrf
{{-- Basic Information --}}
<section class="rounded-2xl border border-base-200 bg-base-100 shadow-sm">
<header class="px-4 py-3 border-b border-base-200 flex items-center gap-2">
<span class="icon-[heroicons--link] size-4 text-base-content/60"></span>
<h2 class="text-sm font-semibold">Basic Information</h2>
</header>
<section class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-4">
<h3 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">Basic Information</h3>
<div class="px-4 py-4 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-sm font-medium text-base-content/80">
Meeting Name <span class="text-error">*</span>
</label>
<input type="text" name="name" value="{{ old('name') }}" placeholder="e.g., 30 Minute Discovery Call" class="input input-bordered w-full @error('name') input-error @enderror" required>
@error('name')
<p class="text-xs text-error">{{ $message }}</p>
@enderror
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-base-content/80">
URL Slug <span class="text-error">*</span>
</label>
<div class="join w-full">
<span class="join-item bg-base-200 px-3 flex items-center text-sm text-base-content/60">/book/</span>
<input type="text" name="slug" value="{{ old('slug') }}" placeholder="discovery-call" class="input input-bordered join-item flex-1 @error('slug') input-error @enderror" required x-on:input="$el.value = $el.value.toLowerCase().replace(/[^a-z0-9-]/g, '-')">
</div>
<p class="text-xs text-base-content/60">Lowercase letters, numbers, and dashes only</p>
@error('slug')
<p class="text-xs text-error">{{ $message }}</p>
@enderror
</div>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-base-content/80">Description</label>
<textarea name="description" rows="2" placeholder="Brief description of what this meeting is for..." class="textarea textarea-bordered w-full @error('description') textarea-error @enderror">{{ old('description') }}</textarea>
@error('description')
<p class="text-xs text-error">{{ $message }}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-sm font-medium" for="name">
Meeting Name <span class="text-error">*</span>
</label>
<input type="text"
name="name"
id="name"
value="{{ old('name') }}"
placeholder="e.g., 30 Minute Discovery Call"
class="input input-bordered input-sm w-full @error('name') input-error @enderror"
required>
@error('name')
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-sm font-medium text-base-content/80">
Duration <span class="text-error">*</span>
</label>
<select name="duration_minutes" class="select select-bordered w-full @error('duration_minutes') select-error @enderror" required>
<option value="15" {{ old('duration_minutes') == '15' ? 'selected' : '' }}>15 minutes</option>
<option value="30" {{ old('duration_minutes', '30') == '30' ? 'selected' : '' }}>30 minutes</option>
<option value="45" {{ old('duration_minutes') == '45' ? 'selected' : '' }}>45 minutes</option>
<option value="60" {{ old('duration_minutes') == '60' ? 'selected' : '' }}>1 hour</option>
<option value="90" {{ old('duration_minutes') == '90' ? 'selected' : '' }}>1.5 hours</option>
<option value="120" {{ old('duration_minutes') == '120' ? 'selected' : '' }}>2 hours</option>
</select>
@error('duration_minutes')
<p class="text-xs text-error">{{ $message }}</p>
@enderror
<div class="space-y-1.5">
<label class="text-sm font-medium" for="slug">
URL Slug <span class="text-error">*</span>
</label>
<div class="join w-full">
<span class="join-item bg-base-200 px-3 flex items-center text-xs text-base-content/60">/book/</span>
<input type="text"
name="slug"
id="slug"
value="{{ old('slug') }}"
placeholder="discovery-call"
class="input input-bordered input-sm join-item flex-1 @error('slug') input-error @enderror"
required
x-on:input="$el.value = $el.value.toLowerCase().replace(/[^a-z0-9-]/g, '-')">
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-base-content/80">
Location Type <span class="text-error">*</span>
</label>
<select name="location_type" x-model="locationType" class="select select-bordered w-full @error('location_type') select-error @enderror" required>
<option value="video" {{ old('location_type', 'video') == 'video' ? 'selected' : '' }}>Video Call</option>
<option value="phone" {{ old('location_type') == 'phone' ? 'selected' : '' }}>Phone Call</option>
<option value="in_person" {{ old('location_type') == 'in_person' ? 'selected' : '' }}>In Person</option>
</select>
@error('location_type')
<p class="text-xs text-error">{{ $message }}</p>
@enderror
</div>
</div>
<div class="space-y-2" x-show="locationType !== 'phone'" x-cloak>
<label class="text-sm font-medium text-base-content/80">Location Details</label>
<input type="text" name="location_details" value="{{ old('location_details') }}" x-bind:placeholder="locationType === 'video' ? 'e.g., Zoom link will be sent in confirmation' : 'e.g., 123 Main St, Suite 400'" class="input input-bordered w-full">
@error('location_details')
<p class="text-xs text-error">{{ $message }}</p>
<p class="text-xs text-base-content/50">Lowercase letters, numbers, and dashes only</p>
@error('slug')
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="description">
Description
</label>
<textarea name="description"
id="description"
rows="2"
placeholder="Brief description of what this meeting is for..."
class="textarea textarea-bordered w-full text-sm @error('description') textarea-error @enderror">{{ old('description') }}</textarea>
@error('description')
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-sm font-medium" for="duration_minutes">
Duration <span class="text-error">*</span>
</label>
<select name="duration_minutes"
id="duration_minutes"
class="select select-bordered select-sm w-full @error('duration_minutes') select-error @enderror"
required>
<option value="15" {{ old('duration_minutes') == '15' ? 'selected' : '' }}>15 minutes</option>
<option value="30" {{ old('duration_minutes', '30') == '30' ? 'selected' : '' }}>30 minutes</option>
<option value="45" {{ old('duration_minutes') == '45' ? 'selected' : '' }}>45 minutes</option>
<option value="60" {{ old('duration_minutes') == '60' ? 'selected' : '' }}>1 hour</option>
<option value="90" {{ old('duration_minutes') == '90' ? 'selected' : '' }}>1.5 hours</option>
<option value="120" {{ old('duration_minutes') == '120' ? 'selected' : '' }}>2 hours</option>
</select>
@error('duration_minutes')
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="location_type">
Location Type <span class="text-error">*</span>
</label>
<select name="location_type"
id="location_type"
x-model="locationType"
class="select select-bordered select-sm w-full @error('location_type') select-error @enderror"
required>
<option value="video" {{ old('location_type', 'video') == 'video' ? 'selected' : '' }}>Video Call</option>
<option value="phone" {{ old('location_type') == 'phone' ? 'selected' : '' }}>Phone Call</option>
<option value="in_person" {{ old('location_type') == 'in_person' ? 'selected' : '' }}>In Person</option>
</select>
@error('location_type')
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
</div>
<div class="space-y-1.5" x-show="locationType !== 'phone'" x-cloak>
<label class="text-sm font-medium" for="location_details">
Location Details
</label>
<input type="text"
name="location_details"
id="location_details"
value="{{ old('location_details') }}"
x-bind:placeholder="locationType === 'video' ? 'e.g., Zoom link will be sent in confirmation' : 'e.g., 123 Main St, Suite 400'"
class="input input-bordered input-sm w-full">
@error('location_details')
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
</section>
{{-- Availability --}}
<section class="rounded-2xl border border-base-200 bg-base-100 shadow-sm">
<header class="px-4 py-3 border-b border-base-200 flex items-center gap-2">
<span class="icon-[heroicons--calendar] size-4 text-base-content/60"></span>
<h2 class="text-sm font-semibold">Weekly Availability</h2>
</header>
{{-- Weekly Availability --}}
<section class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-4">
<div>
<h3 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">Weekly Availability</h3>
<p class="text-xs text-base-content/50 mt-1">Set your available hours for each day</p>
</div>
<div class="px-4 py-4 space-y-3">
<p class="text-xs text-base-content/60 mb-4">Set your available hours for each day of the week. Leave unchecked to mark a day as unavailable.</p>
@php
$days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
$defaultAvailability = [
1 => ['start' => '09:00', 'end' => '17:00'], // Monday
2 => ['start' => '09:00', 'end' => '17:00'], // Tuesday
3 => ['start' => '09:00', 'end' => '17:00'], // Wednesday
4 => ['start' => '09:00', 'end' => '17:00'], // Thursday
5 => ['start' => '09:00', 'end' => '17:00'], // Friday
];
@endphp
@php
$days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
@endphp
<div class="space-y-2">
<template x-for="(day, index) in days" :key="index">
<div class="flex items-center gap-4 py-2 border-b border-base-200 last:border-0">
<div class="w-28">
@@ -134,9 +154,15 @@
<template x-if="availability[index].enabled">
<div class="flex items-center gap-2 flex-1">
<input type="hidden" x-bind:name="'availability[' + availabilityIndex(index) + '][day]'" x-bind:value="index">
<input type="time" x-bind:name="'availability[' + availabilityIndex(index) + '][start]'" x-model="availability[index].start" class="input input-sm input-bordered w-28">
<input type="time"
x-bind:name="'availability[' + availabilityIndex(index) + '][start]'"
x-model="availability[index].start"
class="input input-sm input-bordered w-28">
<span class="text-sm text-base-content/60">to</span>
<input type="time" x-bind:name="'availability[' + availabilityIndex(index) + '][end]'" x-model="availability[index].end" class="input input-sm input-bordered w-28">
<input type="time"
x-bind:name="'availability[' + availabilityIndex(index) + '][end]'"
x-model="availability[index].end"
class="input input-sm input-bordered w-28">
</div>
</template>
<template x-if="!availability[index].enabled">
@@ -148,71 +174,91 @@
</section>
{{-- Booking Settings --}}
<section class="rounded-2xl border border-base-200 bg-base-100 shadow-sm">
<header class="px-4 py-3 border-b border-base-200 flex items-center gap-2">
<span class="icon-[heroicons--cog-6-tooth] size-4 text-base-content/60"></span>
<h2 class="text-sm font-semibold">Booking Settings</h2>
</header>
<section class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-4">
<h3 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">Booking Settings</h3>
<div class="px-4 py-4 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-sm font-medium text-base-content/80">Buffer Before (minutes)</label>
<input type="number" name="buffer_before" value="{{ old('buffer_before', 0) }}" min="0" max="60" class="input input-bordered w-full">
<p class="text-xs text-base-content/60">Time blocked before each meeting</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-sm font-medium" for="buffer_before">
Buffer Before (minutes)
</label>
<input type="number"
name="buffer_before"
id="buffer_before"
value="{{ old('buffer_before', 0) }}"
min="0"
max="60"
class="input input-bordered input-sm w-full">
<p class="text-xs text-base-content/50">Time blocked before each meeting</p>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-base-content/80">Buffer After (minutes)</label>
<input type="number" name="buffer_after" value="{{ old('buffer_after', 0) }}" min="0" max="60" class="input input-bordered w-full">
<p class="text-xs text-base-content/60">Time blocked after each meeting</p>
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="buffer_after">
Buffer After (minutes)
</label>
<input type="number"
name="buffer_after"
id="buffer_after"
value="{{ old('buffer_after', 0) }}"
min="0"
max="60"
class="input input-bordered input-sm w-full">
<p class="text-xs text-base-content/50">Time blocked after each meeting</p>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-base-content/80">Minimum Notice (hours)</label>
<input type="number" name="min_notice_hours" value="{{ old('min_notice_hours', 24) }}" min="0" max="168" class="input input-bordered w-full">
<p class="text-xs text-base-content/60">How far in advance bookings must be made</p>
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="min_notice_hours">
Minimum Notice (hours)
</label>
<input type="number"
name="min_notice_hours"
id="min_notice_hours"
value="{{ old('min_notice_hours', 24) }}"
min="0"
max="168"
class="input input-bordered input-sm w-full">
<p class="text-xs text-base-content/50">How far in advance bookings must be made</p>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-base-content/80">Maximum Days Ahead</label>
<input type="number" name="max_days_ahead" value="{{ old('max_days_ahead', 30) }}" min="1" max="90" class="input input-bordered w-full">
<p class="text-xs text-base-content/60">How far into the future bookings can be made</p>
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="max_days_ahead">
Maximum Days Ahead
</label>
<input type="number"
name="max_days_ahead"
id="max_days_ahead"
value="{{ old('max_days_ahead', 30) }}"
min="1"
max="90"
class="input input-bordered input-sm w-full">
<p class="text-xs text-base-content/50">How far into the future bookings can be made</p>
</div>
</div>
</section>
{{-- Status --}}
<section class="rounded-2xl border border-base-200 bg-base-100 shadow-sm">
<header class="px-4 py-3 border-b border-base-200 flex items-center gap-2">
<span class="icon-[heroicons--signal] size-4 text-base-content/60"></span>
<h2 class="text-sm font-semibold">Status</h2>
</header>
<section class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-4">
<h3 class="text-sm font-semibold text-base-content/70 uppercase tracking-wider">Status</h3>
<div class="px-4 py-4">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="is_active" value="1" class="checkbox checkbox-primary" {{ old('is_active', true) ? 'checked' : '' }}>
<span class="label-text">
<span class="font-medium">Active</span>
<span class="block text-xs text-base-content/60">Link is available for bookings</span>
</span>
</label>
</div>
<label class="label cursor-pointer justify-start gap-3 p-0">
<input type="checkbox" name="is_active" value="1" class="checkbox checkbox-sm checkbox-primary" {{ old('is_active', true) ? 'checked' : '' }}>
<span class="label-text">
<span class="font-medium">Active</span>
<span class="block text-xs text-base-content/50">Link is available for bookings</span>
</span>
</label>
</section>
{{-- Action Footer --}}
<section class="rounded-2xl border border-base-200 bg-base-100 shadow-sm">
<div class="px-4 py-4 flex items-center justify-between">
<p class="text-xs text-base-content/60">
<span class="text-error">*</span> Required fields
</p>
<div class="flex items-center gap-3">
<a href="{{ route('seller.business.crm.meetings.links.index', $business) }}" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Create Meeting Link</button>
</div>
</div>
</section>
{{-- Form Actions --}}
<div class="flex items-center justify-end gap-2">
<a href="{{ route('seller.business.crm.meetings.links.index', $business) }}" class="btn btn-ghost btn-sm">
Cancel
</a>
<button type="submit" class="btn btn-primary btn-sm gap-1">
<span class="icon-[heroicons--check] size-4"></span>
Create Meeting Link
</button>
</div>
</form>
</div>

View File

@@ -3,29 +3,31 @@
@section('title', 'New Conversation - ' . $business->name)
@section('content')
<div class="max-w-3xl mx-auto px-4 py-6">
<div class="max-w-3xl mx-auto px-4 py-4 space-y-4">
{{-- Header --}}
<div class="flex items-center gap-4 mb-6">
<a href="{{ route('seller.business.crm.threads.index', $business) }}" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--arrow-left] size-5"></span>
</a>
<div>
<h1 class="text-2xl font-semibold">New Conversation</h1>
<p class="text-sm text-base-content/60">Start a new message thread with a contact</p>
<header class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="{{ route('seller.business.crm.threads.index', $business) }}" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--arrow-left] size-5"></span>
</a>
<div>
<h1 class="text-2xl font-semibold">New Conversation</h1>
<p class="text-sm text-base-content/60">Start a new message thread with a contact</p>
</div>
</div>
</div>
</header>
{{-- Form --}}
<form method="POST" action="{{ route('seller.business.crm.threads.store', $business) }}" enctype="multipart/form-data"
class="rounded-2xl border border-base-200 bg-base-100 shadow-sm">
<form method="POST" action="{{ route('seller.business.crm.threads.store', $business) }}" enctype="multipart/form-data" class="space-y-4">
@csrf
<div class="p-6 space-y-5">
{{-- Recipient & Channel --}}
<section class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-4">
{{-- Contact Selection --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">To <span class="text-error">*</span></span>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="contact_id">
To <span class="text-error">*</span>
</label>
<select name="contact_id" class="select select-bordered w-full" required>
<select name="contact_id" id="contact_id" class="select select-bordered select-sm w-full" required>
<option value="">Select a contact...</option>
@foreach($contacts as $contact)
<option value="{{ $contact->id }}"
@@ -39,22 +41,20 @@
@endforeach
</select>
@error('contact_id')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
<p class="text-error text-xs">{{ $message }}</p>
@enderror
@if($contacts->isEmpty())
<label class="label">
<span class="label-text-alt text-warning">
No contacts found.
<a href="{{ route('seller.business.contacts.create', $business) }}" class="link link-primary">Create a contact</a> first.
</span>
</label>
<p class="text-warning text-xs">
No contacts found.
<a href="{{ route('seller.business.contacts.create', $business) }}" class="link link-primary">Create a contact</a> first.
</p>
@endif
</div>
{{-- Channel Type --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Channel <span class="text-error">*</span></span>
<div class="space-y-1.5">
<label class="text-sm font-medium">
Channel <span class="text-error">*</span>
</label>
<div class="flex flex-wrap gap-2">
@php
@@ -64,65 +64,67 @@
];
@endphp
@foreach($availableChannels as $type => $config)
<label class="flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border border-base-300 has-[:checked]:border-primary has-[:checked]:bg-primary/5 transition-colors">
<label class="flex items-center gap-2 cursor-pointer px-3 py-1.5 rounded-lg border border-base-300 has-[:checked]:border-primary has-[:checked]:bg-primary/5 transition-colors text-sm">
<input type="radio" name="channel_type" value="{{ $type }}" class="radio radio-primary radio-sm"
{{ old('channel_type', 'email') === $type ? 'checked' : '' }} required>
<span class="icon-[{{ $config['icon'] }}] size-4"></span>
<span class="text-sm">{{ $config['label'] }}</span>
<span>{{ $config['label'] }}</span>
</label>
@endforeach
</div>
@error('channel_type')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
</section>
{{-- Message Content --}}
<section class="rounded-lg border border-base-300 bg-base-100 p-4 space-y-4">
{{-- Subject (for email) --}}
<div class="form-control" x-data x-show="document.querySelector('input[name=channel_type]:checked')?.value === 'email'" x-cloak>
<label class="label">
<span class="label-text font-medium">Subject</span>
<div class="space-y-1.5" x-data x-show="document.querySelector('input[name=channel_type]:checked')?.value === 'email'" x-cloak>
<label class="text-sm font-medium" for="subject">
Subject
</label>
<input type="text" name="subject" value="{{ old('subject') }}"
<input type="text" name="subject" id="subject" value="{{ old('subject') }}"
placeholder="Message subject..."
class="input input-bordered w-full">
class="input input-bordered input-sm w-full">
@error('subject')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
{{-- Message Body --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Message <span class="text-error">*</span></span>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="body">
Message <span class="text-error">*</span>
</label>
<textarea name="body" rows="6"
<textarea name="body" id="body" rows="6"
placeholder="Type your message..."
class="textarea textarea-bordered w-full" required>{{ old('body') }}</textarea>
class="textarea textarea-bordered w-full text-sm" required>{{ old('body') }}</textarea>
@error('body')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
{{-- Attachments --}}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Attachments</span>
<span class="label-text-alt">Optional, max 10MB each</span>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="attachments">
Attachments <span class="text-base-content/40 font-normal text-xs">(optional, max 10MB each)</span>
</label>
<input type="file" name="attachments[]" multiple
class="file-input file-input-bordered w-full">
<input type="file" name="attachments[]" id="attachments" multiple
class="file-input file-input-bordered file-input-sm w-full">
@error('attachments.*')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
<p class="text-error text-xs">{{ $message }}</p>
@enderror
</div>
</div>
</section>
{{-- Actions --}}
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-base-200 bg-base-50">
<a href="{{ route('seller.business.crm.threads.index', $business) }}" class="btn btn-ghost">
{{-- Form Actions --}}
<div class="flex items-center justify-end gap-2">
<a href="{{ route('seller.business.crm.threads.index', $business) }}" class="btn btn-ghost btn-sm">
Cancel
</a>
<button type="submit" class="btn btn-primary" {{ $contacts->isEmpty() ? 'disabled' : '' }}>
<button type="submit" class="btn btn-primary btn-sm gap-1" {{ $contacts->isEmpty() ? 'disabled' : '' }}>
<span class="icon-[heroicons--paper-airplane] size-4"></span>
Send Message
</button>
@@ -134,7 +136,7 @@
// Show/hide subject field based on channel type
document.addEventListener('DOMContentLoaded', function() {
const channelInputs = document.querySelectorAll('input[name="channel_type"]');
const subjectField = document.querySelector('input[name="subject"]')?.closest('.form-control');
const subjectField = document.querySelector('input[name="subject"]')?.closest('.space-y-1\\.5');
function updateSubjectVisibility() {
const selected = document.querySelector('input[name="channel_type"]:checked');

View File

@@ -1,27 +1,18 @@
@extends('layouts.app-with-sidebar')
@section('title', 'Command Center - ' . $business->name)
@section('title', 'Overview - ' . $business->name)
@section('content')
<div class="cb-page">
<div class="cb-stack">
{{-- Header Row --}}
{{-- Header --}}
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-base-content">Revenue Command Center</h1>
<div class="flex items-center gap-2 mt-0.5">
<p class="text-xs text-base-content/60">Sales performance & follow-ups</p>
{{-- Scope indicator --}}
@if($commandCenter->currentScope !== 'business')
<span class="badge badge-xs badge-secondary">{{ $commandCenter->scopeLabel }}</span>
@else
<span class="badge badge-xs badge-ghost">All brands</span>
@endif
</div>
<h1 class="text-xl font-semibold text-base-content">Overview</h1>
<p class="text-xs text-base-content/50 mt-0.5">{{ now()->format('l, F j') }}</p>
</div>
<div class="flex items-center gap-2">
{{-- Manager-only Team Dashboard link --}}
@can('manage-team', $business)
<a href="{{ route('seller.business.crm.dashboard.team', $business->slug) }}" class="btn btn-ghost btn-sm gap-1">
<span class="icon-[heroicons--user-group] size-4"></span>
@@ -37,321 +28,264 @@
</div>
</div>
{{-- KPI Strip (8 cards) --}}
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-8 gap-3">
<x-dashboard.stat-card
label="Revenue MTD"
:value="$commandCenter->kpis['revenue_mtd']['value'] ?? 0"
:change="$commandCenter->kpis['revenue_mtd']['change'] ?? null"
format="currency"
:scope="$commandCenter->kpis['revenue_mtd']['scope'] ?? 'business'"
size="compact"
/>
<x-dashboard.stat-card
label="Orders MTD"
:value="$commandCenter->kpis['orders_mtd']['value'] ?? 0"
:change="$commandCenter->kpis['orders_mtd']['change'] ?? null"
format="number"
:scope="$commandCenter->kpis['orders_mtd']['scope'] ?? 'business'"
size="compact"
/>
<x-dashboard.stat-card
label="Pipeline"
:value="$commandCenter->kpis['pipeline_value']['value'] ?? 0"
format="currency"
:scope="$commandCenter->kpis['pipeline_value']['scope'] ?? 'business'"
:sublabel="isset($commandCenter->kpis['pipeline_value']['weighted']) ? 'Wtd: $' . number_format($commandCenter->kpis['pipeline_value']['weighted'], 0) : null"
size="compact"
/>
<x-dashboard.stat-card
label="Won MTD"
:value="$commandCenter->kpis['won_mtd']['value'] ?? 0"
:change="$commandCenter->kpis['won_mtd']['change'] ?? null"
format="currency"
:scope="$commandCenter->kpis['won_mtd']['scope'] ?? 'business'"
size="compact"
/>
<x-dashboard.stat-card
label="Active Buyers"
:value="$commandCenter->kpis['active_buyers']['value'] ?? 0"
format="number"
:scope="$commandCenter->kpis['active_buyers']['scope'] ?? 'business'"
sublabel="90 day window"
size="compact"
/>
<x-dashboard.stat-card
label="Hot Accounts"
:value="$commandCenter->kpis['hot_accounts']['value'] ?? 0"
format="number"
:scope="$commandCenter->kpis['hot_accounts']['scope'] ?? 'business'"
size="compact"
/>
<x-dashboard.stat-card
label="My Tasks"
:value="$commandCenter->kpis['open_tasks']['value'] ?? 0"
format="number"
scope="user"
:sublabel="($commandCenter->kpis['open_tasks']['overdue'] ?? 0) > 0 ? ($commandCenter->kpis['open_tasks']['overdue'] . ' overdue') : null"
size="compact"
/>
<x-dashboard.stat-card
label="SLA"
:value="$commandCenter->kpis['sla_compliance']['value'] ?? 100"
format="percent"
:scope="$commandCenter->kpis['sla_compliance']['scope'] ?? 'business'"
size="compact"
/>
{{-- ========================================= --}}
{{-- NEEDS ATTENTION --}}
{{-- ========================================= --}}
@php
$overdueCount = count($commandCenter->salesInbox['overdue'] ?? []);
$overdueTaskCount = $commandCenter->kpis['open_tasks']['overdue'] ?? 0;
$targetCount = ($commandCenter->orchestratorWidget['enabled'] ?? false) ? count($commandCenter->orchestratorWidget['targets'] ?? []) : 0;
$messageCount = count($commandCenter->salesInbox['messages'] ?? []);
$hasAttention = $overdueCount > 0 || $overdueTaskCount > 0 || $targetCount > 0 || $messageCount > 0;
@endphp
<div class="space-y-3">
<h2 class="text-xs font-medium text-base-content/50 uppercase tracking-wide">Needs Attention</h2>
@if($hasAttention)
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
{{-- Inbox --}}
@if($overdueCount > 0)
<a href="{{ route('seller.business.crm.tasks.index', $business->slug) }}" class="flex items-center gap-3 p-3 bg-base-100 border border-base-200 rounded-lg hover:border-warning/50 transition-colors">
<div class="w-9 h-9 rounded-full bg-warning/10 flex items-center justify-center shrink-0">
<span class="icon-[heroicons--inbox] size-5 text-warning"></span>
</div>
<div>
<p class="text-lg font-semibold text-base-content">{{ $overdueCount }}</p>
<p class="text-xs text-base-content/50">overdue items</p>
</div>
</a>
@endif
{{-- Tasks --}}
@if($overdueTaskCount > 0)
<a href="{{ route('seller.business.crm.tasks.index', $business->slug) }}" class="flex items-center gap-3 p-3 bg-base-100 border border-base-200 rounded-lg hover:border-error/50 transition-colors">
<div class="w-9 h-9 rounded-full bg-error/10 flex items-center justify-center shrink-0">
<span class="icon-[heroicons--clipboard-document-list] size-5 text-error"></span>
</div>
<div>
<p class="text-lg font-semibold text-base-content">{{ $overdueTaskCount }}</p>
<p class="text-xs text-base-content/50">tasks overdue</p>
</div>
</a>
@endif
{{-- Suggestions --}}
@if($targetCount > 0)
<a href="{{ route('seller.business.orchestrator.index', $business->slug) }}" class="flex items-center gap-3 p-3 bg-base-100 border border-base-200 rounded-lg hover:border-accent/50 transition-colors">
<div class="w-9 h-9 rounded-full bg-accent/10 flex items-center justify-center shrink-0">
<span class="icon-[heroicons--bolt] size-5 text-accent"></span>
</div>
<div>
<p class="text-lg font-semibold text-base-content">{{ $targetCount }}</p>
<p class="text-xs text-base-content/50">suggestions</p>
</div>
</a>
@endif
{{-- Messages --}}
@if($messageCount > 0)
<a href="{{ route('seller.business.crm.threads.index', $business->slug) }}" class="flex items-center gap-3 p-3 bg-base-100 border border-base-200 rounded-lg hover:border-primary/50 transition-colors">
<div class="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<span class="icon-[heroicons--chat-bubble-left-ellipsis] size-5 text-primary"></span>
</div>
<div>
<p class="text-lg font-semibold text-base-content">{{ $messageCount }}</p>
<p class="text-xs text-base-content/50">unread messages</p>
</div>
</a>
@endif
</div>
@else
<div class="flex items-center gap-2 py-3 px-4 bg-success/5 border border-success/20 rounded-lg">
<span class="icon-[heroicons--check-circle] size-5 text-success"></span>
<span class="text-sm text-base-content/70">All caught up</span>
</div>
@endif
</div>
{{-- Main Workspace: 3-column layout --}}
<div class="grid grid-cols-1 xl:grid-cols-12 gap-4">
{{-- LEFT COLUMN: Main Panel (8 cols) --}}
<div class="xl:col-span-8 space-y-4">
{{-- Pipeline Snapshot --}}
@if(!empty($commandCenter->pipelineSnapshot))
<x-dashboard.panel title="Pipeline Snapshot" icon="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">
<x-slot name="actions">
<a href="{{ route('seller.business.crm.deals.index', $business->slug) }}" class="text-xs text-primary hover:underline">View all deals</a>
</x-slot>
<div class="flex gap-2 overflow-x-auto pb-2">
@foreach($commandCenter->pipelineSnapshot as $stage)
<div class="flex-shrink-0 w-40 bg-base-200/40 rounded-lg p-3">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-base-content/70 truncate">{{ $stage['name'] }}</span>
<span class="badge badge-ghost badge-xs">{{ $stage['count'] }}</span>
</div>
<p class="text-lg font-semibold text-base-content">${{ number_format($stage['value'], 0) }}</p>
@if(!empty($stage['deals']))
<div class="mt-2 space-y-1">
@foreach(array_slice($stage['deals'], 0, 2) as $deal)
<a href="{{ route('seller.business.crm.deals.show', [$business->slug, $deal['hashid']]) }}"
class="block text-xs text-base-content/60 hover:text-primary truncate">
{{ $deal['name'] }}
</a>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
</x-dashboard.panel>
@endif
{{-- Recent Orders Table --}}
<x-dashboard.preview-table
title="Recent Orders"
:columns="[
'order_number' => ['label' => 'Order', 'format' => 'link', 'hrefKey' => 'href'],
'business_name' => ['label' => 'Buyer'],
'total' => ['label' => 'Total', 'format' => 'currency', 'class' => 'text-right', 'cellClass' => 'text-right'],
'status' => ['label' => 'Status', 'format' => 'status'],
'created_at' => ['label' => 'Date', 'format' => 'datetime'],
]"
:rows="collect($commandCenter->ordersTable)->map(fn($o) => array_merge($o, ['href' => route('seller.business.orders.show', [$business->slug, $o['order_number']])]))->toArray()"
emptyMessage="No recent orders"
:href="route('seller.business.orders.index', $business->slug)"
hrefLabel="View all orders"
/>
{{-- Buyer Intelligence Cards --}}
@if(!empty($commandCenter->intelligenceCards['distribution']) || !empty($commandCenter->intelligenceCards['at_risk']))
<x-dashboard.panel title="Buyer Intelligence" icon="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z">
<x-slot name="actions">
<a href="{{ route('seller.business.buyer-intelligence.index', $business->slug) }}" class="text-xs text-primary hover:underline">View all</a>
</x-slot>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{-- Engagement Distribution --}}
<div class="bg-base-200/30 rounded-lg p-4">
<h4 class="text-xs font-medium text-base-content/70 uppercase tracking-wide mb-3">Engagement Distribution</h4>
<div class="flex items-end gap-3 h-24">
@php
$dist = $commandCenter->intelligenceCards['distribution'] ?? [];
$maxCount = max($dist['hot'] ?? 1, $dist['warm'] ?? 1, $dist['cold'] ?? 1, 1);
@endphp
<div class="flex-1 flex flex-col items-center">
<div class="w-full bg-success/20 rounded-t" style="height: {{ max(10, (($dist['hot'] ?? 0) / $maxCount) * 100) }}%"></div>
<span class="text-lg font-semibold text-success mt-1">{{ $dist['hot'] ?? 0 }}</span>
<span class="text-xs text-base-content/50">Hot</span>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="w-full bg-warning/20 rounded-t" style="height: {{ max(10, (($dist['warm'] ?? 0) / $maxCount) * 100) }}%"></div>
<span class="text-lg font-semibold text-warning mt-1">{{ $dist['warm'] ?? 0 }}</span>
<span class="text-xs text-base-content/50">Warm</span>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="w-full bg-error/20 rounded-t" style="height: {{ max(10, (($dist['cold'] ?? 0) / $maxCount) * 100) }}%"></div>
<span class="text-lg font-semibold text-error mt-1">{{ $dist['cold'] ?? 0 }}</span>
<span class="text-xs text-base-content/50">Cold</span>
</div>
</div>
</div>
{{-- At-Risk Accounts --}}
<div class="bg-base-200/30 rounded-lg p-4">
<h4 class="text-xs font-medium text-base-content/70 uppercase tracking-wide mb-3">At-Risk Accounts</h4>
@if(!empty($commandCenter->intelligenceCards['at_risk']))
<ul class="space-y-2">
@foreach(array_slice($commandCenter->intelligenceCards['at_risk'], 0, 3) as $account)
<li class="flex items-center justify-between">
<span class="text-sm text-base-content truncate">{{ $account['buyer_name'] }}</span>
<span class="badge badge-error badge-xs">{{ $account['days_inactive'] }}d inactive</span>
</li>
@endforeach
</ul>
@else
<p class="text-sm text-base-content/50">No at-risk accounts</p>
@endif
</div>
</div>
</x-dashboard.panel>
@endif
{{-- KPI Context Row --}}
<div class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-8 gap-2">
<div class="bg-base-100 border border-base-200/50 rounded p-2">
<p class="text-[10px] text-base-content/40 uppercase">Revenue MTD</p>
<p class="text-sm font-semibold tabular-nums">${{ number_format($commandCenter->kpis['revenue_mtd']['value'] ?? 0, 0) }}</p>
</div>
<div class="bg-base-100 border border-base-200/50 rounded p-2">
<p class="text-[10px] text-base-content/40 uppercase">Orders MTD</p>
<p class="text-sm font-semibold tabular-nums">{{ number_format($commandCenter->kpis['orders_mtd']['value'] ?? 0) }}</p>
</div>
<div class="bg-base-100 border border-base-200/50 rounded p-2">
<p class="text-[10px] text-base-content/40 uppercase">Pipeline</p>
<p class="text-sm font-semibold tabular-nums">${{ number_format($commandCenter->kpis['pipeline_value']['value'] ?? 0, 0) }}</p>
</div>
<div class="bg-base-100 border border-base-200/50 rounded p-2">
<p class="text-[10px] text-base-content/40 uppercase">Won MTD</p>
<p class="text-sm font-semibold tabular-nums">${{ number_format($commandCenter->kpis['won_mtd']['value'] ?? 0, 0) }}</p>
</div>
<div class="bg-base-100 border border-base-200/50 rounded p-2">
<p class="text-[10px] text-base-content/40 uppercase">Active Buyers</p>
<p class="text-sm font-semibold tabular-nums">{{ number_format($commandCenter->kpis['active_buyers']['value'] ?? 0) }}</p>
</div>
<div class="bg-base-100 border border-base-200/50 rounded p-2">
<p class="text-[10px] text-base-content/40 uppercase">Hot Accounts</p>
<p class="text-sm font-semibold tabular-nums">{{ number_format($commandCenter->kpis['hot_accounts']['value'] ?? 0) }}</p>
</div>
<div class="bg-base-100 border border-base-200/50 rounded p-2">
<p class="text-[10px] text-base-content/40 uppercase">Open Tasks</p>
<p class="text-sm font-semibold tabular-nums">{{ number_format($commandCenter->kpis['open_tasks']['value'] ?? 0) }}</p>
</div>
<div class="bg-base-100 border border-base-200/50 rounded p-2">
<p class="text-[10px] text-base-content/40 uppercase">SLA</p>
<p class="text-sm font-semibold tabular-nums">{{ number_format($commandCenter->kpis['sla_compliance']['value'] ?? 100, 0) }}%</p>
</div>
</div>
{{-- RIGHT RAIL (4 cols) - Sticky on desktop --}}
<div class="xl:col-span-4 space-y-4 xl:sticky xl:top-4 xl:self-start xl:max-h-[calc(100vh-6rem)] xl:overflow-y-auto">
{{-- ========================================= --}}
{{-- WHAT'S HAPPENING --}}
{{-- ========================================= --}}
<div class="space-y-4">
<h2 class="text-xs font-medium text-base-content/50 uppercase tracking-wide">What's Happening</h2>
{{-- Sales Inbox --}}
<x-dashboard.rail-card
title="Sales Inbox"
icon="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
:badge="count($commandCenter->salesInbox['overdue'] ?? [])"
:badgeClass="count($commandCenter->salesInbox['overdue'] ?? []) > 0 ? 'badge-warning' : 'badge-success'"
:href="route('seller.business.crm.tasks.index', $business->slug)"
>
@if(!empty($commandCenter->salesInbox['overdue']))
<div class="space-y-1.5">
@foreach(array_slice($commandCenter->salesInbox['overdue'], 0, 5) as $item)
<div class="flex items-start gap-2 p-2 rounded-lg hover:bg-base-200/50 transition-colors">
<span class="mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full shrink-0
{{ $item['type'] === 'invoice' ? 'bg-error/10 text-error' : ($item['type'] === 'task' ? 'bg-warning/10 text-warning' : 'bg-info/10 text-info') }}">
@if($item['type'] === 'invoice')
<span class="icon-[heroicons--document-text] size-3.5"></span>
@elseif($item['type'] === 'task')
<span class="icon-[heroicons--clipboard-document-check] size-3.5"></span>
@else
<span class="icon-[heroicons--currency-dollar] size-3.5"></span>
@endif
</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content truncate">{{ $item['label'] }}</p>
<p class="text-xs text-base-content/50">
<span class="text-error">{{ abs($item['age']) }}d overdue</span>
@if($item['context']) &middot; {{ $item['context'] }} @endif
</p>
</div>
</div>
{{-- Pipeline --}}
@if(!empty($commandCenter->pipelineSnapshot))
<x-dashboard.panel title="Pipeline" icon="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">
<x-slot name="actions">
<a href="{{ route('seller.business.crm.deals.index', $business->slug) }}" class="text-xs text-primary hover:underline">View all</a>
</x-slot>
<div class="flex gap-2 overflow-x-auto pb-2">
@foreach($commandCenter->pipelineSnapshot as $stage)
<div class="flex-shrink-0 w-36 bg-base-200/40 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-base-content/70 truncate">{{ $stage['name'] }}</span>
<span class="text-xs text-base-content/40">{{ $stage['count'] }}</span>
</div>
<p class="text-base font-semibold text-base-content">${{ number_format($stage['value'], 0) }}</p>
@if(!empty($stage['deals']))
<div class="mt-2 space-y-0.5">
@foreach(array_slice($stage['deals'], 0, 2) as $deal)
<a href="{{ route('seller.business.crm.deals.show', [$business->slug, $deal['hashid']]) }}"
class="block text-xs text-base-content/50 hover:text-primary truncate">
{{ $deal['name'] }}
</a>
@endforeach
</div>
@else
<div class="flex flex-col items-center py-6 text-center">
<div class="w-10 h-10 rounded-full bg-success/15 flex items-center justify-center mb-2">
<span class="icon-[heroicons--check] size-5 text-success"></span>
</div>
<p class="text-sm text-base-content/70">All caught up</p>
</div>
@endif
</x-dashboard.rail-card>
{{-- Orchestrator Widget --}}
@if($commandCenter->orchestratorWidget['enabled'] ?? false)
<x-dashboard.rail-card
title="Orchestrator"
icon="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
:badge="count($commandCenter->orchestratorWidget['targets'] ?? [])"
badgeClass="badge-accent"
:href="route('seller.business.orchestrator.index', $business->slug)"
>
@if(!empty($commandCenter->orchestratorWidget['targets']))
<div class="space-y-1.5">
@foreach(array_slice($commandCenter->orchestratorWidget['targets'], 0, 3) as $target)
<div class="p-2 rounded-lg bg-base-200/30">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-base-content truncate">{{ $target['buyer_name'] }}</span>
<span class="badge badge-xs {{ $target['engagement_level'] === 'hot' ? 'badge-success' : ($target['engagement_level'] === 'warm' ? 'badge-warning' : 'badge-ghost') }}">
{{ ucfirst($target['engagement_level']) }}
</span>
</div>
<p class="text-xs text-base-content/60 truncate">{{ $target['suggested_action'] }}</p>
</div>
@endforeach
</div>
@if(!empty($commandCenter->orchestratorWidget['promo_opportunities']['total_pending_count']))
<div class="mt-3 p-2 rounded-lg bg-accent/10 border border-accent/20">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-accent">Promo Opportunities</span>
<span class="badge badge-accent badge-xs">{{ $commandCenter->orchestratorWidget['promo_opportunities']['total_pending_count'] }}</span>
</div>
</div>
@endif
@else
<div class="flex flex-col items-center py-6 text-center">
<div class="w-10 h-10 rounded-full bg-base-200 flex items-center justify-center mb-2">
<span class="icon-[heroicons--bolt] size-5 text-base-content/30"></span>
</div>
<p class="text-sm text-base-content/70">No targets today</p>
</div>
@endif
</x-dashboard.rail-card>
@endif
{{-- Activity Feed --}}
<x-dashboard.rail-card
title="Activity"
icon="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
collapsible
:collapsed="count($commandCenter->activityFeed ?? []) === 0"
>
@if(!empty($commandCenter->activityFeed))
<div class="space-y-2">
@foreach(array_slice($commandCenter->activityFeed, 0, 10) as $activity)
<div class="flex items-start gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-base-content/30 mt-2 shrink-0"></span>
<div class="flex-1 min-w-0">
<p class="text-xs text-base-content/70 leading-relaxed">{{ $activity['description'] }}</p>
<p class="text-xs text-base-content/40">
{{ $activity['causer_name'] }} &middot;
{{ \Carbon\Carbon::parse($activity['created_at'])->diffForHumans() }}
</p>
</div>
</div>
@endforeach
</div>
@else
<p class="text-sm text-base-content/50 text-center py-4">No recent activity</p>
@endif
</x-dashboard.rail-card>
{{-- Unread Messages --}}
@if(!empty($commandCenter->salesInbox['messages']))
<x-dashboard.rail-card
title="Messages"
icon="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
:badge="count($commandCenter->salesInbox['messages'])"
badgeClass="badge-primary"
:href="route('seller.business.crm.threads.index', $business->slug)"
>
<div class="space-y-1.5">
@foreach(array_slice($commandCenter->salesInbox['messages'], 0, 3) as $message)
<a href="{{ route('seller.business.crm.threads.show', [$business->slug, $message['hashid']]) }}"
class="block p-2 rounded-lg hover:bg-base-200/50 transition-colors">
<p class="text-sm font-medium text-base-content truncate">{{ $message['contact_name'] }}</p>
<p class="text-xs text-base-content/50 truncate">{{ $message['preview'] }}</p>
<p class="text-xs text-base-content/40 mt-0.5">{{ $message['time'] }}</p>
</a>
@endforeach
</div>
</x-dashboard.rail-card>
@endforeach
</div>
</x-dashboard.panel>
@endif
{{-- Recent Orders --}}
<x-dashboard.preview-table
title="Recent Orders"
:columns="[
'order_number' => ['label' => 'Order', 'format' => 'link', 'hrefKey' => 'href'],
'business_name' => ['label' => 'Buyer'],
'total' => ['label' => 'Total', 'format' => 'currency', 'class' => 'text-right', 'cellClass' => 'text-right'],
'status' => ['label' => 'Status', 'format' => 'status'],
'created_at' => ['label' => 'Date', 'format' => 'datetime'],
]"
:rows="collect($commandCenter->ordersTable)->map(fn($o) => array_merge($o, ['href' => route('seller.business.orders.show', [$business->slug, $o['order_number']])]))->toArray()"
emptyMessage="No recent orders"
:href="route('seller.business.orders.index', $business->slug)"
hrefLabel="View all"
/>
</div>
{{-- ========================================= --}}
{{-- BRAND PERFORMANCE --}}
{{-- ========================================= --}}
@if(!empty($commandCenter->intelligenceCards['distribution']) || !empty($commandCenter->intelligenceCards['at_risk']))
<div class="space-y-4">
<h2 class="text-xs font-medium text-base-content/50 uppercase tracking-wide">Brand Performance</h2>
<x-dashboard.panel title="Buyer Intelligence" icon="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z">
<x-slot name="actions">
<a href="{{ route('seller.business.buyer-intelligence.index', $business->slug) }}" class="text-xs text-primary hover:underline">View all</a>
</x-slot>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{-- Engagement Distribution --}}
<div class="bg-base-200/30 rounded-lg p-4">
<h4 class="text-xs font-medium text-base-content/60 uppercase tracking-wide mb-3">Engagement</h4>
<div class="flex items-end gap-3 h-20">
@php
$dist = $commandCenter->intelligenceCards['distribution'] ?? [];
$maxCount = max($dist['hot'] ?? 1, $dist['warm'] ?? 1, $dist['cold'] ?? 1, 1);
@endphp
<div class="flex-1 flex flex-col items-center">
<div class="w-full bg-success/20 rounded-t" style="height: {{ max(10, (($dist['hot'] ?? 0) / $maxCount) * 100) }}%"></div>
<span class="text-sm font-semibold text-success mt-1">{{ $dist['hot'] ?? 0 }}</span>
<span class="text-xs text-base-content/40">Hot</span>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="w-full bg-warning/20 rounded-t" style="height: {{ max(10, (($dist['warm'] ?? 0) / $maxCount) * 100) }}%"></div>
<span class="text-sm font-semibold text-warning mt-1">{{ $dist['warm'] ?? 0 }}</span>
<span class="text-xs text-base-content/40">Warm</span>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="w-full bg-error/20 rounded-t" style="height: {{ max(10, (($dist['cold'] ?? 0) / $maxCount) * 100) }}%"></div>
<span class="text-sm font-semibold text-error mt-1">{{ $dist['cold'] ?? 0 }}</span>
<span class="text-xs text-base-content/40">Cold</span>
</div>
</div>
</div>
{{-- At-Risk / Inactive --}}
<div class="bg-base-200/30 rounded-lg p-4">
<h4 class="text-xs font-medium text-base-content/60 uppercase tracking-wide mb-3">Inactive Accounts</h4>
@if(!empty($commandCenter->intelligenceCards['at_risk']))
<ul class="space-y-1.5">
@foreach(array_slice($commandCenter->intelligenceCards['at_risk'], 0, 4) as $account)
<li class="flex items-center justify-between text-sm">
<span class="text-base-content truncate">{{ $account['buyer_name'] }}</span>
<span class="text-base-content/40 text-xs">{{ $account['days_inactive'] }}d</span>
</li>
@endforeach
</ul>
@else
<p class="text-sm text-base-content/40">None</p>
@endif
</div>
</div>
</x-dashboard.panel>
</div>
@endif
{{-- ========================================= --}}
{{-- DON'T FORGET --}}
{{-- ========================================= --}}
@if(!empty($commandCenter->activityFeed) || !empty($commandCenter->orchestratorWidget['promo_opportunities']['total_pending_count']))
<div class="space-y-3">
<h2 class="text-xs font-medium text-base-content/50 uppercase tracking-wide">Don't Forget</h2>
<div class="bg-base-100 border border-base-200/50 rounded-lg p-4">
@if(!empty($commandCenter->orchestratorWidget['promo_opportunities']['total_pending_count']))
<div class="flex items-center justify-between pb-3 mb-3 border-b border-base-200">
<div class="flex items-center gap-2">
<span class="icon-[heroicons--tag] size-4 text-accent"></span>
<span class="text-sm text-base-content">{{ $commandCenter->orchestratorWidget['promo_opportunities']['total_pending_count'] }} promo opportunities pending</span>
</div>
<a href="{{ route('seller.business.orchestrator.index', $business->slug) }}" class="text-xs text-primary hover:underline">Review</a>
</div>
@endif
@if(!empty($commandCenter->activityFeed))
<div class="space-y-2">
@foreach(array_slice($commandCenter->activityFeed, 0, 5) as $activity)
<div class="flex items-start gap-2 text-xs">
<span class="w-1 h-1 rounded-full bg-base-content/20 mt-1.5 shrink-0"></span>
<span class="text-base-content/60 flex-1">{{ $activity['description'] }}</span>
<span class="text-base-content/30 shrink-0">{{ \Carbon\Carbon::parse($activity['created_at'])->diffForHumans(short: true) }}</span>
</div>
@endforeach
</div>
@endif
</div>
</div>
@endif
</div>
</div>

View File

@@ -19,6 +19,33 @@
</a>
</div>
{{-- Filters --}}
<form method="GET" action="{{ route('seller.business.promotions.index', $business->slug) }}" class="flex flex-wrap items-center gap-3 mb-6">
<select name="brand" class="select select-bordered select-sm w-48" onchange="this.form.submit()">
<option value="">All Brands</option>
@foreach($brands ?? [] as $brand)
<option value="{{ $brand->id }}" {{ request('brand') == $brand->id ? 'selected' : '' }}>
{{ $brand->name }}
</option>
@endforeach
</select>
<select name="status" class="select select-bordered select-sm w-36" onchange="this.form.submit()">
<option value="">All Statuses</option>
<option value="active" {{ request('status') === 'active' ? 'selected' : '' }}>Active</option>
<option value="scheduled" {{ request('status') === 'scheduled' ? 'selected' : '' }}>Scheduled</option>
<option value="expired" {{ request('status') === 'expired' ? 'selected' : '' }}>Expired</option>
<option value="draft" {{ request('status') === 'draft' ? 'selected' : '' }}>Draft</option>
</select>
@if(request('brand') || request('status'))
<a href="{{ route('seller.business.promotions.index', $business->slug) }}" class="btn btn-ghost btn-sm gap-1">
<span class="icon-[heroicons--x-mark] size-4"></span>
Clear
</a>
@endif
</form>
{{-- Tab Navigation --}}
<div class="tabs tabs-bordered mb-6">
<button @click="activeTab = 'promotions'"

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Api\Internal\BrandPlacementController;
use App\Http\Controllers\Api\PushSubscriptionController;
use Illuminate\Support\Facades\Route;
/*
@@ -58,3 +59,15 @@ Route::prefix('internal')->middleware(['auth:sanctum'])->group(function () {
Route::post('/signals/compute', [BrandPlacementController::class, 'computeSignals'])
->name('api.internal.signals.compute');
});
/*
|--------------------------------------------------------------------------
| Push Notifications
|--------------------------------------------------------------------------
*/
Route::middleware(['auth:sanctum'])->group(function () {
Route::post('/push-subscriptions', [PushSubscriptionController::class, 'store'])
->name('api.push-subscriptions.store');
Route::delete('/push-subscriptions', [PushSubscriptionController::class, 'destroy'])
->name('api.push-subscriptions.destroy');
});

View File

@@ -1,6 +1,7 @@
import { defineConfig, loadEnv } from "vite";
import laravel from "laravel-vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig(({ mode }) => {
// Load env file based on `mode` in the current working directory
@@ -25,6 +26,81 @@ export default defineConfig(({ mode }) => {
input: ["resources/css/app.css", "resources/js/app.js"],
refresh: true,
}),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'icons/apple-touch-icon.png'],
manifest: {
name: 'Cannabrands Hub',
short_name: 'Hub',
description: 'Cannabis B2B Sales & Distribution Platform',
theme_color: '#5C0C36',
background_color: '#ffffff',
display: 'standalone',
start_url: '/',
icons: [
{
src: '/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'gstatic-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /\/images\/.*/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'images-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
}
}
}
]
}
}),
],
server: {
host: '0.0.0.0',