- Added buttons for assigning diagnosis and starting diagnosis based on job card status in the job card view. - Implemented a modal for assigning technicians for diagnosis, including form validation and technician selection. - Updated routes to include a test route for job cards. - Created a new Blade view for testing inspection inputs. - Developed comprehensive feature tests for the estimate module, including creation, viewing, editing, and validation of estimates. - Added tests for estimate model relationships and statistics calculations. - Introduced a basic feature test for job cards index.
505 lines
17 KiB
PHP
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' => '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');
|
|
}
|
|
}
|