feat: Добавить валидацию для заполнения одного поля корректировки цены

Реализована логика чтобы только одно из четырёх полей корректировки цены
можно было заполнить одновременно:

JavaScript валидация:
- При заполнении одного поля остальные 3 автоматически отключаются
- При попытке заполнить два поля одновременно:
  - Оставляется только первое заполненное
  - Остальные очищаются и помечаются как ошибка
  - При очистке всех полей они снова активируются

CSS стили:
- Disabled поля: серый фон, пониженная прозрачность, запрещённый курсор
- Invalid поля: красная граница и shadow (Bootstrap стиль)

Валидация работает на обе стороны:
- Frontend JavaScript (instant feedback)
- Backend Python валидация (безопасность)

Файлы:
- products/templates/products/productkit_create.html
- products/templates/products/productkit_edit.html
- products/forms.py (документация)
This commit is contained in:
2025-11-02 19:40:47 +03:00
parent 2e305a810a
commit 390d547e97
3 changed files with 193 additions and 5 deletions

View File

@@ -139,7 +139,9 @@ class ProductKitForm(forms.ModelForm):
def clean(self):
"""
Валидация формы комплекта.
Проверяет что если выбран тип корректировки, указано значение.
Проверяет:
1. Что если выбран тип корректировки, указано значение
2. Что заполнено максимум одно поле корректировки (увеличение или уменьшение)
"""
cleaned_data = super().clean()

View File

