Улучшения UX формы заказа и создания клиента

- Изменен порядок секций формы: товары перемещены выше доставки
- Добавлена защита от двойного создания клиента
- Улучшена валидация при создании клиента с детализацией ошибок
- Добавлен индикатор загрузки при сохранении клиента
- Исправлена логика обработки специальной опции создания клиента

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-11 21:16:48 +03:00
parent 9394abfa3f
commit 4a1f8266de
2 changed files with 181 additions and 181 deletions

View File

@@ -338,7 +338,7 @@ def api_search_customers(request):
# Если ничего не найдено, предлагаем создать нового клиента
if not results:
results.append({
'id': None,
'id': '__create_new__', # Специальный ID для опции создания
'text': f'Создать клиента: "{query}"',
'is_create_option': True,
'search_text': query,
@@ -390,6 +390,7 @@ def api_create_customer(request):
'name': name,
'phone': phone if phone else None,
'email': email if email else None,
'loyalty_tier': 'no_discount', # Значение по умолчанию для новых клиентов
}
# Используем форму для валидации и создания
@@ -409,18 +410,26 @@ def api_create_customer(request):
'email': customer.email if customer.email else '',
}, status=201)
else:
# Собираем ошибки валидации
# Собираем ошибки валидации с указанием полей
errors = []
for field, field_errors in form.errors.items():
for error in field_errors:
errors.append(error)
field_labels = {
'name': 'Имя клиента',
'phone': 'Телефон',
'email': 'Email',
}
# Возвращаем первую ошибку
error_message = errors[0] if errors else 'Ошибка валидации данных'
for field, field_errors in form.errors.items():
field_label = field_labels.get(field, field)
for error in field_errors:
errors.append(f'{field_label}: {error}')
# Возвращаем все ошибки
error_message = '<br>'.join(errors) if errors else 'Ошибка валидации данных'
return JsonResponse({
'success': False,
'error': error_message
'error': error_message,
'errors': form.errors # Добавляем детальную информацию об ошибках
}, status=400)
except json.JSONDecodeError:

View File

