From 9de0c41a0aa2bf1056f2766fdecc4e18fdb2a550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 20 Jul 2026 08:23:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(sales):=20=D1=82=D1=80=D0=B8=20=D0=B3?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BF=D0=BB=D0=BE=D1=89?= =?UTF-8?q?=D0=B0=D0=B4=D0=BE=D0=BA=20=D0=B8=20=D0=BA=D0=BD=D0=BE=D0=BF?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=9C=D0=A2=D0=A1=20=D0=BD=D0=B0=20=D1=8D=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/resources/js/api/sales.ts | 16 ++-- .../js/views/sales/SalesAdAudienceView.vue | 85 ++++++++++++------- .../Frontend/SalesAdAudienceView.spec.ts | 53 ++++++++++-- 3 files changed, 111 insertions(+), 43 deletions(-) diff --git a/app/resources/js/api/sales.ts b/app/resources/js/api/sales.ts index fabd9a3f..81085585 100644 --- a/app/resources/js/api/sales.ts +++ b/app/resources/js/api/sales.ts @@ -1002,8 +1002,12 @@ export async function updateSalesAdAudience( * state/stop_reason — что сейчас с рекламой этой фирмы (см. AdAudienceScheduler); * assigned=true → менеджер уже назначен, кнопка «Назначить менеджера» скрыта. */ -/** Площадка прогрева: где крутим рекламу на эту фирму. */ -export type SalesAdAudienceChannels = 'yandex' | 'vk' | 'both'; +/** Площадки прогрева: три независимые галочки — Яндекс, ВК, МТС. */ +export interface SalesAdAudienceChannels { + ch_yandex: boolean; + ch_vk: boolean; + ch_mts: boolean; +} export interface SalesAdAudienceFirmRow { id: number; @@ -1017,7 +1021,9 @@ export interface SalesAdAudienceFirmRow { prospect_id: number | null; stage: string | null; assigned: boolean; - channels: SalesAdAudienceChannels; + ch_yandex: boolean; + ch_vk: boolean; + ch_mts: boolean; } /** GET /api/sales/ad-audience/firms (Bearer, начальник) → { firms: SalesAdAudienceFirmRow[] } */ @@ -1057,7 +1063,7 @@ export async function assignSalesAdAudienceFirm( /** * POST /api/sales/ad-audience/channels (Bearer, начальник) → { updated: number } - * Начальник меняет площадку прогрева сразу у нескольких отмеченных галочкой фирм. + * Начальник проставляет три независимые площадки сразу нескольким отмеченным фирмам. */ export async function setSalesAdAudienceChannels( firmIds: number[], @@ -1065,7 +1071,7 @@ export async function setSalesAdAudienceChannels( ): Promise<{ updated: number }> { const { data } = await axios.post<{ updated: number }>( '/api/sales/ad-audience/channels', - { firm_ids: firmIds, channels }, + { firm_ids: firmIds, ...channels }, { headers: { Accept: 'application/json', diff --git a/app/resources/js/views/sales/SalesAdAudienceView.vue b/app/resources/js/views/sales/SalesAdAudienceView.vue index dad479c0..8e6cf4a7 100644 --- a/app/resources/js/views/sales/SalesAdAudienceView.vue +++ b/app/resources/js/views/sales/SalesAdAudienceView.vue @@ -249,17 +249,20 @@ async function loadFirms() { } } -// ─── где греем (площадка прогрева) ─────────────────────────────────────────── +// ─── где греем (площадки прогрева) ─────────────────────────────────────────── -/** Человеческое название площадки — начальник не должен читать англицизмы. */ -const CHANNELS_LABELS: Record = { - yandex: 'Яндекс', - vk: 'ВК', - both: 'оба', -}; +/** Человеческая подпись из трёх независимых флагов — без единого англицизма. */ +function channelsLabelFromFlags(flags: SalesAdAudienceChannels): string { + const gde = [flags.ch_yandex ? 'Яндекс' : null, flags.ch_vk ? 'ВК' : null, flags.ch_mts ? 'МТС' : null].filter( + Boolean, + ); -function channelsLabel(channels: SalesAdAudienceChannels): string { - return CHANNELS_LABELS[channels]; + return gde.length > 0 ? gde.join(', ') : 'нигде'; +} + +/** Где эта фирма греется прямо сейчас — колонка «Где греем» в таблице. */ +function channelsLabel(row: SalesAdAudienceFirmRow): string { + return channelsLabelFromFlags(row); } /** @@ -274,18 +277,21 @@ const VK_STATUS_LABELS: Record = { const vkStatusLabel = computed(() => VK_STATUS_LABELS[state.value?.vk_status ?? 'no_access']); -/** Строки, отмеченные галочкой — им применится следующая нажатая кнопка. */ +/** Строки, отмеченные галочкой — им применятся площадки ниже. */ const selectedFirmIds = ref([]); const applyingChannels = ref(false); -/** Начальник жмёт «В Яндекс» / «В ВК» / «В оба» — площадка меняется у всех отмеченных сразу. */ -async function applyChannels(channels: SalesAdAudienceChannels): Promise { +/** Галочки площадок, которые начальник собирается проставить отмеченным фирмам. */ +const pick = ref({ ch_yandex: false, ch_vk: false, ch_mts: false }); + +/** Начальник отмечает фирмы галочками и жмёт «Применить к отмеченным». */ +async function applyChannels(): Promise { if (selectedFirmIds.value.length === 0) return; applyingChannels.value = true; try { - await setSalesAdAudienceChannels(selectedFirmIds.value, channels); - notify('Площадка изменена: ' + channelsLabel(channels)); + await setSalesAdAudienceChannels(selectedFirmIds.value, { ...pick.value }); + notify('Площадки изменены: ' + channelsLabelFromFlags(pick.value)); selectedFirmIds.value = []; await loadFirms(); } catch (e) { @@ -361,6 +367,7 @@ defineExpose({ firmsError, selectedFirmIds, applyingChannels, + pick, managers, assignOpen, assignFirm, @@ -514,36 +521,39 @@ defineExpose({ Не удалось загрузить список фирм. Попробуйте обновить страницу. -
+
+ Греть в: + + + - В Яндекс + Применить к отмеченным + - В ВК - - - В оба + Скачать файл для МТС
@@ -579,7 +589,7 @@ defineExpose({ {{ row.city ?? '—' }} - {{ channelsLabel(row.channels) }} + {{ channelsLabel(row) }} {{ row.phones_count }} @@ -814,6 +824,15 @@ defineExpose({ gap: 16px; } +.sm-pick-label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #081319; + cursor: pointer; +} + .sm-name { font-weight: 500; color: #081319; diff --git a/app/tests/Frontend/SalesAdAudienceView.spec.ts b/app/tests/Frontend/SalesAdAudienceView.spec.ts index a842c4f6..cdd01417 100644 --- a/app/tests/Frontend/SalesAdAudienceView.spec.ts +++ b/app/tests/Frontend/SalesAdAudienceView.spec.ts @@ -35,6 +35,20 @@ import type { * файл там не запустился бы. Кладём туда, где реально лежат все Frontend-тесты. */ +/** + * Task 5 плана «Три площадки прогрева и модуль МТС» (2026-07-20): вместо + * одного поля channels ('yandex'|'vk'|'both') — три независимые галочки + * ch_yandex/ch_vk/ch_mts, панель их простановки отмеченным фирмам и кнопка + * «Скачать файл для МТС» (программного доступа к рекламе в Telegram у МТС + * нет — портал отдаёт готовый файл). + * + * ⚠️ Отступление от буквы плана: план предлагал хелпер mountView() и + * data-testid="firm-check-1"/"pick-vk"/"apply-channels" в отдельном файле + * resources/js/views/sales/__tests__/*.spec.ts — по тем же причинам, что и + * выше (vitest.config.ts, отсутствие mountView в проекте), кейсы дописаны + * сюда тем же способом (vi.mock + factory()), но testid из плана сохранены. + */ + vi.mock('../../resources/js/api/sales', async (importOriginal) => { const orig = await importOriginal(); return { @@ -94,7 +108,9 @@ const FIRM_ACTIVE: SalesAdAudienceFirmRow = { prospect_id: null, stage: null, assigned: false, - channels: 'yandex', + ch_yandex: true, + ch_vk: false, + ch_mts: false, }; const FIRM_READY: SalesAdAudienceFirmRow = { @@ -109,7 +125,9 @@ const FIRM_READY: SalesAdAudienceFirmRow = { prospect_id: null, stage: null, assigned: false, - channels: 'vk', + ch_yandex: false, + ch_vk: true, + ch_mts: false, }; const MANAGER: SalesManagerRow = { @@ -238,7 +256,7 @@ describe('Реклама на кандидатов — экран начальн expect(document.body.textContent ?? '').toContain('Выберите менеджера'); }); - it('показывает, где греется фирма, и меняет площадку у отмеченных галочкой', async () => { + it('показывает площадки фирмы и проставляет их отмеченным', async () => { vi.mocked(salesApi.setSalesAdAudienceChannels).mockResolvedValue({ updated: 1 }); const wrapper = factory(); @@ -249,10 +267,35 @@ describe('Реклама на кандидатов — экран начальн expect(channelsCells[1].text()).toBe('ВК'); await wrapper.find('[data-testid="firm-check-1"]').setValue(true); - await wrapper.find('[data-testid="channels-both"]').trigger('click'); + await wrapper.find('[data-testid="pick-vk"]').setValue(true); + await wrapper.find('[data-testid="apply-channels"]').trigger('click'); await flushPromises(); - expect(salesApi.setSalesAdAudienceChannels).toHaveBeenCalledWith([1], 'both'); + expect(salesApi.setSalesAdAudienceChannels).toHaveBeenCalledWith([1], { + ch_yandex: false, + ch_vk: true, + ch_mts: false, + }); + }); + + it('фирма без единой площадки показывает «нигде»', async () => { + vi.mocked(salesApi.listSalesAdAudienceFirms).mockResolvedValue([ + { ...FIRM_ACTIVE, ch_yandex: false, ch_vk: false, ch_mts: false }, + ]); + + const wrapper = factory(); + await flushPromises(); + + expect(wrapper.find('[data-testid="firm-channels"]').text()).toBe('нигде'); + }); + + it('кнопка «Скачать файл для МТС» ведёт на маршрут выгрузки', async () => { + 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'); }); // Task 9 плана «Выбор площадки прогрева» (2026-07-19): состояние канала ВК