diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3466de7..d772ffa 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -80,3 +80,449 @@ The system follows a structured automotive repair workflow: - Follow existing permission key patterns (`domain.action`). If anything above is unclear or you need deeper details (e.g., settings schema, specific Livewire page conventions), propose a short diff or ask for a quick pointer to the relevant file. + +=== + + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.4.1 +- laravel/framework (LARAVEL) - v12 +- laravel/prompts (PROMPTS) - v0 +- livewire/flux (FLUXUI_FREE) - v2 +- livewire/livewire (LIVEWIRE) - v3 +- livewire/volt (VOLT) - v1 +- laravel/pint (PINT) - v1 +- tailwindcss (TAILWINDCSS) - v4 + + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries, package information is already shared. Use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== fluxui-free/core rules === + +## Flux UI Free + +- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components. +- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize. +- You should use Flux UI components when available. +- Fallback to standard Blade components if Flux is unavailable. +- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project. +- Flux UI components look like this: + + + + + + +### Available Components +This is correct as of Boost installation, but there may be additional components within the codebase. + + +avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip + + + +=== livewire/core rules === + +## Livewire Core +- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests. +- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components +- State should live on the server, with the UI reflecting it. +- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. + +## Livewire Best Practices +- Livewire components require a single root element. +- Use `wire:loading` and `wire:dirty` for delightful loading states. +- Add `wire:key` in loops: + + ```blade + @foreach ($items as $item) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== livewire/v3 rules === + +## Livewire 3 + +### Key Changes From Livewire 2 +- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. + - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. + - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). + - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). + - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`). + +### New Directives +- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. + +### Alpine +- Alpine is now included with Livewire, don't manually include Alpine.js. +- Plugins included with Alpine: persist, intersect, collapse, and focus. + +### Lifecycle Hooks +- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: + + +document.addEventListener('livewire:init', function () { + Livewire.hook('request', ({ fail }) => { + if (fail && fail.status === 419) { + alert('Your session expired'); + } + }); + + Livewire.hook('message.failed', (message, component) => { + console.error(message); + }); +}); + + + +=== volt/core rules === + +## Livewire Volt + +- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it. +- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]` +- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file +- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array ( +)])) + + + +### Volt Class Based Component Example +To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax: + + + +use Livewire\Volt\Component; + +new class extends Component { + public $count = 0; + + public function increment() + { + $this->count++; + } +} ?> + +
+

{{ $count }}

