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

- Добавлен 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();
}
})();

View File

@@ -108,7 +108,17 @@
{% endif %} {% endif %}
</div> </div>
<div class="col-12 col-md-6"> <!-- Показывать по -->
<div class="col-12 col-md-2">
<label for="per_page" class="form-label"><i class="bi bi-list-ol"></i> Показывать по:</label>
<select class="form-select" id="per_page" name="per_page" onchange="this.form.submit()">
<option value="20" {% if filters.current.per_page == '20' %}selected{% endif %}>20</option>
<option value="50" {% if filters.current.per_page == '50' %}selected{% endif %}>50</option>
<option value="100" {% if filters.current.per_page == '100' %}selected{% endif %}>100</option>
</select>
</div>
<div class="col-12 col-md-4">
<label class="form-label d-none d-md-block">&nbsp;</label> <label class="form-label d-none d-md-block">&nbsp;</label>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
@@ -125,10 +135,71 @@
</div> </div>
{% if items %} {% if items %}
<!-- Инструментальная панель для пакетных действий -->
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center gap-3">
{% if is_paginated %}
<span class="text-muted">
Показано {{ page_obj.start_index }}{{ page_obj.end_index }} из {{ page_obj.paginator.count }}
</span>
{% else %}
<span class="text-muted">Всего: {{ items|length }}</span>
{% endif %}
<!-- Dropdown для выбора всех отфильтрованных -->
<div class="btn-group" id="select-all-dropdown-group" style="display: none;">
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-check2-square"></i> Выбрать
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="#" onclick="document.getElementById('select-all-checkbox').click(); return false;">
<i class="bi bi-check2"></i> Все на странице
</a>
</li>
{% if is_paginated %}
<li>
<a class="dropdown-item" href="#" onclick="window.BatchSelection.selectAllFiltered(); return false;">
<i class="bi bi-check2-all"></i> Все отфильтрованные ({{ page_obj.paginator.count }})
</a>
</li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="#" onclick="window.BatchSelection.clearSelection(); return false;">
<i class="bi bi-x-square"></i> Снять выбор
</a>
</li>
</ul>
</div>
</div>
<div class="btn-group">
<button type="button" class="btn btn-outline-secondary" id="batch-actions-btn" disabled>
<i class="bi bi-gear-fill"></i> Действия над выбранными (<span id="selection-count">0</span>)
</button>
<button type="button" class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split"
id="batch-actions-dropdown" data-bs-toggle="dropdown" aria-expanded="false" disabled>
<span class="visually-hidden">Открыть меню</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="batch-actions-dropdown">
<li>
<a class="dropdown-item" href="#" id="bulk-category-action">
<i class="bi bi-bookmark-fill"></i> Изменить категории
</a>
</li>
</ul>
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped table-hover align-middle"> <table class="table table-striped table-hover align-middle">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th style="width: 50px;">
<input type="checkbox" id="select-all-checkbox" class="form-check-input"
title="Выбрать все на странице">
</th>
<th style="width: 80px;">Фото</th> <th style="width: 80px;">Фото</th>
<th>Название</th> <th>Название</th>
<th style="width: 120px;">Артикул</th> <th style="width: 120px;">Артикул</th>
@@ -144,6 +215,14 @@
<tbody> <tbody>
{% for item in items %} {% for item in items %}
<tr> <tr>
<td>
<input type="checkbox" name="selected_items"
value="{{ item.item_type }}:{{ item.pk }}"
class="form-check-input item-checkbox"
data-item-type="{{ item.item_type }}"
data-item-id="{{ item.pk }}"
data-item-name="{{ item.name }}">
</td>
<td> <td>
{% if item.photos.all %} {% if item.photos.all %}
{% with photo=item.photos.first %} {% with photo=item.photos.first %}
@@ -318,4 +397,78 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Модальное окно для массового изменения категорий -->
<div class="modal fade" id="bulkCategoryModal" tabindex="-1" aria-labelledby="bulkCategoryModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bulkCategoryModalLabel">
<i class="bi bi-bookmark-fill"></i> Изменить категории для выбранных товаров
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<!-- Информация о выбранных товарах -->
<div class="alert alert-info mb-3">
<i class="bi bi-info-circle"></i> <strong>Выбрано:</strong> <span id="selectedItemsCount">0</span> товаров
<span id="selectedItemsBreakdown" class="ms-2 text-muted"></span>
</div>
<!-- Опции применения -->
<div class="mb-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="clearExistingCategoriesToggle" role="switch">
<label class="form-check-label" for="clearExistingCategoriesToggle">
<i class="bi bi-trash"></i> Очистить существующие категории перед сохранением
</label>
<div class="form-text text-warning">
<i class="bi bi-exclamation-triangle"></i>
При включении все текущие категории товаров будут удалены, а затем применены только выбранные ниже
</div>
</div>
</div>
<!-- Выбор категорий -->
<div class="mb-3">
<label class="form-label fw-bold">Выберите категории:</label>
<!-- Поиск по категориям -->
<input type="text" class="form-control mb-2" id="categorySearchInput"
placeholder="Поиск категорий...">
<!-- Список категорий -->
<div id="categoryListContainer" style="max-height: 300px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 0.25rem; padding: 0.5rem;">
<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>
</div>
</div>
<!-- Сообщения об ошибках -->
<div id="bulkCategoryError" class="alert alert-danger d-none" role="alert"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger me-auto" id="clearAllCategoriesBtn">
<i class="bi bi-trash3"></i> Очистить все категории
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Отмена
</button>
<button type="button" class="btn btn-primary" id="applyBulkCategoriesBtn" disabled>
<i class="bi bi-check2-circle"></i> Применить
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{% load static %}
<script src="{% static 'products/js/batch-selection.js' %}?v=1.1"></script>
<script src="{% static 'products/js/bulk-category-modal.js' %}?v=1.0"></script>
{% endblock %} {% endblock %}

