Compare commits
87 Commits
fix/gitea-
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec9853c571 | ||
|
|
c7d6ee5e21 | ||
|
|
496ca61489 | ||
|
|
a812380b32 | ||
|
|
9bb0f6d373 | ||
|
|
798476e991 | ||
|
|
bad6c24597 | ||
|
|
5b7898f478 | ||
|
|
9cc582b869 | ||
|
|
ac70cc0247 | ||
|
|
eb95528b76 | ||
|
|
879d1c61df | ||
|
|
0f5901e55f | ||
|
|
8fcc3629bd | ||
|
|
0b54c251bc | ||
|
|
8995c60d88 | ||
|
|
c4e178a900 | ||
|
|
6688bbf8a1 | ||
|
|
bb5f2c8aaa | ||
|
|
a9d0f328a8 | ||
|
|
3b769905b7 | ||
|
|
f7727d8c17 | ||
|
|
6d7eb4f151 | ||
|
|
0c260f69b0 | ||
|
|
63b9372372 | ||
|
|
aaff332937 | ||
|
|
964548ba38 | ||
|
|
cf05d8cad1 | ||
|
|
05dca8f847 | ||
|
|
27328c9106 | ||
|
|
b3dd9a8e23 | ||
|
|
1cd6c15cb3 | ||
|
|
3554578554 | ||
|
|
3962807fc6 | ||
|
|
32054ddcce | ||
|
|
5905699ca1 | ||
|
|
eb8e2a89c4 | ||
|
|
8286aebf4e | ||
|
|
4cff4af841 | ||
|
|
8abcd3291e | ||
|
|
a7c3eb4183 | ||
|
|
1ed62fe0de | ||
|
|
160b312ca5 | ||
|
|
6d22a99259 | ||
|
|
febfd75016 | ||
|
|
fbb72f902b | ||
|
|
fd11ae0fe0 | ||
|
|
16c5c455fa | ||
|
|
df587fdda3 | ||
|
|
3fb5747aa2 | ||
|
|
33c9420b00 | ||
|
|
37204edfd7 | ||
|
|
8d9725b501 | ||
|
|
6cf8ad1854 | ||
|
|
58f787feb0 | ||
|
|
970ce05846 | ||
|
|
672b0d5f6b | ||
|
|
4415194b28 | ||
|
|
213b0ef8f2 | ||
|
|
13dbe046e1 | ||
|
|
592df4de44 | ||
|
|
ae581b4d5c | ||
|
|
8a8f83cc0c | ||
|
|
722904d487 | ||
|
|
ddc84f6730 | ||
|
|
2c510844f0 | ||
|
|
105a1e8ce0 | ||
|
|
7e06ff3488 | ||
|
|
aed1e62c65 | ||
|
|
f9f1b8dc46 | ||
|
|
89d3a54988 | ||
|
|
0c60e5c519 | ||
|
|
1ecc4a916b | ||
|
|
d4ec8c16f3 | ||
|
|
f9d7573cb4 | ||
|
|
e48e9c9b82 | ||
|
|
afbb1ba79c | ||
|
|
5f0042e483 | ||
|
|
08f5a3adac | ||
|
|
e62ea5c809 | ||
|
|
8d43953cad | ||
|
|
a628f2b207 | ||
|
|
367daadfe9 | ||
|
|
329c01523a | ||
|
|
5fb26f901d | ||
|
|
a3508c57a2 | ||
|
|
b33ebac9bf |
@@ -22,6 +22,16 @@ when:
|
||||
event: push
|
||||
- event: [pull_request, tag]
|
||||
|
||||
# Use explicit git clone plugin to fix auth issues
|
||||
# The default clone was failing with "could not read Username"
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
depth: 50
|
||||
lfs: false
|
||||
partial: false
|
||||
|
||||
steps:
|
||||
# ============================================
|
||||
# DEPENDENCY INSTALLATION (Sequential)
|
||||
|
||||
203
app/Filament/Pages/CannaiqSettings.php
Normal file
203
app/Filament/Pages/CannaiqSettings.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Services\Cannaiq\CannaiqClient;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class CannaiqSettings extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-chart-bar-square';
|
||||
|
||||
protected string $view = 'filament.pages.cannaiq-settings';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'Integrations';
|
||||
|
||||
protected static ?string $navigationLabel = 'CannaiQ';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
protected static ?string $title = 'CannaiQ Settings';
|
||||
|
||||
protected static ?string $slug = 'cannaiq-settings';
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return auth('admin')->check();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'base_url' => config('services.cannaiq.base_url'),
|
||||
'api_key' => '', // Never show the actual key
|
||||
'cache_ttl' => config('services.cannaiq.cache_ttl', 7200),
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
$apiKeyConfigured = ! empty(config('services.cannaiq.api_key'));
|
||||
$baseUrl = config('services.cannaiq.base_url');
|
||||
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('CannaiQ Integration')
|
||||
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
|
||||
->schema([
|
||||
Placeholder::make('status')
|
||||
->label('Connection Status')
|
||||
->content(function () use ($apiKeyConfigured, $baseUrl) {
|
||||
$statusHtml = '<div class="space-y-2">';
|
||||
|
||||
// API Key status
|
||||
if ($apiKeyConfigured) {
|
||||
$statusHtml .= '<div class="flex items-center gap-2 text-success-600 dark:text-success-400">'.
|
||||
'<span class="text-lg">✓</span>'.
|
||||
'<span>API Key configured</span>'.
|
||||
'</div>';
|
||||
} else {
|
||||
$statusHtml .= '<div class="flex items-center gap-2 text-warning-600 dark:text-warning-400">'.
|
||||
'<span class="text-lg">⚠</span>'.
|
||||
'<span>API Key not configured (using trusted origin auth)</span>'.
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Base URL
|
||||
$statusHtml .= '<div class="text-sm text-gray-500 dark:text-gray-400">'.
|
||||
'Base URL: <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">'.$baseUrl.'</code>'.
|
||||
'</div>';
|
||||
|
||||
$statusHtml .= '</div>';
|
||||
|
||||
return new HtmlString($statusHtml);
|
||||
}),
|
||||
|
||||
Placeholder::make('features')
|
||||
->label('Features Enabled')
|
||||
->content(new HtmlString(
|
||||
'<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 p-4">'.
|
||||
'<ul class="list-disc list-inside text-sm text-gray-600 dark:text-gray-400 space-y-1">'.
|
||||
'<li><strong>Brand Analysis</strong> - Market positioning, SKU velocity, shelf opportunities</li>'.
|
||||
'<li><strong>Marketing Intelligence</strong> - Competitive insights and recommendations</li>'.
|
||||
'<li><strong>Promo Recommendations</strong> - AI-powered promotional strategies</li>'.
|
||||
'<li><strong>Store Playbook</strong> - Actionable insights for retail partners</li>'.
|
||||
'</ul>'.
|
||||
'</div>'
|
||||
)),
|
||||
]),
|
||||
|
||||
Section::make('Configuration')
|
||||
->description('CannaiQ is configured via environment variables. Update your .env file to change these settings.')
|
||||
->schema([
|
||||
TextInput::make('base_url')
|
||||
->label('Base URL')
|
||||
->disabled()
|
||||
->helperText('Set via CANNAIQ_BASE_URL environment variable'),
|
||||
|
||||
TextInput::make('cache_ttl')
|
||||
->label('Cache TTL (seconds)')
|
||||
->disabled()
|
||||
->helperText('Set via CANNAIQ_CACHE_TTL environment variable. Default: 7200 (2 hours)'),
|
||||
|
||||
Placeholder::make('env_example')
|
||||
->label('Environment Variables')
|
||||
->content(new HtmlString(
|
||||
'<div class="rounded-lg bg-gray-900 text-gray-100 p-4 font-mono text-sm overflow-x-auto">'.
|
||||
'<div class="text-gray-400"># CannaiQ Configuration</div>'.
|
||||
'<div>CANNAIQ_BASE_URL=https://cannaiq.co/api/v1</div>'.
|
||||
'<div>CANNAIQ_API_KEY=your-api-key-here</div>'.
|
||||
'<div>CANNAIQ_CACHE_TTL=7200</div>'.
|
||||
'</div>'
|
||||
)),
|
||||
])
|
||||
->collapsed(),
|
||||
|
||||
Section::make('Business Access')
|
||||
->description('CannaiQ features must be enabled per-business in the Business settings.')
|
||||
->schema([
|
||||
Placeholder::make('business_info')
|
||||
->label('')
|
||||
->content(new HtmlString(
|
||||
'<div class="rounded-lg border border-info-200 bg-info-50 dark:border-info-800 dark:bg-info-950 p-4">'.
|
||||
'<div class="flex items-start gap-3">'.
|
||||
'<span class="text-info-600 dark:text-info-400 text-lg">ⓘ</span>'.
|
||||
'<div class="text-sm">'.
|
||||
'<p class="font-medium text-info-800 dark:text-info-200">How to enable CannaiQ for a business:</p>'.
|
||||
'<ol class="list-decimal list-inside mt-2 text-info-700 dark:text-info-300 space-y-1">'.
|
||||
'<li>Go to <strong>Users → Businesses</strong></li>'.
|
||||
'<li>Edit the business</li>'.
|
||||
'<li>Go to the <strong>Integrations</strong> tab</li>'.
|
||||
'<li>Toggle <strong>Enable CannaiQ</strong></li>'.
|
||||
'</ol>'.
|
||||
'</div>'.
|
||||
'</div>'.
|
||||
'</div>'
|
||||
)),
|
||||
]),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function testConnection(): void
|
||||
{
|
||||
try {
|
||||
$client = app(CannaiqClient::class);
|
||||
|
||||
// Try to fetch something from the API to verify connection
|
||||
// We'll use a simple health check or fetch minimal data
|
||||
$response = $client->getBrandAnalysis('test-brand', 'test-business');
|
||||
|
||||
// If we get here without exception, connection works
|
||||
// (even if the response is empty/error from CannaiQ side)
|
||||
Notification::make()
|
||||
->title('Connection Test')
|
||||
->body('Successfully connected to CannaiQ API')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->title('Connection Failed')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
// Clear all CannaiQ-related cache keys
|
||||
$patterns = [
|
||||
'cannaiq:*',
|
||||
'brand_analysis:*',
|
||||
];
|
||||
|
||||
$cleared = 0;
|
||||
foreach ($patterns as $pattern) {
|
||||
// Note: This is a simplified clear - in production you might want
|
||||
// to use Redis SCAN for pattern matching
|
||||
Cache::forget($pattern);
|
||||
$cleared++;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Cache Cleared')
|
||||
->body('CannaiQ cache has been cleared')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
@@ -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'].'.%');
|
||||
}
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -701,17 +701,6 @@ class BusinessResource extends Resource
|
||||
}),
|
||||
]),
|
||||
|
||||
// ===== CANNAIQ SECTION =====
|
||||
// CannaiQ Marketing Intelligence Engine
|
||||
Section::make('CannaiQ')
|
||||
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
|
||||
->schema([
|
||||
Toggle::make('cannaiq_enabled')
|
||||
->label('Enable CannaiQ')
|
||||
->helperText('When enabled, this business gets access to Intelligence and Promos features under the Growth menu.')
|
||||
->default(false),
|
||||
]),
|
||||
|
||||
// ===== SUITE ASSIGNMENT SECTION =====
|
||||
// Suites control feature access (menus, screens, capabilities)
|
||||
Section::make('Suite Assignment')
|
||||
@@ -863,6 +852,40 @@ class BusinessResource extends Resource
|
||||
]),
|
||||
]),
|
||||
|
||||
// ===== INTEGRATIONS TAB =====
|
||||
// Third-party service integrations
|
||||
Tab::make('Integrations')
|
||||
->icon('heroicon-o-link')
|
||||
->schema([
|
||||
// ===== CANNAIQ SECTION =====
|
||||
Section::make('CannaiQ')
|
||||
->description('CannaiQ is the Marketing Intelligence Engine that powers competitive analysis, pricing intelligence, and promotional recommendations.')
|
||||
->schema([
|
||||
Toggle::make('cannaiq_enabled')
|
||||
->label('Enable CannaiQ')
|
||||
->helperText('When enabled, this business gets access to Brand Analysis, Intelligence, and Promos features.')
|
||||
->default(false),
|
||||
|
||||
Forms\Components\Placeholder::make('cannaiq_info')
|
||||
->label('')
|
||||
->content(new \Illuminate\Support\HtmlString(
|
||||
'<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900 p-4 text-sm">'.
|
||||
'<div class="font-medium text-gray-700 dark:text-gray-300 mb-2">CannaiQ Features</div>'.
|
||||
'<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-1">'.
|
||||
'<li>Brand Analysis - Market positioning, SKU velocity, shelf opportunities</li>'.
|
||||
'<li>Marketing Intelligence - Competitive insights and recommendations</li>'.
|
||||
'<li>Promo Recommendations - AI-powered promotional strategies</li>'.
|
||||
'</ul>'.
|
||||
'<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">'.
|
||||
'<a href="https://cannaiq.co" target="_blank" class="text-primary-600 hover:text-primary-500 dark:text-primary-400 font-medium inline-flex items-center gap-1">'.
|
||||
'Visit CannaiQ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>'.
|
||||
'</a>'.
|
||||
'</div>'.
|
||||
'</div>'
|
||||
)),
|
||||
]),
|
||||
]),
|
||||
|
||||
// ===== LEGACY MODULES TAB =====
|
||||
// These flags are kept for backward compatibility.
|
||||
// The recommended way to configure access is via Suites above.
|
||||
@@ -1766,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')
|
||||
@@ -1887,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}%");
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -1920,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')
|
||||
|
||||
@@ -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}%");
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
58
app/Http/Controllers/Api/PushSubscriptionController.php
Normal file
58
app/Http/Controllers/Api/PushSubscriptionController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use NotificationChannels\WebPush\PushSubscription;
|
||||
|
||||
class PushSubscriptionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store a new push subscription
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'endpoint' => 'required|url',
|
||||
'keys.p256dh' => 'required|string',
|
||||
'keys.auth' => 'required|string',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Delete existing subscription for this endpoint
|
||||
PushSubscription::where('endpoint', $validated['endpoint'])->delete();
|
||||
|
||||
// Create new subscription
|
||||
$subscription = $user->updatePushSubscription(
|
||||
$validated['endpoint'],
|
||||
$validated['keys']['p256dh'],
|
||||
$validated['keys']['auth']
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Push subscription saved',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a push subscription
|
||||
*/
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'endpoint' => 'required|url',
|
||||
]);
|
||||
|
||||
PushSubscription::where('endpoint', $validated['endpoint'])
|
||||
->where('subscribable_id', $request->user()->id)
|
||||
->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Push subscription removed',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,18 @@ class OrderController extends Controller
|
||||
|
||||
$orders = $query->paginate(20)->withQueryString();
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $orders->map(fn ($o) => [
|
||||
'order_number' => $o->order_number,
|
||||
'name' => $o->order_number.' - '.$o->business->name,
|
||||
'customer' => $o->business->name,
|
||||
'status' => $o->status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.orders.index', compact('orders', 'business'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ class BatchController extends Controller
|
||||
->where('quantity_available', '>', 0)
|
||||
->where('is_active', true)
|
||||
->where('is_quarantined', false)
|
||||
->with('component')
|
||||
->with('product')
|
||||
->orderBy('batch_number')
|
||||
->get()
|
||||
->map(function ($batch) {
|
||||
|
||||
@@ -333,11 +333,14 @@ class BrandController extends Controller
|
||||
{
|
||||
$perPage = $request->get('per_page', 50);
|
||||
$productsPaginator = $brand->products()
|
||||
->whereNotNull('hashid')
|
||||
->where('hashid', '!=', '')
|
||||
->with('images')
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate($perPage);
|
||||
|
||||
$products = $productsPaginator->getCollection()
|
||||
->filter(fn ($product) => ! empty($product->hashid))
|
||||
->map(function ($product) use ($business, $brand) {
|
||||
$product->setRelation('brand', $brand);
|
||||
|
||||
@@ -354,7 +357,8 @@ class BrandController extends Controller
|
||||
'edit_url' => route('seller.business.products.edit', [$business->slug, $product->hashid]),
|
||||
'preview_url' => route('seller.business.products.preview', [$business->slug, $product->hashid]),
|
||||
];
|
||||
});
|
||||
})
|
||||
->values();
|
||||
|
||||
return [
|
||||
'products' => $products,
|
||||
@@ -1948,4 +1952,141 @@ class BrandController extends Controller
|
||||
'visibilityIssues' => $visibilityIssues,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Display brand market analysis / intelligence page.
|
||||
*
|
||||
* v4 endpoint with optional store_id filtering for per-store projections.
|
||||
*/
|
||||
public function analysis(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// CannaiQ must be enabled to access Brand Analysis
|
||||
if (! $business->cannaiq_enabled) {
|
||||
return view('seller.brands.analysis-disabled', [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
]);
|
||||
}
|
||||
|
||||
// v4: Get optional store_id filter for shelf value projections
|
||||
$storeId = $request->query('store_id');
|
||||
|
||||
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
|
||||
$analysis = $analysisService->getAnalysis($brand, $business, $storeId);
|
||||
|
||||
// Load all brands for the brand selector
|
||||
$brands = $business->brands()
|
||||
->where('is_active', true)
|
||||
->withCount('products')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Build store list from placement data for store selector
|
||||
$storeList = [];
|
||||
if ((bool) $business->cannaiq_enabled) {
|
||||
$placementStores = $analysis->placement['stores'] ?? $analysis->placement ?? [];
|
||||
$whitespaceStores = $analysis->placement['whitespaceStores'] ?? [];
|
||||
|
||||
foreach ($placementStores as $store) {
|
||||
$storeList[] = [
|
||||
'id' => $store['storeId'] ?? '',
|
||||
'name' => $store['storeName'] ?? 'Unknown',
|
||||
'state' => $store['state'] ?? null,
|
||||
];
|
||||
}
|
||||
foreach ($whitespaceStores as $store) {
|
||||
$storeList[] = [
|
||||
'id' => $store['storeId'] ?? '',
|
||||
'name' => $store['storeName'] ?? 'Unknown',
|
||||
'state' => $store['state'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return view('seller.brands.analysis', [
|
||||
'business' => $business,
|
||||
'brand' => $brand,
|
||||
'brands' => $brands,
|
||||
'analysis' => $analysis,
|
||||
'cannaiqEnabled' => (bool) $business->cannaiq_enabled,
|
||||
'storeList' => $storeList,
|
||||
'selectedStoreId' => $storeId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh brand analysis data (clears cache and re-fetches).
|
||||
*/
|
||||
public function analysisRefresh(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// CannaiQ must be enabled to refresh analysis
|
||||
if (! $business->cannaiq_enabled) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'CannaiQ is not enabled for this business. Please contact support.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return back()->with('error', 'CannaiQ is not enabled for this business.');
|
||||
}
|
||||
|
||||
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
|
||||
$analysis = $analysisService->refreshAnalysis($brand, $business);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Analysis data refreshed',
|
||||
'data' => $analysis->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.analysis', [$business->slug, $brand->hashid])
|
||||
->with('success', 'Analysis data refreshed successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get store-level playbook for a specific store.
|
||||
*
|
||||
* Returns targeted recommendations for a single retail account.
|
||||
*/
|
||||
public function storePlaybook(Request $request, Business $business, Brand $brand, string $storeId)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
if (! $business->cannaiq_enabled) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'CannaiQ is not enabled for this business',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return back()->with('error', 'CannaiQ is not enabled for this business');
|
||||
}
|
||||
|
||||
$analysisService = app(\App\Services\Cannaiq\BrandAnalysisService::class);
|
||||
$playbook = $analysisService->getStorePlaybook($brand, $business, $storeId);
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $playbook,
|
||||
]);
|
||||
}
|
||||
|
||||
// For non-JSON requests, redirect to analysis page with store selected
|
||||
return redirect()
|
||||
->route('seller.business.brands.analysis', [
|
||||
$business->slug,
|
||||
$brand->hashid,
|
||||
'store_id' => $storeId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -43,6 +48,18 @@ class AccountController extends Controller
|
||||
|
||||
$accounts = $query->orderBy('name')->paginate(25);
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $accounts->map(fn ($a) => [
|
||||
'slug' => $a->slug,
|
||||
'name' => $a->name,
|
||||
'email' => $a->business_email,
|
||||
'status' => $a->status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.crm.accounts.index', compact('business', 'accounts'));
|
||||
}
|
||||
|
||||
@@ -90,7 +107,7 @@ class AccountController extends Controller
|
||||
'status' => 'approved', // Auto-approve customers created by sellers
|
||||
]);
|
||||
|
||||
// Create primary contact if provided
|
||||
// Create contact if provided
|
||||
if (! empty($validated['contact_name'])) {
|
||||
$account->contacts()->create([
|
||||
'first_name' => explode(' ', $validated['contact_name'])[0],
|
||||
@@ -98,7 +115,6 @@ class AccountController extends Controller
|
||||
'email' => $validated['contact_email'] ?? null,
|
||||
'phone' => $validated['contact_phone'] ?? null,
|
||||
'title' => $validated['contact_title'] ?? null,
|
||||
'is_primary' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -165,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)
|
||||
@@ -234,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)
|
||||
@@ -248,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,
|
||||
@@ -285,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',
|
||||
@@ -303,7 +400,12 @@ class AccountController extends Controller
|
||||
'tasks',
|
||||
'conversationEvents',
|
||||
'sendHistory',
|
||||
'activities'
|
||||
'activities',
|
||||
'locations',
|
||||
'selectedLocation',
|
||||
'locationStats',
|
||||
'unattributedOrdersCount',
|
||||
'unattributedInvoicesCount'
|
||||
));
|
||||
}
|
||||
|
||||
@@ -312,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'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,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'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,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'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,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'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,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'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -397,14 +565,8 @@ class AccountController extends Controller
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'title' => 'nullable|string|max:100',
|
||||
'is_primary' => 'boolean',
|
||||
]);
|
||||
|
||||
// If setting as primary, unset other primary contacts
|
||||
if ($validated['is_primary'] ?? false) {
|
||||
$account->contacts()->update(['is_primary' => false]);
|
||||
}
|
||||
|
||||
$contact = $account->contacts()->create($validated);
|
||||
|
||||
// Return JSON for AJAX requests
|
||||
@@ -453,14 +615,11 @@ class AccountController extends Controller
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'title' => 'nullable|string|max:100',
|
||||
'is_primary' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
// If setting as primary, unset other primary contacts
|
||||
if ($validated['is_primary'] ?? false) {
|
||||
$account->contacts()->where('id', '!=', $contact->id)->update(['is_primary' => false]);
|
||||
}
|
||||
// Handle checkbox - if not sent, default to false
|
||||
$validated['is_active'] = $request->boolean('is_active');
|
||||
|
||||
$contact->update($validated);
|
||||
|
||||
@@ -485,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,18 @@ class ContactController extends Controller
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $contacts->map(fn ($c) => [
|
||||
'hashid' => $c->hashid,
|
||||
'name' => $c->getFullName(),
|
||||
'email' => $c->email,
|
||||
'account' => $c->business?->name,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Get accounts for filter dropdown
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->where('status', 'approved')
|
||||
|
||||
@@ -172,8 +172,9 @@ class CrmCalendarController extends Controller
|
||||
]);
|
||||
$allEvents = $allEvents->merge($bookings);
|
||||
|
||||
// 4. CRM Tasks with due dates (shown as all-day markers)
|
||||
// 4. CRM Tasks with due dates (shown as all-day markers) - only show user's assigned tasks
|
||||
$tasks = CrmTask::forSellerBusiness($business->id)
|
||||
->where('assigned_to', $user->id)
|
||||
->incomplete()
|
||||
->whereNotNull('due_at')
|
||||
->whereBetween('due_at', [$startDate, $endDate])
|
||||
|
||||
@@ -115,12 +115,13 @@ class DealController extends Controller
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
// Limit accounts for dropdown - most recent 100
|
||||
$accounts = Business::whereHas('ordersAsCustomer', function ($q) use ($business) {
|
||||
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
|
||||
})
|
||||
// Limit accounts for dropdown - buyers who have ordered from this seller
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->whereHas('orders', function ($q) use ($business) {
|
||||
$q->whereHas('items.product.brand', fn ($b) => $b->where('business_id', $business->id));
|
||||
})
|
||||
->select('id', 'name')
|
||||
->orderByDesc('updated_at')
|
||||
->orderBy('name')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@ class InvoiceController extends Controller
|
||||
// Stats - single efficient query with conditional aggregation
|
||||
$invoiceStats = CrmInvoice::forBusiness($business->id)
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN amount_due ELSE 0 END) as outstanding,
|
||||
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN amount_due ELSE 0 END) as overdue
|
||||
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN balance_due ELSE 0 END) as outstanding,
|
||||
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN balance_due ELSE 0 END) as overdue
|
||||
")
|
||||
->first();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -35,6 +35,19 @@ class LeadController extends Controller
|
||||
|
||||
$leads = $query->latest()->paginate(25);
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $leads->map(fn ($l) => [
|
||||
'hashid' => $l->hashid,
|
||||
'name' => $l->company_name,
|
||||
'contact' => $l->contact_name,
|
||||
'email' => $l->contact_email,
|
||||
'status' => $l->status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.crm.leads.index', compact('business', 'leads'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,13 +38,26 @@ 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}%");
|
||||
});
|
||||
}
|
||||
|
||||
$quotes = $query->orderByDesc('created_at')->paginate(25);
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $quotes->map(fn ($q) => [
|
||||
'id' => $q->id,
|
||||
'name' => $q->quote_number.' - '.($q->title ?? 'Untitled'),
|
||||
'contact' => $q->contact?->name ?? '-',
|
||||
'status' => $q->status,
|
||||
'total' => '$'.number_format($q->total, 2),
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.crm.quotes.index', compact('quotes', 'business'));
|
||||
}
|
||||
|
||||
@@ -52,11 +66,13 @@ class QuoteController extends Controller
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
// Get buyer businesses that have contacts (potential and existing customers)
|
||||
// Get all approved buyer businesses as potential customers
|
||||
// Contacts are loaded dynamically via /search/contacts?customer_id={account_id}
|
||||
// Include locations for delivery address selection
|
||||
// Note: We don't filter by whereHas('contacts') because newly created customers
|
||||
// may not have contacts yet - contacts can be added after selecting the account
|
||||
$accounts = Business::where('type', 'buyer')
|
||||
->whereHas('contacts')
|
||||
->where('status', 'approved')
|
||||
->with('locations:id,business_id,name,is_primary')
|
||||
->orderBy('name')
|
||||
->select(['id', 'name', 'slug'])
|
||||
@@ -69,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'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,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',
|
||||
@@ -124,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',
|
||||
]);
|
||||
|
||||
|
||||
@@ -40,8 +40,30 @@ class TaskController extends Controller
|
||||
$tasksQuery->where('type', $request->type);
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('q')) {
|
||||
$search = $request->q;
|
||||
$tasksQuery->where(function ($q) use ($search) {
|
||||
$q->where('title', 'ILIKE', "%{$search}%")
|
||||
->orWhere('details', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$tasks = $tasksQuery->paginate(25);
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $tasks->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'name' => $t->title,
|
||||
'type' => $t->type,
|
||||
'assignee' => $t->assignee?->name ?? 'Unassigned',
|
||||
'due_at' => $t->due_at?->format('M j, Y'),
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Get stats with single efficient query
|
||||
$statsQuery = CrmTask::where('seller_business_id', $business->id)
|
||||
->selectRaw('
|
||||
@@ -75,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'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}%"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ class InvoiceController extends Controller
|
||||
/**
|
||||
* Display a listing of invoices for the business.
|
||||
*/
|
||||
public function index(Business $business)
|
||||
public function index(Business $business, Request $request)
|
||||
{
|
||||
// Get brand IDs for this business (single query, reused for filtering)
|
||||
$brandIds = $business->brands()->pluck('id');
|
||||
@@ -138,11 +138,47 @@ class InvoiceController extends Controller
|
||||
->where('due_date', '<', now())->count(),
|
||||
];
|
||||
|
||||
// Apply search filter - search by customer business name or invoice number
|
||||
$search = $request->input('search');
|
||||
if ($search) {
|
||||
$baseQuery->where(function ($query) use ($search) {
|
||||
$query->where('invoice_number', 'ilike', "%{$search}%")
|
||||
->orWhereHas('business', function ($q) use ($search) {
|
||||
$q->where('name', 'ilike', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
$status = $request->input('status');
|
||||
if ($status === 'unpaid') {
|
||||
$baseQuery->where('payment_status', 'unpaid');
|
||||
} elseif ($status === 'paid') {
|
||||
$baseQuery->where('payment_status', 'paid');
|
||||
} elseif ($status === 'overdue') {
|
||||
$baseQuery->where('payment_status', '!=', 'paid')
|
||||
->where('due_date', '<', now());
|
||||
}
|
||||
|
||||
// Paginate with only the relations needed for display
|
||||
$invoices = (clone $baseQuery)
|
||||
->with(['business:id,name,primary_contact_email,business_email', 'order:id,contact_id,user_id', 'order.contact:id,first_name,last_name,email', 'order.user:id,email'])
|
||||
->latest()
|
||||
->paginate(25);
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $invoices->map(fn ($i) => [
|
||||
'hashid' => $i->hashid,
|
||||
'name' => $i->invoice_number.' - '.$i->business->name,
|
||||
'invoice_number' => $i->invoice_number,
|
||||
'customer' => $i->business->name,
|
||||
'status' => $i->payment_status,
|
||||
])->values()->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.invoices.index', compact('business', 'invoices', 'stats'));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -29,6 +29,11 @@ class ProductController extends Controller
|
||||
// Get brand IDs to filter by (respects brand context switcher)
|
||||
$brandIds = BrandSwitcherController::getFilteredBrandIds();
|
||||
|
||||
// Get all brands for the business for the filter dropdown
|
||||
$brands = \App\Models\Brand::where('business_id', $business->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name']);
|
||||
|
||||
// Calculate missing BOM count for health alert
|
||||
$missingBomCount = Product::whereIn('brand_id', $brandIds)
|
||||
->where('is_assembly', true)
|
||||
@@ -106,7 +111,7 @@ class ProductController extends Controller
|
||||
'hashid' => $variety->hashid,
|
||||
'name' => $variety->name,
|
||||
'sku' => $variety->sku ?? 'N/A',
|
||||
'price' => $variety->wholesale_price ?? 0,
|
||||
'price' => $variety->effective_price ?? $variety->wholesale_price ?? 0,
|
||||
'status' => $variety->is_active ? 'active' : 'inactive',
|
||||
'image_url' => $variety->getImageUrl('thumb'),
|
||||
'edit_url' => route('seller.business.products.edit', [$business->slug, $variety->hashid]),
|
||||
@@ -123,7 +128,7 @@ class ProductController extends Controller
|
||||
'sku' => $product->sku ?? 'N/A',
|
||||
'brand' => $product->brand->name ?? 'N/A',
|
||||
'channel' => 'Marketplace', // TODO: Add channel field to products
|
||||
'price' => $product->wholesale_price ?? 0,
|
||||
'price' => $product->effective_price ?? $product->wholesale_price ?? 0,
|
||||
'views' => rand(500, 3000), // TODO: Replace with real view tracking
|
||||
'orders' => rand(10, 200), // TODO: Replace with real order count
|
||||
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation
|
||||
@@ -150,7 +155,20 @@ class ProductController extends Controller
|
||||
'to' => $paginator->lastItem(),
|
||||
];
|
||||
|
||||
return view('seller.products.index', compact('business', 'products', 'missingBomCount', 'paginator', 'pagination'));
|
||||
// Return JSON for AJAX/API requests (live search)
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'data' => $products->map(fn ($p) => [
|
||||
'hashid' => $p['hashid'],
|
||||
'name' => $p['product'],
|
||||
'sku' => $p['sku'],
|
||||
'brand' => $p['brand'],
|
||||
])->values()->toArray(),
|
||||
'pagination' => $pagination,
|
||||
]);
|
||||
}
|
||||
|
||||
return view('seller.products.index', compact('business', 'brands', 'products', 'missingBomCount', 'paginator', 'pagination'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -475,20 +493,34 @@ class ProductController extends Controller
|
||||
// Set default value for price_unit if not provided
|
||||
$validated['price_unit'] = $validated['price_unit'] ?? 'each';
|
||||
|
||||
// Create product
|
||||
$product = Product::create($validated);
|
||||
// Create product and handle images in a transaction
|
||||
$product = \DB::transaction(function () use ($validated, $request, $business) {
|
||||
$product = Product::create($validated);
|
||||
|
||||
// Handle image uploads if present
|
||||
if ($request->hasFile('images')) {
|
||||
foreach ($request->file('images') as $index => $image) {
|
||||
$path = $image->store('products', 'public');
|
||||
$product->images()->create([
|
||||
'path' => $path,
|
||||
'type' => 'product',
|
||||
'is_primary' => $index === 0,
|
||||
]);
|
||||
// Handle image uploads if present
|
||||
// Note: Uses default disk (minio) per CLAUDE.md rules - never use 'public' disk for media
|
||||
if ($request->hasFile('images')) {
|
||||
$brand = $product->brand;
|
||||
$basePath = "businesses/{$business->slug}/brands/{$brand->slug}/products/{$product->sku}/images";
|
||||
|
||||
foreach ($request->file('images') as $index => $image) {
|
||||
$filename = $image->hashName();
|
||||
$path = $image->storeAs($basePath, $filename);
|
||||
|
||||
if ($path === false) {
|
||||
throw new \RuntimeException('Failed to upload image to storage');
|
||||
}
|
||||
|
||||
$product->images()->create([
|
||||
'path' => $path,
|
||||
'type' => 'product',
|
||||
'is_primary' => $index === 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $product;
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.products.index', $business->slug)
|
||||
@@ -891,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
|
||||
@@ -906,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);
|
||||
@@ -1032,7 +1064,7 @@ class ProductController extends Controller
|
||||
'sku' => $product->sku ?? 'N/A',
|
||||
'brand' => $product->brand->name ?? 'N/A',
|
||||
'channel' => 'Marketplace', // TODO: Add channel field to products
|
||||
'price' => $product->wholesale_price ?? 0,
|
||||
'price' => (float) ($product->wholesale_price ?? 0),
|
||||
'views' => rand(500, 3000), // TODO: Replace with real view tracking
|
||||
'orders' => rand(10, 200), // TODO: Replace with real order count
|
||||
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation
|
||||
|
||||
@@ -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]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@ class StoreBrandRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'tagline' => ['nullable', 'string', 'min:30', 'max:45'],
|
||||
'description' => ['nullable', 'string', 'min:100', 'max:150'],
|
||||
'long_description' => ['nullable', 'string', 'max:500'],
|
||||
'tagline' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'long_description' => ['nullable', 'string', 'max:5000'],
|
||||
'brand_announcement' => ['nullable', 'string', 'max:500'],
|
||||
'website_url' => 'nullable|string|max:255',
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ class UpdateBrandRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'tagline' => ['nullable', 'string', 'min:30', 'max:45'],
|
||||
'description' => ['nullable', 'string', 'min:100', 'max:150'],
|
||||
'long_description' => ['nullable', 'string', 'max:500'],
|
||||
'tagline' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'long_description' => ['nullable', 'string', 'max:5000'],
|
||||
'brand_announcement' => ['nullable', 'string', 'max:500'],
|
||||
'website_url' => 'nullable|string|max:255',
|
||||
|
||||
|
||||
159
app/Jobs/CalculateBrandAnalysisMetrics.php
Normal file
159
app/Jobs/CalculateBrandAnalysisMetrics.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use App\Services\Cannaiq\BrandAnalysisService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Background job to pre-calculate Brand Analysis metrics.
|
||||
*
|
||||
* This job runs in the background to compute expensive engagement and sentiment
|
||||
* metrics for brands, caching the results for 2 hours. This prevents N+1 queries
|
||||
* and expensive aggregations from running on page load.
|
||||
*
|
||||
* Schedule: Every 2 hours via Horizon
|
||||
* Queue: default (or 'analytics' if available)
|
||||
*
|
||||
* Key benefits:
|
||||
* - Aggregates CRM message counts, response rates, and quote/order metrics in batch
|
||||
* - Pre-computes buyer engagement scores
|
||||
* - For CannaiQ-enabled businesses, also pre-computes sentiment scores
|
||||
* - Uses existing BrandAnalysisService caching mechanism (2-hour TTL)
|
||||
*/
|
||||
class CalculateBrandAnalysisMetrics implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The business to calculate metrics for (null = all seller businesses)
|
||||
*/
|
||||
public ?int $businessId;
|
||||
|
||||
/**
|
||||
* The brand to calculate metrics for (null = all brands in business)
|
||||
*/
|
||||
public ?int $brandId;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(?int $businessId = null, ?int $brandId = null)
|
||||
{
|
||||
$this->businessId = $businessId;
|
||||
$this->brandId = $brandId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(BrandAnalysisService $service): void
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$processedCount = 0;
|
||||
|
||||
try {
|
||||
if ($this->businessId && $this->brandId) {
|
||||
// Single brand calculation
|
||||
$this->calculateForBrand($service, $this->businessId, $this->brandId);
|
||||
$processedCount = 1;
|
||||
} elseif ($this->businessId) {
|
||||
// All brands for a single business
|
||||
$processedCount = $this->calculateForBusiness($service, $this->businessId);
|
||||
} else {
|
||||
// All seller businesses with active brands
|
||||
$processedCount = $this->calculateForAllBusinesses($service);
|
||||
}
|
||||
|
||||
$duration = round(microtime(true) - $startTime, 2);
|
||||
Log::info('CalculateBrandAnalysisMetrics completed', [
|
||||
'business_id' => $this->businessId ?? 'all',
|
||||
'brand_id' => $this->brandId ?? 'all',
|
||||
'brands_processed' => $processedCount,
|
||||
'duration_seconds' => $duration,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CalculateBrandAnalysisMetrics failed', [
|
||||
'business_id' => $this->businessId,
|
||||
'brand_id' => $this->brandId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate metrics for all seller businesses
|
||||
*/
|
||||
private function calculateForAllBusinesses(BrandAnalysisService $service): int
|
||||
{
|
||||
$processedCount = 0;
|
||||
|
||||
Business::where('type', 'seller')
|
||||
->where('status', 'approved')
|
||||
->chunk(10, function ($businesses) use ($service, &$processedCount) {
|
||||
foreach ($businesses as $business) {
|
||||
$processedCount += $this->calculateForBusiness($service, $business->id);
|
||||
}
|
||||
});
|
||||
|
||||
return $processedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate metrics for all active brands in a business
|
||||
*/
|
||||
private function calculateForBusiness(BrandAnalysisService $service, int $businessId): int
|
||||
{
|
||||
$business = Business::find($businessId);
|
||||
if (! $business) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$brands = Brand::where('business_id', $businessId)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
foreach ($brands as $brand) {
|
||||
$this->calculateForBrand($service, $businessId, $brand->id);
|
||||
}
|
||||
|
||||
return $brands->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate metrics for a single brand
|
||||
*/
|
||||
private function calculateForBrand(BrandAnalysisService $service, int $businessId, int $brandId): void
|
||||
{
|
||||
$business = Business::find($businessId);
|
||||
$brand = Brand::find($brandId);
|
||||
|
||||
if (! $business || ! $brand) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This triggers the full analysis calculation and caches it
|
||||
// The BrandAnalysisService handles caching internally with 2-hour TTL
|
||||
$service->refreshAnalysis($brand, $business);
|
||||
}
|
||||
|
||||
/**
|
||||
* The job failed to process.
|
||||
*/
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error('CalculateBrandAnalysisMetrics job failed', [
|
||||
'business_id' => $this->businessId,
|
||||
'brand_id' => $this->brandId,
|
||||
'exception' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.'%');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'.%');
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\BelongsToBusinessViaProduct;
|
||||
use App\Traits\HasHashid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -13,15 +14,17 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Batch extends Model
|
||||
{
|
||||
use BelongsToBusinessViaProduct, HasFactory, SoftDeletes;
|
||||
use BelongsToBusinessViaProduct, HasFactory, HasHashid, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'hashid',
|
||||
'product_id',
|
||||
'lab_id',
|
||||
'parent_batch_id',
|
||||
'business_id',
|
||||
'cannabinoid_unit',
|
||||
'batch_number',
|
||||
'quantity_unit',
|
||||
'internal_code',
|
||||
'batch_type',
|
||||
'production_date',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -109,6 +109,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$versionData = cache()->remember('app.version_data', now()->addSeconds(5), function () {
|
||||
$version = 'dev';
|
||||
$commit = 'unknown';
|
||||
$buildDate = null;
|
||||
|
||||
// For Docker: read from version.env (injected at build time)
|
||||
$versionFile = base_path('version.env');
|
||||
@@ -117,6 +118,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$data = parse_ini_file($versionFile);
|
||||
$version = $data['VERSION'] ?? 'dev';
|
||||
$commit = $data['COMMIT'] ?? 'unknown';
|
||||
$buildDate = $data['BUILD_DATE'] ?? null;
|
||||
}
|
||||
// For local dev: read from git directly (but cached for 5 seconds)
|
||||
// Check for .git (directory for regular repos, file for worktrees)
|
||||
@@ -128,6 +130,13 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
// Only proceed if we successfully got a commit SHA
|
||||
if ($commit !== '' && $commit !== 'unknown') {
|
||||
// Get commit date for local dev
|
||||
$dateCommand = sprintf('cd %s && git log -1 --format=%%ci 2>/dev/null', escapeshellarg(base_path()));
|
||||
$commitDate = trim(shell_exec($dateCommand) ?: '');
|
||||
if ($commitDate) {
|
||||
$buildDate = date('M j, g:ia', strtotime($commitDate));
|
||||
}
|
||||
|
||||
// Check for uncommitted changes (dirty working directory)
|
||||
$diffCommand = sprintf('cd %s && git diff --quiet 2>/dev/null; echo $?', escapeshellarg(base_path()));
|
||||
$cachedCommand = sprintf('cd %s && git diff --cached --quiet 2>/dev/null; echo $?', escapeshellarg(base_path()));
|
||||
@@ -147,17 +156,19 @@ class AppServiceProvider extends ServiceProvider
|
||||
return [
|
||||
'version' => $version,
|
||||
'commit' => $commit,
|
||||
'buildDate' => $buildDate,
|
||||
];
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
// If cache fails (e.g., Redis not ready), calculate version without caching
|
||||
$versionData = ['version' => 'dev', 'commit' => 'unknown'];
|
||||
$versionData = ['version' => 'dev', 'commit' => 'unknown', 'buildDate' => null];
|
||||
|
||||
$versionFile = base_path('version.env');
|
||||
if (File::exists($versionFile)) {
|
||||
$data = parse_ini_file($versionFile);
|
||||
$versionData['version'] = $data['VERSION'] ?? 'dev';
|
||||
$versionData['commit'] = $data['COMMIT'] ?? 'unknown';
|
||||
$versionData['buildDate'] = $data['BUILD_DATE'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +176,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$view->with([
|
||||
'appVersion' => $versionData['version'],
|
||||
'appCommit' => $versionData['commit'],
|
||||
'appBuildDate' => $versionData['buildDate'],
|
||||
'appVersionFull' => "{$versionData['version']} (sha-{$versionData['commit']})",
|
||||
]);
|
||||
});
|
||||
@@ -299,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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,10 +260,10 @@ class BrandVoicePrompt
|
||||
* AI generation MUST respect these limits.
|
||||
*/
|
||||
public const CHARACTER_LIMITS = [
|
||||
'tagline' => ['min' => 30, 'max' => 45, 'label' => 'Tagline'],
|
||||
'short_description' => ['min' => 100, 'max' => 150, 'label' => 'Short Description'],
|
||||
'description' => ['min' => 100, 'max' => 150, 'label' => 'Short Description'], // Alias
|
||||
'long_description' => ['min' => 400, 'max' => 500, 'label' => 'Long Description'],
|
||||
'tagline' => ['min' => null, 'max' => 255, 'label' => 'Tagline'],
|
||||
'short_description' => ['min' => null, 'max' => 1000, 'label' => 'Short Description'],
|
||||
'description' => ['min' => null, 'max' => 1000, 'label' => 'Short Description'], // Alias
|
||||
'long_description' => ['min' => null, 'max' => 5000, 'label' => 'Long Description'],
|
||||
'brand_announcement' => ['min' => 400, 'max' => 500, 'label' => 'Brand Announcement'],
|
||||
'seo_title' => ['min' => 60, 'max' => 70, 'label' => 'SEO Title'],
|
||||
'seo_description' => ['min' => 150, 'max' => 160, 'label' => 'SEO Description'],
|
||||
@@ -803,6 +803,11 @@ class BrandVoicePrompt
|
||||
return '';
|
||||
}
|
||||
|
||||
// If no min is set, only enforce max
|
||||
if ($limits['min'] === null) {
|
||||
return "CHARACTER LIMIT: Output should not exceed {$limits['max']} characters.";
|
||||
}
|
||||
|
||||
return "STRICT CHARACTER LIMIT: Output MUST be between {$limits['min']}-{$limits['max']} characters. Do NOT output fewer than {$limits['min']} or more than {$limits['max']} characters.";
|
||||
}
|
||||
|
||||
|
||||
@@ -694,7 +694,7 @@ class AccountingReportingService
|
||||
->active()
|
||||
->where(function ($q) {
|
||||
$q->where('account_subtype', 'cash')
|
||||
->orWhere('name', 'like', '%Cash%');
|
||||
->orWhere('name', 'ilike', '%Cash%');
|
||||
})
|
||||
->get();
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
533
app/Services/Cannaiq/AdvancedV3IntelligenceDTO.php
Normal file
533
app/Services/Cannaiq/AdvancedV3IntelligenceDTO.php
Normal file
@@ -0,0 +1,533 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Cannaiq;
|
||||
|
||||
/**
|
||||
* Advanced v4 Intelligence Data Transfer Object
|
||||
*
|
||||
* Contains advanced brand intelligence analytics including:
|
||||
* - Brand positioning and differentiation scoring (v3)
|
||||
* - Trend lead/lag analysis (predictive vs laggy behavior) (v3)
|
||||
* - Cross-state market signals (v3)
|
||||
* - Shelf displacement opportunities (v3)
|
||||
* - Shelf value projections with capture scenarios (v4)
|
||||
*
|
||||
* v4.0 Additions:
|
||||
* - shelfValueProjections: Revenue projections by scope (store/state/multi_state)
|
||||
* - capture_scenarios: 10%, 25%, 50% market capture modeling
|
||||
* - opportunity_label: "Big prize, low effort" etc.
|
||||
* - consumerDemand: Consumer Demand Index + SKU lifecycle stages
|
||||
* - elasticity: Price elasticity metrics per SKU
|
||||
* - competitiveThreat: Competitive pressure scoring
|
||||
* - portfolioBalance: Category mix, redundancy clusters, gaps
|
||||
*
|
||||
* All data is derived from existing CannaiQ + internal data; no new scrapes.
|
||||
*/
|
||||
class AdvancedV3IntelligenceDTO
|
||||
{
|
||||
public function __construct(
|
||||
// v3.0 fields
|
||||
public readonly ?array $brandPositioning = null,
|
||||
public readonly ?array $trendLeadLag = null,
|
||||
public readonly array $marketSignals = [],
|
||||
public readonly array $shelfOpportunities = [],
|
||||
// v4.0: Shelf value projections with capture scenarios
|
||||
public readonly array $shelfValueProjections = [],
|
||||
// v4.0: Consumer Demand Index + SKU lifecycle
|
||||
public readonly ?array $consumerDemand = null,
|
||||
// v4.0: Price elasticity metrics
|
||||
public readonly ?array $elasticity = null,
|
||||
// v4.0: Competitive threat scoring
|
||||
public readonly ?array $competitiveThreat = null,
|
||||
// v4.0: Portfolio balance analysis
|
||||
public readonly ?array $portfolioBalance = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create empty DTO when data is unavailable
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self(
|
||||
brandPositioning: null,
|
||||
trendLeadLag: null,
|
||||
marketSignals: [],
|
||||
shelfOpportunities: [],
|
||||
shelfValueProjections: [],
|
||||
consumerDemand: null,
|
||||
elasticity: null,
|
||||
competitiveThreat: null,
|
||||
portfolioBalance: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty brand positioning structure
|
||||
*
|
||||
* Structure:
|
||||
* - differentiation_score: 0-100 (how unique vs competitors)
|
||||
* - positioning_label: 'more_of_the_same'|'value_disruptor'|'premium_standout'|'potency_leader'|'format_outlier'
|
||||
* - comparables: Array of similar brands with distance scores
|
||||
* - notes: Array of bullet explanations
|
||||
*/
|
||||
public static function emptyBrandPositioning(): array
|
||||
{
|
||||
return [
|
||||
'differentiation_score' => null,
|
||||
'positioning_label' => 'more_of_the_same',
|
||||
'comparables' => [],
|
||||
'notes' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty trend lead/lag structure
|
||||
*
|
||||
* Structure:
|
||||
* - lead_lag_index: -100 (laggy) to +100 (predictive)
|
||||
* - classification: 'strong_leader'|'emerging_leader'|'in_line'|'follower'|'laggy'
|
||||
* - supporting_signals: Array of category-level signals
|
||||
*/
|
||||
public static function emptyTrendLeadLag(): array
|
||||
{
|
||||
return [
|
||||
'lead_lag_index' => 0,
|
||||
'classification' => 'in_line',
|
||||
'supporting_signals' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty market signal structure
|
||||
*
|
||||
* Structure:
|
||||
* - scope: 'multi_state'|'state'|'category'
|
||||
* - state_code: optional state
|
||||
* - category: optional category
|
||||
* - description: human-readable summary
|
||||
* - trend_strength: 0-100
|
||||
* - relevant_to_brand: bool
|
||||
* - brand_fit: 'strong_fit'|'partial_fit'|'gap'
|
||||
* - example_brand: optional example
|
||||
*/
|
||||
public static function emptyMarketSignal(): array
|
||||
{
|
||||
return [
|
||||
'scope' => 'category',
|
||||
'state_code' => null,
|
||||
'category' => null,
|
||||
'description' => '',
|
||||
'trend_strength' => 0,
|
||||
'relevant_to_brand' => false,
|
||||
'brand_fit' => 'gap',
|
||||
'example_brand' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty shelf opportunity structure
|
||||
*
|
||||
* Structure:
|
||||
* - store_id: CannaiQ store external ID
|
||||
* - store_name: Store display name
|
||||
* - state_code: State abbreviation
|
||||
* - opportunity_type: 'whitespace'|'displacement'
|
||||
* - competitor_brand: null for whitespace
|
||||
* - competitor_product_name: null for whitespace
|
||||
* - our_best_sku_id: our matching product ID
|
||||
* - our_best_sku_name: our matching product name
|
||||
* - est_monthly_units_current: competitor's current volume
|
||||
* - est_monthly_units_if_we_win: projected volume if we win
|
||||
* - est_monthly_revenue_if_we_win: projected revenue
|
||||
* - quality_score_delta: -100 to +100 (positive = we're better)
|
||||
* - value_score_delta: -100 to +100 (positive = better value)
|
||||
* - displacement_difficulty: 'low'|'medium'|'high'
|
||||
* - difficulty_score: 0-100 (100 = hardest)
|
||||
* - rationale_tags: Array of reason strings
|
||||
*/
|
||||
public static function emptyShelfOpportunity(): array
|
||||
{
|
||||
return [
|
||||
'store_id' => null,
|
||||
'store_name' => 'Unknown',
|
||||
'state_code' => null,
|
||||
'opportunity_type' => 'whitespace',
|
||||
'competitor_brand' => null,
|
||||
'competitor_product_name' => null,
|
||||
'our_best_sku_id' => null,
|
||||
'our_best_sku_name' => null,
|
||||
'est_monthly_units_current' => 0,
|
||||
'est_monthly_units_if_we_win' => 0,
|
||||
'est_monthly_revenue_if_we_win' => 0,
|
||||
'quality_score_delta' => 0,
|
||||
'value_score_delta' => 0,
|
||||
'displacement_difficulty' => 'medium',
|
||||
'difficulty_score' => 50,
|
||||
'rationale_tags' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty shelf value projection structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - scope: 'store'|'state'|'multi_state' - geographic scope of projection
|
||||
* - store_id: CannaiQ store ID (when scope='store')
|
||||
* - store_name: Store display name (when scope='store')
|
||||
* - state_code: State abbreviation (when scope='store' or 'state')
|
||||
* - current_competitor_sales: Competitor revenue currently on shelf
|
||||
* - category_total_sales: Total category sales at location
|
||||
* - our_current_share: Our % of category sales (0.0-1.0)
|
||||
* - our_current_shelf_value: Our current monthly revenue at location
|
||||
* - avg_displacement_difficulty: 0-100 (aggregated from opportunities)
|
||||
* - opportunity_label: 'Big prize, low effort'|'Low-hanging fruit'|'High potential, high difficulty'|'Grind zone'
|
||||
* - capture_scenarios: Array of capture scenario projections
|
||||
*/
|
||||
public static function emptyShelfValueProjection(): array
|
||||
{
|
||||
return [
|
||||
'scope' => 'store',
|
||||
'store_id' => null,
|
||||
'store_name' => null,
|
||||
'state_code' => null,
|
||||
'current_competitor_sales' => 0,
|
||||
'category_total_sales' => 0,
|
||||
'our_current_share' => 0,
|
||||
'our_current_shelf_value' => 0,
|
||||
'avg_displacement_difficulty' => 50,
|
||||
'opportunity_label' => 'Grind zone',
|
||||
'capture_scenarios' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty capture scenario structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - capture_percent: 10|25|50 - % of competitor shelf to capture
|
||||
* - projected_monthly_revenue: Revenue if we achieve this capture
|
||||
* - projected_units: Units if we achieve this capture
|
||||
* - revenue_lift_from_current: Delta from our current revenue
|
||||
* - effort_level: 'low'|'medium'|'high' - based on difficulty + capture %
|
||||
*/
|
||||
public static function emptyCaptureScenario(): array
|
||||
{
|
||||
return [
|
||||
'capture_percent' => 10,
|
||||
'projected_monthly_revenue' => 0,
|
||||
'projected_units' => 0,
|
||||
'revenue_lift_from_current' => 0,
|
||||
'effort_level' => 'medium',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get opportunity label based on value and difficulty
|
||||
*
|
||||
* @param float $value Estimated monthly revenue opportunity
|
||||
* @param int $difficulty 0-100 difficulty score
|
||||
*/
|
||||
public static function getOpportunityLabel(float $value, int $difficulty): string
|
||||
{
|
||||
// High value threshold: $5,000/mo
|
||||
// Low difficulty threshold: 40
|
||||
$highValue = $value >= 5000;
|
||||
$lowDifficulty = $difficulty <= 40;
|
||||
|
||||
return match (true) {
|
||||
$highValue && $lowDifficulty => 'Big prize, low effort',
|
||||
! $highValue && $lowDifficulty => 'Low-hanging fruit',
|
||||
$highValue && ! $lowDifficulty => 'High potential, high difficulty',
|
||||
default => 'Grind zone',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for views
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'brandPositioning' => $this->brandPositioning,
|
||||
'trendLeadLag' => $this->trendLeadLag,
|
||||
'marketSignals' => $this->marketSignals,
|
||||
'shelfOpportunities' => $this->shelfOpportunities,
|
||||
'shelfValueProjections' => $this->shelfValueProjections,
|
||||
'consumerDemand' => $this->consumerDemand,
|
||||
'elasticity' => $this->elasticity,
|
||||
'competitiveThreat' => $this->competitiveThreat,
|
||||
'portfolioBalance' => $this->portfolioBalance,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any v3/v4 intelligence data is available
|
||||
*/
|
||||
public function hasData(): bool
|
||||
{
|
||||
return $this->brandPositioning !== null
|
||||
|| $this->trendLeadLag !== null
|
||||
|| ! empty($this->marketSignals)
|
||||
|| ! empty($this->shelfOpportunities)
|
||||
|| ! empty($this->shelfValueProjections)
|
||||
|| $this->consumerDemand !== null
|
||||
|| $this->elasticity !== null
|
||||
|| $this->competitiveThreat !== null
|
||||
|| $this->portfolioBalance !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty consumer demand structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - consumer_demand_index: 0-100 overall brand demand score
|
||||
* - sku_scores: Array of per-SKU demand metrics
|
||||
*/
|
||||
public static function emptyConsumerDemand(): array
|
||||
{
|
||||
return [
|
||||
'consumer_demand_index' => null,
|
||||
'sku_scores' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty SKU demand score structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - product_id: Internal product ID
|
||||
* - product_name: Display name
|
||||
* - demand_index: 0-100 demand score
|
||||
* - promo_independence: 0-100 (higher = sells well without promos)
|
||||
* - cross_store_consistency: 0-100 (higher = consistent across stores)
|
||||
* - stage: 'launch'|'growth'|'peak'|'decline'|'terminal'|null
|
||||
*/
|
||||
public static function emptySkuDemandScore(): array
|
||||
{
|
||||
return [
|
||||
'product_id' => null,
|
||||
'product_name' => 'Unknown',
|
||||
'demand_index' => null,
|
||||
'promo_independence' => null,
|
||||
'cross_store_consistency' => null,
|
||||
'stage' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty elasticity structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - sku_elasticity: Array of per-SKU price elasticity metrics
|
||||
*/
|
||||
public static function emptyElasticity(): array
|
||||
{
|
||||
return [
|
||||
'sku_elasticity' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty SKU elasticity structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - product_id: Internal product ID
|
||||
* - product_name: Display name
|
||||
* - current_price: Current average price
|
||||
* - elasticity: Numeric elasticity coefficient (negative = price sensitive)
|
||||
* - price_behavior: 'sensitive'|'stable'|'room_to_raise'|null
|
||||
* - note: Human-readable recommendation
|
||||
*/
|
||||
public static function emptySkuElasticity(): array
|
||||
{
|
||||
return [
|
||||
'product_id' => null,
|
||||
'product_name' => 'Unknown',
|
||||
'current_price' => null,
|
||||
'elasticity' => null,
|
||||
'price_behavior' => null,
|
||||
'note' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty competitive threat structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - overall_threat_score: 0-100 aggregate threat level
|
||||
* - threat_level: 'low'|'medium'|'high'
|
||||
* - threats: Array of competitor threat details
|
||||
*/
|
||||
public static function emptyCompetitiveThreat(): array
|
||||
{
|
||||
return [
|
||||
'overall_threat_score' => null,
|
||||
'threat_level' => null,
|
||||
'threats' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty competitor threat structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - brand_name: Competitor brand name
|
||||
* - threat_score: 0-100 individual threat score
|
||||
* - price_aggression: 0-100 (how aggressively they undercut)
|
||||
* - velocity_trend: -100 to +100 (their growth vs decline)
|
||||
* - overlap_score: 0-100 (category/store overlap)
|
||||
* - notes: Array of threat reasons
|
||||
*/
|
||||
public static function emptyThreatBrand(): array
|
||||
{
|
||||
return [
|
||||
'brand_name' => 'Unknown',
|
||||
'threat_score' => null,
|
||||
'price_aggression' => null,
|
||||
'velocity_trend' => null,
|
||||
'overlap_score' => null,
|
||||
'notes' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty portfolio balance structure (v4.0)
|
||||
*
|
||||
* Structure:
|
||||
* - category_mix: Array of category distribution
|
||||
* - redundancy_clusters: Array of similar SKU groupings
|
||||
* - gaps: Array of identified portfolio gaps
|
||||
*/
|
||||
public static function emptyPortfolioBalance(): array
|
||||
{
|
||||
return [
|
||||
'category_mix' => [],
|
||||
'redundancy_clusters' => [],
|
||||
'gaps' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty category mix item structure (v4.0)
|
||||
*/
|
||||
public static function emptyCategoryMix(): array
|
||||
{
|
||||
return [
|
||||
'category' => 'Unknown',
|
||||
'sku_count' => 0,
|
||||
'revenue_share_percent' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty redundancy cluster structure (v4.0)
|
||||
*/
|
||||
public static function emptyRedundancyCluster(): array
|
||||
{
|
||||
return [
|
||||
'cluster_id' => null,
|
||||
'label' => 'Unknown',
|
||||
'product_ids' => [],
|
||||
'note' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create empty portfolio gap structure (v4.0)
|
||||
*/
|
||||
public static function emptyPortfolioGap(): array
|
||||
{
|
||||
return [
|
||||
'category' => 'Unknown',
|
||||
'description' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get threat level label from score
|
||||
*/
|
||||
public static function getThreatLevel(float $score): string
|
||||
{
|
||||
return match (true) {
|
||||
$score >= 70 => 'high',
|
||||
$score >= 40 => 'medium',
|
||||
default => 'low',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get lifecycle stage from velocity metrics
|
||||
*
|
||||
* @param float $velocity Current daily velocity
|
||||
* @param float|null $velocityTrend % change vs prior period (-100 to +100)
|
||||
* @param float $categoryAvgVelocity Category average velocity
|
||||
*/
|
||||
public static function getLifecycleStage(float $velocity, ?float $velocityTrend, float $categoryAvgVelocity): string
|
||||
{
|
||||
$relativeVelocity = $categoryAvgVelocity > 0 ? $velocity / $categoryAvgVelocity : 0;
|
||||
|
||||
// Very low velocity with flat/declining trend = terminal
|
||||
if ($relativeVelocity < 0.2 && ($velocityTrend === null || $velocityTrend <= 0)) {
|
||||
return 'terminal';
|
||||
}
|
||||
|
||||
// Low velocity but growing = launch
|
||||
if ($relativeVelocity < 0.5 && $velocityTrend !== null && $velocityTrend > 20) {
|
||||
return 'launch';
|
||||
}
|
||||
|
||||
// Medium velocity with strong growth = growth
|
||||
if ($velocityTrend !== null && $velocityTrend > 10) {
|
||||
return 'growth';
|
||||
}
|
||||
|
||||
// High velocity, stable = peak
|
||||
if ($relativeVelocity >= 0.8 && ($velocityTrend === null || abs($velocityTrend) <= 10)) {
|
||||
return 'peak';
|
||||
}
|
||||
|
||||
// Declining = decline
|
||||
if ($velocityTrend !== null && $velocityTrend < -10) {
|
||||
return 'decline';
|
||||
}
|
||||
|
||||
// Default to growth for healthy products
|
||||
return 'growth';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get positioning label for display
|
||||
*/
|
||||
public function getPositioningLabelDisplay(): string
|
||||
{
|
||||
if (! $this->brandPositioning) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
return match ($this->brandPositioning['positioning_label'] ?? 'more_of_the_same') {
|
||||
'value_disruptor' => 'Value Disruptor',
|
||||
'premium_standout' => 'Premium Standout',
|
||||
'potency_leader' => 'Potency Leader',
|
||||
'format_outlier' => 'Format Outlier',
|
||||
default => 'More of the Same',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trend classification for display
|
||||
*/
|
||||
public function getTrendClassificationDisplay(): string
|
||||
{
|
||||
if (! $this->trendLeadLag) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
return match ($this->trendLeadLag['classification'] ?? 'in_line') {
|
||||
'strong_leader' => 'Predictive (Leads Market)',
|
||||
'emerging_leader' => 'Early Mover',
|
||||
'follower' => 'Follower',
|
||||
'laggy' => 'Laggy (Follows Late)',
|
||||
default => 'In Line with Market',
|
||||
};
|
||||
}
|
||||
}
|
||||
1895
app/Services/Cannaiq/AdvancedV3IntelligenceService.php
Normal file
1895
app/Services/Cannaiq/AdvancedV3IntelligenceService.php
Normal file
File diff suppressed because it is too large
Load Diff
337
app/Services/Cannaiq/BrandAnalysisDTO.php
Normal file
337
app/Services/Cannaiq/BrandAnalysisDTO.php
Normal file
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Cannaiq;
|
||||
|
||||
/**
|
||||
* Brand Analysis Data Transfer Object (v3.0)
|
||||
*
|
||||
* Contains all market intelligence data for a brand, structured for the Analysis page.
|
||||
* When CannaiQ is disabled, contains only internal sales data.
|
||||
* When CannaiQ is enabled, enriched with market intelligence.
|
||||
*
|
||||
* v2.0 Additions:
|
||||
* - engagement: Buyer outreach and response tracking (always available)
|
||||
* - sentiment: Store support and brand positioning (CannaiQ only)
|
||||
*
|
||||
* v3.0 Additions:
|
||||
* - advancedV3: Advanced intelligence analytics (CannaiQ only)
|
||||
* - brandPositioning: Differentiation score and positioning label
|
||||
* - trendLeadLag: Predictive vs laggy behavior analysis
|
||||
* - marketSignals: Cross-state market trends
|
||||
* - shelfOpportunities: Displacement opportunities with difficulty scores
|
||||
*
|
||||
* Structure Reference (v1.5):
|
||||
*
|
||||
* placement: [
|
||||
* 'stores' => [...], // List of stores carrying brand
|
||||
* 'whitespaceStores' => [...], // v1.5: Stores with competitors but not us
|
||||
* 'whitespaceCount' => int, // v1.5: Count of whitespace opportunities
|
||||
* 'penetrationByRegion' => [ // v1.5: Regional breakdown
|
||||
* ['region' => 'CA', 'storeCount' => 10, 'totalStores' => 50, 'penetrationPercent' => 20],
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* competitors: [
|
||||
* 'competitors' => [...], // List of competitor brands
|
||||
* 'pricePosition' => 'value'|'mid'|'premium', // v1.5: Our price position
|
||||
* 'headToHeadSkus' => [ // v1.5: Direct SKU comparisons
|
||||
* ['ourSku' => '...', 'competitorSku' => '...', 'ourVelocity' => 0.5, ...],
|
||||
* ],
|
||||
* 'marketShareTrend' => [ // v1.5: Time series market share
|
||||
* ['period' => '2025-01', 'ourShare' => 12.5, 'competitor1Share' => 15.2, ...],
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* promoPerformance: [
|
||||
* [
|
||||
* 'id' => ..., 'name' => ...,
|
||||
* 'baselineVelocity' => float, // v1.5: Non-promo velocity
|
||||
* 'promoVelocity' => float, // v1.5: During-promo velocity
|
||||
* 'velocityLift' => float, // v1.5: Percent lift
|
||||
* 'efficiencyScore' => float, // v1.5: Units gained per discount dollar
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* inventoryProjection: [
|
||||
* 'items' => [ // v1.5: Structured items array
|
||||
* ['sku' => '...', 'daysOfStock' => int, 'riskLevel' => 'low'|'medium'|'high', ...],
|
||||
* ],
|
||||
* 'overstockedItems' => [...], // v1.5: Items with >90 days supply
|
||||
* 'rollup' => [ // v1.5: Brand-level summary
|
||||
* 'criticalCount' => int,
|
||||
* 'warningCount' => int,
|
||||
* 'overstockedSkuCount' => int,
|
||||
* 'riskLevel' => 'healthy'|'moderate'|'elevated'|'critical',
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* slippage: [
|
||||
* 'alerts' => [...], // Basic alerts (existing)
|
||||
* 'summary' => [ // v1.5: Summary metrics
|
||||
* 'lostStores30dCount' => int,
|
||||
* 'lostStores60dCount' => int,
|
||||
* 'lostSkus30dCount' => int,
|
||||
* 'competitorTakeoverCount' => int,
|
||||
* ],
|
||||
* 'lostStores30d' => [...], // v1.5: List of lost stores
|
||||
* 'lostStores60d' => [...],
|
||||
* 'lostSkus30d' => [...], // v1.5: List of lost SKUs
|
||||
* 'competitorTakeovers' => [...], // v1.5: SKU replacement events
|
||||
* 'oosMetrics' => [ // v1.5: Out-of-stock metrics
|
||||
* 'avgOOSDuration' => float,
|
||||
* 'avgReorderLag' => float,
|
||||
* 'chronicOOSStores' => [...],
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* engagement: [ // v2.0: Buyer outreach & response (ALWAYS available)
|
||||
* 'reach' => [
|
||||
* 'storesContacted30d' => int, // Unique stores contacted
|
||||
* 'messagesSent30d' => int, // Total outbound messages
|
||||
* 'touchesPerStore' => float, // Avg touches per store
|
||||
* 'repActivityLeaders' => [...], // Top reps by activity
|
||||
* ],
|
||||
* 'response' => [
|
||||
* 'responseRate' => float, // 0..1 reply rate
|
||||
* 'avgResponseTimeHours' => float|null, // Median reply time
|
||||
* 'storesNotResponding' => int, // Silent accounts
|
||||
* 'mostEngagedStores' => [...], // Top responding stores
|
||||
* ],
|
||||
* 'actions' => [
|
||||
* 'quotesIssued30d' => int, // Quotes tied to brand
|
||||
* 'ordersPlaced30d' => int, // Orders with brand products
|
||||
* 'conversionRate' => float|null, // Quotes → Orders
|
||||
* 'reorderRate' => float|null, // Repeat buyers
|
||||
* 'atRiskAccounts' => [...], // Accounts needing attention
|
||||
* ],
|
||||
* 'quality' => [
|
||||
* 'touchTypeBreakdown' => [...], // By channel type
|
||||
* 'buyerEngagementScore' => float|null, // 0..100
|
||||
* 'buyerEngagementLabel' => string, // "Strong partner" / "Healthy" / "At risk" / "Needs action"
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* sentiment: [ // v2.0: Store support (CannaiQ ONLY - null when disabled)
|
||||
* 'storeSupport' => [
|
||||
* 'storesPromotingBrand30d' => int, // Stores with active promos
|
||||
* 'promoFrequencyPerStore' => float|null,// Promos per store
|
||||
* 'featuredPlacementCount' => int, // Featured/specials count
|
||||
* 'avgShelfShare' => float|null, // Category share
|
||||
* 'storeSentimentScore' => float|null, // 0..100
|
||||
* 'storeSentimentLabel' => string, // "Advocates" / "Supportive" / "Neutral" / "Unsupportive"
|
||||
* ],
|
||||
* 'pricingBehavior' => [
|
||||
* 'avgDiscountRate' => float|null, // Avg promo discount
|
||||
* 'priceRespectIndex' => float|null, // 0..100 (MSRP adherence)
|
||||
* 'competitorPricePressure' => float|null, // 0..100
|
||||
* ],
|
||||
* 'inventoryBehavior' => [
|
||||
* 'sellThroughAfterRestock' => float|null, // Units/day post-restock
|
||||
* 'restockUrgencyIndex' => float|null, // 0..100 (faster reorders = higher)
|
||||
* 'stockNeglectEvents' => int, // Extended OOS events
|
||||
* 'shelfCommitment' => [
|
||||
* 'singleSkuStores' => int, // Stores with 1 SKU
|
||||
* 'multiSkuStores' => int, // Stores with 3+ SKUs
|
||||
* 'avgSkusPerStore' => float|null, // Avg SKU depth
|
||||
* ],
|
||||
* ],
|
||||
* ]
|
||||
*/
|
||||
class BrandAnalysisDTO
|
||||
{
|
||||
public function __construct(
|
||||
// Core metadata
|
||||
public readonly int $brandId,
|
||||
public readonly string $brandName,
|
||||
public readonly bool $cannaiqEnabled,
|
||||
public readonly ?\DateTimeInterface $dataFreshness = null,
|
||||
|
||||
// Connection error message (when CannaiQ is enabled but API fails)
|
||||
public readonly ?string $connectionError = null,
|
||||
|
||||
// Store placement data (v1.5: enriched with whitespace + regional)
|
||||
public readonly array $placement = [],
|
||||
|
||||
// Competitor analysis (v1.5: enriched with head-to-head + trends)
|
||||
public readonly array $competitors = [],
|
||||
|
||||
// SKU performance data
|
||||
public readonly array $skuPerformance = [],
|
||||
|
||||
// Promo performance data (v1.5: enriched with lift + efficiency)
|
||||
public readonly array $promoPerformance = [],
|
||||
|
||||
// Inventory projections (v1.5: enriched with risk levels + rollup)
|
||||
public readonly array $inventoryProjection = [],
|
||||
|
||||
// Slippage/velocity warnings (v1.5: fully structured)
|
||||
public readonly array $slippage = [],
|
||||
|
||||
// Summary metrics (v1.5: enriched with whitespace count)
|
||||
public readonly array $summary = [],
|
||||
|
||||
// v2.0: Buyer engagement (internal CRM + orders - ALWAYS available)
|
||||
public readonly array $engagement = [],
|
||||
|
||||
// v2.0: Store sentiment (CannaiQ data - ONLY when cannaiq_enabled)
|
||||
public readonly ?array $sentiment = null,
|
||||
|
||||
// v3.0: Advanced intelligence (CannaiQ data - ONLY when cannaiq_enabled)
|
||||
public readonly ?AdvancedV3IntelligenceDTO $advancedV3 = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create empty DTO for when data is unavailable
|
||||
*/
|
||||
public static function empty(int $brandId, string $brandName, bool $cannaiqEnabled): self
|
||||
{
|
||||
return new self(
|
||||
brandId: $brandId,
|
||||
brandName: $brandName,
|
||||
cannaiqEnabled: $cannaiqEnabled,
|
||||
dataFreshness: null,
|
||||
placement: [
|
||||
'stores' => [],
|
||||
'whitespaceStores' => [],
|
||||
'whitespaceCount' => 0,
|
||||
'penetrationByRegion' => [],
|
||||
],
|
||||
competitors: [
|
||||
'competitors' => [],
|
||||
'pricePosition' => null,
|
||||
'headToHeadSkus' => [],
|
||||
'marketShareTrend' => [],
|
||||
],
|
||||
skuPerformance: [],
|
||||
promoPerformance: [],
|
||||
inventoryProjection: [
|
||||
'items' => [],
|
||||
'overstockedItems' => [],
|
||||
'rollup' => [
|
||||
'criticalCount' => 0,
|
||||
'warningCount' => 0,
|
||||
'overstockedSkuCount' => 0,
|
||||
'riskLevel' => 'healthy',
|
||||
],
|
||||
],
|
||||
slippage: [
|
||||
'alerts' => [],
|
||||
'summary' => [
|
||||
'lostStores30dCount' => 0,
|
||||
'lostStores60dCount' => 0,
|
||||
'lostSkus30dCount' => 0,
|
||||
'competitorTakeoverCount' => 0,
|
||||
],
|
||||
'lostStores30d' => [],
|
||||
'lostStores60d' => [],
|
||||
'lostSkus30d' => [],
|
||||
'competitorTakeovers' => [],
|
||||
'oosMetrics' => [
|
||||
'avgOOSDuration' => null,
|
||||
'avgReorderLag' => null,
|
||||
'chronicOOSStores' => [],
|
||||
],
|
||||
],
|
||||
summary: [
|
||||
'totalStores' => 0,
|
||||
'totalSkus' => 0,
|
||||
'avgPrice' => 0,
|
||||
'marketShare' => null,
|
||||
'pricePosition' => null,
|
||||
'whitespaceCount' => 0,
|
||||
],
|
||||
engagement: self::emptyEngagement(),
|
||||
sentiment: null,
|
||||
advancedV3: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get empty engagement structure
|
||||
*/
|
||||
public static function emptyEngagement(): array
|
||||
{
|
||||
return [
|
||||
'reach' => [
|
||||
'storesContacted30d' => 0,
|
||||
'messagesSent30d' => 0,
|
||||
'touchesPerStore' => 0,
|
||||
'repActivityLeaders' => [],
|
||||
],
|
||||
'response' => [
|
||||
'responseRate' => 0,
|
||||
'avgResponseTimeHours' => null,
|
||||
'storesNotResponding' => 0,
|
||||
'mostEngagedStores' => [],
|
||||
],
|
||||
'actions' => [
|
||||
'quotesIssued30d' => 0,
|
||||
'ordersPlaced30d' => 0,
|
||||
'conversionRate' => null,
|
||||
'reorderRate' => null,
|
||||
'atRiskAccounts' => [],
|
||||
],
|
||||
'quality' => [
|
||||
'touchTypeBreakdown' => [],
|
||||
'buyerEngagementScore' => null,
|
||||
'buyerEngagementLabel' => 'Needs action',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get empty sentiment structure
|
||||
*/
|
||||
public static function emptySentiment(): array
|
||||
{
|
||||
return [
|
||||
'storeSupport' => [
|
||||
'storesPromotingBrand30d' => 0,
|
||||
'promoFrequencyPerStore' => null,
|
||||
'featuredPlacementCount' => 0,
|
||||
'avgShelfShare' => null,
|
||||
'storeSentimentScore' => null,
|
||||
'storeSentimentLabel' => 'Neutral',
|
||||
],
|
||||
'pricingBehavior' => [
|
||||
'avgDiscountRate' => null,
|
||||
'priceRespectIndex' => null,
|
||||
'competitorPricePressure' => null,
|
||||
],
|
||||
'inventoryBehavior' => [
|
||||
'sellThroughAfterRestock' => null,
|
||||
'restockUrgencyIndex' => null,
|
||||
'stockNeglectEvents' => 0,
|
||||
'shelfCommitment' => [
|
||||
'singleSkuStores' => 0,
|
||||
'multiSkuStores' => 0,
|
||||
'avgSkusPerStore' => null,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for views
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'brandId' => $this->brandId,
|
||||
'brandName' => $this->brandName,
|
||||
'cannaiqEnabled' => $this->cannaiqEnabled,
|
||||
'connectionError' => $this->connectionError,
|
||||
'dataFreshness' => $this->dataFreshness?->format('Y-m-d H:i:s'),
|
||||
'placement' => $this->placement,
|
||||
'competitors' => $this->competitors,
|
||||
'skuPerformance' => $this->skuPerformance,
|
||||
'promoPerformance' => $this->promoPerformance,
|
||||
'inventoryProjection' => $this->inventoryProjection,
|
||||
'slippage' => $this->slippage,
|
||||
'summary' => $this->summary,
|
||||
'engagement' => $this->engagement,
|
||||
'sentiment' => $this->sentiment,
|
||||
'advancedV3' => $this->advancedV3?->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
1827
app/Services/Cannaiq/BrandAnalysisService.php
Normal file
1827
app/Services/Cannaiq/BrandAnalysisService.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
*
|
||||
@@ -437,4 +477,135 @@ class CannaiqClient
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Brand Analytics API Endpoints (v1.5)
|
||||
// These endpoints provide brand-level intelligence
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Get brand-level metrics including whitespace and regional penetration
|
||||
*
|
||||
* @param string $brandName Brand name/slug
|
||||
*/
|
||||
public function getBrandMetrics(string $brandName): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/brands/{$brandName}/metrics");
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch brand metrics', [
|
||||
'brand' => $brandName,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch brand metrics'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching brand metrics', [
|
||||
'brand' => $brandName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get competitor analysis for a brand
|
||||
* Returns: head-to-head comparisons, market share trends, price position
|
||||
*
|
||||
* @param string $brandName Brand name/slug
|
||||
* @param array $options Optional parameters (top_n, etc)
|
||||
*/
|
||||
public function getBrandCompetitors(string $brandName, array $options = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/brands/{$brandName}/competitors", $options);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch brand competitors', [
|
||||
'brand' => $brandName,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch brand competitors'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching brand competitors', [
|
||||
'brand' => $brandName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get promotion performance metrics for a brand
|
||||
* Returns: velocity lift, baseline vs promo velocity, efficiency scores
|
||||
*
|
||||
* @param string $brandName Brand name/slug
|
||||
* @param array $options Optional parameters (from, to date range)
|
||||
*/
|
||||
public function getBrandPromoMetrics(string $brandName, array $options = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/brands/{$brandName}/promo-metrics", $options);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch brand promo metrics', [
|
||||
'brand' => $brandName,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch brand promo metrics'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching brand promo metrics', [
|
||||
'brand' => $brandName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slippage/churn metrics for a brand
|
||||
* Returns: lost stores, lost SKUs, competitor takeovers, OOS metrics
|
||||
*
|
||||
* @param string $brandName Brand name/slug
|
||||
* @param array $options Optional parameters (days_back, etc)
|
||||
*/
|
||||
public function getBrandSlippage(string $brandName, array $options = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->http->get("/brands/{$brandName}/slippage", $options);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
Log::warning('CannaiQ: Failed to fetch brand slippage', [
|
||||
'brand' => $brandName,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => 'Failed to fetch brand slippage'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CannaiQ: Exception fetching brand slippage', [
|
||||
'brand' => $brandName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['error' => true, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
108
app/Services/Dashboard/CommandCenterData.php
Normal file
108
app/Services/Dashboard/CommandCenterData.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
530
app/Services/Dashboard/CommandCenterService.php
Normal file
530
app/Services/Dashboard/CommandCenterService.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
219
app/Services/Dashboard/OrchestratorWidgetService.php
Normal file
219
app/Services/Dashboard/OrchestratorWidgetService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
45
database/data/missing_products.php
Normal file
45
database/data/missing_products.php
Normal 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' => ''],
|
||||
];
|
||||
362
database/data/product_descriptions_hf.php
Normal file
362
database/data/product_descriptions_hf.php
Normal 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 Factory’s 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 Breath’s 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 can’t 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. It’s 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. It’s 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 you’re 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. It’s 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. It’s 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 that’s 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. It’s 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 doesn’t 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 you’re 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. It’s 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. You’ll 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 isn’t just a concentrate; it’s your ticket to tropical bliss in every dab. Whether you’re 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 isn’t just a concentrate; it’s your ticket to tropical bliss in every dab. Whether you’re 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 you’re 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. It’s 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. It’s 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, it’s 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, Chemdawg’s 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 that’s ideal for evening use or whenever you’re 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, it’s 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, Chemdawg’s 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 that’s ideal for evening use or whenever you’re 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. It’s a refreshing, fruity treat that’s 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 doesn’t 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 Factory’s 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. 🌍
|
||||
|
||||
Superboof’s 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. It’s 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. It’s 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. It’s 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. It’s 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. 💍🍰🌙"],
|
||||
];
|
||||
5300
database/data/product_descriptions_non_hf.php
Normal file
5300
database/data/product_descriptions_non_hf.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
<?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.
|
||||
*
|
||||
* Fixes:
|
||||
* - Issue #162: Contacts without hashids cause 404 on edit
|
||||
* - Issue #163: Batches table missing hashid column
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Add hashid column to batches table if it doesn't exist
|
||||
if (! Schema::hasColumn('batches', 'hashid')) {
|
||||
Schema::table('batches', function (Blueprint $table) {
|
||||
$table->string('hashid', 10)->nullable()->unique()->after('id');
|
||||
});
|
||||
}
|
||||
|
||||
// Add quantity_unit column to batches table if it doesn't exist (for Issue #163)
|
||||
if (! Schema::hasColumn('batches', 'quantity_unit')) {
|
||||
Schema::table('batches', function (Blueprint $table) {
|
||||
$table->string('quantity_unit', 50)->nullable()->after('quantity_produced');
|
||||
});
|
||||
}
|
||||
|
||||
// Backfill hashids for contacts
|
||||
$this->backfillHashids('contacts');
|
||||
|
||||
// Backfill hashids for batches
|
||||
$this->backfillHashids('batches');
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill hashids for a given table
|
||||
*/
|
||||
private function backfillHashids(string $table): void
|
||||
{
|
||||
$numbers = '123456789';
|
||||
$letters = 'abcdefghjkmnpqrstuvwxyz';
|
||||
|
||||
$records = DB::table($table)
|
||||
->whereNull('hashid')
|
||||
->orWhere('hashid', '')
|
||||
->select('id')
|
||||
->get();
|
||||
|
||||
foreach ($records as $record) {
|
||||
$hashid = $this->generateUniqueHashid($table, $numbers, $letters);
|
||||
DB::table($table)
|
||||
->where('id', $record->id)
|
||||
->update(['hashid' => $hashid]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique hashid in NNLLN format
|
||||
*/
|
||||
private function generateUniqueHashid(string $table, string $numbers, string $letters): string
|
||||
{
|
||||
do {
|
||||
$hashid = $numbers[rand(0, strlen($numbers) - 1)]
|
||||
.$numbers[rand(0, strlen($numbers) - 1)]
|
||||
.$letters[rand(0, strlen($letters) - 1)]
|
||||
.$letters[rand(0, strlen($letters) - 1)]
|
||||
.$numbers[rand(0, strlen($numbers) - 1)];
|
||||
|
||||
$exists = DB::table($table)->where('hashid', $hashid)->exists();
|
||||
} while ($exists);
|
||||
|
||||
return $hashid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// We don't remove hashids on rollback - they're useful data
|
||||
// Only remove the new columns if needed
|
||||
if (Schema::hasColumn('batches', 'quantity_unit')) {
|
||||
Schema::table('batches', function (Blueprint $table) {
|
||||
$table->dropColumn('quantity_unit');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Brand;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Backfill descriptions from MySQL hub_cannabrands database.
|
||||
*
|
||||
* This migration:
|
||||
* 1. Updates existing products with long_description from product_extras
|
||||
* 2. Creates missing Nuvata products (NU-* SKUs)
|
||||
* 3. Updates existing brands with tagline, description, long_description
|
||||
*
|
||||
* Idempotent: Only updates null fields, won't overwrite existing data.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
protected string $mysqlConnection = 'mysql_import';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
// Check if mysql_import connection is configured
|
||||
if (! config('database.connections.mysql_import')) {
|
||||
echo "Skipping: mysql_import connection not configured.\n";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::connection($this->mysqlConnection)->getPdo();
|
||||
} catch (\Exception $e) {
|
||||
echo "Skipping: Cannot connect to mysql_import database.\n";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->backfillProductDescriptions();
|
||||
$this->importNuvataProducts();
|
||||
$this->backfillBrandDescriptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing products with long_description from product_extras.
|
||||
*/
|
||||
protected function backfillProductDescriptions(): void
|
||||
{
|
||||
echo "Backfilling product long_descriptions...\n";
|
||||
|
||||
// Get all products with long_description from MySQL
|
||||
$mysqlProducts = DB::connection($this->mysqlConnection)
|
||||
->table('products')
|
||||
->join('product_extras', 'products.id', '=', 'product_extras.product_id')
|
||||
->whereNotNull('product_extras.long_description')
|
||||
->where('product_extras.long_description', '!=', '')
|
||||
->select('products.code as sku', 'product_extras.long_description')
|
||||
->get();
|
||||
|
||||
echo "Found {$mysqlProducts->count()} products with long_description in MySQL.\n";
|
||||
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($mysqlProducts as $mysqlProduct) {
|
||||
// Find matching product in PostgreSQL by SKU
|
||||
$pgProduct = Product::where('sku', $mysqlProduct->sku)->first();
|
||||
|
||||
if (! $pgProduct) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only update if long_description is null/empty
|
||||
if (empty($pgProduct->long_description)) {
|
||||
$pgProduct->update([
|
||||
'long_description' => $mysqlProduct->long_description,
|
||||
]);
|
||||
$updated++;
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "Updated {$updated} products, skipped {$skipped} (not found or already has data).\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Import missing Nuvata products (NU-* SKUs).
|
||||
*/
|
||||
protected function importNuvataProducts(): void
|
||||
{
|
||||
echo "Importing missing Nuvata products...\n";
|
||||
|
||||
// Find Nuvata brand in PostgreSQL
|
||||
$nuvataBrand = Brand::where('slug', 'nuvata')->first();
|
||||
|
||||
if (! $nuvataBrand) {
|
||||
echo "Nuvata brand not found in PostgreSQL. Skipping Nuvata product import.\n";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get Nuvata products from MySQL
|
||||
$nuvataProducts = DB::connection($this->mysqlConnection)
|
||||
->table('products')
|
||||
->leftJoin('product_extras', 'products.id', '=', 'product_extras.product_id')
|
||||
->where('products.code', 'like', 'NU-%')
|
||||
->select(
|
||||
'products.code as sku',
|
||||
'products.name',
|
||||
'products.description',
|
||||
'products.wholesale_price',
|
||||
'products.active',
|
||||
'products.created_at',
|
||||
'products.updated_at',
|
||||
'product_extras.long_description'
|
||||
)
|
||||
->get();
|
||||
|
||||
echo "Found {$nuvataProducts->count()} Nuvata products in MySQL.\n";
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($nuvataProducts as $mysqlProduct) {
|
||||
// Check if product already exists
|
||||
if (Product::where('sku', $mysqlProduct->sku)->exists()) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate unique hashid
|
||||
$hashid = Str::random(8);
|
||||
while (Product::where('hashid', $hashid)->exists()) {
|
||||
$hashid = Str::random(8);
|
||||
}
|
||||
|
||||
Product::create([
|
||||
'brand_id' => $nuvataBrand->id,
|
||||
'hashid' => $hashid,
|
||||
'sku' => $mysqlProduct->sku,
|
||||
'name' => $mysqlProduct->name,
|
||||
'description' => $mysqlProduct->description,
|
||||
'long_description' => $mysqlProduct->long_description,
|
||||
'wholesale_price' => $mysqlProduct->wholesale_price,
|
||||
'is_active' => (bool) $mysqlProduct->active,
|
||||
'created_at' => $mysqlProduct->created_at,
|
||||
'updated_at' => $mysqlProduct->updated_at,
|
||||
]);
|
||||
|
||||
$created++;
|
||||
}
|
||||
|
||||
echo "Created {$created} Nuvata products, skipped {$skipped} existing.\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Update brands with tagline, description, and long_description.
|
||||
*/
|
||||
protected function backfillBrandDescriptions(): void
|
||||
{
|
||||
echo "Backfilling brand descriptions...\n";
|
||||
|
||||
// Get brands from MySQL with descriptions
|
||||
$mysqlBrands = DB::connection($this->mysqlConnection)
|
||||
->table('brands')
|
||||
->select('name', 'tagline', 'short_desc', 'desc')
|
||||
->get();
|
||||
|
||||
echo "Found {$mysqlBrands->count()} brands in MySQL.\n";
|
||||
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($mysqlBrands as $mysqlBrand) {
|
||||
$name = trim($mysqlBrand->name ?? '');
|
||||
if (! $name) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find matching brand in PostgreSQL by name (case-insensitive)
|
||||
$pgBrand = Brand::whereRaw('LOWER(name) = ?', [strtolower($name)])->first();
|
||||
|
||||
if (! $pgBrand) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
|
||||
// Only update null/empty fields
|
||||
if (empty($pgBrand->tagline) && ! empty($mysqlBrand->tagline)) {
|
||||
$updates['tagline'] = $mysqlBrand->tagline;
|
||||
}
|
||||
|
||||
if (empty($pgBrand->description) && ! empty($mysqlBrand->short_desc)) {
|
||||
$updates['description'] = $mysqlBrand->short_desc;
|
||||
}
|
||||
|
||||
if (empty($pgBrand->long_description) && ! empty($mysqlBrand->desc)) {
|
||||
$updates['long_description'] = $mysqlBrand->desc;
|
||||
}
|
||||
|
||||
if (! empty($updates)) {
|
||||
$pgBrand->update($updates);
|
||||
$updated++;
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
echo "Updated {$updated} brands, skipped {$skipped} (not found or already has data).\n";
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// This is a data backfill migration - no rollback needed
|
||||
// Data was only added to null fields, original data preserved
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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 🌱)
|
||||
$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
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
4435
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,8 @@
|
||||
"laravel-vite-plugin": "^1.2.0",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"vite": "^6.2.4"
|
||||
"vite": "^6.2.4",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alpinejs/collapse": "^3.15.2",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 4.2 KiB |
BIN
public/icons/apple-touch-icon.png
Normal file
BIN
public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
public/icons/icon-192x192.png
Normal file
BIN
public/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
public/icons/icon-512x512.png
Normal file
BIN
public/icons/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -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 {
|
||||
@@ -247,15 +256,15 @@ html:not([data-theme="material"], [data-theme="material-dark"]) {
|
||||
|
||||
/* == Cannabrands Brand Colors ==
|
||||
* DO NOT CHANGE THESE COLORS!
|
||||
* Primary: #4B6FA4 (muted blue)
|
||||
* Success: #4E8D71 (muted green)
|
||||
* Primary: #4E8D71 (green - main action color)
|
||||
* Info: #4B6FA4 (blue - informational)
|
||||
* Error: #E1524D (clean red)
|
||||
* These are the official brand colors. */
|
||||
--color-primary: #4B6FA4;
|
||||
--color-primary: #4E8D71;
|
||||
--color-primary-content: #ffffff;
|
||||
--color-secondary: #4B6FA4;
|
||||
--color-secondary-content: #ffffff;
|
||||
--color-accent: #4B6FA4;
|
||||
--color-accent: #4E8D71;
|
||||
--color-accent-content: #ffffff;
|
||||
--color-neutral: #374151;
|
||||
--color-neutral-content: #ffffff;
|
||||
@@ -302,11 +311,11 @@ html:not([data-theme="material"], [data-theme="material-dark"]) {
|
||||
/* == Cannabrands Brand Colors (Dark) ==
|
||||
* DO NOT CHANGE THESE COLORS!
|
||||
* See light theme for brand color documentation. */
|
||||
--color-primary: #5A7FB4;
|
||||
--color-primary: #5E9D81;
|
||||
--color-primary-content: #ffffff;
|
||||
--color-secondary: #5A7FB4;
|
||||
--color-secondary-content: #ffffff;
|
||||
--color-accent: #5A7FB4;
|
||||
--color-accent: #5E9D81;
|
||||
--color-accent-content: #ffffff;
|
||||
--color-neutral: #9ca3af;
|
||||
--color-neutral-content: #1e2832;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import './bootstrap';
|
||||
import './push-notifications';
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
import Precognition from 'laravel-precognition-alpine';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user