80c9ca7e67
Кусок B «витрина прогрева» (спека 2026-07-21 §6), решение владельца — полная летопись: - B1: таблица sales_ad_audience_warming_episodes + модель + WarmingEpisodeRecorder (open/close/record идемпотентно). Схема v8.85, бэкфилл из firm_channels(warming) и боевых СМС, guard по источнику. rls-reviewer OK 8/8. - B2: «Греть»/«Убрать» на площадке открывают/закрывают эпизоды канала. - B4: warmingByProspect считает значки из летописи (live/count вместо массива каналов), тип WarmingBadgeState в sales.ts. - B5: единый компонент WarmingBadges.vue (идёт/грели раньше/×N) в канбане; осиротевший WarmingChannelIcons удалён. Уборка: убраны мёртвые скоупы forYandex/Vk/Mts + их импорт + тест (боевых вызовов нет). cspell: +5 пре-существующих слов CHANGELOG в словарь (apk/cvtjpq/hgq/sar/sca). Проверено: Sales 427/427, composer stan 0, pint/prettier чисто, весь Vue-набор зелёный. B3 (СМС→эпизод) — отдельным коммитом (СМС-блок правит и параллельная сессия). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
8.2 KiB
TypeScript
183 lines
8.2 KiB
TypeScript
import { mount } from '@vue/test-utils';
|
||
import { createVuetify } from 'vuetify';
|
||
import { describe, expect, it } from 'vitest';
|
||
import SalesProspectBoard from '../../resources/js/components/sales/SalesProspectBoard.vue';
|
||
import type { SalesProspect } from '../../resources/js/api/sales';
|
||
|
||
const vuetify = createVuetify();
|
||
|
||
function p(id: number, stage: string, overrides: Partial<SalesProspect> = {}): SalesProspect {
|
||
return {
|
||
id,
|
||
sales_user_id: 1,
|
||
stage,
|
||
firm_name: `Фирма ${id}`,
|
||
city: 'Ростов',
|
||
phone: null,
|
||
site: null,
|
||
inn: null,
|
||
rating_label: 'горячая',
|
||
source: 'search',
|
||
legal_name: null,
|
||
contacts: [],
|
||
payload: {},
|
||
next_call_at: null,
|
||
reason: null,
|
||
registered_email: null,
|
||
notes: null,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe('SalesProspectBoard', () => {
|
||
it('рисует 9 колонок (вкл. «Взят в работу») и раскладывает карточки по стадиям', () => {
|
||
const w = mount(SalesProspectBoard, {
|
||
props: { prospects: [p(1, 'new'), p(2, 'negotiation'), p(3, 'new')] },
|
||
global: { plugins: [vuetify] },
|
||
});
|
||
expect(w.findAll('.prospect-column').length).toBe(9);
|
||
expect(w.text()).toContain('Новые');
|
||
expect(w.text()).toContain('Взят в работу');
|
||
expect(w.text()).toContain('Фирма 1');
|
||
});
|
||
|
||
it('бейдж происхождения: «своя» у менеджера, «от начальника» из поиска', () => {
|
||
const w = mount(SalesProspectBoard, {
|
||
props: {
|
||
prospects: [
|
||
p(1, 'new', { firm_name: 'МОЯ', source: 'manager' }),
|
||
p(2, 'new', { firm_name: 'ОТДАЛИ', source: 'search' }),
|
||
],
|
||
},
|
||
global: { plugins: [vuetify] },
|
||
});
|
||
const cards = w.findAll('.prospect-card');
|
||
const own = cards.find((c) => c.text().includes('МОЯ'));
|
||
const given = cards.find((c) => c.text().includes('ОТДАЛИ'));
|
||
expect(own?.text()).toContain('своя');
|
||
expect(given?.text()).toContain('от начальника');
|
||
});
|
||
|
||
it('клик по карточке эмитит open с прогнозом', async () => {
|
||
const w = mount(SalesProspectBoard, { props: { prospects: [p(5, 'new')] }, global: { plugins: [vuetify] } });
|
||
await w.find('.prospect-card').trigger('click');
|
||
expect(w.emitted('open')?.[0]?.[0]).toMatchObject({ id: 5 });
|
||
});
|
||
|
||
it('просроченный созвон подсвечивается классом overdue', () => {
|
||
const w = mount(SalesProspectBoard, {
|
||
props: { prospects: [p(9, 'negotiation', { next_call_at: '2000-01-01T00:00:00+03:00' })] },
|
||
global: { plugins: [vuetify] },
|
||
});
|
||
expect(w.find('.prospect-card.overdue').exists()).toBe(true);
|
||
});
|
||
|
||
it('колонка «Переговоры» сортируется по ближайшему созвону (ASC, просроченные сверху)', () => {
|
||
// id по убыванию (как отдаёт API), но даты созвона вперемешку.
|
||
const w = mount(SalesProspectBoard, {
|
||
props: {
|
||
prospects: [
|
||
p(30, 'negotiation', { firm_name: 'ПОЗЖЕ', next_call_at: '2026-08-01T10:00:00+03:00' }),
|
||
p(20, 'negotiation', { firm_name: 'ПРОСРОЧЕНО', next_call_at: '2000-01-01T00:00:00+03:00' }),
|
||
p(10, 'negotiation', { firm_name: 'СКОРО', next_call_at: '2026-07-16T10:00:00+03:00' }),
|
||
],
|
||
},
|
||
global: { plugins: [vuetify] },
|
||
});
|
||
const negColumn = w.findAll('.prospect-column')[2]; // 0=Новые, 1=Взят в работу, 2=Переговоры
|
||
const names = negColumn.findAll('.prospect-card-name').map((n) => n.text());
|
||
expect(names).toEqual(['ПРОСРОЧЕНО', 'СКОРО', 'ПОЗЖЕ']);
|
||
});
|
||
|
||
it('карточки «Переговоры» без даты созвона — в конце колонки', () => {
|
||
const w = mount(SalesProspectBoard, {
|
||
props: {
|
||
prospects: [
|
||
p(40, 'negotiation', { firm_name: 'БЕЗ_ДАТЫ', next_call_at: null }),
|
||
p(41, 'negotiation', { firm_name: 'С_ДАТОЙ', next_call_at: '2026-07-16T10:00:00+03:00' }),
|
||
],
|
||
},
|
||
global: { plugins: [vuetify] },
|
||
});
|
||
const negColumn = w.findAll('.prospect-column')[2];
|
||
const names = negColumn.findAll('.prospect-card-name').map((n) => n.text());
|
||
expect(names).toEqual(['С_ДАТОЙ', 'БЕЗ_ДАТЫ']);
|
||
});
|
||
});
|
||
|
||
describe('сайт ссылкой на плитке', () => {
|
||
it('домен без схемы открывается по https', () => {
|
||
const w = mount(SalesProspectBoard, {
|
||
props: { prospects: [p(1, 'new', { site: 'omdent.ru' })] },
|
||
global: { plugins: [vuetify] },
|
||
});
|
||
const a = w.find('.prospect-card-site a');
|
||
expect(a.exists()).toBe(true);
|
||
expect(a.attributes('href')).toBe('https://omdent.ru');
|
||
expect(a.attributes('target')).toBe('_blank');
|
||
expect(a.attributes('rel')).toContain('noopener');
|
||
expect(a.text()).toBe('omdent.ru');
|
||
});
|
||
|
||
it('готовую ссылку не портим', () => {
|
||
const w = mount(SalesProspectBoard, {
|
||
props: { prospects: [p(1, 'new', { site: 'http://vitadent-omsk.ru' })] },
|
||
global: { plugins: [vuetify] },
|
||
});
|
||
expect(w.find('.prospect-card-site a').attributes('href')).toBe('http://vitadent-omsk.ru');
|
||
});
|
||
|
||
it('без сайта строки нет', () => {
|
||
const w = mount(SalesProspectBoard, {
|
||
props: { prospects: [p(1, 'new', { site: null })] },
|
||
global: { plugins: [vuetify] },
|
||
});
|
||
expect(w.find('.prospect-card-site').exists()).toBe(false);
|
||
});
|
||
|
||
it('клик по ссылке НЕ открывает карточку', async () => {
|
||
const w = mount(SalesProspectBoard, {
|
||
props: { prospects: [p(1, 'new', { site: 'omdent.ru' })] },
|
||
global: { plugins: [vuetify] },
|
||
});
|
||
await w.find('.prospect-card-site a').trigger('click');
|
||
expect(w.emitted('open')).toBeFalsy();
|
||
|
||
await w.find('.prospect-card-name').trigger('click');
|
||
expect(w.emitted('open')).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
// Пометка «греется/прогрет» + значки каналов на карточке воронки. Блок warming
|
||
// приходит с сервера (кусок B, форма {active, channels:{code:{live,count}}}),
|
||
// рисует его единый WarmingBadges.
|
||
describe('пометка прогрева на карточке', () => {
|
||
it('карточка с warming показывает бейдж состояния и значки каналов', () => {
|
||
const w = mount(SalesProspectBoard, {
|
||
props: {
|
||
prospects: [
|
||
p(1, 'new', {
|
||
warming: {
|
||
active: true,
|
||
channels: { yandex: { live: true, count: 1 }, sms: { live: false, count: 2 } },
|
||
},
|
||
}),
|
||
],
|
||
},
|
||
global: { plugins: [vuetify] },
|
||
});
|
||
expect(w.find('[data-testid="warming-state-badge"]').exists()).toBe(true);
|
||
expect(w.find('[data-testid="warming-badge-sms"]').exists()).toBe(true);
|
||
expect(w.find('[data-testid="warming-badge-sms"]').text()).toContain('×2');
|
||
});
|
||
|
||
it('карточка без warming пометку не рисует', () => {
|
||
const w = mount(SalesProspectBoard, {
|
||
props: { prospects: [p(1, 'new')] },
|
||
global: { plugins: [vuetify] },
|
||
});
|
||
expect(w.find('[data-testid="warming-state-badge"]').exists()).toBe(false);
|
||
expect(w.find('[data-testid="prospect-warming"]').exists()).toBe(false);
|
||
});
|
||
});
|