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:
58
app/Http/Controllers/Api/PushSubscriptionController.php
Normal file
58
app/Http/Controllers/Api/PushSubscriptionController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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
4435
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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 |
BIN
public/icons/apple-touch-icon.png
Normal file
BIN
public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
public/icons/icon-192x192.png
Normal file
BIN
public/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
public/icons/icon-512x512.png
Normal file
BIN
public/icons/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -1,4 +1,5 @@
|
||||
import './bootstrap';
|
||||
import './push-notifications';
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
import Precognition from 'laravel-precognition-alpine';
|
||||
|
||||
193
resources/js/push-notifications.js
Normal file
193
resources/js/push-notifications.js
Normal 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();
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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' : '' }}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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']) · {{ $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'] }} ·
|
||||
{{ \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>
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user