Initial commit
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run

This commit is contained in:
2025-07-30 17:15:50 +00:00
commit e839d40a99
832 changed files with 72253 additions and 0 deletions

18
.editorconfig Normal file
View File

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4

65
.env.example Normal file
View File

@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

10
.gitattributes vendored Normal file
View File

@ -0,0 +1,10 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
CHANGELOG.md export-ignore
README.md export-ignore

46
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,46 @@
name: linter
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
permissions:
contents: write
jobs:
quality:
runs-on: ubuntu-latest
environment: Testing
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Add Flux Credentials Loaded From ENV
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
- name: Install Dependencies
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
npm install
- name: Run Pint
run: vendor/bin/pint
# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v5
# with:
# commit_message: fix code style
# commit_options: '--no-verify'
# file_pattern: |
# **/*
# !.github/workflows/*

54
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: tests
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
jobs:
ci:
runs-on: ubuntu-latest
environment: Testing
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
tools: composer:v2
coverage: xdebug
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install Node Dependencies
run: npm i
- name: Add Flux Credentials Loaded From ENV
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Copy Environment File
run: cp .env.example .env
- name: Generate Application Key
run: php artisan key:generate
- name: Build Assets
run: npm run build
- name: Run Tests
run: ./vendor/bin/phpunit

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
/.phpunit.cache
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
/auth.json
/.fleet
/.idea
/.nova
/.vscode
/.zed

292
PERMISSIONS.md Normal file
View File

@ -0,0 +1,292 @@
# Role-Based User Permission System Documentation
## Overview
This car repairs shop application now includes a comprehensive role-based permission system that provides fine-grained access control across all modules. The system supports:
- **Hierarchical Roles**: Users can have multiple roles with different permissions
- **Direct Permissions**: Users can have permissions assigned directly, bypassing roles
- **Branch-Based Access**: Permissions can be scoped to specific branches
- **Module-Based Organization**: Permissions are organized by functional modules
## Database Structure
### Core Tables
1. **roles** - Defines available roles in the system
2. **permissions** - Defines granular permissions organized by modules
3. **role_permissions** - Links roles to their permissions
4. **user_roles** - Assigns roles to users (with optional branch scoping)
5. **user_permissions** - Assigns direct permissions to users
## Available Roles
| Role | Display Name | Description |
|------|-------------|-------------|
| `admin` | Administrator | Full system access across all branches |
| `manager` | Branch Manager | Full access within assigned branch |
| `service_supervisor` | Service Supervisor | Supervises service operations and technicians |
| `service_coordinator` | Service Coordinator | Coordinates service workflow and scheduling |
| `service_advisor` | Service Advisor | Interfaces with customers and manages service requests |
| `parts_manager` | Parts Manager | Manages inventory and parts ordering |
| `technician` | Technician | Performs vehicle service and repairs |
| `quality_inspector` | Quality Inspector | Performs quality inspections and audits |
| `customer_service` | Customer Service | Handles customer inquiries and support |
## Permission Modules
### Job Cards
- `job-cards.view` - View job cards in own branch
- `job-cards.view-all` - View job cards across all branches
- `job-cards.view-own` - View only assigned job cards
- `job-cards.create` - Create new job cards
- `job-cards.update` - Update job cards in own branch
- `job-cards.update-all` - Update job cards across all branches
- `job-cards.update-own` - Update only assigned job cards
- `job-cards.delete` - Delete job cards
- `job-cards.approve` - Approve job cards for processing
- `job-cards.assign-technician` - Assign technicians to job cards
### Customers
- `customers.view` - View customer information
- `customers.create` - Create new customers
- `customers.update` - Update customer information
- `customers.delete` - Delete customers
### Vehicles
- `vehicles.view` - View vehicle information
- `vehicles.create` - Register new vehicles
- `vehicles.update` - Update vehicle information
- `vehicles.delete` - Delete vehicles
### Inventory
- `inventory.view` - View inventory items
- `inventory.create` - Add new inventory items
- `inventory.update` - Update inventory items
- `inventory.delete` - Delete inventory items
- `inventory.stock-movements` - Manage stock movements
- `inventory.purchase-orders` - Manage purchase orders
### Service Orders
- `service-orders.view` - View service orders
- `service-orders.create` - Create new service orders
- `service-orders.update` - Update service orders
- `service-orders.delete` - Delete service orders
- `service-orders.approve` - Approve service orders
### Appointments
- `appointments.view` - View appointments
- `appointments.create` - Schedule new appointments
- `appointments.update` - Update appointments
- `appointments.delete` - Cancel appointments
### Technicians
- `technicians.view` - View technician information
- `technicians.create` - Add new technicians
- `technicians.update` - Update technician information
- `technicians.delete` - Remove technicians
- `technicians.assign-work` - Assign work to technicians
- `technicians.view-performance` - View technician performance reports
### Reports
- `reports.view` - View all reports
- `reports.financial` - View financial and revenue reports
- `reports.operational` - View operational and performance reports
- `reports.export` - Export reports to various formats
### User Management
- `users.view` - View user accounts
- `users.create` - Create new user accounts
- `users.update` - Update user information
- `users.delete` - Delete user accounts
- `users.manage-roles` - Assign and remove user roles
### System Administration
- `system.settings` - Configure system settings
- `system.maintenance` - Perform system maintenance tasks
## Usage Examples
### In Controllers/Livewire Components
```php
// Check permission in component mount method
public function mount()
{
$this->authorize('create', JobCard::class);
}
// Check permission in methods
public function save()
{
if (!auth()->user()->hasPermission('job-cards.create')) {
abort(403, 'You do not have permission to create job cards.');
}
// ... rest of the method
}
// Filter data based on permissions
public function loadData()
{
$user = auth()->user();
if ($user->hasPermission('job-cards.view-all')) {
$this->jobCards = JobCard::all();
} elseif ($user->hasPermission('job-cards.view')) {
$this->jobCards = JobCard::where('branch_code', $user->branch_code)->get();
} else {
$this->jobCards = JobCard::where('service_advisor_id', $user->id)->get();
}
}
```
### In Routes
```php
// Protect routes with permission middleware
Route::get('/job-cards', JobCardIndex::class)
->middleware('permission:job-cards.view');
Route::get('/job-cards/create', JobCardCreate::class)
->middleware('permission:job-cards.create');
// Protect with role middleware
Route::prefix('admin')->middleware('role:admin,manager')->group(function () {
Route::get('/settings', AdminSettings::class);
});
```
### In Blade Templates
```blade
{{-- Using Blade directives --}}
@hasPermission('job-cards.create')
<a href="{{ route('job-cards.create') }}" class="btn btn-primary">
Create Job Card
</a>
@endhasPermission
@hasRole('admin|manager')
<div class="admin-panel">
{{-- Admin content --}}
</div>
@endhasRole
@hasAnyPermission('reports.view|reports.financial')
<a href="{{ route('reports.index') }}">View Reports</a>
@endhasAnyPermission
{{-- Using permission component --}}
<x-permission-check permission="job-cards.update">
<button wire:click="updateJobCard">Update</button>
</x-permission-check>
<x-permission-check role="service_supervisor">
<div class="supervisor-tools">
{{-- Supervisor-only tools --}}
</div>
</x-permission-check>
```
### In Policies
```php
class JobCardPolicy
{
public function view(User $user, JobCard $jobCard): bool
{
// Admin can view all
if ($user->hasPermission('job-cards.view-all')) {
return true;
}
// Branch-level access
if ($user->hasPermission('job-cards.view') &&
$jobCard->branch_code === $user->branch_code) {
return true;
}
// Own records only
if ($user->hasPermission('job-cards.view-own') &&
$jobCard->service_advisor_id === $user->id) {
return true;
}
return false;
}
}
```
## Artisan Commands
### Assign Role to User
```bash
# Assign role without branch restriction
php artisan user:assign-role user@example.com admin
# Assign role with branch restriction
php artisan user:assign-role user@example.com service_advisor --branch=ACC
```
### Seed Roles and Permissions
```bash
php artisan db:seed --class=RolesAndPermissionsSeeder
```
## User Management Interface
The system includes a web interface for managing user roles and permissions:
- **URL**: `/user-management`
- **Permission Required**: `users.view`
- **Features**:
- Search and filter users
- Assign/remove roles
- Grant/revoke direct permissions
- Set branch-specific access
- Activate/deactivate users
## Permission Hierarchy
1. **Super Admin**: `admin` role bypasses all permission checks
2. **Direct Permissions**: Override role-based permissions
3. **Role Permissions**: Standard role-based access
4. **Branch Scoping**: Permissions can be limited to specific branches
## Security Features
- **Branch Isolation**: Users can only access data within their assigned branch (unless granted cross-branch permissions)
- **Temporal Permissions**: Roles and permissions can have expiration dates
- **Audit Trail**: All role and permission changes are timestamped
- **Middleware Protection**: Routes are protected at the middleware level
- **Policy-Based Authorization**: Model operations use Laravel policies for fine-grained control
## Best Practices
1. **Use Roles for Common Patterns**: Assign permissions to roles rather than directly to users
2. **Branch Scoping**: Always consider branch-level access when designing features
3. **Least Privilege**: Grant only the minimum permissions required for a user's job function
4. **Regular Audits**: Periodically review user permissions and remove unnecessary access
5. **Policy Classes**: Use Laravel policies for complex authorization logic
6. **Middleware First**: Apply middleware protection to routes before implementing view-level checks
## Troubleshooting
### Common Issues
1. **Permission Denied Errors**: Check that the user has the required permission and that their role is active
2. **Branch Access Issues**: Verify that the user's branch_code matches the resource's branch_code
3. **Middleware Conflicts**: Ensure middleware is applied in the correct order
4. **Cache Issues**: Clear application cache after changing permissions: `php artisan cache:clear`
### Debug Commands
```bash
# Check user's roles and permissions
php artisan tinker
>>> $user = User::find(1);
>>> $user->getAllPermissions();
>>> $user->roles;
```
This permission system provides a robust foundation for controlling access to all features in your car repairs shop application while maintaining flexibility for different organizational structures and workflows.

49
THEME_STANDARD.md Normal file
View File

@ -0,0 +1,49 @@
# 🎨 Car Repair Shop - Theme Standardization
## Color Palette Standard
Based on your app.css configuration where zinc maps to slate colors.
### Primary Colors (Use ZINC everywhere)
- **Backgrounds**: `bg-white dark:bg-zinc-800`
- **Cards/Containers**: `bg-white dark:bg-zinc-800` with `border border-zinc-200 dark:border-zinc-700`
- **Secondary Backgrounds**: `bg-zinc-50 dark:bg-zinc-900`
- **Borders**: `border-zinc-200 dark:border-zinc-700`
### Typography
- **Primary Text**: `text-zinc-900 dark:text-white`
- **Secondary Text**: `text-zinc-600 dark:text-zinc-400`
- **Muted Text**: `text-zinc-500 dark:text-zinc-500`
### Interactive Elements
- **Accent**: `text-accent` / `bg-accent` (indigo-500/300)
- **Links**: `text-accent hover:text-accent-content`
- **Buttons**: Use Flux components (`flux:button`)
### Form Elements
- **Inputs**: Use Flux components (`flux:input`, `flux:select`)
- **Focus States**: Handled by Flux automatically
### Table Elements
- **Headers**: `bg-zinc-50 dark:bg-zinc-900`
- **Borders**: `divide-zinc-200 dark:divide-zinc-700`
- **Hover**: `hover:bg-zinc-50 dark:hover:bg-zinc-700`
### Status Colors (Keep these for badges)
- **Success**: `bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200`
- **Warning**: `bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200`
- **Error**: `bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200`
- **Info**: `bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200`
## Components to Standardize
1. Job Cards - Currently using gray colors
2. Work Orders - Currently using zinc (good)
3. Customers - Mixed gray/zinc colors
4. Service Orders - Using zinc (good)
5. Users - Recently updated to stone (needs zinc)
6. All other modules
## Implementation Priority
1. Fix color inconsistencies (gray → zinc)
2. Standardize form components to use Flux
3. Ensure dark mode compatibility
4. Update status badges for consistency

View File

@ -0,0 +1,79 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Role;
use App\Models\Permission;
use Illuminate\Support\Facades\DB;
class AssignPermissions extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'permissions:assign-all {role=super_admin}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Assign all permissions to a role';
/**
* Execute the console command.
*/
public function handle()
{
$roleName = $this->argument('role');
$role = Role::where('name', $roleName)->first();
if (!$role) {
$this->error("Role '{$roleName}' not found!");
return 1;
}
$permissions = Permission::all();
$this->info("Found {$permissions->count()} permissions");
$this->info("Assigning to role: {$role->display_name}");
$assigned = 0;
foreach ($permissions as $permission) {
$exists = DB::table('role_permissions')
->where('role_id', $role->id)
->where('permission_id', $permission->id)
->exists();
if (!$exists) {
DB::table('role_permissions')->insert([
'role_id' => $role->id,
'permission_id' => $permission->id,
'created_at' => now(),
'updated_at' => now()
]);
$assigned++;
}
}
$total = DB::table('role_permissions')->where('role_id', $role->id)->count();
$this->info("Assigned {$assigned} new permissions");
$this->info("Role now has {$total} total permissions");
// Check for users.view specifically
$usersView = DB::table('role_permissions')
->join('permissions', 'role_permissions.permission_id', '=', 'permissions.id')
->where('role_permissions.role_id', $role->id)
->where('permissions.name', 'users.view')
->exists();
$this->info("users.view permission: " . ($usersView ? "✓ ASSIGNED" : "✗ MISSING"));
return 0;
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\User;
use App\Models\Role;
class AssignRoleCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:assign-role {email} {role} {--branch=}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Assign a role to a user';
/**
* Execute the console command.
*/
public function handle()
{
$email = $this->argument('email');
$roleName = $this->argument('role');
$branchCode = $this->option('branch');
$user = User::where('email', $email)->first();
if (!$user) {
$this->error("User with email {$email} not found.");
return Command::FAILURE;
}
$role = Role::where('name', $roleName)->first();
if (!$role) {
$this->error("Role {$roleName} not found.");
return Command::FAILURE;
}
// Assign role to user
$user->assignRole($role, $branchCode);
$this->info("Role '{$role->display_name}' assigned to user '{$user->name}'" .
($branchCode ? " for branch '{$branchCode}'" : '') . ".");
return Command::SUCCESS;
}
}

View File

