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 токена
function getCsrfToken() {
const cookieValue = document.cookie