Feat: Add inline price editing for products in catalog

Implemented inline editing functionality for product prices directly in the catalog view with support for both regular and sale prices.

Features:
- Click-to-edit price fields with visual hover indicators
- Separate editing for price and sale_price fields
- Add/remove sale price with validation
- Real-time UI updates without page reload
- Permission-based access control
- Server-side validation for price constraints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-25 01:23:46 +03:00
parent 0f212bda69
commit 22bf7e137d
4 changed files with 465 additions and 1 deletions

View File

@@ -562,6 +562,238 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
// ========================================
// Inline-редактирование цен (только для Product, не для ProductKit)
// ========================================
// Обработчик клика на цене (редактирование)
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; // 'price' или 'sale_price'
const currentValue = priceSpan.dataset.currentValue;
// Создаем input
const input = document.createElement('input');
input.type = 'number';
input.step = '0.01';
input.min = '0';
input.value = currentValue;
input.className = 'price-edit-input';
// Сохраняем оригинальный HTML
const originalHTML = priceSpan.innerHTML;
priceSpan.innerHTML = '';
priceSpan.appendChild(input);
input.focus();
input.select();
// Функция сохранения
const savePrice = async () => {
const newValue = input.value.trim();
// Валидация
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
})
});
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);
});
});
// Добавление скидочной цены
document.addEventListener('click', function(e) {
const addBtn = e.target.closest('.add-sale-price');
if (!addBtn) return;
const productId = addBtn.dataset.productId;
const priceContainer = addBtn.closest('.price-edit-container');
const regularPriceSpan = priceContainer.querySelector('.editable-price[data-field="price"]');
const currentPrice = parseFloat(regularPriceSpan.dataset.currentValue);
// Запрашиваем скидочную цену
const salePriceInput = prompt('Введите скидочную цену (меньше ' + currentPrice.toFixed(2) + ' руб.):', '');
if (!salePriceInput) return; // Отменено
const salePrice = parseFloat(salePriceInput);
// Валидация
if (isNaN(salePrice) || salePrice <= 0) {
alert('Некорректная цена');
return;
}
if (salePrice >= currentPrice) {
alert('Скидочная цена должна быть меньше обычной цены (' + currentPrice.toFixed(2) + ' руб.)');
return;
}
// Сохраняем
updatePriceViaAPI(productId, 'sale_price', salePrice.toFixed(2));
});
// Удаление скидочной цены
document.addEventListener('click', function(e) {
const removeBtn = e.target.closest('.remove-sale-price');
if (!removeBtn) return;
const productId = removeBtn.dataset.productId;
if (!confirm('Убрать скидочную цену?')) return;
// Отправляем null для удаления sale_price
updatePriceViaAPI(productId, 'sale_price', null);
});
// Вспомогательная функция для обновления цены через API
async function updatePriceViaAPI(productId, field, value) {
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: value
})
});
const data = await response.json();
if (data.success) {
// Обновляем отображение
updatePriceDisplay(productId, data.price, data.sale_price);
} else {
alert(data.error || 'Ошибка при обновлении цены');
}
} catch (error) {
console.error('Ошибка:', error);
alert('Ошибка сети');
}
}
// Функция обновления отображения цен
function updatePriceDisplay(productId, price, salePrice) {
// Находим контейнер с ценами для этого товара
const catalogItem = document.querySelector(`.catalog-item .editable-price[data-product-id="${productId}"]`)?.closest('.price-edit-container');
if (!catalogItem) return;
// Формируем новый HTML
let newHTML = '';
if (salePrice) {
// Есть скидочная цена
newHTML = `
<span class="editable-price sale-price fw-bold text-success small"
data-product-id="${productId}"
data-field="sale_price"
data-current-value="${salePrice}"
title="Скидочная цена (клик для редактирования)">
${parseFloat(salePrice).toFixed(2)} руб.
</span>
<span class="editable-price regular-price text-muted text-decoration-line-through small"
data-product-id="${productId}"
data-field="price"
data-current-value="${price}"
title="Обычная цена (клик для редактирования)">
${parseFloat(price).toFixed(2)} руб.
</span>
<i class="bi bi-x-circle text-danger remove-sale-price"
data-product-id="${productId}"
title="Убрать скидку"
style="cursor: pointer; font-size: 0.85rem;"></i>
`;
} else {
// Только обычная цена
newHTML = `
<span class="editable-price fw-bold text-primary small"
data-product-id="${productId}"
data-field="price"
data-current-value="${price}"
title="Цена (клик для редактирования)">
${parseFloat(price).toFixed(2)} руб.
</span>
<button class="btn btn-outline-secondary btn-sm add-sale-price py-0 px-1"
data-product-id="${productId}"
title="Добавить скидочную цену"
style="font-size: 0.7rem; line-height: 1.2;">
+ скидка
</button>
`;
}
catalogItem.innerHTML = newHTML;
}
// Получение CSRF токена // Получение CSRF токена
function getCsrfToken() { function getCsrfToken() {
const cookieValue = document.cookie const cookieValue = document.cookie

View File

@@ -140,6 +140,56 @@
opacity: 1 !important; opacity: 1 !important;
color: #198754; color: #198754;
} }
/* Редактируемые цены */
.editable-price {
cursor: pointer;
padding: 2px 4px;
border-radius: 3px;
transition: background-color 0.15s, border-color 0.15s;
border: 1px solid transparent;
}
.editable-price:hover {
background-color: #f8f9fa;
border: 1px dashed #dee2e6;
}
.editable-price.sale-price:hover {
background-color: #d1f4e0;
border-color: #28a745;
}
.editable-price.regular-price:hover {
background-color: #e7f1ff;
border-color: #0d6efd;
}
.price-edit-input {
width: 90px;
font-size: 0.85rem;
padding: 2px 6px;
border: 2px solid #0d6efd;
border-radius: 3px;
text-align: right;
}
.price-edit-input:focus {
outline: none;
border-color: #0a58ca;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
.remove-sale-price {
opacity: 0;
transition: opacity 0.2s;
}
.price-edit-container:hover .remove-sale-price {
opacity: 0.5;
}
.remove-sale-price:hover {
opacity: 1 !important;
}
.add-sale-price {
opacity: 0;
transition: opacity 0.2s;
}
.price-edit-container:hover .add-sale-price {
opacity: 1;
}
</style> </style>
{% endblock %} {% endblock %}
@@ -217,7 +267,52 @@
{% endif %} {% endif %}
</div> </div>
<div class="d-flex justify-content-between align-items-center mt-1"> <div class="d-flex justify-content-between align-items-center mt-1">
<span class="fw-bold text-primary small">{{ item.actual_price|floatformat:0 }} руб.</span> {% if item.item_type == 'product' %}
<div class="price-edit-container d-flex align-items-center gap-1 flex-wrap">
{% if item.sale_price %}
{# Скидочная цена #}
<span class="editable-price sale-price fw-bold text-success small"
data-product-id="{{ item.pk }}"
data-field="sale_price"
data-current-value="{{ item.sale_price }}"
title="Скидочная цена (клик для редактирования)">
{{ item.sale_price|floatformat:2 }} руб.
</span>
{# Обычная цена зачеркнутая #}
<span class="editable-price regular-price text-muted text-decoration-line-through small"
data-product-id="{{ item.pk }}"
data-field="price"
data-current-value="{{ item.price }}"
title="Обычная цена (клик для редактирования)">
{{ item.price|floatformat:2 }} руб.
</span>
{# Кнопка удаления скидки #}
<i class="bi bi-x-circle text-danger remove-sale-price"
data-product-id="{{ item.pk }}"
title="Убрать скидку"
style="cursor: pointer; font-size: 0.85rem;"></i>
{% else %}
{# Только обычная цена #}
<span class="editable-price fw-bold text-primary small"
data-product-id="{{ item.pk }}"
data-field="price"
data-current-value="{{ item.price }}"
title="Цена (клик для редактирования)">
{{ item.price|floatformat:2 }} руб.
</span>
{# Кнопка добавления скидки #}
<button class="btn btn-outline-secondary btn-sm add-sale-price py-0 px-1"
data-product-id="{{ item.pk }}"
title="Добавить скидочную цену"
style="font-size: 0.7rem; line-height: 1.2;">
+ скидка
</button>
{% endif %}
</div>
{% else %}
{# ProductKit - не редактируется #}
<span class="fw-bold text-primary small">{{ item.actual_price|floatformat:2 }} руб.</span>
{% endif %}
<small class="text-muted">{{ item.sku }}</small> <small class="text-muted">{{ item.sku }}</small>
</div> </div>
</div> </div>

View File

@@ -49,6 +49,7 @@ urlpatterns = [
path('api/tags/<int:pk>/toggle/', api_views.toggle_tag_status_api, name='api-tag-toggle'), path('api/tags/<int:pk>/toggle/', api_views.toggle_tag_status_api, name='api-tag-toggle'),
path('api/categories/create/', api_views.create_category_api, name='api-category-create'), path('api/categories/create/', api_views.create_category_api, name='api-category-create'),
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'),
# 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

@@ -1081,3 +1081,139 @@ def create_category_api(request):
'success': False, 'success': False,
'error': f'Ошибка при создании категории: {str(e)}' 'error': f'Ошибка при создании категории: {str(e)}'
}, status=500) }, status=500)
def update_product_price_api(request, pk):
"""
AJAX endpoint для изменения цены товара (inline editing в каталоге).
Принимает JSON:
{
"field": "price" | "sale_price",
"value": "150.50" | null
}
Возвращает JSON:
{
"success": true,
"price": "199.00",
"sale_price": "150.00" | null,
"actual_price": "150.00"
}
"""
if request.method != 'POST':
return JsonResponse({
'success': False,
'error': 'Метод не поддерживается'
}, status=405)
# Проверка прав доступа
if not request.user.has_perm('products.change_product'):
return JsonResponse({
'success': False,
'error': 'У вас нет прав для изменения цен товаров'
}, status=403)
try:
import json
from decimal import Decimal, InvalidOperation
data = json.loads(request.body)
field = data.get('field')
value = data.get('value')
# Валидация поля
if field not in ['price', 'sale_price']:
return JsonResponse({
'success': False,
'error': 'Недопустимое поле. Разрешены: price, sale_price'
}, status=400)
# Получаем товар
product = Product.objects.get(pk=pk)
# Обработка значения
if value is None:
# Очистка sale_price
if field == 'sale_price':
product.sale_price = None
else:
return JsonResponse({
'success': False,
'error': 'Основная цена не может быть пустой'
}, status=400)
else:
# Валидация значения
try:
decimal_value = Decimal(str(value))
except (InvalidOperation, ValueError):
return JsonResponse({
'success': False,
'error': 'Некорректное числовое значение'
}, status=400)
# Проверка диапазона
if decimal_value <= 0:
return JsonResponse({
'success': False,
'error': 'Цена должна быть положительной'
}, status=400)
if decimal_value > Decimal('999999.99'):
return JsonResponse({
'success': False,
'error': 'Цена слишком большая (максимум 999999.99)'
}, status=400)
# Проверка десятичных знаков
if decimal_value.as_tuple().exponent < -2:
return JsonResponse({
'success': False,
'error': 'Максимум 2 знака после запятой'
}, status=400)
# Устанавливаем значение
if field == 'price':
product.price = decimal_value
# Проверка: sale_price должна быть меньше price
if product.sale_price and product.sale_price >= decimal_value:
return JsonResponse({
'success': False,
'error': 'Скидочная цена должна быть меньше обычной цены'
}, status=400)
else: # sale_price
# Проверка: sale_price должна быть меньше price
if decimal_value >= product.price:
return JsonResponse({
'success': False,
'error': 'Скидочная цена должна быть меньше обычной цены'
}, status=400)
product.sale_price = decimal_value
# Сохраняем
product.save(update_fields=[field])
# Возвращаем обновлённые данные
return JsonResponse({
'success': True,
'price': str(product.price),
'sale_price': str(product.sale_price) if product.sale_price else None,
'actual_price': str(product.actual_price)
})
except Product.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Товар не найден'
}, status=404)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Некорректный JSON'
}, status=400)
except Exception as e:
logger.error(f'Ошибка при обновлении цены товара: {str(e)}')
return JsonResponse({
'success': False,
'error': f'Ошибка при обновлении цены: {str(e)}'
}, status=500)