sackey e3b2b220d2
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
Enhance UI and functionality across various components
- Increased icon sizes in service items, service orders, users, and technician management for better visibility.
- Added custom loading indicators with appropriate icons in search fields for vehicles, work orders, and technicians.
- Introduced invoice management routes for better organization and access control.
- Created a new test for the estimate PDF functionality to ensure proper rendering and data integrity.
2025-08-16 14:36:58 +00:00

505 lines
17 KiB
PHP

<?php
namespace App\Livewire\Estimates;
use App\Models\Estimate;
use App\Models\EstimateLineItem;
use App\Models\Part;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Edit extends Component
{
public Estimate $estimate;
#[Validate('required|string')]
public $terms_and_conditions = '';
#[Validate('required|integer|min:1|max:365')]
public $validity_period_days = 30;
#[Validate('required|numeric|min:0|max:50')]
public $tax_rate = 8.25;
#[Validate('nullable|numeric|min:0')]
public $discount_amount = 0;
#[Validate('nullable|string')]
public $notes = '';
#[Validate('nullable|string')]
public $internal_notes = '';
public $lineItems = [];
public $deletedItems = [];
public $subtotal = 0;
public $tax_amount = 0;
public $total_amount = 0;
// Advanced features
public $showAdvancedOptions = false;
public $bulkOperationMode = false;
public $selectedItems = [];
public $autoSave = true;
public $lastSaved;
// Quick add presets
public $quickAddPresets = [
'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' => 'labour',
'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:labour,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');
}
}