diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d772ffa..4c01aee 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -525,4 +525,25 @@ $delete = fn(Product $product) => $product->delete(); - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. - \ No newline at end of file + + +## Theme to use across components +@theme { + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + +--color-zinc-50: var(--color-neutral-50); +--color-zinc-100: var(--color-neutral-100); +--color-zinc-200: var(--color-neutral-200); +--color-zinc-300: var(--color-neutral-300); +--color-zinc-400: var(--color-neutral-400); +--color-zinc-500: var(--color-neutral-500); +--color-zinc-600: var(--color-neutral-600); +--color-zinc-700: var(--color-neutral-700); +--color-zinc-800: var(--color-neutral-800); +--color-zinc-900: var(--color-neutral-900); +--color-zinc-950: var(--color-neutral-950); + + --color-accent: var(--color-orange-500); + --color-accent-content: var(--color-orange-600); + --color-accent-foreground: var(--color-white); +} \ No newline at end of file diff --git a/app/Livewire/Estimates/Create.php b/app/Livewire/Estimates/Create.php index 86613d2..e7d2d53 100644 --- a/app/Livewire/Estimates/Create.php +++ b/app/Livewire/Estimates/Create.php @@ -38,7 +38,7 @@ class Create extends Component 'validity_period_days' => 'required|integer|min:1|max:365', 'tax_rate' => 'required|numeric|min:0|max:50', 'discount_amount' => 'nullable|numeric|min:0', - 'lineItems.*.type' => 'required|in:labor,parts,miscellaneous', + 'lineItems.*.type' => 'required|in:labour,parts,miscellaneous', 'lineItems.*.description' => 'required|string', 'lineItems.*.quantity' => 'required|numeric|min:0.01', 'lineItems.*.unit_price' => 'required|numeric|min:0', @@ -64,7 +64,7 @@ class Create extends Component if ($this->diagnosis->labor_operations) { foreach ($this->diagnosis->labor_operations as $labor) { $this->lineItems[] = [ - 'type' => 'labor', + 'type' => 'labour', 'description' => $labor['operation'] ?? 'Labor Operation', 'quantity' => $labor['estimated_hours'] ?? 1, 'unit_price' => $labor['labor_rate'] ?? 75, @@ -95,7 +95,7 @@ class Create extends Component // If no line items from diagnosis, add a default labor item if (empty($this->lineItems)) { $this->lineItems[] = [ - 'type' => 'labor', + 'type' => 'labour', 'description' => 'Diagnostic and Repair Services', 'quantity' => $this->diagnosis->estimated_repair_time ?? 1, 'unit_price' => 75, @@ -112,7 +112,7 @@ class Create extends Component public function addLineItem() { $this->lineItems[] = [ - 'type' => 'labor', + 'type' => 'labour', 'description' => '', 'quantity' => 1, 'unit_price' => 0, @@ -181,7 +181,7 @@ class Create extends Component 'job_card_id' => $this->diagnosis->job_card_id, 'diagnosis_id' => $this->diagnosis->id, 'prepared_by_id' => auth()->id(), - 'labor_cost' => collect($this->lineItems)->where('type', 'labor')->sum('total_amount'), + 'labor_cost' => collect($this->lineItems)->where('type', 'labour')->sum('total_amount'), 'parts_cost' => collect($this->lineItems)->where('type', 'parts')->sum('total_amount'), 'miscellaneous_cost' => collect($this->lineItems)->where('type', 'miscellaneous')->sum('total_amount'), 'subtotal' => $this->subtotal, diff --git a/app/Livewire/Estimates/CreateStandalone.php b/app/Livewire/Estimates/CreateStandalone.php index c5bbe49..21207af 100644 --- a/app/Livewire/Estimates/CreateStandalone.php +++ b/app/Livewire/Estimates/CreateStandalone.php @@ -51,12 +51,12 @@ class CreateStandalone extends Component protected $rules = [ 'customerId' => 'required|exists:customers,id', - 'vehicleId' => 'required|exists:vehicles,id', + 'vehicleId' => 'nullable|exists:vehicles,id', 'terms_and_conditions' => 'required|string', 'validity_period_days' => 'required|integer|min:1|max:365', 'tax_rate' => 'required|numeric|min:0|max:50', 'discount_amount' => 'nullable|numeric|min:0', - 'lineItems.*.type' => 'required|in:labor,parts,miscellaneous', + 'lineItems.*.type' => 'required|in:labour,parts,miscellaneous', 'lineItems.*.description' => 'required|string', 'lineItems.*.quantity' => 'required|numeric|min:0.01', 'lineItems.*.unit_price' => 'required|numeric|min:0', @@ -104,7 +104,7 @@ class CreateStandalone extends Component 'part_id' => null, 'part_number' => '', 'description' => '', - 'quantity' => 1, + 'quantity' => '', // Start empty to force user input 'unit_price' => 0, 'subtotal' => 0, 'type' => 'labour', @@ -155,10 +155,15 @@ class CreateStandalone extends Component $this->subtotal = 0; foreach ($this->lineItems as $index => $item) { - if (isset($item['quantity'], $item['unit_price'])) { - $lineSubtotal = $item['quantity'] * $item['unit_price']; + $quantity = is_numeric($item['quantity']) ? (float) $item['quantity'] : 0; + $unitPrice = is_numeric($item['unit_price']) ? (float) $item['unit_price'] : 0; + + if ($quantity > 0 && $unitPrice >= 0) { + $lineSubtotal = $quantity * $unitPrice; $this->lineItems[$index]['subtotal'] = $lineSubtotal; $this->subtotal += $lineSubtotal; + } else { + $this->lineItems[$index]['subtotal'] = 0; } } @@ -201,6 +206,9 @@ class CreateStandalone extends Component $this->lineItems[$lineIndex]['stock_available'] = $part->quantity_on_hand; $this->lineItems[$lineIndex]['type'] = 'parts'; + // DO NOT auto-set quantity - let user enter their desired quantity + // $this->lineItems[$lineIndex]['quantity'] stays as user entered + $this->calculateTotals(); } @@ -227,10 +235,19 @@ class CreateStandalone extends Component return; } + // Validate that all line items have quantities + foreach ($this->lineItems as $index => $item) { + if (empty($item['quantity']) || $item['quantity'] <= 0) { + $this->addError("lineItems.{$index}.quantity", 'Quantity is required and must be greater than 0.'); + + return; + } + } + $estimate = Estimate::create([ 'estimate_number' => $this->generateEstimateNumber(), 'customer_id' => $this->customerId, - 'vehicle_id' => $this->vehicleId, + 'vehicle_id' => $this->vehicleId ?: null, // Allow null for vehicle_id 'job_card_id' => null, // No job card for standalone estimates 'diagnosis_id' => null, // No diagnosis for standalone estimates 'status' => 'draft', diff --git a/app/Livewire/Estimates/Edit.php b/app/Livewire/Estimates/Edit.php index 6832501..29f900e 100644 --- a/app/Livewire/Estimates/Edit.php +++ b/app/Livewire/Estimates/Edit.php @@ -56,14 +56,14 @@ class Edit extends Component // Quick add presets public $quickAddPresets = [ - 'oil_change' => ['type' => 'labor', 'description' => 'Oil Change Service', 'quantity' => 1, 'unit_price' => 75], - 'brake_inspection' => ['type' => 'labor', 'description' => 'Brake System Inspection', 'quantity' => 1, 'unit_price' => 125], - 'tire_rotation' => ['type' => 'labor', 'description' => 'Tire Rotation Service', 'quantity' => 1, 'unit_price' => 50], + 'oil_change' => ['type' => 'labour', 'description' => 'Oil Change Service', 'quantity' => 1, 'unit_price' => 75], + 'brake_inspection' => ['type' => 'labour', 'description' => 'Brake System Inspection', 'quantity' => 1, 'unit_price' => 125], + 'tire_rotation' => ['type' => 'labour', 'description' => 'Tire Rotation Service', 'quantity' => 1, 'unit_price' => 50], ]; // Line item templates public $newItem = [ - 'type' => 'labor', + 'type' => 'labour', 'description' => '', 'quantity' => 1, 'unit_price' => 0, @@ -80,7 +80,7 @@ class Edit extends Component 'validity_period_days' => 'required|integer|min:1|max:365', 'tax_rate' => 'required|numeric|min:0|max:50', 'discount_amount' => 'nullable|numeric|min:0', - 'lineItems.*.type' => 'required|in:labor,parts,miscellaneous', + 'lineItems.*.type' => 'required|in:labour,parts,miscellaneous', 'lineItems.*.description' => 'required|string', 'lineItems.*.quantity' => 'required|numeric|min:0.01', 'lineItems.*.unit_price' => 'required|numeric|min:0', diff --git a/app/Livewire/Estimates/Index.php b/app/Livewire/Estimates/Index.php index 199d5a5..19f9f95 100644 --- a/app/Livewire/Estimates/Index.php +++ b/app/Livewire/Estimates/Index.php @@ -5,6 +5,7 @@ namespace App\Livewire\Estimates; use App\Models\Customer; use App\Models\Estimate; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; use Livewire\Attributes\Layout; use Livewire\Attributes\Url; use Livewire\Component; @@ -59,6 +60,9 @@ class Index extends Component public $selectAll = false; + // Row selection for inline actions + public $selectedRow = null; + // Quick stats public $stats = []; @@ -425,17 +429,76 @@ class Index extends Component return; } + // Update estimate status $estimate->update([ 'status' => 'sent', 'sent_to_customer_at' => now(), ]); - // TODO: Send email/SMS notification to customer + // Send notification to customer + $this->sendEstimateNotification($estimate); session()->flash('success', 'Estimate sent to customer successfully.'); $this->loadStats(); } + public function resendEstimate($estimateId) + { + $estimate = Estimate::findOrFail($estimateId); + + if (! Auth::user()->can('update', $estimate)) { + session()->flash('error', 'You are not authorized to resend this estimate.'); + + return; + } + + if (! in_array($estimate->status, ['sent', 'approved', 'rejected'])) { + session()->flash('error', 'Only sent, approved, or rejected estimates can be resent.'); + + return; + } + + // Update the sent timestamp + $estimate->update([ + 'sent_to_customer_at' => now(), + ]); + + // Send notification to customer + $this->sendEstimateNotification($estimate); + + session()->flash('success', 'Estimate resent to customer successfully.'); + $this->loadStats(); + } + + private function sendEstimateNotification(Estimate $estimate) + { + try { + // Get customer from estimate + $customer = $estimate->customer; + + if (! $customer) { + // If no direct customer, get from job card + $customer = $estimate->jobCard?->customer; + } + + if ($customer && $customer->email) { + // Send email notification using Notification facade + \Illuminate\Support\Facades\Notification::send($customer, new \App\Notifications\EstimateNotification($estimate)); + + // TODO: Send SMS notification if phone number is available + // if ($customer->phone) { + // // Implement SMS sending logic here + // } + } else { + session()->flash('warning', 'Estimate status updated, but customer email not found. Please contact customer manually.'); + } + } catch (\Exception $e) { + // Log the error but don't fail the operation + \Illuminate\Support\Facades\Log::error('Failed to send estimate notification: '.$e->getMessage()); + session()->flash('warning', 'Estimate status updated, but notification could not be sent. Please contact customer manually.'); + } + } + public function confirmDelete($estimateId) { $estimate = Estimate::findOrFail($estimateId); @@ -453,6 +516,15 @@ class Index extends Component $this->loadStats(); } + public function selectRow($estimateId) + { + if ($this->selectedRow === $estimateId) { + $this->selectedRow = null; // Deselect if clicking the same row + } else { + $this->selectedRow = $estimateId; // Select the clicked row + } + } + #[Layout('components.layouts.app')] public function render() { diff --git a/app/Livewire/Estimates/Show.php b/app/Livewire/Estimates/Show.php index 67fd14d..e192c8e 100644 --- a/app/Livewire/Estimates/Show.php +++ b/app/Livewire/Estimates/Show.php @@ -217,28 +217,78 @@ class Show extends Component public function downloadPDF() { try { - // Generate PDF using DomPDF - $pdf = Pdf::loadView('estimates.pdf', [ - 'estimate' => $this->estimate, + // Load the estimate with relationships for PDF generation + $estimate = $this->estimate->load([ + 'customer', + 'jobCard.customer', + 'jobCard.vehicle', + 'vehicle', + 'lineItems.part', + 'preparedBy', + 'diagnosis', ]); - // Log the download - Log::info('Estimate PDF downloaded', [ - 'estimate_id' => $this->estimate->id, - 'downloaded_by' => auth()->id(), - ]); + // For now, let's use a simple approach that should work + // First, verify HTML generation works + $html = view('estimates.pdf', compact('estimate'))->render(); - return response()->streamDownload(function () use ($pdf) { - echo $pdf->output(); - }, "estimate-{$this->estimate->estimate_number}.pdf"); + if (empty($html)) { + throw new \Exception('Failed to generate HTML template'); + } + + // Try different approaches for PDF generation + try { + // Approach 1: Use Laravel's PDF facade if available + if (class_exists('\Barryvdh\DomPDF\Facade\Pdf')) { + $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadHtml($html); + $pdf->setPaper('letter', 'portrait'); + $output = $pdf->output(); + } else { + throw new \Exception('PDF facade not available'); + } + } catch (\Exception $e1) { + try { + // Approach 2: Direct DomPDF instantiation + $dompdf = new \Dompdf\Dompdf; + $dompdf->loadHtml($html); + $dompdf->setPaper('letter', 'portrait'); + $dompdf->render(); + $output = $dompdf->output(); + } catch (\Exception $e2) { + // Approach 3: Return HTML for now + $filename = 'estimate-'.$estimate->estimate_number.'.html'; + + return response()->streamDownload(function () use ($html) { + echo $html; + }, $filename, [ + 'Content-Type' => 'text/html', + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', + ]); + } + } + + // Create filename with estimate number + $filename = 'estimate-'.$estimate->estimate_number.'.pdf'; + + // Return PDF for download + return response()->streamDownload(function () use ($output) { + echo $output; + }, $filename, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', + ]); } catch (\Exception $e) { - Log::error('Failed to generate estimate PDF', [ - 'estimate_id' => $this->estimate->id, + // Log the error for debugging + Log::error('PDF generation failed for estimate '.$this->estimate->id, [ 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), ]); - session()->flash('error', 'Failed to generate PDF. Please try again.'); + // Show user-friendly error message + session()->flash('error', 'Failed to generate PDF: '.$e->getMessage()); + + return null; } } @@ -265,6 +315,23 @@ class Show extends Component return redirect()->route('work-orders.create', ['estimate' => $this->estimate->id]); } + public function convertToInvoice() + { + if ($this->estimate->customer_approval_status !== 'approved') { + session()->flash('error', 'Estimate must be approved before converting to invoice.'); + + return; + } + + if (! auth()->user()->can('create', \App\Models\Invoice::class)) { + session()->flash('error', 'You do not have permission to create invoices.'); + + return; + } + + return redirect()->route('invoices.create-from-estimate', $this->estimate); + } + #[Layout('components.layouts.app')] public function render() { diff --git a/app/Livewire/Invoices/Create.php b/app/Livewire/Invoices/Create.php new file mode 100644 index 0000000..d4dff5a --- /dev/null +++ b/app/Livewire/Invoices/Create.php @@ -0,0 +1,222 @@ +invoice_date = now()->format('Y-m-d'); + $this->due_date = now()->addDays(30)->format('Y-m-d'); + $this->branch_id = Auth::user()->branch_id ?? ''; + + $this->customers = Customer::orderBy('first_name')->orderBy('last_name')->get(); + $this->branches = Branch::orderBy('name')->get(); + $this->parts = Part::where('status', 'active')->orderBy('name')->get(); + $this->service_items = ServiceItem::where('status', 'active')->orderBy('service_name')->get(); + + // Initialize with one empty line item + $this->addLineItem(); + } + + public function addLineItem() + { + $this->line_items[] = [ + 'type' => 'labour', + 'description' => '', + 'quantity' => 1, + 'unit_price' => 0, + 'part_id' => '', + 'part_number' => '', + 'technical_notes' => '', + ]; + } + + public function removeLineItem($index) + { + unset($this->line_items[$index]); + $this->line_items = array_values($this->line_items); + } + + public function updatedLineItems($value, $key) + { + // Auto-populate part details when part is selected + if (str_contains($key, 'part_id')) { + $index = explode('.', $key)[0]; + $partId = $this->line_items[$index]['part_id']; + + if ($partId) { + $part = Part::find($partId); + if ($part) { + $this->line_items[$index]['description'] = $part->name; + $this->line_items[$index]['unit_price'] = $part->sell_price ?? 0; + $this->line_items[$index]['part_number'] = $part->part_number; + } + } + } + + // Reset part selection when type changes + if (str_contains($key, 'type')) { + $index = explode('.', $key)[0]; + $this->line_items[$index]['part_id'] = ''; + $this->line_items[$index]['part_number'] = ''; + if ($this->line_items[$index]['type'] !== 'parts') { + $this->line_items[$index]['description'] = ''; + $this->line_items[$index]['unit_price'] = 0; + } + } + + // Auto-populate service item details + if (str_contains($key, 'service_item_id')) { + $index = explode('.', $key)[0]; + $serviceItemId = $this->line_items[$index]['service_item_id'] ?? null; + + if ($serviceItemId) { + $serviceItem = ServiceItem::find($serviceItemId); + if ($serviceItem) { + $this->line_items[$index]['description'] = $serviceItem->service_name; + $this->line_items[$index]['unit_price'] = $serviceItem->price ?? 0; + } + } + } + } + + public function calculateSubtotal() + { + return collect($this->line_items)->sum(function ($item) { + return ($item['quantity'] ?? 0) * ($item['unit_price'] ?? 0); + }); + } + + public function calculateTax() + { + $subtotal = $this->calculateSubtotal() - $this->discount_amount; + + return $subtotal * ($this->tax_rate / 100); + } + + public function calculateTotal() + { + $subtotal = $this->calculateSubtotal(); + $tax = $this->calculateTax(); + + return $subtotal + $tax - $this->discount_amount; + } + + public function save() + { + $this->validate(); + + // Validate line items + if (empty($this->line_items)) { + $this->addError('line_items', 'At least one line item is required.'); + + return; + } + + foreach ($this->line_items as $index => $item) { + if (empty($item['description'])) { + $this->addError("line_items.{$index}.description", 'Description is required.'); + + return; + } + if ($item['quantity'] <= 0) { + $this->addError("line_items.{$index}.quantity", 'Quantity must be greater than 0.'); + + return; + } + if ($item['unit_price'] < 0) { + $this->addError("line_items.{$index}.unit_price", 'Unit price cannot be negative.'); + + return; + } + } + + // Create invoice + $invoice = Invoice::create([ + 'customer_id' => $this->customer_id, + 'branch_id' => $this->branch_id, + 'created_by' => Auth::id(), + 'invoice_date' => $this->invoice_date, + 'due_date' => $this->due_date, + 'description' => $this->description, + 'notes' => $this->notes, + 'terms_and_conditions' => $this->terms_and_conditions, + 'tax_rate' => $this->tax_rate, + 'discount_amount' => $this->discount_amount, + 'status' => 'draft', + ]); + + // Create line items + foreach ($this->line_items as $item) { + InvoiceLineItem::create([ + 'invoice_id' => $invoice->id, + 'type' => $item['type'], + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'part_id' => $item['part_id'] ?: null, + 'part_number' => $item['part_number'] ?: null, + 'technical_notes' => $item['technical_notes'] ?: null, + ]); + } + + session()->flash('success', 'Invoice created successfully.'); + + return $this->redirect(route('invoices.show', $invoice)); + } + + #[Layout('components.layouts.app.sidebar')] + public function render() + { + return view('livewire.invoices.create'); + } +} diff --git a/app/Livewire/Invoices/CreateFromEstimate.php b/app/Livewire/Invoices/CreateFromEstimate.php new file mode 100644 index 0000000..702e9d4 --- /dev/null +++ b/app/Livewire/Invoices/CreateFromEstimate.php @@ -0,0 +1,196 @@ +estimate = $estimate; + $this->authorize('create', Invoice::class); + + // Pre-fill form with estimate data + $this->subject = $estimate->subject; + $this->description = $estimate->description; + $this->branch_id = $estimate->branch_id; + $this->invoice_date = now()->format('Y-m-d'); + $this->due_date = now()->addDays(30)->format('Y-m-d'); + $this->tax_rate = $estimate->tax_rate; + + // Load estimate line items + $this->lineItems = $estimate->estimateLineItems->map(function ($item) { + return [ + 'type' => $item->type, + 'service_item_id' => $item->service_item_id, + 'part_id' => $item->part_id, + 'description' => $item->description, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'total' => $item->total, + ]; + })->toArray(); + + $this->loadFormData(); + } + + public function loadFormData() + { + $this->branches = Branch::all(); + $this->serviceItems = ServiceItem::all(); + $this->parts = Part::all(); + } + + public function addLineItem() + { + $this->lineItems[] = [ + 'type' => 'service', + 'service_item_id' => '', + 'part_id' => '', + 'description' => '', + 'quantity' => 1, + 'unit_price' => 0, + 'total' => 0, + ]; + } + + public function removeLineItem($index) + { + unset($this->lineItems[$index]); + $this->lineItems = array_values($this->lineItems); + } + + public function updatedLineItems($value, $key) + { + $keyParts = explode('.', $key); + $index = $keyParts[0]; + $field = $keyParts[1]; + + if ($field === 'type') { + // Reset related fields when type changes + $this->lineItems[$index]['service_item_id'] = ''; + $this->lineItems[$index]['part_id'] = ''; + $this->lineItems[$index]['description'] = ''; + $this->lineItems[$index]['unit_price'] = 0; + } + + if ($field === 'service_item_id' && $this->lineItems[$index]['type'] === 'service') { + $serviceItem = ServiceItem::find($value); + if ($serviceItem) { + $this->lineItems[$index]['description'] = $serviceItem->name; + $this->lineItems[$index]['unit_price'] = $serviceItem->price; + } + } + + if ($field === 'part_id' && $this->lineItems[$index]['type'] === 'part') { + $part = Part::find($value); + if ($part) { + $this->lineItems[$index]['description'] = $part->name; + $this->lineItems[$index]['unit_price'] = $part->sell_price; + } + } + + // Recalculate total for this line item + if (in_array($field, ['quantity', 'unit_price'])) { + $quantity = (float) $this->lineItems[$index]['quantity']; + $unitPrice = (float) $this->lineItems[$index]['unit_price']; + $this->lineItems[$index]['total'] = $quantity * $unitPrice; + } + } + + public function getSubtotalProperty() + { + return collect($this->lineItems)->sum('total'); + } + + public function getTaxAmountProperty() + { + return $this->subtotal * ($this->tax_rate / 100); + } + + public function getTotalProperty() + { + return $this->subtotal + $this->taxAmount; + } + + public function save() + { + $this->validate(); + + $invoice = Invoice::create([ + 'job_card_id' => $this->estimate->job_card_id, + 'estimate_id' => $this->estimate->id, + 'customer_id' => $this->estimate->customer_id, + 'branch_id' => $this->branch_id, + 'created_by' => auth()->id(), + 'invoice_number' => Invoice::generateInvoiceNumber($this->branch_id), + 'subject' => $this->subject, + 'description' => $this->description, + 'invoice_date' => $this->invoice_date, + 'due_date' => $this->due_date, + 'subtotal' => $this->subtotal, + 'tax_rate' => $this->tax_rate, + 'tax_amount' => $this->taxAmount, + 'total_amount' => $this->total, + 'status' => 'draft', + ]); + + // Create line items + foreach ($this->lineItems as $item) { + $invoice->invoiceLineItems()->create([ + 'type' => $item['type'], + 'service_item_id' => $item['service_item_id'] ?: null, + 'part_id' => $item['part_id'] ?: null, + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'total' => $item['total'], + ]); + } + + session()->flash('success', 'Invoice created successfully from estimate.'); + + return redirect()->route('invoices.show', $invoice); + } + + public function render() + { + return view('livewire.invoices.create-from-estimate') + ->layout('layouts.app'); + } +} diff --git a/app/Livewire/Invoices/Edit.php b/app/Livewire/Invoices/Edit.php new file mode 100644 index 0000000..460cbfb --- /dev/null +++ b/app/Livewire/Invoices/Edit.php @@ -0,0 +1,275 @@ +invoice = $invoice; + + // Check if invoice can be edited + if (in_array($invoice->status, ['paid', 'cancelled'])) { + session()->flash('error', 'This invoice cannot be edited as it is '.$invoice->status.'.'); + + return $this->redirect(route('invoices.show', $invoice)); + } + + // Populate form data + $this->customer_id = $invoice->customer_id; + $this->branch_id = $invoice->branch_id; + $this->invoice_date = $invoice->invoice_date->format('Y-m-d'); + $this->due_date = $invoice->due_date->format('Y-m-d'); + $this->description = $invoice->description; + $this->notes = $invoice->notes; + $this->terms_and_conditions = $invoice->terms_and_conditions; + $this->tax_rate = $invoice->tax_rate; + $this->discount_amount = $invoice->discount_amount; + + // Load line items + $this->line_items = $invoice->lineItems->map(function ($item) { + return [ + 'id' => $item->id, + 'type' => $item->type, + 'description' => $item->description, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'part_id' => $item->part_id, + 'part_number' => $item->part_number, + 'technical_notes' => $item->technical_notes, + ]; + })->toArray(); + + // Load data + $this->customers = Customer::orderBy('first_name')->orderBy('last_name')->get(); + $this->branches = Branch::orderBy('name')->get(); + $this->parts = Part::where('status', 'active')->orderBy('name')->get(); + $this->service_items = ServiceItem::where('status', 'active')->orderBy('service_name')->get(); + + // Add empty line item if none exist + if (empty($this->line_items)) { + $this->addLineItem(); + } + } + + public function addLineItem() + { + $this->line_items[] = [ + 'id' => null, + 'type' => 'labour', + 'description' => '', + 'quantity' => 1, + 'unit_price' => 0, + 'part_id' => '', + 'part_number' => '', + 'technical_notes' => '', + ]; + } + + public function removeLineItem($index) + { + unset($this->line_items[$index]); + $this->line_items = array_values($this->line_items); + } + + public function updatedLineItems($value, $key) + { + // Auto-populate part details when part is selected + if (str_contains($key, 'part_id')) { + $index = explode('.', $key)[0]; + $partId = $this->line_items[$index]['part_id']; + + if ($partId) { + $part = Part::find($partId); + if ($part) { + $this->line_items[$index]['description'] = $part->name; + $this->line_items[$index]['unit_price'] = $part->sell_price ?? 0; + $this->line_items[$index]['part_number'] = $part->part_number; + } + } + } + + // Reset part selection when type changes + if (str_contains($key, 'type')) { + $index = explode('.', $key)[0]; + $this->line_items[$index]['part_id'] = ''; + $this->line_items[$index]['part_number'] = ''; + if ($this->line_items[$index]['type'] !== 'parts') { + $this->line_items[$index]['description'] = ''; + $this->line_items[$index]['unit_price'] = 0; + } + } + + // Auto-populate service item details + if (str_contains($key, 'service_item_id')) { + $index = explode('.', $key)[0]; + $serviceItemId = $this->line_items[$index]['service_item_id'] ?? null; + + if ($serviceItemId) { + $serviceItem = ServiceItem::find($serviceItemId); + if ($serviceItem) { + $this->line_items[$index]['description'] = $serviceItem->service_name; + $this->line_items[$index]['unit_price'] = $serviceItem->price ?? 0; + } + } + } + } + + public function calculateSubtotal() + { + return collect($this->line_items)->sum(function ($item) { + return ($item['quantity'] ?? 0) * ($item['unit_price'] ?? 0); + }); + } + + public function calculateTax() + { + $subtotal = $this->calculateSubtotal() - $this->discount_amount; + + return $subtotal * ($this->tax_rate / 100); + } + + public function calculateTotal() + { + $subtotal = $this->calculateSubtotal(); + $tax = $this->calculateTax(); + + return $subtotal + $tax - $this->discount_amount; + } + + public function save() + { + $this->validate(); + + // Validate line items + if (empty($this->line_items)) { + $this->addError('line_items', 'At least one line item is required.'); + + return; + } + + foreach ($this->line_items as $index => $item) { + if (empty($item['description'])) { + $this->addError("line_items.{$index}.description", 'Description is required.'); + + return; + } + if ($item['quantity'] <= 0) { + $this->addError("line_items.{$index}.quantity", 'Quantity must be greater than 0.'); + + return; + } + if ($item['unit_price'] < 0) { + $this->addError("line_items.{$index}.unit_price", 'Unit price cannot be negative.'); + + return; + } + } + + // Update invoice + $this->invoice->update([ + 'customer_id' => $this->customer_id, + 'branch_id' => $this->branch_id, + 'invoice_date' => $this->invoice_date, + 'due_date' => $this->due_date, + 'description' => $this->description, + 'notes' => $this->notes, + 'terms_and_conditions' => $this->terms_and_conditions, + 'tax_rate' => $this->tax_rate, + 'discount_amount' => $this->discount_amount, + ]); + + // Get existing line item IDs + $existingIds = collect($this->line_items)->pluck('id')->filter()->toArray(); + + // Delete line items not in the current list + $this->invoice->lineItems()->whereNotIn('id', $existingIds)->delete(); + + // Update or create line items + foreach ($this->line_items as $item) { + if ($item['id']) { + // Update existing line item + InvoiceLineItem::where('id', $item['id'])->update([ + 'type' => $item['type'], + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'part_id' => $item['part_id'] ?: null, + 'part_number' => $item['part_number'] ?: null, + 'technical_notes' => $item['technical_notes'] ?: null, + ]); + } else { + // Create new line item + InvoiceLineItem::create([ + 'invoice_id' => $this->invoice->id, + 'type' => $item['type'], + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'part_id' => $item['part_id'] ?: null, + 'part_number' => $item['part_number'] ?: null, + 'technical_notes' => $item['technical_notes'] ?: null, + ]); + } + } + + session()->flash('success', 'Invoice updated successfully.'); + + return $this->redirect(route('invoices.show', $this->invoice)); + } + + #[Layout('components.layouts.app.sidebar')] + public function render() + { + return view('livewire.invoices.edit'); + } +} diff --git a/app/Livewire/Invoices/Index.php b/app/Livewire/Invoices/Index.php new file mode 100644 index 0000000..2858a7d --- /dev/null +++ b/app/Livewire/Invoices/Index.php @@ -0,0 +1,178 @@ + ['except' => ''], + 'filterStatus' => ['except' => ''], + 'filterBranch' => ['except' => ''], + 'sortField' => ['except' => 'invoice_date'], + 'sortDirection' => ['except' => 'desc'], + ]; + + public function mount() + { + // Set default date filter to current month + $this->filterDateFrom = now()->startOfMonth()->format('Y-m-d'); + $this->filterDateTo = now()->endOfMonth()->format('Y-m-d'); + } + + public function updatingSearch() + { + $this->resetPage(); + } + + public function updatingFilterStatus() + { + $this->resetPage(); + } + + public function updatingFilterBranch() + { + $this->resetPage(); + } + + public function sortBy($field) + { + if ($this->sortField === $field) { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + $this->sortField = $field; + $this->sortDirection = 'asc'; + } + $this->resetPage(); + } + + public function clearFilters() + { + $this->reset(['search', 'filterStatus', 'filterBranch', 'filterDateFrom', 'filterDateTo']); + $this->resetPage(); + } + + public function deleteInvoice($invoiceId) + { + $invoice = Invoice::findOrFail($invoiceId); + + // Check permissions + if (! auth()->user()->can('delete', $invoice)) { + session()->flash('error', 'You do not have permission to delete this invoice.'); + + return; + } + + // Only allow deletion of draft invoices + if ($invoice->status !== 'draft') { + session()->flash('error', 'Only draft invoices can be deleted.'); + + return; + } + + $invoice->delete(); + session()->flash('success', 'Invoice deleted successfully.'); + } + + public function markAsPaid($invoiceId) + { + $invoice = Invoice::findOrFail($invoiceId); + + if (! auth()->user()->can('update', $invoice)) { + session()->flash('error', 'You do not have permission to update this invoice.'); + + return; + } + + $invoice->markAsPaid('manual', null, 'Marked as paid from invoice list'); + session()->flash('success', 'Invoice marked as paid.'); + } + + public function sendInvoice($invoiceId) + { + $invoice = Invoice::findOrFail($invoiceId); + + if (! auth()->user()->can('update', $invoice)) { + session()->flash('error', 'You do not have permission to send this invoice.'); + + return; + } + + // This would typically integrate with email service + $invoice->markAsSent('email', $invoice->customer->email); + session()->flash('success', 'Invoice sent successfully.'); + } + + public function getInvoicesProperty() + { + return Invoice::query() + ->with(['customer', 'branch', 'createdBy']) + ->when($this->search, function (Builder $query) { + $query->where(function ($q) { + $q->where('invoice_number', 'like', '%'.$this->search.'%') + ->orWhereHas('customer', function ($customerQuery) { + $customerQuery->where('first_name', 'like', '%'.$this->search.'%') + ->orWhere('last_name', 'like', '%'.$this->search.'%') + ->orWhere('email', 'like', '%'.$this->search.'%') + ->orWhereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", ['%'.$this->search.'%']); + }); + }); + }) + ->when($this->filterStatus, function (Builder $query) { + $query->where('status', $this->filterStatus); + }) + ->when($this->filterBranch, function (Builder $query) { + $query->where('branch_id', $this->filterBranch); + }) + ->when($this->filterDateFrom, function (Builder $query) { + $query->where('invoice_date', '>=', $this->filterDateFrom); + }) + ->when($this->filterDateTo, function (Builder $query) { + $query->where('invoice_date', '<=', $this->filterDateTo); + }) + ->orderBy($this->sortField, $this->sortDirection) + ->paginate(15); + } + + public function getBranchesProperty() + { + return Branch::orderBy('name')->get(); + } + + public function getStatusOptionsProperty() + { + return Invoice::getStatusOptions(); + } + + #[Layout('components.layouts.app.sidebar')] + public function render() + { + return view('livewire.invoices.index', [ + 'invoices' => $this->invoices, + 'branches' => $this->branches, + 'statusOptions' => $this->statusOptions, + ]); + } +} diff --git a/app/Livewire/Invoices/Show.php b/app/Livewire/Invoices/Show.php new file mode 100644 index 0000000..7f72581 --- /dev/null +++ b/app/Livewire/Invoices/Show.php @@ -0,0 +1,171 @@ +invoice = $invoice->load([ + 'customer', + 'branch', + 'createdBy', + 'serviceOrder.vehicle', + 'jobCard.vehicle', + 'estimate', + 'lineItems.part', + 'lineItems.serviceItem', + ]); + } + + public function downloadPDF() + { + try { + // Generate HTML from the template + $html = view('invoices.pdf', ['invoice' => $this->invoice])->render(); + + if (empty($html)) { + throw new \Exception('Failed to generate PDF template'); + } + + // Try different approaches for PDF generation + try { + // Approach 1: Use Laravel's PDF facade if available + if (class_exists('\Barryvdh\DomPDF\Facade\Pdf')) { + $pdf = \Barryvdh\DomPDF\Facade\Pdf::loadHtml($html); + $pdf->setPaper('letter', 'portrait'); + $output = $pdf->output(); + } else { + throw new \Exception('PDF facade not available'); + } + } catch (\Exception $e1) { + try { + // Approach 2: Direct DomPDF instantiation + $dompdf = new \Dompdf\Dompdf; + $dompdf->loadHtml($html); + $dompdf->setPaper('letter', 'portrait'); + $dompdf->render(); + $output = $dompdf->output(); + } catch (\Exception $e2) { + // Approach 3: Return HTML for now + $filename = 'invoice-'.$this->invoice->invoice_number.'.html'; + + return response()->streamDownload(function () use ($html) { + echo $html; + }, $filename, [ + 'Content-Type' => 'text/html', + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', + ]); + } + } + + // Create filename with invoice number + $filename = 'invoice-'.$this->invoice->invoice_number.'.pdf'; + + // Return PDF for download + return response()->streamDownload(function () use ($output) { + echo $output; + }, $filename, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="'.$filename.'"', + ]); + + } catch (\Exception $e) { + // Log the error for debugging + Log::error('PDF generation failed for invoice '.$this->invoice->id, [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + // Show user-friendly error message + session()->flash('error', 'Failed to generate PDF: '.$e->getMessage()); + + return null; + } + } + + public function markAsPaid() + { + if (! auth()->user()->can('update', $this->invoice)) { + session()->flash('error', 'You do not have permission to update this invoice.'); + + return; + } + + $this->invoice->markAsPaid('manual', null, 'Marked as paid from invoice view'); + $this->invoice->refresh(); + session()->flash('success', 'Invoice marked as paid successfully.'); + } + + public function sendInvoice() + { + if (! auth()->user()->can('update', $this->invoice)) { + session()->flash('error', 'You do not have permission to send this invoice.'); + + return; + } + + // This would typically integrate with email service + $this->invoice->markAsSent('email', $this->invoice->customer->email); + $this->invoice->refresh(); + session()->flash('success', 'Invoice sent successfully.'); + } + + public function duplicateInvoice() + { + if (! auth()->user()->can('create', Invoice::class)) { + session()->flash('error', 'You do not have permission to create invoices.'); + + return; + } + + try { + $newInvoice = $this->invoice->replicate(); + $newInvoice->invoice_number = Invoice::generateInvoiceNumber($this->invoice->branch->code); + $newInvoice->status = 'draft'; + $newInvoice->invoice_date = now()->toDateString(); + $newInvoice->due_date = now()->addDays(30)->toDateString(); + $newInvoice->paid_at = null; + $newInvoice->payment_method = null; + $newInvoice->payment_reference = null; + $newInvoice->payment_notes = null; + $newInvoice->sent_at = null; + $newInvoice->sent_method = null; + $newInvoice->sent_to = null; + $newInvoice->created_by = auth()->id(); + $newInvoice->save(); + + // Duplicate line items + foreach ($this->invoice->lineItems as $lineItem) { + $newLineItem = $lineItem->replicate(); + $newLineItem->invoice_id = $newInvoice->id; + $newLineItem->save(); + } + + // Recalculate totals + $newInvoice->recalculateTotals(); + + return redirect()->route('invoices.edit', $newInvoice); + } catch (\Exception $e) { + Log::error('Failed to duplicate invoice', [ + 'invoice_id' => $this->invoice->id, + 'error' => $e->getMessage(), + ]); + + session()->flash('error', 'Failed to duplicate invoice. Please try again.'); + } + } + + #[Layout('components.layouts.app.sidebar')] + public function render() + { + return view('livewire.invoices.show'); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index cf82373..a4924c0 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Notifications\Notifiable; class Customer extends Model { /** @use HasFactory<\Database\Factories\CustomerFactory> */ - use HasFactory; + use HasFactory, Notifiable; protected $fillable = [ 'user_id', diff --git a/app/Models/Estimate.php.backup b/app/Models/Estimate.php.backup deleted file mode 100644 index b6c9632..0000000 --- a/app/Models/Estimate.php.backup +++ /dev/null @@ -1,158 +0,0 @@ - 'decimal:2', - 'parts_cost' => 'decimal:2', - 'miscellaneous_cost' => 'decimal:2', - 'subtotal' => 'decimal:2', - 'tax_rate' => 'decimal:4', - 'tax_amount' => 'decimal:2', - 'discount_amount' => 'decimal:2', - 'total_amount' => 'decimal:2', - 'customer_approved_at' => 'datetime', - 'sent_to_customer_at' => 'datetime', - 'sms_sent_at' => 'datetime', - 'email_sent_at' => 'datetime', - ]; - - protected static function boot() - { - parent::boot(); - - static::creating(function ($estimate) { - if (empty($estimate->estimate_number)) { - $estimate->estimate_number = 'EST-'.date('Y').'-'.str_pad( - static::whereYear('created_at', now()->year)->count() + 1, - 6, - '0', - STR_PAD_LEFT - ); - } - }); - } - - public function jobCard(): BelongsTo - { - return $this->belongsTo(JobCard::class); - } - - public function diagnosis(): BelongsTo - { - return $this->belongsTo(Diagnosis::class); - } - - public function preparedBy(): BelongsTo - { - return $this->belongsTo(User::class, 'prepared_by_id'); - } - - public function customer(): BelongsTo - { - return $this->belongsTo(Customer::class); - } - - public function vehicle(): BelongsTo - { - return $this->belongsTo(Vehicle::class); - } - - public function lineItems(): HasMany - { - return $this->hasMany(EstimateLineItem::class); - } - - public function originalEstimate(): BelongsTo - { - return $this->belongsTo(Estimate::class, 'original_estimate_id'); - } - - public function revisions(): HasMany - { - return $this->hasMany(Estimate::class, 'original_estimate_id'); - } - - public function workOrders(): HasMany - { - return $this->hasMany(WorkOrder::class); - } - - public function getValidUntilAttribute() - { - return $this->created_at->addDays($this->validity_period_days); - } - - public function getIsExpiredAttribute() - { - return $this->valid_until < now() && $this->status !== 'approved'; - } - - public function calculateTotals(): void - { - $this->labor_cost = $this->lineItems()->where('type', 'labor')->sum('total_amount'); - $this->parts_cost = $this->lineItems()->where('type', 'parts')->sum('total_amount'); - $this->miscellaneous_cost = $this->lineItems()->where('type', 'miscellaneous')->sum('total_amount'); - - $this->subtotal = $this->labor_cost + $this->parts_cost + $this->miscellaneous_cost - $this->discount_amount; - $this->tax_amount = $this->subtotal * ($this->tax_rate / 100); - $this->total_amount = $this->subtotal + $this->tax_amount; - - $this->save(); - } - - /** - * Get the customer for this estimate (either direct or through job card) - */ - public function getEstimateCustomerAttribute() - { - return $this->customer_id ? $this->customer : $this->jobCard?->customer; - } - - /** - * Get the vehicle for this estimate (either direct or through job card) - */ - public function getEstimateVehicleAttribute() - { - return $this->vehicle_id ? $this->vehicle : $this->jobCard?->vehicle; - } -} diff --git a/app/Models/Estimate.php.broken b/app/Models/Estimate.php.broken deleted file mode 100644 index 02ffed2..0000000 --- a/app/Models/Estimate.php.broken +++ /dev/null @@ -1,159 +0,0 @@ - 'decimal:2', - 'parts_cost' => 'decimal:2', - 'miscellaneous_cost' => 'decimal:2', - 'subtotal' => 'decimal:2', - 'tax_rate' => 'decimal:4', - 'tax_amount' => 'decimal:2', - 'discount_amount' => 'decimal:2', - 'total_amount' => 'decimal:2', - 'customer_approved_at' => 'datetime', - 'sent_to_customer_at' => 'datetime', - 'sms_sent_at' => 'datetime', - 'email_sent_at' => 'datetime', - ]; - - protected static function boot() - { - parent::boot(); - - static::creating(function ($estimate) { - if (empty($estimate->estimate_number)) { - $estimate->estimate_number = 'EST-'.date('Y').'-'.str_pad( - static::whereYear('created_at', now()->year)->count() + 1, - 6, - '0', - STR_PAD_LEFT - ); - } - }); - } - - public function jobCard(): BelongsTo - { - return $this->belongsTo(JobCard::class); - } - - public function diagnosis(): BelongsTo - { - return $this->belongsTo(Diagnosis::class); - } - - public function preparedBy(): BelongsTo - { - return $this->belongsTo(User::class, 'prepared_by_id'); - } - - // Core relationships for standalone estimates - public function customer(): BelongsTo - { - return $this->belongsTo(Customer::class); - } - - public function vehicle(): BelongsTo - { - return $this->belongsTo(Vehicle::class); - } - - public function lineItems(): HasMany - { - return $this->hasMany(EstimateLineItem::class); - } - - public function originalEstimate(): BelongsTo - { - return $this->belongsTo(Estimate::class, 'original_estimate_id'); - } - - public function revisions(): HasMany - { - return $this->hasMany(Estimate::class, 'original_estimate_id'); - } - - public function workOrders(): HasMany - { - return $this->hasMany(WorkOrder::class); - } - - public function getValidUntilAttribute() - { - return $this->created_at->addDays($this->validity_period_days); - } - - public function getIsExpiredAttribute() - { - return $this->valid_until < now() && $this->status !== 'approved'; - } - - public function calculateTotals(): void - { - $this->labor_cost = $this->lineItems()->where('type', 'labor')->sum('total_amount'); - $this->parts_cost = $this->lineItems()->where('type', 'parts')->sum('total_amount'); - $this->miscellaneous_cost = $this->lineItems()->where('type', 'miscellaneous')->sum('total_amount'); - - $this->subtotal = $this->labor_cost + $this->parts_cost + $this->miscellaneous_cost - $this->discount_amount; - $this->tax_amount = $this->subtotal * ($this->tax_rate / 100); - $this->total_amount = $this->subtotal + $this->tax_amount; - - $this->save(); - } - - /** - * Get the customer for this estimate (either direct or through job card) - */ - public function getEstimateCustomerAttribute() - { - return $this->customer_id ? $this->customer : $this->jobCard?->customer; - } - - /** - * Get the vehicle for this estimate (either direct or through job card) - */ - public function getEstimateVehicleAttribute() - { - return $this->vehicle_id ? $this->vehicle : $this->jobCard?->vehicle; - } -} diff --git a/app/Models/EstimateLineItem.php b/app/Models/EstimateLineItem.php index 926dd4b..0d6e6c6 100644 --- a/app/Models/EstimateLineItem.php +++ b/app/Models/EstimateLineItem.php @@ -12,7 +12,7 @@ class EstimateLineItem extends Model protected $fillable = [ 'estimate_id', - 'type', // 'labor', 'parts', 'miscellaneous' + 'type', // 'labour', 'parts', 'miscellaneous' 'part_id', 'description', 'quantity', diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php new file mode 100644 index 0000000..5c016a0 --- /dev/null +++ b/app/Models/Invoice.php @@ -0,0 +1,268 @@ + 'date', + 'due_date' => 'date', + 'paid_at' => 'datetime', + 'sent_at' => 'datetime', + 'subtotal' => 'decimal:2', + 'tax_rate' => 'decimal:2', + 'tax_amount' => 'decimal:2', + 'discount_amount' => 'decimal:2', + 'total_amount' => 'decimal:2', + ]; + + /** + * Generate the next invoice number for a branch + */ + public static function generateInvoiceNumber(string $branchCode = 'MAIN'): string + { + $year = now()->year; + $prefix = strtoupper($branchCode).'-INV-'.$year.'-'; + + $lastInvoice = static::where('invoice_number', 'like', $prefix.'%') + ->orderBy('invoice_number', 'desc') + ->first(); + + if ($lastInvoice) { + $lastNumber = (int) substr($lastInvoice->invoice_number, strlen($prefix)); + $nextNumber = $lastNumber + 1; + } else { + $nextNumber = 1; + } + + return $prefix.str_pad($nextNumber, 5, '0', STR_PAD_LEFT); + } + + /** + * Status options for invoices + */ + public static function getStatusOptions(): array + { + return [ + 'draft' => 'Draft', + 'sent' => 'Sent', + 'paid' => 'Paid', + 'overdue' => 'Overdue', + 'cancelled' => 'Cancelled', + ]; + } + + /** + * Payment method options + */ + public static function getPaymentMethodOptions(): array + { + return [ + 'cash' => 'Cash', + 'card' => 'Credit/Debit Card', + 'check' => 'Check', + 'bank_transfer' => 'Bank Transfer', + 'other' => 'Other', + ]; + } + + /** + * Check if invoice is overdue + */ + public function isOverdue(): bool + { + return $this->status !== 'paid' && + $this->status !== 'cancelled' && + $this->due_date < now()->startOfDay(); + } + + /** + * Check if invoice is paid + */ + public function isPaid(): bool + { + return $this->status === 'paid' && ! is_null($this->paid_at); + } + + /** + * Mark invoice as paid + */ + public function markAsPaid(?string $paymentMethod = null, ?string $reference = null, ?string $notes = null): void + { + $this->update([ + 'status' => 'paid', + 'paid_at' => now(), + 'payment_method' => $paymentMethod, + 'payment_reference' => $reference, + 'payment_notes' => $notes, + ]); + } + + /** + * Mark invoice as sent + */ + public function markAsSent(string $method = 'email', ?string $sentTo = null): void + { + $this->update([ + 'status' => 'sent', + 'sent_at' => now(), + 'sent_method' => $method, + 'sent_to' => $sentTo, + ]); + } + + /** + * Calculate totals from line items + */ + public function recalculateTotals(): void + { + $subtotal = $this->lineItems()->sum('total_amount'); + $taxAmount = $subtotal * ($this->tax_rate / 100); + $total = $subtotal + $taxAmount - $this->discount_amount; + + $this->update([ + 'subtotal' => $subtotal, + 'tax_amount' => $taxAmount, + 'total_amount' => $total, + ]); + } + + // Relationships + + /** + * Invoice belongs to a customer + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * Invoice belongs to a branch + */ + public function branch(): BelongsTo + { + return $this->belongsTo(Branch::class); + } + + /** + * Invoice belongs to a user (creator) + */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Invoice may belong to a service order + */ + public function serviceOrder(): BelongsTo + { + return $this->belongsTo(ServiceOrder::class); + } + + /** + * Invoice may belong to a job card + */ + public function jobCard(): BelongsTo + { + return $this->belongsTo(JobCard::class); + } + + /** + * Invoice may belong to an estimate + */ + public function estimate(): BelongsTo + { + return $this->belongsTo(Estimate::class); + } + + /** + * Invoice has many line items + */ + public function lineItems(): HasMany + { + return $this->hasMany(InvoiceLineItem::class); + } + + // Scopes + + /** + * Scope to filter by branch + */ + public function scopeByBranch($query, string $branchCode) + { + return $query->whereHas('branch', function ($q) use ($branchCode) { + $q->where('code', $branchCode); + }); + } + + /** + * Scope to filter by status + */ + public function scopeByStatus($query, string $status) + { + return $query->where('status', $status); + } + + /** + * Scope to get overdue invoices + */ + public function scopeOverdue($query) + { + return $query->where('status', '!=', 'paid') + ->where('status', '!=', 'cancelled') + ->where('due_date', '<', now()->startOfDay()); + } + + /** + * Scope to get paid invoices + */ + public function scopePaid($query) + { + return $query->where('status', 'paid'); + } + + /** + * Scope to get unpaid invoices + */ + public function scopeUnpaid($query) + { + return $query->whereIn('status', ['draft', 'sent', 'overdue']); + } +} diff --git a/app/Models/InvoiceLineItem.php b/app/Models/InvoiceLineItem.php new file mode 100644 index 0000000..cd71209 --- /dev/null +++ b/app/Models/InvoiceLineItem.php @@ -0,0 +1,96 @@ + 'decimal:2', + 'unit_price' => 'decimal:2', + 'total_amount' => 'decimal:2', + ]; + + /** + * Type options for line items + */ + public static function getTypeOptions(): array + { + return [ + 'labour' => 'Labour', + 'parts' => 'Parts', + 'miscellaneous' => 'Miscellaneous', + ]; + } + + /** + * Calculate total amount when quantity or unit price changes + */ + public function calculateTotal(): float + { + return $this->quantity * $this->unit_price; + } + + protected static function boot() + { + parent::boot(); + + static::saving(function ($lineItem) { + $lineItem->total_amount = $lineItem->calculateTotal(); + }); + + static::saved(function ($lineItem) { + // Recalculate invoice totals when line item changes + $lineItem->invoice->recalculateTotals(); + }); + + static::deleted(function ($lineItem) { + // Recalculate invoice totals when line item is deleted + $lineItem->invoice->recalculateTotals(); + }); + } + + // Relationships + + /** + * Line item belongs to an invoice + */ + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + /** + * Line item may belong to a part + */ + public function part(): BelongsTo + { + return $this->belongsTo(Part::class); + } + + /** + * Line item may belong to a service item + */ + public function serviceItem(): BelongsTo + { + return $this->belongsTo(ServiceItem::class); + } +} diff --git a/app/Models/PartHistory.php b/app/Models/PartHistory.php index 0ec52ec..4fd65f1 100644 --- a/app/Models/PartHistory.php +++ b/app/Models/PartHistory.php @@ -40,13 +40,21 @@ class PartHistory extends Model // Event types const EVENT_CREATED = 'created'; + const EVENT_UPDATED = 'updated'; + const EVENT_STOCK_IN = 'stock_in'; + const EVENT_STOCK_OUT = 'stock_out'; + const EVENT_ADJUSTMENT = 'adjustment'; + const EVENT_PRICE_CHANGE = 'price_change'; + const EVENT_SUPPLIER_CHANGE = 'supplier_change'; + const EVENT_DELETED = 'deleted'; + const EVENT_RESTORED = 'restored'; public function part(): BelongsTo @@ -61,7 +69,7 @@ class PartHistory extends Model public function getEventColorAttribute(): string { - return match($this->event_type) { + return match ($this->event_type) { self::EVENT_CREATED => 'green', self::EVENT_UPDATED => 'blue', self::EVENT_STOCK_IN => 'green', @@ -77,7 +85,7 @@ class PartHistory extends Model public function getEventIconAttribute(): string { - return match($this->event_type) { + return match ($this->event_type) { self::EVENT_CREATED => 'plus-circle', self::EVENT_UPDATED => 'pencil', self::EVENT_STOCK_IN => 'arrow-down', @@ -98,7 +106,8 @@ class PartHistory extends Model } $prefix = $this->quantity_change > 0 ? '+' : ''; - return $prefix . number_format($this->quantity_change); + + return $prefix.number_format($this->quantity_change); } public static function logEvent( @@ -121,7 +130,7 @@ class PartHistory extends Model 'notes' => $options['notes'] ?? null, 'ip_address' => request()->ip(), 'user_agent' => request()->userAgent(), - 'created_by' => auth()->id() ?? 1, + 'created_by' => auth()->id() ?? \App\Models\User::first()?->id ?? null, ]); } } diff --git a/app/Policies/EstimatePolicy.php b/app/Policies/EstimatePolicy.php index bceb3dc..fcc8de0 100644 --- a/app/Policies/EstimatePolicy.php +++ b/app/Policies/EstimatePolicy.php @@ -26,7 +26,15 @@ class EstimatePolicy */ public function view(User $user, Estimate $estimate): bool { - return false; + // Super admin has global access + if ($user->hasRole('super_admin')) { + return true; + } + + // Service coordinators, supervisors, and admins can view estimates in their branch + // Or if they created the estimate + return $user->hasAnyRole(['service_coordinator', 'service_supervisor', 'admin'], $user->branch_code) || + $estimate->prepared_by_id === $user->id; } /** @@ -48,7 +56,19 @@ class EstimatePolicy */ public function update(User $user, Estimate $estimate): bool { - return false; + // Super admin has global access + if ($user->hasRole('super_admin')) { + return true; + } + + // Service coordinators, supervisors, and admins can update estimates in their branch + // Or if they created the estimate (and it's still in draft status) + if ($user->hasAnyRole(['service_coordinator', 'service_supervisor', 'admin'], $user->branch_code)) { + return true; + } + + // Creator can edit their own draft estimates + return $estimate->prepared_by_id === $user->id && $estimate->status === 'draft'; } /** @@ -56,7 +76,18 @@ class EstimatePolicy */ public function delete(User $user, Estimate $estimate): bool { - return false; + // Super admin has global access + if ($user->hasRole('super_admin')) { + return true; + } + + // Service supervisors and admins can delete estimates in their branch + if ($user->hasAnyRole(['service_supervisor', 'admin'], $user->branch_code)) { + return true; + } + + // Creator can delete their own draft estimates + return $estimate->prepared_by_id === $user->id && $estimate->status === 'draft'; } /** diff --git a/composer.json b/composer.json index 3baf30a..9bbcdc0 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "license": "MIT", "require": { "php": "^8.2", + "barryvdh/laravel-dompdf": "^3.1", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.1.1", diff --git a/composer.lock b/composer.lock index 8f4d543..e388dcf 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,85 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "86ce39c32f1ba24deda1b62e36de786b", + "content-hash": "7f82283c737928e3e0f4cf0046dee15c", "packages": [ + { + "name": "barryvdh/laravel-dompdf", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-dompdf.git", + "reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d", + "reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d", + "shasum": "" + }, + "require": { + "dompdf/dompdf": "^3.0", + "illuminate/support": "^9|^10|^11|^12", + "php": "^8.1" + }, + "require-dev": { + "larastan/larastan": "^2.7|^3.0", + "orchestra/testbench": "^7|^8|^9|^10", + "phpro/grumphp": "^2.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf", + "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf" + }, + "providers": [ + "Barryvdh\\DomPDF\\ServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\DomPDF\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "A DOMPDF Wrapper for Laravel", + "keywords": [ + "dompdf", + "laravel", + "pdf" + ], + "support": { + "issues": "https://github.com/barryvdh/laravel-dompdf/issues", + "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-02-13T15:07:54+00:00" + }, { "name": "brick/math", "version": "0.13.1", @@ -426,6 +503,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.0", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "a51bd7a063a65499446919286fb18b518177155a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/a51bd7a063a65499446919286fb18b518177155a", + "reference": "a51bd7a063a65499446919286fb18b518177155a", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.0" + }, + "time": "2025-01-15T14:09:04+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.1" + }, + "time": "2024-12-02T14:37:59+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0" + }, + "time": "2024-04-29T13:26:35+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.4.0", @@ -2265,6 +2497,73 @@ }, "time": "2025-04-08T15:13:36+00:00" }, + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, { "name": "monolog/monolog", "version": "3.9.0", @@ -3688,6 +3987,72 @@ }, "time": "2025-06-25T14:20:11+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v8.9.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d8e916507b88e389e26d4ab03c904a082aa66bb9", + "reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "require-dev": { + "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41", + "rawr/cross-data-providers": "^2.0.0" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.9.0" + }, + "time": "2025-07-11T13:20:48+00:00" + }, { "name": "spatie/laravel-activitylog", "version": "4.10.2", diff --git a/config/dompdf.php b/config/dompdf.php new file mode 100644 index 0000000..35eef8f --- /dev/null +++ b/config/dompdf.php @@ -0,0 +1,301 @@ + false, // Throw an Exception on warnings from dompdf + + 'public_path' => null, // Override the public path if needed + + /* + * Dejavu Sans font is missing glyphs for converted entities, turn it off if you need to show € and £. + */ + 'convert_entities' => true, + + 'options' => [ + /** + * The location of the DOMPDF font directory + * + * The location of the directory where DOMPDF will store fonts and font metrics + * Note: This directory must exist and be writable by the webserver process. + * *Please note the trailing slash.* + * + * Notes regarding fonts: + * Additional .afm font metrics can be added by executing load_font.php from command line. + * + * Only the original "Base 14 fonts" are present on all pdf viewers. Additional fonts must + * be embedded in the pdf file or the PDF may not display correctly. This can significantly + * increase file size unless font subsetting is enabled. Before embedding a font please + * review your rights under the font license. + * + * Any font specification in the source HTML is translated to the closest font available + * in the font directory. + * + * The pdf standard "Base 14 fonts" are: + * Courier, Courier-Bold, Courier-BoldOblique, Courier-Oblique, + * Helvetica, Helvetica-Bold, Helvetica-BoldOblique, Helvetica-Oblique, + * Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic, + * Symbol, ZapfDingbats. + */ + 'font_dir' => storage_path('fonts'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782) + + /** + * The location of the DOMPDF font cache directory + * + * This directory contains the cached font metrics for the fonts used by DOMPDF. + * This directory can be the same as DOMPDF_FONT_DIR + * + * Note: This directory must exist and be writable by the webserver process. + */ + 'font_cache' => storage_path('fonts'), + + /** + * The location of a temporary directory. + * + * The directory specified must be writeable by the webserver process. + * The temporary directory is required to download remote images and when + * using the PDFLib back end. + */ + 'temp_dir' => sys_get_temp_dir(), + + /** + * ==== IMPORTANT ==== + * + * dompdf's "chroot": Prevents dompdf from accessing system files or other + * files on the webserver. All local files opened by dompdf must be in a + * subdirectory of this directory. DO NOT set it to '/' since this could + * allow an attacker to use dompdf to read any files on the server. This + * should be an absolute path. + * This is only checked on command line call by dompdf.php, but not by + * direct class use like: + * $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output(); + */ + 'chroot' => realpath(base_path()), + + /** + * Protocol whitelist + * + * Protocols and PHP wrappers allowed in URIs, and the validation rules + * that determine if a resouce may be loaded. Full support is not guaranteed + * for the protocols/wrappers specified + * by this array. + * + * @var array + */ + 'allowed_protocols' => [ + 'data://' => ['rules' => []], + 'file://' => ['rules' => []], + 'http://' => ['rules' => []], + 'https://' => ['rules' => []], + ], + + /** + * Operational artifact (log files, temporary files) path validation + */ + 'artifactPathValidation' => null, + + /** + * @var string + */ + 'log_output_file' => null, + + /** + * Whether to enable font subsetting or not. + */ + 'enable_font_subsetting' => false, + + /** + * The PDF rendering backend to use + * + * Valid settings are 'PDFLib', 'CPDF' (the bundled R&OS PDF class), 'GD' and + * 'auto'. 'auto' will look for PDFLib and use it if found, or if not it will + * fall back on CPDF. 'GD' renders PDFs to graphic files. + * {@link * Canvas_Factory} ultimately determines which rendering class to + * instantiate based on this setting. + * + * Both PDFLib & CPDF rendering backends provide sufficient rendering + * capabilities for dompdf, however additional features (e.g. object, + * image and font support, etc.) differ between backends. Please see + * {@link PDFLib_Adapter} for more information on the PDFLib backend + * and {@link CPDF_Adapter} and lib/class.pdf.php for more information + * on CPDF. Also see the documentation for each backend at the links + * below. + * + * The GD rendering backend is a little different than PDFLib and + * CPDF. Several features of CPDF and PDFLib are not supported or do + * not make any sense when creating image files. For example, + * multiple pages are not supported, nor are PDF 'objects'. Have a + * look at {@link GD_Adapter} for more information. GD support is + * experimental, so use it at your own risk. + * + * @link http://www.pdflib.com + * @link http://www.ros.co.nz/pdf + * @link http://www.php.net/image + */ + 'pdf_backend' => 'CPDF', + + /** + * html target media view which should be rendered into pdf. + * List of types and parsing rules for future extensions: + * http://www.w3.org/TR/REC-html40/types.html + * screen, tty, tv, projection, handheld, print, braille, aural, all + * Note: aural is deprecated in CSS 2.1 because it is replaced by speech in CSS 3. + * Note, even though the generated pdf file is intended for print output, + * the desired content might be different (e.g. screen or projection view of html file). + * Therefore allow specification of content here. + */ + 'default_media_type' => 'screen', + + /** + * The default paper size. + * + * North America standard is "letter"; other countries generally "a4" + * + * @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.) + */ + 'default_paper_size' => 'a4', + + /** + * The default paper orientation. + * + * The orientation of the page (portrait or landscape). + * + * @var string + */ + 'default_paper_orientation' => 'portrait', + + /** + * The default font family + * + * Used if no suitable fonts can be found. This must exist in the font folder. + * + * @var string + */ + 'default_font' => 'serif', + + /** + * Image DPI setting + * + * This setting determines the default DPI setting for images and fonts. The + * DPI may be overridden for inline images by explictly setting the + * image's width & height style attributes (i.e. if the image's native + * width is 600 pixels and you specify the image's width as 72 points, + * the image will have a DPI of 600 in the rendered PDF. The DPI of + * background images can not be overridden and is controlled entirely + * via this parameter. + * + * For the purposes of DOMPDF, pixels per inch (PPI) = dots per inch (DPI). + * If a size in html is given as px (or without unit as image size), + * this tells the corresponding size in pt. + * This adjusts the relative sizes to be similar to the rendering of the + * html page in a reference browser. + * + * In pdf, always 1 pt = 1/72 inch + * + * Rendering resolution of various browsers in px per inch: + * Windows Firefox and Internet Explorer: + * SystemControl->Display properties->FontResolution: Default:96, largefonts:120, custom:? + * Linux Firefox: + * about:config *resolution: Default:96 + * (xorg screen dimension in mm and Desktop font dpi settings are ignored) + * + * Take care about extra font/image zoom factor of browser. + * + * In images, size in pixel attribute, img css style, are overriding + * the real image dimension in px for rendering. + * + * @var int + */ + 'dpi' => 96, + + /** + * Enable embedded PHP + * + * If this setting is set to true then DOMPDF will automatically evaluate embedded PHP contained + * within tags. + * + * ==== IMPORTANT ==== Enabling this for documents you do not trust (e.g. arbitrary remote html pages) + * is a security risk. + * Embedded scripts are run with the same level of system access available to dompdf. + * Set this option to false (recommended) if you wish to process untrusted documents. + * This setting may increase the risk of system exploit. + * Do not change this settings without understanding the consequences. + * Additional documentation is available on the dompdf wiki at: + * https://github.com/dompdf/dompdf/wiki + * + * @var bool + */ + 'enable_php' => false, + + /** + * Rnable inline JavaScript + * + * If this setting is set to true then DOMPDF will automatically insert JavaScript code contained + * within tags as written into the PDF. + * NOTE: This is PDF-based JavaScript to be executed by the PDF viewer, + * not browser-based JavaScript executed by Dompdf. + * + * @var bool + */ + 'enable_javascript' => true, + + /** + * Enable remote file access + * + * If this setting is set to true, DOMPDF will access remote sites for + * images and CSS files as required. + * + * ==== IMPORTANT ==== + * This can be a security risk, in particular in combination with isPhpEnabled and + * allowing remote html code to be passed to $dompdf = new DOMPDF(); $dompdf->load_html(...); + * This allows anonymous users to download legally doubtful internet content which on + * tracing back appears to being downloaded by your server, or allows malicious php code + * in remote html pages to be executed by your server with your account privileges. + * + * This setting may increase the risk of system exploit. Do not change + * this settings without understanding the consequences. Additional + * documentation is available on the dompdf wiki at: + * https://github.com/dompdf/dompdf/wiki + * + * @var bool + */ + 'enable_remote' => false, + + /** + * List of allowed remote hosts + * + * Each value of the array must be a valid hostname. + * + * This will be used to filter which resources can be loaded in combination with + * isRemoteEnabled. If enable_remote is FALSE, then this will have no effect. + * + * Leave to NULL to allow any remote host. + * + * @var array|null + */ + 'allowed_remote_hosts' => null, + + /** + * A ratio applied to the fonts height to be more like browsers' line height + */ + 'font_height_ratio' => 1.1, + + /** + * Use the HTML5 Lib parser + * + * @deprecated This feature is now always on in dompdf 2.x + * + * @var bool + */ + 'enable_html5_parser' => true, + ], + +]; diff --git a/database/migrations/2025_08_15_115824_update_estimate_line_items_type_enum_to_british_spelling.php b/database/migrations/2025_08_15_115824_update_estimate_line_items_type_enum_to_british_spelling.php new file mode 100644 index 0000000..f907a91 --- /dev/null +++ b/database/migrations/2025_08_15_115824_update_estimate_line_items_type_enum_to_british_spelling.php @@ -0,0 +1,31 @@ +id(); + $table->string('invoice_number')->unique(); + $table->string('status')->default('draft'); // draft, sent, paid, overdue, cancelled + + // Relationships + $table->foreignId('customer_id')->constrained()->onDelete('cascade'); + $table->foreignId('service_order_id')->nullable()->constrained()->onDelete('set null'); + $table->foreignId('job_card_id')->nullable()->constrained()->onDelete('set null'); + $table->foreignId('estimate_id')->nullable()->constrained()->onDelete('set null'); + $table->foreignId('branch_id')->constrained()->onDelete('cascade'); + $table->foreignId('created_by')->constrained('users')->onDelete('cascade'); + + // Invoice Details + $table->date('invoice_date'); + $table->date('due_date'); + $table->text('description')->nullable(); + $table->text('notes')->nullable(); + $table->text('terms_and_conditions')->nullable(); + + // Financial Information + $table->decimal('subtotal', 10, 2)->default(0); + $table->decimal('tax_rate', 5, 2)->default(0); + $table->decimal('tax_amount', 10, 2)->default(0); + $table->decimal('discount_amount', 10, 2)->default(0); + $table->decimal('total_amount', 10, 2)->default(0); + + // Payment Information + $table->timestamp('paid_at')->nullable(); + $table->string('payment_method')->nullable(); // cash, card, check, bank_transfer + $table->string('payment_reference')->nullable(); + $table->text('payment_notes')->nullable(); + + // Delivery Information + $table->timestamp('sent_at')->nullable(); + $table->string('sent_method')->nullable(); // email, print, portal + $table->string('sent_to')->nullable(); // email address or delivery method + + $table->timestamps(); + + // Indexes + $table->index(['customer_id', 'status']); + $table->index(['branch_id', 'invoice_date']); + $table->index(['status', 'due_date']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('invoices'); + } +}; diff --git a/database/migrations/2025_08_15_134655_create_invoice_line_items_table.php b/database/migrations/2025_08_15_134655_create_invoice_line_items_table.php new file mode 100644 index 0000000..90f6a35 --- /dev/null +++ b/database/migrations/2025_08_15_134655_create_invoice_line_items_table.php @@ -0,0 +1,45 @@ +id(); + $table->foreignId('invoice_id')->constrained()->onDelete('cascade'); + + // Line Item Details + $table->string('type')->default('labour'); // labour, parts, miscellaneous + $table->string('description'); + $table->decimal('quantity', 8, 2)->default(1); + $table->decimal('unit_price', 10, 2); + $table->decimal('total_amount', 10, 2); + + // Optional References + $table->foreignId('part_id')->nullable()->constrained()->onDelete('set null'); + $table->foreignId('service_item_id')->nullable()->constrained()->onDelete('set null'); + $table->string('part_number')->nullable(); + $table->text('technical_notes')->nullable(); + + $table->timestamps(); + + // Indexes + $table->index(['invoice_id', 'type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('invoice_line_items'); + } +}; diff --git a/database/seeders/InvoiceSeeder.php b/database/seeders/InvoiceSeeder.php new file mode 100644 index 0000000..514f61f --- /dev/null +++ b/database/seeders/InvoiceSeeder.php @@ -0,0 +1,111 @@ +get(); + $branch = Branch::first(); + $user = User::first(); + $parts = Part::take(5)->get(); + + if ($customers->isEmpty() || ! $branch || ! $user) { + $this->command->warn('Skipping invoice seeder - missing required data (customers, branch, or user)'); + + return; + } + + // Create sample invoices + foreach ($customers as $index => $customer) { + $invoice = Invoice::create([ + 'invoice_number' => Invoice::generateInvoiceNumber($branch->code), + 'status' => collect(['draft', 'sent', 'paid', 'overdue'])->random(), + 'customer_id' => $customer->id, + 'branch_id' => $branch->id, + 'created_by' => $user->id, + 'invoice_date' => now()->subDays(rand(0, 30)), + 'due_date' => now()->addDays(rand(15, 45)), + 'description' => 'Vehicle maintenance and repair services', + 'notes' => $index % 3 === 0 ? 'Customer requested expedited service. All work completed as specified.' : null, + 'terms_and_conditions' => 'Payment due within 30 days. Late payments subject to 1.5% monthly service charge.', + 'tax_rate' => 8.50, + 'discount_amount' => $index % 4 === 0 ? rand(10, 50) : 0, + ]); + + // Add line items + $numItems = rand(2, 5); + for ($i = 0; $i < $numItems; $i++) { + $type = collect(['labour', 'parts', 'miscellaneous'])->random(); + $quantity = rand(1, 3); + $unitPrice = match ($type) { + 'labour' => rand(75, 150), + 'parts' => rand(25, 300), + 'miscellaneous' => rand(10, 75), + }; + + $part = $parts->random(); + + InvoiceLineItem::create([ + 'invoice_id' => $invoice->id, + 'type' => $type, + 'description' => match ($type) { + 'labour' => collect([ + 'Brake system inspection and repair', + 'Engine diagnostic and tune-up', + 'Transmission service and fluid change', + 'Air conditioning system service', + 'Tire rotation and alignment', + 'Oil change and filter replacement', + ])->random(), + 'parts' => $part->name ?? 'Replacement part', + 'miscellaneous' => collect([ + 'Shop supplies and consumables', + 'Environmental disposal fee', + 'Vehicle inspection fee', + 'Diagnostic software fee', + ])->random(), + }, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'total_amount' => $quantity * $unitPrice, + 'part_id' => $type === 'parts' ? $part->id : null, + 'part_number' => $type === 'parts' ? $part->part_number : null, + 'technical_notes' => $type === 'labour' && $i === 0 ? 'Required specialized diagnostic equipment' : null, + ]); + } + + // Recalculate totals + $invoice->recalculateTotals(); + + // Mark some invoices as paid + if ($invoice->status === 'paid') { + $invoice->markAsPaid( + collect(['cash', 'card', 'check', 'bank_transfer'])->random(), + 'REF-'.strtoupper(\Illuminate\Support\Str::random(6)), + 'Payment processed successfully' + ); + } + + // Mark some invoices as sent + if (in_array($invoice->status, ['sent', 'paid', 'overdue'])) { + $invoice->markAsSent('email', $customer->email); + } + } + + $this->command->info('Created '.$customers->count().' sample invoices with line items'); + } +} diff --git a/database/seeders/RolesAndPermissionsSeeder.php b/database/seeders/RolesAndPermissionsSeeder.php index ee207b2..1ae8e29 100644 --- a/database/seeders/RolesAndPermissionsSeeder.php +++ b/database/seeders/RolesAndPermissionsSeeder.php @@ -2,8 +2,8 @@ namespace Database\Seeders; -use App\Models\Role; use App\Models\Permission; +use App\Models\Role; use App\Models\User; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; @@ -122,6 +122,15 @@ class RolesAndPermissionsSeeder extends Seeder ['name' => 'diagnosis.create', 'display_name' => 'Create Diagnosis', 'description' => 'Can create diagnosis reports'], ], + // Invoices + 'invoices' => [ + ['name' => 'invoices.view', 'display_name' => 'View Invoices', 'description' => 'Can view invoices'], + ['name' => 'invoices.create', 'display_name' => 'Create Invoices', 'description' => 'Can create new invoices'], + ['name' => 'invoices.update', 'display_name' => 'Edit Invoices', 'description' => 'Can edit invoices'], + ['name' => 'invoices.send', 'display_name' => 'Send Invoices', 'description' => 'Can send invoices to customers'], + ['name' => 'invoices.payment', 'display_name' => 'Record Payments', 'description' => 'Can record invoice payments'], + ], + // Reports & Analytics 'reports' => [ ['name' => 'reports.view', 'display_name' => 'View Reports', 'description' => 'Can view business reports'], @@ -160,7 +169,7 @@ class RolesAndPermissionsSeeder extends Seeder 'name' => 'super_admin', 'display_name' => 'Super Administrator', 'description' => 'Full system access with all permissions', - 'permissions' => 'all' // Special case - gets all permissions + 'permissions' => 'all', // Special case - gets all permissions ], [ 'name' => 'manager', @@ -178,9 +187,10 @@ class RolesAndPermissionsSeeder extends Seeder 'technicians.view', 'technicians.create', 'technicians.update', 'technicians.view-performance', 'technicians.schedules', 'inspections.view', 'inspections.create', 'inspections.update', 'inspections.approve', 'inspections.reschedule', 'estimates.view', 'estimates.create', 'estimates.update', 'estimates.approve', 'diagnosis.view', 'diagnosis.create', + 'invoices.view', 'invoices.create', 'invoices.update', 'invoices.send', 'invoices.payment', 'reports.view', 'reports.create', 'reports.export', 'reports.financial', 'timesheets.view', 'timesheets.approve', - ] + ], ], [ 'name' => 'service_advisor', @@ -193,9 +203,10 @@ class RolesAndPermissionsSeeder extends Seeder 'service-orders.view', 'service-orders.create', 'service-orders.update', 'appointments.view', 'appointments.create', 'appointments.update', 'appointments.confirm', 'estimates.view', 'estimates.create', 'diagnosis.view', + 'invoices.view', 'invoices.create', 'inventory.view', 'inventory.stock-movements', 'inspections.view', 'inspections.create', - ] + ], ], [ 'name' => 'technician', @@ -212,7 +223,7 @@ class RolesAndPermissionsSeeder extends Seeder 'inspections.view', 'inspections.create', 'inspections.update', 'diagnosis.view', 'diagnosis.create', 'timesheets.view', 'timesheets.create', 'timesheets.update', - ] + ], ], [ 'name' => 'inventory_manager', @@ -224,7 +235,7 @@ class RolesAndPermissionsSeeder extends Seeder 'inventory.purchase-orders', 'inventory.purchase-orders-approve', 'service-orders.view', 'work-orders.view', 'reports.view', 'reports.create', 'reports.export', - ] + ], ], [ 'name' => 'customer_portal', @@ -235,7 +246,7 @@ class RolesAndPermissionsSeeder extends Seeder 'vehicles.view', 'vehicles.history', 'service-orders.view', 'estimates.view', - ] + ], ], ]; @@ -261,7 +272,7 @@ class RolesAndPermissionsSeeder extends Seeder ->where('is_active', true) ->pluck('id') ->toArray(); - + $role->permissions()->sync($permissionIds); } } diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index 998c6d5..94899f3 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -62,6 +62,12 @@ @endif + @if(auth()->user()->hasPermission('invoices.create')) + + New Invoice + + @endif + @if(auth()->user()->hasPermission('work-orders.create')) New Work Order @@ -255,8 +261,8 @@ @endif - @if(auth()->user()->hasPermission('service-orders.view')) - + @if(auth()->user()->hasPermission('invoices.view')) + Invoices @endif @@ -455,11 +461,22 @@ document.addEventListener('alpine:init', () => { Alpine.data('sidebarState', () => ({ collapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') || '{}'), + screenLg: window.innerWidth >= 1024, init() { this.$watch('collapsed', (value) => { localStorage.setItem('sidebarCollapsed', JSON.stringify(value)); }); + + // Handle screen size changes + this.$watch('screenLg', (value) => { + // Optional: do something when screen size changes + }); + + // Listen for resize events + window.addEventListener('resize', () => { + this.screenLg = window.innerWidth >= 1024; + }); } })); }); diff --git a/resources/views/estimates/pdf.blade.php b/resources/views/estimates/pdf.blade.php new file mode 100644 index 0000000..195836f --- /dev/null +++ b/resources/views/estimates/pdf.blade.php @@ -0,0 +1,419 @@ + + + + + + Estimate #{{ $estimate->estimate_number }} + + + + +
+
+
+
{{ app(\App\Settings\GeneralSettings::class)->shop_name ?? 'Car Repairs Shop' }}
+
{{ app(\App\Settings\GeneralSettings::class)->shop_address ?? 'Shop Address' }}
+
{{ app(\App\Settings\GeneralSettings::class)->shop_phone ?? 'Phone Number' }}
+
{{ app(\App\Settings\GeneralSettings::class)->shop_email ?? 'Email Address' }}
+
+
+
ESTIMATE
+
#{{ $estimate->estimate_number }}
+
Date: {{ $estimate->created_at->format('M j, Y') }}
+ @if($estimate->validity_period_days) +
Valid Until: {{ $estimate->valid_until->format('M j, Y') }}
+ @endif +
+ {{ ucfirst($estimate->status) }} +
+
+
+
+ + +
+
Customer Information
+
+
+ @php + $customer = $estimate->customer ?? $estimate->jobCard?->customer; + @endphp + @if($customer) +
+ Name: + {{ $customer->name }} +
+
+ Email: + {{ $customer->email }} +
+
+ Phone: + {{ $customer->phone }} +
+ @else +
No customer information available
+ @endif +
+
+ @php + $vehicle = $estimate->vehicle ?? $estimate->jobCard?->vehicle; + @endphp + @if($vehicle) +
+ Vehicle: + {{ $vehicle->year }} {{ $vehicle->make }} {{ $vehicle->model }} +
+
+ License: + {{ $vehicle->license_plate }} +
+ @if($vehicle->vin) +
+ VIN: + {{ $vehicle->vin }} +
+ @endif + @else +
No vehicle specified
+ @endif +
+
+
+ + + @if($estimate->jobCard) +
+
Job Information
+
+ Job Card: + #{{ $estimate->jobCard->job_number }} +
+ @if($estimate->diagnosis) +
+ Diagnosis: + {{ Str::limit($estimate->diagnosis->findings, 100) }} +
+ @endif +
+ @endif + + +
+
Services & Parts
+ @if($estimate->lineItems->count() > 0) + + + + + + + + + + + + @foreach($estimate->lineItems as $item) + + + + + + + + @endforeach + +
TypeDescriptionQtyUnit PriceTotal
+ + {{ ucfirst($item->type) }} + + + {{ $item->description }} + @if($item->part) +
Part #: {{ $item->part->part_number }} + @endif +
{{ $item->quantity }}${{ number_format($item->unit_price, 2) }}${{ number_format($item->total_amount, 2) }}
+ @else +
+ No line items available +
+ @endif +
+ + + + + + + + @if($estimate->discount_amount > 0) + + + + + @endif + + + + + + + + +
Subtotal:${{ number_format($estimate->subtotal, 2) }}
Discount:-${{ number_format($estimate->discount_amount, 2) }}
Tax ({{ $estimate->tax_rate }}%):${{ number_format($estimate->tax_amount, 2) }}
Total:${{ number_format($estimate->total_amount, 2) }}
+ + + @if($estimate->notes) +
+
Notes
+
{{ $estimate->notes }}
+
+ @endif + + + @if($estimate->terms_and_conditions) +
+
Terms & Conditions
+
{{ $estimate->terms_and_conditions }}
+
+ @endif + + + + + diff --git a/resources/views/flux/sidebar/index.blade.php b/resources/views/flux/sidebar/index.blade.php index 2066236..4b9b442 100644 --- a/resources/views/flux/sidebar/index.blade.php +++ b/resources/views/flux/sidebar/index.blade.php @@ -19,19 +19,25 @@ if ($stashable) { $attributes = $attributes->merge([ 'x-bind:data-stashed' => '! screenLg', 'x-resize.document' => 'screenLg = window.innerWidth >= 1024', - 'x-init' => '$el.classList.add(\'-translate-x-full\', \'rtl:translate-x-full\'); $el.removeAttribute(\'data-mobile-cloak\'); $el.classList.add(\'transition-transform\')', + 'x-init' => 'screenLg = window.innerWidth >= 1024; $el.classList.add(\'-translate-x-full\', \'rtl:translate-x-full\'); $el.removeAttribute(\'data-mobile-cloak\'); $el.classList.add(\'transition-transform\')', ])->class([ 'max-lg:data-mobile-cloak:hidden', '[[data-show-stashed-sidebar]_&]:translate-x-0! lg:translate-x-0!', 'z-20! data-stashed:start-0! data-stashed:fixed! data-stashed:top-0! data-stashed:min-h-dvh! data-stashed:max-h-dvh!' ]); } + +// Check if x-data already exists, if not add default +$existingXData = $attributes->get('x-data'); +if (!$existingXData) { + $attributes = $attributes->merge(['x-data' => '{ screenLg: window.innerWidth >= 1024 }']); +} @endphp @if ($stashable) @endif -
class($classes) }} x-data="{ screenLg: window.innerWidth >= 1024 }" data-mobile-cloak data-flux-sidebar> +
class($classes) }} data-mobile-cloak data-flux-sidebar> {{ $slot }}
diff --git a/resources/views/invoices/pdf.blade.php b/resources/views/invoices/pdf.blade.php new file mode 100644 index 0000000..81bbb57 --- /dev/null +++ b/resources/views/invoices/pdf.blade.php @@ -0,0 +1,512 @@ + + + + + + Invoice #{{ $invoice->invoice_number }} + + + + @if($invoice->status === 'paid') + + @endif + + +
+
+
+
{{ app(\App\Settings\GeneralSettings::class)->shop_name ?? 'Car Repairs Shop' }}
+
{{ app(\App\Settings\GeneralSettings::class)->shop_address ?? 'Shop Address' }}
+
{{ app(\App\Settings\GeneralSettings::class)->shop_phone ?? 'Phone Number' }}
+
{{ app(\App\Settings\GeneralSettings::class)->shop_email ?? 'Email Address' }}
+
+
+
INVOICE
+
#{{ $invoice->invoice_number }}
+
Date: {{ $invoice->invoice_date->format('M j, Y') }}
+
Due: {{ $invoice->due_date->format('M j, Y') }}
+ @if($invoice->isPaid()) +
Paid: {{ $invoice->paid_at->format('M j, Y') }}
+ @endif +
+ {{ ucfirst($invoice->status) }} +
+
+
+
+ + +
+
Bill To
+
+
+
+ Name: + {{ $invoice->customer->name }} +
+
+ Email: + {{ $invoice->customer->email }} +
+
+ Phone: + {{ $invoice->customer->phone }} +
+
+
+ @if($invoice->customer->address) +
+ Address: + {{ $invoice->customer->address }} +
+ @endif + @if($invoice->customer->city) +
+ City: + {{ $invoice->customer->city }}, {{ $invoice->customer->state ?? '' }} {{ $invoice->customer->zip_code ?? '' }} +
+ @endif +
+
+
+ + + @php + $vehicle = $invoice->serviceOrder?->vehicle ?? $invoice->jobCard?->vehicle; + @endphp + @if($vehicle) +
+
Vehicle Information
+
+
+
+ Vehicle: + {{ $vehicle->year }} {{ $vehicle->make }} {{ $vehicle->model }} +
+
+ License: + {{ $vehicle->license_plate }} +
+
+
+ @if($vehicle->vin) +
+ VIN: + {{ $vehicle->vin }} +
+ @endif + @if($vehicle->mileage) +
+ Mileage: + {{ number_format($vehicle->mileage) }} miles +
+ @endif +
+
+
+ @endif + + + @if($invoice->serviceOrder || $invoice->jobCard || $invoice->estimate) +
+
Service Information
+ @if($invoice->serviceOrder) +
+ Service Order: + #{{ $invoice->serviceOrder->order_number }} +
+ @endif + @if($invoice->jobCard) +
+ Job Card: + #{{ $invoice->jobCard->job_number }} +
+ @endif + @if($invoice->estimate) +
+ Estimate: + #{{ $invoice->estimate->estimate_number }} +
+ @endif +
+ @endif + + +
+
Services & Parts
+ @if($invoice->lineItems->count() > 0) + + + + + + + + + + + + @foreach($invoice->lineItems as $item) + + + + + + + + @endforeach + +
TypeDescriptionQtyUnit PriceTotal
+ + {{ ucfirst($item->type) }} + + + {{ $item->description }} + @if($item->part) +
Part #: {{ $item->part->part_number }} + @endif + @if($item->part_number) +
Part #: {{ $item->part_number }} + @endif + @if($item->technical_notes) +
{{ $item->technical_notes }} + @endif +
{{ $item->quantity }}${{ number_format($item->unit_price, 2) }}${{ number_format($item->total_amount, 2) }}
+ @else +
+ No line items available +
+ @endif +
+ + + + + + + + @if($invoice->discount_amount > 0) + + + + + @endif + + + + + + + + +
Subtotal:${{ number_format($invoice->subtotal, 2) }}
Discount:-${{ number_format($invoice->discount_amount, 2) }}
Tax ({{ $invoice->tax_rate }}%):${{ number_format($invoice->tax_amount, 2) }}
Total:${{ number_format($invoice->total_amount, 2) }}
+ + + @if($invoice->isPaid()) +
+
Payment Information
+
+

