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

View File

@@ -181,6 +181,137 @@
</div> </div>
</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 mb-3">
<div class="card-header"> <div class="card-header">
@@ -324,28 +455,6 @@
<div class="border-top pt-3 mt-3"> <div class="border-top pt-3 mt-3">
<h6 class="mb-3">Получатель</h6> <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="mb-3">
<div class="form-check form-switch" style="padding-left: 3.5em;"> <div class="form-check form-switch" style="padding-left: 3.5em;">
@@ -425,136 +534,6 @@
</div> </div>
</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 mb-3">
@@ -672,19 +651,7 @@ function initCustomerSelect2() {
}, },
templateResult: formatCustomerOption, templateResult: formatCustomerOption,
templateSelection: formatCustomerSelection, templateSelection: formatCustomerSelection,
escapeMarkup: function(markup) { return markup; }, 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;
}
}); });
console.log('6. Select2 инициализирован'); console.log('6. Select2 инициализирован');
@@ -726,10 +693,12 @@ function initCustomerSelect2() {
const data = e.params.data; const data = e.params.data;
console.log('9b. Попытка выбрать элемент (перед выбором):', data); console.log('9b. Попытка выбрать элемент (перед выбором):', data);
if (data.is_create_option) { if (data.is_create_option || data.id === '__create_new__') {
console.log('9c. Это опция создания клиента - предотвращаем выбор и открываем модаль'); console.log('9c. Это опция создания клиента - предотвращаем выбор и открываем модаль');
// Предотвращаем выбор этой опции // Предотвращаем выбор этой опции
e.preventDefault(); e.preventDefault();
// Закрываем dropdown
$customerSelect.select2('close');
// Очищаем значение // Очищаем значение
$customerSelect.val(null).trigger('change.select2'); $customerSelect.val(null).trigger('change.select2');
// Открываем модаль // Открываем модаль
@@ -752,7 +721,7 @@ function initCustomerSelect2() {
const data = e.params.data; const data = e.params.data;
console.log('10b. Выбран элемент:', data); console.log('10b. Выбран элемент:', data);
if (data.is_create_option) { if (data.is_create_option || data.id === '__create_new__') {
console.log('11. Открываем модальное окно для создания клиента'); console.log('11. Открываем модальное окно для создания клиента');
this.value = ''; this.value = '';
// Триггерим нативное change событие для draft-creator.js // Триггерим нативное change событие для draft-creator.js
@@ -769,12 +738,13 @@ function initCustomerSelect2() {
// Форматирование опции в списке // Форматирование опции в списке
function formatCustomerOption(option) { function formatCustomerOption(option) {
if (!option.id) { // ВАЖНО: Проверяем is_create_option или специальный ID ПЕРЕД проверкой !option.id
return option.text; 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) { if (!option.id) {
return '<div class="customer-create-option"><i class="bi bi-plus-circle"></i> ' + option.text + '</div>'; return option.text;
} }
let html = '<div class="customer-option">'; let html = '<div class="customer-option">';
@@ -791,10 +761,10 @@ function initCustomerSelect2() {
// Форматирование выбранного значения // Форматирование выбранного значения
function formatCustomerSelection(option) { function formatCustomerSelection(option) {
if (!option.id) { if (option.is_create_option || option.id === '__create_new__') {
return option.text; return option.text;
} }
if (option.is_create_option) { if (!option.id) {
return option.text; return option.text;
} }
// Возвращаем name если есть (из AJAX), иначе text (из DOM опции) // Возвращаем name если есть (из AJAX), иначе text (из DOM опции)
@@ -1444,6 +1414,13 @@ document.addEventListener('DOMContentLoaded', function() {
<!-- Обработчик сохранения нового клиента (ДОЛЖЕН быть ПОСЛЕ модального окна в HTML) --> <!-- Обработчик сохранения нового клиента (ДОЛЖЕН быть ПОСЛЕ модального окна в HTML) -->
<script> <script>
document.getElementById('save-customer-btn').addEventListener('click', function() { document.getElementById('save-customer-btn').addEventListener('click', function() {
const saveBtn = this;
// Защита от двойного клика
if (saveBtn.disabled) {
return;
}
const name = document.getElementById('customer-name').value.trim(); const name = document.getElementById('customer-name').value.trim();
const phone = document.getElementById('customer-phone').value.trim(); const phone = document.getElementById('customer-phone').value.trim();
const email = document.getElementById('customer-email').value.trim(); const email = document.getElementById('customer-email').value.trim();
@@ -1461,6 +1438,11 @@ document.getElementById('save-customer-btn').addEventListener('click', function(
return; 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 запрос // Отправляем AJAX запрос
fetch('{% url "customers:api-create-customer" %}', { fetch('{% url "customers:api-create-customer" %}', {
method: 'POST', method: 'POST',
@@ -1528,6 +1510,10 @@ document.getElementById('save-customer-btn').addEventListener('click', function(
} }
}, 100); }, 100);
} else { } else {
// Разблокируем кнопку при ошибке
saveBtn.disabled = false;
saveBtn.innerHTML = originalHTML;
const errorDiv = document.getElementById('customer-form-errors'); const errorDiv = document.getElementById('customer-form-errors');
errorDiv.innerHTML = '<div class="alert alert-danger mb-0">' + data.error + '</div>'; errorDiv.innerHTML = '<div class="alert alert-danger mb-0">' + data.error + '</div>';
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';
@@ -1535,6 +1521,11 @@ document.getElementById('save-customer-btn').addEventListener('click', function(
}) })
.catch(error => { .catch(error => {
console.error('Error:', error); console.error('Error:', error);
// Разблокируем кнопку при ошибке
saveBtn.disabled = false;
saveBtn.innerHTML = originalHTML;
const errorDiv = document.getElementById('customer-form-errors'); const errorDiv = document.getElementById('customer-form-errors');
errorDiv.innerHTML = '<div class="alert alert-danger mb-0">Ошибка при создании клиента: ' + error.message + '</div>'; errorDiv.innerHTML = '<div class="alert alert-danger mb-0">Ошибка при создании клиента: ' + error.message + '</div>';
errorDiv.style.display = 'block'; errorDiv.style.display = 'block';