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 токена
|
// Получение CSRF токена
|
||||||
function getCsrfToken() {
|
function getCsrfToken() {
|
||||||
const cookieValue = document.cookie
|
const cookieValue = document.cookie
|
||||||
|
|||||||
@@ -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,9 +151,12 @@
|
|||||||
<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="btn-group btn-group-sm">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-outline-secondary btn-sm py-0" id="expand-all" title="Развернуть"><i class="bi bi-plus-square"></i></button>
|
<i class="bi bi-plus-circle text-success cursor-pointer" id="add-root-category-btn" title="Добавить категорию" style="font-size: 1.2rem; cursor: pointer;"></i>
|
||||||
<button class="btn btn-outline-secondary btn-sm py-0" id="collapse-all" title="Свернуть"><i class="bi bi-dash-square"></i></button>
|
<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>
|
</div>
|
||||||
<div class="card-body p-2 category-tree">
|
<div class="card-body p-2 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user