sackey a65fee9d75
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
Add customer portal workflow progress component and analytics dashboard
- Implemented the customer portal workflow progress component with detailed service progress tracking, including current status, workflow steps, and contact information.
- Developed a management workflow analytics dashboard featuring key performance indicators, charts for revenue by branch, labor utilization, and recent quality issues.
- Created tests for admin-only middleware to ensure proper access control for admin routes.
- Added tests for customer portal view rendering and workflow integration, ensuring the workflow service operates correctly through various stages.
- Introduced a .gitignore file for the debugbar storage directory to prevent unnecessary files from being tracked.
2025-08-10 19:41:25 +00:00

727 lines
24 KiB
PHP

<?php
namespace App\Livewire\Users;
use Livewire\Component;
use Livewire\WithPagination;
use App\Models\User;
use App\Models\Role;
use App\Models\Branch;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class Index extends Component
{
use WithPagination;
// Search and filtering
public $search = '';
public $roleFilter = '';
public $statusFilter = '';
public $departmentFilter = '';
public $branchFilter = '';
public $customerFilter = '';
public $hireYearFilter = '';
// Sorting
public $sortField = 'name';
public $sortDirection = 'asc';
// Display options
public $perPage = 25;
public $showInactive = false;
public $showDetails = false;
// Bulk operations
public $selectedUsers = [];
public $selectAll = false;
// Modal states
public $showDeleteModal = false;
public $userToDelete = null;
public $showBulkDeleteModal = false;
protected $queryString = [
'search' => ['except' => ''],
'roleFilter' => ['except' => ''],
'statusFilter' => ['except' => ''],
'departmentFilter' => ['except' => ''],
'branchFilter' => ['except' => ''],
'customerFilter' => ['except' => ''],
'hireYearFilter' => ['except' => ''],
'sortField' => ['except' => 'name'],
'sortDirection' => ['except' => 'asc'],
'perPage' => ['except' => 25],
'showInactive' => ['except' => false],
'page' => ['except' => 1],
];
protected $listeners = [
'userUpdated' => '$refresh',
'userCreated' => '$refresh',
'userDeleted' => '$refresh',
];
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 updatingCustomerFilter()
{
$this->resetPage();
}
public function updatingHireYearFilter()
{
$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());
});
},
'customer',
'branch',
'jobCards' => function($query) {
$query->select('id', 'service_advisor_id', 'created_at');
}
])
->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());
});
},
'jobCards as job_cards_count'
])
->when($this->search, function ($q) {
$searchTerm = '%' . $this->search . '%';
$q->where(function ($query) use ($searchTerm) {
$query->where('name', 'like', $searchTerm)
->orWhere('email', 'like', $searchTerm)
->orWhere('employee_id', 'like', $searchTerm)
->orWhere('phone', 'like', $searchTerm)
->orWhere('national_id', 'like', $searchTerm)
->orWhereHas('branch', function($q) use ($searchTerm) {
$q->where('name', 'like', $searchTerm);
});
});
})
->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->hireYearFilter, function ($q) {
$q->whereYear('hire_date', $this->hireYearFilter);
})
->when($this->customerFilter, function ($q) {
if ($this->customerFilter === 'customers_only') {
$q->whereHas('customer');
} elseif ($this->customerFilter === 'non_customers') {
$q->whereDoesntHave('customer');
}
})
->when(!$this->showInactive, function ($q) {
$q->where('status', '!=', 'inactive');
})
->orderBy($this->sortField, $this->sortDirection);
$users = $query->paginate($this->perPage);
// Filter options data
$roles = Role::where('is_active', true)->orderBy('display_name')->get();
$departments = User::select('department')
->distinct()
->whereNotNull('department')
->where('department', '!=', '')
->orderBy('department')
->pluck('department');
$branches = Branch::where('is_active', true)
->orderBy('name')
->get();
$hireYears = User::selectRaw('YEAR(hire_date) as year')
->whereNotNull('hire_date')
->distinct()
->orderByDesc('year')
->pluck('year')
->filter();
// Enhanced statistics
$stats = [
'total' => User::count(),
'active' => User::where('status', 'active')->count(),
'inactive' => User::where('status', 'inactive')->count(),
'suspended' => User::where('status', 'suspended')->count(),
'customers' => User::whereHas('customer')->count(),
'staff' => User::whereDoesntHave('customer')->count(),
'recent_hires' => User::where('hire_date', '>=', now()->subDays(30))->count(),
'no_roles' => User::whereDoesntHave('roles', function($q) {
$q->where('user_roles.is_active', true);
})->count(),
];
// Branch distribution for stats
$branchStats = User::select('branch_code')
->selectRaw('count(*) as count')
->with('branch:code,name')
->groupBy('branch_code')
->get()
->mapWithKeys(function($item) {
$branchName = $item->branch ? $item->branch->name : $item->branch_code;
return [$branchName => $item->count];
});
return view('livewire.users.index', compact(
'users',
'roles',
'departments',
'branches',
'hireYears',
'stats',
'branchStats'
));
}
public function sortBy($field)
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
$this->resetPage();
}
public function clearFilters()
{
$this->search = '';
$this->roleFilter = '';
$this->statusFilter = '';
$this->departmentFilter = '';
$this->branchFilter = '';
$this->customerFilter = '';
$this->hireYearFilter = '';
$this->showInactive = false;
$this->resetPage();
}
public function toggleShowInactive()
{
$this->showInactive = !$this->showInactive;
$this->resetPage();
}
public function toggleShowDetails()
{
$this->showDetails = !$this->showDetails;
}
public function selectAllUsers()
{
if ($this->selectAll) {
$this->selectedUsers = [];
$this->selectAll = false;
} else {
// Only select users from current page for performance
$currentPageUsers = User::when($this->search, function ($q) {
$searchTerm = '%' . $this->search . '%';
$q->where(function ($query) use ($searchTerm) {
$query->where('name', 'like', $searchTerm)
->orWhere('email', 'like', $searchTerm);
});
})->pluck('id')->toArray();
$this->selectedUsers = $currentPageUsers;
$this->selectAll = true;
}
}
public function confirmDelete($userId)
{
$this->userToDelete = $userId;
$this->showDeleteModal = true;
}
public function confirmBulkDelete()
{
if (empty($this->selectedUsers)) {
session()->flash('error', 'No users selected.');
return;
}
$this->showBulkDeleteModal = true;
}
public function bulkActivate()
{
if (empty($this->selectedUsers)) {
session()->flash('error', 'No users selected.');
return;
}
try {
$count = User::whereIn('id', $this->selectedUsers)
->where('id', '!=', auth()->id())
->update(['status' => 'active']);
// Log bulk action
activity()
->causedBy(auth()->user())
->log('Bulk activated ' . $count . ' users');
$this->selectedUsers = [];
$this->selectAll = false;
session()->flash('success', "Successfully activated {$count} users.");
} catch (\Exception $e) {
session()->flash('error', 'Failed to activate users: ' . $e->getMessage());
}
}
public function bulkDeactivate()
{
if (empty($this->selectedUsers)) {
session()->flash('error', 'No users selected.');
return;
}
try {
$count = User::whereIn('id', $this->selectedUsers)
->where('id', '!=', auth()->id())
->update(['status' => 'inactive']);
// Log bulk action
activity()
->causedBy(auth()->user())
->log('Bulk deactivated ' . $count . ' users');
$this->selectedUsers = [];
$this->selectAll = false;
session()->flash('success', "Successfully deactivated {$count} users.");
} catch (\Exception $e) {
session()->flash('error', 'Failed to deactivate users: ' . $e->getMessage());
}
}
public function bulkSuspend()
{
if (empty($this->selectedUsers)) {
session()->flash('error', 'No users selected.');
return;
}
try {
$count = User::whereIn('id', $this->selectedUsers)
->where('id', '!=', auth()->id())
->update(['status' => 'suspended']);
// Log bulk action
activity()
->causedBy(auth()->user())
->log('Bulk suspended ' . $count . ' users');
$this->selectedUsers = [];
$this->selectAll = false;
session()->flash('success', "Successfully suspended {$count} users.");
} catch (\Exception $e) {
session()->flash('error', 'Failed to suspend users: ' . $e->getMessage());
}
}
public function bulkAssignRole($roleId)
{
if (empty($this->selectedUsers)) {
session()->flash('error', 'No users selected.');
return;
}
try {
$role = Role::findOrFail($roleId);
$count = 0;
foreach ($this->selectedUsers as $userId) {
$user = User::find($userId);
if ($user && !$user->hasRole($role->name)) {
$user->assignRole($role);
$count++;
}
}
// Log bulk action
activity()
->causedBy(auth()->user())
->log('Bulk assigned role "' . $role->display_name . '" to ' . $count . ' users');
$this->selectedUsers = [];
$this->selectAll = false;
session()->flash('success', "Successfully assigned role '{$role->display_name}' to {$count} users.");
} catch (\Exception $e) {
session()->flash('error', 'Failed to assign role: ' . $e->getMessage());
}
}
public function deactivateUser($userId)
{
try {
$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.");
} catch (\Exception $e) {
session()->flash('error', 'Failed to deactivate user: ' . $e->getMessage());
}
}
public function activateUser($userId)
{
try {
$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.");
} catch (\Exception $e) {
session()->flash('error', 'Failed to activate user: ' . $e->getMessage());
}
}
public function suspendUser($userId)
{
try {
$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.");
} catch (\Exception $e) {
session()->flash('error', 'Failed to suspend user: ' . $e->getMessage());
}
}
public function deleteUser($userId = null)
{
$userId = $userId ?? $this->userToDelete;
try {
$user = User::findOrFail($userId);
if ($user->id === auth()->id()) {
session()->flash('error', 'You cannot delete your own account.');
return;
}
// Check if user has dependencies
$jobCardsCount = $user->jobCards()->count();
if ($jobCardsCount > 0) {
session()->flash('error', "Cannot delete user '{$user->name}'. User has {$jobCardsCount} associated job cards.");
return;
}
// 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());
} finally {
$this->showDeleteModal = false;
$this->userToDelete = null;
}
}
public function bulkDelete()
{
if (empty($this->selectedUsers)) {
session()->flash('error', 'No users selected.');
return;
}
try {
$users = User::whereIn('id', $this->selectedUsers)
->where('id', '!=', auth()->id())
->get();
$deletedCount = 0;
$skippedCount = 0;
foreach ($users as $user) {
// Check dependencies
if ($user->jobCards()->count() > 0) {
$skippedCount++;
continue;
}
// Log before deletion
activity()
->performedOn($user)
->causedBy(auth()->user())
->log('User deleted (bulk)');
$user->delete();
$deletedCount++;
}
$this->selectedUsers = [];
$this->selectAll = false;
$this->showBulkDeleteModal = false;
$message = "Deleted {$deletedCount} users successfully.";
if ($skippedCount > 0) {
$message .= " {$skippedCount} users were skipped due to dependencies.";
}
session()->flash('success', $message);
} catch (\Exception $e) {
session()->flash('error', 'Failed to delete users: ' . $e->getMessage());
}
}
public function exportUsers()
{
try {
$users = User::with(['roles', 'branch'])
->when($this->search, function ($q) {
$searchTerm = '%' . $this->search . '%';
$q->where(function ($query) use ($searchTerm) {
$query->where('name', 'like', $searchTerm)
->orWhere('email', 'like', $searchTerm);
});
})
->when($this->roleFilter, function ($q) {
$q->whereHas('roles', function ($query) {
$query->where('roles.name', $this->roleFilter);
});
})
->when($this->statusFilter, function ($q) {
$q->where('status', $this->statusFilter);
})
->when($this->branchFilter, function ($q) {
$q->where('branch_code', $this->branchFilter);
})
->get();
// Create CSV content
$csvData = [];
$csvData[] = [
'Name', 'Email', 'Employee ID', 'Phone', 'Department',
'Position', 'Branch', 'Status', 'Hire Date', 'Roles'
];
foreach ($users as $user) {
$csvData[] = [
$user->name,
$user->email,
$user->employee_id,
$user->phone,
$user->department,
$user->position,
$user->branch ? $user->branch->name : $user->branch_code,
$user->status,
$user->hire_date ? $user->hire_date->format('Y-m-d') : '',
$user->roles->pluck('display_name')->join(', ')
];
}
// Store CSV file
$fileName = 'users_export_' . now()->format('Y_m_d_H_i_s') . '.csv';
$csv = '';
foreach ($csvData as $row) {
$csv .= '"' . implode('","', $row) . '"' . "\n";
}
Storage::put('exports/' . $fileName, $csv);
session()->flash('success', 'Export completed! Downloaded ' . $users->count() . ' users to ' . $fileName);
} catch (\Exception $e) {
session()->flash('error', 'Export failed: ' . $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) ||
!empty($this->customerFilter) ||
!empty($this->hireYearFilter) ||
$this->showInactive;
}
public function getSelectedCount()
{
return count($this->selectedUsers);
}
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',
'service_coordinator' => 'bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200',
'service_supervisor' => '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_portal' => 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-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',
};
}
public function getUserActivityClass($user)
{
$lastSeen = $user->last_seen_at;
if (!$lastSeen) return 'text-gray-500';
$minutesAgo = now()->diffInMinutes($lastSeen);
if ($minutesAgo < 5) return 'text-green-500';
if ($minutesAgo < 60) return 'text-yellow-500';
if ($minutesAgo < 1440) return 'text-orange-500';
return 'text-red-500';
}
public function getUserLastSeenText($user)
{
$lastSeen = $user->last_seen_at;
if (!$lastSeen) return 'Never';
return $lastSeen->diffForHumans();
}
public function canDeleteUser($user)
{
return $user->id !== auth()->id() &&
$user->jobCards()->count() === 0 &&
!$user->hasRole('super_admin');
}
public function canModifyUser($user)
{
return $user->id !== auth()->id() || auth()->user()->hasRole('super_admin');
}
}