Bladeren bron

Доработки модуля запчастей: реализация фильтров, добавление справочника расценок и улучшение автозаполнения

Alexander Musikhin 3 dagen geleden
bovenliggende
commit
9bc2ac8e60

+ 24 - 0
app/Http/Controllers/PricingCodeController.php

@@ -105,4 +105,28 @@ class PricingCodeController extends Controller
 
         return response()->json(['description' => $description]);
     }
+
+    /**
+     * API метод для поиска кодов (autocomplete)
+     */
+    public function search(Request $request)
+    {
+        $type = $request->get('type');
+        $query = $request->get('query', '');
+
+        if (!$type) {
+            return response()->json([]);
+        }
+
+        $codes = PricingCode::where('type', $type)
+            ->where(function ($q) use ($query) {
+                $q->where('code', 'LIKE', '%' . $query . '%')
+                    ->orWhere('description', 'LIKE', '%' . $query . '%');
+            })
+            ->orderBy('code')
+            ->limit(20)
+            ->get(['code', 'description']);
+
+        return response()->json($codes);
+    }
 }

+ 51 - 1
app/Http/Controllers/ReclamationController.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use App\Http\Requests\CreateReclamationRequest;
 use App\Http\Requests\StoreReclamationDetailsRequest;
 use App\Http\Requests\StoreReclamationRequest;
+use App\Http\Requests\StoreReclamationSparePartsRequest;
 use App\Jobs\GenerateFilesPack;
 use App\Jobs\GenerateReclamationPack;
 use App\Models\File;
@@ -97,7 +98,6 @@ class ReclamationController extends Controller
         $this->data['brigadiers'] = User::query()->where('role', Role::BRIGADIER)->get()->pluck('name', 'id');
         $this->data['reclamation'] = $reclamation;
         $this->data['previous_url'] = $request->get('previous_url');
-        $this->data['spare_parts'] = \App\Models\SparePart::orderBy('article')->get();
         return view('reclamations.edit', $this->data);
     }
 
@@ -258,6 +258,56 @@ class ReclamationController extends Controller
         return redirect()->route('reclamations.show', ['reclamation' => $reclamation, 'previous_url' => $request->get('previous_url')]);
     }
 
+    public function updateSpareParts(StoreReclamationSparePartsRequest $request, Reclamation $reclamation)
+    {
+        $rows = $request->validated('rows') ?? [];
+
+        $inventoryService = app(\App\Services\SparePartInventoryService::class);
+
+        // Получаем текущие привязки для сравнения
+        $currentSpareParts = $reclamation->spareParts->keyBy('id');
+
+        // Собираем новые привязки
+        $newSpareParts = [];
+
+        foreach ($rows as $row) {
+            $sparePartId = $row['spare_part_id'] ?? null;
+            if (empty($sparePartId)) continue;
+
+            $quantity = (int)($row['quantity'] ?? 0);
+            if ($quantity < 1) continue;
+
+            $withDocs = !empty($row['with_documents']) && $row['with_documents'] != '0';
+
+            // Проверяем, изменилось ли количество
+            $currentQty = $currentSpareParts->get($sparePartId)?->pivot->quantity ?? 0;
+            $diff = $quantity - $currentQty;
+
+            if ($diff > 0) {
+                // Нужно списать дополнительное количество
+                $sparePart = \App\Models\SparePart::find($sparePartId);
+                if ($sparePart) {
+                    $inventoryService->deductForReclamation(
+                        $sparePart->article,
+                        $diff,
+                        $withDocs,
+                        $reclamation->id
+                    );
+                }
+            }
+
+            $newSpareParts[$sparePartId] = [
+                'quantity' => $quantity,
+                'with_documents' => $withDocs,
+            ];
+        }
+
+        // Синхронизируем (заменяем все старые привязки новыми)
+        $reclamation->spareParts()->sync($newSpareParts);
+
+        return redirect()->route('reclamations.show', ['reclamation' => $reclamation, 'previous_url' => $request->get('previous_url')]);
+    }
+
     public function generateReclamationPack(Request $request, Reclamation $reclamation)
     {
         GenerateReclamationPack::dispatch($reclamation, auth()->user()->id);

+ 19 - 0
app/Http/Controllers/SparePartController.php

@@ -195,6 +195,25 @@ class SparePartController extends Controller
             ->with(['error' => 'Ошибка загрузки изображения!']);
     }
 
+    /**
+     * API метод для поиска запчастей (autocomplete)
+     */
+    public function search(Request $request)
+    {
+        $query = $request->get('query', '');
+
+        $spareParts = SparePart::query()
+            ->where(function ($q) use ($query) {
+                $q->where('article', 'LIKE', '%' . $query . '%')
+                    ->orWhere('used_in_maf', 'LIKE', '%' . $query . '%');
+            })
+            ->orderBy('article')
+            ->limit(20)
+            ->get(['id', 'article', 'used_in_maf']);
+
+        return response()->json($spareParts);
+    }
+
     /**
      * Создание range фильтров для полей с ценами
      * Использует правильные заголовки из header (_txt версии)

+ 32 - 0
app/Http/Requests/StoreReclamationSparePartsRequest.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Requests;
+
+use App\Models\Role;
+use Illuminate\Foundation\Http\FormRequest;
+
+class StoreReclamationSparePartsRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     */
+    public function authorize(): bool
+    {
+        return in_array(auth()->user()?->role, [Role::ADMIN, Role::MANAGER]);
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules(): array
+    {
+        return [
+            'rows' => 'nullable|array',
+            'rows.*.spare_part_id' => 'nullable|integer|exists:spare_parts,id',
+            'rows.*.quantity' => 'nullable|integer|min:0',
+            'rows.*.with_documents' => 'nullable',
+        ];
+    }
+}