Payment Date: {{ $invoice->paid_at->format('M j, Y g:i A') }}

+ @if($invoice->payment_method) +

Payment Method: {{ ucfirst(str_replace('_', ' ', $invoice->payment_method)) }}

+ @endif + @if($invoice->payment_reference) +

Reference: {{ $invoice->payment_reference }}

+ @endif + @if($invoice->payment_notes) +

Notes: {{ $invoice->payment_notes }}

+ @endif +
+
+ @else +
+
Payment Information
+
+

Payment is due by {{ $invoice->due_date->format('M j, Y') }}.

+

Please include invoice number {{ $invoice->invoice_number }} with your payment.

+

+ Payment Methods: Cash, Credit Card, Check, Bank Transfer +

+
+
+ @endif + + + @if($invoice->notes) +
+
Notes
+
{{ $invoice->notes }}
+
+ @endif + + + @if($invoice->terms_and_conditions) +
+
Terms & Conditions
+
{{ $invoice->terms_and_conditions }}
+
+ @endif + + + + + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 5f34d0b..13bb8cd 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -14,6 +14,7 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) @livewireStyles + @fluxAppearance
@@ -53,5 +54,6 @@
@livewireScripts + @fluxScripts diff --git a/resources/views/livewire/appointments/calendar.blade.php b/resources/views/livewire/appointments/calendar.blade.php index ea809c7..36ac69c 100644 --- a/resources/views/livewire/appointments/calendar.blade.php +++ b/resources/views/livewire/appointments/calendar.blade.php @@ -60,7 +60,7 @@ class="p-2 text-zinc-500 dark:text-zinc-400 hover:text-gray-700 hover:bg-zinc-100 rounded-l-md transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-indigo-500" title="Previous {{ $viewType }}" > - + @@ -69,7 +69,7 @@ class="p-2 text-zinc-500 dark:text-zinc-400 hover:text-gray-700 hover:bg-zinc-100 rounded-r-md transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-indigo-500" title="Next {{ $viewType }}" > - + diff --git a/resources/views/livewire/appointments/create.blade.php b/resources/views/livewire/appointments/create.blade.php index 0319da3..2c5ba83 100644 --- a/resources/views/livewire/appointments/create.blade.php +++ b/resources/views/livewire/appointments/create.blade.php @@ -7,7 +7,7 @@

