Files
portal/app/resources/js/views/DashboardView.vue
T

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>