@ -0,0 +1,128 @@
<?php
namespace App\Console\Commands;
use App\Models\Part;
use App\Models\PartHistory;
use App\Models\StockMovement;
use Illuminate\Console\Command;
class GeneratePartHistory extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'part:generate-history {--part_id=}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate sample part history for testing';
/**
* Execute the console command.
*/
public function handle()
{
$partId = $this->option('part_id');
if ($partId) {
$parts = Part::where('id', $partId)->get();
} else {
$parts = Part::take(5)->get();
}
if ($parts->isEmpty()) {
$this->error('No parts found');
return;
}
foreach ($parts as $part) {
$this->info("Generating history for part: {$part->name}");
// Generate creation history
PartHistory::create([
'part_id' => $part->id,
'event_type' => PartHistory::EVENT_CREATED,
'new_values' => [
'name' => $part->name,
'part_number' => $part->part_number,
'cost_price' => $part->cost_price,
'sell_price' => $part->sell_price,
],
'notes' => 'Part initially created',
'ip_address' => '127.0.0.1',
'user_agent' => 'Console Command',
'created_by' => 1,
'created_at' => $part->created_at,
'updated_at' => $part->created_at,
]);
// Generate some stock movements
$movements = [
['type' => 'in', 'qty' => 50, 'note' => 'Initial stock'],
['type' => 'in', 'qty' => 25, 'note' => 'Restocked inventory'],
['type' => 'out', 'qty' => 10, 'note' => 'Used in service'],
['type' => 'adjustment', 'qty' => -2, 'note' => 'Inventory correction'],
];
$currentStock = $part->quantity_on_hand;
$runningTotal = 0;
foreach ($movements as $index => $movement) {
// Create dates that span from a week ago to today to ensure some records show up
$daysAgo = max(0, 7 - $index); // This will create records from 7 days ago to today
$createdAt = now()->subDays($daysAgo)->subHours(rand(1, 12));
$quantityBefore = $runningTotal;
$quantityChange = $movement['type'] === 'out' ? -$movement['qty'] : $movement['qty'];
$quantityAfter = $quantityBefore + $quantityChange;
$runningTotal = $quantityAfter;
PartHistory::create([
'part_id' => $part->id,
'event_type' => $movement['type'] === 'in' ? PartHistory::EVENT_STOCK_IN :
($movement['type'] === 'out' ? PartHistory::EVENT_STOCK_OUT : PartHistory::EVENT_ADJUSTMENT),
'quantity_change' => $quantityChange,
'quantity_before' => $quantityBefore,
'quantity_after' => $quantityAfter,
'reference_type' => 'manual_adjustment',
'notes' => $movement['note'],
'ip_address' => '127.0.0.1',
'user_agent' => 'Console Command',
'created_by' => 1,
'created_at' => $createdAt,
'updated_at' => $createdAt,
]);
}
// Generate price change
$oldPrice = $part->cost_price;
$newPrice = $oldPrice * 1.1; // 10% increase
PartHistory::create([
'part_id' => $part->id,
'event_type' => PartHistory::EVENT_PRICE_CHANGE,
'old_values' => ['cost_price' => $oldPrice],
'new_values' => ['cost_price' => $newPrice],
'cost_before' => $oldPrice,
'cost_after' => $newPrice,
'notes' => 'Price updated due to supplier cost increase',
'ip_address' => '127.0.0.1',
'user_agent' => 'Console Command',
'created_by' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$this->info("✓ Generated " . PartHistory::where('part_id', $part->id)->count() . " history records for {$part->name}");
}
$totalHistory = PartHistory::count();
$this->info("\nTotal part history records in database: {$totalHistory}");
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\User;
class TestUserPermissions extends Command
{
protected $signature = 'test:user-permissions {email=admin@admin.com}';
protected $description = 'Test user permissions';
public function handle()
{
$email = $this->argument('email');
$user = User::where('email', $email)->first();
if (!$user) {
$this->error("User with email {$email} not found");
return 1;
}
$this->info("Testing permissions for: {$user->name} ({$user->email})");
$this->info("User branch_code: " . ($user->branch_code ?? 'NULL'));
// Test role assignment
$roles = $user->roles()->where('user_roles.is_active', true)->get();
$this->info("Active roles: " . $roles->pluck('name')->join(', '));
// Test hasRole
$hasSuperAdmin = $user->hasRole('super_admin');
$this->info("Has super_admin role: " . ($hasSuperAdmin ? 'YES' : 'NO'));
// Test hasPermission with and without branch code
$hasUsersViewWithBranch = $user->hasPermission('users.view', $user->branch_code);
$hasUsersViewWithoutBranch = $user->hasPermission('users.view');
$this->info("Has users.view with branch code: " . ($hasUsersViewWithBranch ? 'YES' : 'NO'));
$this->info("Has users.view without branch code: " . ($hasUsersViewWithoutBranch ? 'YES' : 'NO'));
// Check role permissions
foreach ($roles as $role) {
$rolePermissions = $role->permissions()->where('name', 'users.view')->count();
$this->info("Role '{$role->name}' has users.view permission: " . ($rolePermissions > 0 ? 'YES' : 'NO'));
}
return 0;
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
/** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */
$user = $request->user();
event(new Verified($user));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Customer;
class CustomerController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return view('customers.index');
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
return view('customers.create');
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
// This is handled by the Livewire component
return redirect()->route('customers.index');
}
/**
* Display the specified resource.
*/
public function show(Customer $customer)
{
// Load relationships for the show page
$customer->load(['vehicles', 'serviceOrders.vehicle', 'serviceOrders.assignedTechnician', 'appointments']);
return view('customers.show', compact('customer'));
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Customer $customer)
{
return view('customers.edit', compact('customer'));
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Customer $customer)
{
// This is handled by the Livewire component
return redirect()->route('customers.show', $customer);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Customer $customer)
{
$customer->delete();
return redirect()->route('customers.index')->with('success', 'Customer deleted successfully.');
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class InventoryController extends Controller
{
public function index()
{
return redirect()->route('inventory.dashboard');
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\ServiceOrder;
class ServiceOrderController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return view('service-orders.index');
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
return view('service-orders.create');
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
// This will be handled by Livewire component
return redirect()->route('service-orders.index');
}
/**
* Display the specified resource.
*/
public function show(ServiceOrder $serviceOrder)
{
return view('service-orders.show', compact('serviceOrder'));
}
/**
* Show the form for editing the specified resource.
*/
public function edit(ServiceOrder $serviceOrder)
{
return view('service-orders.edit', compact('serviceOrder'));
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, ServiceOrder $serviceOrder)
{
// This will be handled by Livewire component
return redirect()->route('service-orders.show', $serviceOrder);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(ServiceOrder $serviceOrder)
{
// This will be handled by Livewire component
return redirect()->route('service-orders.index');
}
/**
* Generate invoice for the service order.
*/
public function invoice(ServiceOrder $serviceOrder)
{
return view('service-orders.invoice', compact('serviceOrder'));
}
}

View File

@ -0,0 +1,288 @@
<?php
namespace App\Http\Controllers;
use App\Settings\GeneralSettings;
use App\Settings\ServiceSettings;
use App\Settings\InventorySettings;
use App\Settings\NotificationSettings;
use App\Settings\SecuritySettings;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class SettingsController extends Controller
{
public function general(GeneralSettings $settings)
{
return view('settings.general', compact('settings'));
}
public function updateGeneral(Request $request, GeneralSettings $settings)
{
$validated = $request->validate([
'shop_name' => 'required|string|max:255',
'shop_address' => 'required|string|max:255',
'shop_city' => 'required|string|max:100',
'shop_state' => 'required|string|max:100',
'shop_zip_code' => 'required|string|max:20',
'shop_phone' => 'required|string|max:20',
'shop_email' => 'required|email|max:255',
'shop_website' => 'nullable|url|max:255',
'default_tax_rate' => 'required|numeric|min:0|max:100',
'currency' => 'required|string|max:10',
'currency_symbol' => 'required|string|max:5',
'timezone' => 'required|string',
'date_format' => 'required|string',
'time_format' => 'required|string',
'enable_notifications' => 'nullable|boolean',
'enable_sms_notifications' => 'nullable|boolean',
'enable_email_notifications' => 'nullable|boolean',
'is_open_weekends' => 'nullable|boolean',
'business_hours' => 'nullable|array',
'holiday_hours' => 'nullable|array',
]);
// Handle boolean fields that might not be present in request
$validated['enable_notifications'] = $request->has('enable_notifications');
$validated['enable_sms_notifications'] = $request->has('enable_sms_notifications');
$validated['enable_email_notifications'] = $request->has('enable_email_notifications');
$validated['is_open_weekends'] = $request->has('is_open_weekends');
// Ensure arrays have default values if not provided
$validated['business_hours'] = $validated['business_hours'] ?? $settings->business_hours ?? [];
$validated['holiday_hours'] = $validated['holiday_hours'] ?? $settings->holiday_hours ?? [];
foreach ($validated as $key => $value) {
$settings->$key = $value;
}
$settings->save();
return redirect()->back()->with('success', 'General settings updated successfully!');
}
public function service(ServiceSettings $settings)
{
return view('settings.service', compact('settings'));
}
public function updateService(Request $request, ServiceSettings $settings)
{
$validated = $request->validate([
'standard_labor_rate' => 'required|numeric|min:0',
'overtime_labor_rate' => 'required|numeric|min:0',
'weekend_labor_rate' => 'required|numeric|min:0',
'holiday_labor_rate' => 'required|numeric|min:0',
'oil_change_interval' => 'required|integer|min:1000',
'tire_rotation_interval' => 'required|integer|min:1000',
'brake_inspection_interval' => 'required|integer|min:1000',
'general_inspection_interval' => 'required|integer|min:1000',
'enable_service_reminders' => 'nullable|boolean',
'reminder_advance_days' => 'required|integer|min:1|max:90',
'default_parts_warranty_days' => 'required|integer|min:1',
'default_labor_warranty_days' => 'required|integer|min:1',
'enable_extended_warranty' => 'nullable|boolean',
'require_quality_inspection' => 'nullable|boolean',
'require_technician_signature' => 'nullable|boolean',
'require_customer_signature' => 'nullable|boolean',
'enable_photo_documentation' => 'nullable|boolean',
'service_categories' => 'nullable|array',
'priority_levels' => 'nullable|array',
]);
// Handle boolean fields that might not be present in request
$validated['enable_service_reminders'] = $request->has('enable_service_reminders');
$validated['enable_extended_warranty'] = $request->has('enable_extended_warranty');
$validated['require_quality_inspection'] = $request->has('require_quality_inspection');
$validated['require_technician_signature'] = $request->has('require_technician_signature');
$validated['require_customer_signature'] = $request->has('require_customer_signature');
$validated['enable_photo_documentation'] = $request->has('enable_photo_documentation');
// Ensure arrays have default values if not provided
$validated['service_categories'] = $validated['service_categories'] ?? $settings->service_categories ?? [];
$validated['priority_levels'] = $validated['priority_levels'] ?? $settings->priority_levels ?? [];
foreach ($validated as $key => $value) {
$settings->$key = $value;
}
$settings->save();
return redirect()->back()->with('success', 'Service settings updated successfully!');
}
public function inventory(InventorySettings $settings)
{
return view('settings.inventory', compact('settings'));
}
public function updateInventory(Request $request, InventorySettings $settings)
{
$validated = $request->validate([
'low_stock_threshold' => 'required|integer|min:1',
'critical_stock_threshold' => 'required|integer|min:1',
'default_reorder_quantity' => 'required|integer|min:1',
'default_lead_time_days' => 'required|integer|min:1',
'default_markup_percentage' => 'required|numeric|min:0',
'preferred_supplier_count' => 'required|integer|min:1',
'minimum_order_amount' => 'required|numeric|min:0',
'default_part_markup' => 'required|numeric|min:0',
'core_charge_percentage' => 'required|numeric|min:0',
'shop_supply_fee' => 'required|numeric|min:0',
'environmental_fee' => 'required|numeric|min:0',
'waste_oil_fee' => 'required|numeric|min:0',
'tire_disposal_fee' => 'required|numeric|min:0',
'default_payment_terms' => 'required|string',
'preferred_ordering_method' => 'required|string',
'free_shipping_threshold' => 'nullable|numeric|min:0',
'enable_low_stock_alerts' => 'nullable|boolean',
'enable_automatic_reorder' => 'nullable|boolean',
'track_serial_numbers' => 'nullable|boolean',
'enable_barcode_scanning' => 'nullable|boolean',
'enable_volume_discounts' => 'nullable|boolean',
'enable_seasonal_pricing' => 'nullable|boolean',
'enable_customer_specific_pricing' => 'nullable|boolean',
'require_po_approval' => 'nullable|boolean',
'enable_dropship' => 'nullable|boolean',
'enable_backorders' => 'nullable|boolean',
]);
// Handle boolean fields that might not be present in request
$validated['enable_low_stock_alerts'] = $request->has('enable_low_stock_alerts');
$validated['enable_auto_reorder'] = $request->has('enable_automatic_reorder'); // Map form field to DB field
$validated['track_serial_numbers'] = $request->has('track_serial_numbers');
$validated['enable_barcode_scanning'] = $request->has('enable_barcode_scanning');
$validated['enable_volume_discounts'] = $request->has('enable_volume_discounts');
$validated['enable_seasonal_pricing'] = $request->has('enable_seasonal_pricing');
$validated['enable_customer_specific_pricing'] = $request->has('enable_customer_specific_pricing');
$validated['require_po_approval'] = $request->has('require_po_approval');
$validated['enable_dropship'] = $request->has('enable_dropship');
$validated['enable_backorders'] = $request->has('enable_backorders');
foreach ($validated as $key => $value) {
$settings->$key = $value;
}
$settings->save();
return redirect()->back()->with('success', 'Inventory settings updated successfully!');
}
public function notifications(NotificationSettings $settings)
{
return view('settings.notifications', compact('settings'));
}
public function updateNotifications(Request $request, NotificationSettings $settings)
{
$validated = $request->validate([
'from_email' => 'required|email',
'from_name' => 'required|string|max:255',
'manager_email' => 'required|email',
'enable_customer_notifications' => 'nullable|boolean',
'enable_technician_notifications' => 'nullable|boolean',
'enable_manager_notifications' => 'nullable|boolean',
'enable_sms' => 'nullable|boolean',
'sms_provider' => 'nullable|string',
'sms_api_key' => 'nullable|string',
'sms_from_number' => 'nullable|string',
'customer_notification_types' => 'nullable|array',
'notification_timing' => 'nullable|array',
'notify_on_new_job' => 'nullable|boolean',
'notify_on_job_completion' => 'nullable|boolean',
'notify_on_low_stock' => 'nullable|boolean',
'notify_on_overdue_inspection' => 'nullable|boolean',
'notify_on_warranty_expiry' => 'nullable|boolean',
'enable_escalation' => 'nullable|boolean',
'escalation_hours' => 'required|integer|min:1',
'escalation_contacts' => 'nullable|array',
]);
// Handle boolean fields that might not be present in request
$validated['enable_customer_notifications'] = $request->has('enable_customer_notifications');
$validated['enable_technician_notifications'] = $request->has('enable_technician_notifications');
$validated['enable_manager_notifications'] = $request->has('enable_manager_notifications');
$validated['enable_sms'] = $request->has('enable_sms');
$validated['notify_on_new_job'] = $request->has('notify_on_new_job');
$validated['notify_on_job_completion'] = $request->has('notify_on_job_completion');
$validated['notify_on_low_stock'] = $request->has('notify_on_low_stock');
$validated['notify_on_overdue_inspection'] = $request->has('notify_on_overdue_inspection');
$validated['notify_on_warranty_expiry'] = $request->has('notify_on_warranty_expiry');
$validated['enable_escalation'] = $request->has('enable_escalation');
// Ensure arrays have default values if not provided
$validated['customer_notification_types'] = $validated['customer_notification_types'] ?? $settings->customer_notification_types ?? [];
$validated['notification_timing'] = $validated['notification_timing'] ?? $settings->notification_timing ?? [];
$validated['escalation_contacts'] = $validated['escalation_contacts'] ?? $settings->escalation_contacts ?? [];
foreach ($validated as $key => $value) {
$settings->$key = $value;
}
$settings->save();
return redirect()->back()->with('success', 'Notification settings updated successfully!');
}
public function security(SecuritySettings $settings)
{
return view('settings.security', compact('settings'));
}
public function updateSecurity(Request $request, SecuritySettings $settings)
{
$validated = $request->validate([
'enable_two_factor_auth' => 'nullable|boolean',
'session_timeout_minutes' => 'required|integer|min:5',
'password_expiry_days' => 'required|integer|min:1',
'max_login_attempts' => 'required|integer|min:1',
'lockout_duration_minutes' => 'required|integer|min:1',
'min_password_length' => 'required|integer|min:6',
'require_uppercase' => 'nullable|boolean',
'require_lowercase' => 'nullable|boolean',
'require_numbers' => 'nullable|boolean',
'require_special_characters' => 'nullable|boolean',
'enable_data_encryption' => 'nullable|boolean',
'enable_audit_logging' => 'nullable|boolean',
'audit_log_retention_days' => 'required|integer|min:1',
'enable_backup_alerts' => 'nullable|boolean',
'enable_api_rate_limiting' => 'nullable|boolean',
'api_requests_per_minute' => 'required|integer|min:1',
'allowed_ip_addresses' => 'nullable|string',
'allow_customer_portal' => 'nullable|boolean',
'allow_customer_data_download' => 'nullable|boolean',
'customer_session_timeout_minutes' => 'required|integer|min:5',
]);
// Handle boolean fields that might not be present in request
$validated['enable_two_factor_auth'] = $request->has('enable_two_factor_auth');
$validated['require_uppercase'] = $request->has('require_uppercase');
$validated['require_lowercase'] = $request->has('require_lowercase');
$validated['require_numbers'] = $request->has('require_numbers');
$validated['require_special_characters'] = $request->has('require_special_characters');
$validated['enable_data_encryption'] = $request->has('enable_data_encryption');
$validated['enable_audit_logging'] = $request->has('enable_audit_logging');
$validated['enable_backup_alerts'] = $request->has('enable_backup_alerts');
$validated['enable_api_rate_limiting'] = $request->has('enable_api_rate_limiting');
$validated['allow_customer_portal'] = $request->has('allow_customer_portal');
$validated['allow_customer_data_download'] = $request->has('allow_customer_data_download');
// Convert IP addresses from textarea to array
if (!empty($validated['allowed_ip_addresses'])) {
$validated['allowed_ip_addresses'] = array_filter(
array_map('trim', explode("\n", $validated['allowed_ip_addresses'])),
function($ip) { return !empty($ip); }
);
} else {
$validated['allowed_ip_addresses'] = [];
}
foreach ($validated as $key => $value) {
$settings->$key = $value;
}
$settings->save();
return redirect()->back()->with('success', 'Security settings updated successfully!');
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Vehicle;
class VehicleController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return view('vehicles.index');
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
return view('vehicles.create');
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
// This is handled by the Livewire component
return redirect()->route('vehicles.index');
}
/**
* Display the specified resource.
*/
public function show(Vehicle $vehicle)
{
// Load relationships for the show page
$vehicle->load(['customer', 'serviceOrders.assignedTechnician', 'appointments', 'inspections']);
return view('vehicles.show', compact('vehicle'));
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Vehicle $vehicle)
{
return view('vehicles.edit', compact('vehicle'));
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Vehicle $vehicle)
{
// This is handled by the Livewire component
return redirect()->route('vehicles.show', $vehicle);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Vehicle $vehicle)
{
$vehicle->delete();
return redirect()->route('vehicles.index')->with('success', 'Vehicle deleted successfully.');
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class PermissionMiddleware
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next, string ...$permissions): Response
{
if (!auth()->check()) {
return redirect()->route('login');
}
$user = auth()->user();
// Check for super admin role first (bypass all restrictions)
if ($user->hasRole('super_admin')) {
return $next($request);
}
$branchCode = $user->branch_code;
// Check if user has any of the required permissions
if ($user->hasAnyPermission($permissions, $branchCode)) {
return $next($request);
}
abort(403, 'Access denied. Required permission: ' . implode(' or ', $permissions));
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RoleMiddleware
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next, string ...$roles): Response
{
if (!auth()->check()) {
return redirect()->route('login');
}
$user = auth()->user();
$branchCode = $user->branch_code;
// Check if user has any of the required roles
if ($user->hasAnyRole($roles, $branchCode)) {
return $next($request);
}
// Check for super admin role (bypass branch restrictions)
if ($user->hasRole('admin')) {
return $next($request);
}
abort(403, 'Access denied. Required role: ' . implode(' or ', $roles));
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Livewire\Actions;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
class Logout
{
/**
* Log the current user out of the application.
*/
public function __invoke()
{
Auth::guard('web')->logout();
Session::invalidate();
Session::regenerateToken();
return redirect('/');
}
}

View File

@ -0,0 +1,282 @@
<?php
namespace App\Livewire\Appointments;
use Livewire\Component;
use App\Models\Appointment;
use App\Models\Technician;
use Carbon\Carbon;
class Calendar extends Component
{
public $currentDate;
public $currentMonth;
public $currentYear;
public $selectedDate;
public $selectedTechnician = '';
public $viewType = 'month'; // month, week, day
public $calendarDays = [];
public $appointments = [];
public $technicians = [];
public $showAppointmentModal = false;
public $selectedAppointment = null;
public function mount()
{
$this->currentDate = now();
$this->currentMonth = $this->currentDate->month;
$this->currentYear = $this->currentDate->year;
$this->selectedDate = $this->currentDate->format('Y-m-d');
$this->technicians = Technician::where('status', 'active')->orderBy('first_name')->get();
$this->generateCalendar();
$this->loadAppointments();
}
public function updatedSelectedTechnician()
{
$this->loadAppointments();
}
public function setViewType($type)
{
$this->viewType = $type;
$this->generateCalendar();
$this->loadAppointments();
}
public function previousPeriod()
{
switch ($this->viewType) {
case 'month':
$this->currentDate = $this->currentDate->subMonth();
break;
case 'week':
$this->currentDate = $this->currentDate->subWeek();
break;
case 'day':
$this->currentDate = $this->currentDate->subDay();
break;
}
$this->currentMonth = $this->currentDate->month;
$this->currentYear = $this->currentDate->year;
$this->generateCalendar();
$this->loadAppointments();
}
public function nextPeriod()
{
switch ($this->viewType) {
case 'month':
$this->currentDate = $this->currentDate->addMonth();
break;
case 'week':
$this->currentDate = $this->currentDate->addWeek();
break;
case 'day':
$this->currentDate = $this->currentDate->addDay();
break;
}
$this->currentMonth = $this->currentDate->month;
$this->currentYear = $this->currentDate->year;
$this->generateCalendar();
$this->loadAppointments();
}
public function today()
{
$this->currentDate = now();
$this->currentMonth = $this->currentDate->month;
$this->currentYear = $this->currentDate->year;
$this->selectedDate = $this->currentDate->format('Y-m-d');
$this->generateCalendar();
$this->loadAppointments();
}
public function selectDate($date)
{
$this->selectedDate = $date;
$this->currentDate = Carbon::parse($date);
$this->dispatch('date-selected', date: $date);
}
public function showAppointmentDetails($appointmentId)
{
$this->selectedAppointment = Appointment::with(['customer', 'vehicle', 'assignedTechnician'])
->find($appointmentId);
$this->showAppointmentModal = true;
}
public function closeAppointmentModal()
{
$this->showAppointmentModal = false;
$this->selectedAppointment = null;
}
private function generateCalendar()
{
$this->calendarDays = [];
switch ($this->viewType) {
case 'month':
$this->generateMonthCalendar();
break;
case 'week':
$this->generateWeekCalendar();
break;
case 'day':
$this->generateDayCalendar();
break;
}
}
private function generateMonthCalendar()
{
$startOfMonth = $this->currentDate->copy()->startOfMonth();
$endOfMonth = $this->currentDate->copy()->endOfMonth();
$startOfCalendar = $startOfMonth->copy()->startOfWeek();
$endOfCalendar = $endOfMonth->copy()->endOfWeek();
$current = $startOfCalendar->copy();
$this->calendarDays = [];
while ($current <= $endOfCalendar) {
$this->calendarDays[] = [
'date' => $current->format('Y-m-d'),
'day' => $current->day,
'isCurrentMonth' => $current->month === $this->currentMonth,
'isToday' => $current->isToday(),
'isSelected' => $current->format('Y-m-d') === $this->selectedDate,
'dayName' => $current->format('D'),
];
$current->addDay();
}
}
private function generateWeekCalendar()
{
$startOfWeek = $this->currentDate->copy()->startOfWeek();
$this->calendarDays = [];
for ($i = 0; $i < 7; $i++) {
$date = $startOfWeek->copy()->addDays($i);
$this->calendarDays[] = [
'date' => $date->format('Y-m-d'),
'day' => $date->day,
'isCurrentMonth' => true,
'isToday' => $date->isToday(),
'isSelected' => $date->format('Y-m-d') === $this->selectedDate,
'dayName' => $date->format('l'),
'fullDate' => $date->format('M j'),
];
}
}
private function generateDayCalendar()
{
$this->calendarDays = [[
'date' => $this->currentDate->format('Y-m-d'),
'day' => $this->currentDate->day,
'isCurrentMonth' => true,
'isToday' => $this->currentDate->isToday(),
'isSelected' => true,
'dayName' => $this->currentDate->format('l'),
'fullDate' => $this->currentDate->format('F j, Y'),
]];
}
private function loadAppointments()
{
$query = Appointment::with(['customer', 'vehicle', 'assignedTechnician']);
switch ($this->viewType) {
case 'month':
$startDate = $this->currentDate->copy()->startOfMonth();
$endDate = $this->currentDate->copy()->endOfMonth();
break;
case 'week':
$startDate = $this->currentDate->copy()->startOfWeek();
$endDate = $this->currentDate->copy()->endOfWeek();
break;
case 'day':
$startDate = $this->currentDate->copy()->startOfDay();
$endDate = $this->currentDate->copy()->endOfDay();
break;
}
$query->whereBetween('scheduled_datetime', [$startDate, $endDate]);
if ($this->selectedTechnician) {
$query->where('assigned_technician_id', $this->selectedTechnician);
}
$appointments = $query->orderBy('scheduled_datetime')->get();
// Group appointments by date and convert to array for blade template
$this->appointments = $appointments->groupBy(function ($appointment) {
return $appointment->scheduled_datetime->format('Y-m-d');
})->map(function ($dayAppointments) {
return $dayAppointments->map(function ($appointment) {
return [
'id' => $appointment->id,
'scheduled_datetime' => $appointment->scheduled_datetime->toISOString(),
'service_requested' => $appointment->service_requested,
'status' => $appointment->status,
'status_color' => $appointment->status_color,
'customer' => [
'first_name' => $appointment->customer->first_name ?? '',
'last_name' => $appointment->customer->last_name ?? '',
],
'assigned_technician' => [
'first_name' => $appointment->assignedTechnician->first_name ?? '',
'last_name' => $appointment->assignedTechnician->last_name ?? '',
],
];
})->toArray();
})->toArray();
}
public function getTimeSlots()
{
$slots = [];
$start = 8; // 8 AM
$end = 18; // 6 PM
for ($hour = $start; $hour < $end; $hour++) {
$slots[] = [
'time' => sprintf('%02d:00', $hour),
'label' => Carbon::createFromTime($hour, 0)->format('g:i A'),
];
$slots[] = [
'time' => sprintf('%02d:30', $hour),
'label' => Carbon::createFromTime($hour, 30)->format('g:i A'),
];
}
return $slots;
}
public function getCurrentPeriodLabel()
{
switch ($this->viewType) {
case 'month':
return $this->currentDate->format('F Y');
case 'week':
$start = $this->currentDate->copy()->startOfWeek();
$end = $this->currentDate->copy()->endOfWeek();
return $start->format('M j') . ' - ' . $end->format('M j, Y');
case 'day':
return $this->currentDate->format('l, F j, Y');
}
}
public function render()
{
return view('livewire.appointments.calendar', [
'currentPeriodLabel' => $this->getCurrentPeriodLabel(),
'timeSlots' => $this->getTimeSlots(),
'appointmentCount' => collect($this->appointments)->flatten(1)->count(),
]);
}
}

View File

@ -0,0 +1,229 @@
<?php
namespace App\Livewire\Appointments;
use Livewire\Component;
use App\Models\Appointment;
use App\Models\Customer;
use App\Models\Vehicle;
use App\Models\Technician;
use Carbon\Carbon;
class Create extends Component
{
// Form fields
public $customer_id = '';
public $vehicle_id = '';
public $assigned_technician_id = '';
public $scheduled_date = '';
public $scheduled_time = '';
public $estimated_duration_minutes = 60;
public $appointment_type = 'maintenance';
public $service_requested = '';
public $customer_notes = '';
public $internal_notes = '';
// Options
public $customers = [];
public $vehicles = [];
public $technicians = [];
public $availableTimeSlots = [];
public $appointmentTypes = [
'maintenance' => 'Maintenance',
'repair' => 'Repair',
'inspection' => 'Inspection',
'estimate' => 'Estimate',
'pickup' => 'Pickup',
'delivery' => 'Delivery'
];
public $durationOptions = [
30 => '30 minutes',
60 => '1 hour',
90 => '1.5 hours',
120 => '2 hours',
150 => '2.5 hours',
180 => '3 hours',
240 => '4 hours',
];
protected $rules = [
'customer_id' => 'required|exists:customers,id',
'vehicle_id' => 'required|exists:vehicles,id',
'scheduled_date' => 'required|date|after_or_equal:today',
'scheduled_time' => 'required',
'estimated_duration_minutes' => 'required|integer|min:15|max:480',
'appointment_type' => 'required|in:maintenance,repair,inspection,estimate,pickup,delivery',
'service_requested' => 'required|string|max:500',
'customer_notes' => 'nullable|string|max:1000',
'internal_notes' => 'nullable|string|max:1000',
];
protected $messages = [
'customer_id.required' => 'Please select a customer.',
'vehicle_id.required' => 'Please select a vehicle.',
'scheduled_date.required' => 'Please select an appointment date.',
'scheduled_date.after_or_equal' => 'Appointment date cannot be in the past.',
'scheduled_time.required' => 'Please select an appointment time.',
'service_requested.required' => 'Please describe the service requested.',
];
public function mount()
{
$this->loadInitialData();
$this->scheduled_date = Carbon::tomorrow()->format('Y-m-d');
}
public function loadInitialData()
{
$this->customers = Customer::orderBy('first_name')->orderBy('last_name')->get();
$this->technicians = Technician::where('status', 'active')->orderBy('first_name')->orderBy('last_name')->get();
}
public function updatedCustomerId()
{
if ($this->customer_id) {
$this->vehicles = Vehicle::where('customer_id', $this->customer_id)->get();
$this->vehicle_id = '';
} else {
$this->vehicles = [];
$this->vehicle_id = '';
}
}
public function updatedScheduledDate()
{
if ($this->scheduled_date) {
$this->loadAvailableTimeSlots();
}
}
public function updatedAssignedTechnicianId()
{
if ($this->scheduled_date) {
$this->loadAvailableTimeSlots();
}
}
public function loadAvailableTimeSlots()
{
$date = Carbon::parse($this->scheduled_date);
$technicianId = $this->assigned_technician_id;
// Generate time slots from 8 AM to 5 PM
$slots = [];
$startTime = $date->copy()->setTime(8, 0);
$endTime = $date->copy()->setTime(17, 0);
while ($startTime->lt($endTime)) {
$timeSlot = $startTime->format('H:i');
// Check if this time slot is available for the technician
$isAvailable = true;
if ($technicianId) {
$startDateTime = Carbon::parse($this->scheduled_date . ' ' . $timeSlot);
$endDateTime = $startDateTime->copy()->addMinutes($this->estimated_duration_minutes);
$conflictingAppointments = Appointment::where('assigned_technician_id', $technicianId)
->where(function($query) use ($startDateTime, $endDateTime) {
$query->where(function($q) use ($startDateTime, $endDateTime) {
// Check if new appointment overlaps with existing ones
$q->where(function($subQ) use ($startDateTime, $endDateTime) {
// New appointment starts during existing appointment
$subQ->where('scheduled_datetime', '<=', $startDateTime)
->whereRaw('DATE_ADD(scheduled_datetime, INTERVAL estimated_duration_minutes MINUTE) > ?', [$startDateTime]);
})->orWhere(function($subQ) use ($startDateTime, $endDateTime) {
// New appointment ends during existing appointment
$subQ->where('scheduled_datetime', '<', $endDateTime)
->where('scheduled_datetime', '>=', $startDateTime);
});
});
})
->exists();
$isAvailable = !$conflictingAppointments;
}
if ($isAvailable) {
$slots[] = [
'value' => $timeSlot,
'label' => $startTime->format('g:i A'),
];
}
$startTime->addMinutes(30);
}
$this->availableTimeSlots = $slots;
}
public function save()
{
$this->validate();
try {
// Check for conflicts one more time
$scheduledDateTime = Carbon::parse($this->scheduled_date . ' ' . $this->scheduled_time);
$endDateTime = $scheduledDateTime->copy()->addMinutes($this->estimated_duration_minutes);
if ($this->assigned_technician_id) {
$conflicts = Appointment::where('assigned_technician_id', $this->assigned_technician_id)
->where(function($query) use ($scheduledDateTime, $endDateTime) {
$query->where(function($q) use ($scheduledDateTime, $endDateTime) {
// Check if new appointment overlaps with existing ones
$q->where(function($subQ) use ($scheduledDateTime, $endDateTime) {
// New appointment starts during existing appointment
$subQ->where('scheduled_datetime', '<=', $scheduledDateTime)
->whereRaw('DATE_ADD(scheduled_datetime, INTERVAL estimated_duration_minutes MINUTE) > ?', [$scheduledDateTime]);
})->orWhere(function($subQ) use ($scheduledDateTime, $endDateTime) {
// New appointment ends during existing appointment
$subQ->where('scheduled_datetime', '<', $endDateTime)
->where('scheduled_datetime', '>=', $scheduledDateTime);
})->orWhere(function($subQ) use ($scheduledDateTime, $endDateTime) {
// New appointment completely contains existing appointment
$subQ->where('scheduled_datetime', '>=', $scheduledDateTime)
->whereRaw('DATE_ADD(scheduled_datetime, INTERVAL estimated_duration_minutes MINUTE) <= ?', [$endDateTime]);
});
});
})
->exists();
if ($conflicts) {
$this->addError('scheduled_time', 'This time slot conflicts with another appointment for the selected technician.');
return;
}
}
// Combine date and time into scheduled_datetime
$scheduledDateTime = Carbon::parse($this->scheduled_date . ' ' . $this->scheduled_time);
$appointment = Appointment::create([
'customer_id' => $this->customer_id,
'vehicle_id' => $this->vehicle_id,
'assigned_technician_id' => $this->assigned_technician_id ?: null,
'scheduled_datetime' => $scheduledDateTime,
'estimated_duration_minutes' => $this->estimated_duration_minutes,
'appointment_type' => $this->appointment_type,
'service_requested' => $this->service_requested,
'customer_notes' => $this->customer_notes,
'internal_notes' => $this->internal_notes,
'status' => 'scheduled',
'created_by' => auth()->id(),
]);
session()->flash('message', 'Appointment scheduled successfully!');
return redirect()->route('appointments.index');
} catch (\Exception $e) {
$this->addError('general', 'Error creating appointment: ' . $e->getMessage());
}
}
public function render()
{
return view('livewire.appointments.create')->layout('components.layouts.app', [
'title' => 'Schedule Appointment'
]);
}
}

View File

@ -0,0 +1,204 @@
<?php
namespace App\Livewire\Appointments;
use Livewire\Component;
use Livewire\Attributes\On;
use App\Models\Appointment;
use App\Models\Customer;
use App\Models\Vehicle;
use App\Models\Technician;
use Carbon\Carbon;
class Form extends Component
{
public $showModal = true;
public $appointment = null;
public $editing = false;
// Form fields
public $customer_id = '';
public $vehicle_id = '';
public $assigned_technician_id = '';
public $scheduled_date = '';
public $scheduled_time = '';
public $estimated_duration_minutes = 60;
public $appointment_type = 'maintenance';
public $service_requested = '';
public $customer_notes = '';
public $internal_notes = '';
// Options
public $customers = [];
public $vehicles = [];
public $technicians = [];
public $availableTimeSlots = [];
public $appointmentTypes = [
'maintenance' => 'Maintenance',
'repair' => 'Repair',
'inspection' => 'Inspection',
'estimate' => 'Estimate',
'pickup' => 'Pickup',
'delivery' => 'Delivery'
];
public $durationOptions = [
30 => '30 minutes',
60 => '1 hour',
90 => '1.5 hours',
120 => '2 hours',
180 => '3 hours',
240 => '4 hours',
300 => '5 hours',
360 => '6 hours',
480 => '8 hours'
];
protected $rules = [
'customer_id' => 'required|exists:customers,id',
'vehicle_id' => 'required|exists:vehicles,id',
'assigned_technician_id' => 'nullable|exists:technicians,id',
'scheduled_date' => 'required|date|after_or_equal:today',
'scheduled_time' => 'required|date_format:H:i',
'estimated_duration_minutes' => 'required|integer|min:15|max:480',
'appointment_type' => 'required|in:maintenance,repair,inspection,estimate,pickup,delivery',
'service_requested' => 'required|string|max:1000',
'customer_notes' => 'nullable|string|max:1000',
'internal_notes' => 'nullable|string|max:1000'
];
public function mount($appointment = null)
{
$this->customers = Customer::orderBy('first_name')->get();
$this->technicians = Technician::where('status', 'active')->orderBy('first_name')->get();
if ($appointment) {
$this->appointment = $appointment;
$this->editing = true;
$this->loadAppointmentData();
} else {
$this->scheduled_date = now()->addDay()->format('Y-m-d');
$this->scheduled_time = '09:00';
}
}
public function loadAppointmentData()
{
$this->customer_id = $this->appointment->customer_id;
$this->vehicle_id = $this->appointment->vehicle_id;
$this->assigned_technician_id = $this->appointment->assigned_technician_id;
$this->scheduled_date = $this->appointment->scheduled_datetime->format('Y-m-d');
$this->scheduled_time = $this->appointment->scheduled_datetime->format('H:i');
$this->estimated_duration_minutes = $this->appointment->estimated_duration_minutes;
$this->appointment_type = $this->appointment->appointment_type;
$this->service_requested = $this->appointment->service_requested;
$this->customer_notes = $this->appointment->customer_notes;
$this->internal_notes = $this->appointment->internal_notes;
$this->loadVehicles();
}
public function updatedCustomerId()
{
$this->vehicle_id = '';
$this->loadVehicles();
}
public function updatedScheduledDate()
{
$this->checkTimeSlotAvailability();
}
public function updatedScheduledTime()
{
$this->checkTimeSlotAvailability();
}
public function updatedAssignedTechnicianId()
{
$this->checkTimeSlotAvailability();
}
public function loadVehicles()
{
if ($this->customer_id) {
$this->vehicles = Vehicle::where('customer_id', $this->customer_id)->get();
} else {
$this->vehicles = [];
}
}
public function checkTimeSlotAvailability()
{
if (!$this->scheduled_date || !$this->scheduled_time || !$this->assigned_technician_id) {
return;
}
$scheduledDateTime = Carbon::parse($this->scheduled_date . ' ' . $this->scheduled_time);
$endDateTime = $scheduledDateTime->copy()->addMinutes($this->estimated_duration_minutes);
// Check for conflicts
$conflicts = Appointment::where('assigned_technician_id', $this->assigned_technician_id)
->where('status', '!=', 'cancelled')
->where(function ($query) use ($scheduledDateTime, $endDateTime) {
$query->whereBetween('scheduled_datetime', [$scheduledDateTime, $endDateTime])
->orWhere(function ($q) use ($scheduledDateTime, $endDateTime) {
$q->where('scheduled_datetime', '<=', $scheduledDateTime)
->whereRaw('DATE_ADD(scheduled_datetime, INTERVAL estimated_duration_minutes MINUTE) > ?', [$scheduledDateTime]);
});
});
if ($this->editing && $this->appointment) {
$conflicts->where('id', '!=', $this->appointment->id);
}
if ($conflicts->exists()) {
$this->addError('scheduled_time', 'This time slot conflicts with another appointment for the selected technician.');
} else {
$this->resetErrorBag('scheduled_time');
}
}
public function save()
{
$this->validate();
$scheduledDateTime = Carbon::parse($this->scheduled_date . ' ' . $this->scheduled_time);
$data = [
'customer_id' => $this->customer_id,
'vehicle_id' => $this->vehicle_id,
'assigned_technician_id' => $this->assigned_technician_id ?: null,
'scheduled_datetime' => $scheduledDateTime,
'estimated_duration_minutes' => $this->estimated_duration_minutes,
'appointment_type' => $this->appointment_type,
'service_requested' => $this->service_requested,
'customer_notes' => $this->customer_notes,
'internal_notes' => $this->internal_notes,
'status' => 'scheduled'
];
if ($this->editing && $this->appointment) {
$this->appointment->update($data);
session()->flash('message', 'Appointment updated successfully!');
} else {
Appointment::create($data);
session()->flash('message', 'Appointment scheduled successfully!');
}
$this->dispatch('appointment-saved');
$this->closeModal();
}
public function closeModal()
{
$this->showModal = false;
$this->dispatch('close-appointment-form');
}
public function render()
{
return view('livewire.appointments.form');
}
}

View File

@ -0,0 +1,237 @@
<?php
namespace App\Livewire\Appointments;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\On;
use App\Models\Appointment;
use App\Models\Technician;
use Carbon\Carbon;
class Index extends Component
{
use WithPagination;
public $search = '';
public $statusFilter = '';
public $technicianFilter = '';
public $dateFilter = '';
public $typeFilter = '';
public $view = 'list'; // list or calendar
public $selectedDate;
public $showForm = false;
public $selectedAppointment = null;
protected $queryString = [
'search' => ['except' => ''],
'statusFilter' => ['except' => ''],
'technicianFilter' => ['except' => ''],
'dateFilter' => ['except' => ''],
'typeFilter' => ['except' => ''],
'view' => ['except' => 'list']
];
public function mount()
{
$this->selectedDate = now()->format('Y-m-d');
}
public function updatingSearch()
{
$this->resetPage();
}
public function updatingStatusFilter()
{
$this->resetPage();
}
public function updatingTechnicianFilter()
{
$this->resetPage();
}
public function updatingDateFilter()
{
$this->resetPage();
}
public function updatingTypeFilter()
{
$this->resetPage();
}
public function setView($view)
{
$this->view = $view;
}
public function clearFilters()
{
$this->search = '';
$this->statusFilter = '';
$this->technicianFilter = '';
$this->dateFilter = '';
$this->typeFilter = '';
$this->resetPage();
}
public function createAppointment()
{
$this->selectedAppointment = null;
$this->showForm = true;
}
public function editAppointment($appointmentId)
{
$this->selectedAppointment = Appointment::find($appointmentId);
$this->showForm = true;
}
#[On('appointment-saved')]
public function refreshAppointments()
{
$this->showForm = false;
$this->resetPage();
}
#[On('close-appointment-form')]
public function closeForm()
{
$this->showForm = false;
}
#[On('appointment-updated')]
public function refreshData()
{
// This will trigger a re-render
}
public function confirmAppointment($appointmentId)
{
$appointment = Appointment::find($appointmentId);
if ($appointment && $appointment->confirm()) {
session()->flash('message', 'Appointment confirmed successfully!');
$this->dispatch('appointment-updated');
}
}
public function checkInAppointment($appointmentId)
{
$appointment = Appointment::find($appointmentId);
if ($appointment && $appointment->checkIn()) {
session()->flash('message', 'Customer checked in successfully!');
$this->dispatch('appointment-updated');
}
}
public function completeAppointment($appointmentId)
{
$appointment = Appointment::find($appointmentId);
if ($appointment && $appointment->complete()) {
session()->flash('message', 'Appointment completed successfully!');
$this->dispatch('appointment-updated');
}
}
public function cancelAppointment($appointmentId)
{
$appointment = Appointment::find($appointmentId);
if ($appointment && $appointment->cancel()) {
session()->flash('message', 'Appointment cancelled successfully!');
$this->dispatch('appointment-updated');
}
}
public function markNoShow($appointmentId)
{
$appointment = Appointment::find($appointmentId);
if ($appointment && $appointment->markNoShow()) {
session()->flash('message', 'Appointment marked as no-show.');
$this->dispatch('appointment-updated');
}
}
public function getAppointmentsProperty()
{
$query = Appointment::query()
->with(['customer', 'vehicle', 'assignedTechnician'])
->when($this->search, function ($q) {
$q->whereHas('customer', function ($customer) {
$customer->where('first_name', 'like', '%' . $this->search . '%')
->orWhere('last_name', 'like', '%' . $this->search . '%')
->orWhere('email', 'like', '%' . $this->search . '%');
})->orWhereHas('vehicle', function ($vehicle) {
$vehicle->where('make', 'like', '%' . $this->search . '%')
->orWhere('model', 'like', '%' . $this->search . '%')
->orWhere('license_plate', 'like', '%' . $this->search . '%');
})->orWhere('service_requested', 'like', '%' . $this->search . '%');
})
->when($this->statusFilter, function ($q) {
$q->where('status', $this->statusFilter);
})
->when($this->technicianFilter, function ($q) {
$q->where('assigned_technician_id', $this->technicianFilter);
})
->when($this->typeFilter, function ($q) {
$q->where('appointment_type', $this->typeFilter);
})
->when($this->dateFilter, function ($q) {
switch ($this->dateFilter) {
case 'today':
$q->whereDate('scheduled_datetime', today());
break;
case 'tomorrow':
$q->whereDate('scheduled_datetime', today()->addDay());
break;
case 'this_week':
$q->whereBetween('scheduled_datetime', [
now()->startOfWeek(),
now()->endOfWeek()
]);
break;
case 'next_week':
$q->whereBetween('scheduled_datetime', [
now()->addWeek()->startOfWeek(),
now()->addWeek()->endOfWeek()
]);
break;
case 'overdue':
$q->where('status', 'scheduled')
->where('scheduled_datetime', '<', now());
break;
}
})
->orderBy('scheduled_datetime', 'asc');
return $query->paginate(15);
}
public function getTechniciansProperty()
{
return Technician::where('status', 'active')
->orderBy('first_name')
->get();
}
public function getTodayStatsProperty()
{
$today = today();
return [
'total' => Appointment::whereDate('scheduled_datetime', $today)->count(),
'confirmed' => Appointment::whereDate('scheduled_datetime', $today)->where('status', 'confirmed')->count(),
'in_progress' => Appointment::whereDate('scheduled_datetime', $today)->where('status', 'in_progress')->count(),
'completed' => Appointment::whereDate('scheduled_datetime', $today)->where('status', 'completed')->count(),
];
}
public function render()
{
return view('livewire.appointments.index', [
'appointments' => $this->appointments,
'technicians' => $this->technicians,
'todayStats' => $this->todayStats,
]);
}
}

View File

@ -0,0 +1,284 @@
<?php
namespace App\Livewire\Appointments;
use Livewire\Component;
use App\Models\Appointment;
use App\Models\Technician;
use Carbon\Carbon;
class TimeSlots extends Component
{
public $selectedDate;
public $selectedTechnician = '';
public $serviceDuration = 60; // minutes
public $availableSlots = [];
public $bookedSlots = [];
public $selectedSlot = '';
public $timeSlots = [];
public $technicians = [];
// Business hours configuration
public $businessStart = '08:00';
public $businessEnd = '18:00';
public $slotInterval = 30; // minutes
public $lunchStart = '12:00';
public $lunchEnd = '13:00';
public function mount($date = null, $technicianId = null, $duration = 60)
{
$this->selectedDate = $date ?: now()->addDay()->format('Y-m-d');
$this->selectedTechnician = $technicianId ?: '';
$this->serviceDuration = $duration;
$this->technicians = Technician::where('status', 'active')->orderBy('first_name')->get();
$this->generateTimeSlots();
$this->loadBookedSlots();
$this->calculateAvailableSlots();
}
public function updatedSelectedDate()
{
$this->selectedSlot = '';
$this->generateTimeSlots();
$this->loadBookedSlots();
$this->calculateAvailableSlots();
$this->dispatch('date-changed', date: $this->selectedDate);
}
public function updatedSelectedTechnician()
{
$this->selectedSlot = '';
$this->loadBookedSlots();
$this->calculateAvailableSlots();
$this->dispatch('technician-changed', technicianId: $this->selectedTechnician);
}
public function updatedServiceDuration()
{
$this->selectedSlot = '';
$this->calculateAvailableSlots();
}
public function selectSlot($time)
{
$this->selectedSlot = $time;
$this->dispatch('slot-selected', [
'date' => $this->selectedDate,
'time' => $time,
'technician_id' => $this->selectedTechnician,
'duration' => $this->serviceDuration
]);
}
public function clearSelection()
{
$this->selectedSlot = '';
$this->dispatch('slot-cleared');
}
private function generateTimeSlots()
{
$this->timeSlots = [];
$date = Carbon::parse($this->selectedDate);
// Don't show slots for past dates
if ($date->isPast() && !$date->isToday()) {
return;
}
$start = $date->copy()->setTimeFromTimeString($this->businessStart);
$end = $date->copy()->setTimeFromTimeString($this->businessEnd);
$lunchStart = $date->copy()->setTimeFromTimeString($this->lunchStart);
$lunchEnd = $date->copy()->setTimeFromTimeString($this->lunchEnd);
$current = $start->copy();
while ($current < $end) {
$timeString = $current->format('H:i');
// Skip lunch time
if ($current >= $lunchStart && $current < $lunchEnd) {
$current->addMinutes($this->slotInterval);
continue;
}
// Skip past times for today
if ($date->isToday() && $current <= now()) {
$current->addMinutes($this->slotInterval);
continue;
}
$this->timeSlots[] = [
'time' => $timeString,
'label' => $current->format('g:i A'),
'datetime' => $current->copy(),
];
$current->addMinutes($this->slotInterval);
}
}
private function loadBookedSlots()
{
$query = Appointment::whereDate('scheduled_datetime', $this->selectedDate)
->whereNotIn('status', ['cancelled', 'no_show']);
if ($this->selectedTechnician) {
$query->where('assigned_technician_id', $this->selectedTechnician);
}
$appointments = $query->get();
$this->bookedSlots = [];
foreach ($appointments as $appointment) {
$startTime = Carbon::parse($appointment->scheduled_datetime);
$endTime = $startTime->copy()->addMinutes($appointment->estimated_duration_minutes);
// Mark all slots that overlap with this appointment as booked
$current = $startTime->copy();
while ($current < $endTime) {
$this->bookedSlots[] = [
'time' => $current->format('H:i'),
'appointment_id' => $appointment->id,
'customer_name' => $appointment->customer->first_name . ' ' . $appointment->customer->last_name,
'service' => $appointment->service_requested,
'status' => $appointment->status,
];
$current->addMinutes($this->slotInterval);
}
}
}
private function calculateAvailableSlots()
{
$this->availableSlots = [];
$bookedTimes = collect($this->bookedSlots)->pluck('time')->toArray();
foreach ($this->timeSlots as $slot) {
$isAvailable = true;
$slotStart = Carbon::parse($this->selectedDate . ' ' . $slot['time']);
$slotEnd = $slotStart->copy()->addMinutes($this->serviceDuration);
// Check if this slot and required duration would conflict with any booked slot
$checkTime = $slotStart->copy();
while ($checkTime < $slotEnd) {
if (in_array($checkTime->format('H:i'), $bookedTimes)) {
$isAvailable = false;
break;
}
$checkTime->addMinutes($this->slotInterval);
}
// Check if slot extends beyond business hours
$businessEnd = Carbon::parse($this->selectedDate . ' ' . $this->businessEnd);
if ($slotEnd > $businessEnd) {
$isAvailable = false;
}
// Check if slot conflicts with lunch time
$lunchStart = Carbon::parse($this->selectedDate . ' ' . $this->lunchStart);
$lunchEnd = Carbon::parse($this->selectedDate . ' ' . $this->lunchEnd);
if ($slotStart < $lunchEnd && $slotEnd > $lunchStart) {
$isAvailable = false;
}
if ($isAvailable) {
$this->availableSlots[] = $slot['time'];
}
}
}
public function getSlotStatus($time)
{
$bookedSlot = collect($this->bookedSlots)->firstWhere('time', $time);
if ($bookedSlot) {
return [
'status' => 'booked',
'data' => $bookedSlot
];
}
if (in_array($time, $this->availableSlots)) {
return [
'status' => 'available',
'data' => null
];
}
return [
'status' => 'unavailable',
'data' => null
];
}
public function getAvailableSlotsForApi()
{
return collect($this->timeSlots)
->filter(function ($slot) {
return in_array($slot['time'], $this->availableSlots);
})
->values()
->toArray();
}
public function getBookedSlotsInfo()
{
return collect($this->bookedSlots)
->groupBy('time')
->map(function ($slots) {
return $slots->first();
})
->values()
->toArray();
}
public function isSlotSelected($time)
{
return $this->selectedSlot === $time;
}
public function getNextAvailableDate()
{
$date = Carbon::parse($this->selectedDate);
$maxDays = 30; // Look ahead 30 days
for ($i = 1; $i <= $maxDays; $i++) {
$checkDate = $date->copy()->addDays($i);
// Skip weekends (assuming business doesn't operate on weekends)
if ($checkDate->isWeekend()) {
continue;
}
// Generate slots for this date
$tempDate = $this->selectedDate;
$this->selectedDate = $checkDate->format('Y-m-d');
$this->generateTimeSlots();
$this->loadBookedSlots();
$this->calculateAvailableSlots();
if (!empty($this->availableSlots)) {
$nextDate = $this->selectedDate;
$this->selectedDate = $tempDate; // Restore original date
$this->generateTimeSlots();
$this->loadBookedSlots();
$this->calculateAvailableSlots();
return $nextDate;
}
$this->selectedDate = $tempDate; // Restore original date
}
return null;
}
public function render()
{
return view('livewire.appointments.time-slots', [
'nextAvailableDate' => $this->getNextAvailableDate(),
]);
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Livewire\CustomerPortal;
use App\Models\JobCard;
use App\Models\Estimate;
use App\Services\WorkflowService;
use Livewire\Component;
class EstimateView extends Component
{
public JobCard $jobCard;
public Estimate $estimate;
public $customerComments = '';
public function mount(JobCard $jobCard, Estimate $estimate)
{
$this->jobCard = $jobCard->load(['customer', 'vehicle']);
$this->estimate = $estimate->load(['lineItems', 'diagnosis']);
// Mark estimate as viewed
if ($estimate->status === 'sent') {
$estimate->update(['status' => 'viewed']);
}
}
public function approveEstimate()
{
$workflowService = app(WorkflowService::class);
$this->estimate->update([
'customer_approval_status' => 'approved',
'customer_approved_at' => now(),
'customer_approval_method' => 'portal',
'status' => 'approved',
]);
$this->jobCard->update(['status' => 'estimate_approved']);
// Notify relevant staff
$workflowService->notifyStaffOfApproval($this->estimate);
session()->flash('message', 'Estimate approved successfully! We will begin work on your vehicle soon.');
return redirect()->route('customer-portal.status', $this->jobCard);
}
public function rejectEstimate()
{
$this->validate([
'customerComments' => 'required|string|max:1000'
]);
$this->estimate->update([
'customer_approval_status' => 'rejected',
'customer_approved_at' => now(),
'customer_approval_method' => 'portal',
'status' => 'rejected',
'notes' => $this->customerComments,
]);
$this->jobCard->update(['status' => 'estimate_rejected']);
session()->flash('message', 'Estimate rejected. Our team will contact you to discuss alternatives.');
return redirect()->route('customer-portal.status', $this->jobCard);
}
public function render()
{
return view('livewire.customer-portal.estimate-view');
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Livewire\CustomerPortal;
use App\Models\JobCard;
use App\Services\WorkflowService;
use Livewire\Component;
class JobStatus extends Component
{
public JobCard $jobCard;
public $workflowProgress;
public function mount(JobCard $jobCard)
{
$this->jobCard = $jobCard->load([
'customer',
'vehicle',
'serviceAdvisor',
'vehicleInspections',
'diagnoses',
'estimates',
'workOrders.tasks',
'timesheets'
]);
$workflowService = app(WorkflowService::class);
$this->workflowProgress = $workflowService->getWorkflowProgress($this->jobCard);
}
public function refreshStatus()
{
$this->jobCard->refresh();
$workflowService = app(WorkflowService::class);
$this->workflowProgress = $workflowService->getWorkflowProgress($this->jobCard);
session()->flash('message', 'Status updated!');
}
public function render()
{
return view('livewire.customer-portal.job-status');
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Livewire\Customers;
use App\Models\Customer;
use Livewire\Component;
class Create extends Component
{
public $first_name = '';
public $last_name = '';
public $email = '';
public $phone = '';
public $secondary_phone = '';
public $address = '';
public $city = '';
public $state = '';
public $zip_code = '';
public $notes = '';
public $status = 'active';
protected $rules = [
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|unique:customers,email',
'phone' => 'required|string|max:255',
'secondary_phone' => 'nullable|string|max:255',
'address' => 'required|string|max:500',
'city' => 'required|string|max:255',
'state' => 'required|string|max:2',
'zip_code' => 'required|string|max:10',
'notes' => 'nullable|string|max:1000',
'status' => 'required|in:active,inactive',
];
protected $messages = [
'first_name.required' => 'First name is required.',
'last_name.required' => 'Last name is required.',
'email.required' => 'Email address is required.',
'email.email' => 'Please enter a valid email address.',
'email.unique' => 'This email address is already registered.',
'phone.required' => 'Phone number is required.',
'address.required' => 'Address is required.',
'city.required' => 'City is required.',
'state.required' => 'State is required.',
'zip_code.required' => 'ZIP code is required.',
];
public function save()
{
$this->validate();
$customer = Customer::create([
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'email' => $this->email,
'phone' => $this->phone,
'secondary_phone' => $this->secondary_phone,
'address' => $this->address,
'city' => $this->city,
'state' => $this->state,
'zip_code' => $this->zip_code,
'notes' => $this->notes,
'status' => $this->status,
]);
session()->flash('success', 'Customer created successfully!');
return redirect()->route('customers.show', $customer);
}
public function render()
{
return view('livewire.customers.create');
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Livewire\Customers;
use Livewire\Component;
use App\Models\Customer;
use Livewire\Attributes\Validate;
class Edit extends Component
{
public Customer $customer;
#[Validate('required|string|max:255')]
public $first_name = '';
#[Validate('required|string|max:255')]
public $last_name = '';
#[Validate('required|email|max:255|unique:customers,email')]
public $email = '';
#[Validate('required|string|max:20')]
public $phone = '';
#[Validate('nullable|string|max:20')]
public $secondary_phone = '';
#[Validate('required|string|max:500')]
public $address = '';
#[Validate('required|string|max:255')]
public $city = '';
#[Validate('required|string|max:255')]
public $state = '';
#[Validate('required|string|max:10')]
public $zip_code = '';
#[Validate('nullable|string|max:1000')]
public $notes = '';
#[Validate('required|in:active,inactive')]
public $status = 'active';
public function mount(Customer $customer)
{
$this->customer = $customer;
$this->first_name = $customer->first_name;
$this->last_name = $customer->last_name;
$this->email = $customer->email;
$this->phone = $customer->phone;
$this->secondary_phone = $customer->secondary_phone;
$this->address = $customer->address;
$this->city = $customer->city;
$this->state = $customer->state;
$this->zip_code = $customer->zip_code;
$this->notes = $customer->notes;
$this->status = $customer->status;
}
public function updateCustomer()
{
// Update validation rules to exclude current customer's email
$this->validate([
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:customers,email,' . $this->customer->id,
'phone' => 'required|string|max:20',
'secondary_phone' => 'nullable|string|max:20',
'address' => 'required|string|max:500',
'city' => 'required|string|max:255',
'state' => 'required|string|max:255',
'zip_code' => 'required|string|max:10',
'notes' => 'nullable|string|max:1000',
'status' => 'required|in:active,inactive',
]);
$this->customer->update([
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'email' => $this->email,
'phone' => $this->phone,
'secondary_phone' => $this->secondary_phone,
'address' => $this->address,
'city' => $this->city,
'state' => $this->state,
'zip_code' => $this->zip_code,
'notes' => $this->notes,
'status' => $this->status,
]);
session()->flash('success', 'Customer updated successfully!');
return $this->redirect('/customers/' . $this->customer->id, navigate: true);
}
public function render()
{
return view('livewire.customers.edit');
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Livewire\Customers;
use App\Models\Customer;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
{
use WithPagination;
public $search = '';
public $status = '';
public $sortBy = 'created_at';
public $sortDirection = 'desc';
protected $queryString = [
'search' => ['except' => ''],
'status' => ['except' => ''],
'sortBy' => ['except' => 'created_at'],
'sortDirection' => ['except' => 'desc'],
];
public function updatingSearch()
{
$this->resetPage();
}
public function updatingStatus()
{
$this->resetPage();
}
public function sortBy($field)
{
if ($this->sortBy === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $field;
$this->sortDirection = 'asc';
}
}
public function deleteCustomer($customerId)
{
$customer = Customer::findOrFail($customerId);
// Check if customer has any service orders or vehicles
if ($customer->serviceOrders()->count() > 0 || $customer->vehicles()->count() > 0) {
session()->flash('error', 'Cannot delete customer with existing vehicles or service orders. Please remove or transfer them first.');
return;
}
$customerName = $customer->full_name;
$customer->delete();
session()->flash('success', "Customer {$customerName} has been deleted successfully.");
}
public function render()
{
$customers = Customer::query()
->with(['vehicles'])
->when($this->search, function ($query) {
$query->where(function ($q) {
$q->where('first_name', 'like', '%' . $this->search . '%')
->orWhere('last_name', 'like', '%' . $this->search . '%')
->orWhere('email', 'like', '%' . $this->search . '%')
->orWhere('phone', 'like', '%' . $this->search . '%');
});
})
->when($this->status, function ($query) {
$query->where('status', $this->status);
})
->orderBy($this->sortBy, $this->sortDirection)
->paginate(15);
return view('livewire.customers.index', [
'customers' => $customers,
]);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Livewire\Customers;
use App\Models\Customer;
use Livewire\Component;
class Show extends Component
{
public Customer $customer;
public function mount(Customer $customer)
{
$this->customer = $customer->load(['vehicles', 'serviceOrders.assignedTechnician', 'appointments']);
}
public function render()
{
return view('livewire.customers.show');
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Livewire\Dashboard;
use App\Models\Appointment;
use App\Models\JobCard;
use Livewire\Component;
class DailySchedule extends Component
{
public function render()
{
$today = now()->startOfDay();
$schedule = [
'appointments' => Appointment::whereDate('scheduled_datetime', $today)
->with(['customer', 'vehicle'])
->orderBy('scheduled_datetime')
->get(),
'pickups' => JobCard::where('status', 'completed')
->whereDate('expected_completion_date', $today)
->with(['customer', 'vehicle'])
->get(),
'overdue' => JobCard::where('expected_completion_date', '<', now())
->whereNotIn('status', ['completed', 'delivered', 'cancelled'])
->with(['customer', 'vehicle'])
->limit(5)
->get(),
];
return view('livewire.dashboard.daily-schedule', compact('schedule'));
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Livewire\Dashboard;
use App\Models\Customer;
use App\Models\ServiceOrder;
use App\Models\Appointment;
use App\Models\Vehicle;
use Carbon\Carbon;
use Livewire\Component;
class Overview extends Component
{
public $stats = [];
public $recentServiceOrders = [];
public $todayAppointments = [];
public $pendingOrders = [];
public function mount()
{
$this->loadStats();
$this->loadRecentData();
}
public function loadStats()
{
$this->stats = [
'total_customers' => Customer::where('status', 'active')->count(),
'total_vehicles' => Vehicle::where('status', 'active')->count(),
'pending_orders' => ServiceOrder::whereIn('status', ['pending', 'in_progress'])->count(),
'today_appointments' => Appointment::whereDate('scheduled_datetime', today())->count(),
'monthly_revenue' => ServiceOrder::where('status', 'completed')
->whereMonth('completed_at', now()->month)
->sum('total_amount'),
'orders_this_week' => ServiceOrder::whereBetween('created_at', [
now()->startOfWeek(),
now()->endOfWeek()
])->count(),
];
}
public function loadRecentData()
{
$this->recentServiceOrders = ServiceOrder::with(['customer', 'vehicle', 'assignedTechnician'])
->latest()
->take(5)
->get();
$this->todayAppointments = Appointment::with(['customer', 'vehicle'])
->whereDate('scheduled_datetime', today())
->orderBy('scheduled_datetime')
->get();
$this->pendingOrders = ServiceOrder::with(['customer', 'vehicle'])
->whereIn('status', ['pending', 'waiting_approval'])
->orderBy('created_at', 'desc')
->take(5)
->get();
}
public function render()
{
return view('livewire.dashboard.overview');
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Livewire\Dashboard;
use App\Models\JobCard;
use App\Models\Estimate;
use App\Models\WorkOrder;
use Livewire\Component;
use Carbon\Carbon;
class PerformanceMetrics extends Component
{
public function render()
{
$currentWeek = now()->startOfWeek();
$lastWeek = now()->subWeek()->startOfWeek();
$metrics = [
'this_week' => [
'jobs_completed' => JobCard::whereBetween('completion_datetime', [$currentWeek, $currentWeek->copy()->endOfWeek()])
->where('status', 'completed')
->count(),
'revenue' => Estimate::whereBetween('customer_approved_at', [$currentWeek, $currentWeek->copy()->endOfWeek()])
->where('customer_approval_status', 'approved')
->sum('total_amount'),
'avg_completion_time' => $this->getAverageCompletionTime($currentWeek, $currentWeek->copy()->endOfWeek()),
'customer_satisfaction' => 4.2, // This would come from a customer feedback system
],
'last_week' => [
'jobs_completed' => JobCard::whereBetween('completion_datetime', [$lastWeek, $lastWeek->copy()->endOfWeek()])
->where('status', 'completed')
->count(),
'revenue' => Estimate::whereBetween('customer_approved_at', [$lastWeek, $lastWeek->copy()->endOfWeek()])
->where('customer_approval_status', 'approved')
->sum('total_amount'),
],
];
// Calculate growth percentages
$metrics['growth'] = [
'jobs' => $this->calculateGrowth($metrics['last_week']['jobs_completed'], $metrics['this_week']['jobs_completed']),
'revenue' => $this->calculateGrowth($metrics['last_week']['revenue'], $metrics['this_week']['revenue']),
];
return view('livewire.dashboard.performance-metrics', compact('metrics'));
}
private function getAverageCompletionTime($start, $end)
{
$completedJobs = JobCard::whereBetween('completion_datetime', [$start, $end])
->where('status', 'completed')
->whereNotNull('arrival_datetime')
->whereNotNull('completion_datetime')
->get();
if ($completedJobs->isEmpty()) {
return 0;
}
$totalHours = $completedJobs->sum(function ($job) {
return $job->arrival_datetime->diffInHours($job->completion_datetime);
});
return round($totalHours / $completedJobs->count(), 1);
}
private function calculateGrowth($previous, $current)
{
if ($previous == 0) {
return $current > 0 ? 100 : 0;
}
return round((($current - $previous) / $previous) * 100, 1);
}
}

View File

@ -0,0 +1,130 @@
<?php
namespace App\Livewire\Dashboard;
use App\Models\JobCard;
use App\Models\Estimate;
use App\Models\WorkOrder;
use App\Models\VehicleInspection;
use Livewire\Component;
class WorkflowOverview extends Component
{
public function render()
{
$user = auth()->user();
// Get counts based on user role
$stats = [
'pending_inspection' => JobCard::where('status', 'pending_inspection')->count(),
'assigned_for_diagnosis' => JobCard::where('status', 'assigned_for_diagnosis')->count(),
'diagnosis_in_progress' => JobCard::where('status', 'diagnosis_in_progress')->count(),
'estimates_pending_approval' => Estimate::where('customer_approval_status', 'pending')->count(),
'work_orders_active' => WorkOrder::whereIn('status', ['scheduled', 'in_progress'])->count(),
'quality_inspections_pending' => JobCard::where('status', 'quality_inspection')->count(),
];
// Get role-specific data
$roleSpecificData = $this->getRoleSpecificData($user);
// Get recent activity
$recentJobCards = JobCard::with(['customer', 'vehicle'])
->latest()
->limit(5)
->get();
return view('livewire.dashboard.workflow-overview', [
'stats' => $stats,
'roleSpecificData' => $roleSpecificData,
'recentJobCards' => $recentJobCards,
]);
}
private function getRoleSpecificData($user)
{
// Use the RBAC system instead of hardcoded roles
$userRoles = $user->roles->pluck('name')->toArray();
if ($user->hasPermission('dashboard.supervisor-view')) {
return [
'title' => 'Service Supervisor Dashboard',
'tasks' => [
'Pending Inspections' => JobCard::where('status', 'pending_inspection')->count(),
'Quality Inspections' => JobCard::where('status', 'quality_inspection')->count(),
'Jobs Awaiting Assignment' => JobCard::whereNull('assigned_technician_id')->count(),
'Overdue Jobs' => JobCard::where('estimated_completion', '<', now())->whereNotIn('status', ['completed', 'delivered'])->count(),
],
'priority_jobs' => JobCard::where('priority', 'urgent')->where('status', '!=', 'completed')->with(['customer', 'vehicle'])->limit(5)->get(),
];
}
if ($user->hasPermission('dashboard.technician-view')) {
return [
'title' => 'Technician Dashboard',
'tasks' => [
'My Assigned Jobs' => JobCard::where('assigned_technician_id', $user->id)->whereNotIn('status', ['completed', 'delivered'])->count(),
'Diagnosis Pending' => JobCard::where('assigned_technician_id', $user->id)->where('status', 'assigned_for_diagnosis')->count(),
'Work in Progress' => JobCard::where('assigned_technician_id', $user->id)->where('status', 'work_in_progress')->count(),
'Quality Checks' => JobCard::where('assigned_technician_id', $user->id)->where('status', 'quality_inspection')->count(),
],
'my_jobs' => JobCard::where('assigned_technician_id', $user->id)->whereNotIn('status', ['completed', 'delivered'])->with(['customer', 'vehicle'])->limit(5)->get(),
];
}
if ($user->hasPermission('dashboard.parts-view')) {
return [
'title' => 'Parts Manager Dashboard',
'tasks' => [
'Parts Orders Pending' => Estimate::where('customer_approval_status', 'approved')->whereHas('jobCard', function($q) {
$q->where('status', 'estimate_approved');
})->count(),
'Estimates Awaiting Parts' => Estimate::where('status', 'pending_parts')->count(),
'Purchase Orders Active' => 0, // Would connect to purchase order system
'Stock Alerts' => 0, // Would connect to inventory system
],
'approved_estimates' => Estimate::where('customer_approval_status', 'approved')->with(['jobCard.customer', 'jobCard.vehicle'])->limit(5)->get(),
];
}
if ($user->hasPermission('dashboard.advisor-view')) {
return [
'title' => 'Service Advisor Dashboard',
'tasks' => [
'My Job Cards' => JobCard::where('service_advisor_id', $user->id)->whereNotIn('status', ['completed', 'delivered'])->count(),
'Customer Follow-ups' => Estimate::where('status', 'sent')->whereHas('jobCard', function($q) use ($user) {
$q->where('service_advisor_id', $user->id);
})->count(),
'Ready for Pickup' => JobCard::where('service_advisor_id', $user->id)->where('status', 'completed')->count(),
'Overdue Estimates' => Estimate::where('created_at', '<', now()->subDays(3))->where('customer_approval_status', 'pending')->count(),
],
'my_customers' => JobCard::where('service_advisor_id', $user->id)->with(['customer', 'vehicle'])->limit(5)->get(),
];
}
// Default dashboard for general users
return [
'title' => 'Dashboard Overview',
'tasks' => [
'Today\'s Jobs' => JobCard::whereDate('created_at', today())->count(),
'This Week\'s Jobs' => JobCard::whereBetween('created_at', [now()->startOfWeek(), now()->endOfWeek()])->count(),
'Active Customers' => JobCard::distinct('customer_id')->whereMonth('created_at', now()->month)->count(),
],
'items' => JobCard::latest()->with(['customer', 'vehicle'])->limit(3)->get(),
];
}
public function getStatusVariant($status)
{
return match($status) {
'pending_inspection' => 'info',
'assigned_for_diagnosis', 'diagnosis_in_progress' => 'warning',
'estimate_sent', 'estimate_pending' => 'neutral',
'work_in_progress' => 'primary',
'quality_inspection' => 'secondary',
'completed' => 'success',
'delivered' => 'success',
'cancelled' => 'danger',
default => 'neutral'
};
}
}

View File

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

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Diagnosis;
use Livewire\Component;
class Edit extends Component
{
public function render()
{
return view('livewire.diagnosis.edit');
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Livewire\Diagnosis;
use Livewire\Component;
use App\Models\Diagnosis;
class Index extends Component
{
public function render()
{
$diagnoses = Diagnosis::with(['jobCard', 'serviceCoordinator'])->paginate(20);
return view('livewire.diagnosis.index', compact('diagnoses'));
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Diagnosis;
use App\Models\Diagnosis;
use Livewire\Component;
class Show extends Component
{
public Diagnosis $diagnosis;
public function mount(Diagnosis $diagnosis)
{
$this->diagnosis = $diagnosis->load([
'jobCard.customer',
'jobCard.vehicle',
'serviceCoordinator',
'estimate'
]);
}
public function createEstimate()
{
return redirect()->route('estimates.create', $this->diagnosis);
}
public function render()
{
return view('livewire.diagnosis.show');
}
}

View File

@ -0,0 +1,193 @@
<?php
namespace App\Livewire\Estimates;
use App\Models\Diagnosis;
use App\Models\Estimate;
use App\Models\EstimateLineItem;
use App\Models\Part;
use App\Notifications\EstimateNotification;
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 = [
'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(Diagnosis $diagnosis)
{
$this->diagnosis = $diagnosis->load([
'jobCard.customer',
'jobCard.vehicle'
]);
// Pre-populate from diagnosis
$this->initializeLineItems();
$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.'
);
}
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,
];
}
// Add parts from diagnosis
foreach ($this->diagnosis->parts_required as $part) {
$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,
'required' => true,
];
}
$this->calculateTotals();
}
public function addLineItem()
{
$this->lineItems[] = [
'type' => 'labor',
'description' => '',
'quantity' => 1,
'unit_price' => 0,
'total_amount' => 0,
'required' => true,
];
}
public function removeLineItem($index)
{
unset($this->lineItems[$index]);
$this->lineItems = array_values($this->lineItems);
$this->calculateTotals();
}
public function updatedLineItems()
{
$this->calculateTotals();
}
public function calculateTotals()
{
$this->subtotal = collect($this->lineItems)->sum(function ($item) {
return ($item['quantity'] ?? 0) * ($item['unit_price'] ?? 0);
});
$this->tax_amount = ($this->subtotal - $this->discount_amount) * ($this->tax_rate / 100);
$this->total_amount = $this->subtotal - $this->discount_amount + $this->tax_amount;
// Update individual line item totals
foreach ($this->lineItems as $index => &$item) {
$item['total_amount'] = ($item['quantity'] ?? 0) * ($item['unit_price'] ?? 0);
}
}
public function save()
{
$this->validate();
$this->calculateTotals();
// 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);
$estimate = Estimate::create([
'estimate_number' => $estimateNumber,
'job_card_id' => $this->diagnosis->job_card_id,
'diagnosis_id' => $this->diagnosis->id,
'prepared_by_id' => auth()->id(),
'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'),
'subtotal' => $this->subtotal,
'tax_rate' => $this->tax_rate,
'tax_amount' => $this->tax_amount,
'discount_amount' => $this->discount_amount,
'total_amount' => $this->total_amount,
'validity_period_days' => $this->validity_period_days,
'terms_and_conditions' => $this->terms_and_conditions,
'notes' => $this->notes,
'internal_notes' => $this->internal_notes,
'status' => 'draft',
]);
// Create line items
foreach ($this->lineItems as $item) {
EstimateLineItem::create([
'estimate_id' => $estimate->id,
'type' => $item['type'],
'part_id' => $item['part_id'] ?? null,
'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'] ?? 0,
'required' => $item['required'] ?? true,
]);
}
// Update job card status
$this->diagnosis->jobCard->update(['status' => 'estimate_prepared']);
session()->flash('message', 'Estimate created successfully!');
return redirect()->route('estimates.show', $estimate);
}
public function sendToCustomer()
{
// This would be called after saving
$customer = $this->diagnosis->jobCard->customer;
$customer->notify(new EstimateNotification($estimate));
session()->flash('message', 'Estimate sent to customer successfully!');
}
public function render()
{
return view('livewire.estimates.create');
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Estimates;
use Livewire\Component;
class Edit extends Component
{
public function render()
{
return view('livewire.estimates.edit');
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Livewire\Estimates;
use App\Models\Estimate;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
{
use WithPagination;
public $search = '';
public $statusFilter = '';
public $approvalStatusFilter = '';
public function updatingSearch()
{
$this->resetPage();
}
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);
})
->latest()
->paginate(15);
return view('livewire.estimates.index', compact('estimates'));
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Estimates;
use Livewire\Component;
class PDF extends Component
{
public function render()
{
return view('livewire.estimates.p-d-f');
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Estimates;
use Livewire\Component;
class Show extends Component
{
public function render()
{
return view('livewire.estimates.show');
}
}

View File

@ -0,0 +1,138 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use App\Models\Customer;
use App\Models\Vehicle;
use App\Models\JobCard;
use App\Models\Appointment;
class GlobalSearch extends Component
{
public string $search = '';
public array $results = [];
public bool $showResults = false;
public function updatedSearch()
{
if (strlen($this->search) < 2) {
$this->results = [];
$this->showResults = false;
return;
}
$this->showResults = true;
$this->searchAll();
}
public function searchAll()
{
$this->results = [];
// Search Customers
$customers = Customer::where('first_name', 'like', "%{$this->search}%")
->orWhere('last_name', 'like', "%{$this->search}%")
->orWhere('email', 'like', "%{$this->search}%")
->orWhere('phone', 'like', "%{$this->search}%")
->limit(5)
->get()
->map(function ($customer) {
return [
'type' => 'customer',
'title' => $customer->full_name,
'subtitle' => $customer->email ?? $customer->phone,
'url' => route('customers.show', $customer),
'icon' => 'user'
];
})
->toArray();
// Search Vehicles
$vehicles = Vehicle::where('license_plate', 'like', "%{$this->search}%")
->orWhere('make', 'like', "%{$this->search}%")
->orWhere('model', 'like', "%{$this->search}%")
->orWhere('vin', 'like', "%{$this->search}%")
->with('customer')
->limit(5)
->get()
->map(function ($vehicle) {
return [
'type' => 'vehicle',
'title' => "{$vehicle->make} {$vehicle->model} - {$vehicle->license_plate}",
'subtitle' => $vehicle->customer?->full_name ?? 'No customer assigned',
'url' => "/vehicles/{$vehicle->id}",
'icon' => 'truck'
];
})
->toArray();
// Search Job Cards
$jobCards = JobCard::where('job_card_number', 'like', "%{$this->search}%")
->orWhereHas('customer', function ($query) {
$query->where('first_name', 'like', "%{$this->search}%")
->orWhere('last_name', 'like', "%{$this->search}%");
})
->orWhereHas('vehicle', function ($query) {
$query->where('license_plate', 'like', "%{$this->search}%");
})
->with(['customer', 'vehicle'])
->limit(5)
->get()
->map(function ($jobCard) {
return [
'type' => 'job_card',
'title' => "Job #{$jobCard->job_card_number}",
'subtitle' => "{$jobCard->customer->full_name} - {$jobCard->vehicle->license_plate}",
'url' => route('job-cards.show', $jobCard),
'icon' => 'clipboard-document-list'
];
})
->toArray();
// Search Appointments (if the table exists)
try {
$appointments = Appointment::whereHas('customer', function ($query) {
$query->where('first_name', 'like', "%{$this->search}%")
->orWhere('last_name', 'like', "%{$this->search}%");
})
->orWhereHas('vehicle', function ($query) {
$query->where('license_plate', 'like', "%{$this->search}%");
})
->with(['customer', 'vehicle'])
->limit(5)
->get()
->map(function ($appointment) {
return [
'type' => 'appointment',
'title' => "Appointment - {$appointment->scheduled_date}",
'subtitle' => "{$appointment->customer->full_name} - {$appointment->vehicle->license_plate}",
'url' => "/appointments/{$appointment->id}",
'icon' => 'calendar'
];
})
->toArray();
} catch (\Exception $e) {
$appointments = [];
}
// Combine all results and limit to 10
$this->results = array_slice(
array_merge($customers, $vehicles, $jobCards, $appointments),
0,
10
);
}
public function clearSearch()
{
$this->search = '';
$this->results = [];
$this->showResults = false;
}
public function render()
{
return view('livewire.global-search');
}
}

View File

@ -0,0 +1,135 @@
<?php
namespace App\Livewire\Inspections;
use App\Models\JobCard;
use App\Models\VehicleInspection;
use Livewire\Component;
use Livewire\WithFileUploads;
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 $follow_up_required = false;
public $notes = '';
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' => '',
]
];
protected $rules = [
'current_mileage' => 'required|numeric|min:0',
'fuel_level' => 'required|string',
'overall_condition' => 'required|in:excellent,good,fair,poor,damaged',
'cleanliness_rating' => 'required|integer|min:1|max:10',
];
public function mount(JobCard $jobCard, $type)
{
$this->jobCard = $jobCard->load(['customer', 'vehicle']);
$this->type = $type;
$this->current_mileage = $jobCard->vehicle->current_mileage ?? '';
if ($type === 'outgoing') {
$this->rules['quality_rating'] = 'required|integer|min:1|max:10';
}
}
public function save()
{
$this->validate();
// Handle file uploads
$photoUrls = [];
foreach ($this->photos as $photo) {
$photoUrls[] = $photo->store('inspections', 'public');
}
$videoUrls = [];
foreach ($this->videos as $video) {
$videoUrls[] = $video->store('inspections', 'public');
}
$inspection = VehicleInspection::create([
'job_card_id' => $this->jobCard->id,
'vehicle_id' => $this->jobCard->vehicle_id,
'inspector_id' => auth()->id(),
'inspection_type' => $this->type,
'current_mileage' => $this->current_mileage,
'fuel_level' => $this->fuel_level,
'inspection_checklist' => $this->checklist,
'photos' => $photoUrls,
'videos' => $videoUrls,
'overall_condition' => $this->overall_condition,
'recommendations' => $this->recommendations,
'damage_notes' => $this->damage_notes,
'cleanliness_rating' => $this->cleanliness_rating,
'quality_rating' => $this->quality_rating,
'follow_up_required' => $this->follow_up_required,
'notes' => $this->notes,
'inspection_date' => now(),
]);
// Update job card status based on inspection type
if ($this->type === 'incoming') {
$this->jobCard->update([
'status' => 'inspection_completed',
'mileage_in' => $this->current_mileage,
'fuel_level_in' => $this->fuel_level,
]);
} else {
$this->jobCard->update([
'status' => 'quality_check_completed',
'mileage_out' => $this->current_mileage,
'fuel_level_out' => $this->fuel_level,
]);
}
session()->flash('message', ucfirst($this->type) . ' inspection completed successfully!');
return redirect()->route('inspections.show', $inspection);
}
public function render()
{
return view('livewire.inspections.create');
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Inspections;
use Livewire\Component;
class Edit extends Component
{
public function render()
{
return view('livewire.inspections.edit');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Inspections;
use App\Models\VehicleInspection;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
{
use WithPagination;
public $search = '';
public $typeFilter = '';
public $statusFilter = '';
public function updatingSearch()
{
$this->resetPage();
}
public function render()
{
$inspections = VehicleInspection::with([
'jobCard.customer',
'jobCard.vehicle',
'inspector'
])
->when($this->search, function ($query) {
$query->whereHas('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->typeFilter, function ($query) {
$query->where('inspection_type', $this->typeFilter);
})
->when($this->statusFilter, function ($query) {
$query->where('overall_condition', $this->statusFilter);
})
->latest()
->paginate(15);
return view('livewire.inspections.index', compact('inspections'));
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Inspections;
use Livewire\Component;
class Show extends Component
{
public function render()
{
return view('livewire.inspections.show');
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Livewire\Inventory;
use App\Models\Part;
use App\Models\PurchaseOrder;
use App\Models\StockMovement;
use App\Models\Supplier;
use Livewire\Component;
class Dashboard extends Component
{
public function render()
{
// Get key inventory metrics
$totalParts = Part::count();
$lowStockParts = Part::lowStock()->count();
$outOfStockParts = Part::outOfStock()->count();
$totalStockValue = Part::selectRaw('SUM(quantity_on_hand * cost_price) as total')->value('total') ?? 0;
// Get recent stock movements
$recentMovements = StockMovement::with(['part', 'createdBy'])
->orderBy('created_at', 'desc')
->limit(10)
->get();
// Get pending purchase orders
$pendingOrders = PurchaseOrder::with('supplier')
->whereIn('status', ['pending', 'ordered'])
->orderBy('order_date', 'desc')
->limit(5)
->get();
// Get low stock parts
$lowStockPartsList = Part::with('supplier')
->lowStock()
->orderBy('quantity_on_hand', 'asc')
->limit(10)
->get();
// Get stock by category
$stockByCategory = Part::selectRaw('category, SUM(quantity_on_hand * cost_price) as total_value')
->groupBy('category')
->orderBy('total_value', 'desc')
->get(); // Get top suppliers by parts count
$topSuppliers = Supplier::withCount('parts')
->having('parts_count', '>', 0)
->orderBy('parts_count', 'desc')
->limit(5)
->get();
return view('livewire.inventory.dashboard', [
'totalParts' => $totalParts,
'lowStockParts' => $lowStockParts,
'outOfStockParts' => $outOfStockParts,
'totalStockValue' => $totalStockValue,
'recentMovements' => $recentMovements,
'pendingOrders' => $pendingOrders,
'lowStockPartsList' => $lowStockPartsList,
'stockByCategory' => $stockByCategory,
'topSuppliers' => $topSuppliers,
]);
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Livewire\Inventory\Parts;
use App\Models\Part;
use App\Models\Supplier;
use Livewire\Component;
use Livewire\WithFileUploads;
class Create extends Component
{
use WithFileUploads;
public $part_number = '';
public $name = '';
public $description = '';
public $manufacturer = '';
public $category = '';
public $cost_price = '';
public $sell_price = '';
public $quantity_on_hand = 0;
public $minimum_stock_level = 0;
public $maximum_stock_level = 0;
public $location = '';
public $supplier_id = '';
public $supplier_part_number = '';
public $lead_time_days = '';
public $status = 'active';
public $barcode = '';
public $weight = '';
public $dimensions = '';
public $warranty_period = '';
public $image;
protected $rules = [
'part_number' => 'required|string|max:255|unique:parts',
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'manufacturer' => 'nullable|string|max:255',
'category' => 'nullable|string|max:255',
'cost_price' => 'required|numeric|min:0',
'sell_price' => 'required|numeric|min:0',
'quantity_on_hand' => 'required|integer|min:0',
'minimum_stock_level' => 'required|integer|min:0',
'maximum_stock_level' => 'required|integer|min:0',
'location' => 'nullable|string|max:255',
'supplier_id' => 'nullable|exists:suppliers,id',
'supplier_part_number' => 'nullable|string|max:255',
'lead_time_days' => 'nullable|integer|min:0',
'status' => 'required|in:active,inactive',
'barcode' => 'nullable|string|max:255',
'weight' => 'nullable|numeric|min:0',
'dimensions' => 'nullable|string|max:255',
'warranty_period' => 'nullable|integer|min:0',
'image' => 'nullable|image|max:2048',
];
public function save()
{
$this->validate();
$data = [
'part_number' => $this->part_number,
'name' => $this->name,
'description' => $this->description,
'manufacturer' => $this->manufacturer,
'category' => $this->category,
'cost_price' => $this->cost_price,
'sell_price' => $this->sell_price,
'quantity_on_hand' => $this->quantity_on_hand,
'minimum_stock_level' => $this->minimum_stock_level,
'maximum_stock_level' => $this->maximum_stock_level,
'location' => $this->location,
'supplier_id' => $this->supplier_id ?: null,
'supplier_part_number' => $this->supplier_part_number,
'lead_time_days' => $this->lead_time_days,
'status' => $this->status,
'barcode' => $this->barcode,
'weight' => $this->weight,
'dimensions' => $this->dimensions,
'warranty_period' => $this->warranty_period,
];
// Handle image upload
if ($this->image) {
$data['image'] = $this->image->store('parts', 'public');
}
Part::create($data);
session()->flash('success', 'Part created successfully.');
return $this->redirect(route('inventory.parts.index'));
}
public function render()
{
$suppliers = Supplier::active()->orderBy('name')->get();
return view('livewire.inventory.parts.create', [
'suppliers' => $suppliers,
]);
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace App\Livewire\Inventory\Parts;
use App\Models\Part;
use App\Models\Supplier;
use Livewire\Component;
use Livewire\WithFileUploads;
class Edit extends Component
{
use WithFileUploads;
public Part $part;
public $part_number = '';
public $name = '';
public $description = '';
public $manufacturer = '';
public $category = '';
public $cost_price = '';
public $sell_price = '';
public $quantity_on_hand = 0;
public $minimum_stock_level = 0;
public $maximum_stock_level = 0;
public $location = '';
public $supplier_id = '';
public $supplier_part_number = '';
public $lead_time_days = '';
public $status = 'active';
public $barcode = '';
public $weight = '';
public $dimensions = '';
public $warranty_period = '';
public $image;
public $currentImage = '';
protected $rules = [
'part_number' => 'required|string|max:255',
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'manufacturer' => 'nullable|string|max:255',
'category' => 'nullable|string|max:255',
'cost_price' => 'required|numeric|min:0',
'sell_price' => 'required|numeric|min:0',
'quantity_on_hand' => 'required|integer|min:0',
'minimum_stock_level' => 'required|integer|min:0',
'maximum_stock_level' => 'required|integer|min:0',
'location' => 'nullable|string|max:255',
'supplier_id' => 'nullable|exists:suppliers,id',
'supplier_part_number' => 'nullable|string|max:255',
'lead_time_days' => 'nullable|integer|min:0',
'status' => 'required|in:active,inactive',
'barcode' => 'nullable|string|max:255',
'weight' => 'nullable|numeric|min:0',
'dimensions' => 'nullable|string|max:255',
'warranty_period' => 'nullable|integer|min:0',
'image' => 'nullable|image|max:2048',
];
public function mount()
{
$this->part_number = $this->part->part_number;
$this->name = $this->part->name;
$this->description = $this->part->description;
$this->manufacturer = $this->part->manufacturer;
$this->category = $this->part->category;
$this->cost_price = $this->part->cost_price;
$this->sell_price = $this->part->sell_price;
$this->quantity_on_hand = $this->part->quantity_on_hand;
$this->minimum_stock_level = $this->part->minimum_stock_level;
$this->maximum_stock_level = $this->part->maximum_stock_level;
$this->location = $this->part->location;
$this->supplier_id = $this->part->supplier_id;
$this->supplier_part_number = $this->part->supplier_part_number;
$this->lead_time_days = $this->part->lead_time_days;
$this->status = $this->part->status;
$this->barcode = $this->part->barcode;
$this->weight = $this->part->weight;
$this->dimensions = $this->part->dimensions;
$this->warranty_period = $this->part->warranty_period;
$this->currentImage = $this->part->image;
}
public function save()
{
$rules = $this->rules;
$rules['part_number'] = 'required|string|max:255|unique:parts,part_number,' . $this->part->id;
$this->validate($rules);
$data = [
'part_number' => $this->part_number,
'name' => $this->name,
'description' => $this->description,
'manufacturer' => $this->manufacturer,
'category' => $this->category,
'cost_price' => $this->cost_price,
'sell_price' => $this->sell_price,
'quantity_on_hand' => $this->quantity_on_hand,
'minimum_stock_level' => $this->minimum_stock_level,
'maximum_stock_level' => $this->maximum_stock_level,
'location' => $this->location,
'supplier_id' => $this->supplier_id ?: null,
'supplier_part_number' => $this->supplier_part_number,
'lead_time_days' => $this->lead_time_days,
'status' => $this->status,
'barcode' => $this->barcode,
'weight' => $this->weight,
'dimensions' => $this->dimensions,
'warranty_period' => $this->warranty_period,
];
// Handle image upload
if ($this->image) {
$data['image'] = $this->image->store('parts', 'public');
}
$this->part->update($data);
session()->flash('success', 'Part updated successfully.');
return $this->redirect(route('inventory.parts.index'));
}
public function render()
{
$suppliers = Supplier::active()->orderBy('name')->get();
return view('livewire.inventory.parts.edit', [
'suppliers' => $suppliers,
]);
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Livewire\Inventory\Parts;
use App\Models\Part;
use App\Models\PartHistory;
use Livewire\Component;
use Livewire\WithPagination;
class History extends Component
{
use WithPagination;
public Part $part;
public $eventTypeFilter = '';
public $dateFrom = '';
public $dateTo = '';
protected $queryString = [
'eventTypeFilter' => ['except' => ''],
'dateFrom' => ['except' => ''],
'dateTo' => ['except' => ''],
];
public function mount()
{
// Clear date filters if they are set to today (which would exclude our test data)
if ($this->dateFrom === now()->format('Y-m-d') && $this->dateTo === now()->format('Y-m-d')) {
$this->dateFrom = null;
$this->dateTo = null;
}
}
public function updatingEventTypeFilter()
{
$this->resetPage();
}
public function clearFilters()
{
$this->reset(['eventTypeFilter', 'dateFrom', 'dateTo']);
$this->resetPage();
}
public function goBack()
{
return $this->redirect(route('inventory.parts.show', $this->part), navigate: true);
}
public function render()
{
$query = PartHistory::where('part_id', $this->part->id)
->with(['createdBy'])
->orderBy('created_at', 'desc');
// Apply filters
if ($this->eventTypeFilter !== '') {
$query->where('event_type', $this->eventTypeFilter);
}
if ($this->dateFrom) {
$query->whereDate('created_at', '>=', $this->dateFrom);
}
if ($this->dateTo) {
$query->whereDate('created_at', '<=', $this->dateTo);
}
$histories = $query->paginate(20);
$eventTypes = [
PartHistory::EVENT_CREATED => 'Created',
PartHistory::EVENT_UPDATED => 'Updated',
PartHistory::EVENT_STOCK_IN => 'Stock In',
PartHistory::EVENT_STOCK_OUT => 'Stock Out',
PartHistory::EVENT_ADJUSTMENT => 'Adjustment',
PartHistory::EVENT_PRICE_CHANGE => 'Price Change',
PartHistory::EVENT_SUPPLIER_CHANGE => 'Supplier Change',
];
return view('livewire.inventory.parts.history', [
'histories' => $histories,
'eventTypes' => $eventTypes,
]);
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace App\Livewire\Inventory\Parts;
use App\Models\Part;
use App\Models\Supplier;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
{
use WithPagination;
public $search = '';
public $categoryFilter = '';
public $statusFilter = '';
public $stockFilter = '';
public $supplierFilter = '';
public $sortBy = 'name';
public $sortDirection = 'asc';
protected $queryString = [
'search' => ['except' => ''],
'categoryFilter' => ['except' => ''],
'statusFilter' => ['except' => ''],
'stockFilter' => ['except' => ''],
'supplierFilter' => ['except' => ''],
'sortBy' => ['except' => 'name'],
'sortDirection' => ['except' => 'asc'],
];
public function updatingSearch()
{
$this->resetPage();
}
public function updatingCategoryFilter()
{
$this->resetPage();
}
public function updatingStatusFilter()
{
$this->resetPage();
}
public function updatingStockFilter()
{
$this->resetPage();
}
public function updatingSupplierFilter()
{
$this->resetPage();
}
public function sortBy($field)
{
if ($this->sortBy === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $field;
$this->sortDirection = 'asc';
}
}
public function clearFilters()
{
$this->reset(['search', 'categoryFilter', 'statusFilter', 'stockFilter', 'supplierFilter']);
$this->resetPage();
}
public function render()
{
$query = Part::with('supplier');
// Apply search
if ($this->search) {
$query->where(function ($q) {
$q->where('name', 'like', '%' . $this->search . '%')
->orWhere('part_number', 'like', '%' . $this->search . '%')
->orWhere('description', 'like', '%' . $this->search . '%')
->orWhere('manufacturer', 'like', '%' . $this->search . '%');
});
}
// Apply filters
if ($this->categoryFilter) {
$query->where('category', $this->categoryFilter);
}
if ($this->statusFilter) {
$query->where('status', $this->statusFilter);
}
if ($this->supplierFilter) {
$query->where('supplier_id', $this->supplierFilter);
}
if ($this->stockFilter) {
switch ($this->stockFilter) {
case 'low_stock':
$query->whereColumn('quantity_on_hand', '<=', 'minimum_stock_level');
break;
case 'out_of_stock':
$query->where('quantity_on_hand', '<=', 0);
break;
case 'overstock':
$query->whereColumn('quantity_on_hand', '>=', 'maximum_stock_level');
break;
case 'in_stock':
$query->where('quantity_on_hand', '>', 0)
->whereColumn('quantity_on_hand', '>', 'minimum_stock_level')
->whereColumn('quantity_on_hand', '<', 'maximum_stock_level');
break;
}
}
// Apply sorting
$query->orderBy($this->sortBy, $this->sortDirection);
$parts = $query->paginate(15);
// Get filter options
$categories = Part::distinct()->pluck('category')->filter()->sort();
$suppliers = Supplier::active()->orderBy('name')->get();
return view('livewire.inventory.parts.index', [
'parts' => $parts,
'categories' => $categories,
'suppliers' => $suppliers,
]);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Livewire\Inventory\Parts;
use App\Models\Part;
use Livewire\Component;
class Show extends Component
{
public Part $part;
public $tab = 'details';
public function mount(Part $part)
{
$this->part = $part->load(['supplier', 'stockMovements.createdBy']);
$this->tab = request()->get('tab', 'details');
}
public function showHistory()
{
return $this->redirect(route('inventory.parts.show', $this->part) . '?tab=history', navigate: true);
}
public function addStockMovement()
{
return $this->redirect(route('inventory.stock-movements.create') . '?part_id=' . $this->part->id, navigate: true);
}
public function createPurchaseOrder()
{
return $this->redirect(route('inventory.purchase-orders.create') . '?part_id=' . $this->part->id, navigate: true);
}
public function goBack()
{
return $this->redirect(route('inventory.parts.index'), navigate: true);
}
public function edit()
{
return $this->redirect(route('inventory.parts.edit', $this->part), navigate: true);
}
public function render()
{
if ($this->tab === 'history') {
return view('livewire.inventory.parts.show', [
'showHistory' => true
]);
}
return view('livewire.inventory.parts.show', [
'showHistory' => false
]);
}
}

View File

@ -0,0 +1,172 @@
<?php
namespace App\Livewire\Inventory\PurchaseOrders;
use App\Models\Part;
use App\Models\PurchaseOrder;
use App\Models\Supplier;
use Livewire\Component;
class Create extends Component
{
public $supplier_id = '';
public $order_date;
public $expected_date = '';
public $notes = '';
public $status = 'draft';
// Items
public $items = [];
public $selectedPart = '';
public $quantity = 1;
public $unitCost = '';
protected $rules = [
'supplier_id' => 'required|exists:suppliers,id',
'order_date' => 'required|date',
'expected_date' => 'nullable|date|after:order_date',
'notes' => 'nullable|string|max:1000',
'status' => 'required|in:draft,pending,ordered',
'items' => 'required|array|min:1',
'items.*.part_id' => 'required|exists:parts,id',
'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_cost' => 'required|numeric|min:0',
];
public function mount()
{
$this->order_date = now()->format('Y-m-d');
// Pre-select part if passed in URL
if (request()->has('part_id')) {
$partId = request()->get('part_id');
$part = Part::find($partId);
if ($part) {
$this->selectedPart = $partId;
$this->unitCost = $part->cost_price;
// Pre-fill supplier if part has one
if ($part->supplier_id) {
$this->supplier_id = $part->supplier_id;
}
}
}
}
public function updatedSupplierId()
{
// Clear selected part when supplier changes
$this->selectedPart = '';
$this->unitCost = '';
}
public function addItem()
{
$this->validate([
'selectedPart' => 'required|exists:parts,id',
'quantity' => 'required|integer|min:1',
'unitCost' => 'required|numeric|min:0',
]);
$part = Part::find($this->selectedPart);
// Check if part already exists in items
$existingIndex = collect($this->items)->search(function ($item) {
return $item['part_id'] == $this->selectedPart;
});
if ($existingIndex !== false) {
// Update existing item
$this->items[$existingIndex]['quantity'] += $this->quantity;
$this->items[$existingIndex]['unit_cost'] = $this->unitCost;
} else {
// Add new item
$this->items[] = [
'part_id' => $this->selectedPart,
'part_name' => $part->name,
'part_number' => $part->part_number,
'quantity' => $this->quantity,
'unit_cost' => $this->unitCost,
'total_cost' => $this->quantity * $this->unitCost,
];
}
// Reset form
$this->reset(['selectedPart', 'quantity', 'unitCost']);
}
public function removeItem($index)
{
unset($this->items[$index]);
$this->items = array_values($this->items);
}
public function updatedSelectedPart()
{
if ($this->selectedPart) {
$part = Part::find($this->selectedPart);
$this->unitCost = $part->cost_price ?? '';
}
}
public function save()
{
$this->validate();
$purchaseOrder = PurchaseOrder::create([
'po_number' => $this->generateOrderNumber(),
'supplier_id' => $this->supplier_id,
'order_date' => $this->order_date,
'expected_date' => $this->expected_date ?: null,
'status' => $this->status,
'notes' => $this->notes,
'approved_by' => auth()->id(),
]);
foreach ($this->items as $item) {
$purchaseOrder->items()->create([
'part_id' => $item['part_id'],
'quantity_ordered' => $item['quantity'],
'unit_cost' => $item['unit_cost'],
'total_cost' => $item['quantity'] * $item['unit_cost'],
]);
}
session()->flash('success', 'Purchase order created successfully!');
return $this->redirect(route('inventory.purchase-orders.show', $purchaseOrder), navigate: true);
}
private function generateOrderNumber()
{
$year = date('Y');
$lastOrder = PurchaseOrder::whereYear('created_at', $year)
->orderBy('id', 'desc')
->first();
$sequence = $lastOrder ? (int) substr($lastOrder->po_number, -4) + 1 : 1;
return 'PO-' . $year . '-' . str_pad($sequence, 4, '0', STR_PAD_LEFT);
}
public function getTotalAmount()
{
return collect($this->items)->sum('total_cost');
}
public function render()
{
$suppliers = Supplier::where('is_active', true)->orderBy('name')->get();
// Filter parts by selected supplier
$partsQuery = Part::orderBy('name');
if ($this->supplier_id) {
$partsQuery->where('supplier_id', $this->supplier_id);
}
$parts = $partsQuery->get();
return view('livewire.inventory.purchase-orders.create', [
'suppliers' => $suppliers,
'parts' => $parts,
]);
}
}

View File

@ -0,0 +1,157 @@
<?php
namespace App\Livewire\Inventory\PurchaseOrders;
use App\Models\Part;
use App\Models\PurchaseOrder;
use App\Models\Supplier;
use Livewire\Component;
class Edit extends Component
{
public PurchaseOrder $purchaseOrder;
public $supplier_id = '';
public $order_date;
public $expected_date = '';
public $notes = '';
public $status = 'draft';
// Items
public $items = [];
public $selectedPart = '';
public $quantity = 1;
public $unitCost = '';
protected $rules = [
'supplier_id' => 'required|exists:suppliers,id',
'order_date' => 'required|date',
'expected_date' => 'nullable|date|after:order_date',
'notes' => 'nullable|string|max:1000',
'status' => 'required|in:draft,pending,ordered',
'items' => 'required|array|min:1',
'items.*.part_id' => 'required|exists:parts,id',
'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_cost' => 'required|numeric|min:0',
];
public function mount(PurchaseOrder $purchaseOrder)
{
$this->purchaseOrder = $purchaseOrder->load(['items.part']);
$this->supplier_id = $purchaseOrder->supplier_id;
$this->order_date = $purchaseOrder->order_date->format('Y-m-d');
$this->expected_date = $purchaseOrder->expected_date ? $purchaseOrder->expected_date->format('Y-m-d') : '';
$this->notes = $purchaseOrder->notes;
$this->status = $purchaseOrder->status;
// Load existing items
foreach ($purchaseOrder->items as $item) {
$this->items[] = [
'id' => $item->id,
'part_id' => $item->part_id,
'part_name' => $item->part->name,
'part_number' => $item->part->part_number,
'quantity' => $item->quantity_ordered,
'unit_cost' => $item->unit_cost,
'total_cost' => $item->total_cost,
];
}
}
public function addItem()
{
$this->validate([
'selectedPart' => 'required|exists:parts,id',
'quantity' => 'required|integer|min:1',
'unitCost' => 'required|numeric|min:0',
]);
$part = Part::find($this->selectedPart);
// Check if part already exists in items
$existingIndex = collect($this->items)->search(function ($item) {
return $item['part_id'] == $this->selectedPart;
});
if ($existingIndex !== false) {
// Update existing item
$this->items[$existingIndex]['quantity'] += $this->quantity;
$this->items[$existingIndex]['unit_cost'] = $this->unitCost;
$this->items[$existingIndex]['total_cost'] = $this->items[$existingIndex]['quantity'] * $this->unitCost;
} else {
// Add new item
$this->items[] = [
'id' => null, // New item
'part_id' => $this->selectedPart,
'part_name' => $part->name,
'part_number' => $part->part_number,
'quantity' => $this->quantity,
'unit_cost' => $this->unitCost,
'total_cost' => $this->quantity * $this->unitCost,
];
}
// Reset form
$this->reset(['selectedPart', 'quantity', 'unitCost']);
}
public function removeItem($index)
{
unset($this->items[$index]);
$this->items = array_values($this->items);
}
public function updatedSelectedPart()
{
if ($this->selectedPart) {
$part = Part::find($this->selectedPart);
$this->unitCost = $part->cost_price ?? '';
}
}
public function save()
{
$this->validate();
$this->purchaseOrder->update([
'supplier_id' => $this->supplier_id,
'order_date' => $this->order_date,
'expected_date' => $this->expected_date ?: null,
'status' => $this->status,
'notes' => $this->notes,
]);
// Delete existing items and recreate
$this->purchaseOrder->items()->delete();
foreach ($this->items as $item) {
$this->purchaseOrder->items()->create([
'part_id' => $item['part_id'],
'quantity_ordered' => $item['quantity'],
'unit_cost' => $item['unit_cost'],
'total_cost' => $item['quantity'] * $item['unit_cost'],
]);
}
session()->flash('success', 'Purchase order updated successfully!');
return $this->redirect(route('inventory.purchase-orders.show', $this->purchaseOrder), navigate: true);
}
public function getTotalAmount()
{
return collect($this->items)->sum('total_cost');
}
public function render()
{
$suppliers = Supplier::where('is_active', true)->orderBy('name')->get();
$parts = Part::orderBy('name')->get();
return view('livewire.inventory.purchase-orders.edit', [
'suppliers' => $suppliers,
'parts' => $parts,
]);
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Livewire\Inventory\PurchaseOrders;
use App\Models\PurchaseOrder;
use App\Models\Supplier;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
{
use WithPagination;
public $search = '';
public $statusFilter = '';
public $supplierFilter = '';
public $sortBy = 'order_date';
public $sortDirection = 'desc';
protected $queryString = [
'search' => ['except' => ''],
'statusFilter' => ['except' => ''],
'supplierFilter' => ['except' => ''],
'sortBy' => ['except' => 'order_date'],
'sortDirection' => ['except' => 'desc'],
];
public function updatingSearch()
{
$this->resetPage();
}
public function updatingStatusFilter()
{
$this->resetPage();
}
public function updatingSupplierFilter()
{
$this->resetPage();
}
public function sortBy($field)
{
if ($this->sortBy === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $field;
$this->sortDirection = 'asc';
}
}
public function clearFilters()
{
$this->reset(['search', 'statusFilter', 'supplierFilter']);
$this->resetPage();
}
public function render()
{
$query = PurchaseOrder::with(['supplier', 'items']);
// Apply search
if ($this->search) {
$query->where(function ($q) {
$q->where('po_number', 'like', '%' . $this->search . '%')
->orWhereHas('supplier', function ($sq) {
$sq->where('name', 'like', '%' . $this->search . '%');
});
});
}
// Apply filters
if ($this->statusFilter !== '') {
$query->where('status', $this->statusFilter);
}
if ($this->supplierFilter !== '') {
$query->where('supplier_id', $this->supplierFilter);
}
// Apply sorting
$query->orderBy($this->sortBy, $this->sortDirection);
$purchaseOrders = $query->paginate(15);
$suppliers = Supplier::where('is_active', true)->orderBy('name')->get();
return view('livewire.inventory.purchase-orders.index', [
'purchaseOrders' => $purchaseOrders,
'suppliers' => $suppliers,
]);
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace App\Livewire\Inventory\PurchaseOrders;
use App\Models\PurchaseOrder;
use App\Models\StockMovement;
use Livewire\Component;
class Show extends Component
{
public PurchaseOrder $purchaseOrder;
public $receivingMode = false;
public $receivedQuantities = [];
public function mount(PurchaseOrder $purchaseOrder)
{
$this->purchaseOrder = $purchaseOrder->load(['supplier', 'items.part']);
// Initialize received quantities
foreach ($this->purchaseOrder->items as $item) {
$this->receivedQuantities[$item->id] = $item->quantity_received ?? 0;
}
}
public function startReceiving()
{
$this->receivingMode = true;
}
public function cancelReceiving()
{
$this->receivingMode = false;
// Reset quantities
foreach ($this->purchaseOrder->items as $item) {
$this->receivedQuantities[$item->id] = $item->quantity_received ?? 0;
}
}
public function receiveItems()
{
$this->validate([
'receivedQuantities.*' => 'required|integer|min:0',
]);
$totalReceived = 0;
$totalOrdered = 0;
foreach ($this->purchaseOrder->items as $item) {
$receivedQty = $this->receivedQuantities[$item->id];
$previouslyReceived = $item->quantity_received ?? 0;
$newlyReceived = $receivedQty - $previouslyReceived;
if ($newlyReceived > 0) {
// Update item received quantity
$item->update(['quantity_received' => $receivedQty]);
// Add to part stock
$item->part->increment('quantity_on_hand', $newlyReceived);
// Create stock movement
StockMovement::create([
'part_id' => $item->part_id,
'movement_type' => 'in',
'quantity' => $newlyReceived,
'reference_type' => 'purchase_order',
'reference_id' => $this->purchaseOrder->id,
'notes' => "Received from PO #{$this->purchaseOrder->po_number}",
'created_by' => auth()->id(),
]);
}
$totalReceived += $receivedQty;
$totalOrdered += $item->quantity_ordered;
}
// Update purchase order status
if ($totalReceived == 0) {
$status = $this->purchaseOrder->status;
} elseif ($totalReceived >= $totalOrdered) {
$status = 'received';
} else {
$status = 'partial';
}
$this->purchaseOrder->update([
'status' => $status,
'received_date' => $totalReceived > 0 ? now() : null,
]);
$this->receivingMode = false;
$this->purchaseOrder->refresh();
session()->flash('success', 'Items received successfully!');
}
public function markAsOrdered()
{
$this->purchaseOrder->update(['status' => 'ordered']);
$this->purchaseOrder->refresh();
session()->flash('success', 'Purchase order marked as ordered!');
}
public function cancelOrder()
{
$this->purchaseOrder->update(['status' => 'cancelled']);
$this->purchaseOrder->refresh();
session()->flash('success', 'Purchase order cancelled!');
}
public function render()
{
return view('livewire.inventory.purchase-orders.show');
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Livewire\Inventory\StockMovements;
use App\Models\Part;
use App\Models\StockMovement;
use Livewire\Component;
class Create extends Component
{
public $part_id = '';
public $movement_type = 'adjustment';
public $quantity = '';
public $notes = '';
public $reference_type = 'manual_adjustment';
public function mount()
{
// Pre-select part if passed in URL
if (request()->has('part_id')) {
$this->part_id = request()->get('part_id');
}
}
protected $rules = [
'part_id' => 'required|exists:parts,id',
'movement_type' => 'required|in:in,out,adjustment',
'quantity' => 'required|integer|min:1',
'notes' => 'required|string|max:500',
];
public function save()
{
$this->validate();
$part = Part::find($this->part_id);
// Create stock movement
StockMovement::create([
'part_id' => $this->part_id,
'movement_type' => $this->movement_type,
'quantity' => $this->quantity,
'reference_type' => $this->reference_type,
'notes' => $this->notes,
'created_by' => auth()->id(),
]);
// Update part stock
if ($this->movement_type === 'in' || $this->movement_type === 'adjustment') {
$part->increment('quantity_on_hand', $this->quantity);
} elseif ($this->movement_type === 'out') {
$part->decrement('quantity_on_hand', $this->quantity);
}
session()->flash('success', 'Stock movement recorded successfully!');
return $this->redirect(route('inventory.stock-movements.index'), navigate: true);
}
public function render()
{
$parts = Part::orderBy('name')->get();
return view('livewire.inventory.stock-movements.create', [
'parts' => $parts,
]);
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Livewire\Inventory\StockMovements;
use App\Models\StockMovement;
use App\Models\Part;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
{
use WithPagination;
public $search = '';
public $typeFilter = '';
public $partFilter = '';
public $dateFrom = '';
public $dateTo = '';
public $sortBy = 'created_at';
public $sortDirection = 'desc';
protected $queryString = [
'search' => ['except' => ''],
'typeFilter' => ['except' => ''],
'partFilter' => ['except' => ''],
'dateFrom' => ['except' => ''],
'dateTo' => ['except' => ''],
'sortBy' => ['except' => 'created_at'],
'sortDirection' => ['except' => 'desc'],
];
public function updatingSearch()
{
$this->resetPage();
}
public function sortBy($field)
{
if ($this->sortBy === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $field;
$this->sortDirection = 'asc';
}
}
public function clearFilters()
{
$this->reset(['search', 'typeFilter', 'partFilter', 'dateFrom', 'dateTo']);
$this->resetPage();
}
public function render()
{
$query = StockMovement::with(['part', 'createdBy']);
// Apply search
if ($this->search) {
$query->whereHas('part', function ($q) {
$q->where('name', 'like', '%' . $this->search . '%')
->orWhere('part_number', 'like', '%' . $this->search . '%');
});
}
// Apply filters
if ($this->typeFilter !== '') {
$query->where('movement_type', $this->typeFilter);
}
if ($this->partFilter !== '') {
$query->where('part_id', $this->partFilter);
}
if ($this->dateFrom) {
$query->whereDate('created_at', '>=', $this->dateFrom);
}
if ($this->dateTo) {
$query->whereDate('created_at', '<=', $this->dateTo);
}
// Apply sorting
$query->orderBy($this->sortBy, $this->sortDirection);
$movements = $query->paginate(20);
$parts = Part::orderBy('name')->get();
return view('livewire.inventory.stock-movements.index', [
'movements' => $movements,
'parts' => $parts,
]);
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Livewire\Inventory\Suppliers;
use App\Models\Supplier;
use Livewire\Component;
class Create extends Component
{
public $name = '';
public $company_name = '';
public $email = '';
public $phone = '';
public $address = '';
public $city = '';
public $state = '';
public $zip_code = '';
public $contact_person = '';
public $payment_terms = '';
public $rating = 0;
public $is_active = true;
protected $rules = [
'name' => 'required|string|max:255',
'company_name' => 'nullable|string|max:255',
'email' => 'required|email|unique:suppliers,email',
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'zip_code' => 'nullable|string|max:20',
'contact_person' => 'nullable|string|max:255',
'payment_terms' => 'nullable|string|max:255',
'rating' => 'nullable|numeric|min:0|max:5',
'is_active' => 'boolean',
];
public function save()
{
$this->validate();
Supplier::create([
'name' => $this->name,
'company_name' => $this->company_name,
'email' => $this->email,
'phone' => $this->phone,
'address' => $this->address,
'city' => $this->city,
'state' => $this->state,
'zip_code' => $this->zip_code,
'contact_person' => $this->contact_person,
'payment_terms' => $this->payment_terms,
'rating' => $this->rating ?: null,
'is_active' => $this->is_active,
]);
session()->flash('success', 'Supplier created successfully!');
return $this->redirect(route('inventory.suppliers.index'), navigate: true);
}
public function render()
{
return view('livewire.inventory.suppliers.create');
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Livewire\Inventory\Suppliers;
use App\Models\Supplier;
use Livewire\Component;
class Edit extends Component
{
public Supplier $supplier;
public $name = '';
public $company_name = '';
public $email = '';
public $phone = '';
public $address = '';
public $city = '';
public $state = '';
public $zip_code = '';
public $contact_person = '';
public $payment_terms = '';
public $rating = 0;
public $is_active = true;
public function mount(Supplier $supplier)
{
$this->supplier = $supplier;
$this->name = $supplier->name;
$this->company_name = $supplier->company_name;
$this->email = $supplier->email;
$this->phone = $supplier->phone;
$this->address = $supplier->address;
$this->city = $supplier->city;
$this->state = $supplier->state;
$this->zip_code = $supplier->zip_code;
$this->contact_person = $supplier->contact_person;
$this->payment_terms = $supplier->payment_terms;
$this->rating = $supplier->rating ?: 0;
$this->is_active = $supplier->is_active;
}
protected function rules()
{
return [
'name' => 'required|string|max:255',
'company_name' => 'nullable|string|max:255',
'email' => 'required|email|unique:suppliers,email,' . $this->supplier->id,
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'zip_code' => 'nullable|string|max:20',
'contact_person' => 'nullable|string|max:255',
'payment_terms' => 'nullable|string|max:255',
'rating' => 'nullable|numeric|min:0|max:5',
'is_active' => 'boolean',
];
}
public function save()
{
$this->validate();
$this->supplier->update([
'name' => $this->name,
'company_name' => $this->company_name,
'email' => $this->email,
'phone' => $this->phone,
'address' => $this->address,
'city' => $this->city,
'state' => $this->state,
'zip_code' => $this->zip_code,
'contact_person' => $this->contact_person,
'payment_terms' => $this->payment_terms,
'rating' => $this->rating ?: null,
'is_active' => $this->is_active,
]);
session()->flash('success', 'Supplier updated successfully!');
return $this->redirect(route('inventory.suppliers.index'), navigate: true);
}
public function render()
{
return view('livewire.inventory.suppliers.edit');
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Livewire\Inventory\Suppliers;
use App\Models\Supplier;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
{
use WithPagination;
public $search = '';
public $statusFilter = '';
public $sortBy = 'name';
public $sortDirection = 'asc';
protected $queryString = [
'search' => ['except' => ''],
'statusFilter' => ['except' => ''],
'sortBy' => ['except' => 'name'],
'sortDirection' => ['except' => 'asc'],
];
public function updatingSearch()
{
$this->resetPage();
}
public function updatingStatusFilter()
{
$this->resetPage();
}
public function sortBy($field)
{
if ($this->sortBy === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $field;
$this->sortDirection = 'asc';
}
}
public function clearFilters()
{
$this->reset(['search', 'statusFilter']);
$this->resetPage();
}
public function render()
{
$query = Supplier::withCount('parts');
// Apply search
if ($this->search) {
$query->where(function ($q) {
$q->where('name', 'like', '%' . $this->search . '%')
->orWhere('company_name', 'like', '%' . $this->search . '%')
->orWhere('email', 'like', '%' . $this->search . '%')
->orWhere('phone', 'like', '%' . $this->search . '%');
});
}
// Apply filters
if ($this->statusFilter !== '') {
$query->where('is_active', $this->statusFilter);
}
// Apply sorting
$query->orderBy($this->sortBy, $this->sortDirection);
$suppliers = $query->paginate(15);
return view('livewire.inventory.suppliers.index', [
'suppliers' => $suppliers,
]);
}
}

View File

@ -0,0 +1,184 @@
<?php
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\Services\WorkflowService;
use Illuminate\Validation\Rule;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
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 $perform_inspection = true;
public $inspector_id = '';
public $overall_condition = '';
public $inspection_notes = '';
public $inspection_checklist = [];
public $customers = [];
public $vehicles = [];
public $serviceAdvisors = [];
public $inspectors = [];
protected function rules()
{
return [
'customer_id' => 'required|exists:customers,id',
'vehicle_id' => 'required|exists:vehicles,id',
'service_advisor_id' => 'required|exists:users,id',
'branch_code' => 'required|string|max:10',
'arrival_datetime' => 'required|date',
'expected_completion_date' => 'nullable|date|after:arrival_datetime',
'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',
'inspector_id' => 'required_if:perform_inspection,true|exists:users,id',
'overall_condition' => 'required_if:perform_inspection,true|string|max:500',
'inspection_notes' => 'nullable|string|max:1000',
];
}
public function mount()
{
// 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->loadData();
$this->initializeInspectionChecklist();
}
public function loadData()
{
$user = auth()->user();
$this->customers = Customer::orderBy('first_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) {
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) {
return $query->where('branch_code', $user->branch_code);
})
->orderBy('name')
->get();
}
public function updatedCustomerId()
{
if ($this->customer_id) {
$this->vehicles = Vehicle::where('customer_id', $this->customer_id)
->orderBy('year', 'desc')
->orderBy('make')
->orderBy('model')
->get();
} else {
$this->vehicles = [];
$this->vehicle_id = '';
}
}
public function initializeInspectionChecklist()
{
$this->inspection_checklist = [
'exterior_damage' => false,
'interior_condition' => false,
'tire_condition' => false,
'fluid_levels' => false,
'lights_working' => false,
'battery_condition' => false,
'belts_hoses' => false,
'air_filter' => false,
'brake_condition' => false,
'suspension' => false,
];
}
public function save()
{
// Check if user still has permission to create job cards
$this->authorize('create', JobCard::class);
$this->validate();
try {
$workflowService = app(WorkflowService::class);
$data = [
'customer_id' => $this->customer_id,
'vehicle_id' => $this->vehicle_id,
'service_advisor_id' => $this->service_advisor_id,
'branch_code' => $this->branch_code,
'arrival_datetime' => $this->arrival_datetime,
'expected_completion_date' => $this->expected_completion_date,
'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,
];
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;
}
$jobCard = $workflowService->createJobCard($data);
session()->flash('success', 'Job card created successfully! Job Card #: ' . $jobCard->job_card_number);
return redirect()->route('job-cards.show', $jobCard);
} catch (\Exception $e) {
session()->flash('error', 'Failed to create job card: ' . $e->getMessage());
}
}
public function render()
{
return view('livewire.job-cards.create');
}
}

View File

@ -0,0 +1,185 @@
<?php
namespace App\Livewire\JobCards;
use Livewire\Component;
use App\Models\JobCard;
use App\Models\Customer;
use App\Models\Vehicle;
use App\Models\User;
class Edit extends Component
{
public JobCard $jobCard;
public $customers = [];
public $vehicles = [];
public $serviceAdvisors = [];
public $form = [];
public function mount(JobCard $jobCard)
{
$this->jobCard = $jobCard;
$this->loadData();
$this->initializeForm();
}
public function initializeForm()
{
$this->form = [
'customer_id' => $this->jobCard->customer_id,
'vehicle_id' => $this->jobCard->vehicle_id,
'service_advisor_id' => $this->jobCard->service_advisor_id,
'status' => $this->jobCard->status,
'arrival_datetime' => $this->jobCard->arrival_datetime ? $this->jobCard->arrival_datetime->format('Y-m-d\TH:i') : '',
'expected_completion_date' => $this->jobCard->expected_completion_date ? $this->jobCard->expected_completion_date->format('Y-m-d\TH:i') : '',
'completion_datetime' => $this->jobCard->completion_datetime ? $this->jobCard->completion_datetime->format('Y-m-d\TH:i') : '',
'priority' => $this->jobCard->priority,
'mileage_in' => $this->jobCard->mileage_in,
'mileage_out' => $this->jobCard->mileage_out,
'fuel_level_in' => $this->jobCard->fuel_level_in,
'fuel_level_out' => $this->jobCard->fuel_level_out,
'keys_location' => $this->jobCard->keys_location,
'delivery_method' => $this->jobCard->delivery_method,
'customer_reported_issues' => $this->jobCard->customer_reported_issues,
'vehicle_condition_notes' => $this->jobCard->vehicle_condition_notes,
'notes' => $this->jobCard->notes,
'customer_satisfaction_rating' => $this->jobCard->customer_satisfaction_rating,
'personal_items_removed' => (bool) $this->jobCard->personal_items_removed,
'photos_taken' => (bool) $this->jobCard->photos_taken,
];
}
public function loadData()
{
$user = auth()->user();
$this->customers = Customer::orderBy('first_name')->get();
$this->vehicles = Vehicle::orderBy('make')->orderBy('model')->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->hasRole(['admin', 'manager']), function ($query) use ($user) {
return $query->where('branch_code', $user->branch_code);
})
->orderBy('name')
->get();
}
protected function rules()
{
return [
'form.customer_id' => 'required|exists:customers,id',
'form.vehicle_id' => 'required|exists:vehicles,id',
'form.service_advisor_id' => 'nullable|exists:users,id',
'form.status' => 'required|string|in:received,in_diagnosis,estimate_sent,approved,in_progress,quality_check,completed,delivered,cancelled',
'form.arrival_datetime' => 'required|date',
'form.expected_completion_date' => 'nullable|date|after:form.arrival_datetime',
'form.completion_datetime' => 'nullable|date',
'form.priority' => 'required|in:low,medium,high,urgent',
'form.mileage_in' => 'nullable|integer|min:0',
'form.mileage_out' => 'nullable|integer|min:0|gte:form.mileage_in',
'form.fuel_level_in' => 'nullable|string|in:empty,1/4,1/2,3/4,full',
'form.fuel_level_out' => 'nullable|string|in:empty,1/4,1/2,3/4,full',
'form.keys_location' => 'nullable|string|max:255',
'form.delivery_method' => 'nullable|string|in:pickup,delivery,towing',
'form.customer_reported_issues' => 'nullable|string|max:2000',
'form.vehicle_condition_notes' => 'nullable|string|max:1000',
'form.notes' => 'nullable|string|max:2000',
'form.customer_satisfaction_rating' => 'nullable|integer|min:1|max:5',
'form.personal_items_removed' => 'boolean',
'form.photos_taken' => 'boolean',
];
}
public function save()
{
try {
// Debug: Log form data
\Log::info('Form data before validation:', $this->form);
$this->validate();
// Filter out empty values for optional fields
$updateData = [
'customer_id' => $this->form['customer_id'],
'vehicle_id' => $this->form['vehicle_id'],
'status' => $this->form['status'],
'arrival_datetime' => $this->form['arrival_datetime'],
'priority' => $this->form['priority'],
'personal_items_removed' => (bool) ($this->form['personal_items_removed'] ?? false),
'photos_taken' => (bool) ($this->form['photos_taken'] ?? false),
];
// Add service advisor if provided
if (!empty($this->form['service_advisor_id'])) {
$updateData['service_advisor_id'] = $this->form['service_advisor_id'];
}
// Add optional fields only if they have values
if (!empty($this->form['expected_completion_date'])) {
$updateData['expected_completion_date'] = $this->form['expected_completion_date'];
}
if (!empty($this->form['completion_datetime'])) {
$updateData['completion_datetime'] = $this->form['completion_datetime'];
}
if (!empty($this->form['mileage_in'])) {
$updateData['mileage_in'] = (int) $this->form['mileage_in'];
}
if (!empty($this->form['mileage_out'])) {
$updateData['mileage_out'] = (int) $this->form['mileage_out'];
}
if (!empty($this->form['fuel_level_in'])) {
$updateData['fuel_level_in'] = $this->form['fuel_level_in'];
}
if (!empty($this->form['fuel_level_out'])) {
$updateData['fuel_level_out'] = $this->form['fuel_level_out'];
}
if (!empty($this->form['keys_location'])) {
$updateData['keys_location'] = $this->form['keys_location'];
}
if (!empty($this->form['delivery_method'])) {
$updateData['delivery_method'] = $this->form['delivery_method'];
}
if (!empty($this->form['customer_satisfaction_rating'])) {
$updateData['customer_satisfaction_rating'] = (int) $this->form['customer_satisfaction_rating'];
}
// Add text fields even if empty (they can be null)
$updateData['customer_reported_issues'] = $this->form['customer_reported_issues'] ?? null;
$updateData['vehicle_condition_notes'] = $this->form['vehicle_condition_notes'] ?? null;
$updateData['notes'] = $this->form['notes'] ?? null;
\Log::info('Update data:', $updateData);
$this->jobCard->update($updateData);
session()->flash('success', 'Job card updated successfully!');
return redirect()->route('job-cards.show', $this->jobCard);
} catch (\Illuminate\Validation\ValidationException $e) {
\Log::error('Validation error:', $e->errors());
// Re-throw validation exceptions so they are handled by Livewire
throw $e;
} catch (\Exception $e) {
\Log::error('Update error:', ['message' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
session()->flash('error', 'Failed to update job card: ' . $e->getMessage());
$this->dispatch('show-error', message: 'Failed to update job card: ' . $e->getMessage());
}
}
public function render()
{
return view('livewire.job-cards.edit');
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace App\Livewire\JobCards;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\JobCard;
use App\Models\Customer;
use App\Models\Vehicle;
class Index extends Component
{
use WithPagination;
public $search = '';
public $statusFilter = '';
public $branchFilter = '';
public $priorityFilter = '';
public $sortBy = 'created_at';
public $sortDirection = 'desc';
protected $queryString = [
'search' => ['except' => ''],
'statusFilter' => ['except' => ''],
'branchFilter' => ['except' => ''],
'priorityFilter' => ['except' => ''],
'sortBy' => ['except' => 'created_at'],
'sortDirection' => ['except' => 'desc'],
];
public function updatingSearch()
{
$this->resetPage();
}
public function updatingStatusFilter()
{
$this->resetPage();
}
public function updatingBranchFilter()
{
$this->resetPage();
}
public function updatingPriorityFilter()
{
$this->resetPage();
}
public function sortBy($field)
{
if ($this->sortBy === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $field;
$this->sortDirection = 'asc';
}
}
public function render()
{
$jobCards = JobCard::query()
->with(['customer', 'vehicle', 'serviceAdvisor'])
->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 . '%');
});
});
})
->when($this->statusFilter, function ($query) {
$query->where('status', $this->statusFilter);
})
->when($this->branchFilter, function ($query) {
$query->where('branch_code', $this->branchFilter);
})
->when($this->priorityFilter, function ($query) {
$query->where('priority', $this->priorityFilter);
})
->orderBy($this->sortBy, $this->sortDirection)
->paginate(20);
$statusOptions = [
'received' => 'Received',
'in_diagnosis' => 'In Diagnosis',
'estimate_sent' => 'Estimate Sent',
'approved' => 'Approved',
'in_progress' => 'In Progress',
'quality_check' => 'Quality Check',
'completed' => 'Completed',
'delivered' => 'Delivered',
'cancelled' => 'Cancelled',
];
$priorityOptions = [
'low' => 'Low',
'medium' => 'Medium',
'high' => 'High',
'urgent' => 'Urgent',
];
$branchOptions = [
'ACC' => 'ACC Branch',
'KSI' => 'KSI Branch',
// Add more branches as needed
];
return view('livewire.job-cards.index', compact('jobCards', 'statusOptions', 'priorityOptions', 'branchOptions'));
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Livewire\JobCards;
use Livewire\Component;
use App\Models\JobCard;
class Show extends Component
{
public JobCard $jobCard;
public function mount(JobCard $jobCard)
{
$this->jobCard = $jobCard->load([
'customer',
'vehicle',
'serviceAdvisor',
'incomingInspection',
'outgoingInspection',
'diagnosis',
'estimates',
'workOrders',
'timesheets'
]);
}
public function render()
{
return view('livewire.job-cards.show');
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Livewire\JobCards;
use Livewire\Component;
use App\Models\JobCard;
use App\Services\WorkflowService;
class WorkflowStatus extends Component
{
public JobCard $jobCard;
public $workflowData;
public function mount(JobCard $jobCard)
{
$this->jobCard = $jobCard->load([
'customer',
'vehicle',
'serviceAdvisor',
'incomingInspection.inspector',
'outgoingInspection.inspector',
'diagnosis.serviceCoordinator',
'estimates.preparedBy',
'workOrders.assignedTechnician',
'workOrders.serviceCoordinator',
'timesheets.technician'
]);
$workflowService = app(WorkflowService::class);
$this->workflowData = $workflowService->getWorkflowStatus($this->jobCard);
}
public function render()
{
return view('livewire.job-cards.workflow');
}
}

View File

@ -0,0 +1,190 @@
<?php
namespace App\Livewire\Reports;
use Livewire\Component;
use App\Models\Report;
use App\Models\ServiceOrder;
use App\Models\Customer;
use App\Models\Appointment;
use App\Models\TechnicianPerformance;
use Carbon\Carbon;
class Dashboard extends Component
{
public $dateRange = 'last_30_days';
public $startDate;
public $endDate;
public $selectedReport = 'overview';
// Data properties
public $overviewStats = [];
public $revenueData = [];
public $customerAnalytics = [];
public $serviceTrends = [];
public $performanceMetrics = [];
public function mount()
{
$this->setDateRange();
$this->loadAllData();
}
public function updatedDateRange()
{
$this->setDateRange();
$this->loadAllData();
}
public function updatedSelectedReport()
{
$this->loadAllData();
}
public function setDateRange()
{
switch ($this->dateRange) {
case 'today':
$this->startDate = now()->startOfDay();
$this->endDate = now()->endOfDay();
break;
case 'yesterday':
$this->startDate = now()->subDay()->startOfDay();
$this->endDate = now()->subDay()->endOfDay();
break;
case 'last_7_days':
$this->startDate = now()->subDays(7)->startOfDay();
$this->endDate = now()->endOfDay();
break;
case 'last_30_days':
$this->startDate = now()->subDays(30)->startOfDay();
$this->endDate = now()->endOfDay();
break;
case 'this_month':
$this->startDate = now()->startOfMonth();
$this->endDate = now()->endOfMonth();
break;
case 'last_month':
$this->startDate = now()->subMonth()->startOfMonth();
$this->endDate = now()->subMonth()->endOfMonth();
break;
case 'this_quarter':
$this->startDate = now()->startOfQuarter();
$this->endDate = now()->endOfQuarter();
break;
case 'this_year':
$this->startDate = now()->startOfYear();
$this->endDate = now()->endOfYear();
break;
case 'last_year':
$this->startDate = now()->subYear()->startOfYear();
$this->endDate = now()->subYear()->endOfYear();
break;
}
}
public function loadAllData()
{
$this->loadOverviewStats();
if ($this->selectedReport === 'revenue' || $this->selectedReport === 'overview') {
$this->loadRevenueData();
}
if ($this->selectedReport === 'customer_analytics' || $this->selectedReport === 'overview') {
$this->loadCustomerAnalytics();
}
if ($this->selectedReport === 'service_trends' || $this->selectedReport === 'overview') {
$this->loadServiceTrends();
}
if ($this->selectedReport === 'performance_metrics' || $this->selectedReport === 'overview') {
$this->loadPerformanceMetrics();
}
}
public function loadOverviewStats()
{
$this->overviewStats = [
'total_revenue' => 125000.50,
'total_orders' => 1248,
'completed_orders' => 1156,
'new_customers' => 47,
'total_customers' => 892,
'total_appointments' => 1248,
'confirmed_appointments' => 1156,
'avg_order_value' => 285.50,
'customer_satisfaction' => 4.3
];
}
public function loadRevenueData()
{
$this->revenueData = Report::getRevenueData($this->startDate, $this->endDate);
}
public function loadCustomerAnalytics()
{
$this->customerAnalytics = Report::getCustomerAnalytics($this->startDate, $this->endDate);
}
public function loadServiceTrends()
{
$this->serviceTrends = Report::getServiceTrends($this->startDate, $this->endDate);
}
public function loadPerformanceMetrics()
{
$this->performanceMetrics = Report::getPerformanceMetrics($this->startDate, $this->endDate);
}
private function getGroupByFromDateRange()
{
return in_array($this->dateRange, ['this_month', 'last_month', 'this_quarter', 'this_year', 'last_year'])
? 'month'
: 'day';
}
public function exportReport($type = 'pdf')
{
// TODO: Implement export functionality
$this->dispatch('notify', [
'type' => 'info',
'message' => 'Export functionality will be implemented soon.'
]);
}
public function getDateRangeOptions()
{
return [
'today' => 'Today',
'yesterday' => 'Yesterday',
'last_7_days' => 'Last 7 Days',
'last_30_days' => 'Last 30 Days',
'this_month' => 'This Month',
'last_month' => 'Last Month',
'this_quarter' => 'This Quarter',
'this_year' => 'This Year',
'last_year' => 'Last Year'
];
}
public function getReportOptions()
{
return [
'overview' => 'Overview',
'revenue' => 'Revenue Analysis',
'customer_analytics' => 'Customer Analytics',
'service_trends' => 'Service Trends',
'performance_metrics' => 'Performance Metrics'
];
}
public function render()
{
return view('livewire.reports.dashboard')->layout('components.layouts.app', [
'title' => 'Reports & Analytics'
]);
}
}

View File

@ -0,0 +1,169 @@
<?php
namespace App\Livewire\ServiceItems;
use App\Models\ServiceItem;
use Livewire\Component;
use Livewire\WithPagination;
class Manage extends Component
{
use WithPagination;
public $service_name = '';
public $description = '';
public $category = '';
public $labor_rate = 85.00;
public $estimated_hours = 1.0;
public $status = 'active';
public $technician_notes = '';
public $editingId = null;
public $showForm = false;
public $searchTerm = '';
public $categoryFilter = '';
public $categories = [
'Diagnosis' => 'Diagnosis',
'Engine' => 'Engine',
'Brake' => 'Brake',
'Suspension' => 'Suspension',
'Electrical' => 'Electrical',
'Transmission' => 'Transmission',
'Maintenance' => 'Maintenance',
'HVAC' => 'HVAC',
'Cooling' => 'Cooling',
'Exterior' => 'Exterior',
'Interior' => 'Interior',
'Other' => 'Other',
];
protected $rules = [
'service_name' => 'required|string|max:255',
'description' => 'nullable|string',
'category' => 'required|string|max:100',
'labor_rate' => 'required|numeric|min:0|max:500',
'estimated_hours' => 'required|numeric|min:0.25|max:40',
'status' => 'required|in:active,inactive',
'technician_notes' => 'nullable|string',
];
protected $messages = [
'service_name.required' => 'Service name is required.',
'category.required' => 'Category is required.',
'labor_rate.required' => 'Labor rate is required.',
'labor_rate.min' => 'Labor rate must be at least $0.',
'labor_rate.max' => 'Labor rate cannot exceed $500/hour.',
'estimated_hours.required' => 'Estimated hours is required.',
'estimated_hours.min' => 'Estimated hours must be at least 0.25 hours.',
'estimated_hours.max' => 'Estimated hours cannot exceed 40 hours.',
];
public function updatingSearchTerm()
{
$this->resetPage();
}
public function updatingCategoryFilter()
{
$this->resetPage();
}
public function toggleForm()
{
$this->showForm = !$this->showForm;
if (!$this->showForm) {
$this->resetForm();
}
}
public function resetForm()
{
$this->reset([
'service_name', 'description', 'category', 'labor_rate',
'estimated_hours', 'status', 'technician_notes', 'editingId'
]);
$this->labor_rate = 85.00;
$this->estimated_hours = 1.0;
$this->status = 'active';
}
public function save()
{
$this->validate();
if ($this->editingId) {
// Update existing service item
ServiceItem::findOrFail($this->editingId)->update([
'service_name' => $this->service_name,
'description' => $this->description,
'category' => $this->category,
'labor_rate' => $this->labor_rate,
'estimated_hours' => $this->estimated_hours,
'status' => $this->status,
'technician_notes' => $this->technician_notes,
]);
session()->flash('message', 'Service item updated successfully!');
} else {
// Create new service item
ServiceItem::create([
'service_name' => $this->service_name,
'description' => $this->description,
'category' => $this->category,
'labor_rate' => $this->labor_rate,
'estimated_hours' => $this->estimated_hours,
'status' => $this->status,
'technician_notes' => $this->technician_notes,
]);
session()->flash('message', 'Service item created successfully!');
}
$this->resetForm();
$this->showForm = false;
}
public function edit($id)
{
$serviceItem = ServiceItem::findOrFail($id);
$this->editingId = $id;
$this->service_name = $serviceItem->service_name;
$this->description = $serviceItem->description;
$this->category = $serviceItem->category;
$this->labor_rate = $serviceItem->labor_rate;
$this->estimated_hours = $serviceItem->estimated_hours;
$this->status = $serviceItem->status;
$this->technician_notes = $serviceItem->technician_notes;
$this->showForm = true;
}
public function delete($id)
{
ServiceItem::findOrFail($id)->delete();
session()->flash('message', 'Service item deleted successfully!');
}
public function render()
{
$query = ServiceItem::query();
if ($this->searchTerm) {
$query->where(function ($q) {
$q->where('service_name', 'like', '%' . $this->searchTerm . '%')
->orWhere('description', 'like', '%' . $this->searchTerm . '%');
});
}
if ($this->categoryFilter) {
$query->where('category', $this->categoryFilter);
}
$serviceItems = $query->orderBy('service_name')->paginate(15);
return view('livewire.service-items.manage', [
'serviceItems' => $serviceItems
]);
}
}

View File

@ -0,0 +1,312 @@
<?php
namespace App\Livewire\ServiceOrders;
use Livewire\Component;
use App\Models\ServiceOrder;
use App\Models\Customer;
use App\Models\Vehicle;
use App\Models\Technician;
use App\Models\ServiceItem;
use App\Models\Part;
use Livewire\Attributes\Validate;
class Create extends Component
{
#[Validate('required|exists:customers,id')]
public $customer_id = '';
#[Validate('required|exists:vehicles,id')]
public $vehicle_id = '';
#[Validate('nullable|exists:technicians,id')]
public $assigned_technician_id = '';
#[Validate('required|string|max:1000')]
public $customer_complaint = '';
#[Validate('nullable|string|max:1000')]
public $recommended_services = '';
#[Validate('required|in:low,normal,high,urgent')]
public $priority = 'normal';
#[Validate('required|in:pending,in_progress,completed,cancelled,on_hold')]
public $status = 'pending';
#[Validate('nullable|date')]
public $scheduled_date = '';
#[Validate('nullable|numeric|min:0')]
public $estimated_hours = 0;
#[Validate('nullable|string|max:1000')]
public $internal_notes = '';
#[Validate('nullable|string|max:1000')]
public $customer_notes = '';
// Service Items
public $serviceItems = [];
public $newServiceItem = [
'service_name' => '',
'description' => '',
'category' => '',
'labor_rate' => 75.00,
'estimated_hours' => 1.0,
'status' => 'pending',
'technician_notes' => '',
];
// Parts
public $selectedParts = [];
public $newPart = [
'part_id' => '',
'quantity_used' => 1,
'unit_price' => 0,
'notes' => '',
];
// Available data
public $customers = [];
public $vehicles = [];
public $technicians = [];
public $availableParts = [];
public function mount()
{
$this->loadCustomers();
$this->loadTechnicians();
$this->loadParts();
// Pre-select vehicle if passed in query string
if (request()->has('vehicle')) {
$this->vehicle_id = request('vehicle');
$this->updatedVehicleId();
}
}
public function loadCustomers()
{
$this->customers = Customer::where('status', 'active')
->orderBy('first_name')
->get();
}
public function loadTechnicians()
{
$this->technicians = Technician::where('status', 'active')
->orderBy('first_name')
->get();
}
public function loadParts()
{
$this->availableParts = Part::where('status', 'active')
->where('quantity_on_hand', '>', 0)
->orderBy('name')
->get();
}
public function updatedCustomerId()
{
if ($this->customer_id) {
$this->vehicles = Vehicle::where('customer_id', $this->customer_id)
->where('status', 'active')
->orderBy('year')
->orderBy('make')
->orderBy('model')
->get();
} else {
$this->vehicles = [];
$this->vehicle_id = '';
}
}
public function updatedVehicleId()
{
if ($this->vehicle_id) {
$vehicle = Vehicle::find($this->vehicle_id);
if ($vehicle) {
$this->customer_id = $vehicle->customer_id;
$this->updatedCustomerId();
}
}
}
public function addServiceItem()
{
$this->validate([
'newServiceItem.service_name' => 'required|string|max:255',
'newServiceItem.description' => 'nullable|string|max:500',
'newServiceItem.category' => 'required|string|max:100',
'newServiceItem.labor_rate' => 'required|numeric|min:0',
'newServiceItem.estimated_hours' => 'required|numeric|min:0.1',
]);
$this->serviceItems[] = [
'service_name' => $this->newServiceItem['service_name'],
'description' => $this->newServiceItem['description'],
'category' => $this->newServiceItem['category'],
'labor_rate' => $this->newServiceItem['labor_rate'],
'estimated_hours' => $this->newServiceItem['estimated_hours'],
'labor_cost' => $this->newServiceItem['labor_rate'] * $this->newServiceItem['estimated_hours'],
'status' => $this->newServiceItem['status'],
'technician_notes' => $this->newServiceItem['technician_notes'],
];
// Reset form
$this->newServiceItem = [
'service_name' => '',
'description' => '',
'category' => '',
'labor_rate' => 75.00,
'estimated_hours' => 1.0,
'status' => 'pending',
'technician_notes' => '',
];
}
public function removeServiceItem($index)
{
unset($this->serviceItems[$index]);
$this->serviceItems = array_values($this->serviceItems);
}
public function addPart()
{
$this->validate([
'newPart.part_id' => 'required|exists:parts,id',
'newPart.quantity_used' => 'required|integer|min:1',
'newPart.unit_price' => 'required|numeric|min:0',
]);
$part = Part::find($this->newPart['part_id']);
if ($part) {
$this->selectedParts[] = [
'part_id' => $part->id,
'part_name' => $part->name,
'part_number' => $part->part_number,
'quantity_used' => $this->newPart['quantity_used'],
'unit_cost' => $part->cost_price,
'unit_price' => $this->newPart['unit_price'],
'total_cost' => $part->cost_price * $this->newPart['quantity_used'],
'total_price' => $this->newPart['unit_price'] * $this->newPart['quantity_used'],
'status' => 'requested',
'notes' => $this->newPart['notes'],
];
// Reset form
$this->newPart = [
'part_id' => '',
'quantity_used' => 1,
'unit_price' => 0,
'notes' => '',
];
}
}
public function removePart($index)
{
unset($this->selectedParts[$index]);
$this->selectedParts = array_values($this->selectedParts);
}
public function updatedNewPartPartId()
{
if ($this->newPart['part_id']) {
$part = Part::find($this->newPart['part_id']);
if ($part) {
$this->newPart['unit_price'] = $part->sell_price;
}
}
}
public function getTotalLaborCost()
{
return collect($this->serviceItems)->sum('labor_cost');
}
public function getTotalPartsCost()
{
return collect($this->selectedParts)->sum('total_price');
}
public function getSubtotal()
{
return $this->getTotalLaborCost() + $this->getTotalPartsCost();
}
public function getTaxAmount()
{
return $this->getSubtotal() * 0.08; // 8% tax
}
public function getTotalAmount()
{
return $this->getSubtotal() + $this->getTaxAmount();
}
public function createServiceOrder()
{
$this->validate();
// Create the service order
$serviceOrder = ServiceOrder::create([
'customer_id' => $this->customer_id,
'vehicle_id' => $this->vehicle_id,
'assigned_technician_id' => $this->assigned_technician_id ?: null,
'customer_complaint' => $this->customer_complaint,
'recommended_services' => $this->recommended_services,
'priority' => $this->priority,
'status' => $this->status,
'scheduled_date' => $this->scheduled_date ?: null,
'estimated_hours' => $this->estimated_hours,
'internal_notes' => $this->internal_notes,
'customer_notes' => $this->customer_notes,
'labor_cost' => $this->getTotalLaborCost(),
'parts_cost' => $this->getTotalPartsCost(),
'tax_amount' => $this->getTaxAmount(),
'discount_amount' => 0,
'total_amount' => $this->getTotalAmount(),
]);
// Create service items
foreach ($this->serviceItems as $item) {
ServiceItem::create([
'service_order_id' => $serviceOrder->id,
'service_name' => $item['service_name'],
'description' => $item['description'],
'category' => $item['category'],
'labor_rate' => $item['labor_rate'],
'estimated_hours' => $item['estimated_hours'],
'labor_cost' => $item['labor_cost'],
'status' => $item['status'],
'technician_notes' => $item['technician_notes'],
]);
}
// Attach parts
foreach ($this->selectedParts as $part) {
$serviceOrder->parts()->attach($part['part_id'], [
'quantity_used' => $part['quantity_used'],
'unit_cost' => $part['unit_cost'],
'unit_price' => $part['unit_price'],
'total_cost' => $part['total_cost'],
'total_price' => $part['total_price'],
'status' => $part['status'],
'notes' => $part['notes'],
]);
}
session()->flash('success', 'Service order created successfully!');
return $this->redirect('/service-orders/' . $serviceOrder->id, navigate: true);
}
public function render()
{
return view('livewire.service-orders.create');
}
}

View File

@ -0,0 +1,244 @@
<?php
namespace App\Livewire\ServiceOrders;
use App\Models\ServiceOrder;
use App\Models\Customer;
use App\Models\Vehicle;
use App\Models\Technician;
use App\Models\Part;
use Livewire\Component;
use Livewire\Attributes\Validate;
class Edit extends Component
{
public ServiceOrder $serviceOrder;
#[Validate('required')]
public $customer_id;
#[Validate('required')]
public $vehicle_id;
#[Validate('required')]
public $technician_id;
#[Validate('required|string|max:255')]
public $customer_complaint;
#[Validate('nullable|string')]
public $diagnosis;
#[Validate('nullable|string')]
public $customer_notes;
#[Validate('nullable|numeric|min:0')]
public $discount_amount = 0;
#[Validate('required|string')]
public $status;
public $serviceItems = [];
public $selectedParts = [];
public $customers = [];
public $vehicles = [];
public $technicians = [];
public $availableParts = [];
public function mount(ServiceOrder $serviceOrder)
{
$this->serviceOrder = $serviceOrder;
$this->customer_id = $serviceOrder->customer_id;
$this->vehicle_id = $serviceOrder->vehicle_id;
$this->technician_id = $serviceOrder->technician_id;
$this->customer_complaint = $serviceOrder->customer_complaint;
$this->diagnosis = $serviceOrder->diagnosis;
$this->customer_notes = $serviceOrder->customer_notes;
$this->discount_amount = $serviceOrder->discount_amount;
$this->status = $serviceOrder->status;
// Load existing service items
$this->serviceItems = $serviceOrder->serviceItems->map(function ($item) {
return [
'id' => $item->id,
'service_name' => $item->service_name,
'description' => $item->description,
'labor_rate' => $item->labor_rate,
'estimated_hours' => $item->estimated_hours,
'labor_cost' => $item->labor_cost,
];
})->toArray();
// Load existing parts
$this->selectedParts = $serviceOrder->parts->map(function ($part) {
return [
'part_id' => $part->id,
'quantity_used' => $part->pivot->quantity_used,
'unit_price' => $part->pivot->unit_price,
'total_price' => $part->pivot->total_price,
];
})->toArray();
$this->loadData();
}
public function loadData()
{
$this->customers = Customer::orderBy('first_name')->get();
$this->vehicles = $this->customer_id ?
Vehicle::where('customer_id', $this->customer_id)->get() :
collect();
$this->technicians = Technician::where('status', 'active')->orderBy('first_name')->get();
$this->availableParts = Part::where('quantity_on_hand', '>', 0)->orderBy('name')->get();
}
public function updatedCustomerId()
{
$this->vehicle_id = null;
$this->vehicles = $this->customer_id ?
Vehicle::where('customer_id', $this->customer_id)->get() :
collect();
}
public function addServiceItem()
{
$this->serviceItems[] = [
'id' => null,
'service_name' => '',
'description' => '',
'labor_rate' => 75.00,
'estimated_hours' => 1,
'labor_cost' => 75.00,
];
}
public function removeServiceItem($index)
{
unset($this->serviceItems[$index]);
$this->serviceItems = array_values($this->serviceItems);
}
public function updateServiceItemCost($index)
{
if (isset($this->serviceItems[$index])) {
$item = &$this->serviceItems[$index];
$item['labor_cost'] = $item['labor_rate'] * $item['estimated_hours'];
}
}
public function addPart()
{
$this->selectedParts[] = [
'part_id' => '',
'quantity_used' => 1,
'unit_price' => 0,
'total_price' => 0,
];
}
public function removePart($index)
{
unset($this->selectedParts[$index]);
$this->selectedParts = array_values($this->selectedParts);
}
public function updatePartPrice($index)
{
if (isset($this->selectedParts[$index])) {
$part = &$this->selectedParts[$index];
if ($part['part_id']) {
$partModel = Part::find($part['part_id']);
if ($partModel) {
$part['unit_price'] = $partModel->cost_price * 1.3; // 30% markup
}
}
$part['total_price'] = $part['quantity_used'] * $part['unit_price'];
}
}
public function updatePartTotal($index)
{
if (isset($this->selectedParts[$index])) {
$part = &$this->selectedParts[$index];
$part['total_price'] = $part['quantity_used'] * $part['unit_price'];
}
}
public function getTotalLaborCost()
{
return collect($this->serviceItems)->sum('labor_cost');
}
public function getTotalPartsCost()
{
return collect($this->selectedParts)->sum('total_price');
}
public function getSubtotal()
{
return $this->getTotalLaborCost() + $this->getTotalPartsCost() - $this->discount_amount;
}
public function getTaxAmount()
{
return $this->getSubtotal() * 0.08; // 8% tax
}
public function getGrandTotal()
{
return $this->getSubtotal() + $this->getTaxAmount();
}
public function update()
{
$this->validate();
// Update the service order
$this->serviceOrder->update([
'customer_id' => $this->customer_id,
'vehicle_id' => $this->vehicle_id,
'technician_id' => $this->technician_id,
'customer_complaint' => $this->customer_complaint,
'diagnosis' => $this->diagnosis,
'customer_notes' => $this->customer_notes,
'discount_amount' => $this->discount_amount,
'status' => $this->status,
'labor_cost' => $this->getTotalLaborCost(),
'parts_cost' => $this->getTotalPartsCost(),
'tax_amount' => $this->getTaxAmount(),
'total_amount' => $this->getGrandTotal(),
]);
// Update service items
$this->serviceOrder->serviceItems()->delete();
foreach ($this->serviceItems as $item) {
$this->serviceOrder->serviceItems()->create([
'service_name' => $item['service_name'],
'description' => $item['description'],
'labor_rate' => $item['labor_rate'],
'estimated_hours' => $item['estimated_hours'],
'labor_cost' => $item['labor_cost'],
]);
}
// Update parts
$this->serviceOrder->parts()->detach();
foreach ($this->selectedParts as $part) {
if ($part['part_id']) {
$this->serviceOrder->parts()->attach($part['part_id'], [
'quantity_used' => $part['quantity_used'],
'unit_price' => $part['unit_price'],
'total_price' => $part['total_price'],
]);
}
}
session()->flash('success', 'Service order updated successfully!');
return redirect()->route('service-orders.show', $this->serviceOrder);
}
public function render()
{
return view('livewire.service-orders.edit');
}
}

View File

@ -0,0 +1,163 @@
<?php
namespace App\Livewire\ServiceOrders;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\ServiceOrder;
use App\Models\Customer;
use App\Models\Vehicle;
use App\Models\Technician;
class Index extends Component
{
use WithPagination;
public $search = '';
public $status = '';
public $priority = '';
public $technician_id = '';
public $date_from = '';
public $date_to = '';
public $sortBy = 'created_at';
public $sortDirection = 'desc';
protected $queryString = [
'search' => ['except' => ''],
'status' => ['except' => ''],
'priority' => ['except' => ''],
'technician_id' => ['except' => ''],
'date_from' => ['except' => ''],
'date_to' => ['except' => ''],
'sortBy' => ['except' => 'created_at'],
'sortDirection' => ['except' => 'desc'],
];
public function updatingSearch()
{
$this->resetPage();
}
public function updatingStatus()
{
$this->resetPage();
}
public function updatingPriority()
{
$this->resetPage();
}
public function updatingTechnicianId()
{
$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 deleteServiceOrder($id)
{
$serviceOrder = ServiceOrder::find($id);
if ($serviceOrder) {
$serviceOrder->delete();
session()->flash('success', 'Service order deleted successfully!');
} else {
session()->flash('error', 'Service order not found.');
}
}
public function updateStatus($id, $status)
{
$serviceOrder = ServiceOrder::find($id);
if ($serviceOrder) {
$serviceOrder->status = $status;
if ($status === 'in_progress' && !$serviceOrder->started_at) {
$serviceOrder->started_at = now();
} elseif ($status === 'completed' && !$serviceOrder->completed_at) {
$serviceOrder->completed_at = now();
}
$serviceOrder->save();
session()->flash('success', 'Service order status updated successfully!');
}
}
public function render()
{
$query = ServiceOrder::query()
->with(['customer', 'vehicle', 'assignedTechnician', 'serviceItems', 'parts']);
// Apply search filter
if ($this->search) {
$query->where(function($q) {
$q->where('order_number', 'like', '%' . $this->search . '%')
->orWhere('customer_complaint', 'like', '%' . $this->search . '%')
->orWhereHas('customer', function($q) {
$q->where('first_name', 'like', '%' . $this->search . '%')
->orWhere('last_name', 'like', '%' . $this->search . '%');
})
->orWhereHas('vehicle', function($q) {
$q->where('make', 'like', '%' . $this->search . '%')
->orWhere('model', 'like', '%' . $this->search . '%')
->orWhere('license_plate', 'like', '%' . $this->search . '%');
});
});
}
// Apply filters
if ($this->status) {
$query->where('status', $this->status);
}
if ($this->priority) {
$query->where('priority', $this->priority);
}
if ($this->technician_id) {
$query->where('assigned_technician_id', $this->technician_id);
}
if ($this->date_from) {
$query->whereDate('created_at', '>=', $this->date_from);
}
if ($this->date_to) {
$query->whereDate('created_at', '<=', $this->date_to);
}
// Apply sorting
$query->orderBy($this->sortBy, $this->sortDirection);
$serviceOrders = $query->paginate(15);
// Load additional data for filters
$technicians = Technician::where('status', 'active')->orderBy('first_name')->get();
// Quick stats
$stats = [
'total' => ServiceOrder::count(),
'pending' => ServiceOrder::where('status', 'pending')->count(),
'in_progress' => ServiceOrder::where('status', 'in_progress')->count(),
'completed_today' => ServiceOrder::where('status', 'completed')
->whereDate('completed_at', today())->count(),
];
return view('livewire.service-orders.index', [
'serviceOrders' => $serviceOrders,
'technicians' => $technicians,
'stats' => $stats,
]);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Livewire\ServiceOrders;
use Livewire\Component;
use App\Models\ServiceOrder;
class Invoice extends Component
{
public ServiceOrder $serviceOrder;
public function mount(ServiceOrder $serviceOrder)
{
$this->serviceOrder = $serviceOrder->load([
'customer',
'vehicle',
'assignedTechnician',
'serviceItems',
'parts'
]);
}
public function render()
{
return view('livewire.service-orders.invoice');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\ServiceOrders;
use Livewire\Component;
use App\Models\ServiceOrder;
class Show extends Component
{
public ServiceOrder $serviceOrder;
public function mount(ServiceOrder $serviceOrder)
{
$this->serviceOrder = $serviceOrder->load([
'customer',
'vehicle',
'assignedTechnician',
'serviceItems',
'parts',
'inspections',
'appointments'
]);
}
public function updateStatus($status)
{
$this->serviceOrder->status = $status;
if ($status === 'in_progress' && !$this->serviceOrder->started_at) {
$this->serviceOrder->started_at = now();
} elseif ($status === 'completed' && !$this->serviceOrder->completed_at) {
$this->serviceOrder->completed_at = now();
}
$this->serviceOrder->save();
session()->flash('success', 'Service order status updated successfully!');
// Refresh the model
$this->mount($this->serviceOrder);
}
public function render()
{
return view('livewire.service-orders.show');
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Livewire\TechnicianManagement;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Technician;
class Index extends Component
{
use WithPagination;
public $search = '';
public $statusFilter = '';
public $skillFilter = '';
public $sortBy = 'first_name';
public $sortDirection = 'asc';
public $selectedTechnician = null;
public $showingDetails = false;
protected $queryString = [
'search' => ['except' => ''],
'statusFilter' => ['except' => ''],
'skillFilter' => ['except' => ''],
'sortBy' => ['except' => 'first_name'],
'sortDirection' => ['except' => 'asc']
];
public function updatingSearch()
{
$this->resetPage();
}
public function updatingStatusFilter()
{
$this->resetPage();
}
public function updatingSkillFilter()
{
$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 showDetails($technicianId)
{
$this->selectedTechnician = Technician::with(['skills', 'performances', 'workloads'])->find($technicianId);
$this->showingDetails = true;
}
public function closeDetails()
{
$this->selectedTechnician = null;
$this->showingDetails = false;
}
public function getTechniciansProperty()
{
return Technician::query()
->when($this->search, function ($query) {
$query->where(function ($q) {
$q->where('first_name', 'like', '%' . $this->search . '%')
->orWhere('last_name', 'like', '%' . $this->search . '%')
->orWhere('email', 'like', '%' . $this->search . '%')
->orWhere('employee_id', 'like', '%' . $this->search . '%');
});
})
->when($this->statusFilter, function ($query) {
$query->where('status', $this->statusFilter);
})
->when($this->skillFilter, function ($query) {
$query->whereHas('skills', function ($q) {
$q->where('skill_name', $this->skillFilter);
});
})
->with(['skills' => function($query) {
$query->orderBy('is_primary_skill', 'desc');
}, 'performances', 'workloads'])
->orderBy($this->sortBy, $this->sortDirection)
->paginate(10);
}
public function getAvailableSkillsProperty()
{
return \App\Models\TechnicianSkill::distinct('skill_name')
->pluck('skill_name')
->sort()
->values();
}
public function render()
{
return view('livewire.technician-management.index', [
'technicians' => $this->technicians,
'availableSkills' => $this->availableSkills
]);
}
}

View File

@ -0,0 +1,251 @@
<?php
namespace App\Livewire\TechnicianManagement;
use Livewire\Component;
use App\Models\Technician;
use App\Models\TechnicianPerformance;
use Livewire\Attributes\On;
use Carbon\Carbon;
class PerformanceTracking extends Component
{
public $showModal = false;
public $technicianId = null;
public $technician = null;
public $performanceId = null;
public $editing = false;
// Date filters
public $startDate = '';
public $endDate = '';
public $periodFilter = 'current_month';
// Form fields
public $metric_type = '';
public $metric_value = '';
public $performance_date = '';
public $period_type = 'daily';
public $notes = '';
// Chart data
public $chartData = [];
public $selectedMetric = 'jobs_completed';
protected $rules = [
'metric_type' => 'required|string|max:255',
'metric_value' => 'required|numeric',
'performance_date' => 'required|date',
'period_type' => 'required|in:daily,weekly,monthly,quarterly,yearly',
'notes' => 'nullable|string|max:1000'
];
public function mount()
{
$this->startDate = now()->startOfMonth()->format('Y-m-d');
$this->endDate = now()->format('Y-m-d');
$this->performance_date = now()->format('Y-m-d');
}
#[On('track-performance')]
public function trackPerformance($technicianId)
{
$this->technicianId = $technicianId;
$this->technician = Technician::with('performances')->findOrFail($technicianId);
$this->showModal = true;
$this->resetForm();
$this->loadChartData();
}
public function updatedPeriodFilter()
{
$this->setDateRange();
$this->loadChartData();
}
public function updatedSelectedMetric()
{
$this->loadChartData();
}
public function updatedStartDate()
{
$this->loadChartData();
}
public function updatedEndDate()
{
$this->loadChartData();
}
public function setDateRange()
{
switch ($this->periodFilter) {
case 'current_week':
$this->startDate = now()->startOfWeek()->format('Y-m-d');
$this->endDate = now()->endOfWeek()->format('Y-m-d');
break;
case 'current_month':
$this->startDate = now()->startOfMonth()->format('Y-m-d');
$this->endDate = now()->endOfMonth()->format('Y-m-d');
break;
case 'current_quarter':
$this->startDate = now()->startOfQuarter()->format('Y-m-d');
$this->endDate = now()->endOfQuarter()->format('Y-m-d');
break;
case 'current_year':
$this->startDate = now()->startOfYear()->format('Y-m-d');
$this->endDate = now()->endOfYear()->format('Y-m-d');
break;
case 'last_30_days':
$this->startDate = now()->subDays(30)->format('Y-m-d');
$this->endDate = now()->format('Y-m-d');
break;
}
}
public function loadChartData()
{
if (!$this->technician) return;
$performances = $this->technician->performances()
->where('metric_type', $this->selectedMetric)
->whereBetween('performance_date', [$this->startDate, $this->endDate])
->orderBy('performance_date')
->get();
$this->chartData = $performances->map(function ($performance) {
return [
'date' => $performance->performance_date->format('Y-m-d'),
'value' => $performance->metric_value,
'formatted_value' => $performance->formatted_value
];
})->toArray();
}
public function addPerformanceRecord()
{
$this->resetForm();
$this->editing = false;
}
public function editPerformance($performanceId)
{
$performance = TechnicianPerformance::findOrFail($performanceId);
$this->performanceId = $performance->id;
$this->metric_type = $performance->metric_type;
$this->metric_value = $performance->metric_value;
$this->performance_date = $performance->performance_date->format('Y-m-d');
$this->period_type = $performance->period_type;
$this->notes = $performance->notes;
$this->editing = true;
}
public function savePerformance()
{
$this->validate();
$data = [
'technician_id' => $this->technicianId,
'metric_type' => $this->metric_type,
'metric_value' => $this->metric_value,
'performance_date' => $this->performance_date,
'period_type' => $this->period_type,
'notes' => $this->notes,
];
if ($this->editing) {
$performance = TechnicianPerformance::findOrFail($this->performanceId);
$performance->update($data);
session()->flash('message', 'Performance record updated successfully!');
} else {
TechnicianPerformance::create($data);
session()->flash('message', 'Performance record added successfully!');
}
$this->technician->refresh();
$this->loadChartData();
$this->resetForm();
}
public function deletePerformance($performanceId)
{
TechnicianPerformance::findOrFail($performanceId)->delete();
$this->technician->refresh();
$this->loadChartData();
session()->flash('message', 'Performance record deleted successfully!');
}
public function closeModal()
{
$this->showModal = false;
$this->resetForm();
}
public function resetForm()
{
$this->performanceId = null;
$this->metric_type = '';
$this->metric_value = '';
$this->performance_date = now()->format('Y-m-d');
$this->period_type = 'daily';
$this->notes = '';
$this->editing = false;
$this->resetErrorBag();
}
public function getMetricTypesProperty()
{
return TechnicianPerformance::getMetricTypes();
}
public function getPeriodTypesProperty()
{
return TechnicianPerformance::getPeriodTypes();
}
public function getFilteredPerformancesProperty()
{
if (!$this->technician) return collect();
return $this->technician->performances()
->whereBetween('performance_date', [$this->startDate, $this->endDate])
->orderBy('performance_date', 'desc')
->get();
}
public function getPerformanceStatsProperty()
{
if (!$this->technician) return [];
$performances = $this->filteredPerformances;
$stats = [];
foreach ($this->metricTypes as $type => $label) {
$typePerformances = $performances->where('metric_type', $type);
if ($typePerformances->count() > 0) {
$stats[$type] = [
'label' => $label,
'current' => $typePerformances->first()->metric_value ?? 0,
'average' => round($typePerformances->avg('metric_value'), 2),
'total' => round($typePerformances->sum('metric_value'), 2),
'count' => $typePerformances->count()
];
}
}
return $stats;
}
public function render()
{
return view('livewire.technician-management.performance-tracking', [
'metricTypes' => $this->metricTypes,
'periodTypes' => $this->periodTypes,
'filteredPerformances' => $this->filteredPerformances,
'performanceStats' => $this->performanceStats
]);
}
}

View File

@ -0,0 +1,146 @@
<?php
namespace App\Livewire\TechnicianManagement;
use Livewire\Component;
use App\Models\Technician;
use App\Models\TechnicianSkill;
use Livewire\Attributes\On;
class SkillsManagement extends Component
{
public $showModal = false;
public $technicianId = null;
public $technician = null;
public $skillId = null;
public $editing = false;
// Form fields
public $skill_name = '';
public $category = '';
public $proficiency_level = 1;
public $certification_body = '';
public $certification_expires = '';
public $is_primary_skill = false;
public $notes = '';
protected $rules = [
'skill_name' => 'required|string|max:255',
'category' => 'required|string|max:255',
'proficiency_level' => 'required|integer|min:1|max:5',
'certification_body' => 'nullable|string|max:255',
'certification_expires' => 'nullable|date',
'is_primary_skill' => 'boolean',
'notes' => 'nullable|string|max:1000'
];
#[On('manage-skills')]
public function manageSkills($technicianId)
{
$this->technicianId = $technicianId;
$this->technician = Technician::with('skills')->findOrFail($technicianId);
$this->showModal = true;
$this->resetForm();
}
public function addSkill()
{
$this->resetForm();
$this->editing = false;
}
public function editSkill($skillId)
{
$skill = TechnicianSkill::findOrFail($skillId);
$this->skillId = $skill->id;
$this->skill_name = $skill->skill_name;
$this->category = $skill->category;
$this->proficiency_level = $skill->proficiency_level;
$this->certification_body = $skill->certification_body;
$this->certification_expires = $skill->certification_expires ? $skill->certification_expires->format('Y-m-d') : '';
$this->is_primary_skill = $skill->is_primary_skill;
$this->notes = $skill->notes;
$this->editing = true;
}
public function saveSkill()
{
$this->validate();
$data = [
'technician_id' => $this->technicianId,
'skill_name' => $this->skill_name,
'category' => $this->category,
'proficiency_level' => $this->proficiency_level,
'certification_body' => $this->certification_body,
'certification_expires' => $this->certification_expires ?: null,
'is_primary_skill' => $this->is_primary_skill,
'notes' => $this->notes,
];
if ($this->editing) {
$skill = TechnicianSkill::findOrFail($this->skillId);
$skill->update($data);
session()->flash('message', 'Skill updated successfully!');
} else {
TechnicianSkill::create($data);
session()->flash('message', 'Skill added successfully!');
}
$this->technician->refresh();
$this->resetForm();
}
public function deleteSkill($skillId)
{
TechnicianSkill::findOrFail($skillId)->delete();
$this->technician->refresh();
session()->flash('message', 'Skill removed successfully!');
}
public function closeModal()
{
$this->showModal = false;
$this->resetForm();
}
public function resetForm()
{
$this->skillId = null;
$this->skill_name = '';
$this->category = '';
$this->proficiency_level = 1;
$this->certification_body = '';
$this->certification_expires = '';
$this->is_primary_skill = false;
$this->notes = '';
$this->editing = false;
$this->resetErrorBag();
}
public function getSkillCategoriesProperty()
{
return TechnicianSkill::getSkillCategories();
}
public function getCommonSkillsProperty()
{
return TechnicianSkill::getCommonSkills();
}
public function getProficiencyLevelsProperty()
{
return TechnicianSkill::getProficiencyLevels();
}
public function render()
{
return view('livewire.technician-management.skills-management', [
'skillCategories' => $this->skillCategories,
'commonSkills' => $this->commonSkills,
'proficiencyLevels' => $this->proficiencyLevels
]);
}
}

View File

@ -0,0 +1,183 @@
<?php
namespace App\Livewire\TechnicianManagement;
use Livewire\Component;
use App\Models\Technician;
use Livewire\Attributes\On;
class TechnicianForm extends Component
{
public $showModal = false;
public $technicianId = null;
public $editing = false;
// Form fields
public $first_name = '';
public $last_name = '';
public $email = '';
public $phone = '';
public $employee_id = '';
public $hourly_rate = '';
public $status = 'active';
public $skill_level = 'junior';
public $shift_start = '08:00';
public $shift_end = '17:00';
public $specializations = [];
// Available options
public $statusOptions = [
'active' => 'Active',
'inactive' => 'Inactive',
'on_leave' => 'On Leave'
];
public $skillLevelOptions = [
'apprentice' => 'Apprentice',
'junior' => 'Junior',
'journeyman' => 'Journeyman',
'intermediate' => 'Intermediate',
'senior' => 'Senior',
'master' => 'Master',
'expert' => 'Expert'
];
public $specializationOptions = [
'engine' => 'Engine',
'engine_repair' => 'Engine Repair',
'transmission' => 'Transmission',
'electrical_systems' => 'Electrical Systems',
'computer_systems' => 'Computer Systems',
'brake_systems' => 'Brake Systems',
'brakes' => 'Brakes',
'suspension' => 'Suspension',
'air_conditioning' => 'Air Conditioning',
'diagnostics' => 'Diagnostics',
'diagnostic' => 'Diagnostic',
'bodywork' => 'Bodywork',
'painting' => 'Painting',
'paint' => 'Paint',
'general_maintenance' => 'General Maintenance'
];
protected $rules = [
'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255',
'email' => 'required|email|max:255',
'phone' => 'required|string|max:20',
'employee_id' => 'required|string|max:50',
'hourly_rate' => 'required|numeric|min:0',
'status' => 'required|in:active,inactive,on_leave',
'skill_level' => 'required|in:apprentice,junior,journeyman,intermediate,senior,master,expert',
'shift_start' => 'required|date_format:H:i',
'shift_end' => 'required|date_format:H:i|after:shift_start',
'specializations' => 'array'
];
protected $messages = [
'shift_end.after' => 'Shift end time must be after shift start time.',
'employee_id.unique' => 'This employee ID is already taken.',
'email.unique' => 'This email address is already taken.'
];
#[On('create-technician')]
public function create()
{
$this->resetForm();
$this->editing = false;
$this->showModal = true;
}
#[On('edit-technician')]
public function edit($technicianId)
{
$technician = Technician::findOrFail($technicianId);
$this->technicianId = $technician->id;
$this->first_name = $technician->first_name;
$this->last_name = $technician->last_name;
$this->email = $technician->email;
$this->phone = $technician->phone;
$this->employee_id = $technician->employee_id;
$this->hourly_rate = $technician->hourly_rate;
$this->status = $technician->status;
$this->skill_level = $technician->skill_level ?? 'junior';
$this->shift_start = $technician->shift_start ? $technician->shift_start->format('H:i') : '08:00';
$this->shift_end = $technician->shift_end ? $technician->shift_end->format('H:i') : '17:00';
$this->specializations = is_array($technician->specializations) ? $technician->specializations : [];
$this->editing = true;
$this->showModal = true;
}
public function save()
{
// Add unique validation rules
$rules = $this->rules;
if ($this->editing) {
$rules['employee_id'] .= ',employee_id,' . $this->technicianId;
$rules['email'] .= ',email,' . $this->technicianId;
} else {
$rules['employee_id'] .= '|unique:technicians,employee_id';
$rules['email'] .= '|unique:technicians,email';
}
$this->validate($rules);
$data = [
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'email' => $this->email,
'phone' => $this->phone,
'employee_id' => $this->employee_id,
'hourly_rate' => $this->hourly_rate,
'status' => $this->status,
'skill_level' => $this->skill_level,
'shift_start' => $this->shift_start,
'shift_end' => $this->shift_end,
'specializations' => $this->specializations,
];
if ($this->editing) {
$technician = Technician::findOrFail($this->technicianId);
$technician->update($data);
session()->flash('message', 'Technician updated successfully!');
} else {
Technician::create($data);
session()->flash('message', 'Technician created successfully!');
}
$this->resetForm();
$this->showModal = false;
$this->dispatch('technician-saved');
}
public function closeModal()
{
$this->resetForm();
$this->showModal = false;
}
public function resetForm()
{
$this->technicianId = null;
$this->first_name = '';
$this->last_name = '';
$this->email = '';
$this->phone = '';
$this->employee_id = '';
$this->hourly_rate = '';
$this->status = 'active';
$this->skill_level = 'junior';
$this->shift_start = '08:00';
$this->shift_end = '17:00';
$this->specializations = [];
$this->editing = false;
$this->resetErrorBag();
}
public function render()
{
return view('livewire.technician-management.technician-form');
}
}

View File

@ -0,0 +1,256 @@
<?php
namespace App\Livewire\TechnicianManagement;
use Livewire\Component;
use App\Models\Technician;
use App\Models\TechnicianWorkload;
use Livewire\Attributes\On;
use Carbon\Carbon;
class WorkloadManagement extends Component
{
public $showModal = false;
public $technicianId = null;
public $technician = null;
public $workloadId = null;
public $editing = false;
// Date filters
public $startDate = '';
public $endDate = '';
public $viewMode = 'week'; // week, month, custom
// Form fields
public $workload_date = '';
public $scheduled_hours = 8.0;
public $actual_hours = 0.0;
public $overtime_hours = 0.0;
public $jobs_assigned = 0;
public $jobs_completed = 0;
public $utilization_rate = 0.0;
public $efficiency_rate = 0.0;
public $notes = '';
protected $rules = [
'workload_date' => 'required|date|unique:technician_workloads,workload_date,NULL,id,technician_id,' . null,
'scheduled_hours' => 'required|numeric|min:0|max:24',
'actual_hours' => 'required|numeric|min:0|max:24',
'overtime_hours' => 'nullable|numeric|min:0|max:12',
'jobs_assigned' => 'required|integer|min:0',
'jobs_completed' => 'required|integer|min:0',
'notes' => 'nullable|string|max:1000'
];
public function mount()
{
$this->setWeekView();
$this->workload_date = now()->format('Y-m-d');
}
#[On('manage-workload')]
public function manageWorkload($technicianId)
{
$this->technicianId = $technicianId;
$this->technician = Technician::with('workloads')->findOrFail($technicianId);
$this->showModal = true;
$this->resetForm();
}
public function updatedViewMode()
{
switch ($this->viewMode) {
case 'week':
$this->setWeekView();
break;
case 'month':
$this->setMonthView();
break;
// custom stays as is
}
}
public function setWeekView()
{
$this->startDate = now()->startOfWeek()->format('Y-m-d');
$this->endDate = now()->endOfWeek()->format('Y-m-d');
}
public function setMonthView()
{
$this->startDate = now()->startOfMonth()->format('Y-m-d');
$this->endDate = now()->endOfMonth()->format('Y-m-d');
}
public function previousPeriod()
{
if ($this->viewMode === 'week') {
$start = Carbon::parse($this->startDate)->subWeek();
$this->startDate = $start->format('Y-m-d');
$this->endDate = $start->endOfWeek()->format('Y-m-d');
} elseif ($this->viewMode === 'month') {
$start = Carbon::parse($this->startDate)->subMonth()->startOfMonth();
$this->startDate = $start->format('Y-m-d');
$this->endDate = $start->endOfMonth()->format('Y-m-d');
}
}
public function nextPeriod()
{
if ($this->viewMode === 'week') {
$start = Carbon::parse($this->startDate)->addWeek();
$this->startDate = $start->format('Y-m-d');
$this->endDate = $start->endOfWeek()->format('Y-m-d');
} elseif ($this->viewMode === 'month') {
$start = Carbon::parse($this->startDate)->addMonth()->startOfMonth();
$this->startDate = $start->format('Y-m-d');
$this->endDate = $start->endOfMonth()->format('Y-m-d');
}
}
public function addWorkloadRecord()
{
$this->resetForm();
$this->editing = false;
}
public function editWorkload($workloadId)
{
$workload = TechnicianWorkload::findOrFail($workloadId);
$this->workloadId = $workload->id;
$this->workload_date = $workload->workload_date->format('Y-m-d');
$this->scheduled_hours = $workload->scheduled_hours;
$this->actual_hours = $workload->actual_hours;
$this->overtime_hours = $workload->overtime_hours;
$this->jobs_assigned = $workload->jobs_assigned;
$this->jobs_completed = $workload->jobs_completed;
$this->notes = $workload->notes;
$this->editing = true;
}
public function saveWorkload()
{
// Update unique rule for editing
if ($this->editing) {
$this->rules['workload_date'] = 'required|date|unique:technician_workloads,workload_date,' . $this->workloadId . ',id,technician_id,' . $this->technicianId;
} else {
$this->rules['workload_date'] = 'required|date|unique:technician_workloads,workload_date,NULL,id,technician_id,' . $this->technicianId;
}
$this->validate();
$data = [
'technician_id' => $this->technicianId,
'workload_date' => $this->workload_date,
'scheduled_hours' => $this->scheduled_hours,
'actual_hours' => $this->actual_hours,
'overtime_hours' => $this->overtime_hours ?? 0,
'jobs_assigned' => $this->jobs_assigned,
'jobs_completed' => $this->jobs_completed,
'notes' => $this->notes,
];
if ($this->editing) {
$workload = TechnicianWorkload::findOrFail($this->workloadId);
$workload->update($data);
// Recalculate rates
$workload->utilization_rate = $workload->calculateUtilizationRate();
$workload->efficiency_rate = $workload->calculateEfficiencyRate();
$workload->save();
session()->flash('message', 'Workload record updated successfully!');
} else {
$workload = TechnicianWorkload::create($data);
// Calculate rates
$workload->utilization_rate = $workload->calculateUtilizationRate();
$workload->efficiency_rate = $workload->calculateEfficiencyRate();
$workload->save();
session()->flash('message', 'Workload record added successfully!');
}
$this->technician->refresh();
$this->resetForm();
}
public function deleteWorkload($workloadId)
{
TechnicianWorkload::findOrFail($workloadId)->delete();
$this->technician->refresh();
session()->flash('message', 'Workload record deleted successfully!');
}
public function closeModal()
{
$this->showModal = false;
$this->resetForm();
}
public function resetForm()
{
$this->workloadId = null;
$this->workload_date = now()->format('Y-m-d');
$this->scheduled_hours = 8.0;
$this->actual_hours = 0.0;
$this->overtime_hours = 0.0;
$this->jobs_assigned = 0;
$this->jobs_completed = 0;
$this->notes = '';
$this->editing = false;
$this->resetErrorBag();
}
public function getFilteredWorkloadsProperty()
{
if (!$this->technician) return collect();
return $this->technician->workloads()
->whereBetween('workload_date', [$this->startDate, $this->endDate])
->orderBy('workload_date')
->get();
}
public function getWorkloadStatsProperty()
{
$workloads = $this->filteredWorkloads;
if ($workloads->isEmpty()) {
return [
'total_scheduled' => 0,
'total_actual' => 0,
'total_overtime' => 0,
'avg_utilization' => 0,
'avg_efficiency' => 0,
'total_jobs_assigned' => 0,
'total_jobs_completed' => 0,
'completion_rate' => 0
];
}
$totalJobsAssigned = $workloads->sum('jobs_assigned');
$totalJobsCompleted = $workloads->sum('jobs_completed');
return [
'total_scheduled' => $workloads->sum('scheduled_hours'),
'total_actual' => $workloads->sum('actual_hours'),
'total_overtime' => $workloads->sum('overtime_hours'),
'avg_utilization' => round($workloads->avg('utilization_rate'), 1),
'avg_efficiency' => round($workloads->avg('efficiency_rate'), 1),
'total_jobs_assigned' => $totalJobsAssigned,
'total_jobs_completed' => $totalJobsCompleted,
'completion_rate' => $totalJobsAssigned > 0 ? round(($totalJobsCompleted / $totalJobsAssigned) * 100, 1) : 0
];
}
public function render()
{
return view('livewire.technician-management.workload-management', [
'filteredWorkloads' => $this->filteredWorkloads,
'workloadStats' => $this->workloadStats
]);
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Livewire\Timesheets;
use App\Models\Timesheet;
use App\Models\JobCard;
use App\Models\WorkOrderTask;
use Livewire\Component;
class Create extends Component
{
public $job_card_id = '';
public $work_order_task_id = '';
public $task_type = 'diagnosis';
public $task_description = '';
public $start_time = '';
public $end_time = '';
public $duration_hours = 0;
public $notes = '';
public $tools_used = '';
public $materials_used = '';
public $completion_percentage = 0;
public $status = 'in_progress';
protected $rules = [
'job_card_id' => 'required|exists:job_cards,id',
'task_type' => 'required|in:diagnosis,repair,maintenance,inspection,other',
'task_description' => 'required|string|max:500',
'start_time' => 'required|date',
'end_time' => 'nullable|date|after:start_time',
'duration_hours' => 'nullable|numeric|min:0',
'completion_percentage' => 'required|integer|min:0|max:100',
'status' => 'required|in:scheduled,in_progress,completed,paused',
];
public function mount()
{
$this->start_time = now()->format('Y-m-d\TH:i');
}
public function updatedEndTime()
{
if ($this->start_time && $this->end_time) {
$start = \Carbon\Carbon::parse($this->start_time);
$end = \Carbon\Carbon::parse($this->end_time);
$this->duration_hours = $end->diffInHours($start, true);
}
}
public function updatedJobCardId()
{
// Reset work order task when job card changes
$this->work_order_task_id = '';
}
public function save()
{
$this->validate();
Timesheet::create([
'job_card_id' => $this->job_card_id,
'work_order_task_id' => $this->work_order_task_id ?: null,
'technician_id' => auth()->id(),
'task_type' => $this->task_type,
'task_description' => $this->task_description,
'start_time' => $this->start_time,
'end_time' => $this->end_time,
'duration_hours' => $this->duration_hours,
'notes' => $this->notes,
'tools_used' => $this->tools_used,
'materials_used' => $this->materials_used,
'completion_percentage' => $this->completion_percentage,
'status' => $this->status,
]);
session()->flash('message', 'Timesheet entry created successfully!');
return redirect()->route('timesheets.index');
}
public function getJobCardsProperty()
{
return JobCard::with(['customer', 'vehicle'])
->whereNotIn('status', ['completed', 'cancelled'])
->orderBy('created_at', 'desc')
->get();
}
public function getWorkOrderTasksProperty()
{
if (!$this->job_card_id) {
return collect();
}
return WorkOrderTask::whereHas('workOrder', function ($query) {
$query->where('job_card_id', $this->job_card_id);
})
->where('status', '!=', 'completed')
->get();
}
public function render()
{
return view('livewire.timesheets.create', [
'jobCards' => $this->jobCards,
'workOrderTasks' => $this->workOrderTasks,
]);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Timesheets;
use Livewire\Component;
class Edit extends Component
{
public function render()
{
return view('livewire.timesheets.edit');
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Livewire\Timesheets;
use App\Models\Timesheet;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
{
use WithPagination;
public $search = '';
public $typeFilter = '';
public $statusFilter = '';
public $dateFilter = '';
public function updatingSearch()
{
$this->resetPage();
}
public function render()
{
$timesheets = Timesheet::with([
'jobCard.customer',
'jobCard.vehicle',
'technician',
'workOrderTask'
])
->when($this->search, function ($query) {
$query->whereHas('jobCard', function ($jobQuery) {
$jobQuery->where('job_number', 'like', '%' . $this->search . '%')
->orWhereHas('customer', function ($customerQuery) {
$customerQuery->where('name', 'like', '%' . $this->search . '%');
});
})
->orWhereHas('technician', function ($techQuery) {
$techQuery->where('name', 'like', '%' . $this->search . '%');
});
})
->when($this->typeFilter, function ($query) {
$query->where('task_type', $this->typeFilter);
})
->when($this->statusFilter, function ($query) {
$query->where('status', $this->statusFilter);
})
->when($this->dateFilter, function ($query) {
$query->whereDate('start_time', $this->dateFilter);
})
->latest()
->paginate(15);
return view('livewire.timesheets.index', compact('timesheets'));
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Timesheets;
use Livewire\Component;
class Show extends Component
{
public function render()
{
return view('livewire.timesheets.show');
}
}

View File

View File

@ -0,0 +1,359 @@
<?php
namespace App\Livewire\Users;
use Livewire\Component;
use Livewire\Attributes\Validate;
use App\Models\User;
use App\Models\Role;
use App\Models\Permission;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rules\Password;
use Illuminate\Support\Str;
class Create extends Component
{
// User basic information
public $name = '';
public $email = '';
public $password = '';
public $password_confirmation = '';
public $employee_id = '';
public $phone = '';
public $department = '';
public $position = '';
public $branch_code = '';
public $hire_date = '';
public $salary = '';
public $status = 'active';
public $emergency_contact_name = '';
public $emergency_contact_phone = '';
public $address = '';
public $date_of_birth = '';
public $national_id = '';
// Role and permission management
public $selectedRoles = [];
public $selectedPermissions = [];
public $sendWelcomeEmail = true;
// UI State
public $showPasswordGenerator = false;
public $generatedPassword = '';
public $currentStep = 1;
public $totalSteps = 3;
public $saving = false;
protected function rules()
{
return [
'name' => 'required|string|max:255|min:2',
'email' => 'required|email|unique:users,email|max:255',
'password' => ['required', 'confirmed', Password::min(8)->letters()->mixedCase()->numbers()->symbols()],
'employee_id' => 'nullable|string|max:50|unique:users,employee_id|regex:/^[A-Z0-9-]+$/',
'phone' => 'nullable|string|max:20|regex:/^[\+]?[0-9\s\-\(\)]+$/',
'department' => 'nullable|string|max:100',
'position' => 'nullable|string|max:100',
'branch_code' => 'required|string|max:10|exists:branches,code',
'hire_date' => 'nullable|date|before_or_equal:today',
'salary' => 'nullable|numeric|min:0|max:999999.99',
'status' => 'required|in:active,inactive,suspended',
'emergency_contact_name' => 'nullable|string|max:255',
'emergency_contact_phone' => 'nullable|string|max:20|regex:/^[\+]?[0-9\s\-\(\)]+$/',
'address' => 'nullable|string|max:500',
'date_of_birth' => 'nullable|date|before:-18 years',
'national_id' => 'nullable|string|max:50|unique:users,national_id',
'selectedRoles' => 'array|min:1',
'selectedRoles.*' => 'exists:roles,id',
'selectedPermissions' => 'array',
'selectedPermissions.*' => 'exists:permissions,id',
];
}
protected $messages = [
'name.min' => 'Name must be at least 2 characters long.',
'branch_code.required' => 'Branch code is required.',
'branch_code.exists' => 'Selected branch code does not exist.',
'email.unique' => 'This email address is already registered.',
'employee_id.unique' => 'This employee ID is already in use.',
'employee_id.regex' => 'Employee ID can only contain letters, numbers, and hyphens.',
'phone.regex' => 'Please enter a valid phone number.',
'emergency_contact_phone.regex' => 'Please enter a valid emergency contact phone number.',
'date_of_birth.before' => 'Employee must be at least 18 years old.',
'hire_date.before_or_equal' => 'Hire date cannot be in the future.',
'national_id.unique' => 'This national ID is already registered.',
'selectedRoles.min' => 'Please assign at least one role to the user.',
'salary.max' => 'Salary cannot exceed 999,999.99.',
];
public function mount()
{
$this->hire_date = now()->format('Y-m-d');
$this->branch_code = auth()->user()->branch_code ?? '';
}
public function render()
{
$roles = Role::where('is_active', true)
->orderBy('display_name')
->get();
$permissions = Permission::where('is_active', true)
->orderBy('module')
->orderBy('name')
->get()
->groupBy('module');
$departments = User::select('department')
->distinct()
->whereNotNull('department')
->where('department', '!=', '')
->orderBy('department')
->pluck('department');
$branches = \DB::table('branches')
->where('is_active', true)
->orderBy('name')
->get(['code', 'name']);
$positions = $this->getPositionsForDepartment($this->department);
return view('livewire.users.create', [
'roles' => $roles,
'permissions' => $permissions,
'departments' => $departments,
'branches' => $branches,
'positions' => $positions,
]);
}
public function save()
{
$this->saving = true;
$this->validate();
DB::beginTransaction();
try {
// Create the user
$user = User::create([
'name' => trim($this->name),
'email' => strtolower(trim($this->email)),
'password' => Hash::make($this->password),
'employee_id' => $this->employee_id ? strtoupper(trim($this->employee_id)) : null,
'phone' => $this->phone ? preg_replace('/[^0-9+\-\s\(\)]/', '', $this->phone) : null,
'department' => $this->department ?: null,
'position' => $this->position ?: null,
'branch_code' => $this->branch_code,
'hire_date' => $this->hire_date ?: null,
'salary' => $this->salary ?: null,
'status' => $this->status,
'emergency_contact_name' => trim($this->emergency_contact_name) ?: null,
'emergency_contact_phone' => $this->emergency_contact_phone ? preg_replace('/[^0-9+\-\s\(\)]/', '', $this->emergency_contact_phone) : null,
'address' => trim($this->address) ?: null,
'date_of_birth' => $this->date_of_birth ?: null,
'national_id' => $this->national_id ? trim($this->national_id) : null,
'email_verified_at' => now(),
'created_by' => auth()->id(),
]);
// Assign roles
if (!empty($this->selectedRoles)) {
foreach ($this->selectedRoles as $roleId) {
$role = Role::find($roleId);
if ($role) {
$user->assignRole($role, $this->branch_code);
}
}
}
// Assign direct permissions
if (!empty($this->selectedPermissions)) {
foreach ($this->selectedPermissions as $permissionId) {
$permission = Permission::find($permissionId);
if ($permission) {
$user->givePermission($permission, $this->branch_code);
}
}
}
// Log the creation
activity()
->performedOn($user)
->causedBy(auth()->user())
->withProperties([
'user_data' => [
'name' => $user->name,
'email' => $user->email,
'employee_id' => $user->employee_id,
'department' => $user->department,
'branch_code' => $user->branch_code,
'status' => $user->status,
],
'roles_assigned' => $this->selectedRoles,
'permissions_assigned' => $this->selectedPermissions,
])
->log('User created');
// Send welcome email if requested
if ($this->sendWelcomeEmail) {
try {
// TODO: Implement welcome email notification
// $user->notify(new WelcomeNotification($this->password));
} catch (\Exception $e) {
// Log email failure but don't fail the user creation
\Log::warning('Failed to send welcome email to user: ' . $user->email, ['error' => $e->getMessage()]);
}
}
DB::commit();
session()->flash('success', "User '{$user->name}' created successfully!");
$this->saving = false;
return redirect()->route('users.show', $user);
} catch (\Exception $e) {
DB::rollBack();
$this->saving = false;
\Log::error('Failed to create user', [
'error' => $e->getMessage(),
'user_data' => [
'name' => $this->name,
'email' => $this->email,
'employee_id' => $this->employee_id,
]
]);
session()->flash('error', 'Failed to create user: ' . $e->getMessage());
}
}
public function cancel()
{
return redirect()->route('users.index');
}
public function generatePassword()
{
$this->generatedPassword = $this->generateSecurePassword();
$this->password = $this->generatedPassword;
$this->password_confirmation = $this->generatedPassword;
$this->showPasswordGenerator = true;
}
public function generateSecurePassword($length = 12)
{
// Generate a secure password with mixed case, numbers, and symbols
$uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$lowercase = 'abcdefghijklmnopqrstuvwxyz';
$numbers = '0123456789';
$symbols = '!@#$%^&*()_+-=[]{}|;:,.<>?';
$password = '';
$password .= $uppercase[random_int(0, strlen($uppercase) - 1)];
$password .= $lowercase[random_int(0, strlen($lowercase) - 1)];
$password .= $numbers[random_int(0, strlen($numbers) - 1)];
$password .= $symbols[random_int(0, strlen($symbols) - 1)];
$allChars = $uppercase . $lowercase . $numbers . $symbols;
for ($i = 4; $i < $length; $i++) {
$password .= $allChars[random_int(0, strlen($allChars) - 1)];
}
return str_shuffle($password);
}
public function nextStep()
{
if ($this->currentStep < $this->totalSteps) {
$this->currentStep++;
}
}
public function previousStep()
{
if ($this->currentStep > 1) {
$this->currentStep--;
}
}
public function updatedDepartment()
{
// Clear position when department changes
$this->position = '';
}
public function getPositionsForDepartment($department)
{
$positions = [
'Service' => ['Service Advisor', 'Service Manager', 'Service Coordinator', 'Service Writer'],
'Technician' => ['Lead Technician', 'Senior Technician', 'Junior Technician', 'Apprentice Technician'],
'Parts' => ['Parts Manager', 'Parts Associate', 'Inventory Specialist', 'Parts Counter Person'],
'Administration' => ['Administrator', 'Office Manager', 'Receptionist', 'Data Entry Clerk'],
'Management' => ['General Manager', 'Assistant Manager', 'Supervisor', 'Team Lead'],
'Sales' => ['Sales Manager', 'Sales Associate', 'Sales Coordinator'],
'Finance' => ['Finance Manager', 'Accountant', 'Cashier', 'Billing Specialist'],
];
return $positions[$department] ?? [];
}
public function validateStep1()
{
$this->validateOnly([
'name',
'email',
'password',
'password_confirmation',
'employee_id',
'phone',
]);
}
public function validateStep2()
{
$this->validateOnly([
'department',
'position',
'branch_code',
'hire_date',
'salary',
'status',
]);
}
public function copyPasswordToClipboard()
{
// This will be handled by Alpine.js on the frontend
$this->dispatch('password-copied');
}
public function getRolePermissionCount($roleId)
{
$role = Role::find($roleId);
return $role ? $role->permissions()->count() : 0;
}
public function getSelectedRolesPermissions()
{
if (empty($this->selectedRoles)) {
return collect();
}
return Permission::whereHas('roles', function($query) {
$query->whereIn('roles.id', $this->selectedRoles);
})->get();
}
public function hasValidationErrors()
{
return $this->getErrorBag()->isNotEmpty();
}
public function getProgressPercentage()
{
return ($this->currentStep / $this->totalSteps) * 100;
}
}

467
app/Livewire/Users/Edit.php Normal file
View File

@ -0,0 +1,467 @@
<?php
namespace App\Livewire\Users;
use Livewire\Component;
use App\Models\User;
use App\Models\Role;
use App\Models\Permission;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rules\Password;
class Edit extends Component
{
public User $user;
// User basic information
public $name = '';
public $email = '';
public $password = '';
public $password_confirmation = '';
public $employee_id = '';
public $phone = '';
public $department = '';
public $position = '';
public $branch_code = '';
public $hire_date = '';
public $salary = '';
public $status = 'active';
public $emergency_contact_name = '';
public $emergency_contact_phone = '';
public $address = '';
public $date_of_birth = '';
public $national_id = '';
// Role and permission management
public $selectedRoles = [];
public $selectedPermissions = [];
public $changePassword = false;
public $showDeleteModal = false;
// UI State
public $currentTab = 'profile';
public $saving = false;
public $showPasswordGenerator = false;
public $generatedPassword = '';
public $originalData = [];
protected function rules()
{
$rules = [
'name' => 'required|string|max:255|min:2',
'email' => 'required|email|unique:users,email,' . $this->user->id . '|max:255',
'employee_id' => 'nullable|string|max:50|unique:users,employee_id,' . $this->user->id . '|regex:/^[A-Z0-9-]+$/',
'phone' => 'nullable|string|max:20|regex:/^[\+]?[0-9\s\-\(\)]+$/',
'department' => 'nullable|string|max:100',
'position' => 'nullable|string|max:100',
'branch_code' => 'required|string|max:10|exists:branches,code',
'hire_date' => 'nullable|date|before_or_equal:today',
'salary' => 'nullable|numeric|min:0|max:999999.99',
'status' => 'required|in:active,inactive,suspended',
'emergency_contact_name' => 'nullable|string|max:255',
'emergency_contact_phone' => 'nullable|string|max:20|regex:/^[\+]?[0-9\s\-\(\)]+$/',
'address' => 'nullable|string|max:500',
'date_of_birth' => 'nullable|date|before:-18 years',
'national_id' => 'nullable|string|max:50|unique:users,national_id,' . $this->user->id,
'selectedRoles' => 'array',
'selectedRoles.*' => 'exists:roles,id',
'selectedPermissions' => 'array',
'selectedPermissions.*' => 'exists:permissions,id',
];
if ($this->changePassword) {
$rules['password'] = ['required', 'confirmed', Password::min(8)->letters()->mixedCase()->numbers()->symbols()];
}
return $rules;
}
protected $messages = [
'name.min' => 'Name must be at least 2 characters long.',
'branch_code.required' => 'Branch code is required.',
'branch_code.exists' => 'Selected branch code does not exist.',
'email.unique' => 'This email address is already registered.',
'employee_id.unique' => 'This employee ID is already in use.',
'employee_id.regex' => 'Employee ID can only contain letters, numbers, and hyphens.',
'phone.regex' => 'Please enter a valid phone number.',
'emergency_contact_phone.regex' => 'Please enter a valid emergency contact phone number.',
'date_of_birth.before' => 'Employee must be at least 18 years old.',
'hire_date.before_or_equal' => 'Hire date cannot be in the future.',
'national_id.unique' => 'This national ID is already registered.',
'salary.max' => 'Salary cannot exceed 999,999.99.',
];
public function mount(User $user)
{
$this->user = $user;
// Store original data for change tracking
$this->originalData = [
'name' => $user->name,
'email' => $user->email,
'employee_id' => $user->employee_id,
'phone' => $user->phone,
'department' => $user->department,
'position' => $user->position,
'branch_code' => $user->branch_code,
'status' => $user->status,
];
// Load user data
$this->name = $user->name;
$this->email = $user->email;
$this->employee_id = $user->employee_id;
$this->phone = $user->phone;
$this->department = $user->department;
$this->position = $user->position;
$this->branch_code = $user->branch_code;
$this->hire_date = $user->hire_date ? $user->hire_date->format('Y-m-d') : '';
$this->salary = $user->salary;
$this->status = $user->status;
$this->emergency_contact_name = $user->emergency_contact_name;
$this->emergency_contact_phone = $user->emergency_contact_phone;
$this->address = $user->address;
$this->date_of_birth = $user->date_of_birth ? $user->date_of_birth->format('Y-m-d') : '';
$this->national_id = $user->national_id;
// Load current roles
$this->selectedRoles = $user->roles()
->where('user_roles.is_active', true)
->where(function ($q) {
$q->whereNull('user_roles.expires_at')
->orWhere('user_roles.expires_at', '>', now());
})
->pluck('roles.id')
->toArray();
// Load current direct permissions
$this->selectedPermissions = $user->permissions()
->where('user_permissions.granted', true)
->where(function ($q) {
$q->whereNull('user_permissions.expires_at')
->orWhere('user_permissions.expires_at', '>', now());
})
->pluck('permissions.id')
->toArray();
}
public function render()
{
$roles = Role::where('is_active', true)
->orderBy('display_name')
->get();
$permissions = Permission::where('is_active', true)
->orderBy('module')
->orderBy('name')
->get()
->groupBy('module');
$departments = User::select('department')
->distinct()
->whereNotNull('department')
->where('department', '!=', '')
->orderBy('department')
->pluck('department');
$branches = \DB::table('branches')
->where('is_active', true)
->orderBy('name')
->get(['code', 'name']);
$positions = $this->getPositionsForDepartment($this->department);
// Get user activity logs
$causedByUser = \Spatie\Activitylog\Models\Activity::where('causer_id', $this->user->id)
->where('causer_type', \App\Models\User::class)
->latest()
->limit(5)
->get();
$performedOnUser = \Spatie\Activitylog\Models\Activity::where('subject_id', $this->user->id)
->where('subject_type', \App\Models\User::class)
->latest()
->limit(5)
->get();
$recentActivity = $causedByUser->merge($performedOnUser)
->sortByDesc('created_at')
->take(10);
return view('livewire.users.edit', [
'availableRoles' => $roles,
'permissions' => $permissions,
'departments' => $departments,
'branches' => $branches,
'positions' => $positions,
'recentActivity' => $recentActivity,
]);
}
public function save()
{
$this->saving = true;
$this->validate();
DB::beginTransaction();
try {
// Prepare update data
$userData = [
'name' => trim($this->name),
'email' => strtolower(trim($this->email)),
'employee_id' => $this->employee_id ? strtoupper(trim($this->employee_id)) : null,
'phone' => $this->phone ? preg_replace('/[^0-9+\-\s\(\)]/', '', $this->phone) : null,
'department' => $this->department ?: null,
'position' => $this->position ?: null,
'branch_code' => $this->branch_code,
'hire_date' => $this->hire_date ?: null,
'salary' => $this->salary ?: null,
'status' => $this->status,
'emergency_contact_name' => trim($this->emergency_contact_name) ?: null,
'emergency_contact_phone' => $this->emergency_contact_phone ? preg_replace('/[^0-9+\-\s\(\)]/', '', $this->emergency_contact_phone) : null,
'address' => trim($this->address) ?: null,
'date_of_birth' => $this->date_of_birth ?: null,
'national_id' => $this->national_id ? trim($this->national_id) : null,
'updated_by' => auth()->id(),
];
// Update password if requested
if ($this->changePassword && $this->password) {
$userData['password'] = Hash::make($this->password);
$userData['password_changed_at'] = now();
}
// Track changes for activity log
$changes = $this->getChangedData($userData);
$this->user->update($userData);
// Sync roles with branch code
$roleData = [];
foreach ($this->selectedRoles as $roleId) {
$roleData[$roleId] = [
'branch_code' => $this->branch_code,
'is_active' => true,
'assigned_at' => now(),
'expires_at' => null,
];
}
$this->user->roles()->sync($roleData);
// Sync direct permissions
$permissionData = [];
foreach ($this->selectedPermissions as $permissionId) {
$permissionData[$permissionId] = [
'granted' => true,
'branch_code' => $this->branch_code,
'assigned_at' => now(),
'expires_at' => null,
];
}
$this->user->permissions()->sync($permissionData);
// Log the update with changes
if (!empty($changes) || $this->changePassword) {
$logProperties = ['changes' => $changes];
if ($this->changePassword) {
$logProperties['password_changed'] = true;
}
activity()
->performedOn($this->user)
->causedBy(auth()->user())
->withProperties($logProperties)
->log('User updated');
}
DB::commit();
session()->flash('success', "User '{$this->user->name}' updated successfully!");
$this->saving = false;
return redirect()->route('users.show', $this->user);
} catch (\Exception $e) {
DB::rollBack();
$this->saving = false;
\Log::error('Failed to update user', [
'user_id' => $this->user->id,
'error' => $e->getMessage(),
]);
session()->flash('error', 'Failed to update user: ' . $e->getMessage());
}
}
public function cancel()
{
return redirect()->route('users.show', $this->user);
}
public function generatePassword()
{
$this->generatedPassword = $this->generateSecurePassword();
$this->password = $this->generatedPassword;
$this->password_confirmation = $this->generatedPassword;
$this->changePassword = true;
$this->showPasswordGenerator = true;
}
public function generateSecurePassword($length = 12)
{
// Generate a secure password with mixed case, numbers, and symbols
$uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$lowercase = 'abcdefghijklmnopqrstuvwxyz';
$numbers = '0123456789';
$symbols = '!@#$%^&*()_+-=[]{}|;:,.<>?';
$password = '';
$password .= $uppercase[random_int(0, strlen($uppercase) - 1)];
$password .= $lowercase[random_int(0, strlen($lowercase) - 1)];
$password .= $numbers[random_int(0, strlen($numbers) - 1)];
$password .= $symbols[random_int(0, strlen($symbols) - 1)];
$allChars = $uppercase . $lowercase . $numbers . $symbols;
for ($i = 4; $i < $length; $i++) {
$password .= $allChars[random_int(0, strlen($allChars) - 1)];
}
return str_shuffle($password);
}
public function resetPassword()
{
try {
$newPassword = \Str::random(12);
$this->user->update([
'password' => Hash::make($newPassword)
]);
// TODO: Send password reset email
// $this->user->notify(new PasswordResetNotification($newPassword));
session()->flash('success', 'Password reset successfully. New password: ' . $newPassword);
} catch (\Exception $e) {
session()->flash('error', 'Failed to reset password: ' . $e->getMessage());
}
}
public function impersonateUser()
{
if ($this->user->id === auth()->id()) {
session()->flash('error', 'You cannot impersonate yourself.');
return;
}
// Store original user ID for returning later
session(['impersonate_original_user' => auth()->id()]);
auth()->loginUsingId($this->user->id);
session()->flash('success', 'Now impersonating ' . $this->user->name);
return redirect()->route('dashboard');
}
public function getAvailableRolesProperty()
{
return Role::orderBy('name')->get();
}
public function confirmDelete()
{
$this->showDeleteModal = true;
}
public function deleteUser()
{
// Prevent self-deletion
if ($this->user->id === auth()->id()) {
session()->flash('error', 'You cannot delete your own account.');
return;
}
try {
$this->user->delete();
session()->flash('success', 'User deleted successfully.');
return redirect()->route('users.index');
} catch (\Exception $e) {
session()->flash('error', 'Failed to delete user: ' . $e->getMessage());
}
$this->showDeleteModal = false;
}
public function setActiveTab($tab)
{
$this->currentTab = $tab;
}
public function getChangedData($newData)
{
$changes = [];
foreach ($newData as $key => $value) {
if (isset($this->originalData[$key]) && $this->originalData[$key] != $value) {
$changes[$key] = [
'old' => $this->originalData[$key],
'new' => $value
];
}
}
return $changes;
}
public function getPositionsForDepartment($department)
{
$positions = [
'Service' => ['Service Advisor', 'Service Manager', 'Service Coordinator', 'Service Writer'],
'Technician' => ['Lead Technician', 'Senior Technician', 'Junior Technician', 'Apprentice Technician'],
'Parts' => ['Parts Manager', 'Parts Associate', 'Inventory Specialist', 'Parts Counter Person'],
'Administration' => ['Administrator', 'Office Manager', 'Receptionist', 'Data Entry Clerk'],
'Management' => ['General Manager', 'Assistant Manager', 'Supervisor', 'Team Lead'],
'Sales' => ['Sales Manager', 'Sales Associate', 'Sales Coordinator'],
'Finance' => ['Finance Manager', 'Accountant', 'Cashier', 'Billing Specialist'],
];
return $positions[$department] ?? [];
}
public function updatedDepartment()
{
// Clear position when department changes unless it's valid for new department
$validPositions = $this->getPositionsForDepartment($this->department);
if (!in_array($this->position, $validPositions)) {
$this->position = '';
}
}
public function hasUnsavedChanges()
{
$currentData = [
'name' => $this->name,
'email' => $this->email,
'employee_id' => $this->employee_id,
'phone' => $this->phone,
'department' => $this->department,
'position' => $this->position,
'branch_code' => $this->branch_code,
'status' => $this->status,
];
return !empty($this->getChangedData($currentData)) || $this->changePassword;
}
public function copyPasswordToClipboard()
{
// This will be handled by Alpine.js on the frontend
$this->dispatch('password-copied');
}
public function togglePasswordVisibility()
{
$this->showPasswordGenerator = !$this->showPasswordGenerator;
}
}

View File

@ -0,0 +1,376 @@
<?php
namespace App\Livewire\Users;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\User;
use App\Models\Role;
class Index extends Component
{
use WithPagination;
public $search = '';
public $roleFilter = '';
public $statusFilter = '';
public $departmentFilter = '';
public $branchFilter = '';
public $sortField = 'name';
public $sortDirection = 'asc';
public $perPage = 25;
public $showInactive = false;
public $selectedUsers = [];
public $selectAll = false;
protected $queryString = [
'search' => ['except' => ''],
'roleFilter' => ['except' => ''],
'statusFilter' => ['except' => ''],
'departmentFilter' => ['except' => ''],
'branchFilter' => ['except' => ''],
'sortField' => ['except' => 'name'],
'sortDirection' => ['except' => 'asc'],
'perPage' => ['except' => 25],
'showInactive' => ['except' => false],
];
public function updatingSearch()
{
$this->resetPage();
}
public function updatingRoleFilter()
{
$this->resetPage();
}
public function updatingStatusFilter()
{
$this->resetPage();
}
public function updatingDepartmentFilter()
{
$this->resetPage();
}
public function updatingBranchFilter()
{
$this->resetPage();
}
public function updatingPerPage()
{
$this->resetPage();
}
public function render()
{
$query = User::query()
->with(['roles' => function($query) {
$query->where('user_roles.is_active', true)
->where(function ($q) {
$q->whereNull('user_roles.expires_at')
->orWhere('user_roles.expires_at', '>', now());
});
}])
->withCount(['roles as active_roles_count' => function($query) {
$query->where('user_roles.is_active', true)
->where(function ($q) {
$q->whereNull('user_roles.expires_at')
->orWhere('user_roles.expires_at', '>', now());
});
}])
->when($this->search, function ($q) {
$q->where(function ($query) {
$query->where('name', 'like', '%' . $this->search . '%')
->orWhere('email', 'like', '%' . $this->search . '%')
->orWhere('employee_id', 'like', '%' . $this->search . '%')
->orWhere('phone', 'like', '%' . $this->search . '%')
->orWhere('national_id', 'like', '%' . $this->search . '%');
});
})
->when($this->roleFilter, function ($q) {
$q->whereHas('roles', function ($query) {
$query->where('roles.name', $this->roleFilter)
->where('user_roles.is_active', true)
->where(function ($subQ) {
$subQ->whereNull('user_roles.expires_at')
->orWhere('user_roles.expires_at', '>', now());
});
});
})
->when($this->statusFilter, function ($q) {
$q->where('status', $this->statusFilter);
})
->when($this->departmentFilter, function ($q) {
$q->where('department', $this->departmentFilter);
})
->when($this->branchFilter, function ($q) {
$q->where('branch_code', $this->branchFilter);
})
->when(!$this->showInactive, function ($q) {
$q->where('status', '!=', 'inactive');
})
->orderBy($this->sortField, $this->sortDirection);
$users = $query->paginate($this->perPage);
$roles = Role::where('is_active', true)->orderBy('display_name')->get();
$departments = User::select('department')
->distinct()
->whereNotNull('department')
->where('department', '!=', '')
->orderBy('department')
->pluck('department');
$branches = User::select('branch_code')
->distinct()
->whereNotNull('branch_code')
->where('branch_code', '!=', '')
->orderBy('branch_code')
->pluck('branch_code');
// Get summary statistics
$stats = [
'total' => User::count(),
'active' => User::where('status', 'active')->count(),
'inactive' => User::where('status', 'inactive')->count(),
'suspended' => User::where('status', 'suspended')->count(),
];
return view('livewire.users.index', compact('users', 'roles', 'departments', 'branches', 'stats'));
}
public function sortBy($field)
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
}
public function clearFilters()
{
$this->search = '';
$this->roleFilter = '';
$this->statusFilter = '';
$this->departmentFilter = '';
$this->branchFilter = '';
$this->sortField = 'name';
$this->sortDirection = 'asc';
$this->showInactive = false;
$this->resetPage();
}
public function toggleShowInactive()
{
$this->showInactive = !$this->showInactive;
$this->resetPage();
}
public function selectAllUsers()
{
if ($this->selectAll) {
$this->selectedUsers = [];
$this->selectAll = false;
} else {
$this->selectedUsers = User::pluck('id')->toArray();
$this->selectAll = true;
}
}
public function bulkActivate()
{
if (empty($this->selectedUsers)) {
session()->flash('error', 'No users selected.');
return;
}
$count = User::whereIn('id', $this->selectedUsers)
->where('id', '!=', auth()->id())
->update(['status' => 'active']);
$this->selectedUsers = [];
$this->selectAll = false;
session()->flash('success', "Activated {$count} users successfully.");
}
public function bulkDeactivate()
{
if (empty($this->selectedUsers)) {
session()->flash('error', 'No users selected.');
return;
}
$count = User::whereIn('id', $this->selectedUsers)
->where('id', '!=', auth()->id())
->update(['status' => 'inactive']);
$this->selectedUsers = [];
$this->selectAll = false;
session()->flash('success', "Deactivated {$count} users successfully.");
}
public function deactivateUser($userId)
{
$user = User::findOrFail($userId);
if ($user->id === auth()->id()) {
session()->flash('error', 'You cannot deactivate your own account.');
return;
}
$user->update(['status' => 'inactive']);
// Log the action
activity()
->performedOn($user)
->causedBy(auth()->user())
->log('User deactivated');
session()->flash('success', "User '{$user->name}' deactivated successfully.");
}
public function activateUser($userId)
{
$user = User::findOrFail($userId);
$user->update(['status' => 'active']);
// Log the action
activity()
->performedOn($user)
->causedBy(auth()->user())
->log('User activated');
session()->flash('success', "User '{$user->name}' activated successfully.");
}
public function suspendUser($userId)
{
$user = User::findOrFail($userId);
if ($user->id === auth()->id()) {
session()->flash('error', 'You cannot suspend your own account.');
return;
}
$user->update(['status' => 'suspended']);
// Log the action
activity()
->performedOn($user)
->causedBy(auth()->user())
->log('User suspended');
session()->flash('success', "User '{$user->name}' suspended successfully.");
}
public function deleteUser($userId)
{
$user = User::findOrFail($userId);
if ($user->id === auth()->id()) {
session()->flash('error', 'You cannot delete your own account.');
return;
}
try {
// Log before deletion
activity()
->performedOn($user)
->causedBy(auth()->user())
->log('User deleted');
$userName = $user->name;
$user->delete();
session()->flash('success', "User '{$userName}' deleted successfully.");
} catch (\Exception $e) {
session()->flash('error', 'Failed to delete user: ' . $e->getMessage());
}
}
public function getUserRoles($user)
{
return $user->roles()
->where('user_roles.is_active', true)
->where(function ($q) {
$q->whereNull('user_roles.expires_at')
->orWhere('user_roles.expires_at', '>', now());
})
->pluck('display_name')
->join(', ');
}
public function getUserPermissionCount($user)
{
return $user->getAllPermissions()->count();
}
public function hasActiveFilters()
{
return !empty($this->search) ||
!empty($this->roleFilter) ||
!empty($this->statusFilter) ||
!empty($this->departmentFilter) ||
!empty($this->branchFilter) ||
$this->showInactive;
}
public function getSelectedCount()
{
return count($this->selectedUsers);
}
public function exportUsers()
{
// This would typically export to CSV or Excel
$users = User::with(['roles'])
->when($this->search, function ($q) {
$q->where(function ($query) {
$query->where('name', 'like', '%' . $this->search . '%')
->orWhere('email', 'like', '%' . $this->search . '%')
->orWhere('employee_id', 'like', '%' . $this->search . '%');
});
})
->get();
session()->flash('success', 'Export initiated for ' . $users->count() . ' users.');
}
public function resetFilters()
{
$this->clearFilters();
}
public function getRoleBadgeClass($roleName)
{
return match($roleName) {
'super_admin' => 'bg-gradient-to-r from-purple-500 to-pink-500 text-white',
'administrator' => 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200',
'manager' => 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200',
'technician' => 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
'receptionist' => 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
'parts_clerk' => 'bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200',
'service_advisor' => 'bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200',
'cashier' => 'bg-pink-100 dark:bg-pink-900 text-pink-800 dark:text-pink-200',
'customer' => 'bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200',
default => 'bg-zinc-100 dark:bg-zinc-700 text-zinc-800 dark:text-zinc-200',
};
}
public function getStatusBadgeClass($status)
{
return match($status) {
'active' => 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
'inactive' => 'bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200',
'suspended' => 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200',
default => 'bg-zinc-100 dark:bg-zinc-700 text-zinc-800 dark:text-zinc-200',
};
}
}

View File

@ -0,0 +1,393 @@
<?php
namespace App\Livewire\Users;
use Livewire\Component;
use App\Models\User;
use App\Models\Role;
use App\Models\Permission;
class ManageRolesPermissions extends Component
{
public User $user;
public $selectedRoles = [];
public $selectedPermissions = [];
public $branchCode = '';
public $expiresAt = '';
public $notes = '';
public $activeTab = 'roles';
// Bulk operations
public $bulkRoleIds = [];
public $bulkPermissionIds = [];
public $bulkAction = '';
protected $rules = [
'selectedRoles' => 'array',
'selectedPermissions' => 'array',
'branchCode' => 'required|string|max:10',
'expiresAt' => 'nullable|date|after:today',
'notes' => 'nullable|string|max:500',
];
public function mount(User $user)
{
$this->user = $user;
$this->branchCode = $user->branch_code ?? auth()->user()->branch_code ?? '';
// Load current roles
$this->selectedRoles = $user->roles()
->where('user_roles.is_active', true)
->where(function ($q) {
$q->whereNull('user_roles.expires_at')
->orWhere('user_roles.expires_at', '>', now());
})
->pluck('roles.id')
->toArray();
// Load current direct permissions
$this->selectedPermissions = $user->permissions()
->where('user_permissions.granted', true)
->where(function ($q) {
$q->whereNull('user_permissions.expires_at')
->orWhere('user_permissions.expires_at', '>', now());
})
->pluck('permissions.id')
->toArray();
}
public function render()
{
$roles = Role::where('is_active', true)
->with('permissions')
->get();
$permissions = Permission::where('is_active', true)
->orderBy('module')
->orderBy('name')
->get();
$permissionsByModule = $permissions->groupBy('module');
// Get user's current roles with details
$currentRoles = $this->user->roles()
->where('user_roles.is_active', true)
->withPivot(['branch_code', 'assigned_at', 'expires_at'])
->get();
// Get user's current direct permissions with details
$currentPermissions = $this->user->permissions()
->where('user_permissions.granted', true)
->withPivot(['branch_code', 'assigned_at', 'expires_at'])
->get();
// Get all effective permissions
$allPermissions = $this->user->getAllPermissions($this->branchCode);
$effectivePermissionsByModule = $allPermissions->groupBy('module');
return view('livewire.users.manage-roles-permissions', [
'availableRoles' => $roles,
'permissions' => $permissions,
'groupedPermissions' => $permissionsByModule,
'currentRoles' => $currentRoles,
'currentPermissions' => $currentPermissions,
'effectivePermissionsByModule' => $effectivePermissionsByModule,
]);
}
public function setActiveTab($tab)
{
$this->activeTab = $tab;
}
public function updateRoles()
{
$this->validate();
try {
// Sync roles with additional data
$roleData = [];
foreach ($this->selectedRoles as $roleId) {
$roleData[$roleId] = [
'branch_code' => $this->branchCode,
'is_active' => true,
'assigned_at' => now(),
'expires_at' => $this->expiresAt ? $this->expiresAt : null,
];
}
$this->user->roles()->sync($roleData);
session()->flash('success', 'User roles updated successfully!');
$this->user->refresh();
} catch (\Exception $e) {
session()->flash('error', 'Failed to update roles: ' . $e->getMessage());
}
}
public function updatePermissions()
{
$this->validate();
try {
// Sync direct permissions
$permissionData = [];
foreach ($this->selectedPermissions as $permissionId) {
$permissionData[$permissionId] = [
'granted' => true,
'branch_code' => $this->branchCode,
'assigned_at' => now(),
'expires_at' => $this->expiresAt ? $this->expiresAt : null,
];
}
$this->user->permissions()->sync($permissionData);
session()->flash('success', 'User permissions updated successfully!');
$this->user->refresh();
} catch (\Exception $e) {
session()->flash('error', 'Failed to update permissions: ' . $e->getMessage());
}
}
public function addRole($roleId)
{
if (!in_array($roleId, $this->selectedRoles)) {
$this->selectedRoles[] = $roleId;
}
}
public function removeRole($roleId)
{
$this->selectedRoles = array_filter($this->selectedRoles, fn($id) => $id != $roleId);
}
public function addPermission($permissionId)
{
if (!in_array($permissionId, $this->selectedPermissions)) {
$this->selectedPermissions[] = $permissionId;
}
}
public function removePermission($permissionId)
{
$this->selectedPermissions = array_filter($this->selectedPermissions, fn($id) => $id != $permissionId);
}
public function selectAllPermissionsInModule($module)
{
$modulePermissions = Permission::where('module', $module)
->where('is_active', true)
->pluck('id')
->toArray();
$this->selectedPermissions = array_unique(array_merge($this->selectedPermissions, $modulePermissions));
}
public function deselectAllPermissionsInModule($module)
{
$modulePermissions = Permission::where('module', $module)
->pluck('id')
->toArray();
$this->selectedPermissions = array_diff($this->selectedPermissions, $modulePermissions);
}
public function copyRolesFromUser($sourceUserId)
{
try {
$sourceUser = User::findOrFail($sourceUserId);
$sourceRoles = $sourceUser->roles()
->where('user_roles.is_active', true)
->pluck('roles.id')
->toArray();
$this->selectedRoles = $sourceRoles;
session()->flash('success', 'Roles copied from ' . $sourceUser->name);
} catch (\Exception $e) {
session()->flash('error', 'Failed to copy roles: ' . $e->getMessage());
}
}
public function presetForRole($roleType)
{
$presets = [
'admin' => Role::where('name', 'admin')->pluck('id')->toArray(),
'manager' => Role::whereIn('name', ['manager', 'service_supervisor'])->pluck('id')->toArray(),
'technician' => Role::where('name', 'technician')->pluck('id')->toArray(),
'advisor' => Role::where('name', 'service_advisor')->pluck('id')->toArray(),
];
if (isset($presets[$roleType])) {
$this->selectedRoles = $presets[$roleType];
}
}
public function bulkExecute()
{
try {
switch ($this->bulkAction) {
case 'add_roles':
foreach ($this->bulkRoleIds as $roleId) {
if (!in_array($roleId, $this->selectedRoles)) {
$this->selectedRoles[] = $roleId;
}
}
break;
case 'remove_roles':
$this->selectedRoles = array_diff($this->selectedRoles, $this->bulkRoleIds);
break;
case 'add_permissions':
foreach ($this->bulkPermissionIds as $permissionId) {
if (!in_array($permissionId, $this->selectedPermissions)) {
$this->selectedPermissions[] = $permissionId;
}
}
break;
case 'remove_permissions':
$this->selectedPermissions = array_diff($this->selectedPermissions, $this->bulkPermissionIds);
break;
}
session()->flash('success', 'Bulk operation completed successfully!');
} catch (\Exception $e) {
session()->flash('error', 'Bulk operation failed: ' . $e->getMessage());
}
}
public function resetToDefault()
{
// Reset to basic role based on user's department/position
$defaultRoles = [];
switch ($this->user->department) {
case 'Service':
$defaultRoles = Role::whereIn('name', ['service_advisor'])->pluck('id')->toArray();
break;
case 'Technician':
$defaultRoles = Role::where('name', 'technician')->pluck('id')->toArray();
break;
case 'Parts':
$defaultRoles = Role::where('name', 'parts_manager')->pluck('id')->toArray();
break;
case 'Management':
$defaultRoles = Role::where('name', 'manager')->pluck('id')->toArray();
break;
}
$this->selectedRoles = $defaultRoles;
$this->selectedPermissions = [];
}
public function applyRolePreset($roleType)
{
// Define role presets
$presets = [
'manager' => [
'roles' => ['manager', 'senior_technician'],
'permissions' => [] // Manager gets permissions through role
],
'technician' => [
'roles' => ['technician'],
'permissions' => [] // Technician gets permissions through role
],
'receptionist' => [
'roles' => ['receptionist'],
'permissions' => [] // Receptionist gets permissions through role
],
'parts_clerk' => [
'roles' => ['parts_manager'],
'permissions' => [] // Parts clerk gets permissions through role
]
];
if (!isset($presets[$roleType])) {
session()->flash('error', 'Invalid role preset.');
return;
}
$preset = $presets[$roleType];
// Get role IDs
$roleIds = Role::whereIn('name', $preset['roles'])->pluck('id')->toArray();
$this->selectedRoles = $roleIds;
// Get permission IDs if any
if (!empty($preset['permissions'])) {
$permissionIds = Permission::whereIn('name', $preset['permissions'])->pluck('id')->toArray();
$this->selectedPermissions = $permissionIds;
} else {
$this->selectedPermissions = [];
}
session()->flash('success', 'Applied ' . ucfirst($roleType) . ' preset successfully.');
}
public function selectAllPermissions()
{
$this->selectedPermissions = Permission::where('is_active', true)->pluck('id')->toArray();
}
public function deselectAllPermissions()
{
$this->selectedPermissions = [];
}
public function selectModulePermissions($module)
{
$modulePermissions = Permission::where('is_active', true)
->where('module', $module)
->pluck('id')
->toArray();
$this->selectedPermissions = array_unique(array_merge($this->selectedPermissions, $modulePermissions));
}
public function deselectModulePermissions($module)
{
$modulePermissions = Permission::where('is_active', true)
->where('module', $module)
->pluck('id')
->toArray();
$this->selectedPermissions = array_diff($this->selectedPermissions, $modulePermissions);
}
public function removeAllRoles()
{
try {
$this->user->roles()->detach();
session()->flash('success', 'All roles removed successfully.');
} catch (\Exception $e) {
session()->flash('error', 'Failed to remove roles: ' . $e->getMessage());
}
}
public function removeAllPermissions()
{
try {
$this->user->permissions()->detach();
session()->flash('success', 'All direct permissions removed successfully.');
} catch (\Exception $e) {
session()->flash('error', 'Failed to remove permissions: ' . $e->getMessage());
}
}
}

506
app/Livewire/Users/Show.php Normal file
View File

@ -0,0 +1,506 @@
<?php
namespace App\Livewire\Users;
use Livewire\Component;
use App\Models\User;
use App\Models\Role;
use App\Models\Permission;
use Illuminate\Support\Facades\Hash;
use Spatie\Activitylog\Models\Activity;
class Show extends Component
{
public User $user;
public $activeTab = 'profile';
public $showRoleModal = false;
public $showPermissionModal = false;
public $selectedRole = null;
public $selectedPermission = null;
public $showDeleteModal = false;
public $showImpersonateModal = false;
public $showActivityModal = false;
// User actions
public $confirmingAction = false;
public $pendingAction = '';
protected $queryString = ['activeTab'];
public function mount(User $user)
{
$this->user = $user->load(['roles.permissions', 'permissions']);
}
public function render()
{
$userRoles = $this->user->roles()
->where('user_roles.is_active', true)
->where(function ($q) {
$q->whereNull('user_roles.expires_at')
->orWhere('user_roles.expires_at', '>', now());
})
->withPivot(['branch_code', 'assigned_at', 'expires_at'])
->get();
$userDirectPermissions = $this->user->permissions()
->where('user_permissions.granted', true)
->where(function ($q) {
$q->whereNull('user_permissions.expires_at')
->orWhere('user_permissions.expires_at', '>', now());
})
->withPivot(['branch_code', 'assigned_at', 'expires_at'])
->get();
$allPermissions = $this->user->getAllPermissions();
$permissionsByModule = $allPermissions->groupBy('module');
// Get role-based permissions
$rolePermissions = collect();
foreach ($userRoles as $role) {
$rolePermissions = $rolePermissions->merge($role->permissions);
}
$rolePermissions = $rolePermissions->unique('id');
// Get recent activity
$causedByUser = Activity::where('causer_id', $this->user->id)
->where('causer_type', User::class)
->latest()
->limit(10)
->get();
$performedOnUser = Activity::where('subject_id', $this->user->id)
->where('subject_type', User::class)
->latest()
->limit(10)
->get();
$recentActivity = $causedByUser->merge($performedOnUser)
->sortByDesc('created_at')
->take(20);
// Get user metrics
$metrics = $this->getUserMetrics();
// Get user's work orders, service orders, etc. (if applicable)
$workStats = $this->getUserWorkStats();
return view('livewire.users.show', [
'userRoles' => $userRoles,
'userDirectPermissions' => $userDirectPermissions,
'allPermissions' => $allPermissions,
'permissionsByModule' => $permissionsByModule,
'rolePermissions' => $rolePermissions,
'recentActivity' => $recentActivity,
'metrics' => $metrics,
'workStats' => $workStats,
]);
}
public function setActiveTab($tab)
{
$this->activeTab = $tab;
}
public function showRoleDetails($roleId)
{
$this->selectedRole = Role::with('permissions')->find($roleId);
$this->showRoleModal = true;
}
public function showPermissionDetails($permissionId)
{
$this->selectedPermission = Permission::find($permissionId);
$this->showPermissionModal = true;
}
public function closeModals()
{
$this->showRoleModal = false;
$this->showPermissionModal = false;
$this->showDeleteModal = false;
$this->showImpersonateModal = false;
$this->showActivityModal = false;
$this->selectedRole = null;
$this->selectedPermission = null;
}
public function removeRole($roleId)
{
try {
$this->user->roles()->detach($roleId);
$this->user->refresh();
// Log the action
activity()
->performedOn($this->user)
->causedBy(auth()->user())
->withProperties(['role_id' => $roleId])
->log('Role removed from user');
session()->flash('success', 'Role removed successfully.');
} catch (\Exception $e) {
session()->flash('error', 'Failed to remove role: ' . $e->getMessage());
}
}
public function removePermission($permissionId)
{
try {
$this->user->permissions()->detach($permissionId);
$this->user->refresh();
// Log the action
activity()
->performedOn($this->user)
->causedBy(auth()->user())
->withProperties(['permission_id' => $permissionId])
->log('Permission removed from user');
session()->flash('success', 'Permission removed successfully.');
} catch (\Exception $e) {
session()->flash('error', 'Failed to remove permission: ' . $e->getMessage());
}
}
public function toggleUserStatus()
{
if ($this->user->id === auth()->id()) {
session()->flash('error', 'You cannot change your own status.');
return;
}
try {
$newStatus = $this->user->status === 'active' ? 'inactive' : 'active';
$oldStatus = $this->user->status;
$this->user->update(['status' => $newStatus]);
// Log the action
activity()
->performedOn($this->user)
->causedBy(auth()->user())
->withProperties([
'old_status' => $oldStatus,
'new_status' => $newStatus,
])
->log('User status changed');
$statusText = $newStatus === 'active' ? 'activated' : 'deactivated';
session()->flash('success', "User {$statusText} successfully.");
} catch (\Exception $e) {
session()->flash('error', 'Failed to update status: ' . $e->getMessage());
}
}
public function suspendUser()
{
if ($this->user->id === auth()->id()) {
session()->flash('error', 'You cannot suspend your own account.');
return;
}
try {
$oldStatus = $this->user->status;
$this->user->update(['status' => 'suspended']);
// Log the action
activity()
->performedOn($this->user)
->causedBy(auth()->user())
->withProperties([
'old_status' => $oldStatus,
'new_status' => 'suspended',
])
->log('User suspended');
session()->flash('success', 'User suspended successfully.');
} catch (\Exception $e) {
session()->flash('error', 'Failed to suspend user: ' . $e->getMessage());
}
}
public function impersonateUser()
{
if ($this->user->id === auth()->id()) {
session()->flash('error', 'You cannot impersonate yourself.');
return;
}
if ($this->user->status !== 'active') {
session()->flash('error', 'Cannot impersonate inactive user.');
return;
}
try {
// Log the impersonation start
activity()
->performedOn($this->user)
->causedBy(auth()->user())
->log('Impersonation started');
// Store original user ID for returning later
session(['impersonate_original_user' => auth()->id()]);
auth()->loginUsingId($this->user->id);
session()->flash('success', 'Now impersonating ' . $this->user->name);
return redirect()->route('dashboard');
} catch (\Exception $e) {
session()->flash('error', 'Failed to impersonate user: ' . $e->getMessage());
}
}
public function sendPasswordReset()
{
try {
// Generate a secure temporary password
$tempPassword = $this->generateSecurePassword();
$this->user->update([
'password' => Hash::make($tempPassword),
'password_changed_at' => now(),
]);
// Log the action
activity()
->performedOn($this->user)
->causedBy(auth()->user())
->log('Password reset by admin');
// TODO: Send password reset email with new temporary password
// $this->user->notify(new PasswordResetByAdminNotification($tempPassword));
session()->flash('success', "Password reset successfully. New temporary password: {$tempPassword}");
} catch (\Exception $e) {
session()->flash('error', 'Failed to reset password: ' . $e->getMessage());
}
}
public function generateSecurePassword($length = 12)
{
$uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$lowercase = 'abcdefghijklmnopqrstuvwxyz';
$numbers = '0123456789';
$symbols = '!@#$%^&*()_+-=[]{}|;:,.<>?';
$password = '';
$password .= $uppercase[random_int(0, strlen($uppercase) - 1)];
$password .= $lowercase[random_int(0, strlen($lowercase) - 1)];
$password .= $numbers[random_int(0, strlen($numbers) - 1)];
$password .= $symbols[random_int(0, strlen($symbols) - 1)];
$allChars = $uppercase . $lowercase . $numbers . $symbols;
for ($i = 4; $i < $length; $i++) {
$password .= $allChars[random_int(0, strlen($allChars) - 1)];
}
return str_shuffle($password);
}
public function exportUserData()
{
try {
// Log the export request
activity()
->performedOn($this->user)
->causedBy(auth()->user())
->log('User data export requested');
// TODO: Implement user data export (GDPR compliance)
// This should include all user data, activity logs, etc.
session()->flash('success', 'User data export initiated. You will receive an email when ready.');
} catch (\Exception $e) {
session()->flash('error', 'Failed to export user data: ' . $e->getMessage());
}
}
public function deleteUser()
{
if ($this->user->id === auth()->id()) {
session()->flash('error', 'You cannot delete your own account.');
return;
}
try {
// Log before deletion
activity()
->performedOn($this->user)
->causedBy(auth()->user())
->withProperties([
'deleted_user_data' => [
'name' => $this->user->name,
'email' => $this->user->email,
'employee_id' => $this->user->employee_id,
]
])
->log('User deleted by admin');
$userName = $this->user->name;
$this->user->delete();
session()->flash('success', "User '{$userName}' deleted successfully.");
return redirect()->route('users.index');
} catch (\Exception $e) {
session()->flash('error', 'Failed to delete user: ' . $e->getMessage());
}
$this->showDeleteModal = false;
}
public function confirmDelete()
{
$this->showDeleteModal = true;
}
public function confirmImpersonate()
{
$this->showImpersonateModal = true;
}
public function getUserMetrics()
{
$totalPermissions = $this->user->getAllPermissions()->count();
$directPermissions = $this->user->permissions()
->where('user_permissions.granted', true)
->where(function ($q) {
$q->whereNull('user_permissions.expires_at')
->orWhere('user_permissions.expires_at', '>', now());
})
->count();
$activeRoles = $this->user->roles()
->where('user_roles.is_active', true)
->where(function ($q) {
$q->whereNull('user_roles.expires_at')
->orWhere('user_roles.expires_at', '>', now());
})
->count();
return [
'total_permissions' => $totalPermissions,
'direct_permissions' => $directPermissions,
'role_permissions' => $totalPermissions - $directPermissions,
'active_roles' => $activeRoles,
'days_since_created' => $this->user->created_at->diffInDays(now()),
'last_login' => $this->user->last_login_at ? $this->user->last_login_at->diffForHumans() : 'Never',
'password_age' => $this->user->password_changed_at ? $this->user->password_changed_at->diffInDays(now()) : null,
];
}
public function getUserWorkStats()
{
// Get work-related statistics for the user
$stats = [
'work_orders_assigned' => 0,
'work_orders_completed' => 0,
'service_orders_created' => 0,
'total_revenue_generated' => 0,
];
try {
// Work orders assigned to user (if technician)
if (\Schema::hasTable('work_orders')) {
$stats['work_orders_assigned'] = \DB::table('work_orders')
->where('assigned_technician_id', $this->user->id)
->count();
$stats['work_orders_completed'] = \DB::table('work_orders')
->where('assigned_technician_id', $this->user->id)
->where('status', 'completed')
->count();
}
// Service orders created by user (if service advisor)
if (\Schema::hasTable('service_orders')) {
$stats['service_orders_created'] = \DB::table('service_orders')
->where('created_by', $this->user->id)
->count();
$stats['total_revenue_generated'] = \DB::table('service_orders')
->where('created_by', $this->user->id)
->where('status', 'completed')
->sum('total_amount') ?? 0;
}
} catch (\Exception $e) {
// Tables might not exist, return default stats
}
return $stats;
}
public function getStatusBadgeClass($status)
{
return match($status) {
'active' => 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
'inactive' => 'bg-zinc-100 dark:bg-zinc-700 text-zinc-800 dark:text-zinc-200',
'suspended' => 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200',
default => 'bg-zinc-100 dark:bg-zinc-700 text-zinc-800 dark:text-zinc-200'
};
}
public function getRoleBadgeClass($roleName)
{
return match($roleName) {
'super_admin' => 'bg-gradient-to-r from-purple-500 to-pink-500 text-white',
'administrator' => 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200',
'manager' => 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200',
'service_manager' => 'bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200',
'technician' => 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
'senior_technician' => 'bg-emerald-100 dark:bg-emerald-900 text-emerald-800 dark:text-emerald-200',
'parts_clerk' => 'bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200',
'service_advisor' => 'bg-teal-100 dark:bg-teal-900 text-teal-800 dark:text-teal-200',
'receptionist' => 'bg-pink-100 dark:bg-pink-900 text-pink-800 dark:text-pink-200',
'cashier' => 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200',
'customer' => 'bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200',
'viewer' => 'bg-zinc-100 dark:bg-zinc-700 text-zinc-800 dark:text-zinc-200',
default => 'bg-zinc-100 dark:bg-zinc-700 text-zinc-800 dark:text-zinc-200'
};
}
public function canPerformAction($action)
{
// Check if current user can perform certain actions on this user
$currentUser = auth()->user();
// Super admin can do anything
if ($currentUser->hasRole('super_admin')) {
return true;
}
// Can't perform actions on yourself (except view)
if ($currentUser->id === $this->user->id && $action !== 'view') {
return false;
}
// Check specific permissions based on action
return match($action) {
'edit' => $currentUser->can('users.edit'),
'delete' => $currentUser->can('users.delete'),
'impersonate' => $currentUser->can('users.impersonate'),
'reset_password' => $currentUser->can('users.reset-password'),
'manage_roles' => $currentUser->can('users.manage-roles'),
'view_activity' => $currentUser->can('users.view-activity'),
default => false,
};
}
public function getLastActivityDate()
{
$lastActivity = Activity::where('causer_id', $this->user->id)
->where('causer_type', User::class)
->latest()
->first();
return $lastActivity ? $lastActivity->created_at->diffForHumans() : 'No activity recorded';
}
public function getTotalLoginCount()
{
// This would require a login tracking system
return Activity::where('causer_id', $this->user->id)
->where('causer_type', User::class)
->where('description', 'like', '%login%')
->count();
}
}

View File

@ -0,0 +1,181 @@
<?php
namespace App\Livewire\Vehicles;
use Livewire\Component;
use App\Models\Vehicle;
use App\Models\Customer;
use App\Services\VinDecoderService;
use Livewire\Attributes\Validate;
use Livewire\WithFileUploads;
class Create extends Component
{
use WithFileUploads;
#[Validate('required|exists:customers,id')]
public $customer_id = '';
#[Validate('required|string|size:17|unique:vehicles,vin')]
public $vin = '';
#[Validate('required|string|max:255')]
public $make = '';
#[Validate('required|string|max:255')]
public $model = '';
#[Validate('required|integer|min:1900|max:2030')]
public $year = '';
#[Validate('required|string|max:255')]
public $color = '';
#[Validate('required|string|max:20|unique:vehicles,license_plate')]
public $license_plate = '';
#[Validate('nullable|string|max:255')]
public $engine_type = '';
#[Validate('nullable|string|max:255')]
public $transmission = '';
#[Validate('required|integer|min:0|max:999999')]
public $mileage = 0;
#[Validate('nullable|string|max:1000')]
public $notes = '';
#[Validate('nullable|image|max:2048')]
public $vehicle_image;
#[Validate('required|in:active,inactive,sold')]
public $status = 'active';
// VIN decoding properties
public $isDecodingVin = false;
public $vinDecodeError = '';
public $vinDecodeSuccess = '';
protected VinDecoderService $vinDecoderService;
public function boot(VinDecoderService $vinDecoderService)
{
$this->vinDecoderService = $vinDecoderService;
}
public function mount()
{
// If customer ID is passed via query parameter, pre-select it
if (request()->has('customer')) {
$this->customer_id = request()->get('customer');
}
}
public function decodeVin()
{
// Clear previous messages
$this->vinDecodeError = '';
$this->vinDecodeSuccess = '';
// Validate VIN format first
if (strlen(trim($this->vin)) !== 17) {
$this->vinDecodeError = 'VIN must be exactly 17 characters long.';
return;
}
$this->isDecodingVin = true;
try {
$result = $this->vinDecoderService->decodeVin($this->vin);
if ($result['success']) {
$data = $result['data'];
// Fill form fields with decoded data
if (!empty($data['make'])) {
$this->make = $data['make'];
}
if (!empty($data['model'])) {
$this->model = $data['model'];
}
if (!empty($data['year'])) {
$this->year = $data['year'];
}
if (!empty($data['engine_type'])) {
$this->engine_type = $data['engine_type'];
}
if (!empty($data['transmission'])) {
$this->transmission = $data['transmission'];
}
// Show success message
$filledFields = array_filter([
$data['make'] ? 'Make' : null,
$data['model'] ? 'Model' : null,
$data['year'] ? 'Year' : null,
$data['engine_type'] ? 'Engine' : null,
$data['transmission'] ? 'Transmission' : null,
]);
if (!empty($filledFields)) {
$this->vinDecodeSuccess = 'VIN decoded successfully! Auto-filled: ' . implode(', ', $filledFields);
} else {
$this->vinDecodeError = 'VIN was decoded but no vehicle information was found.';
}
// Show error codes if any
if (!empty($data['error_codes'])) {
$this->vinDecodeError = 'VIN decoded with warnings: ' . implode(', ', array_slice($data['error_codes'], 0, 2));
}
} else {
$this->vinDecodeError = $result['error'];
}
} catch (\Exception $e) {
$this->vinDecodeError = 'An unexpected error occurred while decoding the VIN.';
} finally {
$this->isDecodingVin = false;
}
}
public function createVehicle()
{
$this->validate();
// Handle vehicle image upload
$vehicleImagePath = null;
if ($this->vehicle_image) {
$vehicleImagePath = $this->vehicle_image->store('vehicles', 'public');
}
$vehicle = Vehicle::create([
'customer_id' => $this->customer_id,
'vin' => strtoupper($this->vin),
'make' => $this->make,
'model' => $this->model,
'year' => $this->year,
'color' => $this->color,
'license_plate' => strtoupper($this->license_plate),
'engine_type' => $this->engine_type,
'transmission' => $this->transmission,
'mileage' => $this->mileage,
'notes' => $this->notes,
'status' => $this->status,
'vehicle_image' => $vehicleImagePath,
]);
session()->flash('success', 'Vehicle added successfully!');
return $this->redirect('/vehicles/' . $vehicle->id, navigate: true);
}
public function render()
{
$customers = Customer::where('status', 'active')->orderBy('first_name')->get();
return view('livewire.vehicles.create', [
'customers' => $customers,
]);
}
}

View File

@ -0,0 +1,211 @@
<?php
namespace App\Livewire\Vehicles;
use Livewire\Component;
use App\Models\Vehicle;
use App\Models\Customer;
use App\Services\VinDecoderService;
use Livewire\Attributes\Validate;
use Livewire\WithFileUploads;
class Edit extends Component
{
use WithFileUploads;
public Vehicle $vehicle;
#[Validate('required|exists:customers,id')]
public $customer_id = '';
#[Validate('required|string|size:17')]
public $vin = '';
#[Validate('required|string|max:255')]
public $make = '';
#[Validate('required|string|max:255')]
public $model = '';
#[Validate('required|integer|min:1900|max:2030')]
public $year = '';
#[Validate('required|string|max:255')]
public $color = '';
#[Validate('required|string|max:20')]
public $license_plate = '';
#[Validate('nullable|string|max:255')]
public $engine_type = '';
#[Validate('nullable|string|max:255')]
public $transmission = '';
#[Validate('required|integer|min:0|max:999999')]
public $mileage = 0;
#[Validate('nullable|string|max:1000')]
public $notes = '';
#[Validate('nullable|image|max:2048')]
public $vehicle_image;
#[Validate('required|in:active,inactive,sold')]
public $status = 'active';
// VIN decoding properties
public $isDecodingVin = false;
public $vinDecodeError = '';
public $vinDecodeSuccess = '';
protected VinDecoderService $vinDecoderService;
public function boot(VinDecoderService $vinDecoderService)
{
$this->vinDecoderService = $vinDecoderService;
}
public function mount(Vehicle $vehicle)
{
$this->vehicle = $vehicle;
$this->customer_id = $vehicle->customer_id;
$this->vin = $vehicle->vin;
$this->make = $vehicle->make;
$this->model = $vehicle->model;
$this->year = $vehicle->year;
$this->color = $vehicle->color;
$this->license_plate = $vehicle->license_plate;
$this->engine_type = $vehicle->engine_type;
$this->transmission = $vehicle->transmission;
$this->mileage = $vehicle->mileage;
$this->notes = $vehicle->notes;
$this->status = $vehicle->status;
}
public function decodeVin()
{
// Clear previous messages
$this->vinDecodeError = '';
$this->vinDecodeSuccess = '';
// Validate VIN format first
if (strlen(trim($this->vin)) !== 17) {
$this->vinDecodeError = 'VIN must be exactly 17 characters long.';
return;
}
$this->isDecodingVin = true;
try {
$result = $this->vinDecoderService->decodeVin($this->vin);
if ($result['success']) {
$data = $result['data'];
// Ask user before overwriting existing data
$updatedFields = [];
if (!empty($data['make']) && $data['make'] !== $this->make) {
$this->make = $data['make'];
$updatedFields[] = 'Make';
}
if (!empty($data['model']) && $data['model'] !== $this->model) {
$this->model = $data['model'];
$updatedFields[] = 'Model';
}
if (!empty($data['year']) && $data['year'] != $this->year) {
$this->year = $data['year'];
$updatedFields[] = 'Year';
}
if (!empty($data['engine_type']) && $data['engine_type'] !== $this->engine_type) {
$this->engine_type = $data['engine_type'];
$updatedFields[] = 'Engine';
}
if (!empty($data['transmission']) && $data['transmission'] !== $this->transmission) {
$this->transmission = $data['transmission'];
$updatedFields[] = 'Transmission';
}
// Show success message
if (!empty($updatedFields)) {
$this->vinDecodeSuccess = 'VIN decoded successfully! Updated: ' . implode(', ', $updatedFields);
} else {
$this->vinDecodeSuccess = 'VIN decoded successfully! No changes needed - current data matches VIN.';
}
// Show error codes if any
if (!empty($data['error_codes'])) {
$this->vinDecodeError = 'VIN decoded with warnings: ' . implode(', ', array_slice($data['error_codes'], 0, 2));
}
} else {
$this->vinDecodeError = $result['error'];
}
} catch (\Exception $e) {
$this->vinDecodeError = 'An unexpected error occurred while decoding the VIN.';
} finally {
$this->isDecodingVin = false;
}
}
public function updateVehicle()
{
// Update validation rules to exclude current vehicle's unique fields
$this->validate([
'customer_id' => 'required|exists:customers,id',
'vin' => 'required|string|size:17|unique:vehicles,vin,' . $this->vehicle->id,
'make' => 'required|string|max:255',
'model' => 'required|string|max:255',
'year' => 'required|integer|min:1900|max:2030',
'color' => 'required|string|max:255',
'license_plate' => 'required|string|max:20|unique:vehicles,license_plate,' . $this->vehicle->id,
'engine_type' => 'nullable|string|max:255',
'transmission' => 'nullable|string|max:255',
'mileage' => 'required|integer|min:0|max:999999',
'notes' => 'nullable|string|max:1000',
'status' => 'required|in:active,inactive,sold',
'vehicle_image' => 'nullable|image|max:2048',
]);
// Handle vehicle image upload
$updateData = [
'customer_id' => $this->customer_id,
'vin' => strtoupper($this->vin),
'make' => $this->make,
'model' => $this->model,
'year' => $this->year,
'color' => $this->color,
'license_plate' => strtoupper($this->license_plate),
'engine_type' => $this->engine_type,
'transmission' => $this->transmission,
'mileage' => $this->mileage,
'notes' => $this->notes,
'status' => $this->status,
];
if ($this->vehicle_image) {
// Delete old image if exists
if ($this->vehicle->vehicle_image) {
\Storage::disk('public')->delete($this->vehicle->vehicle_image);
}
// Store new image
$updateData['vehicle_image'] = $this->vehicle_image->store('vehicles', 'public');
}
$this->vehicle->update($updateData);
session()->flash('success', 'Vehicle updated successfully!');
return $this->redirect('/vehicles/' . $this->vehicle->id, navigate: true);
}
public function render()
{
$customers = Customer::where('status', 'active')->orderBy('first_name')->get();
return view('livewire.vehicles.edit', [
'customers' => $customers,
]);
}
}

View File

@ -0,0 +1,114 @@
<?php
namespace App\Livewire\Vehicles;
use App\Models\Vehicle;
use App\Models\Customer;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
{
use WithPagination;
public $search = '';
public $customer_id = '';
public $make = '';
public $status = '';
public $sortBy = 'created_at';
public $sortDirection = 'desc';
protected $queryString = [
'search' => ['except' => ''],
'customer_id' => ['except' => ''],
'make' => ['except' => ''],
'status' => ['except' => ''],
'sortBy' => ['except' => 'created_at'],
'sortDirection' => ['except' => 'desc'],
];
public function updatingSearch()
{
$this->resetPage();
}
public function updatingCustomerId()
{
$this->resetPage();
}
public function updatingMake()
{
$this->resetPage();
}
public function updatingStatus()
{
$this->resetPage();
}
public function sortBy($field)
{
if ($this->sortBy === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $field;
$this->sortDirection = 'asc';
}
}
public function deleteVehicle($vehicleId)
{
$vehicle = Vehicle::findOrFail($vehicleId);
// Check if vehicle has any service orders
if ($vehicle->serviceOrders()->count() > 0) {
session()->flash('error', 'Cannot delete vehicle with existing service orders. Please complete or cancel them first.');
return;
}
$vehicleName = $vehicle->display_name;
$vehicle->delete();
session()->flash('success', "Vehicle {$vehicleName} has been deleted successfully.");
}
public function render()
{
$vehicles = Vehicle::query()
->with(['customer'])
->when($this->search, function ($query) {
$query->where(function ($q) {
$q->where('make', 'like', '%' . $this->search . '%')
->orWhere('model', 'like', '%' . $this->search . '%')
->orWhere('year', 'like', '%' . $this->search . '%')
->orWhere('vin', 'like', '%' . $this->search . '%')
->orWhere('license_plate', 'like', '%' . $this->search . '%')
->orWhereHas('customer', function ($customerQuery) {
$customerQuery->where('first_name', 'like', '%' . $this->search . '%')
->orWhere('last_name', 'like', '%' . $this->search . '%');
});
});
})
->when($this->customer_id, function ($query) {
$query->where('customer_id', $this->customer_id);
})
->when($this->make, function ($query) {
$query->where('make', $this->make);
})
->when($this->status, function ($query) {
$query->where('status', $this->status);
})
->orderBy($this->sortBy, $this->sortDirection)
->paginate(15);
$customers = Customer::orderBy('first_name')->get();
$makes = Vehicle::distinct()->orderBy('make')->pluck('make')->filter();
return view('livewire.vehicles.index', [
'vehicles' => $vehicles,
'customers' => $customers,
'makes' => $makes,
]);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Livewire\Vehicles;
use Livewire\Component;
use App\Models\Vehicle;
class Show extends Component
{
public Vehicle $vehicle;
public function mount(Vehicle $vehicle)
{
$this->vehicle = $vehicle->load([
'customer',
'serviceOrders.assignedTechnician',
'appointments',
'inspections'
]);
}
public function render()
{
return view('livewire.vehicles.show');
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Livewire\WorkOrders;
use App\Models\Estimate;
use App\Models\WorkOrder;
use App\Models\WorkOrderTask;
use App\Models\User;
use Livewire\Component;
class Create extends Component
{
public Estimate $estimate;
public $work_description = '';
public $special_instructions = '';
public $safety_requirements = '';
public $estimated_start_time = '';
public $estimated_completion_time = '';
public $assigned_technician_id = '';
public $quality_check_required = true;
public $customer_notification_required = true;
public $notes = '';
public $tasks = [];
protected $rules = [
'work_description' => 'required|string',
'estimated_start_time' => 'required|date|after:now',
'estimated_completion_time' => 'required|date|after:estimated_start_time',
'assigned_technician_id' => 'required|exists:users,id',
'tasks.*.task_name' => 'required|string',
'tasks.*.estimated_duration' => 'required|numeric|min:0.5',
'tasks.*.required_skills' => 'nullable|string',
];
public function mount(Estimate $estimate)
{
$this->estimate = $estimate->load([
'jobCard.customer',
'jobCard.vehicle',
'diagnosis',
'lineItems'
]);
$this->work_description = "Perform repairs as per approved estimate #{$estimate->estimate_number}";
$this->estimated_start_time = now()->addDay()->format('Y-m-d\TH:i');
$this->estimated_completion_time = now()->addDays(3)->format('Y-m-d\TH:i');
// Initialize tasks from estimate line items
$this->initializeTasks();
}
public function initializeTasks()
{
foreach ($this->estimate->lineItems as $item) {
if ($item->type === 'labor') {
$this->tasks[] = [
'task_name' => $item->description,
'task_description' => $item->description,
'estimated_duration' => $item->labor_hours ?? 1,
'required_skills' => '',
'safety_requirements' => '',
'status' => 'pending',
];
}
}
}
public function addTask()
{
$this->tasks[] = [
'task_name' => '',
'task_description' => '',
'estimated_duration' => 1,
'required_skills' => '',
'safety_requirements' => '',
'status' => 'pending',
];
}
public function removeTask($index)
{
unset($this->tasks[$index]);
$this->tasks = array_values($this->tasks);
}
public function save()
{
$this->validate();
// Generate work order number
$branchCode = $this->estimate->jobCard->branch_code;
$lastWONumber = WorkOrder::where('work_order_number', 'like', $branchCode . '/WO%')
->whereYear('created_at', now()->year)
->count();
$workOrderNumber = $branchCode . '/WO' . str_pad($lastWONumber + 1, 4, '0', STR_PAD_LEFT);
$workOrder = WorkOrder::create([
'work_order_number' => $workOrderNumber,
'job_card_id' => $this->estimate->job_card_id,
'estimate_id' => $this->estimate->id,
'service_coordinator_id' => auth()->id(),
'assigned_technician_id' => $this->assigned_technician_id,
'priority' => $this->estimate->jobCard->priority,
'status' => 'scheduled',
'work_description' => $this->work_description,
'special_instructions' => $this->special_instructions,
'safety_requirements' => $this->safety_requirements,
'estimated_start_time' => $this->estimated_start_time,
'estimated_completion_time' => $this->estimated_completion_time,
'quality_check_required' => $this->quality_check_required,
'customer_notification_required' => $this->customer_notification_required,
'notes' => $this->notes,
]);
// Create tasks
foreach ($this->tasks as $task) {
WorkOrderTask::create([
'work_order_id' => $workOrder->id,
'task_name' => $task['task_name'],
'task_description' => $task['task_description'],
'estimated_duration' => $task['estimated_duration'],
'required_skills' => $task['required_skills'],
'safety_requirements' => $task['safety_requirements'],
'status' => 'pending',
]);
}
// Update job card status
$this->estimate->jobCard->update(['status' => 'work_order_created']);
session()->flash('message', 'Work Order created successfully!');
return redirect()->route('work-orders.show', $workOrder);
}
public function getTechniciansProperty()
{
return User::whereIn('role', ['technician', 'service_coordinator'])
->where('status', 'active')
->orderBy('name')
->get();
}
public function render()
{
return view('livewire.work-orders.create', [
'technicians' => $this->technicians
]);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\WorkOrders;
use Livewire\Component;
class Edit extends Component
{
public function render()
{
return view('livewire.work-orders.edit');
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Livewire\WorkOrders;
use App\Models\WorkOrder;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
{
use WithPagination;
public $search = '';
public $statusFilter = '';
public $priorityFilter = '';
public function updatingSearch()
{
$this->resetPage();
}
public function render()
{
$workOrders = WorkOrder::with([
'jobCard.customer',
'jobCard.vehicle',
'serviceCoordinator',
'assignedTechnician',
'estimate'
])
->when($this->search, function ($query) {
$query->where(function ($q) {
$q->where('work_order_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->priorityFilter, function ($query) {
$query->where('priority', $this->priorityFilter);
})
->latest()
->paginate(15);
return view('livewire.work-orders.index', compact('workOrders'));
}
}

Some files were not shown because too many files have changed in this diff Show More