Compare commits
36 Commits
fix/ci-git
...
fix/respon
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd11ae0fe0 | ||
|
|
16c5c455fa | ||
|
|
df587fdda3 | ||
|
|
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 | ||
|
|
08f5a3adac | ||
|
|
e62ea5c809 | ||
|
|
8d43953cad | ||
|
|
a628f2b207 | ||
|
|
367daadfe9 | ||
|
|
b33ebac9bf |
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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +52,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'])
|
||||
|
||||
@@ -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,34 @@ 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 view('seller.invoices.index', compact('business', 'invoices', 'stats'));
|
||||
}
|
||||
|
||||
@@ -106,7 +106,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 +123,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
|
||||
@@ -479,9 +479,14 @@ class ProductController extends Controller
|
||||
$product = Product::create($validated);
|
||||
|
||||
// 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) {
|
||||
$path = $image->store('products', 'public');
|
||||
$filename = $image->hashName();
|
||||
$path = $image->storeAs($basePath, $filename);
|
||||
$product->images()->create([
|
||||
'path' => $path,
|
||||
'type' => 'product',
|
||||
@@ -1032,7 +1037,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,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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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']})",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
|
||||
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
@@ -437,4 +437,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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -247,15 +247,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 +302,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;
|
||||
|
||||
@@ -5,24 +5,21 @@
|
||||
--}}
|
||||
@php
|
||||
$statusConfig = [
|
||||
// New orders (primary - blue)
|
||||
'new' => ['label' => 'New', 'class' => 'badge-primary'],
|
||||
// All active/in-progress states (neutral - gray)
|
||||
'buyer_modified' => ['label' => 'Modified', 'class' => 'badge-neutral'],
|
||||
'seller_modified' => ['label' => 'Modified', 'class' => 'badge-neutral'],
|
||||
'accepted' => ['label' => 'Accepted', 'class' => 'badge-neutral'],
|
||||
'in_progress' => ['label' => 'In Progress', 'class' => 'badge-neutral'],
|
||||
'ready_for_delivery' => ['label' => 'Buyer Review', 'class' => 'badge-neutral'],
|
||||
'out_for_delivery' => ['label' => 'Delivering', 'class' => 'badge-neutral'],
|
||||
'ready_for_manifest' => ['label' => 'Ready', 'class' => 'badge-neutral'],
|
||||
'approved_for_delivery' => ['label' => 'Ready', 'class' => 'badge-neutral'],
|
||||
'delivered' => ['label' => 'Delivered', 'class' => 'badge-neutral'],
|
||||
'buyer_approved' => ['label' => 'Complete', 'class' => 'badge-neutral'],
|
||||
'completed' => ['label' => 'Complete', 'class' => 'badge-neutral'],
|
||||
|
||||
// Terminal error states only (error - muted red)
|
||||
'cancelled' => ['label' => 'Cancelled', 'class' => 'badge-error'],
|
||||
'rejected' => ['label' => 'Rejected', 'class' => 'badge-error'],
|
||||
// All statuses use ghost (no color) style
|
||||
'new' => ['label' => 'New', 'class' => 'badge-ghost'],
|
||||
'buyer_modified' => ['label' => 'Modified', 'class' => 'badge-ghost'],
|
||||
'seller_modified' => ['label' => 'Modified', 'class' => 'badge-ghost'],
|
||||
'accepted' => ['label' => 'Accepted', 'class' => 'badge-ghost'],
|
||||
'in_progress' => ['label' => 'In Progress', 'class' => 'badge-ghost'],
|
||||
'ready_for_delivery' => ['label' => 'Buyer Review', 'class' => 'badge-ghost'],
|
||||
'out_for_delivery' => ['label' => 'Delivering', 'class' => 'badge-ghost'],
|
||||
'ready_for_manifest' => ['label' => 'Ready', 'class' => 'badge-ghost'],
|
||||
'approved_for_delivery' => ['label' => 'Ready', 'class' => 'badge-ghost'],
|
||||
'delivered' => ['label' => 'Delivered', 'class' => 'badge-ghost'],
|
||||
'buyer_approved' => ['label' => 'Complete', 'class' => 'badge-ghost'],
|
||||
'completed' => ['label' => 'Complete', 'class' => 'badge-ghost'],
|
||||
'cancelled' => ['label' => 'Cancelled', 'class' => 'badge-ghost'],
|
||||
'rejected' => ['label' => 'Rejected', 'class' => 'badge-ghost'],
|
||||
];
|
||||
|
||||
$config = $statusConfig[$status] ?? ['label' => ucfirst(str_replace('_', ' ', $status)), 'class' => 'badge-neutral'];
|
||||
|
||||
@@ -165,6 +165,9 @@
|
||||
v{{ $appVersion }} (sha-{{ $appCommit }})
|
||||
@endif
|
||||
</p>
|
||||
@if($appBuildDate ?? null)
|
||||
<p class="text-[10px] text-base-content/40 mb-0.5">{{ $appBuildDate }}</p>
|
||||
@endif
|
||||
<p>© {{ date('Y') }} creationshop, {{ config('version.company.suffix') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -178,13 +178,16 @@
|
||||
<!-- Version Info Section -->
|
||||
<div class="mx-2 mb-3 px-3 text-xs text-center text-base-content/50">
|
||||
<p class="mb-0.5">{{ config('version.company.name') }} hub</p>
|
||||
<p class="mb-0.5" style="font-family: 'Courier New', monospace;">
|
||||
<p class="mb-0.5 font-mono">
|
||||
@if($appVersion === 'dev')
|
||||
<span class="text-yellow-500 font-semibold">DEV</span> sha-{{ $appCommit }}
|
||||
@else
|
||||
v{{ $appVersion }} (sha-{{ $appCommit }})
|
||||
@endif
|
||||
</p>
|
||||
@if($appBuildDate ?? null)
|
||||
<p class="text-[10px] text-base-content/40 mb-0.5">{{ $appBuildDate }}</p>
|
||||
@endif
|
||||
<p>© {{ date('Y') }} Made with <span class="text-error">♥</span> <a href="https://creationshop.io" target="_blank" rel="noopener noreferrer" class="link link-hover text-xs">Creationshop</a></p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -179,6 +179,9 @@
|
||||
v{{ $appVersion }} (sha-{{ $appCommit }})
|
||||
@endif
|
||||
</p>
|
||||
@if($appBuildDate ?? null)
|
||||
<p class="text-[10px] text-base-content/40 mb-0.5">{{ $appBuildDate }}</p>
|
||||
@endif
|
||||
<p>© {{ date('Y') }} creationshop, {{ config('version.company.suffix') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,20 +25,11 @@
|
||||
<div class="size-full overflow-y-auto"
|
||||
x-data="{
|
||||
init() {
|
||||
// Wait for collapse animations to complete (they take ~300ms)
|
||||
setTimeout(() => {
|
||||
// Scroll the active menu item into view
|
||||
const activeItem = this.$el.querySelector('.menu-item.active');
|
||||
if (activeItem) {
|
||||
activeItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else {
|
||||
// Fallback to saved scroll position
|
||||
const savedScroll = localStorage.getItem('sidebar-scroll-position');
|
||||
if (savedScroll) {
|
||||
this.$el.scrollTop = parseInt(savedScroll);
|
||||
}
|
||||
}
|
||||
}, 350);
|
||||
// Restore scroll position immediately (no animation)
|
||||
const savedScroll = localStorage.getItem('sidebar-scroll-position');
|
||||
if (savedScroll) {
|
||||
this.$el.scrollTop = parseInt(savedScroll);
|
||||
}
|
||||
}
|
||||
}"
|
||||
@scroll.debounce.150ms="localStorage.setItem('sidebar-scroll-position', $el.scrollTop)">
|
||||
@@ -62,7 +53,6 @@
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuDashboard" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--bar-chart-3] size-4"></span>
|
||||
@@ -99,7 +89,6 @@
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuCommerce" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--shopping-cart] size-4"></span>
|
||||
@@ -145,7 +134,6 @@
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuBrands" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--bookmark] size-4"></span>
|
||||
@@ -176,7 +164,6 @@
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuInventory" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--package-2] size-4"></span>
|
||||
@@ -247,7 +234,6 @@
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuProcessing" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--beaker] size-4"></span>
|
||||
@@ -268,7 +254,6 @@
|
||||
aria-label="Solventless submenu"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-child-item"
|
||||
x-model="menuSolventless" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="grow text-sm font-semibold">Solventless</span>
|
||||
@@ -316,7 +301,6 @@
|
||||
aria-label="BHO submenu"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-child-item"
|
||||
x-model="menuBHO" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="grow text-sm font-semibold">BHO</span>
|
||||
@@ -364,7 +348,6 @@
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuManufacturing" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--factory] size-4"></span>
|
||||
@@ -410,7 +393,6 @@
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuManagement" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--book-open] size-4"></span>
|
||||
@@ -444,7 +426,6 @@
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuGrowth" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--megaphone] size-4"></span>
|
||||
@@ -513,7 +494,6 @@
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuSales" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--briefcase] size-4"></span>
|
||||
@@ -574,7 +554,6 @@
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuInbox" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--inbox] size-4"></span>
|
||||
@@ -655,7 +634,6 @@
|
||||
aria-label="Sidemenu item trigger"
|
||||
type="checkbox"
|
||||
class="peer"
|
||||
name="sidebar-menu-parent-item"
|
||||
x-model="menuReports" />
|
||||
<div class="collapse-title px-2.5 py-1.5">
|
||||
<span class="icon-[lucide--file-bar-chart] size-4"></span>
|
||||
@@ -693,6 +671,9 @@
|
||||
v{{ $appVersion }} (sha-{{ $appCommit }})
|
||||
@endif
|
||||
</p>
|
||||
@if($appBuildDate ?? null)
|
||||
<p class="text-[10px] text-base-content/40 mb-0.5">{{ $appBuildDate }}</p>
|
||||
@endif
|
||||
<p>© {{ date('Y') }} creationshop, {{ config('version.company.suffix') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,9 +82,10 @@
|
||||
name="{{ $searchName }}"
|
||||
value="{{ $searchValue }}"
|
||||
placeholder="{{ $searchPlaceholder }}"
|
||||
class="input input-sm input-bordered w-full pr-8 bg-base-100">
|
||||
<button type="submit" class="absolute right-1.5 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle">
|
||||
<span class="icon-[heroicons--magnifying-glass] size-4 text-base-content/30"></span>
|
||||
class="input input-sm input-bordered w-full pr-10 bg-base-100"
|
||||
onkeydown="if(event.key==='Enter'){this.form.submit();}">
|
||||
<button type="submit" class="absolute right-2.5 top-1/2 -translate-y-1/2" title="Search">
|
||||
<span class="icon-[heroicons--magnifying-glass] size-4 text-base-content/30 hover:text-base-content/60"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<div class="fi-footer flex items-center justify-center gap-x-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div>
|
||||
<div class="text-center">
|
||||
@if ($appVersion === 'dev')
|
||||
<span class="font-semibold text-yellow-600 dark:text-yellow-500">DEV</span>
|
||||
<span class="font-mono">sha-{{ $appCommit }}</span>
|
||||
@else
|
||||
<span class="font-mono">v{{ $appVersion }} (sha-{{ $appCommit }})</span>
|
||||
@endif
|
||||
@if($appBuildDate ?? null)
|
||||
<span class="text-gray-400 dark:text-gray-500 ml-2">{{ $appBuildDate }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
37
resources/views/filament/pages/cannaiq-settings.blade.php
Normal file
37
resources/views/filament/pages/cannaiq-settings.blade.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<x-filament-panels::page>
|
||||
{{ $this->form }}
|
||||
|
||||
<div class="mt-6 flex gap-3">
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="info"
|
||||
wire:click="testConnection"
|
||||
>
|
||||
<x-slot name="icon">
|
||||
<x-heroicon-o-signal class="w-5 h-5" />
|
||||
</x-slot>
|
||||
Test Connection
|
||||
</x-filament::button>
|
||||
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="gray"
|
||||
outlined
|
||||
wire:click="clearCache"
|
||||
>
|
||||
<x-slot name="icon">
|
||||
<x-heroicon-o-trash class="w-5 h-5" />
|
||||
</x-slot>
|
||||
Clear Cache
|
||||
</x-filament::button>
|
||||
|
||||
<x-filament::link
|
||||
href="https://cannaiq.co"
|
||||
target="_blank"
|
||||
color="gray"
|
||||
>
|
||||
Visit CannaiQ
|
||||
<x-heroicon-o-arrow-top-right-on-square class="w-4 h-4 ml-1" />
|
||||
</x-filament::link>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@@ -333,7 +333,7 @@
|
||||
<option value="">Select source batch...</option>
|
||||
@foreach($componentBatches as $batch)
|
||||
<option value="{{ $batch->id }}">
|
||||
{{ $batch->batch_number }} - {{ $batch->component->name ?? 'Unknown' }} ({{ $batch->quantity_remaining }} {{ $batch->quantity_unit }} remaining)
|
||||
{{ $batch->batch_number }} - {{ $batch->product->name ?? 'Unknown' }} ({{ $batch->quantity_remaining }} {{ $batch->quantity_unit }} remaining)
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
68
resources/views/seller/brands/analysis-disabled.blade.php
Normal file
68
resources/views/seller/brands/analysis-disabled.blade.php
Normal file
@@ -0,0 +1,68 @@
|
||||
@extends('layouts.seller')
|
||||
|
||||
@section('title', 'Brand Analysis - ' . $brand->name)
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body text-center py-16">
|
||||
{{-- Icon --}}
|
||||
<div class="flex justify-center mb-6">
|
||||
<div class="bg-primary/10 rounded-full p-6">
|
||||
<span class="icon-[lucide--bar-chart-3] size-16 text-primary"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Title --}}
|
||||
<h1 class="text-3xl font-bold mb-4">Brand Analysis</h1>
|
||||
<h2 class="text-xl text-base-content/70 mb-6">{{ $brand->name }}</h2>
|
||||
|
||||
{{-- Message --}}
|
||||
<div class="max-w-lg mx-auto mb-8">
|
||||
<p class="text-base-content/70 mb-4">
|
||||
Brand Analysis provides powerful market intelligence powered by CannaiQ, including:
|
||||
</p>
|
||||
<ul class="text-left text-base-content/60 space-y-2 mb-6">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="icon-[lucide--check-circle] size-5 text-success"></span>
|
||||
<span>Store placement and whitespace opportunities</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="icon-[lucide--check-circle] size-5 text-success"></span>
|
||||
<span>Competitor analysis and market positioning</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="icon-[lucide--check-circle] size-5 text-success"></span>
|
||||
<span>SKU velocity and performance tracking</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="icon-[lucide--check-circle] size-5 text-success"></span>
|
||||
<span>Inventory projections and slippage alerts</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="icon-[lucide--check-circle] size-5 text-success"></span>
|
||||
<span>Buyer engagement and sentiment scoring</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-base-content/70">
|
||||
This feature requires CannaiQ integration to be enabled for your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- CTA --}}
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="mailto:support@cannabrands.com?subject=Enable CannaiQ for {{ urlencode($business->name) }}&body=Hi,%0A%0AI'd like to enable CannaiQ Brand Analysis for my business.%0A%0ABusiness: {{ urlencode($business->name) }}%0ABrand: {{ urlencode($brand->name) }}%0A%0AThank you!"
|
||||
class="btn btn-primary gap-2">
|
||||
<span class="icon-[lucide--mail] size-5"></span>
|
||||
Contact Support to Enable
|
||||
</a>
|
||||
<a href="{{ route('seller.business.brands.dashboard', [$business->slug, $brand->hashid]) }}"
|
||||
class="btn btn-ghost gap-2">
|
||||
<span class="icon-[lucide--arrow-left] size-5"></span>
|
||||
Back to Brand Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
2138
resources/views/seller/brands/analysis.blade.php
Normal file
2138
resources/views/seller/brands/analysis.blade.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -199,7 +199,7 @@
|
||||
</div>
|
||||
|
||||
{{-- Brand Insights Section (Mini Tiles) --}}
|
||||
@if(isset($brandInsights))
|
||||
@if(!empty($brandInsights) && isset($brandInsights['topPerformer']))
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="icon-[heroicons--light-bulb] size-4 text-base-content/50"></span>
|
||||
@@ -348,11 +348,11 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($brand->products->take(5) as $product)
|
||||
@forelse($brand->products->filter(fn($p) => !empty($p->hashid))->take(5) as $product)
|
||||
<tr class="hover:bg-base-200/30 transition-colors">
|
||||
<td class="pl-5">
|
||||
<div class="flex items-center gap-3">
|
||||
@if($product->image_path)
|
||||
@if($product->image_path && $product->hashid)
|
||||
<div class="avatar">
|
||||
<div class="w-10 h-10 rounded-lg bg-base-200/50">
|
||||
<img src="{{ route('image.product', [$product->hashid, 80]) }}" alt="{{ $product->name }}" class="object-contain" />
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('seller.business.crm.accounts.contacts.update', [$business->slug, $account->slug, $contact->id]) }}">
|
||||
<form method="POST" action="{{ route('seller.business.crm.accounts.contacts.update', [$business->slug, $account->slug, $contact->hashid]) }}">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
<h3 class="font-semibold text-error mb-2">Danger Zone</h3>
|
||||
<p class="text-sm text-base-content/60 mb-4">Permanently delete this contact. This action cannot be undone.</p>
|
||||
<form method="POST"
|
||||
action="{{ route('seller.business.crm.accounts.contacts.destroy', [$business->slug, $account->slug, $contact->id]) }}"
|
||||
action="{{ route('seller.business.crm.accounts.contacts.destroy', [$business->slug, $account->slug, $contact->hashid]) }}"
|
||||
onsubmit="return confirm('Are you sure you want to delete this contact? This action cannot be undone.')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
|
||||
@@ -3,26 +3,27 @@
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 space-y-4">
|
||||
{{-- Page Header --}}
|
||||
<header class="flex items-center justify-between">
|
||||
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">All Customers</h1>
|
||||
<p class="text-sm text-base-content/60">Manage your customer relationships and account activity.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 self-start sm:self-auto">
|
||||
<a href="{{ route('seller.business.crm.accounts.create', $business->slug) }}" class="btn btn-primary btn-sm gap-1">
|
||||
<span class="icon-[heroicons--plus] size-4"></span>
|
||||
Add Customer
|
||||
<span class="hidden sm:inline">Add Customer</span>
|
||||
<span class="sm:hidden">Add</span>
|
||||
</a>
|
||||
<a href="{{ route('seller.business.crm.leads.index', $business->slug) }}" class="btn btn-outline btn-sm gap-1">
|
||||
<span class="icon-[heroicons--user-plus] size-4"></span>
|
||||
Leads
|
||||
<span class="hidden sm:inline">Leads</span>
|
||||
</a>
|
||||
{{-- Quick Actions Dropdown --}}
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-10 menu p-2 shadow-sm bg-base-100 rounded-lg w-52 border border-base-300">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-52 border border-base-300 z-[100]">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.crm.leads.create', $business->slug) }}" class="gap-2">
|
||||
<span class="icon-[heroicons--user-plus] size-4"></span>
|
||||
@@ -91,9 +92,9 @@
|
||||
<thead class="bg-base-200/30">
|
||||
<tr>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Account</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Primary Contact</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap text-right py-3">Orders</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap text-right py-3">Open Opps</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3 hidden md:table-cell">Primary Contact</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap text-right py-3 hidden lg:table-cell">Orders</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap text-right py-3 hidden lg:table-cell">Open Opps</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Status</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap text-right py-3"></th>
|
||||
</tr>
|
||||
@@ -121,7 +122,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 align-middle">
|
||||
<td class="py-2 align-middle hidden md:table-cell">
|
||||
@php $primaryContact = $account->contacts->where('is_primary', true)->first() ?? $account->contacts->first(); @endphp
|
||||
@if($primaryContact)
|
||||
<p class="text-sm font-medium">{{ $primaryContact->getFullName() }}</p>
|
||||
@@ -132,17 +133,17 @@
|
||||
<p class="text-sm text-base-content/40">No contact</p>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2 align-middle text-right">
|
||||
<td class="py-2 align-middle text-right hidden lg:table-cell">
|
||||
<span class="text-sm text-base-content/70">{{ $account->orders_count ?? 0 }}</span>
|
||||
</td>
|
||||
<td class="py-2 align-middle text-right">
|
||||
<td class="py-2 align-middle text-right hidden lg:table-cell">
|
||||
@if(($account->opportunities_as_buyer_count ?? 0) > 0)
|
||||
<span class="text-sm font-medium">{{ $account->opportunities_as_buyer_count }}</span>
|
||||
@else
|
||||
<span class="text-sm text-base-content/40">0</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2 align-middle">
|
||||
<td class="py-2 align-middle hidden sm:table-cell">
|
||||
@if($account->is_active)
|
||||
<span class="badge badge-success badge-sm">Active</span>
|
||||
@else
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 space-y-4">
|
||||
{{-- Page Header --}}
|
||||
<header class="mb-4 flex items-center justify-between gap-3">
|
||||
<header class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold mb-0.5">Automations</h1>
|
||||
<p class="text-sm text-base-content/70">Build workflows that trigger campaigns, tasks, and updates based on buyer activity.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 self-start sm:self-auto">
|
||||
<a href="{{ route('seller.business.crm.automations.create', $business) }}" class="btn btn-primary btn-sm gap-1">
|
||||
<span class="icon-[heroicons--plus] size-4"></span>
|
||||
New Automation
|
||||
@@ -40,10 +40,10 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap">Name</th>
|
||||
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap">Trigger</th>
|
||||
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap text-center">Runs</th>
|
||||
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap hidden md:table-cell">Trigger</th>
|
||||
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap text-center hidden lg:table-cell">Runs</th>
|
||||
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap">Status</th>
|
||||
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap">Last Run</th>
|
||||
<th class="text-xs font-semibold text-base-content/70 whitespace-nowrap hidden md:table-cell">Last Run</th>
|
||||
<th class="text-xs font-semibold text-base-content/70 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -58,10 +58,10 @@
|
||||
<p class="text-[11px] text-base-content/60 truncate max-w-xs">{{ $automation->description }}</p>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2 text-sm">
|
||||
<td class="py-2 text-sm hidden md:table-cell">
|
||||
{{ ucwords(str_replace('_', ' ', $automation->trigger_type)) }}
|
||||
</td>
|
||||
<td class="py-2 text-center">
|
||||
<td class="py-2 text-center hidden lg:table-cell">
|
||||
<span class="text-sm text-base-content/80">{{ $automation->successful_runs ?? 0 }}</span>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
@@ -76,7 +76,7 @@
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 text-sm whitespace-nowrap">
|
||||
<td class="py-2 text-sm whitespace-nowrap hidden md:table-cell">
|
||||
{{ $automation->last_run_at?->diffForHumans() ?? 'Never' }}
|
||||
</td>
|
||||
<td class="py-2 text-right">
|
||||
@@ -90,10 +90,10 @@
|
||||
|
||||
{{-- Actions Dropdown --}}
|
||||
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-2xl w-40 z-[100] border border-base-200 {{ $loop->last ? 'mb-1' : '' }}">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100] {{ $loop->last ? 'mb-1' : '' }}">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.crm.automations.show', [$business, $automation]) }}">
|
||||
<span class="icon-[heroicons--eye] size-4"></span>
|
||||
|
||||
@@ -38,10 +38,10 @@
|
||||
</form>
|
||||
@endif
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-5"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-10 menu p-2 shadow bg-base-100 rounded-box w-48">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.crm.calendar.index', $business->slug) }}?date={{ $event->start_at->format('Y-m-d') }}">
|
||||
<span class="icon-[heroicons--calendar] size-4"></span>
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
</li>
|
||||
@endif
|
||||
<li class="border-t mt-1 pt-1">
|
||||
<form method="POST" action="{{ route('seller.business.crm.contacts.destroy', [$business->slug, $contact->id]) }}"
|
||||
<form method="POST" action="{{ route('seller.business.crm.contacts.destroy', [$business->slug, $contact->hashid]) }}"
|
||||
onsubmit="return confirm('Archive this contact?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
|
||||
@@ -27,12 +27,10 @@
|
||||
</form>
|
||||
@endif
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li><a href="{{ route('seller.business.crm.quotes.create', ['business' => $business, 'deal_id' => $deal->id]) }}">Create Quote</a></li>
|
||||
<li>
|
||||
<form method="POST" action="{{ route('seller.business.crm.deals.destroy', [$business, $deal]) }}" onsubmit="return confirm('Delete this deal?')">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@section('content')
|
||||
<div class="px-4 py-6 max-w-7xl mx-auto">
|
||||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<p class="text-lg font-medium flex items-center gap-2">
|
||||
<span class="icon-[heroicons--user-plus] size-5"></span>
|
||||
@@ -13,18 +13,11 @@
|
||||
Prospects and potential customers
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2 self-start sm:self-auto">
|
||||
<a href="{{ route('seller.business.crm.leads.create', $business->slug) }}" class="btn btn-primary btn-sm gap-2">
|
||||
<span class="icon-[heroicons--plus] size-4"></span>
|
||||
Add Lead
|
||||
</a>
|
||||
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
|
||||
<ul>
|
||||
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
|
||||
<li><a href="{{ route('seller.business.crm.accounts.index', $business->slug) }}">Customers</a></li>
|
||||
<li class="opacity-80">Leads</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,9 +70,9 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th>Contact</th>
|
||||
<th>Location</th>
|
||||
<th>Source</th>
|
||||
<th class="hidden sm:table-cell">Contact</th>
|
||||
<th class="hidden lg:table-cell">Location</th>
|
||||
<th class="hidden md:table-cell">Source</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -105,7 +98,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td class="hidden sm:table-cell">
|
||||
<div>
|
||||
<p class="font-medium text-sm">{{ $lead->contact_name }}</p>
|
||||
@if($lead->contact_email)
|
||||
@@ -113,14 +106,14 @@
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td class="hidden lg:table-cell">
|
||||
@if($lead->city || $lead->state)
|
||||
<span class="text-sm">{{ $lead->city }}{{ $lead->state ? ', ' . $lead->state : '' }}</span>
|
||||
@else
|
||||
<span class="text-xs text-base-content/40">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<td class="hidden md:table-cell">
|
||||
@if($lead->source)
|
||||
<span class="text-sm">{{ \App\Models\Crm\CrmLead::SOURCES[$lead->source] ?? $lead->source }}</span>
|
||||
@else
|
||||
@@ -132,12 +125,12 @@
|
||||
{{ \App\Models\Crm\CrmLead::STATUSES[$lead->status] ?? $lead->status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<td class="py-2 align-middle text-right">
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-10 menu menu-sm p-2 shadow-lg bg-base-100 rounded-box w-52">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.crm.leads.show', [$business->slug, $lead->hashid]) }}">
|
||||
<span class="icon-[heroicons--eye] size-4"></span>
|
||||
|
||||
@@ -127,12 +127,12 @@
|
||||
<span class="text-sm text-base-content/40">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2 text-right">
|
||||
<td class="py-2 align-middle text-right">
|
||||
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow-sm bg-base-100 rounded-lg w-44 z-[100] border border-base-300 {{ $loop->last ? 'mb-1' : '' }}">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100] {{ $loop->last ? 'mb-1' : '' }}">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.crm.quotes.show', [$business, $quote]) }}">
|
||||
<span class="icon-[heroicons--eye] size-4"></span>
|
||||
|
||||
@@ -67,12 +67,10 @@
|
||||
|
||||
{{-- Actions dropdown --}}
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
@if($thread->status !== 'closed')
|
||||
<li>
|
||||
<form method="POST" action="{{ route('seller.business.crm.threads.close', [$business, $thread]) }}">
|
||||
|
||||
@@ -149,10 +149,10 @@
|
||||
{{-- Actions --}}
|
||||
<td>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-xs">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-300">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li><a><span class="icon-[heroicons--eye] size-4"></span> View Product</a></li>
|
||||
<li><a><span class="icon-[heroicons--pencil] size-4"></span> Edit Product</a></li>
|
||||
<li><a><span class="icon-[heroicons--cube] size-4"></span> Adjust Stock</a></li>
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
@section('content')
|
||||
<div class="container-fluid py-6" x-data="invoiceCreator()">
|
||||
<!-- Page Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-base-content flex items-center gap-2">
|
||||
<span class="icon-[heroicons--document-plus] size-8"></span>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-base-content flex items-center gap-2">
|
||||
<span class="icon-[heroicons--document-plus] size-6 sm:size-8"></span>
|
||||
Create Manual Invoice
|
||||
</h1>
|
||||
<p class="text-base-content/60 mt-1">Create a new invoice for an existing customer</p>
|
||||
<p class="text-base-content/60 mt-1 text-sm">Create a new invoice for an existing customer</p>
|
||||
</div>
|
||||
<a href="{{ route('seller.business.invoices.index', $business->slug) }}" class="btn btn-ghost">
|
||||
<span class="icon-[heroicons--arrow-left] size-5"></span>
|
||||
Back to Invoices
|
||||
<a href="{{ route('seller.business.invoices.index', $business->slug) }}" class="btn btn-ghost btn-sm self-start sm:self-auto">
|
||||
<span class="icon-[heroicons--arrow-left] size-4"></span>
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -225,12 +225,12 @@
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-10"></th>
|
||||
<th class="w-10 hidden sm:table-cell"></th>
|
||||
<th>Product <span class="text-error">*</span></th>
|
||||
<th>Quantity <span class="text-error">*</span></th>
|
||||
<th>Wholesale Unit Price <span class="text-error">*</span></th>
|
||||
<th>Discount</th>
|
||||
<th>Notes</th>
|
||||
<th>Qty <span class="text-error">*</span></th>
|
||||
<th class="hidden md:table-cell">Price <span class="text-error">*</span></th>
|
||||
<th class="hidden lg:table-cell">Discount</th>
|
||||
<th class="hidden xl:table-cell">Notes</th>
|
||||
<th>Total</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -239,7 +239,7 @@
|
||||
<template x-for="(item, index) in lineItems" :key="index">
|
||||
<tr>
|
||||
<!-- Product Image -->
|
||||
<td>
|
||||
<td class="hidden sm:table-cell">
|
||||
<div class="avatar" x-show="item.image_url">
|
||||
<div class="w-10 h-10 rounded">
|
||||
<img :src="item.image_url" :alt="item.product_name" />
|
||||
@@ -311,7 +311,7 @@
|
||||
</td>
|
||||
|
||||
<!-- Unit Price -->
|
||||
<td class="w-32">
|
||||
<td class="w-32 hidden md:table-cell">
|
||||
<div class="join w-full">
|
||||
<span class="join-item btn btn-sm btn-disabled">$</span>
|
||||
<input
|
||||
@@ -327,7 +327,7 @@
|
||||
</td>
|
||||
|
||||
<!-- Discount -->
|
||||
<td class="w-40">
|
||||
<td class="w-40 hidden lg:table-cell">
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
type="number"
|
||||
@@ -350,7 +350,7 @@
|
||||
</td>
|
||||
|
||||
<!-- Notes -->
|
||||
<td class="w-48">
|
||||
<td class="w-48 hidden xl:table-cell">
|
||||
<textarea
|
||||
:name="'items[' + index + '][notes]'"
|
||||
class="textarea textarea-bordered textarea-sm w-full h-16 text-xs"
|
||||
@@ -384,43 +384,46 @@
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="6" class="text-right font-semibold">Subtotal:</td>
|
||||
<td colspan="2" class="text-right font-semibold sm:hidden">Subtotal:</td>
|
||||
<td colspan="6" class="text-right font-semibold hidden sm:table-cell">Subtotal:</td>
|
||||
<td colspan="2">
|
||||
<span class="font-semibold" x-text="'$' + calculateSubtotal().toFixed(2)"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr x-show="calculateTotalLineDiscounts() > 0">
|
||||
<td colspan="6" class="text-right font-semibold text-success">Line Discounts:</td>
|
||||
<td colspan="2" class="text-right font-semibold text-success sm:hidden">Discounts:</td>
|
||||
<td colspan="6" class="text-right font-semibold text-success hidden sm:table-cell">Line Discounts:</td>
|
||||
<td colspan="2">
|
||||
<span class="text-success" x-text="'-$' + calculateTotalLineDiscounts().toFixed(2)"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr x-show="calculateTotalLineDiscounts() > 0">
|
||||
<tr x-show="calculateTotalLineDiscounts() > 0" class="hidden md:table-row">
|
||||
<td colspan="6" class="text-right font-semibold">Subtotal After Line Discounts:</td>
|
||||
<td colspan="2">
|
||||
<span class="font-semibold" x-text="'$' + calculateSubtotalAfterLineDiscounts().toFixed(2)"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr x-show="calculateInvoiceDiscount() > 0">
|
||||
<tr x-show="calculateInvoiceDiscount() > 0" class="hidden md:table-row">
|
||||
<td colspan="6" class="text-right font-semibold text-success">Invoice Discount:</td>
|
||||
<td colspan="2">
|
||||
<span class="text-success" x-text="'-$' + calculateInvoiceDiscount().toFixed(2)"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr x-show="calculateInvoiceDiscount() > 0 || calculateTotalLineDiscounts() > 0">
|
||||
<tr x-show="calculateInvoiceDiscount() > 0 || calculateTotalLineDiscounts() > 0" class="hidden md:table-row">
|
||||
<td colspan="6" class="text-right font-semibold">Subtotal After All Discounts:</td>
|
||||
<td colspan="2">
|
||||
<span class="font-semibold" x-text="'$' + calculateSubtotalAfterAllDiscounts().toFixed(2)"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr class="hidden md:table-row">
|
||||
<td colspan="6" class="text-right font-semibold">Tax:</td>
|
||||
<td colspan="2">
|
||||
<span class="text-base-content/60" x-text="'$' + calculateTax().toFixed(2)"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="font-bold text-lg">
|
||||
<td colspan="6" class="text-right">Grand Total:</td>
|
||||
<td colspan="2" class="text-right sm:hidden">Total:</td>
|
||||
<td colspan="6" class="text-right hidden sm:table-cell">Grand Total:</td>
|
||||
<td colspan="2">
|
||||
<span class="text-primary" x-text="'$' + calculateTotal().toFixed(2)"></span>
|
||||
</td>
|
||||
@@ -496,15 +499,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<a href="{{ route('seller.business.invoices.index', $business->slug) }}" class="btn btn-ghost">
|
||||
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
||||
<a href="{{ route('seller.business.invoices.index', $business->slug) }}" class="btn btn-ghost btn-sm sm:btn-md">
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-success"
|
||||
class="btn btn-success btn-sm sm:btn-md"
|
||||
:disabled="lineItems.length === 0 || !selectedCustomer || !paymentTerms || !dueDate">
|
||||
<span class="icon-[heroicons--document-plus] size-5"></span>
|
||||
<span class="icon-[heroicons--document-plus] size-4 sm:size-5"></span>
|
||||
Create Invoice
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 space-y-4">
|
||||
{{-- Page Header --}}
|
||||
<header class="flex items-center justify-between">
|
||||
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Invoices</h1>
|
||||
<p class="text-sm text-base-content/60">Manage and track your invoices.</p>
|
||||
</div>
|
||||
<a href="{{ route('seller.business.invoices.create', $business->slug) }}" class="btn btn-primary btn-sm gap-1">
|
||||
<a href="{{ route('seller.business.invoices.create', $business->slug) }}" class="btn btn-secondary btn-sm gap-1 self-start sm:self-auto">
|
||||
<span class="icon-[heroicons--plus] size-4"></span>
|
||||
Create Invoice
|
||||
</a>
|
||||
@@ -18,6 +18,7 @@
|
||||
<x-ui.filter-bar
|
||||
form-action="{{ route('seller.business.invoices.index', $business->slug) }}"
|
||||
search-placeholder="Search invoices..."
|
||||
search-name="search"
|
||||
:search-value="request('search')"
|
||||
clear-url="{{ route('seller.business.invoices.index', $business->slug) }}"
|
||||
>
|
||||
@@ -42,7 +43,7 @@
|
||||
<p class="text-xs text-base-content/50 mb-4 max-w-md">
|
||||
Create your first invoice to bill customers and track payment status.
|
||||
</p>
|
||||
<a href="{{ route('seller.business.invoices.create', $business->slug) }}" class="btn btn-primary btn-sm">
|
||||
<a href="{{ route('seller.business.invoices.create', $business->slug) }}" class="btn btn-secondary btn-sm">
|
||||
Create Invoice
|
||||
</a>
|
||||
</div>
|
||||
@@ -54,9 +55,9 @@
|
||||
<thead class="bg-base-200/30">
|
||||
<tr>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Invoice</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Customer</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Invoice Date</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Due Date</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3 hidden sm:table-cell">Customer</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3 hidden md:table-cell">Invoice Date</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3 hidden lg:table-cell">Due Date</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap text-right py-3">Amount</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap py-3">Status</th>
|
||||
<th class="text-xs font-medium text-base-content/50 whitespace-nowrap text-right py-3"></th>
|
||||
@@ -70,7 +71,7 @@
|
||||
{{ $invoice->invoice_number }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="py-2 align-middle">
|
||||
<td class="py-2 align-middle hidden sm:table-cell">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-base-200/50 text-base-content/50 flex items-center justify-center text-sm font-medium">
|
||||
{{ strtoupper(mb_substr($invoice->business->name ?? 'C', 0, 1)) }}
|
||||
@@ -89,10 +90,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 align-middle whitespace-nowrap text-sm">
|
||||
<td class="py-2 align-middle whitespace-nowrap text-sm hidden md:table-cell">
|
||||
{{ $invoice->invoice_date->format('M j, Y') }}
|
||||
</td>
|
||||
<td class="py-2 align-middle whitespace-nowrap text-sm">
|
||||
<td class="py-2 align-middle whitespace-nowrap text-sm hidden lg:table-cell">
|
||||
@if($invoice->due_date)
|
||||
<span class="{{ $invoice->isOverdue() ? 'text-error font-medium' : '' }}">
|
||||
{{ $invoice->due_date->format('M j, Y') }}
|
||||
@@ -106,11 +107,11 @@
|
||||
</td>
|
||||
<td class="py-2 align-middle">
|
||||
@if($invoice->payment_status === 'paid')
|
||||
<span class="badge badge-success badge-sm">Paid</span>
|
||||
<span class="badge badge-ghost badge-sm">Paid</span>
|
||||
@elseif($invoice->isOverdue())
|
||||
<span class="badge badge-error badge-sm">Overdue</span>
|
||||
<span class="badge badge-ghost badge-sm">Overdue</span>
|
||||
@else
|
||||
<span class="badge badge-neutral badge-sm">Unpaid</span>
|
||||
<span class="badge badge-ghost badge-sm">Unpaid</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2 align-middle text-right">
|
||||
|
||||
@@ -117,12 +117,12 @@
|
||||
<td class="text-right">
|
||||
@if($period->isOpen() && $canClosePeriods)
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm">
|
||||
<span class="icon-[heroicons--lock-closed] size-4"></span>
|
||||
Close
|
||||
<span class="icon-[heroicons--chevron-down] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-48">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li>
|
||||
<button type="button" onclick="openCloseModal({{ $period->id }}, '{{ $period->period_label }}', 'soft_closed')">
|
||||
<span class="icon-[heroicons--lock-open] size-4 text-warning"></span>
|
||||
|
||||
@@ -133,10 +133,10 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-5"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52 z-10">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li>
|
||||
<button type="button" @click="openEditModal({{ json_encode($vendor) }})">
|
||||
<span class="icon-[heroicons--pencil] size-4"></span>
|
||||
|
||||
@@ -107,12 +107,12 @@
|
||||
<span class="badge badge-ghost badge-sm">Inactive</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<td class="py-2 align-middle text-right">
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-48">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.management.bank-accounts.show', [$business, $account]) }}">
|
||||
<span class="icon-[heroicons--eye] size-4"></span>
|
||||
|
||||
@@ -135,10 +135,10 @@
|
||||
<td class="text-sm text-base-content/60">{{ $tx->category_display }}</td>
|
||||
<td>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-xs">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li>
|
||||
<button @click="openMatchModal({{ $tx->id }})">
|
||||
<span class="icon-[heroicons--link] size-4"></span>
|
||||
|
||||
@@ -80,10 +80,10 @@
|
||||
Edit
|
||||
</a>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-xs">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
@if(!$budget->approved_at)
|
||||
<li>
|
||||
<form action="{{ route('seller.business.management.budgets.approve', [$business, $budget]) }}" method="POST">
|
||||
|
||||
@@ -116,10 +116,10 @@
|
||||
View
|
||||
</a>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-xs">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.management.recurring.edit', [$business, $schedule]) }}">Edit</a>
|
||||
</li>
|
||||
|
||||
@@ -198,12 +198,10 @@
|
||||
|
||||
{{-- Actions Dropdown --}}
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"></path>
|
||||
</svg>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52 z-10">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li><a href="{{ route('seller.marketing.broadcasts.show', $broadcast) }}">View Details</a></li>
|
||||
<li><a href="{{ route('seller.marketing.broadcasts.analytics', $broadcast) }}">Analytics</a></li>
|
||||
|
||||
|
||||
@@ -136,12 +136,12 @@
|
||||
<span class="text-base-content/50 text-sm">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2 text-right">
|
||||
<td class="py-2 align-middle text-right">
|
||||
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-2xl w-44 z-[100] border border-base-200 {{ $loop->last ? 'mb-1' : '' }}">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100] {{ $loop->last ? 'mb-1' : '' }}">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.marketing.campaigns.show', [$business->slug, $campaign]) }}">
|
||||
<span class="icon-[heroicons--eye] size-4"></span>
|
||||
|
||||
@@ -82,10 +82,10 @@
|
||||
<span class="badge badge-neutral badge-sm">Inactive</span>
|
||||
@endif
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-xs btn-square">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-2xl w-40 z-[100] border border-base-200">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.marketing.channels.edit', [$business->slug, $channel]) }}">
|
||||
<span class="icon-[heroicons--pencil] size-4"></span>
|
||||
|
||||
@@ -81,11 +81,11 @@
|
||||
@change="selected = $event.target.checked ? {{ $contacts->pluck('id') }} : []" />
|
||||
</th>
|
||||
<th>Contact</th>
|
||||
<th>Type</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Subscribed</th>
|
||||
<th>Tags</th>
|
||||
<th class="hidden sm:table-cell">Type</th>
|
||||
<th class="hidden md:table-cell">Email</th>
|
||||
<th class="hidden lg:table-cell">Phone</th>
|
||||
<th class="hidden md:table-cell">Subscribed</th>
|
||||
<th class="hidden lg:table-cell">Tags</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -100,14 +100,14 @@
|
||||
<div class="font-medium">{{ $contact->display_name }}</div>
|
||||
<div class="text-xs text-base-content/60">{{ $contact->source }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<td class="hidden sm:table-cell">
|
||||
<span class="badge badge-sm {{ $contact->type === 'buyer' ? 'badge-primary' : ($contact->type === 'consumer' ? 'badge-secondary' : 'badge-ghost') }}">
|
||||
{{ $contact->getTypeLabel() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-sm">{{ $contact->email ?? '-' }}</td>
|
||||
<td class="text-sm">{{ $contact->phone ?? '-' }}</td>
|
||||
<td>
|
||||
<td class="text-sm hidden md:table-cell">{{ $contact->email ?? '-' }}</td>
|
||||
<td class="text-sm hidden lg:table-cell">{{ $contact->phone ?? '-' }}</td>
|
||||
<td class="hidden md:table-cell">
|
||||
<div class="flex gap-1">
|
||||
@if($contact->is_subscribed_email && $contact->email)
|
||||
<span class="badge badge-xs badge-success">Email</span>
|
||||
@@ -117,7 +117,7 @@
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td class="hidden lg:table-cell">
|
||||
@if($contact->tags)
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@foreach(array_slice($contact->tags, 0, 3) as $tag)
|
||||
@@ -131,14 +131,12 @@
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<td class="py-2 align-middle text-right">
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-xs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
</svg>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li><a href="{{ route('seller.business.marketing.contacts.edit', [$business, $contact]) }}">Edit</a></li>
|
||||
<li>
|
||||
<form method="POST" action="{{ route('seller.business.marketing.contacts.destroy', [$business, $contact]) }}" onsubmit="return confirm('Delete this contact?')">
|
||||
|
||||
@@ -29,12 +29,10 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
</svg>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li><a href="{{ route('seller.business.marketing.lists.show', [$business, $list]) }}">View Contacts</a></li>
|
||||
<li><a href="{{ route('seller.business.marketing.lists.edit', [$business, $list]) }}">Edit</a></li>
|
||||
<li>
|
||||
|
||||
@@ -131,12 +131,12 @@
|
||||
<span class="text-base-content/40">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-2 text-right">
|
||||
<td class="py-2 align-middle text-right">
|
||||
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-2xl w-40 z-[100] border border-base-200 {{ $loop->last ? 'mb-1' : '' }}">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100] {{ $loop->last ? 'mb-1' : '' }}">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.marketing.promos.show', [$business->slug, $promo]) }}">
|
||||
<span class="icon-[heroicons--eye] size-4"></span>
|
||||
|
||||
@@ -120,12 +120,12 @@
|
||||
<td class="py-2 text-sm whitespace-nowrap">
|
||||
{{ $template->updated_at->diffForHumans() }}
|
||||
</td>
|
||||
<td class="py-2 text-right">
|
||||
<td class="py-2 align-middle text-right">
|
||||
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-2xl w-40 z-[100] border border-base-200 {{ $loop->last ? 'mb-1' : '' }}">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100] {{ $loop->last ? 'mb-1' : '' }}">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.marketing.templates.show', [$business->slug, $template]) }}">
|
||||
<span class="icon-[heroicons--eye] size-4"></span>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto px-6 py-4 space-y-4">
|
||||
{{-- Page Header --}}
|
||||
<header class="flex items-center justify-between">
|
||||
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Orders</h1>
|
||||
<p class="text-sm text-base-content/60">Manage and fulfill customer orders</p>
|
||||
@@ -51,10 +51,10 @@
|
||||
<tr>
|
||||
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap">Order</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap">Customer</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap">Date</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap text-center">Items</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap hidden md:table-cell">Date</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap text-center hidden lg:table-cell">Items</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap text-right">Total</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap text-center">Fulfillment</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap text-center hidden lg:table-cell">Fulfillment</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-3 whitespace-nowrap">Status</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-3"></th>
|
||||
</tr>
|
||||
@@ -83,36 +83,36 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 whitespace-nowrap">
|
||||
<td class="py-3 whitespace-nowrap hidden md:table-cell">
|
||||
<div class="text-sm">{{ $order->created_at->format('M j, Y') }}</div>
|
||||
<div class="text-[11px] text-base-content/60">{{ $order->created_at->format('g:i A') }}</div>
|
||||
</td>
|
||||
<td class="py-3 text-center">
|
||||
<td class="py-3 text-center hidden lg:table-cell">
|
||||
<span class="text-sm text-base-content/80">{{ $order->items->count() }}</span>
|
||||
</td>
|
||||
<td class="py-3 text-right">
|
||||
<span class="text-sm font-semibold">${{ number_format($order->total, 2) }}</span>
|
||||
</td>
|
||||
<td class="py-3 text-center">
|
||||
<td class="py-3 text-center hidden lg:table-cell">
|
||||
@if($order->workorder_status == 100)
|
||||
<span class="badge badge-neutral badge-sm whitespace-nowrap">Complete</span>
|
||||
<span class="badge badge-success badge-sm whitespace-nowrap">Complete</span>
|
||||
@elseif($order->workorder_status == 0)
|
||||
<span class="badge badge-neutral badge-sm whitespace-nowrap">Not Started</span>
|
||||
<span class="badge badge-success badge-sm whitespace-nowrap">Not Started</span>
|
||||
@elseif($order->workorder_status > 0 && $order->workorder_status < 100)
|
||||
<span class="badge badge-neutral badge-sm whitespace-nowrap">{{ number_format($order->workorder_status, 0) }}%</span>
|
||||
<span class="badge badge-success badge-sm whitespace-nowrap">{{ number_format($order->workorder_status, 0) }}%</span>
|
||||
@else
|
||||
<span class="badge badge-neutral badge-sm whitespace-nowrap">Not Started</span>
|
||||
<span class="badge badge-success badge-sm whitespace-nowrap">Not Started</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-3">
|
||||
@include('buyer.orders.partials.status-badge', ['status' => $order->status])
|
||||
</td>
|
||||
<td class="py-3 text-right">
|
||||
<td class="py-2 align-middle text-right">
|
||||
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
|
||||
<button tabindex="0" role="button" class="btn btn-ghost btn-sm btn-square">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-lg shadow-sm w-48 p-2 z-[100] border border-base-300 {{ $loop->last ? 'mb-1' : '' }}">
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100] {{ $loop->last ? 'mb-1' : '' }}">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.orders.show', [$business->slug, $order]) }}">
|
||||
<span class="icon-[heroicons--eye] size-4"></span>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto px-6 py-4 space-y-4"
|
||||
x-data="{
|
||||
// State
|
||||
search: '',
|
||||
brandFilter: 'all',
|
||||
statusFilter: 'all',
|
||||
// State - initialize from URL params to sync with server-side filtering
|
||||
search: '{{ request('search', '') }}',
|
||||
brandFilter: '{{ request('brand_id', 'all') ?: 'all' }}',
|
||||
statusFilter: '{{ request('status', 'all') ?: 'all' }}',
|
||||
visibilityFilter: 'all',
|
||||
focusFilter: 'all',
|
||||
viewMode: 'table',
|
||||
@@ -88,7 +88,7 @@
|
||||
}">
|
||||
|
||||
{{-- Page Header --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-base-content">Products</h1>
|
||||
<p class="text-sm text-base-content/50">Manage your product catalog</p>
|
||||
@@ -146,38 +146,38 @@
|
||||
</x-ui.filter-bar>
|
||||
|
||||
{{-- Filter Chips Row --}}
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<div class="flex items-center gap-1.5 overflow-x-auto pb-1">
|
||||
<button @click="setFocusFilter('all')"
|
||||
:class="focusFilter === 'all' ? 'bg-base-200 text-base-content' : 'text-base-content/50 hover:text-base-content'"
|
||||
class="btn btn-xs btn-ghost rounded-md px-2.5">
|
||||
class="btn btn-xs btn-ghost rounded-md px-2.5 whitespace-nowrap flex-shrink-0">
|
||||
All
|
||||
</button>
|
||||
<button @click="setFocusFilter('low-stock')"
|
||||
:class="focusFilter === 'low-stock' ? 'bg-base-200 text-base-content' : 'text-base-content/50 hover:text-base-content'"
|
||||
class="btn btn-xs btn-ghost rounded-md px-2.5">
|
||||
class="btn btn-xs btn-ghost rounded-md px-2.5 whitespace-nowrap flex-shrink-0">
|
||||
Low Stock
|
||||
</button>
|
||||
<button @click="setFocusFilter('out-of-stock')"
|
||||
:class="focusFilter === 'out-of-stock' ? 'bg-base-200 text-base-content' : 'text-base-content/50 hover:text-base-content'"
|
||||
class="btn btn-xs btn-ghost rounded-md px-2.5">
|
||||
class="btn btn-xs btn-ghost rounded-md px-2.5 whitespace-nowrap flex-shrink-0">
|
||||
Out of Stock
|
||||
</button>
|
||||
<button @click="setFocusFilter('missing-images')"
|
||||
:class="focusFilter === 'missing-images' ? 'bg-base-200 text-base-content' : 'text-base-content/50 hover:text-base-content'"
|
||||
class="btn btn-xs btn-ghost rounded-md px-2.5">
|
||||
class="btn btn-xs btn-ghost rounded-md px-2.5 whitespace-nowrap flex-shrink-0 hidden sm:inline-flex">
|
||||
Missing Images
|
||||
</button>
|
||||
<button @click="setFocusFilter('not-listed')"
|
||||
:class="focusFilter === 'not-listed' ? 'bg-base-200 text-base-content' : 'text-base-content/50 hover:text-base-content'"
|
||||
class="btn btn-xs btn-ghost rounded-md px-2.5">
|
||||
class="btn btn-xs btn-ghost rounded-md px-2.5 whitespace-nowrap flex-shrink-0 hidden sm:inline-flex">
|
||||
Not Listed
|
||||
</button>
|
||||
<button @click="setFocusFilter('drafts')"
|
||||
:class="focusFilter === 'drafts' ? 'bg-base-200 text-base-content' : 'text-base-content/50 hover:text-base-content'"
|
||||
class="btn btn-xs btn-ghost rounded-md px-2.5">
|
||||
class="btn btn-xs btn-ghost rounded-md px-2.5 whitespace-nowrap flex-shrink-0">
|
||||
Drafts
|
||||
</button>
|
||||
<span class="text-xs text-base-content/40 ml-2">
|
||||
<span class="text-xs text-base-content/40 ml-2 whitespace-nowrap flex-shrink-0">
|
||||
<span x-text="filteredListings.length"></span>/<span x-text="pagination.total"></span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -189,11 +189,11 @@
|
||||
<thead class="bg-base-200/30">
|
||||
<tr>
|
||||
<th class="text-xs font-medium text-base-content/50 py-2.5">Product</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-2.5">Brand</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-2.5 hidden md:table-cell">Brand</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-2.5 text-right">Price</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-2.5 text-right">Stock</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-2.5 text-right">Conv.</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-2.5">Status</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-2.5 text-right hidden lg:table-cell">Stock</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-2.5 text-right hidden xl:table-cell">Conv.</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-2.5 hidden sm:table-cell">Status</th>
|
||||
<th class="text-xs font-medium text-base-content/50 py-2.5"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -227,28 +227,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2.5"><span class="text-sm text-base-content/70" x-text="listing.brand"></span></td>
|
||||
<td class="py-2.5 hidden md:table-cell"><span class="text-sm text-base-content/70" x-text="listing.brand"></span></td>
|
||||
<td class="py-2.5 text-right font-medium text-sm">$<span x-text="listing.price.toFixed(2)"></span></td>
|
||||
<td class="py-2.5 text-right">
|
||||
<td class="py-2.5 text-right hidden lg:table-cell">
|
||||
<span class="text-sm" x-text="listing.stock"></span>
|
||||
<span x-show="listing.stock === 0" class="text-xs ml-1 text-error">(Out)</span>
|
||||
</td>
|
||||
<td class="py-2.5 text-right">
|
||||
<td class="py-2.5 text-right hidden xl:table-cell">
|
||||
<span class="text-sm text-base-content/70" x-text="getConversion(listing) + '%'"></span>
|
||||
</td>
|
||||
<td class="py-2.5">
|
||||
<td class="py-2.5 hidden sm:table-cell">
|
||||
<span x-show="listing.status === 'active'" class="badge badge-neutral badge-sm">Active</span>
|
||||
<span x-show="listing.status === 'paused'" class="badge badge-neutral badge-sm">Paused</span>
|
||||
<span x-show="listing.status === 'draft'" class="badge badge-neutral badge-sm">Draft</span>
|
||||
</td>
|
||||
<td class="py-2.5" @click.stop>
|
||||
<td class="py-2 align-middle text-right" @click.stop>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-xs">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4 text-base-content/40"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-1.5 bg-base-100 rounded-lg w-40 border border-base-300">
|
||||
<li><a @click="openInspector(listing)" class="text-sm py-1.5"><span class="icon-[heroicons--information-circle] size-4"></span> Quick View</a></li>
|
||||
<li><a :href="listing.edit_url" class="text-sm py-1.5"><span class="icon-[heroicons--pencil] size-4"></span> Edit</a></li>
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li>
|
||||
<a @click="openInspector(listing)">
|
||||
<span class="icon-[heroicons--information-circle] size-4"></span>
|
||||
Quick View
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a :href="listing.edit_url">
|
||||
<span class="icon-[heroicons--pencil] size-4"></span>
|
||||
Edit
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -200,10 +200,10 @@
|
||||
|
||||
{{-- Standard actions dropdown --}}
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[lucide--more-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[1] w-48 p-2 shadow-lg border border-base-200">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li>
|
||||
<a href="{{ route('seller.business.products.edit', [$business->slug, $product->hashid]) }}">
|
||||
<span class="icon-[lucide--eye] size-4"></span>
|
||||
|
||||
@@ -501,14 +501,24 @@
|
||||
<span x-show="listing.status === 'paused'" class="badge badge-warning badge-xs">Paused</span>
|
||||
<span x-show="listing.status === 'draft'" class="badge badge-ghost badge-xs">Draft</span>
|
||||
</td>
|
||||
<td @click.stop>
|
||||
<td class="py-2 align-middle text-right" @click.stop>
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-xs">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-300">
|
||||
<li><a @click="openInspector(listing)"><span class="icon-[heroicons--information-circle] size-4"></span> Quick View</a></li>
|
||||
<li><a :href="listing.edit_url"><span class="icon-[heroicons--pencil] size-4"></span> Edit Product</a></li>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li>
|
||||
<a @click="openInspector(listing)">
|
||||
<span class="icon-[heroicons--information-circle] size-4"></span>
|
||||
Quick View
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a :href="listing.edit_url">
|
||||
<span class="icon-[heroicons--pencil] size-4"></span>
|
||||
Edit Product
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -130,12 +130,12 @@
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-success toggle-sm" {{ $webhook['enabled'] ? 'checked' : '' }} onchange="toggleWebhook({{ $webhook['id'] }})" />
|
||||
</td>
|
||||
<td>
|
||||
<td class="py-2 align-middle text-right">
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<span class="icon-[heroicons--ellipsis-vertical] size-4"></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu menu-sm p-2 shadow-lg bg-base-100 rounded-box w-48 border border-base-300">
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-sm bg-base-100 rounded-lg w-48 border border-base-300 z-[100]">
|
||||
<li><a class="gap-2"><span class="icon-[heroicons--pencil] size-4"></span> Edit</a></li>
|
||||
<li><a class="gap-2"><span class="icon-[heroicons--paper-airplane] size-4"></span> Test Webhook</a></li>
|
||||
<li><a class="gap-2"><span class="icon-[heroicons--arrow-path] size-4"></span> View History</a></li>
|
||||
|
||||
@@ -444,6 +444,9 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
|
||||
Route::get('/{brand}/dashboard', [\App\Http\Controllers\Seller\BrandController::class, 'dashboard'])->name('dashboard');
|
||||
Route::get('/{brand}/dashboard/tab-data', [\App\Http\Controllers\Seller\BrandController::class, 'tabData'])->name('dashboard.tab-data');
|
||||
Route::get('/{brand}/stats', [\App\Http\Controllers\Seller\BrandController::class, 'stats'])->name('stats');
|
||||
Route::get('/{brand}/analysis', [\App\Http\Controllers\Seller\BrandController::class, 'analysis'])->name('analysis');
|
||||
Route::post('/{brand}/analysis/refresh', [\App\Http\Controllers\Seller\BrandController::class, 'analysisRefresh'])->name('analysis.refresh');
|
||||
Route::get('/{brand}/store-playbook/{store}', [\App\Http\Controllers\Seller\BrandController::class, 'storePlaybook'])->name('analysis.storePlaybook');
|
||||
Route::get('/{brand}/profile', [\App\Http\Controllers\Seller\BrandController::class, 'profile'])->name('profile');
|
||||
Route::get('/{brand}/edit', [\App\Http\Controllers\Seller\BrandController::class, 'edit'])->name('edit');
|
||||
Route::get('/{brand}/edit-nexus', [\App\Http\Controllers\Seller\BrandController::class, 'editNexus'])->name('edit-nexus');
|
||||
|
||||
@@ -29,13 +29,13 @@ export default {
|
||||
themes: [
|
||||
{
|
||||
cannabrands: {
|
||||
"primary": "#4B6FA4",
|
||||
"primary": "#4E8D71",
|
||||
"primary-content": "#ffffff",
|
||||
|
||||
"secondary": "#6B7280",
|
||||
"secondary": "#4B6FA4",
|
||||
"secondary-content": "#ffffff",
|
||||
|
||||
"accent": "#4B6FA4",
|
||||
"accent": "#4E8D71",
|
||||
"accent-content": "#ffffff",
|
||||
|
||||
"success": "#4E8D71",
|
||||
|
||||
@@ -171,6 +171,14 @@ it('brand manager scoped to single brand only sees their brand', function () {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('product index respects brand context filter', function () {
|
||||
$this->withoutExceptionHandling();
|
||||
|
||||
\DB::listen(function ($query) {
|
||||
if (str_contains($query->sql, 'ERROR')) {
|
||||
dump($query->sql);
|
||||
}
|
||||
});
|
||||
|
||||
// Create products for each brand
|
||||
$product1 = Product::factory()->create([
|
||||
'brand_id' => $this->brand1->id,
|
||||
|
||||
Reference in New Issue
Block a user