From 928b3404869089732af5507c83e4abd6daf26131 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sat, 17 Jan 2026 03:11:00 +0300 Subject: [PATCH] =?UTF-8?q?style(pos):=20=D1=83=D0=BB=D1=83=D1=87=D1=88?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B0=D0=B4=D0=B0=D0=BF=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D1=81=D0=B5=D1=82=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Изменить брейкпоинт для 5 колонок с 992px на 1100px - Увеличить ширину правой панели с 4/12 до 5/12 колонок Co-Authored-By: Claude Opus 4.5 --- myproject/pos/static/pos/css/terminal.css | 122 +++++--- .../static/products/js/inline-price-edit.js | 277 ++++++++++++++++++ .../templates/products/products_list.html | 62 +++- myproject/products/views/api_views.py | 17 +- 4 files changed, 437 insertions(+), 41 deletions(-) create mode 100644 myproject/products/static/products/js/inline-price-edit.js diff --git a/myproject/pos/static/pos/css/terminal.css b/myproject/pos/static/pos/css/terminal.css index e313b08..35d4cc4 100644 --- a/myproject/pos/static/pos/css/terminal.css +++ b/myproject/pos/static/pos/css/terminal.css @@ -32,12 +32,20 @@ body { flex-grow: 1; } +/* 5 колонок для товаров и категорий на экранах от 1100px */ +@media (min-width: 1100px) { + .col-lg-custom-5 { + flex: 0 0 20%; + max-width: 20%; + } +} + /* Стили для корзины */ .cart-item { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.35rem 0; + gap: 0.25rem; + padding: 0.25rem 0; border-bottom: 1px solid #e9ecef; } @@ -46,6 +54,25 @@ body { 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 { font-weight: bold; color: #6c757d; @@ -54,12 +81,12 @@ body { } .qty-input { - width: 50px; - padding: 0.25rem 0.5rem; + width: 45px; + padding: 0.15rem 0.35rem; border: 1px solid #dee2e6; border-radius: 4px; text-align: center; - font-size: 0.9rem; + font-size: 0.85rem; flex-shrink: 0; } @@ -75,23 +102,23 @@ body { .item-total { font-weight: 600; - font-size: 0.95rem; + font-size: 0.85rem; color: #212529; - min-width: 60px; + min-width: 50px; text-align: right; flex-shrink: 0; } .cart-item .btn-link { - font-size: 1.5rem; + font-size: 1.2rem; line-height: 1; - width: 40px; - height: 40px; + width: 32px; + height: 32px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; - margin-left: 0.25rem; + margin-left: 0.15rem; } .cart-item .btn-link:hover { @@ -99,6 +126,16 @@ body { 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 { cursor: pointer; user-select: none; @@ -174,22 +211,28 @@ body { .product-stock { font-size: 0.8rem; color: #6c757d; - font-style: italic; + margin-top: auto; + margin-bottom: 0.15rem; } .product-sku { - font-size: 0.75rem; + font-size: 0.65rem; color: #adb5bd; - margin-top: auto; display: flex; justify-content: space-between; align-items: center; + flex-wrap: nowrap; + gap: 0.5rem; } .product-price { font-size: 0.85rem; font-weight: 600; color: #212529; + border: 1px solid #dee2e6; + border-radius: 6px; + padding: 2px 8px; + background: #f8f9fa; } /* Карточки категорий */ @@ -246,7 +289,7 @@ body { top: 0; right: 0; bottom: 0; - width: 33.333%; /* 4/12 колонок */ + width: 41.667%; /* 5/12 колонок */ overflow-y: auto; padding: 1rem; padding-right: 1.5rem; @@ -318,30 +361,15 @@ body { /* Адаптивность для элементов корзины на маленьких экранах */ @media (max-width: 991.98px) { .cart-item { - flex-wrap: wrap; - gap: 0.5rem; + align-items: flex-start; } - - .item-name-price { - width: 100%; - order: 1; - } - + .multiply-sign { display: none; } - - .cart-item > div:has(.d-flex.align-items-center) { - order: 2; - } - - .item-total { - order: 3; - margin-left: auto; - } - - .cart-item .btn-link { - order: 4; + + .item-name-price .fw-semibold { + -webkit-line-clamp: 1; } } @@ -524,3 +552,27 @@ body { .products-scrollable { -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; +} + +/* На мобильных - такой же вид с текстом */ diff --git a/myproject/products/static/products/js/inline-price-edit.js b/myproject/products/static/products/js/inline-price-edit.js new file mode 100644 index 0000000..14137a9 --- /dev/null +++ b/myproject/products/static/products/js/inline-price-edit.js @@ -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 = ` +
${formatPrice(price)} руб.
+ + ${formatPrice(salePrice)} руб. + + `; + } else { + // Только обычная цена + newHTML = ` + + ${formatPrice(price)} руб. + + `; + } + + parentContainer.innerHTML = newHTML; + }); + } + + /** + * Отправляет обновление цены на сервер + * @param {number} productId - ID товара + * @param {string} field - поле (price или sale_price) + * @param {string|null} value - новое значение + * @returns {Promise} - результат обновления + */ + 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 + }; +})(); diff --git a/myproject/products/templates/products/products_list.html b/myproject/products/templates/products/products_list.html index 9106a66..66b4027 100644 --- a/myproject/products/templates/products/products_list.html +++ b/myproject/products/templates/products/products_list.html @@ -339,11 +339,49 @@ {% endwith %} - {% if item.sale_price %} -
{{ item.price|floatformat:2 }} руб.
- {{ item.sale_price|floatformat:2 }} руб. + {% if item.item_type == 'product' %} +
+ {% if item.sale_price %} +
{{ item.price|floatformat:2 }} руб.
+ {% if user.is_superuser or user.tenant_role.role.code == 'owner' or user.tenant_role.role.code == 'manager' %} + + {{ item.sale_price|floatformat:2 }} руб. + + {% else %} + + {{ item.sale_price|floatformat:2 }} руб. + + {% endif %} + {% else %} + {% if user.is_superuser or user.tenant_role.role.code == 'owner' or user.tenant_role.role.code == 'manager' %} + + {{ item.price|floatformat:2 }} руб. + + {% else %} + + {{ item.price|floatformat:2 }} руб. + + {% endif %} + {% endif %} +
{% else %} - {{ item.price|floatformat:2 }} руб. + {# Для комплектов редактирование не поддерживается #} + {% if item.sale_price %} +
{{ item.price|floatformat:2 }} руб.
+ {{ item.sale_price|floatformat:2 }} руб. + {% else %} + {{ item.price|floatformat:2 }} руб. + {% endif %} {% endif %} @@ -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; +} {% endblock %} {% block extra_js %} {% load static %} + diff --git a/myproject/products/views/api_views.py b/myproject/products/views/api_views.py index ded7d62..acf6494 100644 --- a/myproject/products/views/api_views.py +++ b/myproject/products/views/api_views.py @@ -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': 'У вас нет прав для изменения цен товаров'