Issue #161: Quote submission error - Added missing tax_rate column to crm_quotes table - Column was referenced in model but never created in migration Issue #200: Batch 404 error after save - Batches missing hashids caused 404 (hashid-based routing) - Migration backfills hashids for all existing batches Issue #203: Product image upload error - Fixed route name: images.product -> image.product (singular) Additional improvements: - Quote create page prefill from CRM account dashboard - Product hashid backfill migration
298 lines
11 KiB
PHP
298 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Seller;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Business;
|
|
use App\Models\Contact;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
|
|
/**
|
|
* Seller Search Controller
|
|
*
|
|
* Provides search endpoints for the search-select component.
|
|
* All endpoints return JSON in format: [{value: id, label: name}, ...]
|
|
*
|
|
* These are AJAX endpoints used by the search-select Alpine component.
|
|
*/
|
|
class SearchController extends Controller
|
|
{
|
|
/**
|
|
* Search customers (buyer businesses) for the current seller.
|
|
*
|
|
* GET /s/{business}/search/customers?q=...
|
|
*/
|
|
public function customers(Request $request, Business $business): JsonResponse
|
|
{
|
|
$query = $request->input('q', '');
|
|
|
|
// Search businesses that have placed orders with this seller
|
|
$customers = Business::query()
|
|
->whereHas('orders.items.product.brand', function ($q) use ($business) {
|
|
$q->where('business_id', $business->id);
|
|
})
|
|
->when($query, function ($q) use ($query) {
|
|
$q->where(function ($q2) use ($query) {
|
|
$q2->where('name', 'ILIKE', "%{$query}%")
|
|
->orWhere('business_email', 'ILIKE', "%{$query}%");
|
|
});
|
|
})
|
|
->orderBy('name')
|
|
->limit(25)
|
|
->get(['id', 'name', 'business_email']);
|
|
|
|
return response()->json(
|
|
$customers->map(fn ($c) => [
|
|
'value' => $c->id,
|
|
'label' => $c->name,
|
|
])
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Search contacts for a specific customer or the seller's own contacts.
|
|
*
|
|
* GET /s/{business}/search/contacts?q=...&customer_id=...&location_id=...
|
|
*
|
|
* If location_id is provided, returns only contacts assigned to that location
|
|
* via the location_contact pivot table.
|
|
*/
|
|
public function contacts(Request $request, Business $business): JsonResponse
|
|
{
|
|
$query = $request->input('q', '');
|
|
$customerId = $request->input('customer_id');
|
|
$locationId = $request->input('location_id');
|
|
|
|
$contactsQuery = Contact::query()
|
|
->where('is_active', true);
|
|
|
|
// If customer_id is provided, search contacts for that customer
|
|
if ($customerId) {
|
|
$contactsQuery->where('business_id', $customerId);
|
|
} else {
|
|
// Otherwise, search contacts for the seller's business
|
|
$contactsQuery->where('business_id', $business->id);
|
|
}
|
|
|
|
// If location_id is provided, filter to contacts assigned to that location
|
|
if ($locationId) {
|
|
$contactsQuery->whereHas('locations', function ($q) use ($locationId) {
|
|
$q->where('locations.id', $locationId);
|
|
});
|
|
}
|
|
|
|
$contacts = $contactsQuery
|
|
->when($query, function ($q) use ($query) {
|
|
$q->where(function ($q2) use ($query) {
|
|
$q2->where('first_name', 'ILIKE', "%{$query}%")
|
|
->orWhere('last_name', 'ILIKE', "%{$query}%")
|
|
->orWhere('email', 'ILIKE', "%{$query}%")
|
|
->orWhere('title', 'ILIKE', "%{$query}%");
|
|
});
|
|
})
|
|
->orderBy('first_name')
|
|
->orderBy('last_name')
|
|
->limit(25)
|
|
->get(['id', 'first_name', 'last_name', 'email', 'title']);
|
|
|
|
// If filtering by location, include pivot data for is_primary
|
|
if ($locationId) {
|
|
// Reload contacts with pivot data
|
|
$contactIds = $contacts->pluck('id')->toArray();
|
|
$pivotData = \DB::table('location_contact')
|
|
->whereIn('contact_id', $contactIds)
|
|
->where('location_id', $locationId)
|
|
->get()
|
|
->keyBy('contact_id');
|
|
|
|
return response()->json(
|
|
$contacts->map(fn ($c) => [
|
|
'value' => $c->id,
|
|
'label' => $c->getFullName().($c->email ? " ({$c->email})" : ''),
|
|
'is_primary' => $pivotData[$c->id]->is_primary ?? false,
|
|
'role' => $pivotData[$c->id]->role ?? null,
|
|
])
|
|
);
|
|
}
|
|
|
|
return response()->json(
|
|
$contacts->map(fn ($c) => [
|
|
'value' => $c->id,
|
|
'label' => $c->getFullName().($c->email ? " ({$c->email})" : ''),
|
|
])
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Search accounts (CRM accounts / other businesses).
|
|
*
|
|
* GET /s/{business}/search/accounts?q=...
|
|
*/
|
|
public function accounts(Request $request, Business $business): JsonResponse
|
|
{
|
|
$query = $request->input('q', '');
|
|
|
|
// Search all businesses except the current one
|
|
$accounts = Business::query()
|
|
->where('id', '!=', $business->id)
|
|
->when($query, function ($q) use ($query) {
|
|
$q->where(function ($q2) use ($query) {
|
|
$q2->where('name', 'ILIKE', "%{$query}%")
|
|
->orWhere('business_email', 'ILIKE', "%{$query}%");
|
|
});
|
|
})
|
|
->orderBy('name')
|
|
->limit(25)
|
|
->get(['id', 'name', 'business_email', 'city', 'state']);
|
|
|
|
return response()->json(
|
|
$accounts->map(fn ($a) => [
|
|
'value' => $a->id,
|
|
'label' => $a->name.($a->city ? " ({$a->city}, {$a->state})" : ''),
|
|
])
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Search products for the current business.
|
|
*
|
|
* GET /s/{business}/search/products?q=...&brand_id=...
|
|
*/
|
|
public function products(Request $request, Business $business): JsonResponse
|
|
{
|
|
$query = $request->input('q', '');
|
|
$brandId = $request->input('brand_id');
|
|
|
|
$products = \App\Models\Product::whereHas('brand', fn ($q) => $q->where('business_id', $business->id))
|
|
->where('is_active', true)
|
|
->with('brand:id,name')
|
|
->when($brandId, fn ($q) => $q->where('brand_id', $brandId))
|
|
->when($query, function ($q) use ($query) {
|
|
$q->where(function ($q2) use ($query) {
|
|
$q2->where('name', 'ILIKE', "%{$query}%")
|
|
->orWhere('sku', 'ILIKE', "%{$query}%")
|
|
->orWhereHas('brand', fn ($b) => $b->where('name', 'ILIKE', "%{$query}%"));
|
|
});
|
|
})
|
|
->orderBy('name')
|
|
->limit(100)
|
|
->get(['id', 'brand_id', 'name', 'sku', 'wholesale_price']);
|
|
|
|
return response()->json(
|
|
$products->map(fn ($p) => [
|
|
'value' => $p->id,
|
|
'label' => $p->name.' ('.$p->sku.')',
|
|
'id' => $p->id,
|
|
'brand_id' => $p->brand_id,
|
|
'brand_name' => $p->brand?->name,
|
|
'name' => $p->name,
|
|
'sku' => $p->sku,
|
|
'wholesale_price' => $p->wholesale_price ?? 0,
|
|
])
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Search products with full details for invoices (includes batches, stock, images).
|
|
*
|
|
* GET /s/{business}/search/invoice-products?q=...&type=...&in_stock=1
|
|
*/
|
|
public function invoiceProducts(Request $request, Business $business): JsonResponse
|
|
{
|
|
$query = $request->input('q', '');
|
|
$type = $request->input('type', '');
|
|
$inStockOnly = $request->boolean('in_stock', false);
|
|
|
|
$products = \App\Models\Product::forBusiness($business)
|
|
->where('is_active', true)
|
|
->with(['brand', 'availableBatches.labs'])
|
|
->when($query, function ($q) use ($query) {
|
|
$q->where(function ($q2) use ($query) {
|
|
$q2->where('name', 'ILIKE', "%{$query}%")
|
|
->orWhere('sku', 'ILIKE', "%{$query}%")
|
|
->orWhere('description', 'ILIKE', "%{$query}%");
|
|
});
|
|
})
|
|
->when($type, fn ($q) => $q->where('type', $type))
|
|
->orderBy('name')
|
|
->limit(50)
|
|
->get()
|
|
->map(function ($product) use ($business) {
|
|
// Calculate inventory
|
|
$totalOnHand = $product->inventoryItems()
|
|
->where('business_id', $business->id)
|
|
->sum('quantity_on_hand');
|
|
|
|
// Map batches with COA data
|
|
$batches = $product->availableBatches->map(function ($batch) {
|
|
$latestLab = $batch->getLatestLab();
|
|
|
|
return [
|
|
'id' => $batch->id,
|
|
'batch_number' => $batch->batch_number,
|
|
'quantity_available' => $batch->quantity_available,
|
|
'expiration_date' => $batch->expiration_date?->format('Y-m-d'),
|
|
'has_coa' => $latestLab !== null,
|
|
'thc_total' => $latestLab?->thc_total,
|
|
'cbd_total' => $latestLab?->cbd_total,
|
|
];
|
|
});
|
|
|
|
return [
|
|
'id' => $product->id,
|
|
'name' => $product->name,
|
|
'sku' => $product->sku,
|
|
'type' => $product->type,
|
|
'description' => $product->description,
|
|
'wholesale_price' => $product->wholesale_price ?? 0,
|
|
'msrp_price' => $product->msrp_price,
|
|
'brand_name' => $product->brand?->name,
|
|
'image_url' => $product->getImageUrl('thumb'),
|
|
'quantity_available' => $totalOnHand,
|
|
'has_batches' => $batches->isNotEmpty(),
|
|
'batches' => $batches,
|
|
];
|
|
});
|
|
|
|
// Filter by stock if requested
|
|
if ($inStockOnly) {
|
|
$products = $products->filter(fn ($p) => $p['quantity_available'] > 0)->values();
|
|
}
|
|
|
|
return response()->json($products);
|
|
}
|
|
|
|
/**
|
|
* Search users/team members for the current business.
|
|
*
|
|
* GET /s/{business}/search/users?q=...
|
|
*/
|
|
public function users(Request $request, Business $business): JsonResponse
|
|
{
|
|
$query = $request->input('q', '');
|
|
|
|
$users = $business->users()
|
|
->when($query, function ($q) use ($query) {
|
|
$q->where(function ($q2) use ($query) {
|
|
$q2->where('first_name', 'ILIKE', "%{$query}%")
|
|
->orWhere('last_name', 'ILIKE', "%{$query}%")
|
|
->orWhere('email', 'ILIKE', "%{$query}%");
|
|
});
|
|
})
|
|
->orderBy('first_name')
|
|
->orderBy('last_name')
|
|
->limit(25)
|
|
->get(['users.id', 'first_name', 'last_name', 'email']);
|
|
|
|
return response()->json(
|
|
$users->map(fn ($u) => [
|
|
'value' => $u->id,
|
|
'label' => trim("{$u->first_name} {$u->last_name}") ?: $u->email,
|
|
])
|
|
);
|
|
}
|
|
}
|