ed61bae482
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
146 lines
5.6 KiB
Vue
146 lines
5.6 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Дашборд — стартовая страница. Audit C1/J3: KPI/баланс/активность/воронка
|
|
* грузятся из GET /api/dashboard/summary; при ошибке — fallback на mock,
|
|
* чтобы UI оставался работоспособным (dev / отсутствие backend).
|
|
*/
|
|
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 auth = useAuthStore();
|
|
const range = ref<DashboardRange | 'custom'>('7d');
|
|
|
|
// 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 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 n = parseFloat(raw);
|
|
if (!Number.isFinite(n)) return '0';
|
|
const int = Math.round(n).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> {
|
|
const tenantId = auth.user?.tenant_id;
|
|
if (!tenantId || range.value === 'custom') return;
|
|
try {
|
|
applySummary(await getDashboardSummary(tenantId, range.value as DashboardRange));
|
|
fetchError.value = false;
|
|
} catch {
|
|
fetchError.value = true; // оставляем последнее значение / mock
|
|
}
|
|
}
|
|
|
|
watch(range, load);
|
|
load();
|
|
</script>
|
|
|
|
<template>
|
|
<v-container fluid class="dashboard pa-6">
|
|
<DashboardPageHead v-model="range" />
|
|
|
|
<div v-show="!fetchError" class="ld-meta mt-2">
|
|
<span class="ld-pulse" aria-hidden="true"></span>
|
|
<span>Live · обновлено только что</span>
|
|
</div>
|
|
|
|
<v-row dense class="kpi-row mt-4">
|
|
<DashboardKpiRow :kpis="kpis" />
|
|
<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 :points="activityPoints" :labels="activityLabels" :max="activityMax" />
|
|
</v-col>
|
|
<v-col cols="12" md="5">
|
|
<FunnelChart :counts="funnelCounts" />
|
|
</v-col>
|
|
</v-row>
|
|
</v-container>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.dashboard {
|
|
max-width: 1440px;
|
|
}
|
|
|
|
.ld-meta {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: #66635c;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
</style>
|