352 lines
14 KiB
PHP
352 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Models\User;
|
|
use Livewire\Volt\Component;
|
|
use Livewire\WithPagination;
|
|
use function Livewire\Volt\{layout, title, state};
|
|
|
|
layout('components.layouts.app');
|
|
title('Users Management');
|
|
|
|
new class extends Component {
|
|
use WithPagination;
|
|
|
|
public string $search = '';
|
|
public string $roleFilter = '';
|
|
public string $sortField = 'name';
|
|
public string $sortDirection = 'asc';
|
|
|
|
// Modal state
|
|
public bool $showRoleModal = false;
|
|
public ?int $selectedUserId = null;
|
|
public array $selectedRoles = [];
|
|
|
|
public function with(): array
|
|
{
|
|
$query = User::with('roles')
|
|
->when($this->search, fn($q, $search) =>
|
|
$q->where('name', 'like', "%{$search}%")
|
|
->orWhere('email', 'like', "%{$search}%")
|
|
)
|
|
->when($this->roleFilter, fn($q, $role) =>
|
|
$q->whereHas('roles', fn($roleQuery) =>
|
|
$roleQuery->where('name', $role)
|
|
)
|
|
);
|
|
|
|
return [
|
|
'users' => $query->orderBy($this->sortField, $this->sortDirection)->paginate(15),
|
|
'totalUsers' => User::count(),
|
|
'verifiedUsers' => User::whereNotNull('email_verified_at')->count(),
|
|
'availableRoles' => \Spatie\Permission\Models\Role::orderBy('name')->get(),
|
|
'selectedUser' => $this->selectedUserId ? User::find($this->selectedUserId) : null,
|
|
];
|
|
}
|
|
|
|
public function sortBy(string $field): void
|
|
{
|
|
if ($this->sortField === $field) {
|
|
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
$this->sortField = $field;
|
|
$this->sortDirection = 'asc';
|
|
}
|
|
}
|
|
|
|
public function updatingSearch(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function updatingRoleFilter(): void
|
|
{
|
|
$this->resetPage();
|
|
}
|
|
|
|
public function openRoleModal(int $userId): void
|
|
{
|
|
$user = User::with('roles')->findOrFail($userId);
|
|
$this->selectedUserId = $userId;
|
|
$this->selectedRoles = $user->roles->pluck('name')->toArray();
|
|
$this->showRoleModal = true;
|
|
}
|
|
|
|
public function saveRoles(): void
|
|
{
|
|
if (!$this->selectedUserId) {
|
|
return;
|
|
}
|
|
|
|
$user = User::findOrFail($this->selectedUserId);
|
|
$user->syncRoles($this->selectedRoles);
|
|
|
|
$this->showRoleModal = false;
|
|
$this->selectedUserId = null;
|
|
$this->selectedRoles = [];
|
|
|
|
// Optional: Flash message
|
|
session()->flash('message', __('Roles updated successfully!'));
|
|
}
|
|
|
|
public function closeRoleModal(): void
|
|
{
|
|
$this->showRoleModal = false;
|
|
$this->selectedUserId = null;
|
|
$this->selectedRoles = [];
|
|
}
|
|
}; ?>
|
|
|
|
<div class="space-y-6 p-6">
|
|
{{-- Header --}}
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<flux:heading size="xl" class="mb-2">{{ __('Users Management') }}</flux:heading>
|
|
<flux:subheading>{{ __('Manage users and their roles in your application') }}</flux:subheading>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<flux:button variant="primary" icon="plus">{{ __('Create User') }}</flux:button>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Statistics --}}
|
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
|
<flux:card class="shadow-elegant">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<flux:subheading>{{ __('Total Users') }}</flux:subheading>
|
|
<flux:heading size="2xl" class="mt-2">{{ $totalUsers }}</flux:heading>
|
|
</div>
|
|
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-accent-100 dark:bg-accent-900/20">
|
|
<flux:icon.users class="h-6 w-6 text-accent-600 dark:text-accent-400" />
|
|
</div>
|
|
</div>
|
|
</flux:card>
|
|
|
|
<flux:card class="shadow-elegant">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<flux:subheading>{{ __('Verified Users') }}</flux:subheading>
|
|
<flux:heading size="2xl" class="mt-2">{{ $verifiedUsers }}</flux:heading>
|
|
</div>
|
|
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-accent-100 dark:bg-accent-900/20">
|
|
<flux:icon.shield-check class="h-6 w-6 text-accent-600 dark:text-accent-400" />
|
|
</div>
|
|
</div>
|
|
</flux:card>
|
|
|
|
<flux:card class="shadow-elegant">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<flux:subheading>{{ __('Active Roles') }}</flux:subheading>
|
|
<flux:heading size="2xl" class="mt-2">{{ $availableRoles->count() }}</flux:heading>
|
|
</div>
|
|
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-accent-100 dark:bg-accent-900/20">
|
|
<flux:icon.user-group class="h-6 w-6 text-accent-600 dark:text-accent-400" />
|
|
</div>
|
|
</div>
|
|
</flux:card>
|
|
</div>
|
|
|
|
{{-- Filters --}}
|
|
<flux:card class="shadow-elegant">
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
<flux:input wire:model.live.debounce.300ms="search" icon="magnifying-glass" placeholder="{{ __('Search users...') }}" />
|
|
|
|
<flux:select wire:model.live="roleFilter" placeholder="{{ __('All Roles') }}">
|
|
<flux:select.option value="">{{ __('All Roles') }}</flux:select.option>
|
|
@foreach($availableRoles as $role)
|
|
<flux:select.option :value="$role->name">{{ $role->display_name ?? $role->name }}</flux:select.option>
|
|
@endforeach
|
|
</flux:select>
|
|
|
|
@if($search || $roleFilter)
|
|
<flux:button wire:click="$set('search', ''); $set('roleFilter', '')" variant="ghost" icon="x-mark">
|
|
{{ __('Clear Filters') }}
|
|
</flux:button>
|
|
@endif
|
|
</div>
|
|
</flux:card>
|
|
|
|
{{-- Users Table --}}
|
|
<flux:card class="shadow-elegant">
|
|
<flux:table>
|
|
<flux:table.columns>
|
|
<flux:table.column class="w-1/4" wire:click="sortBy('name')" class="cursor-pointer">
|
|
<div class="flex items-center gap-2">
|
|
{{ __('User') }}
|
|
@if($sortField === 'name')
|
|
<flux:icon.{{ $sortDirection === 'asc' ? 'chevron-up' : 'chevron-down' }} variant="micro" />
|
|
@endif
|
|
</div>
|
|
</flux:table.column>
|
|
<flux:table.column wire:click="sortBy('email')" class="cursor-pointer">
|
|
<div class="flex items-center gap-2">
|
|
{{ __('Email') }}
|
|
@if($sortField === 'email')
|
|
<flux:icon.{{ $sortDirection === 'asc' ? 'chevron-up' : 'chevron-down' }} variant="micro" />
|
|
@endif
|
|
</div>
|
|
</flux:table.column>
|
|
<flux:table.column>{{ __('Roles') }}</flux:table.column>
|
|
<flux:table.column class="w-32">{{ __('Status') }}</flux:table.column>
|
|
<flux:table.column class="w-32">{{ __('Actions') }}</flux:table.column>
|
|
</flux:table.columns>
|
|
|
|
<flux:table.rows>
|
|
@forelse($users as $user)
|
|
<flux:table.row :key="$user->id" class="odd:bg-zinc-50 dark:odd:bg-zinc-800/30">
|
|
<flux:table.cell>
|
|
<div class="flex items-center gap-3 pl-2">
|
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-accent-100 text-accent-600 dark:bg-accent-900/20 dark:text-accent-400 font-semibold">
|
|
{{ $user->initials() }}
|
|
</div>
|
|
<div>
|
|
<div class="font-semibold text-zinc-900 dark:text-white">{{ $user->name }}</div>
|
|
<div class="text-xs text-zinc-500 dark:text-zinc-400">
|
|
{{ __('ID:') }} {{ $user->id }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</flux:table.cell>
|
|
|
|
<flux:table.cell>
|
|
<div class="flex items-center gap-2">
|
|
<flux:icon.envelope variant="micro" class="text-zinc-400" />
|
|
<span>{{ $user->email }}</span>
|
|
</div>
|
|
</flux:table.cell>
|
|
|
|
<flux:table.cell>
|
|
<div class="flex flex-wrap gap-1.5">
|
|
<button
|
|
type="button"
|
|
wire:click="openRoleModal({{ $user->id }})"
|
|
class="flex flex-wrap gap-1.5 cursor-pointer hover:opacity-80 transition-opacity"
|
|
>
|
|
@forelse($user->roles as $role)
|
|
<flux:badge size="sm" :color="$role->color ?? 'zinc'">
|
|
@svg('heroicon-o-'.$role->icon, 'w-5 h-5') {{ $role->display_name ?? $role->name }}
|
|
</flux:badge>
|
|
@empty
|
|
<flux:badge size="sm" color="zinc" icon="plus">{{ __('Assign Role') }}</flux:badge>
|
|
@endforelse
|
|
</button>
|
|
</div>
|
|
</flux:table.cell>
|
|
|
|
<flux:table.cell>
|
|
@if($user->email_verified_at)
|
|
<flux:badge size="sm" color="green" icon="check-circle">
|
|
{{ __('Verified') }}
|
|
</flux:badge>
|
|
@else
|
|
<flux:badge size="sm" color="zinc" icon="exclamation-circle">
|
|
{{ __('Unverified') }}
|
|
</flux:badge>
|
|
@endif
|
|
</flux:table.cell>
|
|
|
|
<flux:table.cell>
|
|
<div class="flex gap-2">
|
|
<flux:button size="sm" variant="ghost" icon="pencil"
|
|
tooltip="{{ __('Edit User') }}"></flux:button>
|
|
<flux:button size="sm" variant="ghost" icon="trash"
|
|
tooltip="{{ __('Delete User') }}"></flux:button>
|
|
</div>
|
|
</flux:table.cell>
|
|
</flux:table.row>
|
|
@empty
|
|
<flux:table.row>
|
|
<flux:table.cell colspan="5">
|
|
<div class="py-12 text-center">
|
|
<flux:icon.users variant="outline" class="mx-auto h-12 w-12 text-zinc-400" />
|
|
<flux:heading size="lg" class="mt-4">{{ __('No users found') }}</flux:heading>
|
|
<flux:subheading class="mt-2">
|
|
@if($search || $roleFilter)
|
|
{{ __('Try adjusting your filters.') }}
|
|
@else
|
|
{{ __('Get started by creating a new user.') }}
|
|
@endif
|
|
</flux:subheading>
|
|
</div>
|
|
</flux:table.cell>
|
|
</flux:table.row>
|
|
@endforelse
|
|
</flux:table.rows>
|
|
</flux:table>
|
|
|
|
{{-- Pagination --}}
|
|
@if($users->hasPages())
|
|
<div class="border-t border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
|
{{ $users->links() }}
|
|
</div>
|
|
@endif
|
|
</flux:card>
|
|
|
|
{{-- Role Assignment Modal --}}
|
|
<flux:modal name="role-modal" :variant="'flyout'" wire:model="showRoleModal">
|
|
<form wire:submit="saveRoles" class="space-y-6">
|
|
<div>
|
|
<flux:heading size="lg">{{ __('Assign Roles') }}</flux:heading>
|
|
<flux:subheading>
|
|
@if($selectedUser)
|
|
{{ __('Managing roles for') }} <strong>{{ $selectedUser->name }}</strong>
|
|
@endif
|
|
</flux:subheading>
|
|
</div>
|
|
|
|
<flux:separator />
|
|
|
|
<div class="space-y-4">
|
|
<flux:field>
|
|
<flux:label>{{ __('Roles') }}</flux:label>
|
|
<flux:description>{{ __('Select one or multiple roles for this user') }}</flux:description>
|
|
|
|
<div class="space-y-2 mt-3">
|
|
@foreach($availableRoles as $role)
|
|
<flux:checkbox
|
|
wire:model="selectedRoles"
|
|
:value="$role->name"
|
|
:label="$role->display_name ?? $role->name"
|
|
/>
|
|
@endforeach
|
|
</div>
|
|
</flux:field>
|
|
|
|
@if(!empty($selectedRoles))
|
|
<div class="rounded-lg bg-zinc-50 p-4 dark:bg-zinc-800/50">
|
|
<flux:subheading class="mb-2">{{ __('Selected Roles:') }}</flux:subheading>
|
|
<div class="flex flex-wrap gap-2">
|
|
@foreach($selectedRoles as $roleName)
|
|
@php
|
|
$roleObj = $availableRoles->firstWhere('name', $roleName);
|
|
@endphp
|
|
<flux:badge size="sm" :color="$roleObj?->color ?? 'zinc'">
|
|
{{ $roleName }}
|
|
</flux:badge>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<flux:separator />
|
|
|
|
<div class="flex justify-between gap-2">
|
|
<flux:button type="button" variant="ghost" wire:click="closeRoleModal">
|
|
{{ __('Cancel') }}
|
|
</flux:button>
|
|
<flux:button type="submit" variant="primary">
|
|
{{ __('Save Roles') }}
|
|
</flux:button>
|
|
</div>
|
|
</form>
|
|
</flux:modal>
|
|
|
|
{{-- Success Message --}}
|
|
@if (session()->has('message'))
|
|
<flux:toast :variant="'success'">
|
|
{{ session('message') }}
|
|
</flux:toast>
|
|
@endif
|
|
</div>
|