From 22bf7e137d0689ef9515d75fe4640189c7329c9f Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Tue, 25 Nov 2025 01:23:46 +0300 Subject: [PATCH] Feat: Add inline price editing for products in catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../products/static/products/js/catalog.js | 232 ++++++++++++++++++ .../products/templates/products/catalog.html | 97 +++++++- myproject/products/urls.py | 1 + myproject/products/views/api_views.py | 136 ++++++++++ 4 files changed, 465 insertions(+), 1 deletion(-) diff --git a/myproject/products/static/products/js/catalog.js b/myproject/products/static/products/js/catalog.js index ad05756..0a9a2b1 100644 --- a/myproject/products/static/products/js/catalog.js +++ b/myproject/products/static/products/js/catalog.js @@ -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 = ` + + ${parseFloat(salePrice).toFixed(2)} Ρ€ΡƒΠ±. + + + ${parseFloat(price).toFixed(2)} Ρ€ΡƒΠ±. + + + `; + } else { + // Волько обычная Ρ†Π΅Π½Π° + newHTML = ` + + ${parseFloat(price).toFixed(2)} Ρ€ΡƒΠ±. + + + `; + } + + catalogItem.innerHTML = newHTML; + } + // ΠŸΠΎΠ»ΡƒΡ‡Π΅Π½ΠΈΠ΅ CSRF Ρ‚ΠΎΠΊΠ΅Π½Π° function getCsrfToken() { const cookieValue = document.cookie diff --git a/myproject/products/templates/products/catalog.html b/myproject/products/templates/products/catalog.html index 6f03d27..8bf471b 100644 --- a/myproject/products/templates/products/catalog.html +++ b/myproject/products/templates/products/catalog.html @@ -140,6 +140,56 @@ opacity: 1 !important; 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; + } {% endblock %} @@ -217,7 +267,52 @@ {% endif %}
- {{ item.actual_price|floatformat:0 }} Ρ€ΡƒΠ±. + {% if item.item_type == 'product' %} +
+ {% if item.sale_price %} + {# Бкидочная Ρ†Π΅Π½Π° #} + + {{ item.sale_price|floatformat:2 }} Ρ€ΡƒΠ±. + + {# ΠžΠ±Ρ‹Ρ‡Π½Π°Ρ Ρ†Π΅Π½Π° зачСркнутая #} + + {{ item.price|floatformat:2 }} Ρ€ΡƒΠ±. + + {# Кнопка удалСния скидки #} + + {% else %} + {# Волько обычная Ρ†Π΅Π½Π° #} + + {{ item.price|floatformat:2 }} Ρ€ΡƒΠ±. + + {# Кнопка добавлСния скидки #} + + {% endif %} +
+ {% else %} + {# ProductKit - Π½Π΅ рСдактируСтся #} + {{ item.actual_price|floatformat:2 }} Ρ€ΡƒΠ±. + {% endif %} {{ item.sku }}
diff --git a/myproject/products/urls.py b/myproject/products/urls.py index 0f2edf5..b6fb912 100644 --- a/myproject/products/urls.py +++ b/myproject/products/urls.py @@ -49,6 +49,7 @@ urlpatterns = [ path('api/tags//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//rename/', api_views.rename_category_api, name='api-category-rename'), + path('api/products//update-price/', api_views.update_product_price_api, name='api-update-product-price'), # Photo processing status API (for AJAX polling) path('api/photos/status//', photo_status_api.photo_processing_status, name='api-photo-status'), diff --git a/myproject/products/views/api_views.py b/myproject/products/views/api_views.py index 4fcec45..3e786ed 100644 --- a/myproject/products/views/api_views.py +++ b/myproject/products/views/api_views.py @@ -1081,3 +1081,139 @@ def create_category_api(request): 'success': False, 'error': f'Ошибка ΠΏΡ€ΠΈ создании ΠΊΠ°Ρ‚Π΅Π³ΠΎΡ€ΠΈΠΈ: {str(e)}' }, 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)