Создан переиспользуемый компонент поиска товаров

- product_search_picker.html - универсальный шаблон компонента поиска и выбора товаров
- product-search-picker.js - JavaScript модуль с поддержкой фильтрации по категориям, тегам, наличию
- product-search-picker.css - стили для компонента
- Поддержка одиночного и множественного выбора товаров
- Фильтрация по категориям, тегам и наличию на складе
- Отображение фото товара в результатах поиска
- Адаптивный интерфейс с прокруткой для больших списков
- API для программного управления (init, search, clearSelection и др.)
- Возможность кастомизации через параметры (заголовок, высота, текст кнопок)
This commit is contained in:
2025-12-10 23:36:13 +03:00
parent 96e04ca4b7
commit ccab09fb40
3 changed files with 1099 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
/**
* ProductSearchPicker - стили компонента поиска и выбора товаров
*/
.product-search-picker .product-picker-item {
transition: all 0.15s ease;
border: 2px solid transparent;
}
.product-search-picker .product-picker-item:hover {
border-color: #dee2e6;
background-color: #f8f9fa;
}
.product-search-picker .product-picker-item.selected {
border-color: #198754 !important;
background-color: #d1e7dd !important;
}
.product-search-picker .product-picker-item.selected .card {
border-color: #198754;
}
/* Grid view - карточки */
.product-search-picker .product-picker-grid[data-view="grid"] {
display: flex;
flex-wrap: wrap;
}
.product-search-picker .product-picker-grid[data-view="grid"] .product-picker-item .card {
transition: transform 0.15s, box-shadow 0.15s;
}
.product-search-picker .product-picker-grid[data-view="grid"] .product-picker-item:hover .card {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.product-search-picker .product-picker-grid[data-view="grid"] .product-picker-item.selected .card {
box-shadow: 0 0 0 2px #198754;
}
/* List view - список */
.product-search-picker .product-picker-grid[data-view="list"] {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.product-search-picker .product-picker-grid[data-view="list"] .col-6,
.product-search-picker .product-picker-grid[data-view="list"] .col-lg-4,
.product-search-picker .product-picker-grid[data-view="list"] .col-xl-3 {
flex: 0 0 100%;
max-width: 100%;
padding: 0;
}
/* Loading spinner */
.product-search-picker .product-picker-loading {
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
/* Контент - scrollable */
.product-search-picker .product-picker-content {
position: relative;
min-height: 150px;
}
/* Фильтры */
.product-search-picker .product-picker-filters {
background-color: #f8f9fa;
}
/* Счетчик выбранных */
.product-search-picker .product-picker-selected-count {
min-width: 24px;
text-align: center;
}
/* Иконка выбора в list view */
.product-search-picker .product-picker-grid[data-view="list"] .bi-circle,
.product-search-picker .product-picker-grid[data-view="list"] .bi-check-circle-fill {
font-size: 1.2rem;
flex-shrink: 0;
}
/* Иконка выбора в grid view */
.product-search-picker .product-picker-grid[data-view="grid"] .check-icon {
position: absolute;
top: 0.5rem;
right: 0.5rem;
font-size: 1.5rem;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
z-index: 1;
}
/* Пустой результат */
.product-search-picker .product-picker-empty {
min-height: 150px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* Кнопка очистки поиска */
.product-search-picker .product-picker-search-clear {
border-left: 0;
}
/* Hover эффект для кнопок действий */
.product-search-picker .card-footer .btn {
transition: all 0.15s;
}
/* Адаптивность */
@media (max-width: 768px) {
.product-search-picker .product-picker-grid[data-view="grid"] .col-6 {
flex: 0 0 50%;
max-width: 50%;
}
.product-search-picker .card-header .d-flex {
flex-direction: column;
align-items: stretch !important;
gap: 0.5rem;
}
.product-search-picker .card-header .input-group {
width: 100% !important;
}
.product-search-picker .card-footer {
flex-direction: column;
gap: 0.5rem;
}
.product-search-picker .card-footer > div {
width: 100%;
display: flex;
gap: 0.5rem;
}
.product-search-picker .card-footer .btn {
flex: 1;
}
}
@media (max-width: 576px) {
.product-search-picker .product-picker-grid[data-view="grid"] .col-6 {
flex: 0 0 100%;
max-width: 100%;
}
.product-search-picker .product-picker-filters .row {
flex-direction: column;
}
.product-search-picker .product-picker-filters .col-auto {
width: 100%;
}
.product-search-picker .product-picker-filters .col-auto.ms-auto {
margin-left: 0 !important;
margin-top: 0.5rem;
}
}

View File

@@ -0,0 +1,747 @@
/**
* ProductSearchPicker - переиспользуемый модуль для поиска и выбора товаров
*
* API:
* - ProductSearchPicker.init(containerSelector, options) - инициализация
* - instance.getSelected() - получить выбранные товары (массив объектов)
* - instance.getSelectedIds() - получить ID выбранных товаров (массив строк)
* - instance.clearSelection() - сбросить выбор
* - instance.setSelection(productIds) - установить выбор программно
* - instance.refresh() - обновить список товаров
* - instance.destroy() - уничтожить экземпляр
*
* Пример использования:
* const picker = ProductSearchPicker.init('#my-picker', {
* onAddSelected: function(products, instance) {
* console.log('Добавлены товары:', products);
* }
* });
*/
(function(window) {
'use strict';
// Приватное хранилище экземпляров
var instances = {};
/**
* Конструктор компонента
*/
function ProductSearchPicker(container, options) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
if (!this.container) {
console.error('ProductSearchPicker: container not found');
return;
}
// Настройки по умолчанию
this.options = Object.assign({
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',
debounceDelay: 300,
pageSize: 30,
// Callbacks
onSelect: null, // (products, instance) => {}
onDeselect: null, // (products, instance) => {}
onSelectionChange: null, // (selectedProducts, instance) => {}
onAddSelected: null // (selectedProducts, instance) => {}
}, options || {});
// Состояние
this.state = {
products: [],
selected: {}, // {productId: productData}
currentPage: 1,
hasMore: false,
isLoading: false,
currentView: 'list',
filters: {
search: '',
category: '',
tag: '',
inStock: false
}
};
// Кэшируем DOM элементы
this._cacheElements();
// Привязываем обработчики
this._bindEvents();
// Начальная загрузка
this._loadProducts();
}
/**
* Кэширование DOM элементов
*/
ProductSearchPicker.prototype._cacheElements = function() {
var c = this.container;
this.elements = {
searchInput: c.querySelector('.product-picker-search'),
searchClear: c.querySelector('.product-picker-search-clear'),
categorySelect: c.querySelector('.product-picker-category'),
tagSelect: c.querySelector('.product-picker-tag'),
inStockCheckbox: c.querySelector('.product-picker-in-stock'),
grid: c.querySelector('.product-picker-grid'),
loading: c.querySelector('.product-picker-loading'),
empty: c.querySelector('.product-picker-empty'),
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'),
viewButtons: c.querySelectorAll('.product-picker-view')
};
// Начальный вид из data-атрибута
if (this.elements.grid) {
var initialView = this.elements.grid.dataset.view || 'list';
this.state.currentView = initialView;
this._updateViewButtons();
}
};
/**
* Привязка обработчиков событий
*/
ProductSearchPicker.prototype._bindEvents = function() {
var self = this;
var debounceTimer;
// Поиск с debounce
if (this.elements.searchInput) {
this.elements.searchInput.addEventListener('input', function() {
clearTimeout(debounceTimer);
var value = this.value.trim();
// Показать/скрыть кнопку очистки
if (self.elements.searchClear) {
self.elements.searchClear.style.display = value ? 'block' : 'none';
}
debounceTimer = setTimeout(function() {
self.state.filters.search = value;
self.state.currentPage = 1;
self._loadProducts();
}, self.options.debounceDelay);
});
// Поиск по Enter
this.elements.searchInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
clearTimeout(debounceTimer);
self.state.filters.search = this.value.trim();
self.state.currentPage = 1;
self._loadProducts();
}
});
}
// Кнопка очистки поиска
if (this.elements.searchClear) {
this.elements.searchClear.addEventListener('click', function() {
self.elements.searchInput.value = '';
this.style.display = 'none';
self.state.filters.search = '';
self.state.currentPage = 1;
self._loadProducts();
});
}
// Фильтр по категории
if (this.elements.categorySelect) {
this.elements.categorySelect.addEventListener('change', function() {
self.state.filters.category = this.value;
self.state.currentPage = 1;
self._loadProducts();
});
}
// Фильтр по тегу
if (this.elements.tagSelect) {
this.elements.tagSelect.addEventListener('change', function() {
self.state.filters.tag = this.value;
self.state.currentPage = 1;
self._loadProducts();
});
}
// Фильтр по наличию
if (this.elements.inStockCheckbox) {
this.elements.inStockCheckbox.addEventListener('change', function() {
self.state.filters.inStock = this.checked;
self.state.currentPage = 1;
self._loadProducts();
});
// Инициализация из checkbox
this.state.filters.inStock = this.elements.inStockCheckbox.checked;
}
// Переключатель вида
this.elements.viewButtons.forEach(function(btn) {
btn.addEventListener('click', function() {
var view = this.dataset.view;
self._setView(view);
});
});
// Клик по товару (делегирование)
if (this.elements.grid) {
this.elements.grid.addEventListener('click', function(e) {
var productCard = e.target.closest('.product-picker-item');
if (productCard) {
var productId = productCard.dataset.productId;
self._toggleProduct(productId);
}
});
}
// Выбрать все
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) {
this.elements.addSelectedBtn.addEventListener('click', function() {
if (self.options.onAddSelected) {
self.options.onAddSelected(self.getSelected(), self);
}
});
}
// Infinite scroll
if (this.elements.content) {
this.elements.content.addEventListener('scroll', function() {
if (self.state.hasMore && !self.state.isLoading) {
var scrollTop = this.scrollTop;
var scrollHeight = this.scrollHeight;
var clientHeight = this.clientHeight;
if (scrollTop + clientHeight >= scrollHeight - 100) {
self.state.currentPage++;
self._loadProducts(true); // append mode
}
}
});
}
};
/**
* Загрузка товаров через AJAX
*/
ProductSearchPicker.prototype._loadProducts = function(append) {
var self = this;
if (this.state.isLoading) return;
this.state.isLoading = true;
// Показать индикатор загрузки
if (!append) {
if (this.elements.loading) this.elements.loading.style.display = 'flex';
if (this.elements.grid) this.elements.grid.innerHTML = '';
if (this.elements.empty) this.elements.empty.style.display = 'none';
}
// Формируем параметры запроса
var params = new URLSearchParams({
type: 'product', // Только штучные товары
page: this.state.currentPage
});
if (this.state.filters.search) {
params.append('q', this.state.filters.search);
}
if (this.state.filters.category) {
params.append('category', this.state.filters.category);
}
if (this.state.filters.tag) {
params.append('tag', this.state.filters.tag);
}
if (this.state.filters.inStock) {
params.append('in_stock', 'true');
}
fetch(this.options.apiUrl + '?' + params.toString())
.then(function(response) {
return response.json();
})
.then(function(data) {
self.state.isLoading = false;
if (self.elements.loading) self.elements.loading.style.display = 'none';
// Извлекаем товары (учитываем группировку в API)
var products = [];
if (Array.isArray(data.results)) {
data.results.forEach(function(item) {
if (item.children) {
// Группированные результаты
item.children.forEach(function(p) {
if (p.type === 'product') {
products.push(p);
}
});
} else if (item.type === 'product') {
products.push(item);
}
});
}
if (append) {
self.state.products = self.state.products.concat(products);
} else {
self.state.products = products;
}
self.state.hasMore = data.pagination && data.pagination.more;
// Рендеринг
self._renderProducts(products, append);
// Показать "пусто" если нет результатов
if (self.state.products.length === 0) {
if (self.elements.empty) self.elements.empty.style.display = 'flex';
} else {
if (self.elements.empty) self.elements.empty.style.display = 'none';
}
})
.catch(function(error) {
console.error('ProductSearchPicker: fetch error', error);
self.state.isLoading = false;
if (self.elements.loading) self.elements.loading.style.display = 'none';
});
};
/**
* Рендеринг товаров
*/
ProductSearchPicker.prototype._renderProducts = function(products, append) {
var self = this;
var fragment = document.createDocumentFragment();
products.forEach(function(product) {
// Извлекаем ID (убираем префикс "product_")
var productId = String(product.id).replace('product_', '');
var isSelected = self.state.selected.hasOwnProperty(productId);
var html = self.state.currentView === 'grid'
? self._renderGridCard(product, productId, isSelected)
: self._renderListItem(product, productId, isSelected);
var temp = document.createElement('div');
temp.innerHTML = html;
fragment.appendChild(temp.firstElementChild);
});
if (append) {
this.elements.grid.appendChild(fragment);
} else {
this.elements.grid.innerHTML = '';
this.elements.grid.appendChild(fragment);
}
};
/**
* Рендеринг карточки товара (grid view)
*/
ProductSearchPicker.prototype._renderGridCard = function(product, productId, isSelected) {
var inStockBadge = product.in_stock
? '<span class="badge bg-success" style="font-size: 0.65rem;">В наличии</span>'
: '<span class="badge bg-secondary" style="font-size: 0.65rem;">Нет</span>';
var selectedClass = isSelected ? 'selected' : '';
var checkIcon = isSelected
? '<i class="bi bi-check-circle-fill text-success check-icon"></i>'
: '';
var photoHtml = product.photo_url
? '<img src="' + product.photo_url + '" class="card-img-top" style="height: 100px; object-fit: cover;" alt="' + (product.text || '') + '">'
: '<div class="bg-light d-flex align-items-center justify-content-center" style="height: 100px;"><i class="bi bi-image text-muted fs-2"></i></div>';
var name = product.text || product.name || '';
var price = product.actual_price || product.price || '0';
return '<div class="col-6 col-lg-4 col-xl-3">' +
'<div class="card product-picker-item h-100 shadow-sm ' + selectedClass + '" ' +
'data-product-id="' + productId + '" style="cursor: pointer;">' +
'<div class="position-relative">' +
photoHtml +
checkIcon +
'</div>' +
'<div class="card-body p-2">' +
'<div class="small text-truncate fw-medium">' + name + '</div>' +
'<div class="d-flex justify-content-between align-items-center mt-1">' +
'<span class="fw-bold text-primary small">' + price + ' руб.</span>' +
inStockBadge +
'</div>' +
'<small class="text-muted">' + (product.sku || '') + '</small>' +
'</div>' +
'</div>' +
'</div>';
};
/**
* Рендеринг строки товара (list view)
*/
ProductSearchPicker.prototype._renderListItem = function(product, productId, isSelected) {
var inStockBadge = product.in_stock
? '<span class="badge bg-success">В наличии</span>'
: '<span class="badge bg-secondary">Нет</span>';
var selectedClass = isSelected ? 'selected' : '';
var checkIcon = isSelected
? '<i class="bi bi-check-circle-fill text-success me-2"></i>'
: '<i class="bi bi-circle text-muted me-2"></i>';
var photoHtml = product.photo_url
? '<img src="' + product.photo_url + '" class="rounded" style="width: 40px; height: 40px; object-fit: cover;">'
: '<div class="bg-light rounded d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;"><i class="bi bi-image text-muted"></i></div>';
var name = product.text || product.name || '';
var price = product.actual_price || product.price || '0';
return '<div class="col-12">' +
'<div class="product-picker-item d-flex align-items-center p-2 border rounded ' + selectedClass + '" ' +
'data-product-id="' + productId + '" style="cursor: pointer;">' +
checkIcon +
'<div class="flex-shrink-0 me-2">' +
photoHtml +
'</div>' +
'<div class="flex-grow-1" style="min-width: 0;">' +
'<div class="text-truncate fw-medium">' + name + '</div>' +
'<small class="text-muted">' + (product.sku || '') + '</small>' +
'</div>' +
'<div class="text-end ms-2 text-nowrap">' +
'<div class="fw-bold text-primary">' + price + ' руб.</div>' +
inStockBadge +
'</div>' +
'</div>' +
'</div>';
};
/**
* Переключение выбора товара
*/
ProductSearchPicker.prototype._toggleProduct = function(productId) {
var self = this;
var product = null;
// Находим товар в списке
for (var i = 0; i < this.state.products.length; i++) {
var p = this.state.products[i];
if (String(p.id).replace('product_', '') === productId) {
product = p;
break;
}
}
if (!product) return;
var isSelected = this.state.selected.hasOwnProperty(productId);
if (isSelected) {
// Снять выбор
delete this.state.selected[productId];
if (this.options.onDeselect) {
this.options.onDeselect([product], this);
}
} else {
// Проверка множественного выбора
if (!this.options.multiSelect) {
this.state.selected = {};
}
// Проверка лимита
if (this.options.maxSelection &&
Object.keys(this.state.selected).length >= this.options.maxSelection) {
return;
}
// Добавить в выбор
this.state.selected[productId] = product;
if (this.options.onSelect) {
this.options.onSelect([product], this);
}
}
// Обновить UI
this._updateProductUI(productId);
this._updateSelectionUI();
// Общий callback
if (this.options.onSelectionChange) {
this.options.onSelectionChange(this.getSelected(), this);
}
};
/**
* Обновление UI товара
*/
ProductSearchPicker.prototype._updateProductUI = function(productId) {
var card = this.elements.grid.querySelector('[data-product-id="' + productId + '"]');
if (!card) return;
var isSelected = this.state.selected.hasOwnProperty(productId);
if (isSelected) {
card.classList.add('selected');
// Обновить иконку
var icon = card.querySelector('.bi-circle');
if (icon) {
icon.classList.remove('bi-circle', 'text-muted');
icon.classList.add('bi-check-circle-fill', 'text-success');
}
// Добавить иконку в grid view если её нет
if (this.state.currentView === 'grid') {
var posRel = card.querySelector('.position-relative');
if (posRel && !posRel.querySelector('.check-icon')) {
var checkEl = document.createElement('i');
checkEl.className = 'bi bi-check-circle-fill text-success check-icon';
posRel.appendChild(checkEl);
}
}
} else {
card.classList.remove('selected');
var iconCheck = card.querySelector('.bi-check-circle-fill');
if (iconCheck) {
// В list view меняем иконку
if (this.state.currentView === 'list') {
iconCheck.classList.remove('bi-check-circle-fill', 'text-success');
iconCheck.classList.add('bi-circle', 'text-muted');
} else {
// В grid view удаляем иконку
if (iconCheck.classList.contains('check-icon')) {
iconCheck.remove();
}
}
}
}
};
/**
* Обновление UI выбора (счетчик, кнопки)
*/
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) {
this.elements.addSelectedBtn.disabled = count === 0;
}
};
/**
* Выбрать все видимые товары
*/
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() {
var self = this;
this.elements.viewButtons.forEach(function(btn) {
if (btn.dataset.view === self.state.currentView) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
};
/**
* Переключение вида (grid/list)
*/
ProductSearchPicker.prototype._setView = function(view) {
this.state.currentView = view;
// Обновить data-атрибут
if (this.elements.grid) {
this.elements.grid.dataset.view = view;
}
// Обновить кнопки
this._updateViewButtons();
// Перерисовать
this._renderProducts(this.state.products, false);
};
// ========== ПУБЛИЧНОЕ API ==========
/**
* Получить массив выбранных товаров
*/
ProductSearchPicker.prototype.getSelected = function() {
var result = [];
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() {
var self = this;
var ids = Object.keys(this.state.selected);
this.state.selected = {};
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();
};
/**
* Обновить список товаров
*/
ProductSearchPicker.prototype.refresh = function() {
this.state.currentPage = 1;
this._loadProducts();
};
/**
* Уничтожить экземпляр
*/
ProductSearchPicker.prototype.destroy = function() {
// Очистить состояние
this.state.products = [];
this.state.selected = {};
// Удалить из хранилища
if (this.container.id && instances[this.container.id]) {
delete instances[this.container.id];
}
};
// ========== СТАТИЧЕСКИЕ МЕТОДЫ ==========
/**
* Инициализация компонента
* @param {string|Element} container - селектор или DOM элемент
* @param {Object} options - настройки
* @returns {ProductSearchPicker}
*/
ProductSearchPicker.init = function(container, options) {
var element = typeof container === 'string'
? document.querySelector(container)
: container;
if (!element) {
console.error('ProductSearchPicker.init: container not found');
return null;
}
// Проверяем, не инициализирован ли уже
if (element.id && instances[element.id]) {
return instances[element.id];
}
var instance = new ProductSearchPicker(element, options);
if (element.id) {
instances[element.id] = instance;
}
return instance;
};
/**
* Получить экземпляр по контейнеру
*/
ProductSearchPicker.getInstance = function(container) {
var element = typeof container === 'string'
? document.querySelector(container)
: container;
return element && element.id ? instances[element.id] : null;
};
// Экспорт в глобальную область
window.ProductSearchPicker = ProductSearchPicker;
})(window);

View File

@@ -0,0 +1,182 @@
{% comment %}
Переиспользуемый компонент поиска и выбора штучных товаров (Product).
Параметры (Django template variables):
- container_id: уникальный ID контейнера (default: 'product-search-picker')
- title: заголовок компонента (default: 'Выбор товаров')
- api_url: URL для AJAX поиска (default: '/products/api/search-products-variants/')
- show_filters: показывать фильтры (default: True)
- show_view_toggle: показывать переключатель вида (default: True)
- show_select_all: показывать кнопку "Выбрать все" (default: True)
- show_add_button: показывать кнопку "Добавить выбранные" (default: True)
- add_button_text: текст кнопки добавления (default: 'Добавить выбранные')
- initial_view: начальный вид 'grid' или 'list' (default: 'list')
- multi_select: множественный выбор (default: True)
- max_selection: максимальное количество выбранных товаров (default: null)
- filter_in_stock_only: показывать только товары в наличии (default: False)
- categories: список категорий для фильтра (queryset или list)
- tags: список тегов для фильтра (queryset или list)
- content_height: высота контейнера с товарами (default: '400px')
Пример использования:
{% include 'products/components/product_search_picker.html' with
container_id='writeoff-products'
title='Выберите товары для списания'
show_filters=True
filter_in_stock_only=True
categories=categories
%}
После включения инициализируйте JS:
<script>
ProductSearchPicker.init('#writeoff-products', {
onAddSelected: function(products, instance) {
// products = [{id, text, sku, price, in_stock, photo_url}, ...]
products.forEach(addToForm);
instance.clearSelection();
}
});
</script>
{% endcomment %}
{% load static %}
<div class="product-search-picker"
id="{{ container_id|default:'product-search-picker' }}"
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">
<div class="card shadow-sm">
<!-- Заголовок -->
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center flex-wrap gap-2">
<strong>
<i class="bi bi-box-seam text-primary"></i>
{{ title|default:'Выбор товаров' }}
</strong>
<div class="d-flex gap-2 align-items-center flex-wrap">
<!-- Поиск -->
<div class="input-group" style="width: 250px;">
<input type="text"
class="form-control form-control-sm product-picker-search"
placeholder="Поиск по названию, артикулу...">
<button class="btn btn-outline-secondary btn-sm product-picker-search-clear"
type="button" style="display: none;">
<i class="bi bi-x-lg"></i>
</button>
</div>
{% if show_view_toggle|default:True %}
<!-- Переключатель вида -->
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary product-picker-view"
data-view="grid" title="Карточки">
<i class="bi bi-grid-3x3-gap"></i>
</button>
<button class="btn btn-outline-secondary product-picker-view"
data-view="list" title="Список">
<i class="bi bi-list-ul"></i>
</button>
</div>
{% endif %}
</div>
</div>
{% if show_filters|default:True %}
<!-- Фильтры -->
<div class="card-body border-bottom py-2 product-picker-filters">
<div class="row g-2 align-items-center">
{% if categories %}
<!-- Фильтр по категории -->
<div class="col-auto">
<select class="form-select form-select-sm product-picker-category">
<option value="">Все категории</option>
{% for cat in categories %}
<option value="{{ cat.id }}">{{ cat.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if tags %}
<!-- Фильтр по тегам -->
<div class="col-auto">
<select class="form-select form-select-sm product-picker-tag">
<option value="">Все теги</option>
{% for tag in tags %}
<option value="{{ tag.id }}">{{ tag.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<!-- Фильтр по наличию -->
<div class="col-auto">
<div class="form-check form-switch">
<input class="form-check-input product-picker-in-stock"
type="checkbox"
id="{{ container_id|default:'product-search-picker' }}-in-stock"
{% if filter_in_stock_only|default:False %}checked{% endif %}>
<label class="form-check-label small"
for="{{ container_id|default:'product-search-picker' }}-in-stock">
Только в наличии
</label>
</div>
</div>
<!-- Счетчик выбранных -->
<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>
{% endif %}
<!-- Контент: сетка/список товаров -->
<div class="card-body product-picker-content" style="max-height: {{ content_height|default:'400px' }}; overflow-y: auto;">
<!-- Индикатор загрузки -->
<div class="product-picker-loading text-center py-4" style="display: none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
<!-- Сетка товаров -->
<div class="row g-2 product-picker-grid" data-view="{{ initial_view|default:'list' }}">
<!-- Товары загружаются через AJAX -->
</div>
<!-- Пустой результат -->
<div class="product-picker-empty text-center py-4 text-muted" style="display: none;">
<i class="bi bi-search fs-1 opacity-25"></i>
<p class="mb-0 mt-2">Товары не найдены</p>
</div>
</div>
<!-- Футер с кнопками действий -->
<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>
{% endif %}
{% if show_add_button|default:True %}
<button class="btn btn-primary btn-sm product-picker-add-selected" disabled>
<i class="bi bi-plus-circle"></i>
{{ add_button_text|default:'Добавить выбранные' }}
</button>
{% endif %}
</div>
</div>
</div>