Create a new appointment for a customer

- + Back to Appointments @@ -173,7 +173,7 @@ Cancel @@ -385,7 +389,7 @@ @@ -395,7 +399,7 @@ @@ -405,7 +409,7 @@ @@ -413,7 +417,7 @@ wire:confirm="Are you sure you want to cancel this appointment?" title="Cancel" class="text-gray-400 hover:text-red-600 transition-colors duration-200"> - + @@ -424,7 +428,7 @@ wire:confirm="Mark this appointment as no-show?" title="No Show" class="text-gray-400 hover:text-orange-600 transition-colors duration-200"> - + @@ -455,7 +459,7 @@ @endif

-
+
Available
-
+
Booked
-
+
Unavailable
-
+
Selected
diff --git a/resources/views/livewire/branches/create.blade.php b/resources/views/livewire/branches/create.blade.php index 35defcf..a8ee177 100644 --- a/resources/views/livewire/branches/create.blade.php +++ b/resources/views/livewire/branches/create.blade.php @@ -9,7 +9,7 @@ - + Back to Branches @@ -200,7 +200,7 @@ @@ -226,7 +226,7 @@ - + Add Branch diff --git a/resources/views/livewire/customer-portal/workflow-progress.blade.php b/resources/views/livewire/customer-portal/workflow-progress.blade.php index 99a6710..4bb0e48 100644 --- a/resources/views/livewire/customer-portal/workflow-progress.blade.php +++ b/resources/views/livewire/customer-portal/workflow-progress.blade.php @@ -87,7 +87,7 @@
@foreach($step['details'] as $detail)
- + {{ $detail }} @@ -124,14 +124,14 @@

