Feat: Add inline category creation in catalog with clickable product names

Added inline category creation functionality to catalog page with user-friendly interface:
- Inline input fields for creating root and nested categories
- '+' button in category tree header for root categories
- '+' icon on hover for each category node to create subcategories
- Clickable product/kit names in catalog grid and list views
- AJAX API endpoint for category creation with validation
- User-friendly error messages for duplicate names and constraints
- Clean implementation using clearTimeout pattern to prevent duplicate requests

Technical details:
- New API endpoint: POST /products/api/categories/create/
- Auto-generates slug and SKU for new categories
- Validates uniqueness, parent existence, and circular references
- Single-request submission with proper race condition handling
- Removes debug logging for production-ready code

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-25 00:24:05 +03:00
parent 03990292a5
commit d566819367
5 changed files with 344 additions and 7 deletions

View File

@@ -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 токена // Получение CSRF токена
function getCsrfToken() { function getCsrfToken() {
const cookieValue = document.cookie const cookieValue = document.cookie

View File

@@ -118,8 +118,9 @@
.catalog-list .catalog-item .card-body .mt-1 { .catalog-list .catalog-item .card-body .mt-1 {
margin-top: 0 !important; margin-top: 0 !important;
} }
/* Кнопка переименования категории */ /* Кнопки управления категориями (переименование, добавление подкатегории) */
.category-rename-btn { .category-rename-btn,
.category-add-child-btn {
opacity: 0; opacity: 0;
cursor: pointer; cursor: pointer;
color: #6c757d; color: #6c757d;
@@ -127,13 +128,18 @@
padding: 2px 4px; padding: 2px 4px;
transition: opacity 0.2s, color 0.15s; 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; opacity: 0.5;
} }
.category-rename-btn:hover { .category-rename-btn:hover {
opacity: 1 !important; opacity: 1 !important;
color: #0d6efd; color: #0d6efd;
} }
.category-add-child-btn:hover {
opacity: 1 !important;
color: #198754;
}
</style> </style>
{% endblock %} {% endblock %}
@@ -145,11 +151,14 @@
<div class="card shadow-sm border-0 mb-3"> <div class="card shadow-sm border-0 mb-3">
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center"> <div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
<strong><i class="bi bi-folder-tree text-primary"></i> Категории</strong> <strong><i class="bi bi-folder-tree text-primary"></i> Категории</strong>
<div class="d-flex gap-2">
<i class="bi bi-plus-circle text-success cursor-pointer" id="add-root-category-btn" title="Добавить категорию" style="font-size: 1.2rem; cursor: pointer;"></i>
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary btn-sm py-0" id="expand-all" title="Развернуть"><i class="bi bi-plus-square"></i></button> <button class="btn btn-outline-secondary btn-sm py-0" id="expand-all" title="Развернуть"><i class="bi bi-plus-square"></i></button>
<button class="btn btn-outline-secondary btn-sm py-0" id="collapse-all" title="Свернуть"><i class="bi bi-dash-square"></i></button> <button class="btn btn-outline-secondary btn-sm py-0" id="collapse-all" title="Свернуть"><i class="bi bi-dash-square"></i></button>
</div> </div>
</div> </div>
</div>
<div class="card-body p-2 category-tree"> <div class="card-body p-2 category-tree">
{% if category_tree %} {% if category_tree %}
{% for node in category_tree %} {% for node in category_tree %}
@@ -200,7 +209,13 @@
</span> </span>
</div> </div>
<div class="card-body p-2"> <div class="card-body p-2">
<div class="small text-truncate fw-medium">{{ item.name }}</div> <div class="small text-truncate fw-medium">
{% if item.item_type == 'kit' %}
<a href="{% url 'products:productkit-detail' item.pk %}" class="text-decoration-none">{{ item.name }}</a>
{% else %}
<a href="{% url 'products:product-detail' item.pk %}" class="text-decoration-none">{{ item.name }}</a>
{% endif %}
</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> <span class="fw-bold text-primary small">{{ item.actual_price|floatformat:0 }} ₽</span>
<small class="text-muted">{{ item.sku }}</small> <small class="text-muted">{{ item.sku }}</small>

View File

@@ -16,6 +16,7 @@
<span class="badge bg-light text-dark border" style="font-size: 0.7rem;">{{ products_count|add:kits_count }}</span> <span class="badge bg-light text-dark border" style="font-size: 0.7rem;">{{ products_count|add:kits_count }}</span>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<i class="bi bi-plus-circle category-add-child-btn" data-parent-id="{{ node.category.pk }}" title="Добавить подкатегорию"></i>
<i class="bi bi-pencil category-rename-btn" data-category-id="{{ node.category.pk }}" title="Переименовать"></i> <i class="bi bi-pencil category-rename-btn" data-category-id="{{ node.category.pk }}" title="Переименовать"></i>
</div> </div>

View File

@@ -47,6 +47,7 @@ urlpatterns = [
path('api/kits/temporary/create/', views.create_temporary_kit_api, name='api-temporary-kit-create'), 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/create/', api_views.create_tag_api, name='api-tag-create'),
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/<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'),
# Photo processing status API (for AJAX polling) # Photo processing status API (for AJAX polling)

View File

@@ -942,3 +942,142 @@ def rename_category_api(request, pk):
'success': False, 'success': False,
'error': f'Ошибка при переименовании: {str(e)}' 'error': f'Ошибка при переименовании: {str(e)}'
}, status=500) }, 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)