Initial commit
This commit is contained in:
18
.editorconfig
Normal file
18
.editorconfig
Normal 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
65
.env.example
Normal 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
10
.gitattributes
vendored
Normal 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
46
.github/workflows/lint.yml
vendored
Normal 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
54
.github/workflows/tests.yml
vendored
Normal 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
23
.gitignore
vendored
Normal 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
292
PERMISSIONS.md
Normal 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
49
THEME_STANDARD.md
Normal 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
|
||||
79
app/Console/Commands/AssignPermissions.php
Normal file
79
app/Console/Commands/AssignPermissions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
54
app/Console/Commands/AssignRoleCommand.php
Normal file
54
app/Console/Commands/AssignRoleCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
128
app/Console/Commands/GeneratePartHistory.php
Normal file
128
app/Console/Commands/GeneratePartHistory.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
49
app/Console/Commands/TestUserPermissions.php
Normal file
49
app/Console/Commands/TestUserPermissions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
30
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
30
app/Http/Controllers/Auth/VerifyEmailController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
71
app/Http/Controllers/CustomerController.php
Normal file
71
app/Http/Controllers/CustomerController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
13
app/Http/Controllers/InventoryController.php
Normal file
13
app/Http/Controllers/InventoryController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
76
app/Http/Controllers/ServiceOrderController.php
Normal file
76
app/Http/Controllers/ServiceOrderController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
288
app/Http/Controllers/SettingsController.php
Normal file
288
app/Http/Controllers/SettingsController.php
Normal 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!');
|
||||
}
|
||||
}
|
||||
71
app/Http/Controllers/VehicleController.php
Normal file
71
app/Http/Controllers/VehicleController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
36
app/Http/Middleware/PermissionMiddleware.php
Normal file
36
app/Http/Middleware/PermissionMiddleware.php
Normal 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));
|
||||
}
|
||||
}
|
||||
35
app/Http/Middleware/RoleMiddleware.php
Normal file
35
app/Http/Middleware/RoleMiddleware.php
Normal 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));
|
||||
}
|
||||
}
|
||||
22
app/Livewire/Actions/Logout.php
Normal file
22
app/Livewire/Actions/Logout.php
Normal 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('/');
|
||||
}
|
||||
}
|
||||
282
app/Livewire/Appointments/Calendar.php
Normal file
282
app/Livewire/Appointments/Calendar.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
229
app/Livewire/Appointments/Create.php
Normal file
229
app/Livewire/Appointments/Create.php
Normal 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'
|
||||
]);
|
||||
}
|
||||
}
|
||||
204
app/Livewire/Appointments/Form.php
Normal file
204
app/Livewire/Appointments/Form.php
Normal 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');
|
||||
}
|
||||
}
|
||||
237
app/Livewire/Appointments/Index.php
Normal file
237
app/Livewire/Appointments/Index.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
284
app/Livewire/Appointments/TimeSlots.php
Normal file
284
app/Livewire/Appointments/TimeSlots.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
73
app/Livewire/CustomerPortal/EstimateView.php
Normal file
73
app/Livewire/CustomerPortal/EstimateView.php
Normal 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');
|
||||
}
|
||||
}
|
||||
44
app/Livewire/CustomerPortal/JobStatus.php
Normal file
44
app/Livewire/CustomerPortal/JobStatus.php
Normal 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');
|
||||
}
|
||||
}
|
||||
75
app/Livewire/Customers/Create.php
Normal file
75
app/Livewire/Customers/Create.php
Normal 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');
|
||||
}
|
||||
}
|
||||
102
app/Livewire/Customers/Edit.php
Normal file
102
app/Livewire/Customers/Edit.php
Normal 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');
|
||||
}
|
||||
}
|
||||
83
app/Livewire/Customers/Index.php
Normal file
83
app/Livewire/Customers/Index.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
21
app/Livewire/Customers/Show.php
Normal file
21
app/Livewire/Customers/Show.php
Normal 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');
|
||||
}
|
||||
}
|
||||
33
app/Livewire/Dashboard/DailySchedule.php
Normal file
33
app/Livewire/Dashboard/DailySchedule.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
65
app/Livewire/Dashboard/Overview.php
Normal file
65
app/Livewire/Dashboard/Overview.php
Normal 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');
|
||||
}
|
||||
}
|
||||
75
app/Livewire/Dashboard/PerformanceMetrics.php
Normal file
75
app/Livewire/Dashboard/PerformanceMetrics.php
Normal 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);
|
||||
}
|
||||
}
|
||||
130
app/Livewire/Dashboard/WorkflowOverview.php
Normal file
130
app/Livewire/Dashboard/WorkflowOverview.php
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
707
app/Livewire/Diagnosis/Create.php
Normal file
707
app/Livewire/Diagnosis/Create.php
Normal 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');
|
||||
}
|
||||
}
|
||||
13
app/Livewire/Diagnosis/Edit.php
Normal file
13
app/Livewire/Diagnosis/Edit.php
Normal 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');
|
||||
}
|
||||
}
|
||||
15
app/Livewire/Diagnosis/Index.php
Normal file
15
app/Livewire/Diagnosis/Index.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
31
app/Livewire/Diagnosis/Show.php
Normal file
31
app/Livewire/Diagnosis/Show.php
Normal 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');
|
||||
}
|
||||
}
|
||||
193
app/Livewire/Estimates/Create.php
Normal file
193
app/Livewire/Estimates/Create.php
Normal 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');
|
||||
}
|
||||
}
|
||||
13
app/Livewire/Estimates/Edit.php
Normal file
13
app/Livewire/Estimates/Edit.php
Normal 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');
|
||||
}
|
||||
}
|
||||
48
app/Livewire/Estimates/Index.php
Normal file
48
app/Livewire/Estimates/Index.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
13
app/Livewire/Estimates/PDF.php
Normal file
13
app/Livewire/Estimates/PDF.php
Normal 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');
|
||||
}
|
||||
}
|
||||
13
app/Livewire/Estimates/Show.php
Normal file
13
app/Livewire/Estimates/Show.php
Normal 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');
|
||||
}
|
||||
}
|
||||
138
app/Livewire/GlobalSearch.php
Normal file
138
app/Livewire/GlobalSearch.php
Normal 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');
|
||||
}
|
||||
}
|
||||
135
app/Livewire/Inspections/Create.php
Normal file
135
app/Livewire/Inspections/Create.php
Normal 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');
|
||||
}
|
||||
}
|
||||
13
app/Livewire/Inspections/Edit.php
Normal file
13
app/Livewire/Inspections/Edit.php
Normal 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');
|
||||
}
|
||||
}
|
||||
49
app/Livewire/Inspections/Index.php
Normal file
49
app/Livewire/Inspections/Index.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
13
app/Livewire/Inspections/Show.php
Normal file
13
app/Livewire/Inspections/Show.php
Normal 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');
|
||||
}
|
||||
}
|
||||
64
app/Livewire/Inventory/Dashboard.php
Normal file
64
app/Livewire/Inventory/Dashboard.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
104
app/Livewire/Inventory/Parts/Create.php
Normal file
104
app/Livewire/Inventory/Parts/Create.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
134
app/Livewire/Inventory/Parts/Edit.php
Normal file
134
app/Livewire/Inventory/Parts/Edit.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
86
app/Livewire/Inventory/Parts/History.php
Normal file
86
app/Livewire/Inventory/Parts/History.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
134
app/Livewire/Inventory/Parts/Index.php
Normal file
134
app/Livewire/Inventory/Parts/Index.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
56
app/Livewire/Inventory/Parts/Show.php
Normal file
56
app/Livewire/Inventory/Parts/Show.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
172
app/Livewire/Inventory/PurchaseOrders/Create.php
Normal file
172
app/Livewire/Inventory/PurchaseOrders/Create.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
157
app/Livewire/Inventory/PurchaseOrders/Edit.php
Normal file
157
app/Livewire/Inventory/PurchaseOrders/Edit.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
93
app/Livewire/Inventory/PurchaseOrders/Index.php
Normal file
93
app/Livewire/Inventory/PurchaseOrders/Index.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
116
app/Livewire/Inventory/PurchaseOrders/Show.php
Normal file
116
app/Livewire/Inventory/PurchaseOrders/Show.php
Normal 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');
|
||||
}
|
||||
}
|
||||
68
app/Livewire/Inventory/StockMovements/Create.php
Normal file
68
app/Livewire/Inventory/StockMovements/Create.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
93
app/Livewire/Inventory/StockMovements/Index.php
Normal file
93
app/Livewire/Inventory/StockMovements/Index.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
66
app/Livewire/Inventory/Suppliers/Create.php
Normal file
66
app/Livewire/Inventory/Suppliers/Create.php
Normal 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');
|
||||
}
|
||||
}
|
||||
88
app/Livewire/Inventory/Suppliers/Edit.php
Normal file
88
app/Livewire/Inventory/Suppliers/Edit.php
Normal 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');
|
||||
}
|
||||
}
|
||||
79
app/Livewire/Inventory/Suppliers/Index.php
Normal file
79
app/Livewire/Inventory/Suppliers/Index.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
184
app/Livewire/JobCards/Create.php
Normal file
184
app/Livewire/JobCards/Create.php
Normal 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');
|
||||
}
|
||||
}
|
||||
185
app/Livewire/JobCards/Edit.php
Normal file
185
app/Livewire/JobCards/Edit.php
Normal 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');
|
||||
}
|
||||
}
|
||||
118
app/Livewire/JobCards/Index.php
Normal file
118
app/Livewire/JobCards/Index.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
31
app/Livewire/JobCards/Show.php
Normal file
31
app/Livewire/JobCards/Show.php
Normal 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');
|
||||
}
|
||||
}
|
||||
37
app/Livewire/JobCards/WorkflowStatus.php
Normal file
37
app/Livewire/JobCards/WorkflowStatus.php
Normal 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');
|
||||
}
|
||||
}
|
||||
190
app/Livewire/Reports/Dashboard.php
Normal file
190
app/Livewire/Reports/Dashboard.php
Normal 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'
|
||||
]);
|
||||
}
|
||||
}
|
||||
169
app/Livewire/ServiceItems/Manage.php
Normal file
169
app/Livewire/ServiceItems/Manage.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
312
app/Livewire/ServiceOrders/Create.php
Normal file
312
app/Livewire/ServiceOrders/Create.php
Normal 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');
|
||||
}
|
||||
}
|
||||
244
app/Livewire/ServiceOrders/Edit.php
Normal file
244
app/Livewire/ServiceOrders/Edit.php
Normal 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');
|
||||
}
|
||||
}
|
||||
163
app/Livewire/ServiceOrders/Index.php
Normal file
163
app/Livewire/ServiceOrders/Index.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
27
app/Livewire/ServiceOrders/Invoice.php
Normal file
27
app/Livewire/ServiceOrders/Invoice.php
Normal 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');
|
||||
}
|
||||
}
|
||||
46
app/Livewire/ServiceOrders/Show.php
Normal file
46
app/Livewire/ServiceOrders/Show.php
Normal 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');
|
||||
}
|
||||
}
|
||||
108
app/Livewire/TechnicianManagement/Index.php
Normal file
108
app/Livewire/TechnicianManagement/Index.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
251
app/Livewire/TechnicianManagement/PerformanceTracking.php
Normal file
251
app/Livewire/TechnicianManagement/PerformanceTracking.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
146
app/Livewire/TechnicianManagement/SkillsManagement.php
Normal file
146
app/Livewire/TechnicianManagement/SkillsManagement.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
183
app/Livewire/TechnicianManagement/TechnicianForm.php
Normal file
183
app/Livewire/TechnicianManagement/TechnicianForm.php
Normal 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');
|
||||
}
|
||||
}
|
||||
256
app/Livewire/TechnicianManagement/WorkloadManagement.php
Normal file
256
app/Livewire/TechnicianManagement/WorkloadManagement.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
108
app/Livewire/Timesheets/Create.php
Normal file
108
app/Livewire/Timesheets/Create.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
13
app/Livewire/Timesheets/Edit.php
Normal file
13
app/Livewire/Timesheets/Edit.php
Normal 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');
|
||||
}
|
||||
}
|
||||
56
app/Livewire/Timesheets/Index.php
Normal file
56
app/Livewire/Timesheets/Index.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
13
app/Livewire/Timesheets/Show.php
Normal file
13
app/Livewire/Timesheets/Show.php
Normal 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');
|
||||
}
|
||||
}
|
||||
0
app/Livewire/UserManagement.php
Normal file
0
app/Livewire/UserManagement.php
Normal file
359
app/Livewire/Users/Create.php
Normal file
359
app/Livewire/Users/Create.php
Normal 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
467
app/Livewire/Users/Edit.php
Normal 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;
|
||||
}
|
||||
}
|
||||
376
app/Livewire/Users/Index.php
Normal file
376
app/Livewire/Users/Index.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
393
app/Livewire/Users/ManageRolesPermissions.php
Normal file
393
app/Livewire/Users/ManageRolesPermissions.php
Normal 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
506
app/Livewire/Users/Show.php
Normal 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();
|
||||
}
|
||||
}
|
||||
181
app/Livewire/Vehicles/Create.php
Normal file
181
app/Livewire/Vehicles/Create.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
211
app/Livewire/Vehicles/Edit.php
Normal file
211
app/Livewire/Vehicles/Edit.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
114
app/Livewire/Vehicles/Index.php
Normal file
114
app/Livewire/Vehicles/Index.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
26
app/Livewire/Vehicles/Show.php
Normal file
26
app/Livewire/Vehicles/Show.php
Normal 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');
|
||||
}
|
||||
}
|
||||
150
app/Livewire/WorkOrders/Create.php
Normal file
150
app/Livewire/WorkOrders/Create.php
Normal 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
|
||||
]);
|
||||
}
|
||||
}
|
||||
13
app/Livewire/WorkOrders/Edit.php
Normal file
13
app/Livewire/WorkOrders/Edit.php
Normal 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');
|
||||
}
|
||||
}
|
||||
54
app/Livewire/WorkOrders/Index.php
Normal file
54
app/Livewire/WorkOrders/Index.php
Normal 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
Reference in New Issue
Block a user