Contact your service advisor for real-time updates:

@if($jobCard->assignedTo)
- + {{ $jobCard->assignedTo->name }}
@endif
- + {{ app(\App\Settings\GeneralSettings::class)->shop_phone }} diff --git a/resources/views/livewire/customers/index.blade.php b/resources/views/livewire/customers/index.blade.php index a3d7289..215fab5 100644 --- a/resources/views/livewire/customers/index.blade.php +++ b/resources/views/livewire/customers/index.blade.php @@ -34,11 +34,14 @@
- +
+ +
+ class="w-full rounded-lg border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 pr-10"> + +
+ +
diff --git a/resources/views/livewire/diagnosis/show.blade.php b/resources/views/livewire/diagnosis/show.blade.php index a098d2d..b17b7ca 100644 --- a/resources/views/livewire/diagnosis/show.blade.php +++ b/resources/views/livewire/diagnosis/show.blade.php @@ -10,13 +10,13 @@
+ + @if($item['type'] === 'parts' && isset($item['stock_available']) && $item['quantity'] && $item['quantity'] > $item['stock_available']) +
+
+ + + +
+ Insufficient Stock: You requested {{ $item['quantity'] }} but only {{ $item['stock_available'] }} are available in inventory. +
+
+
+ @endif + @error("lineItems.{$index}.type")

