Files
portal/app/resources/js/views/ImportView.vue
T
Дмитрий 73d4c8c14f fix/ui: убрать жаргон в клиентском UI — без конкурент/синхронизация/crm.bp-gr.ru/Pay-per-lead
U1 остаток: пояснения источника проекта без слова конкурент.
U5: баннер списка проектов про результат а не синхронизацию + статус Собирает заявки.
Деалы/импорт/логин: убрано имя поставщика и англ Pay-per-lead.
Активность сделки: технический supplier_webhook заменён понятной фразой.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:02:52 +03:00

243 lines
8.3 KiB
Vue
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.
<script setup lang="ts">
/**
* Импорт данных — загрузка CSV исторических лидов из crm.bp-gr.ru (ТЗ §6).
*
* Flow: выбрать файл → загрузить → polling прогресса → таблица результата.
* Неизвестные статусы маппятся через UnknownStatusesDialog.
*/
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
getImport,
getUnknownStatuses,
listImports,
uploadImport,
type ImportLogResource,
type UnknownStatus,
} from '../api/imports';
import UnknownStatusesDialog from '../components/import/UnknownStatusesDialog.vue';
const file = ref<File | null>(null);
const dryRun = ref(false);
const uploading = ref(false);
const errorMessage = ref<string | null>(null);
const history = ref<ImportLogResource[]>([]);
const activeImport = ref<ImportLogResource | null>(null);
const unknownStatuses = ref<UnknownStatus[]>([]);
const wizardOpen = ref(false);
/** Интервал опроса прогресса активного импорта, мс. */
const POLL_INTERVAL_MS = 2000;
let pollTimer: ReturnType<typeof setInterval> | null = null;
const canUpload = computed(() => file.value !== null && !uploading.value);
// Русские подписи статусов импорта (не показываем юзеру сырой enum).
const IMPORT_STATUS_RU: Record<string, string> = {
pending: 'В очереди',
processing: 'Обрабатывается',
done: 'Готово',
completed: 'Готово',
failed: 'Ошибка',
cancelled: 'Отменён',
};
function importStatusLabel(s: string): string {
return IMPORT_STATUS_RU[s] ?? s;
}
const isProcessing = computed(
() => activeImport.value?.status === 'pending' || activeImport.value?.status === 'processing',
);
async function refreshHistory(): Promise<void> {
try {
history.value = await listImports();
} catch {
// история — не критично, тихо игнорируем
}
}
async function refreshUnknown(): Promise<void> {
try {
unknownStatuses.value = await getUnknownStatuses();
} catch {
unknownStatuses.value = [];
}
}
function stopPolling(): void {
if (pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function pollOnce(id: number): Promise<void> {
try {
activeImport.value = await getImport(id);
if (!isProcessing.value) {
stopPolling();
await refreshHistory();
await refreshUnknown();
}
} catch {
stopPolling();
}
}
function startPolling(id: number): void {
stopPolling();
pollTimer = setInterval(() => {
void pollOnce(id);
}, POLL_INTERVAL_MS);
}
async function submit(): Promise<void> {
if (file.value === null) {
return;
}
uploading.value = true;
errorMessage.value = null;
try {
activeImport.value = await uploadImport(file.value, dryRun.value);
startPolling(activeImport.value.id);
file.value = null;
} catch {
errorMessage.value = 'Не удалось загрузить файл. Проверьте формат (CSV, до 10 МБ).';
} finally {
uploading.value = false;
}
}
async function onWizardResolved(): Promise<void> {
wizardOpen.value = false;
await refreshUnknown();
}
onMounted(async () => {
await refreshHistory();
await refreshUnknown();
});
onUnmounted(stopPolling);
</script>
<template>
<v-container fluid class="import-view pa-6">
<header class="page-head mb-4">
<h1 class="text-h4 mb-2">Импорт данных</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Перенос ваших исторических заявок. Формат CSV-выгрузка (UTF-8).
</p>
</header>
<v-alert
v-if="unknownStatuses.length > 0"
data-test="unknown-banner"
type="warning"
variant="tonal"
class="mb-4"
>
Найдено {{ unknownStatuses.length }} неизвестных статусов воронки замапьте вручную.
<template #append>
<v-btn size="small" variant="flat" @click="wizardOpen = true">Замапить</v-btn>
</template>
</v-alert>
<v-card variant="outlined" class="pa-6 mb-6">
<v-file-input
v-model="file"
label="CSV-файл выгрузки лидов"
accept=".csv,text/csv"
prepend-icon="mdi-database-import-outline"
variant="outlined"
density="comfortable"
:disabled="uploading"
/>
<v-checkbox
v-model="dryRun"
label="Пробный прогон (проверить файл без записи сделок)"
density="compact"
hide-details
/>
<v-alert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</v-alert>
<div class="mt-4">
<v-btn
data-test="upload-btn"
color="primary"
:loading="uploading"
:disabled="!canUpload"
@click="submit"
>
Загрузить
</v-btn>
</div>
</v-card>
<v-card v-if="activeImport" variant="outlined" class="pa-6 mb-6">
<h2 class="text-h6 mb-3">Текущий импорт {{ activeImport.filename }}</h2>
<v-progress-linear v-if="isProcessing" indeterminate color="primary" class="mb-3" />
<div data-test="active-status" class="text-body-2">
Статус: <strong>{{ importStatusLabel(activeImport.status) }}</strong>
</div>
<v-table v-if="!isProcessing" density="compact" class="mt-3">
<tbody>
<tr>
<td>Добавлено</td>
<td>{{ activeImport.rows_added }}</td>
</tr>
<tr>
<td>Обновлено</td>
<td>{{ activeImport.rows_updated }}</td>
</tr>
<tr>
<td>Пропущено</td>
<td>{{ activeImport.rows_skipped }}</td>
</tr>
<tr>
<td>Неизвестных статусов</td>
<td>{{ activeImport.unknown_statuses_count }}</td>
</tr>
</tbody>
</v-table>
<v-alert v-if="activeImport.status === 'failed'" type="error" variant="tonal" class="mt-3">
{{ activeImport.error_message }}
</v-alert>
</v-card>
<v-card variant="outlined" class="pa-6">
<h2 class="text-h6 mb-3">История импортов</h2>
<v-table v-if="history.length > 0" density="compact">
<thead>
<tr>
<th>Файл</th>
<th>Статус</th>
<th>Добавлено</th>
<th>Обновлено</th>
<th>Пропущено</th>
</tr>
</thead>
<tbody>
<tr v-for="row in history" :key="row.id">
<td>{{ row.filename }}</td>
<td>{{ importStatusLabel(row.status) }}</td>
<td>{{ row.rows_added }}</td>
<td>{{ row.rows_updated }}</td>
<td>{{ row.rows_skipped }}</td>
</tr>
</tbody>
</v-table>
<p v-else class="text-body-2 text-medium-emphasis ma-0">Импортов пока нет.</p>
</v-card>
<UnknownStatusesDialog v-model="wizardOpen" :statuses="unknownStatuses" @resolved="onWizardResolved" />
</v-container>
</template>
<style scoped>
.import-view {
max-width: 1100px;
}
</style>