@@ -387,6 +387,25 @@
color: #0d6efd;
}
/* Стили для полей корректировки цены */
#id_increase_percent:disabled,
#id_increase_amount:disabled,
#id_decrease_percent:disabled,
#id_decrease_amount:disabled {
background-color: #e9ecef;
color: #6c757d;
cursor: not-allowed;
opacity: 0.6;
}
#id_increase_percent.is-invalid,
#id_increase_amount.is-invalid,
#id_decrease_percent.is-invalid,
#id_decrease_amount.is-invalid {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
/* Адаптивность */
@media (max-width: 991px) {
.col-lg-8, .col-lg-4 {
@@ -584,11 +603,85 @@ document.addEventListener('DOMContentLoaded', function() {
finalPriceDisplay.textContent = finalPrice.toFixed(2) + ' руб.';
}
// Функция для управления состоянием полей (только одно может быть заполнено)
function validateSingleAdjustment() {
const increasePercent = parseFloat(increasePercentInput.value) || 0;
const increaseAmount = parseFloat(increaseAmountInput.value) || 0;
const decreasePercent = parseFloat(decreasePercentInput.value) || 0;
const decreaseAmount = parseFloat(decreaseAmountInput.value) || 0;
// Подсчитываем сколько полей заполнено
const filledCount = (increasePercent > 0 ? 1 : 0) +
(increaseAmount > 0 ? 1 : 0) +
(decreasePercent > 0 ? 1 : 0) +
(decreaseAmount > 0 ? 1 : 0);
const allInputs = [increasePercentInput, increaseAmountInput, decreasePercentInput, decreaseAmountInput];
if (filledCount === 0) {
// Ничего не заполнено - все поля активны
allInputs.forEach(input => {
input.disabled = false;
input.classList.remove('is-invalid');
});
} else if (filledCount === 1) {
// Ровно одно поле заполнено - отключаем остальные
allInputs.forEach(input => {
const isCurrentField = (
input === increasePercentInput && increasePercent > 0 ||
input === increaseAmountInput && increaseAmount > 0 ||
input === decreasePercentInput && decreasePercent > 0 ||
input === decreaseAmountInput && decreaseAmount > 0
);
input.disabled = !isCurrentField;
input.classList.remove('is-invalid');
});
} else {
// Несколько полей заполнено - помечаем как ошибку и очищаем лишние
allInputs.forEach(input => {
input.classList.add('is-invalid');
});
// Оставляем только первое заполненное поле, остальные очищаем
let foundFirst = false;
if (increasePercent > 0 && !foundFirst) {
foundFirst = true;
} else if (foundFirst || increasePercent > 0) {
increasePercentInput.value = '';
}
if (increaseAmount > 0 && !foundFirst) {
foundFirst = true;
} else if (foundFirst || increaseAmount > 0) {
increaseAmountInput.value = '';
}
if (decreasePercent > 0 && !foundFirst) {
foundFirst = true;
} else if (foundFirst || decreasePercent > 0) {
decreasePercentInput.value = '';
}
if (decreaseAmount > 0 && !foundFirst) {
foundFirst = true;
} else if (foundFirst || decreaseAmount > 0) {
decreaseAmountInput.value = '';
}
}
}
// Добавляем обработчики для всех полей цены
[increasePercentInput, increaseAmountInput, decreasePercentInput, decreaseAmountInput].forEach(input => {
if (input) {
input.addEventListener('input', calculateFinalPrice);
input.addEventListener('change', calculateFinalPrice);
input.addEventListener('input', () => {
validateSingleAdjustment();
calculateFinalPrice();
});
input.addEventListener('change', () => {
validateSingleAdjustment();
calculateFinalPrice();
});
}
});

View File

@@ -388,6 +388,25 @@
color: #0d6efd;
}
/* Стили для полей корректировки цены */
#id_increase_percent:disabled,
#id_increase_amount:disabled,
#id_decrease_percent:disabled,
#id_decrease_amount:disabled {
background-color: #e9ecef;
color: #6c757d;
cursor: not-allowed;
opacity: 0.6;
}
#id_increase_percent.is-invalid,
#id_increase_amount.is-invalid,
#id_decrease_percent.is-invalid,
#id_decrease_amount.is-invalid {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
/* Адаптивность */
@media (max-width: 991px) {
.col-lg-8, .col-lg-4 {
@@ -585,11 +604,85 @@ document.addEventListener('DOMContentLoaded', function() {
finalPriceDisplay.textContent = finalPrice.toFixed(2) + ' руб.';
}
// Функция для управления состоянием полей (только одно может быть заполнено)
function validateSingleAdjustment() {
const increasePercent = parseFloat(increasePercentInput.value) || 0;
const increaseAmount = parseFloat(increaseAmountInput.value) || 0;
const decreasePercent = parseFloat(decreasePercentInput.value) || 0;
const decreaseAmount = parseFloat(decreaseAmountInput.value) || 0;
// Подсчитываем сколько полей заполнено
const filledCount = (increasePercent > 0 ? 1 : 0) +
(increaseAmount > 0 ? 1 : 0) +
(decreasePercent > 0 ? 1 : 0) +
(decreaseAmount > 0 ? 1 : 0);
const allInputs = [increasePercentInput, increaseAmountInput, decreasePercentInput, decreaseAmountInput];
if (filledCount === 0) {
// Ничего не заполнено - все поля активны
allInputs.forEach(input => {
input.disabled = false;
input.classList.remove('is-invalid');
});
} else if (filledCount === 1) {
// Ровно одно поле заполнено - отключаем остальные
allInputs.forEach(input => {
const isCurrentField = (
input === increasePercentInput && increasePercent > 0 ||
input === increaseAmountInput && increaseAmount > 0 ||
input === decreasePercentInput && decreasePercent > 0 ||
input === decreaseAmountInput && decreaseAmount > 0
);
input.disabled = !isCurrentField;
input.classList.remove('is-invalid');
});
} else {
// Несколько полей заполнено - помечаем как ошибку и очищаем лишние
allInputs.forEach(input => {
input.classList.add('is-invalid');
});
// Оставляем только первое заполненное поле, остальные очищаем
let foundFirst = false;
if (increasePercent > 0 && !foundFirst) {
foundFirst = true;
} else if (foundFirst || increasePercent > 0) {
increasePercentInput.value = '';
}
if (increaseAmount > 0 && !foundFirst) {
foundFirst = true;
} else if (foundFirst || increaseAmount > 0) {
increaseAmountInput.value = '';
}
if (decreasePercent > 0 && !foundFirst) {
foundFirst = true;
} else if (foundFirst || decreasePercent > 0) {
decreasePercentInput.value = '';
}
if (decreaseAmount > 0 && !foundFirst) {
foundFirst = true;
} else if (foundFirst || decreaseAmount > 0) {
decreaseAmountInput.value = '';
}
}
}
// Добавляем обработчики для всех полей цены
[increasePercentInput, increaseAmountInput, decreasePercentInput, decreaseAmountInput].forEach(input => {
if (input) {
input.addEventListener('input', calculateFinalPrice);
input.addEventListener('change', calculateFinalPrice);
input.addEventListener('input', () => {
validateSingleAdjustment();
calculateFinalPrice();
});
input.addEventListener('change', () => {
validateSingleAdjustment();
calculateFinalPrice();
});
}
});