style(pos): улучшить адаптивность сетки товаров

- Изменить брейкпоинт для 5 колонок с 992px на 1100px
- Увеличить ширину правой панели с 4/12 до 5/12 колонок

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-17 03:11:00 +03:00
parent 18cca326af
commit 928b340486
4 changed files with 437 additions and 41 deletions

View File

@@ -0,0 +1,277 @@
/**
* Inline Price Editing Module
* Переиспользуемый модуль для редактирования цен товаров прямо на странице
* Используется на страницах: products_list.html, catalog.html
*/
(function() {
'use strict';
/**
* Нормализует ввод цены: заменяет запятую на точку
* @param {string} value - введенное значение
* @returns {string} - нормализованное значение
*/
function normalizePrice(value) {
if (typeof value === 'string') {
return value.replace(',', '.');
}
return value;
}
/**
* Форматирует число для отображения (с запятой как разделителем)
* @param {string|number} value - значение
* @returns {string} - отформатированное значение с запятой
*/
function formatPrice(value) {
return parseFloat(value).toFixed(2).replace('.', ',');
}
/**
* Получает CSRF токен из DOM или cookies
* @returns {string} - CSRF токен
*/
function getCsrfToken() {
// Сначала пытаемся найти токен в DOM (из {% csrf_token %})
const csrfInput = document.querySelector('[name=csrfmiddlewaretoken]');
if (csrfInput) {
return csrfInput.value;
}
// Fallback: пытаемся прочитать из cookie
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
const [key, value] = cookie.trim().split('=');
if (key === 'csrftoken') {
return decodeURIComponent(value);
}
}
return '';
}
/**
* Обновляет отображение цен для товара
* @param {number} productId - ID товара
* @param {string} price - основная цена
* @param {string|null} salePrice - цена со скидкой
*/
function updatePriceDisplay(productId, price, salePrice) {
// Находим все контейнеры с ценами для этого товара
const priceContainers = document.querySelectorAll(
`.editable-price[data-product-id="${productId}"]`
);
priceContainers.forEach(container => {
const parentContainer = container.closest('.price-edit-container, td');
if (!parentContainer) return;
// Формируем новый HTML
let newHTML = '';
if (salePrice) {
// Есть скидочная цена
newHTML = `
<div class="text-decoration-line-through text-muted small">${formatPrice(price)} руб.</div>
<strong class="text-danger editable-price"
data-product-id="${productId}"
data-field="sale_price"
data-current-value="${salePrice}"
title="Цена со скидкой (клик для редактирования)"
style="cursor: pointer;">
${formatPrice(salePrice)} руб.
</strong>
`;
} else {
// Только обычная цена
newHTML = `
<strong class="editable-price"
data-product-id="${productId}"
data-field="price"
data-current-value="${price}"
title="Цена продажи (клик для редактирования)"
style="cursor: pointer;">
${formatPrice(price)} руб.
</strong>
`;
}
parentContainer.innerHTML = newHTML;
});
}
/**
* Отправляет обновление цены на сервер
* @param {number} productId - ID товара
* @param {string} field - поле (price или sale_price)
* @param {string|null} value - новое значение
* @returns {Promise<Object>} - результат обновления
*/
async function updatePriceViaAPI(productId, field, value) {
const response = await fetch(`/products/api/products/${productId}/update-price/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
field: field,
value: value
})
});
return await response.json();
}
/**
* Инициализирует inline-редактирование цен
*/
function initInlinePriceEdit() {
// Проверяем, есть ли на странице редактируемые цены
const editablePrices = document.querySelectorAll('.editable-price');
if (editablePrices.length === 0) {
return; // Нет элементов для редактирования
}
// Обработчик клика на редактируемую цену
document.addEventListener('click', function(e) {
const priceSpan = e.target.closest('.editable-price');
if (!priceSpan) return;
// Предотвращаем повторное срабатывание, если уже редактируем
if (priceSpan.querySelector('input')) return;
const productId = priceSpan.dataset.productId;
const field = priceSpan.dataset.field;
const currentValue = priceSpan.dataset.currentValue;
// Сохраняем оригинальный HTML
const originalHTML = priceSpan.innerHTML;
// Создаем input для редактирования
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control form-control-sm';
input.style.width = '100px';
input.style.display = 'inline-block';
input.value = parseFloat(currentValue).toFixed(2);
input.placeholder = 'Цена';
// Заменяем содержимое на input
priceSpan.innerHTML = '';
priceSpan.appendChild(input);
input.focus();
input.select();
// Функция сохранения
const savePrice = async () => {
let newValue = input.value.trim();
// Нормализуем ввод (запятая -> точка)
newValue = normalizePrice(newValue);
// Валидация
if (!newValue || parseFloat(newValue) < 0) {
// Отмена - пустое или отрицательное значение
priceSpan.innerHTML = originalHTML;
return;
}
// Проверяем, изменилось ли значение
if (parseFloat(newValue) === parseFloat(currentValue)) {
// Значение не изменилось
priceSpan.innerHTML = originalHTML;
return;
}
// Показываем загрузку
input.disabled = true;
input.style.opacity = '0.5';
try {
const response = await fetch(`/products/api/products/${productId}/update-price/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken()
},
body: JSON.stringify({
field: field,
value: newValue
})
});
// Проверяем статус ответа
if (!response.ok) {
// Обработка HTTP ошибок
if (response.status === 403) {
alert('У вас нет прав для изменения цен товаров');
} else if (response.status === 404) {
alert('Товар не найден');
} else {
// Пытаемся получить текст ошибки из JSON
try {
const errorData = await response.json();
alert(errorData.error || `Ошибка ${response.status}: ${response.statusText}`);
} catch {
alert(`Ошибка ${response.status}: ${response.statusText}`);
}
}
priceSpan.innerHTML = originalHTML;
return;
}
const data = await response.json();
if (data.success) {
// Обновляем отображение для всех экземпляров этого товара на странице
updatePriceDisplay(productId, data.price, data.sale_price);
} else {
alert(data.error || 'Ошибка при обновлении цены');
priceSpan.innerHTML = originalHTML;
}
} catch (error) {
console.error('Ошибка:', error);
alert('Ошибка сети при обновлении цены');
priceSpan.innerHTML = originalHTML;
}
};
// Функция отмены
const cancelEdit = () => {
priceSpan.innerHTML = originalHTML;
};
// Enter - сохранить
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
savePrice();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
});
// Потеря фокуса - сохранить
input.addEventListener('blur', function() {
setTimeout(savePrice, 100);
});
});
}
// Инициализация при загрузке DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initInlinePriceEdit);
} else {
initInlinePriceEdit();
}
// Экспортируем функции для использования извне
window.InlinePriceEdit = {
init: initInlinePriceEdit,
updateDisplay: updatePriceDisplay,
normalizePrice: normalizePrice
};
})();

