/* global bootstrap, yii */
-window.pmInit = function (cfg) {
- if (!cfg) return;
- // Снимаем все предыдущие обработчики этого модуля с document,
- // чтобы предотвратить накопление при каждом reloadList().
- var NS = '.pmapping';
- $(document).off(NS);
+/**
+ * Модуль маппинга товаров.
+ *
+ * Архитектура:
+ * pmSetup() — регистрирует обработчики НА DOCUMENT один раз при загрузке страницы.
+ * pmReinit() — вызывается каждый раз при (пере)загрузке контента вкладки:
+ * обновляет cfg и пересоздаёт Bootstrap Modal.
+ *
+ * Разделение позволяет не накапливать обработчики при каждом reloadList().
+ */
+(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
+ };
- var pmModal = new bootstrap.Modal(document.getElementById('pm-modal'));
- var editingId = null;
- var searchTimer = null;
- var cascadeXhr = null;
- var loaderHtml = '<div class="text-center p-4"><i class="fa fa-spinner fa-spin fa-2x text-secondary"></i></div>';
+ /* ───────────────────────────────────────────────
+ Helpers
+ ─────────────────────────────────────────────── */
function collectFilters() {
var params = {};
var name = $el.attr('name');
if (!name) return;
if ($el.is(':checkbox')) {
- if ($el.is(':checked')) {
- params[name] = '1';
- }
+ if ($el.is(':checked')) params[name] = '1';
} else {
var v = $el.val();
- if (v !== '' && v !== null) {
- params[name] = v;
- }
+ if (v !== '' && v !== null) params[name] = v;
}
});
- params['per-page'] = $('#pm-per-page').val();
+ params['per-page'] = $('#pm-per-page').val() || 50;
return params;
}
function reloadList(extraParams) {
- // Отменяем незавершённый cascade AJAX — сервер вернёт актуальные данные
- if (cascadeXhr) {
- cascadeXhr.abort();
- cascadeXhr = null;
- }
+ if (!state.cfg) return;
+
+ // Отменяем предыдущие AJAX-запросы чтобы избежать stale-ответов
+ if (state.cascadeXhr) { state.cascadeXhr.abort(); state.cascadeXhr = null; }
+ if (state.activeXhr) { state.activeXhr.abort(); state.activeXhr = null; }
+
var params = collectFilters();
- if (extraParams) {
- $.extend(params, extraParams);
- }
+ if (extraParams) $.extend(params, extraParams);
+
var $tab = $('#tab-mappings');
$tab.html('<div class="text-muted p-4 text-center"><i class="fa fa-spinner fa-spin me-2"></i>Загрузка...</div>');
- $.get(cfg.urls.index, params, function (html) {
+
+ state.activeXhr = $.get(state.cfg.urls.index, params, function (html) {
+ state.activeXhr = null;
$tab.html(html);
+ }).fail(function (xhr, status) {
+ if (status === 'abort') return;
+ state.activeXhr = null;
+ var code = xhr.status || 0;
+ var msg = code >= 500 ? 'Ошибка сервера (' + code + ').'
+ : code === 403 ? 'Нет прав доступа (403).'
+ : code === 404 ? 'Страница не найдена (404).'
+ : 'Ошибка загрузки (' + (code || status) + ').';
+ $tab.html(
+ '<div class="alert alert-danger m-3">' +
+ '<i class="fa fa-exclamation-triangle me-2"></i>' + msg +
+ ' <button type="button" class="btn btn-sm btn-outline-danger ms-2 js-pm-retry">Повторить</button>' +
+ '</div>'
+ );
});
}
- function reloadAnalytics() {
- var params = collectFilters();
- delete params['per-page'];
- $.get(cfg.urls.analytics, params, function (data) {
- $.each(data, function (field, value) {
- $('[data-analytics-field="' + field + '"]').text(value);
- });
- }, 'json');
+ function getModal() {
+ // Всегда берём актуальный экземпляр Bootstrap Modal
+ return state.modal;
}
- // ========= ФИЛЬТРЫ =========
-
- $(document).on('change' + NS, '.pm-filter', function () {
- var name = $(this).attr('name');
-
- if (name === 'category') {
- var category = $(this).val();
- $('#pm-filter-subcategory').html('<option value="">Все</option>').prop('disabled', !category);
- $('#pm-filter-species').html('<option value="">Все</option>').prop('disabled', true);
- if (category) {
- // Показываем подкатегории быстро, до полного reload списка
- cascadeXhr = $.get(cfg.urls.cascade, {category: category}, function (data) {
- cascadeXhr = null;
- var html = '<option value="">Все</option>';
- $.each(data.subcategories, function (_, sub) {
- html += '<option value="' + sub + '">' + sub + '</option>';
- });
- $('#pm-filter-subcategory').html(html).prop('disabled', false);
- }, 'json');
+ /* ───────────────────────────────────────────────
+ pmSetup — вызывается ОДИН РАЗ при загрузке страницы
+ ─────────────────────────────────────────────── */
+
+ window.pmSetup = function () {
+ var NS = '.pmapping';
+ $(document).off(NS); // на случай повторного вызова
+
+ // ── Фильтры ──────────────────────────────
+ $(document).on('change' + NS, '.pm-filter', function () {
+ var name = $(this).attr('name');
+
+ if (name === 'category') {
+ var category = $(this).val();
+ $('#pm-filter-subcategory').html('<option value="">Все</option>').prop('disabled', !category);
+ $('#pm-filter-species').html('<option value="">Все</option>').prop('disabled', true);
+ if (category && state.cfg) {
+ state.cascadeXhr = $.get(state.cfg.urls.cascade, {category: category}, function (data) {
+ state.cascadeXhr = null;
+ var html = '<option value="">Все</option>';
+ $.each(data.subcategories || [], function (_, sub) {
+ html += '<option value="' + sub + '">' + sub + '</option>';
+ });
+ $('#pm-filter-subcategory').html(html).prop('disabled', false);
+ }, 'json').fail(function (x, s) { if (s !== 'abort') state.cascadeXhr = null; });
+ }
+ reloadList({page: 1});
+ return;
}
- reloadList({page: 1});
- return;
- }
- if (name === 'subcategory') {
- var cat = $('#pm-filter-category').val();
- var sub = $(this).val();
- $('#pm-filter-species').html('<option value="">Все</option>').prop('disabled', !sub);
- if (cat && sub) {
- cascadeXhr = $.get(cfg.urls.cascade, {category: cat, subcategory: sub}, function (data) {
- cascadeXhr = null;
- var html = '<option value="">Все</option>';
- $.each(data.species, function (_, sp) {
- html += '<option value="' + sp + '">' + sp + '</option>';
- });
- $('#pm-filter-species').html(html).prop('disabled', false);
- }, 'json');
+ if (name === 'subcategory') {
+ var cat = $('#pm-filter-category').val();
+ var sub = $(this).val();
+ $('#pm-filter-species').html('<option value="">Все</option>').prop('disabled', !sub);
+ if (cat && sub && state.cfg) {
+ state.cascadeXhr = $.get(state.cfg.urls.cascade, {category: cat, subcategory: sub}, function (data) {
+ state.cascadeXhr = null;
+ var html = '<option value="">Все</option>';
+ $.each(data.species || [], function (_, sp) {
+ html += '<option value="' + sp + '">' + sp + '</option>';
+ });
+ $('#pm-filter-species').html(html).prop('disabled', false);
+ }, 'json').fail(function (x, s) { if (s !== 'abort') state.cascadeXhr = null; });
+ }
+ reloadList({page: 1});
+ return;
}
+
+ if (name === 'search') return;
+
reloadList({page: 1});
- return;
- }
+ });
- if (name === 'search') return;
+ // Поиск с debounce
+ $(document).on('input' + NS, 'input[name="search"].pm-filter', function () {
+ clearTimeout(state.searchTimer);
+ state.searchTimer = setTimeout(function () { reloadList({page: 1}); }, 400);
+ });
- reloadList({page: 1});
- });
+ // Сброс фильтров
+ $(document).on('click' + NS, '#pm-filter-reset', function () {
+ $('.pm-filter').each(function () {
+ var $el = $(this);
+ if ($el.is(':checkbox')) $el.prop('checked', false);
+ else if ($el.is('select')) $el.val('');
+ else $el.val('');
+ });
+ reloadList({page: 1});
+ });
- // Поиск — с debounce 400мс
- $(document).on('input' + NS, 'input[name="search"].pm-filter', function () {
- clearTimeout(searchTimer);
- searchTimer = setTimeout(function () {
+ // Смена per-page (делегируем на document — элемент пересоздаётся при reload)
+ $(document).on('change' + NS, '#pm-per-page', function () {
reloadList({page: 1});
- }, 400);
- });
+ });
- // Сброс фильтров
- $(document).on('click' + NS, '#pm-filter-reset', function () {
- $('.pm-filter').each(function () {
- var $el = $(this);
- if ($el.is(':checkbox')) {
- $el.prop('checked', false);
- } else if ($el.is('select')) {
- $el.val('');
- } else {
- $el.val('');
- }
+ // Экспорт
+ $(document).on('click' + NS, '#pm-export-btn', function () {
+ if (!state.cfg) return;
+ var params = collectFilters();
+ delete params['per-page'];
+ var qs = $.param(params);
+ window.location.href = state.cfg.urls.export + (qs ? '?' + qs : '');
});
- reloadList({page: 1});
- });
- // Смена per-page
- $('#pm-per-page').on('change', function () {
- reloadList({page: 1});
- });
+ // Пагинация
+ $(document).on('click' + NS, '.pm-pager a', function (e) {
+ e.preventDefault();
+ var href = $(this).attr('href') || '';
+ var m = href.match(/[?&]page=(\d+)/);
+ reloadList({page: m ? m[1] : 1});
+ });
- // Экспорт в .xlsx — редирект с текущими фильтрами
- $(document).on('click' + NS, '#pm-export-btn', function () {
- var params = collectFilters();
- delete params['per-page'];
- var qs = $.param(params);
- window.location.href = cfg.urls.export + (qs ? '?' + qs : '');
- });
-
- // Пагинация — перехват ссылок
- $(document).on('click' + NS, '.pm-pager a', function (e) {
- e.preventDefault();
- var href = $(this).attr('href');
- var pageMatch = href.match(/[?&]page=(\d+)/);
- var page = pageMatch ? pageMatch[1] : 1;
- reloadList({page: page});
- });
-
- // ========= МОДАЛКА МАППИНГА =========
-
- $(document).on('click' + NS, '.btn-pm-add', function () {
- var $btn = $(this);
- if ($btn.prop('disabled')) return;
- $btn.prop('disabled', true);
- editingId = null;
- var guid = $btn.data('product-guid');
- $('#pm-modal-title').text('Добавить поставщика');
- $('#pm-modal-body').html(loaderHtml);
- pmModal.show();
- $.get(cfg.urls.createForm, {product_guid: guid}, function (html) {
- $('#pm-modal-body').html(html);
- }).always(function () {
- $btn.prop('disabled', false);
+ // Повтор при ошибке загрузки
+ $(document).on('click' + NS, '.js-pm-retry', function () {
+ reloadList({page: 1});
});
- });
-
- $(document).on('click' + NS, '.btn-pm-edit', function (e) {
- e.preventDefault();
- var $btn = $(this);
- if ($btn.prop('disabled')) return;
- $btn.prop('disabled', true);
- editingId = $btn.data('id');
- $('#pm-modal-title').text('Редактировать маппинг');
- $('#pm-modal-body').html(loaderHtml);
- pmModal.show();
- $.get(cfg.urls.updateForm, {id: editingId}, function (html) {
- $('#pm-modal-body').html(html);
- }).always(function () {
- $btn.prop('disabled', false);
+
+ // ── Модалка ───────────────────────────────
+ $(document).on('click' + NS, '.btn-pm-add', function () {
+ if (!state.cfg) return;
+ var $btn = $(this);
+ if ($btn.prop('disabled')) return;
+ $btn.prop('disabled', true);
+ state.editingId = null;
+
+ var modal = getModal();
+ if (!modal) { $btn.prop('disabled', false); return; }
+
+ $('#pm-modal-title').text('Добавить поставщика');
+ $('#pm-modal-body').html('<div class="text-center p-4"><i class="fa fa-spinner fa-spin fa-2x text-secondary"></i></div>');
+ modal.show();
+
+ $.get(state.cfg.urls.createForm, {product_guid: $btn.data('product-guid')}, function (html) {
+ $('#pm-modal-body').html(html);
+ }).always(function () { $btn.prop('disabled', false); });
});
- });
-
- $(document).on('click' + NS, '#btn-mapping-save', function () {
- var $form = $('#mapping-form');
- var url = editingId ? cfg.urls.update + '?id=' + editingId : cfg.urls.create;
-
- $form.find('.is-invalid').removeClass('is-invalid');
- $form.find('.invalid-feedback').remove();
-
- $.ajax({
- url: url,
- type: 'POST',
- data: $form.serialize(),
- dataType: 'json',
- success: function (resp) {
- if (resp.success) {
- pmModal.hide();
- reloadList();
- } else if (resp.errors) {
- $.each(resp.errors, function (field, messages) {
- var $input = $form.find('[name="ProductMapping[' + field + ']"]');
- if (!$input.length) {
- $input = $form.find('[name="ProductMapping[' + field + '][]"]');
- }
- $input.addClass('is-invalid');
- $input.after('<div class="invalid-feedback">' + messages[0] + '</div>');
- });
- }
- },
- error: function () { alert('Ошибка сервера'); }
+
+ $(document).on('click' + NS, '.btn-pm-edit', function (e) {
+ e.preventDefault();
+ if (!state.cfg) return;
+ var $btn = $(this);
+ if ($btn.prop('disabled')) return;
+ $btn.prop('disabled', true);
+ state.editingId = $btn.data('id');
+
+ var modal = getModal();
+ if (!modal) { $btn.prop('disabled', false); return; }
+
+ $('#pm-modal-title').text('Редактировать маппинг');
+ $('#pm-modal-body').html('<div class="text-center p-4"><i class="fa fa-spinner fa-spin fa-2x text-secondary"></i></div>');
+ modal.show();
+
+ $.get(state.cfg.urls.updateForm, {id: state.editingId}, function (html) {
+ $('#pm-modal-body').html(html);
+ }).always(function () { $btn.prop('disabled', false); });
});
- });
- $(document).on('click' + NS, '.btn-pm-delete', function (e) {
- e.preventDefault();
- var id = $(this).data('id');
- var name = $(this).data('name');
+ $(document).on('click' + NS, '#btn-mapping-save', function () {
+ if (!state.cfg) return;
+ var $form = $('#mapping-form');
+ var url = state.editingId
+ ? state.cfg.urls.update + '?id=' + state.editingId
+ : state.cfg.urls.create;
- if (!confirm('Удалить маппинг на поставщика "' + name + '"?')) {
- return;
- }
+ $form.find('.is-invalid').removeClass('is-invalid');
+ $form.find('.invalid-feedback').remove();
- $.ajax({
- url: cfg.urls.delete + '?id=' + id,
- type: 'POST',
- data: {_csrf: yii.getCsrfToken()},
- dataType: 'json',
- success: function (resp) {
- if (resp.success) {
- reloadList();
- } else {
- alert(resp.message || 'Ошибка удаления');
- }
- },
- error: function () { alert('Ошибка сервера'); }
+ $.ajax({
+ url: url,
+ type: 'POST',
+ data: $form.serialize(),
+ dataType: 'json',
+ success: function (resp) {
+ if (resp.success) {
+ var modal = getModal();
+ if (modal) modal.hide();
+ reloadList();
+ } else if (resp.errors) {
+ $.each(resp.errors, function (field, messages) {
+ var $input = $form.find('[name="ProductMapping[' + field + ']"]');
+ if (!$input.length) $input = $form.find('[name="ProductMapping[' + field + '][]"]');
+ $input.addClass('is-invalid').after('<div class="invalid-feedback">' + messages[0] + '</div>');
+ });
+ }
+ },
+ error: function () { alert('Ошибка сервера'); }
+ });
+ });
+
+ $(document).on('click' + NS, '.btn-pm-delete', function (e) {
+ e.preventDefault();
+ if (!state.cfg) return;
+ var id = $(this).data('id');
+ var name = $(this).data('name');
+ if (!confirm('Удалить маппинг на поставщика "' + name + '"?')) return;
+
+ $.ajax({
+ url: state.cfg.urls.delete + '?id=' + id,
+ type: 'POST',
+ data: {_csrf: yii.getCsrfToken()},
+ dataType: 'json',
+ success: function (resp) {
+ if (resp.success) reloadList();
+ else alert(resp.message || 'Ошибка удаления');
+ },
+ error: function () { alert('Ошибка сервера'); }
+ });
+ });
+
+ // Очистка ошибок при вводе в форме модалки
+ $(document).on('input change' + NS, '#mapping-form input, #mapping-form select', function () {
+ $(this).removeClass('is-invalid').next('.invalid-feedback').remove();
});
- });
-
- // Очистка ошибок при вводе
- $(document).on('input change' + NS, '#mapping-form input, #mapping-form select', function () {
- $(this).removeClass('is-invalid');
- $(this).next('.invalid-feedback').remove();
- });
-};
+ };
+
+ /* ───────────────────────────────────────────────
+ pmReinit — вызывается при каждой (пере)загрузке контента вкладки
+ ─────────────────────────────────────────────── */
+
+ window.pmReinit = function (cfg) {
+ state.cfg = cfg;
+
+ // Корректно уничтожаем старый Modal, чтобы Bootstrap не плодил глобальные слушатели
+ if (state.modal) {
+ try { state.modal.dispose(); } catch (e) {}
+ state.modal = null;
+ }
+
+ var el = document.getElementById('pm-modal');
+ if (el) {
+ state.modal = new bootstrap.Modal(el);
+ }
+ };
+}());