Улучшен интерфейс ввода даты и времени доставки

- Исправлены имена полей времени (time_from/time_to вместо delivery_time_start/end)
- Поля времени сделаны необязательными (дата остается обязательной)
- Добавлен улучшенный UI с быстрыми кнопками для даты и времени
- Поля ввода расположены в один ряд, кнопки быстрого выбора ниже
- Добавлены CSS и JS файлы для улучшенного интерфейса
- Обновлена валидация: время необязательно, но если указано одно - должно быть и другое
This commit is contained in:
2025-12-24 18:25:20 +03:00
parent d62caa924b
commit 61ce3f550d
7 changed files with 459 additions and 46 deletions

View File

@@ -146,13 +146,13 @@ class OrderForm(forms.ModelForm):
) )
time_from = forms.TimeField( time_from = forms.TimeField(
required=True, required=False,
widget=forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}), widget=forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}),
label='Время доставки от' label='Время доставки от'
) )
time_to = forms.TimeField( time_to = forms.TimeField(
required=True, required=False,
widget=forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}), widget=forms.TimeInput(attrs={'class': 'form-control', 'type': 'time'}),
label='Время доставки до' label='Время доставки до'
) )
@@ -321,13 +321,13 @@ class OrderForm(forms.ModelForm):
if not delivery_date: if not delivery_date:
raise forms.ValidationError({'delivery_date': 'Необходимо указать дату доставки'}) raise forms.ValidationError({'delivery_date': 'Необходимо указать дату доставки'})
if not time_from: # Время необязательно, но если указано одно, должно быть указано и другое
raise forms.ValidationError({'time_from': 'Необходимо указать время начала доставки'}) if (time_from and not time_to) or (time_to and not time_from):
raise forms.ValidationError({
'time_to': 'Если указано время начала, необходимо указать и время окончания, и наоборот'
})
if not time_to: # Проверяем, что время "до" позже времени "от" (если оба указаны)
raise forms.ValidationError({'time_to': 'Необходимо указать время окончания доставки'})
# Проверяем, что время "до" позже времени "от"
if time_from and time_to and time_from >= time_to: if time_from and time_to and time_from >= time_to:
raise forms.ValidationError({ raise forms.ValidationError({
'time_to': 'Время окончания доставки должно быть позже времени начала' 'time_to': 'Время окончания доставки должно быть позже времени начала'

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.0.10 on 2025-12-24 15:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0003_remove_customer_is_recipient'),
]
operations = [
migrations.AlterField(
model_name='delivery',
name='time_from',
field=models.TimeField(blank=True, help_text='Начальное время временного интервала доставки (необязательно)', null=True, verbose_name='Время доставки от'),
),
migrations.AlterField(
model_name='delivery',
name='time_to',
field=models.TimeField(blank=True, help_text='Конечное время временного интервала доставки (необязательно)', null=True, verbose_name='Время доставки до'),
),
]

View File

@@ -67,13 +67,17 @@ class Delivery(models.Model):
) )
time_from = models.TimeField( time_from = models.TimeField(
null=True,
blank=True,
verbose_name='Время доставки от', verbose_name='Время доставки от',
help_text='Начальное время временного интервала доставки' help_text='Начальное время временного интервала доставки (необязательно)'
) )
time_to = models.TimeField( time_to = models.TimeField(
null=True,
blank=True,
verbose_name='Время доставки до', verbose_name='Время доставки до',
help_text='Конечное время временного интервала доставки' help_text='Конечное время временного интервала доставки (необязательно)'
) )
cost = models.DecimalField( cost = models.DecimalField(

View File

@@ -0,0 +1,161 @@
/* Стили для улучшенного интерфейса даты и времени доставки */
.delivery-datetime-wrapper {
position: relative;
}
/* Контейнер для всех кнопок быстрого выбора */
#delivery-datetime-quick-buttons {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
margin-top: 0.5rem;
}
/* Быстрые кнопки для даты */
.delivery-date-quick-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.delivery-date-quick-btn {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
border: 1px solid #dee2e6;
background-color: #fff;
color: #495057;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.delivery-date-quick-btn:hover {
background-color: #f8f9fa;
border-color: #0d6efd;
color: #0d6efd;
}
.delivery-date-quick-btn.active {
background-color: #0d6efd;
border-color: #0d6efd;
color: #fff;
}
.delivery-date-quick-btn i {
margin-right: 0.25rem;
}
/* Предустановленные интервалы времени */
.delivery-time-presets {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
/* Дополнительный отступ для группы времени */
.delivery-time-presets {
margin-left: 0.5rem;
padding-left: 0.5rem;
border-left: 1px solid #dee2e6;
}
.delivery-time-preset-btn {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border: 1px solid #dee2e6;
background-color: #fff;
color: #495057;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.delivery-time-preset-btn:hover {
background-color: #f8f9fa;
border-color: #0d6efd;
color: #0d6efd;
}
.delivery-time-preset-btn.active {
background-color: #0d6efd;
border-color: #0d6efd;
color: #fff;
}
.delivery-time-preset-btn i {
margin-right: 0.25rem;
}
/* Группа полей времени */
.delivery-time-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.delivery-time-group .form-control {
flex: 1;
}
.delivery-time-separator {
color: #6c757d;
font-weight: 500;
user-select: none;
}
/* Улучшенные поля ввода */
.delivery-datetime-wrapper .form-control {
position: relative;
}
.delivery-datetime-wrapper .form-control:focus {
border-color: #0d6efd;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
/* Подсказки под полями */
.delivery-datetime-hint {
font-size: 0.875rem;
color: #6c757d;
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.delivery-datetime-hint i {
font-size: 0.75rem;
}
/* Адаптивность */
@media (max-width: 768px) {
.delivery-date-quick-buttons {
flex-direction: column;
}
.delivery-date-quick-btn {
width: 100%;
text-align: center;
}
.delivery-time-presets {
grid-template-columns: 1fr;
}
.delivery-time-group {
flex-direction: column;
align-items: stretch;
}
.delivery-time-separator {
display: none;
}
}

View File

@@ -0,0 +1,203 @@
/**
* Улучшенный интерфейс для ввода даты и времени доставки
* Добавляет быстрые кнопки для даты и предустановленные интервалы времени
*/
(function() {
'use strict';
/**
* Инициализация улучшенного интерфейса даты и времени
* @param {string} dateFieldId - ID поля даты
* @param {string} timeFromFieldId - ID поля времени "от"
* @param {string} timeToFieldId - ID поля времени "до"
*/
function initDeliveryDateTime(dateFieldId, timeFromFieldId, timeToFieldId) {
const dateField = document.getElementById(dateFieldId);
const timeFromField = document.getElementById(timeFromFieldId);
const timeToField = document.getElementById(timeToFieldId);
if (!dateField || !timeFromField || !timeToField) {
console.warn('[Delivery DateTime] Поля не найдены');
return;
}
// Находим общий контейнер для кнопок
const buttonsContainer = document.getElementById('delivery-datetime-quick-buttons');
if (!buttonsContainer) {
console.warn('[Delivery DateTime] Контейнер для кнопок не найден');
return;
}
// Добавляем быстрые кнопки для даты
addDateQuickButtons(buttonsContainer, dateField);
// Добавляем предустановленные интервалы времени
addTimePresets(buttonsContainer, timeFromField, timeToField);
}
/**
* Добавляет быстрые кнопки для выбора даты
*/
function addDateQuickButtons(container, dateField) {
const quickButtonsContainer = document.createElement('div');
quickButtonsContainer.className = 'delivery-date-quick-buttons';
const buttons = [
{ label: 'Сегодня', days: 0, icon: 'bi-calendar-day' },
{ label: 'Завтра', days: 1, icon: 'bi-calendar-check' }
];
buttons.forEach(button => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'delivery-date-quick-btn';
btn.innerHTML = `<i class="bi ${button.icon}"></i>${button.label}`;
btn.addEventListener('click', function() {
const date = new Date();
date.setDate(date.getDate() + button.days);
const dateString = formatDateForInput(date);
dateField.value = dateString;
// Обновляем активное состояние кнопок
quickButtonsContainer.querySelectorAll('.delivery-date-quick-btn').forEach(b => {
b.classList.remove('active');
});
btn.classList.add('active');
// Триггерим событие change для валидации
dateField.dispatchEvent(new Event('change', { bubbles: true }));
});
quickButtonsContainer.appendChild(btn);
});
// Проверяем текущую дату и активируем соответствующую кнопку
if (dateField.value) {
const currentDate = new Date(dateField.value);
const today = new Date();
today.setHours(0, 0, 0, 0);
currentDate.setHours(0, 0, 0, 0);
const diffDays = Math.round((currentDate - today) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
quickButtonsContainer.children[0].classList.add('active');
} else if (diffDays === 1) {
quickButtonsContainer.children[1].classList.add('active');
}
}
// Слушаем изменения даты вручную
dateField.addEventListener('change', function() {
quickButtonsContainer.querySelectorAll('.delivery-date-quick-btn').forEach(b => {
b.classList.remove('active');
});
});
container.appendChild(quickButtonsContainer);
}
/**
* Добавляет предустановленные интервалы времени
*/
function addTimePresets(container, timeFromField, timeToField) {
// Создаем контейнер для пресетов
const presetsContainer = document.createElement('div');
presetsContainer.className = 'delivery-time-presets';
const presets = [
{ label: 'Утро', from: '09:00', to: '12:00', icon: 'bi-sunrise' },
{ label: 'День', from: '12:00', to: '15:00', icon: 'bi-sun' },
{ label: 'Вечер', from: '15:00', to: '18:00', icon: 'bi-sunset' },
{ label: 'Поздно', from: '18:00', to: '21:00', icon: 'bi-moon' }
];
presets.forEach(preset => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'delivery-time-preset-btn';
btn.innerHTML = `<i class="bi ${preset.icon}"></i>${preset.label}`;
btn.addEventListener('click', function() {
timeFromField.value = preset.from;
timeToField.value = preset.to;
// Обновляем активное состояние кнопок
presetsContainer.querySelectorAll('.delivery-time-preset-btn').forEach(b => {
b.classList.remove('active');
});
btn.classList.add('active');
// Триггерим события change для валидации
timeFromField.dispatchEvent(new Event('change', { bubbles: true }));
timeToField.dispatchEvent(new Event('change', { bubbles: true }));
});
presetsContainer.appendChild(btn);
});
// Проверяем текущие значения и активируем соответствующий пресет
if (timeFromField.value && timeToField.value) {
presets.forEach((preset, index) => {
if (timeFromField.value === preset.from && timeToField.value === preset.to) {
presetsContainer.children[index].classList.add('active');
}
});
}
// Слушаем изменения времени вручную
const clearPresets = function() {
presetsContainer.querySelectorAll('.delivery-time-preset-btn').forEach(b => {
b.classList.remove('active');
});
};
timeFromField.addEventListener('change', clearPresets);
timeToField.addEventListener('change', clearPresets);
// Вставляем пресеты в контейнер
container.appendChild(presetsContainer);
}
/**
* Форматирует дату для input type="date" (YYYY-MM-DD)
*/
function formatDateForInput(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Автоматическая инициализация при загрузке DOM
*/
function autoInit() {
document.addEventListener('DOMContentLoaded', function() {
// Ищем поля по стандартным ID Django форм
const dateField = document.getElementById('id_delivery_date');
const timeFromField = document.getElementById('id_time_from');
const timeToField = document.getElementById('id_time_to');
if (dateField && timeFromField && timeToField) {
initDeliveryDateTime(
'id_delivery_date',
'id_time_from',
'id_time_to'
);
}
});
}
// Экспортируем функции для глобального использования
window.DeliveryDateTime = {
init: initDeliveryDateTime,
autoInit: autoInit
};
// Автоматическая инициализация
autoInit();
})();

View File

@@ -6,6 +6,7 @@
{% block extra_css %} {% block extra_css %}
<meta name="csrf-token" content="{{ csrf_token }}"> <meta name="csrf-token" content="{{ csrf_token }}">
<link rel="stylesheet" href="{% static 'orders/css/delivery_datetime.css' %}">
<style> <style>
/* Скрываем DELETE checkbox */ /* Скрываем DELETE checkbox */
input[name$="-DELETE"] { input[name$="-DELETE"] {
@@ -200,34 +201,40 @@
<h5 class="mb-0">Дата и время доставки</h5> <h5 class="mb-0">Дата и время доставки</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="delivery-datetime-wrapper">
<div class="col-md-4"> <!-- Поля ввода в один ряд -->
<div class="mb-3"> <div class="row g-3 mb-3">
<label for="{{ form.delivery_date.id_for_label }}" class="form-label">Дата</label> <div class="col-md-4">
<label for="{{ form.delivery_date.id_for_label }}" class="form-label">
Дата <span class="text-danger">*</span>
</label>
{{ form.delivery_date }} {{ form.delivery_date }}
{% if form.delivery_date.errors %} {% if form.delivery_date.errors %}
<div class="text-danger">{{ form.delivery_date.errors }}</div> <div class="text-danger">{{ form.delivery_date.errors }}</div>
{% endif %} {% endif %}
</div> </div>
</div> <div class="col-md-4">
<div class="col-md-4"> <label for="{{ form.time_from.id_for_label }}" class="form-label">
<div class="mb-3"> Время от
<label for="{{ form.delivery_time_start.id_for_label }}" class="form-label">Время от</label> </label>
{{ form.delivery_time_start }} {{ form.time_from }}
{% if form.delivery_time_start.errors %} {% if form.time_from.errors %}
<div class="text-danger">{{ form.delivery_time_start.errors }}</div> <div class="text-danger">{{ form.time_from.errors }}</div>
{% endif %} {% endif %}
</div> </div>
</div> <div class="col-md-4">
<div class="col-md-4"> <label for="{{ form.time_to.id_for_label }}" class="form-label">
<div class="mb-3"> Время до
<label for="{{ form.delivery_time_end.id_for_label }}" class="form-label">Время до</label> </label>
{{ form.delivery_time_end }} {{ form.time_to }}
{% if form.delivery_time_end.errors %} {% if form.time_to.errors %}
<div class="text-danger">{{ form.delivery_time_end.errors }}</div> <div class="text-danger">{{ form.time_to.errors }}</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Кнопки быстрого выбора ниже полей -->
<div id="delivery-datetime-quick-buttons"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -910,6 +917,9 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
</script> </script>
<!-- Delivery Date/Time Widget -->
<script src="{% static 'orders/js/delivery_datetime.js' %}"></script>
<!-- Unified Transaction Form --> <!-- Unified Transaction Form -->
<script src="{% static 'orders/js/unified_transaction_form.js' %}"></script> <script src="{% static 'orders/js/unified_transaction_form.js' %}"></script>
{% if order.pk %} {% if order.pk %}
@@ -971,24 +981,36 @@ document.addEventListener('DOMContentLoaded', function() {
} }
function syncUIFromCheckbox() { function syncUIFromCheckbox() {
if (!isDeliveryCheckbox) return; // Защита от null
if (isDeliveryCheckbox.checked) { if (isDeliveryCheckbox.checked) {
document.getElementById('delivery-type-delivery').checked = true; const deliveryRadio = document.getElementById('delivery-type-delivery');
deliveryModeFields.style.display = 'block'; if (deliveryRadio) {
pickupFields.style.display = 'none'; deliveryRadio.checked = true;
}
if (deliveryModeFields) deliveryModeFields.style.display = 'block';
if (pickupFields) pickupFields.style.display = 'none';
} else { } else {
document.getElementById('delivery-type-pickup').checked = true; const pickupRadio = document.getElementById('delivery-type-pickup');
deliveryModeFields.style.display = 'none'; if (pickupRadio) {
pickupFields.style.display = 'block'; pickupRadio.checked = true;
}
if (deliveryModeFields) deliveryModeFields.style.display = 'none';
if (pickupFields) pickupFields.style.display = 'block';
} }
} }
// Обработчики для кнопок // Обработчики для кнопок
deliveryTypeRadios.forEach(radio => { if (deliveryTypeRadios && deliveryTypeRadios.length > 0) {
radio.addEventListener('change', syncDeliveryTypeFromRadio); deliveryTypeRadios.forEach(radio => {
}); radio.addEventListener('change', syncDeliveryTypeFromRadio);
});
}
// Инициализация при загрузке // Инициализация при загрузке
syncUIFromCheckbox(); if (isDeliveryCheckbox) {
syncUIFromCheckbox();
}
// Показ/скрытие полей получателя // Показ/скрытие полей получателя
const otherRecipientCheckbox = document.getElementById('{{ form.other_recipient.id_for_label }}'); const otherRecipientCheckbox = document.getElementById('{{ form.other_recipient.id_for_label }}');

View File

@@ -131,9 +131,9 @@ def order_create(request):
delivery_cost = form.cleaned_data.get('delivery_cost', Decimal('0')) delivery_cost = form.cleaned_data.get('delivery_cost', Decimal('0'))
pickup_warehouse = form.cleaned_data.get('pickup_warehouse') pickup_warehouse = form.cleaned_data.get('pickup_warehouse')
# Проверяем наличие обязательных полей # Проверяем наличие обязательных полей (время необязательно)
if not all([delivery_type, delivery_date, time_from, time_to]): if not delivery_type or not delivery_date:
raise ValidationError('Необходимо заполнить все поля доставки') raise ValidationError('Необходимо указать способ доставки и дату доставки')
# Обрабатываем адрес для курьерской доставки # Обрабатываем адрес для курьерской доставки
address = None address = None
@@ -284,9 +284,9 @@ def order_update(request, order_number):
delivery_cost = form.cleaned_data.get('delivery_cost', Decimal('0')) delivery_cost = form.cleaned_data.get('delivery_cost', Decimal('0'))
pickup_warehouse = form.cleaned_data.get('pickup_warehouse') pickup_warehouse = form.cleaned_data.get('pickup_warehouse')
# Проверяем наличие обязательных полей # Проверяем наличие обязательных полей (время необязательно)
if not all([delivery_type, delivery_date, time_from, time_to]): if not delivery_type or not delivery_date:
raise ValidationError('Необходимо заполнить все поля доставки') raise ValidationError('Необходимо указать способ доставки и дату доставки')
# Обрабатываем адрес для курьерской доставки # Обрабатываем адрес для курьерской доставки
address = None address = None