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 токена
function getCsrfToken() {
const cookieValue = document.cookie