Dobavlen funkcional importa i eksporta klientov s validaciey i umnym sliyaniem kontaktov

This commit is contained in:
2026-01-03 13:33:34 +03:00
parent 208c6b55de
commit 3248fadffa
7 changed files with 695 additions and 19 deletions

View File

@@ -30,19 +30,142 @@
</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">
<form method="post" enctype="multipart/form-data" id="importForm">
{% csrf_token %}
<!-- Drag & Drop зона -->
<div class="mb-3">
<label for="file" class="form-label">Выберите файл</label>
<input type="file" class="form-control" id="file" name="file"
accept=".csv,.xlsx,.xls" required>
<small class="form-text text-muted">
Поддерживаемые форматы: CSV, Excel (.xlsx, .xls)
</small>
<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">
@@ -52,12 +175,7 @@
</label>
</div>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
<strong>Внимание!</strong> Функция импорта находится в разработке.
</div>
<button type="submit" class="btn btn-primary" disabled>
<button type="submit" class="btn btn-primary">
<i class="bi bi-upload"></i> Импортировать
</button>
<a href="{% url 'customers:customer-list' %}" class="btn btn-outline-secondary">
@@ -70,4 +188,142 @@
</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;
}
});
});
</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 %}