- Форма начинает отправку сразу при submit - Прогресс-бар и защита включаются через 10ms (после начала отправки) - Предупреждение появляется только при попытке закрыть страницу во время импорта - Импорт корректно выполняется на сервере
408 lines
19 KiB
HTML
408 lines
19 KiB
HTML
{% 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 %}
|