Feat: Searchable Select для групп пользователя

 TomSelect с мультивыбором (теги как в WordPress)
 API /api/groups/search для поиска групп
 Обновлён edit.blade.php пользователя
 Обновлён show.blade.php пользователя
 Компонент поддерживает multiple=true

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
mirivlad 2026-03-30 12:07:07 +08:00
parent 7782c59f5b
commit 1f99664d19
6 changed files with 71 additions and 79 deletions

View File

@ -91,24 +91,8 @@ class UserController extends Controller
Gate::authorize('view', $user);
$user->load(['organization', 'roles', 'groups']);
// Получаем доступные группы для пользователя
if ($user->organization_id) {
$availableGroups = Group::whereNull('organization_id')
->orWhere('organization_id', $user->organization_id)
->whereDoesntHave('users', function($q) use ($user) {
$q->where('users.id', $user->id);
})
->get();
} else {
$availableGroups = Group::whereNull('organization_id')
->whereDoesntHave('users', function($q) use ($user) {
$q->where('users.id', $user->id);
})
->get();
}
return view('admin.users.show', compact('user', 'availableGroups'));
return view('admin.users.show', compact('user'));
}
public function edit(User $user)

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Group;
use Illuminate\Http\Request;
class GroupSearchController extends Controller
{
public function __invoke(Request $request)
{
$query = $request->get('q', '');
$groups = Group::query()
->with('organization')
->where('name', 'like', "%{$query}%")
->orderBy('name')
->limit(50)
->get()
->map(function($group) {
return [
'id' => $group->id,
'text' => $group->name . ($group->organization ? " ({$group->organization->name})" : ' (Общая)'),
];
});
return response()->json($groups);
}
}

View File

@ -63,12 +63,14 @@
@if($allGroups->count() > 0)
<div class="mb-3">
<label class="form-label">Группы</label>
@foreach($allGroups as $group)
<div class="form-check">
<input type="checkbox" name="groups[]" value="{{ $group->id }}" class="form-check-input" {{ in_array($group->id, $userGroups) ? 'checked' : '' }}>
<label class="form-check-label">{{ $group->name }}</label>
</div>
@endforeach
<x-searchable-select
name="groups[]"
url="{{ route('admin.groups.search') }}"
placeholder="Начните вводить название группы..."
:value="$userGroups"
:multiple="true"
/>
<small class="text-muted">Выберите группы</small>
</div>
@endif
<div class="form-check mb-3">

View File

@ -38,24 +38,24 @@
</div>
<div class="card-body">
@if($user->groups->count() > 0)
<ul class="list-group list-group-flush">
@foreach($user->groups as $group)
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<a href="{{ route('admin.groups.show', $group) }}" class="text-decoration-none">{{ $group->name }}</a>
<small class="text-muted d-block">{{ $group->organization?->name ?? 'Общая группа' }}</small>
</div>
@can('update', $group)
<form action="{{ route('admin.groups.users.remove', [$group, $user]) }}" method="POST" class="d-inline" onsubmit="return confirm('Удалить пользователя из группы?')">
@csrf @method('DELETE')
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-x-lg"></i></button>
</form>
@endcan
</li>
@endforeach
</ul>
<div class="mb-3">
<x-searchable-select
name="groups[]"
url="{{ route('api.groups.search') }}"
placeholder="Начните вводить название группы..."
:value="$user->groups->pluck('id')->toArray()"
:multiple="true"
/>
</div>
@else
<p class="text-muted mb-0">Не состоит в группах</p>
<div class="mb-3">
<x-searchable-select
name="groups[]"
url="{{ route('api.groups.search') }}"
placeholder="Начните вводить название группы..."
:multiple="true"
/>
</div>
@endif
</div>
</div>
@ -64,34 +64,4 @@
</main>
</div>
</div>
<!-- Modal добавления в группу -->
<div class="modal fade" id="addGroupModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ route('admin.groups.users.add', $user) }}" method="POST">
@csrf
<div class="modal-header">
<h5 class="modal-title">Добавить в группу</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Выберите группу</label>
<select name="group_id" class="form-select" required>
<option value="">Выберите группу</option>
@foreach($availableGroups as $group)
<option value="{{ $group->id }}">{{ $group->name }} @if($group->organization_id)({{ $group->organization?->name }})@else(Общая)@endif</option>
@endforeach
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary">Добавить</button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@ -1,7 +1,7 @@
@props(['name', 'url', 'placeholder' => 'Начните вводить...', 'value' => null, 'required' => false])
@props(['name', 'url', 'placeholder' => 'Начните вводить...', 'value' => null, 'required' => false, 'multiple' => false])
<input type="hidden" name="{{ $name }}" id="{{ $name }}" value="{{ $value }}" {{ $required ? 'required' : '' }}>
<select id="{{ $name }}-select" class="form-select @error($name) is-invalid @enderror" {{ $required ? 'required' : '' }}></select>
<input type="hidden" name="{{ $name }}" id="{{ $name }}" value="{{ is_array($value) ? implode(',', $value) : $value }}" {{ $required ? 'required' : '' }}>
<select id="{{ $name }}-select" class="form-select @error($name) is-invalid @enderror" {{ $required ? 'required' : '' }} {{ $multiple ? 'multiple' : '' }}></select>
@push('scripts')
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.css" rel="stylesheet">
@ -15,6 +15,7 @@ document.addEventListener('DOMContentLoaded', function() {
placeholder: '{{ $placeholder }}',
preload: false,
maxOptions: null,
{{ $multiple ? 'persist: false, hideSelected: true,' : '' }}
load: function(query, callback) {
if (query.length < 2) return callback();
@ -35,7 +36,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
},
onChange: function(value) {
document.getElementById('{{ $name }}').value = value;
document.getElementById('{{ $name }}').value = {{ $multiple ? 'Array.isArray(value) ? value.join(",") : value' : 'value' }};
}
});
@ -43,11 +44,14 @@ document.addEventListener('DOMContentLoaded', function() {
fetch('{{ $url }}?q=')
.then(response => response.json())
.then(items => {
const item = items.find(i => i.id == '{{ $value }}');
if (item) {
select.addOption(item);
select.setValue('{{ $value }}');
}
const values = {{ is_array($value) ? json_encode($value) : "['" . $value . "']" }};
values.forEach(val => {
const item = items.find(i => i.id == val);
if (item) {
select.addOption(item);
}
});
select.setValue(values);
});
@endif
});

View File

@ -12,6 +12,7 @@ use App\Http\Controllers\Admin\QuestionController;
use App\Http\Controllers\Admin\CourseAssignmentController;
use App\Http\Controllers\Admin\GroupUserController;
use App\Http\Controllers\Api\OrganizationSearchController;
use App\Http\Controllers\Api\GroupSearchController;
use App\Http\Controllers\DashboardController;
use Illuminate\Support\Facades\Route;
@ -58,5 +59,6 @@ Route::middleware('auth')->group(function () {
// API для поиска (требуется аутентификация)
Route::middleware('auth')->group(function() {
Route::get('/api/organizations/search', OrganizationSearchController::class)->name('api.organizations.search');
Route::get('/api/groups/search', GroupSearchController::class)->name('api.groups.search');
});
});