- Redesign dashboard as daily briefing format with action-first layout - Consolidate sidebar menu structure (Dashboard as single link) - Fix CRM form styling to use consistent UI patterns - Add PWA icons and push notification groundwork - Update SuiteMenuResolver for cleaner navigation
285 lines
9.4 KiB
PHP
285 lines
9.4 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Seller\Crm;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Business;
|
|
use App\Models\Crm\CrmInvoice;
|
|
use App\Models\Crm\CrmInvoiceItem;
|
|
use App\Models\Crm\CrmInvoicePayment;
|
|
use App\Models\Crm\CrmQuote;
|
|
use Illuminate\Http\Request;
|
|
|
|
class InvoiceController extends Controller
|
|
{
|
|
/**
|
|
* List invoices
|
|
*/
|
|
public function index(Request $request, Business $business)
|
|
{
|
|
$query = CrmInvoice::forBusiness($business->id)
|
|
->with(['contact', 'account', 'creator'])
|
|
->withCount('items');
|
|
|
|
// Filters
|
|
if ($request->filled('status')) {
|
|
$query->where('status', $request->status);
|
|
}
|
|
|
|
if ($request->filled('overdue')) {
|
|
$query->overdue();
|
|
}
|
|
|
|
if ($request->filled('search')) {
|
|
$query->where(function ($q) use ($request) {
|
|
$q->where('invoice_number', 'ILIKE', "%{$request->search}%")
|
|
->orWhere('title', 'ILIKE', "%{$request->search}%");
|
|
});
|
|
}
|
|
|
|
$invoices = $query->orderByDesc('created_at')->paginate(25);
|
|
|
|
// Stats - single efficient query with conditional aggregation
|
|
$invoiceStats = CrmInvoice::forBusiness($business->id)
|
|
->selectRaw("
|
|
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') THEN balance_due ELSE 0 END) as outstanding,
|
|
SUM(CASE WHEN status IN ('sent', 'viewed', 'partial') AND due_date < CURRENT_DATE THEN balance_due ELSE 0 END) as overdue
|
|
")
|
|
->first();
|
|
|
|
$paidThisMonth = CrmInvoicePayment::whereHas('invoice', fn ($q) => $q->where('business_id', $business->id))
|
|
->whereMonth('payment_date', now()->month)
|
|
->whereYear('payment_date', now()->year)
|
|
->sum('amount');
|
|
|
|
$stats = [
|
|
'outstanding' => $invoiceStats->outstanding ?? 0,
|
|
'overdue' => $invoiceStats->overdue ?? 0,
|
|
'paid_this_month' => $paidThisMonth,
|
|
];
|
|
|
|
return view('seller.crm.invoices.index', compact('invoices', 'stats', 'business'));
|
|
}
|
|
|
|
/**
|
|
* Show invoice details
|
|
*/
|
|
public function show(Request $request, Business $business, CrmInvoice $invoice)
|
|
{
|
|
if ($invoice->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$invoice->load(['contact', 'account', 'quote', 'creator', 'items.product', 'payments']);
|
|
|
|
return view('seller.crm.invoices.show', compact('invoice', 'business'));
|
|
}
|
|
|
|
/**
|
|
* Create invoice form
|
|
*/
|
|
public function create(Request $request, Business $business)
|
|
{
|
|
// Limit contacts for dropdown - most recent 100
|
|
$contacts = \App\Models\Contact::where('business_id', $business->id)
|
|
->select('id', 'first_name', 'last_name', 'email', 'company_name')
|
|
->orderByDesc('updated_at')
|
|
->limit(100)
|
|
->get();
|
|
|
|
// Limit quotes to accepted without invoices
|
|
$quotes = CrmQuote::forBusiness($business->id)
|
|
->where('status', CrmQuote::STATUS_ACCEPTED)
|
|
->whereDoesntHave('invoice')
|
|
->select('id', 'quote_number', 'title', 'total', 'contact_id')
|
|
->with('contact:id,first_name,last_name')
|
|
->limit(50)
|
|
->get();
|
|
|
|
return view('seller.crm.invoices.create', compact('contacts', 'quotes', 'business'));
|
|
}
|
|
|
|
/**
|
|
* Store new invoice
|
|
*/
|
|
public function store(Request $request, Business $business)
|
|
{
|
|
$validated = $request->validate([
|
|
'title' => 'required|string|max:255',
|
|
'contact_id' => 'required|exists:contacts,id',
|
|
'account_id' => 'nullable|exists:businesses,id',
|
|
'quote_id' => 'nullable|exists:crm_quotes,id',
|
|
'due_date' => 'required|date|after_or_equal:today',
|
|
'tax_rate' => 'nullable|numeric|min:0|max:100',
|
|
'notes' => 'nullable|string|max:2000',
|
|
'payment_terms' => 'nullable|string|max:1000',
|
|
'items' => 'required|array|min:1',
|
|
'items.*.description' => 'required|string|max:500',
|
|
'items.*.quantity' => 'required|numeric|min:0.01',
|
|
'items.*.unit_price' => 'required|numeric|min:0',
|
|
]);
|
|
|
|
// SECURITY: Verify contact belongs to business
|
|
\App\Models\Contact::where('id', $validated['contact_id'])
|
|
->where('business_id', $business->id)
|
|
->firstOrFail();
|
|
|
|
// SECURITY: Verify quote belongs to business if provided
|
|
if (! empty($validated['quote_id'])) {
|
|
CrmQuote::where('id', $validated['quote_id'])
|
|
->where('business_id', $business->id)
|
|
->firstOrFail();
|
|
}
|
|
|
|
$invoiceNumber = CrmInvoice::generateInvoiceNumber($business->id);
|
|
|
|
$invoice = CrmInvoice::create([
|
|
'business_id' => $business->id,
|
|
'contact_id' => $validated['contact_id'],
|
|
'account_id' => $validated['account_id'],
|
|
'quote_id' => $validated['quote_id'],
|
|
'created_by' => $request->user()->id,
|
|
'invoice_number' => $invoiceNumber,
|
|
'title' => $validated['title'],
|
|
'status' => CrmInvoice::STATUS_DRAFT,
|
|
'issue_date' => now(),
|
|
'due_date' => $validated['due_date'],
|
|
'tax_rate' => $validated['tax_rate'] ?? 0,
|
|
'notes' => $validated['notes'],
|
|
'payment_terms' => $validated['payment_terms'],
|
|
'currency' => 'USD',
|
|
]);
|
|
|
|
// Create line items
|
|
foreach ($validated['items'] as $index => $item) {
|
|
CrmInvoiceItem::create([
|
|
'invoice_id' => $invoice->id,
|
|
'description' => $item['description'],
|
|
'quantity' => $item['quantity'],
|
|
'unit_price' => $item['unit_price'],
|
|
'sort_order' => $index,
|
|
]);
|
|
}
|
|
|
|
$invoice->calculateTotals();
|
|
|
|
return redirect()->route('seller.business.crm.invoices.show', [$business, $invoice])
|
|
->with('success', 'Invoice created successfully.');
|
|
}
|
|
|
|
/**
|
|
* Send invoice to contact
|
|
*/
|
|
public function send(Request $request, Business $business, CrmInvoice $invoice)
|
|
{
|
|
if ($invoice->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $invoice->canBeSent()) {
|
|
return back()->withErrors(['error' => 'This invoice cannot be sent.']);
|
|
}
|
|
|
|
$invoice->send($request->user());
|
|
|
|
// TODO: Send email notification to contact
|
|
|
|
return back()->with('success', 'Invoice sent successfully.');
|
|
}
|
|
|
|
/**
|
|
* Record a payment
|
|
*/
|
|
public function recordPayment(Request $request, Business $business, CrmInvoice $invoice)
|
|
{
|
|
if ($invoice->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
$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,other',
|
|
'reference' => 'nullable|string|max:255',
|
|
'notes' => 'nullable|string|max:500',
|
|
]);
|
|
|
|
CrmInvoicePayment::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,
|
|
]);
|
|
|
|
// Recalculate totals
|
|
$invoice->calculateTotals();
|
|
|
|
// Auto-mark as paid if fully paid
|
|
if ($invoice->amount_due <= 0) {
|
|
$invoice->update([
|
|
'status' => CrmInvoice::STATUS_PAID,
|
|
'paid_at' => now(),
|
|
]);
|
|
}
|
|
|
|
return back()->with('success', 'Payment recorded.');
|
|
}
|
|
|
|
/**
|
|
* Mark invoice as void
|
|
*/
|
|
public function void(Request $request, Business $business, CrmInvoice $invoice)
|
|
{
|
|
if ($invoice->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
if ($invoice->status === CrmInvoice::STATUS_PAID) {
|
|
return back()->withErrors(['error' => 'Paid invoices cannot be voided.']);
|
|
}
|
|
|
|
$invoice->update([
|
|
'status' => CrmInvoice::STATUS_VOID,
|
|
'voided_at' => now(),
|
|
'voided_by' => $request->user()->id,
|
|
]);
|
|
|
|
return back()->with('success', 'Invoice voided.');
|
|
}
|
|
|
|
/**
|
|
* Download invoice PDF
|
|
*/
|
|
public function download(Request $request, Business $business, CrmInvoice $invoice)
|
|
{
|
|
if ($invoice->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
// TODO: Generate PDF
|
|
return back()->with('info', 'PDF generation coming soon.');
|
|
}
|
|
|
|
/**
|
|
* Delete invoice
|
|
*/
|
|
public function destroy(Request $request, Business $business, CrmInvoice $invoice)
|
|
{
|
|
if ($invoice->business_id !== $business->id) {
|
|
abort(404);
|
|
}
|
|
|
|
if ($invoice->status !== CrmInvoice::STATUS_DRAFT) {
|
|
return back()->withErrors(['error' => 'Only draft invoices can be deleted.']);
|
|
}
|
|
|
|
$invoice->delete();
|
|
|
|
return redirect()->route('seller.business.crm.invoices.index', $business)
|
|
->with('success', 'Invoice deleted.');
|
|
}
|
|
}
|