Добавить функционал массового изменения категорий товаров

- Добавлен UI для пакетного выбора товаров с чекбоксами
- Реализована возможность выбора всех товаров на странице
- Реализована возможность выбора всех отфильтрованных товаров
- Добавлено модальное окно для массового управления категориями
- Добавлены API эндпоинты: get_filtered_items_ids, bulk_update_categories
- Реализованы три режима работы с категориями: добавление, замена, очистка
- Добавлен селектор количества элементов на странице (20/50/100)
- Улучшена информативность о количестве выбранных элементов
This commit is contained in:
2026-01-07 09:15:53 +03:00
parent d5c1ed1e4b
commit 161f65e6c3
6 changed files with 1297 additions and 13 deletions

View File

@@ -0,0 +1,336 @@
/**
* Batch Selection Manager for Product List
* Handles checkbox selection, row highlighting, and counter updates
*/
(function() {
'use strict';
// Selection state
let selectedItems = new Set();
// DOM elements
const selectAllCheckbox = document.getElementById('select-all-checkbox');
const batchActionsBtn = document.getElementById('batch-actions-btn');
const batchActionsDropdown = document.getElementById('batch-actions-dropdown');
let selectionCountSpan = document.getElementById('selection-count');
/**
* Initialize the batch selection functionality
*/
function init() {
if (!selectAllCheckbox) {
console.warn('Batch selection: select-all checkbox not found');
return;
}
// Attach event listeners
selectAllCheckbox.addEventListener('change', handleSelectAllChange);
// Attach listeners to all item checkboxes
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
itemCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', handleItemCheckboxChange);
});
// Initialize state
updateUI();
}
/**
* Handle "Select All" checkbox change
*/
function handleSelectAllChange(event) {
const isChecked = event.target.checked;
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
itemCheckboxes.forEach(checkbox => {
checkbox.checked = isChecked;
const itemValue = checkbox.value;
if (isChecked) {
selectedItems.add(itemValue);
highlightRow(checkbox, true);
} else {
selectedItems.delete(itemValue);
highlightRow(checkbox, false);
}
});
updateUI();
}
/**
* Handle individual item checkbox change
*/
function handleItemCheckboxChange(event) {
const checkbox = event.target;
const itemValue = checkbox.value;
const isChecked = checkbox.checked;
if (isChecked) {
selectedItems.add(itemValue);
highlightRow(checkbox, true);
} else {
selectedItems.delete(itemValue);
highlightRow(checkbox, false);
}
// Update "Select All" checkbox state
updateSelectAllCheckbox();
// Update UI
updateUI();
}
/**
* Highlight or unhighlight the row containing the checkbox
*/
function highlightRow(checkbox, highlight) {
const row = checkbox.closest('tr');
if (row) {
if (highlight) {
row.classList.add('table-active');
} else {
row.classList.remove('table-active');
}
}
}
/**
* Update the "Select All" checkbox state based on individual checkboxes
*/
function updateSelectAllCheckbox() {
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
const totalCheckboxes = itemCheckboxes.length;
const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked').length;
if (checkedCheckboxes === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCheckboxes === totalCheckboxes) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = true;
}
}
/**
* Update UI elements based on selection state
*/
function updateUI() {
const count = selectedItems.size;
// Обновляем счётчик (ищем заново на случай, если он был пересоздан)
selectionCountSpan = document.getElementById('selection-count');
if (selectionCountSpan) {
selectionCountSpan.textContent = count;
}
// Enable/disable batch actions button and restore text if needed
const shouldEnable = count > 0;
if (batchActionsBtn) {
batchActionsBtn.disabled = !shouldEnable;
// Если в кнопке нет span#selection-count, значит текст был изменён - восстанавливаем
if (!batchActionsBtn.querySelector('#selection-count')) {
batchActionsBtn.innerHTML = `<i class="bi bi-gear-fill"></i> Действия над выбранными (<span id="selection-count">${count}</span>)`;
selectionCountSpan = document.getElementById('selection-count');
}
}
if (batchActionsDropdown) {
batchActionsDropdown.disabled = !shouldEnable;
}
// Показываем/скрываем dropdown для выбора всех
const selectAllDropdown = document.getElementById('select-all-dropdown-group');
if (selectAllDropdown) {
// Показываем всегда, когда есть элементы на странице
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
if (itemCheckboxes.length > 0) {
selectAllDropdown.style.display = 'block';
}
}
}
/**
* Get currently selected items
* @returns {Array} Array of selected item identifiers
*/
function getSelectedItems() {
return Array.from(selectedItems);
}
/**
* Clear all selections
*/
function clearSelection() {
selectedItems.clear();
// Uncheck all checkboxes
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
itemCheckboxes.forEach(checkbox => {
checkbox.checked = false;
highlightRow(checkbox, false);
});
if (selectAllCheckbox) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
}
updateUI();
}
/**
* Select all filtered items via AJAX request
*/
function selectAllFiltered() {
// Получаем текущие параметры фильтрации из URL
const urlParams = new URLSearchParams(window.location.search);
// Строим URL для API запроса
const apiUrl = '/products/api/filtered-items-ids/?' + urlParams.toString();
// Показываем индикатор загрузки
if (batchActionsBtn) {
batchActionsBtn.disabled = true;
batchActionsBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> Загрузка...';
}
// Выполняем AJAX запрос
fetch(apiUrl, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Очищаем текущий выбор
selectedItems.clear();
// Добавляем все отфильтрованные элементы
data.items.forEach(item => {
const itemValue = `${item.type}:${item.id}`;
selectedItems.add(itemValue);
});
// Отмечаем чекбоксы на текущей странице
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
itemCheckboxes.forEach(checkbox => {
const itemValue = checkbox.value;
if (selectedItems.has(itemValue)) {
checkbox.checked = true;
highlightRow(checkbox, true);
}
});
// Обновляем состояние "Select All" checkbox
updateSelectAllCheckbox();
// Обновляем UI
updateUI();
// Показываем уведомление
showNotification(`Выбрано ${data.count} элементов (включая элементы на других страницах)`, 'success');
} else {
showNotification('Ошибка при загрузке списка элементов: ' + data.error, 'error');
}
})
.catch(error => {
console.error('Error fetching filtered items:', error);
showNotification('Ошибка при загрузке списка элементов', 'error');
})
.finally(() => {
// Восстанавливаем кнопку
updateUI();
});
}
/**
* Show notification to user
*/
function showNotification(message, type = 'info') {
// Создаём toast уведомление (Bootstrap 5)
const toastContainer = document.querySelector('.toast-container') || createToastContainer();
const toastId = 'toast-' + Date.now();
const bgClass = type === 'success' ? 'bg-success' : type === 'error' ? 'bg-danger' : 'bg-info';
const toastHTML = `
<div id="${toastId}" class="toast align-items-center text-white ${bgClass} border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHTML);
const toastElement = document.getElementById(toastId);
const toast = new bootstrap.Toast(toastElement, { delay: 3000 });
toast.show();
// Удаляем элемент после скрытия
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
}
/**
* Create toast container if it doesn't exist
*/
function createToastContainer() {
const container = document.createElement('div');
container.className = 'toast-container position-fixed top-0 end-0 p-3';
container.style.zIndex = '9999';
document.body.appendChild(container);
return container;
}
/**
* Update checkboxes on current page based on selection state
* Called when navigating between pages
*/
function syncCheckboxesWithSelection() {
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
itemCheckboxes.forEach(checkbox => {
const itemValue = checkbox.value;
if (selectedItems.has(itemValue)) {
checkbox.checked = true;
highlightRow(checkbox, true);
}
});
updateSelectAllCheckbox();
updateUI();
}
// Initialize on DOM ready and sync selection state
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
init();
syncCheckboxesWithSelection();
});
} else {
init();
syncCheckboxesWithSelection();
}
// Export public API
window.BatchSelection = {
getSelectedItems: getSelectedItems,
clearSelection: clearSelection,
selectAllFiltered: selectAllFiltered,
syncCheckboxesWithSelection: syncCheckboxesWithSelection
};
})();

View File

@@ -0,0 +1,504 @@
/**
* Bulk Category Assignment Modal
* Handles modal for assigning categories to multiple products/kits
*/
(function() {
'use strict';
// State
let allCategories = [];
let selectedCategoryIds = new Set();
let modalInstance = null;
// DOM elements
const modal = document.getElementById('bulkCategoryModal');
const bulkCategoryAction = document.getElementById('bulk-category-action');
const applyBtn = document.getElementById('applyBulkCategoriesBtn');
const clearAllBtn = document.getElementById('clearAllCategoriesBtn');
const categoryListContainer = document.getElementById('categoryListContainer');
const categorySearchInput = document.getElementById('categorySearchInput');
const clearExistingToggle = document.getElementById('clearExistingCategoriesToggle');
const errorAlert = document.getElementById('bulkCategoryError');
const selectedItemsCountSpan = document.getElementById('selectedItemsCount');
const selectedItemsBreakdownSpan = document.getElementById('selectedItemsBreakdown');
/**
* Initialize the bulk category modal functionality
*/
function init() {
if (!modal || !bulkCategoryAction) {
console.warn('Bulk category modal elements not found');
return;
}
// Initialize Bootstrap modal instance
modalInstance = new bootstrap.Modal(modal);
// Event listeners
bulkCategoryAction.addEventListener('click', handleOpenModal);
applyBtn.addEventListener('click', handleApply);
clearAllBtn.addEventListener('click', handleClearAll);
categorySearchInput.addEventListener('input', handleCategorySearch);
// Listen for modal close to reset state
modal.addEventListener('hidden.bs.modal', resetModalState);
console.log('Bulk category modal initialized');
}
/**
* Handle opening the modal
*/
function handleOpenModal(e) {
e.preventDefault();
// Get selected items from batch selection
const selectedItems = getSelectedItems();
if (selectedItems.length === 0) {
alert('Выберите товары для изменения категорий');
return;
}
// Update modal header with selection info
updateSelectionInfo(selectedItems);
// Reset state
selectedCategoryIds.clear();
hideError();
// Open modal and load categories
modalInstance.show();
loadCategories();
}
/**
* Get selected items from batch selection system
*/
function getSelectedItems() {
const items = [];
// Access the global batch selection state
if (window.BatchSelection && window.BatchSelection.getSelectedItems) {
const selectedItemsArray = window.BatchSelection.getSelectedItems();
selectedItemsArray.forEach(itemKey => {
const [type, id] = itemKey.split(':');
items.push({ type, id: parseInt(id) });
});
} else {
// Fallback: read from checkboxes directly
document.querySelectorAll('.item-checkbox:checked').forEach(checkbox => {
const type = checkbox.dataset.itemType;
const id = parseInt(checkbox.dataset.itemId);
items.push({ type, id });
});
}
return items;
}
/**
* Update selection info in modal
*/
function updateSelectionInfo(items) {
selectedItemsCountSpan.textContent = items.length;
// Calculate breakdown
const products = items.filter(i => i.type === 'product').length;
const kits = items.filter(i => i.type === 'kit').length;
let breakdown = '';
if (products > 0 && kits > 0) {
breakdown = `(${products} товаров, ${kits} комплектов)`;
} else if (products > 0) {
breakdown = `(товары)`;
} else if (kits > 0) {
breakdown = `(комплекты)`;
}
selectedItemsBreakdownSpan.textContent = breakdown;
}
/**
* Load categories from server
*/
async function loadCategories() {
categoryListContainer.innerHTML = `
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
<div class="mt-2 text-muted">Загрузка категорий...</div>
</div>
`;
try {
// Get active categories (we can reuse the filter endpoint or create a dedicated one)
const response = await fetch('/products/api/search-products-variants/?type=category', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
});
if (!response.ok) {
throw new Error('Не удалось загрузить категории');
}
// For now, let's use a simpler approach - get categories from the page context
// In production, you might want a dedicated API endpoint
allCategories = await fetchCategoriesFromDOM();
renderCategoryList(allCategories);
} catch (error) {
console.error('Error loading categories:', error);
categoryListContainer.innerHTML = `
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
Не удалось загрузить категории.
<a href="#" onclick="location.reload()">Обновите страницу</a>
</div>
`;
}
}
/**
* Fetch categories from the page's filter dropdown
*/
function fetchCategoriesFromDOM() {
const categories = [];
const categorySelect = document.getElementById('category');
if (categorySelect) {
categorySelect.querySelectorAll('option').forEach(option => {
if (option.value) { // Skip empty option
categories.push({
id: parseInt(option.value),
name: option.textContent.trim()
});
}
});
}
return categories;
}
/**
* Render category list with checkboxes
*/
function renderCategoryList(categories) {
if (categories.length === 0) {
categoryListContainer.innerHTML = `
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle"></i>
Нет доступных категорий.
<a href="/products/categories/create/" target="_blank">Создать категорию</a>
</div>
`;
return;
}
let html = '<div class="list-group list-group-flush">';
categories.forEach(category => {
const isChecked = selectedCategoryIds.has(category.id);
html += `
<div class="list-group-item list-group-item-action p-2">
<div class="form-check">
<input class="form-check-input category-checkbox"
type="checkbox"
value="${category.id}"
id="category_${category.id}"
${isChecked ? 'checked' : ''}>
<label class="form-check-label w-100" for="category_${category.id}">
${category.name}
</label>
</div>
</div>
`;
});
html += '</div>';
categoryListContainer.innerHTML = html;
// Add event listeners to checkboxes
categoryListContainer.querySelectorAll('.category-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', handleCategoryToggle);
});
updateApplyButtonState();
}
/**
* Handle category checkbox toggle
*/
function handleCategoryToggle(e) {
const categoryId = parseInt(e.target.value);
if (e.target.checked) {
selectedCategoryIds.add(categoryId);
} else {
selectedCategoryIds.delete(categoryId);
}
updateApplyButtonState();
}
/**
* Handle category search
*/
function handleCategorySearch(e) {
const searchTerm = e.target.value.toLowerCase().trim();
if (searchTerm === '') {
renderCategoryList(allCategories);
} else {
const filtered = allCategories.filter(cat =>
cat.name.toLowerCase().includes(searchTerm)
);
renderCategoryList(filtered);
}
}
/**
* Update apply button state
*/
function updateApplyButtonState() {
// Кнопка Применить активна только если выбрана хотя бы одна категория
applyBtn.disabled = selectedCategoryIds.size === 0;
}
/**
* Handle apply button click
*/
async function handleApply() {
if (selectedCategoryIds.size === 0) {
showError('Выберите хотя бы одну категорию');
return;
}
const selectedItems = getSelectedItems();
if (selectedItems.length === 0) {
showError('Нет выбранных товаров');
return;
}
// Определяем режим: если чекбокс включен - replace, иначе - add
const actionMode = clearExistingToggle.checked ? 'replace' : 'add';
// Prepare request data
const requestData = {
items: selectedItems,
category_ids: Array.from(selectedCategoryIds),
action_mode: actionMode
};
// Disable button and show loading
applyBtn.disabled = true;
applyBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Применение...';
try {
const response = await fetch('/products/api/bulk-update-categories/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify(requestData)
});
const result = await response.json();
if (result.success) {
// Show success message
showSuccessToast(result.message);
// Close modal
modalInstance.hide();
// Optionally reload page to show updated categories
// For v1, we'll just show message suggesting refresh
if (result.updated_count > 0) {
setTimeout(() => {
if (confirm('Категории обновлены. Обновить страницу для отображения изменений?')) {
location.reload();
}
}, 500);
}
} else {
showError(result.message || 'Не удалось обновить категории');
// Show skipped reasons if any
if (result.skipped_reasons && result.skipped_reasons.length > 0) {
console.warn('Skipped items:', result.skipped_reasons);
}
}
} catch (error) {
console.error('Error applying bulk categories:', error);
showError('Произошла ошибка при обновлении категорий. Попробуйте снова.');
} finally {
// Reset button
applyBtn.innerHTML = '<i class="bi bi-check2-circle"></i> Применить';
applyBtn.disabled = false;
updateApplyButtonState();
}
}
/**
* Handle clear all categories button click
*/
async function handleClearAll() {
const selectedItems = getSelectedItems();
if (selectedItems.length === 0) {
showError('Нет выбранных товаров');
return;
}
// Подтверждение опасной операции
if (!confirm(`Вы уверены, что хотите удалить ВСЕ категории у ${selectedItems.length} выбранных товаров?\n\nЭто действие нельзя отменить!`)) {
return;
}
// Отправляем запрос с пустым списком категорий и режимом replace
const requestData = {
items: selectedItems,
category_ids: [], // Пустой список = очистить все
action_mode: 'clear' // Специальный режим
};
// Disable button and show loading
clearAllBtn.disabled = true;
clearAllBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Очистка...';
try {
const response = await fetch('/products/api/bulk-update-categories/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify(requestData)
});
const result = await response.json();
if (result.success) {
showSuccessToast(result.message || `Категории удалены у ${result.updated_count} товаров`);
modalInstance.hide();
setTimeout(() => {
if (confirm('Категории очищены. Обновить страницу?')) {
location.reload();
}
}, 500);
} else {
showError(result.message || 'Не удалось очистить категории');
}
} catch (error) {
console.error('Error clearing categories:', error);
showError('Произошла ошибка. Попробуйте снова.');
} finally {
clearAllBtn.innerHTML = '<i class="bi bi-trash3"></i> Очистить все категории';
clearAllBtn.disabled = false;
}
}
/**
* Show error message in modal
*/
function showError(message) {
errorAlert.textContent = message;
errorAlert.classList.remove('d-none');
}
/**
* Hide error message
*/
function hideError() {
errorAlert.classList.add('d-none');
}
/**
* Show success toast notification
*/
function showSuccessToast(message) {
// Create toast container if it doesn't exist
let toastContainer = document.querySelector('.toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3';
toastContainer.style.zIndex = '11000';
document.body.appendChild(toastContainer);
}
// Create toast
const toastId = 'toast_' + Date.now();
const toastHtml = `
<div id="${toastId}" class="toast align-items-center text-white bg-success border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
<i class="bi bi-check-circle me-2"></i>${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
const toastElement = document.getElementById(toastId);
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 5000
});
toast.show();
// Remove toast element after it's hidden
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
}
/**
* Reset modal state
*/
function resetModalState() {
selectedCategoryIds.clear();
categorySearchInput.value = '';
hideError();
// Сбрасываем чекбокс очистки
clearExistingToggle.checked = false;
updateApplyButtonState();
}
/**
* Get CSRF token from cookie
*/
function getCsrfToken() {
const name = 'csrftoken';
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();