Car-Repairs-Shop/app/Livewire/Estimates/CreateStandalone.php
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

304 lines
9.7 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' => '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,
]);
}
}