feat(sales): три галочки площадок и кнопка файла для МТС на экране
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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): состояние канала ВК
|
||||
|
||||
Reference in New Issue
Block a user