+ 22 - 14
app/Http/Requests/StoreSparePartRequest.php

@@ -42,22 +42,30 @@ class StoreSparePartRequest extends FormRequest
      */
     protected function passedValidation()
     {
-        // Сохраняем расшифровку № по ТСН в справочник, если указана
-        if ($this->filled('tsn_number') && $this->filled('tsn_number_description')) {
-            \App\Models\PricingCode::createOrUpdate(
-                \App\Models\PricingCode::TYPE_TSN_NUMBER,
-                $this->input('tsn_number'),
-                $this->input('tsn_number_description')
-            );
+        // Сохраняем/обновляем расшифровку № по ТСН в справочник
+        if ($this->filled('tsn_number')) {
+            $description = $this->input('tsn_number_description');
+            // Сохраняем только если есть расшифровка
+            if ($description) {
+                \App\Models\PricingCode::createOrUpdate(
+                    \App\Models\PricingCode::TYPE_TSN_NUMBER,
+                    $this->input('tsn_number'),
+                    $description
+                );
+            }
         }
 
-        // Сохраняем расшифровку шифра расценки в справочник, если указана
-        if ($this->filled('pricing_code') && $this->filled('pricing_code_description')) {
-            \App\Models\PricingCode::createOrUpdate(
-                \App\Models\PricingCode::TYPE_PRICING_CODE,
-                $this->input('pricing_code'),
-                $this->input('pricing_code_description')
-            );
+        // Сохраняем/обновляем расшифровку шифра расценки в справочник
+        if ($this->filled('pricing_code')) {
+            $description = $this->input('pricing_code_description');
+            // Сохраняем только если есть расшифровка
+            if ($description) {
+                \App\Models\PricingCode::createOrUpdate(
+                    \App\Models\PricingCode::TYPE_PRICING_CODE,
+                    $this->input('pricing_code'),
+                    $description
+                );
+            }
         }
     }
 }

+ 11 - 9
app/Models/SparePart.php

@@ -127,29 +127,31 @@ class SparePart extends Model
     }
 
     // ВЫЧИСЛЯЕМЫЕ ПОЛЯ (с учетом текущего года!)
+    // Примечание: YearScope автоматически применяется к SparePartOrder,
+    // поэтому явный фильтр по году НЕ нужен
     public function getQuantityWithoutDocsAttribute(): int
     {
-        // Убираем фильтр по положительному остатку, чтобы учитывать недостачу
-        return $this->orders()
-            ->where('year', year())  // КРИТИЧНО! Учитываем текущий год из сессии
+        return (int) ($this->orders()
             ->where('status', SparePartOrder::STATUS_IN_STOCK)
             ->where('with_documents', false)
-            ->sum('remaining_quantity') ?? 0;
+            ->sum('remaining_quantity') ?? 0);
     }
 
     public function getQuantityWithDocsAttribute(): int
     {
-        // Убираем фильтр по положительному остатку, чтобы учитывать недостачу
-        return $this->orders()
-            ->where('year', year())  // КРИТИЧНО! Учитываем текущий год из сессии
+        return (int) ($this->orders()
             ->where('status', SparePartOrder::STATUS_IN_STOCK)
             ->where('with_documents', true)
-            ->sum('remaining_quantity') ?? 0;
+            ->sum('remaining_quantity') ?? 0);
     }
 
     public function getTotalQuantityAttribute(): int
     {
-        return $this->quantity_without_docs + $this->quantity_with_docs;
+        // Общее количество — сумма всех остатков на складе за текущий год,
+        // независимо от наличия документов
+        return (int) ($this->orders()
+            ->where('status', SparePartOrder::STATUS_IN_STOCK)
+            ->sum('remaining_quantity') ?? 0);
     }
 
     // Методы проверки

+ 45 - 38
resources/views/layouts/app.blade.php

@@ -124,54 +124,61 @@
     @include('partials.customAlert')
 
     <script>
+        // Глобальные настройки (без jQuery)
+        var user = {{ auth()->user()?->id ?? 0}};
+        var socketAddress = '{{ config('app.ws_addr') }}';
+        localStorage.setItem('user', user);
+        localStorage.setItem('socketAddress', socketAddress);
+
         // Функция для замены стандартного alert
         function customAlert(message) {
-            $('#customAlertModalBody').text(message); // Устанавливаем текст сообщения
-            let myModal = new bootstrap.Modal(document.getElementById('customAlertModal'), {});
+            document.getElementById('customAlertModalBody').textContent = message;
+            var myModal = new bootstrap.Modal(document.getElementById('customAlertModal'), {});
             myModal.show();
         }
-    </script>
 
-    @stack('scripts')
+        // Ждём загрузки jQuery через Vite
+        function waitForJQuery(callback) {
+            if (typeof $ !== 'undefined') {
+                callback();
+            } else {
+                setTimeout(function() { waitForJQuery(callback); }, 50);
+            }
+        }
 
