feat(import): api/imports.ts + UnknownStatusesDialog (wizard маппинга)

- api/imports.ts: типы ImportLogResource/UnknownStatus/StatusMapping,
  функции uploadImport/listImports/getImport/getUnknownStatuses/resolveUnknownStatuses
  (apiClient из ./client, стиль api/dashboard.ts)
- UnknownStatusesDialog.vue: wizard маппинга незамапленных статусов воронки
  (ТЗ §6.4/§6.6), 14 канонических slug, defineExpose(selection, save)
- Vitest 3/3 (tests/Frontend/UnknownStatusesDialog.spec.ts)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-16 19:58:33 +03:00
parent 5d64ca552e
commit 7f05c4ab16
3 changed files with 273 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
import { apiClient } from './client';
/**
* API-клиент исторической миграции лидов (ТЗ §6).
* Эндпоинты: POST/GET /api/imports, /api/imports/unknown-statuses, /api/imports/unknown-statuses/resolve.
*/
export interface ImportLogResource {
id: number;
filename: string;
status: 'pending' | 'processing' | 'done' | 'failed';
rows_total: number;
rows_added: number;
rows_updated: number;
rows_skipped: number;
unknown_statuses_count: number;
dry_run: boolean;
error_message: string | null;
started_at: string | null;
finished_at: string | null;
}
export interface UnknownStatus {
id: number;
status_ru: string;
occurrences: number;
}
export interface StatusMapping {
status_ru: string;
slug: string;
}
/** POST /api/imports — загрузить CSV. */
export async function uploadImport(file: File, dryRun = false): Promise<ImportLogResource> {
const form = new FormData();
form.append('file', file);
if (dryRun) {
form.append('dry_run', '1');
}
const { data } = await apiClient.post<{ data: ImportLogResource }>('/api/imports', form);
return data.data;
}
/** GET /api/imports — история импортов. */
export async function listImports(): Promise<ImportLogResource[]> {
const { data } = await apiClient.get<{ data: ImportLogResource[] }>('/api/imports');
return data.data;
}
/** GET /api/imports/{id} — прогресс одного импорта. */
export async function getImport(id: number): Promise<ImportLogResource> {
const { data } = await apiClient.get<{ data: ImportLogResource }>(`/api/imports/${id}`);
return data.data;
}
/** GET /api/imports/unknown-statuses — незамапленные статусы. */
export async function getUnknownStatuses(): Promise<UnknownStatus[]> {
const { data } = await apiClient.get<{ data: UnknownStatus[] }>('/api/imports/unknown-statuses');
return data.data;
}
/** POST /api/imports/unknown-statuses/resolve — сохранить маппинг. */
export async function resolveUnknownStatuses(mappings: StatusMapping[]): Promise<void> {
await apiClient.post('/api/imports/unknown-statuses/resolve', { mappings });
}
@@ -0,0 +1,125 @@
<script setup lang="ts">
/**
* Wizard маппинга неизвестных статусов воронки из CSV-импорта (ТЗ §6.4/§6.6).
*
* Для каждого незамапленного русского статуса пользователь выбирает один из
* 14 канонических slug'ов. Сохранение → POST /api/imports/unknown-statuses/resolve.
*/
import { computed, reactive, ref } from 'vue';
import { resolveUnknownStatuses, type StatusMapping, type UnknownStatus } from '../../api/imports';
const props = defineProps<{
modelValue: boolean;
statuses: UnknownStatus[];
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
resolved: [];
}>();
/** 14 канонических статусов воронки (ТЗ §6.4). */
const STATUS_OPTIONS: { value: string; title: string }[] = [
{ value: 'new', title: 'Новые' },
{ value: 'viewed', title: 'Просмотрено' },
{ value: 'worked', title: 'Проработан' },
{ value: 'base', title: 'База' },
{ value: 'missed', title: 'Недозвон' },
{ value: 'negotiations', title: 'Переговоры' },
{ value: 'waiting_payment', title: 'Ожидаем оплаты' },
{ value: 'partnership', title: 'Партнерка' },
{ value: 'paid', title: 'Оплачено' },
{ value: 'closed', title: 'Закрыто и не реализовано' },
{ value: 'test_drive', title: 'Тест драйв' },
{ value: 'hot', title: 'Горячий' },
{ value: 'replacement', title: 'На замену' },
{ value: 'final_missed', title: 'Конечный недозвон' },
];
const selection = reactive<Record<string, string | null>>({});
const saving = ref(false);
const error = ref<string | null>(null);
const dialogOpen = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
});
const allMapped = computed(
() => props.statuses.length > 0 && props.statuses.every((s) => !!selection[s.status_ru]),
);
async function save(): Promise<void> {
if (!allMapped.value) {
return;
}
saving.value = true;
error.value = null;
try {
const mappings: StatusMapping[] = props.statuses.map((s) => ({
status_ru: s.status_ru,
slug: selection[s.status_ru] as string,
}));
await resolveUnknownStatuses(mappings);
emit('resolved');
} catch {
error.value = 'Не удалось сохранить маппинг. Повторите попытку.';
} finally {
saving.value = false;
}
}
defineExpose({ selection, save });
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="640">
<v-card>
<v-card-title class="text-h6">Маппинг неизвестных статусов</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-4">
Эти статусы из CSV не входят в стандартную воронку. Выберите
соответствие повторный импорт применит маппинг автоматически.
</p>
<div
v-for="status in statuses"
:key="status.id"
class="d-flex align-center ga-3 mb-3"
>
<div class="flex-grow-1">
<strong>{{ status.status_ru }}</strong>
<span class="text-caption text-medium-emphasis ml-2">
({{ status.occurrences }} шт.)
</span>
</div>
<v-select
v-model="selection[status.status_ru]"
:items="STATUS_OPTIONS"
label="Статус воронки"
density="compact"
variant="outlined"
hide-details
style="max-width: 280px"
/>
</div>
<v-alert v-if="error" type="error" variant="tonal" class="mt-2">
{{ error }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialogOpen = false">Отмена</v-btn>
<v-btn
data-test="save-mappings"
color="primary"
variant="flat"
:loading="saving"
:disabled="!allMapped"
@click="save"
>
Сохранить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
vi.mock('../../resources/js/api/imports', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/imports')>();
return {
...orig,
resolveUnknownStatuses: vi.fn(),
};
});
const importsApi = await import('../../resources/js/api/imports');
const UnknownStatusesDialog = (await import('../../resources/js/components/import/UnknownStatusesDialog.vue')).default;
// VDialog в JSDOM не рендерит через teleport — стаб делает <slot/> доступным
// для wrapper.text() / find(). Паттерн из EditProjectDialog.spec.ts.
function mountDialog() {
return mount(UnknownStatusesDialog, {
props: {
modelValue: true,
statuses: [
{ id: 1, status_ru: 'Архив', occurrences: 3 },
{ id: 2, status_ru: 'Спам', occurrences: 1 },
],
},
global: {
plugins: [createVuetify()],
stubs: {
VDialog: {
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
},
});
}
describe('UnknownStatusesDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(importsApi.resolveUnknownStatuses).mockResolvedValue(undefined);
});
it('рендерит строку на каждый неизвестный статус', async () => {
const wrapper = mountDialog();
await flushPromises();
expect(wrapper.text()).toContain('Архив');
expect(wrapper.text()).toContain('Спам');
wrapper.unmount();
});
it('кнопка сохранения заблокирована пока не выбраны все маппинги', async () => {
const wrapper = mountDialog();
await flushPromises();
const saveBtn = wrapper.find('[data-test="save-mappings"]');
expect(saveBtn.exists()).toBe(true);
expect(saveBtn.attributes('disabled')).toBeDefined();
wrapper.unmount();
});
it('сохраняет маппинги и эмитит resolved', async () => {
const spy = vi.mocked(importsApi.resolveUnknownStatuses);
const wrapper = mountDialog();
await flushPromises();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.selection['Архив'] = 'closed';
vm.selection['Спам'] = 'closed';
await flushPromises();
await vm.save();
await flushPromises();
expect(spy).toHaveBeenCalledWith([
{ status_ru: 'Архив', slug: 'closed' },
{ status_ru: 'Спам', slug: 'closed' },
]);
expect(wrapper.emitted('resolved')).toBeTruthy();
wrapper.unmount();
});
});