Files
hub/app/Http/Controllers/Seller/SearchController.php
kelly aaff332937 fix: resolve Crystal issues #161, #200, #203
Issue #161: Quote submission error
- Added missing tax_rate column to crm_quotes table
- Column was referenced in model but never created in migration

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

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

Additional improvements:
- Quote create page prefill from CRM account dashboard
- Product hashid backfill migration
2025-12-11 17:12:21 -07:00

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,
])
);
}
}