['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], ]; // Line item templates public $newItem = [ 'type' => 'labor', 'description' => '', 'quantity' => 1, 'unit_price' => 0, 'part_id' => null, 'markup_percentage' => 15, 'discount_type' => 'none', 'discount_value' => 0, 'notes' => '', 'is_taxable' => true, ]; protected $rules = [ '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.*.description' => 'required|string', 'lineItems.*.quantity' => 'required|numeric|min:0.01', 'lineItems.*.unit_price' => 'required|numeric|min:0', ]; public function mount(Estimate $estimate) { $this->estimate = $estimate->load([ 'jobCard.customer', 'jobCard.vehicle', 'customer', // For standalone estimates 'vehicle', // For standalone estimates 'lineItems.part', ]); $this->loadEstimateData(); $this->loadLineItems(); $this->calculateTotals(); } protected function loadEstimateData() { $this->terms_and_conditions = $this->estimate->terms_and_conditions ?? ''; $this->validity_period_days = $this->estimate->validity_period_days ?? 30; $this->tax_rate = $this->estimate->tax_rate ?? 8.25; $this->discount_amount = $this->estimate->discount_amount ?? 0; $this->notes = $this->estimate->notes ?? ''; $this->internal_notes = $this->estimate->internal_notes ?? ''; } protected function loadLineItems() { $this->lineItems = $this->estimate->lineItems->map(function ($item) { return [ 'id' => $item->id, 'type' => $item->type, 'description' => $item->description, 'quantity' => $item->quantity, 'unit_price' => $item->unit_price, 'total_amount' => $item->total_amount, 'labor_hours' => $item->labor_hours, 'labor_rate' => $item->labor_rate, 'markup_percentage' => $item->markup_percentage ?? 15, 'discount_type' => $item->discount_type ?? 'none', 'discount_value' => $item->discount_value ?? 0, 'part_id' => $item->part_id, 'part_name' => $item->part?->name, 'notes' => $item->notes ?? '', 'is_taxable' => $item->is_taxable ?? true, 'is_editing' => false, 'required' => true, ]; })->toArray(); } public function addLineItem() { $this->validate([ 'newItem.type' => 'required|in:labor,parts,miscellaneous', 'newItem.description' => 'required|string|max:255', 'newItem.quantity' => 'required|numeric|min:0.01', 'newItem.unit_price' => 'required|numeric|min:0', ]); $lineItem = [ 'id' => null, 'type' => $this->newItem['type'], 'description' => $this->newItem['description'], 'quantity' => $this->newItem['quantity'], 'unit_price' => $this->newItem['unit_price'], 'part_id' => $this->newItem['part_id'], 'markup_percentage' => $this->newItem['markup_percentage'], 'discount_type' => $this->newItem['discount_type'], 'discount_value' => $this->newItem['discount_value'], 'notes' => $this->newItem['notes'], 'is_taxable' => $this->newItem['is_taxable'], 'is_editing' => false, 'required' => true, ]; $lineItem['total_amount'] = $this->calculateLineItemTotal($lineItem); $this->lineItems[] = $lineItem; // Reset new item form $this->newItem = [ 'type' => 'labor', 'description' => '', 'quantity' => 1, 'unit_price' => 0, 'part_id' => null, 'markup_percentage' => 15, 'discount_type' => 'none', 'discount_value' => 0, 'notes' => '', 'is_taxable' => true, ]; $this->calculateTotals(); if ($this->autoSave) { $this->autoSaveEstimate(); } } public function addQuickPreset($presetKey) { if (isset($this->quickAddPresets[$presetKey])) { $preset = $this->quickAddPresets[$presetKey]; $lineItem = array_merge([ 'id' => null, 'part_id' => null, 'markup_percentage' => 15, 'discount_type' => 'none', 'discount_value' => 0, 'notes' => '', 'is_taxable' => true, 'is_editing' => false, 'required' => true, ], $preset); $lineItem['total_amount'] = $this->calculateLineItemTotal($lineItem); $this->lineItems[] = $lineItem; $this->calculateTotals(); if ($this->autoSave) { $this->autoSaveEstimate(); } } } public function removeLineItem($index) { if (isset($this->lineItems[$index]['id'])) { $this->deletedItems[] = $this->lineItems[$index]['id']; } unset($this->lineItems[$index]); $this->lineItems = array_values($this->lineItems); $this->calculateTotals(); if ($this->autoSave) { $this->autoSaveEstimate(); } } public function duplicateLineItem($index) { if (isset($this->lineItems[$index])) { $item = $this->lineItems[$index]; unset($item['id']); // Remove ID so it's treated as new $item['description'] .= ' (Copy)'; $item['is_editing'] = false; $this->lineItems[] = $item; $this->calculateTotals(); } } public function editLineItem($index) { $this->lineItems[$index]['is_editing'] = true; } public function saveLineItem($index) { $this->lineItems[$index]['is_editing'] = false; $this->lineItems[$index]['total_amount'] = $this->calculateLineItemTotal($this->lineItems[$index]); $this->calculateTotals(); if ($this->autoSave) { $this->autoSaveEstimate(); } } public function cancelEditLineItem($index) { $this->lineItems[$index]['is_editing'] = false; // Reload from database if it's an existing item if (isset($this->lineItems[$index]['id'])) { $this->loadLineItems(); $this->calculateTotals(); } } public function toggleBulkMode() { $this->bulkOperationMode = ! $this->bulkOperationMode; $this->selectedItems = []; } public function bulkDelete() { foreach ($this->selectedItems as $index) { if (isset($this->lineItems[$index]['id'])) { $this->deletedItems[] = $this->lineItems[$index]['id']; } unset($this->lineItems[$index]); } $this->lineItems = array_values($this->lineItems); $this->selectedItems = []; $this->bulkOperationMode = false; $this->calculateTotals(); } public function bulkApplyDiscount($discountType, $discountValue) { foreach ($this->selectedItems as $index) { if (isset($this->lineItems[$index])) { $this->lineItems[$index]['discount_type'] = $discountType; $this->lineItems[$index]['discount_value'] = $discountValue; $this->lineItems[$index]['total_amount'] = $this->calculateLineItemTotal($this->lineItems[$index]); } } $this->selectedItems = []; $this->bulkOperationMode = false; $this->calculateTotals(); } public function bulkApplyMarkup($markupPercentage) { foreach ($this->selectedItems as $index) { if (isset($this->lineItems[$index])) { $this->lineItems[$index]['markup_percentage'] = $markupPercentage; $this->lineItems[$index]['total_amount'] = $this->calculateLineItemTotal($this->lineItems[$index]); } } $this->selectedItems = []; $this->bulkOperationMode = false; $this->calculateTotals(); } protected function calculateLineItemTotal($item) { $baseAmount = $item['quantity'] * $item['unit_price']; // Apply markup if (isset($item['markup_percentage']) && $item['markup_percentage'] > 0) { $baseAmount += $baseAmount * ($item['markup_percentage'] / 100); } // Apply discount if ($item['discount_type'] === 'percentage') { $discount = $baseAmount * ($item['discount_value'] / 100); } elseif ($item['discount_type'] === 'fixed') { $discount = min($item['discount_value'], $baseAmount); } else { $discount = 0; } return $baseAmount - $discount; } public function updatedLineItems() { $this->calculateTotals(); if ($this->autoSave) { $this->autoSaveEstimate(); } } public function calculateTotals() { // Calculate line item totals foreach ($this->lineItems as $index => $item) { $this->lineItems[$index]['total_amount'] = $this->calculateLineItemTotal($item); } // Calculate subtotal $this->subtotal = collect($this->lineItems)->sum('total_amount'); // Calculate tax on taxable items only $taxableAmount = collect($this->lineItems) ->where('is_taxable', true) ->sum('total_amount') - $this->discount_amount; $this->tax_amount = max(0, $taxableAmount) * ($this->tax_rate / 100); // Calculate total $this->total_amount = $this->subtotal - $this->discount_amount + $this->tax_amount; } public function autoSaveEstimate() { try { $this->estimate->update([ 'subtotal' => $this->subtotal, 'tax_amount' => $this->tax_amount, 'total_amount' => $this->total_amount, 'labor_cost' => collect($this->lineItems)->where('type', 'labor')->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'), 'last_auto_saved_at' => now(), ]); $this->lastSaved = now()->format('H:i:s'); } catch (\Exception $e) { Log::error('Auto-save failed', [ 'estimate_id' => $this->estimate->id, 'error' => $e->getMessage(), ]); } } public function save() { $this->validate(); try { DB::transaction(function () { $this->calculateTotals(); // Update estimate $this->estimate->update([ 'terms_and_conditions' => $this->terms_and_conditions, 'validity_period_days' => $this->validity_period_days, 'tax_rate' => $this->tax_rate, 'discount_amount' => $this->discount_amount, 'notes' => $this->notes, 'internal_notes' => $this->internal_notes, 'subtotal' => $this->subtotal, 'tax_amount' => $this->tax_amount, 'total_amount' => $this->total_amount, 'labor_cost' => collect($this->lineItems)->where('type', 'labor')->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'), 'updated_by_id' => auth()->id(), ]); // Delete removed items if (! empty($this->deletedItems)) { EstimateLineItem::whereIn('id', $this->deletedItems)->delete(); } // Update/create line items foreach ($this->lineItems as $item) { if ($item['id']) { EstimateLineItem::where('id', $item['id'])->update([ 'type' => $item['type'], 'description' => $item['description'], 'quantity' => $item['quantity'], 'unit_price' => $item['unit_price'], 'total_amount' => $item['total_amount'], 'labor_hours' => $item['labor_hours'] ?? null, 'labor_rate' => $item['labor_rate'] ?? null, 'markup_percentage' => $item['markup_percentage'] ?? null, 'discount_type' => $item['discount_type'] ?? 'none', 'discount_value' => $item['discount_value'] ?? 0, 'part_id' => $item['part_id'], 'notes' => $item['notes'] ?? '', 'is_taxable' => $item['is_taxable'] ?? true, ]); } else { $this->estimate->lineItems()->create([ 'type' => $item['type'], 'description' => $item['description'], 'quantity' => $item['quantity'], 'unit_price' => $item['unit_price'], 'total_amount' => $item['total_amount'], 'labor_hours' => $item['labor_hours'] ?? null, 'labor_rate' => $item['labor_rate'] ?? null, 'markup_percentage' => $item['markup_percentage'] ?? null, 'discount_type' => $item['discount_type'] ?? 'none', 'discount_value' => $item['discount_value'] ?? 0, 'part_id' => $item['part_id'], 'notes' => $item['notes'] ?? '', 'is_taxable' => $item['is_taxable'] ?? true, ]); } } }); Log::info('Estimate updated', [ 'estimate_id' => $this->estimate->id, 'updated_by' => auth()->id(), ]); session()->flash('success', 'Estimate updated successfully.'); return redirect()->route('estimates.show', $this->estimate); } catch (\Exception $e) { Log::error('Failed to update estimate', [ 'estimate_id' => $this->estimate->id, 'error' => $e->getMessage(), ]); session()->flash('error', 'Failed to update estimate. Please try again.'); } } public function getAvailablePartsProperty() { return Part::where('stock_quantity', '>', 0) ->orderBy('name') ->get(['id', 'name', 'part_number', 'unit_price']); } public function toggleAdvancedOptions() { $this->showAdvancedOptions = ! $this->showAdvancedOptions; } public function toggleAutoSave() { $this->autoSave = ! $this->autoSave; } #[Layout('components.layouts.app')] public function render() { return view('livewire.estimates.edit'); } }