feat(dashboard): DashboardView на real API /api/dashboard/summary (audit C1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-16 11:02:10 +03:00
parent 283db070e1
commit cadaecdaf8
3 changed files with 176 additions and 79 deletions
+26
View File
@@ -0,0 +1,26 @@
import { apiClient } from './client';
/**
* API-клиент дашборда (audit C1/J3). Эндпоинт GET /api/dashboard/summary.
* На MVP без auth — tenant_id параметром (на prod возьмётся из middleware).
*/
export type DeltaDir = 'up' | 'down' | 'neutral';
export type DashboardRange = 'today' | '7d' | '30d';
export interface DashboardSummary {
range: string;
leads_received: { value: number; delta_pct: number; delta_dir: DeltaDir };
conversion: { value: number; delta_pp: number; delta_dir: DeltaDir };
active_projects: { active: number; limit: number };
balance: { amount_rub: string; runway_days: number; runway_leads: number };
activity: { points: number[]; labels: string[]; max: number };
funnel: Record<string, number>;
}
export async function getDashboardSummary(tenantId: number, range: DashboardRange): Promise<DashboardSummary> {
const { data } = await apiClient.get<DashboardSummary>('/api/dashboard/summary', {
params: { tenant_id: tenantId, range },
});
return data;
}
+92 -47
View File
@@ -1,60 +1,94 @@
<script setup lang="ts">
/**
* Дашборд — стартовая страница для авторизованных пользователей.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html.
* MVP: page-head + 4 KPI-cards (получено лидов / конверсия / активные проекты /
* баланс). Графики (Активность по дням, Воронка из 14 статусов).
*
* Все числа сейчас mock'и — TODO: GET /api/dashboard/summary с tenant-context'ом
* по middleware SetTenantContext (фаза backend).
*
* Sprint 4 Phase B/3 — split на DashboardPageHead + DashboardKpiRow +
* DashboardBalance (audit O-refactor-04 закрытие). State (range, kpis, balance)
* остаётся в parent ради единого mock-data flow и future API-fetch'а.
*
* Примечание: «recent deals list» в Phase B/3 plan'е — на текущем дашборде нет
* (есть только charts row); если будет добавлено в будущем — выносится в
* DashboardRecentDeals.vue по аналогии.
* Дашборд — стартовая страница. Audit C1/J3: KPI/баланс/активность/воронка
* грузятся из GET /api/dashboard/summary; при ошибке — fallback на mock,
* чтобы UI оставался работоспособным (dev / отсутствие backend).
*/
import { ref } from 'vue';
import { ref, watch } from 'vue';
import ActivityChart from '../components/charts/ActivityChart.vue';
import FunnelChart from '../components/charts/FunnelChart.vue';
import DashboardPageHead from '../components/dashboard/DashboardPageHead.vue';
import DashboardKpiRow, { type Kpi } from '../components/dashboard/DashboardKpiRow.vue';
import DashboardBalance, { type Balance } from '../components/dashboard/DashboardBalance.vue';
import { getDashboardSummary, type DashboardRange, type DashboardSummary } from '../api/dashboard';
import { useAuthStore } from '../stores/auth';
const range = ref<'today' | '7d' | '30d' | 'custom'>('7d');
const auth = useAuthStore();
const range = ref<DashboardRange | 'custom'>('7d');
const kpis: Kpi[] = [
{
label: 'Получено лидов',
value: '247',
delta: { dir: 'up', text: '12.3%' },
sub: 'vs предыдущие 7 дней',
},
{
label: 'Конверсия в оплату',
value: '18.4',
unit: '%',
delta: { dir: 'up', text: '2.1pp' },
sub: 'vs предыдущие 7 дней',
},
{
label: 'Активные проекты',
value: '8',
unit: '/ 10',
delta: { dir: 'neutral', text: '2 свободно' },
sub: 'тариф «Команда»',
},
// runwayMax — display-константа полосы (7 сегментов), не из API.
const RUNWAY_MAX = 7;
// Mock-fallback — UI работоспособен без backend (dev / 500 / нет auth).
const MOCK_KPIS: Kpi[] = [
{ label: 'Получено лидов', value: '247', delta: { dir: 'up', text: '12.3%' }, sub: 'vs предыдущий период' },
{ label: 'Конверсия в оплату', value: '18.4', unit: '%', delta: { dir: 'up', text: '2.1pp' }, sub: 'vs предыдущий период' },
{ label: 'Активные проекты', value: '8', unit: '/ 10', delta: { dir: 'neutral', text: '' }, sub: 'лимит тарифа' },
];
const MOCK_BALANCE: Balance = { amount: '14 250', runwayDays: 4, runwayMax: RUNWAY_MAX, runwayLeads: 285 };
const balance: Balance = {
amount: '14 250',
runwayDays: 4,
runwayMax: 7,
runwayLeads: 285,
};
const kpis = ref<Kpi[]>(MOCK_KPIS);
const balance = ref<Balance>(MOCK_BALANCE);
const activityPoints = ref<number[]>([16, 31, 27, 47, 39, 56, 50]);
const activityLabels = ref<string[]>(['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'сегодня']);
const activityMax = ref(60);
const funnelCounts = ref<Record<string, number> | undefined>(undefined);
const fetchError = ref(false);
/** Форматирует число с пробелами-разделителями тысяч ('14250.00' → '14 250'). */
function formatRub(raw: string): string {
const int = Math.round(parseFloat(raw)).toString();
return int.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}
function applySummary(s: DashboardSummary): void {
kpis.value = [
{
label: 'Получено лидов',
value: String(s.leads_received.value),
delta: { dir: s.leads_received.delta_dir, text: `${s.leads_received.delta_pct}%` },
sub: 'vs предыдущий период',
},
{
label: 'Конверсия в оплату',
value: String(s.conversion.value),
unit: '%',
delta: { dir: s.conversion.delta_dir, text: `${s.conversion.delta_pp}pp` },
sub: 'vs предыдущий период',
},
{
label: 'Активные проекты',
value: String(s.active_projects.active),
unit: `/ ${s.active_projects.limit}`,
delta: { dir: 'neutral', text: '' },
sub: 'лимит тарифа',
},
];
balance.value = {
amount: formatRub(s.balance.amount_rub),
runwayDays: Math.min(s.balance.runway_days, RUNWAY_MAX),
runwayMax: RUNWAY_MAX,
runwayLeads: s.balance.runway_leads,
};
activityPoints.value = s.activity.points;
activityLabels.value = s.activity.labels;
activityMax.value = s.activity.max;
funnelCounts.value = s.funnel;
}
async function load(): Promise<void> {
if (range.value === 'custom') return;
const tenantId = auth.user?.tenant_id ?? 0;
try {
applySummary(await getDashboardSummary(tenantId, range.value as DashboardRange));
fetchError.value = false;
} catch {
fetchError.value = true; // оставляем последнее значение / mock
}
}
watch(range, load);
load();
</script>
<template>
@@ -71,12 +105,23 @@ const balance: Balance = {
<DashboardBalance :balance="balance" />
</v-row>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
class="mt-3"
data-testid="dashboard-fetch-error"
>
Не удалось обновить данные дашборда показаны последние известные значения.
</v-alert>
<v-row class="charts-row mt-4">
<v-col cols="12" md="7">
<ActivityChart />
<ActivityChart :points="activityPoints" :labels="activityLabels" :max="activityMax" />
</v-col>
<v-col cols="12" md="5">
<FunnelChart />
<FunnelChart :counts="funnelCounts" />
</v-col>
</v-row>
</v-container>
+58 -32
View File
@@ -1,47 +1,73 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createPinia, setActivePinia } from 'pinia';
import DashboardView from '../../resources/js/views/DashboardView.vue';
import type { DashboardSummary } from '../../resources/js/api/dashboard';
describe('DashboardView.vue', () => {
const factory = () =>
mount(DashboardView, {
global: { plugins: [createVuetify()] },
});
vi.mock('../../resources/js/api/dashboard', () => ({
getDashboardSummary: vi.fn(),
}));
it('монтируется и содержит приветствие', () => {
const wrapper = factory();
expect(wrapper.text()).toContain('Доброе утро');
const dashboardApi = await import('../../resources/js/api/dashboard');
function makeSummary(overrides: Partial<DashboardSummary> = {}): DashboardSummary {
return {
range: '7d',
leads_received: { value: 247, delta_pct: 12.3, delta_dir: 'up' },
conversion: { value: 18.4, delta_pp: 2.1, delta_dir: 'up' },
active_projects: { active: 8, limit: 10 },
balance: { amount_rub: '14250.00', runway_days: 4, runway_leads: 285 },
activity: { points: [3, 5, 2, 8, 6, 9, 4], labels: ['сб', 'вс', 'пн', 'вт', 'ср', 'чт', 'сегодня'], max: 10 },
funnel: { new: 18, paid: 45 },
...overrides,
};
}
const mountView = () => {
setActivePinia(createPinia());
return mount(DashboardView, { global: { plugins: [createVuetify()] } });
};
beforeEach(() => vi.clearAllMocks());
describe('DashboardView.vue ↔ /api/dashboard/summary', () => {
it('getDashboardSummary вызывается на mount', async () => {
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(makeSummary());
mountView();
await flushPromises();
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(1);
});
it('содержит range-toggle с 4 опциями', () => {
const wrapper = factory();
const text = wrapper.text();
expect(text).toContain('Сегодня');
expect(text).toContain('7 дней');
expect(text).toContain('30 дней');
expect(text).toContain('Период');
});
it('содержит 3 KPI-cards (получено лидов / конверсия / активные проекты)', () => {
const wrapper = factory();
it('успех — KPI и баланс из API видны', async () => {
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
makeSummary({ balance: { amount_rub: '99000.00', runway_days: 9, runway_leads: 500 } }),
);
const wrapper = mountView();
await flushPromises();
const text = wrapper.text();
expect(text).toContain('Получено лидов');
expect(text).toContain('Конверсия в оплату');
expect(text).toContain('Активные проекты');
});
it('содержит balance-card с suммой и runway', () => {
const wrapper = factory();
const text = wrapper.text();
expect(text).toContain('Баланс');
expect(text).toContain('14 250');
expect(text).toContain('LIVE');
expect(text).toContain('хватит на');
expect(text).toContain('99 000');
});
it('runway-bar содержит 7 сегментов (по числу runwayMax)', () => {
const wrapper = factory();
expect(wrapper.findAll('.runway-fill')).toHaveLength(7);
it('ошибка API — fallback на mock, view не падает', async () => {
vi.mocked(dashboardApi.getDashboardSummary).mockRejectedValueOnce(new Error('500'));
const wrapper = mountView();
await flushPromises();
expect(wrapper.text()).toContain('Получено лидов');
expect(wrapper.find('.runway-fill').exists()).toBe(true);
});
it('смена range перезапрашивает summary', async () => {
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValue(makeSummary());
const wrapper = mountView();
await flushPromises();
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(1);
(wrapper.vm as unknown as { range: string }).range = '30d';
await flushPromises();
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(2);
});
});