View File

@@ -339,11 +339,49 @@
{% endwith %}
</td>
<td>
{% if item.sale_price %}
<div class="text-decoration-line-through text-muted small">{{ item.price|floatformat:2 }} руб.</div>
<strong class="text-danger">{{ item.sale_price|floatformat:2 }} руб.</strong>
{% if item.item_type == 'product' %}
<div class="price-edit-container">
{% if item.sale_price %}
<div class="text-decoration-line-through text-muted small">{{ item.price|floatformat:2 }} руб.</div>
{% if user.is_superuser or user.tenant_role.role.code == 'owner' or user.tenant_role.role.code == 'manager' %}
<strong class="text-danger editable-price"
data-product-id="{{ item.pk }}"
data-field="sale_price"
data-current-value="{{ item.sale_price }}"
title="Цена со скидкой (клик для редактирования)"
style="cursor: pointer;">
{{ item.sale_price|floatformat:2 }} руб.
</strong>
{% else %}
<strong class="text-danger">
{{ item.sale_price|floatformat:2 }} руб.
</strong>
{% endif %}
{% else %}
{% if user.is_superuser or user.tenant_role.role.code == 'owner' or user.tenant_role.role.code == 'manager' %}
<strong class="editable-price"
data-product-id="{{ item.pk }}"
data-field="price"
data-current-value="{{ item.price }}"
title="Цена продажи (клик для редактирования)"
style="cursor: pointer;">
{{ item.price|floatformat:2 }} руб.
</strong>
{% else %}
<strong>
{{ item.price|floatformat:2 }} руб.
</strong>
{% endif %}
{% endif %}
</div>
{% else %}
<strong>{{ item.price|floatformat:2 }} руб.</strong>
{# Для комплектов редактирование не поддерживается #}
{% if item.sale_price %}
<div class="text-decoration-line-through text-muted small">{{ item.price|floatformat:2 }} руб.</div>
<strong class="text-danger">{{ item.sale_price|floatformat:2 }} руб.</strong>
{% else %}
<strong>{{ item.price|floatformat:2 }} руб.</strong>
{% endif %}
{% endif %}
</td>
<td>
@@ -588,12 +626,28 @@
#batch-actions-wrapper[data-hint="true"]:hover #batch-actions-hint {
display: inline-block !important;
}
/* Стили для редактируемых цен */
.editable-price {
cursor: pointer;
transition: color 0.2s ease;
}
.editable-price:hover {
color: #0d6efd !important;
text-decoration: underline;
}
.price-edit-container {
min-height: 2.5rem;
}
</style>
{% endblock %}
{% block extra_js %}
{% load static %}
<script src="{% static 'products/js/inline-price-edit.js' %}?v=1.5"></script>
<script src="{% static 'products/js/batch-selection.js' %}?v=1.5"></script>
<script src="{% static 'products/js/bulk-category-modal.js' %}?v=1.6"></script>
<script src="{% static 'products/js/recommerce-sync.js' %}?v=1.2"></script>

View File

@@ -1200,6 +1200,7 @@ def create_category_api(request):
}, status=500)
@login_required
def update_product_price_api(request, pk):
"""
AJAX endpoint для изменения цены товара (inline editing в каталоге).
@@ -1224,8 +1225,20 @@ def update_product_price_api(request, pk):
'error': 'Метод не поддерживается'
}, status=405)
# Проверка прав доступа
if not request.user.has_perm('products.change_product'):
# Проверка прав доступа через кастомную систему ролей
from user_roles.services import RoleService
# Добавляем отладочное логирование
try:
logger.info(f"Update price API - User: {request.user.email}, is_superuser: {request.user.is_superuser}")
user_role = RoleService.get_user_role(request.user)
logger.info(f"Update price API - User role: {user_role.code if user_role else 'None'}")
except Exception as e:
logger.error(f"Update price API - Error getting user role: {str(e)}")
# Owner и Manager имеют право изменять цены
if not request.user.is_superuser and not RoleService.user_has_role(request.user, 'owner', 'manager'):
logger.warning(f"Update price API - Access denied for user {request.user.email}")
return JsonResponse({
'success': False,
'error': 'У вас нет прав для изменения цен товаров'