diff --git a/app/resources/js/api/imports.ts b/app/resources/js/api/imports.ts new file mode 100644 index 00000000..9800514a --- /dev/null +++ b/app/resources/js/api/imports.ts @@ -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 { + 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 { + const { data } = await apiClient.get<{ data: ImportLogResource[] }>('/api/imports'); + return data.data; +} + +/** GET /api/imports/{id} — прогресс одного импорта. */ +export async function getImport(id: number): Promise { + const { data } = await apiClient.get<{ data: ImportLogResource }>(`/api/imports/${id}`); + return data.data; +} + +/** GET /api/imports/unknown-statuses — незамапленные статусы. */ +export async function getUnknownStatuses(): Promise { + 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 { + await apiClient.post('/api/imports/unknown-statuses/resolve', { mappings }); +} diff --git a/app/resources/js/components/import/UnknownStatusesDialog.vue b/app/resources/js/components/import/UnknownStatusesDialog.vue new file mode 100644 index 00000000..2458b54d --- /dev/null +++ b/app/resources/js/components/import/UnknownStatusesDialog.vue @@ -0,0 +1,125 @@ + + + diff --git a/app/tests/Frontend/UnknownStatusesDialog.spec.ts b/app/tests/Frontend/UnknownStatusesDialog.spec.ts new file mode 100644 index 00000000..893ad089 --- /dev/null +++ b/app/tests/Frontend/UnknownStatusesDialog.spec.ts @@ -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(); + 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 — стаб делает доступным +// для 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: '
', + 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(); + }); +});