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:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user