Files
portal/app/resources/js/views/DashboardView.vue
T
Дмитрий 9ff19f3cc3 @
feat(bot): всеобъемлющая инструкция (36 статей) + 31 узкая экскурсия, которая открывает окна

Живой урок владельца 12.07.2026: «дубли чистятся в 2-х местах, а бот выдумал третье»,
«стыдно такого бота показывать», «видео показываем полное, а я спрашиваю про одно —
надо резать», «надо не только кнопку показывать, а и что за ней».

База знаний: 17 обзорных статей → 36 узких по темам. Портал обойдён целиком
(5 агентов): все экраны, окна, подсказки «(?)», тексты ошибок, внутренние правила.
Новое, чего бот не знал: дубли в ДВУХ местах (Поле и Предложения) + окно «источник
уже есть»; правило 18:00; почему приходит меньше лимита; оплата по счёту и акты;
списания; безопасность и 2FA; отчёты; массовые действия; регистрация.

Экскурсии: 15 → 31, каждая на свою тему (3–7 шагов). Раннер GuidedTour научен
шагу `open` — сам открывает окно/вкладку и подсвечивает то, что ВНУТРИ
(окно дублей, форму подбора, диалог пополнения, панель тарифов, карточку сделки).
99 новых якорей data-tour расставлено по всему порталу (4 агента).

Поиск: веса в tsvector (заголовок A / синонимы B / текст C) — иначе на «чистка
дублей» первой всплывала статья «Списания» из-за мимоходного упоминания «поля».

Тесты: Pest бот 39/39, Vitest 1222/1222. Новые сторожа: у каждой статьи есть
экскурсия; экскурсия не короче 3 и не длиннее 7 шагов; каждая цель экскурсии
реально существует в разметке; экскурсии про действия обязаны открывать окно.
Живая проверка в браузере: экскурсия про дубли сама открыла окно объединения
и перешла на второе место чистки.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
2026-07-12 12:56:22 +03:00

268 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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" data-tour="dashboard-kpi">
<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" data-tour="dashboard-activity">
<ActivityChart :points="activityPoints" :labels="activityLabels" :max="activityMax" />
</v-col>
<v-col cols="12" md="5" data-tour="dashboard-funnel">
<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>