]> gitweb.erp-flowers.ru Git - erp24_rep/yii-erp24/.git/commitdiff
fix producer
authorfomichev <vladimir.fomichev@erp-flowers.ru>
Wed, 22 Apr 2026 07:03:22 +0000 (10:03 +0300)
committerfomichev <vladimir.fomichev@erp-flowers.ru>
Wed, 22 Apr 2026 07:03:22 +0000 (10:03 +0300)
erp24/views/producer/index.php
erp24/web/js/budget/index.js
erp24/web/js/product-mapping/index.js

index 78bacc26a3acfb14400ef70aa3ab66a91d94a64d..1193f5669c749b585591e0f91a9920c20a558412 100644 (file)
@@ -104,10 +104,14 @@ $producers = $dataProvider->getModels();
                         <?= $plantation->getStatusBadge() ?>
                     </td>
                     <td style="text-align:center;white-space:nowrap;">
-                        <a href="#" class="btn-plantation-edit" data-id="<?= (int)$plantation->id ?>" title="Редактировать плантацию">
-                            <i class="fa fa-pencil text-secondary"></i>
-                        </a>
-                        <?php if ($plantation->is_active): ?>
+                        <?php if ($plantation->is_active && $producer->is_active): ?>
+                            <a href="#" class="btn-plantation-edit" data-id="<?= (int)$plantation->id ?>" title="Редактировать плантацию">
+                                <i class="fa fa-pencil text-secondary"></i>
+                            </a>
+                        <?php else: ?>
+                            <i class="fa fa-pencil text-secondary" style="opacity:0.3;cursor:default;" title="Недоступно для деактивированной записи"></i>
+                        <?php endif; ?>
+                        <?php if ($plantation->is_active && $producer->is_active): ?>
                             <a href="#" class="btn-plantation-delete ms-1"
                                data-id="<?= (int)$plantation->id ?>"
                                data-name="<?= Html::encode($plantation->name) ?>"
