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
|
||||
|
||||
Reference in New Issue
Block a user