@@ -106,13 +106,35 @@ input[name*="DELETE"] {
{{ attribute_formset.management_form }}
<!-- Список доступных комплектов для JavaScript -->
<!-- Данные для JavaScript -->
< script >
window . 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 % }
] ;
// Справочник атрибутов с их значениями
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 >
{% if attribute_formset.non_form_errors %}
@@ -127,10 +149,26 @@ input[name*="DELETE"] {
{{ form.id }}
< 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 >
{{ 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 %}
< div class = "text-danger small" > {{ form.name.errors.0 }}< / div >
{% endif %}
@@ -169,9 +207,14 @@ input[name*="DELETE"] {
< / div >
< / div >
<!-- Значения параметра (добавляются инлайн через JavaScript) -->
<!-- Значения параметра -->
< 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 }}" >
<!-- Значения будут добавлены через JavaScript -->
< / div >
@@ -231,14 +274,14 @@ function addValueField(container, valueText = '', kitId = '') {
const index = container . querySelectorAll ( '.value-field-group' ) . length ;
const fieldId = ` value- ${ Date . now ( ) } - ${ Math . random ( ) . toString ( 36 ) . substr ( 2 , 9 ) } ` ;
// Получаем список доступных комплектов из скрытого элемента
// Получаем список доступных комплектов
const kitOptionsHtml = getKitOptionsHtml ( kitId ) ;
const html = `
<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"
placeholder="Введите значение"
value=" ${ valueText } "
value=" ${ escapeHtml ( valueText) } "
data-field-id=" ${ fieldId } "
style="min-width: 100px;">
<select class="form-select form-select-sm parameter-kit-select"
@@ -258,17 +301,27 @@ function addValueField(container, valueText = '', kitId = '') {
// Установка выбранного комплекта если был передан
if ( kitId ) {
const ki tSelect = container . querySelector ( '.parameter-kit-select:last-child ' ) ;
if ( ki tSelect) {
ki tSelect. value = kitId ;
const las tSelect = container . querySelector ( '.value-field-group:last-child .parameter-kit-select' ) ;
if ( las tSelect) {
las tSelect. 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' ) ;
if ( lastRemoveBtn ) {
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 с опциями комплектов
@@ -276,23 +329,89 @@ function getKitOptionsHtml(selectedKitId = '') {
const kitsData = window . AVAILABLE _KITS || [ ] ;
return kitsData . map ( kit => {
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 ( '' ) ;
}
// Найти атрибут в справочнике по имени
function findAttributeByName ( name ) {
const attributes = window . PRODUCT _ATTRIBUTES || [ ] ;
return attributes . find ( attr => attr . name . toLowerCase ( ) === name . toLowerCase ( ) ) ;
}
// Инициализация существующих параметров с их значениями из БД
function initializeParameterCards ( ) {
document . querySelectorAll ( '.attribute-card' ) . forEach ( card => {
// Если это существующий параметр с ID, загрузим е г о значения
// Это будет обработано при первой загрузке в view
initAddValueBtn ( card ) ;
initCardHandlers ( 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 ) {
const addBtn = card . querySelector ( '.add-value-btn' ) ;
if ( addBtn ) {
if ( addBtn && ! addBtn . dataset . initialized ) {
addBtn . dataset . initialized = 'true' ;
addBtn . addEventListener ( 'click' , function ( e ) {
e . preventDefault ( ) ;
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 ( ) {
const container = document . getElementById ( 'attributeFormsetContainer' ) ;
@@ -312,12 +688,19 @@ document.getElementById('addParameterBtn')?.addEventListener('click', function()
<input type="hidden" name="attributes- ${ formIdx } -id">
<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>
<input type="text" name="attributes- ${ formIdx } -name"
<div class="input-group">
<select class="form-select param-name-select"
data-name-input="id_attributes- ${ formIdx } -name">
${ getAttributeOptionsHtml ( ) }
</select>
<input type="hidden"
name="attributes- ${ formIdx } -name"
id="id_attributes- ${ formIdx } -name"
class="form-control param-name-input"
placeholder="Например: Длина, Цвет, Размер ">
class="param-name-input"
value=" ">
</div>
</div>
<div class="col-md-2">
<label class="form-label small">Порядок</label>
@@ -348,7 +731,12 @@ document.getElementById('addParameterBtn')?.addEventListener('click', function()
</div>
<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>
@@ -364,16 +752,17 @@ document.getElementById('addParameterBtn')?.addEventListener('click', function()
// Инициализируем новую карточку
const newCard = container . querySelector ( ` [data-formset-index=" ${ formIdx } "] ` ) ;
initAddValueBtn ( newCard ) ;
initCardHandlers ( newCard ) ;
// Инициализируем удаление параметра
initParamDeleteToggle ( newCard ) ;
// Фокус на select
newCard . querySelector ( '.param-name-select' ) ? . focus ( ) ;
} ) ;
// Функция для скрытия удаленного параметра
function initParamDeleteToggle ( card ) {
const deleteCheckbox = card . querySelector ( 'input[type="checkbox"][name$="-DELETE"]' ) ;
if ( deleteCheckbox ) {
if ( deleteCheckbox && ! deleteCheckbox . dataset . initialized ) {
deleteCheckbox . dataset . initialized = 'true' ;
deleteCheckbox . addEventListener ( 'change' , function ( ) {
if ( this . checked ) {
card . style . opacity = '0.5' ;
@@ -388,12 +777,7 @@ function initParamDeleteToggle(card) {
// Функция для сериализации значений параметров и их комплектов перед отправкой формы
function serializeAttributeValues ( ) {
/**
* Перед отправкой формы нужно сериализовать все значения параметров
* и их связанные комплекты из инлайн input'ов в скрытые JSON поля
*/
document . querySelectorAll ( '.attribute-card' ) . forEach ( ( card , idx ) => {
// Получаем все инпуты с значениями и их комплектами внутри этой карточки
const valueGroups = card . querySelectorAll ( '.value-field-group' ) ;
const values = [ ] ;
const kits = [ ] ;
@@ -406,7 +790,7 @@ function serializeAttributeValues() {
const value = valueInput . value . trim ( ) ;
const kitId = kitSelect ? kitSelect . value : '' ;
if ( value && kitId ) { // Требуем чтобы о б а поля были заполнены
if ( value && kitId ) {
values . push ( value ) ;
kits . push ( parseInt ( kitId ) ) ;
}
@@ -414,7 +798,6 @@ function serializeAttributeValues() {
} ) ;
// Создаем или обновляем скрытые поля JSON
// поле values: ["50", "60", "70"]
const valuesFieldName = ` attributes- ${ idx } -values ` ;
let valuesField = document . querySelector ( ` input[name=" ${ valuesFieldName } "] ` ) ;
if ( ! valuesField ) {
@@ -425,7 +808,6 @@ function serializeAttributeValues() {
}
valuesField . value = JSON . stringify ( values ) ;
// поле kits: [1, 2, 3] (id ProductKit)
const kitsFieldName = ` attributes- ${ idx } -kits ` ;
let kitsField = document . querySelector ( ` input[name=" ${ kitsFieldName } "] ` ) ;
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 ( ) {
initializeParameterCards ( ) ;
document . querySelectorAll ( '.attribute-card' ) . forEach ( card => {
initParamDeleteToggle ( card ) ;
} ) ;
// Добавляем сериализацию значений перед отправкой формы
const form = document . querySelector ( 'form' ) ;
if ( form ) {
form . addEventListener ( 'submit' , function ( e ) {
// Перед отправкой формы сериализуем все значения параметров
serializeAttributeValues ( ) ;
} ) ;
}