@@ -181,6 +181,137 @@
</div>
</div>
<!-- Товары в заказе -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Товары в заказе</h5>
</div>
<div class="card-body">
{{ formset.management_form }}
<div id="order-items-container">
{% for item_form in formset %}
<div class="order-item-form border rounded p-3 mb-3" data-form-index="{{ forloop.counter0 }}">
{{ item_form.id }}
{{ item_form.product }} <!-- Hidden field -->
{{ item_form.product_kit }} <!-- Hidden field -->
{{ item_form.is_custom_price }} <!-- Hidden field -->
<div class="row align-items-end">
<div class="col-md-5">
<div class="mb-2">
<label class="form-label">Товар или комплект</label>
<select class="form-select select2-order-item" data-form-index="{{ forloop.counter0 }}">
<option value=""></option>
{% if item_form.instance.product %}
<option value="product_{{ item_form.instance.product.id }}" selected data-type="product" data-price="{{ item_form.instance.product.actual_price }}">
{{ item_form.instance.product.name }}{% if item_form.instance.product.sku %} ({{ item_form.instance.product.sku }}){% endif %}
</option>
{% elif item_form.instance.product_kit %}
<option value="kit_{{ item_form.instance.product_kit.id }}" selected data-type="kit" data-price="{{ item_form.instance.product_kit.actual_price }}">
{{ item_form.instance.product_kit.name }}{% if item_form.instance.product_kit.sku %} ({{ item_form.instance.product_kit.sku }}){% endif %}
</option>
{% endif %}
</select>
</div>
</div>
<div class="col-md-2">
<div class="mb-2">
<label class="form-label">Количество</label>
{{ item_form.quantity }}
</div>
</div>
<div class="col-md-3">
<div class="mb-2">
<label class="form-label">Цена</label>
<div class="position-relative">
{{ item_form.price }}
<span class="custom-price-badge badge bg-warning position-absolute top-0 end-0 mt-1 me-1" style="display: none;">
Изменена
</span>
<small class="text-muted original-price-info position-absolute" style="display: none; top: calc(100% + 2px); left: 0; white-space: nowrap;">
Оригинальная: <span class="original-price-value"></span> руб.
</small>
</div>
</div>
</div>
<div class="col-md-2 text-end">
<div class="mb-2">
<label class="form-label d-block">&nbsp;</label>
{% if formset.can_delete %}
{{ item_form.DELETE }}
{% endif %}
<button type="button" class="btn btn-danger btn-sm remove-item-btn">
<i class="bi bi-trash"></i> Удалить
</button>
</div>
</div>
</div>
{% if item_form.errors %}
<div class="alert alert-danger mt-2">{{ item_form.errors }}</div>
{% endif %}
</div>
{% endfor %}
</div>
<!-- Скрытый шаблон для новых форм -->
<template id="empty-form-template">
<div class="order-item-form border rounded p-3 mb-3" data-form-index="__prefix__">
<input type="hidden" name="items-__prefix__-id" id="id_items-__prefix__-id">
<input type="hidden" name="items-__prefix__-product" id="id_items-__prefix__-product">
<input type="hidden" name="items-__prefix__-product_kit" id="id_items-__prefix__-product_kit">
<input type="hidden" name="items-__prefix__-is_custom_price" id="id_items-__prefix__-is_custom_price" value="false">
<div class="row align-items-end">
<div class="col-md-5">
<div class="mb-2">
<label class="form-label">Товар или комплект</label>
<select class="form-select select2-order-item" data-form-index="__prefix__" id="id_items-__prefix__-select">
<option value=""></option>
</select>
</div>
</div>
<div class="col-md-2">
<div class="mb-2">
<label class="form-label">Количество</label>
<input type="number" name="items-__prefix__-quantity" step="1" min="1" value="1" class="form-control" id="id_items-__prefix__-quantity">
</div>
</div>
<div class="col-md-3">
<div class="mb-2">
<label class="form-label">Цена</label>
<div class="position-relative">
<input type="number" name="items-__prefix__-price" step="0.01" min="0" class="form-control" id="id_items-__prefix__-price">
<span class="custom-price-badge badge bg-warning position-absolute top-0 end-0 mt-1 me-1" style="display: none;">
Изменена
</span>
<small class="text-muted original-price-info position-absolute" style="display: none; top: calc(100% + 2px); left: 0; white-space: nowrap;">
Оригинальная: <span class="original-price-value"></span> руб.
</small>
</div>
</div>
</div>
<div class="col-md-2 text-end">
<div class="mb-2">
<label class="form-label d-block">&nbsp;</label>
<input type="checkbox" name="items-__prefix__-DELETE" id="id_items-__prefix__-DELETE">
<button type="button" class="btn btn-danger btn-sm remove-item-btn">
<i class="bi bi-trash"></i> Удалить
</button>
</div>
</div>
</div>
</div>
</template>
<button type="button" class="btn btn-success" id="add-item-btn">
<i class="bi bi-plus-circle"></i> Добавить товар
</button>
<button type="button" class="btn btn-primary ms-2" id="create-temp-kit-btn" data-bs-toggle="modal" data-bs-target="#tempKitModal">
<i class="bi bi-flower1"></i> Создать и добавить комплект
</button>
</div>
</div>
<!-- Доставка -->
<div class="card mb-3">
<div class="card-header">
@@ -324,28 +455,6 @@
<div class="border-top pt-3 mt-3">
<h6 class="mb-3">Получатель</h6>
<!-- Поля получателя из модели Address -->
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="id_address_recipient_name" class="form-label">
Имя получателя
</label>
<input type="text" name="address_recipient_name" id="id_address_recipient_name"
class="form-control" placeholder="Имя получателя">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="id_address_recipient_phone" class="form-label">
Телефон получателя
</label>
<input type="text" name="address_recipient_phone" id="id_address_recipient_phone"
class="form-control" placeholder="Телефон получателя">
</div>
</div>
</div>
<!-- Крупный переключатель "Покупатель = получатель" -->
<div class="mb-3">
<div class="form-check form-switch" style="padding-left: 3.5em;">
@@ -425,136 +534,6 @@
</div>
</div>
<!-- Товары в заказе -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Товары в заказе</h5>
</div>
<div class="card-body">
{{ formset.management_form }}
<div id="order-items-container">
{% for item_form in formset %}
<div class="order-item-form border rounded p-3 mb-3" data-form-index="{{ forloop.counter0 }}">
{{ item_form.id }}
{{ item_form.product }} <!-- Hidden field -->
{{ item_form.product_kit }} <!-- Hidden field -->
{{ item_form.is_custom_price }} <!-- Hidden field -->
<div class="row align-items-end">
<div class="col-md-5">
<div class="mb-2">
<label class="form-label">Товар или комплект</label>
<select class="form-select select2-order-item" data-form-index="{{ forloop.counter0 }}">
<option value=""></option>
{% if item_form.instance.product %}
<option value="product_{{ item_form.instance.product.id }}" selected data-type="product" data-price="{{ item_form.instance.product.actual_price }}">
{{ item_form.instance.product.name }}{% if item_form.instance.product.sku %} ({{ item_form.instance.product.sku }}){% endif %}
</option>
{% elif item_form.instance.product_kit %}
<option value="kit_{{ item_form.instance.product_kit.id }}" selected data-type="kit" data-price="{{ item_form.instance.product_kit.actual_price }}">
{{ item_form.instance.product_kit.name }}{% if item_form.instance.product_kit.sku %} ({{ item_form.instance.product_kit.sku }}){% endif %}
</option>
{% endif %}
</select>
</div>
</div>
<div class="col-md-2">
<div class="mb-2">
<label class="form-label">Количество</label>
{{ item_form.quantity }}
</div>
</div>
<div class="col-md-3">
<div class="mb-2">
<label class="form-label">Цена</label>
<div class="position-relative">
{{ item_form.price }}
<span class="custom-price-badge badge bg-warning position-absolute top-0 end-0 mt-1 me-1" style="display: none;">
Изменена
</span>
<small class="text-muted original-price-info position-absolute" style="display: none; top: calc(100% + 2px); left: 0; white-space: nowrap;">
Оригинальная: <span class="original-price-value"></span> руб.
</small>
</div>
</div>
</div>
<div class="col-md-2 text-end">
<div class="mb-2">
<label class="form-label d-block">&nbsp;</label>
{% if formset.can_delete %}
{{ item_form.DELETE }}
{% endif %}
<button type="button" class="btn btn-danger btn-sm remove-item-btn">
<i class="bi bi-trash"></i> Удалить
</button>
</div>
</div>
</div>
{% if item_form.errors %}
<div class="alert alert-danger mt-2">{{ item_form.errors }}</div>
{% endif %}
</div>
{% endfor %}
</div>
<!-- Скрытый шаблон для новых форм -->
<template id="empty-form-template">
<div class="order-item-form border rounded p-3 mb-3" data-form-index="__prefix__">
<input type="hidden" name="items-__prefix__-id" id="id_items-__prefix__-id">
<input type="hidden" name="items-__prefix__-product" id="id_items-__prefix__-product">
<input type="hidden" name="items-__prefix__-product_kit" id="id_items-__prefix__-product_kit">
<input type="hidden" name="items-__prefix__-is_custom_price" id="id_items-__prefix__-is_custom_price" value="false">
<div class="row align-items-end">
<div class="col-md-5">
<div class="mb-2">
<label class="form-label">Товар или комплект</label>
<select class="form-select select2-order-item" data-form-index="__prefix__" id="id_items-__prefix__-select">
<option value=""></option>
</select>
</div>
</div>
<div class="col-md-2">
<div class="mb-2">
<label class="form-label">Количество</label>
<input type="number" name="items-__prefix__-quantity" step="1" min="1" value="1" class="form-control" id="id_items-__prefix__-quantity">
</div>
</div>
<div class="col-md-3">
<div class="mb-2">
<label class="form-label">Цена</label>
<div class="position-relative">
<input type="number" name="items-__prefix__-price" step="0.01" min="0" class="form-control" id="id_items-__prefix__-price">
<span class="custom-price-badge badge bg-warning position-absolute top-0 end-0 mt-1 me-1" style="display: none;">
Изменена
</span>
<small class="text-muted original-price-info position-absolute" style="display: none; top: calc(100% + 2px); left: 0; white-space: nowrap;">
Оригинальная: <span class="original-price-value"></span> руб.
</small>
</div>
</div>
</div>
<div class="col-md-2 text-end">
<div class="mb-2">
<label class="form-label d-block">&nbsp;</label>
<input type="checkbox" name="items-__prefix__-DELETE" id="id_items-__prefix__-DELETE">
<button type="button" class="btn btn-danger btn-sm remove-item-btn">
<i class="bi bi-trash"></i> Удалить
</button>
</div>
</div>
</div>
</div>
</template>
<button type="button" class="btn btn-success" id="add-item-btn">
<i class="bi bi-plus-circle"></i> Добавить товар
</button>
<button type="button" class="btn btn-primary ms-2" id="create-temp-kit-btn" data-bs-toggle="modal" data-bs-target="#tempKitModal">
<i class="bi bi-flower1"></i> Создать и добавить комплект
</button>
</div>
</div>
<!-- Оплата и дополнительно -->
<div class="card mb-3">
@@ -672,19 +651,7 @@ function initCustomerSelect2() {
},
templateResult: formatCustomerOption,
templateSelection: formatCustomerSelection,
escapeMarkup: function(markup) { return markup; },
// Очень важно: указываем как отображать уже выбранное значение
matcher: function(params, data) {
// Если нет поискового термина, показываем все результаты
if ($.trim(params.term) === '') {
return data;
}
// Иначе ищем совпадение
if (data.text.toUpperCase().indexOf(params.term.toUpperCase()) > -1) {
return data;
}
return null;
}
escapeMarkup: function(markup) { return markup; }
});
console.log('6. Select2 инициализирован');
@@ -726,10 +693,12 @@ function initCustomerSelect2() {
const data = e.params.data;
console.log('9b. Попытка выбрать элемент (перед выбором):', data);
if (data.is_create_option) {
if (data.is_create_option || data.id === '__create_new__') {
console.log('9c. Это опция создания клиента - предотвращаем выбор и открываем модаль');
// Предотвращаем выбор этой опции
e.preventDefault();
// Закрываем dropdown
$customerSelect.select2('close');
// Очищаем значение
$customerSelect.val(null).trigger('change.select2');
// Открываем модаль
@@ -752,7 +721,7 @@ function initCustomerSelect2() {
const data = e.params.data;
console.log('10b. Выбран элемент:', data);
if (data.is_create_option) {
if (data.is_create_option || data.id === '__create_new__') {
console.log('11. Открываем модальное окно для создания клиента');
this.value = '';
// Триггерим нативное change событие для draft-creator.js
@@ -769,12 +738,13 @@ function initCustomerSelect2() {
// Форматирование опции в списке
function formatCustomerOption(option) {
if (!option.id) {
return option.text;
// ВАЖНО: Проверяем is_create_option или специальный ID ПЕРЕД проверкой !option.id
if (option.is_create_option || option.id === '__create_new__') {
return '<div class="customer-create-option"><i class="bi bi-plus-circle"></i> ' + option.text + '</div>';
}
if (option.is_create_option) {
return '<div class="customer-create-option"><i class="bi bi-plus-circle"></i> ' + option.text + '</div>';
if (!option.id) {
return option.text;
}
let html = '<div class="customer-option">';
@@ -791,10 +761,10 @@ function initCustomerSelect2() {
// Форматирование выбранного значения
function formatCustomerSelection(option) {
if (!option.id) {
if (option.is_create_option || option.id === '__create_new__') {
return option.text;
}
if (option.is_create_option) {
if (!option.id) {
return option.text;
}
// Возвращаем name если есть (из AJAX), иначе text (из DOM опции)
@@ -1444,6 +1414,13 @@ document.addEventListener('DOMContentLoaded', function() {
<!-- Обработчик сохранения нового клиента (ДОЛЖЕН быть ПОСЛЕ модального окна в HTML) -->
<script>
document.getElementById('save-customer-btn').addEventListener('click', function() {
const saveBtn = this;
// Защита от двойного клика
if (saveBtn.disabled) {
return;
}
const name = document.getElementById('customer-name').value.trim();
const phone = document.getElementById('customer-phone').value.trim();
const email = document.getElementById('customer-email').value.trim();
@@ -1461,6 +1438,11 @@ document.getElementById('save-customer-btn').addEventListener('click', function(
return;
}
// Блокируем кнопку и меняем текст
saveBtn.disabled = true;
const originalHTML = saveBtn.innerHTML;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Создание...';
// Отправляем AJAX запрос
fetch('{% url "customers:api-create-customer" %}', {
method: 'POST',
@@ -1528,6 +1510,10 @@ document.getElementById('save-customer-btn').addEventListener('click', function(
}
}, 100);
} else {
// Разблокируем кнопку при ошибке
saveBtn.disabled = false;
saveBtn.innerHTML = originalHTML;
const errorDiv = document.getElementById('customer-form-errors');
errorDiv.innerHTML = '<div class="alert alert-danger mb-0">' + data.error + '</div>';
errorDiv.style.display = 'block';
@@ -1535,6 +1521,11 @@ document.getElementById('save-customer-btn').addEventListener('click', function(
})
.catch(error => {
console.error('Error:', error);
// Разблокируем кнопку при ошибке
saveBtn.disabled = false;
saveBtn.innerHTML = originalHTML;
const errorDiv = document.getElementById('customer-form-errors');
errorDiv.innerHTML = '<div class="alert alert-danger mb-0">Ошибка при создании клиента: ' + error.message + '</div>';
errorDiv.style.display = 'block';