Compare commits

...

36 Commits

Author SHA1 Message Date
kelly
ec9853c571 fix: sidebar double-click bug and standardize 'Accounts' terminology
- Add width constraint (w-8) to collapse checkbox in CSS to prevent
  it from overlaying menu items below the collapse title
- Rename 'Customers' to 'Accounts' across CRM views:
  - accounts/index.blade.php: title, button labels, empty state text
  - accounts/create.blade.php: page title and submit button
  - accounts/edit.blade.php: page title and breadcrumb
  - accounts/contacts-edit.blade.php: breadcrumb
  - accounts/locations-edit.blade.php: breadcrumb
- Update SuiteMenuResolver route from seller.business.customers.index
  to seller.business.crm.accounts.index
2025-12-14 16:08:14 -07:00
kelly
c7d6ee5e21 feat: multiple UI/UX improvements and case-insensitive search
- Refactor New Quote page to enterprise data-entry layout (2-column, dense)
- Add Payment Terms dropdown (COD, NET 15, NET 30, NET 60)
- Fix sidebar menu active states and route names
- Fix brand filter badge visibility on brands page
- Remove company_name references (use business instead)
- Polish Promotions page layout
- Fix double-click issue on sidebar menu collapse
- Make all searches case-insensitive (like -> ilike for PostgreSQL)
2025-12-14 15:36:00 -07:00
kelly
496ca61489 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
2025-12-14 03:41:31 -07:00
kelly
a812380b32 refactor: consolidate sidebar menu structure
- Dashboard: single link (removed Overview section)
- Connect: new section with Overview, Contacts, Conversations, Tasks, Calendar
- Commerce: Accounts, Orders, Quotes, Invoices (removed Backorders, Promotions)
- Brands: All Brands, Promotions, Menus (brand-context aware)
- Inventory: Products, Stock, Batch Management
- Marketing: renamed from Growth, removed Channels & Templates
- Removed: Relationships section (Tasks/Calendar moved to Connect)
2025-12-13 23:48:05 -07:00
kelly
9bb0f6d373 fix: add DaisyUI v5 compatibility for removed -bordered classes 2025-12-13 21:03:55 -07:00
kelly
798476e991 feat: standardize list pages with canonical cb-list-page component
- Create x-cb-list-page shared component for all list pages
- Create x-cb-status-pill component (blue for in-progress, gray for rest)
- Add CSS primitives: cb-filter-bar, cb-filter-search, cb-filter-select
- Migrate Invoices, Orders, Accounts, Quotes, Backorders to use component
- Standardize table headers (uppercase, tracking-wide, text-base-content/70)
- Use text-primary for links (matches dashboard exactly)
- Add dashboard components: stat-card, panel, preview-table, rail-card
- Add CommandCenterService for dashboard data
2025-12-13 18:28:09 -07:00
kelly
bad6c24597 Merge pull request 'feat: add Nuvata products to missing_products.php' (#213) from fix/add-nuvata-products into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/213
2025-12-12 18:46:31 +00:00
kelly
5b7898f478 fix: remove configuration-snippet annotation blocking ingress 2025-12-12 10:49:04 -07:00
kelly
9cc582b869 feat: add Nuvata products to missing_products.php
Added 8 Nuvata products (NU-*) to the data file so they get created
on production without needing MySQL connection.
2025-12-12 09:50:13 -07:00
kelly
ac70cc0247 Merge pull request 'fix: reimport product descriptions with proper UTF-8 emoji encoding' (#212) from fix/product-description-emoji-import into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/212
2025-12-12 16:13:32 +00:00
kelly
eb95528b76 style: fix pint formatting issues 2025-12-12 09:10:05 -07:00
kelly
879d1c61df fix: reimport product descriptions with proper UTF-8 emoji encoding
The original data export had encoding issues that corrupted emojis to
'?' characters or stripped them entirely. Re-exported from MySQL with
proper UTF-8 encoding to preserve emojis (🍬🌊, 🧄, etc).

- Regenerated product_descriptions_non_hf.php (266 products)
- Regenerated product_descriptions_hf.php (15 products)
- Added migration to re-import consumer_long_description
2025-12-12 08:47:14 -07:00
kelly
0f5901e55f Merge pull request 'fix: convert literal escape sequences in product descriptions' (#210) from fix/product-description-literals into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/210
2025-12-12 02:32:49 +00:00
kelly
8fcc3629bd Merge pull request 'fix: add missing quote_date field to quote creation' (#209) from fix/crystal-quote-date into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/209
2025-12-12 02:27:52 +00:00
kelly
0b54c251bc fix: convert literal escape sequences in product descriptions
- Replace literal '\r\n' strings (4 chars) with actual newlines
- Remove '??' corrupted emoji placeholders
- Clean up excessive newlines

Data was imported with escape sequences as literal strings instead of
actual control characters.
2025-12-11 19:14:41 -07:00
kelly
8995c60d88 fix: add missing quote_date field to quote creation
- Add quote_date to CrmQuote model fillable array
- Add quote_date to CrmQuote model casts
- Set quote_date to now() when creating new quotes in controller

Fixes Crystal issue: null value in column 'quote_date' violates not-null constraint
2025-12-11 19:13:24 -07:00
kelly
c4e178a900 Merge pull request 'fix: product image upload improvements' (#208) from fix/product-image-upload into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/208
2025-12-12 01:25:55 +00:00
kelly
6688bbf8a1 fix: product image upload improvements
- Change max images from 8 to 6
- Fix drag and drop with proper event handlers (prevent, stop propagation)
- Stay on page after upload instead of redirecting
- Use proper storage path: businesses/{slug}/brands/{slug}/products/{sku}/images/
- Return image URLs in upload response for dynamic UI update
- Change button text from 'Replace Image' to 'Add Image' for clarity
- Maintain validation: JPG/PNG, max 2MB, 750x384px minimum
2025-12-11 18:18:51 -07:00
kelly
bb5f2c8aaa Merge pull request 'fix(#161): add missing crm_quotes columns' (#206) from fix/crystal-issues-batch-2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/206
2025-12-12 01:15:49 +00:00
kelly
a9d0f328a8 Merge pull request 'fix: oldest past due days and product description encoding' (#207) from fix/oldest-past-due-days into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/207
2025-12-12 01:15:27 +00:00
kelly
3b769905b7 fix: remove corrupt emoji characters from product descriptions
The original product description import had encoding issues where emojis
became ? or replacement characters (U+FFFD). This migration:

- Removes U+FFFD replacement characters
- Removes stray ? at start/end of lines (were emoji headers)
- Normalizes Windows line endings to Unix
2025-12-11 17:57:46 -07:00
kelly
f7727d8c17 fix: round oldest past due days to whole number
- Use abs() to ensure positive value
- Use ceil() to round up
- Cast to int for clean display
2025-12-11 17:48:59 -07:00
kelly
6d7eb4f151 fix(#161): add missing crm_quotes columns and remove signature_requested validation
- Add migration to add missing columns that the CrmQuote model expects:
  signature_requested, signed_by_name, signed_by_email, signature_ip,
  rejection_reason, order_id, notes_customer, notes_internal
- Remove signature_requested from validation rules (no longer required)
- Migration is idempotent with hasColumn checks
2025-12-11 17:41:19 -07:00
kelly
0c260f69b0 Merge pull request 'fix: resolve Crystal issues #161, #200, #203' (#205) from fix/crystal-issues-batch-2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/205
2025-12-12 00:39:09 +00:00
kelly
63b9372372 ci: trigger rebuild 2025-12-11 17:14:18 -07:00
kelly
aaff332937 fix: resolve Crystal issues #161, #200, #203
Issue #161: Quote submission error
- Added missing tax_rate column to crm_quotes table
- Column was referenced in model but never created in migration

Issue #200: Batch 404 error after save
- Batches missing hashids caused 404 (hashid-based routing)
- Migration backfills hashids for all existing batches

Issue #203: Product image upload error
- Fixed route name: images.product -> image.product (singular)

Additional improvements:
- Quote create page prefill from CRM account dashboard
- Product hashid backfill migration
2025-12-11 17:12:21 -07:00
kelly
964548ba38 Merge pull request 'fix: product descriptions, hashids, and CRM updates' (#204) from fix/product-brand-descriptions into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/204
2025-12-12 00:10:40 +00:00
kelly
cf05d8cad1 style: fix pint formatting in migration 2025-12-11 16:59:56 -07:00
kelly
05dca8f847 fix: location detail partial update 2025-12-11 16:32:35 -07:00
kelly
27328c9106 fix: normalize CRLF line endings in all product descriptions 2025-12-11 16:28:45 -07:00
kelly
b3dd9a8e23 fix: decode HTML entities and escape sequences in product descriptions
Fixes literal \r\n and HTML entity emoji codes (🌱) in descriptions
imported from MySQL.
2025-12-11 15:58:37 -07:00
kelly
1cd6c15cb3 fix: backfill missing product hashids
Products without hashids are filtered out in ProductController,
causing the products page to show empty table.
This migration generates hashids for all products that don't have one.
2025-12-11 15:26:32 -07:00
kelly
3554578554 fix: handle soft-deleted products in MySQL sync migrations
- Use withTrashed() to check for existing SKUs (unique constraint applies to all)
- Restore and update soft-deleted products instead of creating duplicates
- Remove invalid DB::statement comment that caused SQL error
2025-12-11 15:07:24 -07:00
kelly
3962807fc6 Merge pull request 'fix: sync product descriptions and SKUs from MySQL source' (#202) from fix/product-brand-descriptions into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/202
2025-12-11 21:50:42 +00:00
kelly
32054ddcce fix: sync product descriptions and SKUs from MySQL source
Migrations to synchronize PostgreSQL products with MySQL source data:

1. Null out existing product descriptions (clean slate)
2. Import descriptions for non-Hash Factory brands (266 products)
3. Import descriptions for Hash Factory brand (21 products)
4. Create 31 missing products from MySQL data
5. Soft-delete orphan products not in MySQL source

Data files contain hardcoded MySQL product data since remote
environment cannot access MySQL directly.

Products affected:
- 287 products get description updates
- 31 new products created
- Orphan products (not in MySQL) soft-deleted
2025-12-11 14:44:43 -07:00
kelly
5905699ca1 Merge pull request 'fix: Gitea issues batch - contacts, batches, products' (#201) from fix/gitea-issues-batch into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/201
2025-12-11 20:51:24 +00:00
126 changed files with 17057 additions and 3939 deletions

View File

@@ -210,7 +210,7 @@ class AiContentRuleResource extends Resource
])
->query(function ($query, array $data) {
if (! empty($data['value'])) {
$query->where('content_type_key', 'like', $data['value'].'.%');
$query->where('content_type_key', 'ilike', $data['value'].'.%');
}
}),
])

View File

@@ -1789,8 +1789,8 @@ class BusinessResource extends Resource
})
->description(fn ($record) => $record->parent ? 'Managed by '.$record->parent->name : null)
->searchable(query: function ($query, $search) {
return $query->where('name', 'like', "%{$search}%")
->orWhere('dba_name', 'like', "%{$search}%");
return $query->where('name', 'ilike', "%{$search}%")
->orWhere('dba_name', 'ilike', "%{$search}%");
})
->sortable(query: fn ($query, $direction) => $query->orderBy('parent_id')->orderBy('name', $direction)),
TextColumn::make('types.label')
@@ -1910,9 +1910,9 @@ class BusinessResource extends Resource
return $query->whereHas('users', function ($q) use ($search) {
$q->wherePivot('is_primary', true)
->where(function ($q2) use ($search) {
$q2->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
$q2->where('first_name', 'ilike', "%{$search}%")
->orWhere('last_name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
});
})
@@ -1943,9 +1943,9 @@ class BusinessResource extends Resource
})
->searchable(query: function ($query, $search) {
return $query->whereHas('users', function ($q) use ($search) {
$q->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
$q->where('first_name', 'ilike', "%{$search}%")
->orWhere('last_name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
}),
TextColumn::make('users_count')

View File

@@ -116,8 +116,8 @@ class DatabaseBackupResource extends Resource
})
->searchable(query: function ($query, $search) {
return $query->whereHas('creator', function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
$q->where('name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
}),

View File

@@ -215,7 +215,7 @@ class UserResource extends Resource
})
->searchable(query: function ($query, $search) {
return $query->whereHas('businesses', function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
$q->where('name', 'ilike', "%{$search}%");
});
}),
TextColumn::make('status')

View File

@@ -26,8 +26,8 @@ class ApVendorController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%");
$q->where('name', 'ilike', "%{$search}%")
->orWhere('code', 'ilike', "%{$search}%");
});
}
@@ -199,7 +199,7 @@ class ApVendorController extends Controller
$prefix = substr($prefix, 0, 6);
$count = ApVendor::where('business_id', $businessId)
->where('code', 'like', "{$prefix}%")
->where('code', 'ilike', "{$prefix}%")
->count();
return $count > 0 ? "{$prefix}-{$count}" : $prefix;

View File

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

View File

@@ -9,6 +9,7 @@ use App\Models\Crm\CrmMeetingBooking;
use App\Models\Crm\CrmTask;
use App\Models\Crm\CrmThread;
use App\Services\Crm\CrmSlaService;
use App\Services\Dashboard\CommandCenterService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -19,6 +20,10 @@ class DashboardController extends Controller
*/
private const DASHBOARD_CACHE_TTL = 300;
public function __construct(
protected CommandCenterService $commandCenterService,
) {}
/**
* Main dashboard redirect - automatically routes to business context
* Redirects to /s/{business}/dashboard based on user's primary business
@@ -40,104 +45,25 @@ class DashboardController extends Controller
}
/**
* Dashboard Overview - Main overview page
* Dashboard Overview - Revenue Command Center
*
* NOTE: All metrics are pre-calculated by CalculateDashboardMetrics job
* and stored in Redis. This method only reads from Redis for instant response.
* Single source of truth for all seller dashboard metrics.
* Uses CommandCenterService which provides:
* - DB/service as source of truth
* - Redis as cache layer
* - Explicit scoping (business|brand|user) per metric
*/
public function overview(Request $request, Business $business)
{
// Read pre-calculated metrics from Redis
$redisKey = "dashboard:{$business->id}:overview";
$cachedMetrics = \Illuminate\Support\Facades\Redis::get($redisKey);
$user = $request->user();
if ($cachedMetrics) {
$data = json_decode($cachedMetrics, true);
// Get all Command Center data via the single service
$commandCenterData = $this->commandCenterService->getData($business, $user);
// Map cached data to view variables
$revenueLast30 = $data['kpis']['revenue_last_30'] ?? 0;
$ordersLast30 = $data['kpis']['orders_last_30'] ?? 0;
$unitsSoldLast30 = $data['kpis']['units_sold_last_30'] ?? 0;
$averageOrderValueLast30 = $data['kpis']['average_order_value_last_30'] ?? 0;
$revenueGrowth = $data['kpis']['revenue_growth'] ?? 0;
$ordersGrowth = $data['kpis']['orders_growth'] ?? 0;
$unitsGrowth = $data['kpis']['units_growth'] ?? 0;
$aovGrowth = $data['kpis']['aov_growth'] ?? 0;
$activeBrandCount = $data['kpis']['active_brand_count'] ?? 0;
$activeBuyerCount = $data['kpis']['active_buyer_count'] ?? 0;
$activeInventoryAlertsCount = $data['kpis']['active_inventory_alerts_count'] ?? 0;
$activePromotionCount = $data['kpis']['active_promotion_count'] ?? 0;
// Convert arrays to objects and parse timestamps back to Carbon
$topProducts = collect($data['top_products'] ?? [])->map(fn ($item) => (object) $item);
$topBrands = collect($data['top_brands'] ?? [])->map(fn ($item) => (object) $item);
$needsAttention = collect($data['needs_attention'] ?? [])->map(function ($item) {
if (isset($item['timestamp']) && is_string($item['timestamp'])) {
$item['timestamp'] = \Carbon\Carbon::parse($item['timestamp']);
}
return $item; // Keep as array since view uses array syntax
});
$recentActivity = collect($data['recent_activity'] ?? [])->map(function ($item) {
if (isset($item['timestamp']) && is_string($item['timestamp'])) {
$item['timestamp'] = \Carbon\Carbon::parse($item['timestamp']);
}
return $item; // Keep as array since view uses array syntax
});
} else {
// No cached data - dispatch job and return empty state
\App\Jobs\CalculateDashboardMetrics::dispatch($business->id);
$revenueLast30 = 0;
$ordersLast30 = 0;
$unitsSoldLast30 = 0;
$averageOrderValueLast30 = 0;
$revenueGrowth = 0;
$ordersGrowth = 0;
$unitsGrowth = 0;
$aovGrowth = 0;
$activeBrandCount = 0;
$activeBuyerCount = 0;
$activeInventoryAlertsCount = 0;
$activePromotionCount = 0;
$topProducts = collect([]);
$topBrands = collect([]);
$needsAttention = collect([]);
$recentActivity = collect([]);
}
// Orchestrator Widget Data (if enabled)
$orchestratorWidget = (new \App\Http\Controllers\Seller\OrchestratorController)->getWidgetData($business);
// Hub Tiles Data (CRM, Tasks, Calendar, etc.)
$hubTiles = $this->getHubTilesData($business, $request->user());
// Sales Inbox - unified view of items needing attention
$salesInbox = $this->getSalesInboxData($business, $request->user());
return view('seller.dashboard.overview', compact(
'business',
'revenueLast30',
'ordersLast30',
'unitsSoldLast30',
'averageOrderValueLast30',
'revenueGrowth',
'ordersGrowth',
'unitsGrowth',
'aovGrowth',
'activeBrandCount',
'activeBuyerCount',
'activeInventoryAlertsCount',
'activePromotionCount',
'topProducts',
'topBrands',
'needsAttention',
'recentActivity',
'orchestratorWidget',
'hubTiles',
'salesInbox'
));
return view('seller.dashboard.overview', [
'business' => $business,
'commandCenter' => $commandCenterData,
]);
}
/**
@@ -1248,7 +1174,7 @@ class DashboardController extends Controller
$overdueTasks = CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->where('due_at', '<', now())
->with(['contact:id,first_name,last_name,company_name'])
->with(['contact:id,first_name,last_name'])
->orderBy('due_at', 'asc')
->limit(10)
->get();
@@ -1256,7 +1182,7 @@ class DashboardController extends Controller
foreach ($overdueTasks as $task) {
$daysOverdue = now()->diffInDays($task->due_at, false);
$contactName = $task->contact
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: $task->contact->company_name
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: 'Contact'
: 'Unknown';
$overdue[] = [
'type' => 'task',
@@ -1295,7 +1221,7 @@ class DashboardController extends Controller
->whereNull('completed_at')
->where('due_at', '>=', now())
->where('due_at', '<=', now()->addDays(7))
->with(['contact:id,first_name,last_name,company_name'])
->with(['contact:id,first_name,last_name'])
->orderBy('due_at', 'asc')
->limit(10)
->get();
@@ -1303,7 +1229,7 @@ class DashboardController extends Controller
foreach ($upcomingTasks as $task) {
$daysUntilDue = now()->diffInDays($task->due_at, false);
$contactName = $task->contact
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: $task->contact->company_name
? trim($task->contact->first_name.' '.$task->contact->last_name) ?: 'Contact'
: 'Unknown';
$upcoming[] = [
'type' => 'task',
@@ -1318,7 +1244,7 @@ class DashboardController extends Controller
->where('status', 'scheduled')
->where('start_at', '>=', now())
->where('start_at', '<=', now()->addDays(7))
->with(['contact:id,first_name,last_name,company_name'])
->with(['contact:id,first_name,last_name'])
->orderBy('start_at', 'asc')
->limit(5)
->get();
@@ -1326,7 +1252,7 @@ class DashboardController extends Controller
foreach ($upcomingMeetings as $meeting) {
$daysUntil = now()->diffInDays($meeting->start_at, false);
$contactName = $meeting->contact
? trim($meeting->contact->first_name.' '.$meeting->contact->last_name) ?: $meeting->contact->company_name
? trim($meeting->contact->first_name.' '.$meeting->contact->last_name) ?: 'Contact'
: 'Unknown';
$upcoming[] = [
'type' => 'meeting',

View File

@@ -22,9 +22,9 @@ class MarketplaceController extends Controller
// Search filter (name, SKU, description)
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('sku', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
$q->where('name', 'ilike', "%{$search}%")
->orWhere('sku', 'ilike', "%{$search}%")
->orWhere('description', 'ilike', "%{$search}%");
});
}

View File

@@ -42,9 +42,9 @@ class DivisionAccountingController extends Controller
// Search filter
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
$q->where('name', 'ilike', "%{$search}%")
->orWhere('code', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
}

View File

@@ -29,9 +29,9 @@ class BrandManagerSettingsController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
$q->where('first_name', 'ilike', "%{$search}%")
->orWhere('last_name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
}

View File

@@ -25,12 +25,12 @@ class ConversationController extends Controller
if ($search) {
$query->where(function ($q) use ($search) {
$q->whereHas('contact', function ($c) use ($search) {
$c->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%");
$c->where('name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%")
->orWhere('phone', 'ilike', "%{$search}%");
})
->orWhereHas('messages', function ($m) use ($search) {
$m->where('message_body', 'like', "%{$search}%");
$m->where('message_body', 'ilike', "%{$search}%");
});
});
}

View File

@@ -10,18 +10,23 @@ use App\Models\Crm\CrmEvent;
use App\Models\Crm\CrmQuote;
use App\Models\Crm\CrmTask;
use App\Models\Invoice;
use App\Models\Location;
use App\Models\SalesOpportunity;
use App\Models\SendMenuLog;
use App\Services\Cannaiq\CannaiqClient;
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
@@ -176,35 +181,55 @@ class AccountController extends Controller
{
$account->load(['contacts']);
// Get orders for this account from this seller (with invoices)
$orders = $account->orders()
// Location filtering
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
// Load all locations for this account with contacts pivot
$locations = $account->locations()
->with(['contacts' => function ($q) {
$q->wherePivot('role', 'buyer');
}])
->orderBy('name')
->get();
// Base order query for this seller
$baseOrderQuery = fn () => $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->with(['invoice'])
->latest()
->limit(10)
->get();
});
// Get quotes for this account
$quotes = CrmQuote::where('business_id', $business->id)
->where('account_id', $account->id)
->with(['contact', 'items'])
->latest()
->limit(10)
->get();
// Get orders (filtered by location if selected)
$ordersQuery = $baseOrderQuery();
if ($selectedLocation) {
$ordersQuery->where('location_id', $selectedLocation->id);
}
$orders = $ordersQuery->with(['invoice', 'location'])->latest()->limit(10)->get();
// Get invoices for this account (via orders)
$invoices = Invoice::whereHas('order', function ($q) use ($business, $account) {
// Get quotes for this account (filtered by location if selected)
$quotesQuery = CrmQuote::where('business_id', $business->id)
->where('account_id', $account->id);
if ($selectedLocation) {
$quotesQuery->where('location_id', $selectedLocation->id);
}
$quotes = $quotesQuery->with(['contact', 'items'])->latest()->limit(10)->get();
// Base invoice query
$baseInvoiceQuery = fn () => Invoice::whereHas('order', function ($q) use ($business, $account) {
$q->where('business_id', $account->id)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
})
->with(['order', 'payments'])
->latest()
->limit(10)
->get();
});
// Get invoices (filtered by location if selected)
$invoicesQuery = $baseInvoiceQuery();
if ($selectedLocation) {
$invoicesQuery->whereHas('order', function ($q) use ($selectedLocation) {
$q->where('location_id', $selectedLocation->id);
});
}
$invoices = $invoicesQuery->with(['order', 'payments'])->latest()->limit(10)->get();
// Get opportunities for this account from this seller
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
@@ -245,13 +270,17 @@ class AccountController extends Controller
->limit(20)
->get();
// Compute stats for this account with efficient queries
$orderStats = $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
->first();
// Compute stats - if location selected, show location-specific stats
if ($selectedLocation) {
$orderStats = $baseOrderQuery()
->where('location_id', $selectedLocation->id)
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
->first();
} else {
$orderStats = $baseOrderQuery()
->selectRaw('COUNT(*) as total_orders, COALESCE(SUM(total), 0) as total_revenue')
->first();
}
$opportunityStats = SalesOpportunity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
@@ -259,14 +288,14 @@ class AccountController extends Controller
->selectRaw('COUNT(*) as open_count, COALESCE(SUM(value), 0) as pipeline_value')
->first();
// Financial stats from invoices
$financialStats = Invoice::whereHas('order', function ($q) use ($business, $account) {
$q->where('business_id', $account->id)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
})
->selectRaw('
// Financial stats from invoices (location-filtered if applicable)
$financialStatsQuery = $baseInvoiceQuery();
if ($selectedLocation) {
$financialStatsQuery->whereHas('order', function ($q) use ($selectedLocation) {
$q->where('location_id', $selectedLocation->id);
});
}
$financialStats = $financialStatsQuery->selectRaw('
COALESCE(SUM(amount_due), 0) as outstanding_balance,
COALESCE(SUM(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN amount_due ELSE 0 END), 0) as past_due_amount,
COUNT(CASE WHEN amount_due > 0 THEN 1 END) as open_invoice_count,
@@ -296,12 +325,69 @@ class AccountController extends Controller
'past_due_amount' => $financialStats->past_due_amount ?? 0,
'open_invoice_count' => $financialStats->open_invoice_count ?? 0,
'oldest_past_due_days' => $financialStats->oldest_past_due_date
? now()->diffInDays($financialStats->oldest_past_due_date)
? (int) ceil(abs(now()->diffInDays($financialStats->oldest_past_due_date)))
: null,
'last_payment_amount' => $lastPayment->amount ?? null,
'last_payment_date' => $lastPayment->payment_date ?? null,
];
// Calculate unattributed orders/invoices (those without location_id)
$unattributedOrdersCount = $baseOrderQuery()->whereNull('location_id')->count();
$unattributedInvoicesCount = $baseInvoiceQuery()
->whereHas('order', function ($q) {
$q->whereNull('location_id');
})
->count();
// Calculate per-location stats for location tiles
$locationStats = [];
if ($locations->count() > 0) {
$locationIds = $locations->pluck('id')->toArray();
// Order stats by location
$ordersByLocation = $baseOrderQuery()
->whereIn('location_id', $locationIds)
->selectRaw('location_id, COUNT(*) as orders_count, COALESCE(SUM(total), 0) as revenue')
->groupBy('location_id')
->get()
->keyBy('location_id');
// Invoice stats by location
$invoicesByLocation = Invoice::whereHas('order', function ($q) use ($business, $account, $locationIds) {
$q->where('business_id', $account->id)
->whereIn('location_id', $locationIds)
->whereHas('items.product.brand', function ($q2) use ($business) {
$q2->where('business_id', $business->id);
});
})
->selectRaw('
(SELECT location_id FROM orders WHERE orders.id = invoices.order_id) as location_id,
COALESCE(SUM(amount_due), 0) as outstanding,
COALESCE(SUM(CASE WHEN due_date < CURRENT_DATE AND amount_due > 0 THEN amount_due ELSE 0 END), 0) as past_due,
COUNT(CASE WHEN amount_due > 0 THEN 1 END) as open_invoices
')
->groupByRaw('(SELECT location_id FROM orders WHERE orders.id = invoices.order_id)')
->get()
->keyBy('location_id');
foreach ($locations as $location) {
$orderData = $ordersByLocation->get($location->id);
$invoiceData = $invoicesByLocation->get($location->id);
$ordersCount = $orderData->orders_count ?? 0;
$openInvoices = $invoiceData->open_invoices ?? 0;
$locationStats[$location->id] = [
'orders' => $ordersCount,
'revenue' => $orderData->revenue ?? 0,
'outstanding' => $invoiceData->outstanding ?? 0,
'past_due' => $invoiceData->past_due ?? 0,
'open_invoices' => $openInvoices,
'has_attributed_data' => ($ordersCount + $openInvoices) > 0,
];
}
}
return view('seller.crm.accounts.show', compact(
'business',
'account',
@@ -314,7 +400,12 @@ class AccountController extends Controller
'tasks',
'conversationEvents',
'sendHistory',
'activities'
'activities',
'locations',
'selectedLocation',
'locationStats',
'unattributedOrdersCount',
'unattributedInvoicesCount'
));
}
@@ -323,9 +414,26 @@ class AccountController extends Controller
*/
public function contacts(Request $request, Business $business, Business $account)
{
$contacts = $account->contacts()->paginate(25);
// Location filtering
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
return view('seller.crm.accounts.contacts', compact('business', 'account', 'contacts'));
// Base query for contacts
$contactsQuery = $account->contacts();
// If location selected, filter to contacts assigned to that location
if ($selectedLocation) {
$contactsQuery->whereHas('locations', function ($q) use ($selectedLocation) {
$q->where('locations.id', $selectedLocation->id);
});
}
$contacts = $contactsQuery->paginate(25);
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.contacts', compact('business', 'account', 'contacts', 'locations', 'selectedLocation'));
}
/**
@@ -333,7 +441,21 @@ class AccountController extends Controller
*/
public function opportunities(Request $request, Business $business, Business $account)
{
return view('seller.crm.accounts.opportunities', compact('business', 'account'));
// Location filtering (note: opportunities don't have location_id yet, so we just pass the context)
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
// Load opportunities for this account
$opportunities = SalesOpportunity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['stage', 'brand', 'owner'])
->latest()
->paginate(25);
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.opportunities', compact('business', 'account', 'opportunities', 'locations', 'selectedLocation'));
}
/**
@@ -341,15 +463,28 @@ class AccountController extends Controller
*/
public function orders(Request $request, Business $business, Business $account)
{
$orders = $account->orders()
// Location filtering
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
$ordersQuery = $account->orders()
->whereHas('items.product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->with(['items.product.brand'])
});
// Filter by location if selected
if ($selectedLocation) {
$ordersQuery->where('location_id', $selectedLocation->id);
}
$orders = $ordersQuery->with(['items.product.brand', 'location'])
->latest()
->paginate(25);
return view('seller.crm.accounts.orders', compact('business', 'account', 'orders'));
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.orders', compact('business', 'account', 'orders', 'locations', 'selectedLocation'));
}
/**
@@ -357,13 +492,20 @@ class AccountController extends Controller
*/
public function activity(Request $request, Business $business, Business $account)
{
// Location filtering (note: activities don't have location_id yet, so we just pass the context)
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
$activities = Activity::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['causer'])
->latest()
->paginate(50);
return view('seller.crm.accounts.activity', compact('business', 'account', 'activities'));
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.activity', compact('business', 'account', 'activities', 'locations', 'selectedLocation'));
}
/**
@@ -371,7 +513,22 @@ class AccountController extends Controller
*/
public function tasks(Request $request, Business $business, Business $account)
{
return view('seller.crm.accounts.tasks', compact('business', 'account'));
// Location filtering (note: tasks don't have location_id yet, so we just pass the context)
$locationId = $request->query('location');
$selectedLocation = $locationId ? $account->locations()->find($locationId) : null;
// Load tasks for this account
$tasks = CrmTask::where('seller_business_id', $business->id)
->where('business_id', $account->id)
->with(['assignee', 'opportunity'])
->orderByRaw('completed_at IS NOT NULL')
->orderBy('due_at')
->paginate(25);
// Load locations for the scope bar
$locations = $account->locations()->orderBy('name')->get();
return view('seller.crm.accounts.tasks', compact('business', 'account', 'tasks', 'locations', 'selectedLocation'));
}
/**
@@ -487,4 +644,167 @@ class AccountController extends Controller
->route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug])
->with('success', 'Contact deleted successfully.');
}
/**
* Show location edit form
*/
public function editLocation(Request $request, Business $business, Business $account, Location $location)
{
// Verify location belongs to this account
if ($location->business_id !== $account->id) {
abort(404);
}
// Load contacts that can be assigned to this location
$contacts = $account->contacts()->orderBy('first_name')->get();
// Load currently assigned contacts with their roles
$locationContacts = $location->contacts()->get();
// Available roles for location contacts
$contactRoles = [
'buyer' => 'Buyer',
'ap' => 'Accounts Payable',
'marketing' => 'Marketing',
'gm' => 'General Manager',
'inventory' => 'Inventory Manager',
'other' => 'Other',
];
// CannaiQ platforms
$cannaiqPlatforms = [
'dutchie' => 'Dutchie',
'jane' => 'Jane',
'weedmaps' => 'Weedmaps',
'leafly' => 'Leafly',
'iheartjane' => 'iHeartJane',
'other' => 'Other',
];
return view('seller.crm.accounts.locations-edit', compact(
'business',
'account',
'location',
'contacts',
'locationContacts',
'contactRoles',
'cannaiqPlatforms'
));
}
/**
* Update location
*/
public function updateLocation(Request $request, Business $business, Business $account, Location $location)
{
// Verify location belongs to this account
if ($location->business_id !== $account->id) {
abort(404);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'address' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:50',
'zipcode' => 'nullable|string|max:20',
'phone' => 'nullable|string|max:50',
'email' => 'nullable|email|max:255',
'is_active' => 'boolean',
'cannaiq_platform' => 'nullable|string|max:50',
'cannaiq_store_slug' => 'nullable|string|max:255',
'cannaiq_store_id' => 'nullable|string|max:100',
'cannaiq_store_name' => 'nullable|string|max:255',
'contact_roles' => 'nullable|array',
'contact_roles.*.contact_id' => 'required|exists:contacts,id',
'contact_roles.*.role' => 'required|string|max:50',
'contact_roles.*.is_primary' => 'boolean',
]);
// Handle checkbox
$validated['is_active'] = $request->boolean('is_active');
// Clear CannaiQ fields if platform is cleared
if (empty($validated['cannaiq_platform'])) {
$validated['cannaiq_store_slug'] = null;
$validated['cannaiq_store_id'] = null;
$validated['cannaiq_store_name'] = null;
}
// Update location
$location->update([
'name' => $validated['name'],
'address' => $validated['address'] ?? null,
'city' => $validated['city'] ?? null,
'state' => $validated['state'] ?? null,
'zipcode' => $validated['zipcode'] ?? null,
'phone' => $validated['phone'] ?? null,
'email' => $validated['email'] ?? null,
'is_active' => $validated['is_active'],
'cannaiq_platform' => $validated['cannaiq_platform'] ?? null,
'cannaiq_store_slug' => $validated['cannaiq_store_slug'] ?? null,
'cannaiq_store_id' => $validated['cannaiq_store_id'] ?? null,
'cannaiq_store_name' => $validated['cannaiq_store_name'] ?? null,
]);
// Sync location contacts
if (isset($validated['contact_roles'])) {
$syncData = [];
foreach ($validated['contact_roles'] as $contactRole) {
// Verify contact belongs to this account
$contact = Contact::where('business_id', $account->id)
->where('id', $contactRole['contact_id'])
->first();
if ($contact) {
$syncData[$contact->id] = [
'role' => $contactRole['role'],
'is_primary' => $contactRole['is_primary'] ?? false,
];
}
}
$location->contacts()->sync($syncData);
} else {
$location->contacts()->detach();
}
return redirect()
->route('seller.business.crm.accounts.show', [$business->slug, $account->slug])
->with('success', 'Location updated successfully.');
}
/**
* Search CannaiQ stores for linking
*/
public function searchCannaiqStores(Request $request, Business $business, Business $account, Location $location)
{
// Verify location belongs to this account
if ($location->business_id !== $account->id) {
abort(404);
}
$request->validate([
'platform' => 'required|string|max:50',
'query' => 'required|string|min:2|max:100',
]);
try {
$client = app(CannaiqClient::class);
$results = $client->searchStores(
platform: $request->input('platform'),
query: $request->input('query')
);
return response()->json([
'success' => true,
'stores' => $results,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Failed to search stores: '.$e->getMessage(),
'stores' => [],
], 500);
}
}
}

