dfb41751c8
Task 3.3b — три экрана Фазы 1 показывают живой earned_rub вместо заглушки «—»: - «Мои клиенты»: колонка «Заработал» = комиссия по клиенту (или «—» без привязки). - Карточка клиента: KPI «Вы заработали (период)». - Сводка: KPI «Я заработал» = суммарная комиссия за период. Тип earned_rub расширен до number|null. Vitest-моки/ассерты обновлены под реальные значения. Фронт-набор 1052 зелёных, ESLint чист. Фаза 3 завершена: доход менеджера считается по тарифу каждого клиента и виден во всех экранах. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
227 lines
8.9 KiB
TypeScript
227 lines
8.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { mount, flushPromises } from '@vue/test-utils';
|
|
import { createVuetify } from 'vuetify';
|
|
import { createPinia } from 'pinia';
|
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
|
import SalesClientCardView from '../../resources/js/views/sales/SalesClientCardView.vue';
|
|
import type { SalesClientCard } from '../../resources/js/api/sales';
|
|
|
|
// ─── sample data ──────────────────────────────────────────────────────────────
|
|
|
|
const sampleCard: SalesClientCard = {
|
|
profile: {
|
|
organization_name: 'Окна Москва ООО',
|
|
contact_email: 'info@okna-msk.ru',
|
|
contact_name: 'Сергей Петров',
|
|
contact_phone: '+7 905 123-45-67',
|
|
inn: '7724444444',
|
|
subject_type: 'legal_entity',
|
|
created_at: '2026-04-12T10:00:00Z',
|
|
desired_daily_numbers: 12,
|
|
last_activity_at: '2026-06-28T09:41:00Z',
|
|
},
|
|
kpi: {
|
|
balance_rub: '14250',
|
|
runway_days: 9,
|
|
projects_count: 3,
|
|
leads_delivered: 331,
|
|
leads_target: 360,
|
|
avg_lead_price_rub: 150.0,
|
|
earned_rub: 2450,
|
|
},
|
|
projects: [
|
|
{
|
|
id: 1,
|
|
name: 'Окна — Москва',
|
|
signal_type: 'site',
|
|
region: ['Москва'],
|
|
daily_limit_target: 12,
|
|
delivered_today: 11,
|
|
status: 'active',
|
|
},
|
|
{
|
|
id: 2,
|
|
name: 'Балконы',
|
|
signal_type: 'sms',
|
|
region: ['Москва', 'МО', 'Тверь', 'Калуга'],
|
|
daily_limit_target: 4,
|
|
delivered_today: 0,
|
|
status: 'paused',
|
|
},
|
|
],
|
|
leads_by_day: [
|
|
{ date: '2026-06-28', count: 18, oborot_rub: 2700 },
|
|
{ date: '2026-06-27', count: 22, oborot_rub: 3300 },
|
|
],
|
|
recent_leads: [
|
|
{
|
|
received_at: '2026-06-28T09:41:00Z',
|
|
phone_masked: '+7 905 •••-34-12',
|
|
region: 'Москва',
|
|
source: 'Сайт (B1)',
|
|
project: 'Окна — Москва',
|
|
},
|
|
{
|
|
received_at: '2026-06-28T09:18:00Z',
|
|
phone_masked: '+7 916 •••-77-08',
|
|
region: 'Москва',
|
|
source: 'Сайт (B1)',
|
|
project: 'Окна — Москва',
|
|
},
|
|
],
|
|
activity: [
|
|
{
|
|
created_at: '2026-06-28T09:12:00Z',
|
|
type: 'leads',
|
|
amount_rub: '0',
|
|
description: 'Доставлено 18 лидов',
|
|
},
|
|
{
|
|
created_at: '2026-06-27T19:40:00Z',
|
|
type: 'topup',
|
|
amount_rub: '10000',
|
|
description: 'Пополнение баланса: + 10 000 ₽',
|
|
},
|
|
],
|
|
};
|
|
|
|
// ─── mock getSalesClientCard ──────────────────────────────────────────────────
|
|
|
|
vi.mock('../../resources/js/api/sales', () => ({
|
|
getSalesClientCard: vi.fn(),
|
|
extractSalesErrorMessage: vi.fn((err: unknown, fb: string) => fb),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
const api = await import('../../resources/js/api/sales');
|
|
(api.getSalesClientCard as ReturnType<typeof vi.fn>).mockResolvedValue(sampleCard);
|
|
});
|
|
|
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function makeRouter() {
|
|
const router = createRouter({
|
|
history: createMemoryHistory(),
|
|
routes: [
|
|
{
|
|
path: '/sales/clients/:id',
|
|
name: 'sales-client-detail',
|
|
component: SalesClientCardView,
|
|
props: true,
|
|
},
|
|
{ path: '/sales/clients', name: 'sales-clients', component: { template: '<div/>' } },
|
|
],
|
|
});
|
|
return router.push('/sales/clients/101').then(() => router);
|
|
}
|
|
|
|
// ─── tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('SalesClientCardView', () => {
|
|
it('отрисовывает название организации', async () => {
|
|
const router = await makeRouter();
|
|
const wrapper = mount(SalesClientCardView, {
|
|
global: { plugins: [createVuetify(), createPinia(), router] },
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(wrapper.text()).toContain('Окна Москва ООО');
|
|
expect(wrapper.find('[data-testid="org-name"]').text()).toBe('Окна Москва ООО');
|
|
});
|
|
|
|
it('KPI-плитка баланса отображает сумму', async () => {
|
|
const router = await makeRouter();
|
|
const wrapper = mount(SalesClientCardView, {
|
|
global: { plugins: [createVuetify(), createPinia(), router] },
|
|
});
|
|
await flushPromises();
|
|
|
|
const kpiBalance = wrapper.find('[data-testid="kpi-balance"]');
|
|
expect(kpiBalance.exists()).toBe(true);
|
|
// 14250 → "14 250 ₽" (ru-RU formatting)
|
|
expect(kpiBalance.text()).toContain('₽');
|
|
expect(kpiBalance.text()).toContain('250');
|
|
});
|
|
|
|
it('строка recent_lead показывает замаскированный телефон', async () => {
|
|
const router = await makeRouter();
|
|
const wrapper = mount(SalesClientCardView, {
|
|
global: { plugins: [createVuetify(), createPinia(), router] },
|
|
});
|
|
await flushPromises();
|
|
|
|
const phoneEls = wrapper.findAll('[data-testid="lead-phone"]');
|
|
expect(phoneEls.length).toBeGreaterThan(0);
|
|
expect(phoneEls[0].text()).toBe('+7 905 •••-34-12');
|
|
expect(phoneEls[1].text()).toBe('+7 916 •••-77-08');
|
|
});
|
|
|
|
it('«Заработано» показывает доход по тарифу клиента', async () => {
|
|
const router = await makeRouter();
|
|
const wrapper = mount(SalesClientCardView, {
|
|
global: { plugins: [createVuetify(), createPinia(), router] },
|
|
});
|
|
await flushPromises();
|
|
|
|
const earned = wrapper.find('[data-testid="earned-value"]');
|
|
expect(earned.exists()).toBe(true);
|
|
// earned_rub=2450 → «2 450,00 ₽»
|
|
expect(earned.text()).toContain('2');
|
|
expect(earned.text()).toContain('₽');
|
|
});
|
|
|
|
it('список проектов отрисовывает строки', async () => {
|
|
const router = await makeRouter();
|
|
const wrapper = mount(SalesClientCardView, {
|
|
global: { plugins: [createVuetify(), createPinia(), router] },
|
|
});
|
|
await flushPromises();
|
|
|
|
const rows = wrapper.findAll('[data-testid="project-row"]');
|
|
expect(rows.length).toBe(2);
|
|
expect(rows[0].text()).toContain('Окна — Москва');
|
|
expect(rows[1].text()).toContain('Балконы');
|
|
});
|
|
|
|
it('403 показывает сообщение «не закреплён»', async () => {
|
|
const { getSalesClientCard } = await import('../../resources/js/api/sales');
|
|
const mockFn = getSalesClientCard as ReturnType<typeof vi.fn>;
|
|
const axiosMock = await import('axios');
|
|
const axiosError = new axiosMock.default.AxiosError('Forbidden', '403');
|
|
Object.assign(axiosError, { response: { status: 403, data: {} } });
|
|
mockFn.mockRejectedValueOnce(axiosError);
|
|
|
|
const router = await makeRouter();
|
|
const wrapper = mount(SalesClientCardView, {
|
|
global: { plugins: [createVuetify(), createPinia(), router] },
|
|
});
|
|
await flushPromises();
|
|
|
|
const alert = wrapper.find('[data-testid="error-403"]');
|
|
expect(alert.exists()).toBe(true);
|
|
expect(alert.text()).toContain('не закреплён');
|
|
});
|
|
|
|
it('вызывает getSalesClientCard при монтировании', async () => {
|
|
const router = await makeRouter();
|
|
mount(SalesClientCardView, {
|
|
global: { plugins: [createVuetify(), createPinia(), router] },
|
|
});
|
|
await flushPromises();
|
|
|
|
const api = await import('../../resources/js/api/sales');
|
|
expect(api.getSalesClientCard).toHaveBeenCalledWith('101', expect.any(Object));
|
|
});
|
|
|
|
it('тип лица «legal_entity» → «Юридическое лицо» в профиле', async () => {
|
|
const router = await makeRouter();
|
|
const wrapper = mount(SalesClientCardView, {
|
|
global: { plugins: [createVuetify(), createPinia(), router] },
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(wrapper.text()).toContain('Юридическое лицо');
|
|
});
|
|
});
|