{{ $message }}

@enderror diff --git a/resources/views/livewire/estimates/create.blade.php b/resources/views/livewire/estimates/create.blade.php index bc1c5d9..aa7a071 100644 --- a/resources/views/livewire/estimates/create.blade.php +++ b/resources/views/livewire/estimates/create.blade.php @@ -77,7 +77,7 @@

Line Items

- + Add Item @@ -102,7 +102,7 @@
- + @@ -124,7 +124,7 @@ @if(!($item['required'] ?? false)) - + @@ -208,7 +208,7 @@ Cancel
- + Create Estimate diff --git a/resources/views/livewire/estimates/edit-improved.blade.php b/resources/views/livewire/estimates/edit-improved.blade.php new file mode 100644 index 0000000..6c64b7b --- /dev/null +++ b/resources/views/livewire/estimates/edit-improved.blade.php @@ -0,0 +1,265 @@ +
+ +
+
+
+

Edit Estimate

+

+ {{ $estimate->estimate_number }} • + {{ $estimate->customer_id ? $estimate->customer?->name : $estimate->jobCard?->customer?->name ?? 'Unknown Customer' }} + @if($estimate->customer_id && $estimate->vehicle) + • {{ $estimate->vehicle->year }} {{ $estimate->vehicle->make }} {{ $estimate->vehicle->model }} + @elseif($estimate->jobCard?->vehicle) + • {{ $estimate->jobCard->vehicle->year }} {{ $estimate->jobCard->vehicle->make }} {{ $estimate->jobCard->vehicle->model }} + @endif +

