浏览代码

refactor(ui): improve area autocomplete and modal backdrop cleanup

- Replace area select dropdown with autocomplete search input
- Add area search functionality with debouncing
- Clean up stale modal backdrop and body lock state on page show
- Remove alert auto-hide behavior
- Improve validation and error display for area field
- Update responsible create and edit forms with new autocomplete pattern
Alexander Musikhin 1 周之前
父节点
当前提交
98270e502a

+ 11 - 1
app/Http/Controllers/ResponsibleController.php

@@ -33,7 +33,17 @@ class ResponsibleController extends Controller
     ];
     public function __construct()
     {
-        $this->data['areas'] = ['' => '-'] + Area::query()->get()->pluck('name', 'id')->toArray();
+        $areas = Area::query()
+            ->orderBy('name')
+            ->get(['id', 'name']);
+
+        $this->data['areas'] = ['' => '-'] + $areas
+            ->pluck('name', 'id')
+            ->toArray();
+        $this->data['areasForSearch'] = $areas
+            ->map(fn (Area $area) => ['id' => (string) $area->id, 'name' => $area->name])
+            ->values()
+            ->toArray();
     }
 
 

+ 1 - 1
app/Http/Requests/StoreResponsibleRequest.php

@@ -22,7 +22,7 @@ class StoreResponsibleRequest extends FormRequest
     public function rules(): array
     {
         return [
-            'area_id'   => 'nullable|exists:areas,id',
+            'area_id'   => 'required|integer|exists:areas,id',
             'name'      => 'required|string',
             'phone'     => 'required|string',
             'post'      => 'nullable|string',

+ 16 - 0
resources/js/custom.js

@@ -1,4 +1,20 @@
 $(document).ready(function () {
+    function cleanupStaleModalState() {
+        // If no modal is visible, remove stale Bootstrap backdrop and body lock.
+        if (!document.querySelector('.modal.show')) {
+            document.querySelectorAll('.modal-backdrop').forEach(function (backdrop) {
+                backdrop.remove();
+            });
+
+            document.body.classList.remove('modal-open');
+            document.body.style.removeProperty('padding-right');
+            document.body.style.removeProperty('overflow');
+        }
+    }
+
+    cleanupStaleModalState();
+    window.addEventListener('pageshow', cleanupStaleModalState);
+
     if ($('.main-alert').length) {
         setTimeout(function () {
             $('.main-alert').fadeTo(2000, 500).slideUp(500, function () {

+ 3 - 1
resources/views/orders/index.blade.php

@@ -11,10 +11,12 @@
         <div class="col-md-4 text-end">
             @if(hasRole('admin'))
                 <a href="{{ route('order.create') }}" class="btn btn-sm btn-primary">Создать</a>
+            @endif
+            @if(hasRole('admin,manager'))
                 <a href="#" class="btn btn-sm btn-primary" onclick="$('#export-orders').submit()">Экспорт</a>
             @endif
         </div>
-        @if(hasRole('admin'))
+        @if(hasRole('admin,manager'))
             <form class="d-none" method="post" action="{{ route('order.export') }}" id="export-orders">
                 @csrf
             </form>

+ 185 - 1
resources/views/responsibles/edit.blade.php

@@ -9,7 +9,30 @@
                 <h4>Ответственный</h4>
                 @csrf
 
-                @include('partials.select', ['name' => 'area_id', 'title' => 'Район', 'options' => $areas, 'value' => $responsible->area?->id])
+                <div class="row mb-2">
+                    <label for="area_search" class="col-form-label small col-md-4 text-md-end">
+                        Район
+                        <sup>*</sup>
+                    </label>
+                    <div class="col-md-8">
+                        <div class="position-relative">
+                            <input type="text"
+                                   class="form-control form-control-sm"
+                                   id="area_search"
+                                   placeholder="Введите район..."
+                                   autocomplete="off"
+                                   value="{{ old('area_name', $responsible->area?->name ?? '') }}"
+                                   required>
+                            <input type="hidden"
+                                   name="area_id"
+                                   id="area_id"
+                                   value="{{ old('area_id', $responsible->area?->id ?? '') }}"
+                                   required>
+                            <div class="autocomplete-dropdown autocomplete-dropdown--order" id="area_dropdown"></div>
+                        </div>
+                        <div class="form-text" id="area_hint"></div>
+                    </div>
+                </div>
                 @include('partials.input', ['name' => 'name', 'title' => 'ФИО', 'required' => true, 'value' => $responsible->name])
                 @include('partials.input', ['name' => 'phone', 'title' => 'Телефон', 'required' => true, 'value' => $responsible->phone])
                 @include('partials.input', ['name' => 'post', 'title' => 'Должность', 'value' => $responsible->post])
@@ -25,3 +48,164 @@
         </form>
     </div>
 @endsection
+@push('scripts')
+    <script>
+        waitForJQuery(function () {
+            const $form = $('form.row[action*="responsible"]');
+            const $input = $('#area_search');
+            const $dropdown = $('#area_dropdown');
+            const $hiddenInput = $('#area_id');
+            const $hint = $('#area_hint');
+            let currentFocus = -1;
+
+            const areas = @json($areasForSearch ?? []);
+
+            function escapeHtml(text) {
+                return String(text)
+                    .replace(/&/g, '&amp;')
+                    .replace(/</g, '&lt;')
+                    .replace(/>/g, '&gt;')
+                    .replace(/"/g, '&quot;')
+                    .replace(/'/g, '&#039;');
+            }
+
+            function escapeRegex(str) {
+                return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+            }
+
+            function highlightMatch(text, query) {
+                if (!text || !query) {
+                    return escapeHtml(text || '');
+                }
+                const safeText = escapeHtml(text);
+                const regex = new RegExp('(' + escapeRegex(query) + ')', 'gi');
+                return safeText.replace(regex, '<strong>$1</strong>');
+            }
+
+            function showDropdown(items, query) {
+                $dropdown.empty();
+                currentFocus = -1;
+
+                if (!items.length) {
+                    $dropdown.html('<div class="autocomplete-item text-muted">Ничего не найдено</div>');
+                    $dropdown.show();
+                    return;
+                }
+
+                items.forEach(function (item) {
+                    const html = '<div class="autocomplete-item" data-id="' + item.id + '" data-name="' + escapeHtml(item.name) + '">' +
+                        '<div class="article">' + highlightMatch(item.name, query) + '</div>' +
+                        '</div>';
+                    $dropdown.append(html);
+                });
+
+                $dropdown.show();
+            }
+
+            function hideDropdown() {
+                $dropdown.hide();
+            }
+
+            function selectItem($item) {
+                const id = String($item.data('id') || '');
+                const name = String($item.data('name') || '');
+
+                $input.val(name);
+                $hiddenInput.val(id);
+                $hint.html('<i class="bi bi-check-circle text-success"></i> Выбрано: ' + name);
+                $input.removeClass('is-invalid');
+                hideDropdown();
+            }
+
+            function setActive($items) {
+                $items.removeClass('active');
+                if (currentFocus >= 0 && currentFocus < $items.length) {
+                    const $active = $items.eq(currentFocus);
+                    $active.addClass('active');
+                    $active[0].scrollIntoView({block: 'nearest'});
+                }
+            }
+
+            function searchAreas(query) {
+                const normalized = String(query || '').trim().toLowerCase();
+                if (normalized.length < 1) {
+                    hideDropdown();
+                    return;
+                }
+
+                const items = areas.filter(function (item) {
+                    return String(item.name).toLowerCase().includes(normalized);
+                });
+
+                showDropdown(items.slice(0, 50), normalized);
+            }
+
+            $input.on('input', function () {
+                $hiddenInput.val('');
+                $hint.html('');
+                $input.removeClass('is-invalid');
+                searchAreas($(this).val());
+            });
+
+            $input.on('focus', function () {
+                if (!$hiddenInput.val()) {
+                    searchAreas($(this).val());
+                }
+            });
+
+            $input.on('keydown', function (e) {
+                const $items = $dropdown.find('.autocomplete-item:not(.text-muted)');
+
+                if (e.key === 'ArrowDown') {
+                    e.preventDefault();
+                    currentFocus++;
+                    if (currentFocus >= $items.length) {
+                        currentFocus = 0;
+                    }
+                    setActive($items);
+                } else if (e.key === 'ArrowUp') {
+                    e.preventDefault();
+                    currentFocus--;
+                    if (currentFocus < 0) {
+                        currentFocus = $items.length - 1;
+                    }
+                    setActive($items);
+                } else if (e.key === 'Enter') {
+                    if ($items.length > 0 && currentFocus > -1) {
+                        e.preventDefault();
+                        selectItem($items.eq(currentFocus));
+                    }
+                } else if (e.key === 'Escape') {
+                    hideDropdown();
+                }
+            });
+
+            $dropdown.on('click', '.autocomplete-item:not(.text-muted)', function () {
+                selectItem($(this));
+            });
+
+            $(document).on('click', function (e) {
+                if (!$(e.target).closest('#area_search, #area_dropdown').length) {
+                    hideDropdown();
+                }
+            });
+
+            $form.on('submit', function (e) {
+                if (!$hiddenInput.val()) {
+                    e.preventDefault();
+                    $input.addClass('is-invalid').focus();
+                    $hint.html('<span class="text-danger">Выберите район из списка</span>');
+                }
+            });
+
+            const initialAreaId = String($hiddenInput.val() || '');
+            const initialArea = areas.find(function (item) {
+                return String(item.id) === initialAreaId;
+            });
+            if (initialArea) {
+                $input.val(initialArea.name);
+                $hint.html('<i class="bi bi-check-circle text-success"></i> Выбрано: ' + initialArea.name);
+            }
+        });
+    </script>
+@endpush

+ 185 - 9
resources/views/responsibles/index.blade.php

@@ -34,7 +34,25 @@
                     <form action="{{ route('responsible.store') }}" method="post">
                         @csrf
 
-                        @include('partials.select', ['name' => 'area_id', 'title' => 'Район', 'options' => $areas])
+                        <div class="row mb-2">
+                            <label for="area_search" class="col-form-label small col-md-4 text-md-end">
+                                Район
+                                <sup>*</sup>
+                            </label>
+                            <div class="col-md-8">
+                                <div class="position-relative">
+                                    <input type="text"
+                                           class="form-control form-control-sm"
+                                           id="area_search"
+                                           placeholder="Введите район..."
+                                           autocomplete="off"
+                                           required>
+                                    <input type="hidden" name="area_id" id="area_id" value="{{ old('area_id', '') }}" required>
+                                    <div class="autocomplete-dropdown autocomplete-dropdown--order" id="area_dropdown"></div>
+                                </div>
+                                <div class="form-text" id="area_hint"></div>
+                            </div>
+                        </div>
                         @include('partials.input', ['name' => 'name', 'title' => 'ФИО', 'required' => true])
                         @include('partials.input', ['name' => 'phone', 'title' => 'Телефон', 'required' => true])
                         @include('partials.input', ['name' => 'post', 'title' => 'Должность'])
@@ -48,12 +66,170 @@
     </div>
 @endsection
 @push('scripts')
-{{--    <script type="module">--}}
-{{--        $('#select_maf').on('change', function () {--}}
-{{--            $('#product_id').val($(this).val());--}}
-{{--            $('#product_name').val($('#select_maf option:selected').text()).slideDown();--}}
-{{--            $('#select_maf_form').slideUp();--}}
-{{--            $('#sku_form').slideDown();--}}
-{{--        });--}}
-{{--    </script>--}}
+    <script>
+        waitForJQuery(function () {
+            const $modal = $('#addModal');
+            const $input = $modal.find('#area_search');
+            const $dropdown = $modal.find('#area_dropdown');
+            const $hiddenInput = $modal.find('#area_id');
+            const $hint = $modal.find('#area_hint');
+            const $form = $modal.find('form');
+            let currentFocus = -1;
+
+            const areas = @json($areasForSearch ?? []);
+
+            function escapeHtml(text) {
+                return String(text)
+                    .replace(/&/g, '&amp;')
+                    .replace(/</g, '&lt;')
+                    .replace(/>/g, '&gt;')
+                    .replace(/"/g, '&quot;')
+                    .replace(/'/g, '&#039;');
+            }
+
+            function escapeRegex(str) {
+                return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+            }
+
+            function highlightMatch(text, query) {
+                if (!text || !query) {
+                    return escapeHtml(text || '');
+                }
+                const safeText = escapeHtml(text);
+                const regex = new RegExp('(' + escapeRegex(query) + ')', 'gi');
+                return safeText.replace(regex, '<strong>$1</strong>');
+            }
+
+            function showDropdown(items, query) {
+                $dropdown.empty();
+                currentFocus = -1;
+
+                if (!items.length) {
+                    $dropdown.html('<div class="autocomplete-item text-muted">Ничего не найдено</div>');
+                    $dropdown.show();
+                    return;
+                }
+
+                items.forEach(function (item) {
+                    const html = '<div class="autocomplete-item" data-id="' + item.id + '" data-name="' + escapeHtml(item.name) + '">' +
+                        '<div class="article">' + highlightMatch(item.name, query) + '</div>' +
+                        '</div>';
+                    $dropdown.append(html);
+                });
+
+                $dropdown.show();
+            }
+
+            function hideDropdown() {
+                $dropdown.hide();
+            }
+
+            function selectItem($item) {
+                const id = String($item.data('id') || '');
+                const name = String($item.data('name') || '');
+
+                $input.val(name);
+                $hiddenInput.val(id);
+                $hint.html('<i class="bi bi-check-circle text-success"></i> Выбрано: ' + name);
+                $input.removeClass('is-invalid');
+                hideDropdown();
+            }
+
+            function setActive($items) {
+                $items.removeClass('active');
+                if (currentFocus >= 0 && currentFocus < $items.length) {
+                    const $active = $items.eq(currentFocus);
+                    $active.addClass('active');
+                    $active[0].scrollIntoView({block: 'nearest'});
+                }
+            }
+
+            function searchAreas(query) {
+                const normalized = String(query || '').trim().toLowerCase();
+                if (normalized.length < 1) {
+                    hideDropdown();
+                    return;
+                }
+
+                const items = areas.filter(function (item) {
+                    return String(item.name).toLowerCase().includes(normalized);
+                });
+
+                showDropdown(items.slice(0, 50), normalized);
+            }
+
+            $input.on('input', function () {
+                $hiddenInput.val('');
+                $hint.html('');
+                $input.removeClass('is-invalid');
+                searchAreas($(this).val());
+            });
+
+            $input.on('focus', function () {
+                if (!$hiddenInput.val()) {
+                    searchAreas($(this).val());
+                }
+            });
+
+            $input.on('keydown', function (e) {
+                const $items = $dropdown.find('.autocomplete-item:not(.text-muted)');
+
+                if (e.key === 'ArrowDown') {
+                    e.preventDefault();
+                    currentFocus++;
+                    if (currentFocus >= $items.length) {
+                        currentFocus = 0;
+                    }
+                    setActive($items);
+                } else if (e.key === 'ArrowUp') {
+                    e.preventDefault();
+                    currentFocus--;
+                    if (currentFocus < 0) {
+                        currentFocus = $items.length - 1;
+                    }
+                    setActive($items);
+                } else if (e.key === 'Enter') {
+                    if ($items.length > 0 && currentFocus > -1) {
+                        e.preventDefault();
+                        selectItem($items.eq(currentFocus));
+                    }
+                } else if (e.key === 'Escape') {
+                    hideDropdown();
+                }
+            });
+
+            $dropdown.on('click', '.autocomplete-item:not(.text-muted)', function () {
+                selectItem($(this));
+            });
+
+            $(document).on('click', function (e) {
+                if (!$(e.target).closest('#area_search, #area_dropdown').length) {
+                    hideDropdown();
+                }
+            });
+
+            $form.on('submit', function (e) {
+                if (!$hiddenInput.val()) {
+                    e.preventDefault();
+                    $input.addClass('is-invalid').focus();
+                    $hint.html('<span class="text-danger">Выберите район из списка</span>');
+                }
+            });
+
+            @if(old('area_id'))
+            const oldAreaId = '{{ old('area_id') }}';
+            const oldArea = areas.find(function (item) {
+                return String(item.id) === String(oldAreaId);
+            });
+            if (oldArea) {
+                $input.val(oldArea.name);
+                $hint.html('<i class="bi bi-check-circle text-success"></i> Выбрано: ' + oldArea.name);
+            }
+            @endif
+
+            $modal.on('hidden.bs.modal', function () {
+                hideDropdown();
+            });
+        });
+    </script>
 @endpush

+ 3 - 1
routes/web.php

@@ -235,7 +235,6 @@ Route::middleware('auth:web')->group(function () {
         Route::delete('schedule/delete/{schedule}', [ScheduleController::class, 'delete'])->name('schedule.delete');
 
         Route::get('order/create', [OrderController::class, 'create'])->name('order.create');
-        Route::post('order/export', [OrderController::class, 'export'])->name('order.export');
         Route::post('order/export-one/{order}', [OrderController::class, 'exportOne'])->name('order.export-one');
 
         Route::get('order/{order}/get-maf', [OrderController::class, 'getMafToOrder'])->name('order.get-maf');
@@ -273,6 +272,9 @@ Route::middleware('auth:web')->group(function () {
         ->middleware('role:' . Role::ADMIN);
 
     Route::get('schedule', [ScheduleController::class, 'index'])->name('schedule.index');
+    Route::post('order/export', [OrderController::class, 'export'])
+        ->name('order.export')
+        ->middleware('role:admin,manager');
 
 
     // ajax search products