Files
octopus/myproject/orders/static/orders/js/autosave.js
Andrey Smakotin 4a4bd437b9 refactor: Заменить сущность Магазин (Shop) на Склад (Warehouse)
Упрощена логика системы путём замены отдельной сущности "Магазин"
на универсальную сущность "Склад", которая может использоваться
как точка самовывоза.

Изменения:
- Расширена модель Warehouse: добавлены адрес, контакты, флаг is_pickup_point
- Модель Order: поле pickup_shop заменено на pickup_warehouse
- Обновлены все формы, сервисы, views, admin для работы со складами
- Обновлены шаблоны HTML и JavaScript код
- Удалено приложение shops полностью
- Пересозданы миграции БД
- Обновлён навбар (удалена ссылка на магазины)

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 23:50:30 +03:00

551 lines
20 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"]',
'select[name="payment_method"]',
'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();
}
/**
* Наблюдает за изменениями в формсете товаров
*/
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="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';
});
}
/**
* Планирует автосохранение с задержкой (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 paymentMethodField = form.querySelector('select[name="payment_method"]');
if (paymentMethodField && paymentMethodField.value) {
data.payment_method = paymentMethodField.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"]');
if (addressStreetField && addressStreetField.value) {
data.address_street = addressStreetField.value;
// Указываем режим "новый адрес" если есть улица
data.address_mode = 'new';
}
const addressBuildingField = form.querySelector('input[name="address_building_number"]');
if (addressBuildingField && addressBuildingField.value) {
data.address_building_number = addressBuildingField.value;
}
const addressApartmentField = form.querySelector('input[name="address_apartment_number"]');
if (addressApartmentField && addressApartmentField.value) {
data.address_apartment_number = addressApartmentField.value;
}
const addressEntranceField = form.querySelector('input[name="address_entrance"]');
if (addressEntranceField && addressEntranceField.value) {
data.address_entrance = addressEntranceField.value;
}
const addressFloorField = form.querySelector('input[name="address_floor"]');
if (addressFloorField && addressFloorField.value) {
data.address_floor = addressFloorField.value;
}
const addressIntercomField = form.querySelector('input[name="address_intercom_code"]');
if (addressIntercomField && addressIntercomField.value) {
data.address_intercom_code = addressIntercomField.value;
}
const addressInstructionsField = form.querySelector('textarea[name="address_delivery_instructions"]');
if (addressInstructionsField && addressInstructionsField.value) {
data.address_delivery_instructions = addressInstructionsField.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;
}
// Собираем позиции заказа
data.items = collectOrderItems();
// Флаг для пересчета итоговой суммы
data.recalculate = true;
return data;
}
/**
* Собирает данные о позициях заказа
*/
function collectOrderItems() {
const items = [];
const itemForms = document.querySelectorAll('.order-item-form');
itemForms.forEach(form => {
// Пропускаем удаленные формы
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
if (deleteCheckbox && deleteCheckbox.checked) {
return;
}
// Получаем выбранный товар/комплект
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'
};
// Определяем тип: товар или комплект
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;
}
/**
* Получает 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();
}
})();