feat(sales): три галочки площадок и кнопка файла для МТС на экране

This commit is contained in:
Дмитрий
2026-07-20 08:23:04 +03:00
parent 27d0b70649
commit 9de0c41a0a
3 changed files with 111 additions and 43 deletions
+11 -5
View File
@@ -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',
@@ -249,17 +249,20 @@ async function loadFirms() {
}
}
// ─── где греем (площадка прогрева) ───────────────────────────────────────────
// ─── где греем (площадки прогрева) ───────────────────────────────────────────
/** Человеческое название площадки — начальник не должен читать англицизмы. */
const CHANNELS_LABELS: Record<SalesAdAudienceChannels, string> = {
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<SalesAdAudienceState['vk_status'], string> = {
const vkStatusLabel = computed(() => VK_STATUS_LABELS[state.value?.vk_status ?? 'no_access']);
/** Строки, отмеченные галочкой — им применится следующая нажатая кнопка. */
/** Строки, отмеченные галочкой — им применятся площадки ниже. */
const selectedFirmIds = ref<number[]>([]);
const applyingChannels = ref(false);
/** Начальник жмёт «В Яндекс» / «В ВК» / «В оба» — площадка меняется у всех отмеченных сразу. */
async function applyChannels(channels: SalesAdAudienceChannels): Promise<void> {
/** Галочки площадок, которые начальник собирается проставить отмеченным фирмам. */
const pick = ref<SalesAdAudienceChannels>({ ch_yandex: false, ch_vk: false, ch_mts: false });
/** Начальник отмечает фирмы галочками и жмёт «Применить к отмеченным». */
async function applyChannels(): Promise<void> {
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({
Не удалось загрузить список фирм. Попробуйте обновить страницу.
</v-alert>
<div class="d-flex ga-2 pa-4 pb-0">
<div class="d-flex align-center ga-4 pa-4 pb-0 flex-wrap">
<span class="text-medium-emphasis sm-hint">Греть в:</span>
<label class="sm-pick-label">
<input type="checkbox" v-model="pick.ch_yandex" data-testid="pick-yandex" />
Яндекс
</label>
<label class="sm-pick-label">
<input type="checkbox" v-model="pick.ch_vk" data-testid="pick-vk" />
ВК
</label>
<label class="sm-pick-label">
<input type="checkbox" v-model="pick.ch_mts" data-testid="pick-mts" />
МТС
</label>
<v-btn
size="small"
variant="tonal"
class="text-none"
:disabled="selectedFirmIds.length === 0 || applyingChannels"
data-testid="channels-yandex"
@click="applyChannels('yandex')"
data-testid="apply-channels"
@click="applyChannels"
>
В Яндекс
Применить к отмеченным
</v-btn>
<v-spacer />
<v-btn
size="small"
variant="tonal"
class="text-none"
:disabled="selectedFirmIds.length === 0 || applyingChannels"
data-testid="channels-vk"
@click="applyChannels('vk')"
href="/api/sales/ad-audience/mts-file"
data-testid="mts-file"
>
В ВК
</v-btn>
<v-btn
size="small"
variant="tonal"
class="text-none"
:disabled="selectedFirmIds.length === 0 || applyingChannels"
data-testid="channels-both"
@click="applyChannels('both')"
>
В оба
Скачать файл для МТС
</v-btn>
</div>
@@ -579,7 +589,7 @@ defineExpose({
<td>{{ row.city ?? '—' }}</td>
<!-- Где греем -->
<td data-testid="firm-channels">{{ channelsLabel(row.channels) }}</td>
<td data-testid="firm-channels">{{ channelsLabel(row) }}</td>
<!-- Номеров -->
<td class="text-right sm-num sm-mono">{{ row.phones_count }}</td>
@@ -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;
+48 -5
View File
@@ -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<typeof import('../../resources/js/api/sales')>();
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): состояние канала ВК