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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -145,11 +151,14 @@
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-2 category-tree">
|
||||
{% if category_tree %}
|
||||
{% for node in category_tree %}
|
||||
@@ -200,7 +209,13 @@
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
<span class="fw-bold text-primary small">{{ item.actual_price|floatformat:0 }} ₽</span>
|
||||
<small class="text-muted">{{ item.sku }}</small>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<span class="badge bg-light text-dark border" style="font-size: 0.7rem;">{{ products_count|add:kits_count }}</span>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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/<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'),
|
||||
|
||||
# Photo processing status API (for AJAX polling)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user