Добавлена модель атрибутов для вариативных товаров (ConfigurableKitProductAttribute)

- Создана модель ConfigurableKitProductAttribute с полями name, option, position, visible
- Добавлены формы и formsets для управления атрибутами родительского товара
- Обновлены CRUD представления для работы с атрибутами (создание/редактирование)
- Добавлен блок атрибутов в шаблоны создания/редактирования
- Обновлена страница детального просмотра с отображением атрибутов товара
- Добавлен JavaScript для динамического добавления форм атрибутов
- Реализована валидация дубликатов атрибутов в formset
- Атрибуты сохраняются в transaction.atomic() вместе с вариантами

Теперь можно определять схему атрибутов для экспорта на WooCommerce без использования JSON или ID, только name и option.
This commit is contained in:
2025-11-18 09:24:49 +03:00
parent bdea6b5398
commit c4260f6b1c
15 changed files with 2017 additions and 2 deletions

View File

@@ -0,0 +1,274 @@
/**
* Управление вариантами вариативного товара (ConfigurableKitProduct)
*/
document.addEventListener('DOMContentLoaded', function() {
// Проверяем наличие данных конфигурации
const configDataEl = document.getElementById('configurableKitData');
if (!configDataEl) return;
const config = JSON.parse(configDataEl.textContent);
const modal = new bootstrap.Modal(document.getElementById('addOptionModal'));
// Элементы формы
const saveBtn = document.getElementById('saveOptionBtn');
const kitSelect = document.getElementById('kitSelect');
const attributesInput = document.getElementById('attributesInput');
const isDefaultCheck = document.getElementById('isDefaultCheck');
const errorDiv = document.getElementById('addOptionError');
const spinner = document.getElementById('saveOptionSpinner');
// Добавление варианта
saveBtn.addEventListener('click', async function() {
const kitId = kitSelect.value;
if (!kitId) {
showError('Пожалуйста, выберите комплект');
return;
}
// Показываем спиннер
spinner.classList.remove('d-none');
saveBtn.disabled = true;
hideError();
const formData = new FormData();
formData.append('kit_id', kitId);
formData.append('attributes', attributesInput.value.trim());
formData.append('is_default', isDefaultCheck.checked ? 'true' : 'false');
formData.append('csrfmiddlewaretoken', getCsrfToken());
try {
const response = await fetch(config.addOptionUrl, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
// Добавляем строку в таблицу
addOptionToTable(data.option);
// Закрываем модальное окно
modal.hide();
// Очищаем форму
resetForm();
// Показываем уведомление
showSuccessMessage('Вариант успешно добавлен');
} else {
showError(data.error || 'Ошибка при добавлении варианта');
}
} catch (error) {
console.error('Error:', error);
showError('Произошла ошибка при сохранении');
} finally {
spinner.classList.add('d-none');
saveBtn.disabled = false;
}
});
// Удаление варианта
document.addEventListener('click', async function(e) {
if (e.target.closest('.remove-option-btn')) {
const btn = e.target.closest('.remove-option-btn');
const optionId = btn.dataset.optionId;
if (!confirm('Вы уверены, что хотите удалить этот вариант?')) {
return;
}
btn.disabled = true;
try {
const url = config.removeOptionUrlTemplate.replace('{optionId}', optionId);
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken()
}
});
const data = await response.json();
if (data.success) {
// Удаляем строку из таблицы
const row = btn.closest('tr');
row.remove();
// Проверяем, есть ли ещё варианты
checkIfTableEmpty();
showSuccessMessage('Вариант удалён');
} else {
showError(data.error || 'Ошибка при удалении');
btn.disabled = false;
}
} catch (error) {
console.error('Error:', error);
alert('Произошла ошибка при удалении');
btn.disabled = false;
}
}
});
// Установка по умолчанию
document.addEventListener('click', async function(e) {
if (e.target.closest('.set-default-btn')) {
const btn = e.target.closest('.set-default-btn');
const optionId = btn.dataset.optionId;
btn.disabled = true;
try {
const url = config.setDefaultUrlTemplate.replace('{optionId}', optionId);
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': getCsrfToken()
}
});
const data = await response.json();
if (data.success) {
// Обновляем UI
updateDefaultBadges(optionId);
showSuccessMessage('Вариант установлен как по умолчанию');
} else {
showError(data.error || 'Ошибка при установке');
btn.disabled = false;
}
} catch (error) {
console.error('Error:', error);
alert('Произошла ошибка');
btn.disabled = false;
}
}
});
// Вспомогательные функции
function addOptionToTable(option) {
const tbody = document.querySelector('#optionsTable tbody');
const noOptionsMsg = document.getElementById('noOptionsMessage');
// Если таблицы нет, создаём её
if (!tbody) {
const container = document.getElementById('optionsTableContainer');
if (noOptionsMsg) noOptionsMsg.remove();
container.innerHTML = `
<div class="table-responsive">
<table class="table table-hover table-sm mb-0" id="optionsTable">
<thead class="table-light">
<tr>
<th>Комплект</th>
<th>Артикул</th>
<th>Цена</th>
<th>Атрибуты</th>
<th style="width: 120px;">По умолчанию</th>
<th style="width: 150px;">Действия</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
`;
}
const newTbody = document.querySelector('#optionsTable tbody');
const kitDetailUrl = config.kitDetailUrlTemplate.replace('{kitId}', option.kit_id);
const row = document.createElement('tr');
row.dataset.optionId = option.id;
row.innerHTML = `
<td>
<a href="${kitDetailUrl}" class="text-decoration-none">
${option.kit_name}
</a>
</td>
<td><small class="text-muted">${option.kit_sku}</small></td>
<td><strong>${option.kit_price}</strong> руб.</td>
<td><small class="text-muted option-attributes">${option.attributes}</small></td>
<td class="text-center">
${option.is_default
? '<span class="badge bg-primary default-badge">Да</span>'
: `<button class="btn btn-sm btn-outline-secondary set-default-btn" data-option-id="${option.id}">Установить</button>`
}
</td>
<td>
<button class="btn btn-sm btn-outline-danger remove-option-btn" data-option-id="${option.id}">
<i class="bi bi-trash"></i> Удалить
</button>
</td>
`;
newTbody.appendChild(row);
}
function updateDefaultBadges(newDefaultId) {
// Убираем все badges "По умолчанию"
document.querySelectorAll('.default-badge').forEach(badge => {
const td = badge.closest('td');
const optionId = badge.closest('tr').dataset.optionId;
td.innerHTML = `<button class="btn btn-sm btn-outline-secondary set-default-btn" data-option-id="${optionId}">Установить</button>`;
});
// Добавляем badge к новому default
const targetRow = document.querySelector(`tr[data-option-id="${newDefaultId}"]`);
if (targetRow) {
const td = targetRow.querySelector('td:nth-child(5)');
td.innerHTML = '<span class="badge bg-primary default-badge">Да</span>';
}
}
function checkIfTableEmpty() {
const tbody = document.querySelector('#optionsTable tbody');
if (tbody && tbody.children.length === 0) {
const container = document.getElementById('optionsTableContainer');
container.innerHTML = '<p class="text-muted text-center py-4" id="noOptionsMessage">Нет вариантов. Нажмите "Добавить вариант" для добавления.</p>';
}
}
function resetForm() {
kitSelect.value = '';
attributesInput.value = '';
isDefaultCheck.checked = false;
hideError();
}
function showError(message) {
errorDiv.textContent = message;
errorDiv.classList.remove('d-none');
}
function hideError() {
errorDiv.classList.add('d-none');
}
function getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]').value;
}
function showSuccessMessage(message) {
// Простое alert, можно заменить на toast-уведомление
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3';
alertDiv.style.zIndex = '9999';
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alertDiv);
setTimeout(() => {
alertDiv.remove();
}, 3000);
}
// Сброс формы при закрытии модального окна
document.getElementById('addOptionModal').addEventListener('hidden.bs.modal', function() {
resetForm();
});
});