-    <script type="module">
-        let user = {{ auth()->user()?->id ?? 0}};
-        let socketAddress = '{{ config('app.ws_addr') }}';
-        localStorage.setItem('user', user);
-        localStorage.setItem('socketAddress', socketAddress);
+        waitForJQuery(function() {
+            $(document).ready(function () {
+                // Поиск МАФ
+                var selectMaf = $('#select_maf');
+                $('#search_maf').on('keyup', function () {
+                    $.get('{{ route('product.search') }}?s=' + $(this).val(),
+                        function (data) {
+                            selectMaf.children().remove()
+                            $.each(data, function (id, name) {
+                                selectMaf.append('<option value=\'' + id + '\'>' + name + '</option>');
+                            });
+                        }
+                    );
+                });
 
-        let selectMaf = $('#select_maf');
-        $('#search_maf').on('keyup', function () {
-            // search products on backend
-            $.get('{{ route('product.search') }}?s=' + $(this).val(),
-                function (data) {
-                    selectMaf.children().remove()
-                    $.each(data, function (id, name) {
-                        selectMaf.append('<option value=\'' + id + '\'>' + name + '</option>');
+                // Keep token alive
+                setInterval(keepTokenAlive, 1000 * 60 * 15); // every 15 mins
+
+                function keepTokenAlive() {
+                    $.ajax({
+                        url: '/keep-token-alive',
+                        method: 'post',
+                        headers: {
+                            'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
+                        }
+                    }).then(function () {
+                        console.log('Updated csrf token');
                     });
                 }
-            );
+            });
         });
-
-        $(document).ready(function () {
-
-            setInterval(keepTokenAlive, 1000 * 60 * 15); // every 15 mins
-
-            function keepTokenAlive() {
-                $.ajax({
-                    url: '/keep-token-alive',
-                    method: 'post',
-                    headers: {
-                        'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
-                    }
-                }).then(function () {
-                    console.log('Updated csrf token');
-                });
-            }
-
-        });
-
-
     </script>
+
+    @stack('scripts')
 </body>
 </html>

+ 290 - 30
resources/views/reclamations/edit.blade.php

@@ -96,49 +96,113 @@
                         </tbody>
                     </table>
                 </div>
-                <div class="details">
-                    <a href="#details" data-bs-toggle="collapse">Детали замен ({{ $reclamation->details->count() }})</a>
-                    <form method="post" action="{{ route('reclamations.update-details', $reclamation) }}"
-                          class=" my-2 collapse" id="details">
+                <div class="spare-parts">
+                    <a href="#spare_parts" data-bs-toggle="collapse">Запчасти ({{ $reclamation->spareParts->count() }})</a>
+                    <form method="post" action="{{ route('reclamations.update-spare-parts', $reclamation) }}"
+                          class="my-2 collapse" id="spare_parts">
                         @csrf
-                        <div class="rows">
-                            @foreach($reclamation->details as $detail)
-                                <div class="row mb-1">
-                                    <div class="col-10">
-                                        <input type="text" name="name[]" value="{{ $detail->name }}"
-                                               class="form-control form-control-sm col-auto"
-                                               @disabled(!hasRole('admin,manager'))
-                                               placeholder="наименование или номер детали">
+                        <div class="spare-parts-rows">
+                            @forelse($reclamation->spareParts as $idx => $sp)
+                                <div class="row mb-1 spare-part-row" data-index="{{ $idx }}">
+                                    <div class="col-6 position-relative">
+                                        <input type="hidden" name="rows[{{ $idx }}][spare_part_id]" value="{{ $sp->id }}" class="spare-part-id">
+                                        <input type="text" class="form-control form-control-sm spare-part-search"
+                                               value="{{ $sp->article }}@if($sp->used_in_maf) ({{ $sp->used_in_maf }})@endif"
+                                               placeholder="Введите артикул или название"
+                                               autocomplete="off"
+                                               @disabled(!hasRole('admin,manager'))>
+                                        <div class="spare-part-dropdown"></div>
                                     </div>
                                     <div class="col-2">
-                                        <input type="text" name="quantity[]" value="{{ $detail->quantity }}"
-                                               class="form-control form-control-sm col-auto text-end"
+                                        <input type="number" name="rows[{{ $idx }}][quantity]" value="{{ $sp->pivot->quantity }}"
+                                               min="0" class="form-control form-control-sm text-end"
                                                @disabled(!hasRole('admin,manager'))
                                                placeholder="кол-во">
                                     </div>
+                                    <div class="col-3">
+                                        <div class="form-check form-check-inline mt-1">
+                                            <input type="hidden" name="rows[{{ $idx }}][with_documents]" value="0">
+                                            <input type="checkbox" name="rows[{{ $idx }}][with_documents]" value="1"
+                                                   class="form-check-input" id="with_docs_{{ $idx }}"
+                                                   @checked($sp->pivot->with_documents)
+                                                   @disabled(!hasRole('admin,manager'))>
+                                            <label class="form-check-label small" for="with_docs_{{ $idx }}">с док.</label>
+                                        </div>
+                                    </div>
+                                    <div class="col-1">
+                                        <span class="btn btn-sm text-danger remove-spare-part-row" title="Удалить строку">
+                                            <i class="bi bi-x-circle"></i>
+                                        </span>
+                                    </div>
                                 </div>
-                            @endforeach
-                            <div class="row mb-1">
-                                <div class="col-10">
-                                    <input type="text" name="name[]" value=""
-                                           class="form-control form-control-sm col-auto"
-                                           @disabled(!hasRole('admin,manager'))
-                                           placeholder="наименование или номер детали">
+                            @empty
+                                <div class="row mb-1 spare-part-row" data-index="0">
+                                    <div class="col-6 position-relative">
+                                        <input type="hidden" name="rows[0][spare_part_id]" value="" class="spare-part-id">
+                                        <input type="text" class="form-control form-control-sm spare-part-search"
+                                               value=""
+                                               placeholder="Введите артикул или название"
+                                               autocomplete="off"
+                                               @disabled(!hasRole('admin,manager'))>
+                                        <div class="spare-part-dropdown"></div>
+                                    </div>
+                                    <div class="col-2">
+                                        <input type="number" name="rows[0][quantity]" value=""
+                                               min="0" class="form-control form-control-sm text-end"
+                                               @disabled(!hasRole('admin,manager'))
+                                               placeholder="кол-во">
+                                    </div>
+                                    <div class="col-3">
+                                        <div class="form-check form-check-inline mt-1">
+                                            <input type="hidden" name="rows[0][with_documents]" value="0">
+                                            <input type="checkbox" name="rows[0][with_documents]" value="1"
+                                                   class="form-check-input" id="with_docs_0"
+                                                   @disabled(!hasRole('admin,manager'))>
+                                            <label class="form-check-label small" for="with_docs_0">с док.</label>
+                                        </div>
+                                    </div>
+                                    <div class="col-1">
+                                        <span class="btn btn-sm text-danger remove-spare-part-row" title="Удалить строку">
+                                            <i class="bi bi-x-circle"></i>
+                                        </span>
+                                    </div>
                                 </div>
-                                <div class="col-2">
-                                    <input type="text" name="quantity[]" value=""
-                                           @disabled(!hasRole('admin,manager'))
-                                           class="form-control form-control-sm col-auto text-end" placeholder="кол-во">
+                            @endforelse
+                        </div>
+                        <div class="row mb-1 spare-part-row spare-part-template d-none" data-index="__INDEX__">
+                            <div class="col-6 position-relative">
+                                <input type="hidden" name="rows[__INDEX__][spare_part_id]" value="" class="spare-part-id">
+                                <input type="text" class="form-control form-control-sm spare-part-search"
+                                       value=""
+                                       placeholder="Введите артикул или название"
+                                       autocomplete="off"
+                                       disabled>
+                                <div class="spare-part-dropdown"></div>
+                            </div>
+                            <div class="col-2">
+                                <input type="number" name="rows[__INDEX__][quantity]" value=""
+                                       min="0" class="form-control form-control-sm text-end" disabled
+                                       placeholder="кол-во">
+                            </div>
+                            <div class="col-3">
+                                <div class="form-check form-check-inline mt-1">
+                                    <input type="hidden" name="rows[__INDEX__][with_documents]" value="0">
+                                    <input type="checkbox" name="rows[__INDEX__][with_documents]" value="1"
+                                           class="form-check-input" disabled>
+                                    <label class="form-check-label small">с док.</label>
                                 </div>
                             </div>
+                            <div class="col-1">
+                                <span class="btn btn-sm text-danger remove-spare-part-row" title="Удалить строку">
+                                    <i class="bi bi-x-circle"></i>
+                                </span>
+                            </div>
                         </div>
                         <div class="row">
                             <div class="col-12 text-end">
-                                <span class="text-secondary small">Для удаления детали поставьте в количество 0</span>
-                                <span class="btn btn-light btn-sm cursor-pointer"
-                                      @disabled(!hasRole('admin,manager'))
-                                      onclick="$('.rows').children().last().clone().appendTo('.rows');">Добавить строку</span>
-                                <button class="btn btn-primary btn-sm" type="submit" @disabled(!hasRole('admin,manager'))>Сохранить детали</button>
+                                <span class="btn btn-light btn-sm cursor-pointer" id="add-spare-part-row"
+                                      @disabled(!hasRole('admin,manager'))>Добавить строку</span>
+                                <button class="btn btn-primary btn-sm" type="submit" @disabled(!hasRole('admin,manager'))>Сохранить запчасти</button>
                             </div>
                         </div>
                         @if($errors->any())
@@ -329,11 +393,207 @@
     </div>
 @endsection
 
+@push('styles')
+<style>
+.spare-part-dropdown {
+    position: absolute;
+    top: 100%;
+    left: 0;
+    right: 0;
+    z-index: 1050;
+    background: white;
+    border: 1px solid #dee2e6;
+    border-top: none;
+    border-radius: 0 0 .375rem .375rem;
+    max-height: 250px;
+    overflow-y: auto;
+    display: none;
+    box-shadow: 0 4px 6px rgba(0,0,0,.1);
+}
+.spare-part-dropdown .sp-item {
+    padding: 8px 12px;
+    cursor: pointer;
+    border-bottom: 1px solid #f0f0f0;
+}
+.spare-part-dropdown .sp-item:last-child {
+    border-bottom: none;
+}
+.spare-part-dropdown .sp-item:hover,
+.spare-part-dropdown .sp-item.active {
+    background-color: #e9ecef;
+}
+.spare-part-dropdown .sp-item .sp-article {
+    font-weight: 600;
+    color: #495057;
+}
+.spare-part-dropdown .sp-item .sp-used {
+    font-size: 0.875em;
+    color: #6c757d;
+}
+</style>
+@endpush
+
 @push('scripts')
     <script type="module">
         $('#createScheduleButton').on('click', function () {
             let myModalSchedule = new bootstrap.Modal(document.getElementById("copySchedule"), {});
             myModalSchedule.show();
         });
+
+        let sparePartIndex = {{ max($reclamation->spareParts->count(), 1) }};
+        const searchUrl = '{{ route('spare_parts.search') }}';
+
+        // Инициализация автокомплита для поля
+        function initSparePartAutocomplete($row) {
+            const $input = $row.find('.spare-part-search');
+            const $dropdown = $row.find('.spare-part-dropdown');
+            const $hiddenId = $row.find('.spare-part-id');
+            let currentFocus = -1;
+            let searchTimeout;
+
+            $input.on('input', function() {
+                const query = $(this).val().trim();
+                clearTimeout(searchTimeout);
+
+                // Сбрасываем ID при изменении текста
+                $hiddenId.val('');
+
+                if (query.length >= 1) {
+                    searchTimeout = setTimeout(function() {
+                        $.get(searchUrl, { query: query }).done(function(data) {
+                            showDropdown(data, query);
+                        });
+                    }, 200);
+                } else {
+                    $dropdown.hide();
+                }
+            });
+
+            function showDropdown(items, query) {
+                $dropdown.empty();
+                currentFocus = -1;
+
+                if (items.length === 0) {
+                    $dropdown.hide();
+                    return;
+                }
+
+                items.forEach(function(item) {
+                    const $item = $('<div class="sp-item"></div>');
+                    const highlightedArticle = highlightMatch(item.article, query);
+                    const highlightedUsed = item.used_in_maf ? highlightMatch(item.used_in_maf, query) : '';
+
+                    $item.html('<span class="sp-article">' + highlightedArticle + '</span>' +
+                               (item.used_in_maf ? ' <span class="sp-used">(' + highlightedUsed + ')</span>' : ''));
+                    $item.data('id', item.id);
+                    $item.data('article', item.article);
+                    $item.data('used', item.used_in_maf || '');
+
+                    $item.on('click', function() {
+                        selectItem($(this));
+                    });
+
+                    $dropdown.append($item);
+                });
+
+                $dropdown.show();
+            }
+
+            function highlightMatch(text, query) {
+                if (!text || !query) return text || '';
+                const regex = new RegExp('(' + escapeRegex(query) + ')', 'gi');
+                return text.replace(regex, '<strong>$1</strong>');
+            }
+
+            function escapeRegex(str) {
+                return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+            }
+
+            function selectItem($item) {
+                const article = $item.data('article');
+                const used = $item.data('used');
+                const displayText = article + (used ? ' (' + used + ')' : '');
+
+                $input.val(displayText);
+                $hiddenId.val($item.data('id'));
+                $dropdown.hide();
+            }
+
+            // Навигация клавиатурой
+            $input.on('keydown', function(e) {
+                const $items = $dropdown.find('.sp-item');
+
+                if (e.keyCode === 40) { // Стрелка вниз
+                    e.preventDefault();
+                    currentFocus++;
+                    if (currentFocus >= $items.length) currentFocus = 0;
+                    setActive($items);
+                } else if (e.keyCode === 38) { // Стрелка вверх
+                    e.preventDefault();
+                    currentFocus--;
+                    if (currentFocus < 0) currentFocus = $items.length - 1;
+                    setActive($items);
+                } else if (e.keyCode === 13) { // Enter
+                    e.preventDefault();
+                    if (currentFocus > -1 && $items.length > 0) {
+                        selectItem($items.eq(currentFocus));
+                    }
+                } else if (e.keyCode === 27) { // Escape
+                    $dropdown.hide();
+                }
+            });
+
+            function setActive($items) {
+                $items.removeClass('active');
+                if (currentFocus >= 0 && currentFocus < $items.length) {
+                    $items.eq(currentFocus).addClass('active');
+                }
+            }
+
+            // Закрытие при клике вне
+            $(document).on('click', function(e) {
+                if (!$(e.target).closest($row).length) {
+                    $dropdown.hide();
+                }
+            });
+        }
+
+        // Инициализация для существующих строк
+        $('.spare-part-row').not('.spare-part-template').each(function() {
+            initSparePartAutocomplete($(this));
+        });
+
+        // Добавление новой строки запчасти
+        $('#add-spare-part-row').on('click', function() {
+            let template = $('.spare-part-template').first();
+            let newRow = template.clone();
+            newRow.removeClass('spare-part-template d-none');
+            newRow.attr('data-index', sparePartIndex);
+
+            // Заменяем __INDEX__ на реальный индекс во всех name атрибутах
+            newRow.find('[name]').each(function() {
+                let name = $(this).attr('name');
+                $(this).attr('name', name.replace('__INDEX__', sparePartIndex));
+                $(this).prop('disabled', false);
+            });
+
+            // Сбрасываем значения
+            newRow.find('.spare-part-search').val('');
+            newRow.find('.spare-part-id').val('');
+            newRow.find('input[type="number"]').val('');
+            newRow.find('input[type="checkbox"]').prop('checked', false);
+
+            $('.spare-parts-rows').append(newRow);
+
+            // Инициализируем автокомплит для новой строки
+            initSparePartAutocomplete(newRow);
+
+            sparePartIndex++;
+        });
+
+        // Удаление строки
+        $(document).on('click', '.remove-spare-part-row', function() {
+            $(this).closest('.spare-part-row').remove();
+        });
     </script>
 @endpush

+ 282 - 86
resources/views/spare_parts/edit.blade.php

@@ -4,21 +4,21 @@
 <div class="container-fluid">
     <div class="row">
         <div class="col-12">
-            <h2>{{ $spare_part ? 'Редактирование запчасти' : 'Создание запчасти' }}</h2>
-
-            @if($spare_part && hasRole('admin'))
-                <div class="mb-3">
-                    <label class="form-label">Загрузить изображение</label>
-                    <form action="{{ route('spare_parts.upload_image', $spare_part) }}"
-                          method="POST"
-                          enctype="multipart/form-data"
-                          id="imageUploadForm">
-                        @csrf
-                        <input type="file" name="image" class="form-control" accept="image/*">
-                        <button type="submit" class="btn btn-sm btn-primary mt-2">Загрузить</button>
-                    </form>
+            <div class="row mb-2">
+                <div class="col-6">
+                    <h2>{{ $spare_part ? 'Редактирование запчасти' : 'Создание запчасти' }}</h2>
                 </div>
-            @endif
+                <div class="col-6 text-end">
+                    @if($spare_part && hasRole('admin'))
+                        <button class="btn btn-sm text-success" onclick="$('#upl-image').trigger('click');"><i class="bi bi-plus-circle-fill"></i> Загрузить изображение</button>
+
+                        <form action="{{ route('spare_parts.upload_image', ['sparePart' => $spare_part]) }}" class="visually-hidden" method="POST" enctype="multipart/form-data">
+                            @csrf
+                            <input type="file" name="image" onchange="$(this).parent().submit()" required id="upl-image" accept="image/*" />
+                        </form>
+                    @endif
+                </div>
+            </div>
 
             <form action="{{ $spare_part ? route('spare_parts.update', $spare_part) : route('spare_parts.store') }}"
                   method="POST">
@@ -107,48 +107,54 @@
 
                         <div class="mb-3">
                             <label for="tsn_number" class="form-label">№ по ТСН</label>
-                            <input type="text"
-                                   class="form-control"
-                                   id="tsn_number"
-                                   name="tsn_number"
-                                   value="{{ old('tsn_number', $spare_part->tsn_number ?? '') }}"
-                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                            <div class="row">
+                                <div class="col-md-5 position-relative">
+                                    <input type="text"
+                                           class="form-control"
+                                           id="tsn_number"
+                                           name="tsn_number"
+                                           value="{{ old('tsn_number', $spare_part->tsn_number ?? '') }}"
+                                           autocomplete="off"
+                                           {{ hasRole('admin') ? '' : 'readonly' }}>
+                                    <div class="autocomplete-dropdown" id="tsn_number_dropdown"></div>
+                                </div>
+                                <div class="col-md-7">
+                                    <input type="text"
+                                           class="form-control"
+                                           id="tsn_number_description"
+                                           name="tsn_number_description"
+                                           placeholder="Расшифровка"
+                                           {{ hasRole('admin') ? '' : 'readonly' }}>
+                                </div>
+                            </div>
                             <div class="form-text" id="tsn_number_hint"></div>
                         </div>
 
-                        <div class="mb-3" id="tsn_description_block" style="display: none;">
-                            <label for="tsn_number_description" class="form-label">Расшифровка № по ТСН</label>
-                            <input type="text"
-                                   class="form-control"
-                                   id="tsn_number_description"
-                                   name="tsn_number_description"
-                                   placeholder="Введите расшифровку для нового номера"
-                                   {{ hasRole('admin') ? '' : 'readonly' }}>
-                            <div class="form-text text-muted">Расшифровка будет сохранена в справочник при сохранении запчасти</div>
-                        </div>
-
                         <div class="mb-3">
                             <label for="pricing_code" class="form-label">Шифр расценки</label>
-                            <input type="text"
-                                   class="form-control"
-                                   id="pricing_code"
-                                   name="pricing_code"
-                                   value="{{ old('pricing_code', $spare_part->pricing_code ?? '') }}"
-                                   {{ hasRole('admin') ? '' : 'readonly' }}>
+                            <div class="row">
+                                <div class="col-md-5 position-relative">
+                                    <input type="text"
+                                           class="form-control"
+                                           id="pricing_code"
+                                           name="pricing_code"
+                                           value="{{ old('pricing_code', $spare_part->pricing_code ?? '') }}"
+                                           autocomplete="off"
+                                           {{ hasRole('admin') ? '' : 'readonly' }}>
+                                    <div class="autocomplete-dropdown" id="pricing_code_dropdown"></div>
+                                </div>
+                                <div class="col-md-7">
+                                    <input type="text"
+                                           class="form-control"
+                                           id="pricing_code_description"
+                                           name="pricing_code_description"
+                                           placeholder="Расшифровка"
+                                           {{ hasRole('admin') ? '' : 'readonly' }}>
+                                </div>
+                            </div>
                             <div class="form-text" id="pricing_code_hint"></div>
                         </div>
 
-                        <div class="mb-3" id="pricing_code_description_block" style="display: none;">
-                            <label for="pricing_code_description" class="form-label">Расшифровка шифра расценки</label>
-                            <input type="text"
-                                   class="form-control"
-                                   id="pricing_code_description"
-                                   name="pricing_code_description"
-                                   placeholder="Введите расшифровку для нового шифра"
-                                   {{ hasRole('admin') ? '' : 'readonly' }}>
-                            <div class="form-text text-muted">Расшифровка будет сохранена в справочник при сохранении запчасти</div>
-                        </div>
-
                         <div class="mb-3">
                             <label for="min_stock" class="form-label">Минимальный остаток</label>
                             <input type="number"
@@ -206,59 +212,249 @@
 @endif
 
 @push('scripts')
+<style>
+.autocomplete-dropdown {
+    position: absolute;
+    top: 100%;
+    left: 0;
+    right: 0;
+    z-index: 1050;
+    background: white;
+    border: 1px solid #dee2e6;
+    border-top: none;
+    border-radius: 0 0 .375rem .375rem;
+    max-height: 250px;
+    overflow-y: auto;
+    display: none;
+    box-shadow: 0 4px 6px rgba(0,0,0,.1);
+}
+.autocomplete-dropdown .autocomplete-item {
+    padding: 8px 12px;
+    cursor: pointer;
+    border-bottom: 1px solid #f0f0f0;
+}
+.autocomplete-dropdown .autocomplete-item:last-child {
+    border-bottom: none;
+}
+.autocomplete-dropdown .autocomplete-item:hover,
+.autocomplete-dropdown .autocomplete-item.active {
+    background-color: #e9ecef;
+}
+.autocomplete-dropdown .autocomplete-item .code {
+    font-weight: 600;
+    color: #495057;
+}
+.autocomplete-dropdown .autocomplete-item .description {
+    font-size: 0.875em;
+    color: #6c757d;
+}
+</style>
 <script>
-$(document).ready(function() {
+function waitForJQuery(callback) {
+    if (typeof $ !== 'undefined') {
+        callback();
+    } else {
+        setTimeout(function() { waitForJQuery(callback); }, 50);
+    }
+}
+
+waitForJQuery(function() {
+    $(document).ready(function() {
     @if(hasRole('admin'))
-        // Автозаполнение для № по ТСН
-        $('#tsn_number').on('input', function() {
-            const code = $(this).val().trim();
-            if (code.length > 0) {
-                $.get('{{ route('pricing_codes.get_description') }}', {
-                    type: 'tsn_number',
-                    code: code
-                }, function(data) {
-                    if (data.description) {
-                        $('#tsn_number_hint').html('<i class="bi bi-info-circle text-success"></i> ' + data.description).removeClass('text-danger').addClass('text-success');
-                        $('#tsn_description_block').hide();
-                        $('#tsn_number_description').val('');
-                    } else {
-                        $('#tsn_number_hint').html('<i class="bi bi-exclamation-triangle text-warning"></i> Расшифровка не найдена').removeClass('text-success').addClass('text-warning');
-                        $('#tsn_description_block').show();
-                    }
+        //console.log('Autocomplete init started');
+
+        // Общая функция для инициализации автокомплита
+        function initAutocomplete(inputId, dropdownId, descriptionId, hintId, type) {
+            const $input = $('#' + inputId);
+            const $dropdown = $('#' + dropdownId);
+            const $description = $('#' + descriptionId);
+            const $hint = $('#' + hintId);
+            let currentFocus = -1;
+            let searchTimeout;
+
+            //console.log('Init autocomplete for:', inputId, $input.length);
+
+            // Обработка ввода
+            $input.on('input', function() {
+                const query = $(this).val().trim();
+                clearTimeout(searchTimeout);
+                //console.log('Input event:', inputId, query);
+
+                if (query.length > 0) {
+                    searchTimeout = setTimeout(function() {
+                        // Поиск вариантов для автокомплита
+                        $.get('{{ route('pricing_codes.search') }}', {
+                            type: type,
+                            query: query
+                        }).done(function(data) {
+                            //console.log('Search result:', data);
+                            showDropdown(data, query);
+                        }).fail(function(xhr) {
+                            console.error('Search error:', xhr.status, xhr.responseText);
+                        });
+
+                        // Проверка точного соответствия для расшифровки
+                        $.get('{{ route('pricing_codes.get_description') }}', {
+                            type: type,
+                            code: query
+                        }).done(function(data) {
+                            // console.log('Description result:', data);
+                            if (data.description) {
+                                $description.val(data.description);
+                                $description.prop('readonly', true);
+                                $hint.html('<i class="bi bi-check-circle text-success"></i> Код найден в справочнике');
+                            } else {
+                                $description.val('');
+                                $description.prop('readonly', false);
+                                $hint.html('<i class="bi bi-plus-circle text-primary"></i> Новый код - заполните расшифровку');
+                            }
+                        }).fail(function(xhr) {
+                            console.error('Description error:', xhr.status, xhr.responseText);
+                        });
+                    }, 200);
+                } else {
+                    $dropdown.hide();
+                    $description.val('');
+                    $description.prop('readonly', false);
+                    $hint.text('');
+                }
+            });
+
+            // Показать выпадающий список
+            function showDropdown(items, query) {
+                $dropdown.empty();
+                currentFocus = -1;
+
+                if (items.length === 0) {
+                    $dropdown.hide();
+                    return;
+                }
+
+                items.forEach(function(item, index) {
+                    const $item = $('<div class="autocomplete-item"></div>');
+                    const highlightedCode = highlightMatch(item.code, query);
+                    const highlightedDesc = item.description ? highlightMatch(item.description, query) : '';
+
+                    $item.html('<div class="code">' + highlightedCode + '</div>' +
+                               (item.description ? '<div class="description">' + highlightedDesc + '</div>' : ''));
+                    $item.data('code', item.code);
+                    $item.data('description', item.description || '');
+
+                    $item.on('click', function() {
+                        selectItem($(this));
+                    });
+
+                    $dropdown.append($item);
                 });
-            } else {
-                $('#tsn_number_hint').text('');
-                $('#tsn_description_block').hide();
+
+                $dropdown.show();
             }
-        });
 
-        // Автозаполнение для шифра расценки
-        $('#pricing_code').on('input', function() {
-            const code = $(this).val().trim();
+            // Подсветка совпадения
+            function highlightMatch(text, query) {
+                if (!text || !query) return text || '';
+                const regex = new RegExp('(' + escapeRegex(query) + ')', 'gi');
+                return text.replace(regex, '<strong>$1</strong>');
+            }
+
+            function escapeRegex(str) {
+                return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+            }
+
+            // Выбор элемента
+            function selectItem($item) {
+                $input.val($item.data('code'));
+                $description.val($item.data('description'));
+                $description.prop('readonly', true);
+                $hint.html('<i class="bi bi-check-circle text-success"></i> Код найден в справочнике');
+                $dropdown.hide();
+            }
+
+            // Навигация клавиатурой
+            $input.on('keydown', function(e) {
+                const $items = $dropdown.find('.autocomplete-item');
+
+                if (e.keyCode === 40) { // Стрелка вниз
+                    e.preventDefault();
+                    currentFocus++;
+                    if (currentFocus >= $items.length) currentFocus = 0;
+                    setActive($items);
+                } else if (e.keyCode === 38) { // Стрелка вверх
+                    e.preventDefault();
+                    currentFocus--;
+                    if (currentFocus < 0) currentFocus = $items.length - 1;
+                    setActive($items);
+                } else if (e.keyCode === 13) { // Enter
+                    e.preventDefault();
+                    if (currentFocus > -1 && $items.length > 0) {
+                        selectItem($items.eq(currentFocus));
+                    }
+                } else if (e.keyCode === 27) { // Escape
+                    $dropdown.hide();
+                }
+            });
+
+            function setActive($items) {
+                $items.removeClass('active');
+                if (currentFocus >= 0 && currentFocus < $items.length) {
+                    $items.eq(currentFocus).addClass('active');
+                }
+            }
+
+            // Закрытие при клике вне
+            $(document).on('click', function(e) {
+                if (!$(e.target).closest('#' + inputId + ', #' + dropdownId).length) {
+                    $dropdown.hide();
+                }
+            });
+
+            // При фокусе показать список если есть значение
+            $input.on('focus', function() {
+                const query = $(this).val().trim();
+                if (query.length > 0) {
+                    $.get('{{ route('pricing_codes.search') }}', {
+                        type: type,
+                        query: query
+                    }, function(data) {
+                        showDropdown(data, query);
+                    });
+                }
+            });
+        }
+
+        // Инициализация автокомплитов
+        initAutocomplete('tsn_number', 'tsn_number_dropdown', 'tsn_number_description', 'tsn_number_hint', 'tsn_number');
+        initAutocomplete('pricing_code', 'pricing_code_dropdown', 'pricing_code_description', 'pricing_code_hint', 'pricing_code');
+
+        // Загрузка расшифровок при открытии страницы
+        function loadInitialDescription(inputId, descriptionId, hintId, type) {
+            const code = $('#' + inputId).val().trim();
+            //console.log('Load initial for:', inputId, 'code:', code);
             if (code.length > 0) {
                 $.get('{{ route('pricing_codes.get_description') }}', {
-                    type: 'pricing_code',
+                    type: type,
                     code: code
-                }, function(data) {
+                }).done(function(data) {
+                    //console.log('Initial description for', inputId, ':', data);
                     if (data.description) {
-                        $('#pricing_code_hint').html('<i class="bi bi-info-circle text-success"></i> ' + data.description).removeClass('text-danger').addClass('text-success');
-                        $('#pricing_code_description_block').hide();
-                        $('#pricing_code_description').val('');
+                        $('#' + descriptionId).val(data.description);
+                        $('#' + descriptionId).prop('readonly', true);
+                        $('#' + hintId).html('<i class="bi bi-check-circle text-success"></i> Код найден в справочнике');
                     } else {
-                        $('#pricing_code_hint').html('<i class="bi bi-exclamation-triangle text-warning"></i> Расшифровка не найдена').removeClass('text-success').addClass('text-warning');
-                        $('#pricing_code_description_block').show();
+                        $('#' + descriptionId).prop('readonly', false);
+                        $('#' + hintId).html('<i class="bi bi-plus-circle text-primary"></i> Новый код - заполните расшифровку');
                     }
+                }).fail(function(xhr) {
+                    console.error('Initial description error:', xhr.status, xhr.responseText);
                 });
-            } else {
-                $('#pricing_code_hint').text('');
-                $('#pricing_code_description_block').hide();
             }
-        });
+        }
 
-        // Триггер при загрузке страницы
-        $('#tsn_number').trigger('input');
-        $('#pricing_code').trigger('input');
+        loadInitialDescription('tsn_number', 'tsn_number_description', 'tsn_number_hint', 'tsn_number');
+        loadInitialDescription('pricing_code', 'pricing_code_description', 'pricing_code_hint', 'pricing_code');
+        //console.log('Autocomplete init complete');
     @endif
+    });
 });
 </script>
 @endpush

+ 7 - 4
routes/web.php

@@ -140,6 +140,7 @@ Route::middleware('auth:web')->group(function () {
         Route::post('reclamations/{reclamation}/upload-document', [ReclamationController::class, 'uploadDocument'])->name('reclamations.upload-document');
         Route::post('reclamations/{reclamation}/upload-act', [ReclamationController::class, 'uploadAct'])->name('reclamations.upload-act');
         Route::post('reclamations/{reclamation}/update-details', [ReclamationController::class, 'updateDetails'])->name('reclamations.update-details');
+        Route::post('reclamations/{reclamation}/update-spare-parts', [ReclamationController::class, 'updateSpareParts'])->name('reclamations.update-spare-parts');
         Route::delete('reclamations/{reclamation}', [ReclamationController::class, 'delete'])->name('reclamations.delete')->middleware('role:' . Role::ADMIN);
 
         Route::get('reports', [ReportController::class, 'index'])->name('reports.index');
@@ -233,6 +234,7 @@ Route::middleware('auth:web')->group(function () {
     // Каталог запчастей
     Route::prefix('spare-parts')->name('spare_parts.')->group(function () {
         Route::get('/', [SparePartController::class, 'index'])->name('index');
+        Route::get('/search', [SparePartController::class, 'search'])->name('search');
         Route::get('/create', [SparePartController::class, 'create'])->name('create')->middleware('role:admin');
         Route::get('/{sparePart}', [SparePartController::class, 'show'])->name('show');
         Route::post('/', [SparePartController::class, 'store'])->name('store')->middleware('role:admin');
@@ -258,7 +260,11 @@ Route::middleware('auth:web')->group(function () {
     // Контроль наличия
     Route::get('/spare-part-inventory', [SparePartInventoryController::class, 'index'])->name('spare_part_inventory.index');
 
-    // Справочник расшифровок
+    // API для получения расшифровки кодов (без ограничения по роли, должны быть ПЕРЕД группой с prefix)
+    Route::get('/pricing-codes/get-description', [PricingCodeController::class, 'getDescription'])->name('pricing_codes.get_description');
+    Route::get('/pricing-codes/search', [PricingCodeController::class, 'search'])->name('pricing_codes.search');
+
+    // Справочник расшифровок (admin only)
     Route::prefix('pricing-codes')->name('pricing_codes.')->middleware('role:admin')->group(function () {
         Route::get('/', [PricingCodeController::class, 'index'])->name('index');
         Route::post('/', [PricingCodeController::class, 'store'])->name('store');
@@ -266,7 +272,4 @@ Route::middleware('auth:web')->group(function () {
         Route::delete('/{pricingCode}', [PricingCodeController::class, 'destroy'])->name('destroy');
     });
 
-    // API для получения расшифровки кодов
-    Route::get('/pricing-codes/get-description', [PricingCodeController::class, 'getDescription'])->name('pricing_codes.get_description');
-
 });