feat(sales): колонка «Где греем» и кнопки смены площадки
Task 6 плана 2026-07-19-vybor-ploshadki-progreva.md — начальник видит площадку прогрева каждой фирмы (Яндекс/ВК/оба), отмечает строки галочкой и жмёт одну из трёх кнопок массовой смены; api-слой зовёт уже готовый POST /api/sales/ad-audience/channels.
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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-сессий.
|
||||
|
||||
## Очередь обучения роутера
|
||||
|
||||
|
||||
Reference in New Issue
Block a user