665 lines
25 KiB
JavaScript
665 lines
25 KiB
JavaScript
/**
|
||
* Модуль автосохранения черновиков заказов.
|
||
*
|
||
* Автоматически сохраняет изменения в черновике заказа при изменении полей формы.
|
||
* Использует debouncing для уменьшения количества запросов к серверу.
|
||
*/
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// Конфигурация
|
||
const CONFIG = {
|
||
AUTOSAVE_DELAY: 3000, // Задержка перед автосохранением (мс)
|
||
AUTOSAVE_URL_PATTERN: '/orders/{orderId}/autosave/',
|
||
STATUS_DISPLAY_DURATION: 5000, // Длительность показа статуса (мс)
|
||
};
|
||
|
||
// Состояние модуля
|
||
let autosaveTimer = null;
|
||
let isAutosaving = false;
|
||
let orderId = null;
|
||
|
||
/**
|
||
* Инициализация модуля автосохранения
|
||
*/
|
||
function init() {
|
||
// Проверяем, что мы на странице редактирования
|
||
const isEditPage = window.location.pathname.includes('/edit/');
|
||
if (!isEditPage) {
|
||
return;
|
||
}
|
||
|
||
const orderForm = document.getElementById('order-form');
|
||
if (!orderForm) {
|
||
return;
|
||
}
|
||
|
||
// Получаем ID заказа из URL
|
||
const urlMatch = window.location.pathname.match(/\/orders\/(\d+)\/edit\//);
|
||
if (!urlMatch) {
|
||
return;
|
||
}
|
||
orderId = urlMatch[1];
|
||
|
||
// Инициализируем UI индикатора
|
||
initStatusIndicator();
|
||
|
||
// Добавляем обработчики событий
|
||
attachEventListeners();
|
||
}
|
||
|
||
/**
|
||
* Создает индикатор статуса автосохранения
|
||
*/
|
||
function initStatusIndicator() {
|
||
// Проверяем, не создан ли уже индикатор
|
||
if (document.getElementById('autosave-status')) {
|
||
return;
|
||
}
|
||
|
||
const indicator = document.createElement('div');
|
||
indicator.id = 'autosave-status';
|
||
indicator.className = 'alert alert-info';
|
||
indicator.style.cssText = `
|
||
position: fixed;
|
||
top: 70px;
|
||
right: 20px;
|
||
z-index: 1050;
|
||
min-width: 250px;
|
||
display: none;
|
||
padding: 10px 15px;
|
||
border-radius: 4px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||
`;
|
||
indicator.innerHTML = `
|
||
<div class="d-flex align-items-center">
|
||
<span id="autosave-icon" class="me-2"></span>
|
||
<span id="autosave-text">Автосохранение...</span>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(indicator);
|
||
}
|
||
|
||
/**
|
||
* Показывает статус автосохранения
|
||
*/
|
||
function showStatus(type, message) {
|
||
const indicator = document.getElementById('autosave-status');
|
||
const icon = document.getElementById('autosave-icon');
|
||
const text = document.getElementById('autosave-text');
|
||
|
||
if (!indicator || !icon || !text) {
|
||
return;
|
||
}
|
||
|
||
// Убираем все классы
|
||
indicator.className = 'alert';
|
||
|
||
// Устанавливаем соответствующий класс и иконку
|
||
switch (type) {
|
||
case 'saving':
|
||
indicator.classList.add('alert-info');
|
||
icon.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
||
break;
|
||
case 'success':
|
||
indicator.classList.add('alert-success');
|
||
icon.innerHTML = '<i class="bi bi-check-circle-fill"></i>';
|
||
break;
|
||
case 'error':
|
||
indicator.classList.add('alert-danger');
|
||
icon.innerHTML = '<i class="bi bi-exclamation-triangle-fill"></i>';
|
||
break;
|
||
}
|
||
|
||
text.textContent = message;
|
||
indicator.style.display = 'block';
|
||
|
||
// Автоматически скрываем статус (кроме ошибок)
|
||
if (type !== 'error') {
|
||
setTimeout(() => {
|
||
indicator.style.display = 'none';
|
||
}, CONFIG.STATUS_DISPLAY_DURATION);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Прикрепляет обработчики событий к полям формы
|
||
*/
|
||
function attachEventListeners() {
|
||
const form = document.getElementById('order-form');
|
||
if (!form) {
|
||
return;
|
||
}
|
||
|
||
// Слушаем изменения в основных полях заказа
|
||
const fieldsToWatch = [
|
||
'select[name="customer"]',
|
||
'select[name="status"]',
|
||
'input[name="delivery_date"]',
|
||
'input[name="delivery_time_start"]',
|
||
'input[name="delivery_time_end"]',
|
||
'input[name="delivery_cost"]',
|
||
'textarea[name="special_instructions"]',
|
||
'input[name="discount_amount"]',
|
||
'input[type="checkbox"]',
|
||
'input[type="radio"]',
|
||
'select[name="delivery_address"]',
|
||
'select[name="pickup_warehouse"]',
|
||
// Поля адреса доставки
|
||
'input[name="address_street"]',
|
||
'input[name="address_building_number"]',
|
||
'input[name="address_apartment_number"]',
|
||
'input[name="address_entrance"]',
|
||
'input[name="address_floor"]',
|
||
'input[name="address_intercom_code"]',
|
||
'textarea[name="address_delivery_instructions"]',
|
||
// Поля получателя
|
||
'input[name="recipient_name"]',
|
||
'input[name="recipient_phone"]',
|
||
];
|
||
|
||
fieldsToWatch.forEach(selector => {
|
||
const fields = form.querySelectorAll(selector);
|
||
fields.forEach(field => {
|
||
// Для select и checkbox используем 'change'
|
||
if (field.tagName === 'SELECT' || field.type === 'checkbox' || field.type === 'radio') {
|
||
field.addEventListener('change', scheduleAutosave);
|
||
} else {
|
||
// Для текстовых полей используем 'input'
|
||
field.addEventListener('input', scheduleAutosave);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Слушаем изменения в формах товаров (formset)
|
||
observeFormsetChanges();
|
||
|
||
// Слушаем изменения в формах платежей (payment formset)
|
||
observePaymentFormsetChanges();
|
||
}
|
||
|
||
/**
|
||
* Наблюдает за изменениями в формсете товаров
|
||
*/
|
||
function observeFormsetChanges() {
|
||
const formsetContainer = document.getElementById('order-items-container');
|
||
if (!formsetContainer) {
|
||
return;
|
||
}
|
||
|
||
// Наблюдаем за добавлением/удалением форм
|
||
const observer = new MutationObserver(() => {
|
||
attachFormsetEventListeners();
|
||
});
|
||
|
||
observer.observe(formsetContainer, {
|
||
childList: true,
|
||
subtree: true
|
||
});
|
||
|
||
// Прикрепляем обработчики к существующим формам
|
||
attachFormsetEventListeners();
|
||
}
|
||
|
||
/**
|
||
* Прикрепляет обработчики к полям в формах товаров
|
||
*/
|
||
function attachFormsetEventListeners() {
|
||
const itemForms = document.querySelectorAll('.order-item-form');
|
||
|
||
itemForms.forEach(form => {
|
||
// Если уже прикреплены обработчики, пропускаем
|
||
if (form.dataset.autosaveAttached === 'true') {
|
||
return;
|
||
}
|
||
|
||
const fields = form.querySelectorAll('select, input[type="number"], input[type="text"], input[type="checkbox"]');
|
||
|
||
fields.forEach(field => {
|
||
if (field.tagName === 'SELECT' || field.type === 'checkbox') {
|
||
field.addEventListener('change', scheduleAutosave);
|
||
|
||
// Для Select2 добавляем дополнительный обработчик
|
||
if (window.jQuery && jQuery(field).data('select2')) {
|
||
jQuery(field).on('select2:select', scheduleAutosave);
|
||
}
|
||
} else {
|
||
field.addEventListener('input', scheduleAutosave);
|
||
}
|
||
});
|
||
|
||
form.dataset.autosaveAttached = 'true';
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Наблюдает за изменениями в формсете платежей
|
||
*/
|
||
function observePaymentFormsetChanges() {
|
||
const paymentsContainer = document.getElementById('payments-container');
|
||
if (!paymentsContainer) {
|
||
return;
|
||
}
|
||
|
||
// Наблюдаем за добавлением/удалением форм платежей
|
||
const observer = new MutationObserver(() => {
|
||
attachPaymentFormsetEventListeners();
|
||
});
|
||
|
||
observer.observe(paymentsContainer, {
|
||
childList: true,
|
||
subtree: true
|
||
});
|
||
|
||
// Прикрепляем обработчики к существующим формам
|
||
attachPaymentFormsetEventListeners();
|
||
}
|
||
|
||
/**
|
||
* Прикрепляет обработчики к полям в формах платежей
|
||
*/
|
||
function attachPaymentFormsetEventListeners() {
|
||
const paymentForms = document.querySelectorAll('.payment-form');
|
||
|
||
paymentForms.forEach(form => {
|
||
// Если уже прикреплены обработчики, пропускаем
|
||
if (form.dataset.autosavePaymentAttached === 'true') {
|
||
return;
|
||
}
|
||
|
||
const fields = form.querySelectorAll('select, input[type="number"], input[type="text"], textarea, input[type="checkbox"]');
|
||
|
||
fields.forEach(field => {
|
||
if (field.tagName === 'SELECT' || field.type === 'checkbox') {
|
||
field.addEventListener('change', scheduleAutosave);
|
||
} else {
|
||
field.addEventListener('input', scheduleAutosave);
|
||
}
|
||
});
|
||
|
||
form.dataset.autosavePaymentAttached = 'true';
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Планирует автосохранение с задержкой (debouncing)
|
||
*/
|
||
function scheduleAutosave() {
|
||
// Отменяем предыдущий таймер
|
||
if (autosaveTimer) {
|
||
clearTimeout(autosaveTimer);
|
||
}
|
||
|
||
// Устанавливаем новый таймер
|
||
autosaveTimer = setTimeout(() => {
|
||
performAutosave();
|
||
}, CONFIG.AUTOSAVE_DELAY);
|
||
}
|
||
|
||
/**
|
||
* Выполняет автосохранение
|
||
*/
|
||
async function performAutosave() {
|
||
if (isAutosaving) {
|
||
return;
|
||
}
|
||
|
||
isAutosaving = true;
|
||
showStatus('saving', 'Сохранение...');
|
||
|
||
try {
|
||
// Собираем данные формы
|
||
const formData = collectFormData();
|
||
|
||
// Отправляем AJAX запрос
|
||
const url = CONFIG.AUTOSAVE_URL_PATTERN.replace('{orderId}', orderId);
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': getCsrfToken(),
|
||
},
|
||
body: JSON.stringify(formData)
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (response.ok && data.success) {
|
||
const lastSaved = formatDateTime(data.last_saved);
|
||
showStatus('success', 'Сохранено ' + lastSaved);
|
||
} else {
|
||
showStatus('error', 'Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
||
}
|
||
|
||
} catch (error) {
|
||
showStatus('error', 'Ошибка соединения с сервером');
|
||
} finally {
|
||
isAutosaving = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Собирает данные формы для отправки
|
||
*/
|
||
function collectFormData() {
|
||
const form = document.getElementById('order-form');
|
||
const data = {};
|
||
|
||
// Основные поля заказа
|
||
const customerField = form.querySelector('select[name="customer"]');
|
||
if (customerField && customerField.value) {
|
||
data.customer = parseInt(customerField.value);
|
||
}
|
||
|
||
const statusField = form.querySelector('select[name="status"]');
|
||
if (statusField && statusField.value) {
|
||
data.status = parseInt(statusField.value);
|
||
}
|
||
|
||
const deliveryDateField = form.querySelector('input[name="delivery_date"]');
|
||
if (deliveryDateField && deliveryDateField.value) {
|
||
data.delivery_date = deliveryDateField.value;
|
||
}
|
||
|
||
const deliveryTimeStartField = form.querySelector('input[name="delivery_time_start"]');
|
||
if (deliveryTimeStartField && deliveryTimeStartField.value) {
|
||
data.delivery_time_start = deliveryTimeStartField.value;
|
||
}
|
||
|
||
const deliveryTimeEndField = form.querySelector('input[name="delivery_time_end"]');
|
||
if (deliveryTimeEndField && deliveryTimeEndField.value) {
|
||
data.delivery_time_end = deliveryTimeEndField.value;
|
||
}
|
||
|
||
const deliveryCostField = form.querySelector('input[name="delivery_cost"]');
|
||
if (deliveryCostField && deliveryCostField.value) {
|
||
data.delivery_cost = deliveryCostField.value;
|
||
}
|
||
|
||
const specialInstructionsField = form.querySelector('textarea[name="special_instructions"]');
|
||
if (specialInstructionsField) {
|
||
data.special_instructions = specialInstructionsField.value;
|
||
}
|
||
|
||
const discountAmountField = form.querySelector('input[name="discount_amount"]');
|
||
if (discountAmountField && discountAmountField.value) {
|
||
data.discount_amount = discountAmountField.value;
|
||
}
|
||
|
||
// Checkbox поля
|
||
const isDeliveryField = form.querySelector('input[name="is_delivery"]');
|
||
if (isDeliveryField) {
|
||
data.is_delivery = isDeliveryField.checked;
|
||
}
|
||
|
||
const customerIsRecipientField = form.querySelector('input[name="customer_is_recipient"]');
|
||
if (customerIsRecipientField) {
|
||
data.customer_is_recipient = customerIsRecipientField.checked;
|
||
}
|
||
|
||
const isAnonymousField = form.querySelector('input[name="is_anonymous"]');
|
||
if (isAnonymousField) {
|
||
data.is_anonymous = isAnonymousField.checked;
|
||
}
|
||
|
||
// Адрес доставки или точка самовывоза
|
||
const deliveryAddressField = form.querySelector('select[name="delivery_address"]');
|
||
if (deliveryAddressField && deliveryAddressField.value) {
|
||
data.delivery_address = parseInt(deliveryAddressField.value);
|
||
}
|
||
|
||
const pickupWarehouseField = form.querySelector('select[name="pickup_warehouse"]');
|
||
if (pickupWarehouseField && pickupWarehouseField.value) {
|
||
data.pickup_warehouse = parseInt(pickupWarehouseField.value);
|
||
}
|
||
|
||
// Поля адреса доставки (новая логика с прямым вводом)
|
||
const addressStreetField = form.querySelector('input[name="address_street"]');
|
||
const addressBuildingField = form.querySelector('input[name="address_building_number"]');
|
||
const addressApartmentField = form.querySelector('input[name="address_apartment_number"]');
|
||
const addressEntranceField = form.querySelector('input[name="address_entrance"]');
|
||
const addressFloorField = form.querySelector('input[name="address_floor"]');
|
||
const addressIntercomField = form.querySelector('input[name="address_intercom_code"]');
|
||
const addressInstructionsField = form.querySelector('textarea[name="address_delivery_instructions"]');
|
||
|
||
// Собираем все поля адреса
|
||
const addressFields = {
|
||
address_street: addressStreetField?.value || '',
|
||
address_building_number: addressBuildingField?.value || '',
|
||
address_apartment_number: addressApartmentField?.value || '',
|
||
address_entrance: addressEntranceField?.value || '',
|
||
address_floor: addressFloorField?.value || '',
|
||
address_intercom_code: addressIntercomField?.value || '',
|
||
address_delivery_instructions: addressInstructionsField?.value || '',
|
||
};
|
||
|
||
// Проверяем, заполнено ли хотя бы одно поле адреса
|
||
const hasAnyAddressData = Object.values(addressFields).some(value => value.trim() !== '');
|
||
|
||
if (hasAnyAddressData) {
|
||
// Указываем режим "новый адрес" если заполнено хотя бы одно поле
|
||
data.address_mode = 'new';
|
||
|
||
// Добавляем все непустые поля в данные
|
||
Object.entries(addressFields).forEach(([key, value]) => {
|
||
if (value.trim() !== '') {
|
||
data[key] = value;
|
||
}
|
||
});
|
||
}
|
||
|
||
const addressConfirmField = form.querySelector('input[name="address_confirm_with_recipient"]');
|
||
if (addressConfirmField) {
|
||
data.address_confirm_with_recipient = addressConfirmField.checked;
|
||
}
|
||
|
||
// Поля получателя
|
||
const recipientNameField = form.querySelector('input[name="recipient_name"]');
|
||
if (recipientNameField && recipientNameField.value) {
|
||
data.recipient_name = recipientNameField.value;
|
||
}
|
||
|
||
const recipientPhoneField = form.querySelector('input[name="recipient_phone"]');
|
||
if (recipientPhoneField && recipientPhoneField.value) {
|
||
data.recipient_phone = recipientPhoneField.value;
|
||
}
|
||
|
||
// Собираем позиции заказа
|
||
const orderItemsData = collectOrderItems();
|
||
data.items = orderItemsData.items;
|
||
data.deleted_item_ids = orderItemsData.deletedItemIds;
|
||
|
||
// Собираем платежи
|
||
const paymentsData = collectPayments();
|
||
data.payments = paymentsData.payments;
|
||
data.deleted_payment_ids = paymentsData.deletedPaymentIds;
|
||
|
||
// Флаг для пересчета итоговой суммы
|
||
data.recalculate = true;
|
||
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Собирает данные о позициях заказа
|
||
*/
|
||
function collectOrderItems() {
|
||
const items = [];
|
||
const deletedItemIds = [];
|
||
const itemForms = document.querySelectorAll('.order-item-form');
|
||
|
||
itemForms.forEach(form => {
|
||
// Проверяем, помечена ли форма на удаление
|
||
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||
const idField = form.querySelector('input[name$="-id"]');
|
||
|
||
if (deleteCheckbox && deleteCheckbox.checked) {
|
||
// Если форма помечена на удаление и имеет ID, добавляем в список удалённых
|
||
if (idField && idField.value) {
|
||
deletedItemIds.push(parseInt(idField.value));
|
||
}
|
||
return; // Не добавляем в items
|
||
}
|
||
|
||
// Получаем выбранный товар/комплект
|
||
const itemSelect = form.querySelector('.select2-order-item');
|
||
if (!itemSelect || !itemSelect.value) {
|
||
return;
|
||
}
|
||
|
||
const itemValue = itemSelect.value;
|
||
const quantityInput = form.querySelector('input[name$="-quantity"]');
|
||
const priceInput = form.querySelector('input[name$="-price"]');
|
||
|
||
if (!quantityInput || !priceInput) {
|
||
return;
|
||
}
|
||
|
||
const item = {
|
||
quantity: quantityInput.value || '1',
|
||
price: (priceInput.value || '0').replace(',', '.')
|
||
};
|
||
|
||
// Если есть ID (существующий товар), добавляем его
|
||
if (idField && idField.value) {
|
||
item.id = parseInt(idField.value);
|
||
}
|
||
|
||
// Определяем тип: товар или комплект
|
||
if (itemValue.startsWith('product_')) {
|
||
item.product_id = parseInt(itemValue.replace('product_', ''));
|
||
} else if (itemValue.startsWith('kit_')) {
|
||
item.product_kit_id = parseInt(itemValue.replace('kit_', ''));
|
||
}
|
||
|
||
items.push(item);
|
||
});
|
||
|
||
return { items, deletedItemIds };
|
||
}
|
||
|
||
/**
|
||
* Собирает данные о платежах
|
||
*/
|
||
function collectPayments() {
|
||
const payments = [];
|
||
const deletedPaymentIds = [];
|
||
const paymentForms = document.querySelectorAll('.payment-form');
|
||
|
||
paymentForms.forEach(form => {
|
||
// Проверяем, помечена ли форма на удаление
|
||
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||
const idField = form.querySelector('input[name$="-id"]');
|
||
|
||
if (deleteCheckbox && deleteCheckbox.checked) {
|
||
// Если форма помечена на удаление и имеет ID, добавляем в список удалённых
|
||
if (idField && idField.value) {
|
||
deletedPaymentIds.push(parseInt(idField.value));
|
||
}
|
||
return; // Не добавляем в payments
|
||
}
|
||
|
||
// Получаем способ оплаты и сумму
|
||
const paymentMethodSelect = form.querySelector('select[name$="-payment_method"]');
|
||
const amountInput = form.querySelector('input[name$="-amount"]');
|
||
const notesInput = form.querySelector('textarea[name$="-notes"]');
|
||
|
||
if (!paymentMethodSelect || !paymentMethodSelect.value || !amountInput || !amountInput.value) {
|
||
return; // Пропускаем пустые платежи
|
||
}
|
||
|
||
const payment = {
|
||
payment_method_id: parseInt(paymentMethodSelect.value),
|
||
amount: (amountInput.value || '0').replace(',', '.'),
|
||
notes: notesInput ? notesInput.value : ''
|
||
};
|
||
|
||
// Если есть ID (существующий платеж), добавляем его
|
||
if (idField && idField.value) {
|
||
payment.id = parseInt(idField.value);
|
||
}
|
||
|
||
payments.push(payment);
|
||
});
|
||
|
||
return { payments, deletedPaymentIds };
|
||
}
|
||
|
||
/**
|
||
* Получает CSRF токен из cookies или meta тега
|
||
*/
|
||
function getCsrfToken() {
|
||
// Пробуем получить из cookie
|
||
const name = 'csrftoken';
|
||
let cookieValue = null;
|
||
if (document.cookie && document.cookie !== '') {
|
||
const cookies = document.cookie.split(';');
|
||
for (let i = 0; i < cookies.length; i++) {
|
||
const cookie = cookies[i].trim();
|
||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Если не нашли в cookie, пробуем в meta теге
|
||
if (!cookieValue) {
|
||
const metaTag = document.querySelector('meta[name="csrf-token"]');
|
||
if (metaTag) {
|
||
cookieValue = metaTag.getAttribute('content');
|
||
}
|
||
}
|
||
|
||
// Если не нашли в meta теге, пробуем в input поле
|
||
if (!cookieValue) {
|
||
const csrfInput = document.querySelector('input[name="csrfmiddlewaretoken"]');
|
||
if (csrfInput) {
|
||
cookieValue = csrfInput.value;
|
||
}
|
||
}
|
||
|
||
return cookieValue;
|
||
}
|
||
|
||
/**
|
||
* Форматирует дату и время для отображения
|
||
*/
|
||
function formatDateTime(isoString) {
|
||
if (!isoString) {
|
||
return 'только что';
|
||
}
|
||
|
||
const date = new Date(isoString);
|
||
const now = new Date();
|
||
const diffMs = now - date;
|
||
const diffSecs = Math.floor(diffMs / 1000);
|
||
const diffMins = Math.floor(diffSecs / 60);
|
||
|
||
if (diffSecs < 60) {
|
||
return 'только что';
|
||
} else if (diffMins < 60) {
|
||
return diffMins + ' мин. назад';
|
||
} else {
|
||
const hours = date.getHours().toString().padStart(2, '0');
|
||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||
return 'в ' + hours + ':' + minutes;
|
||
}
|
||
}
|
||
|
||
// Инициализация при загрузке DOM
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
|
||
// Экспортируем функцию scheduleAutosave в глобальную область
|
||
window.orderAutosave = {
|
||
scheduleAutosave: scheduleAutosave
|
||
};
|
||
|
||
})();
|