96cb64f33a
Code-review Task 10 (🟡): магическое число 2000 (интервал polling'а) вынесено в именованную константу POLL_INTERVAL_MS — паттерн файла (как в DashboardView). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
241 lines
7.9 KiB
Vue
241 lines
7.9 KiB
Vue
<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);
|
|
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">
|
|
Перенос исторических лидов из 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>
|