+ +
+
+ + +### Testing Volt & Volt Components +- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`. + + +use Livewire\Volt\Volt; + +test('counter increments', function () { + Volt::test('counter') + ->assertSee('Count: 0') + ->call('increment') + ->assertSee('Count: 1'); +}); + + + + +declare(strict_types=1); + +use App\Models\{User, Product}; +use Livewire\Volt\Volt; + +test('product form creates product', function () { + $user = User::factory()->create(); + + Volt::test('pages.products.create') + ->actingAs($user) + ->set('form.name', 'Test Product') + ->set('form.description', 'Test Description') + ->set('form.price', 99.99) + ->call('create') + ->assertHasNoErrors(); + + expect(Product::where('name', 'Test Product')->exists())->toBeTrue(); +}); + + + +### Common Patterns + + + + null, 'search' => '']); + +$products = computed(fn() => Product::when($this->search, + fn($q) => $q->where('name', 'like', "%{$this->search}%") +)->get()); + +$edit = fn(Product $product) => $this->editing = $product->id; +$delete = fn(Product $product) => $product->delete(); + +?> + + + + + + + + + + + + + + + Save + Saving... + + + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. + + +=== tailwindcss/core rules === + +## Tailwind Core + +- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. +- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) +- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically +- You can use the `search-docs` tool to get exact examples from the official documentation when needed. + +### Spacing +- When listing items, use gap utilities for spacing, don't use margins. + + +
+
Superior
+
Michigan
+
Erie
+
+
+ + +### Dark Mode +- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. + + +=== tailwindcss/v4 rules === + +## Tailwind 4 + +- Always use Tailwind CSS v4 - do not use the deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. +- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3: + + + + +### Replaced Utilities +- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. +- Opacity values are still numeric. + +| Deprecated | Replacement | +|------------+--------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. +
\ No newline at end of file diff --git a/app/Livewire/CustomerPortal/WorkflowProgress.php b/app/Livewire/CustomerPortal/WorkflowProgress.php index a3653ca..45fde06 100644 --- a/app/Livewire/CustomerPortal/WorkflowProgress.php +++ b/app/Livewire/CustomerPortal/WorkflowProgress.php @@ -2,13 +2,13 @@ namespace App\Livewire\CustomerPortal; -use Livewire\Component; use App\Models\JobCard; -use App\Services\WorkflowService; +use Livewire\Component; class WorkflowProgress extends Component { public JobCard $jobCard; + public array $progressSteps = []; public function mount(JobCard $jobCard) @@ -22,7 +22,7 @@ class WorkflowProgress extends Component 'diagnosis.serviceCoordinator', 'estimates.preparedBy', 'workOrders', - 'timesheets' + 'timesheets', ]); $this->loadProgressSteps(); @@ -31,7 +31,7 @@ class WorkflowProgress extends Component public function loadProgressSteps() { $currentStatus = $this->jobCard->status; - + $this->progressSteps = [ [ 'step' => 1, @@ -42,10 +42,10 @@ class WorkflowProgress extends Component 'icon' => 'truck', 'details' => [ 'Arrival Time' => $this->jobCard->arrival_datetime?->format('M j, Y g:i A'), - 'Mileage' => number_format($this->jobCard->mileage_in) . ' miles', - 'Fuel Level' => $this->jobCard->fuel_level_in . '%', + 'Mileage' => number_format($this->jobCard->mileage_in).' miles', + 'Fuel Level' => $this->jobCard->fuel_level_in.'%', 'Service Advisor' => $this->jobCard->serviceAdvisor?->name, - ] + ], ], [ 'step' => 2, @@ -58,7 +58,7 @@ class WorkflowProgress extends Component 'Inspector' => $this->jobCard->incomingInspection?->inspector?->name, 'Overall Condition' => $this->jobCard->incomingInspection?->overall_condition, 'Inspection Date' => $this->jobCard->incomingInspection?->inspection_date?->format('M j, Y g:i A'), - ] + ], ], [ 'step' => 3, @@ -70,7 +70,7 @@ class WorkflowProgress extends Component 'details' => [ 'Service Coordinator' => $this->jobCard->diagnosis?->serviceCoordinator?->name, 'Assignment Date' => $this->jobCard->diagnosis?->created_at?->format('M j, Y g:i A'), - ] + ], ], [ 'step' => 4, @@ -83,7 +83,7 @@ class WorkflowProgress extends Component 'Diagnosis Status' => ucfirst(str_replace('_', ' ', $this->jobCard->diagnosis?->diagnosis_status ?? '')), 'Started' => $this->jobCard->diagnosis?->started_at?->format('M j, Y g:i A'), 'Completed' => $this->jobCard->diagnosis?->completed_at?->format('M j, Y g:i A'), - ] + ], ], [ 'step' => 5, @@ -93,9 +93,9 @@ class WorkflowProgress extends Component 'completed_at' => $this->jobCard->estimates->where('status', 'sent')->first()?->created_at, 'icon' => 'document-text', 'details' => [ - 'Estimate Total' => '$' . number_format($this->jobCard->estimates->where('status', 'sent')->first()?->total_amount ?? 0, 2), - 'Valid Until' => $this->jobCard->estimates->where('status', 'sent')->first()?->valid_until?->format('M j, Y'), - ] + 'Estimate Total' => '$'.number_format($this->jobCard->estimates->where('status', 'sent')->first()?->total_amount ?? 0, 2), + 'Valid Until' => $this->jobCard->estimates->where('status', 'sent')->first()?->created_at?->addDays($this->jobCard->estimates->where('status', 'sent')->first()?->validity_period_days ?? 30)?->format('M j, Y'), + ], ], [ 'step' => 6, @@ -107,7 +107,7 @@ class WorkflowProgress extends Component 'details' => [ 'Approved At' => $this->jobCard->estimates->where('status', 'approved')->first()?->customer_approved_at?->format('M j, Y g:i A'), 'Approval Method' => ucfirst($this->jobCard->estimates->where('status', 'approved')->first()?->customer_approval_method ?? ''), - ] + ], ], [ 'step' => 7, @@ -116,7 +116,7 @@ class WorkflowProgress extends Component 'status' => $this->getStepStatus('parts_procurement', $currentStatus), 'completed_at' => null, // Would need to track this separately 'icon' => 'cog-6-tooth', - 'details' => [] + 'details' => [], ], [ 'step' => 8, @@ -128,7 +128,7 @@ class WorkflowProgress extends Component 'details' => [ 'Started' => $this->jobCard->workOrders->first()?->actual_start_time?->format('M j, Y g:i A'), 'Technician' => $this->jobCard->workOrders->first()?->assignedTechnician?->name, - ] + ], ], [ 'step' => 9, @@ -140,7 +140,7 @@ class WorkflowProgress extends Component 'details' => [ 'Inspector' => $this->jobCard->outgoingInspection?->inspector?->name, 'Inspection Date' => $this->jobCard->outgoingInspection?->inspection_date?->format('M j, Y g:i A'), - ] + ], ], [ 'step' => 10, @@ -151,8 +151,8 @@ class WorkflowProgress extends Component 'icon' => 'truck', 'details' => [ 'Completion Time' => $this->jobCard->completion_datetime?->format('M j, Y g:i A'), - 'Final Mileage' => number_format($this->jobCard->mileage_out) . ' miles', - ] + 'Final Mileage' => number_format($this->jobCard->mileage_out).' miles', + ], ], ]; } diff --git a/app/Livewire/Diagnosis/Create.php b/app/Livewire/Diagnosis/Create.php index 9c8e8f1..b1cb538 100644 --- a/app/Livewire/Diagnosis/Create.php +++ b/app/Livewire/Diagnosis/Create.php @@ -2,45 +2,63 @@ namespace App\Livewire\Diagnosis; -use App\Models\JobCard; use App\Models\Diagnosis; -use App\Models\Timesheet; -use App\Models\Part; -use App\Models\ServiceItem; use App\Models\Estimate; use App\Models\EstimateLineItem; -use App\Models\User; +use App\Models\JobCard; +use App\Models\Part; +use App\Models\ServiceItem; +use App\Models\Timesheet; use Livewire\Component; use Livewire\WithFileUploads; -use Carbon\Carbon; 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', @@ -56,25 +74,39 @@ class Create extends Component // 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 = [ @@ -102,9 +134,22 @@ class Create extends Component public function mount(JobCard $jobCard) { - $this->jobCard = $jobCard->load(['customer', 'vehicle', 'timesheets']); + // 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}", []); @@ -112,18 +157,18 @@ class Create extends Component $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(); @@ -165,30 +210,30 @@ class Create extends Component public function loadAvailableParts() { $query = Part::where('status', 'active'); - - if (!empty($this->partSearch)) { + + 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 . '%'); + $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)) { + + 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 . '%'); + $q->where('service_name', 'like', '%'.$this->serviceItemSearch.'%') + ->orWhere('description', 'like', '%'.$this->serviceItemSearch.'%') + ->orWhere('category', 'like', '%'.$this->serviceItemSearch.'%'); }); } - + $this->availableServiceItems = $query->limit(20)->get()->toArray(); } @@ -197,62 +242,63 @@ class Create extends Component { $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)) { + 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 . '%'); + $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)) { + 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() + '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 + 'category_filter' => $this->partCategoryFilter, ]); - + $this->filteredParts = collect(); } } @@ -260,18 +306,18 @@ class Create extends Component public function getFilteredServiceItemsProperty() { $query = ServiceItem::query(); - - if (!empty($this->serviceSearchTerm)) { + + if (! empty($this->serviceSearchTerm)) { $query->where(function ($q) { - $q->where('name', 'like', '%' . $this->serviceSearchTerm . '%') - ->orWhere('description', 'like', '%' . $this->serviceSearchTerm . '%'); + $q->where('name', 'like', '%'.$this->serviceSearchTerm.'%') + ->orWhere('description', 'like', '%'.$this->serviceSearchTerm.'%'); }); } - - if (!empty($this->serviceCategoryFilter)) { + + if (! empty($this->serviceCategoryFilter)) { $query->where('category', $this->serviceCategoryFilter); } - + return $query->limit(20)->get(); } @@ -284,53 +330,54 @@ class Create extends Component { $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)) { + if (! empty($this->serviceSearchTerm)) { $searchTerm = trim($this->serviceSearchTerm); $query->where(function ($q) use ($searchTerm) { - $q->where('service_name', 'like', '%' . $searchTerm . '%') - ->orWhere('description', 'like', '%' . $searchTerm . '%'); + $q->where('service_name', 'like', '%'.$searchTerm.'%') + ->orWhere('description', 'like', '%'.$searchTerm.'%'); }); } - + // Add category filter if provided - if (!empty($this->serviceCategoryFilter)) { + 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 + 'category_filter' => $this->serviceCategoryFilter, ]); - + $this->filteredServiceItems = collect(); } } @@ -346,7 +393,7 @@ class Create extends Component 'job_card_id' => $this->jobCard->id, 'user_id' => auth()->id(), 'entry_type' => 'manual', - 'description' => 'Diagnosis: ' . ($this->diagnosisTypes[$this->selectedDiagnosisType] ?? 'General Diagnosis'), + 'description' => 'Diagnosis: '.($this->diagnosisTypes[$this->selectedDiagnosisType] ?? 'General Diagnosis'), 'date' => now()->toDateString(), 'start_time' => now(), 'hourly_rate' => auth()->user()->hourly_rate ?? 85.00, @@ -354,21 +401,21 @@ class Create extends Component ]); $this->loadTimesheets(); - session()->flash('timesheet_message', 'Timesheet started for ' . ($this->diagnosisTypes[$this->selectedDiagnosisType] ?? 'Diagnosis')); + session()->flash('timesheet_message', 'Timesheet started for '.($this->diagnosisTypes[$this->selectedDiagnosisType] ?? 'Diagnosis')); } public function endTimesheet() { - if (!$this->currentTimesheet) { + if (! $this->currentTimesheet) { return; } $timesheet = Timesheet::find($this->currentTimesheet['id']); - if ($timesheet && !$timesheet->end_time) { + 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, @@ -377,7 +424,7 @@ class Create extends Component 'status' => 'submitted', ]); - session()->flash('timesheet_message', 'Timesheet ended. Total time: ' . $billableHours . ' hours'); + session()->flash('timesheet_message', 'Timesheet ended. Total time: '.$billableHours.' hours'); } $this->currentTimesheet = null; @@ -394,7 +441,7 @@ class Create extends Component 'part_number' => $part->part_number, 'quantity' => 1, 'estimated_cost' => $part->sell_price, - 'availability' => $part->quantity_on_hand > 0 ? 'in_stock' : 'out_of_stock' + 'availability' => $part->quantity_on_hand > 0 ? 'in_stock' : 'out_of_stock', ]; $this->updatedPartsRequired(); // Save to session } @@ -411,7 +458,7 @@ class Create extends Component 'estimated_hours' => $serviceItem->estimated_hours, 'labor_rate' => $serviceItem->labor_rate, 'category' => $serviceItem->category, - 'complexity' => 'medium' + 'complexity' => 'medium', ]; $this->updatedLaborOperations(); // Save to session } @@ -425,7 +472,7 @@ class Create extends Component 'part_number' => '', 'quantity' => 1, 'estimated_cost' => 0, - 'availability' => 'in_stock' + 'availability' => 'in_stock', ]; $this->updatedPartsRequired(); // Save to session } @@ -446,7 +493,7 @@ class Create extends Component 'estimated_hours' => 0, 'labor_rate' => 85.00, 'category' => '', - 'complexity' => 'medium' + 'complexity' => 'medium', ]; $this->updatedLaborOperations(); // Save to session } @@ -464,7 +511,7 @@ class Create extends Component $this->updatedPartsRequired(); $this->updatedLaborOperations(); $this->updatedDiagnosticCodes(); - + session()->flash('progress_saved', 'Progress saved successfully!'); } @@ -474,7 +521,7 @@ class Create extends Component 'code' => '', 'description' => '', 'system' => '', - 'severity' => 'medium' + 'severity' => 'medium', ]; } @@ -490,7 +537,7 @@ class Create extends Component 'test_name' => '', 'result' => '', 'specification' => '', - 'status' => 'pass' + 'status' => 'pass', ]; } @@ -505,7 +552,7 @@ class Create extends Component $this->special_tools_required[] = [ 'tool_name' => '', 'tool_type' => '', - 'availability' => 'available' + 'availability' => 'available', ]; } @@ -517,58 +564,58 @@ class Create extends Component public function togglePartsSection() { - $this->showPartsSection = !$this->showPartsSection; + $this->showPartsSection = ! $this->showPartsSection; } public function toggleLaborSection() { - $this->showLaborSection = !$this->showLaborSection; + $this->showLaborSection = ! $this->showLaborSection; } public function toggleDiagnosticCodesSection() { - $this->showDiagnosticCodesSection = !$this->showDiagnosticCodesSection; + $this->showDiagnosticCodesSection = ! $this->showDiagnosticCodesSection; } public function toggleTestResultsSection() { - $this->showTestResultsSection = !$this->showTestResultsSection; + $this->showTestResultsSection = ! $this->showTestResultsSection; } public function toggleAdvancedOptions() { - $this->showAdvancedOptions = !$this->showAdvancedOptions; + $this->showAdvancedOptions = ! $this->showAdvancedOptions; } public function toggleTimesheetSection() { - $this->showTimesheetSection = !$this->showTimesheetSection; + $this->showTimesheetSection = ! $this->showTimesheetSection; } public function calculateTotalEstimatedCost() { - $partsCost = array_sum(array_map(function($part) { + $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) { + $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); @@ -583,7 +630,7 @@ class Create extends Component $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), + '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(), @@ -594,13 +641,13 @@ class Create extends Component 'tax_rate' => $taxRate, 'tax_amount' => $taxAmount, 'total_amount' => $totalAmount, - 'notes' => 'Auto-generated from diagnosis: ' . $diagnosis->id, - 'valid_until' => now()->addDays(30), + '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'])) { + if (! empty($part['part_name'])) { EstimateLineItem::create([ 'estimate_id' => $estimate->id, 'type' => 'part', @@ -616,7 +663,7 @@ class Create extends Component // Create line items for labor foreach ($this->labor_operations as $operation) { - if (!empty($operation['operation'])) { + if (! empty($operation['operation'])) { EstimateLineItem::create([ 'estimate_id' => $estimate->id, 'type' => 'labor', @@ -659,21 +706,21 @@ class Create extends Component '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']); + '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']); + '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']); + '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']); + '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']); + 'test_results' => array_filter($this->test_results, function ($result) { + return ! empty($result['test_name']); }), 'photos' => $photoUrls, 'notes' => $this->notes, @@ -693,10 +740,11 @@ class Create extends Component session()->forget([ "diagnosis_parts_{$this->jobCard->id}", "diagnosis_labor_{$this->jobCard->id}", - "diagnosis_codes_{$this->jobCard->id}" + "diagnosis_codes_{$this->jobCard->id}", ]); - session()->flash('message', 'Diagnosis completed successfully! Estimate #' . $estimate->estimate_number . ' has been created automatically.'); + session()->flash('message', 'Diagnosis completed successfully! Estimate #'.$estimate->estimate_number.' has been created automatically.'); + return redirect()->route('estimates.show', $estimate); } diff --git a/app/Livewire/Diagnosis/Index.php b/app/Livewire/Diagnosis/Index.php index b4fd3a2..3d88502 100644 --- a/app/Livewire/Diagnosis/Index.php +++ b/app/Livewire/Diagnosis/Index.php @@ -2,14 +2,102 @@ namespace App\Livewire\Diagnosis; -use Livewire\Component; use App\Models\Diagnosis; +use Livewire\Component; +use Livewire\WithPagination; class Index extends Component { + use WithPagination; + + public $search = ''; + + public $statusFilter = ''; + + public $priorityFilter = ''; + + public $dateFrom = ''; + + protected $queryString = [ + 'search' => ['except' => ''], + 'statusFilter' => ['except' => ''], + 'priorityFilter' => ['except' => ''], + 'dateFrom' => ['except' => ''], + ]; + + public function updatingSearch() + { + $this->resetPage(); + } + + public function updatingStatusFilter() + { + $this->resetPage(); + } + + public function updatingPriorityFilter() + { + $this->resetPage(); + } + + public function updatingDateFrom() + { + $this->resetPage(); + } + + public function clearFilters() + { + $this->search = ''; + $this->statusFilter = ''; + $this->priorityFilter = ''; + $this->dateFrom = ''; + $this->resetPage(); + } + + public function refreshList() + { + $this->resetPage(); + $this->dispatch('$refresh'); + } + public function render() { - $diagnoses = Diagnosis::with(['jobCard', 'serviceCoordinator'])->paginate(20); + $query = Diagnosis::with([ + 'jobCard.customer', + 'jobCard.vehicle', + 'serviceCoordinator', + 'estimate', + ]); + + // Apply search filter + if ($this->search) { + $query->whereHas('jobCard', function ($q) { + $q->where('job_card_number', 'like', '%'.$this->search.'%') + ->orWhereHas('customer', function ($customerQuery) { + $customerQuery->where('first_name', 'like', '%'.$this->search.'%') + ->orWhere('last_name', 'like', '%'.$this->search.'%') + ->orWhere('phone', 'like', '%'.$this->search.'%'); + }); + }); + } + + // Apply status filter + if ($this->statusFilter) { + $query->where('diagnosis_status', $this->statusFilter); + } + + // Apply priority filter + if ($this->priorityFilter) { + $query->where('priority_level', $this->priorityFilter); + } + + // Apply date filter + if ($this->dateFrom) { + $query->whereDate('diagnosis_date', '>=', $this->dateFrom); + } + + $diagnoses = $query->orderBy('created_at', 'desc')->paginate(20); + return view('livewire.diagnosis.index', compact('diagnoses')); } } diff --git a/app/Livewire/Estimates/Create.php b/app/Livewire/Estimates/Create.php index a94d806..86613d2 100644 --- a/app/Livewire/Estimates/Create.php +++ b/app/Livewire/Estimates/Create.php @@ -12,16 +12,25 @@ use Livewire\Component; class Create extends Component { public Diagnosis $diagnosis; + 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; protected $rules = [ @@ -39,12 +48,12 @@ class Create extends Component { $this->diagnosis = $diagnosis->load([ 'jobCard.customer', - 'jobCard.vehicle' + 'jobCard.vehicle', ]); // Pre-populate from diagnosis $this->initializeLineItems(); - $this->terms_and_conditions = config('app.default_estimate_terms', + $this->terms_and_conditions = config('app.default_estimate_terms', 'This estimate is valid for 30 days. All work will be performed according to industry standards.' ); } @@ -52,29 +61,47 @@ class Create extends Component public function initializeLineItems() { // Add labor operations from diagnosis - foreach ($this->diagnosis->labor_operations as $labor) { - $this->lineItems[] = [ - 'type' => 'labor', - 'description' => $labor['operation'], - 'quantity' => $labor['estimated_hours'], - 'unit_price' => $labor['labor_rate'], - 'total_amount' => $labor['estimated_hours'] * $labor['labor_rate'], - 'labor_hours' => $labor['estimated_hours'], - 'labor_rate' => $labor['labor_rate'], - 'required' => true, - ]; + if ($this->diagnosis->labor_operations) { + foreach ($this->diagnosis->labor_operations as $labor) { + $this->lineItems[] = [ + 'type' => 'labor', + 'description' => $labor['operation'] ?? 'Labor Operation', + 'quantity' => $labor['estimated_hours'] ?? 1, + 'unit_price' => $labor['labor_rate'] ?? 75, + 'total_amount' => ($labor['estimated_hours'] ?? 1) * ($labor['labor_rate'] ?? 75), + 'labor_hours' => $labor['estimated_hours'] ?? 1, + 'labor_rate' => $labor['labor_rate'] ?? 75, + 'required' => true, + ]; + } } // Add parts from diagnosis - foreach ($this->diagnosis->parts_required as $part) { + if ($this->diagnosis->parts_required) { + foreach ($this->diagnosis->parts_required as $part) { + $this->lineItems[] = [ + 'type' => 'parts', + 'part_id' => null, + 'description' => ($part['part_name'] ?? 'Part').' ('.($part['part_number'] ?? 'N/A').')', + 'quantity' => $part['quantity'] ?? 1, + 'unit_price' => $part['estimated_cost'] ?? 0, + 'total_amount' => ($part['quantity'] ?? 1) * ($part['estimated_cost'] ?? 0), + 'markup_percentage' => 20, + 'required' => true, + ]; + } + } + + // If no line items from diagnosis, add a default labor item + if (empty($this->lineItems)) { $this->lineItems[] = [ - 'type' => 'parts', - 'part_id' => null, - 'description' => $part['part_name'] . ' (' . $part['part_number'] . ')', - 'quantity' => $part['quantity'], - 'unit_price' => $part['estimated_cost'], - 'total_amount' => $part['quantity'] * $part['estimated_cost'], - 'markup_percentage' => 20, + 'type' => 'labor', + 'description' => 'Diagnostic and Repair Services', + 'quantity' => $this->diagnosis->estimated_repair_time ?? 1, + 'unit_price' => 75, + 'total_amount' => ($this->diagnosis->estimated_repair_time ?? 1) * 75, + 'labor_hours' => $this->diagnosis->estimated_repair_time ?? 1, + 'labor_rate' => 75, 'required' => true, ]; } @@ -128,10 +155,26 @@ class Create extends Component // Generate estimate number $branchCode = $this->diagnosis->jobCard->branch_code; - $lastEstimateNumber = Estimate::where('estimate_number', 'like', $branchCode . '/EST%') - ->whereYear('created_at', now()->year) - ->count(); - $estimateNumber = $branchCode . '/EST' . str_pad($lastEstimateNumber + 1, 4, '0', STR_PAD_LEFT); + + // Use database lock to prevent race conditions + $maxNumber = \DB::transaction(function () use ($branchCode) { + $lastEstimate = Estimate::where('estimate_number', 'like', $branchCode.'/EST%') + ->whereYear('created_at', now()->year) + ->orderByRaw('CAST(SUBSTRING(estimate_number, '.(strlen($branchCode) + 5).') AS UNSIGNED) DESC') + ->lockForUpdate() + ->first(); + + $nextNumber = 1; + if ($lastEstimate) { + // Extract the number part from the estimate number (e.g., MAIN/EST0001 -> 1) + preg_match('/'.preg_quote($branchCode).'\/EST(\d+)/', $lastEstimate->estimate_number, $matches); + $nextNumber = isset($matches[1]) ? intval($matches[1]) + 1 : 1; + } + + return $nextNumber; + }); + + $estimateNumber = $branchCode.'/EST'.str_pad($maxNumber, 4, '0', STR_PAD_LEFT); $estimate = Estimate::create([ 'estimate_number' => $estimateNumber, @@ -174,6 +217,7 @@ class Create extends Component $this->diagnosis->jobCard->update(['status' => 'estimate_prepared']); session()->flash('message', 'Estimate created successfully!'); + return redirect()->route('estimates.show', $estimate); } @@ -182,7 +226,7 @@ class Create extends Component // This would be called after saving $customer = $this->diagnosis->jobCard->customer; $customer->notify(new EstimateNotification($estimate)); - + session()->flash('message', 'Estimate sent to customer successfully!'); } diff --git a/app/Livewire/Estimates/CreateStandalone.php b/app/Livewire/Estimates/CreateStandalone.php new file mode 100644 index 0000000..c5bbe49 --- /dev/null +++ b/app/Livewire/Estimates/CreateStandalone.php @@ -0,0 +1,286 @@ + '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, + ]); + } +} diff --git a/app/Livewire/Estimates/Edit.php b/app/Livewire/Estimates/Edit.php index 7b83f10..6832501 100644 --- a/app/Livewire/Estimates/Edit.php +++ b/app/Livewire/Estimates/Edit.php @@ -2,10 +2,501 @@ namespace App\Livewire\Estimates; +use App\Models\Estimate; +use App\Models\EstimateLineItem; +use App\Models\Part; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; +use Livewire\Attributes\Layout; +use Livewire\Attributes\Validate; use Livewire\Component; class Edit extends Component { + public Estimate $estimate; + + #[Validate('required|string')] + public $terms_and_conditions = ''; + + #[Validate('required|integer|min:1|max:365')] + public $validity_period_days = 30; + + #[Validate('required|numeric|min:0|max:50')] + public $tax_rate = 8.25; + + #[Validate('nullable|numeric|min:0')] + public $discount_amount = 0; + + #[Validate('nullable|string')] + public $notes = ''; + + #[Validate('nullable|string')] + public $internal_notes = ''; + + public $lineItems = []; + + public $deletedItems = []; + + public $subtotal = 0; + + public $tax_amount = 0; + + public $total_amount = 0; + + // Advanced features + public $showAdvancedOptions = false; + + public $bulkOperationMode = false; + + public $selectedItems = []; + + public $autoSave = true; + + public $lastSaved; + + // Quick add presets + public $quickAddPresets = [ + 'oil_change' => ['type' => 'labor', 'description' => 'Oil Change Service', 'quantity' => 1, 'unit_price' => 75], + 'brake_inspection' => ['type' => 'labor', 'description' => 'Brake System Inspection', 'quantity' => 1, 'unit_price' => 125], + 'tire_rotation' => ['type' => 'labor', 'description' => 'Tire Rotation Service', 'quantity' => 1, 'unit_price' => 50], + ]; + + // Line item templates + public $newItem = [ + 'type' => 'labor', + 'description' => '', + 'quantity' => 1, + 'unit_price' => 0, + 'part_id' => null, + 'markup_percentage' => 15, + 'discount_type' => 'none', + 'discount_value' => 0, + 'notes' => '', + 'is_taxable' => true, + ]; + + protected $rules = [ + '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', + ]; + + public function mount(Estimate $estimate) + { + $this->estimate = $estimate->load([ + 'jobCard.customer', + 'jobCard.vehicle', + 'customer', // For standalone estimates + 'vehicle', // For standalone estimates + 'lineItems.part', + ]); + + $this->loadEstimateData(); + $this->loadLineItems(); + $this->calculateTotals(); + } + + protected function loadEstimateData() + { + $this->terms_and_conditions = $this->estimate->terms_and_conditions ?? ''; + $this->validity_period_days = $this->estimate->validity_period_days ?? 30; + $this->tax_rate = $this->estimate->tax_rate ?? 8.25; + $this->discount_amount = $this->estimate->discount_amount ?? 0; + $this->notes = $this->estimate->notes ?? ''; + $this->internal_notes = $this->estimate->internal_notes ?? ''; + } + + protected function loadLineItems() + { + $this->lineItems = $this->estimate->lineItems->map(function ($item) { + return [ + 'id' => $item->id, + 'type' => $item->type, + 'description' => $item->description, + 'quantity' => $item->quantity, + 'unit_price' => $item->unit_price, + 'total_amount' => $item->total_amount, + 'labor_hours' => $item->labor_hours, + 'labor_rate' => $item->labor_rate, + 'markup_percentage' => $item->markup_percentage ?? 15, + 'discount_type' => $item->discount_type ?? 'none', + 'discount_value' => $item->discount_value ?? 0, + 'part_id' => $item->part_id, + 'part_name' => $item->part?->name, + 'notes' => $item->notes ?? '', + 'is_taxable' => $item->is_taxable ?? true, + 'is_editing' => false, + 'required' => true, + ]; + })->toArray(); + } + + public function addLineItem() + { + $this->validate([ + 'newItem.type' => 'required|in:labor,parts,miscellaneous', + 'newItem.description' => 'required|string|max:255', + 'newItem.quantity' => 'required|numeric|min:0.01', + 'newItem.unit_price' => 'required|numeric|min:0', + ]); + + $lineItem = [ + 'id' => null, + 'type' => $this->newItem['type'], + 'description' => $this->newItem['description'], + 'quantity' => $this->newItem['quantity'], + 'unit_price' => $this->newItem['unit_price'], + 'part_id' => $this->newItem['part_id'], + 'markup_percentage' => $this->newItem['markup_percentage'], + 'discount_type' => $this->newItem['discount_type'], + 'discount_value' => $this->newItem['discount_value'], + 'notes' => $this->newItem['notes'], + 'is_taxable' => $this->newItem['is_taxable'], + 'is_editing' => false, + 'required' => true, + ]; + + $lineItem['total_amount'] = $this->calculateLineItemTotal($lineItem); + + $this->lineItems[] = $lineItem; + + // Reset new item form + $this->newItem = [ + 'type' => 'labor', + 'description' => '', + 'quantity' => 1, + 'unit_price' => 0, + 'part_id' => null, + 'markup_percentage' => 15, + 'discount_type' => 'none', + 'discount_value' => 0, + 'notes' => '', + 'is_taxable' => true, + ]; + + $this->calculateTotals(); + + if ($this->autoSave) { + $this->autoSaveEstimate(); + } + } + + public function addQuickPreset($presetKey) + { + if (isset($this->quickAddPresets[$presetKey])) { + $preset = $this->quickAddPresets[$presetKey]; + + $lineItem = array_merge([ + 'id' => null, + 'part_id' => null, + 'markup_percentage' => 15, + 'discount_type' => 'none', + 'discount_value' => 0, + 'notes' => '', + 'is_taxable' => true, + 'is_editing' => false, + 'required' => true, + ], $preset); + + $lineItem['total_amount'] = $this->calculateLineItemTotal($lineItem); + + $this->lineItems[] = $lineItem; + $this->calculateTotals(); + + if ($this->autoSave) { + $this->autoSaveEstimate(); + } + } + } + + public function removeLineItem($index) + { + if (isset($this->lineItems[$index]['id'])) { + $this->deletedItems[] = $this->lineItems[$index]['id']; + } + + unset($this->lineItems[$index]); + $this->lineItems = array_values($this->lineItems); + $this->calculateTotals(); + + if ($this->autoSave) { + $this->autoSaveEstimate(); + } + } + + public function duplicateLineItem($index) + { + if (isset($this->lineItems[$index])) { + $item = $this->lineItems[$index]; + unset($item['id']); // Remove ID so it's treated as new + $item['description'] .= ' (Copy)'; + $item['is_editing'] = false; + + $this->lineItems[] = $item; + $this->calculateTotals(); + } + } + + public function editLineItem($index) + { + $this->lineItems[$index]['is_editing'] = true; + } + + public function saveLineItem($index) + { + $this->lineItems[$index]['is_editing'] = false; + $this->lineItems[$index]['total_amount'] = $this->calculateLineItemTotal($this->lineItems[$index]); + $this->calculateTotals(); + + if ($this->autoSave) { + $this->autoSaveEstimate(); + } + } + + public function cancelEditLineItem($index) + { + $this->lineItems[$index]['is_editing'] = false; + // Reload from database if it's an existing item + if (isset($this->lineItems[$index]['id'])) { + $this->loadLineItems(); + $this->calculateTotals(); + } + } + + public function toggleBulkMode() + { + $this->bulkOperationMode = ! $this->bulkOperationMode; + $this->selectedItems = []; + } + + public function bulkDelete() + { + foreach ($this->selectedItems as $index) { + if (isset($this->lineItems[$index]['id'])) { + $this->deletedItems[] = $this->lineItems[$index]['id']; + } + unset($this->lineItems[$index]); + } + + $this->lineItems = array_values($this->lineItems); + $this->selectedItems = []; + $this->bulkOperationMode = false; + $this->calculateTotals(); + } + + public function bulkApplyDiscount($discountType, $discountValue) + { + foreach ($this->selectedItems as $index) { + if (isset($this->lineItems[$index])) { + $this->lineItems[$index]['discount_type'] = $discountType; + $this->lineItems[$index]['discount_value'] = $discountValue; + $this->lineItems[$index]['total_amount'] = $this->calculateLineItemTotal($this->lineItems[$index]); + } + } + + $this->selectedItems = []; + $this->bulkOperationMode = false; + $this->calculateTotals(); + } + + public function bulkApplyMarkup($markupPercentage) + { + foreach ($this->selectedItems as $index) { + if (isset($this->lineItems[$index])) { + $this->lineItems[$index]['markup_percentage'] = $markupPercentage; + $this->lineItems[$index]['total_amount'] = $this->calculateLineItemTotal($this->lineItems[$index]); + } + } + + $this->selectedItems = []; + $this->bulkOperationMode = false; + $this->calculateTotals(); + } + + protected function calculateLineItemTotal($item) + { + $baseAmount = $item['quantity'] * $item['unit_price']; + + // Apply markup + if (isset($item['markup_percentage']) && $item['markup_percentage'] > 0) { + $baseAmount += $baseAmount * ($item['markup_percentage'] / 100); + } + + // Apply discount + if ($item['discount_type'] === 'percentage') { + $discount = $baseAmount * ($item['discount_value'] / 100); + } elseif ($item['discount_type'] === 'fixed') { + $discount = min($item['discount_value'], $baseAmount); + } else { + $discount = 0; + } + + return $baseAmount - $discount; + } + + public function updatedLineItems() + { + $this->calculateTotals(); + + if ($this->autoSave) { + $this->autoSaveEstimate(); + } + } + + public function calculateTotals() + { + // Calculate line item totals + foreach ($this->lineItems as $index => $item) { + $this->lineItems[$index]['total_amount'] = $this->calculateLineItemTotal($item); + } + + // Calculate subtotal + $this->subtotal = collect($this->lineItems)->sum('total_amount'); + + // Calculate tax on taxable items only + $taxableAmount = collect($this->lineItems) + ->where('is_taxable', true) + ->sum('total_amount') - $this->discount_amount; + + $this->tax_amount = max(0, $taxableAmount) * ($this->tax_rate / 100); + + // Calculate total + $this->total_amount = $this->subtotal - $this->discount_amount + $this->tax_amount; + } + + public function autoSaveEstimate() + { + try { + $this->estimate->update([ + 'subtotal' => $this->subtotal, + 'tax_amount' => $this->tax_amount, + 'total_amount' => $this->total_amount, + 'labor_cost' => collect($this->lineItems)->where('type', 'labor')->sum('total_amount'), + 'parts_cost' => collect($this->lineItems)->where('type', 'parts')->sum('total_amount'), + 'miscellaneous_cost' => collect($this->lineItems)->where('type', 'miscellaneous')->sum('total_amount'), + 'last_auto_saved_at' => now(), + ]); + + $this->lastSaved = now()->format('H:i:s'); + + } catch (\Exception $e) { + Log::error('Auto-save failed', [ + 'estimate_id' => $this->estimate->id, + 'error' => $e->getMessage(), + ]); + } + } + + public function save() + { + $this->validate(); + + try { + DB::transaction(function () { + $this->calculateTotals(); + + // Update estimate + $this->estimate->update([ + 'terms_and_conditions' => $this->terms_and_conditions, + 'validity_period_days' => $this->validity_period_days, + 'tax_rate' => $this->tax_rate, + 'discount_amount' => $this->discount_amount, + 'notes' => $this->notes, + 'internal_notes' => $this->internal_notes, + 'subtotal' => $this->subtotal, + 'tax_amount' => $this->tax_amount, + 'total_amount' => $this->total_amount, + 'labor_cost' => collect($this->lineItems)->where('type', 'labor')->sum('total_amount'), + 'parts_cost' => collect($this->lineItems)->where('type', 'parts')->sum('total_amount'), + 'miscellaneous_cost' => collect($this->lineItems)->where('type', 'miscellaneous')->sum('total_amount'), + 'updated_by_id' => auth()->id(), + ]); + + // Delete removed items + if (! empty($this->deletedItems)) { + EstimateLineItem::whereIn('id', $this->deletedItems)->delete(); + } + + // Update/create line items + foreach ($this->lineItems as $item) { + if ($item['id']) { + EstimateLineItem::where('id', $item['id'])->update([ + 'type' => $item['type'], + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'total_amount' => $item['total_amount'], + 'labor_hours' => $item['labor_hours'] ?? null, + 'labor_rate' => $item['labor_rate'] ?? null, + 'markup_percentage' => $item['markup_percentage'] ?? null, + 'discount_type' => $item['discount_type'] ?? 'none', + 'discount_value' => $item['discount_value'] ?? 0, + 'part_id' => $item['part_id'], + 'notes' => $item['notes'] ?? '', + 'is_taxable' => $item['is_taxable'] ?? true, + ]); + } else { + $this->estimate->lineItems()->create([ + 'type' => $item['type'], + 'description' => $item['description'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'total_amount' => $item['total_amount'], + 'labor_hours' => $item['labor_hours'] ?? null, + 'labor_rate' => $item['labor_rate'] ?? null, + 'markup_percentage' => $item['markup_percentage'] ?? null, + 'discount_type' => $item['discount_type'] ?? 'none', + 'discount_value' => $item['discount_value'] ?? 0, + 'part_id' => $item['part_id'], + 'notes' => $item['notes'] ?? '', + 'is_taxable' => $item['is_taxable'] ?? true, + ]); + } + } + }); + + Log::info('Estimate updated', [ + 'estimate_id' => $this->estimate->id, + 'updated_by' => auth()->id(), + ]); + + session()->flash('success', 'Estimate updated successfully.'); + + return redirect()->route('estimates.show', $this->estimate); + + } catch (\Exception $e) { + Log::error('Failed to update estimate', [ + 'estimate_id' => $this->estimate->id, + 'error' => $e->getMessage(), + ]); + + session()->flash('error', 'Failed to update estimate. Please try again.'); + } + } + + public function getAvailablePartsProperty() + { + return Part::where('stock_quantity', '>', 0) + ->orderBy('name') + ->get(['id', 'name', 'part_number', 'unit_price']); + } + + public function toggleAdvancedOptions() + { + $this->showAdvancedOptions = ! $this->showAdvancedOptions; + } + + public function toggleAutoSave() + { + $this->autoSave = ! $this->autoSave; + } + + #[Layout('components.layouts.app')] public function render() { return view('livewire.estimates.edit'); diff --git a/app/Livewire/Estimates/Index.php b/app/Livewire/Estimates/Index.php index 8edce66..199d5a5 100644 --- a/app/Livewire/Estimates/Index.php +++ b/app/Livewire/Estimates/Index.php @@ -2,7 +2,11 @@ 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; @@ -10,39 +14,461 @@ class Index extends Component { use WithPagination; + #[Url(as: 'search')] public $search = ''; + + #[Url(as: 'status')] public $statusFilter = ''; + + #[Url(as: 'approval')] public $approvalStatusFilter = ''; - public function updatingSearch() + #[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() { - $estimates = Estimate::with(['jobCard.customer', 'jobCard.vehicle', 'preparedBy']) - ->when($this->search, function ($query) { - $query->where(function ($q) { - $q->where('estimate_number', 'like', '%' . $this->search . '%') - ->orWhereHas('jobCard', function ($jobQuery) { - $jobQuery->where('job_number', 'like', '%' . $this->search . '%') - ->orWhereHas('customer', function ($customerQuery) { - $customerQuery->where('first_name', 'like', '%' . $this->search . '%') - ->orWhere('last_name', 'like', '%' . $this->search . '%'); - }); - }); - }); - }) - ->when($this->statusFilter, function ($query) { - $query->where('status', $this->statusFilter); - }) - ->when($this->approvalStatusFilter, function ($query) { - $query->where('customer_approval_status', $this->approvalStatusFilter); - }) + // Get available diagnoses that don't have estimates yet + $availableDiagnoses = \App\Models\Diagnosis::whereDoesntHave('estimate') + ->with(['jobCard.customer', 'jobCard.vehicle']) ->latest() - ->paginate(15); + ->limit(5) + ->get(); - return view('livewire.estimates.index', compact('estimates')); + return view('livewire.estimates.index', [ + 'estimates' => $this->getEstimates(), + 'customers' => $this->customers, + 'branches' => $this->branches, + 'stats' => $this->stats, + 'availableDiagnoses' => $availableDiagnoses, + ]); } } diff --git a/app/Livewire/Estimates/Show.php b/app/Livewire/Estimates/Show.php index cae4362..67fd14d 100644 --- a/app/Livewire/Estimates/Show.php +++ b/app/Livewire/Estimates/Show.php @@ -2,10 +2,270 @@ namespace App\Livewire\Estimates; +use App\Models\Estimate; +use App\Notifications\EstimateNotification; +use Barryvdh\DomPDF\Facade\Pdf; +use Illuminate\Support\Facades\Log; +use Livewire\Attributes\Layout; use Livewire\Component; class Show extends Component { + public Estimate $estimate; + + public $showItemDetails = false; + + public function mount(Estimate $estimate) + { + $this->estimate = $estimate->load([ + 'jobCard.customer', + 'jobCard.vehicle', + 'jobCard.branch', + 'customer', // For standalone estimates + 'vehicle', // For standalone estimates + 'diagnosis', + 'preparedBy', + 'lineItems', + 'workOrders', + ]); + } + + public function approveEstimate() + { + if (! auth()->user()->can('approve', $this->estimate)) { + session()->flash('error', 'You do not have permission to approve this estimate.'); + + return; + } + + try { + $this->estimate->update([ + 'customer_approval_status' => 'approved', + 'customer_approved_at' => now(), + 'customer_approval_method' => 'staff_portal', + 'status' => 'approved', + ]); + + // Update job card status to approved (if job card exists) + if ($this->estimate->jobCard) { + $this->estimate->jobCard->update([ + 'status' => 'approved', + ]); + } + + // Notify relevant parties + $customer = $this->estimate->customer ?? $this->estimate->jobCard?->customer; + if ($customer) { + $customer->notify( + new EstimateNotification($this->estimate, 'approved') + ); + } + + Log::info('Estimate approved', [ + 'estimate_id' => $this->estimate->id, + 'approved_by' => auth()->id(), + ]); + + session()->flash('success', 'Estimate approved successfully.'); + + // Refresh estimate data + $this->estimate->refresh(); + $this->dispatch('$refresh'); + + } catch (\Exception $e) { + Log::error('Failed to approve estimate', [ + 'estimate_id' => $this->estimate->id, + 'error' => $e->getMessage(), + ]); + + session()->flash('error', 'Failed to approve estimate. Please try again.'); + } + } + + public function rejectEstimate() + { + if (! auth()->user()->can('reject', $this->estimate)) { + session()->flash('error', 'You do not have permission to reject this estimate.'); + + return; + } + + try { + $this->estimate->update([ + 'customer_approval_status' => 'rejected', + 'customer_approved_at' => now(), + 'customer_approval_method' => 'staff_portal', + 'status' => 'rejected', + ]); + + // Notify relevant parties + $customer = $this->estimate->customer ?? $this->estimate->jobCard?->customer; + if ($customer) { + $customer->notify( + new EstimateNotification($this->estimate, 'rejected') + ); + } + + Log::info('Estimate rejected', [ + 'estimate_id' => $this->estimate->id, + 'rejected_by' => auth()->id(), + ]); + + session()->flash('success', 'Estimate rejected.'); + + // Refresh estimate data + $this->estimate->refresh(); + $this->dispatch('$refresh'); + + } catch (\Exception $e) { + Log::error('Failed to reject estimate', [ + 'estimate_id' => $this->estimate->id, + 'error' => $e->getMessage(), + ]); + + session()->flash('error', 'Failed to reject estimate. Please try again.'); + } + } + + public function sendToCustomer() + { + try { + $this->estimate->update([ + 'status' => 'sent', + 'sent_to_customer_at' => now(), + 'sent_by_id' => auth()->id(), + ]); + + // Send notification to customer + $customer = $this->estimate->customer ?? $this->estimate->jobCard?->customer; + if ($customer) { + $customer->notify( + new EstimateNotification($this->estimate, 'sent') + ); + } + + // Update job card status (if job card exists) + if ($this->estimate->jobCard) { + $this->estimate->jobCard->update([ + 'status' => 'estimate_sent', + ]); + } + + Log::info('Estimate sent to customer', [ + 'estimate_id' => $this->estimate->id, + 'customer_id' => $this->estimate->customer_id ?? $this->estimate->jobCard?->customer?->id, + 'sent_by' => auth()->id(), + ]); + + session()->flash('success', 'Estimate sent to customer successfully.'); + + // Refresh estimate data + $this->estimate->refresh(); + $this->dispatch('$refresh'); + + } catch (\Exception $e) { + Log::error('Failed to send estimate to customer', [ + 'estimate_id' => $this->estimate->id, + 'error' => $e->getMessage(), + ]); + + session()->flash('error', 'Failed to send estimate to customer. Please try again.'); + } + } + + public function duplicateEstimate() + { + try { + // Create a new estimate based on current one + $newEstimate = $this->estimate->replicate(); + $newEstimate->estimate_number = 'EST-'.str_pad(Estimate::max('id') + 1, 6, '0', STR_PAD_LEFT); + $newEstimate->status = 'draft'; + $newEstimate->customer_approval_status = 'pending'; + $newEstimate->sent_to_customer_at = null; + $newEstimate->customer_viewed_at = null; + $newEstimate->customer_approved_at = null; + $newEstimate->prepared_by_id = auth()->id(); + $newEstimate->save(); + + // Duplicate line items + foreach ($this->estimate->lineItems as $lineItem) { + $newLineItem = $lineItem->replicate(); + $newLineItem->estimate_id = $newEstimate->id; + $newLineItem->save(); + } + + Log::info('Estimate duplicated', [ + 'original_estimate_id' => $this->estimate->id, + 'new_estimate_id' => $newEstimate->id, + 'duplicated_by' => auth()->id(), + ]); + + session()->flash('success', 'Estimate has been duplicated successfully.'); + + return redirect()->route('estimates.edit', $newEstimate); + + } catch (\Exception $e) { + Log::error('Failed to duplicate estimate', [ + 'estimate_id' => $this->estimate->id, + 'error' => $e->getMessage(), + ]); + + session()->flash('error', 'Failed to duplicate estimate. Please try again.'); + } + } + + public function downloadPDF() + { + try { + // Generate PDF using DomPDF + $pdf = Pdf::loadView('estimates.pdf', [ + 'estimate' => $this->estimate, + ]); + + // Log the download + Log::info('Estimate PDF downloaded', [ + 'estimate_id' => $this->estimate->id, + 'downloaded_by' => auth()->id(), + ]); + + return response()->streamDownload(function () use ($pdf) { + echo $pdf->output(); + }, "estimate-{$this->estimate->estimate_number}.pdf"); + + } catch (\Exception $e) { + Log::error('Failed to generate estimate PDF', [ + 'estimate_id' => $this->estimate->id, + 'error' => $e->getMessage(), + ]); + + session()->flash('error', 'Failed to generate PDF. Please try again.'); + } + } + + public function toggleItemDetails() + { + $this->showItemDetails = ! $this->showItemDetails; + } + + public function refreshEstimate() + { + $this->estimate->refresh(); + session()->flash('success', 'Estimate data refreshed.'); + $this->dispatch('$refresh'); + } + + public function createWorkOrder() + { + if ($this->estimate->customer_approval_status !== 'approved') { + session()->flash('error', 'Estimate must be approved before creating a work order.'); + + return; + } + + return redirect()->route('work-orders.create', ['estimate' => $this->estimate->id]); + } + + #[Layout('components.layouts.app')] public function render() { return view('livewire.estimates.show'); diff --git a/app/Livewire/Inspections/Create.php b/app/Livewire/Inspections/Create.php index 5f3bfac..1b989ca 100644 --- a/app/Livewire/Inspections/Create.php +++ b/app/Livewire/Inspections/Create.php @@ -12,72 +12,180 @@ class Create extends Component use WithFileUploads; public JobCard $jobCard; + public $type; // 'incoming' or 'outgoing' - + public $current_mileage = ''; + public $fuel_level = ''; + public $overall_condition = ''; + public $recommendations = ''; + public $damage_notes = ''; + public $cleanliness_rating = 5; - public $quality_rating = ''; + + public $quality_rating = null; + public $follow_up_required = false; + public $notes = ''; + + public $additional_comments = ''; + public $photos = []; + public $videos = []; - - // Inspection checklist items - public $checklist = [ - 'exterior' => [ - 'body_condition' => '', - 'paint_condition' => '', - 'lights_working' => '', - 'mirrors_intact' => '', - 'windshield_condition' => '', - ], - 'interior' => [ - 'seats_condition' => '', - 'dashboard_condition' => '', - 'electronics_working' => '', - 'upholstery_condition' => '', - ], - 'mechanical' => [ - 'engine_condition' => '', - 'transmission_condition' => '', - 'brakes_condition' => '', - 'suspension_condition' => '', - 'tires_condition' => '', - ], - 'fluids' => [ - 'oil_level' => '', - 'coolant_level' => '', - 'brake_fluid_level' => '', - 'power_steering_fluid' => '', - ] - ]; + + public $damage_diagram_data = []; + + // Comprehensive inspection checklist items matching the form + public $checklist = []; protected $rules = [ - 'current_mileage' => 'required|numeric|min:0', - 'fuel_level' => 'required|string', - 'overall_condition' => 'required|in:excellent,good,fair,poor,damaged', + 'current_mileage' => 'required|numeric|min:0|max:999999', + 'fuel_level' => 'required|string|in:empty,low,quarter,half,three_quarter,full', + 'overall_condition' => 'required|in:excellent,good,fair,poor', 'cleanliness_rating' => 'required|integer|min:1|max:10', + 'quality_rating' => 'nullable|integer|min:1|max:10', + 'additional_comments' => 'nullable|string|max:1000', + 'damage_notes' => 'nullable|string|max:500', + 'recommendations' => 'nullable|string|max:500', + 'notes' => 'nullable|string|max:500', + 'damage_diagram_data' => 'nullable|array', + ]; + + protected $messages = [ + 'current_mileage.required' => 'Current mileage is required.', + 'current_mileage.max' => 'Mileage cannot exceed 999,999 km.', + 'fuel_level.required' => 'Please select the fuel level.', + 'overall_condition.required' => 'Please select the overall vehicle condition.', + 'additional_comments.max' => 'Additional comments cannot exceed 1000 characters.', ]; public function mount(JobCard $jobCard, $type) { $this->jobCard = $jobCard->load(['customer', 'vehicle']); $this->type = $type; - $this->current_mileage = $jobCard->vehicle->current_mileage ?? ''; - + + // Pull mileage and fuel level from job card if available + // Pre-populate with job card data if available + $this->current_mileage = $jobCard->mileage_in ?? $jobCard->vehicle->current_mileage ?? ''; + + // Normalize fuel level from job card + $jobCardFuelLevel = $jobCard->fuel_level_in ?? ''; + $this->fuel_level = $this->normalizeFuelLevel($jobCardFuelLevel); // Initialize the comprehensive checklist array properly + $this->checklist = [ + 'documentation' => [ + 'manufacturers_handbook' => '', + 'service_record_book' => '', + 'company_drivers_handbook' => '', + 'accident_report_form' => '', + 'safety_inspection_sticker' => '', + ], + 'exterior' => [ + 'windshield_not_cracked' => '', + 'windshield_wipers_functional' => '', + 'headlights_high_low' => '', + 'tail_lights_brake_lights' => '', + 'emergency_brake_working' => '', + 'power_brakes_working' => '', + 'horn_works' => '', + 'tires_good_shape' => '', + 'no_air_leaks' => '', + 'no_oil_grease_leaks' => '', + 'no_fuel_leaks' => '', + 'mirrors_good_position' => '', + 'exhaust_system_working' => '', + 'wheels_fasteners_tight' => '', + 'turn_signals' => '', + 'vehicle_free_damage' => '', + 'loads_fastened' => '', + 'spare_tire_good' => '', + 'vehicle_condition_satisfactory' => '', + 'defects_recorded' => '', + ], + 'interior' => [ + 'heating' => '', + 'air_conditioning' => '', + 'windshield_defrosting_system' => '', + 'window_operation' => '', + 'door_handles_locks' => '', + 'alarm' => '', + 'signals' => '', + 'seat_belts_work' => '', + 'interior_lights' => '', + 'mirrors_good_position' => '', + 'warning_lights' => '', + 'fuel_levels' => '', + 'oil_level_sufficient' => '', + 'washer_fluids_sufficient' => '', + 'radiator_fluid_sufficient' => '', + 'emergency_roadside_supplies' => '', + ], + 'engine' => [ + 'engine_oil_level' => '', + 'coolant_level_antifreeze' => '', + 'battery_secured' => '', + 'brake_fluid_level' => '', + 'air_filter_clean' => '', + 'belts_hoses_good' => '', + ], + ]; + if ($type === 'outgoing') { $this->rules['quality_rating'] = 'required|integer|min:1|max:10'; } } + private function normalizeFuelLevel($level) + { + // Map various possible fuel level values to our standard ones + $fuelLevelMap = [ + 'empty' => 'empty', + 'low' => 'low', + 'quarter' => 'quarter', + '1/4' => 'quarter', + 'half' => 'half', + '1/2' => 'half', + 'three_quarter' => 'three_quarter', + '3/4' => 'three_quarter', + 'full' => 'full', + // Add other possible variations + 'E' => 'empty', + 'L' => 'low', + 'Q' => 'quarter', + 'H' => 'half', + 'F' => 'full', + ]; + + $normalized = strtolower(trim($level)); + + return $fuelLevelMap[$normalized] ?? $level; + } + public function save() { $this->validate(); + // Validate that at least some checklist items are completed + $completedItems = 0; + foreach ($this->checklist as $section => $items) { + foreach ($items as $item => $value) { + if (! empty($value)) { + $completedItems++; + } + } + } + + if ($completedItems < 5) { + $this->addError('checklist', 'Please complete at least 5 inspection checklist items before saving.'); + + return; + } + // Handle file uploads $photoUrls = []; foreach ($this->photos as $photo) { @@ -100,36 +208,41 @@ class Create extends Component 'photos' => $photoUrls, 'videos' => $videoUrls, 'overall_condition' => $this->overall_condition, - 'recommendations' => $this->recommendations, - 'damage_notes' => $this->damage_notes, + 'recommendations' => $this->recommendations ?: null, + 'damage_notes' => $this->damage_notes ?: null, 'cleanliness_rating' => $this->cleanliness_rating, - 'quality_rating' => $this->quality_rating, + 'quality_rating' => $this->quality_rating ?: null, 'follow_up_required' => $this->follow_up_required, - 'notes' => $this->notes, + 'notes' => $this->notes ?: null, + 'additional_comments' => $this->additional_comments ?: null, + 'damage_diagram_data' => $this->damage_diagram_data, 'inspection_date' => now(), + 'service_order_id' => null, // This field is nullable ]); // Update job card status based on inspection type if ($this->type === 'incoming') { $this->jobCard->update([ - 'status' => 'inspection_completed', + 'status' => 'in_diagnosis', // Use the correct status from enum 'mileage_in' => $this->current_mileage, 'fuel_level_in' => $this->fuel_level, ]); } else { $this->jobCard->update([ - 'status' => 'quality_check_completed', + 'status' => 'completed', // Outgoing inspection means work is completed 'mileage_out' => $this->current_mileage, 'fuel_level_out' => $this->fuel_level, ]); } - session()->flash('message', ucfirst($this->type) . ' inspection completed successfully!'); + session()->flash('message', ucfirst($this->type).' inspection completed successfully!'); + return redirect()->route('inspections.show', $inspection); } public function render() { - return view('livewire.inspections.create'); + return view('livewire.inspections.create') + ->layout('components.layouts.app', ['title' => 'Vehicle Inspection']); } } diff --git a/app/Livewire/Inspections/Print.php b/app/Livewire/Inspections/Print.php new file mode 100644 index 0000000..696756f --- /dev/null +++ b/app/Livewire/Inspections/Print.php @@ -0,0 +1,25 @@ +inspection = $inspection->load(['jobCard.customer', 'jobCard.vehicle', 'jobCard.branch', 'inspector']); + $this->jobCard = $this->inspection->jobCard; + } + + public function render() + { + return view('livewire.inspections.print') + ->layout('components.layouts.print', ['title' => 'Vehicle Inspection Report - '.$this->jobCard->job_card_number]); + } +} diff --git a/app/Livewire/Inspections/PrintView.php b/app/Livewire/Inspections/PrintView.php new file mode 100644 index 0000000..624ac21 --- /dev/null +++ b/app/Livewire/Inspections/PrintView.php @@ -0,0 +1,25 @@ +inspection = $inspection->load(['jobCard.customer', 'jobCard.vehicle', 'jobCard.branch', 'inspector']); + $this->jobCard = $this->inspection->jobCard; + } + + public function render() + { + return view('livewire.inspections.print') + ->layout('components.layouts.print', ['title' => 'Vehicle Inspection Report - '.$this->jobCard->job_card_number]); + } +} diff --git a/app/Livewire/Inspections/Show.php b/app/Livewire/Inspections/Show.php index 1014189..64969eb 100644 --- a/app/Livewire/Inspections/Show.php +++ b/app/Livewire/Inspections/Show.php @@ -2,12 +2,21 @@ namespace App\Livewire\Inspections; +use App\Models\VehicleInspection; use Livewire\Component; class Show extends Component { + public VehicleInspection $inspection; + + public function mount(VehicleInspection $inspection) + { + $this->inspection = $inspection->load(['jobCard.customer', 'jobCard.vehicle', 'inspector']); + } + public function render() { - return view('livewire.inspections.show'); + return view('livewire.inspections.show') + ->layout('components.layouts.app', ['title' => 'Inspection Details']); } } diff --git a/app/Livewire/JobCards/Create.php b/app/Livewire/JobCards/Create.php index a6da72a..9820206 100644 --- a/app/Livewire/JobCards/Create.php +++ b/app/Livewire/JobCards/Create.php @@ -2,52 +2,70 @@ namespace App\Livewire\JobCards; -use Livewire\Component; -use App\Models\JobCard; -use App\Models\Customer; -use App\Models\Vehicle; -use App\Models\User; use App\Models\Branch; +use App\Models\Customer; +use App\Models\JobCard; +use App\Models\User; +use App\Models\Vehicle; use App\Services\WorkflowService; -use Illuminate\Validation\Rule; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Livewire\Component; class Create extends Component { use AuthorizesRequests; + public $customer_id = ''; + public $vehicle_id = ''; + public $service_advisor_id = ''; + public $branch_code = ''; + public $arrival_datetime = ''; + public $expected_completion_date = ''; + public $mileage_in = ''; + public $fuel_level_in = ''; + public $customer_reported_issues = ''; + public $vehicle_condition_notes = ''; + public $keys_location = ''; + public $personal_items_removed = false; + public $photos_taken = false; + public $priority = 'medium'; + public $notes = ''; - // Inspection fields + public $jobCardId = null; + + // Inspection fields public $perform_inspection = true; + public $inspector_id = ''; + public $overall_condition = ''; + public $inspection_notes = ''; - public $inspection_checklist = [ - 'exterior_damage' => '', - 'interior_condition' => '', - 'tire_condition' => '', - 'fluid_levels' => '', - 'lights_working' => '', - ]; + + public $inspection_checklist = []; public $customers = []; + public $vehicles = []; + public $serviceAdvisors = []; + public $inspectors = []; + public $branches = []; protected function rules() @@ -59,71 +77,47 @@ class Create extends Component 'branch_code' => 'required|string|max:10', 'arrival_datetime' => 'required|date', 'expected_completion_date' => 'nullable|date|after:arrival_datetime', - 'mileage_in' => 'required|integer|min:0|max:999999', + 'mileage_in' => 'nullable|integer|min:0', 'fuel_level_in' => 'nullable|string|max:20', 'customer_reported_issues' => 'required|string|max:2000', - 'vehicle_condition_notes' => 'nullable|string|max:1000', 'keys_location' => 'nullable|string|max:255', - 'personal_items_removed' => 'boolean', - 'photos_taken' => 'boolean', 'priority' => 'required|in:low,medium,high,urgent', 'notes' => 'nullable|string|max:2000', - // Inspection fields - 'perform_inspection' => 'boolean', - 'inspector_id' => $this->perform_inspection ? 'required|exists:users,id' : 'nullable', - 'overall_condition' => $this->perform_inspection ? 'required|in:excellent,good,fair,poor' : 'nullable', - 'inspection_notes' => 'nullable|string|max:1000', - 'inspection_checklist.exterior_damage' => $this->perform_inspection ? 'required|in:excellent,good,fair,poor' : 'nullable', - 'inspection_checklist.interior_condition' => $this->perform_inspection ? 'required|in:excellent,good,fair,poor' : 'nullable', - 'inspection_checklist.tire_condition' => $this->perform_inspection ? 'required|in:excellent,good,fair,poor' : 'nullable', - 'inspection_checklist.fluid_levels' => $this->perform_inspection ? 'required|in:excellent,good,fair,poor' : 'nullable', - 'inspection_checklist.lights_working' => $this->perform_inspection ? 'required|in:excellent,good,fair,poor' : 'nullable', ]; } public function mount() { - $this->loadData(); - - // Set default values + // Check if user has permission to create job cards + $this->authorize('create', JobCard::class); + + $this->branch_code = auth()->user()->branch_code ?? config('app.default_branch_code', 'ACC'); $this->arrival_datetime = now()->format('Y-m-d\TH:i'); - $this->expected_completion_date = now()->addDays(2)->format('Y-m-d'); - $this->mileage_in = 0; // Set default mileage - $this->fuel_level_in = '1/2'; - $this->keys_location = 'Reception Desk'; - $this->branch_code = auth()->user()->branch_code ?? 'MAIN'; - - // Initialize inspection checklist with empty values - $this->inspection_checklist = [ - 'exterior_damage' => '', - 'interior_condition' => '', - 'tire_condition' => '', - 'fluid_levels' => '', - 'lights_working' => '', - ]; + $this->loadData(); + $this->initializeInspectionChecklist(); } public function loadData() { $user = auth()->user(); - + $this->customers = Customer::orderBy('first_name')->get(); - + // Load active branches $this->branches = Branch::active()->orderBy('name')->get(); - + // Filter service advisors based on user's permissions and branch $this->serviceAdvisors = User::whereIn('role', ['service_advisor', 'service_supervisor']) ->where('status', 'active') - ->when(!$user->hasPermission('job-cards.view-all'), function ($query) use ($user) { + ->when(! $user->hasPermission('job-cards.view-all'), function ($query) use ($user) { return $query->where('branch_code', $user->branch_code); }) ->orderBy('name') ->get(); - + $this->inspectors = User::whereIn('role', ['service_supervisor', 'quality_inspector']) ->where('status', 'active') - ->when(!$user->hasPermission('job-cards.view-all'), function ($query) use ($user) { + ->when(! $user->hasPermission('job-cards.view-all'), function ($query) use ($user) { return $query->where('branch_code', $user->branch_code); }) ->orderBy('name') @@ -160,44 +154,16 @@ class Create extends Component ]; } - protected function cleanFormData() - { - // Convert empty strings to null for optional fields - if ($this->expected_completion_date === '') { - $this->expected_completion_date = null; - } - - if ($this->vehicle_condition_notes === '') { - $this->vehicle_condition_notes = null; - } - - if ($this->notes === '') { - $this->notes = null; - } - - if ($this->inspection_notes === '') { - $this->inspection_notes = null; - } - } - public function save() { + // Check if user still has permission to create job cards + $this->authorize('create', JobCard::class); + + $this->validate(); + try { - // Check if user still has permission to create job cards - $this->authorize('create', JobCard::class); - - // Clean form data (convert empty strings to null) - $this->cleanFormData(); - - // Add debug log - \Log::info('JobCard Create: Starting validation', ['user_id' => auth()->id()]); - - $this->validate(); - - \Log::info('JobCard Create: Validation passed'); - $workflowService = app(WorkflowService::class); - + $data = [ 'customer_id' => $this->customer_id, 'vehicle_id' => $this->vehicle_id, @@ -208,41 +174,32 @@ class Create extends Component 'mileage_in' => $this->mileage_in, 'fuel_level_in' => $this->fuel_level_in, 'customer_reported_issues' => $this->customer_reported_issues, - 'vehicle_condition_notes' => $this->vehicle_condition_notes, 'keys_location' => $this->keys_location, - 'personal_items_removed' => $this->personal_items_removed, - 'photos_taken' => $this->photos_taken, 'priority' => $this->priority, 'notes' => $this->notes, + // Set default values for removed fields + 'vehicle_condition_notes' => null, + 'personal_items_removed' => false, + 'photos_taken' => false, ]; - if ($this->perform_inspection) { - $data['inspector_id'] = $this->inspector_id; - $data['inspection_checklist'] = $this->inspection_checklist; - $data['overall_condition'] = $this->overall_condition; - $data['inspection_notes'] = $this->inspection_notes; - } - - \Log::info('JobCard Create: Creating job card with data', $data); - $jobCard = $workflowService->createJobCard($data); - \Log::info('JobCard Create: Job card created successfully', ['job_card_id' => $jobCard->id]); + // Set the job card ID to show the inspection button + $this->jobCardId = $jobCard->id; - session()->flash('success', 'Job card created successfully! Job Card #: ' . $jobCard->job_card_number); - - return redirect()->route('job-cards.show', $jobCard); + session()->flash('success', 'Job card created successfully! Job Card #: '.$jobCard->job_card_number.'. You can now perform the initial inspection.'); - } catch (\Illuminate\Validation\ValidationException $e) { - \Log::error('JobCard Create: Validation failed', ['errors' => $e->errors()]); - session()->flash('error', 'Please check the form for errors and try again.'); - throw $e; - } catch (\Exception $e) { - \Log::error('JobCard Create: Exception occurred', [ - 'message' => $e->getMessage(), - 'trace' => $e->getTraceAsString() + // Reset form fields except jobCardId + $this->reset([ + 'customer_id', 'vehicle_id', 'service_advisor_id', 'branch_code', + 'arrival_datetime', 'expected_completion_date', 'mileage_in', + 'fuel_level_in', 'customer_reported_issues', 'keys_location', + 'priority', 'notes', ]); - session()->flash('error', 'Failed to create job card: ' . $e->getMessage()); + + } catch (\Exception $e) { + session()->flash('error', 'Failed to create job card: '.$e->getMessage()); } } diff --git a/app/Livewire/JobCards/Index.php b/app/Livewire/JobCards/Index.php index a6e541b..12ad8b9 100644 --- a/app/Livewire/JobCards/Index.php +++ b/app/Livewire/JobCards/Index.php @@ -2,129 +2,52 @@ namespace App\Livewire\JobCards; +use App\Models\Branch; +use App\Models\JobCard; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Livewire\WithPagination; -use App\Models\JobCard; -use App\Models\Branch; -use App\Models\User; -use Illuminate\Foundation\Auth\Access\AuthorizesRequests; class Index extends Component { - use WithPagination, AuthorizesRequests; + use AuthorizesRequests, WithPagination; public $search = ''; + public $statusFilter = ''; + public $branchFilter = ''; - public $priorityFilter = ''; - public $serviceAdvisorFilter = ''; - public $dateRange = ''; + public $sortBy = 'created_at'; + public $sortDirection = 'desc'; - // Bulk actions - public $selectedJobCards = []; - public $selectAll = false; - public $bulkAction = ''; - - // Statistics - public $statistics = [ - 'total' => 0, - 'received' => 0, - 'in_progress' => 0, - 'pending_approval' => 0, - 'completed_today' => 0, - 'delivered_today' => 0, - 'overdue' => 0, - ]; - protected $queryString = [ 'search' => ['except' => ''], 'statusFilter' => ['except' => ''], 'branchFilter' => ['except' => ''], - 'priorityFilter' => ['except' => ''], - 'serviceAdvisorFilter' => ['except' => ''], - 'dateRange' => ['except' => ''], 'sortBy' => ['except' => 'created_at'], 'sortDirection' => ['except' => 'desc'], ]; - public function boot() - { - // Ensure properties are properly initialized - $this->selectedJobCards = $this->selectedJobCards ?? []; - $this->statistics = $this->statistics ?? [ - 'total' => 0, - 'received' => 0, - 'in_progress' => 0, - 'pending_approval' => 0, - 'completed_today' => 0, - 'delivered_today' => 0, - 'overdue' => 0, - ]; - } - public function mount() { - $this->boot(); // Ensure properties are initialized $this->authorize('viewAny', JobCard::class); - - // Add debug information to debugbar - if (app()->bound('debugbar')) { - debugbar()->info('JobCard Index component mounted'); - debugbar()->addMessage('User: ' . auth()->user()->name, 'user'); - debugbar()->addMessage('User permissions checked for JobCard access', 'auth'); - } - - $this->loadStatistics(); } public function updatingSearch() { $this->resetPage(); - $this->selectedJobCards = []; - $this->selectAll = false; - $this->loadStatistics(); } public function updatingStatusFilter() { $this->resetPage(); - $this->selectedJobCards = []; - $this->selectAll = false; - $this->loadStatistics(); } public function updatingBranchFilter() { $this->resetPage(); - $this->selectedJobCards = []; - $this->selectAll = false; - $this->loadStatistics(); - } - - public function updatingPriorityFilter() - { - $this->resetPage(); - $this->selectedJobCards = []; - $this->selectAll = false; - $this->loadStatistics(); - } - - public function updatingServiceAdvisorFilter() - { - $this->resetPage(); - $this->selectedJobCards = []; - $this->selectAll = false; - $this->loadStatistics(); - } - - public function updatingDateRange() - { - $this->resetPage(); - $this->selectedJobCards = []; - $this->selectAll = false; - $this->loadStatistics(); } public function sortBy($field) @@ -135,380 +58,89 @@ class Index extends Component $this->sortBy = $field; $this->sortDirection = 'asc'; } - $this->resetPage(); } - public function refreshData() + public function getJobCardsProperty() { - $this->loadStatistics(); - $this->selectedJobCards = []; - $this->selectAll = false; - session()->flash('success', 'Data refreshed successfully.'); - } - - public function clearFilters() - { - $this->search = ''; - $this->statusFilter = ''; - $this->branchFilter = ''; - $this->priorityFilter = ''; - $this->serviceAdvisorFilter = ''; - $this->dateRange = ''; - $this->selectedJobCards = []; - $this->selectAll = false; - $this->resetPage(); - $this->loadStatistics(); - session()->flash('success', 'Filters cleared successfully.'); - } - - /** - * Get workflow progress percentage for a job card - */ - public function getWorkflowProgress($status) - { - $steps = [ - JobCard::STATUS_RECEIVED => 1, - JobCard::STATUS_INSPECTED => 2, - JobCard::STATUS_ASSIGNED_FOR_DIAGNOSIS => 3, - JobCard::STATUS_IN_DIAGNOSIS => 4, - JobCard::STATUS_ESTIMATE_SENT => 5, - JobCard::STATUS_APPROVED => 6, - JobCard::STATUS_PARTS_PROCUREMENT => 7, - JobCard::STATUS_IN_PROGRESS => 8, - JobCard::STATUS_QUALITY_REVIEW_REQUIRED => 9, - JobCard::STATUS_COMPLETED => 10, - JobCard::STATUS_DELIVERED => 11, - ]; - - $currentStep = $steps[$status] ?? 1; - return round(($currentStep / 11) * 100); - } - - public function loadStatistics() - { - try { - if (app()->bound('debugbar')) { - debugbar()->startMeasure('statistics', 'Loading JobCard Statistics'); - } - - $user = auth()->user(); - $query = JobCard::query(); - - // Apply branch filtering based on user permissions - if (!$user->hasPermission('job-cards.view-all')) { - if ($user->hasPermission('job-cards.view-own')) { - $query->where('service_advisor_id', $user->id); - } elseif ($user->hasPermission('job-cards.view')) { - $query->where('branch_code', $user->branch_code); - } - } - - $this->statistics = [ - 'total' => $query->count(), - 'received' => (clone $query)->where('status', JobCard::STATUS_RECEIVED)->count(), - 'in_progress' => (clone $query)->whereIn('status', [ - JobCard::STATUS_IN_DIAGNOSIS, - JobCard::STATUS_IN_PROGRESS, - JobCard::STATUS_PARTS_PROCUREMENT - ])->count(), - 'pending_approval' => (clone $query)->where('status', JobCard::STATUS_ESTIMATE_SENT)->count(), - 'completed_today' => (clone $query)->where('status', JobCard::STATUS_COMPLETED) - ->whereDate('completion_datetime', today())->count(), - 'delivered_today' => (clone $query)->where('status', JobCard::STATUS_DELIVERED) - ->whereDate('completion_datetime', today())->count(), - 'overdue' => (clone $query)->where('expected_completion_date', '<', now()) - ->whereNotIn('status', [JobCard::STATUS_COMPLETED, JobCard::STATUS_DELIVERED]) - ->count(), - ]; - - if (app()->bound('debugbar')) { - debugbar()->stopMeasure('statistics'); - debugbar()->addMessage('Statistics loaded: ' . json_encode($this->statistics), 'statistics'); - } - } catch (\Exception $e) { - // Fallback statistics if there's an error - $this->statistics = [ - 'total' => 0, - 'received' => 0, - 'in_progress' => 0, - 'pending_approval' => 0, - 'completed_today' => 0, - 'delivered_today' => 0, - 'overdue' => 0, - ]; - - if (app()->bound('debugbar')) { - debugbar()->error('Error loading JobCard statistics: ' . $e->getMessage()); - } - - logger()->error('Error loading JobCard statistics: ' . $e->getMessage()); - } - } - - public function updatedSelectAll() - { - if ($this->selectAll) { - try { - $this->selectedJobCards = $this->getJobCards()->pluck('id')->toArray(); - } catch (\Exception $e) { - $this->selectedJobCards = []; - $this->selectAll = false; - session()->flash('error', 'Unable to select all job cards. Please try again.'); - } - } else { - $this->selectedJobCards = []; - } - } - - public function processBulkAction() - { - if (empty($this->selectedJobCards) || empty($this->bulkAction)) { - session()->flash('error', 'Please select job cards and an action.'); - return; - } - - $successCount = 0; - $errorCount = 0; - - foreach ($this->selectedJobCards as $jobCardId) { - try { - $jobCard = JobCard::find($jobCardId); - if (!$jobCard) continue; - - switch ($this->bulkAction) { - case 'export_csv': - return $this->exportSelected(); - break; - } - } catch (\Exception $e) { - $errorCount++; - } - } - - $this->selectedJobCards = []; - $this->selectAll = false; - $this->bulkAction = ''; - $this->loadStatistics(); // Refresh statistics after bulk operations - - if ($successCount > 0) { - session()->flash('success', "{$successCount} job cards processed successfully."); - } - if ($errorCount > 0) { - session()->flash('error', "{$errorCount} job cards failed to process."); - } - } - - public function exportSelected() - { - if (empty($this->selectedJobCards)) { - session()->flash('error', 'Please select job cards to export.'); - return; - } - - $jobCards = JobCard::with(['customer', 'vehicle', 'serviceAdvisor']) - ->whereIn('id', $this->selectedJobCards) - ->get(); - - $csv = "Job Card Number,Customer,Vehicle,Service Advisor,Status,Priority,Created Date,Expected Completion\n"; - - foreach ($jobCards as $jobCard) { - $csv .= sprintf( - "%s,%s,%s,%s,%s,%s,%s,%s\n", - $jobCard->job_card_number, - $jobCard->customer->full_name ?? '', - $jobCard->vehicle->display_name ?? '', - $jobCard->serviceAdvisor->name ?? '', - $jobCard->status, - $jobCard->priority, - $jobCard->created_at->format('Y-m-d'), - $jobCard->expected_completion_date ? $jobCard->expected_completion_date->format('Y-m-d') : '' - ); - } - - return response()->streamDownload(function () use ($csv) { - echo $csv; - }, 'job-cards-' . date('Y-m-d') . '.csv', [ - 'Content-Type' => 'text/csv', - ]); - } - - protected function getJobCards() - { - try { - $user = auth()->user(); - $query = JobCard::query() - ->with(['customer', 'vehicle', 'serviceAdvisor']); - - // Apply permission-based filtering - if (!$user->hasPermission('job-cards.view-all')) { - if ($user->hasPermission('job-cards.view-own')) { - $query->where('service_advisor_id', $user->id); - } elseif ($user->hasPermission('job-cards.view')) { - $query->where('branch_code', $user->branch_code); - } - } - - // Apply filters - if ($this->search) { + $user = auth()->user(); + $query = JobCard::with(['customer', 'vehicle']) + ->when($this->search, function ($query) { $query->where(function ($q) { - $q->where('job_card_number', 'like', '%' . $this->search . '%') - ->orWhereHas('customer', function ($customerQuery) { - $customerQuery->where('first_name', 'like', '%' . $this->search . '%') - ->orWhere('last_name', 'like', '%' . $this->search . '%') - ->orWhere('email', 'like', '%' . $this->search . '%'); - }) - ->orWhereHas('vehicle', function ($vehicleQuery) { - $vehicleQuery->where('license_plate', 'like', '%' . $this->search . '%') - ->orWhere('vin', 'like', '%' . $this->search . '%'); - }); + $q->where('job_card_number', 'like', '%'.$this->search.'%') + ->orWhereHas('customer', function ($customerQuery) { + $customerQuery->where('name', 'like', '%'.$this->search.'%') + ->orWhere('phone', 'like', '%'.$this->search.'%'); + }) + ->orWhereHas('vehicle', function ($vehicleQuery) { + $vehicleQuery->where('license_plate', 'like', '%'.$this->search.'%') + ->orWhere('make', 'like', '%'.$this->search.'%') + ->orWhere('model', 'like', '%'.$this->search.'%'); + }); }); - } - - if ($this->statusFilter) { + }) + ->when($this->statusFilter, function ($query) { $query->where('status', $this->statusFilter); - } - - if ($this->branchFilter) { + }) + ->when($this->branchFilter, function ($query) { $query->where('branch_code', $this->branchFilter); - } + }); - if ($this->priorityFilter) { - $query->where('priority', $this->priorityFilter); + // Apply permissions + if (! $user->hasPermission('job-cards.view-all')) { + if ($user->hasPermission('job-cards.view-own')) { + $query->where('service_advisor_id', $user->id); + } elseif ($user->branch_code) { + $query->where('branch_code', $user->branch_code); } - - if ($this->serviceAdvisorFilter) { - $query->where('service_advisor_id', $this->serviceAdvisorFilter); - } - - if ($this->dateRange) { - switch ($this->dateRange) { - case 'today': - $query->whereDate('created_at', today()); - break; - case 'week': - $query->whereBetween('created_at', [now()->startOfWeek(), now()->endOfWeek()]); - break; - case 'month': - $query->whereMonth('created_at', now()->month) - ->whereYear('created_at', now()->year); - break; - case 'overdue': - $query->where('expected_completion_date', '<', now()) - ->whereNotIn('status', [JobCard::STATUS_COMPLETED, JobCard::STATUS_DELIVERED]); - break; - } - } - - return $query->orderBy($this->sortBy, $this->sortDirection); - } catch (\Exception $e) { - logger()->error('Error in getJobCards query: ' . $e->getMessage()); - // Return empty query as fallback - return JobCard::query()->whereRaw('1 = 0'); // Returns empty result set } + + return $query->orderBy($this->sortBy, $this->sortDirection); + } + + public function getStatisticsProperty() + { + $user = auth()->user(); + $baseQuery = JobCard::query(); + + // Apply same permission filters as main query + if (! $user->hasPermission('job-cards.view-all')) { + if ($user->hasPermission('job-cards.view-own')) { + $baseQuery->where('service_advisor_id', $user->id); + } elseif ($user->branch_code) { + $baseQuery->where('branch_code', $user->branch_code); + } + } + + return [ + 'total' => (clone $baseQuery)->count(), + 'received' => (clone $baseQuery)->where('status', JobCard::STATUS_RECEIVED)->count(), + 'in_progress' => (clone $baseQuery)->whereIn('status', [ + JobCard::STATUS_IN_DIAGNOSIS, + JobCard::STATUS_IN_PROGRESS, + JobCard::STATUS_PARTS_PROCUREMENT, + ])->count(), + 'completed_today' => (clone $baseQuery)->where('status', JobCard::STATUS_COMPLETED) + ->whereDate('updated_at', today())->count(), + 'pending_approval' => (clone $baseQuery)->where('status', JobCard::STATUS_ESTIMATE_SENT)->count(), + ]; } public function render() { - try { - // Ensure statistics are always fresh and available - if (empty($this->statistics) || !isset($this->statistics['total'])) { - $this->loadStatistics(); - } - - $jobCards = $this->getJobCards()->paginate(20); + $jobCards = $this->jobCards->paginate(15); - $statusOptions = JobCard::getStatusOptions(); - - $priorityOptions = [ - 'low' => 'Low', - 'medium' => 'Medium', - 'high' => 'High', - 'urgent' => 'Urgent', - ]; + $statusOptions = JobCard::getStatusOptions(); - $branchOptions = Branch::active() - ->orderBy('name') - ->pluck('name', 'code') - ->toArray(); - - $serviceAdvisorOptions = User::whereHas('roles', function ($query) { - $query->whereIn('name', ['service_advisor', 'service_supervisor']); - }) - ->where('status', 'active') + $branchOptions = Branch::active() ->orderBy('name') - ->pluck('name', 'id') + ->pluck('name', 'code') ->toArray(); - $dateRangeOptions = [ - 'today' => 'Today', - 'week' => 'This Week', - 'month' => 'This Month', - 'overdue' => 'Overdue', - ]; - - return view('livewire.job-cards.index', compact( - 'jobCards', - 'statusOptions', - 'priorityOptions', - 'branchOptions', - 'serviceAdvisorOptions', - 'dateRangeOptions' - ))->with([ - 'statistics' => $this->statistics, - 'selectedJobCards' => $this->selectedJobCards ?? [], - 'selectAll' => $this->selectAll ?? false, - 'bulkAction' => $this->bulkAction ?? '', - 'search' => $this->search ?? '', - 'statusFilter' => $this->statusFilter ?? '', - 'branchFilter' => $this->branchFilter ?? '', - 'priorityFilter' => $this->priorityFilter ?? '', - 'serviceAdvisorFilter' => $this->serviceAdvisorFilter ?? '', - 'dateRange' => $this->dateRange ?? '', - 'sortBy' => $this->sortBy ?? 'created_at', - 'sortDirection' => $this->sortDirection ?? 'desc' - ]); - - } catch (\Exception $e) { - logger()->error('Error rendering JobCard Index: ' . $e->getMessage()); - - // Provide fallback data - $jobCards = collect()->paginate(20); - $statusOptions = []; - $priorityOptions = []; - $branchOptions = []; - $serviceAdvisorOptions = []; - $dateRangeOptions = []; - $statistics = $this->statistics ?? []; - - session()->flash('error', 'There was an error loading the job cards. Please try again.'); - - return view('livewire.job-cards.index', compact( - 'jobCards', - 'statusOptions', - 'priorityOptions', - 'branchOptions', - 'serviceAdvisorOptions', - 'dateRangeOptions' - ))->with([ - 'statistics' => $statistics, - 'selectedJobCards' => $this->selectedJobCards ?? [], - 'selectAll' => $this->selectAll ?? false, - 'bulkAction' => $this->bulkAction ?? '', - 'search' => $this->search ?? '', - 'statusFilter' => $this->statusFilter ?? '', - 'branchFilter' => $this->branchFilter ?? '', - 'priorityFilter' => $this->priorityFilter ?? '', - 'serviceAdvisorFilter' => $this->serviceAdvisorFilter ?? '', - 'dateRange' => $this->dateRange ?? '' - ]); - } - } - - /** - * Handle the component invocation for route compatibility - */ - public function __invoke() - { - return $this->render(); + return view('livewire.job-cards.index', [ + 'jobCards' => $jobCards, + 'statistics' => $this->statistics, + 'statusOptions' => $statusOptions, + 'branchOptions' => $branchOptions, + ]); } } diff --git a/app/Livewire/JobCards/Show.php b/app/Livewire/JobCards/Show.php index 7132ed4..1eece94 100644 --- a/app/Livewire/JobCards/Show.php +++ b/app/Livewire/JobCards/Show.php @@ -2,26 +2,111 @@ namespace App\Livewire\JobCards; -use Livewire\Component; use App\Models\JobCard; +use App\Models\User; +use Livewire\Component; class Show extends Component { public JobCard $jobCard; + public $availableTechnicians = []; + + public $selectedTechnicianId = null; + + public $showAssignmentModal = false; + public function mount(JobCard $jobCard) { $this->jobCard = $jobCard->load([ 'customer', 'vehicle', 'serviceAdvisor', + 'branch', 'incomingInspection', 'outgoingInspection', 'diagnosis', 'estimates', 'workOrders', - 'timesheets' + 'timesheets', ]); + + // Load available technicians for assignment + $this->availableTechnicians = User::where('status', 'active') + ->whereHas('roles', function ($query) { + $query->whereIn('name', ['technician', 'service_coordinator']); + }) + ->get(['id', 'name', 'email']); + } + + public function assignForDiagnosis() + { + if (! $this->selectedTechnicianId) { + session()->flash('error', 'Please select a technician for diagnosis assignment.'); + + return; + } + + if ($this->jobCard->status !== JobCard::STATUS_INSPECTED) { + session()->flash('error', 'Job card must be inspected before assignment for diagnosis.'); + + return; + } + + try { + $this->jobCard->update([ + 'status' => JobCard::STATUS_ASSIGNED_FOR_DIAGNOSIS, + 'service_advisor_id' => $this->selectedTechnicianId, + ]); + + $this->showAssignmentModal = false; + $this->selectedTechnicianId = null; + + session()->flash('success', 'Job card assigned for diagnosis successfully.'); + + // Refresh the job card data + $this->jobCard->refresh(); + + } catch (\Exception $e) { + session()->flash('error', 'Failed to assign job card: '.$e->getMessage()); + } + } + + public function startDiagnosis() + { + if ($this->jobCard->status !== JobCard::STATUS_ASSIGNED_FOR_DIAGNOSIS) { + session()->flash('error', 'Job card must be assigned for diagnosis first.'); + + return; + } + + try { + $this->jobCard->update([ + 'status' => JobCard::STATUS_IN_DIAGNOSIS, + ]); + + session()->flash('success', 'Diagnosis started. You can now create a diagnosis record.'); + + // Refresh the job card data + $this->jobCard->refresh(); + + // Redirect to diagnosis creation + return redirect()->route('diagnosis.create', $this->jobCard); + + } catch (\Exception $e) { + session()->flash('error', 'Failed to start diagnosis: '.$e->getMessage()); + } + } + + public function openAssignmentModal() + { + $this->showAssignmentModal = true; + } + + public function closeAssignmentModal() + { + $this->showAssignmentModal = false; + $this->selectedTechnicianId = null; } public function render() diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 6edc982..cf82373 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -4,8 +4,8 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class Customer extends Model { @@ -60,6 +60,11 @@ class Customer extends Model return "{$this->first_name} {$this->last_name}"; } + public function getNameAttribute(): string + { + return $this->getFullNameAttribute(); + } + public function getFormattedAddressAttribute(): string { return "{$this->address}, {$this->city}, {$this->state} {$this->zip_code}"; diff --git a/app/Models/Diagnosis.php b/app/Models/Diagnosis.php index 3912f68..5d36fbe 100644 --- a/app/Models/Diagnosis.php +++ b/app/Models/Diagnosis.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; class Diagnosis extends Model { diff --git a/app/Models/Estimate.php b/app/Models/Estimate.php index e5650c3..a140e6d 100644 --- a/app/Models/Estimate.php +++ b/app/Models/Estimate.php @@ -14,6 +14,8 @@ class Estimate extends Model protected $fillable = [ 'job_card_id', 'diagnosis_id', + 'customer_id', + 'vehicle_id', 'estimate_number', 'prepared_by_id', 'labor_cost', @@ -44,7 +46,7 @@ class Estimate extends Model 'parts_cost' => 'decimal:2', 'miscellaneous_cost' => 'decimal:2', 'subtotal' => 'decimal:2', - 'tax_rate' => 'decimal:4', + 'tax_rate' => 'decimal:2', 'tax_amount' => 'decimal:2', 'discount_amount' => 'decimal:2', 'total_amount' => 'decimal:2', @@ -57,22 +59,17 @@ class Estimate extends Model protected static function boot() { parent::boot(); - + static::creating(function ($estimate) { if (empty($estimate->estimate_number)) { - $estimate->estimate_number = 'EST-' . date('Y') . '-' . str_pad( - static::whereYear('created_at', now()->year)->count() + 1, - 6, - '0', - STR_PAD_LEFT - ); + $estimate->estimate_number = 'EST'.str_pad(rand(1, 9999), 4, '0', STR_PAD_LEFT); } }); } public function jobCard(): BelongsTo { - return $this->belongsTo(JobCard::class); + return $this->belongsTo(JobCard::class, 'job_card_id'); } public function diagnosis(): BelongsTo @@ -85,6 +82,16 @@ class Estimate extends Model return $this->belongsTo(User::class, 'prepared_by_id'); } + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class); + } + public function lineItems(): HasMany { return $this->hasMany(EstimateLineItem::class); @@ -100,16 +107,61 @@ class Estimate extends Model return $this->hasMany(Estimate::class, 'original_estimate_id'); } - public function calculateTotals(): void + public function workOrders(): HasMany { - $this->labor_cost = $this->lineItems()->where('type', 'labor')->sum('total_amount'); - $this->parts_cost = $this->lineItems()->where('type', 'parts')->sum('total_amount'); - $this->miscellaneous_cost = $this->lineItems()->where('type', 'miscellaneous')->sum('total_amount'); - - $this->subtotal = $this->labor_cost + $this->parts_cost + $this->miscellaneous_cost - $this->discount_amount; - $this->tax_amount = $this->subtotal * ($this->tax_rate / 100); - $this->total_amount = $this->subtotal + $this->tax_amount; - - $this->save(); + return $this->hasMany(WorkOrder::class); + } + + public function getValidUntilAttribute() + { + return $this->created_at->addDays($this->validity_period_days ?? 30); + } + + public function getIsExpiredAttribute() + { + return now()->isAfter($this->valid_until); + } + + public function getFormattedValidUntilAttribute() + { + return $this->valid_until->format('M d, Y'); + } + + public function scopeExpired($query) + { + return $query->whereDate('created_at', '<', now()->subDays(30)); + } + + public function scopeValid($query) + { + return $query->whereDate('created_at', '>=', now()->subDays(30)); + } + + public function scopeByStatus($query, $status) + { + return $query->where('status', $status); + } + + public function scopeByBranch($query, $branchId) + { + return $query->whereHas('jobCard', function ($q) use ($branchId) { + $q->where('branch_id', $branchId); + }); + } + + /** + * Get the customer for this estimate (either direct or through job card) + */ + public function getEstimateCustomerAttribute() + { + return $this->customer_id ? $this->customer : $this->jobCard?->customer; + } + + /** + * Get the vehicle for this estimate (either direct or through job card) + */ + public function getEstimateVehicleAttribute() + { + return $this->vehicle_id ? $this->vehicle : $this->jobCard?->vehicle; } } diff --git a/app/Models/Estimate.php.backup b/app/Models/Estimate.php.backup new file mode 100644 index 0000000..b6c9632 --- /dev/null +++ b/app/Models/Estimate.php.backup @@ -0,0 +1,158 @@ + 'decimal:2', + 'parts_cost' => 'decimal:2', + 'miscellaneous_cost' => 'decimal:2', + 'subtotal' => 'decimal:2', + 'tax_rate' => 'decimal:4', + 'tax_amount' => 'decimal:2', + 'discount_amount' => 'decimal:2', + 'total_amount' => 'decimal:2', + 'customer_approved_at' => 'datetime', + 'sent_to_customer_at' => 'datetime', + 'sms_sent_at' => 'datetime', + 'email_sent_at' => 'datetime', + ]; + + protected static function boot() + { + parent::boot(); + + static::creating(function ($estimate) { + if (empty($estimate->estimate_number)) { + $estimate->estimate_number = 'EST-'.date('Y').'-'.str_pad( + static::whereYear('created_at', now()->year)->count() + 1, + 6, + '0', + STR_PAD_LEFT + ); + } + }); + } + + public function jobCard(): BelongsTo + { + return $this->belongsTo(JobCard::class); + } + + public function diagnosis(): BelongsTo + { + return $this->belongsTo(Diagnosis::class); + } + + public function preparedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'prepared_by_id'); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class); + } + + public function lineItems(): HasMany + { + return $this->hasMany(EstimateLineItem::class); + } + + public function originalEstimate(): BelongsTo + { + return $this->belongsTo(Estimate::class, 'original_estimate_id'); + } + + public function revisions(): HasMany + { + return $this->hasMany(Estimate::class, 'original_estimate_id'); + } + + public function workOrders(): HasMany + { + return $this->hasMany(WorkOrder::class); + } + + public function getValidUntilAttribute() + { + return $this->created_at->addDays($this->validity_period_days); + } + + public function getIsExpiredAttribute() + { + return $this->valid_until < now() && $this->status !== 'approved'; + } + + public function calculateTotals(): void + { + $this->labor_cost = $this->lineItems()->where('type', 'labor')->sum('total_amount'); + $this->parts_cost = $this->lineItems()->where('type', 'parts')->sum('total_amount'); + $this->miscellaneous_cost = $this->lineItems()->where('type', 'miscellaneous')->sum('total_amount'); + + $this->subtotal = $this->labor_cost + $this->parts_cost + $this->miscellaneous_cost - $this->discount_amount; + $this->tax_amount = $this->subtotal * ($this->tax_rate / 100); + $this->total_amount = $this->subtotal + $this->tax_amount; + + $this->save(); + } + + /** + * Get the customer for this estimate (either direct or through job card) + */ + public function getEstimateCustomerAttribute() + { + return $this->customer_id ? $this->customer : $this->jobCard?->customer; + } + + /** + * Get the vehicle for this estimate (either direct or through job card) + */ + public function getEstimateVehicleAttribute() + { + return $this->vehicle_id ? $this->vehicle : $this->jobCard?->vehicle; + } +} diff --git a/app/Models/Estimate.php.broken b/app/Models/Estimate.php.broken new file mode 100644 index 0000000..02ffed2 --- /dev/null +++ b/app/Models/Estimate.php.broken @@ -0,0 +1,159 @@ + 'decimal:2', + 'parts_cost' => 'decimal:2', + 'miscellaneous_cost' => 'decimal:2', + 'subtotal' => 'decimal:2', + 'tax_rate' => 'decimal:4', + 'tax_amount' => 'decimal:2', + 'discount_amount' => 'decimal:2', + 'total_amount' => 'decimal:2', + 'customer_approved_at' => 'datetime', + 'sent_to_customer_at' => 'datetime', + 'sms_sent_at' => 'datetime', + 'email_sent_at' => 'datetime', + ]; + + protected static function boot() + { + parent::boot(); + + static::creating(function ($estimate) { + if (empty($estimate->estimate_number)) { + $estimate->estimate_number = 'EST-'.date('Y').'-'.str_pad( + static::whereYear('created_at', now()->year)->count() + 1, + 6, + '0', + STR_PAD_LEFT + ); + } + }); + } + + public function jobCard(): BelongsTo + { + return $this->belongsTo(JobCard::class); + } + + public function diagnosis(): BelongsTo + { + return $this->belongsTo(Diagnosis::class); + } + + public function preparedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'prepared_by_id'); + } + + // Core relationships for standalone estimates + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class); + } + + public function lineItems(): HasMany + { + return $this->hasMany(EstimateLineItem::class); + } + + public function originalEstimate(): BelongsTo + { + return $this->belongsTo(Estimate::class, 'original_estimate_id'); + } + + public function revisions(): HasMany + { + return $this->hasMany(Estimate::class, 'original_estimate_id'); + } + + public function workOrders(): HasMany + { + return $this->hasMany(WorkOrder::class); + } + + public function getValidUntilAttribute() + { + return $this->created_at->addDays($this->validity_period_days); + } + + public function getIsExpiredAttribute() + { + return $this->valid_until < now() && $this->status !== 'approved'; + } + + public function calculateTotals(): void + { + $this->labor_cost = $this->lineItems()->where('type', 'labor')->sum('total_amount'); + $this->parts_cost = $this->lineItems()->where('type', 'parts')->sum('total_amount'); + $this->miscellaneous_cost = $this->lineItems()->where('type', 'miscellaneous')->sum('total_amount'); + + $this->subtotal = $this->labor_cost + $this->parts_cost + $this->miscellaneous_cost - $this->discount_amount; + $this->tax_amount = $this->subtotal * ($this->tax_rate / 100); + $this->total_amount = $this->subtotal + $this->tax_amount; + + $this->save(); + } + + /** + * Get the customer for this estimate (either direct or through job card) + */ + public function getEstimateCustomerAttribute() + { + return $this->customer_id ? $this->customer : $this->jobCard?->customer; + } + + /** + * Get the vehicle for this estimate (either direct or through job card) + */ + public function getEstimateVehicleAttribute() + { + return $this->vehicle_id ? $this->vehicle : $this->jobCard?->vehicle; + } +} diff --git a/app/Models/JobCard.php b/app/Models/JobCard.php index e6f9fc8..4064129 100644 --- a/app/Models/JobCard.php +++ b/app/Models/JobCard.php @@ -26,6 +26,11 @@ class JobCard extends Model 'status', 'priority', 'service_advisor_id', + 'assigned_technician_id', + 'assignment_notes', + 'assigned_at', + 'diagnosis_started_at', + 'diagnosis_completed_at', 'notes', 'customer_reported_issues', 'vehicle_condition_notes', @@ -45,15 +50,25 @@ class JobCard extends Model // Enhanced status constants following the 11-step workflow public const STATUS_RECEIVED = 'received'; + public const STATUS_INSPECTED = 'inspected'; + public const STATUS_ASSIGNED_FOR_DIAGNOSIS = 'assigned_for_diagnosis'; + public const STATUS_IN_DIAGNOSIS = 'in_diagnosis'; + public const STATUS_ESTIMATE_SENT = 'estimate_sent'; + public const STATUS_APPROVED = 'approved'; + public const STATUS_PARTS_PROCUREMENT = 'parts_procurement'; + public const STATUS_IN_PROGRESS = 'in_progress'; + public const STATUS_QUALITY_REVIEW_REQUIRED = 'quality_review_required'; + public const STATUS_COMPLETED = 'completed'; + public const STATUS_DELIVERED = 'delivered'; public static function getStatusOptions(): array @@ -77,12 +92,12 @@ class JobCard extends Model 'arrival_datetime' => 'datetime', 'expected_completion_date' => 'datetime', 'completion_datetime' => 'datetime', + 'assigned_at' => 'datetime', + 'diagnosis_started_at' => 'datetime', + 'diagnosis_completed_at' => 'datetime', 'archived_at' => 'datetime', 'personal_items_removed' => 'boolean', 'photos_taken' => 'boolean', - 'mileage_in' => 'integer', - 'mileage_out' => 'integer', - 'customer_satisfaction_rating' => 'integer', 'incoming_inspection_data' => 'array', 'outgoing_inspection_data' => 'array', ]; @@ -90,15 +105,15 @@ class JobCard extends Model protected static function boot() { parent::boot(); - + static::creating(function ($jobCard) { if (empty($jobCard->job_card_number)) { $branchCode = $jobCard->branch_code ?? config('app.default_branch_code', 'ACC'); $nextNumber = static::where('branch_code', $branchCode) ->whereYear('created_at', now()->year) ->count() + 1; - - $jobCard->job_card_number = $branchCode . '/' . str_pad($nextNumber, 5, '0', STR_PAD_LEFT); + + $jobCard->job_card_number = $branchCode.'/'.str_pad($nextNumber, 5, '0', STR_PAD_LEFT); } }); } @@ -118,6 +133,16 @@ class JobCard extends Model return $this->belongsTo(User::class, 'service_advisor_id'); } + public function assignedTechnician(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_technician_id'); + } + + public function branch(): BelongsTo + { + return $this->belongsTo(Branch::class, 'branch_code', 'code'); + } + public function incomingInspection(): HasOne { return $this->hasOne(VehicleInspection::class)->incoming(); diff --git a/app/Models/VehicleInspection.php b/app/Models/VehicleInspection.php index a36a9a8..ebb009c 100644 --- a/app/Models/VehicleInspection.php +++ b/app/Models/VehicleInspection.php @@ -31,8 +31,10 @@ class VehicleInspection extends Model 'signature_inspector', 'signature_customer', 'notes', + 'additional_comments', 'follow_up_required', 'quality_rating', + 'damage_diagram_data', ]; protected $casts = [ @@ -41,6 +43,7 @@ class VehicleInspection extends Model 'videos' => 'array', 'recommendations' => 'array', 'discrepancies_found' => 'array', + 'damage_diagram_data' => 'array', 'inspection_date' => 'datetime', 'follow_up_required' => 'boolean', ]; @@ -78,16 +81,16 @@ class VehicleInspection extends Model public function compareWithOtherInspection(VehicleInspection $otherInspection): array { $differences = []; - + if ($this->overall_condition !== $otherInspection->overall_condition) { $differences['overall_condition'] = [ 'before' => $otherInspection->overall_condition, - 'after' => $this->overall_condition + 'after' => $this->overall_condition, ]; } - + // Add more comparison logic as needed - + return $differences; } } diff --git a/app/Policies/EstimatePolicy.php b/app/Policies/EstimatePolicy.php new file mode 100644 index 0000000..bceb3dc --- /dev/null +++ b/app/Policies/EstimatePolicy.php @@ -0,0 +1,77 @@ +hasRole('super_admin')) { + return true; + } + + // Service coordinators, supervisors, and admins can view all estimates in their branch + return $user->hasAnyRole(['service_coordinator', 'service_supervisor', 'admin'], $user->branch_code); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, Estimate $estimate): bool + { + return false; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + // Super admin has global access + if ($user->hasRole('super_admin')) { + return true; + } + + // Service coordinators, supervisors, and admins can create estimates in their branch + return $user->hasAnyRole(['service_coordinator', 'service_supervisor', 'admin'], $user->branch_code); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Estimate $estimate): bool + { + return false; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Estimate $estimate): bool + { + return false; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Estimate $estimate): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Estimate $estimate): bool + { + return false; + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index b8e031a..9430089 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -2,14 +2,13 @@ namespace App\Providers; +use App\Models\Estimate; +use App\Models\JobCard; +use App\Models\User; +use App\Policies\EstimatePolicy; +use App\Policies\JobCardPolicy; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Gate; -use App\Models\JobCard; -use App\Models\Customer; -use App\Models\Vehicle; -use App\Models\ServiceOrder; -use App\Models\User; -use App\Policies\JobCardPolicy; class AuthServiceProvider extends ServiceProvider { @@ -20,6 +19,7 @@ class AuthServiceProvider extends ServiceProvider */ protected $policies = [ JobCard::class => JobCardPolicy::class, + Estimate::class => EstimatePolicy::class, ]; /** @@ -44,7 +44,7 @@ class AuthServiceProvider extends ServiceProvider return $user->hasAnyPermission([ 'reports.view', 'reports.financial', - 'reports.operational' + 'reports.operational', ], $user->branch_code); }); @@ -54,7 +54,7 @@ class AuthServiceProvider extends ServiceProvider 'inventory.update', 'inventory.delete', 'inventory.stock-movements', - 'inventory.purchase-orders' + 'inventory.purchase-orders', ], $user->branch_code); }); @@ -62,7 +62,7 @@ class AuthServiceProvider extends ServiceProvider return $user->hasAnyRole([ 'service_supervisor', 'service_coordinator', - 'manager' + 'manager', ], $user->branch_code); }); diff --git a/app/Services/WorkflowService.php b/app/Services/WorkflowService.php index 16428d2..65cd0ae 100644 --- a/app/Services/WorkflowService.php +++ b/app/Services/WorkflowService.php @@ -2,12 +2,11 @@ namespace App\Services; -use App\Models\JobCard; -use App\Models\Estimate; -use App\Models\WorkOrder; use App\Models\Diagnosis; +use App\Models\Estimate; +use App\Models\JobCard; use App\Models\VehicleInspection; -use App\Models\User; +use App\Models\WorkOrder; use Illuminate\Support\Facades\DB; class WorkflowService @@ -24,18 +23,18 @@ class WorkflowService public function createJobCard(array $data): JobCard { return DB::transaction(function () use ($data) { - $jobCard = JobCard::create([ - 'customer_id' => $data['customer_id'], - 'vehicle_id' => $data['vehicle_id'], - 'service_advisor_id' => $data['service_advisor_id'], - 'branch_code' => $data['branch_code'] ?? config('app.default_branch_code', 'ACC'), - 'arrival_datetime' => $data['arrival_datetime'] ?? now(), - 'mileage_in' => $data['mileage_in'] ?? null, - 'fuel_level_in' => $data['fuel_level_in'] ?? null, - 'customer_reported_issues' => $data['customer_reported_issues'] ?? '', - 'vehicle_condition_notes' => $data['vehicle_condition_notes'] ?? '', - 'keys_location' => $data['keys_location'] ?? 'service_desk', - 'personal_items_removed' => $data['personal_items_removed'] ?? false, + $jobCard = JobCard::create([ + 'customer_id' => $data['customer_id'], + 'vehicle_id' => $data['vehicle_id'], + 'service_advisor_id' => $data['service_advisor_id'], + 'branch_code' => $data['branch_code'] ?? config('app.default_branch_code', 'ACC'), + 'arrival_datetime' => $data['arrival_datetime'] ?? now(), + 'mileage_in' => $data['mileage_in'] ?? null, + 'fuel_level_in' => $data['fuel_level_in'] ?? null, + 'customer_reported_issues' => $data['customer_reported_issues'] ?? '', + 'vehicle_condition_notes' => $data['vehicle_condition_notes'] ?? '', + 'keys_location' => $data['keys_location'] ?? 'service_desk', + 'personal_items_removed' => $data['personal_items_removed'] ?? false, 'photos_taken' => $data['photos_taken'] ?? false, 'expected_completion_date' => $data['expected_completion_date'] ?? null, 'priority' => $data['priority'] ?? 'medium', @@ -52,7 +51,7 @@ class WorkflowService }); } - /** + /** * STEP 2: Initial Inspection by Service Supervisor * Perform arrival inspection checklist */ @@ -66,6 +65,9 @@ class WorkflowService 'incoming_inspection_data' => $inspectionData['inspection_checklist'], ]); + // Send notification that inspection is complete and ready for diagnosis assignment + $this->notificationService->sendInspectionCompletedNotification($jobCard); + return $jobCard->fresh(); } @@ -75,9 +77,14 @@ class WorkflowService */ public function assignToServiceCoordinator(JobCard $jobCard, int $serviceCoordinatorId): Diagnosis { - // Validate workflow progression + // Validate workflow progression - must complete initial inspection first if ($jobCard->status !== JobCard::STATUS_INSPECTED) { - throw new \InvalidArgumentException('Job card must be inspected before assignment to service coordinator'); + throw new \InvalidArgumentException('Initial vehicle inspection must be completed before assignment to service coordinator'); + } + + // Ensure incoming inspection exists + if (! $jobCard->incomingInspection) { + throw new \InvalidArgumentException('Incoming inspection record is required before proceeding to diagnosis'); } $diagnosis = Diagnosis::create([ @@ -152,7 +159,7 @@ class WorkflowService 'total_amount' => $estimateItems['total_amount'], 'status' => 'sent', 'notes' => $estimateItems['notes'] ?? null, - 'valid_until' => $estimateItems['valid_until'] ?? now()->addDays(30), + 'validity_period_days' => $estimateItems['validity_period_days'] ?? 30, ]); // Create estimate line items @@ -170,7 +177,7 @@ class WorkflowService }); } - /** + /** * STEP 6: Handle estimate approval and notify team */ public function approveEstimate(Estimate $estimate, string $approvalMethod = 'portal'): WorkOrder @@ -201,12 +208,12 @@ class WorkflowService public function initiatePartsProcurement(Estimate $estimate): array { $procurementStatus = []; - + foreach ($estimate->lineItems()->where('type', 'part')->get() as $item) { // Check inventory availability $part = Part::find($item->part_id); - - if (!$part || $part->current_stock < $item->quantity) { + + if (! $part || $part->current_stock < $item->quantity) { // Create purchase order if parts are out of stock $procurementStatus[] = [ 'part_id' => $item->part_id, @@ -215,19 +222,19 @@ class WorkflowService 'available_stock' => $part->current_stock ?? 0, 'shortage' => $item->quantity - ($part->current_stock ?? 0), 'status' => 'procurement_required', - 'action' => 'create_purchase_order' + 'action' => 'create_purchase_order', ]; } else { // Reserve parts from inventory $part->decrement('current_stock', $item->quantity); $part->increment('reserved_stock', $item->quantity); - + $procurementStatus[] = [ 'part_id' => $item->part_id, 'part_name' => $item->description, 'required_quantity' => $item->quantity, 'status' => 'reserved', - 'action' => 'reserved_from_stock' + 'action' => 'reserved_from_stock', ]; } } @@ -288,20 +295,21 @@ class WorkflowService $incomingInspection = $jobCard->incomingInspection; if ($incomingInspection) { $differences = $this->inspectionService->compareInspections($incomingInspection, $outgoingInspection); - - if (!empty($differences)) { + + if (! empty($differences)) { // Generate quality alert for significant discrepancies $qualityAlert = $this->inspectionService->generateQualityAlert($jobCard, $differences); - - if (!empty($qualityAlert)) { + + if (! empty($qualityAlert)) { $this->notificationService->sendQualityAlert($jobCard, $differences); - + $outgoingInspection->update([ 'discrepancies_found' => $differences, 'follow_up_required' => true, ]); - + $jobCard->update(['status' => 'quality_review_required']); + return; } } @@ -370,7 +378,7 @@ class WorkflowService // This would typically move documents to long-term storage // For now, we'll just mark them as archived $jobCard->update(['archived_at' => now()]); - + // Update related records $jobCard->inspections()->update(['archived' => true]); $jobCard->timesheets()->update(['archived' => true]); diff --git a/composer.json b/composer.json index c459055..3baf30a 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "require-dev": { "barryvdh/laravel-debugbar": "^3.16", "fakerphp/faker": "^1.23", + "laravel/boost": "^1.0", "laravel/pail": "^1.2.2", "laravel/pint": "^1.18", "laravel/sail": "^1.41", diff --git a/composer.lock b/composer.lock index 2e06dd8..8f4d543 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6522b02012f8730cbe38cf382d5a5a45", + "content-hash": "86ce39c32f1ba24deda1b62e36de786b", "packages": [ { "name": "brick/math", @@ -6764,6 +6764,135 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "laravel/boost", + "version": "v1.0.14", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "89dcf8a8cac9dcdfc9c452115e215c9fdfa3efcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/89dcf8a8cac9dcdfc9c452115e215c9fdfa3efcf", + "reference": "89dcf8a8cac9dcdfc9c452115e215c9fdfa3efcf", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "laravel/mcp": "^0.1.0", + "laravel/prompts": "^0.1.9|^0.3", + "laravel/roster": "^0.2", + "php": "^8.1|^8.2" + }, + "require-dev": { + "laravel/pint": "^1.14|^1.23", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2025-08-14T13:42:14+00:00" + }, + { + "name": "laravel/mcp", + "version": "v0.1.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "417890c0d8032af9a46a86d16651bbe13946cddf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/417890c0d8032af9a46a86d16651bbe13946cddf", + "reference": "417890c0d8032af9a46a86d16651bbe13946cddf", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/http": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/validation": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Workbench\\App\\": "workbench/app/", + "Laravel\\Mcp\\Tests\\": "tests/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The easiest way to add MCP servers to your Laravel app.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "dev", + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2025-08-12T07:09:39+00:00" + }, { "name": "laravel/pail", "version": "v1.2.3", @@ -6912,6 +7041,67 @@ }, "time": "2025-07-10T18:09:32+00:00" }, + { + "name": "laravel/roster", + "version": "v0.2.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/roster.git", + "reference": "caeed7609b02c00c3f1efec52812d8d87c5d4096" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/roster/zipball/caeed7609b02c00c3f1efec52812d8d87c5d4096", + "reference": "caeed7609b02c00c3f1efec52812d8d87c5d4096", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2", + "symfony/yaml": "^6.4|^7.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2025-08-13T15:00:25+00:00" + }, { "name": "laravel/sail", "version": "v1.43.1", diff --git a/database/factories/DiagnosisFactory.php b/database/factories/DiagnosisFactory.php new file mode 100644 index 0000000..8f02cf9 --- /dev/null +++ b/database/factories/DiagnosisFactory.php @@ -0,0 +1,28 @@ + + */ +class DiagnosisFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'job_card_id' => JobCard::factory(), + 'service_coordinator_id' => User::factory(), + 'diagnostic_findings' => fake()->paragraph(), + 'diagnosis_status' => fake()->randomElement(['in_progress', 'completed', 'pending_approval', 'approved']), + ]; + } +} diff --git a/database/factories/EstimateFactory.php b/database/factories/EstimateFactory.php new file mode 100644 index 0000000..a768e53 --- /dev/null +++ b/database/factories/EstimateFactory.php @@ -0,0 +1,41 @@ + + */ +class EstimateFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'estimate_number' => 'MAIN/EST'.str_pad(fake()->unique()->numberBetween(1, 9999), 4, '0', STR_PAD_LEFT), + 'job_card_id' => JobCard::factory(), + 'diagnosis_id' => Diagnosis::factory(), + 'prepared_by_id' => User::factory(), + 'labor_cost' => fake()->randomFloat(2, 100, 1000), + 'parts_cost' => fake()->randomFloat(2, 50, 500), + 'miscellaneous_cost' => fake()->randomFloat(2, 0, 100), + 'subtotal' => fake()->randomFloat(2, 200, 1500), + 'tax_rate' => 8.25, + 'tax_amount' => fake()->randomFloat(2, 10, 100), + 'discount_amount' => fake()->randomFloat(2, 0, 50), + 'total_amount' => fake()->randomFloat(2, 210, 1600), + 'terms_and_conditions' => fake()->paragraph(), + 'validity_period_days' => fake()->numberBetween(7, 90), + 'notes' => fake()->optional()->paragraph(), + 'internal_notes' => fake()->optional()->paragraph(), + ]; + } +} diff --git a/database/migrations/2025_08_11_144450_add_additional_comments_to_vehicle_inspections_table.php b/database/migrations/2025_08_11_144450_add_additional_comments_to_vehicle_inspections_table.php new file mode 100644 index 0000000..a8c56e8 --- /dev/null +++ b/database/migrations/2025_08_11_144450_add_additional_comments_to_vehicle_inspections_table.php @@ -0,0 +1,28 @@ +text('additional_comments')->nullable()->after('notes'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('vehicle_inspections', function (Blueprint $table) { + $table->dropColumn('additional_comments'); + }); + } +}; diff --git a/database/migrations/2025_08_11_150724_add_comprehensive_fields_to_vehicle_inspections_table.php b/database/migrations/2025_08_11_150724_add_comprehensive_fields_to_vehicle_inspections_table.php new file mode 100644 index 0000000..10a42a1 --- /dev/null +++ b/database/migrations/2025_08_11_150724_add_comprehensive_fields_to_vehicle_inspections_table.php @@ -0,0 +1,29 @@ +json('damage_diagram_data')->nullable()->after('quality_rating'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('vehicle_inspections', function (Blueprint $table) { + $table->dropColumn('damage_diagram_data'); + }); + } +}; diff --git a/database/migrations/2025_08_11_164630_make_service_order_id_nullable_in_vehicle_inspections_table.php b/database/migrations/2025_08_11_164630_make_service_order_id_nullable_in_vehicle_inspections_table.php new file mode 100644 index 0000000..0256a93 --- /dev/null +++ b/database/migrations/2025_08_11_164630_make_service_order_id_nullable_in_vehicle_inspections_table.php @@ -0,0 +1,28 @@ +unsignedBigInteger('service_order_id')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('vehicle_inspections', function (Blueprint $table) { + $table->unsignedBigInteger('service_order_id')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2025_08_14_112135_update_job_cards_status_enum_with_all_workflow_statuses.php b/database/migrations/2025_08_14_112135_update_job_cards_status_enum_with_all_workflow_statuses.php new file mode 100644 index 0000000..a9f2aa7 --- /dev/null +++ b/database/migrations/2025_08_14_112135_update_job_cards_status_enum_with_all_workflow_statuses.php @@ -0,0 +1,31 @@ +string('status')->default('received')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('job_cards', function (Blueprint $table) { + $table->string('status')->default('received')->change(); + }); + } +}; diff --git a/database/migrations/2025_08_14_114725_add_diagnosis_workflow_columns_to_job_cards_table.php b/database/migrations/2025_08_14_114725_add_diagnosis_workflow_columns_to_job_cards_table.php new file mode 100644 index 0000000..2512825 --- /dev/null +++ b/database/migrations/2025_08_14_114725_add_diagnosis_workflow_columns_to_job_cards_table.php @@ -0,0 +1,39 @@ +foreignId('assigned_technician_id')->nullable()->after('service_advisor_id')->constrained('users')->onDelete('set null'); + $table->text('assignment_notes')->nullable()->after('notes'); + $table->timestamp('assigned_at')->nullable()->after('assignment_notes'); + $table->timestamp('diagnosis_started_at')->nullable()->after('assigned_at'); + $table->timestamp('diagnosis_completed_at')->nullable()->after('diagnosis_started_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('job_cards', function (Blueprint $table) { + $table->dropForeign(['assigned_technician_id']); + $table->dropColumn([ + 'assigned_technician_id', + 'assignment_notes', + 'assigned_at', + 'diagnosis_started_at', + 'diagnosis_completed_at', + ]); + }); + } +}; diff --git a/database/migrations/2025_08_14_174546_add_customer_and_vehicle_to_estimates_table.php b/database/migrations/2025_08_14_174546_add_customer_and_vehicle_to_estimates_table.php new file mode 100644 index 0000000..b073eca --- /dev/null +++ b/database/migrations/2025_08_14_174546_add_customer_and_vehicle_to_estimates_table.php @@ -0,0 +1,40 @@ +foreignId('customer_id')->nullable()->constrained()->onDelete('cascade'); + $table->foreignId('vehicle_id')->nullable()->constrained()->onDelete('cascade'); + + // Make job_card_id and diagnosis_id nullable for standalone estimates + $table->foreignId('job_card_id')->nullable()->change(); + $table->foreignId('diagnosis_id')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('estimates', function (Blueprint $table) { + $table->dropForeign(['customer_id']); + $table->dropForeign(['vehicle_id']); + $table->dropColumn(['customer_id', 'vehicle_id']); + + // Restore job_card_id and diagnosis_id as required + $table->foreignId('job_card_id')->nullable(false)->change(); + $table->foreignId('diagnosis_id')->nullable(false)->change(); + }); + } +}; diff --git a/public/images/vehicle-diagram.svg b/public/images/vehicle-diagram.svg new file mode 100644 index 0000000..fb806ce --- /dev/null +++ b/public/images/vehicle-diagram.svg @@ -0,0 +1,72 @@ + + + + + + + + + Left Side + + + + + + + + + + Top View + + + + + + + + + + Right Side + + + + + + + + + Front View + + + + + + + + + Rear View + + + + diff --git a/resources/views/components/layouts/print.blade.php b/resources/views/components/layouts/print.blade.php new file mode 100644 index 0000000..a98d4ad --- /dev/null +++ b/resources/views/components/layouts/print.blade.php @@ -0,0 +1,211 @@ + + + + + + Vehicle Inspection Report + + + + + + + + + diff --git a/resources/views/livewire/diagnosis/index.blade.php b/resources/views/livewire/diagnosis/index.blade.php index ad58cc8..3825dc5 100644 --- a/resources/views/livewire/diagnosis/index.blade.php +++ b/resources/views/livewire/diagnosis/index.blade.php @@ -1,3 +1,196 @@ -
- {{-- Stop trying to control. --}} +
+ +
+
+
+

Diagnoses

+

+ Manage vehicle diagnostic records and analysis +

+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+

+ Diagnosis Records + ({{ $diagnoses->total() }} total) +

+
+ + @if($diagnoses->count() > 0) +
+ + + + + + + + + + + + + + + @foreach($diagnoses as $diagnosis) + + + + + + + + + + + @endforeach + +
Job CardCustomerVehicleStatusPriorityTechnicianDateActions
+
+ {{ $diagnosis->jobCard->job_card_number }} +
+
+
{{ $diagnosis->jobCard->customer->name }}
+
{{ $diagnosis->jobCard->customer->phone }}
+
+
+ {{ $diagnosis->jobCard->vehicle->year }} {{ $diagnosis->jobCard->vehicle->make }} {{ $diagnosis->jobCard->vehicle->model }} +
+
{{ $diagnosis->jobCard->vehicle->license_plate }}
+
+ + {{ str_replace('_', ' ', $diagnosis->diagnosis_status) }} + + + + {{ ucfirst($diagnosis->priority_level) }} + + + {{ $diagnosis->serviceCoordinator->name }} + + {{ $diagnosis->diagnosis_date?->format('M j, Y') ?? $diagnosis->created_at->format('M j, Y') }} + + + View + + + Edit + + @if(!$diagnosis->estimate) + + Estimate + + @endif +
+
+ + +
+ {{ $diagnoses->links() }} +
+ @else +
+ + + +

No diagnoses found

+

+ @if($search || $statusFilter || $priorityFilter || $dateFrom) + No diagnoses match your current filters. + @else + Get started by creating a diagnosis from a job card. + @endif +

+ @if($search || $statusFilter || $priorityFilter || $dateFrom) + + @endif +
+ @endif +
diff --git a/resources/views/livewire/diagnosis/show.blade.php b/resources/views/livewire/diagnosis/show.blade.php index a573dbb..a098d2d 100644 --- a/resources/views/livewire/diagnosis/show.blade.php +++ b/resources/views/livewire/diagnosis/show.blade.php @@ -1,3 +1,210 @@ -
- {{-- Success is as dangerous as failure. --}} +
+ +
+
+
+

Diagnosis Details

+

+ Diagnostic analysis for Job Card #{{ $diagnosis->jobCard->job_card_number }} +

+
+ +
+
+ + +
+
+

Vehicle Information

+
+
+
+
+ +

{{ $diagnosis->jobCard->customer->name }}

+

{{ $diagnosis->jobCard->customer->phone }}

+
+
+ +

{{ $diagnosis->jobCard->vehicle->year }} {{ $diagnosis->jobCard->vehicle->make }} {{ $diagnosis->jobCard->vehicle->model }}

+

{{ $diagnosis->jobCard->vehicle->license_plate }}

+
+
+ +

{{ $diagnosis->serviceCoordinator->name }}

+
+
+ +

{{ $diagnosis->diagnosis_date?->format('M j, Y g:i A') ?? 'Not set' }}

+
+
+
+
+ + +
+
+
+
+ + + +
+

Status

+

{{ str_replace('_', ' ', $diagnosis->diagnosis_status) }}

+
+
+ +
+
+
+ + + +
+

Priority

+

{{ $diagnosis->priority_level }}

+
+
+ +
+
+
+ + + +
+

Estimated Time

+

{{ $diagnosis->estimated_repair_time }} hours

+
+
+
+ + +
+
+

Diagnostic Analysis

+
+
+ @if($diagnosis->customer_reported_issues) +
+

Customer Reported Issues

+

{{ $diagnosis->customer_reported_issues }}

+
+ @endif + +
+

Diagnostic Findings

+

{{ $diagnosis->diagnostic_findings }}

+
+ +
+

Root Cause Analysis

+

{{ $diagnosis->root_cause_analysis }}

+
+ +
+

Recommended Repairs

+

{{ $diagnosis->recommended_repairs }}

+
+ + @if($diagnosis->additional_issues_found) +
+

Additional Issues Found

+

{{ $diagnosis->additional_issues_found }}

+
+ @endif + + @if($diagnosis->safety_concerns) +
+

Safety Concerns

+

{{ $diagnosis->safety_concerns }}

+
+ @endif +
+
+ + + @if($diagnosis->parts_required || $diagnosis->labor_operations) +
+ @if($diagnosis->parts_required) +
+
+

Parts Required

+
+
+
+ @foreach($diagnosis->parts_required as $part) +
+
+
+

{{ $part['name'] ?? 'Part Name' }}

+ @if(isset($part['part_number'])) +

Part #: {{ $part['part_number'] }}

+ @endif +
+ @if(isset($part['quantity'])) + Qty: {{ $part['quantity'] }} + @endif +
+
+ @endforeach +
+
+
+ @endif + + @if($diagnosis->labor_operations) +
+
+

Labor Operations

+
+
+
+ @foreach($diagnosis->labor_operations as $operation) +
+
+
+

{{ $operation['operation'] ?? 'Operation' }}

+ @if(isset($operation['description'])) +

{{ $operation['description'] }}

+ @endif +
+ @if(isset($operation['time'])) + {{ $operation['time'] }}h + @endif +
+
+ @endforeach +
+
+
+ @endif +
+ @endif + + @if($diagnosis->notes) + +
+
+

Additional Notes

+
+
+

{{ $diagnosis->notes }}

+
+
+ @endif
diff --git a/resources/views/livewire/estimates/create-standalone.blade.php b/resources/views/livewire/estimates/create-standalone.blade.php new file mode 100644 index 0000000..4f37d22 --- /dev/null +++ b/resources/views/livewire/estimates/create-standalone.blade.php @@ -0,0 +1,340 @@ +
+ +
+
+
+

Create New Estimate

+

Create a standalone estimate for parts sales or services

+
+ +
+
+ + +
+

Customer & Vehicle Information

+ +
+
+ + Customer + + + @foreach($customers as $customer) + + @endforeach + + + + + @if($selectedCustomer) +
+
+

{{ $selectedCustomer->name }}

+

{{ $selectedCustomer->email }}

+

{{ $selectedCustomer->phone }}

+
+
+ @endif +
+ +
+ + Vehicle + + + @foreach($customerVehicles as $vehicle) + + @endforeach + + + + + @if($selectedVehicle) +
+
+

{{ $selectedVehicle->year }} {{ $selectedVehicle->make }} {{ $selectedVehicle->model }}

+

License Plate: {{ $selectedVehicle->license_plate }}

+

VIN: {{ $selectedVehicle->vin }}

+
+
+ @endif +
+
+
+ + +
+
+

Line Items

+ +
+ +
+ @foreach($lineItems as $index => $item) +
+
+ + Type + + + + + + +
+ +
+ @if($item['type'] === 'parts') + + Parts Search +
+ @if(!empty($item['part_id'])) + +
+
+
+ {{ $item['part_number'] }} - {{ $item['description'] }} +
+ @if(isset($item['stock_available'])) +
+ Stock: {{ $item['stock_available'] }} available +
+ @endif +
+ +
+ @else + + + + @if($showPartsDropdown && count($availableParts) > 0) +
+ @foreach($availableParts as $part) + + @endforeach +
+ @elseif($showPartsDropdown && strlen($partSearch) >= 2) +
+
+ No parts found matching "{{ $partSearch }}" +
+
+ @endif + @endif +
+
+ @else + + Description + + + @endif +
+ +
+ + Quantity + + @if($item['type'] === 'parts' && isset($item['stock_available'])) +
+ @if($item['stock_available'] <= 5) + ⚠️ Low stock: {{ $item['stock_available'] }} available + @else + ✓ {{ $item['stock_available'] }} available + @endif +
+ @endif +
+
+ +
+ + Unit Price + + +
+ +
+
+ ${{ number_format($item['subtotal'] ?? 0, 2) }} +
+
+ +
+ @if(count($lineItems) > 1) + + @endif +
+
+ + @error("lineItems.{$index}.type") +

{{ $message }}

+ @enderror + @error("lineItems.{$index}.description") +

{{ $message }}

+ @enderror + @error("lineItems.{$index}.quantity") +

{{ $message }}

+ @enderror + @error("lineItems.{$index}.unit_price") +

{{ $message }}

+ @enderror + @endforeach +
+
+ + +
+ +
+

Estimate Details

+ +
+
+ + Validity Period (Days) + + + +
+ +
+ + Tax Rate (%) + + + +
+ +
+ + Discount Amount ($) + + + +
+ +
+ + Customer Notes + + +
+ +
+ + Internal Notes + + +
+
+
+ + +
+

Totals

+ +
+
+ Subtotal: + ${{ number_format($subtotal, 2) }} +
+ + @if($discount_amount > 0) +
+ Discount: + -${{ number_format($discount_amount, 2) }} +
+ @endif + +
+ Tax ({{ $tax_rate }}%): + ${{ number_format($tax_amount, 2) }} +
+ +
+
+ Total: + ${{ number_format($total_amount, 2) }} +
+
+
+
+
+ + +
+

Terms and Conditions

+ + + + +
+ + +
+ + Cancel + + +
+
diff --git a/resources/views/livewire/estimates/create.blade.php b/resources/views/livewire/estimates/create.blade.php index 15e5660..bc1c5d9 100644 --- a/resources/views/livewire/estimates/create.blade.php +++ b/resources/views/livewire/estimates/create.blade.php @@ -1,3 +1,217 @@ -
- {{-- The whole world belongs to you. --}} +
+ +
+
+

Create Estimate

+

+ Create a detailed estimate for {{ $diagnosis->jobCard->customer->name }} - + {{ $diagnosis->jobCard->vehicle->year }} {{ $diagnosis->jobCard->vehicle->make }} {{ $diagnosis->jobCard->vehicle->model }} +

+
+
+ + Cancel + + + Save Estimate + +
+
+ + +
+

Job Information

+
+
+ + Job Card Number + + +
+
+ + Customer + + +
+
+ + Vehicle + + +
+
+
+ + +
+

Estimate Settings

+
+
+ + Validity Period (Days) + + + +
+
+ + Tax Rate (%) + + + +
+
+ + Discount Amount ($) + + + +
+
+
+ + +
+
+
+

Line Items

+ + + + + Add Item + +
+
+ +
+ + + + + + + + + + + + + @forelse($lineItems as $index => $item) + + + + + + + + + @empty + + + + @endforelse + +
TypeDescriptionQtyUnit PriceTotalActions
+ + + + + + + + + + + + + + ${{ number_format($item['total_amount'] ?? 0, 2) }} + + + @if(!($item['required'] ?? false)) + + + + + + @endif +
+ No line items added yet. Click "Add Item" to get started. +
+
+
+ + +
+

Estimate Totals

+
+
+ + + Terms and Conditions + + + +
+
+ +
+
+ Subtotal: + ${{ number_format($subtotal, 2) }} +
+
+ Discount: + -${{ number_format($discount_amount, 2) }} +
+
+ Tax ({{ number_format($tax_rate, 2) }}%): + ${{ number_format($tax_amount, 2) }} +
+
+
+ Total: + ${{ number_format($total_amount, 2) }} +
+
+
+
+
+
+ + +
+

Notes

+
+
+ + Customer Notes + + + +
+
+ + Internal Notes + + + +
+
+
+ + +
+ + Cancel + + + + + + Create Estimate + +
diff --git a/resources/views/livewire/estimates/edit.blade.php b/resources/views/livewire/estimates/edit.blade.php index 7a4f210..40e8506 100644 --- a/resources/views/livewire/estimates/edit.blade.php +++ b/resources/views/livewire/estimates/edit.blade.php @@ -1,3 +1,410 @@ -
- {{-- Do your work, then step back. --}} +
+ +
+
+
+

Edit Estimate

+

+ {{ $estimate->estimate_number }} • + {{ $estimate->customer_id ? $estimate->customer?->name : $estimate->jobCard?->customer?->name ?? 'Unknown Customer' }} • + @if($estimate->customer_id) + {{ $estimate->vehicle?->year }} {{ $estimate->vehicle?->make }} {{ $estimate->vehicle?->model }} + @else + {{ $estimate->jobCard?->vehicle?->year }} {{ $estimate->jobCard?->vehicle?->make }} {{ $estimate->jobCard?->vehicle?->model }} + @endif +

+ @if($lastSaved) +

+ + + + + Auto-saved at {{ $lastSaved }} + +

+ @endif +
+
+ + +
+
+
+ + +
+

Quick Add Service Items

+
+ @foreach($quickAddPresets as $key => $preset) + + @endforeach +
+
+ + +
+ +
+ +
+
+
+

Line Items

+
+ @if(!$bulkOperationMode) + + @else +
+ + +
+ @endif +
+
+
+ + +
+ @forelse($lineItems as $index => $item) +
+ @if($bulkOperationMode) +
+
+ +
+
+ @endif + + @if($item['is_editing']) + +
+
+ + + + + +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ + @if($showAdvancedOptions) +
+
+ +
+
+ + + + + +
+
+ +
+
+ +
+
+ @endif + @else + +
+
+
+ + {{ ucfirst($item['type']) }} + + {{ $item['description'] }} + @if($item['part_name']) + ({{ $item['part_name'] }}) + @endif +
+
+ Qty: {{ $item['quantity'] }} × ${{ number_format($item['unit_price'], 2) }} + @if($item['markup_percentage'] > 0) + + {{ $item['markup_percentage'] }}% markup + @endif + @if($item['discount_type'] !== 'none') + + - {{ $item['discount_type'] === 'percentage' ? $item['discount_value'].'%' : '$'.number_format($item['discount_value'], 2) }} discount + + @endif +
+ @if($item['notes']) +
{{ $item['notes'] }}
+ @endif +
+
+
+
${{ number_format($item['total_amount'], 2) }}
+ @if(!$item['is_taxable']) +
Tax exempt
+ @endif +
+ @if(!$bulkOperationMode) +
+ + + +
+ @endif +
+
+ @endif + + @if($bulkOperationMode) +
+
+ @endif +
+ @empty +
+ + + +

No line items added yet. Add service items above to get started.

+
+ @endforelse +
+ + +
+

Add New Line Item

+
+
+ + + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + @if($showAdvancedOptions) +
+
+ +
+
+ + + + + +
+
+ +
+
+ +
+
+
+ +
+ @endif +
+
+
+ + +
+ +
+

Financial Summary

+
+
+ Subtotal: + ${{ number_format($subtotal, 2) }} +
+ +
+ Discount: + -${{ number_format($discount_amount, 2) }} +
+ +
+ Tax ({{ $tax_rate }}%): + ${{ number_format($tax_amount, 2) }} +
+ +
+
+ Total: + ${{ number_format($total_amount, 2) }} +
+
+
+
+ + +
+

Estimate Settings

+
+
+ + Tax Rate (%) + + +
+ +
+ + Discount Amount ($) + + +
+ +
+ + Valid for (days) + + +
+
+
+ + +
+

Notes

+
+
+ + Customer Notes + + +
+ +
+ + Internal Notes + + +
+
+
+ + +
+

Terms & Conditions

+ + + +
+ + +
+ + + + + + + Cancel + +
+
+
diff --git a/resources/views/livewire/estimates/index.blade.php b/resources/views/livewire/estimates/index.blade.php index bd03f67..297ae21 100644 --- a/resources/views/livewire/estimates/index.blade.php +++ b/resources/views/livewire/estimates/index.blade.php @@ -1,134 +1,574 @@ -
-
- -
+
+ +
+
-

Estimates

-

Manage service estimates and quotes

+

Estimates

+

Manage service estimates, quotes, and customer approvals

+
+
+ + + + + New Estimate + + @if($availableDiagnoses->count() > 0) +
+ + +
+ @endif + +
+
+
+ + +
+
+
+
+ + + +
+
+

Total

+

{{ number_format($stats['total']) }}

+
- -
-
-
- - +
+
+
+ + +
-
- - -
-
- - -
+ + +
+
+ + Customer + + + @foreach($customers as $customer) + + @endforeach + +
- -
- @if($estimates->count() > 0) -
- - - - - - - - - - - - - - - @foreach($estimates as $estimate) - - - - - - - - - + + @endforelse + +
Estimate #CustomerVehicleTotal AmountStatusApprovalCreatedActions
- {{ $estimate->estimate_number }} - - {{ $estimate->jobCard->customer->first_name }} {{ $estimate->jobCard->customer->last_name }} - - {{ $estimate->jobCard->vehicle->year }} {{ $estimate->jobCard->vehicle->make }} {{ $estimate->jobCard->vehicle->model }} - - ${{ number_format($estimate->total_amount, 2) }} - - - {{ ucfirst($estimate->status) }} - - - - {{ ucfirst($estimate->customer_approval_status) }} - - - {{ $estimate->created_at->format('M j, Y') }} - - + + + @if($bulkMode && !empty($selectedEstimates)) +
+
+
+ {{ count($selectedEstimates) }} estimate(s) selected +
+
+ + + +
+
+
+ @endif + + +
+
+ + + + @if($bulkMode) + + @endif + + + + + + + + + + + + + @forelse($estimates as $estimate) + + @if($bulkMode) + + @endif + + + + + + + + + + + @empty + + - - @endforeach - -
+ + +
+ Estimate # + @if($sortBy === 'estimate_number') + + @if($sortDirection === 'asc') + + @else + + @endif + + @endif +
+
+
+ Date + @if($sortBy === 'created_at') + + @if($sortDirection === 'asc') + + @else + + @endif + + @endif +
+
CustomerVehicleStatusApproval +
+ Total + @if($sortBy === 'total_amount') + + @if($sortDirection === 'asc') + + @else + + @endif + + @endif +
+
Valid UntilActions
+ + +
+
{{ $estimate->estimate_number }}
+
+
+
{{ $estimate->created_at->format('M j, Y') }}
+
{{ $estimate->created_at->format('g:i A') }}
+
+
+
+
+ {{ $estimate->customer_id ? $estimate->customer?->name : $estimate->jobCard?->customer?->name ?? 'Unknown Customer' }} +
+
+ {{ $estimate->customer_id ? $estimate->customer?->email : $estimate->jobCard?->customer?->email ?? 'No email' }} +
+
+
+
+
+ @if($estimate->vehicle_id) + {{ $estimate->vehicle?->year }} {{ $estimate->vehicle?->make }} {{ $estimate->vehicle?->model }} + @else + {{ $estimate->jobCard?->vehicle?->year }} {{ $estimate->jobCard?->vehicle?->make }} {{ $estimate->jobCard?->vehicle?->model }} + @endif +
+
+ {{ $estimate->vehicle_id ? $estimate->vehicle?->license_plate : $estimate->jobCard?->vehicle?->license_plate }} +
+
+ @php + $statusColors = [ + 'draft' => 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', + 'sent' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300', + 'viewed' => 'bg-purple-100 text-purple-800 dark:bg-purple-900/50 dark:text-purple-300', + 'approved' => 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300', + 'rejected' => 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300', + 'expired' => 'bg-accent/20 text-accent dark:bg-accent/30 dark:text-accent-foreground', + ]; + @endphp + + {{ ucfirst($estimate->status) }} + + + @php + $approvalColors = [ + 'pending' => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300', + 'approved' => 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300', + 'rejected' => 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300', + ]; + @endphp + + {{ ucfirst(str_replace('_', ' ', $estimate->customer_approval_status)) }} + + +
+ ${{ number_format($estimate->total_amount, 2) }} +
+
+ @if($estimate->validity_period_days) + @php + $validUntil = $estimate->created_at->addDays($estimate->validity_period_days); + $isExpired = $validUntil->isPast(); + $isExpiringSoon = $validUntil->diffInDays(now()) <= 7 && !$isExpired; + @endphp +
+ {{ $validUntil->format('M j, Y') }} +
+ @if($isExpired) +
Expired
+ @elseif($isExpiringSoon) +
Expires soon
+ @endif + @else + No expiry + @endif +
+
+ + + + + + + @can('update', $estimate) + + + + + + @endcan + @if($estimate->status === 'draft') + + @endif + @can('delete', $estimate) + + @endcan +
+
+
+ + + +
+

No estimates found

+

Get started by creating your first estimate

+
+ @if($availableDiagnoses->count() > 0) +
+ + +
+ @else +
+

No diagnoses available for estimate creation.

+ + + + + Go to Job Cards
-
-
- - -
- {{ $estimates->links() }} -
- @else -
- - - -

No estimates found

-

- @if($search || $statusFilter || $approvalStatusFilter) - Try adjusting your search criteria. - @else - Estimates will appear here once job cards have diagnoses. - @endif -

-
- @endif + @endif +
+
+ + + @if($estimates->hasPages()) +
+ {{ $estimates->links() }} +
+ @endif
diff --git a/resources/views/livewire/estimates/show-advanced.blade.php b/resources/views/livewire/estimates/show-advanced.blade.php new file mode 100644 index 0000000..9ac3b08 --- /dev/null +++ b/resources/views/livewire/estimates/show-advanced.blade.php @@ -0,0 +1,384 @@ +
+ +
+
+
+
+ + + +
+
+

+ Estimate #{{ $estimate->estimate_number }} +

+

+ Job Card #{{ $estimate->jobCard->job_card_number }} • {{ $estimate->created_at->format('M j, Y') }} +

+
+ + {{ ucfirst($estimate->status) }} + + + Customer: {{ ucfirst($estimate->customer_approval_status) }} + + @if($estimate->validity_period_days) + + Valid until {{ $estimate->created_at->addDays($estimate->validity_period_days)->format('M j, Y') }} + + @endif +
+
+
+ + +
+ @if($estimate->status === 'draft') + + @endif + +
+ + +
+
+ + + + + Edit Estimate + + + + @if($estimate->status === 'approved') + + + + + Create Work Order + + @endif +
+
+
+ + + + + + View Job Card + +
+
+
+ + +
+ +
+ +
+
+

+ + + + Customer & Vehicle Details +

+
+
+
+
+
+
+ + + +
+
+

{{ $estimate->jobCard->customer->name }}

+

{{ $estimate->jobCard->customer->phone }}

+

{{ $estimate->jobCard->customer->email }}

+
+
+
+
+
+
+ + + +
+
+

+ {{ $estimate->jobCard->vehicle->year }} {{ $estimate->jobCard->vehicle->make }} {{ $estimate->jobCard->vehicle->model }} +

+

{{ $estimate->jobCard->vehicle->license_plate }}

+

VIN: {{ $estimate->jobCard->vehicle->vin }}

+
+
+
+
+
+
+ + +
+
+
+

+ + + + Service Items & Parts + + {{ $estimate->lineItems->count() }} items + +

+ +
+
+
+
+ + + + + + + + + + + @foreach($estimate->lineItems as $item) + + + + + + + @endforeach + +
DescriptionQtyUnit PriceTotal
+
+ + {{ ucfirst($item->type) }} + +
+

{{ $item->description }}

+ @if($showItemDetails && $item->labor_hours) +

+ Labor: {{ $item->labor_hours }}h @ ${{ number_format($item->labor_rate, 2) }}/hr +

+ @endif + @if($showItemDetails && $item->part_number) +

+ Part #: {{ $item->part_number }} +

+ @endif +
+
+
+ + {{ $item->quantity }} + + + + ${{ number_format($item->unit_price, 2) }} + + + + ${{ number_format($item->total_amount, 2) }} + +
+
+
+
+ + + @if($estimate->terms_and_conditions) +
+
+

+ + + + Terms & Conditions +

+
+
+

{{ $estimate->terms_and_conditions }}

+
+
+ @endif +
+ + +
+ +
+
+

+ + + + Financial Summary +

+
+
+ Labor Cost + ${{ number_format($estimate->labor_cost, 2) }} +
+
+ Parts Cost + ${{ number_format($estimate->parts_cost, 2) }} +
+ @if($estimate->miscellaneous_cost > 0) +
+ Miscellaneous + ${{ number_format($estimate->miscellaneous_cost, 2) }} +
+ @endif +
+ Subtotal + ${{ number_format($estimate->subtotal, 2) }} +
+ @if($estimate->discount_amount > 0) +
+ Discount + -${{ number_format($estimate->discount_amount, 2) }} +
+ @endif +
+ Tax ({{ $estimate->tax_rate }}%) + ${{ number_format($estimate->tax_amount, 2) }} +
+
+ Total + ${{ number_format($estimate->total_amount, 2) }} +
+
+
+
+ + +
+
+

+ + + + Status Timeline +

+
+
+
+
+
+
+

Created

+

{{ $estimate->created_at->format('M j, Y g:i A') }}

+
+
+ @if($estimate->sent_at) +
+
+
+

Sent to Customer

+

{{ $estimate->sent_at->format('M j, Y g:i A') }}

+
+
+ @endif + @if($estimate->customer_viewed_at) +
+
+
+

Viewed by Customer

+

{{ $estimate->customer_viewed_at->format('M j, Y g:i A') }}

+
+
+ @endif + @if($estimate->customer_responded_at) +
+
+
+

Customer {{ ucfirst($estimate->customer_approval_status) }}

+

{{ $estimate->customer_responded_at->format('M j, Y g:i A') }}

+
+
+ @endif +
+
+
+ + +
+
+

+ + + + Related Documents +

+
+ +
+
+
+
diff --git a/resources/views/livewire/estimates/show.blade.php b/resources/views/livewire/estimates/show.blade.php index ad58cc8..3edd523 100644 --- a/resources/views/livewire/estimates/show.blade.php +++ b/resources/views/livewire/estimates/show.blade.php @@ -1,3 +1,421 @@ -
- {{-- Stop trying to control. --}} +
+ +
+
+
+
+ + + +
+
+

+ Estimate #{{ $estimate->estimate_number }} +

+

+ @if($estimate->jobCard) + Job Card #{{ $estimate->jobCard->job_card_number }} • {{ $estimate->created_at->format('M j, Y') }} + @else + Standalone Estimate • {{ $estimate->created_at->format('M j, Y') }} + @endif +

+
+ + {{ ucfirst($estimate->status) }} + + + Customer: {{ ucfirst($estimate->customer_approval_status) }} + + @if($estimate->validity_period_days) + + Valid until {{ $estimate->valid_until->format('M j, Y') }} + + @endif +
+
+
+ + +
+ @if($estimate->status === 'draft') + + @endif + +
+ + +
+
+ + + + + Edit Estimate + + + + @if($estimate->status === 'approved') + + + + + Create Work Order + + @endif +
+
+
+ + @if($estimate->jobCard) + + + + + View Job Card + + @else + + + + + Back to Estimates + + @endif +
+
+
+ + +
+ +
+ +
+
+

+ + + + Customer & Vehicle Details +

+
+
+
+
+
+
+ + + +
+
+ @if($estimate->customer_id) + {{-- Standalone estimate --}} +

{{ $estimate->customer->name }}

+

{{ $estimate->customer->phone }}

+

{{ $estimate->customer->email }}

+ @elseif($estimate->jobCard?->customer) + {{-- Job card-based estimate --}} +

{{ $estimate->jobCard->customer->name }}

+

{{ $estimate->jobCard->customer->phone }}

+

{{ $estimate->jobCard->customer->email }}

+ @else +

Unknown Customer

+

No contact information

+ @endif +
+
+
+
+
+
+ + + +
+
+ @if($estimate->vehicle_id) + {{-- Standalone estimate --}} +

+ {{ $estimate->vehicle->year }} {{ $estimate->vehicle->make }} {{ $estimate->vehicle->model }} +

+

{{ $estimate->vehicle->license_plate }}

+

VIN: {{ $estimate->vehicle->vin }}

+ @elseif($estimate->jobCard?->vehicle) + {{-- Job card-based estimate --}} +

+ {{ $estimate->jobCard->vehicle->year }} {{ $estimate->jobCard->vehicle->make }} {{ $estimate->jobCard->vehicle->model }} +

+

{{ $estimate->jobCard->vehicle->license_plate }}

+

VIN: {{ $estimate->jobCard->vehicle->vin }}

+ @else +

Unknown Vehicle

+

No vehicle information

+ @endif +
+
+
+
+
+
+ + +
+
+
+

+ + + + Service Items & Parts + + {{ $estimate->lineItems->count() }} items + +

+ +
+
+
+
+ + + + + + + + + + + @foreach($estimate->lineItems as $item) + + + + + + + @endforeach + +
DescriptionQtyUnit PriceTotal
+
+ + {{ ucfirst($item->type) }} + +
+

{{ $item->description }}

+ @if($showItemDetails && $item->labor_hours) +

+ Labor: {{ $item->labor_hours }}h @ ${{ number_format($item->labor_rate, 2) }}/hr +

+ @endif + @if($showItemDetails && $item->part_number) +

+ Part #: {{ $item->part_number }} +

+ @endif +
+
+
+ + {{ $item->quantity }} + + + + ${{ number_format($item->unit_price, 2) }} + + + + ${{ number_format($item->total_amount, 2) }} + +
+
+
+
+ + + @if($estimate->terms_and_conditions) +
+
+

+ + + + Terms & Conditions +

+
+
+

{{ $estimate->terms_and_conditions }}

+
+
+ @endif +
+ + +
+ +
+
+

+ + + + Financial Summary +

+
+
+ Labor Cost + ${{ number_format($estimate->labor_cost, 2) }} +
+
+ Parts Cost + ${{ number_format($estimate->parts_cost, 2) }} +
+ @if($estimate->miscellaneous_cost > 0) +
+ Miscellaneous + ${{ number_format($estimate->miscellaneous_cost, 2) }} +
+ @endif +
+ Subtotal + ${{ number_format($estimate->subtotal, 2) }} +
+ @if($estimate->discount_amount > 0) +
+ Discount + -${{ number_format($estimate->discount_amount, 2) }} +
+ @endif +
+ Tax ({{ $estimate->tax_rate }}%) + ${{ number_format($estimate->tax_amount, 2) }} +
+
+ Total + ${{ number_format($estimate->total_amount, 2) }} +
+
+
+
+ + +
+
+

+ + + + Status Timeline +

+
+
+
+
+
+
+

Created

+

{{ $estimate->created_at->format('M j, Y g:i A') }}

+
+
+ @if($estimate->sent_at) +
+
+
+

Sent to Customer

+

{{ $estimate->sent_at->format('M j, Y g:i A') }}

+
+
+ @endif + @if($estimate->customer_viewed_at) +
+
+
+

Viewed by Customer

+

{{ $estimate->customer_viewed_at->format('M j, Y g:i A') }}

+
+
+ @endif + @if($estimate->customer_responded_at) +
+
+
+

Customer {{ ucfirst($estimate->customer_approval_status) }}

+

{{ $estimate->customer_responded_at->format('M j, Y g:i A') }}

+
+
+ @endif +
+
+
+ + +
+
+

+ + + + Related Documents +

+
+ +
+
+
diff --git a/resources/views/livewire/inspections/create.blade.php b/resources/views/livewire/inspections/create.blade.php index db86de0..cb07a6c 100644 --- a/resources/views/livewire/inspections/create.blade.php +++ b/resources/views/livewire/inspections/create.blade.php @@ -1,3 +1,750 @@
- {{-- Close your eyes. Count to one. That is how long forever feels. --}} +
+ +
+
+ {{ ucfirst($type) }} Vehicle Inspection + Job Card: {{ $jobCard->job_card_number }} - {{ $jobCard->customer->name ?? 'Unknown Customer' }} +
+ + + Back to Job Card + +
+ + +
+
+
+ +
+
+ ✓ +
+ Vehicle Reception +
+ +
+ + +
+
+ 2 +
+ {{ ucfirst($type) }} Inspection +
+ +
+ + +
+
+ 3 +
+ Diagnosis +
+
+
+
+ + +
+ + + + + Vehicle Information + +
+
+ Customer: +
{{ $jobCard->customer->name ?? 'Unknown Customer' }}
+ @if($jobCard->customer->phone) +
{{ $jobCard->customer->phone }}
+ @endif +
+
+ Vehicle: +
{{ $jobCard->vehicle->year ?? '' }} {{ $jobCard->vehicle->make ?? '' }} {{ $jobCard->vehicle->model ?? '' }}
+
+
+ License Plate: +
{{ $jobCard->vehicle->license_plate ?? '' }}
+
+
+ VIN: +
{{ $jobCard->vehicle->vin ?? 'N/A' }}
+
+
+ + @if($jobCard->customer_reported_issues) +
+
Customer Reported Issues:
+

{{ $jobCard->customer_reported_issues }}

+
+ @endif +
+ + +
+ +
+ Basic Information + +
+
+ @if($jobCard->mileage_in) + +

+ + + + Pulled from Job Card +

+ @else + + @endif + @error('current_mileage') + {{ $message }} + @enderror +
+ +
+ @if($jobCard->fuel_level_in) +
+ + +

+ + + + Pulled from Job Card +

+
+ @else + + + + + + + + + + @endif + @error('fuel_level') + {{ $message }} + @enderror +
+ +
+ + + + + + + + @error('overall_condition') + {{ $message }} + @enderror +
+
+
+ + +
+ Vehicle Inspection Checklist + + @error('checklist') +
+
+ + + +
+

Checklist Incomplete

+

{{ $message }}

+
+
+
+ @enderror + +
+ +
+ +
+

+ + + + Documentation +

+
+ @foreach([ + 'manufacturers_handbook' => "Manufacturer's handbook", + 'service_record_book' => 'Service record book (up-to-date)', + 'company_drivers_handbook' => "Company driver's handbook", + 'accident_report_form' => 'Accident report form', + 'safety_inspection_sticker' => 'Copy of annual safety inspection sticker or form' + ] as $key => $label) +
+ {{ $label }} +
+ + +
+
+ @endforeach +
+
+ + +
+

+ + + + Vehicle Interior +

+
+ @foreach([ + 'heating' => 'Heating', + 'air_conditioning' => 'Air Conditioning', + 'windshield_defrosting_system' => 'Windshield defrosting system', + 'window_operation' => 'Window operation', + 'door_handles_locks' => 'Door handles / locks', + 'alarm' => 'Alarm', + 'signals' => 'Signals', + 'seat_belts_work' => 'Seat belts work and free of damage / excessive wear', + 'interior_lights' => 'Interior Lights', + 'mirrors_good_position' => 'Mirrors are in good position and properly adjusted', + 'warning_lights' => 'No warning lights are on', + 'fuel_levels' => 'Fuel levels', + 'oil_level_sufficient' => 'Oil level is sufficiently high', + 'washer_fluids_sufficient' => 'Washer fluids levels are sufficiently high', + 'radiator_fluid_sufficient' => 'Radiator fluid levels are sufficient', + 'emergency_roadside_supplies' => 'Emergency roadside supplies are properly stocked and located in the trunk of vehicle' + ] as $key => $label) +
+ {{ $label }} +
+ + +
+
+ @endforeach +
+
+ + +
+

+ + + + Engine Compartment +

+
+ @foreach([ + 'engine_oil_level' => 'Engine oil level', + 'coolant_level_antifreeze' => 'Coolant level (anti-freeze)', + 'battery_secured' => 'Battery secured', + 'brake_fluid_level' => 'Brake fluid level', + 'air_filter_clean' => 'Air filter is clean', + 'belts_hoses_good' => 'Belts and hoses are in good condition - no cracks, full with adequate tension' + ] as $key => $label) +
+ {{ $label }} +
+ + +
+
+ @endforeach +
+
+
+ + +
+ +
+

+ + + + Vehicle Exterior +

+
+ @foreach([ + 'windshield_not_cracked' => 'Windows/windshield not severely cracked', + 'windshield_wipers_functional' => 'Functional Windshield wipers', + 'headlights_high_low' => 'Headlights (high/low beam)', + 'tail_lights_brake_lights' => 'Tail lights / brake lights', + 'emergency_brake_working' => 'Emergency brake in good working order', + 'power_brakes_working' => 'Power brakes are in good working order', + 'horn_works' => 'Horn works', + 'tires_good_shape' => 'Tires in good shape (no damages and adequately inflated)', + 'no_air_leaks' => 'No air leaks (walk around the vehicle and listen for air leaks while driver applies the brakes)', + 'no_oil_grease_leaks' => 'No oil / grease leaks (oil wheel seats or under the vehicle)', + 'no_fuel_leaks' => 'No fuel leaks or odour of gasoline detected', + 'mirrors_good_position' => 'Mirrors are in good position and properly adjusted', + 'exhaust_system_working' => 'Exhaust system is in good working order', + 'wheels_fasteners_tight' => 'Wheels and fasteners are fitted tightly', + 'turn_signals' => 'Turn signals', + 'vehicle_free_damage' => 'Vehicle is free of excessive damage', + 'loads_fastened' => 'All loads are fastened / secured', + 'spare_tire_good' => 'Spare tire is in good condition', + 'vehicle_condition_satisfactory' => 'Vehicle condition is satisfactory', + 'defects_recorded' => 'Defects recorded' + ] as $key => $label) +
+ {{ $label }} +
+ + +
+
+ @endforeach +
+
+ + +
+

+ + + + Vehicle Damage Diagram +

+
+

Click on any area of the vehicle to mark damage, dents, or scratches

+ + +
+
+ + + + + + + + + + Left Side + + + + + + + + + + + + Top View + + + + + + + + + + Right Side + + + + + + + + + Front View + + + + + + + + + Rear View + + + + + +
+
+ + +
+
+
+ Damage +
+
+
+ Dent +
+
+
+ Scratch +
+
+
+ Other +
+
+ + +
+
Marked Damage:
+
+
+
+
+
+
+ + +
+

Additional Comments

+ +
+ + +
+

Inspection Performed By

+
+
+ + +
+
+ + +
+
+
+ +
+ By completing this inspection, {{ auth()->user()->name }} confirms the accuracy of all recorded information. +
+
+
+
+ + +
+ Additional Information + +
+
+ + @error('damage_notes') + {{ $message }} + @enderror +
+ +
+ + @error('recommendations') + {{ $message }} + @enderror +
+
+ +
+ + @error('notes') + {{ $message }} + @enderror +
+ +
+
+ + + + + + + + +
+ +
+ + +
+
+
+ + +
+ + Cancel + + + Complete {{ ucfirst($type) }} Inspection + Processing... + +
+
+
+ + +
diff --git a/resources/views/livewire/inspections/print.blade.php b/resources/views/livewire/inspections/print.blade.php new file mode 100644 index 0000000..edece16 --- /dev/null +++ b/resources/views/livewire/inspections/print.blade.php @@ -0,0 +1,328 @@ +
+ + + + @php + $checklist = $inspection->inspection_checklist ?? []; + $totalItems = 0; + $passedItems = 0; + $failedItems = 0; + + foreach($checklist as $section => $items) { + if(is_array($items)) { + foreach($items as $key => $value) { + $totalItems++; + $lowerValue = strtolower($value ?? ''); + if(in_array($lowerValue, ['pass', 'good', 'excellent', 'satisfactory', 'yes'])) { + $passedItems++; + } elseif(in_array($lowerValue, ['fail', 'poor', 'unsatisfactory', 'no', 'damaged'])) { + $failedItems++; + } + } + } + } + + $passRate = $totalItems > 0 ? round(($passedItems / $totalItems) * 100) : 0; + @endphp + + @if($totalItems > 0) +
+

Inspection Summary

+
+
+ {{ $totalItems }}
+ Total Items +
+
+ {{ $passedItems }}
+ Passed +
+
+ {{ $failedItems }}
+ Failed +
+
+ {{ $passRate }}%
+ Pass Rate +
+
+
+ @endif + +
+ +
+

Job Card Information

+
+ Job Card No: + {{ $jobCard->job_card_number }} +
+
+ Date Created: + {{ $jobCard->created_at->format('d M Y') }} +
+
+ Status: + {{ ucwords(str_replace('_', ' ', $jobCard->status)) }} +
+
+ Branch: + {{ $jobCard->branch->name ?? 'N/A' }} +
+
+ + +
+

Customer Information

+
+ Name: + {{ $jobCard->customer->name }} +
+
+ Email: + {{ $jobCard->customer->email }} +
+
+ Phone: + {{ $jobCard->customer->phone }} +
+
+ Address: + {{ $jobCard->customer->address ?: 'N/A' }} +
+
+ + +
+

Vehicle Information

+
+ Make & Model: + {{ $jobCard->vehicle->make }} {{ $jobCard->vehicle->model }} +
+
+ Year: + {{ $jobCard->vehicle->year }} +
+
+ License Plate: + {{ $jobCard->vehicle->license_plate }} +
+
+ VIN: + {{ $jobCard->vehicle->vin ?: 'N/A' }} +
+
+
+ + +
+
+

Inspection Details

+
+ Type: + {{ ucwords(str_replace('_', ' ', $inspection->inspection_type)) }} +
+
+ Date: + {{ $inspection->created_at->format('d M Y H:i') }} +
+
+ Inspector: + {{ $inspection->inspector->name ?? 'N/A' }} +
+
+ Current Mileage: + {{ number_format($inspection->current_mileage) }} km +
+
+ +
+

Vehicle Condition

+
+ Fuel Level: + {{ $inspection->fuel_level }}% +
+
+ Overall Condition: + {{ ucfirst($inspection->overall_condition ?? 'Good') }} +
+
+ Cleanliness: + {{ ucfirst($inspection->cleanliness_rating ?? 'Clean') }} +
+ @if($inspection->quality_rating) +
+ Quality Rating: + {{ $inspection->quality_rating }}/10 +
+ @endif +
+
+ + +
+

+ Inspection Checklist +

+ + @php + $checklist = $inspection->inspection_checklist ?? []; + @endphp + +
+ + @if(isset($checklist['documentation'])) +
+

📋 Documentation

+ @foreach($checklist['documentation'] as $key => $value) +
+ {{ ucwords(str_replace('_', ' ', $key)) }} + {{ strtoupper($value ?: 'N/A') }} +
+ @endforeach +
+ @endif + + + @if(isset($checklist['exterior'])) +
+

🚗 Exterior

+ @foreach($checklist['exterior'] as $key => $value) +
+ {{ ucwords(str_replace('_', ' ', $key)) }} + {{ strtoupper($value ?: 'N/A') }} +
+ @endforeach +
+ @endif + + + @if(isset($checklist['interior'])) +
+

🏠 Interior

+ @foreach($checklist['interior'] as $key => $value) +
+ {{ ucwords(str_replace('_', ' ', $key)) }} + {{ strtoupper($value ?: 'N/A') }} +
+ @endforeach +
+ @endif + + + @if(isset($checklist['engine'])) +
+

� Engine & Mechanical

+ @foreach($checklist['engine'] as $key => $value) +
+ {{ ucwords(str_replace('_', ' ', $key)) }} + {{ strtoupper($value ?: 'N/A') }} +
+ @endforeach +
+ @endif + + + @if(isset($checklist['under_hood'])) +
+

⚡ Under Hood

+ @foreach($checklist['under_hood'] as $key => $value) +
+ {{ ucwords(str_replace('_', ' ', $key)) }} + {{ strtoupper($value ?: 'N/A') }} +
+ @endforeach +
+ @endif + + + @if(isset($checklist['under_vehicle'])) +
+

🔍 Under Vehicle

+ @foreach($checklist['under_vehicle'] as $key => $value) +
+ {{ ucwords(str_replace('_', ' ', $key)) }} + {{ strtoupper($value ?: 'N/A') }} +
+ @endforeach +
+ @endif +
+
+ + + @if($inspection->damage_diagram_data) +
+

+ Vehicle Damage Report +

+ + @php + $damageData = is_string($inspection->damage_diagram_data) + ? json_decode($inspection->damage_diagram_data, true) + : $inspection->damage_diagram_data; + @endphp + + @if($damageData && is_array($damageData) && count($damageData) > 0) +
+ @foreach($damageData as $index => $damage) +
+ Damage {{ $index + 1 }}
+ + Position: {{ $damage['x'] ?? 'N/A' }}, {{ $damage['y'] ?? 'N/A' }} +
+ + Type: {{ $damage['type'] ?? 'Unknown' }} + +
+ @endforeach +
+ @else +

No damage recorded during inspection.

+ @endif +
+ @endif + + +
+
+
+
+ Inspector Signature
+ {{ $inspection->inspector->name ?? 'N/A' }}
+ Date: {{ $inspection->created_at->format('d/m/Y') }} +
+
+
+ Customer Signature
+ {{ $jobCard->customer->name }}
+ Date: ________________ +
+
+
+ Service Manager
+ Name: ________________
+ Date: ________________ +
+
+
+ + +
+

This report was generated on {{ now()->format('d M Y \a\t H:i') }}

+

{{ app(\App\Settings\GeneralSettings::class)->shop_name ?? 'Car Repairs Shop' }} - Professional Vehicle Inspection Services

+

Report ID: {{ $inspection->id }} | Job Card: {{ $jobCard->job_card_number }}

+
+
diff --git a/resources/views/livewire/inspections/show.blade.php b/resources/views/livewire/inspections/show.blade.php index ad9a90f..b2b241e 100644 --- a/resources/views/livewire/inspections/show.blade.php +++ b/resources/views/livewire/inspections/show.blade.php @@ -1,3 +1,310 @@ -
- {{-- To attain knowledge, add things every day; To attain wisdom, subtract things every day. --}} +
+ + + + + + + +
+
+
+ Inspection Details +

+ {{ ucfirst($inspection->inspection_type) }} inspection for {{ $inspection->jobCard->vehicle->year }} {{ $inspection->jobCard->vehicle->make }} {{ $inspection->jobCard->vehicle->model }} +

+
+
+ + Back to Job Card + + + Print Report + +
+
+
+ + +
+
+
{{ ucfirst($inspection->overall_condition) }}
+
Overall Condition
+
+
+
{{ number_format($inspection->current_mileage) }}
+
Kilometers
+
+
+
{{ $inspection->cleanliness_rating }}/10
+
Cleanliness Rating
+
+
+
{{ ucwords(str_replace('_', ' ', $inspection->fuel_level)) }}
+
Fuel Level
+
+
+ + +
+ +
+ Vehicle Information + +
+
+ Vehicle + {{ $inspection->jobCard->vehicle->year }} {{ $inspection->jobCard->vehicle->make }} {{ $inspection->jobCard->vehicle->model }} +
+
+ License Plate + {{ $inspection->jobCard->vehicle->license_plate }} +
+
+ VIN + {{ $inspection->jobCard->vehicle->vin }} +
+
+ Color + {{ $inspection->jobCard->vehicle->color ?? 'Not specified' }} +
+
+
+ + +
+ Customer Information + +
+
+ Name + {{ $inspection->jobCard->customer->first_name }} {{ $inspection->jobCard->customer->last_name }} +
+
+ Phone + {{ $inspection->jobCard->customer->phone ?? 'Not provided' }} +
+
+ Email + {{ $inspection->jobCard->customer->email ?? 'Not provided' }} +
+
+ Customer ID + #{{ $inspection->jobCard->customer->id }} +
+
+
+ + +
+ Inspection Information + +
+
+ Type + {{ ucfirst($inspection->inspection_type) }} +
+
+ Date & Time + {{ $inspection->inspection_date->format('M d, Y \a\t g:i A') }} +
+
+ Inspector + {{ $inspection->inspector->name }} +
+
+ Job Card + #{{ $inspection->jobCard->job_card_number }} +
+
+
+
+ + + @if($inspection->inspection_checklist) +
+ Inspection Checklist + +
+ @foreach($inspection->inspection_checklist as $section => $items) +
+

+ {{ str_replace('_', ' ', $section) }} +

+
+ @foreach($items as $item => $status) +
+ {{ ucwords(str_replace('_', ' ', $item)) }} +
+ @if($status === 'yes') +
+
+ Pass +
+ @elseif($status === 'no') +
+
+ Fail +
+ @else +
+
+ N/A +
+ @endif +
+
+ @endforeach +
+
+ @endforeach +
+
+ @endif + + + @if($inspection->additional_comments || $inspection->damage_notes || $inspection->recommendations || $inspection->notes) +
+ @if($inspection->additional_comments || $inspection->notes) +
+ Comments & Notes + +
+ @if($inspection->additional_comments) +
+
+ + + + Additional Comments +
+
+

{{ $inspection->additional_comments }}

+
+
+ @endif + + @if($inspection->notes) +
+
+ + + + Inspector Notes +
+
+

{{ $inspection->notes }}

+
+
+ @endif +
+
+ @endif + + @if($inspection->damage_notes || $inspection->recommendations) +
+ Damage & Recommendations + +
+ @if($inspection->damage_notes) +
+
+ + + + Damage Notes +
+
+

{{ $inspection->damage_notes }}

+
+
+ @endif + + @if($inspection->recommendations) +
+
+ + + + Recommendations +
+
+

{{ $inspection->recommendations }}

+
+
+ @endif +
+
+ @endif +
+ @endif + + + @if($inspection->damage_diagram_data && !empty($inspection->damage_diagram_data)) +
+ + + + + Vehicle Damage Report + + +
+ @foreach($inspection->damage_diagram_data as $index => $damage) +
+
+
Damage #{{ $index + 1 }}
+ @php + $colorClass = match($damage['type']) { + 'damage' => 'bg-red-500', + 'dent' => 'bg-orange-500', + 'scratch' => 'bg-yellow-500', + default => 'bg-blue-500' + }; + $textColorClass = match($damage['type']) { + 'damage' => 'text-red-700 dark:text-red-300', + 'dent' => 'text-orange-700 dark:text-orange-300', + 'scratch' => 'text-yellow-700 dark:text-yellow-300', + default => 'text-blue-700 dark:text-blue-300' + }; + @endphp +
+ + {{ $damage['type'] }} +
+
+ @if(!empty($damage['description'])) +

{{ $damage['description'] }}

+ @else +

No description provided

+ @endif +
+ @endforeach +
+
+ @endif
diff --git a/resources/views/livewire/job-cards/create.blade.php b/resources/views/livewire/job-cards/create.blade.php index 35375e3..8541532 100644 --- a/resources/views/livewire/job-cards/create.blade.php +++ b/resources/views/livewire/job-cards/create.blade.php @@ -1,98 +1,25 @@
- +
- Create Job Card - Steps 1-2: Vehicle Reception & Initial Inspection + New Job Card + Vehicle Reception & Service Assignment
- Back to Job Cards + Back
- -
-
- 11-Step Automotive Workflow - Track progress through the complete service process -
- - -
- -
-
1
-
Vehicle
Reception
-
- -
-
2
-
Initial
Inspection
-
- -
-
3
-
Service
Assignment
-
-
-
4
-
Diagnosis
-
-
-
5
-
Estimate
-
-
-
6
-
Approval
-
-
-
7
-
Parts
Procurement
-
-
-
8
-
Repairs
-
-
-
9
-
Final
Inspection
-
-
-
10
-
Delivery
-
-
-
11
-
Archival
-
-
- - -
-
-
-
1
-
2
-
-
-
Vehicle Reception + Initial Inspection
-
Capture vehicle information, customer complaints, and perform incoming inspection
-
-
-
-
-
- +
- Customer & Vehicle Information + Customer & Vehicle -
- +
+
@if($customers && count($customers) > 0) @@ -106,7 +33,7 @@ @enderror
- +
@if($vehicles && count($vehicles) > 0) @@ -126,8 +53,8 @@
Service Assignment -
- +
+
@if($serviceAdvisors && count($serviceAdvisors) > 0) @@ -141,7 +68,7 @@ @enderror
- +
@if($branches && count($branches) > 0) @@ -154,6 +81,38 @@ {{ $message }} @enderror
+
+
+ + +
+ Reception Details + +
+ +
+ + @error('arrival_datetime') + {{ $message }} + @enderror +
+ + +
+ + @error('expected_completion_date') + {{ $message }} + @enderror +
@@ -168,321 +127,68 @@ @enderror
-
- -
- Vehicle Reception Details - -
- -
- -
- - @error('arrival_datetime') - {{ $message }} - @enderror -
- - -
- - @error('expected_completion_date') - {{ $message }} - @enderror -
- - -
- - @error('mileage_in') - {{ $message }} - @enderror -

Enter the current odometer reading

-
-
- - -
- -
- - - - - - - - - - @error('fuel_level_in') - {{ $message }} - @enderror -
- - -
- - @error('keys_location') - {{ $message }} - @enderror -
-
- - -
- -
- - @error('personal_items_removed') - {{ $message }} - @enderror -
- - -
- - @error('photos_taken') - {{ $message }} - @enderror -
-
-
-
- - -
-
- Initial Vehicle Inspection - Perform incoming inspection as part of vehicle reception -
- -
- + +
+
- -

Recommended for quality control and customer protection

-
- - @if($perform_inspection) - -
- - @if($inspectors && count($inspectors) > 0) - @foreach($inspectors as $inspector) - - @endforeach - @endif - - @error('inspector_id') - {{ $message }} - @enderror -
- - -
- - - - - - - @error('overall_condition') - {{ $message }} - @enderror -
- - -
- - Inspection Questionnaire - Rate each vehicle component based on visual inspection - -
- -
-

Exterior Condition

-
- - - - -
-
- - -
-

Interior Condition

-
- - - - -
-
- - -
-

Tire Condition

-
- - - - -
-
- - -
-

Fluid Levels

-
- - - - -
-
- - -
-

Lights & Electrical

-
- - - - -
-
-
-
-
- - -
- - @error('inspection_notes') - {{ $message }} - @enderror -
- @endif -
-
- - -
- Issues & Condition Assessment - -
- -
- - @error('customer_reported_issues') + @error('mileage_in') {{ $message }} @enderror
- + +
+ + + + + + + + + + @error('fuel_level_in') + {{ $message }} + @enderror +
+ + +
+ + @error('keys_location') + {{ $message }} + @enderror +
+
+
+ + +
+ Issues Assessment + +
+
- @error('vehicle_condition_notes') + @error('customer_reported_issues') {{ $message }} @enderror
@@ -492,8 +198,8 @@ @error('notes') {{ $message }} @@ -503,14 +209,30 @@
-
- - Cancel - - - Create Job Card - Creating... - +
+ +
+ @if(isset($jobCardId)) + + Perform Initial Inspection + + @endif +
+ + +
+ + Cancel + + + Create Job Card + +
diff --git a/resources/views/livewire/job-cards/index.blade.php b/resources/views/livewire/job-cards/index.blade.php index 52032ea..43639a4 100644 --- a/resources/views/livewire/job-cards/index.blade.php +++ b/resources/views/livewire/job-cards/index.blade.php @@ -1,387 +1,258 @@
-
- Job Cards -

Manage vehicle service job cards following the 11-step workflow

-
+ Job Cards New Job Card
- -
-
-
-
-
- - - -
-
-
-

Total

-

{{ $statistics['total'] }}

-
+ + @if (session()->has('success')) +
+
+ +
+

{{ session('success') }}

+
+ @endif -
-
-
-
- - - -
-
-
-

Received

-

{{ $statistics['received'] }}

-
+ @if (session()->has('error')) +
+
+ +
+

{{ session('error') }}

+
+ @endif -
-
-
-
- - - -
-
-
-

In Progress

-

{{ $statistics['in_progress'] }}

+ +
+
+
+
+
+
-
- -
-
-
-
- - - -
-
-
-

Pending Approval

-

{{ $statistics['pending_approval'] }}

-
-
-
- -
-
-
-
- - - -
-
-
-

Completed Today

-

{{ $statistics['completed_today'] }}

-
-
-
- -
-
-
-
- - - -
-
-
-

Delivered Today

-

{{ $statistics['delivered_today'] }}

-
-
-
- -
-
-
-
- - - -
-
-
-

Overdue

-

{{ $statistics['overdue'] }}

-
+
+

Total

+

{{ $statistics['total'] }}

- -
-
-
- +
+
+
+
+ +
-
- - @foreach($statusOptions as $value => $label) - - @endforeach - -
-
- - @foreach($branchOptions as $value => $label) - - @endforeach - -
-
- - @foreach($priorityOptions as $value => $label) - - @endforeach - -
-
- - @foreach($serviceAdvisorOptions as $value => $label) - - @endforeach - -
-
- - @foreach($dateRangeOptions as $value => $label) - - @endforeach - +
+

Received

+

{{ $statistics['received'] }}

- -
- - - Refresh - - - - Clear Filters - -
- - - @if(is_array($selectedJobCards) && count($selectedJobCards) > 0) -
-
-
- {{ is_array($selectedJobCards) ? count($selectedJobCards) : 0 }} job card(s) selected -
-
- - - +
+
+
+
+
+
+

In Progress

+

{{ $statistics['in_progress'] }}

+
- @endif +
- -
- @if($jobCards->count() > 0) -
- - - - - + + + + + + @endforeach + +
- - - Job Card # +
+
+
+
+ +
+
+
+

Pending Approval

+

{{ $statistics['pending_approval'] }}

+
+
+
+ +
+
+
+
+ +
+
+
+

Completed Today

+

{{ $statistics['completed_today'] }}

+
+
+
+ + + +
+
+ + + + @foreach($statusOptions as $value => $label) + + @endforeach + + + + @foreach($branchOptions as $value => $label) + + @endforeach + +
+
+ + +
+
+ Job Cards +

+ Showing {{ $jobCards->count() }} of {{ $jobCards->total() }} job cards +

+
+ + @if($jobCards->count() > 0) +
+ + + + - - - + + + + - - - - - - - - @foreach($jobCards as $jobCard) - - - + + + + @foreach($jobCards as $jobCard) + + - + + - + + - - - - - - - @endforeach - -
+ CustomerVehicle - Status - @if($sortBy === 'status') - {{ $sortDirection === 'asc' ? '↑' : '↓' }} + + CustomerVehicleStatus + - Priority - @if($sortBy === 'priority') - {{ $sortDirection === 'asc' ? '↑' : '↓' }} - @endif - - Arrival Date - @if($sortBy === 'arrival_datetime') - {{ $sortDirection === 'asc' ? '↑' : '↓' }} - @endif - Service AdvisorActions
- - - Actions
+
+
+ #{{ $jobCard->job_card_number }}
-
-
- {{ $jobCard->customer->first_name ?? '' }} {{ $jobCard->customer->last_name ?? '' }} +
+ {{ $jobCard->branch_code }} +
+
+
+
+
+ {{ $jobCard->customer->name ?? 'Unknown Customer' }}
{{ $jobCard->customer->phone ?? '' }}
-
-
- {{ $jobCard->vehicle->year ?? '' }} {{ $jobCard->vehicle->make ?? '' }} {{ $jobCard->vehicle->model ?? '' }} +
+
+
+
+ {{ $jobCard->vehicle->make ?? '' }} {{ $jobCard->vehicle->model ?? '' }}
{{ $jobCard->vehicle->license_plate ?? '' }}
-
- @php - $statusClasses = [ - 'received' => 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', - 'inspected' => 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', - 'assigned_for_diagnosis' => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', - 'in_diagnosis' => 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', - 'estimate_sent' => 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', - 'approved' => 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200', - 'parts_procurement' => 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200', - 'in_progress' => 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', - 'completed' => 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200', - 'delivered' => 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200', - ]; - $statusClass = $statusClasses[$jobCard->status] ?? 'bg-zinc-100 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200'; - @endphp - - {{ $statusOptions[$jobCard->status] ?? $jobCard->status }} - - - - @php - $workflowSteps = [ - 'received' => 1, - 'inspected' => 2, - 'assigned_for_diagnosis' => 3, - 'in_diagnosis' => 4, - 'estimate_sent' => 5, - 'approved' => 6, - 'parts_procurement' => 7, - 'in_progress' => 8, - 'completed' => 9, - 'delivered' => 10, - ]; - $currentStep = $workflowSteps[$jobCard->status] ?? 1; - $progress = ($currentStep / 10) * 100; - @endphp -
-
-
-
Step {{ $currentStep }}/10
-
- @php - $priorityClasses = [ - 'low' => 'bg-zinc-100 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200', - 'medium' => 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', - 'high' => 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', - 'urgent' => 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', - ]; - $priorityClass = $priorityClasses[$jobCard->priority] ?? 'bg-zinc-100 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200'; - @endphp - - {{ ucfirst($jobCard->priority) }} - - - {{ $jobCard->arrival_datetime ? $jobCard->arrival_datetime->format('M j, Y g:i A') : '-' }} - - {{ $jobCard->serviceAdvisor->name ?? '-' }} - -
- - View - - @can('update', $jobCard) - - Edit - - @endcan - - Workflow - -
-
-
+
+ + +
+ + {{ $statusOptions[$jobCard->status] ?? $jobCard->status }} + + +
+ {{ $jobCard->created_at->format('M j, Y') }} +
+
+
+ + + View + + + + Edit + +
+
+
- -
- {{ $jobCards->links() }} + +
+ {{ $jobCards->links() }} +
+ @else +
+ + No job cards found +

Get started by creating your first job card.

+
+ + + New Job Card +
- @else -
- - - -

No job cards found

-

Try adjusting your search criteria or create a new job card.

-
@endif
diff --git a/resources/views/livewire/job-cards/show.blade.php b/resources/views/livewire/job-cards/show.blade.php index e45b303..544e188 100644 --- a/resources/views/livewire/job-cards/show.blade.php +++ b/resources/views/livewire/job-cards/show.blade.php @@ -46,6 +46,34 @@ Edit + + + @if($jobCard->status === 'inspected') + + + Assign for Diagnosis + + @elseif($jobCard->status === 'assigned_for_diagnosis') + + + Start Diagnosis + + @elseif($jobCard->status === 'in_diagnosis' && !$jobCard->diagnosis) + + + + + Create Diagnosis + + @elseif($jobCard->diagnosis) + + + + + View Diagnosis + + @endif + @if(in_array($jobCard->status, ['received', 'in_diagnosis'])) @@ -372,6 +400,32 @@
@if($jobCard->status === 'received') + + + + + Perform Initial Inspection + +
+
+
+ + + +
+
+

+ Initial Inspection Required +

+
+

Complete the initial vehicle inspection before proceeding to diagnosis.

+
+
+
+
+ @endif + + @if($jobCard->status === 'inspected') @@ -503,4 +557,43 @@
+ + + @if($showAssignmentModal) +
+
+
+

Assign Technician for Diagnosis

+ +
+ +
+
+ + Select Technician + + @foreach($availableTechnicians as $technician) + + @endforeach + + + +
+ +
+ + Cancel + + + Assign Technician + +
+
+
+
+ @endif
diff --git a/routes/web.php b/routes/web.php index 20eb95d..a637c4e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,15 +1,15 @@ check()) { $user = auth()->user(); - + // Check if user is a customer if ($user->isCustomer()) { // Redirect customers to customer portal @@ -19,7 +19,7 @@ Route::get('/', function () { return redirect('/dashboard'); } } - + // For guests, redirect to login page instead of showing welcome return redirect('/login'); })->name('home'); @@ -33,21 +33,21 @@ Route::middleware(['auth', 'admin.only'])->group(function () { Route::redirect('settings', 'settings/general'); Volt::route('settings/profile', 'settings.profile')->name('settings.profile'); Volt::route('settings/password', 'settings.password')->name('settings.password'); - + // Application Settings Routes Route::prefix('settings')->name('settings.')->group(function () { Route::get('/general', [App\Http\Controllers\SettingsController::class, 'general'])->name('general'); Route::put('/general', [App\Http\Controllers\SettingsController::class, 'updateGeneral'])->name('general.update'); - + Route::get('/service', [App\Http\Controllers\SettingsController::class, 'service'])->name('service'); Route::put('/service', [App\Http\Controllers\SettingsController::class, 'updateService'])->name('service.update'); - + Route::get('/inventory', [App\Http\Controllers\SettingsController::class, 'inventory'])->name('inventory'); Route::put('/inventory', [App\Http\Controllers\SettingsController::class, 'updateInventory'])->name('inventory.update'); - + Route::get('/notifications', [App\Http\Controllers\SettingsController::class, 'notifications'])->name('notifications'); Route::put('/notifications', [App\Http\Controllers\SettingsController::class, 'updateNotifications'])->name('notifications.update'); - + Route::get('/security', [App\Http\Controllers\SettingsController::class, 'security'])->name('security'); Route::put('/security', [App\Http\Controllers\SettingsController::class, 'updateSecurity'])->name('security.update'); }); @@ -57,33 +57,33 @@ Route::middleware(['auth', 'admin.only'])->group(function () { Route::resource('customers', CustomerController::class)->middleware('permission:customers.view'); Route::resource('vehicles', VehicleController::class)->middleware('permission:vehicles.view'); Route::resource('service-orders', ServiceOrderController::class)->middleware('permission:service-orders.view'); - + // Additional Service Order Routes Route::get('/service-orders/{serviceOrder}/invoice', [ServiceOrderController::class, 'invoice']) ->middleware('permission:service-orders.view') ->name('service-orders.invoice'); - + // Livewire Component Routes Route::get('/customers-list', function () { return view('customers.index'); })->middleware('permission:customers.view')->name('customers.list'); - + Route::get('/service-orders-list', function () { return view('service-orders.index'); })->middleware('permission:service-orders.view')->name('service-orders.list'); - + Route::get('/vehicles', function () { return view('vehicles.index'); })->middleware('permission:vehicles.view')->name('vehicles.index'); - + Route::get('/appointments', function () { return view('appointments.index'); })->middleware('permission:appointments.view')->name('appointments.index'); - + Route::get('/service-items', function () { return view('service-items.index'); })->middleware('permission:service-orders.view')->name('service-items.index'); - + // Appointments Management Routes Route::prefix('appointments')->name('appointments.')->middleware('permission:appointments.view')->group(function () { Route::get('/', \App\Livewire\Appointments\Index::class)->name('index'); @@ -91,15 +91,15 @@ Route::middleware(['auth', 'admin.only'])->group(function () { Route::get('/{appointment}/edit', \App\Livewire\Appointments\Form::class)->middleware('permission:appointments.update')->name('edit'); Route::get('/calendar', \App\Livewire\Appointments\Calendar::class)->name('calendar'); }); - + Route::get('/inventory', [App\Http\Controllers\InventoryController::class, 'index']) ->middleware('permission:inventory.view') ->name('inventory.index'); - + // Inventory Management Routes Route::prefix('inventory')->name('inventory.')->middleware('permission:inventory.view')->group(function () { Route::get('/dashboard', \App\Livewire\Inventory\Dashboard::class)->name('dashboard'); - + // Parts Routes Route::prefix('parts')->name('parts.')->group(function () { Route::get('/', \App\Livewire\Inventory\Parts\Index::class)->name('index'); @@ -107,14 +107,14 @@ Route::middleware(['auth', 'admin.only'])->group(function () { Route::get('/{part}', \App\Livewire\Inventory\Parts\Show::class)->name('show'); Route::get('/{part}/edit', \App\Livewire\Inventory\Parts\Edit::class)->middleware('permission:inventory.update')->name('edit'); }); - + // Suppliers Routes Route::prefix('suppliers')->name('suppliers.')->group(function () { Route::get('/', \App\Livewire\Inventory\Suppliers\Index::class)->name('index'); Route::get('/create', \App\Livewire\Inventory\Suppliers\Create::class)->middleware('permission:inventory.create')->name('create'); Route::get('/{supplier}/edit', \App\Livewire\Inventory\Suppliers\Edit::class)->middleware('permission:inventory.update')->name('edit'); }); - + // Purchase Orders Routes Route::prefix('purchase-orders')->name('purchase-orders.')->middleware('permission:inventory.purchase-orders')->group(function () { Route::get('/', \App\Livewire\Inventory\PurchaseOrders\Index::class)->name('index'); @@ -122,42 +122,48 @@ Route::middleware(['auth', 'admin.only'])->group(function () { Route::get('/{purchaseOrder}/edit', \App\Livewire\Inventory\PurchaseOrders\Edit::class)->name('edit'); Route::get('/{purchaseOrder}', \App\Livewire\Inventory\PurchaseOrders\Show::class)->name('show'); }); - + // Stock Movements Routes Route::prefix('stock-movements')->name('stock-movements.')->middleware('permission:inventory.stock-movements')->group(function () { Route::get('/', \App\Livewire\Inventory\StockMovements\Index::class)->name('index'); Route::get('/create', \App\Livewire\Inventory\StockMovements\Create::class)->name('create'); }); }); - + Route::get('/technicians', function () { return view('technicians.index'); })->middleware('permission:technicians.view')->name('technicians.index'); - + // Technician Management Routes Route::prefix('technician')->name('technician.')->middleware('permission:technicians.view')->group(function () { Route::get('/management', function () { return view('technician-management'); })->middleware('permission:technicians.update')->name('management'); - + Route::get('/skills', function () { return view('technician-skills'); })->name('skills'); - + Route::get('/reports', function () { return view('technician-reports'); })->middleware('permission:technicians.view-performance')->name('reports'); }); - + // Job Card Management Routes Route::prefix('job-cards')->name('job-cards.')->middleware('permission:job-cards.view')->group(function () { Route::get('/', \App\Livewire\JobCards\Index::class)->name('index'); + Route::get('/test', function () { + $component = new \App\Livewire\JobCards\Index; + $component->mount(); + + return $component->render(); + })->name('test'); Route::get('/create', \App\Livewire\JobCards\Create::class)->middleware('permission:job-cards.create')->name('create'); Route::get('/{jobCard}', \App\Livewire\JobCards\Show::class)->name('show'); Route::get('/{jobCard}/edit', \App\Livewire\JobCards\Edit::class)->middleware('permission:job-cards.update')->name('edit'); Route::get('/{jobCard}/workflow', \App\Livewire\JobCards\WorkflowStatus::class)->name('workflow'); }); - + // Diagnosis Routes Route::prefix('diagnosis')->name('diagnosis.')->middleware('permission:job-cards.view')->group(function () { Route::get('/', \App\Livewire\Diagnosis\Index::class)->name('index'); @@ -165,16 +171,17 @@ Route::middleware(['auth', 'admin.only'])->group(function () { Route::get('/{diagnosis}', \App\Livewire\Diagnosis\Show::class)->name('show'); Route::get('/{diagnosis}/edit', \App\Livewire\Diagnosis\Edit::class)->middleware('permission:job-cards.update')->name('edit'); }); - + // Estimates Routes Route::prefix('estimates')->name('estimates.')->middleware('permission:service-orders.view')->group(function () { Route::get('/', \App\Livewire\Estimates\Index::class)->name('index'); + Route::get('/create', \App\Livewire\Estimates\CreateStandalone::class)->middleware('permission:service-orders.create')->name('create-standalone'); Route::get('/create/{diagnosis}', \App\Livewire\Estimates\Create::class)->middleware('permission:service-orders.create')->name('create'); Route::get('/{estimate}', \App\Livewire\Estimates\Show::class)->name('show'); Route::get('/{estimate}/edit', \App\Livewire\Estimates\Edit::class)->middleware('permission:service-orders.update')->name('edit'); Route::get('/{estimate}/pdf', \App\Livewire\Estimates\PDF::class)->name('pdf'); }); - + // Work Orders Routes Route::prefix('work-orders')->name('work-orders.')->middleware('permission:service-orders.view')->group(function () { Route::get('/', \App\Livewire\WorkOrders\Index::class)->name('index'); @@ -182,15 +189,16 @@ Route::middleware(['auth', 'admin.only'])->group(function () { Route::get('/{workOrder}', \App\Livewire\WorkOrders\Show::class)->name('show'); Route::get('/{workOrder}/edit', \App\Livewire\WorkOrders\Edit::class)->middleware('permission:service-orders.update')->name('edit'); }); - + // Inspections Routes Route::prefix('inspections')->name('inspections.')->middleware('permission:job-cards.view')->group(function () { Route::get('/', \App\Livewire\Inspections\Index::class)->name('index'); Route::get('/create/{jobCard}/{type}', \App\Livewire\Inspections\Create::class)->middleware('permission:job-cards.update')->name('create'); Route::get('/{inspection}', \App\Livewire\Inspections\Show::class)->name('show'); + Route::get('/{inspection}/print', \App\Livewire\Inspections\PrintView::class)->name('print'); Route::get('/{inspection}/edit', \App\Livewire\Inspections\Edit::class)->middleware('permission:job-cards.update')->name('edit'); }); - + // Timesheets Routes Route::prefix('timesheets')->name('timesheets.')->middleware('permission:technicians.view')->group(function () { Route::get('/', \App\Livewire\Timesheets\Index::class)->name('index'); @@ -198,17 +206,17 @@ Route::middleware(['auth', 'admin.only'])->group(function () { Route::get('/{timesheet}', \App\Livewire\Timesheets\Show::class)->name('show'); Route::get('/{timesheet}/edit', \App\Livewire\Timesheets\Edit::class)->name('edit'); }); - + // Reports Dashboard Route Route::view('reports', 'reports')->middleware(['auth', 'permission:reports.view'])->name('reports.index'); - + // Branch Management Routes Route::prefix('branches')->name('branches.')->middleware('permission:branches.view')->group(function () { Route::get('/', \App\Livewire\Branches\Index::class)->name('index'); Route::get('/create', \App\Livewire\Branches\Create::class)->middleware('permission:branches.create')->name('create'); Route::get('/{branch}/edit', \App\Livewire\Branches\Edit::class)->middleware('permission:branches.edit')->name('edit'); }); - + // User Management Routes Route::prefix('users')->name('users.')->middleware('permission:users.view')->group(function () { Route::get('/', \App\Livewire\Users\Index::class)->name('index'); @@ -217,7 +225,7 @@ Route::middleware(['auth', 'admin.only'])->group(function () { Route::get('/{user}/edit', \App\Livewire\Users\Edit::class)->middleware('permission:users.update')->name('edit'); Route::get('/{user}/manage-roles', \App\Livewire\Users\ManageRolesPermissions::class)->middleware('permission:users.manage-roles')->name('manage-roles'); }); - + // Legacy User Management Route (redirect to new structure) Route::get('/user-management', function () { return redirect()->route('users.index'); diff --git a/test-inspection.blade.php b/test-inspection.blade.php new file mode 100644 index 0000000..1d20ef2 --- /dev/null +++ b/test-inspection.blade.php @@ -0,0 +1,14 @@ +
+ + + + + + + + + + @if(true) +

Test content

+ @endif +
diff --git a/tests/Feature/EstimateModuleTest.php b/tests/Feature/EstimateModuleTest.php new file mode 100644 index 0000000..3f7c9d7 --- /dev/null +++ b/tests/Feature/EstimateModuleTest.php @@ -0,0 +1,356 @@ +user = User::factory()->create([ + 'role' => 'service_coordinator', + ]); + + // Give user required permissions for estimates + $permissions = [ + ['name' => 'service-orders.view', 'display_name' => 'View Service Orders', 'module' => 'service-orders'], + ['name' => 'service-orders.create', 'display_name' => 'Create Service Orders', 'module' => 'service-orders'], + ['name' => 'service-orders.update', 'display_name' => 'Update Service Orders', 'module' => 'service-orders'], + ]; + + foreach ($permissions as $permissionData) { + $permission = \App\Models\Permission::firstOrCreate( + ['name' => $permissionData['name']], + [ + 'display_name' => $permissionData['display_name'], + 'description' => $permissionData['display_name'], + 'module' => $permissionData['module'], + ] + ); + $this->user->permissions()->attach($permission); + } + + $this->branch = Branch::create([ + 'name' => 'Main Branch', + 'code' => 'MAIN', + 'address' => '123 Test St', + 'phone' => '555-0123', + 'email' => 'main@test.com', + ]); + + $this->customer = Customer::factory()->create(); + $this->vehicle = Vehicle::factory()->create([ + 'customer_id' => $this->customer->id, + ]); + + $this->jobCard = JobCard::factory()->create([ + 'customer_id' => $this->customer->id, + 'vehicle_id' => $this->vehicle->id, + 'branch_code' => $this->branch->code, + 'status' => 'in_diagnosis', + 'service_advisor_id' => $this->user->id, + ]); + + $this->diagnosis = Diagnosis::create([ + 'job_card_id' => $this->jobCard->id, + 'service_coordinator_id' => $this->user->id, + 'diagnostic_findings' => 'Worn brake pads detected', + 'labor_operations' => [ + [ + 'operation' => 'Replace front brake pads', + 'estimated_hours' => 2.0, + 'labor_rate' => 85.00, + ], + ], + 'parts_required' => [ + [ + 'part_name' => 'Front brake pads', + 'part_number' => 'BP-TOY-001', + 'quantity' => 1, + 'estimated_cost' => 75.00, + ], + ], + 'estimated_repair_time' => 2.5, + 'diagnosis_status' => 'completed', + ]); + + $this->actingAs($this->user); + } + + /** @test */ + public function it_can_create_an_estimate_from_diagnosis() + { + Livewire::test(Create::class, ['diagnosis' => $this->diagnosis]) + ->assertSet('diagnosis.id', $this->diagnosis->id) + ->assertViewHas('diagnosis') + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(); + + $this->assertDatabaseHas('estimates', [ + 'job_card_id' => $this->jobCard->id, + 'diagnosis_id' => $this->diagnosis->id, + 'prepared_by_id' => $this->user->id, + 'status' => 'draft', + ]); + + // Check that line items were created + $estimate = Estimate::latest()->first(); + $this->assertGreaterThan(0, $estimate->lineItems()->count()); + + // Check that totals were calculated + $this->assertGreaterThan(0, $estimate->total_amount); + } + + /** @test */ + public function it_calculates_totals_correctly() + { + $component = Livewire::test(Create::class, ['diagnosis' => $this->diagnosis]); + + // Verify initial line items from diagnosis + $lineItems = $component->get('lineItems'); + $this->assertNotEmpty($lineItems); + + // Check that totals are calculated + $subtotal = $component->get('subtotal'); + $taxAmount = $component->get('tax_amount'); + $totalAmount = $component->get('total_amount'); + + $this->assertGreaterThan(0, $subtotal); + $this->assertGreaterThan(0, $taxAmount); + $this->assertGreaterThan(0, $totalAmount); + + // Verify total calculation: subtotal + tax = total + $expectedTotal = $subtotal + $taxAmount; + $this->assertEquals($expectedTotal, $totalAmount); + } + + /** @test */ + public function it_can_add_and_remove_line_items() + { + $component = Livewire::test(Create::class, ['diagnosis' => $this->diagnosis]); + + $initialCount = count($component->get('lineItems')); + + // Add a line item + $component->call('addLineItem'); + $this->assertCount($initialCount + 1, $component->get('lineItems')); + + // Remove a line item + $component->call('removeLineItem', 0); + $this->assertCount($initialCount, $component->get('lineItems')); + } + + /** @test */ + public function it_validates_required_fields() + { + Livewire::test(Create::class, ['diagnosis' => $this->diagnosis]) + ->set('terms_and_conditions', '') + ->set('validity_period_days', 0) + ->set('tax_rate', -1) + ->call('save') + ->assertHasErrors([ + 'terms_and_conditions', + 'validity_period_days', + 'tax_rate', + ]); + } + + /** @test */ + public function it_can_view_an_estimate() + { + $estimate = Estimate::create([ + 'estimate_number' => 'MAIN/EST0001', + 'job_card_id' => $this->jobCard->id, + 'diagnosis_id' => $this->diagnosis->id, + 'prepared_by_id' => $this->user->id, + 'labor_cost' => 170.00, + 'parts_cost' => 90.00, + 'subtotal' => 260.00, + 'tax_rate' => 8.25, + 'tax_amount' => 21.45, + 'total_amount' => 281.45, + 'validity_period_days' => 30, + 'terms_and_conditions' => 'Test terms', + 'status' => 'draft', + ]); + + Livewire::test(Show::class, ['estimate' => $estimate]) + ->assertSet('estimate.id', $estimate->id) + ->assertSee($estimate->estimate_number) + ->assertSee('$281.45') + ->assertViewHas('estimate'); + } + + /** @test */ + public function it_can_edit_an_estimate() + { + $estimate = Estimate::factory()->create([ + 'job_card_id' => $this->jobCard->id, + 'terms_and_conditions' => 'Original terms', + 'validity_period_days' => 30, + ]); + + // Test the component functionality directly without layout rendering + $component = Livewire::test(\App\Livewire\Estimates\Edit::class, ['estimate' => $estimate]) + ->set('terms_and_conditions', 'Updated terms and conditions') + ->set('validity_period_days', 45); + + // Assert the properties are set correctly + $component->assertSet('terms_and_conditions', 'Updated terms and conditions') + ->assertSet('validity_period_days', 45); + + // Call save method + $component->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('estimates', [ + 'id' => $estimate->id, + 'terms_and_conditions' => 'Updated terms and conditions', + 'validity_period_days' => 45, + ]); + } + + /** @test */ + public function it_generates_unique_estimate_numbers() + { + $initialCount = Estimate::count(); + + // Create first estimate + Livewire::test(Create::class, ['diagnosis' => $this->diagnosis]) + ->call('save'); + + $this->assertEquals($initialCount + 1, Estimate::count()); + $firstEstimate = Estimate::latest()->first(); + + // Create another diagnosis for second estimate + $diagnosis2 = Diagnosis::create([ + 'job_card_id' => $this->jobCard->id, + 'service_coordinator_id' => $this->user->id, + 'diagnostic_findings' => 'Different issue', + 'diagnosis_status' => 'completed', + ]); + + // Create second estimate + Livewire::test(Create::class, ['diagnosis' => $diagnosis2]) + ->call('save'); + + $this->assertEquals($initialCount + 2, Estimate::count()); + $estimates = Estimate::latest()->take(2)->get(); + $secondEstimate = $estimates->first(); + $firstEstimate = $estimates->last(); + + $this->assertNotEquals($firstEstimate->estimate_number, $secondEstimate->estimate_number); + $this->assertStringContainsString('MAIN/EST', $firstEstimate->estimate_number); + $this->assertStringContainsString('MAIN/EST', $secondEstimate->estimate_number); + } + + /** @test */ + public function it_updates_job_card_status_when_estimate_created() + { + $this->assertEquals('in_diagnosis', $this->jobCard->status); + + Livewire::test(Create::class, ['diagnosis' => $this->diagnosis]) + ->call('save'); + + $this->jobCard->refresh(); + $this->assertEquals('estimate_prepared', $this->jobCard->status); + } + + /** @test */ + public function estimate_routes_are_accessible() + { + // Skip permission checks for this test by using an admin role + $this->user->update(['role' => 'admin']); + + $estimate = Estimate::create([ + 'estimate_number' => 'MAIN/EST0001', + 'job_card_id' => $this->jobCard->id, + 'diagnosis_id' => $this->diagnosis->id, + 'prepared_by_id' => $this->user->id, + 'labor_cost' => 170.00, + 'parts_cost' => 90.00, + 'subtotal' => 260.00, + 'tax_rate' => 8.25, + 'tax_amount' => 21.45, + 'total_amount' => 281.45, + 'validity_period_days' => 30, + 'terms_and_conditions' => 'Test terms', + 'status' => 'draft', + ]); + + // Test index route + $this->actingAs($this->user) + ->get('/estimates') + ->assertStatus(200); + + // Test show route + $this->actingAs($this->user) + ->get("/estimates/{$estimate->id}") + ->assertStatus(200); + + // Test edit route + $this->get("/estimates/{$estimate->id}/edit") + ->assertStatus(200); + + // Test create route + $this->get("/estimates/create/{$this->diagnosis->id}") + ->assertStatus(200); + } + + /** @test */ + public function it_can_calculate_estimate_expiration() + { + $estimate = Estimate::create([ + 'estimate_number' => 'MAIN/EST0001', + 'job_card_id' => $this->jobCard->id, + 'diagnosis_id' => $this->diagnosis->id, + 'prepared_by_id' => $this->user->id, + 'labor_cost' => 170.00, + 'parts_cost' => 90.00, + 'subtotal' => 260.00, + 'tax_rate' => 8.25, + 'tax_amount' => 21.45, + 'total_amount' => 281.45, + 'validity_period_days' => 30, + 'terms_and_conditions' => 'Test terms', + 'status' => 'draft', + ]); + + $expectedValidUntil = $estimate->created_at->addDays(30); + $this->assertEquals($expectedValidUntil->format('Y-m-d'), $estimate->valid_until->format('Y-m-d')); + + // Test if estimate is not expired (fresh estimate) + $this->assertFalse($estimate->is_expired); + } +} diff --git a/tests/Feature/EstimatesTest.php b/tests/Feature/EstimatesTest.php new file mode 100644 index 0000000..d8cd8ed --- /dev/null +++ b/tests/Feature/EstimatesTest.php @@ -0,0 +1,107 @@ +create(); + $vehicle = Vehicle::factory()->create(['customer_id' => $customer->id]); + $user = User::factory()->create(); + + // Create standalone estimate + $estimate = Estimate::factory()->create([ + 'customer_id' => $customer->id, + 'vehicle_id' => $vehicle->id, + 'prepared_by_id' => $user->id, + 'job_card_id' => null, + 'diagnosis_id' => null, + ]); + + // Test relationships + $this->assertEquals($customer->id, $estimate->customer->id); + $this->assertEquals($vehicle->id, $estimate->vehicle->id); + $this->assertEquals($user->id, $estimate->preparedBy->id); + $this->assertNull($estimate->job_card_id); + $this->assertNull($estimate->diagnosis_id); + } + + public function test_estimate_customer_and_vehicle_accessors(): void + { + $customer = Customer::factory()->create(); + $vehicle = Vehicle::factory()->create(['customer_id' => $customer->id]); + $user = User::factory()->create(); + + // Create standalone estimate + $estimate = Estimate::factory()->create([ + 'customer_id' => $customer->id, + 'vehicle_id' => $vehicle->id, + 'prepared_by_id' => $user->id, + 'job_card_id' => null, + 'diagnosis_id' => null, + ]); + + // Test customer accessor (should return direct customer) + $this->assertEquals($customer->name, $estimate->estimate_customer->name); + + // Test vehicle accessor (should return direct vehicle) + $this->assertEquals($vehicle->make, $estimate->estimate_vehicle->make); + } + + public function test_estimate_stats_calculation_with_different_statuses(): void + { + $customer = Customer::factory()->create(); + $vehicle = Vehicle::factory()->create(['customer_id' => $customer->id]); + $user = User::factory()->create(); + + // Create estimates with different statuses + Estimate::factory()->create([ + 'customer_id' => $customer->id, + 'vehicle_id' => $vehicle->id, + 'prepared_by_id' => $user->id, + 'job_card_id' => null, + 'diagnosis_id' => null, + 'status' => 'draft', + 'total_amount' => 100.00, + ]); + + Estimate::factory()->create([ + 'customer_id' => $customer->id, + 'vehicle_id' => $vehicle->id, + 'prepared_by_id' => $user->id, + 'job_card_id' => null, + 'diagnosis_id' => null, + 'status' => 'sent', + 'total_amount' => 200.00, + ]); + + Estimate::factory()->create([ + 'customer_id' => $customer->id, + 'vehicle_id' => $vehicle->id, + 'prepared_by_id' => $user->id, + 'job_card_id' => null, + 'diagnosis_id' => null, + 'status' => 'approved', + 'total_amount' => 300.00, + ]); + + // Verify the estimates were created with correct status and amounts + $this->assertEquals(1, Estimate::where('status', 'draft')->count()); + $this->assertEquals(1, Estimate::where('status', 'sent')->count()); + $this->assertEquals(1, Estimate::where('status', 'approved')->count()); + $this->assertEquals(600.00, Estimate::sum('total_amount')); + } +} diff --git a/tests/Feature/JobCardsIndexTest.php b/tests/Feature/JobCardsIndexTest.php new file mode 100644 index 0000000..ff64174 --- /dev/null +++ b/tests/Feature/JobCardsIndexTest.php @@ -0,0 +1,18 @@ +get('/'); + + $response->assertStatus(200); + } +}