feat(sales): колонка «Где греем» и кнопки смены площадки

Task 6 плана 2026-07-19-vybor-ploshadki-progreva.md — начальник видит
площадку прогрева каждой фирмы (Яндекс/ВК/оба), отмечает строки галочкой
и жмёт одну из трёх кнопок массовой смены; api-слой зовёт уже готовый
POST /api/sales/ad-audience/channels.
This commit is contained in:
Дмитрий
2026-07-19 19:51:52 +03:00
parent aaeab9c62b
commit 7e7c5b6f29
4 changed files with 156 additions and 3 deletions
+26
View File
@@ -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;
}
// ─── Потенциальные клиенты (воронка-канбан) ───────────────────────────────────
/**
@@ -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<SalesAdAudienceChannels, string> = {
yandex: 'Яндекс',
vk: 'ВК',
both: 'оба',
};
function channelsLabel(channels: SalesAdAudienceChannels): string {
return CHANNELS_LABELS[channels];
}
/** Строки, отмеченные галочкой — им применится следующая нажатая кнопка. */
const selectedFirmIds = ref<number[]>([]);
const applyingChannels = ref(false);
/** Начальник жмёт «В Яндекс» / «В ВК» / «В оба» — площадка меняется у всех отмеченных сразу. */
async function applyChannels(channels: SalesAdAudienceChannels): Promise<void> {
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<SalesManagerRow[]>([]);
@@ -311,6 +347,8 @@ defineExpose({
firms,
firmsLoading,
firmsError,
selectedFirmIds,
applyingChannels,
managers,
assignOpen,
assignFirm,
@@ -325,6 +363,8 @@ defineExpose({
saveDuration,
openAssign,
doAssign,
channelsLabel,
applyChannels,
});
</script>
@@ -461,11 +501,46 @@ defineExpose({
Не удалось загрузить список фирм. Попробуйте обновить страницу.
</v-alert>
<div class="d-flex ga-2 pa-4 pb-0">
<v-btn
size="small"
variant="tonal"
class="text-none"
:disabled="selectedFirmIds.length === 0 || applyingChannels"
data-testid="channels-yandex"
@click="applyChannels('yandex')"
>
В Яндекс
</v-btn>
<v-btn
size="small"
variant="tonal"
class="text-none"
:disabled="selectedFirmIds.length === 0 || applyingChannels"
data-testid="channels-vk"
@click="applyChannels('vk')"
>
В ВК
</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>
<v-table density="compact" data-testid="firms-table">
<thead>
<tr>
<th></th>
<th>Фирма</th>
<th>Город</th>
<th>Где греем</th>
<th class="text-right sm-num">Номеров</th>
<th>Состояние рекламы</th>
<th class="text-right sm-num">Дней в рекламе</th>
@@ -474,12 +549,25 @@ defineExpose({
</thead>
<tbody>
<tr v-for="row in firms" :key="row.id" data-testid="firm-row">
<!-- Галочка -->
<td>
<input
type="checkbox"
:data-testid="`firm-check-${row.id}`"
:value="row.id"
v-model="selectedFirmIds"
/>
</td>
<!-- Фирма -->
<td class="sm-name">{{ row.firm_name }}</td>
<!-- Город -->
<td>{{ row.city ?? '—' }}</td>
<!-- Где греем -->
<td data-testid="firm-channels">{{ channelsLabel(row.channels) }}</td>
<!-- Номеров -->
<td class="text-right sm-num sm-mono">{{ row.phones_count }}</td>
@@ -520,7 +608,7 @@ defineExpose({
<!-- Empty state -->
<tr v-if="!firmsLoading && firms.length === 0">
<td colspan="6" class="text-center text-medium-emphasis pa-6">
<td colspan="8" class="text-center text-medium-emphasis pa-6">
Фирм на прогреве пока нет отправьте их из Поиска клиентов
</td>
</tr>
@@ -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');
});
});
+8 -2
View File
@@ -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-сессий.
## Очередь обучения роутера