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.
387 lines
15 KiB
PHP
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);
|
|
}
|
|
}
|