@@ -158,18 +162,41 @@ $deletePlantationUrl = Url::to(['/producer/delete-plantation']);
 
 $js = <<<JS
 (function() {
+    // Снимаем старые обработчики чтобы не накапливать их при каждом reloadProducerTab()
+    $(document).off('.producertab');
+
+    var _bsModal = null;
+    var shouldReload = false;
+    var editingProducerId = null;
+    var editingPlantationId = null;
+    var loaderHtml = '<div class="text-center p-4"><i class="fa fa-spinner fa-spin fa-2x text-secondary"></i></div>';
+
+    var modalEl = document.getElementById('producer-modal');
+
+    function getModal() {
+        if (!_bsModal) {
+            _bsModal = new bootstrap.Modal(modalEl);
+        }
+        return _bsModal;
+    }
+
+    if (modalEl) {
+        // Перезагружаем вкладку только после полного закрытия модала —
+        // иначе Bootstrap не успевает убрать backdrop до замены DOM.
+        modalEl.addEventListener('hidden.bs.modal', function() {
+            if (shouldReload) {
+                shouldReload = false;
+                reloadProducerTab();
+            }
+        });
+    }
+
     function reloadProducerTab() {
         $.get('{$reloadUrl}', function(html) {
-            // Заменяем содержимое вкладки целиком свежей копией (вместе с pjax, модалкой и JS).
             $('#tab-producers').html(html);
         });
     }
 
-    var producerModal = new bootstrap.Modal(document.getElementById('producer-modal'));
-    var editingProducerId = null;
-    var editingPlantationId = null;
-    var loaderHtml = '<div class="text-center p-4"><i class="fa fa-spinner fa-spin fa-2x text-secondary"></i></div>';
-
     // ========== PRODUCER ==========
 
     $('#btn-producer-create').on('click', function() {
@@ -177,9 +204,10 @@ $js = <<<JS
         if (\$btn.prop('disabled')) return;
         \$btn.prop('disabled', true);
         editingProducerId = null;
+        editingPlantationId = null;
         $('#producer-modal-title').text('Добавить производителя');
         $('#producer-modal-body').html(loaderHtml);
-        producerModal.show();
+        getModal().show();
         $.get('{$createProducerFormUrl}', function(html) {
             $('#producer-modal-body').html(html);
         }).always(function() {
@@ -187,15 +215,16 @@ $js = <<<JS
         });
     });
 
-    $(document).on('click', '.btn-producer-edit', function(e) {
+    $(document).on('click.producertab', '.btn-producer-edit', function(e) {
         e.preventDefault();
         var \$btn = $(this);
         if (\$btn.prop('disabled')) return;
         \$btn.prop('disabled', true);
         editingProducerId = \$btn.data('id');
+        editingPlantationId = null;
         $('#producer-modal-title').text('Редактировать производителя');
         $('#producer-modal-body').html(loaderHtml);
-        producerModal.show();
+        getModal().show();
         $.get('{$updateProducerFormUrl}', {id: editingProducerId}, function(html) {
             $('#producer-modal-body').html(html);
         }).always(function() {
@@ -203,7 +232,11 @@ $js = <<<JS
         });
     });
 
-    $(document).on('click', '#btn-producer-save', function() {
+    $(document).on('click.producertab', '#btn-producer-save', function() {
+        var \$saveBtn = $(this);
+        if (\$saveBtn.prop('disabled')) return;
+        \$saveBtn.prop('disabled', true);
+
         var \$form = $('#producer-form');
         var url = editingProducerId ? '{$updateProducerUrl}?id=' + editingProducerId : '{$createProducerUrl}';
 
@@ -217,21 +250,28 @@ $js = <<<JS
             dataType: 'json',
             success: function(resp) {
                 if (resp.success) {
-                    producerModal.hide();
-                    reloadProducerTab();
+                    shouldReload = true;
+                    getModal().hide();
                 } else if (resp.errors) {
+                    \$saveBtn.prop('disabled', false);
                     $.each(resp.errors, function(field, messages) {
                         var \$input = \$form.find('[name="Producer[' + field + ']"]');
                         \$input.addClass('is-invalid');
                         \$input.after('<div class="invalid-feedback">' + messages[0] + '</div>');
                     });
+                } else {
+                    \$saveBtn.prop('disabled', false);
+                    alert(resp.message || 'Ошибка сохранения');
                 }
             },
-            error: function() { alert('Ошибка сервера'); }
+            error: function() {
+                \$saveBtn.prop('disabled', false);
+                alert('Ошибка сервера');
+            }
         });
     });
 
-    $(document).on('click', '.btn-producer-delete', function(e) {
+    $(document).on('click.producertab', '.btn-producer-delete', function(e) {
         e.preventDefault();
         var id = $(this).data('id');
         var name = $(this).data('name');
@@ -263,9 +303,10 @@ $js = <<<JS
         if (\$btn.prop('disabled')) return;
         \$btn.prop('disabled', true);
         editingPlantationId = null;
+        editingProducerId = null;
         $('#producer-modal-title').text('Добавить плантацию');
         $('#producer-modal-body').html(loaderHtml);
-        producerModal.show();
+        getModal().show();
         $.get('{$createPlantationFormUrl}', function(html) {
             $('#producer-modal-body').html(html);
         }).always(function() {
@@ -273,15 +314,16 @@ $js = <<<JS
         });
     });
 
-    $(document).on('click', '.btn-plantation-edit', function(e) {
+    $(document).on('click.producertab', '.btn-plantation-edit', function(e) {
         e.preventDefault();
         var \$btn = $(this);
         if (\$btn.prop('disabled')) return;
         \$btn.prop('disabled', true);
         editingPlantationId = \$btn.data('id');
+        editingProducerId = null;
         $('#producer-modal-title').text('Редактировать плантацию');
         $('#producer-modal-body').html(loaderHtml);
-        producerModal.show();
+        getModal().show();
         $.get('{$updatePlantationFormUrl}', {id: editingPlantationId}, function(html) {
             $('#producer-modal-body').html(html);
         }).always(function() {
@@ -289,7 +331,11 @@ $js = <<<JS
         });
     });
 
-    $(document).on('click', '#btn-plantation-save', function() {
+    $(document).on('click.producertab', '#btn-plantation-save', function() {
+        var \$saveBtn = $(this);
+        if (\$saveBtn.prop('disabled')) return;
+        \$saveBtn.prop('disabled', true);
+
         var \$form = $('#plantation-form');
         var url = editingPlantationId ? '{$updatePlantationUrl}?id=' + editingPlantationId : '{$createPlantationUrl}';
 
@@ -303,21 +349,28 @@ $js = <<<JS
             dataType: 'json',
             success: function(resp) {
                 if (resp.success) {
-                    producerModal.hide();
-                    reloadProducerTab();
+                    shouldReload = true;
+                    getModal().hide();
                 } else if (resp.errors) {
+                    \$saveBtn.prop('disabled', false);
                     $.each(resp.errors, function(field, messages) {
                         var \$input = \$form.find('[name="Plantation[' + field + ']"]');
                         \$input.addClass('is-invalid');
                         \$input.after('<div class="invalid-feedback">' + messages[0] + '</div>');
                     });
+                } else {
+                    \$saveBtn.prop('disabled', false);
+                    alert(resp.message || 'Ошибка сохранения');
                 }
             },
-            error: function() { alert('Ошибка сервера'); }
+            error: function() {
+                \$saveBtn.prop('disabled', false);
+                alert('Ошибка сервера');
+            }
         });
     });
 
-    $(document).on('click', '.btn-plantation-delete', function(e) {
+    $(document).on('click.producertab', '.btn-plantation-delete', function(e) {
         e.preventDefault();
         var id = $(this).data('id');
         var name = $(this).data('name');
@@ -343,7 +396,7 @@ $js = <<<JS
     });
 
     // Очистка ошибок при вводе
-    $(document).on('input change', '#producer-form input, #producer-form select, #plantation-form input, #plantation-form select', function() {
+    $(document).on('input.producertab change.producertab', '#producer-form input, #producer-form select, #plantation-form input, #plantation-form select', function() {
         $(this).removeClass('is-invalid');
         $(this).next('.invalid-feedback').remove();
     });
index 9166e3352ffae3a1469d86175e3a360d906b3033..13920eeaef1817ae6567eb5b51e00783b91822ab 100644 (file)
 /**
  * Budget module — JS logic.
  * Прогресс-бары, AJAX CRUD, approval workflow.
+ *
+ * Конфигурация: window.budgetConfig = { urls: { getProgress, save, requestApproval, resolveApproval } }
+ * Публичный API: window.budgetApp.{ saveBudget, requestApproval, resolveApproval, openApprovalModal }
  */
+(function () {
+    var cfg = window.budgetConfig || {};
+    var urls = cfg.urls || {
+        getProgress:     '/budget/get-progress',
+        save:            '/budget/save',
+        requestApproval: '/budget/request-approval',
+        resolveApproval: '/budget/resolve-approval'
+    };
+
+    var _autoRefreshTimer = null;
+
+    /* ── Helpers ─────────────────────────────────── */
 
-// CSRF token для AJAX POST-запросов
-$.ajaxSetup({
-    headers: {
-        'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content')
+    function escapeHtml(text) {
+        var div = document.createElement('div');
+        div.appendChild(document.createTextNode(String(text)));
+        return div.innerHTML;
     }
-});
-
-function loadProgress() {
-    var category = document.getElementById('filter-category').value;
-    var periodStart = document.getElementById('filter-period').value;
-
-    var url = '/budget/get-progress?';
-    if (category) url += 'category=' + encodeURIComponent(category) + '&';
-    if (periodStart) url += 'period_start=' + encodeURIComponent(periodStart);
-
-    $.ajax({
-        url: url,
-        type: 'GET',
-        dataType: 'json',
-        success: function (response) {
-            if (response.success) {
-                renderProgressBars(response.data);
-                renderBudgetTable(response.data);
-            }
-        },
-        error: function () {
-            console.error('Ошибка загрузки прогресса бюджета');
-        }
-    });
-}
-
-function renderProgressBars(budgets) {
-    var container = document.getElementById('progress-container');
-    container.innerHTML = '';
-
-    budgets.forEach(function (b) {
-        var pct = b.usage_pct;
-        var color = '#28a745';
-        if (b.is_exceeded) color = '#dc3545';
-        else if (b.is_alert) color = '#ffc107';
-
-        var html = '<div class="col-md-6 mb-3">' +
-            '<div class="card"><div class="card-body">' +
-            '<h6 class="card-title">' + escapeHtml(b.category) + ' &nbsp; ' + b.period_start + ' — ' + b.period_end + '</h6>' +
-            '<div class="progress" style="height:30px;">' +
-            '<div class="progress-bar" style="width:' + Math.min(pct, 100) + '%;background-color:' + color + ';">' +
-            pct + '%</div></div>' +
-            '<div class="mt-1 text-muted">' +
-            formatNumber(b.used_amount) + ' &#8381; / ' + formatNumber(b.limit_amount) + ' &#8381; | Остаток: ' + formatNumber(b.remaining) + ' &#8381;</div>' +
-            '</div></div></div>';
-
-        container.innerHTML += html;
-    });
-}
-
-function renderBudgetTable(budgets) {
-    var tbody = document.querySelector('#budget-table tbody');
-    tbody.innerHTML = '';
-
-    budgets.forEach(function (b) {
-        var statusBadge;
-        if (b.is_exceeded) {
-            statusBadge = '<span class="badge bg-danger">Превышен</span>';
-        } else if (b.is_alert) {
-            statusBadge = '<span class="badge bg-warning text-dark">Предупреждение</span>';
-        } else {
-            statusBadge = '<span class="badge bg-success">Норма</span>';
-        }
 
-        var row = '<tr>' +
-            '<td>' + b.period_start + ' — ' + b.period_end + '</td>' +
-            '<td>' + escapeHtml(b.category) + '</td>' +
-            '<td>' + formatNumber(b.limit_amount) + '</td>' +
-            '<td>' + formatNumber(b.used_amount) + '</td>' +
-            '<td>' + b.usage_pct + '%</td>' +
-            '<td>' + b.alert_threshold_pct + '%</td>' +
-            '<td>' + statusBadge + '</td>' +
-            '</tr>';
-
-        tbody.innerHTML += row;
-    });
-}
-
-function saveBudget() {
-    var data = {
-        category: document.getElementById('form-category').value,
-        period_start: document.getElementById('form-period-start').value,
-        limit_amount: document.getElementById('form-limit').value,
-        alert_threshold_pct: document.getElementById('form-threshold').value
-    };
+    function formatNumber(n) {
+        return new Intl.NumberFormat('ru-RU', {minimumFractionDigits: 0, maximumFractionDigits: 0}).format(n);
+    }
+
+    function csrfHeader() {
+        var token = document.querySelector('meta[name="csrf-token"]');
+        return token ? {'X-CSRF-Token': token.getAttribute('content')} : {};
+    }
 
-    if (!data.category || !data.period_start || !data.limit_amount) {
-        alert('Заполните все обязательные поля');
-        return;
+    function getModalInstance(id) {
+        var el = document.getElementById(id);
+        if (!el) return null;
+        return bootstrap.Modal.getOrCreateInstance(el);
     }
 
-    // Проверка: понедельник
-    var d = new Date(data.period_start);
-    if (d.getDay() !== 1) {
-        alert('Период должен начинаться с понедельника');
-        return;
+    /* ── Rendering ───────────────────────────────── */
+
+    function renderProgressBars(budgets) {
+        var container = document.getElementById('progress-container');
+        if (!container) return;
+
+        var parts = [];
+        budgets.forEach(function (b) {
+            var pct = b.usage_pct;
+            var color = b.is_exceeded ? '#dc3545' : b.is_alert ? '#ffc107' : '#28a745';
+            parts.push(
+                '<div class="col-md-6 mb-3">' +
+                '<div class="card"><div class="card-body">' +
+                '<h6 class="card-title">' + escapeHtml(b.category) + ' &nbsp; ' + escapeHtml(b.period_start) + ' — ' + escapeHtml(b.period_end) + '</h6>' +
+                '<div class="progress" style="height:30px;">' +
+                '<div class="progress-bar" style="width:' + Math.min(pct, 100) + '%;background-color:' + color + ';">' +
+                escapeHtml(pct) + '%</div></div>' +
+                '<div class="mt-1 text-muted">' +
+                formatNumber(b.used_amount) + ' &#8381; / ' + formatNumber(b.limit_amount) + ' &#8381; | Остаток: ' + formatNumber(b.remaining) + ' &#8381;</div>' +
+                '</div></div></div>'
+            );
+        });
+        container.innerHTML = parts.join('');
     }
 
-    $.ajax({
-        url: '/budget/save',
-        type: 'POST',
-        data: data,
-        dataType: 'json',
-        success: function (response) {
-            if (response.success) {
-                var modal = bootstrap.Modal.getInstance(document.getElementById('budgetFormModal'));
-                if (modal) modal.hide();
-                loadProgress();
-            } else {
-                alert(response.message || 'Ошибка сохранения');
+    function renderBudgetTable(budgets) {
+        var tbody = document.querySelector('#budget-table tbody');
+        if (!tbody) return;
+
+        var rows = [];
+        budgets.forEach(function (b) {
+            var statusBadge = b.is_exceeded
+                ? '<span class="badge bg-danger">Превышен</span>'
+                : b.is_alert
+                    ? '<span class="badge bg-warning text-dark">Предупреждение</span>'
+                    : '<span class="badge bg-success">Норма</span>';
+
+            rows.push(
+                '<tr>' +
+                '<td>' + escapeHtml(b.period_start) + ' — ' + escapeHtml(b.period_end) + '</td>' +
+                '<td>' + escapeHtml(b.category) + '</td>' +
+                '<td>' + formatNumber(b.limit_amount) + '</td>' +
+                '<td>' + formatNumber(b.used_amount) + '</td>' +
+                '<td>' + escapeHtml(b.usage_pct) + '%</td>' +
+                '<td>' + escapeHtml(b.alert_threshold_pct) + '%</td>' +
+                '<td>' + statusBadge + '</td>' +
+                '</tr>'
+            );
+        });
+        tbody.innerHTML = rows.join('');
+    }
+
+    /* ── Core ────────────────────────────────────── */
+
+    function loadProgress() {
+        var categoryEl = document.getElementById('filter-category');
+        var periodEl   = document.getElementById('filter-period');
+        if (!categoryEl || !periodEl) return;
+
+        var params = {};
+        if (categoryEl.value) params.category     = categoryEl.value;
+        if (periodEl.value)   params.period_start = periodEl.value;
+
+        $.ajax({
+            url: urls.getProgress,
+            type: 'GET',
+            data: params,
+            dataType: 'json',
+            success: function (response) {
+                if (response.success) {
+                    renderProgressBars(response.data);
+                    renderBudgetTable(response.data);
+                }
+            },
+            error: function () {
+                console.error('Ошибка загрузки прогресса бюджета');
             }
-        },
-        error: function () {
-            alert('Ошибка сервера');
+        });
+    }
+
+    function saveBudget() {
+        var data = {
+            category:            document.getElementById('form-category').value,
+            period_start:        document.getElementById('form-period-start').value,
+            limit_amount:        document.getElementById('form-limit').value,
+            alert_threshold_pct: document.getElementById('form-threshold').value
+        };
+
+        if (!data.category || !data.period_start || !data.limit_amount) {
+            alert('Заполните все обязательные поля');
+            return;
         }
-    });
-}
 
-function requestApproval() {
-    var budgetId = document.getElementById('approval-budget-id').value;
-    var amount = document.getElementById('approval-amount').value;
-    var reason = document.getElementById('approval-reason').value;
+        var d = new Date(data.period_start);
+        if (d.getDay() !== 1) {
+            alert('Период должен начинаться с понедельника');
+            return;
+        }
 
-    if (!amount || !reason) {
-        alert('Заполните все поля');
-        return;
+        $.ajax({
+            url: urls.save,
+            type: 'POST',
+            headers: csrfHeader(),
+            data: data,
+            dataType: 'json',
+            success: function (response) {
+                if (response.success) {
+                    var modal = getModalInstance('budgetFormModal');
+                    if (modal) modal.hide();
+                    loadProgress();
+                } else {
+                    alert(response.message || 'Ошибка сохранения');
+                }
+            },
+            error: function () { alert('Ошибка сервера'); }
+        });
     }
 
-    $.ajax({
-        url: '/budget/request-approval',
-        type: 'POST',
-        data: {budget_id: budgetId, amount: amount, reason: reason},
-        dataType: 'json',
-        success: function (response) {
-            if (response.success) {
-                var modal = bootstrap.Modal.getInstance(document.getElementById('approvalRequestModal'));
-                if (modal) modal.hide();
-                location.reload();
-            } else {
-                alert(response.message || 'Ошибка');
-            }
-        },
-        error: function () {
-            alert('Ошибка сервера');
+    function requestApproval() {
+        var budgetId = document.getElementById('approval-budget-id').value;
+        var amount   = document.getElementById('approval-amount').value;
+        var reason   = document.getElementById('approval-reason').value;
+
+        if (!amount || !reason) {
+            alert('Заполните все поля');
+            return;
         }
-    });
-}
 
-function resolveApproval(approvalId, status) {
-    var action = status === 'approved' ? 'одобрить' : 'отклонить';
-    if (!confirm('Вы уверены, что хотите ' + action + ' запрос #' + approvalId + '?')) {
-        return;
+        $.ajax({
+            url: urls.requestApproval,
+            type: 'POST',
+            headers: csrfHeader(),
+            data: {budget_id: budgetId, amount: amount, reason: reason},
+            dataType: 'json',
+            success: function (response) {
+                if (response.success) {
+                    var modal = getModalInstance('approvalRequestModal');
+                    if (modal) modal.hide();
+                    location.reload();
+                } else {
+                    alert(response.message || 'Ошибка');
+                }
+            },
+            error: function () { alert('Ошибка сервера'); }
+        });
     }
 
-    $.ajax({
-        url: '/budget/resolve-approval',
-        type: 'POST',
-        data: {approval_id: approvalId, status: status},
-        dataType: 'json',
-        success: function (response) {
-            if (response.success) {
-                location.reload();
-            } else {
-                alert(response.message || 'Ошибка');
-            }
-        },
-        error: function () {
-            alert('Ошибка сервера');
+    function resolveApproval(approvalId, status) {
+        var action = status === 'approved' ? 'одобрить' : 'отклонить';
+        if (!confirm('Вы уверены, что хотите ' + action + ' запрос #' + approvalId + '?')) return;
+
+        $.ajax({
+            url: urls.resolveApproval,
+            type: 'POST',
+            headers: csrfHeader(),
+            data: {approval_id: approvalId, status: status},
+            dataType: 'json',
+            success: function (response) {
+                if (response.success) {
+                    location.reload();
+                } else {
+                    alert(response.message || 'Ошибка');
+                }
+            },
+            error: function () { alert('Ошибка сервера'); }
+        });
+    }
+
+    function openApprovalModal(budgetId) {
+        document.getElementById('approval-budget-id').value = budgetId;
+        document.getElementById('approval-amount').value = '';
+        document.getElementById('approval-reason').value = '';
+        var modal = getModalInstance('approvalRequestModal');
+        if (modal) modal.show();
+    }
+
+    /* ── Auto-refresh ────────────────────────────── */
+
+    function startAutoRefresh() {
+        if (_autoRefreshTimer) return;
+        _autoRefreshTimer = setInterval(loadProgress, 60000);
+    }
+
+    /* ── Init ────────────────────────────────────── */
+
+    $(function () {
+        // Запускаем авто-обновление только если DOM бюджета присутствует
+        if (document.getElementById('progress-container')) {
+            loadProgress();
+            startAutoRefresh();
         }
     });
-}
-
-function openApprovalModal(budgetId) {
-    document.getElementById('approval-budget-id').value = budgetId;
-    document.getElementById('approval-amount').value = '';
-    document.getElementById('approval-reason').value = '';
-    var modal = new bootstrap.Modal(document.getElementById('approvalRequestModal'));
-    modal.show();
-}
-
-// Helpers
-function formatNumber(n) {
-    return new Intl.NumberFormat('ru-RU', {minimumFractionDigits: 0, maximumFractionDigits: 0}).format(n);
-}
-
-function escapeHtml(text) {
-    var div = document.createElement('div');
-    div.appendChild(document.createTextNode(text));
-    return div.innerHTML;
-}
-
-// Auto-refresh every 60 seconds
-setInterval(loadProgress, 60000);
+
+    /* ── Public API ──────────────────────────────── */
+
+    window.budgetApp = {
+        saveBudget:       saveBudget,
+        requestApproval:  requestApproval,
+        resolveApproval:  resolveApproval,
+        openApprovalModal: openApprovalModal,
+        loadProgress:     loadProgress
+    };
+}());
index 83feb61e931e095b64eb750fc3ea84e8cc9e495e..3d511aa4b591c1ef15215e58f8b00028d314c629 100644 (file)
 (function () {
     // Единое состояние модуля — живёт пока жива страница
     var state = {
-        cfg:        null,   // URL-ы эндпоинтов
-        modal:      null,   // Bootstrap Modal instance
-        editingId:  null,
-        cascadeXhr: null,   // in-flight cascade AJAX
-        activeXhr:  null,   // in-flight reloadList AJAX
-        searchTimer: null
+        cfg:          null,   // URL-ы эндпоинтов
+        modal:        null,   // Bootstrap Modal instance
+        editingId:    null,
+        shouldReload: false,  // флаг: перезагрузить список после hidden.bs.modal
+        cascadeXhr:   null,   // in-flight cascade AJAX
+        activeXhr:    null,   // in-flight reloadList AJAX
+        searchTimer:  null
     };
 
     /* ───────────────────────────────────────────────
         });
     }
 
-    function getModal() {
-        // Всегда берём актуальный экземпляр Bootstrap Modal
-        return state.modal;
-    }
-
     /* ───────────────────────────────────────────────
        pmSetup — вызывается ОДИН РАЗ при загрузке страницы
     ─────────────────────────────────────────────── */
             $btn.prop('disabled', true);
             state.editingId = null;
 
-            var modal = getModal();
+            var modal = state.modal;
             if (!modal) { $btn.prop('disabled', false); return; }
 
             $('#pm-modal-title').text('Добавить поставщика');
             $btn.prop('disabled', true);
             state.editingId = $btn.data('id');
 
-            var modal = getModal();
+            var modal = state.modal;
             if (!modal) { $btn.prop('disabled', false); return; }
 
             $('#pm-modal-title').text('Редактировать маппинг');
 
         $(document).on('click' + NS, '#btn-mapping-save', function () {
             if (!state.cfg) return;
+            var $saveBtn = $(this);
+            if ($saveBtn.prop('disabled')) return;
+            $saveBtn.prop('disabled', true);
+
             var $form = $('#mapping-form');
             var url = state.editingId
                 ? state.cfg.urls.update + '?id=' + state.editingId
                 dataType: 'json',
                 success: function (resp) {
                     if (resp.success) {
-                        var modal = getModal();
-                        if (modal) modal.hide();
-                        reloadList();
+                        state.shouldReload = true;
+                        if (state.modal) state.modal.hide();
                     } else if (resp.errors) {
+                        $saveBtn.prop('disabled', false);
                         $.each(resp.errors, function (field, messages) {
                             var $input = $form.find('[name="ProductMapping[' + field + ']"]');
                             if (!$input.length) $input = $form.find('[name="ProductMapping[' + field + '][]"]');
                         });
                     }
                 },
-                error: function () { alert('Ошибка сервера'); }
+                error: function () {
+                    $saveBtn.prop('disabled', false);
+                    alert('Ошибка сервера');
+                }
             });
         });
 
 
     window.pmReinit = function (cfg) {
         state.cfg = cfg;
+        state.shouldReload = false;
 
         // Корректно уничтожаем старый Modal, чтобы Bootstrap не плодил глобальные слушатели
         if (state.modal) {
         var el = document.getElementById('pm-modal');
         if (el) {
             state.modal = new bootstrap.Modal(el);
+            // Перезагружаем список только после полного закрытия модала,
+            // иначе Bootstrap не успевает убрать backdrop до замены DOM.
+            el.addEventListener('hidden.bs.modal', function () {
+                if (state.shouldReload) {
+                    state.shouldReload = false;
+                    reloadList();
+                }
+            });
         }
     };
 }());