diff --git a/myproject/products/static/products/js/catalog.js b/myproject/products/static/products/js/catalog.js new file mode 100644 index 0000000..636e3ff --- /dev/null +++ b/myproject/products/static/products/js/catalog.js @@ -0,0 +1,228 @@ +/** + * Каталог товаров - JavaScript функционал + * - Раскрытие/сворачивание категорий + * - Фильтрация и поиск товаров + * - Переключение вида (карточки/список) + * - Inline-редактирование названий категорий + */ + +document.addEventListener('DOMContentLoaded', function() { + + // ======================================== + // Раскрытие/сворачивание категорий + // ======================================== + document.querySelectorAll('.category-header').forEach(header => { + header.addEventListener('click', function(e) { + // Игнорируем клик по редактируемому полю + if (e.target.classList.contains('category-name-editable') || + e.target.classList.contains('category-name-input')) { + return; + } + + e.stopPropagation(); + const node = this.closest('.category-node'); + const children = node.querySelector('.category-children'); + const items = node.querySelector('.category-items'); + const toggle = this.querySelector('.category-toggle'); + + if (children) { + children.classList.toggle('d-none'); + } + if (items) { + items.classList.toggle('d-none'); + } + if (toggle) { + toggle.classList.toggle('collapsed'); + } + }); + }); + + // Развернуть все + const expandAllBtn = document.getElementById('expand-all'); + if (expandAllBtn) { + expandAllBtn.addEventListener('click', function() { + document.querySelectorAll('.category-children, .category-items').forEach(el => el.classList.remove('d-none')); + document.querySelectorAll('.category-toggle').forEach(el => el.classList.remove('collapsed')); + }); + } + + // Свернуть все + const collapseAllBtn = document.getElementById('collapse-all'); + if (collapseAllBtn) { + collapseAllBtn.addEventListener('click', function() { + document.querySelectorAll('.category-children, .category-items').forEach(el => el.classList.add('d-none')); + document.querySelectorAll('.category-toggle').forEach(el => el.classList.add('collapsed')); + }); + } + + // ======================================== + // Фильтр по типу (товары/комплекты) + // ======================================== + document.querySelectorAll('[data-filter]').forEach(btn => { + btn.addEventListener('click', function() { + document.querySelectorAll('[data-filter]').forEach(b => { + b.classList.remove('active', 'btn-primary'); + b.classList.add('btn-outline-primary'); + }); + this.classList.add('active', 'btn-primary'); + this.classList.remove('btn-outline-primary'); + + const filter = this.dataset.filter; + document.querySelectorAll('.catalog-item').forEach(item => { + item.style.display = (filter === 'all' || item.dataset.type === filter) ? '' : 'none'; + }); + }); + }); + + // ======================================== + // Поиск + // ======================================== + const searchInput = document.getElementById('catalog-search'); + if (searchInput) { + searchInput.addEventListener('input', function() { + const query = this.value.toLowerCase(); + document.querySelectorAll('.catalog-item').forEach(item => { + const text = item.textContent.toLowerCase(); + item.style.display = text.includes(query) ? '' : 'none'; + }); + }); + } + + // ======================================== + // Переключение вида: карточки / список + // ======================================== + const catalogGrid = document.getElementById('catalog-grid'); + const viewGridBtn = document.getElementById('view-grid'); + const viewListBtn = document.getElementById('view-list'); + + if (viewGridBtn && viewListBtn && catalogGrid) { + viewGridBtn.addEventListener('click', function() { + catalogGrid.classList.remove('catalog-list'); + viewGridBtn.classList.add('active'); + viewListBtn.classList.remove('active'); + // Восстановить колонки для карточек + document.querySelectorAll('.catalog-item').forEach(item => { + item.className = item.className.replace(/col-\d+|col-\w+-\d+/g, ''); + item.classList.add('col-6', 'col-lg-4', 'col-xl-3', 'catalog-item'); + }); + }); + + viewListBtn.addEventListener('click', function() { + catalogGrid.classList.add('catalog-list'); + viewListBtn.classList.add('active'); + viewGridBtn.classList.remove('active'); + // Убрать колонки для списка + document.querySelectorAll('.catalog-item').forEach(item => { + item.className = item.className.replace(/col-\d+|col-\w+-\d+/g, ''); + item.classList.add('col-12', 'catalog-item'); + }); + }); + } + + // ======================================== + // Inline-редактирование названий категорий + // ======================================== + document.querySelectorAll('.category-name-editable').forEach(nameSpan => { + nameSpan.addEventListener('click', function(e) { + e.stopPropagation(); + + // Уже редактируется? + if (this.querySelector('input')) return; + + const categoryId = this.dataset.categoryId; + const currentName = this.textContent.trim(); + + // Создаем input + const input = document.createElement('input'); + input.type = 'text'; + input.value = currentName; + input.className = 'category-name-input'; + input.style.cssText = 'width: 100%; border: 1px solid #0d6efd; border-radius: 3px; padding: 2px 6px; font-size: inherit; outline: none;'; + + // Сохраняем оригинальный текст + this.dataset.originalName = currentName; + this.textContent = ''; + this.appendChild(input); + input.focus(); + input.select(); + + // Функция сохранения + const saveCategory = async () => { + const newName = input.value.trim(); + + if (!newName) { + // Отмена - пустое название + cancelEdit(); + return; + } + + if (newName === this.dataset.originalName) { + // Название не изменилось + cancelEdit(); + return; + } + + // Показываем спиннер + input.disabled = true; + input.style.opacity = '0.5'; + + try { + const response = await fetch(`/products/api/categories/${categoryId}/rename/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify({ name: newName }) + }); + + const data = await response.json(); + + if (data.success) { + // Успешно сохранено + this.textContent = data.name; + } else { + // Ошибка + alert(data.error || 'Ошибка при сохранении'); + cancelEdit(); + } + } catch (error) { + console.error('Ошибка:', error); + alert('Ошибка сети'); + cancelEdit(); + } + }; + + // Функция отмены + const cancelEdit = () => { + this.textContent = this.dataset.originalName; + }; + + // Enter - сохранить + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + saveCategory(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelEdit(); + } + }); + + // Потеря фокуса - сохранить + input.addEventListener('blur', function() { + // Небольшая задержка чтобы не конфликтовать с Enter + setTimeout(saveCategory, 100); + }); + }); + }); + + // Получение CSRF токена + function getCsrfToken() { + const cookieValue = document.cookie + .split('; ') + .find(row => row.startsWith('csrftoken=')) + ?.split('=')[1]; + return cookieValue || ''; + } +}); diff --git a/myproject/products/templates/products/catalog.html b/myproject/products/templates/products/catalog.html index f5a8b73..e16b8b0 100644 --- a/myproject/products/templates/products/catalog.html +++ b/myproject/products/templates/products/catalog.html @@ -69,6 +69,66 @@ font-size: 0.65rem; padding: 0.15rem 0.35rem; } + /* Режим списка */ + .catalog-list.row { + gap: 1px !important; + } + .catalog-list .catalog-item { + flex: 0 0 100%; + max-width: 100%; + padding: 0 !important; + } + .catalog-list .catalog-item .card { + flex-direction: row; + align-items: stretch; + padding: 0; + border-radius: 0.25rem; + overflow: hidden; + } + .catalog-list .catalog-item .card .position-relative { + flex-shrink: 0; + } + .catalog-list .catalog-item .card .position-relative img, + .catalog-list .catalog-item .card .position-relative > div { + height: 100% !important; + min-height: 36px; + width: 50px; + border-radius: 0; + object-fit: cover; + } + .catalog-list .catalog-item .card .position-relative .badge { + display: none; + } + .catalog-list .catalog-item .card-body { + flex-grow: 1; + display: flex; + align-items: center; + padding: 0 0.5rem !important; + gap: 0.75rem; + } + .catalog-list .catalog-item .card-body > div:first-child { + flex-grow: 1; + } + .catalog-list .catalog-item .card-body > div:last-child { + display: flex; + align-items: center; + gap: 0.75rem; + white-space: nowrap; + } + .catalog-list .catalog-item .card-body .mt-1 { + margin-top: 0 !important; + } + /* Inline-редактирование названий категорий */ + .category-name-editable { + cursor: text; + padding: 2px 4px; + margin: -2px -4px; + border-radius: 3px; + transition: background-color 0.15s; + } + .category-name-editable:hover { + background-color: #e7f1ff; + } {% endblock %} @@ -76,7 +136,7 @@
-
+
Категории @@ -100,7 +160,7 @@
-
+
Товары и комплекты @@ -111,12 +171,16 @@
+
+ + +
-
+
{% for item in items %} -
+
{% if item.main_photo %} @@ -152,66 +216,6 @@
- +{% load static %} + {% endblock %} diff --git a/myproject/products/templates/products/catalog_tree_node.html b/myproject/products/templates/products/catalog_tree_node.html index 353c9df..1e1fcd0 100644 --- a/myproject/products/templates/products/catalog_tree_node.html +++ b/myproject/products/templates/products/catalog_tree_node.html @@ -10,7 +10,7 @@ {% endif %} - {{ node.category.name }} + {{ node.category.name }} {% with products_count=node.category.products.count kits_count=node.category.kits.count %} {% if products_count or kits_count %} {{ products_count|add:kits_count }} diff --git a/myproject/products/templates/products/partials/catalog_product_row.html b/myproject/products/templates/products/partials/catalog_product_row.html deleted file mode 100644 index 5357098..0000000 --- a/myproject/products/templates/products/partials/catalog_product_row.html +++ /dev/null @@ -1,22 +0,0 @@ -{% comment %} -Строка товара/комплекта в дереве каталога. -{% endcomment %} -
- - {% if item.main_photo %} - - {% else %} -
- -
- {% endif %} -
- - {{ item.name }} - -
{{ item.sku }}
-
- - {% if item.item_type == 'kit' %}К{% else %}Т{% endif %} - -
diff --git a/myproject/products/templates/products/partials/catalog_tree_node.html b/myproject/products/templates/products/partials/catalog_tree_node.html deleted file mode 100644 index 450e8c8..0000000 --- a/myproject/products/templates/products/partials/catalog_tree_node.html +++ /dev/null @@ -1,25 +0,0 @@ -{% comment %} -Рекурсивный шаблон для отображения узла дерева категорий. -Параметры: nodes - список узлов, level - уровень вложенности -{% endcomment %} -{% for node in nodes %} -
-
- {% if node.children %} - - {% else %} - - {% endif %} - - {{ node.category.name }} - - {{ node.category.products.count|default:0 }} - -
- {% if node.children %} -
- {% include 'products/partials/catalog_tree_node.html' with nodes=node.children level=level|add:1 %} -
- {% endif %} -
-{% endfor %} diff --git a/myproject/products/urls.py b/myproject/products/urls.py index d3c05f4..8318832 100644 --- a/myproject/products/urls.py +++ b/myproject/products/urls.py @@ -47,6 +47,7 @@ urlpatterns = [ path('api/kits/temporary/create/', views.create_temporary_kit_api, name='api-temporary-kit-create'), path('api/tags/create/', api_views.create_tag_api, name='api-tag-create'), path('api/tags//toggle/', api_views.toggle_tag_status_api, name='api-tag-toggle'), + path('api/categories//rename/', api_views.rename_category_api, name='api-category-rename'), # 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 320a8b9..cc3a56c 100644 --- a/myproject/products/views/api_views.py +++ b/myproject/products/views/api_views.py @@ -7,7 +7,7 @@ from django.core.cache import cache from django.core.exceptions import ValidationError import logging -from ..models import Product, ProductVariantGroup, ProductKit +from ..models import Product, ProductVariantGroup, ProductKit, ProductCategory logger = logging.getLogger(__name__) @@ -873,3 +873,72 @@ def toggle_tag_status_api(request, pk): 'success': False, 'error': f'Ошибка при обновлении тега: {str(e)}' }, status=500) + + +def rename_category_api(request, pk): + """ + AJAX endpoint для переименования категории (inline editing). + + Принимает JSON: + { + "name": "Новое название" + } + + Возвращает JSON: + { + "success": true, + "name": "Новое название" + } + """ + if request.method != 'POST': + return JsonResponse({ + 'success': False, + 'error': 'Метод не поддерживается' + }, status=405) + + try: + import json + + data = json.loads(request.body) + name = data.get('name', '').strip() + + # Валидация + if not name: + return JsonResponse({ + 'success': False, + 'error': 'Название категории не может быть пустым' + }, status=400) + + if len(name) > 255: + return JsonResponse({ + 'success': False, + 'error': 'Название слишком длинное (максимум 255 символов)' + }, status=400) + + # Получаем категорию + category = ProductCategory.objects.get(pk=pk) + + # Обновляем название + category.name = name + category.save(update_fields=['name']) + + return JsonResponse({ + 'success': True, + 'name': category.name + }) + + except ProductCategory.DoesNotExist: + return JsonResponse({ + 'success': False, + 'error': 'Категория не найдена' + }, status=404) + except json.JSONDecodeError: + return JsonResponse({ + 'success': False, + 'error': 'Некорректный JSON' + }, status=400) + except Exception as e: + return JsonResponse({ + 'success': False, + 'error': f'Ошибка при переименовании: {str(e)}' + }, status=500)