+ @if($lastSaved) +

+ + + + + Auto-saved at {{ $lastSaved }} + +

+ @endif +
+
+ + @if($autoSave) + + + + Auto-save ON + @else + + + + Auto-save OFF + @endif + + + View Estimate + + + Save Changes + +
+
+
+ + +
+

Quick Add Service Items

+
+ @foreach($quickAddPresets as $key => $preset) + + @endforeach +
+
+ + +
+ +
+
+
+
+

Line Items

+
+ @if(!$bulkOperationMode) + + + + + Bulk Operations + + @else + + + + + Delete Selected + + + Cancel + + @endif + + + + + Add Item + +
+
+
+ +
+ @if(count($lineItems) > 0) + + + + @if($bulkOperationMode) + + @endif + + + + + + + + + + @foreach($lineItems as $index => $item) + + @if($bulkOperationMode) + + @endif + + + + + + + + @endforeach + +
+ + TypeDescriptionQtyUnit PriceTotalActions
+ + + + + + + + + + + + + + + + + + + ${{ number_format(($item['quantity'] ?? 0) * ($item['unit_price'] ?? 0), 2) }} + + + + + + +
+ @else +
+ + + +

No line items yet

+

Get started by adding your first service or part.

+ + + + + Add First Item + +
+ @endif +
+
+
+ + +
+ +
+

Settings

+
+ + Validity Period (Days) + + + + + + Tax Rate (%) + + + + + + Discount Amount ($) + + + +
+
+ + +
+

Summary

+
+
+ Subtotal: + ${{ number_format($subtotal, 2) }} +
+ @if($discount_amount > 0) +
+ Discount: + -${{ number_format($discount_amount, 2) }} +
+ @endif +
+ Tax ({{ $tax_rate }}%): + ${{ number_format($tax_amount, 2) }} +
+
+
+ Total: + ${{ number_format($total_amount, 2) }} +
+
+
+
+ + +
+

Notes

+
+ + Customer Notes + + + + + + Internal Notes + + + +
+
+ + +
+ + Terms & Conditions + + + +
+
+
+
diff --git a/resources/views/livewire/estimates/edit.blade.php b/resources/views/livewire/estimates/edit.blade.php index 40e8506..6c64b7b 100644 --- a/resources/views/livewire/estimates/edit.blade.php +++ b/resources/views/livewire/estimates/edit.blade.php @@ -1,20 +1,20 @@ -
- -
+
+ +
-

Edit Estimate

-

+

Edit Estimate

+

{{ $estimate->estimate_number }} • - {{ $estimate->customer_id ? $estimate->customer?->name : $estimate->jobCard?->customer?->name ?? 'Unknown Customer' }} • - @if($estimate->customer_id) - {{ $estimate->vehicle?->year }} {{ $estimate->vehicle?->make }} {{ $estimate->vehicle?->model }} - @else - {{ $estimate->jobCard?->vehicle?->year }} {{ $estimate->jobCard?->vehicle?->make }} {{ $estimate->jobCard?->vehicle?->model }} + {{ $estimate->customer_id ? $estimate->customer?->name : $estimate->jobCard?->customer?->name ?? 'Unknown Customer' }} + @if($estimate->customer_id && $estimate->vehicle) + • {{ $estimate->vehicle->year }} {{ $estimate->vehicle->make }} {{ $estimate->vehicle->model }} + @elseif($estimate->jobCard?->vehicle) + • {{ $estimate->jobCard->vehicle->year }} {{ $estimate->jobCard->vehicle->make }} {{ $estimate->jobCard->vehicle->model }} @endif

@if($lastSaved) -

+

@@ -25,7 +25,7 @@ @endif

- - + + + View Estimate + + + Save Changes +
-
-

Quick Add Service Items

+
+

Quick Add Service Items

@foreach($quickAddPresets as $key => $preset) @endforeach
- -
- -
- -
-
+ +
+ +
+
+
-

Line Items

+

Line Items

@if(!$bulkOperationMode) - + @else -
- - -
+ + + + + Delete Selected + + + Cancel + @endif -
-
-
- - -
- @forelse($lineItems as $index => $item) -
- @if($bulkOperationMode) -
-
- -
-
- @endif - - @if($item['is_editing']) - -
-
- - - - - -
-
- -
-
- -
-
- -
-
- - -
-
- - @if($showAdvancedOptions) -
-
- -
-
- - - - - -
-
- -
-
- -
-
- @endif - @else - -
-
-
- - {{ ucfirst($item['type']) }} - - {{ $item['description'] }} - @if($item['part_name']) - ({{ $item['part_name'] }}) - @endif -
-
- Qty: {{ $item['quantity'] }} × ${{ number_format($item['unit_price'], 2) }} - @if($item['markup_percentage'] > 0) - + {{ $item['markup_percentage'] }}% markup - @endif - @if($item['discount_type'] !== 'none') - - - {{ $item['discount_type'] === 'percentage' ? $item['discount_value'].'%' : '$'.number_format($item['discount_value'], 2) }} discount - - @endif -
- @if($item['notes']) -
{{ $item['notes'] }}
- @endif -
-
-
-
${{ number_format($item['total_amount'], 2) }}
- @if(!$item['is_taxable']) -
Tax exempt
- @endif -
- @if(!$bulkOperationMode) -
- - - -
- @endif -
-
- @endif - - @if($bulkOperationMode) -
-
- @endif -
- @empty -
- - - -

No line items added yet. Add service items above to get started.

-
- @endforelse -
- - -
-

Add New Line Item

-
-
- - - - - -
-
- -
-
- -
-
- -
-
- +
+
- @if($showAdvancedOptions) -
-
- -
-
- - - - - -
-
- -
-
- -
-
-
- +
+ @if(count($lineItems) > 0) + + + + @if($bulkOperationMode) + + @endif + + + + + + + + + + @foreach($lineItems as $index => $item) + + @if($bulkOperationMode) + + @endif + + + + + + + + @endforeach + +
+ + TypeDescriptionQtyUnit PriceTotalActions
+ + + + + + + + + + + + + + + + + + + ${{ number_format(($item['quantity'] ?? 0) * ($item['unit_price'] ?? 0), 2) }} + + + + + + +
+ @else +
+ + + +

No line items yet

+

Get started by adding your first service or part.

+ + + + + Add First Item +
@endif
- +
- -
-

Financial Summary

+ +
+

Settings

+
+ + Validity Period (Days) + + + + + + Tax Rate (%) + + + + + + Discount Amount ($) + + + +
+
+ + +
+

Summary

- Subtotal: - ${{ number_format($subtotal, 2) }} + Subtotal: + ${{ number_format($subtotal, 2) }}
- + @if($discount_amount > 0) +
+ Discount: + -${{ number_format($discount_amount, 2) }} +
+ @endif
- Discount: - -${{ number_format($discount_amount, 2) }} + Tax ({{ $tax_rate }}%): + ${{ number_format($tax_amount, 2) }}
- -
- Tax ({{ $tax_rate }}%): - ${{ number_format($tax_amount, 2) }} -
- -
+
- Total: - ${{ number_format($total_amount, 2) }} + Total: + ${{ number_format($total_amount, 2) }}
- -
-

Estimate Settings

+ +
+

Notes

-
- - Tax Rate (%) - - -
- -
- - Discount Amount ($) - - -
- -
- - Valid for (days) - - -
-
-
+ + Customer Notes + + + - -
-

Notes

-
-
- - Customer Notes - - -
- -
- - Internal Notes - - -
+ + Internal Notes + + +
-
-

Terms & Conditions

+
- + Terms & Conditions + +
- - -
- - - - - - - Cancel - -
diff --git a/resources/views/livewire/estimates/index.blade.php b/resources/views/livewire/estimates/index.blade.php index 297ae21..22bf65c 100644 --- a/resources/views/livewire/estimates/index.blade.php +++ b/resources/views/livewire/estimates/index.blade.php @@ -20,7 +20,7 @@ From Diagnosis - + @@ -177,7 +177,7 @@ @if($search || $statusFilter || $approvalStatusFilter || $customerFilter || $dateFrom || $dateTo)
+ @endif
@@ -454,60 +487,10 @@ ${{ number_format($estimate->total_amount, 2) }}
- - @if($estimate->validity_period_days) - @php - $validUntil = $estimate->created_at->addDays($estimate->validity_period_days); - $isExpired = $validUntil->isPast(); - $isExpiringSoon = $validUntil->diffInDays(now()) <= 7 && !$isExpired; - @endphp -
- {{ $validUntil->format('M j, Y') }} -
- @if($isExpired) -
Expired
- @elseif($isExpiringSoon) -
Expires soon
- @endif - @else - No expiry - @endif - - -
- - - - - - - @can('update', $estimate) - - - - - - @endcan - @if($estimate->status === 'draft') - - @endif - @can('delete', $estimate) - - @endcan -
- @empty - +
@@ -523,7 +506,7 @@ Create New Estimate - + @@ -566,9 +549,20 @@
- @if($estimates->hasPages()) -
+
+
+ Showing {{ $estimates->firstItem() ?? 0 }} to {{ $estimates->lastItem() ?? 0 }} of {{ $estimates->total() }} estimates +
+
{{ $estimates->links() }}
- @endif +
+ + diff --git a/resources/views/livewire/estimates/p-d-f.blade.php b/resources/views/livewire/estimates/p-d-f.blade.php deleted file mode 100644 index fd5ed6b..0000000 --- a/resources/views/livewire/estimates/p-d-f.blade.php +++ /dev/null @@ -1,3 +0,0 @@ -
- {{-- Nothing in the world is as soft and yielding as water. --}} -
diff --git a/resources/views/livewire/estimates/show-advanced.blade.php b/resources/views/livewire/estimates/show-advanced.blade.php index 9ac3b08..05d68b8 100644 --- a/resources/views/livewire/estimates/show-advanced.blade.php +++ b/resources/views/livewire/estimates/show-advanced.blade.php @@ -49,7 +49,7 @@
@if($estimate->status === 'draft') @if($estimate->status === 'approved') - + Create Work Order @@ -97,7 +97,7 @@
- + View Job Card diff --git a/resources/views/livewire/estimates/show-improved.blade.php b/resources/views/livewire/estimates/show-improved.blade.php new file mode 100644 index 0000000..c816176 --- /dev/null +++ b/resources/views/livewire/estimates/show-improved.blade.php @@ -0,0 +1,317 @@ +
+ +
+
+
+
+ + + +
+
+

+ Estimate #{{ $estimate->estimate_number }} +

+

+ @if($estimate->jobCard) + Job Card #{{ $estimate->jobCard->job_number }} • {{ $estimate->created_at->format('M j, Y') }} + @else + Standalone Estimate • {{ $estimate->created_at->format('M j, Y') }} + @endif +

+
+ + {{ ucfirst($estimate->status) }} + + + Customer: {{ ucfirst($estimate->customer_approval_status) }} + + @if($estimate->validity_period_days) + + Valid until {{ $estimate->valid_until->format('M j, Y') }} + + @endif +
+
+
+ + +
+ @if($estimate->status === 'draft') + + + + + Send to Customer + + @endif + + + + + + + Edit Estimate + + + Duplicate Estimate + + + Download PDF + + @if($estimate->status === 'approved') + + Create Work Order + + @endif + @if($estimate->customer_approval_status === 'pending' && auth()->user()->can('approve', $estimate)) + + + Approve Estimate + + + Reject Estimate + + @endif + + +
+
+
+ + +
+ +
+

Customer Information

+ @php + $customer = $estimate->customer ?? $estimate->jobCard?->customer; + @endphp + @if($customer) +
+
+ Name: +

{{ $customer->name }}

+
+
+ Email: +

{{ $customer->email }}

+
+
+ Phone: +

{{ $customer->phone }}

+
+
+ @else +

No customer information available

+ @endif +
+ + +
+

Vehicle Information

+ @php + $vehicle = $estimate->vehicle ?? $estimate->jobCard?->vehicle; + @endphp + @if($vehicle) +
+
+ Vehicle: +

{{ $vehicle->year }} {{ $vehicle->make }} {{ $vehicle->model }}

+
+
+ License Plate: +

{{ $vehicle->license_plate }}

+
+ @if($vehicle->vin) +
+ VIN: +

{{ $vehicle->vin }}

+
+ @endif +
+ @else +

No vehicle specified

+ @endif +
+
+ + +
+
+
+

Line Items

+ + {{ $showItemDetails ? 'Hide Details' : 'Show Details' }} + +
+
+ +
+ @if($estimate->lineItems->count() > 0) + + + + + + + + + + + + @foreach($estimate->lineItems as $item) + + + + + + + + @endforeach + +
TypeDescriptionQtyUnit PriceTotal
+ + {{ ucfirst($item->type) }} + + +
{{ $item->description }}
+ @if($showItemDetails && $item->part) +
+ Part #: {{ $item->part->part_number }} +
+ @endif +
+ {{ $item->quantity }} + + ${{ number_format($item->unit_price, 2) }} + + ${{ number_format($item->total_amount, 2) }} +
+ @else +
+ + + +

No line items

+

This estimate doesn't have any line items yet.

+
+ @endif +
+
+ + +
+ +
+
+

Summary

+
+
+ Subtotal: + ${{ number_format($estimate->subtotal, 2) }} +
+ @if($estimate->discount_amount > 0) +
+ Discount: + -${{ number_format($estimate->discount_amount, 2) }} +
+ @endif +
+ Tax ({{ $estimate->tax_rate }}%): + ${{ number_format($estimate->tax_amount, 2) }} +
+
+
+ Total: + ${{ number_format($estimate->total_amount, 2) }} +
+
+
+
+
+ + +
+ @if($estimate->notes) +
+

Customer Notes

+

{{ $estimate->notes }}

+
+ @endif + + @if($estimate->internal_notes && auth()->user()->can('view', $estimate)) +
+

Internal Notes

+

{{ $estimate->internal_notes }}

+
+ @endif + + @if($estimate->terms_and_conditions) +
+

Terms & Conditions

+

{{ $estimate->terms_and_conditions }}

+
+ @endif +
+
+ + + @if($estimate->created_at) +
+

Estimate History

