- Increased icon sizes in service items, service orders, users, and technician management for better visibility. - Added custom loading indicators with appropriate icons in search fields for vehicles, work orders, and technicians. - Introduced invoice management routes for better organization and access control. - Created a new test for the estimate PDF functionality to ensure proper rendering and data integrity.
275 lines
17 KiB
PHP
275 lines
17 KiB
PHP
<div>
|
|
<flux:header class="space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<flux:heading size="xl">Technician Management</flux:heading>
|
|
<flux:subheading>Manage technician profiles, skills, and performance</flux:subheading>
|
|
</div>
|
|
<flux:button variant="primary" icon="plus" wire:click="$dispatch('create-technician')">
|
|
Add Technician
|
|
</flux:button>
|
|
</div>
|
|
|
|
<!-- Filters and Search -->
|
|
<div class="flex flex-col lg:flex-row gap-4">
|
|
<div class="flex-1 relative">
|
|
<flux:input wire:model.live="search" placeholder="Search technicians..." icon="magnifying-glass" :loading="false" />
|
|
<!-- Custom loading indicator with user icon -->
|
|
<div wire:loading wire:target="search" class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
|
<flux:icon.user class="w-6 h-6 text-zinc-400 animate-bounce" />
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<flux:select wire:model.live="statusFilter" placeholder="All Statuses">
|
|
<option value="">All Statuses</option>
|
|
<option value="active">Active</option>
|
|
<option value="inactive">Inactive</option>
|
|
<option value="on_leave">On Leave</option>
|
|
</flux:select>
|
|
|
|
<flux:select wire:model.live="skillFilter" placeholder="All Skills">
|
|
<option value="">All Skills</option>
|
|
@foreach($availableSkills as $skill)
|
|
<option value="{{ $skill }}">{{ ucfirst(str_replace('_', ' ', $skill)) }}</option>
|
|
@endforeach
|
|
</flux:select>
|
|
</div>
|
|
</div>
|
|
</flux:header>
|
|
|
|
<!-- Technicians Table -->
|
|
<div class="bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl p-6 space-y-6">
|
|
@if($technicians->count() > 0)
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full">
|
|
<thead>
|
|
<tr class="border-b border-zinc-200 dark:border-zinc-700">
|
|
<th class="text-left py-3 px-4">
|
|
<button wire:click="sortBy('first_name')" class="flex items-center space-x-1 hover:text-blue-600">
|
|
<span>Name</span>
|
|
@if($sortBy === 'first_name')
|
|
<flux:icon name="{{ $sortDirection === 'asc' ? 'chevron-up' : 'chevron-down' }}" class="size-3" />
|
|
@endif
|
|
</button>
|
|
</th>
|
|
<th class="text-left py-3 px-4">
|
|
<button wire:click="sortBy('employee_id')" class="flex items-center space-x-1 hover:text-blue-600">
|
|
<span>Employee ID</span>
|
|
@if($sortBy === 'employee_id')
|
|
<flux:icon name="{{ $sortDirection === 'asc' ? 'chevron-up' : 'chevron-down' }}" class="size-3" />
|
|
@endif
|
|
</button>
|
|
</th>
|
|
<th class="text-left py-3 px-4">Email</th>
|
|
<th class="text-left py-3 px-4">Phone</th>
|
|
<th class="text-left py-3 px-4">
|
|
<button wire:click="sortBy('status')" class="flex items-center space-x-1 hover:text-blue-600">
|
|
<span>Status</span>
|
|
@if($sortBy === 'status')
|
|
<flux:icon name="{{ $sortDirection === 'asc' ? 'chevron-up' : 'chevron-down' }}" class="size-3" />
|
|
@endif
|
|
</button>
|
|
</th>
|
|
<th class="text-left py-3 px-4">Primary Skills</th>
|
|
<th class="text-left py-3 px-4">Performance</th>
|
|
<th class="text-left py-3 px-4">Utilization</th>
|
|
<th class="text-left py-3 px-4">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody>
|
|
@foreach($technicians as $technician)
|
|
<tr class="border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800">
|
|
<td class="py-3 px-4">
|
|
<div class="flex items-center space-x-3">
|
|
<div class="w-10 h-10 bg-zinc-100 rounded-full flex items-center justify-center text-sm font-medium text-zinc-600">
|
|
{{ strtoupper(substr($technician->first_name, 0, 1) . substr($technician->last_name, 0, 1)) }}
|
|
</div>
|
|
<div>
|
|
<div class="font-semibold">{{ $technician->full_name }}</div>
|
|
<div class="text-sm text-zinc-500">
|
|
${{ number_format($technician->hourly_rate, 2) }}/hr
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="py-3 px-4">{{ $technician->employee_id }}</td>
|
|
<td class="py-3 px-4">{{ $technician->email }}</td>
|
|
<td class="py-3 px-4">{{ $technician->phone }}</td>
|
|
<td class="py-3 px-4">
|
|
<flux:badge size="sm"
|
|
:color="$technician->status === 'active' ? 'green' : ($technician->status === 'inactive' ? 'red' : 'yellow')">
|
|
{{ ucfirst(str_replace('_', ' ', $technician->status)) }}
|
|
</flux:badge>
|
|
</td>
|
|
<td class="py-3 px-4">
|
|
<div class="flex flex-wrap gap-1">
|
|
@php
|
|
$primarySkills = $technician->skills->where('is_primary_skill', true);
|
|
@endphp
|
|
@foreach($primarySkills->take(3) as $skill)
|
|
<flux:badge size="sm" color="blue">
|
|
{{ ucfirst(str_replace('_', ' ', $skill->skill_name)) }}
|
|
({{ $skill->proficiency_level }})
|
|
</flux:badge>
|
|
@endforeach
|
|
@if($primarySkills->count() > 3)
|
|
<flux:badge size="sm" color="zinc">
|
|
+{{ $primarySkills->count() - 3 }} more
|
|
</flux:badge>
|
|
@endif
|
|
</div>
|
|
</td>
|
|
<td class="py-3 px-4">
|
|
<div class="flex items-center space-x-2">
|
|
<div class="text-sm">
|
|
{{ number_format($technician->getAverageRating(), 1) }}/5
|
|
</div>
|
|
<div class="flex text-yellow-400">
|
|
@for($i = 1; $i <= 5; $i++)
|
|
@if($i <= floor($technician->getAverageRating()))
|
|
<flux:icon name="star" variant="solid" class="w-3 h-3" />
|
|
@elseif($i - 0.5 <= $technician->getAverageRating())
|
|
<flux:icon name="star" class="w-3 h-3" />
|
|
@else
|
|
<flux:icon name="star" variant="outline" class="w-3 h-3" />
|
|
@endif
|
|
@endfor
|
|
</div>
|
|
</div>
|
|
<div class="text-xs text-zinc-500">
|
|
{{ $technician->getTotalJobsCompleted() }} jobs completed
|
|
</div>
|
|
</td>
|
|
<td class="py-3 px-4">
|
|
<div class="text-sm font-medium">
|
|
{{ number_format($technician->getCurrentUtilizationRate(), 1) }}%
|
|
</div>
|
|
<div class="w-full bg-zinc-200 rounded-full h-2 mt-1">
|
|
<div class="h-2 rounded-full {{ $technician->getCurrentUtilizationRate() >= 80 ? 'bg-green-500' : ($technician->getCurrentUtilizationRate() >= 60 ? 'bg-yellow-500' : 'bg-red-500') }}"
|
|
style="width: {{ min($technician->getCurrentUtilizationRate(), 100) }}%"></div>
|
|
</div>
|
|
</td>
|
|
<td class="py-3 px-4">
|
|
<div class="flex items-center space-x-2">
|
|
<flux:button size="sm" variant="ghost" icon="eye" wire:click="showDetails({{ $technician->id }})">
|
|
View
|
|
</flux:button>
|
|
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="$dispatch('edit-technician', [{{ $technician->id }}])">
|
|
Edit
|
|
</flux:button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div class="mt-6">
|
|
{{ $technicians->links() }}
|
|
</div>
|
|
@else
|
|
<div class="text-center py-12">
|
|
<flux:icon name="users" class="w-12 h-12 text-zinc-400 mx-auto mb-4" />
|
|
<flux:heading size="lg" class="text-zinc-600 mb-2">No technicians found</flux:heading>
|
|
<flux:subheading class="text-zinc-500 mb-4">
|
|
@if($search || $statusFilter || $skillFilter)
|
|
Try adjusting your filters to see more results.
|
|
@else
|
|
Get started by adding your first technician.
|
|
@endif
|
|
</flux:subheading>
|
|
<flux:button variant="primary" icon="plus" wire:click="$dispatch('create-technician')">
|
|
Add Technician
|
|
</flux:button>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<!-- Technician Details Modal -->
|
|
@if($showingDetails && $selectedTechnician)
|
|
<div class="fixed inset-0 z-50 overflow-y-auto" x-data="{ show: @entangle('showingDetails') }" x-show="show" x-cloak>
|
|
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
|
<div class="fixed inset-0 transition-opacity bg-zinc-50 dark:bg-zinc-9000 bg-opacity-75" x-show="show" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"></div>
|
|
|
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">​</span>
|
|
|
|
<div class="inline-block align-bottom bg-white dark:bg-zinc-800 rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full sm:p-6" x-show="show" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-6">
|
|
<flux:heading size="lg">{{ $selectedTechnician->full_name }} - Details</flux:heading>
|
|
<flux:button variant="ghost" size="sm" icon="x-mark" wire:click="closeDetails"></flux:button>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<div class="space-y-6">
|
|
<!-- Basic Info -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<flux:heading size="lg" class="mb-3">Basic Information</flux:heading>
|
|
<div class="space-y-2">
|
|
<div><strong>Employee ID:</strong> {{ $selectedTechnician->employee_id }}</div>
|
|
<div><strong>Email:</strong> {{ $selectedTechnician->email }}</div>
|
|
<div><strong>Phone:</strong> {{ $selectedTechnician->phone }}</div>
|
|
<div><strong>Hourly Rate:</strong> ${{ number_format($selectedTechnician->hourly_rate, 2) }}</div>
|
|
<div><strong>Status:</strong>
|
|
<flux:badge :color="$selectedTechnician->status === 'active' ? 'green' : ($selectedTechnician->status === 'inactive' ? 'red' : 'yellow')">
|
|
{{ ucfirst(str_replace('_', ' ', $selectedTechnician->status)) }}
|
|
</flux:badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<flux:heading size="lg" class="mb-3">Performance Overview</flux:heading>
|
|
<div class="space-y-2">
|
|
<div><strong>Average Rating:</strong> {{ number_format($selectedTechnician->getAverageRating(), 1) }}/5</div>
|
|
<div><strong>Jobs Completed:</strong> {{ $selectedTechnician->getTotalJobsCompleted() }}</div>
|
|
<div><strong>Current Utilization:</strong> {{ number_format($selectedTechnician->getCurrentUtilizationRate(), 1) }}%</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Skills -->
|
|
<div>
|
|
<flux:heading size="lg" class="mb-3">Skills & Certifications</flux:heading>
|
|
@if($selectedTechnician->skills->count() > 0)
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
@foreach($selectedTechnician->skills as $skill)
|
|
<div class="flex items-center justify-between p-3 border rounded-lg">
|
|
<div>
|
|
<div class="font-medium">{{ ucfirst(str_replace('_', ' ', $skill->skill_name)) }}</div>
|
|
<div class="text-sm text-zinc-500">{{ ucfirst($skill->category) }}</div>
|
|
@if($skill->certification_body)
|
|
<div class="text-xs text-blue-600">{{ $skill->certification_body }}</div>
|
|
@endif
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-lg font-bold">{{ $skill->proficiency_level }}/5</div>
|
|
@if($skill->is_primary_skill)
|
|
<flux:badge size="sm" color="blue">Primary</flux:badge>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@else
|
|
<div class="text-center py-4 text-zinc-500">No skills recorded</div>
|
|
@endif
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="mt-6 flex justify-end space-x-3">
|
|
<flux:button variant="ghost" wire:click="closeDetails">Close</flux:button>
|
|
<flux:button variant="primary" icon="pencil" wire:click="$dispatch('edit-technician', [{{ $selectedTechnician->id }}])">
|
|
Edit Technician
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|