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

547 lines
18 KiB
PHP

<?php
namespace App\Livewire\Estimates;
use App\Models\Customer;
use App\Models\Estimate;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
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;
// Row selection for inline actions
public $selectedRow = null;
// 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;
}
// Update estimate status
$estimate->update([
'status' => 'sent',
'sent_to_customer_at' => now(),
]);
// Send notification to customer
$this->sendEstimateNotification($estimate);
session()->flash('success', 'Estimate sent to customer successfully.');
$this->loadStats();
}
public function resendEstimate($estimateId)
{
$estimate = Estimate::findOrFail($estimateId);
if (! Auth::user()->can('update', $estimate)) {
session()->flash('error', 'You are not authorized to resend this estimate.');
return;
}
if (! in_array($estimate->status, ['sent', 'approved', 'rejected'])) {
session()->flash('error', 'Only sent, approved, or rejected estimates can be resent.');
return;
}
// Update the sent timestamp
$estimate->update([
'sent_to_customer_at' => now(),
]);
// Send notification to customer
$this->sendEstimateNotification($estimate);
session()->flash('success', 'Estimate resent to customer successfully.');
$this->loadStats();
}
private function sendEstimateNotification(Estimate $estimate)
{
try {
// Get customer from estimate
$customer = $estimate->customer;
if (! $customer) {
// If no direct customer, get from job card
$customer = $estimate->jobCard?->customer;
}
if ($customer && $customer->email) {
// Send email notification using Notification facade
\Illuminate\Support\Facades\Notification::send($customer, new \App\Notifications\EstimateNotification($estimate));
// TODO: Send SMS notification if phone number is available
// if ($customer->phone) {
// // Implement SMS sending logic here
// }
} else {
session()->flash('warning', 'Estimate status updated, but customer email not found. Please contact customer manually.');
}
} catch (\Exception $e) {
// Log the error but don't fail the operation
\Illuminate\Support\Facades\Log::error('Failed to send estimate notification: '.$e->getMessage());
session()->flash('warning', 'Estimate status updated, but notification could not be sent. Please contact customer manually.');
}
}
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();
}
public function selectRow($estimateId)
{
if ($this->selectedRow === $estimateId) {
$this->selectedRow = null; // Deselect if clicking the same row
} else {
$this->selectedRow = $estimateId; // Select the clicked row
}
}
#[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,
]);
}
}