+
+
+ Created: + {{ $estimate->created_at->format('M j, Y g:i A') }} +
+ @if($estimate->sent_at) +
+ Sent to Customer: + {{ $estimate->sent_at->format('M j, Y g:i A') }} +
+ @endif + @if($estimate->customer_approved_at) +
+ Customer Response: + + {{ ucfirst($estimate->customer_approval_status) }} on {{ $estimate->customer_approved_at->format('M j, Y g:i A') }} + +
+ @endif + @if($estimate->preparedBy) +
+ Prepared By: + {{ $estimate->preparedBy->name }} +
+ @endif +
+
+ @endif +
diff --git a/resources/views/livewire/estimates/show.blade.php b/resources/views/livewire/estimates/show.blade.php index 3edd523..e1510c7 100644 --- a/resources/views/livewire/estimates/show.blade.php +++ b/resources/views/livewire/estimates/show.blade.php @@ -1,47 +1,51 @@ -
- -
-
+
+ +
+
- - -
- -
- -
-
-

- - - - Customer & Vehicle Details -

-
-
-
-
-
-
- - - -
-
- @if($estimate->customer_id) - {{-- Standalone estimate --}} -

{{ $estimate->customer->name }}

-

{{ $estimate->customer->phone }}

-

{{ $estimate->customer->email }}

- @elseif($estimate->jobCard?->customer) - {{-- Job card-based estimate --}} -

{{ $estimate->jobCard->customer->name }}

-

{{ $estimate->jobCard->customer->phone }}

-

{{ $estimate->jobCard->customer->email }}

- @else -

Unknown Customer

-

No contact information

- @endif -
-
-
-
-
-
- - - -
-
- @if($estimate->vehicle_id) - {{-- Standalone estimate --}} -

- {{ $estimate->vehicle->year }} {{ $estimate->vehicle->make }} {{ $estimate->vehicle->model }} -

-

{{ $estimate->vehicle->license_plate }}

-

VIN: {{ $estimate->vehicle->vin }}

- @elseif($estimate->jobCard?->vehicle) - {{-- Job card-based estimate --}} -

- {{ $estimate->jobCard->vehicle->year }} {{ $estimate->jobCard->vehicle->make }} {{ $estimate->jobCard->vehicle->model }} -

-

{{ $estimate->jobCard->vehicle->license_plate }}

-

VIN: {{ $estimate->jobCard->vehicle->vin }}

- @else -

Unknown Vehicle

-

No vehicle information

- @endif -
-
-
-
-
-
- - -
-
-
-

- - - - Service Items & Parts - - {{ $estimate->lineItems->count() }} items - -

- -
-
-
-
- - - - - - - - - - - @foreach($estimate->lineItems as $item) - - - - - - - @endforeach - -
DescriptionQtyUnit PriceTotal
-
- - {{ ucfirst($item->type) }} - -
-

{{ $item->description }}

- @if($showItemDetails && $item->labor_hours) -

- Labor: {{ $item->labor_hours }}h @ ${{ number_format($item->labor_rate, 2) }}/hr -

- @endif - @if($showItemDetails && $item->part_number) -

- Part #: {{ $item->part_number }} -

- @endif -
-
-
- - {{ $item->quantity }} - - - - ${{ number_format($item->unit_price, 2) }} - - - - ${{ number_format($item->total_amount, 2) }} - -
-
-
-
- - - @if($estimate->terms_and_conditions) -
-
-

- - - - Terms & Conditions -

-
-
-

{{ $estimate->terms_and_conditions }}

+ +
+ +
+

Customer Information

+ @php + $customer = $estimate->customer ?? $estimate->jobCard?->customer; + @endphp + @if($customer) +
+
+ Name: +

{{ $customer->name }}

+
+
+ Email: +

{{ $customer->email }}

+
+
+ Phone: +

{{ $customer->phone }}

+ @else +

No customer information available

@endif
- -
- -
-
-

- - - - Financial Summary -

-
-
- Labor Cost - ${{ number_format($estimate->labor_cost, 2) }} -
-
- Parts Cost - ${{ number_format($estimate->parts_cost, 2) }} -
- @if($estimate->miscellaneous_cost > 0) -
- Miscellaneous - ${{ number_format($estimate->miscellaneous_cost, 2) }} -
- @endif -
- Subtotal - ${{ number_format($estimate->subtotal, 2) }} -
- @if($estimate->discount_amount > 0) -
- Discount - -${{ number_format($estimate->discount_amount, 2) }} -
- @endif -
- Tax ({{ $estimate->tax_rate }}%) - ${{ number_format($estimate->tax_amount, 2) }} -
-
- Total - ${{ number_format($estimate->total_amount, 2) }} -
+ +
+

Vehicle Information

+ @php + $vehicle = $estimate->vehicle ?? $estimate->jobCard?->vehicle; + @endphp + @if($vehicle) +
+
+ Vehicle: +

{{ $vehicle->year }} {{ $vehicle->make }} {{ $vehicle->model }}

-
-
- - -
-
-

- - - - Status Timeline -

-
-
-
-
-
-
-

Created

-

{{ $estimate->created_at->format('M j, Y g:i A') }}

-
-
- @if($estimate->sent_at) -
-
-
-

Sent to Customer

-

{{ $estimate->sent_at->format('M j, Y g:i A') }}

-
-
- @endif - @if($estimate->customer_viewed_at) -
-
-
-

Viewed by Customer

-

{{ $estimate->customer_viewed_at->format('M j, Y g:i A') }}

-
-
- @endif - @if($estimate->customer_responded_at) -
-
-
-

Customer {{ ucfirst($estimate->customer_approval_status) }}

-

{{ $estimate->customer_responded_at->format('M j, Y g:i A') }}

-
-
- @endif +
+ License Plate: +

{{ $vehicle->license_plate }}

-
-
- - -
-
-

- - - - Related Documents -

-
-
- @if($estimate->diagnosis) - -
- - - -
-
-

Diagnosis Report

-

View diagnostic findings

-
-
+ @if($vehicle->vin) +
+ VIN: +

{{ $vehicle->vin }}

+
@endif
+ @else +

No vehicle specified

+ @endif +
+
+ + +
+
+
+

Line Items

+ + {{ $showItemDetails ? 'Hide Details' : 'Show Details' }} + +
+
+ +
+ @if($estimate->lineItems->count() > 0) + + + + + + + + + + + + @foreach($estimate->lineItems as $item) + + + + + + + + @endforeach + +
TypeDescriptionQtyUnit PriceTotal
+ + {{ ucfirst($item->type) }} + + +
{{ $item->description }}
+ @if($showItemDetails && $item->part) +
+ Part #: {{ $item->part->part_number }} +
+ @endif +
+ {{ $item->quantity }} + + ${{ number_format($item->unit_price, 2) }} + + ${{ number_format($item->total_amount, 2) }} +
+ @else +
+ + + +

No line items

+

This estimate doesn't have any line items yet.

+
+ @endif +
+
+ + +
+ +
+
+

Summary

+
+
+ Subtotal: + ${{ number_format($estimate->subtotal, 2) }} +
+ @if($estimate->discount_amount > 0) +
+ Discount: + -${{ number_format($estimate->discount_amount, 2) }} +
+ @endif +
+ Tax ({{ $estimate->tax_rate }}%): + ${{ number_format($estimate->tax_amount, 2) }} +
+
+
+ Total: + ${{ number_format($estimate->total_amount, 2) }} +
+
+
+ + +
+ @if($estimate->notes) +
+

Customer Notes

+

{{ $estimate->notes }}

+
+ @endif + + @if($estimate->internal_notes && auth()->user()->can('view', $estimate)) +
+

Internal Notes

+

{{ $estimate->internal_notes }}

+
+ @endif + + @if($estimate->terms_and_conditions) +
+

Terms & Conditions

+

{{ $estimate->terms_and_conditions }}

+
+ @endif +
+ + + @if($estimate->created_at) +
+

Estimate History

