sackey 5403c3591d
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
feat: Enhance job card workflow with diagnosis actions and technician assignment modal
- 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.
2025-08-15 08:37:45 +00:00

756 lines
25 KiB
PHP

<?php
namespace App\Livewire\Diagnosis;
use App\Models\Diagnosis;
use App\Models\Estimate;
use App\Models\EstimateLineItem;
use App\Models\JobCard;
use App\Models\Part;
use App\Models\ServiceItem;
use App\Models\Timesheet;
use Livewire\Component;
use Livewire\WithFileUploads;
class Create extends Component
{
use WithFileUploads;
public JobCard $jobCard;
public $customer_reported_issues = '';
public $diagnostic_findings = '';
public $root_cause_analysis = '';
public $recommended_repairs = '';
public $additional_issues_found = '';
public $priority_level = 'medium';
public $estimated_repair_time = '';
public $parts_required = [];
public $labor_operations = [];
public $special_tools_required = [];
public $safety_concerns = '';
public $diagnostic_codes = [];
public $test_results = [];
public $photos = [];
public $notes = '';
public $environmental_impact = '';
public $customer_authorization_required = false;
// Timesheet tracking
public $currentTimesheet = null;
public $timesheets = [];
public $selectedDiagnosisType = 'general_inspection';
public $diagnosisTypes = [
'general_inspection' => 'General Inspection',
'electrical_diagnosis' => 'Electrical Diagnosis',
'engine_diagnosis' => 'Engine Diagnosis',
'transmission_diagnosis' => 'Transmission Diagnosis',
'brake_diagnosis' => 'Brake System Diagnosis',
'suspension_diagnosis' => 'Suspension Diagnosis',
'air_conditioning' => 'Air Conditioning Diagnosis',
'computer_diagnosis' => 'Computer/ECU Diagnosis',
'emissions_diagnosis' => 'Emissions Diagnosis',
'noise_diagnosis' => 'Noise/Vibration Diagnosis',
];
// Parts integration
public $availableParts = [];
public $partSearch = '';
public $partSearchTerm = '';
public $partCategoryFilter = '';
public $filteredParts;
// Service items integration
public $availableServiceItems = [];
public $serviceItemSearch = '';
public $serviceSearchTerm = '';
public $serviceCategoryFilter = '';
public $filteredServiceItems;
// UI state
public $showPartsSection = false;
public $showLaborSection = false;
public $showDiagnosticCodesSection = false;
public $showTestResultsSection = false;
public $showAdvancedOptions = false;
public $showTimesheetSection = true;
public $createEstimateAutomatically = true;
protected $rules = [
'customer_reported_issues' => 'required|string',
'diagnostic_findings' => 'required|string|min:20',
'root_cause_analysis' => 'required|string|min:20',
'recommended_repairs' => 'required|string|min:10',
'priority_level' => 'required|in:low,medium,high,urgent',
'estimated_repair_time' => 'required|numeric|min:0.5|max:40',
'safety_concerns' => 'nullable|string',
'environmental_impact' => 'nullable|string',
'notes' => 'nullable|string',
'photos.*' => 'nullable|image|max:5120',
'selectedDiagnosisType' => 'required|string',
];
protected $messages = [
'diagnostic_findings.min' => 'Please provide detailed diagnostic findings (at least 20 characters).',
'root_cause_analysis.min' => 'Please provide a thorough root cause analysis (at least 20 characters).',
'recommended_repairs.min' => 'Please specify the recommended repairs (at least 10 characters).',
'estimated_repair_time.max' => 'Estimated repair time cannot exceed 40 hours. For longer repairs, consider breaking into phases.',
'photos.*.max' => 'Each photo must be less than 5MB.',
'selectedDiagnosisType.required' => 'Please select a diagnosis type.',
];
public function mount(JobCard $jobCard)
{
// Validate that initial inspection has been completed
if ($jobCard->status === 'received') {
session()->flash('error', 'Initial vehicle inspection must be completed before starting diagnosis. Please complete the inspection first.');
return redirect()->route('job-cards.show', $jobCard);
}
if (! $jobCard->incomingInspection) {
session()->flash('error', 'No incoming inspection record found. Please complete the initial inspection before proceeding to diagnosis.');
return redirect()->route('inspections.create', ['jobCard' => $jobCard, 'type' => 'incoming']);
}
$this->jobCard = $jobCard->load(['customer', 'vehicle', 'timesheets', 'incomingInspection']);
$this->customer_reported_issues = $jobCard->customer_reported_issues ?? '';
// Initialize arrays to prevent null issues - load from session if available
$this->parts_required = session()->get("diagnosis_parts_{$jobCard->id}", []);
$this->labor_operations = session()->get("diagnosis_labor_{$jobCard->id}", []);
$this->timesheets = [];
$this->diagnostic_codes = session()->get("diagnosis_codes_{$jobCard->id}", []);
$this->test_results = [];
$this->special_tools_required = [];
// Initialize filtered collections
$this->filteredParts = collect();
$this->filteredServiceItems = collect();
// Load existing timesheets for this job card related to diagnosis
$this->loadTimesheets();
// Load available parts and service items
$this->loadAvailableParts();
$this->loadAvailableServiceItems();
// Initialize with one empty part and labor operation for convenience if none exist
if (empty($this->parts_required)) {
$this->addPart();
}
if (empty($this->labor_operations)) {
$this->addLaborOperation();
}
}
public function updatedPartsRequired()
{
// Save parts to session whenever they change
session()->put("diagnosis_parts_{$this->jobCard->id}", $this->parts_required);
}
public function updatedLaborOperations()
{
// Save labor operations to session whenever they change
session()->put("diagnosis_labor_{$this->jobCard->id}", $this->labor_operations);
}
public function updatedDiagnosticCodes()
{
// Save diagnostic codes to session whenever they change
session()->put("diagnosis_codes_{$this->jobCard->id}", $this->diagnostic_codes);
}
public function loadTimesheets()
{
$this->timesheets = Timesheet::where('job_card_id', $this->jobCard->id)
->where('entry_type', 'manual')
->where('description', 'like', '%diagnosis%')
->with('user')
->orderBy('created_at', 'desc')
->get()
->toArray();
}
public function loadAvailableParts()
{
$query = Part::where('status', 'active');
if (! empty($this->partSearch)) {
$query->where(function ($q) {
$q->where('name', 'like', '%'.$this->partSearch.'%')
->orWhere('part_number', 'like', '%'.$this->partSearch.'%')
->orWhere('description', 'like', '%'.$this->partSearch.'%');
});
}
$this->availableParts = $query->limit(20)->get()->toArray();
}
public function loadAvailableServiceItems()
{
$query = ServiceItem::query();
if (! empty($this->serviceItemSearch)) {
$query->where(function ($q) {
$q->where('service_name', 'like', '%'.$this->serviceItemSearch.'%')
->orWhere('description', 'like', '%'.$this->serviceItemSearch.'%')
->orWhere('category', 'like', '%'.$this->serviceItemSearch.'%');
});
}
$this->availableServiceItems = $query->limit(20)->get()->toArray();
}
// Computed properties for filtered collections
public function updatedPartSearchTerm()
{
$this->updateFilteredParts();
}
public function updatedPartCategoryFilter()
{
$this->updateFilteredParts();
}
public function updateFilteredParts()
{
try {
// If no search criteria provided, return empty collection
if (empty($this->partSearchTerm) && empty($this->partCategoryFilter)) {
$this->filteredParts = collect();
return;
}
// Start with active parts only
$query = Part::where('status', 'active');
// Add search term filter if provided
if (! empty($this->partSearchTerm)) {
$searchTerm = trim($this->partSearchTerm);
$query->where(function ($q) use ($searchTerm) {
$q->where('name', 'like', '%'.$searchTerm.'%')
->orWhere('part_number', 'like', '%'.$searchTerm.'%')
->orWhere('description', 'like', '%'.$searchTerm.'%')
->orWhere('manufacturer', 'like', '%'.$searchTerm.'%');
});
}
// Add category filter if provided
if (! empty($this->partCategoryFilter)) {
$query->where('category', $this->partCategoryFilter);
}
// Order by name for consistent results
$query->orderBy('name');
// Get results and assign to property
$this->filteredParts = $query->limit(20)->get();
// Log for debugging
\Log::info('Parts search executed', [
'search_term' => $this->partSearchTerm,
'category_filter' => $this->partCategoryFilter,
'results_count' => $this->filteredParts->count(),
'results' => $this->filteredParts->pluck('name')->toArray(),
]);
} catch (\Exception $e) {
// Log error and return empty collection
\Log::error('Error in updateFilteredParts', [
'error' => $e->getMessage(),
'search_term' => $this->partSearchTerm,
'category_filter' => $this->partCategoryFilter,
]);
$this->filteredParts = collect();
}
}
public function getFilteredServiceItemsProperty()
{
$query = ServiceItem::query();
if (! empty($this->serviceSearchTerm)) {
$query->where(function ($q) {
$q->where('name', 'like', '%'.$this->serviceSearchTerm.'%')
->orWhere('description', 'like', '%'.$this->serviceSearchTerm.'%');
});
}
if (! empty($this->serviceCategoryFilter)) {
$query->where('category', $this->serviceCategoryFilter);
}
return $query->limit(20)->get();
}
public function updatedPartSearch()
{
$this->loadAvailableParts();
}
public function updatedServiceSearchTerm()
{
$this->updateFilteredServiceItems();
}
public function updatedServiceCategoryFilter()
{
$this->updateFilteredServiceItems();
}
public function updateFilteredServiceItems()
{
try {
// If no search criteria provided, return empty collection
if (empty($this->serviceSearchTerm) && empty($this->serviceCategoryFilter)) {
$this->filteredServiceItems = collect();
return;
}
// Start with active service items only
$query = ServiceItem::where('status', 'active');
// Add search term filter if provided
if (! empty($this->serviceSearchTerm)) {
$searchTerm = trim($this->serviceSearchTerm);
$query->where(function ($q) use ($searchTerm) {
$q->where('service_name', 'like', '%'.$searchTerm.'%')
->orWhere('description', 'like', '%'.$searchTerm.'%');
});
}
// Add category filter if provided
if (! empty($this->serviceCategoryFilter)) {
$query->where('category', $this->serviceCategoryFilter);
}
// Order by name for consistent results
$query->orderBy('service_name');
// Get results
$results = $query->limit(20)->get();
$this->filteredServiceItems = $results;
} catch (\Exception $e) {
// Log error and return empty collection
\Log::error('Error in updateFilteredServiceItems', [
'error' => $e->getMessage(),
'search_term' => $this->serviceSearchTerm,
'category_filter' => $this->serviceCategoryFilter,
]);
$this->filteredServiceItems = collect();
}
}
public function startTimesheet()
{
// End any currently running timesheet
if ($this->currentTimesheet) {
$this->endTimesheet();
}
$this->currentTimesheet = Timesheet::create([
'job_card_id' => $this->jobCard->id,
'user_id' => auth()->id(),
'entry_type' => 'manual',
'description' => 'Diagnosis: '.($this->diagnosisTypes[$this->selectedDiagnosisType] ?? 'General Diagnosis'),
'date' => now()->toDateString(),
'start_time' => now(),
'hourly_rate' => auth()->user()->hourly_rate ?? 85.00,
'status' => 'draft',
]);
$this->loadTimesheets();
session()->flash('timesheet_message', 'Timesheet started for '.($this->diagnosisTypes[$this->selectedDiagnosisType] ?? 'Diagnosis'));
}
public function endTimesheet()
{
if (! $this->currentTimesheet) {
return;
}
$timesheet = Timesheet::find($this->currentTimesheet['id']);
if ($timesheet && ! $timesheet->end_time) {
$endTime = now();
$totalMinutes = $timesheet->start_time->diffInMinutes($endTime);
$billableHours = round($totalMinutes / 60, 2);
$timesheet->update([
'end_time' => $endTime,
'hours_worked' => $billableHours,
'billable_hours' => $billableHours,
'total_amount' => $billableHours * $timesheet->hourly_rate,
'status' => 'submitted',
]);
session()->flash('timesheet_message', 'Timesheet ended. Total time: '.$billableHours.' hours');
}
$this->currentTimesheet = null;
$this->loadTimesheets();
}
public function addPartFromCatalog($partId)
{
$part = Part::find($partId);
if ($part) {
$this->parts_required[] = [
'part_id' => $part->id,
'part_name' => $part->name,
'part_number' => $part->part_number,
'quantity' => 1,
'estimated_cost' => $part->sell_price,
'availability' => $part->quantity_on_hand > 0 ? 'in_stock' : 'out_of_stock',
];
$this->updatedPartsRequired(); // Save to session
}
}
public function addServiceItemFromCatalog($serviceItemId)
{
$serviceItem = ServiceItem::find($serviceItemId);
if ($serviceItem) {
$this->labor_operations[] = [
'service_item_id' => $serviceItem->id,
'operation' => $serviceItem->service_name,
'description' => $serviceItem->description,
'estimated_hours' => $serviceItem->estimated_hours,
'labor_rate' => $serviceItem->labor_rate,
'category' => $serviceItem->category,
'complexity' => 'medium',
];
$this->updatedLaborOperations(); // Save to session
}
}
public function addPart()
{
$this->parts_required[] = [
'part_id' => null,
'part_name' => '',
'part_number' => '',
'quantity' => 1,
'estimated_cost' => 0,
'availability' => 'in_stock',
];
$this->updatedPartsRequired(); // Save to session
}
public function removePart($index)
{
unset($this->parts_required[$index]);
$this->parts_required = array_values($this->parts_required);
$this->updatedPartsRequired(); // Save to session
}
public function addLaborOperation()
{
$this->labor_operations[] = [
'service_item_id' => null,
'operation' => '',
'description' => '',
'estimated_hours' => 0,
'labor_rate' => 85.00,
'category' => '',
'complexity' => 'medium',
];
$this->updatedLaborOperations(); // Save to session
}
public function removeLaborOperation($index)
{
unset($this->labor_operations[$index]);
$this->labor_operations = array_values($this->labor_operations);
$this->updatedLaborOperations(); // Save to session
}
public function saveProgress()
{
// Manually save current progress to session
$this->updatedPartsRequired();
$this->updatedLaborOperations();
$this->updatedDiagnosticCodes();
session()->flash('progress_saved', 'Progress saved successfully!');
}
public function addDiagnosticCode()
{
$this->diagnostic_codes[] = [
'code' => '',
'description' => '',
'system' => '',
'severity' => 'medium',
];
}
public function removeDiagnosticCode($index)
{
unset($this->diagnostic_codes[$index]);
$this->diagnostic_codes = array_values($this->diagnostic_codes);
}
public function addTestResult()
{
$this->test_results[] = [
'test_name' => '',
'result' => '',
'specification' => '',
'status' => 'pass',
];
}
public function removeTestResult($index)
{
unset($this->test_results[$index]);
$this->test_results = array_values($this->test_results);
}
public function addSpecialTool()
{
$this->special_tools_required[] = [
'tool_name' => '',
'tool_type' => '',
'availability' => 'available',
];
}
public function removeSpecialTool($index)
{
unset($this->special_tools_required[$index]);
$this->special_tools_required = array_values($this->special_tools_required);
}
public function togglePartsSection()
{
$this->showPartsSection = ! $this->showPartsSection;
}
public function toggleLaborSection()
{
$this->showLaborSection = ! $this->showLaborSection;
}
public function toggleDiagnosticCodesSection()
{
$this->showDiagnosticCodesSection = ! $this->showDiagnosticCodesSection;
}
public function toggleTestResultsSection()
{
$this->showTestResultsSection = ! $this->showTestResultsSection;
}
public function toggleAdvancedOptions()
{
$this->showAdvancedOptions = ! $this->showAdvancedOptions;
}
public function toggleTimesheetSection()
{
$this->showTimesheetSection = ! $this->showTimesheetSection;
}
public function calculateTotalEstimatedCost()
{
$partsCost = array_sum(array_map(function ($part) {
return ($part['quantity'] ?? 1) * ($part['estimated_cost'] ?? 0);
}, $this->parts_required));
$laborCost = 0;
foreach ($this->labor_operations as $operation) {
$laborCost += ($operation['estimated_hours'] ?? 0) * ($operation['labor_rate'] ?? 85);
}
// Include diagnostic time costs
$diagnosticCost = collect($this->timesheets)->sum('total_amount');
return $partsCost + $laborCost + $diagnosticCost;
}
private function createEstimateFromDiagnosis($diagnosis)
{
// Calculate totals
$partsCost = array_sum(array_map(function ($part) {
return ($part['quantity'] ?? 1) * ($part['estimated_cost'] ?? 0);
}, $this->parts_required));
$laborCost = 0;
foreach ($this->labor_operations as $operation) {
$laborCost += ($operation['estimated_hours'] ?? 0) * ($operation['labor_rate'] ?? 85);
}
$subtotal = $partsCost + $laborCost;
$taxRate = 0.0875; // 8.75% tax rate - should be configurable
$taxAmount = $subtotal * $taxRate;
$totalAmount = $subtotal + $taxAmount;
// Create the estimate
$estimate = Estimate::create([
'job_card_id' => $this->jobCard->id,
'diagnosis_id' => $diagnosis->id,
'estimate_number' => 'EST-'.str_pad(Estimate::max('id') + 1, 6, '0', STR_PAD_LEFT),
'customer_id' => $this->jobCard->customer_id,
'vehicle_id' => $this->jobCard->vehicle_id,
'prepared_by_id' => auth()->id(),
'status' => 'draft',
'priority_level' => $this->priority_level,
'estimated_completion_date' => now()->addHours($this->estimated_repair_time),
'subtotal' => $subtotal,
'tax_rate' => $taxRate,
'tax_amount' => $taxAmount,
'total_amount' => $totalAmount,
'notes' => 'Auto-generated from diagnosis: '.$diagnosis->id,
'validity_period_days' => 30,
]);
// Create line items for parts
foreach ($this->parts_required as $part) {
if (! empty($part['part_name'])) {
EstimateLineItem::create([
'estimate_id' => $estimate->id,
'type' => 'part',
'part_id' => $part['part_id'] ?? null,
'description' => $part['part_name'],
'part_number' => $part['part_number'] ?? null,
'quantity' => $part['quantity'] ?? 1,
'unit_price' => $part['estimated_cost'] ?? 0,
'total_price' => ($part['quantity'] ?? 1) * ($part['estimated_cost'] ?? 0),
]);
}
}
// Create line items for labor
foreach ($this->labor_operations as $operation) {
if (! empty($operation['operation'])) {
EstimateLineItem::create([
'estimate_id' => $estimate->id,
'type' => 'labor',
'service_item_id' => $operation['service_item_id'] ?? null,
'description' => $operation['operation'],
'labor_hours' => $operation['estimated_hours'] ?? 0,
'labor_rate' => $operation['labor_rate'] ?? 85,
'total_price' => ($operation['estimated_hours'] ?? 0) * ($operation['labor_rate'] ?? 85),
]);
}
}
return $estimate;
}
public function save()
{
$this->validate();
// End any active timesheet
if ($this->currentTimesheet) {
$this->endTimesheet();
}
// Handle photo uploads
$photoUrls = [];
if ($this->photos) {
foreach ($this->photos as $photo) {
$photoUrls[] = $photo->store('diagnosis', 'public');
}
}
$diagnosis = Diagnosis::create([
'job_card_id' => $this->jobCard->id,
'service_coordinator_id' => auth()->id(),
'customer_reported_issues' => $this->customer_reported_issues,
'diagnostic_findings' => $this->diagnostic_findings,
'root_cause_analysis' => $this->root_cause_analysis,
'recommended_repairs' => $this->recommended_repairs,
'additional_issues_found' => $this->additional_issues_found,
'priority_level' => $this->priority_level,
'estimated_repair_time' => $this->estimated_repair_time,
'parts_required' => array_filter($this->parts_required, function ($part) {
return ! empty($part['part_name']);
}),
'labor_operations' => array_filter($this->labor_operations, function ($operation) {
return ! empty($operation['operation']);
}),
'special_tools_required' => array_filter($this->special_tools_required, function ($tool) {
return ! empty($tool['tool_name']);
}),
'safety_concerns' => $this->safety_concerns,
'diagnostic_codes' => array_filter($this->diagnostic_codes, function ($code) {
return ! empty($code['code']);
}),
'test_results' => array_filter($this->test_results, function ($result) {
return ! empty($result['test_name']);
}),
'photos' => $photoUrls,
'notes' => $this->notes,
'environmental_impact' => $this->environmental_impact,
'customer_authorization_required' => $this->customer_authorization_required,
'diagnosis_status' => 'completed',
'diagnosis_date' => now(),
]);
// Update job card status
$this->jobCard->update(['status' => 'diagnosis_completed']);
// Create estimate automatically
$estimate = $this->createEstimateFromDiagnosis($diagnosis);
// Clear session data after successful diagnosis creation
session()->forget([
"diagnosis_parts_{$this->jobCard->id}",
"diagnosis_labor_{$this->jobCard->id}",
"diagnosis_codes_{$this->jobCard->id}",
]);
session()->flash('message', 'Diagnosis completed successfully! Estimate #'.$estimate->estimate_number.' has been created automatically.');
return redirect()->route('estimates.show', $estimate);
}
public function render()
{
return view('livewire.diagnosis.create');
}
}