feat(import): ImportView — экран импорта CSV
TDD: spec (3 tests) first, then component. ImportView.vue: upload form + polling + history table + unknown-statuses banner. Uses api/imports (uploadImport/listImports/getImport/getUnknownStatuses). setInterval callback wrapped in named async fn (pollOnce) — no eslint-disable needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
<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);
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const canUpload = computed(() => file.value !== null && !uploading.value);
|
||||
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);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
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">
|
||||
Перенос исторических лидов из crm.bp-gr.ru. Формат — 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>{{ 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>{{ 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>
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import * as components from 'vuetify/components';
|
||||
import * as directives from 'vuetify/directives';
|
||||
|
||||
vi.mock('../../resources/js/api/imports', async (importOriginal) => {
|
||||
const orig = await importOriginal<typeof import('../../resources/js/api/imports')>();
|
||||
return { ...orig };
|
||||
});
|
||||
|
||||
const importsApi = await import('../../resources/js/api/imports');
|
||||
const ImportView = (await import('../../resources/js/views/ImportView.vue')).default;
|
||||
|
||||
const vuetify = createVuetify({ components, directives });
|
||||
|
||||
function mountView() {
|
||||
return mount(ImportView, {
|
||||
global: {
|
||||
plugins: [vuetify],
|
||||
stubs: { UnknownStatusesDialog: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('ImportView', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(importsApi, 'listImports').mockResolvedValue([]);
|
||||
vi.spyOn(importsApi, 'getUnknownStatuses').mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('грузит историю импортов при монтировании', async () => {
|
||||
const spy = vi.spyOn(importsApi, 'listImports').mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
filename: 'leads.csv',
|
||||
status: 'done',
|
||||
rows_total: 5,
|
||||
rows_added: 5,
|
||||
rows_updated: 0,
|
||||
rows_skipped: 0,
|
||||
unknown_statuses_count: 0,
|
||||
dry_run: false,
|
||||
error_message: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
},
|
||||
]);
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(wrapper.text()).toContain('leads.csv');
|
||||
});
|
||||
|
||||
it('кнопка загрузки заблокирована без выбранного файла', async () => {
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
const uploadBtn = wrapper.find('[data-test="upload-btn"]');
|
||||
expect(uploadBtn.attributes('disabled')).toBeDefined();
|
||||
});
|
||||
|
||||
it('показывает баннер о неизвестных статусах', async () => {
|
||||
vi.spyOn(importsApi, 'getUnknownStatuses').mockResolvedValue([
|
||||
{ id: 1, status_ru: 'Архив', occurrences: 3 },
|
||||
]);
|
||||
const wrapper = mountView();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-test="unknown-banner"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user