Добавлено API для получения списка атрибутов и их значений; обновлены формы для работы с атрибутами через JavaScript
This commit is contained in:
@@ -858,7 +858,7 @@ ConfigurableProductAttributeFormSetCreate = inlineformset_factory(
|
|||||||
formset=BaseConfigurableProductAttributeFormSet,
|
formset=BaseConfigurableProductAttributeFormSet,
|
||||||
# Убрали 'option' - значения будут добавляться через JavaScript в карточку
|
# Убрали 'option' - значения будут добавляться через JavaScript в карточку
|
||||||
fields=['name', 'position', 'visible'],
|
fields=['name', 'position', 'visible'],
|
||||||
extra=1,
|
extra=0, # Пользователь добавляет параметры через кнопку "Добавить параметр"
|
||||||
can_delete=True,
|
can_delete=True,
|
||||||
min_num=0,
|
min_num=0,
|
||||||
validate_min=False,
|
validate_min=False,
|
||||||
|
|||||||
@@ -106,13 +106,35 @@ input[name*="DELETE"] {
|
|||||||
|
|
||||||
{{ attribute_formset.management_form }}
|
{{ attribute_formset.management_form }}
|
||||||
|
|
||||||
<!-- Список доступных комплектов для JavaScript -->
|
<!-- Данные для JavaScript -->
|
||||||
<script>
|
<script>
|
||||||
window.AVAILABLE_KITS = [
|
window.AVAILABLE_KITS = [
|
||||||
{% for kit in available_kits %}
|
{% for kit in available_kits %}
|
||||||
{ id: {{ kit.id }}, name: "{{ kit.name }}" }{% if not forloop.last %},{% endif %}
|
{ id: {{ kit.id }}, name: "{{ kit.name|escapejs }}" }{% if not forloop.last %},{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Справочник атрибутов с их значениями
|
||||||
|
window.PRODUCT_ATTRIBUTES = [
|
||||||
|
{% for attr in product_attributes %}
|
||||||
|
{
|
||||||
|
id: {{ attr.id }},
|
||||||
|
name: "{{ attr.name|escapejs }}",
|
||||||
|
slug: "{{ attr.slug|escapejs }}",
|
||||||
|
values: [
|
||||||
|
{% for val in attr.values.all %}
|
||||||
|
{ id: {{ val.id }}, value: "{{ val.value|escapejs }}", slug: "{{ val.slug|escapejs }}" }{% if not forloop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
]
|
||||||
|
}{% if not forloop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
// URL для API
|
||||||
|
window.API_URLS = {
|
||||||
|
createAttribute: "{% url 'products:api-attribute-create' %}",
|
||||||
|
addValue: function(attrId) { return `/products/api/attributes/${attrId}/values/add/`; }
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% if attribute_formset.non_form_errors %}
|
{% if attribute_formset.non_form_errors %}
|
||||||
@@ -127,10 +149,26 @@ input[name*="DELETE"] {
|
|||||||
{{ form.id }}
|
{{ form.id }}
|
||||||
|
|
||||||
<div class="row align-items-end g-3 mb-3">
|
<div class="row align-items-end g-3 mb-3">
|
||||||
<!-- Название параметра -->
|
<!-- Название параметра с выбором из справочника -->
|
||||||
<div class="col-md-3">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">{{ form.name.label }}</label>
|
<label class="form-label fw-semibold">{{ form.name.label }}</label>
|
||||||
{{ form.name }}
|
<div class="input-group">
|
||||||
|
<select class="form-select param-name-select"
|
||||||
|
data-name-input="id_attributes-{{ forloop.counter0 }}-name">
|
||||||
|
<option value="">-- Выберите атрибут --</option>
|
||||||
|
{% for attr in product_attributes %}
|
||||||
|
<option value="{{ attr.name }}" {% if form.name.value == attr.name %}selected{% endif %}>
|
||||||
|
{{ attr.name }} ({{ attr.values.count }} знач.)
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
<option value="__new__">+ Создать новый...</option>
|
||||||
|
</select>
|
||||||
|
<input type="hidden"
|
||||||
|
name="attributes-{{ forloop.counter0 }}-name"
|
||||||
|
id="id_attributes-{{ forloop.counter0 }}-name"
|
||||||
|
class="param-name-input"
|
||||||
|
value="{{ form.name.value|default:'' }}">
|
||||||
|
</div>
|
||||||
{% if form.name.errors %}
|
{% if form.name.errors %}
|
||||||
<div class="text-danger small">{{ form.name.errors.0 }}</div>
|
<div class="text-danger small">{{ form.name.errors.0 }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -169,9 +207,14 @@ input[name*="DELETE"] {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Значения параметра (добавляются инлайн через JavaScript) -->
|
<!-- Значения параметра -->
|
||||||
<div class="parameter-values-container mt-3 p-3 bg-white rounded border">
|
<div class="parameter-values-container mt-3 p-3 bg-white rounded border">
|
||||||
<label class="form-label small fw-semibold d-block mb-2">Значения параметра:</label>
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<label class="form-label small fw-semibold mb-0">Значения параметра:</label>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary load-values-btn" title="Загрузить значения из справочника">
|
||||||
|
<i class="bi bi-arrow-down-circle me-1"></i> Загрузить из справочника
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="value-fields-wrapper" data-param-index="{{ forloop.counter0 }}">
|
<div class="value-fields-wrapper" data-param-index="{{ forloop.counter0 }}">
|
||||||
<!-- Значения будут добавлены через JavaScript -->
|
<!-- Значения будут добавлены через JavaScript -->
|
||||||
</div>
|
</div>
|
||||||
@@ -231,14 +274,14 @@ function addValueField(container, valueText = '', kitId = '') {
|
|||||||
const index = container.querySelectorAll('.value-field-group').length;
|
const index = container.querySelectorAll('.value-field-group').length;
|
||||||
const fieldId = `value-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
const fieldId = `value-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
// Получаем список доступных комплектов из скрытого элемента
|
// Получаем список доступных комплектов
|
||||||
const kitOptionsHtml = getKitOptionsHtml(kitId);
|
const kitOptionsHtml = getKitOptionsHtml(kitId);
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<div class="value-field-group d-flex gap-2 mb-2 align-items-start">
|
<div class="value-field-group d-flex gap-2 mb-2 align-items-start">
|
||||||
<input type="text" class="form-control form-control-sm parameter-value-input"
|
<input type="text" class="form-control form-control-sm parameter-value-input"
|
||||||
placeholder="Введите значение"
|
placeholder="Введите значение"
|
||||||
value="${valueText}"
|
value="${escapeHtml(valueText)}"
|
||||||
data-field-id="${fieldId}"
|
data-field-id="${fieldId}"
|
||||||
style="min-width: 100px;">
|
style="min-width: 100px;">
|
||||||
<select class="form-select form-select-sm parameter-kit-select"
|
<select class="form-select form-select-sm parameter-kit-select"
|
||||||
@@ -258,17 +301,27 @@ function addValueField(container, valueText = '', kitId = '') {
|
|||||||
|
|
||||||
// Установка выбранного комплекта если был передан
|
// Установка выбранного комплекта если был передан
|
||||||
if (kitId) {
|
if (kitId) {
|
||||||
const kitSelect = container.querySelector('.parameter-kit-select:last-child');
|
const lastSelect = container.querySelector('.value-field-group:last-child .parameter-kit-select');
|
||||||
if (kitSelect) {
|
if (lastSelect) {
|
||||||
kitSelect.value = kitId;
|
lastSelect.value = kitId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обработчик удаления значения
|
// Обработчик удаления значения
|
||||||
container.querySelector('.remove-value-btn:last-child').addEventListener('click', function(e) {
|
const lastRemoveBtn = container.querySelector('.value-field-group:last-child .remove-value-btn');
|
||||||
e.preventDefault();
|
if (lastRemoveBtn) {
|
||||||
this.closest('.value-field-group').remove();
|
lastRemoveBtn.addEventListener('click', function(e) {
|
||||||
});
|
e.preventDefault();
|
||||||
|
this.closest('.value-field-group').remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экранирование HTML
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получить HTML с опциями комплектов
|
// Получить HTML с опциями комплектов
|
||||||
@@ -276,23 +329,89 @@ function getKitOptionsHtml(selectedKitId = '') {
|
|||||||
const kitsData = window.AVAILABLE_KITS || [];
|
const kitsData = window.AVAILABLE_KITS || [];
|
||||||
return kitsData.map(kit => {
|
return kitsData.map(kit => {
|
||||||
const selected = kit.id == selectedKitId ? 'selected' : '';
|
const selected = kit.id == selectedKitId ? 'selected' : '';
|
||||||
return `<option value="${kit.id}" ${selected}>${kit.name}</option>`;
|
return `<option value="${kit.id}" ${selected}>${escapeHtml(kit.name)}</option>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Найти атрибут в справочнике по имени
|
||||||
|
function findAttributeByName(name) {
|
||||||
|
const attributes = window.PRODUCT_ATTRIBUTES || [];
|
||||||
|
return attributes.find(attr => attr.name.toLowerCase() === name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
// Инициализация существующих параметров с их значениями из БД
|
// Инициализация существующих параметров с их значениями из БД
|
||||||
function initializeParameterCards() {
|
function initializeParameterCards() {
|
||||||
document.querySelectorAll('.attribute-card').forEach(card => {
|
document.querySelectorAll('.attribute-card').forEach(card => {
|
||||||
// Если это существующий параметр с ID, загрузим его значения
|
initCardHandlers(card);
|
||||||
// Это будет обработано при первой загрузке в view
|
|
||||||
initAddValueBtn(card);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация кнопки добавления значения для карточки
|
// Инициализация всех обработчиков для карточки
|
||||||
|
function initCardHandlers(card) {
|
||||||
|
initAddValueBtn(card);
|
||||||
|
initLoadValuesBtn(card);
|
||||||
|
initAttributeSelect(card);
|
||||||
|
initParamDeleteToggle(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить HTML опций для select атрибутов
|
||||||
|
function getAttributeOptionsHtml(selectedName = '') {
|
||||||
|
const attributes = window.PRODUCT_ATTRIBUTES || [];
|
||||||
|
let html = '<option value="">-- Выберите атрибут --</option>';
|
||||||
|
attributes.forEach(attr => {
|
||||||
|
const selected = attr.name === selectedName ? 'selected' : '';
|
||||||
|
html += `<option value="${escapeHtml(attr.name)}" ${selected}>${escapeHtml(attr.name)} (${attr.values.length} знач.)</option>`;
|
||||||
|
});
|
||||||
|
html += '<option value="__new__">+ Создать новый...</option>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация select выбора атрибута
|
||||||
|
function initAttributeSelect(card) {
|
||||||
|
const select = card.querySelector('.param-name-select');
|
||||||
|
if (select && !select.dataset.initialized) {
|
||||||
|
select.dataset.initialized = 'true';
|
||||||
|
select.addEventListener('change', function() {
|
||||||
|
const hiddenInput = card.querySelector('.param-name-input');
|
||||||
|
const value = this.value;
|
||||||
|
|
||||||
|
if (value === '__new__') {
|
||||||
|
// Открываем модальное окно для создания нового атрибута
|
||||||
|
openCreateAttributeModal('', card);
|
||||||
|
// Сбрасываем select
|
||||||
|
this.value = '';
|
||||||
|
} else {
|
||||||
|
// Устанавливаем значение в скрытое поле
|
||||||
|
if (hiddenInput) {
|
||||||
|
hiddenInput.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если выбран атрибут из справочника - предлагаем загрузить значения
|
||||||
|
if (value) {
|
||||||
|
const attribute = findAttributeByName(value);
|
||||||
|
if (attribute && attribute.values.length > 0) {
|
||||||
|
const container = card.querySelector('.value-fields-wrapper');
|
||||||
|
const existingValues = container.querySelectorAll('.value-field-group');
|
||||||
|
|
||||||
|
if (existingValues.length === 0) {
|
||||||
|
// Автоматически загружаем значения если их ещё нет
|
||||||
|
attribute.values.forEach(val => {
|
||||||
|
addValueField(container, val.value, '');
|
||||||
|
});
|
||||||
|
showToast(`Загружено ${attribute.values.length} значений для "${value}"`, 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация кнопки добавления значения
|
||||||
function initAddValueBtn(card) {
|
function initAddValueBtn(card) {
|
||||||
const addBtn = card.querySelector('.add-value-btn');
|
const addBtn = card.querySelector('.add-value-btn');
|
||||||
if (addBtn) {
|
if (addBtn && !addBtn.dataset.initialized) {
|
||||||
|
addBtn.dataset.initialized = 'true';
|
||||||
addBtn.addEventListener('click', function(e) {
|
addBtn.addEventListener('click', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const container = this.closest('.parameter-values-container').querySelector('.value-fields-wrapper');
|
const container = this.closest('.parameter-values-container').querySelector('.value-fields-wrapper');
|
||||||
@@ -301,6 +420,263 @@ function initAddValueBtn(card) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Инициализация кнопки загрузки значений из справочника
|
||||||
|
function initLoadValuesBtn(card) {
|
||||||
|
const loadBtn = card.querySelector('.load-values-btn');
|
||||||
|
if (loadBtn && !loadBtn.dataset.initialized) {
|
||||||
|
loadBtn.dataset.initialized = 'true';
|
||||||
|
loadBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const nameInput = card.querySelector('.param-name-input');
|
||||||
|
const attrName = nameInput ? nameInput.value.trim() : '';
|
||||||
|
|
||||||
|
if (!attrName) {
|
||||||
|
showToast('Сначала введите или выберите название параметра', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attribute = findAttributeByName(attrName);
|
||||||
|
if (!attribute) {
|
||||||
|
showToast(`Атрибут "${attrName}" не найден в справочнике. Создайте его или добавьте значения вручную.`, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attribute.values.length === 0) {
|
||||||
|
showToast(`У атрибута "${attrName}" нет значений в справочнике`, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем значения из справочника
|
||||||
|
const container = card.querySelector('.value-fields-wrapper');
|
||||||
|
|
||||||
|
// Спрашиваем пользователя, если уже есть значения
|
||||||
|
const existingValues = container.querySelectorAll('.value-field-group');
|
||||||
|
if (existingValues.length > 0) {
|
||||||
|
if (!confirm('Уже есть добавленные значения. Добавить значения из справочника дополнительно?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем значения
|
||||||
|
attribute.values.forEach(val => {
|
||||||
|
addValueField(container, val.value, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
showToast(`Загружено ${attribute.values.length} значений из справочника`, 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открыть модальное окно создания атрибута
|
||||||
|
function openCreateAttributeModal(attrName, card) {
|
||||||
|
// Получаем или создаем модальное окно
|
||||||
|
let modal = document.getElementById('createAttributeModal');
|
||||||
|
if (!modal) {
|
||||||
|
createAttributeModal();
|
||||||
|
modal = document.getElementById('createAttributeModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заполняем название
|
||||||
|
const nameInput = modal.querySelector('#newAttributeName');
|
||||||
|
nameInput.value = attrName;
|
||||||
|
|
||||||
|
// Очищаем значения
|
||||||
|
const valuesContainer = modal.querySelector('#newAttributeValues');
|
||||||
|
valuesContainer.innerHTML = '';
|
||||||
|
addNewAttributeValueField(valuesContainer);
|
||||||
|
|
||||||
|
// Сохраняем ссылку на карточку
|
||||||
|
modal.dataset.targetCardIndex = card.dataset.formsetIndex;
|
||||||
|
|
||||||
|
// Показываем модальное окно
|
||||||
|
const bsModal = new bootstrap.Modal(modal);
|
||||||
|
bsModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создать модальное окно для создания атрибута
|
||||||
|
function createAttributeModal() {
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal fade" id="createAttributeModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Создать новый атрибут</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Название атрибута</label>
|
||||||
|
<input type="text" class="form-control" id="newAttributeName" placeholder="Например: Длина стебля">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Значения атрибута <small class="text-muted">(опционально)</small></label>
|
||||||
|
<div id="newAttributeValues"></div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" id="addNewAttributeValueBtn">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i> Добавить значение
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="saveNewAttributeBtn">
|
||||||
|
<i class="bi bi-check-circle me-1"></i> Создать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
|
||||||
|
// Инициализируем обработчики
|
||||||
|
const modal = document.getElementById('createAttributeModal');
|
||||||
|
|
||||||
|
document.getElementById('addNewAttributeValueBtn').addEventListener('click', function() {
|
||||||
|
const container = document.getElementById('newAttributeValues');
|
||||||
|
addNewAttributeValueField(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('saveNewAttributeBtn').addEventListener('click', async function() {
|
||||||
|
await saveNewAttribute();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавить поле значения в модальном окне
|
||||||
|
function addNewAttributeValueField(container) {
|
||||||
|
const html = `
|
||||||
|
<div class="input-group mb-2 new-value-field">
|
||||||
|
<input type="text" class="form-control" placeholder="Значение">
|
||||||
|
<button type="button" class="btn btn-outline-danger remove-new-value-btn">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.insertAdjacentHTML('beforeend', html);
|
||||||
|
|
||||||
|
const lastRemoveBtn = container.querySelector('.new-value-field:last-child .remove-new-value-btn');
|
||||||
|
lastRemoveBtn.addEventListener('click', function() {
|
||||||
|
this.closest('.new-value-field').remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранить новый атрибут через API
|
||||||
|
async function saveNewAttribute() {
|
||||||
|
const modal = document.getElementById('createAttributeModal');
|
||||||
|
const name = document.getElementById('newAttributeName').value.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
showToast('Введите название атрибута', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById('saveNewAttributeBtn');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Сохранение...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Создаем атрибут
|
||||||
|
const csrfToken = document.querySelector('[name="csrfmiddlewaretoken"]').value;
|
||||||
|
|
||||||
|
const response = await fetch(window.API_URLS.createAttribute, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: name })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'Ошибка создания атрибута');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAttr = {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
slug: data.slug,
|
||||||
|
values: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Добавляем значения если они есть
|
||||||
|
const valueInputs = document.querySelectorAll('#newAttributeValues .new-value-field input');
|
||||||
|
for (const input of valueInputs) {
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (value) {
|
||||||
|
const valResponse = await fetch(window.API_URLS.addValue(data.id), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ value: value })
|
||||||
|
});
|
||||||
|
const valData = await valResponse.json();
|
||||||
|
if (valData.success) {
|
||||||
|
newAttr.values.push({
|
||||||
|
id: valData.id,
|
||||||
|
value: valData.value,
|
||||||
|
slug: valData.slug
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем в локальный справочник
|
||||||
|
window.PRODUCT_ATTRIBUTES.push(newAttr);
|
||||||
|
|
||||||
|
// Обновляем все select'ы атрибутов
|
||||||
|
updateAttributeSelects();
|
||||||
|
|
||||||
|
// Закрываем модальное окно
|
||||||
|
const bsModal = bootstrap.Modal.getInstance(modal);
|
||||||
|
bsModal.hide();
|
||||||
|
|
||||||
|
showToast(`Атрибут "${name}" успешно создан!`, 'success');
|
||||||
|
|
||||||
|
// Если есть целевая карточка, обновляем её
|
||||||
|
const cardIndex = modal.dataset.targetCardIndex;
|
||||||
|
if (cardIndex !== undefined) {
|
||||||
|
const card = document.querySelector(`[data-formset-index="${cardIndex}"]`);
|
||||||
|
if (card) {
|
||||||
|
// Устанавливаем новый атрибут в select
|
||||||
|
const select = card.querySelector('.param-name-select');
|
||||||
|
const hiddenInput = card.querySelector('.param-name-input');
|
||||||
|
if (select) {
|
||||||
|
select.value = newAttr.name;
|
||||||
|
}
|
||||||
|
if (hiddenInput) {
|
||||||
|
hiddenInput.value = newAttr.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем значения если есть
|
||||||
|
if (newAttr.values.length > 0) {
|
||||||
|
const container = card.querySelector('.value-fields-wrapper');
|
||||||
|
newAttr.values.forEach(val => {
|
||||||
|
addValueField(container, val.value, '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.message, 'danger');
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.innerHTML = '<i class="bi bi-check-circle me-1"></i> Создать';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновить все select'ы атрибутов после добавления нового
|
||||||
|
function updateAttributeSelects() {
|
||||||
|
document.querySelectorAll('.param-name-select').forEach(select => {
|
||||||
|
const currentValue = select.value;
|
||||||
|
select.innerHTML = getAttributeOptionsHtml(currentValue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Добавление нового параметра
|
// Добавление нового параметра
|
||||||
document.getElementById('addParameterBtn')?.addEventListener('click', function() {
|
document.getElementById('addParameterBtn')?.addEventListener('click', function() {
|
||||||
const container = document.getElementById('attributeFormsetContainer');
|
const container = document.getElementById('attributeFormsetContainer');
|
||||||
@@ -312,12 +688,19 @@ document.getElementById('addParameterBtn')?.addEventListener('click', function()
|
|||||||
<input type="hidden" name="attributes-${formIdx}-id">
|
<input type="hidden" name="attributes-${formIdx}-id">
|
||||||
|
|
||||||
<div class="row align-items-end g-3 mb-3">
|
<div class="row align-items-end g-3 mb-3">
|
||||||
<div class="col-md-3">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">Название параметра</label>
|
<label class="form-label fw-semibold">Название параметра</label>
|
||||||
<input type="text" name="attributes-${formIdx}-name"
|
<div class="input-group">
|
||||||
id="id_attributes-${formIdx}-name"
|
<select class="form-select param-name-select"
|
||||||
class="form-control param-name-input"
|
data-name-input="id_attributes-${formIdx}-name">
|
||||||
placeholder="Например: Длина, Цвет, Размер">
|
${getAttributeOptionsHtml()}
|
||||||
|
</select>
|
||||||
|
<input type="hidden"
|
||||||
|
name="attributes-${formIdx}-name"
|
||||||
|
id="id_attributes-${formIdx}-name"
|
||||||
|
class="param-name-input"
|
||||||
|
value="">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label small">Порядок</label>
|
<label class="form-label small">Порядок</label>
|
||||||
@@ -348,7 +731,12 @@ document.getElementById('addParameterBtn')?.addEventListener('click', function()
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="parameter-values-container mt-3 p-3 bg-white rounded border">
|
<div class="parameter-values-container mt-3 p-3 bg-white rounded border">
|
||||||
<label class="form-label small fw-semibold d-block mb-2">Значения параметра:</label>
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<label class="form-label small fw-semibold mb-0">Значения параметра:</label>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary load-values-btn" title="Загрузить значения из справочника">
|
||||||
|
<i class="bi bi-arrow-down-circle me-1"></i> Загрузить из справочника
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="value-fields-wrapper" data-param-index="${formIdx}">
|
<div class="value-fields-wrapper" data-param-index="${formIdx}">
|
||||||
<!-- Значения добавляются сюда -->
|
<!-- Значения добавляются сюда -->
|
||||||
</div>
|
</div>
|
||||||
@@ -364,16 +752,17 @@ document.getElementById('addParameterBtn')?.addEventListener('click', function()
|
|||||||
|
|
||||||
// Инициализируем новую карточку
|
// Инициализируем новую карточку
|
||||||
const newCard = container.querySelector(`[data-formset-index="${formIdx}"]`);
|
const newCard = container.querySelector(`[data-formset-index="${formIdx}"]`);
|
||||||
initAddValueBtn(newCard);
|
initCardHandlers(newCard);
|
||||||
|
|
||||||
// Инициализируем удаление параметра
|
// Фокус на select
|
||||||
initParamDeleteToggle(newCard);
|
newCard.querySelector('.param-name-select')?.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Функция для скрытия удаленного параметра
|
// Функция для скрытия удаленного параметра
|
||||||
function initParamDeleteToggle(card) {
|
function initParamDeleteToggle(card) {
|
||||||
const deleteCheckbox = card.querySelector('input[type="checkbox"][name$="-DELETE"]');
|
const deleteCheckbox = card.querySelector('input[type="checkbox"][name$="-DELETE"]');
|
||||||
if (deleteCheckbox) {
|
if (deleteCheckbox && !deleteCheckbox.dataset.initialized) {
|
||||||
|
deleteCheckbox.dataset.initialized = 'true';
|
||||||
deleteCheckbox.addEventListener('change', function() {
|
deleteCheckbox.addEventListener('change', function() {
|
||||||
if (this.checked) {
|
if (this.checked) {
|
||||||
card.style.opacity = '0.5';
|
card.style.opacity = '0.5';
|
||||||
@@ -388,12 +777,7 @@ function initParamDeleteToggle(card) {
|
|||||||
|
|
||||||
// Функция для сериализации значений параметров и их комплектов перед отправкой формы
|
// Функция для сериализации значений параметров и их комплектов перед отправкой формы
|
||||||
function serializeAttributeValues() {
|
function serializeAttributeValues() {
|
||||||
/**
|
|
||||||
* Перед отправкой формы нужно сериализовать все значения параметров
|
|
||||||
* и их связанные комплекты из инлайн input'ов в скрытые JSON поля
|
|
||||||
*/
|
|
||||||
document.querySelectorAll('.attribute-card').forEach((card, idx) => {
|
document.querySelectorAll('.attribute-card').forEach((card, idx) => {
|
||||||
// Получаем все инпуты с значениями и их комплектами внутри этой карточки
|
|
||||||
const valueGroups = card.querySelectorAll('.value-field-group');
|
const valueGroups = card.querySelectorAll('.value-field-group');
|
||||||
const values = [];
|
const values = [];
|
||||||
const kits = [];
|
const kits = [];
|
||||||
@@ -406,7 +790,7 @@ function serializeAttributeValues() {
|
|||||||
const value = valueInput.value.trim();
|
const value = valueInput.value.trim();
|
||||||
const kitId = kitSelect ? kitSelect.value : '';
|
const kitId = kitSelect ? kitSelect.value : '';
|
||||||
|
|
||||||
if (value && kitId) { // Требуем чтобы оба поля были заполнены
|
if (value && kitId) {
|
||||||
values.push(value);
|
values.push(value);
|
||||||
kits.push(parseInt(kitId));
|
kits.push(parseInt(kitId));
|
||||||
}
|
}
|
||||||
@@ -414,7 +798,6 @@ function serializeAttributeValues() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Создаем или обновляем скрытые поля JSON
|
// Создаем или обновляем скрытые поля JSON
|
||||||
// поле values: ["50", "60", "70"]
|
|
||||||
const valuesFieldName = `attributes-${idx}-values`;
|
const valuesFieldName = `attributes-${idx}-values`;
|
||||||
let valuesField = document.querySelector(`input[name="${valuesFieldName}"]`);
|
let valuesField = document.querySelector(`input[name="${valuesFieldName}"]`);
|
||||||
if (!valuesField) {
|
if (!valuesField) {
|
||||||
@@ -425,7 +808,6 @@ function serializeAttributeValues() {
|
|||||||
}
|
}
|
||||||
valuesField.value = JSON.stringify(values);
|
valuesField.value = JSON.stringify(values);
|
||||||
|
|
||||||
// поле kits: [1, 2, 3] (id ProductKit)
|
|
||||||
const kitsFieldName = `attributes-${idx}-kits`;
|
const kitsFieldName = `attributes-${idx}-kits`;
|
||||||
let kitsField = document.querySelector(`input[name="${kitsFieldName}"]`);
|
let kitsField = document.querySelector(`input[name="${kitsFieldName}"]`);
|
||||||
if (!kitsField) {
|
if (!kitsField) {
|
||||||
@@ -438,18 +820,52 @@ function serializeAttributeValues() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toast уведомления
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
// Создаем контейнер если его нет
|
||||||
|
let container = document.getElementById('toastContainer');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.id = 'toastContainer';
|
||||||
|
container.className = 'toast-container position-fixed bottom-0 end-0 p-3';
|
||||||
|
container.style.zIndex = '1100';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgClass = {
|
||||||
|
'success': 'bg-success',
|
||||||
|
'danger': 'bg-danger',
|
||||||
|
'warning': 'bg-warning',
|
||||||
|
'info': 'bg-info'
|
||||||
|
}[type] || 'bg-info';
|
||||||
|
|
||||||
|
const textClass = type === 'warning' ? 'text-dark' : 'text-white';
|
||||||
|
|
||||||
|
const toastHtml = `
|
||||||
|
<div class="toast ${bgClass} ${textClass}" role="alert">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">${escapeHtml(message)}</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.insertAdjacentHTML('beforeend', toastHtml);
|
||||||
|
const toastEl = container.lastElementChild;
|
||||||
|
const toast = new bootstrap.Toast(toastEl, { delay: 4000 });
|
||||||
|
toast.show();
|
||||||
|
|
||||||
|
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
|
||||||
|
}
|
||||||
|
|
||||||
// Инициализация при загрузке страницы
|
// Инициализация при загрузке страницы
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initializeParameterCards();
|
initializeParameterCards();
|
||||||
document.querySelectorAll('.attribute-card').forEach(card => {
|
|
||||||
initParamDeleteToggle(card);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Добавляем сериализацию значений перед отправкой формы
|
// Добавляем сериализацию значений перед отправкой формы
|
||||||
const form = document.querySelector('form');
|
const form = document.querySelector('form');
|
||||||
if (form) {
|
if (form) {
|
||||||
form.addEventListener('submit', function(e) {
|
form.addEventListener('submit', function(e) {
|
||||||
// Перед отправкой формы сериализуем все значения параметров
|
|
||||||
serializeAttributeValues();
|
serializeAttributeValues();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ urlpatterns = [
|
|||||||
path('attributes/<int:pk>/delete/', views.ProductAttributeDeleteView.as_view(), name='attribute-delete'),
|
path('attributes/<int:pk>/delete/', views.ProductAttributeDeleteView.as_view(), name='attribute-delete'),
|
||||||
|
|
||||||
# API для атрибутов
|
# API для атрибутов
|
||||||
|
path('api/attributes/', views.get_attributes_list_api, name='api-attributes-list'),
|
||||||
path('api/attributes/create/', views.create_attribute_api, name='api-attribute-create'),
|
path('api/attributes/create/', views.create_attribute_api, name='api-attribute-create'),
|
||||||
path('api/attributes/<int:pk>/values/add/', views.add_attribute_value_api, name='attribute-add-value'),
|
path('api/attributes/<int:pk>/values/add/', views.add_attribute_value_api, name='attribute-add-value'),
|
||||||
path('api/attributes/<int:pk>/values/<int:value_id>/delete/', views.delete_attribute_value_api, name='attribute-delete-value'),
|
path('api/attributes/<int:pk>/values/<int:value_id>/delete/', views.delete_attribute_value_api, name='attribute-delete-value'),
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ from .attribute_views import (
|
|||||||
create_attribute_api,
|
create_attribute_api,
|
||||||
add_attribute_value_api,
|
add_attribute_value_api,
|
||||||
delete_attribute_value_api,
|
delete_attribute_value_api,
|
||||||
|
get_attributes_list_api,
|
||||||
)
|
)
|
||||||
|
|
||||||
# API представления
|
# API представления
|
||||||
@@ -193,6 +194,7 @@ __all__ = [
|
|||||||
'create_attribute_api',
|
'create_attribute_api',
|
||||||
'add_attribute_value_api',
|
'add_attribute_value_api',
|
||||||
'delete_attribute_value_api',
|
'delete_attribute_value_api',
|
||||||
|
'get_attributes_list_api',
|
||||||
|
|
||||||
# API
|
# API
|
||||||
'search_products_and_variants',
|
'search_products_and_variants',
|
||||||
|
|||||||
@@ -245,3 +245,30 @@ def delete_attribute_value_api(request, pk, value_id):
|
|||||||
return JsonResponse({'success': False, 'error': 'Значение не найдено'})
|
return JsonResponse({'success': False, 'error': 'Значение не найдено'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({'success': False, 'error': str(e)})
|
return JsonResponse({'success': False, 'error': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def get_attributes_list_api(request):
|
||||||
|
"""
|
||||||
|
API для получения списка всех атрибутов с их значениями.
|
||||||
|
Используется для autocomplete в форме создания вариативного товара.
|
||||||
|
"""
|
||||||
|
attributes = ProductAttribute.objects.prefetch_related('values').order_by('position', 'name')
|
||||||
|
|
||||||
|
data = []
|
||||||
|
for attr in attributes:
|
||||||
|
data.append({
|
||||||
|
'id': attr.pk,
|
||||||
|
'name': attr.name,
|
||||||
|
'slug': attr.slug,
|
||||||
|
'values': [
|
||||||
|
{
|
||||||
|
'id': val.pk,
|
||||||
|
'value': val.value,
|
||||||
|
'slug': val.slug
|
||||||
|
}
|
||||||
|
for val in attr.values.all().order_by('position', 'value')
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({'success': True, 'attributes': data})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from user_roles.mixins import ManagerOwnerRequiredMixin
|
from user_roles.mixins import ManagerOwnerRequiredMixin
|
||||||
from ..models import ConfigurableProduct, ConfigurableProductOption, ProductKit, ConfigurableProductAttribute
|
from ..models import ConfigurableProduct, ConfigurableProductOption, ProductKit, ConfigurableProductAttribute, ProductAttribute
|
||||||
from ..forms import (
|
from ..forms import (
|
||||||
ConfigurableProductForm,
|
ConfigurableProductForm,
|
||||||
ConfigurableProductOptionFormSetCreate,
|
ConfigurableProductOptionFormSetCreate,
|
||||||
@@ -144,6 +144,9 @@ class ConfigurableProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixi
|
|||||||
is_temporary=False
|
is_temporary=False
|
||||||
).order_by('name')
|
).order_by('name')
|
||||||
|
|
||||||
|
# Справочник атрибутов для autocomplete
|
||||||
|
context['product_attributes'] = ProductAttribute.objects.prefetch_related('values').order_by('position', 'name')
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
@@ -420,6 +423,9 @@ class ConfigurableProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixi
|
|||||||
is_temporary=False
|
is_temporary=False
|
||||||
).order_by('name')
|
).order_by('name')
|
||||||
|
|
||||||
|
# Справочник атрибутов для autocomplete
|
||||||
|
context['product_attributes'] = ProductAttribute.objects.prefetch_related('values').order_by('position', 'name')
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
|
|||||||
Reference in New Issue
Block a user