View File

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

View File

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

View File

@@ -42,8 +42,8 @@ class InvoiceController extends Controller
// Stats - single efficient query with conditional aggregation
$invoiceStats = CrmInvoice::forBusiness($business->id)
->selectRaw("
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN amount_due ELSE 0 END) as outstanding,
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN amount_due ELSE 0 END) as overdue
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN balance_due ELSE 0 END) as outstanding,
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN balance_due ELSE 0 END) as overdue
")
->first();
@@ -82,7 +82,7 @@ class InvoiceController extends Controller
{
// Limit contacts for dropdown - most recent 100
$contacts = \App\Models\Contact::where('business_id', $business->id)
->select('id', 'first_name', 'last_name', 'email', 'company_name')
->select('id', 'first_name', 'last_name', 'email')
->orderByDesc('updated_at')
->limit(100)
->get();

View File

@@ -10,6 +10,7 @@ use App\Models\Contact;
use App\Models\Crm\CrmDeal;
use App\Models\Crm\CrmQuote;
use App\Models\Crm\CrmQuoteItem;
use App\Models\Location;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
@@ -37,8 +38,8 @@ class QuoteController extends Controller
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('quote_number', 'like', "%{$request->search}%")
->orWhere('title', 'like', "%{$request->search}%");
$q->where('quote_number', 'ilike', "%{$request->search}%")
->orWhere('title', 'ilike', "%{$request->search}%");
});
}
@@ -84,7 +85,77 @@ class QuoteController extends Controller
? CrmDeal::forBusiness($business->id)->find($request->deal_id)
: null;
return view('seller.crm.quotes.create', compact('accounts', 'deals', 'deal', 'business'));
// Pre-fill from URL parameters (coming from customer dashboard)
$selectedAccount = null;
$selectedLocation = null;
$selectedContact = null;
$locationContacts = collect();
// Handle clear actions
if ($request->has('clearAccount')) {
// Redirect without any prefills
return redirect()->route('seller.business.crm.quotes.create', $business);
}
if ($request->has('clearLocation')) {
// Keep account but clear location
return redirect()->route('seller.business.crm.quotes.create', [$business, 'account_id' => $request->account_id]);
}
if ($request->has('clearContact')) {
// Keep account and location but clear contact
$params = ['account_id' => $request->account_id];
if ($request->location_id) {
$params['location_id'] = $request->location_id;
}
return redirect()->route('seller.business.crm.quotes.create', array_merge([$business], $params));
}
// Pre-fill account
if ($request->filled('account_id')) {
$selectedAccount = $accounts->firstWhere('id', $request->account_id);
}
// Pre-fill location (must belong to selected account)
if ($selectedAccount && $request->filled('location_id')) {
$selectedLocation = $selectedAccount->locations->firstWhere('id', $request->location_id);
}
// If location selected, get contacts assigned to that location
if ($selectedLocation) {
$locationContacts = $selectedLocation->contacts()
->with('pivot')
->get()
->map(fn ($c) => [
'value' => $c->id,
'label' => $c->getFullName().($c->email ? " ({$c->email})" : ''),
'is_primary' => $c->pivot->is_primary ?? false,
'role' => $c->pivot->role ?? 'buyer',
]);
// Try to find primary buyer for this location
$primaryBuyer = $locationContacts->firstWhere('is_primary', true)
?? $locationContacts->firstWhere('role', 'buyer');
if ($primaryBuyer && ! $request->filled('contact_id')) {
$selectedContact = Contact::find($primaryBuyer['value']);
}
}
// Pre-fill contact if explicitly provided
if ($request->filled('contact_id')) {
$selectedContact = Contact::find($request->contact_id);
}
return view('seller.crm.quotes.create', compact(
'accounts',
'deals',
'deal',
'business',
'selectedAccount',
'selectedLocation',
'selectedContact',
'locationContacts'
));
}
/**
@@ -103,7 +174,6 @@ class QuoteController extends Controller
'tax_rate' => 'nullable|numeric|min:0|max:100',
'terms' => 'nullable|string|max:5000',
'notes' => 'nullable|string|max:2000',
'signature_requested' => 'boolean',
'items' => 'required|array|min:1',
'items.*.product_id' => 'nullable|exists:products,id',
'items.*.description' => 'required|string|max:500',
@@ -139,13 +209,13 @@ class QuoteController extends Controller
'quote_number' => $quoteNumber,
'title' => $validated['title'],
'status' => CrmQuote::STATUS_DRAFT,
'quote_date' => now(),
'valid_until' => $validated['valid_until'] ?? now()->addDays($business->crm_quote_validity_days ?? 30),
'discount_type' => $validated['discount_type'],
'discount_value' => $validated['discount_value'],
'tax_rate' => $validated['tax_rate'] ?? 0,
'terms' => $validated['terms'] ?? $business->crm_default_terms,
'notes' => $validated['notes'],
'signature_requested' => $validated['signature_requested'] ?? false,
'currency' => 'USD',
]);

View File

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

View File

@@ -164,9 +164,9 @@ class ThreadController extends Controller
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('subject', 'like', "%{$request->search}%")
->orWhere('last_message_preview', 'like', "%{$request->search}%")
->orWhereHas('contact', fn ($c) => $c->where('name', 'like', "%{$request->search}%"));
$q->where('subject', 'ilike', "%{$request->search}%")
->orWhere('last_message_preview', 'ilike', "%{$request->search}%")
->orWhereHas('contact', fn ($c) => $c->where('name', 'ilike', "%{$request->search}%"));
});
}

View File

@@ -34,9 +34,9 @@ class ApVendorsController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%")
->orWhere('contact_email', 'like', "%{$search}%");
$q->where('name', 'ilike', "%{$search}%")
->orWhere('code', 'ilike', "%{$search}%")
->orWhere('contact_email', 'ilike', "%{$search}%");
});
}
@@ -320,7 +320,7 @@ class ApVendorsController extends Controller
// Check for uniqueness
$count = ApVendor::where('business_id', $businessId)
->where('code', 'like', "{$prefix}%")
->where('code', 'ilike', "{$prefix}%")
->count();
return $count > 0 ? "{$prefix}-{$count}" : $prefix;

View File

@@ -42,8 +42,8 @@ class ChartOfAccountsController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('account_number', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
$q->where('account_number', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%");
});
}

View File

@@ -63,8 +63,8 @@ class RequisitionsApprovalController extends Controller
// Search
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->where('requisition_number', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%");
$q->where('requisition_number', 'ilike', "%{$search}%")
->orWhere('notes', 'ilike', "%{$search}%");
});
}

View File

@@ -22,7 +22,7 @@ class BiomassController extends Controller
}
if ($request->filled('search')) {
$query->where('lot_number', 'like', '%'.$request->search.'%');
$query->where('lot_number', 'ilike', '%'.$request->search.'%');
}
$biomassLots = $query->paginate(25);

View File

@@ -26,7 +26,7 @@ class MaterialLotController extends Controller
}
if ($request->filled('search')) {
$query->where('lot_number', 'like', '%'.$request->search.'%');
$query->where('lot_number', 'ilike', '%'.$request->search.'%');
}
$materialLots = $query->paginate(25);

View File

@@ -28,7 +28,7 @@ class ProcessingSalesOrderController extends Controller
}
if ($request->filled('search')) {
$query->where('order_number', 'like', '%'.$request->search.'%');
$query->where('order_number', 'ilike', '%'.$request->search.'%');
}
$salesOrders = $query->paginate(25);

View File

@@ -25,7 +25,7 @@ class ProcessingShipmentController extends Controller
}
if ($request->filled('search')) {
$query->where('shipment_number', 'like', '%'.$request->search.'%');
$query->where('shipment_number', 'ilike', '%'.$request->search.'%');
}
$shipments = $query->paginate(25);

View File

@@ -923,10 +923,10 @@ class ProductController extends Controller
// Define checkbox fields per tab
$checkboxesByTab = [
'overview' => ['is_active', 'is_featured', 'sell_multiples', 'fractional_quantities', 'allow_sample', 'has_varieties'],
'overview' => ['is_active', 'is_featured', 'is_sellable', 'sell_multiples', 'fractional_quantities', 'allow_sample', 'has_varieties'],
'pricing' => ['is_case', 'is_box'],
'inventory' => ['sync_bamboo', 'low_stock_alert_enabled', 'is_assembly', 'show_inventory_to_buyers', 'has_varieties'],
'advanced' => ['is_sellable', 'is_fpr', 'is_raw_material'],
'advanced' => ['is_fpr', 'is_raw_material'],
];
// Convert checkboxes to boolean - only for fields in current validation scope
@@ -938,7 +938,7 @@ class ProductController extends Controller
if (array_key_exists($checkbox, $rules)) {
// Use boolean() for fields that send actual values (hidden inputs with 0/1)
// Use has() for traditional checkboxes that are absent when unchecked
$useBoolean = in_array($checkbox, ['is_assembly', 'is_raw_material', 'is_active', 'is_featured', 'low_stock_alert_enabled', 'has_varieties']);
$useBoolean = in_array($checkbox, ['is_assembly', 'is_raw_material', 'is_active', 'is_featured', 'is_sellable', 'low_stock_alert_enabled', 'has_varieties']);
$validated[$checkbox] = $useBoolean
? $request->boolean($checkbox)
: $request->has($checkbox);

View File

@@ -29,16 +29,25 @@ class ProductImageController extends Controller
'image' => 'required|image|mimes:jpeg,jpg,png|max:2048|dimensions:min_width=750,min_height=384', // 2MB max, 750x384 min
]);
// Check if product already has 8 images
if ($product->images()->count() >= 8) {
// Check if product already has 6 images
if ($product->images()->count() >= 6) {
return response()->json([
'success' => false,
'message' => 'Maximum of 8 images allowed per product',
'message' => 'Maximum of 6 images allowed per product',
], 422);
}
// Store the image using trait method
$path = $this->storeFile($request->file('image'), 'products');
// Build proper storage path: businesses/{business_slug}/brands/{brand_slug}/products/{sku}/images/
$brand = $product->brand;
$storagePath = sprintf(
'businesses/%s/brands/%s/products/%s/images',
$business->slug,
$brand->slug,
$product->sku
);
// Store the image with proper path
$path = $this->storeFile($request->file('image'), $storagePath);
// Determine if this should be the primary image (first one)
$isPrimary = $product->images()->count() === 0;
@@ -61,6 +70,8 @@ class ProductImageController extends Controller
'id' => $image->id,
'path' => $image->path,
'is_primary' => $image->is_primary,
'url' => route('image.product', ['product' => $product->hashid, 'width' => 400]),
'thumb_url' => route('image.product', ['product' => $product->hashid, 'width' => 80]),
],
]);
}

View File

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

View File

@@ -44,8 +44,8 @@ class RequisitionsController extends Controller
if ($search = $request->get('search')) {
$query->where(function ($q) use ($search) {
$q->where('requisition_number', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%");
$q->where('requisition_number', 'ilike', "%{$search}%")
->orWhere('notes', 'ilike', "%{$search}%");
});
}

View File

@@ -55,12 +55,16 @@ class SearchController extends Controller
/**
* Search contacts for a specific customer or the seller's own contacts.
*
* GET /s/{business}/search/contacts?q=...&customer_id=...
* GET /s/{business}/search/contacts?q=...&customer_id=...&location_id=...
*
* If location_id is provided, returns only contacts assigned to that location
* via the location_contact pivot table.
*/
public function contacts(Request $request, Business $business): JsonResponse
{
$query = $request->input('q', '');
$customerId = $request->input('customer_id');
$locationId = $request->input('location_id');
$contactsQuery = Contact::query()
->where('is_active', true);
@@ -73,6 +77,13 @@ class SearchController extends Controller
$contactsQuery->where('business_id', $business->id);
}
// If location_id is provided, filter to contacts assigned to that location
if ($locationId) {
$contactsQuery->whereHas('locations', function ($q) use ($locationId) {
$q->where('locations.id', $locationId);
});
}
$contacts = $contactsQuery
->when($query, function ($q) use ($query) {
$q->where(function ($q2) use ($query) {
@@ -87,6 +98,26 @@ class SearchController extends Controller
->limit(25)
->get(['id', 'first_name', 'last_name', 'email', 'title']);
// If filtering by location, include pivot data for is_primary
if ($locationId) {
// Reload contacts with pivot data
$contactIds = $contacts->pluck('id')->toArray();
$pivotData = \DB::table('location_contact')
->whereIn('contact_id', $contactIds)
->where('location_id', $locationId)
->get()
->keyBy('contact_id');
return response()->json(
$contacts->map(fn ($c) => [
'value' => $c->id,
'label' => $c->getFullName().($c->email ? " ({$c->email})" : ''),
'is_primary' => $pivotData[$c->id]->is_primary ?? false,
'role' => $pivotData[$c->id]->role ?? null,
])
);
}
return response()->json(
$contacts->map(fn ($c) => [
'value' => $c->id,

View File

@@ -147,8 +147,8 @@ class SettingsController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
$q->where('name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
}
@@ -917,11 +917,11 @@ class SettingsController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('event', 'like', "%{$search}%")
$q->where('description', 'ilike', "%{$search}%")
->orWhere('event', 'ilike', "%{$search}%")
->orWhereHas('user', function ($userQuery) use ($search) {
$userQuery->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
$userQuery->where('name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
});
}
@@ -1123,11 +1123,11 @@ class SettingsController extends Controller
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhere('event', 'like', "%{$search}%")
$q->where('description', 'ilike', "%{$search}%")
->orWhere('event', 'ilike', "%{$search}%")
->orWhereHas('user', function ($userQuery) use ($search) {
$userQuery->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
$userQuery->where('name', 'ilike', "%{$search}%")
->orWhere('email', 'ilike', "%{$search}%");
});
});
}

View File

@@ -93,7 +93,7 @@ class InterBusinessSettlement extends Model
return DB::transaction(function () use ($parentBusinessId, $prefix) {
$lastSettlement = static::where('parent_business_id', $parentBusinessId)
->where('settlement_number', 'like', "{$prefix}%")
->where('settlement_number', 'ilike', "{$prefix}%")
->orderByDesc('settlement_number')
->lockForUpdate()
->first();

View File

@@ -204,7 +204,7 @@ class JournalEntry extends Model implements AuditableContract
// Get the last entry for this business+day, ordered by entry_number descending
// Lock the row to serialize concurrent access (PostgreSQL-safe)
$lastEntry = static::where('business_id', $businessId)
->where('entry_number', 'like', "{$prefix}%")
->where('entry_number', 'ilike', "{$prefix}%")
->orderByDesc('entry_number')
->lockForUpdate()
->first();

View File

@@ -158,7 +158,7 @@ class Activity extends Model
*/
public function scopeOfTypeGroup($query, string $prefix)
{
return $query->where('type', 'like', $prefix.'%');
return $query->where('type', 'ilike', $prefix.'%');
}
/**

View File

@@ -151,7 +151,7 @@ class Address extends Model
public function scopeInCity($query, string $city)
{
return $query->where('city', 'like', "%{$city}%");
return $query->where('city', 'ilike', "%{$city}%");
}
// Helper Methods

View File

@@ -104,7 +104,7 @@ class AiContentRule extends Model
public function scopeForContext(Builder $query, string $context): Builder
{
return $query->where('content_type_key', 'like', $context.'.%');
return $query->where('content_type_key', 'ilike', $context.'.%');
}
// ========================================

View File

@@ -8,6 +8,7 @@ use DateTime;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -134,6 +135,17 @@ class Contact extends Model
return $this->belongsTo(Location::class);
}
/**
* Locations this contact is assigned to via the location_contact pivot table.
* This is the many-to-many relationship for location-specific contact assignments.
*/
public function locations(): BelongsToMany
{
return $this->belongsToMany(Location::class, 'location_contact')
->withPivot(['role', 'is_primary', 'notes'])
->withTimestamps();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);

View File

@@ -87,7 +87,7 @@ class CrmInternalNote extends Model
foreach (array_unique($matches[1]) as $username) {
$user = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $this->business_id))
->where('name', 'like', "%{$username}%")
->where('name', 'ilike', "%{$username}%")
->first();
if ($user && $user->id !== $this->user_id) {

View File

@@ -47,6 +47,7 @@ class CrmQuote extends Model
'quote_number',
'title',
'status',
'quote_date',
'subtotal',
'discount_type',
'discount_value',
@@ -83,6 +84,7 @@ class CrmQuote extends Model
'tax_amount' => 'decimal:2',
'total' => 'decimal:2',
'signature_requested' => 'boolean',
'quote_date' => 'date',
'valid_until' => 'date',
'sent_at' => 'datetime',
'viewed_at' => 'datetime',

View File

@@ -6,6 +6,7 @@ use App\Traits\BelongsToBusinessDirectly;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -94,6 +95,12 @@ class Location extends Model
'transferred_to_business_id', // For ownership transfers
'settings', // JSON
'notes',
// CannaiQ Integration
'cannaiq_platform',
'cannaiq_store_slug',
'cannaiq_store_id',
'cannaiq_store_name',
];
protected $casts = [
@@ -122,11 +129,57 @@ class Location extends Model
return $this->hasMany(License::class);
}
public function contacts(): HasMany
/**
* Contacts directly associated with this location (location_id on contact)
*/
public function directContacts(): HasMany
{
return $this->hasMany(Contact::class);
}
/**
* Contacts assigned to this location via pivot with roles
*/
public function contacts(): BelongsToMany
{
return $this->belongsToMany(Contact::class, 'location_contact')
->withPivot(['role', 'is_primary', 'notes'])
->withTimestamps();
}
/**
* Get contacts with a specific role for this location
*/
public function contactsByRole(string $role)
{
return $this->contacts()->wherePivot('role', $role);
}
/**
* Get the primary buyer contact for this location
*/
public function getPrimaryBuyer()
{
return $this->contacts()
->wherePivot('role', 'buyer')
->wherePivot('is_primary', true)
->first();
}
/**
* Get buyer names as a comma-separated label
*/
public function getBuyersLabelAttribute(): ?string
{
$buyers = $this->contacts()->wherePivot('role', 'buyer')->get();
if ($buyers->isEmpty()) {
return null;
}
return $buyers->map(fn ($c) => $c->getFullName())->implode(', ');
}
public function addresses(): MorphMany
{
return $this->morphMany(Address::class, 'addressable');
@@ -250,4 +303,25 @@ class Location extends Model
'archived_reason' => null,
]);
}
/**
* Check if this location has CannaiQ store mapping
*/
public function hasCannaiqMapping(): bool
{
return ! empty($this->cannaiq_store_slug);
}
/**
* Clear the CannaiQ store mapping
*/
public function clearCannaiqMapping(): void
{
$this->update([
'cannaiq_platform' => null,
'cannaiq_store_slug' => null,
'cannaiq_store_id' => null,
'cannaiq_store_name' => null,
]);
}
}

View File

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

View File

@@ -311,5 +311,38 @@ class AppServiceProvider extends ServiceProvider
// Department/permission-based access
return $user->hasPermission('manage_bom');
});
// Team Management Gate - Manager-only access for team dashboards
Gate::define('manage-team', function (User $user, ?Business $business = null) {
// Get business from route if not provided
$business = $business ?? request()->route('business');
if (! $business) {
return false;
}
// Business Owner always has access
if ($user->id === $business->owner_user_id) {
return true;
}
// Super admin has access
if ($user->hasRole('super-admin')) {
return true;
}
// Check if user is a manager in any department for this business
$userDepartments = $user->departments ?? collect();
if ($userDepartments->where('pivot.role', 'manager')->isNotEmpty()) {
return true;
}
// Check role-based access
if (in_array($user->role, ['admin', 'manager'])) {
return true;
}
return false;
});
}
}

View File

@@ -694,7 +694,7 @@ class AccountingReportingService
->active()
->where(function ($q) {
$q->where('account_subtype', 'cash')
->orWhere('name', 'like', '%Cash%');
->orWhere('name', 'ilike', '%Cash%');
})
->get();

View File

@@ -87,10 +87,10 @@ class CashFlowForecastService
->where(function ($q) {
$q->where('account_subtype', 'cash')
->orWhere('account_subtype', 'bank')
->orWhere('name', 'like', '%cash%')
->orWhere('name', 'like', '%bank%')
->orWhere('name', 'like', '%checking%')
->orWhere('name', 'like', '%savings%');
->orWhere('name', 'ilike', '%cash%')
->orWhere('name', 'ilike', '%bank%')
->orWhere('name', 'ilike', '%checking%')
->orWhere('name', 'ilike', '%savings%');
})
->active()
->postable()

View File

@@ -294,7 +294,7 @@ class ExpenseService
$month = now()->format('m');
$lastExpense = Expense::where('business_id', $business->id)
->where('expense_number', 'like', "{$prefix}-{$year}{$month}-%")
->where('expense_number', 'ilike', "{$prefix}-{$year}{$month}-%")
->orderByDesc('id')
->first();

View File

@@ -411,7 +411,7 @@ class RecurringSchedulerService
$month = now()->format('m');
$last = ArInvoice::where('business_id', $business->id)
->where('invoice_number', 'like', "{$prefix}-{$year}{$month}-%")
->where('invoice_number', 'ilike', "{$prefix}-{$year}{$month}-%")
->orderByDesc('id')
->first();
@@ -433,7 +433,7 @@ class RecurringSchedulerService
$month = now()->format('m');
$last = ApBill::where('business_id', $business->id)
->where('bill_number', 'like', "{$prefix}-{$year}{$month}-%")
->where('bill_number', 'ilike', "{$prefix}-{$year}{$month}-%")
->orderByDesc('id')
->first();
@@ -455,7 +455,7 @@ class RecurringSchedulerService
$month = now()->format('m');
$last = JournalEntry::where('business_id', $business->id)
->where('entry_number', 'like', "{$prefix}-{$year}{$month}-%")
->where('entry_number', 'ilike', "{$prefix}-{$year}{$month}-%")
->orderByDesc('id')
->first();

View File

@@ -75,7 +75,7 @@ class BuyerContextBuilder extends BaseContextBuilder
'name' => $contact->full_name,
'email' => $contact->email,
'phone' => $contact->phone,
'company' => $contact->company_name ?? $contact->business?->name,
'company' => $contact->business?->name,
'tags' => $contact->tags ?? [],
'lifecycle_stage' => $contact->lifecycle_stage ?? 'lead',
'created_at' => $contact->created_at->format('Y-m-d'),

View File