View File

@@ -11,11 +11,6 @@ urlpatterns = [
# Каталог с drag-n-drop # Каталог с drag-n-drop
path('catalog/', views.CatalogView.as_view(), name='catalog'), path('catalog/', views.CatalogView.as_view(), name='catalog'),
# Legacy URLs for backward compatibility
path('all/', views.CombinedProductListView.as_view(), name='all-products'),
path('products/', views.ProductListView.as_view(), name='product-list-legacy'),
path('kits/', views.ProductKitListView.as_view(), name='productkit-list'),
# CRUD URLs for Product # CRUD URLs for Product
path('product/create/', views.ProductCreateView.as_view(), name='product-create'), path('product/create/', views.ProductCreateView.as_view(), name='product-create'),
path('product/<int:pk>/', views.ProductDetailView.as_view(), name='product-detail'), path('product/<int:pk>/', views.ProductDetailView.as_view(), name='product-detail'),
@@ -58,6 +53,8 @@ urlpatterns = [
path('api/categories/<int:pk>/rename/', api_views.rename_category_api, name='api-category-rename'), path('api/categories/<int:pk>/rename/', api_views.rename_category_api, name='api-category-rename'),
path('api/products/<int:pk>/update-price/', api_views.update_product_price_api, name='api-update-product-price'), path('api/products/<int:pk>/update-price/', api_views.update_product_price_api, name='api-update-product-price'),
path('api/payment-methods/', api_views.get_payment_methods, name='api-payment-methods'), path('api/payment-methods/', api_views.get_payment_methods, name='api-payment-methods'),
path('api/filtered-items-ids/', api_views.get_filtered_items_ids, name='api-filtered-items-ids'),
path('api/bulk-update-categories/', api_views.bulk_update_categories, name='api-bulk-update-categories'),
# Photo processing status API (for AJAX polling) # Photo processing status API (for AJAX polling)
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'), path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),

View File

@@ -1465,3 +1465,287 @@ def get_product_sales_units_api(request, product_id):
'success': False, 'success': False,
'error': str(e) 'error': str(e)
}, status=500) }, status=500)
@login_required
def get_filtered_items_ids(request):
"""
API endpoint для получения ID всех отфильтрованных товаров и комплектов.
Используется для функции "Выбрать все отфильтрованные".
Параметры GET (те же, что и в CombinedProductListView):
- search: строка поиска
- type: 'all', 'products', 'kits'
- category: ID категории
- status: статус товара/комплекта
- in_stock: '1' или '0'
- tags: список ID тегов
Возвращает JSON:
{
"success": true,
"items": [
{"type": "product", "id": 1},
{"type": "kit", "id": 5},
...
],
"count": 150
}
"""
from django.db.models import Q
from ..models import Product, ProductKit
try:
# Получаем фильтры из GET-параметров
type_filter = request.GET.get('type', 'all')
search_query = request.GET.get('search')
category_id = request.GET.get('category')
status_filter = request.GET.get('status')
in_stock_filter = request.GET.get('in_stock')
tags = request.GET.getlist('tags')
# Начинаем с базовых queryset
products = Product.objects.all()
kits = ProductKit.objects.filter(is_temporary=False)
# Применяем фильтры (та же логика, что в CombinedProductListView)
if search_query:
products = products.filter(
Q(name__icontains=search_query) |
Q(sku__icontains=search_query) |
Q(description__icontains=search_query) |
Q(categories__name__icontains=search_query) |
Q(search_keywords__icontains=search_query)
).distinct()
kits = kits.filter(
Q(name__icontains=search_query) |
Q(sku__icontains=search_query) |
Q(description__icontains=search_query) |
Q(categories__name__icontains=search_query)
).distinct()
if category_id:
products = products.filter(categories__id=category_id)
kits = kits.filter(categories__id=category_id)
if status_filter:
products = products.filter(status=status_filter)
kits = kits.filter(status=status_filter)
if in_stock_filter == '1':
products = products.filter(in_stock=True)
elif in_stock_filter == '0':
products = products.filter(in_stock=False)
if tags:
products = products.filter(tags__id__in=tags).distinct()
kits = kits.filter(tags__id__in=tags).distinct()
# Собираем ID в зависимости от фильтра типа
items = []
if type_filter in ['all', 'products']:
product_ids = products.values_list('id', flat=True)
items.extend([{'type': 'product', 'id': pid} for pid in product_ids])
if type_filter in ['all', 'kits']:
kit_ids = kits.values_list('id', flat=True)
items.extend([{'type': 'kit', 'id': kid} for kid in kit_ids])
return JsonResponse({
'success': True,
'items': items,
'count': len(items)
})
except Exception as e:
logger.error(f'Ошибка получения отфильтрованных ID: {str(e)}')
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
@login_required
def bulk_update_categories(request):
"""
API endpoint для массового обновления категорий у выбранных товаров и комплектов.
POST /products/api/bulk-update-categories/
Request JSON:
{
"items": [
{"type": "product", "id": 123},
{"type": "kit", "id": 456}
],
"category_ids": [5, 12, 18],
"action_mode": "add" // "add" или "replace"
}
Response JSON:
{
"success": true,
"updated_count": 15,
"skipped_count": 0,
"message": "Категории успешно обновлены для 15 товаров"
}
"""
if request.method != 'POST':
return JsonResponse({
'success': False,
'message': 'Метод не поддерживается'
}, status=405)
try:
import json
from django.db import transaction
# Парсинг JSON запроса
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'message': 'Неверный формат JSON'
}, status=400)
# Валидация обязательных полей
items = data.get('items', [])
category_ids = data.get('category_ids', [])
action_mode = data.get('action_mode', 'add')
if not items:
return JsonResponse({
'success': False,
'message': 'Не выбраны товары для обновления'
}, status=400)
# Для режима 'clear' категории не обязательны
if action_mode != 'clear' and not category_ids:
return JsonResponse({
'success': False,
'message': 'Не выбраны категории'
}, status=400)
if action_mode not in ['add', 'replace', 'clear']:
return JsonResponse({
'success': False,
'message': 'Неверный режим действия (должен быть add, replace или clear)'
}, status=400)
# Проверка существования категорий (только если они указаны)
categories = []
if category_ids:
categories = ProductCategory.objects.filter(id__in=category_ids, is_active=True)
if categories.count() != len(category_ids):
return JsonResponse({
'success': False,
'message': 'Некоторые категории не найдены или неактивны'
}, status=400)
# Обработка товаров в транзакции
updated_count = 0
skipped_count = 0
skipped_reasons = []
with transaction.atomic():
for item in items:
item_type = item.get('type')
item_id = item.get('id')
if not item_type or not item_id:
skipped_count += 1
continue
try:
# Получаем объект товара или комплекта
if item_type == 'product':
# Проверка прав
if not request.user.has_perm('products.change_product'):
skipped_count += 1
skipped_reasons.append(f'Товар ID {item_id}: нет прав')
continue
obj = Product.objects.get(id=item_id)
elif item_type == 'kit':
# Проверка прав
if not request.user.has_perm('products.change_productkit'):
skipped_count += 1
skipped_reasons.append(f'Комплект ID {item_id}: нет прав')
continue
obj = ProductKit.objects.get(id=item_id)
else:
skipped_count += 1
continue
# Применяем изменения категорий
if action_mode == 'add':
# Добавляем категории к существующим
obj.categories.add(*categories)
elif action_mode == 'replace':
# Заменяем все категории
obj.categories.set(categories)
elif action_mode == 'clear':
# Очищаем все категории
obj.categories.clear()
updated_count += 1
except (Product.DoesNotExist, ProductKit.DoesNotExist):
skipped_count += 1
skipped_reasons.append(f'{item_type.capitalize()} ID {item_id} не найден')
continue
except Exception as e:
logger.error(f'Ошибка обновления {item_type} ID {item_id}: {str(e)}')
skipped_count += 1
skipped_reasons.append(f'{item_type.capitalize()} ID {item_id}: {str(e)}')
continue
# Формируем сообщение результата
if updated_count > 0:
if action_mode == 'clear':
message = f'Категории удалены у {updated_count} товаров'
else:
category_names = ', '.join([c.name for c in categories[:3]])
if len(categories) > 3:
category_names += f' и ещё {len(categories) - 3}'
if action_mode == 'add':
action_text = 'добавлены'
else:
action_text = 'установлены'
message = f'Категории "{category_names}" {action_text} для {updated_count} товаров'
if skipped_count > 0:
message += f'. Пропущено: {skipped_count}'
response_data = {
'success': True,
'updated_count': updated_count,
'skipped_count': skipped_count,
'message': message
}
if skipped_reasons:
response_data['skipped_reasons'] = skipped_reasons[:10] # Ограничение первыми 10
return JsonResponse(response_data)
else:
return JsonResponse({
'success': False,
'updated_count': 0,
'skipped_count': skipped_count,
'message': 'Не удалось обновить ни один товар',
'skipped_reasons': skipped_reasons[:10]
}, status=400)
except Exception as e:
logger.error(f'Ошибка массового обновления категорий: {str(e)}')
return JsonResponse({
'success': False,
'message': f'Произошла ошибка: {str(e)}'
}, status=500)