+
+
+ Created: + {{ $estimate->created_at->format('M j, Y g:i A') }} +
+ @if($estimate->sent_at) +
+ Sent to Customer: + {{ $estimate->sent_at->format('M j, Y g:i A') }} +
+ @endif + @if($estimate->customer_approved_at) +
+ Customer Response: + + {{ ucfirst($estimate->customer_approval_status) }} on {{ $estimate->customer_approved_at->format('M j, Y g:i A') }} + +
+ @endif + @if($estimate->preparedBy) +
+ Prepared By: + {{ $estimate->preparedBy->name }} +
+ @endif +
+
+ @endif
diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index d382623..5c03e0f 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -1,12 +1,19 @@
- +
+ + +
+ +
+
@if($result['icon'] === 'user') - + @elseif($result['icon'] === 'truck') - + @elseif($result['icon'] === 'clipboard-document-list') - + @elseif($result['icon'] === 'calendar') - + @else - + @endif
diff --git a/resources/views/livewire/inspections/create.blade.php b/resources/views/livewire/inspections/create.blade.php index cb07a6c..54e13a6 100644 --- a/resources/views/livewire/inspections/create.blade.php +++ b/resources/views/livewire/inspections/create.blade.php @@ -424,19 +424,19 @@
-
+
Damage
-
+
Dent
-
+
Scratch
-
+
Other
@@ -702,7 +702,7 @@ item.innerHTML = ` ${damage.description} diff --git a/resources/views/livewire/inspections/index.blade.php b/resources/views/livewire/inspections/index.blade.php index e97e6f4..2792a70 100644 --- a/resources/views/livewire/inspections/index.blade.php +++ b/resources/views/livewire/inspections/index.blade.php @@ -11,9 +11,13 @@
-
+
- + + +
+ +
diff --git a/resources/views/livewire/inspections/show.blade.php b/resources/views/livewire/inspections/show.blade.php index b2b241e..3ddc1fd 100644 --- a/resources/views/livewire/inspections/show.blade.php +++ b/resources/views/livewire/inspections/show.blade.php @@ -293,7 +293,7 @@ }; @endphp
- + {{ $damage['type'] }}
diff --git a/resources/views/livewire/inventory/dashboard.blade.php b/resources/views/livewire/inventory/dashboard.blade.php index 3011af0..44a922c 100644 --- a/resources/views/livewire/inventory/dashboard.blade.php +++ b/resources/views/livewire/inventory/dashboard.blade.php @@ -168,11 +168,11 @@
@if($movement->movement_type === 'in')
- +
@else
- +
@endif
@@ -270,37 +270,37 @@

Quick Actions

- + Add Part - + Create Purchase Order - + Record Stock Movement - + Add Supplier
- + Low Stock Items - + Out of Stock - + View Stock History - + Purchase Orders
diff --git a/resources/views/livewire/inventory/parts/create.blade.php b/resources/views/livewire/inventory/parts/create.blade.php index a73d143..453da47 100644 --- a/resources/views/livewire/inventory/parts/create.blade.php +++ b/resources/views/livewire/inventory/parts/create.blade.php @@ -6,7 +6,7 @@

Create a new part in your inventory catalog

- + Back to Parts
diff --git a/resources/views/livewire/inventory/parts/edit.blade.php b/resources/views/livewire/inventory/parts/edit.blade.php index cdf628b..0873677 100644 --- a/resources/views/livewire/inventory/parts/edit.blade.php +++ b/resources/views/livewire/inventory/parts/edit.blade.php @@ -6,7 +6,7 @@

Update part information in your inventory catalog

- + Back to Parts
diff --git a/resources/views/livewire/inventory/parts/index.blade.php b/resources/views/livewire/inventory/parts/index.blade.php index 3313b71..4063102 100644 --- a/resources/views/livewire/inventory/parts/index.blade.php +++ b/resources/views/livewire/inventory/parts/index.blade.php @@ -151,7 +151,7 @@
Part # @if($sortBy === 'part_number') - + @endif
@@ -159,7 +159,7 @@
Part Details @if($sortBy === 'name') - + @endif
@@ -168,7 +168,7 @@
Stock Level @if($sortBy === 'quantity_on_hand') - + @endif
@@ -176,7 +176,7 @@
Pricing @if($sortBy === 'cost_price') - + @endif
diff --git a/resources/views/livewire/inventory/purchase-orders/create.blade.php b/resources/views/livewire/inventory/purchase-orders/create.blade.php index d72eb1a..90e0db9 100644 --- a/resources/views/livewire/inventory/purchase-orders/create.blade.php +++ b/resources/views/livewire/inventory/purchase-orders/create.blade.php @@ -6,7 +6,7 @@

Create a new purchase order for inventory restocking

- + Back to Purchase Orders
diff --git a/resources/views/livewire/inventory/purchase-orders/edit.blade.php b/resources/views/livewire/inventory/purchase-orders/edit.blade.php index 574f1f4..7a1ca0b 100644 --- a/resources/views/livewire/inventory/purchase-orders/edit.blade.php +++ b/resources/views/livewire/inventory/purchase-orders/edit.blade.php @@ -6,7 +6,7 @@

Update purchase order details and items

- + Back to Order
diff --git a/resources/views/livewire/inventory/purchase-orders/index.blade.php b/resources/views/livewire/inventory/purchase-orders/index.blade.php index 7f56a7f..fbe5243 100644 --- a/resources/views/livewire/inventory/purchase-orders/index.blade.php +++ b/resources/views/livewire/inventory/purchase-orders/index.blade.php @@ -179,9 +179,9 @@ Order Number @if($sortBy === 'po_number') @if($sortDirection === 'asc') - + @else - + @endif @endif
@@ -191,9 +191,9 @@ Order Date @if($sortBy === 'order_date') @if($sortDirection === 'asc') - + @else - + @endif @endif
diff --git a/resources/views/livewire/inventory/purchase-orders/show.blade.php b/resources/views/livewire/inventory/purchase-orders/show.blade.php index 0aacc4b..a865499 100644 --- a/resources/views/livewire/inventory/purchase-orders/show.blade.php +++ b/resources/views/livewire/inventory/purchase-orders/show.blade.php @@ -33,7 +33,7 @@ @endif - + Back to Orders
diff --git a/resources/views/livewire/inventory/stock-movements/create.blade.php b/resources/views/livewire/inventory/stock-movements/create.blade.php index 20843e8..aa7e725 100644 --- a/resources/views/livewire/inventory/stock-movements/create.blade.php +++ b/resources/views/livewire/inventory/stock-movements/create.blade.php @@ -6,7 +6,7 @@

Manually record inventory adjustments and movements

- + Back to Stock Movements
diff --git a/resources/views/livewire/inventory/stock-movements/index.blade.php b/resources/views/livewire/inventory/stock-movements/index.blade.php index 106f334..6c20d57 100644 --- a/resources/views/livewire/inventory/stock-movements/index.blade.php +++ b/resources/views/livewire/inventory/stock-movements/index.blade.php @@ -161,9 +161,9 @@ Date @if($sortBy === 'created_at') @if($sortDirection === 'asc') - + @else - + @endif @endif
diff --git a/resources/views/livewire/inventory/suppliers/create.blade.php b/resources/views/livewire/inventory/suppliers/create.blade.php index e334e22..f2aa4cc 100644 --- a/resources/views/livewire/inventory/suppliers/create.blade.php +++ b/resources/views/livewire/inventory/suppliers/create.blade.php @@ -6,7 +6,7 @@

Create a new supplier for your inventory

- + Back to Suppliers
diff --git a/resources/views/livewire/inventory/suppliers/edit.blade.php b/resources/views/livewire/inventory/suppliers/edit.blade.php index e25da0e..9ec1456 100644 --- a/resources/views/livewire/inventory/suppliers/edit.blade.php +++ b/resources/views/livewire/inventory/suppliers/edit.blade.php @@ -6,7 +6,7 @@

Update supplier information

- + Back to Suppliers
diff --git a/resources/views/livewire/inventory/suppliers/index.blade.php b/resources/views/livewire/inventory/suppliers/index.blade.php index fc10613..8237688 100644 --- a/resources/views/livewire/inventory/suppliers/index.blade.php +++ b/resources/views/livewire/inventory/suppliers/index.blade.php @@ -108,7 +108,7 @@
Name @if($sortBy === 'name') - + @endif
@@ -116,7 +116,7 @@
Company @if($sortBy === 'company_name') - + @endif
@@ -167,9 +167,9 @@
@for($i = 1; $i <= 5; $i++) @if($i <= $supplier->rating) - + @else - + @endif @endfor
diff --git a/resources/views/livewire/invoices/create-from-estimate.blade.php b/resources/views/livewire/invoices/create-from-estimate.blade.php new file mode 100644 index 0000000..bca886b --- /dev/null +++ b/resources/views/livewire/invoices/create-from-estimate.blade.php @@ -0,0 +1,214 @@ +
+
+
+
+

Create Invoice from Estimate

+

Converting estimate #{{ $estimate->estimate_number }} to invoice

+
+
+ + Back to Estimate + +
+
+
+ + +
+
+ +
+

Source Estimate

+

{{ $estimate->subject }} - {{ $estimate->estimate_number }}

+
+
+
+ +
+ +
+

Invoice Details

+ +
+
+ + Subject + + + +
+ +
+ + Branch + + Select branch + @foreach($branches as $branch) + {{ $branch->name }} + @endforeach + + + +
+ +
+ + Invoice Date + + + +
+ +
+ + Due Date + + + +
+ +
+ + Description + + + +
+
+
+ + +
+
+

Line Items

+ + Add Item + +
+ +
+ @foreach($lineItems as $index => $item) +
+
+

Item {{ $index + 1 }}

+ @if(count($lineItems) > 1) + + Remove + + @endif +
+ +
+
+ + Type + + Service + Part + Other + + +
+ + @if($item['type'] === 'service') +
+ + Service Item + + Select service + @foreach($serviceItems as $serviceItem) + {{ $serviceItem->name }} - ${{ number_format($serviceItem->price, 2) }} + @endforeach + + +
+ @elseif($item['type'] === 'part') +
+ + Part + + +
+ @else +
+ @endif + +
+ + Description + + +
+ +
+ + Quantity + + +
+ +
+ + Unit Price + + +
+ +
+ + Total + + +
+
+
+ @endforeach +
+
+ + +
+

Invoice Summary

+ +
+
+ + Tax Rate (%) + + + +
+ +
+
+ Subtotal: + ${{ number_format($this->subtotal, 2) }} +
+
+ Tax ({{ $tax_rate }}%): + ${{ number_format($this->taxAmount, 2) }} +
+
+ Total: + ${{ number_format($this->total, 2) }} +
+
+
+
+ + +
+ + Cancel + + + Create Invoice + +
+
+
diff --git a/resources/views/livewire/invoices/create.blade.php b/resources/views/livewire/invoices/create.blade.php new file mode 100644 index 0000000..0f0426b --- /dev/null +++ b/resources/views/livewire/invoices/create.blade.php @@ -0,0 +1,224 @@ +
+ + Create Invoice + Create a new invoice for services and parts + + +
+ {{-- Basic Information --}} +
+ Invoice Details + +
+ + Customer + + @foreach($customers as $customer) + {{ $customer->name }} + @endforeach + + + + + + Branch + + @foreach($branches as $branch) + {{ $branch->name }} + @endforeach + + + + + + Invoice Date + + + + + + Due Date + + + +
+ + + Description + + + +
+ + {{-- Line Items --}} +
+
+ Line Items + + + Add Item + +
+ +
+ @foreach($line_items as $index => $item) +
+
+ {{-- Type --}} +
+ Type + + Labour + Parts + Miscellaneous + +
+ + {{-- Part Selection (only for parts) --}} + @if($item['type'] === 'parts') +
+ Part + + {{ count($parts) }} parts available +
+ @else +
+ Description + +
+ @endif + + {{-- Quantity --}} +
+ Quantity + +
+ + {{-- Unit Price --}} +
+ Unit Price + +
+ + {{-- Total --}} +
+ Total +
+ ${{ number_format(($item['quantity'] ?? 0) * ($item['unit_price'] ?? 0), 2) }} +
+
+ + {{-- Remove Button --}} +
+ @if(count($line_items) > 1) + + + + @endif +
+
+ + {{-- Additional fields for parts --}} + @if($item['type'] === 'parts') +
+ + Part Number + + + + Technical Notes + + +
+ @endif + + {{-- Technical notes for labour --}} + @if($item['type'] === 'labour') +
+ + Technical Notes + + +
+ @endif +
+ @endforeach +
+ + +
+ + {{-- Totals and Additional Info --}} +
+
+ {{-- Additional Information --}} +
+ + Tax Rate (%) + + + + + + Discount Amount + + + + + + Notes + + + +
+ + {{-- Totals Summary --}} +
+ Invoice Summary + +
+
+ Subtotal: + ${{ number_format($this->calculateSubtotal(), 2) }} +
+ @if($discount_amount > 0) +
+ Discount: + -${{ number_format($discount_amount, 2) }} +
+ @endif +
+ Tax ({{ $tax_rate }}%): + ${{ number_format($this->calculateTax(), 2) }} +
+
+
+ Total: + ${{ number_format($this->calculateTotal(), 2) }} +
+
+
+
+ + + Terms and Conditions + + + +
+ + {{-- Actions --}} +
+ Cancel + Create Invoice +
+
+
diff --git a/resources/views/livewire/invoices/edit.blade.php b/resources/views/livewire/invoices/edit.blade.php new file mode 100644 index 0000000..9b9961e --- /dev/null +++ b/resources/views/livewire/invoices/edit.blade.php @@ -0,0 +1,230 @@ +
+ + Edit Invoice #{{ $invoice->invoice_number }} + Modify invoice details and line items + + + + {{ ucfirst($invoice->status) }} + + + + +
+ {{-- Basic Information --}} +
+ Invoice Details + +
+ + Customer + + @foreach($customers as $customer) + {{ $customer->name }} + @endforeach + + + + + + Branch + + @foreach($branches as $branch) + {{ $branch->name }} + @endforeach + + + + + + Invoice Date + + + + + + Due Date + + + +
+ + + Description + + + +
+ + {{-- Line Items --}} +
+
+ Line Items + + + Add Item + +
+ +
+ @foreach($line_items as $index => $item) +
+
+ {{-- Type --}} +
+ Type + + Labour + Parts + Miscellaneous + +
+ + {{-- Part Selection (only for parts) --}} + @if($item['type'] === 'parts') +
+ Part + + {{ count($parts) }} parts available +
+ @else +
+ Description + +
+ @endif + + {{-- Quantity --}} +
+ Quantity + +
+ + {{-- Unit Price --}} +
+ Unit Price + +
+ + {{-- Total --}} +
+ Total +
+ ${{ number_format(($item['quantity'] ?? 0) * ($item['unit_price'] ?? 0), 2) }} +
+
+ + {{-- Remove Button --}} +
+ @if(count($line_items) > 1) + + + + @endif +
+
+ + {{-- Additional fields for parts --}} + @if($item['type'] === 'parts') +
+ + Part Number + + + + Technical Notes + + +
+ @endif + + {{-- Technical notes for labour --}} + @if($item['type'] === 'labour') +
+ + Technical Notes + + +
+ @endif +
+ @endforeach +
+ + +
+ + {{-- Totals and Additional Info --}} +
+
+ {{-- Additional Information --}} +
+ + Tax Rate (%) + + + + + + Discount Amount + + + + + + Notes + + + +
+ + {{-- Totals Summary --}} +
+ Invoice Summary + +
+
+ Subtotal: + ${{ number_format($this->calculateSubtotal(), 2) }} +
+ @if($discount_amount > 0) +
+ Discount: + -${{ number_format($discount_amount, 2) }} +
+ @endif +
+ Tax ({{ $tax_rate }}%): + ${{ number_format($this->calculateTax(), 2) }} +
+
+
+ Total: + ${{ number_format($this->calculateTotal(), 2) }} +
+
+
+
+ + + Terms and Conditions + + + +
+ + {{-- Actions --}} +
+ Cancel + Update Invoice +
+
+
diff --git a/resources/views/livewire/invoices/index.blade.php b/resources/views/livewire/invoices/index.blade.php new file mode 100644 index 0000000..a1ec5ee --- /dev/null +++ b/resources/views/livewire/invoices/index.blade.php @@ -0,0 +1,299 @@ +
+ +
+
+
+

Invoices

+

Manage customer invoices and billing

+
+
+ + + New Invoice + +
+
+
+ + +
+
+
+ + Search + + +
+ +
+ + Status + + + @foreach($statusOptions as $value => $label) + + @endforeach + + +
+ +
+ + Branch + + + @foreach($branches as $branch) + + @endforeach + + +
+ +
+ + Clear Filters + +
+
+ +
+
+ + Date From + + +
+ +
+ + Date To + + +
+
+
+ + +
+
+
+
+ +
+
+

Total Invoices

+

{{ number_format($invoices->total()) }}

+
+
+
+ +
+
+
+ +
+
+

Paid

+

+ {{ $invoices->where('status', 'paid')->count() }} +

+
+
+
+ +
+
+
+ +
+
+

Pending

+

+ {{ $invoices->whereIn('status', ['draft', 'sent'])->count() }} +

+
+
+
+ +
+
+
+ +
+
+

Overdue

+

+ {{ $invoices->where('status', 'overdue')->count() }} +

+
+
+
+
+ + +
+
+ + + + + + + + + + + + + + @forelse($invoices as $invoice) + + + + + + + + + + @empty + + + + @endforelse + +
+
+ Invoice # + @if($sortField === 'invoice_number') + @if($sortDirection === 'asc') + + @else + + @endif + @endif +
+
Customer +
+ Date + @if($sortField === 'invoice_date') + @if($sortDirection === 'asc') + + @else + + @endif + @endif +
+
+
+ Due Date + @if($sortField === 'due_date') + @if($sortDirection === 'asc') + + @else + + @endif + @endif +
+
+
+ Amount + @if($sortField === 'total_amount') + @if($sortDirection === 'asc') + + @else + + @endif + @endif +
+
StatusActions
+
+ {{ $invoice->invoice_number }} +
+
+ {{ $invoice->branch->name }} +
+
+
+ {{ $invoice->customer->name }} +
+
+ {{ $invoice->customer->email }} +
+
+ {{ $invoice->invoice_date->format('M j, Y') }} + + {{ $invoice->due_date->format('M j, Y') }} + @if($invoice->isOverdue()) + (Overdue) + @endif + + ${{ number_format($invoice->total_amount, 2) }} + + + {{ ucfirst($invoice->status) }} + + +
+ + + + + @if($invoice->status !== 'paid') + + + + + @if($invoice->status === 'draft') + + + + @endif + + + + + @endif + + @if($invoice->status === 'draft') + + + + @endif +
+
+ +

No invoices found

+

Get started by creating your first invoice.

+ + Create Invoice + +
+
+ + @if($invoices->hasPages()) +
+ {{ $invoices->links() }} +
+ @endif +
+
diff --git a/resources/views/livewire/invoices/show.blade.php b/resources/views/livewire/invoices/show.blade.php new file mode 100644 index 0000000..2e9aa55 --- /dev/null +++ b/resources/views/livewire/invoices/show.blade.php @@ -0,0 +1,380 @@ +
+ +
+
+
+
+

Invoice {{ $invoice->invoice_number }}

+ + {{ ucfirst($invoice->status) }} + +
+

+ Created on {{ $invoice->created_at->format('M j, Y g:i A') }} by {{ $invoice->createdBy->name }} +

+
+
+ + + Download PDF + + + @if($invoice->status !== 'paid') + + + Edit + + + @if($invoice->status === 'draft') + + + Send Invoice + + @endif + + + + Mark as Paid + + @endif + + + + Duplicate + +
+
+
+ + +
+ +
+ +
+

Invoice Information

+
+
+

Invoice Number

+

{{ $invoice->invoice_number }}

+
+
+

Branch

+

{{ $invoice->branch->name }}

+
+
+

Invoice Date

+

{{ $invoice->invoice_date->format('M j, Y') }}

+
+
+

Due Date

+

{{ $invoice->due_date->format('M j, Y') }} + @if($invoice->isOverdue()) + (Overdue) + @endif +

+
+
+ + @if($invoice->description) +
+

Description

+

{{ $invoice->description }}

+
+ @endif +
+ + +
+

Customer Information

+
+
+

Name

+

{{ $invoice->customer->name }}

+
+
+

Email

+

{{ $invoice->customer->email }}

+
+
+

Phone

+

{{ $invoice->customer->phone }}

+
+ @if($invoice->customer->address) +
+

Address

+

{{ $invoice->customer->address }}

+
+ @endif +
+
+ + + @php + $vehicle = $invoice->serviceOrder?->vehicle ?? $invoice->jobCard?->vehicle; + @endphp + @if($vehicle) +
+

Vehicle Information

+
+
+

Vehicle

+

{{ $vehicle->year }} {{ $vehicle->make }} {{ $vehicle->model }}

+
+
+

License Plate

+

{{ $vehicle->license_plate }}

+
+ @if($vehicle->vin) +
+

VIN

+

{{ $vehicle->vin }}

+
+ @endif + @if($vehicle->mileage) +
+

Mileage

+

{{ number_format($vehicle->mileage) }} miles

+
+ @endif +
+
+ @endif + + +
+
+

Services & Parts

+
+ + @if($invoice->lineItems->count() > 0) +
+ + + + + + + + + + + + @foreach($invoice->lineItems as $item) + + + + + + + + @endforeach + +
TypeDescriptionQtyUnit PriceTotal
+ + {{ ucfirst($item->type) }} + + +
{{ $item->description }}
+ @if($item->part) +
Part #: {{ $item->part->part_number }}
+ @endif + @if($item->part_number) +
Part #: {{ $item->part_number }}
+ @endif + @if($item->technical_notes) +
{{ $item->technical_notes }}
+ @endif +
+ {{ $item->quantity }} + + ${{ number_format($item->unit_price, 2) }} + + ${{ number_format($item->total_amount, 2) }} +
+
+ @else +
+ +

No line items added to this invoice.

+
+ @endif +
+ + + @if($invoice->notes) +
+

Notes

+

{{ $invoice->notes }}

+
+ @endif +
+ + +
+ +
+

Financial Summary

+
+
+ Subtotal: + ${{ number_format($invoice->subtotal, 2) }} +
+ + @if($invoice->discount_amount > 0) +
+ Discount: + -${{ number_format($invoice->discount_amount, 2) }} +
+ @endif + +
+ Tax ({{ $invoice->tax_rate }}%): + ${{ number_format($invoice->tax_amount, 2) }} +
+ +
+
+ Total: + ${{ number_format($invoice->total_amount, 2) }} +
+
+
+
+ + + @if($invoice->isPaid()) +
+

Payment Information

+
+
+

Payment Date

+

{{ $invoice->paid_at->format('M j, Y g:i A') }}

+
+ + @if($invoice->payment_method) +
+

Payment Method

+

{{ ucfirst(str_replace('_', ' ', $invoice->payment_method)) }}

+
+ @endif + + @if($invoice->payment_reference) +
+

Reference

+

{{ $invoice->payment_reference }}

+
+ @endif + + @if($invoice->payment_notes) +
+

Notes

+

{{ $invoice->payment_notes }}

+
+ @endif +
+
+ @endif + + + @if($invoice->serviceOrder || $invoice->jobCard || $invoice->estimate) +
+

Related Records

+
+ @if($invoice->serviceOrder) +
+

Service Order

+ + #{{ $invoice->serviceOrder->order_number }} + +
+ @endif + + @if($invoice->jobCard) +
+

Job Card

+ + #{{ $invoice->jobCard->job_number }} + +
+ @endif + + @if($invoice->estimate) +
+

Estimate

+ + #{{ $invoice->estimate->estimate_number }} + +
+ @endif +
+
+ @endif + + +
+

Quick Actions

+
+ + + Download PDF + + + @if($invoice->status !== 'paid') + @if($invoice->status === 'draft') + + + Send Invoice + + @endif + + + + Mark as Paid + + @endif + + + + Duplicate Invoice + + + + + Back to Invoices + +
+
+
+
+
diff --git a/resources/views/livewire/job-cards/index.blade.php b/resources/views/livewire/job-cards/index.blade.php index 43639a4..a626bf4 100644 --- a/resources/views/livewire/job-cards/index.blade.php +++ b/resources/views/livewire/job-cards/index.blade.php @@ -107,11 +107,18 @@
- +
+ + +
+ +
+
@foreach($statusOptions as $value => $label) diff --git a/resources/views/livewire/job-cards/show.blade.php b/resources/views/livewire/job-cards/show.blade.php index 544e188..e1d0181 100644 --- a/resources/views/livewire/job-cards/show.blade.php +++ b/resources/views/livewire/job-cards/show.blade.php @@ -41,7 +41,7 @@
- + Edit @@ -50,24 +50,24 @@ @if($jobCard->status === 'inspected') - + Assign for Diagnosis @elseif($jobCard->status === 'assigned_for_diagnosis') - + Start Diagnosis @elseif($jobCard->status === 'in_diagnosis' && !$jobCard->diagnosis) - + Create Diagnosis @elseif($jobCard->diagnosis) - + View Diagnosis @@ -76,7 +76,7 @@ @if(in_array($jobCard->status, ['received', 'in_diagnosis'])) - + {{ $jobCard->status === 'received' ? 'Start Workflow' : 'Continue Workflow' }} @@ -462,13 +462,13 @@ @@ -171,7 +171,7 @@
Parts - + Add Part
@@ -208,7 +208,7 @@
- +
diff --git a/resources/views/livewire/service-orders/index.blade.php b/resources/views/livewire/service-orders/index.blade.php index a9235d8..dc0e8ff 100644 --- a/resources/views/livewire/service-orders/index.blade.php +++ b/resources/views/livewire/service-orders/index.blade.php @@ -54,11 +54,18 @@
- +
+ + +
+ +
+
@@ -116,7 +123,7 @@
{{ $vehicle->display_name }}
diff --git a/resources/views/livewire/work-orders/index.blade.php b/resources/views/livewire/work-orders/index.blade.php index 23fa440..4f1d0d3 100644 --- a/resources/views/livewire/work-orders/index.blade.php +++ b/resources/views/livewire/work-orders/index.blade.php @@ -11,9 +11,13 @@
-
+
- + + +
+ +
diff --git a/routes/web.php b/routes/web.php index a637c4e..2d55508 100644 --- a/routes/web.php +++ b/routes/web.php @@ -226,6 +226,15 @@ Route::middleware(['auth', 'admin.only'])->group(function () { Route::get('/{user}/manage-roles', \App\Livewire\Users\ManageRolesPermissions::class)->middleware('permission:users.manage-roles')->name('manage-roles'); }); + // Invoice Management Routes + Route::prefix('invoices')->name('invoices.')->middleware('permission:invoices.view')->group(function () { + Route::get('/', \App\Livewire\Invoices\Index::class)->name('index'); + Route::get('/create', \App\Livewire\Invoices\Create::class)->middleware('permission:invoices.create')->name('create'); + Route::get('/create-from-estimate/{estimate}', \App\Livewire\Invoices\CreateFromEstimate::class)->middleware('permission:invoices.create')->name('create-from-estimate'); + Route::get('/{invoice}', \App\Livewire\Invoices\Show::class)->name('show'); + Route::get('/{invoice}/edit', \App\Livewire\Invoices\Edit::class)->middleware('permission:invoices.update')->name('edit'); + }); + // Legacy User Management Route (redirect to new structure) Route::get('/user-management', function () { return redirect()->route('users.index'); diff --git a/tests/Feature/Estimates/EstimatePdfTest.php b/tests/Feature/Estimates/EstimatePdfTest.php new file mode 100644 index 0000000..a5de0a9 --- /dev/null +++ b/tests/Feature/Estimates/EstimatePdfTest.php @@ -0,0 +1,58 @@ +create(); + $user->assignRole('admin'); + + // Create a simple estimate + $estimate = Estimate::factory()->create([ + 'estimate_number' => 'TEST-001', + 'status' => 'draft', + 'total_amount' => 100.00, + ]); + + // Test the Show component can be mounted + $component = Livewire::actingAs($user) + ->test(Show::class, ['estimate' => $estimate]) + ->assertOk(); + + // Verify estimate is loaded correctly + $this->assertEquals($estimate->id, $component->estimate->id); + $this->assertEquals($estimate->estimate_number, $component->estimate->estimate_number); + } + + public function test_pdf_template_renders_without_errors(): void + { + $estimate = Estimate::factory()->create([ + 'estimate_number' => 'TEST-PDF-001', + 'status' => 'draft', + 'total_amount' => 150.00, + ]); + + // Test that the PDF view can be rendered + $view = view('estimates.pdf', compact('estimate')); + + $this->assertNotNull($view); + + $html = $view->render(); + $this->assertStringContainsString('ESTIMATE', $html); + $this->assertStringContainsString($estimate->estimate_number, $html); + } +}