@@ -1083,7 +1083,7 @@ class AdvancedV3IntelligenceService
$hasEngagement = CrmThread::where('business_id', $business->id)
->whereHas('account', function ($q) use ($storeId) {
$q->where('external_id', $storeId)
->orWhere('name', 'like', "%{$storeId}%");
->orWhere('name', 'ilike', "%{$storeId}%");
})
->where('last_message_at', '>=', now()->subDays(30))
->exists();
@@ -1097,7 +1097,7 @@ class AdvancedV3IntelligenceService
->where('created_at', '>=', now()->subDays(90))
->whereHas('buyer', function ($q) use ($storeId) {
$q->where('external_id', $storeId)
->orWhere('name', 'like', "%{$storeId}%");
->orWhere('name', 'ilike', "%{$storeId}%");
})
->exists();

View File

@@ -88,6 +88,46 @@ class CannaiqClient
}
}
/**
* Search stores by name/query
*
* @param string $platform Platform to filter by (dutchie, jane, etc)
* @param string $query Search query for store name
* @param int $limit Max results
*/
public function searchStores(string $platform, string $query, int $limit = 20): array
{
try {
$response = $this->http->get('/stores', [
'platform' => $platform,
'q' => $query,
'limit' => $limit,
]);
if ($response->successful()) {
$data = $response->json();
return $data['stores'] ?? $data;
}
Log::warning('CannaiQ: Failed to search stores', [
'platform' => $platform,
'query' => $query,
'status' => $response->status(),
]);
return [];
} catch (\Exception $e) {
Log::error('CannaiQ: Exception searching stores', [
'platform' => $platform,
'query' => $query,
'error' => $e->getMessage(),
]);
return [];
}
}
/**
* Get store details
*

View File

@@ -164,10 +164,10 @@ class ContactService
{
return $company->contacts()
->where(function ($q) use ($query) {
$q->where('first_name', 'like', "%{$query}%")
->orWhere('last_name', 'like', "%{$query}%")
->orWhere('email', 'like', "%{$query}%")
->orWhere('phone', 'like', "%{$query}%");
$q->where('first_name', 'ilike', "%{$query}%")
->orWhere('last_name', 'ilike', "%{$query}%")
->orWhere('email', 'ilike', "%{$query}%")
->orWhere('phone', 'ilike', "%{$query}%");
})
->with('user.roles')
->get();

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Services\Dashboard;
/**
* Data Transfer Object for Command Center dashboard
*
* All dashboard data flows through this single DTO.
* Each section declares its scope (business|brand|user).
*/
class CommandCenterData
{
public function __construct(
// KPI Strip (8 cards)
public readonly array $kpis,
// Main Panel Sections
public readonly array $pipelineSnapshot,
public readonly array $ordersTable,
public readonly array $intelligenceCards,
// Right Rail Sections
public readonly array $salesInbox,
public readonly array $orchestratorWidget,
public readonly array $activityFeed,
// Metadata
public readonly string $currentScope,
public readonly ?string $scopeLabel,
public readonly \DateTimeInterface $generatedAt,
) {}
/**
* Create from array (for Redis deserialization)
*/
public static function fromArray(array $data): self
{
return new self(
kpis: $data['kpis'] ?? [],
pipelineSnapshot: $data['pipeline_snapshot'] ?? [],
ordersTable: $data['orders_table'] ?? [],
intelligenceCards: $data['intelligence_cards'] ?? [],
salesInbox: $data['sales_inbox'] ?? [],
orchestratorWidget: $data['orchestrator_widget'] ?? [],
activityFeed: $data['activity_feed'] ?? [],
currentScope: $data['current_scope'] ?? 'business',
scopeLabel: $data['scope_label'] ?? null,
generatedAt: isset($data['generated_at'])
? \Carbon\Carbon::parse($data['generated_at'])
: now(),
);
}
/**
* Convert to array (for Redis serialization)
*/
public function toArray(): array
{
return [
'kpis' => $this->kpis,
'pipeline_snapshot' => $this->pipelineSnapshot,
'orders_table' => $this->ordersTable,
'intelligence_cards' => $this->intelligenceCards,
'sales_inbox' => $this->salesInbox,
'orchestrator_widget' => $this->orchestratorWidget,
'activity_feed' => $this->activityFeed,
'current_scope' => $this->currentScope,
'scope_label' => $this->scopeLabel,
'generated_at' => $this->generatedAt->toIso8601String(),
];
}
/**
* Create empty state for when no data exists
*/
public static function empty(string $scope = 'business', ?string $scopeLabel = null): self
{
return new self(
kpis: self::emptyKpis(),
pipelineSnapshot: [],
ordersTable: [],
intelligenceCards: [],
salesInbox: ['overdue' => [], 'upcoming' => [], 'messages' => []],
orchestratorWidget: ['enabled' => false, 'targets' => [], 'promo_opportunities' => []],
activityFeed: [],
currentScope: $scope,
scopeLabel: $scopeLabel,
generatedAt: now(),
);
}
/**
* Empty KPI structure
*/
private static function emptyKpis(): array
{
return [
'revenue_mtd' => ['value' => 0, 'change' => 0, 'scope' => 'business'],
'orders_mtd' => ['value' => 0, 'change' => 0, 'scope' => 'business'],
'pipeline_value' => ['value' => 0, 'change' => 0, 'scope' => 'business'],
'won_mtd' => ['value' => 0, 'change' => 0, 'scope' => 'business'],
'active_buyers' => ['value' => 0, 'change' => 0, 'scope' => 'business'],
'hot_accounts' => ['value' => 0, 'change' => 0, 'scope' => 'business'],
'open_tasks' => ['value' => 0, 'change' => 0, 'scope' => 'user'],
'sla_compliance' => ['value' => 100, 'change' => 0, 'scope' => 'business'],
];
}
}

View File

@@ -0,0 +1,530 @@
<?php
namespace App\Services\Dashboard;
use App\Http\Controllers\Seller\BrandSwitcherController;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Business;
use App\Models\Crm\CrmDeal;
use App\Models\Crm\CrmMeetingBooking;
use App\Models\Crm\CrmTask;
use App\Models\Crm\CrmThread;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\User;
use App\Services\Crm\CrmSlaService;
use Illuminate\Support\Facades\Redis;
/**
* Command Center Service - Single source of truth for dashboard metrics
*
* Architecture:
* - DB/Service is the source of truth
* - Redis is used as a cache layer only
* - Each metric declares its scope: business|brand|user
*/
class CommandCenterService
{
private const CACHE_TTL = 300; // 5 minutes
public function __construct(
protected CrmSlaService $slaService,
protected OrchestratorWidgetService $orchestratorService,
) {}
/**
* Get complete Command Center data for a business
*
* @param Business $business The business context
* @param User $user The current user (for user-scoped metrics)
* @param bool $forceRefresh Skip cache and compute fresh
*/
public function getData(Business $business, User $user, bool $forceRefresh = false): CommandCenterData
{
$cacheKey = $this->getCacheKey($business, $user);
// Try cache first (unless forcing refresh)
if (! $forceRefresh) {
$cached = Redis::get($cacheKey);
if ($cached) {
return CommandCenterData::fromArray(json_decode($cached, true));
}
}
// Compute fresh data from DB/services
$data = $this->computeData($business, $user);
// Cache for next request
Redis::setex($cacheKey, self::CACHE_TTL, json_encode($data->toArray()));
return $data;
}
/**
* Invalidate cache for a business
*/
public function invalidateCache(Business $business, ?User $user = null): void
{
if ($user) {
Redis::del($this->getCacheKey($business, $user));
} else {
// Invalidate all user caches for this business
$pattern = "command_center:{$business->id}:*";
$keys = Redis::keys($pattern);
if (! empty($keys)) {
Redis::del(...$keys);
}
}
}
/**
* Compute all dashboard data from DB/services
*/
protected function computeData(Business $business, User $user): CommandCenterData
{
// Determine current scope from BrandSwitcher
$brandIds = BrandSwitcherController::getFilteredBrandIds();
$allBrandIds = $business->brands()->pluck('id')->toArray();
$isAllBrands = count($brandIds) === count($allBrandIds);
$currentScope = $isAllBrands ? 'business' : 'brand';
$scopeLabel = $isAllBrands
? 'All brands'
: $business->brands()->whereIn('id', $brandIds)->pluck('name')->implode(', ');
return new CommandCenterData(
kpis: $this->computeKpis($business, $user, $brandIds),
pipelineSnapshot: $this->computePipelineSnapshot($business),
ordersTable: $this->computeOrdersTable($business, $brandIds),
intelligenceCards: $this->computeIntelligenceCards($business),
salesInbox: $this->computeSalesInbox($business, $user, $brandIds),
orchestratorWidget: $this->orchestratorService->getWidgetData($business),
activityFeed: $this->computeActivityFeed($business, $brandIds),
currentScope: $currentScope,
scopeLabel: $scopeLabel,
generatedAt: now(),
);
}
/**
* Compute KPI strip metrics (8 cards)
*/
protected function computeKpis(Business $business, User $user, array $brandIds): array
{
$brandNames = $business->brands()->whereIn('id', $brandIds)->pluck('name')->toArray();
$now = now();
$startOfMonth = $now->copy()->startOfMonth();
$startOfLastMonth = $now->copy()->subMonth()->startOfMonth();
$endOfLastMonth = $now->copy()->subMonth()->endOfMonth();
// Revenue MTD (scope: business, filtered by brand)
$revenueMtd = $this->getOrderRevenue($brandNames, $startOfMonth, $now);
$revenueLastMonth = $this->getOrderRevenue($brandNames, $startOfLastMonth, $endOfLastMonth);
$revenueChange = $revenueLastMonth > 0
? round((($revenueMtd - $revenueLastMonth) / $revenueLastMonth) * 100, 1)
: 0;
// Orders MTD
$ordersMtd = $this->getOrderCount($brandNames, $startOfMonth, $now);
$ordersLastMonth = $this->getOrderCount($brandNames, $startOfLastMonth, $endOfLastMonth);
$ordersChange = $ordersLastMonth > 0
? round((($ordersMtd - $ordersLastMonth) / $ordersLastMonth) * 100, 1)
: 0;
// Pipeline Value (scope: business)
$pipelineStats = CrmDeal::forBusiness($business->id)
->open()
->selectRaw('SUM(value) as total_value, SUM(weighted_value) as weighted_value')
->first();
// Won MTD
$wonMtd = CrmDeal::forBusiness($business->id)
->won()
->whereMonth('actual_close_date', $now->month)
->whereYear('actual_close_date', $now->year)
->sum('value');
$wonLastMonth = CrmDeal::forBusiness($business->id)
->won()
->whereMonth('actual_close_date', $now->copy()->subMonth()->month)
->whereYear('actual_close_date', $now->copy()->subMonth()->year)
->sum('value');
$wonChange = $wonLastMonth > 0
? round((($wonMtd - $wonLastMonth) / $wonLastMonth) * 100, 1)
: 0;
// Active Buyers (scope: business)
$activeBuyers = Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->where('orders.created_at', '>=', $now->copy()->subDays(90))
->distinct('orders.business_id')
->count('orders.business_id');
// Hot Accounts (scope: business)
$hotAccounts = 0;
if (class_exists(BuyerEngagementScore::class)) {
$hotAccounts = BuyerEngagementScore::where('seller_business_id', $business->id)
->where('engagement_level', 'hot')
->count();
}
// Open Tasks (scope: user)
$openTasks = CrmTask::where('seller_business_id', $business->id)
->where('assigned_to', $user->id)
->whereNull('completed_at')
->count();
$overdueTasks = CrmTask::where('seller_business_id', $business->id)
->where('assigned_to', $user->id)
->whereNull('completed_at')
->where('due_at', '<', $now)
->count();
// SLA Compliance (scope: business)
$slaMetrics = $this->slaService->getMetrics($business->id, 30);
return [
'revenue_mtd' => [
'value' => $revenueMtd / 100, // Convert cents to dollars
'change' => $revenueChange,
'scope' => 'business',
'format' => 'currency',
],
'orders_mtd' => [
'value' => $ordersMtd,
'change' => $ordersChange,
'scope' => 'business',
'format' => 'number',
],
'pipeline_value' => [
'value' => ($pipelineStats->total_value ?? 0) / 100,
'weighted' => ($pipelineStats->weighted_value ?? 0) / 100,
'change' => 0, // No historical comparison for pipeline
'scope' => 'business',
'format' => 'currency',
],
'won_mtd' => [
'value' => $wonMtd / 100,
'change' => $wonChange,
'scope' => 'business',
'format' => 'currency',
],
'active_buyers' => [
'value' => $activeBuyers,
'change' => 0,
'scope' => 'business',
'format' => 'number',
],
'hot_accounts' => [
'value' => $hotAccounts,
'change' => 0,
'scope' => 'business',
'format' => 'number',
],
'open_tasks' => [
'value' => $openTasks,
'overdue' => $overdueTasks,
'change' => 0,
'scope' => 'user',
'format' => 'number',
],
'sla_compliance' => [
'value' => $slaMetrics['compliance_rate'] ?? 100,
'change' => 0,
'scope' => 'business',
'format' => 'percent',
],
];
}
/**
* Compute pipeline snapshot for deals kanban preview
*/
protected function computePipelineSnapshot(Business $business): array
{
$pipeline = \App\Models\Crm\CrmPipeline::where('business_id', $business->id)
->where('is_default', true)
->first();
if (! $pipeline) {
return [];
}
$stages = collect($pipeline->stages ?? []);
$deals = CrmDeal::forBusiness($business->id)
->open()
->with(['contact:id,name,email', 'account:id,name'])
->get()
->groupBy('stage_id');
return $stages->map(function ($stage, $index) use ($deals) {
$stageDeals = $deals->get($index, collect());
return [
'id' => $index,
'name' => $stage['name'] ?? "Stage {$index}",
'color' => $stage['color'] ?? 'gray',
'count' => $stageDeals->count(),
'value' => $stageDeals->sum('value') / 100,
'deals' => $stageDeals->take(3)->map(fn ($deal) => [
'id' => $deal->id,
'hashid' => $deal->hashid,
'name' => $deal->name,
'value' => $deal->value / 100,
'contact_name' => $deal->contact?->name ?? $deal->account?->name ?? 'Unknown',
])->toArray(),
];
})->toArray();
}
/**
* Compute recent orders table
*/
protected function computeOrdersTable(Business $business, array $brandIds): array
{
$brandNames = $business->brands()->whereIn('id', $brandIds)->pluck('name')->toArray();
$orders = Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->select('orders.*')
->distinct()
->with(['business:id,name,slug'])
->latest('orders.created_at')
->limit(10)
->get();
return $orders->map(fn ($order) => [
'id' => $order->id,
'order_number' => $order->order_number,
'business_name' => $order->business->name ?? 'Unknown',
'business_slug' => $order->business->slug ?? null,
'total' => $order->total / 100,
'status' => $order->status,
'created_at' => $order->created_at->toIso8601String(),
])->toArray();
}
/**
* Compute buyer intelligence cards
*/
protected function computeIntelligenceCards(Business $business): array
{
if (! class_exists(BuyerEngagementScore::class)) {
return [];
}
// Engagement distribution
$distribution = BuyerEngagementScore::where('seller_business_id', $business->id)
->selectRaw('engagement_level, COUNT(*) as count')
->groupBy('engagement_level')
->pluck('count', 'engagement_level')
->toArray();
// At-risk accounts (cold + declining)
$atRisk = BuyerEngagementScore::where('seller_business_id', $business->id)
->where('engagement_level', 'cold')
->with('buyerBusiness:id,name,slug')
->orderByDesc('days_since_last_activity')
->limit(5)
->get()
->map(fn ($score) => [
'buyer_id' => $score->buyer_business_id,
'buyer_name' => $score->buyerBusiness?->name ?? 'Unknown',
'buyer_slug' => $score->buyerBusiness?->slug ?? null,
'days_inactive' => $score->days_since_last_activity,
'engagement_level' => $score->engagement_level,
])
->toArray();
return [
'distribution' => [
'hot' => $distribution['hot'] ?? 0,
'warm' => $distribution['warm'] ?? 0,
'cold' => $distribution['cold'] ?? 0,
],
'at_risk' => $atRisk,
];
}
/**
* Compute sales inbox data
*/
protected function computeSalesInbox(Business $business, User $user, array $brandIds): array
{
$overdue = [];
$upcoming = [];
$messages = [];
// Overdue invoices
$overdueInvoices = Invoice::whereHas('order.items.product.brand', function ($query) use ($brandIds) {
$query->whereIn('id', $brandIds);
})
->where('payment_status', 'pending')
->where('due_date', '<', now())
->with(['business:id,name', 'order:id,order_number'])
->orderBy('due_date', 'asc')
->limit(5)
->get();
foreach ($overdueInvoices as $invoice) {
$overdue[] = [
'type' => 'invoice',
'label' => "Invoice {$invoice->invoice_number}",
'context' => $invoice->business->name ?? 'Unknown',
'age' => now()->diffInDays($invoice->due_date, false),
];
}
// Overdue tasks
$overdueTasks = CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->where('due_at', '<', now())
->with(['contact:id,first_name,last_name'])
->orderBy('due_at', 'asc')
->limit(5)
->get();
foreach ($overdueTasks as $task) {
$overdue[] = [
'type' => 'task',
'label' => $task->title,
'context' => $task->contact
? trim($task->contact->first_name.' '.$task->contact->last_name)
: null,
'age' => now()->diffInDays($task->due_at, false),
];
}
// Upcoming tasks (next 7 days)
$upcomingTasks = CrmTask::where('seller_business_id', $business->id)
->whereNull('completed_at')
->whereBetween('due_at', [now(), now()->addDays(7)])
->with(['contact:id,first_name,last_name'])
->orderBy('due_at', 'asc')
->limit(5)
->get();
foreach ($upcomingTasks as $task) {
$upcoming[] = [
'type' => 'task',
'label' => $task->title,
'context' => $task->contact
? trim($task->contact->first_name.' '.$task->contact->last_name)
: null,
'days_until' => abs(now()->diffInDays($task->due_at, false)),
];
}
// Upcoming meetings
$upcomingMeetings = CrmMeetingBooking::where('business_id', $business->id)
->where('status', 'scheduled')
->whereBetween('start_at', [now(), now()->addDays(7)])
->with(['contact:id,first_name,last_name'])
->orderBy('start_at', 'asc')
->limit(5)
->get();
foreach ($upcomingMeetings as $meeting) {
$upcoming[] = [
'type' => 'meeting',
'label' => $meeting->title ?? 'Meeting',
'context' => $meeting->contact
? trim($meeting->contact->first_name.' '.$meeting->contact->last_name)
: null,
'days_until' => abs(now()->diffInDays($meeting->start_at, false)),
];
}
// Unread messages
$unreadThreads = CrmThread::forBusiness($business->id)
->where('status', 'open')
->where('is_read', false)
->with(['contact:id,name,email'])
->orderBy('last_message_at', 'desc')
->limit(5)
->get();
foreach ($unreadThreads as $thread) {
$messages[] = [
'id' => $thread->id,
'hashid' => $thread->hashid,
'contact_name' => $thread->contact?->name ?? $thread->contact?->email ?? 'Unknown',
'preview' => $thread->last_message_preview ?? 'New message',
'time' => $thread->last_message_at?->diffForHumans() ?? 'Recently',
];
}
// Sort by urgency
usort($overdue, fn ($a, $b) => $a['age'] <=> $b['age']);
usort($upcoming, fn ($a, $b) => $a['days_until'] <=> $b['days_until']);
return [
'overdue' => $overdue,
'upcoming' => $upcoming,
'messages' => $messages,
];
}
/**
* Compute activity feed
*/
protected function computeActivityFeed(Business $business, array $brandIds): array
{
$activities = \App\Models\Activity::where('seller_business_id', $business->id)
->whereIn('type', [
'order.created',
'deal.stage_changed',
'deal.won',
'deal.lost',
'quote.sent',
'quote.accepted',
'thread.assigned',
'thread.closed',
])
->with('causer:id,name')
->latest()
->limit(20)
->get();
return $activities->map(fn ($activity) => [
'type' => $activity->type,
'description' => $activity->description,
'causer_name' => $activity->causer?->name ?? 'System',
'created_at' => $activity->created_at->toIso8601String(),
])->toArray();
}
/**
* Get order revenue for a period
*/
protected function getOrderRevenue(array $brandNames, $start, $end): int
{
return (int) Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$start, $end])
->sum('orders.total');
}
/**
* Get order count for a period
*/
protected function getOrderCount(array $brandNames, $start, $end): int
{
return Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$start, $end])
->distinct('orders.id')
->count('orders.id');
}
/**
* Generate cache key for business + user combination
*/
protected function getCacheKey(Business $business, User $user): string
{
$brandIds = BrandSwitcherController::getFilteredBrandIds();
$brandHash = md5(implode(',', $brandIds));
return "command_center:{$business->id}:{$user->id}:{$brandHash}";
}
}

View File

@@ -0,0 +1,219 @@
<?php
namespace App\Services\Dashboard;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Business;
use App\Models\Order;
use App\Models\PromoRecommendation;
use Illuminate\Support\Facades\Schema;
/**
* Orchestrator Widget Service
*
* Extracted from OrchestratorController to eliminate controller-to-controller calls.
* Provides widget data for the Command Center dashboard.
*/
class OrchestratorWidgetService
{
/**
* Get orchestrator widget data for dashboard
*/
public function getWidgetData(Business $business): array
{
if (! $this->hasRequiredModules($business)) {
return [
'enabled' => false,
'targets' => [],
'promo_opportunities' => [],
];
}
return [
'enabled' => true,
'targets' => $this->getTodaysTargets($business, 3),
'promo_opportunities' => $this->getPromoOpportunitiesForWidget($business),
];
}
/**
* Check if all required modules are enabled
*/
public function hasRequiredModules(Business $business): bool
{
// Check if business has Sales Suite assigned
if ($business->hasSalesSuite()) {
return true;
}
// Fallback: check if business has a suite with required features
return $business->hasSuiteFeature('crm')
&& $business->hasSuiteFeature('buyer_intelligence')
&& $business->hasSuiteFeature('copilot');
}
/**
* Get list of missing modules for the feature-disabled view
*/
public function getMissingModules(Business $business): array
{
if (! $business->hasSalesSuite()) {
return ['Sales Suite'];
}
$missing = [];
if (! $business->hasSuiteFeature('crm')) {
$missing[] = 'CRM';
}
if (! $business->hasSuiteFeature('buyer_intelligence')) {
$missing[] = 'Buyer Intelligence';
}
if (! $business->hasSuiteFeature('copilot')) {
$missing[] = 'AI Copilot';
}
return $missing;
}
/**
* Get today's target buyers ranked by engagement
*/
public function getTodaysTargets(Business $business, int $limit): array
{
$brandNames = $business->brands()->pluck('name')->toArray();
return BuyerEngagementScore::where('seller_business_id', $business->id)
->with('buyerBusiness')
->orderByRaw("CASE WHEN engagement_level = 'hot' THEN 1 WHEN engagement_level = 'warm' THEN 2 ELSE 3 END")
->orderByDesc('total_score')
->limit($limit)
->get()
->map(function ($score) use ($business, $brandNames) {
// Get last order for this buyer
$lastOrder = Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->where('orders.business_id', $score->buyer_business_id)
->whereIn('order_items.brand_name', $brandNames)
->select('orders.*')
->latest('orders.created_at')
->first();
// Get recent intent signals for this buyer
$recentSignals = IntentSignal::where('business_id', $business->id)
->where('buyer_business_id', $score->buyer_business_id)
->where('created_at', '>=', now()->subDays(7))
->count();
// Get recent product views
$recentViews = ProductView::whereHas('product.brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->where('viewer_business_id', $score->buyer_business_id)
->where('created_at', '>=', now()->subDays(7))
->count();
return [
'buyer_business_id' => $score->buyer_business_id,
'buyer_name' => $score->buyerBusiness->name ?? 'Unknown',
'engagement_level' => $score->engagement_level,
'total_score' => $score->total_score,
'last_activity_at' => $score->last_activity_at?->toIso8601String(),
'days_since_activity' => $score->days_since_last_activity,
'last_order_at' => $lastOrder?->created_at?->toIso8601String(),
'days_since_order' => $lastOrder ? now()->diffInDays($lastOrder->created_at) : null,
'recent_signals' => $recentSignals,
'recent_views' => $recentViews,
'suggested_action' => $this->suggestAction($score, $lastOrder),
];
})
->toArray();
}
/**
* Get promo opportunities summary for the dashboard widget
*/
public function getPromoOpportunitiesForWidget(Business $business): array
{
// Gracefully handle if promo_recommendations table doesn't exist yet
if (! Schema::hasTable('promo_recommendations')) {
return [
'high_priority_count' => 0,
'total_pending_count' => 0,
'top_recommendations' => [],
];
}
$highPriority = PromoRecommendation::where('business_id', $business->id)
->pending()
->notExpired()
->where('priority', 'high')
->count();
$totalPending = PromoRecommendation::where('business_id', $business->id)
->pending()
->notExpired()
->count();
// Get top 2 recommendations for preview
$topRecommendations = PromoRecommendation::where('business_id', $business->id)
->pending()
->notExpired()
->with(['product', 'brand'])
->orderByRaw("CASE WHEN priority = 'high' THEN 1 WHEN priority = 'medium' THEN 2 ELSE 3 END")
->orderByDesc('confidence')
->limit(2)
->get()
->map(function ($rec) {
return [
'id' => $rec->id,
'product_name' => $rec->product?->name ?? 'Bundle',
'brand_name' => $rec->brand?->name ?? 'Cross-Brand',
'type_label' => $rec->getTypeLabel(),
'priority' => $rec->priority,
];
})
->toArray();
return [
'high_priority_count' => $highPriority,
'total_pending_count' => $totalPending,
'top_recommendations' => $topRecommendations,
];
}
/**
* Suggest an action based on engagement and order history
*/
protected function suggestAction($engagementScore, $lastOrder): string
{
$daysSinceOrder = $lastOrder ? now()->diffInDays($lastOrder->created_at) : null;
if ($engagementScore->engagement_level === 'hot') {
if ($daysSinceOrder === null || $daysSinceOrder > 14) {
return 'Send personalized offer to convert high interest';
}
return 'Maintain relationship - send product updates';
}
if ($engagementScore->engagement_level === 'warm') {
if ($daysSinceOrder === null) {
return 'First order push - send intro offer';
}
if ($daysSinceOrder > 30) {
return 'Re-engagement needed - check in on needs';
}
return 'Nurture - share new products or promos';
}
// Cold
if ($daysSinceOrder !== null && $daysSinceOrder > 60) {
return 'Win-back campaign - special offer required';
}
return 'Needs attention - schedule discovery call';
}
}

View File

@@ -112,8 +112,8 @@ class OrchestratorCrossBrandService
// Find Thunder Bud parent brand
$thunderBud = Brand::where('business_id', $seller->id)
->where(function ($q) {
$q->where('name', 'like', '%Thunder Bud%')
->orWhere('slug', 'like', '%thunder-bud%');
$q->where('name', 'ilike', '%Thunder Bud%')
->orWhere('slug', 'ilike', '%thunder-bud%');
})
->whereNull('parent_brand_id') // Parent brand only
->first();
@@ -165,8 +165,8 @@ class OrchestratorCrossBrandService
// Find Hash Factory brand
$hashFactory = Brand::where('business_id', $seller->id)
->where(function ($q) {
$q->where('name', 'like', '%Hash Factory%')
->orWhere('slug', 'like', '%hash-factory%');
$q->where('name', 'ilike', '%Hash Factory%')
->orWhere('slug', 'ilike', '%hash-factory%');
})
->first();
@@ -311,11 +311,11 @@ class OrchestratorCrossBrandService
return DB::table('categories')
->where(function ($q) {
$q->where('name', 'like', '%concentrate%')
->orWhere('name', 'like', '%extract%')
->orWhere('name', 'like', '%hash%')
->orWhere('name', 'like', '%rosin%')
->orWhere('slug', 'like', '%concentrate%');
$q->where('name', 'ilike', '%concentrate%')
->orWhere('name', 'ilike', '%extract%')
->orWhere('name', 'ilike', '%hash%')
->orWhere('name', 'ilike', '%rosin%')
->orWhere('slug', 'ilike', '%concentrate%');
})
->pluck('id');
}

View File

