Files
hub/app/Http/Controllers/Seller/InvoiceController.php
kelly 3fb5747aa2 feat: add server-side search to all index pages
Standardize search functionality across all listing pages:
- Products, Contacts, Quotes, Tasks, Leads, Accounts, Invoices, Orders

All pages now use simple form-based server-side search:
- Type search term, press Enter or click magnifying glass
- Full database search (not limited to current page)
- Removed confusing live-search dropdowns that only searched current page
- Added JSON response support for AJAX requests in controllers

Updated filter-bar component to support alpine mode with optional
server-side search on Enter key press.
2025-12-11 10:01:35 -07:00

387 lines
15 KiB
PHP

<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Mail\Invoices\InvoiceSentMail;
use App\Models\Business;
use App\Models\Invoice;
use App\Models\InvoicePayment;
use App\Services\InvoiceService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
class InvoiceController extends Controller
{
/**
* Show the form for creating a new invoice manually.
*/
public function create(Business $business)
{
// Get all businesses (buyers) for the customer dropdown
$buyers = \App\Models\Business::where('is_active', true)
->with(['locations' => function ($query) {
$query->where('is_active', true)->orderBy('is_primary', 'desc')->orderBy('name');
}])
->orderBy('name')
->get();
// Products are loaded via API search (/search/invoice-products) for better performance
// Get recently invoiced products (last 30 days, top 10 most common)
$recentProducts = \App\Models\Product::forBusiness($business)
->whereHas('orderItems.order.invoice', function ($query) {
$query->where('created_at', '>=', now()->subDays(30));
})
->with(['brand', 'images'])
->withCount(['orderItems' => function ($query) {
$query->whereHas('order.invoice', function ($q) {
$q->where('created_at', '>=', now()->subDays(30));
});
}])
->orderByDesc('order_items_count')
->take(10)
->get()
->map(function ($product) use ($business) {
// Calculate inventory from InventoryItem model
$totalOnHand = $product->inventoryItems()
->where('business_id', $business->id)
->sum('quantity_on_hand');
$totalAllocated = $product->inventoryItems()
->where('business_id', $business->id)
->sum('quantity_allocated');
return [
'id' => $product->id,
'name' => $product->name,
'sku' => $product->sku,
'brand_name' => $product->brand?->name,
'wholesale_price' => $product->wholesale_price,
'quantity_available' => max(0, $totalOnHand - $totalAllocated),
'type' => $product->type,
];
});
return view('seller.invoices.create', compact('business', 'buyers', 'recentProducts'));
}
/**
* Store a newly created invoice.
*/
public function store(Business $business, Request $request, InvoiceService $invoiceService)
{
$validated = $request->validate([
'buyer_business_id' => 'required|exists:businesses,id',
'location_id' => 'nullable|exists:locations,id',
'contact_id' => 'nullable|exists:contacts,id',
'due_date' => 'required|date',
'payment_terms' => 'required|in:cod,net_15,net_30,net_60,net_90',
'notes' => 'nullable|string|max:1000',
'invoice_discount_amount' => 'nullable|numeric|min:0',
'invoice_discount_type' => 'nullable|in:fixed,percent',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_price' => 'required|numeric|min:0',
'items.*.discount_amount' => 'nullable|numeric|min:0',
'items.*.discount_type' => 'nullable|in:fixed,percent',
'items.*.notes' => 'nullable|string|max:500',
'items.*.batch_id' => 'nullable|exists:batches,id',
'items.*.batch_number' => 'nullable|string|max:255',
]);
try {
// Create the invoice using the service
$invoice = $invoiceService->createManualInvoice(
$business,
$validated['buyer_business_id'],
$validated['items'],
$validated['payment_terms'],
$validated['due_date'],
$validated['notes'] ?? null,
$validated['location_id'] ?? null,
$validated['contact_id'] ?? null
);
return redirect()
->route('seller.business.invoices.show', [$business->slug, $invoice->invoice_number])
->with('success', 'Invoice created successfully!');
} catch (\Exception $e) {
return back()
->withInput()
->with('error', 'Failed to create invoice: '.$e->getMessage());
}
}
/**
* Display a listing of invoices for the business.
*/
public function index(Business $business, Request $request)
{
// Get brand IDs for this business (single query, reused for filtering)
$brandIds = $business->brands()->pluck('id');
// Base query: invoices where orders contain items from this business's brands
$baseQuery = Invoice::whereHas('order.items.product', function ($query) use ($brandIds) {
$query->whereIn('brand_id', $brandIds);
});
// Calculate stats with efficient database aggregates (not in-memory iteration)
$stats = [
'total' => (clone $baseQuery)->count(),
'unpaid' => (clone $baseQuery)->where('payment_status', 'unpaid')->count(),
'partially_paid' => (clone $baseQuery)->where('payment_status', 'partially_paid')->count(),
'paid' => (clone $baseQuery)->where('payment_status', 'paid')->count(),
'overdue' => (clone $baseQuery)->where('payment_status', '!=', 'paid')
->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)
->withQueryString();
// Return JSON for AJAX/API requests (live search)
if ($request->wantsJson()) {
return response()->json([
'data' => $invoices->map(fn ($i) => [
'hashid' => $i->hashid,
'name' => $i->invoice_number.' - '.$i->business->name,
'invoice_number' => $i->invoice_number,
'customer' => $i->business->name,
'status' => $i->payment_status,
])->values()->toArray(),
]);
}
return view('seller.invoices.index', compact('business', 'invoices', 'stats'));
}
/**
* Display the specified invoice.
*/
public function show(Business $business, Invoice $invoice)
{
// Verify invoice belongs to this business through order items
$invoice->load([
'order.items.product.brand',
'order.contact',
'order.user',
'business',
'payments.recordedByUser',
]);
// Check if any of the order's items belong to brands owned by this business
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
return $item->product && $item->product->belongsToBusiness($business);
});
if (! $belongsToBusiness) {
abort(403, 'This invoice does not belong to your business');
}
return view('seller.invoices.show', compact('business', 'invoice'));
}
/**
* Download invoice PDF.
*/
public function downloadPdf(Business $business, Invoice $invoice, InvoiceService $invoiceService): Response
{
// Verify invoice belongs to this business through order items
$invoice->load('order.items.product.brand');
// Check if any of the order's items belong to brands owned by this business
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
return $item->product && $item->product->belongsToBusiness($business);
});
if (! $belongsToBusiness) {
abort(403, 'This invoice does not belong to your business');
}
// Regenerate PDF if it doesn't exist
if (! $invoice->pdf_path || ! Storage::disk('local')->exists($invoice->pdf_path)) {
$invoiceService->regeneratePdf($invoice);
$invoice->refresh();
}
$pdf = Storage::disk('local')->get($invoice->pdf_path);
return response($pdf, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$invoice->invoice_number.'.pdf"',
]);
}
/**
* Get contacts for a specific business/location (AJAX endpoint).
*/
public function getContacts(Request $request)
{
$request->validate([
'business_id' => 'required|exists:businesses,id',
'location_id' => 'nullable|exists:locations,id',
]);
$businessId = $request->integer('business_id');
$locationId = $request->input('location_id');
// Get contacts for this business/location
$contacts = \App\Models\Contact::where('business_id', $businessId)
->where('is_active', true)
->where(function ($query) use ($locationId) {
if ($locationId) {
// Get location-specific contacts OR business-wide contacts
$query->where('location_id', $locationId)
->orWhereNull('location_id');
} else {
// Only get business-wide contacts
$query->whereNull('location_id');
}
})
->orderBy('is_primary', 'desc')
->orderBy('first_name')
->get()
->map(function ($contact) {
return [
'id' => $contact->id,
'name' => $contact->getFullName(),
'display_name' => $contact->getDisplayName(),
'email' => $contact->email,
'phone' => $contact->phone,
'is_primary' => $contact->is_primary,
'can_receive_invoices' => $contact->can_receive_invoices,
];
});
return response()->json([
'contacts' => $contacts,
]);
}
/**
* Send invoice by email.
*/
public function send(Business $business, Invoice $invoice, Request $request, InvoiceService $invoiceService): Response
{
// Verify invoice belongs to this business through order items
$invoice->load('order.items.product.brand');
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
return $item->product && $item->product->belongsToBusiness($business);
});
if (! $belongsToBusiness) {
abort(403, 'This invoice does not belong to your business');
}
$validated = $request->validate([
'to' => ['required', 'email'],
'cc' => ['nullable', 'email'],
'message' => ['nullable', 'string', 'max:2000'],
'attach_pdf' => ['sometimes', 'boolean'],
]);
// Generate PDF if requested
$pdfContent = null;
if ($validated['attach_pdf'] ?? false) {
// Regenerate PDF if it doesn't exist
if (! $invoice->pdf_path || ! Storage::disk('local')->exists($invoice->pdf_path)) {
$invoiceService->regeneratePdf($invoice);
$invoice->refresh();
}
if ($invoice->pdf_path && Storage::disk('local')->exists($invoice->pdf_path)) {
$pdfContent = Storage::disk('local')->get($invoice->pdf_path);
}
}
// Send email
$mail = Mail::to($validated['to']);
if (! empty($validated['cc'])) {
$mail->cc($validated['cc']);
}
$mail->send(new InvoiceSentMail(
$invoice,
$validated['message'] ?? null,
$pdfContent
));
return back()->with('success', 'Invoice sent successfully to '.$validated['to']);
}
/**
* Record a payment for an invoice.
*/
public function recordPayment(Business $business, Invoice $invoice, Request $request): Response
{
// Verify invoice belongs to this business through order items
$invoice->load('order.items.product.brand');
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
return $item->product && $item->product->belongsToBusiness($business);
});
if (! $belongsToBusiness) {
abort(403, 'This invoice does not belong to your business');
}
if ($invoice->payment_status === 'paid') {
return back()->withErrors(['error' => 'This invoice is already fully paid.']);
}
$validated = $request->validate([
'amount' => ['required', 'numeric', 'min:0.01', 'max:'.$invoice->amount_due],
'payment_date' => ['required', 'date'],
'payment_method' => ['required', 'string', 'in:cash,check,wire,ach,credit_card,bank_transfer,other'],
'reference' => ['nullable', 'string', 'max:255'],
'notes' => ['nullable', 'string', 'max:500'],
]);
InvoicePayment::create([
'invoice_id' => $invoice->id,
'amount' => $validated['amount'],
'payment_date' => $validated['payment_date'],
'payment_method' => $validated['payment_method'],
'reference' => $validated['reference'],
'notes' => $validated['notes'],
'recorded_by' => $request->user()->id,
]);
$statusMessage = $invoice->fresh()->payment_status === 'paid'
? 'Payment recorded. Invoice is now fully paid.'
: 'Payment recorded successfully.';
return back()->with('success', $statusMessage);
}
}