diff --git a/myproject/products/static/products/js/catalog.js b/myproject/products/static/products/js/catalog.js index bc40808..d48eebc 100644 --- a/myproject/products/static/products/js/catalog.js +++ b/myproject/products/static/products/js/catalog.js @@ -220,6 +220,187 @@ document.addEventListener('DOMContentLoaded', function() { }); }); + // ======================================== + // Inline-создание категорий + // ======================================== + + // Создание корневой категории + const addRootCategoryBtn = document.getElementById('add-root-category-btn'); + if (addRootCategoryBtn) { + addRootCategoryBtn.addEventListener('click', function(e) { + e.stopPropagation(); + showCategoryInput(null); + }); + } + + // Создание подкатегории (через иконку "+") + document.querySelectorAll('.category-add-child-btn').forEach(btn => { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + const parentId = this.dataset.parentId; + const categoryNode = this.closest('.category-node'); + showCategoryInput(parentId, categoryNode); + }); + }); + + // Функция показа inline инпута для создания категории + function showCategoryInput(parentId = null, parentNode = null) { + // Проверяем, нет ли уже активного инпута + const existingInput = document.querySelector('.category-create-input'); + if (existingInput) { + existingInput.focus(); + return; + } + + // Создаем контейнер для нового инпута + const inputContainer = document.createElement('div'); + inputContainer.className = 'category-node'; + inputContainer.style.cssText = 'padding-left: ' + (parentId ? '1rem' : '0'); + + const inputHeader = document.createElement('div'); + inputHeader.className = 'category-header'; + inputHeader.style.cssText = 'background-color: #e7f1ff;'; + + // Иконка (точка для нового узла) + const dotIcon = document.createElement('i'); + dotIcon.className = 'bi bi-dot text-muted'; + inputHeader.appendChild(dotIcon); + + // Иконка папки + const folderIcon = document.createElement('i'); + folderIcon.className = 'bi bi-folder2 text-secondary'; + inputHeader.appendChild(folderIcon); + + // Создаем input + const input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Название категории...'; + input.className = 'category-create-input flex-grow-1'; + input.style.cssText = 'border: 1px solid #198754; border-radius: 3px; padding: 2px 6px; font-size: 0.9rem; outline: none;'; + inputHeader.appendChild(input); + + inputContainer.appendChild(inputHeader); + + // Вставляем в нужное место + if (parentId && parentNode) { + // Подкатегория - вставляем в children контейнер + let childrenContainer = parentNode.querySelector('.category-children'); + + // Если контейнер не существует, создаем его + if (!childrenContainer) { + childrenContainer = document.createElement('div'); + childrenContainer.className = 'category-children'; + parentNode.appendChild(childrenContainer); + } + + // Раскрываем родителя, если свернут + if (childrenContainer.classList.contains('d-none')) { + childrenContainer.classList.remove('d-none'); + const toggle = parentNode.querySelector('.category-toggle'); + if (toggle) { + toggle.classList.remove('collapsed'); + } + } + + childrenContainer.insertBefore(inputContainer, childrenContainer.firstChild); + } else { + // Корневая категория - вставляем в начало дерева + const categoryTree = document.querySelector('.category-tree'); + categoryTree.insertBefore(inputContainer, categoryTree.firstChild); + } + + input.focus(); + + // Флаг для предотвращения повторного вызова + let isSubmitted = false; + let blurTimeoutId = null; + + // Функция создания категории + const createCategory = async () => { + // Проверяем, не была ли уже отправлена форма + if (isSubmitted) return; + + const name = input.value.trim(); + if (!name) { + inputContainer.remove(); + return; + } + + // Отменяем blur обработчик если он запланирован + if (blurTimeoutId) { + clearTimeout(blurTimeoutId); + blurTimeoutId = null; + } + + // Устанавливаем флаг + isSubmitted = true; + + // Показываем загрузку + input.disabled = true; + input.style.opacity = '0.5'; + + try { + const response = await fetch('/products/api/categories/create/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken() + }, + body: JSON.stringify({ + name: name, + parent_id: parentId + }) + }); + + const data = await response.json(); + + if (data.success) { + location.reload(); + } else { + // Удаляем input ДО alert + inputContainer.remove(); + alert(data.error || 'Ошибка при создании категории'); + } + } catch (error) { + console.error('Ошибка при создании категории:', error); + inputContainer.remove(); + alert('Ошибка сети'); + } + }; + + // Функция отмены + const cancelCreate = () => { + if (!isSubmitted) { + inputContainer.remove(); + } + }; + + // Enter - создать + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + createCategory(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelCreate(); + } + }); + + // Потеря фокуса - сохранить или отменить + input.addEventListener('blur', function() { + // Небольшая задержка чтобы не конфликтовать с Enter + blurTimeoutId = setTimeout(() => { + if (!isSubmitted) { + if (input.value.trim()) { + createCategory(); + } else { + cancelCreate(); + } + } + }, 100); + }); + } + // Получение CSRF токена function getCsrfToken() { const cookieValue = document.cookie diff --git a/myproject/products/templates/products/catalog.html b/myproject/products/templates/products/catalog.html index 6923adf..bca7a44 100644 --- a/myproject/products/templates/products/catalog.html +++ b/myproject/products/templates/products/catalog.html @@ -118,8 +118,9 @@ .catalog-list .catalog-item .card-body .mt-1 { margin-top: 0 !important; } - /* Кнопка переименования категории */ - .category-rename-btn { + /* Кнопки управления категориями (переименование, добавление подкатегории) */ + .category-rename-btn, + .category-add-child-btn { opacity: 0; cursor: pointer; color: #6c757d; @@ -127,13 +128,18 @@ padding: 2px 4px; transition: opacity 0.2s, color 0.15s; } - .category-header:hover .category-rename-btn { + .category-header:hover .category-rename-btn, + .category-header:hover .category-add-child-btn { opacity: 0.5; } .category-rename-btn:hover { opacity: 1 !important; color: #0d6efd; } + .category-add-child-btn:hover { + opacity: 1 !important; + color: #198754; + } {% endblock %} @@ -145,9 +151,12 @@
Категории -
- - +
+ +
+ + +
@@ -200,7 +209,13 @@
-
{{ item.name }}
+
+ {% if item.item_type == 'kit' %} + {{ item.name }} + {% else %} + {{ item.name }} + {% endif %} +
{{ item.actual_price|floatformat:0 }} ₽ {{ item.sku }} diff --git a/myproject/products/templates/products/catalog_tree_node.html b/myproject/products/templates/products/catalog_tree_node.html index 123dc98..1abef12 100644 --- a/myproject/products/templates/products/catalog_tree_node.html +++ b/myproject/products/templates/products/catalog_tree_node.html @@ -16,6 +16,7 @@ {{ products_count|add:kits_count }} {% endif %} {% endwith %} +
diff --git a/myproject/products/urls.py b/myproject/products/urls.py index 8318832..0f2edf5 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/create/', api_views.create_category_api, name='api-category-create'), path('api/categories//rename/', api_views.rename_category_api, name='api-category-rename'), # Photo processing status API (for AJAX polling) diff --git a/myproject/products/views/api_views.py b/myproject/products/views/api_views.py index cc3a56c..4fcec45 100644 --- a/myproject/products/views/api_views.py +++ b/myproject/products/views/api_views.py @@ -942,3 +942,142 @@ def rename_category_api(request, pk): 'success': False, 'error': f'Ошибка при переименовании: {str(e)}' }, status=500) + + +def create_category_api(request): + """ + AJAX endpoint для создания новой категории (inline creation). + + Принимает JSON: + { + "name": "Название категории", + "parent_id": 123 # опционально, null для корневой категории + } + + Возвращает JSON: + { + "success": true, + "category": { + "id": 456, + "name": "Название категории", + "slug": "nazvanie-kategorii", + "parent_id": 123 или null + } + } + """ + if request.method != 'POST': + return JsonResponse({ + 'success': False, + 'error': 'Метод не поддерживается' + }, status=405) + + try: + import json + from django.db import IntegrityError + + data = json.loads(request.body) + name = data.get('name', '').strip() + parent_id = data.get('parent_id') + + # Валидация названия + if not name: + return JsonResponse({ + 'success': False, + 'error': 'Название категории не может быть пустым' + }, status=400) + + if len(name) > 200: + return JsonResponse({ + 'success': False, + 'error': 'Название слишком длинное (максимум 200 символов)' + }, status=400) + + # Получаем родительскую категорию, если указана + parent = None + if parent_id: + try: + parent = ProductCategory.objects.get(pk=parent_id) + if not parent.is_active: + return JsonResponse({ + 'success': False, + 'error': 'Родительская категория неактивна' + }, status=400) + except ProductCategory.DoesNotExist: + return JsonResponse({ + 'success': False, + 'error': 'Родительская категория не найдена' + }, status=404) + + # Создаем новую категорию + category = ProductCategory( + name=name, + parent=parent, + is_active=True + ) + + # save() автоматически сгенерирует slug и sku + category.save() + + return JsonResponse({ + 'success': True, + 'category': { + 'id': category.pk, + 'name': category.name, + 'slug': category.slug, + 'parent_id': category.parent_id + } + }) + + except IntegrityError as e: + # Определяем тип ошибки уникальности + error_str = str(e).lower() + if 'unique_active_category_name' in error_str or 'unique constraint' in error_str: + error_message = 'Категория с таким названием уже существует' + elif 'sku' in error_str: + error_message = 'Ошибка при генерации артикула. Попробуйте ещё раз' + else: + error_message = 'Ошибка: категория с такими данными уже существует' + + return JsonResponse({ + 'success': False, + 'error': error_message + }, status=400) + except json.JSONDecodeError: + return JsonResponse({ + 'success': False, + 'error': 'Некорректный JSON' + }, status=400) + except ValidationError as e: + # ValidationError может содержать словарь с ошибками + if hasattr(e, 'message_dict'): + # Извлекаем первое сообщение из словаря + error_messages = [] + for field, messages in e.message_dict.items(): + if isinstance(messages, list): + error_messages.extend(messages) + else: + error_messages.append(str(messages)) + error_text = ' '.join(error_messages) + elif hasattr(e, 'messages'): + error_text = ' '.join(e.messages) + else: + error_text = str(e) + + # Заменяем технические сообщения на понятные + if 'unique_active_category_name' in error_text.lower(): + error_text = 'Категория с таким названием уже существует' + elif 'циклическая ссылка' in error_text.lower(): + error_text = 'Невозможно создать категорию: обнаружена циклическая ссылка' + elif 'слишком глубокая вложенность' in error_text.lower(): + error_text = 'Превышена максимальная глубина вложенности категорий' + + return JsonResponse({ + 'success': False, + 'error': error_text + }, status=400) + except Exception as e: + logger.error(f'Ошибка при создании категории: {str(e)}') + return JsonResponse({ + 'success': False, + 'error': f'Ошибка при создании категории: {str(e)}' + }, status=500)