@@ -29,23 +29,91 @@ class SuiteMenuResolver
*/
protected array $menuMap = [
// ═══════════════════════════════════════════════════════════════
// SALES SUITE ITEMS
// DASHBOARD SECTION (Single link)
// ═══════════════════════════════════════════════════════════════
'dashboard' => [
'label' => 'Dashboard',
'icon' => 'heroicon-o-home',
'route' => 'seller.business.dashboard',
'section' => 'Overview',
'section' => 'Dashboard',
'order' => 10,
'exact_match' => true, // Don't match seller.business.dashboard.* routes
],
'brands' => [
'label' => 'Brands',
'icon' => 'heroicon-o-building-storefront',
'route' => 'seller.business.brands.index',
'section' => 'Overview',
// ═══════════════════════════════════════════════════════════════
// CONNECT SECTION (Communications, Tasks, Calendar)
// ═══════════════════════════════════════════════════════════════
'connect_overview' => [
'label' => 'Overview',
'icon' => 'heroicon-o-inbox',
'route' => 'seller.business.messaging.index',
'section' => 'Connect',
'order' => 19,
],
'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.crm.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',
'label' => 'Accounts',
'icon' => 'heroicon-o-user-group',
'route' => 'seller.business.customers.index',
'section' => 'CRM',
'order' => 300,
'route' => 'seller.business.crm.accounts.index',
'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,
'route' => 'seller.business.crm.contacts.index',
'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,
'route' => 'seller.business.crm.contacts.index',
'section' => 'Connect',
'order' => 21,
],
'inbox_conversations' => [
'label' => 'Conversations',
'icon' => 'heroicon-o-chat-bubble-left-right',
'route' => 'seller.business.conversations.index',
'section' => 'Inbox',
'order' => 402,
'section' => 'Connect',
'order' => 22,
'requires_route' => true,
],
// NOTE: 'settings' removed from sidebar - access via user dropdown only

View File

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

View File

@@ -0,0 +1,45 @@
<?php
return [
['sku' => 'DK-CF-AZ0.5G', 'name' => 'Cake Face - 0.5G Hash Infused Doink', 'brand_slug' => 'doinks', 'desc' => ''],
['sku' => 'DK-CF-AZ1G', 'name' => 'Cake Face - 1G Hash Infused Doink', 'brand_slug' => 'doinks', 'desc' => ''],
['sku' => 'HF-T2-CD-AZ1G', 'name' => 'Chemdawg - Tier 2 - Live Hash Rosin', 'brand_slug' => 'hash-factory', 'desc' => null],
['sku' => 'HF-T2-JLL-AZ1G', 'name' => 'Juanita La Lagrimosa - Tier 2 - Live Hash Rosin', 'brand_slug' => 'hash-factory', 'desc' => null],
['sku' => 'HF-T2-WP-AZ1G', 'name' => 'Wedding Pie - Tier 2 - Live Hash Rosin', 'brand_slug' => 'hash-factory', 'desc' => 'Wedding Pie Live Hash Rosin is a sweet, solventless concentrate from the relaxing Wedding Pie strain. Pure, smooth, and packed with dessert-like flavors, it offers a calming, euphoric escape for ultimate relaxation.'],
['sku' => 'JV-HH-AZ05G', 'name' => 'Candy Fumez Solventless Live Rosin Cart', 'brand_slug' => 'just-vape', 'desc' => ''],
['sku' => 'OC-DR-AZ1G', 'name' => 'Dark Rainbow - 1G All Flower Preroll', 'brand_slug' => 'outlaw-cannabis', 'desc' => 'Dark Rainbow is a standout preroll designed to make your dispensary a destination for serious smokers. Packed with 29.77% THC and 35.46% total cannabinoids, this 1g roll delivers a euphoric, luxurious high that satisfies even the most demanding customers. Its 2.99% terpene profile bursts with layered notes of fruit, earth, and spice, creating a complex and unforgettable flavor experience. Limited-run batches make each preroll rare, and once it sells out, it could be months before it returns. Stocking Dark Rainbow prerolls puts your store among the few offering true connoisseur-level products, with scarcity driving urgency and premium appeal. Add Dark Rainbow to your menu and elevate your shelves and your sales.'],
['sku' => 'OG-1', 'name' => 'OG Khush - 1', 'brand_slug' => 'white-label-canna', 'desc' => ''],
['sku' => 'TB-BPC-AZ3G', 'name' => 'Banana Punch Cake - 3 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-BPC-AZ5G', 'name' => 'Banana Punch Cake - 5 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-CF-AZ3G', 'name' => 'Cake Face - 3 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-CF-AZ5G', 'name' => 'Cake Face - 5 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-F95-AZ3G', 'name' => 'Fam 95 - 3 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-F95-AZ5G', 'name' => 'Fam 95 - 5 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-HMS-AZ3G', 'name' => 'Hot Mint Sundae - 3 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-HMS-AZ5G', 'name' => 'Hot Mint Sundae - 5 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-IL-AZ1G', 'name' => 'Illemonati', 'brand_slug' => 'thunder-bud', 'desc' => 'description coming soon'],
['sku' => 'TB-I-TC-AZ1G', 'name' => 'Tropic Cake', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-MB-AZ3G', 'name' => 'Modified Banana - 5 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-MC-AZ3G', 'name' => 'Macaroons - 5 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-PG-AZ3G', 'name' => 'Plasma Gas - 3 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-PG-AZ5G', 'name' => 'Plasma Gas - 5 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-SS-AZ1G', 'name' => 'Singapore Sling - 3 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-VM-AZ3G', 'name' => 'Violet Meadows - 3 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-VM-AZ5G', 'name' => 'Violet Meadows - 3 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-WNLA-AZ3G', 'name' => 'Walkin-N-LA - 3 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'TB-WNLA-AZ5G', 'name' => 'Walkin-N-LA - 5 Pack', 'brand_slug' => 'thunder-bud', 'desc' => ''],
['sku' => 'WLC-GH-HFG', 'name' => 'Granulated Hash - Hybrid Food Grade', 'brand_slug' => 'white-label-canna', 'desc' => "Granulated Hash - Hybrid Food Grade\r\nBalanced | Versatile | Full-Spectrum\r\n\r\nThis Hybrid Food Grade Granulated Hash offers a balanced blend of uplifting and relaxing effects, perfect for versatile infusion use. Milled to a fine, sandy consistency, it delivers full-spectrum potency with rich cannabinoid and terpene content. Ideal for blending into pre-rolls, edibles, or solventless concentrates, this hash brings consistent performance and a smooth, flavorful finish to any product it's infused into."],
['sku' => 'WLC-LHR-H-FG', 'name' => 'Live Hash Rosin - Hybrid Food Grade', 'brand_slug' => 'white-label-canna', 'desc' => ''],
['sku' => 'WLC-LHR-I-FG', 'name' => 'Live Hash Rosin - Indica Food Grade', 'brand_slug' => 'white-label-canna', 'desc' => ''],
['sku' => 'WLC-LHR-S-FG', 'name' => 'Live Hash Rosin - Sativa Food Grade', 'brand_slug' => 'white-label-canna', 'desc' => "Sativa Food Grade Live Hash Rosin\r\nUplifting | Solventless\r\n\r\nCrafted from fresh frozen flower and processed without solvents, this Sativa Food Grade Live Hash Rosin delivers bright, energetic effects with a clean, citrus-forward terpene profile. Its smooth, pliable texture makes it ideal for infusions, vapes, or solventless formulations where clarity and flavor shine. Perfect for products designed to elevate and inspire."],
// Nuvata products
['sku' => 'NU-FB-WG', 'name' => 'Nuvata - Full Body - Wild Grape', 'brand_slug' => 'nuvata', 'desc' => "Those in search of full-bodied tranquility will enjoy the alleviating sensations this blend inspires. You will feel physical contentment wash through you, top to bottom, inside and out. The unique, wild grape flavoring creates an intriguing taste and full aroma without being overwhelming.\n\nThe terpene composition of this blend begins with myrcene, creating the dominant calming sensation. Caryophyllene contributes a warm feeling of uplift along with pinene, which enhances a sense of focus. Humulene and linalool work to provide an active sense of relaxation that inspires a uniquely present feeling of serenity.\n\nPotency Results: THC: 70.88%, CBD: 8.55%\nProminent Terpenes: Myrcene, caryophyllene, pinene, humulene, linalool\n*Individual batch testing on products may vary."],
['sku' => 'NU-FM-ST', 'name' => 'Nuvata - Full Mind - Strawberry', 'brand_slug' => 'nuvata', 'desc' => "Full Mind - Strawberry Flavor - Fuel Your Creativity\nCreative minds will find that this blend widens the lens through which inspiration passes while providing an active boost to motivation. The refreshing strawberry taste will leave you feeling clear and rejuvenated.\n\nYou will experience an uplifted sensation, due to the strong presence of the terpene caryophyllene. At the same time, limonene will engage your creative instincts, while myrcene and linalool introduce calming and relaxing effects. Humulene will also be present, reinforcing an active, doer's mindset.\n\nPotency Results: THC: 70.66%, CBD: 8.53%\nProminent Terpenes: Caryophyllene, limonene, myrcene, humulene, linalool\n*Individual batch testing on products may vary."],
['sku' => 'NU-MD-TA', 'name' => 'Nuvata - Mind Dominant - Tangerine', 'brand_slug' => 'nuvata', 'desc' => "Mind Dominant - Tangerine Flavor - Energize Your Being\nActive minds will find motivation and focus in this stimulating oil. It offers a boost to willpower and energizes the mind while also providing a sense of controlled calm. The tangerine citrus flavor is a pleasant jolt to the taste buds, enlivening the senses with its bright profile.\n\nThe terpene terpinolene sets a meditative tone and combines with caryophyllene for a sensation of uplifted reflection. Myrcene provides a calming sensation, while humulene and pinene keep your mind alert and focused, primed to experience your world in high definition.\n\nPotency Results: THC: 71.64%, CBD: 10.34%\nProminent Terpenes: Terpinolene, caryophyllene, myrcene, humulene, pinene\n*Individual batch testing on products may vary."],
['sku' => 'NU-MB-TR', 'name' => 'Nuvata - Mind Balance - Tropical', 'brand_slug' => 'nuvata', 'desc' => "Mind Balance - Tropical Flavor - Balance Your Energy\nAttentive minds will enjoy this hybrid's ability to deliver sharp, cognitive focus while calming the body and lifting the spirit. It's designed to make you feel present and at ease. With a bursting, tropical flavor profile, every inhale will bring to mind relaxing, exotic getaways.\n\nThe terpene myrcene lays a calming sensational foundation upon which pinene and caryophyllene then build perceptual focus and spiritual uplift. Limonene inspires creative introspection while humulene keeps the body feeling active, rounding out this blend's balanced feel.\n\nPotency Results: THC: 70.85%, CBD: 8.40%\nProminent Terpenes: Myrcene, pinene, caryophyllene, limonene, humulene\n*Individual batch testing on products may vary."],
['sku' => 'NU-BB-LM', 'name' => 'Nuvata - Body Balance - Lime', 'brand_slug' => 'nuvata', 'desc' => "Body Balance - Lime Flavor - Engage Your Body\n\nEnergetic and lively individuals will enjoy this hybrid's ability to lift the spirit and engage the body while soothing the mind. It delivers sharp focus housed within a tranquil state of being, and its lime flavor offers a citrus burst of cool refreshment.\n\nThe terpene caryophyllene sets this blend's uplifting tone, bolstered by humulene and limonene, which keep you feeling active and creatively stimulated. Linalool and myrcene introduce a relaxed feeling of calm to complete this blend's balanced feel.\n\nPotency Results: THC: 70.58%, CBD: 8.32%\nProminent Terpenes: Caryophyllene, humulene, limonene, linalool, myrcene\n*Individual batch testing on products may vary."],
['sku' => 'NU-BD-BB', 'name' => 'Nuvata - Body Dominant - Blueberry', 'brand_slug' => 'nuvata', 'desc' => "Body Dominant - Blueberry Flavor - Restore Your Form\n\nThose seeking peace for the mind and relaxation for the body will find this alleviating oil soothing and uplifting. It evokes a settled air of contentment and is ideal for feeling comfortable and at ease. A refreshing blueberry flavor floods the senses with a cool, calming aroma.\n\nThe terpene limonene sets a meditative tone by inspiring a sense of creativity, while myrcene provides a strong, calming effect. Caryophyllene and linalool keep the spirit uplifted and relaxed, and humulene injects a subtle hint of kinetic energy to keep you feeling engaged with your tranquility.\n\nPotency Results: THC: 71.82%, CBD: 8.53%\nProminent Terpenes: Limonene, myrcene, caryophyllene, linalool, humulene\n*Individual batch testing on products may vary."],
['sku' => 'NU-FL-AP', 'name' => 'Nuvata - Flow 1:1 - Apricot', 'brand_slug' => 'nuvata', 'desc' => "Flow 1:1 CBD:THC - Apricot Flavor - Find Your Flow\n\nEase into your inner peace with the harmonious blend of CBD and THC in our new Flow 1:1 CBD:THC - Apricot Flavor. This delicate balance of cannabinoids is designed to elevate your senses and calm your mind, while providing a feeling of physical comfort. The apricot flavor is a sweet and a juicy burst of refreshment that will captivate your taste buds and awaken your senses.\n\nAt the core of this blend lies the terpene myrcene, providing a foundation of deep relaxation and serenity. Terpinolene adds a meditative and introspective touch, while pinene brings a clear focus and heightened perception. Caryophyllene and linalool work together to lift your spirits and provide a warm, comforting sensation.\n\nCompact and portable, the Nuvata Flow Vape is perfect for those on-the-go, whether commuting, hiking, or relaxing at home. Find your flow and experience a sense of balance, peace, and clarity with every inhale.\n\nPotency Results: THC: 43.18%, CBD: 41.31%\nProminent Terpenes: Myrcene, Terpinolene, Pinene, Caryophyllene, and Linalool\n*Individual batch testing on products may vary."],
['sku' => 'NU-MBK', 'name' => 'Nuvata - Mind Body Kit', 'brand_slug' => 'nuvata', 'desc' => ''],
];

View File

@@ -0,0 +1,362 @@
<?php
return [
['sku' => 'HF-T2-GB-AZ1G', 'desc' => "Hash Factory: Garlic Breath Live Hash Rosin
Embrace the Flavor with Hash Factorys Garlic Breath! 🧄✨
Introducing Garlic Breath Live Hash Rosin, a top-shelf solventless concentrate that offers a uniquely bold flavor and a blissful high. Crafted from the distinctive Garlic Breath strain, this 100% pure, cold-pressed live hash rosin is made with meticulous care. Sustainably sourced and responsibly packaged by Arizonans, get ready for a rich and savory journey with every dab!
Key Features:
Balanced Hybrid: Garlic Breath provides an ideal mix of soothing relaxation and uplifting euphoria, perfect for winding down or sparking creativity. 🌿
Savory Delight: Experience a robust blend of pungent garlic, earthy undertones, and a hint of spice that will tantalize your taste buds. 🧄🌍
Aromatic Depth: The aroma envelops your senses with bold notes of garlic and herbal richness, creating an inviting and unique experience. 🍃
Euphoric Lift: Begins with a comforting wave of happiness that enhances mood and creativity, making it suitable for any time of day. 😊
Relaxing Calm: Transitions into gentle, soothing relaxation that melts away stress. 😌
Live Hash Rosin: Cold-pressed to maintain purity and flavor, ensuring a smooth experience. ❄️
Sustainably Sourced: An eco-friendly choice that respects our planet. 🌍
Garlic Breaths balanced effects promote a positive mood while keeping you relaxed, making it perfect for social gatherings or quiet evenings. Whether you\'re looking to unwind after a long day or explore your creative side, this strain is your go-to choice for a flavorful escape.
Grab your Hash Factory: Garlic Breath Live Hash Rosin today and savor the bold flavors. This premium rosin is your ticket to a savory, out-of-this-world experience. Let the good times roll with Hash Factory! 🧄✨"],
['sku' => 'HF-T1-MP-AZ1G', 'desc' => "Moroccan Peaches Live Hash Rosin 🍑✨
Introducing Moroccan Peaches Live Hash Rosin, a premium, solventless concentrate that elevates your cannabis experience to new heights of relaxation and euphoria. Derived from the hybrid Moroccan Peaches strain, this 100% pure, live hash rosin captures the perfect blend of sweet, peachy flavors and uplifting effects. Sustainably sourced and thoughtfully packaged, this rosin is your ticket to a blissful escape, whether you\'re unwinding after a busy day or seeking a creative boost. 🌱💨
Key Features:
Live Hash Rosin: Made from freshly harvested, frozen cannabis to preserve the plant\'s full terpene profile and potency, resulting in a clean, flavorful, and highly aromatic concentrate. ❄️🍯
Hybrid Strain: Moroccan Peaches offers a balanced experience, combining euphoric, mood-enhancing effects with a soothing body relaxation—ideal for a variety of occasions. 🌿
Deliciously Sweet Flavor: Indulge in a mouthwatering flavor profile of sweet peaches, with spicy, cinnamon-coated citrus notes—reminiscent of a tropical dessert you cant resist! 🍑🍊
Aromatic Bliss: The aroma is a fragrant blend of sweet peach, citrusy lemon, and earthy undertones, enveloping you in a cozy, comforting scent like a warm, tropical breeze. 🌸🍋
Euphoric Creativity: The effects start with a gentle wave of euphoria, sparking creativity and enhancing mood, followed by a soothing relaxation that leaves you calm without being sedative. 💡🌈
Perfect for Any Occasion: Whether you\'re looking to boost creativity, unwind after work, or enjoy a cozy evening, Moroccan Peaches Live Hash Rosin is the ideal companion for a vibrant and enjoyable experience. 🧘‍♀️🎨
Sustainably Sourced: An eco-friendly choice that respects our planet. 🌍
Moroccan Peaches Live Hash Rosin is perfect for those who want a flavorful, balanced, and uplifting experience without feeling overwhelmed. Its smooth blend of sweet and spicy flavors, combined with its versatile effects, makes it the ideal choice for any time of day.
Treat yourself to Moroccan Peaches Live Hash Rosin today and let its tropical flavors and euphoric, relaxing effects take you on a blissful journey. 🍑✨🌿"],
['sku' => 'HF-T1-WP-AZ1G', 'desc' => "Wedding Pie Live Hash Rosin 💍🍰✨
Introducing Wedding Pie Live Hash Rosin, a premium, solventless concentrate that promises to elevate your cannabis experience to new heights of relaxation and bliss. Derived from the indica dominant Wedding Pie strain, this 100% pure, live hash rosin captures the perfect balance of sweet, earthy flavors and potent effects. Sustainably sourced and thoughtfully packaged, this rosin is your invitation to indulge in a luxurious experience, whether you\'re unwinding after a long day or simply seeking a calming, euphoric escape.
Key Features:
Live Hash Rosin: Made from freshly harvested, frozen cannabis to preserve the plant\'s full terpene profile and potency, resulting in a clean, flavorful, and highly aromatic concentrate. ❄️🍯
Indica-Dominant Hybrid: Wedding Pie delivers a deeply relaxing, soothing experience with its calming effects. Its perfect for unwinding, evening use, and finding comfort after a busy day.
Deliciously Sweet Flavor: Indulge in a mouthwatering flavor profile of sweet vanilla, tart cherry, and creamy dough, reminiscent of a decadent slice of wedding cake. The dessert-like taste will leave you craving just one more hit! 🍒🍰🍦
Aromatic Bliss: The aroma is a warm, fragrant blend of vanilla, sweet berries, and earthy undertones that instantly envelops you in a comforting, rich scent. Its like being surrounded by freshly baked goods and blooming flowers. 🌸🍇🍪
Relaxing Euphoria: The effects begin with a gentle wave of euphoria, perfect for relieving stress and anxiety while enhancing mood. As the high progresses, a soothing body relaxation takes over, leaving you with a deep sense of calm without being overly sedative. Ideal for relaxing after work, watching a movie, or simply enjoying a cozy night in. 🛋️😌
Perfect for Evening Use: Whether you\'re looking to wind down, catch up with friends, or drift off into a peaceful sleep, Wedding Pie Live Hash Rosin is your ideal companion for a tranquil and blissful evening. 🌙💤
Sustainably Sourced: An eco-friendly choice that respects our planet. 🌍
Wedding Pie is the perfect strain for those who want a delicious, relaxing, and calming experience without feeling completely overwhelmed. Its smooth blend of sweet and earthy flavors, combined with its soothing effects, make it the ideal evening companion for those looking to unwind and indulge.
Treat yourself to Wedding Pie Live Hash Rosin today and immerse yourself in the luxurious, calming effects of this exceptional indica-dominant hybrid. Let the sweet, dessert-like flavors and deep relaxation take you on a journey of bliss and serenity. 💍🍰🌙"],
['sku' => 'HF-T1-SS-AZ1G', 'desc' => "Singapore Sling Live Hash Rosin 🍹✨
Introducing Singapore Sling Live Hash Rosin, a premium, solventless concentrate that will take your cannabis experience to a whole new level of tropical bliss. Derived from the exotic Singapore Sling strain, this 100% pure, live hash rosin captures the dynamic energy and rich flavors of its tropical lineage. Sustainably sourced and thoughtfully packaged, this rosin is your ticket to an adventure filled with vibrant creativity and refreshing relaxation, whether youre diving into a creative project or just enjoying a laid-back vibe.
Key Features:
Live Hash Rosin: Made from freshly harvested, frozen cannabis to preserve the plant\'s full terpene profile and potency, resulting in a clean, flavorful, and highly aromatic concentrate. ❄️🍯
Sativa-Dominant Hybrid: Singapore Sling delivers a burst of creative energy with a clear-headed, uplifting high. Its perfect for daytime use, spa
Tropical Flavor Symphony: Prepare your taste buds for a whirlwind of tropical flavors—notes of juicy pineapple, sweet citrus, tangy lychee, and a touch of zesty lime make every hit feel like sipping a refreshing tropical cocktail. 🍍🍋🍹
Aromatic Citrus Breeze: The aroma is an enticing mix of citrus zest, tropical fruits, and a touch of floral sweetness, evoking the scent of a breezy island escap
Creative Uplift: The effects begin with a cerebral rush, sparking creativity, focus, and a deep sense of motivation. Perfect for getting lost in art, brainstorming new ideas, or engaging in social conversations. You\'ll feel energized and mentally sharp, with a refreshing burst of happiness. 💭🌟🎨
Relaxing Balance: As the high continues, the effects shift into a gentle body relaxation, keeping you calm and comfortable without overwhelming sedation. Its the perfect balance f
Sustainably Sourced: An eco-friendly choice that respects our planet. 🌍
Singapore Sling is a sativa-dominant hybrid that perfectly balances vibrant creativity with soothing relaxation. Whether you\'re looking to dive into a new project, mingle with friends, or simply bask in the positive, tropical energy, this rosin is your ideal companion for a lively yet serene day.
Grab your Singapore Sling Live Hash Rosin today and experience the tropical burst of energy, creativity, and relaxation that this exotic sativa-dominant hybrid brings. Let the flavorful journey transport you to a world of sunshine and inspiration! 🍹🍍🌞"],
['sku' => 'HF-T1-SB-AZ1G', 'desc' => "Superboof Live Hash Rosin💥🔥✨
Introducing Superboof Live Hash Rosin, a premium solventless concentrate that packs as much punch as its name. Crafted from the potent Superboof strain, this 100% pure, cold-extracted live hash rosin captures the raw potency and intricate flavors of this unique hybrid. Sustainably sourced and responsibly packaged, this live hash rosin delivers an electrifying experience thats both smooth and powerful.
Key Features:
Hybrid Powerhouse: Superboof delivers a balanced hybrid experience that combines cerebral uplift with soothing body effects. The high starts with a burst of euphoria, elevating your mood and energy, before settling into a relaxing body buzz that helps you unwind without feeling overly sedated. ⚡🧠🛋️
Flavor Bomb: Bold, earthy flavors meet spicy, herbal notes with hints of sweet, citrusy zest—creating a rich, multi-layered profile that keeps you coming back for more. Each hit offers a deep, satisfying taste that lingers on your palate. 🍋🌿🔥
Aromatic Punch: The aroma is equally as intense, with pungent, earthy undertones and a sharp, citrusy zing that gives way to a touch of spicy sweetness. Its a fragrance that announces itself, leaving a lasting impression. 🌱🍊🌶️
Euphoric Burst: The effects kick off with a heady, uplifting euphoria that ignites creativity and focus. Perfect for creative pursuits or deep thought, it keeps you sharp and engaged while enhancing your mood. 💭🌟🎨
Body Relaxation: As the high continues, it gently transitions into a mellow body relaxation that soothes stress and tension, making it ideal for winding down after a busy day. It\'s not too heavy, leaving you feeling calm but still active. 🛋️😌
Live Hash Rosin: Cold-extracted from freshly frozen cannabis to maintain the highest purity and flavor, ensuring a smooth and refined experience with every dab. ❄️🍯
Sustainably Sourced: An eco-friendly choice that respects our planet. 🌍
Superboof is the hybrid you need when you want an experience that hits hard but doesnt weigh you down. With a burst of creative energy, followed by deep relaxation, this rosin offers versatility for any time of day, whether you need focus, stress relief, or a little bit of both.
Grab your Superboof Live Hash Rosin today and experience the powerful, multi-dimensional effects of this hybrid strain. Let the robust flavors and electrifying effects take you on an unforgettable ride. 🔥💥🌟"],
['sku' => 'HF-T1-TF-AZ1G', 'desc' => "Truffaloha Live Hash Rosin 🌴🍄✨
Introducing Truffaloha Live Hash Rosin, a premium solventless concentrate that will elevate your cannabis experience to vibrant, tropical heights. Derived from the sativa-dominant Truffaloha strain, this 100% pure, live hash rosin captures the invigorating energy and exotic flavors of its sun-soaked lineage. Sustainably sourced and responsibly packaged, this rosin is your passport to a tropical adventure—whether youre unleashing creativity or simply basking in the uplifting vibes of the islands.
Key Features:
Live Hash Rosin: Made from freshly harvested, frozen cannabis to preserve the plant\'s full terpene profile and potency, resulting in a clean, flavorful, and highly aromatic concentrate. ❄️🍯
Sativa-Dominant Hybrid: Truffaloha delivers a burst of energetic euphoria, perfect for daytime use, creativity, and boosting focus. Its the ideal strain to spark inspiration and keep you engaged, without leaving you feeling too sedated. 🌞🌴💡
Tropical Flavor Explosion: Get ready for a flavorful journey with notes of sweet pineapple, tangy mango, and creamy coconut, with a subtle hint of vanilla that rounds out the experience. It\'s like a tropical fruit cocktail in every hit! 🍍🥭🍦\\
Aromatic Island Breeze: The aroma is a fragrant blend of ripe tropical fruits, creamy vanilla, and a light, earthy sweetness, instantly transporting you to a beachside retreat. 🌺🍋🍃
Energetic Uplift: The effects start with a clear-headed, cerebral high that sparks creativity, motivation, and a deep sense of happiness—perfect for tackling projects, socializing, or simply enjoying a burst of positive energy. 💭🌟🎨
Smooth, Relaxed Vibes: As the high continues, it shifts into a gentle body relaxation that keeps you feeling at ease, without overwhelming sedation. Youll feel comfortable and chill—ideal for lounging or unwinding after an active day. 🏖️😌
Sustainably Sourced: An eco-friendly choice that respects our planet. 🌍
Truffaloha is a sativa-dominant hybrid that strikes the perfect balance between energetic, creative uplift and smooth, relaxing vibes. Whether you\'re looking to spark new ideas, socialize with friends, or just enjoy a burst of tropical sunshine, this rosin is your ultimate companion for any day that calls for a little extra brightness.
So, grab your Truffaloha Live Hash Rosin today and experience the tropical energy and uplifting effects of this exotic sativa-dominant hybrid. Let the vibrant flavors and clear-headed euphoria take you on a sensory journey to paradise! 🌴🍄🌞"],
['sku' => 'HF-T2-BS-AZ1G', 'desc' => '🍌🌴 Banana Shack: A Tropical Escape in Every Dab 🌴🍌
🌸🍓 Unleash the sweetness with Hash Factory: Frankenberry Delight! 🍓🌸
Introducing Banana Shack Live Hash Rosin, an indulgent concentrate that transports you straight to a tropical paradise! This premium, solventless creation is carefully crafted from the harmonious blend of Banana OG and Shackzilla strains, using only the finest, sustainably sourced plants. 100% pure, cold-pressed, and made with love in Arizona, this is the ultimate indulgence for connoisseurs.
Relaxing Hybrid: Banana Shack delivers a balanced high that brings you the perfect mix of uplifting energy and full-bodied relaxation. 🍃💆‍♀️🌞
Live Hash Rosin: Cold-pressed to maintain purity and flavor, ensuring a smooth experience. ❄️
Tropical Bliss: Get ready to savor a delightful fusion of creamy banana, sweet tropical fruit, and a whisper of earthy pine with every inhale. 🍌🌿🍍
Aromatic Journey: The aroma is an inviting mix of ripe bananas, tangy citrus, and subtle hints of vanilla and spice, making every session an aromatic experience. 🌺🍦
Sustainably Sourced: An eco-friendly choice that respects our planet. 🌍
The Banana Shack experience is truly unique. Banana OG brings its sweet, creamy banana flavor and calming effects, while Shackzilla adds a layer of earthy richness and stress-melting relaxation. Together, they create a perfect balance for enhancing your creativity, unwinding after a long day, or enjoying moments of tranquility in nature.
Banana Shack Live Hash Rosin isnt just a concentrate; its your ticket to tropical bliss in every dab. Whether youre winding down at home, looking to spark inspiration, or simply want to escape into a fruity, relaxing haze, Banana Shack has you covered.
Grab your Banana Shack Live Hash Rosin today and treat yourself to a lush, tropical adventure with every hit. Let the good vibes flow with Banana Shack!'],
['sku' => 'HF-T1-BS-AZ1G', 'desc' => '🍌🌴 Banana Shack: A Tropical Escape in Every Dab 🌴🍌
🌸🍓 Unleash the sweetness with Hash Factory: Frankenberry Delight! 🍓🌸
Introducing Banana Shack Live Hash Rosin, an indulgent concentrate that transports you straight to a tropical paradise! This premium, solventless creation is carefully crafted from the harmonious blend of Banana OG and Shackzilla strains, using only the finest, sustainably sourced plants. 100% pure, cold-pressed, and made with love in Arizona, this is the ultimate indulgence for connoisseurs.
Relaxing Hybrid: Banana Shack delivers a balanced high that brings you the perfect mix of uplifting energy and full-bodied relaxation. 🍃💆‍♀️🌞
Live Hash Rosin: Cold-pressed to maintain purity and flavor, ensuring a smooth experience. ❄️
Tropical Bliss: Get ready to savor a delightful fusion of creamy banana, sweet tropical fruit, and a whisper of earthy pine with every inhale. 🍌🌿🍍
Aromatic Journey: The aroma is an inviting mix of ripe bananas, tangy citrus, and subtle hints of vanilla and spice, making every session an aromatic experience. 🌺🍦
Sustainably Sourced: An eco-friendly choice that respects our planet. 🌍
The Banana Shack experience is truly unique. Banana OG brings its sweet, creamy banana flavor and calming effects, while Shackzilla adds a layer of earthy richness and stress-melting relaxation. Together, they create a perfect balance for enhancing your creativity, unwinding after a long day, or enjoying moments of tranquility in nature.
Banana Shack Live Hash Rosin isnt just a concentrate; its your ticket to tropical bliss in every dab. Whether youre winding down at home, looking to spark inspiration, or simply want to escape into a fruity, relaxing haze, Banana Shack has you covered.
Grab your Banana Shack Live Hash Rosin today and treat yourself to a lush, tropical adventure with every hit. Let the good vibes flow with Banana Shack!'],
['sku' => 'HF-T2-SS-AZ1G', 'desc' => "Singapore Sling Live Hash Rosin 🍹✨
Introducing Singapore Sling Live Hash Rosin, a premium, solventless concentrate that will take your cannabis experience to a whole new level of tropical bliss. Derived from the exotic Singapore Sling strain, this 100% pure, live hash rosin captures the dynamic energy and rich flavors of its tropical lineage. Sustainably sourced and thoughtfully packaged, this rosin is your ticket to an adventure filled with vibrant creativity and refreshing relaxation, whether youre diving into a creative project or just enjoying a laid-back vibe.
Key Features:
Live Hash Rosin: Made from freshly harvested, frozen cannabis to preserve the plant\'s full terpene profile and potency, resulting in a clean, flavorful, and highly aromatic concentrate. ❄️🍯
Sativa-Dominant Hybrid: Singapore Sling delivers a burst of creative energy with a clear-headed, uplifting high. Its perfect for daytime use, spa
Tropical Flavor Symphony: Prepare your taste buds for a whirlwind of tropical flavors—notes of juicy pineapple, sweet citrus, tangy lychee, and a touch of zesty lime make every hit feel like sipping a refreshing tropical cocktail. 🍍🍋🍹
Aromatic Citrus Breeze: The aroma is an enticing mix of citrus zest, tropical fruits, and a touch of floral sweetness, evoking the scent of a breezy island escap
Creative Uplift: The effects begin with a cerebral rush, sparking creativity, focus, and a deep sense of motivation. Perfect for getting lost in art, brainstorming new ideas, or engaging in social conversations. You\'ll feel energized and mentally sharp, with a refreshing burst of happiness. 💭🌟🎨
Relaxing Balance: As the high continues, the effects shift into a gentle body relaxation, keeping you calm and comfortable without overwhelming sedation. Its the perfect balance f
Sustainably Sourced: An eco-friendly choice that respects our planet. 🌍
Singapore Sling is a sativa-dominant hybrid that perfectly balances vibrant creativity with soothing relaxation. Whether you\'re looking to dive into a new project, mingle with friends, or simply bask in the positive, tropical energy, this rosin is your ideal companion for a lively yet serene day.
Grab your Singapore Sling Live Hash Rosin today and experience the tropical burst of energy, creativity, and relaxation that this exotic sativa-dominant hybrid brings. Let the flavorful journey transport you to a world of sunshine and inspiration! 🍹🍍🌞"],
['sku' => 'HF-T1-CD-AZ1G', 'desc' => "Chemdawg Live Hash Rosin 🚀🌿
Chemdawg Live Hash Rosin is a premium, solventless concentrate crafted for those who appreciate depth, potency, and full-bodied relaxation. Extracted from the legendary Chemdawg strain, this indica-dominant hybrid rosin is made from fresh frozen flower to preserve its intense aroma, rich terpene profile, and powerful effects. Smooth, clean, and flavorful, its the perfect way to unwind and sink into a calm, centered state of mind.
Key Features:
🌱 Live Hash Rosin
Pressed from fresh frozen cannabis flower to retain maximum terpene and cannabinoid content. No solvents, just pure, full-spectrum extract with bold flavor and a smooth finish.
🛋️ Indica-Dominant Hybrid
Chemdawg leans into its indica roots with a deeply relaxing body high, while still offering a clear-headed mental buzz. Great for easing stress, tension, and winding down at the end of the day.
Bold, Gassy Flavor Profile
Expect rich diesel notes layered with earthy pine and a slightly citrusy finish. This concentrate brings the classic Chemdawg funk with every hit.
👃 Pungent Aromatics
Sharp and skunky with hints of spice and sour fuel, Chemdawgs aroma is instantly recognizable and lingers in the best way.
🧘 Deep Relaxation with Mental Clarity
The high begins with a calming wave that relaxes the body without completely sedating the mind. Perfect for slowing down while staying grounded and present.
🌍 Eco-Friendly and Sustainably Sourced
Crafted with care using environmentally conscious practices from cultivation to extraction.
Chemdawg Live Hash Rosin offers a full-bodied, flavorful experience thats ideal for evening use or whenever youre looking to relax without losing your sense of awareness. Whether you\'re sinking into the couch after a long day or easing into a meditative state, this rosin delivers smooth, lasting relief with rich, nostalgic flavor.
Take it slow, breathe it in, and let Chemdawg guide you to a deeper level of calm."],
['sku' => 'HF-T2-CD-AZ1G', 'desc' => "Chemdawg Live Hash Rosin 🚀🌿
Chemdawg Live Hash Rosin is a premium, solventless concentrate crafted for those who appreciate depth, potency, and full-bodied relaxation. Extracted from the legendary Chemdawg strain, this indica-dominant hybrid rosin is made from fresh frozen flower to preserve its intense aroma, rich terpene profile, and powerful effects. Smooth, clean, and flavorful, its the perfect way to unwind and sink into a calm, centered state of mind.
Key Features:
🌱 Live Hash Rosin
Pressed from fresh frozen cannabis flower to retain maximum terpene and cannabinoid content. No solvents, just pure, full-spectrum extract with bold flavor and a smooth finish.
🛋️ Indica-Dominant Hybrid
Chemdawg leans into its indica roots with a deeply relaxing body high, while still offering a clear-headed mental buzz. Great for easing stress, tension, and winding down at the end of the day.
Bold, Gassy Flavor Profile
Expect rich diesel notes layered with earthy pine and a slightly citrusy finish. This concentrate brings the classic Chemdawg funk with every hit.
👃 Pungent Aromatics
Sharp and skunky with hints of spice and sour fuel, Chemdawgs aroma is instantly recognizable and lingers in the best way.
🧘 Deep Relaxation with Mental Clarity
The high begins with a calming wave that relaxes the body without completely sedating the mind. Perfect for slowing down while staying grounded and present.
🌍 Eco-Friendly and Sustainably Sourced
Crafted with care using environmentally conscious practices from cultivation to extraction.
Chemdawg Live Hash Rosin offers a full-bodied, flavorful experience thats ideal for evening use or whenever youre looking to relax without losing your sense of awareness. Whether you\'re sinking into the couch after a long day or easing into a meditative state, this rosin delivers smooth, lasting relief with rich, nostalgic flavor.
Take it slow, breathe it in, and let Chemdawg guide you to a deeper level of calm."],
['sku' => 'HF-T1-LCG-AZ1G', 'desc' => 'Hash Factory Lemon Cherry Gelato Live Hash Rosin 🍋🍒✨
Introducing Lemon Cherry Gelato Live Hash Rosin, a premium solventless concentrate that blends sweet, fruity flavors with a deeply relaxing high. Crafted from the Indica-dominant Lemon Cherry Gelato strain, this 100% pure, cold-pressed rosin delivers a soothing experience that balances mental clarity with physical relaxation. Sustainably sourced and responsibly packaged, this rosin is perfect for unwinding after a long day or enjoying a laid-back evening.
Key Features:
Indica-Dominant Hybrid: Lemon Cherry Gelato offers a perfect blend of relaxation and euphoria. The high starts with a gentle cerebral uplift that calms the mind, followed by a deeply soothing body buzz that melts away stress and tension, leaving you relaxed without feeling too heavy. 🍋🍒🛋️
Flavor Bliss: Enjoy the mouthwatering combination of tangy lemon and sweet cherry, with creamy vanilla undertones that add a rich, dessert-like finish. Its a refreshing, fruity treat thats as delicious as it is smooth. 🍓🍦🍊
Aromatic Comfort: The aroma is equally enticing, with bright citrus notes of lemon and cherry, balanced by creamy, sweet undertones. The scent is sweet and inviting, filling the air with a pleasant and comforting fragrance. 🌸🍋🍬
Relaxing Euphoria: The initial cerebral effects provide a gentle mood lift and a sense of happiness, which transitions into a relaxing body high that helps ease tension and promote a sense of calm. Perfect for relaxation, evening use, or winding down after a busy day. 🧘‍♀️💭😌
Smooth, Calming Body Buzz: As the high progresses, the indica-dominant effects provide a calming body buzz that doesnt weigh you down, but rather helps you fully unwind and let go of stress. Ideal for those looking to relax and soothe both mind and body. 🛋️😴
Live Hash Rosin: Cold-pressed to maintain the highest level of purity and flavor, ensuring a smooth and refined experience. ❄️🍯
Sustainably Sourced: An eco-friendly choice that respects our planet. 🌍
Lemon Cherry Gelato is the perfect indica-dominant hybrid for those who want a balanced yet soothing experience. Its sweet, citrusy flavor and relaxing effects make it ideal for evening relaxation, unwinding, or simply enjoying a peaceful, mellow mood.
So, grab your Lemon Cherry Gelato Live Hash Rosin today and enjoy the relaxing, flavorful journey this indica-dominant hybrid has to offer. Let the calming effects and delicious taste take you to a place of blissful serenity! 🍋🍒🌟'],
['sku' => 'HF-SB-T1-AZ1G', 'desc' => 'Hash Factory: Superboof Live Hash Rosin
Unleash the Fun with Hash Factorys Superboof! 🚀✨
Introducing Super Boof Live Hash Rosin, a premium solventless concentrate that packs a playful punch of flavor and relaxation. Crafted from the unique Superboof strain, this 100% pure, cold-pressed live hash rosin is made with care and precision. Sustainably sourced and responsibly packaged by Arizonans, get ready for a wild ride with every dab!
Key Features:
Hybrid Strain: Superboof delivers a delightful balance of uplifting euphoria and soothing relaxation, perfect for any occasion. 🌿
Flavorful Adventure: Indulge in the bold mix of sweet fruit and funky earthiness, creating an unforgettable taste experience. 🍉
Aromatic Delight: The aroma enchants with vibrant notes of tropical fruits and rich, earthy undertones. 🍃
Euphoric Vibes: Experience a burst of happiness that elevates your mood and encourages social interaction. 😊
Relaxing Comfort: Eases tension while promoting a sense of well-being, making it ideal for fun gatherings or relaxing nights in. 😌
Live Hash Rosin: Cold-pressed to maintain purity and flavor, ensuring a smooth experience. ❄️
Sustainably Sourced: An eco-friendly choice that respects our planet. 🌍
Superboofs dense, frosty buds showcase striking green and purple hues, adorned with a glistening layer of trichomes. The combination of sweet fruit and funky earthiness offers a rich and flavorful experience. Its uplifting effects encourage creativity and sociability, making it great for parties or artistic endeavors. Superboof is also effective for stress relief and enhancing overall mood.
So, grab your Hash Factory: Superboof Live Hash Rosin today and let the good times roll. This premium rosin is your ticket to a euphoric adventure like no other. Enjoy the ride with Hash Factory! 🚀✨'],
['sku' => 'HF-T2-WP-AZ1G', 'desc' => "Wedding Pie Live Hash Rosin 💍🍰✨
Introducing Wedding Pie Live Hash Rosin, a premium, solventless concentrate that promises to elevate your cannabis experience to new heights of relaxation and bliss. Derived from the indica dominant Wedding Pie strain, this 100% pure, live hash rosin captures the perfect balance of sweet, earthy flavors and potent effects. Sustainably sourced and thoughtfully packaged, this rosin is your invitation to indulge in a luxurious experience, whether you\'re unwinding after a long day or simply seeking a calming, euphoric escape.
Key Features:
Live Hash Rosin: Made from freshly harvested, frozen cannabis to preserve the plant\'s full terpene profile and potency, resulting in a clean, flavorful, and highly aromatic concentrate. ❄️🍯
Indica-Dominant Hybrid: Wedding Pie delivers a deeply relaxing, soothing experience with its calming effects. Its perfect for unwinding, evening use, and finding comfort after a busy day.
Deliciously Sweet Flavor: Indulge in a mouthwatering flavor profile of sweet vanilla, tart cherry, and creamy dough, reminiscent of a decadent slice of wedding cake. The dessert-like taste will leave you craving just one more hit! 🍒🍰🍦
Aromatic Bliss: The aroma is a warm, fragrant blend of vanilla, sweet berries, and earthy undertones that instantly envelops you in a comforting, rich scent. Its like being surrounded by freshly baked goods and blooming flowers. 🌸🍇🍪
Relaxing Euphoria: The effects begin with a gentle wave of euphoria, perfect for relieving stress and anxiety while enhancing mood. As the high progresses, a soothing body relaxation takes over, leaving you with a deep sense of calm without being overly sedative. Ideal for relaxing after work, watching a movie, or simply enjoying a cozy night in. 🛋️😌
Perfect for Evening Use: Whether you\'re looking to wind down, catch up with friends, or drift off into a peaceful sleep, Wedding Pie Live Hash Rosin is your ideal companion for a tranquil and blissful evening. 🌙💤
Sustainably Sourced: An eco-friendly choice that respects our planet. 🌍
Wedding Pie is the perfect strain for those who want a delicious, relaxing, and calming experience without feeling completely overwhelmed. Its smooth blend of sweet and earthy flavors, combined with its soothing effects, make it the ideal evening companion for those looking to unwind and indulge.
Treat yourself to Wedding Pie Live Hash Rosin today and immerse yourself in the luxurious, calming effects of this exceptional indica-dominant hybrid. Let the sweet, dessert-like flavors and deep relaxation take you on a journey of bliss and serenity. 💍🍰🌙"],
['sku' => 'HF-T2-WP-AZ1G', 'desc' => "Wedding Pie Live Hash Rosin 💍🍰✨
Introducing Wedding Pie Live Hash Rosin, a premium, solventless concentrate that promises to elevate your cannabis experience to new heights of relaxation and bliss. Derived from the indica dominant Wedding Pie strain, this 100% pure, live hash rosin captures the perfect balance of sweet, earthy flavors and potent effects. Sustainably sourced and thoughtfully packaged, this rosin is your invitation to indulge in a luxurious experience, whether you\'re unwinding after a long day or simply seeking a calming, euphoric escape.
Key Features:
Live Hash Rosin: Made from freshly harvested, frozen cannabis to preserve the plant\'s full terpene profile and potency, resulting in a clean, flavorful, and highly aromatic concentrate. ❄️🍯
Indica-Dominant Hybrid: Wedding Pie delivers a deeply relaxing, soothing experience with its calming effects. Its perfect for unwinding, evening use, and finding comfort after a busy day.
Deliciously Sweet Flavor: Indulge in a mouthwatering flavor profile of sweet vanilla, tart cherry, and creamy dough, reminiscent of a decadent slice of wedding cake. The dessert-like taste will leave you craving just one more hit! 🍒🍰🍦
Aromatic Bliss: The aroma is a warm, fragrant blend of vanilla, sweet berries, and earthy undertones that instantly envelops you in a comforting, rich scent. Its like being surrounded by freshly baked goods and blooming flowers. 🌸🍇🍪
Relaxing Euphoria: The effects begin with a gentle wave of euphoria, perfect for relieving stress and anxiety while enhancing mood. As the high progresses, a soothing body relaxation takes over, leaving you with a deep sense of calm without being overly sedative. Ideal for relaxing after work, watching a movie, or simply enjoying a cozy night in. 🛋️😌
Perfect for Evening Use: Whether you\'re looking to wind down, catch up with friends, or drift off into a peaceful sleep, Wedding Pie Live Hash Rosin is your ideal companion for a tranquil and blissful evening. 🌙💤
Sustainably Sourced: An eco-friendly choice that respects our planet. 🌍
Wedding Pie is the perfect strain for those who want a delicious, relaxing, and calming experience without feeling completely overwhelmed. Its smooth blend of sweet and earthy flavors, combined with its soothing effects, make it the ideal evening companion for those looking to unwind and indulge.
Treat yourself to Wedding Pie Live Hash Rosin today and immerse yourself in the luxurious, calming effects of this exceptional indica-dominant hybrid. Let the sweet, dessert-like flavors and deep relaxation take you on a journey of bliss and serenity. 💍🍰🌙"],
];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Null out all product description fields in preparation for MySQL import.
*/
public function up(): void
{
DB::table('products')->update([
'description' => null,
'consumer_long_description' => null,
'buyer_long_description' => null,
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Cannot restore - data is gone
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Import product descriptions from MySQL for all brands except Hash Factory.
* Data extracted from MySQL product_extras.long_description.
*/
public function up(): void
{
$data = require database_path('data/product_descriptions_non_hf.php');
foreach ($data as $row) {
DB::table('products')
->where('sku', $row['sku'])
->update([
'consumer_long_description' => $row['desc'],
'updated_at' => now(),
]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Cannot restore - original data unknown
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Import product descriptions from MySQL for Hash Factory brand.
* HF has marketing copy in products.description (not product_extras.long_description).
*/
public function up(): void
{
$data = require database_path('data/product_descriptions_hf.php');
foreach ($data as $row) {
DB::table('products')
->where('sku', $row['sku'])
->update([
'consumer_long_description' => $row['desc'],
'updated_at' => now(),
]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Cannot restore - original data unknown
}
};

View File

@@ -0,0 +1,62 @@
<?php
use App\Models\Brand;
use App\Models\Product;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
$data = require database_path('data/missing_products.php');
// Cache brand IDs by slug
$brandIds = Brand::whereIn('slug', array_unique(array_column($data, 'brand_slug')))
->pluck('id', 'slug')
->toArray();
foreach ($data as $row) {
$brandId = $brandIds[$row['brand_slug']] ?? null;
if (! $brandId) {
continue; // Skip if brand doesn't exist
}
// Check if SKU exists (including soft-deleted - unique constraint applies to all)
$existing = Product::withTrashed()->where('sku', $row['sku'])->first();
if ($existing) {
// If soft-deleted, restore it and update
if ($existing->trashed()) {
$existing->restore();
$existing->update([
'consumer_long_description' => $row['desc'] ?: null,
'is_active' => true,
'status' => 'available',
]);
}
continue;
}
Product::create([
'brand_id' => $brandId,
'sku' => $row['sku'],
'name' => $row['name'],
'slug' => Str::slug($row['name']),
'consumer_long_description' => $row['desc'] ?: null,
'is_active' => true,
'status' => 'available',
]);
}
}
public function down(): void
{
$data = require database_path('data/missing_products.php');
$skus = array_column($data, 'sku');
Product::whereIn('sku', $skus)->forceDelete();
}
};

View File

@@ -0,0 +1,44 @@
<?php
use App\Models\Product;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
// Collect all valid SKUs from MySQL source data
$validSkus = [];
// SKUs from non-HF descriptions import
$nonHfData = require database_path('data/product_descriptions_non_hf.php');
foreach ($nonHfData as $row) {
$validSkus[] = $row['sku'];
}
// SKUs from HF descriptions import
$hfData = require database_path('data/product_descriptions_hf.php');
foreach ($hfData as $row) {
$validSkus[] = $row['sku'];
}
// SKUs from missing products import
$missingData = require database_path('data/missing_products.php');
foreach ($missingData as $row) {
$validSkus[] = $row['sku'];
}
// Remove duplicates
$validSkus = array_unique($validSkus);
// Get orphan products (exist in PG but not in MySQL source)
// Soft-delete them, don't force delete
Product::whereNotIn('sku', $validSkus)->delete();
}
public function down(): void
{
// Cannot restore - would need backup of deleted products
// To restore: Product::onlyTrashed()->whereNotIn('sku', $validSkus)->restore();
}
};

View File

@@ -0,0 +1,23 @@
<?php
use App\Models\Product;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
// Backfill hashids for products that don't have one
Product::withTrashed()
->whereNull('hashid')
->orWhere('hashid', '')
->each(function ($product) {
$product->update(['hashid' => $product->generateHashid()]);
});
}
public function down(): void
{
// Cannot undo - hashids are permanent identifiers
}
};

View File

@@ -0,0 +1,78 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Fix encoding issues in product descriptions.
*
* The original import had mojibake/encoding corruption where emojis
* became "?" or "U+FFFD" (replacement char) characters. This migration
* removes those artifacts using direct SQL.
*/
public function up(): void
{
// Remove U+FFFD replacement character (chr(65533))
DB::statement("
UPDATE products
SET consumer_long_description = REPLACE(consumer_long_description, chr(65533), '')
WHERE consumer_long_description LIKE '%' || chr(65533) || '%'
");
// Remove ? at start of lines (was emoji headers)
DB::statement("
UPDATE products
SET consumer_long_description = REGEXP_REPLACE(consumer_long_description, E'^[?]\\s*', '', 'gm')
WHERE consumer_long_description LIKE '%?%'
");
// Remove ? at end of lines (was emoji endings)
DB::statement("
UPDATE products
SET consumer_long_description = REGEXP_REPLACE(consumer_long_description, E'\\s*[?]$', '', 'gm')
WHERE consumer_long_description LIKE '%?%'
");
// Normalize Windows line endings
DB::statement("
UPDATE products
SET consumer_long_description = REPLACE(consumer_long_description, E'\\r\\n', E'\\n')
WHERE consumer_long_description LIKE E'%\\r\\n%'
");
// Same for buyer_long_description
DB::statement("
UPDATE products
SET buyer_long_description = REPLACE(buyer_long_description, chr(65533), '')
WHERE buyer_long_description LIKE '%' || chr(65533) || '%'
");
DB::statement("
UPDATE products
SET buyer_long_description = REGEXP_REPLACE(buyer_long_description, E'^[?]\\s*', '', 'gm')
WHERE buyer_long_description LIKE '%?%'
");
DB::statement("
UPDATE products
SET buyer_long_description = REGEXP_REPLACE(buyer_long_description, E'\\s*[?]$', '', 'gm')
WHERE buyer_long_description LIKE '%?%'
");
DB::statement("
UPDATE products
SET buyer_long_description = REPLACE(buyer_long_description, E'\\r\\n', E'\\n')
WHERE buyer_long_description LIKE E'%\\r\\n%'
");
}
/**
* Cannot reverse encoding cleanup.
*/
public function down(): void
{
// Cannot reverse - original corrupt data is not preserved
}
};

View File

@@ -0,0 +1,85 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Add CannaiQ fields to locations
Schema::table('locations', function (Blueprint $table) {
$table->string('cannaiq_platform')->nullable()->after('notes'); // 'dutchie', 'jane', etc.
$table->string('cannaiq_store_slug')->nullable()->after('cannaiq_platform');
$table->string('cannaiq_store_id')->nullable()->after('cannaiq_store_slug');
$table->string('cannaiq_store_name')->nullable()->after('cannaiq_store_id');
});
// Create location_contact pivot table for location-specific contact roles
Schema::create('location_contact', function (Blueprint $table) {
$table->id();
$table->foreignId('location_id')->constrained()->onDelete('cascade');
$table->foreignId('contact_id')->constrained()->onDelete('cascade');
$table->string('role')->default('buyer'); // buyer, ap, marketing, gm, etc.
$table->boolean('is_primary')->default(false);
$table->text('notes')->nullable();
$table->timestamps();
$table->unique(['location_id', 'contact_id', 'role']);
});
// Add location_id to sales_opportunities
Schema::table('sales_opportunities', function (Blueprint $table) {
$table->foreignId('location_id')->nullable()->after('business_id')->constrained()->onDelete('set null');
});
// Add location_id to crm_events (notes/activity)
if (! Schema::hasColumn('crm_events', 'location_id')) {
Schema::table('crm_events', function (Blueprint $table) {
$table->foreignId('location_id')->nullable()->after('buyer_business_id')->constrained()->onDelete('set null');
});
}
// Add location_id to crm_tasks
if (! Schema::hasColumn('crm_tasks', 'location_id')) {
Schema::table('crm_tasks', function (Blueprint $table) {
$table->foreignId('location_id')->nullable()->after('business_id')->constrained()->onDelete('set null');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('locations', function (Blueprint $table) {
$table->dropColumn(['cannaiq_platform', 'cannaiq_store_slug', 'cannaiq_store_id', 'cannaiq_store_name']);
});
Schema::dropIfExists('location_contact');
Schema::table('sales_opportunities', function (Blueprint $table) {
$table->dropForeign(['location_id']);
$table->dropColumn('location_id');
});
if (Schema::hasColumn('crm_events', 'location_id')) {
Schema::table('crm_events', function (Blueprint $table) {
$table->dropForeign(['location_id']);
$table->dropColumn('location_id');
});
}
if (Schema::hasColumn('crm_tasks', 'location_id')) {
Schema::table('crm_tasks', function (Blueprint $table) {
$table->dropForeign(['location_id']);
$table->dropColumn('location_id');
});
}
}
};

View File

@@ -0,0 +1,34 @@
<?php
use App\Models\Product;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
// Fix all product descriptions - normalize line endings and decode HTML entities
Product::whereNotNull('consumer_long_description')
->each(function ($product) {
$desc = $product->consumer_long_description;
$original = $desc;
// Normalize Windows CRLF (chr 13 + chr 10) to Unix LF (chr 10)
$desc = str_replace("\r\n", "\n", $desc);
$desc = str_replace("\r", "\n", $desc);
// Decode HTML entities (emoji codes like &#127793;)
$desc = html_entity_decode($desc, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Only update if changed
if ($desc !== $original) {
$product->update(['consumer_long_description' => $desc]);
}
});
}
public function down(): void
{
// Cannot reliably undo formatting changes
}
};

View File

@@ -0,0 +1,23 @@
<?php
use App\Models\Batch;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
// Backfill hashids for batches that don't have one
Batch::withTrashed()
->whereNull('hashid')
->orWhere('hashid', '')
->each(function ($batch) {
$batch->update(['hashid' => $batch->generateHashid()]);
});
}
public function down(): void
{
// Cannot undo hashid generation
}
};

View File

@@ -0,0 +1,92 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Add missing columns to crm_quotes table.
*
* The model expects these columns but they weren't in the original migration:
* - signature_requested (was 'requires_signature' in migration)
* - signed_by_name (was 'signer_name' in migration)
* - signed_by_email (was 'signer_email' in migration)
* - signature_ip (was 'signer_ip' in migration)
* - rejection_reason
* - order_id
* - notes_customer
* - notes_internal
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('crm_quotes', function (Blueprint $table) {
// Signature fields - model uses different names than original migration
if (! Schema::hasColumn('crm_quotes', 'signature_requested')) {
$table->boolean('signature_requested')->default(false)->after('pdf_path');
}
if (! Schema::hasColumn('crm_quotes', 'signed_by_name')) {
$table->string('signed_by_name')->nullable()->after('signed_at');
}
if (! Schema::hasColumn('crm_quotes', 'signed_by_email')) {
$table->string('signed_by_email')->nullable()->after('signed_by_name');
}
if (! Schema::hasColumn('crm_quotes', 'signature_ip')) {
$table->string('signature_ip')->nullable()->after('signed_by_email');
}
// Additional fields expected by model
if (! Schema::hasColumn('crm_quotes', 'rejection_reason')) {
$table->text('rejection_reason')->nullable()->after('rejected_at');
}
if (! Schema::hasColumn('crm_quotes', 'order_id')) {
$table->foreignId('order_id')->nullable()->after('converted_to_order_id');
}
if (! Schema::hasColumn('crm_quotes', 'notes_customer')) {
$table->text('notes_customer')->nullable()->after('notes');
}
if (! Schema::hasColumn('crm_quotes', 'notes_internal')) {
$table->text('notes_internal')->nullable()->after('notes_customer');
}
});
// Add foreign key constraint for order_id separately if column exists
if (Schema::hasColumn('crm_quotes', 'order_id')) {
Schema::table('crm_quotes', function (Blueprint $table) {
// Only add constraint if it doesn't already exist
try {
$table->foreign('order_id')->references('id')->on('orders')->nullOnDelete();
} catch (\Exception $e) {
// Foreign key already exists
}
});
}
}
public function down(): void
{
Schema::table('crm_quotes', function (Blueprint $table) {
$table->dropForeignIfExists('crm_quotes_order_id_foreign');
});
Schema::table('crm_quotes', function (Blueprint $table) {
$columns = [
'signature_requested',
'signed_by_name',
'signed_by_email',
'signature_ip',
'rejection_reason',
'order_id',
'notes_customer',
'notes_internal',
];
foreach ($columns as $column) {
if (Schema::hasColumn('crm_quotes', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@@ -0,0 +1,48 @@
<?php
use App\Models\Product;
use Illuminate\Database\Migrations\Migration;
/**
* Fix product descriptions that have literal escape sequences.
*
* The data contains literal "\r\n" strings (4 characters: backslash, r, backslash, n)
* instead of actual carriage return and line feed characters.
* Also contains "??" instead of emojis due to encoding issues during import.
*/
return new class extends Migration
{
public function up(): void
{
// Fix all product descriptions with literal escape sequences
Product::whereNotNull('consumer_long_description')
->each(function ($product) {
$desc = $product->consumer_long_description;
$original = $desc;
// Replace literal "\r\n" string (4 chars) with actual newline
$desc = str_replace('\r\n', "\n", $desc);
// Replace literal "\r" and "\n" individually if they exist
$desc = str_replace('\r', '', $desc);
$desc = str_replace('\n', "\n", $desc);
// Remove "??" which are corrupted emoji placeholders
// These were emojis that got lost during encoding conversion
$desc = str_replace('??', '', $desc);
// Clean up double newlines that may result
$desc = preg_replace('/\n{3,}/', "\n\n", $desc);
// Only update if changed
if ($desc !== $original) {
$product->update(['consumer_long_description' => $desc]);
}
});
}
public function down(): void
{
// Cannot reliably undo - the original escape sequences are not recoverable
}
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Re-import product descriptions from MySQL with proper UTF-8 encoding.
*
* The original import had encoding issues that corrupted emojis to '?' or
* stripped them entirely. This migration overwrites consumer_long_description
* with properly encoded data that preserves emojis.
*/
return new class extends Migration
{
public function up(): void
{
// Import non-HF products
$nonHf = require database_path('data/product_descriptions_non_hf.php');
foreach ($nonHf as $row) {
DB::table('products')
->where('sku', $row['sku'])
->update([
'consumer_long_description' => $row['desc'],
'updated_at' => now(),
]);
}
echo 'Updated '.count($nonHf)." non-HF products\n";
// Import HF products
$hf = require database_path('data/product_descriptions_hf.php');
foreach ($hf as $row) {
DB::table('products')
->where('sku', $row['sku'])
->update([
'consumer_long_description' => $row['desc'],
'updated_at' => now(),
]);
}
echo 'Updated '.count($hf)." HF products\n";
}
public function down(): void
{
// Cannot restore - original corrupted data not preserved
}
};

View File

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

View File

@@ -7,9 +7,6 @@ metadata:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
# Ensure X-Forwarded-Proto is set correctly for HTTPS detection
nginx.ingress.kubernetes.io/configuration-snippet: |
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support for Reverb
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"

4435
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -20,6 +20,21 @@
display: none !important;
}
/* DaisyUI v5 compatibility - these classes were removed in v5 but are still used in templates */
/* In v5, inputs are bordered by default, so these are now no-ops for backwards compatibility */
.input-bordered {
/* no-op: inputs are bordered by default in DaisyUI v5 */
}
.select-bordered {
/* no-op: selects are bordered by default in DaisyUI v5 */
}
.textarea-bordered {
/* no-op: textareas are bordered by default in DaisyUI v5 */
}
.file-input-bordered {
/* no-op: file inputs are bordered by default in DaisyUI v5 */
}
/* Layout variables */
:root {
--layout-sidebar-width: 256px;
@@ -75,11 +90,11 @@
/* Individual Nav Items */
.sidebar-item {
@apply flex items-center gap-2 px-2.5 py-2 text-sm rounded-lg transition-all cursor-pointer;
@apply text-base-content/70 hover:bg-base-200/50 hover:text-base-content;
@apply text-base-content/50 hover:bg-base-200/50 hover:text-base-content/70;
/* Active state - light gray rounded background (matches screenshot) */
/* Active state - visible background + full color text */
&.active {
@apply bg-base-200 text-base-content font-medium;
@apply bg-base-300 text-base-content font-medium;
}
}
@@ -106,26 +121,20 @@
@apply text-base-content/50 text-xs font-medium;
}
.menu-item {
@apply rounded-lg flex h-8 items-center gap-2 px-2.5 text-sm;
}
a.menu-item {
@apply rounded-lg flex h-8 items-center gap-2 px-2.5 text-sm text-base-content/50 hover:bg-base-200/50 hover:text-base-content/70 cursor-pointer;
a,
.menu-item-link {
@apply cursor-pointer;
&.menu-item {
@apply hover:bg-base-200/50;
&.active {
@apply bg-base-200 text-base-content font-medium;
}
&.active {
@apply bg-base-300 text-base-content font-medium;
}
}
.collapse {
input {
@apply min-h-8 p-0;
/* Constrain checkbox to title area only - DaisyUI default has z-index:1 and width:100%
which causes it to overlay the collapse-content and intercept clicks on menu items.
Fix: constrain both height AND width to match the collapse-title toggle area */
@apply min-h-8 max-h-8 w-8 p-0;
}
.collapse-title {
@@ -458,3 +467,199 @@ html[data-theme='dark'] textarea::placeholder {
html[data-theme='dark'] .tooltip:before {
color: #9ca3af !important;
}
/* ============================================
Command Center Color Language
Shared semantic classes for consistent UI
============================================ */
/* cb-btn-primary: Green solid button (matches Command Center "New Deal")
Usage: <a class="cb-btn-primary">Add</a> */
.cb-btn-primary {
@apply btn btn-primary btn-sm;
}
/* cb-link: Primary green link (matches Dashboard "View all" links exactly)
Uses --color-primary directly for perfect consistency
Usage: <a class="cb-link">View all</a> */
.cb-link {
@apply text-primary hover:underline transition-colors;
}
/* cb-chip: Small count badge (matches Command Center KPI chips)
Usage: <span class="cb-chip">42 total</span> */
.cb-chip {
@apply text-xs text-base-content/50 font-normal;
}
/* cb-pill-secondary: Subdued pill for secondary status (Fulfillment, etc.)
Uses badge-ghost to match dashboard neutral styling
Usage: <span class="cb-pill-secondary">Done</span> */
.cb-pill-secondary {
@apply badge badge-sm badge-ghost;
}
/* ============================================
List Page Filter Bar (Canonical)
Single row, consistent across all list pages
Invoices = baseline
============================================ */
/* cb-filter-bar: Single-row filter container */
.cb-filter-bar {
@apply flex flex-wrap items-center gap-2;
}
/* cb-filter-search: Search input wrapper with icon */
.cb-filter-search {
@apply relative flex items-center;
}
.cb-filter-search .icon-\[heroicons--magnifying-glass\] {
@apply absolute left-3 pointer-events-none;
}
/* cb-filter-search-input: The actual search input (matches Invoices exactly) */
.cb-filter-search-input {
@apply input input-sm w-48 pl-9 bg-base-100;
@apply focus:border-primary focus:outline-none;
}
/* cb-filter-select: Dropdown selects in filter bar */
.cb-filter-select {
@apply select select-sm bg-base-100;
@apply focus:border-primary focus:outline-none;
}
/* cb-filter-clear: Clear filters link */
.cb-filter-clear {
@apply text-sm text-base-content/50 hover:text-base-content hover:underline ml-1;
}
/* ============================================
Layout Primitives
Shared layout grammar for consistent page structure
============================================ */
/* cb-page: Page container with consistent max-width and padding
Used by all page types (dashboard, list, form)
Usage: <div class="cb-page">...</div> */
.cb-page {
@apply w-full max-w-7xl mx-auto px-4 sm:px-6 py-4 sm:py-6;
}
/* cb-page-narrow: Narrower container for forms and detail views
Keeps forms readable without stretching inputs too wide
Usage: <div class="cb-page-narrow">...</div> */
.cb-page-narrow {
@apply w-full max-w-4xl mx-auto px-4 sm:px-6 py-4 sm:py-6;
}
/* ----------------------------------------
Page Header Patterns
Two variants: compact (lists) and standard (forms)
---------------------------------------- */
/* cb-header-compact: Single-line header with inline actions (lists, dashboards)
Usage: <header class="cb-header-compact">...</header> */
.cb-header-compact {
@apply flex flex-wrap items-center gap-x-4 gap-y-2;
}
/* cb-header-compact title styling */
.cb-header-compact h1 {
@apply text-lg font-semibold;
}
/* cb-header-form: Two-row header for forms (title + breadcrumb)
Usage: <header class="cb-header-form">...</header> */
.cb-header-form {
@apply flex items-center justify-between;
}
.cb-header-form h1 {
@apply text-lg font-medium;
}
/* ----------------------------------------
Section Card Component
Consistent card styling across all page types
---------------------------------------- */
/* cb-section: Standard section card with border
Usage: <section class="cb-section">...</section> */
.cb-section {
@apply rounded-lg border border-base-300 bg-base-100;
}
/* cb-section-padded: Section with internal padding (for non-table content)
Usage: <section class="cb-section cb-section-padded">...</section> */
.cb-section-padded {
@apply p-4 sm:p-5;
}
/* cb-section-header: Section header within a card
Usage: <div class="cb-section-header"><h2>Title</h2></div> */
.cb-section-header {
@apply pb-3 mb-4 border-b border-base-200;
}
.cb-section-header h2 {
@apply text-base font-medium;
}
/* cb-section-footer: Section footer (pagination, actions)
Usage: <div class="cb-section-footer">...</div> */
.cb-section-footer {
@apply border-t border-base-300 px-4 py-2.5;
}
/* ----------------------------------------
Form Section Card (for forms with card-title style)
Extends cb-section with form-specific spacing
---------------------------------------- */
/* cb-form-card: Card container for form sections
Replaces .card.bg-base-100.shadow pattern
Usage: <div class="cb-form-card">...</div> */
.cb-form-card {
@apply rounded-lg border border-base-300 bg-base-100 p-5 sm:p-6;
}
.cb-form-card h2 {
@apply text-base font-semibold mb-4;
}
/* ----------------------------------------
Vertical Spacing Scale
Consistent gaps between page sections
---------------------------------------- */
/* cb-stack-tight: Minimal spacing (between related items)
Usage: <div class="cb-stack-tight">...</div> */
.cb-stack-tight {
@apply space-y-2;
}
/* cb-stack: Standard spacing (between sections)
Usage: <div class="cb-stack">...</div> */
.cb-stack {
@apply space-y-4;
}
/* cb-stack-loose: Generous spacing (between major sections)
Usage: <div class="cb-stack-loose">...</div> */
.cb-stack-loose {
@apply space-y-6;
}
/* ----------------------------------------
Form Action Bar
Consistent footer for form pages
---------------------------------------- */
/* cb-form-actions: Form submit/cancel button row
Usage: <div class="cb-form-actions">...</div> */
.cb-form-actions {
@apply flex gap-3 justify-end pt-4 mt-6 border-t border-base-200;
}

View File

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

View File

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

View File

@@ -0,0 +1,103 @@
{{--
Canonical List Page Component
SINGLE SOURCE OF TRUTH for all list pages.
No page-specific variants allowed.
Structure:
[Header Row]
- Title (text-lg, semibold)
- Count (inline muted text, NOT a chip)
- Primary action (right-aligned, btn-primary)
- Secondary actions slot (ghost buttons)
[Filter Bar - SINGLE ROW]
- Search input (consistent styling)
- Status dropdown
- Optional secondary dropdowns
- Clear link (inline, subtle)
Usage:
<x-cb-list-page
title="Orders"
:count="$orders->total()"
:primary-action-url="route('...')"
primary-action-label="New Order"
:search-value="request('search')"
:clear-url="route('...')"
:form-action="route('...')"
>
<x-slot:filters>
<select name="status" class="cb-filter-select" onchange="this.form.submit()">
...
</select>
</x-slot:filters>
<x-slot:secondary-actions>
<a href="..." class="btn btn-ghost btn-sm">Export</a>
</x-slot:secondary-actions>
</x-cb-list-page>
--}}
@props([
'title',
'count' => null,
'primaryActionUrl' => null,
'primaryActionLabel' => null,
'primaryActionIcon' => 'heroicons--plus',
'searchValue' => null,
'searchName' => 'search',
'searchPlaceholder' => 'Search...',
'clearUrl' => null,
'formAction' => null,
])
<div class="cb-page">
<div class="cb-stack">
{{-- Header Row --}}
<header class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<h1 class="text-lg font-semibold">{{ $title }}</h1>
@if($count !== null && $count > 0)
<span class="text-sm text-base-content/50">{{ number_format($count) }} total</span>
@endif
</div>
<div class="flex items-center gap-2">
{{ $secondaryActions ?? '' }}
@if($primaryActionUrl && $primaryActionLabel)
<a href="{{ $primaryActionUrl }}" class="btn btn-primary btn-sm gap-1">
<span class="icon-[{{ $primaryActionIcon }}] size-4"></span>
{{ $primaryActionLabel }}
</a>
@endif
</div>
</header>
{{-- Filter Bar - SINGLE ROW --}}
<form method="GET" action="{{ $formAction }}" class="cb-filter-bar">
{{-- Search Input --}}
<div class="cb-filter-search">
<span class="icon-[heroicons--magnifying-glass] size-4 text-base-content/40"></span>
<input
type="text"
name="{{ $searchName }}"
value="{{ $searchValue }}"
placeholder="{{ $searchPlaceholder }}"
class="cb-filter-search-input"
>
</div>
{{-- Filter Dropdowns --}}
{{ $filters ?? '' }}
{{-- Clear Link --}}
@if($clearUrl && (request($searchName) || request()->hasAny(['status', 'workorder_filter', 'type'])))
<a href="{{ $clearUrl }}" class="cb-filter-clear">Clear</a>
@endif
</form>
{{-- Page Content (table, empty state, etc.) --}}
{{ $slot }}
</div>
</div>

View File

@@ -0,0 +1,63 @@
{{--
Command Center Status Pill Component
Matches Dashboard exactly:
- Blue (badge-info): ONLY for "In Progress" type states
- Neutral gray (badge-ghost): ALL other states
Usage:
<x-cb-status-pill status="delivered" />
<x-cb-status-pill status="active" label="Active Account" />
<x-cb-status-pill :status="$order->status" />
--}}
@props(['status', 'label' => null])
@php
// Only these statuses get blue (in-progress indicator)
$inProgressStatuses = ['in_progress', 'processing', 'out_for_delivery', 'sent'];
// Status label map
$labelMap = [
'new' => 'New',
'in_progress' => 'In Progress',
'processing' => 'Processing',
'pending' => 'Pending',
'out_for_delivery' => 'Delivering',
'ready_for_delivery' => 'Buyer Review',
'delivered' => 'Delivered',
'active' => 'Active',
'completed' => 'Completed',
'buyer_approved' => 'Completed',
'done' => 'Done',
'approved' => 'Approved',
'approved_for_delivery' => 'Ready',
'ready_for_manifest' => 'Ready',
'overdue' => 'Overdue',
'failed' => 'Failed',
'rejected' => 'Rejected',
'cancelled' => 'Cancelled',
'inactive' => 'Inactive',
'at_risk' => 'At Risk',
'needs_attention' => 'Needs Attention',
'expiring' => 'Expiring',
'accepted' => 'Accepted',
'buyer_modified' => 'Modified',
'seller_modified' => 'Modified',
'draft' => 'Draft',
'unknown' => 'Unknown',
'open' => 'Open',
'sent' => 'Sent',
'declined' => 'Declined',
'expired' => 'Expired',
'converted' => 'Converted',
'unpaid' => 'Unpaid',
'paid' => 'Paid',
];
$displayLabel = $label ?? ($labelMap[$status] ?? ucfirst(str_replace('_', ' ', $status)));
$badgeClass = in_array($status, $inProgressStatuses) ? 'badge-info' : 'badge-ghost';
@endphp
<span {{ $attributes->merge(['class' => 'badge badge-sm ' . $badgeClass . ' whitespace-nowrap']) }}>
{{ $displayLabel }}
</span>

View File

@@ -0,0 +1,49 @@
@props([
'title' => '',
'subtitle' => null,
'icon' => null,
'actions' => null,
'noPadding' => false,
'fullWidth' => false,
])
<div {{ $attributes->merge(['class' => 'card bg-base-100 shadow-sm border border-base-200']) }}>
{{-- Header (only if title provided) --}}
@if($title || $actions)
<div class="flex items-center justify-between px-4 py-3 border-b border-base-200">
<div class="flex items-center gap-2">
@if($icon)
<svg class="w-5 h-5 text-base-content/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ $icon }}" />
</svg>
@endif
<div>
@if($title)
<h3 class="font-semibold text-base-content">{{ $title }}</h3>
@endif
@if($subtitle)
<p class="text-xs text-base-content/60">{{ $subtitle }}</p>
@endif
</div>
</div>
@if($actions)
<div class="flex items-center gap-2">
{{ $actions }}
</div>
@endif
</div>
@endif
{{-- Content --}}
<div class="{{ $noPadding ? '' : 'p-4' }} {{ $fullWidth ? '' : 'max-w-full overflow-x-auto' }}">
{{ $slot }}
</div>
{{-- Footer slot --}}
@if(isset($footer))
<div class="px-4 py-3 border-t border-base-200 bg-base-200/30">
{{ $footer }}
</div>
@endif
</div>

View File

@@ -0,0 +1,111 @@
@props([
'title' => '',
'columns' => [],
'rows' => [],
'emptyMessage' => 'No data available',
'href' => null,
'hrefLabel' => 'View all',
'compact' => true,
'maxRows' => 5,
])
@php
$displayRows = collect($rows)->take($maxRows);
@endphp
<div {{ $attributes->merge(['class' => 'card bg-base-100 shadow-sm border border-base-200']) }}>
{{-- Header --}}
<div class="flex items-center justify-between px-4 py-3 border-b border-base-200">
<h3 class="font-semibold text-base-content">{{ $title }}</h3>
@if($href)
<a href="{{ $href }}" class="text-sm text-primary hover:underline">{{ $hrefLabel }}</a>
@endif
</div>
{{-- Table --}}
@if($displayRows->isNotEmpty())
<div class="overflow-x-auto">
<table class="table {{ $compact ? 'table-sm' : '' }} w-full">
<thead>
<tr class="bg-base-200/50">
@foreach($columns as $column)
<th class="{{ $column['class'] ?? '' }} text-xs font-medium text-base-content/70 uppercase tracking-wide">
{{ $column['label'] }}
</th>
@endforeach
</tr>
</thead>
<tbody>
@foreach($displayRows as $row)
<tr class="hover:bg-base-200/30 transition-colors">
@foreach($columns as $key => $column)
<td class="{{ $column['cellClass'] ?? '' }}">
@php
$value = is_array($row) ? ($row[$key] ?? '') : ($row->$key ?? '');
$format = $column['format'] ?? 'text';
@endphp
@switch($format)
@case('currency')
<span class="tabular-nums">${{ number_format($value, 2) }}</span>
@break
@case('number')
<span class="tabular-nums">{{ number_format($value) }}</span>
@break
@case('date')
<span class="text-base-content/70">
{{ $value ? \Carbon\Carbon::parse($value)->format('M j, Y') : '-' }}
</span>
@break
@case('datetime')
<span class="text-base-content/70">
{{ $value ? \Carbon\Carbon::parse($value)->diffForHumans() : '-' }}
</span>
@break
@case('status')
@php
$statusClass = match($value) {
'pending', 'draft' => 'badge-warning',
'completed', 'paid', 'won', 'accepted' => 'badge-success',
'failed', 'overdue', 'lost', 'rejected' => 'badge-error',
'processing', 'in_progress', 'open' => 'badge-info',
default => 'badge-ghost',
};
@endphp
<span class="badge badge-sm {{ $statusClass }}">{{ ucfirst(str_replace('_', ' ', $value)) }}</span>
@break
@case('link')
@php
$linkHref = is_array($row) ? ($row[$column['hrefKey'] ?? 'href'] ?? '#') : ($row->{$column['hrefKey'] ?? 'href'} ?? '#');
@endphp
<a href="{{ $linkHref }}" class="link link-primary link-hover">{{ $value }}</a>
@break
@default
{{ $value }}
@endswitch
</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="px-4 py-8 text-center text-base-content/50">
{{ $emptyMessage }}
</div>
@endif
{{-- Footer slot --}}
@if(isset($footer))
<div class="px-4 py-2 border-t border-base-200 bg-base-200/30">
{{ $footer }}
</div>
@endif
</div>

View File

@@ -0,0 +1,57 @@
@props([
'title' => '',
'icon' => null,
'badge' => null,
'badgeClass' => 'badge-primary',
'collapsible' => false,
'collapsed' => false,
'href' => null,
'hrefLabel' => 'View all',
])
@php
$collapseId = 'rail-card-' . Str::random(8);
@endphp
<div {{ $attributes->merge(['class' => 'card bg-base-100 shadow-sm border border-base-200']) }}>
{{-- Header --}}
<div class="flex items-center justify-between px-3 py-2.5 border-b border-base-200 {{ $collapsible ? 'cursor-pointer hover:bg-base-200/30' : '' }}"
@if($collapsible) x-data="{ open: {{ $collapsed ? 'false' : 'true' }} }" @click="open = !open" @endif>
<div class="flex items-center gap-2">
@if($icon)
<svg class="w-4 h-4 text-base-content/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ $icon }}" />
</svg>
@endif
<h4 class="font-medium text-sm text-base-content">{{ $title }}</h4>
@if($badge !== null)
<span class="badge badge-sm {{ $badgeClass }}">{{ $badge }}</span>
@endif
</div>
<div class="flex items-center gap-2">
@if($href)
<a href="{{ $href }}" class="text-xs text-primary hover:underline" @click.stop>{{ $hrefLabel }}</a>
@endif
@if($collapsible)
<svg class="w-4 h-4 text-base-content/50 transition-transform" :class="{ 'rotate-180': open }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
@endif
</div>
</div>
{{-- Content --}}
@if($collapsible)
<div x-show="open" x-collapse x-cloak>
<div class="p-3 space-y-2">
{{ $slot }}
</div>
</div>
@else
<div class="p-3 space-y-2">
{{ $slot }}
</div>
@endif
</div>

View File

@@ -0,0 +1,86 @@
@props([
'label' => '',
'value' => 0,
'change' => null,
'format' => 'number', // number|currency|percent
'scope' => 'business', // business|brand|user
'sublabel' => null,
'icon' => null,
'href' => null,
'size' => 'default', // default|compact
])
@php
$formattedValue = match($format) {
'currency' => '$' . number_format($value, 0),
'percent' => number_format($value, 1) . '%',
default => number_format($value),
};
$changeColor = match(true) {
$change > 0 => 'text-success',
$change < 0 => 'text-error',
default => 'text-base-content/50',
};
$changeIcon = match(true) {
$change > 0 => 'M5 15l7-7 7 7',
$change < 0 => 'M19 9l-7 7-7-7',
default => null,
};
$cardClasses = $size === 'compact'
? 'card bg-base-100 shadow-sm border border-base-200 p-3'
: 'card bg-base-100 shadow-sm border border-base-200 p-4';
$scopeBadge = match($scope) {
'brand' => 'badge-secondary',
'user' => 'badge-accent',
default => 'badge-ghost',
};
@endphp
<div {{ $attributes->merge(['class' => $cardClasses]) }}>
@if($href)
<a href="{{ $href }}" class="absolute inset-0 z-10" aria-label="View {{ $label }}"></a>
@endif
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-1">
@if($icon)
<svg class="w-4 h-4 text-base-content/50" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ $icon }}" />
</svg>
@endif
<span class="text-xs font-medium text-base-content/70 uppercase tracking-wide truncate">{{ $label }}</span>
</div>
<div class="{{ $size === 'compact' ? 'text-xl' : 'text-2xl' }} font-bold text-base-content tabular-nums">
{{ $formattedValue }}
</div>
@if($sublabel)
<div class="text-xs text-base-content/50 mt-0.5">{{ $sublabel }}</div>
@endif
</div>
@if($change !== null)
<div class="flex items-center gap-0.5 {{ $changeColor }} text-sm font-medium">
@if($changeIcon)
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ $changeIcon }}" />
</svg>
@endif
<span>{{ abs($change) }}%</span>
</div>
@endif
</div>
{{-- Scope indicator (only shown when not business-level) --}}
@if($scope !== 'business')
<div class="absolute top-2 right-2">
<span class="badge badge-xs {{ $scopeBadge }}">{{ ucfirst($scope) }}</span>
</div>
@endif
</div>

View File

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

View File

@@ -1,6 +1,9 @@
@php
// Use the business from the current route if available, otherwise fall back to primary business
$sidebarBusiness = request()->route('business') ?? auth()->user()?->primaryBusiness();
// Get selected brand for brand-context-aware links (Promotions, etc.)
$selectedBrand = \App\Http\Controllers\Seller\BrandSwitcherController::getSelectedBrand();
@endphp
<input
@@ -46,41 +49,76 @@
menuSales: $persist(false).as('sidebar-menu-sales-crm'),
menuInbox: $persist(false).as('sidebar-menu-inbox')
}">
{{-- Overview Section (Core - always visible) --}}
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Overview</p>
<div class="group collapse">
{{-- Dashboard (Single Link) --}}
@if($sidebarBusiness)
<a class="menu-item px-2.5 py-1.5 mt-1 {{ request()->routeIs('seller.business.dashboard') || request()->routeIs('seller.dashboard') ? 'active' : '' }}"
href="{{ route('seller.business.dashboard', $sidebarBusiness->slug) }}">
<span class="icon-[lucide--bar-chart-3] size-4"></span>
<span class="grow">Dashboard</span>
</a>
{{-- Connect Section (Communications, Tasks, Calendar) --}}
<div class="group collapse mt-1">
<input
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
x-model="menuDashboard" />
x-model="menuInbox" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--bar-chart-3] size-4"></span>
<span class="grow">Dashboard</span>
<span class="icon-[lucide--link] size-4"></span>
<span class="grow">Connect</span>
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
</div>
@if($sidebarBusiness)
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
<a class="menu-item {{ request()->routeIs('seller.business.dashboard') || request()->routeIs('seller.dashboard') ? 'active' : '' }}" href="{{ route('seller.business.dashboard', $sidebarBusiness->slug) }}">
<span class="grow">Dashboard</span>
{{-- Premium: Messaging overview when has Sales Suite --}}
@if($sidebarBusiness->hasSalesSuite())
<a class="menu-item {{ request()->routeIs('seller.business.messaging.*') ? 'active' : '' }}"
href="{{ route('seller.business.messaging.index', $sidebarBusiness->slug) }}">
<span class="grow">Overview</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.brands.*') ? 'active' : '' }}" href="{{ route('seller.business.brands.index', $sidebarBusiness->slug) }}">
<span class="grow">Brands</span>
@endif
{{-- Core: Contacts is always available --}}
<a class="menu-item {{ request()->routeIs('seller.business.contacts.*') ? 'active' : '' }}"
href="{{ route('seller.business.contacts.index', $sidebarBusiness->slug) }}">
<span class="grow">Contacts</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.intelligence') ? 'active' : '' }}" href="{{ route('seller.business.intelligence', $sidebarBusiness->slug) }}">
<span class="grow">Market Intelligence</span>
{{-- 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' : '' }}"
href="{{ route('seller.business.crm.threads.index', $sidebarBusiness->slug) }}">
<span class="grow">Conversations</span>
</a>
@endif
{{-- Tasks --}}
<a class="menu-item {{ request()->routeIs('seller.business.crm.tasks.*') ? 'active' : '' }}"
href="{{ route('seller.business.crm.tasks.index', $sidebarBusiness->slug) }}">
<span class="grow">Tasks</span>
@if(($crmStats['overdue_tasks'] ?? 0) > 0)
<span class="badge badge-xs badge-error" title="Overdue">{{ $crmStats['overdue_tasks'] }}</span>
@elseif(($crmStats['my_tasks'] ?? 0) > 0)
<span class="badge badge-xs badge-primary">{{ $crmStats['my_tasks'] }}</span>
@endif
</a>
{{-- Calendar --}}
<a class="menu-item {{ request()->routeIs('seller.business.crm.calendar.*') ? 'active' : '' }}"
href="{{ route('seller.business.crm.calendar.index', $sidebarBusiness->slug) }}">
<span class="grow">Calendar</span>
</a>
</div>
</div>
@else
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 px-3 py-2">
<p class="text-xs text-base-content/60">Complete your business profile to view dashboard</p>
</div>
</div>
@endif
</div>
@endif
{{-- Commerce Section (Core - always visible) --}}
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Commerce</p>
@@ -98,24 +136,19 @@
@if($sidebarBusiness)
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
<a class="menu-item {{ request()->routeIs('seller.business.dashboard.sales') ? 'active' : '' }}" href="{{ route('seller.business.dashboard.sales', $sidebarBusiness->slug) }}">
<span class="grow">Overview</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.customers.*') ? 'active' : '' }}" href="{{ route('seller.business.customers.index', $sidebarBusiness->slug) }}">
<span class="grow">All Customers</span>
{{-- Accounts links to CRM accounts --}}
<a class="menu-item {{ request()->routeIs('seller.business.crm.accounts.*') || request()->routeIs('seller.business.customers.*') ? 'active' : '' }}" href="{{ route('seller.business.crm.accounts.index', $sidebarBusiness->slug) }}">
<span class="grow">Accounts</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.orders.*') ? 'active' : '' }}" href="{{ route('seller.business.orders.index', $sidebarBusiness->slug) }}">
<span class="grow">Orders</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.invoices.*') ? 'active' : '' }}" href="{{ route('seller.business.invoices.index', $sidebarBusiness->slug) }}">
<a class="menu-item {{ request()->routeIs('seller.business.crm.quotes.*') ? 'active' : '' }}" href="{{ route('seller.business.crm.quotes.index', $sidebarBusiness->slug) }}">
<span class="grow">Quotes</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.crm.invoices.*') || request()->routeIs('seller.business.invoices.*') ? 'active' : '' }}" href="{{ route('seller.business.crm.invoices.index', $sidebarBusiness->slug) }}">
<span class="grow">Invoices</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.backorders.*') ? 'active' : '' }}" href="{{ route('seller.business.backorders.index', $sidebarBusiness->slug) }}">
<span class="grow">Backorders</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.promotions.*') ? 'active' : '' }}" href="{{ route('seller.business.promotions.index', $sidebarBusiness->slug) }}">
<span class="grow">Promotions</span>
</a>
</div>
</div>
@else
@@ -143,9 +176,19 @@
@if($sidebarBusiness)
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
<a class="menu-item {{ request()->routeIs('seller.business.brands.*') ? 'active' : '' }}" href="{{ route('seller.business.brands.index', $sidebarBusiness->slug) }}">
<a class="menu-item {{ request()->routeIs('seller.business.brands.*') && !request()->routeIs('seller.business.brands.promotions.*') && !request()->routeIs('seller.business.brands.menus.*') ? 'active' : '' }}" href="{{ route('seller.business.brands.index', $sidebarBusiness->slug) }}">
<span class="grow">All Brands</span>
</a>
{{-- Promotions - brand-context aware --}}
<a class="menu-item {{ request()->routeIs('seller.business.brands.promotions.*') || request()->routeIs('seller.business.promotions.*') ? 'active' : '' }}"
href="{{ $selectedBrand ? route('seller.business.brands.promotions.index', [$sidebarBusiness->slug, $selectedBrand->hashid]) : route('seller.business.brands.index', $sidebarBusiness->slug) }}">
<span class="grow">Promotions</span>
</a>
{{-- Menus - brand-context aware --}}
<a class="menu-item {{ request()->routeIs('seller.business.brands.menus.*') ? 'active' : '' }}"
href="{{ $selectedBrand ? route('seller.business.brands.menus.index', [$sidebarBusiness->slug, $selectedBrand->hashid]) : route('seller.business.brands.index', $sidebarBusiness->slug) }}">
<span class="grow">Menus</span>
</a>
</div>
</div>
@else
@@ -157,8 +200,8 @@
@endif
</div>
{{-- Sales Suite Section (Sales + Merchandising) --}}
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Sales Suite</p>
{{-- Inventory Section --}}
<p class="menu-label px-2.5 pt-3 pb-1.5 first:pt-0">Inventory</p>
<div class="group collapse">
<input
aria-label="Sidemenu item trigger"
@@ -417,10 +460,11 @@
--}}
{{-- ═══════════════════════════════════════════════════════════
GROWTH SECTION (Premium - gated by Sales Suite)
MARKETING SECTION (Premium - gated by Sales Suite)
Channels & Templates removed - accessible from Campaigns
═══════════════════════════════════════════════════════════ --}}
@if($sidebarBusiness && $sidebarBusiness->hasSalesSuite())
<p class="menu-label px-2.5 pt-3 pb-1.5">Growth</p>
<p class="menu-label px-2.5 pt-3 pb-1.5">Marketing</p>
<div class="group collapse">
<input
aria-label="Sidemenu item trigger"
@@ -429,12 +473,12 @@
x-model="menuGrowth" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--megaphone] size-4"></span>
<span class="grow">Growth</span>
<span class="grow">Marketing</span>
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
</div>
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
{{-- Messaging items - NOT gated by CannaiQ, only Sales/Messaging Suite --}}
{{-- Core marketing items --}}
<a class="menu-item {{ request()->routeIs('seller.business.marketing.campaigns.*') ? 'active' : '' }}"
href="{{ route('seller.business.marketing.campaigns.index', $sidebarBusiness->slug) }}">
<span class="grow">Campaigns</span>
@@ -447,14 +491,6 @@
href="{{ route('seller.business.marketing.lists.index', $sidebarBusiness->slug) }}">
<span class="grow">Lists</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.marketing.channels.*') ? 'active' : '' }}"
href="{{ route('seller.business.marketing.channels.index', $sidebarBusiness->slug) }}">
<span class="grow">Channels</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.marketing.templates.*') ? 'active' : '' }}"
href="{{ route('seller.business.marketing.templates.index', $sidebarBusiness->slug) }}">
<span class="grow">Templates</span>
</a>
{{-- Automations - requires Sales Suite + CannaiQ + Messaging --}}
@if($sidebarBusiness->hasAutomationAccess())
@@ -483,111 +519,6 @@
</div>
@endif
{{-- ═══════════════════════════════════════════════════════════
SALES SECTION (Premium - gated by Sales Suite)
CRM functionality under a cleaner "Sales" label
═══════════════════════════════════════════════════════════ --}}
@if($sidebarBusiness && $sidebarBusiness->hasSalesSuite())
<p class="menu-label px-2.5 pt-3 pb-1.5">Sales</p>
<div class="group collapse">
<input
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
x-model="menuSales" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--briefcase] size-4"></span>
<span class="grow">Sales</span>
@if(($crmStats['overdue_tasks'] ?? 0) > 0)
<span class="badge badge-xs badge-error">{{ $crmStats['overdue_tasks'] }}</span>
@elseif(($crmStats['my_tasks'] ?? 0) > 0)
<span class="badge badge-xs badge-primary">{{ $crmStats['my_tasks'] }}</span>
@endif
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
</div>
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
<a class="menu-item {{ request()->routeIs('seller.business.crm.dashboard') ? 'active' : '' }}"
href="{{ route('seller.business.crm.dashboard', $sidebarBusiness->slug) }}">
<span class="grow">Overview</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.crm.deals.*') ? 'active' : '' }}"
href="{{ route('seller.business.crm.deals.index', $sidebarBusiness->slug) }}">
<span class="grow">Pipeline</span>
@if(($crmStats['open_opportunities'] ?? 0) > 0)
<span class="badge badge-xs badge-primary">{{ $crmStats['open_opportunities'] }}</span>
@endif
</a>
<a class="menu-item {{ request()->routeIs('seller.business.crm.accounts.*') ? 'active' : '' }}"
href="{{ route('seller.business.crm.accounts.index', $sidebarBusiness->slug) }}">
<span class="grow">Accounts</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.crm.tasks.*') ? 'active' : '' }}"
href="{{ route('seller.business.crm.tasks.index', $sidebarBusiness->slug) }}">
<span class="grow">Tasks</span>
@if(($crmStats['overdue_tasks'] ?? 0) > 0)
<span class="badge badge-xs badge-error" title="Overdue">{{ $crmStats['overdue_tasks'] }}</span>
@elseif(($crmStats['my_tasks'] ?? 0) > 0)
<span class="badge badge-xs badge-primary">{{ $crmStats['my_tasks'] }}</span>
@endif
</a>
<a class="menu-item {{ request()->routeIs('seller.business.crm.activity.*') ? 'active' : '' }}"
href="{{ route('seller.business.crm.activity.index', $sidebarBusiness->slug) }}">
<span class="grow">Activity</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.crm.calendar.*') ? 'active' : '' }}"
href="{{ route('seller.business.crm.calendar.index', $sidebarBusiness->slug) }}">
<span class="grow">Calendar</span>
</a>
</div>
</div>
</div>
@endif
{{-- ═══════════════════════════════════════════════════════════
INBOX SECTION (Core contacts + Premium messaging)
═══════════════════════════════════════════════════════════ --}}
@if($sidebarBusiness)
<p class="menu-label px-2.5 pt-3 pb-1.5">Inbox</p>
<div class="group collapse">
<input
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
x-model="menuInbox" />
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[lucide--inbox] size-4"></span>
<span class="grow">Inbox</span>
<span class="icon-[lucide--chevron-right] arrow-icon size-3.5"></span>
</div>
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
{{-- Premium: Messaging overview when has Sales Suite --}}
@if($sidebarBusiness->hasSalesSuite())
<a class="menu-item {{ request()->routeIs('seller.business.messaging.*') ? 'active' : '' }}"
href="{{ route('seller.business.messaging.index', $sidebarBusiness->slug) }}">
<span class="grow">Overview</span>
</a>
@endif
{{-- Core: Contacts is always available --}}
<a class="menu-item {{ request()->routeIs('seller.business.contacts.*') ? 'active' : '' }}"
href="{{ route('seller.business.contacts.index', $sidebarBusiness->slug) }}">
<span class="grow">Contacts</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' : '' }}"
href="{{ route('seller.business.crm.threads.index', $sidebarBusiness->slug) }}">
<span class="grow">Conversations</span>
</a>
@endif
</div>
</div>
</div>
@endif
{{-- Reports Section - Department-based (Bottom of Navigation) --}}
@if($sidebarBusiness)
@php

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,190 +1,137 @@
@extends('layouts.app-with-sidebar')
@section('content')
<div class="max-w-7xl mx-auto px-4 py-6 space-y-6">
{{-- Header Section --}}
<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--shopping-cart] size-5"></span>
Backorders
</p>
<p class="text-base-content/60 text-sm mt-1">
Committed pre-orders that auto-convert to orders when inventory arrives
</p>
</div>
</div>
{{-- Filters Section --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{{-- Search --}}
<div class="form-control lg:col-span-2">
<input
type="text"
placeholder="Search backorders..."
class="input input-sm input-bordered w-full"
>
</div>
{{-- Status Filter --}}
<div class="form-control">
<select class="select select-sm select-bordered w-full">
<option>All Statuses</option>
<option>Pending</option>
<option>Converted</option>
<option>Cancelled</option>
</select>
</div>
{{-- Product Filter --}}
<div class="form-control">
<select class="select select-sm select-bordered w-full">
<option>All Products</option>
</select>
</div>
</div>
{{-- Filter Actions & Results Count --}}
<div class="flex items-center justify-between mt-3 pt-3 border-t border-base-300">
<p class="text-sm text-base-content/60">
Showing <span class="font-semibold">{{ $backorders->count() }}</span> {{ Str::plural('backorder', $backorders->count()) }}
</p>
<button type="button" class="btn btn-ghost btn-sm gap-1">
<span class="icon-[heroicons--x-mark] size-4"></span>
Clear
</button>
</div>
</div>
</div>
<x-cb-list-page
title="Backorders"
:count="$backorders->count()"
search-placeholder="Search backorders..."
:form-action="route('seller.business.backorders.index', $business->slug)"
:clear-url="route('seller.business.backorders.index', $business->slug)"
>
<x-slot:filters>
<select name="status" class="cb-filter-select" onchange="this.form.submit()">
<option value="">All Status</option>
<option value="pending" {{ request('status') === 'pending' ? 'selected' : '' }}>Pending</option>
<option value="converted" {{ request('status') === 'converted' ? 'selected' : '' }}>Converted</option>
<option value="cancelled" {{ request('status') === 'cancelled' ? 'selected' : '' }}>Cancelled</option>
</select>
</x-slot:filters>
{{-- Demand by Product (if data available) --}}
@if(count($stats['by_product']) > 0)
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<h3 class="font-semibold flex items-center gap-2 mb-3">
<span class="icon-[heroicons--chart-bar] size-5"></span>
Current Demand by Product
</h3>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th class="bg-base-200">Product</th>
<th class="bg-base-200 text-right">Total Units</th>
<th class="bg-base-200 text-right">Customers</th>
</tr>
</thead>
<tbody>
@foreach($stats['by_product'] as $productStats)
<tr class="hover">
<td class="font-medium">{{ $productStats['product'] }}</td>
<td class="text-right">
<span class="font-semibold">{{ $productStats['total_quantity'] }}</span> units
</td>
<td class="text-right">{{ $productStats['customer_count'] }} {{ Str::plural('customer', $productStats['customer_count']) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<section class="cb-section overflow-hidden">
<div class="px-4 py-3 border-b border-base-200 bg-base-200/30">
<h3 class="text-sm font-medium">Current Demand by Product</h3>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-sm w-full">
<thead class="bg-base-200/50">
<tr>
<th class="text-xs font-medium text-base-content/70 uppercase tracking-wide py-2.5">Product</th>
<th class="text-xs font-medium text-base-content/70 uppercase tracking-wide text-right py-2.5">Total Units</th>
<th class="text-xs font-medium text-base-content/70 uppercase tracking-wide text-right py-2.5">Customers</th>
</tr>
</thead>
<tbody>
@foreach($stats['by_product'] as $productStats)
<tr class="hover">
<td class="py-2.5 align-middle text-sm font-medium">{{ $productStats['product'] }}</td>
<td class="py-2.5 align-middle text-right text-sm">
<span class="font-medium">{{ $productStats['total_quantity'] }}</span> units
</td>
<td class="py-2.5 align-middle text-right text-sm text-base-content/60">
{{ $productStats['customer_count'] }} {{ Str::plural('customer', $productStats['customer_count']) }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</section>
@endif
{{-- All Backorders Table Section --}}
<div class="card bg-base-100 shadow-sm">
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th class="bg-base-200">Customer</th>
<th class="bg-base-200">Product</th>
<th class="bg-base-200 text-right">Quantity</th>
<th class="bg-base-200">Status</th>
<th class="bg-base-200">Created</th>
<th class="bg-base-200">Converted</th>
<th class="bg-base-200 text-right">Order</th>
</tr>
</thead>
<tbody>
@forelse($backorders as $backorder)
<tr class="hover">
{{-- Customer --}}
<td>
<div>
<div class="font-medium">{{ $backorder->business->name }}</div>
<div class="text-xs opacity-60">{{ $backorder->user->name }}</div>
</div>
</td>
{{-- Backorders Table --}}
@if($backorders->isEmpty())
<section class="cb-section">
<div class="flex flex-col items-center justify-center text-center py-10 px-4">
<div class="w-10 h-10 rounded-lg bg-base-200/50 flex items-center justify-center mb-3">
<span class="icon-[heroicons--shopping-cart] size-5 text-base-content/30"></span>
</div>
<p class="text-sm font-medium text-base-content/80 mb-1">No backorders yet</p>
<p class="text-xs text-base-content/60 max-w-md mb-4">
Backorders will appear here when customers pre-order out-of-stock items.
They'll automatically convert to orders when inventory arrives.
</p>
</div>
</section>
@else
<section class="cb-section overflow-hidden">
<div class="overflow-x-auto">
<table class="table table-sm w-full">
<thead class="bg-base-200/50">
<tr>
<th class="text-xs font-medium text-base-content/70 uppercase tracking-wide py-2.5">Customer</th>
<th class="text-xs font-medium text-base-content/70 uppercase tracking-wide py-2.5">Product</th>
<th class="text-xs font-medium text-base-content/70 uppercase tracking-wide text-right py-2.5">Qty</th>
<th class="text-xs font-medium text-base-content/70 uppercase tracking-wide py-2.5">Status</th>
<th class="text-xs font-medium text-base-content/70 uppercase tracking-wide py-2.5">Created</th>
<th class="text-xs font-medium text-base-content/70 uppercase tracking-wide py-2.5">Converted</th>
<th class="text-xs font-medium text-base-content/70 uppercase tracking-wide text-right py-2.5">Order</th>
</tr>
</thead>
<tbody>
@foreach($backorders as $backorder)
<tr class="hover group">
<td class="py-2.5 align-middle">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-base-200/50 text-base-content/50 flex items-center justify-center text-sm font-medium flex-shrink-0">
{{ strtoupper(substr($backorder->business->name ?? 'C', 0, 1)) }}
</div>
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ $backorder->business->name }}</p>
<p class="text-xs text-base-content/60 truncate">{{ $backorder->user->name }}</p>
</div>
</div>
</td>
<td class="py-2.5 align-middle">
<p class="text-sm font-medium">{{ $backorder->product->name }}</p>
<p class="text-xs text-base-content/60">{{ $backorder->product->sku }}</p>
</td>
<td class="py-2.5 align-middle text-right">
<span class="text-sm font-medium">{{ $backorder->quantity }}</span>
</td>
<td class="py-2.5 align-middle">
<x-cb-status-pill :status="$backorder->status" />
</td>
<td class="py-2.5 align-middle whitespace-nowrap">
<span class="text-sm">{{ $backorder->created_at->format('M j, Y') }}</span>
</td>
<td class="py-2.5 align-middle whitespace-nowrap">
<span class="text-sm">{{ $backorder->converted_at?->format('M j, Y') ?? '—' }}</span>
</td>
<td class="py-2.5 align-middle text-right">
@if($backorder->order)
<a href="{{ route('seller.business.orders.show', [$business->slug, $backorder->order->order_number]) }}"
class="cb-link text-sm font-medium font-mono">
{{ $backorder->order->order_number }}
</a>
@else
<span class="text-base-content/40"></span>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{-- Product --}}
<td>
<div>
<div class="font-medium">{{ $backorder->product->name }}</div>
<div class="text-xs opacity-60">SKU: {{ $backorder->product->sku }}</div>
</div>
</td>
{{-- Quantity --}}
<td class="text-right">
<span class="font-semibold">{{ $backorder->quantity }}</span>
</td>
{{-- Status --}}
<td>
@if($backorder->status === 'pending')
<span class="badge badge-warning badge-xs">Pending</span>
@elseif($backorder->status === 'converted')
<span class="badge badge-success badge-xs">Converted</span>
@elseif($backorder->status === 'cancelled')
<span class="badge badge-ghost badge-xs">Cancelled</span>
@else
<span class="badge badge-primary badge-xs">{{ ucfirst($backorder->status) }}</span>
@endif
</td>
{{-- Created --}}
<td>
<span class="text-sm">{{ $backorder->created_at->format('M d, Y') }}</span>
</td>
{{-- Converted --}}
<td>
<span class="text-sm">{{ $backorder->converted_at?->format('M d, Y') ?? '—' }}</span>
</td>
{{-- Order --}}
<td class="text-right">
@if($backorder->order)
<a href="{{ route('seller.business.orders.show', [$business->slug, $backorder->order->order_number]) }}"
class="font-semibold text-primary hover:underline">
{{ $backorder->order->order_number }}
</a>
@else
<span class="text-base-content/40"></span>
@endif
</td>
</tr>
@empty
{{-- Empty State --}}
<tr>
<td colspan="7" class="text-center py-12">
<div class="flex flex-col items-center">
<span class="icon-[heroicons--shopping-cart] size-16 text-base-content/20 mb-4"></span>
<p class="font-medium text-lg mb-2">No backorders yet</p>
<p class="text-base-content/60 mb-4 max-w-md mx-auto">
Backorders will appear here when customers pre-order out-of-stock items.
They'll automatically convert to orders when inventory arrives.
</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
{{-- Pagination --}}
@if(method_exists($backorders, 'hasPages') && $backorders->hasPages())
<div class="cb-section-footer">
{{ $backorders->links() }}
</div>
@endif
</section>
@endif
</x-cb-list-page>
@endsection

View File

@@ -1,9 +1,10 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between">
<p class="text-lg font-medium">Create Brand</p>
<div class="cb-page-narrow">
{{-- Page Header --}}
<header class="cb-header-form">
<h1>Create Brand</h1>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
@@ -11,18 +12,17 @@
<li class="opacity-80">Create</li>
</ul>
</div>
</div>
</header>
<form action="{{ route('seller.business.brands.store', $business->slug) }}" method="POST" enctype="multipart/form-data" class="mt-6">
<form action="{{ route('seller.business.brands.store', $business->slug) }}" method="POST" enctype="multipart/form-data" class="cb-stack-loose mt-6">
@csrf
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Brand Identity -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Brand Identity</h2>
{{-- Brand Identity --}}
<div class="cb-form-card">
<h2>Brand Identity</h2>
<fieldset class="fieldset mt-4 gap-4">
<fieldset class="fieldset gap-4">
<!-- Brand Name -->
<div class="space-y-2">
<label class="label text-sm font-medium" for="name">
@@ -134,16 +134,14 @@
<p class="text-error text-sm">{{ $message }}</p>
@enderror
</div>
</fieldset>
</div>
</fieldset>
</div>
<!-- Brand Images -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Brand Images</h2>
{{-- Brand Images --}}
<div class="cb-form-card">
<h2>Brand Images</h2>
<fieldset class="fieldset mt-4 gap-4">
<fieldset class="fieldset gap-4">
<!-- Logo Upload -->
<div class="space-y-2">
<label class="label text-sm font-medium">Brand Logo</label>
@@ -177,16 +175,14 @@
<img src="" alt="Banner preview" class="max-w-full rounded-lg">
</div>
</div>
</fieldset>
</div>
</fieldset>
</div>
<!-- Tagline, Descriptions, and Brand Messaging -->
<div class="card bg-base-100 shadow lg:col-span-2">
<div class="card-body">
<h2 class="card-title">Tagline, Descriptions, and Brand Messaging</h2>
{{-- Tagline, Descriptions, and Brand Messaging --}}
<div class="cb-form-card lg:col-span-2">
<h2>Tagline, Descriptions, and Brand Messaging</h2>
<fieldset class="fieldset mt-4 gap-4">
<fieldset class="fieldset gap-4">
<!-- Tagline -->
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
@@ -264,16 +260,14 @@
<p class="text-error text-sm">{{ $message }}</p>
@enderror
</div>
</fieldset>
</div>
</fieldset>
</div>
<!-- Brand Emails -->
<div class="card bg-base-100 shadow lg:col-span-2">
<div class="card-body">
<h2 class="card-title">Brand Emails</h2>
{{-- Brand Emails --}}
<div class="cb-form-card lg:col-span-2">
<h2>Brand Emails</h2>
<fieldset class="fieldset mt-4 gap-4">
<fieldset class="fieldset gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Sales Email -->
<div class="space-y-2">
@@ -309,16 +303,14 @@
@enderror
</div>
</div>
</fieldset>
</div>
</fieldset>
</div>
<!-- SEO Settings -->
<div class="card bg-base-100 shadow lg:col-span-2">
<div class="card-body">
<h2 class="card-title">SEO Settings</h2>
{{-- SEO Settings --}}
<div class="cb-form-card lg:col-span-2">
<h2>SEO Settings</h2>
<fieldset class="fieldset mt-4 gap-4">
<fieldset class="fieldset gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- SEO Title -->
<div class="space-y-2">
@@ -357,16 +349,14 @@
@enderror
</div>
</div>
</fieldset>
</div>
</fieldset>
</div>
<!-- Address Information -->
<div class="card bg-base-100 shadow lg:col-span-2">
<div class="card-body">
<h2 class="card-title">Address Information</h2>
{{-- Address Information --}}
<div class="cb-form-card lg:col-span-2">
<h2>Address Information</h2>
<fieldset class="fieldset mt-4 gap-4">
<fieldset class="fieldset gap-4">
<!-- Street Address -->
<div class="space-y-2">
<label class="label text-sm font-medium" for="address">Street Address</label>
@@ -453,16 +443,14 @@
<p class="text-error text-sm">{{ $message }}</p>
@enderror
</div>
</fieldset>
</div>
</fieldset>
</div>
<!-- Social Media -->
<div class="card bg-base-100 shadow lg:col-span-2">
<div class="card-body">
<h2 class="card-title">Social Media</h2>
{{-- Social Media --}}
<div class="cb-form-card lg:col-span-2">
<h2>Social Media</h2>
<fieldset class="fieldset mt-4 gap-4">
<fieldset class="fieldset gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Facebook -->
<div class="space-y-2">
@@ -534,16 +522,14 @@
@enderror
</div>
</div>
</fieldset>
</div>
</fieldset>
</div>
<!-- Visibility Settings -->
<div class="card bg-base-100 shadow lg:col-span-2">
<div class="card-body">
<h2 class="card-title">Visibility Settings</h2>
{{-- Visibility Settings --}}
<div class="cb-form-card lg:col-span-2">
<h2>Visibility Settings</h2>
<fieldset class="fieldset mt-4 gap-4">
<fieldset class="fieldset gap-4">
<div class="flex flex-col gap-3">
<!-- Public Menu -->
<label class="flex items-start gap-3 cursor-pointer">
@@ -584,13 +570,12 @@
</div>
</label>
</div>
</fieldset>
</div>
</fieldset>
</div>
</div>
<!-- Action Buttons -->
<div class="mt-6 flex gap-3 justify-end">
{{-- Action Buttons --}}
<div class="cb-form-actions">
<a href="{{ route('seller.business.brands.index', $business->slug) }}" class="btn btn-ghost">
Cancel
</a>
@@ -600,6 +585,7 @@
</button>
</div>
</form>
</div>
@push('scripts')
<script>

View File

@@ -1,535 +1,349 @@
@extends('layouts.app-with-sidebar')
@section('content')
<div class="px-4 py-6 max-w-7xl mx-auto"
x-data="{
search: '',
statusFilter: 'all',
visibilityFilter: 'all',
sortBy: 'name',
brandSegment: 'all',
selectedBrand: null,
brands: @js($brandsJson),
<div class="nx-shell">
<div class="nx-page space-y-4"
x-data="{
search: '',
statusFilter: 'all',
visibilityFilter: 'all',
sortBy: 'name',
brandSegment: 'all',
showFilters: false,
brands: @js($brandsJson),
get filteredBrands() {
let filtered = this.brands.filter(brand => {
// Search filter
const matchesSearch = !this.search ||
brand.name.toLowerCase().includes(this.search.toLowerCase()) ||
(brand.tagline && brand.tagline.toLowerCase().includes(this.search.toLowerCase()));
get filteredBrands() {
let filtered = this.brands.filter(brand => {
// Search filter
const matchesSearch = !this.search ||
brand.name.toLowerCase().includes(this.search.toLowerCase()) ||
(brand.tagline && brand.tagline.toLowerCase().includes(this.search.toLowerCase()));
// Status filter
const matchesStatus = this.statusFilter === 'all' ||
(this.statusFilter === 'active' && brand.is_active) ||
(this.statusFilter === 'inactive' && !brand.is_active);
// Status filter
const matchesStatus = this.statusFilter === 'all' ||
(this.statusFilter === 'active' && brand.is_active) ||
(this.statusFilter === 'inactive' && !brand.is_active);
// Visibility filter
const matchesVisibility = this.visibilityFilter === 'all' ||
(this.visibilityFilter === 'public' && brand.is_public) ||
(this.visibilityFilter === 'private' && !brand.is_public) ||
(this.visibilityFilter === 'featured' && brand.is_featured);
// Visibility filter
const matchesVisibility = this.visibilityFilter === 'all' ||
(this.visibilityFilter === 'public' && brand.is_public) ||
(this.visibilityFilter === 'private' && !brand.is_public) ||
(this.visibilityFilter === 'featured' && brand.is_featured);
// Segment filter (using real data)
const matchesSegment = this.brandSegment === 'all' ||
(this.brandSegment === 'new' && brand.isNewBrand) ||
(this.brandSegment === 'active' && brand.is_active) ||
(this.brandSegment === 'featured' && brand.is_featured);
// Segment filter (using real data)
const matchesSegment = this.brandSegment === 'all' ||
(this.brandSegment === 'new' && brand.isNewBrand) ||
(this.brandSegment === 'active' && brand.is_active) ||
(this.brandSegment === 'featured' && brand.is_featured);
return matchesSearch && matchesStatus && matchesVisibility && matchesSegment;
});
return matchesSearch && matchesStatus && matchesVisibility && matchesSegment;
});
// Sort
if (this.sortBy === 'name') {
filtered.sort((a, b) => a.name.localeCompare(b.name));
} else if (this.sortBy === 'products') {
filtered.sort((a, b) => b.products_count - a.products_count);
} else if (this.sortBy === 'updated') {
filtered.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
// Sort
if (this.sortBy === 'name') {
filtered.sort((a, b) => a.name.localeCompare(b.name));
} else if (this.sortBy === 'products') {
filtered.sort((a, b) => b.products_count - a.products_count);
} else if (this.sortBy === 'updated') {
filtered.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
}
return filtered;
},
get totalBrands() {
return this.brands.length;
},
get activeBrands() {
return this.brands.filter(b => b.is_active).length;
},
get featuredBrands() {
return this.brands.filter(b => b.is_featured).length;
},
get brandsWithProducts() {
return this.brands.filter(b => b.products_count > 0).length;
},
get brandsWithNoProducts() {
return this.brands.filter(b => b.products_count === 0).length;
},
get avgSKUsPerBrand() {
const total = this.brands.reduce((sum, b) => sum + b.products_count, 0);
return this.brands.length > 0 ? Math.round(total / this.brands.length) : 0;
},
get newBrandsCount() {
return this.brands.filter(b => b.isNewBrand).length;
},
get hasActiveFilters() {
return this.search || this.statusFilter !== 'all' || this.visibilityFilter !== 'all' || this.sortBy !== 'name';
},
clearFilters() {
this.search = '';
this.statusFilter = 'all';
this.visibilityFilter = 'all';
this.sortBy = 'name';
this.brandSegment = 'all';
}
}">
return filtered;
},
get totalBrands() {
return this.brands.length;
},
get activeBrands() {
return this.brands.filter(b => b.is_active).length;
},
get featuredBrands() {
return this.brands.filter(b => b.is_featured).length;
},
get brandsWithProducts() {
return this.brands.filter(b => b.products_count > 0).length;
},
get brandsWithNoProducts() {
return this.brands.filter(b => b.products_count === 0).length;
},
get avgSKUsPerBrand() {
const total = this.brands.reduce((sum, b) => sum + b.products_count, 0);
return this.brands.length > 0 ? Math.round(total / this.brands.length) : 0;
},
get newBrandsCount() {
return this.brands.filter(b => b.isNewBrand).length;
},
selectBrand(brand) {
this.selectedBrand = brand;
}
}">
{{-- 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--bookmark] size-5"></span>
Brand Relationship Hub
</p>
<p class="text-base-content/60 text-sm mt-1">
Performance insights and relationship management for {{ $business->division_name ?? $business->name }}
</p>
{{-- Header Row --}}
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-base-content">Brands</h1>
<p class="text-xs text-base-content/60">{{ $business->division_name ?? $business->name }}</p>
</div>
<a href="{{ route('seller.business.brands.create', $business->slug) }}" class="btn btn-primary btn-sm">
<span class="icon-[heroicons--plus] size-4"></span>
New Brand
</a>
</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 class="opacity-80">Brands</li>
</ul>
</div>
</div>
{{-- Overview Stats --}}
<div class="grid grid-cols-1 gap-5 mb-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{{-- Total Brands --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body gap-2 p-4">
<div class="flex items-start justify-between gap-2">
<div>
<p class="text-base-content/60 font-medium">Total Brands</p>
<div class="mt-3 flex items-center gap-2">
<p class="inline text-2xl font-semibold" x-text="totalBrands"></p>
</div>
</div>
<div class="bg-base-200 rounded-box flex items-center p-1.5">
<span class="icon-[heroicons--bookmark] size-4.5 text-base-content/40"></span>
</div>
{{-- KPI Strip (5 cards - compact style matching Command Center) --}}
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-3">
<div class="card bg-base-100 shadow-sm border border-base-200 p-3">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-xs font-medium text-base-content/70 uppercase tracking-wide">Total</span>
</div>
<p class="text-base-content/60 text-sm" x-show="newBrandsCount > 0">
<div class="text-xl font-bold text-base-content tabular-nums" x-text="totalBrands"></div>
<div class="text-xs text-base-content/50 mt-0.5" x-show="newBrandsCount > 0">
+<span x-text="newBrandsCount"></span> this month
</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm border border-base-200 p-3">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-xs font-medium text-base-content/70 uppercase tracking-wide">Active</span>
</div>
<div class="text-xl font-bold text-base-content tabular-nums" x-text="activeBrands"></div>
<div class="text-xs text-base-content/50 mt-0.5">Currently enabled</div>
</div>
<div class="card bg-base-100 shadow-sm border border-base-200 p-3">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-xs font-medium text-base-content/70 uppercase tracking-wide">Featured</span>
</div>
<div class="text-xl font-bold text-base-content tabular-nums" x-text="featuredBrands"></div>
<div class="text-xs text-base-content/50 mt-0.5">Highlighted</div>
</div>
<div class="card bg-base-100 shadow-sm border border-base-200 p-3">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-xs font-medium text-base-content/70 uppercase tracking-wide">With Products</span>
</div>
<div class="text-xl font-bold text-base-content tabular-nums" x-text="brandsWithProducts"></div>
<div class="text-xs text-base-content/50 mt-0.5"><span x-text="brandsWithNoProducts"></span> need products</div>
</div>
<div class="card bg-base-100 shadow-sm border border-base-200 p-3">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-xs font-medium text-base-content/70 uppercase tracking-wide">Avg SKUs</span>
</div>
<div class="text-xl font-bold text-base-content tabular-nums" x-text="avgSKUsPerBrand"></div>
<div class="text-xs text-base-content/50 mt-0.5">Per brand</div>
</div>
</div>
{{-- Active Brands --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body gap-2 p-4">
<div class="flex items-start justify-between gap-2">
<div>
<p class="text-base-content/60 font-medium">Active Brands</p>
<div class="mt-3 flex items-center gap-2">
<p class="inline text-2xl font-semibold" x-text="activeBrands"></p>
</div>
</div>
<div class="bg-base-200 rounded-box flex items-center p-1.5">
<span class="icon-[heroicons--check-circle] size-4.5 text-base-content/40"></span>
</div>
</div>
<p class="text-base-content/60 text-sm">Currently enabled</p>
</div>
</div>
{{-- Featured Brands --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body gap-2 p-4">
<div class="flex items-start justify-between gap-2">
<div>
<p class="text-base-content/60 font-medium">Featured</p>
<div class="mt-3 flex items-center gap-2">
<p class="inline text-2xl font-semibold" x-text="featuredBrands"></p>
</div>
</div>
<div class="bg-base-200 rounded-box flex items-center p-1.5">
<span class="icon-[heroicons--star] size-4.5 text-base-content/40"></span>
</div>
</div>
<p class="text-base-content/60 text-sm">Highlighted brands</p>
</div>
</div>
{{-- With Products --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body gap-2 p-4">
<div class="flex items-start justify-between gap-2">
<div>
<p class="text-base-content/60 font-medium">With Products</p>
<div class="mt-3 flex items-center gap-2">
<p class="inline text-2xl font-semibold" x-text="brandsWithProducts"></p>
</div>
</div>
<div class="bg-base-200 rounded-box flex items-center p-1.5">
<span class="icon-[heroicons--building-storefront] size-4.5 text-base-content/40"></span>
</div>
</div>
<p class="text-base-content/60 text-sm">
<span x-text="brandsWithNoProducts"></span> need products
</p>
</div>
</div>
{{-- Avg SKUs --}}
<div class="card bg-base-100 shadow-sm">
<div class="card-body gap-2 p-4">
<div class="flex items-start justify-between gap-2">
<div>
<p class="text-base-content/60 font-medium">Avg SKUs per Brand</p>
<div class="mt-3 flex items-center gap-2">
<p class="inline text-2xl font-semibold" x-text="avgSKUsPerBrand"></p>
</div>
</div>
<div class="bg-base-200 rounded-box flex items-center p-1.5">
<span class="icon-[heroicons--cube] size-4.5 text-base-content/40"></span>
</div>
</div>
<p class="text-base-content/60 text-sm">Product catalog depth</p>
</div>
</div>
</div>
{{-- Filters --}}
<div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body p-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{{-- Search --}}
<div class="form-control">
<input type="text"
x-model="search"
placeholder="Search brands..."
class="input input-sm input-bordered w-full">
</div>
{{-- Status Filter --}}
<div class="form-control">
<select x-model="statusFilter" class="select select-sm select-bordered w-full">
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
{{-- Visibility Filter --}}
<div class="form-control">
<select x-model="visibilityFilter" class="select select-sm select-bordered w-full">
<option value="all">All Visibility</option>
<option value="public">Public</option>
<option value="private">Private</option>
<option value="featured">Featured</option>
</select>
</div>
{{-- Sort --}}
<div class="form-control">
<select x-model="sortBy" class="select select-sm select-bordered w-full">
<option value="name">A-Z</option>
<option value="products">Most SKUs</option>
<option value="updated">Recently Updated</option>
</select>
</div>
</div>
{{-- Segmentation Filters --}}
<div class="mt-3 flex flex-wrap gap-2 border-t border-base-300 pt-3">
{{-- Compact Filter Row --}}
<div class="flex flex-wrap items-center gap-2">
{{-- Tab Pills --}}
<div class="flex items-center gap-1 bg-base-200 rounded-lg p-0.5">
<button @click="brandSegment = 'all'"
:class="brandSegment === 'all' ? 'btn-primary' : 'btn-ghost'"
class="btn btn-xs gap-1">
<span class="icon-[heroicons--squares-2x2] size-3"></span>
All Brands
:class="brandSegment === 'all' ? 'bg-base-100 shadow-sm text-base-content' : 'text-base-content/50 hover:text-base-content/70'"
class="px-2.5 py-1 text-xs font-medium rounded-md transition-all">
All
</button>
<button @click="brandSegment = 'active'"
:class="brandSegment === 'active' ? 'btn-primary' : 'btn-ghost'"
class="btn btn-xs gap-1">
<span class="icon-[heroicons--check-circle] size-3"></span>
:class="brandSegment === 'active' ? 'bg-base-100 shadow-sm text-base-content' : 'text-base-content/50 hover:text-base-content/70'"
class="px-2.5 py-1 text-xs font-medium rounded-md transition-all">
Active
</button>
<button @click="brandSegment = 'featured'"
:class="brandSegment === 'featured' ? 'btn-primary' : 'btn-ghost'"
class="btn btn-xs gap-1">
<span class="icon-[heroicons--star] size-3"></span>
:class="brandSegment === 'featured' ? 'bg-base-100 shadow-sm text-base-content' : 'text-base-content/50 hover:text-base-content/70'"
class="px-2.5 py-1 text-xs font-medium rounded-md transition-all">
Featured
</button>
<button @click="brandSegment = 'new'"
:class="brandSegment === 'new' ? 'btn-primary' : 'btn-ghost'"
class="btn btn-xs gap-1">
<span class="icon-[heroicons--sparkles] size-3"></span>
:class="brandSegment === 'new' ? 'bg-base-100 shadow-sm text-base-content' : 'text-base-content/50 hover:text-base-content/70'"
class="px-2.5 py-1 text-xs font-medium rounded-md transition-all flex items-center gap-1">
New
<span x-show="newBrandsCount > 0" class="badge badge-xs" x-text="newBrandsCount"></span>
<span x-show="newBrandsCount > 0" class="badge badge-xs badge-primary" x-text="newBrandsCount"></span>
</button>
</div>
{{-- Results Count --}}
<div class="mt-3 flex items-center justify-between border-t border-base-300 pt-3">
<p class="text-base-content/60 text-sm">
Showing <span class="font-semibold" x-text="filteredBrands.length"></span> of <span class="font-semibold">{{ $brands->count() }}</span> brands
</p>
<button @click="search = ''; statusFilter = 'all'; visibilityFilter = 'all'; sortBy = 'name'; brandSegment = 'all'"
class="btn btn-ghost btn-sm gap-1">
<span class="icon-[heroicons--x-mark] size-4"></span>
Clear
</button>
{{-- Divider --}}
<div class="w-px h-5 bg-base-300 hidden sm:block"></div>
{{-- Search --}}
<div class="relative flex-1 min-w-[140px] max-w-[200px]">
<span class="icon-[heroicons--magnifying-glass] size-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-base-content/40"></span>
<input type="text"
x-model="search"
placeholder="Search..."
class="input input-xs input-bordered w-full pl-8 h-7">
</div>
{{-- Status --}}
<select x-model="statusFilter" class="select select-xs select-bordered h-7 min-h-0">
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
{{-- Visibility --}}
<select x-model="visibilityFilter" class="select select-xs select-bordered h-7 min-h-0">
<option value="all">All Visibility</option>
<option value="public">Public</option>
<option value="private">Private</option>
<option value="featured">Featured</option>
</select>
{{-- Sort --}}
<select x-model="sortBy" class="select select-xs select-bordered h-7 min-h-0">
<option value="name">A-Z</option>
<option value="products">Most SKUs</option>
<option value="updated">Recent</option>
</select>
{{-- Clear (only when filters active) --}}
<button x-show="hasActiveFilters"
x-cloak
@click="clearFilters()"
class="btn btn-ghost btn-xs h-7 min-h-0 gap-1 text-base-content/60">
<span class="icon-[heroicons--x-mark] size-3"></span>
Clear
</button>
{{-- Results count (muted, right-aligned) --}}
<div class="flex-1"></div>
<span class="text-xs text-base-content/50">
<span x-text="filteredBrands.length"></span> of {{ $brands->count() }}
</span>
</div>
</div>
{{-- Empty State --}}
@if($brands->isEmpty())
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-16 text-center">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full bg-base-200 mb-4 mx-auto">
<span class="icon-[heroicons--bookmark] size-10 text-base-content/40"></span>
</div>
<h3 class="text-xl font-semibold mb-2">No brands yet</h3>
<p class="text-base-content/60 mb-6 max-w-md mx-auto">
Get started by creating your first brand. Brands help you organize and market your products.
</p>
<div>
<a href="{{ route('seller.business.brands.create', $business->slug) }}" class="btn btn-primary gap-2">
{{-- Empty State --}}
@if($brands->isEmpty())
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-12 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-base-200 mb-4 mx-auto">
<span class="icon-[heroicons--bookmark] size-8 text-base-content/40"></span>
</div>
<h3 class="text-lg font-semibold mb-2">No brands yet</h3>
<p class="text-base-content/60 text-sm mb-4 max-w-sm mx-auto">
Get started by creating your first brand. Brands help you organize and market your products.
</p>
<a href="{{ route('seller.business.brands.create', $business->slug) }}" class="btn btn-primary btn-sm gap-2">
<span class="icon-[heroicons--plus] size-4"></span>
Create Your First Brand
</a>
</div>
</div>
</div>
@else
{{-- Main Content Grid --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Brand Cards Grid --}}
<div class="lg:col-span-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<template x-for="brand in filteredBrands" :key="brand.id">
<div @click="selectBrand(brand)"
:class="selectedBrand && selectedBrand.id === brand.id ? 'ring-1 ring-base-300' : ''"
class="card bg-base-100 shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer">
<div class="card-body p-4">
{{-- Logo & Name --}}
<div class="flex items-start gap-3 mb-3">
{{-- Logo --}}
<div x-show="brand.logo_url" class="flex-shrink-0">
<img :src="brand.logo_url"
:alt="brand.name"
class="w-12 h-12 object-contain rounded-lg border border-base-300">
</div>
<div x-show="!brand.logo_url" class="flex-shrink-0">
<div class="avatar placeholder">
<div class="bg-base-200 text-base-content/60 w-12 h-12 rounded-lg">
<span class="text-lg font-semibold" x-text="brand.name.charAt(0)"></span>
</div>
@else
{{-- Brand Cards Grid (unchanged layout) --}}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<template x-for="brand in filteredBrands" :key="brand.id">
<div class="card bg-base-100 shadow-sm border border-base-200 hover:shadow-md transition-shadow duration-200">
<div class="card-body p-4">
{{-- Logo & Name --}}
<div class="flex items-start gap-3 mb-3">
{{-- Logo --}}
<div x-show="brand.logo_url" class="flex-shrink-0">
<img :src="brand.logo_url"
:alt="brand.name"
class="w-12 h-12 object-contain rounded-lg border border-base-300">
</div>
<div x-show="!brand.logo_url" class="flex-shrink-0">
<div class="avatar placeholder">
<div class="bg-base-200 text-base-content/60 w-12 h-12 rounded-lg">
<span class="text-lg font-semibold" x-text="brand.name.charAt(0)"></span>
</div>
</div>
</div>
{{-- Name & Status --}}
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-base truncate" x-text="brand.name"></h3>
<p class="text-xs text-base-content/60 mt-0.5" x-text="brand.updated_at ? 'Updated ' + brand.updated_at : ''"></p>
</div>
{{-- Name & Status --}}
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-base truncate" x-text="brand.name"></h3>
<p class="text-xs text-base-content/60 mt-0.5" x-text="brand.updated_at ? 'Updated ' + brand.updated_at : ''"></p>
</div>
{{-- Website Link --}}
<a x-show="brand.website_url"
:href="brand.website_url"
@click.stop
target="_blank"
rel="noopener"
class="text-base-content/60 hover:text-primary transition-colors flex-shrink-0"
:title="brand.website_url">
<span class="icon-[heroicons--arrow-top-right-on-square] size-3.5"></span>
{{-- Website Link --}}
<a x-show="brand.website_url"
:href="brand.website_url"
target="_blank"
rel="noopener"
class="text-base-content/60 hover:text-primary transition-colors flex-shrink-0"
:title="brand.website_url">
<span class="icon-[heroicons--arrow-top-right-on-square] size-3.5"></span>
</a>
</div>
{{-- Tagline --}}
<p x-show="brand.tagline"
class="text-xs text-base-content/60 mb-3 line-clamp-2"
x-text="brand.tagline"></p>
{{-- Stats --}}
<div class="flex items-center gap-3 text-xs text-base-content/60 mb-3">
<div class="flex items-center gap-1">
<span class="icon-[heroicons--cube] size-3"></span>
<span x-text="brand.products_count + ' products'"></span>
</div>
<span x-show="brand.isNewBrand" class="badge badge-primary badge-xs">New</span>
</div>
{{-- Status Badges --}}
<div class="flex flex-wrap gap-1.5 mb-3">
<span x-show="brand.is_active" class="badge badge-success badge-xs">Active</span>
<span x-show="!brand.is_active" class="badge badge-ghost badge-xs">Inactive</span>
<span x-show="brand.is_featured" class="badge badge-warning badge-xs">Featured</span>
<span x-show="brand.is_public && !brand.is_featured" class="badge badge-ghost badge-xs">Public</span>
<span x-show="!brand.is_public" class="badge badge-ghost badge-xs">Private</span>
</div>
{{-- Actions --}}
<div class="pt-3 border-t border-base-300">
<div class="join join-horizontal w-full">
<a :href="brand.dashboard_url"
class="join-item btn btn-xs btn-ghost flex-1 gap-1">
<span class="icon-[heroicons--eye] size-3"></span>
<span class="text-xs">View</span>
</a>
<a :href="brand.dashboard_url + '?tab=products'"
class="join-item btn btn-xs btn-ghost flex-1 gap-1">
<span class="icon-[heroicons--cube] size-3"></span>
<span class="text-xs">Products</span>
</a>
<a :href="brand.dashboard_url + '?tab=analytics'"
class="join-item btn btn-xs btn-ghost flex-1 gap-1">
<span class="icon-[heroicons--chart-bar] size-3"></span>
<span class="text-xs">Analytics</span>
</a>
</div>
{{-- Tagline --}}
<p x-show="brand.tagline"
class="text-xs text-base-content/60 mb-3 line-clamp-2"
x-text="brand.tagline"></p>
{{-- Stats --}}
<div class="flex items-center gap-3 text-xs text-base-content/60 mb-3">
<div class="flex items-center gap-1">
<span class="icon-[heroicons--cube] size-3"></span>
<span x-text="brand.products_count + ' products'"></span>
</div>
<span x-show="brand.isNewBrand" class="badge badge-primary badge-xs">New</span>
</div>
{{-- Status Badges --}}
<div class="flex flex-wrap gap-1.5 mb-3">
<span x-show="brand.is_active" class="badge badge-success badge-xs">Active</span>
<span x-show="!brand.is_active" class="badge badge-ghost badge-xs">Inactive</span>
<span x-show="brand.is_featured" class="badge badge-warning badge-xs">Featured</span>
<span x-show="brand.is_public && !brand.is_featured" class="badge badge-ghost badge-xs">Public</span>
<span x-show="!brand.is_public" class="badge badge-ghost badge-xs">Private</span>
</div>
{{-- Actions --}}
<div class="pt-3 border-t border-base-300">
<div class="join join-horizontal w-full">
<a :href="brand.dashboard_url"
@click.stop
class="join-item btn btn-xs btn-ghost flex-1 gap-1">
<span class="icon-[heroicons--eye] size-3"></span>
<span class="text-xs">View</span>
</a>
<a :href="brand.dashboard_url + '?tab=products'"
@click.stop
class="join-item btn btn-xs btn-ghost flex-1 gap-1">
<span class="icon-[heroicons--cube] size-3"></span>
<span class="text-xs">Products</span>
</a>
<a :href="brand.dashboard_url + '?tab=analytics'"
@click.stop
class="join-item btn btn-xs btn-ghost flex-1 gap-1">
<span class="icon-[heroicons--chart-bar] size-3"></span>
<span class="text-xs">Analytics</span>
</a>
</div>
</div>
</div>
</div>
</template>
</div>
{{-- No Results State --}}
<div x-show="filteredBrands.length === 0 && brands.length > 0" class="card bg-base-100 shadow-sm">
<div class="card-body p-16 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-base-200 mb-4 mx-auto">
<span class="icon-[heroicons--magnifying-glass-minus] size-8 text-base-content/40"></span>
</div>
<h3 class="text-lg font-semibold mb-2">No brands found</h3>
<p class="text-base-content/60 mb-4">
Try adjusting your filters or search terms
</p>
<button @click="search = ''; statusFilter = 'all'; visibilityFilter = 'all'; sortBy = 'name'; brandSegment = 'all'"
class="btn btn-outline btn-sm">
Clear All Filters
</button>
</div>
</div>
</template>
</div>
{{-- Brand Insights Panel --}}
<div class="lg:col-span-1">
{{-- Placeholder State --}}
<div x-show="!selectedBrand" class="card bg-base-100 shadow-sm sticky top-6">
<div class="card-body p-6 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-base-200 mb-4 mx-auto">
<span class="icon-[heroicons--eye] size-8 text-base-content/40"></span>
</div>
<h3 class="font-semibold mb-2">Brand Insights</h3>
<p class="text-sm text-base-content/60">
Click on any brand card to view detailed insights and performance metrics
</p>
</div>
</div>
{{-- Selected Brand Insights --}}
<div x-cloak x-show="selectedBrand" class="card bg-base-100 shadow-sm sticky top-6">
<div class="card-body p-4" x-show="selectedBrand">
{{-- Header --}}
<div class="flex items-start gap-3 mb-4 pb-4 border-b border-base-300">
{{-- Logo --}}
<div x-show="selectedBrand?.logo_url" class="flex-shrink-0">
<img :src="selectedBrand?.logo_url"
:alt="selectedBrand?.name"
class="w-12 h-12 object-contain rounded-lg border border-base-300">
</div>
<div x-show="!selectedBrand?.logo_url" class="flex-shrink-0">
<div class="avatar placeholder">
<div class="bg-base-200 text-base-content/60 w-12 h-12 rounded-lg">
<span class="text-lg font-semibold" x-text="selectedBrand?.name.charAt(0)"></span>
</div>
</div>
</div>
{{-- Name & Status --}}
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-base truncate" x-text="selectedBrand?.name"></h3>
<p class="text-xs text-base-content/60 mt-0.5" x-text="selectedBrand?.updated_at ? 'Updated ' + selectedBrand.updated_at : ''"></p>
</div>
{{-- Close Button --}}
<button @click="selectedBrand = null"
class="btn btn-ghost btn-xs btn-circle">
<span class="icon-[heroicons--x-mark] size-4"></span>
</button>
</div>
{{-- Stats --}}
<div class="card bg-base-200 shadow-sm mb-4">
<div class="card-body p-3">
<p class="text-xs text-base-content/60">Products</p>
<p class="text-2xl font-bold" x-text="selectedBrand?.products_count"></p>
</div>
</div>
{{-- Status --}}
<div class="mb-4">
<h4 class="font-semibold text-sm mb-3">Status</h4>
<div class="flex flex-wrap gap-2">
<span x-show="selectedBrand?.is_active" class="badge badge-success">Active</span>
<span x-show="!selectedBrand?.is_active" class="badge badge-ghost">Inactive</span>
<span x-show="selectedBrand?.is_featured" class="badge badge-warning">Featured</span>
<span x-show="selectedBrand?.is_public" class="badge badge-info">Public</span>
<span x-show="!selectedBrand?.is_public" class="badge badge-ghost">Private</span>
<span x-show="selectedBrand?.isNewBrand" class="badge badge-primary">New</span>
</div>
</div>
{{-- Website --}}
<div x-show="selectedBrand?.website_url" class="mb-4">
<h4 class="font-semibold text-sm mb-2">Website</h4>
<a :href="selectedBrand?.website_url"
target="_blank"
rel="noopener"
class="link link-primary text-sm flex items-center gap-1">
<span x-text="selectedBrand?.website_url"></span>
<span class="icon-[heroicons--arrow-top-right-on-square] size-3"></span>
</a>
</div>
{{-- Suggested Next Step --}}
<div class="card bg-base-200 shadow-sm mb-4">
<div class="card-body p-3">
<h4 class="font-semibold text-sm mb-2 flex items-center gap-2">
<span class="icon-[heroicons--light-bulb] size-4"></span>
Suggested Next Step
</h4>
<p class="text-xs text-base-content/60" x-show="selectedBrand?.products_count === 0">
This brand has no products yet. Add your first product to get started.
</p>
<p class="text-xs text-base-content/60" x-show="selectedBrand?.products_count > 0 && !selectedBrand?.is_featured">
Feature this brand on buyer menus to increase visibility and orders.
</p>
<p class="text-xs text-base-content/60" x-show="selectedBrand?.products_count > 0 && selectedBrand?.is_featured">
This brand is featured. Consider expanding the product line.
</p>
</div>
</div>
{{-- Quick Action --}}
<a :href="selectedBrand?.dashboard_url"
class="btn btn-primary btn-sm w-full gap-2">
<span class="icon-[heroicons--arrow-right] size-4"></span>
Open Brand Dashboard
</a>
{{-- No Results State --}}
<div x-show="filteredBrands.length === 0 && brands.length > 0" x-cloak class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-12 text-center">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-base-200 mb-3 mx-auto">
<span class="icon-[heroicons--magnifying-glass-minus] size-6 text-base-content/40"></span>
</div>
<h3 class="text-base font-semibold mb-1">No brands found</h3>
<p class="text-base-content/60 text-sm mb-3">
Try adjusting your filters
</p>
<button @click="clearFilters()"
class="btn btn-outline btn-xs">
Clear All Filters
</button>
</div>
</div>
@endif
</div>
@endif
</div>
@endsection
@push('scripts')
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
@endpush

View File

@@ -1,11 +1,14 @@
@extends('layouts.app-with-sidebar')
@section('content')
@php
$locationParam = ($selectedLocation ?? null) ? ['location' => $selectedLocation->id] : [];
@endphp
<div class="max-w-7xl 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.accounts.show', [$business->slug, $account->slug]) }}" class="btn btn-ghost btn-sm btn-square">
<a href="{{ route('seller.business.crm.accounts.show', array_merge([$business->slug, $account->slug], $locationParam)) }}" class="btn btn-ghost btn-sm btn-square">
<span class="icon-[heroicons--arrow-left] size-4"></span>
</a>
<div>
@@ -15,14 +18,27 @@
</div>
</header>
{{-- Location Scope Banner --}}
@if($selectedLocation ?? null)
<div class="flex items-center gap-2 text-sm">
<span class="badge badge-ghost text-neutral border-base-300">
<span class="icon-[heroicons--map-pin] size-3 mr-1"></span>
Viewing: {{ $selectedLocation->name }}
</span>
<a href="{{ route('seller.business.crm.accounts.activity', [$business->slug, $account->slug]) }}"
class="text-xs text-primary hover:underline">Clear location filter</a>
<span class="text-xs text-neutral/50">(Activity doesn't have location filtering yet)</span>
</div>
@endif
{{-- Tabs --}}
<div role="tablist" class="tabs tabs-bordered">
<a href="{{ route('seller.business.crm.accounts.show', [$business->slug, $account->slug]) }}" class="tab">Overview</a>
<a href="{{ route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug]) }}" class="tab">Contacts</a>
<a href="{{ route('seller.business.crm.accounts.opportunities', [$business->slug, $account->slug]) }}" class="tab">Opportunities</a>
<a href="{{ route('seller.business.crm.accounts.orders', [$business->slug, $account->slug]) }}" class="tab">Orders</a>
<a href="{{ route('seller.business.crm.accounts.tasks', [$business->slug, $account->slug]) }}" class="tab">Tasks</a>
<a href="{{ route('seller.business.crm.accounts.activity', [$business->slug, $account->slug]) }}" class="tab tab-active">Activity</a>
<a href="{{ route('seller.business.crm.accounts.show', array_merge([$business->slug, $account->slug], $locationParam)) }}" class="tab">Overview</a>
<a href="{{ route('seller.business.crm.accounts.contacts', array_merge([$business->slug, $account->slug], $locationParam)) }}" class="tab">Contacts</a>
<a href="{{ route('seller.business.crm.accounts.opportunities', array_merge([$business->slug, $account->slug], $locationParam)) }}" class="tab">Opportunities</a>
<a href="{{ route('seller.business.crm.accounts.orders', array_merge([$business->slug, $account->slug], $locationParam)) }}" class="tab">Orders</a>
<a href="{{ route('seller.business.crm.accounts.tasks', array_merge([$business->slug, $account->slug], $locationParam)) }}" class="tab">Tasks</a>
<a href="{{ route('seller.business.crm.accounts.activity', array_merge([$business->slug, $account->slug], $locationParam)) }}" class="tab tab-active">Activity</a>
</div>
{{-- Activity Table --}}

View File

@@ -15,7 +15,7 @@
</div>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.crm.accounts.index', $business->slug) }}">Customers</a></li>
<li><a href="{{ route('seller.business.crm.accounts.index', $business->slug) }}">Accounts</a></li>
<li><a href="{{ route('seller.business.crm.accounts.show', [$business->slug, $account->slug]) }}">{{ $account->name }}</a></li>
<li><a href="{{ route('seller.business.crm.accounts.contacts', [$business->slug, $account->slug]) }}">Contacts</a></li>
<li class="opacity-80">Edit</li>

Some files were not shown because too many files have changed in this diff Show More