- 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.
287 lines
8.9 KiB
PHP
287 lines
8.9 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Estimates;
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\Estimate;
|
|
use App\Models\EstimateLineItem;
|
|
use App\Models\Part;
|
|
use App\Models\Vehicle;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Component;
|
|
|
|
class CreateStandalone extends Component
|
|
{
|
|
public $customerId = '';
|
|
|
|
public $vehicleId = '';
|
|
|
|
public $selectedCustomer = null;
|
|
|
|
public $selectedVehicle = null;
|
|
|
|
public $customerVehicles = [];
|
|
|
|
public $terms_and_conditions = '';
|
|
|
|
public $validity_period_days = 30;
|
|
|
|
public $tax_rate = 8.25;
|
|
|
|
public $discount_amount = 0;
|
|
|
|
public $notes = '';
|
|
|
|
public $internal_notes = '';
|
|
|
|
public $lineItems = [];
|
|
|
|
public $subtotal = 0;
|
|
|
|
public $tax_amount = 0;
|
|
|
|
public $total_amount = 0;
|
|
|
|
// Parts search
|
|
public $partSearch = '';
|
|
|
|
public $availableParts = [];
|
|
|
|
public $showPartsDropdown = false;
|
|
|
|
protected $rules = [
|
|
'customerId' => 'required|exists:customers,id',
|
|
'vehicleId' => 'required|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.*.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' => 1,
|
|
'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) {
|
|
if (isset($item['quantity'], $item['unit_price'])) {
|
|
$lineSubtotal = $item['quantity'] * $item['unit_price'];
|
|
$this->lineItems[$index]['subtotal'] = $lineSubtotal;
|
|
$this->subtotal += $lineSubtotal;
|
|
}
|
|
}
|
|
|
|
$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';
|
|
|
|
$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;
|
|
}
|
|
|
|
$estimate = Estimate::create([
|
|
'estimate_number' => $this->generateEstimateNumber(),
|
|
'customer_id' => $this->customerId,
|
|
'vehicle_id' => $this->vehicleId,
|
|
'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,
|
|
]);
|
|
}
|
|
}
|