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

@@ -32,12 +32,20 @@ body {
flex-grow: 1; flex-grow: 1;
} }
/* 5 колонок для товаров и категорий на экранах от 1100px */
@media (min-width: 1100px) {
.col-lg-custom-5 {
flex: 0 0 20%;
max-width: 20%;
}
}
/* Стили для корзины */ /* Стили для корзины */
.cart-item { .cart-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.25rem;
padding: 0.35rem 0; padding: 0.25rem 0;
border-bottom: 1px solid #e9ecef; border-bottom: 1px solid #e9ecef;
} }
@@ -46,6 +54,25 @@ body {
min-width: 0; min-width: 0;
} }
.item-name-price .fw-semibold {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.3;
}
.item-name-price .text-muted {
margin-top: 0.15rem;
}
/* Цена и единица измерения на одной строке */
.price-unit-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.multiply-sign { .multiply-sign {
font-weight: bold; font-weight: bold;
color: #6c757d; color: #6c757d;
@@ -54,12 +81,12 @@ body {
} }
.qty-input { .qty-input {
width: 50px; width: 45px;
padding: 0.25rem 0.5rem; padding: 0.15rem 0.35rem;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 4px; border-radius: 4px;
text-align: center; text-align: center;
font-size: 0.9rem; font-size: 0.85rem;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -75,23 +102,23 @@ body {
.item-total { .item-total {
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.85rem;
color: #212529; color: #212529;
min-width: 60px; min-width: 50px;
text-align: right; text-align: right;
flex-shrink: 0; flex-shrink: 0;
} }
.cart-item .btn-link { .cart-item .btn-link {
font-size: 1.5rem; font-size: 1.2rem;
line-height: 1; line-height: 1;
width: 40px; width: 32px;
height: 40px; height: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
margin-left: 0.25rem; margin-left: 0.15rem;
} }
.cart-item .btn-link:hover { .cart-item .btn-link:hover {
@@ -99,6 +126,16 @@ body {
border-radius: 4px; border-radius: 4px;
} }
/* Кнопки +/- компактнее */
.cart-item .btn-sm {
padding: 0.15rem 0.35rem;
font-size: 0.875rem;
}
.cart-item .btn-sm i {
font-size: 1em;
}
.product-card { .product-card {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@@ -174,22 +211,28 @@ body {
.product-stock { .product-stock {
font-size: 0.8rem; font-size: 0.8rem;
color: #6c757d; color: #6c757d;
font-style: italic; margin-top: auto;
margin-bottom: 0.15rem;
} }
.product-sku { .product-sku {
font-size: 0.75rem; font-size: 0.65rem;
color: #adb5bd; color: #adb5bd;
margin-top: auto;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: nowrap;
gap: 0.5rem;
} }
.product-price { .product-price {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
color: #212529; color: #212529;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 2px 8px;
background: #f8f9fa;
} }
/* Карточки категорий */ /* Карточки категорий */
@@ -246,7 +289,7 @@ body {
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
width: 33.333%; /* 4/12 колонок */ width: 41.667%; /* 5/12 колонок */
overflow-y: auto; overflow-y: auto;
padding: 1rem; padding: 1rem;
padding-right: 1.5rem; padding-right: 1.5rem;
@@ -318,30 +361,15 @@ body {
/* Адаптивность для элементов корзины на маленьких экранах */ /* Адаптивность для элементов корзины на маленьких экранах */
@media (max-width: 991.98px) { @media (max-width: 991.98px) {
.cart-item { .cart-item {
flex-wrap: wrap; align-items: flex-start;
gap: 0.5rem;
}
.item-name-price {
width: 100%;
order: 1;
} }
.multiply-sign { .multiply-sign {
display: none; display: none;
} }
.cart-item > div:has(.d-flex.align-items-center) { .item-name-price .fw-semibold {
order: 2; -webkit-line-clamp: 1;
}
.item-total {
order: 3;
margin-left: auto;
}
.cart-item .btn-link {
order: 4;
} }
} }
@@ -524,3 +552,27 @@ body {
.products-scrollable { .products-scrollable {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
/* ============================================================
КОМПАКТНАЯ КНОПКА ВЫБОРА КЛИЕНТА
============================================================ */
#customerSelectBtn {
min-width: 160px;
max-width: 200px;
height: 38px;
padding: 0.25rem 0.75rem;
gap: 0.5rem;
}
#customerSelectBtn i {
font-size: 1rem;
flex-shrink: 0;
}
#customerSelectBtnText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* На мобильных - такой же вид с текстом */

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,12 +339,50 @@
{% endwith %} {% endwith %}
</td> </td>
<td> <td>
{% 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 %}
{# Для комплектов редактирование не поддерживается #}
{% if item.sale_price %} {% if item.sale_price %}
<div class="text-decoration-line-through text-muted small">{{ item.price|floatformat:2 }} руб.</div> <div class="text-decoration-line-through text-muted small">{{ item.price|floatformat:2 }} руб.</div>
<strong class="text-danger">{{ item.sale_price|floatformat:2 }} руб.</strong> <strong class="text-danger">{{ item.sale_price|floatformat:2 }} руб.</strong>
{% else %} {% else %}
<strong>{{ item.price|floatformat:2 }} руб.</strong> <strong>{{ item.price|floatformat:2 }} руб.</strong>
{% endif %} {% endif %}
{% endif %}
</td> </td>
<td> <td>
{% if item.item_type == 'product' %} {% if item.item_type == 'product' %}
@@ -588,12 +626,28 @@
#batch-actions-wrapper[data-hint="true"]:hover #batch-actions-hint { #batch-actions-wrapper[data-hint="true"]:hover #batch-actions-hint {
display: inline-block !important; 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> </style>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{% load static %} {% 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/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/bulk-category-modal.js' %}?v=1.6"></script>
<script src="{% static 'products/js/recommerce-sync.js' %}?v=1.2"></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) }, status=500)
@login_required
def update_product_price_api(request, pk): def update_product_price_api(request, pk):
""" """
AJAX endpoint для изменения цены товара (inline editing в каталоге). AJAX endpoint для изменения цены товара (inline editing в каталоге).
@@ -1224,8 +1225,20 @@ def update_product_price_api(request, pk):
'error': 'Метод не поддерживается' 'error': 'Метод не поддерживается'
}, status=405) }, 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({ return JsonResponse({
'success': False, 'success': False,
'error': 'У вас нет прав для изменения цен товаров' 'error': 'У вас нет прав для изменения цен товаров'