diff --git a/app/resources/js/api/sales.ts b/app/resources/js/api/sales.ts index 2695b872..253d1803 100644 --- a/app/resources/js/api/sales.ts +++ b/app/resources/js/api/sales.ts @@ -998,6 +998,9 @@ export async function updateSalesAdAudience( * state/stop_reason — что сейчас с рекламой этой фирмы (см. AdAudienceScheduler); * assigned=true → менеджер уже назначен, кнопка «Назначить менеджера» скрыта. */ +/** Площадка прогрева: где крутим рекламу на эту фирму. */ +export type SalesAdAudienceChannels = 'yandex' | 'vk' | 'both'; + export interface SalesAdAudienceFirmRow { id: number; firm_name: string; @@ -1010,6 +1013,7 @@ export interface SalesAdAudienceFirmRow { prospect_id: number | null; stage: string | null; assigned: boolean; + channels: SalesAdAudienceChannels; } /** GET /api/sales/ad-audience/firms (Bearer, начальник) → { firms: SalesAdAudienceFirmRow[] } */ @@ -1047,6 +1051,28 @@ export async function assignSalesAdAudienceFirm( return data; } +/** + * POST /api/sales/ad-audience/channels (Bearer, начальник) → { updated: number } + * Начальник меняет площадку прогрева сразу у нескольких отмеченных галочкой фирм. + */ +export async function setSalesAdAudienceChannels( + firmIds: number[], + channels: SalesAdAudienceChannels, +): Promise<{ updated: number }> { + const { data } = await axios.post<{ updated: number }>( + '/api/sales/ad-audience/channels', + { firm_ids: firmIds, channels }, + { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + ...authHeaders(), + }, + }, + ); + return data; +} + // ─── Потенциальные клиенты (воронка-канбан) ─────────────────────────────────── /** diff --git a/app/resources/js/views/sales/SalesAdAudienceView.vue b/app/resources/js/views/sales/SalesAdAudienceView.vue index 4c5aaf21..ad5f6ac8 100644 --- a/app/resources/js/views/sales/SalesAdAudienceView.vue +++ b/app/resources/js/views/sales/SalesAdAudienceView.vue @@ -24,10 +24,12 @@ import { listSalesAdAudienceFirms, assignSalesAdAudienceFirm, listSalesManagers, + setSalesAdAudienceChannels, extractSalesErrorMessage, type SalesAdAudienceState, type SalesAdAudienceDurations, type SalesAdAudienceFirmRow, + type SalesAdAudienceChannels, type SalesManagerRow, } from '../../api/sales'; @@ -247,6 +249,40 @@ async function loadFirms() { } } +// ─── где греем (площадка прогрева) ─────────────────────────────────────────── + +/** Человеческое название площадки — начальник не должен читать англицизмы. */ +const CHANNELS_LABELS: Record = { + yandex: 'Яндекс', + vk: 'ВК', + both: 'оба', +}; + +function channelsLabel(channels: SalesAdAudienceChannels): string { + return CHANNELS_LABELS[channels]; +} + +/** Строки, отмеченные галочкой — им применится следующая нажатая кнопка. */ +const selectedFirmIds = ref([]); +const applyingChannels = ref(false); + +/** Начальник жмёт «В Яндекс» / «В ВК» / «В оба» — площадка меняется у всех отмеченных сразу. */ +async function applyChannels(channels: SalesAdAudienceChannels): Promise { + if (selectedFirmIds.value.length === 0) return; + + applyingChannels.value = true; + try { + await setSalesAdAudienceChannels(selectedFirmIds.value, channels); + notify('Площадка изменена: ' + channelsLabel(channels)); + selectedFirmIds.value = []; + await loadFirms(); + } catch (e) { + notify(extractSalesErrorMessage(e), 'error'); + } finally { + applyingChannels.value = false; + } +} + // ─── назначение менеджера ──────────────────────────────────────────────────────── const managers = ref([]); @@ -311,6 +347,8 @@ defineExpose({ firms, firmsLoading, firmsError, + selectedFirmIds, + applyingChannels, managers, assignOpen, assignFirm, @@ -325,6 +363,8 @@ defineExpose({ saveDuration, openAssign, doAssign, + channelsLabel, + applyChannels, }); @@ -461,11 +501,46 @@ defineExpose({ Не удалось загрузить список фирм. Попробуйте обновить страницу. +
+ + В Яндекс + + + В ВК + + + В оба + +
+ + Фирма Город + Где греем Номеров Состояние рекламы Дней в рекламе @@ -474,12 +549,25 @@ defineExpose({ + + + + + {{ row.firm_name }} {{ row.city ?? '—' }} + + {{ channelsLabel(row.channels) }} + {{ row.phones_count }} @@ -520,7 +608,7 @@ defineExpose({ - + Фирм на прогреве пока нет — отправьте их из Поиска клиентов diff --git a/app/tests/Frontend/SalesAdAudienceView.spec.ts b/app/tests/Frontend/SalesAdAudienceView.spec.ts index 1c824922..55f17a2e 100644 --- a/app/tests/Frontend/SalesAdAudienceView.spec.ts +++ b/app/tests/Frontend/SalesAdAudienceView.spec.ts @@ -8,6 +8,19 @@ import type { SalesManagerRow, } from '../../resources/js/api/sales'; +/** + * Task 6 плана «Выбор площадки прогрева» (2026-07-19): колонка «Где греем», + * галочки у строк и три кнопки массовой смены площадки. + * + * ⚠️ Отступление от буквы плана: план предлагал отдельный файл + * resources/js/views/sales/__tests__/SalesAdAudienceView.spec.ts с хелпером + * mountView(). Реальный тест на этот экран уже жил здесь + * (tests/Frontend/SalesAdAudienceView.spec.ts, см. шапку файла выше — та же + * причина: vitest.config.ts подключает только tests/Frontend/**\/*.spec.ts) и + * мокает api-модуль через vi.mock + фабрику factory(), без mountView. Кейсы + * этой задачи дописаны в тот же файл тем же способом. + */ + /** * Task 8 плана v2 (реклама на кандидатов): экран начальника — список фирм на * прогреве + назначение менеджера + 11 человеческих сроков планировщика. @@ -31,6 +44,7 @@ vi.mock('../../resources/js/api/sales', async (importOriginal) => { listSalesAdAudienceFirms: vi.fn(), assignSalesAdAudienceFirm: vi.fn(), listSalesManagers: vi.fn(), + setSalesAdAudienceChannels: vi.fn(), }; }); @@ -77,6 +91,7 @@ const FIRM_ACTIVE: SalesAdAudienceFirmRow = { prospect_id: null, stage: null, assigned: false, + channels: 'yandex', }; const FIRM_READY: SalesAdAudienceFirmRow = { @@ -91,6 +106,7 @@ const FIRM_READY: SalesAdAudienceFirmRow = { prospect_id: null, stage: null, assigned: false, + channels: 'vk', }; const MANAGER: SalesManagerRow = { @@ -218,4 +234,21 @@ describe('Реклама на кандидатов — экран начальн expect(salesApi.assignSalesAdAudienceFirm).not.toHaveBeenCalled(); expect(document.body.textContent ?? '').toContain('Выберите менеджера'); }); + + it('показывает, где греется фирма, и меняет площадку у отмеченных галочкой', async () => { + vi.mocked(salesApi.setSalesAdAudienceChannels).mockResolvedValue({ updated: 1 }); + + const wrapper = factory(); + await flushPromises(); + + const channelsCells = wrapper.findAll('[data-testid="firm-channels"]'); + expect(channelsCells[0].text()).toBe('Яндекс'); + 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 flushPromises(); + + expect(salesApi.setSalesAdAudienceChannels).toHaveBeenCalledWith([1], 'both'); + }); }); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 8b93dada..69ccf79d 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-19T16:11:21.169Z +Last updated: 2026-07-19T16:43:24.343Z | Контролёр | Состояние | Детали | |---|---|---| @@ -108,7 +108,13 @@ Episodes since last run: 542 / threshold: 10 ## System Health -Долго работающих процессов нет (порог CPU > 1ч). +Топ-3 процессов с CPU > 1ч: + +| PID | Имя | CPU-время | Возраст | +|---|---|---|---| +| 3576 | MsMpEng | 1.11ч | 0.0ч | + +⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. ## Очередь обучения роутера