Files
portal/app/resources/js/views/DashboardView.vue
T
Дмитрий 7ac9af7c79
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat: убрать лимит по числу проектов — ограничение только по балансу/лидам
Правило продукта: ограничений по количеству проектов нет, лимит только
по балансу и заказанным лидам. Убран гейт 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>
2026-06-27 12:47:49 +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">
<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>