- 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.
475 lines
15 KiB
PHP
475 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Estimates;
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\Estimate;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\Url;
|
|
use Livewire\Component;
|
|
use Livewire\WithPagination;
|
|
|
|
class Index extends Component
|
|
{
|
|
use WithPagination;
|
|
|
|
#[Url(as: 'search')]
|
|
public $search = '';
|
|
|
|
#[Url(as: 'status')]
|
|
public $statusFilter = '';
|
|
|
|
#[Url(as: 'approval')]
|
|
public $approvalStatusFilter = '';
|
|
|
|
#[Url(as: 'customer')]
|
|
public $customerFilter = '';
|
|
|
|
#[Url(as: 'date_from')]
|
|
public $dateFrom = '';
|
|
|
|
#[Url(as: 'date_to')]
|
|
public $dateTo = '';
|
|
|
|
#[Url(as: 'sort')]
|
|
public $sortBy = 'created_at';
|
|
|
|
#[Url(as: 'direction')]
|
|
public $sortDirection = 'desc';
|
|
|
|
#[Url(as: 'per_page')]
|
|
public $perPage = 15;
|
|
|
|
// Advanced filters
|
|
public $showAdvancedFilters = false;
|
|
|
|
public $totalAmountMin = '';
|
|
|
|
public $totalAmountMax = '';
|
|
|
|
public $validityFilter = '';
|
|
|
|
public $branchFilter = '';
|
|
|
|
// Bulk operations
|
|
public $bulkMode = false;
|
|
|
|
public $selectedEstimates = [];
|
|
|
|
public $selectAll = false;
|
|
|
|
// Quick stats
|
|
public $stats = [];
|
|
|
|
protected $queryString = [
|
|
'search' => ['except' => ''],
|
|
'statusFilter' => ['except' => ''],
|
|
'approvalStatusFilter' => ['except' => ''],
|
|
'customerFilter' => ['except' => ''],
|
|
'dateFrom' => ['except' => ''],
|
|
'dateTo' => ['except' => ''],
|
|
'sortBy' => ['except' => 'created_at'],
|
|
'sortDirection' => ['except' => 'desc'],
|
|
'perPage' => ['except' => 15],
|
|
];
|
|
|
|
public function mount()
|
|
{
|
|
$this->loadStats();
|
|
}
|
|
|
|
public function loadStats()
|
|
{
|
|
$userId = auth()->id();
|
|
|
|
// Check if user can view all estimates or only their own
|
|
$canViewAll = Auth::user()->can('viewAny', Estimate::class);
|
|
|
|
// Build base where clause
|
|
$baseWhere = [];
|
|
if (! $canViewAll) {
|
|
$baseWhere['prepared_by_id'] = $userId;
|
|
}
|
|
|
|
// Calculate stats individually with fresh queries each time
|
|
$this->stats = [
|
|
'total' => Estimate::where($baseWhere)->count(),
|
|
'draft' => Estimate::where($baseWhere)->where('status', 'draft')->count(),
|
|
'sent' => Estimate::where($baseWhere)->where('status', 'sent')->count(),
|
|
'approved' => Estimate::where($baseWhere)->where('customer_approval_status', 'approved')->count(),
|
|
'pending' => Estimate::where($baseWhere)->whereIn('status', ['sent', 'viewed'])->where('customer_approval_status', 'pending')->count(),
|
|
'expired' => $this->getExpiredCount($canViewAll ? null : $userId),
|
|
'total_value' => Estimate::where($baseWhere)->sum('total_amount') ?: 0,
|
|
'avg_value' => Estimate::where($baseWhere)->avg('total_amount') ?: 0,
|
|
];
|
|
}
|
|
|
|
private function getExpiredCount($userId = null)
|
|
{
|
|
$query = Estimate::where('status', '!=', 'approved')
|
|
->whereNotNull('validity_period_days');
|
|
|
|
if ($userId) {
|
|
$query->where('prepared_by_id', $userId);
|
|
}
|
|
|
|
if (\DB::getDriverName() === 'mysql') {
|
|
return $query->whereRaw('DATE_ADD(created_at, INTERVAL validity_period_days DAY) < NOW()')->count();
|
|
} else {
|
|
return $query->whereRaw("datetime(created_at, '+' || validity_period_days || ' days') < datetime('now')")->count();
|
|
}
|
|
}
|
|
|
|
public function getDateAddExpressionForDatabase($comparison = 'expired')
|
|
{
|
|
// Use the actual database connection driver instead of config
|
|
$databaseDriver = \DB::getDriverName();
|
|
|
|
if ($databaseDriver === 'mysql') {
|
|
switch ($comparison) {
|
|
case 'expired':
|
|
return 'DATE_ADD(created_at, INTERVAL validity_period_days DAY) < NOW()';
|
|
case 'expiring_soon':
|
|
return 'DATE_ADD(created_at, INTERVAL validity_period_days DAY) BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 7 DAY)';
|
|
case 'valid':
|
|
return 'DATE_ADD(created_at, INTERVAL validity_period_days DAY) > NOW()';
|
|
default:
|
|
return 'DATE_ADD(created_at, INTERVAL validity_period_days DAY) < NOW()';
|
|
}
|
|
} else {
|
|
// SQLite syntax
|
|
switch ($comparison) {
|
|
case 'expired':
|
|
return "datetime(created_at, '+' || validity_period_days || ' days') < datetime('now')";
|
|
case 'expiring_soon':
|
|
return "datetime(created_at, '+' || validity_period_days || ' days') BETWEEN datetime('now') AND datetime('now', '+7 days')";
|
|
case 'valid':
|
|
return "datetime(created_at, '+' || validity_period_days || ' days') > datetime('now')";
|
|
default:
|
|
return "datetime(created_at, '+' || validity_period_days || ' days') < datetime('now')";
|
|
}
|
|
}
|
|
}
|
|
|
|
public function updatedSearch()
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedStatusFilter()
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedApprovalStatusFilter()
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedCustomerFilter()
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedDateFrom()
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedDateTo()
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatedPerPage()
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function sortBy($field)
|
|
{
|
|
if ($this->sortBy === $field) {
|
|
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
$this->sortBy = $field;
|
|
$this->sortDirection = 'asc';
|
|
}
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function clearFilters()
|
|
{
|
|
$this->reset([
|
|
'search',
|
|
'statusFilter',
|
|
'approvalStatusFilter',
|
|
'customerFilter',
|
|
'dateFrom',
|
|
'dateTo',
|
|
'totalAmountMin',
|
|
'totalAmountMax',
|
|
'validityFilter',
|
|
'branchFilter',
|
|
]);
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function toggleAdvancedFilters()
|
|
{
|
|
$this->showAdvancedFilters = ! $this->showAdvancedFilters;
|
|
}
|
|
|
|
public function toggleBulkMode()
|
|
{
|
|
$this->bulkMode = ! $this->bulkMode;
|
|
$this->selectedEstimates = [];
|
|
$this->selectAll = false;
|
|
}
|
|
|
|
public function updatedSelectAll($value)
|
|
{
|
|
if ($value) {
|
|
$this->selectedEstimates = $this->getEstimates()->pluck('id')->toArray();
|
|
} else {
|
|
$this->selectedEstimates = [];
|
|
}
|
|
}
|
|
|
|
public function bulkAction($action)
|
|
{
|
|
if (empty($this->selectedEstimates)) {
|
|
session()->flash('error', 'Please select estimates to perform bulk action.');
|
|
|
|
return;
|
|
}
|
|
|
|
$estimates = Estimate::whereIn('id', $this->selectedEstimates)->get();
|
|
|
|
switch ($action) {
|
|
case 'delete':
|
|
$estimates->each(function ($estimate) {
|
|
if (Auth::user()->can('delete', $estimate)) {
|
|
$estimate->delete();
|
|
}
|
|
});
|
|
session()->flash('success', count($this->selectedEstimates).' estimates deleted.');
|
|
break;
|
|
|
|
case 'mark_sent':
|
|
$estimates->each(function ($estimate) {
|
|
if (Auth::user()->can('update', $estimate)) {
|
|
$estimate->update([
|
|
'status' => 'sent',
|
|
'sent_to_customer_at' => now(),
|
|
]);
|
|
}
|
|
});
|
|
session()->flash('success', count($this->selectedEstimates).' estimates marked as sent.');
|
|
break;
|
|
|
|
case 'export':
|
|
// Export functionality would go here
|
|
session()->flash('success', 'Export started for '.count($this->selectedEstimates).' estimates.');
|
|
break;
|
|
}
|
|
|
|
$this->selectedEstimates = [];
|
|
$this->selectAll = false;
|
|
$this->bulkMode = false;
|
|
$this->loadStats();
|
|
}
|
|
|
|
public function getEstimates()
|
|
{
|
|
$query = Estimate::with([
|
|
'jobCard.customer',
|
|
'jobCard.vehicle',
|
|
'jobCard.branch',
|
|
'customer', // For standalone estimates
|
|
'vehicle', // For standalone estimates
|
|
'preparedBy',
|
|
]);
|
|
|
|
// Apply permissions
|
|
if (! Auth::user()->can('viewAny', Estimate::class)) {
|
|
$query->where('prepared_by_id', Auth::id());
|
|
}
|
|
|
|
// Search filter
|
|
if ($this->search) {
|
|
$query->where(function ($q) {
|
|
$q->where('estimate_number', 'like', '%'.$this->search.'%')
|
|
// Search in jobCard customers (traditional estimates)
|
|
->orWhereHas('jobCard.customer', function ($customerQuery) {
|
|
$customerQuery->where('first_name', 'like', '%'.$this->search.'%')
|
|
->orWhere('last_name', 'like', '%'.$this->search.'%')
|
|
->orWhere('email', 'like', '%'.$this->search.'%')
|
|
->orWhere('phone', 'like', '%'.$this->search.'%');
|
|
})
|
|
// Search in direct customers (standalone estimates)
|
|
->orWhereHas('customer', function ($customerQuery) {
|
|
$customerQuery->where('first_name', 'like', '%'.$this->search.'%')
|
|
->orWhere('last_name', 'like', '%'.$this->search.'%')
|
|
->orWhere('email', 'like', '%'.$this->search.'%')
|
|
->orWhere('phone', 'like', '%'.$this->search.'%');
|
|
})
|
|
// Search in jobCard vehicles (traditional estimates)
|
|
->orWhereHas('jobCard.vehicle', function ($vehicleQuery) {
|
|
$vehicleQuery->where('license_plate', 'like', '%'.$this->search.'%')
|
|
->orWhere('vin', 'like', '%'.$this->search.'%')
|
|
->orWhereRaw("CONCAT(year, ' ', make, ' ', model) LIKE ?", ['%'.$this->search.'%']);
|
|
})
|
|
// Search in direct vehicles (standalone estimates)
|
|
->orWhereHas('vehicle', function ($vehicleQuery) {
|
|
$vehicleQuery->where('license_plate', 'like', '%'.$this->search.'%')
|
|
->orWhere('vin', 'like', '%'.$this->search.'%')
|
|
->orWhereRaw("CONCAT(year, ' ', make, ' ', model) LIKE ?", ['%'.$this->search.'%']);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Status filter
|
|
if ($this->statusFilter) {
|
|
if ($this->statusFilter === 'pending_approval') {
|
|
$query->whereIn('status', ['sent', 'viewed'])
|
|
->where('customer_approval_status', 'pending');
|
|
} elseif ($this->statusFilter === 'expired') {
|
|
$query->whereRaw($this->getDateAddExpressionForDatabase('expired'))
|
|
->where('status', '!=', 'approved');
|
|
} else {
|
|
$query->where('status', $this->statusFilter);
|
|
}
|
|
}
|
|
|
|
// Approval status filter
|
|
if ($this->approvalStatusFilter) {
|
|
$query->where('customer_approval_status', $this->approvalStatusFilter);
|
|
}
|
|
|
|
// Customer filter
|
|
if ($this->customerFilter) {
|
|
$query->whereHas('jobCard.customer', function ($customerQuery) {
|
|
$customerQuery->where('id', $this->customerFilter);
|
|
});
|
|
}
|
|
|
|
// Date filters
|
|
if ($this->dateFrom) {
|
|
$query->whereDate('created_at', '>=', $this->dateFrom);
|
|
}
|
|
|
|
if ($this->dateTo) {
|
|
$query->whereDate('created_at', '<=', $this->dateTo);
|
|
}
|
|
|
|
// Advanced filters
|
|
if ($this->totalAmountMin) {
|
|
$query->where('total_amount', '>=', $this->totalAmountMin);
|
|
}
|
|
|
|
if ($this->totalAmountMax) {
|
|
$query->where('total_amount', '<=', $this->totalAmountMax);
|
|
}
|
|
|
|
if ($this->validityFilter) {
|
|
if ($this->validityFilter === 'expired') {
|
|
$query->whereRaw($this->getDateAddExpressionForDatabase('expired'));
|
|
} elseif ($this->validityFilter === 'expiring_soon') {
|
|
$query->whereRaw($this->getDateAddExpressionForDatabase('expiring_soon'));
|
|
} elseif ($this->validityFilter === 'valid') {
|
|
$query->whereRaw($this->getDateAddExpressionForDatabase('valid'));
|
|
}
|
|
}
|
|
|
|
if ($this->branchFilter) {
|
|
$query->whereHas('jobCard.branch', function ($branchQuery) {
|
|
$branchQuery->where('code', $this->branchFilter);
|
|
});
|
|
}
|
|
|
|
// Sorting
|
|
$query->orderBy($this->sortBy, $this->sortDirection);
|
|
|
|
return $query->paginate($this->perPage);
|
|
}
|
|
|
|
public function getCustomersProperty()
|
|
{
|
|
return Customer::orderBy('first_name')->orderBy('last_name')->get(['id', 'first_name', 'last_name'])->map(function ($customer) {
|
|
return (object) [
|
|
'id' => $customer->id,
|
|
'name' => $customer->name,
|
|
];
|
|
});
|
|
}
|
|
|
|
public function getBranchesProperty()
|
|
{
|
|
return \App\Models\Branch::orderBy('name')->get(['code', 'name']);
|
|
}
|
|
|
|
public function sendEstimate($estimateId)
|
|
{
|
|
$estimate = Estimate::findOrFail($estimateId);
|
|
|
|
if (! Auth::user()->can('update', $estimate)) {
|
|
session()->flash('error', 'You are not authorized to send this estimate.');
|
|
|
|
return;
|
|
}
|
|
|
|
if ($estimate->status !== 'draft') {
|
|
session()->flash('error', 'Only draft estimates can be sent.');
|
|
|
|
return;
|
|
}
|
|
|
|
$estimate->update([
|
|
'status' => 'sent',
|
|
'sent_to_customer_at' => now(),
|
|
]);
|
|
|
|
// TODO: Send email/SMS notification to customer
|
|
|
|
session()->flash('success', 'Estimate sent to customer successfully.');
|
|
$this->loadStats();
|
|
}
|
|
|
|
public function confirmDelete($estimateId)
|
|
{
|
|
$estimate = Estimate::findOrFail($estimateId);
|
|
|
|
if (! Auth::user()->can('delete', $estimate)) {
|
|
session()->flash('error', 'You are not authorized to delete this estimate.');
|
|
|
|
return;
|
|
}
|
|
|
|
// For now, just delete directly. In production, you might want a confirmation modal
|
|
$estimate->delete();
|
|
|
|
session()->flash('success', 'Estimate deleted successfully.');
|
|
$this->loadStats();
|
|
}
|
|
|
|
#[Layout('components.layouts.app')]
|
|
public function render()
|
|
{
|
|
// Get available diagnoses that don't have estimates yet
|
|
$availableDiagnoses = \App\Models\Diagnosis::whereDoesntHave('estimate')
|
|
->with(['jobCard.customer', 'jobCard.vehicle'])
|
|
->latest()
|
|
->limit(5)
|
|
->get();
|
|
|
|
return view('livewire.estimates.index', [
|
|
'estimates' => $this->getEstimates(),
|
|
'customers' => $this->customers,
|
|
'branches' => $this->branches,
|
|
'stats' => $this->stats,
|
|
'availableDiagnoses' => $availableDiagnoses,
|
|
]);
|
|
}
|
|
}
|