Files
octopus/myproject/customers/templates/customers/customer_import.html
Andrey Smakotin a2ce8d648f Исправлена логика прогресс-бара импорта: форма отправляется до блокировки UI
- Форма начинает отправку сразу при submit
- Прогресс-бар и защита включаются через 10ms (после начала отправки)
- Предупреждение появляется только при попытке закрыть страницу во время импорта
- Импорт корректно выполняется на сервере
2026-01-03 14:35:47 +03:00

408 lines
19 KiB
HTML
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.
{% extends "base.html" %}
{% block title %}Импорт клиентов{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Импорт клиентов</h1>
<a href="{% url 'customers:customer-list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Назад к списку
</a>
</div>
<!-- Инструкция -->
<div class="alert alert-info mb-4">
<h5 class="alert-heading"><i class="bi bi-info-circle"></i> Инструкция</h5>
<p class="mb-2">Загрузите CSV или Excel файл со следующими столбцами:</p>
<ul class="mb-2">
<li><strong>Имя</strong> (обязательно)</li>
<li><strong>Email</strong> (опционально, должен быть уникальным)</li>
<li><strong>Телефон</strong> (опционально, формат: +375XXXXXXXXX, должен быть уникальным)</li>
</ul>
<p class="mb-0">
<a href="{% url 'customers:customer-export' %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-download"></i> Скачать образец (текущие клиенты)
</a>
</p>
</div>
<!-- Результаты импорта -->
{% if import_result %}
<div class="card mb-4 border-{{ import_result.success|yesno:'success,danger' }}">
<div class="card-header bg-{{ import_result.success|yesno:'success,danger' }} text-white">
<h5 class="mb-0">
<i class="bi bi-{{ import_result.success|yesno:'check-circle,exclamation-triangle' }}"></i>
Результаты импорта
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-2">
<div class="text-center p-3 bg-light rounded">
<div class="h2 mb-0 text-success">{{ import_result.created }}</div>
<small class="text-muted">Создано</small>
</div>
</div>
<div class="col-md-2">
<div class="text-center p-3 bg-light rounded">
<div class="h2 mb-0 text-primary">{{ import_result.enriched }}</div>
<small class="text-muted">Дополнено</small>
</div>
</div>
<div class="col-md-2">
<div class="text-center p-3 bg-light rounded">
<div class="h2 mb-0 text-info">{{ import_result.updated }}</div>
<small class="text-muted">Обновлено</small>
</div>
</div>
<div class="col-md-2">
<div class="text-center p-3 bg-light rounded">
<div class="h2 mb-0 text-secondary">{{ import_result.conflicts_resolved }}</div>
<small class="text-muted">Альт. контакты</small>
</div>
</div>
<div class="col-md-2">
<div class="text-center p-3 bg-light rounded">
<div class="h2 mb-0 text-warning">{{ import_result.duplicate_count }}</div>
<small class="text-muted">Дубликатов</small>
</div>
</div>
<div class="col-md-2">
<div class="text-center p-3 bg-light rounded">
<div class="h2 mb-0 text-danger">{{ import_result.real_error_count }}</div>
<small class="text-muted">Ошибок</small>
</div>
</div>
</div>
{% if import_result.real_errors %}
<div class="alert alert-warning">
<h6 class="alert-heading">
<i class="bi bi-exclamation-triangle"></i>
Обнаружено {{ import_result.real_error_count }} ошибок валидации
</h6>
<p class="mb-2">Первые ошибки:</p>
<ul class="mb-0">
{% for error in import_result.real_errors|slice:":10" %}
<li>
<strong>Строка {{ error.row }}:</strong> {{ error.reason }}
{% if error.email %}<code>{{ error.email }}</code>{% endif %}
{% if error.phone %}<code>{{ error.phone }}</code>{% endif %}
</li>
{% endfor %}
</ul>
{% if import_result.real_error_count > 10 %}
<p class="mb-0 mt-2 text-muted">
<small>...и ещё {{ import_result.real_error_count|add:"-10" }} ошибок</small>
</p>
{% endif %}
</div>
{% if has_error_file %}
<div class="text-center">
<a href="{% url 'customers:customer-import-download-errors' %}" class="btn btn-danger btn-lg">
<i class="bi bi-download"></i> Скачать файл с ошибками
</a>
<p class="text-muted mt-2 mb-0">
<small>Исправьте ошибки в файле и загрузите снова</small>
</p>
</div>
{% endif %}
{% endif %}
{% if import_result.duplicate_count > 0 %}
<div class="alert alert-info mt-3">
<i class="bi bi-info-circle"></i>
Пропущено дубликатов: {{ import_result.duplicate_count }}
(клиенты с такими email/телефонами уже существуют)
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Форма загрузки -->
<div class="card">
<div class="card-body">
<form method="post" enctype="multipart/form-data" id="importForm">
{% csrf_token %}
<!-- Drag & Drop зона -->
<div class="mb-3">
<label for="file" class="form-label">Выберите файл</label>
<div id="dropZone" class="border border-2 border-dashed rounded p-4 text-center position-relative"
style="min-height: 150px; cursor: pointer; transition: all 0.3s;">
<input type="file" class="form-control d-none" id="file" name="file"
accept=".csv,.xlsx,.xls" required>
<div id="dropZoneContent">
<i class="bi bi-cloud-upload fs-1 text-muted"></i>
<p class="mt-3 mb-2">
<strong>Перетащите файл сюда</strong> или
<span class="text-primary">нажмите для выбора</span>
</p>
<p class="text-muted mb-0">
<small>
Поддерживаемые форматы: CSV, Excel (.xlsx, .xls)<br>
Также можно вставить файл через <kbd>Ctrl+V</kbd>
</small>
</p>
</div>
<div id="filePreview" class="d-none">
<i class="bi bi-file-earmark-spreadsheet fs-1 text-success"></i>
<p class="mt-3 mb-0">
<strong id="fileName"></strong>
<br>
<small class="text-muted" id="fileSize"></small>
</p>
<button type="button" class="btn btn-sm btn-outline-danger mt-2" id="removeFile">
<i class="bi bi-x-circle"></i> Удалить
</button>
</div>
</div>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="update_existing" name="update_existing">
<label class="form-check-label" for="update_existing">
Обновлять существующих клиентов (по email или телефону)
</label>
</div>
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="bi bi-upload"></i> Импортировать
</button>
<a href="{% url 'customers:customer-list' %}" class="btn btn-outline-secondary">
Отмена
</a>
</form>
<!-- Прогресс-бар (скрыт по умолчанию) -->
<div id="progressContainer" class="mt-4 d-none">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>Импорт в процессе...</strong>
<span id="progressText" class="text-muted">Подготовка...</span>
</div>
<div class="progress" style="height: 25px;">
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%">
<span id="progressPercent">0%</span>
</div>
</div>
<p class="text-muted mt-2 mb-0">
<small><i class="bi bi-exclamation-triangle text-warning"></i> Не закрывайте страницу до завершения импорта</small>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('file');
const dropZoneContent = document.getElementById('dropZoneContent');
const filePreview = document.getElementById('filePreview');
const fileName = document.getElementById('fileName');
const fileSize = document.getElementById('fileSize');
const removeFileBtn = document.getElementById('removeFile');
const importForm = document.getElementById('importForm');
// Клик по зоне = открыть диалог выбора файла
dropZone.addEventListener('click', function(e) {
if (e.target.id !== 'removeFile' && !e.target.closest('#removeFile')) {
fileInput.click();
}
});
// Обработка выбора файла через input
fileInput.addEventListener('change', function(e) {
if (e.target.files.length > 0) {
showFilePreview(e.target.files[0]);
}
});
// Drag & Drop события
dropZone.addEventListener('dragover', function(e) {
e.preventDefault();
e.stopPropagation();
dropZone.classList.add('border-primary', 'bg-light');
});
dropZone.addEventListener('dragleave', function(e) {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove('border-primary', 'bg-light');
});
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove('border-primary', 'bg-light');
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (isValidFile(file)) {
// Присваиваем файл в input через DataTransfer
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInput.files = dataTransfer.files;
showFilePreview(file);
} else {
alert('Неподдерживаемый формат файла. Используйте CSV или Excel (.xlsx, .xls)');
}
}
});
// Paste через Ctrl+V
document.addEventListener('paste', function(e) {
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
const item = items[i];
// Проверяем, есть ли файл в буфере
if (item.kind === 'file') {
const file = item.getAsFile();
if (file && isValidFile(file)) {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInput.files = dataTransfer.files;
showFilePreview(file);
e.preventDefault();
break;
}
}
}
});
// Показать превью файла
function showFilePreview(file) {
fileName.textContent = file.name;
fileSize.textContent = formatFileSize(file.size);
dropZoneContent.classList.add('d-none');
filePreview.classList.remove('d-none');
}
// Удалить файл
removeFileBtn.addEventListener('click', function(e) {
e.stopPropagation();
fileInput.value = '';
filePreview.classList.add('d-none');
dropZoneContent.classList.remove('d-none');
});
// Проверка формата файла
function isValidFile(file) {
const validExtensions = ['.csv', '.xlsx', '.xls'];
const fileName = file.name.toLowerCase();
return validExtensions.some(ext => fileName.endsWith(ext));
}
// Форматирование размера файла
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// Валидация перед отправкой
importForm.addEventListener('submit', function(e) {
if (!fileInput.files || fileInput.files.length === 0) {
e.preventDefault();
alert('Пожалуйста, выберите файл для импорта');
return false;
}
// НЕ блокируем отправку формы, но показываем прогресс-бар сразу после клика
// Используем setTimeout чтобы форма успела начать отправку
setTimeout(() => {
showProgressBar();
}, 10);
});
// Показать прогресс-бар и защиту от закрытия
function showProgressBar() {
const submitBtn = document.getElementById('submitBtn');
const progressContainer = document.getElementById('progressContainer');
const progressBar = document.getElementById('progressBar');
const progressPercent = document.getElementById('progressPercent');
const progressText = document.getElementById('progressText');
// Блокируем кнопку и форму
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Импорт...';
fileInput.disabled = true;
dropZone.style.pointerEvents = 'none';
dropZone.style.opacity = '0.6';
// Показываем прогресс-бар
progressContainer.classList.remove('d-none');
// Анимация прогресса (имитация, т.к. реальный прогресс без WebSocket сложен)
let progress = 0;
const progressInterval = setInterval(() => {
if (progress < 90) {
progress += Math.random() * 15;
if (progress > 90) progress = 90;
progressBar.style.width = progress + '%';
progressBar.setAttribute('aria-valuenow', progress);
progressPercent.textContent = Math.round(progress) + '%';
if (progress < 30) {
progressText.textContent = 'Чтение файла...';
} else if (progress < 60) {
progressText.textContent = 'Обработка данных...';
} else {
progressText.textContent = 'Сохранение в базу...';
}
}
}, 300);
// Сохраняем интервал для очистки при завершении страницы
window.importProgressInterval = progressInterval;
// Включаем защиту от закрытия страницы
window.importInProgress = true;
}
// Предупреждение при закрытии страницы во время импорта
window.addEventListener('beforeunload', function(e) {
if (window.importInProgress) {
e.preventDefault();
e.returnValue = 'Импорт ещё не завершён. Вы уверены, что хотите покинуть страницу?';
return e.returnValue;
}
});
});
</script>
<style>
#dropZone {
transition: all 0.3s ease;
}
#dropZone:hover {
border-color: var(--bs-primary) !important;
background-color: rgba(var(--bs-primary-rgb), 0.05);
}
#dropZone.border-primary {
background-color: rgba(var(--bs-primary-rgb), 0.1);
}
</style>
{% endblock %}