diff --git a/app/resources/js/api/sales.ts b/app/resources/js/api/sales.ts index 81085585..e23b3b48 100644 --- a/app/resources/js/api/sales.ts +++ b/app/resources/js/api/sales.ts @@ -1083,6 +1083,38 @@ export async function setSalesAdAudienceChannels( return data; } +/** + * GET /api/sales/ad-audience/mts-file (Bearer, начальник) — файл со списком + * номеров для МТС. + * + * Маршрут за auth:sales (Bearer-токен, который SPA шлёт через axios) — обычная + * его не пошлёт и получит 401. Качаем через тот же axios-слой, что + * и остальные запросы, и отдаём файл пользователю через Blob + временную ссылку. + */ +export async function downloadSalesAdAudienceMtsFile(): Promise { + const response = await axios.get('/api/sales/ad-audience/mts-file', { + responseType: 'blob', + headers: { + Accept: 'text/plain', + 'X-Requested-With': 'XMLHttpRequest', + ...authHeaders(), + }, + }); + + const disposition = response.headers['content-disposition'] as string | undefined; + const match = disposition?.match(/filename="?([^"]+)"?/); + const filename = match?.[1] ?? `liderra-mts-${new Date().toISOString().slice(0, 10)}.txt`; + + const blobUrl = URL.createObjectURL(response.data as Blob); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(blobUrl); +} + // ─── Потенциальные клиенты (воронка-канбан) ─────────────────────────────────── /** diff --git a/app/resources/js/views/sales/SalesAdAudienceView.vue b/app/resources/js/views/sales/SalesAdAudienceView.vue index 8e6cf4a7..bb176005 100644 --- a/app/resources/js/views/sales/SalesAdAudienceView.vue +++ b/app/resources/js/views/sales/SalesAdAudienceView.vue @@ -25,6 +25,7 @@ import { assignSalesAdAudienceFirm, listSalesManagers, setSalesAdAudienceChannels, + downloadSalesAdAudienceMtsFile, extractSalesErrorMessage, type SalesAdAudienceState, type SalesAdAudienceDurations, @@ -301,6 +302,20 @@ async function applyChannels(): Promise { } } +/** Начальник жмёт «Скачать файл для МТС» — файл качается через axios (Bearer-токен). */ +const downloadingMtsFile = ref(false); + +async function downloadMtsFile(): Promise { + downloadingMtsFile.value = true; + try { + await downloadSalesAdAudienceMtsFile(); + } catch (e) { + notify(extractSalesErrorMessage(e, 'Не удалось скачать файл для МТС.'), 'error'); + } finally { + downloadingMtsFile.value = false; + } +} + // ─── назначение менеджера ──────────────────────────────────────────────────────── const managers = ref([]); @@ -368,6 +383,7 @@ defineExpose({ selectedFirmIds, applyingChannels, pick, + downloadingMtsFile, managers, assignOpen, assignFirm, @@ -384,6 +400,7 @@ defineExpose({ doAssign, channelsLabel, applyChannels, + downloadMtsFile, }); @@ -550,8 +567,9 @@ defineExpose({ size="small" variant="tonal" class="text-none" - href="/api/sales/ad-audience/mts-file" + :loading="downloadingMtsFile" data-testid="mts-file" + @click="downloadMtsFile" > Скачать файл для МТС diff --git a/app/tests/Frontend/SalesAdAudienceView.spec.ts b/app/tests/Frontend/SalesAdAudienceView.spec.ts index cdd01417..84beff0e 100644 --- a/app/tests/Frontend/SalesAdAudienceView.spec.ts +++ b/app/tests/Frontend/SalesAdAudienceView.spec.ts @@ -59,6 +59,7 @@ vi.mock('../../resources/js/api/sales', async (importOriginal) => { assignSalesAdAudienceFirm: vi.fn(), listSalesManagers: vi.fn(), setSalesAdAudienceChannels: vi.fn(), + downloadSalesAdAudienceMtsFile: vi.fn(), }; }); @@ -289,13 +290,21 @@ describe('Реклама на кандидатов — экран начальн expect(wrapper.find('[data-testid="firm-channels"]').text()).toBe('нигде'); }); - it('кнопка «Скачать файл для МТС» ведёт на маршрут выгрузки', async () => { + it('кнопка «Скачать файл для МТС» качает файл через axios-слой с токеном, не через href', async () => { + vi.mocked(salesApi.downloadSalesAdAudienceMtsFile).mockResolvedValue(undefined); + const wrapper = factory(); await flushPromises(); - const link = wrapper.find('[data-testid="mts-file"]'); - expect(link.exists()).toBe(true); - expect(link.attributes('href')).toBe('/api/sales/ad-audience/mts-file'); + const btn = wrapper.find('[data-testid="mts-file"]'); + expect(btn.exists()).toBe(true); + // Маршрут за auth:sales (Bearer-токен) — обычный его не пошлёт и получит 401. + expect(btn.attributes('href')).toBeUndefined(); + + await btn.trigger('click'); + await flushPromises(); + + expect(salesApi.downloadSalesAdAudienceMtsFile).toHaveBeenCalledTimes(1); }); // Task 9 плана «Выбор площадки прогрева» (2026-07-19): состояние канала ВК