View File

@@ -97,13 +97,6 @@ class ProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
'icon': 'box-seam' 'icon': 'box-seam'
}) })
action_buttons.append({
'url': reverse_lazy('products:productkit-list'),
'text': 'К списку комплектов',
'class': 'btn-outline-secondary',
'icon': 'list'
})
context['action_buttons'] = action_buttons context['action_buttons'] = action_buttons
return context return context
@@ -265,6 +258,22 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
context_object_name = 'items' context_object_name = 'items'
paginate_by = 20 paginate_by = 20
def get_paginate_by(self, queryset):
"""
Динамическое определение количества элементов на странице.
Читает параметр per_page из GET-запроса.
"""
per_page = self.request.GET.get('per_page', '20')
try:
per_page_int = int(per_page)
# Разрешаем только значения 20, 50, 100
if per_page_int in [20, 50, 100]:
return per_page_int
except (ValueError, TypeError):
pass
# По умолчанию возвращаем 20
return 20
def get_queryset(self): def get_queryset(self):
# Получаем фильтр по типу # Получаем фильтр по типу
type_filter = self.request.GET.get('type', 'all') type_filter = self.request.GET.get('type', 'all')
@@ -377,6 +386,7 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
'type': self.request.GET.get('type', 'all'), 'type': self.request.GET.get('type', 'all'),
'in_stock': self.request.GET.get('in_stock', ''), 'in_stock': self.request.GET.get('in_stock', ''),
'tags': [int(tag) for tag in self.request.GET.getlist('tags') if tag.isdigit()], 'tags': [int(tag) for tag in self.request.GET.getlist('tags') if tag.isdigit()],
'per_page': self.request.GET.get('per_page', '20'),
} }
} }