Упрощён компонент поиска товаров: убран мультивыбор, только single-select

- Удалён весь функционал множественного выбора
- Удалены кнопки 'Выбрать все' и 'Сбросить'
- Удалён счётчик выбранных товаров
- state.selected теперь содержит один объект вместо словаря
- Убраны параметры multi_select, max_selection, show_select_all
- onAddSelected теперь возвращает объект вместо массива
- Удалены методы getSelectedIds() и setSelection()
- Упрощена логика _toggleProduct для single-select
- Обновлены все callback'и для работы с одним товаром
- Компонент стал значительно проще и понятнее
This commit is contained in:
2025-12-11 00:26:48 +03:00
parent 1607fbe3fe
commit b115869b2d
3 changed files with 57 additions and 186 deletions

View File

@@ -107,7 +107,7 @@
<div class="card-body"> <div class="card-body">
<!-- Компонент поиска товаров --> <!-- Компонент поиска товаров -->
<div class="mb-3"> <div class="mb-3">
{% include 'products/components/product_search_picker.html' with container_id='writeoff-document-picker' title='Найти товар для списания' warehouse_id=document.warehouse.id filter_in_stock_only=True categories=categories tags=tags add_button_text='Выбрать товар' multi_select=False show_select_all=False content_height='250px' %} {% include 'products/components/product_search_picker.html' with container_id='writeoff-document-picker' title='Найти товар для списания' warehouse_id=document.warehouse.id filter_in_stock_only=True categories=categories tags=tags add_button_text='Выбрать товар' content_height='250px' %}
</div> </div>
<!-- Информация о выбранном товаре --> <!-- Информация о выбранном товаре -->
@@ -291,9 +291,9 @@ document.addEventListener('DOMContentLoaded', function() {
// Инициализация компонента поиска товаров // Инициализация компонента поиска товаров
const picker = ProductSearchPicker.init('#writeoff-document-picker', { const picker = ProductSearchPicker.init('#writeoff-document-picker', {
onAddSelected: function(products, instance) { onAddSelected: function(product, instance) {
if (products.length > 0) { if (product) {
selectProduct(products[0]); selectProduct(product);
instance.clearSelection(); instance.clearSelection();
} }
} }

View File

@@ -1,19 +1,18 @@
/** /**
* ProductSearchPicker - переиспользуемый модуль для поиска и выбора товаров * ProductSearchPicker - переиспользуемый модуль для поиска и выбора товара
* Только single-select режим.
* *
* API: * API:
* - ProductSearchPicker.init(containerSelector, options) - инициализация * - ProductSearchPicker.init(containerSelector, options) - инициализация
* - instance.getSelected() - получить выбранные товары (массив объектов) * - instance.getSelected() - получить выбранный товар (объект или null)
* - instance.getSelectedIds() - получить ID выбранных товаров (массив строк)
* - instance.clearSelection() - сбросить выбор * - instance.clearSelection() - сбросить выбор
* - instance.setSelection(productIds) - установить выбор программно
* - instance.refresh() - обновить список товаров * - instance.refresh() - обновить список товаров
* - instance.destroy() - уничтожить экземпляр * - instance.destroy() - уничтожить экземпляр
* *
* Пример использования: * Пример использования:
* const picker = ProductSearchPicker.init('#my-picker', { * const picker = ProductSearchPicker.init('#my-picker', {
* onAddSelected: function(products, instance) { * onAddSelected: function(product, instance) {
* console.log('Добавлены товары:', products); * console.log('Выбран товар:', product);
* } * }
* }); * });
*/ */
@@ -39,23 +38,20 @@
// Настройки по умолчанию // Настройки по умолчанию
this.options = Object.assign({ this.options = Object.assign({
apiUrl: this.container.dataset.apiUrl || '/products/api/search-products-variants/', apiUrl: this.container.dataset.apiUrl || '/products/api/search-products-variants/',
multiSelect: this.container.dataset.multiSelect !== 'false',
maxSelection: parseInt(this.container.dataset.maxSelection) || null,
excludeKits: this.container.dataset.excludeKits !== 'false', excludeKits: this.container.dataset.excludeKits !== 'false',
debounceDelay: 300, debounceDelay: 300,
pageSize: 30, pageSize: 30,
// Callbacks // Callbacks
onSelect: null, // (products, instance) => {} onSelect: null, // (product, instance) => {}
onDeselect: null, // (products, instance) => {} onDeselect: null, // (instance) => {}
onSelectionChange: null, // (selectedProducts, instance) => {} onAddSelected: null // (product, instance) => {}
onAddSelected: null // (selectedProducts, instance) => {}
}, options || {}); }, options || {});
// Состояние // Состояние
this.state = { this.state = {
products: [], products: [],
selected: {}, // {productId: productData} selected: null, // Единичный выбранный товар (объект или null)
currentPage: 1, currentPage: 1,
hasMore: false, hasMore: false,
isLoading: false, isLoading: false,
@@ -94,9 +90,6 @@
loading: c.querySelector('.product-picker-loading'), loading: c.querySelector('.product-picker-loading'),
empty: c.querySelector('.product-picker-empty'), empty: c.querySelector('.product-picker-empty'),
content: c.querySelector('.product-picker-content'), content: c.querySelector('.product-picker-content'),
selectedCount: c.querySelector('.product-picker-selected-count'),
selectAllBtn: c.querySelector('.product-picker-select-all'),
clearSelectionBtn: c.querySelector('.product-picker-clear-selection'),
addSelectedBtn: c.querySelector('.product-picker-add-selected'), addSelectedBtn: c.querySelector('.product-picker-add-selected'),
viewButtons: c.querySelectorAll('.product-picker-view') viewButtons: c.querySelectorAll('.product-picker-view')
}; };
@@ -210,25 +203,11 @@
}); });
} }
// Выбрать все // Добавить выбранный
if (this.elements.selectAllBtn) {
this.elements.selectAllBtn.addEventListener('click', function() {
self._selectAll();
});
}
// Сбросить выбор
if (this.elements.clearSelectionBtn) {
this.elements.clearSelectionBtn.addEventListener('click', function() {
self.clearSelection();
});
}
// Добавить выбранные
if (this.elements.addSelectedBtn) { if (this.elements.addSelectedBtn) {
this.elements.addSelectedBtn.addEventListener('click', function() { this.elements.addSelectedBtn.addEventListener('click', function() {
if (self.options.onAddSelected) { if (self.state.selected && self.options.onAddSelected) {
self.options.onAddSelected(self.getSelected(), self); self.options.onAddSelected(self.state.selected, self);
} }
}); });
} }
@@ -348,7 +327,7 @@
products.forEach(function(product) { products.forEach(function(product) {
// Извлекаем ID (убираем префикс "product_") // Извлекаем ID (убираем префикс "product_")
var productId = String(product.id).replace('product_', ''); var productId = String(product.id).replace('product_', '');
var isSelected = self.state.selected.hasOwnProperty(productId); var isSelected = self.state.selected && self.state.selected.id === productId;
var html = self.state.currentView === 'grid' var html = self.state.currentView === 'grid'
? self._renderGridCard(product, productId, isSelected) ? self._renderGridCard(product, productId, isSelected)
@@ -446,7 +425,7 @@
}; };
/** /**
* Переключение выбора товара * Переключение выбора товара (single-select)
*/ */
ProductSearchPicker.prototype._toggleProduct = function(productId) { ProductSearchPicker.prototype._toggleProduct = function(productId) {
var self = this; var self = this;
@@ -457,47 +436,40 @@
var p = this.state.products[i]; var p = this.state.products[i];
if (String(p.id).replace('product_', '') === productId) { if (String(p.id).replace('product_', '') === productId) {
product = p; product = p;
product.id = productId; // Сохраняем очищенный ID
break; break;
} }
} }
if (!product) return; if (!product) return;
var isSelected = this.state.selected.hasOwnProperty(productId); var isSelected = this.state.selected && this.state.selected.id === productId;
if (isSelected) { if (isSelected) {
// Снять выбор // Снять выбор
delete this.state.selected[productId]; var oldProductId = this.state.selected.id;
this.state.selected = null;
this._updateProductUI(oldProductId);
if (this.options.onDeselect) { if (this.options.onDeselect) {
this.options.onDeselect([product], this); this.options.onDeselect(this);
} }
} else { } else {
// Проверка множественного выбора // Сначала снимаем выбор со старого товара
if (!this.options.multiSelect) { if (this.state.selected) {
this.state.selected = {}; var oldProductId = this.state.selected.id;
this._updateProductUI(oldProductId);
} }
// Проверка лимита // Выбираем новый товар
if (this.options.maxSelection && this.state.selected = product;
Object.keys(this.state.selected).length >= this.options.maxSelection) { this._updateProductUI(productId);
return;
}
// Добавить в выбор
this.state.selected[productId] = product;
if (this.options.onSelect) { if (this.options.onSelect) {
this.options.onSelect([product], this); this.options.onSelect(product, this);
} }
} }
// Обновить UI // Обновить UI
this._updateProductUI(productId);
this._updateSelectionUI(); this._updateSelectionUI();
// Общий callback
if (this.options.onSelectionChange) {
this.options.onSelectionChange(this.getSelected(), this);
}
}; };
/** /**
@@ -507,7 +479,7 @@
var card = this.elements.grid.querySelector('[data-product-id="' + productId + '"]'); var card = this.elements.grid.querySelector('[data-product-id="' + productId + '"]');
if (!card) return; if (!card) return;
var isSelected = this.state.selected.hasOwnProperty(productId); var isSelected = this.state.selected && this.state.selected.id === productId;
if (isSelected) { if (isSelected) {
card.classList.add('selected'); card.classList.add('selected');
@@ -545,52 +517,16 @@
}; };
/** /**
* Обновление UI выбора (счетчик, кнопки) * Обновление UI выбора (кнопка)
*/ */
ProductSearchPicker.prototype._updateSelectionUI = function() { ProductSearchPicker.prototype._updateSelectionUI = function() {
var count = Object.keys(this.state.selected).length;
// Счетчик
if (this.elements.selectedCount) {
this.elements.selectedCount.textContent = count;
}
// Кнопка "Добавить" // Кнопка "Добавить"
if (this.elements.addSelectedBtn) { if (this.elements.addSelectedBtn) {
this.elements.addSelectedBtn.disabled = count === 0; this.elements.addSelectedBtn.disabled = !this.state.selected;
} }
}; };
/** /**
* Выбрать все видимые товары
*/
ProductSearchPicker.prototype._selectAll = function() {
var self = this;
var currentCount = Object.keys(this.state.selected).length;
var maxToSelect = this.options.maxSelection
? this.options.maxSelection - currentCount
: Infinity;
var added = 0;
this.state.products.forEach(function(product) {
if (added >= maxToSelect) return;
var productId = String(product.id).replace('product_', '');
if (!self.state.selected.hasOwnProperty(productId)) {
self.state.selected[productId] = product;
self._updateProductUI(productId);
added++;
}
});
this._updateSelectionUI();
if (this.options.onSelectionChange) {
this.options.onSelectionChange(this.getSelected(), this);
}
};
/**
* Обновление кнопок переключателя вида * Обновление кнопок переключателя вида
*/ */
ProductSearchPicker.prototype._updateViewButtons = function() { ProductSearchPicker.prototype._updateViewButtons = function() {
@@ -625,68 +561,25 @@
// ========== ПУБЛИЧНОЕ API ========== // ========== ПУБЛИЧНОЕ API ==========
/** /**
* Получить массив выбранных товаров * Получить выбранный товар (объект или null)
*/ */
ProductSearchPicker.prototype.getSelected = function() { ProductSearchPicker.prototype.getSelected = function() {
var result = []; return this.state.selected;
for (var key in this.state.selected) {
if (this.state.selected.hasOwnProperty(key)) {
result.push(this.state.selected[key]);
}
}
return result;
};
/**
* Получить массив ID выбранных товаров
*/
ProductSearchPicker.prototype.getSelectedIds = function() {
return Object.keys(this.state.selected);
}; };
/** /**
* Сбросить выбор * Сбросить выбор
*/ */
ProductSearchPicker.prototype.clearSelection = function() { ProductSearchPicker.prototype.clearSelection = function() {
var self = this; if (this.state.selected) {
var ids = Object.keys(this.state.selected); var productId = this.state.selected.id;
this.state.selected = {}; this.state.selected = null;
this._updateProductUI(productId);
ids.forEach(function(productId) {
self._updateProductUI(productId);
});
this._updateSelectionUI();
if (this.options.onSelectionChange) {
this.options.onSelectionChange([], this);
} }
};
/**
* Установить выбор программно
*/
ProductSearchPicker.prototype.setSelection = function(productIds) {
var self = this;
this.clearSelection();
productIds.forEach(function(id) {
var productId = String(id).replace('product_', '');
// Ищем товар в загруженном списке
for (var i = 0; i < self.state.products.length; i++) {
var p = self.state.products[i];
if (String(p.id).replace('product_', '') === productId) {
self.state.selected[productId] = p;
self._updateProductUI(productId);
break;
}
}
});
this._updateSelectionUI(); this._updateSelectionUI();
}; };
/** /**
* Обновить список товаров * Обновить список товаров
*/ */
ProductSearchPicker.prototype.refresh = function() { ProductSearchPicker.prototype.refresh = function() {

View File

@@ -1,18 +1,15 @@
{% comment %} {% comment %}
Переиспользуемый компонент поиска и выбора штучных товаров (Product). Переиспользуемый компонент поиска и выбора товара (Product).
Только единичный выбор (single-select).
Параметры (Django template variables): Параметры (Django template variables):
- container_id: уникальный ID контейнера (default: 'product-search-picker') - container_id: уникальный ID контейнера (default: 'product-search-picker')
- title: заголовок компонента (default: 'Выбор товаров') - title: заголовок компонента (default: 'Выбор товара')
- api_url: URL для AJAX поиска (default: '/products/api/search-products-variants/') - api_url: URL для AJAX поиска (default: '/products/api/search-products-variants/')
- show_filters: показывать фильтры (default: True) - show_filters: показывать фильтры (default: True)
- show_view_toggle: показывать переключатель вида (default: True) - show_view_toggle: показывать переключатель вида (default: True)
- show_select_all: показывать кнопку "Выбрать все" (default: True) - add_button_text: текст кнопки добавления (default: 'Выбрать товар')
- show_add_button: показывать кнопку "Добавить выбранные" (default: True)
- add_button_text: текст кнопки добавления (default: 'Добавить выбранные')
- initial_view: начальный вид 'grid' или 'list' (default: 'list') - initial_view: начальный вид 'grid' или 'list' (default: 'list')
- multi_select: множественный выбор (default: True)
- max_selection: максимальное количество выбранных товаров (default: null)
- filter_in_stock_only: показывать только товары в наличии (default: False) - filter_in_stock_only: показывать только товары в наличии (default: False)
- warehouse_id: ID склада для фильтрации товаров (default: None) - warehouse_id: ID склада для фильтрации товаров (default: None)
- categories: список категорий для фильтра (queryset или list) - categories: список категорий для фильтра (queryset или list)
@@ -22,7 +19,7 @@
Пример использования: Пример использования:
{% include 'products/components/product_search_picker.html' with {% include 'products/components/product_search_picker.html' with
container_id='writeoff-products' container_id='writeoff-products'
title='Выберите товары для списания' title='Выберите товар для списания'
show_filters=True show_filters=True
filter_in_stock_only=True filter_in_stock_only=True
categories=categories categories=categories
@@ -31,9 +28,9 @@
После включения инициализируйте JS: После включения инициализируйте JS:
<script> <script>
ProductSearchPicker.init('#writeoff-products', { ProductSearchPicker.init('#writeoff-products', {
onAddSelected: function(products, instance) { onAddSelected: function(product, instance) {
// products = [{id, text, sku, price, in_stock, photo_url}, ...] // product = {id, text, sku, price, in_stock, photo_url}
products.forEach(addToForm); addToForm(product);
instance.clearSelection(); instance.clearSelection();
} }
}); });
@@ -45,8 +42,6 @@ ProductSearchPicker.init('#writeoff-products', {
<div class="product-search-picker" <div class="product-search-picker"
id="{{ container_id|default:'product-search-picker' }}" id="{{ container_id|default:'product-search-picker' }}"
data-api-url="{{ api_url|default:'/products/api/search-products-variants/' }}" data-api-url="{{ api_url|default:'/products/api/search-products-variants/' }}"
data-multi-select="{{ multi_select|default:'true' }}"
data-max-selection="{{ max_selection|default:'' }}"
data-exclude-kits="true" data-exclude-kits="true"
{% if warehouse_id %}data-warehouse-id="{{ warehouse_id }}"{% endif %}> {% if warehouse_id %}data-warehouse-id="{{ warehouse_id }}"{% endif %}>
@@ -55,7 +50,7 @@ ProductSearchPicker.init('#writeoff-products', {
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center flex-wrap gap-2"> <div class="card-header bg-white py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
<strong> <strong>
<i class="bi bi-box-seam text-primary"></i> <i class="bi bi-box-seam text-primary"></i>
{{ title|default:'Выбор товаров' }} {{ title|default:'Выбор товара' }}
</strong> </strong>
<div class="d-flex gap-2 align-items-center flex-wrap"> <div class="d-flex gap-2 align-items-center flex-wrap">
@@ -128,11 +123,7 @@ ProductSearchPicker.init('#writeoff-products', {
</div> </div>
</div> </div>
<!-- Счетчик выбранных --> <!-- Счетчик выбранных - УБРАН в single-select режиме -->
<div class="col-auto ms-auto">
<span class="badge bg-primary product-picker-selected-count">0</span>
<span class="text-muted small">выбрано</span>
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -158,27 +149,14 @@ ProductSearchPicker.init('#writeoff-products', {
</div> </div>
</div> </div>
<!-- Футер с кнопками действий --> <!-- Футер с кнопкой действия -->
<div class="card-footer bg-white py-2 d-flex justify-content-between align-items-center flex-wrap gap-2"> <div class="card-footer bg-white py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
{% if show_select_all|default:True %}
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm product-picker-select-all">
<i class="bi bi-check2-all"></i> Выбрать все
</button>
<button class="btn btn-outline-secondary btn-sm product-picker-clear-selection">
<i class="bi bi-x-lg"></i> Сбросить
</button>
</div>
{% else %}
<div></div> <div></div>
{% endif %}
{% if show_add_button|default:True %}
<button class="btn btn-primary btn-sm product-picker-add-selected" disabled> <button class="btn btn-primary btn-sm product-picker-add-selected" disabled>
<i class="bi bi-plus-circle"></i> <i class="bi bi-plus-circle"></i>
{{ add_button_text|default:'Добавить выбранные' }} {{ add_button_text|default:'Выбрать товар' }}
</button> </button>
{% endif %}
</div> </div>
</div> </div>
</div> </div>