parent
b30b1fc4fa
commit
8e827de35f
8 changed files with 376 additions and 8 deletions
@ -0,0 +1,141 @@ |
||||
<?php |
||||
|
||||
namespace App\Components\User; |
||||
|
||||
use Livewire\Component; |
||||
use Livewire\Attributes\On; |
||||
use Livewire\WithPagination; |
||||
use Illuminate\Validation\Rule; |
||||
use App\Models\User; |
||||
use Spatie\Permission\Models\Role; |
||||
|
||||
class Manager extends Component |
||||
{ |
||||
use WithPagination; |
||||
|
||||
public array $perPageOptions = [10, 25, 50, 100]; |
||||
public int $perPage = 10; |
||||
protected string $paginationTheme = 'bootstrap'; |
||||
public string $mode = 'index'; |
||||
public ?int $editingId = null; |
||||
public string $search = ''; |
||||
|
||||
// Form fields |
||||
public string $fullname = ''; |
||||
public string $email = ''; |
||||
public string $password = ''; |
||||
public string $password_confirmation = ''; |
||||
public array $roles = []; |
||||
public array $allRoles = []; |
||||
|
||||
public function mount(): void |
||||
{ |
||||
$this->allRoles = Role::all()->toArray(); |
||||
} |
||||
|
||||
public function updatedPerPage(int $value): void |
||||
{ |
||||
$this->resetPage(); |
||||
} |
||||
|
||||
public function updatedSearch(string $value): void |
||||
{ |
||||
$this->resetPage(); |
||||
} |
||||
|
||||
#[On('userSaved')] |
||||
public function showIndex(): void |
||||
{ |
||||
$this->mode = 'index'; |
||||
$this->resetPage(); |
||||
$this->resetForm(); |
||||
} |
||||
|
||||
public function showForm(?int $id = null): void |
||||
{ |
||||
$this->mode = 'form'; |
||||
$this->editingId = $id; |
||||
|
||||
if ($id) { |
||||
$user = User::with('roles')->findOrFail($id); |
||||
$this->fullname = $user->fullname; |
||||
$this->email = $user->email; |
||||
$this->roles = $user->roles->pluck('name')->toArray(); |
||||
} else { |
||||
$this->resetForm(); |
||||
} |
||||
} |
||||
|
||||
protected function resetForm(): void |
||||
{ |
||||
$this->fullname = ''; |
||||
$this->email = ''; |
||||
$this->password = ''; |
||||
$this->password_confirmation = ''; |
||||
$this->roles = []; |
||||
$this->editingId = null; |
||||
} |
||||
|
||||
public function delete(int $id): void |
||||
{ |
||||
User::findOrFail($id)->delete(); |
||||
session()->flash('message', 'User deleted.'); |
||||
$this->resetPage(); |
||||
} |
||||
|
||||
public function save(): void |
||||
{ |
||||
$rules = [ |
||||
'fullname' => ['required', 'string', 'max:255'], |
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($this->editingId)], |
||||
'roles' => ['nullable', 'array'], |
||||
'roles.*' => ['string', Rule::exists('roles', 'name')], |
||||
]; |
||||
|
||||
if (! $this->editingId) { |
||||
$rules['password'] = ['required', 'string', 'min:6', 'confirmed']; |
||||
} elseif ($this->password) { |
||||
$rules['password'] = ['string', 'min:6', 'confirmed']; |
||||
} |
||||
|
||||
$this->validate($rules); |
||||
|
||||
$user = $this->editingId |
||||
? User::findOrFail($this->editingId) |
||||
: new User; |
||||
|
||||
$user->fullname = $this->fullname; |
||||
$user->email = $this->email; |
||||
|
||||
if ($this->password) { |
||||
$user->password = $this->password; |
||||
} |
||||
|
||||
$user->save(); |
||||
$user->syncRoles($this->roles); |
||||
|
||||
session()->flash('message', 'User saved.'); |
||||
$this->dispatch('userSaved'); |
||||
} |
||||
|
||||
public function render() |
||||
{ |
||||
$users = User::with('roles') |
||||
->when( |
||||
$this->search, |
||||
fn($q) => $q |
||||
->where('fullname', 'like', "%{$this->search}%") |
||||
->orWhere('email', 'like', "%{$this->search}%") |
||||
) |
||||
->orderByDesc('id') |
||||
->paginate($this->perPage); |
||||
|
||||
return view('components.user.manager', [ |
||||
'users' => $users, |
||||
'allRoles' => collect($this->allRoles), |
||||
'title' => $this->mode === 'index' |
||||
? 'Users' |
||||
: ($this->editingId ? 'Edit User' : 'Create User'), |
||||
]); |
||||
} |
||||
} |
@ -0,0 +1,13 @@ |
||||
<?php |
||||
namespace App\Http\Controllers; |
||||
|
||||
use App\Http\Controllers\Controller; |
||||
use Spatie\Permission\Models\Role; |
||||
|
||||
class UserController extends Controller |
||||
{ |
||||
public function manager() |
||||
{ |
||||
return view('admin.users.manager'); |
||||
} |
||||
} |
@ -0,0 +1,5 @@ |
||||
@extends('layouts.app') |
||||
|
||||
@section('content') |
||||
<livewire:user.manager /> |
||||
@endsection |
@ -0,0 +1,184 @@ |
||||
{{-- resources/views/components/user/manager.blade.php --}} |
||||
<div class="row"> |
||||
<div class="col-12"> |
||||
<div class="card mb-4"> |
||||
{{-- Header --}} |
||||
<div class="card-header d-flex justify-content-between align-items-center pb-0"> |
||||
<h6 class="mb-0">{{ $title }}</h6> |
||||
@if ($mode === 'index') |
||||
<div class="d-flex align-items-center"> |
||||
<div class="me-2"> |
||||
<select wire:model="perPage" wire:change="resetPage" |
||||
class="form-select form-select-sm" style="width:100px;display:inline-block"> |
||||
@foreach ($perPageOptions as $opt) |
||||
<option value="{{ $opt }}">{{ $opt }}</option> |
||||
@endforeach |
||||
</select> |
||||
</div> |
||||
<div class="input-group input-group-outline me-2"> |
||||
<input type="text" |
||||
wire:model.debounce.500ms="search" |
||||
wire:keydown.enter="resetPage" |
||||
class="form-control form-control-sm" |
||||
placeholder="Search name or email..."> |
||||
</div> |
||||
<button wire:click="resetPage" |
||||
class="btn btn-sm btn-outline-success me-2 m-0"> |
||||
<i class="fas fa-search"></i> |
||||
</button> |
||||
</div> |
||||
@else |
||||
<button wire:click="showIndex" class="btn btn-sm btn-secondary"> |
||||
← Back to list |
||||
</button> |
||||
@endif |
||||
</div> |
||||
|
||||
{{-- Body --}} |
||||
<div class="card-body p-3"> |
||||
@if (session()->has('message')) |
||||
<div class="alert alert-success">{{ session('message') }}</div> |
||||
@endif |
||||
|
||||
@if ($mode === 'index') |
||||
{{-- Skeleton with pagination placeholders --}} |
||||
<x-skeleton-table |
||||
:columns="6" |
||||
:rows="10" |
||||
height="3.5rem" |
||||
:pages="5" |
||||
/> |
||||
|
||||
{{-- Actual table --}} |
||||
<div wire:loading.remove class="table-responsive"> |
||||
<table class="table table-sm align-middle mb-0"> |
||||
<thead> |
||||
<tr style="height:3.5rem"> |
||||
<th class="text-center" style="width:5%;">ID</th> |
||||
<th style="width:25%;">Full Name</th> |
||||
<th style="width:30%;">Email</th> |
||||
<th style="width:20%;">Role</th> |
||||
<th style="width:20%;">Phone</th> |
||||
<th class="text-center" style="width:5%;">Actions</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
@forelse($users as $u) |
||||
<tr style="height:3.5rem"> |
||||
<td class="text-center">{{ $u->id }}</td> |
||||
<td>{{ $u->fullname }}</td> |
||||
<td>{{ $u->email }}</td> |
||||
<td>{{ $u->roles->pluck('name')->join(', ') }}</td> |
||||
<td>{{ $u->phone }}</td> |
||||
<td class="text-center"> |
||||
<button wire:click="showForm({{ $u->id }})" |
||||
class="btn btn-sm btn-outline-info me-1"> |
||||
Edit |
||||
</button> |
||||
<button x-data |
||||
@click.prevent="if(confirm('Delete user?')){ $wire.delete({{ $u->id }}) }" |
||||
class="btn btn-sm btn-danger"> |
||||
Delete |
||||
</button> |
||||
</td> |
||||
</tr> |
||||
@empty |
||||
<tr> |
||||
<td colspan="6" class="text-center">No users found.</td> |
||||
</tr> |
||||
@endforelse |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
|
||||
{{-- Real pagination --}} |
||||
<div wire:loading.remove class="mt-3"> |
||||
@if ($users->lastPage() > 1) |
||||
{{ $users->links() }} |
||||
@endif |
||||
</div> |
||||
@else |
||||
{{-- Form create/edit --}} |
||||
<x-skeleton-form :fields="4" :button-count="2" /> |
||||
<div wire:loading.remove> |
||||
<form> |
||||
<div class="mb-3"> |
||||
<label class="form-label">Name</label> |
||||
<input type="text" |
||||
wire:model.defer="fullname" |
||||
class="form-control form-control-sm" |
||||
placeholder="fullname"> |
||||
@error('fullname') |
||||
<div class="text-danger small">{{ $message }}</div> |
||||
@enderror |
||||
</div> |
||||
<div class="mb-3"> |
||||
<label class="form-label">Email</label> |
||||
<input type="email" |
||||
wire:model.defer="email" |
||||
class="form-control form-control-sm" |
||||
placeholder="Email"> |
||||
@error('email') |
||||
<div class="text-danger small">{{ $message }}</div> |
||||
@enderror |
||||
</div> |
||||
<div class="mb-3"> |
||||
<label class="form-label"> |
||||
Password |
||||
@if ($editingId) |
||||
<small>(leave blank to keep current)</small> |
||||
@endif |
||||
</label> |
||||
<input type="password" |
||||
wire:model.defer="password" |
||||
class="form-control form-control-sm" |
||||
placeholder="Password"> |
||||
@error('password') |
||||
<div class="text-danger small">{{ $message }}</div> |
||||
@enderror |
||||
</div> |
||||
<div class="mb-3"> |
||||
<label class="form-label">Confirm Password</label> |
||||
<input type="password" |
||||
wire:model.defer="password_confirmation" |
||||
class="form-control form-control-sm" |
||||
placeholder="Confirm"> |
||||
</div> |
||||
<div class="mb-3"> |
||||
<label class="form-label">Roles</label> |
||||
<select wire:model.defer="roles" |
||||
multiple |
||||
class="form-select form-select-sm"> |
||||
@foreach ($allRoles as $role) |
||||
<option value="{{ $role['name'] }}">{{ ucfirst($role['name']) }}</option> |
||||
@endforeach |
||||
</select> |
||||
@error('roles') |
||||
<div class="text-danger small">{{ $message }}</div> |
||||
@enderror |
||||
</div> |
||||
<div class="d-flex justify-content-end"> |
||||
<button type="button" |
||||
wire:click="showIndex" |
||||
class="btn btn-secondary btn-sm me-2"> |
||||
Cancel |
||||
</button> |
||||
<button type="button" |
||||
@click.prevent="if(confirm('Do you really want to {{ $editingId ? 'update' : 'create' }} this user?')){ $wire.save() }" |
||||
class="btn btn-primary btn-sm"> |
||||
{{ $editingId ? 'Update' : 'Create' }} |
||||
</button> |
||||
</div> |
||||
</form> |
||||
</div> |
||||
@endif |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
@script |
||||
<script> |
||||
document.title = @json($title); |
||||
</script> |
||||
@endscript |
||||
</div> |
Loading…
Reference in new issue