refactor(frontend): Sprint 4 Phase B/3 — split 2 utility views (audit O-refactor-04 хвост)
ErrorView 320→178 (+ ErrorBrand 54 + ErrorIllustration 31 + ErrorActions 55 + ErrorMeta 102).
DashboardView 302→84 (+ DashboardPageHead 65 + DashboardKpiRow 97 + DashboardBalance 124).
State (config, errorCode в ErrorView; range/kpis/balance в DashboardView) остаётся
в parent ради единого route.meta-driven flow + future API-fetch'а (Phase B/1 паттерн).
DashboardPageHead использует Vue 3.5 defineModel<T>() для двусторонней привязки range.
Sub-components читают только props — без Pinia stores (mock-data flow).
Все sub-components <250 строк (acceptance threshold). Shell line counts: 178/84.
ЗАМЕЧАНИЕ по acceptance «0 components >300»: НЕ закрыто полностью. 2 файла остались
выше порога — DealsView 560 + DealDetailDrawer 386. Зафиксировано в Sprint 3 Phase C
commit 6c2f0ce: bulk-action функции (applyBulkStatus/applyBulkDelete/applyBulkExport/
undoBulkDelete/applyBulkRestoreFromTrash) и comment/reminders fetch экспонируются
через defineExpose в Vitest-тестах напрямую — дальнейшая декомпозиция требует
изменения тест-контракта (отдельным flow, не вошло в Sprint 4 Phase B).
Phase B/3 закрывает 8/12 audit-кандидатов O-refactor-04: 3 в Sprint 3 Phase C
(Top-3) + 5 в Sprint 4 Phase B/1+B/2 (admin/layout/billing/security/reminders) +
2 в B/3 (errors/dashboard). Оставшиеся: 2 крупных deals-view (defineExpose-blocked)
+ ImpersonationDialog уже <300 органически.
Регрессия: ESLint 0 + vue-tsc 0 + Vitest 416/416 + build OK 989 ms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DashboardBalance — карта баланса с runway-bar (segments по дням).
|
||||
* Класс `.runway-fill` сохранён — Vitest тест считает их количество.
|
||||
*
|
||||
* Sprint 4 Phase B/3 — split DashboardView (audit O-refactor-04 закрытие).
|
||||
*/
|
||||
export interface Balance {
|
||||
amount: string;
|
||||
runwayDays: number;
|
||||
runwayMax: number;
|
||||
runwayLeads: number;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
balance: Balance;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card variant="flat" color="secondary" class="balance-card pa-4">
|
||||
<div class="balance-row1">
|
||||
<span class="balance-label">Баланс</span>
|
||||
<v-chip size="x-small" color="primary" variant="elevated" class="ml-2">LIVE</v-chip>
|
||||
</div>
|
||||
<div class="balance-amount">
|
||||
<span class="num">{{ balance.amount }}</span>
|
||||
<span class="ru"> ₽</span>
|
||||
</div>
|
||||
<div class="runway mt-3">
|
||||
<div
|
||||
class="runway-bar"
|
||||
:aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`"
|
||||
>
|
||||
<span
|
||||
v-for="i in balance.runwayMax"
|
||||
:key="i"
|
||||
class="runway-fill"
|
||||
:class="{ filled: i <= balance.runwayDays }"
|
||||
/>
|
||||
</div>
|
||||
<div class="runway-foot text-caption">
|
||||
<span
|
||||
>≈ {{ balance.runwayLeads }} лидов · хватит на
|
||||
<strong>{{ balance.runwayDays }} дня</strong></span
|
||||
>
|
||||
<a href="/billing" class="ml-2 runway-action">пополнить →</a>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.balance-card {
|
||||
color: #fff;
|
||||
background: #012019 !important;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.balance-row1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.balance-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #7a8c87;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.balance-amount {
|
||||
margin-top: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.balance-amount .num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
color: #fff;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.balance-amount .ru {
|
||||
color: #7a8c87;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.runway-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.runway-fill {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.runway-fill.filled {
|
||||
background: #32c8a9;
|
||||
}
|
||||
|
||||
.runway-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
color: #7a8c87;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
.runway-foot strong {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.runway-action {
|
||||
color: #32c8a9;
|
||||
text-decoration: none;
|
||||
}
|
||||
.runway-action:hover {
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DashboardKpiRow — 3 KPI-карты (получено лидов / конверсия / активные проекты).
|
||||
* Numerics через JetBrains Mono с tabular-nums.
|
||||
*
|
||||
* Sprint 4 Phase B/3 — split DashboardView (audit O-refactor-04 закрытие).
|
||||
*/
|
||||
export interface Kpi {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
delta?: { dir: 'up' | 'down' | 'neutral'; text: string };
|
||||
sub: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
kpis: Kpi[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-col v-for="kpi in kpis" :key="kpi.label" cols="12" sm="6" md="3">
|
||||
<v-card variant="outlined" class="kpi-card pa-4">
|
||||
<div class="kpi-label text-body-2 text-medium-emphasis">{{ kpi.label }}</div>
|
||||
<div class="kpi-value">
|
||||
{{ kpi.value }}
|
||||
<span v-if="kpi.unit" class="kpi-unit">{{ kpi.unit }}</span>
|
||||
</div>
|
||||
<div class="kpi-foot text-caption text-medium-emphasis mt-2">
|
||||
<span v-if="kpi.delta?.dir === 'up'" class="delta-up">
|
||||
<v-icon size="14" color="success">mdi-arrow-up</v-icon>
|
||||
{{ kpi.delta.text }}
|
||||
</span>
|
||||
<span v-else-if="kpi.delta?.dir === 'down'" class="delta-down">
|
||||
<v-icon size="14" color="error">mdi-arrow-down</v-icon>
|
||||
{{ kpi.delta.text }}
|
||||
</span>
|
||||
<span v-else-if="kpi.delta" class="delta-neutral">{{ kpi.delta.text }}</span>
|
||||
<span class="ml-1">{{ kpi.sub }}</span>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kpi-card {
|
||||
background: #fff;
|
||||
border-color: #d9d5cd !important;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.kpi-card:hover {
|
||||
border-color: #66635c !important;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.kpi-unit {
|
||||
font-size: 18px;
|
||||
color: #66635c;
|
||||
font-weight: 500;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.kpi-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.delta-up,
|
||||
.delta-down,
|
||||
.delta-neutral {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.delta-up {
|
||||
color: #2e8b57;
|
||||
}
|
||||
.delta-down {
|
||||
color: #b83a3a;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DashboardPageHead — приветствие + meta-строка (новые лиды / средняя стоимость) +
|
||||
* range-toggle (Сегодня / 7 дней / 30 дней / Период…). Использует Vue 3.5
|
||||
* `defineModel` для двусторонней привязки range.
|
||||
*
|
||||
* Sprint 4 Phase B/3 — split DashboardView (audit O-refactor-04 закрытие).
|
||||
*/
|
||||
const range = defineModel<'today' | '7d' | '30d' | 'custom'>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1 class="text-h4 mb-2 page-greet">Доброе утро, <em class="text-primary">Иван</em></h1>
|
||||
<div class="page-meta text-body-2 text-medium-emphasis">
|
||||
<span><span class="num text-primary">+3</span> новых лида с утра</span>
|
||||
<span class="sep">·</span>
|
||||
<span>сегодня <span class="num">11</span> · вчера <span class="num">38</span></span>
|
||||
<span class="sep">·</span>
|
||||
<span>средняя стоимость <span class="num">2 248 ₽</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<v-btn-toggle v-model="range" mandatory color="primary" density="comfortable" variant="outlined">
|
||||
<v-btn value="today" size="small">Сегодня</v-btn>
|
||||
<v-btn value="7d" size="small">7 дней</v-btn>
|
||||
<v-btn value="30d" size="small">30 дней</v-btn>
|
||||
<v-btn value="custom" size="small">Период…</v-btn>
|
||||
</v-btn-toggle>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-greet {
|
||||
font-variation-settings: 'opsz' 28;
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
.page-greet em {
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.page-meta .sep {
|
||||
color: #92907b;
|
||||
}
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ErrorActions — две кнопки (primary + опциональная secondary) для ErrorView.
|
||||
* Принимает action-объекты как props; click на handler делегирует через
|
||||
* вызов action.onClick() (router-link / href обрабатывается Vuetify-биндингами).
|
||||
*
|
||||
* Sprint 4 Phase B/3 — split ErrorView (audit O-refactor-04 закрытие).
|
||||
*/
|
||||
interface ErrorAction {
|
||||
label: string;
|
||||
icon: string;
|
||||
to?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
primary: ErrorAction;
|
||||
secondary?: ErrorAction;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="err-actions">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:prepend-icon="primary.icon"
|
||||
:to="primary.to"
|
||||
@click="primary.onClick?.()"
|
||||
>
|
||||
{{ primary.label }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="secondary"
|
||||
variant="outlined"
|
||||
:prepend-icon="secondary.icon"
|
||||
:to="secondary.to"
|
||||
:href="secondary.href"
|
||||
@click="secondary.onClick?.()"
|
||||
>
|
||||
{{ secondary.label }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.err-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ErrorBrand — фирменный блок «Лидерра.» в шапке ErrorView.
|
||||
* Sprint 4 Phase B/3 — split ErrorView (audit O-refactor-04 закрытие).
|
||||
*
|
||||
* Класс `.top-brand` сохранён — Vitest тест selector'ом `.top-brand` рассчитывает на
|
||||
* корневой элемент сабкомпонента.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a class="top-brand" href="/" aria-label="На главную">
|
||||
<span class="mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 48" width="22" height="22">
|
||||
<path
|
||||
d="M16 14 L16 34 L32 34"
|
||||
stroke="#fff"
|
||||
stroke-width="4.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="32" cy="34" r="3.5" fill="#0F6E56" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand-text">Лидерра<span class="dot">.</span></span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.top-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
padding: 24px 32px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.top-brand .mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.top-brand .dot {
|
||||
color: #32c8a9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ErrorIllustration — большая JetBrains Mono цифра кода ошибки (404 / 403 / 500)
|
||||
* с акцентом teal на средней цифре. Sprint 4 Phase B/3 — split ErrorView
|
||||
* (audit O-refactor-04 закрытие).
|
||||
*/
|
||||
defineProps<{
|
||||
code: '404' | '403' | '500';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1 class="err-code">
|
||||
{{ code[0] }}<span class="accent">{{ code[1] }}</span>{{ code[2] }}
|
||||
</h1>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.err-code {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 96px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
letter-spacing: -0.04em;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.err-code .accent {
|
||||
color: #32c8a9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ErrorMeta — мета-блоки ErrorView: status-list (только для 500),
|
||||
* RequestId/IncidentId с кнопкой копирования, и помощь-mailto (только 404).
|
||||
*
|
||||
* Sprint 4 Phase B/3 — split ErrorView (audit O-refactor-04 закрытие).
|
||||
*/
|
||||
defineProps<{
|
||||
code: '404' | '403' | '500';
|
||||
showStatusList?: boolean;
|
||||
showRequestId?: boolean;
|
||||
requestIdLabel?: string;
|
||||
requestId?: string;
|
||||
}>();
|
||||
|
||||
const statusList = [
|
||||
{ name: 'API', status: 'ok' },
|
||||
{ name: 'Telegram', status: 'degraded' },
|
||||
{ name: 'YooKassa', status: 'ok' },
|
||||
];
|
||||
|
||||
async function copyRequestId(id: string | undefined) {
|
||||
if (id) {
|
||||
await navigator.clipboard.writeText(id);
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(s: string): string {
|
||||
return s === 'ok' ? '#2E8B57' : s === 'degraded' ? '#D9A441' : '#B83A3A';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showStatusList" class="status-list">
|
||||
<span v-for="s in statusList" :key="s.name" class="status-item">
|
||||
<span class="dot" :style="{ background: statusColor(s.status) }" />
|
||||
{{ s.name }} ·
|
||||
{{ s.status === 'ok' ? 'OK' : s.status === 'degraded' ? 'деградация' : 'недоступен' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="showRequestId" class="err-id">
|
||||
<span class="text-caption text-medium-emphasis">{{ requestIdLabel }}</span>
|
||||
<span class="request-id">{{ requestId }}</span>
|
||||
<v-btn
|
||||
icon="mdi-content-copy"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
:aria-label="`Скопировать ID ${requestIdLabel}`"
|
||||
@click="copyRequestId(requestId)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="code === '404'" class="err-help text-caption">
|
||||
Что-то не так? Напишите в
|
||||
<a href="mailto:support@liderra.app" class="text-primary">support@liderra.app</a>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-list {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.status-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #b1c2bd;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
.status-item .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.err-id {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.request-id {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.err-help {
|
||||
color: #7a8c87;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,26 +4,28 @@
|
||||
*
|
||||
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html.
|
||||
* MVP: page-head + 4 KPI-cards (получено лидов / конверсия / активные проекты /
|
||||
* баланс). Графики (Активность по дням, Воронка из 14 статусов) и блоки ниже
|
||||
* (Top deals, Recent activity) — отдельные коммиты.
|
||||
* баланс). Графики (Активность по дням, Воронка из 14 статусов).
|
||||
*
|
||||
* Все числа сейчас mock'и — TODO: GET /api/dashboard/summary с tenant-context'ом
|
||||
* по middleware SetTenantContext (фаза backend).
|
||||
*
|
||||
* Sprint 4 Phase B/3 — split на DashboardPageHead + DashboardKpiRow +
|
||||
* DashboardBalance (audit O-refactor-04 закрытие). State (range, kpis, balance)
|
||||
* остаётся в parent ради единого mock-data flow и future API-fetch'а.
|
||||
*
|
||||
* Примечание: «recent deals list» в Phase B/3 plan'е — на текущем дашборде нет
|
||||
* (есть только charts row); если будет добавлено в будущем — выносится в
|
||||
* DashboardRecentDeals.vue по аналогии.
|
||||
*/
|
||||
import { ref } 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';
|
||||
|
||||
const range = ref<'today' | '7d' | '30d' | 'custom'>('7d');
|
||||
|
||||
interface Kpi {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
delta?: { dir: 'up' | 'down' | 'neutral'; text: string };
|
||||
sub: string;
|
||||
}
|
||||
|
||||
const kpis: Kpi[] = [
|
||||
{
|
||||
label: 'Получено лидов',
|
||||
@@ -47,7 +49,7 @@ const kpis: Kpi[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const balance = {
|
||||
const balance: Balance = {
|
||||
amount: '14 250',
|
||||
runwayDays: 4,
|
||||
runwayMax: 7,
|
||||
@@ -57,80 +59,11 @@ const balance = {
|
||||
|
||||
<template>
|
||||
<v-container fluid class="dashboard pa-6">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1 class="text-h4 mb-2 page-greet">Доброе утро, <em class="text-primary">Иван</em></h1>
|
||||
<div class="page-meta text-body-2 text-medium-emphasis">
|
||||
<span><span class="num text-primary">+3</span> новых лида с утра</span>
|
||||
<span class="sep">·</span>
|
||||
<span>сегодня <span class="num">11</span> · вчера <span class="num">38</span></span>
|
||||
<span class="sep">·</span>
|
||||
<span>средняя стоимость <span class="num">2 248 ₽</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<v-btn-toggle v-model="range" mandatory color="primary" density="comfortable" variant="outlined">
|
||||
<v-btn value="today" size="small">Сегодня</v-btn>
|
||||
<v-btn value="7d" size="small">7 дней</v-btn>
|
||||
<v-btn value="30d" size="small">30 дней</v-btn>
|
||||
<v-btn value="custom" size="small">Период…</v-btn>
|
||||
</v-btn-toggle>
|
||||
</header>
|
||||
<DashboardPageHead v-model="range" />
|
||||
|
||||
<v-row dense class="kpi-row mt-4">
|
||||
<v-col v-for="kpi in kpis" :key="kpi.label" cols="12" sm="6" md="3">
|
||||
<v-card variant="outlined" class="kpi-card pa-4">
|
||||
<div class="kpi-label text-body-2 text-medium-emphasis">{{ kpi.label }}</div>
|
||||
<div class="kpi-value">
|
||||
{{ kpi.value }}
|
||||
<span v-if="kpi.unit" class="kpi-unit">{{ kpi.unit }}</span>
|
||||
</div>
|
||||
<div class="kpi-foot text-caption text-medium-emphasis mt-2">
|
||||
<span v-if="kpi.delta?.dir === 'up'" class="delta-up">
|
||||
<v-icon size="14" color="success">mdi-arrow-up</v-icon>
|
||||
{{ kpi.delta.text }}
|
||||
</span>
|
||||
<span v-else-if="kpi.delta?.dir === 'down'" class="delta-down">
|
||||
<v-icon size="14" color="error">mdi-arrow-down</v-icon>
|
||||
{{ kpi.delta.text }}
|
||||
</span>
|
||||
<span v-else-if="kpi.delta" class="delta-neutral">{{ kpi.delta.text }}</span>
|
||||
<span class="ml-1">{{ kpi.sub }}</span>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card variant="flat" color="secondary" class="balance-card pa-4">
|
||||
<div class="balance-row1">
|
||||
<span class="balance-label">Баланс</span>
|
||||
<v-chip size="x-small" color="primary" variant="elevated" class="ml-2">LIVE</v-chip>
|
||||
</div>
|
||||
<div class="balance-amount">
|
||||
<span class="num">{{ balance.amount }}</span>
|
||||
<span class="ru"> ₽</span>
|
||||
</div>
|
||||
<div class="runway mt-3">
|
||||
<div
|
||||
class="runway-bar"
|
||||
:aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`"
|
||||
>
|
||||
<span
|
||||
v-for="i in balance.runwayMax"
|
||||
:key="i"
|
||||
class="runway-fill"
|
||||
:class="{ filled: i <= balance.runwayDays }"
|
||||
/>
|
||||
</div>
|
||||
<div class="runway-foot text-caption">
|
||||
<span
|
||||
>≈ {{ balance.runwayLeads }} лидов · хватит на
|
||||
<strong>{{ balance.runwayDays }} дня</strong></span
|
||||
>
|
||||
<a href="/billing" class="ml-2 runway-action">пополнить →</a>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<DashboardKpiRow :kpis="kpis" />
|
||||
<DashboardBalance :balance="balance" />
|
||||
</v-row>
|
||||
|
||||
<v-row class="charts-row mt-4">
|
||||
@@ -148,155 +81,4 @@ const balance = {
|
||||
.dashboard {
|
||||
max-width: 1440px;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-greet {
|
||||
font-variation-settings: 'opsz' 28;
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
.page-greet em {
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.page-meta .sep {
|
||||
color: #92907b;
|
||||
}
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
background: #fff;
|
||||
border-color: #d9d5cd !important;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.kpi-card:hover {
|
||||
border-color: #66635c !important;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
color: #081319;
|
||||
}
|
||||
|
||||
.kpi-unit {
|
||||
font-size: 18px;
|
||||
color: #66635c;
|
||||
font-weight: 500;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.kpi-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.delta-up,
|
||||
.delta-down,
|
||||
.delta-neutral {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.delta-up {
|
||||
color: #2e8b57;
|
||||
}
|
||||
.delta-down {
|
||||
color: #b83a3a;
|
||||
}
|
||||
|
||||
.balance-card {
|
||||
color: #fff;
|
||||
background: #012019 !important;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.balance-row1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.balance-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #7a8c87;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.balance-amount {
|
||||
margin-top: 8px;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.balance-amount .num {
|
||||
color: #fff;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.balance-amount .ru {
|
||||
color: #7a8c87;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.runway-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.runway-fill {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.runway-fill.filled {
|
||||
background: #32c8a9;
|
||||
}
|
||||
|
||||
.runway-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
color: #7a8c87;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
.runway-foot strong {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.runway-action {
|
||||
color: #32c8a9;
|
||||
text-decoration: none;
|
||||
}
|
||||
.runway-action:hover {
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
* Layout: meta.layout='error' — AppShell рендерит ErrorView напрямую без
|
||||
* AppLayout/AuthLayout (full-bleed теало-нуар bg + центрированный card).
|
||||
*
|
||||
* Sprint 4 Phase B/3 — split на ErrorBrand + ErrorIllustration + ErrorActions
|
||||
* + ErrorMeta (audit O-refactor-04 закрытие). State (config, errorCode) — в parent
|
||||
* ради единого route.meta-driven flow.
|
||||
*
|
||||
* Не входит в этот коммит:
|
||||
* - Реальный requestId/incidentId из backend (сейчас mock).
|
||||
* - Status-pills из API /api/health (сейчас static на 500).
|
||||
@@ -15,13 +19,25 @@
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import ErrorBrand from '../../components/errors/ErrorBrand.vue';
|
||||
import ErrorIllustration from '../../components/errors/ErrorIllustration.vue';
|
||||
import ErrorActions from '../../components/errors/ErrorActions.vue';
|
||||
import ErrorMeta from '../../components/errors/ErrorMeta.vue';
|
||||
|
||||
interface ErrorAction {
|
||||
label: string;
|
||||
icon: string;
|
||||
to?: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface ErrorConfig {
|
||||
code: '404' | '403' | '500';
|
||||
title: string;
|
||||
description: string;
|
||||
primaryAction: { label: string; icon: string; to?: string; onClick?: () => void };
|
||||
secondaryAction?: { label: string; icon: string; to?: string; href?: string; onClick?: () => void };
|
||||
primaryAction: ErrorAction;
|
||||
secondaryAction?: ErrorAction;
|
||||
showRequestId?: boolean;
|
||||
requestIdLabel?: string;
|
||||
requestId?: string;
|
||||
@@ -97,98 +113,27 @@ const config = computed<ErrorConfig>(() => {
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const statusList = [
|
||||
{ name: 'API', status: 'ok' },
|
||||
{ name: 'Telegram', status: 'degraded' },
|
||||
{ name: 'YooKassa', status: 'ok' },
|
||||
];
|
||||
|
||||
async function copyRequestId() {
|
||||
if (config.value.requestId) {
|
||||
await navigator.clipboard.writeText(config.value.requestId);
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(s: string): string {
|
||||
return s === 'ok' ? '#2E8B57' : s === 'degraded' ? '#D9A441' : '#B83A3A';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app>
|
||||
<v-main class="error-main">
|
||||
<a class="top-brand" href="/" aria-label="На главную">
|
||||
<span class="mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 48" width="22" height="22">
|
||||
<path
|
||||
d="M16 14 L16 34 L32 34"
|
||||
stroke="#fff"
|
||||
stroke-width="4.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="32" cy="34" r="3.5" fill="#0F6E56" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand-text">Лидерра<span class="dot">.</span></span>
|
||||
</a>
|
||||
<ErrorBrand />
|
||||
|
||||
<div class="error-content">
|
||||
<h1 class="err-code">
|
||||
{{ config.code[0] }}<span class="accent">{{ config.code[1] }}</span
|
||||
>{{ config.code[2] }}
|
||||
</h1>
|
||||
<ErrorIllustration :code="config.code" />
|
||||
<h2 class="err-title">{{ config.title }}</h2>
|
||||
<p class="err-desc">{{ config.description }}</p>
|
||||
|
||||
<div class="err-actions">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:prepend-icon="config.primaryAction.icon"
|
||||
:to="config.primaryAction.to"
|
||||
@click="config.primaryAction.onClick?.()"
|
||||
>
|
||||
{{ config.primaryAction.label }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="config.secondaryAction"
|
||||
variant="outlined"
|
||||
:prepend-icon="config.secondaryAction.icon"
|
||||
:to="config.secondaryAction.to"
|
||||
:href="config.secondaryAction.href"
|
||||
@click="config.secondaryAction.onClick?.()"
|
||||
>
|
||||
{{ config.secondaryAction.label }}
|
||||
</v-btn>
|
||||
</div>
|
||||
<ErrorActions :primary="config.primaryAction" :secondary="config.secondaryAction" />
|
||||
|
||||
<div v-if="config.showStatusList" class="status-list">
|
||||
<span v-for="s in statusList" :key="s.name" class="status-item">
|
||||
<span class="dot" :style="{ background: statusColor(s.status) }" />
|
||||
{{ s.name }} ·
|
||||
{{ s.status === 'ok' ? 'OK' : s.status === 'degraded' ? 'деградация' : 'недоступен' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="config.showRequestId" class="err-id">
|
||||
<span class="text-caption text-medium-emphasis">{{ config.requestIdLabel }}</span>
|
||||
<span class="request-id">{{ config.requestId }}</span>
|
||||
<v-btn
|
||||
icon="mdi-content-copy"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
:aria-label="`Скопировать ID ${config.requestIdLabel}`"
|
||||
@click="copyRequestId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errorCode === '404'" class="err-help text-caption">
|
||||
Что-то не так? Напишите в
|
||||
<a href="mailto:support@liderra.app" class="text-primary">support@liderra.app</a>
|
||||
</p>
|
||||
<ErrorMeta
|
||||
:code="config.code"
|
||||
:show-status-list="config.showStatusList"
|
||||
:show-request-id="config.showRequestId"
|
||||
:request-id-label="config.requestIdLabel"
|
||||
:request-id="config.requestId"
|
||||
/>
|
||||
</div>
|
||||
</v-main>
|
||||
</v-app>
|
||||
@@ -203,30 +148,6 @@ function statusColor(s: string): string {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.top-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
padding: 24px 32px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.top-brand .mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 5px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.top-brand .dot {
|
||||
color: #32c8a9;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -239,19 +160,6 @@ function statusColor(s: string): string {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.err-code {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 96px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
letter-spacing: -0.04em;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.err-code .accent {
|
||||
color: #32c8a9;
|
||||
}
|
||||
|
||||
.err-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
@@ -267,54 +175,4 @@ function statusColor(s: string): string {
|
||||
font-size: 15px;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.err-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-list {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.status-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #b1c2bd;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
.status-item .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.err-id {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.request-id {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.err-help {
|
||||
color: #7a8c87;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user