'required|exists:customers,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:labour,parts,miscellaneous', 'lineItems.*.description' => 'required|string', 'lineItems.*.quantity' => 'required|numeric|min:0.01', 'lineItems.*.unit_price' => 'required|numeric|min:0', 'lineItems.*.part_id' => 'nullable|exists:parts,id', ]; public function mount() { $this->initializeDefaults(); $this->addLineItem(); } private function initializeDefaults() { $this->terms_and_conditions = 'This estimate is valid for '.$this->validity_period_days.' days from the date of issue. All prices are subject to change without notice. Additional charges may apply for unforeseen complications.'; } public function updatedCustomerId($value) { if ($value) { $this->selectedCustomer = Customer::find($value); $this->customerVehicles = Vehicle::where('customer_id', $value)->get(); $this->vehicleId = ''; $this->selectedVehicle = null; } else { $this->selectedCustomer = null; $this->customerVehicles = []; $this->vehicleId = ''; $this->selectedVehicle = null; } } public function updatedVehicleId($value) { if ($value) { $this->selectedVehicle = Vehicle::find($value); } else { $this->selectedVehicle = null; } } public function addLineItem() { $this->lineItems[] = [ 'part_id' => null, 'part_number' => '', 'description' => '', 'quantity' => '', // Start empty to force user input 'unit_price' => 0, 'subtotal' => 0, 'type' => 'labour', 'stock_available' => null, ]; } public function removeLineItem($index) { unset($this->lineItems[$index]); $this->lineItems = array_values($this->lineItems); $this->calculateTotals(); } public function updatedLineItems($value, $name) { // Extract the index and field name from the wire:model path if (preg_match('/(\d+)\.(.+)/', $name, $matches)) { $index = $matches[1]; $field = $matches[2]; // Validate stock levels for parts if ($field === 'quantity' && isset($this->lineItems[$index]['part_id']) && $this->lineItems[$index]['part_id']) { $part = Part::find($this->lineItems[$index]['part_id']); if ($part && $value > $part->quantity_on_hand) { $this->addError("lineItems.{$index}.quantity", "Quantity cannot exceed available stock ({$part->quantity_on_hand})"); return; } } // Clear the quantity error if validation passes if ($field === 'quantity') { $this->resetErrorBag("lineItems.{$index}.quantity"); } } $this->calculateTotals(); } public function updatedPartSearch() { $this->searchParts($this->partSearch); } public function calculateTotals() { $this->subtotal = 0; foreach ($this->lineItems as $index => $item) { $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; } } $discountedSubtotal = max(0, $this->subtotal - ($this->discount_amount ?? 0)); $this->tax_amount = $discountedSubtotal * ($this->tax_rate / 100); $this->total_amount = $discountedSubtotal + $this->tax_amount; } public function searchParts($query = '') { if (strlen($query) < 2) { $this->availableParts = []; $this->showPartsDropdown = false; return; } $this->availableParts = Part::where('status', 'active') ->where(function ($q) use ($query) { $q->where('name', 'like', "%{$query}%") ->orWhere('part_number', 'like', "%{$query}%") ->orWhere('description', 'like', "%{$query}%"); }) ->orderBy('name') ->limit(10) ->get(); $this->showPartsDropdown = true; } public function selectPart($lineIndex, $partId) { $part = Part::find($partId); if ($part) { $this->lineItems[$lineIndex]['part_id'] = $part->id; $this->lineItems[$lineIndex]['part_number'] = $part->part_number; $this->lineItems[$lineIndex]['description'] = $part->name; $this->lineItems[$lineIndex]['unit_price'] = $part->sell_price; $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(); } $this->partSearch = ''; $this->availableParts = []; $this->showPartsDropdown = false; } public function clearPartSelection($lineIndex) { $this->lineItems[$lineIndex]['part_id'] = null; $this->lineItems[$lineIndex]['part_number'] = ''; $this->lineItems[$lineIndex]['stock_available'] = null; $this->calculateTotals(); } public function save() { $this->validate(); if (empty($this->lineItems)) { session()->flash('error', 'At least one line item is required.'); 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 ?: 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', 'customer_approval_status' => 'pending', 'terms_and_conditions' => $this->terms_and_conditions, 'validity_period_days' => $this->validity_period_days, 'tax_rate' => $this->tax_rate, 'discount_amount' => $this->discount_amount ?? 0, 'subtotal_amount' => $this->subtotal, 'tax_amount' => $this->tax_amount, 'total_amount' => $this->total_amount, 'notes' => $this->notes, 'internal_notes' => $this->internal_notes, 'prepared_by_id' => auth()->id(), ]); foreach ($this->lineItems as $item) { EstimateLineItem::create([ 'estimate_id' => $estimate->id, 'part_id' => $item['part_id'], 'part_number' => $item['part_number'], 'type' => $item['type'], 'description' => $item['description'], 'quantity' => $item['quantity'], 'unit_price' => $item['unit_price'], 'total_amount' => $item['quantity'] * $item['unit_price'], // Calculate total_amount 'subtotal' => $item['subtotal'], ]); } session()->flash('success', 'Estimate created successfully.'); return redirect()->route('estimates.show', $estimate); } private function generateEstimateNumber() { $lastEstimate = Estimate::latest()->first(); $lastNumber = $lastEstimate ? (int) substr($lastEstimate->estimate_number, -5) : 0; return 'EST-'.str_pad($lastNumber + 1, 5, '0', STR_PAD_LEFT); } #[Layout('components.layouts.app')] public function render() { $customers = Customer::orderBy('first_name')->orderBy('last_name')->get(); return view('livewire.estimates.create-standalone', [ 'customers' => $customers, ]); } }