7ac9af7c79
Правило продукта: ограничений по количеству проектов нет, лимит только по балансу и заказанным лидам. Убран гейт tenants.limits.max_projects в ProjectService::create и показ лимита проектов на дашборде. Поле limits оставлено как резерв; max_users и api_rps в коде не используются. Заодно фикс типа в EditProjectDialog.spec: sampleProject типизирован настоящим Project, source_locked больше не краснит vue-tsc. Тесты: ProjectsStore 13/13, DashboardSummary 11/11, DashboardView 8/8, EditProjectDialog 7/7; vue-tsc чисто; pint чисто; vite build ок. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
268 lines
12 KiB
Vue
268 lines
12 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Дашборд — стартовая страница. Audit C1/J3: KPI/баланс/активность/воронка
|
||
* грузятся из GET /api/dashboard/summary; при ошибке — fallback на mock,
|
||
* чтобы UI оставался работоспособным (dev / отсутствие backend).
|
||
*/
|
||
import { computed, ref, watch } from 'vue';
|
||
import { useRouter } from 'vue-router';
|
||
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 router = useRouter();
|
||
const range = ref<DashboardRange | 'custom'>('7d');
|
||
|
||
// Косяк 07: онбординг новичка. Показываем «с чего начать», когда сводка
|
||
// уже загружена и у клиента нет ни активных проектов, ни лидов за период.
|
||
const loaded = ref(false);
|
||
const activeProjectsCount = ref(0);
|
||
const leadsReceivedCount = ref(0);
|
||
const onboardingDismissed = ref(localStorage.getItem('dashboard.onboardingDismissed') === '1');
|
||
const showOnboarding = computed(
|
||
() => loaded.value && !onboardingDismissed.value && activeProjectsCount.value === 0 && leadsReceivedCount.value === 0,
|
||
);
|
||
function dismissOnboarding(): void {
|
||
onboardingDismissed.value = true;
|
||
localStorage.setItem('dashboard.onboardingDismissed', '1');
|
||
}
|
||
|
||
// runwayMax — display-константа полосы (7 сегментов), не из API.
|
||
const RUNWAY_MAX = 7;
|
||
|
||
// Пустой стартовый стейт (БЕЗ фейк-данных): KPI/баланс/график = нули до ответа
|
||
// GET /api/dashboard/summary. При ошибке остаются нули + баннер fetchError —
|
||
// пользователь не видит выдуманных цифр (прежний mock-fallback показывал
|
||
// «247 лидов / 18.4% / 14 250₽» как настоящие — F5).
|
||
const EMPTY_KPIS: Kpi[] = [
|
||
{
|
||
label: 'Получено лидов',
|
||
value: '0',
|
||
delta: { dir: 'neutral', text: '' },
|
||
sub: 'vs предыдущий период',
|
||
hint: 'Сколько заявок вы получили за выбранный период.',
|
||
},
|
||
{
|
||
label: 'Конверсия в оплату',
|
||
value: '0',
|
||
unit: '%',
|
||
delta: { dir: 'neutral', text: '' },
|
||
sub: 'vs предыдущий период',
|
||
hint: 'Доля полученных заявок, по которым прошла оплата.',
|
||
},
|
||
{
|
||
label: 'Активные проекты',
|
||
value: '0',
|
||
delta: { dir: 'neutral', text: '' },
|
||
sub: 'лимит тарифа',
|
||
hint: 'Проекты, которые сейчас собирают заявки.',
|
||
},
|
||
];
|
||
const EMPTY_BALANCE: Balance = { amount: '0', runwayDays: 0, runwayMax: RUNWAY_MAX, runwayLeads: 0 };
|
||
|
||
const kpis = ref<Kpi[]>(EMPTY_KPIS);
|
||
const balance = ref<Balance>(EMPTY_BALANCE);
|
||
const activityPoints = ref<number[]>([0, 0, 0, 0, 0, 0, 0]);
|
||
const activityLabels = ref<string[]>(['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'сегодня']);
|
||
const activityMax = ref(60);
|
||
const funnelCounts = ref<Record<string, number> | undefined>(undefined);
|
||
// F5: средняя стоимость лида из API (avg_lead_cost_rub); null — нет rub-списаний.
|
||
const avgLeadCost = ref<number | null>(null);
|
||
const fetchError = ref(false);
|
||
|
||
// F5: сегодня/вчера для meta-строки — из последних точек активности
|
||
// (последняя точка = сегодня, предпоследняя = вчера; activity всегда 7 дней).
|
||
const leadsToday = computed(() => activityPoints.value[activityPoints.value.length - 1] ?? 0);
|
||
const leadsYesterday = computed(() => activityPoints.value[activityPoints.value.length - 2] ?? 0);
|
||
|
||
/** Форматирует число с пробелами-разделителями тысяч ('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 предыдущий период',
|
||
hint: 'Сколько заявок вы получили за выбранный период.',
|
||
},
|
||
{
|
||
label: 'Конверсия в оплату',
|
||
value: String(s.conversion.value),
|
||
unit: '%',
|
||
// «п.п.» (процентные пункты) вместо англ. «pp» — понятнее новичку.
|
||
delta: { dir: s.conversion.delta_dir, text: `${s.conversion.delta_pp} п.п.` },
|
||
sub: 'vs предыдущий период',
|
||
hint: 'Доля полученных заявок, по которым прошла оплата.',
|
||
},
|
||
{
|
||
label: 'Активные проекты',
|
||
value: String(s.active_projects.active),
|
||
// Лимита по числу проектов нет (ограничение только по балансу/лидам) —
|
||
// показываем просто количество активных, без «/ N лимит тарифа».
|
||
unit: '',
|
||
delta: { dir: 'neutral', text: '' },
|
||
sub: '',
|
||
hint: 'Проекты, которые сейчас собирают заявки.',
|
||
},
|
||
];
|
||
balance.value = {
|
||
amount: formatRub(s.balance.amount_rub),
|
||
// F3: реальное число дней (полоса из RUNWAY_MAX сегментов заполняется
|
||
// естественно `i <= runwayDays`; раньше cap до 7 врал в тексте «хватит на N дней»).
|
||
runwayDays: s.balance.runway_days,
|
||
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;
|
||
avgLeadCost.value = s.avg_lead_cost_rub;
|
||
activeProjectsCount.value = s.active_projects.active;
|
||
leadsReceivedCount.value = s.leads_received.value;
|
||
loaded.value = true;
|
||
}
|
||
|
||
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"
|
||
:leads-today="leadsToday"
|
||
:leads-yesterday="leadsYesterday"
|
||
:avg-lead-cost-rub="avgLeadCost"
|
||
/>
|
||
|
||
<!-- Косяк 07: онбординг новичка — «с чего начать» вместо пустых нулей без подсказок. -->
|
||
<v-card
|
||
v-if="showOnboarding"
|
||
variant="tonal"
|
||
color="primary"
|
||
class="onboarding-card mt-4"
|
||
data-testid="dashboard-onboarding"
|
||
>
|
||
<v-card-text>
|
||
<div class="d-flex align-start justify-space-between ga-2">
|
||
<div>
|
||
<h2 class="text-h6 mb-1">Добро пожаловать в Лидерру!</h2>
|
||
<p class="text-medium-emphasis mb-2">
|
||
Чтобы пошли лиды — создайте первый проект и пополните баланс. Это пара минут.
|
||
</p>
|
||
</div>
|
||
<v-btn
|
||
icon="mdi-close"
|
||
size="small"
|
||
variant="text"
|
||
data-testid="onboarding-dismiss"
|
||
aria-label="Скрыть подсказку"
|
||
@click="dismissOnboarding"
|
||
/>
|
||
</div>
|
||
<ol class="onboarding-steps">
|
||
<li>Создайте первый проект — короткие реквизиты попросим прямо в окне.</li>
|
||
<li>Пополните баланс — лиды списываются по факту.</li>
|
||
<li>Готово: Лидерра поставит проект в сбор, лиды пойдут автоматически.</li>
|
||
</ol>
|
||
<div class="mt-3 d-flex flex-wrap ga-2">
|
||
<v-btn
|
||
color="primary"
|
||
variant="flat"
|
||
data-testid="onboarding-create-project"
|
||
@click="router.push('/projects')"
|
||
>
|
||
Создать первый проект
|
||
</v-btn>
|
||
<v-btn
|
||
color="primary"
|
||
variant="outlined"
|
||
data-testid="onboarding-topup"
|
||
@click="router.push('/billing')"
|
||
>
|
||
Пополнить баланс
|
||
</v-btn>
|
||
</div>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<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;
|
||
}
|
||
|
||
.onboarding-steps {
|
||
margin: 4px 0 0;
|
||
padding-left: 20px;
|
||
line-height: 1.7;
|
||
}
|
||
.onboarding-steps li {
|
||
margin-bottom: 2px;
|
||
}
|
||
</style>
|