Files
octopus/myproject/orders/static/orders/js/autosave.js

665 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Модуль автосохранения черновиков заказов.
*
* Автоматически сохраняет изменения в черновике заказа при изменении полей формы.
* Использует 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
};
})();